Skip to content

Commit b7db8fc

Browse files
hugocasaclaude
andcommitted
feat: restart installed services after webmux update
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e048da9 commit b7db8fc

3 files changed

Lines changed: 192 additions & 0 deletions

File tree

bin/src/service-restart.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { afterEach, describe, expect, it } from "bun:test";
2+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { listInstalledServices, restartCommand, type InstalledService } from "./service-restart.ts";
6+
7+
const cleanups: Array<() => Promise<void>> = [];
8+
9+
afterEach(async () => {
10+
while (cleanups.length > 0) {
11+
const fn = cleanups.pop();
12+
if (fn) await fn();
13+
}
14+
});
15+
16+
async function makeTempDir(): Promise<string> {
17+
const dir = await mkdtemp(join(tmpdir(), "webmux-service-restart-"));
18+
cleanups.push(async () => rm(dir, { recursive: true, force: true }));
19+
return dir;
20+
}
21+
22+
describe("listInstalledServices", () => {
23+
it("picks up systemd units and strips the .service suffix", async () => {
24+
const systemdDir = await makeTempDir();
25+
await writeFile(join(systemdDir, "webmux-alpha.service"), "[Service]\nExecStart=/bin/x\n");
26+
await writeFile(join(systemdDir, "webmux-beta.service"), "[Service]\nExecStart=/bin/x\n");
27+
await writeFile(join(systemdDir, "unrelated.service"), "[Service]\nExecStart=/bin/x\n");
28+
29+
const services = listInstalledServices({
30+
systemdDir,
31+
launchdDir: "/no/such/dir",
32+
});
33+
34+
expect(services.map((s) => s.name).sort()).toEqual(["webmux-alpha", "webmux-beta"]);
35+
for (const svc of services) expect(svc.platform).toBe("linux");
36+
});
37+
38+
it("picks up launchd plists and keeps the full label", async () => {
39+
const launchdDir = await makeTempDir();
40+
await writeFile(join(launchdDir, "com.webmux.alpha.plist"), "<plist></plist>");
41+
await writeFile(join(launchdDir, "com.other.thing.plist"), "<plist></plist>");
42+
43+
const services = listInstalledServices({
44+
systemdDir: "/no/such/dir",
45+
launchdDir,
46+
});
47+
48+
expect(services.map((s) => s.name)).toEqual(["com.webmux.alpha"]);
49+
expect(services[0].platform).toBe("darwin");
50+
});
51+
52+
it("returns empty when neither directory exists", () => {
53+
const services = listInstalledServices({
54+
systemdDir: "/no/such/systemd",
55+
launchdDir: "/no/such/launchd",
56+
});
57+
expect(services).toEqual([]);
58+
});
59+
});
60+
61+
describe("restartCommand", () => {
62+
it("builds the systemctl --user restart command for linux", () => {
63+
const svc: InstalledService = {
64+
name: "webmux-foo",
65+
filePath: "/x/webmux-foo.service",
66+
platform: "linux",
67+
};
68+
expect(restartCommand(svc, 1000)).toEqual({
69+
bin: "systemctl",
70+
args: ["--user", "restart", "webmux-foo"],
71+
});
72+
});
73+
74+
it("builds the launchctl kickstart command for darwin", () => {
75+
const svc: InstalledService = {
76+
name: "com.webmux.foo",
77+
filePath: "/x/com.webmux.foo.plist",
78+
platform: "darwin",
79+
};
80+
expect(restartCommand(svc, 501)).toEqual({
81+
bin: "launchctl",
82+
args: ["kickstart", "-k", "gui/501/com.webmux.foo"],
83+
});
84+
});
85+
});

