diff --git a/.github/scripts/run_langgraph_cli_test.py b/.github/scripts/run_langgraph_cli_test.py index a6024778bf..478a215ab0 100644 --- a/.github/scripts/run_langgraph_cli_test.py +++ b/.github/scripts/run_langgraph_cli_test.py @@ -22,8 +22,7 @@ def test( # check docker available capabilities = langgraph_cli.docker.check_capabilities(runner) # open config - with open(config) as f: - config_json = langgraph_cli.config.validate_config(json.load(f)) + config_json = langgraph_cli.config.validate_config_file(config) set("Running...") args = [ diff --git a/libs/cli/langgraph_cli/cli.py b/libs/cli/langgraph_cli/cli.py index 2fe9ef8347..4de7568ea4 100644 --- a/libs/cli/langgraph_cli/cli.py +++ b/libs/cli/langgraph_cli/cli.py @@ -1,4 +1,3 @@ -import json import pathlib import shutil import sys @@ -354,8 +353,7 @@ def build( with Runner() as runner, Progress(message="Pulling...") as set: if shutil.which("docker") is None: raise click.UsageError("Docker not installed") from None - with open(config) as f: - config_json = langgraph_cli.config.validate_config(json.load(f)) + config_json = langgraph_cli.config.validate_config_file(config) _build( runner, set, config, config_json, base_image, pull, tag, docker_build_args ) @@ -435,8 +433,7 @@ def _get_docker_ignore_content() -> str: def dockerfile(save_path: str, config: pathlib.Path, add_docker_compose: bool) -> None: save_path = pathlib.Path(save_path).absolute() secho(f"🔍 Validating configuration at path: {config}", fg="yellow") - with open(config, encoding="utf-8") as f: - config_json = langgraph_cli.config.validate_config(json.load(f)) + config_json = langgraph_cli.config.validate_config_file(config) secho("✅ Configuration validated!", fg="green") secho(f"📝 Generating Dockerfile at {save_path}", fg="yellow") @@ -575,7 +572,7 @@ def dev( host: str, port: int, no_reload: bool, - config: str, + config: pathlib.Path, n_jobs_per_worker: Optional[int], no_browser: bool, debug_port: Optional[int], @@ -601,12 +598,9 @@ def dev( "Please ensure langgraph-cli is installed with the 'inmem' extra: pip install -U \"langgraph-cli[inmem]\"" ) from None - import json - - with open(config, encoding="utf-8") as f: - config_data = json.load(f) + config_json = langgraph_cli.config.validate_config_file(config) - graphs = config_data.get("graphs", {}) + graphs = config_json.get("graphs", {}) run_server( host, port, @@ -674,8 +668,7 @@ def prepare( debugger_base_url: Optional[str] = None, postgres_uri: Optional[str] = None, ): - with open(config_path) as f: - config = langgraph_cli.config.validate_config(json.load(f)) + config_json = langgraph_cli.config.validate_config_file(config_path) # pull latest images if pull: runner.run( @@ -683,9 +676,9 @@ def prepare( "docker", "pull", ( - f"langchain/langgraphjs-api:{config['node_version']}" - if config.get("node_version") - else f"langchain/langgraph-api:{config['python_version']}" + f"langchain/langgraphjs-api:{config_json['node_version']}" + if config_json.get("node_version") + else f"langchain/langgraph-api:{config_json['python_version']}" ), verbose=verbose, ) @@ -694,7 +687,7 @@ def prepare( args, stdin = prepare_args_and_stdin( capabilities=capabilities, config_path=config_path, - config=config, + config=config_json, docker_compose=docker_compose, port=port, watch=watch, diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index 549fbffbc5..95b72e0cda 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -6,6 +6,9 @@ import click +MIN_NODE_VERSION = "20" +MIN_PYTHON_VERSION = "3.11" + class Config(TypedDict): python_version: str @@ -17,9 +20,6 @@ class Config(TypedDict): env: Union[dict[str, str], str] -MIN_PYTHON_VERSION = "3.11" - - def _parse_version(version_str: str) -> tuple[int, int]: """Parse a version string into a tuple of (major, minor).""" try: @@ -29,6 +29,19 @@ def _parse_version(version_str: str) -> tuple[int, int]: raise click.UsageError(f"Invalid version format: {version_str}") from None +def _parse_node_version(version_str: str) -> int: + """Parse a Node.js version string into a major version number.""" + try: + if "." in version_str: + raise ValueError("Node.js version must be major version only") + return int(version_str) + except ValueError: + raise click.UsageError( + f"Invalid Node.js version format: {version_str}. " + "Use major version only (e.g., '20')." + ) from None + + def validate_config(config: Config) -> Config: config = ( { @@ -49,11 +62,17 @@ def validate_config(config: Config) -> Config: ) if config.get("node_version"): - if config["node_version"] not in ("20",): - raise click.UsageError( - f"Unsupported Node.js version: {config['node_version']}. " - "Currently only `node_version: \"20\"` is supported." - ) + node_version = config["node_version"] + try: + major = _parse_node_version(node_version) + min_major = _parse_node_version(MIN_NODE_VERSION) + if major < min_major: + raise click.UsageError( + f"Node.js version {node_version} is not supported. " + f"Minimum required version is {MIN_NODE_VERSION}." + ) + except ValueError as e: + raise click.UsageError(str(e)) from None if config.get("python_version"): pyversion = config["python_version"] @@ -85,6 +104,48 @@ def validate_config(config: Config) -> Config: return config +def validate_config_file(config_path: pathlib.Path) -> Config: + with open(config_path) as f: + config = json.load(f) + validated = validate_config(config) + # Enforce the package.json doesn't enforce an + # incompatible Node.js version + if validated.get("node_version"): + package_json_path = config_path.parent / "package.json" + if package_json_path.is_file(): + try: + with open(package_json_path) as f: + package_json = json.load(f) + if "engines" in package_json: + engines = package_json["engines"] + if any(engine != "node" for engine in engines.keys()): + raise click.UsageError( + "Only 'node' engine is supported in package.json engines." + f" Got engines: {list(engines.keys())}" + ) + if engines: + node_version = engines["node"] + try: + major = _parse_node_version(node_version) + min_major = _parse_node_version(MIN_NODE_VERSION) + if major < min_major: + raise click.UsageError( + f"Node.js version in package.json engines must be >= {MIN_NODE_VERSION} " + f"(major version only), got '{node_version}'. Minor/patch versions " + "(like '20.x.y') are not supported to prevent deployment issues " + "when new Node.js versions are released." + ) + except ValueError as e: + raise click.UsageError(str(e)) from None + + except json.JSONDecodeError: + raise click.UsageError( + "Invalid package.json found in langgraph " + f"config directory {package_json_path}: file is not valid JSON" + ) from None + return validated + + class LocalDeps(NamedTuple): pip_reqs: list[tuple[pathlib.Path, str]] real_pkgs: dict[pathlib.Path, str] diff --git a/libs/cli/pyproject.toml b/libs/cli/pyproject.toml index 217e3762e8..bc64904ffd 100644 --- a/libs/cli/pyproject.toml +++ b/libs/cli/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "langgraph-cli" -version = "0.1.55" +version = "0.1.56" description = "CLI for interacting with LangGraph API" authors = [] license = "MIT" diff --git a/libs/cli/tests/unit_tests/test_config.py b/libs/cli/tests/unit_tests/test_config.py index d12c660cd1..c1632a8b3b 100644 --- a/libs/cli/tests/unit_tests/test_config.py +++ b/libs/cli/tests/unit_tests/test_config.py @@ -1,10 +1,17 @@ +import json import os import pathlib +import tempfile import click import pytest -from langgraph_cli.config import config_to_compose, config_to_docker, validate_config +from langgraph_cli.config import ( + config_to_compose, + config_to_docker, + validate_config, + validate_config_file, +) from langgraph_cli.util import clean_empty_lines PATH_TO_CONFIG = pathlib.Path(__file__).parent / "test_config.json" @@ -81,6 +88,70 @@ def test_validate_config(): assert "Minimum required version" in str(exc_info.value) +def test_validate_config_file(): + with tempfile.TemporaryDirectory() as tmpdir: + tmpdir_path = pathlib.Path(tmpdir) + + config_path = tmpdir_path / "langgraph.json" + + node_config = {"node_version": "20", "graphs": {"agent": "./agent.js:graph"}} + with open(config_path, "w") as f: + json.dump(node_config, f) + + validate_config_file(config_path) + + package_json = {"name": "test", "engines": {"node": "20"}} + with open(tmpdir_path / "package.json", "w") as f: + json.dump(package_json, f) + validate_config_file(config_path) + + package_json["engines"]["node"] = "20.18" + with open(tmpdir_path / "package.json", "w") as f: + json.dump(package_json, f) + with pytest.raises(click.UsageError, match="Use major version only"): + validate_config_file(config_path) + + package_json["engines"] = {"node": "18"} + with open(tmpdir_path / "package.json", "w") as f: + json.dump(package_json, f) + with pytest.raises(click.UsageError, match="must be >= 20"): + validate_config_file(config_path) + + package_json["engines"] = {"node": "20", "deno": "1.0"} + with open(tmpdir_path / "package.json", "w") as f: + json.dump(package_json, f) + with pytest.raises(click.UsageError, match="Only 'node' engine is supported"): + validate_config_file(config_path) + + with open(tmpdir_path / "package.json", "w") as f: + f.write("{invalid json") + with pytest.raises(click.UsageError, match="Invalid package.json"): + validate_config_file(config_path) + + python_config = { + "python_version": "3.11", + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"}, + } + with open(config_path, "w") as f: + json.dump(python_config, f) + + validate_config_file(config_path) + + for package_content in [ + {"name": "test"}, + {"engines": {"node": "18"}}, + {"engines": {"node": "20", "deno": "1.0"}}, + "{invalid json", + ]: + with open(tmpdir_path / "package.json", "w") as f: + if isinstance(package_content, dict): + json.dump(package_content, f) + else: + f.write(package_content) + validate_config_file(config_path) + + # config_to_docker def test_config_to_docker_simple(): graphs = {"agent": "./agent.py:graph"}