diff --git a/cli/package.json b/cli/package.json index 4113ed4d45e..28f394ca8f1 100644 --- a/cli/package.json +++ b/cli/package.json @@ -56,6 +56,7 @@ "dotenv": "^17.4.2", "commander": "^13.1.0", "embedded-postgres": "^18.1.0-beta.16", + "open": "^11.0.1", "picocolors": "^1.1.1" }, "devDependencies": { diff --git a/cli/src/__tests__/onboard-service.test.ts b/cli/src/__tests__/onboard-service.test.ts index 1b2b6baf4ae..7f6ab8b46e4 100644 --- a/cli/src/__tests__/onboard-service.test.ts +++ b/cli/src/__tests__/onboard-service.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { handleOnboardService, isInstallableReleaseVersion, shouldOfferForegroundStart } from "../onboard-service.js"; +import { announceServiceReady, handleOnboardService, isInstallableReleaseVersion, shouldOfferForegroundStart } from "../onboard-service.js"; function supportedDetection() { return { @@ -162,3 +162,59 @@ describe("shouldOfferForegroundStart", () => { expect(shouldOfferForegroundStart({ ...base, interactive: false })).toBe(false); }); }); + +describe("announceServiceReady", () => { + function readyDeps(overrides: Record = {}) { + return { + probeHealth: vi.fn(async () => true), + openBrowser: vi.fn(async () => undefined), + sleep: vi.fn(async () => undefined), + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + ...overrides, + }; + } + + it("prints the URL and opens the browser once the service answers", async () => { + const deps = readyDeps(); + const ready = await announceServiceReady({ baseUrl: "http://127.0.0.1:3100", interactive: true }, deps); + expect(ready).toBe(true); + expect(deps.probeHealth).toHaveBeenCalledWith("http://127.0.0.1:3100/api/health"); + expect(deps.success).toHaveBeenCalledWith("Paperclip is running at http://127.0.0.1:3100"); + expect(deps.openBrowser).toHaveBeenCalledWith("http://127.0.0.1:3100"); + }); + + it("polls until the service becomes healthy", async () => { + const probeHealth = vi.fn(async () => false).mockResolvedValueOnce(false).mockResolvedValueOnce(false).mockResolvedValue(true); + const deps = readyDeps({ probeHealth }); + const ready = await announceServiceReady({ baseUrl: "http://127.0.0.1:3100", interactive: true, timeoutMs: 50, pollIntervalMs: 10 }, deps); + expect(ready).toBe(true); + expect(probeHealth).toHaveBeenCalledTimes(3); + }); + + it("prints the URL instead of opening a browser on non-interactive runs", async () => { + const deps = readyDeps(); + await announceServiceReady({ baseUrl: "http://127.0.0.1:3100", interactive: false }, deps); + expect(deps.openBrowser).not.toHaveBeenCalled(); + expect(deps.info).toHaveBeenCalledWith("Open http://127.0.0.1:3100 in your browser to get started."); + }); + + it("falls back to printing the URL when the browser cannot open", async () => { + const deps = readyDeps({ openBrowser: vi.fn(async () => { throw new Error("no display"); }) }); + const ready = await announceServiceReady({ baseUrl: "http://127.0.0.1:3100", interactive: true }, deps); + expect(ready).toBe(true); + expect(deps.info).toHaveBeenCalledWith("Open http://127.0.0.1:3100 in your browser to get started."); + }); + + it("warns with service diagnostics when the service never answers", async () => { + const deps = readyDeps({ probeHealth: vi.fn(async () => false) }); + const ready = await announceServiceReady({ baseUrl: "http://127.0.0.1:3100", interactive: true, timeoutMs: 30, pollIntervalMs: 10 }, deps); + expect(ready).toBe(false); + expect(deps.openBrowser).not.toHaveBeenCalled(); + const warned = (deps.warn as ReturnType).mock.calls[0]?.[0] as string; + expect(warned).toContain("has not answered at http://127.0.0.1:3100"); + expect(warned).toContain("paperclipai service status"); + expect(warned).toContain("paperclipai service logs"); + }); +}); diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index db94cb24a75..75d3597be93 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -52,7 +52,8 @@ import { trackInstallStarted, trackInstallCompleted, } from "../telemetry.js"; -import { handleOnboardService, shouldOfferForegroundStart } from "../onboard-service.js"; +import { announceServiceReady, handleOnboardService, shouldOfferForegroundStart } from "../onboard-service.js"; +import { buildLocalBaseUrl } from "../utils/health-url.js"; import { readInstallManifest, isManagedExecutable } from "../install-store.js"; type SetupMode = "quickstart" | "advanced"; @@ -457,6 +458,12 @@ export async function onboard(opts: OnboardOptions): Promise { printManagedInstallHint(); const serviceInstalled = await handleOnboardService(opts); + if (serviceInstalled) { + await announceServiceReady({ + baseUrl: buildLocalBaseUrl(existingConfig.server.host, existingConfig.server.port), + interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY), + }); + } 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,12 @@ export async function onboard(opts: OnboardOptions): Promise { } const serviceInstalled = await handleOnboardService(opts); + if (serviceInstalled) { + await announceServiceReady({ + baseUrl: buildLocalBaseUrl(server.host, server.port), + interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY), + }); + } 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) })) { @@ -744,11 +757,17 @@ export async function onboard(opts: OnboardOptions): Promise { if (server.deploymentMode === "authenticated" && database.mode === "embedded-postgres") { p.log.info( - [ - "Bootstrap CEO invite will be created after the server starts.", - `Next: ${pc.cyan("paperclipai run")}`, - `Then: ${pc.cyan("paperclipai auth bootstrap-ceo")}`, - ].join("\n"), + (serviceInstalled + ? [ + "The background service is running the server.", + `Create your sign-in invite with: ${pc.cyan("paperclipai auth bootstrap-ceo")}`, + ] + : [ + "Bootstrap CEO invite will be created after the server starts.", + `Next: ${pc.cyan("paperclipai run")}`, + `Then: ${pc.cyan("paperclipai auth bootstrap-ceo")}`, + ] + ).join("\n"), ); } diff --git a/cli/src/onboard-service.ts b/cli/src/onboard-service.ts index 1f52e970aa9..645ae888211 100644 --- a/cli/src/onboard-service.ts +++ b/cli/src/onboard-service.ts @@ -166,6 +166,72 @@ export async function handleOnboardService( return true; } +export type ServiceReadyDependencies = { + probeHealth: (healthUrl: string) => Promise; + openBrowser: (url: string) => Promise; + sleep: (ms: number) => Promise; + info: (message: string) => void; + success: (message: string) => void; + warn: (message: string) => void; +}; + +const defaultServiceReadyDependencies: ServiceReadyDependencies = { + probeHealth: async (healthUrl) => { + try { + const response = await fetch(healthUrl, { signal: AbortSignal.timeout(2_000) }); + const body = (await response.json()) as { status?: unknown }; + return response.ok && body.status === "ok"; + } catch { + return false; + } + }, + openBrowser: async (url) => { + const mod = await import("open"); + await mod.default(url); + }, + sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)), + info: (message) => p.log.message(pc.dim(message)), + success: (message) => p.log.success(message), + warn: (message) => p.log.warn(message), +}; + +// A freshly installed service is what will serve the user's first session, +// so onboarding stays on the hook until it can hand over a working address: +// wait for health, print the URL, and open a browser for a human. The first +// boot initializes the embedded database, so the default timeout is generous. +export async function announceServiceReady( + options: { baseUrl: string; interactive: boolean; timeoutMs?: number; pollIntervalMs?: number }, + dependencies: Partial = {}, +): Promise { + const deps = { ...defaultServiceReadyDependencies, ...dependencies }; + const timeoutMs = options.timeoutMs ?? 180_000; + const pollIntervalMs = options.pollIntervalMs ?? 1_000; + const healthUrl = `${options.baseUrl}/api/health`; + deps.info(`Waiting for the server at ${options.baseUrl} ...`); + const attempts = Math.max(1, Math.ceil(timeoutMs / pollIntervalMs)); + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (await deps.probeHealth(healthUrl)) { + deps.success(`Paperclip is running at ${options.baseUrl}`); + if (options.interactive) { + try { + await deps.openBrowser(options.baseUrl); + return true; + } catch { + // Fall through to printing the address. + } + } + deps.info(`Open ${options.baseUrl} in your browser to get started.`); + return true; + } + await deps.sleep(pollIntervalMs); + } + deps.warn( + `The background service has not answered at ${options.baseUrl} yet. ` + + "Check `paperclipai service status` and `paperclipai service logs`.", + ); + return false; +} + // 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. diff --git a/cli/src/utils/health-url.ts b/cli/src/utils/health-url.ts index 12f20312ffa..ae212ec557b 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 buildLocalBaseUrl(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 `${buildLocalBaseUrl(host, port)}/api/health`; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88daa34971e..617f23693af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,6 +109,9 @@ importers: embedded-postgres: specifier: ^18.1.0-beta.16 version: 18.1.0-beta.16(patch_hash=55uhvnotpqyiy37rn3pqpukhei) + open: + specifier: ^11.0.1 + version: 11.0.1 picocolors: specifier: ^1.1.1 version: 1.1.1