From da95c7c6627963aab9bad99422930a801f2d4224 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 11 Oct 2026 20:28:34 +0200 Subject: [PATCH 1/3] feat(edit): allow renaming Link nodes --- README.md | 6 +- src/edit-plan.js | 23 +++-- .../edit-acceptance.integration.test.js | 61 ++++++++++++++ test/unit/edit.unit.test.js | 83 +++++++++++++++++++ 4 files changed, 163 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index d3db5cf..c0e5a58 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,11 @@ node-red-cli edit apply plan.json --json Plans use the opaque revision returned by list/get and an ordered operation array. Operations are transformed sequentially on a deep copy. `set` changes -non-structural fields; `add` requires an explicit ID, ordinary node type (the +non-structural fields on ordinary nodes. For an existing Link In, Link Out, or +Link Call, `set` accepts only `{ "name": "..." }` by exact ID; an empty string +clears the display name. No Link identity, mode, connection, wire, or other +property can be changed this way. Use `link connect`/`disconnect` for structural +Link relationship edits. `add` requires an explicit ID, ordinary node type (the caller must ensure it is installed), tab ID, coordinates and wires. The CLI checks installed palette types via `GET /nodes` and refuses a type already represented as a config node in the current flow; Node-RED's Admin API does not diff --git a/src/edit-plan.js b/src/edit-plan.js index a201fe3..5ac5d31 100644 --- a/src/edit-plan.js +++ b/src/edit-plan.js @@ -97,7 +97,8 @@ const OPERATIONS = { set(result, op) { assertOperationKeys(op, ["op", "id", "fields"]); if (typeof op.id !== "string" || !op.id) throw new Error("set requires an exact node id"); - const node = findOrdinaryNode(result, op.id, "set"); + const node = result.find((item) => item?.id === op.id); + if (!node) throw new Error(`node '${op.id}' was not found`); if ( !op.fields || typeof op.fields !== "object" || @@ -105,7 +106,14 @@ const OPERATIONS = { Object.keys(op.fields).length === 0 ) throw new Error("set fields must be a nonempty object"); - validateFields(op.fields); + if (isLinkType(node.type)) { + if (Object.keys(op.fields).length !== 1 || !Object.hasOwn(op.fields, "name")) + throw new Error("set on Link nodes may change only the 'name' field"); + if (typeof op.fields.name !== "string") throw new Error("Link node name must be a string"); + } else { + assertOrdinaryNode(result, node, "set"); + validateFields(op.fields); + } Object.assign(node, structuredClone(op.fields)); }, credential(result, op, context) { @@ -243,13 +251,6 @@ function assertOperationKeys(op, allowed) { throw new Error(`invalid '${op.op}' operation fields`); } -function findOrdinaryNode(flows, id, operation) { - const node = flows.find((item) => item?.id === id); - if (!node) throw new Error(`node '${id}' was not found`); - assertOrdinaryNode(flows, node, operation); - return node; -} - function assertOrdinaryNode(flows, node, operation) { if ( !node?.z || @@ -268,6 +269,10 @@ function isSpecialType(type) { return typeof type !== "string" || type.startsWith("subflow:") || /^link (in|out|call)$/.test(type); } +function isLinkType(type) { + return ["link in", "link out", "link call"].includes(type); +} + function resolveWire(flows, op) { if (typeof op.from !== "string" || !op.from || typeof op.to !== "string" || !op.to) throw new Error(`${op.op} requires exact from and to node ids`); diff --git a/test/integration/edit-acceptance.integration.test.js b/test/integration/edit-acceptance.integration.test.js index cf15b31..e415f79 100644 --- a/test/integration/edit-acceptance.integration.test.js +++ b/test/integration/edit-acceptance.integration.test.js @@ -904,3 +904,64 @@ test("structural edit operations preserve native Link and subflow behavior acros assert.equal(finalRun.status, 0, finalRun.stderr); assert.equal(finalRun.stdout.trim(), "nested-complete"); }); + +test("Link node names preview and deploy without changing relationships", async (t) => { + const project = temporaryNativeProject(t); + let port = await freePort(); + const server = launch(project, port); + t.after(async () => stop(server)); + const initial = await waitForAdmin(server, port); + const selectedIds = ["cross", "internal", "native-call"]; + const names = new Map(selectedIds.map((id) => [id, `Renamed ${id}`])); + const plan = { + rev: initial.rev, + ops: selectedIds.map((id) => ({ op: "set", id, fields: { name: names.get(id) } })) + }; + const planFile = writePlan(project, "rename-links.json", plan); + const preview = invoke( + ["edit", "preview", planFile, "--admin-url", `http://127.0.0.1:${port}`, "--json"], + project + ); + assert.equal(preview.status, 0, preview.stderr); + assert.equal(JSON.parse(preview.stdout).deployed, false); + assert.deepEqual(await admin(port).then(({ body }) => body), initial, "preview must not deploy"); + + const before = structuredClone(initial.flows); + const applied = invoke( + ["edit", "apply", planFile, "--admin-url", `http://127.0.0.1:${port}`, "--json"], + project + ); + assert.equal(applied.status, 0, applied.stderr); + const deployed = await admin(port); + assert.equal(deployed.response.status, 200); + assert.deepEqual( + deployed.body.flows.map(({ id }) => id), + before.map(({ id }) => id) + ); + for (const oldNode of before) { + const updated = deployed.body.flows.find(({ id }) => id === oldNode.id); + const expected = names.has(oldNode.id) ? { ...oldNode, name: names.get(oldNode.id) } : oldNode; + assert.deepEqual(updated, expected, `only the name of ${oldNode.id} may change`); + } + + for (const fields of [{ name: "bad", links: [] }, { name: 42 }]) { + const invalid = writePlan(project, "invalid-link-rename.json", { + rev: deployed.body.rev, + ops: [{ op: "set", id: "cross", fields }] + }); + const rejected = invoke( + ["edit", "preview", invalid, "--admin-url", `http://127.0.0.1:${port}`, "--json"], + project + ); + assert.notEqual(rejected.status, 0); + assert.equal((await admin(port)).body.rev, deployed.body.rev, "rejected preview must not deploy"); + } + + await stop(server); + port = await freePort(); + const restarted = launch(project, port); + t.after(async () => stop(restarted)); + const persisted = await waitForAdmin(restarted, port); + for (const id of selectedIds) + assert.equal(persisted.flows.find((node) => node.id === id).name, names.get(id)); +}); diff --git a/test/unit/edit.unit.test.js b/test/unit/edit.unit.test.js index a4e1b8b..16ad908 100644 --- a/test/unit/edit.unit.test.js +++ b/test/unit/edit.unit.test.js @@ -174,6 +174,89 @@ test("plan parser and transformer are strict, pure, ordered and protect structur assert.doesNotMatch(JSON.stringify(safeDiff.changes), /nested-secret-sentinel/); }); +test("set renames Link nodes by exact ID without changing relationships", () => { + const graph = [ + { id: "tab", type: "tab", label: "Main" }, + { + id: "entry", + type: "link in", + z: "tab", + x: 1, + y: 2, + name: "Entry", + links: ["out"], + wires: [["worker"]] + }, + { id: "worker", type: "function", z: "tab", x: 3, y: 4, wires: [[]] }, + { id: "out", type: "link out", z: "tab", x: 5, y: 6, mode: "link", links: ["entry"], wires: [] }, + { id: "return", type: "link out", z: "tab", x: 7, y: 8, mode: "return", links: [], wires: [] }, + { id: "call", type: "link call", z: "tab", x: 9, y: 10, mode: "static", links: ["entry"], wires: [[]] }, + { id: "dynamic", type: "link call", z: "tab", x: 11, y: 12, mode: "dynamic", links: [], wires: [[]] } + ]; + const original = structuredClone(graph); + const ops = [ + ["entry", "Entry renamed"], + ["out", "Out renamed"], + ["return", "Return renamed"], + ["call", "Call renamed"], + ["dynamic", "Dynamic renamed"] + ].map(([id, name]) => ({ op: "set", id, fields: { name } })); + const result = transformPlan(graph, { rev: "rev", ops }); + assert.deepEqual(graph, original); + assert.deepEqual( + result.changes.map(({ id, fields }) => [id, Object.keys(fields)]), + [ + ["entry", ["name"]], + ["out", ["name"]], + ["return", ["name"]], + ["call", ["name"]], + ["dynamic", ["name"]] + ] + ); + for (const node of original.filter(({ type }) => type.startsWith("link "))) { + const renamed = result.flows.find(({ id }) => id === node.id); + assert.deepEqual({ ...renamed, name: undefined }, { ...node, name: undefined }); + } + assert.deepEqual( + result.flows.map(({ id }) => id), + original.map(({ id }) => id) + ); + + const cleared = transformPlan(graph, { + rev: "rev", + ops: [{ op: "set", id: "entry", fields: { name: "" } }] + }); + assert.equal(cleared.flows.find(({ id }) => id === "entry").name, ""); + assert.throws( + () => transformPlan(graph, { rev: "rev", ops: [{ op: "set", id: "entry", fields: { name: "Entry" } }] }), + /does not change/ + ); + + for (const fields of [ + { links: [] }, + { wires: [] }, + { mode: "return" }, + { linkType: "dynamic" }, + { id: "other" }, + { z: "other" }, + { x: 0 }, + { unknown: true }, + { name: "Entry", mode: "return" }, + { name: null }, + { name: [] }, + { name: 1 }, + { name: {} } + ]) + assert.throws( + () => transformPlan(graph, { rev: "rev", ops: [{ op: "set", id: "entry", fields }] }), + /Link node name|Link nodes may change only/ + ); + assert.throws( + () => transformPlan(graph, { rev: "rev", ops: [{ op: "set", id: "missing", fields: { name: "x" } }] }), + /was not found/ + ); +}); + test("add is deterministic, validates inputs, and supports later connect operations", () => { const node = { id: "debug-new", type: "debug", z: "tab-a", x: 10, y: 20, wires: [] }; const original = structuredClone(flows); From bbd9912e1ae5b99074540e27288d3c48d8ee90f8 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 11 Oct 2026 20:35:56 +0200 Subject: [PATCH 2/3] fix(edit): distinguish flow metadata change counts --- README.md | 4 ++ bin/node-red-cli.js | 1 + src/edit-plan.js | 12 ++++- .../edit-acceptance.integration.test.js | 50 ++++++++++++++++++- test/unit/edit.unit.test.js | 21 +++++++- 5 files changed, 84 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c0e5a58..c82ae8b 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,10 @@ identity. `preview` performs a fresh v2 GET and reports a redacted diff without POSTing. Human-readable previews omit Function source bodies and point reviewers to the plan file; JSON previews include source changes with an explicit warning. +JSON previews add `changeCounts: { "nodes": 1, "flowMetadata": 1 }`: `nodes` +counts changed Node-RED flow objects (including tabs, groups and config +objects), while `flowMetadata` counts synthetic flow-level changes such as +tab order. Human-readable summaries report these separately. `apply` repeats that validation against a fresh GET and makes at most one revision-checked `POST /flows` v2 request. It never forces or retries a deploy; a stale plan or HTTP 409 must be reviewed and regenerated. A successful apply diff --git a/bin/node-red-cli.js b/bin/node-red-cli.js index 75d60b8..450b0a9 100755 --- a/bin/node-red-cli.js +++ b/bin/node-red-cli.js @@ -306,6 +306,7 @@ async function editPlanFile(planFile, options, deploy) { rev: current.rev, valid: true, deployed: false, + changeCounts: transformed.changeCounts, summary: transformed.summary, warnings: [...transformed.warnings, ...previewWarnings(transformed.changes)], changes: transformed.changes diff --git a/src/edit-plan.js b/src/edit-plan.js index 5ac5d31..1c7cba3 100644 --- a/src/edit-plan.js +++ b/src/edit-plan.js @@ -83,11 +83,21 @@ function transformPlan(flows, plan, { resolveCredentialValues = false, credentia if (flows.map((node) => node.id).join("\u0000") !== result.map((node) => node.id).join("\u0000")) changes.push({ id: "flow-order", type: "flow", action: "reordered" }); if (changes.length === 0) throw new Error("plan does not change any flow fields"); + const changeCounts = changes.reduce( + (counts, change) => { + counts[change.type === "flow" ? "flowMetadata" : "nodes"] += 1; + return counts; + }, + { nodes: 0, flowMetadata: 0 } + ); + const nodeCount = `${changeCounts.nodes} node${changeCounts.nodes === 1 ? "" : "s"} changed`; + const summary = changeCounts.flowMetadata > 0 ? `${nodeCount}; flow order changed` : nodeCount; return { flows: result, changes, + changeCounts, warnings, - summary: `${changes.length} node${changes.length === 1 ? "" : "s"} changed` + summary }; } diff --git a/test/integration/edit-acceptance.integration.test.js b/test/integration/edit-acceptance.integration.test.js index e415f79..59f8fb1 100644 --- a/test/integration/edit-acceptance.integration.test.js +++ b/test/integration/edit-acceptance.integration.test.js @@ -302,9 +302,20 @@ test("edit acceptance on a native serve process: queries, preview, deploy, stale project ); assert.equal(preview.status, 0, preview.stderr); - assert.equal(JSON.parse(preview.stdout).deployed, false); + const previewResult = JSON.parse(preview.stdout); + assert.equal(previewResult.deployed, false); + assert.deepEqual(previewResult.changeCounts, { nodes: 3, flowMetadata: 1 }); + assert.equal(previewResult.summary, "3 nodes changed; flow order changed"); assert.deepEqual((await admin(port)).body, seededFlows, "preview must not deploy"); + const humanPreview = invoke( + ["edit", "preview", planFile, "--admin-url", `http://127.0.0.1:${port}`], + project + ); + assert.equal(humanPreview.status, 0, humanPreview.stderr); + assert.match(humanPreview.stdout, /3 nodes changed/); + assert.deepEqual((await admin(port)).body, seededFlows, "human preview must not deploy"); + const applied = invoke( ["edit", "apply", planFile, "--admin-url", `http://127.0.0.1:${port}`, "--json"], project @@ -312,6 +323,7 @@ test("edit acceptance on a native serve process: queries, preview, deploy, stale assert.equal(applied.status, 0, applied.stderr); const appliedResult = JSON.parse(applied.stdout); assert.equal(appliedResult.deployed, true); + assert.equal(appliedResult.summary, "3 nodes changed; flow order changed"); assert.notEqual(appliedResult.rev, base.body.rev); const deployed = await admin(port); assert.equal(deployed.body.flows.find((node) => node.id === "serve-tab").label, "Serve HTTP"); @@ -798,9 +810,43 @@ test("structural edit operations preserve native Link and subflow behavior acros project ); assert.equal(preview.status, 0, preview.stderr); - assert.equal(JSON.parse(preview.stdout).deployed, false); + const previewResult = JSON.parse(preview.stdout); + assert.equal(previewResult.deployed, false); + assert.deepEqual(previewResult.changeCounts, { + nodes: previewResult.changes.filter((change) => change.type !== "flow").length, + flowMetadata: previewResult.changes.filter((change) => change.type === "flow").length + }); + assert.match(previewResult.summary, /flow order changed/); assert.equal((await admin(port)).body.rev, initial.body.rev, "preview must not deploy structural changes"); + const reorderFile = writePlan(project, "reorder-preview.json", { + rev: initial.body.rev, + ops: [ + { + op: "tab", + action: "reorder", + order: ["second", "first", "return-tab", "nested-tab", "timeout-tab", "duplicate-tab"] + } + ] + }); + const reorderPreview = invoke( + ["edit", "preview", reorderFile, "--admin-url", `http://127.0.0.1:${port}`, "--json"], + project + ); + assert.equal(reorderPreview.status, 0, reorderPreview.stderr); + const reorderResult = JSON.parse(reorderPreview.stdout); + assert.deepEqual(reorderResult.changeCounts, { nodes: 0, flowMetadata: 1 }); + assert.deepEqual(reorderResult.changes.at(-1), { id: "flow-order", type: "flow", action: "reordered" }); + assert.equal(reorderResult.summary, "0 nodes changed; flow order changed"); + const humanReorderPreview = invoke( + ["edit", "preview", reorderFile, "--admin-url", `http://127.0.0.1:${port}`], + project + ); + assert.equal(humanReorderPreview.status, 0, humanReorderPreview.stderr); + assert.match(humanReorderPreview.stdout, /0 nodes changed; flow order changed/); + assert.doesNotMatch(humanReorderPreview.stdout, /1 node changed/); + assert.equal((await admin(port)).body.rev, initial.body.rev, "reorder preview must not deploy"); + const applied = invoke( ["edit", "apply", previewFile, "--admin-url", `http://127.0.0.1:${port}`, "--json"], project diff --git a/test/unit/edit.unit.test.js b/test/unit/edit.unit.test.js index 16ad908..e5f3c33 100644 --- a/test/unit/edit.unit.test.js +++ b/test/unit/edit.unit.test.js @@ -432,7 +432,7 @@ test("delete cleans owned wires and group membership but refuses structural or u test("tab reorder moves tab slots only and tab deletion refuses owned content", () => { const graph = [ { id: "a", type: "tab" }, - { id: "n", type: "inject", z: "a", wires: [[]] }, + { id: "n", type: "inject", z: "a", x: 10, y: 20, wires: [[]] }, { id: "b", type: "tab" } ]; const reordered = transformPlan(graph, { @@ -443,6 +443,25 @@ test("tab reorder moves tab slots only and tab deletion refuses owned content", reordered.flows.map((item) => item.id), ["b", "n", "a"] ); + assert.deepEqual(reordered.changeCounts, { nodes: 0, flowMetadata: 1 }); + assert.equal(reordered.summary, "0 nodes changed; flow order changed"); + assert.deepEqual(reordered.changes, [{ id: "flow-order", type: "flow", action: "reordered" }]); + const nodeAndOrder = transformPlan(graph, { + rev: "r", + ops: [ + { op: "set", id: "n", fields: { name: "Updated" } }, + { op: "tab", action: "reorder", order: ["b", "a"] } + ] + }); + assert.deepEqual(nodeAndOrder.changeCounts, { nodes: 1, flowMetadata: 1 }); + assert.equal(nodeAndOrder.summary, "1 node changed; flow order changed"); + assert.deepEqual(nodeAndOrder.changes.at(-1), { id: "flow-order", type: "flow", action: "reordered" }); + const nodeOnly = transformPlan(graph, { + rev: "r", + ops: [{ op: "set", id: "n", fields: { name: "Updated" } }] + }); + assert.deepEqual(nodeOnly.changeCounts, { nodes: 1, flowMetadata: 0 }); + assert.equal(nodeOnly.summary, "1 node changed"); assert.throws( () => transformPlan(graph, { rev: "r", ops: [{ op: "tab", action: "delete", id: "a" }] }), /nonempty/ From f1c5118c4d05f74daf7d92193b0b91abbee19602 Mon Sep 17 00:00:00 2001 From: Tom Brandenburg Date: Sun, 11 Oct 2026 20:44:46 +0200 Subject: [PATCH 3/3] docs: clarify serve-run user directory continuity --- README.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/README.md b/README.md index c82ae8b..6748723 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,65 @@ node-red-cli edit preview plan.json --json node-red-cli edit apply plan.json --json ``` +#### Serve → edit → run with one user directory + +`serve` uses Node-RED's normal persistent user directory by default, while +`run` uses a fresh temporary directory unless `--user-dir` is supplied. When a +flow depends on stored credentials, installed nodes, settings, or other +user-directory state, use the same explicit directory for both commands. This +is not needed for every flow, and the defaults do not need to change. + +From a checkout after `make install`, prepare a disposable copy of the fixture; +do not edit the tracked fixture in place: + +```bash +mkdir -p ./.roundtrip-demo/user +cp test/fixtures/flows.json ./.roundtrip-demo/flows.json +``` + +In Terminal A, start the server and wait until Node-RED reports that it is +running: + +```bash +node bin/node-red-cli.js serve ./.roundtrip-demo/flows.json --user-dir ./.roundtrip-demo/user --port 1880 +``` + +In Terminal B, read the current revision and create a plan to rename the +fixture's `add` node. `edit` connects to `http://127.0.0.1:1880` by default; +it has no `--user-dir` option because it operates on the running server's +state. If the server uses another port or Admin API path, pass its matching +`--admin-url` to each `edit` command below. + +```bash +node bin/node-red-cli.js edit list --json | node -e 'let input = ""; process.stdin.on("data", chunk => input += chunk); process.stdin.on("end", () => { const { rev } = JSON.parse(input); process.stdout.write(JSON.stringify({ rev, ops: [{ op: "set", id: "add", fields: { name: "Add two numbers" } }] }, null, 2) + "\n"); });' > ./.roundtrip-demo/plan.json +node bin/node-red-cli.js edit preview ./.roundtrip-demo/plan.json --json +node bin/node-red-cli.js edit get add --json +node bin/node-red-cli.js edit apply ./.roundtrip-demo/plan.json --json +node bin/node-red-cli.js edit get add --json +``` + +Preview should succeed with `"deployed": false`; the first `get` should still +show the original name, and the final `get` should show `Add two numbers`. +After preview/apply, stop Terminal A with Ctrl-C before running the flow as a +standalone invocation in Terminal B: + +```bash +node bin/node-red-cli.js run ./.roundtrip-demo/flows.json calculate --user-dir ./.roundtrip-demo/user --set x=4 --set y=5 < /dev/null +``` + +The result is `9`. Stop both processes before removing the disposable demo: + +```bash +rm -rf ./.roundtrip-demo +``` + +Node-RED's user directory holds runtime data such as settings, credentials, and +installed nodes. Using different directories can therefore produce +configuration or credential warnings, or leave a required node package +unavailable; an actual missing package or invalid credential still needs to be +resolved. Sharing the directory for this sequential example keeps both +runtimes aligned where the flow needs that state. + Plans use the opaque revision returned by list/get and an ordered operation array. Operations are transformed sequentially on a deep copy. `set` changes non-structural fields on ordinary nodes. For an existing Link In, Link Out, or