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:
parent
ffff1fe6e3
commit
0f0e544317
|
|
@ -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 };
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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) })) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue