feat(cli): add managed service lifecycle

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-27 15:32:12 +00:00
1 parent 189da8bb95
commit b4b596503d
7 files changed
+587

No files matched your search

+125
View File
@@ -0,0 +1,125 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
assertForegroundRunAllowed,
detectServiceManager,
LaunchdServiceManager,
renderLaunchdPlist,
renderSystemdUnit,
SystemdServiceManager,
type CommandRunner,
type ServiceManager,
} from "../services/service-manager.js";
const temporaryDirectories: string[] = [];
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true })));
delete process.env.PAPERCLIP_SERVICE_MANAGED;
});
async function temporaryDirectory(): Promise<string> {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-service-test-"));
temporaryDirectories.push(directory);
return directory;
}
describe("service definition generation", () => {
it("generates a stable systemd notify unit without secrets", () => {
const unit = renderSystemdUnit({ instanceId: "team-a", shimPath: "/home/alice/.local/bin/paperclipai", homeDir: "/home/alice/.paperclip" });
expect(unit).toContain("Type=notify");
expect(unit).toContain("NotifyAccess=all");
expect(unit).toContain('ExecStart="/home/alice/.local/bin/paperclipai" run --instance "team-a"');
expect(unit).toContain("Restart=always");
expect(unit).toContain("TimeoutStopSec=300");
expect(unit).not.toContain("API_KEY");
});
it("generates a launchd agent with keepalive and instance logs", () => {
const plist = renderLaunchdPlist({ instanceId: "team-a", shimPath: "/Users/alice/.local/bin/paperclipai", homeDir: "/Users/alice/.paperclip", stdoutPath: "/Users/alice/.paperclip/instances/team-a/logs/service.log", stderrPath: "/Users/alice/.paperclip/instances/team-a/logs/service.err.log" });
expect(plist).toContain("ing.paperclip.paperclipai.team-a");
expect(plist).toContain("<key>RunAtLoad</key><true/>");
expect(plist).toContain("<key>KeepAlive</key><true/>");
expect(plist).toContain("service.err.log");
});
});
describe("systemd drift regeneration", () => {
it("rewrites a drifted unit and reloads the user manager", async () => {
const userHome = await temporaryDirectory();
const calls: string[] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args].join(" "));
return { stdout: "", stderr: "" };
};
const manager = new SystemdServiceManager("default", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
await fs.mkdir(path.dirname(manager.definitionPath), { recursive: true });
await fs.writeFile(manager.definitionPath, "stale\n", "utf8");
const result = await manager.install({ startNow: false, startOnLogin: false });
expect(result.changed).toBe(true);
expect(await fs.readFile(manager.definitionPath, "utf8")).toBe(manager.renderDefinition());
expect(calls).toContain("systemctl --user daemon-reload");
});
});
describe("service adapter dispatch", () => {
it("selects launchd on macOS", async () => {
const detection = await detectServiceManager({ platform: "darwin", instanceId: "default" });
expect(detection.supported).toBe(true);
if (detection.supported) expect(detection.manager).toBeInstanceOf(LaunchdServiceManager);
});
it("selects systemd only when the user manager is reachable", async () => {
const runner: CommandRunner = async () => ({ stdout: "", stderr: "" });
const detection = await detectServiceManager({ platform: "linux", instanceId: "default", runner });
expect(detection.supported).toBe(true);
if (detection.supported) expect(detection.manager).toBeInstanceOf(SystemdServiceManager);
});
it("returns a foreground-run skip on unsupported hosts", async () => {
const runner: CommandRunner = async () => { throw new Error("no bus"); };
const detection = await detectServiceManager({ platform: "linux", instanceId: "default", runner });
expect(detection).toEqual({ supported: false, reason: expect.stringContaining("paperclipai run") });
});
});
describe("launchd lifecycle", () => {
it("disables login startup and unloads the keepalive job when stopped", async () => {
const userHome = await temporaryDirectory();
const calls: string[] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args].join(" "));
return { stdout: "", stderr: "" };
};
const manager = new LaunchdServiceManager("team-a", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
await manager.install({ startNow: false, startOnLogin: false });
await manager.stop();
expect(calls).toContain(`launchctl disable gui/${process.getuid?.() ?? 0}/ing.paperclip.paperclipai.team-a`);
expect(calls).toContain(`launchctl bootout gui/${process.getuid?.() ?? 0}/ing.paperclip.paperclipai.team-a`);
expect(calls.some((call) => call.includes("launchctl kill"))).toBe(false);
});
});
describe("single-writer guard", () => {
const activeManager = { status: async () => ({ active: true, serviceName: "paperclipai.service" }) } as unknown as ServiceManager;
const detector = async () => ({ supported: true as const, manager: activeManager });
it("refuses a second foreground writer", async () => {
await expect(assertForegroundRunAllowed("default", false, detector)).rejects.toThrow("already running");
});
it("allows an explicit force override", async () => {
await expect(assertForegroundRunAllowed("default", true, detector)).resolves.toBeUndefined();
});
it("allows the supervisor-owned process", async () => {
process.env.PAPERCLIP_SERVICE_MANAGED = "1";
await expect(assertForegroundRunAllowed("default", false, detector)).resolves.toBeUndefined();
});
});
+3
View File
@@ -16,6 +16,7 @@ import {
resolvePaperclipHomeDir,
resolvePaperclipInstanceId,
} from "../config/home.js";
import { assertForegroundRunAllowed } from "../services/service-manager.js";
interface RunOptions {
config?: string;
@@ -23,6 +24,7 @@ interface RunOptions {
repair?: boolean;
yes?: boolean;
bind?: "loopback" | "lan" | "tailnet";
force?: boolean;
}
interface StartedServer {
@@ -35,6 +37,7 @@ interface StartedServer {
export async function runCommand(opts: RunOptions): Promise<void> {
const instanceId = resolvePaperclipInstanceId(opts.instance);
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
await assertForegroundRunAllowed(instanceId, opts.force);
const homeDir = resolvePaperclipHomeDir();
fs.mkdirSync(homeDir, { recursive: true });
+158
View File
@@ -0,0 +1,158 @@
import fs from "node:fs/promises";
import path from "node:path";
import * as p from "@clack/prompts";
import type { Command } from "commander";
import { cliVersion } from "../version.js";
import { readConfig, resolveConfigPath } from "../config/store.js";
import { resolvePaperclipHomeDir, resolvePaperclipInstanceId } from "../config/home.js";
import { detectServiceManager, type ServiceManager, type ServiceStatus } from "../services/service-manager.js";
type CommonOptions = { instance?: string; json?: boolean };
type HealthResult = { ok: boolean; serverVersion: string | null; error?: string };
function output(value: unknown, json: boolean | undefined): void {
if (json) console.log(JSON.stringify(value, null, 2));
else if (typeof value === "string") console.log(value);
else console.log(JSON.stringify(value, null, 2));
}
async function resolveManager(opts: CommonOptions): Promise<ServiceManager | null> {
const detection = await detectServiceManager({ instanceId: opts.instance });
if (detection.supported) return detection.manager;
output({ supported: false, message: detection.reason }, opts.json);
return null;
}
function healthUrl(instanceId: string): string {
process.env.PAPERCLIP_INSTANCE_ID = instanceId;
const config = readConfig(resolveConfigPath());
const port = config?.server.port ?? 3100;
const configuredHost = config?.server.host?.trim();
const host = !configuredHost || configuredHost === "0.0.0.0" || configuredHost === "::" ? "127.0.0.1" : configuredHost;
return `http://${host}:${port}/api/health`;
}
async function probeHealth(instanceId: string): Promise<HealthResult> {
try {
const response = await fetch(healthUrl(instanceId), { signal: AbortSignal.timeout(2_000) });
const body = await response.json() as { status?: unknown; serverVersion?: unknown; version?: unknown };
return { ok: response.ok && body.status === "ok", serverVersion: typeof body.serverVersion === "string" ? body.serverVersion : typeof body.version === "string" ? body.version : null };
} catch (error) {
return { ok: false, serverVersion: null, error: error instanceof Error ? error.message : String(error) };
}
}
async function waitForHealth(instanceId: string, expectedVersion: string | null, timeoutMs = 60_000): Promise<HealthResult> {
const deadline = Date.now() + timeoutMs;
let last: HealthResult = { ok: false, serverVersion: null };
while (Date.now() < deadline) {
last = await probeHealth(instanceId);
if (last.ok && (!expectedVersion || last.serverVersion === expectedVersion)) return last;
await new Promise((resolve) => setTimeout(resolve, 500));
}
throw new Error(`Paperclip service did not become healthy${expectedVersion ? ` at version ${expectedVersion}` : ""}: ${last.error ?? `reported ${last.serverVersion ?? "no version"}`}`);
}
async function writeHotRestartIntent(status: ServiceStatus, instanceId: string, drainRequired: boolean): Promise<{ requestedAt: string; previousVersion: string }> {
if (!status.pid) throw new Error(`Cannot restart ${status.serviceName}: supervisor did not report a server pid.`);
const health = await probeHealth(instanceId);
const homeDir = resolvePaperclipHomeDir();
const requestedAt = new Date().toISOString();
await fs.mkdir(homeDir, { recursive: true });
await fs.rm(path.join(homeDir, "hot-restart-report.json"), { force: true });
await fs.writeFile(path.join(homeDir, "hot-restart-intent.json"), `${JSON.stringify({
version: 1,
requestedAt,
previousServerPid: status.pid,
previousServerVersion: health.serverVersion,
drainRequired,
requestedByRunId: process.env.PAPERCLIP_RUN_ID?.trim() || null,
}, null, 2)}\n`, "utf8");
return { requestedAt, previousVersion: health.serverVersion ?? cliVersion };
}
async function waitForRestartReport(requestedAt: string, timeoutMs = 10_000): Promise<unknown | null> {
const reportPath = path.join(resolvePaperclipHomeDir(), "hot-restart-report.json");
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const report = JSON.parse(await fs.readFile(reportPath, "utf8")) as { requestedAt?: unknown };
if (report.requestedAt === requestedAt) return report;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
return null;
}
export async function restartManagedService(input: { instanceId?: string; expectedVersion?: string | null; waitForDrain?: boolean } = {}): Promise<{ status: ServiceStatus; health: HealthResult; report: unknown | null }> {
const instanceId = resolvePaperclipInstanceId(input.instanceId);
const detection = await detectServiceManager({ instanceId });
if (!detection.supported) throw new Error(detection.reason);
const before = await detection.manager.status();
const intent = await writeHotRestartIntent(before, instanceId, input.waitForDrain ?? false);
await detection.manager.restart();
const health = await waitForHealth(instanceId, input.expectedVersion ?? intent.previousVersion);
return { status: await detection.manager.status(), health, report: await waitForRestartReport(intent.requestedAt) };
}
export function registerServiceCommands(program: Command): void {
const service = program.command("service").description("Manage Paperclip as a background service");
const common = (command: Command) => command.option("-i, --instance <id>", "Local instance id (default: default)").option("--json", "Print machine-readable JSON", false);
common(service.command("install").description("Install and register the background service"))
.option("--no-start-now", "Install without starting now")
.option("--no-start-on-login", "Install without enabling start on login")
.option("--enable-linger", "Allow systemd startup without an active login session", false)
.action(async (opts) => {
const manager = await resolveManager(opts); if (!manager) return;
const result = await manager.install({ startNow: opts.startNow, startOnLogin: opts.startOnLogin });
let lingerEnabled = false;
if (manager.enableLinger) {
let consent = opts.enableLinger === true;
if (!consent && process.stdin.isTTY && process.stdout.isTTY) {
consent = await p.confirm({ message: "Allow Paperclip to run without an active login session? This runs 'loginctl enable-linger' for your user and may request system authorization.", initialValue: false }) === true;
}
if (consent) { await manager.enableLinger(); lingerEnabled = true; }
}
output({ installed: true, changed: result.changed, platform: manager.platform, serviceName: manager.serviceName, definitionPath: manager.definitionPath, lingerEnabled }, opts.json);
});
common(service.command("uninstall").description("Stop, disable, and remove the background service")).action(async (opts) => {
const manager = await resolveManager(opts); if (!manager) return;
await manager.uninstall();
const status = await manager.status();
if (status.installed || status.active) throw new Error(`${manager.serviceName} is still loaded after uninstall.`);
output({ uninstalled: true, serviceName: manager.serviceName }, opts.json);
});
for (const verb of ["start", "stop"] as const) {
common(service.command(verb).description(`${verb === "start" ? "Start" : "Stop"} the background service`)).action(async (opts) => {
const manager = await resolveManager(opts); if (!manager) return;
await manager[verb]();
output(await manager.status(), opts.json);
});
}
common(service.command("restart").description("Hot-restart the service while preserving active agent runs"))
.option("--wait", "Wait for active runs to drain instead of adopting them", false)
.option("--expected-version <version>", "Require the restarted server to report this version")
.action(async (opts) => output(await restartManagedService({ instanceId: opts.instance, expectedVersion: opts.expectedVersion, waitForDrain: opts.wait }), opts.json));
common(service.command("status").description("Show supervisor and health status")).action(async (opts) => {
const manager = await resolveManager(opts); if (!manager) return;
const instanceId = resolvePaperclipInstanceId(opts.instance);
output({ ...await manager.status(), health: await probeHealth(instanceId) }, opts.json);
});
common(service.command("logs").description("Show service logs"))
.option("-f, --follow", "Follow new log output", false)
.option("-n, --lines <count>", "Number of recent lines", "100")
.action(async (opts) => {
const manager = await resolveManager(opts); if (!manager) return;
const lines = Number.parseInt(opts.lines, 10);
if (!Number.isInteger(lines) || lines < 1) throw new Error("--lines must be a positive integer.");
await manager.logs(opts.follow, lines);
});
}
+3
View File
@@ -43,6 +43,7 @@ import { registerAdapterCommands } from "./commands/client/adapter.js";
import { registerAssetCommands } from "./commands/client/asset.js";
import { registerSkillCommands } from "./commands/client/skill.js";
import { cliVersion } from "./version.js";
import { registerServiceCommands } from "./commands/service.js";
const program = new Command();
const DATA_DIR_OPTION_HELP =
@@ -131,9 +132,11 @@ const run = program
.option("--bind <mode>", "On first run, use onboarding reachability preset (loopback, lan, tailnet)")
.option("--repair", "Attempt automatic repairs during doctor", true)
.option("--no-repair", "Disable automatic repairs during doctor")
.option("--force", "Run even when the same instance is active under the service manager")
.action(runCommand);
registerRunCommands(run);
registerServiceCommands(program);
const heartbeat = program.command("heartbeat").description("Heartbeat utilities");
+285
View File
@@ -0,0 +1,285 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { resolvePaperclipHomeDir, resolvePaperclipInstanceId } from "../config/home.js";
const execFileAsync = promisify(execFile);
export type ServicePlatform = "systemd" | "launchd";
export type ServiceStatus = {
platform: ServicePlatform;
serviceName: string;
installed: boolean;
active: boolean;
enabled: boolean;
pid: number | null;
detail?: string;
linger?: boolean | null;
};
export type ServiceInstallOptions = { startNow: boolean; startOnLogin: boolean };
export interface ServiceManager {
readonly platform: ServicePlatform;
readonly instanceId: string;
readonly serviceName: string;
readonly definitionPath: string;
renderDefinition(): string;
install(options: ServiceInstallOptions): Promise<{ changed: boolean }>;
uninstall(): Promise<void>;
start(): Promise<void>;
stop(): Promise<void>;
restart(): Promise<void>;
status(): Promise<ServiceStatus>;
logs(follow: boolean, lines: number): Promise<void>;
enableLinger?(): Promise<void>;
}
export type CommandResult = { stdout: string; stderr: string };
export type CommandRunner = (command: string, args: string[], options?: { inherit?: boolean }) => Promise<CommandResult>;
export const defaultCommandRunner: CommandRunner = async (command, args, options) => {
if (options?.inherit) {
await new Promise<void>((resolve, reject) => {
const child = execFile(command, args, { windowsHide: true }, (error) => error ? reject(error) : resolve());
child.stdout?.pipe(process.stdout);
child.stderr?.pipe(process.stderr);
});
return { stdout: "", stderr: "" };
}
const result = await execFileAsync(command, args, { encoding: "utf8", windowsHide: true });
return { stdout: result.stdout, stderr: result.stderr };
};
function escapeSystemd(value: string): string {
return value.replaceAll("\\", "\\\\").replaceAll('"', '\\"');
}
function escapeXml(value: string): string {
return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
}
export function resolveServiceShimPath(homeDir = os.homedir()): string {
return process.env.PAPERCLIP_SHIM_PATH?.trim() || path.join(homeDir, ".local", "bin", "paperclipai");
}
export function systemdServiceName(instanceId: string): string {
return instanceId === "default" ? "paperclipai.service" : `paperclipai-${instanceId}.service`;
}
export function launchdServiceName(instanceId: string): string {
return instanceId === "default" ? "ing.paperclip.paperclipai" : `ing.paperclip.paperclipai.${instanceId}`;
}
export function renderSystemdUnit(input: { instanceId: string; shimPath: string; homeDir: string }): string {
return `[Unit]
Description=Paperclip AI (${input.instanceId})
After=network.target
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=notify
NotifyAccess=all
ExecStart="${escapeSystemd(input.shimPath)}" run --instance "${escapeSystemd(input.instanceId)}"
Environment="PAPERCLIP_SERVICE_MANAGED=1"
Environment="PAPERCLIP_INSTANCE_ID=${escapeSystemd(input.instanceId)}"
Environment="PAPERCLIP_HOME=${escapeSystemd(input.homeDir)}"
WorkingDirectory=%h
Restart=always
RestartSec=5
TimeoutStopSec=300
[Install]
WantedBy=default.target
`;
}
export function renderLaunchdPlist(input: { instanceId: string; shimPath: string; homeDir: string; stdoutPath: string; stderrPath: string }): string {
const label = launchdServiceName(input.instanceId);
return `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>${escapeXml(label)}</string>
<key>ProgramArguments</key>
<array>
<string>${escapeXml(input.shimPath)}</string><string>run</string><string>--instance</string><string>${escapeXml(input.instanceId)}</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>PAPERCLIP_SERVICE_MANAGED</key><string>1</string>
<key>PAPERCLIP_INSTANCE_ID</key><string>${escapeXml(input.instanceId)}</string>
<key>PAPERCLIP_HOME</key><string>${escapeXml(input.homeDir)}</string>
</dict>
<key>RunAtLoad</key><true/>
<key>KeepAlive</key><true/>
<key>ThrottleInterval</key><integer>5</integer>
<key>ExitTimeOut</key><integer>300</integer>
<key>StandardOutPath</key><string>${escapeXml(input.stdoutPath)}</string>
<key>StandardErrorPath</key><string>${escapeXml(input.stderrPath)}</string>
</dict>
</plist>
`;
}
async function writeIfChanged(filePath: string, contents: string): Promise<boolean> {
try {
if (await fs.readFile(filePath, "utf8") === contents) return false;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, contents, { encoding: "utf8", mode: 0o644 });
return true;
}
export class SystemdServiceManager implements ServiceManager {
readonly platform = "systemd" as const;
readonly serviceName: string;
readonly definitionPath: string;
constructor(readonly instanceId: string, private readonly runner: CommandRunner = defaultCommandRunner, private readonly homeDir = resolvePaperclipHomeDir(), private readonly shimPath = resolveServiceShimPath(), userHomeDir = os.homedir()) {
this.serviceName = systemdServiceName(instanceId);
this.definitionPath = path.join(userHomeDir, ".config", "systemd", "user", this.serviceName);
}
renderDefinition(): string {
return renderSystemdUnit({ instanceId: this.instanceId, shimPath: this.shimPath, homeDir: this.homeDir });
}
private async ensureCurrent(): Promise<boolean> {
const changed = await writeIfChanged(this.definitionPath, this.renderDefinition());
if (changed) await this.runner("systemctl", ["--user", "daemon-reload"]);
return changed;
}
async install(options: ServiceInstallOptions): Promise<{ changed: boolean }> {
const changed = await this.ensureCurrent();
if (options.startOnLogin) await this.runner("systemctl", ["--user", "enable", this.serviceName]);
else await this.runner("systemctl", ["--user", "disable", this.serviceName]).catch(() => undefined);
if (options.startNow) await this.start();
return { changed };
}
async uninstall(): Promise<void> {
await this.stop().catch(() => undefined);
await this.runner("systemctl", ["--user", "disable", this.serviceName]).catch(() => undefined);
await fs.rm(this.definitionPath, { force: true });
await this.runner("systemctl", ["--user", "daemon-reload"]);
await this.runner("systemctl", ["--user", "reset-failed", this.serviceName]).catch(() => undefined);
}
async start(): Promise<void> { await this.ensureCurrent(); await this.runner("systemctl", ["--user", "start", this.serviceName]); }
async stop(): Promise<void> { await this.runner("systemctl", ["--user", "stop", this.serviceName]); }
async restart(): Promise<void> { await this.ensureCurrent(); await this.runner("systemctl", ["--user", "restart", this.serviceName]); }
async status(): Promise<ServiceStatus> {
let output: string;
try {
output = (await this.runner("systemctl", ["--user", "show", this.serviceName, "--property=LoadState,ActiveState,UnitFileState,MainPID"])).stdout;
} catch {
return { platform: this.platform, serviceName: this.serviceName, installed: false, active: false, enabled: false, pid: null, linger: await this.lingerStatus() };
}
const values = Object.fromEntries(output.trim().split(/\r?\n/).map((line) => line.split(/=(.*)/s).slice(0, 2)));
const pid = Number(values.MainPID);
return { platform: this.platform, serviceName: this.serviceName, installed: values.LoadState === "loaded", active: values.ActiveState === "active", enabled: values.UnitFileState === "enabled", pid: Number.isInteger(pid) && pid > 0 ? pid : null, detail: values.ActiveState, linger: await this.lingerStatus() };
}
private async lingerStatus(): Promise<boolean | null> {
try {
const result = await this.runner("loginctl", ["show-user", String(process.getuid?.() ?? os.userInfo().username), "--property=Linger", "--value"]);
return result.stdout.trim() === "yes";
} catch { return null; }
}
async enableLinger(): Promise<void> { await this.runner("loginctl", ["enable-linger", os.userInfo().username]); }
async logs(follow: boolean, lines: number): Promise<void> { await this.runner("journalctl", ["--user", "--unit", this.serviceName, "--lines", String(lines), ...(follow ? ["--follow"] : [])], { inherit: true }); }
}
export class LaunchdServiceManager implements ServiceManager {
readonly platform = "launchd" as const;
readonly serviceName: string;
readonly definitionPath: string;
private readonly domain = `gui/${process.getuid?.() ?? 0}`;
private readonly stdoutPath: string;
private readonly stderrPath: string;
constructor(readonly instanceId: string, private readonly runner: CommandRunner = defaultCommandRunner, private readonly homeDir = resolvePaperclipHomeDir(), private readonly shimPath = resolveServiceShimPath(), userHomeDir = os.homedir()) {
this.serviceName = launchdServiceName(instanceId);
this.definitionPath = path.join(userHomeDir, "Library", "LaunchAgents", `${this.serviceName}.plist`);
const logDir = path.join(homeDir, "instances", instanceId, "logs");
this.stdoutPath = path.join(logDir, "service.log");
this.stderrPath = path.join(logDir, "service.err.log");
}
renderDefinition(): string { return renderLaunchdPlist({ instanceId: this.instanceId, shimPath: this.shimPath, homeDir: this.homeDir, stdoutPath: this.stdoutPath, stderrPath: this.stderrPath }); }
async install(options: ServiceInstallOptions): Promise<{ changed: boolean }> {
await fs.mkdir(path.dirname(this.stdoutPath), { recursive: true });
const changed = await writeIfChanged(this.definitionPath, this.renderDefinition());
if (changed) await this.runner("launchctl", ["bootout", `${this.domain}/${this.serviceName}`]).catch(() => undefined);
await this.runner("launchctl", [options.startOnLogin ? "enable" : "disable", `${this.domain}/${this.serviceName}`]);
if (options.startOnLogin || options.startNow) {
await this.runner("launchctl", ["bootstrap", this.domain, this.definitionPath]).catch(async () => this.runner("launchctl", ["kickstart", "-k", `${this.domain}/${this.serviceName}`]));
}
if (!options.startNow) await this.stop().catch(() => undefined);
return { changed };
}
async uninstall(): Promise<void> {
await this.runner("launchctl", ["bootout", `${this.domain}/${this.serviceName}`]).catch(() => undefined);
await this.runner("launchctl", ["enable", `${this.domain}/${this.serviceName}`]).catch(() => undefined);
await fs.rm(this.definitionPath, { force: true });
}
async start(): Promise<void> { await this.install({ startNow: true, startOnLogin: true }); }
async stop(): Promise<void> { await this.runner("launchctl", ["bootout", `${this.domain}/${this.serviceName}`]); }
async restart(): Promise<void> { await writeIfChanged(this.definitionPath, this.renderDefinition()); await this.runner("launchctl", ["kickstart", "-k", `${this.domain}/${this.serviceName}`]); }
async status(): Promise<ServiceStatus> {
try {
const result = await this.runner("launchctl", ["print", `${this.domain}/${this.serviceName}`]);
const pidMatch = result.stdout.match(/\bpid\s*=\s*(\d+)/);
const pid = pidMatch ? Number(pidMatch[1]) : null;
return { platform: this.platform, serviceName: this.serviceName, installed: true, active: Boolean(pid), enabled: await this.isEnabled(), pid, detail: pid ? "running" : "loaded" };
} catch {
let installed = true;
try { await fs.access(this.definitionPath); } catch { installed = false; }
return { platform: this.platform, serviceName: this.serviceName, installed, active: false, enabled: installed && await this.isEnabled(), pid: null };
}
}
private async isEnabled(): Promise<boolean> {
try {
const result = await this.runner("launchctl", ["print-disabled", this.domain]);
return !new RegExp(`"${this.serviceName.replaceAll(".", "\\.")}"\\s*=>\\s*true`).test(result.stdout);
} catch { return true; }
}
async logs(follow: boolean, lines: number): Promise<void> { await this.runner("tail", ["-n", String(lines), ...(follow ? ["-F"] : []), this.stdoutPath, this.stderrPath], { inherit: true }); }
}
export type ServiceManagerDetection = { supported: true; manager: ServiceManager } | { supported: false; reason: string };
export async function detectServiceManager(input: { instanceId?: string; platform?: NodeJS.Platform; runner?: CommandRunner } = {}): Promise<ServiceManagerDetection> {
const instanceId = resolvePaperclipInstanceId(input.instanceId);
const platform = input.platform ?? process.platform;
const runner = input.runner ?? defaultCommandRunner;
if (platform === "darwin") return { supported: true, manager: new LaunchdServiceManager(instanceId, runner) };
if (platform !== "linux") return { supported: false, reason: `Service management is not supported on ${platform}. Use paperclipai run instead.` };
try {
await runner("systemctl", ["--user", "show-environment"]);
return { supported: true, manager: new SystemdServiceManager(instanceId, runner) };
} catch {
return { supported: false, reason: "No usable systemd user manager was detected (common in containers and WSL1). Use paperclipai run instead." };
}
}
export async function assertForegroundRunAllowed(instanceId: string, force = false, detector: typeof detectServiceManager = detectServiceManager): Promise<void> {
if (force || process.env.PAPERCLIP_SERVICE_MANAGED === "1") return;
const detection = await detector({ instanceId });
if (!detection.supported) return;
const status = await detection.manager.status();
if (status.active) throw new Error(`Paperclip instance '${instanceId}' is already running as ${status.serviceName}. Use 'paperclipai service status --instance ${instanceId}' or pass --force to bypass this safety check.`);
}
+5
View File
@@ -72,6 +72,7 @@ import { maybePersistWorktreeRuntimePorts } from "./worktree-config.js";
import { initTelemetry, getTelemetryClient } from "./telemetry.js";
import { conflict } from "./errors.js";
import { coordinateHeartbeatSchedulerShutdown } from "./shutdown.js";
import { systemdNotify } from "./services/systemd-notify.js";
import type {
InstanceDatabaseBackupRunResult,
InstanceDatabaseBackupTrigger,
@@ -1215,6 +1216,9 @@ export async function startServer(): Promise<StartedServer> {
server.listen(listenPort, config.host, () => {
server.off("error", onError);
logger.info(`Server listening on ${config.host}:${listenPort}`);
void systemdNotify(["--ready", `--status=Listening on ${config.host}:${listenPort}`]).then((notified) => {
if (notified) logger.info("Notified systemd that Paperclip is ready");
});
if (process.env.PAPERCLIP_OPEN_ON_LISTEN === "true") {
const openHost = config.host === "0.0.0.0" || config.host === "::" ? "127.0.0.1" : config.host;
const url = `http://${openHost}:${listenPort}`;
@@ -1268,6 +1272,7 @@ export async function startServer(): Promise<StartedServer> {
{
const shutdown = async (signal: "SIGINT" | "SIGTERM") => {
await systemdNotify(["--stopping", `--status=Stopping after ${signal}`]);
heartbeatSchedulerStopped = true;
if (heartbeatSchedulerInterval) {
clearInterval(heartbeatSchedulerInterval);
+8
View File
@@ -0,0 +1,8 @@
import { execFile } from "node:child_process";
export async function systemdNotify(args: string[]): Promise<boolean> {
if (!process.env.NOTIFY_SOCKET?.trim()) return false;
return await new Promise<boolean>((resolve) => {
execFile("systemd-notify", args, { windowsHide: true }, (error) => resolve(!error));
});
}