feat: add local backup CLI (#40163)

Merged via squash.

Prepared head SHA: ed46625ae2
Co-authored-by: shichangs <46870204+shichangs@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
shichangs
2026-03-09 04:21:20 +08:00
committed by GitHub
parent a075baba84
commit 0ecfd37b44
22 changed files with 2256 additions and 12 deletions

View File

@@ -11,6 +11,13 @@ vi.mock("./register.agent.js", () => ({
},
}));
vi.mock("./register.backup.js", () => ({
registerBackupCommand: (program: Command) => {
const backup = program.command("backup");
backup.command("create");
},
}));
vi.mock("./register.maintenance.js", () => ({
registerMaintenanceCommands: (program: Command) => {
program.command("doctor");
@@ -67,6 +74,7 @@ describe("command-registry", () => {
expect(names).toContain("config");
expect(names).toContain("memory");
expect(names).toContain("agents");
expect(names).toContain("backup");
expect(names).toContain("browser");
expect(names).toContain("sessions");
expect(names).not.toContain("agent");

View File

@@ -92,6 +92,19 @@ const coreEntries: CoreCliEntry[] = [
mod.registerConfigCli(program);
},
},
{
commands: [
{
name: "backup",
description: "Create and verify local backup archives for OpenClaw state",
hasSubcommands: true,
},
],
register: async ({ program }) => {
const mod = await import("./register.backup.js");
mod.registerBackupCommand(program);
},
},
{
commands: [
{

View File

@@ -80,6 +80,11 @@ describe("registerPreActionHooks", () => {
function buildProgram() {
const program = new Command().name("openclaw");
program.command("status").action(() => {});
program
.command("backup")
.command("create")
.option("--json")
.action(() => {});
program.command("doctor").action(() => {});
program.command("completion").action(() => {});
program.command("secrets").action(() => {});
@@ -226,6 +231,15 @@ describe("registerPreActionHooks", () => {
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
});
it("bypasses config guard for backup create", async () => {
await runPreAction({
parseArgv: ["backup", "create"],
processArgv: ["node", "openclaw", "backup", "create", "--json"],
});
expect(ensureConfigReadyMock).not.toHaveBeenCalled();
});
beforeAll(() => {
program = buildProgram();
const hooks = (

View File

@@ -36,7 +36,7 @@ const PLUGIN_REQUIRED_COMMANDS = new Set([
"status",
"health",
]);
const CONFIG_GUARD_BYPASS_COMMANDS = new Set(["doctor", "completion", "secrets"]);
const CONFIG_GUARD_BYPASS_COMMANDS = new Set(["backup", "doctor", "completion", "secrets"]);
const JSON_PARSE_ONLY_COMMANDS = new Set(["config set"]);
let configGuardModulePromise: Promise<typeof import("./config-guard.js")> | undefined;
let pluginRegistryModulePromise: Promise<typeof import("../plugin-registry.js")> | undefined;

View File

@@ -0,0 +1,104 @@
import { Command } from "commander";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const backupCreateCommand = vi.fn();
const backupVerifyCommand = vi.fn();
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
vi.mock("../../commands/backup.js", () => ({
backupCreateCommand,
}));
vi.mock("../../commands/backup-verify.js", () => ({
backupVerifyCommand,
}));
vi.mock("../../runtime.js", () => ({
defaultRuntime: runtime,
}));
let registerBackupCommand: typeof import("./register.backup.js").registerBackupCommand;
beforeAll(async () => {
({ registerBackupCommand } = await import("./register.backup.js"));
});
describe("registerBackupCommand", () => {
async function runCli(args: string[]) {
const program = new Command();
registerBackupCommand(program);
await program.parseAsync(args, { from: "user" });
}
beforeEach(() => {
vi.clearAllMocks();
backupCreateCommand.mockResolvedValue(undefined);
backupVerifyCommand.mockResolvedValue(undefined);
});
it("runs backup create with forwarded options", async () => {
await runCli(["backup", "create", "--output", "/tmp/backups", "--json", "--dry-run"]);
expect(backupCreateCommand).toHaveBeenCalledWith(
runtime,
expect.objectContaining({
output: "/tmp/backups",
json: true,
dryRun: true,
verify: false,
onlyConfig: false,
includeWorkspace: true,
}),
);
});
it("honors --no-include-workspace", async () => {
await runCli(["backup", "create", "--no-include-workspace"]);
expect(backupCreateCommand).toHaveBeenCalledWith(
runtime,
expect.objectContaining({
includeWorkspace: false,
}),
);
});
it("forwards --verify to backup create", async () => {
await runCli(["backup", "create", "--verify"]);
expect(backupCreateCommand).toHaveBeenCalledWith(
runtime,
expect.objectContaining({
verify: true,
}),
);
});
it("forwards --only-config to backup create", async () => {
await runCli(["backup", "create", "--only-config"]);
expect(backupCreateCommand).toHaveBeenCalledWith(
runtime,
expect.objectContaining({
onlyConfig: true,
}),
);
});
it("runs backup verify with forwarded options", async () => {
await runCli(["backup", "verify", "/tmp/openclaw-backup.tar.gz", "--json"]);
expect(backupVerifyCommand).toHaveBeenCalledWith(
runtime,
expect.objectContaining({
archive: "/tmp/openclaw-backup.tar.gz",
json: true,
}),
);
});
});

View File

@@ -0,0 +1,92 @@
import type { Command } from "commander";
import { backupVerifyCommand } from "../../commands/backup-verify.js";
import { backupCreateCommand } from "../../commands/backup.js";
import { defaultRuntime } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { formatHelpExamples } from "../help-format.js";
export function registerBackupCommand(program: Command) {
const backup = program
.command("backup")
.description("Create and verify local backup archives for OpenClaw state")
.addHelpText(
"after",
() =>
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/backup", "docs.openclaw.ai/cli/backup")}\n`,
);
backup
.command("create")
.description("Write a backup archive for config, credentials, sessions, and workspaces")
.option("--output <path>", "Archive path or destination directory")
.option("--json", "Output JSON", false)
.option("--dry-run", "Print the backup plan without writing the archive", false)
.option("--verify", "Verify the archive after writing it", false)
.option("--only-config", "Back up only the active JSON config file", false)
.option("--no-include-workspace", "Exclude workspace directories from the backup")
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
["openclaw backup create", "Create a timestamped backup in the current directory."],
[
"openclaw backup create --output ~/Backups",
"Write the archive into an existing backup directory.",
],
[
"openclaw backup create --dry-run --json",
"Preview the archive plan without writing any files.",
],
[
"openclaw backup create --verify",
"Create the archive and immediately validate its manifest and payload layout.",
],
[
"openclaw backup create --no-include-workspace",
"Back up state/config without agent workspace files.",
],
["openclaw backup create --only-config", "Back up only the active JSON config file."],
])}`,
)
.action(async (opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await backupCreateCommand(defaultRuntime, {
output: opts.output as string | undefined,
json: Boolean(opts.json),
dryRun: Boolean(opts.dryRun),
verify: Boolean(opts.verify),
onlyConfig: Boolean(opts.onlyConfig),
includeWorkspace: opts.includeWorkspace as boolean,
});
});
});
backup
.command("verify <archive>")
.description("Validate a backup archive and its embedded manifest")
.option("--json", "Output JSON", false)
.addHelpText(
"after",
() =>
`\n${theme.heading("Examples:")}\n${formatHelpExamples([
[
"openclaw backup verify ./2026-03-09T00-00-00.000Z-openclaw-backup.tar.gz",
"Check that the archive structure and manifest are intact.",
],
[
"openclaw backup verify ~/Backups/latest.tar.gz --json",
"Emit machine-readable verification output.",
],
])}`,
)
.action(async (archive, opts) => {
await runCommandWithRuntime(defaultRuntime, async () => {
await backupVerifyCommand(defaultRuntime, {
archive: archive as string,
json: Boolean(opts.json),
});
});
});
}