feat(cli): add managed GitHub ref installs

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-22 19:38:47 +00:00
1 parent 148fa819a6
commit 512d0dddf6
5 files changed
+157 -4

No files matched your search

+35 -1
View File
@@ -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) => {
+29
View File
@@ -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);
+73 -1
View File
@@ -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<string> {
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<void> {
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);
+18 -2
View File
@@ -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<D
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; }
if (mode === "managed" && manifest?.source === "git") {
if (!manifest.repo || !manifest.ref || !manifest.sha) throw new Error("Managed git install metadata is incomplete.");
if (/^[0-9a-f]{7,40}$/i.test(manifest.ref)) { emit(options, { mode, source: "git", pinned: true, sha: manifest.sha }, `Git install is pinned at ${manifest.sha.slice(0, 12)}.`); return; }
const targetSha = await resolveGitHubRef(manifest.repo, manifest.ref, runCommand);
if (targetSha === manifest.sha) { emit(options, { mode, source: "git", changed: false, sha: targetSha, ref: manifest.ref }, `${manifest.repo}@${manifest.ref} is already at ${targetSha.slice(0, 12)}.`); return; }
if (options.check || options.dryRun) { emit(options, { mode, source: "git", changed: true, currentSha: manifest.sha, targetSha, ref: manifest.ref, dryRun: Boolean(options.dryRun) }, `Git update available: ${manifest.sha.slice(0, 12)} → ${targetSha.slice(0, 12)}.`); if (options.check) process.exitCode = 10; return; }
if (options.backup !== false) await (overrides.backup ?? (() => 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;
+2
View File
@@ -64,6 +64,8 @@ program
.description("Install Paperclip into a managed per-user CLI store")
.option("--canary", "Install the npm canary channel")
.option("--version <version>", "Install an exact published npm version")
.option("--ref <ref>", "Install a GitHub branch, tag, or commit SHA")
.option("--repo <owner/name>", "Override the GitHub repository used with --ref")
.option("-y, --yes", "Consent to the supported shell PATH update without prompting")
.action(installCommand);