Skip to content

Ship devctl on Homebrew, with in-app updates and a real uninstall - #18

Merged
quantizor merged 7 commits into
mainfrom
fix/installer-path-and-one-copy
Aug 9, 2026
Merged

Ship devctl on Homebrew, with in-app updates and a real uninstall#18
quantizor merged 7 commits into
mainfrom
fix/installer-path-and-one-copy

Conversation

@quantizor

@quantizor quantizor commented Aug 9, 2026

Copy link
Copy Markdown
Owner

devctl installs from Homebrew, upgrades cleanly, tells you when a new version is out, and uninstalls without leaving dead entries behind. The two installer fixes this branch started with are still here, folded into the setup foundation. Commits are ordered so you can read them one at a time.

Homebrew

brew install --cask quantizor/tap/devctl. The tap (quantizor/homebrew-tap) is required, not a nicety: the official cask repo needs 225 stars and a 30-day-old project, and a tapless cask can never be upgraded (brew re-reads the definition it saved at install time, so the version always compares equal). brew upgrade now works, and the release pipeline keeps the cask current on its own: after the DMG is uploaded, a workflow injects the release's version and checksum into the tap.

Setup became Homebrew-aware. It detects whether devctl or brew owns the CLI by matching the running bundle against the Caskroom symlink by real path, not by looking for /Caskroom/ in a path (which is false for every normally-installed cask, since brew moves the app to /Applications and links back). Under a brew install it skips installing a second CLI copy and drops the PATH warning, because brew's bin is already on PATH.

Knowing about updates, and taking them

The menu bar popover shows a quiet row when a newer version exists. A Homebrew install gets a one-click Upgrade that runs brew upgrade in a Terminal window (a menu bar app has no usable PATH of its own, and a password prompt needs somewhere to go); a direct download gets a link to the release notes. devctl doctor reports the same thing from a shared cache, so a machine where the app never runs still learns about a release. The check fails silently and never enters the session context the agent hook injects.

Hooks you can manage after first run

A new Settings window, opened from the gear at the bottom of the popover, is the way back to anything skipped at first run: install or remove the Claude Code and Cursor session hooks per harness, toggle Start at login, and turn the update check on or off. devctl still edits a harness's settings only when you click, never on its own. devctl doctor now reports a harness whose hook is missing or points at a path that no longer exists, and names the command to fix it, but never fixes it for you.

Uninstall

devctl uninstall is the one removal verb: it unregisters the background agent, removes the agent hooks, and removes the CLI, keeping your data unless you pass --purge. Running servers keep going. --agent-only is what the cask runs on every upgrade, so it stays narrow and never touches hooks or data. devctl daemon uninstall still works as a deprecated alias, warning on stderr so --json stdout stays clean. The Settings window offers the same, and a cask install is pointed at brew uninstall --cask --zap.

The original installer fixes (still here)

One copy of the menu bar app: a launching copy stands down when the same bundle path is already running, path-scoped so the disk-image-to-Applications handoff (the one case where two copies are correct) is left alone. The installer's PATH check reads the login shell's real PATH (sourcing .zshrc, which zsh -l alone skips) instead of the app's launchd-given process PATH, so it no longer warns everyone about a PATH that was already fine, and servers devctl starts inherit the tools you installed.

Review feedback

Folds in the open Copilot review on this branch: the shell terminationHandler is set before run() so a child that exits early cannot make the timeout path wait out its ceiling and SIGKILL a reaped pid; the setup panel's PATH probe moved off the main thread so it never stalls launch; and the pgrep/pkill patterns in the deep-link smoke script escape their dots.

Releasing signed builds without gatekeeping contributors

make dmg now produces a signed test DMG for an external contributor (ad-hoc when they have no certificate, never a hard failure), and reserves the notarized real DMG for where the notary credentials live: this machine or CI. The release build fails rather than silently shipping an ad-hoc image Gatekeeper would disable.

Verification

  • make test (all suites), scripts/smoke.sh, and the new scripts/smoke-cask.sh non-destructive tier pass. The cask install tier confirmed the Caskroom backlink and the CLI symlink against a real brew install.
  • New unit tests cover CLIOwner resolution, hook install/uninstall round-trips against the real adapters, and the update-check cache.
  • AppInstancePolicy still has its red-green mutation tests; the one-copy behavior and PATH capture are unchanged.
  • The tap repo exists and is seeded; the first release makes brew install resolve to a real DMG.

Copilot AI lite review requested due to automatic review settings August 9, 2026 14:36

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves installation and launch behavior for devctl.app by (1) ensuring only one instance of the menu bar app runs per bundle path, and (2) making the installer’s PATH diagnostics reflect the user’s real shell PATH (not launchd’s minimal PATH). It also refreshes DMG layout text/geometry and tightens the deeplink smoke test cleanup so it only targets the built app under test.

Changes:

  • Add path-scoped “stand down if twin is running” policy and integrate it into app launch and installer handoff flows.
  • Fix PATH checking/remediation messaging to use a captured user-shell PATH, with new focused tests.
  • Update DMG window/background layout and harden scripts/smoke-deeplink.sh process cleanup and twin-instance assertion.

Reviewed changes

