Skip to content
Merged
2 changes: 1 addition & 1 deletion apps/desktop/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,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 <id> --yes --json`, only for `kind: safe`.
10. **R7b — exists.** `hack doctor --fix --only <id> --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=<generated_at>` long-poll.
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -1623,6 +1623,8 @@ hack doctor [options]
| --- | --- |
| `--path, -p <dir>` | Run a project command against a repo path (overrides cwd search) |
| `--fix` | Attempt safe auto-remediations (network + CoreDNS + CA) |
| `--only <id>` | 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 |
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 @@ -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 <repo>` 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.
Expand Down
79 changes: 78 additions & 1 deletion src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -430,6 +434,19 @@ const optBrowserResult = defineOption({
const doctorOptions = [
optPath,
optFix,
defineOption({
name: "only",
type: "string",
long: "--only",
valueHint: "<id>",
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,
Expand Down Expand Up @@ -741,6 +758,15 @@ async function runNativePreflightMode(
const handleDoctor: CommandHandlerFor<typeof doctorSpec> = async ({
args,
}): Promise<number> => {
if (selectedDoctorFixRequested(args.options)) {
return await runSelectedDoctorFix(args);
}
return await runDoctorAudit(args);
};

async function runDoctorAudit(
args: Parameters<CommandHandlerFor<typeof doctorSpec>>[0]["args"]
): Promise<number> {
if (args.options.nativePreflight) {
return await runNativePreflightMode(args);
}
Expand Down Expand Up @@ -1140,7 +1166,58 @@ const handleDoctor: CommandHandlerFor<typeof doctorSpec> = 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<CommandHandlerFor<typeof doctorSpec>>[0]["args"]
): Promise<number> {
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
Expand Down
29 changes: 24 additions & 5 deletions src/lib/doctor-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
71 changes: 71 additions & 0 deletions src/lib/doctor-safe-fix.ts
Original file line number Diff line number Diff line change
@@ -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.",
},
};
}
Loading
Loading