bin/src/service-restart.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { existsSync, readdirSync } from "node:fs";
2+
import { homedir } from "node:os";
3+
import { join } from "node:path";
4+
import { run, type RunResult } from "./shared.ts";
5+
6+
export type ServicePlatform = "linux" | "darwin";
7+
8+
export interface InstalledService {
9+
/** Full unit name. systemd: "webmux-foo" (no .service suffix). launchd: the
10+
* plist Label, e.g. "com.webmux.foo". */
11+
name: string;
12+
filePath: string;
13+
platform: ServicePlatform;
14+
}
15+
16+
const DEFAULT_SYSTEMD_DIR = join(homedir(), ".config", "systemd", "user");
17+
const DEFAULT_LAUNCHD_DIR = join(homedir(), "Library", "LaunchAgents");
18+
19+
/** Enumerate webmux service units installed for the current user, across both
20+
* platforms (one platform's dir is typically absent). Best-effort: unreadable
21+
* directories return nothing rather than throwing. */
22+
export function listInstalledServices(opts: {
23+
systemdDir?: string;
24+
launchdDir?: string;
25+
} = {}): InstalledService[] {
26+
const out: InstalledService[] = [];
27+
const systemdDir = opts.systemdDir ?? DEFAULT_SYSTEMD_DIR;
28+
const launchdDir = opts.launchdDir ?? DEFAULT_LAUNCHD_DIR;
29+
30+
if (existsSync(systemdDir)) {
31+
try {
32+
for (const name of readdirSync(systemdDir)) {
33+
if (!name.startsWith("webmux-") || !name.endsWith(".service")) continue;
34+
out.push({
35+
name: name.slice(0, -".service".length),
36+
filePath: join(systemdDir, name),
37+
platform: "linux",
38+
});
39+
}
40+
} catch {
41+
// unreadable dir — skip
42+
}
43+
}
44+
45+
if (existsSync(launchdDir)) {
46+
try {
47+
for (const name of readdirSync(launchdDir)) {
48+
if (!name.startsWith("com.webmux.") || !name.endsWith(".plist")) continue;
49+
out.push({
50+
name: name.slice(0, -".plist".length),
51+
filePath: join(launchdDir, name),
52+
platform: "darwin",
53+
});
54+
}
55+
} catch {
56+
// unreadable dir — skip
57+
}
58+
}
59+
60+
return out;
61+
}
62+
63+
/** Pure command builder for restarting a service. Kept separate from the
64+
* I/O call so it can be unit-tested without spawning processes. */
65+
export function restartCommand(service: InstalledService, uid: number): { bin: string; args: string[] } {
66+
if (service.platform === "linux") {
67+
return { bin: "systemctl", args: ["--user", "restart", service.name] };
68+
}
69+
return { bin: "launchctl", args: ["kickstart", "-k", `gui/${uid}/${service.name}`] };
70+
}
71+
72+
export interface RestartOutcome {
73+
service: InstalledService;
74+
ok: boolean;
75+
error?: string;
76+
}
77+
78+
/** Restart a single installed service. Best-effort: a failure (service not
79+
* loaded, masked, etc.) is reported back rather than thrown. */
80+
export function restartInstalledService(service: InstalledService): RestartOutcome {
81+
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
82+
const { bin, args } = restartCommand(service, uid);
83+
const result: RunResult = run(bin, args);
84+
if (!result.success) {
85+
return {
86+
service,
87+
ok: false,
88+
error: result.stderr.toString().trim() || `${bin} ${args.join(" ")} failed`,
89+
};
90+
}
91+
return { service, ok: true };
92+
}

bin/src/webmux.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,21 @@ async function main(args: string[] = process.argv.slice(2)): Promise<void> {
319319
stderr: "inherit",
320320
});
321321
const code = await proc.exited;
322+
if (code === 0) {
323+
const { listInstalledServices, restartInstalledService } = await import("./service-restart.ts");
324+
const services = listInstalledServices();
325+
if (services.length > 0) {
326+
console.log(`\nRestarting ${services.length} installed webmux service(s) to pick up the new version...`);
327+
for (const svc of services) {
328+
const outcome = restartInstalledService(svc);
329+
if (outcome.ok) {
330+
console.log(` ${svc.name}: restarted`);
331+
} else {
332+
console.log(` ${svc.name}: restart failed — ${outcome.error}`);
333+
}
334+
}
335+
}
336+
}
322337
process.exit(code);
323338
}
324339

0 commit comments

Comments
 (0)