Copilot reviewed 15 out of 15 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
Tests/DevCtlKitTests/SetupPlannerTests.swift Adds tests pinning PATH-check behavior and remediation text.
Tests/DevCtlKitTests/AppInstancePolicyTests.swift New unit tests for path-scoped single-instance policy and tie-breaking.
Sources/DevCtlKit/Setup/SetupPlanner.swift Introduces pathRemedy and switches PATH checking to the captured user PATH.
Sources/DevCtlKit/Setup/AppInstancePolicy.swift New pure, testable policy for deciding whether a launching app instance should stand down.
Sources/DevCtlKit/Launchd/LaunchdAdmin.swift Enhances PATH capture (env isolation, .zshrc sourcing, timeout) and extends shell helper to support env/timeout.
Sources/DevCtlApp/SetupPerformer.swift Uses user PATH check and adds twin-stand-down logic plus stronger “quit peers” semantics.
Sources/DevCtlApp/SetupPanel.swift Updates UI text to use pathRemedy and blocks upgrade if peers cannot be quit.
Sources/DevCtlApp/DevCtlApp.swift Enforces stand-down early in applicationDidFinishLaunching.
scripts/smoke-deeplink.sh Makes cleanup path-scoped and adds an open -n twin-instance assertion.
scripts/make-dmg.sh Adjusts Finder window bounds and icon position for updated DMG layout.
scripts/make-dmg-background.swift Keeps background rendering aligned with DMG window geometry and revises copy/layout.
README.md Updates top-level product description/tagline.
AGENTS.md Updates tagline and keeps repo guidance consistent with new behaviors.
.changeset/one-copy-of-the-app.md Release note for single-instance / safer upgrade replacement behavior.
.changeset/honest-path-check.md Release note for PATH capture correctness and timeout behavior.
Suppressed comments (1)

scripts/smoke-deeplink.sh:107

  • Same regex issue here: pkill -f will treat devctl.app's . as a wildcard. Escaping APP's dots avoids terminating processes whose command line merely happens to match the regex.
# Path-scoped so an installed /Applications copy, which shares the executable
# name, is left alone: only the copy under test has to be gone for the next open
# to be a cold launch.
pkill -f "$APP/Contents/MacOS/devctl-app" 2>/dev/null || true
sleep 1

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread Sources/DevCtlApp/SetupPerformer.swift
Comment thread Sources/DevCtlApp/SetupPanel.swift
Comment thread scripts/smoke-deeplink.sh Outdated
Comment thread scripts/smoke-deeplink.sh Outdated
Comment thread Sources/DevCtlKit/Launchd/LaunchdAdmin.swift
The backlog carries release-ops detail and known-issue notes that do not
belong in a public repo. Gitignore it and drop it from the tree; earlier
copies remain in history.
Add CLIOwner, deciding whether devctl or Homebrew owns the CLI by realpath-
matching the running bundle against the Caskroom backlink rather than a
/Caskroom/ path substring, which is false for every normally-installed cask;
the PATH check becomes owner-aware so brew's bin never warrants a warning.
Carries the installer fixes this branch began with: the login-shell PATH
capture, and AppInstancePolicy deciding which of two copies of one bundle
stands down at launch. Resolve argv0 symlinks before deriving the daemon
sibling, and set the shell terminationHandler before run() so a child that
exits early cannot make the timeout path SIGKILL a reaped pid.
Compare the newest non-prerelease against the running version, cache the
result with an ETag so a repeat check is a cheap 304, and fail silently. The
app polls it and devctl doctor reads the same cache, refreshing only when
stale. The result never enters the session context the hook injects.
DevCtlDistribution is the one home for the tap token and the brew commands.
Add devctl uninstall (agent, hooks, and CLI; --agent-only for the cask,
--purge for data) as the single uninstall verb, with devctl daemon uninstall
kept as a deprecated alias that warns on stderr and leaves --json stdout
clean. Add devctl hook uninstall and give each harness adapter uninstall and
hookState. devctl doctor gains report-only harness-hook and update findings,
naming the fix and never editing a file the user owns. hook install takes
--devctl-path so a brew install records the stable shim path.
Add a Settings window (gear at the popover bottom) for per-harness hook
install and removal, Start at login, the update-check toggle, and Uninstall,
all through the CLI so the app never edits a harness's settings on its own. A
quiet popover footer row offers a one-click Upgrade that runs brew in a
Terminal login shell, since a GUI-launched app has no usable PATH. The setup
panel and its evaluation move off the main thread and become owner-aware,
dropping the CLI install line and the PATH warning under a Homebrew install.
Add packaging/homebrew/devctl.rb (the one home for the cask structure; its
uninstall unregisters only the agent, since cask uninstall runs on every
upgrade) and bump-homebrew-cask.yml, which injects a release's version and
checksum and pushes to the tap. Release DMG now kicks it after upload and
fails the build on an ad-hoc signature. make dmg produces a signed test DMG
for contributors and reserves the notarized path for where the notary
credentials live; make-app-bundle writes CFBundleVersion. Add smoke-cask.sh
and escape the pgrep/pkill patterns in smoke-deeplink.sh.
README leads with brew install and the uninstall path; CONTRIBUTING gains the
tap, its HOMEBREW_TAP_TOKEN secret, and the cask-uninstall-runs-on-upgrade
constraint; AGENTS.md maps the Settings window, update check, CLIOwner, and
smoke-cask, corrects the BTM notes (invalidation is xpcproxy-triggered on the
next spawn; the store is BackgroundItems-v16.btm), and notes BACKLOG.md is now
maintainer-local.
@quantizor
quantizor force-pushed the fix/installer-path-and-one-copy branch from ee0f530 to 815bdc1 Compare August 9, 2026 17:56
@quantizor quantizor changed the title Run one copy of the app, and get the installer's PATH right Ship devctl on Homebrew, with in-app updates and a real uninstall Aug 9, 2026
@quantizor
quantizor merged commit 0069434 into main Aug 9, 2026
4 checks passed
@quantizor
quantizor deleted the fix/installer-path-and-one-copy branch August 9, 2026 20:53
@github-actions github-actions Bot mentioned this pull request Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants