fix(cli): explain unavailable update backups

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-31 18:52:28 +00:00
1 parent 7a10927ca3
commit 54f10dba44
3 files changed
+108 -3

No files matched your search

+50
View File
@@ -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");
+52 -3
View File
@@ -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<void>; confirm: (message: string) => Promise<boolean>; now: () => Date; paths: InstallStorePaths; restartActiveService: (expectedVersion: string) => Promise<boolean> };
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<unknown>();
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<string, unknown>;
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<void>): Promise<void> {
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<boolean> {
const instanceId = resolvePaperclipInstanceId();
const detection = await detectServiceManager({ instanceId });
@@ -142,7 +191,7 @@ export async function updateCommand(options: UpdateOptions, overrides: Partial<D
const confirmed = await (overrides.confirm ?? defaultConfirm)(`Update from ${manifest.repo}@${manifest.ref} and execute build scripts from commit ${targetSha.slice(0, 12)}?`);
if (!confirmed) throw new Error("Git update cancelled. Re-run with --yes to confirm executing build scripts from the updated commit.");
}
if (options.backup !== false) await (overrides.backup ?? (() => 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<D
if (comparison === 0) { emit(options, { mode, currentVersion, targetVersion, changed: false }, `paperclipai ${targetVersion} is already active.`); return; }
if (comparison < 0 && options.yes !== true) { const confirmed = await (overrides.confirm ?? defaultConfirm)(`Downgrade paperclipai from ${currentVersion} to ${targetVersion}?`); if (!confirmed) throw new Error("Downgrade cancelled. Re-run with --yes to confirm explicitly."); }
if (options.dryRun) { emit(options, { mode, currentVersion, targetVersion, action: comparison < 0 ? "downgrade" : "update", backup: options.backup !== false, dryRun: true }, `Would ${comparison < 0 ? "downgrade" : "update"} paperclipai ${currentVersion} → ${targetVersion}${options.backup === false ? " without a backup" : " after a database backup"}.`); return; }
if (options.backup !== false) await (overrides.backup ?? (() => 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() };
+6
View File
@@ -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