@tbrandenburg/node-red-cli 0.2.8 → 0.2.10
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/README.md +108 -91
- package/bin/node-red-cli-sandbox-entry.js +47 -0
- package/bin/node-red-cli.js +74 -63
- package/package.json +1 -1
- package/src/docker-image.js +211 -0
- package/src/docker-run.js +84 -0
- package/src/run-envelope.js +103 -0
package/README.md
CHANGED
|
@@ -4,12 +4,9 @@
|
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](package.json)
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
`node-red-cli` explores a simple, powerful idea: existing Node-RED flows
|
|
12
|
-
should be usable from a CLI or a Node.js host just like ordinary functions.
|
|
7
|
+
Call existing Node-RED flows from a CLI or a Node.js host, like ordinary
|
|
8
|
+
functions — using the real embedded Node-RED runtime, no flow mutation, no
|
|
9
|
+
temporary nodes.
|
|
13
10
|
|
|
14
11
|
```bash
|
|
15
12
|
node-red-cli flows.json calculate --set x=4 --set y=5 < /dev/null
|
|
@@ -19,29 +16,50 @@ node-red-cli flows.json calculate --set x=4 --set y=5 < /dev/null
|
|
|
19
16
|
9
|
|
20
17
|
```
|
|
21
18
|
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
This turns Node-RED from a visual automation tool into a reusable runtime
|
|
20
|
+
building block for scripts, services, pipelines, and developer tooling. 🧩
|
|
21
|
+
|
|
22
|
+
## Install 📦
|
|
24
23
|
|
|
25
24
|
```bash
|
|
26
|
-
node-red-cli
|
|
27
|
-
--set x=4 --set y=5 --format=json < /dev/null
|
|
25
|
+
npm install -g @tbrandenburg/node-red-cli
|
|
28
26
|
```
|
|
29
27
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
```
|
|
28
|
+
This installs the `node-red-cli` command globally, ready to use against any
|
|
29
|
+
Node-RED flow file (see [Usage](#usage-) below).
|
|
33
30
|
|
|
34
|
-
|
|
35
|
-
building block for scripts, services, pipelines, and developer tooling. 🧩
|
|
31
|
+
To build and run from a repo checkout instead (e.g. for contributing):
|
|
36
32
|
|
|
37
|
-
|
|
33
|
+
```bash
|
|
34
|
+
make install
|
|
35
|
+
make test
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
`make install` also wires up a `pre-push` git hook that runs `make ci`
|
|
39
|
+
(format, lint, and tests) automatically before every push. To use
|
|
40
|
+
`node-red-cli` as a regular command from a repo checkout, install it
|
|
41
|
+
globally from the local source:
|
|
38
42
|
|
|
39
43
|
```bash
|
|
40
|
-
|
|
44
|
+
make install-global
|
|
41
45
|
```
|
|
42
46
|
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
## Table of contents
|
|
48
|
+
|
|
49
|
+
- [The idea](#the-idea-)
|
|
50
|
+
- [Why node-red-cli?](#why-node-red-cli-)
|
|
51
|
+
- [Project layout](#project-layout-)
|
|
52
|
+
- [Usage](#usage-)
|
|
53
|
+
- [Quick start](#quick-start)
|
|
54
|
+
- [Passing flow JSON inline](#passing-flow-json-inline)
|
|
55
|
+
- [Installing additional Node-RED node packages](#installing-additional-node-red-node-packages)
|
|
56
|
+
- [Running sandboxed in Docker](#running-sandboxed-in-docker-)
|
|
57
|
+
- [Host API](#host-api-)
|
|
58
|
+
- [Technical approach](#technical-approach-)
|
|
59
|
+
- [Preflight and limitations](#preflight-and-limitations-)
|
|
60
|
+
- [Contributing](#contributing-)
|
|
61
|
+
- [Security](#security-)
|
|
62
|
+
- [License](#license-)
|
|
45
63
|
|
|
46
64
|
## The idea 💡
|
|
47
65
|
|
|
@@ -77,25 +95,8 @@ no permanently deployed adapter structure. 🚫🔧
|
|
|
77
95
|
`stderr`.
|
|
78
96
|
- **No flow mutation:** the current implementation adds no temporary nodes and
|
|
79
97
|
never redeploys `flows.json`.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
This repository provides an early-stage CLI and host-side adapter for
|
|
84
|
-
Node-RED 5.0.4. The adapter invokes an existing `link in` node and captures
|
|
85
|
-
the response from a `link out` node in return mode as a Promise.
|
|
86
|
-
|
|
87
|
-
The included example flow (`test/fixtures/flows.json`) computes `x + y`:
|
|
88
|
-
|
|
89
|
-
```text
|
|
90
|
-
link in: calculate -> Function -> link out: return
|
|
91
|
-
```
|
|
92
|
-
|
|
93
|
-
The test suite (`test/e2e/flow.e2e.test.js`) verifies:
|
|
94
|
-
|
|
95
|
-
1. ✅ A successful call returning `{ payload: 9 }`.
|
|
96
|
-
2. ✅ Preflight validation rejecting an unknown target.
|
|
97
|
-
3. ✅ A timeout when a flow doesn't respond in time.
|
|
98
|
-
4. ✅ An unchanged SHA-256 hash of the flow file before and after the call.
|
|
98
|
+
- **Optional sandboxing:** `--docker` re-executes a call inside a disposable,
|
|
99
|
+
hardened container instead of the host process.
|
|
99
100
|
|
|
100
101
|
## Project layout 📁
|
|
101
102
|
|
|
@@ -108,24 +109,9 @@ test/e2e/ Full round trip through the example flow
|
|
|
108
109
|
test/fixtures/ Example Node-RED flow used as a test asset
|
|
109
110
|
```
|
|
110
111
|
|
|
111
|
-
##
|
|
112
|
-
|
|
113
|
-
To build and run from a repo checkout instead (e.g. for contributing):
|
|
114
|
-
|
|
115
|
-
```bash
|
|
116
|
-
make install
|
|
117
|
-
make test
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
`make install` also wires up a `pre-push` git hook that runs `make ci`
|
|
121
|
-
(format, lint, and tests) automatically before every push.
|
|
122
|
-
|
|
123
|
-
To use `node-red-cli` as a regular command from a repo checkout, install it
|
|
124
|
-
globally from the local source:
|
|
112
|
+
## Usage 🚀
|
|
125
113
|
|
|
126
|
-
|
|
127
|
-
make install-global
|
|
128
|
-
```
|
|
114
|
+
### Quick start
|
|
129
115
|
|
|
130
116
|
Try the CLI directly against the example flow:
|
|
131
117
|
|
|
@@ -137,8 +123,18 @@ echo '{"payload":{"x":4,"y":5}}' | node-red-cli test/fixtures/flows.json calcula
|
|
|
137
123
|
9
|
|
138
124
|
```
|
|
139
125
|
|
|
140
|
-
|
|
141
|
-
|
|
126
|
+
By default only the resulting payload is printed as plain text. Pass
|
|
127
|
+
`--format=json` to print the full result object as JSON instead (the
|
|
128
|
+
`_msgid` is generated by Node-RED and differs on every run):
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
node-red-cli test/fixtures/flows.json calculate \
|
|
132
|
+
--set x=4 --set y=5 --format=json < /dev/null
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```json
|
|
136
|
+
{ "payload": 9, "_msgid": "..." }
|
|
137
|
+
```
|
|
142
138
|
|
|
143
139
|
The `target` argument is optional; if the flow has exactly one `link in`
|
|
144
140
|
node, it is used automatically (with a warning on stderr if it also had to be
|
|
@@ -163,7 +159,7 @@ node-red-cli test/fixtures/flows.json calculate \
|
|
|
163
159
|
9
|
|
164
160
|
```
|
|
165
161
|
|
|
166
|
-
|
|
162
|
+
### Passing flow JSON inline
|
|
167
163
|
|
|
168
164
|
Instead of a `<flows.json>` file path, `--flow-json <value>` accepts the flow
|
|
169
165
|
definition directly, so in-memory callers (tests, another Node.js process, a
|
|
@@ -200,7 +196,7 @@ node-red-cli --flow-json - calculate --set x=4 --set y=5 \
|
|
|
200
196
|
9
|
|
201
197
|
```
|
|
202
198
|
|
|
203
|
-
|
|
199
|
+
### Installing additional Node-RED node packages
|
|
204
200
|
|
|
205
201
|
By default the CLI creates a fresh, ephemeral Node-RED `userDir` per
|
|
206
202
|
invocation and deletes it afterwards, so only the node types bundled with
|
|
@@ -244,6 +240,59 @@ Node-RED runtime/state files (e.g. `.config.runtime.json`) across runs.
|
|
|
244
240
|
Delete the directory (or the default `~/.cache/node-red-cli`) to clear the
|
|
245
241
|
cache and start fresh.
|
|
246
242
|
|
|
243
|
+
### Running sandboxed in Docker 🐳
|
|
244
|
+
|
|
245
|
+
`--docker [value]` re-executes the _entire_ invocation (flow resolution,
|
|
246
|
+
link call, and any `--node-modules` install) inside a disposable, hardened
|
|
247
|
+
Docker container instead of the host process — useful when `--node-modules`
|
|
248
|
+
installs untrusted community packages, since that's a real code-execution
|
|
249
|
+
surface. Works for both `<flows.json>` (from-file) and `--flow-json` modes,
|
|
250
|
+
with **zero bind mounts and zero leftover host files**: the resolved flow
|
|
251
|
+
and message are streamed over the container's stdin as a single JSON
|
|
252
|
+
envelope, never written to disk.
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
node-red-cli flows.json calculate --set x=4 --set y=5 --docker
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`<value>` is one of:
|
|
259
|
+
|
|
260
|
+
- **omitted (bare flag)**: resolves/builds a locally-cached image tagged
|
|
261
|
+
`node-red-cli-sandbox:<installed node-red-cli version>`, built from
|
|
262
|
+
`node:24-slim` + a global `npm install` of this package from the public
|
|
263
|
+
npm registry. Cached by Docker forever afterward (npm registry versions
|
|
264
|
+
are immutable, so a version bump is the only thing that invalidates the
|
|
265
|
+
tag) — later runs of the same version need no network access beyond the
|
|
266
|
+
container's own sandboxed execution.
|
|
267
|
+
- **`<image[:tag]>`**: use an explicit image. If it already contains the
|
|
268
|
+
sandbox entrypoint, it's used as-is; otherwise `node-red-cli` is
|
|
269
|
+
installed into a derived image (`FROM <image>` + a global npm install)
|
|
270
|
+
on first use, cached by image+version so the check/build only happens
|
|
271
|
+
once per image.
|
|
272
|
+
- **`@<path>`** or an **http(s) URL**: build from a user-supplied
|
|
273
|
+
Dockerfile (local file or fetched URL), cached by content hash so an
|
|
274
|
+
unchanged Dockerfile isn't rebuilt every run.
|
|
275
|
+
|
|
276
|
+
Sandboxing defaults applied to every `--docker` run:
|
|
277
|
+
|
|
278
|
+
- `--rm -i` (always disposable)
|
|
279
|
+
- `--network none`, unless `--node-modules` is also given (needs registry
|
|
280
|
+
access) — narrowest network exposure by default
|
|
281
|
+
- `--read-only` root filesystem + a `/tmp` tmpfs mount
|
|
282
|
+
- `--cap-drop=ALL`
|
|
283
|
+
- `--security-opt=no-new-privileges`
|
|
284
|
+
|
|
285
|
+
Combined with `--user-dir` + `--node-modules`, persistence uses a
|
|
286
|
+
deterministic **named Docker volume** (derived from the `--user-dir` value)
|
|
287
|
+
mounted inside the container, never a host bind mount — so "no stray host
|
|
288
|
+
files" holds even for persistent installs.
|
|
289
|
+
|
|
290
|
+
Fails fast with a clear `node-red-cli: docker unavailable: ...` error if
|
|
291
|
+
the Docker CLI/daemon isn't reachable, or `node-red-cli: docker build
|
|
292
|
+
failed: ...` if the image build fails (e.g. the local version isn't yet
|
|
293
|
+
published to npm — use `--docker <image>` or `--docker @path` as an
|
|
294
|
+
escape hatch in that case).
|
|
295
|
+
|
|
247
296
|
## Host API 🛠️
|
|
248
297
|
|
|
249
298
|
The core interface is intentionally small:
|
|
@@ -313,38 +362,6 @@ Before a call, `validateTarget(RED, targetId)` checks:
|
|
|
313
362
|
Validation does not prove that a flow terminates semantically or replies
|
|
314
363
|
exactly once. A runtime timeout remains necessary for that.
|
|
315
364
|
|
|
316
|
-
## Roadmap 🗺️
|
|
317
|
-
|
|
318
|
-
The long-term goal is a stable, official host API in Node-RED core:
|
|
319
|
-
|
|
320
|
-
```js
|
|
321
|
-
const result = await callNodeRedFlow({
|
|
322
|
-
target: "calculate",
|
|
323
|
-
msg,
|
|
324
|
-
timeout: 5000
|
|
325
|
-
});
|
|
326
|
-
```
|
|
327
|
-
|
|
328
|
-
Or as a runtime interface:
|
|
329
|
-
|
|
330
|
-
```js
|
|
331
|
-
const result = await RED.runtime.flows.call("calculate", msg, {
|
|
332
|
-
timeout: 5000
|
|
333
|
-
});
|
|
334
|
-
```
|
|
335
|
-
|
|
336
|
-
The research focuses on which parts of the existing `node.linkcall()`
|
|
337
|
-
implementation can be generalized, and what a small upstream API such as
|
|
338
|
-
`RED.nodes.callLink()` or `RED.runtime.flows.call()` could look like.
|
|
339
|
-
|
|
340
|
-
## Status 📊
|
|
341
|
-
|
|
342
|
-
**Early-stage CLI:** the approach works for the included Node-RED 5.0.4
|
|
343
|
-
example flow. The link-call internals used here are not stabilized as a
|
|
344
|
-
public Node-RED API. Production use therefore requires deliberate version
|
|
345
|
-
pinning, integration tests, and robust error handling for ambiguous or
|
|
346
|
-
non-terminating flows.
|
|
347
|
-
|
|
348
365
|
## Contributing 🤝
|
|
349
366
|
|
|
350
367
|
Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Container-side entrypoint for `--docker`, set as the sandbox image's
|
|
6
|
+
* `ENTRYPOINT` (see `src/docker-image.js`). Reads a `{ flow, msg, options }`
|
|
7
|
+
* envelope as JSON from stdin, runs it against a real Node-RED runtime via
|
|
8
|
+
* the shared `runFlowInvocation` (the exact same logic the host CLI uses
|
|
9
|
+
* for its non-Docker path), and writes the formatted result to stdout.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const { runFlowInvocation } = require("../src/run-envelope");
|
|
13
|
+
|
|
14
|
+
function readStdin() {
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
const chunks = [];
|
|
17
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
18
|
+
process.stdin.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
19
|
+
process.stdin.on("error", reject);
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function main() {
|
|
24
|
+
let envelope;
|
|
25
|
+
try {
|
|
26
|
+
const raw = await readStdin();
|
|
27
|
+
envelope = JSON.parse(raw);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.error(`node-red-cli: invalid envelope on stdin: ${error.message}`);
|
|
30
|
+
process.exitCode = 1;
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const { output } = await runFlowInvocation({
|
|
36
|
+
flow: envelope.flow,
|
|
37
|
+
msg: envelope.msg,
|
|
38
|
+
options: envelope.options || {}
|
|
39
|
+
});
|
|
40
|
+
process.stdout.write(`${output}\n`);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
console.error(`node-red-cli: ${error.message}`);
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
main();
|
package/bin/node-red-cli.js
CHANGED
|
@@ -4,12 +4,11 @@
|
|
|
4
4
|
const fs = require("node:fs");
|
|
5
5
|
const path = require("node:path");
|
|
6
6
|
const { Command } = require("commander");
|
|
7
|
-
const
|
|
8
|
-
const { createHostLinkCaller } = require("../src/link-call");
|
|
9
|
-
const { createMemoryStorageModule } = require("../src/flow-storage");
|
|
10
|
-
const { applySetParams, parseFlowJsonParam, parseFormatParam, formatPlain } = require("../src/cli-params");
|
|
7
|
+
const { applySetParams, parseFlowJsonParam, parseFormatParam } = require("../src/cli-params");
|
|
11
8
|
const { parseNodeModulesParam, resolveUserDir } = require("../src/node-modules");
|
|
12
|
-
const {
|
|
9
|
+
const { runFlowInvocation } = require("../src/run-envelope");
|
|
10
|
+
const { resolveImage } = require("../src/docker-image");
|
|
11
|
+
const { runContainer, volumeNameFor, CONTAINER_USER_DIR } = require("../src/docker-run");
|
|
13
12
|
const { version } = require("../package.json");
|
|
14
13
|
|
|
15
14
|
const HELP_TEXT = [
|
|
@@ -58,6 +57,24 @@ const HELP_TEXT = [
|
|
|
58
57
|
"`npm install`, i.e. arbitrary code execution from the configured npm",
|
|
59
58
|
"registry - only use it with trusted module names.",
|
|
60
59
|
"",
|
|
60
|
+
"--docker [value] re-executes the entire invocation (flow resolution,",
|
|
61
|
+
"link call, and any --node-modules install) inside a disposable, hardened",
|
|
62
|
+
"Docker container instead of the host process. Zero bind mounts, zero",
|
|
63
|
+
"leftover host files. <value> is one of:",
|
|
64
|
+
" - omitted (bare flag): use/build a cached local image",
|
|
65
|
+
" node-red-cli-sandbox:<installed version>, from node:24-slim + a",
|
|
66
|
+
" global npm install of this package.",
|
|
67
|
+
" - '<image[:tag]>': use an explicit image. If it already contains the",
|
|
68
|
+
" sandbox entrypoint it is used as-is; otherwise node-red-cli is",
|
|
69
|
+
" installed into a derived image on first use (cached by image+version).",
|
|
70
|
+
" - '@<path>' or an http(s) URL: build from a Dockerfile (local file or",
|
|
71
|
+
" fetched URL), cached by content hash.",
|
|
72
|
+
"Sandboxing defaults: --network none (unless --node-modules is also set,",
|
|
73
|
+
"which needs registry access), --read-only rootfs with a /tmp tmpfs,",
|
|
74
|
+
"--cap-drop=ALL, --security-opt=no-new-privileges. When combined with",
|
|
75
|
+
"--user-dir, persistence uses a named Docker volume, never a host bind",
|
|
76
|
+
"mount.",
|
|
77
|
+
"",
|
|
61
78
|
"Example:",
|
|
62
79
|
' echo \'{"payload":{"x":4,"y":5}}\' | node-red-cli flows.json calculate',
|
|
63
80
|
"",
|
|
@@ -65,31 +82,6 @@ const HELP_TEXT = [
|
|
|
65
82
|
" node-red-cli flows.json calculate --set x=4 --set y=5 < /dev/null"
|
|
66
83
|
].join("\n");
|
|
67
84
|
|
|
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
85
|
function readStdin() {
|
|
94
86
|
return new Promise((resolve, reject) => {
|
|
95
87
|
const chunks = [];
|
|
@@ -210,52 +202,66 @@ async function run(args, options) {
|
|
|
210
202
|
return;
|
|
211
203
|
}
|
|
212
204
|
|
|
213
|
-
|
|
214
|
-
|
|
205
|
+
if (options.docker) {
|
|
206
|
+
if (!flows) {
|
|
207
|
+
try {
|
|
208
|
+
flows = JSON.parse(fs.readFileSync(flowFile, "utf8"));
|
|
209
|
+
} catch (error) {
|
|
210
|
+
console.error(`node-red-cli: could not read/parse flow file '${flowFile}': ${error.message}`);
|
|
211
|
+
process.exitCode = 1;
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const volumeName = persistentUserDir ? volumeNameFor(persistentUserDir) : undefined;
|
|
217
|
+
const envelope = {
|
|
218
|
+
flow: flows,
|
|
219
|
+
msg,
|
|
220
|
+
options: {
|
|
221
|
+
target,
|
|
222
|
+
flow: options.flow,
|
|
223
|
+
timeoutMs: options.timeout,
|
|
224
|
+
format: options.format,
|
|
225
|
+
nodeModules,
|
|
226
|
+
userDir: volumeName ? CONTAINER_USER_DIR : undefined
|
|
227
|
+
}
|
|
228
|
+
};
|
|
215
229
|
|
|
216
|
-
|
|
230
|
+
let image;
|
|
231
|
+
let result;
|
|
217
232
|
try {
|
|
218
|
-
await
|
|
233
|
+
image = await resolveImage(options.docker, { version });
|
|
234
|
+
result = await runContainer(image, envelope, { networkNeeded: nodeModules.length > 0, volumeName });
|
|
219
235
|
} catch (error) {
|
|
220
|
-
console.error(
|
|
236
|
+
console.error(error.message);
|
|
221
237
|
process.exitCode = 1;
|
|
222
|
-
if (!persistentUserDir) fs.rmSync(userDir, { recursive: true, force: true });
|
|
223
238
|
return;
|
|
224
239
|
}
|
|
225
|
-
}
|
|
226
240
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
httpAdminRoot: false,
|
|
233
|
-
httpNodeRoot: false,
|
|
234
|
-
editorTheme: { projects: { enabled: false } },
|
|
235
|
-
logging: { console: { level: "warn", metrics: false, audit: false, handler: stderrLogHandler } }
|
|
236
|
-
});
|
|
241
|
+
if (result.stdout) process.stdout.write(result.stdout);
|
|
242
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
243
|
+
process.exitCode = result.code ?? 1;
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
237
246
|
|
|
238
247
|
try {
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
+
const { output } = await runFlowInvocation({
|
|
249
|
+
flow: flows,
|
|
250
|
+
flowFile,
|
|
251
|
+
msg,
|
|
252
|
+
options: {
|
|
253
|
+
target,
|
|
254
|
+
flow: options.flow,
|
|
255
|
+
timeoutMs: options.timeout,
|
|
256
|
+
format: options.format,
|
|
257
|
+
nodeModules,
|
|
258
|
+
userDir: persistentUserDir
|
|
259
|
+
}
|
|
248
260
|
});
|
|
249
|
-
process.stdout.write(
|
|
250
|
-
options.format === "plain" ? `${formatPlain(result.payload)}\n` : `${JSON.stringify(result)}\n`
|
|
251
|
-
);
|
|
261
|
+
process.stdout.write(`${output}\n`);
|
|
252
262
|
} catch (error) {
|
|
253
263
|
console.error(`node-red-cli: ${error.message}`);
|
|
254
264
|
process.exitCode = 1;
|
|
255
|
-
} finally {
|
|
256
|
-
caller?.close();
|
|
257
|
-
await RED.stop();
|
|
258
|
-
if (!persistentUserDir) fs.rmSync(userDir, { recursive: true, force: true });
|
|
259
265
|
}
|
|
260
266
|
}
|
|
261
267
|
|
|
@@ -285,6 +291,11 @@ program
|
|
|
285
291
|
collectNodeModules,
|
|
286
292
|
[]
|
|
287
293
|
)
|
|
294
|
+
.option(
|
|
295
|
+
"--docker [value]",
|
|
296
|
+
"run the invocation sandboxed in a disposable Docker container; bare = cached default image, " +
|
|
297
|
+
"'<image[:tag]>' = explicit image (installed into if missing), '@path'/URL = build from a Dockerfile"
|
|
298
|
+
)
|
|
288
299
|
.addHelpText("after", HELP_TEXT)
|
|
289
300
|
.version(version, "-v, --version", "print the installed node-red-cli version and exit")
|
|
290
301
|
.action(run);
|
package/package.json
CHANGED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Resolves a `--docker [value]` CLI argument into a concrete Docker image
|
|
5
|
+
* reference, building/caching it via the local Docker daemon as needed.
|
|
6
|
+
*
|
|
7
|
+
* Three forms of `value`:
|
|
8
|
+
* - bare (`true`/`undefined`/`""`): default sandbox image
|
|
9
|
+
* `node-red-cli-sandbox:<local package.json version>`, built on demand
|
|
10
|
+
* from `node:24-slim` + `npm install -g @tbrandenburg/node-red-cli@<version>`
|
|
11
|
+
* the first time that version is needed, then cached by Docker forever
|
|
12
|
+
* (npm registry versions are immutable, so the tag is a permanently valid
|
|
13
|
+
* cache key - a version bump is the only thing that changes it).
|
|
14
|
+
* - `<image[:tag]>`: used as-is if it already contains the sandbox
|
|
15
|
+
* entrypoint at `SANDBOX_ENTRY_PATH`. Otherwise a derived image is built
|
|
16
|
+
* on demand (`FROM <image>` + a global npm install of this package),
|
|
17
|
+
* cached by `<image>@<version>` so the check/build only happens once per
|
|
18
|
+
* image+version pair.
|
|
19
|
+
* - `@<path>` or `<http(s)-url>`: build from a user-supplied Dockerfile
|
|
20
|
+
* (local file or fetched URL), cached by content hash so an unchanged
|
|
21
|
+
* Dockerfile isn't rebuilt every run.
|
|
22
|
+
*
|
|
23
|
+
* All failures throw a plain `Error` already carrying the
|
|
24
|
+
* `node-red-cli: docker ...` prefix convention used throughout the CLI.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fs = require("node:fs");
|
|
28
|
+
const path = require("node:path");
|
|
29
|
+
const crypto = require("node:crypto");
|
|
30
|
+
const { spawnSync, spawn } = require("node:child_process");
|
|
31
|
+
const https = require("node:https");
|
|
32
|
+
const http = require("node:http");
|
|
33
|
+
|
|
34
|
+
/** Fixed path the default sandbox image installs itself into and the entrypoint runs from. */
|
|
35
|
+
const SANDBOX_ENTRY_PATH =
|
|
36
|
+
"/usr/local/lib/node_modules/@tbrandenburg/node-red-cli/bin/node-red-cli-sandbox-entry.js";
|
|
37
|
+
|
|
38
|
+
function isBareValue(value) {
|
|
39
|
+
return value === true || value === undefined || value === "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isDockerfilePath(value) {
|
|
43
|
+
return typeof value === "string" && value.startsWith("@");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isUrl(value) {
|
|
47
|
+
return typeof value === "string" && /^https?:\/\//i.test(value);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Fails fast with a clear error if the Docker CLI/daemon isn't reachable. */
|
|
51
|
+
function checkDockerAvailable() {
|
|
52
|
+
const result = spawnSync("docker", ["info"], { stdio: ["ignore", "ignore", "pipe"] });
|
|
53
|
+
if (result.error) {
|
|
54
|
+
throw new Error(`node-red-cli: docker unavailable: ${result.error.message}`);
|
|
55
|
+
}
|
|
56
|
+
if (result.status !== 0) {
|
|
57
|
+
const detail = (result.stderr || Buffer.alloc(0)).toString("utf8").trim();
|
|
58
|
+
throw new Error(`node-red-cli: docker unavailable: ${detail || "docker info failed"}`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function imageExists(tag) {
|
|
63
|
+
const result = spawnSync("docker", ["image", "inspect", tag], { stdio: ["ignore", "ignore", "ignore"] });
|
|
64
|
+
return result.status === 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Checks whether `image` already has the sandbox entrypoint file at `SANDBOX_ENTRY_PATH`. */
|
|
68
|
+
function hasSandboxEntry(image) {
|
|
69
|
+
const result = spawnSync(
|
|
70
|
+
"docker",
|
|
71
|
+
["run", "--rm", "--entrypoint", "sh", image, "-c", `test -f ${SANDBOX_ENTRY_PATH}`],
|
|
72
|
+
{ stdio: ["ignore", "ignore", "ignore"] }
|
|
73
|
+
);
|
|
74
|
+
return result.status === 0;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Deterministic derived-image tag for a given base image + node-red-cli version. */
|
|
78
|
+
function derivedTag(image, version) {
|
|
79
|
+
const hash = crypto.createHash("sha256").update(`${image}@${version}`).digest("hex").slice(0, 16);
|
|
80
|
+
return `node-red-cli-sandbox-derived:${hash}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function derivedDockerfile(image, version) {
|
|
84
|
+
return [
|
|
85
|
+
`FROM ${image}`,
|
|
86
|
+
`RUN npm install -g @tbrandenburg/node-red-cli@${version}`,
|
|
87
|
+
`ENTRYPOINT ["node", "${SANDBOX_ENTRY_PATH}"]`,
|
|
88
|
+
""
|
|
89
|
+
].join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Runs `docker build -t <tag> -`, feeding `dockerfileContent` in via stdin (no build context needed). */
|
|
93
|
+
function dockerBuild(tag, dockerfileContent) {
|
|
94
|
+
return new Promise((resolve, reject) => {
|
|
95
|
+
const child = spawn("docker", ["build", "-t", tag, "-"], { stdio: ["pipe", "pipe", "pipe"] });
|
|
96
|
+
let stderr = "";
|
|
97
|
+
let stdout = "";
|
|
98
|
+
child.stdout.on("data", (chunk) => (stdout += chunk));
|
|
99
|
+
child.stderr.on("data", (chunk) => (stderr += chunk));
|
|
100
|
+
child.on("error", (error) => {
|
|
101
|
+
reject(new Error(`node-red-cli: docker build failed: ${error.message}`));
|
|
102
|
+
});
|
|
103
|
+
child.on("close", (code) => {
|
|
104
|
+
if (code !== 0) {
|
|
105
|
+
reject(new Error(`node-red-cli: docker build failed: ${(stderr || stdout).trim()}`));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
resolve();
|
|
109
|
+
});
|
|
110
|
+
child.stdin.end(dockerfileContent);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function defaultDockerfile(version) {
|
|
115
|
+
return [
|
|
116
|
+
"FROM node:24-slim",
|
|
117
|
+
`RUN npm install -g @tbrandenburg/node-red-cli@${version}`,
|
|
118
|
+
`ENTRYPOINT ["node", "${SANDBOX_ENTRY_PATH}"]`,
|
|
119
|
+
""
|
|
120
|
+
].join("\n");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function customTag(content) {
|
|
124
|
+
const hash = crypto.createHash("sha256").update(content).digest("hex").slice(0, 16);
|
|
125
|
+
return `node-red-cli-sandbox-custom:${hash}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function fetchText(url) {
|
|
129
|
+
return new Promise((resolve, reject) => {
|
|
130
|
+
const client = url.startsWith("https://") ? https : http;
|
|
131
|
+
client
|
|
132
|
+
.get(url, (response) => {
|
|
133
|
+
if (response.statusCode !== 200) {
|
|
134
|
+
reject(new Error(`HTTP ${response.statusCode}`));
|
|
135
|
+
response.resume();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const chunks = [];
|
|
139
|
+
response.on("data", (chunk) => chunks.push(chunk));
|
|
140
|
+
response.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
141
|
+
response.on("error", reject);
|
|
142
|
+
})
|
|
143
|
+
.on("error", reject);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function readDockerfileFrom(value, cwd) {
|
|
148
|
+
if (isDockerfilePath(value)) {
|
|
149
|
+
const filePath = path.resolve(cwd, value.slice(1));
|
|
150
|
+
try {
|
|
151
|
+
return fs.readFileSync(filePath, "utf8");
|
|
152
|
+
} catch (error) {
|
|
153
|
+
throw new Error(
|
|
154
|
+
`node-red-cli: docker build failed: could not read Dockerfile '${filePath}': ${error.message}`,
|
|
155
|
+
{
|
|
156
|
+
cause: error
|
|
157
|
+
}
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
return await fetchText(value);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`node-red-cli: docker build failed: could not fetch Dockerfile from '${value}': ${error.message}`,
|
|
167
|
+
{ cause: error }
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Resolves `dockerValue` (the `--docker [value]` option) into an image
|
|
174
|
+
* reference, building it if not already cached. `version` is the local
|
|
175
|
+
* `package.json` version, used for the default (bare) form.
|
|
176
|
+
*/
|
|
177
|
+
async function resolveImage(dockerValue, { version, cwd = process.cwd() } = {}) {
|
|
178
|
+
checkDockerAvailable();
|
|
179
|
+
|
|
180
|
+
if (isBareValue(dockerValue)) {
|
|
181
|
+
const tag = `node-red-cli-sandbox:${version}`;
|
|
182
|
+
if (!imageExists(tag)) {
|
|
183
|
+
await dockerBuild(tag, defaultDockerfile(version));
|
|
184
|
+
}
|
|
185
|
+
return tag;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (isDockerfilePath(dockerValue) || isUrl(dockerValue)) {
|
|
189
|
+
const content = await readDockerfileFrom(dockerValue, cwd);
|
|
190
|
+
const tag = customTag(content);
|
|
191
|
+
if (!imageExists(tag)) {
|
|
192
|
+
await dockerBuild(tag, content);
|
|
193
|
+
}
|
|
194
|
+
return tag;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Explicit image[:tag] reference: use as-is if it already has the sandbox
|
|
198
|
+
// entrypoint, otherwise install node-red-cli into a derived image on
|
|
199
|
+
// first use (cached by image+version, so this only happens once).
|
|
200
|
+
if (hasSandboxEntry(dockerValue)) {
|
|
201
|
+
return dockerValue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const tag = derivedTag(dockerValue, version);
|
|
205
|
+
if (!imageExists(tag)) {
|
|
206
|
+
await dockerBuild(tag, derivedDockerfile(dockerValue, version));
|
|
207
|
+
}
|
|
208
|
+
return tag;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
module.exports = { resolveImage, SANDBOX_ENTRY_PATH };
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Builds and executes the hardened `docker run` invocation for `--docker`:
|
|
5
|
+
* streams the envelope (`{ flow, msg, options }`) over stdin to a disposable
|
|
6
|
+
* container running the resolved sandbox image, relays stdout/stderr/exit
|
|
7
|
+
* code back to the host.
|
|
8
|
+
*
|
|
9
|
+
* Sandboxing defaults (always applied):
|
|
10
|
+
* - `--rm -i` (always disposable, interactive stdin)
|
|
11
|
+
* - `--network none`, unless `networkNeeded` (i.e. `--node-modules` is set)
|
|
12
|
+
* - `--read-only` root filesystem + a `/tmp` tmpfs mount
|
|
13
|
+
* - `--cap-drop=ALL`
|
|
14
|
+
* - `--security-opt=no-new-privileges`
|
|
15
|
+
*
|
|
16
|
+
* When `volumeName` is given (derived from `--user-dir`, see
|
|
17
|
+
* `volumeNameFor`), it is mounted as a named Docker volume at
|
|
18
|
+
* `CONTAINER_USER_DIR` instead of a host bind mount, so `--user-dir` +
|
|
19
|
+
* `--node-modules` persistence never touches the visible host filesystem.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const crypto = require("node:crypto");
|
|
23
|
+
const { spawn } = require("node:child_process");
|
|
24
|
+
|
|
25
|
+
/** Fixed in-container mount path for the `--user-dir` named volume. */
|
|
26
|
+
const CONTAINER_USER_DIR = "/data/userDir";
|
|
27
|
+
|
|
28
|
+
/** Deterministic named-volume name for a given `--user-dir` path, so repeat runs reuse the same volume. */
|
|
29
|
+
function volumeNameFor(userDirPath) {
|
|
30
|
+
const hash = crypto.createHash("sha256").update(userDirPath).digest("hex").slice(0, 16);
|
|
31
|
+
return `node-red-cli-userdir-${hash}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function buildRunArgs(image, { networkNeeded, volumeName }) {
|
|
35
|
+
const args = ["run", "--rm", "-i"];
|
|
36
|
+
if (!networkNeeded) args.push("--network", "none");
|
|
37
|
+
args.push("--read-only", "--tmpfs", "/tmp", "--cap-drop=ALL", "--security-opt=no-new-privileges");
|
|
38
|
+
if (networkNeeded) {
|
|
39
|
+
// --node-modules runs a real `npm install`, which needs a writable cache
|
|
40
|
+
// dir; point it at the /tmp tmpfs since the root filesystem is read-only.
|
|
41
|
+
args.push("-e", "npm_config_cache=/tmp/.npm-cache");
|
|
42
|
+
}
|
|
43
|
+
if (volumeName) args.push("-v", `${volumeName}:${CONTAINER_USER_DIR}`);
|
|
44
|
+
args.push(image);
|
|
45
|
+
return args;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Runs `envelope` through the resolved sandbox `image` in a disposable,
|
|
50
|
+
* hardened container. Resolves with `{ code, stdout, stderr }` on any
|
|
51
|
+
* container exit (including non-zero, which is the flow's own error exit
|
|
52
|
+
* code, not a docker-orchestration failure); rejects only when `docker run`
|
|
53
|
+
* itself could not be spawned or exited before the container's own process
|
|
54
|
+
* ran (docker CLI missing, daemon unreachable, invalid image, etc.).
|
|
55
|
+
*/
|
|
56
|
+
function runContainer(image, envelope, { networkNeeded = false, volumeName } = {}) {
|
|
57
|
+
const args = buildRunArgs(image, { networkNeeded, volumeName });
|
|
58
|
+
|
|
59
|
+
return new Promise((resolve, reject) => {
|
|
60
|
+
const child = spawn("docker", args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
61
|
+
let stdout = "";
|
|
62
|
+
let stderr = "";
|
|
63
|
+
let spawnFailed = false;
|
|
64
|
+
|
|
65
|
+
child.stdout.on("data", (chunk) => (stdout += chunk));
|
|
66
|
+
child.stderr.on("data", (chunk) => (stderr += chunk));
|
|
67
|
+
child.on("error", (error) => {
|
|
68
|
+
spawnFailed = true;
|
|
69
|
+
reject(new Error(`node-red-cli: docker run failed: ${error.message}`));
|
|
70
|
+
});
|
|
71
|
+
child.on("close", (code) => {
|
|
72
|
+
if (spawnFailed) return;
|
|
73
|
+
resolve({ code, stdout, stderr });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
child.stdin.on("error", () => {
|
|
77
|
+
// A broken pipe here (e.g. the container failed to start) is reported
|
|
78
|
+
// via the 'close'/'error' handlers above; swallow the EPIPE itself.
|
|
79
|
+
});
|
|
80
|
+
child.stdin.end(JSON.stringify(envelope));
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = { runContainer, buildRunArgs, volumeNameFor, CONTAINER_USER_DIR };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const path = require("node:path");
|
|
5
|
+
const os = require("node:os");
|
|
6
|
+
const RED = require("node-red");
|
|
7
|
+
const { createHostLinkCaller } = require("./link-call");
|
|
8
|
+
const { createMemoryStorageModule } = require("./flow-storage");
|
|
9
|
+
const { formatPlain } = require("./cli-params");
|
|
10
|
+
const { installMissingNodeModules } = require("./node-modules-install");
|
|
11
|
+
|
|
12
|
+
const LEVEL_NAMES = {
|
|
13
|
+
10: "fatal",
|
|
14
|
+
20: "error",
|
|
15
|
+
30: "warn",
|
|
16
|
+
40: "info",
|
|
17
|
+
50: "debug",
|
|
18
|
+
60: "trace",
|
|
19
|
+
98: "audit",
|
|
20
|
+
99: "metric"
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Node-RED's built-in console log handler always writes via console.log,
|
|
25
|
+
* i.e. to stdout. That would corrupt the JSON result on stdout, so replace
|
|
26
|
+
* it with a handler that writes to stderr instead.
|
|
27
|
+
*/
|
|
28
|
+
function stderrLogHandler() {
|
|
29
|
+
return (msg) => {
|
|
30
|
+
const levelName = LEVEL_NAMES[msg.level] || msg.level;
|
|
31
|
+
const source = msg.type ? `[${msg.type}:${msg.name || msg.id}] ` : "";
|
|
32
|
+
const message = msg.msg && msg.msg.message ? msg.msg.message : msg.msg;
|
|
33
|
+
console.error(`node-red-cli: [${levelName}] ${source}${message}`);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Runs a single link-call invocation against a real, freshly booted
|
|
39
|
+
* Node-RED runtime: installs any missing `--node-modules`, boots RED with
|
|
40
|
+
* either an in-memory flow array (`flow`) or a flow file path (`flowFile`),
|
|
41
|
+
* calls the target link-in node, tears everything down again, and returns
|
|
42
|
+
* the formatted stdout output.
|
|
43
|
+
*
|
|
44
|
+
* This is the single shared implementation used by both the host CLI
|
|
45
|
+
* (`bin/node-red-cli.js`, non-Docker path) and the containerized sandbox
|
|
46
|
+
* entrypoint (`bin/node-red-cli-sandbox-entry.js`, `--docker` path), so both
|
|
47
|
+
* execute the exact same runtime logic.
|
|
48
|
+
*
|
|
49
|
+
* `options.userDir`, when set, is treated as a persistent directory and is
|
|
50
|
+
* never removed afterward (host: an explicit `--user-dir`; container: the
|
|
51
|
+
* fixed mount path of a named Docker volume). When omitted, an ephemeral
|
|
52
|
+
* tmpdir is created and removed again after the call.
|
|
53
|
+
*/
|
|
54
|
+
async function runFlowInvocation({ flow, flowFile, msg, options }) {
|
|
55
|
+
const {
|
|
56
|
+
target,
|
|
57
|
+
flow: flowSelector,
|
|
58
|
+
timeoutMs = 5000,
|
|
59
|
+
format = "plain",
|
|
60
|
+
nodeModules = [],
|
|
61
|
+
userDir: fixedUserDir
|
|
62
|
+
} = options;
|
|
63
|
+
|
|
64
|
+
const persistentUserDir = Boolean(fixedUserDir);
|
|
65
|
+
const userDir = fixedUserDir || fs.mkdtempSync(path.join(os.tmpdir(), "node-red-cli-"));
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
if (nodeModules.length > 0) {
|
|
69
|
+
await installMissingNodeModules(userDir, nodeModules);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let caller;
|
|
73
|
+
RED.init({
|
|
74
|
+
...(flow ? { storageModule: createMemoryStorageModule(flow) } : { flowFile }),
|
|
75
|
+
userDir,
|
|
76
|
+
httpAdminRoot: false,
|
|
77
|
+
httpNodeRoot: false,
|
|
78
|
+
editorTheme: { projects: { enabled: false } },
|
|
79
|
+
logging: { console: { level: "warn", metrics: false, audit: false, handler: stderrLogHandler } }
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const flowsStarted = new Promise((resolve) => RED.events.once("flows:started", resolve));
|
|
84
|
+
await RED.start();
|
|
85
|
+
await flowsStarted;
|
|
86
|
+
|
|
87
|
+
caller = createHostLinkCaller(RED);
|
|
88
|
+
const result = await caller.call(target, msg, {
|
|
89
|
+
flow: flowSelector,
|
|
90
|
+
timeout: timeoutMs,
|
|
91
|
+
onWarning: (warning) => console.error(`node-red-cli: warning: ${warning}`)
|
|
92
|
+
});
|
|
93
|
+
return { output: format === "plain" ? formatPlain(result.payload) : JSON.stringify(result) };
|
|
94
|
+
} finally {
|
|
95
|
+
caller?.close();
|
|
96
|
+
await RED.stop();
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
if (!persistentUserDir) fs.rmSync(userDir, { recursive: true, force: true });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { runFlowInvocation, stderrLogHandler };
|