feat(cli): add managed install, update, and service lifecycle (#10045)

## 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>
This commit is contained in:
Dotta 2026-07-31 18:52:23 -07:00 committed by GitHub
parent 492555aaf9
commit fc5a30805e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
57 changed files with 5432 additions and 69 deletions

View File

@ -301,7 +301,39 @@ Paperclip is a full control plane, not a wrapper. Before you build any of this y
Open source. Self-hosted. No Paperclip account required.
```bash
npx paperclipai onboard --yes
curl -fsSLO https://paperclip.ing/install.sh
curl -fsSLO https://paperclip.ing/install.sh.sha256
if command -v sha256sum >/dev/null 2>&1; then
sha256sum -c install.sh.sha256
else
shasum -a 256 -c install.sh.sha256
fi
bash install.sh
```
The installer ensures Node.js 20 or newer is available, installs a managed
Paperclip CLI under `~/.paperclip/cli`, and starts interactive onboarding. It
can also install Paperclip as a background service on supported Linux and
macOS systems. The checksum detects transfer or publishing mistakes, but it is
served from the same origin as the script; use a release-tag or commit-pinned
GitHub copy when you need an independently hosted source.
For a non-interactive managed install:
```bash
curl -fsSL https://paperclip.ing/install.sh | bash -s -- --no-prompt --no-onboard
paperclipai onboard --yes
```
The piped form requires supported Node.js, npm, and npx to already be present.
If Node.js bootstrap is required, download and review `install.sh` before
running it so no privileged dependency-install command is accepted through a
pipe.
To try Paperclip without installing anything permanently:
```bash
npx --registry https://registry.npmjs.org paperclipai onboard --yes
```
> **Troubleshooting: private npm registry `.npmrc`**
@ -323,13 +355,16 @@ npx paperclipai onboard --yes
That quickstart path now defaults to trusted local loopback mode for the fastest first run. To start in authenticated/private mode instead, choose a bind preset explicitly:
```bash
npx paperclipai onboard --yes --bind lan
paperclipai onboard --yes --bind lan
# or:
npx paperclipai onboard --yes --bind tailnet
paperclipai onboard --yes --bind tailnet
```
If you already have Paperclip configured, rerunning `onboard` keeps the existing config in place. Use `paperclipai configure` to edit settings.
See [`doc/INSTALLING.md`](doc/INSTALLING.md) for pinned versions, canary and
git-ref installs, updates, rollback, service management, and uninstalling.
Or manually:
```bash

View File

@ -6,6 +6,7 @@
*/
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { bundledCliNpmDependencies } from "../scripts/cli-bundled-npm-dependencies.mjs";
@ -53,6 +54,17 @@ for (const name of externalWorkspacePackages) {
externals.add(name);
}
if (bundledCliNpmDependencies.has("embedded-postgres")) {
const requireFromDb = createRequire(resolve(repoRoot, "packages/db/package.json"));
const embeddedPostgresRoot = dirname(requireFromDb.resolve("embedded-postgres"));
const embeddedPostgresPackage = JSON.parse(
readFileSync(resolve(embeddedPostgresRoot, "..", "package.json"), "utf8"),
);
for (const name of Object.keys(embeddedPostgresPackage.optionalDependencies ?? {})) {
externals.add(name);
}
}
/** @type {import('esbuild').BuildOptions} */
export default {
entryPoints: ["src/index.ts"],

View File

@ -0,0 +1,376 @@
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 {
type CommandRunner,
installCommand,
installGitPayload,
resolveGitHubRef,
resolveGitInstallRequest,
resolveGitInstallWorkspacePackages,
resolveNpmInstallRequest,
runCommandWithDiagnostics,
} from "../commands/install.js";
import { uninstallCommand } from "../commands/uninstall.js";
import { resolvePaperclipInstanceId } from "../config/home.js";
import {
INSTALL_MANIFEST_VERSION,
flipCurrentAtomic,
initializeInstallStore,
payloadPathFor,
readInstallManifest,
resolveInstallStorePaths,
withInstallStoreLock,
writeInstallManifestAtomic,
} from "../install-store.js";
import { resolveCliVersion } from "../version.js";
import { systemdServiceName } from "../services/service-manager.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("resolves branch, tag, full SHA, and short SHA refs through GitHub", async () => {
const sha = "a".repeat(40);
const runCommand = vi.fn(async (_file: string, _args: string[]) => ({ stdout: JSON.stringify({ sha }), stderr: "" }));
for (const ref of ["master", "v1.2.3", sha, sha.slice(0, 12)]) {
await expect(resolveGitHubRef("paperclipai/paperclip", ref, runCommand)).resolves.toBe(sha);
}
expect(runCommand.mock.calls.map((call) => call[1].at(-1))).toEqual([
"https://api.github.com/repos/paperclipai/paperclip/commits/master",
"https://api.github.com/repos/paperclipai/paperclip/commits/v1.2.3",
`https://api.github.com/repos/paperclipai/paperclip/commits/${sha}`,
`https://api.github.com/repos/paperclipai/paperclip/commits/${sha.slice(0, 12)}`,
]);
});
it("supports fork overrides and classifies SHA refs as pinned", () => {
expect(resolveGitInstallRequest({ ref: "feature/test", repo: "HenkDz/paperclip" })).toEqual({ repo: "HenkDz/paperclip", ref: "feature/test", pinned: false });
expect(resolveGitInstallRequest({ ref: "abcdef1" })).toEqual({ repo: "paperclipai/paperclip", ref: "abcdef1", pinned: true });
expect(() => resolveGitInstallRequest({ repo: "HenkDz/paperclip" })).toThrow("requires --ref");
});
it("requires explicit non-interactive consent before resolving git refs", async () => {
const runCommand = vi.fn();
await expect(installCommand({ ref: "master", repo: "HenkDz/paperclip" }, { runCommand }))
.rejects.toThrow("Re-run with --yes");
expect(runCommand).not.toHaveBeenCalled();
});
it("reuses a SHA-keyed git payload without downloading or rebuilding", async () => {
const sha = "b".repeat(40);
const paths = resolveInstallStorePaths();
const payloadPath = payloadPathFor(paths, "git", sha.slice(0, 12));
const packageRoot = path.join(payloadPath, "node_modules", "paperclipai");
fs.mkdirSync(path.join(packageRoot, "dist"), { recursive: true });
fs.writeFileSync(path.join(packageRoot, "package.json"), JSON.stringify({ version: "0.3.1" }));
fs.writeFileSync(path.join(packageRoot, "dist", "index.js"), "#!/usr/bin/env node\n");
const runCommand = vi.fn(async (_file: string, _args: string[]) => ({ stdout: "0.3.1\n", stderr: "" }));
await expect(installGitPayload("paperclipai/paperclip", sha, runCommand, paths)).resolves.toEqual({ payloadPath, reused: true, version: "0.3.1" });
expect(runCommand).toHaveBeenCalledOnce();
expect(runCommand.mock.calls[0]?.[0]).toBe(process.execPath);
});
const createGitCheckoutRunCommand = (sha: string) =>
vi.fn(async (file: string, args: string[], _options?: Parameters<CommandRunner>[2]) => {
if (file === "curl" && !args.includes("--output")) return { stdout: JSON.stringify({ sha }), stderr: "" };
if (file === "curl") { fs.writeFileSync(args[args.indexOf("--output") + 1], "archive"); return { stdout: "", stderr: "" }; }
if (file === "tar") {
const checkout = args[args.indexOf("-C") + 1];
const packages = [
{ dir: "packages/shared", name: "@paperclipai/shared", packageJson: { name: "@paperclipai/shared", version: "0.3.1" } },
{ dir: "packages/db", name: "@paperclipai/db", packageJson: { name: "@paperclipai/db", version: "0.3.1", dependencies: { "@paperclipai/shared": "workspace:*" }, bundleDependencies: ["embedded-postgres"] } },
{ dir: "server", name: "@paperclipai/server", packageJson: { name: "@paperclipai/server", version: "0.3.1", dependencies: { "@paperclipai/db": "workspace:*" } } },
];
fs.mkdirSync(path.join(checkout, "cli"), { recursive: true });
fs.writeFileSync(path.join(checkout, "cli", "package.json"), JSON.stringify({ version: "0.3.1" }));
fs.mkdirSync(path.join(checkout, "scripts"), { recursive: true });
fs.writeFileSync(path.join(checkout, "scripts", "release-package-manifest.json"), JSON.stringify(packages.map(({ dir, name }) => ({ dir, name }))));
for (const workspacePackage of packages) {
fs.mkdirSync(path.join(checkout, workspacePackage.dir), { recursive: true });
fs.writeFileSync(path.join(checkout, workspacePackage.dir, "package.json"), JSON.stringify(workspacePackage.packageJson));
}
return { stdout: "", stderr: "" };
}
if (file === "corepack") {
if (args.includes("pack")) {
const destination = args[args.indexOf("--pack-destination") + 1];
const packageDir = args[args.indexOf("--dir") + 1];
const packageName = packageDir === "server" ? "paperclipai-server" : "paperclipai-shared";
fs.writeFileSync(path.join(destination, `${packageName}-0.3.1.tgz`), "package");
}
return { stdout: "", stderr: "" };
}
if (file === "bash") return { stdout: "", stderr: "" };
if (file === "npm" && args[0] === "pack") {
const packageName = args[1]?.includes("workspace-package-") ? "paperclipai-db" : "paperclipai";
fs.writeFileSync(path.join(args[args.indexOf("--pack-destination") + 1], `${packageName}-0.3.1.tgz`), "package");
return { stdout: "", stderr: "" };
}
if (file === "npm" && args[0] === "install") { const prefix = args[args.indexOf("--prefix") + 1]; const packageRoot = path.join(prefix, "node_modules", "paperclipai"); fs.mkdirSync(path.join(packageRoot, "dist"), { recursive: true }); fs.writeFileSync(path.join(packageRoot, "package.json"), JSON.stringify({ version: "0.3.1" })); fs.writeFileSync(path.join(packageRoot, "dist", "index.js"), "#!/usr/bin/env node\n"); return { stdout: "", stderr: "" }; }
if (file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs")) {
fs.mkdirSync(args[2], { recursive: true });
fs.writeFileSync(path.join(args[2], "package.json"), JSON.stringify({ name: "@paperclipai/db", version: "0.3.1" }));
return { stdout: "", stderr: "" };
}
if (file === process.execPath) return { stdout: "0.3.1\n", stderr: "" };
throw new Error(`Unexpected command: ${file} ${args.join(" ")}`);
});
it("installs a GitHub branch through codeload and reuses the resolved SHA", async () => {
const sha = "c".repeat(40);
const runCommand = createGitCheckoutRunCommand(sha);
await installCommand({ ref: "master", repo: "HenkDz/paperclip", yes: true }, { runCommand });
await installCommand({ ref: "master", repo: "HenkDz/paperclip", yes: true }, { runCommand });
const manifest = readInstallManifest(resolveInstallStorePaths());
expect(manifest).toMatchObject({ source: "git", repo: "HenkDz/paperclip", ref: "master", sha });
expect(manifest?.payloadPath).toContain(path.join("git", sha.slice(0, 12)));
expect(runCommand.mock.calls.filter(([command, args]) => command === "curl" && args.includes("--output"))).toHaveLength(1);
expect(runCommand.mock.calls.filter(([command, args]) => command === "corepack" && args[1] === "install")).toHaveLength(1);
expect(runCommand.mock.calls.filter(([command, args]) => command === "corepack" && args.includes("pack"))).toHaveLength(2);
expect(runCommand.mock.calls.filter(([command, args]) => command === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs"))).toHaveLength(1);
expect(runCommand.mock.calls.filter(([command, args]) => command === "npm" && args[0] === "pack")).toHaveLength(2);
const installCall = runCommand.mock.calls.find(([command, args]) => command === "npm" && args[0] === "install");
expect(installCall?.[1].filter((arg) => arg.endsWith(".tgz"))).toHaveLength(4);
});
it("builds git checkouts with NODE_ENV cleared so ambient production mode keeps devDependencies", async () => {
process.env.NODE_ENV = "production";
const sha = "d".repeat(40);
const runCommand = createGitCheckoutRunCommand(sha);
await expect(installGitPayload("paperclipai/paperclip", sha, runCommand, resolveInstallStorePaths())).resolves.toMatchObject({ version: "0.3.1", reused: false });
const buildCalls = runCommand.mock.calls.filter(([file, args]) =>
file === "bash" ||
file === "corepack" ||
(file === "npm" && args[0] === "pack") ||
(file === process.execPath && args[0]?.endsWith("prepare-bundled-package.mjs")));
expect(buildCalls).toHaveLength(9);
for (const call of buildCalls) {
const env = call[2]?.env;
expect(env, `${call[0]} ${call[1].join(" ")} must run with an explicit env`).toBeDefined();
expect(env, `${call[0]} ${call[1].join(" ")} must not inherit NODE_ENV`).not.toHaveProperty("NODE_ENV");
}
const uiPackCall = buildCalls.find(([file, , options]) => file === "corepack" && options?.env?.PAPERCLIP_RELEASE_REUSE_UI_DIST === "1");
expect(uiPackCall).toBeDefined();
});
it("resolves the complete server workspace dependency closure in dependency order", () => {
const checkout = path.join(root, "checkout");
const packages = [
{ dir: "packages/shared", name: "@paperclipai/shared", dependencies: {} },
{ dir: "packages/db", name: "@paperclipai/db", dependencies: { "@paperclipai/shared": "workspace:*" } },
{ dir: "server", name: "@paperclipai/server", dependencies: { "@paperclipai/db": "workspace:*" } },
];
fs.mkdirSync(path.join(checkout, "scripts"), { recursive: true });
fs.writeFileSync(path.join(checkout, "scripts", "release-package-manifest.json"), JSON.stringify(packages.map(({ dir, name }) => ({ dir, name }))));
for (const workspacePackage of packages) {
fs.mkdirSync(path.join(checkout, workspacePackage.dir), { recursive: true });
fs.writeFileSync(path.join(checkout, workspacePackage.dir, "package.json"), JSON.stringify({ name: workspacePackage.name, dependencies: workspacePackage.dependencies }));
}
expect(resolveGitInstallWorkspacePackages(checkout).map(({ name }) => name)).toEqual([
"@paperclipai/shared",
"@paperclipai/db",
"@paperclipai/server",
]);
});
it("includes child-process stderr in command failures", async () => {
await expect(runCommandWithDiagnostics(process.execPath, ["-e", "process.stderr.write('unsupported workspace dependency\\n'); process.exit(1)"]))
.rejects.toThrow("unsupported workspace dependency");
});
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");
const uninstallService = vi.fn(async () => {
expect(fs.existsSync(paths.shimPath)).toBe(true);
});
await uninstallCommand({
detectServiceManager: vi.fn(async () => ({
supported: true as const,
manager: {
status: vi.fn(async () => ({ installed: true, active: true })),
uninstall: uninstallService,
} as never,
})),
});
expect(uninstallService).toHaveBeenCalledOnce();
expect(fs.existsSync(paths.cliRoot)).toBe(false);
expect(fs.existsSync(paths.shimPath)).toBe(false);
expect(fs.readFileSync(userData, "utf8")).toBe("keep");
});
it("refuses to remove the shared CLI while another instance service is installed", async () => {
const paths = resolveInstallStorePaths();
const otherUnitPath = path.join(process.env.HOME!, ".config", "systemd", "user", systemdServiceName("team-a"));
fs.mkdirSync(path.dirname(otherUnitPath), { recursive: true });
fs.writeFileSync(otherUnitPath, "unit");
await expect(uninstallCommand({
detectServiceManager: vi.fn(async () => ({
supported: true as const,
manager: { status: vi.fn(async () => ({ installed: false, active: false })) } as never,
})),
platform: "linux",
userHomeDir: process.env.HOME!,
})).rejects.toThrow("other instance services are installed");
expect(fs.existsSync(paths.cliRoot)).toBe(false);
expect(fs.existsSync(otherUnitPath)).toBe(true);
});
it("preserves the managed install when an existing systemd unit cannot be checked", async () => {
const paths = resolveInstallStorePaths();
fs.mkdirSync(path.dirname(paths.shimPath), { recursive: true });
fs.writeFileSync(paths.shimPath, "managed shim");
const unitPath = path.join(
process.env.HOME!,
".config",
"systemd",
"user",
systemdServiceName(resolvePaperclipInstanceId()),
);
fs.mkdirSync(path.dirname(unitPath), { recursive: true });
fs.writeFileSync(unitPath, "unit");
await expect(uninstallCommand({
detectServiceManager: vi.fn(async () => ({
supported: false as const,
reason: "No usable systemd user manager was detected",
})),
platform: "linux",
userHomeDir: process.env.HOME!,
})).rejects.toThrow("Cannot verify or remove the background service");
expect(fs.existsSync(paths.shimPath)).toBe(true);
expect(fs.existsSync(unitPath)).toBe(true);
});
it("rejects a symlinked installs root before npm writes outside the store", async () => {
const paths = resolveInstallStorePaths();
const outside = path.join(root, "outside");
fs.mkdirSync(paths.cliRoot, { recursive: true });
fs.mkdirSync(outside);
fs.symlinkSync(outside, paths.installsRoot, "dir");
const runCommand = vi.fn(async () => ({ stdout: JSON.stringify("2026.720.0"), stderr: "" }));
await expect(installCommand({}, { runCommand })).rejects.toThrow("non-directory install-store path");
expect(runCommand).toHaveBeenCalledTimes(1);
expect(fs.readdirSync(outside)).toEqual([]);
});
it("refuses to uninstall an unverified cli directory", async () => {
const paths = resolveInstallStorePaths();
const unrelatedFile = path.join(paths.cliRoot, "keep.txt");
fs.mkdirSync(paths.cliRoot, { recursive: true });
fs.writeFileSync(unrelatedFile, "keep");
await expect(uninstallCommand()).rejects.toThrow("unverified install store");
expect(fs.readFileSync(unrelatedFile, "utf8")).toBe("keep");
});
it("refuses to uninstall while another store mutation holds the lock", async () => {
const paths = resolveInstallStorePaths();
const payloadPath = payloadPathFor(paths, "npm", "2026.720.0");
initializeInstallStore(paths);
fs.mkdirSync(payloadPath, { recursive: true });
flipCurrentAtomic(payloadPath, paths);
writeInstallManifestAtomic({
schemaVersion: INSTALL_MANIFEST_VERSION,
source: "npm",
version: "2026.720.0",
channel: "latest",
payloadPath,
installedAt: "2026-07-22T18:00:00.000Z",
previous: [],
}, paths);
await withInstallStoreLock(
async () => {
await expect(uninstallCommand()).rejects.toThrow("already running");
},
paths,
);
expect(fs.existsSync(paths.lockPath)).toBe(false);
});
it("refuses a symlinked git payload root before downloading", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const outside = path.join(root, "outside-git"); fs.mkdirSync(outside);
fs.symlinkSync(outside, path.join(paths.installsRoot, "git"));
const runCommand = vi.fn(async () => ({ stdout: "", stderr: "" }));
await expect(installGitPayload("paperclipai/paperclip", "4".repeat(40), runCommand, paths)).rejects.toThrow("unsafe payload root");
expect(runCommand).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,206 @@
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,
MANAGED_SHIM_MARKER,
addManagedPathBlock,
buildNextManifest,
flipCurrentAtomic,
isManagedExecutable,
payloadPathFor,
pruneInstallPayloads,
readInstallManifest,
removeManagedPathBlock,
removeManagedShim,
resolveInstallStorePaths,
withInstallStoreLock,
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 with the validated runtime and custom store path", () => {
writeManagedShim(paths);
const shim = fs.readFileSync(paths.shimPath, "utf8");
expect(shim).toContain(process.execPath);
expect(shim).toContain(paths.currentPath);
expect(shim).not.toContain("PAPERCLIP_HOME");
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);
fs.chmodSync(rcPath, 0o640);
expect(removeManagedPathBlock(rcPath)).toBe(true);
expect(fs.readFileSync(rcPath, "utf8")).not.toContain("paperclipai managed PATH");
expect(fs.statSync(rcPath).mode & 0o777).toBe(0o640);
});
it("rejects marker substrings that are not the exact managed shim format", () => {
fs.mkdirSync(path.dirname(paths.shimPath), { recursive: true });
fs.writeFileSync(paths.shimPath, `#!/bin/sh\necho '${MANAGED_SHIM_MARKER}'\n`);
expect(removeManagedShim(paths)).toBe(false);
expect(fs.existsSync(paths.shimPath)).toBe(true);
expect(() => writeManagedShim(paths)).toThrow("non-managed command");
});
it("serializes install-store mutations with an exclusive lock", async () => {
await expect(
withInstallStoreLock(
() => withInstallStoreLock(async () => undefined, paths),
paths,
),
).rejects.toThrow("already running");
expect(fs.existsSync(paths.lockPath)).toBe(false);
});
it("recovers a lock owned by a process that no longer exists", async () => {
const staleToken = "2147483647:stale";
await withInstallStoreLock(async () => undefined, paths);
fs.writeFileSync(paths.lockPath, `${staleToken}\n`, { mode: 0o600 });
await expect(withInstallStoreLock(async () => undefined, paths)).resolves.toBeUndefined();
expect(fs.existsSync(paths.lockPath)).toBe(false);
});
it("reports managed provenance only for the payload selected by current", () => {
const manifestPayload = payloadPathFor(paths, "npm", "1.0.0");
const currentPayload = payloadPathFor(paths, "npm", "2.0.0");
const executable = path.join(manifestPayload, "node_modules", "paperclipai", "dist", "index.js");
fs.mkdirSync(path.dirname(executable), { recursive: true });
fs.writeFileSync(executable, "");
fs.mkdirSync(currentPayload, { recursive: true });
flipCurrentAtomic(currentPayload, paths);
const manifest: InstallManifest = {
schemaVersion: INSTALL_MANIFEST_VERSION,
...record(manifestPayload, "1.0.0"),
previous: [],
};
expect(isManagedExecutable(executable, manifest, paths)).toBe(false);
});
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");
});
it("refuses symlinked rc files, unsafe shim parents, and multiply linked shims", () => {
const outsideRc = path.join(root, "outside-rc");
fs.writeFileSync(outsideRc, "keep\n");
const rcPath = path.join(root, "home", ".bashrc");
fs.mkdirSync(path.dirname(rcPath), { recursive: true });
fs.symlinkSync(outsideRc, rcPath);
expect(() => addManagedPathBlock(rcPath)).toThrow("non-regular shell rc file");
expect(() => removeManagedPathBlock(rcPath)).toThrow("non-regular shell rc file");
expect(fs.readFileSync(outsideRc, "utf8")).toBe("keep\n");
fs.rmSync(rcPath);
const localDir = path.join(root, "home", ".local");
const outsideBin = path.join(root, "outside-bin");
fs.mkdirSync(outsideBin);
fs.symlinkSync(outsideBin, localDir, "dir");
expect(() => writeManagedShim(paths)).toThrow("unsafe shim directory");
fs.rmSync(localDir);
fs.mkdirSync(path.dirname(paths.shimPath), { recursive: true });
fs.writeFileSync(paths.shimPath, `# ${MANAGED_SHIM_MARKER}\n`);
fs.linkSync(paths.shimPath, path.join(root, "linked-shim"));
expect(() => writeManagedShim(paths)).toThrow("multiply linked shim");
});
});

View File

@ -0,0 +1,88 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { managedInstallChecks } from "../checks/managed-install-check.js";
import {
MANAGED_STORE_MARKER,
buildNextManifest,
flipCurrentAtomic,
resolveInstallStorePaths,
writeInstallManifestAtomic,
writeManagedShim,
} from "../install-store.js";
const originalPath = process.env.PATH;
afterEach(() => {
process.env.PATH = originalPath;
});
describe("managed install doctor checks", () => {
it("passes for a consistent store, manifest, current link, shim, and PATH", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-install-doctor-"));
const paths = resolveInstallStorePaths({
paperclipHome: path.join(root, ".paperclip"),
homeDir: root,
});
const payloadPath = path.join(paths.installsRoot, "npm", "1.2.3");
fs.mkdirSync(path.join(payloadPath, "dist"), { recursive: true });
const manifest = buildNextManifest(
{
source: "npm",
version: "1.2.3",
channel: "latest",
payloadPath,
installedAt: "2026-07-22T00:00:00.000Z",
},
null,
);
flipCurrentAtomic(payloadPath, paths);
writeInstallManifestAtomic(manifest, paths);
writeManagedShim(paths);
process.env.PATH = `${path.dirname(paths.shimPath)}${path.delimiter}${originalPath ?? ""}`;
expect(managedInstallChecks(paths).every((result) => result.status === "pass")).toBe(true);
});
it("fails when managed artifacts exist without a manifest", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-install-doctor-"));
const paths = resolveInstallStorePaths({
paperclipHome: path.join(root, ".paperclip"),
homeDir: root,
});
fs.mkdirSync(paths.cliRoot, { recursive: true });
fs.writeFileSync(paths.markerPath, MANAGED_STORE_MARKER);
expect(managedInstallChecks(paths)).toEqual([
expect.objectContaining({ name: "Managed install manifest", status: "fail" }),
]);
});
it("ignores the shared CLI directory when it only contains update notice state", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-install-doctor-"));
const paths = resolveInstallStorePaths({
paperclipHome: path.join(root, ".paperclip"),
homeDir: root,
});
fs.mkdirSync(paths.cliRoot, { recursive: true });
fs.writeFileSync(path.join(paths.cliRoot, "update-check.json"), "{}\n");
expect(managedInstallChecks(paths)).toEqual([
expect.objectContaining({ name: "Managed install", status: "pass" }),
]);
});
it("ignores an empty installs directory left by a harmless lock lifecycle", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-install-doctor-"));
const paths = resolveInstallStorePaths({
paperclipHome: path.join(root, ".paperclip"),
homeDir: root,
});
fs.mkdirSync(paths.installsRoot, { recursive: true });
expect(managedInstallChecks(paths)).toEqual([
expect.objectContaining({ name: "Managed install", status: "pass" }),
]);
});
});

View File

