From 65ad956ab409a568c24e6578d0509c76a90a3f53 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 4 Jan 2026 15:39:23 +0000 Subject: [PATCH] feat(daemon): add legacy Clawdis service cleanup --- src/daemon/constants.ts | 7 +++ src/daemon/launchd.ts | 127 +++++++++++++++++++++++++++++++--------- src/daemon/legacy.ts | 103 ++++++++++++++++++++++++++++++++ src/daemon/schtasks.ts | 88 +++++++++++++++++++++++++++- src/daemon/systemd.ts | 101 +++++++++++++++++++++++++++++--- 5 files changed, 387 insertions(+), 39 deletions(-) create mode 100644 src/daemon/legacy.ts diff --git a/src/daemon/constants.ts b/src/daemon/constants.ts index 313cdb3f6..3c09451b9 100644 --- a/src/daemon/constants.ts +++ b/src/daemon/constants.ts @@ -1,3 +1,10 @@ export const GATEWAY_LAUNCH_AGENT_LABEL = "com.clawdbot.gateway"; export const GATEWAY_SYSTEMD_SERVICE_NAME = "clawdbot-gateway"; export const GATEWAY_WINDOWS_TASK_NAME = "Clawdbot Gateway"; +export const LEGACY_GATEWAY_LAUNCH_AGENT_LABELS = [ + "com.steipete.clawdbot.gateway", + "com.steipete.clawdis.gateway", + "com.clawdis.gateway", +]; +export const LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES = ["clawdis-gateway"]; +export const LEGACY_GATEWAY_WINDOWS_TASK_NAMES = ["Clawdis Gateway"]; diff --git a/src/daemon/launchd.ts b/src/daemon/launchd.ts index 2ef3c7341..6fb6a0391 100644 --- a/src/daemon/launchd.ts +++ b/src/daemon/launchd.ts @@ -3,39 +3,30 @@ import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; -import { GATEWAY_LAUNCH_AGENT_LABEL } from "./constants.js"; +import { + GATEWAY_LAUNCH_AGENT_LABEL, + LEGACY_GATEWAY_LAUNCH_AGENT_LABELS, +} from "./constants.js"; const execFileAsync = promisify(execFile); -const LEGACY_GATEWAY_LAUNCH_AGENT_LABEL = "com.steipete.clawdbot.gateway"; - function resolveHomeDir(env: Record): string { const home = env.HOME?.trim() || env.USERPROFILE?.trim(); if (!home) throw new Error("Missing HOME"); return home; } +function resolveLaunchAgentPlistPathForLabel( + env: Record, + label: string, +): string { + const home = resolveHomeDir(env); + return path.join(home, "Library", "LaunchAgents", `${label}.plist`); +} + export function resolveLaunchAgentPlistPath( env: Record, ): string { - const home = resolveHomeDir(env); - return path.join( - home, - "Library", - "LaunchAgents", - `${GATEWAY_LAUNCH_AGENT_LABEL}.plist`, - ); -} - -function resolveLegacyLaunchAgentPlistPath( - env: Record, -): string { - const home = resolveHomeDir(env); - return path.join( - home, - "Library", - "LaunchAgents", - `${LEGACY_GATEWAY_LAUNCH_AGENT_LABEL}.plist`, - ); + return resolveLaunchAgentPlistPathForLabel(env, GATEWAY_LAUNCH_AGENT_LABEL); } export function resolveGatewayLogPaths( @@ -212,6 +203,79 @@ export async function isLaunchAgentLoaded(): Promise { return res.code === 0; } +export type LegacyLaunchAgent = { + label: string; + plistPath: string; + loaded: boolean; + exists: boolean; +}; + +export async function findLegacyLaunchAgents( + env: Record, +): Promise { + const domain = resolveGuiDomain(); + const results: LegacyLaunchAgent[] = []; + for (const label of LEGACY_GATEWAY_LAUNCH_AGENT_LABELS) { + const plistPath = resolveLaunchAgentPlistPathForLabel(env, label); + const res = await execLaunchctl(["print", `${domain}/${label}`]); + const loaded = res.code === 0; + let exists = false; + try { + await fs.access(plistPath); + exists = true; + } catch { + // ignore + } + if (loaded || exists) { + results.push({ label, plistPath, loaded, exists }); + } + } + return results; +} + +export async function uninstallLegacyLaunchAgents({ + env, + stdout, +}: { + env: Record; + stdout: NodeJS.WritableStream; +}): Promise { + const domain = resolveGuiDomain(); + const agents = await findLegacyLaunchAgents(env); + if (agents.length === 0) return agents; + + const home = resolveHomeDir(env); + const trashDir = path.join(home, ".Trash"); + try { + await fs.mkdir(trashDir, { recursive: true }); + } catch { + // ignore + } + + for (const agent of agents) { + await execLaunchctl(["bootout", domain, agent.plistPath]); + await execLaunchctl(["unload", agent.plistPath]); + + try { + await fs.access(agent.plistPath); + } catch { + continue; + } + + const dest = path.join(trashDir, `${agent.label}.plist`); + try { + await fs.rename(agent.plistPath, dest); + stdout.write(`Moved legacy LaunchAgent to Trash: ${dest}\n`); + } catch { + stdout.write( + `Legacy LaunchAgent remains at ${agent.plistPath} (could not move)\n`, + ); + } + } + + return agents; +} + export async function uninstallLaunchAgent({ env, stdout, @@ -259,14 +323,19 @@ export async function installLaunchAgent({ const { logDir, stdoutPath, stderrPath } = resolveGatewayLogPaths(env); await fs.mkdir(logDir, { recursive: true }); - const legacyPlistPath = resolveLegacyLaunchAgentPlistPath(env); const domain = resolveGuiDomain(); - await execLaunchctl(["bootout", domain, legacyPlistPath]); - await execLaunchctl(["unload", legacyPlistPath]); - try { - await fs.unlink(legacyPlistPath); - } catch { - // ignore + for (const legacyLabel of LEGACY_GATEWAY_LAUNCH_AGENT_LABELS) { + const legacyPlistPath = resolveLaunchAgentPlistPathForLabel( + env, + legacyLabel, + ); + await execLaunchctl(["bootout", domain, legacyPlistPath]); + await execLaunchctl(["unload", legacyPlistPath]); + try { + await fs.unlink(legacyPlistPath); + } catch { + // ignore + } } const plistPath = resolveLaunchAgentPlistPath(env); diff --git a/src/daemon/legacy.ts b/src/daemon/legacy.ts new file mode 100644 index 000000000..f800cf262 --- /dev/null +++ b/src/daemon/legacy.ts @@ -0,0 +1,103 @@ +import { + findLegacyLaunchAgents, + uninstallLegacyLaunchAgents, +} from "./launchd.js"; +import { + findLegacyScheduledTasks, + uninstallLegacyScheduledTasks, +} from "./schtasks.js"; +import { + findLegacySystemdUnits, + uninstallLegacySystemdUnits, +} from "./systemd.js"; + +export type LegacyGatewayService = { + platform: "darwin" | "linux" | "win32"; + label: string; + detail: string; +}; + +function formatLegacyLaunchAgents( + agents: Awaited>, +): LegacyGatewayService[] { + return agents.map((agent) => ({ + platform: "darwin", + label: agent.label, + detail: [ + agent.loaded ? "loaded" : "not loaded", + agent.exists ? `plist: ${agent.plistPath}` : "plist missing", + ].join(", "), + })); +} + +function formatLegacySystemdUnits( + units: Awaited>, +): LegacyGatewayService[] { + return units.map((unit) => ({ + platform: "linux", + label: `${unit.name}.service`, + detail: [ + unit.enabled ? "enabled" : "disabled", + unit.exists ? `unit: ${unit.unitPath}` : "unit missing", + ].join(", "), + })); +} + +function formatLegacyScheduledTasks( + tasks: Awaited>, +): LegacyGatewayService[] { + return tasks.map((task) => ({ + platform: "win32", + label: task.name, + detail: [ + task.installed ? "installed" : "not installed", + task.scriptExists ? `script: ${task.scriptPath}` : "script missing", + ].join(", "), + })); +} + +export async function findLegacyGatewayServices( + env: Record, +): Promise { + if (process.platform === "darwin") { + const agents = await findLegacyLaunchAgents(env); + return formatLegacyLaunchAgents(agents); + } + + if (process.platform === "linux") { + const units = await findLegacySystemdUnits(env); + return formatLegacySystemdUnits(units); + } + + if (process.platform === "win32") { + const tasks = await findLegacyScheduledTasks(env); + return formatLegacyScheduledTasks(tasks); + } + + return []; +} + +export async function uninstallLegacyGatewayServices({ + env, + stdout, +}: { + env: Record; + stdout: NodeJS.WritableStream; +}): Promise { + if (process.platform === "darwin") { + const agents = await uninstallLegacyLaunchAgents({ env, stdout }); + return formatLegacyLaunchAgents(agents); + } + + if (process.platform === "linux") { + const units = await uninstallLegacySystemdUnits({ env, stdout }); + return formatLegacySystemdUnits(units); + } + + if (process.platform === "win32") { + const tasks = await uninstallLegacyScheduledTasks({ env, stdout }); + return formatLegacyScheduledTasks(tasks); + } + + return []; +} diff --git a/src/daemon/schtasks.ts b/src/daemon/schtasks.ts index b27d2a77f..3d8da28f7 100644 --- a/src/daemon/schtasks.ts +++ b/src/daemon/schtasks.ts @@ -3,7 +3,10 @@ import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; -import { GATEWAY_WINDOWS_TASK_NAME } from "./constants.js"; +import { + GATEWAY_WINDOWS_TASK_NAME, + LEGACY_GATEWAY_WINDOWS_TASK_NAMES, +} from "./constants.js"; const execFileAsync = promisify(execFile); @@ -20,6 +23,13 @@ function resolveTaskScriptPath( return path.join(home, ".clawdbot", "gateway.cmd"); } +function resolveLegacyTaskScriptPath( + env: Record, +): string { + const home = resolveHomeDir(env); + return path.join(home, ".clawdis", "gateway.cmd"); +} + function quoteCmdArg(value: string): string { if (!/[ \t"]/g.test(value)) return value; return `"${value.replace(/"/g, '\\"')}"`; @@ -242,3 +252,79 @@ export async function isScheduledTaskInstalled(): Promise { const res = await execSchtasks(["/Query", "/TN", GATEWAY_WINDOWS_TASK_NAME]); return res.code === 0; } +export type LegacyScheduledTask = { + name: string; + scriptPath: string; + installed: boolean; + scriptExists: boolean; +}; + +export async function findLegacyScheduledTasks( + env: Record, +): Promise { + const results: LegacyScheduledTask[] = []; + let schtasksAvailable = true; + try { + await assertSchtasksAvailable(); + } catch { + schtasksAvailable = false; + } + + for (const name of LEGACY_GATEWAY_WINDOWS_TASK_NAMES) { + const scriptPath = resolveLegacyTaskScriptPath(env); + let installed = false; + if (schtasksAvailable) { + const res = await execSchtasks(["/Query", "/TN", name]); + installed = res.code === 0; + } + let scriptExists = false; + try { + await fs.access(scriptPath); + scriptExists = true; + } catch { + // ignore + } + if (installed || scriptExists) { + results.push({ name, scriptPath, installed, scriptExists }); + } + } + + return results; +} + +export async function uninstallLegacyScheduledTasks({ + env, + stdout, +}: { + env: Record; + stdout: NodeJS.WritableStream; +}): Promise { + const tasks = await findLegacyScheduledTasks(env); + if (tasks.length === 0) return tasks; + + let schtasksAvailable = true; + try { + await assertSchtasksAvailable(); + } catch { + schtasksAvailable = false; + } + + for (const task of tasks) { + if (schtasksAvailable && task.installed) { + await execSchtasks(["/Delete", "/F", "/TN", task.name]); + } else if (!schtasksAvailable && task.installed) { + stdout.write( + `schtasks unavailable; unable to remove legacy task: ${task.name}\n`, + ); + } + + try { + await fs.unlink(task.scriptPath); + stdout.write(`Removed legacy task script: ${task.scriptPath}\n`); + } catch { + stdout.write(`Legacy task script not found at ${task.scriptPath}\n`); + } + } + + return tasks; +} diff --git a/src/daemon/systemd.ts b/src/daemon/systemd.ts index eb511ec4f..4a3a289c5 100644 --- a/src/daemon/systemd.ts +++ b/src/daemon/systemd.ts @@ -3,7 +3,10 @@ import fs from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; -import { GATEWAY_SYSTEMD_SERVICE_NAME } from "./constants.js"; +import { + GATEWAY_SYSTEMD_SERVICE_NAME, + LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES, +} from "./constants.js"; const execFileAsync = promisify(execFile); @@ -13,17 +16,18 @@ function resolveHomeDir(env: Record): string { return home; } +function resolveSystemdUnitPathForName( + env: Record, + name: string, +): string { + const home = resolveHomeDir(env); + return path.join(home, ".config", "systemd", "user", `${name}.service`); +} + function resolveSystemdUnitPath( env: Record, ): string { - const home = resolveHomeDir(env); - return path.join( - home, - ".config", - "systemd", - "user", - `${GATEWAY_SYSTEMD_SERVICE_NAME}.service`, - ); + return resolveSystemdUnitPathForName(env, GATEWAY_SYSTEMD_SERVICE_NAME); } function systemdEscapeArg(value: string): string { @@ -276,3 +280,82 @@ export async function isSystemdServiceEnabled(): Promise { const res = await execSystemctl(["--user", "is-enabled", unitName]); return res.code === 0; } +export type LegacySystemdUnit = { + name: string; + unitPath: string; + enabled: boolean; + exists: boolean; +}; + +async function isSystemctlAvailable(): Promise { + const res = await execSystemctl(["--user", "status"]); + if (res.code === 0) return true; + const detail = `${res.stderr || res.stdout}`.toLowerCase(); + return !detail.includes("not found"); +} + +export async function findLegacySystemdUnits( + env: Record, +): Promise { + const results: LegacySystemdUnit[] = []; + const systemctlAvailable = await isSystemctlAvailable(); + for (const name of LEGACY_GATEWAY_SYSTEMD_SERVICE_NAMES) { + const unitPath = resolveSystemdUnitPathForName(env, name); + let exists = false; + try { + await fs.access(unitPath); + exists = true; + } catch { + // ignore + } + let enabled = false; + if (systemctlAvailable) { + const res = await execSystemctl([ + "--user", + "is-enabled", + `${name}.service`, + ]); + enabled = res.code === 0; + } + if (exists || enabled) { + results.push({ name, unitPath, enabled, exists }); + } + } + return results; +} + +export async function uninstallLegacySystemdUnits({ + env, + stdout, +}: { + env: Record; + stdout: NodeJS.WritableStream; +}): Promise { + const units = await findLegacySystemdUnits(env); + if (units.length === 0) return units; + + const systemctlAvailable = await isSystemctlAvailable(); + for (const unit of units) { + if (systemctlAvailable) { + await execSystemctl([ + "--user", + "disable", + "--now", + `${unit.name}.service`, + ]); + } else { + stdout.write( + `systemctl unavailable; removed legacy unit file only: ${unit.name}.service\n`, + ); + } + + try { + await fs.unlink(unit.unitPath); + stdout.write(`Removed legacy systemd service: ${unit.unitPath}\n`); + } catch { + stdout.write(`Legacy systemd unit not found at ${unit.unitPath}\n`); + } + } + + return units; +}