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