From 0f0e5443176df5233b9877e248fdcf4881d30c33 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:33:09 -0500 Subject: [PATCH] fix(cli): open dashboard after onboarding service starts (#12164) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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@ 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@ 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 --- cli/src/__tests__/onboard-service.test.ts | 110 +++++++++++++++++++++- cli/src/__tests__/runtime-info.test.ts | 56 +++++++++++ cli/src/commands/onboard.ts | 12 ++- cli/src/commands/run.ts | 11 +++ cli/src/onboard-service.ts | 92 ++++++++++++++++++ cli/src/runtime-info.ts | 82 ++++++++++++++++ cli/src/utils/health-url.ts | 8 +- doc/INSTALLING.md | 5 + 8 files changed, 372 insertions(+), 4 deletions(-) create mode 100644 cli/src/__tests__/runtime-info.test.ts create mode 100644 cli/src/runtime-info.ts diff --git a/cli/src/__tests__/onboard-service.test.ts b/cli/src/__tests__/onboard-service.test.ts index 1b2b6baf4a..92697fc675 100644 --- a/cli/src/__tests__/onboard-service.test.ts +++ b/cli/src/__tests__/onboard-service.test.ts @@ -1,5 +1,30 @@ import { describe, expect, it, vi } from "vitest"; -import { handleOnboardService, isInstallableReleaseVersion, shouldOfferForegroundStart } from "../onboard-service.js"; +import { + handleOnboardService, + handoffToOnboardedService, + isInstallableReleaseVersion, + resolveOnboardServiceDashboardUrl, + shouldOfferForegroundStart, +} from "../onboard-service.js"; + +function dashboardConfig(overrides: { + host?: string; + port?: number; + baseUrlMode?: "auto" | "explicit"; + publicBaseUrl?: string; +} = {}) { + return { + server: { + host: overrides.host ?? "127.0.0.1", + port: overrides.port ?? 3100, + }, + auth: { + baseUrlMode: overrides.baseUrlMode ?? "auto", + disableSignUp: false, + ...(overrides.publicBaseUrl ? { publicBaseUrl: overrides.publicBaseUrl } : {}), + }, + }; +} function supportedDetection() { return { @@ -139,6 +164,89 @@ describe("isInstallableReleaseVersion", () => { }); }); +describe("onboarded service dashboard handoff", () => { + it("resolves a reachable local dashboard URL", () => { + expect(resolveOnboardServiceDashboardUrl(dashboardConfig({ host: "0.0.0.0", port: 4321 }))) + .toBe("http://127.0.0.1:4321"); + expect(resolveOnboardServiceDashboardUrl(dashboardConfig({ host: "::1" }))) + .toBe("http://[::1]:3100"); + }); + + it("uses the configured public URL when auth requires one", () => { + expect(resolveOnboardServiceDashboardUrl(dashboardConfig({ + baseUrlMode: "explicit", + publicBaseUrl: "https://paperclip.example.com/", + }))).toBe("https://paperclip.example.com"); + }); + + it("prints the dashboard URL without opening a browser in non-interactive runs", async () => { + const info = vi.fn(); + const waitUntilReady = vi.fn(async () => ({ + schemaVersion: 1 as const, + instanceId: "default", + pid: 123, + host: "127.0.0.1", + port: 3100, + dashboardUrl: "http://127.0.0.1:3100", + startedAt: "2026-08-25T00:00:00.000Z", + })); + const openDashboard = vi.fn(async () => true); + + await handoffToOnboardedService(dashboardConfig(), { + isInteractive: () => false, + waitUntilReady, + openDashboard, + info, + }); + + expect(info).toHaveBeenCalledWith(expect.stringContaining("http://127.0.0.1:3100")); + expect(waitUntilReady).toHaveBeenCalledOnce(); + expect(openDashboard).not.toHaveBeenCalled(); + }); + + it("uses the ready service runtime port before opening the dashboard", async () => { + const waitUntilReady = vi.fn(async () => ({ + schemaVersion: 1 as const, + instanceId: "default", + pid: 123, + host: "127.0.0.1", + port: 3101, + dashboardUrl: "http://127.0.0.1:3101", + startedAt: "2026-08-25T00:00:00.000Z", + })); + const openDashboard = vi.fn(async () => true); + const success = vi.fn(); + + await handoffToOnboardedService(dashboardConfig(), { + isInteractive: () => true, + waitUntilReady, + openDashboard, + info: vi.fn(), + success, + }); + + expect(waitUntilReady).toHaveBeenCalledOnce(); + expect(openDashboard).toHaveBeenCalledWith("http://127.0.0.1:3101"); + expect(success).toHaveBeenCalledWith(expect.stringContaining("Sent")); + }); + + it("keeps the manual link and warns when service health does not become ready", async () => { + const openDashboard = vi.fn(async () => true); + const warn = vi.fn(); + + await handoffToOnboardedService(dashboardConfig(), { + isInteractive: () => true, + waitUntilReady: vi.fn(async () => null), + openDashboard, + info: vi.fn(), + warn, + }); + + expect(openDashboard).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("paperclipai service logs")); + }); +}); + describe("shouldOfferForegroundStart", () => { const base = { serviceInstalled: false, startAlreadyDecided: false, invokedByRun: false, interactive: true }; diff --git a/cli/src/__tests__/runtime-info.test.ts b/cli/src/__tests__/runtime-info.test.ts new file mode 100644 index 0000000000..eea58f3a05 --- /dev/null +++ b/cli/src/__tests__/runtime-info.test.ts @@ -0,0 +1,56 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + readRuntimeInfo, + removeRuntimeInfoForPid, + writeRuntimeInfo, + type PaperclipRuntimeInfo, +} from "../runtime-info.js"; + +const roots: string[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +function fixture(): { filePath: string; info: PaperclipRuntimeInfo } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-runtime-info-")); + roots.push(root); + return { + filePath: path.join(root, "runtime-info.json"), + info: { + schemaVersion: 1, + instanceId: "default", + pid: 123, + host: "127.0.0.1", + port: 3101, + dashboardUrl: "http://127.0.0.1:3101", + startedAt: "2026-08-25T00:00:00.000Z", + }, + }; +} + +describe("runtime info", () => { + it("writes and reads the selected runtime endpoint", () => { + const { filePath, info } = fixture(); + writeRuntimeInfo(info, filePath); + expect(readRuntimeInfo("default", filePath)).toEqual(info); + }); + + it("does not remove runtime info owned by a replacement process", () => { + const { filePath, info } = fixture(); + writeRuntimeInfo(info, filePath); + removeRuntimeInfoForPid(999, "default", filePath); + expect(readRuntimeInfo("default", filePath)).toEqual(info); + removeRuntimeInfoForPid(info.pid, "default", filePath); + expect(readRuntimeInfo("default", filePath)).toBeNull(); + }); + + it("rejects malformed runtime info", () => { + const { filePath } = fixture(); + fs.writeFileSync(filePath, JSON.stringify({ schemaVersion: 1, port: 70_000 })); + expect(readRuntimeInfo("default", filePath)).toBeNull(); + }); +}); diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index db94cb24a7..714741b258 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -52,7 +52,11 @@ import { trackInstallStarted, trackInstallCompleted, } from "../telemetry.js"; -import { handleOnboardService, shouldOfferForegroundStart } from "../onboard-service.js"; +import { + handleOnboardService, + handoffToOnboardedService, + shouldOfferForegroundStart, +} from "../onboard-service.js"; import { readInstallManifest, isManagedExecutable } from "../install-store.js"; type SetupMode = "quickstart" | "advanced"; @@ -457,6 +461,9 @@ export async function onboard(opts: OnboardOptions): Promise { printManagedInstallHint(); const serviceInstalled = await handleOnboardService(opts); + if (serviceInstalled) { + await handoffToOnboardedService(existingConfig); + } let shouldRunNow = !serviceInstalled && (opts.run === true || opts.yes === true); if (shouldOfferForegroundStart({ serviceInstalled, startAlreadyDecided: shouldRunNow, invokedByRun: opts.invokedByRun === true, interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY) })) { @@ -723,6 +730,9 @@ export async function onboard(opts: OnboardOptions): Promise { } const serviceInstalled = await handleOnboardService(opts); + if (serviceInstalled) { + await handoffToOnboardedService(config); + } let shouldRunNow = !serviceInstalled && (opts.run === true || opts.yes === true); if (shouldOfferForegroundStart({ serviceInstalled, startAlreadyDecided: shouldRunNow, invokedByRun: opts.invokedByRun === true, interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY) })) { diff --git a/cli/src/commands/run.ts b/cli/src/commands/run.ts index e9003e8949..9269851bbd 100644 --- a/cli/src/commands/run.ts +++ b/cli/src/commands/run.ts @@ -17,6 +17,7 @@ import { resolvePaperclipInstanceId, } from "../config/home.js"; import { assertForegroundRunAllowed } from "../services/service-manager.js"; +import { removeRuntimeInfoForPid, writeRuntimeInfo } from "../runtime-info.js"; import { printUpdateNotice } from "../update-notice.js"; import { ensureWorktreeSeeded } from "./worktree.js"; @@ -93,6 +94,16 @@ export async function runCommand(opts: RunOptions): Promise { p.log.step("Starting Paperclip server..."); const startedServer = await importServerEntry(); + writeRuntimeInfo({ + schemaVersion: 1, + instanceId, + pid: process.pid, + host: startedServer.host, + port: startedServer.listenPort, + dashboardUrl: startedServer.apiUrl.replace(/\/api\/?$/, ""), + startedAt: new Date().toISOString(), + }); + process.once("exit", () => removeRuntimeInfoForPid(process.pid, instanceId)); if (shouldGenerateBootstrapInviteAfterStart(config)) { p.log.step("Generating bootstrap CEO invite"); diff --git a/cli/src/onboard-service.ts b/cli/src/onboard-service.ts index 1f52e970aa..52a115953b 100644 --- a/cli/src/onboard-service.ts +++ b/cli/src/onboard-service.ts @@ -1,8 +1,11 @@ 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, @@ -14,6 +17,7 @@ import { resolveServiceShimPath, type ServiceManagerDetection, } from "./services/service-manager.js"; +import { buildLocalAppUrl, buildLocalHealthUrl } from "./utils/health-url.js"; import { packageVersion } from "./version.js"; export type OnboardServiceOptions = { @@ -22,6 +26,94 @@ export type OnboardServiceOptions = { }; type EnsureShimResult = { ok: boolean; installedNow: boolean; reason?: string }; +type OnboardServiceDashboardConfig = { + auth: Pick; + server: Pick; +}; + +type OnboardServiceDashboardDependencies = { + isInteractive: () => boolean; + waitUntilReady: () => Promise; + openDashboard: (url: string) => Promise; + 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 { + 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 | 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 = {}, +): Promise { + 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 diff --git a/cli/src/runtime-info.ts b/cli/src/runtime-info.ts new file mode 100644 index 0000000000..6c2287bdc7 --- /dev/null +++ b/cli/src/runtime-info.ts @@ -0,0 +1,82 @@ +import fs from "node:fs"; +import path from "node:path"; +import { resolvePaperclipInstanceRoot } from "./config/home.js"; + +export const PAPERCLIP_RUNTIME_INFO_FILENAME = "runtime-info.json"; + +export type PaperclipRuntimeInfo = { + schemaVersion: 1; + instanceId: string; + pid: number; + host: string; + port: number; + dashboardUrl: string; + startedAt: string; +}; + +export function resolveRuntimeInfoPath(instanceId?: string): string { + return path.join(resolvePaperclipInstanceRoot(instanceId), PAPERCLIP_RUNTIME_INFO_FILENAME); +} + +function parseRuntimeInfo(value: unknown): PaperclipRuntimeInfo | null { + if (!value || typeof value !== "object") return null; + const record = value as Record; + if ( + record.schemaVersion !== 1 || + typeof record.instanceId !== "string" || + !Number.isInteger(record.pid) || + (record.pid as number) <= 0 || + typeof record.host !== "string" || + !Number.isInteger(record.port) || + (record.port as number) <= 0 || + (record.port as number) > 65_535 || + typeof record.dashboardUrl !== "string" || + typeof record.startedAt !== "string" + ) { + return null; + } + return record as PaperclipRuntimeInfo; +} + +export function readRuntimeInfo(instanceId?: string, filePath = resolveRuntimeInfoPath(instanceId)): PaperclipRuntimeInfo | null { + try { + const info = parseRuntimeInfo(JSON.parse(fs.readFileSync(filePath, "utf8"))); + if (!info) return null; + if (instanceId && info.instanceId !== instanceId) return null; + return info; + } catch { + return null; + } +} + +export function writeRuntimeInfo( + info: PaperclipRuntimeInfo, + filePath = resolveRuntimeInfoPath(info.instanceId), +): void { + const directoryPath = path.dirname(filePath); + fs.mkdirSync(directoryPath, { recursive: true, mode: 0o700 }); + const temporaryPath = path.join( + directoryPath, + `.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}`, + ); + try { + fs.writeFileSync(temporaryPath, `${JSON.stringify(info, null, 2)}\n`, { + encoding: "utf8", + mode: 0o600, + flag: "wx", + }); + fs.renameSync(temporaryPath, filePath); + } finally { + fs.rmSync(temporaryPath, { force: true }); + } +} + +export function removeRuntimeInfoForPid( + pid: number, + instanceId?: string, + filePath = resolveRuntimeInfoPath(instanceId), +): void { + const current = readRuntimeInfo(instanceId, filePath); + if (current?.pid !== pid) return; + fs.rmSync(filePath, { force: true }); +} diff --git a/cli/src/utils/health-url.ts b/cli/src/utils/health-url.ts index 12f20312ff..ed3d95c15e 100644 --- a/cli/src/utils/health-url.ts +++ b/cli/src/utils/health-url.ts @@ -1,4 +1,4 @@ -export function buildLocalHealthUrl(host: string | undefined, port: number): string { +export function buildLocalAppUrl(host: string | undefined, port: number): string { const configuredHost = host?.trim(); const reachableHost = !configuredHost || configuredHost === "0.0.0.0" || configuredHost === "::" ? "127.0.0.1" @@ -6,5 +6,9 @@ export function buildLocalHealthUrl(host: string | undefined, port: number): str const urlHost = reachableHost.includes(":") && !reachableHost.startsWith("[") ? `[${reachableHost}]` : reachableHost; - return `http://${urlHost}:${port}/api/health`; + return `http://${urlHost}:${port}`; +} + +export function buildLocalHealthUrl(host: string | undefined, port: number): string { + return `${buildLocalAppUrl(host, port)}/api/health`; } diff --git a/doc/INSTALLING.md b/doc/INSTALLING.md index c817645e2b..26f0952845 100644 --- a/doc/INSTALLING.md +++ b/doc/INSTALLING.md @@ -138,6 +138,11 @@ paperclipai onboard --yes --install-service # explicit automation opt-in paperclipai onboard --yes --no-install-service ``` +After onboarding installs and starts the service, it waits for the service to +report its selected runtime port and then prints the dashboard URL. Interactive +terminals open that URL in the default browser; headless and non-interactive +runs print the URL without trying to launch a browser. + Service commands are namespaced: ```sh