Files
PaperClipAI/cli/src/commands/install.ts
T
DottaandPaperclip 2991a59b17 fix(adapters): prevent engine fallback and preserve usable runtime defaults (#13105)
## Thinking Path

> - Paperclip manages agents that must write work and report task
outcomes through its API.
> - Local adapters select an execution engine and its permission
settings.
> - A higher ACP Node requirement can make an unchanged installation
lose access to its default engine.
> - The adapter then silently selects CLI, which can change permissions
and block API access.
> - This pull request keeps the engine choice fixed and reports missing
prerequisites before work starts.
> - It also gives explicit Codex CLI runs usable defaults and keeps
managed services on a supported Node runtime.

## Linked Issues or Issue Description

Refs #12215. Related changes: #11792 raised the Node requirement; #13094
addressed separate runner networking behavior. This change fixes the
engine-selection and managed-launcher paths.

**What happened?**

An unchanged agent could switch from ACP to CLI after an upgrade. Codex
CLI then used read-only permissions with networking disabled. The run
could finish without updating its task. Repeated recovery attempts used
the same unavailable setup. Managed updates also skipped the Node check
and did not refresh old launchers.

**Expected behavior**

An unavailable engine must fail with a clear setup error. It must not
silently select another engine. Explicit CLI runs must be able to write
workspace files and call the API unless the operator configures stricter
settings. Managed updates must validate Node and keep child tools on
that runtime.

**Steps to reproduce**

1. Run an ACP-default agent under Node 22 after the ACP minimum rises to
24.11.
2. Leave the engine unset and disable the approval/sandbox bypass.
3. Observe the old adapter select CLI and fail to write task disposition
through the API.
4. Start a managed service with an old launcher and a supervisor PATH
that selects a different Node for child tools.

## What Changed

- Remove automatic engine fallback for Codex, Claude, Gemini, and Kimi.
Check prerequisites for default and explicit ACP selections.
- Return a configuration error with proof that provider work did not
start. Stop automatic continuation retries for this error.
- Enable Codex ACP workspace networking at the actual turn boundary.
Upstream mode presets otherwise force it off even when config.toml
enables it. Preserve explicit network denial and read-only mode.
- Set workspace-write and network access defaults for explicit Codex CLI
runs. Preserve explicit sandbox modes, profiles, and network
restrictions.
- Pin the validated Node directory in managed launcher PATH. Refresh
legacy launchers during installs and npm/Git updates.
- Reject updates on unsupported Node. Keep update checks, dry runs, and
rollback available.
- Synchronize the qualified Codex ACP executable identity across server,
TypeScript runner, Rust runner, and provider-pack launch paths.
- Add regression tests and update engine and installation documentation.

## Verification

- [Full CI passed on the final
head](https://github.com/paperclipai/paperclip/actions/runs/34387099695):
typecheck, build/native runner verification, all general and serialized
test shards, all browser shards, release registry, canary dry run, and
policy checks.
- Greptile: 5/5 on `2c1d6e2815830a5cd39e36c8a082cc0c4441b6c0`, with no
unresolved review findings. Security gates are green.
- Full workspace typecheck and build also passed locally. The final
deployed Linux build passed.
- Full Codex, Claude, Gemini, and Kimi source test suites: 804 passed, 2
skipped. Installer, updater, and launcher tests: 47 passed. Installed
ACP turn-boundary tests: 3 passed. ACP packaging tests: 14 passed.
Focused recovery classification tests also passed.
- Real Linux Codex CLI runs, both fresh and resumed, wrote a workspace
file and reached the control-plane health API with the new defaults.
- Explicit read-only and network-disabled control probes retained those
restrictions.
- A real ACP run on the final deployed Linux build wrote a file and
reached the control-plane API with HTTP 200, without engine fallback.
The same probe failed DNS before the turn-policy patch.
- Executable-identity and installed-policy contracts: 12 passed.
Affected native server tests: 197 passed. Runner factory tests: 21
passed. Rust qualification and native provider integration tests: 11
passed.
- Deployed the production changes to a Linux service on Node 24.20 after
a verified database backup. Health, bootstrap readiness, static UI,
executable/cwd identity, and guarded restart checks passed. The restart
lost no runs.
- Corrected stale Kimi skill-default and Gemini remote-archive fixtures;
both suites pass.

## Risks

- Default or legacy auto engine settings now fail when ACP is
unavailable. Operators who intend to use CLI must select it explicitly.
- Codex CLI now permits workspace writes and networking by default, and
ACP workspace-write turns permit networking by default. Explicit
operator sandbox settings remain authoritative.
- Old managed launchers keep their pinned Node until they are
reinstalled under a supported runtime. An old updater cannot repair
itself; the documentation gives the current installer command.
- Custom service wrappers and global/source installations must configure
their runtime PATH. No database migration is required.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository inspection,
shell execution, and test tools. The exact serving model identifier and
context-window size are not exposed in this session.

## 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
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-09 13:27:24 -05:00

421 lines
21 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;
} 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.`);
}