mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
feat(cli): add managed npm install store
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
47c38777d8
commit
4a43745b01
8 files changed
+819
-1
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<typeof resolveInstallStorePaths>;
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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<typeof execFileAsync>[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<string> {
|
||||
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<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}.`);
|
||||
}
|
||||
}
|
||||
|
||||
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<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.`));
|
||||
}
|
||||
|
||||
export async function installCommand(
|
||||
options: InstallOptions,
|
||||
dependencies: { runCommand?: CommandRunner; now?: () => Date } = {},
|
||||
): Promise<void> {
|
||||
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.`);
|
||||
}
|
||||
@@ -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<void> {
|
||||
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}.`));
|
||||
}
|
||||
@@ -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 <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()));
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
+23
-1
@@ -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();
|
||||
Executable
+29
@@ -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"
|
||||
Reference in new issue
Block a user