From 48c3915e4db3cf8178d84d5b0378a98cb324a2b8 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Sun, 28 Jun 2026 13:18:28 -0700 Subject: [PATCH] feat: Enhance node runner to execute scripts in source directory and update tests --- core/src/castle_core/deploy.py | 11 +++++++++- core/tests/test_deploy_run_cmd.py | 35 ++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/core/src/castle_core/deploy.py b/core/src/castle_core/deploy.py index 26a198c..f8a8ae9 100644 --- a/core/src/castle_core/deploy.py +++ b/core/src/castle_core/deploy.py @@ -465,7 +465,16 @@ def _build_run_cmd( cmd.extend(run.args) # type: ignore[union-attr] return cmd case "node": - cmd = [run.package_manager, "run", run.script] # type: ignore[union-attr] + # Like the python runner bakes `--project ` into `uv run`, the + # node runner bakes `--dir ` so the package manager runs the + # script in the program's source dir — the systemd unit carries no + # WorkingDirectory, so a bare `pnpm run` would otherwise execute in the + # service's (wrong) cwd. Resolve the package manager to an absolute path. + pm = shutil.which(run.package_manager) or run.package_manager # type: ignore[union-attr] + cmd = [pm] + if source_dir and source_dir.is_dir(): + cmd.extend(["--dir", str(source_dir)]) + cmd.extend(["run", run.script]) # type: ignore[union-attr] if run.args: # type: ignore[union-attr] cmd.extend(run.args) # type: ignore[union-attr] return cmd diff --git a/core/tests/test_deploy_run_cmd.py b/core/tests/test_deploy_run_cmd.py index 8172063..d0993ba 100644 --- a/core/tests/test_deploy_run_cmd.py +++ b/core/tests/test_deploy_run_cmd.py @@ -6,7 +6,7 @@ from pathlib import Path from unittest.mock import patch from castle_core.deploy import _build_run_cmd -from castle_core.manifest import RunContainer, RunPython +from castle_core.manifest import RunContainer, RunNode, RunPython def test_python_runner_uses_uv_run_from_source(tmp_path: Path) -> None: @@ -52,6 +52,39 @@ def test_python_runner_warns_when_unresolvable() -> None: assert any("my-svc" in m for m in messages) +def test_node_runner_bakes_source_dir(tmp_path: Path) -> None: + """A node service runs the script in its source dir via `--dir` (no unit cwd).""" + run = RunNode(runner="node", script="gateway:watch:raw", package_manager="pnpm") + with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/pnpm"): + cmd = _build_run_cmd("oc", run, {}, [], source_dir=tmp_path) + assert cmd == ["/usr/bin/pnpm", "--dir", str(tmp_path), "run", "gateway:watch:raw"] + + +def test_node_runner_appends_args(tmp_path: Path) -> None: + run = RunNode( + runner="node", script="start", package_manager="pnpm", args=["--port", "18789"] + ) + with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/pnpm"): + cmd = _build_run_cmd("oc", run, {}, [], source_dir=tmp_path) + assert cmd == [ + "/usr/bin/pnpm", + "--dir", + str(tmp_path), + "run", + "start", + "--port", + "18789", + ] + + +def test_node_runner_without_source_omits_dir() -> None: + """No resolvable source → bare `pnpm run` (package manager still PATH-resolved).""" + run = RunNode(runner="node", script="start", package_manager="pnpm") + with patch("castle_core.deploy.shutil.which", return_value="/usr/bin/pnpm"): + cmd = _build_run_cmd("oc", run, {}, [], source_dir=None) + assert cmd == ["/usr/bin/pnpm", "run", "start"] + + def test_container_secrets_use_env_file_not_argv() -> None: """Secrets go through --env-file; plain vars stay as -e; no secret in argv.""" run = RunContainer(runner="container", image="img:latest", env={"PLAIN": "1"})