mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 02:40:18 +00:00
Infra: extract backup and plugin path helpers
This commit is contained in:
@@ -1,382 +1,31 @@
|
|||||||
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 type { RuntimeEnv } from "../runtime.js";
|
|
||||||
import { resolveHomeDir, resolveUserPath } from "../utils.js";
|
|
||||||
import { resolveRuntimeServiceVersion } from "../version.js";
|
|
||||||
import {
|
import {
|
||||||
buildBackupArchiveBasename,
|
createBackupArchive,
|
||||||
buildBackupArchiveRoot,
|
formatBackupCreateSummary,
|
||||||
buildBackupArchivePath,
|
type BackupCreateOptions,
|
||||||
type BackupAsset,
|
type BackupCreateResult,
|
||||||
resolveBackupPlanFromDisk,
|
} from "../infra/backup-create.js";
|
||||||
} from "./backup-shared.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { backupVerifyCommand } from "./backup-verify.js";
|
import { backupVerifyCommand } from "./backup-verify.js";
|
||||||
import { isPathWithin } from "./cleanup-utils.js";
|
export type { BackupCreateOptions, BackupCreateResult } from "../infra/backup-create.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 {
|
|
||||||
// Treat as a file path when the target does not exist yet.
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
// Some backup targets support ordinary files but not hard links.
|
|
||||||
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,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTextSummary(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 backupCreateCommand(
|
export async function backupCreateCommand(
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
opts: BackupCreateOptions = {},
|
opts: BackupCreateOptions = {},
|
||||||
): Promise<BackupCreateResult> {
|
): Promise<BackupCreateResult> {
|
||||||
const nowMs = opts.nowMs ?? Date.now();
|
const result = await createBackupArchive(opts);
|
||||||
const archiveRoot = buildBackupArchiveRoot(nowMs);
|
if (opts.verify && !opts.dryRun) {
|
||||||
const onlyConfig = Boolean(opts.onlyConfig);
|
await backupVerifyCommand(
|
||||||
const includeWorkspace = onlyConfig ? false : (opts.includeWorkspace ?? true);
|
{
|
||||||
const plan = await resolveBackupPlanFromDisk({ includeWorkspace, onlyConfig, nowMs });
|
...runtime,
|
||||||
const outputPath = await resolveOutputPath({
|
log: () => {},
|
||||||
output: opts.output,
|
},
|
||||||
nowMs,
|
{ archive: result.archivePath, json: false },
|
||||||
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.",
|
|
||||||
);
|
);
|
||||||
|
result.verified = true;
|
||||||
}
|
}
|
||||||
|
const output = opts.json
|
||||||
const canonicalOutputPath = await canonicalizePathForContainment(outputPath);
|
? JSON.stringify(result, null, 2)
|
||||||
const overlappingAsset = plan.included.find((asset) =>
|
: formatBackupCreateSummary(result).join("\n");
|
||||||
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) {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (opts.verify) {
|
|
||||||
await backupVerifyCommand(
|
|
||||||
{
|
|
||||||
...runtime,
|
|
||||||
log: () => {},
|
|
||||||
},
|
|
||||||
{ archive: outputPath, json: false },
|
|
||||||
);
|
|
||||||
result.verified = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const output = opts.json ? JSON.stringify(result, null, 2) : formatTextSummary(result).join("\n");
|
|
||||||
runtime.log(output);
|
runtime.log(output);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|||||||
368
src/infra/backup-create.ts
Normal file
368
src/infra/backup-create.ts
Normal file
@@ -0,0 +1,368 @@
|
|||||||
|
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 {
|
||||||
|
// Treat as a file path when the target does not exist yet.
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// Some backup targets support ordinary files but not hard links.
|
||||||
|
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;
|
||||||
|
}
|
||||||
61
src/infra/plugin-install-path-warnings.test.ts
Normal file
61
src/infra/plugin-install-path-warnings.test.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||||
|
import {
|
||||||
|
detectPluginInstallPathIssue,
|
||||||
|
formatPluginInstallPathIssue,
|
||||||
|
} from "./plugin-install-path-warnings.js";
|
||||||
|
|
||||||
|
describe("plugin install path warnings", () => {
|
||||||
|
it("detects stale custom plugin install paths", async () => {
|
||||||
|
const issue = await detectPluginInstallPathIssue({
|
||||||
|
pluginId: "matrix",
|
||||||
|
install: {
|
||||||
|
source: "path",
|
||||||
|
sourcePath: "/tmp/openclaw-matrix-missing",
|
||||||
|
installPath: "/tmp/openclaw-matrix-missing",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(issue).toEqual({
|
||||||
|
kind: "missing-path",
|
||||||
|
pluginId: "matrix",
|
||||||
|
path: "/tmp/openclaw-matrix-missing",
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
formatPluginInstallPathIssue({
|
||||||
|
issue: issue!,
|
||||||
|
pluginLabel: "Matrix",
|
||||||
|
defaultInstallCommand: "openclaw plugins install @openclaw/matrix",
|
||||||
|
repoInstallCommand: "openclaw plugins install ./extensions/matrix",
|
||||||
|
}),
|
||||||
|
).toEqual([
|
||||||
|
"Matrix is installed from a custom path that no longer exists: /tmp/openclaw-matrix-missing",
|
||||||
|
'Reinstall with "openclaw plugins install @openclaw/matrix".',
|
||||||
|
'If you are running from a repo checkout, you can also use "openclaw plugins install ./extensions/matrix".',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects active custom plugin install paths", async () => {
|
||||||
|
await withTempHome(async (home) => {
|
||||||
|
const pluginPath = path.join(home, "matrix-plugin");
|
||||||
|
await fs.mkdir(pluginPath, { recursive: true });
|
||||||
|
|
||||||
|
const issue = await detectPluginInstallPathIssue({
|
||||||
|
pluginId: "matrix",
|
||||||
|
install: {
|
||||||
|
source: "path",
|
||||||
|
sourcePath: pluginPath,
|
||||||
|
installPath: pluginPath,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(issue).toEqual({
|
||||||
|
kind: "custom-path",
|
||||||
|
pluginId: "matrix",
|
||||||
|
path: pluginPath,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
73
src/infra/plugin-install-path-warnings.ts
Normal file
73
src/infra/plugin-install-path-warnings.ts
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { PluginInstallRecord } from "../config/types.plugins.js";
|
||||||
|
|
||||||
|
export type PluginInstallPathIssue = {
|
||||||
|
kind: "custom-path" | "missing-path";
|
||||||
|
pluginId: string;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolvePluginInstallCandidatePaths(
|
||||||
|
install: PluginInstallRecord | null | undefined,
|
||||||
|
): string[] {
|
||||||
|
if (!install || install.source !== "path") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [install.sourcePath, install.installPath]
|
||||||
|
.map((value) => (typeof value === "string" ? value.trim() : ""))
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectPluginInstallPathIssue(params: {
|
||||||
|
pluginId: string;
|
||||||
|
install: PluginInstallRecord | null | undefined;
|
||||||
|
}): Promise<PluginInstallPathIssue | null> {
|
||||||
|
const candidatePaths = resolvePluginInstallCandidatePaths(params.install);
|
||||||
|
if (candidatePaths.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const candidatePath of candidatePaths) {
|
||||||
|
try {
|
||||||
|
await fs.access(path.resolve(candidatePath));
|
||||||
|
return {
|
||||||
|
kind: "custom-path",
|
||||||
|
pluginId: params.pluginId,
|
||||||
|
path: candidatePath,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Keep checking remaining candidate paths before warning about a stale install.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: "missing-path",
|
||||||
|
pluginId: params.pluginId,
|
||||||
|
path: candidatePaths[0] ?? "(unknown)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPluginInstallPathIssue(params: {
|
||||||
|
issue: PluginInstallPathIssue;
|
||||||
|
pluginLabel: string;
|
||||||
|
defaultInstallCommand: string;
|
||||||
|
repoInstallCommand: string;
|
||||||
|
formatCommand?: (command: string) => string;
|
||||||
|
}): string[] {
|
||||||
|
const formatCommand = params.formatCommand ?? ((command: string) => command);
|
||||||
|
if (params.issue.kind === "custom-path") {
|
||||||
|
return [
|
||||||
|
`${params.pluginLabel} is installed from a custom path: ${params.issue.path}`,
|
||||||
|
`Main updates will not automatically replace that plugin with the repo's default ${params.pluginLabel} package.`,
|
||||||
|
`Reinstall with "${formatCommand(params.defaultInstallCommand)}" when you want to return to the standard ${params.pluginLabel} plugin.`,
|
||||||
|
`If you are intentionally running from a repo checkout, reinstall that checkout explicitly with "${formatCommand(params.repoInstallCommand)}" after updates.`,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
`${params.pluginLabel} is installed from a custom path that no longer exists: ${params.issue.path}`,
|
||||||
|
`Reinstall with "${formatCommand(params.defaultInstallCommand)}".`,
|
||||||
|
`If you are running from a repo checkout, you can also use "${formatCommand(params.repoInstallCommand)}".`,
|
||||||
|
];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user