mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-26 02:00:52 +01:00
## 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
168 lines
6.2 KiB
TypeScript
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;
|
|
}
|