From 4a43745b016773d69316ec941dcd72c7644be669 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 22 Jul 2026 18:28:48 +0000 Subject: [PATCH] feat(cli): add managed npm install store Co-Authored-By: Paperclip --- cli/src/__tests__/install-command.test.ts | 88 +++++++ cli/src/__tests__/install-store.test.ts | 129 ++++++++++ cli/src/commands/install.ts | 215 ++++++++++++++++ cli/src/commands/uninstall.ts | 25 ++ cli/src/index.ts | 17 ++ cli/src/install-store.ts | 293 ++++++++++++++++++++++ cli/src/version.ts | 24 +- scripts/clean-install-npm.sh | 29 +++ 8 files changed, 819 insertions(+), 1 deletion(-) create mode 100644 cli/src/__tests__/install-command.test.ts create mode 100644 cli/src/__tests__/install-store.test.ts create mode 100644 cli/src/commands/install.ts create mode 100644 cli/src/commands/uninstall.ts create mode 100644 cli/src/install-store.ts create mode 100755 scripts/clean-install-npm.sh diff --git a/cli/src/__tests__/install-command.test.ts b/cli/src/__tests__/install-command.test.ts new file mode 100644 index 00000000000..4a1d932e34c --- /dev/null +++ b/cli/src/__tests__/install-command.test.ts @@ -0,0 +1,88 @@ +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 { installCommand, resolveNpmInstallRequest } from "../commands/install.js"; +import { uninstallCommand } from "../commands/uninstall.js"; +import { readInstallManifest, resolveInstallStorePaths } from "../install-store.js"; +import { resolveCliVersion } from "../version.js"; + +const ORIGINAL_ENV = { ...process.env }; + +describe("managed install commands", () => { + let root: string; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-install-command-")); + process.env = { + ...ORIGINAL_ENV, + HOME: path.join(root, "home"), + PAPERCLIP_HOME: path.join(root, "home", ".paperclip"), + PATH: "/usr/bin:/bin", + SHELL: "/bin/bash", + }; + fs.mkdirSync(process.env.HOME!, { recursive: true }); + vi.spyOn(console, "log").mockImplementation(() => undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + process.env = { ...ORIGINAL_ENV }; + fs.rmSync(root, { recursive: true, force: true }); + }); + + it("selects stable, canary, and exact-version npm sources", () => { + expect(resolveNpmInstallRequest({})).toEqual({ spec: "latest", channel: "latest" }); + expect(resolveNpmInstallRequest({ canary: true })).toEqual({ spec: "canary", channel: "canary" }); + expect(resolveNpmInstallRequest({ version: "2026.720.0" })).toEqual({ + spec: "2026.720.0", + channel: "pinned", + }); + expect(() => resolveNpmInstallRequest({ canary: true, version: "1.2.3" })).toThrow(); + expect(() => resolveNpmInstallRequest({ version: "latest" })).toThrow(); + }); + + it("installs through the shim, reports provenance, and uninstalls without deleting user data", async () => { + const version = "2026.720.0"; + const runCommand = vi.fn(async (file: string, args: string[], _options?: unknown) => { + if (file === "npm" && args[0] === "view") return { stdout: JSON.stringify(version), stderr: "" }; + if (file === "npm" && args[0] === "install") { + const prefix = args[args.indexOf("--prefix") + 1]; + const entrypoint = path.join(prefix, "node_modules", "paperclipai", "dist", "index.js"); + fs.mkdirSync(path.dirname(entrypoint), { recursive: true }); + fs.writeFileSync(entrypoint, "#!/usr/bin/env node\n"); + return { stdout: "", stderr: "" }; + } + if (file === process.execPath && args.at(-1) === "--version") { + return { stdout: `${version}\n`, stderr: "" }; + } + throw new Error(`Unexpected command: ${file} ${args.join(" ")}`); + }); + + await installCommand({}, { runCommand, now: () => new Date("2026-07-22T18:00:00.000Z") }); + + const paths = resolveInstallStorePaths(); + const manifest = readInstallManifest(paths); + expect(manifest?.version).toBe(version); + expect(manifest?.channel).toBe("latest"); + expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(manifest!.payloadPath)); + expect(fs.existsSync(paths.shimPath)).toBe(true); + const installCall = runCommand.mock.calls.find( + ([file, args]) => file === "npm" && args[0] === "install", + ); + expect(installCall?.[1]).toContain("--@paperclipai:registry=https://registry.npmjs.org"); + const installOptions = installCall?.[2] as { env?: NodeJS.ProcessEnv } | undefined; + expect(installOptions?.env?.npm_config_userconfig).toContain(".npmrc-"); + const entrypoint = path.join(manifest!.payloadPath, "node_modules", "paperclipai", "dist", "index.js"); + expect(resolveCliVersion(entrypoint)).toContain(`managed npm latest; payload ${manifest!.payloadPath}`); + + const userData = path.join(process.env.PAPERCLIP_HOME!, "instances", "default", "keep.txt"); + fs.mkdirSync(path.dirname(userData), { recursive: true }); + fs.writeFileSync(userData, "keep"); + await uninstallCommand(); + + expect(fs.existsSync(paths.cliRoot)).toBe(false); + expect(fs.existsSync(paths.shimPath)).toBe(false); + expect(fs.readFileSync(userData, "utf8")).toBe("keep"); + }); +}); diff --git a/cli/src/__tests__/install-store.test.ts b/cli/src/__tests__/install-store.test.ts new file mode 100644 index 00000000000..6110e8f5651 --- /dev/null +++ b/cli/src/__tests__/install-store.test.ts @@ -0,0 +1,129 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + INSTALL_MANIFEST_VERSION, + addManagedPathBlock, + buildNextManifest, + flipCurrentAtomic, + payloadPathFor, + pruneInstallPayloads, + readInstallManifest, + removeManagedPathBlock, + resolveInstallStorePaths, + writeInstallManifestAtomic, + writeManagedShim, + type InstallManifest, + type InstallRecord, +} from "../install-store.js"; + +function record(payloadPath: string, version: string): InstallRecord { + return { + source: "npm", + version, + channel: "latest", + payloadPath, + installedAt: `2026-07-${version.padStart(2, "0")}T00:00:00.000Z`, + }; +} + +describe("managed install store", () => { + let root: string; + let paths: ReturnType; + + beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-install-store-")); + paths = resolveInstallStorePaths({ + homeDir: path.join(root, "home"), + paperclipHome: path.join(root, "home", ".paperclip"), + }); + }); + + afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); + }); + + it("resolves the documented npm and git payload layout", () => { + expect(payloadPathFor(paths, "npm", "2026.720.0")).toBe( + path.join(paths.cliRoot, "installs", "npm", "2026.720.0"), + ); + expect(payloadPathFor(paths, "git", "ab12cd34ef56")).toBe( + path.join(paths.cliRoot, "installs", "git", "ab12cd34ef56"), + ); + }); + + it("writes and reads the manifest atomically with private permissions", () => { + const payloadPath = payloadPathFor(paths, "npm", "1.2.3"); + const manifest: InstallManifest = { + schemaVersion: INSTALL_MANIFEST_VERSION, + ...record(payloadPath, "1.2.3"), + previous: [], + }; + writeInstallManifestAtomic(manifest, paths); + expect(readInstallManifest(paths)).toEqual(manifest); + expect(fs.statSync(paths.manifestPath).mode & 0o777).toBe(0o600); + }); + + it("leaves the old current payload working when interrupted before rename", () => { + const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); + const newPayload = payloadPathFor(paths, "npm", "2.0.0"); + fs.mkdirSync(oldPayload, { recursive: true }); + fs.mkdirSync(newPayload, { recursive: true }); + flipCurrentAtomic(oldPayload, paths); + + expect(() => + flipCurrentAtomic(newPayload, paths, { + beforeRename: () => { + throw new Error("simulated crash"); + }, + }), + ).toThrow("simulated crash"); + + expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(oldPayload)); + expect(fs.readdirSync(paths.cliRoot).filter((entry) => entry.startsWith(".current-"))).toEqual([]); + }); + + it("retains current plus two previous payloads and prunes older entries", () => { + const payloads = ["1", "2", "3", "4"].map((version) => payloadPathFor(paths, "npm", version)); + for (const payload of payloads) fs.mkdirSync(payload, { recursive: true }); + const previousManifest: InstallManifest = { + schemaVersion: INSTALL_MANIFEST_VERSION, + ...record(payloads[2], "3"), + previous: [record(payloads[1], "2"), record(payloads[0], "1")], + }; + const next = buildNextManifest(record(payloads[3], "4"), previousManifest); + + expect(next.previous.map((entry) => entry.version)).toEqual(["3", "2"]); + expect(pruneInstallPayloads(next, paths)).toEqual([payloads[0]]); + expect(fs.existsSync(payloads[0])).toBe(false); + expect(payloads.slice(1).every((payload) => fs.existsSync(payload))).toBe(true); + }); + + it("writes a stable shim and manages an idempotent shell PATH block", () => { + writeManagedShim(paths); + const shim = fs.readFileSync(paths.shimPath, "utf8"); + expect(shim).toContain("$PAPERCLIP_HOME/cli/current/node_modules/paperclipai/dist/index.js"); + expect(fs.statSync(paths.shimPath).mode & 0o777).toBe(0o755); + + const rcPath = path.join(root, "home", ".bashrc"); + expect(addManagedPathBlock(rcPath)).toBe(true); + expect(addManagedPathBlock(rcPath)).toBe(false); + expect(removeManagedPathBlock(rcPath)).toBe(true); + expect(fs.readFileSync(rcPath, "utf8")).not.toContain("paperclipai managed PATH"); + }); + + it("refuses symlinked payload roots and pre-existing non-managed shims", () => { + const outside = path.join(root, "outside"); + fs.mkdirSync(outside, { recursive: true }); + fs.mkdirSync(paths.installsRoot, { recursive: true }); + fs.symlinkSync(outside, path.join(paths.installsRoot, "npm"), "dir"); + const escapedPayload = path.join(paths.installsRoot, "npm", "1.2.3"); + fs.mkdirSync(path.join(outside, "1.2.3")); + expect(() => flipCurrentAtomic(escapedPayload, paths)).toThrow("resolves outside"); + + fs.mkdirSync(path.dirname(paths.shimPath), { recursive: true }); + fs.writeFileSync(paths.shimPath, "#!/bin/sh\necho other-command\n"); + expect(() => writeManagedShim(paths)).toThrow("non-managed command"); + }); +}); diff --git a/cli/src/commands/install.ts b/cli/src/commands/install.ts new file mode 100644 index 00000000000..36975f08ab5 --- /dev/null +++ b/cli/src/commands/install.ts @@ -0,0 +1,215 @@ +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 { + addManagedPathBlock, + assertManagedShimWritable, + buildNextManifest, + flipCurrentAtomic, + payloadPathFor, + pruneInstallPayloads, + readInstallManifest, + resolveInstallStorePaths, + writeInstallManifestAtomic, + writeManagedShim, + type InstallChannel, + type InstallRecord, +} from "../install-store.js"; + +const execFileAsync = promisify(execFile); +const PUBLIC_NPM_REGISTRY = "https://registry.npmjs.org"; +const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; + +export type InstallOptions = { canary?: boolean; version?: string; yes?: boolean }; + +type CommandRunner = ( + file: string, + args: string[], + options?: Parameters[2], +) => Promise<{ stdout: string; stderr: string }>; + +function assertSupportedNodeVersion(): void { + const major = Number(process.versions.node.split(".")[0]); + if (!Number.isFinite(major) || major < 20) { + throw new Error(`Managed installs require Node.js 20 or newer (found ${process.version}).`); + } +} + +export function resolveNpmInstallRequest(options: InstallOptions): { + spec: string; + channel: InstallChannel; +} { + if (options.canary && options.version) throw new Error("Choose either --canary or --version, not both."); + if (options.version) { + const version = options.version.trim(); + if (!EXACT_VERSION_PATTERN.test(version)) { + throw new Error(`--version requires an exact published version, received '${options.version}'.`); + } + return { spec: version, channel: "pinned" }; + } + return options.canary ? { spec: "canary", channel: "canary" } : { spec: "latest", channel: "latest" }; +} + +function parseResolvedVersion(stdout: string): string { + const trimmed = stdout.trim(); + if (!trimmed) throw new Error("npm returned an empty version response."); + try { + const parsed = JSON.parse(trimmed) as unknown; + if (typeof parsed === "string") return parsed; + } catch { + if (EXACT_VERSION_PATTERN.test(trimmed)) return trimmed; + } + throw new Error(`npm returned an unexpected version response: ${trimmed}`); +} + +async function resolvePublishedVersion(spec: string, runCommand: CommandRunner): Promise { + const result = await runCommand( + "npm", + ["view", `paperclipai@${spec}`, "version", "--json", `--registry=${PUBLIC_NPM_REGISTRY}`], + { maxBuffer: 1024 * 1024 }, + ); + return parseResolvedVersion(result.stdout); +} + +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 { + 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 }); + const reportedVersion = result.stdout.trim().split(/\s+/)[0]; + if (reportedVersion !== expectedVersion) { + throw new Error(`Installed CLI smoke check reported ${reportedVersion || "no version"}; expected ${expectedVersion}.`); + } +} + +async function installNpmPayload(version: string, runCommand: CommandRunner): Promise<{ payloadPath: string; reused: boolean }> { + const paths = resolveInstallStorePaths(); + const payloadPath = payloadPathFor(paths, "npm", version); + if (fs.existsSync(payloadPath)) { + await smokePayload(payloadPath, version, runCommand); + return { payloadPath, reused: true }; + } + const sourceRoot = path.dirname(payloadPath); + fs.mkdirSync(sourceRoot, { recursive: true, mode: 0o700 }); + const sourceStat = fs.lstatSync(sourceRoot); + if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) { + throw new Error(`Refusing to install into unsafe payload root ${sourceRoot}.`); + } + fs.chmodSync(paths.cliRoot, 0o700); + fs.chmodSync(paths.installsRoot, 0o700); + fs.chmodSync(sourceRoot, 0o700); + const stagingPath = path.join(sourceRoot, `.${version}.tmp-${process.pid}-${Date.now()}`); + const npmUserConfigPath = path.join(sourceRoot, `.npmrc-${process.pid}-${Date.now()}`); + fs.rmSync(stagingPath, { recursive: true, force: true }); + try { + fs.writeFileSync( + npmUserConfigPath, + `registry=${PUBLIC_NPM_REGISTRY}\n@paperclipai:registry=${PUBLIC_NPM_REGISTRY}\n`, + { mode: 0o600 }, + ); + await runCommand( + "npm", + [ + "install", + "--prefix", + stagingPath, + `paperclipai@${version}`, + `--registry=${PUBLIC_NPM_REGISTRY}`, + `--@paperclipai:registry=${PUBLIC_NPM_REGISTRY}`, + "--no-audit", + "--no-fund", + ], + { + cwd: sourceRoot, + env: { ...process.env, npm_config_userconfig: npmUserConfigPath }, + maxBuffer: 16 * 1024 * 1024, + }, + ); + await smokePayload(stagingPath, version, runCommand); + fs.renameSync(stagingPath, payloadPath); + return { payloadPath, reused: false }; + } finally { + fs.rmSync(stagingPath, { recursive: true, force: true }); + fs.rmSync(npmUserConfigPath, { force: true }); + } +} + +function pathContains(directory: string): boolean { + const normalized = path.resolve(directory); + return (process.env.PATH ?? "").split(path.delimiter).filter(Boolean).some((entry) => path.resolve(entry) === normalized); +} + +function shellRcPath(): string | null { + const home = process.env.HOME; + if (!home) return null; + const shell = path.basename(process.env.SHELL ?? ""); + if (shell === "bash") return path.join(home, ".bashrc"); + if (shell === "zsh") return path.join(home, ".zshrc"); + return null; +} + +async function ensureShimOnPath(options: InstallOptions): Promise { + const paths = resolveInstallStorePaths(); + const binDir = path.dirname(paths.shimPath); + if (pathContains(binDir)) return; + const manualInstruction = `export PATH="$HOME/.local/bin:$PATH"`; + const rcPath = shellRcPath(); + if (!process.stdin.isTTY || !process.stdout.isTTY || !rcPath) { + console.log(pc.yellow(`Add Paperclip to PATH for this shell:\n ${manualInstruction}`)); + return; + } + const confirmed = options.yes === true ? true : await p.confirm({ message: `Add ~/.local/bin to PATH in ${rcPath}?`, initialValue: true }); + if (p.isCancel(confirmed) || !confirmed) { + console.log(pc.yellow(`PATH was not changed. Run:\n ${manualInstruction}`)); + return; + } + const changed = addManagedPathBlock(rcPath); + console.log(changed ? pc.green(`Updated ${rcPath}.`) : pc.dim(`${rcPath} already contains the PATH block.`)); +} + +export async function installCommand( + options: InstallOptions, + dependencies: { runCommand?: CommandRunner; now?: () => Date } = {}, +): Promise { + assertSupportedNodeVersion(); + const runCommand = dependencies.runCommand ?? execFileAsync; + const request = resolveNpmInstallRequest(options); + console.log(`Resolving paperclipai@${request.spec} from ${PUBLIC_NPM_REGISTRY}...`); + const version = await resolvePublishedVersion(request.spec, runCommand); + console.log(`Installing paperclipai@${version}...`); + + const paths = resolveInstallStorePaths(); + assertManagedShimWritable(paths); + const currentManifest = readInstallManifest(paths); + const installed = await installNpmPayload(version, runCommand); + const record: InstallRecord = { + source: "npm", + version, + channel: request.channel, + payloadPath: installed.payloadPath, + installedAt: (dependencies.now?.() ?? new Date()).toISOString(), + }; + const nextManifest = buildNextManifest(record, currentManifest); + const oldTarget = fs.existsSync(paths.currentPath) ? fs.readlinkSync(paths.currentPath) : null; + flipCurrentAtomic(installed.payloadPath, paths); + try { + writeInstallManifestAtomic(nextManifest, paths); + } catch (error) { + if (oldTarget) flipCurrentAtomic(path.resolve(paths.cliRoot, oldTarget), paths); + else fs.rmSync(paths.currentPath, { force: true }); + throw error; + } + writeManagedShim(paths); + pruneInstallPayloads(nextManifest, paths); + await ensureShimOnPath(options); + + console.log(pc.green(`${installed.reused ? "Activated cached" : "Installed"} paperclipai ${version} (${request.channel}).`)); + console.log(pc.dim(`Payload: ${installed.payloadPath}`)); + console.log(`Run ${pc.cyan("paperclipai --version")} to verify the managed install.`); +} diff --git a/cli/src/commands/uninstall.ts b/cli/src/commands/uninstall.ts new file mode 100644 index 00000000000..4677a363c21 --- /dev/null +++ b/cli/src/commands/uninstall.ts @@ -0,0 +1,25 @@ +import fs from "node:fs"; +import path from "node:path"; +import pc from "picocolors"; +import { + removeManagedPathBlock, + removeManagedShim, + resolveInstallStorePaths, +} from "../install-store.js"; + +export async function uninstallCommand(): Promise { + const paths = resolveInstallStorePaths(); + const shimRemoved = removeManagedShim(paths); + fs.rmSync(paths.cliRoot, { recursive: true, force: true }); + + const home = process.env.HOME; + for (const rcFile of home ? [path.join(home, ".bashrc"), path.join(home, ".zshrc")] : []) { + removeManagedPathBlock(rcFile); + } + + if (!shimRemoved) { + console.log(pc.yellow(`Left ${paths.shimPath} unchanged because it is not a Paperclip-managed shim.`)); + } + console.log(pc.green("Removed the managed Paperclip CLI install.")); + console.log(pc.dim(`User data was left untouched under ${paths.paperclipHome}.`)); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index 2adb131a2b0..ea06b2c353b 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -43,16 +43,33 @@ import { registerAdapterCommands } from "./commands/client/adapter.js"; import { registerAssetCommands } from "./commands/client/asset.js"; import { registerSkillCommands } from "./commands/client/skill.js"; import { cliVersion } from "./version.js"; +import { installCommand } from "./commands/install.js"; +import { uninstallCommand } from "./commands/uninstall.js"; const program = new Command(); const DATA_DIR_OPTION_HELP = "Paperclip data directory root (isolates state from ~/.paperclip)"; +program.enablePositionalOptions(); + program .name("paperclipai") .description("Paperclip CLI — setup, diagnose, and configure your instance") .version(cliVersion); +program + .command("install") + .description("Install Paperclip into a managed per-user CLI store") + .option("--canary", "Install the npm canary channel") + .option("--version ", "Install an exact published npm version") + .option("-y, --yes", "Consent to the supported shell PATH update without prompting") + .action(installCommand); + +program + .command("uninstall") + .description("Remove the managed CLI install while preserving user data") + .action(uninstallCommand); + 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/install-store.ts b/cli/src/install-store.ts new file mode 100644 index 00000000000..b11cac0e731 --- /dev/null +++ b/cli/src/install-store.ts @@ -0,0 +1,293 @@ +import fs from "node:fs"; +import path from "node:path"; +import { resolvePaperclipHomeDir } from "./config/home.js"; + +export const INSTALL_MANIFEST_VERSION = 1; +export const MANAGED_SHIM_MARKER = "paperclipai managed install shim"; +export const PATH_BLOCK_START = "# >>> paperclipai managed PATH >>>"; +export const PATH_BLOCK_END = "# <<< paperclipai managed PATH <<<"; + +export type InstallSource = "npm" | "git"; +export type InstallChannel = "latest" | "canary" | "pinned"; + +export type InstallRecord = { + source: InstallSource; + version: string; + channel: InstallChannel; + payloadPath: string; + repo?: string; + ref?: string; + sha?: string; + installedAt: string; +}; + +export type InstallManifest = InstallRecord & { + schemaVersion: typeof INSTALL_MANIFEST_VERSION; + previous: InstallRecord[]; +}; + +export type InstallStorePaths = { + paperclipHome: string; + cliRoot: string; + installsRoot: string; + manifestPath: string; + currentPath: string; + shimPath: string; +}; + +function ensurePrivateDirectory(directoryPath: string): void { + fs.mkdirSync(directoryPath, { recursive: true, mode: 0o700 }); + const stat = fs.lstatSync(directoryPath); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`Refusing to use non-directory install-store path ${directoryPath}.`); + } + fs.chmodSync(directoryPath, 0o700); +} + +export function resolveInstallStorePaths(options: { + paperclipHome?: string; + homeDir?: string; +} = {}): InstallStorePaths { + const paperclipHome = path.resolve(options.paperclipHome ?? resolvePaperclipHomeDir()); + const homeDir = path.resolve(options.homeDir ?? process.env.HOME ?? path.dirname(paperclipHome)); + const cliRoot = path.join(paperclipHome, "cli"); + return { + paperclipHome, + cliRoot, + installsRoot: path.join(cliRoot, "installs"), + manifestPath: path.join(cliRoot, "install.json"), + currentPath: path.join(cliRoot, "current"), + shimPath: path.join(homeDir, ".local", "bin", "paperclipai"), + }; +} + +export function payloadPathFor( + paths: InstallStorePaths, + source: InstallSource, + identifier: string, +): string { + if (!/^[A-Za-z0-9._-]+$/.test(identifier)) { + throw new Error(`Invalid install payload identifier '${identifier}'.`); + } + return path.join(paths.installsRoot, source, identifier); +} + +export function readInstallManifest(paths = resolveInstallStorePaths()): InstallManifest | null { + try { + const value = JSON.parse(fs.readFileSync(paths.manifestPath, "utf8")) as InstallManifest; + if ( + value.schemaVersion !== INSTALL_MANIFEST_VERSION || + (value.source !== "npm" && value.source !== "git") || + !Array.isArray(value.previous) || + typeof value.payloadPath !== "string" + ) { + throw new Error("unsupported manifest shape"); + } + return value; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw new Error(`Could not read managed install manifest at ${paths.manifestPath}: ${String(error)}`); + } +} + +export function writeInstallManifestAtomic( + manifest: InstallManifest, + paths = resolveInstallStorePaths(), +): void { + ensurePrivateDirectory(paths.cliRoot); + const temporaryPath = `${paths.manifestPath}.tmp-${process.pid}-${Date.now()}`; + try { + fs.writeFileSync(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 }); + fs.renameSync(temporaryPath, paths.manifestPath); + } finally { + fs.rmSync(temporaryPath, { force: true }); + } +} + +function assertPayloadPath(payloadPath: string, paths: InstallStorePaths): void { + const relative = path.relative(paths.installsRoot, path.resolve(payloadPath)); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`Refusing to activate payload outside ${paths.installsRoot}.`); + } + const stat = fs.lstatSync(payloadPath); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`Refusing to activate non-directory payload ${payloadPath}.`); + } + const installsRealPath = fs.realpathSync(paths.installsRoot); + const payloadRealPath = fs.realpathSync(payloadPath); + if (!payloadRealPath.startsWith(`${installsRealPath}${path.sep}`)) { + throw new Error(`Refusing to activate payload that resolves outside ${paths.installsRoot}.`); + } +} + +export function flipCurrentAtomic( + payloadPath: string, + paths = resolveInstallStorePaths(), + hooks: { beforeRename?: () => void } = {}, +): void { + assertPayloadPath(payloadPath, paths); + ensurePrivateDirectory(paths.cliRoot); + try { + const currentStat = fs.lstatSync(paths.currentPath); + if (!currentStat.isSymbolicLink()) { + throw new Error(`Refusing to replace non-symlink ${paths.currentPath}.`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + + const temporaryLink = path.join( + paths.cliRoot, + `.current-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`, + ); + const relativeTarget = path.relative(paths.cliRoot, payloadPath); + try { + fs.symlinkSync(relativeTarget, temporaryLink, "dir"); + hooks.beforeRename?.(); + fs.renameSync(temporaryLink, paths.currentPath); + } finally { + fs.rmSync(temporaryLink, { force: true }); + } +} + +export function buildNextManifest( + record: InstallRecord, + current: InstallManifest | null, +): InstallManifest { + const candidates: InstallRecord[] = current + ? [ + { + source: current.source, + version: current.version, + channel: current.channel, + payloadPath: current.payloadPath, + repo: current.repo, + ref: current.ref, + sha: current.sha, + installedAt: current.installedAt, + }, + ...current.previous, + ] + : []; + const previous = candidates + .filter((candidate) => path.resolve(candidate.payloadPath) !== path.resolve(record.payloadPath)) + .filter( + (candidate, index, all) => + all.findIndex((other) => path.resolve(other.payloadPath) === path.resolve(candidate.payloadPath)) === + index, + ) + .slice(0, 2); + + return { schemaVersion: INSTALL_MANIFEST_VERSION, ...record, previous }; +} + +export function pruneInstallPayloads( + manifest: InstallManifest, + paths = resolveInstallStorePaths(), +): string[] { + const retained = new Set( + [manifest, ...manifest.previous].map((record) => path.resolve(record.payloadPath)), + ); + const removed: string[] = []; + for (const source of ["npm", "git"] as const) { + const sourceRoot = path.join(paths.installsRoot, source); + if (!fs.existsSync(sourceRoot)) continue; + const sourceStat = fs.lstatSync(sourceRoot); + if (!sourceStat.isDirectory() || sourceStat.isSymbolicLink()) { + throw new Error(`Refusing to prune unsafe install-store path ${sourceRoot}.`); + } + for (const entry of fs.readdirSync(sourceRoot)) { + if (entry.startsWith(".")) continue; + const candidate = path.join(sourceRoot, entry); + if (!retained.has(path.resolve(candidate))) { + fs.rmSync(candidate, { recursive: true, force: true }); + removed.push(candidate); + } + } + } + return removed; +} + +export function assertManagedShimWritable(paths = resolveInstallStorePaths()): void { + try { + const stat = fs.lstatSync(paths.shimPath); + if (stat.isSymbolicLink()) { + throw new Error(`Refusing to replace symlinked shim ${paths.shimPath}.`); + } + const existing = fs.readFileSync(paths.shimPath, "utf8"); + if (!existing.includes(MANAGED_SHIM_MARKER)) { + throw new Error(`Refusing to replace existing non-managed command ${paths.shimPath}.`); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } +} + +export function writeManagedShim(paths = resolveInstallStorePaths()): void { + assertManagedShimWritable(paths); + fs.mkdirSync(path.dirname(paths.shimPath), { recursive: true, mode: 0o755 }); + const contents = `#!/bin/sh\n# ${MANAGED_SHIM_MARKER}\nset -eu\nPAPERCLIP_HOME="\${PAPERCLIP_HOME:-\$HOME/.paperclip}"\nexec node "\$PAPERCLIP_HOME/cli/current/node_modules/paperclipai/dist/index.js" "\$@"\n`; + fs.writeFileSync(paths.shimPath, contents, { mode: 0o755 }); + fs.chmodSync(paths.shimPath, 0o755); +} + +export function removeManagedShim(paths = resolveInstallStorePaths()): boolean { + try { + const contents = fs.readFileSync(paths.shimPath, "utf8"); + if (!contents.includes(MANAGED_SHIM_MARKER)) return false; + fs.rmSync(paths.shimPath, { force: true }); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; + throw error; + } +} + +export function managedPathBlock(): string { + return `${PATH_BLOCK_START}\nexport PATH="$HOME/.local/bin:$PATH"\n${PATH_BLOCK_END}`; +} + +export function addManagedPathBlock(rcPath: string): boolean { + let existing = ""; + try { + existing = fs.readFileSync(rcPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (existing.includes(PATH_BLOCK_START)) return false; + fs.mkdirSync(path.dirname(rcPath), { recursive: true }); + const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""; + fs.appendFileSync(rcPath, `${prefix}${managedPathBlock()}\n`); + return true; +} + +export function removeManagedPathBlock(rcPath: string): boolean { + let existing: string; + try { + existing = fs.readFileSync(rcPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + const escapedStart = PATH_BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const escapedEnd = PATH_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + const next = existing.replace(new RegExp(`(?:^|\\n)${escapedStart}\\n[\\s\\S]*?${escapedEnd}\\n?`), "\n"); + if (next === existing) return false; + fs.writeFileSync(rcPath, next.replace(/^\n/, "")); + return true; +} + +export function isManagedExecutable( + executablePath: string | undefined, + manifest: InstallManifest, + paths = resolveInstallStorePaths(), +): boolean { + if (!executablePath) return false; + try { + const executableRealPath = fs.realpathSync(executablePath); + const payloadRealPath = fs.realpathSync(manifest.payloadPath); + return executableRealPath.startsWith(`${payloadRealPath}${path.sep}`) && fs.existsSync(paths.currentPath); + } catch { + return false; + } +} diff --git a/cli/src/version.ts b/cli/src/version.ts index 7b94c8b35df..077e9e4e582 100644 --- a/cli/src/version.ts +++ b/cli/src/version.ts @@ -1,4 +1,9 @@ import { createRequire } from "node:module"; +import { + isManagedExecutable, + readInstallManifest, + resolveInstallStorePaths, +} from "./install-store.js"; type PackageJson = { version?: string; @@ -7,4 +12,21 @@ type PackageJson = { const require = createRequire(import.meta.url); const pkg = require("../package.json") as PackageJson; -export const cliVersion = pkg.version ?? "0.0.0"; +const packageVersion = pkg.version ?? "0.0.0"; + +export function resolveCliVersion(executablePath = process.argv[1]): string { + try { + const paths = resolveInstallStorePaths(); + const manifest = readInstallManifest(paths); + if (!manifest || !isManagedExecutable(executablePath, manifest, paths)) return packageVersion; + const provenance = + manifest.source === "git" + ? `managed git ${manifest.ref ?? manifest.sha ?? "unknown"}` + : `managed npm ${manifest.channel}`; + return `${packageVersion} (${provenance}; payload ${manifest.payloadPath})`; + } catch { + return packageVersion; + } +} + +export const cliVersion = resolveCliVersion(); diff --git a/scripts/clean-install-npm.sh b/scripts/clean-install-npm.sh new file mode 100755 index 00000000000..a65bb95ed74 --- /dev/null +++ b/scripts/clean-install-npm.sh @@ -0,0 +1,29 @@ +#!/bin/bash +set -euo pipefail + +PC_TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-clean-install.XXXXXX")" +PC_HOME="$PC_TEST_ROOT/home" +PC_CACHE="$PC_TEST_ROOT/npm-cache" +mkdir -p "$PC_HOME" "$PC_CACHE" +trap 'rm -rf "$PC_TEST_ROOT"' EXIT + +export HOME="$PC_HOME" +export npm_config_cache="$PC_CACHE" +export npm_config_userconfig="$PC_HOME/.npmrc" +export PATH="$PC_HOME/.local/bin:$PATH" + +cd "$PC_TEST_ROOT" +npx --yes --registry https://registry.npmjs.org paperclipai install + +test -x "$PC_HOME/.local/bin/paperclipai" +test -L "$PC_HOME/.paperclip/cli/current" +test -f "$PC_HOME/.paperclip/cli/install.json" +paperclipai --version + +mkdir -p "$PC_HOME/.paperclip/instances/default" +touch "$PC_HOME/.paperclip/instances/default/user-data-marker" +paperclipai uninstall + +test ! -e "$PC_HOME/.paperclip/cli" +test ! -e "$PC_HOME/.local/bin/paperclipai" +test -f "$PC_HOME/.paperclip/instances/default/user-data-marker"