Ship devctl on Homebrew, with in-app updates and a real uninstall - #18
Merged
Conversation
There was a problem hiding this comment.
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.shprocess 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 -fwill treatdevctl.app's.as a wildcard. EscapingAPP'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.
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
force-pushed
the
fix/installer-path-and-one-copy
branch
from
August 9, 2026 17:56
ee0f530 to
815bdc1
Compare
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 upgradenow 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 upgradein 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 doctorreports 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 doctornow 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 uninstallis 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-onlyis what the cask runs on every upgrade, so it stays narrow and never touches hooks or data.devctl daemon uninstallstill works as a deprecated alias, warning on stderr so--jsonstdout stays clean. The Settings window offers the same, and a cask install is pointed atbrew 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, whichzsh -lalone 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
terminationHandleris set beforerun()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 thepgrep/pkillpatterns in the deep-link smoke script escape their dots.Releasing signed builds without gatekeeping contributors
make dmgnow 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 newscripts/smoke-cask.shnon-destructive tier pass. The cask install tier confirmed the Caskroom backlink and the CLI symlink against a realbrew install.AppInstancePolicystill has its red-green mutation tests; the one-copy behavior and PATH capture are unchanged.brew installresolve to a real DMG.