mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 09:41:04 +01:00
fix(cli): harden managed lifecycle coordination
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
b45f2c0b9e
commit
3e9214cd6d
8 files changed
+143
-22
No files matched your search
@@ -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 });
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.`));
|
||||
}
|
||||
@@ -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}`]); }
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user