diff --git a/src/commands/backup-shared.ts b/src/commands/backup-shared.ts index b4b6961bbaa..479fd8c0e12 100644 --- a/src/commands/backup-shared.ts +++ b/src/commands/backup-shared.ts @@ -83,39 +83,26 @@ export function buildBackupArchivePath(archiveRoot: string, sourcePath: string): return path.posix.join(archiveRoot, "payload", encodeAbsolutePathForBackupArchive(sourcePath)); } -function compareCandidates(left: BackupAssetCandidate, right: BackupAssetCandidate): number { - const depthDelta = left.canonicalPath.length - right.canonicalPath.length; - if (depthDelta !== 0) { - return depthDelta; - } - const priorityDelta = backupAssetPriority(left.kind) - backupAssetPriority(right.kind); - if (priorityDelta !== 0) { - return priorityDelta; - } - return left.canonicalPath.localeCompare(right.canonicalPath); -} - -async function canonicalizeExistingPath(targetPath: string): Promise { - try { - return await fs.realpath(targetPath); - } catch { - return path.resolve(targetPath); - } -} - -export async function resolveBackupPlanFromDisk( - params: { - includeWorkspace?: boolean; - onlyConfig?: boolean; - nowMs?: number; - } = {}, -): Promise { +export async function resolveBackupPlanFromPaths(params: { + stateDir: string; + configPath: string; + oauthDir: string; + workspaceDirs?: string[]; + includeWorkspace?: boolean; + onlyConfig?: boolean; + configInsideState?: boolean; + oauthInsideState?: boolean; + nowMs?: number; +}): Promise { const includeWorkspace = params.includeWorkspace ?? true; const onlyConfig = params.onlyConfig ?? false; - const stateDir = resolveStateDir(); - const configPath = resolveConfigPath(); - const oauthDir = resolveOAuthDir(); + const stateDir = params.stateDir; + const configPath = params.configPath; + const oauthDir = params.oauthDir; const archiveRoot = buildBackupArchiveRoot(params.nowMs); + const workspaceDirs = includeWorkspace ? (params.workspaceDirs ?? []) : []; + const configInsideState = params.configInsideState ?? false; + const oauthInsideState = params.oauthInsideState ?? false; if (onlyConfig) { const resolvedConfigPath = path.resolve(configPath); @@ -155,34 +142,18 @@ export async function resolveBackupPlanFromDisk( }; } - const configSnapshot = await readConfigFileSnapshot(); - if (includeWorkspace && configSnapshot.exists && !configSnapshot.valid) { - throw new Error( - `Config invalid at ${shortenHomePath(configSnapshot.path)}. OpenClaw cannot reliably discover custom workspaces for backup. Fix the config or rerun with --no-include-workspace for a partial backup.`, - ); - } - const cleanupPlan = buildCleanupPlan({ - cfg: configSnapshot.config, - stateDir, - configPath, - oauthDir, - }); - const workspaceDirs = includeWorkspace ? cleanupPlan.workspaceDirs : []; - const rawCandidates: Array> = [ { kind: "state", sourcePath: path.resolve(stateDir) }, - ...(cleanupPlan.configInsideState + ...(configInsideState ? [] : [{ kind: "config" as const, sourcePath: path.resolve(configPath) }]), - ...(cleanupPlan.oauthInsideState + ...(oauthInsideState ? [] : [{ kind: "credentials" as const, sourcePath: path.resolve(oauthDir) }]), - ...(includeWorkspace - ? workspaceDirs.map((workspaceDir) => ({ - kind: "workspace" as const, - sourcePath: path.resolve(workspaceDir), - })) - : []), + ...workspaceDirs.map((workspaceDir) => ({ + kind: "workspace" as const, + sourcePath: path.resolve(workspaceDir), + })), ]; const candidates: BackupAssetCandidate[] = await Promise.all( @@ -252,3 +223,61 @@ export async function resolveBackupPlanFromDisk( skipped, }; } + +function compareCandidates(left: BackupAssetCandidate, right: BackupAssetCandidate): number { + const depthDelta = left.canonicalPath.length - right.canonicalPath.length; + if (depthDelta !== 0) { + return depthDelta; + } + const priorityDelta = backupAssetPriority(left.kind) - backupAssetPriority(right.kind); + if (priorityDelta !== 0) { + return priorityDelta; + } + return left.canonicalPath.localeCompare(right.canonicalPath); +} + +async function canonicalizeExistingPath(targetPath: string): Promise { + try { + return await fs.realpath(targetPath); + } catch { + return path.resolve(targetPath); + } +} + +export async function resolveBackupPlanFromDisk( + params: { + includeWorkspace?: boolean; + onlyConfig?: boolean; + nowMs?: number; + } = {}, +): Promise { + const includeWorkspace = params.includeWorkspace ?? true; + const onlyConfig = params.onlyConfig ?? false; + const stateDir = resolveStateDir(); + const configPath = resolveConfigPath(); + const oauthDir = resolveOAuthDir(); + + const configSnapshot = await readConfigFileSnapshot(); + if (includeWorkspace && configSnapshot.exists && !configSnapshot.valid) { + throw new Error( + `Config invalid at ${shortenHomePath(configSnapshot.path)}. OpenClaw cannot reliably discover custom workspaces for backup. Fix the config or rerun with --no-include-workspace for a partial backup.`, + ); + } + const cleanupPlan = buildCleanupPlan({ + cfg: configSnapshot.config, + stateDir, + configPath, + oauthDir, + }); + return await resolveBackupPlanFromPaths({ + stateDir, + configPath, + oauthDir, + workspaceDirs: includeWorkspace ? cleanupPlan.workspaceDirs : [], + includeWorkspace, + onlyConfig, + configInsideState: cleanupPlan.configInsideState, + oauthInsideState: cleanupPlan.oauthInsideState, + nowMs: params.nowMs, + }); +} diff --git a/src/commands/backup.test.ts b/src/commands/backup.test.ts index a9d658b6693..32c1b9e0185 100644 --- a/src/commands/backup.test.ts +++ b/src/commands/backup.test.ts @@ -8,6 +8,7 @@ import { createTempHomeEnv, type TempHomeEnv } from "../test-utils/temp-home.js" import { buildBackupArchiveRoot, encodeAbsolutePathForBackupArchive, + resolveBackupPlanFromPaths, resolveBackupPlanFromDisk, } from "./backup-shared.js"; import { backupCreateCommand } from "./backup.js"; @@ -88,13 +89,25 @@ describe("backup commands", () => { it("collapses default config, credentials, and workspace into the state backup root", async () => { const stateDir = path.join(tempHome.home, ".openclaw"); - await fs.writeFile(path.join(stateDir, "openclaw.json"), JSON.stringify({}), "utf8"); - await fs.mkdir(path.join(stateDir, "credentials"), { recursive: true }); - await fs.writeFile(path.join(stateDir, "credentials", "oauth.json"), "{}", "utf8"); - await fs.mkdir(path.join(stateDir, "workspace"), { recursive: true }); - await fs.writeFile(path.join(stateDir, "workspace", "SOUL.md"), "# soul\n", "utf8"); + const configPath = path.join(stateDir, "openclaw.json"); + const oauthDir = path.join(stateDir, "credentials"); + const workspaceDir = path.join(stateDir, "workspace"); + await fs.writeFile(configPath, JSON.stringify({}), "utf8"); + await fs.mkdir(oauthDir, { recursive: true }); + await fs.writeFile(path.join(oauthDir, "oauth.json"), "{}", "utf8"); + await fs.mkdir(workspaceDir, { recursive: true }); + await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "# soul\n", "utf8"); - const plan = await resolveBackupPlanFromDisk({ includeWorkspace: true, nowMs: 123 }); + const plan = await resolveBackupPlanFromPaths({ + stateDir, + configPath, + oauthDir, + workspaceDirs: [workspaceDir], + includeWorkspace: true, + configInsideState: true, + oauthInsideState: true, + nowMs: 123, + }); expectWorkspaceCoveredByState(plan); }); @@ -111,19 +124,16 @@ describe("backup commands", () => { await fs.mkdir(workspaceDir, { recursive: true }); await fs.writeFile(path.join(workspaceDir, "SOUL.md"), "# soul\n", "utf8"); await fs.symlink(workspaceDir, workspaceLink); - await fs.writeFile( - path.join(stateDir, "openclaw.json"), - JSON.stringify({ - agents: { - defaults: { - workspace: workspaceLink, - }, - }, - }), - "utf8", - ); - - const plan = await resolveBackupPlanFromDisk({ includeWorkspace: true, nowMs: 123 }); + const plan = await resolveBackupPlanFromPaths({ + stateDir, + configPath: path.join(stateDir, "openclaw.json"), + oauthDir: path.join(stateDir, "credentials"), + workspaceDirs: [workspaceLink], + includeWorkspace: true, + configInsideState: true, + oauthInsideState: true, + nowMs: 123, + }); expectWorkspaceCoveredByState(plan); } finally { await fs.rm(symlinkDir, { recursive: true, force: true });