feat(doctor): add repair/force flows
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Unreleased
|
## 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
|
- 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
|
- 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
|
- Cron: allow Telegram delivery targets with topic/thread IDs (e.g. `-100…:topic:123`). (#474) — thanks @mitschabaude-bot
|
||||||
|
|||||||
@@ -23,6 +23,18 @@ clawdbot doctor --yes
|
|||||||
|
|
||||||
Accept defaults without prompting (including restart/service/sandbox repair steps when applicable).
|
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
|
```bash
|
||||||
clawdbot doctor --non-interactive
|
clawdbot doctor --non-interactive
|
||||||
```
|
```
|
||||||
@@ -153,6 +165,8 @@ rewrite the service file/task to the current defaults.
|
|||||||
Notes:
|
Notes:
|
||||||
- `clawdbot doctor` prompts before rewriting supervisor config.
|
- `clawdbot doctor` prompts before rewriting supervisor config.
|
||||||
- `clawdbot doctor --yes` accepts the default repair prompts.
|
- `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`.
|
- You can always force a full rewrite via `clawdbot daemon install --force`.
|
||||||
|
|
||||||
### 12) Gateway runtime + port diagnostics
|
### 12) Gateway runtime + port diagnostics
|
||||||
|
|||||||
@@ -552,7 +552,9 @@ function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) {
|
|||||||
const detail = issue.detail ? ` (${issue.detail})` : "";
|
const detail = issue.detail ? ` (${issue.detail})` : "";
|
||||||
defaultRuntime.error(`Service config issue: ${issue.message}${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) {
|
if (status.config) {
|
||||||
const cliCfg = `${status.config.cli.path}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`;
|
const cliCfg = `${status.config.cli.path}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`;
|
||||||
|
|||||||
@@ -328,6 +328,8 @@ export function buildProgram() {
|
|||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
.option("--yes", "Accept defaults without prompting", 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(
|
.option(
|
||||||
"--non-interactive",
|
"--non-interactive",
|
||||||
"Run without prompts (safe migrations only)",
|
"Run without prompts (safe migrations only)",
|
||||||
@@ -339,6 +341,8 @@ export function buildProgram() {
|
|||||||
await doctorCommand(defaultRuntime, {
|
await doctorCommand(defaultRuntime, {
|
||||||
workspaceSuggestions: opts.workspaceSuggestions,
|
workspaceSuggestions: opts.workspaceSuggestions,
|
||||||
yes: Boolean(opts.yes),
|
yes: Boolean(opts.yes),
|
||||||
|
repair: Boolean(opts.repair),
|
||||||
|
force: Boolean(opts.force),
|
||||||
nonInteractive: Boolean(opts.nonInteractive),
|
nonInteractive: Boolean(opts.nonInteractive),
|
||||||
deep: Boolean(opts.deep),
|
deep: Boolean(opts.deep),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -167,10 +167,31 @@ export async function maybeRepairGatewayServiceConfig(
|
|||||||
"Gateway service config",
|
"Gateway service config",
|
||||||
);
|
);
|
||||||
|
|
||||||
const repair = await prompter.confirmSkipInNonInteractive({
|
const aggressiveIssues = audit.issues.filter(
|
||||||
message: "Update gateway service config to the recommended defaults now?",
|
(issue) => issue.level === "aggressive",
|
||||||
initialValue: true,
|
);
|
||||||
});
|
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;
|
if (!repair) return;
|
||||||
|
|
||||||
const devMode =
|
const devMode =
|
||||||
|
|||||||
@@ -8,14 +8,20 @@ export type DoctorOptions = {
|
|||||||
yes?: boolean;
|
yes?: boolean;
|
||||||
nonInteractive?: boolean;
|
nonInteractive?: boolean;
|
||||||
deep?: boolean;
|
deep?: boolean;
|
||||||
|
repair?: boolean;
|
||||||
|
force?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DoctorPrompter = {
|
export type DoctorPrompter = {
|
||||||
confirm: (params: Parameters<typeof confirm>[0]) => Promise<boolean>;
|
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: (
|
confirmSkipInNonInteractive: (
|
||||||
params: Parameters<typeof confirm>[0],
|
params: Parameters<typeof confirm>[0],
|
||||||
) => Promise<boolean>;
|
) => Promise<boolean>;
|
||||||
select: <T>(params: Parameters<typeof select>[0], fallback: T) => Promise<T>;
|
select: <T>(params: Parameters<typeof select>[0], fallback: T) => Promise<T>;
|
||||||
|
shouldRepair: boolean;
|
||||||
|
shouldForce: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createDoctorPrompter(params: {
|
export function createDoctorPrompter(params: {
|
||||||
@@ -24,24 +30,42 @@ export function createDoctorPrompter(params: {
|
|||||||
}): DoctorPrompter {
|
}): DoctorPrompter {
|
||||||
const yes = params.options.yes === true;
|
const yes = params.options.yes === true;
|
||||||
const requestedNonInteractive = params.options.nonInteractive === 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 isTty = Boolean(process.stdin.isTTY);
|
||||||
const nonInteractive = requestedNonInteractive || (!isTty && !yes);
|
const nonInteractive = requestedNonInteractive || (!isTty && !yes);
|
||||||
|
|
||||||
const canPrompt = isTty && !yes && !nonInteractive;
|
const canPrompt = isTty && !yes && !nonInteractive;
|
||||||
const confirmDefault = async (p: Parameters<typeof confirm>[0]) => {
|
const confirmDefault = async (p: Parameters<typeof confirm>[0]) => {
|
||||||
|
if (nonInteractive) return false;
|
||||||
|
if (shouldRepair) return true;
|
||||||
if (!canPrompt) return Boolean(p.initialValue ?? false);
|
if (!canPrompt) return Boolean(p.initialValue ?? false);
|
||||||
return guardCancel(await confirm(p), params.runtime) === true;
|
return guardCancel(await confirm(p), params.runtime) === true;
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
confirm: confirmDefault,
|
confirm: confirmDefault,
|
||||||
confirmSkipInNonInteractive: async (p) => {
|
confirmRepair: async (p) => {
|
||||||
if (nonInteractive) return false;
|
if (nonInteractive) return false;
|
||||||
return confirmDefault(p);
|
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) => {
|
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;
|
return guardCancel(await select(p), params.runtime) as T;
|
||||||
},
|
},
|
||||||
|
shouldRepair,
|
||||||
|
shouldForce,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type ServiceConfigIssue = {
|
|||||||
code: string;
|
code: string;
|
||||||
message: string;
|
message: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
|
level?: "recommended" | "aggressive";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ServiceConfigAudit = {
|
export type ServiceConfigAudit = {
|
||||||
@@ -84,6 +85,7 @@ async function auditSystemdUnit(
|
|||||||
code: "systemd-after-network-online",
|
code: "systemd-after-network-online",
|
||||||
message: "Missing systemd After=network-online.target",
|
message: "Missing systemd After=network-online.target",
|
||||||
detail: unitPath,
|
detail: unitPath,
|
||||||
|
level: "recommended",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!parsed.wants.has("network-online.target")) {
|
if (!parsed.wants.has("network-online.target")) {
|
||||||
@@ -91,6 +93,7 @@ async function auditSystemdUnit(
|
|||||||
code: "systemd-wants-network-online",
|
code: "systemd-wants-network-online",
|
||||||
message: "Missing systemd Wants=network-online.target",
|
message: "Missing systemd Wants=network-online.target",
|
||||||
detail: unitPath,
|
detail: unitPath,
|
||||||
|
level: "recommended",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!isRestartSecPreferred(parsed.restartSec)) {
|
if (!isRestartSecPreferred(parsed.restartSec)) {
|
||||||
@@ -98,6 +101,7 @@ async function auditSystemdUnit(
|
|||||||
code: "systemd-restart-sec",
|
code: "systemd-restart-sec",
|
||||||
message: "RestartSec does not match the recommended 5s",
|
message: "RestartSec does not match the recommended 5s",
|
||||||
detail: unitPath,
|
detail: unitPath,
|
||||||
|
level: "recommended",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,6 +125,7 @@ async function auditLaunchdPlist(
|
|||||||
code: "launchd-run-at-load",
|
code: "launchd-run-at-load",
|
||||||
message: "LaunchAgent is missing RunAtLoad=true",
|
message: "LaunchAgent is missing RunAtLoad=true",
|
||||||
detail: plistPath,
|
detail: plistPath,
|
||||||
|
level: "recommended",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!hasKeepAlive) {
|
if (!hasKeepAlive) {
|
||||||
@@ -128,6 +133,7 @@ async function auditLaunchdPlist(
|
|||||||
code: "launchd-keep-alive",
|
code: "launchd-keep-alive",
|
||||||
message: "LaunchAgent is missing KeepAlive=true",
|
message: "LaunchAgent is missing KeepAlive=true",
|
||||||
detail: plistPath,
|
detail: plistPath,
|
||||||
|
level: "recommended",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,6 +147,7 @@ function auditGatewayCommand(
|
|||||||
issues.push({
|
issues.push({
|
||||||
code: "gateway-command-missing",
|
code: "gateway-command-missing",
|
||||||
message: "Service command does not include the gateway subcommand",
|
message: "Service command does not include the gateway subcommand",
|
||||||
|
level: "aggressive",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user