Skip to content

Distribute Paddock via Homebrew as the always-on install path #673

Description

@edspencer

Goal

Distribute Paddock via Homebrew, as the permanent, always-on install path.

The two front doors are meant to feel different:

npx @edspencer/paddock brew install + brew services
intent ad hoc, try it, per-directory installed on the system, always up
lifetime foreground, Ctrl-C daemon, survives login/reboot
workspace --here opens the cwd one home instance
upgrade re-run npx brew upgrade

Nothing here is started yet — this issue is the design record and the implementation
plan. See "Decisions needed" at the bottom.


1. Own tap, not homebrew-core

homebrew-core gates new formulae on notability
(policy), and self-submission is the
strict tier
— 90 forks, 90 watchers, or 225 stars. Paddock today has 2 stars, 0 forks,
0 watchers. Not close, and not worth optimising for.

So: edspencer/homebrew-tapbrew install edspencer/tap/paddock.

Homebrew explicitly endorses this route ("software that does not meet the official
criteria can generally be maintained in a third-party tap"). brew upgrade,
brew services, and brew uninstall all behave identically. The only cost is a longer
install command. Revisit core if the stars ever arrive.

2. The formula

The current idiom for npm CLIs points
url at the registry tarball we already publish. Verified against the real
typescript and prettier formulae in homebrew-core — the install block is
byte-identical to theirs.

class Paddock < Formula
  desc "Persistent, web-based home for your Claude Code sessions"
  homepage "https://github.com/edspencer/paddock"
  url "https://registry.npmjs.org/@edspencer/paddock/-/paddock-0.59.1.tgz"
  sha256 "4e1edc66aee44e1114f1507e20344050365c629b528058d4fddc5ee68e2b91c0"
  license "MIT"

  depends_on "node"

  def install
    system "npm", "install", *std_npm_args
    bin.install_symlink libexec.glob("bin/*")
  end

  service do
    run [opt_bin/"paddock", "--port", "7373"]
    keep_alive true
    log_path var/"log/paddock.log"
    error_log_path var/"log/paddock.log"
    environment_variables PATH: std_service_path_env
  end

  test do
    assert_match version.to_s, shell_output("#{bin}/paddock --version")
  end
end

The sha256 above is real (computed from the live 0.59.1 tarball) but will need
regenerating per release — see §7.

The npx work (#637/#638/#640) paid for nearly all of this: because we already synthesize
a single self-contained package with a working bin, the formula is the boring
three-line install.

3. VERIFIED: ignore_scripts is safe for us

std_npm_args disables npm lifecycle scripts by default — Homebrew's deliberate
supply-chain hardening. Paddock needs a ~250 MB Claude SDK platform binary that arrives
via an optional dependency, so this was the main thing that could have made brew
impossible. Tested:

$ npm install --ignore-scripts @edspencer/paddock@0.59.1
added 320 packages in 11s

$ ls node_modules/@anthropic-ai/
claude-agent-sdk  claude-agent-sdk-linux-x64  sdk     # platform binary present

$ ./node_modules/.bin/paddock --version
0.59.1

$ du -sh node_modules
407M

It works. The platform binary is a plain optionalDependency with no postinstall, so
ignoring scripts costs nothing functional. npm resolves the right per-platform package on
each machine, so darwin-arm64 users get theirs.

One casualty: packages/server/scripts/install-notice.mjs (the "this is 250 MB"
preinstall warning) never fires under brew. That's acceptable — arguably better, since
brew install prints its own progress and the notice existed to explain npx's silence.
Worth a comment in make-npm-package.mjs so nobody later "fixes" the notice by making it
load-bearing.

4. Default port: move off 4000

4000 is a bad default. It's IANA-registered to terabase, and in practice it's Phoenix's
and Jekyll's default — a collision on a dev machine is likely, and it's much worse for an
always-on service than for a foreground process you can just Ctrl-C and rerun.

Proposed: 7373. Checked against the full IANA registry (6,350 genuinely-assigned
ports, parsed from the official CSV — filtering out Reserved/Unassigned placeholder
rows, which naive parsing counts as taken):

  • IANA-unassigned ✅
  • below 32768, so below the Linux ephemeral floor — a port inside the ephemeral range can
    already be held by a random outbound connection, causing intermittent bind failures ✅
  • no popular collision (only an obscure ATTO config tool) ✅
  • memorable ✅

Alternatives that also pass every check: 23232, 27272.
Rejected: 8123 (ClickHouse HTTP, Home Assistant), 7233 (Temporal), 11434 (Ollama),
26000 (Quake), 4747/5417/19191 (IANA-assigned).

Open question: change the default globally, or only in the service block? Globally is
cleaner and breaks bookmarks/reverse-proxy configs for existing users — but the user base
is tiny right now, so this is the cheapest it will ever be. Leaning global.

5. Service design — the actual work

It's launchd on macOS, not systemctl

brew services start paddock writes ~/Library/LaunchAgents/homebrew.mxcl.paddock.plist
on macOS. You get systemd/systemctl on Linux, where
brew services requires systemd.
Same command either way.

The daemon has no cwd — but the defaults already work

--here is per-directory and consent-gated by the flag. A daemon has no meaningful cwd.
Paddock already has the right answer built in: with no --here, dataDir defaults to
~/.paddock and projectsRoot falls out as ~/.paddock/projects
(packages/server/src/config.ts:774).

So the service block should set no PADDOCK_DATA_DIR. Then
brew services start paddock and a bare paddock in a terminal land on the same
instance — one home instance, two ways to reach it. Note this deliberately departs from
the Homebrew convention of var/paddock; for a per-user agent, HOME-relative is more
coherent and matches what npx users already have.

🔴 Credentials under launchd — the biggest unknown

On macOS, Claude Code stores credentials in the Keychain. A user-level launch agent
should reach the login keychain once unlocked, but this is untested and the failure
mode is nasty: Paddock boots fine, the UI loads, and every chat fails.

This must be tested on a real Mac before the tap is announced. If keychain access
doesn't work from a launch agent, options are environment_variables carrying
ANTHROPIC_API_KEY, or documenting claude setup-token + a token file. Note
warnIfNoCredentials (cli/paddock.ts) prints to stderr, which under launchd goes to
error_log_path where nobody looks — the service path may need a louder signal, e.g.
surfacing "no credentials" in the UI rather than only in a log.

🔴 An always-on unauthenticated agent is a different threat model

Auth defaults to none on a loopback bind, which is the right call for npx in a
terminal you Ctrl-C when you're done. A daemon that is up from login to shutdown, exposing
filesystem-wide agent capability on an unauthenticated local port, is a materially larger
surface: any local process — or any other user on a shared machine — can drive an agent
with your credentials.

The bind-safety guard (#435) still prevents non-loopback + auth: none, so this is not a
hole so much as a posture that should be chosen deliberately rather than inherited.

Proposal: service mode should default to token auth, with the token generated on
first service start and printed by brew services info paddock or written to
~/.paddock/service-token. Needs its own design pass — flagging as design-needed.

brew upgrade does not restart a running service

Users need brew services restart paddock. Must be a caveat in the formula (def caveats)
and in the docs, or people will run an old build for weeks and report fixed bugs.

6. No bottles → a real 400 MB install

For a personal tap nobody prebuilds binaries, so brew install runs an actual
npm install on the user's machine: ~400 MB, a minute or two. Acceptable; just be honest
about it in the docs. Building bottles in CI and attaching them to GitHub releases is
possible but not worth it until someone complains.

7. Keeping the formula current

Two options for bumping url + sha256 on each release:

  1. Push directly — add a step to release.yml after the provenance check that
    checks out the tap and commits the bump.
  2. brew bump-formula-pr — opens a PR against the tap instead.

Given it's our own tap and the release is already automated, (1) is simpler.

⚠️ Auth gotcha: our npm publish uses OIDC trusted publishing, which does not extend
to pushing to a second repo. This needs a PAT with repo scope on the tap, or a GitHub App
token. Don't assume the existing release auth covers it.

8. Prerequisite: the licence

Homebrew formulae need a license stanza that matches reality, and Paddock currently has
no LICENSE file and no license field anywhere. Tracked in #674 — that must land
before the tap is published.


Decisions needed

  • Default port: 7373 (proposed), 23232, or 27272?
  • Change the port default globally, or only in service mode?
  • Does service mode default to token auth? (needs design)
  • Create edspencer/homebrew-tap — a new public repo under the personal account

Implementation checklist (once decided)

  • Licence landed (blocking)
  • Default port changed + docs/tests updated
  • Create edspencer/homebrew-tap with Formula/paddock.rb
  • Test brew install on macOS and Linux
  • 🔴 Test brew services start on a real Mac — verify chats actually work (keychain)
  • def caveats covering: restart-after-upgrade, where data lives, how to reach the UI
  • Release automation to bump the formula, with a token that can push to the tap
  • Docs: install page covering npx vs brew, and when to pick which

Metadata

Metadata

Assignees

No one assigned

    Labels

    design-neededNeeds a design decisionfeatureNew featureinfraInfra/deploy/herdctl

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions