fix(cli): harden managed lifecycle coordination

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-07-22 19:54:40 +00:00
1 parent b45f2c0b9e
commit 3e9214cd6d
8 files changed
+143 -22

No files matched your search

+19
View File
@@ -164,6 +164,25 @@ describe("managed install commands", () => {
expect(fs.readFileSync(userData, "utf8")).toBe("keep");
});
it("refuses to remove the shared CLI while another instance service is installed", async () => {
const paths = resolveInstallStorePaths();
const otherUnitPath = path.join(process.env.HOME!, ".config", "systemd", "user", systemdServiceName("team-a"));
fs.mkdirSync(path.dirname(otherUnitPath), { recursive: true });
fs.writeFileSync(otherUnitPath, "unit");
await expect(uninstallCommand({
detectServiceManager: vi.fn(async () => ({
supported: true as const,
manager: { status: vi.fn(async () => ({ installed: false, active: false })) } as never,
})),
platform: "linux",
userHomeDir: process.env.HOME!,
})).rejects.toThrow("other instance services are installed");
expect(fs.existsSync(paths.cliRoot)).toBe(false);
expect(fs.existsSync(otherUnitPath)).toBe(true);
});
it("preserves the managed install when an existing systemd unit cannot be checked", async () => {
const paths = resolveInstallStorePaths();
fs.mkdirSync(path.dirname(paths.shimPath), { recursive: true });
+31
View File
@@ -75,6 +75,21 @@ describe("systemd drift regeneration", () => {
expect(await fs.readFile(manager.definitionPath, "utf8")).toBe(manager.renderDefinition());
expect(calls).toContain("systemctl --user daemon-reload");
});
it("keeps the unit installed when stopping an active service fails", async () => {
const userHome = await temporaryDirectory();
const runner: CommandRunner = async (command, args) => {
if (args.includes("--property=LoadState,ActiveState,UnitFileState,MainPID")) return { stdout: "LoadState=loaded\nActiveState=active\nUnitFileState=enabled\nMainPID=42\n", stderr: "" };
if (command === "systemctl" && args.includes("stop")) throw new Error("stop failed");
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, manager.renderDefinition(), "utf8");
await expect(manager.uninstall()).rejects.toThrow("stop failed");
await expect(fs.access(manager.definitionPath)).resolves.toBeUndefined();
});
});
describe("service adapter dispatch", () => {
@@ -99,6 +114,22 @@ describe("service adapter dispatch", () => {
});
describe("launchd lifecycle", () => {
it("starts without changing the saved login preference", async () => {
const userHome = await temporaryDirectory();
const calls: string[] = [];
const runner: CommandRunner = async (command, args) => {
calls.push([command, ...args].join(" "));
if (args[0] === "print-disabled") return { stdout: `\"ing.paperclip.paperclipai.team-a\" => true`, stderr: "" };
return { stdout: "", stderr: "" };
};
const manager = new LaunchdServiceManager("team-a", runner, path.join(userHome, ".paperclip"), path.join(userHome, ".local/bin/paperclipai"), userHome);
await manager.start();
expect(calls).toContain(`launchctl disable gui/${process.getuid?.() ?? 0}/ing.paperclip.paperclipai.team-a`);
expect(calls).not.toContain(`launchctl enable gui/${process.getuid?.() ?? 0}/ing.paperclip.paperclipai.team-a`);
});
it("disables login startup and unloads the keepalive job when stopped", async () => {
const userHome = await temporaryDirectory();
const calls: string[] = [];
+21 -3
View File
@@ -4,7 +4,7 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { flipCurrentAtomic, initializeInstallStore, payloadPathFor, readInstallManifest, resolveInstallStorePaths, writeInstallManifestAtomic, type InstallManifest, type InstallRecord } from "../install-store.js";
import type { CommandRunner } from "../commands/install.js";
import { detectInstallMode, resolveUpdateRequest, rollbackManagedInstall, updateCommand } from "../commands/update.js";
import { compareVersions, detectInstallMode, resolveUpdateRequest, rollbackManagedInstall, updateCommand } from "../commands/update.js";
let root: string;
let previousHome: string | undefined;
@@ -36,6 +36,12 @@ afterEach(() => {
});
describe("update command", () => {
it("orders SemVer prerelease identifiers numerically", () => {
expect(compareVersions("1.0.0-canary.10", "1.0.0-canary.2")).toBeGreaterThan(0);
expect(compareVersions("1.0.0-1", "1.0.0-alpha")).toBeLessThan(0);
expect(compareVersions("1.0.0-alpha", "1.0.0-alpha.1")).toBeLessThan(0);
});
it("detects managed, global npm, npx, and source modes", () => {
const paths = resolveInstallStorePaths(); initializeInstallStore(paths);
const payload = payloadPathFor(paths, "npm", "1.0.0"); const entrypoint = createPayload(payload, "1.0.0");
@@ -67,9 +73,11 @@ describe("update command", () => {
flipCurrentAtomic(oldPayload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, source: "git", version: "0.3.1", channel: "pinned", repo: "paperclipai/paperclip", ref: "master", sha: oldSha, payloadPath: oldPayload, installedAt: "2026-07-22T00:00:00.000Z", previous: [] }, paths);
const backup = vi.fn(async () => undefined);
const restartActiveService = vi.fn(async () => true);
const runCommand = vi.fn(async (file: string) => file === "curl" ? { stdout: JSON.stringify({ sha: newSha }), stderr: "" } : { stdout: "0.3.1\n", stderr: "" });
await updateCommand({}, { paths, executablePath: executable, runCommand, backup, now: () => new Date("2026-07-22T12:00:00Z") });
await updateCommand({}, { paths, executablePath: executable, runCommand, backup, restartActiveService, now: () => new Date("2026-07-22T12:00:00Z") });
expect(backup).toHaveBeenCalledOnce();
expect(restartActiveService).toHaveBeenCalledWith("0.3.1");
expect(readInstallManifest(paths)?.sha).toBe(newSha);
expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(newPayload));
});
@@ -92,6 +100,14 @@ describe("update command", () => {
await expect(updateCommand({ version: "1.0.0", dryRun: true }, { paths, executablePath: entrypoint, runCommand, confirm: async () => false })).rejects.toThrow("Downgrade cancelled");
});
it("requires explicit confirmation before a global npm downgrade", async () => {
const paths = resolveInstallStorePaths();
const executable = path.join(root, "lib", "node_modules", "paperclipai", "dist", "index.js");
const runCommand = vi.fn(async () => ({ stdout: '"0.2.0"\n', stderr: "" }));
await expect(updateCommand({ version: "0.2.0" }, { paths, executablePath: executable, runCommand, confirm: async () => false })).rejects.toThrow("Downgrade cancelled");
expect(runCommand).toHaveBeenCalledTimes(1);
});
it("isolates global npm updates from hostile registry configuration", async () => {
const paths = resolveInstallStorePaths();
const executable = path.join(root, "lib", "node_modules", "paperclipai", "dist", "index.js");
@@ -117,13 +133,15 @@ describe("update command", () => {
const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); const executable = createPayload(oldPayload, "1.0.0"); flipCurrentAtomic(oldPayload, paths);
writeInstallManifestAtomic({ schemaVersion: 1, ...record(oldPayload, "1.0.0"), previous: [] }, paths);
const backup = vi.fn(async () => undefined);
const restartActiveService = vi.fn(async () => true);
const runCommand = vi.fn(async (file: string, args: string[]) => {
if (args[0] === "view") return { stdout: '"2.0.0"\n', stderr: "" };
if (file === "npm" && args[0] === "install") { const prefix = args[args.indexOf("--prefix") + 1]; createPayload(prefix, "2.0.0"); return { stdout: "", stderr: "" }; }
return { stdout: "2.0.0\n", stderr: "" };
});
await updateCommand({}, { paths, executablePath: executable, runCommand, backup, now: () => new Date("2026-07-22T12:00:00Z") });
await updateCommand({}, { paths, executablePath: executable, runCommand, backup, restartActiveService, now: () => new Date("2026-07-22T12:00:00Z") });
expect(backup).toHaveBeenCalledOnce();
expect(restartActiveService).toHaveBeenCalledWith("2.0.0");
expect(readInstallManifest(paths)?.version).toBe("2.0.0");
expect(fs.realpathSync(paths.currentPath)).toBe(fs.realpathSync(payloadPathFor(paths, "npm", "2.0.0")));
const rolledBack = rollbackManagedInstall(paths);
+8 -8
View File
@@ -3,7 +3,7 @@ import path from "node:path";
import * as p from "@clack/prompts";
import type { Command } from "commander";
import { readConfig, resolveConfigPath } from "../config/store.js";
import { resolvePaperclipHomeDir, resolvePaperclipInstanceId } from "../config/home.js";
import { resolvePaperclipInstanceId, resolvePaperclipInstanceRoot } from "../config/home.js";
import { detectServiceManager, type ServiceManager, type ServiceStatus } from "../services/service-manager.js";
import { buildLocalHealthUrl } from "../utils/health-url.js";
@@ -57,11 +57,11 @@ export function resolveRestartExpectedVersion(expectedVersion: string | null | u
async function writeHotRestartIntent(status: ServiceStatus, instanceId: string, drainRequired: boolean): Promise<{ requestedAt: 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 instanceRoot = resolvePaperclipInstanceRoot(instanceId);
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({
await fs.mkdir(instanceRoot, { recursive: true });
await fs.rm(path.join(instanceRoot, "hot-restart-report.json"), { force: true });
await fs.writeFile(path.join(instanceRoot, "hot-restart-intent.json"), `${JSON.stringify({
version: 1,
requestedAt,
previousServerPid: status.pid,
@@ -72,8 +72,8 @@ async function writeHotRestartIntent(status: ServiceStatus, instanceId: string,
return { requestedAt };
}
async function waitForRestartReport(requestedAt: string, timeoutMs = 10_000): Promise<unknown | null> {
const reportPath = path.join(resolvePaperclipHomeDir(), "hot-restart-report.json");
async function waitForRestartReport(instanceId: string, requestedAt: string, timeoutMs = 10_000): Promise<unknown | null> {
const reportPath = path.join(resolvePaperclipInstanceRoot(instanceId), "hot-restart-report.json");
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
@@ -95,7 +95,7 @@ export async function restartManagedService(input: { instanceId?: string; expect
const intent = await writeHotRestartIntent(before, instanceId, input.waitForDrain ?? false);
await detection.manager.restart();
const health = await waitForHealth(instanceId, resolveRestartExpectedVersion(input.expectedVersion));
return { status: await detection.manager.status(), health, report: await waitForRestartReport(intent.requestedAt) };
return { status: await detection.manager.status(), health, report: await waitForRestartReport(instanceId, intent.requestedAt) };
}
export function registerServiceCommands(program: Command): void {
+23 -1
View File
@@ -10,7 +10,7 @@ import {
withInstallStoreLock,
} from "../install-store.js";
import { resolvePaperclipInstanceId } from "../config/home.js";
import { detectServiceManager, systemdServiceName } from "../services/service-manager.js";
import { detectServiceManager, launchdServiceName, systemdServiceName } from "../services/service-manager.js";
type UninstallDependencies = {
detectServiceManager: typeof detectServiceManager;
@@ -18,6 +18,24 @@ type UninstallDependencies = {
userHomeDir: string;
};
function otherServiceDefinitions(platform: NodeJS.Platform, userHomeDir: string, instanceId: string): string[] {
const directory = platform === "linux"
? path.join(userHomeDir, ".config", "systemd", "user")
: platform === "darwin"
? path.join(userHomeDir, "Library", "LaunchAgents")
: null;
if (!directory || !fs.existsSync(directory)) return [];
const currentName = platform === "linux"
? systemdServiceName(instanceId)
: `${launchdServiceName(instanceId)}.plist`;
const pattern = platform === "linux"
? /^paperclipai(?:-.+)?\.service$/
: /^ing\.paperclip\.paperclipai(?:\..+)?\.plist$/;
return fs.readdirSync(directory)
.filter((name) => name !== currentName && pattern.test(name))
.map((name) => path.join(directory, name));
}
export async function uninstallCommand(
dependencies: Partial<UninstallDependencies> = {},
): Promise<void> {
@@ -26,6 +44,10 @@ export async function uninstallCommand(
const platform = dependencies.platform ?? process.platform;
const userHomeDir = dependencies.userHomeDir ?? os.homedir();
const detection = await detect({ instanceId, platform });
const otherDefinitions = otherServiceDefinitions(platform, userHomeDir, instanceId);
if (otherDefinitions.length > 0) {
throw new Error(`Cannot remove the shared managed CLI while other instance services are installed: ${otherDefinitions.join(", ")}. Uninstall those services first.`);
}
if (!detection.supported && platform === "linux") {
const definitionPath = path.join(
userHomeDir,
+36 -6
View File
@@ -8,11 +8,23 @@ import pc from "picocolors";
import { buildNextManifest, flipCurrentAtomic, isManagedExecutable, pruneInstallPayloads, readInstallManifest, resolveInstallStorePaths, withInstallStoreLock, writeInstallManifestAtomic, type InstallChannel, type InstallManifest, type InstallRecord, type InstallStorePaths } from "../install-store.js";
import { dbBackupCommand } from "./db-backup.js";
import { installGitPayload, installNpmPayload, PUBLIC_NPM_REGISTRY, resolveGitHubRef, resolvePublishedVersion, type CommandRunner } from "./install.js";
import { resolvePaperclipInstanceId } from "../config/home.js";
import { detectServiceManager } from "../services/service-manager.js";
import { restartManagedService } from "./service.js";
import { packageVersion } from "../version.js";
const execFileAsync = promisify(execFile);
export type InstallMode = "managed" | "global-npm" | "npx" | "source" | "unknown";
export type UpdateOptions = { canary?: boolean; latest?: boolean; version?: string; rollback?: boolean; check?: boolean; dryRun?: boolean; json?: boolean; yes?: boolean; backup?: boolean };
type Dependencies = { executablePath: string; runCommand: CommandRunner; backup: () => Promise<void>; confirm: (message: string) => Promise<boolean>; now: () => Date; paths: InstallStorePaths };
type Dependencies = { executablePath: string; runCommand: CommandRunner; backup: () => Promise<void>; confirm: (message: string) => Promise<boolean>; now: () => Date; paths: InstallStorePaths; restartActiveService: (expectedVersion: string) => Promise<boolean> };
async function restartActiveManagedService(expectedVersion: string): Promise<boolean> {
const instanceId = resolvePaperclipInstanceId();
const detection = await detectServiceManager({ instanceId });
if (!detection.supported || !(await detection.manager.status()).active) return false;
await restartManagedService({ instanceId, expectedVersion });
return true;
}
export function detectInstallMode(executablePath = process.argv[1] ?? "", paths = resolveInstallStorePaths()): InstallMode {
const resolved = path.resolve(executablePath || ".");
@@ -36,7 +48,21 @@ export function compareVersions(left: string, right: string): number {
if (a.prerelease === b.prerelease) return 0;
if (!a.prerelease) return 1;
if (!b.prerelease) return -1;
return a.prerelease.localeCompare(b.prerelease);
const aParts = a.prerelease.split(".");
const bParts = b.prerelease.split(".");
for (let index = 0; index < Math.max(aParts.length, bParts.length); index += 1) {
const leftPart = aParts[index];
const rightPart = bParts[index];
if (leftPart === undefined) return -1;
if (rightPart === undefined) return 1;
if (leftPart === rightPart) continue;
const leftNumeric = /^\d+$/.test(leftPart);
const rightNumeric = /^\d+$/.test(rightPart);
if (leftNumeric && rightNumeric) return Math.sign(Number(leftPart) - Number(rightPart));
if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1;
return leftPart < rightPart ? -1 : 1;
}
return 0;
}
export function resolveUpdateRequest(manifest: InstallManifest | null, options: Pick<UpdateOptions, "canary" | "latest" | "version">): { spec: string; channel: InstallChannel; explicit: boolean } {
@@ -81,7 +107,8 @@ export async function updateCommand(options: UpdateOptions, overrides: Partial<D
if (mode !== "managed") throw new Error("--rollback is only available for managed installs.");
if (options.dryRun) { emit(options, { mode, action: "rollback", dryRun: true, target: manifest?.previous[0]?.version ?? null }, `Would roll back to ${manifest?.previous[0]?.version ?? "the previous payload"}.`); return; }
const next = await withInstallStoreLock(async () => rollbackManagedInstall(paths), paths);
emit(options, { mode, action: "rollback", version: next.version }, pc.green(`Rolled back instantly to paperclipai ${next.version}. Database migrations are not reversed; restore the pre-update backup if needed.`));
const restarted = await (overrides.restartActiveService ?? restartActiveManagedService)(next.version);
emit(options, { mode, action: "rollback", version: next.version, restarted }, pc.green(`Rolled back to paperclipai ${next.version}${restarted ? " and restarted the active service" : ""}. Database migrations are not reversed; restore the pre-update backup if needed.`));
return;
}
const request = resolveUpdateRequest(manifest, options);
@@ -101,14 +128,16 @@ export async function updateCommand(options: UpdateOptions, overrides: Partial<D
try { writeInstallManifestAtomic(next, paths); } catch (error) { flipCurrentAtomic(path.resolve(paths.cliRoot, oldTarget), paths); throw error; }
pruneInstallPayloads(next, paths); return payload;
}, paths);
emit(options, { mode, source: "git", changed: true, currentSha: manifest.sha, targetSha, reused: installed.reused }, pc.yellow(`Updated unreleased git payload ${manifest.sha.slice(0, 12)} → ${targetSha.slice(0, 12)} from ${manifest.repo}@${manifest.ref}.`));
const restarted = await (overrides.restartActiveService ?? restartActiveManagedService)(installed.version);
emit(options, { mode, source: "git", changed: true, currentSha: manifest.sha, targetSha, reused: installed.reused, restarted }, pc.yellow(`Updated unreleased git payload ${manifest.sha.slice(0, 12)} → ${targetSha.slice(0, 12)} from ${manifest.repo}@${manifest.ref}${restarted ? " and restarted the active service" : ""}.`));
return;
}
const targetVersion = await resolvePublishedVersion(request.spec, runCommand);
const currentVersion = manifest?.version;
const currentVersion = manifest?.version ?? (mode === "global-npm" ? packageVersion : undefined);
const comparison = currentVersion ? compareVersions(targetVersion, currentVersion) : 1;
if (options.check) { emit(options, { mode, currentVersion: currentVersion ?? null, targetVersion, updateAvailable: comparison > 0, downgrade: comparison < 0, channel: request.channel }, comparison > 0 ? `Update available: ${targetVersion}` : comparison < 0 ? `Target ${targetVersion} is older than ${currentVersion}.` : `paperclipai ${targetVersion} is current.`); if (comparison > 0) process.exitCode = 10; return; }
if (mode === "global-npm") {
if (comparison < 0 && options.yes !== true) { const confirmed = await (overrides.confirm ?? defaultConfirm)(`Downgrade paperclipai from ${currentVersion} to ${targetVersion}?`); if (!confirmed) throw new Error("Downgrade cancelled. Re-run with --yes to confirm explicitly."); }
const args = ["install", "-g", `paperclipai@${targetVersion}`, `--registry=${PUBLIC_NPM_REGISTRY}`, `--@paperclipai:registry=${PUBLIC_NPM_REGISTRY}`]; console.log(`Running: npm ${args.join(" ")}`);
if (!options.dryRun) {
const npmConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-npm-"));
@@ -143,5 +172,6 @@ export async function updateCommand(options: UpdateOptions, overrides: Partial<D
try { writeInstallManifestAtomic(next, paths); } catch (error) { flipCurrentAtomic(path.resolve(paths.cliRoot, oldTarget), paths); throw error; }
pruneInstallPayloads(next, paths); return payload;
}, paths);
emit(options, { mode, currentVersion, targetVersion, changed: true, reused: installed.reused }, pc.green(`Updated paperclipai ${currentVersion} → ${targetVersion}. Run \`paperclipai update --rollback\` for an instant payload rollback.`));
const restarted = await (overrides.restartActiveService ?? restartActiveManagedService)(targetVersion);
emit(options, { mode, currentVersion, targetVersion, changed: true, reused: installed.reused, restarted }, pc.green(`Updated paperclipai ${currentVersion} → ${targetVersion}${restarted ? " and restarted the active service" : ""}. Run \`paperclipai update --rollback\` for an instant payload rollback.`));
}
+3 -2
View File
@@ -168,7 +168,8 @@ export class SystemdServiceManager implements ServiceManager {
}
async uninstall(): Promise<void> {
await this.stop().catch(() => undefined);
const status = await this.status();
if (status.active) await this.stop();
await this.runner("systemctl", ["--user", "disable", this.serviceName]).catch(() => undefined);
await fs.rm(this.definitionPath, { force: true });
await this.runner("systemctl", ["--user", "daemon-reload"]);
@@ -237,7 +238,7 @@ export class LaunchdServiceManager implements ServiceManager {
await this.runner("launchctl", ["disable", `${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 start(): Promise<void> { await this.install({ startNow: true, startOnLogin: await this.isEnabled() }); }
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}`]); }
+2 -2
View File
@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolvePaperclipHomeDir } from "../home-paths.js";
import { resolvePaperclipInstanceRoot } from "../home-paths.js";
export const HOT_RESTART_INTENT_FILENAME = "hot-restart-intent.json";
export const HOT_RESTART_REPORT_FILENAME = "hot-restart-report.json";
@@ -56,7 +56,7 @@ export type HotRestartReport = {
};
function resolveHotRestartPath(filename: string, homeDir?: string) {
return path.join(resolvePaperclipHomeDir(homeDir), filename);
return path.join(resolvePaperclipInstanceRoot({ homeDir }), filename);
}
export function resolveHotRestartIntentPath(homeDir?: string) {