test(cli): isolate update backup state

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-31 19:34:20 +00:00
1 parent ba65687bc1
commit 3fb1ce83b5
2 files changed
+9 -25

No files matched your search

+4 -20
View File
@@ -19,10 +19,6 @@ 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-"));
previousHome = process.env.HOME;
@@ -66,7 +62,6 @@ 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));
@@ -81,7 +76,7 @@ describe("update command", () => {
const confirm = vi.fn(async () => true);
const restartActiveService = vi.fn(async () => true);
const runCommand = vi.fn(async (file: string) => file === "curl" ? { stdout: JSON.stringify({ sha: newSha }), stderr: "" } : { stdout: "0.3.1\n", stderr: "" });
await updateCommand({}, { paths, executablePath: executable, runCommand, backup, confirm, restartActiveService, now: () => new Date("2026-07-22T12:00:00Z") });
await updateCommand({}, { paths, executablePath: executable, runCommand, backup, confirm, restartActiveService, hasInstanceData: () => true, now: () => new Date("2026-07-22T12:00:00Z") });
expect(confirm).toHaveBeenCalledWith(expect.stringContaining(`commit ${newSha.slice(0, 12)}`));
expect(backup).toHaveBeenCalledOnce();
expect(restartActiveService).toHaveBeenCalledWith("0.3.1");
@@ -136,7 +131,6 @@ 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);
@@ -147,7 +141,7 @@ describe("update command", () => {
if (file === "npm" && args[0] === "install") { const prefix = args[args.indexOf("--prefix") + 1]; createPayload(prefix, "2.0.0"); return { stdout: "", stderr: "" }; }
return { stdout: "2.0.0\n", stderr: "" };
});
await updateCommand({}, { paths, executablePath: executable, runCommand, backup, restartActiveService, now: () => new Date("2026-07-22T12:00:00Z") });
await updateCommand({}, { paths, executablePath: executable, runCommand, backup, restartActiveService, hasInstanceData: () => true, now: () => new Date("2026-07-22T12:00:00Z") });
expect(backup).toHaveBeenCalledOnce();
expect(restartActiveService).toHaveBeenCalledWith("2.0.0");
expect(readInstallManifest(paths)?.version).toBe("2.0.0");
@@ -158,7 +152,6 @@ describe("update command", () => {
});
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);
@@ -166,7 +159,7 @@ describe("update command", () => {
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(
await expect(updateCommand({}, { paths, executablePath: executable, runCommand, backup, hasInstanceData: () => true })).rejects.toThrow(
"Start the service with `paperclipai service start` and retry, or skip the backup with `paperclipai update --no-backup`.",
);
expect(backup).toHaveBeenCalledOnce();
@@ -174,9 +167,6 @@ describe("update command", () => {
});
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);
@@ -188,13 +178,7 @@ describe("update command", () => {
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);
}
await updateCommand({}, { paths, executablePath: executable, runCommand, backup, restartActiveService: async () => false, hasInstanceData: () => false });
expect(backup).not.toHaveBeenCalled();
expect(log).toHaveBeenCalledWith(expect.stringContaining("has not been onboarded and has no data to back up"));
+5 -5
View File
@@ -17,7 +17,7 @@ import { packageVersion } from "../version.js";
const execFileAsync = promisify(execFile);
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> };
type Dependencies = { executablePath: string; runCommand: CommandRunner; backup: () => Promise<void>; confirm: (message: string) => Promise<boolean>; now: () => Date; paths: InstallStorePaths; restartActiveService: (expectedVersion: string) => Promise<boolean>; hasInstanceData: () => boolean };
const DATABASE_UNREACHABLE_CODES = new Set(["ECONNREFUSED", "EHOSTUNREACH", "ENETUNREACH", "ETIMEDOUT"]);
@@ -48,8 +48,8 @@ function isDatabaseUnreachableError(error: unknown): boolean {
return false;
}
async function runPreUpdateBackup(options: UpdateOptions, backup: () => Promise<void>): Promise<void> {
if (!hasPaperclipInstanceData()) {
async function runPreUpdateBackup(options: UpdateOptions, backup: () => Promise<void>, hasInstanceData = hasPaperclipInstanceData): Promise<void> {
if (!hasInstanceData()) {
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;
@@ -191,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 runPreUpdateBackup(options, overrides.backup ?? (() => dbBackupCommand({})));
if (options.backup !== false) await runPreUpdateBackup(options, overrides.backup ?? (() => dbBackupCommand({})), overrides.hasInstanceData);
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() };
@@ -245,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 runPreUpdateBackup(options, overrides.backup ?? (() => dbBackupCommand({})));
if (options.backup !== false) await runPreUpdateBackup(options, overrides.backup ?? (() => dbBackupCommand({})), overrides.hasInstanceData);
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() };