diff --git a/AGENTS.md b/AGENTS.md
index b7b34e6..092dae1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/README.md b/README.md
index 218ae57..d3db5cf 100644
--- a/README.md
+++ b/README.md
@@ -257,6 +257,7 @@ node-red-cli run plan.json --flow-dir ~/.workflows
| Option | Description |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
+| `--cwd
` | Root-level host working directory and base for local relative paths; incompatible with `run --docker`. |
| `--tab ` | Scope entry selection to a workspace tab ID or unique label. |
| `--flow-dir ` | Directory for a bare flow-file name and the default `flows.json`. |
| `--flow-json ` | Use inline flow JSON, or `-` to read the flow definition from stdin. Mutually exclusive with a flow-file positional. |
@@ -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 ` 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.
diff --git a/bin/node-red-cli.js b/bin/node-red-cli.js
index 91003fe..75d60b8 100755
--- a/bin/node-red-cli.js
+++ b/bin/node-red-cli.js
@@ -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 = [
"",
@@ -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 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,",
@@ -526,6 +531,19 @@ async function run(flowFileArg, entryArg, options, command) {
const program = new Command();
+program
+ .option("--cwd ", "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 ", "workspace tab id or unique label to scope entry selection")
@@ -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 ", "directory for a bare flow-file name")
.option("--user-dir ", "native persistent Node-RED userDir")
.option("--port ", "native Node-RED HTTP port (default: 1880)", parsePort, 1880)
@@ -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 ", "workspace tab id or unique label")
.option("--flow-dir ", "directory for a bare flow-file name")
.option("--flow-json ", "inline flow JSON or '-' to read flow JSON from stdin")
@@ -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 ", "filter by tab ID or exact label")
.option("--kind ", "list workspace nodes (default) or config nodes")
@@ -630,6 +651,7 @@ editList.action(async (options, command) => {
});
const editGet = editCommand.command("get ").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();
@@ -646,19 +668,21 @@ editGet.action(async (id, options, command) => {
const editPreview = editCommand
.command("preview ")
- .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 ")
- .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;
});
diff --git a/src/working-dir.js b/src/working-dir.js
new file mode 100644
index 0000000..c2099e0
--- /dev/null
+++ b/src/working-dir.js
@@ -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 };
diff --git a/test/fixtures/cwd-exec.flows.json b/test/fixtures/cwd-exec.flows.json
new file mode 100644
index 0000000..b1b02eb
--- /dev/null
+++ b/test/fixtures/cwd-exec.flows.json
@@ -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": []
+ }
+]
diff --git a/test/fixtures/cwd-http.flows.json b/test/fixtures/cwd-http.flows.json
new file mode 100644
index 0000000..021cd60
--- /dev/null
+++ b/test/fixtures/cwd-http.flows.json
@@ -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": []
+ }
+]
\ No newline at end of file
diff --git a/test/integration/cli.integration.test.js b/test/integration/cli.integration.test.js
index e6fdc18..2cae921 100644
--- a/test/integration/cli.integration.test.js
+++ b/test/integration/cli.integration.test.js
@@ -12,6 +12,7 @@ const test = require("node:test");
const root = path.resolve(__dirname, "../..");
const cli = path.join(root, "bin/node-red-cli.js");
const fixture = path.join(root, "test/fixtures/single-link-in.flows.json");
+const cwdFixture = path.join(root, "test/fixtures/cwd-exec.flows.json");
function createProject(t) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "node-red-cli-command-"));
@@ -62,6 +63,7 @@ test("run is mandatory and help exposes only the canonical run options", (t) =>
const rootHelp = invoke(["--help"], project);
assert.equal(rootHelp.status, 0, rootHelp.stderr);
assert.match(rootHelp.stdout, /run/);
+ assert.match(rootHelp.stdout, /--cwd /);
assert.doesNotMatch(rootHelp.stdout, /flow-file execution/);
const legacy = invoke([fixture, "calculate", "--set", "x=4"], project);
assert.notEqual(legacy.status, 0);
@@ -70,11 +72,122 @@ test("run is mandatory and help exposes only the canonical run options", (t) =>
for (const flag of ["--tab", "--flow-dir", "--flow-json", "--user-dir", "--docker-user-dir", "--output"]) {
assert.match(help.stdout, new RegExp(flag));
}
+ assert.match(help.stdout, /--cwd /);
for (const flag of ["--flow ", "--flowDir", "--flowJson", "--userDir", "--docker-userdir", "--format"]) {
assert.doesNotMatch(help.stdout, new RegExp(flag));
}
});
+test("global cwd works around commands, controls host runtime, and validates before actions", (t) => {
+ const project = createProject(t);
+ const caller = path.join(project.directory, "caller");
+ const selected = path.join(project.directory, "project");
+ fs.mkdirSync(caller);
+ fs.mkdirSync(selected);
+ const isolated = { ...project, directory: caller };
+ const relativeCwd = path.relative(caller, selected);
+ const expectedCwd = fs.realpathSync(selected);
+
+ const afterRun = invoke(["run", cwdFixture, "cwd", "--cwd", relativeCwd], isolated);
+ assert.equal(afterRun.status, 0, afterRun.stderr);
+ assert.equal(afterRun.stdout.trim(), expectedCwd);
+
+ const inlineFlow = fs.readFileSync(cwdFixture, "utf8");
+ for (const [args, input] of [
+ [["run", "cwd", "--flow-json", inlineFlow, "--cwd", relativeCwd], ""],
+ [["run", "cwd", "--flow-json", "-", "--cwd", relativeCwd], inlineFlow]
+ ]) {
+ const result = invoke(args, isolated, input);
+ assert.equal(result.status, 0, result.stderr);
+ assert.equal(result.stdout.trim(), expectedCwd);
+ }
+
+ const beforeRun = invoke(["--cwd", relativeCwd, "run", fixture, "--set", "x=4", "--set", "y=5"], isolated);
+ assert.equal(beforeRun.status, 0, beforeRun.stderr);
+ assert.equal(beforeRun.stdout.trim(), "9");
+
+ const flowDir = path.join(project.directory, "flow source");
+ fs.mkdirSync(flowDir);
+ fs.copyFileSync(fixture, path.join(flowDir, "plan.json"));
+ const fromFlowDir = invoke(
+ [
+ "run",
+ "plan.json",
+ "--flow-dir",
+ path.relative(selected, flowDir),
+ "--cwd",
+ selected,
+ "--set",
+ "x=4",
+ "--set",
+ "y=5"
+ ],
+ isolated
+ );
+ assert.equal(fromFlowDir.status, 0, fromFlowDir.stderr);
+ assert.equal(fromFlowDir.stdout.trim(), "9");
+ const inspection = invoke(
+ ["inspect", "plan.json", "--flow-dir", path.relative(selected, flowDir), "--cwd", selected, "--json"],
+ isolated
+ );
+ assert.equal(inspection.status, 0, inspection.stderr);
+ assert.equal(JSON.parse(inspection.stdout).source.path, path.join(flowDir, "plan.json"));
+ fs.copyFileSync(fixture, path.join(selected, "plan.json"));
+ const explicitInspection = invoke(
+ ["inspect", "./plan.json", "--flow-dir", flowDir, "--cwd", selected, "--json"],
+ isolated
+ );
+ assert.equal(explicitInspection.status, 0, explicitInspection.stderr);
+ assert.equal(JSON.parse(explicitInspection.stdout).source.path, path.join(selected, "plan.json"));
+
+ const explicitUserDir = invoke(
+ ["run", fixture, "--cwd", selected, "--user-dir", "runtime", "--set", "x=4", "--set", "y=5"],
+ isolated
+ );
+ assert.equal(explicitUserDir.status, 0, explicitUserDir.stderr);
+ assert.ok(fs.existsSync(path.join(selected, "runtime", ".config.runtime.json")));
+ assert.equal(fs.existsSync(path.join(caller, "runtime")), false);
+ const cacheRun = invoke(
+ ["run", fixture, "--cwd", selected, "--user-dir", "--set", "x=4", "--set", "y=5"],
+ isolated
+ );
+ assert.equal(cacheRun.status, 0, cacheRun.stderr);
+ assert.ok(fs.existsSync(path.join(project.home, ".cache", "node-red-cli")));
+ const missingCwd = invoke(["run", cwdFixture, "cwd", "--cwd", path.join(selected, "missing")], isolated);
+ assert.equal(missingCwd.status, 1);
+ assert.match(missingCwd.stderr, /working directory not found:/);
+ const fileCwd = invoke(["inspect", fixture, "--cwd", fixture, "--json"], isolated);
+ assert.equal(fileCwd.status, 1);
+ assert.match(fileCwd.stderr, /not a working directory:/);
+
+ for (const args of [
+ ["run", "missing.json", "--docker", "--cwd", path.join(selected, "missing")],
+ ["run", "missing.json", "--docker=mock:sandbox", "--cwd", selected],
+ ["--cwd", selected, "run", "missing.json", "--docker"]
+ ]) {
+ const rejected = invoke(args, isolated);
+ assert.equal(rejected.status, 1, args.join(" "));
+ assert.match(rejected.stderr, /--cwd cannot be combined with --docker/);
+ assert.doesNotMatch(rejected.stderr, /flow file not found/);
+ }
+
+ for (const args of [["--help"], ["--version"], ["run", "--help"], ["edit", "preview", "--help"]]) {
+ const result = invoke(args, isolated);
+ assert.equal(result.status, 0, result.stderr);
+ }
+ for (const args of [
+ ["serve", "--help"],
+ ["inspect", "--help"],
+ ["edit", "list", "--help"],
+ ["edit", "get", "--help"],
+ ["edit", "apply", "--help"]
+ ]) {
+ const help = invoke(args, isolated);
+ assert.equal(help.status, 0, help.stderr);
+ assert.match(help.stdout, /--cwd /, args.join(" "));
+ }
+});
+
test("flow-file resolution uses cwd, flow-dir, defaults, and exact paths without discovery", (t) => {
const project = createProject(t);
fs.copyFileSync(fixture, path.join(project.directory, "flows.json"));
@@ -267,7 +380,14 @@ test("colon-containing local flow filenames are not reinterpreted as HTTP URLs",
test("run and inspect load remote flow arrays in memory from direct URLs", async (t) => {
const project = createProject(t);
const flow = fs.readFileSync(fixture);
+ const cwdFlow = fs.readFileSync(cwdFixture);
+ const requests = [];
const server = http.createServer((request, response) => {
+ requests.push(request.url);
+ if (request.url.startsWith("/cwd")) {
+ response.writeHead(200, { "Content-Type": "application/json" }).end(cwdFlow);
+ return;
+ }
if (request.url.startsWith("/redirect")) {
response.writeHead(302, { Location: "/flows.json" }).end();
return;
@@ -286,6 +406,12 @@ test("run and inspect load remote flow arrays in memory from direct URLs", async
t.after(() => new Promise((resolve) => server.close(resolve)));
const base = `http://127.0.0.1:${server.address().port}`;
+ const selected = path.join(project.directory, "url-project");
+ fs.mkdirSync(selected);
+ const cwdUrlRun = await invokeAsync(["run", `${base}/cwd.json`, "cwd", "--cwd", selected], project);
+ assert.equal(cwdUrlRun.status, 0, cwdUrlRun.stderr);
+ assert.equal(cwdUrlRun.stdout.trim(), fs.realpathSync(selected));
+
const inventory = await invokeAsync(
["inspect", `${base}/flows.json?token=secret`, "calculate", "--depth", "2", "--json"],
project
@@ -297,6 +423,15 @@ test("run and inspect load remote flow arrays in memory from direct URLs", async
assert.ok(!inventory.stdout.includes("secret"));
assert.deepEqual(fs.readdirSync(project.home), []);
+ const requestCount = requests.length;
+ const rejectedDockerUrl = await invokeAsync(
+ ["run", `${base}/cwd.json`, "cwd", "--docker", "--cwd", selected],
+ project
+ );
+ assert.equal(rejectedDockerUrl.status, 1);
+ assert.match(rejectedDockerUrl.stderr, /--cwd cannot be combined with --docker/);
+ assert.equal(requests.length, requestCount, "Docker conflict must fail before fetching flow URLs");
+
assert.equal(
(
await invokeAsync(["run", `${base}/flows.json`, "calculate", "--set", "x=4", "--set", "y=5"], project)
diff --git a/test/integration/edit-acceptance.integration.test.js b/test/integration/edit-acceptance.integration.test.js
index 4071060..cf15b31 100644
--- a/test/integration/edit-acceptance.integration.test.js
+++ b/test/integration/edit-acceptance.integration.test.js
@@ -431,6 +431,94 @@ test("edit acceptance on a native serve process: queries, preview, deploy, stale
assert.equal(await (await fetch(`http://127.0.0.1:${port}/hello`)).text(), "edited by native apply");
});
+test("edit preview and apply resolve relative plan paths from --cwd", async (t) => {
+ const project = temporaryProject(t);
+ const caller = path.join(project.directory, "caller");
+ const selected = path.join(project.directory, "project");
+ fs.mkdirSync(caller);
+ fs.mkdirSync(selected);
+ const planDirectory = path.join(selected, "plans");
+ fs.mkdirSync(planDirectory);
+ const port = await freePort();
+ const server = launch(project, port);
+ t.after(async () => stop(server));
+ await waitForReady(server, port);
+ const before = await admin(port);
+ const list = spawnSync(
+ process.execPath,
+ [
+ cli,
+ "edit",
+ "--cwd",
+ path.relative(caller, selected),
+ "list",
+ "--admin-url",
+ `http://127.0.0.1:${port}`,
+ "--json"
+ ],
+ { cwd: caller, encoding: "utf8", env: { ...process.env, HOME: project.directory } }
+ );
+ assert.equal(list.status, 0, list.stderr);
+ assert.ok(JSON.parse(list.stdout).nodes.some((node) => node.id === "serve-set-payload"));
+ const get = spawnSync(
+ process.execPath,
+ [
+ cli,
+ "edit",
+ "get",
+ "serve-set-payload",
+ "--cwd",
+ path.relative(caller, selected),
+ "--admin-url",
+ `http://127.0.0.1:${port}`,
+ "--json"
+ ],
+ { cwd: caller, encoding: "utf8", env: { ...process.env, HOME: project.directory } }
+ );
+ assert.equal(get.status, 0, get.stderr);
+ assert.equal(JSON.parse(get.stdout).node.id, "serve-set-payload");
+ const planPath = path.join(planDirectory, "change.json");
+ fs.writeFileSync(
+ planPath,
+ JSON.stringify({
+ rev: before.body.rev,
+ ops: [{ op: "set", id: "serve-set-payload", fields: { name: "cwd plan applied" } }]
+ })
+ );
+ const args = [
+ "edit",
+ "preview",
+ "plans/change.json",
+ "--cwd",
+ path.relative(caller, selected),
+ "--admin-url",
+ `http://127.0.0.1:${port}`,
+ "--json"
+ ];
+ const preview = spawnSync(process.execPath, [cli, ...args], {
+ cwd: caller,
+ encoding: "utf8",
+ env: { ...process.env, HOME: project.directory }
+ });
+ assert.equal(preview.status, 0, preview.stderr);
+ assert.equal(JSON.parse(preview.stdout).deployed, false);
+ assert.equal((await admin(port)).body.rev, before.body.rev);
+
+ const apply = spawnSync(
+ process.execPath,
+ [cli, ...args.map((value, index) => (index === 1 ? "apply" : value))],
+ {
+ cwd: caller,
+ encoding: "utf8",
+ env: { ...process.env, HOME: project.directory }
+ }
+ );
+ assert.equal(apply.status, 0, apply.stderr);
+ assert.equal(JSON.parse(apply.stdout).deployed, true);
+ const after = await admin(port);
+ assert.equal(after.body.flows.find((node) => node.id === "serve-set-payload").name, "cwd plan applied");
+});
+
test("credential operation fails closed when the runtime exposes no credential schema", async (t) => {
const project = temporaryProject(t);
const port = await freePort();
diff --git a/test/integration/serve.integration.test.js b/test/integration/serve.integration.test.js
index bc76023..82e8518 100644
--- a/test/integration/serve.integration.test.js
+++ b/test/integration/serve.integration.test.js
@@ -10,6 +10,7 @@ const test = require("node:test");
const root = path.resolve(__dirname, "../..");
const cli = path.join(root, "bin/node-red-cli.js");
const fixture = path.join(root, "test/fixtures/serve-http.flows.json");
+const cwdFixture = path.join(root, "test/fixtures/cwd-http.flows.json");
function project(t) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "node-red-cli-serve-"));
@@ -100,3 +101,72 @@ test("serve launches a native HTTP In flow and shuts its child down on SIGTERM",
assert.ok(fs.existsSync(userDir), "native userDir persists after shutdown");
await assert.rejects(globalThis.fetch(`http://127.0.0.1:${address[1]}/hello`));
});
+
+test("serve resolves the flow and runs native child processes from global cwd", async (t) => {
+ const { directory, home } = project(t);
+ const caller = path.join(directory, "caller");
+ const selected = path.join(directory, "project");
+ const flowDir = path.join(directory, "flow source");
+ const userDir = "native-runtime";
+ fs.mkdirSync(caller);
+ fs.mkdirSync(selected);
+ fs.mkdirSync(flowDir);
+ fs.copyFileSync(cwdFixture, path.join(flowDir, "server.json"));
+ const expectedCwd = fs.realpathSync(selected);
+ const relativeCwd = path.relative(caller, selected);
+ const child = spawn(
+ process.execPath,
+ [
+ cli,
+ "serve",
+ "server.json",
+ "--flow-dir",
+ path.relative(selected, flowDir),
+ "--cwd",
+ relativeCwd,
+ "--port",
+ "0",
+ "--user-dir",
+ userDir
+ ],
+ {
+ cwd: caller,
+ env: { ...process.env, HOME: home },
+ stdio: ["ignore", "pipe", "pipe"]
+ }
+ );
+ let output = "";
+ child.stdout.setEncoding("utf8").on("data", (chunk) => (output += chunk));
+ child.stderr.setEncoding("utf8").on("data", (chunk) => (output += chunk));
+ const closed = new Promise((resolve, reject) => {
+ child.once("error", reject);
+ child.once("close", (code, signal) => resolve({ code, signal }));
+ });
+ t.after(() => {
+ if (child.exitCode === null && child.signalCode === null) child.kill("SIGTERM");
+ });
+
+ let response;
+ const deadline = Date.now() + 20000;
+ while (Date.now() < deadline) {
+ const address = output.match(/127\.0\.0\.1:(\d+)/);
+ if (address) {
+ try {
+ response = await fetch(`http://127.0.0.1:${address[1]}/cwd`);
+ if (response.ok) break;
+ } catch {
+ response = undefined;
+ }
+ }
+ if (child.exitCode !== null) break;
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ assert.ok(response?.ok, `native cwd endpoint did not become ready: ${output}`);
+ assert.equal((await response.text()).trim(), expectedCwd);
+ assert.ok(fs.existsSync(path.join(selected, userDir)));
+ assert.equal(fs.existsSync(path.join(caller, userDir)), false);
+ const address = output.match(/127\.0\.0\.1:(\d+)/);
+ child.kill("SIGTERM");
+ assert.deepEqual(await closed, { code: 0, signal: null });
+ await assert.rejects(fetch(`http://127.0.0.1:${address[1]}/cwd`));
+});
diff --git a/test/unit/working-dir.unit.test.js b/test/unit/working-dir.unit.test.js
new file mode 100644
index 0000000..d028d8b
--- /dev/null
+++ b/test/unit/working-dir.unit.test.js
@@ -0,0 +1,25 @@
+"use strict";
+
+const assert = require("node:assert/strict");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+const test = require("node:test");
+const { applyWorkingDirectory, resolveWorkingDirectory } = require("../../src/working-dir");
+
+test("working directory resolves once from the original cwd without tilde expansion", () => {
+ assert.equal(resolveWorkingDirectory(undefined, "/caller"), undefined);
+ assert.equal(resolveWorkingDirectory("project", "/caller"), path.resolve("/caller/project"));
+ assert.equal(resolveWorkingDirectory("/project", "/caller"), path.resolve("/project"));
+ assert.equal(resolveWorkingDirectory("~/project", "/caller"), path.resolve("/caller/~/project"));
+});
+
+test("working directory rejects missing paths and regular files", (t) => {
+ const directory = fs.mkdtempSync(path.join(os.tmpdir(), "node-red-working-dir-"));
+ t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
+ const file = path.join(directory, "plan.json");
+ fs.writeFileSync(file, "{}");
+
+ assert.throws(() => applyWorkingDirectory(path.join(directory, "missing")), /working directory not found:/);
+ assert.throws(() => applyWorkingDirectory(file), /not a working directory:/);
+});