@rynx-ai/cli 0.1.11-beta.4 → 0.1.11-beta.41
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/autostart.d.ts +27 -0
- package/dist/autostart.js +229 -0
- package/dist/browser-cli-args.d.ts +7 -1
- package/dist/browser-cli-args.js +51 -6
- package/dist/commands/autostart.d.ts +1 -0
- package/dist/commands/autostart.js +58 -0
- package/dist/commands/browser.d.ts +4 -0
- package/dist/commands/browser.js +75 -6
- package/dist/commands/lifecycle.d.ts +3 -1
- package/dist/commands/lifecycle.js +22 -5
- package/dist/commands/plugin.js +52 -26
- package/dist/commands/setup.js +107 -17
- package/dist/commands/skills.js +2 -2
- package/dist/commands/update.js +2 -2
- package/dist/control-client.js +2 -0
- package/dist/desktop-browser-host-client.js +43 -10
- package/dist/progress-display.d.ts +7 -0
- package/dist/progress-display.js +78 -0
- package/dist/run-cli.js +4 -0
- package/dist/standalone.js +3 -0
- package/dist/systemd-service.d.ts +58 -0
- package/dist/systemd-service.js +636 -0
- package/dist/usage.d.ts +1 -1
- package/dist/usage.js +2 -1
- package/package.json +8 -7
- package/skill-guides/browser.md +41 -1
- package/skills/rynx-cli/SKILL.md +9 -4
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { AutostartRenderInput, AutostartState } from "./autostart.js";
|
|
2
|
+
export declare const RYNX_SYSTEMD_SERVICE = "rynx.service";
|
|
3
|
+
export declare const RYNX_SYSTEMD_SERVICE_ENV = "RYNX_SYSTEMD_SERVICE";
|
|
4
|
+
export declare const RYNX_SERVICE_ENV_KEYS = "RYNX_SERVICE_ENV_KEYS";
|
|
5
|
+
export interface LinuxSystemdServiceState {
|
|
6
|
+
loadState: string;
|
|
7
|
+
activeState: string;
|
|
8
|
+
subState: string;
|
|
9
|
+
type: string;
|
|
10
|
+
remainAfterExit: string;
|
|
11
|
+
killMode: string;
|
|
12
|
+
killSignal: string;
|
|
13
|
+
restartKillSignal: string;
|
|
14
|
+
finalKillSignal: string;
|
|
15
|
+
sendSigkill: string;
|
|
16
|
+
workingDirectory: string;
|
|
17
|
+
environment: string;
|
|
18
|
+
execStart: string;
|
|
19
|
+
execStop: string;
|
|
20
|
+
}
|
|
21
|
+
export interface LinuxPm2GodProcess {
|
|
22
|
+
pid: number;
|
|
23
|
+
cgroup: string;
|
|
24
|
+
startIdentity: string;
|
|
25
|
+
}
|
|
26
|
+
export type LinuxPm2GodInspection = {
|
|
27
|
+
kind: "absent";
|
|
28
|
+
} | {
|
|
29
|
+
kind: "service-cgroup";
|
|
30
|
+
processes: LinuxPm2GodProcess[];
|
|
31
|
+
} | {
|
|
32
|
+
kind: "external";
|
|
33
|
+
processes: LinuxPm2GodProcess[];
|
|
34
|
+
};
|
|
35
|
+
export interface LinuxPm2InspectionDeps {
|
|
36
|
+
procEntries?: () => string[];
|
|
37
|
+
readText?: (file: string) => string;
|
|
38
|
+
statUid?: (file: string) => number;
|
|
39
|
+
currentUid?: number;
|
|
40
|
+
}
|
|
41
|
+
export declare function renderSystemdUnit(input: AutostartRenderInput): string;
|
|
42
|
+
export declare function linuxUserSystemdAvailable(): boolean;
|
|
43
|
+
export declare function inspectLinuxAutostart(): AutostartState;
|
|
44
|
+
export declare function enableLinuxAutostart(): void;
|
|
45
|
+
export declare function disableLinuxAutostart(): void;
|
|
46
|
+
/** Refresh an existing autostart unit without changing enablement. */
|
|
47
|
+
export declare function refreshLinuxServiceIfPresent(): boolean;
|
|
48
|
+
export declare function assertLinuxSystemdInvocation(): void;
|
|
49
|
+
/**
|
|
50
|
+
* Stop through systemd while the oneshot unit owns the lifecycle state. PM2
|
|
51
|
+
* owns its own process tree; systemd only records whether its start hook has
|
|
52
|
+
* completed and provides the matching synchronous ExecStop boundary.
|
|
53
|
+
*/
|
|
54
|
+
export declare function stopLinuxServiceIfActive(): boolean;
|
|
55
|
+
export declare function parseLinuxSystemdShow(output: string): LinuxSystemdServiceState;
|
|
56
|
+
export declare function inspectLinuxPm2GodProcesses(home: string, deps?: LinuxPm2InspectionDeps): LinuxPm2GodInspection;
|
|
57
|
+
export declare function scanLinuxPm2GodPids(home: string, deps?: LinuxPm2InspectionDeps): number[];
|
|
58
|
+
export declare function linuxSystemdCgroupForPid(pid: number, readText?: (file: string) => string): string;
|
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { homedir, userInfo } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { rynxHome } from "@rynx-ai/core";
|
|
7
|
+
export const RYNX_SYSTEMD_SERVICE = "rynx.service";
|
|
8
|
+
export const RYNX_SYSTEMD_SERVICE_ENV = "RYNX_SYSTEMD_SERVICE";
|
|
9
|
+
export const RYNX_SERVICE_ENV_KEYS = "RYNX_SERVICE_ENV_KEYS";
|
|
10
|
+
const COMMAND_TIMEOUT_MS = 5_000;
|
|
11
|
+
const STOP_COMMAND_TIMEOUT_MS = 50_000;
|
|
12
|
+
const AUTOMATIC_SERVICE_ENVIRONMENT = new Set([
|
|
13
|
+
"ALL_PROXY",
|
|
14
|
+
"AWS_CA_BUNDLE",
|
|
15
|
+
"CURL_CA_BUNDLE",
|
|
16
|
+
"GIT_SSL_CAINFO",
|
|
17
|
+
"HTTPS_PROXY",
|
|
18
|
+
"HTTP_PROXY",
|
|
19
|
+
"NODE_EXTRA_CA_CERTS",
|
|
20
|
+
"NO_PROXY",
|
|
21
|
+
"REQUESTS_CA_BUNDLE",
|
|
22
|
+
"SSL_CERT_DIR",
|
|
23
|
+
"SSL_CERT_FILE",
|
|
24
|
+
"all_proxy",
|
|
25
|
+
"https_proxy",
|
|
26
|
+
"http_proxy",
|
|
27
|
+
"no_proxy",
|
|
28
|
+
]);
|
|
29
|
+
const BLOCKED_SERVICE_ENVIRONMENT = new Set([
|
|
30
|
+
"PATH",
|
|
31
|
+
"INVOCATION_ID",
|
|
32
|
+
"JOURNAL_STREAM",
|
|
33
|
+
"LISTEN_FDS",
|
|
34
|
+
"LISTEN_FDNAMES",
|
|
35
|
+
"LISTEN_PID",
|
|
36
|
+
"MAINPID",
|
|
37
|
+
"MANAGERPID",
|
|
38
|
+
"NOTIFY_SOCKET",
|
|
39
|
+
"ELECTRON_RUN_AS_NODE",
|
|
40
|
+
"NODE_CHANNEL_FD",
|
|
41
|
+
"NODE_UNIQUE_ID",
|
|
42
|
+
"PM2_HOME",
|
|
43
|
+
"RYNX_DAEMON_LIFECYCLE",
|
|
44
|
+
"RYNX_HOME",
|
|
45
|
+
"RYNX_REFRESH_LOGIN_SHELL_PATH",
|
|
46
|
+
RYNX_SERVICE_ENV_KEYS,
|
|
47
|
+
RYNX_SYSTEMD_SERVICE_ENV,
|
|
48
|
+
"SYSTEMD_EXEC_PID",
|
|
49
|
+
"SYSTEMD_INVOCATION_ID",
|
|
50
|
+
"WATCHDOG_PID",
|
|
51
|
+
"WATCHDOG_USEC",
|
|
52
|
+
]);
|
|
53
|
+
const TRANSIENT_SERVICE_ENVIRONMENT = new Set([
|
|
54
|
+
"_",
|
|
55
|
+
"CI",
|
|
56
|
+
"CLICOLOR",
|
|
57
|
+
"CLICOLOR_FORCE",
|
|
58
|
+
"CODEX_CI",
|
|
59
|
+
"COLORTERM",
|
|
60
|
+
"FORCE_COLOR",
|
|
61
|
+
"GH_PAGER",
|
|
62
|
+
"GIT_PAGER",
|
|
63
|
+
"NO_COLOR",
|
|
64
|
+
"OLDPWD",
|
|
65
|
+
"PAGER",
|
|
66
|
+
"PWD",
|
|
67
|
+
"SHLVL",
|
|
68
|
+
"SSH_CLIENT",
|
|
69
|
+
"SSH_CONNECTION",
|
|
70
|
+
"SSH_TTY",
|
|
71
|
+
"TERM",
|
|
72
|
+
"TERM_PROGRAM",
|
|
73
|
+
"TERM_PROGRAM_VERSION",
|
|
74
|
+
"TERM_SESSION_ID",
|
|
75
|
+
"TERMINFO",
|
|
76
|
+
"TMUX",
|
|
77
|
+
"TMUX_PANE",
|
|
78
|
+
]);
|
|
79
|
+
export function renderSystemdUnit(input) {
|
|
80
|
+
const inheritedEnvironment = renderInheritedEnvironment(input.environment);
|
|
81
|
+
return `[Unit]
|
|
82
|
+
Description=Rynx local control plane
|
|
83
|
+
After=network-online.target
|
|
84
|
+
Wants=network-online.target
|
|
85
|
+
|
|
86
|
+
[Service]
|
|
87
|
+
Type=oneshot
|
|
88
|
+
RemainAfterExit=yes
|
|
89
|
+
KillMode=process
|
|
90
|
+
KillSignal=SIGCONT
|
|
91
|
+
RestartKillSignal=SIGCONT
|
|
92
|
+
FinalKillSignal=SIGCONT
|
|
93
|
+
SendSIGKILL=no
|
|
94
|
+
TimeoutStartSec=60
|
|
95
|
+
TimeoutStopSec=45
|
|
96
|
+
WorkingDirectory=${systemdPath(input.dataDir)}
|
|
97
|
+
${inheritedEnvironment}
|
|
98
|
+
Environment=${systemdQuote(`PATH=${input.pathEnv}`)}
|
|
99
|
+
Environment=${systemdQuote(`RYNX_HOME=${input.dataDir}`)}
|
|
100
|
+
Environment=${systemdQuote(`${RYNX_SYSTEMD_SERVICE_ENV}=${RYNX_SYSTEMD_SERVICE}`)}
|
|
101
|
+
ExecStart=${systemdQuote(input.nodePath)} ${systemdQuote(input.cliPath)} start --systemd-service
|
|
102
|
+
ExecStop=${systemdQuote(input.nodePath)} ${systemdQuote(input.cliPath)} stop --systemd-service
|
|
103
|
+
|
|
104
|
+
[Install]
|
|
105
|
+
WantedBy=default.target
|
|
106
|
+
`;
|
|
107
|
+
}
|
|
108
|
+
export function linuxUserSystemdAvailable() {
|
|
109
|
+
return process.platform === "linux" &&
|
|
110
|
+
run("systemctl", ["--user", "show-environment"]).status === 0;
|
|
111
|
+
}
|
|
112
|
+
export function inspectLinuxAutostart() {
|
|
113
|
+
const context = currentContext();
|
|
114
|
+
if (!linuxUserSystemdAvailable()) {
|
|
115
|
+
return {
|
|
116
|
+
supported: false,
|
|
117
|
+
registered: existsSync(context.unitPath),
|
|
118
|
+
manager: "systemd-user",
|
|
119
|
+
registrationPath: context.unitPath,
|
|
120
|
+
detail: "user systemd is unavailable in this login session",
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const lingerEnabled = inspectLinuxLinger();
|
|
124
|
+
const active = run("systemctl", ["--user", "is-active", RYNX_SYSTEMD_SERVICE]).status === 0;
|
|
125
|
+
let detail;
|
|
126
|
+
if (active) {
|
|
127
|
+
try {
|
|
128
|
+
assertActiveServiceLifecycle(context);
|
|
129
|
+
}
|
|
130
|
+
catch (error) {
|
|
131
|
+
detail = errorMessage(error);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
supported: true,
|
|
136
|
+
registered: run("systemctl", ["--user", "is-enabled", RYNX_SYSTEMD_SERVICE]).status === 0,
|
|
137
|
+
active,
|
|
138
|
+
...(lingerEnabled === undefined
|
|
139
|
+
? {}
|
|
140
|
+
: {
|
|
141
|
+
lingerEnabled,
|
|
142
|
+
...(!lingerEnabled
|
|
143
|
+
? { lingerEnableCommand: `sudo loginctl enable-linger ${userInfo().username}` }
|
|
144
|
+
: {}),
|
|
145
|
+
}),
|
|
146
|
+
manager: "systemd-user",
|
|
147
|
+
registrationPath: context.unitPath,
|
|
148
|
+
...(detail ? { detail } : {}),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
export function enableLinuxAutostart() {
|
|
152
|
+
assertUserSystemdAvailable();
|
|
153
|
+
const context = currentContext();
|
|
154
|
+
syncLinuxService(context);
|
|
155
|
+
requireSuccess(run("systemctl", ["--user", "enable", RYNX_SYSTEMD_SERVICE]), "systemctl --user enable");
|
|
156
|
+
}
|
|
157
|
+
export function disableLinuxAutostart() {
|
|
158
|
+
assertUserSystemdAvailable();
|
|
159
|
+
const context = currentContext();
|
|
160
|
+
const disabled = run("systemctl", ["--user", "disable", RYNX_SYSTEMD_SERVICE]);
|
|
161
|
+
if (disabled.status !== 0 && existsSync(context.unitPath)) {
|
|
162
|
+
requireSuccess(disabled, "systemctl --user disable");
|
|
163
|
+
}
|
|
164
|
+
rmSync(context.unitPath, { force: true });
|
|
165
|
+
requireSuccess(run("systemctl", ["--user", "daemon-reload"]), "systemctl --user daemon-reload");
|
|
166
|
+
}
|
|
167
|
+
/** Refresh an existing autostart unit without changing enablement. */
|
|
168
|
+
export function refreshLinuxServiceIfPresent() {
|
|
169
|
+
if (process.platform !== "linux" || !linuxUserSystemdAvailable())
|
|
170
|
+
return false;
|
|
171
|
+
const context = currentContext();
|
|
172
|
+
if (!existsSync(context.unitPath))
|
|
173
|
+
return false;
|
|
174
|
+
return syncLinuxService(context);
|
|
175
|
+
}
|
|
176
|
+
export function assertLinuxSystemdInvocation() {
|
|
177
|
+
if (process.platform !== "linux") {
|
|
178
|
+
throw new Error("--systemd-service is only valid on Linux");
|
|
179
|
+
}
|
|
180
|
+
if (process.env[RYNX_SYSTEMD_SERVICE_ENV] !== RYNX_SYSTEMD_SERVICE) {
|
|
181
|
+
throw new Error("--systemd-service requires the Rynx systemd environment");
|
|
182
|
+
}
|
|
183
|
+
const cgroup = linuxSystemdCgroupForPid(process.pid);
|
|
184
|
+
if (!cgroupHasService(cgroup)) {
|
|
185
|
+
throw new Error(`--systemd-service requires the ${RYNX_SYSTEMD_SERVICE} cgroup`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Stop through systemd while the oneshot unit owns the lifecycle state. PM2
|
|
190
|
+
* owns its own process tree; systemd only records whether its start hook has
|
|
191
|
+
* completed and provides the matching synchronous ExecStop boundary.
|
|
192
|
+
*/
|
|
193
|
+
export function stopLinuxServiceIfActive() {
|
|
194
|
+
if (process.platform !== "linux")
|
|
195
|
+
return false;
|
|
196
|
+
const context = currentContext();
|
|
197
|
+
const inspection = inspectLinuxPm2GodProcesses(context.pm2Home);
|
|
198
|
+
assertSingleGod(inspection);
|
|
199
|
+
if (!linuxUserSystemdAvailable()) {
|
|
200
|
+
const unitMode = installedLinuxUnitMode(context);
|
|
201
|
+
if (unitMode === "legacy-or-unknown" ||
|
|
202
|
+
(unitMode === "absent" && (inspection.kind === "service-cgroup" ||
|
|
203
|
+
currentProcessHasLinuxServiceEvidence()))) {
|
|
204
|
+
throw new Error(`cannot safely stop a legacy or still-loaded ${RYNX_SYSTEMD_SERVICE} without user systemd; ` +
|
|
205
|
+
"retry from a login session where systemctl --user is available");
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
const state = inspectLinuxSystemdService();
|
|
210
|
+
if (linuxSystemdServiceIsSettledInactive(state))
|
|
211
|
+
return false;
|
|
212
|
+
stopLinuxSystemdServiceAndVerify(context);
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
function stopLinuxSystemdServiceAndVerify(context) {
|
|
216
|
+
requireSuccess(run("systemctl", ["--user", "stop", RYNX_SYSTEMD_SERVICE], STOP_COMMAND_TIMEOUT_MS), `systemctl --user stop ${RYNX_SYSTEMD_SERVICE}`);
|
|
217
|
+
const remaining = inspectLinuxPm2GodProcesses(context.pm2Home);
|
|
218
|
+
if (remaining.kind !== "absent") {
|
|
219
|
+
throw new Error(`${RYNX_SYSTEMD_SERVICE} stop completed but the PM2 supervisor is still running: ` +
|
|
220
|
+
describeInspection(remaining));
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
function linuxSystemdServiceIsSettledInactive(state) {
|
|
224
|
+
return state.activeState === "inactive" || state.activeState === "failed";
|
|
225
|
+
}
|
|
226
|
+
function installedLinuxUnitMode(context) {
|
|
227
|
+
if (!existsSync(context.unitPath))
|
|
228
|
+
return "absent";
|
|
229
|
+
const content = readFileSync(context.unitPath, "utf8");
|
|
230
|
+
const type = /^\s*Type\s*=\s*([^\s#]+)\s*$/mi.exec(content)?.[1]?.toLowerCase();
|
|
231
|
+
const remains = /^\s*RemainAfterExit\s*=\s*yes\s*$/mi.test(content);
|
|
232
|
+
return type === "oneshot" && remains ? "oneshot" : "legacy-or-unknown";
|
|
233
|
+
}
|
|
234
|
+
function currentProcessHasLinuxServiceEvidence() {
|
|
235
|
+
if (process.env[RYNX_SYSTEMD_SERVICE_ENV] === RYNX_SYSTEMD_SERVICE)
|
|
236
|
+
return true;
|
|
237
|
+
try {
|
|
238
|
+
return cgroupHasService(linuxSystemdCgroupForPid(process.pid));
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
export function parseLinuxSystemdShow(output) {
|
|
245
|
+
const values = new Map();
|
|
246
|
+
for (const line of output.split(/\r?\n/)) {
|
|
247
|
+
const separator = line.indexOf("=");
|
|
248
|
+
if (separator <= 0)
|
|
249
|
+
continue;
|
|
250
|
+
values.set(line.slice(0, separator), line.slice(separator + 1));
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
loadState: values.get("LoadState") ?? "",
|
|
254
|
+
activeState: values.get("ActiveState") ?? "",
|
|
255
|
+
subState: values.get("SubState") ?? "",
|
|
256
|
+
type: values.get("Type") ?? "",
|
|
257
|
+
remainAfterExit: values.get("RemainAfterExit") ?? "",
|
|
258
|
+
killMode: values.get("KillMode") ?? "",
|
|
259
|
+
killSignal: values.get("KillSignal") ?? "",
|
|
260
|
+
restartKillSignal: values.get("RestartKillSignal") ?? "",
|
|
261
|
+
finalKillSignal: values.get("FinalKillSignal") ?? "",
|
|
262
|
+
sendSigkill: values.get("SendSIGKILL") ?? "",
|
|
263
|
+
workingDirectory: values.get("WorkingDirectory") ?? "",
|
|
264
|
+
environment: values.get("Environment") ?? "",
|
|
265
|
+
execStart: values.get("ExecStart") ?? "",
|
|
266
|
+
execStop: values.get("ExecStop") ?? "",
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
export function inspectLinuxPm2GodProcesses(home, deps = {}) {
|
|
270
|
+
const readText = deps.readText ?? ((file) => readFileSync(file, "utf8"));
|
|
271
|
+
const entries = scanLinuxPm2GodPids(home, deps);
|
|
272
|
+
const processes = [];
|
|
273
|
+
for (const pid of entries) {
|
|
274
|
+
try {
|
|
275
|
+
const startBefore = linuxProcStartIdentity(readText(`/proc/${pid}/stat`));
|
|
276
|
+
const cmdline = readText(`/proc/${pid}/cmdline`).replaceAll("\u0000", " ").trim();
|
|
277
|
+
const cgroup = linuxSystemdCgroupForPid(pid, readText);
|
|
278
|
+
const startAfter = linuxProcStartIdentity(readText(`/proc/${pid}/stat`));
|
|
279
|
+
if (!startBefore || startBefore !== startAfter) {
|
|
280
|
+
throw new Error(`PM2 supervisor pid ${pid} changed during inspection`);
|
|
281
|
+
}
|
|
282
|
+
if (!isDedicatedPm2God(cmdline, home))
|
|
283
|
+
continue;
|
|
284
|
+
processes.push({ pid, cgroup, startIdentity: startBefore });
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
if (!processDisappeared(error))
|
|
288
|
+
throw procReadError(`/proc/${pid}`, error);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (processes.length === 0)
|
|
292
|
+
return { kind: "absent" };
|
|
293
|
+
const external = processes.filter((entry) => !cgroupHasService(entry.cgroup));
|
|
294
|
+
return external.length === 0
|
|
295
|
+
? { kind: "service-cgroup", processes }
|
|
296
|
+
: { kind: "external", processes };
|
|
297
|
+
}
|
|
298
|
+
export function scanLinuxPm2GodPids(home, deps = {}) {
|
|
299
|
+
const procEntries = deps.procEntries ?? (() => readdirSync("/proc"));
|
|
300
|
+
const readText = deps.readText ?? ((file) => readFileSync(file, "utf8"));
|
|
301
|
+
const statUid = deps.statUid ?? ((file) => statSync(file).uid);
|
|
302
|
+
const currentUid = deps.currentUid ?? process.getuid?.();
|
|
303
|
+
let entries;
|
|
304
|
+
try {
|
|
305
|
+
entries = procEntries();
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
throw new Error(`cannot inspect /proc: ${errorMessage(error)}`);
|
|
309
|
+
}
|
|
310
|
+
const pids = [];
|
|
311
|
+
for (const entry of entries) {
|
|
312
|
+
if (!/^\d+$/.test(entry))
|
|
313
|
+
continue;
|
|
314
|
+
const pid = Number(entry);
|
|
315
|
+
if (!Number.isSafeInteger(pid) || pid <= 1)
|
|
316
|
+
continue;
|
|
317
|
+
if (currentUid !== undefined) {
|
|
318
|
+
try {
|
|
319
|
+
if (statUid(`/proc/${pid}`) !== currentUid)
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
catch (error) {
|
|
323
|
+
if (processDisappeared(error))
|
|
324
|
+
continue;
|
|
325
|
+
throw procReadError(`/proc/${pid}`, error);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
try {
|
|
329
|
+
const cmdline = readText(`/proc/${pid}/cmdline`).replaceAll("\u0000", " ").trim();
|
|
330
|
+
if (isDedicatedPm2God(cmdline, home))
|
|
331
|
+
pids.push(pid);
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
if (!processDisappeared(error)) {
|
|
335
|
+
throw procReadError(`/proc/${pid}/cmdline`, error);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return [...new Set(pids)].sort((left, right) => left - right);
|
|
340
|
+
}
|
|
341
|
+
export function linuxSystemdCgroupForPid(pid, readText = (file) => readFileSync(file, "utf8")) {
|
|
342
|
+
return normalizedCgroupPath(readText(`/proc/${pid}/cgroup`)) ?? "(unreadable)";
|
|
343
|
+
}
|
|
344
|
+
function currentContext() {
|
|
345
|
+
const dataDir = rynxHome();
|
|
346
|
+
const homeDir = homedir();
|
|
347
|
+
const pm2Home = path.join(dataDir, "pm2");
|
|
348
|
+
return {
|
|
349
|
+
homeDir,
|
|
350
|
+
dataDir,
|
|
351
|
+
logDir: path.join(dataDir, "logs"),
|
|
352
|
+
cliPath: fileURLToPath(new URL("./cli.js", import.meta.url)),
|
|
353
|
+
environment: process.env,
|
|
354
|
+
nodePath: process.execPath,
|
|
355
|
+
pathEnv: process.env.PATH || defaultLinuxPath(),
|
|
356
|
+
pm2Home,
|
|
357
|
+
unitPath: path.join(homeDir, ".config", "systemd", "user", RYNX_SYSTEMD_SERVICE),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
function syncLinuxService(context) {
|
|
361
|
+
mkdirSync(context.dataDir, { recursive: true, mode: 0o700 });
|
|
362
|
+
mkdirSync(context.logDir, { recursive: true, mode: 0o700 });
|
|
363
|
+
const content = renderSystemdUnit(context);
|
|
364
|
+
const previous = existsSync(context.unitPath)
|
|
365
|
+
? readFileSync(context.unitPath, "utf8")
|
|
366
|
+
: undefined;
|
|
367
|
+
const changed = previous !== content;
|
|
368
|
+
if (changed)
|
|
369
|
+
replaceFileAtomically(context.unitPath, content);
|
|
370
|
+
try {
|
|
371
|
+
requireSuccess(run("systemctl", ["--user", "daemon-reload"]), "systemctl --user daemon-reload");
|
|
372
|
+
assertStaticServiceDefinition(context, inspectLinuxSystemdService());
|
|
373
|
+
return changed;
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
if (changed) {
|
|
377
|
+
if (previous === undefined)
|
|
378
|
+
rmSync(context.unitPath, { force: true });
|
|
379
|
+
else
|
|
380
|
+
replaceFileAtomically(context.unitPath, previous);
|
|
381
|
+
const rollback = run("systemctl", ["--user", "daemon-reload"]);
|
|
382
|
+
if (rollback.status !== 0) {
|
|
383
|
+
throw new AggregateError([error, new Error(`systemd rollback failed: ${commandDetail(rollback)}`)], "could not update or restore the Rynx systemd service");
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
function inspectLinuxSystemdService() {
|
|
390
|
+
const output = systemctlUser([
|
|
391
|
+
"show",
|
|
392
|
+
RYNX_SYSTEMD_SERVICE,
|
|
393
|
+
"--property=LoadState",
|
|
394
|
+
"--property=ActiveState",
|
|
395
|
+
"--property=SubState",
|
|
396
|
+
"--property=Type",
|
|
397
|
+
"--property=RemainAfterExit",
|
|
398
|
+
"--property=KillMode",
|
|
399
|
+
"--property=KillSignal",
|
|
400
|
+
"--property=RestartKillSignal",
|
|
401
|
+
"--property=FinalKillSignal",
|
|
402
|
+
"--property=SendSIGKILL",
|
|
403
|
+
"--property=WorkingDirectory",
|
|
404
|
+
"--property=Environment",
|
|
405
|
+
"--property=ExecStart",
|
|
406
|
+
"--property=ExecStop",
|
|
407
|
+
]);
|
|
408
|
+
return parseLinuxSystemdShow(output);
|
|
409
|
+
}
|
|
410
|
+
function assertStaticServiceDefinition(context, state) {
|
|
411
|
+
const errors = [];
|
|
412
|
+
if (state.loadState !== "loaded")
|
|
413
|
+
errors.push(`LoadState=${state.loadState || "(empty)"}`);
|
|
414
|
+
if (state.type !== "oneshot")
|
|
415
|
+
errors.push(`Type=${state.type || "(empty)"}`);
|
|
416
|
+
if (state.remainAfterExit !== "yes") {
|
|
417
|
+
errors.push(`RemainAfterExit=${state.remainAfterExit || "(empty)"}`);
|
|
418
|
+
}
|
|
419
|
+
if (state.workingDirectory !== context.dataDir) {
|
|
420
|
+
errors.push(`WorkingDirectory=${state.workingDirectory || "(empty)"}`);
|
|
421
|
+
}
|
|
422
|
+
if (state.killMode !== "process")
|
|
423
|
+
errors.push(`KillMode=${state.killMode || "(empty)"}`);
|
|
424
|
+
if (!signalMatches(state.killSignal, "18", "SIGCONT")) {
|
|
425
|
+
errors.push(`KillSignal=${state.killSignal || "(empty)"}`);
|
|
426
|
+
}
|
|
427
|
+
if (!signalMatches(state.restartKillSignal, "18", "SIGCONT")) {
|
|
428
|
+
errors.push(`RestartKillSignal=${state.restartKillSignal || "(empty)"}`);
|
|
429
|
+
}
|
|
430
|
+
if (!signalMatches(state.finalKillSignal, "18", "SIGCONT")) {
|
|
431
|
+
errors.push(`FinalKillSignal=${state.finalKillSignal || "(empty)"}`);
|
|
432
|
+
}
|
|
433
|
+
if (state.sendSigkill !== "no")
|
|
434
|
+
errors.push(`SendSIGKILL=${state.sendSigkill || "(empty)"}`);
|
|
435
|
+
for (const expected of [
|
|
436
|
+
`RYNX_HOME=${context.dataDir}`,
|
|
437
|
+
`${RYNX_SYSTEMD_SERVICE_ENV}=${RYNX_SYSTEMD_SERVICE}`,
|
|
438
|
+
`PATH=${context.pathEnv}`,
|
|
439
|
+
]) {
|
|
440
|
+
if (!state.environment.includes(expected))
|
|
441
|
+
errors.push(`Environment missing ${expected}`);
|
|
442
|
+
}
|
|
443
|
+
for (const [name, value, action] of [
|
|
444
|
+
["ExecStart", state.execStart, "start"],
|
|
445
|
+
["ExecStop", state.execStop, "stop"],
|
|
446
|
+
]) {
|
|
447
|
+
if (!value.includes(context.nodePath) ||
|
|
448
|
+
!value.includes(context.cliPath) ||
|
|
449
|
+
!value.includes(action) ||
|
|
450
|
+
!value.includes("--systemd-service")) {
|
|
451
|
+
errors.push(`${name}=${value || "(empty)"}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (errors.length > 0) {
|
|
455
|
+
throw new Error(`${RYNX_SYSTEMD_SERVICE} effective configuration is invalid: ${errors.join("; ")}`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
function assertActiveServiceLifecycle(context) {
|
|
459
|
+
const inspection = inspectLinuxPm2GodProcesses(context.pm2Home);
|
|
460
|
+
assertSingleGod(inspection);
|
|
461
|
+
if (inspection.kind === "absent") {
|
|
462
|
+
throw new Error("systemd started without a PM2 supervisor");
|
|
463
|
+
}
|
|
464
|
+
const process = inspection.processes[0];
|
|
465
|
+
const state = inspectLinuxSystemdService();
|
|
466
|
+
assertStaticServiceDefinition(context, state);
|
|
467
|
+
if (state.activeState !== "active" ||
|
|
468
|
+
state.subState !== "exited") {
|
|
469
|
+
throw new Error(`${RYNX_SYSTEMD_SERVICE} lifecycle is not active: ` +
|
|
470
|
+
`ActiveState=${state.activeState}, SubState=${state.subState}`);
|
|
471
|
+
}
|
|
472
|
+
return process;
|
|
473
|
+
}
|
|
474
|
+
function assertSingleGod(inspection) {
|
|
475
|
+
if (inspection.kind !== "absent" && inspection.processes.length !== 1) {
|
|
476
|
+
throw new Error(`multiple PM2 supervisors use this RYNX_HOME: ${describeInspection(inspection)}`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
function describeInspection(inspection) {
|
|
480
|
+
if (inspection.kind === "absent")
|
|
481
|
+
return "absent";
|
|
482
|
+
return inspection.processes
|
|
483
|
+
.map((entry) => `${entry.pid}@${entry.cgroup}`)
|
|
484
|
+
.join(", ");
|
|
485
|
+
}
|
|
486
|
+
function inspectLinuxLinger() {
|
|
487
|
+
const result = run("loginctl", [
|
|
488
|
+
"show-user",
|
|
489
|
+
userInfo().username,
|
|
490
|
+
"--property=Linger",
|
|
491
|
+
]);
|
|
492
|
+
if (result.status !== 0)
|
|
493
|
+
return undefined;
|
|
494
|
+
const value = result.stdout?.trim();
|
|
495
|
+
if (value === "Linger=yes")
|
|
496
|
+
return true;
|
|
497
|
+
if (value === "Linger=no")
|
|
498
|
+
return false;
|
|
499
|
+
return undefined;
|
|
500
|
+
}
|
|
501
|
+
function normalizedCgroupPath(raw) {
|
|
502
|
+
const trimmed = raw.trim();
|
|
503
|
+
if (!trimmed)
|
|
504
|
+
return undefined;
|
|
505
|
+
if (trimmed.startsWith("/"))
|
|
506
|
+
return trimmed;
|
|
507
|
+
let unified;
|
|
508
|
+
for (const line of trimmed.split(/\r?\n/)) {
|
|
509
|
+
const first = line.indexOf(":");
|
|
510
|
+
const second = first < 0 ? -1 : line.indexOf(":", first + 1);
|
|
511
|
+
if (second < 0)
|
|
512
|
+
continue;
|
|
513
|
+
const hierarchy = line.slice(0, first);
|
|
514
|
+
const controllers = line.slice(first + 1, second);
|
|
515
|
+
const cgroup = line.slice(second + 1).trim();
|
|
516
|
+
if (!cgroup)
|
|
517
|
+
continue;
|
|
518
|
+
if (controllers.split(",").includes("name=systemd"))
|
|
519
|
+
return cgroup;
|
|
520
|
+
if (hierarchy === "0" && controllers === "")
|
|
521
|
+
unified = cgroup;
|
|
522
|
+
}
|
|
523
|
+
return unified;
|
|
524
|
+
}
|
|
525
|
+
function cgroupHasService(cgroup) {
|
|
526
|
+
return cgroup?.split("/").includes(RYNX_SYSTEMD_SERVICE) === true;
|
|
527
|
+
}
|
|
528
|
+
function linuxProcStartIdentity(raw) {
|
|
529
|
+
const closeParen = raw.lastIndexOf(")");
|
|
530
|
+
if (closeParen < 0)
|
|
531
|
+
return undefined;
|
|
532
|
+
return raw.slice(closeParen + 2).trim().split(/\s+/)[19];
|
|
533
|
+
}
|
|
534
|
+
function isDedicatedPm2God(cmdline, home) {
|
|
535
|
+
return cmdline.includes("PM2 v") && cmdline.includes(`God Daemon (${home})`);
|
|
536
|
+
}
|
|
537
|
+
function processDisappeared(error) {
|
|
538
|
+
const code = error?.code;
|
|
539
|
+
return code === "ENOENT" || code === "ESRCH" || code === "ENOTDIR";
|
|
540
|
+
}
|
|
541
|
+
function procReadError(file, error) {
|
|
542
|
+
return new Error(`cannot inspect ${file}: ${errorMessage(error)}`);
|
|
543
|
+
}
|
|
544
|
+
function assertUserSystemdAvailable() {
|
|
545
|
+
if (!linuxUserSystemdAvailable()) {
|
|
546
|
+
throw new Error("user systemd is unavailable in this login session");
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
function systemctlUser(args, timeout = COMMAND_TIMEOUT_MS) {
|
|
550
|
+
const result = run("systemctl", ["--user", ...args], timeout);
|
|
551
|
+
requireSuccess(result, `systemctl --user ${args.join(" ")}`);
|
|
552
|
+
return result.stdout ?? "";
|
|
553
|
+
}
|
|
554
|
+
function replaceFileAtomically(file, content) {
|
|
555
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
556
|
+
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
|
|
557
|
+
try {
|
|
558
|
+
writeFileSync(temporary, content, {
|
|
559
|
+
encoding: "utf8",
|
|
560
|
+
flag: "wx",
|
|
561
|
+
// The generated unit contains the caller's environment and may include
|
|
562
|
+
// runtime credentials. Keep the snapshot private to the owning user.
|
|
563
|
+
mode: 0o600,
|
|
564
|
+
});
|
|
565
|
+
renameSync(temporary, file);
|
|
566
|
+
}
|
|
567
|
+
finally {
|
|
568
|
+
rmSync(temporary, { force: true });
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
function run(command, args, timeout = COMMAND_TIMEOUT_MS) {
|
|
572
|
+
return spawnSync(command, [...args], {
|
|
573
|
+
encoding: "utf8",
|
|
574
|
+
stdio: "pipe",
|
|
575
|
+
timeout,
|
|
576
|
+
killSignal: "SIGTERM",
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
function requireSuccess(result, description) {
|
|
580
|
+
if (result.status === 0)
|
|
581
|
+
return;
|
|
582
|
+
throw new Error(`${description} failed: ${commandDetail(result)}`);
|
|
583
|
+
}
|
|
584
|
+
function commandDetail(result) {
|
|
585
|
+
return result.error?.message
|
|
586
|
+
|| result.stderr?.trim()
|
|
587
|
+
|| result.stdout?.trim()
|
|
588
|
+
|| `exit ${result.status ?? "unknown"}`;
|
|
589
|
+
}
|
|
590
|
+
function errorMessage(error) {
|
|
591
|
+
return error instanceof Error ? error.message : String(error);
|
|
592
|
+
}
|
|
593
|
+
function signalMatches(value, number, name) {
|
|
594
|
+
return value === number || value === name;
|
|
595
|
+
}
|
|
596
|
+
function defaultLinuxPath() {
|
|
597
|
+
return "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
|
598
|
+
}
|
|
599
|
+
function renderInheritedEnvironment(env) {
|
|
600
|
+
if (!env)
|
|
601
|
+
return "";
|
|
602
|
+
const requested = new Set((env[RYNX_SERVICE_ENV_KEYS] ?? "")
|
|
603
|
+
.split(/[\s,]+/)
|
|
604
|
+
.filter((name) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name)));
|
|
605
|
+
return Object.entries(env)
|
|
606
|
+
.filter(([name, value]) => value !== undefined &&
|
|
607
|
+
/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) &&
|
|
608
|
+
(AUTOMATIC_SERVICE_ENVIRONMENT.has(name) || requested.has(name)) &&
|
|
609
|
+
!name.startsWith("npm_") &&
|
|
610
|
+
!name.startsWith("RYNX_RUNNER_") &&
|
|
611
|
+
!BLOCKED_SERVICE_ENVIRONMENT.has(name) &&
|
|
612
|
+
!TRANSIENT_SERVICE_ENVIRONMENT.has(name))
|
|
613
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
614
|
+
.map(([name, value]) => `Environment=${systemdQuote(`${name}=${value}`)}`)
|
|
615
|
+
.join("\n");
|
|
616
|
+
}
|
|
617
|
+
function systemdQuote(value) {
|
|
618
|
+
return `"${value
|
|
619
|
+
.replaceAll("\\", "\\\\")
|
|
620
|
+
.replaceAll("\"", "\\\"")
|
|
621
|
+
.replaceAll("%", "%%")
|
|
622
|
+
.replaceAll("\t", "\\t")
|
|
623
|
+
.replaceAll("\n", "\\n")
|
|
624
|
+
.replaceAll("\r", "\\r")}"`;
|
|
625
|
+
}
|
|
626
|
+
function systemdPath(value) {
|
|
627
|
+
return value
|
|
628
|
+
.replaceAll("%", "%%")
|
|
629
|
+
.replaceAll("\\", "\\x5c")
|
|
630
|
+
.replaceAll(" ", "\\x20")
|
|
631
|
+
.replaceAll("\t", "\\t")
|
|
632
|
+
.replaceAll("\n", "\\n")
|
|
633
|
+
.replaceAll("\r", "\\r")
|
|
634
|
+
.replaceAll('"', "\\x22")
|
|
635
|
+
.replaceAll("'", "\\x27");
|
|
636
|
+
}
|