Infra: extract backup and plugin path helpers

This commit is contained in:
Gustavo Madeira Santana
2026-03-10 20:16:35 -04:00
parent f4a4b50cd5
commit 3ba6491659
4 changed files with 521 additions and 370 deletions

View File

@@ -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 {
buildBackupArchiveBasename,
buildBackupArchiveRoot,
buildBackupArchivePath,
type BackupAsset,
resolveBackupPlanFromDisk,
} from "./backup-shared.js";
createBackupArchive,
formatBackupCreateSummary,
type BackupCreateOptions,
type BackupCreateResult,
} from "../infra/backup-create.js";
import type { RuntimeEnv } from "../runtime.js";
import { backupVerifyCommand } from "./backup-verify.js";
import { isPathWithin } from "./cleanup-utils.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 type { BackupCreateOptions, BackupCreateResult } from "../infra/backup-create.js";
export async function backupCreateCommand(
runtime: RuntimeEnv,
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 result = await createBackupArchive(opts);
if (opts.verify && !opts.dryRun) {
await backupVerifyCommand(
{
...runtime,
log: () => {},
},
{ archive: result.archivePath, json: false },
);
result.verified = true;
}
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) {
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");
const output = opts.json
? JSON.stringify(result, null, 2)
: formatBackupCreateSummary(result).join("\n");
runtime.log(output);
return result;
}

368
src/infra/backup-create.ts Normal file
View 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;
}

View 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,
});
});
});
});

View 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)}".`,
];
}