package root import ( "encoding/json" "fmt" "io" "os" "path/filepath" "strconv" "strings" "time" "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, }) // Wrap the app config snippet into a valid compose overlay YAML. // ConfigToCompose returns content indented at the service-property level // (8 spaces), so we wrap it in the correct compose structure. appOverlayYAML := "services:\n langgraph-api:" + 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 infrastructure compose and app overlay to separate temp files. // Docker compose handles merging when given multiple -f flags. tmpInfra, err := os.CreateTemp("", "langgraph-infra-*.yml") if err != nil { errPrint(stderr, err.Error()) return 1 } defer os.Remove(tmpInfra.Name()) _, _ = tmpInfra.WriteString(infraYAML) tmpInfra.Close() tmpApp, err := os.CreateTemp("", "langgraph-app-*.yml") if err != nil { errPrint(stderr, err.Error()) return 1 } defer os.Remove(tmpApp.Name()) _, _ = tmpApp.WriteString(appOverlayYAML) tmpApp.Close() composeCmd := "docker" composeArgs := []string{"compose"} if caps.ComposeType == "standalone" { composeCmd = "docker-compose" composeArgs = nil } upArgs := append(composeArgs, "-f", tmpInfra.Name(), "-f", tmpApp.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 _ = 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 } if !noWait { _, _ = fmt.Fprintln(stdout, "Waiting for deployment...") finalStatus, pollErr := deploy.PollDeploymentStatus(client, depID, 300, 2, func(status string) { _, _ = fmt.Fprintf(stdout, " Status: %s\n", status) }) if pollErr != nil { errPrint(stderr, pollErr.Error()) return 1 } if finalStatus != "DEPLOYED" { errPrint(stderr, fmt.Sprintf("Deployment failed with status: %s", finalStatus)) 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 } if follow { payload["order"] = "asc" seen := make(map[string]bool) for { var resp map[string]any var err error if logType == "build" { if revisionID == "" { 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 == "" { time.Sleep(2 * time.Second) continue } 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 _, l := range logs { entry, _ := l.(map[string]any) id, _ := entry["id"].(string) if id == "" { // Fall back to timestamp+message as key ts, _ := entry["timestamp"].(string) msg, _ := entry["message"].(string) id = ts + "|" + msg } if seen[id] { continue } seen[id] = true 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) } time.Sleep(2 * time.Second) } } 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 }