Files
PaperClipAI/cli/src/onboard-service.ts
T
Devin Foley 2943795b57 fix(cli): materialize the managed install before the onboarding service install (#12148)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Interactive onboarding offers to install Paperclip as a background
service, defaulting to yes
> - The service definition targets the managed command shim, but an
ephemeral npx run never installs it, and the service step never checks
> - The result is a crash-looping service, a doctor hint about a
nonexistent port conflict, and a first run that ends with nothing
serving
> - This pull request materializes the managed install before
registering the service, or declines with the repair path
> - The benefit is that saying yes to the service prompt yields a
working service — or an honest explanation

## Linked Issues or Issue Description

**What happened?**

On a machine with no managed install, `npx paperclipai@2026.824.0
onboard` (interactive), accepting the background-service prompt,
produced: a LaunchAgent pointing at `~/.local/bin/paperclipai` (which
does not exist), launchd exit code 78 in a KeepAlive crash loop, doctor
reporting "inactive but the configured port is serving another Paperclip
process — stop the conflicting foreground process" (no such process
existed), and "Service health: fetch failed". Reproduced twice on a
clean field. `latest` has carried this path since v2026.817.0 shipped;
CI never sees it because `--yes` onboarding skips the service prompt.

**Expected behavior**

Accepting the service prompt installs a working service (materializing
the managed payload and shim first when needed), and doctor diagnoses a
missing service binary as exactly that.

**Steps to reproduce**

On macOS with no `~/.local/bin/paperclipai`: `npx paperclipai@latest
onboard`, accept the service prompt, then `launchctl print
gui/$UID/ing.paperclip.paperclipai` (exit code 78, spawn scheduled) and
`paperclipai doctor`.

**Paperclip version or commit**

