@tbrandenburg/node-red-cli 0.2.16 → 0.2.17
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 +20 -16
- package/bin/node-red-cli-sandbox-entry.js +9 -6
- package/bin/node-red-cli.js +29 -8
- package/package.json +1 -1
- package/src/run-envelope.js +120 -37
package/README.md
CHANGED
|
@@ -290,14 +290,20 @@ deterministic **named Docker volume** (derived from the `--user-dir` value)
|
|
|
290
290
|
mounted inside the container, never a host bind mount — so "no stray host
|
|
291
291
|
files" holds even for persistent installs.
|
|
292
292
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
293
|
+
Without an explicit `--user-dir`, `--docker` also auto-probes the
|
|
294
|
+
container's own `/data` for a userDir a community image already
|
|
295
|
+
pre-populated with its own Node-RED node packages (e.g. the motivating
|
|
296
|
+
[`ghcr.io/tbrandenburg/agentic-workflow-dev-env`](https://github.com/tbrandenburg/agentic-workflow-dev-env),
|
|
297
|
+
which sets `NODE_RED_HOME=/data`) — validated by scanning `/data/node_modules`
|
|
298
|
+
(including scoped `@scope/*` packages) for any `package.json` declaring a
|
|
299
|
+
`"node-red"` key. This is inherently best-effort: an unrelated `/data` that
|
|
300
|
+
happens to contain such a package is a (rare) false positive, and a real
|
|
301
|
+
userDir laid out differently is a false negative that silently falls back
|
|
302
|
+
to the ephemeral default. For a reliable, explicit alternative, pass
|
|
303
|
+
`--docker-userdir <path>` to name the in-container directory directly —
|
|
304
|
+
it takes precedence over the auto-probe (but is itself still overridden by
|
|
305
|
+
an explicit `--user-dir`). Whichever wins, that directory is used as
|
|
306
|
+
`userDir` and, like an explicit `--user-dir`, never deleted afterward.
|
|
301
307
|
|
|
302
308
|
Fails fast with a clear `node-red-cli: docker unavailable: ...` error if
|
|
303
309
|
the Docker CLI/daemon isn't reachable, or `node-red-cli: docker build
|
|
@@ -326,12 +332,11 @@ echo '{"payload":"Summarize this repo in one sentence.","cwd":"/repo"}' \
|
|
|
326
332
|
|
|
327
333
|
The same flow runs sandboxed via `--docker <image>` against an image that
|
|
328
334
|
already ships `opencode` + `node-red-agents`, e.g.
|
|
329
|
-
[`ghcr.io/tbrandenburg/agentic-workflow-dev-env`](https://github.com/tbrandenburg/agentic-workflow-dev-env)
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
see [#31](https://github.com/tbrandenburg/node-red-cli/issues/31)):
|
|
335
|
+
[`ghcr.io/tbrandenburg/agentic-workflow-dev-env`](https://github.com/tbrandenburg/agentic-workflow-dev-env),
|
|
336
|
+
which pre-installs its node packages into `/data` (`NODE_RED_HOME=/data`) —
|
|
337
|
+
exactly the layout the `/data` auto-probe discovers automatically, with
|
|
338
|
+
no `--node-modules`/`--user-dir` needed (`--network` is still required for
|
|
339
|
+
network access, since the agent calls out to its own API):
|
|
335
340
|
|
|
336
341
|
```bash
|
|
337
342
|
echo '{"payload":"Summarize this repo in one sentence.","cwd":"/repo"}' \
|
|
@@ -343,8 +348,7 @@ echo '{"payload":"Summarize this repo in one sentence.","cwd":"/repo"}' \
|
|
|
343
348
|
"cwd":"cwd","cwdType":"msg","wires":[["return"],[]]},
|
|
344
349
|
{"id":"return","type":"link out","z":"tab","name":"return","mode":"return"}
|
|
345
350
|
]' ask --docker ghcr.io/tbrandenburg/agentic-workflow-dev-env:latest \
|
|
346
|
-
--
|
|
347
|
-
--timeout=120000 --format=json
|
|
351
|
+
--network --timeout=120000 --format=json
|
|
348
352
|
```
|
|
349
353
|
|
|
350
354
|
## Host API 🛠️
|
|
@@ -8,11 +8,14 @@
|
|
|
8
8
|
* the shared `runFlowInvocation` (the exact same logic the host CLI uses
|
|
9
9
|
* for its non-Docker path), and writes the formatted result to stdout.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* Sets `options.probeContainerDefault: true` before delegating to
|
|
12
|
+
* `runFlowInvocation`, so its `/data` auto-probe (see #33,
|
|
13
|
+
* `resolveContainerDefaultUserDir` in `src/run-envelope.js`) is only ever
|
|
14
|
+
* attempted here -- inside the container -- and never on the host CLI
|
|
15
|
+
* path, since `/data` has no reserved meaning outside a container. It only
|
|
16
|
+
* takes effect as a fallback: an explicit `userDir` (named-volume mount) or
|
|
17
|
+
* `dockerUserDir` (`--docker-userdir`) already present on `envelope.options`
|
|
18
|
+
* still wins, per `resolveEffectiveUserDir`'s precedence.
|
|
16
19
|
*/
|
|
17
20
|
|
|
18
21
|
const { runFlowInvocation } = require("../src/run-envelope");
|
|
@@ -41,7 +44,7 @@ async function main() {
|
|
|
41
44
|
const { output } = await runFlowInvocation({
|
|
42
45
|
flow: envelope.flow,
|
|
43
46
|
msg: envelope.msg,
|
|
44
|
-
options: envelope.options || {}
|
|
47
|
+
options: { ...(envelope.options || {}), probeContainerDefault: true }
|
|
45
48
|
});
|
|
46
49
|
process.stdout.write(`${output}\n`);
|
|
47
50
|
} catch (error) {
|
package/bin/node-red-cli.js
CHANGED
|
@@ -74,9 +74,16 @@ const HELP_TEXT = [
|
|
|
74
74
|
"network access independent of installing any package), --read-only",
|
|
75
75
|
"rootfs with a /tmp tmpfs, --cap-drop=ALL, --security-opt=no-new-privileges.",
|
|
76
76
|
"When combined with --user-dir, persistence uses a named Docker volume,",
|
|
77
|
-
"never a host bind mount.
|
|
78
|
-
"
|
|
79
|
-
"
|
|
77
|
+
"never a host bind mount. Without --user-dir, the container's own /data",
|
|
78
|
+
"is auto-probed for a pre-populated Node-RED userDir (best-effort; see",
|
|
79
|
+
"--docker-userdir for a reliable, explicit alternative).",
|
|
80
|
+
"",
|
|
81
|
+
"--docker-userdir <path> tells --docker to use <path> (a directory inside",
|
|
82
|
+
"the container, e.g. one a base image already pre-installs Node-RED node",
|
|
83
|
+
"packages into) as the userDir, instead of the default ephemeral tmpdir or",
|
|
84
|
+
"the best-effort /data auto-probe. Ignored outside --docker mode.",
|
|
85
|
+
"Precedence: --user-dir > --docker-userdir > the /data auto-probe > the",
|
|
86
|
+
"ephemeral default.",
|
|
80
87
|
"",
|
|
81
88
|
"Example:",
|
|
82
89
|
' echo \'{"payload":{"x":4,"y":5}}\' | node-red-cli flows.json calculate',
|
|
@@ -197,10 +204,18 @@ async function run(args, options) {
|
|
|
197
204
|
|
|
198
205
|
const persistentUserDir = resolveUserDir(options.userDir);
|
|
199
206
|
if (nodeModules.length > 0 && !persistentUserDir) {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
"
|
|
203
|
-
|
|
207
|
+
if (options.dockerUserdir || options.docker) {
|
|
208
|
+
console.error(
|
|
209
|
+
"node-red-cli: --node-modules requires an explicit --user-dir (a persistent directory); " +
|
|
210
|
+
"--docker-userdir and the best-effort /data auto-probe are not guaranteed persistent, " +
|
|
211
|
+
"so installing into them would just reinstall from npm on every run"
|
|
212
|
+
);
|
|
213
|
+
} else {
|
|
214
|
+
console.error(
|
|
215
|
+
"node-red-cli: --node-modules requires an explicit --user-dir (a persistent directory); " +
|
|
216
|
+
"using it with the default ephemeral userDir would reinstall from npm on every run"
|
|
217
|
+
);
|
|
218
|
+
}
|
|
204
219
|
process.exitCode = 1;
|
|
205
220
|
return;
|
|
206
221
|
}
|
|
@@ -226,7 +241,8 @@ async function run(args, options) {
|
|
|
226
241
|
timeoutMs: options.timeout,
|
|
227
242
|
format: options.format,
|
|
228
243
|
nodeModules,
|
|
229
|
-
userDir: volumeName ? CONTAINER_USER_DIR : undefined
|
|
244
|
+
userDir: volumeName ? CONTAINER_USER_DIR : undefined,
|
|
245
|
+
dockerUserDir: options.dockerUserdir
|
|
230
246
|
}
|
|
231
247
|
};
|
|
232
248
|
|
|
@@ -302,6 +318,11 @@ program
|
|
|
302
318
|
"run the invocation sandboxed in a disposable Docker container; bare = cached default image, " +
|
|
303
319
|
"'<image[:tag]>' = explicit image (installed into if missing), '@path'/URL = build from a Dockerfile"
|
|
304
320
|
)
|
|
321
|
+
.option(
|
|
322
|
+
"--docker-userdir <path>",
|
|
323
|
+
"in --docker mode, use <path> (inside the container) as the userDir instead of the default " +
|
|
324
|
+
"ephemeral tmpdir or the best-effort /data auto-probe; ignored outside --docker mode"
|
|
325
|
+
)
|
|
305
326
|
.option(
|
|
306
327
|
"--network",
|
|
307
328
|
"enable network access in --docker mode, independent of --node-modules (default: --network none)"
|
package/package.json
CHANGED
package/src/run-envelope.js
CHANGED
|
@@ -133,31 +133,110 @@ function waitForFlowsSettled(RED) {
|
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
/**
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
* explicit `--user-dir`) discovers it automatically. Returns `undefined` if
|
|
141
|
-
* unset. Fails open, not closed: if the path doesn't exist, isn't a
|
|
142
|
-
* directory, or isn't accessible, logs a one-line stderr warning and returns
|
|
143
|
-
* `undefined` so the caller falls back to its normal ephemeral tmpdir,
|
|
144
|
-
* rather than aborting the invocation.
|
|
136
|
+
* True if `dirPath` exists and, following symlinks (npm frequently
|
|
137
|
+
* symlinks packages, e.g. in workspace/monorepo installs), is a directory.
|
|
138
|
+
* Never throws: any stat failure (missing path, broken symlink, etc.)
|
|
139
|
+
* resolves to `false`.
|
|
145
140
|
*/
|
|
146
|
-
function
|
|
147
|
-
|
|
148
|
-
|
|
141
|
+
function isDirectory(dirPath) {
|
|
142
|
+
try {
|
|
143
|
+
return fs.statSync(dirPath).isDirectory();
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
149
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Returns every immediate subdirectory of `node_modules`, including one
|
|
151
|
+
* level of scoped-package expansion (`@scope/*`), as a flat list of
|
|
152
|
+
* absolute directory paths. Follows symlinks (see `isDirectory`). Never
|
|
153
|
+
* throws: an unreadable/missing `node_modules` (or scope dir) simply
|
|
154
|
+
* contributes no candidates.
|
|
155
|
+
*/
|
|
156
|
+
function listNodeModuleDirs(nodeModulesDir) {
|
|
157
|
+
let entries;
|
|
150
158
|
try {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
);
|
|
159
|
+
entries = fs.readdirSync(nodeModulesDir);
|
|
160
|
+
} catch {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return entries.flatMap((name) => {
|
|
165
|
+
const entryPath = path.join(nodeModulesDir, name);
|
|
166
|
+
if (!name.startsWith("@")) return isDirectory(entryPath) ? [entryPath] : [];
|
|
167
|
+
if (!isDirectory(entryPath)) return [];
|
|
168
|
+
|
|
169
|
+
let scopedNames;
|
|
170
|
+
try {
|
|
171
|
+
scopedNames = fs.readdirSync(entryPath);
|
|
172
|
+
} catch {
|
|
173
|
+
return [];
|
|
174
|
+
}
|
|
175
|
+
return scopedNames.map((scoped) => path.join(entryPath, scoped)).filter(isDirectory);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** True if `packageDir/package.json` exists, parses, and declares a `"node-red"` key. */
|
|
180
|
+
function isNodeRedPackage(packageDir) {
|
|
181
|
+
try {
|
|
182
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(packageDir, "package.json"), "utf8"));
|
|
183
|
+
return Boolean(pkg["node-red"]);
|
|
184
|
+
} catch {
|
|
185
|
+
return false;
|
|
159
186
|
}
|
|
160
|
-
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Auto-probes `baseDir` (default `/data`, the conventional mount point of
|
|
191
|
+
* the motivating `ghcr.io/tbrandenburg/agentic-workflow-dev-env` image, see
|
|
192
|
+
* issue #33) for a Node-RED userDir a community image pre-populated with
|
|
193
|
+
* its own node packages. Returns `baseDir` when it exists, is a directory,
|
|
194
|
+
* and at least one direct or scoped (`@scope/*`) child of its
|
|
195
|
+
* `node_modules` declares a `"node-red"` key in `package.json`; returns
|
|
196
|
+
* `undefined` otherwise.
|
|
197
|
+
*
|
|
198
|
+
* Best-effort by design: a real userDir with unrelated packages under
|
|
199
|
+
* `node_modules` (false negative) or a `/data` that merely happens to
|
|
200
|
+
* contain an unrelated `"node-red"`-keyed package (false positive) are both
|
|
201
|
+
* possible; `--docker-userdir <path>` is the reliable, explicit alternative
|
|
202
|
+
* when this heuristic doesn't fit an image. Never throws: any missing or
|
|
203
|
+
* unreadable path along the way resolves to "not usable".
|
|
204
|
+
*
|
|
205
|
+
* Only meaningful inside a container (`/data` has no reserved meaning on
|
|
206
|
+
* the host), so this is only ever called from the sandbox entrypoint
|
|
207
|
+
* (`bin/node-red-cli-sandbox-entry.js`), never from the host CLI path.
|
|
208
|
+
*/
|
|
209
|
+
function resolveContainerDefaultUserDir(baseDir = "/data") {
|
|
210
|
+
if (!isDirectory(baseDir)) return undefined;
|
|
211
|
+
const candidateDirs = listNodeModuleDirs(path.join(baseDir, "node_modules"));
|
|
212
|
+
return candidateDirs.some(isNodeRedPackage) ? baseDir : undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Resolves which `userDir` source wins, in order of precedence (see #33):
|
|
217
|
+
*
|
|
218
|
+
* 1. `userDir` -- host-managed, explicit `--user-dir` (or its container
|
|
219
|
+
* named-volume mount path).
|
|
220
|
+
* 2. `dockerUserDir` -- explicit `--docker-userdir <path>` passthrough.
|
|
221
|
+
* 3. the auto-probed `/data` default (see `resolveContainerDefaultUserDir`),
|
|
222
|
+
* only attempted when `probeContainerDefault` is set (sandbox entrypoint
|
|
223
|
+
* only).
|
|
224
|
+
* 4. `undefined` -- caller falls back to an ephemeral tmpdir.
|
|
225
|
+
*
|
|
226
|
+
* The first three are all treated as persistent (never removed afterward);
|
|
227
|
+
* only the ephemeral tmpdir fallback is managed/cleaned up by the caller.
|
|
228
|
+
*
|
|
229
|
+
* `probeBaseDir` overrides the auto-probed path (defaults to `/data`) --
|
|
230
|
+
* only ever used by tests; real callers always probe the real `/data`.
|
|
231
|
+
*/
|
|
232
|
+
function resolveEffectiveUserDir({ userDir, dockerUserDir, probeContainerDefault, probeBaseDir } = {}) {
|
|
233
|
+
if (userDir) return { userDir, persistent: true };
|
|
234
|
+
if (dockerUserDir) return { userDir: dockerUserDir, persistent: true };
|
|
235
|
+
if (probeContainerDefault) {
|
|
236
|
+
const probed = resolveContainerDefaultUserDir(probeBaseDir);
|
|
237
|
+
if (probed) return { userDir: probed, persistent: true };
|
|
238
|
+
}
|
|
239
|
+
return { userDir: undefined, persistent: false };
|
|
161
240
|
}
|
|
162
241
|
|
|
163
242
|
/**
|
|
@@ -172,15 +251,14 @@ function resolveDefaultUserDir() {
|
|
|
172
251
|
* entrypoint (`bin/node-red-cli-sandbox-entry.js`, `--docker` path), so both
|
|
173
252
|
* execute the exact same runtime logic.
|
|
174
253
|
*
|
|
175
|
-
* `
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
* after the call.
|
|
254
|
+
* `userDir` resolution follows `resolveEffectiveUserDir`'s precedence:
|
|
255
|
+
* `options.userDir` (host: an explicit `--user-dir`; container: the fixed
|
|
256
|
+
* mount path of a named Docker volume) > `options.dockerUserDir` (explicit
|
|
257
|
+
* `--docker-userdir <path>` passthrough) > the auto-probed `/data` default
|
|
258
|
+
* (see `resolveContainerDefaultUserDir`, only attempted when
|
|
259
|
+
* `options.probeContainerDefault` is set -- sandbox entrypoint only) > an
|
|
260
|
+
* ephemeral tmpdir created fresh and removed again after the call. The
|
|
261
|
+
* first three are all treated as persistent and never removed afterward.
|
|
184
262
|
*/
|
|
185
263
|
async function runFlowInvocation({ flow, flowFile, msg, options }) {
|
|
186
264
|
const {
|
|
@@ -189,14 +267,14 @@ async function runFlowInvocation({ flow, flowFile, msg, options }) {
|
|
|
189
267
|
timeoutMs = 5000,
|
|
190
268
|
format = "plain",
|
|
191
269
|
nodeModules = [],
|
|
192
|
-
userDir: fixedUserDir
|
|
270
|
+
userDir: fixedUserDir,
|
|
271
|
+
dockerUserDir,
|
|
272
|
+
probeContainerDefault
|
|
193
273
|
} = options;
|
|
194
274
|
|
|
195
|
-
const
|
|
196
|
-
const
|
|
197
|
-
const
|
|
198
|
-
fixedUserDir || imageDefaultUserDir || fs.mkdtempSync(path.join(os.tmpdir(), "node-red-cli-"));
|
|
199
|
-
const managedUserDir = !persistentUserDir && !imageDefaultUserDir;
|
|
275
|
+
const resolved = resolveEffectiveUserDir({ userDir: fixedUserDir, dockerUserDir, probeContainerDefault });
|
|
276
|
+
const userDir = resolved.userDir || fs.mkdtempSync(path.join(os.tmpdir(), "node-red-cli-"));
|
|
277
|
+
const managedUserDir = !resolved.persistent;
|
|
200
278
|
|
|
201
279
|
try {
|
|
202
280
|
if (nodeModules.length > 0) {
|
|
@@ -234,4 +312,9 @@ async function runFlowInvocation({ flow, flowFile, msg, options }) {
|
|
|
234
312
|
}
|
|
235
313
|
}
|
|
236
314
|
|
|
237
|
-
module.exports = {
|
|
315
|
+
module.exports = {
|
|
316
|
+
runFlowInvocation,
|
|
317
|
+
stderrLogHandler,
|
|
318
|
+
resolveContainerDefaultUserDir,
|
|
319
|
+
resolveEffectiveUserDir
|
|
320
|
+
};
|