@tbrandenburg/node-red-cli 0.2.12 → 0.2.14

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
@@ -54,6 +54,7 @@ make install-global
54
54
  - [Passing flow JSON inline](#passing-flow-json-inline)
55
55
  - [Installing additional Node-RED node packages](#installing-additional-node-red-node-packages)
56
56
  - [Running sandboxed in Docker](#running-sandboxed-in-docker-)
57
+ - [Agentic workflows](#agentic-workflows-)
57
58
  - [Host API](#host-api-)
58
59
  - [Technical approach](#technical-approach-)
59
60
  - [Preflight and limitations](#preflight-and-limitations-)
@@ -277,7 +278,9 @@ Sandboxing defaults applied to every `--docker` run:
277
278
 
278
279
  - `--rm -i` (always disposable)
279
280
  - `--network none`, unless `--node-modules` is also given (needs registry
280
- access) narrowest network exposure by default
281
+ access) or `--network` is passed explicitly (enables network access for a
282
+ flow that needs to call out, independent of installing any package) —
283
+ narrowest network exposure by default
281
284
  - `--read-only` root filesystem + a `/tmp` tmpfs mount
282
285
  - `--cap-drop=ALL`
283
286
  - `--security-opt=no-new-privileges`
@@ -293,6 +296,49 @@ failed: ...` if the image build fails (e.g. the local version isn't yet
293
296
  published to npm — use `--docker <image>` or `--docker @path` as an
294
297
  escape hatch in that case).
295
298
 
299
+ ### Agentic workflows 🤖
300
+
301
+ Node-RED nodes such as [`agent`](https://www.npmjs.com/package/@tbrandenburg/node-red-agents)
302
+ (OpenCode, `pi`) turn a `link in -> agent -> link out (return)` flow into a
303
+ callable AI step, invoked like any other target — a JSON flow with an
304
+ inline multiline prompt, in one command:
305
+
306
+ ```bash
307
+ echo '{"payload":"Summarize this repo in one sentence.","cwd":"/repo"}' \
308
+ | node-red-cli --flow-json '[
309
+ {"id":"tab","type":"tab","label":"Agent"},
310
+ {"id":"ask","type":"link in","z":"tab","name":"ask","wires":[["agent"]]},
311
+ {"id":"agent","type":"agent","z":"tab","name":"opencode","agent":"opencode",
312
+ "runtime":"direct","prompt":"payload","promptType":"msg",
313
+ "cwd":"cwd","cwdType":"msg","wires":[["return"],[]]},
314
+ {"id":"return","type":"link out","z":"tab","name":"return","mode":"return"}
315
+ ]' ask --node-modules @tbrandenburg/node-red-agents --user-dir --timeout=120000 --format=json
316
+ ```
317
+
318
+ The same flow runs sandboxed via `--docker <image>` against an image that
319
+ already ships `opencode` + `node-red-agents`, e.g.
320
+ [`ghcr.io/tbrandenburg/agentic-workflow-dev-env`](https://github.com/tbrandenburg/agentic-workflow-dev-env)
321
+ (`--network` is required for network access, since the agent calls out to
322
+ its own API; `--node-modules`/`--user-dir` are still required too, since
323
+ Node-RED only discovers node types from a userDir it actually loaded —
324
+ see [#24](https://github.com/tbrandenburg/node-red-cli/issues/24) for a
325
+ currently-tracked compatibility gap when the image's own default userDir
326
+ already ships the package):
327
+
328
+ ```bash
329
+ echo '{"payload":"Summarize this repo in one sentence.","cwd":"/repo"}' \
330
+ | node-red-cli --flow-json '[
331
+ {"id":"tab","type":"tab","label":"Agent"},
332
+ {"id":"ask","type":"link in","z":"tab","name":"ask","wires":[["agent"]]},
333
+ {"id":"agent","type":"agent","z":"tab","name":"opencode","agent":"opencode",
334
+ "runtime":"direct","prompt":"payload","promptType":"msg",
335
+ "cwd":"cwd","cwdType":"msg","wires":[["return"],[]]},
336
+ {"id":"return","type":"link out","z":"tab","name":"return","mode":"return"}
337
+ ]' ask --docker ghcr.io/tbrandenburg/agentic-workflow-dev-env:latest \
338
+ --node-modules @tbrandenburg/node-red-agents --user-dir --network \
339
+ --timeout=120000 --format=json
340
+ ```
341
+
296
342
  ## Host API 🛠️
297
343
 
298
344
  The core interface is intentionally small:
@@ -70,10 +70,11 @@ const HELP_TEXT = [
70
70
  " - '@<path>' or an http(s) URL: build from a Dockerfile (local file or",
71
71
  " fetched URL), cached by content hash.",
72
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.",
73
+ "which needs registry access, or --network is passed explicitly to enable",
74
+ "network access independent of installing any package), --read-only",
75
+ "rootfs with a /tmp tmpfs, --cap-drop=ALL, --security-opt=no-new-privileges.",
76
+ "When combined with --user-dir, persistence uses a named Docker volume,",
77
+ "never a host bind mount.",
77
78
  "",
78
79
  "Example:",
79
80
  ' echo \'{"payload":{"x":4,"y":5}}\' | node-red-cli flows.json calculate',
@@ -231,7 +232,10 @@ async function run(args, options) {
231
232
  let result;
232
233
  try {
233
234
  image = await resolveImage(options.docker, { version });
234
- result = await runContainer(image, envelope, { networkNeeded: nodeModules.length > 0, volumeName });
235
+ result = await runContainer(image, envelope, {
236
+ networkNeeded: nodeModules.length > 0 || options.network,
237
+ volumeName
238
+ });
235
239
  } catch (error) {
236
240
  console.error(error.message);
237
241
  process.exitCode = 1;
@@ -296,6 +300,10 @@ program
296
300
  "run the invocation sandboxed in a disposable Docker container; bare = cached default image, " +
297
301
  "'<image[:tag]>' = explicit image (installed into if missing), '@path'/URL = build from a Dockerfile"
298
302
  )
303
+ .option(
304
+ "--network",
305
+ "enable network access in --docker mode, independent of --node-modules (default: --network none)"
306
+ )
299
307
  .addHelpText("after", HELP_TEXT)
300
308
  .version(version, "-v, --version", "print the installed node-red-cli version and exit")
301
309
  .action(run);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tbrandenburg/node-red-cli",
3
- "version": "0.2.12",
3
+ "version": "0.2.14",
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": {
@@ -87,9 +87,16 @@ function derivedDockerfile(image, version) {
87
87
  // The derived image is left running as root - restoring the original
88
88
  // image's default user would require inspecting the base image at build
89
89
  // time, which is out of scope here.
90
+ //
91
+ // Force the npm global prefix to /usr/local regardless of what the base
92
+ // image's own NPM_CONFIG_PREFIX/.npmrc sets: SANDBOX_ENTRY_PATH is
93
+ // hardcoded to /usr/local, so an install that lands elsewhere would
94
+ // silently succeed at build time but fail with MODULE_NOT_FOUND at
95
+ // `docker run`.
90
96
  return [
91
97
  `FROM ${image}`,
92
98
  "USER root",
99
+ "ENV NPM_CONFIG_PREFIX=/usr/local",
93
100
  `RUN npm install -g @tbrandenburg/node-red-cli@${version}`,
94
101
  `ENTRYPOINT ["node", "${SANDBOX_ENTRY_PATH}"]`,
95
102
  ""
package/src/docker-run.js CHANGED
@@ -8,7 +8,8 @@
8
8
  *
9
9
  * Sandboxing defaults (always applied):
10
10
  * - `--rm -i` (always disposable, interactive stdin)
11
- * - `--network none`, unless `networkNeeded` (i.e. `--node-modules` is set)
11
+ * - `--network none`, unless `networkNeeded` (i.e. `--node-modules` and/or
12
+ * the CLI's `--network` flag is set)
12
13
  * - `--read-only` root filesystem + a `/tmp` tmpfs mount
13
14
  * - `-e HOME=/tmp`, so tools needing a writable `$HOME` (config/cache dirs)
14
15
  * land on the writable `/tmp` tmpfs instead of the read-only rootfs
@@ -74,7 +74,13 @@ function checkNpmAvailable() {
74
74
  });
75
75
  }
76
76
 
77
- /** Runs `npm install <name>[@version]` into `userDir`, returns on success, throws a clear error otherwise. */
77
+ /**
78
+ * Runs `npm install <name>[@version]` into `userDir`, returns on success,
79
+ * throws a clear error otherwise. Passes an explicit `--prefix <userDir>`
80
+ * so npm's own project-root/workspace detection can't walk up to an
81
+ * ancestor directory's `node_modules` when `userDir` is fresh/empty
82
+ * (see issue #24); `cwd` is kept as-is for the npm CLI invocation itself.
83
+ */
78
84
  function npmInstall(userDir, { name, version }, timeoutMs = 5 * 60 * 1000) {
79
85
  const installName = version ? `${name}@${version}` : name;
80
86
  const args = [
@@ -85,6 +91,8 @@ function npmInstall(userDir, { name, version }, timeoutMs = 5 * 60 * 1000) {
85
91
  "--no-fund",
86
92
  "--save",
87
93
  "--omit=dev",
94
+ "--prefix",
95
+ userDir,
88
96
  "--",
89
97
  installName
90
98
  ];
@@ -144,5 +152,6 @@ module.exports = {
144
152
  isModuleInstalled,
145
153
  diffMissingModules,
146
154
  installMissingNodeModules,
147
- checkNpmAvailable
155
+ checkNpmAvailable,
156
+ npmInstall
148
157
  };
@@ -34,6 +34,48 @@ function stderrLogHandler() {
34
34
  };
35
35
  }
36
36
 
37
+ /**
38
+ * Waits for Node-RED to finish attempting to start the deployed flows.
39
+ *
40
+ * `RED.start()` resolves as soon as the runtime itself has booted, but the
41
+ * actual flow deploy happens asynchronously afterward and normally signals
42
+ * completion via a one-off `flows:started` event. However, when the flow
43
+ * references a node type that isn't registered (or another deploy-blocking
44
+ * condition applies, e.g. missing external modules or safe mode), Node-RED's
45
+ * `Flow.start()` logs the problem and returns *without* ever emitting
46
+ * `flows:started` (see `@node-red/runtime/lib/flows/index.js`). Awaiting
47
+ * only `flows:started` would then hang forever; since nothing else keeps
48
+ * the event loop alive, the process exits silently with code 0 once the
49
+ * loop drains, abandoning the pending call.
50
+ *
51
+ * Node-RED does always emit a `runtime-event` with id `runtime-state` in
52
+ * both cases: `payload.state === "start"` on success, and
53
+ * `payload.state === "stop"` / `"safe"` on any of the early-return failure
54
+ * paths. Racing both events lets us return as soon as Node-RED has settled
55
+ * either way; if the flows never actually started, the target/return nodes
56
+ * simply won't be instantiated and the existing preflight validation in
57
+ * `createHostLinkCaller` reports the real, specific error instead.
58
+ */
59
+ function waitForFlowsSettled(RED) {
60
+ return new Promise((resolve) => {
61
+ const onStarted = () => {
62
+ RED.events.removeListener("runtime-event", onRuntimeEvent);
63
+ resolve();
64
+ };
65
+ const onRuntimeEvent = (event) => {
66
+ if (
67
+ event?.id === "runtime-state" &&
68
+ (event.payload?.state === "stop" || event.payload?.state === "safe")
69
+ ) {
70
+ RED.events.removeListener("flows:started", onStarted);
71
+ resolve();
72
+ }
73
+ };
74
+ RED.events.once("flows:started", onStarted);
75
+ RED.events.on("runtime-event", onRuntimeEvent);
76
+ });
77
+ }
78
+
37
79
  /**
38
80
  * Runs a single link-call invocation against a real, freshly booted
39
81
  * Node-RED runtime: installs any missing `--node-modules`, boots RED with
@@ -80,9 +122,9 @@ async function runFlowInvocation({ flow, flowFile, msg, options }) {
80
122
  });
81
123
 
82
124
  try {
83
- const flowsStarted = new Promise((resolve) => RED.events.once("flows:started", resolve));
125
+ const flowsSettled = waitForFlowsSettled(RED);
84
126
  await RED.start();
85
- await flowsStarted;
127
+ await flowsSettled;
86
128
 
87
129
  caller = createHostLinkCaller(RED);
88
130
  const result = await caller.call(target, msg, {