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.
This commit is contained in:
Devin Foley committed 2026-08-25 01:41:11 -07:00
1 parent c664a5de76
commit 55872ca83c
3 files changed
+56 -25

No files matched your search

+33
View File
@@ -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();
+6 -24
View File
@@ -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<void> {
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<PaperclipConfig["server"], "bind" | "customBindHost" | "host" | "port">): string {
const bind = server.bind ?? inferBindModeFromHost(server.host);
const detail =
@@ -475,11 +457,11 @@ export async function onboard(opts: OnboardOptions): Promise<void> {
);
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<void> {
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),
});
}
+17 -1
View File
@@ -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<boolean>;
confirmLinger: () => Promise<boolean>;
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 <id>` 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);