`2026.824.0` (path present since #10045).

## What Changed

- `cli/src/onboard-service.ts`: after the user opts in, an
`ensureServiceShim` step checks the service shim path. Missing +
managed-store location → run `installCommand` pinned to the onboarding
version (payload, shim, PATH block), then proceed. Missing + custom
`PAPERCLIP_SHIM_PATH`, or install failure → decline with `paperclipai
install` / `paperclipai service install` guidance and install nothing.
- `cli/src/checks/service-health-check.ts`: the runtime check diagnoses
a missing service binary with the install repair hint (instead of the
port-conflict hint); an inactive service with a healthy responder gets a
`warn` attributing the foreign process instead of a plain "Healthy"
pass.
- Tests: new cases for shim materialization ordering,
decline-on-failure, missing-binary diagnosis, and foreign-responder
attribution; existing fixtures updated to inject the new dependencies.

## Verification

- `vitest run` on both touched suites: 15 pass.
- `tsc --noEmit` error count identical to the master baseline (16
pre-existing, all in `server/`, none in changed files).
- The live failure was reproduced on macOS before the fix (twice, clean
field) and the mechanism confirmed in source: `install()` writes the
definition and bootstraps launchd only; `install-store` was previously
reachable solely from the `install`/`update` commands.

## Risks

- Low: the new path runs only when the user opts into the service and
the shim is absent. The managed install resolves the pinned onboarding
version from the public registry; on failure the flow declines exactly
as it does on unsupported platforms. `--yes` quickstarts, Docker, and
managed installs are untouched.

## Model Used

Claude Fable 5 (Claude Code)

## Pre-submission 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
2026-08-25 11:57:18 -07:00

168 lines
6.2 KiB
TypeScript

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 {
readInstallManifest,
resolveInstallStorePaths,
type InstallManifest,
} from "./install-store.js";
import {
detectServiceManager,
isExecutableFile,
resolveServiceShimPath,
type ServiceManagerDetection,
} from "./services/service-manager.js";
import { packageVersion } from "./version.js";
export type OnboardServiceOptions = {
yes?: boolean;
installService?: boolean;
};
type EnsureShimResult = { ok: boolean; installedNow: boolean; reason?: string };
// Source checkouts carry the repository placeholder version; installing
// that as an npm spec would fetch an ancient release (or nothing) instead
// of the running code. Only real calendar versions are installable.
export function isInstallableReleaseVersion(version: string): boolean {
return /^\d{4}\.\d{1,4}\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version);
}
type OnboardServiceDependencies = {
detect: (instanceId: string) => Promise<ServiceManagerDetection>;
ensureServiceShim: () => Promise<EnsureShimResult>;
confirm: () => Promise<boolean>;
confirmLinger: () => Promise<boolean>;
isInteractive: () => boolean;
info: (message: string) => void;
success: (message: string) => void;
warn: (message: string) => void;
};
const defaultDependencies: OnboardServiceDependencies = {
detect: (instanceId) => detectServiceManager({ instanceId }),
// The service definition targets the managed shim. An ephemeral run (npx)
// never lays it down, so installing the service without this step creates
// a definition that crash-loops on a missing binary.
ensureServiceShim: async () => {
const shimPath = resolveServiceShimPath();
if (await isExecutableFile(shimPath)) {
return { ok: true, installedNow: false };
}
const storeShimPath = resolveInstallStorePaths().shimPath;
if (path.resolve(shimPath) !== path.resolve(storeShimPath)) {
return {
ok: false,
installedNow: false,
reason: `no executable exists at ${shimPath} (PAPERCLIP_SHIM_PATH), and it is outside the managed install store`,
};
}
let manifest: InstallManifest | null = null;
try {
manifest = readInstallManifest();
} catch {}
try {
if (manifest?.source === "git" && manifest.repo) {
// A managed git payload must be preserved as-is: reinstall the
// exact revision the manifest records, not an npm release.
await installCommand({ repo: manifest.repo, ref: manifest.sha ?? manifest.ref, yes: true });
} else if (isInstallableReleaseVersion(packageVersion)) {
// packageVersion, not cliVersion: a managed executable's cliVersion
// carries provenance text that is not an installable npm spec.
await installCommand({ version: packageVersion, yes: true });
} else {
return {
ok: false,
installedNow: false,
reason:
`this build reports version ${packageVersion}, which is not an installable release; ` +
"run `paperclipai install` (or `paperclipai install --repo <repo> --ref <ref>` for source builds) first",
};
}
} catch (error) {
return {
ok: false,
installedNow: false,
reason: error instanceof Error ? error.message : String(error),
};
}
if (await isExecutableFile(shimPath)) {
return { ok: true, installedNow: true };
}
return {
ok: false,
installedNow: false,
reason: `the managed install completed but no executable shim appeared at ${shimPath}`,
};
},
confirm: async () => {
const answer = await p.confirm({
message: "Install Paperclip as a background service?",
initialValue: true,
});
return !p.isCancel(answer) && answer === true;
},
confirmLinger: async () => {
const answer = await p.confirm({
message: "Allow Paperclip to keep running after logout? This may request system authorization.",
initialValue: false,
});
return !p.isCancel(answer) && answer === true;
},
isInteractive: () => process.stdin.isTTY === true && process.stdout.isTTY === true,
info: (message) => p.log.message(pc.dim(message)),
success: (message) => p.log.success(message),
warn: (message) => p.log.warn(message),
};
export async function handleOnboardService(
options: OnboardServiceOptions,
dependencies: Partial<OnboardServiceDependencies> = {},
): Promise<boolean> {
const deps = { ...defaultDependencies, ...dependencies };
if (options.installService === false) return false;
const explicitlyRequested = options.installService === true;
const canPrompt = options.yes !== true && deps.isInteractive();
if (!explicitlyRequested && !canPrompt) {
deps.info(
"Background service not installed. Use `paperclipai onboard --install-service` or `paperclipai service install` to opt in.",
);
return false;
}
const instanceId = resolvePaperclipInstanceId();
const detection = await deps.detect(instanceId);
if (!detection.supported) {
if (explicitlyRequested) deps.warn(detection.reason);
return false;
}
if (!explicitlyRequested && !(await deps.confirm())) return false;
// A definition pointing at a missing binary crash-loops in the platform
// supervisor's penalty box while doctor blames a port conflict.
// Materialize the managed install first, or decline with the repair path
// instead of installing a corpse.
const shim = await deps.ensureServiceShim();
if (!shim.ok) {
deps.warn(
`Background service not installed: ${shim.reason ?? "the managed install could not be completed"}. ` +
"Run `paperclipai install`, then `paperclipai service install`.",
);
return false;
}
if (shim.installedNow) {
deps.success("Installed the managed paperclipai payload and command shim for the service.");
}
await detection.manager.install({ startNow: true, startOnLogin: true });
if (!explicitlyRequested && detection.manager.enableLinger && await deps.confirmLinger()) {
await detection.manager.enableLinger();
}
deps.success(`Installed and started ${detection.manager.serviceName}.`);
return true;
}