@ix-xs/node-comfort 1.2.0 → 2.1.0

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +340 -0
  2. package/README.md +137 -548
  3. package/index.js +80 -35
  4. package/package.json +161 -33
  5. package/src/Arr.js +1050 -418
  6. package/src/Async.js +817 -0
  7. package/src/Cache.js +433 -0
  8. package/src/Checker.js +1202 -539
  9. package/src/Cli.js +1293 -0
  10. package/src/Color.js +556 -0
  11. package/src/Crypto.js +1082 -0
  12. package/src/Emitter.js +356 -0
  13. package/src/Env.js +689 -0
  14. package/src/FS.js +1626 -894
  15. package/src/Func.js +823 -356
  16. package/src/Http.js +700 -0
  17. package/src/Id.js +568 -200
  18. package/src/Logger.js +963 -378
  19. package/src/Num.js +965 -360
  20. package/src/Obj.js +1077 -379
  21. package/src/SQLite.js +1629 -1005
  22. package/src/Schema.js +2101 -0
  23. package/src/Str.js +1209 -432
  24. package/src/Sys.js +654 -0
  25. package/src/Time.js +1533 -440
  26. package/src/Utils.js +342 -325
  27. package/src/errors.js +335 -0
  28. package/src/internal/shared.js +415 -0
  29. package/types/index.d.ts +735 -526
  30. package/types/src/Arr.d.ts +533 -145
  31. package/types/src/Arr.d.ts.map +1 -1
  32. package/types/src/Async.d.ts +372 -0
  33. package/types/src/Async.d.ts.map +1 -0
  34. package/types/src/Cache.d.ts +221 -0
  35. package/types/src/Cache.d.ts.map +1 -0
  36. package/types/src/Checker.d.ts +651 -232
  37. package/types/src/Checker.d.ts.map +1 -1
  38. package/types/src/Cli.d.ts +664 -0
  39. package/types/src/Cli.d.ts.map +1 -0
  40. package/types/src/Color.d.ts +300 -0
  41. package/types/src/Color.d.ts.map +1 -0
  42. package/types/src/Crypto.d.ts +620 -0
  43. package/types/src/Crypto.d.ts.map +1 -0
  44. package/types/src/Emitter.d.ts +189 -0
  45. package/types/src/Emitter.d.ts.map +1 -0
  46. package/types/src/Env.d.ts +412 -0
  47. package/types/src/Env.d.ts.map +1 -0
  48. package/types/src/FS.d.ts +717 -238
  49. package/types/src/FS.d.ts.map +1 -1
  50. package/types/src/Func.d.ts +508 -135
  51. package/types/src/Func.d.ts.map +1 -1
  52. package/types/src/Http.d.ts +444 -0
  53. package/types/src/Http.d.ts.map +1 -0
  54. package/types/src/Id.d.ts +199 -66
  55. package/types/src/Id.d.ts.map +1 -1
  56. package/types/src/Logger.d.ts +414 -46
  57. package/types/src/Logger.d.ts.map +1 -1
  58. package/types/src/Num.d.ts +514 -137
  59. package/types/src/Num.d.ts.map +1 -1
  60. package/types/src/Obj.d.ts +453 -104
  61. package/types/src/Obj.d.ts.map +1 -1
  62. package/types/src/SQLite.d.ts +754 -308
  63. package/types/src/SQLite.d.ts.map +1 -1
  64. package/types/src/Schema.d.ts +1189 -0
  65. package/types/src/Schema.d.ts.map +1 -0
  66. package/types/src/Str.d.ts +617 -166
  67. package/types/src/Str.d.ts.map +1 -1
  68. package/types/src/Sys.d.ts +314 -0
  69. package/types/src/Sys.d.ts.map +1 -0
  70. package/types/src/Time.d.ts +759 -204
  71. package/types/src/Time.d.ts.map +1 -1
  72. package/types/src/Utils.d.ts +158 -102
  73. package/types/src/Utils.d.ts.map +1 -1
  74. package/types/src/errors.d.ts +316 -0
  75. package/types/src/errors.d.ts.map +1 -0
  76. package/types/src/internal/shared.d.ts +123 -0
  77. package/types/src/internal/shared.d.ts.map +1 -0
  78. package/types/index.d.ts.map +0 -1
