diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index c8b20a79c..11c6e2336 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -199,7 +199,7 @@ is removed after use. The app never types into an existing terminal or pane. Source: `hack doctor --json --path ` per registered project, plus one run outside any project for machine checks (until R7c), on open and every 10 -minutes while visible. Today it gives `checks[{id,status,detail}]`. +minutes while visible. The versioned audit gives `{ok:true,data:{schema:1,checks,summary,counts}}`; checks include `id`, `status`, `detail`, `area`, `scope` and `fix` (R7a). | Card part | Content | |---|---| @@ -209,9 +209,13 @@ minutes while visible. Today it gives `checks[{id,status,detail}]`. | One action | Run safe fix (R7b) · Open Terminal (admin) · Copy command · Open doc | Machine findings are de-duplicated by check `id` and shown once; project -findings link from that project's header. Until R7a adds `area`, `scope` and -`fix`, `core` maps known `id`s to areas and shows unknown ones under "Other" -with only Copy and Open doc. No Run button appears without a `fix.kind: safe`. +findings link from that project's header. Schema 1 supplies `area`, `scope` and +`fix`; older CLI versions still need `core`'s known-ID mapping. Unknown checks +appear under "Other" with only Copy and Open doc. No Run button appears without +a `fix.kind: safe`. + +Browser observations and native hostname DNS/HTTPS route checks are project-scoped: +each describes the selected exact target and must not be de-duplicated across projects. ## 4. Upgrades @@ -375,7 +379,7 @@ Each is small enough for one PR. 6. **R5a** Activity writer: append-only `activity.jsonl` in the Hack home, one line per `up`/`down`/`restart`/autostart (`ts, project, branch, action, origin, outcome, failed_services, duration_ms`), `O_APPEND` writes under one line size limit, rotation at a size cap, `origin` from `HACK_ORIGIN` (the app sets `app`). 7. **R5b** Activity reader: `hack activity --json [--project] [--limit]`. 8. **R6** Action progress: `hack up|down|restart --json --progress` emitting ndjson `{type:"progress", service, phase}` before the final envelope. -9. **R7a** Doctor metadata (a new doctor schema): per check `area`, `scope: machine | project`, `fix: {kind: safe | admin | manual, args[], doc_url}`. +9. **R7a — exists.** Doctor audit schema 1 adds per-check `area`, `scope: machine | project` and `fix: {kind: safe | admin | manual, args[], doc_url}` inside the existing `{ok,data}` envelope. Unknown checks default to Other/manual with no executable args. A category is guidance, not permission to run it. 10. **R7b** One fix without prompts: `hack doctor --fix --only --yes --json`, only for `kind: safe`. 11. **R7c** Machine-only doctor: `hack doctor --machine --json` (no project), so the app does not need to de-duplicate. 12. **R8** Daemon unit staleness: `service.unit_stale` and reason in `hack daemon status --json`, and `version` there. diff --git a/docs/versions/v5/troubleshooting.md b/docs/versions/v5/troubleshooting.md index 8f32405f7..a76eac28b 100644 --- a/docs/versions/v5/troubleshooting.md +++ b/docs/versions/v5/troubleshooting.md @@ -6,6 +6,8 @@ native, use the full `hack-next` path from [Try native](native/start.md). Read the named finding before using `--fix`; some problems need a decision or an original key, not an automatic repair. +`hack doctor --json` returns `{ok:true,data:{schema:1,checks,summary,counts}}`. Each check adds an area, machine/project scope and a fix category (`safe`, `admin` or `manual`), argv after `hack`, and a documentation URL. Empty argv means consult the docs. The audit remains read-only; `ok:true` means the audit ran, while error findings still exit 1. Unknown checks offer no runnable repair. + The rows marked **next.17 source** describe merged code intended for next.17. They do not change an installed next.16 bundle. See the [release notes](../guides/v5-release-notes.md) for version boundaries. diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 3b4d6e660..76eb83c91 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -132,6 +132,11 @@ import { untrackGeneratedFiles, } from "../lib/doctor-generated-files.ts"; import { checkMacHostTlsTrust } from "../lib/doctor-host-tls.ts"; +import { + type DoctorMetadata, + type DoctorScope, + doctorMetadata, +} from "../lib/doctor-metadata.ts"; import { AMD64_EMULATION_CHECK, type Amd64EmulationReport, @@ -325,6 +330,7 @@ interface CheckResult { readonly name: string; readonly status: CheckStatus; readonly message: string; + readonly scope?: DoctorScope; /** Counts and public fingerprints only; never paths or keychain contents. */ readonly supersededRoots?: SupersededRootInventory; /** Native hack.local route state; lines and paths only, never file contents. */ @@ -1568,7 +1574,7 @@ export async function checkNativeHackLocalRoute(opts: { return results; } -export type DoctorJsonCheck = { +export type DoctorJsonCheck = DoctorMetadata & { readonly id: string; readonly status: CheckStatus; readonly detail: string; @@ -1596,6 +1602,7 @@ export type DoctorJsonCheck = { }; export type DoctorJsonData = { + readonly schema: 1; readonly checks: readonly DoctorJsonCheck[]; readonly summary: readonly string[]; readonly counts: { @@ -1616,8 +1623,10 @@ export function buildDoctorJsonData(opts: { counts[result.status] += 1; } return { + schema: 1, checks: opts.results.map((result) => ({ id: result.name, + ...doctorMetadata({ id: result.name, scope: result.scope }), status: result.status, detail: result.message, durationMs: result.durationMs, @@ -3259,10 +3268,13 @@ export async function resolveSecretsHygieneScope(opts: { export async function checkSecretsHygiene(opts: { readonly startDir: string; }): Promise { - const findings = await inspectSecretsHygiene( - await resolveSecretsHygieneScope(opts) - ); - return { name: SECRETS_HYGIENE_CHECK, ...describeSecretsHygiene(findings) }; + const scope = await resolveSecretsHygieneScope(opts); + const findings = await inspectSecretsHygiene(scope); + return { + name: SECRETS_HYGIENE_CHECK, + scope: scope.project ? "project" : "machine", + ...describeSecretsHygiene(findings), + }; } function describeSecretsHygiene(findings: readonly SecretsHygieneFinding[]): { diff --git a/src/lib/doctor-metadata.ts b/src/lib/doctor-metadata.ts new file mode 100644 index 000000000..409e7440c --- /dev/null +++ b/src/lib/doctor-metadata.ts @@ -0,0 +1,131 @@ +/** Stable presentation metadata. Neither detail text nor a check ID grants repair authority. */ +export type DoctorArea = + | "engine" + | "routing_dns" + | "certificates" + | "daemon" + | "env_secrets" + | "project_files" + | "native" + | "other"; + +export type DoctorScope = "machine" | "project"; +export type DoctorMetadata = { + readonly area: DoctorArea; + readonly scope: DoctorScope; + readonly fix: { + readonly kind: "safe" | "admin" | "manual"; + /** Argv after `hack`; empty means consult the documentation, not a runnable repair. */ + readonly args: readonly string[]; + readonly doc_url: string; + }; +}; + +const DOC_URL = + "https://github.com/hack-dance/hack/blob/next/docs/versions/v5/troubleshooting.md"; +const PROJECT_AREAS: Readonly> = { + "browser local network": "routing_dns", + "native https route": "native", + "native host dns": "routing_dns", + project: "project_files", + "sessions mux": "project_files", + "generated files": "project_files", + "hack gitignore": "project_files", + "runtime hygiene": "engine", + "lifecycle hygiene": "engine", + "worktree instances": "engine", + "compose networks": "routing_dns", + "caddy hosts": "routing_dns", + DEV_HOST: "routing_dns", + "env mode": "env_secrets", + "env materialization": "env_secrets", + "env overlay warnings": "env_secrets", + "worktree keys": "env_secrets", + "env key": "env_secrets", + "secret integrity": "env_secrets", + "secrets hygiene": "env_secrets", + "compose env at rest": "env_secrets", + "compose backend features": "native", + "native data services": "native", + "amd64 emulation": "native", + "native settings": "native", + "native process wrappers": "native", + "native source size": "native", +}; +const MACHINE_AREAS: Readonly> = { + bun: "engine", + docker: "engine", + "docker compose": "engine", + "docker daemon": "engine", + "brew (optional)": "engine", + "dnsmasq (optional)": "routing_dns", + "mkcert (optional)": "certificates", + "gum (optional)": "other", + "fzf (optional)": "other", + "tmux (sessions)": "engine", + mutagen: "engine", + "go (optional)": "engine", + "caddy (optional)": "routing_dns", + "asdf (optional)": "other", + "global files": "routing_dns", + daemon: "daemon", + "daemon service": "daemon", + "gateway config": "other", + "gateway tokens": "other", + "linux dns route": "routing_dns", + grafana: "other", + "proxy ports": "routing_dns", + "proxy network": "routing_dns", + "coredns forwarding": "routing_dns", + "caddy local ca": "certificates", + "local ca trust": "certificates", + "host tls trust": "certificates", + "superseded caddy roots": "certificates", + "native https owner": "native", + "native root trust": "certificates", + "native ca repair": "certificates", + "native hack.local route": "routing_dns", + "native hack.local repair": "routing_dns", +}; + +/** Closed IDs and prefixes only; never parse executable instructions from diagnostic text. */ +export function doctorMetadata(opts: { + readonly id: string; + /** Mixed project/global checks set their actual scope at the observation site. */ + readonly scope?: DoctorScope; +}): DoctorMetadata { + const projectArea = Object.hasOwn(PROJECT_AREAS, opts.id) + ? PROJECT_AREAS[opts.id] + : undefined; + const machineArea = Object.hasOwn(MACHINE_AREAS, opts.id) + ? MACHINE_AREAS[opts.id] + : undefined; + let area = projectArea ?? machineArea ?? "other"; + const network = [ + "network:", + "resolver:", + "dnsmasq.conf:", + "dnsmasq:", + "dns:", + ].some((prefix) => opts.id.startsWith(prefix)); + if (network) { + area = "routing_dns"; + } + const knownMachine = Object.hasOwn(MACHINE_AREAS, opts.id) || network; + const scope = opts.scope ?? (knownMachine ? "machine" : "project"); + const adminTrust = [ + "local ca trust", + "host tls trust", + "native root trust", + "native ca repair", + ].includes(opts.id); + return { + area, + scope, + fix: { + kind: adminTrust ? "admin" : "manual", + args: opts.id === "local ca trust" ? ["global", "trust"] : [], + doc_url: DOC_URL, + }, + }; +} diff --git a/tests/doctor-adopted-project.test.ts b/tests/doctor-adopted-project.test.ts index b0760f6ba..9de376516 100644 --- a/tests/doctor-adopted-project.test.ts +++ b/tests/doctor-adopted-project.test.ts @@ -1,7 +1,10 @@ import { afterEach, beforeEach, expect, test } from "bun:test"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; -import { buildDoctorRemediationPlanLines } from "../src/commands/doctor.ts"; +import { + buildDoctorRemediationPlanLines, + type DoctorJsonCheck, +} from "../src/commands/doctor.ts"; import { ADOPTED_PROJECT_NOT_APPLICABLE_CHECKS, ADOPTED_PROJECT_NOT_APPLICABLE_MESSAGE, @@ -85,13 +88,6 @@ async function doctorJson(): Promise<{ return { exit, stdout, stderr }; } -type JsonCheck = { - readonly id: string; - readonly status: string; - readonly detail: string; - readonly durationMs: number; -}; - test("doctor reports an adopted project with closed messages instead of refusing it as native input (HACK-1311)", async () => { await adopt(); const receipt = await readFile(receiptPath(), "utf8"); @@ -99,12 +95,21 @@ test("doctor reports an adopted project with closed messages instead of refusing expect(run.stdout + run.stderr).not.toContain("E_NATIVE_PROJECT"); expect(run.stdout + run.stderr).not.toContain(SOURCE_BIND_CANARY); const parsed: unknown = JSON.parse(run.stdout); - expect(parsed).toMatchObject({ ok: true }); - const checks = (parsed as { data: { checks: JsonCheck[] } }).data.checks; + expect(parsed).toMatchObject({ ok: true, data: { schema: 1 } }); + const checks = (parsed as { data: { checks: DoctorJsonCheck[] } }).data + .checks; const byId = new Map(checks.map((check) => [check.id, check])); expect(byId.get("project")).toEqual({ id: "project", + area: "project_files", + scope: "project", + fix: { + kind: "manual", + args: [], + doc_url: + "https://github.com/hack-dance/hack/blob/next/docs/versions/v5/troubleshooting.md", + }, status: "ok", detail: `Adopted Compose project at ${join(h.root, ".hack")} ('hack config adopt'). Its Compose inputs belong to the adoption; adopted commands support retained-container start, restart, stop, ps, logs and exec.`, durationMs: expect.any(Number), @@ -198,11 +203,20 @@ test("doctor reports a pending adoption as a project warning that asks for recov expect(run.stdout + run.stderr).not.toContain(SOURCE_BIND_CANARY); const parsed: unknown = JSON.parse(run.stdout); // A warning, not an error: doctor still exits ok. - expect(parsed).toMatchObject({ ok: true }); - const checks = (parsed as { data: { checks: JsonCheck[] } }).data.checks; + expect(parsed).toMatchObject({ ok: true, data: { schema: 1 } }); + const checks = (parsed as { data: { checks: DoctorJsonCheck[] } }).data + .checks; const byId = new Map(checks.map((check) => [check.id, check])); expect(byId.get("project")).toEqual({ id: "project", + area: "project_files", + scope: "project", + fix: { + kind: "manual", + args: [], + doc_url: + "https://github.com/hack-dance/hack/blob/next/docs/versions/v5/troubleshooting.md", + }, status: "warn", detail: `Adopted Compose project at ${join(h.root, ".hack")} has a pending or interrupted adoption operation. Use explicit adoption recovery before other project commands; values omitted.`, durationMs: expect.any(Number), diff --git a/tests/doctor-json.test.ts b/tests/doctor-json.test.ts index 8e9099779..3e692c6a3 100644 --- a/tests/doctor-json.test.ts +++ b/tests/doctor-json.test.ts @@ -26,7 +26,8 @@ test("buildDoctorJsonData maps checks to id/status/detail and counts", () => { ], }); - expect(data.checks).toEqual([ + expect(data.schema).toBe(1); + expect(data.checks.map(({ area, scope, fix, ...check }) => check)).toEqual([ { id: "docker", status: "ok", @@ -79,7 +80,7 @@ test("buildDoctorJsonData adds only superseded-root counts and fingerprints", () { name: "bun", status: "ok", message: "/usr/bin/bun", durationMs: 2 }, ], }); - expect(data.checks[0]).toStrictEqual({ + expect(data.checks[0]).toMatchObject({ id: "superseded caddy roots", status: "warn", detail: "2 superseded Caddy roots remain", @@ -88,6 +89,9 @@ test("buildDoctorJsonData adds only superseded-root counts and fingerprints", () }); expect(Object.keys(data.checks[1] ?? {})).toEqual([ "id", + "area", + "scope", + "fix", "status", "detail", "durationMs", diff --git a/tests/doctor-metadata.test.ts b/tests/doctor-metadata.test.ts new file mode 100644 index 000000000..8f322a3b6 --- /dev/null +++ b/tests/doctor-metadata.test.ts @@ -0,0 +1,151 @@ +import { expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { buildDoctorJsonData } from "../src/commands/doctor.ts"; +import { okResult } from "../src/lib/cli-result.ts"; +import { doctorMetadata } from "../src/lib/doctor-metadata.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +test("doctor metadata distinguishes machine and project findings", () => { + for (const id of [ + "docker", + "network:hack-dev subnet", + "resolver:hack", + "daemon service", + ]) { + expect(doctorMetadata({ id }).scope).toBe("machine"); + } + for (const id of [ + "project", + "env key", + "hack gitignore", + "native settings", + "lifecycle hygiene", + ]) { + expect(doctorMetadata({ id }).scope).toBe("project"); + } + expect(doctorMetadata({ id: "local ca trust" })).toMatchObject({ + area: "certificates", + scope: "machine", + fix: { kind: "admin", args: ["global", "trust"] }, + }); + expect( + doctorMetadata({ id: "secrets hygiene", scope: "machine" }).scope + ).toBe("machine"); +}); + +test("unknown checks and inherited object names offer no executable repair", () => { + for (const id of [ + "new future check", + "__proto__", + "constructor", + "toString", + ]) { + expect(doctorMetadata({ id })).toMatchObject({ + area: "other", + scope: "project", + fix: { kind: "manual", args: [] }, + }); + } +}); + +test("different browser and hostname targets retain independent project findings", () => { + for (const id of [ + "browser local network", + "native host dns", + "native https route", + ]) { + for (const status of ["ok", "error"] as const) { + const data = buildDoctorJsonData({ + results: [ + { + name: id, + status, + message: "An exact target was observed", + durationMs: 0, + }, + ], + }); + expect(data.checks[0]).toMatchObject({ id, status, scope: "project" }); + } + } +}); + +test("versioned doctor audit keeps schema inside data and never parses repair instructions", () => { + const data = buildDoctorJsonData({ + results: [ + { + name: "env key", + status: "error", + message: "run: sudo injected-command", + durationMs: 0, + }, + ], + }); + expect(data).toMatchObject({ + schema: 1, + counts: { error: 1 }, + checks: [ + { + area: "env_secrets", + scope: "project", + fix: { kind: "manual", args: [] }, + }, + ], + }); + const envelope = okResult({ data }); + expect(Object.keys(envelope)).toEqual(["ok", "data"]); + expect(envelope.data.schema).toBe(1); +}); + +test("secrets hygiene reports its actual scope inside and outside a project", async () => { + const root = await mkdtemp(join(tmpdir(), "doctor-metadata-")); + try { + const project = join(root, "project"); + await mkdir(join(project, ".hack"), { recursive: true }); + await Bun.write( + join(project, ".hack/docker-compose.yml"), + "services: {}\n" + ); + const modulePath = resolve(import.meta.dir, "../src/commands/doctor.ts"); + const child = Bun.spawn( + [ + process.execPath, + "--no-env-file", + "--eval", + ` + import {checkSecretsHygiene} from ${JSON.stringify(modulePath)}; + const checks = []; + for (const startDir of process.argv.slice(1)) checks.push(await checkSecretsHygiene({startDir})); + process.stdout.write(JSON.stringify(checks.map(check => check.scope))); + `, + root, + project, + ], + { + cwd: root, + env: privateCliEnv({ + root, + env: { + HACK_HOME: join(root, "state"), + HACK_GLOBAL_CONFIG_PATH: join(root, "state/hack.config.json"), + HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK: "1", + }, + }), + stdout: "pipe", + stderr: "pipe", + } + ); + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(stderr).toBe(""); + expect(code).toBe(0); + expect(JSON.parse(stdout)).toEqual(["machine", "project"]); + } finally { + await rm(root, { recursive: true, force: true }); + } +});