From faad235aa2126fb815f17ee4ffd516f0a29d2e85 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Mon, 24 Aug 2026 23:30:54 -0700 Subject: [PATCH] fix(cli): materialize the managed install before the onboarding service install (#12148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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 --- cli/src/__tests__/onboard-service.test.ts | 62 +++++++++- .../__tests__/service-health-check.test.ts | 113 ++++++++++++++++++ cli/src/checks/service-health-check.ts | 58 ++++++--- cli/src/onboard-service.ts | 90 ++++++++++++++ cli/src/services/service-manager.ts | 57 +++++++++ 5 files changed, 363 insertions(+), 17 deletions(-) diff --git a/cli/src/__tests__/onboard-service.test.ts b/cli/src/__tests__/onboard-service.test.ts index c4a711f3f3..5b3f406433 100644 --- a/cli/src/__tests__/onboard-service.test.ts +++ b/cli/src/__tests__/onboard-service.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { handleOnboardService } from "../onboard-service.js"; +import { handleOnboardService, isInstallableReleaseVersion } from "../onboard-service.js"; function supportedDetection() { return { @@ -24,6 +24,7 @@ function supportedDetection() { pid: 123, })), logs: vi.fn(async () => undefined), + installedExecutablePath: vi.fn(async () => null), }, }; } @@ -48,7 +49,11 @@ describe("onboard service policy", () => { const installed = await handleOnboardService( { yes: true, installService: true }, - { detect: vi.fn(async () => detection), isInteractive: () => false }, + { + detect: vi.fn(async () => detection), + isInteractive: () => false, + ensureServiceShim: vi.fn(async () => ({ ok: true, installedNow: false })), + }, ); expect(installed).toBe(true); @@ -61,7 +66,12 @@ describe("onboard service policy", () => { const installed = await handleOnboardService( {}, - { detect: vi.fn(async () => detection), isInteractive: () => true, confirm }, + { + detect: vi.fn(async () => detection), + isInteractive: () => true, + confirm, + ensureServiceShim: vi.fn(async () => ({ ok: true, installedNow: false })), + }, ); expect(confirm).toHaveBeenCalledOnce(); @@ -81,4 +91,50 @@ describe("onboard service policy", () => { expect(detect).not.toHaveBeenCalled(); expect(info).not.toHaveBeenCalled(); }); + + it("materializes the managed shim before installing the service", async () => { + const detection = supportedDetection(); + const success = vi.fn(); + const ensureServiceShim = vi.fn(async () => ({ ok: true, installedNow: true })); + + const installed = await handleOnboardService( + { yes: true, installService: true }, + { detect: vi.fn(async () => detection), isInteractive: () => false, ensureServiceShim, success }, + ); + + expect(installed).toBe(true); + expect(ensureServiceShim).toHaveBeenCalledOnce(); + expect(success).toHaveBeenCalledWith(expect.stringContaining("managed paperclipai payload")); + expect(detection.manager.install).toHaveBeenCalledWith({ startNow: true, startOnLogin: true }); + }); + + it("declines instead of installing a service without a binary", async () => { + const detection = supportedDetection(); + const warn = vi.fn(); + + const installed = await handleOnboardService( + { yes: true, installService: true }, + { + detect: vi.fn(async () => detection), + isInteractive: () => false, + ensureServiceShim: vi.fn(async () => ({ ok: false, installedNow: false, reason: "npm exploded" })), + warn, + }, + ); + + expect(installed).toBe(false); + expect(detection.manager.install).not.toHaveBeenCalled(); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("npm exploded")); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("paperclipai install")); + }); + +}); + +describe("isInstallableReleaseVersion", () => { + it("accepts calendar releases and rejects placeholders", () => { + expect(isInstallableReleaseVersion("2026.824.1")).toBe(true); + expect(isInstallableReleaseVersion("2026.818.0-beta.1")).toBe(true); + expect(isInstallableReleaseVersion("0.3.1")).toBe(false); + expect(isInstallableReleaseVersion("not-a-version")).toBe(false); + }); }); diff --git a/cli/src/__tests__/service-health-check.test.ts b/cli/src/__tests__/service-health-check.test.ts index d913b2771d..cb4eb298e3 100644 --- a/cli/src/__tests__/service-health-check.test.ts +++ b/cli/src/__tests__/service-health-check.test.ts @@ -3,6 +3,13 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { serviceHealthChecks } from "../checks/service-health-check.js"; +import { + extractExecutableFromLaunchdPlist, + extractExecutableFromSystemdUnit, + isExecutableFile, + renderLaunchdPlist, + renderSystemdUnit, +} from "../services/service-manager.js"; import { resolveRestartExpectedVersion, withHotRestartLock } from "../commands/service.js"; import type { PaperclipConfig } from "../config/schema.js"; import { buildLocalHealthUrl } from "../utils/health-url.js"; @@ -52,6 +59,7 @@ function managerFixture(active = true) { linger: true, })), logs: vi.fn(async () => undefined), + installedExecutablePath: vi.fn(async () => null), }; } @@ -129,6 +137,7 @@ describe("service health doctor checks", () => { const results = await serviceHealthChecks(config, { detect: vi.fn(async () => ({ supported: true as const, manager })), probe: vi.fn(async () => ({ ok: true, version: "1.2.3" })), + shimPresent: vi.fn(async () => true), }); expect(results).toContainEqual( @@ -140,3 +149,107 @@ describe("service health doctor checks", () => { ); }); }); + +describe("isExecutableFile", () => { + it("accepts only executable regular files", async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "shim-check-")); + const executable = path.join(dir, "exec"); + const plain = path.join(dir, "plain"); + fs.writeFileSync(executable, "#!/bin/sh\n", { mode: 0o755 }); + fs.writeFileSync(plain, "data", { mode: 0o644 }); + + await expect(isExecutableFile(executable)).resolves.toBe(true); + await expect(isExecutableFile(plain)).resolves.toBe(false); + await expect(isExecutableFile(dir)).resolves.toBe(false); + await expect(isExecutableFile(path.join(dir, "missing"))).resolves.toBe(false); + }); +}); + +describe("service runtime shim awareness", () => { + function inactiveManager() { + return { + platform: "launchd" as const, + instanceId: "default", + serviceName: "ing.paperclip.paperclipai", + definitionPath: "/tmp/nonexistent-definition.plist", + renderDefinition: () => "plist", + install: vi.fn(async () => ({ changed: false })), + uninstall: vi.fn(async () => undefined), + start: vi.fn(async () => undefined), + stop: vi.fn(async () => undefined), + restart: vi.fn(async () => undefined), + status: vi.fn(async () => ({ + platform: "launchd" as const, + serviceName: "ing.paperclip.paperclipai", + installed: true, + active: false, + enabled: true, + pid: null, + detail: "loaded", + })), + logs: vi.fn(async () => undefined), + installedExecutablePath: vi.fn(async (): Promise => null), + }; + } + + it("blames the missing binary, not a port conflict, when the shim is gone", async () => { + const results = await serviceHealthChecks({} as never, { + detect: vi.fn(async () => ({ supported: true as const, manager: inactiveManager() as never })), + probe: vi.fn(async () => ({ ok: false, version: null, error: "fetch failed" })), + shimPresent: vi.fn(async () => false), + }); + const runtime = results.find((r) => r.name === "Service runtime"); + expect(runtime?.status).toBe("fail"); + expect(runtime?.message).toContain("no executable exists at"); + expect(runtime?.repairHint).toContain("paperclipai install"); + }); + + it("diagnoses against the executable recorded in the definition, not the current env", async () => { + const manager = inactiveManager(); + manager.installedExecutablePath = vi.fn(async () => "/custom/bin/paperclipai"); + const shimPresent = vi.fn(async () => false); + const results = await serviceHealthChecks({} as never, { + detect: vi.fn(async () => ({ supported: true as const, manager: manager as never })), + probe: vi.fn(async () => ({ ok: false, version: null, error: "fetch failed" })), + shimPresent, + }); + const runtime = results.find((r) => r.name === "Service runtime"); + expect(shimPresent).toHaveBeenCalledWith("/custom/bin/paperclipai"); + expect(runtime?.message).toContain("/custom/bin/paperclipai"); + expect(runtime?.repairHint).toContain("/custom/bin/paperclipai"); + expect(runtime?.repairHint).toContain("unset PAPERCLIP_SHIM_PATH"); + expect(runtime?.repairHint).toContain("`paperclipai install` followed by `paperclipai service install`"); + }); + + it("attributes a healthy foreign responder instead of reporting Healthy", async () => { + const results = await serviceHealthChecks({} as never, { + detect: vi.fn(async () => ({ supported: true as const, manager: inactiveManager() as never })), + probe: vi.fn(async () => ({ ok: true, version: "9.9.9" })), + shimPresent: vi.fn(async () => true), + }); + const healthResult = results.find((r) => r.name === "Service health"); + expect(healthResult?.status).toBe("warn"); + expect(healthResult?.message).toContain("but not from ing.paperclip.paperclipai"); + const runtime = results.find((r) => r.name === "Service runtime"); + expect(runtime?.message).toContain("serving another Paperclip process"); + }); +}); + +describe("definition executable extraction", () => { + it("round-trips through both renderers", () => { + const unit = renderSystemdUnit({ instanceId: "default", shimPath: "/custom/bin/paperclipai", homeDir: "/home/x/.paperclip" }); + expect(extractExecutableFromSystemdUnit(unit)).toBe("/custom/bin/paperclipai"); + const plist = renderLaunchdPlist({ instanceId: "default", shimPath: "/custom/bin/paperclipai", homeDir: "/home/x/.paperclip", stdoutPath: "/tmp/o.log", stderrPath: "/tmp/e.log" }); + expect(extractExecutableFromLaunchdPlist(plist)).toBe("/custom/bin/paperclipai"); + expect(extractExecutableFromSystemdUnit("garbage")).toBe(null); + expect(extractExecutableFromLaunchdPlist("garbage")).toBe(null); + }); + + it("round-trips paths the renderers escape", () => { + const hostile = '/tmp/we"ird $pa%th & /paperclipai'; + const unit = renderSystemdUnit({ instanceId: "default", shimPath: hostile, homeDir: "/home/x/.paperclip" }); + expect(extractExecutableFromSystemdUnit(unit)).toBe(hostile); + const plist = renderLaunchdPlist({ instanceId: "default", shimPath: hostile, homeDir: "/home/x/.paperclip", stdoutPath: "/tmp/o.log", stderrPath: "/tmp/e.log" }); + expect(extractExecutableFromLaunchdPlist(plist)).toBe(hostile); + }); +}); diff --git a/cli/src/checks/service-health-check.ts b/cli/src/checks/service-health-check.ts index 27f3a3a3ad..5a4aadd6db 100644 --- a/cli/src/checks/service-health-check.ts +++ b/cli/src/checks/service-health-check.ts @@ -1,9 +1,12 @@ import fs from "node:fs/promises"; +import path from "node:path"; import type { PaperclipConfig } from "../config/schema.js"; import { resolvePaperclipInstanceId } from "../config/home.js"; -import { readInstallManifest } from "../install-store.js"; +import { readInstallManifest, resolveInstallStorePaths } from "../install-store.js"; import { detectServiceManager, + isExecutableFile, + resolveServiceShimPath, type ServiceManagerDetection, } from "../services/service-manager.js"; import { buildLocalHealthUrl } from "../utils/health-url.js"; @@ -13,6 +16,7 @@ type HealthResult = { ok: boolean; version: string | null; error?: string }; type ServiceCheckDependencies = { detect: (instanceId: string) => Promise; probe: (config: PaperclipConfig) => Promise; + shimPresent: (executablePath: string) => Promise; }; async function probeHealth(config: PaperclipConfig): Promise { @@ -45,6 +49,7 @@ export async function serviceHealthChecks( const deps: ServiceCheckDependencies = { detect: (instanceId) => detectServiceManager({ instanceId }), probe: probeHealth, + shimPresent: (executablePath) => isExecutableFile(executablePath), ...dependencies, }; const instanceId = resolvePaperclipInstanceId(); @@ -84,17 +89,36 @@ export async function serviceHealthChecks( ); const health = await deps.probe(config); + // The installed definition is the truth about what the service executes; + // fall back to the environment-derived path only when it is unreadable. + const serviceExecutable = (await manager.installedExecutablePath()) ?? resolveServiceShimPath(); + const shimPresent = status.active ? true : await deps.shimPresent(serviceExecutable); results.push( status.active ? { name: "Service runtime", status: "pass", message: `${status.serviceName} is active` } - : { - name: "Service runtime", - status: "fail", - message: health.ok - ? `${status.serviceName} is inactive but the configured port is serving another Paperclip process` - : `${status.serviceName} is ${status.detail ?? "inactive"}`, - repairHint: "Run `paperclipai service start`, or stop the conflicting foreground process first", - }, + : !shimPresent + ? { + name: "Service runtime", + status: "fail", + message: `${status.serviceName} cannot start: no executable exists at ${serviceExecutable}`, + repairHint: + path.resolve(serviceExecutable) === path.resolve(resolveInstallStorePaths().shimPath) + ? "Run `paperclipai install` to restore the managed payload and shim, then `paperclipai service start`" + : `Restore the executable at ${serviceExecutable}, or unset PAPERCLIP_SHIM_PATH and run \`paperclipai install\` followed by \`paperclipai service install\` to re-point the service at the managed shim`, + } + : health.ok + ? { + name: "Service runtime", + status: "fail", + message: `${status.serviceName} is inactive but the configured port is serving another Paperclip process`, + repairHint: "Run `paperclipai service start`, or stop the conflicting foreground process first", + } + : { + name: "Service runtime", + status: "fail", + message: `${status.serviceName} is ${status.detail ?? "inactive"}`, + repairHint: "Run `paperclipai service start`; inspect `paperclipai service logs` if it does not stay up", + }, ); let expectedVersion: string | null = null; @@ -116,11 +140,17 @@ export async function serviceHealthChecks( message: `Running ${health.version ?? "unknown"}; managed install is ${expectedVersion}`, repairHint: "Run `paperclipai service restart --expected-version " + expectedVersion + "`", } - : { - name: "Service health", - status: "pass", - message: `Healthy${health.version ? ` at version ${health.version}` : ""}`, - }, + : status.active + ? { + name: "Service health", + status: "pass", + message: `Healthy${health.version ? ` at version ${health.version}` : ""}`, + } + : { + name: "Service health", + status: "warn", + message: `The configured port answers healthy${health.version ? ` (version ${health.version})` : ""}, but not from ${status.serviceName} — the service is inactive`, + }, ); if (status.enabled && status.linger === false) { diff --git a/cli/src/onboard-service.ts b/cli/src/onboard-service.ts index 50cc8e052f..268f121e51 100644 --- a/cli/src/onboard-service.ts +++ b/cli/src/onboard-service.ts @@ -1,18 +1,38 @@ +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; + ensureServiceShim: () => Promise; confirm: () => Promise; confirmLinger: () => Promise; isInteractive: () => boolean; @@ -23,6 +43,60 @@ type OnboardServiceDependencies = { 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 }; + } + 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?", @@ -68,6 +142,22 @@ export async function handleOnboardService( 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(); diff --git a/cli/src/services/service-manager.ts b/cli/src/services/service-manager.ts index f284afa59b..5267d5e145 100644 --- a/cli/src/services/service-manager.ts +++ b/cli/src/services/service-manager.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; import os from "node:os"; import path from "node:path"; import { execFile } from "node:child_process"; @@ -33,6 +34,7 @@ export interface ServiceManager { restart(): Promise; status(): Promise; logs(follow: boolean, lines: number): Promise; + installedExecutablePath(): Promise; enableLinger?(): Promise; } @@ -75,6 +77,45 @@ export function resolveServiceShimPath(homeDir = os.homedir()): string { return process.env.PAPERCLIP_SHIM_PATH?.trim() || path.join(homeDir, ".local", "bin", "paperclipai"); } +// The installed definition, not the current environment, is the truth +// about what the service executes: PAPERCLIP_SHIM_PATH may have changed +// or been unset since the definition was written. +function unescapeSystemd(value: string): string { + return value.replace(/\\\\|\\"|\$\$|%%/g, (m) => + m === "\\\\" ? "\\" : m === '\\"' ? '"' : m === "$$" ? "$" : "%", + ); +} + +function unescapeXml(value: string): string { + return value.replace(/&(amp|lt|gt|quot|apos);/g, (_, name: string) => + name === "amp" ? "&" : name === "lt" ? "<" : name === "gt" ? ">" : name === "quot" ? '"' : "'", + ); +} + +export function extractExecutableFromSystemdUnit(content: string): string | null { + const match = content.match(/^ExecStart="((?:\\.|[^"\\])*)"/m); + return match ? unescapeSystemd(match[1]) : null; +} + +export function extractExecutableFromLaunchdPlist(content: string): string | null { + const match = content.match(/ProgramArguments<\/key>\s*\s*([^<]+)<\/string>/); + return match ? unescapeXml(match[1]) : null; +} + +// The service definition executes this path directly: existence is not +// enough — a directory or a non-executable file would satisfy fs.access's +// default mode and still crash the supervisor at spawn. +export async function isExecutableFile(filePath: string): Promise { + try { + const stats = await fs.stat(filePath); + if (!stats.isFile()) return false; + await fs.access(filePath, fsConstants.X_OK); + return true; + } catch { + return false; + } +} + export function systemdServiceName(instanceId: string): string { return instanceId === "default" ? "paperclipai.service" : `paperclipai-${instanceId}.service`; } @@ -174,6 +215,14 @@ export class SystemdServiceManager implements ServiceManager { return renderSystemdUnit({ instanceId: this.instanceId, shimPath: this.shimPath, homeDir: this.homeDir }); } + async installedExecutablePath(): Promise { + try { + return extractExecutableFromSystemdUnit(await fs.readFile(this.definitionPath, "utf8")); + } catch { + return null; + } + } + private async ensureCurrent(): Promise { const changed = await writeIfChanged(this.definitionPath, this.renderDefinition()); if (changed) await this.runner("systemctl", ["--user", "daemon-reload"]); @@ -242,6 +291,14 @@ export class LaunchdServiceManager implements ServiceManager { renderDefinition(): string { return renderLaunchdPlist({ instanceId: this.instanceId, shimPath: this.shimPath, homeDir: this.homeDir, stdoutPath: this.stdoutPath, stderrPath: this.stderrPath }); } + async installedExecutablePath(): Promise { + try { + return extractExecutableFromLaunchdPlist(await fs.readFile(this.definitionPath, "utf8")); + } catch { + return null; + } + } + async install(options: ServiceInstallOptions): Promise<{ changed: boolean }> { await fs.mkdir(path.dirname(this.stdoutPath), { recursive: true }); const changed = await writeIfChanged(this.definitionPath, this.renderDefinition());