mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
feat(cli): add managed update and rollback
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
2229aac205
commit
7484df71f4
9 files changed
+263
-4
No files matched your search
@@ -0,0 +1,82 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { flipCurrentAtomic, initializeInstallStore, payloadPathFor, readInstallManifest, resolveInstallStorePaths, writeInstallManifestAtomic, type InstallManifest, type InstallRecord } from "../install-store.js";
|
||||
import { detectInstallMode, resolveUpdateRequest, rollbackManagedInstall, updateCommand } from "../commands/update.js";
|
||||
|
||||
let root: string;
|
||||
let previousHome: string | undefined;
|
||||
let previousPaperclipHome: string | undefined;
|
||||
|
||||
function record(payloadPath: string, version: string, channel: "latest" | "canary" | "pinned" = "latest"): InstallRecord {
|
||||
return { source: "npm", version, channel, payloadPath, installedAt: `2026-07-22T00:00:0${version}.000Z` };
|
||||
}
|
||||
function createPayload(payloadPath: string, version: string): string {
|
||||
const entrypoint = path.join(payloadPath, "node_modules", "paperclipai", "dist", "index.js");
|
||||
fs.mkdirSync(path.dirname(entrypoint), { recursive: true });
|
||||
fs.writeFileSync(entrypoint, version);
|
||||
return entrypoint;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-update-"));
|
||||
previousHome = process.env.HOME;
|
||||
previousPaperclipHome = process.env.PAPERCLIP_HOME;
|
||||
process.env.HOME = path.join(root, "home");
|
||||
process.env.PAPERCLIP_HOME = path.join(root, "paperclip");
|
||||
});
|
||||
afterEach(() => {
|
||||
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;
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
process.exitCode = undefined;
|
||||
});
|
||||
|
||||
describe("update command", () => {
|
||||
it("detects managed, global npm, npx, and source modes", () => {
|
||||
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
|
||||
const payload = payloadPathFor(paths, "npm", "1.0.0"); const entrypoint = createPayload(payload, "1.0.0");
|
||||
flipCurrentAtomic(payload, paths);
|
||||
writeInstallManifestAtomic({ schemaVersion: 1, ...record(payload, "1.0.0"), previous: [] }, paths);
|
||||
expect(detectInstallMode(entrypoint, paths)).toBe("managed");
|
||||
expect(detectInstallMode(path.join(root, "lib", "node_modules", "paperclipai", "dist", "index.js"), paths)).toBe("global-npm");
|
||||
expect(detectInstallMode(path.join(root, ".npm", "_npx", "abc", "node_modules", "paperclipai", "dist", "index.js"), paths)).toBe("npx");
|
||||
const source = path.join(root, "source"); fs.mkdirSync(path.join(source, ".git"), { recursive: true });
|
||||
expect(detectInstallMode(path.join(source, "cli", "src", "index.ts"), paths)).toBe("source");
|
||||
});
|
||||
|
||||
it("resolves channels and keeps pinned installs pinned by default", () => {
|
||||
const manifest = { channel: "pinned", version: "1.2.3" } as InstallManifest;
|
||||
expect(resolveUpdateRequest(manifest, {})).toEqual({ spec: "1.2.3", channel: "pinned", explicit: false });
|
||||
expect(resolveUpdateRequest(manifest, { latest: true })).toEqual({ spec: "latest", channel: "latest", explicit: true });
|
||||
expect(() => resolveUpdateRequest(manifest, { latest: true, canary: true })).toThrow("only one");
|
||||
});
|
||||
|
||||
it("requires explicit confirmation before downgrading", async () => {
|
||||
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
|
||||
const payload = payloadPathFor(paths, "npm", "2.0.0"); const entrypoint = createPayload(payload, "2.0.0"); flipCurrentAtomic(payload, paths);
|
||||
writeInstallManifestAtomic({ schemaVersion: 1, ...record(payload, "2.0.0"), previous: [] }, paths);
|
||||
const runCommand = vi.fn(async () => ({ stdout: '"1.0.0"\n', stderr: "" }));
|
||||
await expect(updateCommand({ version: "1.0.0", dryRun: true }, { paths, executablePath: entrypoint, runCommand, confirm: async () => false })).rejects.toThrow("Downgrade cancelled");
|
||||
});
|
||||
|
||||
it("backs up, installs side-by-side, flips, and rolls back instantly", async () => {
|
||||
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 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") { 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, now: () => new Date("2026-07-22T12:00:00Z") });
|
||||
expect(backup).toHaveBeenCalledOnce();
|
||||
expect(readInstallManifest(paths)?.version).toBe("2.0.0");
|
||||
expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(payloadPathFor(paths, "npm", "2.0.0")));
|
||||
const rolledBack = rollbackManagedInstall(paths);
|
||||
expect(rolledBack.version).toBe("1.0.0");
|
||||
expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(oldPayload));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { checkForUpdateNotice, isUpdateNoticeEnabled } from "../update-notice.js";
|
||||
let root: string; let previous: string | undefined;
|
||||
beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-notice-")); previous = process.env.PAPERCLIP_UPDATE_CHECK; delete process.env.PAPERCLIP_UPDATE_CHECK; });
|
||||
afterEach(() => { if (previous === undefined) delete process.env.PAPERCLIP_UPDATE_CHECK; else process.env.PAPERCLIP_UPDATE_CHECK = previous; fs.rmSync(root, { recursive: true, force: true }); });
|
||||
describe("update notice", () => {
|
||||
it("honors the environment and config kill switches", () => {
|
||||
process.env.PAPERCLIP_UPDATE_CHECK = "0"; expect(isUpdateNoticeEnabled(path.join(root, "missing.json"))).toBe(false);
|
||||
delete process.env.PAPERCLIP_UPDATE_CHECK; const config = path.join(root, "config.json"); fs.writeFileSync(config, JSON.stringify({ updates: { checkEnabled: false } })); expect(isUpdateNoticeEnabled(config)).toBe(false);
|
||||
});
|
||||
it("throttles registry checks for 24 hours", async () => {
|
||||
const cachePath = path.join(root, "cache.json"); const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ "dist-tags": { latest: "99.0.0" } }), { status: 200 }));
|
||||
expect(await checkForUpdateNotice({ cachePath, now: 1000, fetchImpl })).toBe("99.0.0");
|
||||
expect(await checkForUpdateNotice({ cachePath, now: 2000, fetchImpl })).toBe("99.0.0");
|
||||
expect(fetchImpl).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "../checks/index.js";
|
||||
import { loadPaperclipEnvFile } from "../config/env.js";
|
||||
import { printPaperclipCliBanner } from "../utils/banner.js";
|
||||
import { printUpdateNotice } from "../update-notice.js";
|
||||
|
||||
const STATUS_ICON = {
|
||||
pass: pc.green("✓"),
|
||||
@@ -31,6 +32,7 @@ export async function doctor(opts: {
|
||||
repair?: boolean;
|
||||
yes?: boolean;
|
||||
}): Promise<{ passed: number; warned: number; failed: number }> {
|
||||
await printUpdateNotice(opts.config);
|
||||
printPaperclipCliBanner();
|
||||
p.intro(pc.bgCyan(pc.black(" paperclip doctor ")));
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
||||
|
||||
export type InstallOptions = { canary?: boolean; version?: string; yes?: boolean };
|
||||
|
||||
type CommandRunner = (
|
||||
export type CommandRunner = (
|
||||
file: string,
|
||||
args: string[],
|
||||
options?: Parameters<typeof execFileAsync>[2],
|
||||
@@ -66,7 +66,7 @@ function parseResolvedVersion(stdout: string): string {
|
||||
throw new Error(`npm returned an unexpected version response: ${trimmed}`);
|
||||
}
|
||||
|
||||
async function resolvePublishedVersion(spec: string, runCommand: CommandRunner): Promise<string> {
|
||||
export async function resolvePublishedVersion(spec: string, runCommand: CommandRunner): Promise<string> {
|
||||
const result = await runCommand(
|
||||
"npm",
|
||||
["view", `paperclipai@${spec}`, "version", "--json", `--registry=${PUBLIC_NPM_REGISTRY}`],
|
||||
@@ -79,7 +79,7 @@ function payloadEntrypoint(payloadPath: string): string {
|
||||
return path.join(payloadPath, "node_modules", "paperclipai", "dist", "index.js");
|
||||
}
|
||||
|
||||
async function smokePayload(payloadPath: string, expectedVersion: string, runCommand: CommandRunner): Promise<void> {
|
||||
export async function smokePayload(payloadPath: string, expectedVersion: string, runCommand: CommandRunner): Promise<void> {
|
||||
const entrypoint = payloadEntrypoint(payloadPath);
|
||||
if (!fs.existsSync(entrypoint)) throw new Error(`Installed package is missing its CLI entrypoint: ${entrypoint}`);
|
||||
const result = await runCommand(process.execPath, [entrypoint, "--version"], { maxBuffer: 1024 * 1024 });
|
||||
@@ -89,7 +89,7 @@ async function smokePayload(payloadPath: string, expectedVersion: string, runCom
|
||||
}
|
||||
}
|
||||
|
||||
async function installNpmPayload(
|
||||
export async function installNpmPayload(
|
||||
version: string,
|
||||
runCommand: CommandRunner,
|
||||
paths = resolveInstallStorePaths(),
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
resolvePaperclipInstanceId,
|
||||
} from "../config/home.js";
|
||||
import { assertForegroundRunAllowed } from "../services/service-manager.js";
|
||||
import { printUpdateNotice } from "../update-notice.js";
|
||||
|
||||
interface RunOptions {
|
||||
config?: string;
|
||||
@@ -48,6 +49,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
|
||||
const configPath = resolveConfigPath(opts.config);
|
||||
process.env.PAPERCLIP_CONFIG = configPath;
|
||||
loadPaperclipEnvFile(configPath);
|
||||
await printUpdateNotice(configPath);
|
||||
|
||||
p.intro(pc.bgCyan(pc.black(" paperclipai run ")));
|
||||
p.log.message(pc.dim(`Home: ${paths.homeDir}`));
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import * as p from "@clack/prompts";
|
||||
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 { installNpmPayload, resolvePublishedVersion, type CommandRunner } from "./install.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 };
|
||||
|
||||
export function detectInstallMode(executablePath = process.argv[1] ?? "", paths = resolveInstallStorePaths()): InstallMode {
|
||||
const resolved = path.resolve(executablePath || ".");
|
||||
const manifest = readInstallManifest(paths);
|
||||
if (manifest && isManagedExecutable(resolved, manifest, paths)) return "managed";
|
||||
const normalized = resolved.split(path.sep).join("/");
|
||||
if (normalized.includes("/.npm/_npx/") || normalized.includes("/node_modules/.cache/npx/")) return "npx";
|
||||
if (normalized.includes("/node_modules/paperclipai/")) return "global-npm";
|
||||
let cursor = path.dirname(resolved);
|
||||
while (cursor !== path.dirname(cursor)) {
|
||||
if (fs.existsSync(path.join(cursor, ".git"))) return "source";
|
||||
cursor = path.dirname(cursor);
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function compareVersions(left: string, right: string): number {
|
||||
const parse = (value: string) => { const [core, prerelease = ""] = value.replace(/^v/, "").split("-", 2); return { numbers: core.split(".").map((part) => Number(part) || 0), prerelease }; };
|
||||
const a = parse(left); const b = parse(right);
|
||||
for (let index = 0; index < Math.max(a.numbers.length, b.numbers.length); index += 1) { const delta = (a.numbers[index] ?? 0) - (b.numbers[index] ?? 0); if (delta !== 0) return Math.sign(delta); }
|
||||
if (a.prerelease === b.prerelease) return 0;
|
||||
if (!a.prerelease) return 1;
|
||||
if (!b.prerelease) return -1;
|
||||
return a.prerelease.localeCompare(b.prerelease);
|
||||
}
|
||||
|
||||
export function resolveUpdateRequest(manifest: InstallManifest | null, options: Pick<UpdateOptions, "canary" | "latest" | "version">): { spec: string; channel: InstallChannel; explicit: boolean } {
|
||||
const selected = Number(Boolean(options.canary)) + Number(Boolean(options.latest)) + Number(Boolean(options.version));
|
||||
if (selected > 1) throw new Error("Choose only one of --latest, --canary, or --version.");
|
||||
if (options.version) return { spec: options.version.trim(), channel: "pinned", explicit: true };
|
||||
if (options.canary) return { spec: "canary", channel: "canary", explicit: true };
|
||||
if (options.latest) return { spec: "latest", channel: "latest", explicit: true };
|
||||
if (manifest?.channel === "pinned") return { spec: manifest.version, channel: "pinned", explicit: false };
|
||||
const channel = manifest?.channel === "canary" ? "canary" : "latest";
|
||||
return { spec: channel, channel, explicit: false };
|
||||
}
|
||||
|
||||
export function rollbackManagedInstall(paths = resolveInstallStorePaths()): InstallManifest {
|
||||
const manifest = readInstallManifest(paths);
|
||||
if (!manifest) throw new Error("No managed install was found to roll back.");
|
||||
const target = manifest.previous[0];
|
||||
if (!target) throw new Error("No previous managed payload is available for rollback.");
|
||||
if (!fs.existsSync(target.payloadPath)) throw new Error(`Previous payload is missing: ${target.payloadPath}`);
|
||||
const current: InstallRecord = { source: manifest.source, version: manifest.version, channel: manifest.channel, payloadPath: manifest.payloadPath, repo: manifest.repo, ref: manifest.ref, sha: manifest.sha, installedAt: manifest.installedAt };
|
||||
const next: InstallManifest = { schemaVersion: manifest.schemaVersion, ...target, previous: [current, ...manifest.previous.slice(1)].slice(0, 2) };
|
||||
const oldTarget = fs.readlinkSync(paths.currentPath);
|
||||
flipCurrentAtomic(target.payloadPath, paths);
|
||||
try { writeInstallManifestAtomic(next, paths); } catch (error) { flipCurrentAtomic(path.resolve(paths.cliRoot, oldTarget), paths); throw error; }
|
||||
return next;
|
||||
}
|
||||
|
||||
async function defaultConfirm(message: string): Promise<boolean> {
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
|
||||
const answer = await p.confirm({ message, initialValue: false });
|
||||
return !p.isCancel(answer) && answer === true;
|
||||
}
|
||||
function emit(options: UpdateOptions, value: Record<string, unknown>, message: string): void { if (options.json) console.log(JSON.stringify(value, null, 2)); else console.log(message); }
|
||||
|
||||
export async function updateCommand(options: UpdateOptions, overrides: Partial<Dependencies> = {}): Promise<void> {
|
||||
const paths = overrides.paths ?? resolveInstallStorePaths();
|
||||
const executablePath = overrides.executablePath ?? process.argv[1] ?? "";
|
||||
const runCommand = overrides.runCommand ?? execFileAsync;
|
||||
const mode = detectInstallMode(executablePath, paths);
|
||||
const manifest = readInstallManifest(paths);
|
||||
if (options.rollback) {
|
||||
if (mode !== "managed") throw new Error("--rollback is only available for managed installs.");
|
||||
if (options.dryRun) { emit(options, { mode, action: "rollback", dryRun: true, target: manifest?.previous[0]?.version ?? null }, `Would roll back to ${manifest?.previous[0]?.version ?? "the previous payload"}.`); return; }
|
||||
const next = await withInstallStoreLock(async () => rollbackManagedInstall(paths), paths);
|
||||
emit(options, { mode, action: "rollback", version: next.version }, pc.green(`Rolled back instantly to paperclipai ${next.version}. Database migrations are not reversed; restore the pre-update backup if needed.`));
|
||||
return;
|
||||
}
|
||||
const request = resolveUpdateRequest(manifest, options);
|
||||
if (mode === "npx") { emit(options, { mode, action: "install" }, "This is an ephemeral npx install. Run `paperclipai install`, then use `paperclipai update` from the managed shim."); return; }
|
||||
if (mode === "source" || mode === "unknown") { emit(options, { mode, action: "manual" }, "This appears to be a source checkout. Update it with `git pull` followed by `pnpm install`; Paperclip will not mutate the repository."); return; }
|
||||
if (mode === "managed" && manifest?.source === "git") { emit(options, { mode, source: "git", action: "manual", ref: manifest.ref ?? manifest.sha }, "Managed git payload updates require reinstalling the desired ref; the current payload was left unchanged."); return; }
|
||||
const targetVersion = await resolvePublishedVersion(request.spec, runCommand);
|
||||
const currentVersion = manifest?.version;
|
||||
const comparison = currentVersion ? compareVersions(targetVersion, currentVersion) : 1;
|
||||
if (options.check) { emit(options, { mode, currentVersion: currentVersion ?? null, targetVersion, updateAvailable: comparison > 0, downgrade: comparison < 0, channel: request.channel }, comparison > 0 ? `Update available: ${targetVersion}` : comparison < 0 ? `Target ${targetVersion} is older than ${currentVersion}.` : `paperclipai ${targetVersion} is current.`); if (comparison > 0) process.exitCode = 10; return; }
|
||||
if (mode === "global-npm") {
|
||||
const args = ["install", "-g", `paperclipai@${targetVersion}`]; console.log(`Running: npm ${args.join(" ")}`);
|
||||
if (!options.dryRun) await runCommand("npm", args, { maxBuffer: 16 * 1024 * 1024 });
|
||||
emit(options, { mode, action: "update", targetVersion, dryRun: Boolean(options.dryRun), command: ["npm", ...args] }, options.dryRun ? "Dry run complete." : pc.green(`Updated global npm install to ${targetVersion}.`)); return;
|
||||
}
|
||||
if (!manifest) throw new Error("Managed install metadata is missing.");
|
||||
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({})))();
|
||||
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() };
|
||||
const next = buildNextManifest(record, manifest); const oldTarget = fs.readlinkSync(paths.currentPath); flipCurrentAtomic(payload.payloadPath, paths);
|
||||
try { writeInstallManifestAtomic(next, paths); } catch (error) { flipCurrentAtomic(path.resolve(paths.cliRoot, oldTarget), paths); throw error; }
|
||||
pruneInstallPayloads(next, paths); return payload;
|
||||
}, paths);
|
||||
emit(options, { mode, currentVersion, targetVersion, changed: true, reused: installed.reused }, pc.green(`Updated paperclipai ${currentVersion} → ${targetVersion}. Run \`paperclipai update --rollback\` for an instant payload rollback.`));
|
||||
}
|
||||
@@ -45,6 +45,7 @@ import { registerSkillCommands } from "./commands/client/skill.js";
|
||||
import { cliVersion } from "./version.js";
|
||||
import { installCommand } from "./commands/install.js";
|
||||
import { uninstallCommand } from "./commands/uninstall.js";
|
||||
import { updateCommand } from "./commands/update.js";
|
||||
import { registerServiceCommands } from "./commands/service.js";
|
||||
|
||||
const program = new Command();
|
||||
@@ -71,6 +72,21 @@ program
|
||||
.description("Remove the managed CLI install while preserving user data")
|
||||
.action(uninstallCommand);
|
||||
|
||||
program
|
||||
.command("update")
|
||||
.alias("upgrade")
|
||||
.description("Check, update, or roll back the Paperclip CLI")
|
||||
.option("--latest", "Switch to the latest stable channel")
|
||||
.option("--canary", "Switch to the canary channel")
|
||||
.option("--version <version>", "Install an exact published version")
|
||||
.option("--rollback", "Flip back to the retained previous managed payload")
|
||||
.option("--check", "Check for an available update without applying it")
|
||||
.option("--dry-run", "Print the action without changing anything")
|
||||
.option("--json", "Print machine-readable output")
|
||||
.option("-y, --yes", "Confirm an explicit downgrade")
|
||||
.option("--no-backup", "Skip the pre-update database backup")
|
||||
.action(updateCommand);
|
||||
|
||||
program.hook("preAction", (_thisCommand, actionCommand) => {
|
||||
const options = actionCommand.optsWithGlobals() as DataDirOptionLike;
|
||||
const optionNames = new Set(actionCommand.options.map((option) => option.attributeName()));
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { packageVersion } from "./version.js";
|
||||
import { compareVersions } from "./commands/update.js";
|
||||
import { readInstallManifest, resolveInstallStorePaths } from "./install-store.js";
|
||||
import { resolveConfigPath } from "./config/store.js";
|
||||
const NOTICE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
export function isUpdateNoticeEnabled(configPath?: string): boolean {
|
||||
if (process.env.PAPERCLIP_UPDATE_CHECK === "0") return false;
|
||||
try { const raw = JSON.parse(fs.readFileSync(resolveConfigPath(configPath), "utf8")) as { updates?: { checkEnabled?: boolean } }; return raw.updates?.checkEnabled !== false; } catch { return true; }
|
||||
}
|
||||
export async function checkForUpdateNotice(options: { configPath?: string; now?: number; fetchImpl?: typeof fetch; cachePath?: string } = {}): Promise<string | null> {
|
||||
if (!isUpdateNoticeEnabled(options.configPath)) return null;
|
||||
const paths = resolveInstallStorePaths(); const cachePath = options.cachePath ?? path.join(paths.cliRoot, "update-check.json"); const now = options.now ?? Date.now();
|
||||
try { const cache = JSON.parse(fs.readFileSync(cachePath, "utf8")) as { checkedAt?: number; latest?: string }; if (cache.checkedAt && now - cache.checkedAt < NOTICE_INTERVAL_MS) return cache.latest && compareVersions(cache.latest, packageVersion) > 0 ? cache.latest : null; } catch {}
|
||||
const tag = readInstallManifest(paths)?.channel === "canary" ? "canary" : "latest";
|
||||
try { const response = await (options.fetchImpl ?? fetch)("https://registry.npmjs.org/paperclipai", { signal: AbortSignal.timeout(2500) }); if (!response.ok) return null; const body = await response.json() as { ["dist-tags"]?: Record<string, string> }; const latest = body["dist-tags"]?.[tag]; fs.mkdirSync(path.dirname(cachePath), { recursive: true, mode: 0o700 }); fs.writeFileSync(cachePath, JSON.stringify({ checkedAt: now, latest: latest ?? null }) + "\n", { mode: 0o600 }); return latest && compareVersions(latest, packageVersion) > 0 ? latest : null; } catch { return null; }
|
||||
}
|
||||
export async function printUpdateNotice(configPath?: string): Promise<void> { const latest = await checkForUpdateNotice({ configPath }); if (latest) console.log(`Update available: ${latest} — run \`paperclipai update\``); }
|
||||
@@ -103,6 +103,10 @@ export const telemetryConfigSchema = z.object({
|
||||
enabled: z.boolean().default(true),
|
||||
}).default({});
|
||||
|
||||
export const updatesConfigSchema = z.object({
|
||||
checkEnabled: z.boolean().default(true),
|
||||
}).default({});
|
||||
|
||||
export const paperclipConfigSchema = z
|
||||
.object({
|
||||
$meta: configMetaSchema,
|
||||
@@ -111,6 +115,7 @@ export const paperclipConfigSchema = z
|
||||
logging: loggingConfigSchema,
|
||||
server: serverConfigSchema,
|
||||
telemetry: telemetryConfigSchema,
|
||||
updates: updatesConfigSchema.optional(),
|
||||
auth: authConfigSchema.default({
|
||||
baseUrlMode: "auto",
|
||||
disableSignUp: false,
|
||||
@@ -195,5 +200,6 @@ export type SecretsConfig = z.infer<typeof secretsConfigSchema>;
|
||||
export type SecretsLocalEncryptedConfig = z.infer<typeof secretsLocalEncryptedConfigSchema>;
|
||||
export type AuthConfig = z.infer<typeof authConfigSchema>;
|
||||
export type TelemetryConfig = z.infer<typeof telemetryConfigSchema>;
|
||||
export type UpdatesConfig = z.infer<typeof updatesConfigSchema>;
|
||||
export type ConfigMeta = z.infer<typeof configMetaSchema>;
|
||||
export type DatabaseBackupConfig = z.infer<typeof databaseBackupConfigSchema>;
|
||||
Reference in new issue
Block a user