mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-07 07:23:08 +02:00
## Thinking Path > - Paperclip is the open source control plane people use to manage AI-agent companies > - Operators need a predictable installation path that survives beyond an ephemeral `npx` process > - A durable installation needs an owned per-user payload store, stable command shim, safe shell integration, and supported service lifecycle > - Updates must preserve recoverability by backing up data, installing side-by-side, verifying the new payload, and retaining rollback state > - Bootstrap scripts and privileged service operations must fail closed across download, filesystem, ownership, and consent boundaries > - This pull request integrates managed install, update, rollback, service, uninstall, doctor, bootstrap-installer, and runtime-serving support into one workflow > - The benefit is a recoverable, inspectable, and documented installation lifecycle with explicit safety boundaries across Linux, macOS, containers, WSL, npm, npx, and source checkouts ## Linked Issues or Issue Description ### Problem Paperclip lacks a first-class durable installation and lifecycle workflow. Operators currently have to assemble npm/npx installation, PATH setup, background-service management, updates, rollback, diagnostics, and uninstall behavior themselves. That makes upgrades harder to recover, creates inconsistent behavior across platforms, and leaves shell/download/service trust boundaries without one documented implementation. ### Proposed Solution Add a managed per-user install store and stable shim, a verified shell bootstrap installer, service lifecycle commands, install-mode-aware update/rollback behavior, doctor checks, and documentation. Managed updates back up the database, install and smoke-test a side-by-side payload, atomically switch `current`, and retain prior payloads. The shell installer pins registry/download trust boundaries and requires explicit consent for non-interactive privileged actions. ### Alternatives Considered - Keep recommending `npx`: simple for evaluation, but ephemeral and unsuitable for stable services, atomic updates, or rollback. - Require global npm installation only: familiar, but cannot provide the owned side-by-side payload store and retained rollback semantics. - Split the capability across multiple PRs: rejected because install, update, service, uninstall, bootstrap, and serving behavior share contracts and security boundaries that need review together. ### Related Pull Requests - Supersedes #10042 and #10044 with one integrated final diff. - Incorporates and replaces the closed preparatory work in #10032 and #10034. ## What Changed - Added `paperclipai install`, `update`/`upgrade`, rollback, uninstall, service lifecycle, onboarding integration, and managed-install doctor checks. - Added a private managed payload store, verified manifest/marker ownership, exclusive mutation locks, atomic manifest/current/shim writes, retained previous payloads, and provenance validation. - Added npm and GitHub-ref install sources with exact target resolution, registry isolation, database backup, side-by-side verification, atomic activation, service restart coordination, and failure rollback. - Made managed-update backups report actionable service-start and `--no-backup` recovery guidance for unreachable databases, while clean never-onboarded instances skip an empty backup. - Added systemd user and launchd service definitions, status/health/log commands, single-instance coordination, stale-port recovery, and explicit sudo/lingering consent handling. - Added the `scripts/install.sh` bootstrap path with checked two-stage downloads, pinned public npm registry usage, platform checks, dry-run/non-interactive controls, and Docker fixtures. - Added embedded Postgres/native bootstrap integration, hot-restart/systemd-notify serving support, passive update notices, configuration contracts, README/CLI/install documentation, and focused regression tests. - Security re-review should explicitly re-verify: (1) `addManagedPathBlock`/`removeManagedPathBlock` reject symlinked or non-regular rc files, assert current-user ownership, preserve restrictive modes, and replace atomically; (2) managed shim replacement rejects unsafe parents, foreign-owned or multiply linked files, and uses checked atomic replacement; (3) the shell installer and sudo path preserve explicit consent and checked downloads; and (4) installed service/runtime serving remains bound to the validated managed shim and instance configuration. ## Verification - `bash -n scripts/install.sh scripts/clean-install-git.sh scripts/clean-install-npm.sh scripts/test-install-sh-docker.sh` - `pnpm exec vitest run cli/src/__tests__/install-store.test.ts cli/src/__tests__/install-command.test.ts cli/src/__tests__/managed-install-check.test.ts cli/src/__tests__/onboard-service.test.ts cli/src/__tests__/service-health-check.test.ts cli/src/__tests__/service-manager.test.ts cli/src/__tests__/update-command.test.ts cli/src/__tests__/update-notice.test.ts packages/db/src/embedded-postgres-native.test.ts` — 9 files, 66 tests passed - `pnpm --dir cli typecheck` - `pnpm --dir cli build` - Follow-up verification: `pnpm exec vitest run cli/src/__tests__/update-command.test.ts` (14/14), `pnpm --dir cli typecheck`, `pnpm --dir cli build`, and `pnpm --filter @paperclipai/server typecheck`. - `pnpm -r typecheck` - `pnpm build` - Full `pnpm test:run` exercised all suites; an injected static AWS credential changed one unrelated doctor expectation, which passed when those credentials were removed. A second run cleared that case and exposed stale pre-existing adapter-utils `dist` output; rebuilding `@paperclipai/adapter-utils` made the isolated test pass. The updated PR CI is the authoritative clean-workspace full-suite run. ## Risks - Installer/update code writes executable shims, symlinks, shell rc blocks, service definitions, and managed payloads; ownership, regular-file, symlink, hard-link, marker, and path-containment checks fail closed before destructive changes. - The bootstrap installer executes downloaded tooling; downloads are staged and checked before execution, npm traffic is pinned to the public registry, and non-interactive privileged behavior requires explicit consent. - Linux lingering may invoke `sudo`; the command is surfaced and confirmed before execution, and unsupported service managers fall back to foreground-run guidance. - Database migrations remain forward-only; payload rollback does not reverse migrations, so managed updates create a backup before activation unless explicitly disabled. - Service restart and runtime serving touch process/port ownership; lifecycle locks, health/version checks, and stable-shim service definitions reduce split-brain and stale-process risk. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex coding agents using GPT-5.5 and GPT-5.6-sol, with reasoning, repository/API access, shell execution, and test tooling. The runtime did not expose a reliable context-window size. ## 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> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
484 lines
17 KiB
TypeScript
484 lines
17 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 v1";
|
|
export const MANAGED_STORE_MARKER = "paperclipai managed install store v1\n";
|
|
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;
|
|
markerPath: string;
|
|
lockPath: 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);
|
|
}
|
|
|
|
function assertOwnedByCurrentUser(stat: fs.Stats, targetPath: string): void {
|
|
const getuid = process.getuid;
|
|
if (typeof getuid === "function" && stat.uid !== getuid()) {
|
|
throw new Error(`Refusing to modify path not owned by the current user: ${targetPath}.`);
|
|
}
|
|
}
|
|
|
|
function writeFileAtomic(filePath: string, contents: string, mode: number): void {
|
|
const temporaryPath = path.join(
|
|
path.dirname(filePath),
|
|
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
|
|
);
|
|
try {
|
|
fs.writeFileSync(temporaryPath, contents, { mode, flag: "wx" });
|
|
fs.renameSync(temporaryPath, filePath);
|
|
} finally {
|
|
fs.rmSync(temporaryPath, { force: true });
|
|
}
|
|
}
|
|
|
|
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"),
|
|
markerPath: path.join(cliRoot, ".managed-install"),
|
|
lockPath: path.join(cliRoot, ".install.lock"),
|
|
currentPath: path.join(cliRoot, "current"),
|
|
shimPath: path.join(homeDir, ".local", "bin", "paperclipai"),
|
|
};
|
|
}
|
|
|
|
export function initializeInstallStore(paths = resolveInstallStorePaths()): void {
|
|
ensurePrivateDirectory(paths.cliRoot);
|
|
ensurePrivateDirectory(paths.installsRoot);
|
|
try {
|
|
const markerStat = fs.lstatSync(paths.markerPath);
|
|
if (!markerStat.isFile() || markerStat.isSymbolicLink() || markerStat.nlink > 1) {
|
|
throw new Error(`Refusing to use unsafe install-store marker ${paths.markerPath}.`);
|
|
}
|
|
assertOwnedByCurrentUser(markerStat, paths.markerPath);
|
|
if (fs.readFileSync(paths.markerPath, "utf8") !== MANAGED_STORE_MARKER) {
|
|
throw new Error(`Refusing to use unrecognized install store ${paths.cliRoot}.`);
|
|
}
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
try {
|
|
fs.writeFileSync(paths.markerPath, MANAGED_STORE_MARKER, { mode: 0o600, flag: "wx" });
|
|
} catch (writeError) {
|
|
if (
|
|
(writeError as NodeJS.ErrnoException).code !== "EEXIST" ||
|
|
fs.readFileSync(paths.markerPath, "utf8") !== MANAGED_STORE_MARKER
|
|
) {
|
|
throw writeError;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export function assertManagedInstallStore(paths = resolveInstallStorePaths()): InstallManifest {
|
|
const cliStat = fs.lstatSync(paths.cliRoot);
|
|
if (!cliStat.isDirectory() || cliStat.isSymbolicLink()) {
|
|
throw new Error(`Refusing to remove unsafe install-store path ${paths.cliRoot}.`);
|
|
}
|
|
assertOwnedByCurrentUser(cliStat, paths.cliRoot);
|
|
let markerStat: fs.Stats;
|
|
try {
|
|
markerStat = fs.lstatSync(paths.markerPath);
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
throw new Error(`Refusing to remove unverified install store ${paths.cliRoot}.`);
|
|
}
|
|
throw error;
|
|
}
|
|
if (!markerStat.isFile() || markerStat.isSymbolicLink() || markerStat.nlink > 1) {
|
|
throw new Error(`Refusing to remove unverified install store ${paths.cliRoot}.`);
|
|
}
|
|
assertOwnedByCurrentUser(markerStat, paths.markerPath);
|
|
if (fs.readFileSync(paths.markerPath, "utf8") !== MANAGED_STORE_MARKER) {
|
|
throw new Error(`Refusing to remove unverified install store ${paths.cliRoot}.`);
|
|
}
|
|
const manifest = readInstallManifest(paths);
|
|
if (!manifest) throw new Error(`Refusing to remove install store without a manifest at ${paths.cliRoot}.`);
|
|
const relativePayload = path.relative(paths.installsRoot, path.resolve(manifest.payloadPath));
|
|
if (!relativePayload || relativePayload.startsWith("..") || path.isAbsolute(relativePayload)) {
|
|
throw new Error(`Refusing to remove install store with an invalid manifest at ${paths.cliRoot}.`);
|
|
}
|
|
return manifest;
|
|
}
|
|
|
|
export async function withInstallStoreLock<T>(
|
|
callback: () => Promise<T>,
|
|
paths = resolveInstallStorePaths(),
|
|
options: { initialize?: boolean } = {},
|
|
): Promise<T> {
|
|
if (options.initialize !== false) initializeInstallStore(paths);
|
|
const token = `${process.pid}:${Date.now()}:${Math.random().toString(16).slice(2)}`;
|
|
const processIsAlive = (pid: number): boolean => {
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch (error) {
|
|
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
}
|
|
};
|
|
const acquire = (): void => {
|
|
const temporaryPath = `${paths.lockPath}.${token}.tmp`;
|
|
try {
|
|
fs.writeFileSync(temporaryPath, `${token}\n`, { mode: 0o600, flag: "wx" });
|
|
try {
|
|
fs.linkSync(temporaryPath, paths.lockPath);
|
|
return;
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
}
|
|
const owner = fs.readFileSync(paths.lockPath, "utf8").trim();
|
|
const ownerPid = Number.parseInt(owner.split(":", 1)[0] ?? "", 10);
|
|
if (Number.isInteger(ownerPid) && ownerPid > 0 && !processIsAlive(ownerPid)) {
|
|
fs.rmSync(paths.lockPath);
|
|
fs.rmSync(temporaryPath, { force: true });
|
|
acquire();
|
|
return;
|
|
}
|
|
const ownerLabel = Number.isInteger(ownerPid) && ownerPid > 0 ? ` (pid ${ownerPid})` : "";
|
|
throw new Error(
|
|
`Another managed install is already running${ownerLabel}. ` +
|
|
`If no install process is active, remove the stale lock at ${paths.lockPath} and retry.`,
|
|
);
|
|
} finally {
|
|
fs.rmSync(temporaryPath, { force: true });
|
|
}
|
|
};
|
|
|
|
acquire();
|
|
try {
|
|
return await callback();
|
|
} finally {
|
|
try {
|
|
if (fs.readFileSync(paths.lockPath, "utf8").trim() === token) {
|
|
fs.rmSync(paths.lockPath, { force: true });
|
|
}
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 {
|
|
const homeDir = path.dirname(path.dirname(path.dirname(paths.shimPath)));
|
|
for (const directoryPath of [homeDir, path.join(homeDir, ".local"), path.dirname(paths.shimPath)]) {
|
|
if (!fs.existsSync(directoryPath)) continue;
|
|
const directoryStat = fs.lstatSync(directoryPath);
|
|
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) {
|
|
throw new Error(`Refusing to use unsafe shim directory ${directoryPath}.`);
|
|
}
|
|
assertOwnedByCurrentUser(directoryStat, directoryPath);
|
|
}
|
|
try {
|
|
const stat = fs.lstatSync(paths.shimPath);
|
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
throw new Error(`Refusing to replace non-regular shim ${paths.shimPath}.`);
|
|
}
|
|
assertOwnedByCurrentUser(stat, paths.shimPath);
|
|
if (stat.nlink > 1) throw new Error(`Refusing to replace multiply linked shim ${paths.shimPath}.`);
|
|
const existing = fs.readFileSync(paths.shimPath, "utf8");
|
|
if (!isManagedShimContents(existing)) {
|
|
throw new Error(`Refusing to replace existing non-managed command ${paths.shimPath}.`);
|
|
}
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
}
|
|
}
|
|
|
|
function shellQuote(value: string): string {
|
|
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
}
|
|
|
|
function isManagedShimContents(contents: string): boolean {
|
|
const lines = contents.split("\n");
|
|
return (
|
|
lines.length === 5 &&
|
|
lines[0] === "#!/bin/sh" &&
|
|
lines[1] === `# ${MANAGED_SHIM_MARKER}` &&
|
|
lines[2] === "set -eu" &&
|
|
/^exec '(?:[^']|'"'"')+' '(?:[^']|'"'"')+' "\$@"$/.test(lines[3]) &&
|
|
lines[4] === ""
|
|
);
|
|
}
|
|
|
|
export function writeManagedShim(paths = resolveInstallStorePaths()): void {
|
|
assertManagedShimWritable(paths);
|
|
const homeDir = path.dirname(path.dirname(path.dirname(paths.shimPath)));
|
|
const localDir = path.dirname(path.dirname(paths.shimPath));
|
|
fs.mkdirSync(homeDir, { recursive: true, mode: 0o700 });
|
|
fs.mkdirSync(localDir, { recursive: true, mode: 0o755 });
|
|
fs.mkdirSync(path.dirname(paths.shimPath), { recursive: true, mode: 0o755 });
|
|
assertManagedShimWritable(paths);
|
|
const entrypoint = path.join(paths.currentPath, "node_modules", "paperclipai", "dist", "index.js");
|
|
const contents = `#!/bin/sh\n# ${MANAGED_SHIM_MARKER}\nset -eu\nexec ${shellQuote(process.execPath)} ${shellQuote(entrypoint)} "\$@"\n`;
|
|
writeFileAtomic(paths.shimPath, contents, 0o755);
|
|
}
|
|
|
|
export function removeManagedShim(paths = resolveInstallStorePaths()): boolean {
|
|
try {
|
|
const contents = fs.readFileSync(paths.shimPath, "utf8");
|
|
if (!isManagedShimContents(contents)) 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 = "";
|
|
let mode = 0o600;
|
|
try {
|
|
const stat = fs.lstatSync(rcPath);
|
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
throw new Error(`Refusing to modify non-regular shell rc file ${rcPath}.`);
|
|
}
|
|
assertOwnedByCurrentUser(stat, rcPath);
|
|
mode = stat.mode & 0o777;
|
|
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" : "";
|
|
writeFileAtomic(rcPath, `${existing}${prefix}${managedPathBlock()}\n`, mode);
|
|
return true;
|
|
}
|
|
|
|
export function removeManagedPathBlock(rcPath: string): boolean {
|
|
let existing: string;
|
|
let mode: number;
|
|
try {
|
|
const stat = fs.lstatSync(rcPath);
|
|
if (!stat.isFile() || stat.isSymbolicLink()) {
|
|
throw new Error(`Refusing to modify non-regular shell rc file ${rcPath}.`);
|
|
}
|
|
assertOwnedByCurrentUser(stat, rcPath);
|
|
mode = stat.mode & 0o777;
|
|
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;
|
|
writeFileAtomic(rcPath, next.replace(/^\n/, ""), mode);
|
|
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);
|
|
const currentRealPath = fs.realpathSync(paths.currentPath);
|
|
return (
|
|
currentRealPath === payloadRealPath &&
|
|
executableRealPath.startsWith(`${payloadRealPath}${path.sep}`)
|
|
);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|