diff --git a/CHANGELOG.md b/CHANGELOG.md index 61e63120e..a7c4aedd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 059ee480a..0b0513850 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -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 diff --git a/src/cli/daemon-cli.ts b/src/cli/daemon-cli.ts index 0a07d4423..f2652f1d0 100644 --- a/src/cli/daemon-cli.ts +++ b/src/cli/daemon-cli.ts @@ -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)"}`; diff --git a/src/cli/program.ts b/src/cli/program.ts index 8c09a5758..686963601 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -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), }); diff --git a/src/commands/doctor-gateway-services.ts b/src/commands/doctor-gateway-services.ts index 9769ca5f1..0072b0a81 100644 --- a/src/commands/doctor-gateway-services.ts +++ b/src/commands/doctor-gateway-services.ts @@ -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 = diff --git a/src/commands/doctor-prompter.ts b/src/commands/doctor-prompter.ts index 66476399b..7717ea52b 100644 --- a/src/commands/doctor-prompter.ts +++ b/src/commands/doctor-prompter.ts @@ -8,14 +8,20 @@ export type DoctorOptions = { yes?: boolean; nonInteractive?: boolean; deep?: boolean; + repair?: boolean; + force?: boolean; }; export type DoctorPrompter = { confirm: (params: Parameters[0]) => Promise; + confirmRepair: (params: Parameters[0]) => Promise; + confirmAggressive: (params: Parameters[0]) => Promise; confirmSkipInNonInteractive: ( params: Parameters[0], ) => Promise; select: (params: Parameters[0], fallback: T) => Promise; + 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[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 (p: Parameters[0], fallback: T) => { - if (!canPrompt) return fallback; + if (!canPrompt || shouldRepair) return fallback; return guardCancel(await select(p), params.runtime) as T; }, + shouldRepair, + shouldForce, }; } diff --git a/src/daemon/service-audit.ts b/src/daemon/service-audit.ts index c8ae0c8b8..feb28dc4a 100644 --- a/src/daemon/service-audit.ts +++ b/src/daemon/service-audit.ts @@ -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", }); } }