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:/); +});