@tbrandenburg/node-red-cli 0.2.16 → 0.2.18

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
@@ -106,7 +106,6 @@ bin/ CLI entrypoint (node-red-cli)
106
106
  src/ Host-side link-call adapter (library API)
107
107
  test/unit/ Fast tests against a fake Node-RED runtime
108
108
  test/integration/ Adapter tests against a real embedded runtime
109
- test/e2e/ Full round trip through the example flow
110
109
  test/fixtures/ Example Node-RED flow used as a test asset
111
110
  ```
112
111
 
@@ -290,14 +289,20 @@ deterministic **named Docker volume** (derived from the `--user-dir` value)
290
289
  mounted inside the container, never a host bind mount — so "no stray host
291
290
  files" holds even for persistent installs.
292
291
 
293
- For images/derived images that pre-install Node-RED node packages into
294
- their own conventional userDir, setting the `NODE_RED_CLI_DEFAULT_USERDIR`
295
- environment variable (inside the image, e.g. via `ENV`) to that path lets
296
- `--docker` discover it automatically whenever `--user-dir` isn't given —
297
- that directory is used as `userDir` and, like an explicit `--user-dir`,
298
- never deleted afterward. If the path doesn't exist or isn't a directory,
299
- `--docker` logs a warning and falls back to the normal ephemeral `userDir`
300
- rather than failing the invocation.
292
+ Without an explicit `--user-dir`, `--docker` also auto-probes the
293
+ container's own `/data` for a userDir a community image already
294
+ pre-populated with its own Node-RED node packages (e.g. the motivating
295
+ [`ghcr.io/tbrandenburg/agentic-workflow-dev-env`](https://github.com/tbrandenburg/agentic-workflow-dev-env),
296
+ which sets `NODE_RED_HOME=/data`) validated by scanning `/data/node_modules`
297
+ (including scoped `@scope/*` packages) for any `package.json` declaring a
298
+ `"node-red"` key. This is inherently best-effort: an unrelated `/data` that
299
+ happens to contain such a package is a (rare) false positive, and a real
300
+ userDir laid out differently is a false negative that silently falls back
301
+ to the ephemeral default. For a reliable, explicit alternative, pass
302
+ `--docker-userdir <path>` to name the in-container directory directly —
303
+ it takes precedence over the auto-probe (but is itself still overridden by
304
+ an explicit `--user-dir`). Whichever wins, that directory is used as
305
+ `userDir` and, like an explicit `--user-dir`, never deleted afterward.
301
306
 
302
307
  Fails fast with a clear `node-red-cli: docker unavailable: ...` error if
303
308
  the Docker CLI/daemon isn't reachable, or `node-red-cli: docker build
@@ -326,12 +331,11 @@ echo '{"payload":"Summarize this repo in one sentence.","cwd":"/repo"}' \
326
331
 
327
332
  The same flow runs sandboxed via `--docker <image>` against an image that
328
333
  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
- (`--network` is required for network access, since the agent calls out to
331
- its own API; `--node-modules`/`--user-dir` are still required too, since
332
- Node-RED only discovers node types from a userDir it actually loaded, and
333
- `--docker` doesn't yet reuse an image's own pre-populated default userDir
334
- see [#31](https://github.com/tbrandenburg/node-red-cli/issues/31)):
334
+ [`ghcr.io/tbrandenburg/agentic-workflow-dev-env`](https://github.com/tbrandenburg/agentic-workflow-dev-env),
335
+ which pre-installs its node packages into `/data` (`NODE_RED_HOME=/data`)
336
+ exactly the layout the `/data` auto-probe discovers automatically, with
337
+ no `--node-modules`/`--user-dir` needed (`--network` is still required for
338
+ network access, since the agent calls out to its own API):
335
339
 
336
340
  ```bash
337
341
  echo '{"payload":"Summarize this repo in one sentence.","cwd":"/repo"}' \
@@ -343,8 +347,7 @@ echo '{"payload":"Summarize this repo in one sentence.","cwd":"/repo"}' \
343
347
  "cwd":"cwd","cwdType":"msg","wires":[["return"],[]]},
344
348
  {"id":"return","type":"link out","z":"tab","name":"return","mode":"return"}
345
349
  ]' ask --docker ghcr.io/tbrandenburg/agentic-workflow-dev-env:latest \
346
- --node-modules @tbrandenburg/node-red-agents --user-dir --network \
347
- --timeout=120000 --format=json
350
+ --network --timeout=120000 --format=json
348
351
  ```
349
352
 
350
353
  ## 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
- * The `NODE_RED_CLI_DEFAULT_USERDIR` env-var convention (see #31), which
12
- * lets an image's own pre-populated default userDir be discovered when
13
- * `--user-dir` isn't given, is resolved entirely inside the shared
14
- * `runFlowInvocation` (`src/run-envelope.js`) -- nothing to do here beyond
15
- * the existing pass-through of `envelope.options`.
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) {
@@ -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. Image authors can set",
78
- "NODE_RED_CLI_DEFAULT_USERDIR=<path> so a pre-installed userDir is",
79
- "discovered automatically when --user-dir isn't given.",
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
- console.error(
201
- "node-red-cli: --node-modules requires an explicit --user-dir (a persistent directory); " +
202
- "using it with the default ephemeral userDir would reinstall from npm on every run"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tbrandenburg/node-red-cli",
3
- "version": "0.2.16",
3
+ "version": "0.2.18",
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": {
@@ -133,31 +133,110 @@ function waitForFlowsSettled(RED) {
133
133
  }
134
134
 
135
135
  /**
136
- * Resolves the image/host-provided default `userDir` from the
137
- * `NODE_RED_CLI_DEFAULT_USERDIR` environment variable (see #31): community
138
- * Docker images that pre-install Node-RED node packages into their own
139
- * conventional userDir can set this variable so `--docker` (without an
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 resolveDefaultUserDir() {
147
- const configuredPath = process.env.NODE_RED_CLI_DEFAULT_USERDIR;
148
- if (!configuredPath) return undefined;
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
- if (fs.statSync(configuredPath).isDirectory()) return configuredPath;
152
- console.error(
153
- `node-red-cli: NODE_RED_CLI_DEFAULT_USERDIR='${configuredPath}' is not usable (not a directory), falling back to an ephemeral userDir`
154
- );
155
- } catch (error) {
156
- console.error(
157
- `node-red-cli: NODE_RED_CLI_DEFAULT_USERDIR='${configuredPath}' is not usable (${error.message}), falling back to an ephemeral userDir`
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
- return undefined;
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
- * `options.userDir`, when set, is treated as a persistent directory and is
176
- * never removed afterward (host: an explicit `--user-dir`; container: the
177
- * fixed mount path of a named Docker volume). When omitted, and the
178
- * `NODE_RED_CLI_DEFAULT_USERDIR` environment variable points at an existing
179
- * directory (see `resolveDefaultUserDir`), that directory is used instead —
180
- * also treated as persistent and never removed afterward, letting a Docker
181
- * image's own pre-populated default userDir be discovered automatically
182
- * (see #31). Otherwise an ephemeral tmpdir is created and removed again
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 persistentUserDir = Boolean(fixedUserDir);
196
- const imageDefaultUserDir = !persistentUserDir ? resolveDefaultUserDir() : undefined;
197
- const userDir =
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 = { runFlowInvocation, stderrLogHandler, resolveDefaultUserDir };
315
+ module.exports = {
316
+ runFlowInvocation,
317
+ stderrLogHandler,
318
+ resolveContainerDefaultUserDir,
319
+ resolveEffectiveUserDir
320
+ };