@tbrandenburg/node-red-cli 0.2.15 → 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 CHANGED
@@ -290,6 +290,21 @@ 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
+ 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.
307
+
293
308
  Fails fast with a clear `node-red-cli: docker unavailable: ...` error if
294
309
  the Docker CLI/daemon isn't reachable, or `node-red-cli: docker build
295
310
  failed: ...` if the image build fails (e.g. the local version isn't yet
@@ -317,13 +332,11 @@ echo '{"payload":"Summarize this repo in one sentence.","cwd":"/repo"}' \
317
332
 
318
333
  The same flow runs sandboxed via `--docker <image>` against an image that
319
334
  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):
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):
327
340
 
328
341
  ```bash
329
342
  echo '{"payload":"Summarize this repo in one sentence.","cwd":"/repo"}' \
@@ -335,8 +348,7 @@ echo '{"payload":"Summarize this repo in one sentence.","cwd":"/repo"}' \
335
348
  "cwd":"cwd","cwdType":"msg","wires":[["return"],[]]},
336
349
  {"id":"return","type":"link out","z":"tab","name":"return","mode":"return"}
337
350
  ]' 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
351
+ --network --timeout=120000 --format=json
340
352
  ```
341
353
 
342
354
  ## Host API 🛠️
@@ -7,6 +7,15 @@
7
7
  * envelope as JSON from stdin, runs it against a real Node-RED runtime via
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
+ *
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.
10
19
  */
11
20
 
12
21
  const { runFlowInvocation } = require("../src/run-envelope");
@@ -35,7 +44,7 @@ async function main() {
35
44
  const { output } = await runFlowInvocation({
36
45
  flow: envelope.flow,
37
46
  msg: envelope.msg,
38
- options: envelope.options || {}
47
+ options: { ...(envelope.options || {}), probeContainerDefault: true }
39
48
  });
40
49
  process.stdout.write(`${output}\n`);
41
50
  } catch (error) {
@@ -74,7 +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.",
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.",
78
87
  "",
79
88
  "Example:",
80
89
  ' echo \'{"payload":{"x":4,"y":5}}\' | node-red-cli flows.json calculate',
@@ -195,10 +204,18 @@ async function run(args, options) {
195
204
 
196
205
  const persistentUserDir = resolveUserDir(options.userDir);
197
206
  if (nodeModules.length > 0 && !persistentUserDir) {
198
- console.error(
199
- "node-red-cli: --node-modules requires an explicit --user-dir (a persistent directory); " +
200
- "using it with the default ephemeral userDir would reinstall from npm on every run"
201
- );
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
+ }
202
219
  process.exitCode = 1;
203
220
  return;
204
221
  }
@@ -224,7 +241,8 @@ async function run(args, options) {
224
241
  timeoutMs: options.timeout,
225
242
  format: options.format,
226
243
  nodeModules,
227
- userDir: volumeName ? CONTAINER_USER_DIR : undefined
244
+ userDir: volumeName ? CONTAINER_USER_DIR : undefined,
245
+ dockerUserDir: options.dockerUserdir
228
246
  }
229
247
  };
230
248
 
@@ -300,6 +318,11 @@ program
300
318
  "run the invocation sandboxed in a disposable Docker container; bare = cached default image, " +
301
319
  "'<image[:tag]>' = explicit image (installed into if missing), '@path'/URL = build from a Dockerfile"
302
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
+ )
303
326
  .option(
304
327
  "--network",
305
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.15",
3
+ "version": "0.2.17",
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": {
@@ -132,6 +132,113 @@ function waitForFlowsSettled(RED) {
132
132
  });
133
133
  }
134
134
 
135
+ /**
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`.
140
+ */
141
+ function isDirectory(dirPath) {
142
+ try {
143
+ return fs.statSync(dirPath).isDirectory();
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
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;
158
+ try {
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;
186
+ }
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 };
240
+ }
241
+
135
242
  /**
136
243
  * Runs a single link-call invocation against a real, freshly booted
137
244
  * Node-RED runtime: installs any missing `--node-modules`, boots RED with
@@ -144,10 +251,14 @@ function waitForFlowsSettled(RED) {
144
251
  * entrypoint (`bin/node-red-cli-sandbox-entry.js`, `--docker` path), so both
145
252
  * execute the exact same runtime logic.
146
253
  *
147
- * `options.userDir`, when set, is treated as a persistent directory and is
148
- * never removed afterward (host: an explicit `--user-dir`; container: the
149
- * fixed mount path of a named Docker volume). When omitted, an ephemeral
150
- * tmpdir is created and removed again 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.
151
262
  */
152
263
  async function runFlowInvocation({ flow, flowFile, msg, options }) {
153
264
  const {
@@ -156,11 +267,14 @@ async function runFlowInvocation({ flow, flowFile, msg, options }) {
156
267
  timeoutMs = 5000,
157
268
  format = "plain",
158
269
  nodeModules = [],
159
- userDir: fixedUserDir
270
+ userDir: fixedUserDir,
271
+ dockerUserDir,
272
+ probeContainerDefault
160
273
  } = options;
161
274
 
162
- const persistentUserDir = Boolean(fixedUserDir);
163
- const userDir = fixedUserDir || fs.mkdtempSync(path.join(os.tmpdir(), "node-red-cli-"));
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;
164
278
 
165
279
  try {
166
280
  if (nodeModules.length > 0) {
@@ -194,8 +308,13 @@ async function runFlowInvocation({ flow, flowFile, msg, options }) {
194
308
  await RED.stop();
195
309
  }
196
310
  } finally {
197
- if (!persistentUserDir) fs.rmSync(userDir, { recursive: true, force: true });
311
+ if (managedUserDir) fs.rmSync(userDir, { recursive: true, force: true });
198
312
  }
199
313
  }
200
314
 
201
- module.exports = { runFlowInvocation, stderrLogHandler };
315
+ module.exports = {
316
+ runFlowInvocation,
317
+ stderrLogHandler,
318
+ resolveContainerDefaultUserDir,
319
+ resolveEffectiveUserDir
320
+ };