Skip to content

Commit 9696ae3

Browse files
hugocasaclaude
andcommitted
feat: regenerate service unit files on webmux update
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3ff5c03 commit 9696ae3

4 files changed

Lines changed: 235 additions & 15 deletions

File tree

bin/src/service-restart.test.ts

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55
import { listInstalledServices, restartCommand, type InstalledService } from "./service-restart.ts";
6+
import { generateServiceFile, parseInstalledServiceConfig, type ServiceConfig } from "./service.ts";
67

78
const cleanups: Array<() => Promise<void>> = [];
89

@@ -58,6 +59,88 @@ describe("listInstalledServices", () => {
5859
});
5960
});
6061

62+
describe("parseInstalledServiceConfig", () => {
63+
it("reconstructs a ServiceConfig from a systemd unit written by generateServiceFile", async () => {
64+
const dir = await makeTempDir();
65+
const filePath = join(dir, "webmux-roundtrip.service");
66+
const original: ServiceConfig = {
67+
platform: "linux",
68+
projectName: "roundtrip",
69+
serviceName: "webmux-roundtrip",
70+
webmuxPath: "/usr/local/bin/webmux",
71+
projectDir: dir,
72+
port: 5117,
73+
};
74+
await writeFile(filePath, generateServiceFile(original));
75+
76+
const parsed = parseInstalledServiceConfig(filePath, "linux", "/new/path/webmux");
77+
78+
expect(parsed).not.toBeNull();
79+
expect(parsed?.port).toBe(5117);
80+
expect(parsed?.projectDir).toBe(dir);
81+
expect(parsed?.serviceName).toBe("webmux-roundtrip");
82+
// webmuxPath comes from the caller (post-upgrade `which webmux`), not the unit.
83+
expect(parsed?.webmuxPath).toBe("/new/path/webmux");
84+
});
85+
86+
it("reconstructs a ServiceConfig from a launchd plist written by generateServiceFile", async () => {
87+
const dir = await makeTempDir();
88+
const filePath = join(dir, "com.webmux.webmux-roundtrip.plist");
89+
const original: ServiceConfig = {
90+
platform: "darwin",
91+
projectName: "roundtrip",
92+
serviceName: "webmux-roundtrip",
93+
webmuxPath: "/usr/local/bin/webmux",
94+
projectDir: dir,
95+
port: 5222,
96+
};
97+
await writeFile(filePath, generateServiceFile(original));
98+
99+
const parsed = parseInstalledServiceConfig(filePath, "darwin", "/new/path/webmux");
100+
101+
expect(parsed).not.toBeNull();
102+
expect(parsed?.port).toBe(5222);
103+
expect(parsed?.projectDir).toBe(dir);
104+
expect(parsed?.serviceName).toBe("webmux-roundtrip");
105+
});
106+
107+
it("returns null when the unit file lacks --port", async () => {
108+
const dir = await makeTempDir();
109+
const filePath = join(dir, "broken.service");
110+
await writeFile(filePath, "[Service]\nWorkingDirectory=/x\n");
111+
expect(parseInstalledServiceConfig(filePath, "linux", "/path/webmux")).toBeNull();
112+
});
113+
});
114+
115+
describe("generateServiceFile → parseInstalledServiceConfig → generateServiceFile is idempotent", () => {
116+
it("regenerated content matches the original for systemd units", async () => {
117+
const dir = await makeTempDir();
118+
// `detectProjectName` reads package.json's `name` first, so seeding one
119+
// with a stable name guarantees the re-derived `projectName` matches the
120+
// original — otherwise it would fall back to the random temp-dir basename
121+
// and the round-trip would diverge on the `Description=` line.
122+
await writeFile(join(dir, "package.json"), JSON.stringify({ name: "idempotent" }));
123+
const filePath = join(dir, "webmux-idempotent.service");
124+
const original: ServiceConfig = {
125+
platform: "linux",
126+
projectName: "idempotent",
127+
serviceName: "webmux-idempotent",
128+
webmuxPath: "/usr/local/bin/webmux",
129+
projectDir: dir,
130+
port: 5333,
131+
};
132+
const originalContent = generateServiceFile(original);
133+
await writeFile(filePath, originalContent);
134+
135+
const parsed = parseInstalledServiceConfig(filePath, "linux", "/usr/local/bin/webmux");
136+
expect(parsed).not.toBeNull();
137+
if (!parsed) throw new Error("parse failed");
138+
139+
// Round-trip should produce identical content when webmuxPath is unchanged.
140+
expect(generateServiceFile(parsed)).toBe(originalContent);
141+
});
142+
});
143+
61144
describe("restartCommand", () => {
62145
it("builds the systemctl --user restart command for linux", () => {
63146
const svc: InstalledService = {

bin/src/service-restart.ts

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { existsSync, readdirSync } from "node:fs";
1+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
22
import { homedir } from "node:os";
33
import { join } from "node:path";
44
import { run, type RunResult } from "./shared.ts";
5+
import { generateServiceFile, parseInstalledServiceConfig, type Platform } from "./service.ts";
56

6-
export type ServicePlatform = "linux" | "darwin";
7+
export type ServicePlatform = Platform;
78

89
export interface InstalledService {
910
/** Full unit name. systemd: "webmux-foo" (no .service suffix). launchd: the
@@ -90,3 +91,85 @@ export function restartInstalledService(service: InstalledService): RestartOutco
9091
}
9192
return { service, ok: true };
9293
}
94+
95+
export interface UpdateOutcome {
96+
service: InstalledService;
97+
regenerated: boolean;
98+
restarted: boolean;
99+
error?: string;
100+
}
101+
102+
function reloadAfterRegenerate(service: InstalledService): RunResult | null {
103+
if (service.platform === "linux") {
104+
return run("systemctl", ["--user", "daemon-reload"]);
105+
}
106+
// launchd: kickstart -k doesn't re-read the plist. Force unload + load so
107+
// the new content takes effect. unload may fail when the service isn't
108+
// currently loaded — that's expected during the first refresh, treat as
109+
// non-fatal and let `load` decide success.
110+
run("launchctl", ["unload", service.filePath]);
111+
return run("launchctl", ["load", "-w", service.filePath]);
112+
}
113+
114+
/** Bring an installed unit file in sync with the current `generateServiceFile`
115+
* template (preserving the user's port and project), reload the service
116+
* manager so the change takes effect, and restart so the running process
117+
* picks up both the new binary and any unit-file changes. Falls back to a
118+
* plain restart when the unit can't be parsed — that still gets the new
119+
* binary loaded even if regeneration is skipped. */
120+
export function updateInstalledService(
121+
service: InstalledService,
122+
webmuxPath: string,
123+
): UpdateOutcome {
124+
const config = parseInstalledServiceConfig(service.filePath, service.platform, webmuxPath);
125+
let regenerated = false;
126+
127+
if (config !== null) {
128+
let currentContent = "";
129+
try {
130+
currentContent = readFileSync(service.filePath, "utf8");
131+
} catch {
132+
// unreadable — fall through to plain restart
133+
}
134+
const expected = generateServiceFile(config);
135+
if (currentContent !== expected) {
136+
try {
137+
writeFileSync(service.filePath, expected);
138+
regenerated = true;
139+
} catch (err: unknown) {
140+
return {
141+
service,
142+
regenerated: false,
143+
restarted: false,
144+
error: `could not rewrite ${service.filePath}: ${String(err)}`,
145+
};
146+
}
147+
}
148+
}
149+
150+
if (regenerated) {
151+
const reload = reloadAfterRegenerate(service);
152+
if (reload && !reload.success) {
153+
return {
154+
service,
155+
regenerated,
156+
restarted: false,
157+
error: reload.stderr.toString().trim() || "reload failed",
158+
};
159+
}
160+
// On launchd the load step already (re)started the service. systemd
161+
// still needs an explicit restart so an already-running process picks
162+
// up the new ExecStart.
163+
if (service.platform === "darwin") {
164+
return { service, regenerated, restarted: true };
165+
}
166+
}
167+
168+
const outcome = restartInstalledService(service);
169+
return {
170+
service,
171+
regenerated,
172+
restarted: outcome.ok,
173+
error: outcome.error,
174+
};
175+
}

bin/src/service.ts

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
11
import * as p from "@clack/prompts";
2-
import { existsSync, mkdirSync, unlinkSync } from "node:fs";
3-
import { join } from "node:path";
2+
import { existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
3+
import { basename, join } from "node:path";
44
import { homedir } from "node:os";
55
import { run, getGitRoot, detectProjectName } from "./shared.ts";
66
import type { RunResult } from "./shared.ts";
77
import { discoverTakenPorts, pickFreePort, readPortFromUnit } from "./install-ports.ts";
88

99
// ── Types ───────────────────────────────────────────────────────────────────
1010

11-
type Platform = "linux" | "darwin";
11+
export type Platform = "linux" | "darwin";
1212
type Command = [bin: string, args: string[]];
1313

14-
interface ServiceConfig {
14+
export interface ServiceConfig {
1515
platform: Platform;
1616
projectName: string;
1717
serviceName: string;
@@ -130,11 +130,60 @@ function generateLaunchdPlist(config: ServiceConfig): string {
130130
`;
131131
}
132132

133-
function generateServiceFile(config: ServiceConfig): string {
133+
export function generateServiceFile(config: ServiceConfig): string {
134134
if (config.platform === "linux") return generateSystemdUnit(config);
135135
return generateLaunchdPlist(config);
136136
}
137137

138+
const SYSTEMD_WORKDIR_RE = /^WorkingDirectory=(.+)$/m;
139+
const LAUNCHD_WORKDIR_RE = /<key>WorkingDirectory<\/key>\s*<string>([^<]+)<\/string>/;
140+
141+
function readWorkingDirFromUnit(filePath: string, platform: Platform): string | null {
142+
let text: string;
143+
try {
144+
text = readFileSync(filePath, "utf8");
145+
} catch {
146+
return null;
147+
}
148+
const regex = platform === "linux" ? SYSTEMD_WORKDIR_RE : LAUNCHD_WORKDIR_RE;
149+
const match = regex.exec(text);
150+
return match ? match[1].trim() : null;
151+
}
152+
153+
/** Reconstruct a ServiceConfig from an installed unit file. The serviceName
154+
* is taken from the file basename (not re-derived) so a renamed project dir
155+
* doesn't change the launchd Label / systemd unit name that the OS is
156+
* already tracking — only the regenerated *content* (description, paths,
157+
* environment) reflects the current state. Returns null when the file is
158+
* missing required fields. */
159+
export function parseInstalledServiceConfig(
160+
filePath: string,
161+
platform: Platform,
162+
webmuxPath: string,
163+
): ServiceConfig | null {
164+
const port = readPortFromUnit(filePath);
165+
if (port === null) return null;
166+
167+
const projectDir = readWorkingDirFromUnit(filePath, platform);
168+
if (projectDir === null) return null;
169+
170+
const fileBase = basename(filePath);
171+
const serviceName = platform === "linux"
172+
? fileBase.replace(/\.service$/, "")
173+
: fileBase.replace(/^com\.webmux\./, "").replace(/\.plist$/, "");
174+
175+
const projectName = detectProjectName(projectDir);
176+
177+
return {
178+
platform,
179+
projectName,
180+
serviceName,
181+
webmuxPath,
182+
projectDir,
183+
port,
184+
};
185+
}
186+
138187
// ── Install/uninstall commands ──────────────────────────────────────────────
139188

140189
function installCommands(config: ServiceConfig): Command[] {

bin/src/webmux.ts

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -320,17 +320,22 @@ async function main(args: string[] = process.argv.slice(2)): Promise<void> {
320320
});
321321
const code = await proc.exited;
322322
if (code === 0) {
323-
const { listInstalledServices, restartInstalledService } = await import("./service-restart.ts");
323+
const { listInstalledServices, updateInstalledService } = await import("./service-restart.ts");
324324
const services = listInstalledServices();
325325
if (services.length > 0) {
326-
console.log(`\nRestarting ${services.length} installed webmux service(s) to pick up the new version...`);
326+
const whichResult = Bun.spawnSync(["which", "webmux"], { stdout: "pipe", stderr: "pipe" });
327+
const webmuxPath = whichResult.success ? whichResult.stdout.toString().trim() : "";
328+
console.log(`\nRefreshing ${services.length} installed webmux service(s) to pick up the new version...`);
327329
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-
}
330+
const outcome = updateInstalledService(svc, webmuxPath);
331+
const parts: string[] = [];
332+
if (outcome.regenerated) parts.push("regenerated unit");
333+
if (outcome.restarted) parts.push("restarted");
334+
if (!outcome.regenerated && !outcome.restarted && !outcome.error) parts.push("no change");
335+
const status = outcome.error
336+
? `failed — ${outcome.error}`
337+
: parts.join(", ");
338+
console.log(` ${svc.name}: ${status}`);
334339
}
335340
}
336341
}

0 commit comments

Comments
 (0)