@tbrandenburg/node-red-cli 0.2.13 → 0.2.15

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
@@ -319,8 +319,11 @@ The same flow runs sandboxed via `--docker <image>` against an image that
319
319
  already ships `opencode` + `node-red-agents`, e.g.
320
320
  [`ghcr.io/tbrandenburg/agentic-workflow-dev-env`](https://github.com/tbrandenburg/agentic-workflow-dev-env)
321
321
  (`--network` is required for network access, since the agent calls out to
322
- its own API the package is already in the image, so `--node-modules` is
323
- not needed here):
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):
324
327
 
325
328
  ```bash
326
329
  echo '{"payload":"Summarize this repo in one sentence.","cwd":"/repo"}' \
@@ -332,7 +335,8 @@ echo '{"payload":"Summarize this repo in one sentence.","cwd":"/repo"}' \
332
335
  "cwd":"cwd","cwdType":"msg","wires":[["return"],[]]},
333
336
  {"id":"return","type":"link out","z":"tab","name":"return","mode":"return"}
334
337
  ]' ask --docker ghcr.io/tbrandenburg/agentic-workflow-dev-env:latest \
335
- --network --timeout=120000 --format=json
338
+ --node-modules @tbrandenburg/node-red-agents --user-dir --network \
339
+ --timeout=120000 --format=json
336
340
  ```
337
341
 
338
342
  ## Host API 🛠️
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tbrandenburg/node-red-cli",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
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": {
@@ -74,7 +74,13 @@ function checkNpmAvailable() {
74
74
  });
75
75
  }
76
76
 
77
- /** Runs `npm install <name>[@version]` into `userDir`, returns on success, throws a clear error otherwise. */
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
  };
@@ -34,6 +34,104 @@ 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
+
74
+ /**
75
+ * Waits for Node-RED to finish attempting to start the deployed flows.
76
+ *
77
+ * `RED.start()` resolves as soon as the runtime itself has booted, but the
78
+ * actual flow deploy happens asynchronously afterward and normally signals
79
+ * completion via a one-off `flows:started` event. However, when the flow
80
+ * references a node type that isn't registered (or another deploy-blocking
81
+ * condition applies, e.g. missing external modules or safe mode), Node-RED's
82
+ * `Flow.start()` logs the problem and returns *without* ever emitting
83
+ * `flows:started` (see `@node-red/runtime/lib/flows/index.js`). Awaiting
84
+ * only `flows:started` would then hang forever; since nothing else keeps
85
+ * the event loop alive, the process exits silently with code 0 once the
86
+ * loop drains, abandoning the pending call.
87
+ *
88
+ * Node-RED does always emit a `runtime-event` with id `runtime-state` in
89
+ * both cases: `payload.state === "start"` on success, and
90
+ * `payload.state === "stop"` / `"safe"` on any of the early-return failure
91
+ * paths. Racing both events lets us return as soon as Node-RED has settled
92
+ * either way; if the flows never actually started, the target/return nodes
93
+ * simply won't be instantiated and the existing preflight validation in
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.)
110
+ */
111
+ function waitForFlowsSettled(RED) {
112
+ return new Promise((resolve) => {
113
+ let settled = false;
114
+ const finish = () => {
115
+ if (settled) return;
116
+ settled = true;
117
+ RED.events.removeListener("flows:started", onStarted);
118
+ RED.events.removeListener("runtime-event", onRuntimeEvent);
119
+ resolve();
120
+ };
121
+ const onStarted = () => finish();
122
+ const onRuntimeEvent = (event) => {
123
+ if (
124
+ event?.id === "runtime-state" &&
125
+ (event.payload?.state === "stop" || event.payload?.state === "safe")
126
+ ) {
127
+ setTimeout(finish, 0);
128
+ }
129
+ };
130
+ RED.events.once("flows:started", onStarted);
131
+ RED.events.on("runtime-event", onRuntimeEvent);
132
+ });
133
+ }
134
+
37
135
  /**
38
136
  * Runs a single link-call invocation against a real, freshly booted
39
137
  * Node-RED runtime: installs any missing `--node-modules`, boots RED with
@@ -71,7 +169,7 @@ async function runFlowInvocation({ flow, flowFile, msg, options }) {
71
169
 
72
170
  let caller;
73
171
  RED.init({
74
- ...(flow ? { storageModule: createMemoryStorageModule(flow) } : { flowFile }),
172
+ ...(flow ? { storageModule: createMemoryStorageModule(withDeployCoordinates(flow)) } : { flowFile }),
75
173
  userDir,
76
174
  httpAdminRoot: false,
77
175
  httpNodeRoot: false,
@@ -80,9 +178,9 @@ async function runFlowInvocation({ flow, flowFile, msg, options }) {
80
178
  });
81
179
 
82
180
  try {
83
- const flowsStarted = new Promise((resolve) => RED.events.once("flows:started", resolve));
181
+ const flowsSettled = waitForFlowsSettled(RED);
84
182
  await RED.start();
85
- await flowsStarted;
183
+ await flowsSettled;
86
184
 
87
185
  caller = createHostLinkCaller(RED);
88
186
  const result = await caller.call(target, msg, {