diff --git a/src/commands/backup.ts b/src/commands/backup.ts index 15f0f505d76..ab4397db0f3 100644 --- a/src/commands/backup.ts +++ b/src/commands/backup.ts @@ -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 { - 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 { - 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 { - 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 { - 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 { - 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; } diff --git a/src/infra/backup-create.ts b/src/infra/backup-create.ts new file mode 100644 index 00000000000..4697d859cc3 --- /dev/null +++ b/src/infra/backup-create.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/src/infra/plugin-install-path-warnings.test.ts b/src/infra/plugin-install-path-warnings.test.ts new file mode 100644 index 00000000000..6c24e57623f --- /dev/null +++ b/src/infra/plugin-install-path-warnings.test.ts @@ -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, + }); + }); + }); +}); diff --git a/src/infra/plugin-install-path-warnings.ts b/src/infra/plugin-install-path-warnings.ts new file mode 100644 index 00000000000..8435a93e33f --- /dev/null +++ b/src/infra/plugin-install-path-warnings.ts @@ -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 { + 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)}".`, + ]; +}