fix(doctor): add headless flags + auto-migrate sessions
This commit is contained in:
@@ -50,6 +50,8 @@
|
|||||||
- Configure: add OpenAI Codex (ChatGPT OAuth) auth choice (align with onboarding).
|
- Configure: add OpenAI Codex (ChatGPT OAuth) auth choice (align with onboarding).
|
||||||
- Doctor: suggest adding the workspace memory system when missing (opt-out via `--no-workspace-suggestions`).
|
- Doctor: suggest adding the workspace memory system when missing (opt-out via `--no-workspace-suggestions`).
|
||||||
- Doctor: normalize default workspace path to `~/clawd` (avoid `~/clawdbot`).
|
- Doctor: normalize default workspace path to `~/clawd` (avoid `~/clawdbot`).
|
||||||
|
- Doctor: add `--yes` and `--non-interactive` for headless/automation runs (`--non-interactive` only applies safe migrations).
|
||||||
|
- Gateway/CLI: auto-migrate legacy sessions + agent state layouts on startup (safe; WhatsApp auth still requires `clawdbot doctor`).
|
||||||
- Workspace: only create `BOOTSTRAP.md` for brand-new workspaces (don’t recreate after deletion).
|
- Workspace: only create `BOOTSTRAP.md` for brand-new workspaces (don’t recreate after deletion).
|
||||||
- Build: fix duplicate protocol export, align Codex OAuth options, and add proper-lockfile typings.
|
- Build: fix duplicate protocol export, align Codex OAuth options, and add proper-lockfile typings.
|
||||||
- Build: install Bun in the Dockerfile so `pnpm build` can run Bun scripts. Thanks @loukotal for PR #284.
|
- Build: install Bun in the Dockerfile so `pnpm build` can run Bun scripts. Thanks @loukotal for PR #284.
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ Doctor can migrate older on-disk layouts into the current structure:
|
|||||||
- to `~/.clawdbot/credentials/whatsapp/<accountId>/...` (default account id: `default`)
|
- to `~/.clawdbot/credentials/whatsapp/<accountId>/...` (default account id: `default`)
|
||||||
|
|
||||||
These migrations are best-effort and idempotent; doctor will emit warnings when it leaves any legacy folders behind as backups.
|
These migrations are best-effort and idempotent; doctor will emit warnings when it leaves any legacy folders behind as backups.
|
||||||
The Gateway/CLI also auto-migrates the legacy agent dir on startup so auth/models land in the per-agent path without a manual doctor run.
|
The Gateway/CLI also auto-migrates the legacy sessions + agent dir on startup so history/auth/models land in the per-agent path without a manual doctor run. WhatsApp auth is intentionally only migrated via `clawdbot doctor`.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -56,6 +56,20 @@ The Gateway/CLI also auto-migrates the legacy agent dir on startup so auth/model
|
|||||||
clawdbot doctor
|
clawdbot doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Headless / automation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot doctor --yes
|
||||||
|
```
|
||||||
|
|
||||||
|
Accept defaults without prompting (including restart/service/sandbox repair steps when applicable).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
clawdbot doctor --non-interactive
|
||||||
|
```
|
||||||
|
|
||||||
|
Run without prompts and only apply safe migrations (config normalization + on-disk state moves). Skips restart/service/sandbox actions that require human confirmation.
|
||||||
|
|
||||||
If you want to review changes before writing, open the config file first:
|
If you want to review changes before writing, open the config file first:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
writeConfigFile,
|
writeConfigFile,
|
||||||
} from "../config/config.js";
|
} from "../config/config.js";
|
||||||
import { danger, setVerbose } from "../globals.js";
|
import { danger, setVerbose } from "../globals.js";
|
||||||
import { autoMigrateLegacyAgentDir } from "../infra/state-migrations.js";
|
import { autoMigrateLegacyState } from "../infra/state-migrations.js";
|
||||||
import { loginWeb, logoutWeb } from "../provider-web.js";
|
import { loginWeb, logoutWeb } from "../provider-web.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
@@ -132,7 +132,7 @@ export function buildProgram() {
|
|||||||
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
program.hook("preAction", async (_thisCommand, actionCommand) => {
|
||||||
if (actionCommand.name() === "doctor") return;
|
if (actionCommand.name() === "doctor") return;
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
await autoMigrateLegacyAgentDir({ cfg });
|
await autoMigrateLegacyState({ cfg });
|
||||||
});
|
});
|
||||||
const examples = [
|
const examples = [
|
||||||
[
|
[
|
||||||
@@ -307,10 +307,18 @@ export function buildProgram() {
|
|||||||
"Disable workspace memory system suggestions",
|
"Disable workspace memory system suggestions",
|
||||||
false,
|
false,
|
||||||
)
|
)
|
||||||
|
.option("--yes", "Accept defaults without prompting", false)
|
||||||
|
.option(
|
||||||
|
"--non-interactive",
|
||||||
|
"Run without prompts (safe migrations only)",
|
||||||
|
false,
|
||||||
|
)
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
try {
|
try {
|
||||||
await doctorCommand(defaultRuntime, {
|
await doctorCommand(defaultRuntime, {
|
||||||
workspaceSuggestions: opts.workspaceSuggestions,
|
workspaceSuggestions: opts.workspaceSuggestions,
|
||||||
|
yes: Boolean(opts.yes),
|
||||||
|
nonInteractive: Boolean(opts.nonInteractive),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(String(err));
|
defaultRuntime.error(String(err));
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
autoMigrateLegacyAgentDir,
|
autoMigrateLegacyState,
|
||||||
detectLegacyStateMigrations,
|
detectLegacyStateMigrations,
|
||||||
resetAutoMigrateLegacyAgentDirForTest,
|
resetAutoMigrateLegacyStateForTest,
|
||||||
runLegacyStateMigrations,
|
runLegacyStateMigrations,
|
||||||
} from "./doctor-state-migrations.js";
|
} from "./doctor-state-migrations.js";
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ async function makeTempRoot() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
resetAutoMigrateLegacyAgentDirForTest();
|
resetAutoMigrateLegacyStateForTest();
|
||||||
if (!tempRoot) return;
|
if (!tempRoot) return;
|
||||||
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
await fs.promises.rm(tempRoot, { recursive: true, force: true });
|
||||||
tempRoot = null;
|
tempRoot = null;
|
||||||
@@ -111,7 +111,7 @@ describe("doctor legacy state migrations", () => {
|
|||||||
|
|
||||||
const log = { info: vi.fn(), warn: vi.fn() };
|
const log = { info: vi.fn(), warn: vi.fn() };
|
||||||
|
|
||||||
const result = await autoMigrateLegacyAgentDir({
|
const result = await autoMigrateLegacyState({
|
||||||
cfg,
|
cfg,
|
||||||
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
|
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||||
log,
|
log,
|
||||||
@@ -123,6 +123,35 @@ describe("doctor legacy state migrations", () => {
|
|||||||
expect(log.info).toHaveBeenCalled();
|
expect(log.info).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("auto-migrates legacy sessions on startup", async () => {
|
||||||
|
const root = await makeTempRoot();
|
||||||
|
const cfg: ClawdbotConfig = {};
|
||||||
|
|
||||||
|
const legacySessionsDir = path.join(root, "sessions");
|
||||||
|
fs.mkdirSync(legacySessionsDir, { recursive: true });
|
||||||
|
writeJson5(path.join(legacySessionsDir, "sessions.json"), {
|
||||||
|
"+1555": { sessionId: "a", updatedAt: 10 },
|
||||||
|
});
|
||||||
|
fs.writeFileSync(path.join(legacySessionsDir, "a.jsonl"), "a", "utf-8");
|
||||||
|
|
||||||
|
const log = { info: vi.fn(), warn: vi.fn() };
|
||||||
|
|
||||||
|
const result = await autoMigrateLegacyState({
|
||||||
|
cfg,
|
||||||
|
env: { CLAWDBOT_STATE_DIR: root } as NodeJS.ProcessEnv,
|
||||||
|
log,
|
||||||
|
now: () => 123,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.migrated).toBe(true);
|
||||||
|
expect(log.info).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const targetDir = path.join(root, "agents", "main", "sessions");
|
||||||
|
expect(fs.existsSync(path.join(targetDir, "a.jsonl"))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(legacySessionsDir, "a.jsonl"))).toBe(false);
|
||||||
|
expect(fs.existsSync(path.join(targetDir, "sessions.json"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("migrates legacy WhatsApp auth files without touching oauth.json", async () => {
|
it("migrates legacy WhatsApp auth files without touching oauth.json", async () => {
|
||||||
const root = await makeTempRoot();
|
const root = await makeTempRoot();
|
||||||
const cfg: ClawdbotConfig = {};
|
const cfg: ClawdbotConfig = {};
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
export type { LegacyStateDetection } from "../infra/state-migrations.js";
|
export type { LegacyStateDetection } from "../infra/state-migrations.js";
|
||||||
export {
|
export {
|
||||||
autoMigrateLegacyAgentDir,
|
autoMigrateLegacyAgentDir,
|
||||||
|
autoMigrateLegacyState,
|
||||||
detectLegacyStateMigrations,
|
detectLegacyStateMigrations,
|
||||||
migrateLegacyAgentDir,
|
migrateLegacyAgentDir,
|
||||||
resetAutoMigrateLegacyAgentDirForTest,
|
resetAutoMigrateLegacyAgentDirForTest,
|
||||||
|
resetAutoMigrateLegacyStateForTest,
|
||||||
runLegacyStateMigrations,
|
runLegacyStateMigrations,
|
||||||
} from "../infra/state-migrations.js";
|
} from "../infra/state-migrations.js";
|
||||||
|
|||||||
@@ -1,4 +1,26 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
let originalIsTTY: boolean | undefined;
|
||||||
|
|
||||||
|
function setStdinTty(value: boolean | undefined) {
|
||||||
|
try {
|
||||||
|
Object.defineProperty(process.stdin, "isTTY", {
|
||||||
|
value,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
originalIsTTY = process.stdin.isTTY;
|
||||||
|
setStdinTty(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setStdinTty(originalIsTTY);
|
||||||
|
});
|
||||||
|
|
||||||
const readConfigFileSnapshot = vi.fn();
|
const readConfigFileSnapshot = vi.fn();
|
||||||
const confirm = vi.fn().mockResolvedValue(true);
|
const confirm = vi.fn().mockResolvedValue(true);
|
||||||
@@ -443,4 +465,153 @@ describe("doctor", () => {
|
|||||||
expect(docker.image).toBe("clawdis-sandbox-common:bookworm-slim");
|
expect(docker.image).toBe("clawdis-sandbox-common:bookworm-slim");
|
||||||
expect(runCommandWithTimeout).not.toHaveBeenCalled();
|
expect(runCommandWithTimeout).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("runs legacy state migrations in non-interactive mode without prompting", async () => {
|
||||||
|
readConfigFileSnapshot.mockResolvedValue({
|
||||||
|
path: "/tmp/clawdbot.json",
|
||||||
|
exists: true,
|
||||||
|
raw: "{}",
|
||||||
|
parsed: {},
|
||||||
|
valid: true,
|
||||||
|
config: {},
|
||||||
|
issues: [],
|
||||||
|
legacyIssues: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { doctorCommand } = await import("./doctor.js");
|
||||||
|
const runtime = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { detectLegacyStateMigrations, runLegacyStateMigrations } =
|
||||||
|
await import("./doctor-state-migrations.js");
|
||||||
|
detectLegacyStateMigrations.mockResolvedValueOnce({
|
||||||
|
targetAgentId: "main",
|
||||||
|
targetMainKey: "main",
|
||||||
|
stateDir: "/tmp/state",
|
||||||
|
oauthDir: "/tmp/oauth",
|
||||||
|
sessions: {
|
||||||
|
legacyDir: "/tmp/state/sessions",
|
||||||
|
legacyStorePath: "/tmp/state/sessions/sessions.json",
|
||||||
|
targetDir: "/tmp/state/agents/main/sessions",
|
||||||
|
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
||||||
|
hasLegacy: true,
|
||||||
|
},
|
||||||
|
agentDir: {
|
||||||
|
legacyDir: "/tmp/state/agent",
|
||||||
|
targetDir: "/tmp/state/agents/main/agent",
|
||||||
|
hasLegacy: false,
|
||||||
|
},
|
||||||
|
whatsappAuth: {
|
||||||
|
legacyDir: "/tmp/oauth",
|
||||||
|
targetDir: "/tmp/oauth/whatsapp/default",
|
||||||
|
hasLegacy: false,
|
||||||
|
},
|
||||||
|
preview: ["- Legacy sessions detected"],
|
||||||
|
});
|
||||||
|
runLegacyStateMigrations.mockResolvedValueOnce({
|
||||||
|
changes: ["migrated"],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
confirm.mockClear();
|
||||||
|
|
||||||
|
await doctorCommand(runtime, { nonInteractive: true });
|
||||||
|
|
||||||
|
expect(runLegacyStateMigrations).toHaveBeenCalledTimes(1);
|
||||||
|
expect(confirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs legacy state migrations in yes mode without prompting", async () => {
|
||||||
|
readConfigFileSnapshot.mockResolvedValue({
|
||||||
|
path: "/tmp/clawdbot.json",
|
||||||
|
exists: true,
|
||||||
|
raw: "{}",
|
||||||
|
parsed: {},
|
||||||
|
valid: true,
|
||||||
|
config: {},
|
||||||
|
issues: [],
|
||||||
|
legacyIssues: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { doctorCommand } = await import("./doctor.js");
|
||||||
|
const runtime = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const { detectLegacyStateMigrations, runLegacyStateMigrations } =
|
||||||
|
await import("./doctor-state-migrations.js");
|
||||||
|
detectLegacyStateMigrations.mockResolvedValueOnce({
|
||||||
|
targetAgentId: "main",
|
||||||
|
targetMainKey: "main",
|
||||||
|
stateDir: "/tmp/state",
|
||||||
|
oauthDir: "/tmp/oauth",
|
||||||
|
sessions: {
|
||||||
|
legacyDir: "/tmp/state/sessions",
|
||||||
|
legacyStorePath: "/tmp/state/sessions/sessions.json",
|
||||||
|
targetDir: "/tmp/state/agents/main/sessions",
|
||||||
|
targetStorePath: "/tmp/state/agents/main/sessions/sessions.json",
|
||||||
|
hasLegacy: true,
|
||||||
|
},
|
||||||
|
agentDir: {
|
||||||
|
legacyDir: "/tmp/state/agent",
|
||||||
|
targetDir: "/tmp/state/agents/main/agent",
|
||||||
|
hasLegacy: false,
|
||||||
|
},
|
||||||
|
whatsappAuth: {
|
||||||
|
legacyDir: "/tmp/oauth",
|
||||||
|
targetDir: "/tmp/oauth/whatsapp/default",
|
||||||
|
hasLegacy: false,
|
||||||
|
},
|
||||||
|
preview: ["- Legacy sessions detected"],
|
||||||
|
});
|
||||||
|
runLegacyStateMigrations.mockResolvedValueOnce({
|
||||||
|
changes: ["migrated"],
|
||||||
|
warnings: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
runLegacyStateMigrations.mockClear();
|
||||||
|
confirm.mockClear();
|
||||||
|
|
||||||
|
await doctorCommand(runtime, { yes: true });
|
||||||
|
|
||||||
|
expect(runLegacyStateMigrations).toHaveBeenCalledTimes(1);
|
||||||
|
expect(confirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips gateway restarts in non-interactive mode", async () => {
|
||||||
|
readConfigFileSnapshot.mockResolvedValue({
|
||||||
|
path: "/tmp/clawdbot.json",
|
||||||
|
exists: true,
|
||||||
|
raw: "{}",
|
||||||
|
parsed: {},
|
||||||
|
valid: true,
|
||||||
|
config: {},
|
||||||
|
issues: [],
|
||||||
|
legacyIssues: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { healthCommand } = await import("./health.js");
|
||||||
|
healthCommand.mockRejectedValueOnce(new Error("gateway closed"));
|
||||||
|
|
||||||
|
serviceIsLoaded.mockResolvedValueOnce(true);
|
||||||
|
serviceRestart.mockClear();
|
||||||
|
confirm.mockClear();
|
||||||
|
|
||||||
|
const { doctorCommand } = await import("./doctor.js");
|
||||||
|
const runtime = {
|
||||||
|
log: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
exit: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await doctorCommand(runtime, { nonInteractive: true });
|
||||||
|
|
||||||
|
expect(serviceRestart).not.toHaveBeenCalled();
|
||||||
|
expect(confirm).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -346,8 +346,46 @@ async function runSandboxScript(
|
|||||||
|
|
||||||
type DoctorOptions = {
|
type DoctorOptions = {
|
||||||
workspaceSuggestions?: boolean;
|
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 = [
|
const MEMORY_SYSTEM_PROMPT = [
|
||||||
"Memory system not found in workspace.",
|
"Memory system not found in workspace.",
|
||||||
"Paste this into your agent:",
|
"Paste this into your agent:",
|
||||||
@@ -463,6 +501,7 @@ type SandboxImageCheck = {
|
|||||||
async function handleMissingSandboxImage(
|
async function handleMissingSandboxImage(
|
||||||
params: SandboxImageCheck,
|
params: SandboxImageCheck,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
|
prompter: DoctorPrompter,
|
||||||
) {
|
) {
|
||||||
const exists = await dockerImageExists(params.image);
|
const exists = await dockerImageExists(params.image);
|
||||||
if (exists) return;
|
if (exists) return;
|
||||||
@@ -477,13 +516,10 @@ async function handleMissingSandboxImage(
|
|||||||
|
|
||||||
let built = false;
|
let built = false;
|
||||||
if (params.buildScript) {
|
if (params.buildScript) {
|
||||||
const build = guardCancel(
|
const build = await prompter.confirmSkipInNonInteractive({
|
||||||
await confirm({
|
message: `Build ${params.label} sandbox image now?`,
|
||||||
message: `Build ${params.label} sandbox image now?`,
|
initialValue: true,
|
||||||
initialValue: true,
|
});
|
||||||
}),
|
|
||||||
runtime,
|
|
||||||
);
|
|
||||||
if (build) {
|
if (build) {
|
||||||
built = await runSandboxScript(params.buildScript, runtime);
|
built = await runSandboxScript(params.buildScript, runtime);
|
||||||
}
|
}
|
||||||
@@ -496,13 +532,10 @@ async function handleMissingSandboxImage(
|
|||||||
const legacyExists = await dockerImageExists(legacyImage);
|
const legacyExists = await dockerImageExists(legacyImage);
|
||||||
if (!legacyExists) return;
|
if (!legacyExists) return;
|
||||||
|
|
||||||
const fallback = guardCancel(
|
const fallback = await prompter.confirmSkipInNonInteractive({
|
||||||
await confirm({
|
message: `Switch config to legacy image ${legacyImage}?`,
|
||||||
message: `Switch config to legacy image ${legacyImage}?`,
|
initialValue: false,
|
||||||
initialValue: false,
|
});
|
||||||
}),
|
|
||||||
runtime,
|
|
||||||
);
|
|
||||||
if (!fallback) return;
|
if (!fallback) return;
|
||||||
|
|
||||||
params.updateConfig(legacyImage);
|
params.updateConfig(legacyImage);
|
||||||
@@ -511,6 +544,7 @@ async function handleMissingSandboxImage(
|
|||||||
async function maybeRepairSandboxImages(
|
async function maybeRepairSandboxImages(
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
|
prompter: DoctorPrompter,
|
||||||
): Promise<ClawdbotConfig> {
|
): Promise<ClawdbotConfig> {
|
||||||
const sandbox = cfg.agent?.sandbox;
|
const sandbox = cfg.agent?.sandbox;
|
||||||
const mode = sandbox?.mode ?? "off";
|
const mode = sandbox?.mode ?? "off";
|
||||||
@@ -542,6 +576,7 @@ async function maybeRepairSandboxImages(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
runtime,
|
runtime,
|
||||||
|
prompter,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (sandbox.browser?.enabled) {
|
if (sandbox.browser?.enabled) {
|
||||||
@@ -556,6 +591,7 @@ async function maybeRepairSandboxImages(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
runtime,
|
runtime,
|
||||||
|
prompter,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -717,6 +753,7 @@ async function maybeMigrateLegacyConfigFile(runtime: RuntimeEnv) {
|
|||||||
async function maybeMigrateLegacyGatewayService(
|
async function maybeMigrateLegacyGatewayService(
|
||||||
cfg: ClawdbotConfig,
|
cfg: ClawdbotConfig,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
|
prompter: DoctorPrompter,
|
||||||
) {
|
) {
|
||||||
const legacyServices = await findLegacyGatewayServices(process.env);
|
const legacyServices = await findLegacyGatewayServices(process.env);
|
||||||
if (legacyServices.length === 0) return;
|
if (legacyServices.length === 0) return;
|
||||||
@@ -728,13 +765,10 @@ async function maybeMigrateLegacyGatewayService(
|
|||||||
"Legacy Clawdis services detected",
|
"Legacy Clawdis services detected",
|
||||||
);
|
);
|
||||||
|
|
||||||
const migrate = guardCancel(
|
const migrate = await prompter.confirmSkipInNonInteractive({
|
||||||
await confirm({
|
message: "Migrate legacy Clawdis services to Clawdbot now?",
|
||||||
message: "Migrate legacy Clawdis services to Clawdbot now?",
|
initialValue: true,
|
||||||
initialValue: true,
|
});
|
||||||
}),
|
|
||||||
runtime,
|
|
||||||
);
|
|
||||||
if (!migrate) return;
|
if (!migrate) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -764,23 +798,20 @@ async function maybeMigrateLegacyGatewayService(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const install = guardCancel(
|
const install = await prompter.confirmSkipInNonInteractive({
|
||||||
await confirm({
|
message: "Install Clawdbot gateway service now?",
|
||||||
message: "Install Clawdbot gateway service now?",
|
initialValue: true,
|
||||||
initialValue: true,
|
});
|
||||||
}),
|
|
||||||
runtime,
|
|
||||||
);
|
|
||||||
if (!install) return;
|
if (!install) return;
|
||||||
|
|
||||||
const daemonRuntime = guardCancel(
|
const daemonRuntime = await prompter.select<GatewayDaemonRuntime>(
|
||||||
await select({
|
{
|
||||||
message: "Gateway daemon runtime",
|
message: "Gateway daemon runtime",
|
||||||
options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
options: GATEWAY_DAEMON_RUNTIME_OPTIONS,
|
||||||
initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
initialValue: DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||||
}),
|
},
|
||||||
runtime,
|
DEFAULT_GATEWAY_DAEMON_RUNTIME,
|
||||||
) as GatewayDaemonRuntime;
|
);
|
||||||
const devMode =
|
const devMode =
|
||||||
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
process.argv[1]?.includes(`${path.sep}src${path.sep}`) &&
|
||||||
process.argv[1]?.endsWith(".ts");
|
process.argv[1]?.endsWith(".ts");
|
||||||
@@ -811,6 +842,7 @@ export async function doctorCommand(
|
|||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
options: DoctorOptions = {},
|
options: DoctorOptions = {},
|
||||||
) {
|
) {
|
||||||
|
const prompter = createDoctorPrompter({ runtime, options });
|
||||||
printWizardHeader(runtime);
|
printWizardHeader(runtime);
|
||||||
intro("Clawdbot doctor");
|
intro("Clawdbot doctor");
|
||||||
|
|
||||||
@@ -833,13 +865,10 @@ export async function doctorCommand(
|
|||||||
.join("\n"),
|
.join("\n"),
|
||||||
"Legacy config keys detected",
|
"Legacy config keys detected",
|
||||||
);
|
);
|
||||||
const migrate = guardCancel(
|
const migrate = await prompter.confirm({
|
||||||
await confirm({
|
message: "Migrate legacy config entries now?",
|
||||||
message: "Migrate legacy config entries now?",
|
initialValue: true,
|
||||||
initialValue: true,
|
});
|
||||||
}),
|
|
||||||
runtime,
|
|
||||||
);
|
|
||||||
if (migrate) {
|
if (migrate) {
|
||||||
// Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom.
|
// Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom.
|
||||||
const { config: migrated, changes } = migrateLegacyConfig(
|
const { config: migrated, changes } = migrateLegacyConfig(
|
||||||
@@ -863,13 +892,10 @@ export async function doctorCommand(
|
|||||||
const legacyState = await detectLegacyStateMigrations({ cfg });
|
const legacyState = await detectLegacyStateMigrations({ cfg });
|
||||||
if (legacyState.preview.length > 0) {
|
if (legacyState.preview.length > 0) {
|
||||||
note(legacyState.preview.join("\n"), "Legacy state detected");
|
note(legacyState.preview.join("\n"), "Legacy state detected");
|
||||||
const migrate = guardCancel(
|
const migrate = await prompter.confirm({
|
||||||
await confirm({
|
message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?",
|
||||||
message: "Migrate legacy state (sessions/agent/WhatsApp auth) now?",
|
initialValue: true,
|
||||||
initialValue: true,
|
});
|
||||||
}),
|
|
||||||
runtime,
|
|
||||||
);
|
|
||||||
if (migrate) {
|
if (migrate) {
|
||||||
const migrated = await runLegacyStateMigrations({
|
const migrated = await runLegacyStateMigrations({
|
||||||
detected: legacyState,
|
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);
|
await noteSecurityWarnings(cfg);
|
||||||
|
|
||||||
if (process.platform === "linux" && resolveMode(cfg) === "local") {
|
if (
|
||||||
|
options.nonInteractive !== true &&
|
||||||
|
process.platform === "linux" &&
|
||||||
|
resolveMode(cfg) === "local"
|
||||||
|
) {
|
||||||
const service = resolveGatewayService();
|
const service = resolveGatewayService();
|
||||||
let loaded = false;
|
let loaded = false;
|
||||||
try {
|
try {
|
||||||
@@ -901,7 +931,7 @@ export async function doctorCommand(
|
|||||||
await ensureSystemdUserLingerInteractive({
|
await ensureSystemdUserLingerInteractive({
|
||||||
runtime,
|
runtime,
|
||||||
prompter: {
|
prompter: {
|
||||||
confirm: async (p) => guardCancel(await confirm(p), runtime) === true,
|
confirm: async (p) => prompter.confirm(p),
|
||||||
note,
|
note,
|
||||||
},
|
},
|
||||||
reason:
|
reason:
|
||||||
@@ -955,13 +985,10 @@ export async function doctorCommand(
|
|||||||
"Gateway",
|
"Gateway",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const restart = guardCancel(
|
const restart = await prompter.confirmSkipInNonInteractive({
|
||||||
await confirm({
|
message: "Restart gateway daemon now?",
|
||||||
message: "Restart gateway daemon now?",
|
initialValue: true,
|
||||||
initialValue: true,
|
});
|
||||||
}),
|
|
||||||
runtime,
|
|
||||||
);
|
|
||||||
if (restart) {
|
if (restart) {
|
||||||
await service.restart({ stdout: process.stdout });
|
await service.restart({ stdout: process.stdout });
|
||||||
await sleep(1500);
|
await sleep(1500);
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ import { startHeartbeatRunner } from "../infra/heartbeat-runner.js";
|
|||||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||||
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
||||||
import { autoMigrateLegacyAgentDir } from "../infra/state-migrations.js";
|
import { autoMigrateLegacyState } from "../infra/state-migrations.js";
|
||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import {
|
import {
|
||||||
listSystemPresence,
|
listSystemPresence,
|
||||||
@@ -389,7 +389,7 @@ export async function startGatewayServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cfgAtStart = loadConfig();
|
const cfgAtStart = loadConfig();
|
||||||
await autoMigrateLegacyAgentDir({ cfg: cfgAtStart, log });
|
await autoMigrateLegacyState({ cfg: cfgAtStart, log });
|
||||||
const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback";
|
const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback";
|
||||||
const bindHost = opts.host ?? resolveGatewayBindHost(bindMode);
|
const bindHost = opts.host ?? resolveGatewayBindHost(bindMode);
|
||||||
if (!bindHost) {
|
if (!bindHost) {
|
||||||
|
|||||||
@@ -174,10 +174,14 @@ function removeDirIfEmpty(dir: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resetAutoMigrateLegacyAgentDirForTest() {
|
export function resetAutoMigrateLegacyStateForTest() {
|
||||||
autoMigrateChecked = false;
|
autoMigrateChecked = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resetAutoMigrateLegacyAgentDirForTest() {
|
||||||
|
resetAutoMigrateLegacyStateForTest();
|
||||||
|
}
|
||||||
|
|
||||||
export async function detectLegacyStateMigrations(params: {
|
export async function detectLegacyStateMigrations(params: {
|
||||||
cfg: ClawdbotConfig;
|
cfg: ClawdbotConfig;
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
@@ -478,6 +482,21 @@ export async function autoMigrateLegacyAgentDir(params: {
|
|||||||
skipped: boolean;
|
skipped: boolean;
|
||||||
changes: string[];
|
changes: string[];
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
|
}> {
|
||||||
|
return await autoMigrateLegacyState(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function autoMigrateLegacyState(params: {
|
||||||
|
cfg: ClawdbotConfig;
|
||||||
|
env?: NodeJS.ProcessEnv;
|
||||||
|
homedir?: () => string;
|
||||||
|
log?: MigrationLogger;
|
||||||
|
now?: () => number;
|
||||||
|
}): Promise<{
|
||||||
|
migrated: boolean;
|
||||||
|
skipped: boolean;
|
||||||
|
changes: string[];
|
||||||
|
warnings: string[];
|
||||||
}> {
|
}> {
|
||||||
if (autoMigrateChecked) {
|
if (autoMigrateChecked) {
|
||||||
return { migrated: false, skipped: true, changes: [], warnings: [] };
|
return { migrated: false, skipped: true, changes: [], warnings: [] };
|
||||||
@@ -494,25 +513,27 @@ export async function autoMigrateLegacyAgentDir(params: {
|
|||||||
env,
|
env,
|
||||||
homedir: params.homedir,
|
homedir: params.homedir,
|
||||||
});
|
});
|
||||||
if (!detected.agentDir.hasLegacy) {
|
if (!detected.sessions.hasLegacy && !detected.agentDir.hasLegacy) {
|
||||||
return { migrated: false, skipped: false, changes: [], warnings: [] };
|
return { migrated: false, skipped: false, changes: [], warnings: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { changes, warnings } = await migrateLegacyAgentDir(
|
const now = params.now ?? (() => Date.now());
|
||||||
detected,
|
const sessions = await migrateLegacySessions(detected, now);
|
||||||
params.now ?? (() => Date.now()),
|
const agentDir = await migrateLegacyAgentDir(detected, now);
|
||||||
);
|
const changes = [...sessions.changes, ...agentDir.changes];
|
||||||
|
const warnings = [...sessions.warnings, ...agentDir.warnings];
|
||||||
|
|
||||||
const logger = params.log ?? createSubsystemLogger("state-migrations");
|
const logger = params.log ?? createSubsystemLogger("state-migrations");
|
||||||
if (changes.length > 0) {
|
if (changes.length > 0) {
|
||||||
logger.info(
|
logger.info(
|
||||||
`Auto-migrated legacy agent dir:\n${changes
|
`Auto-migrated legacy state:\n${changes
|
||||||
.map((entry) => `- ${entry}`)
|
.map((entry) => `- ${entry}`)
|
||||||
.join("\n")}`,
|
.join("\n")}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (warnings.length > 0) {
|
if (warnings.length > 0) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
`Legacy agent dir migration warnings:\n${warnings
|
`Legacy state migration warnings:\n${warnings
|
||||||
.map((entry) => `- ${entry}`)
|
.map((entry) => `- ${entry}`)
|
||||||
.join("\n")}`,
|
.join("\n")}`,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user