@tbrandenburg/node-red-cli 0.2.14 → 0.2.16

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,15 @@ 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
+ 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.
301
+
293
302
  Fails fast with a clear `node-red-cli: docker unavailable: ...` error if
294
303
  the Docker CLI/daemon isn't reachable, or `node-red-cli: docker build
295
304
  failed: ...` if the image build fails (e.g. the local version isn't yet
@@ -320,10 +329,9 @@ already ships `opencode` + `node-red-agents`, e.g.
320
329
  [`ghcr.io/tbrandenburg/agentic-workflow-dev-env`](https://github.com/tbrandenburg/agentic-workflow-dev-env)
321
330
  (`--network` is required for network access, since the agent calls out to
322
331
  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):
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)):
327
335
 
328
336
  ```bash
329
337
  echo '{"payload":"Summarize this repo in one sentence.","cwd":"/repo"}' \
@@ -7,6 +7,12 @@
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
+ * 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`.
10
16
  */
11
17
 
12
18
  const { runFlowInvocation } = require("../src/run-envelope");
@@ -74,7 +74,9 @@ 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. 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.",
78
80
  "",
79
81
  "Example:",
80
82
  ' echo \'{"payload":{"x":4,"y":5}}\' | node-red-cli flows.json calculate',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tbrandenburg/node-red-cli",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
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": {
@@ -34,6 +34,43 @@ function stderrLogHandler() {
34
34
  };
35
35
  }
36
36
 
37
+ /**
38
+ * Node-RED's flow parser (`@node-red/runtime/lib/flows/util.js`) classifies
39
+ * *any* node lacking both `x` and `y` properties as a global config node --
40
+ * regardless of its actual `type` -- since those coordinates are otherwise
41
+ * only ever used by the editor canvas. A real, editor-exported flow always
42
+ * has them on every wired node, so this never matters there. But hand-authored
43
+ * `--flow-json` flows (this tool's own core use case; see the README's
44
+ * `agent` example) commonly omit them, since they carry no runtime meaning.
45
+ * A wired node misclassified as a config node undergoes `Flow.js`'s
46
+ * config-node circular-dependency scan instead of normal instantiation,
47
+ * which scans every one of its own property values against other node ids
48
+ * and throws "Circular config node dependency detected" the moment any
49
+ * property value happens to equal another node's id -- including its own,
50
+ * e.g. a node whose `name` equals its own `id` (an extremely natural thing
51
+ * to write by hand, and exactly what the README's own agent example does).
52
+ * That aborts the whole flow's instantiation, so downstream preflight
53
+ * validation reports the target/return nodes as "not instantiated" even
54
+ * though the flow is otherwise entirely valid (see issue #28).
55
+ *
56
+ * Fix: assign synthetic coordinates to every node that is unambiguously a
57
+ * regular (wired) node -- i.e. it already declares a `wires` array, or is a
58
+ * `link out` node (which routes via `links` instead of `wires`) -- so
59
+ * Node-RED's parser classifies it correctly. Nodes without either (real
60
+ * config nodes) are left untouched.
61
+ */
62
+ function withDeployCoordinates(flow) {
63
+ let n = 0;
64
+ return flow.map((node) => {
65
+ const isWired = Object.prototype.hasOwnProperty.call(node, "wires") || node.type === "link out";
66
+ const hasCoords =
67
+ Object.prototype.hasOwnProperty.call(node, "x") && Object.prototype.hasOwnProperty.call(node, "y");
68
+ if (!isWired || hasCoords) return node;
69
+ n += 1;
70
+ return { ...node, x: n * 100, y: 100 };
71
+ });
72
+ }
73
+
37
74
  /**
38
75
  * Waits for Node-RED to finish attempting to start the deployed flows.
39
76
  *
@@ -55,20 +92,39 @@ function stderrLogHandler() {
55
92
  * either way; if the flows never actually started, the target/return nodes
56
93
  * simply won't be instantiated and the existing preflight validation in
57
94
  * `createHostLinkCaller` reports the real, specific error instead.
95
+ *
96
+ * A `stop`/`safe` `runtime-state` event and a real `flows:started` are
97
+ * mutually exclusive outcomes of the same deploy attempt in the installed
98
+ * `@node-red/runtime` (each early-return failure path returns before ever
99
+ * reaching the code that emits `flows:started`), so this never races in
100
+ * practice today. Still, resolving on `stop`/`safe` is deferred by one
101
+ * macrotask (`setImmediate`) rather than immediately, so that if a
102
+ * `flows:started` for the same attempt is already scheduled to fire right
103
+ * after, it wins instead -- cheap insurance against exactly the kind of
104
+ * premature-resolution regression reported in issue #28, without delaying
105
+ * genuine failures beyond a single negligible tick.
106
+ *
107
+ * (Uses `setTimeout(fn, 0)` rather than `setImmediate` purely because the
108
+ * latter isn't part of this project's configured ESLint globals; both defer
109
+ * to the next macrotask.)
58
110
  */
59
111
  function waitForFlowsSettled(RED) {
60
112
  return new Promise((resolve) => {
61
- const onStarted = () => {
113
+ let settled = false;
114
+ const finish = () => {
115
+ if (settled) return;
116
+ settled = true;
117
+ RED.events.removeListener("flows:started", onStarted);
62
118
  RED.events.removeListener("runtime-event", onRuntimeEvent);
63
119
  resolve();
64
120
  };
121
+ const onStarted = () => finish();
65
122
  const onRuntimeEvent = (event) => {
66
123
  if (
67
124
  event?.id === "runtime-state" &&
68
125
  (event.payload?.state === "stop" || event.payload?.state === "safe")
69
126
  ) {
70
- RED.events.removeListener("flows:started", onStarted);
71
- resolve();
127
+ setTimeout(finish, 0);
72
128
  }
73
129
  };
74
130
  RED.events.once("flows:started", onStarted);
@@ -76,6 +132,34 @@ function waitForFlowsSettled(RED) {
76
132
  });
77
133
  }
78
134
 
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.
145
+ */
146
+ function resolveDefaultUserDir() {
147
+ const configuredPath = process.env.NODE_RED_CLI_DEFAULT_USERDIR;
148
+ if (!configuredPath) return undefined;
149
+
150
+ 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
+ }
160
+ return undefined;
161
+ }
162
+
79
163
  /**
80
164
  * Runs a single link-call invocation against a real, freshly booted
81
165
  * Node-RED runtime: installs any missing `--node-modules`, boots RED with
@@ -90,8 +174,13 @@ function waitForFlowsSettled(RED) {
90
174
  *
91
175
  * `options.userDir`, when set, is treated as a persistent directory and is
92
176
  * never removed afterward (host: an explicit `--user-dir`; container: the
93
- * fixed mount path of a named Docker volume). When omitted, an ephemeral
94
- * tmpdir is created and removed again after the call.
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.
95
184
  */
96
185
  async function runFlowInvocation({ flow, flowFile, msg, options }) {
97
186
  const {
@@ -104,7 +193,10 @@ async function runFlowInvocation({ flow, flowFile, msg, options }) {
104
193
  } = options;
105
194
 
106
195
  const persistentUserDir = Boolean(fixedUserDir);
107
- const userDir = fixedUserDir || fs.mkdtempSync(path.join(os.tmpdir(), "node-red-cli-"));
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;
108
200
 
109
201
  try {
110
202
  if (nodeModules.length > 0) {
@@ -113,7 +205,7 @@ async function runFlowInvocation({ flow, flowFile, msg, options }) {
113
205
 
114
206
  let caller;
115
207
  RED.init({
116
- ...(flow ? { storageModule: createMemoryStorageModule(flow) } : { flowFile }),
208
+ ...(flow ? { storageModule: createMemoryStorageModule(withDeployCoordinates(flow)) } : { flowFile }),
117
209
  userDir,
118
210
  httpAdminRoot: false,
119
211
  httpNodeRoot: false,
@@ -138,8 +230,8 @@ async function runFlowInvocation({ flow, flowFile, msg, options }) {
138
230
  await RED.stop();
139
231
  }
140
232
  } finally {
141
- if (!persistentUserDir) fs.rmSync(userDir, { recursive: true, force: true });
233
+ if (managedUserDir) fs.rmSync(userDir, { recursive: true, force: true });
142
234
  }
143
235
  }
144
236
 
145
- module.exports = { runFlowInvocation, stderrLogHandler };
237
+ module.exports = { runFlowInvocation, stderrLogHandler, resolveDefaultUserDir };