mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
fix(cli): explain unavailable update backups
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7a10927ca3
commit
54f10dba44
3 files changed
+108
-3
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user