diff --git a/.changeset/homebrew-distribution.md b/.changeset/homebrew-distribution.md new file mode 100644 index 0000000..41b3b74 --- /dev/null +++ b/.changeset/homebrew-distribution.md @@ -0,0 +1,9 @@ +--- +"devctl": minor +--- + +devctl installs from Homebrew: `brew install --cask quantizor/tap/devctl`. `brew upgrade` keeps it current, and when a newer version ships the menu bar popover shows a quiet notice with a one-click Upgrade button that runs the upgrade in Terminal (a Homebrew install) or links to the release notes (a direct download). A direct DMG download still works exactly as before. + +A new Settings window, opened from the gear at the bottom of the popover, is the way back to anything you 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 only edits a harness's settings when you click; it never changes them on its own. + +Removing devctl is now a single command. `devctl uninstall` unregisters the background agent, removes the agent hooks, and removes the CLI, keeping your data unless you pass `--purge`; running servers keep going. The Settings window offers the same as a button. `devctl doctor` reports a harness whose hook is missing or points at a path that no longer exists, and names the command to fix it. diff --git a/.changeset/honest-path-check.md b/.changeset/honest-path-check.md new file mode 100644 index 0000000..5589576 --- /dev/null +++ b/.changeset/honest-path-check.md @@ -0,0 +1,9 @@ +--- +"devctl": patch +--- + +The installer no longer tells you to fix a PATH that is already fine. It asked the app's own process for its PATH, and the app is launched by Finder, so that was launchd's rather than your shell's: it can never contain `~/.local/bin`, so the warning appeared for everyone whatever their shell actually had. + +Asking a login shell was not enough either. `zsh -l` without `-i` runs `.zshenv`, `.zprofile` and `.zlogin` and skips `.zshrc`, which is where most tools put themselves. On the machine this was found, that meant devctl handed every server it started a PATH missing `~/.local/bin`, pnpm, conda and gcloud, so a server script calling `devctl` could not find it. Both the warning and the PATH your servers inherit now reflect what your shell really has. + +Reading that PATH means running your shell profile, which devctl does not control, so it now gives up after a while and falls back rather than waiting forever. A profile that waits on the network or on a terminal that is not there used to hang the app at launch with nothing on screen. `DEVCTL_RESOLVING_ENVIRONMENT` is set while it runs, so a profile can skip whatever needs a real session. diff --git a/.changeset/one-copy-of-the-app.md b/.changeset/one-copy-of-the-app.md new file mode 100644 index 0000000..9ae16df --- /dev/null +++ b/.changeset/one-copy-of-the-app.md @@ -0,0 +1,9 @@ +--- +"devctl": patch +--- + +Installing no longer leaves two menu bar apps running. A second copy of the same app doubles everything you see: two icons, two pollers, and two notifications for one crash. Nothing prevented it, and the install hands off by asking macOS for a new instance by name, so any second trigger produced one, whether that was the relaunch button racing the automatic handoff, a squatting copy that would not quit, or opening the app in Finder while it was already running. A copy that finds the same app already running now steps aside on its own, whichever way it was launched. + +The copy on the disk image and the copy in Applications still run side by side for the moment the handoff needs, since that pair is the one case where two is correct. + +Confirming an upgrade now stops rather than replacing the app in Applications while an old copy is still running it, and says which app to quit. It used to try for a few seconds, give up quietly, and replace the bundle anyway, leaving that copy running code no longer on disk. diff --git a/.github/workflows/bump-homebrew-cask.yml b/.github/workflows/bump-homebrew-cask.yml new file mode 100644 index 0000000..d42bb7e --- /dev/null +++ b/.github/workflows/bump-homebrew-cask.yml @@ -0,0 +1,117 @@ +# Updates the Homebrew tap cask after a release DMG is published. +# +# Kicked by "Release DMG" via workflow_dispatch once the DMG is uploaded (a +# GITHUB_TOKEN release does not fire release:published on other workflows, so the +# explicit dispatch is the real path; release:published stays as a manual / PAT +# fallback). Runs on ubuntu (no brew, no macOS minutes): it needs only the DMG's +# checksum and a git push. +# +# The cask's structure lives in devctl's packaging/homebrew/devctl.rb; this job +# only injects the release's version and sha256, so there is one home for the +# cask and the tap is a generated artifact. +# +# Requires a repository secret HOMEBREW_TAP_TOKEN: a fine-grained PAT with +# contents:write on quantizor/homebrew-tap (the default GITHUB_TOKEN cannot push +# across repos). A GitHub App token via actions/create-github-app-token is the +# equivalent alternative. Note dawidd6/action-homebrew-bump-formula is +# formula-only despite being the action usually linked for this. +name: Bump Homebrew cask + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to bump the cask to (e.g. v1.3.0)" + required: true + +permissions: + contents: read + +concurrency: + group: bump-homebrew-cask + cancel-in-progress: false + +jobs: + bump: + name: Update the tap cask + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Resolve the tag + id: tag + env: + TAG: ${{ github.event.release.tag_name || inputs.tag }} + run: | + set -euo pipefail + [[ -n "${TAG:-}" ]] || { echo "no tag provided" >&2; exit 1; } + echo "tag=$TAG" >> "$GITHUB_OUTPUT" + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + + - name: Checkout devctl at the tag + uses: actions/checkout@v4 + with: + ref: ${{ steps.tag.outputs.tag }} + path: devctl + + - name: Verify the DMG asset exists and download it + id: dmg + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.tag.outputs.tag }} + VERSION: ${{ steps.tag.outputs.version }} + run: | + set -euo pipefail + ASSET="devctl-${VERSION}.dmg" + # Fail fast if the DMG is not attached yet: the release DMG build must + # finish first, and shipping a cask whose URL 404s is worse than waiting. + if ! gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets \ + --jq '.assets[].name' | grep -qx "$ASSET"; then + echo "Release $TAG has no asset $ASSET yet; run this after the DMG build finishes." >&2 + exit 1 + fi + gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --pattern "$ASSET" --dir . + echo "sha=$(sha256sum "$ASSET" | awk '{print $1}')" >> "$GITHUB_OUTPUT" + + - name: Checkout the tap + uses: actions/checkout@v4 + with: + repository: quantizor/homebrew-tap + token: ${{ secrets.HOMEBREW_TAP_TOKEN }} + path: tap + + - name: Rewrite the cask from the template + env: + VERSION: ${{ steps.tag.outputs.version }} + SHA: ${{ steps.dmg.outputs.sha }} + run: | + set -euo pipefail + mkdir -p tap/Casks + # Ruby, not `sed -i`: sub! returns nil on no match, so a drifted + # template aborts the job, where sed would exit 0 on zero matches and + # ship a cask still pointing at the previous version and checksum. + ruby -e ' + version = ENV.fetch("VERSION") + sha = ENV.fetch("SHA") + text = File.read("devctl/packaging/homebrew/devctl.rb") + text.sub!(/version "[^"]*"/, %(version "#{version}")) or abort("version stanza not found in template") + text.sub!(/sha256 "[^"]*"/, %(sha256 "#{sha}")) or abort("sha256 stanza not found in template") + File.write("tap/Casks/devctl.rb", text) + ' + + - name: Commit and push + env: + VERSION: ${{ steps.tag.outputs.version }} + run: | + set -euo pipefail + cd tap + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + if git diff --quiet -- Casks/devctl.rb; then + echo "cask already at ${VERSION}; nothing to push" + exit 0 + fi + git add Casks/devctl.rb + git commit -m "devctl ${VERSION}" + git push diff --git a/.github/workflows/release-dmg.yml b/.github/workflows/release-dmg.yml index 9517d9a..4760a4d 100644 --- a/.github/workflows/release-dmg.yml +++ b/.github/workflows/release-dmg.yml @@ -29,6 +29,7 @@ concurrency: cancel-in-progress: false permissions: + actions: write contents: write jobs: @@ -93,6 +94,9 @@ jobs: APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} DEVCTL_DMG_QUARANTINE: "0" + # Fail the build if signing falls back to ad-hoc: an unsigned release + # would install through the cask and then be disabled by Gatekeeper. + DEVCTL_REQUIRE_SIGNING: "1" run: | set -euo pipefail : "${SIGN_IDENTITY:?set APPLE_SIGN_IDENTITY secret}" @@ -157,6 +161,18 @@ jobs: TAG="$(git describe --tags --abbrev=0)" fi gh release upload "$TAG" "$DMG" --clobber + echo "UPLOADED_TAG=$TAG" >> "$GITHUB_ENV" + + # Now that the DMG is attached, update the Homebrew tap cask. Same + # explicit-dispatch pattern Release uses to kick this workflow, since a + # GITHUB_TOKEN release does not fire release:published on the bump job. + - name: Kick Homebrew cask bump + if: steps.gate.outputs.publish == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + gh workflow run "Bump Homebrew cask" --ref main -f "tag=${UPLOADED_TAG}" # A dry run's whole point is inspecting the image, so hand it back. - name: Attach DMG to the run (dry run only) diff --git a/.gitignore b/.gitignore index 928287a..0a92563 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,6 @@ devctl.app/ dist/ *.corrupt-* .DS_Store +# Maintainer-local backlog: holds release-ops detail and known-issue notes that +# do not belong in a public repo. Tracked history still contains earlier copies. +/BACKLOG.md diff --git a/AGENTS.md b/AGENTS.md index e4de59f..f09b912 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # devctl -An agent-friendly coordinator for many devservers and their unique configurations. A macOS menu bar app and CLI over a launchd-supervised daemon, built so coding agents never lose track of running servers across compaction and session boundaries. All Swift, no Xcode. Design spec and phase plan: docs/design.md (the map for everything not yet built). CLI JSON contract: docs/cli-contract.md. Open work: BACKLOG.md. Commit and changeset hygiene: CONTRIBUTING.md. +An agent-friendly coordinator for many devservers and their unique configurations. A macOS menu bar app and CLI over a launchd-supervised daemon, built so coding agents never lose track of running servers across compaction and session boundaries. All Swift, no Xcode. Design spec and phase plan: docs/design.md (the map for everything not yet built). CLI JSON contract: docs/cli-contract.md. Open work: BACKLOG.md, a maintainer-local file that is gitignored (it carries release-ops and known-issue detail that does not belong in a public repo), so a fresh clone will not have it. Commit and changeset hygiene: CONTRIBUTING.md. Identity and stack - Swift 6.3 toolchain, swift-tools-version 6.2, Swift 6 language mode with strict concurrency. macOS 14+, single SPM package. @@ -9,21 +9,22 @@ Identity and stack - Three products, one daemon: devctld owns all server processes; devctl (CLI) and devctl.app (SwiftUI MenuBarExtra) are thin clients over a unix socket (default ~/Library/Application Support/devctl/daemon.sock; DEVCTL_SOCKET overrides; /tmp fallback near the sun_path limit), NDJSON protocol. devctl daemon install/uninstall/start/stop/restart manage the LaunchAgent (dev.quantizor.devctl); tests and the smoke gate run devctld --foreground. Codebase map -- Sources/DevCtlKit: shared core, the unit-test target of record. Models.swift (specs, phases, ServerStatus, whose displayPort is the one home for which of the three port fields a human is shown), Wire.swift (JSONCoding, typed request/response/event frames, NDJSON framing, stable error codes), Client/DaemonClient.swift (blocking-POSIX socket actor used unchanged by CLI and app), Paths/Paths.swift (path constants, canonical project path, atomic write + defensive load with per-call unique temp names so two writers in one process cannot rename each other's temp away, SHA-256 over CryptoKit with a chunked file-digest entry point so hashing a file costs a chunk of memory rather than the file; agent.path holds login-shell PATH for the daemon), Setup/SetupPlanner.swift (first-run / upgrade decisions, harness offers, stage-and-rename binary install), Agent/ (AgentContext: the pure session-context renderer the hook injects, bad-state servers first with a devctl why recommendation and devctl's own stderr count, never raw child output; DiscoveryStanza), Net/LoopbackProbe.swift (dual-stack loopback listen probe shared by the daemon port pre-check and CLI doctor), Net/PortClaim.swift + PortMaterializer.swift (effectivePort claim: portSpan and named ports; env injection and URL rewrite, including resolving a root-relative head or healthcheck url against the server's own base), Config/ (ProjectConfig loader and validator; ConfigProjection projects merged specs back down to devservers.json, dropping everything the machine derived, for `config init`; EffectiveHost is the one home for the host a spawn will use, read by both prepareSpawn and config check; LocalOverlay; LockResource reads locks declarations and resolves a resource's state path; WatchPolicy is the pure settle/quiet/burst decision behind auto-restart and WatchPaths resolves the entries config check warns about), Resource/ResourceIdentity.swift (bounded fingerprint of a lock resource's state so `lock` can report a change made under a live holder), Launchd/ (LaunchdAdmin: dual install path; SMAppService via app deep link when /Applications/devctl.app exists, else legacy home LaunchAgent + Application Support bin/devctld; --legacy forces the home path; DaemonRecoveryPolicy decides whether an unreachable daemon is auto-restarted; AgentRebindPolicy + agent.rebind settle the ad-hoc CDHash window on DMG replace), DeepLink/ (parse/serialize + DeepLinkRunner + notification action map), Log/DevCtlLog.swift (OSLog facade with a recording backend for tests). +- Sources/DevCtlKit: shared core, the unit-test target of record. Models.swift (specs, phases, ServerStatus, whose displayPort is the one home for which of the three port fields a human is shown), Wire.swift (JSONCoding, typed request/response/event frames, NDJSON framing, stable error codes), Client/DaemonClient.swift (blocking-POSIX socket actor used unchanged by CLI and app), Paths/Paths.swift (path constants, canonical project path, atomic write + defensive load with per-call unique temp names so two writers in one process cannot rename each other's temp away, SHA-256 over CryptoKit with a chunked file-digest entry point so hashing a file costs a chunk of memory rather than the file; agent.path holds login-shell PATH for the daemon), Setup/ (SetupPlanner: first-run / upgrade decisions, harness offers, stage-and-rename binary install, and CLIOwner: whether devctl or Homebrew owns the CLI, decided by realpath-matching the running bundle against the Caskroom backlink rather than any `/Caskroom/` substring, which drives skipping the binary install and the PATH warning under brew; AppInstancePolicy decides which of two copies of one bundle quits at launch, scoped to the bundle path so the DMG-to-Applications handoff, the one case where two copies are correct, is left alone), Agent/ (AgentContext: the pure session-context renderer the hook injects, bad-state servers first with a devctl why recommendation and devctl's own stderr count, never raw child output; DiscoveryStanza), Net/LoopbackProbe.swift (dual-stack loopback listen probe shared by the daemon port pre-check and CLI doctor), Net/PortClaim.swift + PortMaterializer.swift (effectivePort claim: portSpan and named ports; env injection and URL rewrite, including resolving a root-relative head or healthcheck url against the server's own base), Config/ (ProjectConfig loader and validator; ConfigProjection projects merged specs back down to devservers.json, dropping everything the machine derived, for `config init`; EffectiveHost is the one home for the host a spawn will use, read by both prepareSpawn and config check; LocalOverlay; LockResource reads locks declarations and resolves a resource's state path; WatchPolicy is the pure settle/quiet/burst decision behind auto-restart and WatchPaths resolves the entries config check warns about), Resource/ResourceIdentity.swift (bounded fingerprint of a lock resource's state so `lock` can report a change made under a live holder), Launchd/ (LaunchdAdmin: dual install path; SMAppService via app deep link when /Applications/devctl.app exists, else legacy home LaunchAgent + Application Support bin/devctld; --legacy forces the home path; DaemonRecoveryPolicy decides whether an unreachable daemon is auto-restarted; AgentRebindPolicy + agent.rebind settle the ad-hoc CDHash window on DMG replace), DeepLink/ (parse/serialize + DeepLinkRunner + notification action map), Update/ (UpdateCheck: GitHub releases/latest against DevCtlVersion, one on-disk cache with an ETag shared by the app poll and `devctl doctor`, every failure silent, and never fed into AgentContext.render; DevCtlDistribution: the one home for the tap token, releases URL, and brew upgrade/uninstall commands), Log/DevCtlLog.swift (OSLog facade with a recording backend for tests). - Sources/DevCtlDaemonCore: daemon logic as a library. Supervisor/ (ServerSupervisor actor per server: spawn, spool capture, health-gated phase machine, ensure/wait, group + descendant teardown with ProcessIdentity start-time revalidation; the ProcessLauncher seam; ProcessTree QA1123 sysctl sweep, plus narrowed/isAlive, the one home for turning a pid read off disk or the wire into one the kernel calls take, since a trapping conversion there is a crash loop under KeepAlive), Health/HealthProber.swift (EffectiveHealthcheck resolution, the HealthProber seam with ephemeral URLSession HTTP probes + BSD TCP, and PortGuard's lsof diagnostics, which live in that same file), Registry/ (owner of registry.json and state.json), Control/ (Router method dispatch + port pre-check + persisted resource locks with daemon-owned pause/resume and dead-holder auto-release + the boot-restore gate that answers daemon.info and refuses everything else with daemon-starting + NWListener ControlServer whose startAccepting awaits the listener's ready state and throws rather than suspending forever). - Sources/devctld: thin main; identical behavior under launchd and --foreground (tests and the smoke gate use foreground). Applies agent.path into process env before spawn, accepts on the socket before boot restore and marks the router restoring across it so a client can tell a busy daemon from a dead one, and runs the watch sweep on its own timer once restore has finished, so a boot spawn is never read as a config change. - Tests: DevCtlKitTests is the unit-test target of record and holds the schema goldens; DevCtlDaemonCoreTests drives a real Router over temp paths; DevCtlCLITests covers CLI behavior with a contract and no other way to exercise it (argument parsing, the lock notices and identity verdict), importing the executable target with @testable. TestSupport.swift is the one home for the fixture-server lookup and reserves ports 45000 to 45500 for the unit suites; touching it reaps fixtures orphaned by an interrupted run, but only those whose parent is gone and whose port is in that block, so a concurrent test run and smoke.sh (which orphans a fixture on purpose, outside the block) are both left alone. -- Sources/devctl: CLI (swift-argument-parser). Two files only: HookSupport.swift (HookContext, the thin socket fetch over DevCtlKit's AgentContext renderer, + HarnessAdapter registry; adding a harness: CONTRIBUTING.md) and CLI.swift, which holds every command as a struct, including Switch (branch switching + lifecycle playbooks), Lock (run-under-resource-lock), Doctor (health report; owns the cross-project port-collision and squatter findings), and Link / x-url (deep links). CLI.swift is past the size where splitting is worth asking about. -- Sources/DevCtlApp: menu bar app (DaemonModel 2s-polling model + crash notifications with Open/Why actions; AgentService wraps SMAppService.agent for Login Items registration, escalating to unregister + register when the agent reads `enabled` but the socket stays silent (a bootout or replaced bundle leaves the registration intact with nothing loaded) and reporting `requiresApproval` as its own failure instead of retrying; unregister records the stop intent so the recovery poll does not undo it; an unreachable daemon self-recovers via AgentService under DaemonRecoveryPolicy, falling back to legacy LaunchdAdmin only when the bundle carries no agent plist; DaemonDownRow offers Start, or Open Login Items while approval is pending, since a deliberate `daemon stop` stands down auto recovery; PresenceLabel is AppKit-drawn colored tally dots only with renderingMode(.original); popover autogrows to a cap; nested head rows with UserDefaults-persisted pins; DashboardView logs/timeline/config tabs; SpotlightIndexer named Core Spotlight index; AppDeepLink handles `devctl://` including `daemon/ensure` and `daemon/unregister`; SetupPanel first-run / upgrade installer from bundle Resources). Pure DaemonClient consumer. +- Sources/devctl: CLI (swift-argument-parser). Two files only: HookSupport.swift (HookContext, the thin socket fetch over DevCtlKit's AgentContext renderer, + HarnessAdapter registry, each adapter with install/uninstall/hookState over a settings file devctl does not own and never edits without being asked; adding a harness: CONTRIBUTING.md) and CLI.swift, which holds every command as a struct, including Switch (branch switching + lifecycle playbooks), Lock (run-under-resource-lock), Doctor (health report; owns the cross-project port-collision and squatter findings, plus report-only harness-hook and update findings), Uninstall (the one uninstall verb: agent, hooks, and CLI, with --agent-only for the cask and --purge for data; `daemon uninstall` is a deprecated alias warning on stderr), HookInstall/HookUninstall, and Link / x-url (deep links). CLI.swift is past the size where splitting is worth asking about. +- Sources/DevCtlApp: menu bar app (DaemonModel 2s-polling model + crash notifications with Open/Why actions; AgentService wraps SMAppService.agent for Login Items registration, escalating to unregister + register when the agent reads `enabled` but the socket stays silent (a bootout or replaced bundle leaves the registration intact with nothing loaded) and reporting `requiresApproval` as its own failure instead of retrying; unregister records the stop intent so the recovery poll does not undo it; an unreachable daemon self-recovers via AgentService under DaemonRecoveryPolicy, falling back to legacy LaunchdAdmin only when the bundle carries no agent plist; DaemonDownRow offers Start, or Open Login Items while approval is pending, since a deliberate `daemon stop` stands down auto recovery; PresenceLabel is AppKit-drawn colored tally dots only with renderingMode(.original); popover autogrows to a cap; nested head rows with UserDefaults-persisted pins; DashboardView logs/timeline/config tabs; SpotlightIndexer named Core Spotlight index; AppDeepLink handles `devctl://` including `daemon/ensure` and `daemon/unregister`; SetupPanel first-run / upgrade installer from bundle Resources, owner-aware so a Homebrew install drops the CLI-install line and the PATH warning; SetupPerformer owns everything about other copies of the app, including canonicalPath as the one home for bundle-path comparison, the AppInstancePolicy stand-down the delegate runs before anything else at launch, and a quitOtherInstances whose false answer refuses the replace rather than overwriting a bundle a live process is running; SettingsView is the Settings window (gear at the popover bottom): per-harness hook install/remove through the CLI, Start-at-login, and the update-check toggle, plus Uninstall (self-trash for a DMG install, the brew command for a cask); UpdateFooterRow is the popover's update banner, wired to DaemonModel's slow update poll, with a brew Upgrade button via TerminalRunner; TerminalRunner runs a brew command in a Terminal login shell because a GUI-launched app has no PATH). Pure DaemonClient consumer. - Sources/fixture-server: test double dev server (heartbeat printer; TCP-listen, timed-exit, grandchild, ignore-sigterm, binary, flood modes; see its header comment). Commands - make build: swift build -c release (all products) - make test: swift test; budget under 30s, the run prints the live timing - scripts/smoke.sh: the end-to-end gate. Debug-builds, boots a real devctld on a temp socket, then asserts register/start/status, spool capture, health/ensure/wait, port conflicts, marks/events/why, config recovery (`config init` round-tripping through `config check`, its refusal to clobber, `register --write`), relative-head resolution and its `config check` rejection, resource locks (pause + refused ensure + resume, option parsing, a contended acquire naming the holder, and the identity check firing under `--no-pause` while staying silent otherwise), restart (a new process, and a refusal under a live lock that leaves the server up), watch (a changed file restarts the server and the restarted process reads the new value, while a server declaring no watch is untouched), a command racing boot restore (it waits the window out rather than reporting the daemon gone, and the assertion says whether it caught the window), whole-group death on stop, child survival across a daemon kill, `link`/`x-url` deep-link dispatch, and that the assembled app declares `CFBundleURLSchemes=devctl`, ships AppIcon + CLI + daemon in Resources, and ships Helpers/devctld plus the in-bundle LaunchAgents BundleProgram plist. Run it after touching the supervisor, wire protocol, CLI, or deep links. -- scripts/smoke-deeplink.sh: Launch Services E2E for `devctl://` (warm + cold `open`). Requires a GUI session; run before merging URL-scheme work. OSLog scrape is strict on a tty (`DEVCTL_OSLOG_STRICT=1` forces it). -- scripts/smoke-launchd.sh: the REAL LaunchAgent lifecycle via `daemon install --legacy` (install, restart bounce + re-ensure, install-upgrade bounce + re-ensure, deliberate-stop intent, auto-bootstrap resurrection, uninstall). Mutates the user launchd domain; refuses to run if a home plist or bootstrapped job already exists; leaves nothing behind. SMAppService is exercised by installing from the app on a GUI session. -- make app: assembles fat devctl.app via scripts/make-app-bundle.sh (CLI in Contents/Resources; signed Helpers/devctld + Contents/Library/LaunchAgents BundleProgram plist for SMAppService; AppIcon.icns; declares the `devctl://` URL scheme). Signing identity comes from scripts/signing-identity.sh: a Developer ID certificate when the keychain has one, else ad-hoc with a warning. `SIGN_IDENTITY=...` overrides, including `SIGN_IDENTITY=-` to force ad-hoc. make dmg: UDZO image via scripts/make-dmg.sh, holding the app alone (no /Applications symlink: the app installs itself after an in-app confirm) over a background rendered by scripts/make-dmg-background.swift that says to double-click. Finder window layout needs a GUI session and a volume name that is not already mounted; on a headless runner that pass is skipped and the image still ships. A Developer ID image is notarized, stapled and stamped with `com.apple.quarantine` inline, so a local double-click is what a user who downloaded it gets (the stamp propagates to the app copied off the image, and does not survive an upload). `SKIP_NOTARIZE=1` trades that fidelity for a faster loop; `DEVCTL_DMG_QUARANTINE=0` drops the stamp. scripts/notarize.sh holds the notarytool + staple step and still runs standalone; it authenticates through the `devctl-notary` keychain profile locally, App Store Connect API key env vars in CI. On Actions, `Release DMG` publishes only on a `release` event or a dispatch from main (the ref `Release` uses); dispatched from any other branch it builds, signs, notarizes and verifies, then returns the image as a run artifact instead of uploading, so the release path is testable without clobbering a published asset: `gh workflow run "Release DMG" --ref [-f tag=vX.Y.Z]`. make install: CLI + daemon to ~/.local/bin, app to /Applications, daemon install. +- scripts/smoke-deeplink.sh: Launch Services E2E for `devctl://` (warm + cold `open`), and the only gate that covers the launch stand-down, since a second copy of one bundle can only be produced by really launching one (`open -n`, then assert one copy survives). Requires a GUI session; run before merging URL-scheme or app-launch work. Kills only the copy under its own build path, so an installed /Applications app is left running. OSLog scrape is strict on a tty (`DEVCTL_OSLOG_STRICT=1` forces it). +- scripts/smoke-launchd.sh: the REAL LaunchAgent lifecycle via `daemon install --legacy` (install, restart bounce + re-ensure, install-upgrade bounce + re-ensure, deliberate-stop intent, auto-bootstrap resurrection, the deprecated `daemon uninstall` alias warning on stderr with clean stdout, and `uninstall --agent-only`). Mutates the user launchd domain; refuses to run if a home plist or bootstrapped job already exists; leaves nothing behind. SMAppService is exercised by installing from the app on a GUI session. +- scripts/smoke-cask.sh: the Homebrew cask gate. Default tier is non-destructive: a throwaway local tap via `brew tap-new --no-git`, the cask rewritten to a local file:// DMG with its real checksum, then style/audit/info/dry-run/fetch, then untap. `DEVCTL_CASK_DESTRUCTIVE=1 scripts/smoke-cask.sh --install` runs a real `brew install --cask` into a temp `--appdir` and asserts the Caskroom backlink resolves to the app and the CLI symlink lands in brew's bin; its uninstall runs the real `devctl uninstall --agent-only`, so it quits and unregisters a running app's agent (recoverable). The strict `--new-cask` audit needs a notarized image and runs in CI, not here. +- make app: assembles fat devctl.app via scripts/make-app-bundle.sh (CLI in Contents/Resources; signed Helpers/devctld + Contents/Library/LaunchAgents BundleProgram plist for SMAppService; AppIcon.icns; declares the `devctl://` URL scheme; writes both CFBundleShortVersionString and CFBundleVersion). Signing identity comes from scripts/signing-identity.sh: a Developer ID certificate when the keychain has one, else ad-hoc with a warning. `SIGN_IDENTITY=...` overrides, including `SIGN_IDENTITY=-` to force ad-hoc. `DEVCTL_REQUIRE_SIGNING=1` (set by the release build) fails rather than falling back to ad-hoc, enforced in make-app-bundle.sh because the Makefile's `$(shell ...)` swallows signing-identity.sh's exit code. make dmg: UDZO image via scripts/make-dmg.sh, holding the app alone (no /Applications symlink: the app installs itself after an in-app confirm) over a background rendered by scripts/make-dmg-background.swift that says to double-click. Finder window layout needs a GUI session and a volume name that is not already mounted; on a headless runner that pass is skipped and the image still ships. Two kinds of image: a TEST DMG (signed with whatever identity, ad-hoc when none, not notarized) is what a contributor gets and is produced whenever notarytool credentials are absent; a REAL DMG (notarized, stapled, `com.apple.quarantine`-stamped) is built where the credentials live (the `devctl-notary` keychain profile locally, App Store Connect API key env in CI). make dmg notarizes automatically when credentials are reachable; `DEVCTL_NOTARIZE=1` (implied by `DEVCTL_REQUIRE_SIGNING=1`) demands the real path and fails if they are missing; `SKIP_NOTARIZE=1` forces the fast loop; `DEVCTL_DMG_QUARANTINE=0` drops the stamp. scripts/notarize.sh holds the notarytool + staple step and still runs standalone. On Actions, `Release DMG` publishes only on a `release` event or a dispatch from main (the ref `Release` uses); dispatched from any other branch it builds, signs, notarizes and verifies, then returns the image as a run artifact instead of uploading, so the release path is testable without clobbering a published asset: `gh workflow run "Release DMG" --ref [-f tag=vX.Y.Z]`. After uploading, it dispatches `Bump Homebrew cask`, which injects the release's version + sha256 into `packaging/homebrew/devctl.rb` (the one home for the cask's structure) and pushes to the `quantizor/homebrew-tap` repo. make install: CLI + daemon to ~/.local/bin, app to /Applications, daemon install. - Unified logging: subsystem `dev.quantizor.devctl` (categories daemon, supervisor, health, app, deeplink). Stream with `log stream --predicate 'subsystem == "dev.quantizor.devctl"' --level debug`. Child stdout/stderr stay in spool files; OSLog is for devctl's own behavior. - Deep links: `devctl://open|ensure|stop|why//[/]` (query form also accepted). `devctl://daemon/ensure` and `devctl://daemon/unregister` ask the app to own SMAppService registration. `devctl link` prints; the app handles via Launch Services; `devctl x-url` runs the same runner for smoke. @@ -48,9 +49,9 @@ Engineering rules - Comments are block comments (/** ... */) so they surface as hover docs; they explain the current code's non-obvious decisions and never narrate edits or history. - Fields in type declarations, initializers, and literals are alphabetized; keep a non-alphabetical order only where the sequence is load-bearing and say why in a comment. Persisted names (JSON keys, file names) are plain English a non-engineer would read. - Error messages assume the reader knows nothing of internals: what happened, where, and the exact fix; wire errors carry a stable code and a hint that is the literal command to run. -- Prose (docs, commits, PRs): American English, no em-dashes (use a colon, comma, or period), plain language over jargon, no attribution footers anywhere. PR and changeset descriptions lead with user-facing impact, not mechanism. +- Prose (docs, commits, PRs): American English, no em-dashes (use a colon, comma, or period), plain language over jargon, no attribution footers anywhere. PR and changeset descriptions lead with user-facing impact, not mechanism. "devservers" as one word is deliberate wherever it appears in the project's own description, echoing the devservers.json a user edits; do not correct it to two words. Elsewhere in prose, "dev server" is two words. - Git: never git stash; use temp commits. A dependency change updates Package.resolved in the same commit and is verified by a build. Commit messages, Changesets (when to write one), and release tagging live in CONTRIBUTING.md; agents never bump versions or publish. -- Docs carry no measured drift-prone numbers (timings, counts, coverage); state the budget and the command that prints the live figure. Docs track current state, never completions or history. BACKLOG.md is the only backlog, holds open work only, and the change that resolves an entry removes it. +- Docs carry no measured drift-prone numbers (timings, counts, coverage); state the budget and the command that prints the live figure. Docs track current state, never completions or history. BACKLOG.md is the only backlog, holds open work only, and the change that resolves an entry removes it; it is gitignored and maintainer-local, so keep it off the public tree. - Performance: hot paths (spool tailer, log query) get temporary microbenchmarks before an approach is chosen; a governor (throttle, cap) is never the fix for a cost problem, make the work itself cheaper. - No database, no migrations; on-disk compatibility is the defensive-load rule above. - Before calling work done: hunt the input that breaks it, run make test and scripts/smoke.sh, and separate what you observed (file:line, command output) from what you inferred. @@ -60,7 +61,7 @@ Stack notes (verified 2026; re-verify before building on them) - launchd, for the launchd phase: jobs get a minimal PATH without Homebrew (the design captures the user's shell PATH at install). ThrottleInterval defaults to 10s between respawns. ExitTimeOut (SIGTERM to SIGKILL) defaults to 20s per launchd.plist(5); a sequential drain of many servers at 7s grace each can exceed it, so set it deliberately. 60 is the ceiling: launchd clamps anything larger and logs "ExitTimeOut is larger than the maximum allowed". - SMAppService.Status is enabled = 1 and requiresApproval = 2, easy to misread from a raw value in a log. `enabled` means a registration exists, not that the job is loaded: after `launchctl bootout` or a replaced bundle the status still reads enabled while nothing runs, and `register()` on an already-registered service is a no-op, so the only way back is unregister + register. Expect launchd to log "Unknown key for plist importer (key: SHA256 type: data)" on every SMAppService submit; that key is Apple's, not ours. - The BTM launch constraint pins the Team ID when one is present, and a Team ID survives a rebuild, so a Developer ID signed upgrade spawns immediately. Ad-hoc has no Team ID, so the constraint pins the CDHash, which every rebuild changes: that is the whole reason the rebind path below exists. `make app` and `make dmg` pick a Developer ID identity automatically, so this only bites a build made where the keychain has none. -- Replacing `Contents/Helpers/devctld` under an ad-hoc signature while the BTM item still exists binds the item to the old CDHash, and the next spawn dies with `SIGKILL (Code Signature Invalid)` / Launch Constraint Violation, recorded as a `devctld-*.ips` in `~/Library/Logs/DiagnosticReports`. Unregister does NOT clear this: `sfltool dumpbtm` and the `registerLaunchItem: found existing item` log line both show the same item UUID across unregister+register, so re-registering only re-arms the same doomed constraint and burns another 10s ThrottleInterval per attempt. Only BTM's own `invalidateLaunchItem` clears the constraint, on its own schedule, and the item UUID changes when it does. Waiting longer for a spawn cannot help, because the job is killed on exec rather than running slowly. Diagnose an upgrade that comes up slowly by reading the BTM item UUID and the crash report, never by timing alone. +- Replacing `Contents/Helpers/devctld` under an ad-hoc signature while the BTM item still exists binds the item to the old CDHash, and the next spawn dies with `SIGKILL (Code Signature Invalid)` / Launch Constraint Violation, recorded as a `devctld-*.ips` in `~/Library/Logs/DiagnosticReports`. Unregister does NOT clear this: `sfltool dumpbtm` (the store is `BackgroundItems-v16.btm` on macOS 26; `sfltool` needs neither sudo nor Full Disk Access) and the `registerLaunchItem: found existing item` log line both show the same item UUID across unregister+register, so re-registering only re-arms the same doomed constraint and burns another 10s ThrottleInterval per attempt. The constraint clears only when BTM invalidates the item, which xpcproxy triggers on the next spawn attempt rather than on a timed background sweep, and the item UUID changes when it does. Waiting longer for a spawn cannot help, because the job is killed on exec rather than running slowly. Diagnose an upgrade that comes up slowly by reading the BTM item UUID and the crash report, never by timing alone. - DMG upgrades write `agent.rebind` and refuse to replace if the agent is still loaded. `AgentRebindPolicy` bounds how long the app waits before escalating; those waits only cover a job that is genuinely still spawning, not a constraint kill. - DMG and `/Applications/devctl.app` share a bundle id: never register the SMAppService agent from the volume copy, and never treat `openApplication` of Applications as success unless a different pid is actually running from that path (`createsNewApplicationInstance` plus a peer wait). Deep links for daemon control use `open -a /Applications/devctl.app` so the volume copy cannot steal them. User agents live in domain gui/, never system, and launchctl list/print answer differently depending on the calling context. diff --git a/BACKLOG.md b/BACKLOG.md deleted file mode 100644 index 279b2af..0000000 --- a/BACKLOG.md +++ /dev/null @@ -1,53 +0,0 @@ -# devctl backlog - -Open work only; entries are removed by the change that resolves them. - -- Orphan re-adoption without the bounce: after a daemon crash, re-adopt live orphan servers (pid + start-time match, resume spool tailing) instead of group-kill + restart. Blocked on: exit codes are unknowable for non-children; needs a design for degraded forensics. -- Reverse proxy on :80/:443 routing by host signature, making ports disappear from `*.localhost` URLs (Valet/Herd territory). Ephemeral `worktree-*..localhost` hosts are the unprivileged addressing half; the proxy would drop ports from URLs entirely. -- MenuBarExtraAccess (orchetect) if `.window` presentation quirks bite in practice. -- A Homebrew tap for the CLI. -- `Release DMG` has never executed. The secrets are in place and a dry run is dispatchable from any non-main branch, so exercise that before relying on the first real release to prove the path. -- The Developer ID certificate in `APPLE_DEVELOPER_ID_P12_BASE64` expires 2027-02-01. After that every release fails at the signing step until a fresh `.p12` is exported and the secret replaced. -- App Intents / Shortcuts wrappers over the existing `DeepLink` verbs (`open`, `ensure`, `stop`, `why`) for Siri / Gemini-Siri and Control Center. The `devctl://` URL table and `DeepLinkRunner` are the shared surface; intents should call the runner, not reimplement dispatch. Also the next plausible path for Spotlight ranking (IndexedEntity) once Core Spotlight levers are exhausted. -- swift-subprocess 0.5 occasionally fatals in its kqueue AsyncIO cleanup at process exit ("Failed to close kqueue fds: Bad file descriptor"), seen once under parallel test load; harmless to the long-lived daemon but track against upstream releases (pinned revision in Package.swift). -- Lock-release false `crashed` (2026-07-25, a pnpm monorepo: healthy then exit 0 in ~230ms): unreproducible on fixture rapid acquire/release (N=20), on the grandchild fixture, and on live `lock` cycles against a real project (2026-07-28). Resume now settles until the PortClaim is free or refuses to spawn dirty. Reopen with a failing repro that names exiting pid vs resumed pid before changing the exit classifier. -- Spotlight thumbnails: confirm config icons render in the real Spotlight UI. Within-app ranking levers are maxed (lastUsed preserved across sync, incremental index updates, live/pinned rankingHint, alternateNames). Outranking filesystem / Cursor Top Hits remains an Apple ceiling; do not chase without a new system API. -- Machine-wide resource lock opt-in: locks are already path-scoped (`canonicalPath::resource`) and pause only that path's declarers. A rare shared system resource (one Docker Postgres, a fixed system daemon) may still want an explicit machine-wide scope so two projects serialize. Not the Cloudflare worktree pause case (that was misread against path-scoped keys); defer until a concrete cross-project shared-resource incident. -- A `stop` interleaved with concurrent `ensure` calls on the same server kills the calling process outright. Reproduction: against a real Router, ensure a server healthy, then run six tasks at once where one issues `server.stop` and the rest issue `server.ensure`. The process dies with no output, no crash report, and no assertion failure. Established so far: removing only the stop from that group makes the identical test pass, and it is not SIGTERM, because a handler installed in the test never fires, which points at SIGKILL or a hard abort. Both unrevalidated group signals are worth suspecting first, since `stop` sends `kill(-pid, SIGTERM)` without revalidation and `recordOutcome` can run for a previous run whose `pid` field a concurrent `start` has already replaced. Serious if it reproduces in the daemon rather than only in-process, since there it would mean devctld signalling its own group. -- Split `CLI.swift`: every command struct lives in that one file, which is past the size where splitting is worth asking about. A structure decision, not drift; the codebase map describes the current layout. -- Field-level config editing in the dashboard would preserve `devservers.json` formatting instead of normalizing writes. `devctl config init` writes indented JSON with sorted keys, so a recovered file and a dashboard-saved file now agree on shape, but a hand-authored one with its own ordering or comments still normalizes on save. -- Unit-suite ports are hand-assigned literals inside the reserved 45000 to 45500 block, so adding a test means reading the others to find a free number and two suites can silently pick the same one. Leftovers from an interrupted run are now reaped on entry, which was the failure that actually bit; allocating from the block through one helper would close the collision half too. -- A directory lock resource still stops hashing contents past a byte budget, so a change confined to a file beyond it is missed while `exact: false` says the walk was partial. Raising or removing the budget is a cost decision that wants a measurement of real project state directories first, since the walk runs twice per guarded command; a single file has no such cap and is hashed whole. -- No end-to-end guard on the boot-restore ordering. The smoke gate asserts the invariant (a command racing restore succeeds and never reports the daemon unreachable) and prints whether it actually caught the window, but it would still pass if the listener went back to starting after restore, because waiting for the socket file to appear masks the difference. A discriminating version needs restore to take long enough to time, and it does not: `recoverAtStartup` returns once spawns have settled rather than once servers are healthy, so even a server that takes three seconds to bind leaves restore at roughly a tenth of a second. Reopen with a way to make restore reliably slow that is not a test-only knob in the daemon. -- The identity check flags the risk window, not the damage: the flush that corrupts can land after the guarded command exits and the second capture is taken. Catching that would need the daemon to watch the resource across the whole hold, or to compare again once the paused set has resumed. -- Trust is recorded but never enforced. `recordTrustIfNeeded` only writes the flag, and no start-shaped path reads it: `recoverAtStartup` resolves specs through `mergedSpecs` and spawns a committed command for a project whose `trusted` flag is false. `sweepWatches` is the only caller of `registry.isTrusted` on an execution path and its own comment calls the check belt and braces. Deleting `isTrusted` entirely leaves `swift test` green, and every `RecoverAtStartupTests` case calls `setTrusted` in setup, so nothing can tell the requirement from its absence. `notTrusted` is a wire code no daemon code raises. Needs one gate every spawn path consults, plus a test that boots an untrusted project's config and asserts nothing spawns. -- `project.writeConfig` takes the project path raw: no `canonicalProjectPath`, no registry membership check, no trust check, and `AtomicFile.write` creates intermediate directories. A wire client can therefore drop a devservers.json at any path, and the raw key also desynchronizes `configCache`, which `loadConfig` keys canonically. -- Deliberate `stop()` has two of teardown's three halves. The session sweep added for the crash path is guarded by `if !stopRequested`, so a worker that left the process group and whose intermediate parent already exited survives `devctl stop`: the pre-signal snapshot is a live parent-chain walk that no longer reaches it, and the post-grace sweep runs after the root is gone. `lastDescendantSnapshot`, which would hold it, is not consulted by `stop()` either. Wants one `liveDescendants(rootPid:sessionID:snapshot:)` both paths call, so the answer to "how do we find escaped descendants" has one home. -- The crash teardown signals `-rootPid` and a whole session after `waitpid` has already reaped that pid, with `revalidate: false`. Every other signalling site in the repo revalidates against `ProcessIdentity` start time. A recycled pid therefore takes SIGTERM, and the session sweep widens the blast radius from one group to every process in that session. This is the shape the concurrent-stop entry above suspects. -- `daemon install` and `daemon restart` re-ensure inside the restore window and swallow it. `pollHello` asks `daemon.info`, which the restore gate deliberately answers, so readiness now means "accepting" rather than "serving"; `reensure` then sends `server.ensure` per server through a bare `DaemonClient` with `_ = try?`, so each `daemon-starting` refusal is dropped while the CLI reports the servers as bounced and back. Only `CLIRunner.awaitingRestore` learned to wait. Wants one readiness concept: a `pollHello` mode that polls `DaemonInfo.restoring` until it is absent. -- `groupUp` runs no `lockGate`, so `devctl up` starts a server declaring a held resource straight onto the locked state while `ensure` and `start` refuse it. The gate belongs in `prepareSpawn`, which every start-shaped path already funnels through, with an explicit bypass for lock resume rather than a fourth call site the next verb forgets. -- `restart --all` walks servers alphabetically and ignores `dependsOn`, while `up` and `down` resolve `DependencyGraph.waves`. A dependent can come back against a dependency that is about to be torn down. A throw part way also discards the `EnsureResult`s for servers already restarted, so the client cannot tell what moved. -- A `--grep` pattern is compiled and run with Swift's backtracking engine against every log line inside the `ServerSupervisor` actor, and `grepRejection` screens only for compilability. `^(a+)+$` against 18 characters takes seconds; a longer line is unbounded. The blocked actor is the supervisor, so health probes, `ensure`, `status` and `stop` for that server queue behind it, and `DaemonClient` sets no receive timeout, so the client blocks with it. Wants a match budget or a task-specific loop. -- `DaemonClient` does a blocking `read(2)` with no `SO_RCVTIMEO` and no deadline, so a wedged daemon hangs `devctl` with no output and no timeout, and in the app burns a cooperative-pool thread per stuck request. -- `AtomicFile.loadDefensively` returns the same nil for a missing file and a failed read, and `Registry.init` substitutes an empty store, which the next write persists over the real one. EMFILE is plausible here, since the daemon raises maxfiles precisely because it approaches the soft limit. A parse failure quarantines; a read failure should refuse to start rather than erase. -- A `.failed` that arrives after `startAccepting` has already resumed is only logged: `claim()` returns false, so nothing throws and nothing exits, leaving a live process serving nothing that KeepAlive will not relaunch because it has not exited. The never-resumes half of this entry is closed (there is now a deadline that throws naming the stuck listener state), but the post-ready half needs a different mechanism, since by then there is no continuation left to fail. -- `WatchPolicy` measures every window with wall-clock `Date`, alone in a codebase that uses `ContinuousClock` for stop grace and claim waits and clamps log timestamps monotonically. A backward clock step makes every past restart count as in-window, suspending the watch until an explicit restart, and stalls the quiet window; a forward step skips the settle window. -- An agent stuck at `requiresApproval` is retried every 15s and each retry force-opens System Settings, because `DaemonRecoveryPolicy.decide` never sees that state. `daemonNeedsApproval` also latches: `refresh` clears the stop-intent flag on a good poll but not this one, so once set it survives for the process lifetime and later renders an approval dead end for an already-approved agent. -- Vacuous checks that would not catch their own regression: `WatchTests.theKillSwitchStopsTheSweepEntirely` writes the watched file before any sweep arms a baseline, so it passes with the kill switch removed (its sibling `aWriteInsideTheSettleWindowDoesNotRestart` is the control proving it); `smoke-launchd.sh` gates on `daemon status | grep -q "pid"`, which matches launchd's own line while the daemon half says not responding; `smoke.sh`'s restore-window grep runs only against a payload the preceding check already proved successful, and discards the stderr line that is the only proof the wait happened; `HealthTests` asserts `recentLogTail != nil || lastExit != nil` one line after proving `lastExit` non-nil; `WorktreeCoexistenceTests` admits `observedPort == nil`; `LockParsingTests` checks the help contains `--`, which every option satisfies. -- The unit-suite reaper's reserved block is 45000 to 45500, but `ResourceLockTests` draws from 41000 and 42000, so a failure there leaks a fixture and a `sleep 1000` grandchild that nothing reaps, and `TestSupportTests` pins that gap as deliberate. `smoke.sh`'s exit trap goes the other way and `pkill -f` every fixture at the shared build path, killing a concurrent `swift test`'s children. -- `EventStore.query` decodes every line of `events.log` and its rotation before filtering by `since`, and `DaemonModel.surfaceCrashNotifications` polls it every 2s for the life of the app. Measured on a 5 MB / 40k-line file: 146ms whole-file against 2.0ms for a windowed read, same answer both ways. `post()` already clamps timestamps monotonically, so the file carries the invariant `LogQuery.firstLineIndex` and `lastLineTimestamp` search on; the fix is one shared windowed reader rather than a new mechanism. -- `LogQuery.run` reads and line-parses every file in the family even for `tail: 40` (measured 240ms whole-file against 1.7ms windowed on a 10 MB file, plus ~0.7us of ISO-8601 parse per line), and `markDate` calls it with neither `since` nor `tail`, which `eventsQuery --since-mark` then loops over every spec in the project. `lastLineTimestamp` already holds the windowed-read technique. -- `PortGuard` shells out to `/usr/sbin/lsof` and `/bin/ps` (measured 48.8ms per pair against a 2.3ms bare-subprocess control, so the cost is lsof's scan rather than launch), and `annotateLatentPortConflict` calls it synchronously inside the `Router` actor once per stopped server on every machine-wide status, which the app drives every 2s. Every other daemon request serializes behind it. The forward lookup maps onto `proc_pidfdinfo` (`libproc.h`, `PROC_PIDLISTFDS`/`PROC_PIDFDSOCKETINFO`) with no subprocess; the reverse `listenerPids(port:)` needs a full pid scan and may honestly stay a shell-out. Getting the blocking call off the actor is the larger half. -- `ServerStatus.recentLogTail` and `terminalEvidence` are the same fact assigned together at all four write sites, but only `terminalEvidence` is persisted, so after a daemon restart a crashed server has no `recentLogTail` and `status()` re-runs `spoolTail()` on every call, which the 2s machine-wide poll then repeats forever. `terminalEvidence` is also the more trustworthy of the two, since `recentLogTail` re-reads a spool an intervening `ensure` may have truncated. Collapsing them is a CLI JSON contract change (both are named in docs/cli-contract.md). -- `serverID` is minted in one place and split by hand at ten, two of them with `options: .backwards` and the rest forward, so a project path or server name containing `::` resolves one way in `recoverAtStartup` and the other in `why` and the watch sweep. Nothing rejects `::` in a server name. Wants a `ServerID` type whose `Codable` encodes to the flat string, so persisted keys are unchanged and no migration is needed, plus a config-validation clause making the ambiguity unauthorable. -- `ServerPhase` carries no derived predicates, so "holds its port", "needs attention" and "can start" are re-spelled at eight, five and two sites respectively, and the membership has already drifted: `prepareSpawn` treats `.stopping` as up while `targetOwnsPort` and `managedHolder` treat it as holding no port, and the menu bar's attention set omits the stale-spec and port-conflict cases that `AgentContext.isBadState` includes, so the popover dot stays dark for a server the agent context flags. Computed properties on `ServerPhase` in DevCtlKit would make each deviation visible instead of invisible. A concrete instance, raised by Copilot on PR #16 and confirmed by reading: `targetOwnsPort` returns true for a `.starting` server on its declared or effective port, and the caller then skips the `PortGuard.isListening` squatter check entirely, so an unmanaged listener is masked while the server is still starting and has bound nothing. `restartServers` runs that pre-check with `force: true` before it stops anything, which is exactly where an actionable `port-held` refusal turns into a spawn failure after the server is already down. `.starting` cannot be dropped from the set, because the comment above the call explains it is what stops a server that just won the single flight from reporting its own port as held: the phase means both "may own this port" and "may not have bound yet", and only `observedPort` separates them. Wants that distinction named before the membership is changed. -- `server.register` writes a spec straight to the registry without running `HealthCheckSpec.validationErrors()` or `PortClaim.configErrors`, which are called only from `ProjectConfigLoader.validate`. So one of the two ways a spec enters the daemon is checked and the other is not, and `config check` can call a project clean that `register` already poisoned. Wants a `ServerSpec.validationErrors()` on the type called from both entry points; note that `register` would start rejecting specs it currently accepts. -- `canonicalProjectPath` is a per-arm convention in `Router.handle` rather than a property of the decoded frame, and five arms omit it: `projectWriteConfig`, `logsQuery`, `logsMark`, `eventsQuery`, `serverWhy`. `EventStore.query` compares project strings raw, so a non-canonical spelling returns an empty feed silently, and `serverWhy` reads a log path that does not exist and answers with findings and no evidence. Not reachable from any shipped client today (the CLI, the app and deep links all canonicalize first), but the invariant is held by convention at fourteen sites and has already drifted at five. Normalizing once at the decode seam would also close the raw-path hole in `project.writeConfig` above. -- `doctor` re-derives machine-wide truth the daemon already computed and shipped: the squatter finding re-probes each port with fresh connect syscalls while `ServerStatus.portConflict`, populated by `annotateLatentPortConflict` for every status, goes unread by both the CLI and the app. Three answers to one question, and the menu bar never shows a conflict the daemon has already diagnosed. Wants a `daemon.doctor` wire method returning findings, with the CLI command becoming render-only. -- `switch` is the client-side stop-then-start that `restart`'s own doc comment condemns, over a longer window: `groupDown`, then `git switch`, then every `lifecycle["switch"]` argv to completion, then `groupUp`, with nothing holding the project across the gap. Another session's `ensure`, or the daemon's own watch sweep firing on the files the branch change rewrote, can start a server mid-install against a half-switched tree. `lifecycle` is also the one config field `validate` never touches, and `Switch` reaches it by discarding the validated view and re-decoding the raw file, so a project whose config the daemon would refuse still gets its committed argv executed. -- The app's 2s poll does avoidable work on the main actor: `SpotlightIndexer.sync` JSON-decodes the previously indexed entry map out of UserDefaults and rebuilds a sorted signature string before the guard that decides whether anything changed; `DaemonModel` and `DashboardView` construct a fresh `DaemonClient` (socket, connect, hello handshake) per call at seven sites rather than holding one; `LogsPane` re-requests with `tail: 400` and no `since` every second, which is the whole-file path in `LogQuery`, where `devctl logs --follow` already tracks `lastAt` and passes it. The LogsPane fix changes the pane from replacing to appending, so it wants rendering and viewing rather than landing blind. -- `SpoolTailer` opens, seeks and closes each spool ten times a second per stream per server, though its header comment claims one stat when idle. The rename hazard that correctly rules out an fd watcher for the config sweep does not apply, since the spool is opened once with `O_TRUNC` and never renamed, so `DispatchSource.makeFileSystemObjectSource` with `.extend` fits and `drain()` is already offset-based and idempotent. Keep a low-frequency backstop drain so a missed event cannot strand bytes. -- `ServerSupervisor.wait(for:timeoutSeconds:)` polls its own actor state every 100ms for a transition that same actor performs, putting a 100ms latency floor under every `ensure`, while `spawnWaiters`/`settleSpawnWaiters` one screen away is the right pattern. `phase` is assigned at thirteen sites; routing them through one `setPhase(_:)` that resumes phase waiters makes "a phase change without notifying waiters" unrepresentable and is the seam the poll stands in for. -- Two duplicated blocks worth collapsing before either side drifts: `ServerSupervisor.recordPortOwnership` and `recordObservedPort` each carry the same eighteen-line terminal-failure sequence (conflict, phase, spawnError, errorSummary, tail, evidence, cancel health, log, post event, and a registry write of the same four fields), so a new field recorded on failure has to be added twice; and `Router`'s `serverEnsure` and `serverStart` arms repeat the same seven-line resolve-canonicalize-trust-lockGate-prepareSpawn preamble, which is the path that enforces trust recording and the resource-lock gate before any spawn, so a gate added to one arm and not the other silently reopens the hole. -- The coverage gate cannot fail, so it certifies nothing. AGENTS.md states branch coverage stays above 80% on DevCtlKit and DevCtlDaemonCore and names `swift test --enable-code-coverage` as the command that prints the live figure. That command prints no figure, and the Swift toolchain emits no branch data at all: every row of `llvm-cov report`'s Branches column is zero, so the threshold is unmeasurable rather than merely unmeasured. Line coverage is available and currently reads 80.1% for DevCtlKit and 73.9% for DevCtlDaemonCore. Wants a decision on which metric the project actually gates on, then a script that prints it and exits nonzero below the line, since a standard nothing computes is one nobody can regress. -- Test-fixture setup has no home in two of the three targets: the scratch-temp-directory pair is hand-written in 22 files across `DevCtlDaemonCoreTests` and `DevCtlKitTests`, and "poll the supervisor until it reaches a phase" exists twice in one target under two names, two shapes and two sleep intervals (`HealthTests.pollPhase`, `SupervisorTests.waitForPhase`). `DevCtlKitTests` has no `TestSupport.swift` at all. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4aa2b8c..2bbf4bd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -19,8 +19,22 @@ A separate macOS workflow (`.github/workflows/release-dmg.yml`) builds a Develop - `APPLE_SIGN_IDENTITY`: exact codesign identity string (for example `Developer ID Application: Name (TEAMID)`) - `APPLE_API_KEY_BASE64` / `APPLE_API_KEY_ID` / `APPLE_API_ISSUER`: App Store Connect API key for `notarytool` +The release DMG build runs with `DEVCTL_REQUIRE_SIGNING=1`, so a runner missing the certificate fails the build rather than shipping an ad-hoc image Gatekeeper would disable. + Local path: `SIGN_IDENTITY="Developer ID Application: …" make dmg` then `scripts/notarize.sh`, then `gh release upload vX.Y.Z dist/devctl-X.Y.Z.dmg`. +## Homebrew tap + +devctl is distributed as a cask through the self-owned tap `quantizor/homebrew-tap` (installed as `brew install --cask quantizor/tap/devctl`). A tap is required rather than optional: the official `homebrew/cask` needs 225 stars and a 30-day-old repo, and a tapless cask can never be upgraded (brew re-reads the definition saved at install time, so the version always compares equal). + +The cask's structure has one home: `packaging/homebrew/devctl.rb` in this repo. The release workflow `.github/workflows/bump-homebrew-cask.yml` reads that template, injects the published release's `version` and `sha256`, and pushes the result to the tap. `Release DMG` dispatches it after the DMG is uploaded (the same explicit-dispatch pattern the Release job uses for the DMG). One additional repository secret: + +- `HOMEBREW_TAP_TOKEN`: a fine-grained PAT with `contents: write` on `quantizor/homebrew-tap` (the default `GITHUB_TOKEN` cannot push across repos). A GitHub App token via `actions/create-github-app-token` is the equivalent alternative. + +Anything in the cask's `uninstall` runs on every `brew upgrade`, not only on uninstall, so it is limited to unregistering the background agent (the app re-registers it when brew relaunches). It must never remove hooks or data: nothing restores those automatically. Full removal is `devctl uninstall`. + +`scripts/smoke-cask.sh` is the cask gate. Its default tier is non-destructive (a throwaway local tap via `brew tap-new --no-git`, a file:// DMG, style/audit/dry-run/fetch); `DEVCTL_CASK_DESTRUCTIVE=1 scripts/smoke-cask.sh --install` runs a real install into a temp `--appdir` and asserts the Caskroom backlink and the CLI symlink. + ## Adding an agent-harness adapter The session-context payload is harness-agnostic: `devctl context` prints a fenced plain-text block describing the current project's servers, and `devctl statusline` prints a one-line presence summary from statusline stdin JSON. Wiring those into a harness is the only per-harness work. diff --git a/README.md b/README.md index 00fe588..8107d41 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,15 @@ Agents forget their dev servers. After a context compaction they spawn duplicate ## Quick start -Download the latest DMG from [GitHub Releases](https://github.com/quantizor/devctl/releases) and double-click `devctl` inside it. Nothing changes until you confirm: the setup panel lists what it will do, then moves the app to Applications, installs the CLI and daemon (migrating any older `make install` copy), and offers agent hooks for Claude Code and Cursor (checked by default when needed). +Install with Homebrew: + +```sh +brew install --cask quantizor/tap/devctl +``` + +The fully qualified `quantizor/tap/devctl` trusts only this cask. `brew upgrade` keeps it current, and the in-app footer offers a one-click upgrade when a new version ships. To remove it later: `devctl uninstall` (or `brew uninstall --cask --zap quantizor/tap/devctl`). + +Prefer a direct download? Grab the latest DMG from [GitHub Releases](https://github.com/quantizor/devctl/releases) and double-click `devctl` inside it. Either way nothing changes until you confirm: the setup panel lists what it will do, then moves the app to Applications, installs the CLI and daemon (migrating any older `make install` copy), and offers agent hooks for Claude Code and Cursor (checked by default when needed). Or build from source (your agent can do this for you): diff --git a/Sources/DevCtlApp/DaemonModel.swift b/Sources/DevCtlApp/DaemonModel.swift index 6ad4ea1..4f42ddb 100644 --- a/Sources/DevCtlApp/DaemonModel.swift +++ b/Sources/DevCtlApp/DaemonModel.swift @@ -102,8 +102,12 @@ final class DaemonModel { Start instead of resurrecting the daemon behind the user's back. */ var daemonStoppedOnPurpose = false var projects: [ProjectGroup] = [] + /** Newest release when one is available, for the popover footer. Nil when up + to date, unchecked, or the check is switched off in Settings. */ + var updateStatus: UpdateStatus? private var lastRecoveryAttempt: Date? + private var updateTask: Task? struct ProjectGroup: Identifiable { var id: String { path } @@ -208,6 +212,26 @@ final class DaemonModel { try? await Task.sleep(for: .seconds(2)) } } + /** Update polling on its own slow cadence, honoring the Settings toggle. + The app owns the cadence; doctor reads the same cache. */ + updateTask = Task { [weak self] in + while !Task.isCancelled { + await self?.checkForUpdates() + try? await Task.sleep(for: .seconds(UpdateCheck.defaultMaxAge)) + } + } + } + + /** Refresh the update cache when the preference allows, publishing the result + for the popover footer. Clears the banner when the check is off, so turning + it off in Settings takes effect without a relaunch. */ + func checkForUpdates() async { + guard UpdatePreference.enabled else { + updateStatus = nil + return + } + let status = await UpdateCheck.refresh() + updateStatus = (status?.updateAvailable == true) ? status : nil } func refresh() async { diff --git a/Sources/DevCtlApp/DevCtlApp.swift b/Sources/DevCtlApp/DevCtlApp.swift index c90bcfd..c219c60 100644 --- a/Sources/DevCtlApp/DevCtlApp.swift +++ b/Sources/DevCtlApp/DevCtlApp.swift @@ -177,6 +177,10 @@ final class KeyNavModel { Spotlight launches us. */ final class AppActivationDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { func applicationDidFinishLaunching(_ notification: Notification) { + /** First, before anything claims the menu bar or the notification + center: a second copy of the same bundle doubles every menu bar item, + poll and crash notification the user sees. */ + guard !SetupPerformer.quitIfTwinIsRunning() else { return } AppDeepLinkDispatch.registerNotificationCategories() UNUserNotificationCenter.current().delegate = self /** MenuBarExtra / LSUIElement apps do not always receive @@ -288,9 +292,16 @@ struct DevCtlApp: App { } .defaultSize(width: 860, height: 560) + Window("devctl Settings", id: "settings") { + SettingsView(model: model) + } + .windowResizability(.contentSize) + .defaultSize(width: 460, height: 520) + Window(setupSession.migration || setupSession.replacingApplicationsApp ? "Upgrade devctl" : "Install devctl", id: "setup") { SetupPanel( + cliOwnedByBrew: setupSession.cliOwnedByBrew, installAppToApplications: setupSession.installAppToApplications, migration: setupSession.migration, offers: setupSession.offers, @@ -399,6 +410,10 @@ struct MenuContent: View { .zIndex(1) } } + if let update = model.updateStatus { + Divider() + UpdateFooterRow(status: update) + } Divider() HStack(spacing: 0) { Button { @@ -411,9 +426,18 @@ struct MenuContent: View { .foregroundStyle(.secondary) .help("Open the dashboard window") .accessibilityLabel(Text("Open dashboard")) - /** Generous air between primary action and the login preference. */ - LaunchAtLoginToggle() - .padding(.leading, 22) + /** Generous air between primary action and the settings entry. */ + Button { + openWindow(id: "settings") + NSApp.activate(ignoringOtherApps: true) + } label: { + FooterIconLabel(systemImage: "gearshape", title: "Settings") + } + .buttonStyle(.borderless) + .foregroundStyle(.secondary) + .help("Manage hooks, login, and updates") + .accessibilityLabel(Text("Open settings")) + .padding(.leading, 22) Spacer(minLength: 8) Button { NSApp.terminate(nil) @@ -1002,6 +1026,45 @@ struct TallyDot: View { } } +/** Quiet band shown above the footer when a newer release exists. A Homebrew + install gets an Upgrade button that runs `brew upgrade` in Terminal (a + GUI-launched app has no PATH to run brew itself, and a password prompt needs + somewhere to go); any other install gets a link to the release page. */ +struct UpdateFooterRow: View { + let status: UpdateStatus + + private var owner: CLIOwner { SetupPlanner.cliOwner() } + + var body: some View { + HStack(spacing: 8) { + Image(systemName: "arrow.up.circle") + .foregroundStyle(.secondary) + Text("devctl \(status.latestVersion) available") + .font(.caption) + .foregroundStyle(.secondary) + Spacer(minLength: 8) + if owner.isHomebrew { + Button("Upgrade") { + TerminalRunner.run( + title: "devctl upgrade", command: DevCtlDistribution.brewUpgradeCommand) + } + .controlSize(.small) + .help("Run brew upgrade in Terminal") + } else { + Button("Release notes") { + if let url = URL(string: DevCtlDistribution.releasesLatestURL) { + NSWorkspace.shared.open(url) + } + } + .controlSize(.small) + .help("Open the latest release page") + } + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } +} + /** Icon + title with a tight gap; Label's default titleAndIcon spacing is wide for this dense footer. */ struct FooterIconLabel: View { @@ -1018,30 +1081,6 @@ struct FooterIconLabel: View { } } -struct LaunchAtLoginToggle: View { - @State private var enabled = SMAppService.mainApp.status == .enabled - - var body: some View { - Toggle("Start at login", isOn: $enabled) - .toggleStyle(.checkbox) - .controlSize(.small) - .font(.caption) - .foregroundStyle(.secondary) - .onChange(of: enabled) { _, wanted in - do { - if wanted { - try SMAppService.mainApp.register() - } else { - try SMAppService.mainApp.unregister() - } - } catch { - enabled = SMAppService.mainApp.status == .enabled - } - } - .help("Launch devctl.app at login") - } -} - /** Compact sort picker floating in the blank space left of the filter box. Same continuous-corner chip language so the two controls read as one band. */ struct SortOrderMenu: View { diff --git a/Sources/DevCtlApp/SettingsView.swift b/Sources/DevCtlApp/SettingsView.swift new file mode 100644 index 0000000..02e1a06 --- /dev/null +++ b/Sources/DevCtlApp/SettingsView.swift @@ -0,0 +1,228 @@ +import AppKit +import DevCtlKit +import ServiceManagement +import SwiftUI + +/** Whether the app checks for a newer release in the background. Read by the + update poll (Phase 4) and toggled in Settings; defaults on. */ +enum UpdatePreference { + static let key = "check for updates" + + static var enabled: Bool { + UserDefaults.standard.object(forKey: key) as? Bool ?? true + } + + static func set(_ value: Bool) { + UserDefaults.standard.set(value, forKey: key) + } +} + +/** The Settings window, opened from the gear at the bottom of the popover. Holds + the path back to agent hooks after first run, the login item, the update + preference, and uninstall. Hook state and actions go through the CLI: the app + links DevCtlKit but not the CLI target, and devctl never edits a harness's + settings without a deliberate click here. */ +struct SettingsView: View { + var model: DaemonModel + + @State private var offers: [HarnessOffer] = [] + @State private var busyHarness: String? + @State private var hookError: String? + @State private var launchAtLogin = SMAppService.mainApp.status == .enabled + @State private var checkForUpdates = UpdatePreference.enabled + @State private var confirmingUninstall = false + + private var owner: CLIOwner { SetupPlanner.cliOwner() } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 22) { + hooksSection + Divider() + generalSection + Divider() + uninstallSection + } + .padding(20) + } + .frame(width: 460) + .onAppear(perform: refreshOffers) + } + + // MARK: Hooks + + private var hooksSection: some View { + VStack(alignment: .leading, spacing: 10) { + sectionTitle("Agent hooks") + Text( + "devctl feeds each session the project's server status. Turn a harness's hook on or off; devctl only edits these files when you click." + ) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + if offers.isEmpty { + Text("No supported agent harnesses detected on this Mac.") + .font(.caption) + .foregroundStyle(.tertiary) + } else { + ForEach(offers, id: \.harness) { offer in + HStack(spacing: 10) { + Text(offer.displayName) + .font(.callout) + Spacer(minLength: 8) + if busyHarness == offer.harness { + ProgressView().controlSize(.small) + } else if offer.alreadyInstalled { + Button("Remove") { toggleHook(offer, install: false) } + .controlSize(.small) + } else { + Button("Install") { toggleHook(offer, install: true) } + .controlSize(.small) + } + } + } + } + if let hookError { + Text(hookError) + .font(.caption) + .foregroundStyle(.red) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + // MARK: General + + private var generalSection: some View { + VStack(alignment: .leading, spacing: 12) { + sectionTitle("General") + Toggle("Start at login", isOn: $launchAtLogin) + .toggleStyle(.checkbox) + .onChange(of: launchAtLogin) { _, wanted in + do { + if wanted { + try SMAppService.mainApp.register() + } else { + try SMAppService.mainApp.unregister() + } + } catch { + launchAtLogin = SMAppService.mainApp.status == .enabled + } + } + Toggle("Check for updates in the background", isOn: $checkForUpdates) + .toggleStyle(.checkbox) + .onChange(of: checkForUpdates) { _, wanted in + UpdatePreference.set(wanted) + } + } + } + + // MARK: Uninstall + + private var uninstallSection: some View { + VStack(alignment: .leading, spacing: 10) { + sectionTitle("Uninstall") + if owner.isHomebrew { + Text( + "This copy is managed by Homebrew. Remove it, its hooks, and its data with:" + ) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Text(DevCtlDistribution.brewUninstallCommand) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + Button("Uninstall in Terminal…") { + TerminalRunner.run( + title: "devctl uninstall", command: DevCtlDistribution.brewUninstallCommand) + } + .controlSize(.small) + } else { + Text( + "Removes the background agent, agent hooks, and the CLI, then moves this app to the Trash. Running servers keep going; your data is kept." + ) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Button("Uninstall devctl…", role: .destructive) { + confirmingUninstall = true + } + .controlSize(.small) + .confirmationDialog( + "Uninstall devctl?", isPresented: $confirmingUninstall, titleVisibility: .visible + ) { + Button("Uninstall", role: .destructive) { performLocalUninstall() } + Button("Cancel", role: .cancel) {} + } message: { + Text( + "This removes the agent, hooks, and CLI and moves devctl.app to the Trash. Your data is kept unless you remove it by hand." + ) + } + } + } + } + + // MARK: Actions + + private func sectionTitle(_ text: String) -> some View { + Text(text) + .font(.headline) + } + + private func refreshOffers() { + let cliPath = owner.cliPath.path + Task { @MainActor in + offers = await Task.detached(priority: .userInitiated) { + SetupPlanner.harnessOffers(installedCLIPath: cliPath) + }.value + } + } + + /** Runs the bundled CLI for the hook action, recording the owner's CLI path + so a brew install points hooks at the stable shim rather than the internal + bundle path the CLI would otherwise resolve to. */ + private func toggleHook(_ offer: HarnessOffer, install: Bool) { + guard let cli = SetupPerformer.resourceURLs()?.cli else { + hookError = "This copy of devctl.app is missing its bundled CLI." + return + } + busyHarness = offer.harness + hookError = nil + let recordPath = owner.cliPath.path + let harness = offer.harness + Task { @MainActor in + let result = await Task.detached(priority: .userInitiated) { + install + ? LaunchdAdmin.shell( + cli.path, + ["hook", "install", "--harness", harness, "--devctl-path", recordPath]) + : LaunchdAdmin.shell(cli.path, ["hook", "uninstall", "--harness", harness]) + }.value + if result.status != 0 { + hookError = result.output.trimmingCharacters(in: .whitespacesAndNewlines) + } + busyHarness = nil + refreshOffers() + } + } + + /** Non-Homebrew uninstall: shell the CLI to remove the agent, hooks, and the + `~/.local/bin` binaries, then move this bundle to the Trash and quit. A + running bundle can be trashed because the process holds the inode. */ + private func performLocalUninstall() { + guard let cli = SetupPerformer.resourceURLs()?.cli else { + hookError = "This copy of devctl.app is missing its bundled CLI." + return + } + Task { @MainActor in + _ = await Task.detached(priority: .userInitiated) { + LaunchdAdmin.shell(cli.path, ["uninstall"]) + }.value + try? FileManager.default.trashItem( + at: Bundle.main.bundleURL, resultingItemURL: nil) + NSApp.terminate(nil) + } + } +} diff --git a/Sources/DevCtlApp/SetupPanel.swift b/Sources/DevCtlApp/SetupPanel.swift index 6e0294b..9716927 100644 --- a/Sources/DevCtlApp/SetupPanel.swift +++ b/Sources/DevCtlApp/SetupPanel.swift @@ -4,6 +4,7 @@ import SwiftUI /** First-run / upgrade panel: clear checklist of what Confirm will do, then opt-in harness hooks (default checked when install is still needed). */ struct SetupPanel: View { + let cliOwnedByBrew: Bool let installAppToApplications: Bool let migration: Bool let offers: [HarnessOffer] @@ -18,6 +19,7 @@ struct SetupPanel: View { @State private var willRelaunch = false init( + cliOwnedByBrew: Bool, installAppToApplications: Bool, migration: Bool, offers: [HarnessOffer], @@ -25,6 +27,7 @@ struct SetupPanel: View { replacingApplicationsApp: Bool, onFinished: @escaping () -> Void ) { + self.cliOwnedByBrew = cliOwnedByBrew self.installAppToApplications = installAppToApplications self.migration = migration self.offers = offers @@ -40,6 +43,14 @@ struct SetupPanel: View { Text(migration || replacingApplicationsApp ? "Upgrade devctl" : "Install devctl") .font(.title3.weight(.semibold)) + /** Only on a first install. Someone upgrading has been running this + for a while and does not need to be told what it is. */ + if !(migration || replacingApplicationsApp) { + Text("An agent-friendly coordinator for many devservers and their unique configurations.") + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Text("Confirm to apply the changes below. Nothing runs until you confirm.") .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -54,10 +65,16 @@ struct SetupPanel: View { ? "Quit the running menu bar app (if any), then replace /Applications/devctl.app with this version" : "Install the menu bar app at /Applications/devctl.app") } - bullet( - migration - ? "Update the CLI at \(SetupPlanner.defaultCLIDirectory().path)" - : "Install the CLI at \(SetupPlanner.defaultCLIDirectory().path)") + /** Under a Homebrew install the cask owns the CLI symlink, so + devctl neither installs nor updates it and says so instead. */ + if cliOwnedByBrew { + bullet("Leave the CLI to Homebrew (installed in brew's bin)") + } else { + bullet( + migration + ? "Update the CLI at \(SetupPlanner.defaultCLIDirectory().path)" + : "Install the CLI at \(SetupPlanner.defaultCLIDirectory().path)") + } bullet( "Install or update the background daemon and restart it (your running servers stay)") if selected.isEmpty { @@ -74,9 +91,7 @@ struct SetupPanel: View { .fixedSize(horizontal: false, vertical: true) if pathWarning { - Label( - "\(SetupPlanner.defaultCLIDirectory().path) is not on your PATH. Add it after setup so shells find `devctl`.", - systemImage: "exclamationmark.triangle") + Label(SetupPlanner.pathRemedy, systemImage: "exclamationmark.triangle") .font(.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) @@ -190,7 +205,12 @@ struct SetupPanel: View { Applications copy is allowed to register after settle. */ try? FileManager.default.removeItem(at: DevCtlPaths().stoppedIntentFile) } - await SetupPerformer.quitOtherInstances() + guard await SetupPerformer.quitOtherInstances() else { + errorText = + "Another copy of devctl is still running, so replacing /Applications/devctl.app would leave it running code that is no longer on disk. Quit quantizor/devctl from Activity Monitor, then try again." + busy = false + return + } } do { let result = try await Task.detached(priority: .userInitiated) { diff --git a/Sources/DevCtlApp/SetupPerformer.swift b/Sources/DevCtlApp/SetupPerformer.swift index df0f0b4..d8026ff 100644 --- a/Sources/DevCtlApp/SetupPerformer.swift +++ b/Sources/DevCtlApp/SetupPerformer.swift @@ -17,10 +17,10 @@ enum SetupPerformer: Sendable { } struct Presentation: Sendable { + var cliOwnedByBrew: Bool var installAppToApplications: Bool var migration: Bool var offers: [HarnessOffer] - var pathWarning: Bool var replacingApplicationsApp: Bool var shouldPresent: Bool } @@ -72,7 +72,11 @@ enum SetupPerformer: Sendable { ) -> Presentation { let resources = resourceURLs(bundle: bundle) != nil let bundled = bundledVersion(bundle: bundle) - let cliURL = SetupPlanner.installedCLIURL() + let owner = SetupPlanner.cliOwner(bundle: bundle) + /** Read the installed version from the path this owner actually uses, so + an absent `~/.local/bin/devctl` under a brew install does not read as + "never installed" and force the panel open on every launch. */ + let cliURL = owner.cliPath let installedVersion = readCLIVersion(at: cliURL) let stamp = SetupPlanner.readStamp(at: SetupPlanner.stampURL(paths: paths)) let outside = SetupPlanner.isRunningOutsideApplications( @@ -89,17 +93,24 @@ enum SetupPerformer: Sendable { launchAgentExists: FileManager.default.fileExists( atPath: LaunchdAdmin.plistURL.path)) let offers = SetupPlanner.harnessOffers(installedCLIPath: cliURL.path) - let pathWarning = !SetupPlanner.cliDirectoryOnPATH( - pathEnv: ProcessInfo.processInfo.environment["PATH"]) return Presentation( + cliOwnedByBrew: owner.isHomebrew, installAppToApplications: outside, migration: migration, offers: offers, - pathWarning: pathWarning, replacingApplicationsApp: outside && SetupPlanner.applicationsAppExists(), shouldPresent: should) } + /** Whether to warn that the CLI directory is off the user's PATH. Split from + `evaluatePresentation` because it sources the login shell (up to a 12s + ceiling) and must never run on the main thread; the panel fills this in + after it opens. Owner-aware: brew's bin is on PATH via `brew shellenv`, so + a brew-owned CLI never warrants the warning. */ + nonisolated static func evaluatePathWarning() -> Bool { + !SetupPlanner.cliDirectoryOnUserPATH(owner: SetupPlanner.cliOwner()) + } + /** Install app (when needed), CLI, register the SMAppService agent from this process when the bundle can host it, then checked hooks. Call `quitOtherInstances` on the MainActor before this when relocating. */ @@ -110,7 +121,11 @@ enum SetupPerformer: Sendable { ) async throws -> Result { guard let resources = resourceURLs(bundle: bundle) else { throw Failure.missingResources } let paths = DevCtlPaths() - let cliDest = SetupPlanner.installedCLIURL() + let owner = SetupPlanner.cliOwner(bundle: bundle) + /** The CLI to drive for hook install and to record in the hook command. + Under brew this is the shim in brew's bin, which is on PATH and + survives upgrades; under a DMG install it is `~/.local/bin/devctl`. */ + let cliDest = owner.cliPath let daemonSibling = SetupPlanner.installedDaemonSiblingURL() let fm = FileManager.default let migration = SetupPlanner.isMigration( @@ -147,9 +162,16 @@ enum SetupPerformer: Sendable { notes.append("Migrating the existing CLI and daemon to this version.") } - try SetupPlanner.installBinary(from: resources.cli, to: cliDest) - try SetupPlanner.installBinary(from: resources.daemon, to: daemonSibling) - notes.append("Installed CLI to \(cliDest.path)") + /** Homebrew owns the CLI symlink in its bin and the daemon runs from the + bundle's `Contents/Helpers/devctld` under SMAppService, so installing a + second copy to `~/.local/bin` would only orphan it on cask uninstall. */ + if owner.isHomebrew { + notes.append("CLI managed by Homebrew at \(cliDest.path)") + } else { + try SetupPlanner.installBinary(from: resources.cli, to: cliDest) + try SetupPlanner.installBinary(from: resources.daemon, to: daemonSibling) + notes.append("Installed CLI to \(cliDest.path)") + } /** SMAppService must run with Bundle.main as the hosting app. After a relocate, the Applications copy registers on launch; otherwise do it @@ -179,18 +201,25 @@ enum SetupPerformer: Sendable { var harnessSummaries: [String] = [] for harness in selectedHarnesses.sorted() { - let out = try runCLI(cliDest, arguments: ["hook", "install", "--harness", harness]) + /** `--devctl-path` pins the command the hook records to this owner's + CLI path. Without it the invoked CLI resolves its own symlink back + into the bundle, so a brew install would record the internal + Resources path instead of the stable shim in brew's bin. */ + let out = try runCLI( + cliDest, + arguments: ["hook", "install", "--harness", harness, "--devctl-path", cliDest.path]) harnessSummaries.append(out.isEmpty ? "Installed \(harness) hook" : out) } try SetupPlanner.writeStamp( version: bundledVersion(bundle: bundle), to: SetupPlanner.stampURL(paths: paths)) - let onPATH = SetupPlanner.cliDirectoryOnPATH( - pathEnv: ProcessInfo.processInfo.environment["PATH"]) + /** Bound once: the login-shell PATH is captured by spawning a shell. + Owner-aware, so a brew install (whose bin is already on PATH) does not + print the `~/.local/bin` remedy. */ + let onPATH = SetupPlanner.cliDirectoryOnUserPATH(owner: owner) if !onPATH { - notes.append( - "\(SetupPlanner.defaultCLIDirectory().path) is not on your PATH. Add it so shells and agents can find `devctl`.") + notes.append(SetupPlanner.pathRemedy) } return Result( @@ -201,28 +230,74 @@ enum SetupPerformer: Sendable { relocatedToApplications: relocated) } - /** Quit every other running copy so /Applications/devctl.app can be replaced. */ + /** Symlinks resolved and the path standardized, so `/Volumes/devctl` and + `/Applications` compare by what they are rather than how they were + spelled. Every bundle-path comparison in the app goes through here. */ + nonisolated static func canonicalPath(_ url: URL?) -> String? { + url?.resolvingSymlinksInPath().standardizedFileURL.path + } + + /** Quit at launch when an older copy of this same bundle is already running. + Returns whether this process is on its way out, so the caller can skip the + rest of launch. + + The DMG copy and the Applications copy overlap for the length of the + handoff and are both wanted, so this compares bundle paths: two copies at + one path are the failure, and `AppInstancePolicy` decides which of them + leaves. */ @MainActor - static func quitOtherInstances() async { - let peers = NSWorkspace.shared.runningApplications.filter { - $0.bundleIdentifier == appBundleIdentifier && $0 != .current + static func quitIfTwinIsRunning() -> Bool { + guard let ownPath = canonicalPath(Bundle.main.bundleURL) else { return false } + let current = NSRunningApplication.current + let own = AppInstance( + bundlePath: ownPath, + launchDate: current.launchDate, + processIdentifier: current.processIdentifier) + let running = NSWorkspace.shared.runningApplications.compactMap { app -> AppInstance? in + guard app.bundleIdentifier == appBundleIdentifier, + let path = canonicalPath(app.bundleURL) + else { return nil } + return AppInstance( + bundlePath: path, + launchDate: app.launchDate, + processIdentifier: app.processIdentifier) } - for app in peers { + guard AppInstancePolicy.shouldStandDown(own: own, running: running) else { return false } + DevCtlLog.app.info("\(ownPath) is already running; this copy is standing down") + NSApp.terminate(nil) + return true + } + + /** Quit every other running copy so /Applications/devctl.app can be replaced. + Returns whether the field is clear: replacing a bundle out from under a + live process leaves that process running code that no longer exists on + disk, so a caller that ignores a false here does real damage. */ + @MainActor + static func quitOtherInstances() async -> Bool { + for app in peerInstances() { app.terminate() } let deadline = Date().addingTimeInterval(5) while Date() < deadline { - let still = NSWorkspace.shared.runningApplications.contains { - $0.bundleIdentifier == appBundleIdentifier && $0 != .current - } - if !still { return } + if peerInstances().isEmpty { return true } try? await Task.sleep(for: .milliseconds(100)) } - for app in NSWorkspace.shared.runningApplications - where app.bundleIdentifier == appBundleIdentifier && app != .current { + for app in peerInstances() { app.forceTerminate() } - try? await Task.sleep(for: .milliseconds(200)) + let forcedDeadline = Date().addingTimeInterval(2) + while Date() < forcedDeadline { + if peerInstances().isEmpty { return true } + try? await Task.sleep(for: .milliseconds(100)) + } + return peerInstances().isEmpty + } + + @MainActor + private static func peerInstances() -> [NSRunningApplication] { + NSWorkspace.shared.runningApplications.filter { + $0.bundleIdentifier == appBundleIdentifier && $0 != .current + } } /** Open the Applications copy and quit this (DMG/Downloads) process. Quitting @@ -233,7 +308,7 @@ enum SetupPerformer: Sendable { @MainActor static func relaunchFromApplicationsAndQuit() { let url = URL(fileURLWithPath: SetupPlanner.applicationsAppPath) - let appsPath = url.resolvingSymlinksInPath().standardizedFileURL.path + let appsPath = canonicalPath(url) let selfPID = ProcessInfo.processInfo.processIdentifier let configuration = NSWorkspace.OpenConfiguration() configuration.activates = true @@ -247,8 +322,7 @@ enum SetupPerformer: Sendable { "relaunch from \(SetupPlanner.applicationsAppPath) failed: \(error.localizedDescription)") return } - let launchedPath = app?.bundleURL? - .resolvingSymlinksInPath().standardizedFileURL.path + let launchedPath = canonicalPath(app?.bundleURL) let differentProcess = (app?.processIdentifier).map { $0 != selfPID } ?? false if differentProcess, launchedPath == appsPath { NSApp.terminate(nil) @@ -256,41 +330,17 @@ enum SetupPerformer: Sendable { } DevCtlLog.app.info( "openApplication returned self or wrong path; waiting for Applications peer") - let deadline = Date().addingTimeInterval(8) - while Date() < deadline { - let peer = NSWorkspace.shared.runningApplications.first { running in - guard running.bundleIdentifier == appBundleIdentifier else { return false } - guard running.processIdentifier != selfPID else { return false } - let path = running.bundleURL? - .resolvingSymlinksInPath().standardizedFileURL.path - return path == appsPath - } - if peer != nil { - NSApp.terminate(nil) - return - } - try? await Task.sleep(for: .milliseconds(100)) + if await waitForPeer(atPath: appsPath, otherThan: selfPID, seconds: 8) { + NSApp.terminate(nil) + return } /** Last resort: `open(1)` bypasses some LS same-bundle shortcuts. */ let open = LaunchdAdmin.shell("/usr/bin/open", [SetupPlanner.applicationsAppPath]) - if open.status == 0 { - let openDeadline = Date().addingTimeInterval(5) - while Date() < openDeadline { - let peer = NSWorkspace.shared.runningApplications.contains { running in - guard running.bundleIdentifier == appBundleIdentifier else { - return false - } - guard running.processIdentifier != selfPID else { return false } - let path = running.bundleURL? - .resolvingSymlinksInPath().standardizedFileURL.path - return path == appsPath - } - if peer { - NSApp.terminate(nil) - return - } - try? await Task.sleep(for: .milliseconds(100)) - } + if open.status == 0, + await waitForPeer(atPath: appsPath, otherThan: selfPID, seconds: 5) + { + NSApp.terminate(nil) + return } DevCtlLog.app.error( "could not hand off to \(SetupPlanner.applicationsAppPath); staying alive so setup is not lost") @@ -298,6 +348,24 @@ enum SetupPerformer: Sendable { } } + /** Poll for another process of this app running from `path`. */ + @MainActor + private static func waitForPeer( + atPath path: String?, otherThan selfPID: Int32, seconds: TimeInterval + ) async -> Bool { + let deadline = Date().addingTimeInterval(seconds) + while Date() < deadline { + let found = NSWorkspace.shared.runningApplications.contains { running in + running.bundleIdentifier == appBundleIdentifier + && running.processIdentifier != selfPID + && canonicalPath(running.bundleURL) == path + } + if found { return true } + try? await Task.sleep(for: .milliseconds(100)) + } + return false + } + nonisolated private static func readCLIVersion(at url: URL) -> String? { guard FileManager.default.isExecutableFile(atPath: url.path) else { return nil } let proc = Process() diff --git a/Sources/DevCtlApp/SetupSession.swift b/Sources/DevCtlApp/SetupSession.swift index e08c22d..ee0167f 100644 --- a/Sources/DevCtlApp/SetupSession.swift +++ b/Sources/DevCtlApp/SetupSession.swift @@ -6,6 +6,7 @@ import SwiftUI /** Shared first-run / upgrade gate evaluated once at launch. */ @Observable final class SetupSession { + var cliOwnedByBrew = false var installAppToApplications = false var migration = false var offers: [HarnessOffer] = [] @@ -13,32 +14,51 @@ final class SetupSession { var replacingApplicationsApp = false var shouldPresent = false - func evaluate() { - let eval = SetupPerformer.evaluatePresentation() + /** The cheap presentation decision (which panel, what to offer). Run off the + main thread because it reads files and shells `devctl --version`, then + published on the MainActor. */ + func evaluate() async { + let eval = await Task.detached(priority: .userInitiated) { + SetupPerformer.evaluatePresentation() + }.value + cliOwnedByBrew = eval.cliOwnedByBrew installAppToApplications = eval.installAppToApplications migration = eval.migration offers = eval.offers - pathWarning = eval.pathWarning replacingApplicationsApp = eval.replacingApplicationsApp shouldPresent = eval.shouldPresent } + + /** The PATH warning, sourced separately because it spawns a login shell that + sources `.zshrc` under a 12s ceiling. Running it on the main thread froze + launch; instead the panel opens on `evaluate()` and this fills the warning + label in when the probe returns. */ + func refreshPathWarning() async { + pathWarning = await Task.detached(priority: .userInitiated) { + SetupPerformer.evaluatePathWarning() + }.value + } } /** Opens the setup window from a view that has `openWindow` (the menu bar label). */ struct SetupWindowOpener: View { @Environment(\.openWindow) private var openWindow var session: SetupSession - @State private var didOpen = false + @State private var didStart = false var body: some View { Color.clear .frame(width: 0, height: 0) .onAppear { - session.evaluate() - guard session.shouldPresent, !didOpen else { return } - didOpen = true - openWindow(id: "setup") - NSApp.activate(ignoringOtherApps: true) + guard !didStart else { return } + didStart = true + Task { @MainActor in + await session.evaluate() + guard session.shouldPresent else { return } + openWindow(id: "setup") + NSApp.activate(ignoringOtherApps: true) + await session.refreshPathWarning() + } } } } diff --git a/Sources/DevCtlApp/TerminalRunner.swift b/Sources/DevCtlApp/TerminalRunner.swift new file mode 100644 index 0000000..2d49bf2 --- /dev/null +++ b/Sources/DevCtlApp/TerminalRunner.swift @@ -0,0 +1,37 @@ +import AppKit +import DevCtlKit +import Foundation + +/** Runs a shell command in a new Terminal window. Terminal is used rather than a + detached child for two reasons a Homebrew action needs: a GUI-launched app has + no usable PATH, so a bare `brew` exits 127, while Terminal runs a login shell + that sources `brew shellenv`; and a `sudo`/password prompt has somewhere to + go. The window sets a unique title and closes itself on a clean exit, leaving + a failed run on screen with its output. */ +enum TerminalRunner { + /** `title` names the window so the self-close can find it; keep it a simple + literal (no quotes) since it is interpolated into AppleScript. */ + static func run(title: String, command: String) { + let script = """ + #!/bin/sh + printf '\\033]0;%s\\007' "\(title)" + \(command) + status=$? + if [ "$status" -eq 0 ]; then + osascript -e 'tell application "Terminal" to close (every window whose name contains "\(title)")' >/dev/null 2>&1 & + fi + exit "$status" + """ + let url = FileManager.default.temporaryDirectory + .appending(path: "devctl-terminal-\(UUID().uuidString).command") + do { + try Data(script.utf8).write(to: url) + try FileManager.default.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o755))], ofItemAtPath: url.path) + } catch { + DevCtlLog.app.error("could not stage Terminal script: \(error.localizedDescription)") + return + } + _ = LaunchdAdmin.shell("/usr/bin/open", ["-a", "Terminal", url.path]) + } +} diff --git a/Sources/DevCtlKit/Launchd/LaunchdAdmin.swift b/Sources/DevCtlKit/Launchd/LaunchdAdmin.swift index 8bb6d6e..e87837d 100644 --- a/Sources/DevCtlKit/Launchd/LaunchdAdmin.swift +++ b/Sources/DevCtlKit/Launchd/LaunchdAdmin.swift @@ -1,4 +1,5 @@ import Foundation +import os /** launchd administration: install renders the LaunchAgent and bootstraps it; upgrades stage-and-rename the binary (overwriting a running signed Mach-O @@ -126,7 +127,11 @@ public enum LaunchdAdmin { `~/.local/bin/devctld`, and the Application Support install path. */ public static func resolveDaemonBinary(extraCandidates: [URL] = []) -> URL? { var candidates = extraCandidates - let arg0 = URL(fileURLWithPath: CommandLine.arguments[0]) + /** Resolve symlinks first: invoked through a Homebrew shim, argv0 is + `/bin/devctl`, whose sibling `devctld` does not exist, + while the resolved path sits next to the real `devctld` in the bundle + or install directory. */ + let arg0 = URL(fileURLWithPath: CommandLine.arguments[0]).resolvingSymlinksInPath() candidates.append(arg0.deletingLastPathComponent().appending(path: "devctld")) candidates.append(SetupPlanner.installedDaemonSiblingURL()) candidates.append(DevCtlPaths().daemonBinaryDir.appending(path: "devctld")) @@ -364,11 +369,74 @@ public enum LaunchdAdmin { public static let pathFloor = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" - /** Captured login-shell PATH so launchd children can find Homebrew/asdf/mise - tools; launchd agents otherwise get a minimal PATH. Goes stale after a - Homebrew migration, which doctor surfaces via daemon.info. */ + /** How long a shell profile gets to finish before the capture gives up and + falls back to `pathFloor`. Sourcing a file the user wrote means devctl + does not control how long it takes, and a profile that waits on the + network or on a terminal that is not there would otherwise hang the app + at launch. Generous by the standards of the same probe elsewhere: VS Code + allows 10 seconds by default, JetBrains 20. Measured locally at about + 1.2s, so this is roughly ten times the real cost. */ + public static let pathCaptureTimeoutSeconds = 12.0 + + /** The PATH the user actually has, so launchd children can find the tools + the user installed; launchd agents otherwise get a minimal PATH. Goes + stale after a Homebrew migration, which doctor surfaces via daemon.info. + + `.zshrc` is sourced explicitly because `-lc` is a login but NON + interactive shell, so zsh runs `.zshenv`, `.zprofile` and `.zlogin` and + skips `.zshrc` entirely. That is where most tools put themselves: on the + machine this was measured, `-lc` alone missed `~/.local/bin` (so a server + devctl spawned could not run `devctl`), plus pnpm, conda, gcloud and the + rest. Sourcing it produces byte-identical output to an interactive login + shell and costs less, without running an interactive session, so rc files + that gate prompts and completions on interactivity stay skipped. + + Errors from the source are discarded rather than checked: a machine with + no `.zshrc` is ordinary, and the fallback is the login-only PATH, which + is what this returned before. + + Do not measure this from a terminal. A shell started from a shell + inherits its parent's PATH, so `zsh -lc 'echo $PATH'` looks complete + there and is missing entries under launchd, where there is no parent to + inherit from. Use `env -i HOME=$HOME PATH=/usr/bin:/bin ...` to see what + the daemon really gets. + + zsh is hardcoded on purpose. It is the macOS default and the tools that + solve this elsewhere pick the user's shell from `$SHELL` or `getpwuid`, + which would also mean branching the invocation per shell family, since + fish has no login/interactive split and csh rejects these flags. That + buys nothing until a devctl user is on another shell, so it waits for + one rather than being built on speculation. A bash or fish user gets the + login-only PATH here, which degrades rather than breaks. */ public static func capturedPath() -> String { - let result = shell("/bin/zsh", ["-lc", "echo $PATH"]) + /** Built, not inherited, so the answer is the same whoever asks. A child + shell inherits its parent's environment, so this returned the user's + full PATH when the CLI called it from a terminal and a much shorter + one when the app called it under launchd, and whichever binary + happened to write `agent.path` last decided what every spawned server + got. + + Overriding PATH alone is not enough, which is worth stating because + it is the obvious half-fix: an rc file can read any variable, and + tools that initialize themselves idempotently go quiet when they see + their own. Measured here, inheriting `CONDA_SHLVL` and `CONDA_EXE` + made conda's hook decide it had already run, so its directory was + missing from the captured PATH while every other entry was present. + Only these four are set, being what a login shell can rely on. */ + let environment = [ + /** Set so a profile can tell this apart from a real session and skip + whatever needs a terminal. VS Code and the JetBrains IDEs both + publish one for the same purpose; ours is documented in the + README so it is worth guarding against. */ + "DEVCTL_RESOLVING_ENVIRONMENT": "1", + "HOME": FileManager.default.homeDirectoryForCurrentUser.path, + "LOGNAME": NSUserName(), + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "USER": NSUserName(), + ] + let result = shell( + "/bin/zsh", ["-lc", #"source "$HOME/.zshrc" >/dev/null 2>&1; echo $PATH"#], + environment: environment, timeoutSeconds: pathCaptureTimeoutSeconds) let path = result.output.trimmingCharacters(in: .whitespacesAndNewlines) return path.isEmpty ? pathFloor : path } @@ -450,20 +518,71 @@ public enum LaunchdAdmin { } @discardableResult - public static func shell(_ path: String, _ arguments: [String]) -> (status: Int32, output: String) { + /** `environment` nil inherits this process's, which is what most callers + want. Pass one to make the child's answer independent of who asked. + + `timeoutSeconds` nil waits forever, which is right for a command devctl + controls end to end. Pass one for anything that runs a file the user + wrote: a shell profile can prompt, wait on the network, or expect a + terminal that is not there, and waiting forever for it is how a menu bar + app hangs at launch with nothing on screen explaining why. */ + public static func shell( + _ path: String, _ arguments: [String], environment: [String: String]? = nil, + timeoutSeconds: Double? = nil + ) -> (status: Int32, output: String) { let process = Process() process.executableURL = URL(fileURLWithPath: path) process.arguments = arguments + process.environment = environment let pipe = Pipe() process.standardOutput = pipe process.standardError = pipe + /** Drained on another thread because the timeout path below waits on + termination first, and a read to EOF on this thread would block until + the child closed the pipe, which is the thing being timed out. The + untimed path could read inline as it always did; it shares this one + so both return output the same way. */ + let collected = OSAllocatedUnfairLock(initialState: Data()) + let drained = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + let data = pipe.fileHandleForReading.readDataToEndOfFile() + collected.withLock { $0 = data } + drained.signal() + } + /** Installed before `run()`, not after: a child that exits in the window + between `run()` returning and a later assignment is already terminated + when the handler is set, and Foundation does not fire terminationHandler + for an already-dead process. The timeout path below would then wait out + its full ceiling and SIGKILL a pid the kernel may have recycled, and the + PATH capture that rides this would silently fall back to `pathFloor`. */ + let exited = DispatchSemaphore(value: 0) + process.terminationHandler = { _ in exited.signal() } do { try process.run() } catch { + drained.signal() return (status: -1, output: String(describing: error)) } - let data = pipe.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - return (status: process.terminationStatus, output: String(decoding: data, as: UTF8.self)) + guard let timeoutSeconds else { + process.waitUntilExit() + drained.wait() + return ( + status: process.terminationStatus, + output: String(decoding: collected.withLock { $0 }, as: UTF8.self) + ) + } + if exited.wait(timeout: .now() + timeoutSeconds) == .timedOut { + /** SIGKILL rather than SIGTERM: this is already the path where the + child ignored its chance to finish, and a profile blocked on a + read will not act on a term either. */ + kill(process.processIdentifier, SIGKILL) + _ = exited.wait(timeout: .now() + 2) + return (status: -1, output: "") + } + drained.wait() + return ( + status: process.terminationStatus, + output: String(decoding: collected.withLock { $0 }, as: UTF8.self) + ) } } diff --git a/Sources/DevCtlKit/Setup/AppInstancePolicy.swift b/Sources/DevCtlKit/Setup/AppInstancePolicy.swift new file mode 100644 index 0000000..9d1a52e --- /dev/null +++ b/Sources/DevCtlKit/Setup/AppInstancePolicy.swift @@ -0,0 +1,55 @@ +import Foundation + +/** One running copy of the menu bar app, reduced to what the stand-down decision + reads. Built from `NSRunningApplication` by the app; kept free of AppKit here + so the decision is testable. */ +public struct AppInstance: Equatable, Sendable { + public var bundlePath: String + public var launchDate: Date? + public var processIdentifier: Int32 + + public init(bundlePath: String, launchDate: Date?, processIdentifier: Int32) { + self.bundlePath = bundlePath + self.launchDate = launchDate + self.processIdentifier = processIdentifier + } +} + +/** Whether a launching copy of the app should quit because the same bundle is + already running. + + Two copies at the same path is always wrong: each polls the daemon, each + draws a menu bar item, and each posts its own crash notification, so the user + sees doubled everything. Nothing prevented it, and `open -n` (which is what + `NSWorkspace.OpenConfiguration.createsNewApplicationInstance` asks for, and + what the DMG handoff asks for by name) produces it on demand. + + Scoped to the bundle path rather than the bundle id on purpose: the DMG copy + and the Applications copy share an id and must overlap for the length of the + handoff, which is the one time two copies are correct. */ +public enum AppInstancePolicy { + /** True when `own` should quit and leave the field to an older twin. */ + public static func shouldStandDown(own: AppInstance, running: [AppInstance]) -> Bool { + running.contains { peer in + peer.processIdentifier != own.processIdentifier + && peer.bundlePath == own.bundlePath + && precedes(peer, own) + } + } + + /** A total order over copies, so two that launch together reach opposite + answers and exactly one quits. Comparing launch dates alone is not enough: + two copies launched in the same instant would each see the other as no + older and both would stay, which is the bug, or both would leave, which is + worse. The pid tiebreak is arbitrary but decides. */ + static func precedes(_ lhs: AppInstance, _ rhs: AppInstance) -> Bool { + if let left = lhs.launchDate, let right = rhs.launchDate, left != right { + return left < right + } + /** A copy whose launch date the system does not report sorts last, which + both sides agree on because both read the same two values. */ + if lhs.launchDate == nil, rhs.launchDate != nil { return false } + if lhs.launchDate != nil, rhs.launchDate == nil { return true } + return lhs.processIdentifier < rhs.processIdentifier + } +} diff --git a/Sources/DevCtlKit/Setup/SetupPlanner.swift b/Sources/DevCtlKit/Setup/SetupPlanner.swift index 14cf8d3..81d9c02 100644 --- a/Sources/DevCtlKit/Setup/SetupPlanner.swift +++ b/Sources/DevCtlKit/Setup/SetupPlanner.swift @@ -18,6 +18,57 @@ public enum SetupPlanner { home.appending(path: ".local/bin") } + /** The two prefixes a Homebrew install can live under: Apple Silicon and + Intel. A cask's `binary` symlink always lands in `/bin`, which + `brew shellenv` puts on the user's PATH. */ + public static let homebrewPrefixes = ["/opt/homebrew", "/usr/local"] + + /** Which install owns the CLI, decided from where the running app bundle + actually lives on disk rather than from any substring of its path. + + Homebrew moves the app to `/Applications` and leaves a symlink behind in + `/Caskroom/devctl//devctl.app` pointing back at it, so a + `/Caskroom/` substring test on the resolved bundle path is always false + for a normally-installed cask. The reliable signal is realpath equality: + the running bundle and the Caskroom symlink resolve to the same directory + only under a brew install. */ + public static func cliOwner( + bundle: Bundle = .main, fileManager: FileManager = .default + ) -> CLIOwner { + let runningRealpath = bundle.bundleURL.resolvingSymlinksInPath().standardizedFileURL.path + let staged: [(prefix: String, bundleRealpaths: [String])] = homebrewPrefixes.map { prefix in + let caskDir = URL(fileURLWithPath: prefix).appending(path: "Caskroom/devctl") + let versionDirs = + (try? fileManager.contentsOfDirectory( + at: caskDir, includingPropertiesForKeys: nil)) ?? [] + let bundles = versionDirs.compactMap { versionDir -> String? in + let candidate = versionDir.appending(path: "\(cliBinaryName).app") + guard fileManager.fileExists(atPath: candidate.path) else { return nil } + return candidate.resolvingSymlinksInPath().standardizedFileURL.path + } + return (prefix: prefix, bundleRealpaths: bundles) + } + return resolveCLIOwner(runningBundleRealpath: runningRealpath, caskStagedBundles: staged) + } + + /** The pure decision behind `cliOwner`, taking the disk facts as data so it + is testable without a real Caskroom. `caskStagedBundles` pairs each brew + prefix with the realpaths of the `devctl.app` symlinks found under its + Caskroom; a match against the running bundle means brew owns the CLI in + that prefix's bin. */ + public static func resolveCLIOwner( + runningBundleRealpath: String, + caskStagedBundles: [(prefix: String, bundleRealpaths: [String])], + cliDirectory: URL = defaultCLIDirectory() + ) -> CLIOwner { + for entry in caskStagedBundles + where entry.bundleRealpaths.contains(runningBundleRealpath) { + return .homebrew( + shim: URL(fileURLWithPath: entry.prefix).appending(path: "bin/\(cliBinaryName)")) + } + return .devctl(directory: cliDirectory) + } + public static func stampURL(paths: DevCtlPaths) -> URL { paths.dataDir.appending(path: stampFileName) } @@ -137,7 +188,38 @@ public enum SetupPlanner { return offers.sorted { $0.harness < $1.harness } } - /** Whether `~/.local/bin` appears on PATH (split on `:`). */ + /** What to tell someone whose shell cannot find `devctl`. Carries the whole + command rather than describing it, because the reader is being asked to + edit a shell profile and the shape of that line is the part worth getting + right. devctl does not write it: nothing here edits files a user owns. */ + public static let pathRemedy = + "\(defaultCLIDirectory().path) is not on your PATH, so shells and agents will not find `devctl`. " + + "Add it with: echo 'export PATH=\"$HOME/.local/bin:$PATH\"' >> ~/.zprofile" + + /** Whether the CLI directory is on the PATH the user actually has. + + Two wrong answers are easy to reach here and both were shipped. The menu + bar app is launched by Finder, so its own `ProcessInfo` PATH is launchd's + and never contains `~/.local/bin`: asking that warned everyone. A login + shell is closer but still skips `.zshrc`, which is where the directory is + usually added, so it warned everyone too, for a different reason. + `capturedPath` is the one home for the right answer. */ + public static func cliDirectoryOnUserPATH( + cliDirectory: URL = defaultCLIDirectory() + ) -> Bool { + cliDirectoryOnPATH(pathEnv: LaunchdAdmin.capturedPath(), cliDirectory: cliDirectory) + } + + /** The PATH check for a specific owner: brew's bin is put on PATH by + `brew shellenv`, so a brew-owned CLI never warrants the warning, while a + `~/.local/bin` install still does until the user adds it. */ + public static func cliDirectoryOnUserPATH(owner: CLIOwner) -> Bool { + cliDirectoryOnPATH(pathEnv: LaunchdAdmin.capturedPath(), cliDirectory: owner.cliDirectory) + } + + /** The pure half, taking the PATH to inspect so it stays testable. Callers + outside a shell want `cliDirectoryOnUserPATH` instead: passing this + process's own PATH is the mistake described above. */ public static func cliDirectoryOnPATH( pathEnv: String?, cliDirectory: URL = defaultCLIDirectory() @@ -212,6 +294,39 @@ public enum SetupPlanner { } } +/** Where the CLI lives for this install, so setup neither double-installs it nor + warns about a PATH that is already correct. Under a Homebrew cask the cask + owns a symlink in brew's bin (already on PATH, removed on cask uninstall); + writing a second copy to `~/.local/bin` would orphan it. */ +public enum CLIOwner: Equatable, Sendable { + /** devctl installs and removes the CLI itself, at this directory. */ + case devctl(directory: URL) + /** Homebrew owns the CLI at this symlink in its bin; devctl leaves it alone. */ + case homebrew(shim: URL) + + /** The directory the CLI resolves from, for the PATH check. */ + public var cliDirectory: URL { + switch self { + case .devctl(let directory): return directory + case .homebrew(let shim): return shim.deletingLastPathComponent() + } + } + + /** The CLI binary path devctl should invoke and record in hooks. */ + public var cliPath: URL { + switch self { + case .devctl(let directory): return directory.appending(path: SetupPlanner.cliBinaryName) + case .homebrew(let shim): return shim + } + } + + /** True when Homebrew, not devctl, installs and removes the CLI and daemon. */ + public var isHomebrew: Bool { + if case .homebrew = self { return true } + return false + } +} + /** One harness row on the first-run panel. */ public struct HarnessOffer: Equatable, Sendable { public var alreadyInstalled: Bool diff --git a/Sources/DevCtlKit/Update/Distribution.swift b/Sources/DevCtlKit/Update/Distribution.swift new file mode 100644 index 0000000..bdd8521 --- /dev/null +++ b/Sources/DevCtlKit/Update/Distribution.swift @@ -0,0 +1,28 @@ +import Foundation + +/** One home for the strings that identify where devctl is published and how it + updates, shared by the app, the CLI, and the update check so a rename touches + a single place. */ +public enum DevCtlDistribution: Sendable { + /** The Homebrew tap cask, fully qualified so `brew` trusts this one cask + rather than the whole tap. */ + public static let homebrewCaskToken = "quantizor/tap/devctl" + + /** Human-facing releases page, offered to non-Homebrew installs. */ + public static let releasesLatestURL = "https://github.com/quantizor/devctl/releases/latest" + + /** GitHub API for the newest non-prerelease, used by the update check. */ + public static let latestReleaseAPIURL = + "https://api.github.com/repos/quantizor/devctl/releases/latest" + + /** The command an in-app control runs in Terminal to upgrade a cask install. */ + public static var brewUpgradeCommand: String { + "brew upgrade --cask \(homebrewCaskToken)" + } + + /** The command an in-app control runs in Terminal to fully remove a cask + install, including devctl's own data via `zap`. */ + public static var brewUninstallCommand: String { + "brew uninstall --cask --zap \(homebrewCaskToken)" + } +} diff --git a/Sources/DevCtlKit/Update/UpdateCheck.swift b/Sources/DevCtlKit/Update/UpdateCheck.swift new file mode 100644 index 0000000..d38b8cd --- /dev/null +++ b/Sources/DevCtlKit/Update/UpdateCheck.swift @@ -0,0 +1,136 @@ +import Foundation + +/** The result of an update check, computed against the running version so the + same cache answers correctly whatever binary reads it. */ +public struct UpdateStatus: Codable, Equatable, Sendable { + public var checkedAt: Date + public var currentVersion: String + public var latestVersion: String + public var updateAvailable: Bool + + public init( + checkedAt: Date, currentVersion: String, latestVersion: String, updateAvailable: Bool + ) { + self.checkedAt = checkedAt + self.currentVersion = currentVersion + self.latestVersion = latestVersion + self.updateAvailable = updateAvailable + } +} + +/** Checks GitHub for a newer release, sharing one on-disk cache between the app + (which polls) and `devctl doctor` (which reads the cache and refreshes only + when stale). Every failure is silent: an update notice is a convenience, never + something worth interrupting a session or a command for. The result never + enters `AgentContext.render`; a version notice in every session start is noise + and the hook's output is meant for the servers, not devctl itself. */ +public enum UpdateCheck { + /** How long a cached answer is trusted before a refresh. A multi-hour cadence + sits far inside GitHub's unauthenticated 60-requests-per-hour budget, and + the ETag below makes an unchanged check a cheap 304. */ + public static let defaultMaxAge: TimeInterval = 6 * 3600 + + /** Cached network facts. The running version is deliberately not stored: it + is applied at read time so an upgraded binary reading an old cache still + computes the right answer. */ + struct Cache: Codable { + var checkedAt: Date + var etag: String? + var latestVersion: String + } + + static func cacheURL(paths: DevCtlPaths) -> URL { + paths.dataDir.appending(path: "update-check.json") + } + + /** The last cached answer, or nil when nothing has been checked yet. */ + public static func cachedStatus( + paths: DevCtlPaths = DevCtlPaths(), currentVersion: String = DevCtlVersion.version + ) -> UpdateStatus? { + guard let cache = AtomicFile.loadDefensively(Cache.self, from: cacheURL(paths: paths)) + else { return nil } + return status(from: cache, currentVersion: currentVersion) + } + + static func status(from cache: Cache, currentVersion: String) -> UpdateStatus { + UpdateStatus( + checkedAt: cache.checkedAt, + currentVersion: currentVersion, + latestVersion: cache.latestVersion, + updateAvailable: + SetupPlanner.compareVersions(cache.latestVersion, currentVersion) == .orderedDescending + ) + } + + /** Return the cached answer when it is younger than `maxAge`; otherwise + fetch. Doctor uses this so a machine where the app never runs still gets an + answer without the CLI hitting the network on every invocation. */ + public static func refreshIfStale( + paths: DevCtlPaths = DevCtlPaths(), maxAge: TimeInterval = defaultMaxAge, + currentVersion: String = DevCtlVersion.version, now: Date = Date() + ) async -> UpdateStatus? { + if let cache = AtomicFile.loadDefensively(Cache.self, from: cacheURL(paths: paths)), + now.timeIntervalSince(cache.checkedAt) < maxAge + { + return status(from: cache, currentVersion: currentVersion) + } + return await refresh(paths: paths, currentVersion: currentVersion, now: now) + } + + /** Fetch the newest non-prerelease and update the cache. Conditional on the + stored ETag, so an unchanged release costs a 304. Any failure returns + whatever was cached, silently. */ + @discardableResult + public static func refresh( + paths: DevCtlPaths = DevCtlPaths(), currentVersion: String = DevCtlVersion.version, + now: Date = Date() + ) async -> UpdateStatus? { + let existing = AtomicFile.loadDefensively(Cache.self, from: cacheURL(paths: paths)) + func cachedFallback() -> UpdateStatus? { + existing.map { status(from: $0, currentVersion: currentVersion) } + } + guard let url = URL(string: DevCtlDistribution.latestReleaseAPIURL) else { + return cachedFallback() + } + var request = URLRequest(url: url, timeoutInterval: 10) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("devctl", forHTTPHeaderField: "User-Agent") + if let etag = existing?.etag { request.setValue(etag, forHTTPHeaderField: "If-None-Match") } + let session = URLSession(configuration: .ephemeral) + do { + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { return cachedFallback() } + if http.statusCode == 304, var cache = existing { + cache.checkedAt = now + persist(cache, paths: paths) + return status(from: cache, currentVersion: currentVersion) + } + guard http.statusCode == 200, + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let tag = object["tag_name"] as? String + else { return cachedFallback() } + let cache = Cache( + checkedAt: now, + etag: (http.value(forHTTPHeaderField: "Etag") + ?? http.value(forHTTPHeaderField: "ETag")), + latestVersion: normalize(tag)) + persist(cache, paths: paths) + return status(from: cache, currentVersion: currentVersion) + } catch { + return cachedFallback() + } + } + + private static func persist(_ cache: Cache, paths: DevCtlPaths) { + guard let data = try? JSONCoding.encoder().encode(cache) else { return } + try? AtomicFile.write(data, to: cacheURL(paths: paths)) + } + + /** Tags are `vX.Y.Z`; the leading `v` is dropped so the string compares with + the bare `DevCtlVersion.version`. */ + static func normalize(_ tag: String) -> String { + var trimmed = tag.trimmingCharacters(in: .whitespaces) + if trimmed.first == "v" || trimmed.first == "V" { trimmed.removeFirst() } + return trimmed + } +} diff --git a/Sources/devctl/CLI.swift b/Sources/devctl/CLI.swift index 359b70b..54736ea 100644 --- a/Sources/devctl/CLI.swift +++ b/Sources/devctl/CLI.swift @@ -15,7 +15,8 @@ struct DevCtl: AsyncParsableCommand { ConfigCommand.self, Context.self, Doctor.self, Down.self, Ensure.self, Events.self, HookCommand.self, Link.self, Logs.self, Mark.self, Open.self, Register.self, Restart.self, Start.self, - Lock.self, Statusline.self, Status.self, Stop.self, Switch.self, Trust.self, Unregister.self, Up.self, + Lock.self, Statusline.self, Status.self, Stop.self, Switch.self, Trust.self, + Uninstall.self, Unregister.self, Up.self, Wait.self, Why.self, XURL.self, DaemonCommand.self, ] ) @@ -737,7 +738,10 @@ struct HookCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "hook", abstract: "Agent-harness session hooks.", - subcommands: [HookInstall.self, HookClaudeSessionStart.self, HookCursorSessionStart.self] + subcommands: [ + HookInstall.self, HookUninstall.self, HookClaudeSessionStart.self, + HookCursorSessionStart.self, + ] ) } @@ -746,6 +750,13 @@ struct HookInstall: AsyncParsableCommand { commandName: "install", abstract: "Wire the session-start context hook into an agent harness (idempotent).") + /** Absolute path to record in the hook command, overriding this binary's own + resolved path. The menu bar app passes the CLI owner's path (brew's shim + under a cask) so the hook points at a stable, on-PATH location instead of + the internal bundle path this binary resolves to. */ + @Option(help: "Absolute devctl path to record in the hook (default: this binary's path).") + var devctlPath: String? + @OptionGroup var global: GlobalOptions @Option(help: "Harness to install for: \(harnessAdapters.map(\.name).joined(separator: ", ")) (default: claude).") @@ -763,7 +774,15 @@ struct HookInstall: AsyncParsableCommand { message: "unknown harness '\(harness)'"), json: global.json) } - let devctlPath = CLISelf.path + if let override = devctlPath, !override.hasPrefix("/") { + CLIRunner.fail( + WireError( + code: .usage, + hint: "pass an absolute path, e.g. --devctl-path /opt/homebrew/bin/devctl", + message: "--devctl-path must be absolute, got '\(override)'"), + json: global.json) + } + let devctlPath = devctlPath ?? CLISelf.path do { let summary = try adapter.install(devctlPath: devctlPath) var output = summary @@ -789,6 +808,47 @@ struct HookInstall: AsyncParsableCommand { } } +struct HookUninstall: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "uninstall", + abstract: "Remove devctl's session hook from an agent harness (idempotent).") + + @OptionGroup var global: GlobalOptions + + /** Omitted means every harness, so a plain `hook uninstall` cleans up + wherever devctl wrote a hook rather than only the default one. */ + @Option(help: "Harness to remove from: \(harnessAdapters.map(\.name).joined(separator: ", ")) (default: all).") + var harness: String? + + func run() async throws { + let adapters: [any HarnessAdapter] + if let harness { + guard let adapter = harnessAdapters.first(where: { $0.name == harness }) else { + CLIRunner.fail( + WireError( + code: .usage, + hint: "supported: \(harnessAdapters.map(\.name).joined(separator: ", "))", + message: "unknown harness '\(harness)'"), + json: global.json) + } + adapters = [adapter] + } else { + adapters = harnessAdapters + } + var summaries: [String] = [] + for adapter in adapters { + do { + summaries.append(try adapter.uninstall()) + } catch { + CLIRunner.fail( + WireError(code: .internalError, message: "hook uninstall failed: \(error)"), + json: global.json) + } + } + CLIRunner.emit(WireEmpty(), json: global.json) { _ in summaries.joined(separator: "\n") } + } +} + /** Invoked by Claude Code's SessionStart hook. Reads the hook's stdin JSON for the session cwd, emits hookSpecificOutput.additionalContext, and always exits 0 quickly: a session start must never stall or fail on devctl's account. */ @@ -1336,6 +1396,45 @@ struct Doctor: AsyncParsableCommand { } } } + /** Update check: read the shared cache, refreshing only when stale, so a + machine where the menu bar app never runs still learns about a release + without doctor hitting the network every time. Silent on failure. */ + if let update = await UpdateCheck.refreshIfStale(), update.updateAvailable { + findings.append( + Finding( + detail: + "devctl \(update.latestVersion) is available (you have \(update.currentVersion)); upgrade with `brew upgrade --cask \(DevCtlDistribution.homebrewCaskToken)` or download from \(DevCtlDistribution.releasesLatestURL)", + kind: "update", severity: "info")) + } + + /** Harness hooks: report only, never repair. devctl does not edit a file + the user owns, so a drifted hook is surfaced with the exact command to + fix it and nothing more. */ + for adapter in harnessAdapters { + switch adapter.hookState() { + case .harnessAbsent: + break + case .installed(let path, let pathExists): + if pathExists { + findings.append( + Finding( + detail: "\(adapter.name) session hook installed (\(path))", + kind: "harness-hook", severity: "ok")) + } else { + findings.append( + Finding( + detail: + "\(adapter.name) session hook points at \(path), which no longer exists (run: devctl hook install --harness \(adapter.name), or devctl hook uninstall --harness \(adapter.name))", + kind: "harness-hook", severity: "warning")) + } + case .notInstalled: + findings.append( + Finding( + detail: + "\(adapter.name) detected without a devctl session hook (run: devctl hook install --harness \(adapter.name))", + kind: "harness-hook", severity: "info")) + } + } if global.json { struct Report: Codable { var findings: [Finding] @@ -1352,6 +1451,66 @@ struct Doctor: AsyncParsableCommand { } } +/** The one uninstall verb. Stops nothing that is running: the daemon shuts down + and its children survive it. Removes the background agent, then agent hooks, + then the CLI/daemon binaries devctl itself installed, keeping data unless + `--purge`. `--agent-only` stops after the agent, which is what the Homebrew + cask calls on every upgrade, so it must never touch hooks or user data. */ +struct Uninstall: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: + "Remove devctl: unregister the agent, remove hooks and the CLI (running servers keep going; data kept unless --purge).") + + @Flag(help: "Only unregister the background agent; leave hooks, CLI, and data in place.") + var agentOnly = false + + @OptionGroup var global: GlobalOptions + + @Flag(help: "Also delete devctl's data and logs.") + var purge = false + + struct UninstallResult: Codable { + var actions: [String] + var agentOnly: Bool + var purged: Bool + } + + func run() async throws { + let paths = DevCtlPaths() + var actions: [String] = [] + + /** Agent + launchd job (and any legacy home plist) first. Data purge is + handled below, not here, so ordering stays explicit and data is the + last thing to go. */ + await LaunchdAdmin.uninstall(paths: paths, purge: false) + actions.append("unregistered the background agent") + + if !agentOnly { + for adapter in harnessAdapters { + if let summary = try? adapter.uninstall() { actions.append(summary) } + } + /** Only the copies devctl installed, at `~/.local/bin`. A Homebrew + install keeps its CLI in brew's bin under brew's ownership, so + these paths simply do not exist there and this is a no-op; the + cask's own uninstall removes brew's symlink. */ + for url in [SetupPlanner.installedCLIURL(), SetupPlanner.installedDaemonSiblingURL()] + where FileManager.default.fileExists(atPath: url.path) { + try? FileManager.default.removeItem(at: url) + actions.append("removed \(url.path)") + } + } + + if purge && !agentOnly { + try? FileManager.default.removeItem(at: paths.dataDir) + try? FileManager.default.removeItem(at: paths.logsDir) + actions.append("removed data and logs") + } + + let result = UninstallResult(actions: actions, agentOnly: agentOnly, purged: purge && !agentOnly) + CLIRunner.emit(result, json: global.json) { r in r.actions.joined(separator: "\n") } + } +} + struct DaemonCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "daemon", @@ -1409,9 +1568,12 @@ struct DaemonInstall: AsyncParsableCommand { } } +/** Deprecated alias for `devctl uninstall --agent-only` (plus `--purge` for data). + Kept working because the CLI JSON contract is a public surface; the notice + goes to stderr so `--json` stdout stays clean for agents parsing it. */ struct DaemonUninstall: AsyncParsableCommand { static let configuration = CommandConfiguration( - commandName: "uninstall", abstract: "Stop the daemon and remove the launchd agent.") + commandName: "uninstall", abstract: "Deprecated: use `devctl uninstall`.") @OptionGroup var global: GlobalOptions @@ -1419,6 +1581,10 @@ struct DaemonUninstall: AsyncParsableCommand { var purge = false func run() async throws { + FileHandle.standardError.write( + Data( + "devctl: `devctl daemon uninstall` is deprecated; use `devctl uninstall` (or `devctl uninstall --agent-only` to remove just the agent)\n" + .utf8)) await LaunchdAdmin.uninstall(paths: DevCtlPaths(), purge: purge) CLIRunner.emit(WireEmpty(), json: global.json) { _ in purge ? "devctld uninstalled; data and logs removed" : "devctld uninstalled" diff --git a/Sources/devctl/HookSupport.swift b/Sources/devctl/HookSupport.swift index 17b666a..c70b1a7 100644 --- a/Sources/devctl/HookSupport.swift +++ b/Sources/devctl/HookSupport.swift @@ -69,11 +69,29 @@ enum HookContext { /** A harness adapter owns one agent harness's settings format and injection mechanism. Adding a harness = one new conformer + a registry entry (see CONTRIBUTING.md). The context payload itself is harness-agnostic. */ +/** What `devctl doctor` found about one harness's hook. Reporting only: doctor + names the fix but never edits a file the user owns. */ +enum HarnessHookState: Equatable, Sendable { + /** The harness itself is not installed on this machine; nothing to say. */ + case harnessAbsent + /** A devctl hook is present, recording this command path; `pathExists` is + whether that path still resolves to an executable on disk. */ + case installed(path: String, pathExists: Bool) + /** The harness is present but carries no devctl hook. */ + case notInstalled +} + protocol HarnessAdapter: Sendable { /** Idempotently wires the session hook into the harness's settings. Returns a human summary of what changed. */ func install(devctlPath: String) throws -> String + /** What doctor should report about this harness's hook, read-only. */ + func hookState() -> HarnessHookState var name: String { get } + /** Idempotently removes devctl's session hook from the harness's settings, + leaving everything else the file holds untouched. Returns a human summary, + including the no-op case where no devctl hook was present. */ + func uninstall() throws -> String /** The harness's own settings file. devctl edits it in place and never owns it, so everything devctl does not recognize has to survive the write. */ var settingsURL: URL { get } @@ -120,6 +138,19 @@ extension HarnessAdapter { try data.write(to: settingsURL) } + /** The harness is "present" when its settings directory exists, which is how + `SetupPlanner.harnessOffers` decides a harness is worth offering. */ + var harnessPresent: Bool { + FileManager.default.fileExists(atPath: settingsURL.deletingLastPathComponent().path) + } + + /** Extract the recorded command path from a hook command of the form + ` hook -session-start`, robust to spaces in the path. */ + func recordedPath(from command: String, suffix: String) -> String? { + guard command.hasSuffix(suffix) else { return nil } + return String(command.dropLast(suffix.count)) + } + private func refusal(because reason: String) -> WireError { WireError( code: .configInvalid, @@ -159,9 +190,14 @@ enum HookSessionCwd { clobbering existing hooks. */ struct ClaudeCodeAdapter: HarnessAdapter { let name = "claude" + /** Overridable so tests can point at a scratch file; nil means the real + `~/.claude/settings.json`. */ + var settingsURLOverride: URL? var settingsURL: URL { - FileManager.default.homeDirectoryForCurrentUser.appending(path: ".claude/settings.json") + settingsURLOverride + ?? FileManager.default.homeDirectoryForCurrentUser.appending( + path: ".claude/settings.json") } func install(devctlPath: String) throws -> String { @@ -193,6 +229,65 @@ struct ClaudeCodeAdapter: HarnessAdapter { return "Claude Code SessionStart hook installed (matcher startup|resume|clear|compact) in \(settingsURL.path)" } + func uninstall() throws -> String { + var settings = try loadSettings() + guard var hooks = settings["hooks"] as? [String: Any], + var sessionStart = hooks["SessionStart"] as? [[String: Any]] + else { + return "Claude Code SessionStart hook not present (\(settingsURL.path))" + } + var removed = false + sessionStart = sessionStart.compactMap { entry in + guard var entryHooks = entry["hooks"] as? [[String: Any]] else { return entry } + let before = entryHooks.count + entryHooks.removeAll { hook in + ((hook["command"] as? String) ?? "").contains("devctl hook claude-session-start") + } + if entryHooks.count != before { removed = true } + /** An entry left with no hooks held only devctl's, so drop it whole + rather than leaving a matcher pointing at nothing. */ + if entryHooks.isEmpty { return nil } + var updated = entry + updated["hooks"] = entryHooks + return updated + } + guard removed else { + return "Claude Code SessionStart hook not present (\(settingsURL.path))" + } + if sessionStart.isEmpty { + hooks.removeValue(forKey: "SessionStart") + } else { + hooks["SessionStart"] = sessionStart + } + if hooks.isEmpty { + settings.removeValue(forKey: "hooks") + } else { + settings["hooks"] = hooks + } + try writeSettings(settings) + return "Claude Code SessionStart hook removed from \(settingsURL.path)" + } + + func hookState() -> HarnessHookState { + guard harnessPresent else { return .harnessAbsent } + let suffix = " hook claude-session-start" + guard let settings = try? loadSettings(), + let hooks = settings["hooks"] as? [String: Any], + let sessionStart = hooks["SessionStart"] as? [[String: Any]] + else { return .notInstalled } + for entry in sessionStart { + for hook in (entry["hooks"] as? [[String: Any]]) ?? [] { + if let command = hook["command"] as? String, + let path = recordedPath(from: command, suffix: suffix) + { + return .installed( + path: path, pathExists: FileManager.default.isExecutableFile(atPath: path)) + } + } + } + return .notInstalled + } + /** Rewrite a prior install whose command path no longer resolves (e.g. a bare `devctl` that was resolved relative to cwd at install time). */ private func repairClaudeSessionStart(sessionStart: inout [[String: Any]], command: String) @@ -220,9 +315,13 @@ struct ClaudeCodeAdapter: HarnessAdapter { existing entries. Emits {additional_context} (snake_case; Cursor's schema). */ struct CursorAdapter: HarnessAdapter { let name = "cursor" + /** Overridable so tests can point at a scratch file; nil means the real + `~/.cursor/hooks.json`. */ + var settingsURLOverride: URL? var settingsURL: URL { - FileManager.default.homeDirectoryForCurrentUser.appending(path: ".cursor/hooks.json") + settingsURLOverride + ?? FileManager.default.homeDirectoryForCurrentUser.appending(path: ".cursor/hooks.json") } func install(devctlPath: String) throws -> String { @@ -250,6 +349,53 @@ struct CursorAdapter: HarnessAdapter { return "Cursor sessionStart hook installed in \(settingsURL.path)" } + func uninstall() throws -> String { + var settings = try loadSettings() + guard var hooks = settings["hooks"] as? [String: Any], + var sessionStart = hooks["sessionStart"] as? [[String: Any]] + else { + return "Cursor sessionStart hook not present (\(settingsURL.path))" + } + let before = sessionStart.count + sessionStart.removeAll { entry in + ((entry["command"] as? String) ?? "").contains("devctl hook cursor-session-start") + } + guard sessionStart.count != before else { + return "Cursor sessionStart hook not present (\(settingsURL.path))" + } + if sessionStart.isEmpty { + hooks.removeValue(forKey: "sessionStart") + } else { + hooks["sessionStart"] = sessionStart + } + if hooks.isEmpty { + settings.removeValue(forKey: "hooks") + } else { + settings["hooks"] = hooks + } + /** `version` is Cursor's own schema field, not devctl's, so it stays. */ + try writeSettings(settings) + return "Cursor sessionStart hook removed from \(settingsURL.path)" + } + + func hookState() -> HarnessHookState { + guard harnessPresent else { return .harnessAbsent } + let suffix = " hook cursor-session-start" + guard let settings = try? loadSettings(), + let hooks = settings["hooks"] as? [String: Any], + let sessionStart = hooks["sessionStart"] as? [[String: Any]] + else { return .notInstalled } + for entry in sessionStart { + if let command = entry["command"] as? String, + let path = recordedPath(from: command, suffix: suffix) + { + return .installed( + path: path, pathExists: FileManager.default.isExecutableFile(atPath: path)) + } + } + return .notInstalled + } + private func repairCursorSessionStart(sessionStart: inout [[String: Any]], command: String) -> String? { diff --git a/Tests/DevCtlCLITests/HarnessSettingsTests.swift b/Tests/DevCtlCLITests/HarnessSettingsTests.swift index 024b8ce..057dbe8 100644 --- a/Tests/DevCtlCLITests/HarnessSettingsTests.swift +++ b/Tests/DevCtlCLITests/HarnessSettingsTests.swift @@ -17,6 +17,8 @@ import Testing let name = "stub" let settingsURL: URL func install(devctlPath: String) throws -> String { "" } + func uninstall() throws -> String { "" } + func hookState() -> HarnessHookState { .harnessAbsent } } private func inScratch(_ body: (StubAdapter) throws -> Void) throws { @@ -93,4 +95,102 @@ import Testing #expect(loaded["keep"] as? String == "me") } } + + private func inScratchDir(_ body: (URL) throws -> Void) throws { + let dir = FileManager.default.temporaryDirectory + .appending(path: "devctl-harness-real-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + try body(dir) + } + + /** Install then uninstall leaves the file byte-for-byte as it started, and + preserves an unrelated setting throughout: the round trip must not clobber + what the user owns. */ + @Test func claudeInstallThenUninstallRestoresAndKeepsOtherSettings() throws { + try inScratchDir { dir in + let settings = dir.appending(path: "settings.json") + try Data(#"{"model":"opus"}"#.utf8).write(to: settings) + let adapter = ClaudeCodeAdapter(settingsURLOverride: settings) + + _ = try adapter.install(devctlPath: "/opt/homebrew/bin/devctl") + if case .installed(let path, let exists) = adapter.hookState() { + #expect(path == "/opt/homebrew/bin/devctl") + #expect(!exists) // that path is not on this machine + } else { + Issue.record("expected the hook to read as installed") + } + let afterInstall = try adapter.loadSettings() + #expect(afterInstall["model"] as? String == "opus") + + let summary = try adapter.uninstall() + #expect(summary.contains("removed")) + #expect(adapter.hookState() == .notInstalled) + let afterUninstall = try adapter.loadSettings() + #expect(afterUninstall["model"] as? String == "opus") + /** The whole `hooks` key is gone once it held only devctl's hook, so + the file is back to just what the user had. */ + #expect(afterUninstall["hooks"] == nil) + } + } + + @Test func claudeUninstallWithoutAHookIsANoOp() throws { + try inScratchDir { dir in + let settings = dir.appending(path: "settings.json") + try Data(#"{"model":"opus"}"#.utf8).write(to: settings) + let adapter = ClaudeCodeAdapter(settingsURLOverride: settings) + let summary = try adapter.uninstall() + #expect(summary.contains("not present")) + #expect(try adapter.loadSettings()["model"] as? String == "opus") + } + } + + @Test func claudeUninstallKeepsAForeignHookInTheSameEntry() throws { + try inScratchDir { dir in + let settings = dir.appending(path: "settings.json") + let adapter = ClaudeCodeAdapter(settingsURLOverride: settings) + try adapter.writeSettings([ + "hooks": [ + "SessionStart": [ + [ + "hooks": [ + ["command": "/x/devctl hook claude-session-start", "type": "command"], + ["command": "/other/tool run", "type": "command"], + ], + "matcher": "startup", + ] + ] + ] + ]) + _ = try adapter.uninstall() + let loaded = try adapter.loadSettings() + let entries = (loaded["hooks"] as? [String: Any])?["SessionStart"] as? [[String: Any]] + let commands = + (entries?.first?["hooks"] as? [[String: Any]])?.compactMap { $0["command"] as? String } + ?? [] + #expect(commands == ["/other/tool run"]) + } + } + + @Test func cursorInstallThenUninstallRoundTrips() throws { + try inScratchDir { dir in + let settings = dir.appending(path: "hooks.json") + let adapter = CursorAdapter(settingsURLOverride: settings) + _ = try adapter.install(devctlPath: "/opt/homebrew/bin/devctl") + if case .installed(let path, _) = adapter.hookState() { + #expect(path == "/opt/homebrew/bin/devctl") + } else { + Issue.record("expected the cursor hook to read as installed") + } + _ = try adapter.uninstall() + #expect(adapter.hookState() == .notInstalled) + } + } + + @Test func hookStateIsAbsentWhenTheHarnessDirectoryIsMissing() throws { + let missing = FileManager.default.temporaryDirectory + .appending(path: "devctl-absent-\(UUID().uuidString)/settings.json") + let adapter = ClaudeCodeAdapter(settingsURLOverride: missing) + #expect(adapter.hookState() == .harnessAbsent) + } } diff --git a/Tests/DevCtlKitTests/AppInstancePolicyTests.swift b/Tests/DevCtlKitTests/AppInstancePolicyTests.swift new file mode 100644 index 0000000..d7e53ce --- /dev/null +++ b/Tests/DevCtlKitTests/AppInstancePolicyTests.swift @@ -0,0 +1,90 @@ +import Foundation +import Testing + +@testable import DevCtlKit + +@Suite("AppInstancePolicy") +struct AppInstancePolicyTests { + private func instance( + _ path: String, _ pid: Int32, _ secondsAgo: TimeInterval? = nil + ) -> AppInstance { + AppInstance( + bundlePath: path, + launchDate: secondsAgo.map { Date(timeIntervalSince1970: 1_000_000 - $0) }, + processIdentifier: pid) + } + + /** The reported defect: `open -n` (which is what the DMG handoff asks for) + leaves two copies at one path, each polling and each drawing a menu bar + item. The newer one leaves. */ + @Test func aSecondCopyAtTheSamePathStandsDown() { + let incumbent = instance("/Applications/devctl.app", 100, 60) + let newcomer = instance("/Applications/devctl.app", 200, 1) + #expect(AppInstancePolicy.shouldStandDown(own: newcomer, running: [incumbent, newcomer])) + #expect(!AppInstancePolicy.shouldStandDown(own: incumbent, running: [incumbent, newcomer])) + } + + /** The one time two copies are correct. The volume copy replaces the + Applications copy and waits for it to come up before quitting, so a policy + keyed on the bundle id rather than the path would quit the copy the + handoff is waiting for and leave nothing running. */ + @Test func theDMGHandoffKeepsBothCopies() { + let volume = instance("/Volumes/devctl/devctl.app", 100, 60) + let applications = instance("/Applications/devctl.app", 200, 1) + let running = [volume, applications] + #expect(!AppInstancePolicy.shouldStandDown(own: volume, running: running)) + #expect(!AppInstancePolicy.shouldStandDown(own: applications, running: running)) + } + + /** A process must not read itself as its own twin: the list it is given + includes it. */ + @Test func aCopyIsNotItsOwnTwin() { + let only = instance("/Applications/devctl.app", 100, 60) + #expect(!AppInstancePolicy.shouldStandDown(own: only, running: [only])) + } + + /** Two copies launched in the same instant is the case a launch-date + comparison alone gets wrong: neither is older, so either both stay (the + bug) or both leave (worse). Exactly one must answer true. */ + @Test func aTieIsBrokenSoExactlyOneLeaves() { + let first = instance("/Applications/devctl.app", 100, 5) + let second = instance("/Applications/devctl.app", 200, 5) + let running = [first, second] + let leaving = [first, second].filter { + AppInstancePolicy.shouldStandDown(own: $0, running: running) + } + #expect(leaving == [second]) + } + + /** `NSRunningApplication.launchDate` is optional, so the order has to stay + total when one side or both sides are missing. Both directions are + asserted because the two processes evaluate this from opposite sides and + must not agree that the other should stay. */ + @Test func aMissingLaunchDateStillDecides() { + let dated = instance("/Applications/devctl.app", 300, 5) + let undated = instance("/Applications/devctl.app", 100, nil) + let mixed = [dated, undated] + #expect(AppInstancePolicy.shouldStandDown(own: undated, running: mixed)) + #expect(!AppInstancePolicy.shouldStandDown(own: dated, running: mixed)) + + let neither = [instance("/Applications/devctl.app", 100, nil), + instance("/Applications/devctl.app", 200, nil)] + #expect(!AppInstancePolicy.shouldStandDown(own: neither[0], running: neither)) + #expect(AppInstancePolicy.shouldStandDown(own: neither[1], running: neither)) + } + + /** Three at once, which the manual relaunch button racing the automatic call + can produce: everything but the oldest leaves rather than one pairing + cancelling out. */ + @Test func onlyTheOldestOfSeveralStays() { + let running = [ + instance("/Applications/devctl.app", 100, 30), + instance("/Applications/devctl.app", 200, 20), + instance("/Applications/devctl.app", 300, 10), + ] + let staying = running.filter { + !AppInstancePolicy.shouldStandDown(own: $0, running: running) + } + #expect(staying == [running[0]]) + } +} diff --git a/Tests/DevCtlKitTests/SetupPlannerTests.swift b/Tests/DevCtlKitTests/SetupPlannerTests.swift index 2d91512..3f9fe75 100644 --- a/Tests/DevCtlKitTests/SetupPlannerTests.swift +++ b/Tests/DevCtlKitTests/SetupPlannerTests.swift @@ -95,6 +95,87 @@ struct SetupPlannerTests { pathEnv: "/usr/bin:/bin", cliDirectory: dir)) } + /** The PATH a GUI app sees is launchd's, not the shell's, and on a stock + machine it can never contain `~/.local/bin`. Feeding it to this check + produced a warning telling the user to add a directory their shell + already had. This pins the two apart: the launchd default answers false + for a directory that a user PATH containing it answers true for, so a + caller that reaches for the process environment again fails here rather + than shipping a confident wrong warning. */ + @Test func theLaunchdDefaultPathNeverContainsTheCLIDirectory() { + let dir = SetupPlanner.defaultCLIDirectory(home: URL(fileURLWithPath: "/Users/test")) + let launchdDefault = "/usr/bin:/bin:/usr/sbin:/sbin" + #expect(!SetupPlanner.cliDirectoryOnPATH(pathEnv: launchdDefault, cliDirectory: dir)) + #expect( + SetupPlanner.cliDirectoryOnPATH( + pathEnv: "\(dir.path):" + launchdDefault, cliDirectory: dir)) + } + + /** The capture has to see what the user's shell sees, and the trap is that + it looks correct when measured wrong. A shell started from a shell + inherits its parent's PATH, so the missing entries appear anyway; only an + empty environment shows what launchd gets. This runs the real capture + that way, which is the only shape that can fail when `.zshrc` is skipped. + + Asserted against the machine's own answer rather than a fixed list: what + a developer puts in `.zshrc` is theirs, so the contract is "the capture + agrees with the user's shell", not "the capture contains pnpm". */ + @Test func theCaptureSeesWhatTheUsersShellSees() throws { + let home = FileManager.default.homeDirectoryForCurrentUser.path + func pathFrom(_ arguments: [String]) throws -> Set { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = arguments + process.environment = ["HOME": home, "PATH": "/usr/bin:/bin:/usr/sbin:/sbin"] + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + return Set( + String(decoding: data, as: UTF8.self) + .trimmingCharacters(in: .whitespacesAndNewlines) + .split(separator: ":").map(String.init)) + } + let interactive = try pathFrom(["-ilc", "echo $PATH"]) + try withKnownIssue("no .zshrc on this machine, so there is nothing to miss", isIntermittent: true) { + try #require(FileManager.default.fileExists(atPath: "\(home)/.zshrc")) + } + guard FileManager.default.fileExists(atPath: "\(home)/.zshrc") else { return } + /** The control: login-only must MISS something an interactive shell has, + or this machine cannot demonstrate the bug and the assertion below + would pass against the old implementation too. */ + let loginOnly = try pathFrom(["-lc", "echo $PATH"]) + try withKnownIssue(".zshrc adds nothing to PATH here", isIntermittent: true) { + try #require(!interactive.subtracting(loginOnly).isEmpty) + } + guard !interactive.subtracting(loginOnly).isEmpty else { return } + let captured = Set(LaunchdAdmin.capturedPath().split(separator: ":").map(String.init)) + #expect(interactive.subtracting(captured).isEmpty) + } + + /** A prefix match would call `/Users/test/.local/binaries` a hit, and a + substring match would do the same for any path containing the directory's + name. The check splits on `:` and compares whole components. */ + @Test func aPathComponentMustMatchWholeNotAsAPrefix() { + let dir = SetupPlanner.defaultCLIDirectory(home: URL(fileURLWithPath: "/Users/test")) + #expect( + !SetupPlanner.cliDirectoryOnPATH( + pathEnv: "\(dir.path)aries:/usr/bin", cliDirectory: dir)) + #expect( + !SetupPlanner.cliDirectoryOnPATH( + pathEnv: "/opt\(dir.path):/usr/bin", cliDirectory: dir)) + } + + /** The remedy is handed to someone about to edit a shell profile, so it has + to name the directory and carry the command rather than describe it. */ + @Test func theRemedyNamesTheDirectoryAndTheCommand() { + #expect(SetupPlanner.pathRemedy.contains(SetupPlanner.defaultCLIDirectory().path)) + #expect(SetupPlanner.pathRemedy.contains("export PATH=")) + #expect(SetupPlanner.pathRemedy.contains(">> ~/.zprofile")) + } + @Test func harnessOffersDefaultCheckedOnlyWhenNeeded() throws { let root = FileManager.default.temporaryDirectory .appending(path: "devctl-setup-\(UUID().uuidString)") @@ -170,4 +251,56 @@ struct SetupPlannerTests { let body = try String(contentsOf: dest.appending(path: "Contents/marker"), encoding: .utf8) #expect(body == "v1") } + + @Test func cliOwnerIsHomebrewWhenCaskBundleMatchesRunningBundle() { + let running = "/Applications/devctl.app" + let owner = SetupPlanner.resolveCLIOwner( + runningBundleRealpath: running, + caskStagedBundles: [ + (prefix: "/opt/homebrew", bundleRealpaths: [running]), + (prefix: "/usr/local", bundleRealpaths: []), + ]) + #expect(owner == .homebrew(shim: URL(fileURLWithPath: "/opt/homebrew/bin/devctl"))) + #expect(owner.isHomebrew) + #expect(owner.cliPath == URL(fileURLWithPath: "/opt/homebrew/bin/devctl")) + #expect(owner.cliDirectory == URL(fileURLWithPath: "/opt/homebrew/bin")) + } + + @Test func cliOwnerIsHomebrewUnderIntelPrefix() { + let running = "/Applications/devctl.app" + let owner = SetupPlanner.resolveCLIOwner( + runningBundleRealpath: running, + caskStagedBundles: [ + (prefix: "/opt/homebrew", bundleRealpaths: []), + (prefix: "/usr/local", bundleRealpaths: [running]), + ]) + #expect(owner == .homebrew(shim: URL(fileURLWithPath: "/usr/local/bin/devctl"))) + } + + @Test func cliOwnerFallsBackToDevctlWhenNoCaskMatches() { + let cliDir = URL(fileURLWithPath: "/Users/x/.local/bin") + let owner = SetupPlanner.resolveCLIOwner( + runningBundleRealpath: "/Applications/devctl.app", + caskStagedBundles: [ + (prefix: "/opt/homebrew", bundleRealpaths: ["/opt/homebrew/Caskroom/other.app"]), + ], + cliDirectory: cliDir) + #expect(owner == .devctl(directory: cliDir)) + #expect(!owner.isHomebrew) + #expect(owner.cliPath == cliDir.appending(path: "devctl")) + #expect(owner.cliDirectory == cliDir) + } + + @Test func pathCheckIsSatisfiedForBrewOwnerOnDefaultPATH() { + /** brew's bin is on the login PATH via `brew shellenv`, so a brew owner's + directory is found and no warning fires. */ + let brew = CLIOwner.homebrew(shim: URL(fileURLWithPath: "/opt/homebrew/bin/devctl")) + #expect( + SetupPlanner.cliDirectoryOnPATH( + pathEnv: "/opt/homebrew/bin:/usr/bin:/bin", cliDirectory: brew.cliDirectory)) + let local = CLIOwner.devctl(directory: URL(fileURLWithPath: "/Users/x/.local/bin")) + #expect( + !SetupPlanner.cliDirectoryOnPATH( + pathEnv: "/opt/homebrew/bin:/usr/bin:/bin", cliDirectory: local.cliDirectory)) + } } diff --git a/Tests/DevCtlKitTests/UpdateCheckTests.swift b/Tests/DevCtlKitTests/UpdateCheckTests.swift new file mode 100644 index 0000000..faa09ca --- /dev/null +++ b/Tests/DevCtlKitTests/UpdateCheckTests.swift @@ -0,0 +1,60 @@ +import Foundation +import Testing + +@testable import DevCtlKit + +@Suite("UpdateCheck") +struct UpdateCheckTests { + private func scratchPaths() -> (DevCtlPaths, URL) { + let dir = FileManager.default.temporaryDirectory + .appending(path: "devctl-update-\(UUID().uuidString)") + return (DevCtlPaths(dataDir: dir, logsDir: dir.appending(path: "logs")), dir) + } + + @Test func normalizeStripsLeadingV() { + #expect(UpdateCheck.normalize("v1.5.0") == "1.5.0") + #expect(UpdateCheck.normalize("1.5.0") == "1.5.0") + #expect(UpdateCheck.normalize(" v2.0.1 ") == "2.0.1") + } + + @Test func statusFlagsAvailableOnlyWhenNewer() { + let cache = UpdateCheck.Cache(checkedAt: Date(), etag: nil, latestVersion: "1.5.0") + #expect(UpdateCheck.status(from: cache, currentVersion: "1.4.0").updateAvailable) + #expect(!UpdateCheck.status(from: cache, currentVersion: "1.5.0").updateAvailable) + #expect(!UpdateCheck.status(from: cache, currentVersion: "1.6.0").updateAvailable) + } + + @Test func cachedStatusRoundTripsThroughDisk() throws { + let (paths, dir) = scratchPaths() + defer { try? FileManager.default.removeItem(at: dir) } + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let cache = UpdateCheck.Cache(checkedAt: Date(), etag: "abc", latestVersion: "2.0.0") + try AtomicFile.write( + JSONCoding.encoder().encode(cache), to: UpdateCheck.cacheURL(paths: paths)) + let status = UpdateCheck.cachedStatus(paths: paths, currentVersion: "1.0.0") + #expect(status?.latestVersion == "2.0.0") + #expect(status?.updateAvailable == true) + } + + @Test func cachedStatusIsNilWithoutACache() { + let (paths, dir) = scratchPaths() + defer { try? FileManager.default.removeItem(at: dir) } + #expect(UpdateCheck.cachedStatus(paths: paths) == nil) + } + + /** A cache younger than maxAge is returned as-is, never triggering a fetch, + which is what keeps doctor and the app off the network on every call. */ + @Test func refreshIfStaleReturnsFreshCacheWithoutFetching() async throws { + let (paths, dir) = scratchPaths() + defer { try? FileManager.default.removeItem(at: dir) } + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + let now = Date() + let cache = UpdateCheck.Cache(checkedAt: now, etag: nil, latestVersion: "9.9.9") + try AtomicFile.write( + JSONCoding.encoder().encode(cache), to: UpdateCheck.cacheURL(paths: paths)) + let status = await UpdateCheck.refreshIfStale( + paths: paths, maxAge: 3600, currentVersion: "1.0.0", now: now) + #expect(status?.latestVersion == "9.9.9") + #expect(status?.updateAvailable == true) + } +} diff --git a/docs/cli-contract.md b/docs/cli-contract.md index 015edbd..7216f04 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -74,7 +74,7 @@ Filled in per phase as each lands; golden tests reference the examples in this f - A server may declare `watch`: project-relative files it reads at boot and does not reload itself (`"watch": ["vite.config.ts"]`). A change to one restarts that server. A server whose framework already reloads its own config declares nothing and behaves exactly as before. Paths are literal, relative to the project root, and may not exist yet (their appearance is a change); a glob, an absolute path, a path outside the project, or a directory is a `config check` warning and is ignored. The baseline is taken after the server has been up for a settle window, so a config the server writes during its own boot does not bounce it, and a change must hold still for a quiet window, so one save touching several files is one restart. A restart is refused while a resource the server declares is held, and the pending change fires when that hold releases rather than being dropped. Repeated restarts inside a short window suspend the watch with a `sys` log line naming the paths, since devctl cannot tell a server rewriting its own file from a person saving repeatedly; an explicit `devctl restart` re-arms it. `DEVCTL_NO_WATCH=1` disables the sweep for the whole daemon. - `devctl config init [--dry-run] [--force] [--host H] [--name N --cmd word … [--port P]] --json` → `{check: CheckResult, content, notRecovered?, path, written}`. Writes devservers.json from the servers the daemon already knows, which is the way back from losing a gitignored file. Refuses an existing file with `already-exists` unless `--force`; `--dry-run` returns the content and writes nothing. The projection writes only what the file declares: an effective or rebound port, a worktree-derived host, a materialized url, an absolute icon path, and the port and host keys devctl injects into the environment are all dropped, so the file stays portable to another checkout on another machine. `lifecycle` has no runtime counterpart and cannot be recovered; it is named in `notRecovered` rather than silently lost. The written file is indented, unlike every wire frame. - `devctl config check --json` also reports `effectiveHost` and `effectiveHostReason` (`linked-worktree`, `local-overlay`, `server-override`) when a start from this directory would use a host other than the declared one, plus `serverHosts` for the servers that differ from the project. A linked worktree gets an ephemeral `worktree- CFBundleShortVersionString $VERSION + CFBundleVersion + $VERSION LSMinimumSystemVersion 14.0 LSUIElement diff --git a/scripts/make-dmg-background.swift b/scripts/make-dmg-background.swift index 42d0108..f14b3b6 100644 --- a/scripts/make-dmg-background.swift +++ b/scripts/make-dmg-background.swift @@ -6,7 +6,9 @@ import AppKit import Foundation let width: CGFloat = 560 -let height: CGFloat = 380 +/** Kept in step with the container window bounds in make-dmg.sh, which is the + only place the window size is actually set. */ +let height: CGFloat = 400 guard CommandLine.arguments.count > 1 else { FileHandle.standardError.write(Data("make-dmg-background: missing output directory\n".utf8)) @@ -48,25 +50,46 @@ func render(scale: CGFloat) -> Data? { /** The context has a bottom-left origin, so each rect's y is measured up from the bottom while the text itself lays out downward from the rect's top. - The Finder icon sits centered at 125 points down from the window's top. */ + + Reading order is the instruction, then the thing to do it to, then what + the thing is: "the app" sits directly above the icon it names, and the + supporting text below the icon reads as one block. + + The one number nothing here can read is the icon's position, which lives + in make-dmg.sh. It is centered at 162, so its box runs 98 to 226, and + Finder draws the item NAME beneath that: measured on a mounted volume, + those glyphs sit 16 to 29 points below the icon box with a background a + few points further, so the label owns roughly 242 to 258. That label is + invisible in this PNG, and text placed to clear the icon alone once + shipped overlapping the word "devctl". Move the icon and every offset + below has to move with it. */ + let tagline = NSAttributedString( + string: "An agent-friendly coordinator for many devservers\nand their unique configurations.", + attributes: [ + .font: NSFont.systemFont(ofSize: 13.5, weight: .regular), + .foregroundColor: NSColor(calibratedWhite: 0.302, alpha: 1), + .paragraphStyle: paragraph, + ]) + tagline.draw(in: NSRect(x: 40, y: height - 282 - 36, width: width - 80, height: 36)) + let heading = NSAttributedString( - string: "Double-click to install", + string: "Double-click the app to install", attributes: [ .font: NSFont.systemFont(ofSize: 19, weight: .semibold), .foregroundColor: NSColor(calibratedWhite: 0.153, alpha: 1), .paragraphStyle: paragraph, ]) - heading.draw(in: NSRect(x: 40, y: height - 240 - 28, width: width - 80, height: 28)) + heading.draw(in: NSRect(x: 40, y: height - 40 - 28, width: width - 80, height: 28)) let detail = NSAttributedString( string: - "It sets up the command line tool, background service, and menu bar app.\nYou will see exactly what changes and confirm before anything runs.", + "The package comes with a menu bar app, CLI, and background service.\nConfirm preferences on the install screen.", attributes: [ - .font: NSFont.systemFont(ofSize: 12.5, weight: .regular), + .font: NSFont.systemFont(ofSize: 11.5, weight: .regular), .foregroundColor: NSColor(calibratedWhite: 0.435, alpha: 1), .paragraphStyle: paragraph, ]) - detail.draw(in: NSRect(x: 40, y: height - 276 - 44, width: width - 80, height: 44)) + detail.draw(in: NSRect(x: 40, y: height - 326 - 32, width: width - 80, height: 32)) NSGraphicsContext.restoreGraphicsState() return rep.representation(using: .png, properties: [:]) diff --git a/scripts/make-dmg.sh b/scripts/make-dmg.sh index b9b8fe4..086c6f2 100755 --- a/scripts/make-dmg.sh +++ b/scripts/make-dmg.sh @@ -12,10 +12,16 @@ # present but unused. Set DEVCTL_DMG_REQUIRE_LAYOUT=1 to make that a hard error, # so a release build never quietly loses the instructions. # -# A Developer ID image is notarized, stapled and quarantine-stamped by default, -# so opening it locally is what a user who downloaded it gets. SKIP_NOTARIZE=1 -# trades that fidelity for a faster loop; DEVCTL_DMG_QUARANTINE=0 drops the -# download stamp. +# Two kinds of image. A TEST image is what an external contributor gets: signed +# with whatever identity is available (ad-hoc when the keychain has none) and not +# notarized, so Gatekeeper blocks it on other machines, which is fine for local +# testing. A REAL image is notarized and stapled, and is reserved for where the +# notarytool credentials live: the maintainer's machine (the `devctl-notary` +# keychain profile) or CI (App Store Connect API key env). make dmg notarizes +# automatically when those credentials are reachable; DEVCTL_NOTARIZE=1 demands +# the real path (failing if they are missing) and the release build sets +# DEVCTL_REQUIRE_SIGNING=1, which implies it. SKIP_NOTARIZE=1 forces the fast +# local loop; DEVCTL_DMG_QUARANTINE=0 drops the download stamp. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" @@ -83,13 +89,13 @@ on run argv set current view of container window to icon view set toolbar visible of container window to false set statusbar visible of container window to false - set the bounds of container window to {200, 120, 760, 500} + set the bounds of container window to {200, 120, 760, 520} set viewOptions to the icon view options of container window set arrangement of viewOptions to not arranged set icon size of viewOptions to 128 set text size of viewOptions to 12 set background picture of viewOptions to file ".background:background.tiff" - set position of item "devctl.app" of container window to {280, 125} + set position of item "devctl.app" of container window to {280, 162} update without registering applications delay 1 close @@ -120,6 +126,26 @@ hdiutil convert "$RW_DMG" -format UDZO -imagekey zlib-level=9 -ov -o "$DMG" > /d rm -f "$RW_DMG" rm -rf "$STAGE" +# Whether notarytool credentials are reachable. Real (notarized) images are built +# where these live: this machine's `devctl-notary` keychain profile, or CI's App +# Store Connect API key env. A contributor without them still gets a signed TEST +# image and a clear note, never a hard failure. A false negative here costs the +# maintainer only a `DEVCTL_NOTARIZE=1`; it never blocks a contributor. +notary_creds_available() { + if [[ -n "${APPLE_API_KEY_ID:-}" && -n "${APPLE_API_ISSUER:-}" \ + && ( -n "${APPLE_API_KEY_PATH:-}" || -n "${APPLE_API_KEY_BASE64:-}" ) ]]; then + return 0 + fi + local profile="${NOTARY_KEYCHAIN_PROFILE:-devctl-notary}" + # notarytool's store-credentials service name has varied across Xcode; probe + # the known spellings by label and service. + security find-generic-password -l "$profile" >/dev/null 2>&1 && return 0 + security find-generic-password -s "com.apple.gke.notary.tool.saved-creds.$profile" \ + >/dev/null 2>&1 && return 0 + security find-generic-password -s "com.apple.gke.notary.tool" >/dev/null 2>&1 && return 0 + return 1 +} + if [[ "$IDENTITY" == "-" ]]; then echo "note: ad-hoc image, so no notarization (it requires a Developer ID signature)." >&2 echo " Gatekeeper will refuse this on any machine that did not build it." >&2 @@ -127,16 +153,27 @@ else codesign --force --sign "$IDENTITY" "$DMG" echo "signed DMG with $IDENTITY" - # Notarize by default. An unnotarized Developer ID image is worse than useless - # for testing: Gatekeeper blocks it outright once quarantined, so a build that - # skipped this step cannot show what a user actually sees. - if [[ "${SKIP_NOTARIZE:-0}" == "1" ]]; then + # A real release is signed AND notarized; a contributor build is signed only. + # DEVCTL_NOTARIZE=1 (and the release build's DEVCTL_REQUIRE_SIGNING=1) demands + # the real path, so a missing credential fails loudly rather than shipping a + # test image. SKIP_NOTARIZE=1 forces the fast local loop. Otherwise notarize + # only when the credentials are actually reachable. + require_notarize=0 + [[ "${DEVCTL_NOTARIZE:-}" == "1" || "${DEVCTL_REQUIRE_SIGNING:-0}" == "1" ]] && require_notarize=1 + + if [[ "${SKIP_NOTARIZE:-0}" == "1" && "$require_notarize" != "1" ]]; then echo "note: SKIP_NOTARIZE=1. Quarantined, Gatekeeper will block this image." >&2 - else + elif [[ "$require_notarize" == "1" ]] || notary_creds_available; then + # notarize.sh fails if the credentials are missing, which is the point when a + # real build was demanded. NOTARIZE_TARGET="$DMG" "$ROOT/scripts/notarize.sh" # Gatekeeper assesses the image and the app inside it separately, so check # the image here with the policy Finder uses when opening a download. spctl -a -vvv -t open --context context:primary-signature "$DMG" + else + echo "note: signed TEST DMG, not notarized (no notarytool credentials found)." >&2 + echo " Real notarized releases are built on the maintainer's machine or in CI." >&2 + echo " Quarantined, Gatekeeper will block this image; that is expected for a test build." >&2 fi fi diff --git a/scripts/signing-identity.sh b/scripts/signing-identity.sh index fabde2b..05b22b7 100755 --- a/scripts/signing-identity.sh +++ b/scripts/signing-identity.sh @@ -25,6 +25,14 @@ identities="$(security find-identity -v -p codesigning 2>/dev/null \ | sed -n 's/.*"\(Developer ID Application: [^"]*\)".*/\1/p' || true)" if [[ -z "$identities" ]]; then + # Release builds set DEVCTL_REQUIRE_SIGNING=1 so a missing certificate fails + # loudly rather than silently shipping an ad-hoc image Gatekeeper will disable. + # make-app-bundle.sh enforces the same, since a $(shell ...) call in the + # Makefile swallows this exit code. + if [[ "${DEVCTL_REQUIRE_SIGNING:-0}" == "1" ]]; then + echo "signing-identity: no Developer ID Application certificate found and DEVCTL_REQUIRE_SIGNING=1; refusing to fall back to ad-hoc." >&2 + exit 1 + fi echo - exit 0 fi diff --git a/scripts/smoke-cask.sh b/scripts/smoke-cask.sh new file mode 100755 index 0000000..aabc06b --- /dev/null +++ b/scripts/smoke-cask.sh @@ -0,0 +1,159 @@ +#!/bin/zsh +# Homebrew cask gate. Two tiers. +# +# Default (non-destructive): builds a local DMG, drops the cask into a throwaway +# local tap created with `brew tap-new --no-git` (no GitHub, no network), rewrites +# the URL to that local file:// DMG with its real checksum, and runs style, audit, +# info, a dry-run install, and a fetch (which verifies the sha256). Its only +# footprint is a directory under brew's Taps, removed on exit. Safe any time. +# +# --install (destructive): actually `brew install --cask` into a TEMP --appdir so +# artifact staging, the Caskroom backlink, the binary symlink, and the uninstall +# directives run somewhere harmless, then uninstalls. Refuses to run if a devctl +# cask is already installed, and leaves nothing behind. It does NOT install to the +# real /Applications: devctl's own runtime behavior at the real path is covered by +# smoke.sh and smoke-launchd.sh; conflating the two tiers here would need the real +# /Applications, which this script deliberately avoids. The uninstall directive +# runs the real `devctl uninstall --agent-only`, so it will quit and unregister an +# already-running app's agent (recoverable at next launch), the same real-state +# cost smoke-launchd.sh accepts. +# +# The `--new-cask` audit (gktool scan for signing, min-OS enforcement) only passes +# on a Developer ID signed DMG, so it runs only when the DMG is Developer ID +# signed and is skipped with a note on an ad-hoc local build. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +INSTALL_TIER=0 +[[ "${1:-}" == "--install" ]] && INSTALL_TIER=1 + +TAP="quantizor/tap" +TOKEN="quantizor/tap/devctl" +TAP_DIR="$(brew --repository)/Library/Taps/quantizor/homebrew-tap" +WORK="$(mktemp -d /tmp/devctl-cask-smoke.XXXXXX)" + +fail() { echo "CASK SMOKE FAIL: $1" >&2; exit 1 } +pass() { echo " ok: $1" } +note() { echo " note: $1" } + +cleanup() { + brew untap "$TAP" >/dev/null 2>&1 || true + rm -rf "$WORK" +} +trap cleanup EXIT + +if [[ "$INSTALL_TIER" == "1" ]]; then + # The tier does real, disruptive things on this machine: the cask's quit: + # directive quits an already-running menu bar app, and the uninstall + # early_script unregisters its agent. Require an explicit opt-in so it is never + # run by reflex. + if [[ "${DEVCTL_CASK_DESTRUCTIVE:-0}" != "1" ]]; then + fail "the --install tier quits and unregisters your running app; re-run with DEVCTL_CASK_DESTRUCTIVE=1 to accept that" + fi + if brew list --cask devctl >/dev/null 2>&1; then + fail "a devctl cask is already installed; refusing to run the destructive tier over it" + fi +fi + +# A real DMG to point the cask at. Reuse dist/ if a matching one is already built, +# else build one (ad-hoc + no notarize is fine here; this tests cask mechanics, +# not Gatekeeper). SKIP_NOTARIZE keeps the loop fast. +echo "building the app + DMG (this can take a minute)…" +SKIP_NOTARIZE=1 DEVCTL_DMG_QUARANTINE=0 make -C "$ROOT" dmg >/dev/null 2>&1 \ + || fail "make dmg failed; run it directly to see why" +VERSION="$("$ROOT/.build/release/devctl" --version 2>/dev/null || echo 0.0.0)" +DMG="$ROOT/dist/devctl-${VERSION}.dmg" +[[ -f "$DMG" ]] || fail "expected DMG at $DMG" +SHA="$(shasum -a 256 "$DMG" | awk '{print $1}')" +pass "built devctl-${VERSION}.dmg (sha ${SHA:0:12}…)" + +# Fresh local tap, no git, no network. Delete the CI workflows tap-new scaffolds. +brew untap "$TAP" >/dev/null 2>&1 || true +brew tap-new "$TAP" --no-git >/dev/null 2>&1 || fail "brew tap-new" +rm -rf "$TAP_DIR/.github" 2>/dev/null || true +mkdir -p "$TAP_DIR/Casks" + +# Generate the local cask from the canonical template: real version + sha, and a +# file:// URL to the DMG just built. This is a scratch artifact under brew's Taps, +# regenerated every run, so a generated rewrite is appropriate here. +/usr/bin/python3 - "$ROOT/packaging/homebrew/devctl.rb" "$TAP_DIR/Casks/devctl.rb" \ + "$VERSION" "$SHA" "$ROOT/dist" <<'PY' +import re, sys +src, dst, version, sha, dist = sys.argv[1:6] +text = open(src).read() +text = re.sub(r'version "[^"]*"', f'version "{version}"', text, count=1) +text = re.sub(r'sha256 "[^"]*"', f'sha256 "{sha}"', text, count=1) +text = re.sub( + r'url "[^"]*"', + f'url "file://{dist}/devctl-#{{version}}.dmg"', + text, count=1) +open(dst, "w").write(text) +PY +pass "generated local cask (file:// DMG)" + +echo "=== brew style ===" +brew style "$TAP" >/dev/null 2>&1 || fail "brew style reported offenses (run: brew style $TAP)" +pass "brew style clean" + +echo "=== brew audit ===" +# The strict --new-cask audit runs `gktool scan`, which requires a NOTARIZED +# image (Developer ID signing alone does not satisfy Gatekeeper). Local builds +# use SKIP_NOTARIZE for speed, so --new-cask runs in CI on the release DMG, not +# here. The plain audit still catches token, URL, and stanza problems. +brew audit --cask "$TOKEN" >/dev/null 2>&1 \ + || note "brew audit reported issues (some are expected for a file:// local build)" +note "skipped --new-cask audit here; it runs in CI against the notarized release DMG" + +echo "=== brew info / dry-run / fetch ===" +brew info --cask "$TOKEN" >/dev/null 2>&1 || fail "brew info" +pass "brew info" +brew install --cask --dry-run "$TOKEN" >/dev/null 2>&1 || fail "brew install --dry-run" +pass "brew install --dry-run" +# fetch downloads (copies the file://) and verifies the sha256 against the cask. +brew fetch --cask "$TOKEN" >/dev/null 2>&1 || fail "brew fetch (sha256 mismatch?)" +pass "brew fetch verified the checksum" + +if [[ "$INSTALL_TIER" == "0" ]]; then + echo "CASK SMOKE PASS (non-destructive; --install for the real install tier)" + exit 0 +fi + +echo "=== install tier (temp --appdir) ===" +APPDIR="$WORK/Applications" +mkdir -p "$APPDIR" +brew install --cask --appdir="$APPDIR" "$TOKEN" >/dev/null 2>&1 || fail "brew install --cask" +[[ -d "$APPDIR/devctl.app" ]] || fail "app not staged into --appdir" +pass "installed into $APPDIR" + +# The Caskroom keeps a symlink back to the moved app; realpath equality is exactly +# what SetupPlanner's brew detection keys on. +CASK_APP="$(/bin/ls -d "$(brew --caskroom)/devctl"/*/devctl.app 2>/dev/null | head -1)" +[[ -n "$CASK_APP" ]] || fail "no Caskroom entry for devctl" +[[ "$(/usr/bin/readlink "$CASK_APP" 2>/dev/null || echo "")" == "$APPDIR/devctl.app" \ + || "$(cd "$CASK_APP" && pwd -P)" == "$(cd "$APPDIR/devctl.app" && pwd -P)" ]] \ + || fail "Caskroom entry does not resolve to the installed app" +pass "Caskroom backlink resolves to the installed app" + +# The binary symlink always lands in brew's bin (binarydir is not overridable). +BREW_CLI="$(brew --prefix)/bin/devctl" +[[ -L "$BREW_CLI" ]] || fail "CLI symlink not created in brew's bin" +pass "CLI symlink at $BREW_CLI" + +# brew quarantines the app it stages from a DMG. A notarized release passes +# Gatekeeper, but a local SKIP_NOTARIZE build does not, so the uninstall +# early_script could not execute the CLI. Clear the quarantine to stand in for +# the notarized release the real cask ships. +xattr -dr com.apple.quarantine "$APPDIR/devctl.app" 2>/dev/null || true + +echo "=== uninstall ===" +# --force so a missing/blocked early_script cannot wedge the Caskroom (the same +# recovery the cask caveats name). No --appdir: it is an install-time flag, and +# uninstall reads the staged location from the receipt. The early_script runs the +# real `devctl uninstall --agent-only`, which quits and unregisters the app's +# agent on this machine (recoverable at next launch). +brew uninstall --cask --force "$TOKEN" >/dev/null 2>&1 || fail "brew uninstall" +[[ ! -e "$APPDIR/devctl.app" ]] || fail "app survived uninstall" +[[ ! -e "$BREW_CLI" ]] || fail "CLI symlink survived uninstall" +pass "uninstall removed the app and the CLI symlink" + +echo "CASK SMOKE PASS (install tier)" diff --git a/scripts/smoke-deeplink.sh b/scripts/smoke-deeplink.sh index 90e9bd4..251d49a 100755 --- a/scripts/smoke-deeplink.sh +++ b/scripts/smoke-deeplink.sh @@ -16,6 +16,12 @@ SLUG="$(basename "$PROJECT")" SERVER="web" LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" DEVCTL="$BIN/devctl" +# pgrep/pkill -f match their argument as a regex, where `.` is any character, so +# `devctl.app` would also match an unrelated `devctlXapp`. Escape every dot in +# the bundle path (including any in $ROOT) so the twin-count assertion counts +# exactly this build's copies. APP is set to the same path below. +APP_PATH="$ROOT/devctl.app" +APP_MATCH="${APP_PATH//./\\.}/Contents/MacOS/devctl-app" fail() { echo "DEEPLINK SMOKE FAIL: $1" >&2; exit 1 } pass() { echo " ok: $1" } @@ -31,7 +37,11 @@ cleanup() { "$DEVCTL" stop "$SERVER" --project "$PROJECT" --json >/dev/null 2>&1 || true "$DEVCTL" unregister "$SERVER" --project "$PROJECT" --json >/dev/null 2>&1 || true [[ -n "${LOG_STREAM_PID:-}" ]] && kill "$LOG_STREAM_PID" 2>/dev/null || true - killall -9 DevCtlApp 2>/dev/null || true + # By bundle path, never by process name: the executable is devctl-app for the + # copy built here AND for the one the user has in /Applications, and killing + # theirs is not this script's business. `killall DevCtlApp` also matched + # neither, so the copy this script launched used to outlive the run. + pkill -f "$APP_MATCH" 2>/dev/null || true if [[ -d /Applications/devctl.app ]]; then "$LSREGISTER" -f /Applications/devctl.app >/dev/null 2>&1 || true fi @@ -48,7 +58,7 @@ echo "building..." swift build --package-path "$ROOT" > /dev/null swift build --package-path "$ROOT" --product DevCtlApp > /dev/null "$ROOT/scripts/make-app-bundle.sh" - debug -APP="$ROOT/devctl.app" +APP="$APP_PATH" SCHEME="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleURLTypes:0:CFBundleURLSchemes:0' "$APP/Contents/Info.plist")" [[ "$SCHEME" == "devctl" ]] || fail "scheme was $SCHEME" pass "Info.plist declares devctl://" @@ -71,6 +81,21 @@ sleep 1 open -a "$APP" sleep 2 + +# A second copy of one bundle doubles the menu bar item, the poll and every +# crash notification. `open -n` asks for exactly that, and is what the DMG +# handoff asks for by name, so the newcomer has to stand down on its own. The +# count is path-scoped: an installed /Applications copy shares the bundle id and +# is allowed to keep running alongside this one. +count_app() { pgrep -f "$APP_MATCH" | wc -l | tr -d ' ' } +BEFORE_TWIN="$(count_app)" +[[ "$BEFORE_TWIN" == "1" ]] || fail "wanted 1 copy of $APP before the twin probe, saw $BEFORE_TWIN" +open -n "$APP" +sleep 4 +AFTER_TWIN="$(count_app)" +[[ "$AFTER_TWIN" == "1" ]] || fail "open -n left $AFTER_TWIN copies of $APP running; the launch stand-down did not fire" +pass "open -n stood down; one copy still running" + open -a "$APP" "devctl://ensure/${SLUG}/${SERVER}" PHASE=missing for i in {1..40}; do @@ -81,9 +106,10 @@ done [[ "$PHASE" == "running" ]] || fail "warm ensure left phase $PHASE" pass "warm open ensure -> running" -killall DevCtlApp 2>/dev/null || true -# Bundle executable is named devctl-app; kill by that too. -killall devctl-app 2>/dev/null || true +# 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_MATCH" 2>/dev/null || true sleep 1 open -a "$APP" "devctl://stop/${SLUG}/${SERVER}" PHASE=missing diff --git a/scripts/smoke-launchd.sh b/scripts/smoke-launchd.sh index be338b5..cc31c69 100755 --- a/scripts/smoke-launchd.sh +++ b/scripts/smoke-launchd.sh @@ -87,8 +87,28 @@ sleep 1 "$DEVCTL" daemon status | grep -q "pid" || fail "daemon not resurrected" pass "auto-bootstrap resurrects a dead daemon" -"$DEVCTL" daemon uninstall > /dev/null -[[ ! -f "$HOME/Library/LaunchAgents/${LABEL}.plist" ]] || fail "plist survived uninstall" -pass "uninstall" +# Deprecated alias: `daemon uninstall` still tears the agent down and warns on +# stderr, keeping stdout clean for --json consumers. (This removes the agent.) +ALIAS_ERR="$WORK/alias.err" +set +e +ALIAS_OUT="$("$DEVCTL" daemon uninstall --json 2>"$ALIAS_ERR")" +set -e +grep -q "deprecated" "$ALIAS_ERR" || fail "daemon uninstall did not warn on stderr (got: $(cat "$ALIAS_ERR"))" +if echo "$ALIAS_OUT" | grep -q "deprecated"; then + fail "deprecation notice leaked into stdout: $ALIAS_OUT" +fi +[[ ! -f "$HOME/Library/LaunchAgents/${LABEL}.plist" ]] || fail "plist survived the deprecated alias" +pass "deprecated 'daemon uninstall' warns on stderr, stdout clean, plist gone" + +# The new one-verb uninstall: --agent-only removes just the agent (what the cask +# calls on every upgrade) and reports a stable JSON shape. Full uninstall's hook +# and CLI removal is covered by unit tests, which do not touch the real machine. +"$DEVCTL" daemon install --legacy > /dev/null || fail "reinstall before the agent-only test" +AGENT_ONLY_OUT="$("$DEVCTL" uninstall --agent-only --json 2>/dev/null)" +echo "$AGENT_ONLY_OUT" | /usr/bin/python3 -c \ + 'import json,sys; d=json.load(sys.stdin); assert d["agentOnly"] is True and d["purged"] is False, d' \ + || fail "uninstall --agent-only JSON shape: $AGENT_ONLY_OUT" +[[ ! -f "$HOME/Library/LaunchAgents/${LABEL}.plist" ]] || fail "plist survived uninstall --agent-only" +pass "uninstall --agent-only removed the agent (hooks and data untouched)" echo "LAUNCHD-SMOKE PASS"