From f3de8ed373aa6cb2e3333b98572ae3a26557d819 Mon Sep 17 00:00:00 2001 From: hari-dhanushkodi <203702815+hari-dhanushkodi@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:26:01 +0000 Subject: [PATCH] fix(cli): add monorepo commands to dockerfile Co-authored-by: open-swe[bot] --- libs/cli/langgraph_cli/cli.py | 38 +++++++++++- libs/cli/langgraph_cli/docker.py | 30 ++++++--- libs/cli/tests/unit_tests/cli/test_cli.py | 76 +++++++++++++++++++++++ 3 files changed, 133 insertions(+), 11 deletions(-) diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index 3a3b8b6b0..891a911e8 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -15,7 +15,11 @@ from langgraph_cli.analytics import log_command from langgraph_cli.config import Config from langgraph_cli.constants import DEFAULT_CONFIG, DEFAULT_PORT from langgraph_cli.deploy import deploy -from langgraph_cli.docker import DockerCapabilities, build_docker_image +from langgraph_cli.docker import ( + DockerCapabilities, + build_docker_image, + get_config_to_docker_build_context, +) from langgraph_cli.exec import Runner, subp_exec from langgraph_cli.progress import Progress from langgraph_cli.templates import TEMPLATE_HELP_STRING, create_new @@ -546,6 +550,14 @@ tests ) @OPT_API_VERSION @OPT_ENGINE_RUNTIME_MODE +@click.option( + "--install-command", + help="Custom install command to run from the build context root. If not provided, auto-detects based on package manager files.", +) +@click.option( + "--build-command", + help="Custom build command to run from the langgraph.json directory. If not provided, uses default build process.", +) @log_command def dockerfile( save_path: str, @@ -554,9 +566,24 @@ def dockerfile( base_image: str | None = None, api_version: str | None = None, engine_runtime_mode: str = "combined_queue_worker", + install_command: str | None = None, + build_command: str | None = None, ) -> None: from click import secho + if install_command and langgraph_cli.config.has_disallowed_build_command_content( + install_command + ): + raise click.UsageError( + "install_command contains disallowed characters or patterns." + ) + if build_command and langgraph_cli.config.has_disallowed_build_command_content( + build_command + ): + raise click.UsageError( + "build_command contains disallowed characters or patterns." + ) + save_path = pathlib.Path(save_path).absolute() secho(f"🔍 Validating configuration at path: {config}", fg="yellow") config_json = langgraph_cli.config.validate_config_file(config) @@ -569,12 +596,21 @@ def dockerfile( config_json, engine_runtime_mode=engine_runtime_mode ) + build_context = get_config_to_docker_build_context( + config_json, + install_command=install_command, + build_command=build_command, + ) + secho(f"📝 Generating Dockerfile at {save_path}", fg="yellow") dockerfile_content, additional_contexts = langgraph_cli.config.config_to_docker( config_path=config, config=config_json, base_image=effective_base_image, api_version=api_version, + install_command=install_command, + build_command=build_command, + build_context=build_context, ) with open(str(save_path), "w", encoding="utf-8") as f: f.write(dockerfile_content) diff --git a/libs/cli/langgraph_cli/docker.py b/libs/cli/langgraph_cli/docker.py index a376b5698..556456c2b 100644 --- a/libs/cli/langgraph_cli/docker.py +++ b/libs/cli/langgraph_cli/docker.py @@ -330,6 +330,20 @@ def compose( return compose_str +def get_config_to_docker_build_context( + config_json: dict, + install_command: str | None = None, + build_command: str | None = None, +) -> str | None: + """Return the build context passed to config_to_docker, if overridden.""" + is_js_project = config_json.get("node_version") and not config_json.get( + "python_version" + ) + if is_js_project and (build_command or install_command): + return str(pathlib.Path.cwd()) + return None + + def build_docker_image( runner, set: Callable[[str], None], @@ -365,16 +379,12 @@ def build_docker_image( "-t", tag, ] - # determine build context: use current directory for JS projects, config parent for Python - is_js_project = config_json.get("node_version") and not config_json.get( - "python_version" + config_to_docker_build_context = get_config_to_docker_build_context( + config_json, + install_command=install_command, + build_command=build_command, ) - # build/install commands only apply to JS projects for now - # without install/build command, JS projects will follow the old behavior - if is_js_project and (build_command or install_command): - build_context = str(pathlib.Path.cwd()) - else: - build_context = str(config.parent) + build_context = config_to_docker_build_context or str(config.parent) # Deep copy to avoid mutating the caller's config (config_to_docker # rewrites graph paths to container-internal paths in place). @@ -386,7 +396,7 @@ def build_docker_image( api_version=api_version, install_command=install_command, build_command=build_command, - build_context=build_context, + build_context=config_to_docker_build_context, ) # add additional_contexts if additional_contexts: diff --git a/libs/cli/tests/unit_tests/cli/test_cli.py b/libs/cli/tests/unit_tests/cli/test_cli.py index 8b3ae47c6..96c621a62 100644 --- a/libs/cli/tests/unit_tests/cli/test_cli.py +++ b/libs/cli/tests/unit_tests/cli/test_cli.py @@ -1088,6 +1088,82 @@ def test_dockerfile_command_with_api_version_nodejs() -> None: assert "FROM langchain/langgraphjs-api:0.2.74-node20" in dockerfile +def test_dockerfile_command_nodejs_monorepo_commands() -> None: + runner = CliRunner() + + with runner.isolated_filesystem(): + root = pathlib.Path.cwd() + config_dir = root / "apps" / "agent" + graph_path = config_dir / "src" / "agent.ts" + graph_path.parent.mkdir(parents=True) + graph_path.touch() + (root / "package.json").write_text( + json.dumps({"packageManager": "pnpm@10.0.0"}) + ) + (root / "pnpm-lock.yaml").touch() + (root / "pnpm-workspace.yaml").write_text('packages:\n - "apps/*"\n') + config_path = config_dir / "langgraph.json" + config_path.write_text( + json.dumps( + { + "node_version": "20", + "graphs": {"agent": "src/agent.ts:graph"}, + "image_distro": "wolfi", + } + ) + ) + save_path = root / "Dockerfile" + + result = runner.invoke( + cli, + [ + "dockerfile", + str(save_path), + "--config", + str(config_path), + "--install-command", + "pnpm install --frozen-lockfile", + "--build-command", + "pnpm run build", + ], + ) + + assert result.exit_code == 0, result.output + assert save_path.exists() + dockerfile = save_path.read_text() + container_root = f"/deps/{root.name}" + assert f"ADD . {container_root}" in dockerfile + assert f"WORKDIR {container_root}" in dockerfile + assert "RUN pnpm install --frozen-lockfile" in dockerfile + assert f"WORKDIR {container_root}/apps/agent" in dockerfile + assert "RUN pnpm run build" in dockerfile + + +def test_dockerfile_command_rejects_disallowed_nodejs_commands() -> None: + runner = CliRunner() + config_content = { + "node_version": "20", + "graphs": {"agent": "agent.js:graph"}, + } + + with temporary_config_folder(config_content) as temp_dir: + (temp_dir / "agent.js").touch() + for option in ("--install-command", "--build-command"): + result = runner.invoke( + cli, + [ + "dockerfile", + str(temp_dir / "Dockerfile"), + "--config", + str(temp_dir / "config.json"), + option, + "npm install; echo bad", + ], + ) + assert result.exit_code != 0 + assert "contains disallowed characters or patterns" in result.output + + def test_build_command_with_api_version() -> None: """Test the 'build' command with --api-version flag.""" runner = CliRunner()