@gajae-gateway/cli 0.1.0 → 0.1.2

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,361 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import {
5
+ currentPlatform,
6
+ restartOrder,
7
+ restartStackCommands,
8
+ type ServicePlatform,
9
+ serviceSpecs,
10
+ systemdUnitName,
11
+ } from "./services";
12
+
13
+ /**
14
+ * `gajaeway ops restart-stack` (issue #54).
15
+ *
16
+ * A persona turn that restarts the gateway is killed with it, so running the
17
+ * ordered restart inline stopped between the gateway and its adapters and left
18
+ * the adapters on the old binary while the caller reported success. The
19
+ * sequence therefore runs in a detached supervisor (its own session, so the
20
+ * caller's process group and the service manager's job kill do not reach it),
21
+ * verifies every service against its deployed binary, and records a receipt
22
+ * under `$GAJAEWAY_HOME` that the next turn reads with `--status`.
23
+ */
24
+
25
+ export type RestartStepResult = "pending" | "ok" | "stale" | "failed" | "skipped";
26
+ export type RestartState = "queued" | "running" | "ok" | "stale" | "failed";
27
+
28
+ export interface RestartStep {
29
+ readonly label: string;
30
+ /** Absent on systemd, where the gateway unit's restart carries its dependents. */
31
+ command?: readonly string[];
32
+ exitStatus?: number;
33
+ result: RestartStepResult;
34
+ pid?: number;
35
+ processStartedAt?: string;
36
+ binary?: string;
37
+ binaryModifiedAt?: string;
38
+ detail?: string;
39
+ }
40
+
41
+ export interface RestartReceipt {
42
+ readonly id: string;
43
+ state: RestartState;
44
+ readonly platform: ServicePlatform;
45
+ readonly requestedAt: string;
46
+ supervisorPid?: number;
47
+ finishedAt?: string;
48
+ readonly steps: RestartStep[];
49
+ }
50
+
51
+ /** A running service process as the service manager and `ps` report it. */
52
+ export interface ServiceProcess {
53
+ readonly pid: number;
54
+ /** Epoch ms; `ps` reports whole seconds. */
55
+ readonly startedAt: number;
56
+ readonly binary: string;
57
+ }
58
+
59
+ export type CommandRunner = (command: readonly string[]) => number | PromiseLike<number>;
60
+ export type ServiceProbe = (label: string) => Promise<ServiceProcess | undefined>;
61
+
62
+ export interface RunRestartOptions {
63
+ readonly home: string;
64
+ readonly id: string;
65
+ readonly platform?: ServicePlatform;
66
+ readonly uid?: number;
67
+ readonly runner?: CommandRunner;
68
+ readonly probe?: ServiceProbe;
69
+ /** Epoch ms modification time of a deployed binary. */
70
+ readonly binaryModifiedAt?: (path: string) => Promise<number>;
71
+ readonly now?: () => number;
72
+ readonly sleep?: (ms: number) => Promise<void>;
73
+ /** How long one service may take to come back verified; covers the gateway's ordered shutdown. */
74
+ readonly verifyTimeoutMs?: number;
75
+ readonly pollMs?: number;
76
+ }
77
+
78
+ const RECEIPT_FILE = "restart-stack.json";
79
+ const VERIFY_TIMEOUT_MS = 90_000;
80
+ const POLL_MS = 1_000;
81
+
82
+ export function restartReceiptPath(home: string): string {
83
+ return join(home, RECEIPT_FILE);
84
+ }
85
+
86
+ export async function readRestartReceipt(home: string): Promise<RestartReceipt | undefined> {
87
+ let raw: string;
88
+ try {
89
+ raw = await readFile(restartReceiptPath(home), "utf8");
90
+ } catch (error) {
91
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
92
+ throw error;
93
+ }
94
+ return JSON.parse(raw) as RestartReceipt;
95
+ }
96
+
97
+ async function writeReceipt(home: string, receipt: RestartReceipt): Promise<void> {
98
+ await mkdir(home, { recursive: true });
99
+ const path = restartReceiptPath(home);
100
+ const temporary = `${path}.${process.pid}.tmp`;
101
+ await writeFile(temporary, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600 });
102
+ await rename(temporary, path);
103
+ }
104
+
105
+ function newReceipt(id: string, platform: ServicePlatform, uid: number | undefined, now: number): RestartReceipt {
106
+ const commands = restartStackCommands(platform, uid);
107
+ return {
108
+ id,
109
+ state: "queued",
110
+ platform,
111
+ requestedAt: new Date(now).toISOString(),
112
+ steps: restartOrder().map((spec, index) => ({
113
+ label: spec.label,
114
+ ...(platform === "darwin" ? { command: commands[index] } : index === 0 ? { command: commands[0] } : {}),
115
+ result: "pending",
116
+ })),
117
+ };
118
+ }
119
+
120
+ async function defaultCommandRunner(command: readonly string[]): Promise<number> {
121
+ const child = Bun.spawn([...command], { stdin: "ignore", stdout: "ignore", stderr: "ignore" });
122
+ return await child.exited;
123
+ }
124
+
125
+ async function capture(command: readonly string[]): Promise<string | undefined> {
126
+ const child = Bun.spawn([...command], {
127
+ stdin: "ignore",
128
+ stdout: "pipe",
129
+ stderr: "ignore",
130
+ env: { ...process.env, LC_ALL: "C" },
131
+ });
132
+ const output = await new Response(child.stdout).text();
133
+ return (await child.exited) === 0 ? output : undefined;
134
+ }
135
+
136
+ /** Reads the live pid from the service manager, then its start time and executable from `ps`. */
137
+ export function defaultServiceProbe(platform: ServicePlatform): ServiceProbe {
138
+ return async (label) => {
139
+ const spec = serviceSpecs().find((candidate) => candidate.label === label);
140
+ if (spec === undefined) throw new Error(`unknown service label: ${label}`);
141
+ let pid: number | undefined;
142
+ if (platform === "darwin") {
143
+ const listed = await capture(["launchctl", "list", label]);
144
+ const match = listed?.match(/"PID"\s*=\s*(\d+);/);
145
+ pid = match ? Number(match[1]) : undefined;
146
+ } else {
147
+ const shown = await capture(["systemctl", "--user", "show", "-p", "MainPID", "--value", systemdUnitName(spec)]);
148
+ pid = shown === undefined ? undefined : Number(shown.trim());
149
+ }
150
+ if (pid === undefined || !Number.isInteger(pid) || pid <= 0) return undefined;
151
+ const lstart = await capture(["ps", "-o", "lstart=", "-p", String(pid)]);
152
+ const args = await capture(["ps", "-ww", "-o", "args=", "-p", String(pid)]);
153
+ if (lstart === undefined || args === undefined) return undefined;
154
+ const startedAt = Date.parse(lstart.trim());
155
+ if (!Number.isFinite(startedAt)) return undefined;
156
+ // The service definitions exec `<bin-dir>/<binary> <fixed args>`; stripping
157
+ // the known arguments keeps a bin dir containing spaces intact.
158
+ const suffix = spec.args.length > 0 ? ` ${spec.args.join(" ")}` : "";
159
+ const line = args.trim();
160
+ const binary = suffix && line.endsWith(suffix) ? line.slice(0, -suffix.length) : line;
161
+ return { pid, startedAt, binary };
162
+ };
163
+ }
164
+
165
+ async function defaultBinaryModifiedAt(path: string): Promise<number> {
166
+ return (await stat(path)).mtimeMs;
167
+ }
168
+
169
+ function floorSecond(ms: number): number {
170
+ return Math.floor(ms / 1_000) * 1_000;
171
+ }
172
+
173
+ /**
174
+ * Runs the ordered restart, verifying each service before the next command,
175
+ * and persists the receipt after every transition. The first service that is
176
+ * stale or missing aborts the remainder.
177
+ */
178
+ export async function runRestartStack(options: RunRestartOptions): Promise<RestartReceipt> {
179
+ const platform = options.platform ?? currentPlatform();
180
+ const runner = options.runner ?? defaultCommandRunner;
181
+ const probe = options.probe ?? defaultServiceProbe(platform);
182
+ const modifiedAt = options.binaryModifiedAt ?? defaultBinaryModifiedAt;
183
+ const now = options.now ?? Date.now;
184
+ const sleep = options.sleep ?? ((ms: number) => Bun.sleep(ms));
185
+ const timeoutMs = options.verifyTimeoutMs ?? VERIFY_TIMEOUT_MS;
186
+ const pollMs = options.pollMs ?? POLL_MS;
187
+
188
+ const existing = await readRestartReceipt(options.home);
189
+ const receipt =
190
+ existing?.id === options.id && existing.state === "queued"
191
+ ? existing
192
+ : newReceipt(options.id, platform, options.uid, now());
193
+ receipt.state = "running";
194
+ receipt.supervisorPid = process.pid;
195
+ await writeReceipt(options.home, receipt);
196
+
197
+ let issuedAt = now();
198
+ for (const step of receipt.steps) {
199
+ if (receipt.state !== "running") {
200
+ step.result = "skipped";
201
+ continue;
202
+ }
203
+ try {
204
+ if (step.command !== undefined) {
205
+ issuedAt = now();
206
+ const status = await runner(step.command);
207
+ step.exitStatus = status;
208
+ if (status !== 0) {
209
+ step.result = "failed";
210
+ step.detail = `${step.command.join(" ")} exited with status ${status}`;
211
+ }
212
+ }
213
+ if (step.result === "pending") await verifyStep(step, issuedAt);
214
+ } catch (error) {
215
+ step.result = "failed";
216
+ step.detail = error instanceof Error ? error.message : String(error);
217
+ }
218
+ if (step.result !== "ok") receipt.state = step.result === "stale" ? "stale" : "failed";
219
+ await writeReceipt(options.home, receipt);
220
+ }
221
+ if (receipt.state === "running") receipt.state = "ok";
222
+ receipt.finishedAt = new Date(now()).toISOString();
223
+ await writeReceipt(options.home, receipt);
224
+ return receipt;
225
+
226
+ async function verifyStep(step: RestartStep, since: number): Promise<void> {
227
+ const deadline = now() + timeoutMs;
228
+ for (;;) {
229
+ const running = await probe(step.label);
230
+ let binaryModifiedAt = 0;
231
+ if (running !== undefined) {
232
+ binaryModifiedAt = await modifiedAt(running.binary);
233
+ step.pid = running.pid;
234
+ step.processStartedAt = new Date(running.startedAt).toISOString();
235
+ step.binary = running.binary;
236
+ step.binaryModifiedAt = new Date(binaryModifiedAt).toISOString();
237
+ // `ps` has one-second resolution, so the bound is floored to match.
238
+ if (running.startedAt >= floorSecond(Math.max(binaryModifiedAt, since))) {
239
+ step.result = "ok";
240
+ delete step.detail;
241
+ return;
242
+ }
243
+ }
244
+ if (now() >= deadline) {
245
+ if (running === undefined) {
246
+ step.result = "failed";
247
+ step.detail = "no running process after restart";
248
+ } else {
249
+ step.result = "stale";
250
+ step.detail =
251
+ running.startedAt < binaryModifiedAt
252
+ ? "process started before the deployed binary was modified"
253
+ : "process was not restarted by this sequence";
254
+ }
255
+ return;
256
+ }
257
+ await sleep(pollMs);
258
+ }
259
+ }
260
+ }
261
+
262
+ export interface LaunchRestartOptions {
263
+ readonly home: string;
264
+ readonly platform?: ServicePlatform;
265
+ readonly uid?: number;
266
+ /** Argv that re-enters the CLI; `--run <id>` is appended. */
267
+ readonly worker?: readonly string[];
268
+ readonly env?: NodeJS.ProcessEnv;
269
+ }
270
+
271
+ /** The argv that runs this CLI again, whether compiled or run from source. */
272
+ export function cliCommand(): string[] {
273
+ return Bun.main.startsWith("/$bunfs/") ? [process.execPath] : [process.execPath, Bun.main];
274
+ }
275
+
276
+ function alive(pid: number | undefined): boolean {
277
+ if (pid === undefined) return false;
278
+ try {
279
+ process.kill(pid, 0);
280
+ return true;
281
+ } catch {
282
+ return false;
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Queues a restart and hands it to a detached supervisor, returning as soon as
288
+ * the supervisor is spawned so the caller may be killed by the restart itself.
289
+ */
290
+ export async function launchRestartStack(
291
+ options: LaunchRestartOptions,
292
+ ): Promise<{ readonly receipt: RestartReceipt; readonly supervisorPid: number }> {
293
+ const platform = options.platform ?? currentPlatform();
294
+ const existing = await readRestartReceipt(options.home);
295
+ if (existing?.state === "running" && alive(existing.supervisorPid))
296
+ throw new Error(`restart-stack ${existing.id} is still running (supervisor pid ${existing.supervisorPid})`);
297
+ const receipt = newReceipt(randomUUID(), platform, options.uid, Date.now());
298
+ await writeReceipt(options.home, receipt);
299
+ const worker = [...(options.worker ?? [...cliCommand(), "ops", "restart-stack"]), "--run", receipt.id];
300
+ const child = Bun.spawn(supervisorArgv(platform, worker, options.home, receipt.id), {
301
+ detached: true,
302
+ stdin: "ignore",
303
+ stdout: "ignore",
304
+ stderr: "ignore",
305
+ env: { ...(options.env ?? process.env), GAJAEWAY_HOME: options.home },
306
+ });
307
+ child.unref();
308
+ return { receipt, supervisorPid: child.pid };
309
+ }
310
+
311
+ /**
312
+ * A new session escapes the caller's process group, which is enough under
313
+ * launchd. Under systemd the caller usually lives in a service cgroup that the
314
+ * restart it requests may stop, so the supervisor is started as its own
315
+ * transient user unit instead.
316
+ */
317
+ export function supervisorArgv(
318
+ platform: ServicePlatform,
319
+ worker: readonly string[],
320
+ home: string,
321
+ id: string,
322
+ ): string[] {
323
+ if (platform === "darwin") return [...worker];
324
+ return [
325
+ "systemd-run",
326
+ "--user",
327
+ "--collect",
328
+ "--quiet",
329
+ `--unit=gajaeway-restart-stack-${id}`,
330
+ `--setenv=GAJAEWAY_HOME=${home}`,
331
+ "--",
332
+ ...worker,
333
+ ];
334
+ }
335
+
336
+ /**
337
+ * The receipt's state, except that an unfinished sequence whose supervisor is
338
+ * gone is reported as interrupted rather than left looking in progress.
339
+ */
340
+ export function effectiveRestartState(receipt: RestartReceipt): RestartState | "interrupted" {
341
+ if (receipt.state === "running" && !alive(receipt.supervisorPid)) return "interrupted";
342
+ return receipt.state;
343
+ }
344
+
345
+ export function renderRestartReceipt(receipt: RestartReceipt): string[] {
346
+ const lines = [
347
+ `restart-stack ${receipt.id}: ${effectiveRestartState(receipt)}`,
348
+ `requestedAt: ${receipt.requestedAt}`,
349
+ `finishedAt: ${receipt.finishedAt ?? "-"}`,
350
+ ];
351
+ for (const step of receipt.steps) {
352
+ const facts = [
353
+ step.pid === undefined ? undefined : `pid=${step.pid}`,
354
+ step.processStartedAt === undefined ? undefined : `started=${step.processStartedAt}`,
355
+ step.binaryModifiedAt === undefined ? undefined : `binary=${step.binaryModifiedAt}`,
356
+ step.detail,
357
+ ].filter((fact) => fact !== undefined);
358
+ lines.push(`${step.label}: ${step.result}${facts.length > 0 ? ` ${facts.join(" ")}` : ""}`);
359
+ }
360
+ return lines;
361
+ }
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,92 @@ 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
+ 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
+ /**
244
+ * The ordered commands that realign the whole stack.
245
+ *
246
+ * systemd expresses the dependency itself, so one restart of the gateway unit
247
+ * is the entire operation. launchd has no BindsTo/PartOf equivalent and
248
+ * `WatchPaths` does not restart an already-running job, so the ordering is
249
+ * explicit here instead — and `bootout` never appears, because removing a job
250
+ * leaves it with no automatic recovery.
251
+ */
252
+ export function restartStackCommands(platform: ServicePlatform, uid?: number): readonly (readonly string[])[] {
253
+ if (platform === "linux") return [["systemctl", "--user", "restart", GATEWAY_UNIT]];
254
+ if (platform === "darwin") {
255
+ const target = uid ?? process.getuid?.() ?? 0;
256
+ return restartOrder().map((spec) => ["launchctl", "kickstart", "-k", `gui/${target}/${spec.label}`]);
257
+ }
258
+ throw new Error(`unsupported service platform: ${platform}`);
259
+ }
260
+
261
+ /** The gateway first, then every job that holds a connection to it. */
262
+ export function restartOrder(): readonly ServiceSpec[] {
263
+ return [
264
+ ...SERVICE_SPECS.filter((spec) => !spec.dependsOnGateway),
265
+ ...SERVICE_SPECS.filter((spec) => spec.dependsOnGateway),
266
+ ];
267
+ }
268
+
269
+ function requirePlatform(platform: string): ServicePlatform {
270
+ if (platform !== "darwin" && platform !== "linux") throw new Error(`unsupported service platform: ${platform}`);
271
+ return platform;
272
+ }
273
+
274
+ export function currentPlatform(): ServicePlatform {
275
+ if (process.platform === "darwin" || process.platform === "linux") return process.platform;
276
+ throw new Error(`unsupported service platform: ${process.platform}`);
277
+ }
278
+
164
279
  async function readRuntime(home: string): Promise<RuntimeConfig | undefined> {
165
280
  const configPath = join(home, "config.json");
166
281
  let raw: string;
@@ -191,10 +306,14 @@ async function readRuntime(home: string): Promise<RuntimeConfig | undefined> {
191
306
  export async function installServices(options: InstallServicesOptions): Promise<readonly string[]> {
192
307
  if (options.binDir.length === 0) throw new Error("services requires a non-empty --bin-dir DIR");
193
308
  const env = options.env ?? process.env;
309
+ const platform = options.platform === undefined ? currentPlatform() : requirePlatform(options.platform);
194
310
  const userHome = env.HOME || homedir();
195
311
  const home = homeForEnvironment(env);
196
312
  const binDir = expandHome(options.binDir, userHome);
197
- const launchAgentsDir = expandHome(options.launchAgentsDir ?? join(userHome, "Library", "LaunchAgents"), userHome);
313
+ const targetDir =
314
+ platform === "darwin"
315
+ ? expandHome(options.launchAgentsDir ?? join(userHome, "Library", "LaunchAgents"), userHome)
316
+ : expandHome(options.unitDir ?? systemdUnitDir(env, userHome), userHome);
198
317
  const runtime = await readRuntime(home);
199
318
  const path = await resolvePath({
200
319
  binDir,
@@ -203,22 +322,26 @@ export async function installServices(options: InstallServicesOptions): Promise<
203
322
  env,
204
323
  loginPathRunner: options.loginPathRunner,
205
324
  });
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
- }));
325
+ await mkdir(targetDir, { recursive: true, mode: 0o700 });
326
+ const definitions = SERVICE_SPECS.map((spec) =>
327
+ platform === "darwin"
328
+ ? {
329
+ path: join(targetDir, `dev.gajaeway.${spec.id}.plist`),
330
+ contents: renderLaunchAgent(spec, binDir, home, path),
331
+ }
332
+ : { path: join(targetDir, systemdUnitName(spec)), contents: renderSystemdUnit(spec, binDir, home, path) },
333
+ );
211
334
  const write = options.writeFile;
212
- for (const plist of plists) {
213
- if (write) await write(plist.path, plist.contents);
335
+ for (const definition of definitions) {
336
+ if (write) await write(definition.path, definition.contents);
214
337
  else {
215
- await writeFile(plist.path, plist.contents, { encoding: "utf8", mode: 0o600 });
216
- await chmod(plist.path, 0o600);
338
+ await writeFile(definition.path, definition.contents, { encoding: "utf8", mode: 0o600 });
339
+ await chmod(definition.path, 0o600);
217
340
  }
218
341
  }
219
- return plists.map((plist) => plist.path);
342
+ return definitions.map((definition) => definition.path);
220
343
  }
221
344
 
222
345
  export function serviceUsage(): string {
223
- return "usage: gajaeway services install|repair --bin-dir DIR [--launch-agents-dir DIR]";
346
+ return "usage: gajaeway services install|repair --bin-dir DIR [--launch-agents-dir DIR] [--unit-dir DIR] [--platform darwin|linux]";
224
347
  }