mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The CLI `install` and `update` commands resolve the published version with `npm view paperclipai@<spec> version --json` > - npm 12 changed the output of this command. It wraps a single value in an array: `["2026.916.1"]` instead of `"2026.916.1"` > - `parseResolvedVersion()` accepts only a bare JSON string, so it throws "npm returned an unexpected version response" > - Node 26 ships with npm 12, so a new install with the official `install.sh` fails for these users > - This pull request makes the parser accept a one-element string array > - The benefit is that install and update work with npm 12 and with older npm versions ## Linked Issues or Issue Description Fixes #12226 ## What Changed - `cli/src/commands/install.ts`: `parseResolvedVersion()` returns the value when npm returns a one-element array of strings. Other shapes still throw the same error. - `cli/src/__tests__/install-command.test.ts`: add a test for npm 11 output, npm 12 output, and plain-text output. The test also checks that a multi-element array still throws. ## Verification - Before the fix: on Node 26.8.2 with npm 12.0.2, `bash install.sh` fails with `npm returned an unexpected version response: [ "2026.916.1" ]`. - `npm view paperclipai@latest version --json` gives `["2026.916.1"]` with npm 12.0.2 and `"2026.916.1"` with npm 11.20.0. - `cd cli && npx vitest run src/__tests__/install-command.test.ts src/__tests__/update-command.test.ts src/__tests__/channels.test.ts`: 40 tests pass. - Without the source change, the new test fails with the same error as the bug report. ## Risks - Low risk. The change adds one accepted input shape. Existing accepted shapes do not change. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use (shell and file editing). It helped to find the cause, write the change and the test, and run the tests. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code)
423 lines
22 KiB
TypeScript
423 lines
22 KiB
TypeScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
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 { isSupportedNodeVersion, MINIMUM_NODE_VERSION } from "@paperclipai/shared/node-version";
|
|
import {
|
|
addManagedPathBlock,
|
|
assertManagedShimWritable,
|
|
buildNextManifest,
|
|
flipCurrentAtomic,
|
|
payloadPathFor,
|
|
pruneInstallPayloads,
|
|
readInstallManifest,
|
|
resolveInstallStorePaths,
|
|
withInstallStoreLock,
|
|
writeInstallManifestAtomic,
|
|
writeManagedShim,
|
|
type InstallChannel,
|
|
type InstallRecord,
|
|
} from "../install-store.js";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
export 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; ref?: string; repo?: string; yes?: boolean };
|
|
|
|
export type CommandRunner = (
|
|
file: string,
|
|
args: string[],
|
|
options?: Parameters<typeof execFileAsync>[2],
|
|
) => Promise<{ stdout: string; stderr: string }>;
|
|
|
|
type ReleasePackageEntry = { dir: string; name: string };
|
|
|
|
export async function runCommandWithDiagnostics(
|
|
file: string,
|
|
args: string[],
|
|
options?: Parameters<typeof execFileAsync>[2],
|
|
): Promise<{ stdout: string; stderr: string }> {
|
|
try {
|
|
return await execFileAsync(file, args, { ...options, encoding: "utf8" });
|
|
} catch (error) {
|
|
const stderr = error && typeof error === "object" && "stderr" in error && typeof error.stderr === "string"
|
|
? error.stderr.trim()
|
|
: "";
|
|
if (!stderr || (error instanceof Error && error.message.includes(stderr))) throw error;
|
|
throw new Error(`${error instanceof Error ? error.message : String(error)}\n${stderr}`, { cause: error });
|
|
}
|
|
}
|
|
|
|
export function resolveGitInstallWorkspacePackages(checkoutPath: string): ReleasePackageEntry[] {
|
|
const manifestPath = path.join(checkoutPath, "scripts", "release-package-manifest.json");
|
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8")) as ReleasePackageEntry[];
|
|
const packageByName = new Map(manifest.map((entry) => [entry.name, entry]));
|
|
const visiting = new Set<string>();
|
|
const visited = new Set<string>();
|
|
const ordered: ReleasePackageEntry[] = [];
|
|
|
|
const visit = (packageName: string): void => {
|
|
if (visited.has(packageName)) return;
|
|
if (visiting.has(packageName)) throw new Error(`Circular workspace dependency while staging ${packageName}.`);
|
|
const entry = packageByName.get(packageName);
|
|
if (!entry) throw new Error(`Git install cannot stage workspace dependency ${packageName}; it is missing from scripts/release-package-manifest.json.`);
|
|
visiting.add(packageName);
|
|
const packageJson = JSON.parse(fs.readFileSync(path.join(checkoutPath, entry.dir, "package.json"), "utf8")) as Record<string, unknown>;
|
|
for (const section of ["dependencies", "optionalDependencies", "peerDependencies"] as const) {
|
|
const dependencies = packageJson[section];
|
|
if (!dependencies || typeof dependencies !== "object") continue;
|
|
for (const dependencyName of Object.keys(dependencies)) {
|
|
if (dependencyName.startsWith("@paperclipai/")) visit(dependencyName);
|
|
}
|
|
}
|
|
visiting.delete(packageName);
|
|
visited.add(packageName);
|
|
ordered.push(entry);
|
|
};
|
|
|
|
visit("@paperclipai/server");
|
|
return ordered;
|
|
}
|
|
|
|
export function assertSupportedNodeVersion(): void {
|
|
if (!isSupportedNodeVersion(process.versions.node)) {
|
|
throw new Error(`Installing or updating Paperclip requires Node.js ${MINIMUM_NODE_VERSION} or newer (found ${process.version} at ${process.execPath}). Put a supported Node bin directory first on PATH and run 'npx paperclipai@latest install --yes' to re-pin an existing managed install.`);
|
|
}
|
|
}
|
|
|
|
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;
|
|
// npm 12 wraps single-value `npm view --json` output in an array.
|
|
if (Array.isArray(parsed) && parsed.length === 1 && typeof parsed[0] === "string") return parsed[0];
|
|
} catch {
|
|
if (EXACT_VERSION_PATTERN.test(trimmed)) return trimmed;
|
|
}
|
|
throw new Error(`npm returned an unexpected version response: ${trimmed}`);
|
|
}
|
|
|
|
export async function resolvePublishedVersion(spec: string, runCommand: CommandRunner): Promise<string> {
|
|
const result = await runCommand(
|
|
"npm",
|
|
["view", `paperclipai@${spec}`, "version", "--json", `--registry=${PUBLIC_NPM_REGISTRY}`],
|
|
{ maxBuffer: 1024 * 1024 },
|
|
);
|
|
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) };
|
|
}
|
|
|
|
async function runGitHubCurl(
|
|
args: string[],
|
|
runCommand: CommandRunner,
|
|
options?: Parameters<CommandRunner>[2],
|
|
): Promise<{ stdout: string; stderr: string }> {
|
|
// Anonymous GitHub requests are rate-limited per source IP (CI runners and
|
|
// corporate NAT exhaust the shared quota); honor an ambient token when present.
|
|
// The token travels via a curl --config file so it never appears in process args.
|
|
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN;
|
|
if (!token) return runCommand("curl", args, options);
|
|
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclipai-gh-"));
|
|
const configFile = path.join(configDir, "headers");
|
|
try {
|
|
fs.writeFileSync(configFile, `header = "Authorization: Bearer ${token}"\n`, { mode: 0o600 });
|
|
return await runCommand("curl", ["--config", configFile, ...args], options);
|
|
} finally {
|
|
fs.rmSync(configDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
export async function resolveGitHubRef(repo: string, ref: string, runCommand: CommandRunner): Promise<string> {
|
|
const result = await runGitHubCurl(["--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)}`], runCommand, { 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");
|
|
}
|
|
|
|
export async function smokePayload(payloadPath: string, expectedVersion: string, runCommand: CommandRunner): Promise<void> {
|
|
const entrypoint = payloadEntrypoint(payloadPath);
|
|
if (!fs.existsSync(entrypoint)) throw new Error(`Installed package is missing its CLI entrypoint: ${entrypoint}`);
|
|
const result = await runCommand(process.execPath, [entrypoint, "--version"], { maxBuffer: 1024 * 1024 });
|
|
const reportedVersion = result.stdout.trim().split(/\s+/)[0];
|
|
if (reportedVersion !== expectedVersion) {
|
|
throw new Error(`Installed CLI smoke check reported ${reportedVersion || "no version"}; expected ${expectedVersion}.`);
|
|
}
|
|
}
|
|
|
|
export async function installNpmPayload(
|
|
version: string,
|
|
runCommand: CommandRunner,
|
|
paths = resolveInstallStorePaths(),
|
|
): Promise<{ payloadPath: string; reused: boolean }> {
|
|
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 gitBuildEnv(extra: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv {
|
|
const env = { ...process.env, ...extra };
|
|
// Source builds need devDependencies (esbuild, typescript); ambient NODE_ENV=production
|
|
// makes pnpm/npm omit them, so the checkout build must not inherit it.
|
|
delete env.NODE_ENV;
|
|
return env;
|
|
}
|
|
|
|
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 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 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 });
|
|
// Workspace build scripts invoke bare `pnpm`; on a machine where pnpm exists only
|
|
// through corepack, nothing puts it on PATH, so provision a shim into the staging dir.
|
|
const pnpmShimDir = path.join(stagingRoot, "pnpm-bin");
|
|
fs.mkdirSync(pnpmShimDir, { recursive: true, mode: 0o700 });
|
|
const buildEnv = (extra: NodeJS.ProcessEnv = {}) =>
|
|
gitBuildEnv({ PATH: [pnpmShimDir, process.env.PATH].filter(Boolean).join(path.delimiter), ...extra });
|
|
try {
|
|
await runGitHubCurl(["--fail", "--silent", "--show-error", "--location", "--output", archivePath, `https://codeload.github.com/${repo}/tar.gz/${sha}`], runCommand, { maxBuffer: 4 * 1024 * 1024 });
|
|
await runCommand("tar", ["-xzf", archivePath, "--strip-components=1", "-C", checkoutPath], { maxBuffer: 4 * 1024 * 1024 });
|
|
await runCommand("corepack", ["enable", "pnpm", "--install-directory", pnpmShimDir], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 4 * 1024 * 1024 });
|
|
await runCommand("corepack", ["pnpm", "install", "--frozen-lockfile"], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 32 * 1024 * 1024 });
|
|
await runCommand("bash", ["scripts/build-npm.sh", "--skip-checks", "--skip-typecheck"], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 32 * 1024 * 1024 });
|
|
await runCommand("corepack", ["pnpm", "-r", "--filter", "@paperclipai/server...", "--if-present", "run", "build"], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 32 * 1024 * 1024 });
|
|
const metadata = JSON.parse(fs.readFileSync(path.join(checkoutPath, "cli", "package.json"), "utf8")) as { version: string };
|
|
const workspacePackages = resolveGitInstallWorkspacePackages(checkoutPath);
|
|
for (const [index, workspacePackage] of workspacePackages.entries()) {
|
|
const packageDir = path.join(checkoutPath, workspacePackage.dir);
|
|
const packageJson = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8")) as { bundleDependencies?: string[]; bundledDependencies?: string[] };
|
|
const bundledDependencies = packageJson.bundleDependencies ?? packageJson.bundledDependencies ?? [];
|
|
if (bundledDependencies.length > 0) {
|
|
const stagedPackage = path.join(stagingRoot, `workspace-package-${index}`);
|
|
await runCommand(process.execPath, [path.join(checkoutPath, "scripts", "prepare-bundled-package.mjs"), packageDir, stagedPackage], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 32 * 1024 * 1024 });
|
|
await runCommand("npm", ["pack", stagedPackage, "--pack-destination", stagingRoot], { cwd: checkoutPath, env: buildEnv(), maxBuffer: 16 * 1024 * 1024 });
|
|
} else {
|
|
await runCommand("corepack", ["pnpm", "--dir", workspacePackage.dir, "pack", "--pack-destination", stagingRoot], { cwd: checkoutPath, env: buildEnv({ PAPERCLIP_RELEASE_REUSE_UI_DIST: "1" }), maxBuffer: 32 * 1024 * 1024 });
|
|
}
|
|
}
|
|
await runCommand("npm", ["pack", "--pack-destination", stagingRoot], { cwd: path.join(checkoutPath, "cli"), env: buildEnv(), maxBuffer: 16 * 1024 * 1024 });
|
|
const tarballs = fs.readdirSync(stagingRoot).filter((entry) => entry.endsWith(".tgz"));
|
|
const cliTarball = tarballs.find((entry) => entry === `paperclipai-${metadata.version}.tgz`);
|
|
const workspaceTarballs = tarballs.filter((entry) => entry !== cliTarball);
|
|
if (!cliTarball || workspaceTarballs.length !== workspacePackages.length) {
|
|
throw new Error(`Git install packaging produced ${workspaceTarballs.length} workspace tarballs; expected ${workspacePackages.length}.`);
|
|
}
|
|
await runCommand("npm", ["install", "--prefix", stagedPayload, path.join(stagingRoot, cliTarball), ...workspaceTarballs.map((entry) => path.join(stagingRoot, entry)), "--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);
|
|
}
|
|
|
|
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<void> {
|
|
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.`));
|
|
}
|
|
|
|
async function confirmGitInstall(options: InstallOptions, repo: string, ref: string): Promise<void> {
|
|
const warning = `Installing ${repo}@${ref} executes dependency and build scripts from that repository.`;
|
|
console.log(pc.yellow(`Warning: ${warning}`));
|
|
if (options.yes === true) return;
|
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
throw new Error(`${warning} Re-run with --yes to consent in non-interactive environments.`);
|
|
}
|
|
const confirmed = await p.confirm({
|
|
message: `${warning} Continue?`,
|
|
initialValue: false,
|
|
});
|
|
if (p.isCancel(confirmed) || !confirmed) {
|
|
throw new Error("Git-ref install cancelled before downloading or executing repository code.");
|
|
}
|
|
}
|
|
|
|
export async function installCommand(
|
|
options: InstallOptions,
|
|
dependencies: { runCommand?: CommandRunner; now?: () => Date } = {},
|
|
): Promise<void> {
|
|
assertSupportedNodeVersion();
|
|
const runCommand = dependencies.runCommand ?? runCommandWithDiagnostics;
|
|
const gitRequest = resolveGitInstallRequest(options);
|
|
if (gitRequest) {
|
|
await confirmGitInstall(options, gitRequest.repo, gitRequest.ref);
|
|
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.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);
|
|
console.log(`Installing paperclipai@${version}...`);
|
|
|
|
const paths = resolveInstallStorePaths();
|
|
const installed = await withInstallStoreLock(async () => {
|
|
assertManagedShimWritable(paths);
|
|
const currentManifest = readInstallManifest(paths);
|
|
const payload = await installNpmPayload(version, runCommand, paths);
|
|
const record: InstallRecord = {
|
|
source: "npm",
|
|
version,
|
|
channel: request.channel,
|
|
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.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.`);
|
|
}
|