@tbrandenburg/node-red-cli 0.2.3

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/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@tbrandenburg/node-red-cli",
3
+ "version": "0.2.3",
4
+ "description": "Call existing Node-RED flows from Node.js and the command line",
5
+ "main": "src/link-call.js",
6
+ "bin": {
7
+ "node-red-cli": "bin/node-red-cli.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "README.md",
13
+ "LICENSE"
14
+ ],
15
+ "scripts": {
16
+ "test": "node --test",
17
+ "lint": "eslint .",
18
+ "format": "prettier --check .",
19
+ "format:write": "prettier --write .",
20
+ "prepublishOnly": "make ci"
21
+ },
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/tbrandenburg/node-red-cli.git"
28
+ },
29
+ "homepage": "https://github.com/tbrandenburg/node-red-cli#readme",
30
+ "bugs": {
31
+ "url": "https://github.com/tbrandenburg/node-red-cli/issues"
32
+ },
33
+ "author": "Tom Brandenburg",
34
+ "keywords": [
35
+ "node-red",
36
+ "cli",
37
+ "link-in",
38
+ "link-out",
39
+ "automation",
40
+ "workflow",
41
+ "flow"
42
+ ],
43
+ "license": "MIT",
44
+ "engines": {
45
+ "node": ">=24"
46
+ },
47
+ "dependencies": {
48
+ "commander": "^15.0.0",
49
+ "node-red": "5.0.6"
50
+ },
51
+ "overrides": {
52
+ "qs": "6.16.0"
53
+ },
54
+ "devDependencies": {
55
+ "@eslint/js": "^10.0.1",
56
+ "eslint": "^10.9.1",
57
+ "prettier": "^3.9.6"
58
+ }
59
+ }
@@ -0,0 +1,129 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Parses a single `key=value` pair as used by `--set key=value`.
5
+ *
6
+ * The value is JSON-parsed when possible (numbers, booleans, null, objects,
7
+ * arrays), so `--set x=4` yields the number `4`, not the string `"4"`. Values
8
+ * that aren't valid JSON (e.g. `--set name=alice`) are kept as plain strings.
9
+ */
10
+ function parseSetParam(pair) {
11
+ if (typeof pair !== "string") {
12
+ throw new Error("--set requires a key=value argument");
13
+ }
14
+ const eq = pair.indexOf("=");
15
+ if (eq <= 0) {
16
+ throw new Error(`invalid --set value '${pair}', expected key=value`);
17
+ }
18
+ const key = pair.slice(0, eq);
19
+ const raw = pair.slice(eq + 1);
20
+ try {
21
+ return { key, value: JSON.parse(raw) };
22
+ } catch {
23
+ return { key, value: raw };
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Maps `--set key=value` CLI params onto `msg.payload` attributes.
29
+ *
30
+ * Existing payload attributes are kept; `--set` params are applied on top,
31
+ * in the given order, so later params win on key collisions. If the current
32
+ * payload isn't a plain object (e.g. absent, a primitive, or an array), it is
33
+ * replaced by a fresh object built from the params.
34
+ */
35
+ function applySetParams(payload, pairs) {
36
+ if (pairs.length === 0) return payload;
37
+
38
+ const base = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {};
39
+ const merged = { ...base };
40
+ for (const pair of pairs) {
41
+ const { key, value } = parseSetParam(pair);
42
+ merged[key] = value;
43
+ }
44
+ return merged;
45
+ }
46
+
47
+ /**
48
+ * Resolves a `--flow-json <value>` CLI option into a parsed flow array.
49
+ *
50
+ * Supports three forms:
51
+ * - `-`: read the flow JSON from stdin, via the injected `readStdin()`.
52
+ * - `@<path>`: read the flow JSON from the file at `<path>` (resolved
53
+ * relative to `cwd`), mirroring the positional `<flows.json>` argument.
54
+ * - anything else: treated as an inline JSON string.
55
+ *
56
+ * Throws a clear `Error` when the value isn't valid JSON, or when it parses
57
+ * to something other than an array (Node-RED flow files are JSON arrays of
58
+ * node configs).
59
+ */
60
+ async function parseFlowJsonParam(value, { readStdin, cwd = process.cwd() } = {}) {
61
+ let raw;
62
+ let source;
63
+ if (value === "-") {
64
+ if (typeof readStdin !== "function") {
65
+ throw new Error("--flow-json - requires stdin support");
66
+ }
67
+ raw = await readStdin();
68
+ source = "stdin";
69
+ } else if (value.startsWith("@")) {
70
+ const fs = require("node:fs");
71
+ const path = require("node:path");
72
+ const filePath = path.resolve(cwd, value.slice(1));
73
+ source = filePath;
74
+ try {
75
+ raw = fs.readFileSync(filePath, "utf8");
76
+ } catch (error) {
77
+ throw new Error(`could not read --flow-json file '${filePath}': ${error.message}`, { cause: error });
78
+ }
79
+ } else {
80
+ raw = value;
81
+ source = "--flow-json value";
82
+ }
83
+
84
+ let flows;
85
+ try {
86
+ flows = JSON.parse(raw);
87
+ } catch (error) {
88
+ throw new Error(`invalid JSON from ${source}: ${error.message}`, { cause: error });
89
+ }
90
+
91
+ if (!Array.isArray(flows)) {
92
+ throw new Error(`the flow JSON from ${source} must be an array of node configs`);
93
+ }
94
+
95
+ return flows;
96
+ }
97
+
98
+ const VALID_FORMATS = ["json", "plain"];
99
+
100
+ /**
101
+ * Validates the `--format` CLI option value.
102
+ *
103
+ * Returns the format unchanged when valid (`"json"` or `"plain"`); throws
104
+ * otherwise so the CLI can report a clear error and exit non-zero.
105
+ */
106
+ function parseFormatParam(format) {
107
+ if (!VALID_FORMATS.includes(format)) {
108
+ throw new Error(`invalid --format value '${format}', expected one of: ${VALID_FORMATS.join(", ")}`);
109
+ }
110
+ return format;
111
+ }
112
+
113
+ /**
114
+ * Renders a link-out result for `--format=plain`: just the raw payload,
115
+ * as text. Strings are printed as-is; other JSON-compatible values
116
+ * (numbers, booleans, null, objects, arrays) are JSON-stringified.
117
+ */
118
+ function formatPlain(payload) {
119
+ return typeof payload === "string" ? payload : JSON.stringify(payload);
120
+ }
121
+
122
+ module.exports = {
123
+ parseSetParam,
124
+ applySetParams,
125
+ parseFlowJsonParam,
126
+ parseFormatParam,
127
+ formatPlain,
128
+ VALID_FORMATS
129
+ };
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Builds a Node-RED `storageModule` that serves an in-memory flow array
5
+ * instead of reading/writing a flow file on disk.
6
+ *
7
+ * Implements the minimal `storageModule` contract used by
8
+ * `@node-red/runtime/lib/storage`: `init`, `getFlows`, `saveFlows`,
9
+ * `getCredentials`/`saveCredentials`, `getSettings`/`saveSettings`. Since the
10
+ * CLI never persists anything, all save operations are no-ops and settings
11
+ * are not backed by storage.
12
+ *
13
+ * `saveFlows` is a no-op by design: this CLI never mutates the caller's
14
+ * in-memory flow definition or persists it anywhere.
15
+ */
16
+ function createMemoryStorageModule(flows) {
17
+ return {
18
+ async init() {},
19
+ async getFlows() {
20
+ return flows;
21
+ },
22
+ async saveFlows() {},
23
+ async getCredentials() {
24
+ return {};
25
+ },
26
+ async saveCredentials() {},
27
+ async getSettings() {
28
+ return null;
29
+ },
30
+ async saveSettings() {}
31
+ };
32
+ }
33
+
34
+ module.exports = { createMemoryStorageModule };
@@ -0,0 +1,326 @@
1
+ "use strict";
2
+
3
+ const crypto = require("node:crypto");
4
+
5
+ function getConfigs(RED, errors) {
6
+ const configs = new Map();
7
+ RED.nodes.eachNode((config) => {
8
+ if (configs.has(config.id)) errors.push(`duplicate node id '${config.id}'`);
9
+ configs.set(config.id, config);
10
+ });
11
+ return configs;
12
+ }
13
+
14
+ /** Resolve a Node-RED workspace tab by id or label. */
15
+ function resolveFlow(RED, flowSelector) {
16
+ const errors = [];
17
+ const configs = getConfigs(RED, errors);
18
+ const flows = [...configs.values()].filter((config) => config.type === "tab");
19
+
20
+ if (typeof flowSelector === "undefined" || flowSelector === null || flowSelector === "") {
21
+ if (flows.length === 1) {
22
+ return { ok: errors.length === 0, errors, flow: flows[0], configs, selectedBy: "fallback" };
23
+ }
24
+ errors.push(
25
+ flows.length === 0
26
+ ? "the loaded flow configuration contains no workspace tabs"
27
+ : `flow must be specified because ${flows.length} workspace tabs are present`
28
+ );
29
+ return { ok: false, errors, configs };
30
+ }
31
+
32
+ if (typeof flowSelector !== "string") {
33
+ errors.push("flow must be a workspace tab id or label");
34
+ return { ok: false, errors, configs };
35
+ }
36
+
37
+ let matches = flows.filter((flow) => flow.id === flowSelector);
38
+ let selectedBy = "id";
39
+ if (matches.length === 0) {
40
+ matches = flows.filter((flow) => flow.label === flowSelector);
41
+ selectedBy = "label";
42
+ }
43
+ if (matches.length === 1) {
44
+ return { ok: errors.length === 0, errors, flow: matches[0], configs, selectedBy };
45
+ }
46
+ errors.push(
47
+ matches.length === 0
48
+ ? `flow '${flowSelector}' was not found`
49
+ : `flow label '${flowSelector}' is ambiguous`
50
+ );
51
+ return { ok: false, errors, configs };
52
+ }
53
+
54
+ /** Find `link in` nodes, optionally restricted to a single flow tab. */
55
+ function findLinkIns(configs, flowId) {
56
+ return [...configs.values()].filter(
57
+ (config) => config.type === "link in" && (typeof flowId === "undefined" || config.z === flowId)
58
+ );
59
+ }
60
+
61
+ function resolveTargetFallback(configs, flows, flowSelector) {
62
+ const flowOmitted = typeof flowSelector === "undefined" || flowSelector === null || flowSelector === "";
63
+ if (!flowOmitted) return null; // caller resolves the flow normally and scopes the fallback to it
64
+
65
+ const allLinkIns = findLinkIns(configs);
66
+ if (allLinkIns.length === 1) {
67
+ const targetConfig = allLinkIns[0];
68
+ const flow = configs.get(targetConfig.z);
69
+ const warnings = [];
70
+ if (flows.length > 1) {
71
+ warnings.push(
72
+ `flow not specified; inferred flow '${flow?.label || flow?.id || targetConfig.z}' and target ` +
73
+ `'${targetConfig.name || targetConfig.id}' from the only link-in node in the configuration`
74
+ );
75
+ }
76
+ return { ok: true, flow, targetConfig, warnings };
77
+ }
78
+ if (allLinkIns.length === 0) {
79
+ return { ok: false, errors: ["no link-in nodes found in the flow configuration"], warnings: [] };
80
+ }
81
+ // allLinkIns.length > 1
82
+ if (flows.length <= 1) {
83
+ return {
84
+ ok: false,
85
+ errors: [`target must be specified because ${allLinkIns.length} link-in nodes are present`],
86
+ warnings: []
87
+ };
88
+ }
89
+ return {
90
+ ok: false,
91
+ errors: [
92
+ `flow must be specified because ${flows.length} workspace tabs are present`,
93
+ `target must be specified because ${allLinkIns.length} link-in nodes are present across those tabs`
94
+ ],
95
+ warnings: []
96
+ };
97
+ }
98
+
99
+ function validateTarget(RED, targetSelector, { flow } = {}) {
100
+ const errors = [];
101
+ const configs = getConfigs(RED, errors);
102
+ const flows = [...configs.values()].filter((config) => config.type === "tab");
103
+ const targetOmitted =
104
+ typeof targetSelector === "undefined" || targetSelector === null || targetSelector === "";
105
+
106
+ const fallback = targetOmitted ? resolveTargetFallback(configs, flows, flow) : null;
107
+
108
+ let selectedFlow;
109
+ let targetConfig;
110
+ let selectedFlowBy;
111
+ let warnings = [];
112
+
113
+ if (fallback) {
114
+ warnings = fallback.warnings;
115
+ if (fallback.ok) {
116
+ selectedFlow = fallback.flow;
117
+ targetConfig = fallback.targetConfig;
118
+ selectedFlowBy = "fallback-via-target";
119
+ } else {
120
+ errors.push(...fallback.errors);
121
+ }
122
+ } else {
123
+ const flowResolution = resolveFlow(RED, flow);
124
+ errors.push(...flowResolution.errors);
125
+ selectedFlow = flowResolution.flow;
126
+ selectedFlowBy = flowResolution.selectedBy;
127
+
128
+ if (targetOmitted && selectedFlow) {
129
+ const candidates = findLinkIns(configs, selectedFlow.id);
130
+ if (candidates.length === 1) {
131
+ targetConfig = candidates[0];
132
+ } else if (candidates.length === 0) {
133
+ errors.push(`no link-in nodes found in flow '${selectedFlow.id}'`);
134
+ } else {
135
+ errors.push(
136
+ `target must be specified because ${candidates.length} link-in nodes are present in flow '${selectedFlow.id}'`
137
+ );
138
+ }
139
+ }
140
+ }
141
+
142
+ if (!targetOmitted && (typeof targetSelector !== "string" || targetSelector.length === 0)) {
143
+ errors.push("target must be a non-empty link-in id or name");
144
+ }
145
+
146
+ // Validate all ordinary wires before following the requested target.
147
+ for (const config of configs.values()) {
148
+ for (const output of config.wires || []) {
149
+ for (const destinationId of output || []) {
150
+ if (!configs.has(destinationId)) {
151
+ errors.push(`node '${config.id}' wires to missing node '${destinationId}'`);
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ if (!targetOmitted) {
158
+ targetConfig = configs.get(targetSelector);
159
+ if (targetConfig && selectedFlow && targetConfig.z !== selectedFlow.id) {
160
+ errors.push(`target '${targetSelector}' does not belong to flow '${selectedFlow.id}'`);
161
+ targetConfig = undefined;
162
+ }
163
+ if (!targetConfig && selectedFlow && typeof targetSelector === "string") {
164
+ const matches = [...configs.values()].filter(
165
+ (config) =>
166
+ config.type === "link in" && config.z === selectedFlow.id && config.name === targetSelector
167
+ );
168
+ if (matches.length === 1) targetConfig = matches[0];
169
+ else if (matches.length > 1)
170
+ errors.push(`link in name '${targetSelector}' is ambiguous in flow '${selectedFlow.id}'`);
171
+ }
172
+ if (!targetConfig && selectedFlow)
173
+ errors.push(`target '${targetSelector}' is not present in flow '${selectedFlow.id}'`);
174
+ }
175
+ if (targetConfig && targetConfig.type !== "link in") {
176
+ errors.push(`target '${targetConfig.id}' has type '${targetConfig.type}', expected 'link in'`);
177
+ }
178
+
179
+ const targetNode = targetConfig && RED.nodes.getNode(targetConfig.id);
180
+ if (targetConfig && !targetNode)
181
+ errors.push(`target '${targetConfig.id}' is not instantiated in the runtime`);
182
+ else if (targetNode && targetNode.type !== "link in")
183
+ errors.push(`runtime target '${targetConfig.id}' is not a 'link in' node`);
184
+
185
+ const reachable = new Set();
186
+ const returnIds = new Set();
187
+ function walk(id) {
188
+ if (reachable.has(id)) return;
189
+ reachable.add(id);
190
+ const config = configs.get(id);
191
+ if (!config) return;
192
+ if (config.type === "link out" && config.mode === "return") returnIds.add(id);
193
+ for (const output of config.wires || []) {
194
+ for (const destinationId of output || []) walk(destinationId);
195
+ }
196
+ }
197
+ if (targetConfig) walk(targetConfig.id);
198
+
199
+ if (targetConfig && (!targetConfig.wires || targetConfig.wires.every((output) => !output?.length))) {
200
+ errors.push(`target '${targetConfig.id}' has no outgoing wires`);
201
+ }
202
+ if (targetConfig && returnIds.size === 0) {
203
+ errors.push(`target '${targetConfig.id}' has no reachable link out with mode 'return'`);
204
+ }
205
+ for (const returnId of returnIds) {
206
+ if (!RED.nodes.getNode(returnId))
207
+ errors.push(`return node '${returnId}' is not instantiated in the runtime`);
208
+ }
209
+
210
+ if (!RED.hooks || typeof RED.hooks.add !== "function" || typeof RED.hooks.remove !== "function") {
211
+ errors.push("Node-RED runtime hooks are not available");
212
+ }
213
+
214
+ return {
215
+ ok: errors.length === 0,
216
+ flowId: selectedFlow?.id,
217
+ flowLabel: selectedFlow?.label,
218
+ selectedFlowBy,
219
+ targetId: targetConfig?.id,
220
+ targetName: targetConfig?.name,
221
+ errors: [...new Set(errors)],
222
+ warnings: [...new Set(warnings)],
223
+ reachableNodeIds: [...reachable],
224
+ returnLinkOutIds: [...returnIds]
225
+ };
226
+ }
227
+
228
+ /**
229
+ * Adapter for Node-RED 5.0.x link-in/link-out(return) flows.
230
+ *
231
+ * This intentionally uses Node-RED's current internal message convention
232
+ * (_linkSource) and the documented runtime onReceive hook. Keep it isolated
233
+ * and covered by integration tests; it is not a public Node-RED call API.
234
+ */
235
+ function createHostLinkCaller(RED) {
236
+ const callerId = `__node-red-cli-host-${crypto.randomBytes(8).toString("hex")}`;
237
+ const pending = new Map();
238
+ const returnLinkOutIds = new Set();
239
+ const hookId = `onReceive.${callerId}`;
240
+
241
+ // RED.nodes is internal. We only read the deployed node configuration;
242
+ // no node is added, rewired, deployed or removed.
243
+ RED.nodes.eachNode((config) => {
244
+ if (config.type === "link out" && config.mode === "return") {
245
+ returnLinkOutIds.add(config.id);
246
+ }
247
+ });
248
+
249
+ RED.hooks.add(hookId, ({ msg, destination }) => {
250
+ if (!returnLinkOutIds.has(destination.id)) return;
251
+
252
+ const stack = msg?._linkSource;
253
+ const source = stack?.[stack.length - 1];
254
+ if (source?.node !== callerId) return;
255
+
256
+ // Mirror LinkCallNode/FunctionNode cleanup before returning the result.
257
+ stack.pop();
258
+ if (stack.length === 0) delete msg._linkSource;
259
+
260
+ const operation = pending.get(source.id);
261
+ if (operation) {
262
+ pending.delete(source.id);
263
+ clearTimeout(operation.timer);
264
+ operation.resolve(msg);
265
+ }
266
+
267
+ // Do not let LinkOutNode continue: it would try RED.nodes.getNode(callerId),
268
+ // but the host intentionally is not a configured Node-RED node.
269
+ return false;
270
+ });
271
+
272
+ function call(target, msg, { flow, timeout = 5000, clone = true, onWarning } = {}) {
273
+ const validation = validateTarget(RED, target, { flow });
274
+ if (!validation.ok) {
275
+ return Promise.reject(new Error(`preflight validation failed:\n- ${validation.errors.join("\n- ")}`));
276
+ }
277
+ if (typeof onWarning === "function") {
278
+ for (const warning of validation.warnings) onWarning(warning);
279
+ }
280
+ if (!Number.isFinite(timeout) || timeout <= 0) {
281
+ return Promise.reject(new TypeError("timeout must be a positive number of milliseconds"));
282
+ }
283
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) {
284
+ return Promise.reject(new TypeError("msg must be an object"));
285
+ }
286
+
287
+ const targetNode = RED.nodes.getNode(validation.targetId);
288
+ if (!targetNode || targetNode.type !== "link in") {
289
+ return Promise.reject(new Error(`link in '${validation.targetId}' not found`));
290
+ }
291
+
292
+ const callId = crypto.randomBytes(14).toString("hex");
293
+ const input = clone ? RED.util.cloneMessage(msg) : msg;
294
+ input._linkSource ??= [];
295
+ input._linkSource.push({ node: callerId, id: callId });
296
+
297
+ return new Promise((resolve, reject) => {
298
+ const timer = setTimeout(() => {
299
+ pending.delete(callId);
300
+ reject(new Error(`link call timed out after ${timeout} ms`));
301
+ }, timeout);
302
+
303
+ pending.set(callId, { resolve, reject, timer });
304
+ try {
305
+ targetNode.receive(input);
306
+ } catch (error) {
307
+ pending.delete(callId);
308
+ clearTimeout(timer);
309
+ reject(error);
310
+ }
311
+ });
312
+ }
313
+
314
+ function close(reason = new Error("host link caller closed")) {
315
+ RED.hooks.remove(hookId);
316
+ for (const operation of pending.values()) {
317
+ clearTimeout(operation.timer);
318
+ operation.reject(reason);
319
+ }
320
+ pending.clear();
321
+ }
322
+
323
+ return { call, close };
324
+ }
325
+
326
+ module.exports = { createHostLinkCaller, resolveFlow, validateTarget };
@@ -0,0 +1,148 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Disk-diffing and npm install logic for `--node-modules`.
5
+ *
6
+ * Installation is done by shelling out to the npm CLI bundled as a
7
+ * transitive dependency of `node-red` (the same binary Node-RED's own
8
+ * palette manager uses, see `@node-red/registry/lib/installer.js`),
9
+ * rather than going through `RED.nodes.installModule()`. That API only
10
+ * works reliably once `RED.start()` has fully completed (it needs
11
+ * runtime settings that aren't available until then), but by that point
12
+ * the target flow has already been deployed with any node types it
13
+ * references unresolved, and Node-RED does not hot-swap a "missing" node
14
+ * into a freshly installed one without a fresh deploy. Installing via a
15
+ * plain `npm install` into `userDir` before `RED.init()`/`RED.start()` is
16
+ * simpler and avoids that ordering problem entirely: Node-RED's own
17
+ * `localfilesystem` node loader always scans `<userDir>/node_modules` on
18
+ * disk regardless of which flow storage module is used, so a module
19
+ * installed this way is picked up like any other palette-installed node.
20
+ */
21
+
22
+ const fs = require("node:fs");
23
+ const path = require("node:path");
24
+ const childProcess = require("node:child_process");
25
+
26
+ /**
27
+ * Checks whether `<userDir>/node_modules/<name>` is already present and
28
+ * consistent (has a readable `package.json` with a `node-red` key, and
29
+ * matches `version` when given). Returns `false` for a partially-written
30
+ * or otherwise inconsistent module directory so it gets (re)installed
31
+ * rather than treated as already installed.
32
+ */
33
+ function isModuleInstalled(userDir, name, version) {
34
+ const pkgPath = path.join(userDir, "node_modules", name, "package.json");
35
+ let pkg;
36
+ try {
37
+ pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
38
+ } catch {
39
+ return false;
40
+ }
41
+ if (!pkg || typeof pkg !== "object" || !pkg["node-red"]) {
42
+ return false;
43
+ }
44
+ if (version && pkg.version !== version) {
45
+ return false;
46
+ }
47
+ return true;
48
+ }
49
+
50
+ /** Returns the subset of `modules` not already present/consistent in `userDir`. */
51
+ function diffMissingModules(userDir, modules) {
52
+ return modules.filter(({ name, version }) => !isModuleInstalled(userDir, name, version));
53
+ }
54
+
55
+ function npmCliPath() {
56
+ return path.join(path.dirname(require.resolve("npm/package.json")), "bin", "npm-cli.js");
57
+ }
58
+
59
+ /**
60
+ * Verifies the bundled npm CLI is usable, mirroring
61
+ * `@node-red/registry/lib/installer.js`'s own `checkPrereq()` npm-version
62
+ * check. Throws a clear error otherwise (e.g. missing/broken `npm` install
63
+ * or unusable Node.js binary).
64
+ */
65
+ function checkNpmAvailable() {
66
+ return new Promise((resolve, reject) => {
67
+ childProcess.execFile(process.execPath, [npmCliPath(), "-v"], (error) => {
68
+ if (error) {
69
+ reject(new Error(`npm is not available (required by --node-modules): ${error.message}`));
70
+ return;
71
+ }
72
+ resolve();
73
+ });
74
+ });
75
+ }
76
+
77
+ /** Runs `npm install <name>[@version]` into `userDir`, returns on success, throws a clear error otherwise. */
78
+ function npmInstall(userDir, { name, version }, timeoutMs = 5 * 60 * 1000) {
79
+ const installName = version ? `${name}@${version}` : name;
80
+ const args = [
81
+ npmCliPath(),
82
+ "install",
83
+ "--no-audit",
84
+ "--no-update-notifier",
85
+ "--no-fund",
86
+ "--save",
87
+ "--omit=dev",
88
+ "--",
89
+ installName
90
+ ];
91
+ return new Promise((resolve, reject) => {
92
+ childProcess.execFile(
93
+ process.execPath,
94
+ args,
95
+ { cwd: userDir, timeout: timeoutMs },
96
+ (error, stdout, stderr) => {
97
+ if (error) {
98
+ if (error.killed || error.signal) {
99
+ reject(
100
+ new Error(
101
+ `failed to install --node-modules '${installName}': npm install timed out after ${timeoutMs}ms ` +
102
+ "(is the npm registry reachable?)"
103
+ )
104
+ );
105
+ return;
106
+ }
107
+ const detail = (stderr || stdout || error.message).trim().split("\n").slice(-20).join("\n");
108
+ reject(new Error(`failed to install --node-modules '${installName}': ${detail}`));
109
+ return;
110
+ }
111
+ resolve();
112
+ }
113
+ );
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Installs every module in `modules` that is missing/inconsistent in
119
+ * `<userDir>/node_modules`, skipping any already present and consistent.
120
+ * Installs run sequentially so a shared `userDir` isn't hit by concurrent
121
+ * npm invocations from within a single CLI run. Throws on the first
122
+ * failure (no partial-success reporting) and re-validates the module is a
123
+ * genuine Node-RED node module afterwards.
124
+ */
125
+ async function installMissingNodeModules(userDir, modules, { timeoutMs } = {}) {
126
+ const missing = diffMissingModules(userDir, modules);
127
+ if (missing.length === 0) return { installed: [], skipped: modules };
128
+
129
+ await checkNpmAvailable();
130
+ fs.mkdirSync(userDir, { recursive: true });
131
+
132
+ for (const module of missing) {
133
+ await npmInstall(userDir, module, timeoutMs);
134
+ if (!isModuleInstalled(userDir, module.name, module.version)) {
135
+ throw new Error(
136
+ `failed to install --node-modules '${module.name}': installed package is not a valid Node-RED node module (missing "node-red" key in its package.json)`
137
+ );
138
+ }
139
+ }
140
+ return { installed: missing, skipped: modules.filter((m) => !missing.includes(m)) };
141
+ }
142
+
143
+ module.exports = {
144
+ isModuleInstalled,
145
+ diffMissingModules,
146
+ installMissingNodeModules,
147
+ checkNpmAvailable
148
+ };