diff --git a/cli/src/__tests__/install-command.test.ts b/cli/src/__tests__/install-command.test.ts index 7e5557e9d52..495e4fab671 100644 --- a/cli/src/__tests__/install-command.test.ts +++ b/cli/src/__tests__/install-command.test.ts @@ -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 }); diff --git a/cli/src/__tests__/service-manager.test.ts b/cli/src/__tests__/service-manager.test.ts index f1fa153b81b..74411e78414 100644 --- a/cli/src/__tests__/service-manager.test.ts +++ b/cli/src/__tests__/service-manager.test.ts @@ -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[] = []; diff --git a/cli/src/__tests__/update-command.test.ts b/cli/src/__tests__/update-command.test.ts index 2bcf133e907..512fecd2ba8 100644 --- a/cli/src/__tests__/update-command.test.ts +++ b/cli/src/__tests__/update-command.test.ts @@ -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); diff --git a/cli/src/commands/service.ts b/cli/src/commands/service.ts index 2941bad381e..5740ad5fed3 100644 --- a/cli/src/commands/service.ts +++ b/cli/src/commands/service.ts @@ -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 { - const reportPath = path.join(resolvePaperclipHomeDir(), "hot-restart-report.json"); +async function waitForRestartReport(instanceId: string, requestedAt: string, timeoutMs = 10_000): Promise { + 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 { diff --git a/cli/src/commands/uninstall.ts b/cli/src/commands/uninstall.ts index 1c2764afc7f..2125df33aab 100644 --- a/cli/src/commands/uninstall.ts +++ b/cli/src/commands/uninstall.ts @@ -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 = {}, ): Promise { @@ -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, diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index a4e1dcdb33f..b0aae823bc7 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -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; confirm: (message: string) => Promise; now: () => Date; paths: InstallStorePaths }; +type Dependencies = { executablePath: string; runCommand: CommandRunner; backup: () => Promise; confirm: (message: string) => Promise; now: () => Date; paths: InstallStorePaths; restartActiveService: (expectedVersion: string) => Promise }; + +async function restartActiveManagedService(expectedVersion: string): Promise { + 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): { spec: string; channel: InstallChannel; explicit: boolean } { @@ -81,7 +107,8 @@ export async function updateCommand(options: UpdateOptions, overrides: Partial 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 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 { - 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 { await this.install({ startNow: true, startOnLogin: true }); } + async start(): Promise { await this.install({ startNow: true, startOnLogin: await this.isEnabled() }); } async stop(): Promise { await this.runner("launchctl", ["bootout", `${this.domain}/${this.serviceName}`]); } async restart(): Promise { await writeIfChanged(this.definitionPath, this.renderDefinition()); await this.runner("launchctl", ["kickstart", "-k", `${this.domain}/${this.serviceName}`]); } diff --git a/server/src/services/hot-restart.ts b/server/src/services/hot-restart.ts index 7c0a4596ce2..25b7f4647fe 100644 --- a/server/src/services/hot-restart.ts +++ b/server/src/services/hot-restart.ts @@ -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) {