diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f4a7f1b..e32180c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -19,8 +19,9 @@ name: CI # after a crash. Those need a privileged, reboot-capable host and are validated # by hand / in a later wave, NOT here. This job verifies the cross-platform UNIT # LOGIC (templates render, argv resolves, the suite is green on each OS) and -# that the bin builds + packs clean on each OS - nothing about live service -# lifecycle. +# that the bin builds + packs clean on each OS. A dedicated executable adapter +# fixture below drives every real platform adapter through an in-memory manager +# boundary; it is explicit simulation and does not claim live/reboot proof. # # Node is pinned to the package's engine floor (22.x). The cross-OS proof is the # dimension that matters, so the matrix spends its axes on OS, not Node majors. @@ -76,12 +77,21 @@ jobs: - name: Doctor gate (typecheck + test) run: npm run ci + # GitHub-hosted runners are not reboot-capable service test hosts. Execute the + # real adapter transactions against a deterministic manager fixture on every OS + # without claiming this is live launchd/systemd/Task Scheduler service proof. + - name: Native adapter integration fixture (simulated manager boundary) + run: npx vitest run tests/service/native-adapter.integration.test.ts + # The build is `tsc && node esbuild.config.mjs` - both must run. Proves the # single-file bin bundles on each OS (esbuild.config.mjs chmod is a no-op on # win32 but exercised, and the shebang/define injection is validated). - name: Build (tsc + esbuild bin) run: npm run build + - name: Packed CLI conformance + run: npm run test:packed-cli + # Prove `npm pack`'s tarball carries no forbidden/secret filenames, no # source/test/dist leak, and the bin is present - on each OS (pack-check has # a win32 branch for the npm.cmd shell-out). `pack:check` runs `prepack` -> diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ab01b0..02e8795 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v0.6.0 — 2026-07-14 + +Doctor's CLI now standardizes on the Apiary command contract: canonical `service-install`/`service-uninstall`, a new `logs`/`telemetry` surface, stable JSON output, and a verified `update` command with health-checked rollback, while keeping the previous commands available as deprecated aliases. + +## Unreleased + +- Standardize Doctor on the Apiary CLI contract: canonical `service-install` / `service-uninstall`, stable JSON envelopes, grouped Doctor branding, service-isolated `logs`, read-only `telemetry`, and shared exit codes. +- Preserve `install-service` / `uninstall-service` as deprecated aliases, preserve fleet incident records as `doctor incidents`, and preserve the former primary-daemon updater as `doctor daemon-update`. +- Make canonical `doctor update` update Doctor itself with pinned release resolution, restart/health verification, and rollback to the installed version on failed verification. `doctor self-update` remains a deprecated compatibility alias. + ## v0.5.0 — 2026-07-12 Doctor now re-reads the daemon registry at runtime and automatically supervises newly registered daemons (or drops removed ones) without requiring a restart, fixing onboarding hangs where services registered after boot were never watched. diff --git a/README.md b/README.md index 5967a83..fe69938 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,7 @@ To install or update it on its own: ```bash npm install -g @legioncodeinc/doctor -doctor install-service # register the OS service (restart-on-crash, start-on-boot) +doctor service-install # register the OS service (restart-on-crash, start-on-boot) ```
@@ -161,23 +161,28 @@ The dashboard is **Hive portal at `http://127.0.0.1:3853`**: fleet health lives Run `doctor` with no arguments for the banner and menu. The full surface: +The [Apiary suite command matrix](https://github.com/legioncodeinc/cli-kit/blob/main/library/notes/prd-003-command-matrix.md) is the normative cross-product contract; Doctor-specific behavior and compatibility commands are listed below. + | Command | What it does | |---|---| -| `doctor status` | daemon health, service state, versions, last heal, opt-out flags | +| `doctor start` / `doctor stop` / `doctor restart` | control Doctor's installed OS service; restart waits for the service to report running | +| `doctor install` / `doctor uninstall --yes` | onboard or remove Doctor and Doctor-owned state | +| `doctor service-install` / `doctor service-uninstall` | reconcile or remove only Doctor's OS service definition | +| `doctor update [--check]` | safely update Doctor, verify the restarted service, and roll back on failed health verification | +| `doctor status` | standard service/install/health/path snapshot plus supervised-daemon detail | +| `doctor logs [--lines N] [--no-follow] [--since 30m]` | tail only Doctor's authoritative service log with secret redaction | +| `doctor telemetry` | show read-only collection state, controlling setting, and destination class | | `doctor diagnose` | classify health and print the recommended fix, taking **no** action | | `doctor heal` | run the remediation ladder once (gated steps confirm first) | -| `doctor start` / `doctor stop` | start or stop Doctor's own daemon (via the OS service when registered) | -| `doctor restart` | restart the primary daemon (rung 1) | | `doctor reinstall` | reinstall the primary daemon (rung 2) | | `doctor uninstall-hivemind` | remove a conflicting `@deeplake/hivemind` global (rung 3, confirms) | -| `doctor update [--check]` | update the primary daemon via the blessed gate | -| `doctor self-update` | update Doctor's own package (the **only** thing that does) | -| `doctor install-service` / `uninstall-service` | register or remove the OS service | -| `doctor uninstall` | remove Doctor's service unit, registry entry, and state dir (npm package stays) | +| `doctor daemon-update [--check]` | compatibility surface for the former primary-daemon `update` behavior | +| `doctor self-update` | deprecated alias for canonical `doctor update` | +| `doctor install-service` / `uninstall-service` | deprecated aliases for the canonical service commands | | `doctor purge` | **DESTRUCTIVE:** wipe every Apiary asset on the machine (confirms, see below) | -| `doctor logs` | tail incident logs for all daemons, or one via `--daemon ` | +| `doctor incidents [--daemon name]` | read Doctor's product-specific fleet incident records | -Doctor never updates itself in the background. `self-update` is the single, explicit way to bump it, and there is deliberately no `clear-credentials` command: credential purges are only ever recommended via escalation, never automated. +Doctor never updates itself in the background. Canonical `update` is the explicit, verified Doctor update transaction; the old primary-daemon meaning moved to `daemon-update`. There is deliberately no `clear-credentials` command: credential purges are only ever recommended via escalation, never automated. ### The escape hatch: `doctor purge` diff --git a/package-lock.json b/package-lock.json index 7f1af9f..729858a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,16 @@ { "name": "@legioncodeinc/doctor", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@legioncodeinc/doctor", - "version": "0.5.0", + "version": "0.6.0", "license": "AGPL-3.0-or-later", + "dependencies": { + "@legioncodeinc/cli-kit": "^0.3.0" + }, "bin": { "doctor": "bundle/cli.js" }, @@ -605,6 +608,15 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@legioncodeinc/cli-kit": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@legioncodeinc/cli-kit/-/cli-kit-0.3.0.tgz", + "integrity": "sha512-Jm/ruBdX3dbRzxFApC719J9w7jO58yBvF5aHfxT6BfSVy4SxnuAYbvceQvljCshpUyT6i8Uq8kDTDDC/+LI87w==", + "license": "AGPL-3.0-or-later", + "engines": { + "node": ">=22.5.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", diff --git a/package.json b/package.json index 105e941..0919aa7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@legioncodeinc/doctor", - "version": "0.5.0", + "version": "0.6.0", "//version": "INT placeholder, still 0.0.0 so a stray publish is obvious AND the release workflow's preflight fails closed on it (a real cut bumps this deliberately, mirroring the parent's `npm version` flow). Doctor versions INDEPENDENTLY of the root honeycomb package (PRD-063 OD-6) and never auto-updates its own package (AC-6); esbuild reads THIS version and inlines it via `define` (__DOCTOR_VERSION__). This one manifest IS the single source of truth - there is no cross-manifest sync to run.", "description": "Doctor: a deliberately tiny, dependency-light, self-healing watchdog that keeps the Honeycomb primary daemon alive and reports when it cannot.", "license": "AGPL-3.0-or-later", @@ -26,6 +26,7 @@ "bundle": "node esbuild.config.mjs", "typecheck": "tsc --noEmit", "test": "vitest run", + "test:packed-cli": "node scripts/verify-packed-cli.mjs", "test:watch": "vitest", "ci": "npm run typecheck && npm run test", "pack:check": "node scripts/pack-check.mjs", @@ -44,5 +45,8 @@ }, "allowScripts": { "esbuild@0.28.1": true + }, + "dependencies": { + "@legioncodeinc/cli-kit": "^0.3.0" } } diff --git a/scripts/verify-packed-cli.mjs b/scripts/verify-packed-cli.mjs new file mode 100644 index 0000000..3ed7f37 --- /dev/null +++ b/scripts/verify-packed-cli.mjs @@ -0,0 +1,240 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; + +const npmCli = process.env.npm_execpath; +if (!npmCli) throw new Error("npm_execpath is required for packed CLI verification"); +const runNpm = (args, options = {}) => execFileSync(process.execPath, [npmCli, ...args], options); +const temp = mkdtempSync(join(tmpdir(), "doctor-packed-cli-")); +let tarball; + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +const identityColors = Object.freeze({ + bold: String, cyan: String, dim: String, green: String, red: String, yellow: String, +}); + +function fixtureContext(args, options = {}) { + const stdout = []; + const stderr = []; + const command = args[0] ?? ""; + const failure = options.failure; + let registered = options.registered ?? true; + let state = options.initialState ?? (command === "start" ? "not-running" : "running"); + const classification = options.unhealthy ? { kind: "unreachable-timeout" } : { kind: "ok" }; + const fails = (name) => failure === name; + const serviceModule = { + install: async () => { + if (fails(command)) return { ok: false, message: "fixture service install failed" }; + registered = true; + state = "running"; + return { ok: true, message: "fixture Doctor service installed and running" }; + }, + uninstall: async () => { + if (fails(command)) return { ok: false, message: "fixture service uninstall failed" }; + registered = false; + state = "not-running"; + return { ok: true, message: "fixture Doctor service uninstalled" }; + }, + }; + const serviceLifecycle = { + start: async () => { + if (fails("start") || fails("restart")) return { ok: false, message: "fixture Doctor service start failed" }; + state = "running"; + return { ok: true, message: "fixture Doctor service started" }; + }, + stop: async () => { + if (fails("stop") || fails("restart")) return { ok: false, message: "fixture Doctor service stop failed" }; + state = "not-running"; + return { ok: true, message: "fixture Doctor service stopped" }; + }, + }; + return { + stdout, + stderr, + ctx: { + io: { out: (text) => stdout.push(text), err: (text) => stderr.push(text) }, + confirm: async () => false, + confirmToken: async () => false, + isInteractive: () => false, + colors: identityColors, + deps: { + probe: async () => classification, + statusDaemons: () => [{ + name: "honeycomb", + probe: async () => classification, + readDaemonVersion: async () => classification.kind === "ok" ? "1.2.3-fixture" : null, + readStatusState: () => ({ lastHealAt: null, lastKnownHealth: classification.kind }), + }], + readDaemonVersion: async () => "1.2.3-fixture", + doctorVersion: "0.5.0-fixture", + ladder: { + decide: () => ({ rung: 1, advanced: false }), + run: async () => ({ ok: true, action: "fixture remediation" }), + escalate: async () => ({ ok: true, action: "fixture escalation" }), + }, + rungContextFor: (value) => ({ classification: value, logger: {} }), + decideRung: () => ({ rung: 1, advanced: false }), + readConsecutiveFailures: () => 0, + readStatusState: () => ({ lastHealAt: null, lastKnownHealth: classification.kind }), + serviceState: () => state, + serviceStateAsync: async () => { + if (fails("status")) throw new Error("fixture status failed"); + return state; + }, + serviceModule, + serviceLifecycle, + lifecycleSleep: async () => undefined, + productUninstall: { + precheck: () => ({ registryEntryExists: registered, stateDirExists: registered }), + serviceStatusAsync: async () => state, + isServiceRegistered: async () => registered, + serviceUninstall: serviceModule.uninstall, + removeState: () => ({ registryEntryRemoved: true, stateDirRemoved: true }), + }, + optOut: { autoUpdateDisabled: false, source: "default" }, + update: { + checkPrimaryUpdate: async () => "fixture daemon update check complete", + applyPrimaryUpdate: async () => "fixture daemon update complete", + checkSelfUpdate: async () => "fixture Doctor update check complete", + selfUpdate: async () => fails("update") ? "fixture Doctor update failed" : "fixture Doctor update complete", + }, + tailIncidents: async () => ["fixture Doctor incident"], + tailServiceLogs: async (_argv, write) => { + if (fails("logs")) return { ok: false, error: "fixture Doctor logs failed" }; + write("DOCTOR_PACKED_LOG_IDENTITY"); + write("Authorization: Bearer [REDACTED]"); + return { ok: true }; + }, + paths: { config: "/fixture/doctor", logs: "/fixture/doctor/service.log" }, + telemetrySummary: () => { + if (fails("telemetry")) throw new Error("fixture telemetry failed"); + return options.optedOut + ? { state: "opted-out", controllingSetting: "DO_NOT_TRACK", destination: "disabled", optOutInstruction: "Set DO_NOT_TRACK=1" } + : { state: "enabled", controllingSetting: "default", destination: "hosted", optOutInstruction: "Set DO_NOT_TRACK=1" }; + }, + }, + }, + }; +} + +try { + // `--ignore-scripts` deliberately prevents lifecycle execution during pack/install, so + // build explicitly first and never verify a stale bundle left by an earlier checkout. + runNpm(["run", "build"], { stdio: "pipe" }); + const packed = JSON.parse(runNpm(["pack", "--json", "--ignore-scripts"], { encoding: "utf8" })); + const filename = packed[0]?.filename; + if (typeof filename !== "string") throw new Error("npm pack did not return a tarball filename"); + tarball = join(process.cwd(), filename); + runNpm(["install", "--prefix", temp, "--ignore-scripts", tarball], { stdio: "pipe" }); + const bin = join(temp, "node_modules", "@legioncodeinc", "doctor", "bundle", "cli.js"); + const bundledSource = readFileSync(bin, "utf8"); + assert(!bundledSource.includes("DOCTOR_PACKED_"), "packed executable contains a shipped packed-test environment seam"); + const { dispatch } = await import(pathToFileURL(bin).href); + assert(typeof dispatch === "function", "packed executable does not expose the programmatic dispatcher"); + + const apiaryHome = join(temp, "apiary-home"); + const doctorDir = join(apiaryHome, "doctor"); + mkdirSync(doctorDir, { recursive: true }); + writeFileSync(join(doctorDir, "service.log"), [ + "2026-07-13T10:00:00Z DOCTOR_PACKED_LOG_IDENTITY", + "2026-07-13T10:00:01Z Authorization: Bearer packed-secret", + ].join("\n")); + for (const product of ["hive", "honeycomb", "nectar"]) { + const dir = join(apiaryHome, product); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "service.log"), `POISON_${product.toUpperCase()}\n`); + } + + const baseEnv = { ...process.env, APIARY_HOME: apiaryHome, NO_COLOR: "1" }; + const run = (args, extraEnv = {}) => spawnSync(process.execPath, [bin, ...args], { + encoding: "utf8", + env: { ...baseEnv, ...extraEnv }, + stdio: ["ignore", "pipe", "pipe"], + maxBuffer: 4 * 1024 * 1024, + }); + const expectExit = (args, expected, extraEnv = {}) => { + const result = run(args, extraEnv); + assert(result.error === undefined, `${args.join(" ")} failed to spawn: ${result.error?.message}`); + assert(result.status === expected, `${args.join(" ")} exited ${result.status}, expected ${expected}: ${result.stdout}${result.stderr}`); + return result; + }; + const expectJson = (args, expected, extraEnv = {}) => { + const result = expectExit([...args, "--json"], expected, extraEnv); + assert(result.stderr === "", `${args.join(" ")} JSON wrote stderr: ${result.stderr}`); + return JSON.parse(result.stdout.trim()); + }; + const runFixture = async (args, expected, options = {}) => { + const fixture = fixtureContext(args, options); + const status = await dispatch(args, fixture.ctx); + assert(status === expected, `${args.join(" ")} fixture exited ${status}, expected ${expected}: ${fixture.stdout.join("\n")}${fixture.stderr.join("\n")}`); + return fixture; + }; + const expectFixtureJson = async (args, expected, options = {}) => { + const fixture = await runFixture([...args, "--json"], expected, options); + assert(fixture.stderr.length === 0, `${args.join(" ")} fixture JSON wrote stderr: ${fixture.stderr.join("\n")}`); + return JSON.parse(fixture.stdout.join("\n")); + }; + + const help = expectExit(["--help"], 0).stdout; + assert(expectExit([], 0).stdout === help, "bare invocation and --help differ"); + assert(help.includes("DOCTOR") && help.includes("Legion Code Inc. x Activeloop"), "packed help lacks standard brand anatomy"); + assert((help.match(/Legion Code Inc\. x Activeloop/gu) ?? []).length === 1, "packed help does not contain exactly one credit line"); + assert(!help.includes("LEGION CODE INC.") && !help.includes("powered by deeplake.ai"), "packed art contains legacy partner prose"); + for (const command of ["start", "stop", "restart", "install", "uninstall", "service-install", "service-uninstall", "update", "status", "logs", "telemetry"]) { + assert(new RegExp(`^\\s*${command}\\s`, "mu").test(help), `packed help omits ${command}`); + } + assert(!/^\s*register\s/mu.test(help), "Doctor help advertises exempt register command"); + + const manifest = JSON.parse(readFileSync(join(process.cwd(), "package.json"), "utf8")); + assert(expectExit(["--version"], 0).stdout === `doctor v${manifest.version}\n`, "unexpected packed version output"); + assert(expectJson(["--version"], 0).version === manifest.version, "packed JSON version mismatch"); + assert(expectJson(["register"], 2).ok === false, "Doctor register exemption did not return JSON usage failure"); + assert(expectJson(["start", "--bogus"], 2).ok === false, "malformed start option was accepted"); + assert(expectExit(["telemetry", "--bogus"], 2).status === 2, "malformed telemetry option was accepted"); + assert(expectJson(["logs", "--lines", "0", "--no-follow"], 2).ok === false, "malformed logs option was not usage exit 2"); + + const successCases = [ + { args: ["start"], options: { initialState: "not-running" } }, + { args: ["stop"], options: { initialState: "running" } }, + { args: ["restart"], options: { initialState: "running" } }, + { args: ["install"] }, + { args: ["uninstall", "--yes"] }, + { args: ["service-install"] }, + { args: ["service-uninstall"] }, + { args: ["update", "--check"] }, + { args: ["status"] }, + { args: ["logs", "--no-follow"] }, + { args: ["telemetry"] }, + ]; + for (const { args, options } of successCases) { + const human = await runFixture(args, 0, options); + assert(human.stdout.join("\n").trim() !== "", `${args.join(" ")} fixture human success emitted no output`); + const payload = await expectFixtureJson(args, 0, options); + assert(payload.product === "doctor" && payload.ok === true, `${args.join(" ")} fixture JSON success envelope is invalid`); + } + + const logs = expectExit(["logs", "--no-follow"], 0).stdout; + assert(logs.includes("DOCTOR_PACKED_LOG_IDENTITY") && logs.includes("[REDACTED]"), "packed logs lack Doctor identity or redaction"); + for (const product of ["HIVE", "HONEYCOMB", "NECTAR"]) assert(!logs.includes(`POISON_${product}`), `packed logs leaked ${product}`); + assert(expectJson(["logs", "--no-follow"], 0).lines.some((line) => line.includes("DOCTOR_PACKED_LOG_IDENTITY")), "packed JSON logs lack Doctor identity"); + + for (const args of [["start"], ["stop"], ["restart"], ["install"], ["uninstall", "--yes"], ["service-install"], ["service-uninstall"], ["update"], ["status"], ["logs", "--no-follow"], ["telemetry"]]) { + const failure = args[0]; + await runFixture(args, 1, { failure, initialState: failure === "start" ? "not-running" : "running" }); + const payload = await expectFixtureJson(args, 1, { failure, initialState: failure === "start" ? "not-running" : "running" }); + assert(payload.product === "doctor" && payload.ok === false, `${args.join(" ")} fixture JSON failure envelope is invalid`); + } + + const optedOut = await expectFixtureJson(["telemetry"], 0, { optedOut: true }); + assert(optedOut.telemetry.state === "opted-out" && optedOut.telemetry.destination === "disabled", "packed dispatcher opted-out telemetry is incorrect"); + const unhealthy = await expectFixtureJson(["status"], 0, { unhealthy: true, initialState: "not-running" }); + assert(unhealthy.status.process.state === "stopped" && JSON.stringify(unhealthy.status.details).includes("unreachable"), "packed dispatcher unhealthy/stopped status is incorrect"); +} finally { + if (tarball) rmSync(tarball, { force: true }); + rmSync(temp, { recursive: true, force: true }); +} diff --git a/src/cli/banner.ts b/src/cli/banner.ts index 5601d11..f6c039c 100644 --- a/src/cli/banner.ts +++ b/src/cli/banner.ts @@ -1,63 +1,51 @@ -/** - * The Doctor banner + command menu (PRD-064f AC-064f.1 / parent AC-7). - * - * A clean attribution banner - the Legion Code Inc. and Activeloop wordmarks with a - * collaboration line - rendered on bare invocation, followed by a focused command menu. - * Aligned with the branded Honeycomb CLI voice (warm, amber, concise). ASCII-only by - * design so it renders identically on every terminal and code page (no box-drawing or - * exotic glyphs to mojibake). The `[LC]` / `[AL]` marks are plain monogram stand-ins for - * the real brand logos, which a terminal cannot render; swap in proper glyphs if desired. - * Colorized through the injected {@link Colors} surface so it degrades cleanly under - * NO_COLOR / non-TTY. - * - * The banner and the menu are pure string builders (no I/O), so a test can capture the - * exact output without spawning a process. - */ +import { renderGroupedHelp, renderProductBanner, type ProductBrand } from "@legioncodeinc/cli-kit"; import type { Colors } from "./colors.js"; -import { COMMAND_MENU } from "./command-table.js"; +import { DOCTOR_MANIFEST } from "./command-table.js"; import { DOCTOR_VERSION } from "../version.js"; -/** - * The attribution banner: the two wordmarks (with monogram marks) on one ruled line, - * then the collaboration line. ASCII-only so it survives any code page. - */ -const ART = String.raw` - ================================================================ - [LC] LEGION CODE INC. [AL] ACTIVELOOP - ================================================================ - A collaboration between Legion Code Inc. x Activeloop, - powered by deeplake.ai -`; - -/** Product identity line shown beneath the collaboration banner. */ -const NAME = "Doctor"; - -/** Build the full banner: collaboration banner + product name + version. Pure; colorized via `colors`. */ -export function renderBanner(colors: Colors): string { - const art = colors.amber(ART.replace(/^\n/, "")); - const name = colors.bold(NAME); - const version = colors.dim(`v${DOCTOR_VERSION}`); - return `${art}\n${name} ${version}\n`; -} - -/** Build the command-menu block from the single-sourced command table. Pure. */ -export function renderMenu(colors: Colors): string { - const header = colors.bold("Usage:"); - const usage = ` ${colors.cyan("doctor")} ${colors.dim(" [options]")}`; - const commandsHeader = colors.bold("Commands:"); - - // Right-pad the command column so the descriptions align. - const width = COMMAND_MENU.reduce((max, e) => Math.max(max, e.invocation.length), 0); - const lines = COMMAND_MENU.map((e) => { - const name = colors.cyan(e.invocation.padEnd(width)); - return ` ${name} ${e.summary}`; +/** Doctor-specific medical/watchdog motif, ASCII-only and comfortably below 80 columns. */ +export const DOCTOR_BRAND: ProductBrand = Object.freeze({ + executable: "doctor", + name: "DOCTOR", + descriptor: "Doctor - Apiary service health and diagnostics", + art: [ + " .--------.", + " ___| + |___", + " / | /|\\ | \\", + " | | / | \\ | |", + " \\___| | |___/", + " '--------'", + ].join("\n"), +}); + +/** Pure, shared-frame help renderer. Color remains a compatibility parameter. */ +export function renderBanner(_colors: Colors, width = 80): string { + const full = renderProductBanner({ + brand: DOCTOR_BRAND, + version: DOCTOR_VERSION, + manifest: { product: "doctor", commands: [] }, + width, }); + return full.split("\n\n")[0] ?? full; +} - return [header, usage, "", commandsHeader, ...lines, ""].join("\n"); +export function renderMenu(_colors: Colors, width = 80): string { + return [ + "Usage: doctor [options]", + "", + "Commands:", + renderGroupedHelp(DOCTOR_MANIFEST, width), + "", + "Global options", + " --help, -h Show help", + " --version Show version", + " --json Emit machine-readable output", + " --no-color Disable color", + ].join("\n"); } -/** Build the full bare-invocation output: banner + menu (AC-064f.1). */ -export function renderBannerWithMenu(colors: Colors): string { - return `${renderBanner(colors)}\n${renderMenu(colors)}`; +export function renderBannerWithMenu(_colors: Colors, width = 80): string { + return _colors.amber(renderProductBanner({ brand: DOCTOR_BRAND, version: DOCTOR_VERSION, manifest: DOCTOR_MANIFEST, width })) + .replace("\nService lifecycle", "\n\nCommands:\nService lifecycle"); } diff --git a/src/cli/bin.ts b/src/cli/bin.ts index dee6ba2..cfcaab1 100644 --- a/src/cli/bin.ts +++ b/src/cli/bin.ts @@ -23,19 +23,30 @@ */ import { runCli } from "./index.js"; +import { isDirectInvocation } from "./direct-invocation.js"; import { finalizeOneShot, isOneShot } from "./shutdown.js"; -const argv = process.argv.slice(2); -const code = await runCli(argv); +// Programmatic conformance tests may import the packed artifact and inject a hermetic +// context into the real dispatcher. Importing the executable must never run production +// assembly as a side effect; direct bin execution remains the only path into runCli. +export { dispatch } from "./dispatch.js"; -if (!isOneShot(argv)) { - // The `run` watchdog already stopped its own loops; exit plainly (do NOT run the one-shot - // fetch-pool teardown - the service lifecycle owns this process's shutdown). - process.exit(code); -} +const invokedPath = process.argv[1]; +const directlyInvoked = isDirectInvocation(invokedPath, import.meta.url); + +if (directlyInvoked) { + const argv = process.argv.slice(2); + const code = await runCli(argv); -// One-shot path: tear down the fetch connection pool, release lingering handles, set the exit -// code, and let the process exit naturally (a bounded backstop force-exits only if the loop -// refuses to drain). Calling process.exit() directly here is what trips the Windows -// UV_HANDLE_CLOSING assertion, so the happy path never forces it. -await finalizeOneShot(code); + if (!isOneShot(argv)) { + // The `run` watchdog already stopped its own loops; exit plainly (do NOT run the one-shot + // fetch-pool teardown - the service lifecycle owns this process's shutdown). + process.exit(code); + } + + // One-shot path: tear down the fetch connection pool, release lingering handles, set the exit + // code, and let the process exit naturally (a bounded backstop force-exits only if the loop + // refuses to drain). Calling process.exit() directly here is what trips the Windows + // UV_HANDLE_CLOSING assertion, so the happy path never forces it. + await finalizeOneShot(code); +} diff --git a/src/cli/command-table.ts b/src/cli/command-table.ts index 7809ee1..bd25fbc 100644 --- a/src/cli/command-table.ts +++ b/src/cli/command-table.ts @@ -1,86 +1,90 @@ -/** - * The single-sourced Doctor command surface (PRD-064f Scope command table). - * - * ONE list defines every command name, its menu summary, and the canonical set of - * known commands. The dispatcher ({@link file://./dispatch.ts}) and the banner menu - * ({@link file://./banner.ts}) both read this list, so the menu can never drift from - * what actually dispatches. - * - * Binding rulings encoded here (PRD-064 Decisions, OD-4): - * - There is NO `clear-credentials` command. Credential purge is DEFERRED, not in v1; - * it is only ever RECOMMENDED via escalation, never offered as a command (AC-064f.4). - * - `self-update` is the ONLY command that updates Doctor's own package (AC-064f.5). - * - * Pure data + a tiny lookup; no I/O. Built-ins only. - */ +import { + composeProductManifest, + resolveCommand as resolveManifestCommand, + type CommandSpec, + type ProductManifest, +} from "@legioncodeinc/cli-kit"; -/** Every command Doctor's CLI dispatches. The string union is the closed set. */ export type CommandName = - | "run" + | "start" + | "stop" + | "restart" | "status" + | "logs" + | "install" + | "uninstall" + | "service-install" + | "service-uninstall" + | "update" + | "telemetry" + | "run" | "diagnose" | "heal" - | "restart" | "reinstall" | "uninstall-hivemind" - | "update" + | "daemon-update" | "self-update" - | "install-service" - | "uninstall-service" - | "start" - | "stop" - | "uninstall" | "purge" - | "logs" + | "incidents" | "help"; -/** One row of the command menu (name + one-line summary). */ +type ProductCommand = Omit; + +const productCommand = ( + name: CommandName, + summary: string, + destructive = false, +): ProductCommand => ({ name, summary, destructive, idempotent: !destructive, json: true }); + +const PRODUCT_COMMANDS: readonly ProductCommand[] = [ + productCommand("run", "Run the watchdog service entry"), + productCommand("diagnose", "Classify supervised-service health without changing it"), + productCommand("heal", "Run the remediation ladder once"), + productCommand("reinstall", "Reinstall the supervised primary daemon", true), + productCommand("uninstall-hivemind", "Remove a conflicting Hivemind package", true), + productCommand("daemon-update", "Update the supervised primary daemon"), + productCommand("self-update", "Deprecated alias for the canonical update command"), + productCommand("purge", "Remove all Apiary products and state", true), + productCommand("incidents", "Read Doctor's fleet incident records"), + productCommand("help", "Show this help"), +]; + +/** Shared baseline plus Doctor-only commands. Doctor intentionally omits register. */ +export const DOCTOR_MANIFEST: ProductManifest = composeProductManifest("doctor", PRODUCT_COMMANDS); + export interface CommandMenuEntry { - /** How the command is invoked on the menu (the bare command name). */ readonly invocation: CommandName; - /** A one-line summary shown in the menu. */ readonly summary: string; } -/** - * The command menu, in display order. This is the dispatch surface and the menu surface. - * Deliberately ABSENT: `clear-credentials` (deferred, OD-4 / AC-064f.4). - */ -export const COMMAND_MENU: readonly CommandMenuEntry[] = [ - { invocation: "run", summary: "Run the supervised watchdog (the OS service entry; not for manual use)." }, - { invocation: "status", summary: "Daemon health, service state, versions, last heal, opt-out flags." }, - { invocation: "diagnose", summary: "Classify health and print the recommended fix - takes no action." }, - { invocation: "heal", summary: "Run the remediation ladder once (gated steps confirm)." }, - { invocation: "restart", summary: "Restart the primary daemon (rung 1)." }, - { invocation: "reinstall", summary: "Reinstall the primary daemon (rung 2)." }, - { invocation: "uninstall-hivemind", summary: "Remove a conflicting @deeplake/hivemind global (rung 3, confirms)." }, - { invocation: "update", summary: "Update the primary daemon via the blessed gate (--check to preview)." }, - { invocation: "self-update", summary: "Update Doctor's own package (the ONLY path that does)." }, - { invocation: "install-service", summary: "Register Doctor as an OS service (064b)." }, - { invocation: "uninstall-service", summary: "Unregister the Doctor OS service (064b)." }, - { invocation: "start", summary: "Start Doctor's own watchdog service (PRD-003b)." }, - { invocation: "stop", summary: "Stop Doctor's own watchdog service (PRD-003b)." }, - { - invocation: "uninstall", - summary: "Remove Doctor's service, registry entry, and state dir (keeps the npm package; PRD-003b).", - }, - { - invocation: "purge", - summary: "DESTRUCTIVE: wipe every Apiary service, package, and state dir on this machine, Doctor last (PRD-003c).", - }, - { invocation: "logs", summary: "Tail incident logs for all daemons, or one via --daemon ." }, - { invocation: "help", summary: "Show this banner and command menu." }, -]; +/** Compatibility export retained for callers which inspect the old menu constant. */ +export const COMMAND_MENU: readonly CommandMenuEntry[] = DOCTOR_MANIFEST.commands.map((entry) => ({ + invocation: entry.name as CommandName, + summary: entry.summary, +})); -/** The set of known command names, derived from the menu so it cannot drift. */ -export const KNOWN_COMMANDS: ReadonlySet = new Set(COMMAND_MENU.map((e) => e.invocation)); +export const KNOWN_COMMANDS: ReadonlySet = new Set([ + ...DOCTOR_MANIFEST.commands.flatMap((entry) => [entry.name, ...(entry.aliases?.map(({ name }) => name) ?? [])]), + "help", +]); -/** - * Resolve a raw first-arg token to a {@link CommandName}, or null when unknown. - * `undefined`/empty (bare invocation) resolves to null so the caller renders the banner. - */ -export function resolveCommand(token: string | undefined): CommandName | null { +export interface ResolvedDoctorCommand { + readonly command: CommandName; + readonly deprecatedAlias?: string; +} + +export function resolveCommandDetailed(token: string | undefined): ResolvedDoctorCommand | null { if (token === undefined || token.trim() === "") return null; - const t = token.trim(); - return KNOWN_COMMANDS.has(t) ? (t as CommandName) : null; + if (token === "help") return { command: "help" }; + const resolution = resolveManifestCommand(DOCTOR_MANIFEST, token.trim()); + if (!resolution.ok) return null; + return { + command: resolution.canonicalName as CommandName, + ...(resolution.deprecatedAlias === undefined ? {} : { deprecatedAlias: resolution.deprecatedAlias }), + }; +} + +/** Legacy lookup shape retained for existing Doctor integrations. */ +export function resolveCommand(token: string | undefined): CommandName | null { + return resolveCommandDetailed(token)?.command ?? null; } diff --git a/src/cli/context.ts b/src/cli/context.ts index 6db3e75..3d56222 100644 --- a/src/cli/context.ts +++ b/src/cli/context.ts @@ -21,6 +21,7 @@ import type { LadderDecision, RemediationLadder, RungContext } from "../remediat import type { LifecycleTelemetry } from "../telemetry/capture.js"; import type { ServiceLifecycleModule, ServiceModule, ServiceResult } from "./service-stub.js"; import type { ResolvedOptOut } from "./opt-out.js"; +import type { LogTailResult, TelemetrySummary } from "@legioncodeinc/cli-kit"; /** A captured line of output (text + which stream it went to). */ export interface OutputLine { @@ -64,11 +65,20 @@ export interface UpdateActions { * only command that ever calls it. */ selfUpdate(): Promise; + /** Preview Doctor's own approved release without installing it. */ + readonly checkSelfUpdate?: () => Promise; } /** Reads the recent incident-log lines for `logs`. */ export type TailIncidentsFn = (limit: number, daemonName?: string) => Promise; +/** Tail only Doctor's authoritative OS-service log. */ +export type TailServiceLogsFn = ( + argv: readonly string[], + write: (line: string) => void, + signal?: AbortSignal, +) => Promise; + /** Snapshot of the persisted state the `status` command reports (read defensively). */ export interface StatusStateSnapshot { /** The last confirmed heal time (ISO-8601), or null. */ @@ -176,6 +186,12 @@ export interface CliDeps { readonly update: UpdateActions; /** Tail recent incident-log lines for `logs`. */ readonly tailIncidents: TailIncidentsFn; + /** Canonical service-log tailer; deliberately separate from fleet incident records. */ + readonly tailServiceLogs?: TailServiceLogsFn; + /** Doctor-owned paths used by the common status contract. */ + readonly paths?: { readonly config: string; readonly logs: string }; + /** Read-only common telemetry summary. */ + readonly telemetrySummary?: () => TelemetrySummary; /** * The 064b service module, when wired in. When absent, `install-service`/`uninstall-service` * print the "not yet available" stub message. The composition root injects the real module. @@ -186,6 +202,8 @@ export interface CliDeps { * and `stop` print the same "not yet available" stub message `install-service` does. */ readonly serviceLifecycle?: ServiceLifecycleModule; + /** Injectable bounded-poll delay; production uses a timer and tests use an immediate seam. */ + readonly lifecycleSleep?: (milliseconds: number) => Promise; /** * The full three-part `uninstall` seam (PRD-003b b-AC-2/3/4/6), when wired in. When * absent, `uninstall` prints the "not yet available" stub message. `uninstall-service` diff --git a/src/cli/direct-invocation.ts b/src/cli/direct-invocation.ts new file mode 100644 index 0000000..2c6c982 --- /dev/null +++ b/src/cli/direct-invocation.ts @@ -0,0 +1,26 @@ +import { realpathSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +type CanonicalizePath = (path: string) => string; + +/** + * Decide whether this module is the process entrypoint without trusting textual path equality. + * macOS exposes `/var` through the `/private/var` symlink, and package-manager bins may also be + * invoked through symlinks. Canonicalizing both existing files keeps those legitimate entrypoints + * executable while imports from a different script remain side-effect free. + */ +export function isDirectInvocation( + invokedPath: string | undefined, + moduleUrl: string, + canonicalize: CanonicalizePath = realpathSync, +): boolean { + if (invokedPath === undefined) return false; + const resolvedInvocation = resolve(invokedPath); + try { + return canonicalize(resolvedInvocation) === canonicalize(fileURLToPath(moduleUrl)); + } catch { + // Preserve the previous safe behavior when either path disappears between lookup and use. + return moduleUrl === pathToFileURL(resolvedInvocation).href; + } +} diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index a503563..9a018b0 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -22,17 +22,107 @@ import { parseArgs, hasFlag, type ParsedArgs } from "./arg-parse.js"; import { renderBannerWithMenu } from "./banner.js"; -import { resolveCommand, type CommandName } from "./command-table.js"; +import { resolveCommandDetailed, type CommandName } from "./command-table.js"; import { DOCTOR_VERSION } from "../version.js"; import type { CliContext, ServiceState } from "./context.js"; import { SERVICE_NOT_AVAILABLE } from "./service-stub.js"; import type { HealthClassification } from "../health-probe.js"; +import { + formatStatus, + formatTelemetrySummary, + parseLogTailOptions, + redactLogSecrets, + renderVersion, + renderVersionJson, + type ServiceStatus, +} from "@legioncodeinc/cli-kit"; /** Exit codes the dispatcher returns. 0 = ok; 1 = handler error; 2 = user declined a gate. */ export const EXIT_OK = 0; export const EXIT_ERROR = 1; export const EXIT_DECLINED = 2; +type OptionKind = "boolean" | "value"; + +const GLOBAL_OPTIONS: Readonly> = Object.freeze({ + help: "boolean", + version: "boolean", + json: "boolean", + "no-color": "boolean", +}); + +const COMMAND_OPTIONS: Readonly>>> = Object.freeze({ + start: {}, stop: {}, restart: {}, status: {}, install: {}, "service-install": {}, + "service-uninstall": {}, telemetry: {}, run: {}, diagnose: {}, "self-update": {}, help: {}, + logs: { lines: "value", since: "value", "no-follow": "boolean" }, + uninstall: { yes: "boolean" }, + update: { check: "boolean" }, + heal: { yes: "boolean" }, + reinstall: { yes: "boolean" }, + "uninstall-hivemind": { yes: "boolean" }, + "daemon-update": { check: "boolean" }, + purge: { yes: "boolean" }, + incidents: { lines: "value", daemon: "value" }, +}); + +function optionArgv(parsed: ParsedArgs, ignored: ReadonlySet = new Set()): string[] { + const result: string[] = []; + for (const [name, value] of Object.entries(parsed.flags)) { + if (ignored.has(name)) continue; + result.push(`--${name}`); + if (typeof value === "string") result.push(value); + } + result.push(...parsed.positionals); + return result; +} + +function validateInvocation(command: CommandName, parsed: ParsedArgs): string | null { + if (parsed.positionals.length > 0) { + return `${command} does not accept positional arguments: ${parsed.positionals.join(" ")}`; + } + const commandOptions = COMMAND_OPTIONS[command]; + for (const [name, value] of Object.entries(parsed.flags)) { + const kind = commandOptions[name] ?? GLOBAL_OPTIONS[name]; + if (kind === undefined) return `Unknown option for ${command}: --${name}`; + if (kind === "boolean" && value !== true) return `Option --${name} does not accept a value.`; + if (kind === "value" && (typeof value !== "string" || value.trim() === "")) { + return `Option --${name} requires a value.`; + } + } + if (command === "logs") { + const result = parseLogTailOptions(optionArgv(parsed, new Set(Object.keys(GLOBAL_OPTIONS)))); + if (!result.ok) return result.error; + } + if (command === "incidents") { + const lines = parsed.flags["lines"]; + if (lines !== undefined && (typeof lines !== "string" || !/^\d+$/u.test(lines) || Number(lines) < 1)) { + return "--lines must be a positive integer."; + } + } + return null; +} + +function validateBareInvocation(parsed: ParsedArgs): string | null { + for (const [name, value] of Object.entries(parsed.flags)) { + const kind = GLOBAL_OPTIONS[name]; + if (kind === undefined) return `Unknown global option: --${name}`; + if (kind === "boolean" && value !== true) return `Option --${name} does not accept a value.`; + } + return null; +} + +function usageFailure(ctx: CliContext, command: string, message: string, json: boolean): number { + const full = `${message} Run 'doctor ${command || "--help"} --help' for usage.`; + if (json) ctx.io.out(JSON.stringify({ product: "doctor", command, ok: false, message: full })); + else ctx.io.err(ctx.colors.red(full)); + return EXIT_DECLINED; +} + +interface DoctorStatusResult { + readonly status: ServiceStatus; + readonly daemonDetails: readonly string[]; +} + /** Map a classification to a short human label for `status` / `diagnose`. */ function healthLabel(c: HealthClassification): string { switch (c.kind) { @@ -55,8 +145,8 @@ function isUnhealthy(c: HealthClassification): boolean { } /** `status` (AC-064f.2 / AC-064f.6): health, service state, both versions, last heal, opt-out. */ -async function runStatus(ctx: CliContext): Promise { - const { io, colors, deps } = ctx; +async function inspectStatus(ctx: CliContext): Promise { + const { deps } = ctx; const daemonSources = deps.statusDaemons(); // The service state prefers the bounded ASYNC probe (wired to serviceStatus in the composition // root, IRD-192 AC-5) so a registered task reports its real state, never a hardcoded "unknown". @@ -64,9 +154,13 @@ async function runStatus(ctx: CliContext): Promise { // The sync serviceState() seam is the test-harness fallback when the async probe is not injected. const serviceState = deps.serviceStateAsync !== undefined ? await deps.serviceStateAsync() : deps.serviceState(); - io.out(colors.bold("Doctor status")); - io.out(` Doctor service: ${colors.cyan(serviceState)}`); - io.out(` Doctor version: ${deps.doctorVersion}`); + let installed = serviceState !== "unknown"; + try { + if (deps.productUninstall !== undefined) installed = await deps.productUninstall.isServiceRegistered(); + } catch { + installed = serviceState !== "unknown"; + } + const daemonDetails: string[] = []; for (const daemon of daemonSources) { let classification: HealthClassification = { kind: "unreachable-timeout" }; try { @@ -92,22 +186,35 @@ async function runStatus(ctx: CliContext): Promise { state = { lastHealAt: null, lastKnownHealth: "unknown" }; } - io.out(""); - io.out(colors.bold(`Daemon: ${daemon.name}`)); - io.out(` Daemon health: ${colors.cyan(healthLabel(classification))}`); - io.out(` Daemon version: ${daemonVersion ?? colors.dim("unknown (daemon unreachable)")}`); - io.out(` Last heal: ${state.lastHealAt ?? colors.dim("never")}`); + daemonDetails.push( + `Daemon: ${daemon.name}\n Daemon health: ${healthLabel(classification)}\n Daemon version: ${daemonVersion ?? "unknown (daemon unreachable)"}\n Last heal: ${state.lastHealAt ?? "never"}`, + ); } // Opt-out flags - honest about which layer disabled auto-update (OD-5 / AC-064e.4). - const autoUpdate = deps.optOut.autoUpdateDisabled - ? colors.yellow(`disabled (${deps.optOut.source})`) - : colors.green("enabled"); - io.out(""); - io.out(` Auto-update: ${autoUpdate}`); + const autoUpdate = deps.optOut.autoUpdateDisabled ? `disabled (${deps.optOut.source})` : "enabled"; + daemonDetails.push(`Auto-update: ${autoUpdate}`); if (deps.optOut.pinnedVersion !== undefined) { - io.out(` Pinned version: ${deps.optOut.pinnedVersion}`); + daemonDetails.push(`Pinned version: ${deps.optOut.pinnedVersion}`); } + return { + status: { + product: "DOCTOR", + version: deps.doctorVersion, + installation: installed ? "installed" : "not-installed", + process: { state: serviceState === "running" ? "running" : serviceState === "not-running" ? "stopped" : "unknown" }, + health: { state: "not-applicable", result: "Doctor is the health observer" }, + registration: "not-applicable", + paths: deps.paths ?? { config: "unknown", logs: "unknown" }, + details: Object.fromEntries(daemonDetails.map((line, index) => [`detail${index + 1}`, line])), + }, + daemonDetails, + }; +} + +async function runStatus(ctx: CliContext): Promise { + const result = await inspectStatus(ctx); + ctx.io.out(formatStatus(result.status).trimEnd()); return EXIT_OK; } @@ -162,7 +269,7 @@ async function runHeal(ctx: CliContext, parsed: ParsedArgs): Promise { const result = await deps.ladder.run(decision.rung, deps.rungContextFor(classification)); const outcome = result.skipped === true ? "skipped" : result.ok ? "succeeded" : "failed"; - io.out(`Ran ${colors.cyan(result.action)} -> ${outcome}${result.detail ? ` (${result.detail})` : ""}`); + io.out(`Ran ${colors.cyan(redactLogSecrets(result.action))} -> ${outcome}${result.detail ? ` (${redactLogSecrets(result.detail)})` : ""}`); return result.ok || result.skipped === true ? EXIT_OK : EXIT_ERROR; } @@ -179,7 +286,7 @@ async function runRung(ctx: CliContext, rung: number, gated: boolean, parsed: Pa const classification = await deps.probe(); const result = await deps.ladder.run(rung, deps.rungContextFor(classification)); const outcome = result.skipped === true ? "skipped" : result.ok ? "succeeded" : "failed"; - io.out(`Ran ${colors.cyan(result.action)} -> ${outcome}${result.detail ? ` (${result.detail})` : ""}`); + io.out(`Ran ${colors.cyan(redactLogSecrets(result.action))} -> ${outcome}${result.detail ? ` (${redactLogSecrets(result.detail)})` : ""}`); return result.ok || result.skipped === true ? EXIT_OK : EXIT_ERROR; } @@ -195,21 +302,35 @@ async function runUninstallHivemind(ctx: CliContext, parsed: ParsedArgs): Promis return runRung(ctx, 3, true, parsed); } -/** `update [--check]`: primary-daemon update via the blessed gate (064e). */ +/** Canonical `update`: Doctor's own package. The supervised-daemon updater is `daemon-update`. */ async function runUpdate(ctx: CliContext, parsed: ParsedArgs): Promise { const { io, deps } = ctx; if (hasFlag(parsed, "check")) { - io.out(await deps.update.checkPrimaryUpdate()); + io.out(redactLogSecrets( + deps.update.checkSelfUpdate === undefined + ? `Doctor installed version: ${deps.doctorVersion}; run 'doctor update' to resolve the approved latest release.` + : await deps.update.checkSelfUpdate(), + )); return EXIT_OK; } - io.out(await deps.update.applyPrimaryUpdate()); - return EXIT_OK; + const message = await deps.update.selfUpdate(); + io.out(redactLogSecrets(message)); + return /failed/iu.test(message) ? EXIT_ERROR : EXIT_OK; } -/** `self-update` (AC-064f.5): THE ONLY path that updates Doctor's own package. */ +/** Compatibility alias for canonical update. */ async function runSelfUpdate(ctx: CliContext): Promise { - ctx.io.out(await ctx.deps.update.selfUpdate()); - return EXIT_OK; + ctx.io.out("Warning: 'self-update' is deprecated; use 'update'."); + return runUpdate(ctx, { command: "update", flags: {}, positionals: [] }); +} + +/** Preserve the pre-standard primary-daemon update under an explicit product command. */ +async function runDaemonUpdate(ctx: CliContext, parsed: ParsedArgs): Promise { + const message = hasFlag(parsed, "check") + ? await ctx.deps.update.checkPrimaryUpdate() + : await ctx.deps.update.applyPrimaryUpdate(); + ctx.io.out(redactLogSecrets(message)); + return /failed|rolled_back/iu.test(message) ? EXIT_ERROR : EXIT_OK; } /** @@ -226,8 +347,8 @@ async function runSelfUpdate(ctx: CliContext): Promise { async function runService(ctx: CliContext, kind: "install" | "uninstall"): Promise { const { io, deps } = ctx; if (deps.serviceModule === undefined) { - io.out(SERVICE_NOT_AVAILABLE); - return EXIT_OK; + io.err(SERVICE_NOT_AVAILABLE); + return EXIT_ERROR; } if (kind === "uninstall" && deps.lifecycle !== undefined) { // Fire-and-forget BEFORE teardown: the emitter never throws/rejects, and the POST @@ -235,7 +356,7 @@ async function runService(ctx: CliContext, kind: "install" | "uninstall"): Promi void deps.lifecycle.uninstalled(); } const result = kind === "install" ? await deps.serviceModule.install() : await deps.serviceModule.uninstall(); - io.out(result.message); + io.out(redactLogSecrets(result.message)); if (kind === "install" && result.ok && deps.lifecycle !== undefined) { // Once per machine: the emitter checks + persists the state-store marker itself. await deps.lifecycle.installed(); @@ -243,16 +364,90 @@ async function runService(ctx: CliContext, kind: "install" | "uninstall"): Promi return result.ok ? EXIT_OK : EXIT_ERROR; } +async function waitForServiceState(ctx: CliContext, expected: "running" | "not-running"): Promise { + const probe = ctx.deps.productUninstall?.serviceStatusAsync ?? ctx.deps.serviceStateAsync; + if (probe === undefined) return false; + for (let attempt = 0; attempt < 20; attempt += 1) { + if ((await probe()) === expected) return true; + if (attempt < 19) { + await (ctx.deps.lifecycleSleep?.(100) ?? new Promise((resolve) => setTimeout(resolve, 100))); + } + } + return false; +} + /** `start` / `stop` (PRD-003b b-AC-1): front doctor's OWN OS service, when 003b is wired. */ async function runStartStop(ctx: CliContext, kind: "start" | "stop"): Promise { const { io, deps } = ctx; if (deps.serviceLifecycle === undefined) { - io.out(SERVICE_NOT_AVAILABLE); - return EXIT_OK; + io.err(SERVICE_NOT_AVAILABLE); + return EXIT_ERROR; + } + if (deps.productUninstall !== undefined) { + const registered = await deps.productUninstall.isServiceRegistered(); + if (!registered) { + if (kind === "stop") { + io.out("Doctor service is already stopped (not installed)."); + return EXIT_OK; + } + io.err("Doctor service is not installed; run 'doctor service-install' first."); + return EXIT_ERROR; + } + const state = await deps.productUninstall.serviceStatusAsync(); + if (kind === "start" && state === "running") { + io.out("Doctor service is already running."); + return EXIT_OK; + } + // Do not return early for an apparently stopped Windows task. Task Scheduler can + // report Ready after `/End` while its headless Node child remains alive. The real + // stop adapter is idempotent and also reaps only that exact Doctor child. } const result = kind === "start" ? await deps.serviceLifecycle.start() : await deps.serviceLifecycle.stop(); - io.out(result.message); - return result.ok ? EXIT_OK : EXIT_ERROR; + io.out(redactLogSecrets(result.message)); + if (!result.ok) return EXIT_ERROR; + const expected = kind === "start" ? "running" : "not-running"; + if (!(await waitForServiceState(ctx, expected))) { + io.err(`Doctor service ${kind} command completed but the service did not reach ${expected === "running" ? "running" : "stopped"} state before the timeout.`); + return EXIT_ERROR; + } + io.out(`Doctor service is confirmed ${expected === "running" ? "running" : "stopped"}.`); + return EXIT_OK; +} + +/** Restart Doctor's installed service and prove the manager reports it running. */ +async function runRestart(ctx: CliContext): Promise { + const lifecycle = ctx.deps.serviceLifecycle; + if (lifecycle === undefined || ctx.deps.productUninstall === undefined) { + ctx.io.err("Doctor service lifecycle is unavailable; restart was not attempted."); + return EXIT_ERROR; + } + if (!(await ctx.deps.productUninstall.isServiceRegistered())) { + ctx.io.err("Doctor service is not installed; run 'doctor service-install' first."); + return EXIT_ERROR; + } + const stopped = await lifecycle.stop(); + ctx.io.out(redactLogSecrets(stopped.message)); + if (!stopped.ok || !(await waitForServiceState(ctx, "not-running"))) { + ctx.io.err("Doctor service did not reach stopped state before restart timed out."); + return EXIT_ERROR; + } + const started = await lifecycle.start(); + ctx.io.out(redactLogSecrets(started.message)); + if (!started.ok) return EXIT_ERROR; + if (await waitForServiceState(ctx, "running")) { + ctx.io.out("Doctor service restarted and is running."); + return EXIT_OK; + } + ctx.io.err("Doctor service restarted but did not reach running state before the timeout."); + return EXIT_ERROR; +} + +/** Doctor onboarding: reconcile its service definition and report the phase explicitly. */ +async function runInstall(ctx: CliContext): Promise { + ctx.io.out("Onboarding: Doctor requires no login or fleet self-registration."); + const code = await runService(ctx, "install"); + if (code === EXIT_OK) ctx.io.out("Onboarding complete: Doctor service is installed."); + return code; } /** @@ -264,8 +459,8 @@ async function runStartStop(ctx: CliContext, kind: "start" | "stop"): Promise { const { io, colors, deps } = ctx; if (deps.productUninstall === undefined) { - io.out(SERVICE_NOT_AVAILABLE); - return EXIT_OK; + io.err(SERVICE_NOT_AVAILABLE); + return EXIT_ERROR; } const u = deps.productUninstall; @@ -302,7 +497,7 @@ async function runUninstall(ctx: CliContext): Promise { if (deps.lifecycle !== undefined) void deps.lifecycle.uninstalled(); const serviceResult = await u.serviceUninstall(); - io.out(serviceResult.message); + io.out(redactLogSecrets(serviceResult.message)); const { registryEntryRemoved, stateDirRemoved } = u.removeState(); io.out( @@ -338,7 +533,7 @@ async function runPurge(ctx: CliContext, parsed: ParsedArgs): Promise { if (!autoYes) { io.out(colors.bold("`doctor purge` will PERMANENTLY remove:")); - for (const line of p.summaryLines()) io.out(` - ${line}`); + for (const line of p.summaryLines()) io.out(redactLogSecrets(` - ${line}`)); io.out(""); const interactive = ctx.isInteractive?.() ?? false; @@ -359,11 +554,11 @@ async function runPurge(ctx: CliContext, parsed: ParsedArgs): Promise { } const report = await p.run(); - for (const line of report.lines) io.out(line); + for (const line of report.lines) io.out(redactLogSecrets(line)); return report.ok ? EXIT_OK : EXIT_ERROR; } -/** `logs`: tail the local incident log. */ +/** `incidents`: Doctor's product-specific fleet incident records (legacy `logs --daemon`). */ async function runLogs(ctx: CliContext, parsed: ParsedArgs): Promise { const { io, colors, deps } = ctx; const limitRaw = parsed.flags["lines"]; @@ -378,13 +573,44 @@ async function runLogs(ctx: CliContext, parsed: ParsedArgs): Promise { const lines = await deps.tailIncidents(limit, daemonName); if (lines.length === 0) { if (daemonName !== undefined) { - io.out(colors.dim(`No incidents recorded yet for daemon "${daemonName}".`)); + io.out(colors.dim(`No incidents recorded yet for daemon "${redactLogSecrets(daemonName)}".`)); } else { io.out(colors.dim("No incidents recorded yet.")); } return EXIT_OK; } - for (const line of lines) io.out(line); + for (const line of lines) io.out(redactLogSecrets(line)); + return EXIT_OK; +} + +/** Canonical logs: hard-bound to Doctor's own authoritative service log. */ +async function runServiceLogs(ctx: CliContext, parsed: ParsedArgs): Promise { + if (ctx.deps.tailServiceLogs === undefined) { + ctx.io.err("Doctor service log source is unavailable; no other product log was read."); + return EXIT_ERROR; + } + const args = optionArgv(parsed, new Set(Object.keys(GLOBAL_OPTIONS))); + const controller = new AbortController(); + const stop = (): void => controller.abort(); + process.once("SIGINT", stop); + try { + const result = await ctx.deps.tailServiceLogs(args, (line) => ctx.io.out(line.trimEnd()), controller.signal); + if (!result.ok) { + ctx.io.err(redactLogSecrets(result.error)); + return EXIT_ERROR; + } + return EXIT_OK; + } finally { + process.removeListener("SIGINT", stop); + } +} + +async function runTelemetry(ctx: CliContext): Promise { + if (ctx.deps.telemetrySummary === undefined) { + ctx.io.err("Doctor telemetry state is unavailable."); + return EXIT_ERROR; + } + ctx.io.out(formatTelemetrySummary(ctx.deps.telemetrySummary()).trimEnd()); return EXIT_OK; } @@ -410,18 +636,22 @@ async function route(command: CommandName, ctx: CliContext, parsed: ParsedArgs): case "heal": return runHeal(ctx, parsed); case "restart": - return runRung(ctx, 1, false, parsed); + return runRestart(ctx); case "reinstall": return runRung(ctx, 2, true, parsed); case "uninstall-hivemind": return runUninstallHivemind(ctx, parsed); case "update": return runUpdate(ctx, parsed); + case "daemon-update": + return runDaemonUpdate(ctx, parsed); case "self-update": return runSelfUpdate(ctx); - case "install-service": + case "install": + return runInstall(ctx); + case "service-install": return runService(ctx, "install"); - case "uninstall-service": + case "service-uninstall": return runService(ctx, "uninstall"); case "start": return runStartStop(ctx, "start"); @@ -432,7 +662,11 @@ async function route(command: CommandName, ctx: CliContext, parsed: ParsedArgs): case "purge": return runPurge(ctx, parsed); case "logs": + return runServiceLogs(ctx, parsed); + case "incidents": return runLogs(ctx, parsed); + case "telemetry": + return runTelemetry(ctx); case "help": return runHelp(ctx); default: { @@ -449,33 +683,117 @@ async function route(command: CommandName, ctx: CliContext, parsed: ParsedArgs): * mapped to {@link EXIT_ERROR}). */ export async function dispatch(argv: readonly string[], ctx: CliContext): Promise { - const parsed = parseArgs(argv); - const command = resolveCommand(parsed.command); + let parsed = parseArgs(argv); + const resolution = resolveCommandDetailed(parsed.command); + const json = hasFlag(parsed, "json"); + const invocationError = resolution === null + ? (parsed.command === undefined ? validateBareInvocation(parsed) : null) + : validateInvocation(resolution.command, parsed); + if (invocationError !== null) return usageFailure(ctx, resolution?.command ?? "", invocationError, json); + + const shortVersionOnly = (parsed.command === "-v" || parsed.command === "-V") && parsed.positionals.length === 0; + if ((resolution !== null && hasFlag(parsed, "version")) || (parsed.command === undefined && hasFlag(parsed, "version")) || shortVersionOnly) { + ctx.io.out((json ? renderVersionJson("doctor", DOCTOR_VERSION) : renderVersion("doctor", DOCTOR_VERSION)).trimEnd()); + return EXIT_OK; + } - // `--version` / `-v` / `-V` -> print just the version string and exit, BEFORE the - // bare-invocation banner fallback (otherwise `doctor --version` shows the banner). - if (hasFlag(parsed, "version") || argv.includes("-v") || argv.includes("-V")) { - ctx.io.out(DOCTOR_VERSION); + const shortHelpOnly = parsed.command === "-h" && parsed.positionals.length === 0; + if (resolution === null && (parsed.command === undefined || shortHelpOnly)) { + if (json) ctx.io.out(JSON.stringify({ product: "doctor", command: "help", ok: true, message: "help" })); + else return runHelp(ctx); + return EXIT_OK; + } + if (resolution !== null && (resolution.command === "help" || argv.includes("--help") || argv.includes("-h"))) { + if (json) ctx.io.out(JSON.stringify({ product: "doctor", command: "help", ok: true, message: "help" })); + else return runHelp(ctx); return EXIT_OK; } - // Bare invocation (no command) -> banner + menu (AC-064f.1). - if (command === null && parsed.command === undefined) { - return runHelp(ctx); + if (resolution === null) { + const name = parsed.command ?? ""; + const message = `Unknown command: ${name}. Run 'doctor --help' for usage.`; + if (json) ctx.io.out(JSON.stringify({ product: "doctor", command: name, ok: false, message })); + else ctx.io.err(ctx.colors.red(message)); + return 2; } - // An UNKNOWN command token: print a short error + the menu, exit non-zero. - if (command === null) { - ctx.io.err(ctx.colors.red(`Unknown command: ${parsed.command ?? ""}`)); - ctx.io.out(renderBannerWithMenu(ctx.colors)); - return EXIT_ERROR; + if (resolution.deprecatedAlias !== undefined && !json) { + ctx.io.err(`Warning: '${resolution.deprecatedAlias}' is deprecated; use '${resolution.command}'.`); + } + + const command = resolution.command; + if (command === "uninstall" && !hasFlag(parsed, "yes")) { + const message = "Doctor uninstall requires confirmation; re-run with --yes in non-interactive use."; + if (json || !(ctx.isInteractive?.() ?? false)) { + if (json) ctx.io.out(JSON.stringify({ product: "doctor", command, ok: false, message })); + else ctx.io.err(message); + return 2; + } + if (!(await ctx.confirm("Remove Doctor's service and Doctor-owned state?"))) { + ctx.io.out("Doctor uninstall cancelled; no changes were made."); + return EXIT_OK; + } + } + if (!json) { + try { + return await route(command, ctx, parsed); + } catch (error) { + ctx.io.err(ctx.colors.red(redactLogSecrets(`Command failed: ${error instanceof Error ? error.message : "unknown error"}`))); + return EXIT_ERROR; + } + } + + if (command === "logs" && !hasFlag(parsed, "no-follow")) { + parsed = { ...parsed, flags: { ...parsed.flags, "no-follow": true } }; + } + if (command === "status") { + try { + const status = (await inspectStatus(ctx)).status; + ctx.io.out(JSON.stringify({ product: "doctor", command, ok: true, message: "Doctor status", status })); + return EXIT_OK; + } catch (error) { + const message = redactLogSecrets(error instanceof Error ? error.message : "Doctor status failed"); + ctx.io.out(JSON.stringify({ product: "doctor", command, ok: false, message })); + return EXIT_ERROR; + } + } + if (command === "telemetry") { + try { + if (ctx.deps.telemetrySummary === undefined) throw new Error("Doctor telemetry state is unavailable."); + ctx.io.out(JSON.stringify({ product: "doctor", command, ok: true, message: "Doctor telemetry status", telemetry: ctx.deps.telemetrySummary() })); + return EXIT_OK; + } catch (error) { + const message = redactLogSecrets(error instanceof Error ? error.message : "Doctor telemetry status failed"); + ctx.io.out(JSON.stringify({ product: "doctor", command, ok: false, message })); + return EXIT_ERROR; + } } + const stdout: string[] = []; + const stderr: string[] = []; + const capturedCtx: CliContext = { + ...ctx, + io: { + out: (text) => stdout.push(text), + err: (text) => stderr.push(text), + }, + confirm: async () => false, + isInteractive: () => false, + }; try { - return await route(command, ctx, parsed); + const code = await route(command, capturedCtx, parsed); + const clean = [...stdout, ...stderr].join("\n").replace(/\u001b(?:\[[0-?]*[ -/]*[@-~]|\][^\u0007]*(?:\u0007|\u001b\\)?)/gu, "").trim(); + ctx.io.out(JSON.stringify({ + product: "doctor", + command, + ok: code === EXIT_OK, + message: clean || (code === EXIT_OK ? `${command} completed.` : `${command} failed.`), + ...(command === "logs" ? { lines: stdout.flatMap((line) => line.split(/\r?\n/u)).filter(Boolean) } : {}), + })); + return code; } catch (error) { - // Crash-safe: a handler error is reported, never a thrown stack trace (parent AC-8). - ctx.io.err(ctx.colors.red(`Command failed: ${error instanceof Error ? error.message : "unknown error"}`)); + const message = redactLogSecrets(error instanceof Error ? error.message : "unknown error"); + ctx.io.out(JSON.stringify({ product: "doctor", command, ok: false, message })); return EXIT_ERROR; } } diff --git a/src/cli/index.ts b/src/cli/index.ts index 2ee7723..098c743 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -20,7 +20,6 @@ import { join } from "node:path"; import { runApiaryMigrations } from "../apiary-migration.js"; import { legacyHoneycombRoot } from "../apiary-root.js"; -import { createDoctor } from "../compose/index.js"; import { resolveConfig } from "../config.js"; import { resolveDeviceId } from "../device-id.js"; import { probeHealth } from "../health-probe.js"; @@ -47,6 +46,7 @@ import { readProductUninstallState, removeProductState } from "../product-uninst import { createPurgeEngine } from "../purge/engine.js"; import { createStateStore } from "../state.js"; import { createLifecycleTelemetry } from "../telemetry/capture.js"; +import { isOptedOut } from "../telemetry/emit.js"; import { createInstalledPackageVersionReader, createRegistryLatestReader, @@ -60,8 +60,9 @@ import { readDaemonVersion } from "./daemon-version.js"; import { dispatch } from "./dispatch.js"; import { createIncidentsTail } from "./incidents-tail.js"; import { resolveOptOut } from "./opt-out.js"; -import { createSelfUpdate } from "./self-update.js"; +import { createSelfUpdate, parseApprovedVersion } from "./self-update.js"; import { createUpdateActions } from "./update-actions.js"; +import { appendDoctorServiceLog, captureDoctorServiceOutput, doctorServiceLogPath, tailDoctorServiceLog } from "./service-logs.js"; import type { CliContext, ConfirmFn, ConfirmTokenFn, OutputSink } from "./context.js"; import type { HealthClassification } from "../health-probe.js"; import type { RungContext } from "../remediation.js"; @@ -118,7 +119,9 @@ export function buildCliContext(argv: readonly string[]): CliContext { const env = process.env; const config = resolveConfig(env); const logger = createLogger({ level: "warn" }); // The CLI is quiet unless something is wrong. - const colors = createColors(); + const colors = createColors({ + env: hasFlag(parseArgs(argv), "no-color") ? { ...env, NO_COLOR: "1" } : env, + }); const runner = createExecFileRunner(); const home = homedir(); // PRD-004b: read the registry NEW-first with the legacy-additive merge (read-only; the @@ -147,6 +150,7 @@ export function buildCliContext(argv: readonly string[]): CliContext { healthUrl: config.healthUrl, stateStore: createStateStore({ workspaceDir: config.workspaceDir, name: "honeycomb", logger }), }; + const doctorStateStore = createStateStore({ workspaceDir: config.workspaceDir, logger }); const statusDaemons = () => daemonStateStores.map((daemon) => ({ @@ -243,7 +247,7 @@ export function buildCliContext(argv: readonly string[]): CliContext { // fallback) with the resolved device id as fallback. // Every method is gated (empty key / DO_NOT_TRACK / HONEYCOMB_TELEMETRY=0) + fail-soft. const lifecycle = createLifecycleTelemetry({ - stateStore: createStateStore({ workspaceDir: config.workspaceDir, logger }), + stateStore: doctorStateStore, distinctId: { deviceId }, }); @@ -269,7 +273,24 @@ export function buildCliContext(argv: readonly string[]): CliContext { logger, }); - const selfUpdate = createSelfUpdate({ runner, logger }); + const selfUpdate = createSelfUpdate({ + runner, + logger, + restartService: async () => { + await serviceStop(serviceDeps); + return (await serviceStart(serviceDeps)).ok; + }, + verifyHealthy: async () => (await serviceStatus(serviceDeps)) === "running", + }); + const checkSelfUpdate = async (): Promise => { + const result = await runner.run("npm", ["view", "@legioncodeinc/doctor", "version", "--json"], { timeoutMs: 15_000 }); + if (!result.ok) return `Unable to resolve Doctor's approved release: ${result.detail ?? "npm query failed"}.`; + const target = parseApprovedVersion(result.stdout); + if (target === null) return "Unable to resolve Doctor's approved release: invalid registry metadata."; + return target === DOCTOR_VERSION + ? `Doctor is up to date (${DOCTOR_VERSION}).` + : `Doctor update available: ${DOCTOR_VERSION} -> ${target}.`; + }; // The real 064b OS-service module (install/uninstall/start/stop), built once and shared // by every seam below (`serviceModule`, `serviceLifecycle`, `productUninstall`, `purge`). @@ -334,11 +355,28 @@ export function buildCliContext(argv: readonly string[]): CliContext { optOut, // `update --check` previews via previewUpdate() (READ-ONLY, never mutates); `update` // applies via runUpdateTransaction(); `self-update` is the sole own-package path. - update: createUpdateActions(updateEngine, selfUpdate), + update: { ...createUpdateActions(updateEngine, selfUpdate), checkSelfUpdate }, tailIncidents: createIncidentsTail( config.workspaceDir, daemonEntries.map((entry) => entry.name), ), + tailServiceLogs: (args, write, signal) => + tailDoctorServiceLog({ argv: args, workspaceDir: config.workspaceDir, write, ...(signal === undefined ? {} : { signal }) }), + paths: { config: config.workspaceDir, logs: doctorServiceLogPath(config.workspaceDir) }, + telemetrySummary: () => { + const disabled = isOptedOut(env); + const controllingSetting = env["DO_NOT_TRACK"] !== undefined + ? "DO_NOT_TRACK" + : env["HONEYCOMB_TELEMETRY"] === "0" + ? "HONEYCOMB_TELEMETRY" + : "default"; + return { + state: disabled ? "opted-out" : "enabled", + controllingSetting, + destination: disabled ? "disabled" : "hosted", + optOutInstruction: "Set DO_NOT_TRACK=1 or HONEYCOMB_TELEMETRY=0", + }; + }, }, }; } @@ -352,30 +390,49 @@ export function buildCliContext(argv: readonly string[]): CliContext { */ async function runWatchdog(argv: readonly string[]): Promise { const cliNoAutoUpdate = hasFlag(parseArgs(argv), "no-auto-update"); - // PRD-004 (ADR-0003): run the one-time, idempotent, best-effort state migrations on boot - // BEFORE assembling the watchdog: doctor's own workspace (004a) and the fleet-shared - // registry (004b) move from the legacy `~/.honeycomb` root to the neutral `~/.apiary` root. - // runApiaryMigrations is TOTAL (never throws), so a migration hiccup can never block boot; - // readers fall back to the legacy location until the new path exists. The workspace leg is - // skipped (and logged) when DOCTOR_WORKSPACE_DIR pins the workspace explicitly. - runApiaryMigrations({ logger: createLogger({ level: "info" }) }); - const doctor = createDoctor({ cliNoAutoUpdate }); - await doctor.start(); - // Keep `run` alive even when optional referenced handles (notably the status page) - // fail to bind and all internal loop timers are deliberately unref'ed. - const keepAlive = setInterval(() => undefined, 60 * 60 * 1000); + // Arm termination before the first asynchronous startup step. A service manager may + // request shutdown while migrations, the dynamic compose import, or doctor.start() is + // still in flight; registering later loses that signal and leaves the watchdog alive. + let resolveTermination: (() => void) | undefined; + const termination = new Promise((resolve) => { + resolveTermination = resolve; + }); + const stop = (): void => resolveTermination?.(); + process.once("SIGTERM", stop); + process.once("SIGINT", stop); try { - // Block until a termination signal arrives; the service manager owns the lifecycle. - await new Promise((resolve) => { - const stop = (): void => resolve(); - process.once("SIGTERM", stop); - process.once("SIGINT", stop); - }); + // PRD-004 (ADR-0003): run the one-time, idempotent, best-effort state migrations on boot + // BEFORE assembling the watchdog: doctor's own workspace (004a) and the fleet-shared + // registry (004b) move from the legacy `~/.honeycomb` root to the neutral `~/.apiary` root. + // runApiaryMigrations is TOTAL (never throws), so a migration hiccup can never block boot; + // readers fall back to the legacy location until the new path exists. The workspace leg is + // skipped (and logged) when DOCTOR_WORKSPACE_DIR pins the workspace explicitly. + runApiaryMigrations({ logger: createLogger({ level: "info" }) }); + const config = resolveConfig(process.env); + await appendDoctorServiceLog(config.workspaceDir, "Doctor service starting"); + const restoreServiceOutput = captureDoctorServiceOutput(config.workspaceDir); + // Keep the watchdog assembly (and its experimental node:sqlite reader) off every + // one-shot CLI path. JSON commands must emit exactly one document with no runtime warning. + const { createDoctor } = await import("../compose/index.js"); + const doctor = createDoctor({ cliNoAutoUpdate }); + await doctor.start(); + // Keep `run` alive even when optional referenced handles (notably the status page) + // fail to bind and all internal loop timers are deliberately unref'ed. + const keepAlive = setInterval(() => undefined, 60 * 60 * 1000); + try { + // Block until a termination signal arrives; the service manager owns the lifecycle. + await termination; + } finally { + clearInterval(keepAlive); + await doctor.stop(); + await appendDoctorServiceLog(config.workspaceDir, "Doctor service stopped"); + restoreServiceOutput(); + } + return 0; } finally { - clearInterval(keepAlive); - await doctor.stop(); + process.removeListener("SIGTERM", stop); + process.removeListener("SIGINT", stop); } - return 0; } /** Run the CLI: build the context, dispatch, resolve the exit code. Never throws. */ diff --git a/src/cli/self-update.ts b/src/cli/self-update.ts index d058670..c6e7a9e 100644 --- a/src/cli/self-update.ts +++ b/src/cli/self-update.ts @@ -18,6 +18,7 @@ import type { CommandRunner } from "../rungs/command-runner.js"; import type { Logger } from "../logger.js"; import { DOCTOR_PACKAGE } from "../version.js"; +import { DOCTOR_VERSION } from "../version.js"; /** Construction deps for {@link createSelfUpdate}. */ export interface SelfUpdateDeps { @@ -29,10 +30,27 @@ export interface SelfUpdateDeps { readonly tag?: string; /** Per-install timeout in ms (default: the runner's own default). */ readonly installTimeoutMs?: number; + /** Restart the installed Doctor service after replacement. Enables verified transaction mode. */ + readonly restartService?: () => Promise; + /** Verify the restarted Doctor service. Enables rollback when verification fails. */ + readonly verifyHealthy?: () => Promise; } /** Stable action verb for logs. */ const ACTION = "self-update"; +const EXACT_SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/u; + +export function parseApprovedVersion(stdout: string): string | null { + let candidate = stdout.trim(); + try { + const parsed: unknown = JSON.parse(candidate); + if (typeof parsed !== "string") return null; + candidate = parsed.trim(); + } catch { + // Older npm clients may emit plain text. It is still accepted only after exact validation. + } + return EXACT_SEMVER.test(candidate) ? candidate : null; +} /** * Build the self-update action. Returns a human-readable result line (success or a @@ -42,7 +60,21 @@ const ACTION = "self-update"; export function createSelfUpdate(deps: SelfUpdateDeps): () => Promise { const tag = deps.tag ?? "latest"; return async (): Promise => { - const spec = `${DOCTOR_PACKAGE}@${tag}`; + if (tag !== "latest" && !EXACT_SEMVER.test(tag)) { + return "Doctor update failed before install: invalid approved release channel."; + } + let target = tag; + if (deps.verifyHealthy !== undefined) { + const resolved = await deps.runner.run("npm", ["view", `${DOCTOR_PACKAGE}@${tag}`, "version", "--json"], { + timeoutMs: 15_000, + }); + if (!resolved.ok) return `Doctor update failed before install: ${resolved.detail ?? "release resolution failed"}.`; + const approved = parseApprovedVersion(resolved.stdout); + if (approved === null) return "Doctor update failed before install: invalid release metadata."; + target = approved; + } + if (target === DOCTOR_VERSION) return `Doctor is already up to date (${DOCTOR_VERSION}).`; + const spec = `${DOCTOR_PACKAGE}@${target}`; deps.logger.info(`${ACTION}.start`, { spec }); const result = await deps.runner.run( "npm", @@ -50,8 +82,21 @@ export function createSelfUpdate(deps: SelfUpdateDeps): () => Promise { deps.installTimeoutMs !== undefined ? { timeoutMs: deps.installTimeoutMs } : undefined, ); if (result.ok) { + if (deps.verifyHealthy !== undefined) { + const restarted = await (deps.restartService?.() ?? Promise.resolve(false)); + const healthy = restarted && (await deps.verifyHealthy()); + if (!healthy) { + const rollbackSpec = `${DOCTOR_PACKAGE}@${DOCTOR_VERSION}`; + const rollback = await deps.runner.run("npm", ["install", "-g", rollbackSpec], deps.installTimeoutMs !== undefined ? { timeoutMs: deps.installTimeoutMs } : undefined); + const rollbackRestarted = rollback.ok && (await (deps.restartService?.() ?? Promise.resolve(false))); + const rollbackHealthy = rollbackRestarted && (await deps.verifyHealthy()); + return rollbackHealthy + ? `Doctor update failed health verification and rolled back: ${target} -> ${DOCTOR_VERSION}.` + : `Doctor update failed health verification; rollback to ${DOCTOR_VERSION} did not recover Doctor and manual repair is required.`; + } + } deps.logger.info(`${ACTION}.ok`, { spec }); - return `Doctor updated (${spec}). Restart any running Doctor process to pick it up.`; + return `Doctor updated: ${DOCTOR_VERSION} -> ${target}.`; } deps.logger.error(`${ACTION}.failed`, { code: result.code, detail: result.detail }); return `Doctor self-update failed: ${result.detail ?? `npm exited ${result.code ?? "non-zero"}`}.`; diff --git a/src/cli/service-logs.ts b/src/cli/service-logs.ts new file mode 100644 index 0000000..438b725 --- /dev/null +++ b/src/cli/service-logs.ts @@ -0,0 +1,98 @@ +import { appendFileSync, mkdirSync, promises as fsPromises, watch } from "node:fs"; +import { join } from "node:path"; + +import { + parseLogTailOptions, + tailProductLog, + type LogFileSystem, + type LogTailResult, +} from "@legioncodeinc/cli-kit"; + +import { SERVICE_LABEL } from "../service/platform.js"; + +export const DOCTOR_SERVICE_LOG = "service.log" as const; + +export function doctorServiceLogPath(workspaceDir: string): string { + return join(workspaceDir, DOCTOR_SERVICE_LOG); +} + +const nodeLogFs: LogFileSystem = { + readFile: (path) => fsPromises.readFile(path, "utf8"), + realpath: (path) => fsPromises.realpath(path), + watch(path, onChange) { + const watcher = watch(path, onChange); + return { close: () => watcher.close() }; + }, +}; + +export interface TailDoctorServiceLogOptions { + readonly argv: readonly string[]; + readonly workspaceDir: string; + readonly write: (line: string) => void; + readonly signal?: AbortSignal; + readonly fs?: LogFileSystem; +} + +/** Tail only Doctor's authoritative service log; no path or service identifier is user-controlled. */ +export async function tailDoctorServiceLog(options: TailDoctorServiceLogOptions): Promise { + const parsed = parseLogTailOptions(options.argv); + if (!parsed.ok) return { ok: false, error: parsed.error }; + const path = doctorServiceLogPath(options.workspaceDir); + return tailProductLog({ + productId: "doctor", + serviceId: SERVICE_LABEL, + source: { productId: "doctor", serviceId: SERVICE_LABEL, root: options.workspaceDir, path }, + options: parsed.options, + fs: options.fs ?? nodeLogFs, + write: options.write, + ...(options.signal === undefined ? {} : { signal: options.signal }), + }); +} + +/** Best-effort service boundary logging used on every platform, including Scheduled Tasks. */ +export async function appendDoctorServiceLog(workspaceDir: string, message: string): Promise { + try { + await fsPromises.mkdir(workspaceDir, { recursive: true }); + await fsPromises.appendFile( + doctorServiceLogPath(workspaceDir), + `${new Date().toISOString()} ${message.replace(/[\r\n]+/gu, " ")}\n`, + { encoding: "utf8", mode: 0o600 }, + ); + } catch { + // A logging failure must never prevent Doctor from supervising the fleet. + } +} + +/** + * Bind the long-running service process' stdout and stderr to Doctor's one authoritative + * file. This is the Windows Scheduled Task capture path; launchd/systemd also point their + * native stream destinations at the same file. + */ +export function captureDoctorServiceOutput(workspaceDir: string): () => void { + const path = doctorServiceLogPath(workspaceDir); + const originalOut = process.stdout.write.bind(process.stdout); + const originalErr = process.stderr.write.bind(process.stderr); + try { + mkdirSync(workspaceDir, { recursive: true }); + const append = ((chunk: string | Uint8Array, encodingOrCallback?: unknown, callback?: unknown): boolean => { + try { + appendFileSync(path, chunk, { mode: 0o600 }); + const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback; + if (typeof done === "function") done(); + return true; + } catch (error) { + const done = typeof encodingOrCallback === "function" ? encodingOrCallback : callback; + if (typeof done === "function") done(error); + return false; + } + }) as typeof process.stdout.write; + process.stdout.write = append; + process.stderr.write = append as typeof process.stderr.write; + } catch { + return () => undefined; + } + return () => { + process.stdout.write = originalOut; + process.stderr.write = originalErr; + }; +} diff --git a/src/service/argv.ts b/src/service/argv.ts index 2e8ec59..911329a 100644 --- a/src/service/argv.ts +++ b/src/service/argv.ts @@ -41,6 +41,51 @@ export interface ServiceCommand { readonly args: readonly string[]; } +/** Fixed PowerShell host used only for the Windows Scheduled Task descendant cleanup. */ +export const WINDOWS_POWERSHELL_PATH = + "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" as const; + +/** + * Task Scheduler can end the `conhost --headless` action while leaving its Node child alive. + * This fixed script receives the trusted Doctor bundle path and Node executable as separate argv + * values, then terminates only an exact `node run` identity. No value is interpolated + * into the script and the command is executed through execFile, not a shell string. + */ +export const WINDOWS_DOCTOR_PROCESS_CLEANUP_SCRIPT = + "& { param([string]$cli64,[string]$node64); " + + "$ErrorActionPreference='Stop'; " + + "$cli=[IO.Path]::GetFullPath([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($cli64))); " + + "$node=[IO.Path]::GetFullPath([Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($node64))); " + + "Get-CimInstance Win32_Process | Where-Object { " + + "$_.Name -ieq 'node.exe' -and $_.CommandLine -and $_.ExecutablePath -and " + + "[IO.Path]::GetFullPath($_.ExecutablePath) -ieq $node -and " + + "$_.CommandLine.IndexOf($cli,[StringComparison]::OrdinalIgnoreCase) -ge 0 -and " + + "$_.CommandLine -match '\\srun(?:\\s|$)' " + + "} | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction Stop } }"; + +/** Encode a path as UTF-16LE/base64 so PowerShell's `-Command` parser cannot split it. */ +export function encodeWindowsCleanupPath(value: string): string { + return Buffer.from(value, "utf16le").toString("base64"); +} + +/** Kill only orphaned/running Doctor children for this exact installed bundle path. */ +export function windowsDoctorProcessCleanupCommand(plan: ServicePlan): ServiceCommand { + return { + command: WINDOWS_POWERSHELL_PATH, + args: [ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-Command", + WINDOWS_DOCTOR_PROCESS_CLEANUP_SCRIPT, + encodeWindowsCleanupPath(plan.execPath), + encodeWindowsCleanupPath(process.execPath), + ], + }; +} + /** The user's numeric uid for the launchd `gui/` domain target. Injected (default: live uid). */ export type ReadUidFn = () => number; @@ -65,6 +110,9 @@ export function installCommands(plan: ServicePlan, uid: number): readonly Servic case "launchd": { const domain = launchdDomainTarget(plan, uid); return [ + // Reconcile a previously loaded definition. A clean "not loaded" result is + // tolerated by the service module; any real permission failure remains fatal. + { command: "launchctl", args: ["bootout", launchdServiceTarget(plan, uid)] }, // Modern load: bootstrap the unit into the (user GUI | system) domain. { command: "launchctl", args: ["bootstrap", domain, plan.unitPath] }, // Ensure it is started now (idempotent kick; harmless if already running). @@ -81,6 +129,11 @@ export function installCommands(plan: ServicePlan, uid: number): readonly Servic case "schtasks": { // Per-user Scheduled Task from the rendered XML; /F overwrites idempotently. return [ + // Reconciliation must stop the tracked wrapper and any legacy orphaned child + // before replacing the definition; otherwise the new `/Run` can create a + // second watchdog while the old one still owns Doctor's status port. + { command: "schtasks", args: ["/End", "/TN", WINDOWS_TASK_NAME] }, + windowsDoctorProcessCleanupCommand(plan), { command: "schtasks", args: ["/Create", "/XML", plan.unitPath, "/TN", WINDOWS_TASK_NAME, "/F"] }, // Start it immediately so a clean install is running without waiting for the next logon. { command: "schtasks", args: ["/Run", "/TN", WINDOWS_TASK_NAME] }, @@ -94,6 +147,8 @@ export function installCommands(plan: ServicePlan, uid: number): readonly Servic command: "sc", args: ["create", WINDOWS_TASK_NAME, `binPath=${binPath}`, "start=", "auto"], }, + // `create` establishes a missing service; `config` reconciles an existing one. + { command: "sc", args: ["config", WINDOWS_TASK_NAME, `binPath=${binPath}`, "start=", "auto"] }, { command: "sc", args: ["start", WINDOWS_TASK_NAME] }, ]; } @@ -116,7 +171,11 @@ export function uninstallCommands(plan: ServicePlan, uid: number): readonly Serv ]; } case "schtasks": - return [{ command: "schtasks", args: ["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"] }]; + return [ + { command: "schtasks", args: ["/End", "/TN", WINDOWS_TASK_NAME] }, + windowsDoctorProcessCleanupCommand(plan), + { command: "schtasks", args: ["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"] }, + ]; case "sc": return [ { command: "sc", args: ["stop", WINDOWS_TASK_NAME] }, @@ -134,14 +193,24 @@ export function uninstallCommands(plan: ServicePlan, uid: number): readonly Serv export function startCommands(plan: ServicePlan, uid: number): readonly ServiceCommand[] { switch (plan.manager) { case "launchd": - // `kickstart -k` (re)starts the job in its existing domain; harmless if already running. - return [{ command: "launchctl", args: ["kickstart", "-k", launchdServiceTarget(plan, uid)] }]; + // Explicit stop unloads the job so KeepAlive cannot resurrect it. Start therefore + // bootstraps the installed plist before kickstarting; "already loaded" is tolerated. + return [ + { command: "launchctl", args: ["bootstrap", launchdDomainTarget(plan, uid), plan.unitPath] }, + { command: "launchctl", args: ["kickstart", "-k", launchdServiceTarget(plan, uid)] }, + ]; case "systemd": { const scopeArgs = plan.scope === "user" ? ["--user"] : []; return [{ command: "systemctl", args: [...scopeArgs, "start", SYSTEMD_UNIT_NAME] }]; } case "schtasks": - return [{ command: "schtasks", args: ["/Run", "/TN", WINDOWS_TASK_NAME] }]; + // A previous Task Scheduler `/End` can leave the headless Node child orphaned. + // Reap only Doctor's exact child before starting so a stale process cannot race + // the new task for the status port. + return [ + windowsDoctorProcessCleanupCommand(plan), + { command: "schtasks", args: ["/Run", "/TN", WINDOWS_TASK_NAME] }, + ]; case "sc": return [{ command: "sc", args: ["start", WINDOWS_TASK_NAME] }]; } @@ -149,24 +218,25 @@ export function startCommands(plan: ServicePlan, uid: number): readonly ServiceC /** * The argv to STOP (without deregistering) the service for this plan (PRD-003b b-AC-1). - * Unlike {@link uninstallCommands}, this never unloads the unit / deletes the task: the - * service manager's own restart-on-crash policy (`KeepAlive`/`Restart=always`/the - * Scheduled Task's own trigger) stays intact, so a later `start` (or a crash) brings it - * back without re-registering. + * Unlike {@link uninstallCommands}, this never deletes the installed definition. launchd + * must unload the job because an unconditional KeepAlive job cannot otherwise remain + * explicitly stopped; a later start bootstraps the retained plist again. */ export function stopCommands(plan: ServicePlan, uid: number): readonly ServiceCommand[] { switch (plan.manager) { case "launchd": - // `kill SIGTERM` stops the running instance WITHOUT bootout (the job stays loaded, - // so `KeepAlive` does not immediately race a restart the way a bare SIGKILL would, - // and a subsequent `start` (kickstart) brings it back without re-bootstrapping). - return [{ command: "launchctl", args: ["kill", "SIGTERM", launchdServiceTarget(plan, uid)] }]; + // Keep the plist installed but unload the job, guaranteeing KeepAlive cannot race + // an explicit stop. `startCommands` reloads this same retained definition. + return [{ command: "launchctl", args: ["bootout", launchdServiceTarget(plan, uid)] }]; case "systemd": { const scopeArgs = plan.scope === "user" ? ["--user"] : []; return [{ command: "systemctl", args: [...scopeArgs, "stop", SYSTEMD_UNIT_NAME] }]; } case "schtasks": - return [{ command: "schtasks", args: ["/End", "/TN", WINDOWS_TASK_NAME] }]; + return [ + { command: "schtasks", args: ["/End", "/TN", WINDOWS_TASK_NAME] }, + windowsDoctorProcessCleanupCommand(plan), + ]; case "sc": return [{ command: "sc", args: ["stop", WINDOWS_TASK_NAME] }]; } diff --git a/src/service/index.ts b/src/service/index.ts index 83ea5c0..7a40102 100644 --- a/src/service/index.ts +++ b/src/service/index.ts @@ -156,6 +156,39 @@ function lastNonEmptyLine(text: string): string | null { return lines.length > 0 ? (lines[lines.length - 1] ?? null) : null; } +function isAlreadyAbsentFailure(result: CommandResult | null): boolean { + if (result === null) return false; + const text = `${result.detail ?? ""}\n${result.stderr}\n${result.stdout}`.toLowerCase(); + return /not found|does not exist|not loaded|not installed|not (?:currently )?running|cannot find|could not find|no such (?:file|unit|service|task)|267011/u.test(text); +} + +function isInstallReconciliationSuccess(command: ServiceCommand, result: CommandResult): boolean { + const text = `${result.detail ?? ""}\n${result.stderr}\n${result.stdout}`.toLowerCase(); + if (command.command === "schtasks" && command.args[0]?.toLowerCase() === "/end") { + return isAlreadyAbsentFailure(result); + } + if (command.command === "launchctl" && command.args[0] === "bootout") return isAlreadyAbsentFailure(result); + if (command.command === "launchctl" && command.args[0] === "bootstrap") { + return /already (?:loaded|bootstrapped)|service already exists/u.test(text); + } + if (command.command === "sc" && command.args[0] === "create") { + return /already exists|1073/u.test(text); + } + if ((command.command === "sc" && command.args[0] === "start") || + (command.command === "schtasks" && command.args[0]?.toLowerCase() === "/run")) { + return /already running|1056/u.test(text); + } + return false; +} + +/** An explicit stop is idempotent when the manager reports the requested stopped state. */ +function isStopReconciliationSuccess(command: ServiceCommand, result: CommandResult): boolean { + if (command.command === "schtasks" && command.args[0]?.toLowerCase() === "/end") { + return isAlreadyAbsentFailure(result); + } + return false; +} + /** * Run an ordered list of commands, stopping at nothing (every result is recorded) but * reporting the first hard failure (and its result, for {@link describeFailure}). Never @@ -165,12 +198,13 @@ function lastNonEmptyLine(text: string): string | null { async function runAll( runner: CommandRunner, commands: readonly ServiceCommand[], + tolerate: (command: ServiceCommand, result: CommandResult) => boolean = () => false, ): Promise<{ allOk: boolean; firstFailure: ServiceCommand | null; firstFailureResult: CommandResult | null }> { let firstFailure: ServiceCommand | null = null; let firstFailureResult: CommandResult | null = null; for (const cmd of commands) { const result = await runner.run(cmd.command, cmd.args, { timeoutMs: SERVICE_COMMAND_TIMEOUT_MS }); - if (!result.ok && firstFailure === null) { + if (!result.ok && !tolerate(cmd, result) && firstFailure === null) { firstFailure = cmd; firstFailureResult = result; } @@ -263,7 +297,11 @@ export function createServiceModule(deps: ServiceModuleDeps): FullServiceModule // 2) Run the manager's install argv. For schtasks the staged file path is the unit path. const planForArgv: ServicePlan = unitTarget === p.unitPath ? p : { ...p, unitPath: unitTarget }; - const { allOk, firstFailure, firstFailureResult } = await runAll(runner, installCommands(planForArgv, uid)); + const { allOk, firstFailure, firstFailureResult } = await runAll( + runner, + installCommands(planForArgv, uid), + isInstallReconciliationSuccess, + ); if (!allOk) { // A manager-command failure (e.g. schtasks /Create rejecting invalid XML) is NOT a // successful install: surface ok:false so the CLI maps it to a non-zero exit (IRD-192 AC-6). @@ -294,7 +332,11 @@ export function createServiceModule(deps: ServiceModuleDeps): FullServiceModule } // 1) Stop + deregister via the manager (idempotent - a missing unit is tolerated). - const { allOk, firstFailure, firstFailureResult } = await runAll(runner, uninstallCommands(p, uid)); + const { allOk, firstFailure, firstFailureResult } = await runAll( + runner, + uninstallCommands(p, uid), + isStopReconciliationSuccess, + ); // 2) Delete the unit file so it cannot resurrect on next boot (AC-064b.5). For schtasks the // staged XML lives beside the workspace; remove that too. @@ -308,6 +350,13 @@ export function createServiceModule(deps: ServiceModuleDeps): FullServiceModule }); } + if (!allOk && isAlreadyAbsentFailure(firstFailureResult)) { + logger.info("service.already_uninstalled", { manager: p.manager, scope: p.scope }); + return { + ok: true, + message: `Doctor service was already gone (${p.manager}, ${scopePhrase(p)}): ${describeFailure(firstFailureResult)}.`, + }; + } if (!allOk) { const detail = describeFailure(firstFailureResult); logger.warn("service.uninstall_command_failed", { command: firstFailure?.command, detail }); @@ -333,13 +382,17 @@ export function createServiceModule(deps: ServiceModuleDeps): FullServiceModule message: `Could not start the Doctor service: ${error instanceof Error ? error.message : "unknown error"}.`, }; } - const { allOk, firstFailure, firstFailureResult } = await runAll(runner, startCommands(p, uid)); + const { allOk, firstFailure, firstFailureResult } = await runAll( + runner, + startCommands(p, uid), + isInstallReconciliationSuccess, + ); if (!allOk) { const detail = describeFailure(firstFailureResult); logger.warn("service.start_command_failed", { command: firstFailure?.command, detail }); return { ok: false, - message: `Could not start the Doctor service (${firstFailure?.command ?? "unknown"}): ${detail}. Is it registered? Run \`doctor install-service\` first.`, + message: `Could not start the Doctor service (${firstFailure?.command ?? "unknown"}): ${detail}. Is it registered? Run \`doctor service-install\` first.`, }; } logger.info("service.started", { manager: p.manager, scope: p.scope }); @@ -356,7 +409,11 @@ export function createServiceModule(deps: ServiceModuleDeps): FullServiceModule message: `Could not stop the Doctor service: ${error instanceof Error ? error.message : "unknown error"}.`, }; } - const { allOk, firstFailure, firstFailureResult } = await runAll(runner, stopCommands(p, uid)); + const { allOk, firstFailure, firstFailureResult } = await runAll( + runner, + stopCommands(p, uid), + isStopReconciliationSuccess, + ); if (!allOk) { const detail = describeFailure(firstFailureResult); logger.warn("service.stop_command_failed", { command: firstFailure?.command, detail }); @@ -391,12 +448,16 @@ export async function serviceStart(deps: ServiceModuleDeps): PromiseProcessType Background StandardOutPath - ${stateDir}/launchd.out.log + ${stateDir}/service.log StandardErrorPath - ${stateDir}/launchd.err.log + ${stateDir}/service.log `; @@ -130,6 +130,8 @@ ExecStart=${exec} Restart=always RestartSec=${RESTART_SEC} StartLimitIntervalSec=0 +StandardOutput=append:${plan.stateDir}/service.log +StandardError=append:${plan.stateDir}/service.log [Install] WantedBy=default.target diff --git a/src/telemetry/sqlite-reader.ts b/src/telemetry/sqlite-reader.ts index 4aa293e..261193c 100644 --- a/src/telemetry/sqlite-reader.ts +++ b/src/telemetry/sqlite-reader.ts @@ -22,7 +22,7 @@ * is isolated (PRD-001c c-AC-6) rather than wedging the loop or another service's read. */ -import { DatabaseSync } from "node:sqlite"; +import { createRequire } from "node:module"; import type { ServiceLogRow, ServiceMetrics, ServiceStatusRow } from "./schema.js"; import { toCamelCase } from "./schema.js"; @@ -121,6 +121,9 @@ function parseMetricsRow(row: Record | undefined): ServiceMetri * the loop or another service's read (PRD-001c c-AC-6). */ export function openTelemetryDb(path: string): TelemetryDbReader { + // Load the experimental built-in only when the long-running ingestion path actually + // opens a database. One-shot CLI commands (especially --json) stay warning-free. + const { DatabaseSync } = createRequire(import.meta.url)("node:sqlite") as typeof import("node:sqlite"); const db = new DatabaseSync(path, { readOnly: true, timeout: BUSY_TIMEOUT_MS }); let closed = false; diff --git a/tests/cli/__snapshots__/banner.test.ts.snap b/tests/cli/__snapshots__/banner.test.ts.snap new file mode 100644 index 0000000..de00bcf --- /dev/null +++ b/tests/cli/__snapshots__/banner.test.ts.snap @@ -0,0 +1,126 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`renderBannerWithMenu > matches the exact 80-column and narrow plain-text goldens > 42 columns 1`] = ` +" .--------. + ___| + |___ + / | /|\\ | \\ + | | / | \\ | | + \\___| | |___/ + '--------' +DOCTOR +Doctor - Apiary service health and +diagnostics +v0.0.0-dev +Legion Code Inc. x Activeloop + +Usage: doctor [options] + + +Commands: +Service lifecycle + start Start the installed service + stop Stop the installed service + restart Restart and verify the service + status Show service status + logs Tail product logs + +Installation + install Configure and onboard + the product + uninstall Remove the product + service-install Install or reconcile + the OS service + service-uninstall Remove only the OS + service + update Update through the + approved release + channel + +Diagnostics + telemetry Inspect or configure + telemetry + +Product commands + run Run the watchdog + service entry + diagnose Classify + supervised-service + health without + changing it + heal Run the remediation + ladder once + reinstall Reinstall the + supervised primary + daemon + uninstall-hivemind Remove a conflicting + Hivemind package + daemon-update Update the + supervised primary + daemon + self-update Deprecated alias for + the canonical update + command + purge Remove all Apiary + products and state + incidents Read Doctor's fleet + incident records + help Show this help + +Global flags + --help, -h Show help + --version Show version + --json Emit machine-readable output + --no-color Disable color" +`; + +exports[`renderBannerWithMenu > matches the exact 80-column and narrow plain-text goldens > 80 columns 1`] = ` +" .--------. + ___| + |___ + / | /|\\ | \\ + | | / | \\ | | + \\___| | |___/ + '--------' +DOCTOR +Doctor - Apiary service health and diagnostics +v0.0.0-dev +Legion Code Inc. x Activeloop + +Usage: doctor [options] + + +Commands: +Service lifecycle + start Start the installed service + stop Stop the installed service + restart Restart and verify the service + status Show service status + logs Tail product logs + +Installation + install Configure and onboard the product + uninstall Remove the product + service-install Install or reconcile the OS service + service-uninstall Remove only the OS service + update Update through the approved release channel + +Diagnostics + telemetry Inspect or configure telemetry + +Product commands + run Run the watchdog service entry + diagnose Classify supervised-service health without changing it + heal Run the remediation ladder once + reinstall Reinstall the supervised primary daemon + uninstall-hivemind Remove a conflicting Hivemind package + daemon-update Update the supervised primary daemon + self-update Deprecated alias for the canonical update command + purge Remove all Apiary products and state + incidents Read Doctor's fleet incident records + help Show this help + +Global flags + --help, -h Show help + --version Show version + --json Emit machine-readable output + --no-color Disable color" +`; diff --git a/tests/cli/__snapshots__/standard-interface.test.ts.snap b/tests/cli/__snapshots__/standard-interface.test.ts.snap new file mode 100644 index 0000000..bce3a42 --- /dev/null +++ b/tests/cli/__snapshots__/standard-interface.test.ts.snap @@ -0,0 +1,251 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`PRD-003 Doctor CLI conformance > goldens enabled and opted-out telemetry in human and JSON > enabled telemetry JSON 1`] = ` +{ + "command": "telemetry", + "message": "Doctor telemetry status", + "ok": true, + "product": "doctor", + "telemetry": { + "controllingSetting": "default", + "destination": "hosted", + "optOutInstruction": "Set DO_NOT_TRACK=1", + "state": "enabled", + }, +} +`; + +exports[`PRD-003 Doctor CLI conformance > goldens enabled and opted-out telemetry in human and JSON > enabled telemetry human 1`] = ` +"Telemetry: enabled +Controlled by: default +Destination: hosted +Queue: not available +Last successful send: not available +Last error: none +Opt out: Set DO_NOT_TRACK=1" +`; + +exports[`PRD-003 Doctor CLI conformance > goldens enabled and opted-out telemetry in human and JSON > opted-out telemetry JSON 1`] = ` +{ + "command": "telemetry", + "message": "Doctor telemetry status", + "ok": true, + "product": "doctor", + "telemetry": { + "controllingSetting": "DO_NOT_TRACK", + "destination": "disabled", + "optOutInstruction": "Unset DO_NOT_TRACK", + "state": "opted-out", + }, +} +`; + +exports[`PRD-003 Doctor CLI conformance > goldens enabled and opted-out telemetry in human and JSON > opted-out telemetry human 1`] = ` +"Telemetry: opted-out +Controlled by: DO_NOT_TRACK +Destination: disabled +Queue: not available +Last successful send: not available +Last error: none +Opt out: Unset DO_NOT_TRACK" +`; + +exports[`PRD-003 Doctor CLI conformance > goldens running, stopped, not-installed, and unhealthy status in human and JSON > not-installed status JSON 1`] = ` +{ + "command": "status", + "message": "Doctor status", + "ok": true, + "product": "doctor", + "status": { + "details": { + "detail1": "Daemon: honeycomb + Daemon health: ok + Daemon version: 1.2.3 + Last heal: never", + "detail2": "Auto-update: enabled", + }, + "health": { + "result": "Doctor is the health observer", + "state": "not-applicable", + }, + "installation": "not-installed", + "paths": { + "config": "/apiary/doctor", + "logs": "/apiary/doctor/service.log", + }, + "process": { + "state": "unknown", + }, + "product": "DOCTOR", + "registration": "not-applicable", + "version": "9.9.9-test", + }, +} +`; + +exports[`PRD-003 Doctor CLI conformance > goldens running, stopped, not-installed, and unhealthy status in human and JSON > not-installed status human 1`] = ` +"Product: DOCTOR 9.9.9-test +Service: not-installed +Process: unknown +Health: not-applicable - Doctor is the health observer +Registration: not-applicable +Update: unknown +Config: /apiary/doctor +Logs: /apiary/doctor/service.log +Details: + detail1: Daemon: honeycomb + Daemon health: ok + Daemon version: 1.2.3 + Last heal: never + detail2: Auto-update: enabled" +`; + +exports[`PRD-003 Doctor CLI conformance > goldens running, stopped, not-installed, and unhealthy status in human and JSON > running status JSON 1`] = ` +{ + "command": "status", + "message": "Doctor status", + "ok": true, + "product": "doctor", + "status": { + "details": { + "detail1": "Daemon: honeycomb + Daemon health: ok + Daemon version: 1.2.3 + Last heal: never", + "detail2": "Auto-update: enabled", + }, + "health": { + "result": "Doctor is the health observer", + "state": "not-applicable", + }, + "installation": "installed", + "paths": { + "config": "/apiary/doctor", + "logs": "/apiary/doctor/service.log", + }, + "process": { + "state": "running", + }, + "product": "DOCTOR", + "registration": "not-applicable", + "version": "9.9.9-test", + }, +} +`; + +exports[`PRD-003 Doctor CLI conformance > goldens running, stopped, not-installed, and unhealthy status in human and JSON > running status human 1`] = ` +"Product: DOCTOR 9.9.9-test +Service: installed +Process: running +Health: not-applicable - Doctor is the health observer +Registration: not-applicable +Update: unknown +Config: /apiary/doctor +Logs: /apiary/doctor/service.log +Details: + detail1: Daemon: honeycomb + Daemon health: ok + Daemon version: 1.2.3 + Last heal: never + detail2: Auto-update: enabled" +`; + +exports[`PRD-003 Doctor CLI conformance > goldens running, stopped, not-installed, and unhealthy status in human and JSON > stopped status JSON 1`] = ` +{ + "command": "status", + "message": "Doctor status", + "ok": true, + "product": "doctor", + "status": { + "details": { + "detail1": "Daemon: honeycomb + Daemon health: ok + Daemon version: 1.2.3 + Last heal: never", + "detail2": "Auto-update: enabled", + }, + "health": { + "result": "Doctor is the health observer", + "state": "not-applicable", + }, + "installation": "installed", + "paths": { + "config": "/apiary/doctor", + "logs": "/apiary/doctor/service.log", + }, + "process": { + "state": "stopped", + }, + "product": "DOCTOR", + "registration": "not-applicable", + "version": "9.9.9-test", + }, +} +`; + +exports[`PRD-003 Doctor CLI conformance > goldens running, stopped, not-installed, and unhealthy status in human and JSON > stopped status human 1`] = ` +"Product: DOCTOR 9.9.9-test +Service: installed +Process: stopped +Health: not-applicable - Doctor is the health observer +Registration: not-applicable +Update: unknown +Config: /apiary/doctor +Logs: /apiary/doctor/service.log +Details: + detail1: Daemon: honeycomb + Daemon health: ok + Daemon version: 1.2.3 + Last heal: never + detail2: Auto-update: enabled" +`; + +exports[`PRD-003 Doctor CLI conformance > goldens running, stopped, not-installed, and unhealthy status in human and JSON > unhealthy status JSON 1`] = ` +{ + "command": "status", + "message": "Doctor status", + "ok": true, + "product": "doctor", + "status": { + "details": { + "detail1": "Daemon: honeycomb + Daemon health: unreachable (timed out / wedged) + Daemon version: 1.2.3 + Last heal: never", + "detail2": "Auto-update: enabled", + }, + "health": { + "result": "Doctor is the health observer", + "state": "not-applicable", + }, + "installation": "installed", + "paths": { + "config": "/apiary/doctor", + "logs": "/apiary/doctor/service.log", + }, + "process": { + "state": "running", + }, + "product": "DOCTOR", + "registration": "not-applicable", + "version": "9.9.9-test", + }, +} +`; + +exports[`PRD-003 Doctor CLI conformance > goldens running, stopped, not-installed, and unhealthy status in human and JSON > unhealthy status human 1`] = ` +"Product: DOCTOR 9.9.9-test +Service: installed +Process: running +Health: not-applicable - Doctor is the health observer +Registration: not-applicable +Update: unknown +Config: /apiary/doctor +Logs: /apiary/doctor/service.log +Details: + detail1: Daemon: honeycomb + Daemon health: unreachable (timed out / wedged) + Daemon version: 1.2.3 + Last heal: never + detail2: Auto-update: enabled" +`; diff --git a/tests/cli/banner.test.ts b/tests/cli/banner.test.ts index 09fe558..acdfceb 100644 --- a/tests/cli/banner.test.ts +++ b/tests/cli/banner.test.ts @@ -10,13 +10,13 @@ import { createColors } from "../../src/cli/colors.js"; const plain = createColors({ env: {}, isTty: false }); describe("renderBanner", () => { - it("contains both wordmarks, the collaboration line, and the product name", () => { + it("has product-only art and exactly one shared credit line", () => { const b = renderBanner(plain); - expect(b).toContain("LEGION CODE INC."); - expect(b).toContain("ACTIVELOOP"); - expect(b).toContain("A collaboration between Legion Code Inc. x Activeloop"); - expect(b).toContain("deeplake.ai"); - expect(b).toContain("Doctor"); + expect(b).toContain("DOCTOR"); + expect(b.match(/Legion Code Inc\. x Activeloop/gu)).toHaveLength(1); + expect(b).not.toContain("LEGION CODE INC."); + expect(b).not.toContain("ACTIVELOOP"); + expect(b).not.toContain("powered by"); }); it("includes the single-sourced version", () => { @@ -49,4 +49,11 @@ describe("renderBannerWithMenu", () => { expect(colored.includes(ESC)).toBe(true); expect(renderBannerWithMenu(plain).includes(ESC)).toBe(false); }); + + it("matches the exact 80-column and narrow plain-text goldens", () => { + expect(renderBannerWithMenu(plain, 80)).toMatchSnapshot("80 columns"); + expect(renderBannerWithMenu(plain, 42)).toMatchSnapshot("42 columns"); + const noColor = createColors({ env: { NO_COLOR: "1" }, isTty: true }); + expect(renderBannerWithMenu(noColor, 80)).toBe(renderBannerWithMenu(plain, 80)); + }); }); diff --git a/tests/cli/direct-invocation.test.ts b/tests/cli/direct-invocation.test.ts new file mode 100644 index 0000000..ad094b7 --- /dev/null +++ b/tests/cli/direct-invocation.test.ts @@ -0,0 +1,33 @@ +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { isDirectInvocation } from "../../src/cli/direct-invocation.js"; + +describe("packed CLI direct-invocation detection", () => { + it("accepts macOS /var and /private/var aliases of the same executable", () => { + const invoked = "/var/folders/fixture/doctor/bundle/cli.js"; + const modulePath = "/private/var/folders/fixture/doctor/bundle/cli.js"; + const canonicalize = (path: string): string => path + .replaceAll("\\", "/") + .replace(/^[A-Za-z]:/u, "") + .replace(/^\/var\//u, "/private/var/"); + + expect(isDirectInvocation(invoked, pathToFileURL(modulePath).href, canonicalize)).toBe(true); + }); + + it("rejects an import from a different process entrypoint", () => { + const canonicalize = (path: string): string => path; + expect( + isDirectInvocation("/tmp/fixture-runner.js", pathToFileURL("/tmp/doctor/bundle/cli.js").href, canonicalize), + ).toBe(false); + }); + + it("falls back to exact URL equality when canonicalization is unavailable", () => { + const invoked = resolve("fixture", "doctor-cli.js"); + const unavailable = (): string => { + throw new Error("realpath unavailable"); + }; + expect(isDirectInvocation(invoked, pathToFileURL(invoked).href, unavailable)).toBe(true); + }); +}); diff --git a/tests/cli/dispatch.test.ts b/tests/cli/dispatch.test.ts index 26d6d2b..1b6b49b 100644 --- a/tests/cli/dispatch.test.ts +++ b/tests/cli/dispatch.test.ts @@ -5,7 +5,7 @@ * scripted confirm, spy-able deps). No process is spawned, no network/npm/daemon touched. */ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { dispatch, EXIT_OK, EXIT_DECLINED, EXIT_ERROR } from "../../src/cli/dispatch.js"; import { buildCliHarness, fakeLadder } from "./helpers/fake-cli.js"; @@ -19,10 +19,10 @@ describe("dispatch (PRD-064f)", () => { expect(code).toBe(EXIT_OK); const text = h.out.text(); - // The attribution banner carries both wordmarks. - expect(text).toContain("LEGION CODE INC."); - expect(text).toContain("ACTIVELOOP"); - expect(text).toContain("Doctor"); + // Shared anatomy owns the sole exact credit; product art contains no partner prose. + expect(text.match(/Legion Code Inc\. x Activeloop/gu)).toHaveLength(1); + expect(text).not.toContain("LEGION CODE INC."); + expect(text).toContain("DOCTOR"); // The menu: a sampling of commands must be listed. expect(text).toContain("status"); expect(text).toContain("diagnose"); @@ -63,11 +63,11 @@ describe("dispatch (PRD-064f)", () => { const text = h.out.text(); expect(text).toContain("Daemon health:"); expect(text).toContain("degraded"); - expect(text).toContain("Doctor service:"); + expect(text).toContain("Process:"); expect(text).toContain("running"); expect(text).toContain("Daemon version:"); expect(text).toContain("1.2.3"); - expect(text).toContain("Doctor version:"); + expect(text).toContain("Product: DOCTOR"); expect(text).toContain("9.9.9-test"); expect(text).toContain("Last heal:"); expect(text).toContain("2026-06-27T00:00:00.000Z"); @@ -196,8 +196,8 @@ describe("dispatch (PRD-064f)", () => { it("`clear-credentials` is NOT a known command (deferred, OD-4)", async () => { const h = buildCliHarness(); const code = await dispatch(["clear-credentials"], h.ctx); - // Unknown command -> error exit + the menu, never a credential action. - expect(code).toBe(EXIT_ERROR); + // Shared CLI contract: unknown/usage errors return 2. + expect(code).toBe(2); expect(h.out.errText()).toContain("Unknown command: clear-credentials"); }); }); @@ -210,7 +210,7 @@ describe("dispatch (PRD-064f)", () => { expect(h.selfUpdate).toHaveBeenCalledTimes(1); }); - it("NO other command calls self-update", async () => { + it("only canonical update and its legacy self-update alias call the Doctor updater", async () => { // Run every other known command and assert self-update is never invoked. const otherCommands = [ "status", @@ -219,7 +219,7 @@ describe("dispatch (PRD-064f)", () => { "restart", "reinstall", "uninstall-hivemind", - "update", + "daemon-update", "install-service", "uninstall-service", "logs", @@ -236,16 +236,16 @@ describe("dispatch (PRD-064f)", () => { } }); - it("`update` (primary) does not call self-update; it calls the primary update action", async () => { + it("canonical `update` calls Doctor's own updater", async () => { const h = buildCliHarness(); await dispatch(["update"], h.ctx); - expect(h.applyPrimaryUpdate).toHaveBeenCalledTimes(1); - expect(h.selfUpdate).not.toHaveBeenCalled(); + expect(h.applyPrimaryUpdate).not.toHaveBeenCalled(); + expect(h.selfUpdate).toHaveBeenCalledTimes(1); }); - it("`update --check` previews without applying and never self-updates", async () => { + it("`daemon-update --check` preserves the legacy primary-daemon preview", async () => { const h = buildCliHarness(); - await dispatch(["update", "--check"], h.ctx); + await dispatch(["daemon-update", "--check"], h.ctx); expect(h.checkPrimaryUpdate).toHaveBeenCalledTimes(1); expect(h.applyPrimaryUpdate).not.toHaveBeenCalled(); expect(h.selfUpdate).not.toHaveBeenCalled(); @@ -277,11 +277,11 @@ describe("dispatch (PRD-064f)", () => { }); describe("service stubs + restart + logs", () => { - it("install-service prints 'not yet available' when 064b is not wired", async () => { + it("service-install fails closed when the service adapter is not wired", async () => { const h = buildCliHarness(); const code = await dispatch(["install-service"], h.ctx); - expect(code).toBe(EXIT_OK); - expect(h.out.text()).toContain("not yet available"); + expect(code).toBe(EXIT_ERROR); + expect(h.out.errText()).toContain("not yet available"); }); it("install-service delegates to the 064b module when wired", async () => { @@ -295,25 +295,54 @@ describe("dispatch (PRD-064f)", () => { expect(h.out.text()).toContain("service registered"); }); - it("restart (rung 1) runs without a confirm gate", async () => { + it("restart fails closed instead of delegating to the supervised-daemon rung", async () => { const ladder = fakeLadder({ runResult: { ok: true, action: "restart-daemon" } }); const h = buildCliHarness({ ladder, classification: { kind: "unreachable-refused", detail: "x" } }); const code = await dispatch(["restart"], h.ctx); expect(h.confirmSpy).not.toHaveBeenCalled(); - expect(ladder.runCalls).toEqual([1]); - expect(code).toBe(EXIT_OK); + expect(ladder.runCalls).toEqual([]); + expect(code).toBe(EXIT_ERROR); + expect(h.out.errText()).toContain("restart was not attempted"); + }); + + it("canonical logs fails closed and never falls back to fleet incidents", async () => { + const h = buildCliHarness({ incidents: ['{"source":"hive"}'] }); + const code = await dispatch(["logs", "--no-follow"], h.ctx); + expect(code).toBe(EXIT_ERROR); + expect(h.out.errText()).toContain("no other product log was read"); + expect(h.out.text()).not.toContain('"source":"hive"'); + }); + + it("canonical logs uses only the injected Doctor service-log adapter", async () => { + const tailServiceLogs = vi.fn(async (_argv, write: (line: string) => void) => { + write("doctor-only\n"); + return { ok: true as const }; + }); + const h = buildCliHarness({ tailServiceLogs, incidents: ["fleet-incident"] }); + expect(await dispatch(["logs", "--no-follow"], h.ctx)).toBe(EXIT_OK); + expect(tailServiceLogs).toHaveBeenCalledTimes(1); + expect(h.out.text()).toContain("doctor-only"); + expect(h.out.text()).not.toContain("fleet-incident"); }); it("logs prints recent incident lines, or a friendly empty message", async () => { const withLines = buildCliHarness({ incidents: ['{"id":"a"}', '{"id":"b"}'] }); - await dispatch(["logs"], withLines.ctx); + await dispatch(["incidents"], withLines.ctx); expect(withLines.out.text()).toContain('{"id":"a"}'); const empty = buildCliHarness({ incidents: [] }); - await dispatch(["logs"], empty.ctx); + await dispatch(["incidents"], empty.ctx); expect(empty.out.text()).toContain("No incidents recorded yet."); }); + it("redacts secrets and terminal controls from product-specific incident output", async () => { + const h = buildCliHarness({ incidents: ["Authorization: Bearer secret-token\u001b]0;owned\u0007"] }); + expect(await dispatch(["incidents"], h.ctx)).toBe(EXIT_OK); + expect(h.out.text()).toContain("Authorization: [REDACTED]"); + expect(h.out.text()).not.toContain("secret-token"); + expect(h.out.text()).not.toContain("\u001b"); + }); + it("b-AC-6: logs --daemon filters to one daemon stream", async () => { const h = buildCliHarness({ incidentsByDaemon: { @@ -321,7 +350,7 @@ describe("dispatch (PRD-064f)", () => { hive: ['{"id":"t1"}'], }, }); - const code = await dispatch(["logs", "--daemon", "hive"], h.ctx); + const code = await dispatch(["incidents", "--daemon", "hive"], h.ctx); expect(code).toBe(EXIT_OK); const text = h.out.text(); expect(text).toContain('{"id":"t1"}'); @@ -335,7 +364,7 @@ describe("dispatch (PRD-064f)", () => { hive: ['{"id":"t1"}'], }, }); - const code = await dispatch(["logs"], h.ctx); + const code = await dispatch(["incidents"], h.ctx); expect(code).toBe(EXIT_OK); const text = h.out.text(); expect(text).toContain('[honeycomb] {"id":"h1"}'); diff --git a/tests/cli/helpers/fake-cli.ts b/tests/cli/helpers/fake-cli.ts index a601a7e..d6d89d1 100644 --- a/tests/cli/helpers/fake-cli.ts +++ b/tests/cli/helpers/fake-cli.ts @@ -111,6 +111,12 @@ export interface CliHarnessOptions { readonly optOut?: ResolvedOptOut; /** Incident log tail lines (default: none). */ readonly incidents?: readonly string[]; + /** Doctor-only authoritative service-log adapter (default: absent/fail-closed). */ + readonly tailServiceLogs?: CliDeps["tailServiceLogs"]; + /** Read-only telemetry summary seam. */ + readonly telemetrySummary?: CliDeps["telemetrySummary"]; + /** Common status paths. */ + readonly paths?: CliDeps["paths"]; /** Per-daemon incident lines for `logs --daemon` and all-daemon interleave tests. */ readonly incidentsByDaemon?: Readonly>; /** Optional explicit per-daemon status sources for multi-daemon `status` tests. */ @@ -124,6 +130,8 @@ export interface CliHarnessOptions { readonly serviceModule?: CliDeps["serviceModule"]; /** Whether the PRD-003b start/stop lifecycle seam is wired (default: absent). */ readonly serviceLifecycle?: CliDeps["serviceLifecycle"]; + /** Delay seam for bounded lifecycle polls (default: immediate in tests). */ + readonly lifecycleSleep?: CliDeps["lifecycleSleep"]; /** Whether the PRD-003b full `uninstall` seam is wired (default: absent). */ readonly productUninstall?: CliDeps["productUninstall"]; /** Whether the PRD-003c `purge` seam is wired (default: absent). */ @@ -217,6 +225,9 @@ export function buildCliHarness(options: CliHarnessOptions = {}): CliHarness { ...(options.serviceStateAsync !== undefined ? { serviceStateAsync: options.serviceStateAsync } : {}), optOut: options.optOut ?? resolveOptOut({ cliNoAutoUpdate: false, env: {} }), update: { checkPrimaryUpdate, applyPrimaryUpdate, selfUpdate }, + ...(options.tailServiceLogs !== undefined ? { tailServiceLogs: options.tailServiceLogs } : {}), + ...(options.telemetrySummary !== undefined ? { telemetrySummary: options.telemetrySummary } : {}), + ...(options.paths !== undefined ? { paths: options.paths } : {}), tailIncidents: async (_limit, daemonName) => { if (daemonName !== undefined) return options.incidentsByDaemon?.[daemonName] ?? []; if (options.incidentsByDaemon !== undefined) { @@ -230,6 +241,7 @@ export function buildCliHarness(options: CliHarnessOptions = {}): CliHarness { }, ...(options.serviceModule !== undefined ? { serviceModule: options.serviceModule } : {}), ...(options.serviceLifecycle !== undefined ? { serviceLifecycle: options.serviceLifecycle } : {}), + lifecycleSleep: options.lifecycleSleep ?? (async () => undefined), ...(options.productUninstall !== undefined ? { productUninstall: options.productUninstall } : {}), ...(options.purge !== undefined ? { purge: options.purge } : {}), ...(options.lifecycle !== undefined ? { lifecycle: options.lifecycle } : {}), diff --git a/tests/cli/lifecycle-commands.test.ts b/tests/cli/lifecycle-commands.test.ts index 8e50f1f..ff2a068 100644 --- a/tests/cli/lifecycle-commands.test.ts +++ b/tests/cli/lifecycle-commands.test.ts @@ -15,7 +15,7 @@ import { buildCliHarness } from "./helpers/fake-cli.js"; describe("start (PRD-003b b-AC-1)", () => { it("delegates to serviceLifecycle.start() and prints its message", async () => { const start = vi.fn(async () => ({ ok: true, message: "Doctor service started (systemd, user scope)." })); - const { ctx, out } = buildCliHarness({ serviceLifecycle: { start, stop: vi.fn() } }); + const { ctx, out } = buildCliHarness({ serviceLifecycle: { start, stop: vi.fn() }, serviceStateAsync: async () => "running" }); const code = await dispatch(["start"], ctx); expect(code).toBe(EXIT_OK); expect(start).toHaveBeenCalledTimes(1); @@ -29,18 +29,18 @@ describe("start (PRD-003b b-AC-1)", () => { expect(code).toBe(EXIT_ERROR); }); - it("prints the 'not yet available' stub when serviceLifecycle is not wired", async () => { + it("fails closed when serviceLifecycle is not wired", async () => { const { ctx, out } = buildCliHarness(); const code = await dispatch(["start"], ctx); - expect(code).toBe(EXIT_OK); - expect(out.text()).toContain(SERVICE_NOT_AVAILABLE); + expect(code).toBe(EXIT_ERROR); + expect(out.errText()).toContain(SERVICE_NOT_AVAILABLE); }); }); describe("stop (PRD-003b b-AC-1)", () => { it("delegates to serviceLifecycle.stop() and prints its message", async () => { const stop = vi.fn(async () => ({ ok: true, message: "Doctor service stopped (systemd, user scope)." })); - const { ctx, out } = buildCliHarness({ serviceLifecycle: { start: vi.fn(), stop } }); + const { ctx, out } = buildCliHarness({ serviceLifecycle: { start: vi.fn(), stop }, serviceStateAsync: async () => "not-running" }); const code = await dispatch(["stop"], ctx); expect(code).toBe(EXIT_OK); expect(stop).toHaveBeenCalledTimes(1); @@ -54,11 +54,26 @@ describe("stop (PRD-003b b-AC-1)", () => { expect(code).toBe(EXIT_ERROR); }); - it("prints the 'not yet available' stub when serviceLifecycle is not wired", async () => { + it("polls until stopped and fails after the bounded verification window", async () => { + const stop = vi.fn(async () => ({ ok: true, message: "stop command accepted" })); + const transitions: Array<"running" | "not-running"> = ["running", "running", "not-running"]; + const state = vi.fn(async () => transitions.shift() ?? "not-running"); + const success = buildCliHarness({ serviceLifecycle: { start: vi.fn(), stop }, serviceStateAsync: state }); + expect(await dispatch(["stop"], success.ctx)).toBe(EXIT_OK); + expect(state).toHaveBeenCalledTimes(3); + + const stillRunning = vi.fn(async () => "running" as const); + const timeout = buildCliHarness({ serviceLifecycle: { start: vi.fn(), stop }, serviceStateAsync: stillRunning }); + expect(await dispatch(["stop"], timeout.ctx)).toBe(EXIT_ERROR); + expect(stillRunning).toHaveBeenCalledTimes(20); + expect(timeout.out.errText()).toContain("did not reach stopped state"); + }); + + it("fails closed when serviceLifecycle is not wired", async () => { const { ctx, out } = buildCliHarness(); const code = await dispatch(["stop"], ctx); - expect(code).toBe(EXIT_OK); - expect(out.text()).toContain(SERVICE_NOT_AVAILABLE); + expect(code).toBe(EXIT_ERROR); + expect(out.errText()).toContain(SERVICE_NOT_AVAILABLE); }); }); @@ -283,11 +298,11 @@ describe("uninstall (PRD-003b b-AC-2/3/4/6/7)", () => { expect(uninstalled).not.toHaveBeenCalled(); }); - it("prints the 'not yet available' stub when productUninstall is not wired", async () => { + it("fails closed when productUninstall is not wired", async () => { const { ctx, out } = buildCliHarness(); const code = await dispatch(["uninstall"], ctx); - expect(code).toBe(EXIT_OK); - expect(out.text()).toContain(SERVICE_NOT_AVAILABLE); + expect(code).toBe(EXIT_ERROR); + expect(out.errText()).toContain(SERVICE_NOT_AVAILABLE); }); }); diff --git a/tests/cli/self-update.test.ts b/tests/cli/self-update.test.ts index 2cbe9d6..6e102bd 100644 --- a/tests/cli/self-update.test.ts +++ b/tests/cli/self-update.test.ts @@ -3,9 +3,9 @@ * own package, and only when explicitly invoked. */ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; -import { createSelfUpdate } from "../../src/cli/self-update.js"; +import { createSelfUpdate, parseApprovedVersion } from "../../src/cli/self-update.js"; import { silentLogger } from "../../src/logger.js"; import { DOCTOR_PACKAGE } from "../../src/version.js"; import type { CommandResult, CommandRunner } from "../../src/rungs/command-runner.js"; @@ -56,4 +56,84 @@ describe("createSelfUpdate", () => { await createSelfUpdate({ runner: r.runner, logger: silentLogger, tag: "1.0.0" })(); expect(r.calls[0]?.args[2]).toBe(`${DOCTOR_PACKAGE}@1.0.0`); }); + + it("pins the resolved release and rolls back when post-update service health fails", async () => { + const calls: Array<{ cmd: string; args: string[] }> = []; + const runner: CommandRunner = { + async run(cmd, args): Promise { + calls.push({ cmd, args: [...args] }); + if (args[0] === "view") return { ok: true, code: 0, stdout: '"9.9.9"', stderr: "" }; + return { ok: true, code: 0, stdout: "", stderr: "" }; + }, + }; + const verifyHealthy = vi.fn() + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const message = await createSelfUpdate({ + runner, + logger: silentLogger, + restartService: async () => true, + verifyHealthy, + })(); + expect(calls[0]?.args).toEqual(["view", `${DOCTOR_PACKAGE}@latest`, "version", "--json"]); + expect(calls[1]?.args).toEqual(["install", "-g", `${DOCTOR_PACKAGE}@9.9.9`]); + expect(calls[2]?.args[2]).toBe(`${DOCTOR_PACKAGE}@0.0.0-dev`); + expect(message).toContain("rolled back"); + expect(verifyHealthy).toHaveBeenCalledTimes(2); + }); + + it.each([ + '"9.9.9 && whoami"', + '"latest"', + '"v9.9.9"', + '"1.2"', + '{"version":"9.9.9"}', + ])("rejects malicious or non-semver release metadata before install: %s", async (stdout) => { + const calls: Array<{ cmd: string; args: string[] }> = []; + const runner: CommandRunner = { + async run(cmd, args): Promise { + calls.push({ cmd, args: [...args] }); + return { ok: true, code: 0, stdout, stderr: "" }; + }, + }; + const message = await createSelfUpdate({ + runner, + logger: silentLogger, + restartService: async () => true, + verifyHealthy: async () => true, + })(); + expect(message).toContain("invalid release metadata"); + expect(calls).toHaveLength(1); + expect(calls[0]?.args[0]).toBe("view"); + }); + + it("reports manual repair unless rollback restart and health verification both succeed", async () => { + const calls: Array<{ cmd: string; args: string[] }> = []; + const runner: CommandRunner = { + async run(cmd, args): Promise { + calls.push({ cmd, args: [...args] }); + return args[0] === "view" + ? { ok: true, code: 0, stdout: '"9.9.9"', stderr: "" } + : { ok: true, code: 0, stdout: "", stderr: "" }; + }, + }; + const restartService = vi.fn() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + const message = await createSelfUpdate({ + runner, + logger: silentLogger, + restartService, + verifyHealthy: async () => false, + })(); + expect(message).toContain("manual repair is required"); + expect(message).not.toContain("rolled back:"); + expect(calls[2]?.args[2]).toBe(`${DOCTOR_PACKAGE}@0.0.0-dev`); + }); + + it("accepts strict exact semver metadata only", () => { + expect(parseApprovedVersion('"2.0.0-beta.1+build.7"')).toBe("2.0.0-beta.1+build.7"); + expect(parseApprovedVersion("2.0.0;rm -rf /")).toBeNull(); + expect(parseApprovedVersion('"01.2.3"')).toBeNull(); + }); }); diff --git a/tests/cli/service-logs.test.ts b/tests/cli/service-logs.test.ts new file mode 100644 index 0000000..9e71b54 --- /dev/null +++ b/tests/cli/service-logs.test.ts @@ -0,0 +1,109 @@ +import { resolve } from "node:path"; + +import { describe, expect, it } from "vitest"; +import type { LogFileSystem } from "@legioncodeinc/cli-kit"; + +import { dispatch } from "../../src/cli/dispatch.js"; +import { doctorServiceLogPath, tailDoctorServiceLog } from "../../src/cli/service-logs.js"; +import { buildCliHarness } from "./helpers/fake-cli.js"; + +const root = resolve("virtual-apiary", "doctor"); +const path = doctorServiceLogPath(root); + +function memoryLogFs(content: string, onWatch?: () => void): LogFileSystem & { readonly reads: string[]; readonly closes: string[] } { + const reads: string[] = []; + const closes: string[] = []; + return { + reads, + closes, + readFile: async (candidate) => { reads.push(candidate); return content; }, + realpath: async (candidate) => candidate, + watch: (_candidate, callback) => { + onWatch?.(); + return { close() { closes.push("closed"); } }; + }, + }; +} + +describe("Doctor authoritative service logs", () => { + it("defaults to the last 100 lines and redacts secrets", async () => { + const content = Array.from({ length: 150 }, (_, index) => + `2026-07-13T10:${String(index).padStart(2, "0")}:00Z line-${index + 1}${index === 149 ? " api_key=supersecret" : ""}`, + ).join("\n"); + const fs = memoryLogFs(content); + const output: string[] = []; + expect(await tailDoctorServiceLog({ argv: ["--no-follow"], workspaceDir: root, write: (line) => output.push(line), fs })).toEqual({ ok: true }); + expect(output).toHaveLength(100); + expect(output[0]).toContain("line-51"); + expect(output.at(-1)).toContain("api_key=[REDACTED]"); + expect(output.join("")).not.toContain("supersecret"); + }); + + it("follows by default and aborts cleanly with watcher cleanup", async () => { + const controller = new AbortController(); + const fs = memoryLogFs("2026-07-13T10:00:00Z started\n"); + const pending = tailDoctorServiceLog({ argv: [], workspaceDir: root, write() {}, signal: controller.signal, fs }); + await new Promise((resolveReady) => setImmediate(resolveReady)); + controller.abort(); + expect(await pending).toEqual({ ok: true }); + expect(fs.closes).toEqual(["closed"]); + }); + + it("applies --since before the line limit", async () => { + const fs = memoryLogFs([ + "2026-07-13T09:00:00Z old", + "2026-07-13T10:00:00Z retained-one", + "2026-07-13T11:00:00Z retained-two", + ].join("\n")); + const output: string[] = []; + const result = await tailDoctorServiceLog({ + argv: ["--since", "2026-07-13T09:30:00Z", "--no-follow"], + workspaceDir: root, + write: (line) => output.push(line), + fs, + }); + expect(result).toEqual({ ok: true }); + expect(output.join("")).toBe("2026-07-13T10:00:00Z retained-one\n2026-07-13T11:00:00Z retained-two\n"); + }); + + it("returns runtime failure for missing and unreadable logs", async () => { + for (const failure of ["ENOENT: missing", "EACCES: unreadable"]) { + const fs: LogFileSystem = { + readFile: async () => { throw new Error(failure); }, + realpath: async (candidate) => candidate, + watch: () => ({ close() {} }), + }; + const result = await tailDoctorServiceLog({ argv: ["--no-follow"], workspaceDir: root, write() {}, fs }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain(failure); + } + }); + + it("reads only Doctor and rejects Hive, Honeycomb, and Nectar source substitution", async () => { + const poison = new Map([ + [doctorServiceLogPath(resolve("virtual-apiary", "hive")), "HIVE_POISON"], + [doctorServiceLogPath(resolve("virtual-apiary", "honeycomb")), "HONEYCOMB_POISON"], + [doctorServiceLogPath(resolve("virtual-apiary", "nectar")), "NECTAR_POISON"], + ]); + const fs = memoryLogFs("DOCTOR_IDENTITY\n"); + const output: string[] = []; + expect(await tailDoctorServiceLog({ argv: ["--no-follow"], workspaceDir: root, write: (line) => output.push(line), fs })).toEqual({ ok: true }); + expect(fs.reads).toEqual([path]); + expect(output.join("")).toContain("DOCTOR_IDENTITY"); + for (const marker of poison.values()) expect(output.join("")).not.toContain(marker); + }); + + it("maps Ctrl+C abort to exit 0 and removes its process listener", async () => { + const before = process.listenerCount("SIGINT"); + const h = buildCliHarness({ + tailServiceLogs: async (_args, _write, signal) => new Promise((resolveDone) => { + signal?.addEventListener("abort", () => resolveDone({ ok: true }), { once: true }); + }), + }); + const pending = dispatch(["logs"], h.ctx); + await new Promise((resolveReady) => setImmediate(resolveReady)); + process.emit("SIGINT"); + expect(await pending).toBe(0); + expect(process.listenerCount("SIGINT")).toBe(before); + }); +}); diff --git a/tests/cli/standard-interface.test.ts b/tests/cli/standard-interface.test.ts new file mode 100644 index 0000000..a62ccb7 --- /dev/null +++ b/tests/cli/standard-interface.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it, vi } from "vitest"; +import { validateManifest } from "@legioncodeinc/cli-kit"; + +import { DOCTOR_MANIFEST } from "../../src/cli/command-table.js"; +import { renderBannerWithMenu } from "../../src/cli/banner.js"; +import { createColors } from "../../src/cli/colors.js"; +import { dispatch } from "../../src/cli/dispatch.js"; +import { DOCTOR_VERSION } from "../../src/version.js"; +import { buildCliHarness } from "./helpers/fake-cli.js"; + +describe("PRD-003 Doctor CLI conformance", () => { + it("uses the shared manifest with only Doctor's register exemption", () => { + expect(validateManifest(DOCTOR_MANIFEST)).toEqual([]); + const names = DOCTOR_MANIFEST.commands.map(({ name }) => name); + expect(names).toEqual(expect.arrayContaining([ + "start", "stop", "restart", "install", "uninstall", "service-install", + "service-uninstall", "update", "status", "logs", "telemetry", + ])); + expect(names).not.toContain("register"); + }); + + it("renders Doctor art, uppercase identity, exact credit, version, groups, and globals", () => { + const text = renderBannerWithMenu(createColors({ env: {}, isTty: false })); + expect(text).toContain("DOCTOR"); + expect(text).toContain("Legion Code Inc. x Activeloop"); + expect(text).toContain("Service lifecycle"); + expect(text).toContain("Product commands"); + expect(text).toContain("--json"); + }); + + it("returns usage exit 2 for register and emits a clean JSON error", async () => { + const h = buildCliHarness(); + expect(await dispatch(["register", "--json"], h.ctx)).toBe(2); + const payload = JSON.parse(h.out.text()) as Record; + expect(payload).toMatchObject({ product: "doctor", command: "register", ok: false }); + expect(h.out.text()).not.toContain("Legion Code Inc."); + expect(h.out.text()).not.toContain("\u001b"); + const withHelp = buildCliHarness(); + expect(await dispatch(["register", "--help"], withHelp.ctx)).toBe(2); + expect(withHelp.out.errText()).toContain("Unknown command: register"); + }); + + it("emits one stable JSON envelope for every Doctor baseline command", async () => { + for (const command of ["start", "stop", "restart", "install", "uninstall", "service-install", "service-uninstall", "update", "status", "logs", "telemetry"]) { + let state: "running" | "not-running" = command === "start" ? "not-running" : "running"; + let registered = true; + const serviceModule = { + install: async () => { registered = true; state = "running"; return { ok: true, message: "installed" }; }, + uninstall: async () => { registered = false; state = "not-running"; return { ok: true, message: "removed" }; }, + }; + const h = buildCliHarness({ + serviceModule, + serviceLifecycle: { + start: async () => { state = "running"; return { ok: true, message: "started" }; }, + stop: async () => { state = "not-running"; return { ok: true, message: "stopped" }; }, + }, + productUninstall: { + precheck: () => ({ registryEntryExists: true, stateDirExists: true }), + serviceStatusAsync: async () => state, + isServiceRegistered: async () => registered, + serviceUninstall: serviceModule.uninstall, + removeState: () => ({ registryEntryRemoved: true, stateDirRemoved: true }), + }, + tailServiceLogs: async (_args, write) => { write("Doctor log\n"); return { ok: true }; }, + telemetrySummary: () => ({ state: "enabled", controllingSetting: "default", destination: "hosted", optOutInstruction: "Set DO_NOT_TRACK=1" }), + }); + const args = [command, ...(command === "uninstall" ? ["--yes"] : []), ...(command === "logs" ? ["--no-follow"] : []), "--json"]; + await dispatch(args, h.ctx); + expect(h.out.stdout).toHaveLength(1); + expect(JSON.parse(h.out.stdout[0] ?? "{}")).toMatchObject({ product: "doctor", command, ok: true }); + } + }); + + it("makes bare invocation byte-equivalent to help and renders exact human/JSON versions", async () => { + const bare = buildCliHarness(); + const help = buildCliHarness(); + expect(await dispatch([], bare.ctx)).toBe(0); + expect(await dispatch(["--help"], help.ctx)).toBe(0); + expect(bare.out.text()).toBe(help.out.text()); + const humanVersion = buildCliHarness(); + expect(await dispatch(["--version"], humanVersion.ctx)).toBe(0); + expect(humanVersion.out.text()).toBe(`doctor v${DOCTOR_VERSION}`); + const jsonVersion = buildCliHarness(); + expect(await dispatch(["--version", "--json"], jsonVersion.ctx)).toBe(0); + expect(JSON.parse(jsonVersion.out.text())).toEqual({ product: "doctor", command: "version", ok: true, message: "version", version: DOCTOR_VERSION }); + }); + + it("rejects unknown options, values on booleans, positionals, and malformed logs as usage 2", async () => { + for (const args of [ + ["start", "--bogus"], ["telemetry", "--bogus"], ["status", "extra"], + ["update", "--check=yes"], ["logs", "--lines", "0", "--no-follow"], + ["incidents", "--daemon"], ["--bogus"], + ]) { + for (const json of [false, true]) { + const h = buildCliHarness(); + const code = await dispatch([...args, ...(json ? ["--json"] : [])], h.ctx); + expect(code, args.join(" ")).toBe(2); + if (json) expect(JSON.parse(h.out.text())).toMatchObject({ product: "doctor", ok: false }); + } + } + }); + + it("preserves supported Doctor product options", async () => { + const h = buildCliHarness({ incidentsByDaemon: { honeycomb: ["incident"] } }); + expect(await dispatch(["incidents", "--daemon", "honeycomb", "--lines", "1"], h.ctx)).toBe(0); + const update = buildCliHarness(); + expect(await dispatch(["daemon-update", "--check"], update.ctx)).toBe(0); + }); + + it("goldens running, stopped, not-installed, and unhealthy status in human and JSON", async () => { + const cases = [ + { name: "running", state: "running" as const, registered: true, classification: { kind: "ok" as const } }, + { name: "stopped", state: "not-running" as const, registered: true, classification: { kind: "ok" as const } }, + { name: "not-installed", state: "unknown" as const, registered: false, classification: { kind: "ok" as const } }, + { name: "unhealthy", state: "running" as const, registered: true, classification: { kind: "unreachable-timeout" as const } }, + ]; + for (const item of cases) { + const productUninstall = { + precheck: () => ({ registryEntryExists: false, stateDirExists: false }), + serviceStatusAsync: async () => item.state, + isServiceRegistered: async () => item.registered, + serviceUninstall: async () => ({ ok: true, message: "removed" }), + removeState: () => ({ registryEntryRemoved: false, stateDirRemoved: false }), + }; + const human = buildCliHarness({ serviceStateAsync: async () => item.state, productUninstall, classification: item.classification, paths: { config: "/apiary/doctor", logs: "/apiary/doctor/service.log" } }); + expect(await dispatch(["status"], human.ctx)).toBe(0); + expect(human.out.text()).toMatchSnapshot(`${item.name} status human`); + const json = buildCliHarness({ serviceStateAsync: async () => item.state, productUninstall, classification: item.classification, paths: { config: "/apiary/doctor", logs: "/apiary/doctor/service.log" } }); + expect(await dispatch(["status", "--json"], json.ctx)).toBe(0); + expect(JSON.parse(json.out.text())).toMatchSnapshot(`${item.name} status JSON`); + } + }); + + it("goldens enabled and opted-out telemetry in human and JSON", async () => { + for (const telemetry of [ + { state: "enabled" as const, controllingSetting: "default", destination: "hosted" as const, optOutInstruction: "Set DO_NOT_TRACK=1" }, + { state: "opted-out" as const, controllingSetting: "DO_NOT_TRACK", destination: "disabled" as const, optOutInstruction: "Unset DO_NOT_TRACK" }, + ]) { + const human = buildCliHarness({ telemetrySummary: () => telemetry }); + expect(await dispatch(["telemetry"], human.ctx)).toBe(0); + expect(human.out.text()).toMatchSnapshot(`${telemetry.state} telemetry human`); + const json = buildCliHarness({ telemetrySummary: () => telemetry }); + expect(await dispatch(["telemetry", "--json"], json.ctx)).toBe(0); + expect(JSON.parse(json.out.text())).toMatchSnapshot(`${telemetry.state} telemetry JSON`); + } + }); + + it("telemetry is read-only and structured in JSON mode", async () => { + const h = buildCliHarness(); + const probe = vi.fn(() => ({ + state: "opted-out" as const, + controllingSetting: "DO_NOT_TRACK", + destination: "disabled" as const, + optOutInstruction: "unset DO_NOT_TRACK", + })); + const ctx = { ...h.ctx, deps: { ...h.ctx.deps, telemetrySummary: probe } }; + expect(await dispatch(["telemetry", "--json"], ctx)).toBe(0); + expect(JSON.parse(h.out.text())).toMatchObject({ telemetry: { state: "opted-out", destination: "disabled" } }); + expect(probe).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/compose/registry-reload-reconcile.test.ts b/tests/compose/registry-reload-reconcile.test.ts index 00860f2..a6ecc6b 100644 --- a/tests/compose/registry-reload-reconcile.test.ts +++ b/tests/compose/registry-reload-reconcile.test.ts @@ -258,10 +258,10 @@ describe("createDoctor registry live-reload + reconcile (PRD-005)", () => { } }); - it("AC-9: doctor ships with no external runtime dependency", () => { + it("PRD-003: doctor ships only with the shared zero-dependency CLI contract", () => { const pkg = JSON.parse( readFileSync(join(import.meta.dirname, "..", "..", "package.json"), "utf8"), ) as { dependencies?: Record }; - expect(pkg.dependencies ?? {}).toEqual({}); + expect(pkg.dependencies ?? {}).toEqual({ "@legioncodeinc/cli-kit": "^0.3.0" }); }); }); diff --git a/tests/config.test.ts b/tests/config.test.ts index 505c820..da38d7c 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -125,11 +125,11 @@ describe("resolveConfig fleet-root workspace (PRD-004a / ADR-0003)", () => { }); }); -describe("doctor design principle 1 (module AC-9): zero runtime dependencies", () => { - it("AC-9: package.json declares no runtime `dependencies` (Node built-ins only)", () => { +describe("doctor runtime dependency boundary", () => { + it("PRD-003: package.json allows only the shared zero-dependency CLI kit", () => { const pkgPath = join(dirname(fileURLToPath(import.meta.url)), "..", "package.json"); const pkg = JSON.parse(readFileSync(pkgPath, "utf8")) as { dependencies?: Record }; // Either no dependencies key at all, or an empty one: no external runtime package is added. - expect(Object.keys(pkg.dependencies ?? {})).toEqual([]); + expect(Object.keys(pkg.dependencies ?? {})).toEqual(["@legioncodeinc/cli-kit"]); }); }); diff --git a/tests/service/argv.test.ts b/tests/service/argv.test.ts index b27433c..eba7840 100644 --- a/tests/service/argv.test.ts +++ b/tests/service/argv.test.ts @@ -6,12 +6,15 @@ import { describe, expect, it } from "vitest"; import { + encodeWindowsCleanupPath, installCommands, launchdServiceTarget, startCommands, statusCommand, stopCommands, uninstallCommands, + WINDOWS_DOCTOR_PROCESS_CLEANUP_SCRIPT, + WINDOWS_POWERSHELL_PATH, } from "../../src/service/argv.js"; import { resolveServicePlan, SERVICE_LABEL, SYSTEMD_UNIT_NAME, WINDOWS_TASK_NAME } from "../../src/service/platform.js"; import { fixedEnv } from "./helpers.js"; @@ -21,10 +24,11 @@ const UID = 501; describe("launchd argv (macOS, user scope)", () => { const plan = resolveServicePlan(fixedEnv({ platform: "darwin", home: "/Users/t" })); - it("install: bootstrap into gui/ then kickstart the service", () => { + it("install: unloads any prior job, bootstraps the plist, then kickstarts", () => { const cmds = installCommands(plan, UID); - expect(cmds[0]).toEqual({ command: "launchctl", args: ["bootstrap", `gui/${UID}`, plan.unitPath] }); - expect(cmds[1]).toEqual({ command: "launchctl", args: ["kickstart", "-k", `gui/${UID}/${SERVICE_LABEL}`] }); + expect(cmds[0]).toEqual({ command: "launchctl", args: ["bootout", `gui/${UID}/${SERVICE_LABEL}`] }); + expect(cmds[1]).toEqual({ command: "launchctl", args: ["bootstrap", `gui/${UID}`, plan.unitPath] }); + expect(cmds[2]).toEqual({ command: "launchctl", args: ["kickstart", "-k", `gui/${UID}/${SERVICE_LABEL}`] }); }); it("uninstall: bootout the service target", () => { @@ -39,22 +43,23 @@ describe("launchd argv (macOS, user scope)", () => { }); }); - it("b-AC-1: start kickstarts the existing service target", () => { + it("b-AC-1: start reloads the retained plist then kickstarts", () => { expect(startCommands(plan, UID)).toEqual([ + { command: "launchctl", args: ["bootstrap", `gui/${UID}`, plan.unitPath] }, { command: "launchctl", args: ["kickstart", "-k", `gui/${UID}/${SERVICE_LABEL}`] }, ]); }); - it("b-AC-1: stop sends SIGTERM without unloading the unit (no bootout)", () => { + it("b-AC-1: stop unloads KeepAlive while retaining the plist", () => { expect(stopCommands(plan, UID)).toEqual([ - { command: "launchctl", args: ["kill", "SIGTERM", `gui/${UID}/${SERVICE_LABEL}`] }, + { command: "launchctl", args: ["bootout", `gui/${UID}/${SERVICE_LABEL}`] }, ]); }); it("system scope targets the `system` domain, not gui/", () => { const sys = resolveServicePlan(fixedEnv({ platform: "darwin", privileged: true, preferSystemScope: true })); expect(launchdServiceTarget(sys, UID)).toBe(`system/${SERVICE_LABEL}`); - expect(installCommands(sys, UID)[0]).toEqual({ + expect(installCommands(sys, UID)[1]).toEqual({ command: "launchctl", args: ["bootstrap", "system", sys.unitPath], }); @@ -115,15 +120,30 @@ describe("schtasks argv (Windows per-user, the default)", () => { it("install: /Create /XML /TN Doctor /F then /Run", () => { const cmds = installCommands(plan, UID); - expect(cmds[0]).toEqual({ + expect(cmds[0]).toEqual({ command: "schtasks", args: ["/End", "/TN", WINDOWS_TASK_NAME] }); + expect(cmds[1]).toEqual({ + command: WINDOWS_POWERSHELL_PATH, + args: [ + "-NoLogo", "-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", + "-Command", WINDOWS_DOCTOR_PROCESS_CLEANUP_SCRIPT, + encodeWindowsCleanupPath(plan.execPath), encodeWindowsCleanupPath(process.execPath), + ], + }); + expect(cmds[2]).toEqual({ command: "schtasks", args: ["/Create", "/XML", plan.unitPath, "/TN", WINDOWS_TASK_NAME, "/F"], }); - expect(cmds[1]).toEqual({ command: "schtasks", args: ["/Run", "/TN", WINDOWS_TASK_NAME] }); + expect(cmds[3]).toEqual({ command: "schtasks", args: ["/Run", "/TN", WINDOWS_TASK_NAME] }); }); - it("uninstall: /Delete /TN Doctor /F", () => { - expect(uninstallCommands(plan, UID)[0]).toEqual({ + it("uninstall: ends the task, kills only the exact Doctor child, then deletes", () => { + const cmds = uninstallCommands(plan, UID); + expect(cmds[0]).toEqual({ command: "schtasks", args: ["/End", "/TN", WINDOWS_TASK_NAME] }); + expect(cmds[1]?.command).toBe(WINDOWS_POWERSHELL_PATH); + expect(cmds[1]?.args.slice(-2)).toEqual([ + encodeWindowsCleanupPath(plan.execPath), encodeWindowsCleanupPath(process.execPath), + ]); + expect(cmds[2]).toEqual({ command: "schtasks", args: ["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"], }); @@ -136,19 +156,30 @@ describe("schtasks argv (Windows per-user, the default)", () => { }); }); - it("b-AC-1: start: /Run /TN Doctor", () => { - expect(startCommands(plan, UID)).toEqual([{ command: "schtasks", args: ["/Run", "/TN", WINDOWS_TASK_NAME] }]); + it("b-AC-1: start reaps an exact stale child before /Run /TN Doctor", () => { + const cmds = startCommands(plan, UID); + expect(cmds[0]?.command).toBe(WINDOWS_POWERSHELL_PATH); + expect(cmds[0]?.args.slice(-2)).toEqual([ + encodeWindowsCleanupPath(plan.execPath), encodeWindowsCleanupPath(process.execPath), + ]); + expect(cmds[1]).toEqual({ command: "schtasks", args: ["/Run", "/TN", WINDOWS_TASK_NAME] }); }); - it("b-AC-1: stop: /End /TN Doctor", () => { - expect(stopCommands(plan, UID)).toEqual([{ command: "schtasks", args: ["/End", "/TN", WINDOWS_TASK_NAME] }]); + it("b-AC-1: stop ends the task and kills only its exact Doctor child", () => { + const cmds = stopCommands(plan, UID); + expect(cmds[0]).toEqual({ command: "schtasks", args: ["/End", "/TN", WINDOWS_TASK_NAME] }); + expect(cmds[1]?.command).toBe(WINDOWS_POWERSHELL_PATH); + expect(cmds[1]?.args).toContain(WINDOWS_DOCTOR_PROCESS_CLEANUP_SCRIPT); + expect(cmds[1]?.args.slice(-2)).toEqual([ + encodeWindowsCleanupPath(plan.execPath), encodeWindowsCleanupPath(process.execPath), + ]); }); }); describe("sc.exe argv (Windows Service, enterprise opt-in)", () => { const plan = resolveServicePlan(fixedEnv({ platform: "win32", privileged: true, preferSystemScope: true })); - it("install: sc create ... start= auto, then sc start", () => { + it("install: sc create, reconcile config, then start", () => { const cmds = installCommands(plan, UID); const create = cmds[0]; expect(create).toBeDefined(); @@ -156,7 +187,8 @@ describe("sc.exe argv (Windows Service, enterprise opt-in)", () => { expect(create?.args.slice(0, 2)).toEqual(["create", WINDOWS_TASK_NAME]); expect(create?.args).toContain("start="); expect(create?.args).toContain("auto"); - expect(cmds[1]).toEqual({ command: "sc", args: ["start", WINDOWS_TASK_NAME] }); + expect(cmds[1]?.args.slice(0, 2)).toEqual(["config", WINDOWS_TASK_NAME]); + expect(cmds[2]).toEqual({ command: "sc", args: ["start", WINDOWS_TASK_NAME] }); }); it("uninstall: sc stop then sc delete", () => { diff --git a/tests/service/native-adapter.integration.test.ts b/tests/service/native-adapter.integration.test.ts new file mode 100644 index 0000000..0e62699 --- /dev/null +++ b/tests/service/native-adapter.integration.test.ts @@ -0,0 +1,71 @@ +/** + * Executable native-adapter fixture for CI. This drives the real launchd/systemd/ + * Task Scheduler/sc adapter transactions with an in-memory manager boundary. It proves + * platform argv, reconciliation, state classification, and retained-definition stop/start + * without claiming that GitHub-hosted runners provide reboot-capable live-service proof. + */ +import { describe, expect, it } from "vitest"; + +import { createServiceModule, serviceStatus } from "../../src/service/index.js"; +import { SERVICE_LABEL } from "../../src/service/platform.js"; +import { createMemoryFs, createRecordingRunner, fixedEnv } from "./helpers.js"; + +describe("native service adapter transaction fixture", () => { + for (const target of [ + { name: "launchd", environment: fixedEnv({ platform: "darwin", home: "/Users/fixture" }) }, + { name: "systemd", environment: fixedEnv({ platform: "linux", home: "/home/fixture" }) }, + { name: "schtasks", environment: fixedEnv({ platform: "win32", home: "C:\\Users\\fixture" }) }, + { name: "sc", environment: fixedEnv({ platform: "win32", privileged: true, preferSystemScope: true }) }, + ] as const) { + it(`${target.name}: install -> stop -> start -> uninstall`, async () => { + let registered = false; + let running = false; + const runner = createRecordingRunner((command, args) => { + const joined = args.join(" "); + if (joined.includes("hivedoctor") || joined.includes("HiveDoctor")) return { ok: false, code: 3, stdout: "", stderr: "not found" }; + if (command.toLowerCase().includes("whoami")) return { ok: false, code: 1, stdout: "", stderr: "fixture identity unavailable" }; + if (command === "launchctl") { + if (args[0] === "bootstrap") registered = true; + if (args[0] === "kickstart") running = true; + if (args[0] === "bootout" && joined.includes(SERVICE_LABEL)) running = false; + if (args[0] === "print") return running ? { ok: true, code: 0, stdout: "pid = 123", stderr: "" } : { ok: false, code: 3, stdout: "", stderr: "not loaded" }; + } + if (command === "systemctl") { + if (args.includes("enable") || args.includes("start")) { registered = true; running = true; } + if (args.includes("stop")) running = false; + if (args.includes("disable")) { registered = false; running = false; } + if (args.includes("is-active")) return running ? { ok: true, code: 0, stdout: "active\n", stderr: "" } : { ok: false, code: 3, stdout: "inactive\n", stderr: "" }; + } + if (command === "schtasks") { + if (args[0]?.toLowerCase() === "/create") registered = true; + if (args[0]?.toLowerCase() === "/run") running = true; + if (args[0]?.toLowerCase() === "/end") running = false; + if (args[0]?.toLowerCase() === "/delete") { registered = false; running = false; } + if (args[0]?.toLowerCase() === "/query") return registered + ? { ok: true, code: 0, stdout: `Status: ${running ? "Running" : "Ready"}\n`, stderr: "" } + : { ok: false, code: 1, stdout: "", stderr: "not found" }; + } + if (command === "sc") { + if (args[0] === "create" || args[0] === "config") registered = true; + if (args[0] === "start") running = true; + if (args[0] === "stop") running = false; + if (args[0] === "delete") { registered = false; running = false; } + if (args[0] === "query") return registered + ? { ok: true, code: 0, stdout: `STATE : ${running ? "4 RUNNING" : "1 STOPPED"}\n`, stderr: "" } + : { ok: false, code: 1060, stdout: "", stderr: "not found" }; + } + return { ok: true, code: 0, stdout: "", stderr: "" }; + }); + const deps = { execPath: target.name === "sc" ? "C:\\bin\\doctor.cmd" : "/opt/doctor", runner, fs: createMemoryFs(), environment: target.environment }; + const module = createServiceModule(deps); + expect((await module.install()).ok).toBe(true); + expect(await serviceStatus(deps)).toBe("running"); + expect((await module.stop()).ok).toBe(true); + expect(await serviceStatus(deps)).toBe("not-running"); + expect((await module.start()).ok).toBe(true); + expect(await serviceStatus(deps)).toBe("running"); + expect((await module.uninstall()).ok).toBe(true); + expect(runner.calls.length).toBeGreaterThan(4); + }); + } +}); diff --git a/tests/service/service-module.test.ts b/tests/service/service-module.test.ts index 21987d8..4eb5484 100644 --- a/tests/service/service-module.test.ts +++ b/tests/service/service-module.test.ts @@ -15,9 +15,12 @@ import { serviceStatus, serviceStop, } from "../../src/service/index.js"; +import { encodeWindowsCleanupPath } from "../../src/service/argv.js"; import { SERVICE_LABEL } from "../../src/service/platform.js"; import { createMemoryFs, createRecordingRunner, fixedEnv } from "./helpers.js"; +const TEST_MACOS_UID = 501; + describe("install - writes the unit file then runs the manager argv", () => { it("Linux: writes the systemd unit, then enables it (file before command)", async () => { const runner = createRecordingRunner(); @@ -58,6 +61,7 @@ describe("install - writes the unit file then runs the manager argv", () => { execPath: "/opt/doctor", runner, fs, + uid: TEST_MACOS_UID, environment: fixedEnv({ platform: "darwin", home: "/Users/t" }), }); @@ -69,7 +73,9 @@ describe("install - writes the unit file then runs the manager argv", () => { expect(runner.calls[0]?.command).toBe("launchctl"); expect(runner.calls[0]?.args[0]).toBe("bootout"); expect(runner.calls[0]?.args[1]).toContain("com.legioncode.hivedoctor"); - expect(runner.calls[1]?.args[0]).toBe("bootstrap"); + // Current-label bootout reconciles an already-loaded job before the new plist is loaded. + expect(runner.calls[1]?.args).toEqual(["bootout", `gui/${TEST_MACOS_UID}/${SERVICE_LABEL}`]); + expect(runner.calls[2]?.args[0]).toBe("bootstrap"); expect(result.ok).toBe(true); }); @@ -91,17 +97,23 @@ describe("install - writes the unit file then runs the manager argv", () => { // IRD-192 AC-2: the staged XML carries the Task-Scheduler-valid PT1M interval. expect(fs.files.get(staged)).toContain("PT1M"); expect(fs.files.get(staged)).toContain(" { expect(result.message).not.toContain(longLine); }); + it("macOS: already-absent bootout is tolerated and fixed argv still reloads and starts", async () => { + const runner = createRecordingRunner((command, args) => + command === "launchctl" && args[0] === "bootout" && String(args[1]).includes(SERVICE_LABEL) + ? { ok: false, code: 3, stdout: "", stderr: "Could not find service" } + : { ok: true, code: 0, stdout: "", stderr: "" }, + ); + const module = createServiceModule({ + execPath: "/opt/doctor", + runner, + fs: createMemoryFs(), + environment: fixedEnv({ platform: "darwin", home: "/Users/t" }), + }); + expect((await module.install()).ok).toBe(true); + expect(runner.calls.slice(-3).map(({ args }) => args[0])).toEqual(["bootout", "bootstrap", "kickstart"]); + }); + + it("Windows sc: already-existing and already-running results reconcile through config", async () => { + const runner = createRecordingRunner((command, args) => { + if (command === "sc" && args[0] === "create") return { ok: false, code: 1073, stdout: "", stderr: "service already exists" }; + if (command === "sc" && args[0] === "start") return { ok: false, code: 1056, stdout: "", stderr: "service already running" }; + return { ok: true, code: 0, stdout: "", stderr: "" }; + }); + const module = createServiceModule({ + execPath: "C:\\bin\\doctor.cmd", + runner, + fs: createMemoryFs(), + environment: fixedEnv({ platform: "win32", privileged: true, preferSystemScope: true }), + }); + expect((await module.install()).ok).toBe(true); + expect(runner.calls.some(({ command, args }) => command === "sc" && args[0] === "config")).toBe(true); + }); + + it("Windows Scheduled Task: /F reconciles the definition and already-running is success", async () => { + const runner = createRecordingRunner((command, args) => + command === "schtasks" && args[0] === "/Run" + ? { ok: false, code: 1, stdout: "", stderr: "The task is already running" } + : { ok: true, code: 0, stdout: "", stderr: "" }, + ); + const module = createServiceModule({ + execPath: "C:\\bin\\doctor.cmd", + runner, + fs: createMemoryFs(), + environment: fixedEnv({ platform: "win32", home: "C:\\Users\\t", execPath: "C:\\bin\\doctor.cmd" }), + windowsIdentity: { systemRoot: "C:\\Windows" }, + }); + expect((await module.install()).ok).toBe(true); + expect(runner.calls.some(({ command, args }) => command === "schtasks" && args.includes("/F"))).toBe(true); + }); + it("an unsupported platform returns a clean message, never throws", async () => { const module = createServiceModule({ execPath: "/x", @@ -332,12 +393,17 @@ describe("uninstall - deregisters then removes the unit file (AC-064b.5)", () => execPath: "C:\\bin\\doctor.cmd", runner, fs, - environment: fixedEnv({ platform: "win32", home: "C:\\Users\\t" }), + environment: fixedEnv({ platform: "win32", home: "C:\\Users\\t", execPath: "C:\\bin\\doctor.cmd" }), }); await module.uninstall(); - expect(runner.calls[0]).toEqual({ command: "schtasks", args: ["/Delete", "/TN", "doctor", "/F"] }); + expect(runner.calls[0]).toEqual({ command: "schtasks", args: ["/End", "/TN", "doctor"] }); + expect(runner.calls[1]?.command).toContain("WindowsPowerShell"); + expect(runner.calls[1]?.args.slice(-2)).toEqual([ + encodeWindowsCleanupPath("C:\\bin\\doctor.cmd"), encodeWindowsCleanupPath(process.execPath), + ]); + expect(runner.calls[2]).toEqual({ command: "schtasks", args: ["/Delete", "/TN", "doctor", "/F"] }); expect(fs.removed).toContain("C:\\Users\\t/.apiary/doctor/doctor-task.xml"); }); @@ -387,32 +453,42 @@ describe("start/stop (PRD-003b b-AC-1)", () => { expect(result.message).toContain("remains registered"); }); - it("macOS: start kickstarts, stop sends SIGTERM (no bootout)", async () => { + it("macOS: start bootstraps the retained plist and stop bootouts KeepAlive", async () => { const runner = createRecordingRunner(); + const unitPath = `/Users/t/Library/LaunchAgents/${SERVICE_LABEL}.plist`; + const fs = createMemoryFs(false, [unitPath]); const module = createServiceModule({ execPath: "/opt/doctor", runner, - fs: createMemoryFs(), + fs, + uid: TEST_MACOS_UID, environment: fixedEnv({ platform: "darwin", home: "/Users/t" }), }); await module.start(); - expect(runner.calls[0]?.args[0]).toBe("kickstart"); + expect(runner.calls[0]?.args[0]).toBe("bootstrap"); + expect(runner.calls[1]?.args[0]).toBe("kickstart"); await module.stop(); - expect(runner.calls[1]?.args).toEqual(expect.arrayContaining(["kill", "SIGTERM"])); + expect(runner.calls[2]?.args).toEqual(["bootout", `gui/${TEST_MACOS_UID}/${SERVICE_LABEL}`]); + expect(fs.files.has(unitPath)).toBe(true); }); - it("Windows: start runs /Run, stop runs /End", async () => { + it("Windows: start reaps an exact stale child before /Run; stop ends the task and reaps again", async () => { const runner = createRecordingRunner(); const module = createServiceModule({ execPath: "C:\\bin\\doctor.cmd", runner, fs: createMemoryFs(), - environment: fixedEnv({ platform: "win32" }), + environment: fixedEnv({ platform: "win32", execPath: "C:\\bin\\doctor.cmd" }), }); await module.start(); - expect(runner.calls[0]).toEqual({ command: "schtasks", args: ["/Run", "/TN", "doctor"] }); + expect(runner.calls[0]?.command).toContain("WindowsPowerShell"); + expect(runner.calls[1]).toEqual({ command: "schtasks", args: ["/Run", "/TN", "doctor"] }); await module.stop(); - expect(runner.calls[1]).toEqual({ command: "schtasks", args: ["/End", "/TN", "doctor"] }); + expect(runner.calls[2]).toEqual({ command: "schtasks", args: ["/End", "/TN", "doctor"] }); + expect(runner.calls[3]?.command).toContain("WindowsPowerShell"); + expect(runner.calls[3]?.args.slice(-2)).toEqual([ + encodeWindowsCleanupPath("C:\\bin\\doctor.cmd"), encodeWindowsCleanupPath(process.execPath), + ]); }); it("a manager-command failure on start/stop resolves ok:false, never throws", async () => { @@ -578,6 +654,21 @@ describe("serviceStatus classification", () => { }); expect(status).toBe("running"); }); + + it("schtasks Ready and sc STOPPED query output -> not-running", async () => { + const task = await serviceStatus({ + execPath: "C:\\doctor.cmd", + runner: createRecordingRunner(() => ({ ok: true, code: 0, stdout: "Status: Ready\r\n", stderr: "" })), + environment: fixedEnv({ platform: "win32", execPath: "C:\\bin\\doctor.cmd" }), + }); + const service = await serviceStatus({ + execPath: "C:\\doctor.cmd", + runner: createRecordingRunner(() => ({ ok: true, code: 0, stdout: "STATE : 1 STOPPED\r\n", stderr: "" })), + environment: fixedEnv({ platform: "win32", privileged: true, preferSystemScope: true }), + }); + expect(task).toBe("not-running"); + expect(service).toBe("not-running"); + }); }); describe("isServiceRegistered (PRD-003b b-AC-6 fix): registration evidence, not activity", () => { diff --git a/tests/service/templates.test.ts b/tests/service/templates.test.ts index c580987..cda33c4 100644 --- a/tests/service/templates.test.ts +++ b/tests/service/templates.test.ts @@ -48,7 +48,7 @@ describe("renderLaunchdPlist (macOS)", () => { it("writes logs under the fleet root doctor state dir (no root required)", () => { // ADR-0003 (PRD-004a): doctor's state moved from ~/.honeycomb/doctor to /doctor. - expect(xml).toContain("/Users/t/.apiary/doctor/launchd.out.log"); + expect(xml).toContain("/Users/t/.apiary/doctor/service.log"); }); });