Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 40 additions & 20 deletions src/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
type ElevatedSchtasksCreateAndRunExecution,
type ElevatedSchtasksCreateAndRunResult,
} from "./lib/windows-elevation";
import { defaultWinswEntry, installWinswService, startWinswService, stopWinswService, statusWinswRaw, uninstallWinswService, winswStatusSummary, winswXmlPath, WINSW_SERVICE_ID, WINSW_SHA256, WINSW_VERSION } from "./lib/winsw";
import { defaultWinswEntry, installWinswService, startWinswService, stopWinswService, statusWinswRaw, uninstallWinswService, winswStatusSummary, winswXmlPath, WINSW_SERVICE_ID, WINSW_SHA256, WINSW_VERSION, type WinswStatus } from "./lib/winsw";
import { hardenSecretDir, hardenSecretPath } from "./lib/windows-secret-acl";
import { windowsEnvIndirectBatchPathList, windowsEnvIndirectBatchValue } from "./lib/win-paths";
import { recordOwnedConfigPath } from "./lib/config-ownership";
Expand Down Expand Up @@ -2452,33 +2452,53 @@ function removeServiceInstallState(): void {
}
}

type UninstallServiceHooksForTests = {
platform: typeof process.platform;
assertEnvironment: () => void;
probeWindowsTask: () => WindowsSchedulerTaskProbe;
uninstallWindowsTask: () => void;
nativeStatus: () => WinswStatus;
uninstallNative: () => void;
removeInstallState: () => void;
};

let uninstallServiceHooksForTests: UninstallServiceHooksForTests | null = null;

/** Test-only hooks for full-uninstall service removal. */
export function setUninstallServiceHooksForTests(hooks: UninstallServiceHooksForTests | null): void {
uninstallServiceHooksForTests = hooks;
}

/**
* Best-effort service removal for full uninstall. Unlike `ocx service uninstall`, this is quiet
* when no service exists and never exits the process just because the platform has no service
* manager.
* when no service exists or the platform has no service manager. An installed native Windows
* service or scheduler task that cannot be removed throws so the caller cannot erase state and
* report success.
*/
export function uninstallServiceIfInstalled(): boolean {
assertServiceEnvironmentMatchesInstall();
if (process.platform === "darwin") {
const hooks = uninstallServiceHooksForTests;
(hooks?.assertEnvironment ?? assertServiceEnvironmentMatchesInstall)();
const platform = hooks?.platform ?? process.platform;
if (platform === "darwin") {
if (existsSync(plistPath())) {
try { uninstallLaunchd(); removeServiceInstallState(); return true; } catch { return false; }
}
} else if (process.platform === "win32") {
} else if (platform === "win32") {
let removed = false;
try {
const q = schtasks(["/query", "/tn", TASK]);
if (q.includes(TASK)) { uninstallWindows(); removed = true; }
} catch { /* task not found */ }
if (statusWinswRaw() !== "nonexistent") {
try {
uninstallWinswService();
removed = true;
} catch (err) {
console.warn(`⚠️ Failed to remove native service: ${err instanceof Error ? err.message : String(err)}. Check 'sc.exe query ${WINSW_SERVICE_ID}'.`);
}
const scheduler = (hooks?.probeWindowsTask ?? probeWindowsSchedulerTask)();
if (scheduler.status === "unknown") {
throw new Error(`Could not determine Task Scheduler state: ${scheduler.detail}`);
}
if (scheduler.status === "present") {
(hooks?.uninstallWindowsTask ?? uninstallWindows)();
removed = true;
}
if (removed) { removeServiceInstallState(); return true; }
} else if (process.platform === "linux" && existsSync(unitPath())) {
if ((hooks?.nativeStatus ?? statusWinswRaw)() !== "nonexistent") {
(hooks?.uninstallNative ?? uninstallWinswService)();
removed = true;
}
if (removed) { (hooks?.removeInstallState ?? removeServiceInstallState)(); return true; }
} else if (platform === "linux" && existsSync(unitPath())) {
try { uninstallSystemd(); removeServiceInstallState(); return true; } catch {
try { unlinkSync(unitPath()); removeServiceInstallState(); return true; } catch { return false; }
}
Expand Down Expand Up @@ -2873,4 +2893,4 @@ export async function serviceCommand(...args: (string | undefined)[]): Promise<v
console.error(" --native (Windows only): register a real SCM service via WinSW instead of Task Scheduler.");
process.exit(1);
}
}
}
45 changes: 44 additions & 1 deletion tests/uninstall.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { describe, expect, test } from "bun:test";
import { afterEach, describe, expect, test } from "bun:test";
import {
setUninstallServiceHooksForTests,
uninstallServiceIfInstalled,
} from "../src/service";

const root = new URL("../", import.meta.url);

Expand All @@ -7,6 +11,8 @@ async function readText(path: string): Promise<string> {
}

describe("full uninstall command", () => {
afterEach(() => setUninstallServiceHooksForTests(null));

test("CLI exposes a one-shot local state cleanup command", async () => {
const cli = await readText("src/cli/index.ts");

Expand Down Expand Up @@ -41,6 +47,43 @@ describe("full uninstall command", () => {
expect(service).toContain("uninstallSystemd");
});

test("native service removal failure propagates without deleting install state", () => {
const calls: string[] = [];
let stateRemovals = 0;
setUninstallServiceHooksForTests({
platform: "win32",
assertEnvironment: () => {},
probeWindowsTask: () => ({ status: "present" }),
uninstallWindowsTask: () => { calls.push("scheduler"); },
nativeStatus: () => "started",
uninstallNative: () => {
calls.push("native");
throw new Error("native removal failed");
},
removeInstallState: () => { stateRemovals++; },
});

expect(() => uninstallServiceIfInstalled()).toThrow("native removal failed");
expect(calls).toEqual(["scheduler", "native"]);
expect(stateRemovals).toBe(0);
});

test("scheduler removal failure propagates without deleting install state", () => {
let stateRemovals = 0;
setUninstallServiceHooksForTests({
platform: "win32",
assertEnvironment: () => {},
probeWindowsTask: () => ({ status: "present" }),
uninstallWindowsTask: () => { throw new Error("scheduler removal failed"); },
nativeStatus: () => "nonexistent",
uninstallNative: () => {},
removeInstallState: () => { stateRemovals++; },
});

expect(() => uninstallServiceIfInstalled()).toThrow("scheduler removal failed");
expect(stateRemovals).toBe(0);
});

test("full uninstall kills the tracked proxy before deleting service assets", async () => {
const cli = await readText("src/cli/index.ts");
const uninstallBody = cli.slice(cli.indexOf("async function handleUninstall()"), cli.indexOf("type HealthCheck"));
Expand Down
Loading