From fb260f0ed7c6e1ad41f7ae4b34ce3b29abae8747 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 22 Jul 2026 18:31:02 +0000 Subject: [PATCH] feat(cli): integrate managed install workflows Co-Authored-By: Paperclip --- README.md | 27 ++- .../__tests__/managed-install-check.test.ts | 59 +++++++ cli/src/__tests__/onboard-service.test.ts | 84 ++++++++++ .../__tests__/service-health-check.test.ts | 66 ++++++++ cli/src/checks/index.ts | 2 + cli/src/checks/managed-install-check.ts | 158 ++++++++++++++++++ cli/src/checks/service-health-check.ts | 136 +++++++++++++++ cli/src/commands/doctor.ts | 18 ++ cli/src/commands/onboard.ts | 29 +++- cli/src/index.ts | 2 + cli/src/onboard-service.ts | 66 ++++++++ doc/CLI.md | 63 ++++++- scripts/clean-install-git.sh | 46 +++++ scripts/clean-install-npm.sh | 13 +- 14 files changed, 757 insertions(+), 12 deletions(-) create mode 100644 cli/src/__tests__/managed-install-check.test.ts create mode 100644 cli/src/__tests__/onboard-service.test.ts create mode 100644 cli/src/__tests__/service-health-check.test.ts create mode 100644 cli/src/checks/managed-install-check.ts create mode 100644 cli/src/checks/service-health-check.ts create mode 100644 cli/src/onboard-service.ts create mode 100755 scripts/clean-install-git.sh diff --git a/README.md b/README.md index 2d02971edcb..ffbd2eec0eb 100644 --- a/README.md +++ b/README.md @@ -301,7 +301,25 @@ 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 -fsSL https://paperclip.ing/install.sh | bash +``` + +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. + +For a non-interactive managed install: + +```bash +curl -fsSL https://paperclip.ing/install.sh | bash -s -- --no-prompt --no-onboard +paperclipai onboard --yes +``` + +To try Paperclip without installing anything permanently: + +```bash +npx --registry https://registry.npmjs.org paperclipai onboard --yes ``` > **Troubleshooting: private npm registry `.npmrc`** @@ -323,13 +341,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/src/__tests__/managed-install-check.test.ts b/cli/src/__tests__/managed-install-check.test.ts new file mode 100644 index 00000000000..354f0be7033 --- /dev/null +++ b/cli/src/__tests__/managed-install-check.test.ts @@ -0,0 +1,59 @@ +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 { + 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 }); + + expect(managedInstallChecks(paths)).toEqual([ + expect.objectContaining({ name: "Managed install manifest", status: "fail" }), + ]); + }); +}); diff --git a/cli/src/__tests__/onboard-service.test.ts b/cli/src/__tests__/onboard-service.test.ts new file mode 100644 index 00000000000..c4a711f3f31 --- /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 00000000000..accb8775369 --- /dev/null +++ b/cli/src/__tests__/service-health-check.test.ts @@ -0,0 +1,66 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { serviceHealthChecks } from "../checks/service-health-check.js"; +import type { PaperclipConfig } from "../config/schema.js"; + +const config = { + server: { host: "127.0.0.1", port: 3100 }, +} as PaperclipConfig; + +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("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/checks/index.ts b/cli/src/checks/index.ts index 7c2cb861634..dd784aafeca 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 00000000000..324c5be812a --- /dev/null +++ b/cli/src/checks/managed-install-check.ts @@ -0,0 +1,158 @@ +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 { + return [paths.cliRoot, paths.manifestPath, paths.currentPath, paths.shimPath].some((entry) => + fs.existsSync(entry), + ); +} + +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 00000000000..aa84ad6e6ae --- /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 type { CheckResult } from "./index.js"; + +type HealthResult = { ok: boolean; version: string | null; error?: string }; +type ServiceCheckDependencies = { + detect: (instanceId: string) => Promise; + probe: (config: PaperclipConfig) => Promise; +}; + +function healthUrl(config: PaperclipConfig): string { + const configuredHost = config.server.host?.trim(); + const host = !configuredHost || configuredHost === "0.0.0.0" || configuredHost === "::" + ? "127.0.0.1" + : configuredHost; + return `http://${host}:${config.server.port}/api/health`; +} + +async function probeHealth(config: PaperclipConfig): Promise { + try { + const response = await fetch(healthUrl(config), { 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 { + 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", + }, + ); + + const expectedVersion = readInstallManifest()?.version ?? null; + 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 3ace070ed32..212301f23b5 100644 --- a/cli/src/commands/doctor.ts +++ b/cli/src/commands/doctor.ts @@ -9,8 +9,11 @@ import { deploymentAuthCheck, llmCheck, logCheck, + managedInstallChecks, + nodeRuntimeCheck, portCheck, secretsCheck, + serviceHealthChecks, storageCheck, type CheckResult, } from "../checks/index.js"; @@ -120,6 +123,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/onboard.ts b/cli/src/commands/onboard.ts index 62158e05bbf..3024360dda5 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/index.ts b/cli/src/index.ts index 61731023ae9..ba0dd47c2bb 100644 --- a/cli/src/index.ts +++ b/cli/src/index.ts @@ -89,6 +89,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); diff --git a/cli/src/onboard-service.ts b/cli/src/onboard-service.ts new file mode 100644 index 00000000000..476d599a946 --- /dev/null +++ b/cli/src/onboard-service.ts @@ -0,0 +1,66 @@ +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; + 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; + }, + 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 }); + deps.success(`Installed and started ${detection.manager.serviceName}.`); + return true; +} diff --git a/doc/CLI.md b/doc/CLI.md index 392b3a440c9..130c2b7551d 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,13 @@ Use repo script in development: pnpm paperclipai --help ``` -First-time local bootstrap + run: +Recommended installation and interactive onboarding: + +```sh +curl -fsSL https://paperclip.ing/install.sh | bash +``` + +First-time local bootstrap from a source checkout: ```sh pnpm paperclipai run @@ -25,6 +32,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 |--ref +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/scripts/clean-install-git.sh b/scripts/clean-install-git.sh new file mode 100755 index 00000000000..db9547ba043 --- /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 index a65bb95ed74..831b5d1d378 100755 --- a/scripts/clean-install-npm.sh +++ b/scripts/clean-install-npm.sh @@ -8,6 +8,7 @@ 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" @@ -16,14 +17,14 @@ cd "$PC_TEST_ROOT" npx --yes --registry https://registry.npmjs.org paperclipai install test -x "$PC_HOME/.local/bin/paperclipai" -test -L "$PC_HOME/.paperclip/cli/current" -test -f "$PC_HOME/.paperclip/cli/install.json" +test -L "$PAPERCLIP_HOME/cli/current" +test -f "$PAPERCLIP_HOME/cli/install.json" paperclipai --version -mkdir -p "$PC_HOME/.paperclip/instances/default" -touch "$PC_HOME/.paperclip/instances/default/user-data-marker" +mkdir -p "$PAPERCLIP_HOME/instances/default" +touch "$PAPERCLIP_HOME/instances/default/user-data-marker" paperclipai uninstall -test ! -e "$PC_HOME/.paperclip/cli" +test ! -e "$PAPERCLIP_HOME/cli" test ! -e "$PC_HOME/.local/bin/paperclipai" -test -f "$PC_HOME/.paperclip/instances/default/user-data-marker" +test -f "$PAPERCLIP_HOME/instances/default/user-data-marker"