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:
parent
492555aaf9
commit
fc5a30805e
41
README.md
41
README.md
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.`);
|
||||
}
|
||||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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}`));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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}.`));
|
||||
}
|
||||
|
|
@ -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.`));
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
|
||||
}
|
||||
|
||||
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.`);
|
||||
}
|
||||
|
|
@ -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\``); }
|
||||
|
|
@ -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`;
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
76
doc/CLI.md
76
doc/CLI.md
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -400,6 +400,7 @@ export interface IssueBlockerAttention {
|
|||
coveredBlockerCount: number;
|
||||
stalledBlockerCount: number;
|
||||
attentionBlockerCount: number;
|
||||
pendingFinalizeBlockerIssueIds?: string[];
|
||||
sampleBlockerIdentifier: string | null;
|
||||
sampleStalledBlockerIdentifier: string | null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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"
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
export const bundledCliNpmDependencies = new Set([
|
||||
"acpx",
|
||||
"embedded-postgres",
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue