Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions apps/desktop/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,7 @@ is removed after use. The app never types into an existing terminal or pane.

Source: `hack doctor --json --path <root>` 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 |
|---|---|
Expand All @@ -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

Expand Down Expand Up @@ -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 <id> --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.
Expand Down
2 changes: 2 additions & 0 deletions docs/versions/v5/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
22 changes: 17 additions & 5 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1596,6 +1602,7 @@ export type DoctorJsonCheck = {
};

export type DoctorJsonData = {
readonly schema: 1;
readonly checks: readonly DoctorJsonCheck[];
readonly summary: readonly string[];
readonly counts: {
Expand All @@ -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,
Expand Down Expand Up @@ -3259,10 +3268,13 @@ export async function resolveSecretsHygieneScope(opts: {
export async function checkSecretsHygiene(opts: {
readonly startDir: string;
}): Promise<CheckResult> {
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[]): {
Expand Down
131 changes: 131 additions & 0 deletions src/lib/doctor-metadata.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, DoctorArea>> = {
"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<Record<string, DoctorArea>> = {
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,
},
};
}
38 changes: 26 additions & 12 deletions tests/doctor-adopted-project.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -85,26 +88,28 @@ 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");
const run = await doctorJson();
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),
Expand Down Expand Up @@ -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),
Expand Down
8 changes: 6 additions & 2 deletions tests/doctor-json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading
Loading