diff --git a/README.md b/README.md index 0d94189718..5aa71a37ee 100644 --- a/README.md +++ b/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 diff --git a/cli/esbuild.config.mjs b/cli/esbuild.config.mjs index 99e50ee479..3432ef5bf4 100644 --- a/cli/esbuild.config.mjs +++ b/cli/esbuild.config.mjs @@ -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"], diff --git a/cli/src/__tests__/install-command.test.ts b/cli/src/__tests__/install-command.test.ts new file mode 100644 index 0000000000..620504e348 --- /dev/null +++ b/cli/src/__tests__/install-command.test.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[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(); + }); + +}); diff --git a/cli/src/__tests__/install-store.test.ts b/cli/src/__tests__/install-store.test.ts new file mode 100644 index 0000000000..eede05f47d --- /dev/null +++ b/cli/src/__tests__/install-store.test.ts @@ -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; + + 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"); + }); +}); diff --git a/cli/src/__tests__/managed-install-check.test.ts b/cli/src/__tests__/managed-install-check.test.ts new file mode 100644 index 0000000000..457dbc1c6d --- /dev/null +++ b/cli/src/__tests__/managed-install-check.test.ts @@ -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" }), + ]); + }); +}); diff --git a/cli/src/__tests__/onboard-service.test.ts b/cli/src/__tests__/onboard-service.test.ts new file mode 100644 index 0000000000..c4a711f3f3 --- /dev/null +++ b/cli/src/__tests__/onboard-service.test.ts @@ -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(); + }); +}); diff --git a/cli/src/__tests__/service-health-check.test.ts b/cli/src/__tests__/service-health-check.test.ts new file mode 100644 index 0000000000..d913b2771d --- /dev/null +++ b/cli/src/__tests__/service-health-check.test.ts @@ -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((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"), + }), + ); + }); +}); diff --git a/cli/src/__tests__/service-manager.test.ts b/cli/src/__tests__/service-manager.test.ts new file mode 100644 index 0000000000..f64a312552 --- /dev/null +++ b/cli/src/__tests__/service-manager.test.ts @@ -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 { + 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("RunAtLoad"); + expect(plist).toContain("KeepAlive"); + 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"); + }); + +}); diff --git a/cli/src/__tests__/update-command.test.ts b/cli/src/__tests__/update-command.test.ts new file mode 100644 index 0000000000..2b5c68f0c2 --- /dev/null +++ b/cli/src/__tests__/update-command.test.ts @@ -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(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"); + }); + +}); diff --git a/cli/src/__tests__/update-notice.test.ts b/cli/src/__tests__/update-notice.test.ts new file mode 100644 index 0000000000..9af23edcd9 --- /dev/null +++ b/cli/src/__tests__/update-notice.test.ts @@ -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(); + }); +}); diff --git a/cli/src/checks/index.ts b/cli/src/checks/index.ts index 7c2cb86163..dd784aafec 100644 --- a/cli/src/checks/index.ts +++ b/cli/src/checks/index.ts @@ -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"; diff --git a/cli/src/checks/managed-install-check.ts b/cli/src/checks/managed-install-check.ts new file mode 100644 index 0000000000..025f1bf33f --- /dev/null +++ b/cli/src/checks/managed-install-check.ts @@ -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; +} diff --git a/cli/src/checks/service-health-check.ts b/cli/src/checks/service-health-check.ts new file mode 100644 index 0000000000..27f3a3a3ad --- /dev/null +++ b/cli/src/checks/service-health-check.ts @@ -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; + probe: (config: PaperclipConfig) => Promise; +}; + +async function probeHealth(config: PaperclipConfig): Promise { + 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 = {}, +): Promise { + 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; +} diff --git a/cli/src/commands/doctor.ts b/cli/src/commands/doctor.ts index 3ace070ed3..28ce37e3c6 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -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); } diff --git a/cli/src/commands/install.ts b/cli/src/commands/install.ts new file mode 100644 index 0000000000..1667fe3b3b --- /dev/null +++ b/cli/src/commands/install.ts @@ -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[2], +) => Promise<{ stdout: string; stderr: string }>; + +type ReleasePackageEntry = { dir: string; name: string }; + +export async function runCommandWithDiagnostics( + file: string, + args: string[], + options?: Parameters[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(); + const visited = new Set(); + 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; + 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 { + 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[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 { + 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 { + 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 { + 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 { + 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 { + 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.`); +} diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index 62158e05bb..3024360dda 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -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; @@ -322,6 +325,21 @@ function canCreateBootstrapInviteImmediately(config: Pick { 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 { "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 { "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?", diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index 9bc9655b44..41b02491b6 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -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 { 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 { 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}`)); diff --git a/cli/src/commands/service.ts b/cli/src/commands/service.ts new file mode 100644 index 0000000000..8e8a689dc6 --- /dev/null +++ b/cli/src/commands/service.ts @@ -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 { + 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 { + 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 { + 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( + instanceId: string, + callback: () => Promise, + options: { timeoutMs?: number; pollMs?: number; isProcessAlive?: (pid: number) => boolean } = {}, +): Promise { + 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 { + 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 ", "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 ", "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 ", "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); + }); +} diff --git a/cli/src/commands/uninstall.ts b/cli/src/commands/uninstall.ts new file mode 100644 index 0000000000..2125df33aa --- /dev/null +++ b/cli/src/commands/uninstall.ts @@ -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 = {}, +): Promise { + 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}.`)); +} diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts new file mode 100644 index 0000000000..4c76fa3bd2 --- /dev/null +++ b/cli/src/commands/update.ts @@ -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; confirm: (message: string) => Promise; now: () => Date; paths: InstallStorePaths; restartActiveService: (expectedVersion: string) => Promise; 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(); + 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; + 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, hasInstanceData = hasPaperclipInstanceData): Promise { + 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 { + 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): { 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 { + 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, 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, + validationError: unknown, + payloadLabel: string, +): Promise { + 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 = {}): Promise { + 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.`)); +} diff --git a/cli/src/index.ts b/cli/src/index.ts index c53454636e..fd8d1ee4f7 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -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 ", "Install an exact published npm version") + .option("--ref ", "Install a GitHub branch, tag, or commit SHA") + .option("--repo ", "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 ", "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 ", DATA_DIR_OPTION_HELP) .option("--bind ", "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 ", "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"); diff --git a/cli/src/install-store.ts b/cli/src/install-store.ts new file mode 100644 index 0000000000..a89e867dc7 --- /dev/null +++ b/cli/src/install-store.ts @@ -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( + callback: () => Promise, + paths = resolveInstallStorePaths(), + options: { initialize?: boolean } = {}, +): Promise { + 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; + } +} diff --git a/cli/src/onboard-service.ts b/cli/src/onboard-service.ts new file mode 100644 index 0000000000..50cc8e052f --- /dev/null +++ b/cli/src/onboard-service.ts @@ -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; + confirm: () => Promise; + confirmLinger: () => Promise; + 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 = {}, +): Promise { + 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; +} diff --git a/cli/src/services/service-manager.ts b/cli/src/services/service-manager.ts new file mode 100644 index 0000000000..f284afa59b --- /dev/null +++ b/cli/src/services/service-manager.ts @@ -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; + start(): Promise; + stop(): Promise; + restart(): Promise; + status(): Promise; + logs(follow: boolean, lines: number): Promise; + enableLinger?(): Promise; +} + +export type CommandResult = { stdout: string; stderr: string }; +export type CommandRunner = (command: string, args: string[], options?: { inherit?: boolean }) => Promise; + +export const defaultCommandRunner: CommandRunner = async (command, args, options) => { + if (options?.inherit) { + await new Promise((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 ` + + + + Label${escapeXml(label)} + ProgramArguments + + ${escapeXml(input.shimPath)}run--instance${escapeXml(input.instanceId)} + + EnvironmentVariables + + PAPERCLIP_SERVICE_MANAGED1 + PAPERCLIP_INSTANCE_ID${escapeXml(input.instanceId)} + PAPERCLIP_HOME${escapeXml(input.homeDir)} + + RunAtLoad + KeepAlive + ThrottleInterval5 + ExitTimeOut300 + StandardOutPath${escapeXml(input.stdoutPath)} + StandardErrorPath${escapeXml(input.stderrPath)} + + +`; +} + +async function writeIfChanged(filePath: string, contents: string): Promise { + 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 { + 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 { + 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 { await this.ensureCurrent(); await this.runner("systemctl", ["--user", "start", this.serviceName]); } + async stop(): Promise { await this.runner("systemctl", ["--user", "stop", this.serviceName]); } + async restart(): Promise { await this.ensureCurrent(); await this.runner("systemctl", ["--user", "restart", this.serviceName]); } + + async status(): Promise { + 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 { + 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 { await this.runner("loginctl", ["enable-linger", os.userInfo().username]); } + async logs(follow: boolean, lines: number): Promise { 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 { + 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 { await this.install({ startNow: true, startOnLogin: await this.isEnabled() }); } + async stop(): Promise { await this.runner("launchctl", ["bootout", `${this.domain}/${this.serviceName}`]); } + async restart(): Promise { await writeIfChanged(this.definitionPath, this.renderDefinition()); await this.runner("launchctl", ["kickstart", "-k", `${this.domain}/${this.serviceName}`]); } + + async status(): Promise { + 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 { + 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 { 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 { + 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 { + 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.`); +} diff --git a/cli/src/update-notice.ts b/cli/src/update-notice.ts new file mode 100644 index 0000000000..01b90f4f7b --- /dev/null +++ b/cli/src/update-notice.ts @@ -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 { + 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 }; 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 { const latest = await checkForUpdateNotice({ configPath }); if (latest) console.log(`Update available: ${latest} — run \`paperclipai update\``); } diff --git a/cli/src/utils/health-url.ts b/cli/src/utils/health-url.ts new file mode 100644 index 0000000000..12f20312ff --- /dev/null +++ b/cli/src/utils/health-url.ts @@ -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`; +} diff --git a/cli/src/version.ts b/cli/src/version.ts index 7b94c8b35d..8f3632f44c 100644 --- a/cli/src/version.ts +++ b/cli/src/version.ts @@ -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(); diff --git a/doc/CLI.md b/doc/CLI.md index 392b3a440c..5b36f79958 100644 --- a/doc/CLI.md +++ b/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 +paperclipai install --ref [--repo owner/repo] +paperclipai update +paperclipai update --latest|--canary|--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 ` 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`. diff --git a/doc/INSTALLING.md b/doc/INSTALLING.md new file mode 100644 index 0000000000..60db823616 --- /dev/null +++ b/doc/INSTALLING.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// + └── git// + +~/.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 +``` + +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. diff --git a/doc/RELEASE-AUTOMATION-SETUP.md b/doc/RELEASE-AUTOMATION-SETUP.md index d6e08b9f1a..d21975c106 100644 --- a/doc/RELEASE-AUTOMATION-SETUP.md +++ b/doc/RELEASE-AUTOMATION-SETUP.md @@ -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: diff --git a/doc/RELEASING.md b/doc/RELEASING.md index 3ece1fbad0..da4143dfae 100644 --- a/doc/RELEASING.md +++ b/doc/RELEASING.md @@ -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` diff --git a/package.json b/package.json index fd98a50098..38a3abc023 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/db/package.json b/packages/db/package.json index 35a05e5d64..59abf3b89f 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -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", diff --git a/packages/db/src/embedded-postgres-native.test.ts b/packages/db/src/embedded-postgres-native.test.ts index 7335f2c649..049b121217 100644 --- a/packages/db/src/embedded-postgres-native.test.ts +++ b/packages/db/src/embedded-postgres-native.test.ts @@ -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"); + }); }); diff --git a/packages/shared/src/config-schema.ts b/packages/shared/src/config-schema.ts index efa1bdee1d..229abd1a02 100644 --- a/packages/shared/src/config-schema.ts +++ b/packages/shared/src/config-schema.ts @@ -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; export type SecretsLocalEncryptedConfig = z.infer; export type AuthConfig = z.infer; export type TelemetryConfig = z.infer; +export type UpdatesConfig = z.infer; export type ConfigMeta = z.infer; export type DatabaseBackupConfig = z.infer; diff --git a/packages/shared/src/types/issue.ts b/packages/shared/src/types/issue.ts index be4bd771d9..44867a219f 100644 --- a/packages/shared/src/types/issue.ts +++ b/packages/shared/src/types/issue.ts @@ -400,6 +400,7 @@ export interface IssueBlockerAttention { coveredBlockerCount: number; stalledBlockerCount: number; attentionBlockerCount: number; + pendingFinalizeBlockerIssueIds?: string[]; sampleBlockerIdentifier: string | null; sampleStalledBlockerIdentifier: string | null; } diff --git a/scripts/acpx-patch-packaging.test.mjs b/scripts/acpx-patch-packaging.test.mjs index d5ef5b817d..227c77695b 100644 --- a/scripts/acpx-patch-packaging.test.mjs +++ b/scripts/acpx-patch-packaging.test.mjs @@ -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); +}); diff --git a/scripts/build-npm.sh b/scripts/build-npm.sh index 00b8acc223..74d801fdfe 100755 --- a/scripts/build-npm.sh +++ b/scripts/build-npm.sh @@ -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 diff --git a/scripts/clean-install-git.sh b/scripts/clean-install-git.sh new file mode 100755 index 0000000000..0cc193eca7 --- /dev/null +++ b/scripts/clean-install-git.sh @@ -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" diff --git a/scripts/clean-install-npm.sh b/scripts/clean-install-npm.sh new file mode 100755 index 0000000000..73fa082c53 --- /dev/null +++ b/scripts/clean-install-npm.sh @@ -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" diff --git a/scripts/cli-bundled-npm-dependencies.mjs b/scripts/cli-bundled-npm-dependencies.mjs index 49826a140c..9edbb24aca 100644 --- a/scripts/cli-bundled-npm-dependencies.mjs +++ b/scripts/cli-bundled-npm-dependencies.mjs @@ -1,3 +1,4 @@ export const bundledCliNpmDependencies = new Set([ "acpx", + "embedded-postgres", ]); diff --git a/scripts/e2e-install-lifecycle.sh b/scripts/e2e-install-lifecycle.sh new file mode 100755 index 0000000000..7e1719cc99 --- /dev/null +++ b/scripts/e2e-install-lifecycle.sh @@ -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 ""; } + +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@ 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/ ($(basename "$(current_target)"))" ;; + *) fail_ "2c current -> installs/npm/ (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/ ($(basename "$(current_target)"))" ;; + *) fail_ "3b current -> installs/git/ (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 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:-}" + 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" diff --git a/scripts/generate-npm-package-json.mjs b/scripts/generate-npm-package-json.mjs index 4c589379ee..310d6e1277 100644 --- a/scripts/generate-npm-package-json.mjs +++ b/scripts/generate-npm-package-json.mjs @@ -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( diff --git a/scripts/install-sh-fixtures/npx b/scripts/install-sh-fixtures/npx new file mode 100755 index 0000000000..0d59c77292 --- /dev/null +++ b/scripts/install-sh-fixtures/npx @@ -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" diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 0000000000..572f2f73c8 --- /dev/null +++ b/scripts/install.sh @@ -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 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 +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 ' 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/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 diff --git a/scripts/prepare-bundled-package.mjs b/scripts/prepare-bundled-package.mjs index 27dbf643c3..90aac8b68f 100644 --- a/scripts/prepare-bundled-package.mjs +++ b/scripts/prepare-bundled-package.mjs @@ -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)) { diff --git a/scripts/release-lib.sh b/scripts/release-lib.sh index a0e2852d75..67c35919f0 100644 --- a/scripts/release-lib.sh +++ b/scripts/release-lib.sh @@ -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}" diff --git a/scripts/release-lib.test.mjs b/scripts/release-lib.test.mjs index e33974c8c1..eb6a6efde0 100644 --- a/scripts/release-lib.test.mjs +++ b/scripts/release-lib.test.mjs @@ -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/); +}); diff --git a/scripts/release-package-map.mjs b/scripts/release-package-map.mjs index 12ed0124fa..bf41cd9c22 100644 --- a/scripts/release-package-map.mjs +++ b/scripts/release-package-map.mjs @@ -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) { diff --git a/scripts/release-package-map.test.mjs b/scripts/release-package-map.test.mjs index 577186057b..e323f6c94d 100644 --- a/scripts/release-package-map.test.mjs +++ b/scripts/release-package-map.test.mjs @@ -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"); diff --git a/scripts/release.sh b/scripts/release.sh index cbdc39d50b..8e42505af1 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -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 "" diff --git a/scripts/test-install-sh-docker.sh b/scripts/test-install-sh-docker.sh new file mode 100755 index 0000000000..741c138cf9 --- /dev/null +++ b/scripts/test-install-sh-docker.sh @@ -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." diff --git a/server/src/__tests__/issues-service.test.ts b/server/src/__tests__/issues-service.test.ts index 532828082f..ffa2879e34 100644 --- a/server/src/__tests__/issues-service.test.ts +++ b/server/src/__tests__/issues-service.test.ts @@ -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 () => { diff --git a/server/src/index.ts b/server/src/index.ts index 80523982a9..66705852ef 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -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 { 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 { { const shutdown = async (signal: "SIGINT" | "SIGTERM") => { + await systemdNotify(["--stopping", `--status=Stopping after ${signal}`]); heartbeatSchedulerStopped = true; if (heartbeatSchedulerInterval) { clearInterval(heartbeatSchedulerInterval); diff --git a/server/src/services/hot-restart.ts b/server/src/services/hot-restart.ts index 7c0a4596ce..25b7f4647f 100644 --- a/server/src/services/hot-restart.ts +++ b/server/src/services/hot-restart.ts @@ -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) { diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index 721d15deee..760dda43ff 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -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(); const latestUnattributedByWorkspace = new Map(); + const latestSuccessfulFinalizeByWorkspace = new Map(); 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, + 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, companyId: string, @@ -1931,6 +1967,7 @@ function createIssueBlockerAttention(input: Partial = {}) 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(); for (let depth = 0; frontier.length > 0 && depth < BLOCKER_ATTENTION_MAX_DEPTH; depth += 1) { const nextFrontier = new Set(); 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 = 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 = 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 diff --git a/server/src/services/systemd-notify.ts b/server/src/services/systemd-notify.ts new file mode 100644 index 0000000000..793cbd62b8 --- /dev/null +++ b/server/src/services/systemd-notify.ts @@ -0,0 +1,8 @@ +import { execFile } from "node:child_process"; + +export async function systemdNotify(args: string[]): Promise { + if (!process.env.NOTIFY_SOCKET?.trim()) return false; + return await new Promise((resolve) => { + execFile("systemd-notify", args, { windowsHide: true }, (error) => resolve(!error)); + }); +}