Skip to content
13 changes: 13 additions & 0 deletions src/cli/program/register.backup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
import { runCommandWithRuntime } from "../cli-utils.js";
import { formatHelpExamples } from "../help-format.js";
import { collectOption } from "./helpers.js";

export function registerBackupCommand(program: Command) {
const backup = program
Expand All @@ -26,6 +27,8 @@ export function registerBackupCommand(program: Command) {
.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")
.option("--exclude <pattern>", "Exclude files matching pattern (repeatable)", collectOption, [])
.option("--exclude-file <path>", "Read exclude patterns from file")
.addHelpText(
"after",
() =>
Expand All @@ -48,6 +51,14 @@ export function registerBackupCommand(program: Command) {
"Back up state/config without agent workspace files.",
],
["openclaw backup create --only-config", "Back up only the active JSON config file."],
[
"openclaw backup create --exclude 'node_modules' --exclude '*.log'",
"Exclude specific patterns from backup.",
],
[
"openclaw backup create --exclude-file .openclawignore",
"Exclude patterns from a file (like .gitignore).",
],
])}`,
)
.action(async (opts) => {
Expand All @@ -59,6 +70,8 @@ export function registerBackupCommand(program: Command) {
verify: Boolean(opts.verify),
onlyConfig: Boolean(opts.onlyConfig),
includeWorkspace: opts.includeWorkspace as boolean,
exclude: opts.exclude as string[] | undefined,
excludeFile: opts.excludeFile as string | undefined,
});
});
});
Expand Down
139 changes: 139 additions & 0 deletions src/commands/backup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -431,4 +431,143 @@ describe("backup commands", () => {
delete process.env.OPENCLAW_CONFIG_PATH;
}
});

describe("exclude patterns", () => {
it("excludes files matching --exclude pattern", async () => {
const stateDir = path.join(tempHome.home, ".openclaw");
await fs.mkdir(path.join(stateDir, "workspace"), { recursive: true });
await fs.writeFile(path.join(stateDir, "workspace", "important.txt"), "important", "utf8");
await fs.writeFile(path.join(stateDir, "workspace", "node_modules", "dep.js"), "dep", "utf8");
await fs.writeFile(
path.join(stateDir, "openclaw.json"),
JSON.stringify({ agents: { defaults: { workspace: path.join(stateDir, "workspace") } } }),
"utf8",
);

const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };

const result = await backupCreateCommand(runtime, {
dryRun: true,
exclude: ["node_modules"],
});

expect(result.assets.some((a) => a.sourcePath.includes("node_modules"))).toBe(false);
expect(result.skipped.some((s) => s.reason === "excluded")).toBe(true);
});

it("escapes regex special characters in exclude patterns", async () => {
const stateDir = path.join(tempHome.home, ".openclaw");
await fs.mkdir(path.join(stateDir, "workspace"), { recursive: true });
await fs.writeFile(path.join(stateDir, "workspace", "file.txt"), "txt", "utf8");
await fs.writeFile(path.join(stateDir, "workspace", "file.old.txt"), "old", "utf8");
await fs.writeFile(
path.join(stateDir, "openclaw.json"),
JSON.stringify({ agents: { defaults: { workspace: path.join(stateDir, "workspace") } } }),
"utf8",
);

const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };

// Pattern file.old.txt should only match literal file, not file.txt
const result = await backupCreateCommand(runtime, {
dryRun: true,
exclude: ["file.old.txt"],
});

// file.txt should still be included
expect(result.assets.some((a) => a.sourcePath.includes("file.txt"))).toBe(true);
// file.old.txt should be excluded
expect(result.assets.some((a) => a.sourcePath.includes("file.old.txt"))).toBe(false);
});

it("loads exclude patterns from --exclude-file", async () => {
const stateDir = path.join(tempHome.home, ".openclaw");
const excludeFile = path.join(tempHome.home, ".openclawignore");
await fs.mkdir(path.join(stateDir, "workspace"), { recursive: true });
await fs.writeFile(path.join(stateDir, "workspace", "important.txt"), "important", "utf8");
await fs.writeFile(path.join(stateDir, "workspace", "secret.env"), "secret", "utf8");
await fs.writeFile(excludeFile, "secret.env\n*.log", "utf8");
await fs.writeFile(
path.join(stateDir, "openclaw.json"),
JSON.stringify({ agents: { defaults: { workspace: path.join(stateDir, "workspace") } } }),
"utf8",
);

const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };

const result = await backupCreateCommand(runtime, {
dryRun: true,
excludeFile: excludeFile,
});

expect(result.assets.some((a) => a.sourcePath.includes("important.txt"))).toBe(true);
expect(result.assets.some((a) => a.sourcePath.includes("secret.env"))).toBe(false);
});

it("throws error when --exclude-file does not exist", async () => {
const stateDir = path.join(tempHome.home, ".openclaw");
await fs.mkdir(path.join(stateDir, "workspace"), { recursive: true });
await fs.writeFile(
path.join(stateDir, "openclaw.json"),
JSON.stringify({ agents: { defaults: { workspace: path.join(stateDir, "workspace") } } }),
"utf8",
);

const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };

await expect(
backupCreateCommand(runtime, {
dryRun: true,
excludeFile: "/nonexistent/file.txt",
}),
).rejects.toThrow("Failed to load exclude file");
});

it("auto-loads .gitignore from workspace directories", async () => {
const stateDir = path.join(tempHome.home, ".openclaw");
const workspaceDir = path.join(stateDir, "workspace");
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "keep.txt"), "keep", "utf8");
await fs.writeFile(path.join(workspaceDir, "skip.log"), "skip", "utf8");
await fs.writeFile(path.join(workspaceDir, ".gitignore"), "*.log\nnode_modules/", "utf8");
await fs.writeFile(
path.join(stateDir, "openclaw.json"),
JSON.stringify({ agents: { defaults: { workspace: workspaceDir } } }),
"utf8",
);

const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };

const result = await backupCreateCommand(runtime, {
dryRun: true,
});

expect(result.assets.some((a) => a.sourcePath.includes("keep.txt"))).toBe(true);
expect(result.assets.some((a) => a.sourcePath.includes("skip.log"))).toBe(false);
});

it("supports multiple --exclude patterns", async () => {
const stateDir = path.join(tempHome.home, ".openclaw");
await fs.mkdir(path.join(stateDir, "workspace"), { recursive: true });
await fs.writeFile(path.join(stateDir, "workspace", "a.txt"), "a", "utf8");
await fs.writeFile(path.join(stateDir, "workspace", "b.txt"), "b", "utf8");
await fs.writeFile(path.join(stateDir, "workspace", "c.txt"), "c", "utf8");
await fs.writeFile(
path.join(stateDir, "openclaw.json"),
JSON.stringify({ agents: { defaults: { workspace: path.join(stateDir, "workspace") } } }),
"utf8",
);

const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };

const result = await backupCreateCommand(runtime, {
dryRun: true,
exclude: ["a.txt", "b.txt"],
});

expect(result.assets.some((a) => a.sourcePath.includes("c.txt"))).toBe(true);
expect(result.assets.some((a) => a.sourcePath.includes("a.txt"))).toBe(false);
expect(result.assets.some((a) => a.sourcePath.includes("b.txt"))).toBe(false);
});
});
});
Loading