package/src/Sys.js ADDED
@@ -0,0 +1,654 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Processes and the system: run commands and get their output, find
5
+ * executables, shut down cleanly, open URLs, and learn about the machine.
6
+ * Behaves the same on Windows, macOS and Linux; on Windows,
7
+ * `run("npm", [...])` finds `npm.cmd` and escapes arguments safely.
8
+ *
9
+ * @example
10
+ * const { stdout } = await sys.run("git", ["rev-parse", "HEAD"]);
11
+ * sys.onShutdown(async () => {
12
+ * await server.close();
13
+ * await db.close();
14
+ * });
15
+ */
16
+
17
+ const fs = require("node:fs");
18
+ const os = require("node:os");
19
+ const nodePath = require("node:path");
20
+ const { ProcessError, AbortError } = require("./errors.js");
21
+ const { toMs } = require("./internal/shared.js");
22
+
23
+ // Loaded on first use: most programs never start another one.
24
+ /** @type {typeof import("node:child_process") | undefined} */
25
+ let _childProcess;
26
+ const _cp = () => (_childProcess ??= require("node:child_process"));
27
+
28
+ /**
29
+ * Options for `run()` and `exec()`.
30
+ * @typedef {object} RunOptions
31
+ * @property {string} [cwd] Where to run the command.
32
+ * @property {Record<string, string | undefined>} [env] Extra environment variables, added to `process.env`. `undefined` removes one.
33
+ * @property {boolean} [extendEnv] Set to `false` to pass only `env`, without `process.env`.
34
+ * @property {string | Buffer} [input] Written to the command's stdin.
35
+ * @property {number | string} [timeout] Kill the command after this long, like `"30s"`. On Windows, what it started is stopped with it.
36
+ * @property {AbortSignal} [signal] Kills the command when aborted. On Windows, what it started is stopped with it.
37
+ * @property {"pipe" | "inherit"} [stdio] `"inherit"` shows the output live instead of capturing it. Defaults to `"pipe"`.
38
+ * @property {(line: string) => void} [onStdout] Called for each line of stdout.
39
+ * @property {(line: string) => void} [onStderr] Called for each line of stderr.
40
+ * @property {boolean} [reject] Throw when the exit code isn't 0. Defaults to `true`.
41
+ * @property {boolean} [trim] Drop the final newline of the output. Defaults to `true`.
42
+ */
43
+
44
+ /**
45
+ * A finished command.
46
+ * @typedef {object} RunResult
47
+ * @property {string} command The command line that ran.
48
+ * @property {string} stdout
49
+ * @property {string} stderr
50
+ * @property {number | null} exitCode `null` if a signal killed it.
51
+ * @property {string | null} signal
52
+ * @property {number} duration In milliseconds.
53
+ */
54
+
55
+ /**
56
+ * Options for `onShutdown()`. They apply to all handlers.
57
+ * @typedef {object} ShutdownOptions
58
+ * @property {number | string} [timeout] How long the handlers get before the exit is forced. Defaults to 10 seconds.
59
+ * @property {number} [exitCode] Exit code after a clean shutdown. Defaults to `0`.
60
+ * @property {NodeJS.Signals[]} [signals] Signals that trigger it. Defaults to `SIGINT`, `SIGTERM` and `SIGHUP`.
61
+ */
62
+
63
+ /**
64
+ * Why the process is stopping: the signal it got, or `"shutdown"` when
65
+ * `sys.shutdown()` was called.
66
+ * @typedef {NodeJS.Signals | "shutdown"} ShutdownReason
67
+ */
68
+
69
+ /**
70
+ * Returned by `info()`.
71
+ * @typedef {object} SystemInfo
72
+ * @property {NodeJS.Platform} platform `"win32"`, `"darwin"`, `"linux"`...
73
+ * @property {string} arch `"x64"`, `"arm64"`...
74
+ * @property {string} release OS release.
75
+ * @property {string} node Node.js version.
76
+ * @property {number} cpus Number of logical CPUs.
77
+ * @property {string} cpuModel
78
+ * @property {{ total: number, free: number, used: number }} memory System memory, in bytes.
79
+ * @property {number} uptime In seconds.
80
+ * @property {string} hostname
81
+ * @property {string} user
82
+ * @property {number} pid
83
+ * @property {boolean} ci Running in CI.
84
+ * @property {boolean} docker Running in a container.
85
+ * @property {boolean} wsl Running in WSL.
86
+ */
87
+
88
+ const META = /([()\][%!^"`<>&|;, *?])/g;
89
+
90
+ /** @param {string} command */
91
+ const _escapeCommand = (command) => command.replace(META, "^$1");
92
+
93
+ /**
94
+ * Escapes an argument for cmd.exe, like `cross-spawn` does.
95
+ * @param {string} arg
96
+ * @param {boolean} doubleEscape
97
+ */
98
+ const _escapeArg = (arg, doubleEscape) => {
99
+ let out = String(arg).replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1");
100
+ out = `"${out}"`.replace(META, "^$1");
101
+ return doubleEscape ? out.replace(META, "^$1") : out;
102
+ };
103
+
104
+ /**
105
+ * Handles Windows `.cmd` and `.bat` files.
106
+ * @param {string} file
107
+ * @param {string[]} args
108
+ * @returns {{ file: string, args: string[], verbatim: boolean }}
109
+ */
110
+ const _prepare = (file, args) => {
111
+ if (process.platform !== "win32") return { file, args, verbatim: false };
112
+ const resolved = which(file) ?? file;
113
+ if (!/\.(cmd|bat)$/i.test(resolved)) return { file: resolved, args, verbatim: false };
114
+ const shim = /node_modules[\\/]\.bin[\\/][^\\/]+\.cmd$/i.test(resolved);
115
+ const line = [_escapeCommand(nodePath.normalize(resolved)), ...args.map((a) => _escapeArg(a, shim))].join(" ");
116
+ return { file: process.env.comspec || "cmd.exe", args: ["/d", "/s", "/c", `"${line}"`], verbatim: true };
117
+ };
118
+
119
+ /**
120
+ * @param {RunOptions} options
121
+ * @returns {Record<string, string | undefined> | undefined}
122
+ */
123
+ const _env = (options) => {
124
+ if (options.extendEnv === false) return options.env;
125
+ if (!options.env) return process.env;
126
+ /** @type {Record<string, string | undefined>} */
127
+ const merged = { ...process.env };
128
+ if (process.platform === "win32") {
129
+ // Windows reads PATH and Path as one variable: ours has to replace it, not sit next to it.
130
+ const given = new Set(Object.keys(options.env).map((key) => key.toUpperCase()));
131
+ for (const key of Object.keys(merged)) if (given.has(key.toUpperCase())) delete merged[key];
132
+ }
133
+ return Object.assign(merged, options.env);
134
+ };
135
+
136
+ /**
137
+ * @param {string} file
138
+ * @param {string[]} args
139
+ * @param {boolean} shell
140
+ * @param {string} display
141
+ * @param {RunOptions} options
142
+ * @returns {Promise<RunResult>}
143
+ */
144
+ const _spawn = (file, args, shell, display, options) =>
145
+ new Promise((resolve, reject) => {
146
+ const started = Date.now();
147
+ const inherit = options.stdio === "inherit";
148
+ const prepared = shell ? { file, args, verbatim: false } : _prepare(file, args);
149
+ const timeoutMs = options.timeout === undefined ? undefined : toMs(options.timeout, "timeout");
150
+ if (options.signal?.aborted) return reject(new AbortError(`Command aborted: ${display}`, { cause: options.signal.reason }));
151
+
152
+ const child = _cp().spawn(prepared.file, prepared.args, {
153
+ cwd: options.cwd,
154
+ env: /** @type {NodeJS.ProcessEnv} */ (_env(options)),
155
+ shell,
156
+ windowsHide: true,
157
+ windowsVerbatimArguments: prepared.verbatim,
158
+ stdio: [options.input !== undefined ? "pipe" : inherit ? "inherit" : "ignore", inherit ? "inherit" : "pipe", inherit ? "inherit" : "pipe"],
159
+ });
160
+
161
+ let stdout = "";
162
+ let stderr = "";
163
+ let timedOut = false;
164
+ /** @type {NodeJS.Timeout | undefined} */
165
+ let killTimer;
166
+ /** @type {NodeJS.Timeout | undefined} */
167
+ let forceTimer;
168
+
169
+ /**
170
+ * @param {import("node:stream").Readable | null} stream
171
+ * @param {(chunk: string) => void} append
172
+ * @param {((line: string) => void) | undefined} onLine
173
+ */
174
+ const collect = (stream, append, onLine) => {
175
+ if (!stream) return;
176
+ stream.setEncoding("utf8");
177
+ let pending = "";
178
+ stream.on("data", (chunk) => {
179
+ append(chunk);
180
+ if (!onLine) return;
181
+ pending += chunk;
182
+ const lines = pending.split(/\r?\n/);
183
+ pending = lines.pop() ?? "";
184
+ for (const line of lines) onLine(line);
185
+ });
186
+ stream.on("end", () => {
187
+ if (onLine && pending) onLine(pending);
188
+ });
189
+ };
190
+ collect(child.stdout, (c) => (stdout += c), options.onStdout);
191
+ collect(child.stderr, (c) => (stderr += c), options.onStderr);
192
+
193
+ if (options.input !== undefined && child.stdin) {
194
+ child.stdin.on("error", () => {});
195
+ child.stdin.end(options.input);
196
+ }
197
+
198
+ let stopped = false;
199
+ const kill = () => {
200
+ stopped = true;
201
+ if (process.platform === "win32" && child.pid !== undefined) {
202
+ // On Windows, child.kill() ends cmd.exe and leaves what it started running.
203
+ const killer = _cp().spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
204
+ killer.on("error", () => child.kill());
205
+ return;
206
+ }
207
+ child.kill("SIGTERM");
208
+ forceTimer = setTimeout(() => child.kill("SIGKILL"), 2000);
209
+ forceTimer.unref();
210
+ };
211
+ if (timeoutMs !== undefined && timeoutMs !== Infinity) {
212
+ killTimer = setTimeout(() => {
213
+ timedOut = true;
214
+ kill();
215
+ }, timeoutMs);
216
+ }
217
+ const onAbort = () => kill();
218
+ options.signal?.addEventListener("abort", onAbort, { once: true });
219
+
220
+ child.on("error", (error) => {
221
+ clearTimeout(killTimer);
222
+ options.signal?.removeEventListener("abort", onAbort);
223
+ reject(new ProcessError(`Failed to start "${display}": ${error.message}`, { command: display, cause: error }));
224
+ });
225
+ child.on("exit", () => {
226
+ // What the command started may outlive it and keep its output open:
227
+ // once the command was stopped on purpose, there is nothing more to wait for.
228
+ if (!stopped) return;
229
+ child.stdout?.destroy();
230
+ child.stderr?.destroy();
231
+ });
232
+ child.on("close", (exitCode, signal) => {
233
+ clearTimeout(killTimer);
234
+ clearTimeout(forceTimer);
235
+ options.signal?.removeEventListener("abort", onAbort);
236
+ const trim = options.trim ?? true;
237
+ const result = {
238
+ command: display,
239
+ stdout: trim ? stdout.replace(/\r?\n$/, "") : stdout,
240
+ stderr: trim ? stderr.replace(/\r?\n$/, "") : stderr,
241
+ exitCode,
242
+ signal,
243
+ duration: Date.now() - started,
244
+ };
245
+ if (options.signal?.aborted) return reject(new AbortError(`Command aborted: ${display}`, { cause: options.signal.reason }));
246
+ if (timedOut) return reject(new ProcessError(`Command timed out after ${timeoutMs}ms: ${display}`, { ...result, timedOut: true }));
247
+ if ((options.reject ?? true) && exitCode !== 0) {
248
+ const detail = result.stderr.trim().split(/\r?\n/).slice(-5).join("\n");
249
+ return reject(new ProcessError(`Command failed with ${exitCode === null ? `signal ${signal}` : `exit code ${exitCode}`}: ${display}${detail ? `\n${detail}` : ""}`, result));
250
+ }
251
+ resolve(result);
252
+ });
253
+ });
254
+
255
+ /**
256
+ * Runs a program without a shell, so arguments are passed as they are and
257
+ * user input can't inject anything. Resolves with the output; rejects with
258
+ * a `ProcessError` if the exit code isn't 0.
259
+ *
260
+ * @example
261
+ * const { stdout } = await sys.run("git", ["log", "-1", "--format=%s"]);
262
+ * await sys.run("npm", ["install"], { cwd: "./app", stdio: "inherit" });
263
+ * await sys.run("ffmpeg", ["-i", input, output], { timeout: "5m" });
264
+ *
265
+ * @param {string} file A name found in `PATH`, or a path.
266
+ * @param {string[]} [args=[]]
267
+ * @param {RunOptions} [options]
268
+ * @returns {Promise<RunResult>}
269
+ * @throws {ProcessError} If it can't start, fails (unless `reject: false`) or times out.
270
+ */
271
+ function run(file, args = [], options = {}) {
272
+ const display = [file, ...args.map((a) => (/\s/.test(a) ? JSON.stringify(a) : a))].join(" ");
273
+ return _spawn(file, args.map(String), false, display, options);
274
+ }
275
+
276
+ /**
277
+ * Runs a command line through the shell, so pipes, `&&` and variables
278
+ * work. Don't build it from untrusted input; use `run()` for that.
279
+ *
280
+ * @example
281
+ * const { stdout } = await sys.exec("git status --short | wc -l");
282
+ * const { exitCode } = await sys.exec("npm test", { reject: false, stdio: "inherit" });
283
+ *
284
+ * @param {string} command
285
+ * @param {RunOptions} [options]
286
+ * @returns {Promise<RunResult>}
287
+ * @throws {ProcessError} If it fails (unless `reject: false`) or times out.
288
+ */
289
+ function exec(command, options = {}) {
290
+ return _spawn(command, [], true, command, options);
291
+ }
292
+
293
+ /** @type {Map<string, string>} */
294
+ const _found = new Map();
295
+
296
+ /** @param {string} file */
297
+ const _runnable = (file) => {
298
+ try {
299
+ if (!fs.statSync(file).isFile()) return false;
300
+ if (process.platform !== "win32") fs.accessSync(file, fs.constants.X_OK);
301
+ return true;
302
+ } catch {
303
+ return false;
304
+ }
305
+ };
306
+
307
+ /**
308
+ * Finds an executable in `PATH`, like the `which` command.
309
+ *
310
+ * @example
311
+ * sys.which("git"); // "/usr/bin/git"
312
+ * sys.which("nope"); // undefined
313
+ *
314
+ * @param {string} command
315
+ * @returns {string | undefined}
316
+ */
317
+ function which(command) {
318
+ if (typeof command !== "string" || !command) return undefined;
319
+ const isWin = process.platform === "win32";
320
+ const searchPath = process.env.PATH ?? process.env.Path ?? "";
321
+ const key = `${command}\0${searchPath}\0${process.cwd()}`;
322
+ const known = _found.get(key);
323
+ // A found program is checked again with one call, in case it was removed since.
324
+ if (known !== undefined && _runnable(known)) return known;
325
+ const exts = isWin ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";").filter(Boolean) : [""];
326
+ const candidates = (/** @type {string} */ base) => (isWin && !nodePath.extname(base) ? exts.map((e) => base + e.toLowerCase()) : isWin ? [base, ...exts.map((e) => base + e.toLowerCase())] : [base]);
327
+ /** @type {string | undefined} */
328
+ let found;
329
+ if (command.includes("/") || command.includes("\\")) {
330
+ found = candidates(nodePath.resolve(command)).find(_runnable);
331
+ } else {
332
+ for (const dir of searchPath.split(nodePath.delimiter)) {
333
+ if (!dir) continue;
334
+ found = candidates(nodePath.join(dir.replace(/^"|"$/g, ""), command)).find(_runnable);
335
+ if (found) break;
336
+ }
337
+ }
338
+ if (found) {
339
+ if (_found.size >= 200) _found.clear();
340
+ _found.set(key, found);
341
+ } else {
342
+ _found.delete(key);
343
+ }
344
+ return found;
345
+ }
346
+
347
+ /**
348
+ * Opens a URL, file or folder with the default app.
349
+ *
350
+ * @example
351
+ * sys.open("http://localhost:3000");
352
+ *
353
+ * @param {string} target
354
+ * @returns {boolean} `true` if the opener started.
355
+ */
356
+ function open(target) {
357
+ try {
358
+ const value = /^[a-z][a-z\d+.-]*:/i.test(target) ? target : nodePath.resolve(target);
359
+ const child =
360
+ process.platform === "win32"
361
+ ? _cp().spawn(process.env.comspec || "cmd.exe", ["/d", "/s", "/c", `start "" ${_escapeArg(value, false)}`], { detached: true, stdio: "ignore", windowsHide: true, windowsVerbatimArguments: true })
362
+ : _cp().spawn(process.platform === "darwin" ? "open" : "xdg-open", [value], { detached: true, stdio: "ignore" });
363
+ child.on("error", () => {});
364
+ child.unref();
365
+ return true;
366
+ } catch {
367
+ return false;
368
+ }
369
+ }
370
+
371
+ /** @type {Array<(reason: ShutdownReason) => unknown>} */
372
+ const _shutdownHandlers = [];
373
+ /** @type {Set<string>} */
374
+ const _shutdownSignals = new Set();
375
+ let _shuttingDown = false;
376
+ /** @type {{ timeout: number, exitCode: number }} */
377
+ const _shutdownConfig = { timeout: 10_000, exitCode: 0 };
378
+
379
+ /**
380
+ * Runs cleanup code when the process is asked to stop (Ctrl+C,
381
+ * `docker stop`, `kill`): close servers, flush logs, disconnect databases.
382
+ * Handlers run in reverse order, then the process exits. If they take
383
+ * longer than `timeout`, the exit is forced; a second Ctrl+C exits at once.
384
+ *
385
+ * @example
386
+ * const server = app.listen(3000);
387
+ * sys.onShutdown(() => new Promise((done) => server.close(done)));
388
+ * sys.onShutdown((reason) => log.info(`Stopping (${reason})`));
389
+ *
390
+ * @param {(reason: ShutdownReason) => unknown} handler Can be async. Gets the signal that stopped the process.
391
+ * @param {ShutdownOptions} [options]
392
+ * @returns {() => void} Removes the handler.
393
+ */
394
+ function onShutdown(handler, options = {}) {
395
+ if (options.timeout !== undefined) _shutdownConfig.timeout = toMs(options.timeout, "timeout");
396
+ if (options.exitCode !== undefined) _shutdownConfig.exitCode = options.exitCode;
397
+ _shutdownHandlers.push(handler);
398
+ const signals = options.signals ?? /** @type {NodeJS.Signals[]} */ (["SIGINT", "SIGTERM", "SIGHUP", ...(process.platform === "win32" ? ["SIGBREAK"] : [])]);
399
+ for (const signal of signals) {
400
+ if (_shutdownSignals.has(signal)) continue;
401
+ _shutdownSignals.add(signal);
402
+ try {
403
+ process.on(signal, () => {
404
+ void _shutdown(signal, undefined);
405
+ });
406
+ } catch {
407
+ // unsupported signal on this platform
408
+ }
409
+ }
410
+ return () => {
411
+ const index = _shutdownHandlers.lastIndexOf(handler);
412
+ if (index !== -1) _shutdownHandlers.splice(index, 1);
413
+ };
414
+ }
415
+
416
+ /**
417
+ * @param {ShutdownReason} reason
418
+ * @param {number | undefined} exitCode
419
+ * @returns {Promise<void>}
420
+ */
421
+ const _shutdown = async (reason, exitCode) => {
422
+ if (_shuttingDown) {
423
+ process.exit(1);
424
+ return;
425
+ }
426
+ _shuttingDown = true;
427
+ const force = setTimeout(() => {
428
+ process.stderr.write(`[@ix-xs/node-comfort] Shutdown (${reason}) took longer than ${_shutdownConfig.timeout}ms, forcing exit\n`);
429
+ process.exit(1);
430
+ }, Math.min(_shutdownConfig.timeout, 2 ** 31 - 1));
431
+ // Not unref'd: a handler that never settles must not let the process end
432
+ // quietly with the wrong exit code once nothing else keeps it alive.
433
+ let failed = false;
434
+ for (const handler of [..._shutdownHandlers].reverse()) {
435
+ try {
436
+ await handler(reason);
437
+ } catch (error) {
438
+ failed = true;
439
+ process.stderr.write(`[@ix-xs/node-comfort] Shutdown handler failed: ${error instanceof Error ? error.stack : String(error)}\n`);
440
+ }
441
+ }
442
+ clearTimeout(force);
443
+ process.exit(failed ? 1 : exitCode ?? _shutdownConfig.exitCode);
444
+ };
445
+
446
+ /**
447
+ * Stops the process the clean way: runs the `onShutdown()` handlers, then
448
+ * exits. Use it instead of `process.exit()` when something goes wrong for
449
+ * good, so servers and databases still get closed.
450
+ *
451
+ * @example
452
+ * process.on("uncaughtException", (error) => {
453
+ * log.fatal(error);
454
+ * sys.shutdown(1);
455
+ * });
456
+ *
457
+ * @param {number} [exitCode] Defaults to the `exitCode` given to `onShutdown()`, or `0`. A failing handler makes it `1`.
458
+ * @returns {Promise<void>} Never resolves: the process exits first.
459
+ */
460
+ function shutdown(exitCode) {
461
+ return _shutdown("shutdown", exitCode);
462
+ }
463
+
464
+ /**
465
+ * Are we on Windows?
466
+ * @returns {boolean}
467
+ */
468
+ function isWindows() {
469
+ return process.platform === "win32";
470
+ }
471
+
472
+ /**
473
+ * Are we on macOS?
474
+ * @returns {boolean}
475
+ */
476
+ function isMac() {
477
+ return process.platform === "darwin";
478
+ }
479
+
480
+ /**
481
+ * Are we on Linux? WSL counts.
482
+ * @returns {boolean}
483
+ */
484
+ function isLinux() {
485
+ return process.platform === "linux";
486
+ }
487
+
488
+ /**
489
+ * Are we running in CI? Detects GitHub Actions, GitLab, CircleCI, Jenkins,
490
+ * Azure, Vercel, Netlify and more.
491
+ *
492
+ * @example
493
+ * if (sys.isCI()) nc.setLevel("warn");
494
+ *
495
+ * @returns {boolean}
496
+ */
497
+ function isCI() {
498
+ const env = process.env;
499
+ if (env.CI === "false" || env.CI === "0") return false;
500
+ return Boolean(
501
+ env.CI || env.CONTINUOUS_INTEGRATION || env.BUILD_NUMBER || env.RUN_ID || env.GITHUB_ACTIONS || env.GITLAB_CI ||
502
+ env.CIRCLECI || env.TRAVIS || env.JENKINS_URL || env.TF_BUILD || env.BITBUCKET_BUILD_NUMBER || env.BUILDKITE ||
503
+ env.VERCEL || env.NETLIFY || env.CODEBUILD_BUILD_ID || env.TEAMCITY_VERSION,
504
+ );
505
+ }
506
+
507
+ /** @type {boolean | undefined} */
508
+ let _docker;
509
+
510
+ /**
511
+ * Are we running inside a container?
512
+ *
513
+ * @returns {boolean}
514
+ */
515
+ function isDocker() {
516
+ if (_docker !== undefined) return _docker;
517
+ if (process.platform !== "linux") return (_docker = false);
518
+ try {
519
+ _docker = fs.existsSync("/.dockerenv") || fs.existsSync("/run/.containerenv") || /docker|kubepods|containerd/.test(fs.readFileSync("/proc/self/cgroup", "utf8"));
520
+ } catch {
521
+ _docker = false;
522
+ }
523
+ return _docker;
524
+ }
525
+
526
+ /**
527
+ * Are we running in WSL?
528
+ *
529
+ * @returns {boolean}
530
+ */
531
+ function isWSL() {
532
+ return process.platform === "linux" && (/microsoft/i.test(os.release()) || Boolean(process.env.WSL_DISTRO_NAME));
533
+ }
534
+
535
+ /**
536
+ * Facts about the machine and the runtime, handy for bug reports.
537
+ *
538
+ * @example
539
+ * console.table(sys.info());
540
+ *
541
+ * @returns {SystemInfo}
542
+ */
543
+ function info() {
544
+ const cpus = os.cpus();
545
+ let user = "";
546
+ try {
547
+ user = os.userInfo().username;
548
+ } catch {
549
+ user = process.env.USER ?? process.env.USERNAME ?? "";
550
+ }
551
+ return {
552
+ platform: process.platform,
553
+ arch: process.arch,
554
+ release: os.release(),
555
+ node: process.version,
556
+ cpus: cpus.length,
557
+ cpuModel: cpus[0]?.model?.trim() ?? "unknown",
558
+ memory: { total: os.totalmem(), free: os.freemem(), used: os.totalmem() - os.freemem() },
559
+ uptime: os.uptime(),
560
+ hostname: os.hostname(),
561
+ user,
562
+ pid: process.pid,
563
+ ci: isCI(),
564
+ docker: isDocker(),
565
+ wsl: isWSL(),
566
+ };
567
+ }
568
+
569
+ /**
570
+ * Finds a TCP port nobody is listening on. Give a preferred port to get it
571
+ * back when it's free, and another one when it's taken.
572
+ *
573
+ * @example
574
+ * const port = await sys.freePort(3000); // 3000, or a free one if 3000 is in use
575
+ * app.listen(port);
576
+ *
577
+ * @param {number} [preferred] Tried first.
578
+ * @param {string} [host] The address to check. Defaults to all of them.
579
+ * @returns {Promise<number>}
580
+ */
581
+ function freePort(preferred, host) {
582
+ const net = require("node:net");
583
+ /**
584
+ * @param {number} port
585
+ * @returns {Promise<number>}
586
+ */
587
+ const listen = (port) =>
588
+ new Promise((resolve, reject) => {
589
+ const server = net.createServer();
590
+ server.unref();
591
+ server.once("error", reject);
592
+ server.listen({ port, host, exclusive: true }, () => {
593
+ const address = server.address();
594
+ server.close(() => resolve(address !== null && typeof address === "object" ? address.port : port));
595
+ });
596
+ });
597
+ if (preferred === undefined) return listen(0);
598
+ if (!Number.isInteger(preferred) || preferred < 0 || preferred > 65535) return Promise.reject(new RangeError(`freePort(): expected a port from 0 to 65535, got ${preferred}`));
599
+ return listen(preferred).catch(() => listen(0));
600
+ }
601
+
602
+ /**
603
+ * The address of this machine on the local network, like `"192.168.1.20"`.
604
+ * It's what you print next to `localhost` so a phone on the same Wi-Fi can
605
+ * reach a dev server.
606
+ *
607
+ * @example
608
+ * console.log(`http://${sys.localIP() ?? "localhost"}:3000`);
609
+ *
610
+ * @param {4 | 6} [family=4] IPv4 or IPv6.
611
+ * @returns {string | undefined} `undefined` when the machine has no network.
612
+ */
613
+ function localIP(family = 4) {
614
+ const wanted = family === 6 ? "IPv6" : "IPv4";
615
+ for (const addresses of Object.values(os.networkInterfaces())) {
616
+ for (const address of addresses ?? []) {
617
+ // fe80:: addresses only work with an interface name attached, so they're no use in a URL
618
+ if (address.family === wanted && !address.internal && !address.address.startsWith("fe80:")) return address.address;
619
+ }
620
+ }
621
+ return undefined;
622
+ }
623
+
624
+ /**
625
+ * Memory used by this process, in bytes.
626
+ *
627
+ * @example
628
+ * nc.num.formatBytes(sys.memory().heapUsed); // "42.1 MB"
629
+ *
630
+ * @returns {{ rss: number, heapTotal: number, heapUsed: number, external: number, arrayBuffers: number }}
631
+ */
632
+ function memory() {
633
+ const usage = process.memoryUsage();
634
+ return { rss: usage.rss, heapTotal: usage.heapTotal, heapUsed: usage.heapUsed, external: usage.external, arrayBuffers: usage.arrayBuffers };
635
+ }
636
+
637
+ module.exports = {
638
+ run,
639
+ exec,
640
+ which,
641
+ open,
642
+ onShutdown,
643
+ shutdown,
644
+ freePort,
645
+ localIP,
646
+ isWindows,
647
+ isMac,
648
+ isLinux,
649
+ isCI,
650
+ isDocker,
651
+ isWSL,
652
+ info,
653
+ memory,
654
+ };