Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 68 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,72 @@ 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; `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
Expand Down Expand Up @@ -223,6 +286,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
Expand Down
1 change: 1 addition & 0 deletions bin/node-red-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 25 additions & 10 deletions src/edit-plan.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
}

Expand All @@ -97,15 +107,23 @@ 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" ||
Array.isArray(op.fields) ||
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) {
Expand Down Expand Up @@ -243,13 +261,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 ||
Expand All @@ -268,6 +279,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`);
Expand Down
111 changes: 109 additions & 2 deletions test/integration/edit-acceptance.integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -302,16 +302,28 @@ 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
);
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");
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -904,3 +950,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));
});
Loading
Loading