@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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tom Brandenburg
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,356 @@
1
+ # node-red-cli 🔗
2
+
3
+ [![Checks](https://github.com/tbrandenburg/node-red-cli/actions/workflows/checks.yml/badge.svg)](https://github.com/tbrandenburg/node-red-cli/actions/workflows/checks.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
+ [![Node.js >=24](https://img.shields.io/badge/node-%3E%3D24-brightgreen.svg)](package.json)
6
+
7
+ ## Call Node-RED flows like Unix functions ⚡
8
+
9
+ `node-red-cli` explores a simple, powerful idea: existing Node-RED flows
10
+ should be usable from a CLI or a Node.js host just like ordinary functions.
11
+
12
+ ```bash
13
+ node-red-cli flows.json calculate --set x=4 --set y=5 < /dev/null
14
+ ```
15
+
16
+ ```
17
+ 9
18
+ ```
19
+
20
+ By default only the resulting payload is printed as plain text. Pass
21
+ `--format=json` to print the full result object as JSON instead:
22
+
23
+ ```bash
24
+ node bin/node-red-cli.js test/fixtures/flows.json calculate \
25
+ --set x=4 --set y=5 --format=json < /dev/null
26
+ ```
27
+
28
+ ```json
29
+ { "payload": 9, "_msgid": "..." }
30
+ ```
31
+
32
+ This turns Node-RED from a visual automation tool into a reusable runtime
33
+ building block for scripts, services, pipelines, and developer tooling. 🧩
34
+
35
+ ## The idea 💡
36
+
37
+ An existing flow becomes a clean input/output interface:
38
+
39
+ ```text
40
+ stdin / CLI args
41
+ |
42
+ v
43
+ Node-RED runtime
44
+ |
45
+ v
46
+ link in: calculate -> any flow -> link out: return
47
+ |
48
+ v
49
+ stdout / Promise<Result>
50
+ ```
51
+
52
+ The flow itself stays untouched. No extra CLI nodes, no copy-pasted logic, and
53
+ no permanently deployed adapter structure. 🚫🔧
54
+
55
+ ## Why node-red-cli? ✅
56
+
57
+ - **Reuse existing flows:** business logic stays where it's already
58
+ maintained — in Node-RED.
59
+ - **Uses the real Node-RED runtime:** core and contrib nodes don't need to be
60
+ reimplemented.
61
+ - **CLI-friendly I/O:** JSON in, JSON out.
62
+ - **Async support included:** Node-RED flows keep working exactly as they
63
+ normally do.
64
+ - **Safely bounded calls:** timeouts prevent a process from hanging forever.
65
+ - **Clean separation:** results go to `stdout`, logs and errors go to
66
+ `stderr`.
67
+ - **No flow mutation:** the current implementation adds no temporary nodes and
68
+ never redeploys `flows.json`.
69
+
70
+ ## Current state 🚧
71
+
72
+ This repository provides an early-stage CLI and host-side adapter for
73
+ Node-RED 5.0.4. The adapter invokes an existing `link in` node and captures
74
+ the response from a `link out` node in return mode as a Promise.
75
+
76
+ The included example flow (`test/fixtures/flows.json`) computes `x + y`:
77
+
78
+ ```text
79
+ link in: calculate -> Function -> link out: return
80
+ ```
81
+
82
+ The test suite (`test/e2e/flow.e2e.test.js`) verifies:
83
+
84
+ 1. ✅ A successful call returning `{ payload: 9 }`.
85
+ 2. ✅ Preflight validation rejecting an unknown target.
86
+ 3. ✅ A timeout when a flow doesn't respond in time.
87
+ 4. ✅ An unchanged SHA-256 hash of the flow file before and after the call.
88
+
89
+ ## Project layout 📁
90
+
91
+ ```text
92
+ bin/ CLI entrypoint (node-red-cli)
93
+ src/ Host-side link-call adapter (library API)
94
+ test/unit/ Fast tests against a fake Node-RED runtime
95
+ test/integration/ Adapter tests against a real embedded runtime
96
+ test/e2e/ Full round trip through the example flow
97
+ test/fixtures/ Example Node-RED flow used as a test asset
98
+ ```
99
+
100
+ ## Install 📦
101
+
102
+ ```bash
103
+ npm install -g @tbrandenburg/node-red-cli
104
+ ```
105
+
106
+ This installs the `node-red-cli` command globally, ready to use against
107
+ any Node-RED flow file (see [Quick start](#quick-start-) below).
108
+
109
+ ## Quick start 🚀
110
+
111
+ To build and run from a repo checkout instead (e.g. for contributing):
112
+
113
+ ```bash
114
+ make install
115
+ make test
116
+ ```
117
+
118
+ `make install` also wires up a `pre-push` git hook that runs `make ci`
119
+ (format, lint, and tests) automatically before every push.
120
+
121
+ To use `node-red-cli` as a regular command from a checkout instead of via
122
+ `node bin/...`, install it globally from the local source:
123
+
124
+ ```bash
125
+ make install-global
126
+ ```
127
+
128
+ Try the CLI directly against the example flow:
129
+
130
+ ```bash
131
+ echo '{"payload":{"x":4,"y":5}}' | node bin/node-red-cli.js test/fixtures/flows.json calculate
132
+ ```
133
+
134
+ ```
135
+ 9
136
+ ```
137
+
138
+ The `_msgid` is generated by Node-RED and differs on every run. To see it
139
+ along with the rest of the result object, pass `--format=json`.
140
+
141
+ The `target` argument is optional; if the flow has exactly one `link in`
142
+ node, it is used automatically (with a warning on stderr if it also had to be
143
+ inferred across multiple tabs):
144
+
145
+ ```bash
146
+ echo '{"payload":{"x":4,"y":5}}' | node bin/node-red-cli.js test/fixtures/single-link-in.flows.json
147
+ ```
148
+
149
+ Instead of building the whole JSON message yourself, individual payload
150
+ attributes can be set directly from CLI params with repeatable
151
+ `--set <key>=<value>` flags. Values are JSON-parsed when possible (so `4`
152
+ becomes a number, `true` a boolean), otherwise kept as plain strings, and they
153
+ are applied on top of (and override) any payload read from stdin:
154
+
155
+ ```bash
156
+ node bin/node-red-cli.js test/fixtures/flows.json calculate \
157
+ --set x=4 --set y=5 < /dev/null
158
+ ```
159
+
160
+ ```
161
+ 9
162
+ ```
163
+
164
+ ## Passing flow JSON inline 📥
165
+
166
+ Instead of a `<flows.json>` file path, `--flow-json <value>` accepts the flow
167
+ definition directly, so in-memory callers (tests, another Node.js process, a
168
+ Node-RED editor "run this flow" action) never have to write a temp file just
169
+ to satisfy this CLI's file-based API. It is mutually exclusive with the
170
+ `<flows.json>` positional argument. `<value>` is one of:
171
+
172
+ - an inline JSON array: `--flow-json '[{"id":"a",...}]'`
173
+ - `-` to read the flow JSON from stdin
174
+ - `@<path>` to read it from a file (equivalent to the positional argument)
175
+
176
+ The flow is never written to disk in any of these forms.
177
+
178
+ ```bash
179
+ node bin/node-red-cli.js --flow-json @test/fixtures/flows.json calculate \
180
+ --set x=4 --set y=5 < /dev/null
181
+ ```
182
+
183
+ ```
184
+ 9
185
+ ```
186
+
187
+ Since stdin is also used to read the `msg` payload, `--flow-json -` and the
188
+ stdin `msg` are mutually exclusive: when `--flow-json -` is used, stdin is
189
+ consumed by the flow definition instead, so `msg` must be built entirely from
190
+ `--set` params:
191
+
192
+ ```bash
193
+ node bin/node-red-cli.js --flow-json - calculate --set x=4 --set y=5 \
194
+ < test/fixtures/flows.json
195
+ ```
196
+
197
+ ```
198
+ 9
199
+ ```
200
+
201
+ ## Installing additional Node-RED node packages 📦
202
+
203
+ By default the CLI creates a fresh, ephemeral Node-RED `userDir` per
204
+ invocation and deletes it afterwards, so only the node types bundled with
205
+ `node-red` itself are available to a flow. To use community/custom nodes
206
+ (e.g. `node-red-contrib-something`), two options work together:
207
+
208
+ - `--user-dir [path]` makes the `userDir` persistent/reusable across runs
209
+ instead of ephemeral. Pass a path to use a specific directory, or the bare
210
+ flag to use a stable cache dir (`$XDG_CACHE_HOME/node-red-cli`, falling
211
+ back to `~/.cache/node-red-cli`). Omitting `--user-dir` entirely preserves
212
+ today's ephemeral behavior unchanged.
213
+ - `--node-modules <name[@version]>[,...]` installs any of the given
214
+ Node-RED node npm packages that are missing from `<userDir>/node_modules`
215
+ before the flow runs. Repeatable and/or comma-separated. **Requires an
216
+ explicit `--user-dir`** — using it with the default ephemeral `userDir`
217
+ is rejected with a clear error, since the installed module would be
218
+ thrown away immediately and reinstalled from npm on every single
219
+ invocation.
220
+
221
+ ```bash
222
+ node bin/node-red-cli.js flows.json calculate \
223
+ --user-dir ~/.cache/node-red-cli \
224
+ --node-modules node-red-node-random \
225
+ --set x=4 --set y=5 < /dev/null
226
+ ```
227
+
228
+ Already-installed, version-matching modules are left untouched, so repeat
229
+ runs against a warm cache do not touch the network. **No invocation ever
230
+ reaches out to npm unless `--node-modules` is explicitly passed.**
231
+
232
+ ⚠️ **Security note**: `--node-modules` runs a real `npm install`, i.e.
233
+ arbitrary code execution from whatever npm registry is configured. Only
234
+ use it with trusted module names. A minimal built-in denylist blocks
235
+ obviously unsafe values (path traversal, URLs, whitespace); operators can
236
+ add exact names or `*`-glob patterns via the `NODE_RED_CLI_DENY_MODULES`
237
+ environment variable (comma-separated), e.g.
238
+ `NODE_RED_CLI_DENY_MODULES="node-red-contrib-*-internal"`.
239
+
240
+ ⚠️ **Persistent `userDir` caveat**: a shared `userDir` accumulates
241
+ Node-RED runtime/state files (e.g. `.config.runtime.json`) across runs.
242
+ Delete the directory (or the default `~/.cache/node-red-cli`) to clear the
243
+ cache and start fresh.
244
+
245
+ ## Host API 🛠️
246
+
247
+ The core interface is intentionally small:
248
+
249
+ ```js
250
+ const { createHostLinkCaller } = require("./src/link-call");
251
+
252
+ const caller = createHostLinkCaller(RED);
253
+
254
+ const result = await caller.call(
255
+ "calculate",
256
+ { payload: { x: 4, y: 5 } },
257
+ { flow: "calculator", timeout: 5000 }
258
+ );
259
+
260
+ console.log(result.payload); // 9
261
+ caller.close();
262
+ ```
263
+
264
+ `flow` accepts either the tab ID or the unique tab label. If omitted, the only
265
+ existing workspace tab is selected automatically.
266
+
267
+ `target` (the `link in` node) is also optional. If omitted, the only `link in`
268
+ node in the resolved flow is used automatically. If no `flow` is given and
269
+ several tabs exist, but only one `link in` node is present overall, that node
270
+ (and its tab) is inferred and a warning is reported via the optional
271
+ `onWarning` callback — pass one to `caller.call(...)` to observe it:
272
+
273
+ ```js
274
+ const result = await caller.call(
275
+ undefined,
276
+ { payload: { x: 4, y: 5 } },
277
+ {
278
+ onWarning: (warning) => console.error(warning)
279
+ }
280
+ );
281
+ ```
282
+
283
+ If either the flow or the target remains ambiguous (more than one candidate),
284
+ `call()` rejects with a preflight validation error naming what must be
285
+ specified explicitly.
286
+
287
+ ## Technical approach 🔬
288
+
289
+ Node-RED's link-call semantics use `_linkSource` to make the origin of a call
290
+ available to a return link. This adapter sets the required stack entry on the
291
+ host side and registers a targeted `onReceive` hook. The returned message
292
+ resolves the Promise before the link-out node needs to resolve the caller via
293
+ `RED.nodes.getNode(...)`.
294
+
295
+ This is a lightweight compatibility layer for Node-RED 5.0.x, not a public
296
+ runtime API. The internal semantics are therefore encapsulated behind
297
+ `createHostLinkCaller(RED)` and should be integration-tested separately for
298
+ each supported Node-RED version.
299
+
300
+ ## Preflight and limitations ⚠️
301
+
302
+ Before a call, `validateTarget(RED, targetId)` checks:
303
+
304
+ - target ID and target type `link in`
305
+ - instantiation of the target node
306
+ - missing wire targets and duplicate IDs
307
+ - at least one reachable `link out` with `mode: "return"`
308
+ - instantiation of reachable return nodes
309
+ - availability of the required runtime hooks
310
+
311
+ Validation does not prove that a flow terminates semantically or replies
312
+ exactly once. A runtime timeout remains necessary for that.
313
+
314
+ ## Roadmap 🗺️
315
+
316
+ The long-term goal is a stable, official host API in Node-RED core:
317
+
318
+ ```js
319
+ const result = await callNodeRedFlow({
320
+ target: "calculate",
321
+ msg,
322
+ timeout: 5000
323
+ });
324
+ ```
325
+
326
+ Or as a runtime interface:
327
+
328
+ ```js
329
+ const result = await RED.runtime.flows.call("calculate", msg, {
330
+ timeout: 5000
331
+ });
332
+ ```
333
+
334
+ The research focuses on which parts of the existing `node.linkcall()`
335
+ implementation can be generalized, and what a small upstream API such as
336
+ `RED.nodes.callLink()` or `RED.runtime.flows.call()` could look like.
337
+
338
+ ## Status 📊
339
+
340
+ **Early-stage CLI:** the approach works for the included Node-RED 5.0.4
341
+ example flow. The link-call internals used here are not stabilized as a
342
+ public Node-RED API. Production use therefore requires deliberate version
343
+ pinning, integration tests, and robust error handling for ambiguous or
344
+ non-terminating flows.
345
+
346
+ ## Contributing 🤝
347
+
348
+ Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
349
+
350
+ ## Security 🔒
351
+
352
+ Please report vulnerabilities responsibly — see [SECURITY.md](SECURITY.md).
353
+
354
+ ## License 📄
355
+
356
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+ const { Command } = require("commander");
7
+ const RED = require("node-red");
8
+ const { createHostLinkCaller } = require("../src/link-call");
9
+ const { createMemoryStorageModule } = require("../src/flow-storage");
10
+ const { applySetParams, parseFlowJsonParam, parseFormatParam, formatPlain } = require("../src/cli-params");
11
+ const { parseNodeModulesParam, resolveUserDir } = require("../src/node-modules");
12
+ const { installMissingNodeModules } = require("../src/node-modules-install");
13
+ const { version } = require("../package.json");
14
+
15
+ const HELP_TEXT = [
16
+ "",
17
+ "Reads a JSON message from stdin and invokes the given link-in target",
18
+ "in the specified Node-RED flow file. The message returned by the",
19
+ "matching link-out (return) node is printed to stdout.",
20
+ "",
21
+ "target defaults to the sole link-in node in the flow file when omitted.",
22
+ "If multiple tabs exist but only one link-in node is present overall,",
23
+ "it is used automatically (a warning is printed to stderr).",
24
+ "",
25
+ "--set <key>=<value> sets msg.payload.<key> to <value>, repeatable.",
26
+ "Values are JSON-parsed when possible (4 -> number, true -> boolean),",
27
+ "otherwise kept as plain strings. --set params are applied on top of",
28
+ "the payload read from stdin (if any) and override matching keys.",
29
+ "",
30
+ "--format=json|plain selects the stdout output format (default: plain).",
31
+ "json prints the full result object as JSON. plain prints only the",
32
+ "result payload as plain text.",
33
+ "",
34
+ "--flow-json <value> supplies the flow definition inline instead of the",
35
+ "<flows.json> positional argument (the two are mutually exclusive).",
36
+ "<value> is one of:",
37
+ ' - an inline JSON array, e.g. --flow-json \'[{"id":"a",...}]\'',
38
+ " - '-' to read the flow JSON from stdin",
39
+ " - '@<path>' to read it from a file",
40
+ "The flow is never written to disk. Because stdin can only be consumed",
41
+ "once, --flow-json - takes stdin for the flow definition, not for msg;",
42
+ "in that mode msg must be built entirely from --set params.",
43
+ "",
44
+ "--user-dir [path] makes Node-RED's userDir persistent/reusable across",
45
+ "runs instead of the default ephemeral tmpdir that is created fresh and",
46
+ "deleted after every invocation. Pass a path to use a specific directory,",
47
+ "or the bare flag to use a stable cache dir ($XDG_CACHE_HOME/node-red-cli,",
48
+ "falling back to ~/.cache/node-red-cli). A shared userDir accumulates",
49
+ "Node-RED runtime/state files (e.g. .config.runtime.json) across runs;",
50
+ "delete the directory to clear the cache.",
51
+ "",
52
+ "--node-modules <name[@version]>[,...] installs any of the given",
53
+ "Node-RED node npm packages that are missing from userDir/node_modules",
54
+ "before the flow runs (repeatable and/or comma-separated). Requires an",
55
+ "explicit --user-dir (installing into an ephemeral userDir would just",
56
+ "reinstall from npm on every run). Already-installed, version-matching",
57
+ "modules are left untouched (no network access). This runs a real",
58
+ "`npm install`, i.e. arbitrary code execution from the configured npm",
59
+ "registry - only use it with trusted module names.",
60
+ "",
61
+ "Example:",
62
+ ' echo \'{"payload":{"x":4,"y":5}}\' | node-red-cli flows.json calculate',
63
+ "",
64
+ "Equivalent using --set instead of stdin:",
65
+ " node-red-cli flows.json calculate --set x=4 --set y=5 < /dev/null"
66
+ ].join("\n");
67
+
68
+ const LEVEL_NAMES = {
69
+ 10: "fatal",
70
+ 20: "error",
71
+ 30: "warn",
72
+ 40: "info",
73
+ 50: "debug",
74
+ 60: "trace",
75
+ 98: "audit",
76
+ 99: "metric"
77
+ };
78
+
79
+ /**
80
+ * Node-RED's built-in console log handler always writes via console.log,
81
+ * i.e. to stdout. That would corrupt the JSON result on stdout, so replace
82
+ * it with a handler that writes to stderr instead.
83
+ */
84
+ function stderrLogHandler() {
85
+ return (msg) => {
86
+ const levelName = LEVEL_NAMES[msg.level] || msg.level;
87
+ const source = msg.type ? `[${msg.type}:${msg.name || msg.id}] ` : "";
88
+ const message = msg.msg && msg.msg.message ? msg.msg.message : msg.msg;
89
+ console.error(`node-red-cli: [${levelName}] ${source}${message}`);
90
+ };
91
+ }
92
+
93
+ function readStdin() {
94
+ return new Promise((resolve, reject) => {
95
+ const chunks = [];
96
+ process.stdin.on("data", (chunk) => chunks.push(chunk));
97
+ process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
98
+ process.stdin.on("error", reject);
99
+ });
100
+ }
101
+
102
+ /** Collects a repeatable `--set key=value` option into an array. */
103
+ function collectSet(value, previous) {
104
+ return [...previous, value];
105
+ }
106
+
107
+ /** Collects a repeatable `--node-modules` option into an array. */
108
+ function collectNodeModules(value, previous) {
109
+ return [...previous, value];
110
+ }
111
+
112
+ async function run(args, options) {
113
+ // <flows.json> and --flow-json are mutually exclusive, and both share the
114
+ // "first positional" slot conceptually, so parse positionals manually
115
+ // instead of relying on commander's fixed argument order: when
116
+ // --flow-json is given, the sole remaining positional is the target;
117
+ // otherwise the first positional is the flow file and the second the
118
+ // target.
119
+ const [flowFileArg, target] = options.flowJson ? [undefined, args[0]] : args;
120
+
121
+ if (args.length > (options.flowJson ? 1 : 2)) {
122
+ console.error("node-red-cli: too many positional arguments");
123
+ process.exitCode = 1;
124
+ return;
125
+ }
126
+
127
+ if (!flowFileArg && !options.flowJson) {
128
+ console.error("node-red-cli: either <flows.json> or --flow-json must be given");
129
+ process.exitCode = 1;
130
+ return;
131
+ }
132
+ if (flowFileArg && options.flowJson) {
133
+ console.error("node-red-cli: <flows.json> and --flow-json are mutually exclusive");
134
+ process.exitCode = 1;
135
+ return;
136
+ }
137
+
138
+ let flowFile;
139
+ let flows;
140
+ if (options.flowJson) {
141
+ try {
142
+ flows = await parseFlowJsonParam(options.flowJson, { readStdin });
143
+ } catch (error) {
144
+ console.error(`node-red-cli: ${error.message}`);
145
+ process.exitCode = 1;
146
+ return;
147
+ }
148
+ } else {
149
+ flowFile = path.resolve(process.cwd(), flowFileArg);
150
+ if (!fs.existsSync(flowFile)) {
151
+ console.error(`node-red-cli: flow file not found: ${flowFile}`);
152
+ process.exitCode = 1;
153
+ return;
154
+ }
155
+ }
156
+
157
+ const usedStdinForFlow = options.flowJson === "-";
158
+ let msg;
159
+ if (usedStdinForFlow) {
160
+ msg = { payload: {} };
161
+ } else {
162
+ const rawInput = (await readStdin()).trim();
163
+ try {
164
+ msg = rawInput.length > 0 ? JSON.parse(rawInput) : {};
165
+ } catch (error) {
166
+ console.error(`node-red-cli: invalid JSON on stdin: ${error.message}`);
167
+ process.exitCode = 1;
168
+ return;
169
+ }
170
+
171
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) {
172
+ console.error("node-red-cli: the JSON message on stdin must be an object");
173
+ process.exitCode = 1;
174
+ return;
175
+ }
176
+ }
177
+
178
+ try {
179
+ msg.payload = applySetParams(msg.payload, options.set);
180
+ } catch (error) {
181
+ console.error(`node-red-cli: ${error.message}`);
182
+ process.exitCode = 1;
183
+ return;
184
+ }
185
+
186
+ try {
187
+ parseFormatParam(options.format);
188
+ } catch (error) {
189
+ console.error(`node-red-cli: ${error.message}`);
190
+ process.exitCode = 1;
191
+ return;
192
+ }
193
+
194
+ let nodeModules;
195
+ try {
196
+ nodeModules = options.nodeModules.length > 0 ? parseNodeModulesParam(options.nodeModules) : [];
197
+ } catch (error) {
198
+ console.error(`node-red-cli: ${error.message}`);
199
+ process.exitCode = 1;
200
+ return;
201
+ }
202
+
203
+ const persistentUserDir = resolveUserDir(options.userDir);
204
+ if (nodeModules.length > 0 && !persistentUserDir) {
205
+ console.error(
206
+ "node-red-cli: --node-modules requires an explicit --user-dir (a persistent directory); " +
207
+ "using it with the default ephemeral userDir would reinstall from npm on every run"
208
+ );
209
+ process.exitCode = 1;
210
+ return;
211
+ }
212
+
213
+ const userDir =
214
+ persistentUserDir || fs.mkdtempSync(path.join(require("node:os").tmpdir(), "node-red-cli-"));
215
+
216
+ if (nodeModules.length > 0) {
217
+ try {
218
+ await installMissingNodeModules(userDir, nodeModules);
219
+ } catch (error) {
220
+ console.error(`node-red-cli: ${error.message}`);
221
+ process.exitCode = 1;
222
+ if (!persistentUserDir) fs.rmSync(userDir, { recursive: true, force: true });
223
+ return;
224
+ }
225
+ }
226
+
227
+ let caller;
228
+
229
+ RED.init({
230
+ ...(flows ? { storageModule: createMemoryStorageModule(flows) } : { flowFile }),
231
+ userDir,
232
+ httpAdminRoot: false,
233
+ httpNodeRoot: false,
234
+ editorTheme: { projects: { enabled: false } },
235
+ logging: { console: { level: "warn", metrics: false, audit: false, handler: stderrLogHandler } }
236
+ });
237
+
238
+ try {
239
+ const flowsStarted = new Promise((resolve) => RED.events.once("flows:started", resolve));
240
+ await RED.start();
241
+ await flowsStarted;
242
+
243
+ caller = createHostLinkCaller(RED);
244
+ const result = await caller.call(target, msg, {
245
+ flow: options.flow,
246
+ timeout: options.timeout,
247
+ onWarning: (warning) => console.error(`node-red-cli: warning: ${warning}`)
248
+ });
249
+ process.stdout.write(
250
+ options.format === "plain" ? `${formatPlain(result.payload)}\n` : `${JSON.stringify(result)}\n`
251
+ );
252
+ } catch (error) {
253
+ console.error(`node-red-cli: ${error.message}`);
254
+ process.exitCode = 1;
255
+ } finally {
256
+ caller?.close();
257
+ await RED.stop();
258
+ if (!persistentUserDir) fs.rmSync(userDir, { recursive: true, force: true });
259
+ }
260
+ }
261
+
262
+ const program = new Command();
263
+
264
+ program
265
+ .name("node-red-cli")
266
+ .usage(
267
+ "<flows.json>|--flow-json <value> [target] [--flow=<tab>] [--timeout=<ms>] [--set <key>=<value>]... [--format=json|plain]"
268
+ )
269
+ .argument("[args...]", "[flows.json] [target], or [target] alone when --flow-json is given (see --help)")
270
+ .option("--flow <tab>", "flow tab name/id to search the target in")
271
+ .option(
272
+ "--flow-json <value>",
273
+ "flow JSON inline, '-' for stdin, or '@path' for a file; mutually exclusive with <flows.json>"
274
+ )
275
+ .option("--timeout <ms>", "call timeout in milliseconds", (value) => Number(value), 5000)
276
+ .option("--format <format>", "output format: json|plain", "plain")
277
+ .option("--set <key=value>", "set msg.payload.<key> to <value>, repeatable", collectSet, [])
278
+ .option(
279
+ "--user-dir [path]",
280
+ "persistent Node-RED userDir (bare flag = default cache dir); omit for an ephemeral tmpdir"
281
+ )
282
+ .option(
283
+ "--node-modules <name[@version]>",
284
+ "install missing Node-RED node npm package(s), comma-separated and/or repeatable; requires --user-dir",
285
+ collectNodeModules,
286
+ []
287
+ )
288
+ .addHelpText("after", HELP_TEXT)
289
+ .version(version, "-v, --version", "print the installed node-red-cli version and exit")
290
+ .action(run);
291
+
292
+ program.parseAsync(process.argv).catch((error) => {
293
+ console.error(error.stack || error.message);
294
+ process.exitCode = 1;
295
+ });