mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 04:25:52 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The CLI onboarding wizard can install Paperclip as a background service, and it offers a foreground start when nothing else will serve > - After #12148, an interactive onboard installs and starts the service, then still asks "Start Paperclip now?" > - Answering yes runs the foreground start into the already-running instance guard, so a fully successful onboard ends with an error message > - This pull request excludes the just-installed-service case from the foreground-start prompt > - The benefit is that an interactive onboard that installs the service ends cleanly instead of steering the user into a guard refusal ## Linked Issues or Issue Description Refs #12148 — found while verifying that fix interactively. The `shouldRunNow` flag already accounts for `serviceInstalled`, but the interactive TTY fallback prompt did not, so only real interactive runs hit it: `--yes` runs, CI, and container smokes all skip the prompt branch. **What happened?** Interactive `onboard`, accept the background-service prompt. Output ends with: service installed and started, then "Start Paperclip now?" → yes → "Paperclip instance 'default' is already running as ing.paperclip.paperclipai. Use 'paperclipai service status --instance default' or pass --force to bypass this safety check." **What did you expect to happen?** Onboarding ends cleanly after "Installed and started …" — there is nothing left to start, so no prompt. **Steps to reproduce** Run `npx paperclipai@2026.825.0-nightly.1 onboard --data-dir "$(mktemp -d)"` in a terminal, accept the service prompt, then accept "Start Paperclip now?". ## What Changed - New `shouldOfferForegroundStart` predicate in `cli/src/onboard-service.ts`: the foreground-start prompt is offered only when the start was not already decided by flags, the service was not just installed, onboarding was not invoked by `run`, and the terminal is interactive. - Both onboarding call sites in `cli/src/commands/onboard.ts` use the predicate instead of the inline condition that ignored `serviceInstalled`. - Unit tests cover the predicate matrix in `cli/src/__tests__/onboard-service.test.ts`. ## Verification - `npx vitest run src/__tests__/onboard-service.test.ts` in `cli/`: 12 passed (5 new). - `tsc --noEmit` reports no errors in the changed files (remaining errors are pre-existing in `server/`). - Manual reproduction of the defect on macOS with `2026.825.0-nightly.1` before the fix: service installed, started, and healthy, then the prompt steered into the guard refusal. ## Risks - Low risk. The prompt still appears in every case it did before except when the service was just installed and is already serving. - No behavior change for `--yes`, `--run`, `--install-service` in non-interactive runs: those paths never reached the prompt. ## Model Used - Claude Fable 5 (Anthropic, model ID `claude-fable-5`), extended thinking, agentic tool use via Claude Code. ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
185 lines
6.7 KiB
TypeScript
185 lines
6.7 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;
|
|
}
|
|
|
|
// Onboarding falls back to offering a foreground start when nothing else
|
|
// will serve. A just-installed service is already serving, so offering the
|
|
// start would only run the user into the already-running instance guard.
|
|
export function shouldOfferForegroundStart(options: {
|
|
serviceInstalled: boolean;
|
|
startAlreadyDecided: boolean;
|
|
invokedByRun: boolean;
|
|
interactive: boolean;
|
|
}): boolean {
|
|
return (
|
|
!options.startAlreadyDecided &&
|
|
!options.serviceInstalled &&
|
|
!options.invokedByRun &&
|
|
options.interactive
|
|
);
|
|
}
|