From 7484df71f4002b5ec562278fa6bdf468fcc1d832 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 22 Jul 2026 19:26:16 +0000 Subject: [PATCH] feat(cli): add managed update and rollback Co-Authored-By: Paperclip --- cli/src/__tests__/update-command.test.ts | 82 +++++++++++++++++ cli/src/__tests__/update-notice.test.ts | 20 ++++ cli/src/commands/doctor.ts | 2 + cli/src/commands/install.ts | 8 +- cli/src/commands/run.ts | 2 + cli/src/commands/update.ts | 112 +++++++++++++++++++++++ cli/src/index.ts | 16 ++++ cli/src/update-notice.ts | 19 ++++ packages/shared/src/config-schema.ts | 6 ++ 9 files changed, 263 insertions(+), 4 deletions(-) create mode 100644 cli/src/__tests__/update-command.test.ts create mode 100644 cli/src/__tests__/update-notice.test.ts create mode 100644 cli/src/commands/update.ts create mode 100644 cli/src/update-notice.ts diff --git a/cli/src/__tests__/update-command.test.ts b/cli/src/__tests__/update-command.test.ts new file mode 100644 index 00000000000..f666190011b --- /dev/null +++ b/cli/src/__tests__/update-command.test.ts @@ -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)); + }); +}); diff --git a/cli/src/__tests__/update-notice.test.ts b/cli/src/__tests__/update-notice.test.ts new file mode 100644 index 00000000000..9af23edcd92 --- /dev/null +++ b/cli/src/__tests__/update-notice.test.ts @@ -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(); + }); +}); diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index 212301f23b5..28ce37e3c6a 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -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 "))); diff --git a/cli/src/commands/install.ts b/cli/src/commands/install.ts index 9ca839befb4..7b4ff747c91 100644 --- a/cli/src/commands/install.ts +++ b/cli/src/commands/install.ts @@ -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[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 { +export async function resolvePublishedVersion(spec: string, runCommand: CommandRunner): Promise { 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 { +export async function smokePayload(payloadPath: string, expectedVersion: string, runCommand: CommandRunner): Promise { 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(), diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index ddc9b81d65d..41b02491b68 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -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 { 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}`)); diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts new file mode 100644 index 00000000000..c6268d78952 --- /dev/null +++ b/cli/src/commands/update.ts @@ -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; confirm: (message: string) => Promise; 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): { 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 { + 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, 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 = {}): Promise { + 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.`)); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index ba0dd47c2bb..fdafa70895e 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -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 ", "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())); diff --git a/cli/src/update-notice.ts b/cli/src/update-notice.ts new file mode 100644 index 00000000000..01b90f4f7bb --- /dev/null +++ b/cli/src/update-notice.ts @@ -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 { + 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 }; 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 { const latest = await checkForUpdateNotice({ configPath }); if (latest) console.log(`Update available: ${latest} — run \`paperclipai update\``); } diff --git a/packages/shared/src/config-schema.ts b/packages/shared/src/config-schema.ts index efa1bdee1d8..229abd1a02d 100644 --- a/packages/shared/src/config-schema.ts +++ b/packages/shared/src/config-schema.ts @@ -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; export type SecretsLocalEncryptedConfig = z.infer; export type AuthConfig = z.infer; export type TelemetryConfig = z.infer; +export type UpdatesConfig = z.infer; export type ConfigMeta = z.infer; export type DatabaseBackupConfig = z.infer;