mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 10:26:26 +01:00
294 lines
10 KiB
TypeScript
294 lines
10 KiB
TypeScript
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;
|
|
}
|
|
}
|