feat(doctor): add repair/force flows

This commit is contained in:
Peter Steinberger
2026-01-08 21:47:35 +01:00
parent 712a7dddf6
commit 2d4ec35e1c
7 changed files with 80 additions and 8 deletions

View File

@@ -2,7 +2,7 @@
## Unreleased
- Doctor/Daemon: audit supervisor configs, recommend doctor from daemon status, and document user vs system services. (#?) — thanks @steipete
- Doctor/Daemon: audit supervisor configs, add --repair/--force flows, surface service config audits in daemon status, and document user vs system services. — thanks @steipete
- Daemon: align generated systemd unit with docs for network-online + restart delay. (#479) — thanks @azade-c
- Outbound: default Telegram account selection for config-only tokens; remove heartbeat-specific accountId handling. (follow-up #516) — thanks @YuriNachos
- Cron: allow Telegram delivery targets with topic/thread IDs (e.g. `-100…:topic:123`). (#474) — thanks @mitschabaude-bot

View File

@@ -23,6 +23,18 @@ clawdbot doctor --yes
Accept defaults without prompting (including restart/service/sandbox repair steps when applicable).
```bash
clawdbot doctor --repair
```
Apply recommended repairs without prompting (repairs + restarts where safe).
```bash
clawdbot doctor --repair --force
```
Apply aggressive repairs too (overwrites custom supervisor configs).
```bash
clawdbot doctor --non-interactive
```
@@ -153,6 +165,8 @@ rewrite the service file/task to the current defaults.
Notes:
- `clawdbot doctor` prompts before rewriting supervisor config.
- `clawdbot doctor --yes` accepts the default repair prompts.
- `clawdbot doctor --repair` applies recommended fixes without prompts.
- `clawdbot doctor --repair --force` overwrites custom supervisor configs.
- You can always force a full rewrite via `clawdbot daemon install --force`.
### 12) Gateway runtime + port diagnostics

View File

@@ -552,7 +552,9 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
const detail = issue.detail ? ` (${issue.detail})` : "";
defaultRuntime.error(`Service config issue: ${issue.message}${detail}`);
}
defaultRuntime.error('Recommendation: run "clawdbot doctor".');
defaultRuntime.error(
'Recommendation: run "clawdbot doctor" (or "clawdbot doctor --repair").',
);
}
if (status.config) {
const cliCfg = `${status.config.cli.path}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`;

View File

@@ -328,6 +328,8 @@ export function buildProgram() {
false,
)
.option("--yes", "Accept defaults without prompting", false)
.option("--repair", "Apply recommended repairs without prompting", false)
.option("--force", "Apply aggressive repairs (overwrites custom service config)", false)
.option(
"--non-interactive",
"Run without prompts (safe migrations only)",
@@ -339,6 +341,8 @@ export function buildProgram() {
await doctorCommand(defaultRuntime, {
workspaceSuggestions: opts.workspaceSuggestions,
yes: Boolean(opts.yes),
repair: Boolean(opts.repair),
force: Boolean(opts.force),
nonInteractive: Boolean(opts.nonInteractive),
deep: Boolean(opts.deep),
});

View File

@@ -167,10 +167,31 @@ export async function maybeRepairGatewayServiceConfig(
"Gateway service config",
);
const repair = await prompter.confirmSkipInNonInteractive({
message: "Update gateway service config to the recommended defaults now?",
initialValue: true,
});
const aggressiveIssues = audit.issues.filter(
(issue) => issue.level === "aggressive",
);
const recommendedIssues = audit.issues.filter(
(issue) => issue.level !== "aggressive",
);
const needsAggressive = aggressiveIssues.length > 0;
if (needsAggressive && !prompter.shouldForce) {
note(
"Custom or unexpected service edits detected. Rerun with --force to overwrite.",
"Gateway service config",
);
}
const repair = needsAggressive
? await prompter.confirmAggressive({
message:
"Overwrite gateway service config with current defaults now?",
initialValue: Boolean(prompter.shouldForce),
})
: await prompter.confirmRepair({
message: "Update gateway service config to the recommended defaults now?",
initialValue: true,
});
if (!repair) return;
const devMode =

View File

@@ -8,14 +8,20 @@ export type DoctorOptions = {
yes?: boolean;
nonInteractive?: boolean;
deep?: boolean;
repair?: boolean;
force?: boolean;
};
export type DoctorPrompter = {
confirm: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
confirmRepair: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
confirmAggressive: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
confirmSkipInNonInteractive: (
params: Parameters<typeof confirm>[0],
) => Promise<boolean>;
select: <T>(params: Parameters<typeof select>[0], fallback: T) => Promise<T>;
shouldRepair: boolean;
shouldForce: boolean;
};
export function createDoctorPrompter(params: {
@@ -24,24 +30,42 @@ export function createDoctorPrompter(params: {
}): DoctorPrompter {
const yes = params.options.yes === true;
const requestedNonInteractive = params.options.nonInteractive === true;
const shouldRepair = params.options.repair === true || yes;
const shouldForce = params.options.force === true;
const isTty = Boolean(process.stdin.isTTY);
const nonInteractive = requestedNonInteractive || (!isTty && !yes);
const canPrompt = isTty && !yes && !nonInteractive;
const confirmDefault = async (p: Parameters<typeof confirm>[0]) => {
if (nonInteractive) return false;
if (shouldRepair) return true;
if (!canPrompt) return Boolean(p.initialValue ?? false);
return guardCancel(await confirm(p), params.runtime) === true;
};
return {
confirm: confirmDefault,
confirmSkipInNonInteractive: async (p) => {
confirmRepair: async (p) => {
if (nonInteractive) return false;
return confirmDefault(p);
},
confirmAggressive: async (p) => {
if (nonInteractive) return false;
if (shouldRepair && shouldForce) return true;
if (shouldRepair && !shouldForce) return false;
if (!canPrompt) return Boolean(p.initialValue ?? false);
return guardCancel(await confirm(p), params.runtime) === true;
},
confirmSkipInNonInteractive: async (p) => {
if (nonInteractive) return false;
if (shouldRepair) return true;
return confirmDefault(p);
},
select: async <T>(p: Parameters<typeof select>[0], fallback: T) => {
if (!canPrompt) return fallback;
if (!canPrompt || shouldRepair) return fallback;
return guardCancel(await select(p), params.runtime) as T;
},
shouldRepair,
shouldForce,
};
}

View File

@@ -13,6 +13,7 @@ export type ServiceConfigIssue = {
code: string;
message: string;
detail?: string;
level?: "recommended" | "aggressive";
};
export type ServiceConfigAudit = {
@@ -84,6 +85,7 @@ async function auditSystemdUnit(
code: "systemd-after-network-online",
message: "Missing systemd After=network-online.target",
detail: unitPath,
level: "recommended",
});
}
if (!parsed.wants.has("network-online.target")) {
@@ -91,6 +93,7 @@ async function auditSystemdUnit(
code: "systemd-wants-network-online",
message: "Missing systemd Wants=network-online.target",
detail: unitPath,
level: "recommended",
});
}
if (!isRestartSecPreferred(parsed.restartSec)) {
@@ -98,6 +101,7 @@ async function auditSystemdUnit(
code: "systemd-restart-sec",
message: "RestartSec does not match the recommended 5s",
detail: unitPath,
level: "recommended",
});
}
}
@@ -121,6 +125,7 @@ async function auditLaunchdPlist(
code: "launchd-run-at-load",
message: "LaunchAgent is missing RunAtLoad=true",
detail: plistPath,
level: "recommended",
});
}
if (!hasKeepAlive) {
@@ -128,6 +133,7 @@ async function auditLaunchdPlist(
code: "launchd-keep-alive",
message: "LaunchAgent is missing KeepAlive=true",
detail: plistPath,
level: "recommended",
});
}
}
@@ -141,6 +147,7 @@ function auditGatewayCommand(
issues.push({
code: "gateway-command-missing",
message: "Service command does not include the gateway subcommand",
level: "aggressive",
});
}
}