@ -0,0 +1,84 @@
import { describe, expect, it, vi } from "vitest";
import { handleOnboardService } from "../onboard-service.js";
function supportedDetection() {
return {
supported: true as const,
manager: {
platform: "systemd" as const,
instanceId: "default",
serviceName: "paperclipai.service",
definitionPath: "/tmp/paperclipai.service",
renderDefinition: () => "unit",
install: vi.fn(async () => ({ changed: true })),
uninstall: vi.fn(async () => undefined),
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
restart: vi.fn(async () => undefined),
status: vi.fn(async () => ({
platform: "systemd" as const,
serviceName: "paperclipai.service",
installed: true,
active: true,
enabled: true,
pid: 123,
})),
logs: vi.fn(async () => undefined),
},
};
}
describe("onboard service policy", () => {
it("does not install during --yes onboarding without opt-in", async () => {
const detection = supportedDetection();
const info = vi.fn();
const installed = await handleOnboardService(
{ yes: true },
{ detect: vi.fn(async () => detection), isInteractive: () => false, info },
);
expect(installed).toBe(false);
expect(detection.manager.install).not.toHaveBeenCalled();
expect(info).toHaveBeenCalledWith(expect.stringContaining("--install-service"));
});
it("installs when --yes explicitly opts in", async () => {
const detection = supportedDetection();
const installed = await handleOnboardService(
{ yes: true, installService: true },
{ detect: vi.fn(async () => detection), isInteractive: () => false },
);
expect(installed).toBe(true);
expect(detection.manager.install).toHaveBeenCalledWith({ startNow: true, startOnLogin: true });
});
it("asks during interactive onboarding", async () => {
const detection = supportedDetection();
const confirm = vi.fn(async () => true);
const installed = await handleOnboardService(
{},
{ detect: vi.fn(async () => detection), isInteractive: () => true, confirm },
);
expect(confirm).toHaveBeenCalledOnce();
expect(installed).toBe(true);
});
it("silences the hint with --no-install-service", async () => {
const info = vi.fn();
const detect = vi.fn(async () => supportedDetection());
const installed = await handleOnboardService(
{ yes: true, installService: false },
{ detect, isInteractive: () => false, info },
);
expect(installed).toBe(false);
expect(detect).not.toHaveBeenCalled();
expect(info).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,142 @@
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 { serviceHealthChecks } from "../checks/service-health-check.js";
import { resolveRestartExpectedVersion, withHotRestartLock } from "../commands/service.js";
import type { PaperclipConfig } from "../config/schema.js";
import { buildLocalHealthUrl } from "../utils/health-url.js";
const config = {
server: { host: "127.0.0.1", port: 3100 },
} as PaperclipConfig;
let previousPaperclipHome: string | undefined;
let previousServiceManaged: string | undefined;
beforeEach(() => {
previousPaperclipHome = process.env.PAPERCLIP_HOME;
previousServiceManaged = process.env.PAPERCLIP_SERVICE_MANAGED;
process.env.PAPERCLIP_HOME = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-service-restart-"));
});
afterEach(() => {
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME;
else process.env.PAPERCLIP_HOME = previousPaperclipHome;
if (previousServiceManaged === undefined) delete process.env.PAPERCLIP_SERVICE_MANAGED;
else process.env.PAPERCLIP_SERVICE_MANAGED = previousServiceManaged;
});
function managerFixture(active = true) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-service-doctor-"));
const definitionPath = path.join(root, "paperclipai.service");
fs.writeFileSync(definitionPath, "unit");
return {
platform: "systemd" as const,
instanceId: "default",
serviceName: "paperclipai.service",
definitionPath,
renderDefinition: () => "unit",
install: vi.fn(async () => ({ changed: false })),
uninstall: vi.fn(async () => undefined),
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
restart: vi.fn(async () => undefined),
status: vi.fn(async () => ({
platform: "systemd" as const,
serviceName: "paperclipai.service",
installed: true,
active,
enabled: true,
pid: active ? 123 : null,
linger: true,
})),
logs: vi.fn(async () => undefined),
};
}
describe("service health doctor checks", () => {
it("skips live service checks during the managed unit's own activation", async () => {
process.env.PAPERCLIP_SERVICE_MANAGED = "1";
const detect = vi.fn();
const probe = vi.fn();
await expect(serviceHealthChecks(config, { detect, probe })).resolves.toEqual([]);
expect(detect).not.toHaveBeenCalled();
expect(probe).not.toHaveBeenCalled();
});
it("skips exact version matching unless a restart version is explicit", () => {
expect(resolveRestartExpectedVersion(null)).toBeNull();
expect(resolveRestartExpectedVersion(undefined)).toBeNull();
expect(resolveRestartExpectedVersion("1.2.3")).toBe("1.2.3");
});
it("serializes concurrent restarts for the same instance", async () => {
const order: string[] = [];
let releaseFirst!: () => void;
const firstBlocked = new Promise<void>((resolve) => { releaseFirst = resolve; });
const first = withHotRestartLock("default", async () => {
order.push("first-start");
await firstBlocked;
order.push("first-end");
}, { pollMs: 5 });
await vi.waitFor(() => expect(order).toEqual(["first-start"]));
const second = withHotRestartLock("default", async () => {
order.push("second-start");
}, { pollMs: 5 });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(order).toEqual(["first-start"]);
releaseFirst();
await Promise.all([first, second]);
expect(order).toEqual(["first-start", "first-end", "second-start"]);
});
it("reclaims restart locks left by terminated processes", async () => {
const lockPath = path.join(process.env.PAPERCLIP_HOME!, "instances", "default", "hot-restart.lock");
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
fs.writeFileSync(lockPath, "424242:stale-token\n");
const callback = vi.fn(async () => "restarted");
await expect(withHotRestartLock("default", callback, {
pollMs: 1,
timeoutMs: 20,
isProcessAlive: () => false,
})).resolves.toBe("restarted");
expect(callback).toHaveBeenCalledOnce();
expect(fs.existsSync(lockPath)).toBe(false);
});
it("brackets configured IPv6 hosts in health URLs", () => {
expect(buildLocalHealthUrl("::1", 3100)).toBe("http://[::1]:3100/api/health");
expect(buildLocalHealthUrl("::", 3100)).toBe("http://127.0.0.1:3100/api/health");
});
it("passes for a current, active, healthy service", async () => {
const manager = managerFixture();
const results = await serviceHealthChecks(config, {
detect: vi.fn(async () => ({ supported: true as const, manager })),
probe: vi.fn(async () => ({ ok: true, version: "1.2.3" })),
});
expect(results.every((result) => result.status === "pass")).toBe(true);
});
it("detects a foreground process on the configured port while the service is inactive", async () => {
const manager = managerFixture(false);
const results = await serviceHealthChecks(config, {
detect: vi.fn(async () => ({ supported: true as const, manager })),
probe: vi.fn(async () => ({ ok: true, version: "1.2.3" })),
});
expect(results).toContainEqual(
expect.objectContaining({
name: "Service runtime",
status: "fail",
message: expect.stringContaining("another Paperclip process"),
}),
);
});
});

View File

@ -0,0 +1,216 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
assertForegroundRunAllowed,
detectServiceManager,
LaunchdServiceManager,
renderLaunchdPlist,
renderSystemdUnit,
SystemdServiceManager,
type CommandRunner,
type ServiceManager,
} from "../services/service-manager.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
delete process.env.PAPERCLIP_SERVICE_MANAGED;
});
async function temporaryDirectory(): Promise<string> {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-service-test-"));
temporaryDirectories.push(directory);
return directory;
}
describe("service definition generation", () => {
it("generates a stable systemd notify unit without secrets", () => {
const unit = renderSystemdUnit({ instanceId: "team-a", shimPath: "/home/alice/.local/bin/paperclipai", homeDir: "/home/alice/.paperclip" });
expect(unit).toContain("Type=notify");
expect(unit).toContain("NotifyAccess=all");
expect(unit).toContain('ExecStart="/home/alice/.local/bin/paperclipai" run --instance "team-a"');
expect(unit).toContain("Restart=always");
expect(unit).toContain("TimeoutStopSec=300");
expect(unit).not.toContain("API_KEY");
});
it("escapes systemd variable and specifier expansion in configured values", () => {
const unit = renderSystemdUnit({
instanceId: "team-$USER-%i",
shimPath: "/home/$USER/%i/paperclipai",
homeDir: "/home/$USER/%i/.paperclip",
});
expect(unit).toContain('ExecStart="/home/$$USER/%%i/paperclipai" run --instance "team-$$USER-%%i"');
expect(unit).toContain('Environment="PAPERCLIP_HOME=/home/$$USER/%%i/.paperclip"');
});
it.each([
["instanceId", { instanceId: "team-a\nExecStartPre=/tmp/attack", shimPath: "/home/alice/.local/bin/paperclipai", homeDir: "/home/alice/.paperclip" }],
["shimPath", { instanceId: "team-a", shimPath: "/home/alice/bin/paperclipai\r\nExecStartPre=/tmp/attack", homeDir: "/home/alice/.paperclip" }],
["homeDir", { instanceId: "team-a", shimPath: "/home/alice/.local/bin/paperclipai", homeDir: "/home/alice/.paperclip\nEnvironment=ATTACK=1" }],
])("rejects line breaks in the systemd %s", (_field, input) => {
expect(() => renderSystemdUnit(input)).toThrow("Systemd service values must not contain line breaks");
});
it("generates a launchd agent with keepalive and instance logs", () => {
const plist = renderLaunchdPlist({ instanceId: "team-a", shimPath: "/Users/alice/.local/bin/paperclipai", homeDir: "/Users/alice/.paperclip", stdoutPath: "/Users/alice/.paperclip/instances/team-a/logs/service.log", stderrPath: "/Users/alice/.paperclip/instances/team-a/logs/service.err.log" });
expect(plist).toContain("ing.paperclip.paperclipai.team-a");
expect(plist).toContain("<key>RunAtLoad</key><true/>");
expect(plist).toContain("<key>KeepAlive</key><true/>");
expect(plist).toContain("service.err.log");
});
});
describe("systemd drift regeneration", () => {
it("rewrites a drifted unit and reloads the user manager", async () => {
const userHome = await temporaryDirectory();
const calls: string[] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args].join(" "));
return { stdout: "", stderr: "" };
};
const manager = new SystemdServiceManager("default", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
await fs.mkdir(path.dirname(manager.definitionPath), { recursive: true });
await fs.writeFile(manager.definitionPath, "stale\n", "utf8");
const result = await manager.install({ startNow: false, startOnLogin: false });
expect(result.changed).toBe(true);
expect(await fs.readFile(manager.definitionPath, "utf8")).toBe(manager.renderDefinition());
expect(calls).toContain("systemctl --user daemon-reload");
});
it("keeps the unit installed when stopping an active service fails", async () => {
const userHome = await temporaryDirectory();
const runner: CommandRunner = async (command, args) => {
if (args.includes("--property=LoadState,ActiveState,UnitFileState,MainPID")) return { stdout: "LoadState=loaded\nActiveState=active\nUnitFileState=enabled\nMainPID=42\n", stderr: "" };
if (command === "systemctl" && args.includes("stop")) throw new Error("stop failed");
return { stdout: "", stderr: "" };
};
const manager = new SystemdServiceManager("default", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
await fs.mkdir(path.dirname(manager.definitionPath), { recursive: true });
await fs.writeFile(manager.definitionPath, manager.renderDefinition(), "utf8");
await expect(manager.uninstall()).rejects.toThrow("stop failed");
await expect(fs.access(manager.definitionPath)).resolves.toBeUndefined();
});
});
describe("service adapter dispatch", () => {
it("selects launchd on macOS", async () => {
const detection = await detectServiceManager({ platform: "darwin", instanceId: "default" });
expect(detection.supported).toBe(true);
if (detection.supported) expect(detection.manager).toBeInstanceOf(LaunchdServiceManager);
});
it("selects systemd only when the user manager is reachable", async () => {
const runner: CommandRunner = async () => ({ stdout: "", stderr: "" });
const detection = await detectServiceManager({ platform: "linux", instanceId: "default", runner });
expect(detection.supported).toBe(true);
if (detection.supported) expect(detection.manager).toBeInstanceOf(SystemdServiceManager);
});
it("returns a foreground-run skip on unsupported hosts", async () => {
const runner: CommandRunner = async () => { throw new Error("no bus"); };
const detection = await detectServiceManager({ platform: "linux", instanceId: "default", runner });
expect(detection).toEqual({ supported: false, reason: expect.stringContaining("paperclipai run") });
});
});
describe("launchd lifecycle", () => {
it("starts without changing the saved login preference", async () => {
const userHome = await temporaryDirectory();
const calls: string[] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args].join(" "));
if (args[0] === "print-disabled") return { stdout: `\"ing.paperclip.paperclipai.team-a\" => true`, stderr: "" };
return { stdout: "", stderr: "" };
};
const manager = new LaunchdServiceManager("team-a", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
await manager.start();
expect(calls).toContain(`launchctl disable gui/${process.getuid?.() ?? 0}/ing.paperclip.paperclipai.team-a`);
expect(calls).not.toContain(`launchctl enable gui/${process.getuid?.() ?? 0}/ing.paperclip.paperclipai.team-a`);
});
it("preserves disabled state when the service name contains regex metacharacters", async () => {
const userHome = await temporaryDirectory();
const calls: string[] = [];
const serviceName = "ing.paperclip.paperclipai.team[qa]+";
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args].join(" "));
if (args[0] === "print-disabled") return { stdout: `"${serviceName}" => true`, stderr: "" };
return { stdout: "", stderr: "" };
};
const manager = new LaunchdServiceManager("team[qa]+", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
await manager.start();
expect(calls).toContain(`launchctl disable gui/${process.getuid?.() ?? 0}/${serviceName}`);
expect(calls).not.toContain(`launchctl enable gui/${process.getuid?.() ?? 0}/${serviceName}`);
});
it("disables login startup and unloads the keepalive job when stopped", async () => {
const userHome = await temporaryDirectory();
const calls: string[] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args].join(" "));
return { stdout: "", stderr: "" };
};
const manager = new LaunchdServiceManager("team-a", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
await manager.install({ startNow: false, startOnLogin: false });
await manager.stop();
expect(calls).toContain(`launchctl disable gui/${process.getuid?.() ?? 0}/ing.paperclip.paperclipai.team-a`);
expect(calls).toContain(`launchctl bootout gui/${process.getuid?.() ?? 0}/ing.paperclip.paperclipai.team-a`);
expect(calls.some((call) => call.includes("launchctl kill"))).toBe(false);
});
it("disables login startup when uninstalled", async () => {
const userHome = await temporaryDirectory();
const calls: string[] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args].join(" "));
return { stdout: "", stderr: "" };
};
const manager = new LaunchdServiceManager("team-a", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
await manager.uninstall();
expect(calls).toContain(`launchctl disable gui/${process.getuid?.() ?? 0}/ing.paperclip.paperclipai.team-a`);
});
});
describe("single-writer guard", () => {
const activeManager = { status: async () => ({ active: true, serviceName: "paperclipai.service" }) } as unknown as ServiceManager;
const detector = async () => ({ supported: true as const, manager: activeManager });
it("refuses a second foreground writer", async () => {
await expect(assertForegroundRunAllowed("default", false, detector)).rejects.toThrow("already running");
});
it("allows an explicit force override", async () => {
await expect(assertForegroundRunAllowed("default", true, detector)).resolves.toBeUndefined();
});
it("allows the supervisor-owned process", async () => {
process.env.PAPERCLIP_SERVICE_MANAGED = "1";
await expect(assertForegroundRunAllowed("default", false, detector)).resolves.toBeUndefined();
});
it("refuses to replace a symlinked service definition", async () => {
const userHome = await temporaryDirectory();
const manager = new SystemdServiceManager("default", async () => ({ stdout: "", stderr: "" }), path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
await fs.mkdir(path.dirname(manager.definitionPath), { recursive: true });
const target = path.join(userHome, "target.service"); await fs.writeFile(target, "preserve\n"); await fs.symlink(target, manager.definitionPath);
await expect(manager.install({ startNow: false, startOnLogin: false })).rejects.toThrow("unsafe service definition");
expect(await fs.readFile(target, "utf8")).toBe("preserve\n");
});
});

View File

@ -0,0 +1,240 @@
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 { flipCurrentAtomic, initializeInstallStore, payloadPathFor, readInstallManifest, resolveInstallStorePaths, writeInstallManifestAtomic, type InstallManifest, type InstallRecord } from "../install-store.js";
import type { CommandRunner } from "../commands/install.js";
import { compareVersions, detectInstallMode, resolveUpdateRequest, rollbackManagedInstall, updateCommand } from "../commands/update.js";
let root: string;
let previousHome: string | undefined;
let previousPaperclipHome: string | undefined;
function record(payloadPath: string, version: string, channel: "latest" | "canary" | "pinned" = "latest"): InstallRecord {
return { source: "npm", version, channel, payloadPath, installedAt: `2026-07-22T00:00:0${version}.000Z` };
}
function createPayload(payloadPath: string, version: string): string {
const entrypoint = path.join(payloadPath, "node_modules", "paperclipai", "dist", "index.js");
fs.mkdirSync(path.dirname(entrypoint), { recursive: true });
fs.writeFileSync(entrypoint, version);
return entrypoint;
}
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-update-"));
previousHome = process.env.HOME;
previousPaperclipHome = process.env.PAPERCLIP_HOME;
process.env.HOME = path.join(root, "home");
process.env.PAPERCLIP_HOME = path.join(root, "paperclip");
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome;
if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousPaperclipHome;
fs.rmSync(root, { recursive: true, force: true });
process.exitCode = undefined;
});
describe("update command", () => {
it("orders SemVer prerelease identifiers numerically", () => {
expect(compareVersions("1.0.0-canary.10", "1.0.0-canary.2")).toBeGreaterThan(0);
expect(compareVersions("1.0.0-1", "1.0.0-alpha")).toBeLessThan(0);
expect(compareVersions("1.0.0-alpha", "1.0.0-alpha.1")).toBeLessThan(0);
});
it("detects managed, global npm, npx, and source modes", () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const payload = payloadPathFor(paths, "npm", "1.0.0"); const entrypoint = createPayload(payload, "1.0.0");
flipCurrentAtomic(payload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, ...record(payload, "1.0.0"), previous: [] }, paths);
expect(detectInstallMode(entrypoint, paths)).toBe("managed");
expect(detectInstallMode(path.join(root, "lib", "node_modules", "paperclipai", "dist", "index.js"), paths)).toBe("global-npm");
expect(detectInstallMode(path.join(root, ".npm", "_npx", "abc", "node_modules", "paperclipai", "dist", "index.js"), paths)).toBe("npx");
const source = path.join(root, "source"); fs.mkdirSync(path.join(source, ".git"), { recursive: true });
expect(detectInstallMode(path.join(source, "cli", "src", "index.ts"), paths)).toBe("source");
});
it("resolves channels and keeps pinned installs pinned by default", () => {
const manifest = { channel: "pinned", version: "1.2.3" } as InstallManifest;
expect(resolveUpdateRequest(manifest, {})).toEqual({ spec: "1.2.3", channel: "pinned", explicit: false });
expect(resolveUpdateRequest(manifest, { latest: true })).toEqual({ spec: "latest", channel: "latest", explicit: true });
expect(() => resolveUpdateRequest(manifest, { latest: true, canary: true })).toThrow("only one");
});
it("re-resolves a moving git branch and activates the new SHA payload", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const oldSha = "1".repeat(40); const newSha = "2".repeat(40);
const oldPayload = payloadPathFor(paths, "git", oldSha.slice(0, 12));
const executable = createPayload(oldPayload, "0.3.1");
fs.writeFileSync(path.join(oldPayload, "node_modules", "paperclipai", "package.json"), JSON.stringify({ version: "0.3.1" }));
const newPayload = payloadPathFor(paths, "git", newSha.slice(0, 12));
createPayload(newPayload, "0.3.1");
fs.writeFileSync(path.join(newPayload, "node_modules", "paperclipai", "package.json"), JSON.stringify({ version: "0.3.1" }));
flipCurrentAtomic(oldPayload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, source: "git", version: "0.3.1", channel: "pinned", repo: "paperclipai/paperclip", ref: "master", sha: oldSha, payloadPath: oldPayload, installedAt: "2026-07-22T00:00:00.000Z", previous: [] }, paths);
const backup = vi.fn(async () => undefined);
const confirm = vi.fn(async () => true);
const restartActiveService = vi.fn(async () => true);
const runCommand = vi.fn(async (file: string) => file === "curl" ? { stdout: JSON.stringify({ sha: newSha }), stderr: "" } : { stdout: "0.3.1\n", stderr: "" });
await updateCommand({}, { paths, executablePath: executable, runCommand, backup, confirm, restartActiveService, hasInstanceData: () => true, now: () => new Date("2026-07-22T12:00:00Z") });
expect(confirm).toHaveBeenCalledWith(expect.stringContaining(`commit ${newSha.slice(0, 12)}`));
expect(backup).toHaveBeenCalledOnce();
expect(restartActiveService).toHaveBeenCalledWith("0.3.1");
expect(readInstallManifest(paths)?.sha).toBe(newSha);
expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(newPayload));
});
it("reports SHA git installs as pinned without resolving again", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const sha = "3".repeat(40); const payload = payloadPathFor(paths, "git", sha.slice(0, 12)); const executable = createPayload(payload, "0.3.1");
flipCurrentAtomic(payload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, source: "git", version: "0.3.1", channel: "pinned", repo: "paperclipai/paperclip", ref: sha.slice(0, 12), sha, payloadPath: payload, installedAt: "2026-07-22T00:00:00.000Z", previous: [] }, paths);
const runCommand = vi.fn(async () => ({ stdout: "", stderr: "" }));
await updateCommand({}, { paths, executablePath: executable, runCommand });
expect(runCommand).not.toHaveBeenCalled();
});
it("requires explicit confirmation before downgrading", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const payload = payloadPathFor(paths, "npm", "2.0.0"); const entrypoint = createPayload(payload, "2.0.0"); flipCurrentAtomic(payload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, ...record(payload, "2.0.0"), previous: [] }, paths);
const runCommand = vi.fn(async () => ({ stdout: '"1.0.0"\n', stderr: "" }));
await expect(updateCommand({ version: "1.0.0", dryRun: true }, { paths, executablePath: entrypoint, runCommand, confirm: async () => false })).rejects.toThrow("Downgrade cancelled");
});
it("requires explicit confirmation before a global npm downgrade", async () => {
const paths = resolveInstallStorePaths();
const executable = path.join(root, "lib", "node_modules", "paperclipai", "dist", "index.js");
const runCommand = vi.fn(async () => ({ stdout: '"0.2.0"\n', stderr: "" }));
await expect(updateCommand({ version: "0.2.0" }, { paths, executablePath: executable, runCommand, confirm: async () => false })).rejects.toThrow("Downgrade cancelled");
expect(runCommand).toHaveBeenCalledTimes(1);
});
it("isolates global npm updates from hostile registry configuration", async () => {
const paths = resolveInstallStorePaths();
const executable = path.join(root, "lib", "node_modules", "paperclipai", "dist", "index.js");
vi.stubEnv("NPM_CONFIG_REGISTRY", "http://attacker-registry.invalid");
fs.mkdirSync(process.env.HOME!, { recursive: true });
fs.writeFileSync(path.join(process.env.HOME!, ".npmrc"), "registry=http://attacker-registry.invalid\n");
const runCommand = vi.fn<CommandRunner>(async (_file, args, commandOptions) => {
if (args[0] === "view") return { stdout: '"2.0.0"\n', stderr: "" };
expect(args).toContain("--registry=https://registry.npmjs.org");
expect(args).toContain("--@paperclipai:registry=https://registry.npmjs.org");
expect(commandOptions?.env?.NPM_CONFIG_REGISTRY).toBe("https://registry.npmjs.org");
expect(commandOptions?.env?.npm_config_registry).toBe("https://registry.npmjs.org");
expect(commandOptions?.env?.NPM_CONFIG_USERCONFIG).toBe(commandOptions?.env?.npm_config_userconfig);
expect(fs.readFileSync(commandOptions!.env!.NPM_CONFIG_USERCONFIG!, "utf8")).toContain("registry=https://registry.npmjs.org");
return { stdout: "", stderr: "" };
});
await updateCommand({}, { paths, executablePath: executable, runCommand });
expect(runCommand).toHaveBeenCalledTimes(2);
});
it("backs up, installs side-by-side, flips, and rolls back instantly", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); const executable = createPayload(oldPayload, "1.0.0"); flipCurrentAtomic(oldPayload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, ...record(oldPayload, "1.0.0"), previous: [] }, paths);
const backup = vi.fn(async () => undefined);
const restartActiveService = vi.fn(async () => true);
const runCommand = vi.fn(async (file: string, args: string[]) => {
if (args[0] === "view") return { stdout: '"2.0.0"\n', stderr: "" };
if (file === "npm" && args[0] === "install") { const prefix = args[args.indexOf("--prefix") + 1]; createPayload(prefix, "2.0.0"); return { stdout: "", stderr: "" }; }
return { stdout: "2.0.0\n", stderr: "" };
});
await updateCommand({}, { paths, executablePath: executable, runCommand, backup, restartActiveService, hasInstanceData: () => true, now: () => new Date("2026-07-22T12:00:00Z") });
expect(backup).toHaveBeenCalledOnce();
expect(restartActiveService).toHaveBeenCalledWith("2.0.0");
expect(readInstallManifest(paths)?.version).toBe("2.0.0");
expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(payloadPathFor(paths, "npm", "2.0.0")));
const rolledBack = rollbackManagedInstall(paths);
expect(rolledBack.version).toBe("1.0.0");
expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(oldPayload));
});
it("explains how to recover when the pre-update database is unreachable", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); const executable = createPayload(oldPayload, "1.0.0"); flipCurrentAtomic(oldPayload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, ...record(oldPayload, "1.0.0"), previous: [] }, paths);
const backupError = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1:54329"), { code: "ECONNREFUSED" });
const backup = vi.fn(async () => { throw backupError; });
const runCommand = vi.fn(async () => ({ stdout: '"2.0.0"\n', stderr: "" }));
await expect(updateCommand({}, { paths, executablePath: executable, runCommand, backup, hasInstanceData: () => true })).rejects.toThrow(
"Start the service with `paperclipai service start` and retry, or skip the backup with `paperclipai update --no-backup`.",
);
expect(backup).toHaveBeenCalledOnce();
expect(readInstallManifest(paths)?.version).toBe("1.0.0");
});
it("skips the pre-update backup when there is no onboarded instance data", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); const executable = createPayload(oldPayload, "1.0.0"); flipCurrentAtomic(oldPayload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, ...record(oldPayload, "1.0.0"), previous: [] }, paths);
const backup = vi.fn(async () => undefined);
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
const runCommand = vi.fn(async (file: string, args: string[]) => {
if (args[0] === "view") return { stdout: '"2.0.0"\n', stderr: "" };
if (file === "npm" && args[0] === "install") { createPayload(args[args.indexOf("--prefix") + 1], "2.0.0"); return { stdout: "", stderr: "" }; }
return { stdout: "2.0.0\n", stderr: "" };
});
await updateCommand({}, { paths, executablePath: executable, runCommand, backup, restartActiveService: async () => false, hasInstanceData: () => false });
expect(backup).not.toHaveBeenCalled();
expect(log).toHaveBeenCalledWith(expect.stringContaining("has not been onboarded and has no data to back up"));
expect(readInstallManifest(paths)?.version).toBe("2.0.0");
});
it("does not inherit a managed pin for global npm updates", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const managedPayload = payloadPathFor(paths, "npm", "1.2.3"); createPayload(managedPayload, "1.2.3");
writeInstallManifestAtomic({ schemaVersion: 1, ...record(managedPayload, "1.2.3"), channel: "pinned", previous: [] }, paths);
const executable = path.join(root, "lib", "node_modules", "paperclipai", "dist", "index.js");
const runCommand = vi.fn(async (_file: string, args: string[]) => args[0] === "view" ? { stdout: '"2.0.0"\n', stderr: "" } : { stdout: "", stderr: "" });
await updateCommand({ dryRun: true }, { paths, executablePath: executable, runCommand });
expect(runCommand).toHaveBeenCalledWith("npm", expect.arrayContaining(["view", "paperclipai@latest"]), expect.anything());
});
it("rolls back the active payload when restart validation fails", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); const executable = createPayload(oldPayload, "1.0.0"); flipCurrentAtomic(oldPayload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, ...record(oldPayload, "1.0.0"), previous: [] }, paths);
const runCommand = vi.fn(async (file: string, args: string[]) => {
if (args[0] === "view") return { stdout: '"2.0.0"\n', stderr: "" };
if (file === "npm" && args[0] === "install") { createPayload(args[args.indexOf("--prefix") + 1], "2.0.0"); return { stdout: "", stderr: "" }; }
return { stdout: "2.0.0\n", stderr: "" };
});
const restartActiveService = vi.fn(async (version: string) => { if (version === "2.0.0") throw new Error("health timeout"); return true; });
await expect(updateCommand({}, { paths, executablePath: executable, runCommand, backup: async () => undefined, restartActiveService })).rejects.toThrow("rolled back to 1.0.0");
expect(readInstallManifest(paths)?.version).toBe("1.0.0");
expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(oldPayload));
expect(restartActiveService).toHaveBeenLastCalledWith("1.0.0");
});
it("surfaces a failure to restart the rolled-back payload", async () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); const executable = createPayload(oldPayload, "1.0.0"); flipCurrentAtomic(oldPayload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, ...record(oldPayload, "1.0.0"), previous: [] }, paths);
const runCommand = vi.fn(async (file: string, args: string[]) => {
if (args[0] === "view") return { stdout: '"2.0.0"\n', stderr: "" };
if (file === "npm" && args[0] === "install") { createPayload(args[args.indexOf("--prefix") + 1], "2.0.0"); return { stdout: "", stderr: "" }; }
return { stdout: "2.0.0\n", stderr: "" };
});
const restartActiveService = vi.fn(async (version: string) => {
throw new Error(version === "2.0.0" ? "health timeout" : "rollback restart failed");
});
await expect(updateCommand({}, {
paths,
executablePath: executable,
runCommand,
backup: async () => undefined,
restartActiveService,
})).rejects.toThrow("rolled-back service also failed to restart");
expect(readInstallManifest(paths)?.version).toBe("1.0.0");
expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(oldPayload));
expect(restartActiveService).toHaveBeenNthCalledWith(1, "2.0.0");
expect(restartActiveService).toHaveBeenNthCalledWith(2, "1.0.0");
});
});

