diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6046b16e9..9e11ec201 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,7 +47,7 @@ jobs: - name: Cross-compile Go binaries if: inputs.working-directory == 'libs/cli' working-directory: ${{ inputs.working-directory }} - run: make build-go-all + run: make build-go-all GO_BIN_DIR=build/go-bin - name: Run Go tests if: inputs.working-directory == 'libs/cli' @@ -89,11 +89,12 @@ jobs: plat=$(echo "$entry" | awk '{print $2}') ext="" if [[ "$key" == windows-* ]]; then ext=".exe"; fi - binary="langgraph_cli/bin/langgraph-${key}${ext}" + binary="build/go-bin/langgraph-${key}${ext}" echo "Building wheel for ${key} -> ${plat}..." LANGGRAPH_GO_BINARY="$binary" LANGGRAPH_WHEEL_PLAT="$plat" uv build --wheel done # Also build the sdist and pure-Python fallback wheel + rm -rf langgraph_cli/bin uv build echo "All wheels built:" ls -lh dist/ diff --git a/libs/cli/hatch_build.py b/libs/cli/hatch_build.py index 1cc126988..5e0a306e7 100644 --- a/libs/cli/hatch_build.py +++ b/libs/cli/hatch_build.py @@ -24,6 +24,10 @@ class GoBinaryBuildHook(BuildHookInterface): PLUGIN_NAME = "go-binary" def initialize(self, version: str, build_data: dict) -> None: + bin_dir = Path("langgraph_cli/bin") + if bin_dir.exists(): + shutil.rmtree(bin_dir) + binary_path = os.environ.get("LANGGRAPH_GO_BINARY") if not binary_path: return @@ -33,7 +37,6 @@ class GoBinaryBuildHook(BuildHookInterface): msg = f"LANGGRAPH_GO_BINARY points to missing file: {source}" raise FileNotFoundError(msg) - bin_dir = Path("langgraph_cli/bin") bin_dir.mkdir(parents=True, exist_ok=True) # Determine output name (langgraph or langgraph.exe) diff --git a/libs/cli/internal/config/config.go b/libs/cli/internal/config/config.go index 9b0f7cd95..429972577 100644 --- a/libs/cli/internal/config/config.go +++ b/libs/cli/internal/config/config.go @@ -475,16 +475,39 @@ func ValidateConfig(raw map[string]any) (map[string]any, error) { // ValidateConfigFile loads a config file, validates it, and returns the result. func ValidateConfigFile(configPath string) (map[string]any, error) { + raw, err := LoadRawConfigFile(configPath) + if err != nil { + return nil, err + } + + return validateConfigFile(configPath, raw) +} + +// LoadRawConfigFile loads a config file and requires the top-level JSON value +// to be an object. +func LoadRawConfigFile(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 { + var rawAny any + if err := json.Unmarshal(data, &rawAny); err != nil { return nil, fmt.Errorf("Invalid JSON in %s: %s", configPath, err.Error()) } + raw, ok := rawAny.(map[string]any) + if !ok { + return nil, fmt.Errorf( + "Invalid config in %s: top-level JSON value must be an object.", + configPath, + ) + } + + return raw, nil +} + +func validateConfigFile(configPath string, raw map[string]any) (map[string]any, error) { validated, err := ValidateConfig(raw) if err != nil { return nil, err @@ -512,7 +535,10 @@ func validatePackageJSON(path string) error { var pkg map[string]any if err := json.Unmarshal(data, &pkg); err != nil { - return fmt.Errorf("Invalid package.json: %s", err.Error()) + return fmt.Errorf( + "Invalid package.json found in langgraph config directory %s: file is not valid JSON", + path, + ) } enginesRaw, ok := pkg["engines"] diff --git a/libs/cli/internal/root/root.go b/libs/cli/internal/root/root.go index e5dead1a6..ab443def3 100644 --- a/libs/cli/internal/root/root.go +++ b/libs/cli/internal/root/root.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "os" + osexec "os/exec" "path/filepath" "strconv" "strings" @@ -18,6 +19,19 @@ import ( "github.com/langchain-ai/langgraph/libs/cli/internal/version" ) +var runPythonSubprocess = func( + pythonExe string, + args []string, + stdout io.Writer, + stderr io.Writer, +) error { + cmd := osexec.Command(pythonExe, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + return cmd.Run() +} + const helpText = `Usage: langgraph [OPTIONS] COMMAND [ARGS]... LangGraph CLI @@ -229,17 +243,13 @@ func loadAndValidateConfig(configPath string, stderr io.Writer) (map[string]any, errPrint(stderr, fmt.Sprintf("Path '%s' does not exist.", configPath)) return nil, nil, false } - data, err := os.ReadFile(configPath) + + raw, err := config.LoadRawConfigFile(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) + validated, err := config.ValidateConfigFile(configPath) if err != nil { errPrint(stderr, err.Error()) return nil, nil, false @@ -289,15 +299,27 @@ Options: _, _ = 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 { + + var rawAny any + if err := json.Unmarshal(data, &rawAny); err != nil { _, _ = fmt.Fprintf(stderr, "Error: Invalid JSON in %s: %s\n", configPath, err.Error()) return 1 } + rawConfig, ok := rawAny.(map[string]any) + if !ok { + _, _ = fmt.Fprintf( + stderr, + "%sError: Invalid config in %s: top-level JSON value must be an object.%s\n", + colorRed, + configPath, + colorReset, + ) + return 1 + } unknownWarnings := config.GetUnknownKeys(rawConfig) - validated, validErr := config.ValidateConfig(rawConfig) + validated, validErr := config.ValidateConfigFile(configPath) if validErr != nil { _, _ = fmt.Fprintf(stderr, "%sError: %s%s\n", colorRed, validErr, colorReset) if len(unknownWarnings) > 0 { @@ -683,29 +705,7 @@ Options: } } - // 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 + return runPythonCLI("dev", args, stdout, stderr) } // --------------------------------------------------------------------------- @@ -767,37 +767,33 @@ Available templates: // --------------------------------------------------------------------------- func runDeploy(args []string, stdout, stderr io.Writer) int { - if len(args) == 0 { - return runDeployMain(args, stdout, stderr) + return runPythonCLI("deploy", args, stdout, stderr) +} + +func runPythonCLI(subcommand string, args []string, stdout, stderr io.Writer) int { + pythonExe := os.Getenv("LANGGRAPH_CALLING_PYTHON") + if pythonExe == "" { + for _, name := range []string{"python3", "python"} { + if p, _, err := lgexec.RunCollect("which", []string{name}); err == nil && strings.TrimSpace(p) != "" { + pythonExe = strings.TrimSpace(p) + break + } + } } - 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) + if pythonExe == "" { + errPrint(stderr, "Could not find Python interpreter. Set LANGGRAPH_CALLING_PYTHON.") + return 1 } + + pyArgs := append([]string{"-m", "langgraph_cli.cli", subcommand}, args...) + if err := runPythonSubprocess(pythonExe, pyArgs, stdout, stderr); err != nil { + if exitErr, ok := err.(*osexec.ExitError); ok { + return exitErr.ExitCode() + } + errPrint(stderr, err.Error()) + return 1 + } + return 0 } func resolveDeployClient(args []string, stderr io.Writer) (apiKey, hostURL string, extra []string) { diff --git a/libs/cli/internal/root/root_test.go b/libs/cli/internal/root/root_test.go index 18fb54215..38c092651 100644 --- a/libs/cli/internal/root/root_test.go +++ b/libs/cli/internal/root/root_test.go @@ -2,6 +2,7 @@ package root import ( "bytes" + "io" "os" "path/filepath" "strings" @@ -117,6 +118,22 @@ func TestRunValidateWithInvalidJSON(t *testing.T) { } } +func TestRunValidateWithNonObjectJSON(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + path := writeTempConfig(t, `[]`) + + 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(), "top-level JSON value must be an object") { + t.Fatalf("expected stderr to mention object-shaped config, got %q", stderr.String()) + } +} + func TestRunValidateDefaultConfigMissing(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -203,3 +220,68 @@ func TestRunValidateWithWarningsAndErrors(t *testing.T) { t.Fatalf("expected stderr to contain 'warning', got %q", errOut) } } + +func TestRunValidateWithInvalidPackageJSON(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + + dir := t.TempDir() + configPath := filepath.Join(dir, "langgraph.json") + packagePath := filepath.Join(dir, "package.json") + if err := os.WriteFile( + configPath, + []byte(`{"node_version":"20","graphs":{"agent":"./agent.js:graph"}}`), + 0644, + ); err != nil { + t.Fatalf("failed to write config: %v", err) + } + if err := os.WriteFile(packagePath, []byte(`{invalid json`), 0644); err != nil { + t.Fatalf("failed to write package.json: %v", err) + } + + exitCode := Run([]string{"validate", "-c", configPath}, &stdout, &stderr) + + if exitCode != 1 { + t.Fatalf("expected exit code 1, got %d", exitCode) + } + if !strings.Contains(stderr.String(), "Invalid package.json found") { + t.Fatalf("expected stderr to mention invalid package.json, got %q", stderr.String()) + } +} + +func TestRunDeployDelegatesToPythonCLI(t *testing.T) { + t.Setenv("LANGGRAPH_CALLING_PYTHON", "/custom/python") + + originalRunPythonSubprocess := runPythonSubprocess + t.Cleanup(func() { + runPythonSubprocess = originalRunPythonSubprocess + }) + + var gotPython string + var gotArgs []string + runPythonSubprocess = func( + pythonExe string, + args []string, + stdout io.Writer, + stderr io.Writer, + ) error { + gotPython = pythonExe + gotArgs = append([]string(nil), args...) + return nil + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + exitCode := Run([]string{"deploy", "--remote", "--install-command", "make deps"}, &stdout, &stderr) + + if exitCode != 0 { + t.Fatalf("expected exit code 0, got %d; stderr: %q", exitCode, stderr.String()) + } + if gotPython != "/custom/python" { + t.Fatalf("expected delegated python to be /custom/python, got %q", gotPython) + } + expected := []string{"-m", "langgraph_cli.cli", "deploy", "--remote", "--install-command", "make deps"} + if strings.Join(gotArgs, "\x00") != strings.Join(expected, "\x00") { + t.Fatalf("unexpected delegated args: got %q want %q", gotArgs, expected) + } +}