From 55872ca83c55947caf54d26303679573cead4112 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Tue, 25 Aug 2026 01:41:11 -0700 Subject: [PATCH] fix(cli): decline the service install for configs the service never reads The supervisor starts run --instance with only the home baked in, so the service always reads the default instance config path. When onboarding selected a custom config (--config, PAPERCLIP_CONFIG, or an ancestor file), it still installed a service that would serve the default config or exit. Decline the install for non-default configs with the reason and the foreground alternative, mirroring the custom-shim decline. The announcement no longer needs a mismatch branch: an installed service implies the default config. --- cli/src/__tests__/onboard-service.test.ts | 33 +++++++++++++++++++++++ cli/src/commands/onboard.ts | 30 +++++---------------- cli/src/onboard-service.ts | 18 ++++++++++++- 3 files changed, 56 insertions(+), 25 deletions(-) diff --git a/cli/src/__tests__/onboard-service.test.ts b/cli/src/__tests__/onboard-service.test.ts index a24806fbdd5..01bde9d701a 100644 --- a/cli/src/__tests__/onboard-service.test.ts +++ b/cli/src/__tests__/onboard-service.test.ts @@ -44,6 +44,39 @@ describe("onboard service policy", () => { expect(info).toHaveBeenCalledWith(expect.stringContaining("--install-service")); }); + it("declines a custom config path the service would never read", async () => { + const warn = vi.fn(); + const detect = vi.fn(); + const installed = await handleOnboardService( + { yes: true, installService: true, configPath: "/custom/paperclip.json" }, + { + detect, + warn, + serviceConfigPath: () => "/home/user/.paperclip/instances/default/config.json", + ensureServiceShim: vi.fn(async () => ({ ok: true, installedNow: false })), + }, + ); + expect(installed).toBe(false); + expect(detect).not.toHaveBeenCalled(); + const message = warn.mock.calls[0]?.[0] as string; + expect(message).toContain("/home/user/.paperclip/instances/default/config.json"); + expect(message).toContain("/custom/paperclip.json"); + }); + + it("installs when the config path is the one the service reads", async () => { + const detection = supportedDetection(); + const installed = await handleOnboardService( + { yes: true, installService: true, configPath: "/home/user/.paperclip/instances/default/config.json" }, + { + detect: vi.fn(async () => detection), + isInteractive: () => false, + serviceConfigPath: () => "/home/user/.paperclip/instances/default/config.json", + ensureServiceShim: vi.fn(async () => ({ ok: true, installedNow: false })), + }, + ); + expect(installed).toBe(true); + }); + it("installs when --yes explicitly opts in", async () => { const detection = supportedDetection(); diff --git a/cli/src/commands/onboard.ts b/cli/src/commands/onboard.ts index cce0571c0d6..f24bd793417 100644 --- a/cli/src/commands/onboard.ts +++ b/cli/src/commands/onboard.ts @@ -43,7 +43,6 @@ import { resolveDefaultBackupDir, resolveDefaultEmbeddedPostgresDir, resolveDefaultLogsDir, - resolveDefaultConfigPath, resolvePaperclipInstanceId, } from "../config/home.js"; import { bootstrapCeoInvite } from "./auth-bootstrap-ceo.js"; @@ -130,23 +129,6 @@ function resolvePathFromEnv(rawValue: string | undefined): string | null { return path.resolve(expandHomePrefix(rawValue.trim())); } -async function announceInstalledService(options: { configPath: string; baseUrl: string }): Promise { - const serviceConfigPath = resolveDefaultConfigPath(resolvePaperclipInstanceId()); - if (path.resolve(options.configPath) === path.resolve(serviceConfigPath)) { - await announceServiceReady({ - baseUrl: options.baseUrl, - interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY), - }); - return; - } - p.log.info( - [ - `The background service reads ${serviceConfigPath}, not ${options.configPath}.`, - `Check ${pc.cyan("paperclipai service status")} for the served address.`, - ].join("\n"), - ); -} - function describeServerBinding(server: Pick): string { const bind = server.bind ?? inferBindModeFromHost(server.host); const detail = @@ -475,11 +457,11 @@ export async function onboard(opts: OnboardOptions): Promise { ); printManagedInstallHint(); - const serviceInstalled = await handleOnboardService(opts); + const serviceInstalled = await handleOnboardService({ ...opts, configPath }); if (serviceInstalled) { - await announceInstalledService({ - configPath, + await announceServiceReady({ baseUrl: buildLocalBaseUrl(existingConfig.server.host, existingConfig.server.port), + interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY), }); } @@ -747,11 +729,11 @@ export async function onboard(opts: OnboardOptions): Promise { await bootstrapCeoInvite({ config: configPath }); } - const serviceInstalled = await handleOnboardService(opts); + const serviceInstalled = await handleOnboardService({ ...opts, configPath }); if (serviceInstalled) { - await announceInstalledService({ - configPath, + await announceServiceReady({ baseUrl: buildLocalBaseUrl(server.host, server.port), + interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY), }); } diff --git a/cli/src/onboard-service.ts b/cli/src/onboard-service.ts index 95725783805..5d03837324e 100644 --- a/cli/src/onboard-service.ts +++ b/cli/src/onboard-service.ts @@ -2,7 +2,7 @@ import path from "node:path"; import * as p from "@clack/prompts"; import pc from "picocolors"; import { installCommand } from "./commands/install.js"; -import { resolvePaperclipInstanceId } from "./config/home.js"; +import { resolveDefaultConfigPath, resolvePaperclipInstanceId } from "./config/home.js"; import { readInstallManifest, resolveInstallStorePaths, @@ -19,6 +19,7 @@ import { packageVersion } from "./version.js"; export type OnboardServiceOptions = { yes?: boolean; installService?: boolean; + configPath?: string; }; type EnsureShimResult = { ok: boolean; installedNow: boolean; reason?: string }; @@ -36,6 +37,7 @@ type OnboardServiceDependencies = { confirm: () => Promise; confirmLinger: () => Promise; isInteractive: () => boolean; + serviceConfigPath: (instanceId: string) => string; info: (message: string) => void; success: (message: string) => void; warn: (message: string) => void; @@ -112,6 +114,7 @@ const defaultDependencies: OnboardServiceDependencies = { return !p.isCancel(answer) && answer === true; }, isInteractive: () => process.stdin.isTTY === true && process.stdout.isTTY === true, + serviceConfigPath: (instanceId) => resolveDefaultConfigPath(instanceId), info: (message) => p.log.message(pc.dim(message)), success: (message) => p.log.success(message), warn: (message) => p.log.warn(message), @@ -134,6 +137,19 @@ export async function handleOnboardService( } const instanceId = resolvePaperclipInstanceId(); + + // The supervisor starts `run --instance ` with only the home baked in, + // so the service always reads the instance's default config path. Installing + // it for a custom config would leave that config unserved. + const serviceConfigPath = deps.serviceConfigPath(instanceId); + if (options.configPath && path.resolve(options.configPath) !== path.resolve(serviceConfigPath)) { + const reason = + `the background service reads ${serviceConfigPath}, not ${options.configPath}. ` + + "Move the configuration to the default path, or run the server in the foreground with `paperclipai run`."; + if (explicitlyRequested) deps.warn(`Background service not installed: ${reason}`); + return false; + } + const detection = await deps.detect(instanceId); if (!detection.supported) { if (explicitlyRequested) deps.warn(detection.reason);