View File

@ -0,0 +1,20 @@
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 { checkForUpdateNotice, isUpdateNoticeEnabled } from "../update-notice.js";
let root: string; let previous: string | undefined;
beforeEach(() => { root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-notice-")); previous = process.env.PAPERCLIP_UPDATE_CHECK; delete process.env.PAPERCLIP_UPDATE_CHECK; });
afterEach(() => { if (previous === undefined) delete process.env.PAPERCLIP_UPDATE_CHECK; else process.env.PAPERCLIP_UPDATE_CHECK = previous; fs.rmSync(root, { recursive: true, force: true }); });
describe("update notice", () => {
it("honors the environment and config kill switches", () => {
process.env.PAPERCLIP_UPDATE_CHECK = "0"; expect(isUpdateNoticeEnabled(path.join(root, "missing.json"))).toBe(false);
delete process.env.PAPERCLIP_UPDATE_CHECK; const config = path.join(root, "config.json"); fs.writeFileSync(config, JSON.stringify({ updates: { checkEnabled: false } })); expect(isUpdateNoticeEnabled(config)).toBe(false);
});
it("throttles registry checks for 24 hours", async () => {
const cachePath = path.join(root, "cache.json"); const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ "dist-tags": { latest: "99.0.0" } }), { status: 200 }));
expect(await checkForUpdateNotice({ cachePath, now: 1000, fetchImpl })).toBe("99.0.0");
expect(await checkForUpdateNotice({ cachePath, now: 2000, fetchImpl })).toBe("99.0.0");
expect(fetchImpl).toHaveBeenCalledOnce();
});
});

View File

@ -16,3 +16,5 @@ export { logCheck } from "./log-check.js";
export { portCheck } from "./port-check.js";
export { secretsCheck } from "./secrets-check.js";
export { storageCheck } from "./storage-check.js";
export { managedInstallChecks, nodeRuntimeCheck } from "./managed-install-check.js";
export { serviceHealthChecks } from "./service-health-check.js";

View File

@ -0,0 +1,168 @@
import fs from "node:fs";
import path from "node:path";
import {
MANAGED_SHIM_MARKER,
readInstallManifest,
resolveInstallStorePaths,
type InstallStorePaths,
} from "../install-store.js";
import type { CheckResult } from "./index.js";
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 hasManagedArtifacts(paths: InstallStorePaths): boolean {
const persistentArtifacts = [
paths.manifestPath,
paths.markerPath,
paths.currentPath,
paths.shimPath,
].some((entry) => fs.existsSync(entry));
if (persistentArtifacts) return true;
try {
return fs.readdirSync(paths.installsRoot).length > 0;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
return true;
}
}
export function nodeRuntimeCheck(): CheckResult {
const major = Number.parseInt(process.versions.node.split(".")[0] ?? "0", 10);
return major >= 20
? { name: "Node.js runtime", status: "pass", message: `Node.js ${process.versions.node}` }
: {
name: "Node.js runtime",
status: "fail",
message: `Node.js ${process.versions.node} is unsupported`,
repairHint: "Install Node.js 20 or newer before installing or running Paperclip",
};
}
export function managedInstallChecks(
paths = resolveInstallStorePaths(),
): CheckResult[] {
if (!hasManagedArtifacts(paths)) {
return [
{
name: "Managed install",
status: "pass",
message: "Not present (optional for npx, global npm, and source-checkout usage)",
},
];
}
let manifest;
try {
manifest = readInstallManifest(paths);
} catch (error) {
return [
{
name: "Managed install manifest",
status: "fail",
message: error instanceof Error ? error.message : String(error),
repairHint: "Re-run `paperclipai install` to rebuild the managed install metadata",
},
];
}
if (!manifest) {
return [
{
name: "Managed install manifest",
status: "fail",
message: `Managed install artifacts exist but ${paths.manifestPath} is missing`,
repairHint: "Re-run `paperclipai install`",
},
];
}
const results: CheckResult[] = [];
const payloadPath = path.resolve(manifest.payloadPath);
const relativePayload = path.relative(paths.installsRoot, payloadPath);
const payloadInStore = Boolean(relativePayload) && !relativePayload.startsWith("..") && !path.isAbsolute(relativePayload);
const payloadExists = payloadInStore && fs.existsSync(payloadPath) && fs.statSync(payloadPath).isDirectory();
let currentMatches = false;
try {
currentMatches = fs.lstatSync(paths.currentPath).isSymbolicLink()
&& fs.realpathSync(paths.currentPath) === fs.realpathSync(payloadPath);
} catch {
currentMatches = false;
}
results.push(
payloadExists && currentMatches
? {
name: "Managed install store",
status: "pass",
message: `${manifest.source} ${manifest.version} is active`,
}
: {
name: "Managed install store",
status: "fail",
message: !payloadExists
? `Manifest payload is missing or outside the install store: ${manifest.payloadPath}`
: `Current link does not point to ${manifest.payloadPath}`,
repairHint: "Re-run `paperclipai install` or roll back to a retained payload",
},
);
let shimValid = false;
try {
shimValid = fs.readFileSync(paths.shimPath, "utf8").includes(MANAGED_SHIM_MARKER);
} catch {
shimValid = false;
}
results.push(
shimValid
? { name: "Managed install shim", status: "pass", message: paths.shimPath }
: {
name: "Managed install shim",
status: "fail",
message: `Missing or unrecognized shim at ${paths.shimPath}`,
repairHint: "Re-run `paperclipai install`",
},
);
const shimDirectory = path.dirname(paths.shimPath);
results.push(
pathContains(shimDirectory)
? { name: "Managed install PATH", status: "pass", message: `${shimDirectory} is on PATH` }
: {
name: "Managed install PATH",
status: "warn",
message: `${shimDirectory} is not on PATH`,
repairHint: 'Run `export PATH="$HOME/.local/bin:$PATH"` and add it to your shell startup file',
},
);
const retained = new Set(
[manifest, ...manifest.previous].map((record) => path.resolve(record.payloadPath)),
);
const orphaned: string[] = [];
for (const source of ["npm", "git"] as const) {
const sourceRoot = path.join(paths.installsRoot, source);
if (!fs.existsSync(sourceRoot)) continue;
for (const entry of fs.readdirSync(sourceRoot)) {
const candidate = path.join(sourceRoot, entry);
if (!entry.startsWith(".") && !retained.has(path.resolve(candidate))) orphaned.push(candidate);
}
}
results.push(
orphaned.length === 0
? { name: "Managed install retention", status: "pass", message: "No orphaned payloads" }
: {
name: "Managed install retention",
status: "warn",
message: `${orphaned.length} orphaned payload${orphaned.length === 1 ? "" : "s"} found`,
repairHint: "A successful `paperclipai update` prunes unretained payloads",
},
);
return results;
}

View File

@ -0,0 +1,136 @@
import fs from "node:fs/promises";
import type { PaperclipConfig } from "../config/schema.js";
import { resolvePaperclipInstanceId } from "../config/home.js";
import { readInstallManifest } from "../install-store.js";
import {
detectServiceManager,
type ServiceManagerDetection,
} from "../services/service-manager.js";
import { buildLocalHealthUrl } from "../utils/health-url.js";
import type { CheckResult } from "./index.js";
type HealthResult = { ok: boolean; version: string | null; error?: string };
type ServiceCheckDependencies = {
detect: (instanceId: string) => Promise<ServiceManagerDetection>;
probe: (config: PaperclipConfig) => Promise<HealthResult>;
};
async function probeHealth(config: PaperclipConfig): Promise<HealthResult> {
try {
const response = await fetch(buildLocalHealthUrl(config.server.host, config.server.port), {
signal: AbortSignal.timeout(2_000),
});
const body = (await response.json()) as {
status?: unknown;
serverVersion?: unknown;
version?: unknown;
};
const version = typeof body.serverVersion === "string"
? body.serverVersion
: typeof body.version === "string"
? body.version
: null;
return { ok: response.ok && body.status === "ok", version };
} catch (error) {
return { ok: false, version: null, error: error instanceof Error ? error.message : String(error) };
}
}
export async function serviceHealthChecks(
config: PaperclipConfig,
dependencies: Partial<ServiceCheckDependencies> = {},
): Promise<CheckResult[]> {
if (process.env.PAPERCLIP_SERVICE_MANAGED === "1") return [];
const deps: ServiceCheckDependencies = {
detect: (instanceId) => detectServiceManager({ instanceId }),
probe: probeHealth,
...dependencies,
};
const instanceId = resolvePaperclipInstanceId();
const detection = await deps.detect(instanceId);
if (!detection.supported) {
return [{ name: "Background service", status: "pass", message: detection.reason }];
}
const manager = detection.manager;
const status = await manager.status();
if (!status.installed) {
return [
{
name: "Background service",
status: "pass",
message: `Not installed for instance ${instanceId} (optional)`,
},
];
}
const results: CheckResult[] = [];
let definitionCurrent = false;
try {
definitionCurrent = (await fs.readFile(manager.definitionPath, "utf8")) === manager.renderDefinition();
} catch {
definitionCurrent = false;
}
results.push(
definitionCurrent
? { name: "Service definition", status: "pass", message: manager.definitionPath }
: {
name: "Service definition",
status: "fail",
message: `Missing or drifted definition at ${manager.definitionPath}`,
repairHint: "Run `paperclipai service install` to regenerate the service definition",
},
);
const health = await deps.probe(config);
results.push(
status.active
? { name: "Service runtime", status: "pass", message: `${status.serviceName} is active` }
: {
name: "Service runtime",
status: "fail",
message: health.ok
? `${status.serviceName} is inactive but the configured port is serving another Paperclip process`
: `${status.serviceName} is ${status.detail ?? "inactive"}`,
repairHint: "Run `paperclipai service start`, or stop the conflicting foreground process first",
},
);
let expectedVersion: string | null = null;
try {
expectedVersion = readInstallManifest()?.version ?? null;
} catch {}
results.push(
!health.ok
? {
name: "Service health",
status: "fail",
message: health.error ?? "Health endpoint did not report ok",
repairHint: "Inspect `paperclipai service status` and `paperclipai service logs`",
}
: expectedVersion && health.version !== expectedVersion
? {
name: "Service version",
status: "fail",
message: `Running ${health.version ?? "unknown"}; managed install is ${expectedVersion}`,
repairHint: "Run `paperclipai service restart --expected-version " + expectedVersion + "`",
}
: {
name: "Service health",
status: "pass",
message: `Healthy${health.version ? ` at version ${health.version}` : ""}`,
},
);
if (status.enabled && status.linger === false) {
results.push({
name: "Service linger",
status: "warn",
message: "Start-on-login is enabled but systemd user lingering is off",
repairHint: "Re-run `paperclipai service install --enable-linger` if the service must survive logout",
});
}
return results;
}

View File

@ -9,13 +9,17 @@ import {
deploymentAuthCheck,
llmCheck,
logCheck,
managedInstallChecks,
nodeRuntimeCheck,
portCheck,
secretsCheck,
serviceHealthChecks,
storageCheck,
type CheckResult,
} from "../checks/index.js";
import { loadPaperclipEnvFile } from "../config/env.js";
import { printPaperclipCliBanner } from "../utils/banner.js";
import { printUpdateNotice } from "../update-notice.js";
const STATUS_ICON = {
pass: pc.green("✓"),
@ -28,6 +32,7 @@ export async function doctor(opts: {
repair?: boolean;
yes?: boolean;
}): Promise<{ passed: number; warned: number; failed: number }> {
await printUpdateNotice(opts.config);
printPaperclipCliBanner();
p.intro(pc.bgCyan(pc.black(" paperclip doctor ")));
@ -120,6 +125,21 @@ export async function doctor(opts: {
results.push(portResult);
printResult(portResult);
// 10. Runtime and managed install checks
const nodeResult = nodeRuntimeCheck();
results.push(nodeResult);
printResult(nodeResult);
for (const result of managedInstallChecks()) {
results.push(result);
printResult(result);
}
// 11. Background service checks
for (const result of await serviceHealthChecks(config)) {
results.push(result);
printResult(result);
}
// Summary
return printSummary(results);
}

420
cli/src/commands/install.ts Normal file
View File

@ -0,0 +1,420 @@
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 {
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;
}
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}`);
}
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.`);
}

View File

