@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.
- package/CHANGELOG.md +340 -0
- package/README.md +137 -548
- package/index.js +80 -35
- package/package.json +161 -33
- package/src/Arr.js +1050 -418
- package/src/Async.js +817 -0
- package/src/Cache.js +433 -0
- package/src/Checker.js +1202 -539
- package/src/Cli.js +1293 -0
- package/src/Color.js +556 -0
- package/src/Crypto.js +1082 -0
- package/src/Emitter.js +356 -0
- package/src/Env.js +689 -0
- package/src/FS.js +1626 -894
- package/src/Func.js +823 -356
- package/src/Http.js +700 -0
- package/src/Id.js +568 -200
- package/src/Logger.js +963 -378
- package/src/Num.js +965 -360
- package/src/Obj.js +1077 -379
- package/src/SQLite.js +1629 -1005
- package/src/Schema.js +2101 -0
- package/src/Str.js +1209 -432
- package/src/Sys.js +654 -0
- package/src/Time.js +1533 -440
- package/src/Utils.js +342 -325
- package/src/errors.js +335 -0
- package/src/internal/shared.js +415 -0
- package/types/index.d.ts +735 -526
- package/types/src/Arr.d.ts +533 -145
- package/types/src/Arr.d.ts.map +1 -1
- package/types/src/Async.d.ts +372 -0
- package/types/src/Async.d.ts.map +1 -0
- package/types/src/Cache.d.ts +221 -0
- package/types/src/Cache.d.ts.map +1 -0
- package/types/src/Checker.d.ts +651 -232
- package/types/src/Checker.d.ts.map +1 -1
- package/types/src/Cli.d.ts +664 -0
- package/types/src/Cli.d.ts.map +1 -0
- package/types/src/Color.d.ts +300 -0
- package/types/src/Color.d.ts.map +1 -0
- package/types/src/Crypto.d.ts +620 -0
- package/types/src/Crypto.d.ts.map +1 -0
- package/types/src/Emitter.d.ts +189 -0
- package/types/src/Emitter.d.ts.map +1 -0
- package/types/src/Env.d.ts +412 -0
- package/types/src/Env.d.ts.map +1 -0
- package/types/src/FS.d.ts +717 -238
- package/types/src/FS.d.ts.map +1 -1
- package/types/src/Func.d.ts +508 -135
- package/types/src/Func.d.ts.map +1 -1
- package/types/src/Http.d.ts +444 -0
- package/types/src/Http.d.ts.map +1 -0
- package/types/src/Id.d.ts +199 -66
- package/types/src/Id.d.ts.map +1 -1
- package/types/src/Logger.d.ts +414 -46
- package/types/src/Logger.d.ts.map +1 -1
- package/types/src/Num.d.ts +514 -137
- package/types/src/Num.d.ts.map +1 -1
- package/types/src/Obj.d.ts +453 -104
- package/types/src/Obj.d.ts.map +1 -1
- package/types/src/SQLite.d.ts +754 -308
- package/types/src/SQLite.d.ts.map +1 -1
- package/types/src/Schema.d.ts +1189 -0
- package/types/src/Schema.d.ts.map +1 -0
- package/types/src/Str.d.ts +617 -166
- package/types/src/Str.d.ts.map +1 -1
- package/types/src/Sys.d.ts +314 -0
- package/types/src/Sys.d.ts.map +1 -0
- package/types/src/Time.d.ts +759 -204
- package/types/src/Time.d.ts.map +1 -1
- package/types/src/Utils.d.ts +158 -102
- package/types/src/Utils.d.ts.map +1 -1
- package/types/src/errors.d.ts +316 -0
- package/types/src/errors.d.ts.map +1 -0
- package/types/src/internal/shared.d.ts +123 -0
- package/types/src/internal/shared.d.ts.map +1 -0
- 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
|
+
};
|