feat(cli): integrate managed install workflows

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-22 19:38:47 +00:00
1 parent 550a0fe29c
commit fb260f0ed7
14 files changed
+757 -12

No files matched your search

+24 -3
View File
@@ -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
@@ -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" }),
]);
});
});
+84
View File
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from "vitest";
import { handleOnboardService } from "../onboard-service.js";
function supportedDetection() {
return {
supported: true as const,
manager: {
platform: "systemd" as const,
instanceId: "default",
serviceName: "paperclipai.service",
definitionPath: "/tmp/paperclipai.service",
renderDefinition: () => "unit",
install: vi.fn(async () => ({ changed: true })),
uninstall: vi.fn(async () => undefined),
start: vi.fn(async () => undefined),
stop: vi.fn(async () => undefined),
restart: vi.fn(async () => undefined),
status: vi.fn(async () => ({
platform: "systemd" as const,
serviceName: "paperclipai.service",
installed: true,
active: true,
enabled: true,
pid: 123,
})),
logs: vi.fn(async () => undefined),
},
};
}
describe("onboard service policy", () => {
it("does not install during --yes onboarding without opt-in", async () => {
const detection = supportedDetection();
const info = vi.fn();
const installed = await handleOnboardService(
{ yes: true },
{ detect: vi.fn(async () => detection), isInteractive: () => false, info },
);
expect(installed).toBe(false);
expect(detection.manager.install).not.toHaveBeenCalled();
expect(info).toHaveBeenCalledWith(expect.stringContaining("--install-service"));
});
it("installs when --yes explicitly opts in", async () => {
const detection = supportedDetection();
const installed = await handleOnboardService(
{ yes: true, installService: true },
{ detect: vi.fn(async () => detection), isInteractive: () => false },
);
expect(installed).toBe(true);
expect(detection.manager.install).toHaveBeenCalledWith({ startNow: true, startOnLogin: true });
});
it("asks during interactive onboarding", async () => {
const detection = supportedDetection();
const confirm = vi.fn(async () => true);
const installed = await handleOnboardService(
{},
{ detect: vi.fn(async () => detection), isInteractive: () => true, confirm },
);
expect(confirm).toHaveBeenCalledOnce();
expect(installed).toBe(true);
});
it("silences the hint with --no-install-service", async () => {
const info = vi.fn();
const detect = vi.fn(async () => supportedDetection());
const installed = await handleOnboardService(
{ yes: true, installService: false },
{ detect, isInteractive: () => false, info },
);
expect(installed).toBe(false);
expect(detect).not.toHaveBeenCalled();
expect(info).not.toHaveBeenCalled();
});
});
@@ -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"),
}),
);
});
});
+2
View File
@@ -16,3 +16,5 @@ export { logCheck } from "./log-check.js";
export { portCheck } from "./port-check.js";
export { secretsCheck } from "./secrets-check.js";
export { storageCheck } from "./storage-check.js";
export { managedInstallChecks, nodeRuntimeCheck } from "./managed-install-check.js";
export { serviceHealthChecks } from "./service-health-check.js";
+158
View File
@@ -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;
}
+136
View File
@@ -0,0 +1,136 @@
import fs from "node:fs/promises";
import type { PaperclipConfig } from "../config/schema.js";
import { resolvePaperclipInstanceId } from "../config/home.js";
import { readInstallManifest } from "../install-store.js";
import {
detectServiceManager,
type ServiceManagerDetection,
} from "../services/service-manager.js";
import type { CheckResult } from "./index.js";
type HealthResult = { ok: boolean; version: string | null; error?: string };
type ServiceCheckDependencies = {
detect: (instanceId: string) => Promise<ServiceManagerDetection>;
probe: (config: PaperclipConfig) => Promise<HealthResult>;
};
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<HealthResult> {
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<ServiceCheckDependencies> = {},
): Promise<CheckResult[]> {
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;
}
+18
View File
@@ -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);
}
+27 -2
View File
@@ -43,6 +43,8 @@ import {
trackInstallStarted,
trackInstallCompleted,
} from "../telemetry.js";
import { handleOnboardService } from "../onboard-service.js";
import { readInstallManifest, isManagedExecutable } from "../install-store.js";
type SetupMode = "quickstart" | "advanced";
@@ -52,6 +54,7 @@ type OnboardOptions = {
yes?: boolean;
invokedByRun?: boolean;
bind?: BindMode;
installService?: boolean;
};
type OnboardDefaults = Pick<PaperclipConfig, "database" | "logging" | "server" | "auth" | "storage" | "secrets">;
@@ -322,6 +325,21 @@ function canCreateBootstrapInviteImmediately(config: Pick<PaperclipConfig, "data
return config.server.deploymentMode === "authenticated" && config.database.mode !== "embedded-postgres";
}
export function isEphemeralNpxExecution(entrypoint = process.argv[1]): boolean {
if (!entrypoint) return false;
const normalized = entrypoint.replaceAll("\\", "/");
return normalized.includes("/_npx/") || normalized.includes("/npm/_npx/");
}
function printManagedInstallHint(): void {
const manifest = readInstallManifest();
if (manifest && isManagedExecutable(process.argv[1], manifest)) return;
if (!isEphemeralNpxExecution()) return;
p.log.info(
`This npx run is temporary. Use ${pc.cyan("paperclipai install")} for atomic updates, rollback, and service support.`,
);
}
export async function onboard(opts: OnboardOptions): Promise<void> {
if (opts.bind && !["loopback", "lan", "tailnet"].includes(opts.bind)) {
throw new Error(`Unsupported bind preset for onboard: ${opts.bind}. Use loopback, lan, or tailnet.`);
@@ -400,7 +418,10 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
"Next commands",
);
let shouldRunNow = opts.run === true || opts.yes === true;
printManagedInstallHint();
const serviceInstalled = await handleOnboardService(opts);
let shouldRunNow = !serviceInstalled && (opts.run === true || opts.yes === true);
if (!shouldRunNow && !opts.invokedByRun && process.stdin.isTTY && process.stdout.isTTY) {
const answer = await p.confirm({
message: "Start Paperclip now?",
@@ -655,12 +676,16 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
"Next commands",
);
printManagedInstallHint();
if (canCreateBootstrapInviteImmediately({ database, server })) {
p.log.step("Generating bootstrap CEO invite");
await bootstrapCeoInvite({ config: configPath });
}
let shouldRunNow = opts.run === true || opts.yes === true;
const serviceInstalled = await handleOnboardService(opts);
let shouldRunNow = !serviceInstalled && (opts.run === true || opts.yes === true);
if (!shouldRunNow && !opts.invokedByRun && process.stdin.isTTY && process.stdout.isTTY) {
const answer = await p.confirm({
message: "Start Paperclip now?",
+2
View File
@@ -89,6 +89,8 @@ program
.option("-d, --data-dir <path>", DATA_DIR_OPTION_HELP)
.option("--bind <mode>", "Quickstart reachability preset (loopback, lan, tailnet)")
.option("-y, --yes", "Accept quickstart defaults (trusted local loopback unless --bind is set) and start immediately", false)
.option("--install-service", "Install and start the background service after onboarding")
.option("--no-install-service", "Do not install or suggest the background service")
.option("--run", "Start Paperclip immediately after saving config", false)
.action(onboard);
+66
View File
@@ -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<ServiceManagerDetection>;
confirm: () => Promise<boolean>;
isInteractive: () => boolean;
info: (message: string) => void;
success: (message: string) => void;
warn: (message: string) => void;
};
const defaultDependencies: OnboardServiceDependencies = {
detect: (instanceId) => detectServiceManager({ instanceId }),
confirm: async () => {
const answer = await p.confirm({
message: "Install Paperclip as a background service?",
initialValue: true,
});
return !p.isCancel(answer) && answer === true;
},
isInteractive: () => process.stdin.isTTY === true && process.stdout.isTTY === true,
info: (message) => p.log.message(pc.dim(message)),
success: (message) => p.log.success(message),
warn: (message) => p.log.warn(message),
};
export async function handleOnboardService(
options: OnboardServiceOptions,
dependencies: Partial<OnboardServiceDependencies> = {},
): Promise<boolean> {
const deps = { ...defaultDependencies, ...dependencies };
if (options.installService === false) return false;
const explicitlyRequested = options.installService === true;
const canPrompt = options.yes !== true && deps.isInteractive();
if (!explicitlyRequested && !canPrompt) {
deps.info(
"Background service not installed. Use `paperclipai onboard --install-service` or `paperclipai service install` to opt in.",
);
return false;
}
const instanceId = resolvePaperclipInstanceId();
const detection = await deps.detect(instanceId);
if (!detection.supported) {
if (explicitlyRequested) deps.warn(detection.reason);
return false;
}
if (!explicitlyRequested && !(await deps.confirm())) return false;
await detection.manager.install({ startNow: true, startOnLogin: true });
deps.success(`Installed and started ${detection.manager.serviceName}.`);
return true;
}
+62 -1
View File
@@ -2,6 +2,7 @@
Paperclip CLI now supports both:
- installation and lifecycle management (`install`, `uninstall`, `update`, `upgrade`, `service`)
- instance setup/diagnostics (`onboard`, `doctor`, `configure`, `env`, `allowed-hostname`, `env-lab`)
- control-plane client operations (issues, approvals, agents, activity, dashboard)
@@ -13,7 +14,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 <version>
paperclipai install --ref <branch|tag|sha> [--repo owner/repo]
paperclipai update
paperclipai update --latest|--canary|--version <version>|--ref <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 <id>` and `--json`. Linux and WSL2 use
a systemd user unit when available; macOS uses a LaunchAgent. Unsupported
environments receive foreground `paperclipai run` guidance.
`paperclipai doctor` includes managed-install and service-health diagnostics in
addition to configuration, storage, database, logging, and port checks.
## Deployment Modes
Mode taxonomy and design intent are documented in `doc/DEPLOYMENT-MODES.md`.
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PC_TEST_ROOT="${PC_TEST_ROOT:-$(mktemp -d "${TMPDIR:-/tmp}/paperclip-clean-install-git.XXXXXX")}"
PC_HOME="${PC_HOME:-$PC_TEST_ROOT/home}"
PC_CACHE="${PC_CACHE:-$PC_TEST_ROOT/npm-cache}"
KEEP_TEMP="${KEEP_TEMP:-0}"
cleanup() {
if [ "$KEEP_TEMP" != "1" ]; then
rm -rf "$PC_TEST_ROOT"
fi
}
trap cleanup EXIT
mkdir -p "$PC_HOME" "$PC_CACHE"
echo "REPO_ROOT: $REPO_ROOT"
echo "PC_TEST_ROOT: $PC_TEST_ROOT"
echo "PC_HOME: $PC_HOME"
env \
HOME="$PC_HOME" \
PAPERCLIP_HOME="$PC_HOME/.paperclip" \
npm_config_cache="$PC_CACHE" \
npm_config_userconfig="$PC_HOME/.npmrc" \
PATH="$PC_HOME/.local/bin:$PATH" \
pnpm --dir "$REPO_ROOT" paperclipai install --yes
test -x "$PC_HOME/.local/bin/paperclipai"
test -L "$PC_HOME/.paperclip/cli/current"
test -f "$PC_HOME/.paperclip/cli/install.json"
env HOME="$PC_HOME" PAPERCLIP_HOME="$PC_HOME/.paperclip" PATH="$PC_HOME/.local/bin:$PATH" paperclipai --version
env HOME="$PC_HOME" PAPERCLIP_HOME="$PC_HOME/.paperclip" PATH="$PC_HOME/.local/bin:$PATH" paperclipai doctor \
--config "$PC_TEST_ROOT/missing-config.json" >/dev/null || true
env \
HOME="$PC_HOME" \
PAPERCLIP_HOME="$PC_HOME/.paperclip" \
PATH="$PC_HOME/.local/bin:$PATH" \
pnpm --dir "$REPO_ROOT" paperclipai uninstall
test ! -e "$PC_HOME/.paperclip/cli"
test ! -e "$PC_HOME/.local/bin/paperclipai"
+7 -6
View File
@@ -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"