diff --git a/libs/cli/Cargo.lock b/libs/cli/Cargo.lock index a2f89047e..c6082bd31 100644 --- a/libs/cli/Cargo.lock +++ b/libs/cli/Cargo.lock @@ -948,7 +948,6 @@ dependencies = [ "serde_json", "tempfile", "thiserror 2.0.18", - "tokio", "which", "zip", ] @@ -977,15 +976,6 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - [[package]] name = "log" version = "0.4.29" @@ -1096,29 +1086,6 @@ dependencies = [ "pathdiff", ] -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - [[package]] name = "pathdiff" version = "0.2.3" @@ -1337,15 +1304,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - [[package]] name = "regex" version = "1.12.3" @@ -1495,12 +1453,6 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - [[package]] name = "semver" version = "1.0.27" @@ -1585,16 +1537,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - [[package]] name = "simd-adler32" version = "0.3.8" @@ -1784,25 +1726,11 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", - "signal-hook-registry", "socket2", - "tokio-macros", "windows-sys 0.61.2", ] -[[package]] -name = "tokio-macros" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "tokio-rustls" version = "0.26.4" diff --git a/libs/cli/Cargo.toml b/libs/cli/Cargo.toml index 6ab969e54..f9d26d870 100644 --- a/libs/cli/Cargo.toml +++ b/libs/cli/Cargo.toml @@ -13,7 +13,6 @@ path = "src/main.rs" clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = { version = "1" } -tokio = { version = "1", features = ["full"] } reqwest = { version = "0.12", features = ["rustls-tls", "blocking"], default-features = false } zip = "2" indexmap = { version = "2", features = ["serde"] } diff --git a/libs/cli/build.rs b/libs/cli/build.rs index e98200255..0dfa23036 100644 --- a/libs/cli/build.rs +++ b/libs/cli/build.rs @@ -1,7 +1,3 @@ fn main() { - // Make version available at compile time - println!( - "cargo:rustc-env=CARGO_PKG_VERSION={}", - env!("CARGO_PKG_VERSION") - ); + // Cargo already sets CARGO_PKG_VERSION, nothing to do } diff --git a/libs/cli/src/commands/dev.rs b/libs/cli/src/commands/dev.rs index 5b7382714..d0a85a3dc 100644 --- a/libs/cli/src/commands/dev.rs +++ b/libs/cli/src/commands/dev.rs @@ -7,11 +7,25 @@ use console::style; use crate::analytics; use crate::config::validate_config_file; +/// Find the Python interpreter, preferring python3 over python. +fn find_python() -> Result { + for candidate in &["python3", "python"] { + if which::which(candidate).is_ok() { + return Ok(candidate.to_string()); + } + } + Err( + "Python not found. The `langgraph dev` command requires Python >= 3.11 with \ + langgraph-cli[inmem] installed.\n\ + Install with: pip install -U \"langgraph-cli[inmem]\"" + .to_string(), + ) +} + /// Run the LangGraph API server in development mode (in-memory, via Python subprocess). /// -/// This spawns `python -c "from langgraph_api.cli import run_server; ..."` as a subprocess, -/// passing the parsed config as arguments. For JS graphs, an error is returned since the -/// in-memory server doesn't support them in this CLI. +/// This passes the config as a JSON object via stdin to a Python bootstrap script, +/// avoiding any string interpolation into Python source code. #[allow(clippy::too_many_arguments)] pub fn run( host: &str, @@ -48,162 +62,126 @@ pub fn run( ); } - // Build the graphs JSON - let graphs_json = serde_json::to_string(&config_json.graphs) - .map_err(|e| format!("Failed to serialize graphs: {e}"))?; + let python = find_python()?; - // Build env JSON (optional) - let env_json = config_json - .env - .as_ref() - .map(|e| serde_json::to_string(e).unwrap_or_else(|_| "null".to_string())) - .unwrap_or_else(|| "None".to_string()); + // Pre-check that langgraph_api is importable + let check = Command::new(&python) + .args(["-c", "from langgraph_api.cli import run_server"]) + .output(); - // Build store JSON (optional) - let store_json = config_json - .store - .as_ref() - .map(|s| serde_json::to_string(s).unwrap_or_else(|_| "null".to_string())) - .unwrap_or_else(|| "None".to_string()); + match check { + Ok(output) if !output.status.success() => { + return Err( + "Required package 'langgraph-api' is not installed.\n\ + Please install it with:\n\n\ + pip install -U \"langgraph-cli[inmem]\"\n\n\ + Note: The in-mem server requires Python 3.11 or higher." + .to_string(), + ); + } + Err(_) => { + return Err(format!( + "Failed to run {python}. The `langgraph dev` command requires Python >= 3.11 with \ + langgraph-cli[inmem] installed.\n\ + Install with: pip install -U \"langgraph-cli[inmem]\"" + )); + } + _ => {} + } - // Build auth JSON (optional) - let auth_json = config_json - .auth - .as_ref() - .map(|a| serde_json::to_string(a).unwrap_or_else(|_| "null".to_string())) - .unwrap_or_else(|| "None".to_string()); + // Build a JSON config object to pass via stdin. + // This avoids interpolating user data into Python source code. + let dev_config = serde_json::json!({ + "host": host, + "port": port, + "reload": !no_reload, + "open_browser": !no_browser, + "wait_for_client": wait_for_client, + "allow_blocking": allow_blocking, + "tunnel": tunnel, + "server_log_level": server_log_level, + "dependencies": config_json.dependencies, + "graphs": config_json.graphs, + "n_jobs_per_worker": n_jobs_per_worker, + "debug_port": debug_port, + "studio_url": studio_url, + "env": config_json.env, + "store": config_json.store, + "auth": config_json.auth, + "http": config_json.http, + "ui": config_json.ui, + "ui_config": config_json.ui_config, + "webhooks": config_json.webhooks, + }); - // Build http JSON (optional) - let http_json = config_json - .http - .as_ref() - .map(|h| serde_json::to_string(h).unwrap_or_else(|_| "null".to_string())) - .unwrap_or_else(|| "None".to_string()); - - // Build ui JSON (optional) - let ui_json = config_json - .ui - .as_ref() - .map(|u| serde_json::to_string(u).unwrap_or_else(|_| "null".to_string())) - .unwrap_or_else(|| "None".to_string()); - - // Build ui_config JSON (optional) - let ui_config_json = config_json - .ui_config - .as_ref() - .map(|u| serde_json::to_string(u).unwrap_or_else(|_| "null".to_string())) - .unwrap_or_else(|| "None".to_string()); - - // Build webhooks JSON (optional) - let webhooks_json = config_json - .webhooks - .as_ref() - .map(|w| serde_json::to_string(w).unwrap_or_else(|_| "null".to_string())) - .unwrap_or_else(|| "None".to_string()); - - // Build n_jobs_per_worker - let n_jobs_str = n_jobs_per_worker - .map(|n| n.to_string()) - .unwrap_or_else(|| "None".to_string()); - - // Build debug_port - let debug_port_str = debug_port - .map(|p| p.to_string()) - .unwrap_or_else(|| "None".to_string()); - - // Build studio_url - let studio_url_str = studio_url - .map(|s| format!("\"{}\"", s.replace('"', "\\\""))) - .unwrap_or_else(|| "None".to_string()); - - // Construct the Python code to execute - let python_code = format!( - r#" -import sys, os, json + // Python bootstrap: reads JSON from stdin, calls run_server + let python_code = r#" +import sys, os, json, pathlib +config = json.loads(sys.stdin.read()) cwd = os.getcwd() sys.path.append(cwd) -deps = json.loads('{deps_json}') -for dep in deps: - import pathlib +for dep in config.get('dependencies', []): dep_path = pathlib.Path(cwd) / dep if dep_path.is_dir() and dep_path.exists(): sys.path.append(str(dep_path)) from langgraph_api.cli import run_server -graphs = json.loads('{graphs}') run_server( - "{host}", - {port}, - {reload}, - graphs, - n_jobs_per_worker={n_jobs}, - open_browser={open_browser}, - debug_port={debug_port}, - env={env}, - store={store}, - wait_for_client={wait_for_client}, - auth={auth}, - http={http}, - ui={ui}, - ui_config={ui_config}, - webhooks={webhooks}, - studio_url={studio_url}, - allow_blocking={allow_blocking}, - tunnel={tunnel}, - server_level="{server_log_level}", + config['host'], + config['port'], + config['reload'], + config['graphs'], + n_jobs_per_worker=config.get('n_jobs_per_worker'), + open_browser=config['open_browser'], + debug_port=config.get('debug_port'), + env=config.get('env'), + store=config.get('store'), + wait_for_client=config['wait_for_client'], + auth=config.get('auth'), + http=config.get('http'), + ui=config.get('ui'), + ui_config=config.get('ui_config'), + webhooks=config.get('webhooks'), + studio_url=config.get('studio_url'), + allow_blocking=config['allow_blocking'], + tunnel=config['tunnel'], + server_level=config['server_log_level'], ) -"#, - deps_json = serde_json::to_string(&config_json.dependencies) - .unwrap_or_else(|_| "[]".to_string()) - .replace('\'', "\\'"), - graphs = graphs_json.replace('\'', "\\'"), - host = host, - port = port, - reload = if no_reload { "False" } else { "True" }, - n_jobs = n_jobs_str, - open_browser = if no_browser { "False" } else { "True" }, - debug_port = debug_port_str, - env = env_json, - store = store_json, - wait_for_client = if wait_for_client { "True" } else { "False" }, - auth = auth_json, - http = http_json, - ui = ui_json, - ui_config = ui_config_json, - webhooks = webhooks_json, - studio_url = studio_url_str, - allow_blocking = if allow_blocking { "True" } else { "False" }, - tunnel = if tunnel { "True" } else { "False" }, - server_log_level = server_log_level, - ); +"#; eprintln!( "{}", style("Starting LangGraph API server in development mode...").green() ); - // Spawn Python subprocess - let status = Command::new("python") + // Spawn Python subprocess with config on stdin + let mut child = Command::new(&python) .arg("-c") - .arg(&python_code) + .arg(python_code) .current_dir( config_path .parent() .unwrap_or_else(|| Path::new(".")), ) - .stdin(std::process::Stdio::inherit()) + .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::inherit()) .stderr(std::process::Stdio::inherit()) - .status() - .map_err(|e| { - if e.kind() == std::io::ErrorKind::NotFound { - "Python not found. The `langgraph dev` command requires Python >= 3.11 with \ - langgraph-cli[inmem] installed.\n\ - Install with: pip install -U \"langgraph-cli[inmem]\"" - .to_string() - } else { - format!("Failed to start Python: {e}") - } - })?; + .spawn() + .map_err(|e| format!("Failed to start Python: {e}"))?; + + // Write JSON config to stdin + if let Some(ref mut stdin) = child.stdin { + use std::io::Write; + let json_bytes = dev_config.to_string(); + stdin + .write_all(json_bytes.as_bytes()) + .map_err(|e| format!("Failed to write config to Python stdin: {e}"))?; + } + // Drop stdin to signal EOF + drop(child.stdin.take()); + + let status = child + .wait() + .map_err(|e| format!("Failed to wait for Python: {e}"))?; if !status.success() { let code = status.code().unwrap_or(1); diff --git a/libs/cli/src/commands/up.rs b/libs/cli/src/commands/up.rs index ef2758b4c..607184638 100644 --- a/libs/cli/src/commands/up.rs +++ b/libs/cli/src/commands/up.rs @@ -9,7 +9,7 @@ use crate::config::validate_config_file; use crate::docker::capabilities::{check_capabilities, ComposeType}; use crate::docker::compose::compose; use crate::docker::dockerfile::config_to_compose; -use crate::exec::{run_command, run_command_streaming}; +use crate::exec::{run_command, run_command_streaming_with_callback}; use crate::progress::Progress; use crate::util::warn_non_wolfi_distro; @@ -153,10 +153,46 @@ pub fn run( cmd_args.push(a.as_str()); } - progress.finish(); + // Run docker compose with streaming output, intercepting stdout + // to detect startup and show Ready! URLs + let mut ready_printed = false; + let debugger_base_url_query = debugger_base_url + .map(|s| s.to_string()) + .unwrap_or_else(|| format!("http://127.0.0.1:{port}")); - // Run docker compose with streaming output - run_command_streaming(compose_cmd[0], &cmd_args, Some(&compose_stdin), verbose)?; + run_command_streaming_with_callback( + compose_cmd[0], + &cmd_args, + Some(&compose_stdin), + verbose, + |line| { + if !ready_printed { + if line.contains("unpacking to docker.io") { + progress.set_message("Starting..."); + } else if line.contains("Application startup complete") { + progress.finish(); + ready_printed = true; + + let debugger_origin = if let Some(dp) = debugger_port { + format!("http://localhost:{dp}") + } else { + "https://smith.langchain.com".to_string() + }; + + println!( + "Ready!\n\ + - API: http://localhost:{port}\n\ + - Docs: http://localhost:{port}/docs\n\ + - LangGraph Studio: {debugger_origin}/studio/?baseUrl={debugger_base_url_query}" + ); + } + } + }, + )?; + + if !ready_printed { + progress.finish(); + } Ok(()) } diff --git a/libs/cli/src/config/schema.rs b/libs/cli/src/config/schema.rs index 42645f2d1..9668443e0 100644 --- a/libs/cli/src/config/schema.rs +++ b/libs/cli/src/config/schema.rs @@ -78,13 +78,6 @@ pub enum GraphSpec { } impl GraphSpec { - pub fn get_path(&self) -> Option<&str> { - match self { - GraphSpec::Path(s) => Some(s), - GraphSpec::Dict(m) => m.get("path").and_then(|v| v.as_str()), - } - } - pub fn set_path(&mut self, new_path: String) { match self { GraphSpec::Path(s) => *s = new_path, diff --git a/libs/cli/src/constants.rs b/libs/cli/src/constants.rs index 5e8c3afcc..43a7b197d 100644 --- a/libs/cli/src/constants.rs +++ b/libs/cli/src/constants.rs @@ -1,6 +1,3 @@ -pub const DEFAULT_CONFIG: &str = "langgraph.json"; -pub const DEFAULT_PORT: u16 = 8123; - pub const MIN_NODE_VERSION: u32 = 20; pub const DEFAULT_NODE_VERSION: u32 = 20; diff --git a/libs/cli/src/docker/capabilities.rs b/libs/cli/src/docker/capabilities.rs index 2f2f08dd8..f22d8395d 100644 --- a/libs/cli/src/docker/capabilities.rs +++ b/libs/cli/src/docker/capabilities.rs @@ -28,7 +28,9 @@ pub enum ComposeType { /// Docker capabilities detected on the system. #[derive(Debug, Clone)] pub struct DockerCapabilities { + #[allow(dead_code)] pub version_docker: Version, + #[allow(dead_code)] pub version_compose: Version, pub healthcheck_start_interval: bool, pub compose_type: ComposeType, diff --git a/libs/cli/src/docker/dockerfile.rs b/libs/cli/src/docker/dockerfile.rs index eb5291975..ead4148e8 100644 --- a/libs/cli/src/docker/dockerfile.rs +++ b/libs/cli/src/docker/dockerfile.rs @@ -1,5 +1,6 @@ use std::collections::HashSet; use std::path::Path; +use std::sync::LazyLock; use indexmap::IndexMap; @@ -13,13 +14,15 @@ use crate::constants::{BUILD_TOOLS, DEFAULT_NODE_VERSION}; use regex::Regex; +static IMAGE_VERSION_RE: LazyLock = + LazyLock::new(|| Regex::new(r":(\d+(?:\.\d+)?(?:\.\d+)?)(?:-|$)").unwrap()); + /// Check if a base image supports uv. fn image_supports_uv(base_image: &str) -> bool { if base_image == "langchain/langgraph-trial" { return false; } - let re = Regex::new(r":(\d+(?:\.\d+)?(?:\.\d+)?)(?:-|$)").unwrap(); - match re.find(base_image) { + match IMAGE_VERSION_RE.find(base_image) { None => true, // Default image supports it Some(m) => { let raw = &base_image[m.start() + 1..m.end()]; @@ -109,6 +112,66 @@ fn get_pip_cleanup_lines( commands.join("\n") } +/// Build ENV lines for all config-driven environment variables (shared between Python and Node). +fn build_config_env_vars(config: &Config) -> Vec { + let mut env_vars = Vec::new(); + + if let Some(ref store) = config.store { + env_vars.push(format!( + "ENV LANGGRAPH_STORE='{}'", + serde_json::to_string(store).unwrap() + )); + } + if let Some(ref auth) = config.auth { + env_vars.push(format!( + "ENV LANGGRAPH_AUTH='{}'", + serde_json::to_string(auth).unwrap() + )); + } + if let Some(ref encryption) = config.encryption { + env_vars.push(format!( + "ENV LANGGRAPH_ENCRYPTION='{}'", + serde_json::to_string(encryption).unwrap() + )); + } + if let Some(ref http) = config.http { + env_vars.push(format!( + "ENV LANGGRAPH_HTTP='{}'", + serde_json::to_string(http).unwrap() + )); + } + if let Some(ref webhooks) = config.webhooks { + env_vars.push(format!( + "ENV LANGGRAPH_WEBHOOKS='{}'", + serde_json::to_string(webhooks).unwrap() + )); + } + if let Some(ref checkpointer) = config.checkpointer { + env_vars.push(format!( + "ENV LANGGRAPH_CHECKPOINTER='{}'", + serde_json::to_string(checkpointer).unwrap() + )); + } + if let Some(ref ui) = config.ui { + env_vars.push(format!( + "ENV LANGGRAPH_UI='{}'", + serde_json::to_string(ui).unwrap() + )); + } + if let Some(ref ui_config) = config.ui_config { + env_vars.push(format!( + "ENV LANGGRAPH_UI_CONFIG='{}'", + serde_json::to_string(ui_config).unwrap() + )); + } + env_vars.push(format!( + "ENV LANGSERVE_GRAPHS='{}'", + serde_json::to_string(&config.graphs).unwrap() + )); + + env_vars +} + /// Detect the Node.js package manager install command. fn get_node_pm_install_cmd(config_path: &Path, _config: &Config) -> String { let parent = config_path.parent().unwrap(); @@ -275,60 +338,7 @@ pub fn python_config_to_docker( let installs = installs.join("\n\n"); // Environment variables - let mut env_vars = Vec::new(); - - if let Some(ref store) = config.store { - env_vars.push(format!( - "ENV LANGGRAPH_STORE='{}'", - serde_json::to_string(store).unwrap() - )); - } - if let Some(ref auth) = config.auth { - env_vars.push(format!( - "ENV LANGGRAPH_AUTH='{}'", - serde_json::to_string(auth).unwrap() - )); - } - if let Some(ref encryption) = config.encryption { - env_vars.push(format!( - "ENV LANGGRAPH_ENCRYPTION='{}'", - serde_json::to_string(encryption).unwrap() - )); - } - if let Some(ref http) = config.http { - env_vars.push(format!( - "ENV LANGGRAPH_HTTP='{}'", - serde_json::to_string(http).unwrap() - )); - } - if let Some(ref webhooks) = config.webhooks { - env_vars.push(format!( - "ENV LANGGRAPH_WEBHOOKS='{}'", - serde_json::to_string(webhooks).unwrap() - )); - } - if let Some(ref checkpointer) = config.checkpointer { - env_vars.push(format!( - "ENV LANGGRAPH_CHECKPOINTER='{}'", - serde_json::to_string(checkpointer).unwrap() - )); - } - if let Some(ref ui) = config.ui { - env_vars.push(format!( - "ENV LANGGRAPH_UI='{}'", - serde_json::to_string(ui).unwrap() - )); - } - if let Some(ref ui_config) = config.ui_config { - env_vars.push(format!( - "ENV LANGGRAPH_UI_CONFIG='{}'", - serde_json::to_string(ui_config).unwrap() - )); - } - env_vars.push(format!( - "ENV LANGSERVE_GRAPHS='{}'", - serde_json::to_string(&config.graphs).unwrap() - )); + let env_vars = build_config_env_vars(config); // JS install let js_inst_str = @@ -451,59 +461,7 @@ pub fn node_config_to_docker( let image_str = docker_tag(config, Some(base_image), api_version); // Environment variables - let mut env_vars = Vec::new(); - if let Some(ref store) = config.store { - env_vars.push(format!( - "ENV LANGGRAPH_STORE='{}'", - serde_json::to_string(store).unwrap() - )); - } - if let Some(ref auth) = config.auth { - env_vars.push(format!( - "ENV LANGGRAPH_AUTH='{}'", - serde_json::to_string(auth).unwrap() - )); - } - if let Some(ref encryption) = config.encryption { - env_vars.push(format!( - "ENV LANGGRAPH_ENCRYPTION='{}'", - serde_json::to_string(encryption).unwrap() - )); - } - if let Some(ref http) = config.http { - env_vars.push(format!( - "ENV LANGGRAPH_HTTP='{}'", - serde_json::to_string(http).unwrap() - )); - } - if let Some(ref webhooks) = config.webhooks { - env_vars.push(format!( - "ENV LANGGRAPH_WEBHOOKS='{}'", - serde_json::to_string(webhooks).unwrap() - )); - } - if let Some(ref checkpointer) = config.checkpointer { - env_vars.push(format!( - "ENV LANGGRAPH_CHECKPOINTER='{}'", - serde_json::to_string(checkpointer).unwrap() - )); - } - if let Some(ref ui) = config.ui { - env_vars.push(format!( - "ENV LANGGRAPH_UI='{}'", - serde_json::to_string(ui).unwrap() - )); - } - if let Some(ref ui_config) = config.ui_config { - env_vars.push(format!( - "ENV LANGGRAPH_UI_CONFIG='{}'", - serde_json::to_string(ui_config).unwrap() - )); - } - env_vars.push(format!( - "ENV LANGSERVE_GRAPHS='{}'", - serde_json::to_string(&config.graphs).unwrap() - )); + let env_vars = build_config_env_vars(config); let (install_step, build_step) = if let Some(_bc) = build_context { let cr = container_root.as_ref().unwrap(); diff --git a/libs/cli/src/exec.rs b/libs/cli/src/exec.rs index 26c47182c..482058e8c 100644 --- a/libs/cli/src/exec.rs +++ b/libs/cli/src/exec.rs @@ -1,4 +1,4 @@ -use std::io::Write; +use std::io::{BufRead, BufReader, Write}; use std::process::{Command, Stdio}; /// Run a command synchronously, optionally piping stdin, and capturing stdout/stderr. @@ -134,3 +134,78 @@ pub fn run_command_streaming( Ok(()) } + +/// Run a command, streaming stderr to the parent process, while intercepting +/// stdout line-by-line through a callback. Each stdout line is forwarded to +/// the parent's stdout after the callback processes it. +pub fn run_command_streaming_with_callback( + cmd: &str, + args: &[&str], + input: Option<&str>, + verbose: bool, + mut on_stdout: F, +) -> Result<(), String> +where + F: FnMut(&str), +{ + if verbose { + let cmd_str = format!("+ {} {}", cmd, args.join(" ")); + if let Some(inp) = input { + let filtered: Vec<&str> = inp.lines().filter(|l| !l.is_empty()).collect(); + println!("{} <\n{}", cmd_str, filtered.join("\n")); + } else { + println!("{cmd_str}"); + } + } + + let stdin_cfg = if input.is_some() { + Stdio::piped() + } else { + Stdio::null() + }; + + let mut child = Command::new(cmd) + .args(args) + .stdin(stdin_cfg) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .map_err(|e| format!("Failed to execute `{cmd}`: {e}"))?; + + if let Some(input_data) = input { + if let Some(ref mut stdin_handle) = child.stdin { + stdin_handle + .write_all(input_data.as_bytes()) + .map_err(|e| format!("Failed to write to stdin of `{cmd}`: {e}"))?; + } + drop(child.stdin.take()); + } + + // Read stdout line by line, forward to our stdout, and call callback + if let Some(stdout) = child.stdout.take() { + let reader = BufReader::new(stdout); + for line in reader.lines() { + match line { + Ok(line) => { + println!("{line}"); + on_stdout(&line); + } + Err(_) => break, + } + } + } + + let status = child + .wait() + .map_err(|e| format!("Failed to wait for `{cmd}`: {e}"))?; + + if !status.success() { + let code = status.code().unwrap_or(-1); + if code == 130 { + return Ok(()); + } + return Err(format!("Command `{cmd}` exited with code {code}")); + } + + Ok(()) +} diff --git a/libs/cli/src/main.rs b/libs/cli/src/main.rs index 810cf0b10..aabea3870 100644 --- a/libs/cli/src/main.rs +++ b/libs/cli/src/main.rs @@ -47,9 +47,9 @@ enum Commands { #[arg(long)] recreate: bool, - /// Pull latest images before running - #[arg(long, default_value_t = true)] - pull: bool, + /// Skip pulling latest images before running + #[arg(long)] + no_pull: bool, /// Wait for services to be healthy before returning #[arg(long)] @@ -90,9 +90,9 @@ enum Commands { #[arg(short, long)] tag: String, - /// Pull latest images before building - #[arg(long, default_value_t = true)] - pull: bool, + /// Skip pulling latest images before building + #[arg(long)] + no_pull: bool, /// Base image for the LangGraph API server #[arg(long)] @@ -152,7 +152,7 @@ enum Commands { no_reload: bool, /// Path to configuration file - #[arg(long, default_value = "langgraph.json")] + #[arg(short, long, default_value = "langgraph.json")] config: String, /// Max concurrent jobs per worker @@ -210,7 +210,7 @@ fn main() { verbose, watch, recreate, - pull, + no_pull, wait, debugger_port, debugger_base_url, @@ -225,7 +225,7 @@ fn main() { verbose, watch, recreate, - pull, + !no_pull, wait, debugger_port, debugger_base_url.as_deref(), @@ -237,7 +237,7 @@ fn main() { Commands::Build { config, tag, - pull, + no_pull, base_image, api_version, install_command, @@ -246,7 +246,7 @@ fn main() { } => commands::build_cmd::run( &config, &tag, - pull, + !no_pull, base_image.as_deref(), api_version.as_deref(), install_command.as_deref(),