@gajae-gateway/cli 0.1.1 → 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.
- package/dist/main.d.ts +38 -2
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +617 -66
- package/dist/restart-stack.d.ts +101 -0
- package/dist/restart-stack.d.ts.map +1 -0
- package/dist/services.d.ts +32 -1
- package/dist/services.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/main.ts +358 -51
- package/src/restart-stack.ts +361 -0
- package/src/services.ts +138 -15
|
@@ -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
|
|
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
|
-
{
|
|
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
|
|
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(
|
|
207
|
-
const
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
213
|
-
if (write) await write(
|
|
335
|
+
for (const definition of definitions) {
|
|
336
|
+
if (write) await write(definition.path, definition.contents);
|
|
214
337
|
else {
|
|
215
|
-
await writeFile(
|
|
216
|
-
await chmod(
|
|
338
|
+
await writeFile(definition.path, definition.contents, { encoding: "utf8", mode: 0o600 });
|
|
339
|
+
await chmod(definition.path, 0o600);
|
|
217
340
|
}
|
|
218
341
|
}
|
|
219
|
-
return
|
|
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
|
}
|