diff --git a/.changeset/README.md b/.changeset/README.md index c6ef363..da82089 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -9,3 +9,5 @@ Version and changelog for GitHub releases. This repo is a Swift product: the roo 3. Merge that PR when ready. The workflow tags `vX.Y.Z` (matching historical releases) and creates the GitHub release from the changelog. `npm run version` also syncs `Sources/DevCtlKit/Model/Models.swift` (`DevCtlVersion.version`) from `package.json` so `devctl --version` and the app bundle stay aligned. + +Changelog lines still link the PR/commit via `@changesets/changelog-github`. The local `changelog.cjs` wrapper drops "Thanks @quantizor" (maintainer self-thanks) and keeps thanks for other contributors. diff --git a/.changeset/bare-loopback-warn.md b/.changeset/bare-loopback-warn.md new file mode 100644 index 0000000..5b0596a --- /dev/null +++ b/.changeset/bare-loopback-warn.md @@ -0,0 +1,5 @@ +--- +"devctl": patch +--- + +Warn in `config check` when a host or url uses bare `localhost` / `127.0.0.1` instead of a `.localhost` origin. diff --git a/.changeset/changelog.cjs b/.changeset/changelog.cjs new file mode 100644 index 0000000..20905ed --- /dev/null +++ b/.changeset/changelog.cjs @@ -0,0 +1,30 @@ +/** Wraps `@changesets/changelog-github`, omitting self-thanks for the + maintainer login while still thanking other contributors. */ +const github = require("@changesets/changelog-github"); + +const NO_THANKS = new Set(["quantizor"]); + +/** @param {string} line */ +function stripMaintainerThanks(line) { + return line.replace( + / Thanks ((?:\[@[^\]]+\]\([^)]+\))(?:, (?:\[@[^\]]+\]\([^)]+\)))*)!/g, + (_, users) => { + const kept = users + .split(/,\s*/) + .filter((user) => { + const match = user.match(/^\[@([^\]]+)\]/); + return match ? !NO_THANKS.has(match[1].toLowerCase()) : true; + }); + if (kept.length === 0) return ""; + return ` Thanks ${kept.join(", ")}!`; + }, + ); +} + +module.exports = { + getDependencyReleaseLine: github.getDependencyReleaseLine, + getReleaseLine: async (changeset, type, options) => { + const line = await github.getReleaseLine(changeset, type, options); + return stripMaintainerThanks(line); + }, +}; diff --git a/.changeset/config.json b/.changeset/config.json index 6109106..7e730da 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,7 +1,7 @@ { "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", "changelog": [ - "@changesets/changelog-github", + "./changelog.cjs", { "repo": "quantizor/devctl" } ], "commit": false, diff --git a/.changeset/deeplink-oslog.md b/.changeset/deeplink-oslog.md new file mode 100644 index 0000000..54a0b7d --- /dev/null +++ b/.changeset/deeplink-oslog.md @@ -0,0 +1,5 @@ +--- +"devctl": minor +--- + +Open servers and run lifecycle verbs via `devctl://` URLs (menu bar app + `devctl link` / `devctl x-url`), with unified logging under subsystem `dev.quantizor.devctl`. diff --git a/.changeset/discovery-stanza.md b/.changeset/discovery-stanza.md new file mode 100644 index 0000000..c8393e4 --- /dev/null +++ b/.changeset/discovery-stanza.md @@ -0,0 +1,5 @@ +--- +"devctl": patch +--- + +After `hook install`, print a one-bullet CLAUDE.md / AGENTS.md discovery tip for the project (paste-only; never auto-edits those files). diff --git a/.changeset/recover-config-servers.md b/.changeset/recover-config-servers.md new file mode 100644 index 0000000..959e033 --- /dev/null +++ b/.changeset/recover-config-servers.md @@ -0,0 +1,5 @@ +--- +"devctl": patch +--- + +Restore config-defined servers after reboot and `daemon install` upgrades (merged config+registry recover; install re-ensures like restart). diff --git a/.changeset/spotlight-labels.md b/.changeset/spotlight-labels.md new file mode 100644 index 0000000..a1638b3 --- /dev/null +++ b/.changeset/spotlight-labels.md @@ -0,0 +1,5 @@ +--- +"devctl": patch +--- + +Spotlight entries use ` · ` titles with a `devctl · ` subtitle for clearer discovery. diff --git a/BACKLOG.md b/BACKLOG.md index 4bc0240..b00ff20 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -7,9 +7,8 @@ Open work only; entries are removed by the change that resolves them. - 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. -- Verify the ProcessType=Interactive claim (App Nap/QoS throttling of own-session children) empirically; unconfirmed since install. +- 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. - 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. -- `devctl hook install` (and possibly `register`) should offer the one-line CLAUDE.md/AGENTS.md discovery stanza for the project, per the design doc; candor's was written by hand. Until then a new project's agents learn about devctl only from the injected context. -- devctl.app accessibility pass: the popover's borderless footer buttons report no AX name to System Events (missing value); audit the app with the a11y toolchain and label every control. -- Spotlight thumbnails: verify the icon renders in the real Spotlight results UI (indexing reports ok and thumbnailData is set from the config icon; the visual result awaits a human Spotlight search). +- 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. +- 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 a7bfc3c..04672d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,19 +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). +- 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). -- Sources/DevCtlApp: menu bar app (DaemonModel 2s-polling model + crash notifications; 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 indexes servers and heads with config-icon thumbnails). Pure DaemonClient consumer. +- 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/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, and child survival across a daemon kill. Run it after touching the supervisor, wire protocol, or CLI. -- scripts/smoke-launchd.sh: the REAL LaunchAgent lifecycle (install, restart 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). make install: binaries to ~/.local/bin, app to /Applications, daemon install. +- 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-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. +- 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. Hard rules - Output capture is spool-file fds, never pipes: children must survive daemon death without SIGPIPE. Do not introduce pipe-based capture anywhere. diff --git a/README.md b/README.md index 272fe4f..1d3006c 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A launchd-supervised daemon owns every server process. Sessions come and go, con ## Why -Agents forget their dev servers. After a context compaction they spawn duplicates, tail dead logs, and fight over ports. devctl gives them one idempotent verb (`devctl ensure web`) that always lands in the same place, a session hook that re-teaches every new or compacted session what is running, and a `why` command that turns a broken server into a root-cause diagnosis. +Agents forget their dev servers. After a context compaction they spawn duplicates, tail dead logs, and fight over ports. devctl gives them one idempotent verb (`devctl ensure myproj`) that always lands in the same place, a session hook that re-teaches every new or compacted session what is running, and a `why` command that turns a broken server into a root-cause diagnosis. ## Quick start @@ -16,13 +16,15 @@ Agents forget their dev servers. After a context compaction they spawn duplicate ```sh make install # binaries to ~/.local/bin, app to /Applications, daemon installed cd your-project -devctl register --name web --cmd bun --cmd run --cmd dev --port 3000 -devctl ensure web # idempotent: healthy is a no-op -devctl why web # root cause when something breaks +devctl register --name myproj --cmd bun --cmd run --cmd dev --port 3000 +devctl ensure myproj # idempotent: healthy is a no-op +devctl why myproj # root cause when something breaks devctl hook install --harness cursor # Cursor Agent sessions rediscover servers automatically devctl hook install --harness claude # same for Claude Code (default harness) ``` +Name each server after the project (`myproj`, not a generic `web`) so it is easy to spot in Spotlight and search, and give it a `.localhost` host rather than bare `localhost`: the per-project subdomain keeps browser cookies, storage, and service workers isolated between projects. + Or commit a `devservers.json` at the project root (multiple servers, dependencies, healthchecks, `*.localhost` host signatures, multi-headed proxies, lifecycle playbooks); `devctl up` brings the whole project up in dependency order. The full CLI contract lives in [docs/cli-contract.md](./docs/cli-contract.md). ## The parts diff --git a/Sources/DevCtlApp/AppDeepLink.swift b/Sources/DevCtlApp/AppDeepLink.swift new file mode 100644 index 0000000..32295c9 --- /dev/null +++ b/Sources/DevCtlApp/AppDeepLink.swift @@ -0,0 +1,79 @@ +import AppKit +import DevCtlKit +import Foundation +import UserNotifications + +/** AppKit / UserNotifications side effects for `DeepLinkRunner`. */ +struct AppDeepLinkEffects: DeepLinkEffects { + func copyToPasteboard(_ text: String) async { + await MainActor.run { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + } + + func notify(title: String, body: String) async { + let content = UNMutableNotificationContent() + content.title = title + content.body = body + content.sound = .default + let request = UNNotificationRequest( + identifier: "devctl-deeplink-\(UUID().uuidString)", content: content, trigger: nil) + try? await UNUserNotificationCenter.current().add(request) + } + + func openBrowser(_ url: URL) async { + await MainActor.run { NSWorkspace.shared.open(url) } + } +} + +/** Shared deep-link dispatch used by URL opens and notification actions. */ +enum AppDeepLinkDispatch { + static let serverAlertCategory = "dev.quantizor.devctl.server-alert" + static let userInfoProject = "project" + static let userInfoServer = "server" + static let userInfoHead = "head" + + static func run(_ url: URL) { + switch DeepLink.parse(url: url) { + case .failure(let error): + DevCtlLog.deeplink.error("reject \(error.message)") + Task { + await AppDeepLinkEffects().notify(title: "devctl link failed", body: error.message) + } + case .success(let link): + Task { await execute(link) } + } + } + + static func run(_ link: DeepLink) { + Task { await execute(link) } + } + + private static func execute(_ link: DeepLink) async { + let client = DaemonClient(socketPath: DevCtlPaths().socketPath) + do { + try await client.connect() + let result = try await DeepLinkRunner(client: client, effects: AppDeepLinkEffects()).run(link) + DevCtlLog.app.info( + "deeplink \(result.verb.rawValue) \(result.projectPath) \(result.detail ?? "")") + } catch let error as WireError { + DevCtlLog.deeplink.error("\(error.message)") + await AppDeepLinkEffects().notify(title: "devctl link failed", body: error.message) + } catch { + DevCtlLog.deeplink.error("\(error)") + await AppDeepLinkEffects().notify(title: "devctl link failed", body: String(describing: error)) + } + } + + /** Register Open / Why actions once at launch. */ + static func registerNotificationCategories() { + let open = UNNotificationAction( + identifier: DeepLinkNotificationAction.open.rawValue, title: "Open") + let why = UNNotificationAction( + identifier: DeepLinkNotificationAction.why.rawValue, title: "Why") + let category = UNNotificationCategory( + identifier: serverAlertCategory, actions: [open, why], intentIdentifiers: []) + UNUserNotificationCenter.current().setNotificationCategories([category]) + } +} diff --git a/Sources/DevCtlApp/DaemonModel.swift b/Sources/DevCtlApp/DaemonModel.swift index 0f261de..bfda0b7 100644 --- a/Sources/DevCtlApp/DaemonModel.swift +++ b/Sources/DevCtlApp/DaemonModel.swift @@ -267,11 +267,18 @@ final class DaemonModel { content.title = "\(event.server) \(event.kind.rawValue)" content.body = event.detail.map { "\($0) · \((event.project as NSString).lastPathComponent)" } ?? (event.project as NSString).lastPathComponent + content.categoryIdentifier = AppDeepLinkDispatch.serverAlertCategory + content.userInfo = [ + AppDeepLinkDispatch.userInfoProject: event.project, + AppDeepLinkDispatch.userInfoServer: event.server, + ] + content.sound = .default let request = UNNotificationRequest( identifier: "devctl-\(event.server)-\(event.at.timeIntervalSince1970)", content: content, trigger: nil) try? await UNUserNotificationCenter.current().add(request) + DevCtlLog.app.info("notified \(event.kind.rawValue) \(event.server)") } } } diff --git a/Sources/DevCtlApp/DashboardView.swift b/Sources/DevCtlApp/DashboardView.swift index 8496f2e..ac678da 100644 --- a/Sources/DevCtlApp/DashboardView.swift +++ b/Sources/DevCtlApp/DashboardView.swift @@ -77,6 +77,7 @@ struct ServerDetail: View { model: model, reserveSlot: false, server: server, size: 16) if server.url != nil { Button { + SpotlightIndexer.noteOpened(identifier: "\(server.project)::\(server.server)") openURL(server.url) } label: { Image(systemName: "safari") @@ -103,7 +104,11 @@ struct ServerDetail: View { if let heads = server.heads, !heads.isEmpty { Menu { ForEach(heads.sorted(by: { $0.key < $1.key }), id: \.key) { name, url in - Button("\(name) · \(url)") { openURL(url) } + Button("\(name) · \(url)") { + SpotlightIndexer.noteOpened( + identifier: "\(server.project)::\(server.server)::\(name)") + openURL(url) + } } } label: { Image(systemName: "rectangle.stack") @@ -113,6 +118,7 @@ struct ServerDetail: View { .menuStyle(.borderlessButton) .frame(width: 22) .help("Open head") + .accessibilityLabel(Text("Open head")) } Button { NSWorkspace.shared.activateFileViewerSelecting( @@ -130,6 +136,7 @@ struct ServerDetail: View { } .pickerStyle(.segmented) .frame(width: 240) + .accessibilityLabel(Text("Detail view")) } } .padding(.horizontal, 12) diff --git a/Sources/DevCtlApp/DevCtlApp.swift b/Sources/DevCtlApp/DevCtlApp.swift index eea7738..1ef25cb 100644 --- a/Sources/DevCtlApp/DevCtlApp.swift +++ b/Sources/DevCtlApp/DevCtlApp.swift @@ -3,6 +3,7 @@ import CoreSpotlight import DevCtlKit import ServiceManagement import SwiftUI +import UserNotifications /** One selectable row in the popover's keyboard model. A server row or one of its heads; `actions` are the Left/Right targets in display order. */ @@ -145,6 +146,7 @@ final class KeyNavModel { switch sel.action { case .open: ProjectAccessLog.shared.record(projectPath: project) + SpotlightIndexer.noteOpened(identifier: "\(project)::\(name)") if let url = server.url.flatMap(URL.init(string:)) { NSWorkspace.shared.open(url) } case .start: daemon?.startServer(server) case .stop: daemon?.stopServer(server) @@ -156,19 +158,51 @@ final class KeyNavModel { switch sel.action { case .open: ProjectAccessLog.shared.record(projectPath: project) + SpotlightIndexer.noteOpened(identifier: "\(project)::\(name)::\(head)") if let parsed = URL(string: url) { NSWorkspace.shared.open(parsed) } case .pin: + let identifier = "\(project)::\(name)::\(head)" + let wasPinned = HeadPins.shared.isPinned(project: project, server: name, head: head) HeadPins.shared.toggle(project: project, server: name, head: head) + if !wasPinned { SpotlightIndexer.noteOpened(identifier: identifier) } default: break } } } } -/** Handles Spotlight item activation: a background (LSUIElement) app receives - it through the app delegate, not a scene view, since the popover's view tree - may not exist when Spotlight launches us. */ -final class SpotlightActivationDelegate: NSObject, NSApplicationDelegate { +/** Handles Spotlight, `devctl://` URL opens, and notification action taps. A + background (LSUIElement) app receives these through the app delegate, not a + scene view: the popover's view tree may not exist when Launch Services or + Spotlight launches us. */ +final class AppActivationDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { + func applicationDidFinishLaunching(_ notification: Notification) { + AppDeepLinkDispatch.registerNotificationCategories() + UNUserNotificationCenter.current().delegate = self + /** MenuBarExtra / LSUIElement apps do not always receive + application(_:open:); the GetURL Apple Event is the reliable path. */ + NSAppleEventManager.shared().setEventHandler( + self, + andSelector: #selector(handleGetURLEvent(_:withReplyEvent:)), + forEventClass: AEEventClass(kInternetEventClass), + andEventID: AEEventID(kAEGetURL)) + } + + @objc private func handleGetURLEvent( + _ event: NSAppleEventDescriptor, withReplyEvent reply: NSAppleEventDescriptor + ) { + guard let raw = event.paramDescriptor(forKeyword: keyDirectObject)?.stringValue, + let url = URL(string: raw) + else { return } + AppDeepLinkDispatch.run(url) + } + + func application(_ application: NSApplication, open urls: [URL]) { + for url in urls where url.scheme?.lowercased() == "devctl" { + AppDeepLinkDispatch.run(url) + } + } + func application( _ application: NSApplication, continue userActivity: NSUserActivity, @@ -178,9 +212,31 @@ final class SpotlightActivationDelegate: NSObject, NSApplicationDelegate { let identifier = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String, let url = SpotlightIndexer.url(forIdentifier: identifier) else { return false } + SpotlightIndexer.noteOpened(identifier: identifier) NSWorkspace.shared.open(url) return true } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void + ) { + let info = response.notification.request.content.userInfo + let project = info[AppDeepLinkDispatch.userInfoProject] as? String + let server = info[AppDeepLinkDispatch.userInfoServer] as? String + let head = info[AppDeepLinkDispatch.userInfoHead] as? String + if let project, let server, + let link = DeepLinkNotificationAction.link( + actionId: response.actionIdentifier, + projectSlug: (project as NSString).lastPathComponent, + server: server, + head: head) + { + AppDeepLinkDispatch.run(link) + } + completionHandler() + } } /** devctl.app: quiet instrument panel in the menu bar. The glyph reports @@ -189,7 +245,7 @@ final class SpotlightActivationDelegate: NSObject, NSApplicationDelegate { necessity (unix socket), LSUIElement, no Dock icon. */ @main struct DevCtlApp: App { - @NSApplicationDelegateAdaptor(SpotlightActivationDelegate.self) private var spotlightDelegate + @NSApplicationDelegateAdaptor(AppActivationDelegate.self) private var appDelegate @State private var model = DaemonModel() /** Polling starts at launch, not first popover open: the collapsed label's @@ -262,7 +318,7 @@ struct MenuContent: View { } else if model.projects.isEmpty { VStack(alignment: .leading, spacing: 4) { Text("No servers registered") - Text("devctl register --name web --cmd …") + Text("devctl register --name myproj --cmd …") .font(.system(.caption, design: .monospaced)) .foregroundStyle(.secondary) } @@ -322,6 +378,8 @@ struct MenuContent: View { } .buttonStyle(.borderless) .foregroundStyle(.secondary) + .help("Open the dashboard window") + .accessibilityLabel(Text("Open dashboard")) /** Generous air between primary action and the login preference. */ LaunchAtLoginToggle() .padding(.leading, 22) @@ -333,6 +391,8 @@ struct MenuContent: View { } .buttonStyle(.borderless) .foregroundStyle(.secondary) + .help("Quit devctl") + .accessibilityLabel(Text("Quit devctl")) } .padding(.horizontal, 12) .padding(.vertical, 8) @@ -398,6 +458,7 @@ struct ServerRow: View { the signature is a one-click browser origin. */ Button { ProjectAccessLog.shared.record(projectPath: server.project) + SpotlightIndexer.noteOpened(identifier: "\(server.project)::\(server.server)") if let url = server.url.flatMap(URL.init(string:)) { NSWorkspace.shared.open(url) } @@ -673,7 +734,9 @@ enum PresenceDots { } /** Pinned-head preference, persisted across launches (UserDefaults). Keys are - `project::server::head` so pins survive renames of nothing they should not. */ + `project::server::head`. A server rename (dev → candor) orphans the old key; + `reconcile` remaps when the project still has exactly one server that exposes + that head, otherwise drops the dead pin. */ @Observable final class HeadPins { static let shared = HeadPins() @@ -696,6 +759,42 @@ final class HeadPins { } else { pinned.insert(key) } + persist() + } + + /** Drop or remap pins that no longer match a live head (server renames). */ + func reconcile(projects: [DaemonModel.ProjectGroup]) { + var valid: Set = [] + /** projectPath → head → [server names that expose it] */ + var byProjectHead: [String: [String: [String]]] = [:] + for project in projects { + for server in project.servers { + for head in (server.heads ?? [:]).keys { + let key = "\(project.path)::\(server.server)::\(head)" + valid.insert(key) + byProjectHead[project.path, default: [:]][head, default: []].append(server.server) + } + } + } + var next = pinned + for key in pinned where !valid.contains(key) { + next.remove(key) + let parts = key.split(separator: "::", maxSplits: 2, omittingEmptySubsequences: false) + .map(String.init) + guard parts.count == 3 else { continue } + let project = parts[0] + let head = parts[2] + let candidates = byProjectHead[project]?[head] ?? [] + if candidates.count == 1, let server = candidates.first { + next.insert("\(project)::\(server)::\(head)") + } + } + guard next != pinned else { return } + pinned = next + persist() + } + + private func persist() { UserDefaults.standard.set(pinned.sorted(), forKey: Self.defaultsKey) } } @@ -718,6 +817,7 @@ struct HeadRow: View { HStack(spacing: 0) { Button { onOpen() + SpotlightIndexer.noteOpened(identifier: rowID) if let parsed = URL(string: url) { NSWorkspace.shared.open(parsed) } @@ -748,7 +848,10 @@ struct HeadRow: View { HStack(spacing: 8) { Color.clear.frame(width: 14, height: 14) if pinned || hovering { - Button(action: onTogglePin) { + Button { + onTogglePin() + if !pinned { SpotlightIndexer.noteOpened(identifier: rowID) } + } label: { Image(systemName: pinned ? "pin.fill" : "pin") .font(.system(size: 9)) .foregroundStyle(pinned ? AnyShapeStyle(.secondary) : AnyShapeStyle(.tertiary)) @@ -928,6 +1031,7 @@ struct FilterBox: View { } .buttonStyle(.plain) .help("Clear filter") + .accessibilityLabel(Text("Clear filter")) } } .padding(.horizontal, 8) diff --git a/Sources/DevCtlApp/SpotlightIndexer.swift b/Sources/DevCtlApp/SpotlightIndexer.swift index 1fa7584..c522538 100644 --- a/Sources/DevCtlApp/SpotlightIndexer.swift +++ b/Sources/DevCtlApp/SpotlightIndexer.swift @@ -4,71 +4,90 @@ import DevCtlKit import Foundation /** Spotlight integration: every server and every head becomes a searchable item - ("candor operator" from anywhere opens the surface in the browser). The - id -> URL map is persisted at index time so activation resolves without the - daemon on the hot path. Re-indexes only when the item set changes. */ + ("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. */ enum SpotlightIndexer { - static let domain = "devctl-servers" - private static let urlMapKey = "spotlight urls" + /** 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. */ + static let activityType = "dev.quantizor.devctl.open-surface" + private static let entriesKey = "spotlight entries" private static let signatureKey = "spotlight signature" - private static let statusKey = "spotlight last sync" + /** 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 + + struct SpotlightEntry: Codable, Sendable { + let icon: String? + let keywords: [String] + let rankingHint: Int + let title: String + let url: String + } + + private static var currentActivity: NSUserActivity? static func sync(projects: [DaemonModel.ProjectGroup]) { - var urlByIdentifier: [String: String] = [:] - var entriesToIndex: [(icon: String?, identifier: String, title: String, url: String)] = [] + HeadPins.shared.reconcile(projects: projects) + let pins = HeadPins.shared + var entriesByIdentifier: [String: SpotlightEntry] = [:] + var payload: [(identifier: String, entry: SpotlightEntry)] = [] for project in projects { for server in project.servers { - var entries: [(icon: String?, identifier: String, title: String, url: String)] = [] if let url = server.url { - entries.append( - ( - icon: server.icon, - identifier: "\(project.path)::\(server.server)", - title: "\(project.name) \(server.server)", - url: url - )) + let identifier = "\(project.path)::\(server.server)" + let entry = SpotlightEntry( + icon: server.icon, + keywords: SpotlightLabel.keywords( + project: project.name, server: server.server, head: nil, url: url), + rankingHint: baseRankingHint, + title: SpotlightLabel.title( + project: project.name, server: server.server, head: nil), + url: url) + entriesByIdentifier[identifier] = entry + payload.append((identifier, entry)) } for (head, url) in server.heads ?? [:] { - entries.append( - ( - icon: server.icon, - identifier: "\(project.path)::\(server.server)::\(head)", - title: "\(project.name) \(head)", - url: url - )) - } - for entry in entries { - urlByIdentifier[entry.identifier] = entry.url - entriesToIndex.append(entry) + let identifier = "\(project.path)::\(server.server)::\(head)" + let pinned = pins.isPinned(project: project.path, server: server.server, head: head) + let entry = SpotlightEntry( + icon: server.icon, + keywords: SpotlightLabel.keywords( + project: project.name, server: server.server, head: head, url: url), + rankingHint: pinned ? pinnedRankingHint : baseRankingHint, + title: SpotlightLabel.title( + project: project.name, server: server.server, head: head), + url: url) + entriesByIdentifier[identifier] = entry + payload.append((identifier, entry)) } } } - let signature = urlByIdentifier.keys.sorted().joined(separator: "|") - + "#" + urlByIdentifier.values.sorted().joined(separator: "|") - + "#" + entriesToIndex.compactMap(\.icon).sorted().joined(separator: "|") + 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: ","))" + }.sorted().joined(separator: "|") guard signature != UserDefaults.standard.string(forKey: signatureKey) else { return } - UserDefaults.standard.set(urlByIdentifier, forKey: urlMapKey) + if let data = try? JSONCoding.encoder().encode(entriesByIdentifier) { + UserDefaults.standard.set(data, forKey: entriesKey) + } UserDefaults.standard.set(signature, forKey: signatureKey) - /** CSSearchableItem is not Sendable, so the items are BUILT inside the - callback from Sendable value tuples rather than captured across it. */ - let payload = entriesToIndex + let items = payload CSSearchableIndex.default().deleteSearchableItems(withDomainIdentifiers: [domain]) { _ in - let items = payload.map { entry -> CSSearchableItem in - let attributes = CSSearchableItemAttributeSet(contentType: .url) - attributes.contentDescription = "\(entry.url) · devctl dev server" - attributes.keywords = ["devctl", "dev server"] - attributes.thumbnailData = entry.icon.flatMap(Self.thumbnailPNG) - attributes.title = entry.title - attributes.url = URL(string: entry.url) + let built = items.map { element -> CSSearchableItem in let item = CSSearchableItem( - uniqueIdentifier: entry.identifier, + uniqueIdentifier: element.identifier, domainIdentifier: domain, - attributeSet: attributes) + attributeSet: attributeSet(for: element.entry)) item.expirationDate = .distantFuture return item } - let count = items.count - CSSearchableIndex.default().indexSearchableItems(items) { error in + let count = built.count + CSSearchableIndex.default().indexSearchableItems(built) { error in UserDefaults.standard.set( error.map { "error: \($0.localizedDescription)" } ?? "ok \(count) items \(JSONCoding.formatISO8601(Date()))", @@ -77,10 +96,59 @@ enum SpotlightIndexer { } } - /** Rasterizes a config-supplied icon (png/svg/pdf, anything NSImage reads) - to PNG at thumbnail size. */ - /** nonisolated: runs inside the index callback; offscreen NSImage drawing - has no main-thread dependence. */ + static func noteOpened(identifier: String) { + guard let entry = loadEntries()[identifier] else { return } + let item = CSSearchableItem( + uniqueIdentifier: identifier, + domainIdentifier: domain, + attributeSet: attributeSet(for: entry, lastUsed: Date())) + item.isUpdate = true + item.expirationDate = .distantFuture + CSSearchableIndex.default().indexSearchableItems([item]) { _ in } + donateActivity(identifier: identifier, entry: entry) + } + + nonisolated private static func attributeSet( + for entry: SpotlightEntry, lastUsed: Date? = nil + ) -> 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))" + attributes.containerDisplayName = "devctl" + attributes.kind = "Dev Server" + attributes.keywords = entry.keywords + attributes.rankingHint = NSNumber(value: entry.rankingHint) + attributes.thumbnailData = entry.icon.flatMap(Self.thumbnailPNG) + attributes.url = URL(string: entry.url) + if let lastUsed { attributes.lastUsedDate = lastUsed } + return attributes + } + + private static func donateActivity(identifier: String, entry: SpotlightEntry) { + let activity = NSUserActivity(activityType: activityType) + activity.title = entry.title + activity.keywords = Set(entry.keywords) + activity.userInfo = [CSSearchableItemActivityIdentifier: identifier] + activity.isEligibleForSearch = true + activity.persistentIdentifier = identifier + if let url = URL(string: entry.url) { activity.webpageURL = url } + let attributes = attributeSet(for: entry, lastUsed: Date()) + attributes.relatedUniqueIdentifier = identifier + activity.contentAttributeSet = attributes + activity.becomeCurrent() + currentActivity = activity + } + + private static func loadEntries() -> [String: SpotlightEntry] { + guard let data = UserDefaults.standard.data(forKey: entriesKey), + let map = try? JSONCoding.decoder().decode([String: SpotlightEntry].self, from: data) + else { return [:] } + return map + } + nonisolated static func thumbnailPNG(path: String) -> Data? { guard let image = NSImage(contentsOfFile: path) else { return nil } let side: CGFloat = 128 @@ -98,9 +166,7 @@ enum SpotlightIndexer { return bitmap.representation(using: .png, properties: [:]) } - /** Resolves a Spotlight activation to its URL via the persisted map. */ static func url(forIdentifier identifier: String) -> URL? { - let map = UserDefaults.standard.dictionary(forKey: urlMapKey) as? [String: String] - return map?[identifier].flatMap(URL.init(string:)) + loadEntries()[identifier].flatMap { URL(string: $0.url) } } } diff --git a/Sources/DevCtlDaemonCore/Control/ControlServer.swift b/Sources/DevCtlDaemonCore/Control/ControlServer.swift index 7ac7e3e..aebd8e5 100644 --- a/Sources/DevCtlDaemonCore/Control/ControlServer.swift +++ b/Sources/DevCtlDaemonCore/Control/ControlServer.swift @@ -71,6 +71,8 @@ public actor Router { } try await portPreCheck(target: target, supervisor: supervisor) let result = await supervisor.ensure(timeoutSeconds: request.params.timeoutSeconds) + DevCtlLog.daemon.info( + "ensure \(target.name)@\(target.project) -> \(result.server.phase.rawValue)") return try respond(id: head.id, result: result) case .serverStart: let request = try decoder.decode(WireRequest.self, from: line) @@ -154,7 +156,9 @@ public actor Router { case .serverStop: let request = try decoder.decode(WireRequest.self, from: line) let supervisor = try await resolvedSupervisor(request.params) - return try respond(id: head.id, result: ServerResult(server: await supervisor.stop())) + let stopped = await supervisor.stop() + DevCtlLog.daemon.info("stop \(request.params.name)@\(request.params.project)") + return try respond(id: head.id, result: ServerResult(server: stopped)) case .serverWait: let request = try decoder.decode(WireRequest.self, from: line) let target = ServerTargetParams(name: request.params.name, project: request.params.project) @@ -299,7 +303,13 @@ public actor Router { 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. + + Specs resolve through the merged view (devservers.json + ad-hoc registry), + the same path ensure/status use. Config-defined servers are never written + into registry.json, so a registry-only lookup would silently skip every + committed server on boot. A rename/delete with no matching spec drops the + orphaned state row instead of retrying forever. */ public func recoverAtStartup() async { for (id, persisted) in await registry.allPersistedState() { guard let separator = id.range(of: "::") else { continue } @@ -308,30 +318,81 @@ public actor Router { let leftActive = persisted.phase == .running || persisted.phase == .starting let wantsRestore = persisted.resumeOnBoot ?? false guard persisted.pid != nil || leftActive || wantsRestore else { continue } - guard let spec = await registry.spec(project: project, name: name) else { continue } - if let pid = persisted.pid.map(pid_t.init), kill(pid, 0) == 0 { - let descendants = ProcessTree.descendants(of: pid) - ProcessTree.signalTree(rootPid: pid, descendants: descendants, signal: SIGTERM) - try? await Task.sleep(for: .seconds(2)) - if kill(pid, 0) == 0 { - ProcessTree.signalTree(rootPid: pid, descendants: descendants, signal: SIGKILL) + switch await resolveSpecForRecover(project: project, name: name) { + case .missing: + DevCtlLog.daemon.info( + "recover skip \(name)@\(project): no matching spec (renamed or removed)") + try? await registry.removeState(serverID: id) + continue + case .unavailable: + DevCtlLog.daemon.info( + "recover defer \(name)@\(project): config unreadable; keeping resume intent") + continue + case .found(let spec): + if let pid = persisted.pid.map(pid_t.init), kill(pid, 0) == 0 { + let descendants = ProcessTree.descendants(of: pid) + ProcessTree.signalTree(rootPid: pid, descendants: descendants, signal: SIGTERM) + try? await Task.sleep(for: .seconds(2)) + if kill(pid, 0) == 0 { + ProcessTree.signalTree(rootPid: pid, descendants: descendants, signal: SIGKILL) + } + await events.post( + kind: .crashed, project: project, server: name, + detail: "daemon-restart: orphan pid \(pid) bounced") + } else if leftActive { + await events.post( + kind: .crashed, project: project, server: name, detail: "daemon-restart") + } + try? await registry.updateState(serverID: id) { entry in + entry.lastExit = entry.lastExit ?? LastExit(at: Date()) + entry.phase = .crashed + entry.pid = nil + } + if leftActive || wantsRestore { + DevCtlLog.daemon.info("recover start \(name)@\(project)") + let supervisor = await supervisor(project: project, spec: spec) + _ = await supervisor.start() } - await events.post( - kind: .crashed, project: project, server: name, - detail: "daemon-restart: orphan pid \(pid) bounced") - } else if leftActive { - await events.post( - kind: .crashed, project: project, server: name, detail: "daemon-restart") } - try? await registry.updateState(serverID: id) { entry in - entry.lastExit = entry.lastExit ?? LastExit(at: Date()) - entry.phase = .crashed - entry.pid = nil + } + /** Second pass: drop leftover rows for renamed/deleted servers even when + they carry no resume intent (e.g. a deliberate stop under the old + name). Only when the config is readable so a parse blip cannot wipe + state. */ + for (id, _) in await registry.allPersistedState() { + guard let separator = id.range(of: "::") else { continue } + let project = String(id[id.startIndex.. RecoverSpec { + do { + let merged = try await mergedSpecs(project: project) + if let spec = merged.specs.first(where: { $0.name == name }) { + return .found(spec) + } + return .missing + } catch { + if let spec = await registry.spec(project: project, name: name) { + return .found(spec) } + return .unavailable } } @@ -370,6 +431,8 @@ public actor Router { let status = await other.status() let active = status.phase == .running || status.phase == .starting || status.phase == .unhealthy if active, status.declaredPort == port || status.observedPort == port { + DevCtlLog.daemon.error( + "port-held \(port) by \(status.server)@\(status.project) for \(target.name)") throw WireError( code: .portHeld, hint: "run: devctl stop \(status.server) --project \(status.project)", diff --git a/Sources/DevCtlDaemonCore/Registry/Registry.swift b/Sources/DevCtlDaemonCore/Registry/Registry.swift index 983d002..0decc0b 100644 --- a/Sources/DevCtlDaemonCore/Registry/Registry.swift +++ b/Sources/DevCtlDaemonCore/Registry/Registry.swift @@ -131,6 +131,14 @@ public actor Registry { try persistState() } + /** Drop a state row whose server no longer exists in config or the registry + (rename / delete). Keeps recoverAtStartup from re-visiting ghosts. */ + public func removeState(serverID: String) throws { + guard state.servers[serverID] != nil else { return } + state.servers[serverID] = nil + try persistState() + } + private func persistRegistry() throws { try AtomicFile.write(JSONCoding.encoder().encode(registry), to: paths.registryFile) } diff --git a/Sources/DevCtlDaemonCore/Supervisor/ServerSupervisor.swift b/Sources/DevCtlDaemonCore/Supervisor/ServerSupervisor.swift index 6215f53..bf0da95 100644 --- a/Sources/DevCtlDaemonCore/Supervisor/ServerSupervisor.swift +++ b/Sources/DevCtlDaemonCore/Supervisor/ServerSupervisor.swift @@ -291,6 +291,7 @@ public actor ServerSupervisor { if consecutiveSuccesses >= healthyAfter { everHealthy = true phase = .running + DevCtlLog.supervisor.info("healthy \(spec.name)@\(projectPath)") scanObservedPort() postHealthEvent(.healthy) } diff --git a/Sources/DevCtlKit/Agent/DiscoveryStanza.swift b/Sources/DevCtlKit/Agent/DiscoveryStanza.swift new file mode 100644 index 0000000..7421c63 --- /dev/null +++ b/Sources/DevCtlKit/Agent/DiscoveryStanza.swift @@ -0,0 +1,17 @@ +import Foundation + +/** The copy-paste discovery tip `devctl hook install` prints so a project can + teach its agents about devctl by pasting one bullet into CLAUDE.md/AGENTS.md. + A pure string helper: the `devctl` CLI target has no unit tests, so the copy + lives in DevCtlKit where it can be asserted. devctl never edits those files; + the human decides whether to paste. */ +public enum DiscoveryStanza { + /** One markdown bullet. With no known server names it uses the `` + placeholder; with names it wires the first (alphabetically first, matching + the config's sorted specs) into the `ensure`/`logs` examples. The + naming/host conventions sentence is constant either way. */ + public static func render(serverNames: [String]) -> String { + let example = serverNames.first ?? "" + return "- This project is registered with devctl (devservers.json). Prefer `devctl ensure \(example)` / `devctl status` / `devctl logs \(example)` over launching the server directly. Name servers after the project (not `web`); use `.localhost` hosts, not bare `localhost`." + } +} diff --git a/Sources/DevCtlKit/Config/ProjectConfig.swift b/Sources/DevCtlKit/Config/ProjectConfig.swift index dbb2be2..6c61695 100644 --- a/Sources/DevCtlKit/Config/ProjectConfig.swift +++ b/Sources/DevCtlKit/Config/ProjectConfig.swift @@ -114,18 +114,36 @@ public enum ProjectConfigLoader { } public static func validate(config: ProjectFileConfig, project: String) -> ProjectConfigView { - let host = config.host ?? "\(defaultSlug(project: project)).localhost" + let recommendedHost = "\(defaultSlug(project: project)).localhost" + let host = config.host ?? recommendedHost var view = ProjectConfigView(host: host) var warnings: [String] = [] if config.version != 1 { warnings.append("unknown config version \(config.version); this devctl understands version 1") } + /** Bare loopback hosts collapse every project onto one browser origin, so + cookies/storage/service workers leak across projects. Warn (never fail): + an explicit bare host may be a deliberate choice for a non-browser + server. */ + if let explicit = config.host, isBareLoopback(explicit) { + warnings.append( + "host '\(explicit)' is a bare loopback address; prefer '\(recommendedHost)' so each project keeps an isolated browser origin") + } var declaredPorts: [Int: String] = [:] var specs: [ServerSpec] = [] for (name, entry) in config.servers.sorted(by: { $0.key < $1.key }) { if entry.command.isEmpty { view.errors.append("server '\(name)': command is empty") } + if let explicitHost = entry.host, isBareLoopback(explicitHost) { + warnings.append( + "server '\(name)': host '\(explicitHost)' is a bare loopback address; prefer a '\(recommendedHost)' subdomain") + } + if let explicitURL = entry.url, let urlHost = URL(string: explicitURL)?.host, + isBareLoopback(urlHost) { + warnings.append( + "server '\(name)': url '\(explicitURL)' points at a bare loopback host; prefer '\(recommendedHost)'") + } var iconPath: String? if let relative = entry.icon ?? config.icon { let absolute = (project as NSString).appendingPathComponent(relative) @@ -178,6 +196,14 @@ public enum ProjectConfigLoader { return view } + /** A host that resolves to loopback with no per-project subdomain, so it + shares one origin across every project (the isolation the `*.localhost` + signature exists to give). `.localhost` and `api.myproj.localhost` + are fine; only the bare forms trip this. */ + static func isBareLoopback(_ host: String) -> Bool { + host == "localhost" || host == "127.0.0.1" + } + public static func defaultSlug(project: String) -> String { (project as NSString).lastPathComponent .lowercased() diff --git a/Sources/DevCtlKit/DeepLink/DeepLink.swift b/Sources/DevCtlKit/DeepLink/DeepLink.swift new file mode 100644 index 0000000..186694a --- /dev/null +++ b/Sources/DevCtlKit/DeepLink/DeepLink.swift @@ -0,0 +1,209 @@ +import Foundation + +/** The action a `devctl://` URL requests. The raw value IS the URL authority + (`devctl:///…`), so parsing and serializing round-trip through it. */ +public enum DeepLinkVerb: String, Sendable, Equatable, CaseIterable, Codable { + case ensure + case open + case stop + case why +} + +/** A parsed `devctl://` deep link. `head` is meaningful only for `open` (which + surface of a multi-headed server to open); the parser rejects it on any other + verb, so a `DeepLink` value is always internally consistent. */ +public struct DeepLink: Sendable, Equatable, Codable { + public var head: String? + public var projectSlug: String + public var server: String + public var verb: DeepLinkVerb + + public init(verb: DeepLinkVerb, projectSlug: String, server: String, head: String? = nil) { + self.head = head + self.projectSlug = projectSlug + self.server = server + self.verb = verb + } + + // MARK: - Parsing + + public static func parse(_ string: String) -> Result { + guard let components = URLComponents(string: string) else { + return .failure(malformed(string)) + } + return parse(components: components) + } + + public static func parse(url: URL) -> Result { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return .failure(malformed(url.absoluteString)) + } + return parse(components: components) + } + + private static func parse(components: URLComponents) -> Result { + guard components.scheme?.lowercased() == scheme else { + return .failure( + WireError( + code: .usage, + hint: "use a devctl://// URL", + message: "not a devctl:// URL (scheme was '\(components.scheme ?? "")')")) + } + guard let host = components.host, !host.isEmpty else { + return .failure( + WireError( + code: .usage, + hint: "expected devctl://open//", + message: "devctl:// URL is missing its verb")) + } + guard let verb = DeepLinkVerb(rawValue: host.lowercased()) else { + return .failure( + WireError( + code: .usage, + hint: "verbs are: \(DeepLinkVerb.allCases.map(\.rawValue).sorted().joined(separator: ", "))", + message: "unknown devctl verb '\(host)'")) + } + + let segments = (components.percentEncodedPath) + .split(separator: "/", omittingEmptySubsequences: true) + .map { String($0).removingPercentEncoding ?? String($0) } + + let slug: String + let server: String + var head: String? + if segments.isEmpty { + // Query-form alias: devctl://?project=&server=&head= + let items = components.queryItems ?? [] + func value(_ name: String) -> String? { + items.first { $0.name == name }?.value + } + guard let project = value("project"), let name = value("server") else { + return .failure(missingSegments(verb: verb)) + } + slug = project + server = name + head = value("head") + } else { + guard segments.count >= 2 else { + return .failure(missingSegments(verb: verb)) + } + guard segments.count <= 3 else { + return .failure( + WireError( + code: .usage, + hint: "shape is devctl://\(verb.rawValue)//[/]", + message: "too many path segments in devctl:// URL")) + } + slug = segments[0] + server = segments[1] + head = segments.count == 3 ? segments[2] : nil + } + + // An empty head string (query alias `head=`) means "no head". + if head?.isEmpty == true { head = nil } + + if let slugError = validateSlug(slug) { + return .failure(slugError) + } + guard !server.isEmpty else { + return .failure( + WireError(code: .usage, message: "devctl:// URL has an empty server name")) + } + if head != nil, verb != .open { + return .failure( + WireError( + code: .usage, + hint: "only devctl://open/… takes a head", + message: "verb '\(verb.rawValue)' does not take a head")) + } + + return .success(DeepLink(verb: verb, projectSlug: slug, server: server, head: head)) + } + + // MARK: - Serialization + + /** The canonical path-form URL string. Round-trips: parsing the result yields + an equal `DeepLink`. */ + public func urlString() -> String { + var parts = [projectSlug, server] + if let head, verb == .open { parts.append(head) } + let path = parts.map(Self.encodeSegment).joined(separator: "/") + return "\(Self.scheme)://\(verb.rawValue)/\(path)" + } + + // MARK: - Project resolution + + /** Maps a project slug to a concrete project path from a candidate list + (each candidate is an absolute project directory). Matches the directory's + last path component case-insensitively: a unique hit resolves, several hits + are ambiguous (`usage`, candidates named), none is `not-found`. */ + public static func resolveProject( + slug: String, against paths: [String] + ) -> Result { + if let slugError = validateSlug(slug) { + return .failure(slugError) + } + let wanted = slug.lowercased() + var matches: [String] = [] + var seen: Set = [] + for path in paths { + let base = (path as NSString).lastPathComponent.lowercased() + if base == wanted, seen.insert(path).inserted { + matches.append(path) + } + } + switch matches.count { + case 0: + return .failure( + WireError( + code: .notFound, + hint: "run: devctl status --all --json", + message: "no registered project matches '\(slug)'")) + case 1: + return .success(matches[0]) + default: + let candidates = matches.sorted().joined(separator: ", ") + return .failure( + WireError( + code: .usage, + hint: "several projects share that name; open by full path", + message: "project slug '\(slug)' is ambiguous: \(candidates)")) + } + } + + // MARK: - Internals + + static let scheme = "devctl" + + private static func validateSlug(_ slug: String) -> WireError? { + if slug.isEmpty { + return WireError(code: .usage, message: "devctl:// URL has an empty project slug") + } + if slug.contains("/") || slug.contains("..") { + return WireError( + code: .usage, + hint: "a project slug is a single directory name", + message: "invalid project slug '\(slug)' (path separators and '..' are not allowed)") + } + return nil + } + + private static func encodeSegment(_ segment: String) -> String { + segment.addingPercentEncoding(withAllowedCharacters: segmentAllowed) ?? segment + } + + /** URL path-segment characters minus `/`, so an encoded segment never splits. */ + private static let segmentAllowed = CharacterSet.urlPathAllowed + .subtracting(CharacterSet(charactersIn: "/")) + + private static func malformed(_ raw: String) -> WireError { + WireError(code: .usage, message: "could not parse a URL from '\(raw)'") + } + + private static func missingSegments(verb: DeepLinkVerb) -> WireError { + WireError( + code: .usage, + hint: "shape is devctl://\(verb.rawValue)//", + message: "devctl:// URL is missing its project and/or server") + } +} diff --git a/Sources/DevCtlKit/DeepLink/DeepLinkNotificationAction.swift b/Sources/DevCtlKit/DeepLink/DeepLinkNotificationAction.swift new file mode 100644 index 0000000..ae130bb --- /dev/null +++ b/Sources/DevCtlKit/DeepLink/DeepLinkNotificationAction.swift @@ -0,0 +1,25 @@ +import Foundation + +/** The user-notification action buttons devctl attaches to a server alert, and + the mapping from a tapped action back into a `DeepLink`. Kept beside `DeepLink` + (and free of AppKit/UserNotifications) so the mapping is unit-testable without + a running app. The raw value is the `UNNotificationAction` identifier. */ +public enum DeepLinkNotificationAction: String, Sendable, Equatable, CaseIterable { + case open = "dev.quantizor.devctl.notification.open" + case why = "dev.quantizor.devctl.notification.why" + + /** 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. */ + public static func link( + actionId: String, projectSlug: String, server: String, head: String? = nil + ) -> DeepLink? { + guard let action = DeepLinkNotificationAction(rawValue: actionId) else { return nil } + switch action { + case .open: + return DeepLink(verb: .open, projectSlug: projectSlug, server: server, head: head) + case .why: + return DeepLink(verb: .why, projectSlug: projectSlug, server: server) + } + } +} diff --git a/Sources/DevCtlKit/DeepLink/DeepLinkRunner.swift b/Sources/DevCtlKit/DeepLink/DeepLinkRunner.swift new file mode 100644 index 0000000..3b8030c --- /dev/null +++ b/Sources/DevCtlKit/DeepLink/DeepLinkRunner.swift @@ -0,0 +1,174 @@ +import Foundation + +/** Side effects a resolved deep link needs but that live outside the daemon + protocol: opening a browser, writing the pasteboard, posting a notification. + The app supplies AppKit/UserNotifications implementations; tests supply a + recorder. */ +public protocol DeepLinkEffects: Sendable { + func copyToPasteboard(_ text: String) async + func notify(title: String, body: String) async + func openBrowser(_ url: URL) async +} + +/** The outcome of running a deep link: the verb that ran, the project path the + slug resolved to, and a short human detail (the resulting phase, the opened + URL, or the diagnosed root cause). */ +public struct DeepLinkRunResult: Sendable, Equatable, Codable { + public var detail: String? + public var projectPath: String + public var verb: DeepLinkVerb + + public init(verb: DeepLinkVerb, projectPath: String, detail: String? = nil) { + self.detail = detail + self.projectPath = projectPath + self.verb = verb + } +} + +/** The daemon calls the runner needs, one per verb. `DaemonClient` conforms via + its generic `request`; a test conforms a mock. Kept internal: the public + `DeepLinkRunner.init` still takes a concrete `DaemonClient`, and the seam only + exists so the runner's branching is testable without a live socket. */ +protocol DeepLinkDaemon: Sendable { + func ensure(_ params: EnsureParams) async throws -> EnsureResult + func statusList(project: String, name: String?) async throws -> ServerListResult + func stop(_ target: ServerTargetParams) async throws -> ServerResult + func why(_ target: ServerTargetParams) async throws -> WhyResult +} + +extension DaemonClient: DeepLinkDaemon { + func ensure(_ params: EnsureParams) async throws -> EnsureResult { + try request(.serverEnsure, params: params, expecting: EnsureResult.self) + } + + func statusList(project: String, name: String?) async throws -> ServerListResult { + try request( + .serverStatus, params: ProjectParams(name: name, project: project), + expecting: ServerListResult.self) + } + + func stop(_ target: ServerTargetParams) async throws -> ServerResult { + try request(.serverStop, params: target, expecting: ServerResult.self) + } + + func why(_ target: ServerTargetParams) async throws -> WhyResult { + try request(.serverWhy, params: target, expecting: WhyResult.self) + } +} + +/** Resolves a `DeepLink`'s slug to a project via the daemon, dispatches the verb, + and runs any side effect (open/copy/notify). Pure branching over the daemon + protocol and the effects; the only shared state is the log. */ +public struct DeepLinkRunner: Sendable { + private let daemon: any DeepLinkDaemon + private let effects: any DeepLinkEffects + + public init(client: DaemonClient, effects: any DeepLinkEffects) { + self.daemon = client + self.effects = effects + } + + init(daemon: any DeepLinkDaemon, effects: any DeepLinkEffects) { + self.daemon = daemon + self.effects = effects + } + + public func run(_ link: DeepLink) async throws -> DeepLinkRunResult { + DevCtlLog.deeplink.info("dispatch \(link.verb.rawValue) \(link.server)@\(link.projectSlug)") + let project = try await resolveProjectPath(link.projectSlug) + switch link.verb { + case .ensure: + let result = try await daemon.ensure( + EnsureParams(name: link.server, project: project, timeoutSeconds: 60)) + let detail = result.reason.map { "fell short: \($0.rawValue)" } ?? result.server.phase.rawValue + return DeepLinkRunResult(verb: .ensure, projectPath: project, detail: detail) + case .stop: + let result = try await daemon.stop( + ServerTargetParams(name: link.server, project: project)) + return DeepLinkRunResult( + verb: .stop, projectPath: project, detail: result.server.phase.rawValue) + case .why: + let result = try await daemon.why( + ServerTargetParams(name: link.server, project: project)) + await effects.copyToPasteboard(Self.whySummary(result, server: link.server)) + await effects.notify( + title: "Why \(link.server)?", + body: result.rootCause ?? "Diagnosis copied to the clipboard.") + return DeepLinkRunResult(verb: .why, projectPath: project, detail: result.rootCause) + case .open: + let list = try await daemon.statusList(project: project, name: link.server) + guard let server = list.servers.first else { + throw WireError( + code: .notFound, + hint: "run: devctl status --project \(project) --json", + message: "no server named '\(link.server)' is registered for \(project)") + } + let target = try resolveOpenURL(server: server, head: link.head) + await effects.openBrowser(target) + return DeepLinkRunResult( + verb: .open, projectPath: project, detail: target.absoluteString) + } + } + + private func resolveProjectPath(_ slug: String) async throws -> String { + let list = try await daemon.statusList(project: "", name: nil) + var paths: [String] = [] + var seen: Set = [] + for status in list.servers where seen.insert(status.project).inserted { + paths.append(status.project) + } + switch DeepLink.resolveProject(slug: slug, against: paths) { + case .success(let path): + return path + case .failure(let error): + DevCtlLog.deeplink.error("reject \(slug): \(error.message)") + throw error + } + } + + private func resolveOpenURL(server: ServerStatus, head: String?) throws -> URL { + let raw: String + if let head { + guard let headURL = server.heads?[head] else { + let known = (server.heads ?? [:]).keys.sorted().joined(separator: ", ") + let detail = known.isEmpty + ? "this server declares no heads" + : "known heads: \(known)" + throw WireError( + code: .notFound, + hint: "run: devctl status \(server.server) --json", + message: "no head named '\(head)' on \(server.server); \(detail)") + } + raw = headURL + } else { + guard let url = server.url else { + throw WireError( + code: .notFound, + hint: "add a port, url, or heads to \(server.server) in devservers.json", + message: "\(server.server) has no URL to open") + } + raw = url + } + guard let url = URL(string: raw) else { + throw WireError( + code: .internalError, message: "server '\(server.server)' has an unparseable URL '\(raw)'") + } + return url + } + + /** A copy-to-clipboard rendering of the why chain: root cause first, then each + finding with its evidence indented. */ + static func whySummary(_ result: WhyResult, server: String) -> String { + var lines: [String] = ["devctl why \(server)"] + if let root = result.rootCause { + lines.append("root cause: \(root)") + } + for finding in result.findings { + lines.append("\(finding.server) [\(finding.phase.rawValue)]: \(finding.summary)") + for evidence in finding.evidence { + lines.append(" \(evidence)") + } + } + return lines.joined(separator: "\n") + } +} diff --git a/Sources/DevCtlKit/Log/DevCtlLog.swift b/Sources/DevCtlKit/Log/DevCtlLog.swift new file mode 100644 index 0000000..a599e1a --- /dev/null +++ b/Sources/DevCtlKit/Log/DevCtlLog.swift @@ -0,0 +1,143 @@ +import Foundation +import os + +/** The unified-logging category a message belongs to; the string is the literal + OSLog category, so `log show --predicate 'subsystem == "dev.quantizor.devctl"'` + can filter by it. */ +public enum DevCtlLogCategory: String, Sendable, Equatable, CaseIterable { + case app + case daemon + case deeplink + case health + case supervisor +} + +/** Severity, mapped onto the matching `os.Logger` level by the OSLog backend. */ +public enum DevCtlLogLevel: String, Sendable, Equatable, CaseIterable { + case debug + case error + case info +} + +/** The sink every log call funnels through. The default is `OSLogBackend`; tests + swap in `RecordingBackend` so an assertion can read back what was emitted. */ +public protocol DevCtlLogBackend: Sendable { + func log(category: DevCtlLogCategory, level: DevCtlLogLevel, message: String) +} + +/** Production backend: one `os.Logger` per (subsystem, category), levels mapped + straight through. Messages are logged `.public` because devctl logs its own + server names and phases, never resident or third-party data. */ +public struct OSLogBackend: DevCtlLogBackend { + public init() {} + + public func log(category: DevCtlLogCategory, level: DevCtlLogLevel, message: String) { + let logger = Logger(subsystem: DevCtlLog.subsystem, category: category.rawValue) + switch level { + case .debug: logger.debug("\(message, privacy: .public)") + case .error: logger.error("\(message, privacy: .public)") + case .info: logger.info("\(message, privacy: .public)") + } + } +} + +/** Test backend: a thread-safe append-only record of every emitted message. + `install`/`reset` bracket a test; read `entries` (or `messages`) to assert. */ +public final class RecordingBackend: DevCtlLogBackend { + public struct Entry: Sendable, Equatable { + public var category: DevCtlLogCategory + public var level: DevCtlLogLevel + public var message: String + + public init(category: DevCtlLogCategory, level: DevCtlLogLevel, message: String) { + self.category = category + self.level = level + self.message = message + } + } + + private let state = OSAllocatedUnfairLock(initialState: [Entry]()) + + public init() {} + + public var entries: [Entry] { + state.withLock { $0 } + } + + public var messages: [String] { + state.withLock { $0.map(\.message) } + } + + public func log(category: DevCtlLogCategory, level: DevCtlLogLevel, message: String) { + state.withLock { $0.append(Entry(category: category, level: level, message: message)) } + } + + public func reset() { + state.withLock { $0.removeAll() } + } + + /** Swaps this recorder in as the active backend and returns it, so a test can + `let log = RecordingBackend.install()` in one line. */ + @discardableResult + public static func install() -> RecordingBackend { + let backend = RecordingBackend() + DevCtlLog.backend = backend + return backend + } +} + +/** The logging front door. Call the ergonomic per-category members + (`DevCtlLog.deeplink.info("…")`) or the category-parameterized statics; both + reach the swappable `backend`. */ +public enum DevCtlLog { + public static let subsystem = "dev.quantizor.devctl" + + /** A category bound to the front door; the object `DevCtlLog.deeplink` is. */ + public struct CategoryLogger: Sendable { + public let category: DevCtlLogCategory + + public func debug(_ message: @autoclosure () -> String) { + DevCtlLog.emit(category: category, level: .debug, message: message()) + } + + public func error(_ message: @autoclosure () -> String) { + DevCtlLog.emit(category: category, level: .error, message: message()) + } + + public func info(_ message: @autoclosure () -> String) { + DevCtlLog.emit(category: category, level: .info, message: message()) + } + } + + public static let app = CategoryLogger(category: .app) + public static let daemon = CategoryLogger(category: .daemon) + public static let deeplink = CategoryLogger(category: .deeplink) + public static let health = CategoryLogger(category: .health) + public static let supervisor = CategoryLogger(category: .supervisor) + + /** The active backend, guarded by a lock so a test's swap and a concurrent + log never race. */ + public static var backend: any DevCtlLogBackend { + get { backendLock.withLock { $0 } } + set { backendLock.withLock { $0 = newValue } } + } + + public static func debug(_ category: DevCtlLogCategory, _ message: String) { + emit(category: category, level: .debug, message: message) + } + + public static func error(_ category: DevCtlLogCategory, _ message: String) { + emit(category: category, level: .error, message: message) + } + + public static func info(_ category: DevCtlLogCategory, _ message: String) { + emit(category: category, level: .info, message: message) + } + + private static let backendLock = OSAllocatedUnfairLock( + initialState: OSLogBackend()) + + private static func emit(category: DevCtlLogCategory, level: DevCtlLogLevel, message: String) { + backend.log(category: category, level: level, message: message) + } +} diff --git a/Sources/DevCtlKit/Spotlight/SpotlightLabel.swift b/Sources/DevCtlKit/Spotlight/SpotlightLabel.swift new file mode 100644 index 0000000..4456385 --- /dev/null +++ b/Sources/DevCtlKit/Spotlight/SpotlightLabel.swift @@ -0,0 +1,47 @@ +import Foundation + +/** Pure labeling for Spotlight (and anything else that needs the same words). + Titles lead with the project / head a person types so Spotlight's prefix + ranking works; `devctl` lives in the subtitle so the rows stay branded + without burying the match. */ +public enum SpotlightLabel { + /** Human title shown in Spotlight. Server name is omitted when it equals + the project leaf (the common `candor`/`candor` case). */ + public static func title(project: String, server: String, head: String?) -> String { + if let head { + return "\(project) · \(head)" + } + if server == project { + return project + } + return "\(project) · \(server)" + } + + /** Subtitle: brand first, then the URL. */ + public static func subtitle(url: String) -> String { + "devctl · \(url)" + } + + /** Keyword tokens: project, server, head, distinctive host/path parts, plus + the standing "devctl" / "dev server" anchors so a typed "devctl candor" + still lands. */ + public static func keywords(project: String, server: String, head: String?, url: String) + -> [String] + { + var tokens: Set = ["devctl", "dev server"] + tokens.insert(project) + tokens.insert(server) + if let head { tokens.insert(head) } + if let parsed = URL(string: url) { + if let host = parsed.host { + for part in host.split(separator: ".") where part != "localhost" && !part.isEmpty { + tokens.insert(String(part)) + } + } + for part in parsed.path.split(separator: "/") where !part.isEmpty { + tokens.insert(String(part)) + } + } + return tokens.sorted() + } +} diff --git a/Sources/devctl/CLI.swift b/Sources/devctl/CLI.swift index fee22aa..4961c5e 100644 --- a/Sources/devctl/CLI.swift +++ b/Sources/devctl/CLI.swift @@ -13,9 +13,9 @@ struct DevCtl: AsyncParsableCommand { version: DevCtlVersion.version, subcommands: [ ConfigCommand.self, Context.self, Doctor.self, Down.self, Ensure.self, Events.self, - HookCommand.self, Logs.self, Mark.self, Open.self, Register.self, Start.self, + HookCommand.self, Link.self, Logs.self, Mark.self, Open.self, Register.self, Start.self, Lock.self, Statusline.self, Status.self, Stop.self, Switch.self, Trust.self, Unregister.self, Up.self, - Wait.self, Why.self, DaemonCommand.self, + Wait.self, Why.self, XURL.self, DaemonCommand.self, ] ) } @@ -316,7 +316,7 @@ struct Status: AsyncParsableCommand { } CLIRunner.emit(result, json: global.json) { list in if list.servers.isEmpty { - return "no servers registered for this project (hint: devctl register --name web --cmd …)" + return "no servers registered for this project (hint: devctl register --name myproj --cmd …)" } return list.servers.map(CLIRunner.describe).joined(separator: "\n") } @@ -644,8 +644,18 @@ struct HookInstall: AsyncParsableCommand { let summary = try adapter.install(devctlPath: devctlPath) var output = summary if statusline { - output += "\n\nStatusline: pipe your statusline script through `devctl statusline` to append server presence, e.g.\n devctl statusline <<< \"$STDIN_JSON\" -> web:3000 ok · api crashed" + output += "\n\nStatusline: pipe your statusline script through `devctl statusline` to append server presence, e.g.\n devctl statusline <<< \"$STDIN_JSON\" -> myproj:3000 ok · api crashed" } + /** The discovery tip is printed, never appended to CLAUDE.md/AGENTS.md: + devctl does not edit a project's files. Server names come from the + nearest devservers.json (empty when there is none yet). */ + let serverNames: [String] + if let view = try? ProjectConfigLoader.load(project: global.resolvedProject()) { + serverNames = view.specs.map(\.name) + } else { + serverNames = [] + } + output += "\n\nDiscovery tip: paste this bullet into the project's CLAUDE.md/AGENTS.md so agents find devctl on their own (devctl never edits those files):\n\(DiscoveryStanza.render(serverNames: serverNames))" CLIRunner.emit(WireEmpty(), json: global.json) { _ in output } } catch { CLIRunner.fail( @@ -856,6 +866,102 @@ struct Open: AsyncParsableCommand { } } +/** Print a `devctl://` URL for the cwd project (Raycast snippets, docs, smoke). */ +struct Link: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Print a devctl:// URL for a verb and server in this project.") + + @Argument(help: "Verb: open, ensure, stop, or why.") + var verb: String + + @Argument(help: "Server name.") + var name: String + + @Argument(help: "Head name (open only).") + var head: String? + + @OptionGroup var global: GlobalOptions + + func run() async throws { + guard let deepVerb = DeepLinkVerb(rawValue: verb.lowercased()) else { + CLIRunner.fail( + WireError( + code: .usage, + hint: "verbs: \(DeepLinkVerb.allCases.map(\.rawValue).sorted().joined(separator: ", "))", + message: "unknown verb '\(verb)'"), + json: global.json) + } + if head != nil, deepVerb != .open { + CLIRunner.fail( + WireError( + code: .usage, message: "a head is only valid with verb open"), + json: global.json) + } + let project = global.resolvedProject() + let slug = (project as NSString).lastPathComponent + let link = DeepLink(verb: deepVerb, projectSlug: slug, server: name, head: head) + struct LinkResult: Codable { + var url: String + } + let url = link.urlString() + CLIRunner.emit(LinkResult(url: url), json: global.json) { $0.url } + } +} + +/** Smoke/CI entry: run a `devctl://` URL through DeepLinkRunner without Launch + Services or the menu bar app. Hidden from `--help`. */ +struct XURL: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "x-url", + abstract: "Dispatch a devctl:// URL via the daemon (smoke / agents).", + shouldDisplay: false) + + @Argument(help: "A devctl:// URL.") + var url: String + + @OptionGroup var global: GlobalOptions + + func run() async throws { + let link: DeepLink + switch DeepLink.parse(url) { + case .failure(let error): + CLIRunner.fail(error, json: global.json) + case .success(let parsed): + link = parsed + } + let result = await CLIRunner.run(json: global.json, bootstrap: !global.noBootstrap) { client in + try await DeepLinkRunner(client: client, effects: CLIDeepLinkEffects()).run(link) + } + CLIRunner.emit(result, json: global.json) { r in + var line = "\(r.verb.rawValue) \(r.projectPath)" + if let detail = r.detail { line += ": \(detail)" } + return line + } + } +} + +/** CLI-side effects for x-url: open(1), pbcopy, stderr notify (no AppKit). */ +struct CLIDeepLinkEffects: DeepLinkEffects { + func copyToPasteboard(_ text: String) async { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/pbcopy") + let pipe = Pipe() + process.standardInput = pipe + try? process.run() + pipe.fileHandleForWriting.write(Data(text.utf8)) + try? pipe.fileHandleForWriting.close() + process.waitUntilExit() + } + + func notify(title: String, body: String) async { + FileHandle.standardError.write(Data("devctl: \(title): \(body)\n".utf8)) + } + + func openBrowser(_ url: URL) async { + _ = LaunchdAdmin.shell("/usr/bin/open", [url.absoluteString]) + } +} + struct ConfigCommand: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "config", @@ -1044,13 +1150,16 @@ struct DaemonInstall: AsyncParsableCommand { message: "cannot find a devctld binary next to devctl"), json: global.json) } + let restored: [(project: String, name: String)] do { - try await LaunchdAdmin.install(daemonBinary: binary, paths: DevCtlPaths()) + restored = try await LaunchdAdmin.install(daemonBinary: binary, paths: DevCtlPaths()) } catch let error as WireError { CLIRunner.fail(error, json: global.json) } CLIRunner.emit(WireEmpty(), json: global.json) { _ in - "devctld installed and running (\(LaunchdAdmin.label))" + restored.isEmpty + ? "devctld installed and running (\(LaunchdAdmin.label))" + : "devctld installed; re-ensured \(restored.map(\.name).joined(separator: ", "))" } } } diff --git a/Sources/devctl/LaunchdAdmin.swift b/Sources/devctl/LaunchdAdmin.swift index 8bac502..820a0fe 100644 --- a/Sources/devctl/LaunchdAdmin.swift +++ b/Sources/devctl/LaunchdAdmin.swift @@ -19,7 +19,12 @@ enum LaunchdAdmin { return FileManager.default.isExecutableFile(atPath: sibling.path) ? sibling : nil } - static func install(daemonBinary: URL, paths: DevCtlPaths) async throws { + /** 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") @@ -32,9 +37,10 @@ enum LaunchdAdmin { 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. */ - let client = DaemonClient(socketPath: paths.socketPath) _ = 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) @@ -56,6 +62,8 @@ enum LaunchdAdmin { 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 { @@ -88,13 +96,7 @@ enum LaunchdAdmin { come back" is the restart contract. */ static func restart(paths: DevCtlPaths) async throws -> [(project: String, name: String)] { let client = DaemonClient(socketPath: paths.socketPath) - var runningServers: [(project: String, name: String)] = [] - if let all = try? await client.request( - .serverStatus, params: ProjectParams(project: ""), expecting: ServerListResult.self) { - runningServers = all.servers - .filter { $0.phase == .running || $0.phase == .starting || $0.phase == .unhealthy } - .map { (project: $0.project, name: $0.server) } - } + 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 { @@ -104,14 +106,29 @@ enum LaunchdAdmin { 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 runningServers { + for server in servers { _ = try? await fresh.request( .serverEnsure, params: EnsureParams(name: server.name, project: server.project, timeoutSeconds: 60), expecting: EnsureResult.self) } - return runningServers } static func launchdState() -> String { diff --git a/Tests/DevCtlDaemonCoreTests/RecoverAtStartupTests.swift b/Tests/DevCtlDaemonCoreTests/RecoverAtStartupTests.swift new file mode 100644 index 0000000..2d614b7 --- /dev/null +++ b/Tests/DevCtlDaemonCoreTests/RecoverAtStartupTests.swift @@ -0,0 +1,168 @@ +import DevCtlKit +import Foundation +import Testing + +@testable import DevCtlDaemonCore + +private struct RecoverEnv { + let paths: DevCtlPaths + let projectPath: String +} + +private func makeRecoverEnv() throws -> RecoverEnv { + let base = FileManager.default.temporaryDirectory.appending(path: "devctl-recover-\(UUID().uuidString)") + let project = base.appending(path: "proj") + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + return RecoverEnv( + paths: DevCtlPaths(dataDir: base.appending(path: "data"), logsDir: base.appending(path: "logs")), + projectPath: project.path) +} + +private func writeDevservers(project: String, serversJSON: String) throws { + let body = """ + { + "servers": \(serversJSON), + "version": 1 + } + """ + try Data(body.utf8).write( + to: URL(fileURLWithPath: project).appending(path: "devservers.json")) +} + +private func statusList(router: Router, project: String) async throws -> [ServerStatus] { + let line = try NDJSON.encodeLine( + WireRequest( + id: "status", method: WireMethod.serverStatus.rawValue, + params: ProjectParams(project: project))) + 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 WireError(code: .internalError, message: response.error?.message ?? "status failed") + } + return result.servers +} + +private func stopServer(router: Router, project: String, name: String) async { + let line = try? NDJSON.encodeLine( + WireRequest( + id: "stop", method: WireMethod.serverStop.rawValue, + params: ServerTargetParams(name: name, project: project))) + guard let line else { return } + _ = await router.handle(line: line) +} + +@Suite struct RecoverAtStartupTests { + /** Config-defined servers live only in devservers.json (registry.servers is + empty for them). Boot restore must still find the spec and bring them up. */ + @Test func restoresConfigDefinedServerWithResumeIntent() async throws { + let env = try makeRecoverEnv() + try writeDevservers( + project: env.projectPath, + serversJSON: """ + { + "web": { + "command": ["/bin/sh", "-c", "sleep 30"] + } + } + """) + let registry = Registry(paths: env.paths) + try await registry.setTrusted(project: env.projectPath) + let id = serverID(project: env.projectPath, name: "web") + try await registry.updateState(serverID: id) { entry in + entry.phase = .stopped + entry.resumeOnBoot = true + entry.pid = nil + } + #expect(await registry.spec(project: env.projectPath, name: "web") == nil) + let router = Router(launcher: SubprocessLauncher(), paths: env.paths, registry: registry) + await router.recoverAtStartup() + let web = try await statusList(router: router, project: env.projectPath) + .first { $0.server == "web" } + #expect(web != nil) + #expect(web?.phase == .starting || web?.phase == .running) + #expect(web?.pid != nil) + await stopServer(router: router, project: env.projectPath, name: "web") + } + + /** A rename leaves resume intent under the old name. Recover must drop the + orphan row, not resurrect a ghost. */ + @Test func dropsOrphanStateWhenSpecMissing() async throws { + let env = try makeRecoverEnv() + try writeDevservers( + project: env.projectPath, + serversJSON: """ + { + "candor": { + "command": ["/bin/sh", "-c", "sleep 30"] + } + } + """) + let registry = Registry(paths: env.paths) + try await registry.setTrusted(project: env.projectPath) + let staleID = serverID(project: env.projectPath, name: "dev") + try await registry.updateState(serverID: staleID) { entry in + entry.phase = .stopped + entry.resumeOnBoot = true + } + let router = Router(launcher: SubprocessLauncher(), paths: env.paths, registry: registry) + await router.recoverAtStartup() + #expect(await registry.persistedState(serverID: staleID) == nil) + let statuses = try await statusList(router: router, project: env.projectPath) + #expect(statuses.contains { $0.server == "dev" } == false) + #expect(statuses.allSatisfy { $0.phase == .stopped }) + } + + /** A stopped row under a deleted name (no resume intent) is still pruned. */ + @Test func prunesStoppedOrphanWithoutResumeIntent() async throws { + let env = try makeRecoverEnv() + try writeDevservers( + project: env.projectPath, + serversJSON: """ + { + "web": { + "command": ["/bin/sh", "-c", "sleep 30"] + } + } + """) + let registry = Registry(paths: env.paths) + try await registry.setTrusted(project: env.projectPath) + let staleID = serverID(project: env.projectPath, name: "old") + try await registry.updateState(serverID: staleID) { entry in + entry.phase = .stopped + entry.resumeOnBoot = nil + } + let router = Router(launcher: SubprocessLauncher(), paths: env.paths, registry: registry) + await router.recoverAtStartup() + #expect(await registry.persistedState(serverID: staleID) == nil) + } + + /** Pre-feature state.json may lack resumeOnBoot. A phase left running still + restores (daemon-crash case), using the config spec. */ + @Test func restoresLeftActivePhaseWithoutResumeFlag() async throws { + let env = try makeRecoverEnv() + try writeDevservers( + project: env.projectPath, + serversJSON: """ + { + "api": { + "command": ["/bin/sh", "-c", "sleep 30"] + } + } + """) + let registry = Registry(paths: env.paths) + try await registry.setTrusted(project: env.projectPath) + let id = serverID(project: env.projectPath, name: "api") + try await registry.updateState(serverID: id) { entry in + entry.phase = .running + entry.resumeOnBoot = nil + entry.pid = nil + } + let router = Router(launcher: SubprocessLauncher(), paths: env.paths, registry: registry) + await router.recoverAtStartup() + let api = try await statusList(router: router, project: env.projectPath) + .first { $0.server == "api" } + #expect(api?.phase == .starting || api?.phase == .running) + #expect(api?.pid != nil) + await stopServer(router: router, project: env.projectPath, name: "api") + } +} diff --git a/Tests/DevCtlKitTests/DeepLinkNotificationActionTests.swift b/Tests/DevCtlKitTests/DeepLinkNotificationActionTests.swift new file mode 100644 index 0000000..9c3b0fe --- /dev/null +++ b/Tests/DevCtlKitTests/DeepLinkNotificationActionTests.swift @@ -0,0 +1,32 @@ +import Testing + +@testable import DevCtlKit + +@Suite struct DeepLinkNotificationActionTests { + @Test func openActionMapsToOpenLinkWithHead() { + let link = DeepLinkNotificationAction.link( + actionId: DeepLinkNotificationAction.open.rawValue, + projectSlug: "candor", server: "cms", head: "wren-hollow") + #expect(link == DeepLink(verb: .open, projectSlug: "candor", server: "cms", head: "wren-hollow")) + } + + @Test func openActionWithoutHead() { + let link = DeepLinkNotificationAction.link( + actionId: DeepLinkNotificationAction.open.rawValue, projectSlug: "candor", server: "cms") + #expect(link == DeepLink(verb: .open, projectSlug: "candor", server: "cms")) + } + + @Test func whyActionMapsToWhyAndDropsHead() { + let link = DeepLinkNotificationAction.link( + actionId: DeepLinkNotificationAction.why.rawValue, + projectSlug: "candor", server: "cms", head: "ignored") + #expect(link == DeepLink(verb: .why, projectSlug: "candor", server: "cms")) + } + + @Test func unknownActionIsNil() { + #expect( + DeepLinkNotificationAction.link( + actionId: "com.apple.UNNotificationDefaultActionIdentifier", + projectSlug: "candor", server: "cms") == nil) + } +} diff --git a/Tests/DevCtlKitTests/DeepLinkRunnerTests.swift b/Tests/DevCtlKitTests/DeepLinkRunnerTests.swift new file mode 100644 index 0000000..8afa696 --- /dev/null +++ b/Tests/DevCtlKitTests/DeepLinkRunnerTests.swift @@ -0,0 +1,163 @@ +import Foundation +import Testing + +@testable import DevCtlKit + +/** A daemon stand-in: fixed project list plus a per-server status, and canned + ensure/stop/why results. Records nothing beyond what the runner returns. */ +private struct MockDaemon: DeepLinkDaemon { + var projectPaths: [String] = ["/Users/x/code/candor"] + var status: ServerStatus? + var ensureResult: EnsureResult? + var stopResult: ServerResult? + var whyResult: WhyResult? + + func statusList(project: String, name: String?) async throws -> ServerListResult { + if project.isEmpty { + // Machine-wide slug resolution: one status per project path. + return ServerListResult( + servers: projectPaths.map { + ServerStatus(logPath: "", phase: .running, project: $0, server: "any") + }) + } + guard let status else { return ServerListResult(servers: []) } + return ServerListResult(servers: [status]) + } + + func ensure(_ params: EnsureParams) async throws -> EnsureResult { + ensureResult ?? EnsureResult( + server: ServerStatus(logPath: "", phase: .running, project: params.project, server: params.name)) + } + + func stop(_ target: ServerTargetParams) async throws -> ServerResult { + stopResult ?? ServerResult( + server: ServerStatus(logPath: "", phase: .stopped, project: target.project, server: target.name)) + } + + func why(_ target: ServerTargetParams) async throws -> WhyResult { + whyResult ?? WhyResult(findings: []) + } +} + +private actor RecordingEffects: DeepLinkEffects { + private(set) var opened: [URL] = [] + private(set) var pasteboard: [String] = [] + private(set) var notifications: [(title: String, body: String)] = [] + + func copyToPasteboard(_ text: String) async { pasteboard.append(text) } + func notify(title: String, body: String) async { notifications.append((title, body)) } + func openBrowser(_ url: URL) async { opened.append(url) } +} + +@Suite struct DeepLinkRunnerTests { + private func runner(_ daemon: MockDaemon, _ effects: RecordingEffects) -> DeepLinkRunner { + DeepLinkRunner(daemon: daemon, effects: effects) + } + + @Test func ensureReportsPhase() async throws { + let daemon = MockDaemon( + ensureResult: EnsureResult( + server: ServerStatus(logPath: "", phase: .running, project: "/Users/x/code/candor", server: "cms"))) + let effects = RecordingEffects() + let result = try await runner(daemon, effects).run( + DeepLink(verb: .ensure, projectSlug: "candor", server: "cms")) + #expect(result == DeepLinkRunResult(verb: .ensure, projectPath: "/Users/x/code/candor", detail: "running")) + } + + @Test func ensureReportsFellShort() async throws { + let daemon = MockDaemon( + ensureResult: EnsureResult( + reason: .timeout, + server: ServerStatus(logPath: "", phase: .starting, project: "/Users/x/code/candor", server: "cms"))) + let result = try await runner(daemon, RecordingEffects()).run( + DeepLink(verb: .ensure, projectSlug: "candor", server: "cms")) + #expect(result.detail == "fell short: timeout") + } + + @Test func stopReportsPhase() async throws { + let result = try await runner(MockDaemon(), RecordingEffects()).run( + DeepLink(verb: .stop, projectSlug: "candor", server: "cms")) + #expect(result == DeepLinkRunResult(verb: .stop, projectPath: "/Users/x/code/candor", detail: "stopped")) + } + + @Test func openOpensServerURL() async throws { + let daemon = MockDaemon( + status: ServerStatus( + logPath: "", phase: .running, project: "/Users/x/code/candor", server: "cms", + url: "http://candor.localhost:3000/")) + let effects = RecordingEffects() + let result = try await runner(daemon, effects).run( + DeepLink(verb: .open, projectSlug: "candor", server: "cms")) + #expect(await effects.opened == [URL(string: "http://candor.localhost:3000/")!]) + #expect(result.detail == "http://candor.localhost:3000/") + } + + @Test func openOpensNamedHead() async throws { + let daemon = MockDaemon( + status: ServerStatus( + heads: ["wren-hollow": "http://wren-hollow.localhost:3000/"], + logPath: "", phase: .running, project: "/Users/x/code/candor", server: "cms", + url: "http://candor.localhost:3000/")) + let effects = RecordingEffects() + _ = try await runner(daemon, effects).run( + DeepLink(verb: .open, projectSlug: "candor", server: "cms", head: "wren-hollow")) + #expect(await effects.opened == [URL(string: "http://wren-hollow.localhost:3000/")!]) + } + + @Test func openMissingHeadThrows() async { + let daemon = MockDaemon( + status: ServerStatus( + heads: ["wren-hollow": "http://wren-hollow.localhost:3000/"], + logPath: "", phase: .running, project: "/Users/x/code/candor", server: "cms")) + do { + _ = try await runner(daemon, RecordingEffects()).run( + DeepLink(verb: .open, projectSlug: "candor", server: "cms", head: "cold-brook")) + Issue.record("expected WireError") + } catch let error as WireError { + #expect(error.code == .notFound) + #expect(error.hint == "run: devctl status cms --json") + #expect(error.message.contains("known heads: wren-hollow")) + } catch { + Issue.record("unexpected error \(error)") + } + } + + @Test func openWithoutURLThrows() async { + let daemon = MockDaemon( + status: ServerStatus(logPath: "", phase: .running, project: "/Users/x/code/candor", server: "cms")) + await #expect(throws: WireError.self) { + try await runner(daemon, RecordingEffects()).run( + DeepLink(verb: .open, projectSlug: "candor", server: "cms")) + } + } + + @Test func whyCopiesAndNotifies() async throws { + let daemon = MockDaemon( + whyResult: WhyResult( + findings: [ + WhyFinding( + evidence: ["exit code 1"], phase: .crashed, server: "cms", + summary: "crashed on boot") + ], + rootCause: "cms crashed on boot")) + let effects = RecordingEffects() + let result = try await runner(daemon, effects).run( + DeepLink(verb: .why, projectSlug: "candor", server: "cms")) + #expect(result.detail == "cms crashed on boot") + let pasteboard = await effects.pasteboard + #expect(pasteboard.count == 1) + #expect(pasteboard[0].contains("root cause: cms crashed on boot")) + #expect(pasteboard[0].contains("exit code 1")) + let notifications = await effects.notifications + #expect(notifications.count == 1) + #expect(notifications[0].body == "cms crashed on boot") + } + + @Test func unknownProjectSlugThrowsBeforeDispatch() async { + let daemon = MockDaemon(projectPaths: ["/Users/x/code/candor"]) + await #expect(throws: WireError.self) { + try await runner(daemon, RecordingEffects()).run( + DeepLink(verb: .stop, projectSlug: "ghost", server: "cms")) + } + } +} diff --git a/Tests/DevCtlKitTests/DeepLinkTests.swift b/Tests/DevCtlKitTests/DeepLinkTests.swift new file mode 100644 index 0000000..c489a8d --- /dev/null +++ b/Tests/DevCtlKitTests/DeepLinkTests.swift @@ -0,0 +1,149 @@ +import Foundation +import Testing + +@testable import DevCtlKit + +@Suite struct DeepLinkParseTests { + @Test func pathFormNoHead() throws { + let link = try DeepLink.parse("devctl://open/my-proj/web").get() + #expect(link == DeepLink(verb: .open, projectSlug: "my-proj", server: "web")) + } + + @Test func pathFormWithHead() throws { + let link = try DeepLink.parse("devctl://open/my-proj/web/wren-hollow").get() + #expect(link == DeepLink(verb: .open, projectSlug: "my-proj", server: "web", head: "wren-hollow")) + } + + @Test func verbsWithoutHead() throws { + for verb in [DeepLinkVerb.ensure, .stop, .why] { + let link = try DeepLink.parse("devctl://\(verb.rawValue)/proj/api").get() + #expect(link == DeepLink(verb: verb, projectSlug: "proj", server: "api")) + } + } + + @Test func queryFormAlias() throws { + let link = try DeepLink.parse("devctl://open?project=my-proj&server=web&head=wren-hollow").get() + #expect(link == DeepLink(verb: .open, projectSlug: "my-proj", server: "web", head: "wren-hollow")) + } + + @Test func queryFormEmptyHeadIsNil() throws { + let link = try DeepLink.parse("devctl://open?project=my-proj&server=web&head=").get() + #expect(link.head == nil) + } + + @Test func schemeIsCaseInsensitive() throws { + let link = try DeepLink.parse("DEVCTL://ENSURE/proj/api").get() + #expect(link == DeepLink(verb: .ensure, projectSlug: "proj", server: "api")) + } + + @Test func percentEncodedSegmentsDecode() throws { + let link = try DeepLink.parse("devctl://open/my%20proj/web").get() + #expect(link.projectSlug == "my proj") + } + + @Test func parseFromURL() throws { + let url = URL(string: "devctl://why/proj/api")! + let link = try DeepLink.parse(url: url).get() + #expect(link == DeepLink(verb: .why, projectSlug: "proj", server: "api")) + } +} + +@Suite struct DeepLinkRejectTests { + private func expectUsage(_ string: String) { + switch DeepLink.parse(string) { + case .success(let link): Issue.record("expected rejection, got \(link)") + case .failure(let error): #expect(error.code == .usage) + } + } + + @Test func unknownVerb() { expectUsage("devctl://frobnicate/proj/web") } + @Test func wrongScheme() { expectUsage("http://open/proj/web") } + @Test func missingServerSegment() { expectUsage("devctl://open/only-project") } + @Test func tooManySegments() { expectUsage("devctl://open/proj/web/head/extra") } + @Test func emptyServer() { expectUsage("devctl://open?project=proj&server=") } + @Test func headOnNonOpenVerb() { expectUsage("devctl://ensure/proj/web/head") } + @Test func slugTraversal() { expectUsage("devctl://open/../etc/web") } + @Test func queryFormSlugWithSlash() { expectUsage("devctl://open?project=a%2Fb&server=web") } + @Test func missingVerb() { expectUsage("devctl:///proj/web") } +} + +@Suite struct DeepLinkRoundTripTests { + @Test func pathFormRoundTrips() throws { + for link in [ + DeepLink(verb: .open, projectSlug: "my-proj", server: "web"), + DeepLink(verb: .open, projectSlug: "my-proj", server: "web", head: "wren-hollow"), + DeepLink(verb: .ensure, projectSlug: "candor", server: "cms"), + DeepLink(verb: .stop, projectSlug: "candor", server: "cms"), + DeepLink(verb: .why, projectSlug: "candor", server: "cms"), + ] { + let reparsed = try DeepLink.parse(link.urlString()).get() + #expect(reparsed == link) + } + } + + @Test func headDroppedForNonOpenOnSerialize() throws { + // A head can only exist on `open`, but guard the serializer directly too. + let link = DeepLink(verb: .stop, projectSlug: "p", server: "s", head: "ignored") + #expect(link.urlString() == "devctl://stop/p/s") + } + + @Test func encodedSegmentRoundTrips() throws { + let link = DeepLink(verb: .open, projectSlug: "my proj", server: "web ui") + let reparsed = try DeepLink.parse(link.urlString()).get() + #expect(reparsed == link) + } +} + +@Suite struct DeepLinkResolveTests { + private let paths = [ + "/Users/x/code/candor", + "/Users/x/code/other", + "/Users/x/work/other", + ] + + @Test func uniqueMatch() throws { + let path = try DeepLink.resolveProject(slug: "candor", against: paths).get() + #expect(path == "/Users/x/code/candor") + } + + @Test func matchIsCaseInsensitive() throws { + let path = try DeepLink.resolveProject(slug: "CANDOR", against: paths).get() + #expect(path == "/Users/x/code/candor") + } + + @Test func ambiguousNamesCandidates() { + switch DeepLink.resolveProject(slug: "other", against: paths) { + case .success(let path): Issue.record("expected ambiguity, got \(path)") + case .failure(let error): + #expect(error.code == .usage) + #expect(error.message.contains("/Users/x/code/other")) + #expect(error.message.contains("/Users/x/work/other")) + } + } + + @Test func unknownIsNotFound() { + switch DeepLink.resolveProject(slug: "ghost", against: paths) { + case .success(let path): Issue.record("expected not-found, got \(path)") + case .failure(let error): #expect(error.code == .notFound) + } + } + + @Test func trailingSlashPathResolves() throws { + let path = try DeepLink.resolveProject(slug: "candor", against: ["/Users/x/code/candor/"]).get() + #expect(path == "/Users/x/code/candor/") + } + + @Test func traversalSlugRejected() { + switch DeepLink.resolveProject(slug: "..", against: paths) { + case .success: Issue.record("expected rejection of '..'") + case .failure(let error): #expect(error.code == .usage) + } + } + + @Test func slashSlugRejected() { + switch DeepLink.resolveProject(slug: "a/b", against: paths) { + case .success: Issue.record("expected rejection of 'a/b'") + case .failure(let error): #expect(error.code == .usage) + } + } +} diff --git a/Tests/DevCtlKitTests/DevCtlLogTests.swift b/Tests/DevCtlKitTests/DevCtlLogTests.swift new file mode 100644 index 0000000..d1947fe --- /dev/null +++ b/Tests/DevCtlKitTests/DevCtlLogTests.swift @@ -0,0 +1,52 @@ +import Testing + +@testable import DevCtlKit + +/** Serialized: every case swaps the process-global `DevCtlLog.backend`, so + parallel cases would clobber each other's recorder. */ +@Suite(.serialized) struct DevCtlLogTests { + /** Installs a recorder for the duration of `body`, then restores the default + backend so one suite's swap never leaks into another. */ + private func withRecorder(_ body: (RecordingBackend) -> Void) { + let previous = DevCtlLog.backend + defer { DevCtlLog.backend = previous } + let recorder = RecordingBackend() + DevCtlLog.backend = recorder + body(recorder) + } + + @Test func categoryLoggerCapturesLevelAndCategory() { + withRecorder { recorder in + DevCtlLog.deeplink.info("dispatched ensure") + DevCtlLog.deeplink.error("rejected slug") + DevCtlLog.daemon.debug("tick") + #expect( + recorder.entries == [ + .init(category: .deeplink, level: .info, message: "dispatched ensure"), + .init(category: .deeplink, level: .error, message: "rejected slug"), + .init(category: .daemon, level: .debug, message: "tick"), + ]) + } + } + + @Test func staticFormsMatchCategoryLoggers() { + withRecorder { recorder in + DevCtlLog.info(.app, "hello") + DevCtlLog.error(.health, "probe failed") + #expect(recorder.messages == ["hello", "probe failed"]) + } + } + + @Test func resetClearsEntries() { + withRecorder { recorder in + DevCtlLog.supervisor.info("first") + recorder.reset() + DevCtlLog.supervisor.info("second") + #expect(recorder.messages == ["second"]) + } + } + + @Test func subsystemIsStable() { + #expect(DevCtlLog.subsystem == "dev.quantizor.devctl") + } +} diff --git a/Tests/DevCtlKitTests/DiscoveryStanzaTests.swift b/Tests/DevCtlKitTests/DiscoveryStanzaTests.swift new file mode 100644 index 0000000..8e899a5 --- /dev/null +++ b/Tests/DevCtlKitTests/DiscoveryStanzaTests.swift @@ -0,0 +1,23 @@ +import Testing + +@testable import DevCtlKit + +@Suite struct DiscoveryStanzaTests { + @Test func emptyUsesPlaceholder() { + #expect( + DiscoveryStanza.render(serverNames: []) + == "- This project is registered with devctl (devservers.json). Prefer `devctl ensure ` / `devctl status` / `devctl logs ` over launching the server directly. Name servers after the project (not `web`); use `.localhost` hosts, not bare `localhost`.") + } + + @Test func oneNameWiresTheExamples() { + #expect( + DiscoveryStanza.render(serverNames: ["myproj"]) + == "- This project is registered with devctl (devservers.json). Prefer `devctl ensure myproj` / `devctl status` / `devctl logs myproj` over launching the server directly. Name servers after the project (not `web`); use `.localhost` hosts, not bare `localhost`.") + } + + @Test func manyNamesUseTheFirst() { + #expect( + DiscoveryStanza.render(serverNames: ["api", "web", "worker"]) + == "- This project is registered with devctl (devservers.json). Prefer `devctl ensure api` / `devctl status` / `devctl logs api` over launching the server directly. Name servers after the project (not `web`); use `.localhost` hosts, not bare `localhost`.") + } +} diff --git a/Tests/DevCtlKitTests/ProjectConfigTests.swift b/Tests/DevCtlKitTests/ProjectConfigTests.swift index c001793..7487e9a 100644 --- a/Tests/DevCtlKitTests/ProjectConfigTests.swift +++ b/Tests/DevCtlKitTests/ProjectConfigTests.swift @@ -42,6 +42,40 @@ import Testing #expect(view.warnings.contains { $0.contains("both declare port 3000") }) } + @Test func bareLoopbackHostsWarnButDoNotFail() { + let config = ProjectFileConfig( + host: "localhost", + servers: [ + "api": ProjectFileServer(command: ["x"], host: "127.0.0.1", port: 4000), + "web": ProjectFileServer(command: ["x"], url: "http://localhost:3000/"), + ]) + let view = ProjectConfigLoader.validate(config: config, project: "/Users/x/code/My Proj") + #expect(view.errors.isEmpty) + #expect( + view.warnings.contains { + $0 == "host 'localhost' is a bare loopback address; prefer 'my-proj.localhost' so each project keeps an isolated browser origin" + }) + #expect( + view.warnings.contains { + $0 == "server 'api': host '127.0.0.1' is a bare loopback address; prefer a 'my-proj.localhost' subdomain" + }) + #expect( + view.warnings.contains { + $0 == "server 'web': url 'http://localhost:3000/' points at a bare loopback host; prefer 'my-proj.localhost'" + }) + } + + @Test func subdomainLocalhostHostsDoNotWarn() { + let config = ProjectFileConfig( + host: "shop.localhost", + servers: [ + "api": ProjectFileServer(command: ["x"], host: "api.shop.localhost", port: 4000), + "web": ProjectFileServer(command: ["x"], port: 3000), + ]) + let view = ProjectConfigLoader.validate(config: config, project: "/p") + #expect(view.warnings.allSatisfy { !$0.contains("loopback") }) + } + @Test func parseErrorIsActionable() throws { let dir = FileManager.default.temporaryDirectory.appending(path: "devctl-cfg-\(UUID().uuidString)") try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) diff --git a/Tests/DevCtlKitTests/SpotlightLabelTests.swift b/Tests/DevCtlKitTests/SpotlightLabelTests.swift new file mode 100644 index 0000000..3df56a2 --- /dev/null +++ b/Tests/DevCtlKitTests/SpotlightLabelTests.swift @@ -0,0 +1,35 @@ +import Foundation +import Testing + +@testable import DevCtlKit + +@Suite struct SpotlightLabelTests { + @Test func titleOmitsRedundantServerName() { + #expect(SpotlightLabel.title(project: "candor", server: "candor", head: nil) == "candor") + #expect( + SpotlightLabel.title(project: "candor", server: "candor", head: "operator") + == "candor · operator") + #expect( + SpotlightLabel.title(project: "styled-components", server: "native", head: nil) + == "styled-components · native") + } + + @Test func subtitleLeadsWithBrand() { + #expect( + SpotlightLabel.subtitle(url: "http://candor.localhost:3000/") + == "devctl · http://candor.localhost:3000/") + } + + @Test func keywordsIncludeProjectHostAndAnchors() { + #expect( + SpotlightLabel.keywords( + project: "candor", server: "candor", head: "operator", + url: "http://candor.localhost:3000/") + == ["candor", "dev server", "devctl", "operator"]) + #expect( + SpotlightLabel.keywords( + project: "candor", server: "candor", head: "qa", + url: "http://demo1.localhost:3000/qa") + == ["candor", "demo1", "dev server", "devctl", "qa"]) + } +} diff --git a/docs/cli-contract.md b/docs/cli-contract.md index 08f9017..e319ed7 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -50,12 +50,14 @@ Filled in per phase as each lands; golden tests reference the examples in this f - `devctl up [--only a,b] [--timeout 60] --json` → `{results: [{reason?, server}]}`. Wave-parallel in dependency order (Kahn waves); `--only` pulls in transitive dependencies; a wave with a failure stops the rollout (later waves depend on it); any `reason` ⇒ exit 1. `waitFor: "started"` on a server launches it without blocking on health; the default blocks until healthy. `devctl down --json` stops in reverse waves, always exit 0. - `devctl trust --json` → approves the project's committed config. Trust is also recorded implicitly by an explicit `start`/`ensure`/`up` on a file-sourced server (the invocation is the approval); the SessionStart hook advertises only trusted projects. `status --json` carries `trusted` when project-scoped. - `devctl open [head]` → opens the server's URL in the default browser, or a named head of a multi-headed server. URLs derive as `http://:/` from the project `host` (default `.localhost`), overridable per server (`host` or `url` keys). A `heads` map (display name → URL) models one server fronting several surfaces (a Host-routing dev proxy); heads appear in `ServerStatus.heads`, the agent context block, and the app's row/detail menus. -- `devctl config check --json` → `{errors, host, servers, warnings}` from the daemon's own validator (cycles and unknown dependencies are errors; duplicate declared ports and unknown versions are warnings); errors ⇒ exit 1. +- `devctl link [head] [--json]` → prints a `devctl://` URL for the cwd project (`devctl://ensure//`, etc.). Verbs: `open`, `ensure`, `stop`, `why`. `--json` → `{url}`. For Raycast/Shortcuts/docs; the menu bar app handles the same URLs via Launch Services. +- `devctl x-url [--json]` (hidden): dispatches a `devctl://` URL through the same `DeepLinkRunner` the app uses (no Launch Services). Smoke/CI entry. Success → `DeepLinkRunResult` `{verb, projectPath, detail?}`; bad URL / unknown slug → usage/not-found. +- `devctl config check --json` → `{errors, host, servers, warnings}` from the daemon's own validator (cycles and unknown dependencies are errors; duplicate declared ports, unknown versions, and bare-loopback hosts are warnings); errors ⇒ exit 1. Name servers after the project (not a generic `web`) and give each a `.localhost` host, not bare `localhost`: the subdomain keeps browser cookies/storage/service workers isolated per project. - `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 (" " opens the URL); `heads` and pins surface in the menu bar app. +- 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 daemon install|uninstall [--purge]|start|stop|restart|status`: launchd lifecycle. `stop` drains and writes a deliberate-stop marker that auto-bootstrap honors; `restart` drains, kickstarts, and re-ensures what ran; `install` stages-and-renames the daemon binary and captures the login-shell PATH into the agent plist. Reboot recovery: the LaunchAgent runs at load, and starting a server records a resume-on-boot intent; a machine shutdown drains without clearing it, so the next login's daemon brings back every server that was up. A deliberate `devctl stop`/`down` clears the intent, so only those stay down. +- `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}`). Adding a harness: CONTRIBUTING.md. -- `devctl statusline`: reads harness statusline stdin JSON (workspace.current_dir or cwd), prints `web:3000 ok · api crashed` for the project, empty otherwise. +- `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. +- `devctl statusline`: reads harness statusline stdin JSON (workspace.current_dir or cwd), prints `myproj:3000 ok · api crashed` for the project, empty otherwise. diff --git a/docs/design.md b/docs/design.md index 48f3206..4735f1d 100644 --- a/docs/design.md +++ b/docs/design.md @@ -72,7 +72,7 @@ Swift 6.3 (6.3.3 latest), Swift 6 language mode with strict concurrency. Depende ``` - `command` is an argv array (no shell); `"shell": true` escape hatch runs via `/bin/zsh -lc` for nvm/mise-style setups, tradeoff documented. `cwd` is relative to project root (monorepo packages need it). -- Host signature (Evan's isolation requirement): project-level `host` defaults to `.localhost`; per-server subdomain overrides allowed (`api.myproj.localhost`). Each server's `url` derives as `http://:/` unless set explicitly. The registry enforces machine-wide uniqueness of host:port signatures; registering a claimed pair errors naming the holder. Unique origins keep browser cookies/storage/service workers isolated per project. Resolution caveat handled: browsers resolve `*.localhost` to loopback themselves, CLI tools and the system resolver do not reliably, so daemon healthchecks connect to 127.0.0.1 with the configured `Host` header, and the agent cheat-sheet says the same for curl. Backlog: devctl-managed reverse proxy on :80/:443 for port-free URLs. +- Host signature (Evan's isolation requirement): project-level `host` defaults to `.localhost`; per-server subdomain overrides allowed (`api.myproj.localhost`). Each server's `url` derives as `http://:/` unless set explicitly. The registry enforces machine-wide uniqueness of host:port signatures; registering a claimed pair errors naming the holder. Unique origins keep browser cookies/storage/service workers isolated per project, so a bare `localhost` or `127.0.0.1` host (which collapses every project onto one origin) draws a config-check warning pointing at `.localhost`, never a hard error. Resolution caveat handled: browsers resolve `*.localhost` to loopback themselves, CLI tools and the system resolver do not reliably, so daemon healthchecks connect to 127.0.0.1 with the configured `Host` header, and the agent cheat-sheet says the same for curl. Backlog: devctl-managed reverse proxy on :80/:443 for port-free URLs. - Healthcheck defaults: explicit block wins; else a declared `port` implies a TCP probe on it; else healthy = process alive past a 2s stabilization window. Status JSON carries `healthcheck: "http"|"tcp"|"none"` so agents know when "healthy" is unverified. `unhealthyAfter` applies only after first-healthy; exiting during `starting` is a crash, not a flap. - Validation: unknown keys warn, dependency cycles hard-error, signature/port collisions error at registration and start (below). Malformed config → `config-invalid` error carrying file, line, and failing key; `devctl config check` validates read-side (same validator the daemon uses on write). - `devctl register` writes ad-hoc entries into the machine registry only (optional `--write` back to devservers.json). Daemon re-reads devservers.json lazily per project-scoped request, mtime-cached; no fsnotify in v1. A spec hash is recorded at spawn; when the file drifts under a running server, status/ensure report `specStale: true` with a restart hint. @@ -93,7 +93,7 @@ NDJSON over the unix socket (one JSON object per line; JSONEncoder without prett - Daemon lifecycle semantics (stated truthfully, the red-team centerpiece): - Graceful exit (`daemon.shutdown`, uninstall, upgrade, `daemon restart`): drain-stop all servers through the normal teardown path first. Restart/upgrade records the set of running servers and re-ensures them after the new daemon is up, so "servers bounce, then come back" is the contract. Shutdown writes `stopped.intent`; exits 0. - `KeepAlive = {SuccessfulExit: false}`: crashes relaunch, deliberate shutdown stays down. CLI auto-bootstrap honors `stopped.intent` (cleared by `daemon install`/explicit start). - - Crash recovery: on launch, servers recorded running whose pid is gone (kill(pid,0) + proc_pidinfo start-time against pid reuse) → `crashed(daemon-restart)`; servers whose pid is alive (orphans, still logging safely into their spool) → group-killed, marked, and restarted if their recorded phase was running. Never adopted silently; pipe-less capture means nothing died or wedged in the interim. True re-adoption without the bounce is backlogged (orphan exit codes are unknowable for non-children). + - Crash / boot recovery (`recoverAtStartup`): on launch, servers with resume-on-boot or a phase left running/starting are restored. Specs resolve through the merged view (devservers.json + ad-hoc registry), never registry.json alone: config-defined servers are not written into the registry's `servers` map, so a registry-only lookup silently skipped every committed server. Orphan state rows (rename/delete with no matching spec) are dropped. Live orphan pids are group-killed then restarted (never adopted silently; exit forensics are unknowable for non-children). True re-adoption without the bounce is backlogged. - Upgrades stage the new binary and `rename(2)` it into place, never cp over the running Mach-O (overwriting a signed running binary gets it SIGKILLed mid-install). - Health monitor per running server: HTTP 2xx or TCP connect probes (127.0.0.1 + Host header), consecutive-threshold state machine, transitions pushed to subscribers and appended to health.json. Sleep-aware: system power notifications (IORegisterForSystemPower) pause probes across sleep and reset failure counters with a wake grace window, so lid-open does not flap every server unhealthy. Timeouts use ContinuousClock; uptime derives from the recorded start wall-timestamp. - ControlServer: NWListener with `requiredLocalEndpoint = .unix(path:)`; single-instancing via the flock above; one Task per request so a slow `server.wait` never blocks the connection. Known cosmetic NECP log noise on unix listeners (Apple DTS-confirmed); not suppressed. `setrlimit` raises maxfiles to the hard limit at startup (launchd jobs default to a 256 soft limit, verified; a dozen servers plus subscribers approaches it) and doctor reports fd usage. @@ -123,20 +123,23 @@ Every command has `--json` (stable schemas carrying `schemaVersion`, generated f ## Agent integration (the amnesia fix; hook facts verified against current docs) - `devctl hook install` merges (never clobbers, idempotent) a SessionStart hook into user settings with matcher `startup|resume|clear|compact`; `compact` fires right after auto/manual compaction, exactly when agents forget. Hook command `devctl status --hook`: `--no-bootstrap`, hard 300ms self-timeout, always exits 0, silent when the project is unregistered/untrusted or the daemon is down (one stderr line pointing at doctor). Emits `hookSpecificOutput.additionalContext` with per-server name/phase/url/logPath plus the ensure→wait→logs cheat-sheet, inside clearly delimited fences, length-capped, never raw log lines or command strings (prompt-injection hygiene; server output is attacker-influenceable). -- `devctl hook install --statusline` offers a statusline snippet (stdin carries `workspace.current_dir`): `web:3000 ok · api:8787 crashed`. -- Discovery: `register --write` and `hook install` offer a one-line CLAUDE.md/AGENTS.md stanza for the project, so an agent that has never heard of devctl learns to prefer `devctl ensure` over `npm run dev`. +- `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. +- 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. ## launchd -Plist: `RunAtLoad`, `KeepAlive = {SuccessfulExit: false}`, `ProcessType=Interactive` (App Nap/QoS claim for own-session children unverified; confirm during Phase 4), stdout/err redirect for pre-init only, `EnvironmentVariables.PATH` captured from `/bin/zsh -lc 'echo $PATH'` at install (surfaced in `daemon.info` and doctor since it goes stale after Homebrew migrations). No socket activation: the daemon must be resident whenever servers run, KeepAlive covers crashes, one code path keeps foreground/test runs identical. Install flow: stage binary → rename into place → render plist → `launchctl bootout gui/$UID/...` (ignore failure) → `launchctl bootstrap gui/$UID ` → poll socket for hello. Restart = drain + `launchctl kickstart -k` + re-ensure. (Current launchctl 2.0 vocabulary.) +Plist: `RunAtLoad`, `KeepAlive = {SuccessfulExit: false}`, `ProcessType=Interactive` (verified 2026-07-24: `launchctl print` reports `spawn type = interactive (4)` for `dev.quantizor.devctl`; managed children are session leaders with `pgid == pid` via `createSession`, which group teardown requires). Apple's `launchd.plist` man page: Interactive jobs get the same resource limits as apps (none). Whether App Nap still clamps those setsid children is not userland-provable without root `taskinfo` / Instruments; without that evidence, keep Interactive on the agent and do not raise child QoS. Repeatable check: `scripts/verify-interactive-qos.sh`. stdout/err redirect for pre-init only, `EnvironmentVariables.PATH` captured from `/bin/zsh -lc 'echo $PATH'` at install (surfaced in `daemon.info` and doctor since it goes stale after Homebrew migrations). No socket activation: the daemon must be resident whenever servers run, KeepAlive covers crashes, one code path keeps foreground/test runs identical. Install flow: stage binary → rename into place → render plist → `launchctl bootout gui/$UID/...` (ignore failure) → `launchctl bootstrap gui/$UID ` → poll socket for hello. Restart = drain + `launchctl kickstart -k` + re-ensure. (Current launchctl 2.0 vocabulary.) ## Menu bar app + dashboard - `MenuBarExtra` with `.window` style (menu style cannot render dots/rows/buttons). `LSUIElement=true`, unsandboxed (sandboxed apps cannot reach unix sockets outside their container; non-MAS by design). Launch-at-login toggle via `SMAppService.mainApp`. - No SwiftUI `Settings` scene (documented-broken from MenuBarExtra on Tahoe); dashboard and settings are a plain `Window` scene via `openWindow` + `NSApp.activate`. - 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 a "View logs" action (UserNotifications lives in the app since the daemon has no bundle). +- 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). - 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. @@ -150,12 +153,12 @@ Plist: `RunAtLoad`, `KeepAlive = {SuccessfulExit: false}`, `ProcessType=Interact 1. Tracer bullet: DevCtlKit models + NDJSON codec + POSIX DaemonClient; `devctld --foreground` on NWListener (flock single-instancing from day one) serving info/register/start/status/stop; supervisor with spool-file capture, tailer, group-kill; CLI register/start/status/stop --json. Gate: start fixture-server, verify pid + logs flowing, stop, verify the whole group died; kill the daemon mid-run, verify the child kept running and logging into its spool. 2. Agent ergonomics: healthchecks + defaults, ensure state matrix + single-flight, wait with fail-fast reasons, failed-vs-crashed forensics, port/signature pre-checks, error envelope + `docs/cli-contract.md` + golden tests. 3. Logs + events: structured LogStore, rotation, monotonic clamp, since/since-mark/tail/grep, follow subscription with bounded queues, mark with ids/labels, ANSI stripping; the unified `events` feed and `devctl why` (both derive from LogStore sys/mark data + health transitions). -4. launchd: daemon install/uninstall/restart with drain + re-ensure, stage-and-rename upgrade, intent marker, auto-bootstrap, PATH capture, crash recovery incl. orphan handling, setrlimit, sleep/wake pause. Verify the ProcessType claim. +4. launchd: daemon install/uninstall/restart with drain + re-ensure, stage-and-rename upgrade, intent marker, auto-bootstrap, PATH capture, crash recovery incl. orphan handling, setrlimit, sleep/wake pause. ProcessType=Interactive verified (see launchd section; `scripts/verify-interactive-qos.sh`). 5. Projects: devservers.json loading + validation + trust, host signatures, DAG, up/down, specStale. 6. Menu bar app: bundle pipeline, dropdown with click-to-open, status.subscribe, ambient icon state, crash notifications, login item. 7. Dashboard: log viewer + markers, config editing with optimistic concurrency, event timeline lanes, signature map. `devctl hook install` (can land any time after phase 2). -Project hygiene at bootstrap: CLAUDE.md (symlinked AGENTS.md) with codebase map + build/test commands; BACKLOG.md seeded with: orphan re-adoption without the bounce, reverse proxy for port-free `*.localhost` URLs, setsid-grandchild sweep via proc_listchildpids, MenuBarExtraAccess if presentation quirks bite, Developer ID signing + notarization + Homebrew tap, ProcessType=Interactive verification, field-level config editing to preserve file formatting. Unit branch coverage target >80% on DevCtlKit/DevCtlDaemonCore. +Project hygiene at bootstrap: CLAUDE.md (symlinked AGENTS.md) with codebase map + build/test commands; BACKLOG.md seeded with: orphan re-adoption without the bounce, reverse proxy for port-free `*.localhost` URLs, setsid-grandchild sweep via proc_listchildpids, MenuBarExtraAccess if presentation quirks bite, Developer ID signing + notarization + Homebrew tap, field-level config editing to preserve file formatting. Unit branch coverage target >80% on DevCtlKit/DevCtlDaemonCore. ## Verification diff --git a/scripts/make-app-bundle.sh b/scripts/make-app-bundle.sh index df2bd03..c204f32 100755 --- a/scripts/make-app-bundle.sh +++ b/scripts/make-app-bundle.sh @@ -37,6 +37,17 @@ cat > "$APP/Contents/Info.plist" <14.0 LSUIElement + CFBundleURLTypes + + + CFBundleURLName + dev.quantizor.devctl.url + CFBundleURLSchemes + + devctl + + + NSHighResolutionCapable diff --git a/scripts/smoke-deeplink.sh b/scripts/smoke-deeplink.sh new file mode 100755 index 0000000..90e9bd4 --- /dev/null +++ b/scripts/smoke-deeplink.sh @@ -0,0 +1,111 @@ +#!/bin/zsh +# Launch Services E2E for devctl:// (warm + cold open). Not part of default +# swift test; run before merging URL-scheme work. Requires a GUI session. +# Soft-fails the OSLog scrape when not on a tty unless DEVCTL_OSLOG_STRICT=1. +# +# Uses the LIVE default daemon socket (the menu bar app cannot see DEVCTL_SOCKET). +# Registers a throwaway server under a temp project, then tears it down. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +BIN="$ROOT/.build/debug" +WORK="$(mktemp -d /tmp/devctl-deeplink-smoke.XXXXXX)" +PROJECT="$WORK/deeplink-fixture" +mkdir -p "$PROJECT" +SLUG="$(basename "$PROJECT")" +SERVER="web" +LSREGISTER="/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister" +DEVCTL="$BIN/devctl" + +fail() { echo "DEEPLINK SMOKE FAIL: $1" >&2; exit 1 } +pass() { echo " ok: $1" } +warn() { echo " warn: $1" >&2 } + +phase_of() { + "$DEVCTL" status "$SERVER" --project "$PROJECT" --json 2>/dev/null \ + | /usr/bin/python3 -c 'import json,sys; print(json.load(sys.stdin)["servers"][0]["phase"])' \ + 2>/dev/null || echo missing +} + +cleanup() { + "$DEVCTL" stop "$SERVER" --project "$PROJECT" --json >/dev/null 2>&1 || true + "$DEVCTL" unregister "$SERVER" --project "$PROJECT" --json >/dev/null 2>&1 || true + [[ -n "${LOG_STREAM_PID:-}" ]] && kill "$LOG_STREAM_PID" 2>/dev/null || true + killall -9 DevCtlApp 2>/dev/null || true + if [[ -d /Applications/devctl.app ]]; then + "$LSREGISTER" -f /Applications/devctl.app >/dev/null 2>&1 || true + fi + rm -rf "$WORK" +} +trap cleanup EXIT + +STRICT_OSLOG="${DEVCTL_OSLOG_STRICT:-}" +if [[ -z "$STRICT_OSLOG" ]]; then + if [[ -t 1 ]]; then STRICT_OSLOG=1; else STRICT_OSLOG=0; fi +fi + +echo "building..." +swift build --package-path "$ROOT" > /dev/null +swift build --package-path "$ROOT" --product DevCtlApp > /dev/null +"$ROOT/scripts/make-app-bundle.sh" - debug +APP="$ROOT/devctl.app" +SCHEME="$(/usr/libexec/PlistBuddy -c 'Print :CFBundleURLTypes:0:CFBundleURLSchemes:0' "$APP/Contents/Info.plist")" +[[ "$SCHEME" == "devctl" ]] || fail "scheme was $SCHEME" +pass "Info.plist declares devctl://" + +# Need a live daemon on the default socket (menu bar app path). +"$DEVCTL" daemon status >/dev/null 2>&1 || fail "devctld not running on the default socket; run: devctl daemon start" + +cd "$PROJECT" +"$DEVCTL" register --name "$SERVER" --cmd "$BIN/fixture-server" --json > /dev/null +pass "fixture registered on live daemon" + +"$LSREGISTER" -f "$APP" +pass "lsregister -f built app" + +OSLOG_FILE="$WORK/oslog.txt" +/usr/bin/log stream --style compact --predicate 'subsystem == "dev.quantizor.devctl"' --level debug \ + >"$OSLOG_FILE" 2>/dev/null & +LOG_STREAM_PID=$! +sleep 1 + +open -a "$APP" +sleep 2 +open -a "$APP" "devctl://ensure/${SLUG}/${SERVER}" +PHASE=missing +for i in {1..40}; do + PHASE="$(phase_of)" + [[ "$PHASE" == "running" ]] && break + sleep 0.5 +done +[[ "$PHASE" == "running" ]] || fail "warm ensure left phase $PHASE" +pass "warm open ensure -> running" + +killall DevCtlApp 2>/dev/null || true +# Bundle executable is named devctl-app; kill by that too. +killall devctl-app 2>/dev/null || true +sleep 1 +open -a "$APP" "devctl://stop/${SLUG}/${SERVER}" +PHASE=missing +for i in {1..40}; do + PHASE="$(phase_of)" + [[ "$PHASE" == "stopped" ]] && break + sleep 0.5 +done +[[ "$PHASE" == "stopped" ]] || fail "cold stop left phase $PHASE" +pass "cold open stop -> stopped" + +sleep 1 +kill "$LOG_STREAM_PID" 2>/dev/null || true +LOG_STREAM_PID="" +if rg -q 'deeplink|ensure|stop|dispatch' "$OSLOG_FILE"; then + pass "oslog stream saw deeplink markers" +else + if [[ "$STRICT_OSLOG" == "1" ]]; then + fail "oslog stream empty (strict); file=$OSLOG_FILE" + else + warn "oslog stream empty (non-strict); skipping" + fi +fi + +echo "DEEPLINK SMOKE PASS" diff --git a/scripts/smoke-launchd.sh b/scripts/smoke-launchd.sh index a3fb375..831114d 100755 --- a/scripts/smoke-launchd.sh +++ b/scripts/smoke-launchd.sh @@ -1,7 +1,8 @@ #!/bin/zsh # launchd smoke: exercises the REAL LaunchAgent lifecycle on this machine. # install → daemon status → server ensure → restart (bounce + re-ensure) → -# deliberate stop honored by auto-bootstrap → start → uninstall. +# install-upgrade (bounce + re-ensure) → deliberate stop honored by +# auto-bootstrap → start → uninstall. # Leaves no agent installed; uses a throwaway project + fixture-server. set -euo pipefail @@ -47,6 +48,16 @@ PID_AFTER="$("$DEVCTL" status web --json | /usr/bin/python3 -c 'import json,sys; [[ "$PID_AFTER" != "$PID_BEFORE" ]] || fail "pid unchanged across restart; bounce did not happen" 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" +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"])')" +[[ "$PID_POST_UPGRADE" != "$PID_PRE_UPGRADE" ]] || fail "pid unchanged across install upgrade; bounce did not happen" +pass "install upgrade bounced and re-ensured (pid $PID_PRE_UPGRADE -> $PID_POST_UPGRADE)" + "$DEVCTL" daemon stop > /dev/null sleep 2 set +e diff --git a/scripts/smoke.sh b/scripts/smoke.sh index fc0b894..2658768 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -172,6 +172,36 @@ PHASE_AFTER="$("$DEVCTL" wait db --healthy --timeout 15 --json | /usr/bin/python pass "resource lock pauses holder, refuses ensure, resumes after" "$DEVCTL" down --json > /dev/null +# Deep links: print URL + dispatch via x-url (no Launch Services). +cd "$PROJECT" +SLUG="$(basename "$PROJECT")" +LINK_URL="$("$DEVCTL" link ensure web)" +[[ "$LINK_URL" == "devctl://ensure/${SLUG}/web" ]] || fail "link ensure printed '$LINK_URL'" +pass "link prints canonical URL ($LINK_URL)" +"$DEVCTL" x-url "$LINK_URL" --json > /dev/null || fail "x-url ensure failed" +"$DEVCTL" wait web --healthy --timeout 15 --json > /dev/null || fail "x-url ensure never healthy" +pass "x-url ensure" +"$DEVCTL" x-url "devctl://stop/${SLUG}/web" --json > /dev/null || fail "x-url stop failed" +PHASE_STOP="$("$DEVCTL" status web --json | /usr/bin/python3 -c 'import json,sys; print(json.load(sys.stdin)["servers"][0]["phase"])')" +[[ "$PHASE_STOP" == "stopped" ]] || fail "x-url stop left phase $PHASE_STOP" +pass "x-url stop" +"$DEVCTL" x-url "devctl://why/${SLUG}/web" --json > /dev/null || fail "x-url why failed" +pass "x-url why" +set +e +BAD_OUT="$("$DEVCTL" x-url 'devctl://ensure/no-such-slug/web' --json 2>/dev/null)" +BAD_EXIT=$? +set -e +[[ "$BAD_EXIT" -ne 0 ]] || fail "x-url unknown slug should fail" +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 +"$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" + kill -9 "$DAEMON_PID" 2>/dev/null || true DAEMON_PID="" diff --git a/scripts/verify-interactive-qos.sh b/scripts/verify-interactive-qos.sh new file mode 100755 index 0000000..313fef7 --- /dev/null +++ b/scripts/verify-interactive-qos.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Verify ProcessType=Interactive on the live LaunchAgent and that managed +# children are session leaders (pgid == pid). Full App Nap / QoS inheritance +# for setsid children needs root `taskinfo` or Instruments; this script stays +# userland and never rewrites the installed plist. +set -euo pipefail + +LABEL="dev.quantizor.devctl" +UID_NUM="$(id -u)" +SERVICE="gui/${UID_NUM}/${LABEL}" +PRINT_FILE="$(mktemp -t devctl-launchctl-print)" + +echo "== launchctl spawn type ==" +if ! launchctl print "$SERVICE" >"$PRINT_FILE" 2>/dev/null; then + echo "service $SERVICE not loaded; run: devctl daemon install" >&2 + exit 1 +fi +grep -nE $'^\t(path|state|pid|spawn type|job state) =' "$PRINT_FILE" || true + +SPAWN=$(grep -E 'spawn type =' "$PRINT_FILE" | head -1 || true) +echo "observed: ${SPAWN:-unknown}" +echo "$SPAWN" | grep -qi 'interactive' || { + echo "expected spawn type = interactive (4); ProcessType may be missing from the plist" >&2 + exit 1 +} + +# The service summary line is a single tab then "pid = N" (not nested under xpc). +DAEMON_PID=$(awk -F' = ' '/^\tpid = /{print $2; exit}' "$PRINT_FILE") +if [[ -z "${DAEMON_PID:-}" || "$DAEMON_PID" == "0" ]]; then + echo "daemon not running (could not parse pid from launchctl print)" >&2 + exit 1 +fi +echo "daemon pid: $DAEMON_PID" + +echo +echo "== daemon + direct children (expect children: pgid==pid) ==" +ps -o pid,ppid,pgid,pri,nice,state,command -p "$DAEMON_PID" +CHILDREN=$(pgrep -P "$DAEMON_PID" || true) +if [[ -z "$CHILDREN" ]]; then + echo "(no managed children right now; ensure a server and re-run)" + exit 0 +fi + +FAIL=0 +for c in $CHILDREN; do + line=$(ps -o pid=,ppid=,pgid=,pri=,nice=,state=,command= -p "$c") + echo "$line" + pid=$(awk '{print $1}' <<<"$line") + pgid=$(awk '{print $3}' <<<"$line") + if [[ "$pid" != "$pgid" ]]; then + echo " FAIL: pid $pid is not a session leader (pgid=$pgid); group teardown assumes createSession" >&2 + FAIL=1 + fi +done + +if [[ "$FAIL" -ne 0 ]]; then + exit 1 +fi + +echo +echo "OK: LaunchAgent is Interactive; managed children are session leaders." +echo "Root taskinfo / Instruments still needed to prove App Nap does not clamp setsid children;" +echo "without that evidence, keep ProcessType=Interactive and do not raise child QoS."