diff --git a/cli/src/__tests__/update-command.test.ts b/cli/src/__tests__/update-command.test.ts index 99e09d156de..bbb7560d8a7 100644 --- a/cli/src/__tests__/update-command.test.ts +++ b/cli/src/__tests__/update-command.test.ts @@ -19,6 +19,9 @@ function createPayload(payloadPath: string, version: string): string { fs.writeFileSync(entrypoint, version); return entrypoint; } +function markInstanceOnboarded(): void { + fs.mkdirSync(path.join(process.env.PAPERCLIP_HOME!, "instances", "default"), { recursive: true }); +} beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-update-")); @@ -28,6 +31,7 @@ beforeEach(() => { process.env.PAPERCLIP_HOME = path.join(root, "paperclip"); }); afterEach(() => { + vi.restoreAllMocks(); vi.unstubAllEnvs(); if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome; if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousPaperclipHome; @@ -62,6 +66,7 @@ describe("update command", () => { }); it("re-resolves a moving git branch and activates the new SHA payload", async () => { + markInstanceOnboarded(); const paths = resolveInstallStorePaths(); initializeInstallStore(paths); const oldSha = "1".repeat(40); const newSha = "2".repeat(40); const oldPayload = payloadPathFor(paths, "git", oldSha.slice(0, 12)); @@ -131,6 +136,7 @@ describe("update command", () => { }); it("backs up, installs side-by-side, flips, and rolls back instantly", async () => { + markInstanceOnboarded(); const paths = resolveInstallStorePaths(); initializeInstallStore(paths); const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); const executable = createPayload(oldPayload, "1.0.0"); flipCurrentAtomic(oldPayload, paths); writeInstallManifestAtomic({ schemaVersion: 1, ...record(oldPayload, "1.0.0"), previous: [] }, paths); @@ -151,6 +157,50 @@ describe("update command", () => { expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(oldPayload)); }); + it("explains how to recover when the pre-update database is unreachable", async () => { + markInstanceOnboarded(); + const paths = resolveInstallStorePaths(); initializeInstallStore(paths); + const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); const executable = createPayload(oldPayload, "1.0.0"); flipCurrentAtomic(oldPayload, paths); + writeInstallManifestAtomic({ schemaVersion: 1, ...record(oldPayload, "1.0.0"), previous: [] }, paths); + const backupError = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:54329"), { code: "ECONNREFUSED" }); + const backup = vi.fn(async () => { throw backupError; }); + const runCommand = vi.fn(async () => ({ stdout: '"2.0.0"\n', stderr: "" })); + + await expect(updateCommand({}, { paths, executablePath: executable, runCommand, backup })).rejects.toThrow( + "Start the service with `paperclipai service start` and retry, or skip the backup with `paperclipai update --no-backup`.", + ); + expect(backup).toHaveBeenCalledOnce(); + expect(readInstallManifest(paths)?.version).toBe("1.0.0"); + }); + + it("skips the pre-update backup when there is no onboarded instance data", async () => { + vi.stubEnv("PAPERCLIP_CONFIG", path.join(root, "missing-config.json")); + vi.stubEnv("PAPERCLIP_INSTANCE_ID", "default"); + vi.stubEnv("DATABASE_URL", ""); + const paths = resolveInstallStorePaths(); initializeInstallStore(paths); + const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); const executable = createPayload(oldPayload, "1.0.0"); flipCurrentAtomic(oldPayload, paths); + writeInstallManifestAtomic({ schemaVersion: 1, ...record(oldPayload, "1.0.0"), previous: [] }, paths); + const backup = vi.fn(async () => undefined); + const log = vi.spyOn(console, "log").mockImplementation(() => undefined); + const runCommand = vi.fn(async (file: string, args: string[]) => { + if (args[0] === "view") return { stdout: '"2.0.0"\n', stderr: "" }; + if (file === "npm" && args[0] === "install") { createPayload(args[args.indexOf("--prefix") + 1], "2.0.0"); return { stdout: "", stderr: "" }; } + return { stdout: "2.0.0\n", stderr: "" }; + }); + + const previousCwd = process.cwd(); + process.chdir(root); + try { + await updateCommand({}, { paths, executablePath: executable, runCommand, backup, restartActiveService: async () => false }); + } finally { + process.chdir(previousCwd); + } + + expect(backup).not.toHaveBeenCalled(); + expect(log).toHaveBeenCalledWith(expect.stringContaining("has not been onboarded and has no data to back up")); + expect(readInstallManifest(paths)?.version).toBe("2.0.0"); + }); + it("does not inherit a managed pin for global npm updates", async () => { const paths = resolveInstallStorePaths(); initializeInstallStore(paths); const managedPayload = payloadPathFor(paths, "npm", "1.2.3"); createPayload(managedPayload, "1.2.3"); diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index a04704999e8..63cd0b78c35 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -8,7 +8,8 @@ import pc from "picocolors"; import { buildNextManifest, flipCurrentAtomic, isManagedExecutable, pruneInstallPayloads, readInstallManifest, resolveInstallStorePaths, withInstallStoreLock, writeInstallManifestAtomic, type InstallChannel, type InstallManifest, type InstallRecord, type InstallStorePaths } from "../install-store.js"; import { dbBackupCommand } from "./db-backup.js"; import { installGitPayload, installNpmPayload, PUBLIC_NPM_REGISTRY, resolveGitHubRef, resolvePublishedVersion, type CommandRunner } from "./install.js"; -import { resolvePaperclipInstanceId } from "../config/home.js"; +import { resolvePaperclipInstanceId, resolvePaperclipInstanceRoot } from "../config/home.js"; +import { resolveConfigPath } from "../config/store.js"; import { detectServiceManager } from "../services/service-manager.js"; import { restartManagedService } from "./service.js"; import { packageVersion } from "../version.js"; @@ -18,6 +19,54 @@ export type InstallMode = "managed" | "global-npm" | "npx" | "source" | "unknown export type UpdateOptions = { canary?: boolean; latest?: boolean; version?: string; rollback?: boolean; check?: boolean; dryRun?: boolean; json?: boolean; yes?: boolean; backup?: boolean }; type Dependencies = { executablePath: string; runCommand: CommandRunner; backup: () => Promise; confirm: (message: string) => Promise; now: () => Date; paths: InstallStorePaths; restartActiveService: (expectedVersion: string) => Promise }; +const DATABASE_UNREACHABLE_CODES = new Set(["ECONNREFUSED", "EHOSTUNREACH", "ENETUNREACH", "ETIMEDOUT"]); + +function hasPaperclipInstanceData(): boolean { + return Boolean(process.env.DATABASE_URL?.trim()) + || fs.existsSync(resolveConfigPath()) + || fs.existsSync(resolvePaperclipInstanceRoot()); +} + +function isDatabaseUnreachableError(error: unknown): boolean { + const pending = [error]; + const seen = new Set(); + while (pending.length > 0) { + const current = pending.pop(); + if (current === null || current === undefined || seen.has(current)) continue; + seen.add(current); + if (typeof current === "string") { + if (/\b(?:ECONNREFUSED|EHOSTUNREACH|ENETUNREACH|ETIMEDOUT)\b|connection refused/i.test(current)) return true; + continue; + } + if (typeof current !== "object") continue; + const record = current as Record; + if (typeof record.code === "string" && DATABASE_UNREACHABLE_CODES.has(record.code)) return true; + if (typeof record.message === "string" && /\b(?:ECONNREFUSED|EHOSTUNREACH|ENETUNREACH|ETIMEDOUT)\b|connection refused/i.test(record.message)) return true; + if (record.cause !== undefined) pending.push(record.cause); + if (Array.isArray(record.errors)) pending.push(...record.errors); + } + return false; +} + +async function runPreUpdateBackup(options: UpdateOptions, backup: () => Promise): Promise { + if (!hasPaperclipInstanceData()) { + const message = "Skipping the pre-update backup because this Paperclip instance has not been onboarded and has no data to back up."; + if (options.json) console.error(message); else console.log(pc.yellow(message)); + return; + } + try { + await backup(); + } catch (error) { + if (isDatabaseUnreachableError(error)) { + throw new Error( + "The Paperclip database is not running or reachable, so the pre-update backup cannot be taken. Start the service with `paperclipai service start` and retry, or skip the backup with `paperclipai update --no-backup`.", + { cause: error }, + ); + } + throw error; + } +} + async function restartActiveManagedService(expectedVersion: string): Promise { const instanceId = resolvePaperclipInstanceId(); const detection = await detectServiceManager({ instanceId }); @@ -142,7 +191,7 @@ export async function updateCommand(options: UpdateOptions, overrides: Partial dbBackupCommand({})))(); + if (options.backup !== false) await runPreUpdateBackup(options, overrides.backup ?? (() => dbBackupCommand({}))); const installed = await withInstallStoreLock(async () => { const payload = await installGitPayload(manifest.repo!, targetSha, runCommand, paths); const record: InstallRecord = { source: "git", version: payload.version, channel: "pinned", repo: manifest.repo, ref: manifest.ref, sha: targetSha, payloadPath: payload.payloadPath, installedAt: (overrides.now?.() ?? new Date()).toISOString() }; @@ -196,7 +245,7 @@ export async function updateCommand(options: UpdateOptions, overrides: Partial dbBackupCommand({})))(); + if (options.backup !== false) await runPreUpdateBackup(options, overrides.backup ?? (() => dbBackupCommand({}))); const installed = await withInstallStoreLock(async () => { const payload = await installNpmPayload(targetVersion, runCommand, paths); const record: InstallRecord = { source: "npm", version: targetVersion, channel: request.channel, payloadPath: payload.payloadPath, installedAt: (overrides.now?.() ?? new Date()).toISOString() }; diff --git a/doc/INSTALLING.md b/doc/INSTALLING.md index 791910daf1e..60db823616e 100644 --- a/doc/INSTALLING.md +++ b/doc/INSTALLING.md @@ -184,6 +184,12 @@ Managed updates create a database backup before switching payloads, verify the new CLI, atomically flip `current`, and restart an installed service. A failed install or verification leaves the previous payload active. +If the service is stopped, start it with `paperclipai service start` before +updating so Paperclip can take the safety backup. Use +`paperclipai update --no-backup` only when you intentionally accept updating +without that rollback safeguard. A never-onboarded instance with no config or +instance data skips the backup automatically because there is nothing to save. + Roll back to the previous retained payload: ```sh