mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
feat(cli): integrate managed install workflows
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
550a0fe29c
commit
fb260f0ed7
14 files changed
+757
-12
No files matched your search
@@ -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" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -16,3 +16,5 @@ export { logCheck } from "./log-check.js";
|
||||
export { portCheck } from "./port-check.js";
|
||||
export { secretsCheck } from "./secrets-check.js";
|
||||
export { storageCheck } from "./storage-check.js";
|
||||
export { managedInstallChecks, nodeRuntimeCheck } from "./managed-install-check.js";
|
||||
export { serviceHealthChecks } from "./service-health-check.js";
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
@@ -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`.
|
||||
|
||||
Executable
+46
@@ -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"
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user