From 512d0dddf6d1d963fda10352e1766197599790c0 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 22 Jul 2026 19:32:11 +0000 Subject: [PATCH] feat(cli): add managed GitHub ref installs Co-Authored-By: Paperclip --- cli/src/__tests__/install-command.test.ts | 36 ++++++++++- cli/src/__tests__/update-command.test.ts | 29 +++++++++ cli/src/commands/install.ts | 74 ++++++++++++++++++++++- cli/src/commands/update.ts | 20 +++++- cli/src/index.ts | 2 + 5 files changed, 157 insertions(+), 4 deletions(-) diff --git a/cli/src/__tests__/install-command.test.ts b/cli/src/__tests__/install-command.test.ts index 38845fe5473..9da98708dac 100644 --- a/cli/src/__tests__/install-command.test.ts +++ b/cli/src/__tests__/install-command.test.ts @@ -2,7 +2,7 @@ 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 { installCommand, installGitPayload, resolveGitHubRef, resolveGitInstallRequest, resolveNpmInstallRequest } from "../commands/install.js"; import { uninstallCommand } from "../commands/uninstall.js"; import { INSTALL_MANIFEST_VERSION, @@ -51,6 +51,40 @@ describe("managed install commands", () => { expect(() => resolveNpmInstallRequest({ version: "latest" })).toThrow(); }); + it("resolves branch, tag, full SHA, and short SHA refs through GitHub", async () => { + const sha = "a".repeat(40); + const runCommand = vi.fn(async (_file: string, _args: string[]) => ({ stdout: JSON.stringify({ sha }), stderr: "" })); + for (const ref of ["master", "v1.2.3", sha, sha.slice(0, 12)]) { + await expect(resolveGitHubRef("paperclipai/paperclip", ref, runCommand)).resolves.toBe(sha); + } + expect(runCommand.mock.calls.map((call) => call[1].at(-1))).toEqual([ + "https://api.github.com/repos/paperclipai/paperclip/commits/master", + "https://api.github.com/repos/paperclipai/paperclip/commits/v1.2.3", + `https://api.github.com/repos/paperclipai/paperclip/commits/${sha}`, + `https://api.github.com/repos/paperclipai/paperclip/commits/${sha.slice(0, 12)}`, + ]); + }); + + it("supports fork overrides and classifies SHA refs as pinned", () => { + expect(resolveGitInstallRequest({ ref: "feature/test", repo: "HenkDz/paperclip" })).toEqual({ repo: "HenkDz/paperclip", ref: "feature/test", pinned: false }); + expect(resolveGitInstallRequest({ ref: "abcdef1" })).toEqual({ repo: "paperclipai/paperclip", ref: "abcdef1", pinned: true }); + expect(() => resolveGitInstallRequest({ repo: "HenkDz/paperclip" })).toThrow("requires --ref"); + }); + + it("reuses a SHA-keyed git payload without downloading or rebuilding", async () => { + const sha = "b".repeat(40); + const paths = resolveInstallStorePaths(); + const payloadPath = payloadPathFor(paths, "git", sha.slice(0, 12)); + const packageRoot = path.join(payloadPath, "node_modules", "paperclipai"); + fs.mkdirSync(path.join(packageRoot, "dist"), { recursive: true }); + fs.writeFileSync(path.join(packageRoot, "package.json"), JSON.stringify({ version: "0.3.1" })); + fs.writeFileSync(path.join(packageRoot, "dist", "index.js"), "#!/usr/bin/env node\n"); + const runCommand = vi.fn(async (_file: string, _args: string[]) => ({ stdout: "0.3.1\n", stderr: "" })); + await expect(installGitPayload("paperclipai/paperclip", sha, runCommand, paths)).resolves.toEqual({ payloadPath, reused: true, version: "0.3.1" }); + expect(runCommand).toHaveBeenCalledOnce(); + expect(runCommand.mock.calls[0]?.[0]).toBe(process.execPath); + }); + 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) => { diff --git a/cli/src/__tests__/update-command.test.ts b/cli/src/__tests__/update-command.test.ts index f666190011b..1547c9180da 100644 --- a/cli/src/__tests__/update-command.test.ts +++ b/cli/src/__tests__/update-command.test.ts @@ -53,6 +53,35 @@ describe("update command", () => { expect(() => resolveUpdateRequest(manifest, { latest: true, canary: true })).toThrow("only one"); }); + it("re-resolves a moving git branch and activates the new SHA payload", async () => { + const paths = resolveInstallStorePaths(); initializeInstallStore(paths); + const oldSha = "1".repeat(40); const newSha = "2".repeat(40); + const oldPayload = payloadPathFor(paths, "git", oldSha.slice(0, 12)); + const executable = createPayload(oldPayload, "0.3.1"); + fs.writeFileSync(path.join(oldPayload, "node_modules", "paperclipai", "package.json"), JSON.stringify({ version: "0.3.1" })); + const newPayload = payloadPathFor(paths, "git", newSha.slice(0, 12)); + createPayload(newPayload, "0.3.1"); + fs.writeFileSync(path.join(newPayload, "node_modules", "paperclipai", "package.json"), JSON.stringify({ version: "0.3.1" })); + flipCurrentAtomic(oldPayload, paths); + writeInstallManifestAtomic({ schemaVersion: 1, source: "git", version: "0.3.1", channel: "pinned", repo: "paperclipai/paperclip", ref: "master", sha: oldSha, payloadPath: oldPayload, installedAt: "2026-07-22T00:00:00.000Z", previous: [] }, paths); + const backup = vi.fn(async () => undefined); + 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, now: () => new Date("2026-07-22T12:00:00Z") }); + expect(backup).toHaveBeenCalledOnce(); + expect(readInstallManifest(paths)?.sha).toBe(newSha); + expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(newPayload)); + }); + + it("reports SHA git installs as pinned without resolving again", async () => { + const paths = resolveInstallStorePaths(); initializeInstallStore(paths); + const sha = "3".repeat(40); const payload = payloadPathFor(paths, "git", sha.slice(0, 12)); const executable = createPayload(payload, "0.3.1"); + flipCurrentAtomic(payload, paths); + writeInstallManifestAtomic({ schemaVersion: 1, source: "git", version: "0.3.1", channel: "pinned", repo: "paperclipai/paperclip", ref: sha.slice(0, 12), sha, payloadPath: payload, installedAt: "2026-07-22T00:00:00.000Z", previous: [] }, paths); + const runCommand = vi.fn(async () => ({ stdout: "", stderr: "" })); + await updateCommand({}, { paths, executablePath: executable, runCommand }); + expect(runCommand).not.toHaveBeenCalled(); + }); + 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); diff --git a/cli/src/commands/install.ts b/cli/src/commands/install.ts index 7b4ff747c91..d75ad73432e 100644 --- a/cli/src/commands/install.ts +++ b/cli/src/commands/install.ts @@ -22,9 +22,10 @@ import { const execFileAsync = promisify(execFile); const PUBLIC_NPM_REGISTRY = "https://registry.npmjs.org"; +const DEFAULT_GITHUB_REPO = "paperclipai/paperclip"; const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; -export type InstallOptions = { canary?: boolean; version?: string; yes?: boolean }; +export type InstallOptions = { canary?: boolean; version?: string; ref?: string; repo?: string; yes?: boolean }; export type CommandRunner = ( file: string, @@ -75,6 +76,25 @@ export async function resolvePublishedVersion(spec: string, runCommand: CommandR return parseResolvedVersion(result.stdout); } +export function resolveGitInstallRequest(options: InstallOptions): { repo: string; ref: string; pinned: boolean } | null { + if (!options.ref && !options.repo) return null; + if (!options.ref) throw new Error("--repo requires --ref."); + if (options.canary || options.version) throw new Error("--ref cannot be combined with --canary or --version."); + const repo = (options.repo ?? DEFAULT_GITHUB_REPO).trim(); + const ref = options.ref.trim(); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repo)) throw new Error(`--repo must be an owner/name GitHub repository, received '${repo}'.`); + if (!ref || ref.startsWith("-") || /[\0\r\n]/.test(ref)) throw new Error(`Invalid GitHub ref '${options.ref}'.`); + return { repo, ref, pinned: /^[0-9a-f]{7,40}$/i.test(ref) }; +} + +export async function resolveGitHubRef(repo: string, ref: string, runCommand: CommandRunner): Promise { + const result = await runCommand("curl", ["--fail", "--silent", "--show-error", "--location", "--header", "Accept: application/vnd.github+json", "--header", "User-Agent: paperclipai-install", `https://api.github.com/repos/${repo}/commits/${encodeURIComponent(ref)}`], { maxBuffer: 4 * 1024 * 1024 }); + let sha: unknown; + try { sha = (JSON.parse(result.stdout) as { sha?: unknown }).sha; } catch { throw new Error(`GitHub returned an invalid response while resolving ${repo}@${ref}.`); } + if (typeof sha !== "string" || !/^[0-9a-f]{40}$/i.test(sha)) throw new Error(`GitHub did not return a full commit SHA for ${repo}@${ref}.`); + return sha.toLowerCase(); +} + function payloadEntrypoint(payloadPath: string): string { return path.join(payloadPath, "node_modules", "paperclipai", "dist", "index.js"); } @@ -144,6 +164,38 @@ export async function installNpmPayload( } } +export async function installGitPayload(repo: string, sha: string, runCommand: CommandRunner, paths = resolveInstallStorePaths()): Promise<{ payloadPath: string; reused: boolean; version: string }> { + const identifier = sha.slice(0, 12); + const payloadPath = payloadPathFor(paths, "git", identifier); + if (fs.existsSync(payloadPath)) { + const metadata = JSON.parse(fs.readFileSync(path.join(payloadPath, "node_modules", "paperclipai", "package.json"), "utf8")) as { version: string }; + await smokePayload(payloadPath, metadata.version, runCommand); + return { payloadPath, reused: true, version: metadata.version }; + } + const sourceRoot = path.dirname(payloadPath); + fs.mkdirSync(sourceRoot, { recursive: true, mode: 0o700 }); + const stagingRoot = path.join(sourceRoot, `.${identifier}.tmp-${process.pid}-${Date.now()}`); + const checkoutPath = path.join(stagingRoot, "source"); + const archivePath = path.join(stagingRoot, "source.tar.gz"); + const stagedPayload = path.join(stagingRoot, "payload"); + fs.rmSync(stagingRoot, { recursive: true, force: true }); + fs.mkdirSync(checkoutPath, { recursive: true, mode: 0o700 }); + try { + await runCommand("curl", ["--fail", "--silent", "--show-error", "--location", "--output", archivePath, `https://codeload.github.com/${repo}/tar.gz/${sha}`], { maxBuffer: 4 * 1024 * 1024 }); + await runCommand("tar", ["-xzf", archivePath, "--strip-components=1", "-C", checkoutPath], { maxBuffer: 4 * 1024 * 1024 }); + await runCommand("corepack", ["pnpm", "install", "--frozen-lockfile"], { cwd: checkoutPath, maxBuffer: 32 * 1024 * 1024 }); + await runCommand("bash", ["scripts/build-npm.sh", "--skip-checks"], { cwd: checkoutPath, maxBuffer: 32 * 1024 * 1024 }); + const metadata = JSON.parse(fs.readFileSync(path.join(checkoutPath, "cli", "package.json"), "utf8")) as { version: string }; + await runCommand("npm", ["pack", "--pack-destination", stagingRoot], { cwd: path.join(checkoutPath, "cli"), maxBuffer: 16 * 1024 * 1024 }); + const tarball = fs.readdirSync(stagingRoot).find((entry) => entry.endsWith(".tgz")); + if (!tarball) throw new Error("npm pack did not produce a paperclipai tarball."); + await runCommand("npm", ["install", "--prefix", stagedPayload, path.join(stagingRoot, tarball), "--no-audit", "--no-fund"], { cwd: stagingRoot, maxBuffer: 32 * 1024 * 1024 }); + await smokePayload(stagedPayload, metadata.version, runCommand); + fs.renameSync(stagedPayload, payloadPath); + return { payloadPath, reused: false, version: metadata.version }; + } finally { fs.rmSync(stagingRoot, { recursive: true, 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); @@ -183,6 +235,26 @@ export async function installCommand( ): Promise { assertSupportedNodeVersion(); const runCommand = dependencies.runCommand ?? execFileAsync; + const gitRequest = resolveGitInstallRequest(options); + if (gitRequest) { + const sha = await resolveGitHubRef(gitRequest.repo, gitRequest.ref, runCommand); + const paths = resolveInstallStorePaths(); + const installed = await withInstallStoreLock(async () => { + assertManagedShimWritable(paths); + const currentManifest = readInstallManifest(paths); + const payload = await installGitPayload(gitRequest.repo, sha, runCommand, paths); + const record: InstallRecord = { source: "git", version: payload.version, channel: "pinned", repo: gitRequest.repo, ref: gitRequest.ref, sha, payloadPath: payload.payloadPath, installedAt: (dependencies.now?.() ?? new Date()).toISOString() }; + const nextManifest = buildNextManifest(record, currentManifest); + const oldTarget = fs.existsSync(paths.currentPath) ? fs.readlinkSync(paths.currentPath) : null; + flipCurrentAtomic(payload.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); return payload; + }, paths); + await ensureShimOnPath(options); + console.log(pc.yellow(`Warning: running unreleased code from ${gitRequest.repo}@${gitRequest.ref} (${sha.slice(0, 12)}).`)); + console.log(pc.green(`${installed.reused ? "Activated cached" : "Installed"} paperclipai git payload ${sha.slice(0, 12)}.`)); + return; + } const request = resolveNpmInstallRequest(options); console.log(`Resolving paperclipai@${request.spec} from ${PUBLIC_NPM_REGISTRY}...`); const version = await resolvePublishedVersion(request.spec, runCommand); diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index c6268d78952..a4d0bcf92ad 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -6,7 +6,7 @@ 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"; +import { installGitPayload, installNpmPayload, resolveGitHubRef, resolvePublishedVersion, type CommandRunner } from "./install.js"; const execFileAsync = promisify(execFile); export type InstallMode = "managed" | "global-npm" | "npx" | "source" | "unknown"; @@ -86,7 +86,23 @@ export async function updateCommand(options: UpdateOptions, overrides: Partial dbBackupCommand({})))(); + const installed = await withInstallStoreLock(async () => { + const payload = await installGitPayload(manifest.repo!, targetSha, runCommand, paths); + const record: InstallRecord = { source: "git", version: payload.version, channel: "pinned", repo: manifest.repo, ref: manifest.ref, sha: targetSha, payloadPath: payload.payloadPath, installedAt: (overrides.now?.() ?? new Date()).toISOString() }; + 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, source: "git", changed: true, currentSha: manifest.sha, targetSha, reused: installed.reused }, pc.yellow(`Updated unreleased git payload ${manifest.sha.slice(0, 12)} → ${targetSha.slice(0, 12)} from ${manifest.repo}@${manifest.ref}.`)); + return; + } const targetVersion = await resolvePublishedVersion(request.spec, runCommand); const currentVersion = manifest?.version; const comparison = currentVersion ? compareVersions(targetVersion, currentVersion) : 1; diff --git a/cli/src/index.ts b/cli/src/index.ts index fdafa70895e..ac4c74e27c5 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -64,6 +64,8 @@ program .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("--ref ", "Install a GitHub branch, tag, or commit SHA") + .option("--repo ", "Override the GitHub repository used with --ref") .option("-y, --yes", "Consent to the supported shell PATH update without prompting") .action(installCommand);