| import { randomUUID } from "node:crypto"; |
| import { constants as fsConstants } from "node:fs"; |
| import fs from "node:fs/promises"; |
| import os from "node:os"; |
| import path from "node:path"; |
| import * as tar from "tar"; |
| import { |
| buildBackupArchiveBasename, |
| buildBackupArchivePath, |
| buildBackupArchiveRoot, |
| type BackupAsset, |
| resolveBackupPlanFromDisk, |
| } from "../commands/backup-shared.js"; |
| import { isPathWithin } from "../commands/cleanup-utils.js"; |
| import { resolveHomeDir, resolveUserPath } from "../utils.js"; |
| import { resolveRuntimeServiceVersion } from "../version.js"; |
|
|
| export type BackupCreateOptions = { |
| output?: string; |
| dryRun?: boolean; |
| includeWorkspace?: boolean; |
| onlyConfig?: boolean; |
| verify?: boolean; |
| json?: boolean; |
| nowMs?: number; |
| }; |
|
|
| type BackupManifestAsset = { |
| kind: BackupAsset["kind"]; |
| sourcePath: string; |
| archivePath: string; |
| }; |
|
|
| type BackupManifest = { |
| schemaVersion: 1; |
| createdAt: string; |
| archiveRoot: string; |
| runtimeVersion: string; |
| platform: NodeJS.Platform; |
| nodeVersion: string; |
| options: { |
| includeWorkspace: boolean; |
| onlyConfig?: boolean; |
| }; |
| paths: { |
| stateDir: string; |
| configPath: string; |
| oauthDir: string; |
| workspaceDirs: string[]; |
| }; |
| assets: BackupManifestAsset[]; |
| skipped: Array<{ |
| kind: string; |
| sourcePath: string; |
| reason: string; |
| coveredBy?: string; |
| }>; |
| }; |
|
|
| export type BackupCreateResult = { |
| createdAt: string; |
| archiveRoot: string; |
| archivePath: string; |
| dryRun: boolean; |
| includeWorkspace: boolean; |
| onlyConfig: boolean; |
| verified: boolean; |
| assets: BackupAsset[]; |
| skipped: Array<{ |
| kind: string; |
| sourcePath: string; |
| displayPath: string; |
| reason: string; |
| coveredBy?: string; |
| }>; |
| }; |
|
|
| async function resolveOutputPath(params: { |
| output?: string; |
| nowMs: number; |
| includedAssets: BackupAsset[]; |
| stateDir: string; |
| }): Promise<string> { |
| const basename = buildBackupArchiveBasename(params.nowMs); |
| const rawOutput = params.output?.trim(); |
| if (!rawOutput) { |
| const cwd = path.resolve(process.cwd()); |
| const canonicalCwd = await fs.realpath(cwd).catch(() => cwd); |
| const cwdInsideSource = params.includedAssets.some((asset) => |
| isPathWithin(canonicalCwd, asset.sourcePath), |
| ); |
| const defaultDir = cwdInsideSource ? (resolveHomeDir() ?? path.dirname(params.stateDir)) : cwd; |
| return path.resolve(defaultDir, basename); |
| } |
|
|
| const resolved = resolveUserPath(rawOutput); |
| if (rawOutput.endsWith("/") || rawOutput.endsWith("\\")) { |
| return path.join(resolved, basename); |
| } |
|
|
| try { |
| const stat = await fs.stat(resolved); |
| if (stat.isDirectory()) { |
| return path.join(resolved, basename); |
| } |
| } catch { |
| |
| } |
|
|
| return resolved; |
| } |
|
|
| async function assertOutputPathReady(outputPath: string): Promise<void> { |
| try { |
| await fs.access(outputPath); |
| throw new Error(`Refusing to overwrite existing backup archive: ${outputPath}`); |
| } catch (err) { |
| const code = (err as NodeJS.ErrnoException | undefined)?.code; |
| if (code === "ENOENT") { |
| return; |
| } |
| throw err; |
| } |
| } |
|
|
| function buildTempArchivePath(outputPath: string): string { |
| return `${outputPath}.${randomUUID()}.tmp`; |
| } |
|
|
| function isLinkUnsupportedError(code: string | undefined): boolean { |
| return code === "ENOTSUP" || code === "EOPNOTSUPP" || code === "EPERM"; |
| } |
|
|
| async function publishTempArchive(params: { |
| tempArchivePath: string; |
| outputPath: string; |
| }): Promise<void> { |
| try { |
| await fs.link(params.tempArchivePath, params.outputPath); |
| } catch (err) { |
| const code = (err as NodeJS.ErrnoException | undefined)?.code; |
| if (code === "EEXIST") { |
| throw new Error(`Refusing to overwrite existing backup archive: ${params.outputPath}`, { |
| cause: err, |
| }); |
| } |
| if (!isLinkUnsupportedError(code)) { |
| throw err; |
| } |
|
|
| try { |
| |
| await fs.copyFile(params.tempArchivePath, params.outputPath, fsConstants.COPYFILE_EXCL); |
| } catch (copyErr) { |
| const copyCode = (copyErr as NodeJS.ErrnoException | undefined)?.code; |
| if (copyCode !== "EEXIST") { |
| await fs.rm(params.outputPath, { force: true }).catch(() => undefined); |
| } |
| if (copyCode === "EEXIST") { |
| throw new Error(`Refusing to overwrite existing backup archive: ${params.outputPath}`, { |
| cause: copyErr, |
| }); |
| } |
| throw copyErr; |
| } |
| } |
| await fs.rm(params.tempArchivePath, { force: true }); |
| } |
|
|
| async function canonicalizePathForContainment(targetPath: string): Promise<string> { |
| const resolved = path.resolve(targetPath); |
| const suffix: string[] = []; |
| let probe = resolved; |
|
|
| while (true) { |
| try { |
| const realProbe = await fs.realpath(probe); |
| return suffix.length === 0 ? realProbe : path.join(realProbe, ...suffix.toReversed()); |
| } catch { |
| const parent = path.dirname(probe); |
| if (parent === probe) { |
| return resolved; |
| } |
| suffix.push(path.basename(probe)); |
| probe = parent; |
| } |
| } |
| } |
|
|
| function buildManifest(params: { |
| createdAt: string; |
| archiveRoot: string; |
| includeWorkspace: boolean; |
| onlyConfig: boolean; |
| assets: BackupAsset[]; |
| skipped: BackupCreateResult["skipped"]; |
| stateDir: string; |
| configPath: string; |
| oauthDir: string; |
| workspaceDirs: string[]; |
| }): BackupManifest { |
| return { |
| schemaVersion: 1, |
| createdAt: params.createdAt, |
| archiveRoot: params.archiveRoot, |
| runtimeVersion: resolveRuntimeServiceVersion(), |
| platform: process.platform, |
| nodeVersion: process.version, |
| options: { |
| includeWorkspace: params.includeWorkspace, |
| onlyConfig: params.onlyConfig, |
| }, |
| paths: { |
| stateDir: params.stateDir, |
| configPath: params.configPath, |
| oauthDir: params.oauthDir, |
| workspaceDirs: params.workspaceDirs, |
| }, |
| assets: params.assets.map((asset) => ({ |
| kind: asset.kind, |
| sourcePath: asset.sourcePath, |
| archivePath: asset.archivePath, |
| })), |
| skipped: params.skipped.map((entry) => ({ |
| kind: entry.kind, |
| sourcePath: entry.sourcePath, |
| reason: entry.reason, |
| coveredBy: entry.coveredBy, |
| })), |
| }; |
| } |
|
|
| export function formatBackupCreateSummary(result: BackupCreateResult): string[] { |
| const lines = [`Backup archive: ${result.archivePath}`]; |
| lines.push(`Included ${result.assets.length} path${result.assets.length === 1 ? "" : "s"}:`); |
| for (const asset of result.assets) { |
| lines.push(`- ${asset.kind}: ${asset.displayPath}`); |
| } |
| if (result.skipped.length > 0) { |
| lines.push(`Skipped ${result.skipped.length} path${result.skipped.length === 1 ? "" : "s"}:`); |
| for (const entry of result.skipped) { |
| if (entry.reason === "covered" && entry.coveredBy) { |
| lines.push(`- ${entry.kind}: ${entry.displayPath} (${entry.reason} by ${entry.coveredBy})`); |
| } else { |
| lines.push(`- ${entry.kind}: ${entry.displayPath} (${entry.reason})`); |
| } |
| } |
| } |
| if (result.dryRun) { |
| lines.push("Dry run only; archive was not written."); |
| } else { |
| lines.push(`Created ${result.archivePath}`); |
| if (result.verified) { |
| lines.push("Archive verification: passed"); |
| } |
| } |
| return lines; |
| } |
|
|
| function remapArchiveEntryPath(params: { |
| entryPath: string; |
| manifestPath: string; |
| archiveRoot: string; |
| }): string { |
| const normalizedEntry = path.resolve(params.entryPath); |
| if (normalizedEntry === params.manifestPath) { |
| return path.posix.join(params.archiveRoot, "manifest.json"); |
| } |
| return buildBackupArchivePath(params.archiveRoot, normalizedEntry); |
| } |
|
|
| export async function createBackupArchive( |
| opts: BackupCreateOptions = {}, |
| ): Promise<BackupCreateResult> { |
| const nowMs = opts.nowMs ?? Date.now(); |
| const archiveRoot = buildBackupArchiveRoot(nowMs); |
| const onlyConfig = Boolean(opts.onlyConfig); |
| const includeWorkspace = onlyConfig ? false : (opts.includeWorkspace ?? true); |
| const plan = await resolveBackupPlanFromDisk({ includeWorkspace, onlyConfig, nowMs }); |
| const outputPath = await resolveOutputPath({ |
| output: opts.output, |
| nowMs, |
| includedAssets: plan.included, |
| stateDir: plan.stateDir, |
| }); |
|
|
| if (plan.included.length === 0) { |
| throw new Error( |
| onlyConfig |
| ? "No OpenClaw config file was found to back up." |
| : "No local OpenClaw state was found to back up.", |
| ); |
| } |
|
|
| const canonicalOutputPath = await canonicalizePathForContainment(outputPath); |
| const overlappingAsset = plan.included.find((asset) => |
| isPathWithin(canonicalOutputPath, asset.sourcePath), |
| ); |
| if (overlappingAsset) { |
| throw new Error( |
| `Backup output must not be written inside a source path: ${outputPath} is inside ${overlappingAsset.sourcePath}`, |
| ); |
| } |
|
|
| if (!opts.dryRun) { |
| await assertOutputPathReady(outputPath); |
| } |
|
|
| const createdAt = new Date(nowMs).toISOString(); |
| const result: BackupCreateResult = { |
| createdAt, |
| archiveRoot, |
| archivePath: outputPath, |
| dryRun: Boolean(opts.dryRun), |
| includeWorkspace, |
| onlyConfig, |
| verified: false, |
| assets: plan.included, |
| skipped: plan.skipped, |
| }; |
|
|
| if (opts.dryRun) { |
| return result; |
| } |
|
|
| await fs.mkdir(path.dirname(outputPath), { recursive: true }); |
| const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-")); |
| const manifestPath = path.join(tempDir, "manifest.json"); |
| const tempArchivePath = buildTempArchivePath(outputPath); |
| try { |
| const manifest = buildManifest({ |
| createdAt, |
| archiveRoot, |
| includeWorkspace, |
| onlyConfig, |
| assets: result.assets, |
| skipped: result.skipped, |
| stateDir: plan.stateDir, |
| configPath: plan.configPath, |
| oauthDir: plan.oauthDir, |
| workspaceDirs: plan.workspaceDirs, |
| }); |
| await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); |
|
|
| await tar.c( |
| { |
| file: tempArchivePath, |
| gzip: true, |
| portable: true, |
| preservePaths: true, |
| onWriteEntry: (entry) => { |
| entry.path = remapArchiveEntryPath({ |
| entryPath: entry.path, |
| manifestPath, |
| archiveRoot, |
| }); |
| }, |
| }, |
| [manifestPath, ...result.assets.map((asset) => asset.sourcePath)], |
| ); |
| await publishTempArchive({ tempArchivePath, outputPath }); |
| } finally { |
| await fs.rm(tempArchivePath, { force: true }).catch(() => undefined); |
| await fs.rm(tempDir, { recursive: true, force: true }).catch(() => undefined); |
| } |
|
|
| return result; |
| } |
|
|