fix(cli): hand over a working URL after the service install

Onboarding that installed the background service ended at "You are all
set!" without printing the server address or opening a browser - both
lived only in the foreground-start path. The authenticated tail also
told the user "Next: paperclipai run" while the service was already
running the server.

Add announceServiceReady: wait for the service to answer health, print
the URL, open the browser on interactive terminals, and point at
service status/logs when it never answers. Wire it into both onboard
call sites and correct the authenticated next-steps tail for the
service case.
This commit is contained in:
Devin Foley committed 2026-08-25 01:17:56 -07:00
1 parent 8d714c2d84
commit 6c12246e6b
6 files changed
+158 -9

No files matched your search

+1
View File
@@ -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": {
+57 -1
View File
@@ -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<string, unknown> = {}) {
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<typeof vi.fn>).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");
});
});
+25 -6
View File
@@ -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<void> {
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<void> {
}
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<void> {
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"),
);
}
+66
View File
@@ -166,6 +166,72 @@ export async function handleOnboardService(
return true;
}
export type ServiceReadyDependencies = {
probeHealth: (healthUrl: string) => Promise<boolean>;
openBrowser: (url: string) => Promise<void>;
sleep: (ms: number) => Promise<void>;
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<ServiceReadyDependencies> = {},
): Promise<boolean> {
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.
+6 -2
View File
@@ -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`;
}
+3
View File
@@ -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