fix(doctor): add headless flags + auto-migrate sessions

This commit is contained in:
Peter Steinberger
2026-01-07 04:43:05 +01:00
parent 9c9ae5aa54
commit 6ffece68b0
9 changed files with 350 additions and 76 deletions

View File

@@ -346,8 +346,46 @@ async function runSandboxScript(
type DoctorOptions = {
workspaceSuggestions?: boolean;
yes?: boolean;
nonInteractive?: boolean;
};
type DoctorPrompter = {
confirm: (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>;
};
function createDoctorPrompter(params: {
runtime: RuntimeEnv;
options: DoctorOptions;
}): DoctorPrompter {
const yes = params.options.yes === true;
const requestedNonInteractive = params.options.nonInteractive === 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 (!canPrompt) return Boolean(p.initialValue ?? false);
return guardCancel(await confirm(p), params.runtime) === true;
};
return {
confirm: confirmDefault,
confirmSkipInNonInteractive: async (p) => {
if (nonInteractive) return false;
return confirmDefault(p);
},
select: async <T>(p: Parameters<typeof select>[0], fallback: T) => {
if (!canPrompt) return fallback;
return guardCancel(await select(p), params.runtime) as T;
},
};
}
const MEMORY_SYSTEM_PROMPT = [
"Memory system not found in workspace.",
"Paste this into your agent:",
@@ -463,6 +501,7 @@ type SandboxImageCheck = {
async function handleMissingSandboxImage(
params: SandboxImageCheck,
runtime: RuntimeEnv,
prompter: DoctorPrompter,
) {
const exists = await dockerImageExists(params.image);
if (exists) return;
@@ -477,13 +516,10 @@ async function handleMissingSandboxImage(
let built = false;
if (params.buildScript) {
const build = guardCancel(
await confirm({
message: `Build ${params.label} sandbox image now?`,
initialValue: true,
}),
runtime,
);
const build = await prompter.confirmSkipInNonInteractive({
message: `Build ${params.label} sandbox image now?`,
initialValue: true,
});
if (build) {
built = await runSandboxScript(params.buildScript, runtime);
}
@@ -496,13 +532,10 @@ async function handleMissingSandboxImage(
const legacyExists = await dockerImageExists(legacyImage);
if (!legacyExists) return;
const fallback = guardCancel(
await confirm({
message: `Switch config to legacy image ${legacyImage}?`,
initialValue: false,
}),
runtime,
);
const fallback = await prompter.confirmSkipInNonInteractive({
message: `Switch config to legacy image ${legacyImage}?`,
initialValue: false,
});
if (!fallback) return;
params.updateConfig(legacyImage);
@@ -511,6 +544,7 @@ async function handleMissingSandboxImage(
async function maybeRepairSandboxImages(
cfg: ClawdbotConfig,
runtime: RuntimeEnv,
prompter: DoctorPrompter,
): Promise<ClawdbotConfig> {
const sandbox = cfg.agent?.sandbox;
const mode = sandbox?.mode ?? "off";
@@ -542,6 +576,7 @@ async function maybeRepairSandboxImages(
},
},
runtime,
prompter,
);
if (sandbox.browser?.enabled) {
@@ -556,6 +591,7 @@ async function maybeRepairSandboxImages(
},
},
runtime,
prompter,
);
}
@@ -717,6 +753,7 @@ async function maybeMigrateLegacyConfigFile(runtime: RuntimeEnv) {
async function maybeMigrateLegacyGatewayService(
cfg: ClawdbotConfig,
runtime: RuntimeEnv,
prompter: DoctorPrompter,
) {
const legacyServices = await findLegacyGatewayServices(process.env);
if (legacyServices.length === 0) return;
@@ -728,13 +765,10 @@ async function maybeMigrateLegacyGatewayService(
"Legacy Clawdis services detected",
);
const migrate = guardCancel(
await confirm({
message: "Migrate legacy Clawdis services to Clawdbot now?",
initialValue: true,
}),
runtime,
);
const migrate = await prompter.confirmSkipInNonInteractive({
message: "Migrate legacy Clawdis services to Clawdbot now?",
initialValue: true,
});
if (!migrate) return;
try {
@@ -764,23 +798,20 @@ async function maybeMigrateLegacyGatewayService(
return;
}
const install = guardCancel(
await confirm({
message: "Install Clawdbot gateway service now?",
initialValue: true,
}),
runtime,
);
const install = await prompter.confirmSkipInNonInteractive({
message: "Install Clawdbot gateway service now?",
initialValue: true,
});
if (!install) return;
const daemonRuntime = guardCancel(
await select({
const daemonRuntime = await prompter.select<GatewayDaemonRuntime>(
{
message: "Gateway daemon runtime",
options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME,
}),
runtime,
) as GatewayDaemonRuntime;
},
DEFAULT_GATEWAY_DAEMON_RUNTIME,
);
const devMode =
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
process.argv[1]?.endsWith(".ts");
@@ -811,6 +842,7 @@ export async function doctorCommand(
runtime: RuntimeEnv = defaultRuntime,
options: DoctorOptions = {},
) {
const prompter = createDoctorPrompter({ runtime, options });
printWizardHeader(runtime);
intro("Clawdbot doctor");
@@ -833,13 +865,10 @@ export async function doctorCommand(
.join("\n"),
"Legacy config keys detected",
);
const migrate = guardCancel(
await confirm({
message: "Migrate legacy config entries now?",
initialValue: true,
}),
runtime,
);
const migrate = await prompter.confirm({
message: "Migrate legacy config entries now?",
initialValue: true,
});
if (migrate) {
// Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom.
const { config: migrated, changes } = migrateLegacyConfig(
@@ -863,13 +892,10 @@ export async function doctorCommand(
const legacyState = await detectLegacyStateMigrations({ cfg });
if (legacyState.preview.length > 0) {
note(legacyState.preview.join("\n"), "Legacy state detected");
const migrate = guardCancel(
await confirm({
message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?",
initialValue: true,
}),
runtime,
);
const migrate = await prompter.confirm({
message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?",
initialValue: true,
});
if (migrate) {
const migrated = await runLegacyStateMigrations({
detected: legacyState,
@@ -883,13 +909,17 @@ export async function doctorCommand(
}
}
cfg = await maybeRepairSandboxImages(cfg, runtime);
cfg = await maybeRepairSandboxImages(cfg, runtime, prompter);
await maybeMigrateLegacyGatewayService(cfg, runtime);
await maybeMigrateLegacyGatewayService(cfg, runtime, prompter);
await noteSecurityWarnings(cfg);
if (process.platform === "linux" && resolveMode(cfg) === "local") {
if (
options.nonInteractive !== true &&
process.platform === "linux" &&
resolveMode(cfg) === "local"
) {
const service = resolveGatewayService();
let loaded = false;
try {
@@ -901,7 +931,7 @@ export async function doctorCommand(
await ensureSystemdUserLingerInteractive({
runtime,
prompter: {
confirm: async (p) => guardCancel(await confirm(p), runtime) === true,
confirm: async (p) => prompter.confirm(p),
note,
},
reason:
@@ -955,13 +985,10 @@ export async function doctorCommand(
"Gateway",
);
}
const restart = guardCancel(
await confirm({
message: "Restart gateway daemon now?",
initialValue: true,
}),
runtime,
);
const restart = await prompter.confirmSkipInNonInteractive({
message: "Restart gateway daemon now?",
initialValue: true,
});
if (restart) {
await service.restart({ stdout: process.stdout });
await sleep(1500);