mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-19 14:00:51 +00:00
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:
@@ -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");
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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;
|
||||
|
||||
104
src/cli/program/register.backup.test.ts
Normal file
104
src/cli/program/register.backup.test.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
92
src/cli/program/register.backup.ts
Normal file
92
src/cli/program/register.backup.ts
Normal 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),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user