From 620003e4c89fdf77850d1250baf1916b46586ea3 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:07:24 +0000 Subject: [PATCH 1/6] feat(doctor): add versioned per-check metadata (R7a) --- apps/desktop/DESIGN.md | 4 +- docs/versions/v5/troubleshooting.md | 2 + src/commands/doctor.ts | 22 +++-- src/lib/doctor-metadata.ts | 131 ++++++++++++++++++++++++++++ tests/doctor-json.test.ts | 8 +- tests/doctor-metadata.test.ts | 74 ++++++++++++++++ 6 files changed, 232 insertions(+), 9 deletions(-) create mode 100644 src/lib/doctor-metadata.ts create mode 100644 tests/doctor-metadata.test.ts diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index c8b20a79c..850b31c3e 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 | |---|---| @@ -375,7 +375,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..0c1d52334 --- /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> = { + 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", + "browser local network": "routing_dns", + "native https owner": "native", + "native root trust": "certificates", + "native ca repair": "certificates", + "native https route": "native", + "native host dns": "routing_dns", + "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-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..6a16fc614 --- /dev/null +++ b/tests/doctor-metadata.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from "bun:test"; +import { buildDoctorJsonData } from "../src/commands/doctor.ts"; +import { okResult } from "../src/lib/cli-result.ts"; +import { doctorMetadata } from "../src/lib/doctor-metadata.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("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); +}); From f884ba36e4fe8a6da7cabd1939ee82b3c7b9f1ea Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:10:32 +0000 Subject: [PATCH 2/6] test(doctor): pin mixed-scope metadata observation --- apps/desktop/DESIGN.md | 7 +++-- tests/doctor-metadata.test.ts | 55 +++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 850b31c3e..32847ed5b 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -209,9 +209,10 @@ minutes while visible. The versioned audit gives `{ok:true,data:{schema:1,checks | 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`. ## 4. Upgrades diff --git a/tests/doctor-metadata.test.ts b/tests/doctor-metadata.test.ts index 6a16fc614..c1b15b8d4 100644 --- a/tests/doctor-metadata.test.ts +++ b/tests/doctor-metadata.test.ts @@ -1,7 +1,11 @@ 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 [ @@ -72,3 +76,54 @@ test("versioned doctor audit keeps schema inside data and never parses repair in 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 }); + } +}); From 98f7178166f018e5f40c9c01bd6c65c97e4edfdc Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:15:58 +0000 Subject: [PATCH 3/6] feat(doctor): run one consented safe fix and verify it (R7b) --- apps/desktop/DESIGN.md | 2 +- docs/reference/cli.md | 2 + docs/versions/v5/troubleshooting.md | 2 + src/commands/doctor.ts | 79 ++++++++++++- src/lib/doctor-metadata.ts | 29 ++++- src/lib/doctor-safe-fix.ts | 71 +++++++++++ tests/doctor-safe-fix.test.ts | 176 ++++++++++++++++++++++++++++ 7 files changed, 354 insertions(+), 7 deletions(-) create mode 100644 src/lib/doctor-safe-fix.ts create mode 100644 tests/doctor-safe-fix.test.ts diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 32847ed5b..b419bfabd 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -377,7 +377,7 @@ Each is small enough for one PR. 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 — 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`. +10. **R7b — exists.** `hack doctor --fix --only --yes --json` executes one allowlisted safe fix and rechecks it. Initially only `hack gitignore` is safe; other IDs and mixed modes refuse before writes. Results use `{ok,data:{schema:1,checks,summary,counts,fix}}`; `fix` records `id`, `changed`, `confirmed`. 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. 13. **R9** (optional) Push updates: `GET /v1/projects?wait=` long-poll. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index af5b832cf..8980a6129 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1623,6 +1623,8 @@ hack doctor [options] | --- | --- | | `--path, -p ` | Run a project command against a repo path (overrides cwd search) | | `--fix` | Attempt safe auto-remediations (network + CoreDNS + CA) | +| `--only ` | With --fix --yes, run exactly one allowlisted safe repair | +| `--yes` | Consent to the one safe repair selected with --fix --only | | `--migrate-env-config` | Migrate legacy hack.env.json/.env state to the new env config files | | `--accept-tracked-env-changes` | With --migrate-env-config and no prompt, allow deleting or editing git-tracked files (hack.env.json, the Compose file) | | `--domain-migration preview|apply|rollback` | Preview, apply, or roll back only this project's hack.local migration | diff --git a/docs/versions/v5/troubleshooting.md b/docs/versions/v5/troubleshooting.md index a76eac28b..358cdb8fa 100644 --- a/docs/versions/v5/troubleshooting.md +++ b/docs/versions/v5/troubleshooting.md @@ -8,6 +8,8 @@ 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. +For a targeted repair, `hack doctor --fix --only 'hack gitignore' --yes --json --path ` repairs only Hack's managed ignore block, keeping user lines. It needs a Git checkout and leaves adopted, unsafe or read-only inputs alone. It rechecks the result and exits 1 when unconfirmed. `data.fix` reports `id`, `changed` and `confirmed`; no other check is run or repaired. Other IDs, absent consent and combinations with other repair modes refuse before writes. Broad `--fix --json` still refuses. + 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 76eb83c91..f9c17b8f5 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -152,6 +152,10 @@ import { NATIVE_SOURCE_CHECK, type NativeSourceReport, } from "../lib/doctor-native-source.ts"; +import { + DOCTOR_SAFE_FIX_ID, + runDoctorSafeFix, +} from "../lib/doctor-safe-fix.ts"; import { inspectSecretIntegrity } from "../lib/doctor-secret-integrity.ts"; import { findCrossCheckoutInstances, @@ -430,6 +434,19 @@ const optBrowserResult = defineOption({ const doctorOptions = [ optPath, optFix, + defineOption({ + name: "only", + type: "string", + long: "--only", + valueHint: "", + description: "With --fix --yes, run exactly one allowlisted safe repair", + } as const), + defineOption({ + name: "yes", + type: "boolean", + long: "--yes", + description: "Consent to the one safe repair selected with --fix --only", + } as const), optMigrateEnvConfig, optAcceptTrackedEnvChanges, optDomainMigration, @@ -741,6 +758,15 @@ async function runNativePreflightMode( const handleDoctor: CommandHandlerFor = async ({ args, }): Promise => { + if (selectedDoctorFixRequested(args.options)) { + return await runSelectedDoctorFix(args); + } + return await runDoctorAudit(args); +}; + +async function runDoctorAudit( + args: Parameters>[0]["args"] +): Promise { if (args.options.nativePreflight) { return await runNativePreflightMode(args); } @@ -1140,7 +1166,58 @@ const handleDoctor: CommandHandlerFor = async ({ } return 0; -}; +} + +function selectedDoctorFixRequested(opts: { + readonly only?: string; + readonly yes?: boolean; +}): boolean { + return opts.only !== undefined || opts.yes === true; +} + +/** An allowlist before discovery or side effects: new Doctor flags cannot widen a selected repair. */ +async function runSelectedDoctorFix( + args: Parameters>[0]["args"] +): Promise { + const allowed = new Set(["path", "fix", "only", "yes", "json"]); + if ( + Object.entries(args.options).some( + ([key, value]) => + !allowed.has(key) && value !== undefined && value !== false + ) || + !args.options.fix || + !args.options.yes || + args.options.only !== DOCTOR_SAFE_FIX_ID + ) { + throw new CliUsageError( + "Use --fix --only 'hack gitignore' --yes for the supported safe repair; other check IDs and repair modes are refused." + ); + } + const started = performance.now(); + const fixed = await runDoctorSafeFix({ + startDir: resolveDoctorStartDir(args.options.path), + }); + const results = [ + { ...fixed.check, durationMs: Math.round(performance.now() - started) }, + ]; + if (args.options.json) { + emitCliResult({ + result: okResult({ + data: { + ...buildDoctorJsonData({ results }), + fix: { + id: DOCTOR_SAFE_FIX_ID, + changed: fixed.changed, + confirmed: fixed.confirmed, + }, + }, + }), + }); + } else { + await renderDoctorSummary(results); + } + return fixed.confirmed ? 0 : 1; +} /** * Compose doctor's browser check concerns the reported origin, else the diff --git a/src/lib/doctor-metadata.ts b/src/lib/doctor-metadata.ts index 0c1d52334..f0f94a17e 100644 --- a/src/lib/doctor-metadata.ts +++ b/src/lib/doctor-metadata.ts @@ -119,13 +119,32 @@ export function doctorMetadata(opts: { "native root trust", "native ca repair", ].includes(opts.id); + const fix: DoctorMetadata["fix"] = { + kind: adminTrust ? "admin" : "manual", + args: opts.id === "local ca trust" ? ["global", "trust"] : [], + doc_url: DOC_URL, + }; + if (opts.id === "hack gitignore") { + return { + area, + scope, + fix: { + kind: "safe", + args: [ + "doctor", + "--fix", + "--only", + "hack gitignore", + "--yes", + "--json", + ], + doc_url: DOC_URL, + }, + }; + } return { area, scope, - fix: { - kind: adminTrust ? "admin" : "manual", - args: opts.id === "local ca trust" ? ["global", "trust"] : [], - doc_url: DOC_URL, - }, + fix, }; } diff --git a/src/lib/doctor-safe-fix.ts b/src/lib/doctor-safe-fix.ts new file mode 100644 index 000000000..a400f2392 --- /dev/null +++ b/src/lib/doctor-safe-fix.ts @@ -0,0 +1,71 @@ +import { HackCliError } from "./cli-result.ts"; +import { preflightDoctorProject } from "./doctor-adopted-project.ts"; +import { inspectHackDirGitignoreForDoctor } from "./doctor-generated-files.ts"; +import { findProjectContext } from "./project.ts"; +import { ensureHackDirGitignore } from "./project-env-config.ts"; + +export const DOCTOR_SAFE_FIX_ID = "hack gitignore"; + +/** Select an ordinary legacy project again at publication; adoption never authorizes this write. */ +async function selectedProject(startDir: string) { + if (await preflightDoctorProject({ startDir })) { + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: "Selected repair does not change adopted project inputs.", + }); + } + const project = await findProjectContext(startDir); + if (!project) { + throw new HackCliError({ + code: "E_PROJECT_NOT_FOUND", + message: "Selected repair requires a project in a Git checkout.", + }); + } + return project; +} + +/** Exactly one additive repair, followed by readback. Never calls the broad guided remediation. */ +export async function runDoctorSafeFix(opts: { readonly startDir: string }) { + const project = await selectedProject(opts.startDir); + const inspect = () => + inspectHackDirGitignoreForDoctor({ projectDir: project.projectDir }); + const before = await inspect(); + if (before === null) { + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: "Selected repair requires a Git checkout.", + }); + } + let changed = false; + if (before === "missing" || before === "outdated") { + const result = await ensureHackDirGitignore({ + projectDir: project.projectDir, + beforePublish: async () => { + const current = await selectedProject(opts.startDir); + if ( + current.projectRoot !== project.projectRoot || + current.projectDir !== project.projectDir + ) { + throw new HackCliError({ + code: "E_CONFIG_INVALID", + message: "Selected repair project changed; nothing published.", + }); + } + }, + }); + changed = result.changed; + } + const after = await inspect(); + const confirmed = after === "current"; + return { + changed, + confirmed, + check: { + name: DOCTOR_SAFE_FIX_ID, + status: confirmed ? ("ok" as const) : ("error" as const), + message: confirmed + ? "Managed ignore block verified; existing user lines preserved." + : "Selected repair could not verify the managed ignore block; unsafe, unreadable and read-only files are left unchanged.", + }, + }; +} diff --git a/tests/doctor-safe-fix.test.ts b/tests/doctor-safe-fix.test.ts new file mode 100644 index 000000000..c359c22a5 --- /dev/null +++ b/tests/doctor-safe-fix.test.ts @@ -0,0 +1,176 @@ +import { expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + symlink, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { runFixtureGit } from "./helpers/fixture-git.ts"; +import { privateCliEnv } from "./helpers/private-cli-env.ts"; + +const cli = resolve(import.meta.dir, "../index.ts"); +const selected = ["--fix", "--only", "hack gitignore", "--yes", "--json"]; + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), "doctor-safe-fix-")); + const project = join(root, "project"); + await mkdir(join(project, ".hack"), { recursive: true }); + await runFixtureGit({ cwd: project, args: ["init", "--quiet"] }); + await Bun.write(join(project, ".hack/docker-compose.yml"), "services: {}\n"); + const tools = join(root, "tools"); + await mkdir(tools); + const called = join(root, "unexpected-tool"); + for (const name of ["docker", "hackd", "sudo", "security", "systemctl"]) { + await Bun.write( + join(tools, name), + `#!/bin/sh\nprintf unexpected >> '${called}'\nexit 90\n` + ); + await chmod(join(tools, name), 0o700); + } + const invoke = async (flags: string[]) => { + const child = Bun.spawn( + [ + process.execPath, + "--no-env-file", + cli, + "doctor", + "--path", + project, + ...flags, + ], + { + cwd: root, + env: privateCliEnv({ + root, + env: { + HACK_HOME: join(root, "state"), + HACK_GLOBAL_CONFIG_PATH: join(root, "state/hack.config.json"), + PATH: `${tools}:/usr/bin:/bin`, + HACK_NO_INTERACTIVE: "1", + HACK_LOGGER: "console", + }, + }), + stdout: "pipe", + stderr: "pipe", + } + ); + const [code, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + expect(await Bun.file(called).exists()).toBe(false); + return { code, stdout, stderr, json: JSON.parse(stdout) }; + }; + return { root, project, invoke, file: join(project, ".hack/.gitignore") }; +} + +test("one safe fix preserves user lines and other findings, rechecks, and is idempotent", async () => { + const f = await fixture(); + try { + await Bun.write(f.file, "# user-owned rule\ncustom.tmp\n"); + const unrelated = join(f.project, ".hack/.env"); + await Bun.write(unrelated, "SYNTHETIC_SENTINEL=not-a-real-secret\n"); + const result = await f.invoke(selected); + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(Object.keys(result.json)).toEqual(["ok", "data"]); + expect(result.json).toMatchObject({ + ok: true, + data: { + schema: 1, + fix: { id: "hack gitignore", changed: true, confirmed: true }, + counts: { ok: 1, warn: 0, error: 0 }, + checks: [{ id: "hack gitignore", status: "ok", fix: { kind: "safe" } }], + }, + }); + expect(result.json.data.checks).toHaveLength(1); + expect(await Bun.file(f.file).text()).toContain( + "# user-owned rule\ncustom.tmp\n" + ); + expect(await Bun.file(unrelated).text()).toBe( + "SYNTHETIC_SENTINEL=not-a-real-secret\n" + ); + expect(result.stdout).not.toContain("SYNTHETIC_SENTINEL"); + const before = await readFile(f.file); + const again = await f.invoke(selected); + expect(again.code).toBe(0); + expect(again.json.data.fix).toMatchObject({ + changed: false, + confirmed: true, + }); + expect(await readFile(f.file)).toEqual(before); + } finally { + await rm(f.root, { recursive: true, force: true }); + } +}); + +test("missing consent, broad fix, admin/manual/unknown IDs and mixed modes refuse before mutation", async () => { + const f = await fixture(); + try { + for (const flags of [ + ["--fix", "--only", "hack gitignore", "--json"], + ["--only", "hack gitignore", "--yes", "--json"], + ["--fix", "--yes", "--json"], + ["--fix", "--json"], + ...["local ca trust", "daemon", "unknown", "__proto__"].map((id) => [ + "--fix", + "--only", + id, + "--yes", + "--json", + ]), + [...selected, "--migrate-env-config"], + [...selected, "--native-preflight"], + ]) { + const result = await f.invoke(flags); + expect(result.code).not.toBe(0); + expect(result.json).toMatchObject({ + ok: false, + error: { code: "E_USAGE" }, + }); + expect(await Bun.file(f.file).exists()).toBe(false); + expect(await readdir(join(f.project, ".hack"))).toEqual([ + "docker-compose.yml", + ]); + } + } finally { + await rm(f.root, { recursive: true, force: true }); + } +}); + +for (const mode of ["read-only", "symlink"] as const) { + test(`a ${mode} ignore file is retained and cannot be reported fixed`, async () => { + const f = await fixture(); + try { + const target = join(f.root, "user-content"); + await Bun.write(target, "user content\n"); + if (mode === "symlink") { + await symlink(target, f.file); + } else { + await Bun.write(f.file, "user rule\n"); + await chmod(f.file, 0o400); + } + const result = await f.invoke(selected); + expect(result.code).toBe(1); + expect(result.json).toMatchObject({ + ok: true, + data: { + fix: { changed: false, confirmed: false }, + checks: [{ status: "error" }], + }, + }); + expect(await Bun.file(target).text()).toBe("user content\n"); + expect(await Bun.file(f.file).text()).toBe( + mode === "symlink" ? "user content\n" : "user rule\n" + ); + } finally { + await rm(f.root, { recursive: true, force: true }); + } + }); +} From e27d8998449677058cac4d75ee4c551636bdb952 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:22:28 +0000 Subject: [PATCH 4/6] fix(doctor): preserve project scope for exact-target findings --- apps/desktop/DESIGN.md | 3 +++ src/lib/doctor-metadata.ts | 6 +++--- tests/doctor-metadata.test.ts | 22 ++++++++++++++++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/desktop/DESIGN.md b/apps/desktop/DESIGN.md index 32847ed5b..11c6e2336 100644 --- a/apps/desktop/DESIGN.md +++ b/apps/desktop/DESIGN.md @@ -214,6 +214,9 @@ findings link from that project's header. Schema 1 supplies `area`, `scope` and 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 ![Upgrade](diagrams/upgrade.svg) ([source](diagrams/upgrade.mmd)) diff --git a/src/lib/doctor-metadata.ts b/src/lib/doctor-metadata.ts index 0c1d52334..409e7440c 100644 --- a/src/lib/doctor-metadata.ts +++ b/src/lib/doctor-metadata.ts @@ -24,6 +24,9 @@ export type DoctorMetadata = { 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", @@ -78,12 +81,9 @@ const MACHINE_AREAS: Readonly> = { "local ca trust": "certificates", "host tls trust": "certificates", "superseded caddy roots": "certificates", - "browser local network": "routing_dns", "native https owner": "native", "native root trust": "certificates", "native ca repair": "certificates", - "native https route": "native", - "native host dns": "routing_dns", "native hack.local route": "routing_dns", "native hack.local repair": "routing_dns", }; diff --git a/tests/doctor-metadata.test.ts b/tests/doctor-metadata.test.ts index c1b15b8d4..8f322a3b6 100644 --- a/tests/doctor-metadata.test.ts +++ b/tests/doctor-metadata.test.ts @@ -50,6 +50,28 @@ test("unknown checks and inherited object names offer no executable repair", () } }); +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: [ From 34ba0248eedb42255e846168b5af31195b2e2521 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:37:43 +0000 Subject: [PATCH 5/6] test(doctor): expect metadata on adopted project findings --- tests/doctor-adopted-project.test.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/doctor-adopted-project.test.ts b/tests/doctor-adopted-project.test.ts index b0760f6ba..a88ac7335 100644 --- a/tests/doctor-adopted-project.test.ts +++ b/tests/doctor-adopted-project.test.ts @@ -99,12 +99,20 @@ 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 }); + expect(parsed).toMatchObject({ ok: true, data: { schema: 1 } }); const checks = (parsed as { data: { checks: JsonCheck[] } }).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 +206,19 @@ 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 }); + expect(parsed).toMatchObject({ ok: true, data: { schema: 1 } }); const checks = (parsed as { data: { checks: JsonCheck[] } }).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), From 6e27a9a2958b2fe29b16d7ab60f9339876c66cd6 Mon Sep 17 00:00:00 2001 From: Kit <344842+roodboi@users.noreply.github.com> Date: Sat, 14 Nov 2026 10:43:49 +0000 Subject: [PATCH 6/6] test(doctor): reuse the exported JSON check contract --- tests/doctor-adopted-project.test.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/tests/doctor-adopted-project.test.ts b/tests/doctor-adopted-project.test.ts index a88ac7335..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"); @@ -100,7 +96,8 @@ test("doctor reports an adopted project with closed messages instead of refusing expect(run.stdout + run.stderr).not.toContain(SOURCE_BIND_CANARY); const parsed: unknown = JSON.parse(run.stdout); expect(parsed).toMatchObject({ ok: true, data: { schema: 1 } }); - const checks = (parsed as { data: { checks: JsonCheck[] } }).data.checks; + const checks = (parsed as { data: { checks: DoctorJsonCheck[] } }).data + .checks; const byId = new Map(checks.map((check) => [check.id, check])); expect(byId.get("project")).toEqual({ @@ -207,7 +204,8 @@ test("doctor reports a pending adoption as a project warning that asks for recov const parsed: unknown = JSON.parse(run.stdout); // A warning, not an error: doctor still exits ok. expect(parsed).toMatchObject({ ok: true, data: { schema: 1 } }); - const checks = (parsed as { data: { checks: JsonCheck[] } }).data.checks; + 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",