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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,4 @@ test/fixtures/ Example Node-RED flow used as a test asset
- 2026-09-06: Pitfall: `node --test` runs test files concurrently by default, so `test/e2e`'s "no leftover temp dirs" check can false-positive on another test file's in-flight `os.tmpdir()` entry (see #26). Prevention: don't assume a lone CI test failure is a real regression before ruling out cross-file concurrency races; re-run once before investigating code changes.
- 2026-10-05: Pitfall: Node-RED 5.0.7 can classify flow nodes without editor coordinates as config nodes, silently leaving subflow instances uninstantiated. Prevention: give real-runtime fixtures `x`/`y` coordinates and verify runtime node instantiation when testing subflows.
- 2026-10-11: Pitfall: `spawnSync` in a test that hosts a local HTTP mock blocks the server event loop, making subprocess requests time out. Prevention: use async `spawn` and await child closure whenever an in-process server must serve the child.
- 2026-10-11: Pitfall: shell-quoted Exec fixture commands can be platform-specific. Prevention: keep commands simple and verify them through Node-RED's real Exec node on supported platforms.
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ node-red-cli run plan.json --flow-dir ~/.workflows

| Option | Description |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| `--cwd <dir>` | Root-level host working directory and base for local relative paths; incompatible with `run --docker`. |
| `--tab <id\|label>` | Scope entry selection to a workspace tab ID or unique label. |
| `--flow-dir <dir>` | Directory for a bare flow-file name and the default `flows.json`. |
| `--flow-json <json\|->` | Use inline flow JSON, or `-` to read the flow definition from stdin. Mutually exclusive with a flow-file positional. |
Expand All @@ -279,6 +280,36 @@ the invocation message with `--set` rather than stdin:
node-red-cli run --flow-json - calculate --set x=4 --set y=5 < test/fixtures/flows.json
```

### Working directory

The root-level `--cwd <dir>` option selects the host process working directory
for local relative paths and host flow execution. It can appear before or after
the command and its positional arguments. A relative value is resolved against
the directory from which `node-red-cli` was invoked; the directory must exist.
Without `--cwd`, the CLI does not change the process directory. This option does
not change `HOME`, Node-RED module lookup, or a remote server's working directory.

```bash
# Keep a flow file in a central location while running its code in a project:
node-red-cli run /opt/flows/task.json execute --cwd /workspace/project

# Search for a bare flow name in a separate flow directory:
node-red-cli run task.json execute --cwd /workspace/project --flow-dir /opt/flows

# Read an edit plan relative to the selected directory; the server stays remote:
node-red-cli edit preview changes.json --cwd /workspace/project
```

`--cwd` selects the host process directory. `--flow-dir` selects only where a
bare flow filename (or default `flows.json`) is looked up; an explicit path
continues to resolve directly from the effective working directory. `--user-dir`
selects Node-RED settings, runtime state, and installed nodes: an explicit
relative value is based on `--cwd`, the bare flag still selects its stable
cache, and omission retains the command's normal default (ephemeral for `run`,
Node-RED's native default for `serve`). `--cwd` cannot be combined with
`run --docker`; the sandbox has no host directory mounts, and its working
directory is independent.

`--node-modules` runs `npm install` and therefore executes package code from the
configured registry. Use trusted packages only. Docker mode uses a read-only
root filesystem, drops capabilities, and disables networking by default.
Expand Down
30 changes: 27 additions & 3 deletions bin/node-red-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const { parseFlowUrl, fetchFlowUrl, displayFlowUrl } = require("../src/flow-url"
const { createEditClient, DEFAULT_ADMIN_URL } = require("../src/edit-api");
const { listNodes, getNodeContext } = require("../src/edit-query");
const { parsePlan, transformPlan } = require("../src/edit-plan");
const { applyWorkingDirectory } = require("../src/working-dir");

const HELP_TEXT = [
"",
Expand Down Expand Up @@ -87,6 +88,10 @@ const HELP_TEXT = [
"the best-effort /data auto-probe. Ignored outside --docker mode.",
"Precedence: --user-dir > --docker-user-dir > the /data auto-probe > the ephemeral default.",
"",
"--cwd <dir> changes the host process working directory before local paths",
"and flow execution. Relative values are based on the invoking directory.",
"It is incompatible with --docker, which has no host directory mounts.",
"",
"The flow-file may be a local path or a direct HTTP(S) URL returning a JSON array.",
"URLs are fetched on the host (also with --docker); only use trusted sources.",
"A flow-file path with a separator is exact. Bare flow-files resolve in cwd,",
Expand Down Expand Up @@ -526,6 +531,19 @@ async function run(flowFileArg, entryArg, options, command) {

const program = new Command();

program
.option("--cwd <dir>", "working directory for local paths and host execution")
.hook("preAction", (_thisCommand, actionCommand) => {
const cwd = program.getOptionValue("cwd");
if (program.getOptionValueSource("cwd") !== "cli") return;
if (actionCommand.name() === "run" && actionCommand.optsWithGlobals().docker) {
throw new Error(
"--cwd cannot be combined with --docker; host project directories are not mounted into the sandbox"
);
}
applyWorkingDirectory(cwd);
});

function addRunOptions(command) {
return command
.option("--tab <id|label>", "workspace tab id or unique label to scope entry selection")
Expand Down Expand Up @@ -577,6 +595,7 @@ program
.command("serve")
.description("run a flow file with native Node-RED server/editor")
.argument("[flow-file]", "local Node-RED flows.json file")
.configureHelp({ showGlobalOptions: true })
.option("--flow-dir <dir>", "directory for a bare flow-file name")
.option("--user-dir <dir>", "native persistent Node-RED userDir")
.option("--port <port>", "native Node-RED HTTP port (default: 1880)", parsePort, 1880)
Expand All @@ -587,6 +606,7 @@ program
.description("inspect a Node-RED flow-file without running it")
.argument("[flow-file]", "local flow JSON file or direct HTTP(S) flow JSON URL")
.argument("[entry]", "workspace Link In id or unique name")
.configureHelp({ showGlobalOptions: true })
.option("--tab <id|label>", "workspace tab id or unique label")
.option("--flow-dir <dir>", "directory for a bare flow-file name")
.option("--flow-json <json|->", "inline flow JSON or '-' to read flow JSON from stdin")
Expand All @@ -605,6 +625,7 @@ editOptions(editCommand);
editCommand.configureHelp({ showGlobalOptions: true });

const editList = editCommand.command("list").description("list live tabs and workspace nodes");
editList.configureHelp({ showGlobalOptions: true });
editOptions(editList)
.option("--tab <id|label>", "filter by tab ID or exact label")
.option("--kind <workspace|config>", "list workspace nodes (default) or config nodes")
Expand All @@ -630,6 +651,7 @@ editList.action(async (options, command) => {
});

const editGet = editCommand.command("get <id>").description("get a live node and its ordinary wire context");
editGet.configureHelp({ showGlobalOptions: true });
editOptions(editGet);
editGet.action(async (id, options, command) => {
options = command.optsWithGlobals();
Expand All @@ -646,19 +668,21 @@ editGet.action(async (id, options, command) => {

const editPreview = editCommand
.command("preview <plan.json>")
.description("preview a revision-bound edit plan without deploying");
.description("preview a revision-bound edit plan without deploying")
.configureHelp({ showGlobalOptions: true });
editOptions(editPreview).action((planFile, options, command) =>
editPlanFile(planFile, command.optsWithGlobals(), false)
);

const editApply = editCommand
.command("apply <plan.json>")
.description("apply a revision-bound edit plan with one full deployment");
.description("apply a revision-bound edit plan with one full deployment")
.configureHelp({ showGlobalOptions: true });
editOptions(editApply).action((planFile, options, command) =>
editPlanFile(planFile, command.optsWithGlobals(), true)
);

program.parseAsync(process.argv).catch((error) => {
console.error(error.stack || error.message);
console.error(`node-red-cli: ${error.message}`);
process.exitCode = 1;
});
36 changes: 36 additions & 0 deletions src/working-dir.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"use strict";

const fs = require("node:fs");
const path = require("node:path");

function resolveWorkingDirectory(value, originalCwd = process.cwd()) {
if (value === undefined) return undefined;
return path.resolve(originalCwd, value);
}

function applyWorkingDirectory(value, originalCwd = process.cwd()) {
const directory = resolveWorkingDirectory(value, originalCwd);
if (directory === undefined) return;

let stats;
try {
stats = fs.statSync(directory);
} catch (error) {
if (error.code === "ENOENT" || error.code === "ENOTDIR")
throw new Error(`working directory not found: ${directory}`, { cause: error });
throw new Error(`cannot access working directory ${directory}: ${error.code || error.message}`, {
cause: error
});
}
if (!stats.isDirectory()) throw new Error(`not a working directory: ${directory}`);

try {
process.chdir(directory);
} catch (error) {
throw new Error(`cannot use working directory ${directory}: ${error.code || error.message}`, {
cause: error
});
}
}

module.exports = { applyWorkingDirectory, resolveWorkingDirectory };
46 changes: 46 additions & 0 deletions test/fixtures/cwd-exec.flows.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
[
{
"id": "cwd-tab",
"type": "tab",
"label": "Working directory",
"disabled": false,
"info": ""
},
{
"id": "cwd-entry",
"type": "link in",
"z": "cwd-tab",
"name": "cwd",
"links": [],
"x": 120,
"y": 80,
"wires": [["cwd-exec"]]
},
{
"id": "cwd-exec",
"type": "exec",
"z": "cwd-tab",
"command": "node -p \"process.cwd()\"",
"addpay": false,
"append": "",
"useSpawn": "false",
"timer": "",
"winHide": false,
"oldrc": false,
"name": "Report subprocess cwd",
"x": 340,
"y": 80,
"wires": [["cwd-return"], [], []]
},
{
"id": "cwd-return",
"type": "link out",
"z": "cwd-tab",
"name": "return",
"mode": "return",
"links": [],
"x": 560,
"y": 80,
"wires": []
}
]
59 changes: 59 additions & 0 deletions test/fixtures/cwd-http.flows.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
[
{
"id": "cwd-http-tab",
"type": "tab",
"label": "Working directory HTTP",
"disabled": false,
"info": ""
},
{
"id": "cwd-http-in",
"type": "http in",
"z": "cwd-http-tab",
"name": "",
"url": "/cwd",
"method": "get",
"upload": false,
"swaggerDoc": "",
"x": 120,
"y": 80,
"wires": [
[
"cwd-http-exec"
]
]
},
{
"id": "cwd-http-exec",
"type": "exec",
"z": "cwd-http-tab",
"command": "node -p \"process.cwd()\"",
"addpay": false,
"append": "",
"useSpawn": "false",
"timer": "",
"winHide": false,
"oldrc": false,
"name": "cwd manual E2E updated",
"x": 340,
"y": 80,
"wires": [
[
"cwd-http-response"
],
[],
[]
]
},
{
"id": "cwd-http-response",
"type": "http response",
"z": "cwd-http-tab",
"name": "",
"statusCode": "",
"headers": {},
"x": 560,
"y": 80,
"wires": []
}
]
Loading
Loading