@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 +21 -0
- package/README.md +356 -0
- package/bin/node-red-cli.js +295 -0
- package/package.json +59 -0
- package/src/cli-params.js +129 -0
- package/src/flow-storage.js +34 -0
- package/src/link-call.js +326 -0
- package/src/node-modules-install.js +148 -0
- package/src/node-modules.js +138 -0
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
|
+
[](https://github.com/tbrandenburg/node-red-cli/actions/workflows/checks.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](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
|
+
});
|