@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 +47 -1
- package/bin/node-red-cli.js +13 -5
- package/package.json +1 -1
- package/src/docker-image.js +7 -0
- package/src/docker-run.js +2 -1
- package/src/node-modules-install.js +11 -2
- package/src/run-envelope.js +44 -2
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)
|
|
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:
|
package/bin/node-red-cli.js
CHANGED
|
@@ -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
|
|
74
|
-
"
|
|
75
|
-
"
|
|
76
|
-
"
|
|
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, {
|
|
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
package/src/docker-image.js
CHANGED
|
@@ -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`
|
|
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
|
-
/**
|
|
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
|
};
|
package/src/run-envelope.js
CHANGED
|
@@ -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
|
|
125
|
+
const flowsSettled = waitForFlowsSettled(RED);
|
|
84
126
|
await RED.start();
|
|
85
|
-
await
|
|
127
|
+
await flowsSettled;
|
|
86
128
|
|
|
87
129
|
caller = createHostLinkCaller(RED);
|
|
88
130
|
const result = await caller.call(target, msg, {
|