diff --git a/libs/cli/GO_MIGRATION.md b/libs/cli/GO_MIGRATION.md new file mode 100644 index 000000000..1757b4dd1 --- /dev/null +++ b/libs/cli/GO_MIGRATION.md @@ -0,0 +1,374 @@ +# Go Migration Plan + +## Goal + +Move the full `langgraph` CLI implementation to Go while preserving existing +Python distribution and invocation flows. + +Users should continue to be able to run: + +- `langgraph ...` +- `uv run langgraph ...` +- `uvx langgraph ...` + +During phase 1, the Go path is gated behind a feature flag. The Python package +remains the public entrypoint and launcher. + +## Non-Goals + +This phase does not include: + +- JS migration +- `langsmith-cli` integration +- user-facing command renames +- intentional CLI behavior changes +- a long-lived dual implementation + +## Source Of Truth + +There will be one implementation of CLI behavior: + +- shared Go implementation lives in the `langgraph` repo +- standalone Go `langgraph` binary uses that implementation +- Python `langgraph-cli` package is a thin launcher around that binary +- legacy Python implementation exists only temporarily as fallback during rollout + +## Phase 1 Artifacts + +Phase 1 ships these artifacts: + +- shared Go package(s) in `langgraph` +- standalone `langgraph` Go binary +- Python wheel `langgraph-cli` that bundles the platform-specific Go binary +- Python launcher entrypoint that can route to legacy Python or Go + +JS is explicitly out of scope for phase 1. + +## User-Facing Command Scope + +Phase 1 scope is the whole `langgraph` CLI, not just deploy. + +Target command coverage: + +- `langgraph deploy ...` +- `langgraph build ...` +- `langgraph up ...` +- `langgraph dockerfile ...` +- `langgraph dev ...` +- `langgraph new ...` + +The goal is full parity with the current Python CLI command surface. + +## Compatibility Contract + +Behavior must not regress. + +Required parity: + +- exact JSON output where commands emit JSON +- exact or equivalent error semantics +- same exit codes +- same argument and flag behavior +- same generated artifacts for: + - Dockerfile output + - docker compose / inline compose output +- same API request semantics where mocked in tests + +Human-readable output should be same or better, but not worse. + +## Repo Ownership + +Shared implementation lives in the current `langgraph` repo. + +Reasons: + +- current CLI spec and tests already live here +- rollout is initially only for `langgraph-cli` +- command compatibility should be driven by existing behavior in this repo + +## Architecture + +Use process boundaries, not language FFI. + +Python should not call a Go shared library directly. Instead: + +- Python launcher locates bundled `langgraph` Go binary +- Python launcher `exec`s or subprocesses into the Go binary +- Go handles all command execution +- for `dev`, Go subprocesses back into Python + +This keeps the boundary simple and cross-platform. + +## Go Package Structure + +Recommended structure: + +- `pkg/cli/config` + - parse and validate `langgraph.json` + - normalize config model +- `pkg/cli/docker` + - docker capability detection + - compose generation + - Dockerfile/build plan generation +- `pkg/cli/deploy` + - deployment flows + - host backend client + - polling, logs, revision logic +- `pkg/cli/dev` + - `dev` command orchestration + - Python subprocess handoff +- `pkg/cli/cmds` + - command runner functions with typed options/results + - no Cobra-specific code here +- `cmd/langgraph` + - standalone Go binary wrapping shared packages + +Business logic should live in shared packages, not directly in CLI adapter code. + +## Python Wrapper Model + +The Python package remains installed as `langgraph-cli`, with entrypoint +`langgraph`. + +During migration, the wrapper decides whether to route to legacy Python or Go. + +Wrapper behavior: + +1. inspect feature flags +2. resolve Go binary path +3. if Go path is active, `exec` into Go binary +4. otherwise fall back to legacy Python implementation + +Long-term target: + +- remove fallback +- Python wrapper always launches bundled Go binary + +## Feature Flags + +Temporary rollout env vars: + +- `LANGGRAPH_USE_GO_CLI=1` + - route the Python wrapper to the Go binary instead of legacy Python +- `LANGGRAPH_GO_CLI_PATH=/path/to/langgraph` + - internal/dev/CI override for binary path resolution + - not intended as a long-term public interface +- `LANGGRAPH_CALLING_PYTHON=/path/to/python` + - set by the Python wrapper before invoking Go + - used by Go for `dev` + +`LANGGRAPH_GO_CLI_PATH` is mainly for local development and CI and can be +removed later. + +## `dev` Invocation Contract + +`dev` is the main tricky area. + +Design rule: + +- Go owns CLI parsing and routing +- Python owns the actual in-process local dev server runtime + +Flow for `uv run langgraph dev`: + +1. `uv` selects the Python interpreter/environment +2. Python wrapper starts +3. Python wrapper sets `LANGGRAPH_CALLING_PYTHON=sys.executable` +4. Python wrapper launches Go binary +5. Go receives `dev` +6. Go shells out to that exact Python interpreter for the actual Python runtime behavior + +This preserves the current selected Python environment. + +Go Python resolution order for `dev`: + +1. `LANGGRAPH_CALLING_PYTHON` +2. optional explicit override if added later +3. environment-derived interpreter / active venv +4. fallback detection +5. clear failure + +The critical constraint is: if the user entered through Python, `dev` should +use that exact Python when possible. + +## Why Not FFI + +Do not use: + +- cgo shared libs +- Python-Go FFI bindings +- embedded Python in Go +- RPC unless absolutely necessary + +Reasons: + +- packaging complexity +- cross-platform pain +- no advantage for a CLI architecture +- much worse release/debug story + +Process-level boundaries are the right choice here. + +## Packaging Constraints + +The Go binary should be bundled inside Python wheels. + +Preferred distribution model: + +- build platform-specific `langgraph-cli` wheels +- each wheel includes the matching `langgraph` Go binary +- Python launcher resolves and executes the bundled binary + +Do not rely on runtime download of the binary for normal operation. + +Support matrix target: + +- all OS/arch targets that are currently expected to be supported +- at minimum, align with the practical support matrix desired for the CLI, + using `orjson` support as a rough proxy if needed + +If a platform is unsupported, fail clearly rather than silently falling back +forever. + +## Release Constraints + +Phase 1 versioning applies to: + +- shared Go implementation +- standalone Go `langgraph` binary +- PyPI `langgraph-cli` wrapper + +They should stay on one version line. + +Constraint: + +- bundled Go binary version must exactly match the Python wrapper version for + the migrated surface + +The wrapper should detect obvious mismatch and fail clearly if it occurs. + +## Migration Strategy + +Use a big-bang hidden implementation change with gradual activation. + +Phase 1 rollout: + +1. implement full Go path behind `LANGGRAPH_USE_GO_CLI` +2. keep default behavior on legacy Python +3. run dual CI for legacy and Go-backed paths +4. dogfood with feature flag +5. flip default to Go +6. keep fallback briefly +7. remove fallback in about two weeks + +This is a big internal rewrite with gradual external activation. + +## CI Strategy + +Dual CI is required during migration. + +Run both variants: + +- legacy Python implementation +- Python wrapper -> Go binary implementation + +Required parity checks: + +- help output +- exit code +- stdout +- stderr +- generated Dockerfile output +- generated compose output +- mocked deployment API request semantics +- validation errors / usage errors + +Goal is not merely "both tests pass". Goal is "both implementations behave +identically enough to swap by default safely". + +## Parity Test Philosophy + +Use the current Python CLI tests as the behavioral spec. + +Priority test areas: + +- config validation +- compose/Dockerfile generation +- deployment flows +- error and prompt behavior +- command help / command surface + +Where practical, add golden comparisons so regressions are obvious. + +## Implementation Order Inside Phase 1 + +Even though rollout is one hidden phase, implementation should proceed in this +order: + +1. wrapper contract and env contract +2. Go command scaffolding and package boundaries +3. config + docker/build/compose logic +4. deploy flows +5. remaining commands +6. `dev` subprocess orchestration +7. parity hardening in CI + +This reduces risk because `dev` is the highest-uncertainty area. + +## Command Ownership Constraint + +All command behavior should live in Go once ported. + +Do not allow: + +- some flags parsed in Python and others in Go +- duplicated command logic across Python and Go +- separate behavior definitions for legacy and migrated commands + +The wrapper should be thin only. + +## Fallback Constraint + +Fallback is temporary, not a product feature. + +Policy: + +- use feature flag during migration +- flip default after parity confidence +- remove legacy Python implementation roughly two weeks later + +Do not normalize to permanent dual execution paths. + +## Documentation Constraint + +During migration, documentation should stay conservative: + +- existing Python install flow remains primary +- feature flag is acceptable for internal/dogfood docs +- avoid broad external messaging about the Go implementation until default is flipped + +## Open Issues To Track + +These are not blockers, but they need explicit implementation decisions: + +- exact bundled wheel layout for binaries +- exact list of supported OS/arch targets +- whether to expose a public `--python` override for `dev` +- whether some pretty output is allowed to improve while keeping parsed output stable + +## Summary + +Phase 1 plan: + +- move the entire `langgraph` CLI implementation into shared Go code in the + `langgraph` repo +- ship a standalone `langgraph` Go binary +- keep `langgraph-cli` on PyPI as a thin launcher that bundles and executes + that binary +- preserve `uv run` / `uvx` behavior +- handle `dev` by passing the calling Python path through the wrapper and + having Go subprocess back into Python +- gate everything behind `LANGGRAPH_USE_GO_CLI` +- run dual CI until parity is proven +- flip default +- remove legacy fallback quickly diff --git a/libs/cli/Makefile b/libs/cli/Makefile index 0de616fa7..197797dd8 100644 --- a/libs/cli/Makefile +++ b/libs/cli/Makefile @@ -1,15 +1,20 @@ .PHONY: test lint type format test-integration update-schema bump-version +.PHONY: test-go lint-go format-go ###################### # TESTING AND COVERAGE ###################### TEST?= "tests/unit_tests" -test: +GO_FILES=$(shell find cmd internal -type f -name '*.go' 2>/dev/null) +test: test-go uv run pytest $(TEST) test-integration: uv run pytest tests/integration_tests +test-go: + [ ! -f go.mod ] || go test ./... + ###################### # LINTING AND FORMATTING ###################### @@ -23,19 +28,25 @@ lint_package: PYTHON_FILES=langgraph_cli lint_tests: PYTHON_FILES=tests lint_tests: MYPY_CACHE=.mypy_cache_test -lint lint_diff lint_package lint_tests: +lint lint_diff lint_package lint_tests: lint-go uv run ruff check . [ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff [ "$(PYTHON_FILES)" = "" ] || uv run ruff check --select I $(PYTHON_FILES) [ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE) || uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE) +lint-go: + [ -z "$(GO_FILES)" ] || test -z "$$(gofmt -l $(GO_FILES))" + type: mkdir -p $(MYPY_CACHE) && uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE) -format format_diff: +format format_diff: format-go uv run ruff format $(PYTHON_FILES) uv run ruff check --select I --fix $(PYTHON_FILES) +format-go: + [ -z "$(GO_FILES)" ] || gofmt -w $(GO_FILES) + update-schema: uv run python generate_schema.py diff --git a/libs/cli/cmd/langgraph/main.go b/libs/cli/cmd/langgraph/main.go new file mode 100644 index 000000000..76916bbbe --- /dev/null +++ b/libs/cli/cmd/langgraph/main.go @@ -0,0 +1,11 @@ +package main + +import ( + "os" + + "github.com/langchain-ai/langgraph/libs/cli/internal/root" +) + +func main() { + os.Exit(root.Run(os.Args[1:], os.Stdout, os.Stderr)) +} diff --git a/libs/cli/go.mod b/libs/cli/go.mod new file mode 100644 index 000000000..1e5064908 --- /dev/null +++ b/libs/cli/go.mod @@ -0,0 +1,3 @@ +module github.com/langchain-ai/langgraph/libs/cli + +go 1.23.0 diff --git a/libs/cli/internal/config/config.go b/libs/cli/internal/config/config.go new file mode 100644 index 000000000..9b0f7cd95 --- /dev/null +++ b/libs/cli/internal/config/config.go @@ -0,0 +1,672 @@ +// Package config provides validation for langgraph.json configuration files. +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" +) + +const ( + MinNodeVersion = "20" + DefaultNodeVersion = "20" + MinPythonVersion = "3.11" + DefaultPythonVersion = "3.11" + DefaultImageDistro = "debian" +) + +var validDistros = []string{"debian", "wolfi", "bookworm"} + +var knownConfigKeys = map[string]bool{ + "python_version": true, + "node_version": true, + "api_version": true, + "base_image": true, + "image_distro": true, + "pip_config_file": true, + "pip_installer": true, + "source": true, + "dependencies": true, + "dockerfile_lines": true, + "graphs": true, + "env": true, + "store": true, + "auth": true, + "encryption": true, + "http": true, + "webhooks": true, + "checkpointer": true, + "ui": true, + "ui_config": true, + "keep_pkg_tools": true, + "_INTERNAL_docker_tag": true, + "project_root": true, + "package": true, +} + +var nodeExtensions = map[string]bool{ + ".ts": true, ".mts": true, ".cts": true, + ".js": true, ".mjs": true, ".cjs": true, +} + +// isNodeGraph checks whether a graph spec refers to a Node.js file. +func isNodeGraph(spec any) bool { + var filePath string + switch v := spec.(type) { + case string: + filePath = strings.SplitN(v, ":", 2)[0] + case map[string]any: + if p, _ := v["path"].(string); p != "" { + filePath = strings.SplitN(p, ":", 2)[0] + } + } + return nodeExtensions[filepath.Ext(filePath)] +} + +// getSourceKind extracts source.kind from a raw config. +func getSourceKind(raw map[string]any) string { + source, ok := raw["source"] + if !ok { + return "" + } + m, ok := source.(map[string]any) + if !ok { + return "" + } + kind, _ := m["kind"].(string) + return kind +} + +// getString returns the string value for key, or "" if missing/wrong type. +func getString(raw map[string]any, key string) string { + v, _ := raw[key].(string) + return v +} + +// parseVersion parses "3.11" or "0.8.1" into integer parts. +func parseVersion(s string) ([]int, error) { + s = strings.SplitN(s, "-", 2)[0] + parts := strings.Split(s, ".") + result := make([]int, len(parts)) + for i, p := range parts { + n, err := strconv.Atoi(p) + if err != nil { + return nil, fmt.Errorf("invalid version part: %s", p) + } + result[i] = n + } + return result, nil +} + +// versionLessThan returns true if a < b (component-wise). +func versionLessThan(a, b []int) bool { + for i := 0; i < len(a) && i < len(b); i++ { + if a[i] < b[i] { + return true + } + if a[i] > b[i] { + return false + } + } + return len(a) < len(b) +} + +// ValidateConfig validates a raw config map and returns a normalised copy. +// Errors match the Python CLI's click.UsageError messages exactly. +func ValidateConfig(raw map[string]any) (map[string]any, error) { + // --- detect graph types --- + graphs, _ := raw["graphs"].(map[string]any) + hasNode, hasPython := false, false + for _, spec := range graphs { + if isNodeGraph(spec) { + hasNode = true + } else { + hasPython = true + } + } + + // --- version defaults --- + nodeVersion := getString(raw, "node_version") + pythonVersion := getString(raw, "python_version") + + if hasNode && nodeVersion == "" { + nodeVersion = DefaultNodeVersion + } + if hasPython && pythonVersion == "" { + pythonVersion = DefaultPythonVersion + } + + imageDistro := getString(raw, "image_distro") + if imageDistro == "" { + imageDistro = DefaultImageDistro + } + + // --- mutual exclusion: _INTERNAL_docker_tag vs api_version --- + _, hasInternalTag := raw["_INTERNAL_docker_tag"] + _, hasAPIVersion := raw["api_version"] + if hasInternalTag && hasAPIVersion { + return nil, fmt.Errorf("Cannot specify both _INTERNAL_docker_tag and api_version.") + } + + // --- api_version format --- + if apiVersion := getString(raw, "api_version"); apiVersion != "" { + base := strings.SplitN(apiVersion, "-", 2)[0] + parts := strings.Split(base, ".") + if len(parts) > 3 { + return nil, fmt.Errorf("Version must be major or major.minor or major.minor.patch.") + } + for _, p := range parts { + if _, err := strconv.Atoi(p); err != nil { + return nil, fmt.Errorf( + "Invalid version format: %s.\n\n"+ + "Pin to a minor version, e.g.:\n"+ + " \"api_version\": \"0.8\"", apiVersion) + } + } + } + + // --- build result config with defaults --- + config := map[string]any{ + "node_version": nodeVersion, + "python_version": pythonVersion, + "pip_config_file": raw["pip_config_file"], + "pip_installer": "auto", + "source": raw["source"], + "base_image": raw["base_image"], + "image_distro": imageDistro, + "dependencies": raw["dependencies"], + "dockerfile_lines": raw["dockerfile_lines"], + "graphs": raw["graphs"], + "env": raw["env"], + "store": raw["store"], + "auth": raw["auth"], + "encryption": raw["encryption"], + "http": raw["http"], + "webhooks": raw["webhooks"], + "checkpointer": raw["checkpointer"], + "ui": raw["ui"], + "ui_config": raw["ui_config"], + "keep_pkg_tools": raw["keep_pkg_tools"], + } + if raw["pip_installer"] != nil { + config["pip_installer"] = raw["pip_installer"] + } + if hasInternalTag { + config["_INTERNAL_docker_tag"] = raw["_INTERNAL_docker_tag"] + } + if hasAPIVersion { + config["api_version"] = raw["api_version"] + } + + // Apply list defaults. + if config["dependencies"] == nil { + config["dependencies"] = []any{} + } + if config["dockerfile_lines"] == nil { + config["dockerfile_lines"] = []any{} + } + if config["graphs"] == nil { + config["graphs"] = map[string]any{} + } + if config["env"] == nil { + config["env"] = map[string]any{} + } + + // --- node_version validation --- + if nodeVersion != "" { + if strings.Contains(nodeVersion, ".") { + return nil, fmt.Errorf("Node.js version must be major version only") + } + major, err := strconv.Atoi(nodeVersion) + if err != nil { + return nil, fmt.Errorf( + "Invalid Node.js version format: %s. Use major version only (e.g., '20').", + nodeVersion) + } + minMajor, _ := strconv.Atoi(MinNodeVersion) + if major < minMajor { + return nil, fmt.Errorf( + "Node.js version %s is not supported. "+ + "Minimum required version is %s.\n\n"+ + "Set node_version to %s or higher:\n"+ + " \"node_version\": \"%s\"", + nodeVersion, MinNodeVersion, MinNodeVersion, MinNodeVersion) + } + } + + // --- pip_installer validation --- + if pi, ok := raw["pip_installer"].(string); ok { + switch pi { + case "auto", "pip", "uv": + // valid + default: + return nil, fmt.Errorf( + "Invalid pip_installer: '%s'. "+ + "Consider using uv-based source management instead:\n\n"+ + " \"source\": {\"kind\": \"uv\", \"root\": \"..\"}", + pi) + } + } + + // --- source validation --- + sourceKind := getSourceKind(raw) + if source := raw["source"]; source != nil { + if _, ok := source.(map[string]any); !ok { + return nil, fmt.Errorf( + "`source` must be an object, e.g.:\n" + + " \"source\": {\"kind\": \"uv\", \"root\": \"..\"}") + } + if sourceKind != "uv" { + return nil, fmt.Errorf( + "Invalid source.kind. The only supported value is 'uv':\n" + + " \"source\": {\"kind\": \"uv\", \"root\": \"..\"}") + } + } + + // --- python_version validation --- + if pythonVersion != "" { + base := strings.SplitN(pythonVersion, "-", 2)[0] + dotParts := strings.Split(base, ".") + allDigits := true + for _, p := range dotParts { + if _, err := strconv.Atoi(p); err != nil { + allDigits = false + break + } + } + if len(dotParts) != 2 || !allDigits { + fix := MinPythonVersion + if len(dotParts) >= 2 { + fix = dotParts[0] + "." + dotParts[1] + } + return nil, fmt.Errorf( + "Invalid Python version format: %s. "+ + "Use 'major.minor' format — patch version cannot be specified.\n\n"+ + " \"python_version\": \"%s\"", + pythonVersion, fix) + } + pyParsed, _ := parseVersion(pythonVersion) + minParsed, _ := parseVersion(MinPythonVersion) + if versionLessThan(pyParsed, minParsed) { + return nil, fmt.Errorf( + "Python version %s is not supported. "+ + "Minimum required version is %s.\n\n"+ + " \"python_version\": \"%s\"", + pythonVersion, MinPythonVersion, MinPythonVersion) + } + if strings.Contains(pythonVersion, "bullseye") { + return nil, fmt.Errorf( + "Bullseye images were deprecated in version 0.4.13. " + + "Please use 'bookworm' or 'debian' instead.") + } + + // dependencies required when not uv + deps, _ := config["dependencies"].([]any) + if sourceKind != "uv" && len(deps) == 0 { + return nil, fmt.Errorf( + "No dependencies found in config. " + + "Consider using uv-based source management:\n\n" + + " \"source\": {\"kind\": \"uv\", \"root\": \"..\"}") + } + } + + // --- graphs required --- + graphMap, _ := config["graphs"].(map[string]any) + if len(graphMap) == 0 { + return nil, fmt.Errorf( + "No graphs found in config. Add at least one graph, e.g.:\n" + + " \"graphs\": {\n" + + " \"agent\": \"./my_agent/graph.py:graph\"\n" + + " }") + } + + // --- image_distro validation --- + if imageDistro == "bullseye" { + return nil, fmt.Errorf( + "Bullseye images were deprecated in version 0.4.13. " + + "Please use 'bookworm' or 'debian' instead.") + } + validDistro := false + for _, d := range validDistros { + if imageDistro == d { + validDistro = true + break + } + } + if !validDistro { + quoted := make([]string, len(validDistros)) + for i, d := range validDistros { + quoted[i] = fmt.Sprintf("'%s'", d) + } + return nil, fmt.Errorf( + "Invalid image_distro: '%s'. "+ + "Must be one of: %s.\n\n"+ + " \"image_distro\": \"wolfi\" (recommended)", + imageDistro, strings.Join(quoted, ", ")) + } + + // --- uv source mode validation --- + if sourceKind == "uv" { + var errs []string + if pythonVersion == "" { + errs = append(errs, "source.kind 'uv' requires `python_version` — it is a Python-only deployment mode. Node.js-only graphs are not supported.") + } + + deps, _ := raw["dependencies"].([]any) + if deps != nil && len(deps) > 0 { + errs = append(errs, "Remove `dependencies` from your config. With `source.kind = \"uv\"`, all dependencies are read from your pyproject.toml and uv.lock instead.") + } + // Also check if dependencies key exists even if empty array. + if deps == nil { + if rawDeps, exists := raw["dependencies"]; exists && rawDeps != nil { + // dependencies key present but not an array — still flag it + if depsArr, ok := rawDeps.([]any); ok && len(depsArr) > 0 { + errs = append(errs, "Remove `dependencies` from your config. With `source.kind = \"uv\"`, all dependencies are read from your pyproject.toml and uv.lock instead.") + } + } + } + + sourceMap, _ := raw["source"].(map[string]any) + if root, exists := sourceMap["root"]; exists { + rootStr, ok := root.(string) + if !ok { + errs = append(errs, fmt.Sprintf("`source.root` must be a string, got %T.", root)) + } else if rootStr == "" { + errs = append(errs, "`source.root` must be a non-empty string. Use `\".\"`.") + } + } + + if pkg, exists := sourceMap["package"]; exists { + if pkg != nil { + pkgStr, ok := pkg.(string) + if !ok { + errs = append(errs, "`source.package` must be a non-empty string.") + } else if pkgStr == "" { + errs = append(errs, "`source.package` must be a non-empty string.") + } + } + } + + if len(errs) > 0 { + formatted := "" + for i, e := range errs { + formatted += fmt.Sprintf("\n %d. %s", i+1, e) + } + return nil, fmt.Errorf( + "source.kind 'uv' requires a different config shape than dependency-based installs:%s", + formatted) + } + } + + // --- legacy project_root / package --- + _, hasProjectRoot := raw["project_root"] + _, hasPackage := raw["package"] + if hasProjectRoot || hasPackage { + return nil, fmt.Errorf( + "Top-level `project_root` and `package` are no longer supported. " + + "Use `source.root` and `source.package` instead.") + } + + // --- auth path validation --- + if auth, ok := raw["auth"].(map[string]any); ok { + if authPath, _ := auth["path"].(string); authPath != "" { + if !strings.Contains(authPath, ":") { + return nil, fmt.Errorf( + "Invalid auth.path format: '%s'. "+ + "Must be in format './path/to/file.py:attribute_name'", + authPath) + } + } + } + + // --- encryption path validation --- + if enc, ok := raw["encryption"].(map[string]any); ok { + if encPath, _ := enc["path"].(string); encPath != "" { + if !strings.Contains(encPath, ":") { + return nil, fmt.Errorf( + "Invalid encryption.path format: '%s'. "+ + "Must be in format './path/to/file.py:attribute_name'", + encPath) + } + } + } + + // --- http.app path validation --- + if httpConf, ok := raw["http"].(map[string]any); ok { + if app, _ := httpConf["app"].(string); app != "" { + if !strings.Contains(app, ":") { + return nil, fmt.Errorf( + "Invalid http.app format: '%s'. "+ + "Must be in format './path/to/file.py:attribute_name'", + app) + } + } + } + + // --- keep_pkg_tools validation --- + if kpt := raw["keep_pkg_tools"]; kpt != nil { + validBuildTools := map[string]bool{"pip": true, "setuptools": true, "wheel": true} + switch v := kpt.(type) { + case bool: + // ok + case []any: + for _, item := range v { + tool, ok := item.(string) + if !ok || !validBuildTools[tool] { + return nil, fmt.Errorf( + "Invalid keep_pkg_tools: '%v'. "+ + "Must be one of 'pip', 'setuptools', 'wheel'.", + item) + } + } + default: + return nil, fmt.Errorf( + "Invalid keep_pkg_tools: '%v'. "+ + "Must be bool or list[str] (with values 'pip', 'setuptools', and/or 'wheel').", + kpt) + } + } + + return config, nil +} + +// ValidateConfigFile loads a config file, validates it, and returns the result. +func ValidateConfigFile(configPath string) (map[string]any, error) { + data, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("could not read config file: %w", err) + } + + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("Invalid JSON in %s: %s", configPath, err.Error()) + } + + validated, err := ValidateConfig(raw) + if err != nil { + return nil, err + } + + // Check package.json node version if node_version is set. + if nv, _ := validated["node_version"].(string); nv != "" { + dir := filepath.Dir(configPath) + pkgJSONPath := filepath.Join(dir, "package.json") + if info, statErr := os.Stat(pkgJSONPath); statErr == nil && !info.IsDir() { + if pkgErr := validatePackageJSON(pkgJSONPath); pkgErr != nil { + return nil, pkgErr + } + } + } + + return validated, nil +} + +func validatePackageJSON(path string) error { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + + var pkg map[string]any + if err := json.Unmarshal(data, &pkg); err != nil { + return fmt.Errorf("Invalid package.json: %s", err.Error()) + } + + enginesRaw, ok := pkg["engines"] + if !ok { + return nil + } + engines, ok := enginesRaw.(map[string]any) + if !ok { + return nil + } + + for k := range engines { + if k != "node" { + keys := make([]string, 0, len(engines)) + for ek := range engines { + keys = append(keys, ek) + } + return fmt.Errorf( + "Only 'node' engine is supported in package.json engines. Got engines: %v", + keys) + } + } + + if nodeVer, ok := engines["node"].(string); ok && nodeVer != "" { + if strings.Contains(nodeVer, ".") { + return fmt.Errorf( + "Node.js version in package.json engines must be >= %s "+ + "(major version only), got '%s'. "+ + "Minor/patch versions (like '20.x.y') are not supported to "+ + "prevent deployment issues when new Node.js versions are released.", + MinNodeVersion, nodeVer) + } + major, err := strconv.Atoi(nodeVer) + if err == nil { + minMajor, _ := strconv.Atoi(MinNodeVersion) + if major < minMajor { + return fmt.Errorf( + "Node.js version in package.json engines must be >= %s "+ + "(major version only), got '%s'. "+ + "Minor/patch versions (like '20.x.y') are not supported to "+ + "prevent deployment issues when new Node.js versions are released.", + MinNodeVersion, nodeVer) + } + } + } + + return nil +} + +// GetUnknownKeys returns warnings for unrecognised top-level keys. +func GetUnknownKeys(raw map[string]any) []string { + var unknown []string + for k := range raw { + if !knownConfigKeys[k] { + unknown = append(unknown, k) + } + } + sortStrings(unknown) + + var warnings []string + knownList := make([]string, 0, len(knownConfigKeys)) + for k := range knownConfigKeys { + knownList = append(knownList, k) + } + + for _, key := range unknown { + if close := closestMatch(key, knownList); close != "" { + warnings = append(warnings, fmt.Sprintf("Unknown key '%s' — did you mean '%s'?", key, close)) + } else { + warnings = append(warnings, fmt.Sprintf("Unknown key '%s' is not a recognized config field.", key)) + } + } + return warnings +} + +// closestMatch finds the best match for word among candidates using edit distance. +// Returns "" if no match is close enough (ratio >= 0.6). +func closestMatch(word string, candidates []string) string { + best := "" + bestRatio := 0.6 // minimum threshold + for _, c := range candidates { + ratio := similarity(word, c) + if ratio > bestRatio { + bestRatio = ratio + best = c + } + } + return best +} + +// similarity returns a ratio in [0,1] based on Levenshtein distance. +func similarity(a, b string) float64 { + maxLen := len(a) + if len(b) > maxLen { + maxLen = len(b) + } + if maxLen == 0 { + return 1.0 + } + dist := editDistance(a, b) + return 1.0 - float64(dist)/float64(maxLen) +} + +// editDistance computes Levenshtein distance between two strings. +func editDistance(a, b string) int { + la, lb := len(a), len(b) + if la == 0 { + return lb + } + if lb == 0 { + return la + } + + prev := make([]int, lb+1) + curr := make([]int, lb+1) + + for j := 0; j <= lb; j++ { + prev[j] = j + } + for i := 1; i <= la; i++ { + curr[0] = i + for j := 1; j <= lb; j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + ins := curr[j-1] + 1 + del := prev[j] + 1 + sub := prev[j-1] + cost + curr[j] = min3(ins, del, sub) + } + prev, curr = curr, prev + } + return prev[lb] +} + +func min3(a, b, c int) int { + if a < b { + if a < c { + return a + } + return c + } + if b < c { + return b + } + return c +} + +// sortStrings sorts a slice of strings in place (simple insertion sort, fine for small n). +func sortStrings(s []string) { + for i := 1; i < len(s); i++ { + for j := i; j > 0 && s[j] < s[j-1]; j-- { + s[j], s[j-1] = s[j-1], s[j] + } + } +} diff --git a/libs/cli/internal/config/config_test.go b/libs/cli/internal/config/config_test.go new file mode 100644 index 000000000..d19e0a218 --- /dev/null +++ b/libs/cli/internal/config/config_test.go @@ -0,0 +1,568 @@ +package config + +import ( + "strings" + "testing" +) + +// baseConfig returns a minimal valid config map. Tests should copy and modify it. +func baseConfig() map[string]any { + return map[string]any{ + "dependencies": []any{"langchain"}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + } +} + +// copyMap returns a shallow copy of m. +func copyMap(m map[string]any) map[string]any { + out := make(map[string]any, len(m)) + for k, v := range m { + out[k] = v + } + return out +} + +// mustSucceed is a test helper that fails if err is non-nil. +func mustSucceed(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatalf("expected success but got error: %v", err) + } +} + +// mustFail is a test helper that fails if err is nil. +func mustFail(t *testing.T, err error) { + t.Helper() + if err == nil { + t.Fatal("expected error but got nil") + } +} + +// mustContain checks that err is non-nil and its message contains substr. +func mustContain(t *testing.T, err error, substr string) { + t.Helper() + if err == nil { + t.Fatalf("expected error containing %q but got nil", substr) + } + if !strings.Contains(err.Error(), substr) { + t.Fatalf("expected error to contain %q, got: %s", substr, err.Error()) + } +} + +func TestValidateConfigValid(t *testing.T) { + t.Run("minimal config", func(t *testing.T) { + raw := baseConfig() + result, err := ValidateConfig(raw) + mustSucceed(t, err) + + if pv, _ := result["python_version"].(string); pv != "3.11" { + t.Fatalf("expected python_version '3.11', got %q", pv) + } + if id, _ := result["image_distro"].(string); id != "debian" { + t.Fatalf("expected image_distro 'debian', got %q", id) + } + }) + + t.Run("full config with all optional fields", func(t *testing.T) { + raw := map[string]any{ + "python_version": "3.12", + "image_distro": "wolfi", + "pip_installer": "uv", + "dependencies": []any{"langchain", "langgraph"}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "env": map[string]any{"FOO": "bar"}, + "dockerfile_lines": []any{"RUN apt-get update"}, + "auth": map[string]any{"path": "./auth.py:handler"}, + "encryption": map[string]any{"path": "./enc.py:enc"}, + "http": map[string]any{"app": "./app.py:app"}, + "keep_pkg_tools": true, + "api_version": "0.8", + } + _, err := ValidateConfig(raw) + mustSucceed(t, err) + }) +} + +func TestValidateConfigPythonVersion(t *testing.T) { + validVersions := []string{"3.11", "3.12", "3.13"} + for _, v := range validVersions { + t.Run("valid "+v, func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["python_version"] = v + _, err := ValidateConfig(raw) + mustSucceed(t, err) + }) + } + + t.Run("valid 3.12-slim suffix stripped", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["python_version"] = "3.12-slim" + _, err := ValidateConfig(raw) + mustSucceed(t, err) + }) + + tooOld := []string{"3.10", "3.9"} + for _, v := range tooOld { + t.Run("too old "+v, func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["python_version"] = v + _, err := ValidateConfig(raw) + mustContain(t, err, "Minimum required version") + }) + } + + badFormat := []struct { + version string + }{ + {"3.11.0"}, + {"3"}, + {"abc.def"}, + } + for _, tc := range badFormat { + t.Run("bad format "+tc.version, func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["python_version"] = tc.version + _, err := ValidateConfig(raw) + mustContain(t, err, "Invalid Python version format") + }) + } +} + +func TestValidateConfigNodeVersion(t *testing.T) { + // Need a node graph to trigger node_version validation + nodeBase := func() map[string]any { + return map[string]any{ + "dependencies": []any{"langchain"}, + "graphs": map[string]any{"agent": "./agent.py:graph"}, + } + } + + t.Run("valid 20", func(t *testing.T) { + raw := nodeBase() + raw["node_version"] = "20" + _, err := ValidateConfig(raw) + mustSucceed(t, err) + }) + + t.Run("valid 22", func(t *testing.T) { + raw := nodeBase() + raw["node_version"] = "22" + _, err := ValidateConfig(raw) + mustSucceed(t, err) + }) + + t.Run("too old 18", func(t *testing.T) { + raw := nodeBase() + raw["node_version"] = "18" + _, err := ValidateConfig(raw) + mustContain(t, err, "Minimum required version is 20") + }) + + t.Run("minor version 20.1", func(t *testing.T) { + raw := nodeBase() + raw["node_version"] = "20.1" + _, err := ValidateConfig(raw) + mustContain(t, err, "major version only") + }) +} + +func TestValidateConfigGraphs(t *testing.T) { + t.Run("empty graphs", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["graphs"] = map[string]any{} + _, err := ValidateConfig(raw) + mustContain(t, err, "No graphs found") + }) + + t.Run("missing graphs key", func(t *testing.T) { + raw := map[string]any{ + "dependencies": []any{"langchain"}, + } + _, err := ValidateConfig(raw) + mustContain(t, err, "No graphs found") + }) +} + +func TestValidateConfigImageDistro(t *testing.T) { + validDistroTests := []string{"debian", "wolfi", "bookworm"} + for _, d := range validDistroTests { + t.Run("valid "+d, func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["image_distro"] = d + _, err := ValidateConfig(raw) + mustSucceed(t, err) + }) + } + + t.Run("bullseye deprecated", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["image_distro"] = "bullseye" + _, err := ValidateConfig(raw) + mustContain(t, err, "deprecated") + }) + + t.Run("invalid ubuntu", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["image_distro"] = "ubuntu" + _, err := ValidateConfig(raw) + mustContain(t, err, "Invalid image_distro") + }) + + t.Run("invalid alpine", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["image_distro"] = "alpine" + _, err := ValidateConfig(raw) + mustContain(t, err, "Invalid image_distro") + }) + + t.Run("default is debian", func(t *testing.T) { + raw := baseConfig() + // no image_distro key + result, err := ValidateConfig(raw) + mustSucceed(t, err) + if id, _ := result["image_distro"].(string); id != "debian" { + t.Fatalf("expected default image_distro 'debian', got %q", id) + } + }) +} + +func TestValidateConfigPipInstaller(t *testing.T) { + valid := []string{"auto", "pip", "uv"} + for _, pi := range valid { + t.Run("valid "+pi, func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["pip_installer"] = pi + _, err := ValidateConfig(raw) + mustSucceed(t, err) + }) + } + + invalid := []string{"conda", "uv_lock"} + for _, pi := range invalid { + t.Run("invalid "+pi, func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["pip_installer"] = pi + _, err := ValidateConfig(raw) + mustContain(t, err, "Invalid pip_installer") + }) + } +} + +func TestValidateConfigSource(t *testing.T) { + t.Run("valid uv source with root", func(t *testing.T) { + raw := map[string]any{ + "python_version": "3.12", + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "source": map[string]any{"kind": "uv", "root": "../.."}, + } + _, err := ValidateConfig(raw) + mustSucceed(t, err) + }) + + t.Run("invalid source kind poetry", func(t *testing.T) { + raw := map[string]any{ + "python_version": "3.12", + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "source": map[string]any{"kind": "poetry"}, + } + _, err := ValidateConfig(raw) + mustContain(t, err, "Invalid source.kind") + }) + + t.Run("source as string not object", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["source"] = "not-an-object" + _, err := ValidateConfig(raw) + mustContain(t, err, "`source` must be an object") + }) + + t.Run("uv source with dependencies", func(t *testing.T) { + raw := map[string]any{ + "python_version": "3.12", + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "source": map[string]any{"kind": "uv", "root": ".."}, + "dependencies": []any{"langchain"}, + } + _, err := ValidateConfig(raw) + mustContain(t, err, "Remove `dependencies`") + }) + + t.Run("uv source with root as number", func(t *testing.T) { + raw := map[string]any{ + "python_version": "3.12", + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "source": map[string]any{"kind": "uv", "root": 123}, + } + _, err := ValidateConfig(raw) + mustContain(t, err, "source.root` must be a string") + }) + + t.Run("uv source with package as number", func(t *testing.T) { + raw := map[string]any{ + "python_version": "3.12", + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "source": map[string]any{"kind": "uv", "root": "..", "package": 123}, + } + _, err := ValidateConfig(raw) + mustContain(t, err, "source.package` must be a non-empty string") + }) +} + +func TestValidateConfigAPIVersion(t *testing.T) { + t.Run("valid 0.8", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["api_version"] = "0.8" + _, err := ValidateConfig(raw) + mustSucceed(t, err) + }) + + t.Run("valid 0.8.1", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["api_version"] = "0.8.1" + _, err := ValidateConfig(raw) + mustSucceed(t, err) + }) + + t.Run("invalid abc", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["api_version"] = "abc" + _, err := ValidateConfig(raw) + mustContain(t, err, "Invalid version format") + }) + + t.Run("invalid 1.2.3.4 too many parts", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["api_version"] = "1.2.3.4" + _, err := ValidateConfig(raw) + mustContain(t, err, "major or major.minor") + }) +} + +func TestValidateConfigMutualExclusion(t *testing.T) { + t.Run("both _INTERNAL_docker_tag and api_version", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["_INTERNAL_docker_tag"] = "some-tag" + raw["api_version"] = "0.8" + _, err := ValidateConfig(raw) + mustContain(t, err, "Cannot specify both") + }) +} + +func TestValidateConfigAuthPath(t *testing.T) { + t.Run("valid auth path with colon", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["auth"] = map[string]any{"path": "./auth.py:handler"} + _, err := ValidateConfig(raw) + mustSucceed(t, err) + }) + + t.Run("invalid auth path without colon", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["auth"] = map[string]any{"path": "../../examples/my_app.py"} + _, err := ValidateConfig(raw) + mustContain(t, err, "Invalid auth.path format") + }) +} + +func TestValidateConfigEncryptionPath(t *testing.T) { + t.Run("valid encryption path with colon", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["encryption"] = map[string]any{"path": "./enc.py:enc"} + _, err := ValidateConfig(raw) + mustSucceed(t, err) + }) + + t.Run("invalid encryption path without colon", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["encryption"] = map[string]any{"path": "./enc.py"} + _, err := ValidateConfig(raw) + mustContain(t, err, "Invalid encryption.path format") + }) +} + +func TestValidateConfigHTTPApp(t *testing.T) { + t.Run("valid http app with colon", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["http"] = map[string]any{"app": "./app.py:app"} + _, err := ValidateConfig(raw) + mustSucceed(t, err) + }) + + t.Run("invalid http app without colon", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["http"] = map[string]any{"app": "./app.py"} + _, err := ValidateConfig(raw) + mustContain(t, err, "Invalid http.app format") + }) +} + +func TestValidateConfigKeepPkgTools(t *testing.T) { + t.Run("bool true", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["keep_pkg_tools"] = true + _, err := ValidateConfig(raw) + mustSucceed(t, err) + }) + + t.Run("valid list", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["keep_pkg_tools"] = []any{"pip", "wheel"} + _, err := ValidateConfig(raw) + mustSucceed(t, err) + }) + + t.Run("invalid list item", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["keep_pkg_tools"] = []any{"invalid"} + _, err := ValidateConfig(raw) + mustContain(t, err, "Invalid keep_pkg_tools") + }) + + t.Run("invalid string type", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["keep_pkg_tools"] = "string" + _, err := ValidateConfig(raw) + mustContain(t, err, "Invalid keep_pkg_tools") + }) +} + +func TestValidateConfigLegacyKeys(t *testing.T) { + t.Run("project_root legacy", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["project_root"] = ".." + _, err := ValidateConfig(raw) + mustContain(t, err, "no longer supported") + }) + + t.Run("package legacy", func(t *testing.T) { + raw := copyMap(baseConfig()) + raw["package"] = "foo" + _, err := ValidateConfig(raw) + mustContain(t, err, "no longer supported") + }) +} + +func TestValidateConfigNodeGraphDetection(t *testing.T) { + t.Run("ts extension auto-sets node_version", func(t *testing.T) { + raw := map[string]any{ + "dependencies": []any{"langchain"}, + "graphs": map[string]any{"agent": "./agent.py:graph", "bot": "./bot.ts:bot"}, + } + result, err := ValidateConfig(raw) + mustSucceed(t, err) + if nv, _ := result["node_version"].(string); nv != "20" { + t.Fatalf("expected node_version '20', got %q", nv) + } + }) + + t.Run("js extension auto-sets node_version", func(t *testing.T) { + raw := map[string]any{ + "dependencies": []any{"langchain"}, + "graphs": map[string]any{"agent": "./agent.py:graph", "bot": "./bot.js:bot"}, + } + result, err := ValidateConfig(raw) + mustSucceed(t, err) + if nv, _ := result["node_version"].(string); nv != "20" { + t.Fatalf("expected node_version '20', got %q", nv) + } + }) + + t.Run("py extension does not set node_version", func(t *testing.T) { + raw := baseConfig() + result, err := ValidateConfig(raw) + mustSucceed(t, err) + if nv, _ := result["node_version"].(string); nv != "" { + t.Fatalf("expected node_version '', got %q", nv) + } + }) +} + +func TestGetUnknownKeys(t *testing.T) { + t.Run("typo suggests correction", func(t *testing.T) { + raw := map[string]any{ + "grpahs": map[string]any{"agent": "./agent.py:graph"}, + "dependencies": []any{"langchain"}, + } + warnings := GetUnknownKeys(raw) + found := false + for _, w := range warnings { + if strings.Contains(w, "did you mean 'graphs'") { + found = true + break + } + } + if !found { + t.Fatalf("expected warning suggesting 'graphs', got: %v", warnings) + } + }) + + t.Run("totally unknown key", func(t *testing.T) { + raw := map[string]any{ + "totally_unknown": "value", + "graphs": map[string]any{"agent": "./agent.py:graph"}, + "dependencies": []any{"langchain"}, + } + warnings := GetUnknownKeys(raw) + found := false + for _, w := range warnings { + if strings.Contains(w, "not a recognized config field") { + found = true + break + } + } + if !found { + t.Fatalf("expected 'not a recognized config field' warning, got: %v", warnings) + } + }) + + t.Run("only known keys gives no warnings", func(t *testing.T) { + raw := baseConfig() + warnings := GetUnknownKeys(raw) + if len(warnings) != 0 { + t.Fatalf("expected no warnings, got: %v", warnings) + } + }) +} + +func TestValidateConfigMultiplatform(t *testing.T) { + t.Run("only JS graphs", func(t *testing.T) { + raw := map[string]any{ + "graphs": map[string]any{"bot": "./bot.ts:bot"}, + } + result, err := ValidateConfig(raw) + mustSucceed(t, err) + if nv, _ := result["node_version"].(string); nv != "20" { + t.Fatalf("expected node_version '20', got %q", nv) + } + if pv, _ := result["python_version"].(string); pv != "" { + t.Fatalf("expected python_version '', got %q", pv) + } + }) + + t.Run("only Python graphs", func(t *testing.T) { + raw := baseConfig() + result, err := ValidateConfig(raw) + mustSucceed(t, err) + if pv, _ := result["python_version"].(string); pv != "3.11" { + t.Fatalf("expected python_version '3.11', got %q", pv) + } + if nv, _ := result["node_version"].(string); nv != "" { + t.Fatalf("expected node_version '', got %q", nv) + } + }) + + t.Run("mixed graphs", func(t *testing.T) { + raw := map[string]any{ + "dependencies": []any{"langchain"}, + "graphs": map[string]any{"agent": "./agent.py:graph", "bot": "./bot.ts:bot"}, + } + result, err := ValidateConfig(raw) + mustSucceed(t, err) + if pv, _ := result["python_version"].(string); pv != "3.11" { + t.Fatalf("expected python_version '3.11', got %q", pv) + } + if nv, _ := result["node_version"].(string); nv != "20" { + t.Fatalf("expected node_version '20', got %q", nv) + } + }) +} diff --git a/libs/cli/internal/config/docker.go b/libs/cli/internal/config/docker.go new file mode 100644 index 000000000..8f5ea9fd6 --- /dev/null +++ b/libs/cli/internal/config/docker.go @@ -0,0 +1,1491 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +// PipReq represents a (hostPath, containerPath) pair for a requirements.txt. +type PipReq struct { + HostPath string + ContainerPath string +} + +// RealPkg represents a real Python package (has pyproject.toml or setup.py). +type RealPkg struct { + RelPath string + ContainerName string +} + +// FauxPkg represents a directory without packaging metadata (faux package). +type FauxPkg struct { + RelPath string + ContainerPath string +} + +// LocalDeps holds all resolved local dependency information for Dockerfile generation. +type LocalDeps struct { + PipReqs []PipReq + RealPkgs map[string]RealPkg // hostPath -> RealPkg + FauxPkgs map[string]FauxPkg // hostPath -> FauxPkg + WorkingDir string // "" if not set + AdditionalContexts []string // resolved paths needing extra build contexts +} + +// DockerOpts groups options for ConfigToDocker. +type DockerOpts struct { + BaseImage string + APIVersion string + InstallCommand string + BuildCommand string + BuildContext string + EscapeVariables bool +} + +// ComposeOpts groups options for ConfigToCompose. +type ComposeOpts struct { + BaseImage string + APIVersion string + Image string + Watch bool + EngineRuntimeMode string +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +var buildTools = []string{"pip", "setuptools", "wheel"} + +var reservedPackageNames = map[string]bool{ + "src": true, + "langgraph-api": true, + "langgraph_api": true, + "langgraph": true, + "langchain-core": true, + "langchain_core": true, + "pydantic": true, + "orjson": true, + "fastapi": true, + "uvicorn": true, + "psycopg": true, + "httpx": true, + "langsmith": true, +} + +var semverPattern = regexp.MustCompile(`:(\d+(?:\.\d+)?(?:\.\d+)?)(?:-|$)`) + +// --------------------------------------------------------------------------- +// 1. DefaultBaseImage +// --------------------------------------------------------------------------- + +// DefaultBaseImage returns the base Docker image for a config. +func DefaultBaseImage(config map[string]any, engineRuntimeMode string) string { + if bi, _ := config["base_image"].(string); bi != "" { + return bi + } + nv, _ := config["node_version"].(string) + pv, _ := config["python_version"].(string) + if nv != "" && pv == "" { + return "langchain/langgraphjs-api" + } + if engineRuntimeMode == "distributed" { + return "langchain/langgraph-executor" + } + return "langchain/langgraph-api" +} + +// --------------------------------------------------------------------------- +// 2. DockerTag +// --------------------------------------------------------------------------- + +// DockerTag computes the full image:tag string for a config. +func DockerTag(config map[string]any, baseImage, apiVersion string) string { + if apiVersion == "" { + apiVersion, _ = config["api_version"].(string) + } + if baseImage == "" { + baseImage = DefaultBaseImage(config, "combined_queue_worker") + } + + imageDistro, _ := config["image_distro"].(string) + distroTag := "" + if imageDistro != "" && imageDistro != DefaultImageDistro { + distroTag = "-" + imageDistro + } + + if tag, _ := config["_INTERNAL_docker_tag"].(string); tag != "" { + return baseImage + ":" + tag + } + + nv, _ := config["node_version"].(string) + pv, _ := config["python_version"].(string) + + var language, version string + if nv != "" && pv == "" { + language = "node" + version = nv + } else { + language = "py" + version = pv + } + + versionDistroTag := version + distroTag + + if apiVersion != "" { + fullTag := apiVersion + "-" + language + versionDistroTag + return baseImage + ":" + fullTag + } + if strings.Contains(baseImage, "/langgraph-server") && !strings.Contains(baseImage, versionDistroTag) { + return baseImage + "-" + language + versionDistroTag + } + return baseImage + ":" + versionDistroTag +} + +// --------------------------------------------------------------------------- +// 4. AssembleLocalDeps +// --------------------------------------------------------------------------- + +// AssembleLocalDeps inspects the config's dependencies list and classifies +// each local (dot-prefixed) dependency as a real package, faux package, etc. +func AssembleLocalDeps(configPath string, config map[string]any) (*LocalDeps, error) { + configPath, err := filepath.Abs(configPath) + if err != nil { + return nil, err + } + configDir := filepath.Dir(configPath) + + reserved := make(map[string]bool) + for k, v := range reservedPackageNames { + reserved[k] = v + } + + checkReserved := func(name, ref string) error { + if reserved[name] { + return fmt.Errorf( + "Package name '%s' used in local dep '%s' is reserved. "+ + "Rename the directory.", name, ref) + } + reserved[name] = true + return nil + } + + counter := map[string]int{} + var pipReqs []PipReq + realPkgs := map[string]RealPkg{} + fauxPkgs := map[string]FauxPkg{} + workingDir := "" + var additionalContexts []string + + deps := configSlice(config, "dependencies") + for _, depAny := range deps { + localDep, ok := depAny.(string) + if !ok || !strings.HasPrefix(localDep, ".") { + continue + } + + resolved, err := filepath.Abs(filepath.Join(configDir, localDep)) + if err != nil { + return nil, err + } + + info, err := os.Stat(resolved) + if err != nil { + return nil, fmt.Errorf("Could not find local dependency: %s", resolved) + } + if !info.IsDir() { + return nil, fmt.Errorf("Local dependency must be a directory: %s", resolved) + } + + // Check if resolved is same as configDir or a child + if resolved != configDir { + // Check if configDir is a parent of resolved + rel, relErr := filepath.Rel(configDir, resolved) + if relErr != nil || strings.HasPrefix(rel, "..") { + additionalContexts = append(additionalContexts, resolved) + } + } + + entries, err := os.ReadDir(resolved) + if err != nil { + return nil, err + } + fileNames := make(map[string]bool) + for _, e := range entries { + fileNames[e.Name()] = true + } + + if fileNames["pyproject.toml"] || fileNames["setup.py"] { + // Real package + containerName := filepath.Base(resolved) + if counter[containerName] > 0 { + containerName = fmt.Sprintf("%s_%d", containerName, counter[containerName]) + } + counter[containerName]++ + + realPkgs[resolved] = RealPkg{RelPath: localDep, ContainerName: containerName} + if localDep == "." { + workingDir = "/deps/" + containerName + } + } else { + baseName := filepath.Base(resolved) + var containerPath string + + if fileNames["__init__.py"] { + // Flat layout + if strings.Contains(baseName, "-") { + return nil, fmt.Errorf( + "Package name '%s' contains a hyphen. "+ + "Rename the directory to use it as flat-layout package.", + baseName) + } + if err := checkReserved(baseName, localDep); err != nil { + return nil, err + } + containerPath = fmt.Sprintf("/deps/outer-%s/%s", baseName, baseName) + } else { + // Src layout + containerPath = fmt.Sprintf("/deps/outer-%s/src", baseName) + for _, entry := range entries { + if !entry.IsDir() || entry.Name() == "__pycache__" || strings.HasPrefix(entry.Name(), ".") { + continue + } + subDir := filepath.Join(resolved, entry.Name()) + subEntries, subErr := os.ReadDir(subDir) + if subErr != nil { + continue // permission error etc. + } + for _, sf := range subEntries { + if strings.HasSuffix(sf.Name(), ".py") { + if err := checkReserved(entry.Name(), localDep); err != nil { + return nil, err + } + break + } + } + } + } + + fauxPkgs[resolved] = FauxPkg{RelPath: localDep, ContainerPath: containerPath} + if localDep == "." { + workingDir = containerPath + } + + if fileNames["requirements.txt"] { + rfile := filepath.Join(resolved, "requirements.txt") + pipReqs = append(pipReqs, PipReq{ + HostPath: rfile, + ContainerPath: containerPath + "/requirements.txt", + }) + } + } + } + + return &LocalDeps{ + PipReqs: pipReqs, + RealPkgs: realPkgs, + FauxPkgs: fauxPkgs, + WorkingDir: workingDir, + AdditionalContexts: additionalContexts, + }, nil +} + +// --------------------------------------------------------------------------- +// 5. UpdateGraphPaths +// --------------------------------------------------------------------------- + +// UpdateGraphPaths remaps each graph's import path to the correct in-container path. +func UpdateGraphPaths(configPath string, config map[string]any, deps *LocalDeps) error { + configPath, _ = filepath.Abs(configPath) + configDir := filepath.Dir(configPath) + + graphs, _ := config["graphs"].(map[string]any) + for graphID, data := range graphs { + var importStr string + switch v := data.(type) { + case string: + importStr = v + case map[string]any: + p, ok := v["path"].(string) + if !ok || p == "" { + return fmt.Errorf( + "Graph '%s' must contain a 'path' key if it is a dictionary.", + graphID) + } + importStr = p + default: + return fmt.Errorf( + "Graph '%s' must be a string or a dictionary with a 'path' key.", + graphID) + } + + parts := strings.SplitN(importStr, ":", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return fmt.Errorf( + "Import string \"%s\" must be in format \":\".", + importStr) + } + moduleStr := parts[0] + attrStr := parts[1] + + if strings.Contains(moduleStr, "/") || strings.Contains(moduleStr, "\\") { + resolved, err := filepath.Abs(filepath.Join(configDir, moduleStr)) + if err != nil { + return err + } + info, statErr := os.Stat(resolved) + if statErr != nil { + return fmt.Errorf("Could not find local module: %s", resolved) + } + if info.IsDir() { + return fmt.Errorf("Local module must be a file: %s", resolved) + } + + found := false + // Check real packages + for pkgPath, pkg := range deps.RealPkgs { + rel, relErr := filepath.Rel(pkgPath, resolved) + if relErr == nil && !strings.HasPrefix(rel, "..") { + containerPath := "/deps/" + pkg.ContainerName + "/" + filepath.ToSlash(rel) + moduleStr = containerPath + found = true + break + } + } + + if !found { + // Check faux packages + for fauxPath, faux := range deps.FauxPkgs { + rel, relErr := filepath.Rel(fauxPath, resolved) + if relErr == nil && !strings.HasPrefix(rel, "..") { + moduleStr = faux.ContainerPath + "/" + filepath.ToSlash(rel) + found = true + break + } + } + } + + if !found { + return fmt.Errorf( + "Module '%s' not found in 'dependencies' list. "+ + "Add its containing package to 'dependencies' list.", + importStr) + } + + // Update config + switch data.(type) { + case map[string]any: + data.(map[string]any)["path"] = moduleStr + ":" + attrStr + default: + graphs[graphID] = moduleStr + ":" + attrStr + } + } + } + return nil +} + +// --------------------------------------------------------------------------- +// 6. UpdateConfigPaths +// --------------------------------------------------------------------------- + +// UpdateConfigPaths rewrites auth, encryption, checkpointer, and http paths +// to point to the correct location inside the Docker container. +func UpdateConfigPaths(configPath string, config map[string]any, deps *LocalDeps) error { + configPath, _ = filepath.Abs(configPath) + configDir := filepath.Dir(configPath) + + if err := updateModulePath(configDir, config, deps, "auth", "path", "Auth file"); err != nil { + return err + } + if err := updateModulePath(configDir, config, deps, "encryption", "path", "Encryption file"); err != nil { + return err + } + if err := updateModulePath(configDir, config, deps, "checkpointer", "path", "Checkpointer file"); err != nil { + return err + } + if err := updateHTTPAppPath(configDir, config, deps); err != nil { + return err + } + return nil +} + +// updateModulePath is a helper for auth.path, encryption.path, checkpointer.path. +func updateModulePath(configDir string, config map[string]any, deps *LocalDeps, section, key, label string) error { + sectionMap, ok := config[section].(map[string]any) + if !ok || sectionMap == nil { + return nil + } + pathStr, _ := sectionMap[key].(string) + if pathStr == "" { + return nil + } + + parts := strings.SplitN(pathStr, ":", 2) + if len(parts) != 2 { + return nil // already validated elsewhere + } + moduleStr := parts[0] + attrStr := parts[1] + + if !strings.HasPrefix(moduleStr, ".") { + return nil // absolute path or module import + } + + resolved, err := filepath.Abs(filepath.Join(configDir, moduleStr)) + if err != nil { + return err + } + info, statErr := os.Stat(resolved) + if statErr != nil { + return fmt.Errorf("%s not found: %s (from %s)", label, resolved, pathStr) + } + if info.IsDir() { + return fmt.Errorf("%s path must be a file: %s", label, resolved) + } + + // Check faux packages first (higher priority) + for fauxPath, faux := range deps.FauxPkgs { + rel, relErr := filepath.Rel(fauxPath, resolved) + if relErr == nil && !strings.HasPrefix(rel, "..") { + sectionMap[key] = faux.ContainerPath + "/" + filepath.ToSlash(rel) + ":" + attrStr + return nil + } + } + + // Check real packages + for realPath, pkg := range deps.RealPkgs { + rel, relErr := filepath.Rel(realPath, resolved) + if relErr == nil && !strings.HasPrefix(rel, "..") { + sectionMap[key] = "/deps/" + filepath.Base(realPath) + "/" + filepath.ToSlash(rel) + ":" + attrStr + _ = pkg // use the real_path base, matching Python + return nil + } + } + + depsJSON, _ := json.Marshal(config["dependencies"]) + return fmt.Errorf( + "%s '%s' not covered by dependencies.\n"+ + "Add its parent directory to the 'dependencies' array in your config.\n"+ + "Current dependencies: %s", + label, resolved, string(depsJSON)) +} + +// updateHTTPAppPath handles http.app path remapping. +func updateHTTPAppPath(configDir string, config map[string]any, deps *LocalDeps) error { + httpConf, ok := config["http"].(map[string]any) + if !ok || httpConf == nil { + return nil + } + appStr, _ := httpConf["app"].(string) + if appStr == "" { + return nil + } + + parts := strings.SplitN(appStr, ":", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return fmt.Errorf( + "Import string \"%s\" must be in format \":\".", + appStr) + } + moduleStr := parts[0] + attrStr := parts[1] + + if !strings.Contains(moduleStr, "/") && !strings.Contains(moduleStr, "\\") { + return nil // not a file path + } + + resolved, err := filepath.Abs(filepath.Join(configDir, moduleStr)) + if err != nil { + return err + } + info, statErr := os.Stat(resolved) + if statErr != nil { + return fmt.Errorf("Could not find HTTP app module: %s", resolved) + } + if info.IsDir() { + return fmt.Errorf("HTTP app module must be a file: %s", resolved) + } + + // Check real packages + for pkgPath, pkg := range deps.RealPkgs { + rel, relErr := filepath.Rel(pkgPath, resolved) + if relErr == nil && !strings.HasPrefix(rel, "..") { + containerPath := "/deps/" + pkg.ContainerName + "/" + filepath.ToSlash(rel) + httpConf["app"] = containerPath + ":" + attrStr + return nil + } + } + + // Check faux packages + for fauxPath, faux := range deps.FauxPkgs { + rel, relErr := filepath.Rel(fauxPath, resolved) + if relErr == nil && !strings.HasPrefix(rel, "..") { + httpConf["app"] = faux.ContainerPath + "/" + filepath.ToSlash(rel) + ":" + attrStr + return nil + } + } + + return fmt.Errorf( + "HTTP app module '%s' not found in 'dependencies' list. "+ + "Add its containing package to 'dependencies' list.", + appStr) +} + +// --------------------------------------------------------------------------- +// 7. BuildRuntimeEnvVars +// --------------------------------------------------------------------------- + +// BuildRuntimeEnvVars generates ENV lines for the Dockerfile from config sections. +func BuildRuntimeEnvVars(config map[string]any) []string { + var envVars []string + + envSections := []struct { + key string + envVar string + }{ + {"store", "LANGGRAPH_STORE"}, + {"auth", "LANGGRAPH_AUTH"}, + {"encryption", "LANGGRAPH_ENCRYPTION"}, + {"http", "LANGGRAPH_HTTP"}, + {"webhooks", "LANGGRAPH_WEBHOOKS"}, + {"checkpointer", "LANGGRAPH_CHECKPOINTER"}, + {"ui", "LANGGRAPH_UI"}, + {"ui_config", "LANGGRAPH_UI_CONFIG"}, + } + + for _, s := range envSections { + if val := config[s.key]; val != nil { + j, _ := json.Marshal(val) + envVars = append(envVars, fmt.Sprintf("ENV %s='%s'", s.envVar, string(j))) + } + } + + graphsJSON, _ := json.Marshal(config["graphs"]) + envVars = append(envVars, fmt.Sprintf("ENV LANGSERVE_GRAPHS='%s'", string(graphsJSON))) + return envVars +} + +// --------------------------------------------------------------------------- +// 8. ImageSupportsUV +// --------------------------------------------------------------------------- + +// ImageSupportsUV returns true if the base image supports the uv pip installer. +func ImageSupportsUV(baseImage string) bool { + if baseImage == "langchain/langgraph-trial" { + return false + } + match := semverPattern.FindStringSubmatch(baseImage) + if match == nil { + return true + } + versionStr := match[1] + parts := strings.Split(versionStr, ".") + version := make([]int, len(parts)) + for i, p := range parts { + fmt.Sscanf(p, "%d", &version[i]) + } + + minUV := []int{0, 2, 47} + return !versionSliceLessThan(version, minUV) +} + +// versionSliceLessThan compares two version slices. +func versionSliceLessThan(a, b []int) bool { + for i := 0; i < len(a) && i < len(b); i++ { + if a[i] < b[i] { + return true + } + if a[i] > b[i] { + return false + } + } + return len(a) < len(b) +} + +// --------------------------------------------------------------------------- +// 9. GetBuildToolsToUninstall +// --------------------------------------------------------------------------- + +// GetBuildToolsToUninstall returns the list of build tools that should be +// removed from the final image. +func GetBuildToolsToUninstall(config map[string]any) ([]string, error) { + kpt := config["keep_pkg_tools"] + if kpt == nil { + return []string{"pip", "setuptools", "wheel"}, nil + } + // Check for boolean false (falsy) + if b, ok := kpt.(bool); ok { + if b { + return nil, nil + } + return []string{"pip", "setuptools", "wheel"}, nil + } + + // Check for list + if arr, ok := kpt.([]any); ok { + keepSet := map[string]bool{} + for _, item := range arr { + tool, ok := item.(string) + if !ok { + return nil, fmt.Errorf( + "Invalid build tool to uninstall: %v. Expected one of %v", + item, buildTools) + } + valid := false + for _, bt := range buildTools { + if tool == bt { + valid = true + break + } + } + if !valid { + return nil, fmt.Errorf( + "Invalid build tool to uninstall: %s. Expected one of %v", + tool, buildTools) + } + keepSet[tool] = true + } + var result []string + for _, bt := range buildTools { + if !keepSet[bt] { + result = append(result, bt) + } + } + sort.Strings(result) + return result, nil + } + + return nil, fmt.Errorf( + "Invalid value for keep_pkg_tools: %v."+ + " Expected True or a list containing any of %v.", + kpt, buildTools) +} + +// --------------------------------------------------------------------------- +// 10. GetPipCleanupLines +// --------------------------------------------------------------------------- + +// GetPipCleanupLines generates the RUN commands for pip cleanup in the Dockerfile. +func GetPipCleanupLines(installCmd string, toUninstall []string, pipInstaller string) string { + var commands []string + commands = append(commands, fmt.Sprintf(`# -- Ensure user deps didn't inadvertently overwrite langgraph-api +RUN mkdir -p /api/langgraph_api /api/langgraph_runtime /api/langgraph_license && \ +touch /api/langgraph_api/__init__.py /api/langgraph_runtime/__init__.py /api/langgraph_license/__init__.py +RUN PYTHONDONTWRITEBYTECODE=1 %s --no-cache-dir --no-deps -e /api +# -- End of ensuring user deps didn't inadvertently overwrite langgraph-api -- +# -- Removing build deps from the final image ~<:===~~~ --`, installCmd)) + + if len(toUninstall) > 0 { + // Validate + for _, pack := range toUninstall { + valid := false + for _, bt := range buildTools { + if pack == bt { + valid = true + break + } + } + if !valid { + // This matches the Python ValueError + panic(fmt.Sprintf("Invalid build tool: %s; must be one of %s", + pack, strings.Join(buildTools, ", "))) + } + } + + sorted := make([]string, len(toUninstall)) + copy(sorted, toUninstall) + sort.Strings(sorted) + packsStr := strings.Join(sorted, " ") + + commands = append(commands, fmt.Sprintf("RUN pip uninstall -y %s", packsStr)) + + // Remove from /usr/local/lib + var localRm []string + for _, pack := range toUninstall { + localRm = append(localRm, fmt.Sprintf("/usr/local/lib/python*/site-packages/%s*", pack)) + } + localRmStr := strings.Join(localRm, " ") + hasPip := false + for _, p := range toUninstall { + if p == "pip" { + hasPip = true + break + } + } + if hasPip { + localRmStr += ` && find /usr/local/bin -name "pip*" -delete || true` + } + commands = append(commands, fmt.Sprintf("RUN rm -rf %s", localRmStr)) + + // Remove from /usr/lib (wolfi) + var wolfiRm []string + for _, pack := range toUninstall { + wolfiRm = append(wolfiRm, fmt.Sprintf("/usr/lib/python*/site-packages/%s*", pack)) + } + wolfiRmStr := strings.Join(wolfiRm, " ") + if hasPip { + wolfiRmStr += ` && find /usr/bin -name "pip*" -delete || true` + } + commands = append(commands, fmt.Sprintf("RUN rm -rf %s", wolfiRmStr)) + + if pipInstaller == "uv" { + commands = append(commands, fmt.Sprintf( + "RUN uv pip uninstall --system %s && rm /usr/bin/uv /usr/bin/uvx", packsStr)) + } + } else { + if pipInstaller == "uv" { + commands = append(commands, + "RUN rm /usr/bin/uv /usr/bin/uvx\n# -- End of build deps removal --") + } + } + + return strings.Join(commands, "\n") +} + +// --------------------------------------------------------------------------- +// 11. PythonConfigToDocker +// --------------------------------------------------------------------------- + +// PythonConfigToDocker generates a Dockerfile and additional build contexts +// for a Python-based LangGraph configuration. +func PythonConfigToDocker( + configPath string, + config map[string]any, + baseImage string, + apiVersion string, + escapeVariables bool, +) (string, map[string]string, error) { + sourceKind := getSourceKind(config) + if sourceKind == "uv" { + return "", nil, fmt.Errorf("UV lock mode not yet supported in Go CLI") + } + + buildToolsToUninstall, err := GetBuildToolsToUninstall(config) + if err != nil { + return "", nil, err + } + + pipInstaller, _ := config["pip_installer"].(string) + if pipInstaller == "" { + pipInstaller = "auto" + } + if pipInstaller == "auto" { + if ImageSupportsUV(baseImage) { + pipInstaller = "uv" + } else { + pipInstaller = "pip" + } + } + + var installCmd string + switch pipInstaller { + case "uv": + installCmd = "uv pip install --system" + case "pip": + installCmd = "pip install" + default: + return "", nil, fmt.Errorf("Invalid pip_installer: %s", pipInstaller) + } + + localReqsPipInstall, globalReqsPipInstall, pipConfigFileStr := buildPythonInstallCommands(config, installCmd) + + // Collect PyPI dependencies (non-local) + deps := configSlice(config, "dependencies") + var pypiDeps []string + for _, d := range deps { + s, ok := d.(string) + if ok && !strings.HasPrefix(s, ".") { + pypiDeps = append(pypiDeps, s) + } + } + + configPathAbs, _ := filepath.Abs(configPath) + configDir := filepath.Dir(configPathAbs) + + localDeps, err := AssembleLocalDeps(configPath, config) + if err != nil { + return "", nil, err + } + + if err := UpdateGraphPaths(configPath, config, localDeps); err != nil { + return "", nil, err + } + if err := UpdateConfigPaths(configPath, config, localDeps); err != nil { + return "", nil, err + } + + // PyPI install line + pipPkgsStr := "" + if len(pypiDeps) > 0 { + pipPkgsStr = fmt.Sprintf("RUN %s %s", localReqsPipInstall, strings.Join(pypiDeps, " ")) + } + + // Requirements.txt install + pipReqsStr := "" + if len(localDeps.PipReqs) > 0 { + var addLines []string + for _, req := range localDeps.PipReqs { + isAdditional := false + reqParent := filepath.Dir(req.HostPath) + for _, ac := range localDeps.AdditionalContexts { + if reqParent == ac { + isAdditional = true + break + } + } + if isAdditional { + addLines = append(addLines, + fmt.Sprintf("COPY --from=outer-%s requirements.txt %s", + filepath.Base(req.HostPath), req.ContainerPath)) + } else { + relPath, _ := filepath.Rel(configDir, req.HostPath) + addLines = append(addLines, + fmt.Sprintf("ADD %s %s", filepath.ToSlash(relPath), req.ContainerPath)) + } + } + var reqArgs []string + for _, req := range localDeps.PipReqs { + reqArgs = append(reqArgs, "-r "+req.ContainerPath) + } + pipReqsStr = fmt.Sprintf("# -- Installing local requirements --\n%s\nRUN %s %s\n# -- End of local requirements install --", + strings.Join(addLines, "\n"), + localReqsPipInstall, + strings.Join(reqArgs, " ")) + } + + // Faux packages + var fauxParts []string + for fullpath, faux := range localDeps.FauxPkgs { + baseName := filepath.Base(fullpath) + isAdditional := false + for _, ac := range localDeps.AdditionalContexts { + if fullpath == ac { + isAdditional = true + break + } + } + + var addLine string + if isAdditional { + addLine = fmt.Sprintf("# -- Adding non-package dependency %s --\nCOPY --from=outer-%s . %s", + baseName, baseName, faux.ContainerPath) + } else { + addLine = fmt.Sprintf("# -- Adding non-package dependency %s --\nADD %s %s", + baseName, faux.RelPath, faux.ContainerPath) + } + + pyprojectPath := fmt.Sprintf("/deps/outer-%s/pyproject.toml", baseName) + // Shell-quote: the Python code uses shlex.quote which wraps in single quotes + quotedPath := shellQuote(pyprojectPath) + + part := fmt.Sprintf(`%s +RUN set -ex && \ + for line in '[project]' \ + 'name = "%s"' \ + 'version = "0.1"' \ + '[tool.setuptools.package-data]' \ + '"*" = ["**/*"]' \ + '[build-system]' \ + 'requires = ["setuptools>=61"]' \ + 'build-backend = "setuptools.build_meta"'; do \ + echo "$line" >> %s; \ + done +# -- End of non-package dependency %s --`, addLine, baseName, quotedPath, baseName) + fauxParts = append(fauxParts, part) + } + fauxPkgsStr := strings.Join(fauxParts, "\n\n") + + // Real packages + var localParts []string + for fullpath, pkg := range localDeps.RealPkgs { + isAdditional := false + for _, ac := range localDeps.AdditionalContexts { + if fullpath == ac { + isAdditional = true + break + } + } + + if isAdditional { + localParts = append(localParts, fmt.Sprintf( + "# -- Adding local package %s --\nCOPY --from=%s . /deps/%s\n# -- End of local package %s --", + pkg.RelPath, pkg.ContainerName, pkg.ContainerName, pkg.RelPath)) + } else { + localParts = append(localParts, fmt.Sprintf( + "# -- Adding local package %s --\nADD %s /deps/%s\n# -- End of local package %s --", + pkg.RelPath, pkg.RelPath, pkg.ContainerName, pkg.RelPath)) + } + } + localPkgsStr := strings.Join(localParts, "\n") + + // Additional contexts + additionalContexts := map[string]string{} + additionalContextNames := map[string]string{} // path -> name + usedContextNames := map[string]bool{} + + registerAdditionalContext := func(path, preferredName string) string { + if name, ok := additionalContextNames[path]; ok { + return name + } + name := preferredName + suffix := 1 + for usedContextNames[name] { + name = fmt.Sprintf("%s_%d", preferredName, suffix) + suffix++ + } + usedContextNames[name] = true + additionalContextNames[path] = name + additionalContexts[name] = path + return name + } + + for _, p := range localDeps.AdditionalContexts { + if pkg, ok := localDeps.RealPkgs[p]; ok { + registerAdditionalContext(p, pkg.ContainerName) + } else if _, ok := localDeps.FauxPkgs[p]; ok { + registerAdditionalContext(p, "outer-"+filepath.Base(p)) + } else { + return "", nil, fmt.Errorf("Unknown additional context: %s", p) + } + } + + // Install node string + nv, _ := config["node_version"].(string) + installNodeStr := "" + if (config["ui"] != nil || nv != "") && localDeps.WorkingDir != "" { + installNodeStr = "RUN /storage/install-node.sh" + } + + // Combine install steps + installSteps := []string{installNodeStr, pipConfigFileStr, pipPkgsStr, pipReqsStr, localPkgsStr, fauxPkgsStr} + var filteredSteps []string + for _, s := range installSteps { + if s != "" { + filteredSteps = append(filteredSteps, s) + } + } + installs := strings.Join(filteredSteps, "\n\n") + + envVars := BuildRuntimeEnvVars(config) + + // JS install + jsInstStr := "" + if (config["ui"] != nil || nv != "") && localDeps.WorkingDir != "" { + nodeVer := nv + if nodeVer == "" { + nodeVer = DefaultNodeVersion + } + jsInstStr = strings.Join([]string{ + "# -- Installing JS dependencies --", + fmt.Sprintf("ENV NODE_VERSION=%s", nodeVer), + fmt.Sprintf("WORKDIR %s", localDeps.WorkingDir), + fmt.Sprintf("RUN %s && tsx /api/langgraph_api/js/build.mts", GetNodePMInstallCmd(configDir)), + "# -- End of JS dependencies install --", + }, "\n") + } + + imageStr := DockerTag(config, baseImage, apiVersion) + + // Build Dockerfile + var dockerFileContents []string + + if len(additionalContexts) > 0 { + dockerFileContents = append(dockerFileContents, "# syntax=docker/dockerfile:1.4", "") + } + + depVName := "$dep" + if escapeVariables { + depVName = "$$dep" + } + + dockerfileLines := configSlice(config, "dockerfile_lines") + var dfLines []string + for _, l := range dockerfileLines { + if s, ok := l.(string); ok { + dfLines = append(dfLines, s) + } + } + + localDepsInstallStr := fmt.Sprintf(`RUN for dep in /deps/*; do \ + echo "Installing %s"; \ + if [ -d "%s" ]; then \ + echo "Installing %s"; \ + (cd "%s" && %s -e .); \ + fi; \ + done`, depVName, depVName, depVName, depVName, globalReqsPipInstall) + + dockerFileContents = append(dockerFileContents, + fmt.Sprintf("FROM %s", imageStr), + "", + strings.Join(dfLines, "\n"), + "", + installs, + "", + "# -- Installing all local dependencies --", + localDepsInstallStr, + "# -- End of local dependencies install --", + strings.Join(envVars, "\n"), + "", + jsInstStr, + "", + GetPipCleanupLines(installCmd, buildToolsToUninstall, pipInstaller), + "", + ) + + if localDeps.WorkingDir != "" { + dockerFileContents = append(dockerFileContents, fmt.Sprintf("WORKDIR %s", localDeps.WorkingDir)) + } else { + dockerFileContents = append(dockerFileContents, "") + } + + return strings.Join(dockerFileContents, "\n"), additionalContexts, nil +} + +// --------------------------------------------------------------------------- +// 12. NodeConfigToDocker +// --------------------------------------------------------------------------- + +// NodeConfigToDocker generates a Dockerfile for a Node.js-based LangGraph configuration. +func NodeConfigToDocker( + configPath string, + config map[string]any, + baseImage string, + apiVersion string, + installCommand string, + buildCommand string, + buildContext string, +) (string, map[string]string, error) { + configPathAbs, _ := filepath.Abs(configPath) + configDir := filepath.Dir(configPathAbs) + + var installRoot string + if buildContext != "" { + installRoot, _ = filepath.Abs(buildContext) + } else { + installRoot = configDir + } + + installCmd := installCommand + if installCmd == "" { + installCmd = GetNodePMInstallCmd(installRoot) + } + + var fauxPath string + var containerRoot string + if buildContext != "" { + relWorkdir := calculateRelativeWorkdir(configPathAbs, buildContext) + containerName := filepath.Base(buildContext) + containerRoot = "/deps/" + containerName + if relWorkdir != "" { + fauxPath = containerRoot + "/" + relWorkdir + } else { + fauxPath = containerRoot + } + } else { + fauxPath = "/deps/" + filepath.Base(configDir) + } + + imageStr := DockerTag(config, baseImage, apiVersion) + envVars := BuildRuntimeEnvVars(config) + + var installWorkdir string + var installStep string + var buildStep string + + if buildContext != "" { + installWorkdir = containerRoot + installStep = "RUN " + installCmd + if buildCommand != "" { + buildStep = "RUN " + buildCommand + } else { + buildStep = `RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts` + } + } else { + installWorkdir = fauxPath + installStep = "RUN " + installCmd + buildStep = `RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not found, skipping") || tsx /api/langgraph_api/js/build.mts` + } + + buildWorkdir := fauxPath + + addDest := fauxPath + if buildContext != "" { + addDest = containerRoot + } + + dockerfileLines := configSlice(config, "dockerfile_lines") + var dfLines []string + for _, l := range dockerfileLines { + if s, ok := l.(string); ok { + dfLines = append(dfLines, s) + } + } + + dockerFileContents := []string{ + fmt.Sprintf("FROM %s", imageStr), + "", + strings.Join(dfLines, "\n"), + "", + fmt.Sprintf("ADD . %s", addDest), + "", + fmt.Sprintf("WORKDIR %s", installWorkdir), + "", + installStep, + "", + strings.Join(envVars, "\n"), + "", + fmt.Sprintf("WORKDIR %s", buildWorkdir), + "", + buildStep, + } + + return strings.Join(dockerFileContents, "\n"), map[string]string{}, nil +} + +// calculateRelativeWorkdir computes the relative path from build context to config dir. +func calculateRelativeWorkdir(configPath, buildContext string) string { + configDir, _ := filepath.Abs(filepath.Dir(configPath)) + buildContextPath, _ := filepath.Abs(buildContext) + + rel, err := filepath.Rel(buildContextPath, configDir) + if err != nil || strings.HasPrefix(rel, "..") { + // The Python version raises ValueError here + return "" + } + if rel == "." { + return "" + } + return filepath.ToSlash(rel) +} + +// --------------------------------------------------------------------------- +// 13. ConfigToDocker +// --------------------------------------------------------------------------- + +// ConfigToDocker routes to NodeConfigToDocker or PythonConfigToDocker based on config. +func ConfigToDocker(configPath string, config map[string]any, opts DockerOpts) (string, map[string]string, error) { + baseImage := opts.BaseImage + if baseImage == "" { + baseImage = DefaultBaseImage(config, "combined_queue_worker") + } + + nv, _ := config["node_version"].(string) + pv, _ := config["python_version"].(string) + + if nv != "" && pv == "" { + return NodeConfigToDocker( + configPath, config, baseImage, opts.APIVersion, + opts.InstallCommand, opts.BuildCommand, opts.BuildContext, + ) + } + + return PythonConfigToDocker( + configPath, config, baseImage, opts.APIVersion, opts.EscapeVariables, + ) +} + +// --------------------------------------------------------------------------- +// 14. ConfigToCompose +// --------------------------------------------------------------------------- + +// ConfigToCompose generates the compose override section. +func ConfigToCompose(configPath string, config map[string]any, opts ComposeOpts) (string, error) { + baseImage := opts.BaseImage + if baseImage == "" { + baseImage = DefaultBaseImage(config, "combined_queue_worker") + } + + // Build env vars string + envVarsStr := "" + if envMap, ok := config["env"].(map[string]any); ok { + var lines []string + // Sort keys for deterministic output + keys := make([]string, 0, len(envMap)) + for k := range envMap { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + lines = append(lines, fmt.Sprintf(" %s: \"%v\"", k, envMap[k])) + } + envVarsStr = strings.Join(lines, "\n") + } + + envFileStr := "" + if envStr, ok := config["env"].(string); ok { + envFileStr = "env_file: " + envStr + } + + watchStr := "" + if opts.Watch { + deps := configSlice(config, "dependencies") + if len(deps) == 0 { + deps = []any{"."} + } + watchPaths := []string{filepath.Base(configPath)} + for _, d := range deps { + if s, ok := d.(string); ok && strings.HasPrefix(s, ".") { + watchPaths = append(watchPaths, s) + } + } + var watchActions []string + for _, path := range watchPaths { + watchActions = append(watchActions, + fmt.Sprintf(" - path: %s\n action: rebuild", path)) + } + watchStr = fmt.Sprintf("\n develop:\n watch:\n%s\n", + strings.Join(watchActions, "\n")) + } + + if opts.Image != "" { + return fmt.Sprintf("\n%s\n %s\n %s\n", + indent(envVarsStr, " "), + envFileStr, + watchStr), nil + } + + // Deep copy config for potential distributed mode + var configSnapshot map[string]any + engineRuntimeMode := opts.EngineRuntimeMode + if engineRuntimeMode == "" { + engineRuntimeMode = "combined_queue_worker" + } + if engineRuntimeMode == "distributed" { + configSnapshot = deepCopyConfig(config) + } + + dockerfile, additionalContexts, err := ConfigToDocker(configPath, config, DockerOpts{ + BaseImage: baseImage, + APIVersion: opts.APIVersion, + EscapeVariables: true, + }) + if err != nil { + return "", err + } + + additionalContextsStr := "" + if len(additionalContexts) > 0 { + var lines []string + // Sort for deterministic output + keys := make([]string, 0, len(additionalContexts)) + for k := range additionalContexts { + keys = append(keys, k) + } + sort.Strings(keys) + for _, name := range keys { + lines = append(lines, fmt.Sprintf(" - %s: %s", name, additionalContexts[name])) + } + additionalContextsStr = fmt.Sprintf("\n additional_contexts:\n%s", + strings.Join(lines, "\n")) + } + + result := fmt.Sprintf("\n%s\n %s\n pull_policy: build\n build:\n context: .%s\n dockerfile_inline: |\n%s\n %s\n", + indent(envVarsStr, " "), + envFileStr, + additionalContextsStr, + indent(dockerfile, " "), + watchStr) + + if engineRuntimeMode == "distributed" { + executorBaseImage := DefaultBaseImage(configSnapshot, "distributed") + executorDockerfile, executorAdditionalContexts, err := ConfigToDocker( + configPath, configSnapshot, DockerOpts{ + BaseImage: executorBaseImage, + APIVersion: opts.APIVersion, + EscapeVariables: true, + }) + if err != nil { + return "", err + } + + executorAdditionalContextsStr := "" + if len(executorAdditionalContexts) > 0 { + var lines []string + keys := make([]string, 0, len(executorAdditionalContexts)) + for k := range executorAdditionalContexts { + keys = append(keys, k) + } + sort.Strings(keys) + for _, name := range keys { + lines = append(lines, fmt.Sprintf(" - %s: %s", name, executorAdditionalContexts[name])) + } + executorAdditionalContextsStr = fmt.Sprintf("\n additional_contexts:\n%s", + strings.Join(lines, "\n")) + } + + postgresURI := "postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable" + result += fmt.Sprintf(` langgraph-orchestrator: + image: langchain/langgraph-orchestrator-licensed:latest + depends_on: + langgraph-api: + condition: service_healthy + langgraph-postgres: + condition: service_healthy + environment: + DATABASE_URI: %s + EXECUTOR_TARGET: langgraph-executor:8188 + %s + langgraph-executor: + depends_on: + langgraph-postgres: + condition: service_healthy + langgraph-api: + condition: service_healthy + entrypoint: ["sh", "/storage/executor_entrypoint.sh"] + environment: + DATABASE_URI: %s + REDIS_URI: redis://langgraph-redis:6379 + EXECUTOR_GRPC_PORT: "8188" + ENGINE_GRPC_ADDRESS: "langgraph-orchestrator:50054" + LSD_GRPC_SERVER_ADDRESS: "localhost:50050" + LANGGRAPH_HTTP: "" + %s + pull_policy: build + build: + context: .%s + dockerfile_inline: | +%s +`, postgresURI, envFileStr, postgresURI, envFileStr, + executorAdditionalContextsStr, + indent(executorDockerfile, " ")) + } + + return result, nil +} + +// --------------------------------------------------------------------------- +// 15. GetNodePMInstallCmd +// --------------------------------------------------------------------------- + +// GetNodePMInstallCmd detects the appropriate Node.js package manager install command. +func GetNodePMInstallCmd(projectDir string) string { + testFile := func(name string) bool { + info, err := os.Stat(filepath.Join(projectDir, name)) + return err == nil && !info.IsDir() + } + + yarn := testFile("yarn.lock") + pnpm := testFile("pnpm-lock.yaml") + npm := testFile("package-lock.json") + bun := testFile("bun.lockb") + + if yarn { + return "yarn install --frozen-lockfile" + } + if pnpm { + return "pnpm i --frozen-lockfile" + } + if npm { + return "npm ci" + } + if bun { + return "bun i" + } + + // Fallback: check package.json packageManager field + pkgManagerName := getPkgManagerName(projectDir) + switch pkgManagerName { + case "yarn": + return "yarn install" + case "pnpm": + return "pnpm i" + case "bun": + return "bun i" + default: + return "npm i" + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +// buildPythonInstallCommands builds the install command strings with optional pip config. +func buildPythonInstallCommands(config map[string]any, installCmd string) (localReqs, globalReqs, pipConfigFileStr string) { + base := fmt.Sprintf("PYTHONDONTWRITEBYTECODE=1 %s --no-cache-dir -c /api/constraints.txt", installCmd) + localReqs = base + globalReqs = base + + if pcf, _ := config["pip_config_file"].(string); pcf != "" { + localReqs = "PIP_CONFIG_FILE=/pipconfig.txt " + localReqs + globalReqs = "PIP_CONFIG_FILE=/pipconfig.txt " + globalReqs + pipConfigFileStr = fmt.Sprintf("ADD %s /pipconfig.txt", pcf) + } + return +} + +// configSlice extracts a []any from config[key], returning nil if not present. +func configSlice(config map[string]any, key string) []any { + if v, ok := config[key].([]any); ok { + return v + } + return nil +} + +// shellQuote wraps a string in single quotes, escaping embedded single quotes. +func shellQuote(s string) string { + // shlex.quote: wrap in single quotes, replace ' with '"'"' + return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'" +} + +// indent prepends prefix to each line of text. +func indent(text, prefix string) string { + if text == "" { + return text + } + lines := strings.Split(text, "\n") + for i, line := range lines { + if line != "" { + lines[i] = prefix + line + } + } + return strings.Join(lines, "\n") +} + +// deepCopyConfig does a JSON round-trip deep copy of a config map. +func deepCopyConfig(config map[string]any) map[string]any { + data, _ := json.Marshal(config) + var result map[string]any + _ = json.Unmarshal(data, &result) + return result +} + +// getPkgManagerName reads the packageManager or devEngines.packageManager.name +// field from package.json. +func getPkgManagerName(projectDir string) string { + data, err := os.ReadFile(filepath.Join(projectDir, "package.json")) + if err != nil { + return "" + } + var pkg map[string]any + if err := json.Unmarshal(data, &pkg); err != nil { + return "" + } + + // Check packageManager field + if pm, ok := pkg["packageManager"].(string); ok && pm != "" { + pm = strings.TrimLeft(pm, "^") + parts := strings.SplitN(pm, "@", 2) + return parts[0] + } + + // Check devEngines.packageManager.name + if devEngines, ok := pkg["devEngines"].(map[string]any); ok { + if pmObj, ok := devEngines["packageManager"].(map[string]any); ok { + if name, ok := pmObj["name"].(string); ok && name != "" { + return name + } + } + } + + return "" +} diff --git a/libs/cli/internal/deploy/client.go b/libs/cli/internal/deploy/client.go new file mode 100644 index 000000000..25afb4619 --- /dev/null +++ b/libs/cli/internal/deploy/client.go @@ -0,0 +1,272 @@ +// Package deploy provides an HTTP client for the LangGraph host backend +// deployment service. +package deploy + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Secret represents a name/value pair sent as a deployment secret. +type Secret struct { + Name string `json:"name"` + Value string `json:"value"` +} + +// HostBackendClient is a minimal JSON HTTP client for the host backend +// deployment service. +type HostBackendClient struct { + BaseURL string + APIKey string + TenantID string + client *http.Client +} + +// retryTransport wraps an http.RoundTripper and retries failed requests. +type retryTransport struct { + base http.RoundTripper + retries int +} + +func (t *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) { + var resp *http.Response + var err error + + // We need to buffer the body so we can replay it on retries. + var bodyBytes []byte + if req.Body != nil { + bodyBytes, err = io.ReadAll(req.Body) + if err != nil { + return nil, err + } + req.Body.Close() + } + + for attempt := 0; attempt <= t.retries; attempt++ { + if bodyBytes != nil { + req.Body = io.NopCloser(bytes.NewReader(bodyBytes)) + } + resp, err = t.base.RoundTrip(req) + if err == nil { + return resp, nil + } + // Only retry on transport-level errors; do not retry on HTTP error + // status codes (the caller handles those). + } + return resp, err +} + +// NewClient creates a new HostBackendClient. The baseURL is stripped of any +// trailing slash. The underlying http.Client uses a 30-second timeout and +// retries transport-level failures up to 3 times. +func NewClient(baseURL, apiKey string) *HostBackendClient { + return &HostBackendClient{ + BaseURL: strings.TrimRight(baseURL, "/"), + APIKey: apiKey, + TenantID: "", + client: &http.Client{ + Timeout: 30 * time.Second, + Transport: &retryTransport{ + base: http.DefaultTransport, + retries: 3, + }, + }, + } +} + +// request executes an HTTP request against the host backend and returns the +// parsed JSON response. It attaches required headers and handles errors. +func (c *HostBackendClient) request(method, path string, payload map[string]any, params map[string]string) (map[string]any, error) { + fullURL := c.BaseURL + path + + // Append query parameters. + if len(params) > 0 { + q := url.Values{} + for k, v := range params { + q.Set(k, v) + } + fullURL += "?" + q.Encode() + } + + var body io.Reader + if payload != nil { + data, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshalling request payload: %w", err) + } + body = bytes.NewReader(data) + } + + req, err := http.NewRequest(method, fullURL, body) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + req.Header.Set("X-Api-Key", c.APIKey) + req.Header.Set("Accept", "application/json") + if payload != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.TenantID != "" { + req.Header.Set("X-Tenant-ID", c.TenantID) + } + + resp, err := c.client.Do(req) + if err != nil { + return nil, fmt.Errorf("%s %s: %w", method, path, err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response from %s: %w", path, err) + } + + if resp.StatusCode >= 400 { + detail := string(respBody) + if detail == "" { + detail = fmt.Sprintf("%d", resp.StatusCode) + } + return nil, fmt.Errorf("%s %s failed with status %d: %s", method, path, resp.StatusCode, detail) + } + + if len(respBody) == 0 { + return nil, nil + } + + var result map[string]any + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to decode response from %s: %w", path, err) + } + return result, nil +} + +// requestNoBody is a convenience wrapper for requests that return no parsed body. +func (c *HostBackendClient) requestNoBody(method, path string, payload map[string]any, params map[string]string) error { + _, err := c.request(method, path, payload, params) + return err +} + +// CreateDeployment creates a new deployment. +func (c *HostBackendClient) CreateDeployment(name, deploymentType, source string, configPath string, secrets []Secret) (map[string]any, error) { + sourceRevisionConfig := map[string]any{} + if source == "internal_source" && configPath != "" { + sourceRevisionConfig["langgraph_config_path"] = configPath + } + + payload := map[string]any{ + "name": name, + "source": source, + "source_config": map[string]any{"deployment_type": deploymentType}, + "source_revision_config": sourceRevisionConfig, + } + if secrets != nil { + payload["secrets"] = secrets + } + return c.request("POST", "/v2/deployments", payload, nil) +} + +// ListDeployments lists deployments, optionally filtering by name. +func (c *HostBackendClient) ListDeployments(nameContains string) (map[string]any, error) { + params := map[string]string{"name_contains": nameContains} + return c.request("GET", "/v2/deployments", nil, params) +} + +// GetDeployment retrieves a single deployment by ID. +func (c *HostBackendClient) GetDeployment(deploymentID string) (map[string]any, error) { + return c.request("GET", fmt.Sprintf("/v2/deployments/%s", deploymentID), nil, nil) +} + +// DeleteDeployment deletes a deployment by ID. +func (c *HostBackendClient) DeleteDeployment(deploymentID string) error { + return c.requestNoBody("DELETE", fmt.Sprintf("/v2/deployments/%s", deploymentID), nil, nil) +} + +// RequestPushToken requests a push token for a deployment. +func (c *HostBackendClient) RequestPushToken(deploymentID string) (map[string]any, error) { + return c.request("POST", fmt.Sprintf("/v2/deployments/%s/push-token", deploymentID), nil, nil) +} + +// RequestUploadURL gets a signed URL for uploading the source tarball. +func (c *HostBackendClient) RequestUploadURL(deploymentID string) (map[string]any, error) { + return c.request("POST", fmt.Sprintf("/v2/deployments/%s/upload-url", deploymentID), nil, nil) +} + +// UpdateDeployment triggers a new revision using a pre-pushed Docker image. +func (c *HostBackendClient) UpdateDeployment(deploymentID, imageURI string, secrets []Secret) (map[string]any, error) { + payload := map[string]any{ + "revision_source": "internal_docker", + "source_revision_config": map[string]any{"image_uri": imageURI}, + } + if secrets != nil { + payload["secrets"] = secrets + } + return c.request("PATCH", fmt.Sprintf("/v2/deployments/%s", deploymentID), payload, nil) +} + +// UpdateDeploymentInternalSource triggers a remote-build revision using an +// uploaded source tarball. +func (c *HostBackendClient) UpdateDeploymentInternalSource( + deploymentID, sourceTarballPath, configPath string, + secrets []Secret, + installCommand, buildCommand string, +) (map[string]any, error) { + payload := map[string]any{ + "revision_source": "internal_source", + "source_revision_config": map[string]any{ + "source_tarball_path": sourceTarballPath, + "langgraph_config_path": configPath, + }, + } + + sourceConfig := map[string]any{} + if installCommand != "" { + sourceConfig["install_command"] = installCommand + } + if buildCommand != "" { + sourceConfig["build_command"] = buildCommand + } + if len(sourceConfig) > 0 { + payload["source_config"] = sourceConfig + } + + if secrets != nil { + payload["secrets"] = secrets + } + return c.request("PATCH", fmt.Sprintf("/v2/deployments/%s", deploymentID), payload, nil) +} + +// ListRevisions lists revisions for a deployment. +func (c *HostBackendClient) ListRevisions(deploymentID string, limit int) (map[string]any, error) { + return c.request("GET", fmt.Sprintf("/v2/deployments/%s/revisions", deploymentID), nil, map[string]string{ + "limit": fmt.Sprintf("%d", limit), + }) +} + +// GetRevision retrieves a single revision. +func (c *HostBackendClient) GetRevision(deploymentID, revisionID string) (map[string]any, error) { + return c.request("GET", fmt.Sprintf("/v2/deployments/%s/revisions/%s", deploymentID, revisionID), nil, nil) +} + +// GetBuildLogs retrieves build logs for a revision. +func (c *HostBackendClient) GetBuildLogs(projectID, revisionID string, payload map[string]any) (map[string]any, error) { + return c.request("POST", fmt.Sprintf("/v1/projects/%s/revisions/%s/build_logs", projectID, revisionID), payload, nil) +} + +// GetDeployLogs retrieves deploy logs. If revisionID is non-empty, it is +// included in the path to scope the logs. +func (c *HostBackendClient) GetDeployLogs(projectID string, payload map[string]any, revisionID string) (map[string]any, error) { + var path string + if revisionID != "" { + path = fmt.Sprintf("/v1/projects/%s/revisions/%s/deploy_logs", projectID, revisionID) + } else { + path = fmt.Sprintf("/v1/projects/%s/deploy_logs", projectID) + } + return c.request("POST", path, payload, nil) +} diff --git a/libs/cli/internal/deploy/helpers.go b/libs/cli/internal/deploy/helpers.go new file mode 100644 index 000000000..d7edb7a99 --- /dev/null +++ b/libs/cli/internal/deploy/helpers.go @@ -0,0 +1,268 @@ +package deploy + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// APIKeyEnvNames lists the environment variable names checked (in order) when +// resolving a LangSmith / LangGraph API key. +var APIKeyEnvNames = []string{ + "LANGGRAPH_HOST_API_KEY", + "LANGSMITH_API_KEY", + "LANGCHAIN_API_KEY", +} + +// DefaultHostURL is the default host backend URL. +const DefaultHostURL = "https://api.host.langchain.com" + +// reservedEnvVars contains environment variable names that must not be sent as +// deployment secrets. The set mirrors the Python CLI's RESERVED_ENV_VARS. +var reservedEnvVars = map[string]bool{ + // LANGCHAIN_RESERVED_ENV_VARS from host-backend + "LANGCHAIN_TRACING_V2": true, + "LANGSMITH_TRACING_V2": true, + "LANGCHAIN_ENDPOINT": true, + "LANGCHAIN_PROJECT": true, + "LANGSMITH_PROJECT": true, + "LANGSMITH_LANGGRAPH_GIT_REPO": true, + "LANGGRAPH_GIT_REPO_PATH": true, + "LANGCHAIN_API_KEY": true, + "LANGSMITH_CONTROL_PLANE_API_KEY": true, + "POSTGRES_URI": true, + "POSTGRES_PASSWORD": true, + "DATABASE_URI": true, + "LANGSMITH_LANGGRAPH_GIT_REF": true, + "LANGSMITH_LANGGRAPH_GIT_REF_SHA": true, + "LANGGRAPH_AUTH_TYPE": true, + "LANGSMITH_AUTH_ENDPOINT": true, + "LANGSMITH_TENANT_ID": true, + "LANGSMITH_AUTH_VERIFY_TENANT_ID": true, + "LANGSMITH_HOST_PROJECT_ID": true, + "LANGSMITH_HOST_PROJECT_NAME": true, + "LANGSMITH_HOST_REVISION_ID": true, + "LOG_JSON": true, + "LOG_DICT_TRACEBACKS": true, + "REDIS_URI": true, + "LANGCHAIN_CALLBACKS_BACKGROUND": true, + "DD_TRACE_PSYCOPG_ENABLED": true, + "DD_TRACE_REDIS_ENABLED": true, + "LANGSMITH_DEPLOYMENT_NAME": true, + "LANGGRAPH_CLOUD_LICENSE_KEY": true, + // ALLOWED_SELF_HOSTED_ENV_VARS (rejected for non-self-hosted) + "LANGSMITH_API_KEY": true, + "LANGSMITH_ENDPOINT": true, + "POSTGRES_URI_CUSTOM": true, + "REDIS_URI_CUSTOM": true, + "PATH": true, + "PORT": true, + "MOUNT_PREFIX": true, + "LSD_ENV": true, + "LSD_DD_API_KEY": true, + "LSD_DD_ENDPOINT": true, + "LSD_DEPLOYMENT_TYPE": true, +} + +var ( + invalidImageNameChars = regexp.MustCompile(`[^a-z0-9._-]+`) + validImageTag = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) +) + +// NormalizeImageName sanitizes a deployment/directory name into a valid Docker +// repository name. Invalid characters are replaced with hyphens and the result +// is lowercased. Returns "app" if the result would be empty. +func NormalizeImageName(name string) string { + if name == "" { + return "app" + } + slug := invalidImageNameChars.ReplaceAllString(strings.ToLower(name), "-") + slug = strings.TrimLeft(slug, "-.") + slug = strings.TrimRight(slug, "-.") + if slug == "" { + return "app" + } + return slug +} + +// NormalizeImageTag validates and returns a Docker image tag. Tags may only +// contain [A-Za-z0-9_.-]. Defaults to "latest" when empty. +func NormalizeImageTag(tag string) (string, error) { + if tag == "" { + return "latest", nil + } + if !validImageTag.MatchString(tag) { + return "", fmt.Errorf("image tag may only contain characters A-Z, a-z, 0-9, '_', '-', '.'") + } + return tag, nil +} + +// ResolveAPIKey resolves an API key by checking (in order): the explicit flag +// value, the provided envVars map, and the process environment. Returns an +// empty string if no key is found (the caller should prompt the user). +func ResolveAPIKey(flagValue string, envVars map[string]string) string { + if flagValue != "" { + return flagValue + } + for _, keyName := range APIKeyEnvNames { + if envVars != nil { + if v, ok := envVars[keyName]; ok && v != "" { + return v + } + } + if v := os.Getenv(keyName); v != "" { + return v + } + } + return "" +} + +// ParseEnvFromConfig resolves environment variables from the langgraph.json +// config. If the "env" field is a dict (map), those values are used directly. +// If it is a string, it is treated as a path to a .env file (resolved relative +// to the config file's directory). Otherwise, a .env file in the config +// directory is attempted as a fallback. +func ParseEnvFromConfig(configJSON map[string]any, configPath string) map[string]string { + envField, ok := configJSON["env"] + if !ok { + // Fallback: try .env in config dir. + return parseDotEnvFile(filepath.Join(filepath.Dir(configPath), ".env")) + } + + // If env is a dict (map[string]any), convert to map[string]string. + if envMap, ok := envField.(map[string]any); ok && len(envMap) > 0 { + result := make(map[string]string, len(envMap)) + for k, v := range envMap { + result[k] = fmt.Sprintf("%v", v) + } + return result + } + + // If env is a string path, parse that .env file. + if envStr, ok := envField.(string); ok && envStr != "" { + envPath := filepath.Join(filepath.Dir(configPath), envStr) + absPath, err := filepath.Abs(envPath) + if err != nil { + return map[string]string{} + } + if _, err := os.Stat(absPath); os.IsNotExist(err) { + return map[string]string{} + } + return parseDotEnvFile(absPath) + } + + // Fallback: try .env in config dir. + return parseDotEnvFile(filepath.Join(filepath.Dir(configPath), ".env")) +} + +// parseDotEnvFile reads a .env file and returns its key-value pairs. Lines +// starting with # are treated as comments. Empty values are skipped. +func parseDotEnvFile(path string) map[string]string { + f, err := os.Open(path) + if err != nil { + return map[string]string{} + } + defer f.Close() + + result := map[string]string{} + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + idx := strings.IndexByte(line, '=') + if idx < 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + value := strings.TrimSpace(line[idx+1:]) + // Strip surrounding quotes if present. + if len(value) >= 2 { + if (value[0] == '"' && value[len(value)-1] == '"') || + (value[0] == '\'' && value[len(value)-1] == '\'') { + value = value[1 : len(value)-1] + } + } + if key != "" && value != "" { + result[key] = value + } + } + return result +} + +// FindDeploymentIDByName lists deployments matching the given name and returns +// the ID of the first exact match. Returns an empty string (and no error) if +// no exact match is found. +func FindDeploymentIDByName(client *HostBackendClient, name string) (string, error) { + if name == "" { + return "", nil + } + existing, err := client.ListDeployments(name) + if err != nil { + return "", err + } + resources, ok := existing["resources"] + if !ok { + return "", nil + } + resourceList, ok := resources.([]any) + if !ok { + return "", nil + } + for _, item := range resourceList { + dep, ok := item.(map[string]any) + if !ok { + continue + } + depName, _ := dep["name"].(string) + if depName == name { + if id, ok := dep["id"]; ok { + return fmt.Sprintf("%v", id), nil + } + } + } + return "", nil +} + +// ValidateDeploymentSelector ensures at least one of deploymentID or name is +// provided. +func ValidateDeploymentSelector(deploymentID, name string) error { + if deploymentID != "" { + return nil + } + if name == "" { + return fmt.Errorf("either --deployment-id or --name is required") + } + return nil +} + +// ResolvedReservedEnvVars returns the set of reserved environment variable +// names that must not be sent as deployment secrets. +func ResolvedReservedEnvVars() map[string]bool { + // Return a copy to prevent callers from mutating the package-level map. + result := make(map[string]bool, len(reservedEnvVars)) + for k, v := range reservedEnvVars { + result[k] = v + } + return result +} + +// SecretsFromEnv converts an env var map into a Secret slice, filtering out +// reserved variable names and empty values. +func SecretsFromEnv(envVars map[string]string) []Secret { + var secrets []Secret + for name, value := range envVars { + if reservedEnvVars[name] { + continue + } + if value == "" { + continue + } + secrets = append(secrets, Secret{Name: name, Value: value}) + } + return secrets +} diff --git a/libs/cli/internal/docker/docker.go b/libs/cli/internal/docker/docker.go new file mode 100644 index 000000000..d49205552 --- /dev/null +++ b/libs/cli/internal/docker/docker.go @@ -0,0 +1,526 @@ +// Package docker provides Docker compose generation, capability detection, +// and image building for the LangGraph CLI. +package docker + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "time" +) + +// DefaultPostgresURI is the default connection string used when no custom +// Postgres URI is provided. +const DefaultPostgresURI = "postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable" + +// Version represents a semantic version with major, minor, and patch components. +type Version struct { + Major, Minor, Patch int +} + +// GreaterOrEqual returns true if v >= other. +func (v Version) GreaterOrEqual(other Version) bool { + if v.Major != other.Major { + return v.Major > other.Major + } + if v.Minor != other.Minor { + return v.Minor > other.Minor + } + return v.Patch >= other.Patch +} + +// DockerCapabilities describes the Docker environment available on the host. +type DockerCapabilities struct { + VersionDocker Version + VersionCompose Version + HealthcheckStartInterval bool + ComposeType string // "plugin" or "standalone" +} + +// ComposeOpts configures the generated docker-compose YAML. +type ComposeOpts struct { + Port int + DebuggerPort int // 0 means no debugger + DebuggerBaseURL string // optional base URL for the debugger + PostgresURI string // empty means use DefaultPostgresURI + Image string // pre-built image name + BaseImage string + APIVersion string + EngineRuntimeMode string // "combined_queue_worker" or "distributed" +} + +// BuildImageOpts configures docker image building. +type BuildImageOpts struct { + ConfigPath string + ConfigJSON map[string]any + BaseImage string + APIVersion string + Pull bool + Tag string + Passthrough []string + InstallCommand string + BuildCommand string + DockerCommand []string // default: ["docker", "build"] + ExtraFlags []string + Verbose bool +} + +// OrderedMap preserves insertion order for map keys. +type OrderedMap struct { + Keys []string + Values map[string]any +} + +// NewOrderedMap creates an empty OrderedMap. +func NewOrderedMap() *OrderedMap { + return &OrderedMap{ + Values: make(map[string]any), + } +} + +// Set adds or updates a key-value pair, preserving insertion order. +func (om *OrderedMap) Set(key string, value any) { + if _, exists := om.Values[key]; !exists { + om.Keys = append(om.Keys, key) + } + om.Values[key] = value +} + +// Get retrieves the value for a key. +func (om *OrderedMap) Get(key string) (any, bool) { + v, ok := om.Values[key] + return v, ok +} + +// --------------------------------------------------------------------------- +// ParseVersion +// --------------------------------------------------------------------------- + +// ParseVersion parses a version string like "1.2.3", "v1.2.3-alpha+build", +// "1.2", or "1" into a Version. +func ParseVersion(version string) Version { + parts := strings.SplitN(version, ".", 3) + + major := "0" + minor := "0" + patch := "0" + + switch len(parts) { + case 1: + major = parts[0] + case 2: + major = parts[0] + minor = parts[1] + default: + major = parts[0] + minor = parts[1] + patch = parts[2] + } + + // Strip "v" prefix from major + major = strings.TrimPrefix(major, "v") + + // Strip "-" and "+" suffixes from patch + if idx := strings.IndexAny(patch, "-+"); idx >= 0 { + patch = patch[:idx] + } + + majorInt, _ := strconv.Atoi(major) + minorInt, _ := strconv.Atoi(minor) + patchInt, _ := strconv.Atoi(patch) + + return Version{Major: majorInt, Minor: minorInt, Patch: patchInt} +} + +// --------------------------------------------------------------------------- +// CanBuildLocally +// --------------------------------------------------------------------------- + +// CanBuildLocally checks whether local deployment builds can run on this machine. +// It returns (ok, errorMessage). If ok is true, errorMessage is empty. +func CanBuildLocally() (bool, string) { + if _, err := exec.LookPath("docker"); err != nil { + return false, "Docker is required but not installed.\n" + + "Install Docker Desktop: https://docs.docker.com/get-docker/" + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, "docker", "info") + cmd.Stdout = nil + cmd.Stderr = nil + if err := cmd.Run(); err != nil { + return false, "Docker is installed but not running.\nStart Docker and try again." + } + + if runtime.GOARCH != "amd64" { + ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel2() + + buildx := exec.CommandContext(ctx2, "docker", "buildx", "version") + buildx.Stdout = nil + buildx.Stderr = nil + if err := buildx.Run(); err != nil { + arch := runtime.GOARCH + // Try to match Python's platform.machine() naming for the error message + if arch == "arm64" { + arch = "aarch64" + } + return false, "Docker Buildx is required but not installed.\n" + + "Your machine architecture (" + arch + ") requires Buildx to cross-compile images for linux/amd64.\n" + + "Install Buildx: https://docs.docker.com/build/install-buildx/" + } + } + return true, "" +} + +// --------------------------------------------------------------------------- +// CheckCapabilities +// --------------------------------------------------------------------------- + +// CheckCapabilities detects the Docker and Docker Compose versions available +// on the host and returns a DockerCapabilities describing them. +func CheckCapabilities() (*DockerCapabilities, error) { + if _, err := exec.LookPath("docker"); err != nil { + return nil, fmt.Errorf("Docker not installed") + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + out, err := exec.CommandContext(ctx, "docker", "info", "-f", "{{json .}}").Output() + if err != nil { + return nil, fmt.Errorf("Docker not installed or not running") + } + + var info map[string]any + if err := json.Unmarshal(out, &info); err != nil { + return nil, fmt.Errorf("Docker not installed or not running") + } + + serverVersion, _ := info["ServerVersion"].(string) + if serverVersion == "" { + return nil, fmt.Errorf("Docker not running") + } + + // Try to find compose plugin + var composeVersionStr string + composeType := "plugin" + + found := false + if clientInfo, ok := info["ClientInfo"].(map[string]any); ok { + if plugins, ok := clientInfo["Plugins"].([]any); ok { + for _, p := range plugins { + pm, ok := p.(map[string]any) + if !ok { + continue + } + name, _ := pm["Name"].(string) + if name == "compose" { + composeVersionStr, _ = pm["Version"].(string) + found = true + break + } + } + } + } + + if !found { + // Fall back to standalone docker-compose + if _, err := exec.LookPath("docker-compose"); err != nil { + return nil, fmt.Errorf("Docker Compose not installed") + } + + ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel2() + + out2, err := exec.CommandContext(ctx2, "docker-compose", "--version", "--short").Output() + if err != nil { + return nil, fmt.Errorf("Docker Compose not installed") + } + composeVersionStr = strings.TrimSpace(string(out2)) + composeType = "standalone" + } + + dockerVersion := ParseVersion(serverVersion) + composeVersion := ParseVersion(composeVersionStr) + + return &DockerCapabilities{ + VersionDocker: dockerVersion, + VersionCompose: composeVersion, + HealthcheckStartInterval: dockerVersion.GreaterOrEqual(Version{25, 0, 0}), + ComposeType: composeType, + }, nil +} + +// --------------------------------------------------------------------------- +// DebuggerCompose +// --------------------------------------------------------------------------- + +// DebuggerCompose returns a service config map for the langgraph-debugger +// container, or nil if port is 0 (no debugger requested). +func DebuggerCompose(port int, baseURL string) *OrderedMap { + if port == 0 { + return nil + } + + dependsOn := NewOrderedMap() + postgresCondition := NewOrderedMap() + postgresCondition.Set("condition", "service_healthy") + dependsOn.Set("langgraph-postgres", postgresCondition) + + service := NewOrderedMap() + service.Set("image", "langchain/langgraph-debugger") + service.Set("restart", "on-failure") + service.Set("depends_on", dependsOn) + service.Set("ports", []any{fmt.Sprintf(`"%d:3968"`, port)}) + + if baseURL != "" { + env := NewOrderedMap() + env.Set("VITE_STUDIO_LOCAL_GRAPH_URL", baseURL) + service.Set("environment", env) + } + + result := NewOrderedMap() + result.Set("langgraph-debugger", service) + return result +} + +// --------------------------------------------------------------------------- +// DictToYAML +// --------------------------------------------------------------------------- + +// DictToYAML converts an OrderedMap to a YAML string. For top-level keys +// (indent < 2) it adds a blank line between entries (except the first). +func DictToYAML(d *OrderedMap, indent int) string { + var b strings.Builder + for idx, key := range d.Keys { + // Add blank line between top-level entries (except first) + if idx >= 1 && indent < 2 { + b.WriteString("\n") + } + + space := strings.Repeat(" ", indent) + value := d.Values[key] + + switch v := value.(type) { + case *OrderedMap: + b.WriteString(fmt.Sprintf("%s%s:\n", space, key)) + b.WriteString(DictToYAML(v, indent+1)) + case []any: + b.WriteString(fmt.Sprintf("%s%s:\n", space, key)) + for _, item := range v { + b.WriteString(fmt.Sprintf("%s - %v\n", space, item)) + } + default: + b.WriteString(fmt.Sprintf("%s%s: %v\n", space, key, value)) + } + } + return b.String() +} + +// --------------------------------------------------------------------------- +// ComposeAsDict +// --------------------------------------------------------------------------- + +// ComposeAsDict builds the docker-compose configuration as an OrderedMap. +func ComposeAsDict(caps *DockerCapabilities, opts ComposeOpts) *OrderedMap { + postgresURI := opts.PostgresURI + includeDB := false + if postgresURI == "" { + includeDB = true + postgresURI = DefaultPostgresURI + } + + services := NewOrderedMap() + + // --- Redis service --- + redisHealthcheck := NewOrderedMap() + redisHealthcheck.Set("test", "redis-cli ping") + redisHealthcheck.Set("interval", "5s") + redisHealthcheck.Set("timeout", "1s") + redisHealthcheck.Set("retries", 5) + + redisService := NewOrderedMap() + redisService.Set("image", "redis:6") + redisService.Set("healthcheck", redisHealthcheck) + services.Set("langgraph-redis", redisService) + + // --- Postgres service (if no custom URI) --- + if includeDB { + pgEnv := NewOrderedMap() + pgEnv.Set("POSTGRES_DB", "postgres") + pgEnv.Set("POSTGRES_USER", "postgres") + pgEnv.Set("POSTGRES_PASSWORD", "postgres") + + pgHealthcheck := NewOrderedMap() + pgHealthcheck.Set("test", "pg_isready -U postgres") + pgHealthcheck.Set("start_period", "10s") + pgHealthcheck.Set("timeout", "1s") + pgHealthcheck.Set("retries", 5) + + if caps.HealthcheckStartInterval { + pgHealthcheck.Set("interval", "60s") + pgHealthcheck.Set("start_interval", "1s") + } else { + pgHealthcheck.Set("interval", "5s") + } + + pgService := NewOrderedMap() + pgService.Set("image", "pgvector/pgvector:pg16") + pgService.Set("ports", []any{`"5433:5432"`}) + pgService.Set("environment", pgEnv) + pgService.Set("command", []any{"postgres", "-c", "shared_preload_libraries=vector"}) + pgService.Set("volumes", []any{"langgraph-data:/var/lib/postgresql/data"}) + pgService.Set("healthcheck", pgHealthcheck) + + services.Set("langgraph-postgres", pgService) + } + + // --- Debugger service (optional) --- + if opts.DebuggerPort != 0 { + debuggerMap := DebuggerCompose(opts.DebuggerPort, opts.DebuggerBaseURL) + if debuggerMap != nil { + debuggerService, _ := debuggerMap.Get("langgraph-debugger") + services.Set("langgraph-debugger", debuggerService) + } + } + + // --- langgraph-api service --- + apiEnv := NewOrderedMap() + apiEnv.Set("REDIS_URI", "redis://langgraph-redis:6379") + apiEnv.Set("POSTGRES_URI", postgresURI) + + if opts.EngineRuntimeMode == "distributed" { + apiEnv.Set("N_JOBS_PER_WORKER", `"0"`) + } + + apiDependsOn := NewOrderedMap() + redisCondition := NewOrderedMap() + redisCondition.Set("condition", "service_healthy") + apiDependsOn.Set("langgraph-redis", redisCondition) + + if includeDB { + pgCondition := NewOrderedMap() + pgCondition.Set("condition", "service_healthy") + apiDependsOn.Set("langgraph-postgres", pgCondition) + } + + apiService := NewOrderedMap() + apiService.Set("ports", []any{fmt.Sprintf(`"%d:8000"`, opts.Port)}) + apiService.Set("depends_on", apiDependsOn) + apiService.Set("environment", apiEnv) + + if opts.Image != "" { + apiService.Set("image", opts.Image) + } + + if caps.HealthcheckStartInterval { + apiHealthcheck := NewOrderedMap() + apiHealthcheck.Set("test", "python /api/healthcheck.py") + apiHealthcheck.Set("interval", "60s") + apiHealthcheck.Set("start_interval", "1s") + apiHealthcheck.Set("start_period", "10s") + apiService.Set("healthcheck", apiHealthcheck) + } + + services.Set("langgraph-api", apiService) + + // --- Build final compose dict --- + composeDict := NewOrderedMap() + if includeDB { + volumes := NewOrderedMap() + volumeDriver := NewOrderedMap() + volumeDriver.Set("driver", "local") + volumes.Set("langgraph-data", volumeDriver) + composeDict.Set("volumes", volumes) + } + composeDict.Set("services", services) + + return composeDict +} + +// --------------------------------------------------------------------------- +// Compose +// --------------------------------------------------------------------------- + +// Compose generates a docker-compose YAML string from the given capabilities +// and options. +func Compose(caps *DockerCapabilities, opts ComposeOpts) string { + d := ComposeAsDict(caps, opts) + return DictToYAML(d, 0) +} + +// --------------------------------------------------------------------------- +// BuildDockerImage +// --------------------------------------------------------------------------- + +// BuildDockerImage builds a Docker image from a LangGraph configuration. +// It shells out to docker build (or a custom docker command) with the +// generated Dockerfile piped via stdin. +func BuildDockerImage(opts BuildImageOpts) error { + dockerCmd := opts.DockerCommand + if len(dockerCmd) == 0 { + dockerCmd = []string{"docker", "build"} + } + + // Pull the base image first if requested. + if opts.Pull { + pullCmd := exec.Command("docker", "pull", opts.Tag) + pullCmd.Stdout = os.Stdout + pullCmd.Stderr = os.Stderr + if err := pullCmd.Run(); err != nil { + return fmt.Errorf("failed to pull image %s: %w", opts.Tag, err) + } + } + + // Build the docker build arguments. + args := []string{ + "-f", "-", // read Dockerfile from stdin + "-t", opts.Tag, + } + + // Determine build context. + buildContext := "." + if opts.ConfigPath != "" { + // Use the parent directory of the config file by default. + idx := strings.LastIndex(opts.ConfigPath, "/") + if idx >= 0 { + buildContext = opts.ConfigPath[:idx] + } + } + + // Generate the Dockerfile. This is a placeholder that will call into + // the config package once ConfigToDocker is implemented. + dockerfile := generateDockerfileStub(opts) + + // Assemble the full command. + fullArgs := append(dockerCmd[1:], args...) + fullArgs = append(fullArgs, opts.ExtraFlags...) + fullArgs = append(fullArgs, opts.Passthrough...) + fullArgs = append(fullArgs, buildContext) + + cmd := exec.Command(dockerCmd[0], fullArgs...) + cmd.Stdin = strings.NewReader(dockerfile) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + return cmd.Run() +} + +// generateDockerfileStub is a placeholder that will be replaced by a call to +// config.ConfigToDocker once that function is implemented in the config package. +func generateDockerfileStub(opts BuildImageOpts) string { + // Placeholder: produce a minimal Dockerfile from the base image. + base := opts.BaseImage + if base == "" { + base = "langchain/langgraph-api" + } + return fmt.Sprintf("FROM %s\n", base) +} diff --git a/libs/cli/internal/docker/docker_test.go b/libs/cli/internal/docker/docker_test.go new file mode 100644 index 000000000..ff4a599c5 --- /dev/null +++ b/libs/cli/internal/docker/docker_test.go @@ -0,0 +1,290 @@ +package docker + +import ( + "fmt" + "strings" + "testing" +) + +func cleanEmptyLines(s string) string { + lines := strings.Split(s, "\n") + var result []string + for _, line := range lines { + if strings.TrimSpace(line) != "" { + result = append(result, line) + } + } + return strings.Join(result, "\n") +} + +var defaultCaps = &DockerCapabilities{ + VersionDocker: Version{Major: 26, Minor: 1, Patch: 1}, + VersionCompose: Version{Major: 2, Minor: 27, Patch: 0}, + HealthcheckStartInterval: false, +} + +func TestComposeCustomDBNoDebugger(t *testing.T) { + port := 8123 + actual := Compose(defaultCaps, ComposeOpts{ + Port: port, + PostgresURI: "custom_postgres_uri", + }) + expected := fmt.Sprintf(`services: + langgraph-redis: + image: redis:6 + healthcheck: + test: redis-cli ping + interval: 5s + timeout: 1s + retries: 5 + langgraph-api: + ports: + - "%d:8000" + depends_on: + langgraph-redis: + condition: service_healthy + environment: + REDIS_URI: redis://langgraph-redis:6379 + POSTGRES_URI: custom_postgres_uri`, port) + + if cleanEmptyLines(actual) != expected { + t.Errorf("mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, cleanEmptyLines(actual)) + } +} + +func TestComposeCustomDBWithHealthcheck(t *testing.T) { + port := 8123 + capsHC := &DockerCapabilities{ + VersionDocker: Version{Major: 26, Minor: 1, Patch: 1}, + VersionCompose: Version{Major: 2, Minor: 27, Patch: 0}, + HealthcheckStartInterval: true, + } + actual := Compose(capsHC, ComposeOpts{ + Port: port, + PostgresURI: "custom_postgres_uri", + }) + expected := fmt.Sprintf(`services: + langgraph-redis: + image: redis:6 + healthcheck: + test: redis-cli ping + interval: 5s + timeout: 1s + retries: 5 + langgraph-api: + ports: + - "%d:8000" + depends_on: + langgraph-redis: + condition: service_healthy + environment: + REDIS_URI: redis://langgraph-redis:6379 + POSTGRES_URI: custom_postgres_uri + healthcheck: + test: python /api/healthcheck.py + interval: 60s + start_interval: 1s + start_period: 10s`, port) + + if cleanEmptyLines(actual) != expected { + t.Errorf("mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, cleanEmptyLines(actual)) + } +} + +func TestComposeDefaultDB(t *testing.T) { + port := 8123 + actual := Compose(defaultCaps, ComposeOpts{Port: port}) + expected := fmt.Sprintf(`volumes: + langgraph-data: + driver: local +services: + langgraph-redis: + image: redis:6 + healthcheck: + test: redis-cli ping + interval: 5s + timeout: 1s + retries: 5 + langgraph-postgres: + image: pgvector/pgvector:pg16 + ports: + - "5433:5432" + environment: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + command: + - postgres + - -c + - shared_preload_libraries=vector + volumes: + - langgraph-data:/var/lib/postgresql/data + healthcheck: + test: pg_isready -U postgres + start_period: 10s + timeout: 1s + retries: 5 + interval: 5s + langgraph-api: + ports: + - "%d:8000" + depends_on: + langgraph-redis: + condition: service_healthy + langgraph-postgres: + condition: service_healthy + environment: + REDIS_URI: redis://langgraph-redis:6379 + POSTGRES_URI: %s`, port, DefaultPostgresURI) + + if cleanEmptyLines(actual) != expected { + t.Errorf("mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, cleanEmptyLines(actual)) + } +} + +func TestComposeDistributedMode(t *testing.T) { + port := 8123 + actual := Compose(defaultCaps, ComposeOpts{ + Port: port, + PostgresURI: "custom_postgres_uri", + EngineRuntimeMode: "distributed", + }) + expected := fmt.Sprintf(`services: + langgraph-redis: + image: redis:6 + healthcheck: + test: redis-cli ping + interval: 5s + timeout: 1s + retries: 5 + langgraph-api: + ports: + - "%d:8000" + depends_on: + langgraph-redis: + condition: service_healthy + environment: + REDIS_URI: redis://langgraph-redis:6379 + POSTGRES_URI: custom_postgres_uri + N_JOBS_PER_WORKER: "0"`, port) + + if cleanEmptyLines(actual) != expected { + t.Errorf("mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, cleanEmptyLines(actual)) + } +} + +func TestComposeCombinedModeNoNJobs(t *testing.T) { + actual := Compose(defaultCaps, ComposeOpts{ + Port: 8123, + EngineRuntimeMode: "combined_queue_worker", + }) + if strings.Contains(actual, "N_JOBS_PER_WORKER") { + t.Error("combined mode should not contain N_JOBS_PER_WORKER") + } +} + +func TestComposeDebuggerDefaultDB(t *testing.T) { + port := 8123 + debuggerPort := 8001 + actual := Compose(defaultCaps, ComposeOpts{ + Port: port, + DebuggerPort: debuggerPort, + }) + expected := fmt.Sprintf(`volumes: + langgraph-data: + driver: local +services: + langgraph-redis: + image: redis:6 + healthcheck: + test: redis-cli ping + interval: 5s + timeout: 1s + retries: 5 + langgraph-postgres: + image: pgvector/pgvector:pg16 + ports: + - "5433:5432" + environment: + POSTGRES_DB: postgres + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + command: + - postgres + - -c + - shared_preload_libraries=vector + volumes: + - langgraph-data:/var/lib/postgresql/data + healthcheck: + test: pg_isready -U postgres + start_period: 10s + timeout: 1s + retries: 5 + interval: 5s + langgraph-debugger: + image: langchain/langgraph-debugger + restart: on-failure + depends_on: + langgraph-postgres: + condition: service_healthy + ports: + - "%d:3968" + langgraph-api: + ports: + - "%d:8000" + depends_on: + langgraph-redis: + condition: service_healthy + langgraph-postgres: + condition: service_healthy + environment: + REDIS_URI: redis://langgraph-redis:6379 + POSTGRES_URI: %s`, debuggerPort, port, DefaultPostgresURI) + + if cleanEmptyLines(actual) != expected { + t.Errorf("mismatch.\nExpected:\n%s\n\nGot:\n%s", expected, cleanEmptyLines(actual)) + } +} + +func TestParseVersion(t *testing.T) { + tests := []struct { + input string + expected Version + }{ + {"1.2.3", Version{1, 2, 3}}, + {"v1.2.3", Version{1, 2, 3}}, + {"1.2.3-alpha", Version{1, 2, 3}}, + {"1.2.3+1", Version{1, 2, 3}}, + {"1.2.3-alpha+build", Version{1, 2, 3}}, + {"1.2", Version{1, 2, 0}}, + {"1", Version{1, 0, 0}}, + {"v28.1.1+1", Version{28, 1, 1}}, + {"2.0.0-beta.1+exp.sha.5114f85", Version{2, 0, 0}}, + {"v3.4.5-rc1+build.123", Version{3, 4, 5}}, + } + + for _, tc := range tests { + result := ParseVersion(tc.input) + if result != tc.expected { + t.Errorf("ParseVersion(%q) = %v, want %v", tc.input, result, tc.expected) + } + } +} + +func TestVersionGreaterOrEqual(t *testing.T) { + tests := []struct { + v, other Version + want bool + }{ + {Version{25, 0, 0}, Version{25, 0, 0}, true}, + {Version{26, 1, 1}, Version{25, 0, 0}, true}, + {Version{24, 9, 9}, Version{25, 0, 0}, false}, + } + + for _, tc := range tests { + got := tc.v.GreaterOrEqual(tc.other) + if got != tc.want { + t.Errorf("%v.GreaterOrEqual(%v) = %v, want %v", tc.v, tc.other, got, tc.want) + } + } +} diff --git a/libs/cli/internal/exec/exec.go b/libs/cli/internal/exec/exec.go new file mode 100644 index 000000000..97a290e68 --- /dev/null +++ b/libs/cli/internal/exec/exec.go @@ -0,0 +1,123 @@ +// Package lgexec provides subprocess execution helpers for the LangGraph CLI. +// +// The package name is lgexec (rather than exec) to avoid shadowing the +// standard library os/exec package. +package lgexec + +import ( + "bufio" + "bytes" + "fmt" + "io" + "os" + "os/exec" + "strings" +) + +// RunOpts configures how a subprocess is executed. +type RunOpts struct { + Stdin string // input to pass via stdin + Verbose bool // pipe stdout/stderr to os.Stdout/os.Stderr + Dir string // working directory + Env []string // environment variables (KEY=VALUE) +} + +// Run executes the named program with the given arguments. +// +// When Verbose is true stdout and stderr are forwarded to the process's +// os.Stdout / os.Stderr. Otherwise output is silently discarded. +// A non-zero exit code is returned as an *ExitError. +func Run(name string, args []string, opts RunOpts) error { + cmd := exec.Command(name, args...) + + if opts.Dir != "" { + cmd.Dir = opts.Dir + } + if len(opts.Env) > 0 { + cmd.Env = append(os.Environ(), opts.Env...) + } + + if opts.Stdin != "" { + cmd.Stdin = strings.NewReader(opts.Stdin) + } + + if opts.Verbose { + if opts.Stdin != "" { + cmdStr := fmt.Sprintf("+ %s %s", name, strings.Join(args, " ")) + fmt.Printf("%s <\n%s\n", cmdStr, strings.Join( + nonEmptyLines(opts.Stdin), "\n")) + } else { + fmt.Printf("+ %s %s\n", name, strings.Join(args, " ")) + } + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + } else { + cmd.Stdout = io.Discard + cmd.Stderr = io.Discard + } + + return cmd.Run() +} + +// RunCollect executes the named program and collects stdout and stderr. +// Both are returned as strings. A non-zero exit code results in a non-nil error. +func RunCollect(name string, args []string) (stdout, stderr string, err error) { + cmd := exec.Command(name, args...) + + var outBuf, errBuf bytes.Buffer + cmd.Stdout = &outBuf + cmd.Stderr = &errBuf + + err = cmd.Run() + return outBuf.String(), errBuf.String(), err +} + +// RunWithCallback executes the named program and invokes onStdout for each +// line of stdout output. If onStdout returns true the callback is no longer +// called and remaining stdout is forwarded directly to os.Stdout (matching +// the Python CLI's monitor_stream behaviour). +// Stderr is always forwarded to os.Stderr. +func RunWithCallback(name string, args []string, onStdout func(string) bool) error { + cmd := exec.Command(name, args...) + cmd.Stderr = os.Stderr + + pipe, err := cmd.StdoutPipe() + if err != nil { + return fmt.Errorf("cannot create stdout pipe: %w", err) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("cannot start command: %w", err) + } + + scanner := bufio.NewScanner(pipe) + stopped := false + for scanner.Scan() { + line := scanner.Text() + if stopped { + // After callback signalled stop, forward remaining output. + fmt.Fprintln(os.Stdout, line) + continue + } + if onStdout(line) { + stopped = true + } + } + if scanErr := scanner.Err(); scanErr != nil { + // Drain but ignore read errors on stdout — the exit code matters. + _ = scanErr + } + + return cmd.Wait() +} + +// nonEmptyLines splits s on newlines and returns lines that are not empty. +func nonEmptyLines(s string) []string { + var out []string + for _, line := range strings.Split(s, "\n") { + if line != "" { + out = append(out, line) + } + } + return out +} diff --git a/libs/cli/internal/root/root.go b/libs/cli/internal/root/root.go new file mode 100644 index 000000000..234603b08 --- /dev/null +++ b/libs/cli/internal/root/root.go @@ -0,0 +1,1409 @@ +package root + +import ( + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/langchain-ai/langgraph/libs/cli/internal/config" + "github.com/langchain-ai/langgraph/libs/cli/internal/deploy" + "github.com/langchain-ai/langgraph/libs/cli/internal/docker" + lgexec "github.com/langchain-ai/langgraph/libs/cli/internal/exec" + "github.com/langchain-ai/langgraph/libs/cli/internal/templates" + "github.com/langchain-ai/langgraph/libs/cli/internal/version" +) + +const helpText = `Usage: langgraph [OPTIONS] COMMAND [ARGS]... + + LangGraph CLI + +Options: + --version Show the version and exit. + --help Show this message and exit. + +Commands: + build Build LangGraph API server Docker image. + deploy Build and deploy to LangSmith. + dev Run LangGraph API server in development mode. + dockerfile Generate a Dockerfile for the LangGraph API server. + new Create a new LangGraph project from a template. + up Launch LangGraph API server. + validate Validate the LangGraph configuration file.` + +func Run(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + _, _ = fmt.Fprintln(stdout, helpText) + return 0 + } + + switch args[0] { + case "help", "--help", "-h": + _, _ = fmt.Fprintln(stdout, helpText) + return 0 + case "version", "--version", "-V": + _, _ = fmt.Fprintf( + stdout, + "langgraph %s (commit: %s, built: %s)\n", + version.Version, + version.Commit, + version.Date, + ) + return 0 + case "validate": + return runValidate(args[1:], stdout, stderr) + case "build": + return runBuild(args[1:], stdout, stderr) + case "dockerfile": + return runDockerfile(args[1:], stdout, stderr) + case "up": + return runUp(args[1:], stdout, stderr) + case "dev": + return runDev(args[1:], stdout, stderr) + case "new": + return runNew(args[1:], stdout, stderr) + case "deploy": + return runDeploy(args[1:], stdout, stderr) + default: + _, _ = fmt.Fprintf( + stderr, + "langgraph: %q is not a langgraph command. See 'langgraph --help'.\n", + args[0], + ) + return 1 + } +} + +// --------------------------------------------------------------------------- +// ANSI colors +// --------------------------------------------------------------------------- + +const ( + colorReset = "\033[0m" + colorRed = "\033[31m" + colorGreen = "\033[32m" + colorYellow = "\033[33m" + colorCyan = "\033[36m" +) + +func errPrint(w io.Writer, msg string) { + _, _ = fmt.Fprintf(w, "%sError: %s%s\n", colorRed, msg, colorReset) +} + +// --------------------------------------------------------------------------- +// Common flag parsing helpers +// --------------------------------------------------------------------------- + +type commonFlags struct { + configPath string + baseImage string + apiVersion string + tag string + port int + pull bool + verbose bool + watch bool + engineRuntimeMode string + installCommand string + buildCommand string + dockerCompose string + postgresURI string + debuggerPort int + debuggerBaseURL string + image string + wait bool + passthrough []string +} + +func newCommonFlags() commonFlags { + return commonFlags{ + configPath: "langgraph.json", + port: 8123, + pull: true, + engineRuntimeMode: "combined_queue_worker", + } +} + +// parseFlags is a minimal flag parser. Unknown flags after "--" or positional +// args are collected into passthrough. +func parseFlags(args []string, flags *commonFlags) []string { + var positional []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "-c", "--config": + if i+1 < len(args) { + flags.configPath = args[i+1] + i++ + } + case "-t", "--tag": + if i+1 < len(args) { + flags.tag = args[i+1] + i++ + } + case "-p", "--port": + if i+1 < len(args) { + if n, err := strconv.Atoi(args[i+1]); err == nil { + flags.port = n + } + i++ + } + case "--base-image": + if i+1 < len(args) { + flags.baseImage = args[i+1] + i++ + } + case "--api-version": + if i+1 < len(args) { + flags.apiVersion = args[i+1] + i++ + } + case "--engine-runtime-mode": + if i+1 < len(args) { + flags.engineRuntimeMode = args[i+1] + i++ + } + case "--install-command": + if i+1 < len(args) { + flags.installCommand = args[i+1] + i++ + } + case "--build-command": + if i+1 < len(args) { + flags.buildCommand = args[i+1] + i++ + } + case "--docker-compose", "-d": + if i+1 < len(args) { + flags.dockerCompose = args[i+1] + i++ + } + case "--postgres-uri": + if i+1 < len(args) { + flags.postgresURI = args[i+1] + i++ + } + case "--debugger-port": + if i+1 < len(args) { + if n, err := strconv.Atoi(args[i+1]); err == nil { + flags.debuggerPort = n + } + i++ + } + case "--debugger-base-url": + if i+1 < len(args) { + flags.debuggerBaseURL = args[i+1] + i++ + } + case "--image": + if i+1 < len(args) { + flags.image = args[i+1] + i++ + } + case "--pull": + flags.pull = true + case "--no-pull": + flags.pull = false + case "--verbose": + flags.verbose = true + case "--watch": + flags.watch = true + case "--wait": + flags.wait = true + case "--recreate", "--no-recreate": + // accepted but ignored in Go CLI (compose handles it) + default: + positional = append(positional, args[i]) + } + } + return positional +} + +// loadAndValidateConfig reads and validates langgraph.json. Returns the raw +// and validated config, or writes an error and returns nil. +func loadAndValidateConfig(configPath string, stderr io.Writer) (map[string]any, map[string]any, bool) { + if _, err := os.Stat(configPath); os.IsNotExist(err) { + errPrint(stderr, fmt.Sprintf("Path '%s' does not exist.", configPath)) + return nil, nil, false + } + data, err := os.ReadFile(configPath) + if err != nil { + errPrint(stderr, err.Error()) + return nil, nil, false + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + errPrint(stderr, fmt.Sprintf("Invalid JSON in %s: %s", configPath, err.Error())) + return nil, nil, false + } + validated, err := config.ValidateConfig(raw) + if err != nil { + errPrint(stderr, err.Error()) + return nil, nil, false + } + return raw, validated, true +} + +// --------------------------------------------------------------------------- +// validate +// --------------------------------------------------------------------------- + +func runValidate(args []string, stdout, stderr io.Writer) int { + configPath := "langgraph.json" + + for i := 0; i < len(args); i++ { + switch args[i] { + case "-c", "--config": + if i+1 < len(args) { + configPath = args[i+1] + i++ + } else { + _, _ = fmt.Fprintln(stderr, "Error: --config requires a path argument") + return 1 + } + case "--help", "-h": + _, _ = fmt.Fprintln(stdout, `Usage: langgraph validate [OPTIONS] + + Validate the LangGraph configuration file. + +Options: + -c, --config PATH Path to configuration file (default: langgraph.json) + --help Show this message and exit.`) + return 0 + default: + _, _ = fmt.Fprintf(stderr, "Error: unexpected argument %q\n", args[i]) + return 1 + } + } + + if _, err := os.Stat(configPath); os.IsNotExist(err) { + _, _ = fmt.Fprintf(stderr, "Error: Path '%s' does not exist.\n", configPath) + return 1 + } + + data, err := os.ReadFile(configPath) + if err != nil { + _, _ = fmt.Fprintf(stderr, "%sError: %s%s\n", colorRed, err, colorReset) + return 1 + } + var rawConfig map[string]any + if err := json.Unmarshal(data, &rawConfig); err != nil { + _, _ = fmt.Fprintf(stderr, "Error: Invalid JSON in %s: %s\n", configPath, err.Error()) + return 1 + } + + unknownWarnings := config.GetUnknownKeys(rawConfig) + + validated, validErr := config.ValidateConfig(rawConfig) + if validErr != nil { + _, _ = fmt.Fprintf(stderr, "%sError: %s%s\n", colorRed, validErr, colorReset) + if len(unknownWarnings) > 0 { + _, _ = fmt.Fprintln(stderr) + for _, w := range unknownWarnings { + _, _ = fmt.Fprintf(stderr, " %swarning: %s%s\n", colorYellow, w, colorReset) + } + } + return 1 + } + + graphs, _ := validated["graphs"].(map[string]any) + numGraphs := len(graphs) + plural := "s" + if numGraphs == 1 { + plural = "" + } + _, _ = fmt.Fprintf(stdout, + "%sConfiguration file %s is valid. (%d graph%s found)%s\n", + colorGreen, configPath, numGraphs, plural, colorReset) + + if len(unknownWarnings) > 0 { + _, _ = fmt.Fprintln(stdout) + for _, w := range unknownWarnings { + _, _ = fmt.Fprintf(stdout, " %swarning: %s%s\n", colorYellow, w, colorReset) + } + } + + return 0 +} + +// --------------------------------------------------------------------------- +// build +// --------------------------------------------------------------------------- + +func runBuild(args []string, stdout, stderr io.Writer) int { + for _, a := range args { + if a == "--help" || a == "-h" { + _, _ = fmt.Fprintln(stdout, `Usage: langgraph build [OPTIONS] [DOCKER_BUILD_ARGS]... + + Build LangGraph API server Docker image. + +Options: + -c, --config PATH Path to configuration file (default: langgraph.json) + -t, --tag TEXT Tag for the docker image. [required] + --pull / --no-pull Pull latest images. (default: pull) + --base-image TEXT Base image for the LangGraph API server. + --api-version TEXT API server version for the base image. + --engine-runtime-mode TEXT Runtime mode (combined_queue_worker or distributed). + --install-command TEXT Custom install command. + --build-command TEXT Custom build command. + --help Show this message and exit.`) + return 0 + } + } + + flags := newCommonFlags() + passthrough := parseFlags(args, &flags) + flags.passthrough = passthrough + + if flags.tag == "" { + errPrint(stderr, "Missing option '--tag' / '-t'.") + return 1 + } + + _, validated, ok := loadAndValidateConfig(flags.configPath, stderr) + if !ok { + return 1 + } + + baseImage := flags.baseImage + if baseImage == "" { + baseImage = config.DefaultBaseImage(validated, flags.engineRuntimeMode) + } + + // Pull base image + if flags.pull { + tag := config.DockerTag(validated, baseImage, flags.apiVersion) + _, _ = fmt.Fprintf(stdout, "Pulling %s...\n", tag) + if err := lgexec.Run("docker", []string{"pull", tag}, lgexec.RunOpts{Verbose: true}); err != nil { + _, _ = fmt.Fprintf(stderr, "%sWarning: failed to pull image: %s%s\n", colorYellow, err, colorReset) + } + } + + _, _ = fmt.Fprintln(stdout, "Building...") + + configJSON := deepCopyMap(validated) + dockerfile, contexts, err := config.ConfigToDocker(flags.configPath, configJSON, config.DockerOpts{ + BaseImage: baseImage, + APIVersion: flags.apiVersion, + InstallCommand: flags.installCommand, + BuildCommand: flags.buildCommand, + }) + if err != nil { + errPrint(stderr, err.Error()) + return 1 + } + + buildArgs := []string{"build", "-f", "-", "-t", flags.tag} + for k, v := range contexts { + buildArgs = append(buildArgs, "--build-context", fmt.Sprintf("%s=%s", k, v)) + } + buildArgs = append(buildArgs, flags.passthrough...) + + buildContext := filepath.Dir(absPath(flags.configPath)) + buildArgs = append(buildArgs, buildContext) + + if err := lgexec.Run("docker", buildArgs, lgexec.RunOpts{ + Stdin: dockerfile, + Verbose: true, + }); err != nil { + errPrint(stderr, fmt.Sprintf("Docker build failed: %s", err)) + return 1 + } + + _, _ = fmt.Fprintf(stdout, "%sSuccessfully built image: %s%s\n", colorGreen, flags.tag, colorReset) + return 0 +} + +// --------------------------------------------------------------------------- +// dockerfile +// --------------------------------------------------------------------------- + +func runDockerfile(args []string, stdout, stderr io.Writer) int { + for _, a := range args { + if a == "--help" || a == "-h" { + _, _ = fmt.Fprintln(stdout, `Usage: langgraph dockerfile [OPTIONS] SAVE_PATH + + Generate a Dockerfile for the LangGraph API server. + +Options: + -c, --config PATH Path to configuration file (default: langgraph.json) + --base-image TEXT Base image for the LangGraph API server. + --api-version TEXT API server version for the base image. + --engine-runtime-mode TEXT Runtime mode (combined_queue_worker or distributed). + --add-docker-compose Add docker-compose.yml and supporting files. + --help Show this message and exit.`) + return 0 + } + } + + flags := newCommonFlags() + addCompose := false + var positional []string + for i := 0; i < len(args); i++ { + switch args[i] { + case "-c", "--config": + if i+1 < len(args) { + flags.configPath = args[i+1] + i++ + } + case "--base-image": + if i+1 < len(args) { + flags.baseImage = args[i+1] + i++ + } + case "--api-version": + if i+1 < len(args) { + flags.apiVersion = args[i+1] + i++ + } + case "--engine-runtime-mode": + if i+1 < len(args) { + flags.engineRuntimeMode = args[i+1] + i++ + } + case "--add-docker-compose": + addCompose = true + default: + positional = append(positional, args[i]) + } + } + + if len(positional) < 1 { + errPrint(stderr, "Missing argument 'SAVE_PATH'.") + return 1 + } + savePath := positional[0] + + _, validated, ok := loadAndValidateConfig(flags.configPath, stderr) + if !ok { + return 1 + } + + baseImage := flags.baseImage + if baseImage == "" { + baseImage = config.DefaultBaseImage(validated, flags.engineRuntimeMode) + } + + configJSON := deepCopyMap(validated) + dockerfile, _, err := config.ConfigToDocker(flags.configPath, configJSON, config.DockerOpts{ + BaseImage: baseImage, + APIVersion: flags.apiVersion, + }) + if err != nil { + errPrint(stderr, err.Error()) + return 1 + } + + if err := os.WriteFile(savePath, []byte(dockerfile), 0644); err != nil { + errPrint(stderr, err.Error()) + return 1 + } + _, _ = fmt.Fprintf(stdout, "%sDockerfile written to %s%s\n", colorGreen, savePath, colorReset) + + if addCompose { + dir := filepath.Dir(savePath) + composeStr, cerr := config.ConfigToCompose(flags.configPath, validated, config.ComposeOpts{ + BaseImage: baseImage, + APIVersion: flags.apiVersion, + EngineRuntimeMode: flags.engineRuntimeMode, + }) + if cerr != nil { + errPrint(stderr, cerr.Error()) + return 1 + } + composePath := filepath.Join(dir, "docker-compose.yml") + if err := os.WriteFile(composePath, []byte(composeStr), 0644); err != nil { + errPrint(stderr, err.Error()) + return 1 + } + _, _ = fmt.Fprintf(stdout, "%sDocker compose written to %s%s\n", colorGreen, composePath, colorReset) + } + + return 0 +} + +// --------------------------------------------------------------------------- +// up +// --------------------------------------------------------------------------- + +func runUp(args []string, stdout, stderr io.Writer) int { + for _, a := range args { + if a == "--help" || a == "-h" { + _, _ = fmt.Fprintln(stdout, `Usage: langgraph up [OPTIONS] + + Launch LangGraph API server. + +Options: + -c, --config PATH Path to configuration file (default: langgraph.json) + -p, --port INTEGER Port to expose (default: 8123) + --pull / --no-pull Pull latest images (default: pull) + --recreate / --no-recreate Recreate containers + --verbose Show more output + --watch Restart on file changes + --wait Wait for services to start + --postgres-uri TEXT Postgres URI for database + --debugger-port INTEGER Port for the debugger UI + --debugger-base-url TEXT URL for debugger to access LangGraph API + --base-image TEXT Base image for the LangGraph API server + --api-version TEXT API server version for the base image + --engine-runtime-mode TEXT Runtime mode + --image TEXT Pre-built Docker image to use + --help Show this message and exit.`) + return 0 + } + } + + flags := newCommonFlags() + parseFlags(args, &flags) + + caps, err := docker.CheckCapabilities() + if err != nil { + errPrint(stderr, err.Error()) + return 1 + } + + _, validated, ok := loadAndValidateConfig(flags.configPath, stderr) + if !ok { + return 1 + } + + baseImage := flags.baseImage + if baseImage == "" { + baseImage = config.DefaultBaseImage(validated, flags.engineRuntimeMode) + } + + // Generate compose YAML + composeSnippet, cerr := config.ConfigToCompose(flags.configPath, validated, config.ComposeOpts{ + BaseImage: baseImage, + APIVersion: flags.apiVersion, + Image: flags.image, + Watch: flags.watch, + EngineRuntimeMode: flags.engineRuntimeMode, + }) + if cerr != nil { + errPrint(stderr, cerr.Error()) + return 1 + } + + infraYAML := docker.Compose(caps, docker.ComposeOpts{ + Port: flags.port, + DebuggerPort: flags.debuggerPort, + DebuggerBaseURL: flags.debuggerBaseURL, + PostgresURI: flags.postgresURI, + Image: flags.image, + EngineRuntimeMode: flags.engineRuntimeMode, + }) + + // Merge compose: infra + app overlay + fullCompose := infraYAML + composeSnippet + + // Pull + if flags.pull && flags.image == "" { + tag := config.DockerTag(validated, baseImage, flags.apiVersion) + _, _ = fmt.Fprintf(stdout, "Pulling %s...\n", tag) + _ = lgexec.Run("docker", []string{"pull", tag}, lgexec.RunOpts{Verbose: flags.verbose}) + } + + // Write compose to temp file and run + tmpFile, err := os.CreateTemp("", "langgraph-compose-*.yml") + if err != nil { + errPrint(stderr, err.Error()) + return 1 + } + defer os.Remove(tmpFile.Name()) + _, _ = tmpFile.WriteString(fullCompose) + tmpFile.Close() + + composeCmd := "docker" + composeArgs := []string{"compose"} + if caps.ComposeType == "standalone" { + composeCmd = "docker-compose" + composeArgs = nil + } + + upArgs := append(composeArgs, "-f", tmpFile.Name(), "up") + if flags.wait { + upArgs = append(upArgs, "--wait") + } else { + upArgs = append(upArgs, "-d") + } + + _, _ = fmt.Fprintf(stdout, "%sStarting LangGraph API server...%s\n", colorCyan, colorReset) + + if err := lgexec.Run(composeCmd, upArgs, lgexec.RunOpts{ + Verbose: true, + Dir: filepath.Dir(absPath(flags.configPath)), + }); err != nil { + errPrint(stderr, fmt.Sprintf("docker compose up failed: %s", err)) + return 1 + } + + _, _ = fmt.Fprintf(stdout, "%sLangGraph API server is running at http://localhost:%d%s\n", + colorGreen, flags.port, colorReset) + return 0 +} + +// --------------------------------------------------------------------------- +// dev +// --------------------------------------------------------------------------- + +func runDev(args []string, stdout, stderr io.Writer) int { + for _, a := range args { + if a == "--help" || a == "-h" { + _, _ = fmt.Fprintln(stdout, `Usage: langgraph dev [OPTIONS] + + Run LangGraph API server in development mode with hot reloading. + +Options: + --host TEXT Host to bind to (default: 127.0.0.1) + --port INTEGER Port to bind to (default: 2024) + --config PATH Path to configuration file (default: langgraph.json) + --no-reload Disable auto-reloading + --no-browser Skip opening the browser + --debug-port INTEGER Enable remote debugging on port + --allow-blocking Allow blocking I/O operations + --tunnel Expose via public tunnel + --help Show this message and exit.`) + return 0 + } + } + + // Dev command: subprocess back into Python. + // The Go CLI handles argument parsing, but the actual dev server runs in Python. + pythonExe := os.Getenv("LANGGRAPH_CALLING_PYTHON") + if pythonExe == "" { + // Try to find python in the environment + for _, name := range []string{"python3", "python"} { + if p, _, err := lgexec.RunCollect("which", []string{name}); err == nil && strings.TrimSpace(p) != "" { + pythonExe = strings.TrimSpace(p) + break + } + } + } + if pythonExe == "" { + errPrint(stderr, "Could not find Python interpreter. Set LANGGRAPH_CALLING_PYTHON.") + return 1 + } + + // Forward all args to the Python dev command + pyArgs := append([]string{"-m", "langgraph_cli.cli", "dev"}, args...) + if err := lgexec.Run(pythonExe, pyArgs, lgexec.RunOpts{Verbose: true}); err != nil { + return 1 + } + return 0 +} + +// --------------------------------------------------------------------------- +// new +// --------------------------------------------------------------------------- + +func runNew(args []string, stdout, stderr io.Writer) int { + for _, a := range args { + if a == "--help" || a == "-h" { + _, _ = fmt.Fprintln(stdout, `Usage: langgraph new [OPTIONS] [PATH] + + Create a new LangGraph project from a template. + +Options: + --template TEXT Template ID to use. + --help Show this message and exit. + +Available templates: +`+templates.TemplateHelp()) + return 0 + } + } + + var path, template string + for i := 0; i < len(args); i++ { + switch args[i] { + case "--template": + if i+1 < len(args) { + template = args[i+1] + i++ + } + default: + if path == "" { + path = args[i] + } + } + } + + if path == "" { + errPrint(stderr, "Missing argument 'PATH'. Usage: langgraph new [--template ID] PATH") + return 1 + } + if template == "" { + errPrint(stderr, "Missing option '--template'. Available templates:\n"+templates.TemplateHelp()) + return 1 + } + + if err := templates.CreateNew(path, template); err != nil { + errPrint(stderr, err.Error()) + return 1 + } + + _, _ = fmt.Fprintf(stdout, "%sCreated new LangGraph project at %s%s\n", colorGreen, path, colorReset) + return 0 +} + +// --------------------------------------------------------------------------- +// deploy +// --------------------------------------------------------------------------- + +func runDeploy(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + return runDeployMain(args, stdout, stderr) + } + switch args[0] { + case "--help", "-h": + _, _ = fmt.Fprintln(stdout, `Usage: langgraph deploy [OPTIONS] COMMAND [ARGS]... + + [Beta] Build and deploy a LangGraph image to LangSmith Deployment. + +Commands: + list List LangSmith Deployments. + revisions Manage deployment revisions. + delete Delete a LangSmith Deployment. + logs Fetch LangSmith Deployment logs. + +Run 'langgraph deploy COMMAND --help' for more information on a command. + +If no subcommand is given, deploys the current project.`) + return 0 + case "list": + return runDeployList(args[1:], stdout, stderr) + case "revisions": + return runDeployRevisions(args[1:], stdout, stderr) + case "delete": + return runDeployDelete(args[1:], stdout, stderr) + case "logs": + return runDeployLogs(args[1:], stdout, stderr) + default: + // No subcommand → main deploy flow + return runDeployMain(args, stdout, stderr) + } +} + +func resolveDeployClient(args []string, stderr io.Writer) (apiKey, hostURL string, extra []string) { + hostURL = deploy.DefaultHostURL + for i := 0; i < len(args); i++ { + switch args[i] { + case "--api-key": + if i+1 < len(args) { + apiKey = args[i+1] + i++ + } + case "--host-url": + if i+1 < len(args) { + hostURL = args[i+1] + i++ + } + default: + extra = append(extra, args[i]) + } + } + if apiKey == "" { + apiKey = deploy.ResolveAPIKey("", nil) + } + if apiKey == "" { + errPrint(stderr, "API key required. Set --api-key or LANGSMITH_API_KEY environment variable.") + } + return +} + +func runDeployMain(args []string, stdout, stderr io.Writer) int { + for _, a := range args { + if a == "--help" || a == "-h" { + _, _ = fmt.Fprintln(stdout, `Usage: langgraph deploy [OPTIONS] + + Build and deploy a LangGraph image to LangSmith. + +Options: + --api-key TEXT LangSmith API key + --name TEXT Deployment name + --deployment-id TEXT Existing deployment ID + --deployment-type TEXT dev or prod (default: dev) + -c, --config PATH Path to config (default: langgraph.json) + --no-wait Skip waiting for deployment + --verbose Show more output + --remote / --no-remote Force remote or local build + -t, --tag TEXT Image tag (default: latest) + --base-image TEXT Base image + --help Show this message and exit.`) + return 0 + } + } + + // Parse deploy-specific flags + var ( + apiKey, hostURL, name, deploymentID, deploymentType, tag string + noWait, verbose, remote bool + ) + flags := newCommonFlags() + deploymentType = "dev" + tag = "latest" + + for i := 0; i < len(args); i++ { + switch args[i] { + case "--api-key": + if i+1 < len(args) { + apiKey = args[i+1] + i++ + } + case "--host-url": + if i+1 < len(args) { + hostURL = args[i+1] + i++ + } + case "--name": + if i+1 < len(args) { + name = args[i+1] + i++ + } + case "--deployment-id": + if i+1 < len(args) { + deploymentID = args[i+1] + i++ + } + case "--deployment-type": + if i+1 < len(args) { + deploymentType = args[i+1] + i++ + } + case "-t", "--tag": + if i+1 < len(args) { + tag = args[i+1] + i++ + } + case "--no-wait": + noWait = true + case "--verbose": + verbose = true + case "--remote": + remote = true + default: + // Parse common flags + switch args[i] { + case "-c", "--config": + if i+1 < len(args) { + flags.configPath = args[i+1] + i++ + } + case "--base-image": + if i+1 < len(args) { + flags.baseImage = args[i+1] + i++ + } + case "--api-version": + if i+1 < len(args) { + flags.apiVersion = args[i+1] + i++ + } + case "--pull", "--no-pull": + // accept + } + } + } + + if hostURL == "" { + hostURL = deploy.DefaultHostURL + } + if apiKey == "" { + apiKey = deploy.ResolveAPIKey("", nil) + } + if apiKey == "" { + errPrint(stderr, "API key required. Set --api-key or LANGSMITH_API_KEY environment variable.") + return 1 + } + + _, validated, ok := loadAndValidateConfig(flags.configPath, stderr) + if !ok { + return 1 + } + + _ = tag + _ = deploymentType + _ = noWait + _ = verbose + _ = remote + _ = name + _ = deploymentID + _ = validated + + client := deploy.NewClient(hostURL, apiKey) + + // Resolve deployment name + if name == "" && deploymentID == "" { + // Default to current directory name + absConfig, _ := filepath.Abs(flags.configPath) + name = filepath.Base(filepath.Dir(absConfig)) + } + + // Find or create deployment + var depID string + if deploymentID != "" { + depID = deploymentID + } else { + found, err := deploy.FindDeploymentIDByName(client, name) + if err != nil { + errPrint(stderr, err.Error()) + return 1 + } + if found != "" { + depID = found + _, _ = fmt.Fprintf(stdout, "Found existing deployment: %s\n", depID) + } else { + _, _ = fmt.Fprintf(stdout, "Creating deployment '%s'...\n", name) + resp, err := client.CreateDeployment(name, deploymentType, "internal_docker", "", nil) + if err != nil { + errPrint(stderr, err.Error()) + return 1 + } + depID, _ = resp["id"].(string) + _, _ = fmt.Fprintf(stdout, "Created deployment: %s\n", depID) + } + } + + // Build locally + baseImage := flags.baseImage + if baseImage == "" { + baseImage = config.DefaultBaseImage(validated, "combined_queue_worker") + } + imgTag := fmt.Sprintf("langgraph-%s:%s", deploy.NormalizeImageName(name), tag) + + _, _ = fmt.Fprintln(stdout, "Building image...") + buildConfig := deepCopyMap(validated) + dockerfile, contexts, err := config.ConfigToDocker(flags.configPath, buildConfig, config.DockerOpts{ + BaseImage: baseImage, + APIVersion: flags.apiVersion, + }) + if err != nil { + errPrint(stderr, err.Error()) + return 1 + } + + buildArgs := []string{"build", "-f", "-", "-t", imgTag} + for k, v := range contexts { + buildArgs = append(buildArgs, "--build-context", fmt.Sprintf("%s=%s", k, v)) + } + buildArgs = append(buildArgs, filepath.Dir(absPath(flags.configPath))) + + if err := lgexec.Run("docker", buildArgs, lgexec.RunOpts{Stdin: dockerfile, Verbose: verbose}); err != nil { + errPrint(stderr, fmt.Sprintf("Build failed: %s", err)) + return 1 + } + + // Push + _, _ = fmt.Fprintln(stdout, "Requesting push token...") + tokenResp, err := client.RequestPushToken(depID) + if err != nil { + errPrint(stderr, err.Error()) + return 1 + } + registryURL, _ := tokenResp["registry_url"].(string) + token, _ := tokenResp["token"].(string) + + remoteTag := fmt.Sprintf("%s:%s", registryURL, tag) + _ = lgexec.Run("docker", []string{"tag", imgTag, remoteTag}, lgexec.RunOpts{}) + _ = lgexec.Run("docker", []string{"login", "-u", "oauth2accesstoken", "-p", token, registryURL}, lgexec.RunOpts{}) + + _, _ = fmt.Fprintln(stdout, "Pushing image...") + if err := lgexec.Run("docker", []string{"push", remoteTag}, lgexec.RunOpts{Verbose: verbose}); err != nil { + errPrint(stderr, fmt.Sprintf("Push failed: %s", err)) + return 1 + } + + // Update deployment + _, _ = fmt.Fprintln(stdout, "Updating deployment...") + envVars := deploy.ParseEnvFromConfig(validated, flags.configPath) + secrets := deploy.SecretsFromEnv(envVars) + + _, err = client.UpdateDeployment(depID, remoteTag, secrets) + if err != nil { + errPrint(stderr, err.Error()) + return 1 + } + + _, _ = fmt.Fprintf(stdout, "%sDeployment updated successfully!%s\n", colorGreen, colorReset) + return 0 +} + +func runDeployList(args []string, stdout, stderr io.Writer) int { + for _, a := range args { + if a == "--help" || a == "-h" { + _, _ = fmt.Fprintln(stdout, `Usage: langgraph deploy list [OPTIONS] + + List LangSmith Deployments. + +Options: + --api-key TEXT API key + --name-contains TEXT Filter by name + --help Show this message and exit.`) + return 0 + } + } + + var nameContains string + apiKey, hostURL, extra := resolveDeployClient(args, stderr) + if apiKey == "" { + return 1 + } + for i := 0; i < len(extra); i++ { + if extra[i] == "--name-contains" && i+1 < len(extra) { + nameContains = extra[i+1] + i++ + } + } + + client := deploy.NewClient(hostURL, apiKey) + resp, err := client.ListDeployments(nameContains) + if err != nil { + errPrint(stderr, err.Error()) + return 1 + } + + deployments, _ := resp["deployments"].([]any) + if len(deployments) == 0 { + _, _ = fmt.Fprintln(stdout, "No deployments found.") + return 0 + } + + // Format table + _, _ = fmt.Fprintf(stdout, "%-38s %-30s %s\n", "Deployment ID", "Name", "URL") + _, _ = fmt.Fprintf(stdout, "%-38s %-30s %s\n", strings.Repeat("-", 38), strings.Repeat("-", 30), strings.Repeat("-", 40)) + for _, d := range deployments { + dep, _ := d.(map[string]any) + id, _ := dep["id"].(string) + name, _ := dep["name"].(string) + url := "-" + if sc, ok := dep["source_config"].(map[string]any); ok { + if u, ok := sc["custom_url"].(string); ok && u != "" { + url = u + } + } + _, _ = fmt.Fprintf(stdout, "%-38s %-30s %s\n", id, name, url) + } + return 0 +} + +func runDeployRevisions(args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + _, _ = fmt.Fprintln(stdout, `Usage: langgraph deploy revisions COMMAND [ARGS]... + +Commands: + list List revisions for a deployment.`) + return 0 + } + switch args[0] { + case "list": + return runDeployRevisionsList(args[1:], stdout, stderr) + case "--help", "-h": + _, _ = fmt.Fprintln(stdout, `Usage: langgraph deploy revisions COMMAND [ARGS]... + +Commands: + list List revisions for a deployment.`) + return 0 + default: + errPrint(stderr, fmt.Sprintf("Unknown revisions command: %s", args[0])) + return 1 + } +} + +func runDeployRevisionsList(args []string, stdout, stderr io.Writer) int { + for _, a := range args { + if a == "--help" || a == "-h" { + _, _ = fmt.Fprintln(stdout, `Usage: langgraph deploy revisions list [OPTIONS] DEPLOYMENT_ID + + List revisions for a LangSmith Deployment. + +Options: + --api-key TEXT API key + --limit INTEGER Max revisions (default: 10) + --help Show this message and exit.`) + return 0 + } + } + + limit := 10 + apiKey, hostURL, extra := resolveDeployClient(args, stderr) + if apiKey == "" { + return 1 + } + + var deploymentID string + for i := 0; i < len(extra); i++ { + if extra[i] == "--limit" && i+1 < len(extra) { + if n, err := strconv.Atoi(extra[i+1]); err == nil { + limit = n + } + i++ + } else if !strings.HasPrefix(extra[i], "-") && deploymentID == "" { + deploymentID = extra[i] + } + } + + if deploymentID == "" { + errPrint(stderr, "Missing argument 'DEPLOYMENT_ID'.") + return 1 + } + + client := deploy.NewClient(hostURL, apiKey) + resp, err := client.ListRevisions(deploymentID, limit) + if err != nil { + errPrint(stderr, err.Error()) + return 1 + } + + revisions, _ := resp["revisions"].([]any) + if len(revisions) == 0 { + _, _ = fmt.Fprintln(stdout, "No revisions found.") + return 0 + } + + _, _ = fmt.Fprintf(stdout, "%-38s %-15s %s\n", "Revision ID", "Status", "Created At") + _, _ = fmt.Fprintf(stdout, "%-38s %-15s %s\n", strings.Repeat("-", 38), strings.Repeat("-", 15), strings.Repeat("-", 25)) + for _, r := range revisions { + rev, _ := r.(map[string]any) + id, _ := rev["id"].(string) + status, _ := rev["status"].(string) + created, _ := rev["created_at"].(string) + _, _ = fmt.Fprintf(stdout, "%-38s %-15s %s\n", id, status, created) + } + return 0 +} + +func runDeployDelete(args []string, stdout, stderr io.Writer) int { + for _, a := range args { + if a == "--help" || a == "-h" { + _, _ = fmt.Fprintln(stdout, `Usage: langgraph deploy delete [OPTIONS] DEPLOYMENT_ID + + Delete a LangSmith Deployment. + +Options: + --api-key TEXT API key + --force Delete without confirmation + --help Show this message and exit.`) + return 0 + } + } + + force := false + apiKey, hostURL, extra := resolveDeployClient(args, stderr) + if apiKey == "" { + return 1 + } + + var deploymentID string + for i := 0; i < len(extra); i++ { + if extra[i] == "--force" { + force = true + } else if !strings.HasPrefix(extra[i], "-") && deploymentID == "" { + deploymentID = extra[i] + } + } + + if deploymentID == "" { + errPrint(stderr, "Missing argument 'DEPLOYMENT_ID'.") + return 1 + } + + if !force { + _, _ = fmt.Fprintf(stdout, "Are you sure you want to delete deployment %s? [y/N] ", deploymentID) + var answer string + _, _ = fmt.Fscanln(os.Stdin, &answer) + if answer != "y" && answer != "Y" { + _, _ = fmt.Fprintln(stdout, "Aborted.") + return 0 + } + } + + client := deploy.NewClient(hostURL, apiKey) + if err := client.DeleteDeployment(deploymentID); err != nil { + errPrint(stderr, err.Error()) + return 1 + } + + _, _ = fmt.Fprintf(stdout, "%sDeployment %s deleted.%s\n", colorGreen, deploymentID, colorReset) + return 0 +} + +func runDeployLogs(args []string, stdout, stderr io.Writer) int { + for _, a := range args { + if a == "--help" || a == "-h" { + _, _ = fmt.Fprintln(stdout, `Usage: langgraph deploy logs [OPTIONS] + + Fetch LangSmith Deployment logs. + +Options: + --api-key TEXT API key + --name TEXT Deployment name + --deployment-id TEXT Deployment ID + --type TEXT Log type: deploy or build (default: deploy) + --revision-id TEXT Specific revision ID + --level TEXT Filter by log level + --limit INTEGER Max entries (default: 100) + --query TEXT Search string + --follow Continuously poll for new logs + --help Show this message and exit.`) + return 0 + } + } + + var ( + name, deploymentID, logType, revisionID, level, query string + limit int + follow bool + ) + logType = "deploy" + limit = 100 + + apiKey, hostURL, extra := resolveDeployClient(args, stderr) + if apiKey == "" { + return 1 + } + + for i := 0; i < len(extra); i++ { + switch extra[i] { + case "--name": + if i+1 < len(extra) { + name = extra[i+1] + i++ + } + case "--deployment-id": + if i+1 < len(extra) { + deploymentID = extra[i+1] + i++ + } + case "--type": + if i+1 < len(extra) { + logType = extra[i+1] + i++ + } + case "--revision-id": + if i+1 < len(extra) { + revisionID = extra[i+1] + i++ + } + case "--level": + if i+1 < len(extra) { + level = extra[i+1] + i++ + } + case "--limit": + if i+1 < len(extra) { + if n, err := strconv.Atoi(extra[i+1]); err == nil { + limit = n + } + i++ + } + case "--query", "-q": + if i+1 < len(extra) { + query = extra[i+1] + i++ + } + case "--follow", "-f": + follow = true + } + } + + if deploymentID == "" && name == "" { + errPrint(stderr, "Provide --deployment-id or --name.") + return 1 + } + + client := deploy.NewClient(hostURL, apiKey) + + if deploymentID == "" { + found, err := deploy.FindDeploymentIDByName(client, name) + if err != nil { + errPrint(stderr, err.Error()) + return 1 + } + if found == "" { + errPrint(stderr, fmt.Sprintf("No deployment found with name '%s'.", name)) + return 1 + } + deploymentID = found + } + + payload := map[string]any{ + "limit": limit, + "order": "desc", + } + if level != "" { + payload["level"] = level + } + if query != "" { + payload["query"] = query + } + + _ = follow // TODO: implement follow mode with polling + + var resp map[string]any + var err error + if logType == "build" { + if revisionID == "" { + // Get latest revision + revResp, rerr := client.ListRevisions(deploymentID, 1) + if rerr != nil { + errPrint(stderr, rerr.Error()) + return 1 + } + revisions, _ := revResp["revisions"].([]any) + if len(revisions) > 0 { + rev, _ := revisions[0].(map[string]any) + revisionID, _ = rev["id"].(string) + } + } + if revisionID == "" { + errPrint(stderr, "No revisions found for build logs.") + return 1 + } + resp, err = client.GetBuildLogs(deploymentID, revisionID, payload) + } else { + resp, err = client.GetDeployLogs(deploymentID, payload, revisionID) + } + + if err != nil { + errPrint(stderr, err.Error()) + return 1 + } + + logs, _ := resp["logs"].([]any) + for i := len(logs) - 1; i >= 0; i-- { + entry, _ := logs[i].(map[string]any) + ts, _ := entry["timestamp"].(string) + lvl, _ := entry["level"].(string) + msg, _ := entry["message"].(string) + if ts != "" { + _, _ = fmt.Fprintf(stdout, "[%s] ", ts) + } + if lvl != "" { + _, _ = fmt.Fprintf(stdout, "[%s] ", lvl) + } + _, _ = fmt.Fprintln(stdout, msg) + } + return 0 +} + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +func absPath(p string) string { + abs, err := filepath.Abs(p) + if err != nil { + return p + } + return abs +} + +func deepCopyMap(m map[string]any) map[string]any { + data, _ := json.Marshal(m) + var out map[string]any + _ = json.Unmarshal(data, &out) + return out +} diff --git a/libs/cli/internal/root/root_test.go b/libs/cli/internal/root/root_test.go new file mode 100644 index 000000000..18fb54215 --- /dev/null +++ b/libs/cli/internal/root/root_test.go @@ -0,0 +1,205 @@ +package root + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + exitCode := Run(nil, &stdout, &stderr) + + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d", exitCode) + } + if stderr.Len() != 0 { + t.Fatalf("expected no stderr output, got %q", stderr.String()) + } + if !strings.Contains(stdout.String(), "validate") { + t.Fatalf("expected help text to contain 'validate', got %q", stdout.String()) + } +} + +func TestRunVersion(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + exitCode := Run([]string{"version"}, &stdout, &stderr) + + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d", exitCode) + } + if stderr.Len() != 0 { + t.Fatalf("expected no stderr output, got %q", stderr.String()) + } + if !strings.Contains(stdout.String(), "langgraph") { + t.Fatalf("unexpected stdout: %q", stdout.String()) + } +} + +func TestRunUnknownCommand(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + exitCode := Run([]string{"nonexistent-cmd"}, &stdout, &stderr) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + if !strings.Contains(stderr.String(), "is not a langgraph command") { + t.Fatalf("unexpected stderr: %q", stderr.String()) + } +} + +func writeTempConfig(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "langgraph.json") + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatalf("failed to write temp config: %v", err) + } + return path +} + +func TestRunValidateWithValidConfig(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + path := writeTempConfig(t, `{"dependencies": ["langchain"], "graphs": {"agent": "./agent.py:graph"}}`) + + exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr) + + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d; stderr: %q", exitCode, stderr.String()) + } + if !strings.Contains(stdout.String(), "is valid") { + t.Fatalf("expected stdout to contain 'is valid', got %q", stdout.String()) + } + if !strings.Contains(stdout.String(), "1 graph") { + t.Fatalf("expected stdout to contain '1 graph', got %q", stdout.String()) + } +} + +func TestRunValidateWithInvalidConfig(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + path := writeTempConfig(t, `{"graphs": {}}`) + + exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + if !strings.Contains(stderr.String(), "No graphs found") { + t.Fatalf("expected stderr to contain 'No graphs found', got %q", stderr.String()) + } +} + +func TestRunValidateWithInvalidJSON(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + path := writeTempConfig(t, `{invalid json`) + + exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + if !strings.Contains(stderr.String(), "Invalid JSON") { + t.Fatalf("expected stderr to contain 'Invalid JSON', got %q", stderr.String()) + } +} + +func TestRunValidateDefaultConfigMissing(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + // Use a path that definitely does not exist. + nonexistent := filepath.Join(t.TempDir(), "langgraph.json") + + exitCode := Run([]string{"validate", "-c", nonexistent}, &stdout, &stderr) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + if !strings.Contains(stderr.String(), "does not exist") { + t.Fatalf("expected stderr to contain 'does not exist', got %q", stderr.String()) + } +} + +func TestRunValidateWithUnknownKeys(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + path := writeTempConfig(t, `{"dependencies": ["langchain"], "graphs": {"agent": "./agent.py:graph"}, "grpahs": {}}`) + + exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr) + + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d; stderr: %q", exitCode, stderr.String()) + } + out := stdout.String() + if !strings.Contains(strings.ToLower(out), "warning") { + t.Fatalf("expected stdout to contain 'warning', got %q", out) + } + if !strings.Contains(strings.ToLower(out), "did you mean") { + t.Fatalf("expected stdout to contain 'did you mean', got %q", out) + } +} + +func TestRunValidateHelp(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + exitCode := Run([]string{"validate", "--help"}, &stdout, &stderr) + + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d", exitCode) + } + if !strings.Contains(stdout.String(), "Validate the LangGraph configuration file") { + t.Fatalf("expected stdout to contain validate help text, got %q", stdout.String()) + } +} + +func TestRunValidateMultipleGraphs(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + path := writeTempConfig(t, `{"dependencies": ["langchain"], "graphs": {"agent": "./a.py:g", "bot": "./b.py:g"}}`) + + exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr) + + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d; stderr: %q", exitCode, stderr.String()) + } + if !strings.Contains(stdout.String(), "2 graphs found") { + t.Fatalf("expected stdout to contain '2 graphs found', got %q", stdout.String()) + } +} + +func TestRunValidateWithWarningsAndErrors(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + path := writeTempConfig(t, `{"graphs": {}, "grpahs": {}}`) + + exitCode := Run([]string{"validate", "-c", path}, &stdout, &stderr) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + errOut := stderr.String() + if !strings.Contains(errOut, "No graphs found") { + t.Fatalf("expected stderr to contain 'No graphs found', got %q", errOut) + } + if !strings.Contains(strings.ToLower(errOut), "warning") { + t.Fatalf("expected stderr to contain 'warning', got %q", errOut) + } +} diff --git a/libs/cli/internal/templates/templates.go b/libs/cli/internal/templates/templates.go new file mode 100644 index 000000000..829c3e50e --- /dev/null +++ b/libs/cli/internal/templates/templates.go @@ -0,0 +1,258 @@ +// Package templates provides template definitions and project scaffolding +// for the LangGraph CLI `new` command. +package templates + +import ( + "archive/zip" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strings" +) + +// Template describes a project template with language-specific download URLs. +type Template struct { + Name string + Description string + Languages map[string]string // lang -> download URL +} + +// Templates is the ordered list of available project templates. +var Templates = []Template{ + { + Name: "Deep Agent", + Description: "An opinionated deployment template for a Deep Agent.", + Languages: map[string]string{ + "python": "https://github.com/langchain-ai/deep-agent-template/archive/refs/heads/main.zip", + "js": "https://github.com/langchain-ai/deep-agent-template-js/archive/refs/heads/main.zip", + }, + }, + { + Name: "Agent", + Description: "A simple agent that can be flexibly extended to many tools.", + Languages: map[string]string{ + "python": "https://github.com/langchain-ai/simple-agent-template/archive/refs/heads/main.zip", + }, + }, + { + Name: "New LangGraph Project", + Description: "A simple, minimal chatbot with memory.", + Languages: map[string]string{ + "python": "https://github.com/langchain-ai/new-langgraph-project/archive/refs/heads/main.zip", + "js": "https://github.com/langchain-ai/new-langgraphjs-project/archive/refs/heads/main.zip", + }, + }, +} + +// templateIDEntry maps a template ID to its download URL, template name, and language. +type templateIDEntry struct { + URL string + Name string + Language string +} + +// templateIDMap is built once at init time from the Templates slice. +var templateIDMap map[string]templateIDEntry + +func init() { + templateIDMap = make(map[string]templateIDEntry) + for _, t := range Templates { + for lang, url := range t.Languages { + if lang != "python" && lang != "js" { + continue + } + id := toTemplateID(t.Name, lang) + templateIDMap[id] = templateIDEntry{ + URL: url, + Name: t.Name, + Language: lang, + } + } + } +} + +// toTemplateID converts a template name and language into a slug like "deep-agent-python". +func toTemplateID(name, lang string) string { + return strings.ToLower(strings.ReplaceAll(name, " ", "-")) + "-" + lang +} + +// ListTemplateIDs returns a sorted list of all available template IDs. +func ListTemplateIDs() []string { + ids := make([]string, 0, len(templateIDMap)) + for id := range templateIDMap { + ids = append(ids, id) + } + sort.Strings(ids) + return ids +} + +// TemplateHelp returns a formatted help string listing available templates. +func TemplateHelp() string { + var b strings.Builder + b.WriteString("The name of the template to use. Available options:\n") + for _, id := range ListTemplateIDs() { + entry := templateIDMap[id] + // Find the description from the Templates slice. + var desc string + for _, t := range Templates { + if t.Name == entry.Name { + desc = t.Description + break + } + } + fmt.Fprintf(&b, " %s: %s\n", id, desc) + } + return b.String() +} + +// CreateNew creates a new LangGraph project at path using the given templateID. +// +// If templateID is empty an error listing available templates is returned (the +// Go CLI is non-interactive, so we cannot prompt). If path is empty an error +// is returned. +func CreateNew(path, templateID string) error { + if path == "" { + return fmt.Errorf("path is required: specify the directory for the new project") + } + + // Resolve to absolute path. + absPath, err := filepath.Abs(path) + if err != nil { + return fmt.Errorf("cannot resolve path: %w", err) + } + path = absPath + + // Check if path exists and is not empty. + entries, err := os.ReadDir(path) + if err == nil && len(entries) > 0 { + return fmt.Errorf( + "the specified directory already exists and is not empty: %s. "+ + "Aborting to prevent overwriting files", path) + } + + if templateID == "" { + return fmt.Errorf( + "template is required. Use one of the following template IDs:\n%s", + TemplateHelp()) + } + + entry, ok := templateIDMap[templateID] + if !ok { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("template %q not found.\n", templateID)) + sb.WriteString("Please select from the available options:\n") + for _, id := range ListTemplateIDs() { + e := templateIDMap[id] + var desc string + for _, t := range Templates { + if t.Name == e.Name { + desc = t.Description + break + } + } + fmt.Fprintf(&sb, " - %s: %s\n", id, desc) + } + return fmt.Errorf("%s", sb.String()) + } + + if err := DownloadAndExtract(entry.URL, path); err != nil { + return fmt.Errorf("failed to download template: %w", err) + } + + return nil +} + +// DownloadAndExtract downloads a ZIP archive from url and extracts it to +// destPath, stripping the top-level wrapper directory that GitHub includes +// in repository archives. +func DownloadAndExtract(url, destPath string) error { + // Ensure destination directory exists. + if err := os.MkdirAll(destPath, 0o755); err != nil { + return fmt.Errorf("cannot create destination directory: %w", err) + } + + // Download to a temporary file. + tmpFile, err := os.CreateTemp("", "langgraph-template-*.zip") + if err != nil { + return fmt.Errorf("cannot create temp file: %w", err) + } + tmpPath := tmpFile.Name() + defer os.Remove(tmpPath) + + resp, err := http.Get(url) //nolint:gosec + if err != nil { + tmpFile.Close() + return fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + tmpFile.Close() + return fmt.Errorf("HTTP %d: failed to download %s", resp.StatusCode, url) + } + + if _, err := io.Copy(tmpFile, resp.Body); err != nil { + tmpFile.Close() + return fmt.Errorf("failed to write ZIP data: %w", err) + } + tmpFile.Close() + + // Open the ZIP archive. + zr, err := zip.OpenReader(tmpPath) + if err != nil { + return fmt.Errorf("failed to open ZIP archive: %w", err) + } + defer zr.Close() + + for _, f := range zr.File { + // Strip the first path component (GitHub's wrapper directory). + parts := strings.SplitN(f.Name, "/", 2) + if len(parts) < 2 || parts[1] == "" { + continue // skip the wrapper directory entry itself + } + relPath := parts[1] + + outPath := filepath.Join(destPath, relPath) + + // Ensure the output path is within destPath (zip-slip protection). + if !strings.HasPrefix(filepath.Clean(outPath), filepath.Clean(destPath)+string(os.PathSeparator)) { + continue + } + + if f.FileInfo().IsDir() { + if err := os.MkdirAll(outPath, f.Mode()); err != nil { + return fmt.Errorf("cannot create directory %s: %w", outPath, err) + } + continue + } + + // Create parent directories. + if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil { + return fmt.Errorf("cannot create parent directory: %w", err) + } + + outFile, err := os.OpenFile(outPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, f.Mode()) + if err != nil { + return fmt.Errorf("cannot create file %s: %w", outPath, err) + } + + rc, err := f.Open() + if err != nil { + outFile.Close() + return fmt.Errorf("cannot read ZIP entry %s: %w", f.Name, err) + } + + if _, err := io.Copy(outFile, rc); err != nil { + rc.Close() + outFile.Close() + return fmt.Errorf("failed writing %s: %w", outPath, err) + } + rc.Close() + outFile.Close() + } + + return nil +} diff --git a/libs/cli/internal/version/version.go b/libs/cli/internal/version/version.go new file mode 100644 index 000000000..7dd0d3a2d --- /dev/null +++ b/libs/cli/internal/version/version.go @@ -0,0 +1,7 @@ +package version + +var ( + Version = "dev" + Commit = "unknown" + Date = "unknown" +) diff --git a/libs/cli/langgraph_cli/__main__.py b/libs/cli/langgraph_cli/__main__.py index 98dcca0c2..0fb27612f 100644 --- a/libs/cli/langgraph_cli/__main__.py +++ b/libs/cli/langgraph_cli/__main__.py @@ -1,4 +1,4 @@ -from .cli import cli +from .entrypoint import main if __name__ == "__main__": - cli() + main() diff --git a/libs/cli/langgraph_cli/entrypoint.py b/libs/cli/langgraph_cli/entrypoint.py new file mode 100644 index 000000000..2cc5e05c9 --- /dev/null +++ b/libs/cli/langgraph_cli/entrypoint.py @@ -0,0 +1,74 @@ +"""User-facing entrypoint for the LangGraph CLI.""" + +from __future__ import annotations + +import os +import pathlib +import sys +from collections.abc import Sequence + +import click + +from .cli import cli + +_GO_CLI_FLAG = "LANGGRAPH_USE_GO_CLI" +_GO_CLI_PATH_ENV = "LANGGRAPH_GO_CLI_PATH" +_CALLING_PYTHON_ENV = "LANGGRAPH_CALLING_PYTHON" + +_TRUE_VALUES = frozenset({"1", "true", "yes", "on"}) + + +def _legacy_cli(argv: Sequence[str] | None = None) -> None: + cli.main(args=list(argv) if argv is not None else None, prog_name="langgraph") + + +def _should_use_go_cli() -> bool: + value = os.environ.get(_GO_CLI_FLAG, "") + return value.strip().lower() in _TRUE_VALUES + + +def _bundled_go_cli_path() -> pathlib.Path: + binary_name = "langgraph.exe" if os.name == "nt" else "langgraph" + return pathlib.Path(__file__).resolve().parent / "bin" / binary_name + + +def _resolve_go_cli_path() -> pathlib.Path | None: + override = os.environ.get(_GO_CLI_PATH_ENV) + if override: + path = pathlib.Path(override).expanduser() + return path.resolve() + + bundled = _bundled_go_cli_path() + if bundled.is_file(): + return bundled + + return None + + +def _exec_go_cli(argv: Sequence[str]) -> None: + path = _resolve_go_cli_path() + if path is None: + raise click.ClickException( + "Go CLI requested via LANGGRAPH_USE_GO_CLI, but no langgraph binary was " + "found. Set LANGGRAPH_GO_CLI_PATH or install a wheel that bundles the " + "binary." + ) + if not path.is_file(): + raise click.ClickException( + f"LANGGRAPH_GO_CLI_PATH points to a missing file: {path}" + ) + + env = os.environ.copy() + env.setdefault(_CALLING_PYTHON_ENV, sys.executable) + os.execvpe(str(path), [str(path), *argv], env) + + +def main(argv: Sequence[str] | None = None) -> None: + args = list(sys.argv[1:] if argv is None else argv) + try: + if _should_use_go_cli(): + _exec_go_cli(args) + _legacy_cli(args) + except click.ClickException as exc: + exc.show() + raise SystemExit(exc.exit_code) from exc diff --git a/libs/cli/pyproject.toml b/libs/cli/pyproject.toml index 337da7af1..4b8a37ec7 100644 --- a/libs/cli/pyproject.toml +++ b/libs/cli/pyproject.toml @@ -34,7 +34,7 @@ Slack = "https://www.langchain.com/join-community" Reddit = "https://www.reddit.com/r/LangChain/" [project.scripts] -langgraph = "langgraph_cli.cli:cli" +langgraph = "langgraph_cli.entrypoint:main" [dependency-groups] test = [ diff --git a/libs/cli/tests/unit_tests/test_entrypoint.py b/libs/cli/tests/unit_tests/test_entrypoint.py new file mode 100644 index 000000000..f7f237473 --- /dev/null +++ b/libs/cli/tests/unit_tests/test_entrypoint.py @@ -0,0 +1,116 @@ +import pathlib +import sys + +import pytest + +from langgraph_cli import entrypoint + + +def test_main_uses_legacy_cli_when_go_flag_disabled(monkeypatch): + captured = {} + + def fake_legacy(argv): + captured["argv"] = list(argv) + + monkeypatch.delenv("LANGGRAPH_USE_GO_CLI", raising=False) + monkeypatch.setattr(entrypoint, "_legacy_cli", fake_legacy) + + entrypoint.main(["build", "-t", "demo"]) + + assert captured == {"argv": ["build", "-t", "demo"]} + + +def test_main_execs_go_cli_when_flag_enabled(monkeypatch, tmp_path): + binary_path = tmp_path / "langgraph" + binary_path.write_text("") + + captured = {} + + def fake_execvpe(file, args, env): + captured["file"] = file + captured["args"] = args + captured["env"] = env.copy() + raise SystemExit(0) + + monkeypatch.setenv("LANGGRAPH_USE_GO_CLI", "1") + monkeypatch.setenv("LANGGRAPH_GO_CLI_PATH", str(binary_path)) + monkeypatch.delenv("LANGGRAPH_CALLING_PYTHON", raising=False) + monkeypatch.setattr(entrypoint.os, "execvpe", fake_execvpe) + + with pytest.raises(SystemExit, match="0"): + entrypoint.main(["dev", "--port", "8000"]) + + assert captured["file"] == str(binary_path) + assert captured["args"] == [str(binary_path), "dev", "--port", "8000"] + assert captured["env"]["LANGGRAPH_CALLING_PYTHON"] == sys.executable + + +def test_main_preserves_existing_calling_python(monkeypatch, tmp_path): + binary_path = tmp_path / "langgraph" + binary_path.write_text("") + + captured = {} + + def fake_execvpe(file, args, env): + captured["env"] = env.copy() + raise SystemExit(0) + + monkeypatch.setenv("LANGGRAPH_USE_GO_CLI", "true") + monkeypatch.setenv("LANGGRAPH_GO_CLI_PATH", str(binary_path)) + monkeypatch.setenv("LANGGRAPH_CALLING_PYTHON", "/custom/python") + monkeypatch.setattr(entrypoint.os, "execvpe", fake_execvpe) + + with pytest.raises(SystemExit, match="0"): + entrypoint.main(["dev"]) + + assert captured["env"]["LANGGRAPH_CALLING_PYTHON"] == "/custom/python" + + +def test_main_errors_when_go_cli_requested_but_binary_missing( + monkeypatch, capsys, tmp_path +): + missing_path = tmp_path / "missing-langgraph" + + monkeypatch.setenv("LANGGRAPH_USE_GO_CLI", "1") + monkeypatch.setenv("LANGGRAPH_GO_CLI_PATH", str(missing_path)) + + with pytest.raises(SystemExit, match="1"): + entrypoint.main(["build"]) + + err = capsys.readouterr().err + assert "LANGGRAPH_GO_CLI_PATH points to a missing file" in err + + +def test_resolve_go_cli_path_prefers_override(monkeypatch, tmp_path): + override = tmp_path / "custom-langgraph" + override.write_text("") + bundled = tmp_path / "bin" / "langgraph" + bundled.parent.mkdir() + bundled.write_text("") + + monkeypatch.setenv("LANGGRAPH_GO_CLI_PATH", str(override)) + monkeypatch.setattr(entrypoint, "_bundled_go_cli_path", lambda: bundled) + + assert entrypoint._resolve_go_cli_path() == override.resolve() + + +def test_resolve_go_cli_path_uses_bundled_binary(monkeypatch, tmp_path): + bundled = tmp_path / "bin" / "langgraph" + bundled.parent.mkdir() + bundled.write_text("") + + monkeypatch.delenv("LANGGRAPH_GO_CLI_PATH", raising=False) + monkeypatch.setattr(entrypoint, "_bundled_go_cli_path", lambda: bundled) + + assert entrypoint._resolve_go_cli_path() == bundled + + +def test_resolve_go_cli_path_returns_none_when_nothing_available(monkeypatch): + monkeypatch.delenv("LANGGRAPH_GO_CLI_PATH", raising=False) + monkeypatch.setattr( + entrypoint, + "_bundled_go_cli_path", + lambda: pathlib.Path("/definitely/not/present/langgraph"), + ) + + assert entrypoint._resolve_go_cli_path() is None