diff --git a/cli/src/__tests__/onboard-service.test.ts b/cli/src/__tests__/onboard-service.test.ts index 92697fc675c..58abea274e8 100644 --- a/cli/src/__tests__/onboard-service.test.ts +++ b/cli/src/__tests__/onboard-service.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it, vi } from "vitest"; +import fs from "node:fs"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { + ensureServiceShim, handleOnboardService, handoffToOnboardedService, isInstallableReleaseVersion, @@ -7,6 +10,97 @@ import { shouldOfferForegroundStart, } from "../onboard-service.js"; +import { readInstallManifest, resolveInstallStorePaths, writeManagedShim } from "../install-store.js"; +import { installCommand, runCommandWithDiagnostics, smokePayload } from "../commands/install.js"; +import { isExecutableFile, resolveServiceShimPath } from "../services/service-manager.js"; + +vi.mock("../version.js", () => ({ packageVersion: "2026.924.0" })); +vi.mock("../install-store.js", async (importOriginal) => ({ + ...await importOriginal(), + readInstallManifest: vi.fn(), + writeManagedShim: vi.fn(), +})); +vi.mock("../commands/install.js", () => ({ + installCommand: vi.fn(), + runCommandWithDiagnostics: vi.fn(), + smokePayload: vi.fn(), +})); +vi.mock("../services/service-manager.js", async (importOriginal) => ({ + ...await importOriginal(), + isExecutableFile: vi.fn(), +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +it("does not install a missing shim when installation is disabled", async () => { + vi.mocked(isExecutableFile).mockResolvedValue(false); + + await expect(ensureServiceShim({ installIfMissing: false })).resolves.toEqual({ + ok: false, + installedNow: false, + reason: `no executable exists at ${resolveServiceShimPath()}`, + }); + expect(installCommand).not.toHaveBeenCalled(); +}); + +it("restores a missing shim without reinstalling the managed payload", async () => { + vi.mocked(readInstallManifest).mockReturnValueOnce({ + schemaVersion: 1, + source: "npm", + version: "2026.824.1", + channel: "latest", + payloadPath: "/managed/payload", + installedAt: "2026-08-24T00:00:00.000Z", + previous: [], + }); + vi.mocked(isExecutableFile).mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + const entrypoint = path.join(resolveInstallStorePaths().currentPath, "node_modules", "paperclipai", "dist", "index.js"); + const existsSync = vi.spyOn(fs, "existsSync").mockImplementation((filePath) => filePath === entrypoint); + try { + await expect(ensureServiceShim()).resolves.toEqual({ ok: true, installedNow: true }); + expect(smokePayload).toHaveBeenCalledExactlyOnceWith( + resolveInstallStorePaths().currentPath, "2026.824.1", runCommandWithDiagnostics, + ); + expect(writeManagedShim).toHaveBeenCalledExactlyOnceWith(); + expect(vi.mocked(smokePayload).mock.invocationCallOrder[0]) + .toBeLessThan(vi.mocked(writeManagedShim).mock.invocationCallOrder[0]); + expect(installCommand).not.toHaveBeenCalled(); + } finally { + existsSync.mockRestore(); + } +}); + +it("does not restore a shim when the managed payload smoke check fails", async () => { + vi.mocked(readInstallManifest).mockReturnValueOnce({ + schemaVersion: 1, + source: "npm", + version: "2026.824.1", + channel: "latest", + payloadPath: "/managed/payload", + installedAt: "2026-08-24T00:00:00.000Z", + previous: [], + }); + vi.mocked(isExecutableFile).mockResolvedValue(false); + vi.mocked(smokePayload).mockRejectedValueOnce(new Error("payload dependency is missing")); + + const entrypoint = path.join(resolveInstallStorePaths().currentPath, "node_modules", "paperclipai", "dist", "index.js"); + const existsSync = vi.spyOn(fs, "existsSync").mockImplementation((filePath) => filePath === entrypoint); + try { + await expect(ensureServiceShim()).resolves.toEqual({ + ok: false, + installedNow: false, + reason: "payload dependency is missing", + }); + expect(writeManagedShim).not.toHaveBeenCalled(); + expect(installCommand).not.toHaveBeenCalled(); + } finally { + existsSync.mockRestore(); + } +}); + function dashboardConfig(overrides: { host?: string; port?: number; diff --git a/cli/src/__tests__/service-install-command.test.ts b/cli/src/__tests__/service-install-command.test.ts new file mode 100644 index 00000000000..170f126e835 --- /dev/null +++ b/cli/src/__tests__/service-install-command.test.ts @@ -0,0 +1,68 @@ +import { Command } from "commander"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { registerServiceCommands } from "../commands/service.js"; +import { ensureServiceShim } from "../onboard-service.js"; +import { detectServiceManager, type ServiceManager } from "../services/service-manager.js"; + +vi.mock("../onboard-service.js", () => ({ ensureServiceShim: vi.fn() })); +vi.mock("../services/service-manager.js", () => ({ detectServiceManager: vi.fn() })); + +const install = vi.fn(async () => ({ changed: true })); + +function runInstall(...args: string[]) { + const program = new Command(); + registerServiceCommands(program); + return program.parseAsync(["service", "install", ...args], { from: "user" }); +} + +describe("service install command", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.mocked(ensureServiceShim).mockResolvedValue({ ok: true, installedNow: false }); + vi.mocked(detectServiceManager).mockResolvedValue({ + supported: true, + manager: { + platform: "systemd", + serviceName: "paperclipai.service", + definitionPath: "/service/paperclipai.service", + install, + } as unknown as ServiceManager, + }); + }); + + afterEach(() => vi.restoreAllMocks()); + + it("checks the shim before installing and preserves JSON output and start flags", async () => { + await runInstall("--json", "--no-start-now", "--no-start-on-login"); + + expect(ensureServiceShim).toHaveBeenCalledWith({ installIfMissing: false }); + expect(vi.mocked(ensureServiceShim).mock.invocationCallOrder[0]).toBeLessThan(install.mock.invocationCallOrder[0]!); + expect(install).toHaveBeenCalledWith({ startNow: false, startOnLogin: false }); + expect(console.log).toHaveBeenCalledExactlyOnceWith(JSON.stringify({ + installed: true, + changed: true, + platform: "systemd", + serviceName: "paperclipai.service", + definitionPath: "/service/paperclipai.service", + lingerEnabled: false, + }, null, 2)); + }); + + it("rejects a missing shim without installing or printing success JSON", async () => { + vi.mocked(ensureServiceShim).mockResolvedValue({ ok: false, installedNow: false, reason: "missing shim" }); + + await expect(runInstall("--json")).rejects.toThrow( + "Background service not installed: missing shim. Run `paperclipai install`, then `paperclipai service install`.", + ); + expect(install).not.toHaveBeenCalled(); + expect(console.log).not.toHaveBeenCalled(); + }); + + it("allows creating the managed shim without --json", async () => { + await runInstall(); + + expect(ensureServiceShim).toHaveBeenCalledWith({ installIfMissing: true }); + expect(install).toHaveBeenCalledWith({ startNow: true, startOnLogin: true }); + }); +}); diff --git a/cli/src/commands/service.ts b/cli/src/commands/service.ts index 8e8a689dc6a..2f16dc88b63 100644 --- a/cli/src/commands/service.ts +++ b/cli/src/commands/service.ts @@ -5,6 +5,7 @@ import type { Command } from "commander"; import { readConfig, resolveConfigPath } from "../config/store.js"; import { resolvePaperclipInstanceId, resolvePaperclipInstanceRoot } from "../config/home.js"; import { detectServiceManager, type ServiceManager, type ServiceStatus } from "../services/service-manager.js"; +import { ensureServiceShim } from "../onboard-service.js"; import { buildLocalHealthUrl } from "../utils/health-url.js"; type CommonOptions = { instance?: string; json?: boolean }; @@ -172,6 +173,16 @@ export function registerServiceCommands(program: Command): void { .option("--enable-linger", "Allow systemd startup without an active login session", false) .action(async (opts) => { const manager = await resolveManager(opts); if (!manager) return; + const shim = await ensureServiceShim({ installIfMissing: !opts.json }); + if (!shim.ok) { + throw new Error( + `Background service not installed: ${shim.reason ?? "the managed install could not be completed"}. ` + + "Run `paperclipai install`, then `paperclipai service install`.", + ); + } + if (shim.installedNow) { + p.log.success("Installed the managed paperclipai payload and command shim for the service."); + } const result = await manager.install({ startNow: opts.startNow, startOnLogin: opts.startOnLogin }); let lingerEnabled = false; if (manager.enableLinger) { diff --git a/cli/src/onboard-service.ts b/cli/src/onboard-service.ts index 52a115953bb..b5b800926da 100644 --- a/cli/src/onboard-service.ts +++ b/cli/src/onboard-service.ts @@ -1,14 +1,16 @@ +import fs from "node:fs"; import path from "node:path"; import * as p from "@clack/prompts"; import pc from "picocolors"; import type { PaperclipConfig } from "./config/schema.js"; import { openUrl } from "./client/board-auth.js"; -import { installCommand } from "./commands/install.js"; +import { installCommand, runCommandWithDiagnostics, smokePayload } from "./commands/install.js"; import { resolvePaperclipInstanceId } from "./config/home.js"; import { readRuntimeInfo, type PaperclipRuntimeInfo } from "./runtime-info.js"; import { readInstallManifest, resolveInstallStorePaths, + writeManagedShim, type InstallManifest, } from "./install-store.js"; import { @@ -133,62 +135,75 @@ type OnboardServiceDependencies = { 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 --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 }; - } +// 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. +export async function ensureServiceShim(options: { installIfMissing?: boolean } = {}): Promise { + const shimPath = resolveServiceShimPath(); + if (await isExecutableFile(shimPath)) { + return { ok: true, installedNow: false }; + } + if (options.installIfMissing === false) { + return { ok: false, installedNow: false, reason: `no executable exists at ${shimPath}` }; + } + const storeShimPath = resolveInstallStorePaths().shimPath; + if (path.resolve(shimPath) !== path.resolve(storeShimPath)) { return { ok: false, installedNow: false, - reason: `the managed install completed but no executable shim appeared at ${shimPath}`, + 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 && + fs.existsSync(path.join(resolveInstallStorePaths().currentPath, "node_modules", "paperclipai", "dist", "index.js")) + ) { + // Only the shim is missing: smoke-test and restore it for the recorded payload, + // keeping its version and update channel instead of reinstalling. + await smokePayload(resolveInstallStorePaths().currentPath, manifest.version, runCommandWithDiagnostics); + writeManagedShim(); + } else 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 --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}`, + }; +} + +const defaultDependencies: OnboardServiceDependencies = { + detect: (instanceId) => detectServiceManager({ instanceId }), + ensureServiceShim: () => ensureServiceShim(), confirm: async () => { const answer = await p.confirm({ message: "Install Paperclip as a background service?",