@gajae-gateway/cli 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,429 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { access, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ currentPlatform,
7
+ launchAgentPlistPath,
8
+ restartOrder,
9
+ restartStackCommands,
10
+ type ServicePlatform,
11
+ serviceSpecs,
12
+ systemdUnitDir,
13
+ systemdUnitName,
14
+ systemdUnitPath,
15
+ } from "./services";
16
+
17
+ /**
18
+ * `gajaeway ops restart-stack` (issue #54).
19
+ *
20
+ * A persona turn that restarts the gateway is killed with it, so running the
21
+ * ordered restart inline stopped between the gateway and its adapters and left
22
+ * the adapters on the old binary while the caller reported success. The
23
+ * sequence therefore runs in a detached supervisor (its own session, so the
24
+ * caller's process group and the service manager's job kill do not reach it),
25
+ * verifies every service against its deployed binary, and records a receipt
26
+ * under `$GAJAEWAY_HOME` that the next turn reads with `--status`.
27
+ */
28
+
29
+ export type RestartStepResult = "pending" | "ok" | "stale" | "failed" | "skipped";
30
+ export type RestartState = "queued" | "running" | "ok" | "stale" | "failed";
31
+
32
+ export interface RestartStep {
33
+ readonly label: string;
34
+ /** Absent on systemd, where the gateway unit's restart carries its dependents. */
35
+ command?: readonly string[];
36
+ exitStatus?: number;
37
+ result: RestartStepResult;
38
+ pid?: number;
39
+ processStartedAt?: string;
40
+ binary?: string;
41
+ binaryModifiedAt?: string;
42
+ detail?: string;
43
+ }
44
+
45
+ export interface RestartReceipt {
46
+ readonly id: string;
47
+ state: RestartState;
48
+ readonly platform: ServicePlatform;
49
+ readonly requestedAt: string;
50
+ supervisorPid?: number;
51
+ finishedAt?: string;
52
+ readonly steps: RestartStep[];
53
+ }
54
+
55
+ /** A running service process as the service manager and `ps` report it. */
56
+ export interface ServiceProcess {
57
+ readonly pid: number;
58
+ /** Epoch ms; `ps` reports whole seconds. */
59
+ readonly startedAt: number;
60
+ readonly binary: string;
61
+ }
62
+
63
+ export type CommandRunner = (command: readonly string[]) => number | PromiseLike<number>;
64
+ export type ServiceProbe = (label: string) => Promise<ServiceProcess | undefined>;
65
+ export type IsServiceInstalled = (label: string) => Promise<boolean>;
66
+
67
+ export interface RunRestartOptions {
68
+ readonly home: string;
69
+ readonly id: string;
70
+ readonly platform?: ServicePlatform;
71
+ readonly uid?: number;
72
+ readonly runner?: CommandRunner;
73
+ readonly probe?: ServiceProbe;
74
+ /** Checks if a service is installed; if not provided, defaults to checking the service manager's files. */
75
+ readonly isServiceInstalled?: IsServiceInstalled;
76
+ /** Epoch ms modification time of a deployed binary. */
77
+ readonly binaryModifiedAt?: (path: string) => Promise<number>;
78
+ readonly now?: () => number;
79
+ readonly sleep?: (ms: number) => Promise<void>;
80
+ /** How long one service may take to come back verified; covers the gateway's ordered shutdown. */
81
+ readonly verifyTimeoutMs?: number;
82
+ readonly pollMs?: number;
83
+ /** Environment variables; used to determine systemd unit dir on linux. Defaults to process.env. */
84
+ readonly env?: NodeJS.ProcessEnv;
85
+ /** LaunchAgents directory on darwin. Defaults to ~/Library/LaunchAgents. */
86
+ readonly launchAgentsDir?: string;
87
+ /** systemd unit directory on linux. Defaults to $XDG_CONFIG_HOME/systemd/user. */
88
+ readonly unitDir?: string;
89
+ }
90
+
91
+ const RECEIPT_FILE = "restart-stack.json";
92
+ const VERIFY_TIMEOUT_MS = 90_000;
93
+ const POLL_MS = 1_000;
94
+
95
+ export function restartReceiptPath(home: string): string {
96
+ return join(home, RECEIPT_FILE);
97
+ }
98
+
99
+ export async function readRestartReceipt(home: string): Promise<RestartReceipt | undefined> {
100
+ let raw: string;
101
+ try {
102
+ raw = await readFile(restartReceiptPath(home), "utf8");
103
+ } catch (error) {
104
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
105
+ throw error;
106
+ }
107
+ return JSON.parse(raw) as RestartReceipt;
108
+ }
109
+
110
+ async function writeReceipt(home: string, receipt: RestartReceipt): Promise<void> {
111
+ await mkdir(home, { recursive: true });
112
+ const path = restartReceiptPath(home);
113
+ const temporary = `${path}.${process.pid}.tmp`;
114
+ await writeFile(temporary, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
115
+ await rename(temporary, path);
116
+ }
117
+
118
+ function newReceipt(id: string, platform: ServicePlatform, uid: number | undefined, now: number): RestartReceipt {
119
+ const commands = restartStackCommands(platform, uid);
120
+ return {
121
+ id,
122
+ state: "queued",
123
+ platform,
124
+ requestedAt: new Date(now).toISOString(),
125
+ steps: restartOrder().map((spec, index) => ({
126
+ label: spec.label,
127
+ ...(platform === "darwin" ? { command: commands[index] } : index === 0 ? { command: commands[0] } : {}),
128
+ result: "pending",
129
+ })),
130
+ };
131
+ }
132
+
133
+ async function defaultCommandRunner(command: readonly string[]): Promise<number> {
134
+ const child = Bun.spawn([...command], { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
135
+ return await child.exited;
136
+ }
137
+
138
+ async function capture(command: readonly string[]): Promise<string | undefined> {
139
+ const child = Bun.spawn([...command], {
140
+ stdin: "ignore",
141
+ stdout: "pipe",
142
+ stderr: "ignore",
143
+ env: { ...process.env, LC_ALL: "C" },
144
+ });
145
+ const output = await new Response(child.stdout).text();
146
+ return (await child.exited) === 0 ? output : undefined;
147
+ }
148
+
149
+ /** Reads the live pid from the service manager, then its start time and executable from `ps`. */
150
+ export function defaultServiceProbe(platform: ServicePlatform): ServiceProbe {
151
+ return async (label) => {
152
+ const spec = serviceSpecs().find((candidate) => candidate.label === label);
153
+ if (spec === undefined) throw new Error(`unknown service label: ${label}`);
154
+ let pid: number | undefined;
155
+ if (platform === "darwin") {
156
+ const listed = await capture(["launchctl", "list", label]);
157
+ const match = listed?.match(/"PID"\s*=\s*(\d+);/);
158
+ pid = match ? Number(match[1]) : undefined;
159
+ } else {
160
+ const shown = await capture(["systemctl", "--user", "show", "-p", "MainPID", "--value", systemdUnitName(spec)]);
161
+ pid = shown === undefined ? undefined : Number(shown.trim());
162
+ }
163
+ if (pid === undefined || !Number.isInteger(pid) || pid <= 0) return undefined;
164
+ const lstart = await capture(["ps", "-o", "lstart=", "-p", String(pid)]);
165
+ const args = await capture(["ps", "-ww", "-o", "args=", "-p", String(pid)]);
166
+ if (lstart === undefined || args === undefined) return undefined;
167
+ const startedAt = Date.parse(lstart.trim());
168
+ if (!Number.isFinite(startedAt)) return undefined;
169
+ // The service definitions exec `<bin-dir>/<binary> <fixed args>`; stripping
170
+ // the known arguments keeps a bin dir containing spaces intact.
171
+ const suffix = spec.args.length > 0 ? ` ${spec.args.join(" ")}` : "";
172
+ const line = args.trim();
173
+ const binary = suffix && line.endsWith(suffix) ? line.slice(0, -suffix.length) : line;
174
+ return { pid, startedAt, binary };
175
+ };
176
+ }
177
+
178
+ async function defaultBinaryModifiedAt(path: string): Promise<number> {
179
+ return (await stat(path)).mtimeMs;
180
+ }
181
+
182
+ /** Checks if a service is installed by verifying the service manager's definition file exists. */
183
+ function defaultIsServiceInstalled(options: {
184
+ readonly platform: ServicePlatform;
185
+ readonly env: NodeJS.ProcessEnv;
186
+ readonly launchAgentsDir?: string;
187
+ readonly unitDir?: string;
188
+ }): IsServiceInstalled {
189
+ return async (label: string) => {
190
+ const spec = serviceSpecs().find((s) => s.label === label);
191
+ if (spec === undefined) return false;
192
+
193
+ if (options.platform === "darwin") {
194
+ const userHome = options.env.HOME || homedir();
195
+ const agentsDir = options.launchAgentsDir || join(userHome, "Library", "LaunchAgents");
196
+ const plistPath = launchAgentPlistPath(spec, agentsDir);
197
+ try {
198
+ await access(plistPath);
199
+ return true;
200
+ } catch {
201
+ return false;
202
+ }
203
+ }
204
+
205
+ // linux
206
+ const userHome = options.env.HOME || homedir();
207
+ const unitDirPath = options.unitDir || systemdUnitDir(options.env, userHome);
208
+ const unitPath = systemdUnitPath(spec, unitDirPath);
209
+ try {
210
+ await access(unitPath);
211
+ return true;
212
+ } catch {
213
+ return false;
214
+ }
215
+ };
216
+ }
217
+
218
+ function floorSecond(ms: number): number {
219
+ return Math.floor(ms / 1_000) * 1_000;
220
+ }
221
+
222
+ /**
223
+ * Runs the ordered restart, verifying each service before the next command,
224
+ * and persists the receipt after every transition. The first service that is
225
+ * stale or missing aborts the remainder.
226
+ */
227
+ export async function runRestartStack(options: RunRestartOptions): Promise<RestartReceipt> {
228
+ const platform = options.platform ?? currentPlatform();
229
+ const runner = options.runner ?? defaultCommandRunner;
230
+ const probe = options.probe ?? defaultServiceProbe(platform);
231
+ const modifiedAt = options.binaryModifiedAt ?? defaultBinaryModifiedAt;
232
+ const isInstalled =
233
+ options.isServiceInstalled ??
234
+ defaultIsServiceInstalled({
235
+ platform,
236
+ env: options.env ?? process.env,
237
+ launchAgentsDir: options.launchAgentsDir,
238
+ unitDir: options.unitDir,
239
+ });
240
+ const now = options.now ?? Date.now;
241
+ const sleep = options.sleep ?? ((ms: number) => Bun.sleep(ms));
242
+ const timeoutMs = options.verifyTimeoutMs ?? VERIFY_TIMEOUT_MS;
243
+ const pollMs = options.pollMs ?? POLL_MS;
244
+
245
+ const existing = await readRestartReceipt(options.home);
246
+ const receipt =
247
+ existing?.id === options.id && existing.state === "queued"
248
+ ? existing
249
+ : newReceipt(options.id, platform, options.uid, now());
250
+ receipt.state = "running";
251
+ receipt.supervisorPid = process.pid;
252
+ await writeReceipt(options.home, receipt);
253
+
254
+ let issuedAt = now();
255
+ for (const step of receipt.steps) {
256
+ if (receipt.state !== "running") {
257
+ step.result = "skipped";
258
+ continue;
259
+ }
260
+ try {
261
+ // Check if the service is installed before trying to restart it
262
+ const installed = await isInstalled(step.label);
263
+ if (!installed) {
264
+ step.result = "skipped";
265
+ step.detail = "service is not installed";
266
+ await writeReceipt(options.home, receipt);
267
+ continue;
268
+ }
269
+
270
+ if (step.command !== undefined) {
271
+ issuedAt = now();
272
+ const status = await runner(step.command);
273
+ step.exitStatus = status;
274
+ if (status !== 0) {
275
+ step.result = "failed";
276
+ step.detail = `${step.command.join(" ")} exited with status ${status}`;
277
+ }
278
+ }
279
+ if (step.result === "pending") await verifyStep(step, issuedAt);
280
+ } catch (error) {
281
+ step.result = "failed";
282
+ step.detail = error instanceof Error ? error.message : String(error);
283
+ }
284
+ if (step.result !== "ok" && step.result !== "skipped") {
285
+ receipt.state = step.result === "stale" ? "stale" : "failed";
286
+ }
287
+ await writeReceipt(options.home, receipt);
288
+ }
289
+ if (receipt.state === "running") receipt.state = "ok";
290
+ receipt.finishedAt = new Date(now()).toISOString();
291
+ await writeReceipt(options.home, receipt);
292
+ return receipt;
293
+
294
+ async function verifyStep(step: RestartStep, since: number): Promise<void> {
295
+ const deadline = now() + timeoutMs;
296
+ for (;;) {
297
+ const running = await probe(step.label);
298
+ let binaryModifiedAt = 0;
299
+ if (running !== undefined) {
300
+ binaryModifiedAt = await modifiedAt(running.binary);
301
+ step.pid = running.pid;
302
+ step.processStartedAt = new Date(running.startedAt).toISOString();
303
+ step.binary = running.binary;
304
+ step.binaryModifiedAt = new Date(binaryModifiedAt).toISOString();
305
+ // `ps` has one-second resolution, so the bound is floored to match.
306
+ if (running.startedAt >= floorSecond(Math.max(binaryModifiedAt, since))) {
307
+ step.result = "ok";
308
+ delete step.detail;
309
+ return;
310
+ }
311
+ }
312
+ if (now() >= deadline) {
313
+ if (running === undefined) {
314
+ step.result = "failed";
315
+ step.detail = "no running process after restart";
316
+ } else {
317
+ step.result = "stale";
318
+ step.detail =
319
+ running.startedAt < binaryModifiedAt
320
+ ? "process started before the deployed binary was modified"
321
+ : "process was not restarted by this sequence";
322
+ }
323
+ return;
324
+ }
325
+ await sleep(pollMs);
326
+ }
327
+ }
328
+ }
329
+
330
+ export interface LaunchRestartOptions {
331
+ readonly home: string;
332
+ readonly platform?: ServicePlatform;
333
+ readonly uid?: number;
334
+ /** Argv that re-enters the CLI; `--run <id>` is appended. */
335
+ readonly worker?: readonly string[];
336
+ readonly env?: NodeJS.ProcessEnv;
337
+ }
338
+
339
+ /** The argv that runs this CLI again, whether compiled or run from source. */
340
+ export function cliCommand(): string[] {
341
+ return Bun.main.startsWith("/$bunfs/") ? [process.execPath] : [process.execPath, Bun.main];
342
+ }
343
+
344
+ function alive(pid: number | undefined): boolean {
345
+ if (pid === undefined) return false;
346
+ try {
347
+ process.kill(pid, 0);
348
+ return true;
349
+ } catch {
350
+ return false;
351
+ }
352
+ }
353
+
354
+ /**
355
+ * Queues a restart and hands it to a detached supervisor, returning as soon as
356
+ * the supervisor is spawned so the caller may be killed by the restart itself.
357
+ */
358
+ export async function launchRestartStack(
359
+ options: LaunchRestartOptions,
360
+ ): Promise<{ readonly receipt: RestartReceipt; readonly supervisorPid: number }> {
361
+ const platform = options.platform ?? currentPlatform();
362
+ const existing = await readRestartReceipt(options.home);
363
+ if (existing?.state === "running" && alive(existing.supervisorPid))
364
+ throw new Error(`restart-stack ${existing.id} is still running (supervisor pid ${existing.supervisorPid})`);
365
+ const receipt = newReceipt(randomUUID(), platform, options.uid, Date.now());
366
+ await writeReceipt(options.home, receipt);
367
+ const worker = [...(options.worker ?? [...cliCommand(), "ops", "restart-stack"]), "--run", receipt.id];
368
+ const child = Bun.spawn(supervisorArgv(platform, worker, options.home, receipt.id), {
369
+ detached: true,
370
+ stdin: "ignore",
371
+ stdout: "ignore",
372
+ stderr: "ignore",
373
+ env: { ...(options.env ?? process.env), GAJAEWAY_HOME: options.home },
374
+ });
375
+ child.unref();
376
+ return { receipt, supervisorPid: child.pid };
377
+ }
378
+
379
+ /**
380
+ * A new session escapes the caller's process group, which is enough under
381
+ * launchd. Under systemd the caller usually lives in a service cgroup that the
382
+ * restart it requests may stop, so the supervisor is started as its own
383
+ * transient user unit instead.
384
+ */
385
+ export function supervisorArgv(
386
+ platform: ServicePlatform,
387
+ worker: readonly string[],
388
+ home: string,
389
+ id: string,
390
+ ): string[] {
391
+ if (platform === "darwin") return [...worker];
392
+ return [
393
+ "systemd-run",
394
+ "--user",
395
+ "--collect",
396
+ "--quiet",
397
+ `--unit=gajaeway-restart-stack-${id}`,
398
+ `--setenv=GAJAEWAY_HOME=${home}`,
399
+ "--",
400
+ ...worker,
401
+ ];
402
+ }
403
+
404
+ /**
405
+ * The receipt's state, except that an unfinished sequence whose supervisor is
406
+ * gone is reported as interrupted rather than left looking in progress.
407
+ */
408
+ export function effectiveRestartState(receipt: RestartReceipt): RestartState | "interrupted" {
409
+ if (receipt.state === "running" && !alive(receipt.supervisorPid)) return "interrupted";
410
+ return receipt.state;
411
+ }
412
+
413
+ export function renderRestartReceipt(receipt: RestartReceipt): string[] {
414
+ const lines = [
415
+ `restart-stack ${receipt.id}: ${effectiveRestartState(receipt)}`,
416
+ `requestedAt: ${receipt.requestedAt}`,
417
+ `finishedAt: ${receipt.finishedAt ?? "-"}`,
418
+ ];
419
+ for (const step of receipt.steps) {
420
+ const facts = [
421
+ step.pid === undefined ? undefined : `pid=${step.pid}`,
422
+ step.processStartedAt === undefined ? undefined : `started=${step.processStartedAt}`,
423
+ step.binaryModifiedAt === undefined ? undefined : `binary=${step.binaryModifiedAt}`,
424
+ step.detail,
425
+ ].filter((fact) => fact !== undefined);
426
+ lines.push(`${step.label}: ${step.result}${facts.length > 0 ? ` ${facts.join(" ")}` : ""}`);
427
+ }
428
+ return lines;
429
+ }
package/src/services.ts CHANGED
@@ -23,13 +23,20 @@ export interface ResolvePathOptions {
23
23
  readonly loginPathRunner?: LoginPathRunner;
24
24
  }
25
25
 
26
+ /** Service managers this CLI can write definitions for. */
27
+ export type ServicePlatform = "darwin" | "linux";
28
+
26
29
  export interface InstallServicesOptions {
27
30
  readonly binDir: string;
28
31
  readonly launchAgentsDir?: string;
32
+ /** systemd user-unit directory; defaults to ${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user. */
33
+ readonly unitDir?: string;
34
+ /** Service manager to generate for; defaults to the running platform. */
35
+ readonly platform?: ServicePlatform;
29
36
  readonly env?: NodeJS.ProcessEnv;
30
37
  /** Test seam for the `${SHELL} -lc` invocation used by the default path. */
31
38
  readonly loginPathRunner?: LoginPathRunner;
32
- /** Test seam for counting or inspecting the plist writes. */
39
+ /** Test seam for counting or inspecting the definition writes. */
33
40
  readonly writeFile?: PlistWriter;
34
41
  }
35
42
 
@@ -38,25 +45,47 @@ interface ServiceSpec {
38
45
  readonly label: string;
39
46
  readonly binary: string;
40
47
  readonly args: readonly string[];
48
+ /**
49
+ * True for every process that holds a live connection to the gateway. A
50
+ * gateway restart leaves such a process attached to the previous generation:
51
+ * it neither dies nor loses messages, so no health check catches it, and the
52
+ * symptom is replies arriving one beat late (issue #251). The service
53
+ * definition, not an operator convention, has to restart it.
54
+ */
55
+ readonly dependsOnGateway: boolean;
41
56
  }
42
57
 
58
+ export const GATEWAY_UNIT = "gajaeway-gateway.service";
59
+
43
60
  const SERVICE_SPECS: readonly ServiceSpec[] = [
44
- { id: "gateway", label: "dev.gajaeway.gateway", binary: "gajaeway-gateway", args: ["daemon"] },
61
+ {
62
+ id: "gateway",
63
+ label: "dev.gajaeway.gateway",
64
+ binary: "gajaeway-gateway",
65
+ args: ["daemon"],
66
+ dependsOnGateway: false,
67
+ },
45
68
  {
46
69
  id: "adapter-discord",
47
70
  label: "dev.gajaeway.adapter-discord",
48
71
  binary: "gajaeway-discord",
49
72
  args: [],
73
+ dependsOnGateway: true,
50
74
  },
51
75
  {
52
76
  id: "adapter-slack",
53
77
  label: "dev.gajaeway.adapter-slack",
54
78
  binary: "gajaeway-slack",
55
79
  args: [],
80
+ dependsOnGateway: true,
56
81
  },
57
- { id: "admin", label: "dev.gajaeway.admin", binary: "gajaeway-admin", args: ["serve"] },
82
+ { id: "admin", label: "dev.gajaeway.admin", binary: "gajaeway-admin", args: ["serve"], dependsOnGateway: true },
58
83
  ];
59
84
 
85
+ export function serviceSpecs(): readonly ServiceSpec[] {
86
+ return SERVICE_SPECS;
87
+ }
88
+
60
89
  function homeForEnvironment(env: NodeJS.ProcessEnv): string {
61
90
  return env.GAJAEWAY_HOME || join(env.HOME || homedir(), ".gajaeway");
62
91
  }
@@ -161,6 +190,102 @@ export function renderLaunchAgent(spec: ServiceSpec, binDir: string, home: strin
161
190
  `;
162
191
  }
163
192
 
193
+ /**
194
+ * systemd reads `Environment=` with its own quoting rules, so a value is wrapped
195
+ * in double quotes with backslashes and quotes escaped. PATH entries containing
196
+ * a space would otherwise silently split into two assignments.
197
+ */
198
+ function unitEnvironment(name: string, value: string): string {
199
+ return `Environment="${name}=${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
200
+ }
201
+
202
+ export function systemdUnitName(spec: ServiceSpec): string {
203
+ return `gajaeway-${spec.id}.service`;
204
+ }
205
+
206
+ export function renderSystemdUnit(spec: ServiceSpec, binDir: string, home: string, path: string): string {
207
+ const execStart = [join(binDir, spec.binary), ...spec.args].join(" ");
208
+ const unit = [
209
+ "[Unit]",
210
+ `Description=gajaeway ${spec.id}`,
211
+ // BindsTo restarts and stops this unit with the gateway; PartOf propagates a
212
+ // gateway restart even when this unit is started on its own; After orders
213
+ // the boot so the adapter connects to a listening socket.
214
+ ...(spec.dependsOnGateway ? [`BindsTo=${GATEWAY_UNIT}`, `After=${GATEWAY_UNIT}`, `PartOf=${GATEWAY_UNIT}`] : []),
215
+ "",
216
+ "[Service]",
217
+ `ExecStart=${execStart}`,
218
+ `WorkingDirectory=${binDir}`,
219
+ unitEnvironment("GAJAEWAY_HOME", home),
220
+ unitEnvironment("PATH", path),
221
+ "Restart=always",
222
+ "RestartSec=2",
223
+ // Only the gateway: GJC daemon and session hosts share the gateway cgroup,
224
+ // so the default control-group kill would take unrelated sessions with it.
225
+ // The gateway exits on its own inside 25s (SHUTDOWN_DEADLINE_MS in the
226
+ // gateway main); the unit's stop window is pinned so that ceiling always fits.
227
+ ...(spec.dependsOnGateway ? [] : ["KillMode=process", "TimeoutStopSec=30s"]),
228
+ "",
229
+ "[Install]",
230
+ // Enabling the gateway pulls the whole stack in; a dependent is never
231
+ // wanted by default.target on its own, because it cannot run without one.
232
+ `WantedBy=${spec.dependsOnGateway ? GATEWAY_UNIT : "default.target"}`,
233
+ "",
234
+ ];
235
+ return unit.join("\n");
236
+ }
237
+
238
+ export function systemdUnitDir(env: NodeJS.ProcessEnv, userHome: string): string {
239
+ const base = env.XDG_CONFIG_HOME || join(userHome, ".config");
240
+ return join(base, "systemd", "user");
241
+ }
242
+
243
+ /** Gets the path where a service's LaunchAgent plist is stored on darwin. */
244
+ export function launchAgentPlistPath(spec: ServiceSpec, launchAgentsDir: string): string {
245
+ return join(launchAgentsDir, `dev.gajaeway.${spec.id}.plist`);
246
+ }
247
+
248
+ /** Gets the path where a service's systemd unit file is stored on linux. */
249
+ export function systemdUnitPath(spec: ServiceSpec, unitDir: string): string {
250
+ return join(unitDir, systemdUnitName(spec));
251
+ }
252
+
253
+ /**
254
+ * The ordered commands that realign the whole stack.
255
+ *
256
+ * systemd expresses the dependency itself, so one restart of the gateway unit
257
+ * is the entire operation. launchd has no BindsTo/PartOf equivalent and
258
+ * `WatchPaths` does not restart an already-running job, so the ordering is
259
+ * explicit here instead — and `bootout` never appears, because removing a job
260
+ * leaves it with no automatic recovery.
261
+ */
262
+ export function restartStackCommands(platform: ServicePlatform, uid?: number): readonly (readonly string[])[] {
263
+ if (platform === "linux") return [["systemctl", "--user", "restart", GATEWAY_UNIT]];
264
+ if (platform === "darwin") {
265
+ const target = uid ?? process.getuid?.() ?? 0;
266
+ return restartOrder().map((spec) => ["launchctl", "kickstart", "-k", `gui/${target}/${spec.label}`]);
267
+ }
268
+ throw new Error(`unsupported service platform: ${platform}`);
269
+ }
270
+
271
+ /** The gateway first, then every job that holds a connection to it. */
272
+ export function restartOrder(): readonly ServiceSpec[] {
273
+ return [
274
+ ...SERVICE_SPECS.filter((spec) => !spec.dependsOnGateway),
275
+ ...SERVICE_SPECS.filter((spec) => spec.dependsOnGateway),
276
+ ];
277
+ }
278
+
279
+ function requirePlatform(platform: string): ServicePlatform {
280
+ if (platform !== "darwin" && platform !== "linux") throw new Error(`unsupported service platform: ${platform}`);
281
+ return platform;
282
+ }
283
+
284
+ export function currentPlatform(): ServicePlatform {
285
+ if (process.platform === "darwin" || process.platform === "linux") return process.platform;
286
+ throw new Error(`unsupported service platform: ${process.platform}`);
287
+ }
288
+
164
289
  async function readRuntime(home: string): Promise<RuntimeConfig | undefined> {
165
290
  const configPath = join(home, "config.json");
166
291
  let raw: string;
@@ -191,10 +316,14 @@ async function readRuntime(home: string): Promise<RuntimeConfig | undefined> {
191
316
  export async function installServices(options: InstallServicesOptions): Promise<readonly string[]> {
192
317
  if (options.binDir.length === 0) throw new Error("services requires a non-empty --bin-dir DIR");
193
318
  const env = options.env ?? process.env;
319
+ const platform = options.platform === undefined ? currentPlatform() : requirePlatform(options.platform);
194
320
  const userHome = env.HOME || homedir();
195
321
  const home = homeForEnvironment(env);
196
322
  const binDir = expandHome(options.binDir, userHome);
197
- const launchAgentsDir = expandHome(options.launchAgentsDir ?? join(userHome, "Library", "LaunchAgents"), userHome);
323
+ const targetDir =
324
+ platform === "darwin"
325
+ ? expandHome(options.launchAgentsDir ?? join(userHome, "Library", "LaunchAgents"), userHome)
326
+ : expandHome(options.unitDir ?? systemdUnitDir(env, userHome), userHome);
198
327
  const runtime = await readRuntime(home);
199
328
  const path = await resolvePath({
200
329
  binDir,
@@ -203,22 +332,26 @@ export async function installServices(options: InstallServicesOptions): Promise<
203
332
  env,
204
333
  loginPathRunner: options.loginPathRunner,
205
334
  });
206
- await mkdir(launchAgentsDir, { recursive: true, mode: 0o700 });
207
- const plists = SERVICE_SPECS.map((spec) => ({
208
- path: join(launchAgentsDir, `dev.gajaeway.${spec.id}.plist`),
209
- contents: renderLaunchAgent(spec, binDir, home, path),
210
- }));
335
+ await mkdir(targetDir, { recursive: true, mode: 0o700 });
336
+ const definitions = SERVICE_SPECS.map((spec) =>
337
+ platform === "darwin"
338
+ ? {
339
+ path: join(targetDir, `dev.gajaeway.${spec.id}.plist`),
340
+ contents: renderLaunchAgent(spec, binDir, home, path),
341
+ }
342
+ : { path: join(targetDir, systemdUnitName(spec)), contents: renderSystemdUnit(spec, binDir, home, path) },
343
+ );
211
344
  const write = options.writeFile;
212
- for (const plist of plists) {
213
- if (write) await write(plist.path, plist.contents);
345
+ for (const definition of definitions) {
346
+ if (write) await write(definition.path, definition.contents);
214
347
  else {
215
- await writeFile(plist.path, plist.contents, { encoding: "utf8", mode: 0o600 });
216
- await chmod(plist.path, 0o600);
348
+ await writeFile(definition.path, definition.contents, { encoding: "utf8", mode: 0o600 });
349
+ await chmod(definition.path, 0o600);
217
350
  }
218
351
  }
219
- return plists.map((plist) => plist.path);
352
+ return definitions.map((definition) => definition.path);
220
353
  }
221
354
 
222
355
  export function serviceUsage(): string {
223
- return "usage: gajaeway services install|repair --bin-dir DIR [--launch-agents-dir DIR]";
356
+ return "usage: gajaeway services install|repair --bin-dir DIR [--launch-agents-dir DIR] [--unit-dir DIR] [--platform darwin|linux]";
224
357
  }