fix(cli): open dashboard after onboarding service starts (#12164)

## 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@<version> 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@<version> 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-25 09:33:09 -05:00 committed by GitHub
parent ffff1fe6e3
commit 0f0e544317
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 372 additions and 4 deletions

View File

@ -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 };

View File

@ -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();
});
});

View File

@ -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<void> {
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<void> {
}
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) })) {

View File

@ -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<void> {
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");

View File

@ -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<PaperclipConfig["auth"], "baseUrlMode" | "publicBaseUrl">;
server: Pick<PaperclipConfig["server"], "host" | "port">;
};
type OnboardServiceDashboardDependencies = {
isInteractive: () => boolean;
waitUntilReady: () => Promise<PaperclipRuntimeInfo | null>;
openDashboard: (url: string) => Promise<boolean>;
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<PaperclipRuntimeInfo | null> {
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<PaperclipRuntimeInfo, "dashboardUrl"> | 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<OnboardServiceDashboardDependencies> = {},
): Promise<void> {
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

82
cli/src/runtime-info.ts Normal file
View File

@ -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<string, unknown>;
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 });
}

View File

@ -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`;
}

View File

@ -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