diff --git a/.changeset/dmg-smappservice-rebind.md b/.changeset/dmg-smappservice-rebind.md new file mode 100644 index 0000000..ce34a03 --- /dev/null +++ b/.changeset/dmg-smappservice-rebind.md @@ -0,0 +1,5 @@ +--- +"devctl": minor +--- + +Ship a DMG installer that places the app in Applications and registers the Login Items agent, keep resource locks across a daemon crash so paused servers resume, and rebind the helper after ad-hoc upgrades so the daemon returns in seconds instead of stalling on a codesign throttle. diff --git a/.github/workflows/release-dmg.yml b/.github/workflows/release-dmg.yml new file mode 100644 index 0000000..e7d8c53 --- /dev/null +++ b/.github/workflows/release-dmg.yml @@ -0,0 +1,110 @@ +# Notarized DMG artifact for GitHub Releases. +# Triggered when the Ubuntu Changesets job publishes a release (tag vX.Y.Z). +# Requires repository secrets: +# APPLE_DEVELOPER_ID_P12_BASE64, APPLE_DEVELOPER_ID_P12_PASSWORD +# APPLE_SIGN_IDENTITY (e.g. "Developer ID Application: Name (TEAMID)") +# APPLE_API_KEY_BASE64, APPLE_API_KEY_ID, APPLE_API_ISSUER +name: Release DMG + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Tag to build (e.g. v1.2.0); defaults to latest release tag" + required: false + +concurrency: + group: release-dmg-${{ github.event.release.tag_name || github.ref }} + cancel-in-progress: false + +permissions: + contents: write + +jobs: + dmg: + name: Build and notarize DMG + runs-on: macos-26 + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name || inputs.tag || github.ref }} + + - name: Cache SwiftPM + uses: actions/cache@v4 + with: + path: | + .build + ~/Library/Caches/org.swift.swiftpm + key: spm-release-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('Package.resolved') }} + restore-keys: | + spm-release-${{ runner.os }}-${{ runner.arch }}- + + - name: Import Developer ID certificate + env: + P12_BASE64: ${{ secrets.APPLE_DEVELOPER_ID_P12_BASE64 }} + P12_PASSWORD: ${{ secrets.APPLE_DEVELOPER_ID_P12_PASSWORD }} + run: | + set -euo pipefail + if [[ -z "${P12_BASE64:-}" || -z "${P12_PASSWORD:-}" ]]; then + echo "Missing APPLE_DEVELOPER_ID_P12_BASE64 or APPLE_DEVELOPER_ID_P12_PASSWORD" >&2 + exit 1 + fi + # RUNNER_TEMP is job-private; mktemp only randomizes trailing Xs, so + # a ".XXXXXX.p12" template would be a fixed path. + CERT_PATH="$(mktemp "$RUNNER_TEMP/devctl-cert.XXXXXX")" + KEYCHAIN_PATH="$RUNNER_TEMP/devctl-signing.keychain-db" + KEYCHAIN_PASSWORD="$(openssl rand -base64 24)" + echo "$P12_BASE64" | base64 -D > "$CERT_PATH" + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security import "$CERT_PATH" -P "$P12_PASSWORD" -A -t cert -f pkcs12 \ + -k "$KEYCHAIN_PATH" + security list-keychain -d user -s "$KEYCHAIN_PATH" $(security list-keychain -d user | tr -d '"') + security set-key-partition-list -S apple-tool:,apple:,codesign: -s \ + -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + rm -f "$CERT_PATH" + echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> "$GITHUB_ENV" + echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> "$GITHUB_ENV" + + - name: Build signed DMG + env: + # A released image must carry the double-click instructions, so a + # runner that cannot drive Finder fails here instead of shipping a + # bare icon view. + DEVCTL_DMG_REQUIRE_LAYOUT: "1" + SIGN_IDENTITY: ${{ secrets.APPLE_SIGN_IDENTITY }} + run: | + set -euo pipefail + : "${SIGN_IDENTITY:?set APPLE_SIGN_IDENTITY secret}" + make dmg + + - name: Notarize and staple + env: + APPLE_API_KEY_BASE64: ${{ secrets.APPLE_API_KEY_BASE64 }} + APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }} + APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }} + run: scripts/notarize.sh + + - name: Upload DMG to GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ github.event.release.tag_name || inputs.tag }} + run: | + set -euo pipefail + DMG="$(ls -t dist/devctl-*.dmg | head -1)" + if [[ -z "${TAG:-}" ]]; then + TAG="$(git describe --tags --abbrev=0)" + fi + gh release upload "$TAG" "$DMG" --clobber + + - name: Cleanup keychain + if: always() + run: | + if [[ -n "${KEYCHAIN_PATH:-}" ]]; then + security delete-keychain "$KEYCHAIN_PATH" || true + fi diff --git a/.gitignore b/.gitignore index 922b61c..928287a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ node_modules/ .build/ .swiftpm/ devctl.app/ +dist/ *.corrupt-* .DS_Store diff --git a/BACKLOG.md b/BACKLOG.md index b00ff20..afe5dca 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -2,13 +2,12 @@ Open work only; entries are removed by the change that resolves them. -- DMG distribution with one-click install (Evan, 2026-07-19): a simple dmg builder (scripts/make-dmg.sh from the existing bundle pipeline; hdiutil + a drag-to-Applications layout) where first launch of devctl.app completes setup itself: install the CLI/daemon binaries from the app bundle's Resources, run daemon install, and auto-detect installed agent harnesses (Claude Code via ~/.claude presence → offer/perform `hook install --harness claude`; Cursor via ~/.cursor presence → `hook install --harness cursor`), with a small first-run panel reporting what was set up. Requires Developer ID signing + notarization (already backlogged) to be download-safe; sequence the two together. - 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). - MenuBarExtraAccess (orchetect) if `.window` presentation quirks bite in practice. -- Developer ID signing + notarization + Homebrew tap for OSS release; SIGN_IDENTITY variable already exists in the bundle script. -- 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. +- Populate Apple Developer ID + App Store Connect API secrets for `.github/workflows/release-dmg.yml` (and a Homebrew tap) so OSS downloads are Gatekeeper-safe; scripts (`make dmg`, `scripts/notarize.sh`) and the workflow already exist. First stapled DMG may be attached to the existing `v1.2.0` GitHub release only after local dogfood and Evan's explicit sign-off (do not upload without it). +- 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). -- Field-level config editing in the dashboard to preserve devservers.json formatting instead of normalizing writes. -- Spotlight thumbnails: confirm config icons render in the real Spotlight UI. Ranking above filesystem / Cursor Top Hits is a hard Apple ceiling (tried; stripped Recent Documents / jump-file chase 2026-07-24); do not reopen without a new system API. +- Field-level config editing in the dashboard to preserve `devservers.json` formatting instead of normalizing writes. +- 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. - Automatic port deconflicting / pre-spawn allocation: today is refuse (`port-held`) + observe framework bumps (`observedPort`). True auto-pick needs env injection, URL/head rewrite, and ephemeral-vs-committed policy. diff --git a/CLAUDE.md b/CLAUDE.md index 04672d6..7599d08 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,22 +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), 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, portable SHA-256), DeepLink/ (parse/serialize + DeepLinkRunner + notification action map), 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; the ProcessLauncher seam; ProcessTree sysctl sweep), Health/ (EffectiveHealthcheck resolution, the HealthProber seam with the real network prober, PortGuard lsof diagnostics), Registry/ (owner of registry.json and state.json), Control/ (Router method dispatch + port pre-check + resource-lock registry with dead-holder auto-release + NWListener ControlServer). -- Sources/devctld: thin main; identical behavior under launchd and --foreground (tests and the smoke gate use foreground). -- Sources/devctl: CLI (swift-argument-parser); LaunchdAdmin (launchd lifecycle; install drains via daemon.shutdown before bootout), HookSupport (AgentContext + HarnessAdapter registry; adding a harness: CONTRIBUTING.md), Switch (branch switching + lifecycle playbooks), Lock (run-under-resource-lock), Link / x-url (deep links). -- Sources/DevCtlApp: menu bar app (DaemonModel 2s-polling model + crash notifications with Open/Why actions; 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 best-effort Core Spotlight; AppDeepLink handles `devctl://` and notification actions). Pure DaemonClient consumer. +- Sources/DevCtlKit: shared core, the unit-test target of record. Models.swift (specs, phases, ServerStatus), 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, portable SHA-256; agent.path holds login-shell PATH for the daemon), Setup/SetupPlanner.swift (first-run / upgrade decisions, harness offers, stage-and-rename binary install), 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/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), Health/ (EffectiveHealthcheck resolution, the HealthProber seam with ephemeral URLSession HTTP probes + BSD TCP, PortGuard lsof diagnostics), 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 + NWListener ControlServer with stateUpdateHandlers). +- 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. +- Sources/devctl: CLI (swift-argument-parser); HookSupport (AgentContext + HarnessAdapter registry; adding a harness: CONTRIBUTING.md), Switch (branch switching + lifecycle playbooks), Lock (run-under-resource-lock), Link / x-url (deep links). +- 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/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, resource locks (pause + refused ensure + resume), 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`. Run it after touching the supervisor, wire protocol, CLI, or deep links. +- 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, resource locks (pause + refused ensure + resume), 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 (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 devctl agent is already installed; leaves nothing behind. -- make app: assembles devctl.app via scripts/make-app-bundle.sh (no Xcode; ad-hoc signed; SIGN_IDENTITY upgrades; declares the `devctl://` URL scheme). make install: binaries to ~/.local/bin, app to /Applications, daemon install. +- 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; ad-hoc signed; SIGN_IDENTITY upgrades; declares the `devctl://` URL scheme). 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. scripts/notarize.sh: notarytool + staple. 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 link` prints; the app handles via Launch Services; `devctl x-url` runs the same runner for smoke. +- 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. Hard rules - Output capture is spool-file fds, never pipes: children must survive daemon death without SIGPIPE. Do not introduce pipe-based capture anywhere. @@ -56,7 +56,10 @@ Engineering rules Stack notes (verified 2026; re-verify before building on them) - swift-subprocess createSession = true gives the child a fresh session, so pgid == pid, which group-directed teardown relies on. -- 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. User agents live in domain gui/, never system, and launchctl list/print answer differently depending on the calling context. +- 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. +- Replacing an ad-hoc signed `Contents/Helpers/devctld` while the agent is still registered binds BTM to the old CDHash: the next spawn dies with `SIGKILL (Code Signature Invalid)` / Launch Constraint Violation. Always unregister and wait for unload before replacing `/Applications/devctl.app`; DMG upgrades write `agent.rebind` and refuse to replace if the agent is still loaded. After replace, the first `register()` often still dies once; KeepAlive's in-place LWCR repair then fails for ad-hoc (`Unable to update LWCR with smd: 22`) and only burns another ThrottleInterval, so the rebind path forces unregister+register after a brief hello miss instead of waiting. Developer ID builds with a stable Team ID are far less sensitive. +- 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. Aesthetic Quiet instrument panel: monochrome SF Symbols glyph, tally-light status dots (subtle breathing animation on starting), dense but calm typography, no chrome. State changes register visibly but never shout. Visual changes are judged by rendering and viewing, never asserted in unit tests. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a97d6a..8c674ac 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,6 +13,14 @@ devctl is a personal tool first; issues and patches are welcome all the same. Re GitHub releases are driven by [Changesets](https://github.com/changesets/changesets). After changesets land on `main`, a Version Packages PR appears; merging it tags `vX.Y.Z` and opens the GitHub release. Nothing is published to npm: the root `package.json` is private and only tracks the product version. +A separate macOS workflow (`.github/workflows/release-dmg.yml`) builds a Developer ID-signed, notarized DMG and attaches it to that release. Required repository secrets: + +- `APPLE_DEVELOPER_ID_P12_BASE64` / `APPLE_DEVELOPER_ID_P12_PASSWORD`: exported Developer ID Application certificate +- `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` + +Local path: `SIGN_IDENTITY="Developer ID Application: …" make dmg` then `scripts/notarize.sh`. The first DMG for an already-published tag (for example attaching to `v1.2.0`) is uploaded only after local dogfood and maintainer sign-off. + ## 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/Makefile b/Makefile index 10e8925..c578069 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ PREFIX ?= $(HOME)/.local SIGN_IDENTITY ?= - -.PHONY: build test app install clean +.PHONY: build test app dmg install clean build: swift build -c release @@ -12,12 +12,16 @@ test: app: build scripts/make-app-bundle.sh "$(SIGN_IDENTITY)" +dmg: app + scripts/make-dmg.sh "$(SIGN_IDENTITY)" + install: build app mkdir -p $(PREFIX)/bin install .build/release/devctl $(PREFIX)/bin/devctl + install .build/release/devctld $(PREFIX)/bin/devctld ditto devctl.app /Applications/devctl.app $(PREFIX)/bin/devctl daemon install clean: swift package clean - rm -rf devctl.app + rm -rf devctl.app dist diff --git a/Package.swift b/Package.swift index eec3d30..6a0b773 100644 --- a/Package.swift +++ b/Package.swift @@ -37,7 +37,19 @@ let package = Package( .executableTarget( name: "devctld", dependencies: ["DevCtlDaemonCore", "DevCtlKit"], - swiftSettings: strictCore + exclude: ["Info.plist"], + swiftSettings: strictCore, + linkerSettings: [ + /** Embedded Info.plist for the helper Mach-O identity. Login + Items naming for the app install path comes from + SMAppService + the responsible app, not this section. */ + .unsafeFlags([ + "-Xlinker", "-sectcreate", + "-Xlinker", "__TEXT", + "-Xlinker", "__info_plist", + "-Xlinker", "\(Context.packageDirectory)/Sources/devctld/Info.plist", + ]) + ] ), .executableTarget( name: "devctl", diff --git a/README.md b/README.md index 533aae4..0665480 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,12 @@ Agents forget their dev servers. After a context compaction they spawn duplicate ## Quick start -(Your agent can do all of this for you, just point them at the repo.) +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). +Or build from source (your agent can do this for you): ```sh -make install # binaries to ~/.local/bin, app to /Applications, daemon installed +make install # CLI + daemon to ~/.local/bin, app to /Applications, daemon installed cd your-project devctl register --name myproj --cmd bun --cmd run --cmd dev --port 3000 devctl ensure myproj # idempotent: healthy is a no-op @@ -37,4 +38,4 @@ Or commit a `devservers.json` at the project root (multiple servers, dependencie ## Building -`make build` and `make test`; `scripts/smoke.sh` is the end-to-end gate. No Xcode required, ever: the app bundle is assembled by a checked-in script. See `CLAUDE.md` for the codebase map and `CONTRIBUTING.md` for adding an agent-harness adapter. +`make build` and `make test`; `make app` / `make dmg` assemble the menu bar app (and a double-click-to-install disk image) without Xcode. `scripts/smoke.sh` is the end-to-end gate. See `CLAUDE.md` for the codebase map and `CONTRIBUTING.md` for adding an agent-harness adapter and for release DMG signing secrets. diff --git a/Resources/AppIcon.icns b/Resources/AppIcon.icns new file mode 100644 index 0000000..390427f Binary files /dev/null and b/Resources/AppIcon.icns differ diff --git a/Resources/AppIcon.png b/Resources/AppIcon.png new file mode 100644 index 0000000..3fe261e Binary files /dev/null and b/Resources/AppIcon.png differ diff --git a/Sources/DevCtlApp/AgentService.swift b/Sources/DevCtlApp/AgentService.swift new file mode 100644 index 0000000..9fb55df --- /dev/null +++ b/Sources/DevCtlApp/AgentService.swift @@ -0,0 +1,277 @@ +import DevCtlKit +import Foundation +import ServiceManagement + +/** SMAppService registration for the in-bundle LaunchAgent. + Must run inside the app process: `SMAppService.agent(plistName:)` resolves + the plist relative to `Bundle.main`. Nonisolated: setup runs off the main + actor and Service Management is thread-safe for these calls. */ +enum AgentService { + nonisolated static let plistName = "dev.quantizor.devctl.plist" + + /** Why registration could not finish. `needsApproval` is not a malfunction: + macOS registered the job and is waiting for the user to switch it on, so + callers must say so rather than retry or fall back to another install. */ + enum Failure: Error, LocalizedError, Sendable { + case missingPlist(String) + case needsApproval + + var errorDescription: String? { + switch self { + case .missingPlist(let name): + return + "This copy of devctl.app is missing \(name). Reinstall from the DMG or run make app." + case .needsApproval: + return + "macOS is waiting for you to allow devctl to run in the background. Turn on quantizor/devctl in System Settings > General > Login Items & Extensions." + } + } + } + + private nonisolated static var agent: SMAppService { + SMAppService.agent(plistName: plistName) + } + + nonisolated static var status: SMAppService.Status { agent.status } + + /** Status by name: the raw values are easy to misread in a log line + (`enabled` is 1, `requiresApproval` is 2). */ + nonisolated static var statusDescription: String { + switch agent.status { + case .enabled: "enabled" + case .notFound: "not found" + case .notRegistered: "not registered" + case .requiresApproval: "requires approval" + @unknown default: "unknown" + } + } + + /** True when this process ships the in-bundle LaunchAgents plist. */ + nonisolated static var bundleHasAgentPlist: Bool { + let url = Bundle.main.bundleURL + .appending(path: "Contents/Library/LaunchAgents") + .appending(path: plistName) + return FileManager.default.fileExists(atPath: url.path) + } + + /** Register the agent. Writes agent.path first so the daemon inherits the + login-shell PATH after start. Registering often lands in + `requiresApproval`, which reads as success from `register()` alone, so the + status is re-read afterwards and reported as `Failure.needsApproval`. */ + nonisolated static func register() throws { + guard bundleHasAgentPlist else { throw Failure.missingPlist(plistName) } + try LaunchdAdmin.writeAgentPath() + migrateLegacyHomeAgent() + if agent.status != .enabled { + /** Registering an already-registered job throws; the status check + above plus this tolerance keeps re-launches idempotent. */ + do { + try agent.register() + } catch { + if agent.status != .requiresApproval { throw error } + } + } + if agent.status == .requiresApproval { + SMAppService.openSystemSettingsLoginItems() + throw Failure.needsApproval + } + } + + /** Unregister then register: required after the helper binary or plist + changes inside the bundle (SDK guidance), and the only way to reload a + job that is registered but not running. + + Ad-hoc resigns change the helper CDHash. Registering (or KeepAlive + respawning) before BTM drops the prior launch constraint produces + `SIGKILL (Code Signature Invalid)` / Launch Constraint Violation. So + unregister always waits for launchd + a BTM settle before register. */ + nonisolated static func reregister() async throws { + try LaunchdAdmin.writeAgentPath() + migrateLegacyHomeAgent() + if agent.status == .enabled || agent.status == .requiresApproval { + try? await agent.unregister() + let unloaded = await LaunchdAdmin.waitUntilAgentUnloaded() + if !unloaded { + DevCtlLog.app.error( + "agent still loaded after unregister; register may hit a Launch Constraint Violation") + } + } + try agent.register() + if agent.status == .requiresApproval { + SMAppService.openSystemSettingsLoginItems() + throw Failure.needsApproval + } + } + + /** Unregistering is a deliberate stand-down, so it records the stop intent + first: without the marker the recovery poll sees an unreachable daemon a + moment later and registers the agent right back. */ + nonisolated static func unregister(paths: DevCtlPaths = DevCtlPaths()) async throws { + try AtomicFile.write(Data(), to: paths.stoppedIntentFile) + migrateLegacyHomeAgent() + guard agent.status != .notRegistered else { return } + try await agent.unregister() + DevCtlLog.app.info("agent unregistered on request") + } + + /** Deep-link / recovery entry: register, then wait until the socket answers. + `Failure.needsApproval` short-circuits the wait, since no amount of + polling starts a job the user has not switched on. + + A status of `enabled` does not prove the job is loaded. Escalate fast when + launchd has nothing; only wait out ThrottleInterval (~10s) when the job is + present but still spawning after a bad first attempt. */ + nonisolated static func ensureRunning(paths: DevCtlPaths = DevCtlPaths()) async throws { + try? FileManager.default.removeItem(at: paths.stoppedIntentFile) + try register() + try await waitForHelloOrEscalate(paths: paths, escalate: true) + } + + /** At launch: keep the agent registered when this bundle can host it, + unless the user deliberately stopped the daemon. Copies running from a + DMG or Downloads skip registration: they share a bundle id with + `/Applications/devctl.app`, so registering from there races the relocate + handoff and can unregister or bind BTM to the volume path. + + A post-replace `agent.rebind` marker means the DMG installer already + unregistered. The first register often dies on an ad-hoc CDHash mismatch; + KeepAlive's in-place LWCR repair fails (`smd` error 22), so a brief hello + miss forces unregister+register instead of waiting out ThrottleInterval. */ + nonisolated static func ensureAtLaunchIfNeeded() async { + guard bundleHasAgentPlist else { + DevCtlLog.app.info("no in-bundle LaunchAgent; leaving the daemon to the CLI") + return + } + if SetupPlanner.isRunningOutsideApplications(bundlePath: Bundle.main.bundlePath) { + DevCtlLog.app.info( + "running outside Applications; deferring agent register until relocated") + return + } + let paths = DevCtlPaths() + let rebind = LaunchdAdmin.agentRebindNeeded(paths: paths) + guard + AgentRebindPolicy.shouldRegisterAtLaunch( + deliberatelyStopped: LaunchdAdmin.deliberatelyStopped(paths: paths), + rebindNeeded: rebind) + else { + DevCtlLog.app.info( + "skipping agent register: devctld was stopped on purpose (Start in the menu clears that)") + return + } + do { + if rebind { + try? FileManager.default.removeItem(at: paths.stoppedIntentFile) + } + try register() + DevCtlLog.app.info("agent register at launch: \(statusDescription)") + if AgentRebindPolicy.shouldForceReregisterAfterHelloMiss(rebindNeeded: rebind) { + if (try? await LaunchdAdmin.pollHello( + paths: paths, timeoutSeconds: AgentRebindPolicy.postReplaceHelloSeconds)) + == nil + { + DevCtlLog.app.info( + "post-replace spawn missed (ad-hoc LWCR repair is a dead end); re-registering") + try await reregister() + try await LaunchdAdmin.pollHello(paths: paths, timeoutSeconds: 10) + } + LaunchdAdmin.clearAgentRebindMarker(paths: paths) + } else { + await bounceStaleDaemon(paths: paths) + try await waitForHelloOrEscalate(paths: paths, escalate: true) + } + } catch Failure.needsApproval { + DevCtlLog.app.error("agent awaiting approval in Login Items") + } catch { + DevCtlLog.app.error("agent register at launch: \(error.localizedDescription)") + } + } + + /** Wait for the socket, escalating only when needed. + Fast path (~2s): covers a clean RunAtLoad spawn. + Missing job: re-register immediately (register was a no-op on a ghost). + Spawn scheduled / silent job: wait out launchd's ~10s throttle before + another unregister+register, which would reset that window. */ + nonisolated static func waitForHelloOrEscalate( + paths: DevCtlPaths = DevCtlPaths(), escalate: Bool + ) async throws { + if (try? await LaunchdAdmin.pollHello(paths: paths, timeoutSeconds: 2)) != nil { + return + } + guard agent.status == .enabled else { + throw WireError(code: .daemonUnreachable, message: "devctld never answered") + } + guard !LaunchdAdmin.deliberatelyStopped(paths: paths) else { return } + + if !LaunchdAdmin.isAgentLoaded() { + guard escalate else { return } + DevCtlLog.app.info("agent enabled but not loaded; re-registering") + try await reregister() + try await LaunchdAdmin.pollHello(paths: paths, timeoutSeconds: 12) + return + } + + /** KeepAlive's in-place LWCR repair after a Launch Constraint Violation + fails for ad-hoc helpers (`Unable to update LWCR with smd: 22`) and + only burns another ThrottleInterval. Prefer a fresh register. */ + if LaunchdAdmin.agentRebindNeeded(paths: paths) + || LaunchdAdmin.agentSpawnScheduled() + { + if LaunchdAdmin.agentRebindNeeded(paths: paths) { + DevCtlLog.app.info( + "post-replace agent still silent; re-registering instead of waiting on LWCR repair") + guard escalate else { return } + try await reregister() + try await LaunchdAdmin.pollHello(paths: paths, timeoutSeconds: 12) + LaunchdAdmin.clearAgentRebindMarker(paths: paths) + return + } + DevCtlLog.app.info("agent spawn scheduled (launchd throttle); waiting") + } + if (try? await LaunchdAdmin.pollHello(paths: paths, timeoutSeconds: 12)) != nil { + return + } + guard escalate else { return } + DevCtlLog.app.info("agent still silent after throttle window; re-registering") + try await reregister() + try await LaunchdAdmin.pollHello(paths: paths, timeoutSeconds: 12) + } + + /** Restart devctld when the running one predates this app. Installing a new + version replaces the helper on disk, but the old process keeps running the + old inode and `register()` is a no-op while the service stays registered, + so without this an upgrade silently leaves the previous daemon in charge. + + The test is the reported version, which does not move between rebuilds of + the same version during development: use `devctl daemon restart` there. */ + nonisolated static func bounceStaleDaemon(paths: DevCtlPaths = DevCtlPaths()) async { + let client = DaemonClient(socketPath: paths.socketPath) + guard + let info = try? await client.request( + .daemonInfo, params: WireEmpty(), expecting: DaemonInfo.self), + info.daemonVersion != DevCtlVersion.version + else { return } + DevCtlLog.app.info( + "devctld v\(info.daemonVersion) predates this app (v\(DevCtlVersion.version)); restarting it") + do { + try await reregister() + try await waitForHelloOrEscalate(paths: paths, escalate: false) + } catch { + DevCtlLog.app.error("could not restart the stale daemon: \(error.localizedDescription)") + } + } + + /** Reopen the Login Items pane for the popover's approval affordance. */ + nonisolated static func openLoginItemsSettings() { + SMAppService.openSystemSettingsLoginItems() + } + + /** Drop a leftover `~/Library/LaunchAgents` job so BTM does not show two rows. */ + nonisolated static func migrateLegacyHomeAgent() { + let plist = LaunchdAdmin.plistURL + guard FileManager.default.fileExists(atPath: plist.path) else { return } + _ = LaunchdAdmin.shell( + "/bin/launchctl", ["bootout", "gui/\(getuid())/\(LaunchdAdmin.label)"]) + try? FileManager.default.removeItem(at: plist) + DevCtlLog.app.info("migrated away from legacy home LaunchAgent") + } +} diff --git a/Sources/DevCtlApp/AppDeepLink.swift b/Sources/DevCtlApp/AppDeepLink.swift index 32295c9..6d24556 100644 --- a/Sources/DevCtlApp/AppDeepLink.swift +++ b/Sources/DevCtlApp/AppDeepLink.swift @@ -23,7 +23,9 @@ struct AppDeepLinkEffects: DeepLinkEffects { } func openBrowser(_ url: URL) async { - await MainActor.run { NSWorkspace.shared.open(url) } + /** Best effort: a browser that refuses to open is not worth failing the + deep link over, and the caller has no recovery for it either. */ + await MainActor.run { _ = NSWorkspace.shared.open(url) } } } @@ -35,6 +37,10 @@ enum AppDeepLinkDispatch { static let userInfoHead = "head" static func run(_ url: URL) { + if isDaemonControl(url) { + Task { await executeDaemonControl(url) } + return + } switch DeepLink.parse(url: url) { case .failure(let error): DevCtlLog.deeplink.error("reject \(error.message)") @@ -46,6 +52,36 @@ enum AppDeepLinkDispatch { } } + /** `devctl://daemon/ensure` and `devctl://daemon/unregister`: CLI asks the + app to own SMAppService registration (correct Bundle.main). */ + private static func isDaemonControl(_ url: URL) -> Bool { + guard url.scheme?.lowercased() == "devctl", + url.host?.lowercased() == "daemon" + else { return false } + let action = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased() + return action == "ensure" || action == "unregister" + } + + private static func executeDaemonControl(_ url: URL) async { + let action = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased() + do { + switch action { + case "ensure": + try await AgentService.ensureRunning() + DevCtlLog.app.info("deeplink daemon/ensure ok") + case "unregister": + try await AgentService.unregister() + DevCtlLog.app.info("deeplink daemon/unregister ok") + default: + break + } + } catch { + DevCtlLog.app.error("deeplink daemon/\(action): \(error.localizedDescription)") + await AppDeepLinkEffects().notify( + title: "devctl daemon control failed", body: error.localizedDescription) + } + } + static func run(_ link: DeepLink) { Task { await execute(link) } } diff --git a/Sources/DevCtlApp/DaemonModel.swift b/Sources/DevCtlApp/DaemonModel.swift index bfda0b7..710770f 100644 --- a/Sources/DevCtlApp/DaemonModel.swift +++ b/Sources/DevCtlApp/DaemonModel.swift @@ -85,8 +85,23 @@ final class DaemonModel { /** Bumped on system theme change so the baked menu bar label re-renders. */ var appearanceTick = 0 var daemonReachable = false + /** Last failed recovery, surfaced in the popover so a dead daemon is never + a dead end. */ + var daemonRecoveryError: String? + /** True while a bootstrap/kickstart is in flight, so the row can say so and + the poll does not stack attempts. */ + var daemonRecovering = false + /** macOS registered the background agent but is waiting for the user to + switch it on. Nothing the app can retry, so the popover asks for the + approval instead of looping. */ + var daemonNeedsApproval = false + /** `devctl daemon stop` wrote the deliberate-stop marker: the app offers + Start instead of resurrecting the daemon behind the user's back. */ + var daemonStoppedOnPurpose = false var projects: [ProjectGroup] = [] + private var lastRecoveryAttempt: Date? + struct ProjectGroup: Identifiable { var id: String { path } let name: String @@ -172,6 +187,18 @@ final class DaemonModel { self?.appearanceTick += 1 } } + /** Launch-time registration runs behind the same in-flight flag and + cooldown as recovery. Left outside them, the 2s poll sees the socket + still silent inside launchd's respawn throttle and fires a second + unregister + register on top of the first. */ + Task { [weak self] in + guard let self else { return } + daemonRecovering = true + await AgentService.ensureAtLaunchIfNeeded() + lastRecoveryAttempt = Date() + daemonRecovering = false + await refresh() + } pollTask = Task { [weak self] in while !Task.isCancelled { await self?.refresh() @@ -194,13 +221,105 @@ final class DaemonModel { servers: (grouped[path] ?? []).sorted { $0.server < $1.server }) } SpotlightIndexer.sync(projects: projects) + daemonRecoveryError = nil + lastRecoveryAttempt = nil + daemonStoppedOnPurpose = false await surfaceCrashNotifications(client: client) } catch { daemonReachable = false projects = [] + daemonStoppedOnPurpose = LaunchdAdmin.deliberatelyStopped() + await recoverDaemonIfNeeded() + } + } + + /** Bring the daemon back on its own after a crash or a failed relaunch. + Skipped when the user stopped it on purpose (their intent wins) and + rate-limited so a permanently broken install does not spin. */ + private func recoverDaemonIfNeeded() async { + let decision = DaemonRecoveryPolicy.decide( + reachable: daemonReachable, + stoppedOnPurpose: daemonStoppedOnPurpose, + recovering: daemonRecovering, + lastAttempt: lastRecoveryAttempt) + guard decision == .recover else { return } + lastRecoveryAttempt = Date() + daemonRecovering = true + let recovered = await recoverAgent() + daemonRecovering = false + if recovered { + daemonRecoveryError = nil + await refresh() + } else if daemonNeedsApproval { + daemonRecoveryError = AgentService.Failure.needsApproval.localizedDescription + } else { + daemonRecoveryError = "could not start devctld automatically" + } + } + + /** Prefer SMAppService inside this app process; fall back to the legacy home + LaunchAgent only when this bundle cannot host an agent. Falling back while + approval is pending would write back the very `~/Library/LaunchAgents` + job the migration removed, and Login Items would name it `devctld` again. */ + private func recoverAgent() async -> Bool { + if AgentService.bundleHasAgentPlist { + do { + try await AgentService.ensureRunning() + daemonNeedsApproval = false + return true + } catch AgentService.Failure.needsApproval { + daemonNeedsApproval = true + DevCtlLog.app.error("agent awaiting approval in Login Items") + return false + } catch { + DevCtlLog.app.error("SMAppService recover failed: \(error.localizedDescription)") + return false + } + } + return await LaunchdAdmin.attemptBootstrap( + extraDaemonCandidates: Self.bundledDaemonCandidates(), forceLegacy: true) + } + + /** Explicit Start from the popover: clears a deliberate-stop marker, so it + also works when auto recovery is intentionally standing down. */ + func startDaemon() { + guard !daemonRecovering else { return } + Task { + daemonRecovering = true + daemonRecoveryError = nil + do { + if AgentService.bundleHasAgentPlist { + try await AgentService.ensureRunning() + } else { + try await LaunchdAdmin.startOrInstall( + extraDaemonCandidates: Self.bundledDaemonCandidates(), + forceLegacy: true) + } + daemonNeedsApproval = false + daemonStoppedOnPurpose = false + } catch AgentService.Failure.needsApproval { + daemonNeedsApproval = true + daemonRecoveryError = AgentService.Failure.needsApproval.localizedDescription + } catch let error as WireError { + daemonRecoveryError = error.message + } catch { + daemonRecoveryError = error.localizedDescription + } + daemonRecovering = false + await refresh() } } + /** The version-matched devctld inside this app bundle's Resources, which is + the right install source when no LaunchAgent exists yet. */ + private static func bundledDaemonCandidates() -> [URL] { + guard + let url = Bundle.main.url( + forResource: SetupPlanner.resourceDaemonName, withExtension: nil) + else { return [] } + return [url] + } + func startServer(_ server: ServerStatus) { ProjectAccessLog.shared.record(projectPath: server.project) Task { diff --git a/Sources/DevCtlApp/DevCtlApp.swift b/Sources/DevCtlApp/DevCtlApp.swift index 1ef25cb..722a0c5 100644 --- a/Sources/DevCtlApp/DevCtlApp.swift +++ b/Sources/DevCtlApp/DevCtlApp.swift @@ -208,7 +208,9 @@ final class AppActivationDelegate: NSObject, NSApplicationDelegate, UNUserNotifi continue userActivity: NSUserActivity, restorationHandler: @escaping ([any NSUserActivityRestoring]) -> Void ) -> Bool { - guard userActivity.activityType == CSSearchableItemActionType, + let fromSpotlight = userActivity.activityType == CSSearchableItemActionType + let fromDonation = userActivity.activityType == SpotlightIndexer.activityType + guard fromSpotlight || fromDonation, let identifier = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String, let url = SpotlightIndexer.url(forIdentifier: identifier) else { return false } @@ -217,6 +219,18 @@ final class AppActivationDelegate: NSObject, NSApplicationDelegate, UNUserNotifi return true } + /** MenuBarExtra is often foreground; without willPresent the SDK drops the + banner entirely (UNUserNotificationCenter.h). Prefer .banner/.list over + the deprecated .alert. */ + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification, + withCompletionHandler completionHandler: + @escaping (UNNotificationPresentationOptions) -> Void + ) { + completionHandler([.banner, .list, .sound]) + } + func userNotificationCenter( _ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, @@ -247,6 +261,7 @@ final class AppActivationDelegate: NSObject, NSApplicationDelegate, UNUserNotifi struct DevCtlApp: App { @NSApplicationDelegateAdaptor(AppActivationDelegate.self) private var appDelegate @State private var model = DaemonModel() + @State private var setupSession = SetupSession() /** Polling starts at launch, not first popover open: the collapsed label's presence dots must be live from the first frame. */ @@ -264,6 +279,7 @@ struct DevCtlApp: App { closures do not participate in Observation tracking, so counts read here directly would never re-render the collapsed label. */ PresenceLabel(model: model) + .background(SetupWindowOpener(session: setupSession)) } .menuBarExtraStyle(.window) @@ -271,6 +287,22 @@ struct DevCtlApp: App { DashboardView(model: model) } .defaultSize(width: 860, height: 560) + + Window(setupSession.migration || setupSession.replacingApplicationsApp + ? "Upgrade devctl" : "Install devctl", id: "setup") { + SetupPanel( + installAppToApplications: setupSession.installAppToApplications, + migration: setupSession.migration, + offers: setupSession.offers, + pathWarning: setupSession.pathWarning, + replacingApplicationsApp: setupSession.replacingApplicationsApp + ) { + setupSession.shouldPresent = false + SetupWindowCloser.close() + } + } + .windowResizability(.contentSize) + .defaultSize(width: 460, height: 420) } } @@ -312,8 +344,7 @@ struct MenuContent: View { var body: some View { VStack(alignment: .leading, spacing: 0) { if !model.daemonReachable { - Label("devctld is not running", systemImage: "bolt.slash") - .foregroundStyle(.secondary) + DaemonDownRow(model: model) .padding(12) } else if model.projects.isEmpty { VStack(alignment: .leading, spacing: 4) { @@ -420,6 +451,59 @@ struct MenuContent: View { } } +/** Shown when the socket does not answer. The app tries to bring the daemon + back on its own, so this states what is happening and still offers the + manual action: a deliberate `devctl daemon stop` suppresses auto recovery, + and a failed recovery needs somewhere to report. */ +struct DaemonDownRow: View { + var model: DaemonModel + + private var message: String { + if model.daemonRecovering { return "Starting devctld…" } + if model.daemonNeedsApproval { return "devctl needs your approval" } + if model.daemonStoppedOnPurpose { return "devctld is stopped" } + if model.daemonRecoveryError != nil { return "devctld is not running" } + return "devctld is not running; restarting" + } + + private var glyph: String { + if model.daemonRecovering { return "bolt" } + if model.daemonNeedsApproval { return "hand.raised" } + return "bolt.slash" + } + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Label(message, systemImage: glyph) + .foregroundStyle(.secondary) + if let error = model.daemonRecoveryError { + Text(error) + .font(.caption) + .foregroundStyle(.secondary) + } + } + Spacer(minLength: 8) + /** Approval is the only action that can help while the switch is + off, so it replaces Start rather than sitting beside it. */ + if model.daemonNeedsApproval { + Button("Open Login Items") { + AgentService.openLoginItemsSettings() + } + .buttonStyle(.borderless) + .help("Turn on quantizor/devctl in System Settings") + } else { + Button("Start") { + model.startDaemon() + } + .buttonStyle(.borderless) + .disabled(model.daemonRecovering) + .help("Start devctld now") + } + } + } +} + struct ProjectSection: View { let filterText: String let keyNav: KeyNavModel diff --git a/Sources/DevCtlApp/SetupPanel.swift b/Sources/DevCtlApp/SetupPanel.swift new file mode 100644 index 0000000..6e0294b --- /dev/null +++ b/Sources/DevCtlApp/SetupPanel.swift @@ -0,0 +1,217 @@ +import DevCtlKit +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 installAppToApplications: Bool + let migration: Bool + let offers: [HarnessOffer] + let pathWarning: Bool + let replacingApplicationsApp: Bool + var onFinished: () -> Void + + @State private var busy = false + @State private var errorText: String? + @State private var finishedSummary: String? + @State private var selected: Set + @State private var willRelaunch = false + + init( + installAppToApplications: Bool, + migration: Bool, + offers: [HarnessOffer], + pathWarning: Bool, + replacingApplicationsApp: Bool, + onFinished: @escaping () -> Void + ) { + self.installAppToApplications = installAppToApplications + self.migration = migration + self.offers = offers + self.pathWarning = pathWarning + self.replacingApplicationsApp = replacingApplicationsApp + self.onFinished = onFinished + _selected = State( + initialValue: Set(offers.filter(\.defaultChecked).map(\.harness))) + } + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text(migration || replacingApplicationsApp ? "Upgrade devctl" : "Install devctl") + .font(.title3.weight(.semibold)) + + Text("Confirm to apply the changes below. Nothing runs until you confirm.") + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + VStack(alignment: .leading, spacing: 6) { + Text("This will:") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + if installAppToApplications { + bullet( + replacingApplicationsApp + ? "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)") + bullet( + "Install or update the background daemon and restart it (your running servers stay)") + if selected.isEmpty { + bullet("Leave agent hooks unchanged (none selected below)") + } else { + let names = offers.filter { selected.contains($0.harness) }.map(\.displayName) + bullet("Install agent hooks for: \(names.joined(separator: ", "))") + } + if installAppToApplications { + bullet("Relaunch from Applications when finished") + } + } + .font(.callout) + .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") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + + if !offers.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Agent hooks (optional)") + .font(.caption.weight(.semibold)) + .foregroundStyle(.secondary) + ForEach(offers, id: \.harness) { offer in + Toggle(isOn: binding(for: offer)) { + HStack(spacing: 6) { + Text("Install \(offer.displayName) hook") + if offer.alreadyInstalled { + Text("already installed") + .font(.caption2) + .foregroundStyle(.tertiary) + } + } + } + .toggleStyle(.checkbox) + .disabled(busy || offer.alreadyInstalled) + } + } + } + + if let errorText { + Text(errorText) + .font(.caption) + .foregroundStyle(.red) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + + if let finishedSummary { + Text(finishedSummary) + .font(.caption) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Spacer() + if finishedSummary == nil { + Button(busy ? "Working…" : "Confirm") { runSetup() } + .keyboardShortcut(.defaultAction) + .disabled(busy) + } else if willRelaunch { + Button("Relaunch from Applications") { + SetupPerformer.relaunchFromApplicationsAndQuit() + } + .keyboardShortcut(.defaultAction) + } else { + Button("Done") { onFinished() } + .keyboardShortcut(.defaultAction) + } + } + } + .padding(20) + .frame(width: 460) + .opacity(busy ? 0.7 : 1) + } + + private func bullet(_ text: String) -> some View { + HStack(alignment: .top, spacing: 6) { + Text("•") + Text(text) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func binding(for offer: HarnessOffer) -> Binding { + Binding( + get: { selected.contains(offer.harness) }, + set: { on in + if on { selected.insert(offer.harness) } else { selected.remove(offer.harness) } + }) + } + + private func runSetup() { + busy = true + errorText = nil + let harnesses = selected + let relocate = installAppToApplications + Task { @MainActor in + if relocate { + /** Drop the SMAppService registration while the on-disk helper + still matches BTM's CDHash. Replacing the ad-hoc signed + bundle first, then re-registering, is what produced the + Launch Constraint Violation crashes. */ + if replacingApplicationsApp { + _ = LaunchdAdmin.requestAppAgentUnregister() + let unloaded = await LaunchdAdmin.waitUntilAgentUnloaded() + if !unloaded { + errorText = + "Could not stop the background agent before replacing the app. Quit quantizor/devctl from Activity Monitor (or turn it off in Login Items), then try again." + busy = false + return + } + do { + try LaunchdAdmin.markAgentRebindNeeded() + } catch { + errorText = + "Could not prepare the daemon rebind marker: \(error.localizedDescription)" + busy = false + return + } + /** Unregister wrote stopped.intent; clear it so the + Applications copy is allowed to register after settle. */ + try? FileManager.default.removeItem(at: DevCtlPaths().stoppedIntentFile) + } + await SetupPerformer.quitOtherInstances() + } + do { + let result = try await Task.detached(priority: .userInitiated) { + try await SetupPerformer.run( + selectedHarnesses: harnesses, + installAppToApplications: relocate) + }.value + let lines = result.notes + result.harnessSummaries + let summary = lines.isEmpty ? "Setup complete." : lines.joined(separator: "\n") + finishedSummary = summary + willRelaunch = result.relocatedToApplications + busy = false + if result.relocatedToApplications { + /** Short beat so the summary is readable, then hand off. */ + try? await Task.sleep(for: .milliseconds(600)) + SetupPerformer.relaunchFromApplicationsAndQuit() + } + } catch { + errorText = error.localizedDescription + busy = false + } + } + } +} diff --git a/Sources/DevCtlApp/SetupPerformer.swift b/Sources/DevCtlApp/SetupPerformer.swift new file mode 100644 index 0000000..9afb105 --- /dev/null +++ b/Sources/DevCtlApp/SetupPerformer.swift @@ -0,0 +1,355 @@ +import AppKit +import DevCtlKit +import Foundation + +/** Performs first-run / upgrade install from the app bundle Resources. Copies + the app into /Applications when needed, installs CLI + daemon, then optional + harness hooks. File I/O is nonisolated; quitting peers / relaunch is MainActor. */ +enum SetupPerformer: Sendable { + static let appBundleIdentifier = "dev.quantizor.devctl.app" + + struct Result: Sendable { + var cliOnPATH: Bool + var harnessSummaries: [String] + var migration: Bool + var notes: [String] + var relocatedToApplications: Bool + } + + struct Presentation: Sendable { + var installAppToApplications: Bool + var migration: Bool + var offers: [HarnessOffer] + var pathWarning: Bool + var replacingApplicationsApp: Bool + var shouldPresent: Bool + } + + enum Failure: Error, LocalizedError, Sendable { + case missingResources + case commandFailed(command: String, status: Int32, output: String) + case appInstallFailed(String) + case agentRegisterFailed(String) + + var errorDescription: String? { + switch self { + case .missingResources: + return "This copy of devctl.app is missing its bundled CLI and daemon. Reinstall from the DMG or run make app." + case .commandFailed(let command, let status, let output): + let detail = output.trimmingCharacters(in: .whitespacesAndNewlines) + if detail.isEmpty { + return "\(command) failed (exit \(status))." + } + return "\(command) failed (exit \(status)): \(detail)" + case .appInstallFailed(let message): + return message + case .agentRegisterFailed(let message): + return message + } + } + } + + nonisolated static func resourceURLs(bundle: Bundle = .main) -> (cli: URL, daemon: URL)? { + guard let cli = bundle.url(forResource: SetupPlanner.resourceCLIName, withExtension: nil), + let daemon = bundle.url(forResource: SetupPlanner.resourceDaemonName, withExtension: nil), + FileManager.default.isExecutableFile(atPath: cli.path), + FileManager.default.isExecutableFile(atPath: daemon.path) + else { return nil } + return (cli, daemon) + } + + nonisolated static func bundledVersion(bundle: Bundle = .main) -> String { + if let short = bundle.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, + !short.isEmpty + { + return short + } + return DevCtlVersion.version + } + + nonisolated static func evaluatePresentation( + bundle: Bundle = .main, paths: DevCtlPaths = DevCtlPaths() + ) -> Presentation { + let resources = resourceURLs(bundle: bundle) != nil + let bundled = bundledVersion(bundle: bundle) + let cliURL = SetupPlanner.installedCLIURL() + let installedVersion = readCLIVersion(at: cliURL) + let stamp = SetupPlanner.readStamp(at: SetupPlanner.stampURL(paths: paths)) + let outside = SetupPlanner.isRunningOutsideApplications( + bundlePath: bundle.bundleURL.path) + let should = SetupPlanner.shouldPresent( + bundledVersion: bundled, + installedCLIVersion: installedVersion, + stampVersion: stamp, + resourcesPresent: resources, + runningOutsideApplications: outside) + let agentPlist = FileManager.default.homeDirectoryForCurrentUser + .appending(path: "Library/LaunchAgents/dev.quantizor.devctl.plist") + let migration = SetupPlanner.isMigration( + installedCLIExists: FileManager.default.isExecutableFile(atPath: cliURL.path), + stampExists: stamp != nil, + launchAgentExists: FileManager.default.fileExists(atPath: agentPlist.path)) + let offers = SetupPlanner.harnessOffers(installedCLIPath: cliURL.path) + let pathWarning = !SetupPlanner.cliDirectoryOnPATH( + pathEnv: ProcessInfo.processInfo.environment["PATH"]) + return Presentation( + installAppToApplications: outside, + migration: migration, + offers: offers, + pathWarning: pathWarning, + replacingApplicationsApp: outside && SetupPlanner.applicationsAppExists(), + shouldPresent: should) + } + + /** 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. */ + nonisolated static func run( + selectedHarnesses: Set, + installAppToApplications: Bool, + bundle: Bundle = .main + ) async throws -> Result { + guard let resources = resourceURLs(bundle: bundle) else { throw Failure.missingResources } + let paths = DevCtlPaths() + let cliDest = SetupPlanner.installedCLIURL() + let daemonSibling = SetupPlanner.installedDaemonSiblingURL() + let fm = FileManager.default + let migration = SetupPlanner.isMigration( + installedCLIExists: fm.isExecutableFile(atPath: cliDest.path), + stampExists: SetupPlanner.readStamp(at: SetupPlanner.stampURL(paths: paths)) != nil, + launchAgentExists: fm.fileExists( + atPath: fm.homeDirectoryForCurrentUser + .appending(path: "Library/LaunchAgents/dev.quantizor.devctl.plist").path)) + + var notes: [String] = [] + var relocated = false + + /** Running setup is intent to run the daemon, so a leftover + deliberate-stop marker from an earlier `daemon stop` / uninstall must + not suppress registration (in the relocated case the Applications + copy reads this marker at launch, long after this process is gone). */ + try? fm.removeItem(at: paths.stoppedIntentFile) + + if installAppToApplications { + let destination = URL(fileURLWithPath: SetupPlanner.applicationsAppPath) + let replacing = fm.fileExists(atPath: destination.path) + do { + try SetupPlanner.installAppBundle(from: bundle.bundleURL, to: destination) + } catch { + throw Failure.appInstallFailed( + "Could not place the app at \(destination.path): \(error.localizedDescription). Quit any open copy of devctl and try again.") + } + relocated = true + notes.append( + replacing + ? "Updated the menu bar app at \(destination.path)." + : "Installed the menu bar app at \(destination.path).") + } + + if migration { + 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)") + + /** SMAppService must run with Bundle.main as the hosting app. After a + relocate, the Applications copy registers on launch; otherwise do it + here (and reregister on upgrade so the helper/plist swap sticks). */ + if !relocated { + do { + try LaunchdAdmin.writeAgentPath(paths: paths) + if migration { + try await AgentService.reregister() + } else { + try AgentService.register() + } + try await LaunchdAdmin.pollHello(paths: paths, timeoutSeconds: 8) + notes.append("Daemon registered via Login Items.") + } catch AgentService.Failure.needsApproval { + /** Not a failed install: the job is registered and one switch + away from running, and Login Items is already open. */ + notes.append(AgentService.Failure.needsApproval.localizedDescription) + } catch { + throw Failure.agentRegisterFailed( + "Could not register the background agent: \(error.localizedDescription). Check System Settings > General > Login Items & Extensions.") + } + } else { + notes.append( + "The copy in Applications registers the daemon when it launches. If macOS asks, allow quantizor/devctl in Login Items.") + } + + var harnessSummaries: [String] = [] + for harness in selectedHarnesses.sorted() { + let out = try runCLI(cliDest, arguments: ["hook", "install", "--harness", harness]) + 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"]) + if !onPATH { + notes.append( + "\(SetupPlanner.defaultCLIDirectory().path) is not on your PATH. Add it so shells and agents can find `devctl`.") + } + + return Result( + cliOnPATH: onPATH, + harnessSummaries: harnessSummaries, + migration: migration, + notes: notes, + relocatedToApplications: relocated) + } + + /** Quit every other running copy so /Applications/devctl.app can be replaced. */ + @MainActor + static func quitOtherInstances() async { + let peers = NSWorkspace.shared.runningApplications.filter { + $0.bundleIdentifier == appBundleIdentifier && $0 != .current + } + for app in peers { + app.terminate() + } + let deadline = Date().addingTimeInterval(5) + while Date() < deadline { + let still = NSWorkspace.shared.runningApplications.contains { + $0.bundleIdentifier == appBundleIdentifier && $0 != .current + } + if !still { return } + try? await Task.sleep(for: .milliseconds(100)) + } + for app in NSWorkspace.shared.runningApplications + where app.bundleIdentifier == appBundleIdentifier && app != .current { + app.forceTerminate() + } + try? await Task.sleep(for: .milliseconds(200)) + } + + /** Open the Applications copy and quit this (DMG/Downloads) process. Quitting + is conditional: Launch Services treats the DMG and Applications copies as + the same app (shared bundle id), so `openApplication` can return *this* + process as a "success". We only quit once a different pid is running from + the Applications path. */ + @MainActor + static func relaunchFromApplicationsAndQuit() { + let url = URL(fileURLWithPath: SetupPlanner.applicationsAppPath) + let appsPath = url.resolvingSymlinksInPath().standardizedFileURL.path + let selfPID = ProcessInfo.processInfo.processIdentifier + let configuration = NSWorkspace.OpenConfiguration() + configuration.activates = true + /** Force a new instance: without this, openApplication often "succeeds" + by activating the already-running DMG copy (same bundle id). */ + configuration.createsNewApplicationInstance = true + NSWorkspace.shared.openApplication(at: url, configuration: configuration) { app, error in + Task { @MainActor in + if let error { + DevCtlLog.app.error( + "relaunch from \(SetupPlanner.applicationsAppPath) failed: \(error.localizedDescription)") + return + } + let launchedPath = app?.bundleURL? + .resolvingSymlinksInPath().standardizedFileURL.path + let differentProcess = (app?.processIdentifier).map { $0 != selfPID } ?? false + if differentProcess, launchedPath == appsPath { + NSApp.terminate(nil) + return + } + 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)) + } + /** 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)) + } + } + DevCtlLog.app.error( + "could not hand off to \(SetupPlanner.applicationsAppPath); staying alive so setup is not lost") + } + } + } + + nonisolated private static func readCLIVersion(at url: URL) -> String? { + guard FileManager.default.isExecutableFile(atPath: url.path) else { return nil } + let proc = Process() + proc.executableURL = url + proc.arguments = ["--version"] + let pipe = Pipe() + proc.standardOutput = pipe + proc.standardError = Pipe() + do { + try proc.run() + proc.waitUntilExit() + } catch { + return nil + } + guard proc.terminationStatus == 0 else { return nil } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let text = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) + return (text?.isEmpty == false) ? text : nil + } + + nonisolated private static func runCLI(_ cli: URL, arguments: [String]) throws -> String { + let proc = Process() + proc.executableURL = cli + proc.arguments = arguments + let out = Pipe() + let err = Pipe() + proc.standardOutput = out + proc.standardError = err + do { + try proc.run() + proc.waitUntilExit() + } catch { + throw Failure.commandFailed( + command: ([cli.path] + arguments).joined(separator: " "), + status: -1, + output: error.localizedDescription) + } + let stdout = String(data: out.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) + ?? "" + let stderr = String(data: err.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) + ?? "" + let combined = (stdout + stderr).trimmingCharacters(in: .whitespacesAndNewlines) + guard proc.terminationStatus == 0 else { + throw Failure.commandFailed( + command: ([cli.path] + arguments).joined(separator: " "), + status: proc.terminationStatus, + output: combined) + } + return combined + } +} diff --git a/Sources/DevCtlApp/SetupSession.swift b/Sources/DevCtlApp/SetupSession.swift new file mode 100644 index 0000000..e08c22d --- /dev/null +++ b/Sources/DevCtlApp/SetupSession.swift @@ -0,0 +1,53 @@ +import AppKit +import DevCtlKit +import Observation +import SwiftUI + +/** Shared first-run / upgrade gate evaluated once at launch. */ +@Observable +final class SetupSession { + var installAppToApplications = false + var migration = false + var offers: [HarnessOffer] = [] + var pathWarning = false + var replacingApplicationsApp = false + var shouldPresent = false + + func evaluate() { + let eval = SetupPerformer.evaluatePresentation() + installAppToApplications = eval.installAppToApplications + migration = eval.migration + offers = eval.offers + pathWarning = eval.pathWarning + replacingApplicationsApp = eval.replacingApplicationsApp + shouldPresent = eval.shouldPresent + } +} + +/** 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 + + 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) + } + } +} + +/** Close the setup SwiftUI window by id (macOS 14-safe; dismissWindow is 15+). */ +enum SetupWindowCloser { + static func close() { + for window in NSApp.windows where window.identifier?.rawValue == "setup" { + window.close() + } + } +} diff --git a/Sources/DevCtlApp/SpotlightIndexer.swift b/Sources/DevCtlApp/SpotlightIndexer.swift index c522538..693cda8 100644 --- a/Sources/DevCtlApp/SpotlightIndexer.swift +++ b/Sources/DevCtlApp/SpotlightIndexer.swift @@ -1,110 +1,156 @@ import AppKit -import CoreSpotlight +@preconcurrency import CoreSpotlight import DevCtlKit import Foundation /** Spotlight integration: every server and every head becomes a searchable item ("candor · operator" opens the surface). Titles lead with the project/head; `devctl` lives in the subtitle. Ranking above filesystem Top Hits is not - something Core Spotlight can force; this is best-effort discovery, not a - launcher. Raycast/Alfred + `devctl://` is the fast path. */ + something Core Spotlight can force; within our own items we preserve + last-used engagement across syncs, update in place instead of wipe-rebuild, + and tier `rankingHint` by live/pinned. Raycast/Alfred + `devctl://` is the + fast path. */ enum SpotlightIndexer { /** nonisolated: read inside the (nonisolated) index completion callback. */ nonisolated static let domain = "devctl-servers" - /** Donated on open so Siri / Spotlight can predict the surface; the type is - opaque since continuation runs through CSSearchableItemActionType, not this. */ + /** Donated on open so Spotlight can re-surface the item; continue accepts + both CSSearchableItemActionType and this type. */ static let activityType = "dev.quantizor.devctl.open-surface" private static let entriesKey = "spotlight entries" private static let signatureKey = "spotlight signature" /** nonisolated: written inside the (nonisolated) index completion callback. */ nonisolated private static let statusKey = "spotlight last sync" - private static let baseRankingHint = 90 - private static let pinnedRankingHint = 100 - + /** Persisted in UserDefaults; new fields stay optional so older payloads keep + decoding (defensive-load rule). */ struct SpotlightEntry: Codable, Sendable { + var alternateNames: [String]? + var firstSeen: Date? let icon: String? let keywords: [String] + var lastUsed: Date? let rankingHint: Int let title: String let url: String } + /** Named index so Spotlight can batch; default() forbids batching. */ + nonisolated private static let index = CSSearchableIndex(name: "devctl-servers") + private static var currentActivity: NSUserActivity? static func sync(projects: [DaemonModel.ProjectGroup]) { + guard CSSearchableIndex.isIndexingAvailable() else { + UserDefaults.standard.set("unavailable", forKey: statusKey) + return + } HeadPins.shared.reconcile(projects: projects) let pins = HeadPins.shared + let previous = loadEntries() + let now = Date() var entriesByIdentifier: [String: SpotlightEntry] = [:] - var payload: [(identifier: String, entry: SpotlightEntry)] = [] + var payload: [(identifier: String, entry: SpotlightEntry, existed: Bool)] = [] for project in projects { for server in project.servers { if let url = server.url { let identifier = "\(project.path)::\(server.server)" + let prior = previous[identifier] + let names = SpotlightLabel.alternateNames( + project: project.name, server: server.server, head: nil, url: url) let entry = SpotlightEntry( + alternateNames: names, + firstSeen: prior?.firstSeen ?? now, icon: server.icon, keywords: SpotlightLabel.keywords( project: project.name, server: server.server, head: nil, url: url), - rankingHint: baseRankingHint, + lastUsed: prior?.lastUsed, + rankingHint: SpotlightLabel.rankingHint(phase: server.phase, pinned: false), title: SpotlightLabel.title( project: project.name, server: server.server, head: nil), url: url) entriesByIdentifier[identifier] = entry - payload.append((identifier, entry)) + payload.append((identifier, entry, prior != nil)) } for (head, url) in server.heads ?? [:] { let identifier = "\(project.path)::\(server.server)::\(head)" + let prior = previous[identifier] let pinned = pins.isPinned(project: project.path, server: server.server, head: head) + let names = SpotlightLabel.alternateNames( + project: project.name, server: server.server, head: head, url: url) let entry = SpotlightEntry( + alternateNames: names, + firstSeen: prior?.firstSeen ?? now, icon: server.icon, keywords: SpotlightLabel.keywords( project: project.name, server: server.server, head: head, url: url), - rankingHint: pinned ? pinnedRankingHint : baseRankingHint, + lastUsed: prior?.lastUsed, + rankingHint: SpotlightLabel.rankingHint(phase: server.phase, pinned: pinned), title: SpotlightLabel.title( project: project.name, server: server.server, head: head), url: url) entriesByIdentifier[identifier] = entry - payload.append((identifier, entry)) + payload.append((identifier, entry, prior != nil)) } } } - let signature = "v6-cs-only|" + payload.map { - "\($0.identifier)#\($0.entry.title)#\($0.entry.url)#\($0.entry.icon ?? "")#\($0.entry.rankingHint)#\($0.entry.keywords.joined(separator: ","))" + let signature = "v7-cs-incremental|" + payload.map { + "\($0.identifier)#\($0.entry.title)#\($0.entry.url)#\($0.entry.icon ?? "")#\($0.entry.rankingHint)#\($0.entry.keywords.joined(separator: ","))#\(($0.entry.alternateNames ?? []).joined(separator: ","))" }.sorted().joined(separator: "|") guard signature != UserDefaults.standard.string(forKey: signatureKey) else { return } if let data = try? JSONCoding.encoder().encode(entriesByIdentifier) { UserDefaults.standard.set(data, forKey: entriesKey) } UserDefaults.standard.set(signature, forKey: signatureKey) - let items = payload - CSSearchableIndex.default().deleteSearchableItems(withDomainIdentifiers: [domain]) { _ in - let built = items.map { element -> CSSearchableItem in - let item = CSSearchableItem( - uniqueIdentifier: element.identifier, - domainIdentifier: domain, - attributeSet: attributeSet(for: element.entry)) - item.expirationDate = .distantFuture - return item - } - let count = built.count - CSSearchableIndex.default().indexSearchableItems(built) { error in - UserDefaults.standard.set( - error.map { "error: \($0.localizedDescription)" } - ?? "ok \(count) items \(JSONCoding.formatISO8601(Date()))", - forKey: statusKey) + + let removed = Set(previous.keys).subtracting(entriesByIdentifier.keys) + let built = payload.map { element -> CSSearchableItem in + let item = CSSearchableItem( + uniqueIdentifier: element.identifier, + domainIdentifier: domain, + attributeSet: attributeSet(for: element.entry)) + item.expirationDate = .distantFuture + item.isUpdate = element.existed + return item + } + let count = built.count + let finish: @Sendable (Error?) -> Void = { error in + UserDefaults.standard.set( + error.map { "error: \($0.localizedDescription)" } + ?? "ok \(count) items \(JSONCoding.formatISO8601(Date()))", + forKey: statusKey) + } + if removed.isEmpty { + index.indexSearchableItems(built, completionHandler: finish) + } else { + let toIndex = built + index.deleteSearchableItems(withIdentifiers: Array(removed)) { + deleteError in + if let deleteError { + finish(deleteError) + return + } + index.indexSearchableItems(toIndex, completionHandler: finish) } } } static func noteOpened(identifier: String) { - guard let entry = loadEntries()[identifier] else { return } + guard var entry = loadEntries()[identifier] else { return } + let now = Date() + entry.lastUsed = now + if entry.firstSeen == nil { entry.firstSeen = now } + var map = loadEntries() + map[identifier] = entry + if let data = try? JSONCoding.encoder().encode(map) { + UserDefaults.standard.set(data, forKey: entriesKey) + } let item = CSSearchableItem( uniqueIdentifier: identifier, domainIdentifier: domain, - attributeSet: attributeSet(for: entry, lastUsed: Date())) + attributeSet: attributeSet(for: entry, lastUsed: now)) item.isUpdate = true item.expirationDate = .distantFuture - CSSearchableIndex.default().indexSearchableItems([item]) { _ in } + index.indexSearchableItems([item]) { _ in } donateActivity(identifier: identifier, entry: entry) } @@ -113,17 +159,27 @@ enum SpotlightIndexer { ) -> CSSearchableItemAttributeSet { /** `.text` surfaces on macOS; `.url` often indexes "ok" but never appears. */ let attributes = CSSearchableItemAttributeSet(contentType: .text) - attributes.displayName = entry.title - attributes.title = entry.title - attributes.contentDescription = SpotlightLabel.subtitle(url: entry.url) - attributes.textContent = "\(entry.title)\n\(SpotlightLabel.subtitle(url: entry.url))" + let created = entry.firstSeen ?? Date() + let used = lastUsed ?? entry.lastUsed + if let names = entry.alternateNames, !names.isEmpty { + attributes.alternateNames = names + } attributes.containerDisplayName = "devctl" - attributes.kind = "Dev Server" + attributes.contentCreationDate = created + attributes.contentDescription = SpotlightLabel.subtitle(url: entry.url) + attributes.contentModificationDate = used ?? created + attributes.displayName = entry.title attributes.keywords = entry.keywords + attributes.kind = "Dev Server" + if let used { attributes.lastUsedDate = used } + attributes.metadataModificationDate = Date() + attributes.organizations = ["devctl"] attributes.rankingHint = NSNumber(value: entry.rankingHint) + attributes.subject = entry.title + attributes.textContent = "\(entry.title)\n\(SpotlightLabel.subtitle(url: entry.url))" attributes.thumbnailData = entry.icon.flatMap(Self.thumbnailPNG) + attributes.title = entry.title attributes.url = URL(string: entry.url) - if let lastUsed { attributes.lastUsedDate = lastUsed } return attributes } @@ -132,8 +188,11 @@ enum SpotlightIndexer { activity.title = entry.title activity.keywords = Set(entry.keywords) activity.userInfo = [CSSearchableItemActivityIdentifier: identifier] + activity.isEligibleForHandoff = false activity.isEligibleForSearch = true activity.persistentIdentifier = identifier + activity.targetContentIdentifier = identifier + activity.expirationDate = .distantFuture if let url = URL(string: entry.url) { activity.webpageURL = url } let attributes = attributeSet(for: entry, lastUsed: Date()) attributes.relatedUniqueIdentifier = identifier @@ -152,14 +211,10 @@ enum SpotlightIndexer { nonisolated static func thumbnailPNG(path: String) -> Data? { guard let image = NSImage(contentsOfFile: path) else { return nil } let side: CGFloat = 128 - let target = NSImage(size: NSSize(width: side, height: side)) - target.lockFocus() - image.draw( - in: NSRect(x: 0, y: 0, width: side, height: side), - from: .zero, - operation: .sourceOver, - fraction: 1) - target.unlockFocus() + let target = NSImage(size: NSSize(width: side, height: side), flipped: false) { rect in + image.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1) + return true + } guard let tiff = target.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff) else { return nil } diff --git a/Sources/DevCtlDaemonCore/Control/ControlServer.swift b/Sources/DevCtlDaemonCore/Control/ControlServer.swift index aebd8e5..176b50c 100644 --- a/Sources/DevCtlDaemonCore/Control/ControlServer.swift +++ b/Sources/DevCtlDaemonCore/Control/ControlServer.swift @@ -12,8 +12,9 @@ public actor Router { private var supervisors: [String: ServerSupervisor] = [:] /** devservers.json views cached by mtime; a save invalidates naturally. */ private var configCache: [String: (mtime: Date, view: ProjectConfigView)] = [:] - /** Held resource locks keyed `project::resource`; stale holders (dead pids) - evaporate on access, so a crashed harness never wedges a resource. */ + /** Held resource locks keyed `project::resource`. Persisted to locks.json so + a daemon crash mid-hold can still resume the paused servers when the + holder is gone. Stale holders (dead pids) evaporate on access. */ private var resourceLocks: [String: LockHolder] = [:] public init(launcher: any ProcessLauncher, paths: DevCtlPaths, registry: Registry) { @@ -21,6 +22,8 @@ public actor Router { self.launcher = launcher self.paths = paths self.registry = registry + self.resourceLocks = + AtomicFile.loadDefensively(LocksFile.self, from: paths.locksFile)?.locks ?? [:] } /** Decodes the typed request for `method` and returns the encoded response @@ -49,7 +52,7 @@ public actor Router { /** Deliberate shutdown stays down: the intent marker keeps auto-bootstrap from resurrecting the daemon, and exit 0 satisfies KeepAlive={SuccessfulExit:false}. */ - try? Data().write(to: await self.paths.stoppedIntentFile) + try? Data().write(to: self.paths.stoppedIntentFile) await self.exitDaemon() } return frame @@ -67,7 +70,7 @@ public actor Router { let supervisor = try await resolvedSupervisor(target) await recordTrustIfNeeded(project: target.project, name: target.name, fileNames: merged.fileNames) if let spec = merged.specs.first(where: { $0.name == target.name }) { - try lockGate(project: target.project, spec: spec) + try await lockGate(project: target.project, spec: spec) } try await portPreCheck(target: target, supervisor: supervisor) let result = await supervisor.ensure(timeoutSeconds: request.params.timeoutSeconds) @@ -81,7 +84,7 @@ public actor Router { await recordTrustIfNeeded( project: request.params.project, name: request.params.name, fileNames: merged.fileNames) if let spec = merged.specs.first(where: { $0.name == request.params.name }) { - try lockGate(project: request.params.project, spec: spec) + try await lockGate(project: request.params.project, spec: spec) } try await portPreCheck(target: request.params, supervisor: supervisor) return try respond(id: head.id, result: ServerResult(server: await supervisor.start())) @@ -169,22 +172,12 @@ public actor Router { id: head.id, result: EnsureResult(reason: reason, server: await supervisor.status())) case .lockAcquire: let request = try decoder.decode(WireRequest.self, from: line) - let key = "\(request.params.project)::\(request.params.resource)" - if let holder = liveLockHolder(key: key), holder.pid != request.params.holderPid { - throw WireError( - code: .resourceLocked, - hint: "wait for pid \(holder.pid) to finish, or verify it: ps -p \(holder.pid)", - message: "resource '\(request.params.resource)' is locked by pid \(holder.pid) since \(JSONCoding.formatISO8601(holder.since))") - } - resourceLocks[key] = LockHolder(pid: request.params.holderPid, since: Date()) - return try respond(id: head.id, result: WireEmpty()) + let result = try await acquireLock(request.params) + return try respond(id: head.id, result: result) case .lockRelease: let request = try decoder.decode(WireRequest.self, from: line) - let key = "\(request.params.project)::\(request.params.resource)" - if resourceLocks[key]?.pid == request.params.holderPid { - resourceLocks[key] = nil - } - return try respond(id: head.id, result: WireEmpty()) + let result = try await releaseLock(request.params) + return try respond(id: head.id, result: result) case .logsQuery: let request = try decoder.decode(WireRequest.self, from: line) let target = ServerTargetParams(name: request.params.name, project: request.params.project) @@ -296,14 +289,15 @@ public actor Router { } } - /** Startup recovery: reconcile persisted state with reality, then restore - boot intent. A recorded pid that is gone becomes crashed(daemon-restart); + /** Startup recovery: reconcile persisted locks first, then restore servers + with boot intent. A recorded pid that is gone becomes crashed(daemon-restart); a live orphan (its spool fd kept it healthy while the daemon was away) is group-killed, since exit forensics are unknowable for non-children. Never adopted silently. What comes back: any server whose start intent survives (resumeOnBoot), which a machine shutdown's drain leaves set, plus the classic daemon-crash case of a phase left running/starting. A deliberate - stop clears the flag, so only those stay down. + stop clears the flag, so only those stay down. Servers still paused under + a live resource lock are left alone: starting them would fight the harness. Specs resolve through the merged view (devservers.json + ad-hoc registry), the same path ensure/status use. Config-defined servers are never written @@ -311,6 +305,8 @@ public actor Router { committed server on boot. A rename/delete with no matching spec drops the orphaned state row instead of retrying forever. */ public func recoverAtStartup() async { + await reconcileLocksAtStartup() + var toStart: [(project: String, spec: ServerSpec)] = [] for (id, persisted) in await registry.allPersistedState() { guard let separator = id.range(of: "::") else { continue } let project = String(id[id.startIndex.. LockHolder? { - guard let holder = resourceLocks[key] else { return nil } - guard kill(pid_t(holder.pid), 0) == 0 else { + /** Acquire: refuse if another live holder owns it, pause active declarers + without retiring boot intent, persist the hold, return who was paused. */ + private func acquireLock(_ params: LockParams) async throws -> LockResult { + let key = "\(params.project)::\(params.resource)" + await releaseOrphanedLock(key: key) + if let holder = resourceLocks[key], holder.pid != params.holderPid { + throw WireError( + code: .resourceLocked, + hint: "wait for pid \(holder.pid) to finish, or verify it: ps -p \(holder.pid)", + message: + "resource '\(params.resource)' is locked by pid \(holder.pid) since \(JSONCoding.formatISO8601(holder.since))" + ) + } + /** Same holder re-acquiring (retry after a blip) keeps the existing pause + set rather than double-stopping. */ + if let existing = resourceLocks[key], existing.pid == params.holderPid { + return LockResult(paused: existing.paused) + } + var paused: [String] = [] + let merged = try? await mergedSpecs(project: params.project) + for spec in merged?.specs ?? [] where (spec.locks ?? []).contains(params.resource) { + let supervisor = await supervisor(project: params.project, spec: spec) + let status = await supervisor.status() + switch status.phase { + case .running, .starting, .unhealthy, .stopping: + /** Non-retiring stop: boot intent survives so a daemon crash + mid-hold can still bring the server back if the holder is gone. */ + _ = await supervisor.stop(deliberate: false) + paused.append(spec.name) + DevCtlLog.daemon.info( + "lock \(params.resource) paused \(spec.name)@\(params.project)") + case .stopped, .crashed, .failed: + break + } + } + paused.sort() + resourceLocks[key] = LockHolder( + paused: paused, pid: params.holderPid, + resumeTimeoutSeconds: params.resumeTimeoutSeconds, since: Date()) + persistLocks() + return LockResult(paused: paused) + } + + /** Release: only the matching holder clears the lock; then ensure everyone + that was paused. */ + private func releaseLock(_ params: LockParams) async throws -> LockResult { + let key = "\(params.project)::\(params.resource)" + guard let holder = resourceLocks[key], holder.pid == params.holderPid else { + return LockResult(paused: []) + } + resourceLocks[key] = nil + persistLocks() + let timeout = params.resumeTimeoutSeconds ?? holder.resumeTimeoutSeconds ?? 60 + await resumePaused( + names: holder.paused, project: params.project, resource: params.resource, + timeoutSeconds: timeout) + return LockResult(paused: holder.paused) + } + + /** Drop dead holders and resume what they paused. Called from gate/acquire + and at startup so a crashed harness (or a dead CLI after a daemon bounce) + never leaves servers stopped. */ + private func releaseOrphanedLock(key: String) async { + guard let holder = resourceLocks[key] else { return } + guard kill(pid_t(holder.pid), 0) != 0 else { return } + guard let separator = key.range(of: "::") else { resourceLocks[key] = nil - return nil + persistLocks() + return } - return holder + let project = String(key[key.startIndex.. Bool { + for (key, holder) in resourceLocks { + guard key.hasPrefix("\(project)::") else { continue } + guard kill(pid_t(holder.pid), 0) == 0 else { continue } + if holder.paused.contains(name) { return true } + } + return false + } + + private func persistLocks() { + /** Empty file is fine: defensive load treats missing/corrupt as {}. */ + try? AtomicFile.write( + JSONCoding.encoder().encode(LocksFile(locks: resourceLocks)), to: paths.locksFile) } /** Refuses to start a server while an external holder owns one of its declared resources: restarting mid-harness-run is exactly the contention the lock exists to prevent. */ - private func lockGate(project: String, spec: ServerSpec) throws { + private func lockGate(project: String, spec: ServerSpec) async throws { for resource in spec.locks ?? [] { - if let holder = liveLockHolder(key: "\(project)::\(resource)") { + let key = "\(project)::\(resource)" + await releaseOrphanedLock(key: key) + if let holder = resourceLocks[key] { throw WireError( code: .resourceLocked, hint: "the holder releases it when done; check: ps -p \(holder.pid)", - message: "server '\(spec.name)' holds resource '\(resource)', locked by pid \(holder.pid) since \(JSONCoding.formatISO8601(holder.since))") + message: + "server '\(spec.name)' holds resource '\(resource)', locked by pid \(holder.pid) since \(JSONCoding.formatISO8601(holder.since))" + ) } } } @@ -706,6 +854,16 @@ public final class ControlServer: Sendable { params.requiredLocalEndpoint = NWEndpoint.unix(path: socketPath) params.allowLocalEndpointReuse = true self.listener = try NWListener(using: params) + listener.stateUpdateHandler = { state in + switch state { + case .failed(let error): + DevCtlLog.daemon.error("control listener failed: \(String(describing: error))") + case .cancelled: + DevCtlLog.daemon.debug("control listener cancelled") + default: + break + } + } listener.newConnectionHandler = { [router] connection in Self.serve(connection: connection, router: router) } @@ -716,8 +874,32 @@ public final class ControlServer: Sendable { listener.start(queue: DispatchQueue(label: "devctl.control")) } + /** A client that exits without a shutdown handshake (every one-shot `devctl` + invocation) surfaces as `.failed` with a peer-close errno. Those are + routine, so they log at debug; anything else is a real listener problem + and stays at error. */ + private static func isRoutineDisconnect(_ error: NWError) -> Bool { + guard case .posix(let code) = error else { return false } + return code == .ENETDOWN || code == .ECONNRESET || code == .EPIPE || code == .ECANCELED + } + private static func serve(connection: NWConnection, router: Router) { let queue = DispatchQueue(label: "devctl.connection") + connection.stateUpdateHandler = { state in + switch state { + case .failed(let error): + if isRoutineDisconnect(error) { + DevCtlLog.daemon.debug("control connection closed by peer: \(String(describing: error))") + } else { + DevCtlLog.daemon.error("control connection failed: \(String(describing: error))") + } + connection.cancel() + case .cancelled: + break + default: + break + } + } connection.start(queue: queue) let hello = try? NDJSON.encodeLine( WireEvent( diff --git a/Sources/DevCtlDaemonCore/Health/HealthProber.swift b/Sources/DevCtlDaemonCore/Health/HealthProber.swift index c428d5b..b5d3573 100644 --- a/Sources/DevCtlDaemonCore/Health/HealthProber.swift +++ b/Sources/DevCtlDaemonCore/Health/HealthProber.swift @@ -47,6 +47,16 @@ public protocol HealthProber: Sendable { } public struct NetworkHealthProber: HealthProber { + /** Dedicated ephemeral session so probes do not share URLSession.shared + caches or the default 60s resource timeout. */ + private static let session: URLSession = { + let config = URLSessionConfiguration.ephemeral + config.timeoutIntervalForRequest = 5 + config.timeoutIntervalForResource = 5 + config.waitsForConnectivity = false + return URLSession(configuration: config, delegate: RedirectStopper.shared, delegateQueue: nil) + }() + public init() {} public func probe(_ check: EffectiveHealthcheck) async -> Bool { @@ -97,8 +107,7 @@ public struct NetworkHealthProber: HealthProber { following it would re-enter hostname resolution the loopback rewrite just avoided, so redirects never get followed. */ private static func httpResponds(_ request: URLRequest) async throws -> Bool { - let (_, response) = try await URLSession.shared.data( - for: request, delegate: RedirectStopper.shared) + let (_, response) = try await session.data(for: request) guard let http = response as? HTTPURLResponse else { return false } return (200..<400).contains(http.statusCode) } diff --git a/Sources/DevCtlDaemonCore/Supervisor/ProcessTree.swift b/Sources/DevCtlDaemonCore/Supervisor/ProcessTree.swift index 89ca8b8..284442e 100644 --- a/Sources/DevCtlDaemonCore/Supervisor/ProcessTree.swift +++ b/Sources/DevCtlDaemonCore/Supervisor/ProcessTree.swift @@ -1,44 +1,165 @@ import Darwin import Foundation +/** A process identity that survives PID reuse: pid alone is not enough across a + grace window, because macOS can recycle the number. Start time comes from + `kinfo_proc.kp_proc.p_starttime`. */ +public struct ProcessIdentity: Hashable, Sendable, Equatable { + public let pid: pid_t + public let startMicroseconds: suseconds_t + public let startSeconds: time_t + + public init(pid: pid_t, startSeconds: time_t, startMicroseconds: suseconds_t) { + self.pid = pid + self.startMicroseconds = startMicroseconds + self.startSeconds = startSeconds + } + + init(_ info: kinfo_proc) { + self.pid = info.kp_proc.p_pid + self.startMicroseconds = info.kp_proc.p_starttime.tv_usec + self.startSeconds = info.kp_proc.p_starttime.tv_sec + } +} + +/** Result of a descendant sweep. Failure must not look like "no children": a + silent empty list drops the escaped-descendant half of teardown. */ +public enum DescendantsResult: Sendable, Equatable { + case failed(errno: Int32) + case ok([ProcessIdentity]) + + public var identities: [ProcessIdentity] { + switch self { + case .failed: [] + case .ok(let ids): ids + } + } + + public var pids: [pid_t] { identities.map(\.pid) } +} + /** Descendant enumeration via sysctl KERN_PROC_ALL. Group-directed signals miss processes that changed their own group (Foundation Process children setpgid; daemonizers setsid), so teardown signals the group AND every live descendant found by walking the parent-pid chain. The snapshot must be taken before the parent dies: orphans reparent to launchd and fall out of the chain. */ -enum ProcessTree { +public enum ProcessTree { /** All live descendants of `pid` (children, grandchildren, ...), excluding `pid` itself. Best-effort: a process spawned after the snapshot is missed. */ - static func descendants(of pid: pid_t) -> [pid_t] { - var name: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0] - var size = 0 - guard sysctl(&name, 4, nil, &size, nil, 0) == 0, size > 0 else { return [] } - /** Headroom: processes can appear between the size probe and the fetch. */ - size += size / 8 - let capacity = size / MemoryLayout.stride - var processes = [kinfo_proc](repeating: kinfo_proc(), count: capacity) - guard sysctl(&name, 4, &processes, &size, nil, 0) == 0 else { return [] } - let count = size / MemoryLayout.stride - var childrenByParent: [pid_t: [pid_t]] = [:] - for info in processes.prefix(count) { - childrenByParent[info.kp_eproc.e_ppid, default: []].append(info.kp_proc.p_pid) - } - var found: [pid_t] = [] - var queue: [pid_t] = [pid] - while let parent = queue.popLast() { - for child in childrenByParent[parent] ?? [] where child != parent { - found.append(child) - queue.append(child) + public static func descendants(of pid: pid_t) -> DescendantsResult { + switch fetchProcessTable() { + case .failed(let errno): + return .failed(errno: errno) + case .ok(let table): + var childrenByParent: [pid_t: [ProcessIdentity]] = [:] + for identity in table { + childrenByParent[identity.parent, default: []].append(identity.process) + } + var found: [ProcessIdentity] = [] + var queue: [pid_t] = [pid] + while let parent = queue.popLast() { + for child in childrenByParent[parent] ?? [] where child.pid != parent { + found.append(child) + queue.append(child.pid) + } } + return .ok(found) } - return found } - /** Signals the process group and every stray descendant outside it. */ - static func signalTree(rootPid: pid_t, descendants: [pid_t], signal: Int32) { + /** Whether a snapshotted identity still names the same process. A nil live + identity means the pid is gone; a start-time mismatch means reuse. */ + public static func shouldSignal(snapshotted: ProcessIdentity, live: ProcessIdentity?) -> Bool { + guard let live else { return false } + return snapshotted == live + } + + /** Round a probed byte count up to whole `kinfo_proc` entries with 12.5% + headroom, then return capacity and the exact allocated byte count to + pass to sysctl (never advertise past the allocation). */ + public static func allocation(forProbedBytes probed: Int) -> (capacity: Int, byteCount: Int) { + let stride = MemoryLayout.stride + guard probed > 0, stride > 0 else { return (0, 0) } + let withHeadroom = probed + probed / 8 + let capacity = max(1, (withHeadroom + stride - 1) / stride) + return (capacity, capacity * stride) + } + + /** Signals the process group and every stray descendant outside it. + When `revalidate` is true, skip identities whose start time no longer + matches (PID reuse after a grace window). */ + public static func signalTree( + rootPid: pid_t, + descendants: [ProcessIdentity], + signal: Int32, + revalidate: Bool = false + ) { kill(-rootPid, signal) - for pid in descendants where getpgid(pid) != rootPid { - kill(pid, signal) + for identity in descendants { + if revalidate { + let live = liveIdentity(of: identity.pid) + guard shouldSignal(snapshotted: identity, live: live) else { continue } + } + if getpgid(identity.pid) != rootPid { + kill(identity.pid, signal) + } + } + } + + /** Individually SIGKILL snapshotted PIDs that still match, used when the + root is already gone so group-directed kill no longer applies. */ + public static func escalateIndividuals(_ identities: [ProcessIdentity]) { + for identity in identities { + let live = liveIdentity(of: identity.pid) + guard shouldSignal(snapshotted: identity, live: live) else { continue } + kill(identity.pid, SIGKILL) } } + + private struct TableRow: Sendable { + let parent: pid_t + let process: ProcessIdentity + } + + private enum TableResult { + case failed(errno: Int32) + case ok([TableRow]) + } + + /** QA1123 shape: 3-level MIB, size probe, rounded allocation, retry ENOMEM. */ + private static func fetchProcessTable() -> TableResult { + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL] + for _ in 0..<8 { + var probed = 0 + guard sysctl(&mib, 3, nil, &probed, nil, 0) == 0, probed > 0 else { + return .failed(errno: errno) + } + let plan = allocation(forProbedBytes: probed) + var buffer = [kinfo_proc](repeating: kinfo_proc(), count: plan.capacity) + var length = plan.byteCount + let status = buffer.withUnsafeMutableBufferPointer { ptr -> Int32 in + sysctl(&mib, 3, ptr.baseAddress, &length, nil, 0) + } + if status == 0 { + let count = length / MemoryLayout.stride + let rows = buffer.prefix(count).map { info in + TableRow(parent: info.kp_eproc.e_ppid, process: ProcessIdentity(info)) + } + return .ok(rows) + } + if errno == ENOMEM { continue } + return .failed(errno: errno) + } + return .failed(errno: ENOMEM) + } + + private static func liveIdentity(of pid: pid_t) -> ProcessIdentity? { + var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid] + var length = MemoryLayout.stride + var info = kinfo_proc() + guard sysctl(&mib, 4, &info, &length, nil, 0) == 0, length >= MemoryLayout.stride + else { return nil } + guard info.kp_proc.p_pid == pid else { return nil } + return ProcessIdentity(info) + } } diff --git a/Sources/DevCtlDaemonCore/Supervisor/ServerSupervisor.swift b/Sources/DevCtlDaemonCore/Supervisor/ServerSupervisor.swift index bf0da95..9d7b32e 100644 --- a/Sources/DevCtlDaemonCore/Supervisor/ServerSupervisor.swift +++ b/Sources/DevCtlDaemonCore/Supervisor/ServerSupervisor.swift @@ -198,7 +198,12 @@ public actor ServerSupervisor { stopRequested = true stopWasDeliberate = deliberate phase = .stopping - let snapshot = ProcessTree.descendants(of: target) + let snapshotResult = ProcessTree.descendants(of: target) + if case .failed(let code) = snapshotResult { + DevCtlLog.supervisor.error( + "descendant sweep failed before SIGTERM (errno \(code)); group-only teardown") + } + let snapshot = snapshotResult.identities ProcessTree.signalTree(rootPid: target, descendants: snapshot, signal: SIGTERM) let deadline = ContinuousClock.now.advanced(by: .seconds(graceSeconds)) while ContinuousClock.now < deadline { @@ -208,13 +213,21 @@ public actor ServerSupervisor { } /** Escalate: the pre-signal snapshot plus a fresh sweep (new children may have appeared during the grace window while the parent lived). */ - let escalation = Set(snapshot).union(ProcessTree.descendants(of: target)) + let fresh = ProcessTree.descendants(of: target) + if case .failed(let code) = fresh { + DevCtlLog.supervisor.error( + "descendant sweep failed before SIGKILL (errno \(code)); using pre-signal snapshot") + } + var byPid: [pid_t: ProcessIdentity] = [:] + for identity in snapshot + fresh.identities { + byPid[identity.pid] = identity + } + let escalation = Array(byPid.values) if kill(target, 0) == 0 { - ProcessTree.signalTree(rootPid: target, descendants: Array(escalation), signal: SIGKILL) + ProcessTree.signalTree( + rootPid: target, descendants: escalation, signal: SIGKILL, revalidate: true) } else { - for pid in escalation where kill(pid, 0) == 0 { - kill(pid, SIGKILL) - } + ProcessTree.escalateIndividuals(escalation) } await waitForRunTaskCompletion() return status() @@ -317,7 +330,7 @@ public actor ServerSupervisor { private func scanObservedPort() { guard let rootPid = pid else { return } Task { [weak self] in - let pids = [rootPid] + ProcessTree.descendants(of: rootPid) + let pids = [rootPid] + ProcessTree.descendants(of: rootPid).pids let ports = PortGuard.listeningPorts(pids: pids) await self?.recordObservedPort(ports: ports) } diff --git a/Sources/DevCtlKit/DeepLink/DeepLinkNotificationAction.swift b/Sources/DevCtlKit/DeepLink/DeepLinkNotificationAction.swift index ae130bb..ebaad33 100644 --- a/Sources/DevCtlKit/DeepLink/DeepLinkNotificationAction.swift +++ b/Sources/DevCtlKit/DeepLink/DeepLinkNotificationAction.swift @@ -8,12 +8,20 @@ public enum DeepLinkNotificationAction: String, Sendable, Equatable, CaseIterabl case open = "dev.quantizor.devctl.notification.open" case why = "dev.quantizor.devctl.notification.why" + /** System default tap (`UNNotificationDefaultActionIdentifier`). Kept as a + string so this module stays free of UserNotifications. */ + public static let defaultActionId = "com.apple.UNNotificationDefaultActionIdentifier" + /** The link a tapped action fires, or nil when the identifier is not one of - ours (a system action like the default tap or dismiss). `head` rides along - only for `open`, matching `DeepLink`'s own head rule. */ + ours and not the body tap. `head` rides along only for `open`, matching + `DeepLink`'s own head rule. Body tap maps to open so a click on the + banner itself is not a dead end. */ public static func link( actionId: String, projectSlug: String, server: String, head: String? = nil ) -> DeepLink? { + if actionId == defaultActionId { + return DeepLink(verb: .open, projectSlug: projectSlug, server: server, head: head) + } guard let action = DeepLinkNotificationAction(rawValue: actionId) else { return nil } switch action { case .open: diff --git a/Sources/DevCtlKit/Launchd/AgentRebindPolicy.swift b/Sources/DevCtlKit/Launchd/AgentRebindPolicy.swift new file mode 100644 index 0000000..ff2d343 --- /dev/null +++ b/Sources/DevCtlKit/Launchd/AgentRebindPolicy.swift @@ -0,0 +1,34 @@ +import Foundation + +/** Decide helpers for SMAppService helper upgrades after a DMG replace. + + Ad-hoc resigns change the helper CDHash. The first `register()` after replace + often dies with Launch Constraint Violation. KeepAlive then asks smd to repair + the LWCR in place; for ad-hoc (empty Team ID) that returns + `Unable to update LWCR with smd: 22` and another 10s throttle. Waiting does + not help. A second unregister+register submits a fresh job whose LWCR matches + the on-disk helper. Developer ID builds are far less sensitive. */ +public enum AgentRebindPolicy { + /** Idle after launchd reports the agent gone, before replacing the helper + or calling register again. Long enough for unload, not a BTM cure. */ + public static let settleSeconds: TimeInterval = 1 + + /** How long to wait for the first post-replace spawn before forcing a + fresh unregister+register (KeepAlive LWCR repair is a dead end). */ + public static let postReplaceHelloSeconds: TimeInterval = 1.5 + + /** Whether launch should register the agent. A post-upgrade rebind marker + outranks a leftover deliberate-stop file from the pre-replace unregister. */ + public static func shouldRegisterAtLaunch( + deliberatelyStopped: Bool, rebindNeeded: Bool + ) -> Bool { + if rebindNeeded { return true } + return !deliberatelyStopped + } + + /** After a brief hello miss on a rebind, skip the throttle wait and + re-register immediately. */ + public static func shouldForceReregisterAfterHelloMiss(rebindNeeded: Bool) -> Bool { + rebindNeeded + } +} diff --git a/Sources/DevCtlKit/Launchd/DaemonRecoveryPolicy.swift b/Sources/DevCtlKit/Launchd/DaemonRecoveryPolicy.swift new file mode 100644 index 0000000..69dfda1 --- /dev/null +++ b/Sources/DevCtlKit/Launchd/DaemonRecoveryPolicy.swift @@ -0,0 +1,41 @@ +import Foundation + +/** Whether an unreachable daemon should be resurrected without being asked. + + Policy, not UI: the menu bar app polls every couple of seconds, so this has + to distinguish "died and should come back" from "the user stopped it" and + from "we just tried and it failed". Recovery shells out to launchctl, which + is why a failed attempt earns a cooldown instead of retrying every poll. */ +public enum DaemonRecoveryPolicy { + public enum Decision: Equatable, Sendable { + /** Socket answered: nothing to do. */ + case healthy + /** Bring it back now. */ + case recover + /** An attempt is already in flight. */ + case inFlight + /** Tried too recently; wait out the cooldown. */ + case cooling + /** `devctl daemon stop` was deliberate; wait for an explicit Start. */ + case awaitUser + } + + public static let cooldown: TimeInterval = 15 + + public static func decide( + reachable: Bool, + stoppedOnPurpose: Bool, + recovering: Bool, + lastAttempt: Date?, + now: Date = Date(), + cooldown: TimeInterval = cooldown + ) -> Decision { + if reachable { return .healthy } + /** Intent outranks liveness: resurrecting a deliberately stopped daemon + would silently undo what the user asked for. */ + if stoppedOnPurpose { return .awaitUser } + if recovering { return .inFlight } + if let lastAttempt, now.timeIntervalSince(lastAttempt) < cooldown { return .cooling } + return .recover + } +} diff --git a/Sources/DevCtlKit/Launchd/LaunchdAdmin.swift b/Sources/DevCtlKit/Launchd/LaunchdAdmin.swift new file mode 100644 index 0000000..14a994f --- /dev/null +++ b/Sources/DevCtlKit/Launchd/LaunchdAdmin.swift @@ -0,0 +1,468 @@ +import Foundation + +/** launchd administration: install renders the LaunchAgent and bootstraps it; + upgrades stage-and-rename the binary (overwriting a running signed Mach-O + gets it SIGKILLed); restart drains, kickstarts, and re-ensures what ran. + Shared by the CLI and the menu bar app so both can bring the daemon back. */ +public enum LaunchdAdmin { + public static let label = "dev.quantizor.devctl" + + public static var plistURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appending(path: "Library/LaunchAgents/\(label).plist") + } + + /** Menu bar app at the standard install location. When present, agent + registration must go through SMAppService inside that process. */ + public static var applicationsAppURL: URL { + URL(fileURLWithPath: SetupPlanner.applicationsAppPath) + } + + public static func applicationsAppPresent() -> Bool { + FileManager.default.fileExists(atPath: applicationsAppURL.path) + } + + /** Ask the menu bar app to register/ensure the SMAppService agent. + CLI processes cannot call `SMAppService.agent` (wrong `Bundle.main`). + Prefer `-a /Applications/devctl.app` so a DMG/Downloads copy with the + same bundle id does not steal the URL. */ + @discardableResult + public static func requestAppAgentEnsure() -> (status: Int32, output: String) { + if applicationsAppPresent() { + return shell( + "/usr/bin/open", ["-a", applicationsAppURL.path, "devctl://daemon/ensure"]) + } + return shell("/usr/bin/open", ["devctl://daemon/ensure"]) + } + + /** Ask the menu bar app to unregister the SMAppService agent. */ + @discardableResult + public static func requestAppAgentUnregister() -> (status: Int32, output: String) { + if applicationsAppPresent() { + return shell( + "/usr/bin/open", ["-a", applicationsAppURL.path, "devctl://daemon/unregister"]) + } + return shell("/usr/bin/open", ["devctl://daemon/unregister"]) + } + + /** True when launchd currently has our agent in the gui domain. */ + public static func isAgentLoaded() -> Bool { + shell("/bin/launchctl", ["print", "gui/\(getuid())/\(label)"]).status == 0 + } + + /** Wait until launchd drops the agent after an unregister, then idle so BTM + can drop the prior launch constraint. Returns false if the job is still + loaded after the timeout (and a last-resort bootout); callers must not + replace the helper in that case. */ + @discardableResult + public static func waitUntilAgentUnloaded( + timeoutSeconds: Double = 10, + settleSeconds: Double = AgentRebindPolicy.settleSeconds + ) async -> Bool { + let deadline = Date().addingTimeInterval(timeoutSeconds) + while Date() < deadline, isAgentLoaded() { + try? await Task.sleep(for: .milliseconds(50)) + } + if isAgentLoaded() { + _ = shell("/bin/launchctl", ["bootout", "gui/\(getuid())/\(label)"]) + let bootoutDeadline = Date().addingTimeInterval(3) + while Date() < bootoutDeadline, isAgentLoaded() { + try? await Task.sleep(for: .milliseconds(50)) + } + } + guard !isAgentLoaded() else { return false } + if settleSeconds > 0 { + try? await Task.sleep(for: .seconds(settleSeconds)) + } + return !isAgentLoaded() + } + + /** Marker for the Applications copy: settle, then register, after a DMG + replace that unregistered first. */ + public static func markAgentRebindNeeded(paths: DevCtlPaths = DevCtlPaths()) throws { + try FileManager.default.createDirectory( + at: paths.dataDir, withIntermediateDirectories: true) + try AtomicFile.write(Data(), to: paths.agentRebindFile) + } + + public static func agentRebindNeeded(paths: DevCtlPaths = DevCtlPaths()) -> Bool { + FileManager.default.fileExists(atPath: paths.agentRebindFile.path) + } + + public static func clearAgentRebindMarker(paths: DevCtlPaths = DevCtlPaths()) { + try? FileManager.default.removeItem(at: paths.agentRebindFile) + } + + /** True when launchd has the job but is waiting out ThrottleInterval + (`minimum runtime` defaults to 10s) after a failed spawn. */ + public static func agentSpawnScheduled() -> Bool { + launchdState().contains("spawn scheduled") + } + + public static func pollHello(paths: DevCtlPaths, timeoutSeconds: Double = 5) async throws { + let deadline = Date().addingTimeInterval(timeoutSeconds) + var lastError: Error = WireError(code: .daemonUnreachable, message: "daemon never answered") + while Date() < deadline { + do { + let client = DaemonClient(socketPath: paths.socketPath) + _ = try await client.request( + .daemonInfo, params: WireEmpty(), expecting: DaemonInfo.self) + return + } catch { + lastError = error + try? await Task.sleep(for: .milliseconds(50)) + } + } + throw lastError + } + + /** True when `devctl daemon stop` left the deliberate-stop marker. Auto + bootstrap honors it; an explicit Start clears it. */ + public static func deliberatelyStopped(paths: DevCtlPaths = DevCtlPaths()) -> Bool { + FileManager.default.fileExists(atPath: paths.stoppedIntentFile.path) + } + + /** First executable `devctld` among `extraCandidates`, then argv0 sibling, + `~/.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]) + candidates.append(arg0.deletingLastPathComponent().appending(path: "devctld")) + candidates.append(SetupPlanner.installedDaemonSiblingURL()) + candidates.append(DevCtlPaths().daemonBinaryDir.appending(path: "devctld")) + let fm = FileManager.default + for url in candidates where fm.isExecutableFile(atPath: url.path) { + return url + } + return nil + } + + /** Auto-bootstrap: only against the default socket (never a test override), + never past a deliberate-stop marker. Prefers the Applications app's + SMAppService path when present; otherwise kickstarts/installs the + legacy home LaunchAgent. */ + @discardableResult + public static func attemptBootstrap( + paths: DevCtlPaths = DevCtlPaths(), + extraDaemonCandidates: [URL] = [], + forceLegacy: Bool = false + ) async -> Bool { + guard ProcessInfo.processInfo.environment["DEVCTL_SOCKET"] == nil else { return false } + guard !deliberatelyStopped(paths: paths) else { return false } + /** With the app installed it owns registration, so a silent socket is + answered by waiting, never by installing a second job. Falling through + to the legacy path here wrote `~/Library/LaunchAgents` back whenever a + command landed inside launchd's respawn throttle, leaving two jobs + competing for one label and socket. The wait clears that throttle. */ + if !forceLegacy, applicationsAppPresent() { + try? writeAgentPath(paths: paths) + _ = requestAppAgentEnsure() + return (try? await pollHello(paths: paths, timeoutSeconds: 12)) != nil + } + if FileManager.default.fileExists(atPath: plistURL.path) { + return (try? await start(paths: paths)) != nil + } + guard let binary = resolveDaemonBinary(extraCandidates: extraDaemonCandidates) else { + return false + } + return (try? await install(daemonBinary: binary, paths: paths, forceLegacy: true)) != nil + } + + /** Explicit user start: clears the stop marker, then kickstarts or installs. */ + public static func startOrInstall( + paths: DevCtlPaths = DevCtlPaths(), + extraDaemonCandidates: [URL] = [], + forceLegacy: Bool = false + ) async throws { + try? FileManager.default.removeItem(at: paths.stoppedIntentFile) + if !forceLegacy, applicationsAppPresent() { + try writeAgentPath(paths: paths) + _ = requestAppAgentEnsure() + try await pollHello(paths: paths, timeoutSeconds: 12) + return + } + if FileManager.default.fileExists(atPath: plistURL.path) { + try await start(paths: paths) + return + } + guard let binary = resolveDaemonBinary(extraCandidates: extraDaemonCandidates) else { + throw WireError( + code: .internalError, + hint: "run: make install (or open the setup panel from the DMG)", + message: "no LaunchAgent and no devctld binary found to install") + } + _ = try await install(daemonBinary: binary, paths: paths, forceLegacy: true) + } + + /** Install (or upgrade) the LaunchAgent. Same bounce contract as restart: + capture what is running, drain, swap binary + bootstrap, re-ensure. The + daemon's recoverAtStartup is the reboot path; install cannot rely on it + alone because a pre-feature state.json may lack resumeOnBoot, and the + CLI already knows the live names from status. */ + /** Install (or upgrade) the agent. When `/Applications/devctl.app` exists + and `forceLegacy` is false, asks the app to register via SMAppService + (correct Bundle.main). Otherwise writes the home LaunchAgent. */ + @discardableResult + public static func install( + daemonBinary: URL, paths: DevCtlPaths, forceLegacy: Bool = false + ) async throws -> [( + project: String, name: String + )] { + let client = DaemonClient(socketPath: paths.socketPath) + let runningServers = await captureActiveServers(client: client) + /** Drain through the daemon FIRST: bootout's own kill can outrun the + SIGTERM drain and orphan server trees mid-escalation. */ + _ = try? await client.request(.daemonShutdown, params: WireEmpty(), expecting: WireEmpty.self) + for _ in 0..<50 where FileManager.default.fileExists(atPath: paths.socketPath) { + if (try? await DaemonClient(socketPath: paths.socketPath) + .request(.daemonInfo, params: WireEmpty(), expecting: DaemonInfo.self)) == nil + { + break + } + try? await Task.sleep(for: .milliseconds(100)) + } + try? FileManager.default.removeItem(at: paths.stoppedIntentFile) + + if !forceLegacy, applicationsAppPresent() { + try writeAgentPath(paths: paths) + /** Leave any legacy home plist for the app to migrate on ensure. */ + _ = requestAppAgentEnsure() + do { + try await pollHello(paths: paths, timeoutSeconds: 10) + } catch { + /** The app owns registration, so the usual reason for silence is + an unapproved background item. Say that instead of a timeout. */ + throw WireError( + code: .daemonUnreachable, + hint: "open \"x-apple.systempreferences:com.apple.LoginItems-Settings.extension\"", + message: + "asked \(SetupPlanner.applicationsAppPath) to start devctld, but it never answered. If macOS is waiting for permission, turn on quantizor/devctl in System Settings > General > Login Items & Extensions, or run: devctl daemon install --legacy") + } + await reensure(runningServers, paths: paths) + return runningServers + } + + let fm = FileManager.default + try fm.createDirectory(at: paths.daemonBinaryDir, withIntermediateDirectories: true) + let destination = paths.daemonBinaryDir.appending(path: "devctld") + /** Stage-and-rename: never cp over a running signed binary. */ + let staged = paths.daemonBinaryDir.appending(path: ".devctld.staged-\(getpid())") + try? fm.removeItem(at: staged) + try fm.copyItem(at: daemonBinary, to: staged) + _ = try fm.replaceItemAt(destination, withItemAt: staged) + let plist = renderPlist(daemonPath: destination.path, paths: paths) + try fm.createDirectory(at: plistURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data(plist.utf8).write(to: plistURL) + try? fm.removeItem(at: paths.stoppedIntentFile) + _ = shell("/bin/launchctl", ["bootout", "gui/\(getuid())/\(label)"]) + let bootstrap = shell("/bin/launchctl", ["bootstrap", "gui/\(getuid())", plistURL.path]) + /** A concurrent session bootstrapping first reports already-bootstrapped; + the socket poll below is the actual success signal. */ + if bootstrap.status != 0, !bootstrap.output.contains("already bootstrapped"), + !bootstrap.output.contains("Bootstrap failed: 5: Input/output error") + { + throw WireError( + code: .internalError, + hint: "run: launchctl bootstrap gui/\(getuid()) \(plistURL.path)", + message: "launchctl bootstrap failed (\(bootstrap.status)): \(bootstrap.output)") + } + try await pollHello(paths: paths) + await reensure(runningServers, paths: paths) + return runningServers + } + + public static func uninstall(paths: DevCtlPaths, purge: Bool) async { + let client = DaemonClient(socketPath: paths.socketPath) + _ = try? await client.request(.daemonShutdown, params: WireEmpty(), expecting: WireEmpty.self) + if applicationsAppPresent() { + _ = requestAppAgentUnregister() + /** Brief wait so the app can finish SMAppService.unregister. */ + try? await Task.sleep(for: .milliseconds(800)) + } + _ = shell("/bin/launchctl", ["bootout", "gui/\(getuid())/\(label)"]) + try? FileManager.default.removeItem(at: plistURL) + if purge { + try? FileManager.default.removeItem(at: paths.dataDir) + try? FileManager.default.removeItem(at: paths.logsDir) + } + } + + public static func start(paths: DevCtlPaths) async throws { + try? FileManager.default.removeItem(at: paths.stoppedIntentFile) + try? writeAgentPath(paths: paths) + guard FileManager.default.fileExists(atPath: plistURL.path) else { + throw WireError( + code: .internalError, + hint: "run: devctl daemon install", + message: "no LaunchAgent installed at \(plistURL.path)") + } + let result = shell("/bin/launchctl", ["kickstart", "gui/\(getuid())/\(label)"]) + if result.status != 0 { + _ = shell("/bin/launchctl", ["bootstrap", "gui/\(getuid())", plistURL.path]) + } + try await pollHello(paths: paths) + } + + /** Drain, kickstart -k, re-ensure what was running: "servers bounce, then + come back" is the restart contract. */ + @discardableResult + public static func restart(paths: DevCtlPaths) async throws -> [(project: String, name: String)] { + let client = DaemonClient(socketPath: paths.socketPath) + let runningServers = await captureActiveServers(client: client) + try? FileManager.default.removeItem(at: paths.stoppedIntentFile) + let result = shell("/bin/launchctl", ["kickstart", "-k", "gui/\(getuid())/\(label)"]) + if result.status != 0 { + throw WireError( + code: .internalError, + hint: "run: devctl daemon install", + message: "launchctl kickstart failed (\(result.status)): \(result.output)") + } + try await pollHello(paths: paths) + await reensure(runningServers, paths: paths) + return runningServers + } + + public static func captureActiveServers(client: DaemonClient) async -> [( + project: String, name: String + )] { + guard + let all = try? await client.request( + .serverStatus, params: ProjectParams(project: ""), expecting: ServerListResult.self) + else { return [] } + return all.servers + .filter { $0.phase == .running || $0.phase == .starting || $0.phase == .unhealthy } + .map { (project: $0.project, name: $0.server) } + } + + public static func reensure( + _ servers: [(project: String, name: String)], paths: DevCtlPaths + ) async { + let fresh = DaemonClient(socketPath: paths.socketPath) + for server in servers { + _ = try? await fresh.request( + .serverEnsure, + params: EnsureParams(name: server.name, project: server.project, timeoutSeconds: 60), + expecting: EnsureResult.self) + } + } + + public static func launchdState() -> String { + let result = shell("/bin/launchctl", ["print", "gui/\(getuid())/\(label)"]) + if result.status != 0 { return "not bootstrapped" } + for line in result.output.split(separator: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("state =") || trimmed.hasPrefix("pid =") { + return trimmed + } + } + return "bootstrapped" + } + + /** Floor PATH for sealed in-bundle LaunchAgent plists. Dynamic user PATH + lives in `agent.path` and is applied by the daemon at start. */ + 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. */ + public static func capturedPath() -> String { + let result = shell("/bin/zsh", ["-lc", "echo $PATH"]) + let path = result.output.trimmingCharacters(in: .whitespacesAndNewlines) + return path.isEmpty ? pathFloor : path + } + + /** Persist the login-shell PATH for the daemon to merge at start. */ + public static func writeAgentPath( + _ path: String = capturedPath(), paths: DevCtlPaths = DevCtlPaths() + ) throws { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + let payload = trimmed.isEmpty ? pathFloor : trimmed + try AtomicFile.write(Data(payload.utf8), to: paths.agentPathFile) + } + + public static func readAgentPath(paths: DevCtlPaths = DevCtlPaths()) -> String? { + guard let data = try? Data(contentsOf: paths.agentPathFile), + let text = String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines), + !text.isEmpty + else { return nil } + return text + } + + /** Apply the persisted PATH into this process before spawning children. */ + public static func applyAgentPathToProcess(paths: DevCtlPaths = DevCtlPaths()) { + guard let path = readAgentPath(paths: paths) else { return } + setenv("PATH", path, 1) + } + + /** LaunchAgent plist for CLI-only installs. AssociatedBundleIdentifiers is + a best-effort BTM hint; Login Items naming for the app path comes from + SMAppService + the responsible app's CFBundleDisplayName. Legacy plists + still bake PATH; the daemon also reads agent.path so both stay in sync. */ + public static func renderPlist(daemonPath: String, paths: DevCtlPaths) -> String { + let resolvedPath: String + do { + try writeAgentPath(paths: paths) + resolvedPath = readAgentPath(paths: paths) ?? capturedPath() + } catch { + resolvedPath = capturedPath() + } + return """ + + + + + AssociatedBundleIdentifiers + + dev.quantizor.devctl.app + + EnvironmentVariables + + PATH + \(resolvedPath) + + ExitTimeOut + 60 + KeepAlive + + SuccessfulExit + + + Label + \(label) + ProcessType + Interactive + ProgramArguments + + \(daemonPath) + + RunAtLoad + + StandardErrorPath + \(paths.daemonLog.path) + StandardOutPath + \(paths.daemonLog.path) + + + """ + } + + @discardableResult + public static func shell(_ path: String, _ arguments: [String]) -> (status: Int32, output: String) { + let process = Process() + process.executableURL = URL(fileURLWithPath: path) + process.arguments = arguments + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + do { + try process.run() + } catch { + 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)) + } +} diff --git a/Sources/DevCtlKit/Paths/Paths.swift b/Sources/DevCtlKit/Paths/Paths.swift index e848927..0bc8837 100644 --- a/Sources/DevCtlKit/Paths/Paths.swift +++ b/Sources/DevCtlKit/Paths/Paths.swift @@ -13,7 +13,18 @@ public struct DevCtlPaths: Sendable { public var daemonBinaryDir: URL { dataDir.appending(path: "bin") } public var daemonLog: URL { dataDir.appending(path: "daemon.log") } + /** Login-shell PATH captured at install/start. The sealed in-bundle + LaunchAgent cannot hold a dynamic PATH; the daemon merges this file + into its environment at startup so children still find Homebrew tools. */ + public var agentPathFile: URL { dataDir.appending(path: "agent.path") } + /** Written before replacing `/Applications/devctl.app` so the relaunched + copy settles BTM before re-registering the helper (ad-hoc CDHash). */ + public var agentRebindFile: URL { dataDir.appending(path: "agent.rebind") } public var lockFile: URL { dataDir.appending(path: "daemon.lock") } + /** Held resource locks (`project::resource` → holder + paused servers). + Survives a daemon crash so mid-lock deaths can still resume what pause + stopped. */ + public var locksFile: URL { dataDir.appending(path: "locks.json") } public var registryFile: URL { dataDir.appending(path: "registry.json") } public var stateFile: URL { dataDir.appending(path: "state.json") } public var stoppedIntentFile: URL { dataDir.appending(path: "stopped.intent") } @@ -92,10 +103,15 @@ public func serverID(project: String, name: String) -> String { failure quarantines the file to `.corrupt-` and returns nil rather than crashing (a startup parse crash under launchd KeepAlive loops forever). */ public enum AtomicFile { + /** Temp + fsync + rename. The temp name carries a per-call unique suffix, not + just the pid: two writers inside one process (the app registers the agent + at launch while its recovery poll writes the same file) would otherwise + share a temp path and rename it out from under each other. */ public static func write(_ data: Data, to url: URL) throws { let dir = url.deletingLastPathComponent() try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) - let tmp = dir.appending(path: ".\(url.lastPathComponent).tmp-\(getpid())") + let tmp = dir.appending( + path: ".\(url.lastPathComponent).tmp-\(getpid())-\(UUID().uuidString)") try data.write(to: tmp) let fd = open(tmp.path, O_WRONLY) if fd >= 0 { diff --git a/Sources/DevCtlKit/Protocol/Wire.swift b/Sources/DevCtlKit/Protocol/Wire.swift index 3f05580..13fa62d 100644 --- a/Sources/DevCtlKit/Protocol/Wire.swift +++ b/Sources/DevCtlKit/Protocol/Wire.swift @@ -283,24 +283,57 @@ public struct LockParams: Codable, Equatable, Sendable { public var holderPid: Int public var project: String public var resource: String + /** Per-server health wait used when this hold ends (release or orphan + cleanup). Optional so older clients keep decoding. */ + public var resumeTimeoutSeconds: Double? - public init(holderPid: Int, project: String, resource: String) { + public init( + holderPid: Int, project: String, resource: String, resumeTimeoutSeconds: Double? = nil + ) { self.holderPid = holderPid self.project = project self.resource = resource + self.resumeTimeoutSeconds = resumeTimeoutSeconds } } +/** In-memory and on-disk lock record. `paused` is who the daemon stopped for + this hold so a crash mid-lock can resume them when the holder is gone. */ public struct LockHolder: Codable, Equatable, Sendable { + public var paused: [String] public var pid: Int + public var resumeTimeoutSeconds: Double? public var since: Date - public init(pid: Int, since: Date) { + public init( + paused: [String] = [], pid: Int, resumeTimeoutSeconds: Double? = nil, since: Date + ) { + self.paused = paused self.pid = pid + self.resumeTimeoutSeconds = resumeTimeoutSeconds self.since = since } } +/** Result of lock.acquire / lock.release: which servers were paused or resumed. */ +public struct LockResult: Codable, Equatable, Sendable { + public var paused: [String] + + public init(paused: [String] = []) { + self.paused = paused + } +} + +/** Persisted resource locks (`locks.json`). Optional on load so a missing or + pre-feature file is just empty. */ +public struct LocksFile: Codable, Sendable { + public var locks: [String: LockHolder] + + public init(locks: [String: LockHolder] = [:]) { + self.locks = locks + } +} + public struct ProjectOnlyParams: Codable, Equatable, Sendable { public var project: String diff --git a/Sources/DevCtlKit/Setup/SetupPlanner.swift b/Sources/DevCtlKit/Setup/SetupPlanner.swift new file mode 100644 index 0000000..14cf8d3 --- /dev/null +++ b/Sources/DevCtlKit/Setup/SetupPlanner.swift @@ -0,0 +1,265 @@ +import Foundation + +/** First-run / upgrade planning for the menu bar app installer. Pure decisions + only: the app performs copies and shells out to the installed CLI. Layout + matches `make install` (`~/.local/bin`) so DMG and source installs converge. */ +public enum SetupPlanner { + public static let applicationsAppPath = "/Applications/devctl.app" + public static let cliBinaryName = "devctl" + public static let daemonBinaryName = "devctld" + public static let resourceCLIName = "devctl" + public static let resourceDaemonName = "devctld" + public static let stampFileName = "setup.stamp" + + /** Canonical CLI directory shared with `make install` (`PREFIX/bin`). */ + public static func defaultCLIDirectory(home: URL = FileManager.default.homeDirectoryForCurrentUser) + -> URL + { + home.appending(path: ".local/bin") + } + + public static func stampURL(paths: DevCtlPaths) -> URL { + paths.dataDir.appending(path: stampFileName) + } + + public static func installedCLIURL(home: URL = FileManager.default.homeDirectoryForCurrentUser) + -> URL + { + defaultCLIDirectory(home: home).appending(path: cliBinaryName) + } + + public static func installedDaemonSiblingURL( + home: URL = FileManager.default.homeDirectoryForCurrentUser + ) -> URL { + defaultCLIDirectory(home: home).appending(path: daemonBinaryName) + } + + /** Semver-ish compare: `"1.2.0"`, optionally with a trailing build label. + Missing/unparseable sides sort as older than a concrete version. */ + public static func compareVersions(_ lhs: String, _ rhs: String) -> ComparisonResult { + let left = parseVersion(lhs) + let right = parseVersion(rhs) + if left.isEmpty && right.isEmpty { return .orderedSame } + if left.isEmpty { return .orderedAscending } + if right.isEmpty { return .orderedDescending } + let count = max(left.count, right.count) + for i in 0.. b { return .orderedDescending } + } + return .orderedSame + } + + /** Whether the setup / upgrade panel should appear. */ + public static func shouldPresent( + bundledVersion: String, + installedCLIVersion: String?, + stampVersion: String?, + resourcesPresent: Bool, + runningOutsideApplications: Bool = false + ) -> Bool { + guard resourcesPresent else { return false } + /** Opening from a DMG or Downloads always offers install: the app must + land in /Applications so login items and deep links keep working. */ + if runningOutsideApplications { return true } + if stampVersion == nil { return true } + if installedCLIVersion == nil { return true } + if let installed = installedCLIVersion, + compareVersions(bundledVersion, installed) == .orderedDescending + { + return true + } + if let stamped = stampVersion, + compareVersions(bundledVersion, stamped) == .orderedDescending + { + return true + } + return false + } + + /** True when this process is not already `/Applications/devctl.app`. */ + public static func isRunningOutsideApplications(bundlePath: String) -> Bool { + let running = URL(fileURLWithPath: bundlePath).resolvingSymlinksInPath() + .standardizedFileURL.path + let apps = URL(fileURLWithPath: applicationsAppPath).resolvingSymlinksInPath() + .standardizedFileURL.path + return running != apps + } + + /** True when an on-disk CLI or LaunchAgent-era install already exists. */ + public static func isMigration( + installedCLIExists: Bool, + stampExists: Bool, + launchAgentExists: Bool + ) -> Bool { + installedCLIExists || stampExists || launchAgentExists + } + + /** True when /Applications already has a copy (replace vs first place). */ + public static func applicationsAppExists( + fileManager: FileManager = .default + ) -> Bool { + fileManager.fileExists(atPath: applicationsAppPath) + } + + /** Detect agent harnesses and whether their hooks still need installing. */ + public static func harnessOffers( + home: URL = FileManager.default.homeDirectoryForCurrentUser, + installedCLIPath: String + ) -> [HarnessOffer] { + var offers: [HarnessOffer] = [] + let claudeDir = home.appending(path: ".claude") + if FileManager.default.fileExists(atPath: claudeDir.path) { + let installed = HookPresence.claudeHookInstalled( + settingsURL: claudeDir.appending(path: "settings.json"), + expectedCLIPath: installedCLIPath) + offers.append( + HarnessOffer( + alreadyInstalled: installed, + defaultChecked: !installed, + displayName: "Claude Code", + harness: "claude")) + } + let cursorDir = home.appending(path: ".cursor") + if FileManager.default.fileExists(atPath: cursorDir.path) { + let installed = HookPresence.cursorHookInstalled( + settingsURL: cursorDir.appending(path: "hooks.json"), + expectedCLIPath: installedCLIPath) + offers.append( + HarnessOffer( + alreadyInstalled: installed, + defaultChecked: !installed, + displayName: "Cursor", + harness: "cursor")) + } + return offers.sorted { $0.harness < $1.harness } + } + + /** Whether `~/.local/bin` appears on PATH (split on `:`). */ + public static func cliDirectoryOnPATH( + pathEnv: String?, + cliDirectory: URL = defaultCLIDirectory() + ) -> Bool { + let needle = cliDirectory.path + let path = pathEnv ?? "" + return path.split(separator: ":").contains { String($0) == needle } + } + + /** Read a prior setup stamp (plain version string). */ + public static func readStamp(at url: URL) -> String? { + guard let data = try? Data(contentsOf: url), + let text = String(data: data, encoding: .utf8) + else { return nil } + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + /** Persist the installed bundled version after a successful setup. */ + public static func writeStamp(version: String, to url: URL) throws { + try AtomicFile.write(Data("\(version)\n".utf8), to: url) + } + + /** Stage-and-rename a Mach-O into place (never overwrite a running signed binary). */ + public static func installBinary(from source: URL, to destination: URL) throws { + let fm = FileManager.default + try fm.createDirectory( + at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + let staged = destination.deletingLastPathComponent() + .appending(path: ".\(destination.lastPathComponent).staged-\(getpid())") + try? fm.removeItem(at: staged) + try fm.copyItem(at: source, to: staged) + try fm.setAttributes( + [.posixPermissions: NSNumber(value: Int16(0o755))], ofItemAtPath: staged.path) + _ = try fm.replaceItemAt(destination, withItemAt: staged) + } + + /** Stage-and-replace an .app bundle into /Applications (or `destination`). + Caller must quit any process still holding the destination first. */ + public static func installAppBundle(from source: URL, to destination: URL) throws { + let fm = FileManager.default + try fm.createDirectory( + at: destination.deletingLastPathComponent(), withIntermediateDirectories: true) + let staged = destination.deletingLastPathComponent() + .appending(path: ".\(destination.lastPathComponent).staged-\(getpid())") + try? fm.removeItem(at: staged) + try fm.copyItem(at: source, to: staged) + clearQuarantine(at: staged) + if fm.fileExists(atPath: destination.path) { + _ = try fm.replaceItemAt(destination, withItemAt: staged) + } else { + try fm.moveItem(at: staged, to: destination) + } + clearQuarantine(at: destination) + } + + /** Drop the DMG/Downloads quarantine so Gatekeeper trusts the Applications copy. */ + private static func clearQuarantine(at url: URL) { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: "/usr/bin/xattr") + proc.arguments = ["-dr", "com.apple.quarantine", url.path] + proc.standardOutput = Pipe() + proc.standardError = Pipe() + try? proc.run() + proc.waitUntilExit() + } + + private static func parseVersion(_ raw: String) -> [Int] { + let head = raw.split(separator: " ", maxSplits: 1).first.map(String.init) ?? raw + let numeric = head.split(separator: "-").first.map(String.init) ?? head + return numeric.split(separator: ".").compactMap { Int($0) } + } +} + +/** One harness row on the first-run panel. */ +public struct HarnessOffer: Equatable, Sendable { + public var alreadyInstalled: Bool + public var defaultChecked: Bool + public var displayName: String + public var harness: String + + public init( + alreadyInstalled: Bool, + defaultChecked: Bool, + displayName: String, + harness: String + ) { + self.alreadyInstalled = alreadyInstalled + self.defaultChecked = defaultChecked + self.displayName = displayName + self.harness = harness + } +} + +/** Lightweight read of harness settings to decide checkbox defaults. Mirrors the + CLI adapters' "already installed" checks without writing. */ +enum HookPresence { + static func claudeHookInstalled(settingsURL: URL, expectedCLIPath: String) -> Bool { + guard let data = try? Data(contentsOf: settingsURL), + let settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let hooks = settings["hooks"] as? [String: Any], + let sessionStart = hooks["SessionStart"] as? [[String: Any]] + else { return false } + let expected = "\(expectedCLIPath) hook claude-session-start" + return sessionStart.contains { entry in + ((entry["hooks"] as? [[String: Any]]) ?? []).contains { hook in + let command = hook["command"] as? String ?? "" + return command == expected || command.contains("devctl hook claude-session-start") + } + } + } + + static func cursorHookInstalled(settingsURL: URL, expectedCLIPath: String) -> Bool { + guard let data = try? Data(contentsOf: settingsURL), + let settings = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let hooks = settings["hooks"] as? [String: Any], + let sessionStart = hooks["sessionStart"] as? [[String: Any]] + else { return false } + let expected = "\(expectedCLIPath) hook cursor-session-start" + return sessionStart.contains { entry in + let command = entry["command"] as? String ?? "" + return command == expected || command.contains("devctl hook cursor-session-start") + } + } +} diff --git a/Sources/DevCtlKit/Spotlight/SpotlightLabel.swift b/Sources/DevCtlKit/Spotlight/SpotlightLabel.swift index 4456385..3249010 100644 --- a/Sources/DevCtlKit/Spotlight/SpotlightLabel.swift +++ b/Sources/DevCtlKit/Spotlight/SpotlightLabel.swift @@ -22,6 +22,34 @@ public enum SpotlightLabel { "devctl · \(url)" } + /** Alternate titles Spotlight can match against without stuffing keywords. + Host leaf and head name are the tokens people type after the project. */ + public static func alternateNames(project: String, server: String, head: String?, url: String) + -> [String] + { + var names: Set = [project, server] + if let head { names.insert(head) } + if let host = URL(string: url)?.host { + let leaf = host.split(separator: ".").first.map(String.init) + if let leaf, !leaf.isEmpty, leaf != "localhost" { + names.insert(leaf) + } + } + names.remove(title(project: project, server: server, head: head)) + return names.sorted() + } + + /** Relative importance among our own indexed items (0…100). Live and pinned + surfaces win within the app; this cannot outrank filesystem Top Hits. */ + public static func rankingHint(phase: ServerPhase, pinned: Bool) -> Int { + if pinned { return 100 } + switch phase { + case .running, .unhealthy: return 100 + case .starting, .stopping: return 95 + case .crashed, .failed, .stopped: return 90 + } + } + /** Keyword tokens: project, server, head, distinctive host/path parts, plus the standing "devctl" / "dev server" anchors so a typed "devctl candor" still lands. */ diff --git a/Sources/devctl/CLI.swift b/Sources/devctl/CLI.swift index 4961c5e..6fd6d0c 100644 --- a/Sources/devctl/CLI.swift +++ b/Sources/devctl/CLI.swift @@ -140,14 +140,7 @@ enum CLIRunner { never past a deliberate-stop marker, install-if-missing when the devctld binary ships alongside this CLI. */ static func attemptBootstrap() async -> Bool { - guard ProcessInfo.processInfo.environment["DEVCTL_SOCKET"] == nil else { return false } - let paths = DevCtlPaths() - guard !FileManager.default.fileExists(atPath: paths.stoppedIntentFile.path) else { return false } - if FileManager.default.fileExists(atPath: LaunchdAdmin.plistURL.path) { - return (try? await LaunchdAdmin.start(paths: paths)) != nil - } - guard let binary = LaunchdAdmin.bundledDaemonBinary() else { return false } - return (try? await LaunchdAdmin.install(daemonBinary: binary, paths: paths)) != nil + await LaunchdAdmin.attemptBootstrap(extraDaemonCandidates: [CLISelf.daemonSibling]) } static func describe(_ status: ServerStatus) -> String { @@ -1019,12 +1012,19 @@ struct Doctor: AsyncParsableCommand { findings.append( Finding(detail: "v\(info.daemonVersion) pid \(info.pid) on \(info.socketPath)", kind: "daemon", severity: "ok")) let livePath = LaunchdAdmin.capturedPath() + let storedPath = LaunchdAdmin.readAgentPath() if let daemonPath = info.searchPath, daemonPath != livePath { findings.append( Finding( detail: "daemon PATH differs from the current login shell (captured at install; run: devctl daemon install)", kind: "path-staleness", severity: "warning")) } + if let storedPath, storedPath != livePath { + findings.append( + Finding( + detail: "agent.path differs from the current login shell (run: devctl daemon install)", + kind: "path-staleness", severity: "warning")) + } } else { findings.append( Finding(detail: "daemon not responding (run: devctl daemon status)", kind: "daemon", severity: "error")) @@ -1138,10 +1138,17 @@ struct DaemonInstall: AsyncParsableCommand { @Option(help: "Path to the devctld binary; defaults to the one alongside this devctl.") var devctld: String? + @Flag( + help: + "Force the home LaunchAgent path even when /Applications/devctl.app is present (CLI-only / smoke)." + ) + var legacy = false + @OptionGroup var global: GlobalOptions func run() async throws { - let binary = devctld.map { URL(fileURLWithPath: $0) } ?? LaunchdAdmin.bundledDaemonBinary() + let binary = devctld.map { URL(fileURLWithPath: $0) } + ?? LaunchdAdmin.resolveDaemonBinary(extraCandidates: [CLISelf.daemonSibling]) guard let binary else { CLIRunner.fail( WireError( @@ -1152,14 +1159,20 @@ struct DaemonInstall: AsyncParsableCommand { } let restored: [(project: String, name: String)] do { - restored = try await LaunchdAdmin.install(daemonBinary: binary, paths: DevCtlPaths()) + restored = try await LaunchdAdmin.install( + daemonBinary: binary, paths: DevCtlPaths(), forceLegacy: legacy) } catch let error as WireError { CLIRunner.fail(error, json: global.json) } + let viaApp = !legacy && LaunchdAdmin.applicationsAppPresent() CLIRunner.emit(WireEmpty(), json: global.json) { _ in - restored.isEmpty - ? "devctld installed and running (\(LaunchdAdmin.label))" - : "devctld installed; re-ensured \(restored.map(\.name).joined(separator: ", "))" + if restored.isEmpty { + viaApp + ? "devctld ensured via \(SetupPlanner.applicationsAppPath) (Login Items)" + : "devctld installed and running (\(LaunchdAdmin.label))" + } else { + "devctld installed; re-ensured \(restored.map(\.name).joined(separator: ", "))" + } } } } @@ -1185,11 +1198,20 @@ struct DaemonStart: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "start", abstract: "Start a stopped daemon (clears the deliberate-stop marker).") + @Flag( + help: + "Force the home LaunchAgent path even when /Applications/devctl.app is present." + ) + var legacy = false + @OptionGroup var global: GlobalOptions func run() async throws { do { - try await LaunchdAdmin.start(paths: DevCtlPaths()) + try await LaunchdAdmin.startOrInstall( + paths: DevCtlPaths(), + extraDaemonCandidates: [CLISelf.daemonSibling], + forceLegacy: legacy) } catch let error as WireError { CLIRunner.fail(error, json: global.json) } @@ -1386,11 +1408,11 @@ struct Switch: AsyncParsableCommand { } } -/** Runs a command while holding a named resource exclusively: managed servers - declaring the resource in their `locks` are stopped first and re-ensured - after, and their starts are refused for the duration, so a test harness's - private server never contends with the managed one over shared mutable - state (a local database, a fixture tree). */ +/** Runs a command while holding a named resource exclusively. The daemon + pauses managed servers that declare the resource, refuses their starts for + the duration, and resumes them on release (or on recover when this process + dies), so a test harness's private server never contends with the managed + one over shared mutable state (a local database, a fixture tree). */ struct Lock: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "Run a command holding a project resource; conflicting servers pause and return.") @@ -1421,52 +1443,32 @@ struct Lock: AsyncParsableCommand { let project = global.resolvedProject() let holderPid = Int(getpid()) let client = CLIRunner.client() - /** Which managed holders are up now decides what comes back after. */ - let statuses = (try? await client.request( - .serverStatus, params: ProjectParams(project: project), expecting: ServerListResult.self))? - .servers ?? [] - let merged = statuses.filter { status in - status.phase == .running || status.phase == .starting || status.phase == .unhealthy - } - var toRestore: [String] = [] - /** Acquire with patience: another harness may hold it. */ + /** Acquire with patience: another harness may hold it. The daemon owns + pause/resume of declaring servers; this CLI just runs the command. */ let deadline = Date().addingTimeInterval(acquireTimeout) - var acquired = false + var acquired: LockResult? var lastError: WireError? while Date() < deadline { do { - _ = try await client.request( + acquired = try await client.request( .lockAcquire, - params: LockParams(holderPid: holderPid, project: project, resource: resource), - expecting: WireEmpty.self) - acquired = true + params: LockParams( + holderPid: holderPid, project: project, resource: resource, + resumeTimeoutSeconds: timeout), + expecting: LockResult.self) break } catch let error as WireError where error.code == .resourceLocked { lastError = error try? await Task.sleep(for: .seconds(1)) } } - guard acquired else { + guard let acquired else { CLIRunner.fail( lastError ?? WireError(code: .resourceLocked, message: "could not acquire '\(resource)'"), json: global.json) } - /** Stop the managed holders of this resource (recorded for the return - trip). The daemon knows which specs declare it via config; the CLI - asks per-server status and stops the ones whose spec declares the - resource: simplest source is the config itself. */ - if let view = try? ProjectConfigLoader.load(project: project) { - for spec in view.specs where (spec.locks ?? []).contains(resource) { - let wasActive = merged.contains { $0.server == spec.name } - if wasActive { - toRestore.append(spec.name) - _ = try? await client.request( - .serverStop, - params: ServerTargetParams(name: spec.name, project: project), - expecting: ServerResult.self) - print("paused \(spec.name) (holds \(resource))") - } - } + for name in acquired.paused { + print("paused \(name) (holds \(resource))") } /** Run the guarded command with inherited stdio. */ let process = Process() @@ -1481,18 +1483,15 @@ struct Lock: AsyncParsableCommand { } catch { FileHandle.standardError.write(Data("devctl lock: cannot run command: \(error)\n".utf8)) } - /** Release, then bring the paused servers back even if the command - failed: the checkpointed world returns either way. */ - _ = try? await client.request( + /** Release resumes whoever was paused, even if the command failed. */ + let released = (try? await client.request( .lockRelease, - params: LockParams(holderPid: holderPid, project: project, resource: resource), - expecting: WireEmpty.self) - for name in toRestore { + params: LockParams( + holderPid: holderPid, project: project, resource: resource, + resumeTimeoutSeconds: timeout), + expecting: LockResult.self)) ?? LockResult() + for name in released.paused { print("resuming \(name)…") - _ = try? await client.request( - .serverEnsure, - params: EnsureParams(name: name, project: project, timeoutSeconds: timeout), - expecting: EnsureResult.self) } Foundation.exit(commandStatus) } diff --git a/Sources/devctl/HookSupport.swift b/Sources/devctl/HookSupport.swift index 0d8418a..44868ff 100644 --- a/Sources/devctl/HookSupport.swift +++ b/Sources/devctl/HookSupport.swift @@ -14,6 +14,12 @@ enum CLISelf { return fallbackPath() } + /** The devctld that shipped alongside this devctl; the preferred install + source because it is version-matched to this binary. */ + static var daemonSibling: URL { + URL(fileURLWithPath: path).deletingLastPathComponent().appending(path: "devctld") + } + /** Standard 2-call `_NSGetExecutablePath`: query size, then fill. */ private static func dyldExecutablePath() -> String? { var size: UInt32 = 0 diff --git a/Sources/devctl/LaunchdAdmin.swift b/Sources/devctl/LaunchdAdmin.swift deleted file mode 100644 index 820a0fe..0000000 --- a/Sources/devctl/LaunchdAdmin.swift +++ /dev/null @@ -1,225 +0,0 @@ -import DevCtlKit -import Foundation - -/** launchd administration: install renders the LaunchAgent and bootstraps it; - upgrades stage-and-rename the binary (overwriting a running signed Mach-O - gets it SIGKILLed); restart drains, kickstarts, and re-ensures what ran. */ -enum LaunchdAdmin { - static let label = "dev.quantizor.devctl" - - static var plistURL: URL { - FileManager.default.homeDirectoryForCurrentUser - .appending(path: "Library/LaunchAgents/\(label).plist") - } - - /** The devctld binary that ships alongside this devctl binary. */ - static func bundledDaemonBinary() -> URL? { - let selfPath = URL(fileURLWithPath: CLISelf.path) - let sibling = selfPath.deletingLastPathComponent().appending(path: "devctld") - return FileManager.default.isExecutableFile(atPath: sibling.path) ? sibling : nil - } - - /** Install (or upgrade) the LaunchAgent. Same bounce contract as restart: - capture what is running, drain, swap binary + bootstrap, re-ensure. The - daemon's recoverAtStartup is the reboot path; install cannot rely on it - alone because a pre-feature state.json may lack resumeOnBoot, and the - CLI already knows the live names from status. */ - static func install(daemonBinary: URL, paths: DevCtlPaths) async throws -> [(project: String, name: String)] { - let fm = FileManager.default - try fm.createDirectory(at: paths.daemonBinaryDir, withIntermediateDirectories: true) - let destination = paths.daemonBinaryDir.appending(path: "devctld") - /** Stage-and-rename: never cp over a running signed binary. */ - let staged = paths.daemonBinaryDir.appending(path: ".devctld.staged-\(getpid())") - try? fm.removeItem(at: staged) - try fm.copyItem(at: daemonBinary, to: staged) - _ = try fm.replaceItemAt(destination, withItemAt: staged) - let plist = renderPlist(daemonPath: destination.path, paths: paths) - try fm.createDirectory(at: plistURL.deletingLastPathComponent(), withIntermediateDirectories: true) - try Data(plist.utf8).write(to: plistURL) - try? fm.removeItem(at: paths.stoppedIntentFile) - let client = DaemonClient(socketPath: paths.socketPath) - let runningServers = await captureActiveServers(client: client) - /** Drain through the daemon FIRST: bootout's own kill can outrun the - SIGTERM drain and orphan server trees mid-escalation. */ - _ = try? await client.request(.daemonShutdown, params: WireEmpty(), expecting: WireEmpty.self) - for _ in 0..<50 where FileManager.default.fileExists(atPath: paths.socketPath) { - if (try? await DaemonClient(socketPath: paths.socketPath) - .request(.daemonInfo, params: WireEmpty(), expecting: DaemonInfo.self)) == nil { - break - } - try? await Task.sleep(for: .milliseconds(100)) - } - try? fm.removeItem(at: paths.stoppedIntentFile) - _ = shell("/bin/launchctl", ["bootout", "gui/\(getuid())/\(label)"]) - let bootstrap = shell("/bin/launchctl", ["bootstrap", "gui/\(getuid())", plistURL.path]) - /** A concurrent session bootstrapping first reports already-bootstrapped; - the socket poll below is the actual success signal. */ - if bootstrap.status != 0, !bootstrap.output.contains("already bootstrapped"), - !bootstrap.output.contains("Bootstrap failed: 5: Input/output error") { - throw WireError( - code: .internalError, - hint: "run: launchctl bootstrap gui/\(getuid()) \(plistURL.path)", - message: "launchctl bootstrap failed (\(bootstrap.status)): \(bootstrap.output)") - } - try await pollHello(paths: paths) - await reensure(runningServers, paths: paths) - return runningServers - } - - static func uninstall(paths: DevCtlPaths, purge: Bool) async { - let client = DaemonClient(socketPath: paths.socketPath) - _ = try? await client.request(.daemonShutdown, params: WireEmpty(), expecting: WireEmpty.self) - _ = shell("/bin/launchctl", ["bootout", "gui/\(getuid())/\(label)"]) - try? FileManager.default.removeItem(at: plistURL) - if purge { - try? FileManager.default.removeItem(at: paths.dataDir) - try? FileManager.default.removeItem(at: paths.logsDir) - } - } - - static func start(paths: DevCtlPaths) async throws { - try? FileManager.default.removeItem(at: paths.stoppedIntentFile) - guard FileManager.default.fileExists(atPath: plistURL.path) else { - throw WireError( - code: .internalError, - hint: "run: devctl daemon install", - message: "no LaunchAgent installed at \(plistURL.path)") - } - let result = shell("/bin/launchctl", ["kickstart", "gui/\(getuid())/\(label)"]) - if result.status != 0 { - _ = shell("/bin/launchctl", ["bootstrap", "gui/\(getuid())", plistURL.path]) - } - try await pollHello(paths: paths) - } - - /** Drain, kickstart -k, re-ensure what was running: "servers bounce, then - come back" is the restart contract. */ - static func restart(paths: DevCtlPaths) async throws -> [(project: String, name: String)] { - let client = DaemonClient(socketPath: paths.socketPath) - let runningServers = await captureActiveServers(client: client) - try? FileManager.default.removeItem(at: paths.stoppedIntentFile) - let result = shell("/bin/launchctl", ["kickstart", "-k", "gui/\(getuid())/\(label)"]) - if result.status != 0 { - throw WireError( - code: .internalError, - hint: "run: devctl daemon install", - message: "launchctl kickstart failed (\(result.status)): \(result.output)") - } - try await pollHello(paths: paths) - await reensure(runningServers, paths: paths) - return runningServers - } - - static func captureActiveServers(client: DaemonClient) async -> [(project: String, name: String)] { - guard let all = try? await client.request( - .serverStatus, params: ProjectParams(project: ""), expecting: ServerListResult.self) - else { return [] } - return all.servers - .filter { $0.phase == .running || $0.phase == .starting || $0.phase == .unhealthy } - .map { (project: $0.project, name: $0.server) } - } - - static func reensure( - _ servers: [(project: String, name: String)], paths: DevCtlPaths - ) async { - let fresh = DaemonClient(socketPath: paths.socketPath) - for server in servers { - _ = try? await fresh.request( - .serverEnsure, - params: EnsureParams(name: server.name, project: server.project, timeoutSeconds: 60), - expecting: EnsureResult.self) - } - } - - static func launchdState() -> String { - let result = shell("/bin/launchctl", ["print", "gui/\(getuid())/\(label)"]) - if result.status != 0 { return "not bootstrapped" } - for line in result.output.split(separator: "\n") { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("state =") || trimmed.hasPrefix("pid =") { - return trimmed - } - } - return "bootstrapped" - } - - static func pollHello(paths: DevCtlPaths, timeoutSeconds: Double = 5) async throws { - let deadline = Date().addingTimeInterval(timeoutSeconds) - var lastError: Error = WireError(code: .daemonUnreachable, message: "daemon never answered") - while Date() < deadline { - do { - let client = DaemonClient(socketPath: paths.socketPath) - _ = try await client.request(.daemonInfo, params: WireEmpty(), expecting: DaemonInfo.self) - return - } catch { - lastError = error - try? await Task.sleep(for: .milliseconds(200)) - } - } - throw lastError - } - - /** 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. */ - static func capturedPath() -> String { - let result = shell("/bin/zsh", ["-lc", "echo $PATH"]) - let path = result.output.trimmingCharacters(in: .whitespacesAndNewlines) - return path.isEmpty ? "/usr/bin:/bin:/usr/sbin:/sbin" : path - } - - static func renderPlist(daemonPath: String, paths: DevCtlPaths) -> String { - """ - - - - - EnvironmentVariables - - PATH - \(capturedPath()) - - ExitTimeOut - 120 - KeepAlive - - SuccessfulExit - - - Label - \(label) - ProcessType - Interactive - ProgramArguments - - \(daemonPath) - - RunAtLoad - - StandardErrorPath - \(paths.daemonLog.path) - StandardOutPath - \(paths.daemonLog.path) - - - """ - } - - @discardableResult - static func shell(_ path: String, _ arguments: [String]) -> (status: Int32, output: String) { - let process = Process() - process.executableURL = URL(fileURLWithPath: path) - process.arguments = arguments - let pipe = Pipe() - process.standardOutput = pipe - process.standardError = pipe - do { - try process.run() - } catch { - 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)) - } -} diff --git a/Sources/devctld/Info.plist b/Sources/devctld/Info.plist new file mode 100644 index 0000000..43bae6c --- /dev/null +++ b/Sources/devctld/Info.plist @@ -0,0 +1,18 @@ + + + + + + CFBundleDisplayName + quantizor/devctl + CFBundleIdentifier + dev.quantizor.devctld + CFBundleName + quantizor/devctl + CFBundlePackageType + APPL + LSMinimumSystemVersion + 14.0 + + diff --git a/Sources/devctld/main.swift b/Sources/devctld/main.swift index 083e69c..ff92060 100644 --- a/Sources/devctld/main.swift +++ b/Sources/devctld/main.swift @@ -63,25 +63,42 @@ guard flock(lockFD, LOCK_EX | LOCK_NB) == 0 else { exit(0) } -/** Raise the fd ceiling: launchd jobs default to a 256 soft limit, and a dozen - servers plus log subscribers approaches it. */ +/** Raise the fd ceiling only when below the target: launchd jobs default to a + 256 soft limit, and a dozen servers plus log subscribers approaches it. Never + lower an already-higher soft limit. */ var limit = rlimit() if getrlimit(RLIMIT_NOFILE, &limit) == 0 { - limit.rlim_cur = min(limit.rlim_max, rlim_t(8192)) - setrlimit(RLIMIT_NOFILE, &limit) + let target = min(limit.rlim_max, rlim_t(8192)) + if limit.rlim_cur < target { + limit.rlim_cur = target + if setrlimit(RLIMIT_NOFILE, &limit) != 0 { + FileHandle.standardError.write( + Data( + "devctld: setrlimit(RLIMIT_NOFILE) failed: \(String(cString: strerror(errno)))\n" + .utf8)) + } + } } /** A running daemon is proof any prior deliberate-stop intent is spent. */ try? FileManager.default.removeItem(at: paths.stoppedIntentFile) +/** Merge the install-time login PATH before any child spawn so Homebrew tools + stay findable under a sealed in-bundle LaunchAgent. */ +LaunchdAdmin.applyAgentPathToProcess(paths: paths) + let registry = Registry(paths: paths) let router = Router(launcher: SubprocessLauncher(), paths: paths, registry: registry) /** Sleep/wake awareness: health probes pause during sleep and get a grace - window on wake so lid-open does not flap every server unhealthy. The IOKit - message constants are iokit_common_msg values from IOMessage.h, written as - literals inside the C callback (it cannot capture top-level lets): 0xE0000270 - can-sleep, 0xE0000280 will-sleep, 0xE0000300 has-powered-on. */ + window on wake so lid-open does not flap every server unhealthy. IOKit + message macros (`kIOMessageCanSystemSleep` et al.) do not bridge into Swift; + these named values match IOMessage.h (`iokit_common_msg`). */ +enum PowerMessage { + static let canSystemSleep: natural_t = 0xE000_0270 + static let systemHasPoweredOn: natural_t = 0xE000_0300 + static let systemWillSleep: natural_t = 0xE000_0280 +} final class PowerContext { var rootPort: io_connect_t = 0 } @@ -95,20 +112,23 @@ powerContext.rootPort = IORegisterForSystemPower( guard let refcon else { return } let context = Unmanaged.fromOpaque(refcon).takeUnretainedValue() switch messageType { - case 0xE000_0270, 0xE000_0280: - if messageType == 0xE000_0280 { + case PowerMessage.canSystemSleep, PowerMessage.systemWillSleep: + if messageType == PowerMessage.systemWillSleep { Task { await PowerState.shared.recordSleep() } } /** Sleep is never vetoed; failing to acknowledge stalls the system. */ IOAllowPowerChange(context.rootPort, Int(bitPattern: argument)) - case 0xE000_0300: + case PowerMessage.systemHasPoweredOn: Task { await PowerState.shared.recordWake() } default: break } }, &powerNotifier) -if let powerPort { +if powerContext.rootPort == 0 { + FileHandle.standardError.write( + Data("devctld: IORegisterForSystemPower failed; sleep/wake probes are unaware\n".utf8)) +} else if let powerPort { IONotificationPortSetDispatchQueue(powerPort, DispatchQueue.main) } @@ -128,12 +148,28 @@ Task { } /** Graceful termination: drain-stop every supervised server through the normal - teardown path, then exit 0 (a deliberate stop must not trigger KeepAlive). */ + teardown path, unregister power notifications, then exit 0 (a deliberate + stop must not trigger KeepAlive). */ let terminationSource = DispatchSource.makeSignalSource(signal: SIGTERM, queue: .main) signal(SIGTERM, SIG_IGN) terminationSource.setEventHandler { - Task { + Task { @MainActor in await router.drainAll() + if let port = powerPort { + IONotificationPortSetDispatchQueue(port, nil) + } + if powerNotifier != 0 { + IODeregisterForSystemPower(&powerNotifier) + powerNotifier = 0 + } + if powerContext.rootPort != 0 { + IOServiceClose(powerContext.rootPort) + powerContext.rootPort = 0 + } + if let port = powerPort { + IONotificationPortDestroy(port) + powerPort = nil + } exit(0) } } diff --git a/Tests/DevCtlDaemonCoreTests/ProcessTreeTests.swift b/Tests/DevCtlDaemonCoreTests/ProcessTreeTests.swift new file mode 100644 index 0000000..fa4e4c7 --- /dev/null +++ b/Tests/DevCtlDaemonCoreTests/ProcessTreeTests.swift @@ -0,0 +1,40 @@ +import Darwin +import Foundation +import Testing + +@testable import DevCtlDaemonCore + +@Suite struct ProcessTreeTests { + @Test func allocationRoundsUpAndNeverOverstatesBytes() { + let stride = MemoryLayout.stride + let plan = ProcessTree.allocation(forProbedBytes: stride * 3 + 1) + #expect(plan.capacity >= 4) + #expect(plan.byteCount == plan.capacity * stride) + #expect(plan.byteCount % stride == 0) + } + + @Test func allocationZeroProbeIsEmpty() { + let plan = ProcessTree.allocation(forProbedBytes: 0) + #expect(plan.capacity == 0) + #expect(plan.byteCount == 0) + } + + @Test func shouldSignalRejectsMissingAndReusedPid() { + let snap = ProcessIdentity(pid: 42, startSeconds: 100, startMicroseconds: 5) + #expect(ProcessTree.shouldSignal(snapshotted: snap, live: nil) == false) + let reused = ProcessIdentity(pid: 42, startSeconds: 200, startMicroseconds: 0) + #expect(ProcessTree.shouldSignal(snapshotted: snap, live: reused) == false) + #expect(ProcessTree.shouldSignal(snapshotted: snap, live: snap) == true) + } + + @Test func failedDescendantsAreNotEmptySuccess() { + /** Live sweep against a nonsense pid still returns .ok([]) (no children), + never .failed. Failure is a sysctl errno path; assert the result type + distinguishes the two shapes we care about. */ + let none = DescendantsResult.ok([]) + let fail = DescendantsResult.failed(errno: ENOMEM) + #expect(none.identities.isEmpty) + #expect(fail.identities.isEmpty) + #expect(none != fail) + } +} diff --git a/Tests/DevCtlDaemonCoreTests/ResourceLockTests.swift b/Tests/DevCtlDaemonCoreTests/ResourceLockTests.swift new file mode 100644 index 0000000..a525582 --- /dev/null +++ b/Tests/DevCtlDaemonCoreTests/ResourceLockTests.swift @@ -0,0 +1,210 @@ +import DevCtlKit +import Foundation +import Testing + +@testable import DevCtlDaemonCore + +private struct LockEnv { + let paths: DevCtlPaths + let projectPath: String +} + +private func makeLockEnv() throws -> LockEnv { + let base = FileManager.default.temporaryDirectory.appending(path: "devctl-lock-\(UUID().uuidString)") + let project = base.appending(path: "proj") + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + return LockEnv( + paths: DevCtlPaths(dataDir: base.appending(path: "data"), logsDir: base.appending(path: "logs")), + projectPath: project.path) +} + +private func writeLockDevservers(project: String) throws { + let body = """ + { + "servers": { + "db": { + "command": ["/bin/sh", "-c", "sleep 60"], + "locks": ["data"] + } + }, + "version": 1 + } + """ + try Data(body.utf8).write( + to: URL(fileURLWithPath: project).appending(path: "devservers.json")) +} + +private func handle( + router: Router, method: WireMethod, params: P, expecting: R.Type +) async throws -> R { + let line = try NDJSON.encodeLine( + WireRequest(id: "t", method: method.rawValue, params: params)) + let data = await router.handle(line: line) + let response = try JSONCoding.decoder().decode(WireResponse.self, from: data) + guard response.ok, let result = response.result else { + throw response.error + ?? WireError(code: .internalError, message: "request failed") + } + return result +} + +private func startDB(router: Router, project: String) async throws { + _ = try await handle( + router: router, method: .serverStart, + params: ServerTargetParams(name: "db", project: project), + expecting: ServerResult.self) + /** Give spawn a beat so phase is active for pause detection. */ + try await Task.sleep(for: .milliseconds(200)) +} + +private func phaseOf(router: Router, project: String, name: String) async throws -> ServerPhase { + let list = try await handle( + router: router, method: .serverStatus, + params: ProjectParams(project: project), + expecting: ServerListResult.self) + let server = try #require(list.servers.first { $0.server == name }) + return server.phase +} + +@Suite struct ResourceLockTests { + /** Acquire pauses the declaring server, refuses ensure, release brings it + back. Pause is non-retiring so boot intent survives the hold. */ + @Test func acquirePausesReleaseResumesAndPreservesBootIntent() async throws { + let env = try makeLockEnv() + try writeLockDevservers(project: env.projectPath) + let registry = Registry(paths: env.paths) + try await registry.setTrusted(project: env.projectPath) + let router = Router(launcher: SubprocessLauncher(), paths: env.paths, registry: registry) + try await startDB(router: router, project: env.projectPath) + let id = serverID(project: env.projectPath, name: "db") + #expect(await registry.persistedState(serverID: id)?.resumeOnBoot == true) + + let acquired = try await handle( + router: router, method: .lockAcquire, + params: LockParams( + holderPid: Int(getpid()), project: env.projectPath, resource: "data", + resumeTimeoutSeconds: 15), + expecting: LockResult.self) + #expect(acquired.paused == ["db"]) + #expect(try await phaseOf(router: router, project: env.projectPath, name: "db") == .stopped) + #expect(await registry.persistedState(serverID: id)?.resumeOnBoot == true) + #expect(FileManager.default.fileExists(atPath: env.paths.locksFile.path)) + + let ensureLine = try NDJSON.encodeLine( + WireRequest( + id: "e", method: WireMethod.serverEnsure.rawValue, + params: EnsureParams(name: "db", project: env.projectPath, timeoutSeconds: 2))) + let ensureData = await router.handle(line: ensureLine) + let ensureResponse = try JSONCoding.decoder().decode( + WireResponse.self, from: ensureData) + #expect(ensureResponse.ok == false) + #expect(ensureResponse.error?.code == .resourceLocked) + + let released = try await handle( + router: router, method: .lockRelease, + params: LockParams( + holderPid: Int(getpid()), project: env.projectPath, resource: "data", + resumeTimeoutSeconds: 15), + expecting: LockResult.self) + #expect(released.paused == ["db"]) + let phase = try await phaseOf(router: router, project: env.projectPath, name: "db") + #expect(phase == .starting || phase == .running) + _ = try await handle( + router: router, method: .serverStop, + params: ServerTargetParams(name: "db", project: env.projectPath), + expecting: ServerResult.self) + } + + /** The candor failure: daemon dies mid-hold, holder is gone, recover must + resume the paused set from locks.json. */ + @Test func recoverResumesWhenHolderIsDead() async throws { + let env = try makeLockEnv() + try writeLockDevservers(project: env.projectPath) + let registry = Registry(paths: env.paths) + try await registry.setTrusted(project: env.projectPath) + let id = serverID(project: env.projectPath, name: "db") + try await registry.updateState(serverID: id) { entry in + entry.phase = .stopped + entry.resumeOnBoot = true + entry.pid = nil + } + /** A pid we know is gone: spawn and wait, then reuse the identifier. */ + let probe = Process() + probe.executableURL = URL(fileURLWithPath: "/usr/bin/true") + try probe.run() + probe.waitUntilExit() + let deadPid = Int(probe.processIdentifier) + #expect(kill(pid_t(deadPid), 0) != 0) + let key = "\(env.projectPath)::data" + try AtomicFile.write( + JSONCoding.encoder().encode( + LocksFile( + locks: [ + key: LockHolder( + paused: ["db"], pid: deadPid, resumeTimeoutSeconds: 15, since: Date()) + ])), + to: env.paths.locksFile) + + let router = Router(launcher: SubprocessLauncher(), paths: env.paths, registry: registry) + await router.recoverAtStartup() + let phase = try await phaseOf(router: router, project: env.projectPath, name: "db") + #expect(phase == .starting || phase == .running) + let locks = AtomicFile.loadDefensively(LocksFile.self, from: env.paths.locksFile) + #expect(locks?.locks.isEmpty == true) + _ = try await handle( + router: router, method: .serverStop, + params: ServerTargetParams(name: "db", project: env.projectPath), + expecting: ServerResult.self) + } + + /** If the harness survived the daemon restart, recover must leave the + paused server down and keep the lock so ensure stays refused. */ + @Test func recoverLeavesPausedWhenHolderStillAlive() async throws { + let env = try makeLockEnv() + try writeLockDevservers(project: env.projectPath) + let registry = Registry(paths: env.paths) + try await registry.setTrusted(project: env.projectPath) + let id = serverID(project: env.projectPath, name: "db") + try await registry.updateState(serverID: id) { entry in + entry.phase = .stopped + entry.resumeOnBoot = true + entry.pid = nil + } + let livePid = Int(getpid()) + let key = "\(env.projectPath)::data" + try AtomicFile.write( + JSONCoding.encoder().encode( + LocksFile( + locks: [ + key: LockHolder( + paused: ["db"], pid: livePid, resumeTimeoutSeconds: 15, since: Date()) + ])), + to: env.paths.locksFile) + + let router = Router(launcher: SubprocessLauncher(), paths: env.paths, registry: registry) + await router.recoverAtStartup() + #expect(try await phaseOf(router: router, project: env.projectPath, name: "db") == .stopped) + + let ensureLine = try NDJSON.encodeLine( + WireRequest( + id: "e", method: WireMethod.serverEnsure.rawValue, + params: EnsureParams(name: "db", project: env.projectPath, timeoutSeconds: 2))) + let ensureData = await router.handle(line: ensureLine) + let ensureResponse = try JSONCoding.decoder().decode( + WireResponse.self, from: ensureData) + #expect(ensureResponse.error?.code == .resourceLocked) + + _ = try await handle( + router: router, method: .lockRelease, + params: LockParams( + holderPid: livePid, project: env.projectPath, resource: "data", + resumeTimeoutSeconds: 15), + expecting: LockResult.self) + let phase = try await phaseOf(router: router, project: env.projectPath, name: "db") + #expect(phase == .starting || phase == .running) + _ = try await handle( + router: router, method: .serverStop, + params: ServerTargetParams(name: "db", project: env.projectPath), + expecting: ServerResult.self) + } +} diff --git a/Tests/DevCtlKitTests/DaemonRecoveryPolicyTests.swift b/Tests/DevCtlKitTests/DaemonRecoveryPolicyTests.swift new file mode 100644 index 0000000..8bd41ad --- /dev/null +++ b/Tests/DevCtlKitTests/DaemonRecoveryPolicyTests.swift @@ -0,0 +1,115 @@ +import Foundation +import Testing + +@testable import DevCtlKit + +@Suite struct LegacyAgentPlistTests { + /** launchd rejects an ExitTimeOut above 60, silently clamps it, and logs + "ExitTimeOut is larger than the maximum allowed", so the rendered value + has to stay inside the ceiling. */ + @Test func exitTimeOutStaysUnderLaunchdCeiling() throws { + let dir = FileManager.default.temporaryDirectory.appending(path: "devctl-test-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: dir) } + let paths = DevCtlPaths(dataDir: dir) + let plist = LaunchdAdmin.renderPlist(daemonPath: "/tmp/devctld", paths: paths) + let data = try #require(plist.data(using: .utf8)) + let parsed = try #require( + try PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any]) + let exitTimeOut = try #require(parsed["ExitTimeOut"] as? Int) + #expect(exitTimeOut <= 60) + } +} + +@Suite struct AgentRebindPolicyTests { + @Test func deliberateStopSkipsRegisterUnlessRebind() { + #expect( + AgentRebindPolicy.shouldRegisterAtLaunch( + deliberatelyStopped: true, rebindNeeded: false) == false) + #expect( + AgentRebindPolicy.shouldRegisterAtLaunch( + deliberatelyStopped: true, rebindNeeded: true) == true) + } + + @Test func runningInstallRegisters() { + #expect( + AgentRebindPolicy.shouldRegisterAtLaunch( + deliberatelyStopped: false, rebindNeeded: false) == true) + #expect( + AgentRebindPolicy.shouldRegisterAtLaunch( + deliberatelyStopped: false, rebindNeeded: true) == true) + } + + @Test func forceReregisterOnlyWhenRebindMarked() { + #expect( + AgentRebindPolicy.shouldForceReregisterAfterHelloMiss(rebindNeeded: true)) + #expect( + !AgentRebindPolicy.shouldForceReregisterAfterHelloMiss(rebindNeeded: false)) + } + + @Test func rebindMarkerRoundTrip() throws { + let dir = FileManager.default.temporaryDirectory + .appending(path: "devctl-rebind-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: dir) } + let paths = DevCtlPaths(dataDir: dir) + #expect(!LaunchdAdmin.agentRebindNeeded(paths: paths)) + try LaunchdAdmin.markAgentRebindNeeded(paths: paths) + #expect(LaunchdAdmin.agentRebindNeeded(paths: paths)) + LaunchdAdmin.clearAgentRebindMarker(paths: paths) + #expect(!LaunchdAdmin.agentRebindNeeded(paths: paths)) + } +} + +@Suite struct DaemonRecoveryPolicyTests { + private let now = Date(timeIntervalSince1970: 1_800_000_000) + + @Test func reachableDaemonNeedsNothing() { + #expect( + DaemonRecoveryPolicy.decide( + reachable: true, stoppedOnPurpose: false, recovering: false, lastAttempt: nil, + now: now) == .healthy) + } + + @Test func crashedDaemonRecoversImmediately() { + #expect( + DaemonRecoveryPolicy.decide( + reachable: false, stoppedOnPurpose: false, recovering: false, lastAttempt: nil, + now: now) == .recover) + } + + /** A deliberate `devctl daemon stop` must survive the app's poll loop. */ + @Test func deliberateStopWaitsForTheUser() { + #expect( + DaemonRecoveryPolicy.decide( + reachable: false, stoppedOnPurpose: true, recovering: false, lastAttempt: nil, + now: now) == .awaitUser) + /** Intent wins even when a cooldown would otherwise allow a retry. */ + #expect( + DaemonRecoveryPolicy.decide( + reachable: false, stoppedOnPurpose: true, recovering: false, + lastAttempt: now.addingTimeInterval(-3600), now: now) == .awaitUser) + } + + @Test func attemptInFlightIsNotStacked() { + #expect( + DaemonRecoveryPolicy.decide( + reachable: false, stoppedOnPurpose: false, recovering: true, lastAttempt: nil, + now: now) == .inFlight) + } + + @Test func failedAttemptCoolsDownThenRetries() { + #expect( + DaemonRecoveryPolicy.decide( + reachable: false, stoppedOnPurpose: false, recovering: false, + lastAttempt: now.addingTimeInterval(-1), now: now) == .cooling) + /** Boundary: exactly at the cooldown is eligible again. */ + #expect( + DaemonRecoveryPolicy.decide( + reachable: false, stoppedOnPurpose: false, recovering: false, + lastAttempt: now.addingTimeInterval(-DaemonRecoveryPolicy.cooldown), now: now) + == .recover) + #expect( + DaemonRecoveryPolicy.decide( + reachable: false, stoppedOnPurpose: false, recovering: false, + lastAttempt: now.addingTimeInterval(-60), now: now) == .recover) + } +} diff --git a/Tests/DevCtlKitTests/DeepLinkNotificationActionTests.swift b/Tests/DevCtlKitTests/DeepLinkNotificationActionTests.swift index 9c3b0fe..749b7c5 100644 --- a/Tests/DevCtlKitTests/DeepLinkNotificationActionTests.swift +++ b/Tests/DevCtlKitTests/DeepLinkNotificationActionTests.swift @@ -23,10 +23,17 @@ import Testing #expect(link == DeepLink(verb: .why, projectSlug: "candor", server: "cms")) } + @Test func defaultBodyTapMapsToOpen() { + let link = DeepLinkNotificationAction.link( + actionId: DeepLinkNotificationAction.defaultActionId, + projectSlug: "candor", server: "cms", head: "wren-hollow") + #expect(link == DeepLink(verb: .open, projectSlug: "candor", server: "cms", head: "wren-hollow")) + } + @Test func unknownActionIsNil() { #expect( DeepLinkNotificationAction.link( - actionId: "com.apple.UNNotificationDefaultActionIdentifier", + actionId: "com.apple.UNNotificationDismissActionIdentifier", projectSlug: "candor", server: "cms") == nil) } } diff --git a/Tests/DevCtlKitTests/SetupPlannerTests.swift b/Tests/DevCtlKitTests/SetupPlannerTests.swift new file mode 100644 index 0000000..2d91512 --- /dev/null +++ b/Tests/DevCtlKitTests/SetupPlannerTests.swift @@ -0,0 +1,173 @@ +import Foundation +import Testing +@testable import DevCtlKit + +@Suite("SetupPlanner") +struct SetupPlannerTests { + @Test func compareVersionsOrdersSemver() { + #expect(SetupPlanner.compareVersions("1.0.0", "1.0.1") == .orderedAscending) + #expect(SetupPlanner.compareVersions("1.2.0", "1.1.9") == .orderedDescending) + #expect(SetupPlanner.compareVersions("1.2.0", "1.2.0") == .orderedSame) + #expect(SetupPlanner.compareVersions("", "1.0.0") == .orderedAscending) + #expect(SetupPlanner.compareVersions("2.0", "2.0.0") == .orderedSame) + } + + @Test func shouldPresentOnFreshInstall() { + #expect( + SetupPlanner.shouldPresent( + bundledVersion: "1.2.0", + installedCLIVersion: nil, + stampVersion: nil, + resourcesPresent: true)) + } + + @Test func shouldPresentWhenBundledNewerThanStamp() { + #expect( + SetupPlanner.shouldPresent( + bundledVersion: "1.3.0", + installedCLIVersion: "1.2.0", + stampVersion: "1.2.0", + resourcesPresent: true)) + } + + @Test func shouldSkipWhenVersionsMatch() { + #expect( + !SetupPlanner.shouldPresent( + bundledVersion: "1.2.0", + installedCLIVersion: "1.2.0", + stampVersion: "1.2.0", + resourcesPresent: true)) + } + + @Test func shouldPresentWhenRunningOutsideApplicationsEvenIfVersionsMatch() { + #expect( + SetupPlanner.shouldPresent( + bundledVersion: "1.2.0", + installedCLIVersion: "1.2.0", + stampVersion: "1.2.0", + resourcesPresent: true, + runningOutsideApplications: true)) + } + + @Test func shouldSkipWithoutResources() { + #expect( + !SetupPlanner.shouldPresent( + bundledVersion: "1.2.0", + installedCLIVersion: nil, + stampVersion: nil, + resourcesPresent: false)) + #expect( + !SetupPlanner.shouldPresent( + bundledVersion: "1.2.0", + installedCLIVersion: nil, + stampVersion: nil, + resourcesPresent: false, + runningOutsideApplications: true)) + } + + @Test func isRunningOutsideApplicationsComparesCanonicalPaths() { + #expect( + SetupPlanner.isRunningOutsideApplications( + bundlePath: "/Volumes/devctl/devctl.app")) + #expect( + !SetupPlanner.isRunningOutsideApplications( + bundlePath: SetupPlanner.applicationsAppPath)) + } + + @Test func isMigrationDetectsPriorLayout() { + #expect(SetupPlanner.isMigration( + installedCLIExists: true, stampExists: false, launchAgentExists: false)) + #expect(SetupPlanner.isMigration( + installedCLIExists: false, stampExists: false, launchAgentExists: true)) + #expect( + !SetupPlanner.isMigration( + installedCLIExists: false, stampExists: false, launchAgentExists: false)) + } + + @Test func cliDirectoryOnPATH() { + let dir = SetupPlanner.defaultCLIDirectory( + home: URL(fileURLWithPath: "/Users/test")) + #expect( + SetupPlanner.cliDirectoryOnPATH( + pathEnv: "/usr/bin:\(dir.path):/bin", cliDirectory: dir)) + #expect( + !SetupPlanner.cliDirectoryOnPATH( + pathEnv: "/usr/bin:/bin", cliDirectory: dir)) + } + + @Test func harnessOffersDefaultCheckedOnlyWhenNeeded() throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "devctl-setup-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let home = root.appending(path: "home") + try FileManager.default.createDirectory( + at: home.appending(path: ".claude"), withIntermediateDirectories: true) + try FileManager.default.createDirectory( + at: home.appending(path: ".cursor"), withIntermediateDirectories: true) + + let cliPath = home.appending(path: ".local/bin/devctl").path + let offersFresh = SetupPlanner.harnessOffers(home: home, installedCLIPath: cliPath) + #expect(offersFresh.map(\.harness) == ["claude", "cursor"]) + #expect(offersFresh.allSatisfy { $0.defaultChecked && !$0.alreadyInstalled }) + + let claudeSettings = """ + {"hooks":{"SessionStart":[{"hooks":[{"command":"\(cliPath) hook claude-session-start","type":"command"}],"matcher":"startup|resume|clear|compact"}]}} + """ + try Data(claudeSettings.utf8).write( + to: home.appending(path: ".claude/settings.json")) + let cursorSettings = """ + {"hooks":{"sessionStart":[{"command":"\(cliPath) hook cursor-session-start"}]},"version":1} + """ + try Data(cursorSettings.utf8).write(to: home.appending(path: ".cursor/hooks.json")) + + let offersInstalled = SetupPlanner.harnessOffers(home: home, installedCLIPath: cliPath) + #expect(offersInstalled.count == 2) + #expect(offersInstalled.allSatisfy { $0.alreadyInstalled && !$0.defaultChecked }) + } + + @Test func stampRoundTrip() throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "devctl-stamp-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let stamp = root.appending(path: "setup.stamp") + try SetupPlanner.writeStamp(version: "1.2.0", to: stamp) + #expect(SetupPlanner.readStamp(at: stamp) == "1.2.0") + } + + @Test func installBinaryStageAndRename() throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "devctl-bin-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + let source = root.appending(path: "src-bin") + let dest = root.appending(path: "bin/devctl") + try Data("#!/bin/sh\necho ok\n".utf8).write(to: source) + try SetupPlanner.installBinary(from: source, to: dest) + #expect(FileManager.default.isExecutableFile(atPath: dest.path)) + let again = root.appending(path: "src-bin-2") + try Data("#!/bin/sh\necho v2\n".utf8).write(to: again) + try SetupPlanner.installBinary(from: again, to: dest) + let body = try String(contentsOf: dest, encoding: .utf8) + #expect(body.contains("v2")) + } + + @Test func installAppBundleReplacesExisting() throws { + let root = FileManager.default.temporaryDirectory + .appending(path: "devctl-app-\(UUID().uuidString)") + defer { try? FileManager.default.removeItem(at: root) } + let apps = root.appending(path: "Applications") + try FileManager.default.createDirectory(at: apps, withIntermediateDirectories: true) + let source = root.appending(path: "source.app") + let dest = apps.appending(path: "devctl.app") + try FileManager.default.createDirectory( + at: source.appending(path: "Contents"), withIntermediateDirectories: true) + try Data("v1".utf8).write(to: source.appending(path: "Contents/marker")) + try FileManager.default.createDirectory( + at: dest.appending(path: "Contents"), withIntermediateDirectories: true) + try Data("old".utf8).write(to: dest.appending(path: "Contents/marker")) + try SetupPlanner.installAppBundle(from: source, to: dest) + let body = try String(contentsOf: dest.appending(path: "Contents/marker"), encoding: .utf8) + #expect(body == "v1") + } +} diff --git a/Tests/DevCtlKitTests/SpotlightLabelTests.swift b/Tests/DevCtlKitTests/SpotlightLabelTests.swift index 3df56a2..f652520 100644 --- a/Tests/DevCtlKitTests/SpotlightLabelTests.swift +++ b/Tests/DevCtlKitTests/SpotlightLabelTests.swift @@ -32,4 +32,24 @@ import Testing url: "http://demo1.localhost:3000/qa") == ["candor", "demo1", "dev server", "devctl", "qa"]) } + + @Test func alternateNamesDropPrimaryTitle() { + #expect( + SpotlightLabel.alternateNames( + project: "candor", server: "candor", head: "operator", + url: "http://candor.localhost:3000/") + == ["candor", "operator"]) + #expect( + SpotlightLabel.alternateNames( + project: "styled-components", server: "native", head: nil, + url: "http://native.localhost:3000/") + == ["native", "styled-components"]) + } + + @Test func rankingHintTiersLiveAndPinned() { + #expect(SpotlightLabel.rankingHint(phase: .running, pinned: false) == 100) + #expect(SpotlightLabel.rankingHint(phase: .stopped, pinned: true) == 100) + #expect(SpotlightLabel.rankingHint(phase: .starting, pinned: false) == 95) + #expect(SpotlightLabel.rankingHint(phase: .stopped, pinned: false) == 90) + } } diff --git a/Tests/DevCtlKitTests/WireTests.swift b/Tests/DevCtlKitTests/WireTests.swift index 81113df..f8fb04f 100644 --- a/Tests/DevCtlKitTests/WireTests.swift +++ b/Tests/DevCtlKitTests/WireTests.swift @@ -98,4 +98,29 @@ import Testing #expect(quarantined.count == 1) try? FileManager.default.removeItem(at: dir) } + + /** Two writers inside one process must both succeed: a pid-only temp name + made them rename the same temp file out from under each other, which is + how the app lost agent.path when launch registration and the recovery + poll wrote it at the same moment. */ + @Test func concurrentWritesToOneFileAllSucceed() async throws { + let dir = FileManager.default.temporaryDirectory.appending(path: "devctl-test-\(UUID().uuidString)") + let file = dir.appending(path: "agent.path") + let payloads = (0..<8).map { "payload-\($0)" } + try await withThrowingTaskGroup(of: Void.self) { group in + for payload in payloads { + group.addTask { + try AtomicFile.write(Data(payload.utf8), to: file) + } + } + try await group.waitForAll() + } + let written = try String(decoding: Data(contentsOf: file), as: UTF8.self) + #expect(payloads.contains(written)) + /** No temp files survive a clean run. */ + let leftovers = try FileManager.default.contentsOfDirectory(atPath: dir.path) + .filter { $0.hasPrefix(".agent.path.tmp-") } + #expect(leftovers.isEmpty) + try? FileManager.default.removeItem(at: dir) + } } diff --git a/docs/cli-contract.md b/docs/cli-contract.md index e319ed7..9888335 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -56,7 +56,7 @@ Filled in per phase as each lands; golden tests reference the examples in this f - `devctl doctor [--fix] --json` → `{findings: [{detail, kind, severity}]}`: daemon/launchd state, captured-PATH staleness, the host:port signature table with conflicts, unmanaged listeners on managed ports, and registry entries whose project path no longer exists (`--fix` prunes those). - `devctl switch [--no-fetch] [--timeout 120]` → clean-tree guard (refuses dirty; never stashes), fetch, group down, `git switch` (remote-tracking fallback), then the project's `lifecycle.switch` playbook (argv arrays run sequentially from the project root; failures stop with `devctl up` as the resume hint), then group up. Playbooks live in devservers.json `lifecycle` and are agent-configurable. - Config extras: project-level `icon` (project-relative path, per-server override) feeds Spotlight thumbnails; every server and head is indexed in Spotlight as ` · ` with subtitle `devctl · ` (best-effort; not a Top Hit launcher); `heads` and pins surface in the menu bar app. -- `devctl lock -- [--acquire-timeout 300] [--timeout 120]` → runs the command holding a project resource exclusively. Servers declaring the resource in their `locks` (devservers.json) are stopped first and re-ensured after (even on command failure); `ensure`/`start` of a declaring server is refused (`resource-locked`, naming the holder pid) while a live holder owns it; a dead holder auto-releases. Exit status is the command's. The mechanism for test harnesses whose private servers share mutable state (a local database) with a managed server. +- `devctl lock -- [--acquire-timeout 300] [--timeout 120]` → runs the command holding a project resource exclusively. The daemon pauses servers that declare the resource in their `locks` (devservers.json) and re-ensures them on release (even on command failure); `ensure`/`start` of a declaring server is refused (`resource-locked`, naming the holder pid) while a live holder owns it. Locks persist across a daemon crash: a dead holder auto-releases and resumes the paused set; a still-live holder keeps them paused so the harness stays exclusive. Exit status is the command's. The mechanism for test harnesses whose private servers share mutable state (a local database) with a managed server. - `devctl daemon install|uninstall [--purge]|start|stop|restart|status`: launchd lifecycle. `stop` drains and writes a deliberate-stop marker that auto-bootstrap honors; `restart` and `install` (upgrade) both capture running servers, bounce the daemon, and re-ensure them by name ("servers bounce, then come back"). `install` also stages-and-renames the daemon binary and captures the login-shell PATH into the agent plist. Reboot recovery: the LaunchAgent runs at load; starting a server records resume-on-boot; a machine shutdown drains without clearing it; `recoverAtStartup` resolves specs through the merged config+registry view (so committed `devservers.json` servers come back, not only ad-hoc `register` entries). A deliberate `devctl stop`/`down` clears the intent. Renamed or deleted servers leave orphan state rows that recover drops. - `devctl context`: the harness-agnostic session context: a fenced `` plain-text block (server phases, URLs, log paths, the ensure/wait/why/logs cheat-sheet) for the cwd's project. Silent (exit 0) when the project is unregistered or untrusted or the daemon is down; never bootstraps; never contains raw log lines or command strings. - `devctl hook install [--harness claude|cursor] [--statusline]`: idempotently wires a session-start hook into the harness's settings (claude: SessionStart with matcher `startup|resume|clear|compact` in ~/.claude/settings.json, emitting `hookSpecificOutput.additionalContext`; cursor: sessionStart in ~/.cursor/hooks.json, emitting `{additional_context}`). After a successful install it also prints a one-bullet discovery tip for the project's CLAUDE.md/AGENTS.md (wired to the nearest devservers.json's first server when one exists); the tip is printed for a human to paste and is never auto-appended to those files. Adding a harness: CONTRIBUTING.md. diff --git a/docs/design.md b/docs/design.md index 4735f1d..6d53208 100644 --- a/docs/design.md +++ b/docs/design.md @@ -36,14 +36,14 @@ Swift 6.3 (6.3.3 latest), Swift 6 language mode with strict concurrency. Depende - `Sources/devctl/`: CLI executable (swift-argument-parser). - `Sources/DevCtlApp/`: SwiftUI MenuBarExtra app, pure DaemonClient consumer, default-MainActor module isolation. - `Sources/fixture-server/`: test helper executable (TCP/HTTP responder with `--exit-after`, `--spawn-grandchild`, `--ignore-sigterm`, `--emit-binary`, `--flood` modes). -- `Makefile` + `scripts/make-app-bundle.sh` + `scripts/install.sh`, `Resources/` (Info.plist with LSUIElement, launchd plist template, AppIcon.icns). +- `Makefile` + `scripts/make-app-bundle.sh` (fat app: CLI + daemon in `Contents/Resources`) + `scripts/make-dmg.sh` + `scripts/notarize.sh`. Info.plist is generated by the bundle script (LSUIElement, `devctl://` URL scheme). ## On-disk layout -- `~/Library/Application Support/devctl/`: `daemon.sock` (0600; fallback `/tmp/devctl-$UID/daemon.sock` near the 104-byte sun_path limit; `DEVCTL_SOCKET` overrides; actual path advertised in `daemon.info`), `daemon.lock` (flock held for daemon lifetime; the single-instance mutex: the lock holder alone may unlink and rebind the socket, so stale-socket takeover cannot race), `registry.json`, `state.json`, `stopped.intent` (marker written by deliberate shutdown; auto-bootstrap honors it), `bin/devctld` (stable path the launchd plist points at), `daemon.log` (self-rotated 10 MB × 5; the launchd redirect catches pre-init stderr only). +- `~/Library/Application Support/devctl/`: `daemon.sock` (0600; fallback `/tmp/devctl-$UID/daemon.sock` near the 104-byte sun_path limit; `DEVCTL_SOCKET` overrides; actual path advertised in `daemon.info`), `daemon.lock` (flock held for daemon lifetime; the single-instance mutex: the lock holder alone may unlink and rebind the socket, so stale-socket takeover cannot race), `registry.json`, `state.json`, `locks.json` (held resource locks with the servers they paused; survives a daemon crash so a dead holder still resumes what it stopped), `stopped.intent` (marker written by deliberate shutdown; auto-bootstrap honors it), `agent.path` (login-shell PATH captured at install; the daemon merges it into process env at start so sealed in-bundle plists need not bake PATH), `bin/devctld` (stable path the legacy home LaunchAgent points at), `daemon.log` (self-rotated 10 MB × 5; the launchd redirect catches pre-init stderr only on the legacy path). - State durability: atomic writes are temp + fsync + rename. On load-parse failure, rename the file to `.corrupt-`, log a `sys` event, continue with empty state; corruption is never fatal (a parse crash under KeepAlive would loop forever). - `~/Library/Logs/devctl/-//`: `current.log` + rotated `.1`-`.5`, `spool.log` (raw child output, truncated per run), `health.json` (ring of last 100 health transitions). -- `~/Library/LaunchAgents/dev.quantizor.devctl.plist`. +- LaunchAgent dual path: when `/Applications/devctl.app` is present, `SMAppService.agent` registers the in-bundle `Contents/Library/LaunchAgents/dev.quantizor.devctl.plist` (`BundleProgram` → `Contents/Helpers/devctld`); CLI `daemon install` opens `devctl://daemon/ensure` so registration runs with the app's `Bundle.main`. Without the app, legacy `~/Library/LaunchAgents/dev.quantizor.devctl.plist` + Application Support `bin/devctld` + `AssociatedBundleIdentifiers` remains (`devctl daemon install --legacy` forces it). - Server identity: `serverID = "::"`, where canonical = realpath (symlinks resolved, on-disk case) computed identically in CLI and daemon, so `~/code` symlinks and `/tmp` vs `/private/tmp` cannot mint duplicate identities. ## Config model: devservers.json (committed at project root) @@ -126,7 +126,7 @@ Every command has `--json` (stable schemas carrying `schemaVersion`, generated f - `devctl hook install --statusline` offers a statusline snippet (stdin carries `workspace.current_dir`): `myproj:3000 ok · api:8787 crashed`. - Discovery: after a successful install `hook install` prints a one-bullet CLAUDE.md/AGENTS.md stanza for the project (`DiscoveryStanza.render`, wired to the nearest devservers.json's first server), so an agent that has never heard of devctl learns to prefer `devctl ensure` over `npm run dev`. Printed for a human to paste; devctl never edits those files. - Deep links: the menu bar app declares `devctl://` (`CFBundleURLTypes`). Path form `devctl:////[/]` (verbs: open, ensure, stop, why); query form accepted. Shared `DeepLink` + `DeepLinkRunner` in DevCtlKit; `devctl link` prints, `devctl x-url` dispatches for smoke, Launch Services delivers to the app. Crash notifications carry Open/Why actions that synthesize the same links. App Intents (backlog) should wrap the runner. -- Spotlight: Core Spotlight indexes each server/head as ` · ` (subtitle `devctl · `, `.text` content type). Best-effort discovery only: Apple does not let third-party items outrank filesystem / heavy-usage app Top Hits for a one-word project name. Fast path is Raycast/Alfred + `devctl://`, or the menu bar. +- Spotlight: Core Spotlight indexes each server/head as ` · ` (subtitle `devctl · `, `.text` content type). Updates are incremental with preserved `lastUsedDate` and live/pinned `rankingHint` tiers. Best-effort discovery only: Apple does not let third-party items outrank filesystem / heavy-usage app Top Hits for a one-word project name. Fast path is Raycast/Alfred + `devctl://`, or the menu bar. - Unified logging: `OSLog` subsystem `dev.quantizor.devctl` (daemon, supervisor, health, app, deeplink). Spool files remain the child-output source of truth. - `docs/cli-contract.md` in the repo enumerates every command's JSON schema and per-phase behavior; it is the contract the golden tests enforce and lands before the code that implements it. @@ -141,12 +141,13 @@ Plist: `RunAtLoad`, `KeepAlive = {SuccessfulExit: false}`, `ProcessType=Interact - One `@Observable @MainActor DaemonModel` owning a DaemonClient; `status.subscribe` mirrors pushes; reconnect loop with backoff so daemon restarts are invisible to the UI. - Ambient state: the menu bar glyph itself reports aggregate health, quiet when everything is green, badged when anything crashed or failed; the app subscribes to the event stream and posts a macOS notification on crash with Open / Why actions (UserNotifications lives in the app since the daemon has no bundle; actions dispatch through DeepLinkRunner). - Dropdown: per-project sections, rows = status dot + name + host:port + start/stop/restart; clicking a row opens the server's URL in the default browser (Evan's request); footer: Open Dashboard, Quit (app only). +- Daemon down: the app resurrects devctld via `SMAppService` when the bundle ships `Contents/Library/LaunchAgents` (Login Items naming shows the responsible app), falling back to the legacy home LaunchAgent install from Resources when needed. Rate-limited so a broken install cannot spin. A deliberate `devctl daemon stop` suppresses that (intent outranks liveness), so the row always carries a Start button and reports a failed recovery rather than dead-ending. - Dashboard: NavigationSplitView; virtualized log viewer over `logs.follow` with replay, search, jump-to-marker; config editor writing through `project.writeConfig` with optimistic concurrency (write carries the hash of the loaded version; daemon rejects on mismatch with reload-and-retry, so an IDE edit is never silently clobbered; v1 warns that writes normalize formatting); a timeline lane per server built from the event stream (colored phase spans with marker pins, replacing a bare sparkline); port/signature map (declared vs observed, squatter pids). - Project aesthetic (recorded in CLAUDE.md at bootstrap): a quiet instrument panel. Monochrome SF Symbols glyph, tally-light status dots with a subtle breathing animation on `starting`, dense but calm typography, no chrome; state changes register visibly but never shout. ## Build pipeline (no Xcode ever) -`make build` = `swift build -c release`; `make app` = checked-in `scripts/make-app-bundle.sh` assembling `devctl.app/Contents/{MacOS,Info.plist,Resources}` + ad-hoc `codesign --force --sign -`; `make install` = binaries to `~/.local/bin`, app to /Applications, `devctl daemon install`. `SIGN_IDENTITY` variable upgrades to Developer ID + notarization at OSS-release time. Tuist/XcodeGen/Swift Bundler evaluated and rejected (Xcode dependency or unclear release cadence); the script is the documentation. +`make build` = `swift build -c release`; `make app` = checked-in `scripts/make-app-bundle.sh` assembling `devctl.app/Contents/{MacOS,Info.plist,Resources}` with CLI + daemon in Resources + ad-hoc `codesign` (Developer ID via `SIGN_IDENTITY`); `make dmg` = UDZO via `scripts/make-dmg.sh` (app alone, no `/Applications` symlink, over a generated background telling the user to double-click; the app relocates itself on confirm); `make install` = CLI + daemon to `~/.local/bin`, app to /Applications, `devctl daemon install`. First launch of the app runs a setup panel (migrate binaries, bounce daemon, opt-in harness hooks). Notarization: `scripts/notarize.sh`; GitHub Actions `.github/workflows/release-dmg.yml` attaches a stapled DMG on release. Tuist/XcodeGen/Swift Bundler evaluated and rejected (Xcode dependency or unclear release cadence); the script is the documentation. ## Implementation phases diff --git a/scripts/make-app-bundle.sh b/scripts/make-app-bundle.sh index c204f32..8e2b86f 100755 --- a/scripts/make-app-bundle.sh +++ b/scripts/make-app-bundle.sh @@ -1,28 +1,92 @@ #!/bin/zsh -# Assembles devctl.app from the SPM-built DevCtlApp binary. No Xcode: the bundle -# is directory layout + Info.plist + signature. Ad-hoc signed by default; pass a -# Developer ID identity as $1 to upgrade. +# Assembles devctl.app from the SPM-built products. No Xcode: the bundle is +# directory layout + Info.plist + signature. Contents/Resources carries the CLI +# (and a Resources copy of the daemon for setup); Contents/Helpers/devctld is the +# SMAppService BundleProgram target; Contents/Library/LaunchAgents holds the +# in-bundle agent plist. Ad-hoc signed by default; pass a Developer ID identity +# as $1 to upgrade (notarization needs it). set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" IDENTITY="${1:--}" CONFIG="${2:-release}" -BINARY="$ROOT/.build/$CONFIG/DevCtlApp" +BUILD="$ROOT/.build/$CONFIG" +APP_BINARY="$BUILD/DevCtlApp" +CLI_BINARY="$BUILD/devctl" +DAEMON_BINARY="$BUILD/devctld" APP="$ROOT/devctl.app" +LABEL="dev.quantizor.devctl" -[[ -x "$BINARY" ]] || { echo "make-app-bundle: build first (missing $BINARY)" >&2; exit 1 } +[[ -x "$APP_BINARY" ]] || { echo "make-app-bundle: build first (missing $APP_BINARY)" >&2; exit 1 } +[[ -x "$CLI_BINARY" ]] || { echo "make-app-bundle: build first (missing $CLI_BINARY)" >&2; exit 1 } +[[ -x "$DAEMON_BINARY" ]] || { echo "make-app-bundle: build first (missing $DAEMON_BINARY)" >&2; exit 1 } + +ICON_ICNS="$ROOT/Resources/AppIcon.icns" +[[ -f "$ICON_ICNS" ]] || { echo "make-app-bundle: missing $ICON_ICNS" >&2; exit 1 } rm -rf "$APP" -mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" -cp "$BINARY" "$APP/Contents/MacOS/devctl-app" +mkdir -p \ + "$APP/Contents/MacOS" \ + "$APP/Contents/Resources" \ + "$APP/Contents/Helpers" \ + "$APP/Contents/Library/LaunchAgents" +cp "$APP_BINARY" "$APP/Contents/MacOS/devctl-app" +cp "$CLI_BINARY" "$APP/Contents/Resources/devctl" +cp "$DAEMON_BINARY" "$APP/Contents/Resources/devctld" +cp "$DAEMON_BINARY" "$APP/Contents/Helpers/devctld" +cp "$ICON_ICNS" "$APP/Contents/Resources/AppIcon.icns" +chmod 755 \ + "$APP/Contents/MacOS/devctl-app" \ + "$APP/Contents/Resources/devctl" \ + "$APP/Contents/Resources/devctld" \ + "$APP/Contents/Helpers/devctld" + +# Sealed in-bundle agent: BundleProgram (SMAppService-only), static PATH floor. +# Dynamic login PATH lives in Application Support/agent.path (daemon applies it). +# Stdio redirects omitted: per-user paths cannot be baked into a notarized plist; +# the daemon writes its own daemon.log after start. +cat > "$APP/Contents/Library/LaunchAgents/${LABEL}.plist" < + + + + BundleProgram + Contents/Helpers/devctld + EnvironmentVariables + + PATH + /opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin + + ExitTimeOut + 60 + KeepAlive + + SuccessfulExit + + + Label + ${LABEL} + ProcessType + Interactive + RunAtLoad + + + +PLIST + +VERSION="$("$CLI_BINARY" --version 2>/dev/null || echo 0.1.0)" cat > "$APP/Contents/Info.plist" < + CFBundleDisplayName + quantizor/devctl CFBundleExecutable devctl-app + CFBundleIconFile + AppIcon CFBundleIdentifier dev.quantizor.devctl.app CFBundleInfoDictionaryVersion @@ -32,7 +96,7 @@ cat > "$APP/Contents/Info.plist" <CFBundlePackageType APPL CFBundleShortVersionString - $("$ROOT/.build/$CONFIG/devctl" --version 2>/dev/null || echo 0.1.0) + $VERSION LSMinimumSystemVersion 14.0 LSUIElement @@ -42,6 +106,8 @@ cat > "$APP/Contents/Info.plist" < CFBundleURLName dev.quantizor.devctl.url + CFBundleTypeRole + Editor CFBundleURLSchemes devctl @@ -55,5 +121,21 @@ cat > "$APP/Contents/Info.plist" < "$APP/Contents/PkgInfo" -codesign --force --sign "$IDENTITY" "$APP" -echo "assembled $APP (signed: $IDENTITY)" + +sign() { + local target="$1" + if [[ "$IDENTITY" == "-" ]]; then + codesign --force --sign "$IDENTITY" "$target" + else + # Hardened runtime is required for Developer ID notarization. + codesign --force --options runtime --sign "$IDENTITY" "$target" + fi +} + +# Nested Mach-Os first, then the outer bundle (Gatekeeper walks inward). +sign "$APP/Contents/Resources/devctl" +sign "$APP/Contents/Resources/devctld" +sign "$APP/Contents/Helpers/devctld" +sign "$APP/Contents/MacOS/devctl-app" +sign "$APP" +echo "assembled $APP (signed: $IDENTITY; Helpers/devctld + LaunchAgents + Resources)" diff --git a/scripts/make-dmg-background.swift b/scripts/make-dmg-background.swift new file mode 100644 index 0000000..42d0108 --- /dev/null +++ b/scripts/make-dmg-background.swift @@ -0,0 +1,89 @@ +#!/usr/bin/env swift +/** Renders the DMG background (1x + 2x PNGs) that tells the user to double-click + the app. Generated at build time so no binary asset lives in the repo. + Usage: make-dmg-background.swift */ +import AppKit +import Foundation + +let width: CGFloat = 560 +let height: CGFloat = 380 + +guard CommandLine.arguments.count > 1 else { + FileHandle.standardError.write(Data("make-dmg-background: missing output directory\n".utf8)) + exit(1) +} +let outputDirectory = URL(fileURLWithPath: CommandLine.arguments[1]) + +func render(scale: CGFloat) -> Data? { + guard + let rep = NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: Int(width * scale), + pixelsHigh: Int(height * scale), + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0) + else { return nil } + rep.size = NSSize(width: width, height: height) + + guard let context = NSGraphicsContext(bitmapImageRep: rep) else { return nil } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = context + + NSColor(calibratedWhite: 0.976, alpha: 1).setFill() + NSRect(x: 0, y: 0, width: width, height: height).fill() + + /** Hairline frame: quiet instrument-panel edge, not chrome. */ + NSColor(calibratedWhite: 0.902, alpha: 1).setStroke() + let frame = NSBezierPath(rect: NSRect(x: 0.5, y: 0.5, width: width - 1, height: height - 1)) + frame.lineWidth = 1 + frame.stroke() + + let paragraph = NSMutableParagraphStyle() + paragraph.alignment = .center + + /** 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. */ + let heading = NSAttributedString( + string: "Double-click 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)) + + 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.", + attributes: [ + .font: NSFont.systemFont(ofSize: 12.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)) + + NSGraphicsContext.restoreGraphicsState() + return rep.representation(using: .png, properties: [:]) +} + +do { + try FileManager.default.createDirectory( + at: outputDirectory, withIntermediateDirectories: true) + for (scale, name) in [(CGFloat(1), "background.png"), (CGFloat(2), "background@2x.png")] { + guard let data = render(scale: scale) else { + FileHandle.standardError.write(Data("make-dmg-background: render failed\n".utf8)) + exit(1) + } + try data.write(to: outputDirectory.appending(path: name)) + } +} catch { + FileHandle.standardError.write( + Data("make-dmg-background: \(error.localizedDescription)\n".utf8)) + exit(1) +} diff --git a/scripts/make-dmg.sh b/scripts/make-dmg.sh new file mode 100755 index 0000000..906a85a --- /dev/null +++ b/scripts/make-dmg.sh @@ -0,0 +1,114 @@ +#!/bin/zsh +# Builds the devctl DMG from the assembled (and signed) devctl.app. +# Usage: scripts/make-dmg.sh [SIGN_IDENTITY] +# +# The image holds the app alone: no /Applications symlink. Setup happens inside +# the app (it copies itself to /Applications after an explicit confirm), so a +# drag target would be a second, conflicting way to install. The window +# background says to double-click instead. +# +# Finder styling needs a GUI session. Without one the AppleScript pass is skipped +# and the image still ships, just unstyled: plain icon view, background file +# present but unused. Set DEVCTL_DMG_REQUIRE_LAYOUT=1 to make that a hard error, +# so a release build never quietly loses the instructions. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +IDENTITY="${1:--}" +APP="$ROOT/devctl.app" +DIST="$ROOT/dist" +STAGE="$DIST/dmg-stage" +VOLUME_NAME="devctl" + +[[ -d "$APP" ]] || { echo "make-dmg: run make app first (missing $APP)" >&2; exit 1 } + +VERSION="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleShortVersionString' "$APP/Contents/Info.plist" 2>/dev/null || echo 0.0.0)" +DMG="$DIST/devctl-${VERSION}.dmg" +RW_DMG="$DIST/.devctl-${VERSION}.rw.dmg" + +rm -rf "$STAGE" +mkdir -p "$STAGE/.background" "$DIST" +ditto "$APP" "$STAGE/devctl.app" + +echo "rendering background..." +swift "$ROOT/scripts/make-dmg-background.swift" "$STAGE/.background" +tiffutil -cathidpicheck \ + "$STAGE/.background/background.png" \ + "$STAGE/.background/background@2x.png" \ + -out "$STAGE/.background/background.tiff" > /dev/null +rm -f "$STAGE/.background/background.png" "$STAGE/.background/background@2x.png" + +rm -f "$DMG" "$RW_DMG" +hdiutil create \ + -volname "$VOLUME_NAME" \ + -srcfolder "$STAGE" \ + -ov \ + -format UDRW \ + "$RW_DMG" > /dev/null + +# Mount under /Volumes and read the name back: Finder resolves the window by +# volume name, and a stale devctl mount makes this one "devctl 1". Custom +# -mountpoint or -nobrowse both break the AppleScript lookup. +MOUNT_POINT="$(hdiutil attach "$RW_DMG" -noverify -noautoopen | awk -F'\t' '/\/Volumes\// { print $NF }' | tail -1)" +[[ -n "$MOUNT_POINT" ]] || { echo "make-dmg: could not mount $RW_DMG" >&2; exit 1 } +MOUNTED_NAME="$(basename "$MOUNT_POINT")" +abort_cleanup() { + hdiutil detach "$MOUNT_POINT" -quiet 2>/dev/null || true + rm -f "$RW_DMG" + rm -rf "$STAGE" +} +trap abort_cleanup EXIT + +LAYOUT_LOG="$(mktemp "${TMPDIR:-/tmp}/devctl-dmg-layout.XXXXXX")" +if osascript - "$MOUNTED_NAME" > "$LAYOUT_LOG" 2>&1 <<'APPLESCRIPT' +on run argv + set volumeName to item 1 of argv + tell application "Finder" + tell disk volumeName + open + 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 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} + update without registering applications + delay 1 + close + end tell + end tell +end run +APPLESCRIPT +then + echo "applied Finder window layout" + rm -f "$LAYOUT_LOG" +elif [[ "${DEVCTL_DMG_REQUIRE_LAYOUT:-0}" == "1" ]]; then + echo "make-dmg: Finder window layout failed and DEVCTL_DMG_REQUIRE_LAYOUT=1" >&2 + cat "$LAYOUT_LOG" >&2 + echo "make-dmg: run from a logged-in GUI session, grant the terminal Automation access for Finder, and check that no other volume is named $VOLUME_NAME" >&2 + exit 1 +else + echo "note: skipped Finder layout; the image ships unstyled. Reason:" + sed 's/^/ /' "$LAYOUT_LOG" + rm -f "$LAYOUT_LOG" +fi + +rm -rf "$MOUNT_POINT/.fseventsd" +sync +hdiutil detach "$MOUNT_POINT" -quiet +trap - EXIT + +hdiutil convert "$RW_DMG" -format UDZO -imagekey zlib-level=9 -ov -o "$DMG" > /dev/null +rm -f "$RW_DMG" +rm -rf "$STAGE" + +if [[ "$IDENTITY" != "-" ]]; then + codesign --force --sign "$IDENTITY" "$DMG" + echo "signed DMG with $IDENTITY" +fi + +echo "wrote $DMG" diff --git a/scripts/notarize.sh b/scripts/notarize.sh new file mode 100755 index 0000000..e6d112d --- /dev/null +++ b/scripts/notarize.sh @@ -0,0 +1,52 @@ +#!/bin/zsh +# Notarize and staple a Developer ID-signed DMG (or app). Credentials come from +# the environment / keychain; never commit secrets. +# +# Auth (one of): +# NOTARY_KEYCHAIN_PROFILE (default: devctl-notary) — local notarytool store-credentials +# APPLE_API_KEY_ID + APPLE_API_ISSUER + (APPLE_API_KEY_PATH | APPLE_API_KEY_BASE64) — CI +# Optional: +# NOTARIZE_TARGET (path to .dmg or .app; default: newest dist/devctl-*.dmg) +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +TARGET="${NOTARIZE_TARGET:-}" +if [[ -z "$TARGET" ]]; then + TARGET="$(ls -t "$ROOT"/dist/devctl-*.dmg 2>/dev/null | head -1 || true)" +fi +[[ -n "$TARGET" && -e "$TARGET" ]] || { + echo "notarize: no target (set NOTARIZE_TARGET or build make dmg first)" >&2 + exit 1 +} + +PROFILE="${NOTARY_KEYCHAIN_PROFILE:-devctl-notary}" +AUTH_ARGS=() +CLEANUP_KEY="" + +if [[ -n "${APPLE_API_KEY_ID:-}" && -n "${APPLE_API_ISSUER:-}" ]]; then + KEY_PATH="${APPLE_API_KEY_PATH:-}" + if [[ -z "$KEY_PATH" ]]; then + : "${APPLE_API_KEY_BASE64:?set APPLE_API_KEY_PATH or APPLE_API_KEY_BASE64}" + # The private key lands in a 0700 directory of its own: mktemp only + # randomizes trailing Xs, so a template like AuthKey.XXXXXX.p8 would be a + # fixed, world-readable path, and chmod after the write leaves a window. + KEY_DIR="$(mktemp -d "${TMPDIR:-/tmp}/devctl-notary.XXXXXX")" + KEY_PATH="$KEY_DIR/AuthKey.p8" + CLEANUP_KEY="$KEY_DIR" + print -r -- "$APPLE_API_KEY_BASE64" | base64 -D > "$KEY_PATH" + fi + AUTH_ARGS=(--key "$KEY_PATH" --key-id "$APPLE_API_KEY_ID" --issuer "$APPLE_API_ISSUER") +else + AUTH_ARGS=(--keychain-profile "$PROFILE") +fi + +trap '[[ -n "$CLEANUP_KEY" ]] && rm -rf "$CLEANUP_KEY"' EXIT + +echo "submitting $TARGET for notarization..." +xcrun notarytool submit "$TARGET" "${AUTH_ARGS[@]}" --wait + +echo "stapling $TARGET..." +xcrun stapler staple "$TARGET" +xcrun stapler validate "$TARGET" +echo "notarize ok: $TARGET" diff --git a/scripts/smoke-launchd.sh b/scripts/smoke-launchd.sh index 831114d..be338b5 100755 --- a/scripts/smoke-launchd.sh +++ b/scripts/smoke-launchd.sh @@ -1,5 +1,8 @@ #!/bin/zsh # launchd smoke: exercises the REAL LaunchAgent lifecycle on this machine. +# Uses the legacy home LaunchAgent path (--legacy) so it stays deterministic +# when /Applications/devctl.app is also present. For SMAppService, install from +# the app / Login Items on a GUI session separately. # install → daemon status → server ensure → restart (bounce + re-ensure) → # install-upgrade (bounce + re-ensure) → deliberate stop honored by # auto-bootstrap → start → uninstall. @@ -12,6 +15,7 @@ WORK="$(mktemp -d /tmp/devctl-launchd-smoke.XXXXXX)" PROJECT="$WORK/project" mkdir -p "$PROJECT" DEVCTL="$BIN/devctl" +LABEL="dev.quantizor.devctl" fail() { echo "LAUNCHD-SMOKE FAIL: $1" >&2; cleanup; exit 1 } pass() { echo " ok: $1" } @@ -25,12 +29,15 @@ trap cleanup EXIT echo "building..." swift build --package-path "$ROOT" > /dev/null -if [[ -f "$HOME/Library/LaunchAgents/dev.quantizor.devctl.plist" ]]; then - fail "a devctl LaunchAgent is already installed; refusing to clobber it" +if [[ -f "$HOME/Library/LaunchAgents/${LABEL}.plist" ]]; then + fail "a legacy home LaunchAgent is already installed; refusing to clobber it" +fi +if launchctl print "gui/$(id -u)/${LABEL}" >/dev/null 2>&1; then + fail "a ${LABEL} job is already bootstrapped; refusing to clobber it" fi -"$DEVCTL" daemon install > /dev/null || fail "daemon install" -pass "daemon install + hello" +"$DEVCTL" daemon install --legacy > /dev/null || fail "daemon install --legacy" +pass "daemon install + hello (legacy home LaunchAgent)" "$DEVCTL" daemon status | grep -q "pid" || fail "daemon status shows no pid" pass "daemon status" @@ -51,7 +58,7 @@ pass "restart bounced and re-ensured (pid $PID_BEFORE -> $PID_AFTER)" # Upgrade-in-place: install again while the server is up must re-ensure it, # same contract as restart (this is what `make install` hits). PID_PRE_UPGRADE="$PID_AFTER" -"$DEVCTL" daemon install > /dev/null || fail "daemon install upgrade" +"$DEVCTL" daemon install --legacy > /dev/null || fail "daemon install upgrade" PHASE="$("$DEVCTL" wait web --healthy --timeout 20 --json | /usr/bin/python3 -c 'import json,sys; print(json.load(sys.stdin)["server"]["phase"])')" [[ "$PHASE" == "running" ]] || fail "server did not come back after install upgrade (phase $PHASE)" PID_POST_UPGRADE="$("$DEVCTL" status web --json | /usr/bin/python3 -c 'import json,sys; print(json.load(sys.stdin)["servers"][0]["pid"])')" @@ -68,20 +75,20 @@ set -e echo "$STATUS_OUT" | grep -q "deliberately stopped" || fail "missing deliberate-stop hint: $STATUS_OUT" pass "deliberate stop honored by auto-bootstrap" -"$DEVCTL" daemon start > /dev/null || fail "daemon start" +"$DEVCTL" daemon start --legacy > /dev/null || fail "daemon start" "$DEVCTL" daemon status | grep -q "pid" || fail "daemon not back after start" pass "daemon start" # Auto-bootstrap: stop via bootout (simulating a dead daemon with no intent), # then a plain status must resurrect it. -launchctl bootout "gui/$(id -u)/dev.quantizor.devctl" 2>/dev/null || true +launchctl bootout "gui/$(id -u)/${LABEL}" 2>/dev/null || true sleep 1 "$DEVCTL" status --json > /dev/null || fail "auto-bootstrap did not resurrect the daemon" "$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/dev.quantizor.devctl.plist" ]] || fail "plist survived uninstall" +[[ ! -f "$HOME/Library/LaunchAgents/${LABEL}.plist" ]] || fail "plist survived uninstall" pass "uninstall" echo "LAUNCHD-SMOKE PASS" diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 2658768..ed3daec 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -17,6 +17,9 @@ pass() { echo " ok: $1" } cleanup() { [[ -n "${DAEMON_PID:-}" ]] && kill -9 "$DAEMON_PID" 2>/dev/null || true [[ -n "${CHILD_PID:-}" ]] && kill -9 "-$CHILD_PID" 2>/dev/null || true + # Grandchildren escape the process group on purpose (that is what the teardown + # assertions exercise), so a group kill leaves them behind. Reap them by pid. + for stray in ${STRAY_PIDS:-}; do kill -9 "$stray" 2>/dev/null || true; done rm -rf "$WORK" } trap cleanup EXIT @@ -116,6 +119,7 @@ sleep 1 AFTER="$(wc -l < "$RAW_SPOOL")" [[ "$AFTER" -gt "$BEFORE" ]] || fail "raw spool stopped growing after daemon death" pass "child survived daemon kill and kept logging ($BEFORE -> $AFTER raw lines)" +STRAY_PIDS="${STRAY_PIDS:-} $(grep grandchild "$RAW_SPOOL" | tail -1 | awk '{print $NF}')" kill -9 "-$CHILD_PID" 2>/dev/null || true CHILD_PID="" @@ -195,12 +199,33 @@ set -e echo "$BAD_OUT" | grep -Eq 'not-found|"ok":false' || fail "x-url bad slug envelope: $BAD_OUT" pass "x-url rejects unknown slug" -# Bundle advertises the custom URL scheme. -swift build --package-path "$ROOT" --product DevCtlApp > /dev/null +# Bundle advertises the custom URL scheme and ships CLI + daemon for first-run. "$ROOT/scripts/make-app-bundle.sh" - debug SCHEME="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleURLTypes:0:CFBundleURLSchemes:0' "$ROOT/devctl.app/Contents/Info.plist")" [[ "$SCHEME" == "devctl" ]] || fail "assembled Info.plist scheme was '$SCHEME'" pass "assembled app declares CFBundleURLSchemes=devctl" +ICON="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleIconFile' "$ROOT/devctl.app/Contents/Info.plist")" +[[ "$ICON" == "AppIcon" ]] || fail "assembled Info.plist CFBundleIconFile was '$ICON'" +[[ -f "$ROOT/devctl.app/Contents/Resources/AppIcon.icns" ]] || fail "bundle missing Resources/AppIcon.icns" +pass "assembled app ships AppIcon.icns" +DISPLAY="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleDisplayName' "$ROOT/devctl.app/Contents/Info.plist")" +[[ "$DISPLAY" == "quantizor/devctl" ]] || fail "assembled Info.plist CFBundleDisplayName was '$DISPLAY'" +pass "assembled app display name is quantizor/devctl" +ROLE="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleURLTypes:0:CFBundleTypeRole' "$ROOT/devctl.app/Contents/Info.plist")" +[[ "$ROLE" == "Editor" ]] || fail "assembled Info.plist CFBundleTypeRole was '$ROLE'" +pass "assembled app URL type role is Editor" +[[ -x "$ROOT/devctl.app/Contents/Resources/devctl" ]] || fail "bundle missing Resources/devctl" +[[ -x "$ROOT/devctl.app/Contents/Resources/devctld" ]] || fail "bundle missing Resources/devctld" +pass "assembled app ships CLI and daemon in Resources" +[[ -x "$ROOT/devctl.app/Contents/Helpers/devctld" ]] || fail "bundle missing Helpers/devctld" +AGENT_PLIST="$ROOT/devctl.app/Contents/Library/LaunchAgents/dev.quantizor.devctl.plist" +[[ -f "$AGENT_PLIST" ]] || fail "bundle missing Library/LaunchAgents/dev.quantizor.devctl.plist" +BUNDLE_PROG="$(/usr/libexec/PlistBuddy -c 'Print :BundleProgram' "$AGENT_PLIST")" +[[ "$BUNDLE_PROG" == "Contents/Helpers/devctld" ]] || fail "BundleProgram was '$BUNDLE_PROG'" +# launchd caps ExitTimeOut at 60 and logs a complaint above it. +AGENT_EXIT_TIMEOUT="$(/usr/libexec/PlistBuddy -c 'Print :ExitTimeOut' "$AGENT_PLIST")" +[[ "$AGENT_EXIT_TIMEOUT" -le 60 ]] || fail "ExitTimeOut was '$AGENT_EXIT_TIMEOUT'; launchd caps it at 60" +pass "assembled app ships Helpers/devctld + in-bundle LaunchAgent" kill -9 "$DAEMON_PID" 2>/dev/null || true DAEMON_PID=""