@ -43,6 +43,8 @@ import {
trackInstallStarted,
trackInstallCompleted,
} from "../telemetry.js";
import { handleOnboardService } from "../onboard-service.js";
import { readInstallManifest, isManagedExecutable } from "../install-store.js";
type SetupMode = "quickstart" | "advanced";
@ -52,6 +54,7 @@ type OnboardOptions = {
yes?: boolean;
invokedByRun?: boolean;
bind?: BindMode;
installService?: boolean;
};
type OnboardDefaults = Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets">;
@ -322,6 +325,21 @@ function canCreateBootstrapInviteImmediately(config: Pick<PaperclipConfig, "data
return config.server.deploymentMode === "authenticated" && config.database.mode !== "embedded-postgres";
}
export function isEphemeralNpxExecution(entrypoint = process.argv[1]): boolean {
if (!entrypoint) return false;
const normalized = entrypoint.replaceAll("\\", "/");
return normalized.includes("/_npx/") || normalized.includes("/npm/_npx/");
}
function printManagedInstallHint(): void {
const manifest = readInstallManifest();
if (manifest && isManagedExecutable(process.argv[1], manifest)) return;
if (!isEphemeralNpxExecution()) return;
p.log.info(
`This npx run is temporary. Use ${pc.cyan("paperclipai install")} for atomic updates, rollback, and service support.`,
);
}
export async function onboard(opts: OnboardOptions): Promise<void> {
if (opts.bind && !["loopback", "lan", "tailnet"].includes(opts.bind)) {
throw new Error(`Unsupported bind preset for onboard: ${opts.bind}. Use loopback, lan, or tailnet.`);
@ -400,7 +418,10 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
"Next commands",
);
let shouldRunNow = opts.run === true || opts.yes === true;
printManagedInstallHint();
const serviceInstalled = await handleOnboardService(opts);
let shouldRunNow = !serviceInstalled && (opts.run === true || opts.yes === true);
if (!shouldRunNow && !opts.invokedByRun && process.stdin.isTTY && process.stdout.isTTY) {
const answer = await p.confirm({
message: "Start Paperclip now?",
@ -655,12 +676,16 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
"Next commands",
);
printManagedInstallHint();
if (canCreateBootstrapInviteImmediately({ database, server })) {
p.log.step("Generating bootstrap CEO invite");
await bootstrapCeoInvite({ config: configPath });
}
let shouldRunNow = opts.run === true || opts.yes === true;
const serviceInstalled = await handleOnboardService(opts);
let shouldRunNow = !serviceInstalled && (opts.run === true || opts.yes === true);
if (!shouldRunNow && !opts.invokedByRun && process.stdin.isTTY && process.stdout.isTTY) {
const answer = await p.confirm({
message: "Start Paperclip now?",

View File

@ -16,6 +16,8 @@ import {
resolvePaperclipHomeDir,
resolvePaperclipInstanceId,
} from "../config/home.js";
import { assertForegroundRunAllowed } from "../services/service-manager.js";
import { printUpdateNotice } from "../update-notice.js";
interface RunOptions {
config?: string;
@ -23,6 +25,7 @@ interface RunOptions {
repair?: boolean;
yes?: boolean;
bind?: "loopback" | "lan" | "tailnet";
force?: boolean;
}
interface StartedServer {
@ -35,6 +38,7 @@ interface StartedServer {
export async function runCommand(opts: RunOptions): Promise<void> {
const instanceId = resolvePaperclipInstanceId(opts.instance);
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
await assertForegroundRunAllowed(instanceId, opts.force);
const homeDir = resolvePaperclipHomeDir();
fs.mkdirSync(homeDir, { recursive: true });
@ -45,6 +49,7 @@ export async function runCommand(opts: RunOptions): Promise<void> {
const configPath = resolveConfigPath(opts.config);
process.env.PAPERCLIP_CONFIG = configPath;
loadPaperclipEnvFile(configPath);
await printUpdateNotice(configPath);
p.intro(pc.bgCyan(pc.black(" paperclipai run ")));
p.log.message(pc.dim(`Home: ${paths.homeDir}`));

223
cli/src/commands/service.ts Normal file
View File

@ -0,0 +1,223 @@
import fs from "node:fs/promises";
import path from "node:path";
import * as p from "@clack/prompts";
import type { Command } from "commander";
import { readConfig, resolveConfigPath } from "../config/store.js";
import { resolvePaperclipInstanceId, resolvePaperclipInstanceRoot } from "../config/home.js";
import { detectServiceManager, type ServiceManager, type ServiceStatus } from "../services/service-manager.js";
import { buildLocalHealthUrl } from "../utils/health-url.js";
type CommonOptions = { instance?: string; json?: boolean };
type HealthResult = { ok: boolean; serverVersion: string | null; error?: string };
function output(value: unknown, json: boolean | undefined): void {
if (json) console.log(JSON.stringify(value, null, 2));
else if (typeof value === "string") console.log(value);
else console.log(JSON.stringify(value, null, 2));
}
async function resolveManager(opts: CommonOptions): Promise<ServiceManager | null> {
const detection = await detectServiceManager({ instanceId: opts.instance });
if (detection.supported) return detection.manager;
output({ supported: false, message: detection.reason }, opts.json);
return null;
}
function healthUrl(instanceId: string): string {
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
const config = readConfig(resolveConfigPath());
return buildLocalHealthUrl(config?.server.host, config?.server.port ?? 3100);
}
async function probeHealth(instanceId: string): Promise<HealthResult> {
try {
const response = await fetch(healthUrl(instanceId), { signal: AbortSignal.timeout(2_000) });
const body = await response.json() as { status?: unknown; serverVersion?: unknown; version?: unknown };
return { ok: response.ok && body.status === "ok", serverVersion: typeof body.serverVersion === "string" ? body.serverVersion : typeof body.version === "string" ? body.version : null };
} catch (error) {
return { ok: false, serverVersion: null, error: error instanceof Error ? error.message : String(error) };
}
}
async function waitForHealth(instanceId: string, expectedVersion: string | null, timeoutMs = 60_000): Promise<HealthResult> {
const deadline = Date.now() + timeoutMs;
let last: HealthResult = { ok: false, serverVersion: null };
while (Date.now() < deadline) {
last = await probeHealth(instanceId);
if (last.ok && (!expectedVersion || last.serverVersion === expectedVersion)) return last;
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Paperclip service did not become healthy${expectedVersion ? ` at version ${expectedVersion}` : ""}: ${last.error ?? `reported ${last.serverVersion ?? "no version"}`}`);
}
export function resolveRestartExpectedVersion(expectedVersion: string | null | undefined): string | null {
return expectedVersion ?? null;
}
export async function withHotRestartLock<T>(
instanceId: string,
callback: () => Promise<T>,
options: { timeoutMs?: number; pollMs?: number; isProcessAlive?: (pid: number) => boolean } = {},
): Promise<T> {
const instanceRoot = resolvePaperclipInstanceRoot(instanceId);
const lockPath = path.join(instanceRoot, "hot-restart.lock");
const token = `${process.pid}:${Date.now()}:${Math.random().toString(16).slice(2)}`;
const deadline = Date.now() + (options.timeoutMs ?? 120_000);
const pollMs = options.pollMs ?? 100;
const isProcessAlive = options.isProcessAlive ?? ((pid: number) => {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code !== "ESRCH";
}
});
await fs.mkdir(instanceRoot, { recursive: true });
while (true) {
try {
await fs.writeFile(lockPath, `${token}\n`, { encoding: "utf8", mode: 0o600, flag: "wx" });
break;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
try {
const existingToken = (await fs.readFile(lockPath, "utf8")).trim();
const ownerPid = Number.parseInt(existingToken.split(":", 1)[0] ?? "", 10);
if (Number.isInteger(ownerPid) && ownerPid > 0 && !isProcessAlive(ownerPid)) {
if ((await fs.readFile(lockPath, "utf8")).trim() === existingToken) {
await fs.rm(lockPath, { force: true });
continue;
}
}
} catch (readError) {
if ((readError as NodeJS.ErrnoException).code === "ENOENT") continue;
throw readError;
}
if (Date.now() >= deadline) {
throw new Error(
`Another restart for instance ${instanceId} is still running. ` +
`If no restart process is active, remove the stale lock at ${lockPath} and retry.`,
);
}
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
}
try {
return await callback();
} finally {
try {
if ((await fs.readFile(lockPath, "utf8")).trim() === token) {
await fs.rm(lockPath, { force: true });
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
}
}
async function writeHotRestartIntent(status: ServiceStatus, instanceId: string, drainRequired: boolean): Promise<{ requestedAt: string }> {
if (!status.pid) throw new Error(`Cannot restart ${status.serviceName}: supervisor did not report a server pid.`);
const health = await probeHealth(instanceId);
const instanceRoot = resolvePaperclipInstanceRoot(instanceId);
const requestedAt = new Date().toISOString();
await fs.mkdir(instanceRoot, { recursive: true });
await fs.rm(path.join(instanceRoot, "hot-restart-report.json"), { force: true });
await fs.writeFile(path.join(instanceRoot, "hot-restart-intent.json"), `${JSON.stringify({
version: 1,
requestedAt,
previousServerPid: status.pid,
previousServerVersion: health.serverVersion,
drainRequired,
requestedByRunId: process.env.PAPERCLIP_RUN_ID?.trim() || null,
}, null, 2)}\n`, "utf8");
return { requestedAt };
}
async function waitForRestartReport(instanceId: string, requestedAt: string, timeoutMs = 10_000): Promise<unknown | null> {
const reportPath = path.join(resolvePaperclipInstanceRoot(instanceId), "hot-restart-report.json");
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const report = JSON.parse(await fs.readFile(reportPath, "utf8")) as { requestedAt?: unknown };
if (report.requestedAt === requestedAt) return report;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
return null;
}
export async function restartManagedService(input: { instanceId?: string; expectedVersion?: string | null; waitForDrain?: boolean } = {}): Promise<{ status: ServiceStatus; health: HealthResult; report: unknown | null }> {
const instanceId = resolvePaperclipInstanceId(input.instanceId);
return withHotRestartLock(instanceId, async () => {
const detection = await detectServiceManager({ instanceId });
if (!detection.supported) throw new Error(detection.reason);
const before = await detection.manager.status();
const intent = await writeHotRestartIntent(before, instanceId, input.waitForDrain ?? false);
await detection.manager.restart();
const health = await waitForHealth(instanceId, resolveRestartExpectedVersion(input.expectedVersion));
return { status: await detection.manager.status(), health, report: await waitForRestartReport(instanceId, intent.requestedAt) };
});
}
export function registerServiceCommands(program: Command): void {
const service = program.command("service").description("Manage Paperclip as a background service");
const common = (command: Command) => command.option("-i, --instance <id>", "Local instance id (default: default)").option("--json", "Print machine-readable JSON", false);
common(service.command("install").description("Install and register the background service"))
.option("--no-start-now", "Install without starting now")
.option("--no-start-on-login", "Install without enabling start on login")
.option("--enable-linger", "Allow systemd startup without an active login session", false)
.action(async (opts) => {
const manager = await resolveManager(opts); if (!manager) return;
const result = await manager.install({ startNow: opts.startNow, startOnLogin: opts.startOnLogin });
let lingerEnabled = false;
if (manager.enableLinger) {
let consent = opts.enableLinger === true;
if (!consent && process.stdin.isTTY && process.stdout.isTTY) {
consent = await p.confirm({ message: "Allow Paperclip to run without an active login session? This runs 'loginctl enable-linger' for your user and may request system authorization.", initialValue: false }) === true;
}
if (consent) { await manager.enableLinger(); lingerEnabled = true; }
}
output({ installed: true, changed: result.changed, platform: manager.platform, serviceName: manager.serviceName, definitionPath: manager.definitionPath, lingerEnabled }, opts.json);
});
common(service.command("uninstall").description("Stop, disable, and remove the background service")).action(async (opts) => {
const manager = await resolveManager(opts); if (!manager) return;
await manager.uninstall();
const status = await manager.status();
if (status.installed || status.active) throw new Error(`${manager.serviceName} is still loaded after uninstall.`);
output({ uninstalled: true, serviceName: manager.serviceName }, opts.json);
});
for (const verb of ["start", "stop"] as const) {
common(service.command(verb).description(`${verb === "start" ? "Start" : "Stop"} the background service`)).action(async (opts) => {
const manager = await resolveManager(opts); if (!manager) return;
await manager[verb]();
output(await manager.status(), opts.json);
});
}
common(service.command("restart").description("Hot-restart the service while preserving active agent runs"))
.option("--wait", "Wait for active runs to drain instead of adopting them", false)
.option("--expected-version <version>", "Require the restarted server to report this version")
.action(async (opts) => output(await restartManagedService({ instanceId: opts.instance, expectedVersion: opts.expectedVersion, waitForDrain: opts.wait }), opts.json));
common(service.command("status").description("Show supervisor and health status")).action(async (opts) => {
const manager = await resolveManager(opts); if (!manager) return;
const instanceId = resolvePaperclipInstanceId(opts.instance);
output({ ...await manager.status(), health: await probeHealth(instanceId) }, opts.json);
});
common(service.command("logs").description("Show service logs"))
.option("-f, --follow", "Follow new log output", false)
.option("-n, --lines <count>", "Number of recent lines", "100")
.action(async (opts) => {
const manager = await resolveManager(opts); if (!manager) return;
const lines = Number.parseInt(opts.lines, 10);
if (!Number.isInteger(lines) || lines < 1) throw new Error("--lines must be a positive integer.");
await manager.logs(opts.follow, lines);
});
}

View File

@ -0,0 +1,90 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import pc from "picocolors";
import {
assertManagedInstallStore,
removeManagedPathBlock,
removeManagedShim,
resolveInstallStorePaths,
withInstallStoreLock,
} from "../install-store.js";
import { resolvePaperclipInstanceId } from "../config/home.js";
import { detectServiceManager, launchdServiceName, systemdServiceName } from "../services/service-manager.js";
type UninstallDependencies = {
detectServiceManager: typeof detectServiceManager;
platform: NodeJS.Platform;
userHomeDir: string;
};
function otherServiceDefinitions(platform: NodeJS.Platform, userHomeDir: string, instanceId: string): string[] {
const directory = platform === "linux"
? path.join(userHomeDir, ".config", "systemd", "user")
: platform === "darwin"
? path.join(userHomeDir, "Library", "LaunchAgents")
: null;
if (!directory || !fs.existsSync(directory)) return [];
const currentName = platform === "linux"
? systemdServiceName(instanceId)
: `${launchdServiceName(instanceId)}.plist`;
const pattern = platform === "linux"
? /^paperclipai(?:-.+)?\.service$/
: /^ing\.paperclip\.paperclipai(?:\..+)?\.plist$/;
return fs.readdirSync(directory)
.filter((name) => name !== currentName && pattern.test(name))
.map((name) => path.join(directory, name));
}
export async function uninstallCommand(
dependencies: Partial<UninstallDependencies> = {},
): Promise<void> {
const instanceId = resolvePaperclipInstanceId();
const detect = dependencies.detectServiceManager ?? detectServiceManager;
const platform = dependencies.platform ?? process.platform;
const userHomeDir = dependencies.userHomeDir ?? os.homedir();
const detection = await detect({ instanceId, platform });
const otherDefinitions = otherServiceDefinitions(platform, userHomeDir, instanceId);
if (otherDefinitions.length > 0) {
throw new Error(`Cannot remove the shared managed CLI while other instance services are installed: ${otherDefinitions.join(", ")}. Uninstall those services first.`);
}
if (!detection.supported && platform === "linux") {
const definitionPath = path.join(
userHomeDir,
".config",
"systemd",
"user",
systemdServiceName(instanceId),
);
if (fs.existsSync(definitionPath)) {
throw new Error(
`Cannot verify or remove the background service: ${detection.reason}. Retry when the service manager is available.`,
);
}
}
if (detection.supported) {
const status = await detection.manager.status();
if (status.installed || status.active) await detection.manager.uninstall();
}
const paths = resolveInstallStorePaths();
const hadStore = fs.existsSync(paths.cliRoot);
if (hadStore) assertManagedInstallStore(paths);
const shimRemoved = await withInstallStoreLock(async () => {
if (hadStore) assertManagedInstallStore(paths);
const removed = removeManagedShim(paths);
const home = process.env.HOME;
for (const rcFile of home ? [path.join(home, ".bashrc"), path.join(home, ".zshrc")] : []) {
removeManagedPathBlock(rcFile);
}
fs.rmSync(paths.cliRoot, { recursive: true, force: true });
return removed;
}, paths, { initialize: !hadStore });
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}.`));
}

268
cli/src/commands/update.ts Normal file
View File

@ -0,0 +1,268 @@
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 { buildNextManifest, flipCurrentAtomic, isManagedExecutable, pruneInstallPayloads, readInstallManifest, resolveInstallStorePaths, withInstallStoreLock, writeInstallManifestAtomic, type InstallChannel, type InstallManifest, type InstallRecord, type InstallStorePaths } from "../install-store.js";
import { dbBackupCommand } from "./db-backup.js";
import { installGitPayload, installNpmPayload, PUBLIC_NPM_REGISTRY, resolveGitHubRef, resolvePublishedVersion, type CommandRunner } from "./install.js";
import { resolvePaperclipInstanceId, resolvePaperclipInstanceRoot } from "../config/home.js";
import { resolveConfigPath } from "../config/store.js";
import { detectServiceManager } from "../services/service-manager.js";
import { restartManagedService } from "./service.js";
import { packageVersion } from "../version.js";
const execFileAsync = promisify(execFile);
export type InstallMode = "managed" | "global-npm" | "npx" | "source" | "unknown";
export type UpdateOptions = { canary?: boolean; latest?: boolean; version?: string; rollback?: boolean; check?: boolean; dryRun?: boolean; json?: boolean; yes?: boolean; backup?: boolean };
type Dependencies = { executablePath: string; runCommand: CommandRunner; backup: () => Promise<void>; confirm: (message: string) => Promise<boolean>; now: () => Date; paths: InstallStorePaths; restartActiveService: (expectedVersion: string) => Promise<boolean>; hasInstanceData: () => boolean };
const DATABASE_UNREACHABLE_CODES = new Set(["ECONNREFUSED", "EHOSTUNREACH", "ENETUNREACH", "ETIMEDOUT"]);
function hasPaperclipInstanceData(): boolean {
return Boolean(process.env.DATABASE_URL?.trim())
|| fs.existsSync(resolveConfigPath())
|| fs.existsSync(resolvePaperclipInstanceRoot());
}
function isDatabaseUnreachableError(error: unknown): boolean {
const pending = [error];
const seen = new Set<unknown>();
while (pending.length > 0) {
const current = pending.pop();
if (current === null || current === undefined || seen.has(current)) continue;
seen.add(current);
if (typeof current === "string") {
if (/\b(?:ECONNREFUSED|EHOSTUNREACH|ENETUNREACH|ETIMEDOUT)\b|connection refused/i.test(current)) return true;
continue;
}
if (typeof current !== "object") continue;
const record = current as Record<string, unknown>;
if (typeof record.code === "string" && DATABASE_UNREACHABLE_CODES.has(record.code)) return true;
if (typeof record.message === "string" && /\b(?:ECONNREFUSED|EHOSTUNREACH|ENETUNREACH|ETIMEDOUT)\b|connection refused/i.test(record.message)) return true;
if (record.cause !== undefined) pending.push(record.cause);
if (Array.isArray(record.errors)) pending.push(...record.errors);
}
return false;
}
async function runPreUpdateBackup(options: UpdateOptions, backup: () => Promise<void>, hasInstanceData = hasPaperclipInstanceData): Promise<void> {
if (!hasInstanceData()) {
const message = "Skipping the pre-update backup because this Paperclip instance has not been onboarded and has no data to back up.";
if (options.json) console.error(message); else console.log(pc.yellow(message));
return;
}
try {
await backup();
} catch (error) {
if (isDatabaseUnreachableError(error)) {
throw new Error(
"The Paperclip database is not running or reachable, so the pre-update backup cannot be taken. Start the service with `paperclipai service start` and retry, or skip the backup with `paperclipai update --no-backup`.",
{ cause: error },
);
}
throw error;
}
}
async function restartActiveManagedService(expectedVersion: string): Promise<boolean> {
const instanceId = resolvePaperclipInstanceId();
const detection = await detectServiceManager({ instanceId });
if (!detection.supported || !(await detection.manager.status()).active) return false;
await restartManagedService({ instanceId, expectedVersion });
return true;
}
export function detectInstallMode(executablePath = process.argv[1] ?? "", paths = resolveInstallStorePaths()): InstallMode {
const resolved = path.resolve(executablePath || ".");
const manifest = readInstallManifest(paths);
if (manifest && isManagedExecutable(resolved, manifest, paths)) return "managed";
const normalized = resolved.split(path.sep).join("/");
if (normalized.includes("/.npm/_npx/") || normalized.includes("/node_modules/.cache/npx/")) return "npx";
if (normalized.includes("/node_modules/paperclipai/")) return "global-npm";
let cursor = path.dirname(resolved);
while (cursor !== path.dirname(cursor)) {
if (fs.existsSync(path.join(cursor, ".git"))) return "source";
cursor = path.dirname(cursor);
}
return "unknown";
}
export function compareVersions(left: string, right: string): number {
const parse = (value: string) => { const [core, prerelease = ""] = value.replace(/^v/, "").split("-", 2); return { numbers: core.split(".").map((part) => Number(part) || 0), prerelease }; };
const a = parse(left); const b = parse(right);
for (let index = 0; index < Math.max(a.numbers.length, b.numbers.length); index += 1) { const delta = (a.numbers[index] ?? 0) - (b.numbers[index] ?? 0); if (delta !== 0) return Math.sign(delta); }
if (a.prerelease === b.prerelease) return 0;
if (!a.prerelease) return 1;
if (!b.prerelease) return -1;
const aParts = a.prerelease.split(".");
const bParts = b.prerelease.split(".");
for (let index = 0; index < Math.max(aParts.length, bParts.length); index += 1) {
const leftPart = aParts[index];
const rightPart = bParts[index];
if (leftPart === undefined) return -1;
if (rightPart === undefined) return 1;
if (leftPart === rightPart) continue;
const leftNumeric = /^\d+$/.test(leftPart);
const rightNumeric = /^\d+$/.test(rightPart);
if (leftNumeric && rightNumeric) return Math.sign(Number(leftPart) - Number(rightPart));
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
return leftPart < rightPart ? -1 : 1;
}
return 0;
}
export function resolveUpdateRequest(manifest: InstallManifest | null, options: Pick<UpdateOptions, "canary" | "latest" | "version">): { spec: string; channel: InstallChannel; explicit: boolean } {
const selected = Number(Boolean(options.canary)) + Number(Boolean(options.latest)) + Number(Boolean(options.version));
if (selected > 1) throw new Error("Choose only one of --latest, --canary, or --version.");
if (options.version) return { spec: options.version.trim(), channel: "pinned", explicit: true };
if (options.canary) return { spec: "canary", channel: "canary", explicit: true };
if (options.latest) return { spec: "latest", channel: "latest", explicit: true };
if (manifest?.channel === "pinned") return { spec: manifest.version, channel: "pinned", explicit: false };
const channel = manifest?.channel === "canary" ? "canary" : "latest";
return { spec: channel, channel, explicit: false };
}
export function rollbackManagedInstall(paths = resolveInstallStorePaths()): InstallManifest {
const manifest = readInstallManifest(paths);
if (!manifest) throw new Error("No managed install was found to roll back.");
const target = manifest.previous[0];
if (!target) throw new Error("No previous managed payload is available for rollback.");
if (!fs.existsSync(target.payloadPath)) throw new Error(`Previous payload is missing: ${target.payloadPath}`);
const current: InstallRecord = { source: manifest.source, version: manifest.version, channel: manifest.channel, payloadPath: manifest.payloadPath, repo: manifest.repo, ref: manifest.ref, sha: manifest.sha, installedAt: manifest.installedAt };
const next: InstallManifest = { schemaVersion: manifest.schemaVersion, ...target, previous: [current, ...manifest.previous.slice(1)].slice(0, 2) };
const oldTarget = fs.readlinkSync(paths.currentPath);
flipCurrentAtomic(target.payloadPath, paths);
try { writeInstallManifestAtomic(next, paths); } catch (error) { flipCurrentAtomic(path.resolve(paths.cliRoot, oldTarget), paths); throw error; }
return next;
}
async function defaultConfirm(message: string): Promise<boolean> {
if (!process.stdin.isTTY || !process.stdout.isTTY) return false;
const answer = await p.confirm({ message, initialValue: false });
return !p.isCancel(answer) && answer === true;
}
function emit(options: UpdateOptions, value: Record<string, unknown>, message: string): void { if (options.json) console.log(JSON.stringify(value, null, 2)); else console.log(message); }
async function rollbackAfterServiceValidationFailure(
paths: InstallStorePaths,
restartActiveService: (expectedVersion: string) => Promise<boolean>,
validationError: unknown,
payloadLabel: string,
): Promise<never> {
const rolledBack = await withInstallStoreLock(async () => rollbackManagedInstall(paths), paths);
try {
await restartActiveService(rolledBack.version);
} catch (restartError) {
throw new Error(
`${payloadLabel} failed service validation and was rolled back to ${rolledBack.version}, but the rolled-back service also failed to restart.`,
{ cause: new AggregateError([validationError, restartError]) },
);
}
throw new Error(`${payloadLabel} failed service validation and was rolled back to ${rolledBack.version}.`, { cause: validationError });
}
export async function updateCommand(options: UpdateOptions, overrides: Partial<Dependencies> = {}): Promise<void> {
const paths = overrides.paths ?? resolveInstallStorePaths();
const executablePath = overrides.executablePath ?? process.argv[1] ?? "";
const runCommand = overrides.runCommand ?? execFileAsync;
const mode = detectInstallMode(executablePath, paths);
const manifest = readInstallManifest(paths);
if (options.rollback) {
if (mode !== "managed") throw new Error("--rollback is only available for managed installs.");
if (options.dryRun) { emit(options, { mode, action: "rollback", dryRun: true, target: manifest?.previous[0]?.version ?? null }, `Would roll back to ${manifest?.previous[0]?.version ?? "the previous payload"}.`); return; }
const next = await withInstallStoreLock(async () => rollbackManagedInstall(paths), paths);
const restarted = await (overrides.restartActiveService ?? restartActiveManagedService)(next.version);
emit(options, { mode, action: "rollback", version: next.version, restarted }, pc.green(`Rolled back to paperclipai ${next.version}${restarted ? " and restarted the active service" : ""}. Database migrations are not reversed; restore the pre-update backup if needed.`));
return;
}
if (mode === "npx") { emit(options, { mode, action: "install" }, "This is an ephemeral npx install. Run `paperclipai install`, then use `paperclipai update` from the managed shim."); return; }
if (mode === "source" || mode === "unknown") { emit(options, { mode, action: "manual" }, "This appears to be a source checkout. Update it with `git pull` followed by `pnpm install`; Paperclip will not mutate the repository."); return; }
const request = resolveUpdateRequest(mode === "managed" ? manifest : null, options);
if (mode === "managed" && manifest?.source === "git") {
if (!manifest.repo || !manifest.ref || !manifest.sha) throw new Error("Managed git install metadata is incomplete.");
if (/^[0-9a-f]{7,40}$/i.test(manifest.ref)) { emit(options, { mode, source: "git", pinned: true, sha: manifest.sha }, `Git install is pinned at ${manifest.sha.slice(0, 12)}.`); return; }
const targetSha = await resolveGitHubRef(manifest.repo, manifest.ref, runCommand);
if (targetSha === manifest.sha) { emit(options, { mode, source: "git", changed: false, sha: targetSha, ref: manifest.ref }, `${manifest.repo}@${manifest.ref} is already at ${targetSha.slice(0, 12)}.`); return; }
if (options.check || options.dryRun) { emit(options, { mode, source: "git", changed: true, currentSha: manifest.sha, targetSha, ref: manifest.ref, dryRun: Boolean(options.dryRun) }, `Git update available: ${manifest.sha.slice(0, 12)}${targetSha.slice(0, 12)}.`); if (options.check) process.exitCode = 10; return; }
if (options.yes !== true) {
const confirmed = await (overrides.confirm ?? defaultConfirm)(`Update from ${manifest.repo}@${manifest.ref} and execute build scripts from commit ${targetSha.slice(0, 12)}?`);
if (!confirmed) throw new Error("Git update cancelled. Re-run with --yes to confirm executing build scripts from the updated commit.");
}
if (options.backup !== false) await runPreUpdateBackup(options, overrides.backup ?? (() => dbBackupCommand({})), overrides.hasInstanceData);
const installed = await withInstallStoreLock(async () => {
const payload = await installGitPayload(manifest.repo!, targetSha, runCommand, paths);
const record: InstallRecord = { source: "git", version: payload.version, channel: "pinned", repo: manifest.repo, ref: manifest.ref, sha: targetSha, payloadPath: payload.payloadPath, installedAt: (overrides.now?.() ?? new Date()).toISOString() };
const next = buildNextManifest(record, manifest); const oldTarget = fs.readlinkSync(paths.currentPath); flipCurrentAtomic(payload.payloadPath, paths);
try { writeInstallManifestAtomic(next, paths); } catch (error) { flipCurrentAtomic(path.resolve(paths.cliRoot, oldTarget), paths); throw error; }
pruneInstallPayloads(next, paths); return payload;
}, paths);
let restarted: boolean;
try {
restarted = await (overrides.restartActiveService ?? restartActiveManagedService)(installed.version);
} catch (error) {
return rollbackAfterServiceValidationFailure(
paths,
overrides.restartActiveService ?? restartActiveManagedService,
error,
"Updated git payload",
);
}
emit(options, { mode, source: "git", changed: true, currentSha: manifest.sha, targetSha, reused: installed.reused, restarted }, pc.yellow(`Updated unreleased git payload ${manifest.sha.slice(0, 12)}${targetSha.slice(0, 12)} from ${manifest.repo}@${manifest.ref}${restarted ? " and restarted the active service" : ""}.`));
return;
}
const targetVersion = await resolvePublishedVersion(request.spec, runCommand);
const currentVersion = manifest?.version ?? (mode === "global-npm" ? packageVersion : undefined);
const comparison = currentVersion ? compareVersions(targetVersion, currentVersion) : 1;
if (options.check) { emit(options, { mode, currentVersion: currentVersion ?? null, targetVersion, updateAvailable: comparison > 0, downgrade: comparison < 0, channel: request.channel }, comparison > 0 ? `Update available: ${targetVersion}` : comparison < 0 ? `Target ${targetVersion} is older than ${currentVersion}.` : `paperclipai ${targetVersion} is current.`); if (comparison > 0) process.exitCode = 10; return; }
if (mode === "global-npm") {
if (comparison < 0 && options.yes !== true) { const confirmed = await (overrides.confirm ?? defaultConfirm)(`Downgrade paperclipai from ${currentVersion} to ${targetVersion}?`); if (!confirmed) throw new Error("Downgrade cancelled. Re-run with --yes to confirm explicitly."); }
const args = ["install", "-g", `paperclipai@${targetVersion}`, `--registry=${PUBLIC_NPM_REGISTRY}`, `--@paperclipai:registry=${PUBLIC_NPM_REGISTRY}`]; console.log(`Running: npm ${args.join(" ")}`);
if (!options.dryRun) {
const npmConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-npm-"));
const npmUserConfigPath = path.join(npmConfigDir, "npmrc");
try {
fs.writeFileSync(npmUserConfigPath, `registry=${PUBLIC_NPM_REGISTRY}\n@paperclipai:registry=${PUBLIC_NPM_REGISTRY}\n`, { mode: 0o600 });
await runCommand("npm", args, {
env: {
...process.env,
npm_config_registry: PUBLIC_NPM_REGISTRY,
NPM_CONFIG_REGISTRY: PUBLIC_NPM_REGISTRY,
npm_config_userconfig: npmUserConfigPath,
NPM_CONFIG_USERCONFIG: npmUserConfigPath,
},
maxBuffer: 16 * 1024 * 1024,
});
} finally {
fs.rmSync(npmConfigDir, { recursive: true, force: true });
}
}
emit(options, { mode, action: "update", targetVersion, dryRun: Boolean(options.dryRun), command: ["npm", ...args] }, options.dryRun ? "Dry run complete." : pc.green(`Updated global npm install to ${targetVersion}.`)); return;
}
if (!manifest) throw new Error("Managed install metadata is missing.");
if (comparison === 0) { emit(options, { mode, currentVersion, targetVersion, changed: false }, `paperclipai ${targetVersion} is already active.`); return; }
if (comparison < 0 && options.yes !== true) { const confirmed = await (overrides.confirm ?? defaultConfirm)(`Downgrade paperclipai from ${currentVersion} to ${targetVersion}?`); if (!confirmed) throw new Error("Downgrade cancelled. Re-run with --yes to confirm explicitly."); }
if (options.dryRun) { emit(options, { mode, currentVersion, targetVersion, action: comparison < 0 ? "downgrade" : "update", backup: options.backup !== false, dryRun: true }, `Would ${comparison < 0 ? "downgrade" : "update"} paperclipai ${currentVersion}${targetVersion}${options.backup === false ? " without a backup" : " after a database backup"}.`); return; }
if (options.backup !== false) await runPreUpdateBackup(options, overrides.backup ?? (() => dbBackupCommand({})), overrides.hasInstanceData);
const installed = await withInstallStoreLock(async () => {
const payload = await installNpmPayload(targetVersion, runCommand, paths);
const record: InstallRecord = { source: "npm", version: targetVersion, channel: request.channel, payloadPath: payload.payloadPath, installedAt: (overrides.now?.() ?? new Date()).toISOString() };
const next = buildNextManifest(record, manifest); const oldTarget = fs.readlinkSync(paths.currentPath); flipCurrentAtomic(payload.payloadPath, paths);
try { writeInstallManifestAtomic(next, paths); } catch (error) { flipCurrentAtomic(path.resolve(paths.cliRoot, oldTarget), paths); throw error; }
pruneInstallPayloads(next, paths); return payload;
}, paths);
let restarted: boolean;
try {
restarted = await (overrides.restartActiveService ?? restartActiveManagedService)(targetVersion);
} catch (error) {
return rollbackAfterServiceValidationFailure(
paths,
overrides.restartActiveService ?? restartActiveManagedService,
error,
"Updated payload",
);
}
emit(options, { mode, currentVersion, targetVersion, changed: true, reused: installed.reused, restarted }, pc.green(`Updated paperclipai ${currentVersion}${targetVersion}${restarted ? " and restarted the active service" : ""}. Run \`paperclipai update --rollback\` for an instant payload rollback.`));
}

View File

@ -42,16 +42,52 @@ 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";
import { updateCommand } from "./commands/update.js";
import { registerServiceCommands } from "./commands/service.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("--ref <ref>", "Install a GitHub branch, tag, or commit SHA")
.option("--repo <owner/name>", "Override the GitHub repository used with --ref")
.option("-y, --yes", "Consent to git-ref code execution and supported shell PATH updates without prompting")
.action(installCommand);
program
.command("uninstall")
.description("Remove the managed CLI install while preserving user data")
.action(uninstallCommand);
program
.command("update")
.alias("upgrade")
.description("Check, update, or roll back the Paperclip CLI")
.option("--latest", "Switch to the latest stable channel")
.option("--canary", "Switch to the canary channel")
.option("--version <version>", "Install an exact published version")
.option("--rollback", "Flip back to the retained previous managed payload")
.option("--check", "Check for an available update without applying it")
.option("--dry-run", "Print the action without changing anything")
.option("--json", "Print machine-readable output")
.option("-y, --yes", "Confirm an explicit downgrade")
.option("--no-backup", "Skip the pre-update database backup")
.action(updateCommand);
program.hook("preAction", (_thisCommand, actionCommand) => {
const options = actionCommand.optsWithGlobals() as DataDirOptionLike;
const optionNames = new Set(actionCommand.options.map((option) => option.attributeName()));
@ -70,6 +106,8 @@ program
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.option("--bind <mode>", "Quickstart reachability preset (loopback, lan, tailnet)")
.option("-y, --yes", "Accept quickstart defaults (trusted local loopback unless --bind is set) and start immediately", false)
.option("--install-service", "Install and start the background service after onboarding")
.option("--no-install-service", "Do not install or suggest the background service")
.option("--run", "Start Paperclip immediately after saving config", false)
.action(onboard);
@ -130,9 +168,11 @@ const run = program
.option("--bind <mode>", "On first run, use onboarding reachability preset (loopback, lan, tailnet)")
.option("--repair", "Attempt automatic repairs during doctor", true)
.option("--no-repair", "Disable automatic repairs during doctor")
.option("--force", "Run even when the same instance is active under the service manager")
.action(runCommand);
registerRunCommands(run);
registerServiceCommands(program);
const heartbeat = program.command("heartbeat").description("Heartbeat utilities");

483
cli/src/install-store.ts Normal file
View File

@ -0,0 +1,483 @@
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;
}
}

View File

@ -0,0 +1,77 @@
import * as p from "@clack/prompts";
import pc from "picocolors";
import { resolvePaperclipInstanceId } from "./config/home.js";
import {
detectServiceManager,
type ServiceManagerDetection,
} from "./services/service-manager.js";
export type OnboardServiceOptions = {
yes?: boolean;
installService?: boolean;
};
type OnboardServiceDependencies = {
detect: (instanceId: string) => Promise<ServiceManagerDetection>;
confirm: () => Promise<boolean>;
confirmLinger: () => Promise<boolean>;
isInteractive: () => boolean;
info: (message: string) => void;
success: (message: string) => void;
warn: (message: string) => void;
};
const defaultDependencies: OnboardServiceDependencies = {
detect: (instanceId) => detectServiceManager({ instanceId }),
confirm: async () => {
const answer = await p.confirm({
message: "Install Paperclip as a background service?",
initialValue: true,
});
return !p.isCancel(answer) && answer === true;
},
confirmLinger: async () => {
const answer = await p.confirm({
message: "Allow Paperclip to keep running after logout? This may request system authorization.",
initialValue: false,
});
return !p.isCancel(answer) && answer === true;
},
isInteractive: () => process.stdin.isTTY === true && process.stdout.isTTY === true,
info: (message) => p.log.message(pc.dim(message)),
success: (message) => p.log.success(message),
warn: (message) => p.log.warn(message),
};
export async function handleOnboardService(
options: OnboardServiceOptions,
dependencies: Partial<OnboardServiceDependencies> = {},
): Promise<boolean> {
const deps = { ...defaultDependencies, ...dependencies };
if (options.installService === false) return false;
const explicitlyRequested = options.installService === true;
const canPrompt = options.yes !== true && deps.isInteractive();
if (!explicitlyRequested && !canPrompt) {
deps.info(
"Background service not installed. Use `paperclipai onboard --install-service` or `paperclipai service install` to opt in.",
);
return false;
}
const instanceId = resolvePaperclipInstanceId();
const detection = await deps.detect(instanceId);
if (!detection.supported) {
if (explicitlyRequested) deps.warn(detection.reason);
return false;
}
if (!explicitlyRequested && !(await deps.confirm())) return false;
await detection.manager.install({ startNow: true, startOnLogin: true });
if (!explicitlyRequested && detection.manager.enableLinger && await deps.confirmLinger()) {
await detection.manager.enableLinger();
}
deps.success(`Installed and started ${detection.manager.serviceName}.`);
return true;
}

View File

@ -0,0 +1,311 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { resolvePaperclipHomeDir, resolvePaperclipInstanceId } from "../config/home.js";
const execFileAsync = promisify(execFile);
export type ServicePlatform = "systemd" | "launchd";
export type ServiceStatus = {
platform: ServicePlatform;
serviceName: string;
installed: boolean;
active: boolean;
enabled: boolean;
pid: number | null;
detail?: string;
linger?: boolean | null;
};
export type ServiceInstallOptions = { startNow: boolean; startOnLogin: boolean };
export interface ServiceManager {
readonly platform: ServicePlatform;
readonly instanceId: string;
readonly serviceName: string;
readonly definitionPath: string;
renderDefinition(): string;
install(options: ServiceInstallOptions): Promise<{ changed: boolean }>;
uninstall(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
restart(): Promise<void>;
status(): Promise<ServiceStatus>;
logs(follow: boolean, lines: number): Promise<void>;
enableLinger?(): Promise<void>;
}
export type CommandResult = { stdout: string; stderr: string };
export type CommandRunner = (command: string, args: string[], options?: { inherit?: boolean }) => Promise<CommandResult>;
export const defaultCommandRunner: CommandRunner = async (command, args, options) => {
if (options?.inherit) {
await new Promise<void>((resolve, reject) => {
const child = execFile(command, args, { windowsHide: true }, (error) => error ? reject(error) : resolve());
child.stdout?.pipe(process.stdout);
child.stderr?.pipe(process.stderr);
});
return { stdout: "", stderr: "" };
}
const result = await execFileAsync(command, args, { encoding: "utf8", windowsHide: true });
return { stdout: result.stdout, stderr: result.stderr };
};
function escapeSystemd(value: string): string {
if (/\r|\n/.test(value)) {
throw new Error("Systemd service values must not contain line breaks");
}
return value
.replaceAll("\\", "\\\\")
.replaceAll('"', '\\"')
.replaceAll("$", () => "$$")
.replaceAll("%", "%%");
}
function escapeXml(value: string): string {
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function resolveServiceShimPath(homeDir = os.homedir()): string {
return process.env.PAPERCLIP_SHIM_PATH?.trim() || path.join(homeDir, ".local", "bin", "paperclipai");
}
export function systemdServiceName(instanceId: string): string {
return instanceId === "default" ? "paperclipai.service" : `paperclipai-${instanceId}.service`;
}
export function launchdServiceName(instanceId: string): string {
return instanceId === "default" ? "ing.paperclip.paperclipai" : `ing.paperclip.paperclipai.${instanceId}`;
}
export function renderSystemdUnit(input: { instanceId: string; shimPath: string; homeDir: string }): string {
return `[Unit]
Description=Paperclip AI (${escapeSystemd(input.instanceId)})
After=network.target
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=notify
NotifyAccess=all
ExecStart="${escapeSystemd(input.shimPath)}" run --instance "${escapeSystemd(input.instanceId)}"
Environment="PAPERCLIP_SERVICE_MANAGED=1"
Environment="PAPERCLIP_INSTANCE_ID=${escapeSystemd(input.instanceId)}"
Environment="PAPERCLIP_HOME=${escapeSystemd(input.homeDir)}"
WorkingDirectory=%h
Restart=always
RestartSec=5
TimeoutStopSec=300
[Install]
WantedBy=default.target
`;
}
export function renderLaunchdPlist(input: { instanceId: string; shimPath: string; homeDir: string; stdoutPath: string; stderrPath: string }): string {
const label = launchdServiceName(input.instanceId);
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>${escapeXml(label)}</string>
<key>ProgramArguments</key>
<array>
<string>${escapeXml(input.shimPath)}</string><string>run</string><string>--instance</string><string>${escapeXml(input.instanceId)}</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PAPERCLIP_SERVICE_MANAGED</key><string>1</string>
<key>PAPERCLIP_INSTANCE_ID</key><string>${escapeXml(input.instanceId)}</string>
<key>PAPERCLIP_HOME</key><string>${escapeXml(input.homeDir)}</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>5</integer>
<key>ExitTimeOut</key><integer>300</integer>
<key>StandardOutPath</key><string>${escapeXml(input.stdoutPath)}</string>
<key>StandardErrorPath</key><string>${escapeXml(input.stderrPath)}</string>
</dict>
</plist>
`;
}
async function writeIfChanged(filePath: string, contents: string): Promise<boolean> {
const directoryPath = path.dirname(filePath);
await fs.mkdir(directoryPath, { recursive: true, mode: 0o700 });
const directoryStat = await fs.lstat(directoryPath);
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw new Error(`Refusing to write service definition through unsafe directory ${directoryPath}.`);
const currentUid = process.getuid?.();
if (currentUid !== undefined && directoryStat.uid !== currentUid) throw new Error(`Refusing to write service definition in directory not owned by the current user: ${directoryPath}.`);
try {
const stat = await fs.lstat(filePath);
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink > 1) throw new Error(`Refusing to replace unsafe service definition ${filePath}.`);
if (currentUid !== undefined && stat.uid !== currentUid) throw new Error(`Refusing to replace service definition not owned by the current user: ${filePath}.`);
if (await fs.readFile(filePath, "utf8") === contents) return false;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
const temporaryPath = path.join(directoryPath, `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}`);
try {
await fs.writeFile(temporaryPath, contents, { encoding: "utf8", mode: 0o644, flag: "wx" });
await fs.rename(temporaryPath, filePath);
} finally {
await fs.rm(temporaryPath, { force: true });
}
return true;
}
export class SystemdServiceManager implements ServiceManager {
readonly platform = "systemd" as const;
readonly serviceName: string;
readonly definitionPath: string;
constructor(readonly instanceId: string, private readonly runner: CommandRunner = defaultCommandRunner, private readonly homeDir = resolvePaperclipHomeDir(), private readonly shimPath = resolveServiceShimPath(), userHomeDir = os.homedir()) {
this.serviceName = systemdServiceName(instanceId);
this.definitionPath = path.join(userHomeDir, ".config", "systemd", "user", this.serviceName);
}
renderDefinition(): string {
return renderSystemdUnit({ instanceId: this.instanceId, shimPath: this.shimPath, homeDir: this.homeDir });
}
private async ensureCurrent(): Promise<boolean> {
const changed = await writeIfChanged(this.definitionPath, this.renderDefinition());
if (changed) await this.runner("systemctl", ["--user", "daemon-reload"]);
return changed;
}
async install(options: ServiceInstallOptions): Promise<{ changed: boolean }> {
const changed = await this.ensureCurrent();
if (options.startOnLogin) await this.runner("systemctl", ["--user", "enable", this.serviceName]);
else await this.runner("systemctl", ["--user", "disable", this.serviceName]).catch(() => undefined);
if (options.startNow) await this.start();
return { changed };
}
async uninstall(): Promise<void> {
const status = await this.status();
if (status.active) await this.stop();
await this.runner("systemctl", ["--user", "disable", this.serviceName]).catch(() => undefined);
await fs.rm(this.definitionPath, { force: true });
await this.runner("systemctl", ["--user", "daemon-reload"]);
await this.runner("systemctl", ["--user", "reset-failed", this.serviceName]).catch(() => undefined);
}
async start(): Promise<void> { await this.ensureCurrent(); await this.runner("systemctl", ["--user", "start", this.serviceName]); }
async stop(): Promise<void> { await this.runner("systemctl", ["--user", "stop", this.serviceName]); }
async restart(): Promise<void> { await this.ensureCurrent(); await this.runner("systemctl", ["--user", "restart", this.serviceName]); }
async status(): Promise<ServiceStatus> {
let output: string;
try {
output = (await this.runner("systemctl", ["--user", "show", this.serviceName, "--property=LoadState,ActiveState,UnitFileState,MainPID"])).stdout;
} catch {
return { platform: this.platform, serviceName: this.serviceName, installed: false, active: false, enabled: false, pid: null, linger: await this.lingerStatus() };
}
const values = Object.fromEntries(output.trim().split(/\r?\n/).map((line) => line.split(/=(.*)/s).slice(0, 2)));
const pid = Number(values.MainPID);
return { platform: this.platform, serviceName: this.serviceName, installed: values.LoadState === "loaded", active: values.ActiveState === "active", enabled: values.UnitFileState === "enabled", pid: Number.isInteger(pid) && pid > 0 ? pid : null, detail: values.ActiveState, linger: await this.lingerStatus() };
}
private async lingerStatus(): Promise<boolean | null> {
try {
const result = await this.runner("loginctl", ["show-user", String(process.getuid?.() ?? os.userInfo().username), "--property=Linger", "--value"]);
return result.stdout.trim() === "yes";
} catch { return null; }
}
async enableLinger(): Promise<void> { await this.runner("loginctl", ["enable-linger", os.userInfo().username]); }
async logs(follow: boolean, lines: number): Promise<void> { await this.runner("journalctl", ["--user", "--unit", this.serviceName, "--lines", String(lines), ...(follow ? ["--follow"] : [])], { inherit: true }); }
}
export class LaunchdServiceManager implements ServiceManager {
readonly platform = "launchd" as const;
readonly serviceName: string;
readonly definitionPath: string;
private readonly domain = `gui/${process.getuid?.() ?? 0}`;
private readonly stdoutPath: string;
private readonly stderrPath: string;
constructor(readonly instanceId: string, private readonly runner: CommandRunner = defaultCommandRunner, private readonly homeDir = resolvePaperclipHomeDir(), private readonly shimPath = resolveServiceShimPath(), userHomeDir = os.homedir()) {
this.serviceName = launchdServiceName(instanceId);
this.definitionPath = path.join(userHomeDir, "Library", "LaunchAgents", `${this.serviceName}.plist`);
const logDir = path.join(homeDir, "instances", instanceId, "logs");
this.stdoutPath = path.join(logDir, "service.log");
this.stderrPath = path.join(logDir, "service.err.log");
}
renderDefinition(): string { return renderLaunchdPlist({ instanceId: this.instanceId, shimPath: this.shimPath, homeDir: this.homeDir, stdoutPath: this.stdoutPath, stderrPath: this.stderrPath }); }
async install(options: ServiceInstallOptions): Promise<{ changed: boolean }> {
await fs.mkdir(path.dirname(this.stdoutPath), { recursive: true });
const changed = await writeIfChanged(this.definitionPath, this.renderDefinition());
if (changed) await this.runner("launchctl", ["bootout", `${this.domain}/${this.serviceName}`]).catch(() => undefined);
await this.runner("launchctl", [options.startOnLogin ? "enable" : "disable", `${this.domain}/${this.serviceName}`]);
if (options.startOnLogin || options.startNow) {
await this.runner("launchctl", ["bootstrap", this.domain, this.definitionPath]).catch(async () => this.runner("launchctl", ["kickstart", "-k", `${this.domain}/${this.serviceName}`]));
}
if (!options.startNow) await this.stop().catch(() => undefined);
return { changed };
}
async uninstall(): Promise<void> {
await this.runner("launchctl", ["bootout", `${this.domain}/${this.serviceName}`]).catch(() => undefined);
await this.runner("launchctl", ["disable", `${this.domain}/${this.serviceName}`]).catch(() => undefined);
await fs.rm(this.definitionPath, { force: true });
}
async start(): Promise<void> { await this.install({ startNow: true, startOnLogin: await this.isEnabled() }); }
async stop(): Promise<void> { await this.runner("launchctl", ["bootout", `${this.domain}/${this.serviceName}`]); }
async restart(): Promise<void> { await writeIfChanged(this.definitionPath, this.renderDefinition()); await this.runner("launchctl", ["kickstart", "-k", `${this.domain}/${this.serviceName}`]); }
async status(): Promise<ServiceStatus> {
try {
const result = await this.runner("launchctl", ["print", `${this.domain}/${this.serviceName}`]);
const pidMatch = result.stdout.match(/\bpid\s*=\s*(\d+)/);
const pid = pidMatch ? Number(pidMatch[1]) : null;
return { platform: this.platform, serviceName: this.serviceName, installed: true, active: Boolean(pid), enabled: await this.isEnabled(), pid, detail: pid ? "running" : "loaded" };
} catch {
let installed = true;
try { await fs.access(this.definitionPath); } catch { installed = false; }
return { platform: this.platform, serviceName: this.serviceName, installed, active: false, enabled: installed && await this.isEnabled(), pid: null };
}
}
private async isEnabled(): Promise<boolean> {
try {
const result = await this.runner("launchctl", ["print-disabled", this.domain]);
return !new RegExp(`"${escapeRegExp(this.serviceName)}"\\s*=>\\s*true`).test(result.stdout);
} catch { return true; }
}
async logs(follow: boolean, lines: number): Promise<void> { await this.runner("tail", ["-n", String(lines), ...(follow ? ["-F"] : []), this.stdoutPath, this.stderrPath], { inherit: true }); }
}
export type ServiceManagerDetection = { supported: true; manager: ServiceManager } | { supported: false; reason: string };
export async function detectServiceManager(input: { instanceId?: string; platform?: NodeJS.Platform; runner?: CommandRunner } = {}): Promise<ServiceManagerDetection> {
const instanceId = resolvePaperclipInstanceId(input.instanceId);
const platform = input.platform ?? process.platform;
const runner = input.runner ?? defaultCommandRunner;
if (platform === "darwin") return { supported: true, manager: new LaunchdServiceManager(instanceId, runner) };
if (platform !== "linux") return { supported: false, reason: `Service management is not supported on ${platform}. Use paperclipai run instead.` };
try {
await runner("systemctl", ["--user", "show-environment"]);
return { supported: true, manager: new SystemdServiceManager(instanceId, runner) };
} catch {
return { supported: false, reason: "No usable systemd user manager was detected (common in containers and WSL1). Use paperclipai run instead." };
}
}
export async function assertForegroundRunAllowed(instanceId: string, force = false, detector: typeof detectServiceManager = detectServiceManager): Promise<void> {
if (force || process.env.PAPERCLIP_SERVICE_MANAGED === "1") return;
const detection = await detector({ instanceId });
if (!detection.supported) return;
const status = await detection.manager.status();
if (status.active) throw new Error(`Paperclip instance '${instanceId}' is already running as ${status.serviceName}. Use 'paperclipai service status --instance ${instanceId}' or pass --force to bypass this safety check.`);
}

19
cli/src/update-notice.ts Normal file
View File

@ -0,0 +1,19 @@
import fs from "node:fs";
import path from "node:path";
import { packageVersion } from "./version.js";
import { compareVersions } from "./commands/update.js";
import { readInstallManifest, resolveInstallStorePaths } from "./install-store.js";
import { resolveConfigPath } from "./config/store.js";
const NOTICE_INTERVAL_MS = 24 * 60 * 60 * 1000;
export function isUpdateNoticeEnabled(configPath?: string): boolean {
if (process.env.PAPERCLIP_UPDATE_CHECK === "0") return false;
try { const raw = JSON.parse(fs.readFileSync(resolveConfigPath(configPath), "utf8")) as { updates?: { checkEnabled?: boolean } }; return raw.updates?.checkEnabled !== false; } catch { return true; }
}
export async function checkForUpdateNotice(options: { configPath?: string; now?: number; fetchImpl?: typeof fetch; cachePath?: string } = {}): Promise<string | null> {
if (!isUpdateNoticeEnabled(options.configPath)) return null;
const paths = resolveInstallStorePaths(); const cachePath = options.cachePath ?? path.join(paths.cliRoot, "update-check.json"); const now = options.now ?? Date.now();
try { const cache = JSON.parse(fs.readFileSync(cachePath, "utf8")) as { checkedAt?: number; latest?: string }; if (cache.checkedAt && now - cache.checkedAt < NOTICE_INTERVAL_MS) return cache.latest && compareVersions(cache.latest, packageVersion) > 0 ? cache.latest : null; } catch {}
const tag = readInstallManifest(paths)?.channel === "canary" ? "canary" : "latest";
try { const response = await (options.fetchImpl ?? fetch)("https://registry.npmjs.org/paperclipai", { signal: AbortSignal.timeout(2500) }); if (!response.ok) return null; const body = await response.json() as { ["dist-tags"]?: Record<string, string> }; const latest = body["dist-tags"]?.[tag]; fs.mkdirSync(path.dirname(cachePath), { recursive: true, mode: 0o700 }); fs.writeFileSync(cachePath, JSON.stringify({ checkedAt: now, latest: latest ?? null }) + "\n", { mode: 0o600 }); return latest && compareVersions(latest, packageVersion) > 0 ? latest : null; } catch { return null; }
}
export async function printUpdateNotice(configPath?: string): Promise<void> { const latest = await checkForUpdateNotice({ configPath }); if (latest) console.log(`Update available: ${latest} — run \`paperclipai update\``); }

View File

@ -0,0 +1,10 @@
export function buildLocalHealthUrl(host: string | undefined, port: number): string {
const configuredHost = host?.trim();
const reachableHost = !configuredHost || configuredHost === "0.0.0.0" || configuredHost === "::"
? "127.0.0.1"
: configuredHost;
const urlHost = reachableHost.includes(":") && !reachableHost.startsWith("[")
? `[${reachableHost}]`
: reachableHost;
return `http://${urlHost}:${port}/api/health`;
}

View File

@ -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";
export 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();

View File

@ -2,6 +2,7 @@
Paperclip CLI now supports both:
- installation and lifecycle management (`install`, `uninstall`, `update`, `upgrade`, `service`)
- instance setup/diagnostics (`onboard`, `doctor`, `configure`, `env`, `allowed-hostname`, `env-lab`)
- control-plane client operations (issues, approvals, agents, activity, dashboard)
@ -13,7 +14,26 @@ Use repo script in development:
pnpm paperclipai --help
```
First-time local bootstrap + run:
Recommended installation and interactive onboarding:
```sh
curl -fsSLO https://paperclip.ing/install.sh
curl -fsSLO https://paperclip.ing/install.sh.sha256
if command -v sha256sum >/dev/null 2>&1; then
sha256sum -c install.sh.sha256
else
shasum -a 256 -c install.sh.sha256
fi
bash install.sh
```
The checksum detects transfer or publishing mistakes but is served from the
same origin as the installer. Use a release-tag or commit-pinned GitHub copy
when you need an independently hosted source. Piped installs require supported
Node.js, npm, and npx to already be installed; download the script first before
allowing it to bootstrap Node.js with privileged package-manager commands.
First-time local bootstrap from a source checkout:
```sh
pnpm paperclipai run
@ -25,6 +45,60 @@ Choose local instance:
pnpm paperclipai run --instance dev
```
## Install, Update, And Uninstall
Managed installs keep CLI payloads under `~/.paperclip/cli`, expose a stable
`~/.local/bin/paperclipai` shim, switch versions atomically, and retain two
previous payloads for rollback.
```sh
paperclipai install
paperclipai install --canary
paperclipai install --version <version>
paperclipai install --ref <branch|tag|sha> [--repo owner/repo]
paperclipai update
paperclipai update --latest|--canary|--version <version>
paperclipai update --rollback
paperclipai upgrade
paperclipai uninstall
```
`upgrade` aliases `update`. `uninstall` removes managed code and the shim but
preserves instance data under `~/.paperclip/instances/`. See
`doc/INSTALLING.md` for installation methods, security notes, PATH setup, and
the complete update and rollback behavior.
## Onboarding And Service Management
Interactive onboarding offers to install a background service on supported
platforms. `--yes` never installs it implicitly; automation must opt in.
```sh
paperclipai onboard
paperclipai onboard --yes
paperclipai onboard --yes --install-service
paperclipai onboard --yes --no-install-service
```
Service lifecycle commands remain under the `service` namespace:
```sh
paperclipai service install [--no-start-now] [--no-start-on-login]
paperclipai service uninstall
paperclipai service start
paperclipai service stop
paperclipai service restart [--wait]
paperclipai service status [--json]
paperclipai service logs [-f]
```
Every service verb supports `--instance <id>` and `--json`. Linux and WSL2 use
a systemd user unit when available; macOS uses a LaunchAgent. Unsupported
environments receive foreground `paperclipai run` guidance.
`paperclipai doctor` includes managed-install and service-health diagnostics in
addition to configuration, storage, database, logging, and port checks.
## Deployment Modes
Mode taxonomy and design intent are documented in `doc/DEPLOYMENT-MODES.md`.

256
doc/INSTALLING.md Normal file
View File

@ -0,0 +1,256 @@
# Installing Paperclip
Paperclip supports a managed installation, an ephemeral `npx` tryout, a
traditional global npm installation, and development from a source checkout.
The managed installation is recommended because it provides atomic updates,
rollback, git-ref installs, and a stable entrypoint for the background service.
## Recommended Install
On macOS, Linux, or WSL2:
```sh
curl -fsSLO https://paperclip.ing/install.sh
curl -fsSLO https://paperclip.ing/install.sh.sha256
if command -v sha256sum >/dev/null 2>&1; then
sha256sum -c install.sh.sha256
else
shasum -a 256 -c install.sh.sha256
fi
bash install.sh
```
The bootstrap script:
1. verifies that the platform is supported;
2. ensures Node.js 20 or newer is available;
3. delegates installation to `paperclipai install`;
4. starts interactive onboarding when stdin and stdout are terminals.
The script prints and confirms any command that requires elevated privileges.
Third-party Node.js bootstrap scripts are pinned and SHA-256 verified before
execution; the installer stops if a published script changes unexpectedly.
The `paperclip.ing` checksum detects transfer or publishing mistakes, but it is
served from the same origin as the script and is not an independent
authenticity proof. For an independently hosted source, download a release-tag
or commit-pinned copy from GitHub, review it, and run that local file.
Use `--no-prompt` for automation and `--no-onboard` to stop after installing.
The piped form only proceeds when supported Node.js, npm, and npx are already
installed; if Node.js bootstrap is required, download the script first so the
privileged commands are inspectable before execution:
```sh
curl -fsSL https://paperclip.ing/install.sh | bash -s -- --no-prompt --no-onboard
paperclipai onboard --yes
```
If the vanity installer endpoint is unavailable, fetch the same
release-controlled source from GitHub raw content:
```sh
raw_base=https://raw.githubusercontent.com/paperclipai/paperclip
curl -fsSL "$raw_base/master/scripts/install.sh" | bash
```
For audits or incident response, pin the raw URL to a release tag or commit SHA
instead of `master` and download it first. That immutable GitHub URL provides a
separate delivery path from `paperclip.ing`; do not treat a checksum served by
the same origin as the artifact as an independent trust anchor.
Each installer flag also has a `PAPERCLIP_INSTALL_*` environment-variable
equivalent. This helps where passing arguments through a pipe is awkward.
## Managed Install Layout
Managed code is separate from instance data:
```text
~/.paperclip/cli/
├── install.json
├── current -> installs/npm/2026.720.0
└── installs/
├── npm/<version>/
└── git/<sha12>/
~/.local/bin/paperclipai
```
The `paperclipai` shim remains stable while `current` switches atomically
between complete payloads. Paperclip keeps the two previous managed payloads
for rollback. Configuration, databases, uploads, logs, secrets, and workspaces
remain under `~/.paperclip/instances/` and are not stored inside CLI payloads.
If `~/.local/bin` is not on `PATH`, the installer offers to update the relevant
shell startup file when running interactively. Non-interactive installs print
the exact `export PATH` command instead of editing shell files silently.
## Install Sources
Install the current stable release:
```sh
npx --registry https://registry.npmjs.org paperclipai install
```
Install canary or pin an exact published version:
```sh
npx --registry https://registry.npmjs.org paperclipai install --canary
npx --registry https://registry.npmjs.org paperclipai install --version 2026.720.0
```
Install a branch, tag, or commit from GitHub:
```sh
npx --registry https://registry.npmjs.org paperclipai install --ref master
npx --registry https://registry.npmjs.org paperclipai install --ref v2026.720.0
npx --registry https://registry.npmjs.org paperclipai install --ref <commit-sha>
```
Use a fork by adding `--repo owner/repository`:
```sh
npx --registry https://registry.npmjs.org paperclipai install \
--repo your-org/paperclip \
--ref your-branch
```
Git-ref installs resolve the requested ref to an exact commit before building.
Review and trust the repository and ref: installing a git ref executes that
revision's package installation and release build scripts on your machine.
## Onboarding And The Service
Run onboarding after a non-interactive installation:
```sh
paperclipai onboard
```
Interactive onboarding asks whether Paperclip should run as a background
service when the platform supports one. Automated onboarding deliberately does
not install a service unless explicitly requested:
```sh
paperclipai onboard --yes # configure only; no service install
paperclipai onboard --yes --install-service # explicit automation opt-in
paperclipai onboard --yes --no-install-service
```
Service commands are namespaced:
```sh
paperclipai service install
paperclipai service status
paperclipai service start
paperclipai service stop
paperclipai service restart
paperclipai service logs -f
paperclipai service uninstall
```
Paperclip uses a systemd user service on Linux and WSL2 systems with user
systemd, and a LaunchAgent on macOS. Containers, WSL1, and systems without a
supported user service manager receive foreground `paperclipai run` guidance
instead of a hard failure.
The service uses the stable managed-install shim, restarts after crashes, and
can start on login. On Linux, service installation may offer to enable user
lingering so it can continue without an active login session. The command
explains and confirms that system-level action before running it.
Use one server process per instance. `paperclipai run` refuses to start when
the same instance is already supervised; stop the service first or use
`--force` only when you intentionally accept the single-writer risk.
## Update And Rollback
Update according to the source and channel recorded in the install manifest:
```sh
paperclipai update
```
Select a different release source explicitly:
```sh
paperclipai update --latest
paperclipai update --canary
paperclipai update --version 2026.720.0
```
Managed updates create a database backup before switching payloads, verify the
new CLI, atomically flip `current`, and restart an installed service. A failed
install or verification leaves the previous payload active.
If the service is stopped, start it with `paperclipai service start` before
updating so Paperclip can take the safety backup. Use
`paperclipai update --no-backup` only when you intentionally accept updating
without that rollback safeguard. A never-onboarded instance with no config or
instance data skips the backup automatically because there is nothing to save.
Roll back to the previous retained payload:
```sh
paperclipai update --rollback
```
The `upgrade` command is an alias for `update`. Exact versions and commit SHAs
are pinned; provide a new target when you want them to move.
## Other Installation Methods
Ephemeral tryout with no managed install:
```sh
npx --registry https://registry.npmjs.org paperclipai onboard --yes
```
Traditional global npm install:
```sh
npm install --global --registry https://registry.npmjs.org paperclipai
paperclipai onboard
```
Source checkout for development:
```sh
git clone https://github.com/paperclipai/paperclip.git
cd paperclip
pnpm install
pnpm dev
```
The managed `paperclipai update` command can update managed and global npm
installs. For source checkouts it reports the appropriate git workflow instead
of modifying the checkout automatically.
## Diagnose An Installation
Run:
```sh
paperclipai doctor
paperclipai service status
```
`doctor` checks the managed install store, manifest, `current` link, shim,
`PATH`, Node.js version, and service state. Service diagnostics cover unit-file
presence and drift, running state, configured port ownership, and the running
server version.
## Uninstall
Remove the background service and managed CLI payloads:
```sh
paperclipai service uninstall
paperclipai uninstall
```
`paperclipai uninstall` removes the managed shim, manifest, and CLI payloads.
It deliberately preserves `~/.paperclip/instances/`, including configuration,
databases, uploads, logs, secrets, backups, and workspaces. Back up and remove
that data separately only when you intend to delete the Paperclip instance.

View File

@ -232,9 +232,13 @@ After setup:
Install-path check:
```bash
npx paperclipai@canary onboard
npm install --prefix "$(mktemp -d)" paperclipai@canary --no-audit --no-fund
```
The release script runs this clean-prefix install after publishing every workspace
package dependency-first and publishing `paperclipai` last. A package that is not
yet registry-visible stops the train before the channel entrypoint can advance.
## 12. Verify the Stable Workflow
After at least one good canary exists:

View File

@ -62,8 +62,11 @@ It:
- verifies the pushed commit
- computes the canary version for the current UTC date
- publishes under npm dist-tag `canary`
- publishes workspace packages dependency-first under npm dist-tag `canary`
- waits for each package version to become registry-visible before continuing
- publishes the user-facing `paperclipai` package last, so `paperclipai@canary` does not advance before the full package set exists
- verifies that `canary` resolves to the just-published version and that published internal dependencies exist on npm
- installs `paperclipai@canary` into a clean temporary prefix as the final npm gate
- fails by default if npm leaves `latest` pointing at a canary; use `--allow-canary-latest` only when that state is intentional
- creates a git tag `canary/vYYYY.MDD.P-canary.N`

View File

@ -40,6 +40,7 @@
"check:token-gates": "node scripts/check-token-gates.mjs",
"check:no-git-push": "node scripts/check-no-git-push.mjs",
"test:check-no-git-push": "node --test scripts/check-no-git-push.test.mjs",
"test:install-sh-docker": "./scripts/test-install-sh-docker.sh",
"test:hermes-gateway-smoke": "node --test scripts/smoke/hermes-gateway-smoke.test.mjs",
"docs:dev": "cd docs && npx mintlify dev",
"smoke:hermes-gateway-join": "./scripts/smoke/hermes-gateway-join.sh",

View File

@ -34,6 +34,9 @@
"files": [
"dist"
],
"bundleDependencies": [
"embedded-postgres"
],
"scripts": {
"check:migrations": "tsx src/check-migration-numbering.ts && tsx src/check-migration-safety.ts",
"build": "pnpm run check:migrations && tsc && cp -r src/migrations dist/migrations",

View File

@ -1,8 +1,12 @@
import childProcess from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createRequire } from "node:module";
import { afterEach, describe, expect, it } from "vitest";
import { ensureLinuxSharedLibraryAliases } from "./embedded-postgres-native.js";
import { ensureLinuxSharedLibraryAliases, prepareEmbeddedPostgresNativeRuntime } from "./embedded-postgres-native.js";
const require = createRequire(import.meta.url);
describe("embedded Postgres native runtime", () => {
const tempDirs: string[] = [];
@ -40,4 +44,19 @@ describe("embedded Postgres native runtime", () => {
expect(second).toEqual([]);
expect(fs.readlinkSync(path.join(tempDir, "libicuuc.so.60"))).toBe("libicuuc.so.60.2");
});
it("keeps the child process API untouched while preparing the runtime", async () => {
const originalSpawn = childProcess.spawn;
await prepareEmbeddedPostgresNativeRuntime();
expect(childProcess.spawn).toBe(originalSpawn);
});
it("uses the dependency-scoped portable locale patch", () => {
const source = fs.readFileSync(require.resolve("embedded-postgres"), "utf8");
expect(source).toContain("const LC_MESSAGES_LOCALE = 'C';");
expect(source).toContain("globalThis.process.env");
});
});

View File

@ -103,6 +103,10 @@ export const telemetryConfigSchema = z.object({
enabled: z.boolean().default(true),
}).default({});
export const updatesConfigSchema = z.object({
checkEnabled: z.boolean().default(true),
}).default({});
export const paperclipConfigSchema = z
.object({
$meta: configMetaSchema,
@ -111,6 +115,7 @@ export const paperclipConfigSchema = z
logging: loggingConfigSchema,
server: serverConfigSchema,
telemetry: telemetryConfigSchema,
updates: updatesConfigSchema.optional(),
auth: authConfigSchema.default({
baseUrlMode: "auto",
disableSignUp: false,
@ -195,5 +200,6 @@ export type SecretsConfig = z.infer<typeof secretsConfigSchema>;
export type SecretsLocalEncryptedConfig = z.infer<typeof secretsLocalEncryptedConfigSchema>;
export type AuthConfig = z.infer<typeof authConfigSchema>;
export type TelemetryConfig = z.infer<typeof telemetryConfigSchema>;
export type UpdatesConfig = z.infer<typeof updatesConfigSchema>;
export type ConfigMeta = z.infer<typeof configMetaSchema>;
export type DatabaseBackupConfig = z.infer<typeof databaseBackupConfigSchema>;

View File

@ -400,6 +400,7 @@ export interface IssueBlockerAttention {
coveredBlockerCount: number;
stalledBlockerCount: number;
attentionBlockerCount: number;
pendingFinalizeBlockerIssueIds?: string[];
sampleBlockerIdentifier: string | null;
sampleStalledBlockerIdentifier: string | null;
}

View File

@ -16,14 +16,21 @@ import test from "node:test";
import cliEsbuildConfig from "../cli/esbuild.config.mjs";
import { bundledCliNpmDependencies } from "./cli-bundled-npm-dependencies.mjs";
import { materializePublishManifest } from "./prepare-bundled-package.mjs";
import {
createBundledInstallManifest,
materializePublishManifest,
} from "./prepare-bundled-package.mjs";
const rootPackage = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf8"));
const adapterUtilsPackage = JSON.parse(
await readFile(new URL("../packages/adapter-utils/package.json", import.meta.url), "utf8"),
);
const dbPackage = JSON.parse(
await readFile(new URL("../packages/db/package.json", import.meta.url), "utf8"),
);
const releaseScript = await readFile(new URL("./release.sh", import.meta.url), "utf8");
const releaseLib = await readFile(new URL("./release-lib.sh", import.meta.url), "utf8");
const buildNpmScript = await readFile(new URL("./build-npm.sh", import.meta.url), "utf8");
test("published packages preserve the patched ACPX runtime", () => {
assert.equal(
@ -36,6 +43,16 @@ test("published packages preserve the patched ACPX runtime", () => {
assert.equal(cliEsbuildConfig.external.includes("acpx"), false);
});
test("published packages preserve the patched embedded-postgres runtime", () => {
assert.equal(
rootPackage.pnpm.patchedDependencies["embedded-postgres@18.1.0-beta.16"],
"patches/embedded-postgres@18.1.0-beta.16.patch",
);
assert.deepEqual(dbPackage.bundleDependencies, ["embedded-postgres"]);
assert.equal(bundledCliNpmDependencies.has("embedded-postgres"), true);
assert.equal(cliEsbuildConfig.external.includes("embedded-postgres"), false);
});
test("bundled package staging materializes publishConfig entrypoints", () => {
const staged = materializePublishManifest(adapterUtilsPackage);
@ -45,6 +62,41 @@ test("bundled package staging materializes publishConfig entrypoints", () => {
assert.deepEqual(staged.exports, adapterUtilsPackage.publishConfig.exports);
});
test("bundled package staging materializes workspace dependency versions", () => {
const staged = materializePublishManifest({
name: "@paperclipai/example",
version: "2026.723.0",
dependencies: { exact: "workspace:*", caret: "workspace:^", tilde: "workspace:~" },
});
assert.deepEqual(staged.dependencies, {
exact: "2026.723.0",
caret: "^2026.723.0",
tilde: "~2026.723.0",
});
});
test("bundled package staging installs only dependencies included in the tarball", () => {
const installManifest = createBundledInstallManifest(
{
name: "@paperclipai/db",
version: "2026.723.0-canary.8",
dependencies: {
"@paperclipai/shared": "2026.723.0-canary.8",
"drizzle-orm": "^0.45.2",
"embedded-postgres": "^18.1.0-beta.16",
},
bundleDependencies: ["embedded-postgres"],
},
["embedded-postgres"],
);
assert.deepEqual(installManifest.dependencies, {
"embedded-postgres": "^18.1.0-beta.16",
});
assert.deepEqual(installManifest.bundleDependencies, ["embedded-postgres"]);
});
test("bundled package staging rebuilds npm dependencies and applies the acpx patch", (t) => {
const fixtureDir = mkdtempSync(join(tmpdir(), "paperclip-bundled-stage-"));
const sourceDir = join(fixtureDir, "source");
@ -52,6 +104,8 @@ test("bundled package staging rebuilds npm dependencies and applies the acpx pat
const binDir = join(fixtureDir, "bin");
const callLog = join(fixtureDir, "calls.log");
mkdirSync(sourceDir);
mkdirSync(join(sourceDir, "dist"));
writeFileSync(join(sourceDir, "dist", "index.js"), "export {};\n");
mkdirSync(destinationDir);
mkdirSync(binDir);
writeFileSync(join(sourceDir, "package.json"), JSON.stringify(adapterUtilsPackage));
@ -136,3 +190,8 @@ test("bundled package dry runs preview without querying published versions", ()
assert.match(releaseLib, /run_bundled_npm_publish publish --tag "\$dist_tag"/);
assert.doesNotMatch(releaseLib, /run_bundled_npm_publish publish "\.\/\$tarball"/);
});
test("npm builds use corepack instead of requiring a global pnpm", () => {
assert.match(buildNpmScript, /corepack pnpm -r typecheck/);
assert.doesNotMatch(buildNpmScript, /^\s*pnpm -r typecheck/m);
});

View File

@ -37,7 +37,7 @@ fi
if [ "$skip_typecheck" = false ]; then
echo " [2/6] Type-checking..."
cd "$REPO_ROOT"
pnpm -r typecheck
corepack pnpm -r typecheck
else
echo " [2/6] Skipping type-check (--skip-typecheck)"
fi

46
scripts/clean-install-git.sh Executable file
View File

@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PC_TEST_ROOT="${PC_TEST_ROOT:-$(mktemp -d "${TMPDIR:-/tmp}/paperclip-clean-install-git.XXXXXX")}"
PC_HOME="${PC_HOME:-$PC_TEST_ROOT/home}"
PC_CACHE="${PC_CACHE:-$PC_TEST_ROOT/npm-cache}"
KEEP_TEMP="${KEEP_TEMP:-0}"
cleanup() {
if [ "$KEEP_TEMP" != "1" ]; then
rm -rf "$PC_TEST_ROOT"
fi
}
trap cleanup EXIT
mkdir -p "$PC_HOME" "$PC_CACHE"
echo "REPO_ROOT: $REPO_ROOT"
echo "PC_TEST_ROOT: $PC_TEST_ROOT"
echo "PC_HOME: $PC_HOME"
env \
HOME="$PC_HOME" \
PAPERCLIP_HOME="$PC_HOME/.paperclip" \
npm_config_cache="$PC_CACHE" \
npm_config_userconfig="$PC_HOME/.npmrc" \
PATH="$PC_HOME/.local/bin:$PATH" \
pnpm --dir "$REPO_ROOT" paperclipai install --yes
test -x "$PC_HOME/.local/bin/paperclipai"
test -L "$PC_HOME/.paperclip/cli/current"
test -f "$PC_HOME/.paperclip/cli/install.json"
env HOME="$PC_HOME" PAPERCLIP_HOME="$PC_HOME/.paperclip" PATH="$PC_HOME/.local/bin:$PATH" paperclipai --version
env HOME="$PC_HOME" PAPERCLIP_HOME="$PC_HOME/.paperclip" PATH="$PC_HOME/.local/bin:$PATH" paperclipai doctor \
--config "$PC_TEST_ROOT/missing-config.json" >/dev/null || true
env \
HOME="$PC_HOME" \
PAPERCLIP_HOME="$PC_HOME/.paperclip" \
PATH="$PC_HOME/.local/bin:$PATH" \
pnpm --dir "$REPO_ROOT" paperclipai uninstall
test ! -e "$PC_HOME/.paperclip/cli"
test ! -e "$PC_HOME/.local/bin/paperclipai"

36
scripts/clean-install-npm.sh Executable file
View File

@ -0,0 +1,36 @@
#!/bin/bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PC_INSTALL_DRIVER="${PC_INSTALL_DRIVER:-source}"
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 PAPERCLIP_HOME="$PC_HOME/.paperclip"
export npm_config_cache="$PC_CACHE"
export npm_config_userconfig="$PC_HOME/.npmrc"
export PATH="$PC_HOME/.local/bin:$PATH"
if [ "$PC_INSTALL_DRIVER" = "published" ]; then
(cd "$PC_TEST_ROOT" && npx --yes --registry https://registry.npmjs.org paperclipai install)
else
(cd "$REPO_ROOT" && pnpm paperclipai install --yes)
fi
test -x "$PC_HOME/.local/bin/paperclipai"
test -L "$PAPERCLIP_HOME/cli/current"
test -f "$PAPERCLIP_HOME/cli/install.json"
paperclipai --version
mkdir -p "$PAPERCLIP_HOME/instances/default"
touch "$PAPERCLIP_HOME/instances/default/user-data-marker"
(cd "$REPO_ROOT" && pnpm paperclipai uninstall)
test ! -e "$PAPERCLIP_HOME/cli"
test ! -e "$PC_HOME/.local/bin/paperclipai"
test -f "$PAPERCLIP_HOME/instances/default/user-data-marker"

View File

@ -1,3 +1,4 @@
export const bundledCliNpmDependencies = new Set([
"acpx",
"embedded-postgres",
]);

230
scripts/e2e-install-lifecycle.sh Executable file
View File

@ -0,0 +1,230 @@
#!/usr/bin/env bash
# End-to-end proof of the paperclipai managed install lifecycle on a CLEAN machine.
#
# Exercises the real user journey against real GitHub + real npm:
# bootstrap build -> install (npm latest) -> install --ref (build-from-source)
# -> update --check -> update --rollback -> reinstall (payload reuse)
# -> bad-ref failure hygiene -> service lifecycle -> uninstall (data preserved)
#
# Machine requirements: bash, curl, tar, node >= 20 (with corepack), npm.
# The machine's $HOME must not already contain a managed install.
#
# Env knobs:
# E2E_REPO GitHub repo to install from (default: paperclipai/paperclip)
# E2E_REF branch/tag/sha to install (default: master)
# E2E_SKIP_NPM=1 skip the npm-channel install step (canary is tested separately;
# the npm leg uses the latest channel)
# E2E_SKIP_SERVICE=1 skip the service lifecycle step
# E2E_SERVICE_TIMEOUT_SECS how long to wait for the service to go active (default 300)
set -uo pipefail
E2E_REPO="${E2E_REPO:-paperclipai/paperclip}"
E2E_REF="${E2E_REF:-master}"
E2E_SERVICE_TIMEOUT_SECS="${E2E_SERVICE_TIMEOUT_SECS:-300}"
# A clean environment: no inherited Paperclip or build-mode state.
for var in $(env | grep -o '^PAPERCLIP_[A-Z_]*' || true); do unset "$var"; done
unset NODE_ENV npm_config_prefix 2>/dev/null || true
export COREPACK_ENABLE_DOWNLOAD_PROMPT=0
export CI="${CI:-1}"
SHIM="$HOME/.local/bin/paperclipai"
STORE="$HOME/.paperclip/cli"
RESULTS=()
FAILED=0
note() { printf '\n\033[1;34m== %s ==\033[0m\n' "$*"; }
pass() { RESULTS+=("PASS $1"); printf '\033[1;32mPASS\033[0m %s\n' "$1"; }
fail_() { RESULTS+=("FAIL $1"); printf '\033[1;31mFAIL\033[0m %s\n' "$1"; FAILED=1; }
skip_() { RESULTS+=("SKIP $1${2:+ — $2}"); printf '\033[1;33mSKIP\033[0m %s%s\n' "$1" "${2:+ — $2}"; }
shim() { "$SHIM" "$@"; }
current_target() { readlink "$STORE/current" 2>/dev/null || echo "<missing>"; }
note "0. Preflight — this machine"
uname -a
node --version && npm --version && curl --version | head -1
command -v corepack >/dev/null || npm install -g corepack
[ -e "$SHIM" ] && { echo "shim already exists at $SHIM — not a clean machine"; exit 2; }
[ -d "$STORE" ] && { echo "store already exists at $STORE — not a clean machine"; exit 2; }
echo "repo=$E2E_REPO ref=$E2E_REF home=$HOME"
note "1. Bootstrap: build the new CLI from the GitHub tarball of $E2E_REF"
# Nothing published on npm has the install/update/service commands yet, so the
# bootstrap simulates what `npx paperclipai@<channel> install` will run post-release:
# the same CLI code, built from the exact ref under test.
BOOT="$HOME/e2e-bootstrap"
mkdir -p "$BOOT"
if curl --fail --silent --show-error --location \
"https://codeload.github.com/$E2E_REPO/tar.gz/$E2E_REF" \
| tar -xz --strip-components=1 -C "$BOOT"; then
pass "1a bootstrap tarball downloaded from codeload"
else
fail_ "1a bootstrap tarball download"; exit 1
fi
cd "$BOOT"
if corepack pnpm install --frozen-lockfile > "$HOME/e2e-bootstrap-install.log" 2>&1; then
pass "1b bootstrap pnpm install"
else
tail -40 "$HOME/e2e-bootstrap-install.log"; fail_ "1b bootstrap pnpm install"; exit 1
fi
if bash scripts/build-npm.sh --skip-checks --skip-typecheck > "$HOME/e2e-bootstrap-build.log" 2>&1; then
pass "1c bootstrap build-npm.sh"
else
tail -40 "$HOME/e2e-bootstrap-build.log"; fail_ "1c bootstrap build-npm.sh"; exit 1
fi
# The in-checkout dist resolves externals against the publishable package.json,
# so run the bootstrap exactly the way npm users get it: pack + install the tarball.
TARBALL="$(cd "$BOOT/cli" && npm pack --silent 2>/dev/null | tail -1)"
mkdir -p "$HOME/e2e-bootstrap-cli"
if (cd "$HOME/e2e-bootstrap-cli" && npm install --no-fund --no-audit "$BOOT/cli/$TARBALL" > "$HOME/e2e-bootstrap-npm.log" 2>&1); then
pass "1d bootstrap CLI packed + npm-installed ($TARBALL)"
else
tail -40 "$HOME/e2e-bootstrap-npm.log"; fail_ "1d bootstrap CLI npm install"; exit 1
fi
BOOTSTRAP_CLI="$HOME/e2e-bootstrap-cli/node_modules/paperclipai/dist/index.js"
node "$BOOTSTRAP_CLI" --version >/dev/null || { fail_ "1e bootstrap CLI smoke"; exit 1; }
cd "$HOME"
if [ "${E2E_SKIP_NPM:-0}" != "1" ]; then
note "2. install (published npm latest channel; proves the npm install mechanism)"
if node "$BOOTSTRAP_CLI" install --yes; then
pass "2a install (latest) exits 0"
else
fail_ "2a install (latest) exits 0"
fi
[ -x "$SHIM" ] && pass "2b shim created at ~/.local/bin/paperclipai" || fail_ "2b shim created"
case "$(current_target)" in
*"installs/npm/"*) pass "2c current -> installs/npm/<version> ($(basename "$(current_target)"))" ;;
*) fail_ "2c current -> installs/npm/<version> (got: $(current_target))" ;;
esac
[ -f "$STORE/install.json" ] && pass "2d install.json manifest present" || fail_ "2d install.json manifest present"
NPM_VERSION="$("$SHIM" --version 2>/dev/null || true)"
[ -n "$NPM_VERSION" ] && pass "2e shim runs: paperclipai --version = $NPM_VERSION" || fail_ "2e shim runs paperclipai --version"
else
skip_ "2 install (npm latest)" "E2E_SKIP_NPM=1"
fi
note "3. install --ref $E2E_REF (real build-from-GitHub-source into the managed store)"
if node "$BOOTSTRAP_CLI" install --repo "$E2E_REPO" --ref "$E2E_REF" --yes; then
pass "3a install --ref exits 0"
else
fail_ "3a install --ref exits 0"
fi
case "$(current_target)" in
*"installs/git/"*) pass "3b current -> installs/git/<sha> ($(basename "$(current_target)"))" ;;
*) fail_ "3b current -> installs/git/<sha> (got: $(current_target))" ;;
esac
GIT_VERSION="$("$SHIM" --version 2>/dev/null || true)"
[ -n "$GIT_VERSION" ] && pass "3c shim runs git payload: --version = $GIT_VERSION" || fail_ "3c shim runs git payload"
[ -x "$SHIM" ] && pass "3d shim still in place" || fail_ "3d shim still in place"
note "4. update --check from the managed shim"
shim update --check --json; CHECK_EXIT=$?
if [ "$CHECK_EXIT" -eq 0 ] || [ "$CHECK_EXIT" -eq 10 ]; then
pass "4a update --check exits $CHECK_EXIT (0=current, 10=update available)"
else
fail_ "4a update --check exit code (got $CHECK_EXIT)"
fi
if [ "${E2E_SKIP_NPM:-0}" != "1" ]; then
note "5. update --rollback (git payload -> previous npm payload)"
if shim update --rollback; then
pass "5a update --rollback exits 0"
else
fail_ "5a update --rollback exits 0"
fi
case "$(current_target)" in
*"installs/npm/"*) pass "5b rollback restored npm payload ($(basename "$(current_target)"))" ;;
*) fail_ "5b rollback restored npm payload (got: $(current_target))" ;;
esac
ROLLED_VERSION="$("$SHIM" --version 2>/dev/null || true)"
[ "$ROLLED_VERSION" = "$NPM_VERSION" ] \
&& pass "5c version after rollback matches npm payload ($ROLLED_VERSION)" \
|| fail_ "5c version after rollback ($ROLLED_VERSION != $NPM_VERSION)"
note "6. reinstall the git ref (payload retained -> reused, no rebuild)"
REINSTALL_START=$(date +%s)
if node "$BOOTSTRAP_CLI" install --repo "$E2E_REPO" --ref "$E2E_REF" --yes; then
REINSTALL_SECS=$(( $(date +%s) - REINSTALL_START ))
pass "6a reinstall exits 0 (${REINSTALL_SECS}s — reused payload should be fast)"
else
fail_ "6a reinstall exits 0"
fi
case "$(current_target)" in
*"installs/git/"*) pass "6b back on git payload" ;;
*) fail_ "6b back on git payload (got: $(current_target))" ;;
esac
else
skip_ "5-6 rollback/reinstall" "E2E_SKIP_NPM=1"
fi
note "7. failure hygiene: install --ref <nonexistent> must fail cleanly"
BEFORE_DIRS="$(ls "$STORE/installs/git" 2>/dev/null | sort)"
if node "$BOOTSTRAP_CLI" install --ref e2e-definitely-not-a-ref-xyz --yes 2>&1; then
fail_ "7a bad ref rejected (command unexpectedly succeeded)"
else
pass "7a bad ref rejected with nonzero exit"
fi
AFTER_DIRS="$(ls "$STORE/installs/git" 2>/dev/null | sort)"
[ "$BEFORE_DIRS" = "$AFTER_DIRS" ] && pass "7b no partial install dir left behind" || fail_ "7b no partial install dir left behind"
"$SHIM" --version >/dev/null 2>&1 && pass "7c existing install still healthy" || fail_ "7c existing install still healthy"
if [ "${E2E_SKIP_SERVICE:-0}" = "1" ]; then
skip_ "8 service lifecycle" "E2E_SKIP_SERVICE=1"
else
if [ "$(uname -s)" = "Linux" ] && [ ! -S "/run/user/$(id -u)/bus" ]; then
skip_ "8 service lifecycle" "no systemd user bus at /run/user/$(id -u)/bus"
else
note "8. service lifecycle ($(uname -s): systemd/launchd)"
# Real quickstart path: onboard with defaults, then install + start the service.
if shim onboard --yes --install-service; then
pass "8a onboard --yes --install-service exits 0"
else
fail_ "8a onboard --yes --install-service exits 0"
fi
DEADLINE=$(( $(date +%s) + E2E_SERVICE_TIMEOUT_SECS ))
ACTIVE=0
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
STATUS_JSON="$(shim service status --json 2>/dev/null || true)"
if echo "$STATUS_JSON" | grep -q '"active"[[:space:]]*:[[:space:]]*true'; then ACTIVE=1; break; fi
sleep 5
done
if [ "$ACTIVE" = "1" ]; then
pass "8b service reached active within ${E2E_SERVICE_TIMEOUT_SECS}s"
else
echo "last status: ${STATUS_JSON:-<none>}"
shim service logs -n 60 || true
fail_ "8b service reached active"
fi
shim service logs -n 20 >/dev/null 2>&1 && pass "8c service logs readable" || fail_ "8c service logs readable"
if shim service stop; then pass "8d service stop exits 0"; else fail_ "8d service stop exits 0"; fi
if shim service uninstall; then pass "8e service uninstall exits 0"; else fail_ "8e service uninstall exits 0"; fi
fi
fi
note "9. installer script guardrails (from the bootstrap checkout)"
# Capture first: under pipefail, install.sh's expected exit 1 would fail the pipeline.
GUARD_OUT="$(bash "$BOOT/scripts/install.sh" --ref deadbeef 2>&1 || true)"
if echo "$GUARD_OUT" | grep -qi "not supported"; then
pass "9a install.sh rejects --ref with guidance to npx path"
else
echo "$GUARD_OUT" | tail -3
fail_ "9a install.sh rejects --ref"
fi
note "10. uninstall preserves user data"
mkdir -p "$HOME/.paperclip" && touch "$HOME/.paperclip/e2e-user-data-marker"
if shim uninstall; then
pass "10a uninstall exits 0"
else
fail_ "10a uninstall exits 0"
fi
[ ! -e "$SHIM" ] && pass "10b shim removed" || fail_ "10b shim removed"
[ ! -d "$STORE" ] && pass "10c managed store removed" || fail_ "10c managed store removed"
[ -f "$HOME/.paperclip/e2e-user-data-marker" ] && pass "10d user data under ~/.paperclip preserved" || fail_ "10d user data preserved"
note "RESULTS ($E2E_REPO@$E2E_REF on $(uname -sm))"
printf '%s\n' "${RESULTS[@]}"
if [ "$FAILED" = "1" ]; then echo; echo "OVERALL: FAIL"; exit 1; fi
echo; echo "OVERALL: PASS"

View File

@ -13,6 +13,7 @@
*/
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { createRequire } from "node:module";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { bundledCliNpmDependencies } from "./cli-bundled-npm-dependencies.mjs";
@ -75,6 +76,15 @@ for (const pkgPath of workspacePaths) {
}
}
if (bundledCliNpmDependencies.has("embedded-postgres")) {
const requireFromDb = createRequire(resolve(repoRoot, "packages/db/package.json"));
const embeddedPostgresRoot = dirname(requireFromDb.resolve("embedded-postgres"));
const embeddedPostgresPackage = JSON.parse(
readFileSync(resolve(embeddedPostgresRoot, "..", "package.json"), "utf8"),
);
Object.assign(allOptionalDeps, embeddedPostgresPackage.optionalDependencies ?? {});
}
// Sort alphabetically
const sortedDeps = Object.fromEntries(Object.entries(allDeps).sort(([a], [b]) => a.localeCompare(b)));
const sortedOptDeps = Object.fromEntries(

15
scripts/install-sh-fixtures/npx Executable file
View File

@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
: "${PAPERCLIP_INSTALL_TEST_LOG:?PAPERCLIP_INSTALL_TEST_LOG is required}"
node --version >"${PAPERCLIP_INSTALL_TEST_LOG}.node"
{
printf 'NPM_CONFIG_REGISTRY=%s\n' "${NPM_CONFIG_REGISTRY:-}"
printf 'npm_config_registry=%s\n' "${npm_config_registry:-}"
printf 'NPM_CONFIG_USERCONFIG=%s\n' "${NPM_CONFIG_USERCONFIG:-}"
printf 'npm_config_userconfig=%s\n' "${npm_config_userconfig:-}"
if [ -n "${NPM_CONFIG_USERCONFIG:-}" ] && [ -f "$NPM_CONFIG_USERCONFIG" ]; then
sed 's/^/npmrc:/' "$NPM_CONFIG_USERCONFIG"
fi
printf '%s\n' "$@"
} >>"$PAPERCLIP_INSTALL_TEST_LOG"

407
scripts/install.sh Executable file
View File

@ -0,0 +1,407 @@
#!/usr/bin/env bash
set -euo pipefail
MIN_NODE_MAJOR=20
DEFAULT_NODE_MAJOR=22
PAPERCLIP_PACKAGE="paperclipai"
PUBLIC_NPM_REGISTRY="https://registry.npmjs.org"
HOMEBREW_INSTALL_COMMIT="99e13e96cbbdc1ac1ac09c0a40b450bf219ef3aa"
HOMEBREW_INSTALL_SHA256="99287f194a8b3c9e6b0203a11a5fa54518be57209343e6bb954dec4635796d9d"
NODESOURCE_DISTRIBUTIONS_COMMIT="9b431d8ae0f10df272598585855c6eca6c0e1bd2"
NODESOURCE_DEB_SHA256="575583bbac2fccc0b5edd0dbc03e222d9f9dc8d724da996d22754d6411104fd1"
NODESOURCE_RPM_SHA256="b0ed2b9b66002e7ee802e8777cf3a92b25f1ecc0129812dc6f59a43a536810cc"
CANARY=0
VERSION=""
REF=""
REPO=""
NO_ONBOARD=0
NO_PROMPT=0
INSTALL_SERVICE=0
DRY_RUN=0
VERBOSE=0
TEMP_DIR=""
PIPED_INSTALL=0
if [ -z "${BASH_SOURCE[0]:-}" ] || [ ! -f "${BASH_SOURCE[0]}" ]; then
PIPED_INSTALL=1
fi
usage() {
cat <<'EOF'
Install Paperclip on macOS, Linux, or WSL2.
Usage:
curl -fsSLO https://paperclip.ing/install.sh
bash install.sh [options]
curl -fsSL https://paperclip.ing/install.sh | bash -s -- --no-prompt [options]
Options:
--canary Install the canary channel
--version <version> Install an exact published version
--no-onboard Do not start onboarding after installation
--no-prompt Run non-interactively
--install-service Install the per-user Paperclip service
--dry-run Print the install plan without changing files
--verbose Enable verbose installer output
-h, --help Show this help
Every option also has a PAPERCLIP_INSTALL_* environment equivalent, for example
PAPERCLIP_INSTALL_VERSION=2026.722.0 and PAPERCLIP_INSTALL_NO_PROMPT=1.
To install from a git branch, tag, or commit, use the Paperclip CLI directly:
npx paperclipai install --ref <ref>
EOF
}
log() {
printf '[paperclip] %s\n' "$*"
}
fail() {
printf '[paperclip] error: %s\n' "$*" >&2
exit 1
}
parse_bool() {
local name="$1"
local value="${2:-}"
# ${value,,} requires bash 4; macOS ships bash 3.2, so lowercase portably.
value="$(printf '%s' "$value" | tr '[:upper:]' '[:lower:]')"
case "$value" in
""|0|false|no|off) printf '0' ;;
1|true|yes|on) printf '1' ;;
*) fail "$name must be one of: 1, 0, true, false, yes, no, on, off" ;;
esac
}
require_value() {
local option="$1"
local value="${2:-}"
[ -n "$value" ] || fail "$option requires a value"
}
cleanup() {
if [ -n "$TEMP_DIR" ] && [ -d "$TEMP_DIR" ]; then
rm -rf "$TEMP_DIR"
fi
}
trap cleanup EXIT
CANARY="$(parse_bool PAPERCLIP_INSTALL_CANARY "${PAPERCLIP_INSTALL_CANARY:-}")"
VERSION="${PAPERCLIP_INSTALL_VERSION:-}"
REF="${PAPERCLIP_INSTALL_REF:-}"
REPO="${PAPERCLIP_INSTALL_REPO:-}"
NO_ONBOARD="$(parse_bool PAPERCLIP_INSTALL_NO_ONBOARD "${PAPERCLIP_INSTALL_NO_ONBOARD:-}")"
NO_PROMPT="$(parse_bool PAPERCLIP_INSTALL_NO_PROMPT "${PAPERCLIP_INSTALL_NO_PROMPT:-}")"
INSTALL_SERVICE="$(parse_bool PAPERCLIP_INSTALL_INSTALL_SERVICE "${PAPERCLIP_INSTALL_INSTALL_SERVICE:-}")"
DRY_RUN="$(parse_bool PAPERCLIP_INSTALL_DRY_RUN "${PAPERCLIP_INSTALL_DRY_RUN:-}")"
VERBOSE="$(parse_bool PAPERCLIP_INSTALL_VERBOSE "${PAPERCLIP_INSTALL_VERBOSE:-}")"
while [ "$#" -gt 0 ]; do
case "$1" in
--canary)
CANARY=1
shift
;;
--version)
require_value "$1" "${2:-}"
VERSION="$2"
shift 2
;;
--ref)
require_value "$1" "${2:-}"
REF="$2"
shift 2
;;
--repo)
require_value "$1" "${2:-}"
REPO="$2"
shift 2
;;
--no-onboard)
NO_ONBOARD=1
shift
;;
--no-prompt)
NO_PROMPT=1
shift
;;
--install-service)
INSTALL_SERVICE=1
shift
;;
--dry-run)
DRY_RUN=1
shift
;;
--verbose)
VERBOSE=1
shift
;;
-h|--help)
usage
exit 0
;;
--)
shift
break
;;
*)
fail "unknown option: $1"
;;
esac
done
[ "$#" -eq 0 ] || fail "unexpected argument: $1"
if [ "$CANARY" = "1" ] && [ -n "$VERSION" ]; then
fail "--canary and --version cannot be used together"
fi
if [ -n "$REF" ] || [ -n "$REPO" ]; then
fail "git-ref installs are not supported by install.sh; run 'npx paperclipai install --ref <ref>' instead"
fi
if { [ ! -t 0 ] || [ ! -t 1 ]; } && [ "$NO_PROMPT" != "1" ]; then
fail "non-interactive installation requires explicit --no-prompt; download the script first for interactive review"
fi
if [ "$VERBOSE" = "1" ]; then
set -x
fi
OS="$(uname -s 2>/dev/null || true)"
ARCH="$(uname -m 2>/dev/null || true)"
case "$OS" in
Darwin) OS_NAME="macos" ;;
Linux) OS_NAME="linux" ;;
*) fail "unsupported operating system: ${OS:-unknown}. Use macOS, Linux, or WSL2." ;;
esac
case "$ARCH" in
x86_64|amd64) ARCH_NAME="x64" ;;
arm64|aarch64) ARCH_NAME="arm64" ;;
*) fail "unsupported architecture: ${ARCH:-unknown}. Supported architectures: x64, arm64." ;;
esac
log "Detected $OS_NAME/$ARCH_NAME"
node_major() {
local version
version="$(node --version 2>/dev/null || true)"
version="${version#v}"
printf '%s' "${version%%.*}"
}
has_supported_node() {
local major
command -v node >/dev/null 2>&1 || return 1
major="$(node_major)"
[[ "$major" =~ ^[0-9]+$ ]] || return 1
[ "$major" -ge "$MIN_NODE_MAJOR" ] || return 1
command -v npm >/dev/null 2>&1 || return 1
command -v npx >/dev/null 2>&1 || return 1
}
print_command() {
printf '[paperclip] +'
printf ' %q' "$@"
printf '\n'
}
confirm_command() {
print_command "$@"
if [ "$NO_PROMPT" = "1" ]; then
return 0
fi
local answer
printf '[paperclip] Run this command? [y/N] ' >/dev/tty
IFS= read -r answer </dev/tty || answer=""
case "$answer" in
y|Y|yes|YES|Yes) ;;
*) fail "installation cancelled" ;;
esac
}
run_command() {
confirm_command "$@"
"$@"
}
run_privileged() {
if [ "$(id -u)" -eq 0 ]; then
run_command "$@"
return
fi
command -v sudo >/dev/null 2>&1 || fail "sudo is required to install Node.js with the system package manager"
run_command sudo "$@"
}
ensure_temp_dir() {
if [ -z "$TEMP_DIR" ]; then
TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-install.XXXXXX")"
fi
}
download_checked_script() {
local url="$1"
local destination="$2"
local expected_sha256="$3"
local actual_sha256
command -v curl >/dev/null 2>&1 || fail "curl is required to bootstrap Node.js"
curl --proto '=https' --tlsv1.2 -fsSL "$url" -o "$destination"
[ -s "$destination" ] || fail "downloaded script is empty: $url"
if command -v sha256sum >/dev/null 2>&1; then
actual_sha256="$(sha256sum "$destination" | awk '{print $1}')"
elif command -v shasum >/dev/null 2>&1; then
actual_sha256="$(shasum -a 256 "$destination" | awk '{print $1}')"
else
fail "sha256sum or shasum is required to verify downloaded scripts"
fi
[ "$actual_sha256" = "$expected_sha256" ] || fail "checksum mismatch for downloaded script: $url"
[ "$(head -c 2 "$destination")" = '#!' ] || fail "downloaded file is not an executable script: $url"
bash -n "$destination" || fail "downloaded script failed syntax validation: $url"
}
check_version_manager() {
if [ -n "${NVM_DIR:-}" ] || [ -d "${HOME:-}/.nvm" ]; then
fail "nvm was detected. Run 'nvm install ${DEFAULT_NODE_MAJOR}' and retry this installer."
fi
if command -v asdf >/dev/null 2>&1 || [ -d "${HOME:-}/.asdf" ]; then
fail "asdf was detected. Run 'asdf install nodejs ${DEFAULT_NODE_MAJOR}' and retry this installer."
fi
}
install_node_macos() {
if ! command -v brew >/dev/null 2>&1; then
ensure_temp_dir
local brew_installer="$TEMP_DIR/homebrew-install.sh"
log "Homebrew is required to install Node.js"
download_checked_script "https://raw.githubusercontent.com/Homebrew/install/$HOMEBREW_INSTALL_COMMIT/install.sh" "$brew_installer" "$HOMEBREW_INSTALL_SHA256"
if [ "$NO_PROMPT" = "1" ]; then
run_command env NONINTERACTIVE=1 /bin/bash "$brew_installer"
else
run_command /bin/bash "$brew_installer"
fi
if [ -x /opt/homebrew/bin/brew ]; then
eval "$(/opt/homebrew/bin/brew shellenv)"
elif [ -x /usr/local/bin/brew ]; then
eval "$(/usr/local/bin/brew shellenv)"
fi
fi
command -v brew >/dev/null 2>&1 || fail "Homebrew installation completed but 'brew' is not available on PATH"
run_command brew install node
}
install_node_apt() {
ensure_temp_dir
local nodesource_installer="$TEMP_DIR/nodesource-setup.sh"
run_privileged env DEBIAN_FRONTEND=noninteractive apt-get update
run_privileged env DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl
download_checked_script "https://raw.githubusercontent.com/nodesource/distributions/$NODESOURCE_DISTRIBUTIONS_COMMIT/scripts/deb/setup_${DEFAULT_NODE_MAJOR}.x" "$nodesource_installer" "$NODESOURCE_DEB_SHA256"
run_privileged env DEBIAN_FRONTEND=noninteractive bash "$nodesource_installer"
run_privileged env DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
}
install_node_dnf() {
ensure_temp_dir
local nodesource_installer="$TEMP_DIR/nodesource-setup.sh"
run_privileged dnf install -y ca-certificates curl
download_checked_script "https://raw.githubusercontent.com/nodesource/distributions/$NODESOURCE_DISTRIBUTIONS_COMMIT/scripts/rpm/setup_${DEFAULT_NODE_MAJOR}.x" "$nodesource_installer" "$NODESOURCE_RPM_SHA256"
run_privileged bash "$nodesource_installer"
run_privileged dnf install -y nodejs
}
install_node_linux() {
if command -v apt-get >/dev/null 2>&1; then
install_node_apt
elif command -v dnf >/dev/null 2>&1; then
install_node_dnf
elif command -v pacman >/dev/null 2>&1; then
run_privileged pacman -Sy --noconfirm --needed nodejs npm
elif command -v apk >/dev/null 2>&1; then
run_privileged apk add --no-cache nodejs npm
else
fail "no supported Node.js package manager found. Supported: apt, dnf, pacman, apk."
fi
}
if has_supported_node; then
log "Using Node.js $(node --version)"
else
if command -v node >/dev/null 2>&1; then
log "Node.js $(node --version 2>/dev/null || printf unknown) is too old; Node.js >= $MIN_NODE_MAJOR is required"
else
log "Node.js was not found"
fi
if [ "$PIPED_INSTALL" = "1" ]; then
fail "Node.js bootstrap is disabled for piped installs; download install.sh, review it, and run 'bash install.sh --no-prompt'"
fi
check_version_manager
log "Installing Node.js $DEFAULT_NODE_MAJOR"
if [ "$OS_NAME" = "macos" ]; then
install_node_macos
else
install_node_linux
fi
has_supported_node || fail "Node.js installation finished, but Node.js >= $MIN_NODE_MAJOR with npm/npx is not available"
log "Installed Node.js $(node --version)"
fi
PACKAGE_SPEC="$PAPERCLIP_PACKAGE@latest"
if [ "$CANARY" = "1" ]; then
PACKAGE_SPEC="$PAPERCLIP_PACKAGE@canary"
elif [ -n "$VERSION" ]; then
PACKAGE_SPEC="$PAPERCLIP_PACKAGE@$VERSION"
fi
INSTALL_ARGS=(install)
[ "$CANARY" = "1" ] && INSTALL_ARGS+=(--canary)
[ -n "$VERSION" ] && INSTALL_ARGS+=(--version "$VERSION")
[ "$NO_PROMPT" = "1" ] && INSTALL_ARGS+=(--yes)
ensure_temp_dir
NPM_USERCONFIG="$TEMP_DIR/npmrc"
printf 'registry=%s\n@paperclipai:registry=%s\n' "$PUBLIC_NPM_REGISTRY" "$PUBLIC_NPM_REGISTRY" >"$NPM_USERCONFIG"
chmod 600 "$NPM_USERCONFIG"
NPM_ENV=(env "NPM_CONFIG_REGISTRY=$PUBLIC_NPM_REGISTRY" "npm_config_registry=$PUBLIC_NPM_REGISTRY" "NPM_CONFIG_USERCONFIG=$NPM_USERCONFIG" "npm_config_userconfig=$NPM_USERCONFIG")
INSTALL_COMMAND=("${NPM_ENV[@]}" npx --yes "--registry=$PUBLIC_NPM_REGISTRY" "$PACKAGE_SPEC" "${INSTALL_ARGS[@]}")
log "Delegating to the Paperclip CLI"
if [ "$DRY_RUN" = "1" ]; then
print_command "${INSTALL_COMMAND[@]}"
exit 0
fi
print_command "${INSTALL_COMMAND[@]}"
"${INSTALL_COMMAND[@]}"
if [ "$INSTALL_SERVICE" = "1" ]; then
log "Installing the Paperclip service"
print_command "${NPM_ENV[@]}" npx --yes "--registry=$PUBLIC_NPM_REGISTRY" "$PACKAGE_SPEC" service install
"${NPM_ENV[@]}" npx --yes "--registry=$PUBLIC_NPM_REGISTRY" "$PACKAGE_SPEC" service install
fi
if [ "$NO_ONBOARD" = "0" ] && [ -t 0 ] && [ -t 1 ]; then
if command -v paperclipai >/dev/null 2>&1; then
exec paperclipai onboard
elif [ -x "${HOME:-}/.local/bin/paperclipai" ]; then
exec "${HOME}/.local/bin/paperclipai" onboard
else
fail "Paperclip was installed, but 'paperclipai' is not available on PATH. Open a new shell and run 'paperclipai onboard'."
fi
fi
if [ "$NO_ONBOARD" = "0" ]; then
log "Installation complete. Next: paperclipai onboard"
else
log "Installation complete."
fi

View File

@ -1,7 +1,7 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { readFileSync, rmSync, writeFileSync } from "node:fs";
import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
@ -15,10 +15,37 @@ export function materializePublishManifest(pkg) {
if (publishConfig[key] !== undefined) publishManifest[key] = publishConfig[key];
}
for (const section of ["dependencies", "optionalDependencies", "peerDependencies"]) {
if (!publishManifest[section]) continue;
publishManifest[section] = Object.fromEntries(
Object.entries(publishManifest[section]).map(([name, specifier]) => {
if (typeof specifier !== "string" || !specifier.startsWith("workspace:")) return [name, specifier];
const range = specifier.slice("workspace:".length);
const prefix = range === "^" || range === "~" ? range : "";
return [name, `${prefix}${pkg.version}`];
}),
);
}
delete publishManifest.publishConfig;
return publishManifest;
}
export function createBundledInstallManifest(publishManifest, bundledDependencies) {
const bundledDependencyNames = new Set(bundledDependencies);
const installManifest = structuredClone(publishManifest);
for (const section of ["dependencies", "optionalDependencies", "peerDependencies"]) {
if (!installManifest[section]) continue;
installManifest[section] = Object.fromEntries(
Object.entries(installManifest[section]).filter(([name]) => bundledDependencyNames.has(name)),
);
if (Object.keys(installManifest[section]).length === 0) delete installManifest[section];
}
return installManifest;
}
function patchedDependencyPackageName(specifier) {
const versionSeparator = specifier.lastIndexOf("@");
return versionSeparator > 0 ? specifier.slice(0, versionSeparator) : specifier;
@ -53,25 +80,27 @@ export function prepareBundledPackage(sourceDir, destinationDir) {
throw new Error(`${sourcePackage.name} does not declare bundled dependencies`);
}
execFileSync(
"pnpm",
["--filter", sourcePackage.name, "deploy", "--prod", resolve(destinationDir)],
{ cwd: repoRoot, stdio: "inherit" },
);
rmSync(destinationDir, { recursive: true, force: true });
mkdirSync(destinationDir, { recursive: true });
for (const entry of sourcePackage.files ?? []) {
cpSync(resolve(sourceDir, entry), resolve(destinationDir, entry), { recursive: true });
}
for (const entry of ["README.md", "LICENSE", "LICENSE.md"]) {
const sourcePath = resolve(sourceDir, entry);
if (existsSync(sourcePath)) cpSync(sourcePath, resolve(destinationDir, entry));
}
const deployedPackagePath = resolve(destinationDir, "package.json");
const deployedPackage = JSON.parse(readFileSync(deployedPackagePath, "utf8"));
writeFileSync(
deployedPackagePath,
`${JSON.stringify(materializePublishManifest(deployedPackage), null, 2)}\n`,
);
const publishManifest = materializePublishManifest(sourcePackage);
const installManifest = createBundledInstallManifest(publishManifest, bundledDependencies);
writeFileSync(deployedPackagePath, `${JSON.stringify(installManifest, null, 2)}\n`);
rmSync(resolve(destinationDir, "node_modules"), { recursive: true, force: true });
execFileSync(
"npm",
["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"],
{ cwd: destinationDir, stdio: "inherit" },
);
writeFileSync(deployedPackagePath, `${JSON.stringify(publishManifest, null, 2)}\n`);
applyBundledDependencyPatches(destinationDir, bundledDependencies);
if (
@ -82,6 +111,30 @@ export function prepareBundledPackage(sourceDir, destinationDir) {
) {
throw new Error("staged acpx runtime is missing the repository patch");
}
if (bundledDependencies.includes("embedded-postgres")) {
const embeddedPostgresSource = readFileSync(
resolve(destinationDir, "node_modules/embedded-postgres/dist/index.js"),
"utf8",
);
if (
!embeddedPostgresSource.includes("const LC_MESSAGES_LOCALE = 'C';") ||
!embeddedPostgresSource.includes("globalThis.process.env")
) {
throw new Error("staged embedded-postgres runtime is missing the repository patch");
}
const embeddedPostgresPackage = JSON.parse(
readFileSync(resolve(destinationDir, "node_modules/embedded-postgres/package.json"), "utf8"),
);
const stagedPackage = JSON.parse(readFileSync(deployedPackagePath, "utf8"));
stagedPackage.optionalDependencies = {
...(stagedPackage.optionalDependencies ?? {}),
...(embeddedPostgresPackage.optionalDependencies ?? {}),
};
writeFileSync(deployedPackagePath, `${JSON.stringify(stagedPackage, null, 2)}\n`);
rmSync(resolve(destinationDir, "node_modules/@embedded-postgres"), { recursive: true, force: true });
}
}
if (process.argv[1] === fileURLToPath(import.meta.url)) {

View File

@ -385,6 +385,43 @@ publish_package_to_npm() {
return 1
}
publish_package_to_npm_and_wait() {
local dist_tag="$1"
local package_name="$2"
local package_version="$3"
local publish_tool="${4:-pnpm}"
local attempts="${5:-12}"
local delay_seconds="${6:-5}"
publish_package_to_npm "$dist_tag" "$package_name" "$package_version" "$publish_tool" || return 1
if wait_for_npm_package_version "$package_name" "$package_version" "$attempts" "$delay_seconds"; then
return 0
fi
release_warn "npm accepted ${package_name}@${package_version}, but the version did not become registry-visible."
return 1
}
verify_npm_installable() {
local package_spec="$1"
local expected_version="$2"
local install_dir
local installed_version
install_dir="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-release-install.XXXXXX")"
if ! npm install --prefix "$install_dir" "$package_spec" --no-audit --no-fund; then
rm -rf "$install_dir"
return 1
fi
installed_version="$(node -e "console.log(require(process.argv[1]).version)" "$install_dir/node_modules/paperclipai/package.json")"
rm -rf "$install_dir"
[ "$installed_version" = "$expected_version" ]
}
wait_for_release_registry_state() {
local attempts="${1:-12}"
local delay_seconds="${2:-5}"

View File

@ -17,6 +17,7 @@ function runPublishHelper({
distTag = "canary",
callerPipefail = true,
publishTool = "pnpm",
waitForRegistry = false,
}) {
const fixtureDir = mkdtempSync(join(tmpdir(), "paperclip-release-lib-"));
const binDir = join(fixtureDir, "bin");
@ -129,7 +130,11 @@ exec npm "$@"
const script = `
${shellOptions}
source "${repoRoot}/scripts/release-lib.sh"
publish_package_to_npm ${distTag} @paperclipai/example 1.2.3 ${publishTool}
${
waitForRegistry
? `publish_package_to_npm_and_wait ${distTag} @paperclipai/example 1.2.3 ${publishTool} 1 0`
: `publish_package_to_npm ${distTag} @paperclipai/example 1.2.3 ${publishTool}`
}
`;
let status = 0;
@ -239,3 +244,23 @@ test("publish_package_to_npm does not retry stable publishes without provenance"
assert.match(result.calls, /^npm view @paperclipai\/example@1\.2\.3 version$/m);
assert.doesNotMatch(result.calls, /--provenance=false/);
});
test("publish_package_to_npm_and_wait confirms registry visibility before returning", () => {
const result = runPublishHelper({
pnpmMode: "success",
npmVersionExists: true,
waitForRegistry: true,
});
assert.equal(result.status, 0);
assert.match(result.calls, /^pnpm publish --no-git-checks --tag canary --access public$/m);
assert.match(result.calls, /^npm view @paperclipai\/example@1\.2\.3 version$/m);
});
test("publish_package_to_npm_and_wait blocks the release when registry visibility lags", () => {
const result = runPublishHelper({ pnpmMode: "success", waitForRegistry: true });
assert.notEqual(result.status, 0);
assert.match(result.calls, /^npm view @paperclipai\/example@1\.2\.3 version$/m);
assert.match(result.output, /did not become registry-visible/);
});

View File

@ -8,6 +8,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "..");
const manifestPath = join(repoRoot, "scripts", "release-package-manifest.json");
const roots = ["packages", "server", "ui", "cli"];
const CHANNEL_ENTRYPOINT_PACKAGE = "paperclipai";
function readJson(filePath) {
return JSON.parse(readFileSync(filePath, "utf8"));
@ -211,7 +212,22 @@ function sortTopologically(packages) {
}
function getReleasePackages() {
return sortTopologically(buildReleasePackagePlan().filter((pkg) => pkg.publishFromCi));
const ordered = sortTopologically(buildReleasePackagePlan().filter((pkg) => pkg.publishFromCi));
const entrypoint = ordered.find((pkg) => pkg.name === CHANNEL_ENTRYPOINT_PACKAGE);
if (!entrypoint) {
throw new Error(
`release package graph is missing channel entrypoint ${CHANNEL_ENTRYPOINT_PACKAGE}`,
);
}
// npm trusted publishing can authenticate `npm publish`, but not a later
// `npm dist-tag add`. Publish the user-facing CLI last so its channel tag
// cannot advance until every other release package has been accepted by npm.
return [
...ordered.filter((pkg) => pkg.name !== CHANNEL_ENTRYPOINT_PACKAGE),
entrypoint,
];
}
function replaceWorkspaceDeps(deps, version) {

View File

@ -24,6 +24,33 @@ test("release package list only contains CI-enrolled packages", () => {
assert.ok(enabledPackages.every((pkg) => pkg.publishFromCi === true));
});
test("release package list publishes the installable channel entrypoint last", () => {
const enabledPackages = getReleasePackages();
assert.equal(enabledPackages.at(-1)?.name, "paperclipai");
assert.ok(enabledPackages.slice(0, -1).some((pkg) => pkg.name === "@paperclipai/server"));
});
test("release package list keeps runtime workspace dependencies ahead of consumers", () => {
const enabledPackages = getReleasePackages();
const publishIndexByName = new Map(enabledPackages.map((pkg, index) => [pkg.name, index]));
for (const pkg of enabledPackages) {
for (const section of ["dependencies", "optionalDependencies", "peerDependencies"]) {
for (const [dependencyName, spec] of Object.entries(pkg.pkg[section] ?? {})) {
if (typeof spec !== "string" || !spec.startsWith("workspace:")) continue;
const dependencyIndex = publishIndexByName.get(dependencyName);
if (dependencyIndex === undefined) continue;
assert.ok(
dependencyIndex < publishIndexByName.get(pkg.name),
`${dependencyName} must publish before ${pkg.name}`,
);
}
}
}
});
test("Hermes release surface publishes the unified built-in package and keeps gateway as a shim", () => {
const packages = buildReleasePackagePlan();
const hermes = packages.find((pkg) => pkg.name === "@paperclipai/hermes-paperclip-adapter");

View File

@ -250,6 +250,9 @@ if [ "$VERSION_IN_CLI_PACKAGE" != "$TARGET_PUBLISH_VERSION" ]; then
release_fail "versioning drift detected. Expected $TARGET_PUBLISH_VERSION but found $VERSION_IN_CLI_PACKAGE."
fi
VERIFY_ATTEMPTS="${NPM_PUBLISH_VERIFY_ATTEMPTS:-12}"
VERIFY_DELAY_SECONDS="${NPM_PUBLISH_VERIFY_DELAY_SECONDS:-5}"
release_info ""
if [ "$dry_run" = true ]; then
release_info "==> Step 5/7: Previewing publish payloads (--dry-run)..."
@ -281,12 +284,24 @@ else
node "$REPO_ROOT/scripts/prepare-bundled-package.mjs" "$REPO_ROOT/$pkg_dir" "$publish_dir"
cd "$publish_dir"
fi
publish_package_to_npm "$DIST_TAG" "$pkg_name" "$pkg_version" "$publish_tool"
if ! publish_package_to_npm_and_wait \
"$DIST_TAG" \
"$pkg_name" \
"$pkg_version" \
"$publish_tool" \
"$VERIFY_ATTEMPTS" \
"$VERIFY_DELAY_SECONDS"; then
if [ "$publish_tool" = "npm" ]; then
rm -rf "$publish_dir"
fi
release_fail "stopping release: npm did not publish and expose ${pkg_name}@${pkg_version}"
fi
if [ "$publish_tool" = "npm" ]; then
rm -rf "$publish_dir"
fi
release_info " ✓ Published version is registry-visible"
done <<< "$VERSIONED_PACKAGE_INFO"
release_info " ✓ Published all packages under dist-tag $DIST_TAG"
release_info " ✓ Published the full package set under dist-tag $DIST_TAG"
fi
release_info ""
@ -294,29 +309,9 @@ if [ "$dry_run" = true ]; then
release_info "==> Step 6/7: Skipping npm verification in dry-run mode..."
else
release_info "==> Step 6/7: Confirming npm package availability and dist-tag integrity..."
VERIFY_ATTEMPTS="${NPM_PUBLISH_VERIFY_ATTEMPTS:-12}"
VERIFY_DELAY_SECONDS="${NPM_PUBLISH_VERIFY_DELAY_SECONDS:-5}"
REGISTRY_STATE_VERIFY_ATTEMPTS="${NPM_REGISTRY_STATE_VERIFY_ATTEMPTS:-12}"
REGISTRY_STATE_VERIFY_DELAY_SECONDS="${NPM_REGISTRY_STATE_VERIFY_DELAY_SECONDS:-5}"
MISSING_PUBLISHED_PACKAGES=""
while IFS=$'\t' read -r _pkg_dir pkg_name pkg_version; do
[ -z "$pkg_name" ] && continue
release_info " Checking $pkg_name@$pkg_version"
if wait_for_npm_package_version "$pkg_name" "$pkg_version" "$VERIFY_ATTEMPTS" "$VERIFY_DELAY_SECONDS"; then
release_info " ✓ Found on npm"
continue
fi
if [ -n "$MISSING_PUBLISHED_PACKAGES" ]; then
MISSING_PUBLISHED_PACKAGES="${MISSING_PUBLISHED_PACKAGES}, "
fi
MISSING_PUBLISHED_PACKAGES="${MISSING_PUBLISHED_PACKAGES}${pkg_name}@${pkg_version}"
done <<< "$VERSIONED_PACKAGE_INFO"
[ -z "$MISSING_PUBLISHED_PACKAGES" ] || release_fail "publish completed but npm never exposed: $MISSING_PUBLISHED_PACKAGES"
release_info " ✓ Verified all versioned packages are available on npm"
release_info " ✓ Every version was registry-visible before the next package publish"
verify_args=(
--channel "$channel"
@ -342,6 +337,12 @@ else
release_fail "publish completed, but npm dist-tags or registry metadata never converged for ${TARGET_PUBLISH_VERSION}"
fi
release_info " Installing paperclipai@$DIST_TAG into a clean prefix..."
if ! verify_npm_installable "paperclipai@$DIST_TAG" "$TARGET_PUBLISH_VERSION"; then
release_fail "paperclipai@$DIST_TAG did not install cleanly at expected version ${TARGET_PUBLISH_VERSION}"
fi
release_info " ✓ Clean-prefix install resolved ${TARGET_PUBLISH_VERSION}"
fi
release_info ""

169
scripts/test-install-sh-docker.sh Executable file
View File

@ -0,0 +1,169 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
RESULTS_DIR="$(mktemp -d "${TMPDIR:-/tmp}/paperclip-install-sh.XXXXXX")"
KEEP_RESULTS="${KEEP_RESULTS:-0}"
cleanup() {
if [ "$KEEP_RESULTS" = "1" ]; then
printf 'Kept installer test results at %s\n' "$RESULTS_DIR"
return
fi
rm -rf "$RESULTS_DIR"
}
trap cleanup EXIT
command -v docker >/dev/null 2>&1 || {
echo "docker is required" >&2
exit 1
}
run_shellcheck() {
docker run --rm \
-v "$REPO_ROOT:/work:ro" \
-w /work \
koalaman/shellcheck:stable \
scripts/install.sh scripts/test-install-sh-docker.sh scripts/install-sh-fixtures/npx
}
run_with_node() {
local name="$1"
shift
docker run --rm \
-v "$REPO_ROOT/scripts:/paperclip-scripts:ro" \
-v "$RESULTS_DIR:/results" \
-e "PAPERCLIP_INSTALL_TEST_LOG=/results/$name.args" \
-e PATH="/paperclip-scripts/install-sh-fixtures:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
node:22-bookworm-slim \
"$@"
}
assert_line() {
local file="$1"
local expected="$2"
grep -Fx -- "$expected" "$file" >/dev/null || {
printf 'Expected %q in %s\n' "$expected" "$file" >&2
cat "$file" >&2
exit 1
}
}
assert_no_line() {
local file="$1"
local unexpected="$2"
if grep -Fx -- "$unexpected" "$file" >/dev/null; then
printf 'Did not expect %q in %s\n' "$unexpected" "$file" >&2
cat "$file" >&2
exit 1
fi
}
echo "==> shellcheck"
run_shellcheck
echo "==> existing Node"
run_with_node with-node bash /paperclip-scripts/install.sh --no-prompt --no-onboard
assert_line "$RESULTS_DIR/with-node.args" "paperclipai@latest"
assert_line "$RESULTS_DIR/with-node.args" "install"
assert_line "$RESULTS_DIR/with-node.args" "--yes"
assert_line "$RESULTS_DIR/with-node.args" "--registry=https://registry.npmjs.org"
assert_line "$RESULTS_DIR/with-node.args" "NPM_CONFIG_REGISTRY=https://registry.npmjs.org"
assert_line "$RESULTS_DIR/with-node.args" "npm_config_registry=https://registry.npmjs.org"
assert_line "$RESULTS_DIR/with-node.args" "npmrc:registry=https://registry.npmjs.org"
echo "==> hostile npm config isolation"
mkdir -p "$RESULTS_DIR/hostile-home"
printf 'registry=http://attacker-registry.invalid\n' >"$RESULTS_DIR/hostile-home/.npmrc"
docker run --rm \
-v "$REPO_ROOT/scripts:/paperclip-scripts:ro" \
-v "$RESULTS_DIR:/results" \
-e HOME=/results/hostile-home \
-e NPM_CONFIG_REGISTRY=http://attacker-registry.invalid \
-e npm_config_registry=http://attacker-registry.invalid \
-e PAPERCLIP_INSTALL_TEST_LOG=/results/hostile.args \
-e PATH="/paperclip-scripts/install-sh-fixtures:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
node:22-bookworm-slim \
bash /paperclip-scripts/install.sh --no-prompt --no-onboard
assert_line "$RESULTS_DIR/hostile.args" "--registry=https://registry.npmjs.org"
assert_line "$RESULTS_DIR/hostile.args" "NPM_CONFIG_REGISTRY=https://registry.npmjs.org"
assert_line "$RESULTS_DIR/hostile.args" "npm_config_registry=https://registry.npmjs.org"
assert_line "$RESULTS_DIR/hostile.args" "npmrc:registry=https://registry.npmjs.org"
echo "==> --ref master"
if run_with_node ref-master bash /paperclip-scripts/install.sh --ref master --no-onboard; then
echo "Expected --ref to fail until git-ref installation support is integrated" >&2
exit 1
fi
[ ! -e "$RESULTS_DIR/ref-master.args" ] || {
echo "Expected --ref failure before invoking npx" >&2
exit 1
}
echo "==> piped mode requires explicit consent"
if run_with_node piped-rejected bash -c 'cat /paperclip-scripts/install.sh | bash -s -- --no-onboard'; then
echo "Expected piped install without --no-prompt to fail" >&2
exit 1
fi
echo "==> piped --no-prompt"
run_with_node piped bash -c 'cat /paperclip-scripts/install.sh | bash -s -- --no-prompt --no-onboard'
assert_line "$RESULTS_DIR/piped.args" "--yes"
echo "==> piped mode refuses privileged Node bootstrap"
if docker run --rm \
-v "$REPO_ROOT/scripts:/paperclip-scripts:ro" \
-e PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
ubuntu:24.04 \
bash -c 'cat /paperclip-scripts/install.sh | bash -s -- --no-prompt --no-onboard' \
>"$RESULTS_DIR/piped-no-node.out" 2>&1; then
echo "Expected piped install without Node.js to fail before privileged bootstrap" >&2
exit 1
fi
assert_line "$RESULTS_DIR/piped-no-node.out" "[paperclip] error: Node.js bootstrap is disabled for piped installs; download install.sh, review it, and run 'bash install.sh --no-prompt'"
echo "==> dry run"
run_with_node dry-run bash /paperclip-scripts/install.sh --no-prompt --dry-run --no-onboard
[ ! -e "$RESULTS_DIR/dry-run.args" ] || {
echo "Expected --dry-run to avoid invoking npx" >&2
exit 1
}
echo "==> environment twins"
docker run --rm \
-v "$REPO_ROOT/scripts:/paperclip-scripts:ro" \
-v "$RESULTS_DIR:/results" \
-e PAPERCLIP_INSTALL_TEST_LOG=/results/env.args \
-e PAPERCLIP_INSTALL_VERSION=2026.722.0 \
-e PAPERCLIP_INSTALL_INSTALL_SERVICE=1 \
-e PAPERCLIP_INSTALL_NO_ONBOARD=1 \
-e PAPERCLIP_INSTALL_NO_PROMPT=1 \
-e PATH="/paperclip-scripts/install-sh-fixtures:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
node:22-bookworm-slim \
bash /paperclip-scripts/install.sh
assert_line "$RESULTS_DIR/env.args" "paperclipai@2026.722.0"
assert_line "$RESULTS_DIR/env.args" "--version"
assert_line "$RESULTS_DIR/env.args" "2026.722.0"
assert_no_line "$RESULTS_DIR/env.args" "--repo"
assert_no_line "$RESULTS_DIR/env.args" "--install-service"
assert_line "$RESULTS_DIR/env.args" "service"
echo "==> no Node, apt bootstrap"
docker run --rm \
-v "$REPO_ROOT/scripts:/paperclip-scripts:ro" \
-v "$RESULTS_DIR:/results" \
-e PAPERCLIP_INSTALL_TEST_LOG=/results/no-node.args \
-e PATH="/paperclip-scripts/install-sh-fixtures:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
ubuntu:24.04 \
bash -c 'apt-get update >/dev/null && apt-get install -y ca-certificates curl >/dev/null && bash /paperclip-scripts/install.sh --no-prompt --no-onboard'
assert_line "$RESULTS_DIR/no-node.args" "paperclipai@latest"
node_version="$(cat "$RESULTS_DIR/no-node.args.node")"
node_major="${node_version#v}"
node_major="${node_major%%.*}"
[ "$node_major" -ge 20 ] || {
printf 'Expected Node >= 20, got %s\n' "$node_version" >&2
exit 1
}
echo "Installer Docker checks passed."

View File

@ -3475,6 +3475,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
id: blockerId,
companyId,
projectId,
identifier: "PAP-15043",
title: "Predecessor",
status: "done",
priority: "medium",
@ -3484,6 +3485,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
id: dependentId,
companyId,
projectId,
identifier: "PAP-15046",
title: "Dependent",
status: "blocked",
priority: "medium",
@ -3493,6 +3495,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
id: foreignIssueId,
companyId,
projectId,
identifier: "PAP-15125",
title: "Foreign in-flight issue",
status: "in_progress",
priority: "medium",
@ -3861,6 +3864,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
executionWorkspaceId,
blockerId,
dependentId,
foreignIssueId,
assigneeAgentId,
} = await seedSharedWorkspaceDependency();
@ -3892,13 +3896,36 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
startedAt: new Date("2026-05-23T22:05:00.000Z"),
});
expect(await svc.listWakeableBlockedDependents(blockerId)).toEqual([]);
const pendingDependent = (await svc.list(companyId, { status: "blocked" }))
.find((issue) => issue.id === dependentId);
expect(pendingDependent?.blockerAttention).toMatchObject({
state: "needs_attention",
unresolvedBlockerCount: 1,
attentionBlockerCount: 1,
pendingFinalizeBlockerIssueIds: [blockerId],
sampleBlockerIdentifier: "PAP-15043",
});
await expect(
svc.checkout(dependentId, assigneeAgentId, ["blocked"], null),
).rejects.toMatchObject({
status: 422,
details: {
unresolvedBlockerIssueIds: [blockerId],
unresolvedBlockers: [{
issueId: blockerId,
identifier: "PAP-15043",
title: "Predecessor",
reason: "pending_finalize",
}],
},
});
// Once a workspace_finalize succeeded row lands AFTER the failed one,
// the gate opens and the dependent is wakeable.
// A later successful finalize on the same workspace, even when attributed
// to another issue, proves the shared branch is coherent past the failure.
await db.insert(workspaceOperations).values({
companyId,
executionWorkspaceId,
issueId: blockerId,
issueId: foreignIssueId,
phase: "workspace_finalize",
status: "succeeded",
startedAt: new Date("2026-05-23T22:10:00.000Z"),
@ -4081,7 +4108,7 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
const blockerId = randomUUID();
const blockedId = randomUUID();
await db.insert(issues).values([
{ id: blockerId, companyId, title: "Blocker", status: "todo", priority: "medium" },
{ id: blockerId, companyId, identifier: "PAP-1", title: "Blocker", status: "todo", priority: "medium" },
{
id: blockedId,
companyId,
@ -4099,7 +4126,18 @@ describeEmbeddedPostgres("issueService blockers and dependency wake readiness",
await expect(
svc.checkout(blockedId, assigneeAgentId, ["todo", "blocked"], null),
).rejects.toMatchObject({ status: 422 });
).rejects.toMatchObject({
status: 422,
details: {
unresolvedBlockerIssueIds: [blockerId],
unresolvedBlockers: [{
issueId: blockerId,
identifier: "PAP-1",
title: "Blocker",
reason: "not_done",
}],
},
});
});
it("wakes parents only when all direct children are terminal", async () => {

View File

@ -72,6 +72,7 @@ import { maybePersistWorktreeRuntimePorts } from "./worktree-config.js";
import { initTelemetry, getTelemetryClient } from "./telemetry.js";
import { conflict } from "./errors.js";
import { coordinateHeartbeatSchedulerShutdown } from "./shutdown.js";
import { systemdNotify } from "./services/systemd-notify.js";
import { flushInFlightRunLogMirrors } from "./services/run-log-store.js";
import type {
InstanceDatabaseBackupRunResult,
@ -1234,6 +1235,9 @@ export async function startServer(): Promise<StartedServer> {
server.listen(listenPort, config.host, () => {
server.off("error", onError);
logger.info(`Server listening on ${config.host}:${listenPort}`);
void systemdNotify(["--ready", `--status=Listening on ${config.host}:${listenPort}`]).then((notified) => {
if (notified) logger.info("Notified systemd that Paperclip is ready");
});
if (process.env.PAPERCLIP_OPEN_ON_LISTEN === "true") {
const openHost = config.host === "0.0.0.0" || config.host === "::" ? "127.0.0.1" : config.host;
const url = `http://${openHost}:${listenPort}`;
@ -1287,6 +1291,7 @@ export async function startServer(): Promise<StartedServer> {
{
const shutdown = async (signal: "SIGINT" | "SIGTERM") => {
await systemdNotify(["--stopping", `--status=Stopping after ${signal}`]);
heartbeatSchedulerStopped = true;
if (heartbeatSchedulerInterval) {
clearInterval(heartbeatSchedulerInterval);

View File

@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolvePaperclipHomeDir } from "../home-paths.js";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
export const HOT_RESTART_INTENT_FILENAME = "hot-restart-intent.json";
export const HOT_RESTART_REPORT_FILENAME = "hot-restart-report.json";
@ -56,7 +56,7 @@ export type HotRestartReport = {
};
function resolveHotRestartPath(filename: string, homeDir?: string) {
return path.join(resolvePaperclipHomeDir(homeDir), filename);
return path.join(resolvePaperclipInstanceRoot({ homeDir }), filename);
}
export function resolveHotRestartIntentPath(homeDir?: string) {

View File

@ -1023,14 +1023,20 @@ async function listPendingFinalizeBlockerIssueIds(
and(
eq(workspaceOperations.companyId, companyId),
inArray(workspaceOperations.executionWorkspaceId, executionWorkspaceIds),
or(inArray(workspaceOperations.issueId, blockerIssueIds), isNull(workspaceOperations.issueId)),
),
);
const latestAttributedByBlockerWorkspace = new Map<string, { phase: string; status: string; startedAt: Date }>();
const latestUnattributedByWorkspace = new Map<string, { phase: string; status: string; startedAt: Date }>();
const latestSuccessfulFinalizeByWorkspace = new Map<string, Date>();
for (const row of rows) {
if (!row.executionWorkspaceId) continue;
if (row.phase === "workspace_finalize" && row.status === "succeeded") {
const current = latestSuccessfulFinalizeByWorkspace.get(row.executionWorkspaceId);
if (!current || row.startedAt > current) {
latestSuccessfulFinalizeByWorkspace.set(row.executionWorkspaceId, row.startedAt);
}
}
if (row.issueId) {
const key = `${row.issueId}:${row.executionWorkspaceId}`;
if (!blockerWorkspaceKeys.has(key)) continue;
@ -1060,6 +1066,8 @@ async function listPendingFinalizeBlockerIssueIds(
?? latestUnattributedByWorkspace.get(pair.executionWorkspaceId);
if (!latest) continue; // no ops recorded -> nothing to finalize for this blocker
if (latest.phase === "workspace_finalize" && latest.status === "succeeded") continue;
const laterSuccessfulFinalize = latestSuccessfulFinalizeByWorkspace.get(pair.executionWorkspaceId);
if (laterSuccessfulFinalize && laterSuccessfulFinalize > latest.startedAt) continue;
pending.add(pair.blockerIssueId);
}
@ -1224,6 +1232,34 @@ async function listIssueDependencyReadinessMap(
return readinessMap;
}
async function listUnresolvedBlockerDetails(
dbOrTx: Pick<Db, "select">,
companyId: string,
unresolvedBlockerIssueIds: string[],
pendingFinalizeBlockerIssueIds: string[] = [],
) {
if (unresolvedBlockerIssueIds.length === 0) return [];
const pendingFinalizeIds = new Set(pendingFinalizeBlockerIssueIds);
const rows = await dbOrTx
.select({
issueId: issues.id,
identifier: issues.identifier,
title: issues.title,
})
.from(issues)
.where(and(eq(issues.companyId, companyId), inArray(issues.id, unresolvedBlockerIssueIds)));
const rowsById = new Map(rows.map((row) => [row.issueId, row]));
return unresolvedBlockerIssueIds.map((issueId) => {
const row = rowsById.get(issueId);
return {
issueId,
identifier: row?.identifier ?? null,
title: row?.title ?? null,
reason: pendingFinalizeIds.has(issueId) ? "pending_finalize" as const : "not_done" as const,
};
});
}
async function listUnresolvedBlockerIssueIds(
dbOrTx: Pick<Db, "select">,
companyId: string,
@ -1931,6 +1967,7 @@ function createIssueBlockerAttention(input: Partial<IssueBlockerAttention> = {})
coveredBlockerCount: input.coveredBlockerCount ?? 0,
stalledBlockerCount: input.stalledBlockerCount ?? 0,
attentionBlockerCount: input.attentionBlockerCount ?? 0,
pendingFinalizeBlockerIssueIds: input.pendingFinalizeBlockerIssueIds ?? [],
sampleBlockerIdentifier: input.sampleBlockerIdentifier ?? null,
sampleStalledBlockerIdentifier: input.sampleStalledBlockerIdentifier ?? null,
};
@ -2186,10 +2223,17 @@ async function listIssueBlockerAttentionMap(
let frontier = roots.map((root) => root.id);
let truncated = false;
const pendingFinalizeBlockerIssueIds = new Set<string>();
for (let depth = 0; frontier.length > 0 && depth < BLOCKER_ATTENTION_MAX_DEPTH; depth += 1) {
const nextFrontier = new Set<string>();
for (const chunk of chunkList([...new Set(frontier)], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
const readinessByIssueId = await listIssueDependencyReadinessMap(dbOrTx, companyId, chunk);
for (const readiness of readinessByIssueId.values()) {
for (const blockerIssueId of readiness.pendingFinalizeBlockerIssueIds) {
pendingFinalizeBlockerIssueIds.add(blockerIssueId);
}
}
const explicitBlockerRowsPromise: Promise<IssueBlockerAttentionQueryRow[]> = dbOrTx
.select({
issueId: issueRelations.relatedIssueId,
@ -2212,7 +2256,6 @@ async function listIssueBlockerAttentionMap(
eq(issueRelations.type, "blocks"),
inArray(issueRelations.relatedIssueId, chunk),
eq(issues.companyId, companyId),
ne(issues.status, "done"),
),
);
const childRowsPromise: Promise<IssueBlockerAttentionQueryRow[]> = dbOrTx
@ -2242,8 +2285,11 @@ async function listIssueBlockerAttentionMap(
childRowsPromise,
]);
const unresolvedExplicitBlockerRows = explicitBlockerRows.filter(
(row) => row.status !== "done" || pendingFinalizeBlockerIssueIds.has(row.blockerIssueId),
);
appendBlockerAttentionEdges(edgesByIssueId, [
...explicitBlockerRows
...unresolvedExplicitBlockerRows
.filter((row): row is IssueBlockerAttentionQueryRow & { issueId: string } => row.issueId !== null)
.map((row) => ({ issueId: row.issueId, blockerIssueId: row.blockerIssueId })),
...childRows
@ -2251,7 +2297,7 @@ async function listIssueBlockerAttentionMap(
.map((row) => ({ issueId: row.issueId, blockerIssueId: row.blockerIssueId })),
]);
for (const row of [...explicitBlockerRows, ...childRows]) {
for (const row of [...unresolvedExplicitBlockerRows, ...childRows]) {
if (!row.issueId || nodesById.has(row.blockerIssueId)) continue;
nodesById.set(row.blockerIssueId, {
id: row.blockerIssueId,
@ -2423,7 +2469,7 @@ async function listIssueBlockerAttentionMap(
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeId, sampleStalledBlockerIdentifier: null };
}
const nodeSample = blockerSampleIdentifier(node);
if (node.status === "done") {
if (node.status === "done" && !pendingFinalizeBlockerIssueIds.has(node.id)) {
return { covered: true, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
}
if (explicitWaitingIssueIds.has(node.id)) {
@ -2449,7 +2495,10 @@ async function listIssueBlockerAttentionMap(
return { covered: false, stalled: false, sampleBlockerIdentifier: nodeSample, sampleStalledBlockerIdentifier: null };
}
const downstream = (edgesByIssueId.get(node.id) ?? []).filter((edge) => nodesById.get(edge.blockerIssueId)?.status !== "done");
const downstream = (edgesByIssueId.get(node.id) ?? []).filter((edge) => {
const blocker = nodesById.get(edge.blockerIssueId);
return blocker?.status !== "done" || pendingFinalizeBlockerIssueIds.has(edge.blockerIssueId);
});
if (downstream.length > 0) {
const nextSeen = new Set(seen);
nextSeen.add(nodeId);
@ -2493,7 +2542,10 @@ async function listIssueBlockerAttentionMap(
};
for (const root of roots) {
const topLevelEdges = (edgesByIssueId.get(root.id) ?? []).filter((edge) => nodesById.get(edge.blockerIssueId)?.status !== "done");
const topLevelEdges = (edgesByIssueId.get(root.id) ?? []).filter((edge) => {
const blocker = nodesById.get(edge.blockerIssueId);
return blocker?.status !== "done" || pendingFinalizeBlockerIssueIds.has(edge.blockerIssueId);
});
if (topLevelEdges.length === 0) {
attentionMap.set(root.id, createIssueBlockerAttention({
state: "needs_attention",
@ -2539,6 +2591,9 @@ async function listIssueBlockerAttentionMap(
coveredBlockerCount,
stalledBlockerCount,
attentionBlockerCount,
pendingFinalizeBlockerIssueIds: topLevelEdges
.map((edge) => edge.blockerIssueId)
.filter((blockerIssueId) => pendingFinalizeBlockerIssueIds.has(blockerIssueId)),
sampleBlockerIdentifier: sampleEntry?.result.sampleBlockerIdentifier ?? blockerSampleIdentifier(sampleNode),
sampleStalledBlockerIdentifier:
stalledEntry?.result.sampleStalledBlockerIdentifier ?? sampleStalledFromChain ?? null,
@ -6904,13 +6959,23 @@ export function issueService(db: Db) {
throw unprocessable("in_progress issues require an assignee");
}
if (patch.status === "in_progress") {
const dependencyReadiness = blockedByIssueIds === undefined
? (await listIssueDependencyReadinessMap(dbOrTx, existing.companyId, [id])).get(id)
: null;
const unresolvedBlockerIssueIds = blockedByIssueIds !== undefined
? await listUnresolvedBlockerIssueIds(dbOrTx, existing.companyId, blockedByIssueIds)
: (
await listIssueDependencyReadinessMap(dbOrTx, existing.companyId, [id])
).get(id)?.unresolvedBlockerIssueIds ?? [];
: dependencyReadiness?.unresolvedBlockerIssueIds ?? [];
if (unresolvedBlockerIssueIds.length > 0) {
throw unprocessable("Issue is blocked by unresolved blockers", { unresolvedBlockerIssueIds });
const unresolvedBlockers = await listUnresolvedBlockerDetails(
dbOrTx,
existing.companyId,
unresolvedBlockerIssueIds,
dependencyReadiness?.pendingFinalizeBlockerIssueIds,
);
throw unprocessable("Issue is blocked by unresolved blockers", {
unresolvedBlockerIssueIds,
unresolvedBlockers,
});
}
}
const shouldValidateNextAssignee =
@ -7235,9 +7300,19 @@ export function issueService(db: Db) {
await clearCheckoutRunIfTerminal(id);
const dependencyReadiness = await listIssueDependencyReadinessMap(db, issueCompany.companyId, [id]);
const unresolvedBlockerIssueIds = dependencyReadiness.get(id)?.unresolvedBlockerIssueIds ?? [];
const readiness = dependencyReadiness.get(id);
const unresolvedBlockerIssueIds = readiness?.unresolvedBlockerIssueIds ?? [];
if (unresolvedBlockerIssueIds.length > 0) {
throw unprocessable("Issue is blocked by unresolved blockers", { unresolvedBlockerIssueIds });
const unresolvedBlockers = await listUnresolvedBlockerDetails(
db,
issueCompany.companyId,
unresolvedBlockerIssueIds,
readiness?.pendingFinalizeBlockerIssueIds,
);
throw unprocessable("Issue is blocked by unresolved blockers", {
unresolvedBlockerIssueIds,
unresolvedBlockers,
});
}
const sameRunAssigneeCondition = checkoutRunId

View File

@ -0,0 +1,8 @@
import { execFile } from "node:child_process";
export async function systemdNotify(args: string[]): Promise<boolean> {
if (!process.env.NOTIFY_SOCKET?.trim()) return false;
return await new Promise<boolean>((resolve) => {
execFile("systemd-notify", args, { windowsHide: true }, (error) => resolve(!error));
});
}