mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 18:06:11 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - The CLI can install and start Paperclip as a managed user service during onboarding. > - Recent fixes now install the service shim and remove the redundant foreground start prompt. > - The service path still ends without a dashboard URL or an open browser. > - The server can also move to a free port when the configured port is busy. > - This pull request adds a health-aware handoff to the managed service's actual endpoint. > - The benefit is that new users can reach Paperclip without starting a second process. ## Linked Issues or Issue Description **What happened?** After interactive onboarding installs and starts the managed service, the command ends without printing the dashboard URL or opening the browser. If the configured port is busy, the service can use a fallback port that the onboarding process does not know. **Expected behavior** Onboarding must print the dashboard URL that belongs to the managed service. An interactive terminal should open the URL after the local health check succeeds. A non-interactive terminal should only print the URL. **Steps to reproduce** 1. Start from a host without an installed Paperclip service. 2. Run another process on the configured Paperclip port. 3. Run `npx paperclipai@<version> onboard` in an interactive terminal. 4. Accept the managed service installation. 5. Observe that the service starts on a fallback port, but onboarding does not provide or open that dashboard URL. **Paperclip version or commit** `b6854e61c` on `master`, after #12148, #12151, and #12153. **Deployment mode** Local managed user service on macOS or Linux. **Installation method** `npx paperclipai@<version> onboard`. The same onboarding path can also run after `install.sh`. Related public pull requests: #12148, #12151, and #12153. ## What Changed - Record each running CLI server's PID, selected port, and dashboard URL in atomic per-instance runtime metadata. - Accept runtime metadata only when its PID matches the active managed service. - Wait for the selected runtime endpoint to report healthy before printing its URL. - Open the URL in interactive terminals and keep headless runs browser-free. - Keep the printed configured URL as a fallback when runtime discovery fails. - Use browser-launch wording that only claims the URL was sent to the opener. - Add runtime metadata, fallback-port, health handoff, headless, and failure-path tests. - Document the managed service dashboard handoff. ## Verification - `pnpm exec vitest run cli/src/__tests__/onboard-service.test.ts cli/src/__tests__/runtime-info.test.ts cli/src/__tests__/onboard.test.ts cli/src/__tests__/open-url.test.ts cli/src/__tests__/service-health-check.test.ts` — 44 tests passed. - `node --test scripts/service-onboard-smoke.test.mjs` — 4 tests passed. - `pnpm -r typecheck` — passed on head `82920596a`. - `pnpm build` — passed on head `82920596a`. - `pnpm test:run` — 4,685 tests passed. The command also reported 31 failures in nine server test files outside this change. This machine generated invalid test ports above 65,535, and some project-skill fixtures resolved outside the worktree. ## Risks - Risk is low because the new handoff runs only after a successful service installation. - Onboarding can wait up to 60 seconds when runtime metadata or the health check does not become ready. - Runtime metadata is matched to the supervisor PID, so stale or foreground-process metadata is ignored. - A non-interactive terminal does not open a browser. - A failed health check or browser launch does not fail onboarding. The CLI keeps a manual URL visible. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex, GPT-5 family. The runtime did not expose the exact model ID or context window. The model used reasoning, repository tools, GitHub access, and code execution. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
277 lines
10 KiB
TypeScript
277 lines
10 KiB
TypeScript
import path from "node:path";
|
|
import * as p from "@clack/prompts";
|
|
import pc from "picocolors";
|
|
import type { PaperclipConfig } from "./config/schema.js";
|
|
import { openUrl } from "./client/board-auth.js";
|
|
import { installCommand } from "./commands/install.js";
|
|
import { resolvePaperclipInstanceId } from "./config/home.js";
|
|
import { readRuntimeInfo, type PaperclipRuntimeInfo } from "./runtime-info.js";
|
|
import {
|
|
readInstallManifest,
|
|
resolveInstallStorePaths,
|
|
type InstallManifest,
|
|
} from "./install-store.js";
|
|
import {
|
|
detectServiceManager,
|
|
isExecutableFile,
|
|
resolveServiceShimPath,
|
|
type ServiceManagerDetection,
|
|
} from "./services/service-manager.js";
|
|
import { buildLocalAppUrl, buildLocalHealthUrl } from "./utils/health-url.js";
|
|
import { packageVersion } from "./version.js";
|
|
|
|
export type OnboardServiceOptions = {
|
|
yes?: boolean;
|
|
installService?: boolean;
|
|
};
|
|
|
|
type EnsureShimResult = { ok: boolean; installedNow: boolean; reason?: string };
|
|
type OnboardServiceDashboardConfig = {
|
|
auth: Pick<PaperclipConfig["auth"], "baseUrlMode" | "publicBaseUrl">;
|
|
server: Pick<PaperclipConfig["server"], "host" | "port">;
|
|
};
|
|
|
|
type OnboardServiceDashboardDependencies = {
|
|
isInteractive: () => boolean;
|
|
waitUntilReady: () => Promise<PaperclipRuntimeInfo | null>;
|
|
openDashboard: (url: string) => Promise<boolean>;
|
|
info: (message: string) => void;
|
|
success: (message: string) => void;
|
|
warn: (message: string) => void;
|
|
};
|
|
|
|
function envDisablesBrowser(value = process.env.PAPERCLIP_NO_BROWSER): boolean {
|
|
const normalized = value?.trim().toLowerCase();
|
|
return normalized === "1" || normalized === "true" || normalized === "yes";
|
|
}
|
|
|
|
async function waitUntilDashboardReady(timeoutMs = 60_000): Promise<PaperclipRuntimeInfo | null> {
|
|
const instanceId = resolvePaperclipInstanceId();
|
|
const detection = await detectServiceManager({ instanceId });
|
|
if (!detection.supported) return null;
|
|
const deadline = Date.now() + timeoutMs;
|
|
while (Date.now() < deadline) {
|
|
const info = readRuntimeInfo(instanceId);
|
|
if (info) {
|
|
const status = await detection.manager.status().catch(() => null);
|
|
if (status?.active && status.pid === info.pid) {
|
|
try {
|
|
const response = await fetch(buildLocalHealthUrl(info.host, info.port), {
|
|
signal: AbortSignal.timeout(2_000),
|
|
});
|
|
const body = await response.json() as { status?: unknown };
|
|
if (response.ok && body.status === "ok") return info;
|
|
} catch {}
|
|
}
|
|
}
|
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
}
|
|
return null;
|
|
}
|
|
|
|
const defaultDashboardDependencies: OnboardServiceDashboardDependencies = {
|
|
isInteractive: () => process.stdin.isTTY === true && process.stdout.isTTY === true,
|
|
waitUntilReady: waitUntilDashboardReady,
|
|
openDashboard: openUrl,
|
|
info: (message) => p.log.info(message),
|
|
success: (message) => p.log.success(message),
|
|
warn: (message) => p.log.warn(message),
|
|
};
|
|
|
|
export function resolveOnboardServiceDashboardUrl(
|
|
config: OnboardServiceDashboardConfig,
|
|
runtime?: Pick<PaperclipRuntimeInfo, "dashboardUrl"> | null,
|
|
): string {
|
|
if (runtime?.dashboardUrl.trim()) return runtime.dashboardUrl.trim().replace(/\/+$/, "");
|
|
if (config.auth.baseUrlMode === "explicit" && config.auth.publicBaseUrl?.trim()) {
|
|
return config.auth.publicBaseUrl.trim().replace(/\/+$/, "");
|
|
}
|
|
return buildLocalAppUrl(config.server.host, config.server.port);
|
|
}
|
|
|
|
export async function handoffToOnboardedService(
|
|
config: OnboardServiceDashboardConfig,
|
|
dependencies: Partial<OnboardServiceDashboardDependencies> = {},
|
|
): Promise<void> {
|
|
const deps = { ...defaultDashboardDependencies, ...dependencies };
|
|
const runtime = await deps.waitUntilReady();
|
|
const dashboardUrl = resolveOnboardServiceDashboardUrl(config, runtime);
|
|
deps.info(`Paperclip dashboard: ${pc.cyan(dashboardUrl)}`);
|
|
|
|
if (!runtime) {
|
|
deps.warn(
|
|
`The background service started, but the dashboard is not ready yet. ` +
|
|
`Open ${dashboardUrl} after checking \`paperclipai service logs\`.`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (!deps.isInteractive() || envDisablesBrowser()) return;
|
|
|
|
if (await deps.openDashboard(dashboardUrl)) {
|
|
deps.success("Sent the Paperclip dashboard to your browser.");
|
|
} else {
|
|
deps.warn(`Could not open a browser automatically. Open ${dashboardUrl} manually.`);
|
|
}
|
|
}
|
|
|
|
// Source checkouts carry the repository placeholder version; installing
|
|
// that as an npm spec would fetch an ancient release (or nothing) instead
|
|
// of the running code. Only real calendar versions are installable.
|
|
export function isInstallableReleaseVersion(version: string): boolean {
|
|
return /^\d{4}\.\d{1,4}\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version);
|
|
}
|
|
|
|
type OnboardServiceDependencies = {
|
|
detect: (instanceId: string) => Promise<ServiceManagerDetection>;
|
|
ensureServiceShim: () => Promise<EnsureShimResult>;
|
|
confirm: () => Promise<boolean>;
|
|
confirmLinger: () => Promise<boolean>;
|
|
isInteractive: () => boolean;
|
|
info: (message: string) => void;
|
|
success: (message: string) => void;
|
|
warn: (message: string) => void;
|
|
};
|
|
|
|
const defaultDependencies: OnboardServiceDependencies = {
|
|
detect: (instanceId) => detectServiceManager({ instanceId }),
|
|
// The service definition targets the managed shim. An ephemeral run (npx)
|
|
// never lays it down, so installing the service without this step creates
|
|
// a definition that crash-loops on a missing binary.
|
|
ensureServiceShim: async () => {
|
|
const shimPath = resolveServiceShimPath();
|
|
if (await isExecutableFile(shimPath)) {
|
|
return { ok: true, installedNow: false };
|
|
}
|
|
const storeShimPath = resolveInstallStorePaths().shimPath;
|
|
if (path.resolve(shimPath) !== path.resolve(storeShimPath)) {
|
|
return {
|
|
ok: false,
|
|
installedNow: false,
|
|
reason: `no executable exists at ${shimPath} (PAPERCLIP_SHIM_PATH), and it is outside the managed install store`,
|
|
};
|
|
}
|
|
let manifest: InstallManifest | null = null;
|
|
try {
|
|
manifest = readInstallManifest();
|
|
} catch {}
|
|
try {
|
|
if (manifest?.source === "git" && manifest.repo) {
|
|
// A managed git payload must be preserved as-is: reinstall the
|
|
// exact revision the manifest records, not an npm release.
|
|
await installCommand({ repo: manifest.repo, ref: manifest.sha ?? manifest.ref, yes: true });
|
|
} else if (isInstallableReleaseVersion(packageVersion)) {
|
|
// packageVersion, not cliVersion: a managed executable's cliVersion
|
|
// carries provenance text that is not an installable npm spec.
|
|
await installCommand({ version: packageVersion, yes: true });
|
|
} else {
|
|
return {
|
|
ok: false,
|
|
installedNow: false,
|
|
reason:
|
|
`this build reports version ${packageVersion}, which is not an installable release; ` +
|
|
"run `paperclipai install` (or `paperclipai install --repo <repo> --ref <ref>` for source builds) first",
|
|
};
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
installedNow: false,
|
|
reason: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
if (await isExecutableFile(shimPath)) {
|
|
return { ok: true, installedNow: true };
|
|
}
|
|
return {
|
|
ok: false,
|
|
installedNow: false,
|
|
reason: `the managed install completed but no executable shim appeared at ${shimPath}`,
|
|
};
|
|
},
|
|
confirm: async () => {
|
|
const answer = await p.confirm({
|
|
message: "Install Paperclip as a background service?",
|
|
initialValue: true,
|
|
});
|
|
return !p.isCancel(answer) && answer === true;
|
|
},
|
|
confirmLinger: async () => {
|
|
const answer = await p.confirm({
|
|
message: "Allow Paperclip to keep running after logout? This may request system authorization.",
|
|
initialValue: false,
|
|
});
|
|
return !p.isCancel(answer) && answer === true;
|
|
},
|
|
isInteractive: () => process.stdin.isTTY === true && process.stdout.isTTY === true,
|
|
info: (message) => p.log.message(pc.dim(message)),
|
|
success: (message) => p.log.success(message),
|
|
warn: (message) => p.log.warn(message),
|
|
};
|
|
|
|
export async function handleOnboardService(
|
|
options: OnboardServiceOptions,
|
|
dependencies: Partial<OnboardServiceDependencies> = {},
|
|
): Promise<boolean> {
|
|
const deps = { ...defaultDependencies, ...dependencies };
|
|
if (options.installService === false) return false;
|
|
|
|
const explicitlyRequested = options.installService === true;
|
|
const canPrompt = options.yes !== true && deps.isInteractive();
|
|
if (!explicitlyRequested && !canPrompt) {
|
|
deps.info(
|
|
"Background service not installed. Use `paperclipai onboard --install-service` or `paperclipai service install` to opt in.",
|
|
);
|
|
return false;
|
|
}
|
|
|
|
const instanceId = resolvePaperclipInstanceId();
|
|
const detection = await deps.detect(instanceId);
|
|
if (!detection.supported) {
|
|
if (explicitlyRequested) deps.warn(detection.reason);
|
|
return false;
|
|
}
|
|
|
|
if (!explicitlyRequested && !(await deps.confirm())) return false;
|
|
|
|
// A definition pointing at a missing binary crash-loops in the platform
|
|
// supervisor's penalty box while doctor blames a port conflict.
|
|
// Materialize the managed install first, or decline with the repair path
|
|
// instead of installing a corpse.
|
|
const shim = await deps.ensureServiceShim();
|
|
if (!shim.ok) {
|
|
deps.warn(
|
|
`Background service not installed: ${shim.reason ?? "the managed install could not be completed"}. ` +
|
|
"Run `paperclipai install`, then `paperclipai service install`.",
|
|
);
|
|
return false;
|
|
}
|
|
if (shim.installedNow) {
|
|
deps.success("Installed the managed paperclipai payload and command shim for the service.");
|
|
}
|
|
|
|
await detection.manager.install({ startNow: true, startOnLogin: true });
|
|
if (!explicitlyRequested && detection.manager.enableLinger && await deps.confirmLinger()) {
|
|
await detection.manager.enableLinger();
|
|
}
|
|
deps.success(`Installed and started ${detection.manager.serviceName}.`);
|
|
return true;
|
|
}
|
|
|
|
// Onboarding falls back to offering a foreground start when nothing else
|
|
// will serve. A just-installed service is already serving, so offering the
|
|
// start would only run the user into the already-running instance guard.
|
|
export function shouldOfferForegroundStart(options: {
|
|
serviceInstalled: boolean;
|
|
startAlreadyDecided: boolean;
|
|
invokedByRun: boolean;
|
|
interactive: boolean;
|
|
}): boolean {
|
|
return (
|
|
!options.startAlreadyDecided &&
|
|
!options.serviceInstalled &&
|
|
!options.invokedByRun &&
|
|
options.interactive
|
|
);
|
|
}
|