@tbrandenburg/node-red-cli 0.2.7 → 0.2.9

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 CHANGED
@@ -4,12 +4,9 @@
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
5
  [![Node.js >=24](https://img.shields.io/badge/node-%3E%3D24-brightgreen.svg)](package.json)
6
6
 
7
- > Install the CLI with `npm install -g @tbrandenburg/node-red-cli`.
8
-
9
- ## Call Node-RED flows like Unix functions ⚡
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,29 @@ node-red-cli flows.json calculate --set x=4 --set y=5 < /dev/null
19
16
  9
20
17
  ```
21
18
 
22
- By default only the resulting payload is printed as plain text. Pass
23
- `--format=json` to print the full result object as JSON instead:
24
-
25
- ```bash
26
- node-red-cli test/fixtures/flows.json calculate \
27
- --set x=4 --set y=5 --format=json < /dev/null
28
- ```
29
-
30
- ```json
31
- { "payload": 9, "_msgid": "..." }
32
- ```
33
-
34
19
  This turns Node-RED from a visual automation tool into a reusable runtime
35
20
  building block for scripts, services, pipelines, and developer tooling. 🧩
36
21
 
37
- ## Install 📦
38
-
39
- ```bash
40
- npm install -g @tbrandenburg/node-red-cli
41
- ```
42
-
43
- This installs the `node-red-cli` command globally, ready to use against
44
- any Node-RED flow file (see [Quick start](#quick-start-) below).
22
+ ## Table of contents
23
+
24
+ - [The idea](#the-idea-)
25
+ - [Why node-red-cli?](#why-node-red-cli-)
26
+ - [Current state](#current-state-)
27
+ - [Project layout](#project-layout-)
28
+ - [Install](#install-)
29
+ - [Usage](#usage-)
30
+ - [Quick start](#quick-start)
31
+ - [Passing flow JSON inline](#passing-flow-json-inline)
32
+ - [Installing additional Node-RED node packages](#installing-additional-node-red-node-packages)
33
+ - [Running sandboxed in Docker](#running-sandboxed-in-docker-)
34
+ - [Host API](#host-api-)
35
+ - [Technical approach](#technical-approach-)
36
+ - [Preflight and limitations](#preflight-and-limitations-)
37
+ - [Roadmap](#roadmap-)
38
+ - [Status](#status-)
39
+ - [Contributing](#contributing-)
40
+ - [Security](#security-)
41
+ - [License](#license-)
45
42
 
46
43
  ## The idea 💡
47
44
 
@@ -77,6 +74,8 @@ no permanently deployed adapter structure. 🚫🔧
77
74
  `stderr`.
78
75
  - **No flow mutation:** the current implementation adds no temporary nodes and
79
76
  never redeploys `flows.json`.
77
+ - **Optional sandboxing:** `--docker` re-executes a call inside a disposable,
78
+ hardened container instead of the host process.
80
79
 
81
80
  ## Current state 🚧
82
81
 
@@ -108,7 +107,14 @@ test/e2e/ Full round trip through the example flow
108
107
  test/fixtures/ Example Node-RED flow used as a test asset
109
108
  ```
110
109
 
111
- ## Quick start 🚀
110
+ ## Install 📦
111
+
112
+ ```bash
113
+ npm install -g @tbrandenburg/node-red-cli
114
+ ```
115
+
116
+ This installs the `node-red-cli` command globally, ready to use against any
117
+ Node-RED flow file (see [Usage](#usage-) below).
112
118
 
113
119
  To build and run from a repo checkout instead (e.g. for contributing):
114
120
 
@@ -118,15 +124,18 @@ make test
118
124
  ```
119
125
 
120
126
  `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
127
+ (format, lint, and tests) automatically before every push. To use
128
+ `node-red-cli` as a regular command from a repo checkout, install it
124
129
  globally from the local source:
125
130
 
126
131
  ```bash
127
132
  make install-global
128
133
  ```
129
134
 
135
+ ## Usage 🚀
136
+
137
+ ### Quick start
138
+
130
139
  Try the CLI directly against the example flow:
131
140
 
132
141
  ```bash
@@ -137,8 +146,18 @@ echo '{"payload":{"x":4,"y":5}}' | node-red-cli test/fixtures/flows.json calcula
137
146
  9
138
147
  ```
139
148
 
140
- The `_msgid` is generated by Node-RED and differs on every run. To see it
141
- along with the rest of the result object, pass `--format=json`.
149
+ By default only the resulting payload is printed as plain text. Pass
150
+ `--format=json` to print the full result object as JSON instead (the
151
+ `_msgid` is generated by Node-RED and differs on every run):
152
+
153
+ ```bash
154
+ node-red-cli test/fixtures/flows.json calculate \
155
+ --set x=4 --set y=5 --format=json < /dev/null
156
+ ```
157
+
158
+ ```json
159
+ { "payload": 9, "_msgid": "..." }
160
+ ```
142
161
 
143
162
  The `target` argument is optional; if the flow has exactly one `link in`
144
163
  node, it is used automatically (with a warning on stderr if it also had to be
@@ -163,7 +182,7 @@ node-red-cli test/fixtures/flows.json calculate \
163
182
  9
164
183
  ```
165
184
 
166
- ## Passing flow JSON inline 📥
185
+ ### Passing flow JSON inline
167
186
 
168
187
  Instead of a `<flows.json>` file path, `--flow-json <value>` accepts the flow
169
188
  definition directly, so in-memory callers (tests, another Node.js process, a
@@ -200,7 +219,7 @@ node-red-cli --flow-json - calculate --set x=4 --set y=5 \
200
219
  9
201
220
  ```
202
221
 
203
- ## Installing additional Node-RED node packages 📦
222
+ ### Installing additional Node-RED node packages
204
223
 
205
224
  By default the CLI creates a fresh, ephemeral Node-RED `userDir` per
206
225
  invocation and deletes it afterwards, so only the node types bundled with
@@ -244,6 +263,59 @@ Node-RED runtime/state files (e.g. `.config.runtime.json`) across runs.
244
263
  Delete the directory (or the default `~/.cache/node-red-cli`) to clear the
245
264
  cache and start fresh.
246
265
 
266
+ ### Running sandboxed in Docker 🐳
267
+
268
+ `--docker [value]` re-executes the _entire_ invocation (flow resolution,
269
+ link call, and any `--node-modules` install) inside a disposable, hardened
270
+ Docker container instead of the host process — useful when `--node-modules`
271
+ installs untrusted community packages, since that's a real code-execution
272
+ surface. Works for both `<flows.json>` (from-file) and `--flow-json` modes,
273
+ with **zero bind mounts and zero leftover host files**: the resolved flow
274
+ and message are streamed over the container's stdin as a single JSON
275
+ envelope, never written to disk.
276
+
277
+ ```bash
278
+ node-red-cli flows.json calculate --set x=4 --set y=5 --docker
279
+ ```
280
+
281
+ `<value>` is one of:
282
+
283
+ - **omitted (bare flag)**: resolves/builds a locally-cached image tagged
284
+ `node-red-cli-sandbox:<installed node-red-cli version>`, built from
285
+ `node:24-slim` + a global `npm install` of this package from the public
286
+ npm registry. Cached by Docker forever afterward (npm registry versions
287
+ are immutable, so a version bump is the only thing that invalidates the
288
+ tag) — later runs of the same version need no network access beyond the
289
+ container's own sandboxed execution.
290
+ - **`<image[:tag]>`**: use an explicit image. If it already contains the
291
+ sandbox entrypoint, it's used as-is; otherwise `node-red-cli` is
292
+ installed into a derived image (`FROM <image>` + a global npm install)
293
+ on first use, cached by image+version so the check/build only happens
294
+ once per image.
295
+ - **`@<path>`** or an **http(s) URL**: build from a user-supplied
296
+ Dockerfile (local file or fetched URL), cached by content hash so an
297
+ unchanged Dockerfile isn't rebuilt every run.
298
+
299
+ Sandboxing defaults applied to every `--docker` run:
300
+
301
+ - `--rm -i` (always disposable)
302
+ - `--network none`, unless `--node-modules` is also given (needs registry
303
+ access) — narrowest network exposure by default
304
+ - `--read-only` root filesystem + a `/tmp` tmpfs mount
305
+ - `--cap-drop=ALL`
306
+ - `--security-opt=no-new-privileges`
307
+
308
+ Combined with `--user-dir` + `--node-modules`, persistence uses a
309
+ deterministic **named Docker volume** (derived from the `--user-dir` value)
310
+ mounted inside the container, never a host bind mount — so "no stray host
311
+ files" holds even for persistent installs.
312
+
313
+ Fails fast with a clear `node-red-cli: docker unavailable: ...` error if
314
+ the Docker CLI/daemon isn't reachable, or `node-red-cli: docker build
315
+ failed: ...` if the image build fails (e.g. the local version isn't yet
316
+ published to npm — use `--docker <image>` or `--docker @path` as an
317
+ escape hatch in that case).
318
+
247
319
  ## Host API 🛠️
248
320
 
249
321
  The core interface is intentionally small:
@@ -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();
@@ -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 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");
7
+ const { applySetParams, parseFlowJsonParam, parseFormatParam } = require("../src/cli-params");
11
8
  const { parseNodeModulesParam, resolveUserDir } = require("../src/node-modules");
12
- const { installMissingNodeModules } = require("../src/node-modules-install");
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
- const userDir =
214
- persistentUserDir || fs.mkdtempSync(path.join(require("node:os").tmpdir(), "node-red-cli-"));
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
- if (nodeModules.length > 0) {
230
+ let image;
231
+ let result;
217
232
  try {
218
- await installMissingNodeModules(userDir, nodeModules);
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(`node-red-cli: ${error.message}`);
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
- 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
- });
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 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
+ 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tbrandenburg/node-red-cli",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "Call existing Node-RED flows from Node.js and the command line",
5
5
  "main": "src/link-call.js",
6
6
  "bin": {
@@ -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 };