mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
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:
1 parent
8d714c2d84
commit
6c12246e6b
6 files changed
+158
-9
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in new issue
Block a user