mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-18 05:35:43 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c768e4768a | ||
|
|
a8328b74ce | ||
|
|
bc01b76e60 |
@@ -0,0 +1,331 @@
|
||||
---
|
||||
name: CLI Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'cli-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag (e.g., cli-v0.2.10)'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write # OIDC trusted publishing for PyPI
|
||||
|
||||
concurrency:
|
||||
group: cli-release
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
# ──────────────────────────────────────────────
|
||||
# Build native binaries for each platform
|
||||
# ──────────────────────────────────────────────
|
||||
build:
|
||||
name: Build - ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: libs/cli
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-x64
|
||||
runner: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
binary: langgraph
|
||||
npm_pkg: langgraph-cli-linux-x64
|
||||
|
||||
- name: linux-arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
binary: langgraph
|
||||
npm_pkg: langgraph-cli-linux-arm64
|
||||
|
||||
- name: darwin-x64
|
||||
runner: macos-13 # Intel
|
||||
target: x86_64-apple-darwin
|
||||
binary: langgraph
|
||||
npm_pkg: langgraph-cli-darwin-x64
|
||||
|
||||
- name: darwin-arm64
|
||||
runner: macos-latest # Apple Silicon
|
||||
target: aarch64-apple-darwin
|
||||
binary: langgraph
|
||||
npm_pkg: langgraph-cli-darwin-arm64
|
||||
|
||||
- name: win32-x64
|
||||
runner: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
binary: langgraph.exe
|
||||
npm_pkg: langgraph-cli-win32-x64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: libs/cli
|
||||
key: ${{ matrix.target }}
|
||||
|
||||
- name: Build release binary
|
||||
run: cargo build --release --target ${{ matrix.target }}
|
||||
|
||||
- name: Run tests
|
||||
if: matrix.name != 'linux-arm64' # skip on ARM (slow QEMU)
|
||||
run: cargo test --release --target ${{ matrix.target }}
|
||||
|
||||
# Upload raw binary for GitHub release
|
||||
- name: Prepare binary artifact
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp target/${{ matrix.target }}/release/${{ matrix.binary }} dist/
|
||||
cd dist
|
||||
if [[ "${{ matrix.name }}" == win32-* ]]; then
|
||||
7z a ../langgraph-${{ matrix.name }}.zip ${{ matrix.binary }}
|
||||
else
|
||||
tar czf ../langgraph-${{ matrix.name }}.tar.gz ${{ matrix.binary }}
|
||||
fi
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: binary-${{ matrix.name }}
|
||||
path: |
|
||||
libs/cli/langgraph-${{ matrix.name }}.tar.gz
|
||||
libs/cli/langgraph-${{ matrix.name }}.zip
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Prepare npm platform package
|
||||
- name: Prepare npm platform package
|
||||
shell: bash
|
||||
run: |
|
||||
cp target/${{ matrix.target }}/release/${{ matrix.binary }} npm/${{ matrix.npm_pkg }}/bin/
|
||||
|
||||
- name: Upload npm platform package
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: npm-${{ matrix.name }}
|
||||
path: libs/cli/npm/${{ matrix.npm_pkg }}/
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Build Linux musl (Alpine-compatible) binary
|
||||
# ──────────────────────────────────────────────
|
||||
build-musl:
|
||||
name: Build - linux-x64-musl
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: libs/cli
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-unknown-linux-musl
|
||||
|
||||
- name: Install musl tools
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: libs/cli
|
||||
key: x86_64-unknown-linux-musl
|
||||
|
||||
- name: Build release binary
|
||||
run: cargo build --release --target x86_64-unknown-linux-musl
|
||||
|
||||
- name: Prepare binary artifact
|
||||
run: |
|
||||
mkdir -p dist
|
||||
cp target/x86_64-unknown-linux-musl/release/langgraph dist/
|
||||
cd dist && tar czf ../langgraph-linux-x64-musl.tar.gz langgraph
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: binary-linux-x64-musl
|
||||
path: libs/cli/langgraph-linux-x64-musl.tar.gz
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Build PyPI wheels via maturin
|
||||
# ──────────────────────────────────────────────
|
||||
pypi-wheels:
|
||||
name: PyPI wheel - ${{ matrix.name }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: linux-x64
|
||||
runner: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
manylinux: manylinux_2_17
|
||||
|
||||
- name: linux-arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
manylinux: manylinux_2_17
|
||||
|
||||
- name: linux-x64-musl
|
||||
runner: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
manylinux: musllinux_1_2
|
||||
|
||||
- name: darwin-x64
|
||||
runner: macos-13
|
||||
target: x86_64-apple-darwin
|
||||
manylinux: auto
|
||||
|
||||
- name: darwin-arm64
|
||||
runner: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
manylinux: auto
|
||||
|
||||
- name: win32-x64
|
||||
runner: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
manylinux: auto
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Build wheel
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
manylinux: ${{ matrix.manylinux }}
|
||||
args: --release --manifest-path libs/cli/Cargo.toml --out libs/cli/dist
|
||||
rust-toolchain: stable
|
||||
|
||||
- name: Upload wheel
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: wheel-${{ matrix.name }}
|
||||
path: libs/cli/dist/*.whl
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Build sdist
|
||||
# ──────────────────────────────────────────────
|
||||
pypi-sdist:
|
||||
name: PyPI sdist
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build sdist
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
command: sdist
|
||||
args: --manifest-path libs/cli/Cargo.toml --out libs/cli/dist
|
||||
|
||||
- name: Upload sdist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: sdist
|
||||
path: libs/cli/dist/*.tar.gz
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Publish to PyPI
|
||||
# ──────────────────────────────────────────────
|
||||
pypi-publish:
|
||||
name: Publish to PyPI
|
||||
needs: [pypi-wheels, pypi-sdist]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/cli-v')
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/project/langgraph-cli/
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: wheel-*
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: sdist
|
||||
path: dist
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Publish to npm
|
||||
# ──────────────────────────────────────────────
|
||||
npm-publish:
|
||||
name: Publish to npm
|
||||
needs: [build]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/cli-v')
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
# Download all npm platform packages
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: npm-*
|
||||
path: npm-artifacts
|
||||
|
||||
# Publish each platform package
|
||||
- name: Publish platform packages
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
for dir in npm-artifacts/npm-*/; do
|
||||
echo "Publishing $(basename $dir)..."
|
||||
cd "$dir"
|
||||
npm publish --access public
|
||||
cd -
|
||||
done
|
||||
|
||||
# Publish the main package
|
||||
- name: Publish main package
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: |
|
||||
cd libs/cli/npm/langgraph-cli
|
||||
npm publish --access public
|
||||
|
||||
# ──────────────────────────────────────────────
|
||||
# Create GitHub Release with standalone binaries
|
||||
# ──────────────────────────────────────────────
|
||||
github-release:
|
||||
name: GitHub Release
|
||||
needs: [build, build-musl]
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/cli-v')
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: binary-*
|
||||
path: release-artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: release-artifacts/*
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
name: CLI Rust
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'libs/cli/src/**'
|
||||
- 'libs/cli/Cargo.toml'
|
||||
- 'libs/cli/Cargo.lock'
|
||||
- 'libs/cli/build.rs'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'libs/cli/src/**'
|
||||
- 'libs/cli/Cargo.toml'
|
||||
- 'libs/cli/Cargo.lock'
|
||||
- 'libs/cli/build.rs'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Check / Lint / Test
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: libs/cli
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: libs/cli
|
||||
- name: Format check
|
||||
run: cargo fmt --check
|
||||
- name: Clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
- name: Test
|
||||
run: cargo test
|
||||
- name: Build release
|
||||
run: cargo build --release
|
||||
@@ -1 +1,10 @@
|
||||
.langgraph_api/
|
||||
npm/
|
||||
|
||||
# Rust build artifacts
|
||||
/target/
|
||||
/dist/
|
||||
|
||||
# npm platform binaries (added at build time, not checked in)
|
||||
npm/*/bin/langgraph
|
||||
npm/*/bin/langgraph.exe
|
||||
|
||||
Generated
+2521
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "langgraph-cli"
|
||||
version = "0.2.10"
|
||||
edition = "2021"
|
||||
description = "Native CLI for LangGraph"
|
||||
license = "MIT"
|
||||
|
||||
[[bin]]
|
||||
name = "langgraph"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1" }
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "blocking"], default-features = false }
|
||||
zip = "2"
|
||||
indexmap = { version = "2", features = ["serde"] }
|
||||
open = "5"
|
||||
indicatif = "0.17"
|
||||
console = "0.15"
|
||||
dotenvy = "0.15"
|
||||
tempfile = "3"
|
||||
thiserror = "2"
|
||||
dialoguer = "0.11"
|
||||
regex = "1"
|
||||
which = "7"
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
predicates = "3"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
strip = "symbols"
|
||||
+52
-5
@@ -1,7 +1,27 @@
|
||||
.PHONY: test lint format test-integration update-schema bump-version
|
||||
.PHONY: test lint format test-integration update-schema build build-release clean cargo-test cargo-lint
|
||||
|
||||
######################
|
||||
# TESTING AND COVERAGE
|
||||
# RUST BUILD
|
||||
######################
|
||||
|
||||
build:
|
||||
cargo build
|
||||
|
||||
build-release:
|
||||
cargo build --release
|
||||
|
||||
cargo-test:
|
||||
cargo test
|
||||
|
||||
cargo-lint:
|
||||
cargo fmt --check
|
||||
cargo clippy -- -D warnings
|
||||
|
||||
cargo-fmt:
|
||||
cargo fmt
|
||||
|
||||
######################
|
||||
# PYTHON TESTING AND COVERAGE
|
||||
######################
|
||||
|
||||
TEST?= "tests/unit_tests"
|
||||
@@ -11,7 +31,7 @@ test-integration:
|
||||
uv run pytest tests/integration_tests
|
||||
|
||||
######################
|
||||
# LINTING AND FORMATTING
|
||||
# PYTHON LINTING AND FORMATTING
|
||||
######################
|
||||
|
||||
# Define a variable for Python and notebook files.
|
||||
@@ -33,8 +53,35 @@ format format_diff:
|
||||
uv run ruff format $(PYTHON_FILES)
|
||||
uv run ruff check --select I --fix $(PYTHON_FILES)
|
||||
|
||||
######################
|
||||
# PACKAGING
|
||||
######################
|
||||
|
||||
# Build maturin wheel for current platform (development)
|
||||
wheel-dev:
|
||||
maturin build --release
|
||||
|
||||
# Build maturin wheel for specific target
|
||||
# Usage: make wheel TARGET=x86_64-unknown-linux-gnu
|
||||
TARGET?=
|
||||
wheel:
|
||||
maturin build --release $(if $(TARGET),--target $(TARGET),)
|
||||
|
||||
# Build sdist
|
||||
sdist:
|
||||
maturin sdist
|
||||
|
||||
######################
|
||||
# SCHEMA
|
||||
######################
|
||||
|
||||
update-schema:
|
||||
uv run python generate_schema.py
|
||||
|
||||
bump-version:
|
||||
uv run hatch version patch
|
||||
######################
|
||||
# CLEANUP
|
||||
######################
|
||||
|
||||
clean:
|
||||
cargo clean
|
||||
rm -rf dist/ target/wheels/
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
// Cargo already sets CARGO_PKG_VERSION, nothing to do
|
||||
}
|
||||
+8
-15
@@ -1,6 +1,6 @@
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
requires = ["maturin>=1.0,<2.0"]
|
||||
build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "langgraph-cli"
|
||||
@@ -11,12 +11,8 @@ requires-python = ">=3.10"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ['LICENSE']
|
||||
dependencies = [
|
||||
"click>=8.1.7",
|
||||
"langgraph-sdk>=0.1.0 ; python_version >= '3.11'",
|
||||
]
|
||||
[tool.hatch.version]
|
||||
path = "langgraph_cli/__init__.py"
|
||||
dependencies = []
|
||||
|
||||
[project.optional-dependencies]
|
||||
inmem = [
|
||||
"langgraph-api>=0.5.35,<0.8.0 ; python_version >= '3.11'",
|
||||
@@ -30,8 +26,10 @@ Twitter = "https://x.com/LangChain"
|
||||
Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
[project.scripts]
|
||||
langgraph = "langgraph_cli.cli:cli"
|
||||
[tool.maturin]
|
||||
bindings = "bin"
|
||||
manifest-path = "Cargo.toml"
|
||||
strip = true
|
||||
|
||||
[dependency-groups]
|
||||
test = [
|
||||
@@ -49,15 +47,11 @@ lint = [
|
||||
dev = [
|
||||
{include-group = "test"},
|
||||
{include-group = "lint"},
|
||||
"hatch>=1.16.2",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
default-groups = ['dev']
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
include = ["langgraph_cli"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--strict-markers --strict-config --durations=5 -vv"
|
||||
asyncio_mode = "auto"
|
||||
@@ -69,7 +63,6 @@ lint.select = [
|
||||
"UP", # pyupgrade
|
||||
"B", # flake8-bugbear
|
||||
"I", # isort
|
||||
"UP", # pyupgrade
|
||||
]
|
||||
lint.ignore = ["E501", "B008"]
|
||||
target-version = "py310"
|
||||
|
||||
Executable
+49
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bumps the version across Cargo.toml and all npm package.json files.
|
||||
# Usage: ./scripts/bump-version.sh 0.3.0
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ $# -ne 1 ]; then
|
||||
echo "Usage: $0 <version>"
|
||||
echo "Example: $0 0.3.0"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION="$1"
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CLI_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
echo "Bumping version to $VERSION"
|
||||
|
||||
# Cargo.toml
|
||||
sed -i.bak "s/^version = \".*\"/version = \"$VERSION\"/" "$CLI_DIR/Cargo.toml"
|
||||
rm -f "$CLI_DIR/Cargo.toml.bak"
|
||||
echo " Updated Cargo.toml"
|
||||
|
||||
# All npm package.json files
|
||||
for pkg in "$CLI_DIR"/npm/*/package.json; do
|
||||
# Update the package's own version
|
||||
sed -i.bak "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" "$pkg"
|
||||
rm -f "$pkg.bak"
|
||||
|
||||
# Update optionalDependencies versions (main package only)
|
||||
if grep -q "optionalDependencies" "$pkg" 2>/dev/null; then
|
||||
sed -i.bak "s/\"@langchain\/langgraph-cli-\([^\"]*\)\": \"[^\"]*\"/\"@langchain\/langgraph-cli-\1\": \"$VERSION\"/" "$pkg"
|
||||
rm -f "$pkg.bak"
|
||||
fi
|
||||
|
||||
echo " Updated $(basename "$(dirname "$pkg")")/package.json"
|
||||
done
|
||||
|
||||
# Update Cargo.lock
|
||||
cd "$CLI_DIR"
|
||||
cargo update -p langgraph-cli 2>/dev/null || true
|
||||
echo " Updated Cargo.lock"
|
||||
|
||||
echo "Done! Version is now $VERSION"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. git add -A && git commit -m 'chore(cli): bump version to $VERSION'"
|
||||
echo " 2. git tag cli-v$VERSION"
|
||||
echo " 3. git push origin main --tags"
|
||||
@@ -0,0 +1,50 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::constants::{SUPABASE_PUBLIC_API_KEY, SUPABASE_URL};
|
||||
|
||||
/// Fire-and-forget telemetry: log a CLI command invocation.
|
||||
///
|
||||
/// Spawns a background thread that POSTs anonymized usage data to Supabase.
|
||||
/// Respects the `LANGGRAPH_CLI_NO_ANALYTICS` environment variable -- if set to "1",
|
||||
/// no data is sent.
|
||||
pub fn log_command(command: &str, params: &HashMap<String, String>) {
|
||||
if std::env::var("LANGGRAPH_CLI_NO_ANALYTICS").as_deref() == Ok("1") {
|
||||
return;
|
||||
}
|
||||
|
||||
let os_name = std::env::consts::OS.to_string();
|
||||
let arch = std::env::consts::ARCH.to_string();
|
||||
let cli_version = env!("CARGO_PKG_VERSION").to_string();
|
||||
let command = command.to_string();
|
||||
let params = params.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let data = serde_json::json!({
|
||||
"os": os_name,
|
||||
"os_version": arch,
|
||||
"python_version": "rust",
|
||||
"cli_version": cli_version,
|
||||
"cli_command": command,
|
||||
"params": params,
|
||||
});
|
||||
|
||||
let url = format!("{SUPABASE_URL}/rest/v1/logs");
|
||||
|
||||
// Use a blocking reqwest client in this thread
|
||||
let client = match reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(5))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let _ = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("apikey", SUPABASE_PUBLIC_API_KEY)
|
||||
.header("User-Agent", "Mozilla/5.0")
|
||||
.body(data.to_string())
|
||||
.send();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use console::style;
|
||||
|
||||
use crate::analytics;
|
||||
use crate::config::docker_tag::docker_tag;
|
||||
use crate::config::validate_config_file;
|
||||
use crate::docker::dockerfile::config_to_docker;
|
||||
use crate::exec::{run_command, run_command_streaming};
|
||||
use crate::progress::Progress;
|
||||
use crate::util::warn_non_wolfi_distro;
|
||||
|
||||
/// Build a LangGraph API server Docker image.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run(
|
||||
config: &str,
|
||||
tag: &str,
|
||||
pull: bool,
|
||||
base_image: Option<&str>,
|
||||
api_version: Option<&str>,
|
||||
install_command: Option<&str>,
|
||||
build_command: Option<&str>,
|
||||
docker_build_args: &[String],
|
||||
) -> Result<(), String> {
|
||||
// Fire-and-forget analytics
|
||||
let mut params = HashMap::new();
|
||||
params.insert("pull".to_string(), pull.to_string());
|
||||
analytics::log_command("build", ¶ms);
|
||||
|
||||
// Check docker is available
|
||||
if which::which("docker").is_err() {
|
||||
return Err("Docker not installed".to_string());
|
||||
}
|
||||
|
||||
let progress = Progress::new("Pulling...");
|
||||
|
||||
// Validate config
|
||||
let config_path = Path::new(config);
|
||||
let mut config_json = validate_config_file(config_path)?;
|
||||
let config_value = serde_json::to_value(&config_json).unwrap();
|
||||
warn_non_wolfi_distro(&config_value);
|
||||
|
||||
// Pull latest images if requested
|
||||
if pull {
|
||||
let image_tag = docker_tag(&config_json, base_image, api_version);
|
||||
run_command("docker", &["pull", &image_tag], None, true)?;
|
||||
}
|
||||
|
||||
progress.set_message("Building...");
|
||||
|
||||
// Determine build context
|
||||
let is_js_project =
|
||||
config_json.node_version.is_some() && config_json.python_version.is_none();
|
||||
|
||||
// For JS projects with install/build commands, use CWD; otherwise use config parent
|
||||
let build_context = if is_js_project && (build_command.is_some() || install_command.is_some()) {
|
||||
std::env::current_dir()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|_| ".".to_string())
|
||||
} else {
|
||||
config_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
};
|
||||
|
||||
// Generate Dockerfile
|
||||
let (dockerfile_content, additional_contexts) = config_to_docker(
|
||||
config_path,
|
||||
&mut config_json,
|
||||
base_image,
|
||||
api_version,
|
||||
install_command,
|
||||
build_command,
|
||||
Some(&build_context),
|
||||
false, // no variable escaping for docker build
|
||||
)?;
|
||||
|
||||
// Build docker build args
|
||||
let mut args: Vec<String> = vec![
|
||||
"build".to_string(),
|
||||
"-f".to_string(),
|
||||
"-".to_string(), // Dockerfile from stdin
|
||||
"-t".to_string(),
|
||||
tag.to_string(),
|
||||
];
|
||||
|
||||
// Add additional build contexts
|
||||
for (name, path) in &additional_contexts {
|
||||
args.push("--build-context".to_string());
|
||||
args.push(format!("{name}={path}"));
|
||||
}
|
||||
|
||||
// Add passthrough docker build args
|
||||
for extra in docker_build_args {
|
||||
args.push(extra.clone());
|
||||
}
|
||||
|
||||
// Add build context as last arg
|
||||
args.push(build_context);
|
||||
|
||||
progress.finish();
|
||||
|
||||
// Run docker build with streaming output
|
||||
let args_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
|
||||
run_command_streaming("docker", &args_refs, Some(&dockerfile_content), true)?;
|
||||
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!("Successfully built image: {tag}")).green()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use console::style;
|
||||
|
||||
use crate::analytics;
|
||||
use crate::config::validate_config_file;
|
||||
|
||||
/// Find the Python interpreter, preferring python3 over python.
|
||||
fn find_python() -> Result<String, String> {
|
||||
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 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,
|
||||
port: u16,
|
||||
no_reload: bool,
|
||||
config: &str,
|
||||
n_jobs_per_worker: Option<u32>,
|
||||
no_browser: bool,
|
||||
debug_port: Option<u16>,
|
||||
wait_for_client: bool,
|
||||
studio_url: Option<&str>,
|
||||
allow_blocking: bool,
|
||||
tunnel: bool,
|
||||
server_log_level: &str,
|
||||
) -> Result<(), String> {
|
||||
// Fire-and-forget analytics
|
||||
let mut params = HashMap::new();
|
||||
params.insert("no_reload".to_string(), no_reload.to_string());
|
||||
params.insert("no_browser".to_string(), no_browser.to_string());
|
||||
params.insert("allow_blocking".to_string(), allow_blocking.to_string());
|
||||
params.insert("tunnel".to_string(), tunnel.to_string());
|
||||
analytics::log_command("dev", ¶ms);
|
||||
|
||||
// Validate config
|
||||
let config_path = Path::new(config);
|
||||
let config_json = validate_config_file(config_path)?;
|
||||
|
||||
// Check for node_version -- in-mem server doesn't support JS graphs
|
||||
if config_json.node_version.is_some() {
|
||||
return Err(
|
||||
"In-mem server for JS graphs is not supported in this version of the LangGraph CLI. \
|
||||
Please use `npx @langchain/langgraph-cli` instead."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let python = find_python()?;
|
||||
|
||||
// Pre-check that langgraph_api is importable
|
||||
let check = Command::new(&python)
|
||||
.args(["-c", "from langgraph_api.cli import run_server"])
|
||||
.output();
|
||||
|
||||
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 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,
|
||||
});
|
||||
|
||||
// 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)
|
||||
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
|
||||
run_server(
|
||||
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'],
|
||||
)
|
||||
"#;
|
||||
|
||||
eprintln!(
|
||||
"{}",
|
||||
style("Starting LangGraph API server in development mode...").green()
|
||||
);
|
||||
|
||||
// Spawn Python subprocess with config on stdin
|
||||
let mut child = Command::new(&python)
|
||||
.arg("-c")
|
||||
.arg(python_code)
|
||||
.current_dir(
|
||||
config_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new(".")),
|
||||
)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::inherit())
|
||||
.stderr(std::process::Stdio::inherit())
|
||||
.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);
|
||||
if code == 130 {
|
||||
// User interrupted with Ctrl-C
|
||||
return Ok(());
|
||||
}
|
||||
return Err(format!("Development server exited with code {code}"));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use console::style;
|
||||
|
||||
use crate::analytics;
|
||||
use crate::config::validate_config_file;
|
||||
use crate::docker::capabilities::check_capabilities;
|
||||
use crate::docker::compose::compose_as_dict;
|
||||
use crate::docker::compose::dict_to_yaml;
|
||||
use crate::docker::dockerfile::config_to_docker;
|
||||
use crate::util::warn_non_wolfi_distro;
|
||||
|
||||
/// Docker ignore file content.
|
||||
fn get_docker_ignore_content() -> &'static str {
|
||||
"\
|
||||
# Ignore node_modules and other dependency directories
|
||||
node_modules
|
||||
bower_components
|
||||
vendor
|
||||
|
||||
# Ignore logs and temporary files
|
||||
*.log
|
||||
*.tmp
|
||||
*.swp
|
||||
|
||||
# Ignore .env files and other environment files
|
||||
.env
|
||||
.env.*
|
||||
*.local
|
||||
|
||||
# Ignore git-related files
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Ignore Docker-related files and configs
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
|
||||
# Ignore build and cache directories
|
||||
dist
|
||||
build
|
||||
.cache
|
||||
__pycache__
|
||||
|
||||
# Ignore IDE and editor configurations
|
||||
.vscode
|
||||
.idea
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
.DS_Store # macOS-specific
|
||||
|
||||
# Ignore test and coverage files
|
||||
coverage
|
||||
*.coverage
|
||||
*.test.js
|
||||
*.spec.js
|
||||
tests
|
||||
"
|
||||
}
|
||||
|
||||
/// Generate a Dockerfile for the LangGraph API server.
|
||||
pub fn run(
|
||||
save_path: &str,
|
||||
config: &str,
|
||||
add_docker_compose: bool,
|
||||
base_image: Option<&str>,
|
||||
api_version: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
// Fire-and-forget analytics
|
||||
let mut params = HashMap::new();
|
||||
params.insert(
|
||||
"add_docker_compose".to_string(),
|
||||
add_docker_compose.to_string(),
|
||||
);
|
||||
analytics::log_command("dockerfile", ¶ms);
|
||||
|
||||
let save_path = Path::new(save_path);
|
||||
let abs_save_path = if save_path.is_absolute() {
|
||||
save_path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_default()
|
||||
.join(save_path)
|
||||
};
|
||||
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!("Validating configuration at path: {config}")).yellow()
|
||||
);
|
||||
let config_path = Path::new(config);
|
||||
let mut config_json = validate_config_file(config_path)?;
|
||||
let config_value = serde_json::to_value(&config_json).unwrap();
|
||||
warn_non_wolfi_distro(&config_value);
|
||||
eprintln!("{}", style("Configuration validated!").green());
|
||||
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!(
|
||||
"Generating Dockerfile at {}",
|
||||
abs_save_path.display()
|
||||
))
|
||||
.yellow()
|
||||
);
|
||||
|
||||
let (dockerfile_content, additional_contexts) = config_to_docker(
|
||||
config_path,
|
||||
&mut config_json,
|
||||
base_image,
|
||||
api_version,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
false,
|
||||
)?;
|
||||
|
||||
std::fs::write(&abs_save_path, &dockerfile_content)
|
||||
.map_err(|e| format!("Failed to write Dockerfile: {e}"))?;
|
||||
eprintln!("{}", style("Created: Dockerfile").green());
|
||||
|
||||
if !additional_contexts.is_empty() {
|
||||
let ctx_str: Vec<String> = additional_contexts
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}={v}"))
|
||||
.collect();
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!(
|
||||
"Run docker build with these additional build contexts `--build-context {}`",
|
||||
ctx_str.join(",")
|
||||
))
|
||||
.yellow()
|
||||
);
|
||||
}
|
||||
|
||||
if add_docker_compose {
|
||||
let parent = abs_save_path.parent().unwrap_or(Path::new("."));
|
||||
|
||||
// Write .dockerignore
|
||||
let dockerignore_path = parent.join(".dockerignore");
|
||||
std::fs::write(&dockerignore_path, get_docker_ignore_content())
|
||||
.map_err(|e| format!("Failed to write .dockerignore: {e}"))?;
|
||||
eprintln!("{}", style("Created: .dockerignore").green());
|
||||
|
||||
// Generate docker-compose.yml
|
||||
let capabilities = check_capabilities()?;
|
||||
let mut compose_dict = compose_as_dict(
|
||||
&capabilities,
|
||||
8123,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
base_image,
|
||||
api_version,
|
||||
);
|
||||
|
||||
// Add env_file and build context to langgraph-api service
|
||||
if let Some(crate::docker::compose::YamlValue::Dict(ref mut services)) =
|
||||
compose_dict.get_mut("services")
|
||||
{
|
||||
if let Some(crate::docker::compose::YamlValue::Dict(ref mut api_service)) =
|
||||
services.get_mut("langgraph-api")
|
||||
{
|
||||
api_service.insert(
|
||||
"env_file".to_string(),
|
||||
crate::docker::compose::YamlValue::List(vec![".env".to_string()]),
|
||||
);
|
||||
|
||||
let dockerfile_name = abs_save_path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let mut build_config = indexmap::IndexMap::new();
|
||||
build_config.insert(
|
||||
"context".to_string(),
|
||||
crate::docker::compose::YamlValue::String(".".to_string()),
|
||||
);
|
||||
build_config.insert(
|
||||
"dockerfile".to_string(),
|
||||
crate::docker::compose::YamlValue::String(dockerfile_name),
|
||||
);
|
||||
api_service.insert(
|
||||
"build".to_string(),
|
||||
crate::docker::compose::YamlValue::Dict(build_config),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let compose_yaml = dict_to_yaml(&compose_dict, 0);
|
||||
let compose_path = parent.join("docker-compose.yml");
|
||||
std::fs::write(&compose_path, &compose_yaml)
|
||||
.map_err(|e| format!("Failed to write docker-compose.yml: {e}"))?;
|
||||
eprintln!("{}", style("Created: docker-compose.yml").green());
|
||||
|
||||
// Create .env file if it doesn't exist
|
||||
let env_path = parent.join(".env");
|
||||
if !env_path.exists() {
|
||||
let env_content = "\
|
||||
# Uncomment the following line to add your LangSmith API key
|
||||
# LANGSMITH_API_KEY=your-api-key
|
||||
# Or if you have a LangSmith Deployment license key, then uncomment the following line:
|
||||
# LANGGRAPH_CLOUD_LICENSE_KEY=your-license-key
|
||||
# Add any other environment variables go below...
|
||||
";
|
||||
std::fs::write(&env_path, env_content)
|
||||
.map_err(|e| format!("Failed to write .env: {e}"))?;
|
||||
eprintln!("{}", style("Created: .env").green());
|
||||
} else {
|
||||
eprintln!(
|
||||
"{}",
|
||||
style("Skipped: .env. It already exists!").yellow()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!(
|
||||
"Files generated successfully at path {}!",
|
||||
abs_save_path
|
||||
.parent()
|
||||
.unwrap_or(Path::new("."))
|
||||
.display()
|
||||
))
|
||||
.cyan()
|
||||
.bold()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
pub mod build_cmd;
|
||||
pub mod dev;
|
||||
pub mod dockerfile;
|
||||
pub mod new;
|
||||
pub mod up;
|
||||
@@ -0,0 +1,16 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::analytics;
|
||||
use crate::templates;
|
||||
|
||||
/// Create a new LangGraph project from a template.
|
||||
pub fn run(path: Option<&str>, template: Option<&str>) -> Result<(), String> {
|
||||
// Fire-and-forget analytics
|
||||
let mut params = HashMap::new();
|
||||
if let Some(t) = template {
|
||||
params.insert("template".to_string(), t.to_string());
|
||||
}
|
||||
analytics::log_command("new", ¶ms);
|
||||
|
||||
templates::create_new(path, template)
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use console::style;
|
||||
|
||||
use crate::analytics;
|
||||
use crate::config::docker_tag::{default_base_image, docker_tag};
|
||||
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_with_callback};
|
||||
use crate::progress::Progress;
|
||||
use crate::util::warn_non_wolfi_distro;
|
||||
|
||||
/// Launch LangGraph API server with Docker Compose.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run(
|
||||
config: &str,
|
||||
port: u16,
|
||||
docker_compose: Option<&str>,
|
||||
verbose: bool,
|
||||
watch: bool,
|
||||
recreate: bool,
|
||||
pull: bool,
|
||||
wait: bool,
|
||||
debugger_port: Option<u16>,
|
||||
debugger_base_url: Option<&str>,
|
||||
postgres_uri: Option<&str>,
|
||||
api_version: Option<&str>,
|
||||
image: Option<&str>,
|
||||
base_image: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
// Fire-and-forget analytics
|
||||
let mut params = HashMap::new();
|
||||
params.insert("verbose".to_string(), verbose.to_string());
|
||||
params.insert("watch".to_string(), watch.to_string());
|
||||
params.insert("recreate".to_string(), recreate.to_string());
|
||||
params.insert("pull".to_string(), pull.to_string());
|
||||
params.insert("wait".to_string(), wait.to_string());
|
||||
analytics::log_command("up", ¶ms);
|
||||
|
||||
eprintln!("{}", style("Starting LangGraph API server...").green());
|
||||
eprintln!(
|
||||
"For local dev, requires env var LANGSMITH_API_KEY with access to LangSmith Deployment.\n\
|
||||
For production use, requires a license key in env var LANGGRAPH_CLOUD_LICENSE_KEY."
|
||||
);
|
||||
|
||||
let progress = Progress::new("Pulling...");
|
||||
|
||||
// Validate config
|
||||
let config_path = Path::new(config);
|
||||
let mut config_json = validate_config_file(config_path)?;
|
||||
let config_value = serde_json::to_value(&config_json).unwrap();
|
||||
warn_non_wolfi_distro(&config_value);
|
||||
|
||||
// Check docker capabilities
|
||||
let capabilities = check_capabilities()?;
|
||||
|
||||
// Pull latest images if requested
|
||||
if pull {
|
||||
let tag = docker_tag(&config_json, base_image, api_version);
|
||||
progress.set_message("Pulling...");
|
||||
run_command("docker", &["pull", &tag], None, verbose)?;
|
||||
}
|
||||
|
||||
// Generate compose YAML
|
||||
let debugger_base_url_resolved = debugger_base_url
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| format!("http://127.0.0.1:{port}"));
|
||||
|
||||
let mut compose_stdin = compose(
|
||||
&capabilities,
|
||||
port,
|
||||
debugger_port,
|
||||
Some(&debugger_base_url_resolved),
|
||||
postgres_uri,
|
||||
image,
|
||||
base_image,
|
||||
api_version,
|
||||
);
|
||||
|
||||
// Append config-to-compose output (build instructions, env, watch sections)
|
||||
let base_img = base_image
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| default_base_image(&config_json));
|
||||
let compose_config = config_to_compose(
|
||||
config_path,
|
||||
&mut config_json,
|
||||
Some(&base_img),
|
||||
api_version,
|
||||
image,
|
||||
watch,
|
||||
)?;
|
||||
compose_stdin.push_str(&compose_config);
|
||||
|
||||
// Build docker compose args
|
||||
let mut args: Vec<String> = Vec::new();
|
||||
args.push("--project-directory".to_string());
|
||||
args.push(
|
||||
config_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new("."))
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
if let Some(dc) = docker_compose {
|
||||
args.push("-f".to_string());
|
||||
args.push(dc.to_string());
|
||||
}
|
||||
|
||||
// Read compose from stdin
|
||||
args.push("-f".to_string());
|
||||
args.push("-".to_string());
|
||||
|
||||
// Add up + options
|
||||
args.push("up".to_string());
|
||||
args.push("--remove-orphans".to_string());
|
||||
|
||||
if recreate {
|
||||
args.push("--force-recreate".to_string());
|
||||
args.push("--renew-anon-volumes".to_string());
|
||||
// Try to remove the volume, ignore errors
|
||||
let _ = run_command("docker", &["volume", "rm", "langgraph-data"], None, false);
|
||||
}
|
||||
|
||||
if watch {
|
||||
args.push("--watch".to_string());
|
||||
}
|
||||
|
||||
if wait {
|
||||
args.push("--wait".to_string());
|
||||
} else {
|
||||
args.push("--abort-on-container-exit".to_string());
|
||||
}
|
||||
|
||||
progress.set_message("Building...");
|
||||
|
||||
// Determine compose command
|
||||
let compose_cmd = match capabilities.compose_type {
|
||||
ComposeType::Plugin => vec!["docker", "compose"],
|
||||
ComposeType::Standalone => vec!["docker-compose"],
|
||||
};
|
||||
|
||||
// Build final command args
|
||||
let mut cmd_args: Vec<&str> = Vec::new();
|
||||
if compose_cmd.len() > 1 {
|
||||
// "docker compose ..."
|
||||
cmd_args.extend_from_slice(&compose_cmd[1..]);
|
||||
}
|
||||
for a in &args {
|
||||
cmd_args.push(a.as_str());
|
||||
}
|
||||
|
||||
// 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_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(())
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
use super::schema::Config;
|
||||
use crate::constants::DEFAULT_IMAGE_DISTRO;
|
||||
|
||||
/// Get the default base image for a config.
|
||||
pub fn default_base_image(config: &Config) -> String {
|
||||
if let Some(ref base) = config.base_image {
|
||||
return base.clone();
|
||||
}
|
||||
if config.node_version.is_some() && config.python_version.is_none() {
|
||||
"langchain/langgraphjs-api".to_string()
|
||||
} else {
|
||||
"langchain/langgraph-api".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the Docker image tag string.
|
||||
pub fn docker_tag(
|
||||
config: &Config,
|
||||
base_image: Option<&str>,
|
||||
api_version: Option<&str>,
|
||||
) -> String {
|
||||
let api_version = api_version
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| config.api_version.clone());
|
||||
let base_image = base_image
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| default_base_image(config));
|
||||
|
||||
let image_distro = config
|
||||
.image_distro
|
||||
.as_deref()
|
||||
.unwrap_or(DEFAULT_IMAGE_DISTRO);
|
||||
let distro_tag = if image_distro == DEFAULT_IMAGE_DISTRO {
|
||||
String::new()
|
||||
} else {
|
||||
format!("-{image_distro}")
|
||||
};
|
||||
|
||||
if let Some(ref tag) = config.internal_docker_tag {
|
||||
return format!("{base_image}:{tag}");
|
||||
}
|
||||
|
||||
// Build the standard tag format
|
||||
let (language, version) = if config.node_version.is_some() && config.python_version.is_none() {
|
||||
("node", config.node_version.as_deref().unwrap_or("20"))
|
||||
} else {
|
||||
(
|
||||
"py",
|
||||
config
|
||||
.python_version
|
||||
.as_deref()
|
||||
.unwrap_or(crate::constants::DEFAULT_PYTHON_VERSION),
|
||||
)
|
||||
};
|
||||
|
||||
let version_distro_tag = format!("{version}{distro_tag}");
|
||||
|
||||
if let Some(api_ver) = api_version {
|
||||
format!("{base_image}:{api_ver}-{language}{version_distro_tag}")
|
||||
} else if base_image.contains("/langgraph-server") && !base_image.contains(&version_distro_tag)
|
||||
{
|
||||
format!("{base_image}-{language}{version_distro_tag}")
|
||||
} else {
|
||||
format!("{base_image}:{version_distro_tag}")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::schema::Config;
|
||||
use crate::config::validate_config;
|
||||
use serde_json::json;
|
||||
|
||||
fn config_from_json(v: serde_json::Value) -> Config {
|
||||
serde_json::from_value(v).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_base_image_python() {
|
||||
let config = Config {
|
||||
python_version: Some("3.11".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(default_base_image(&config), "langchain/langgraph-api");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_base_image_node() {
|
||||
let config = Config {
|
||||
node_version: Some("20".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(default_base_image(&config), "langchain/langgraphjs-api");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_basic() {
|
||||
let config = Config {
|
||||
python_version: Some("3.11".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
docker_tag(&config, None, None),
|
||||
"langchain/langgraph-api:3.11"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_with_api_version_basic() {
|
||||
let config = Config {
|
||||
python_version: Some("3.11".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
docker_tag(&config, None, Some("0.2.74")),
|
||||
"langchain/langgraph-api:0.2.74-py3.11"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_wolfi() {
|
||||
let config = Config {
|
||||
python_version: Some("3.12".to_string()),
|
||||
image_distro: Some("wolfi".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
docker_tag(&config, None, None),
|
||||
"langchain/langgraph-api:3.12-wolfi"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_internal() {
|
||||
let config = Config {
|
||||
python_version: Some("3.11".to_string()),
|
||||
internal_docker_tag: Some("custom-tag".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
docker_tag(&config, None, None),
|
||||
"langchain/langgraph-api:custom-tag"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Comprehensive tests ported from Python test_config.py
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_image_distro() {
|
||||
// Test 1: Default distro (debian) - no suffix
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(docker_tag(&cfg, None, None), "langchain/langgraph-api:3.11");
|
||||
|
||||
// Test 2: Explicit debian distro - same as default
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "debian"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(docker_tag(&cfg, None, None), "langchain/langgraph-api:3.11");
|
||||
|
||||
// Test 3: Wolfi distro with python
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, None),
|
||||
"langchain/langgraph-api:3.11-wolfi"
|
||||
);
|
||||
|
||||
// Test 4: Node.js with default distro
|
||||
let cfg = config_from_json(json!({
|
||||
"node_version": "20",
|
||||
"graphs": {"agent": "./agent.js:graph"}
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, None),
|
||||
"langchain/langgraphjs-api:20"
|
||||
);
|
||||
|
||||
// Test 5: Node.js with wolfi distro
|
||||
let cfg = config_from_json(json!({
|
||||
"node_version": "20",
|
||||
"graphs": {"agent": "./agent.js:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, None),
|
||||
"langchain/langgraphjs-api:20-wolfi"
|
||||
);
|
||||
|
||||
// Test 6: Custom base image with wolfi
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "wolfi",
|
||||
"base_image": "my-registry/custom-image"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, Some("my-registry/custom-image"), None),
|
||||
"my-registry/custom-image:3.12-wolfi"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_multiplatform_with_distro() {
|
||||
// Test 1: Python + Node with wolfi -> defaults to Python
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": "3.11",
|
||||
"node_version": "20",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"python": "./agent.py:graph", "js": "./agent.js:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, None),
|
||||
"langchain/langgraph-api:3.11-wolfi"
|
||||
);
|
||||
|
||||
// Test 2: Node-only with wolfi
|
||||
let cfg = config_from_json(json!({
|
||||
"node_version": "20",
|
||||
"graphs": {"js": "./agent.js:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, None),
|
||||
"langchain/langgraphjs-api:20-wolfi"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_different_python_versions_with_distro() {
|
||||
for (ver, expected) in &[
|
||||
("3.11", "langchain/langgraph-api:3.11-wolfi"),
|
||||
("3.12", "langchain/langgraph-api:3.12-wolfi"),
|
||||
("3.13", "langchain/langgraph-api:3.13-wolfi"),
|
||||
] {
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": ver,
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, None),
|
||||
*expected,
|
||||
"Failed for Python {ver}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_different_node_versions_with_distro() {
|
||||
for (ver, expected) in &[
|
||||
("20", "langchain/langgraphjs-api:20-wolfi"),
|
||||
("21", "langchain/langgraphjs-api:21-wolfi"),
|
||||
("22", "langchain/langgraphjs-api:22-wolfi"),
|
||||
] {
|
||||
let cfg = config_from_json(json!({
|
||||
"node_version": ver,
|
||||
"graphs": {"agent": "./agent.js:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, None),
|
||||
*expected,
|
||||
"Failed for Node.js {ver}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper to run a single api_version test scenario with both in_config and as-argument modes.
|
||||
fn run_api_version_test(
|
||||
base_json: serde_json::Value,
|
||||
api_version: &str,
|
||||
base_image_arg: Option<&str>,
|
||||
expected: &str,
|
||||
) {
|
||||
// Mode 1: api_version passed as function argument (not in config)
|
||||
{
|
||||
let cfg = config_from_json(base_json.clone());
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, base_image_arg, Some(api_version)),
|
||||
expected,
|
||||
"Failed with api_version as argument"
|
||||
);
|
||||
}
|
||||
|
||||
// Mode 2: api_version set in config (not passed as argument)
|
||||
{
|
||||
let mut json_val = base_json.clone();
|
||||
json_val
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("api_version".to_string(), json!(api_version));
|
||||
let cfg = config_from_json(json_val);
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, base_image_arg, None),
|
||||
expected,
|
||||
"Failed with api_version in config"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_tag_with_api_version() {
|
||||
let version = "0.2.74";
|
||||
|
||||
// Test 1: Python config with api_version and default distro
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}),
|
||||
version,
|
||||
None,
|
||||
"langchain/langgraph-api:0.2.74-py3.11",
|
||||
);
|
||||
|
||||
// Test 2: Python config with api_version and wolfi distro
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}),
|
||||
version,
|
||||
None,
|
||||
"langchain/langgraph-api:0.2.74-py3.12-wolfi",
|
||||
);
|
||||
|
||||
// Test 3: Node.js config with api_version and default distro
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"node_version": "20",
|
||||
"graphs": {"agent": "./agent.js:graph"}
|
||||
}),
|
||||
version,
|
||||
None,
|
||||
"langchain/langgraphjs-api:0.2.74-node20",
|
||||
);
|
||||
|
||||
// Test 4: Node.js config with api_version and wolfi distro
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"node_version": "20",
|
||||
"graphs": {"agent": "./agent.js:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}),
|
||||
version,
|
||||
None,
|
||||
"langchain/langgraphjs-api:0.2.74-node20-wolfi",
|
||||
);
|
||||
|
||||
// Test 5: Custom base image with api_version
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"base_image": "my-registry/custom-image"
|
||||
}),
|
||||
version,
|
||||
Some("my-registry/custom-image"),
|
||||
"my-registry/custom-image:0.2.74-py3.11",
|
||||
);
|
||||
|
||||
// Test 6: Different Python versions with api_version
|
||||
for py_ver in &["3.11", "3.12", "3.13"] {
|
||||
let expected = format!("langchain/langgraph-api:{version}-py{py_ver}");
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"python_version": py_ver,
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}),
|
||||
version,
|
||||
None,
|
||||
&expected,
|
||||
);
|
||||
}
|
||||
|
||||
// Test 7: Without api_version should work as before
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(docker_tag(&cfg, None, None), "langchain/langgraph-api:3.11");
|
||||
|
||||
// Test 8: Multiplatform with api_version (should default to Python)
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"python_version": "3.11",
|
||||
"node_version": "20",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"python": "./agent.py:graph", "js": "./agent.js:graph"}
|
||||
}),
|
||||
version,
|
||||
None,
|
||||
"langchain/langgraph-api:0.2.74-py3.11",
|
||||
);
|
||||
|
||||
// Test 9: _INTERNAL_docker_tag ignores api_version
|
||||
// (can only test with api_version as argument since both in config is invalid)
|
||||
let cfg = config_from_json(json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"_INTERNAL_docker_tag": "internal-tag"
|
||||
}));
|
||||
let cfg = validate_config(cfg).unwrap();
|
||||
assert_eq!(
|
||||
docker_tag(&cfg, None, Some("0.2.74")),
|
||||
"langchain/langgraph-api:internal-tag"
|
||||
);
|
||||
|
||||
// Test 10: langgraph-server base image with api_version
|
||||
run_api_version_test(
|
||||
json!({
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}),
|
||||
version,
|
||||
Some("langchain/langgraph-server"),
|
||||
"langchain/langgraph-server:0.2.74-py3.11",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use indexmap::IndexMap;
|
||||
|
||||
use super::schema::Config;
|
||||
use crate::constants::RESERVED_PACKAGE_NAMES;
|
||||
|
||||
/// Container for referencing and managing local Python dependencies.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalDeps {
|
||||
/// (host_requirements_path, container_requirements_path)
|
||||
pub pip_reqs: Vec<(PathBuf, String)>,
|
||||
/// host_path -> (dependency_string, container_package_name)
|
||||
pub real_pkgs: IndexMap<PathBuf, (String, String)>,
|
||||
/// host_path -> (dependency_string, container_path)
|
||||
pub faux_pkgs: IndexMap<PathBuf, (String, String)>,
|
||||
/// If "." is in dependencies, use it as working_dir
|
||||
pub working_dir: Option<String>,
|
||||
/// Directories outside the config parent that need additional Docker build contexts
|
||||
pub additional_contexts: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for LocalDeps {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pip_reqs: Vec::new(),
|
||||
real_pkgs: IndexMap::new(),
|
||||
faux_pkgs: IndexMap::new(),
|
||||
working_dir: None,
|
||||
additional_contexts: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Assemble local dependencies from config.
|
||||
pub fn assemble_local_deps(
|
||||
config_path: &Path,
|
||||
config: &Config,
|
||||
) -> Result<LocalDeps, String> {
|
||||
let config_path = config_path
|
||||
.canonicalize()
|
||||
.map_err(|e| format!("Could not resolve config path: {e}"))?;
|
||||
let config_parent = config_path.parent().unwrap();
|
||||
|
||||
let mut reserved: HashSet<String> = RESERVED_PACKAGE_NAMES
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
let mut counter: HashMap<String, usize> = HashMap::new();
|
||||
|
||||
let check_reserved = |name: &str, ref_str: &str, reserved: &mut HashSet<String>| -> Result<(), String> {
|
||||
if reserved.contains(name) {
|
||||
return Err(format!(
|
||||
"Package name '{name}' used in local dep '{ref_str}' is reserved. Rename the directory."
|
||||
));
|
||||
}
|
||||
reserved.insert(name.to_string());
|
||||
Ok(())
|
||||
};
|
||||
|
||||
let mut pip_reqs = Vec::new();
|
||||
let mut real_pkgs = IndexMap::new();
|
||||
let mut faux_pkgs = IndexMap::new();
|
||||
let mut working_dir: Option<String> = None;
|
||||
let mut additional_contexts = Vec::new();
|
||||
|
||||
for local_dep in &config.dependencies {
|
||||
if !local_dep.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let resolved = (config_parent.join(local_dep))
|
||||
.canonicalize()
|
||||
.map_err(|_| format!("Could not find local dependency: {}", config_parent.join(local_dep).display()))?;
|
||||
|
||||
if !resolved.exists() {
|
||||
return Err(format!("Could not find local dependency: {}", resolved.display()));
|
||||
}
|
||||
if !resolved.is_dir() {
|
||||
return Err(format!(
|
||||
"Local dependency must be a directory: {}",
|
||||
resolved.display()
|
||||
));
|
||||
}
|
||||
|
||||
if resolved != config_parent && !resolved.starts_with(config_parent) {
|
||||
additional_contexts.push(resolved.clone());
|
||||
}
|
||||
|
||||
let files: Vec<String> = std::fs::read_dir(&resolved)
|
||||
.map_err(|e| format!("Could not read directory {}: {e}", resolved.display()))?
|
||||
.filter_map(|entry| entry.ok().map(|e| e.file_name().to_string_lossy().to_string()))
|
||||
.collect();
|
||||
|
||||
if files.contains(&"pyproject.toml".to_string())
|
||||
|| files.contains(&"setup.py".to_string())
|
||||
{
|
||||
// Real package
|
||||
let dir_name = resolved
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let count = counter.entry(dir_name.clone()).or_insert(0);
|
||||
let container_name = if *count > 0 {
|
||||
format!("{}_{}", dir_name, count)
|
||||
} else {
|
||||
dir_name.clone()
|
||||
};
|
||||
*count += 1;
|
||||
|
||||
real_pkgs.insert(resolved.clone(), (local_dep.clone(), container_name.clone()));
|
||||
|
||||
if local_dep == "." {
|
||||
working_dir = Some(format!("/deps/{container_name}"));
|
||||
}
|
||||
} else {
|
||||
// Faux package
|
||||
let dir_name = resolved
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
if files.contains(&"__init__.py".to_string()) {
|
||||
// Flat layout
|
||||
if dir_name.contains('-') {
|
||||
return Err(format!(
|
||||
"Package name '{dir_name}' contains a hyphen. \
|
||||
Rename the directory to use it as flat-layout package."
|
||||
));
|
||||
}
|
||||
check_reserved(&dir_name, local_dep, &mut reserved)?;
|
||||
let container_path = format!("/deps/outer-{dir_name}/{dir_name}");
|
||||
faux_pkgs.insert(resolved.clone(), (local_dep.clone(), container_path.clone()));
|
||||
if local_dep == "." {
|
||||
working_dir = Some(container_path);
|
||||
}
|
||||
} else {
|
||||
// Src layout
|
||||
let container_path = format!("/deps/outer-{dir_name}/src");
|
||||
|
||||
for file in &files {
|
||||
let rfile = resolved.join(file);
|
||||
if rfile.is_dir() && file != "__pycache__" && !file.starts_with('.') {
|
||||
if let Ok(entries) = std::fs::read_dir(&rfile) {
|
||||
for subentry in entries.flatten() {
|
||||
let subname = subentry.file_name().to_string_lossy().to_string();
|
||||
if subname.ends_with(".py") {
|
||||
check_reserved(file, local_dep, &mut reserved)?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
faux_pkgs.insert(resolved.clone(), (local_dep.clone(), container_path.clone()));
|
||||
if local_dep == "." {
|
||||
working_dir = Some(container_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for requirements.txt
|
||||
if files.contains(&"requirements.txt".to_string()) {
|
||||
let rfile = resolved.join("requirements.txt");
|
||||
let container_req_path = if let Some((_, ref cp)) = faux_pkgs.get(&resolved) {
|
||||
format!("{cp}/requirements.txt")
|
||||
} else {
|
||||
format!("/deps/outer-{dir_name}/requirements.txt")
|
||||
};
|
||||
pip_reqs.push((rfile, container_req_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(LocalDeps {
|
||||
pip_reqs,
|
||||
real_pkgs,
|
||||
faux_pkgs,
|
||||
working_dir,
|
||||
additional_contexts,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,849 @@
|
||||
pub mod docker_tag;
|
||||
pub mod local_deps;
|
||||
pub mod path_rewrite;
|
||||
pub mod schema;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use schema::{Config, EnvConfig, GraphSpec, KeepPkgTools};
|
||||
|
||||
use crate::constants::{
|
||||
BUILD_TOOLS, DEFAULT_IMAGE_DISTRO, DEFAULT_NODE_VERSION, DEFAULT_PYTHON_VERSION,
|
||||
MIN_NODE_VERSION, MIN_PYTHON_VERSION, VALID_DISTROS, VALID_PIP_INSTALLERS,
|
||||
};
|
||||
|
||||
/// Check if a graph spec references a Node.js file based on extension.
|
||||
pub fn is_node_graph(spec: &GraphSpec) -> bool {
|
||||
let path_str = match spec {
|
||||
GraphSpec::Path(s) => s.as_str(),
|
||||
GraphSpec::Dict(m) => match m.get("path").and_then(|v| v.as_str()) {
|
||||
Some(p) => p,
|
||||
None => return false,
|
||||
},
|
||||
};
|
||||
|
||||
let file_path = path_str.split(':').next().unwrap_or("");
|
||||
matches!(
|
||||
Path::new(file_path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str()),
|
||||
Some("ts" | "mts" | "cts" | "js" | "mjs" | "cjs")
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse a Python version string "major.minor" into (major, minor).
|
||||
fn parse_version(version_str: &str) -> Result<(u32, u32), String> {
|
||||
let cleaned = version_str.split('-').next().unwrap_or(version_str);
|
||||
let parts: Vec<&str> = cleaned.split('.').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(format!("Invalid version format: {version_str}"));
|
||||
}
|
||||
let major: u32 = parts[0]
|
||||
.parse()
|
||||
.map_err(|_| format!("Invalid version format: {version_str}"))?;
|
||||
let minor: u32 = parts[1]
|
||||
.parse()
|
||||
.map_err(|_| format!("Invalid version format: {version_str}"))?;
|
||||
Ok((major, minor))
|
||||
}
|
||||
|
||||
/// Parse a Node.js version string (major only) into u32.
|
||||
fn parse_node_version(version_str: &str) -> Result<u32, String> {
|
||||
if version_str.contains('.') {
|
||||
return Err(format!(
|
||||
"Invalid Node.js version format: {version_str}. Use major version only (e.g., '20')."
|
||||
));
|
||||
}
|
||||
version_str
|
||||
.parse::<u32>()
|
||||
.map_err(|_| format!("Invalid Node.js version format: {version_str}. Use major version only (e.g., '20')."))
|
||||
}
|
||||
|
||||
/// Validate a configuration dictionary.
|
||||
pub fn validate_config(mut config: Config) -> Result<Config, String> {
|
||||
let graphs = &config.graphs;
|
||||
|
||||
let some_node = graphs.values().any(|spec| is_node_graph(spec));
|
||||
let some_python = graphs.values().any(|spec| !is_node_graph(spec));
|
||||
|
||||
// Set defaults for node_version and python_version
|
||||
if config.node_version.is_none() && some_node {
|
||||
config.node_version = Some(DEFAULT_NODE_VERSION.to_string());
|
||||
}
|
||||
if config.python_version.is_none() && some_python {
|
||||
config.python_version = Some(DEFAULT_PYTHON_VERSION.to_string());
|
||||
}
|
||||
|
||||
// Default image_distro
|
||||
if config.image_distro.is_none() {
|
||||
config.image_distro = Some(DEFAULT_IMAGE_DISTRO.to_string());
|
||||
}
|
||||
|
||||
// Default pip_installer
|
||||
if config.pip_installer.is_none() {
|
||||
config.pip_installer = Some("auto".to_string());
|
||||
}
|
||||
|
||||
// Default env
|
||||
if config.env.is_none() {
|
||||
config.env = Some(EnvConfig::default());
|
||||
}
|
||||
|
||||
// Validate _INTERNAL_docker_tag vs api_version
|
||||
if config.internal_docker_tag.is_some() && config.api_version.is_some() {
|
||||
return Err("Cannot specify both _INTERNAL_docker_tag and api_version.".to_string());
|
||||
}
|
||||
|
||||
// Validate api_version format
|
||||
if let Some(ref api_version) = config.api_version {
|
||||
let cleaned = api_version.split('-').next().unwrap_or(api_version);
|
||||
let parts: Vec<&str> = cleaned.split('.').collect();
|
||||
if parts.len() > 3 {
|
||||
return Err("Version must be major or major.minor or major.minor.patch.".to_string());
|
||||
}
|
||||
for part in &parts {
|
||||
part.parse::<u32>()
|
||||
.map_err(|_| format!("Invalid version format: {api_version}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate node_version
|
||||
if let Some(ref node_version) = config.node_version {
|
||||
let major = parse_node_version(node_version)?;
|
||||
if major < MIN_NODE_VERSION {
|
||||
return Err(format!(
|
||||
"Node.js version {node_version} is not supported. Minimum required version is {MIN_NODE_VERSION}."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate python_version
|
||||
if let Some(ref pyversion) = config.python_version {
|
||||
let cleaned = pyversion.split('-').next().unwrap_or(pyversion);
|
||||
if cleaned.split('.').count() != 2
|
||||
|| !cleaned.split('.').all(|p| p.chars().all(|c| c.is_ascii_digit()))
|
||||
{
|
||||
return Err(format!(
|
||||
"Invalid Python version format: {pyversion}. \
|
||||
Use 'major.minor' format (e.g., '3.11'). \
|
||||
Patch version cannot be specified."
|
||||
));
|
||||
}
|
||||
if parse_version(pyversion)? < MIN_PYTHON_VERSION {
|
||||
return Err(format!(
|
||||
"Python version {pyversion} is not supported. \
|
||||
Minimum required version is {}.{}.",
|
||||
MIN_PYTHON_VERSION.0, MIN_PYTHON_VERSION.1
|
||||
));
|
||||
}
|
||||
|
||||
if config.dependencies.is_empty() {
|
||||
return Err(
|
||||
"No dependencies found in config. Add at least one dependency to 'dependencies' list."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Validate graphs
|
||||
if config.graphs.is_empty() {
|
||||
return Err(
|
||||
"No graphs found in config. Add at least one graph to 'graphs' dictionary."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
// Validate image_distro
|
||||
if let Some(ref distro) = config.image_distro {
|
||||
if distro == "bullseye" {
|
||||
return Err(
|
||||
"Bullseye images were deprecated in version 0.4.13. \
|
||||
Please use 'bookworm' or 'debian' instead."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if !VALID_DISTROS.contains(&distro.as_str()) {
|
||||
return Err(format!(
|
||||
"Invalid image_distro: '{distro}'. Must be one of 'debian', 'wolfi', or 'bookworm'."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate pip_installer
|
||||
if let Some(ref pip_installer) = config.pip_installer {
|
||||
if !VALID_PIP_INSTALLERS.contains(&pip_installer.as_str()) {
|
||||
return Err(format!(
|
||||
"Invalid pip_installer: '{pip_installer}'. Must be 'auto', 'pip', or 'uv'."
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Validate auth config
|
||||
if let Some(ref auth_conf) = config.auth {
|
||||
if let Some(ref path) = auth_conf.path {
|
||||
if !path.contains(':') {
|
||||
return Err(format!(
|
||||
"Invalid auth.path format: '{path}'. \
|
||||
Must be in format './path/to/file.py:attribute_name'"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate encryption config
|
||||
if let Some(ref encryption_conf) = config.encryption {
|
||||
if let Some(ref path) = encryption_conf.path {
|
||||
if !path.contains(':') {
|
||||
return Err(format!(
|
||||
"Invalid encryption.path format: '{path}'. \
|
||||
Must be in format './path/to/file.py:attribute_name'"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate http config
|
||||
if let Some(ref http_conf) = config.http {
|
||||
if let Some(ref app) = http_conf.app {
|
||||
if !app.contains(':') {
|
||||
return Err(format!(
|
||||
"Invalid http.app format: '{app}'. \
|
||||
Must be in format './path/to/file.py:attribute_name'"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate keep_pkg_tools
|
||||
if let Some(ref keep_pkg_tools) = config.keep_pkg_tools {
|
||||
match keep_pkg_tools {
|
||||
KeepPkgTools::List(tools) => {
|
||||
for tool in tools {
|
||||
if !BUILD_TOOLS.contains(&tool.as_str()) {
|
||||
return Err(format!(
|
||||
"Invalid keep_pkg_tools: '{tool}'. \
|
||||
Must be one of 'pip', 'setuptools', 'wheel'."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
KeepPkgTools::Bool(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Load and validate a configuration file.
|
||||
pub fn validate_config_file(config_path: &Path) -> Result<Config, String> {
|
||||
let content = std::fs::read_to_string(config_path)
|
||||
.map_err(|e| format!("Could not read config file {}: {e}", config_path.display()))?;
|
||||
|
||||
let config: Config = serde_json::from_str(&content)
|
||||
.map_err(|e| format!("Invalid JSON in config file {}: {e}", config_path.display()))?;
|
||||
|
||||
let validated = validate_config(config)?;
|
||||
|
||||
// Check package.json engines
|
||||
if validated.node_version.is_some() {
|
||||
let package_json_path = config_path.parent().unwrap().join("package.json");
|
||||
if package_json_path.is_file() {
|
||||
let pkg_content = std::fs::read_to_string(&package_json_path)
|
||||
.map_err(|e| format!("Could not read package.json: {e}"))?;
|
||||
let pkg: serde_json::Value = serde_json::from_str(&pkg_content).map_err(|_| {
|
||||
format!(
|
||||
"Invalid package.json found in langgraph config directory {}: file is not valid JSON",
|
||||
package_json_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(engines) = pkg.get("engines").and_then(|e| e.as_object()) {
|
||||
// Only 'node' engine is supported
|
||||
if engines.keys().any(|k| k != "node") {
|
||||
return Err(format!(
|
||||
"Only 'node' engine is supported in package.json engines. Got engines: {:?}",
|
||||
engines.keys().collect::<Vec<_>>()
|
||||
));
|
||||
}
|
||||
if let Some(node_version) = engines.get("node").and_then(|v| v.as_str()) {
|
||||
let major = parse_node_version(node_version)?;
|
||||
if major < MIN_NODE_VERSION {
|
||||
return Err(format!(
|
||||
"Node.js version in package.json engines must be >= {MIN_NODE_VERSION} \
|
||||
(major version only), got '{node_version}'. Minor/patch versions \
|
||||
(like '20.x.y') are not supported to prevent deployment issues \
|
||||
when new Node.js versions are released."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(validated)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::io::Write;
|
||||
|
||||
fn config_from_json(val: serde_json::Value) -> Config {
|
||||
serde_json::from_value::<Config>(val).unwrap()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// test_validate_config
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_minimal() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.11"));
|
||||
assert_eq!(result.node_version, None);
|
||||
assert_eq!(result.pip_installer.as_deref(), Some("auto"));
|
||||
assert_eq!(result.image_distro.as_deref(), Some("debian"));
|
||||
assert_eq!(result.base_image, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_full() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.12",
|
||||
"dependencies": [".", "langchain"],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"pip_config_file": "/etc/pip.conf",
|
||||
"dockerfile_lines": ["RUN apt-get update"],
|
||||
"env": ".env"
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.12"));
|
||||
assert_eq!(result.pip_config_file.as_deref(), Some("/etc/pip.conf"));
|
||||
assert_eq!(result.dockerfile_lines, vec!["RUN apt-get update"]);
|
||||
assert!(matches!(result.env, Some(EnvConfig::File(ref s)) if s == ".env"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_python_313() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.13",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.13"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_python_39_error() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.9",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Minimum required version"), "Expected 'Minimum required version' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_missing_dependencies() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.11",
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("dependencies"), "Expected error about dependencies but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_missing_graphs() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."]
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("graphs"), "Expected error about graphs but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_python_version_with_patch() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.11.0",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid Python version format"), "Expected 'Invalid Python version format' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_python_version_major_only() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid Python version format"), "Expected 'Invalid Python version format' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_python_version_non_numeric() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "abc.def",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid Python version format"), "Expected 'Invalid Python version format' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_python_310_error() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.10",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Minimum required version"), "Expected 'Minimum required version' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_python_312_slim() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.12-slim",
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.12-slim"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_http_app_no_colon() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"http": {"app": "../../examples/my_app.py"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid http.app format"), "Expected 'Invalid http.app format' but got: {err}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// test_validate_config_image_distro
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_image_distro_debian() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "debian"
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.image_distro.as_deref(), Some("debian"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_image_distro_wolfi() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.image_distro.as_deref(), Some("wolfi"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_image_distro_default() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.image_distro.as_deref(), Some("debian"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_image_distro_bullseye_error() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "bullseye"
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Bullseye images were deprecated"), "Expected 'Bullseye images were deprecated' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_image_distro_ubuntu_error() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "ubuntu"
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid image_distro: 'ubuntu'"), "Expected \"Invalid image_distro: 'ubuntu'\" but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_image_distro_alpine_error() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"image_distro": "alpine"
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid image_distro: 'alpine'"), "Expected \"Invalid image_distro: 'alpine'\" but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_node_with_wolfi() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"},
|
||||
"image_distro": "wolfi"
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.image_distro.as_deref(), Some("wolfi"));
|
||||
assert_eq!(result.node_version.as_deref(), Some("20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_node_default_distro() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.image_distro.as_deref(), Some("debian"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// test_validate_config_pip_installer
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_pip_installer_auto() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"pip_installer": "auto"
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.pip_installer.as_deref(), Some("auto"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_pip_installer_pip() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"pip_installer": "pip"
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.pip_installer.as_deref(), Some("pip"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_pip_installer_uv() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"pip_installer": "uv"
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.pip_installer.as_deref(), Some("uv"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_pip_installer_default() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.pip_installer.as_deref(), Some("auto"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_pip_installer_conda_error() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"pip_installer": "conda"
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid pip_installer: 'conda'"), "Expected \"Invalid pip_installer: 'conda'\" but got: {err}");
|
||||
assert!(err.contains("Must be 'auto', 'pip', or 'uv'"), "Expected \"Must be 'auto', 'pip', or 'uv'\" but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_pip_installer_invalid_error() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"pip_installer": "invalid"
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid pip_installer: 'invalid'"), "Expected \"Invalid pip_installer: 'invalid'\" but got: {err}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// test_validate_config_multiplatform
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_js_only_no_explicit_versions() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./js.mts:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.node_version.as_deref(), Some("20"));
|
||||
assert_eq!(result.python_version, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_both_versions_explicit() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.12",
|
||||
"node_version": "22",
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent_py": "./agent.py:graph",
|
||||
"agent_js": "./agent.mts:graph"
|
||||
}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.12"));
|
||||
assert_eq!(result.node_version.as_deref(), Some("22"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_mixed_graphs_no_explicit_versions() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent_py": "./agent.py:graph",
|
||||
"agent_js": "./agent.mts:graph"
|
||||
}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.node_version.as_deref(), Some("20"));
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.11"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_mixed_graphs_node_version_only() {
|
||||
let config = config_from_json(json!({
|
||||
"node_version": "22",
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent_py": "./agent.py:graph",
|
||||
"agent_js": "./agent.mts:graph"
|
||||
}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.node_version.as_deref(), Some("22"));
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.11"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_mixed_graphs_python_version_only() {
|
||||
let config = config_from_json(json!({
|
||||
"python_version": "3.12",
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent_py": "./agent.py:graph",
|
||||
"agent_js": "./agent.mts:graph"
|
||||
}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.node_version.as_deref(), Some("20"));
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_unknown_extension_assumes_python() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "local.workflow:graph"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.11"));
|
||||
assert_eq!(result.node_version, None);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// test_validate_config_encryption
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_encryption_valid() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"encryption": {"path": "./encryption.py:encryption"}
|
||||
}));
|
||||
let result = validate_config(config).unwrap();
|
||||
assert!(result.encryption.is_some());
|
||||
assert_eq!(
|
||||
result.encryption.as_ref().unwrap().path.as_deref(),
|
||||
Some("./encryption.py:encryption")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_encryption_no_colon_error() {
|
||||
let config = config_from_json(json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"},
|
||||
"encryption": {"path": "./encryption.py"}
|
||||
}));
|
||||
let err = validate_config(config).unwrap_err();
|
||||
assert!(err.contains("Invalid encryption.path format"), "Expected 'Invalid encryption.path format' but got: {err}");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// test_validate_config_file
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_file_node_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("langgraph.json");
|
||||
let config_json = json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"}
|
||||
});
|
||||
let mut f = std::fs::File::create(&config_path).unwrap();
|
||||
write!(f, "{}", serde_json::to_string(&config_json).unwrap()).unwrap();
|
||||
drop(f);
|
||||
|
||||
let result = validate_config_file(&config_path).unwrap();
|
||||
assert_eq!(result.node_version.as_deref(), Some("20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_file_with_valid_package_json() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("langgraph.json");
|
||||
let config_json = json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
||||
|
||||
let pkg_path = tmp.path().join("package.json");
|
||||
let pkg_json = json!({
|
||||
"engines": {"node": "20"}
|
||||
});
|
||||
std::fs::write(&pkg_path, serde_json::to_string(&pkg_json).unwrap()).unwrap();
|
||||
|
||||
let result = validate_config_file(&config_path).unwrap();
|
||||
assert_eq!(result.node_version.as_deref(), Some("20"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_file_package_json_minor_version_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("langgraph.json");
|
||||
let config_json = json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
||||
|
||||
let pkg_path = tmp.path().join("package.json");
|
||||
let pkg_json = json!({
|
||||
"engines": {"node": "20.18"}
|
||||
});
|
||||
std::fs::write(&pkg_path, serde_json::to_string(&pkg_json).unwrap()).unwrap();
|
||||
|
||||
let err = validate_config_file(&config_path).unwrap_err();
|
||||
assert!(err.contains("Use major version only"), "Expected 'Use major version only' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_file_package_json_old_node_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("langgraph.json");
|
||||
let config_json = json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
||||
|
||||
let pkg_path = tmp.path().join("package.json");
|
||||
let pkg_json = json!({
|
||||
"engines": {"node": "18"}
|
||||
});
|
||||
std::fs::write(&pkg_path, serde_json::to_string(&pkg_json).unwrap()).unwrap();
|
||||
|
||||
let err = validate_config_file(&config_path).unwrap_err();
|
||||
assert!(err.contains("must be >= 20"), "Expected 'must be >= 20' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_file_package_json_deno_engine_error() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("langgraph.json");
|
||||
let config_json = json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
||||
|
||||
let pkg_path = tmp.path().join("package.json");
|
||||
let pkg_json = json!({
|
||||
"engines": {"node": "20", "deno": "1.0"}
|
||||
});
|
||||
std::fs::write(&pkg_path, serde_json::to_string(&pkg_json).unwrap()).unwrap();
|
||||
|
||||
let err = validate_config_file(&config_path).unwrap_err();
|
||||
assert!(err.contains("Only 'node' engine is supported"), "Expected 'Only node engine is supported' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_file_invalid_package_json() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("langgraph.json");
|
||||
let config_json = json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.mts:graph"}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
||||
|
||||
let pkg_path = tmp.path().join("package.json");
|
||||
std::fs::write(&pkg_path, "this is not valid json!!!").unwrap();
|
||||
|
||||
let err = validate_config_file(&config_path).unwrap_err();
|
||||
assert!(err.contains("Invalid package.json"), "Expected 'Invalid package.json' but got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_config_file_python_ignores_bad_package_json() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config_path = tmp.path().join("langgraph.json");
|
||||
let config_json = json!({
|
||||
"dependencies": ["."],
|
||||
"graphs": {"agent": "./agent.py:graph"}
|
||||
});
|
||||
std::fs::write(&config_path, serde_json::to_string(&config_json).unwrap()).unwrap();
|
||||
|
||||
// Write a bad package.json - should be ignored for Python-only config
|
||||
let pkg_path = tmp.path().join("package.json");
|
||||
std::fs::write(&pkg_path, "this is not valid json!!!").unwrap();
|
||||
|
||||
let result = validate_config_file(&config_path).unwrap();
|
||||
assert_eq!(result.python_version.as_deref(), Some("3.11"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
use std::path::Path;
|
||||
|
||||
use super::local_deps::LocalDeps;
|
||||
use super::schema::{Config, GraphSpec};
|
||||
|
||||
/// Remap each graph's import path to the correct in-container path.
|
||||
pub fn update_graph_paths(
|
||||
config_path: &Path,
|
||||
config: &mut Config,
|
||||
local_deps: &LocalDeps,
|
||||
) -> Result<(), String> {
|
||||
let config_parent = config_path.parent().unwrap();
|
||||
let graph_ids: Vec<String> = config.graphs.keys().cloned().collect();
|
||||
|
||||
for graph_id in graph_ids {
|
||||
let import_str = {
|
||||
let spec = config.graphs.get(&graph_id).unwrap();
|
||||
match spec {
|
||||
GraphSpec::Path(s) => s.clone(),
|
||||
GraphSpec::Dict(m) => {
|
||||
if let Some(path_val) = m.get("path") {
|
||||
path_val
|
||||
.as_str()
|
||||
.ok_or_else(|| {
|
||||
format!("Graph '{graph_id}' path must be a string")
|
||||
})?
|
||||
.to_string()
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Graph '{graph_id}' must contain a 'path' key if it is a dictionary."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (module_str, attr_str) = match import_str.split_once(':') {
|
||||
Some((m, a)) if !m.is_empty() && !a.is_empty() => (m, a),
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Import string \"{import_str}\" must be in format \"<module>:<attribute>\"."
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// Check for file path (contains / or \)
|
||||
if module_str.contains('/') || module_str.contains('\\') {
|
||||
let resolved = config_parent
|
||||
.join(module_str)
|
||||
.canonicalize()
|
||||
.map_err(|_| format!("Could not find local module: {}", config_parent.join(module_str).display()))?;
|
||||
|
||||
if !resolved.exists() {
|
||||
return Err(format!("Could not find local module: {}", resolved.display()));
|
||||
}
|
||||
if !resolved.is_file() {
|
||||
return Err(format!("Local module must be a file: {}", resolved.display()));
|
||||
}
|
||||
|
||||
let mut new_module = None;
|
||||
|
||||
// Check real packages
|
||||
for (path, (_, container_name)) in &local_deps.real_pkgs {
|
||||
if resolved.starts_with(path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(path) {
|
||||
let container_path =
|
||||
format!("/deps/{}/{}", container_name, relative.to_string_lossy().replace('\\', "/"));
|
||||
new_module = Some(container_path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check faux packages
|
||||
if new_module.is_none() {
|
||||
for (faux_path, (_, destpath)) in &local_deps.faux_pkgs {
|
||||
if resolved.starts_with(faux_path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(faux_path) {
|
||||
new_module = Some(format!(
|
||||
"{}/{}",
|
||||
destpath,
|
||||
relative.to_string_lossy().replace('\\', "/")
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(new_mod) = new_module {
|
||||
let new_path = format!("{new_mod}:{attr_str}");
|
||||
config.graphs.get_mut(&graph_id).unwrap().set_path(new_path);
|
||||
} else {
|
||||
return Err(format!(
|
||||
"Module '{import_str}' not found in 'dependencies' list. \
|
||||
Add its containing package to 'dependencies' list."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update auth.path to use Docker container paths.
|
||||
pub fn update_auth_path(
|
||||
config_path: &Path,
|
||||
config: &mut Config,
|
||||
local_deps: &LocalDeps,
|
||||
) -> Result<(), String> {
|
||||
let auth_conf = match config.auth.as_mut() {
|
||||
Some(a) => a,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let path_str = match auth_conf.path.as_ref() {
|
||||
Some(p) => p.clone(),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let (module_str, attr_str) = match path_str.split_once(':') {
|
||||
Some((m, a)) => (m, a),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if !module_str.starts_with('.') {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config_parent = config_path.parent().unwrap();
|
||||
let resolved = config_parent
|
||||
.join(module_str)
|
||||
.canonicalize()
|
||||
.map_err(|_| format!("Auth file not found: {} (from {path_str})", config_parent.join(module_str).display()))?;
|
||||
|
||||
if !resolved.is_file() {
|
||||
return Err(format!("Auth path must be a file: {}", resolved.display()));
|
||||
}
|
||||
|
||||
// Check faux packages first
|
||||
for (faux_path, (_, destpath)) in &local_deps.faux_pkgs {
|
||||
if resolved.starts_with(faux_path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(faux_path) {
|
||||
auth_conf.path = Some(format!(
|
||||
"{}/{}:{attr_str}",
|
||||
destpath,
|
||||
relative.to_string_lossy()
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check real packages
|
||||
for (real_path, _) in &local_deps.real_pkgs {
|
||||
if resolved.starts_with(real_path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(real_path) {
|
||||
let dir_name = real_path.file_name().unwrap().to_string_lossy();
|
||||
auth_conf.path = Some(format!(
|
||||
"/deps/{}/{}:{attr_str}",
|
||||
dir_name,
|
||||
relative.to_string_lossy()
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Auth file '{}' not covered by dependencies.\n\
|
||||
Add its parent directory to the 'dependencies' array in your config.",
|
||||
resolved.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// Update encryption.path to use Docker container paths.
|
||||
pub fn update_encryption_path(
|
||||
config_path: &Path,
|
||||
config: &mut Config,
|
||||
local_deps: &LocalDeps,
|
||||
) -> Result<(), String> {
|
||||
let encryption_conf = match config.encryption.as_mut() {
|
||||
Some(e) => e,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let path_str = match encryption_conf.path.as_ref() {
|
||||
Some(p) => p.clone(),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let (module_str, attr_str) = match path_str.split_once(':') {
|
||||
Some((m, a)) => (m, a),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
if !module_str.starts_with('.') {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config_parent = config_path.parent().unwrap();
|
||||
let resolved = config_parent
|
||||
.join(module_str)
|
||||
.canonicalize()
|
||||
.map_err(|_| {
|
||||
format!(
|
||||
"Encryption file not found: {} (from {path_str})",
|
||||
config_parent.join(module_str).display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if !resolved.is_file() {
|
||||
return Err(format!(
|
||||
"Encryption path must be a file: {}",
|
||||
resolved.display()
|
||||
));
|
||||
}
|
||||
|
||||
for (faux_path, (_, destpath)) in &local_deps.faux_pkgs {
|
||||
if resolved.starts_with(faux_path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(faux_path) {
|
||||
encryption_conf.path = Some(format!(
|
||||
"{}/{}:{attr_str}",
|
||||
destpath,
|
||||
relative.to_string_lossy()
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (real_path, _) in &local_deps.real_pkgs {
|
||||
if resolved.starts_with(real_path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(real_path) {
|
||||
let dir_name = real_path.file_name().unwrap().to_string_lossy();
|
||||
encryption_conf.path = Some(format!(
|
||||
"/deps/{}/{}:{attr_str}",
|
||||
dir_name,
|
||||
relative.to_string_lossy()
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Encryption file '{}' not covered by dependencies.\n\
|
||||
Add its parent directory to the 'dependencies' array in your config.",
|
||||
resolved.display()
|
||||
))
|
||||
}
|
||||
|
||||
/// Update the HTTP app path to point to the correct location in the Docker container.
|
||||
pub fn update_http_app_path(
|
||||
config_path: &Path,
|
||||
config: &mut Config,
|
||||
local_deps: &LocalDeps,
|
||||
) -> Result<(), String> {
|
||||
let http_config = match config.http.as_mut() {
|
||||
Some(h) => h,
|
||||
None => return Ok(()),
|
||||
};
|
||||
let app_str = match http_config.app.as_ref() {
|
||||
Some(a) => a.clone(),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let (module_str, attr_str) = match app_str.split_once(':') {
|
||||
Some((m, a)) if !m.is_empty() && !a.is_empty() => (m, a),
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"Import string \"{app_str}\" must be in format \"<module>:<attribute>\"."
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
if !module_str.contains('/') && !module_str.contains('\\') {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let config_parent = config_path.parent().unwrap();
|
||||
let resolved = config_parent
|
||||
.join(module_str)
|
||||
.canonicalize()
|
||||
.map_err(|_| format!("Could not find HTTP app module: {}", config_parent.join(module_str).display()))?;
|
||||
|
||||
if !resolved.is_file() {
|
||||
return Err(format!(
|
||||
"HTTP app module must be a file: {}",
|
||||
resolved.display()
|
||||
));
|
||||
}
|
||||
|
||||
// Check real packages
|
||||
for (path, (_, _name)) in &local_deps.real_pkgs {
|
||||
if resolved.starts_with(path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(path) {
|
||||
let dir_name = path.file_name().unwrap().to_string_lossy();
|
||||
http_config.app = Some(format!(
|
||||
"/deps/{}/{}:{attr_str}",
|
||||
dir_name,
|
||||
relative.to_string_lossy().replace('\\', "/")
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check faux packages
|
||||
for (faux_path, (_, destpath)) in &local_deps.faux_pkgs {
|
||||
if resolved.starts_with(faux_path) {
|
||||
if let Ok(relative) = resolved.strip_prefix(faux_path) {
|
||||
http_config.app = Some(format!(
|
||||
"{}/{}:{attr_str}",
|
||||
destpath,
|
||||
relative.to_string_lossy().replace('\\', "/")
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"HTTP app module '{app_str}' not found in 'dependencies' list. \
|
||||
Add its containing package to 'dependencies' list."
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
use indexmap::IndexMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
/// Top-level config for langgraph-cli deployment.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct Config {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub python_version: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub node_version: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub api_version: Option<String>,
|
||||
|
||||
#[serde(rename = "_INTERNAL_docker_tag", skip_serializing_if = "Option::is_none")]
|
||||
pub internal_docker_tag: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub base_image: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub image_distro: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pip_config_file: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pip_installer: Option<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dockerfile_lines: Vec<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub dependencies: Vec<String>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "IndexMap::is_empty")]
|
||||
pub graphs: IndexMap<String, GraphSpec>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub env: Option<EnvConfig>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub store: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auth: Option<AuthConfig>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub encryption: Option<EncryptionConfig>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub http: Option<HttpConfig>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub webhooks: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub checkpointer: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ui: Option<IndexMap<String, String>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub ui_config: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub keep_pkg_tools: Option<KeepPkgTools>,
|
||||
}
|
||||
|
||||
/// Graph specification: either a string path or a dict with a "path" key.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum GraphSpec {
|
||||
Path(String),
|
||||
Dict(IndexMap<String, Value>),
|
||||
}
|
||||
|
||||
impl GraphSpec {
|
||||
pub fn set_path(&mut self, new_path: String) {
|
||||
match self {
|
||||
GraphSpec::Path(s) => *s = new_path,
|
||||
GraphSpec::Dict(m) => {
|
||||
m.insert("path".to_string(), Value::String(new_path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment config: either a dict of key-value pairs or a path to an env file.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum EnvConfig {
|
||||
Dict(IndexMap<String, String>),
|
||||
File(String),
|
||||
}
|
||||
|
||||
impl Default for EnvConfig {
|
||||
fn default() -> Self {
|
||||
EnvConfig::Dict(IndexMap::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Auth configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_studio_auth: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub openapi: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cache: Option<Value>,
|
||||
}
|
||||
|
||||
/// Encryption configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EncryptionConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
/// HTTP configuration.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HttpConfig {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub app: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_assistants: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_threads: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_runs: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_store: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_mcp: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_a2a: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_meta: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_ui: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub disable_webhooks: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cors: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub configurable_headers: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub logging_headers: Option<Value>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub middleware_order: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub enable_custom_route_auth: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mount_prefix: Option<String>,
|
||||
}
|
||||
|
||||
/// Keep package tools config: either a boolean or a list of tool names.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum KeepPkgTools {
|
||||
Bool(bool),
|
||||
List(Vec<String>),
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
pub const MIN_NODE_VERSION: u32 = 20;
|
||||
pub const DEFAULT_NODE_VERSION: u32 = 20;
|
||||
|
||||
pub const MIN_PYTHON_VERSION: (u32, u32) = (3, 11);
|
||||
pub const DEFAULT_PYTHON_VERSION: &str = "3.11";
|
||||
|
||||
pub const DEFAULT_IMAGE_DISTRO: &str = "debian";
|
||||
|
||||
pub const BUILD_TOOLS: &[&str] = &["pip", "setuptools", "wheel"];
|
||||
|
||||
// Analytics
|
||||
pub const SUPABASE_PUBLIC_API_KEY: &str = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imt6cmxwcG9qaW5wY3l5YWlweG5iIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MTkyNTc1NzksImV4cCI6MjAzNDgzMzU3OX0.kkVOlLz3BxemA5nP-vat3K4qRtrDuO4SwZSR_htcX9c";
|
||||
pub const SUPABASE_URL: &str = "https://kzrlppojinpcyyaipxnb.supabase.co";
|
||||
|
||||
pub const DEFAULT_POSTGRES_URI: &str =
|
||||
"postgres://postgres:postgres@langgraph-postgres:5432/postgres?sslmode=disable";
|
||||
|
||||
pub const VALID_DISTROS: &[&str] = &["debian", "wolfi", "bookworm"];
|
||||
pub const VALID_PIP_INSTALLERS: &[&str] = &["auto", "pip", "uv"];
|
||||
|
||||
pub const RESERVED_PACKAGE_NAMES: &[&str] = &[
|
||||
"src",
|
||||
"langgraph-api",
|
||||
"langgraph_api",
|
||||
"langgraph",
|
||||
"langchain-core",
|
||||
"langchain_core",
|
||||
"pydantic",
|
||||
"orjson",
|
||||
"fastapi",
|
||||
"uvicorn",
|
||||
"psycopg",
|
||||
"httpx",
|
||||
"langsmith",
|
||||
];
|
||||
@@ -0,0 +1,185 @@
|
||||
use std::process::Command;
|
||||
|
||||
/// Semantic version tuple.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct Version {
|
||||
pub major: u32,
|
||||
pub minor: u32,
|
||||
pub patch: u32,
|
||||
}
|
||||
|
||||
impl Version {
|
||||
pub fn new(major: u32, minor: u32, patch: u32) -> Self {
|
||||
Self {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Type of Docker Compose installation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ComposeType {
|
||||
Plugin,
|
||||
Standalone,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
|
||||
/// Parse a version string like "1.2.3", "v1.2.3-alpha", etc.
|
||||
pub fn parse_version(version: &str) -> Version {
|
||||
let cleaned = version.trim();
|
||||
let parts: Vec<&str> = cleaned.split('.').collect();
|
||||
|
||||
let parse_part = |s: &str| -> u32 {
|
||||
let s = s.trim_start_matches('v');
|
||||
let s = s.split('-').next().unwrap_or(s);
|
||||
let s = s.split('+').next().unwrap_or(s);
|
||||
s.parse().unwrap_or(0)
|
||||
};
|
||||
|
||||
match parts.len() {
|
||||
1 => Version::new(parse_part(parts[0]), 0, 0),
|
||||
2 => Version::new(parse_part(parts[0]), parse_part(parts[1]), 0),
|
||||
_ => Version::new(
|
||||
parse_part(parts[0]),
|
||||
parse_part(parts[1]),
|
||||
parse_part(parts[2]),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check Docker capabilities on the system.
|
||||
pub fn check_capabilities() -> Result<DockerCapabilities, String> {
|
||||
// Check docker is available
|
||||
if which::which("docker").is_err() {
|
||||
return Err("Docker not installed".to_string());
|
||||
}
|
||||
|
||||
// Get docker info
|
||||
let output = Command::new("docker")
|
||||
.args(["info", "-f", "{{json .}}"])
|
||||
.output()
|
||||
.map_err(|_| "Docker not installed or not running".to_string())?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err("Docker not installed or not running".to_string());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let info: serde_json::Value =
|
||||
serde_json::from_str(&stdout).map_err(|_| "Docker not installed or not running".to_string())?;
|
||||
|
||||
let server_version = info
|
||||
.get("ServerVersion")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or("Docker not running")?;
|
||||
|
||||
if server_version.is_empty() {
|
||||
return Err("Docker not running".to_string());
|
||||
}
|
||||
|
||||
// Try to find compose as plugin
|
||||
let (compose_version_str, compose_type) = if let Some(plugins) = info
|
||||
.get("ClientInfo")
|
||||
.and_then(|ci| ci.get("Plugins"))
|
||||
.and_then(|p| p.as_array())
|
||||
{
|
||||
if let Some(compose) = plugins
|
||||
.iter()
|
||||
.find(|p| p.get("Name").and_then(|n| n.as_str()) == Some("compose"))
|
||||
{
|
||||
let version = compose
|
||||
.get("Version")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("0.0.0");
|
||||
(version.to_string(), ComposeType::Plugin)
|
||||
} else {
|
||||
get_standalone_compose_version()?
|
||||
}
|
||||
} else {
|
||||
get_standalone_compose_version()?
|
||||
};
|
||||
|
||||
let docker_version = parse_version(server_version);
|
||||
let compose_version = parse_version(&compose_version_str);
|
||||
|
||||
Ok(DockerCapabilities {
|
||||
version_docker: docker_version,
|
||||
version_compose: compose_version,
|
||||
healthcheck_start_interval: docker_version >= Version::new(25, 0, 0),
|
||||
compose_type,
|
||||
})
|
||||
}
|
||||
|
||||
fn get_standalone_compose_version() -> Result<(String, ComposeType), String> {
|
||||
if which::which("docker-compose").is_err() {
|
||||
return Err("Docker Compose not installed".to_string());
|
||||
}
|
||||
|
||||
let output = Command::new("docker-compose")
|
||||
.args(["--version", "--short"])
|
||||
.output()
|
||||
.map_err(|_| "Docker Compose not installed".to_string())?;
|
||||
|
||||
let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
Ok((version, ComposeType::Standalone))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_basic() {
|
||||
assert_eq!(parse_version("1.2.3"), Version::new(1, 2, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_with_v() {
|
||||
assert_eq!(parse_version("v1.2.3"), Version::new(1, 2, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_with_prerelease() {
|
||||
assert_eq!(parse_version("1.2.3-alpha"), Version::new(1, 2, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_with_build() {
|
||||
assert_eq!(parse_version("1.2.3+1"), Version::new(1, 2, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_two_parts() {
|
||||
assert_eq!(parse_version("1.2"), Version::new(1, 2, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_one_part() {
|
||||
assert_eq!(parse_version("1"), Version::new(1, 0, 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_complex() {
|
||||
assert_eq!(parse_version("v28.1.1+1"), Version::new(28, 1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version_beta() {
|
||||
assert_eq!(
|
||||
parse_version("2.0.0-beta.1+exp.sha.5114f85"),
|
||||
Version::new(2, 0, 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
use indexmap::IndexMap;
|
||||
|
||||
use super::capabilities::DockerCapabilities;
|
||||
use crate::constants::DEFAULT_POSTGRES_URI;
|
||||
|
||||
/// Value types for our custom YAML writer.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum YamlValue {
|
||||
String(String),
|
||||
Dict(IndexMap<String, YamlValue>),
|
||||
List(Vec<String>),
|
||||
}
|
||||
|
||||
/// Convert a dictionary to a YAML string with custom formatting.
|
||||
/// This matches the Python dict_to_yaml() output exactly.
|
||||
pub fn dict_to_yaml(d: &IndexMap<String, YamlValue>, indent: usize) -> String {
|
||||
let mut yaml_str = String::new();
|
||||
|
||||
for (idx, (key, value)) in d.iter().enumerate() {
|
||||
// Extra newline for top-level keys only (after the first)
|
||||
if idx >= 1 && indent < 2 {
|
||||
yaml_str.push('\n');
|
||||
}
|
||||
let space = " ".repeat(indent);
|
||||
match value {
|
||||
YamlValue::Dict(inner) => {
|
||||
yaml_str.push_str(&format!("{space}{key}:\n"));
|
||||
yaml_str.push_str(&dict_to_yaml(inner, indent + 1));
|
||||
}
|
||||
YamlValue::List(items) => {
|
||||
yaml_str.push_str(&format!("{space}{key}:\n"));
|
||||
for item in items {
|
||||
yaml_str.push_str(&format!("{space} - {item}\n"));
|
||||
}
|
||||
}
|
||||
YamlValue::String(val) => {
|
||||
yaml_str.push_str(&format!("{space}{key}: {val}\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
yaml_str
|
||||
}
|
||||
|
||||
/// Create debugger service config.
|
||||
pub fn debugger_compose(port: Option<u16>, base_url: Option<&str>) -> IndexMap<String, YamlValue> {
|
||||
let port = match port {
|
||||
Some(p) => p,
|
||||
None => return IndexMap::new(),
|
||||
};
|
||||
|
||||
let mut debugger = IndexMap::new();
|
||||
debugger.insert(
|
||||
"image".to_string(),
|
||||
YamlValue::String("langchain/langgraph-debugger".to_string()),
|
||||
);
|
||||
debugger.insert(
|
||||
"restart".to_string(),
|
||||
YamlValue::String("on-failure".to_string()),
|
||||
);
|
||||
|
||||
let mut depends = IndexMap::new();
|
||||
let mut pg_condition = IndexMap::new();
|
||||
pg_condition.insert(
|
||||
"condition".to_string(),
|
||||
YamlValue::String("service_healthy".to_string()),
|
||||
);
|
||||
depends.insert("langgraph-postgres".to_string(), YamlValue::Dict(pg_condition));
|
||||
debugger.insert("depends_on".to_string(), YamlValue::Dict(depends));
|
||||
|
||||
debugger.insert(
|
||||
"ports".to_string(),
|
||||
YamlValue::List(vec![format!("\"{port}:3968\"")]),
|
||||
);
|
||||
|
||||
if let Some(url) = base_url {
|
||||
let mut env = IndexMap::new();
|
||||
env.insert(
|
||||
"VITE_STUDIO_LOCAL_GRAPH_URL".to_string(),
|
||||
YamlValue::String(url.to_string()),
|
||||
);
|
||||
debugger.insert("environment".to_string(), YamlValue::Dict(env));
|
||||
}
|
||||
|
||||
let mut result = IndexMap::new();
|
||||
result.insert("langgraph-debugger".to_string(), YamlValue::Dict(debugger));
|
||||
result
|
||||
}
|
||||
|
||||
/// Create a docker compose file as a dictionary.
|
||||
pub fn compose_as_dict(
|
||||
capabilities: &DockerCapabilities,
|
||||
port: u16,
|
||||
debugger_port: Option<u16>,
|
||||
debugger_base_url: Option<&str>,
|
||||
postgres_uri: Option<&str>,
|
||||
image: Option<&str>,
|
||||
_base_image: Option<&str>,
|
||||
_api_version: Option<&str>,
|
||||
) -> IndexMap<String, YamlValue> {
|
||||
let include_db = postgres_uri.is_none();
|
||||
let postgres_uri = postgres_uri.unwrap_or(DEFAULT_POSTGRES_URI);
|
||||
|
||||
let mut services = IndexMap::new();
|
||||
|
||||
// Redis service
|
||||
let mut redis = IndexMap::new();
|
||||
redis.insert(
|
||||
"image".to_string(),
|
||||
YamlValue::String("redis:6".to_string()),
|
||||
);
|
||||
let mut redis_healthcheck = IndexMap::new();
|
||||
redis_healthcheck.insert(
|
||||
"test".to_string(),
|
||||
YamlValue::String("redis-cli ping".to_string()),
|
||||
);
|
||||
redis_healthcheck.insert(
|
||||
"interval".to_string(),
|
||||
YamlValue::String("5s".to_string()),
|
||||
);
|
||||
redis_healthcheck.insert(
|
||||
"timeout".to_string(),
|
||||
YamlValue::String("1s".to_string()),
|
||||
);
|
||||
redis_healthcheck.insert(
|
||||
"retries".to_string(),
|
||||
YamlValue::String("5".to_string()),
|
||||
);
|
||||
redis.insert("healthcheck".to_string(), YamlValue::Dict(redis_healthcheck));
|
||||
services.insert("langgraph-redis".to_string(), YamlValue::Dict(redis));
|
||||
|
||||
// Postgres service (if needed)
|
||||
if include_db {
|
||||
let mut postgres = IndexMap::new();
|
||||
postgres.insert(
|
||||
"image".to_string(),
|
||||
YamlValue::String("pgvector/pgvector:pg16".to_string()),
|
||||
);
|
||||
postgres.insert(
|
||||
"ports".to_string(),
|
||||
YamlValue::List(vec!["\"5433:5432\"".to_string()]),
|
||||
);
|
||||
|
||||
let mut pg_env = IndexMap::new();
|
||||
pg_env.insert(
|
||||
"POSTGRES_DB".to_string(),
|
||||
YamlValue::String("postgres".to_string()),
|
||||
);
|
||||
pg_env.insert(
|
||||
"POSTGRES_USER".to_string(),
|
||||
YamlValue::String("postgres".to_string()),
|
||||
);
|
||||
pg_env.insert(
|
||||
"POSTGRES_PASSWORD".to_string(),
|
||||
YamlValue::String("postgres".to_string()),
|
||||
);
|
||||
postgres.insert("environment".to_string(), YamlValue::Dict(pg_env));
|
||||
|
||||
postgres.insert(
|
||||
"command".to_string(),
|
||||
YamlValue::List(vec![
|
||||
"postgres".to_string(),
|
||||
"-c".to_string(),
|
||||
"shared_preload_libraries=vector".to_string(),
|
||||
]),
|
||||
);
|
||||
|
||||
postgres.insert(
|
||||
"volumes".to_string(),
|
||||
YamlValue::List(vec![
|
||||
"langgraph-data:/var/lib/postgresql/data".to_string(),
|
||||
]),
|
||||
);
|
||||
|
||||
let mut pg_healthcheck = IndexMap::new();
|
||||
pg_healthcheck.insert(
|
||||
"test".to_string(),
|
||||
YamlValue::String("pg_isready -U postgres".to_string()),
|
||||
);
|
||||
pg_healthcheck.insert(
|
||||
"start_period".to_string(),
|
||||
YamlValue::String("10s".to_string()),
|
||||
);
|
||||
pg_healthcheck.insert(
|
||||
"timeout".to_string(),
|
||||
YamlValue::String("1s".to_string()),
|
||||
);
|
||||
pg_healthcheck.insert(
|
||||
"retries".to_string(),
|
||||
YamlValue::String("5".to_string()),
|
||||
);
|
||||
|
||||
if capabilities.healthcheck_start_interval {
|
||||
pg_healthcheck.insert(
|
||||
"interval".to_string(),
|
||||
YamlValue::String("60s".to_string()),
|
||||
);
|
||||
pg_healthcheck.insert(
|
||||
"start_interval".to_string(),
|
||||
YamlValue::String("1s".to_string()),
|
||||
);
|
||||
} else {
|
||||
pg_healthcheck.insert(
|
||||
"interval".to_string(),
|
||||
YamlValue::String("5s".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
postgres.insert("healthcheck".to_string(), YamlValue::Dict(pg_healthcheck));
|
||||
services.insert("langgraph-postgres".to_string(), YamlValue::Dict(postgres));
|
||||
}
|
||||
|
||||
// Debugger service (if port specified)
|
||||
if let Some(dbg_port) = debugger_port {
|
||||
let debugger = debugger_compose(Some(dbg_port), debugger_base_url);
|
||||
for (k, v) in debugger {
|
||||
services.insert(k, v);
|
||||
}
|
||||
}
|
||||
|
||||
// LangGraph API service
|
||||
let mut api = IndexMap::new();
|
||||
api.insert(
|
||||
"ports".to_string(),
|
||||
YamlValue::List(vec![format!("\"{port}:8000\"")]),
|
||||
);
|
||||
|
||||
let mut api_depends = IndexMap::new();
|
||||
let mut redis_condition = IndexMap::new();
|
||||
redis_condition.insert(
|
||||
"condition".to_string(),
|
||||
YamlValue::String("service_healthy".to_string()),
|
||||
);
|
||||
api_depends.insert(
|
||||
"langgraph-redis".to_string(),
|
||||
YamlValue::Dict(redis_condition),
|
||||
);
|
||||
api.insert("depends_on".to_string(), YamlValue::Dict(api_depends.clone()));
|
||||
|
||||
let mut api_env = IndexMap::new();
|
||||
api_env.insert(
|
||||
"REDIS_URI".to_string(),
|
||||
YamlValue::String("redis://langgraph-redis:6379".to_string()),
|
||||
);
|
||||
api_env.insert(
|
||||
"POSTGRES_URI".to_string(),
|
||||
YamlValue::String(postgres_uri.to_string()),
|
||||
);
|
||||
api.insert("environment".to_string(), YamlValue::Dict(api_env));
|
||||
|
||||
if let Some(img) = image {
|
||||
api.insert(
|
||||
"image".to_string(),
|
||||
YamlValue::String(img.to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
// Add postgres dependency for API service
|
||||
if include_db {
|
||||
if let YamlValue::Dict(ref mut deps) = api.get_mut("depends_on").unwrap() {
|
||||
let mut pg_condition = IndexMap::new();
|
||||
pg_condition.insert(
|
||||
"condition".to_string(),
|
||||
YamlValue::String("service_healthy".to_string()),
|
||||
);
|
||||
deps.insert(
|
||||
"langgraph-postgres".to_string(),
|
||||
YamlValue::Dict(pg_condition),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Healthcheck for API service
|
||||
if capabilities.healthcheck_start_interval {
|
||||
let mut api_healthcheck = IndexMap::new();
|
||||
api_healthcheck.insert(
|
||||
"test".to_string(),
|
||||
YamlValue::String("python /api/healthcheck.py".to_string()),
|
||||
);
|
||||
api_healthcheck.insert(
|
||||
"interval".to_string(),
|
||||
YamlValue::String("60s".to_string()),
|
||||
);
|
||||
api_healthcheck.insert(
|
||||
"start_interval".to_string(),
|
||||
YamlValue::String("1s".to_string()),
|
||||
);
|
||||
api_healthcheck.insert(
|
||||
"start_period".to_string(),
|
||||
YamlValue::String("10s".to_string()),
|
||||
);
|
||||
api.insert("healthcheck".to_string(), YamlValue::Dict(api_healthcheck));
|
||||
}
|
||||
|
||||
services.insert("langgraph-api".to_string(), YamlValue::Dict(api));
|
||||
|
||||
// Build final compose dict
|
||||
let mut compose_dict = IndexMap::new();
|
||||
if include_db {
|
||||
let mut volumes = IndexMap::new();
|
||||
let mut vol_config = IndexMap::new();
|
||||
vol_config.insert(
|
||||
"driver".to_string(),
|
||||
YamlValue::String("local".to_string()),
|
||||
);
|
||||
volumes.insert("langgraph-data".to_string(), YamlValue::Dict(vol_config));
|
||||
compose_dict.insert("volumes".to_string(), YamlValue::Dict(volumes));
|
||||
}
|
||||
compose_dict.insert("services".to_string(), YamlValue::Dict(services));
|
||||
|
||||
compose_dict
|
||||
}
|
||||
|
||||
/// Create a docker compose file as a string.
|
||||
pub fn compose(
|
||||
capabilities: &DockerCapabilities,
|
||||
port: u16,
|
||||
debugger_port: Option<u16>,
|
||||
debugger_base_url: Option<&str>,
|
||||
postgres_uri: Option<&str>,
|
||||
image: Option<&str>,
|
||||
base_image: Option<&str>,
|
||||
api_version: Option<&str>,
|
||||
) -> String {
|
||||
let compose_dict = compose_as_dict(
|
||||
capabilities,
|
||||
port,
|
||||
debugger_port,
|
||||
debugger_base_url,
|
||||
postgres_uri,
|
||||
image,
|
||||
base_image,
|
||||
api_version,
|
||||
);
|
||||
dict_to_yaml(&compose_dict, 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::docker::capabilities::{ComposeType, Version};
|
||||
use crate::util::clean_empty_lines;
|
||||
|
||||
fn default_capabilities() -> DockerCapabilities {
|
||||
DockerCapabilities {
|
||||
version_docker: Version::new(26, 1, 1),
|
||||
version_compose: Version::new(2, 27, 0),
|
||||
healthcheck_start_interval: false,
|
||||
compose_type: ComposeType::Plugin,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dict_to_yaml_simple() {
|
||||
let mut d = IndexMap::new();
|
||||
d.insert(
|
||||
"key1".to_string(),
|
||||
YamlValue::String("value1".to_string()),
|
||||
);
|
||||
d.insert(
|
||||
"key2".to_string(),
|
||||
YamlValue::String("value2".to_string()),
|
||||
);
|
||||
let result = dict_to_yaml(&d, 0);
|
||||
// Top-level keys get an extra newline separator between them
|
||||
assert_eq!(result, "key1: value1\n\nkey2: value2\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dict_to_yaml_nested() {
|
||||
let mut inner = IndexMap::new();
|
||||
inner.insert(
|
||||
"nested_key".to_string(),
|
||||
YamlValue::String("nested_value".to_string()),
|
||||
);
|
||||
let mut d = IndexMap::new();
|
||||
d.insert("outer".to_string(), YamlValue::Dict(inner));
|
||||
let result = dict_to_yaml(&d, 0);
|
||||
assert_eq!(result, "outer:\n nested_key: nested_value\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dict_to_yaml_list() {
|
||||
let mut d = IndexMap::new();
|
||||
d.insert(
|
||||
"items".to_string(),
|
||||
YamlValue::List(vec!["a".to_string(), "b".to_string()]),
|
||||
);
|
||||
let result = dict_to_yaml(&d, 0);
|
||||
assert_eq!(result, "items:\n - a\n - b\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_no_debugger_and_custom_db() {
|
||||
let port = 8123;
|
||||
let custom_postgres_uri = "custom_postgres_uri";
|
||||
let actual = compose(
|
||||
&default_capabilities(),
|
||||
port,
|
||||
None,
|
||||
None,
|
||||
Some(custom_postgres_uri),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let expected = format!(
|
||||
"services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {custom_postgres_uri}"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_no_debugger_and_custom_db_with_healthcheck() {
|
||||
let port = 8123;
|
||||
let custom_postgres_uri = "custom_postgres_uri";
|
||||
let mut caps = default_capabilities();
|
||||
caps.healthcheck_start_interval = true;
|
||||
let actual = compose(
|
||||
&caps,
|
||||
port,
|
||||
None,
|
||||
None,
|
||||
Some(custom_postgres_uri),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let expected = format!(
|
||||
"services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {custom_postgres_uri}\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: python /api/healthcheck.py\n\
|
||||
\x20 interval: 60s\n\
|
||||
\x20 start_interval: 1s\n\
|
||||
\x20 start_period: 10s"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_debugger_and_custom_db() {
|
||||
let port = 8123;
|
||||
let custom_postgres_uri = "custom_postgres_uri";
|
||||
let actual = compose(
|
||||
&default_capabilities(),
|
||||
port,
|
||||
None,
|
||||
None,
|
||||
Some(custom_postgres_uri),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let expected = format!(
|
||||
"services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {custom_postgres_uri}"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_debugger_and_default_db() {
|
||||
let port = 8123;
|
||||
let actual = compose(
|
||||
&default_capabilities(),
|
||||
port,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let expected = format!(
|
||||
"volumes:\n\
|
||||
\x20 langgraph-data:\n\
|
||||
\x20 driver: local\n\
|
||||
services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 image: pgvector/pgvector:pg16\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"5433:5432\"\n\
|
||||
\x20 environment:\n\
|
||||
\x20 POSTGRES_DB: postgres\n\
|
||||
\x20 POSTGRES_USER: postgres\n\
|
||||
\x20 POSTGRES_PASSWORD: postgres\n\
|
||||
\x20 command:\n\
|
||||
\x20 - postgres\n\
|
||||
\x20 - -c\n\
|
||||
\x20 - shared_preload_libraries=vector\n\
|
||||
\x20 volumes:\n\
|
||||
\x20 - langgraph-data:/var/lib/postgresql/data\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: pg_isready -U postgres\n\
|
||||
\x20 start_period: 10s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {DEFAULT_POSTGRES_URI}"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_api_version() {
|
||||
let port = 8123;
|
||||
let actual = compose(
|
||||
&default_capabilities(),
|
||||
port,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("0.2.74"),
|
||||
);
|
||||
let expected = format!(
|
||||
"volumes:\n\
|
||||
\x20 langgraph-data:\n\
|
||||
\x20 driver: local\n\
|
||||
services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 image: pgvector/pgvector:pg16\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"5433:5432\"\n\
|
||||
\x20 environment:\n\
|
||||
\x20 POSTGRES_DB: postgres\n\
|
||||
\x20 POSTGRES_USER: postgres\n\
|
||||
\x20 POSTGRES_PASSWORD: postgres\n\
|
||||
\x20 command:\n\
|
||||
\x20 - postgres\n\
|
||||
\x20 - -c\n\
|
||||
\x20 - shared_preload_libraries=vector\n\
|
||||
\x20 volumes:\n\
|
||||
\x20 - langgraph-data:/var/lib/postgresql/data\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: pg_isready -U postgres\n\
|
||||
\x20 start_period: 10s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {DEFAULT_POSTGRES_URI}"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_api_version_and_base_image() {
|
||||
let port = 8123;
|
||||
let actual = compose(
|
||||
&default_capabilities(),
|
||||
port,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("my-registry/custom-api"),
|
||||
Some("1.0.0"),
|
||||
);
|
||||
let expected = format!(
|
||||
"volumes:\n\
|
||||
\x20 langgraph-data:\n\
|
||||
\x20 driver: local\n\
|
||||
services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 image: pgvector/pgvector:pg16\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"5433:5432\"\n\
|
||||
\x20 environment:\n\
|
||||
\x20 POSTGRES_DB: postgres\n\
|
||||
\x20 POSTGRES_USER: postgres\n\
|
||||
\x20 POSTGRES_PASSWORD: postgres\n\
|
||||
\x20 command:\n\
|
||||
\x20 - postgres\n\
|
||||
\x20 - -c\n\
|
||||
\x20 - shared_preload_libraries=vector\n\
|
||||
\x20 volumes:\n\
|
||||
\x20 - langgraph-data:/var/lib/postgresql/data\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: pg_isready -U postgres\n\
|
||||
\x20 start_period: 10s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {DEFAULT_POSTGRES_URI}"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_api_version_and_custom_postgres() {
|
||||
let port = 8123;
|
||||
let custom_postgres_uri = "postgresql://user:pass@external-db:5432/mydb";
|
||||
let actual = compose(
|
||||
&default_capabilities(),
|
||||
port,
|
||||
None,
|
||||
None,
|
||||
Some(custom_postgres_uri),
|
||||
None,
|
||||
None,
|
||||
Some("0.2.74"),
|
||||
);
|
||||
let expected = format!(
|
||||
"services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {custom_postgres_uri}"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compose_with_api_version_and_debugger() {
|
||||
let port = 8123;
|
||||
let debugger_port = 8001;
|
||||
let actual = compose(
|
||||
&default_capabilities(),
|
||||
port,
|
||||
Some(debugger_port),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("0.2.74"),
|
||||
);
|
||||
let expected = format!(
|
||||
"volumes:\n\
|
||||
\x20 langgraph-data:\n\
|
||||
\x20 driver: local\n\
|
||||
services:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 image: redis:6\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: redis-cli ping\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 image: pgvector/pgvector:pg16\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"5433:5432\"\n\
|
||||
\x20 environment:\n\
|
||||
\x20 POSTGRES_DB: postgres\n\
|
||||
\x20 POSTGRES_USER: postgres\n\
|
||||
\x20 POSTGRES_PASSWORD: postgres\n\
|
||||
\x20 command:\n\
|
||||
\x20 - postgres\n\
|
||||
\x20 - -c\n\
|
||||
\x20 - shared_preload_libraries=vector\n\
|
||||
\x20 volumes:\n\
|
||||
\x20 - langgraph-data:/var/lib/postgresql/data\n\
|
||||
\x20 healthcheck:\n\
|
||||
\x20 test: pg_isready -U postgres\n\
|
||||
\x20 start_period: 10s\n\
|
||||
\x20 timeout: 1s\n\
|
||||
\x20 retries: 5\n\
|
||||
\x20 interval: 5s\n\
|
||||
\x20 langgraph-debugger:\n\
|
||||
\x20 image: langchain/langgraph-debugger\n\
|
||||
\x20 restart: on-failure\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{debugger_port}:3968\"\n\
|
||||
\x20 langgraph-api:\n\
|
||||
\x20 ports:\n\
|
||||
\x20 - \"{port}:8000\"\n\
|
||||
\x20 depends_on:\n\
|
||||
\x20 langgraph-redis:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 langgraph-postgres:\n\
|
||||
\x20 condition: service_healthy\n\
|
||||
\x20 environment:\n\
|
||||
\x20 REDIS_URI: redis://langgraph-redis:6379\n\
|
||||
\x20 POSTGRES_URI: {DEFAULT_POSTGRES_URI}"
|
||||
);
|
||||
assert_eq!(clean_empty_lines(&actual), expected);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
pub mod capabilities;
|
||||
pub mod compose;
|
||||
pub mod dockerfile;
|
||||
@@ -0,0 +1,211 @@
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Run a command synchronously, optionally piping stdin, and capturing stdout/stderr.
|
||||
///
|
||||
/// If `verbose` is true, the command is echoed to stdout before execution.
|
||||
///
|
||||
/// Returns `(Option<stdout>, Option<stderr>)` on success, or an error message on failure.
|
||||
pub fn run_command(
|
||||
cmd: &str,
|
||||
args: &[&str],
|
||||
input: Option<&str>,
|
||||
verbose: bool,
|
||||
) -> Result<(Option<String>, Option<String>), String> {
|
||||
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::piped())
|
||||
.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 stdin to signal EOF
|
||||
drop(child.stdin.take());
|
||||
}
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|e| format!("Failed to wait for `{cmd}`: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let code = output.status.code().unwrap_or(-1);
|
||||
// 130 = SIGINT (Ctrl-C), not an error
|
||||
if code == 130 {
|
||||
return Ok((None, None));
|
||||
}
|
||||
let stdout_str = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr_str = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!(
|
||||
"Command `{cmd}` exited with code {code}\nstdout: {stdout_str}\nstderr: {stderr_str}"
|
||||
));
|
||||
}
|
||||
|
||||
let stdout = if output.stdout.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(String::from_utf8_lossy(&output.stdout).to_string())
|
||||
};
|
||||
let stderr = if output.stderr.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(String::from_utf8_lossy(&output.stderr).to_string())
|
||||
};
|
||||
|
||||
Ok((stdout, stderr))
|
||||
}
|
||||
|
||||
/// Run a command and stream its stdout/stderr to the parent process in real-time.
|
||||
///
|
||||
/// This is useful for long-running commands like `docker compose up` where we
|
||||
/// want to see output as it happens.
|
||||
pub fn run_command_streaming(
|
||||
cmd: &str,
|
||||
args: &[&str],
|
||||
input: Option<&str>,
|
||||
verbose: bool,
|
||||
) -> Result<(), String> {
|
||||
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::inherit())
|
||||
.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());
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
/// 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<F>(
|
||||
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(())
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
mod analytics;
|
||||
mod commands;
|
||||
mod config;
|
||||
mod constants;
|
||||
mod docker;
|
||||
mod exec;
|
||||
mod progress;
|
||||
mod templates;
|
||||
mod util;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "langgraph", version = VERSION, about = "LangGraph CLI")]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Launch LangGraph API server with Docker
|
||||
Up {
|
||||
/// Path to configuration file declaring dependencies, graphs and environment variables
|
||||
#[arg(short, long, default_value = "langgraph.json")]
|
||||
config: String,
|
||||
|
||||
/// Port to expose
|
||||
#[arg(short, long, default_value_t = 8123)]
|
||||
port: u16,
|
||||
|
||||
/// Path to docker-compose.yml file with additional services
|
||||
#[arg(short, long)]
|
||||
docker_compose: Option<String>,
|
||||
|
||||
/// Show detailed output
|
||||
#[arg(short, long)]
|
||||
verbose: bool,
|
||||
|
||||
/// Restart on file changes using docker compose watch
|
||||
#[arg(short, long)]
|
||||
watch: bool,
|
||||
|
||||
/// Recreate containers even if configuration hasn't changed
|
||||
#[arg(long)]
|
||||
recreate: bool,
|
||||
|
||||
/// Skip pulling latest images before running
|
||||
#[arg(long)]
|
||||
no_pull: bool,
|
||||
|
||||
/// Wait for services to be healthy before returning
|
||||
#[arg(long)]
|
||||
wait: bool,
|
||||
|
||||
/// Port to expose the debugger on
|
||||
#[arg(long)]
|
||||
debugger_port: Option<u16>,
|
||||
|
||||
/// Base URL for the debugger
|
||||
#[arg(long)]
|
||||
debugger_base_url: Option<String>,
|
||||
|
||||
/// Postgres connection URI
|
||||
#[arg(long)]
|
||||
postgres_uri: Option<String>,
|
||||
|
||||
/// API version of the LangGraph server
|
||||
#[arg(long)]
|
||||
api_version: Option<String>,
|
||||
|
||||
/// Pre-built image to use instead of building
|
||||
#[arg(long)]
|
||||
image: Option<String>,
|
||||
|
||||
/// Base image for the LangGraph API server
|
||||
#[arg(long)]
|
||||
base_image: Option<String>,
|
||||
},
|
||||
|
||||
/// Build LangGraph API server Docker image
|
||||
Build {
|
||||
/// Path to configuration file
|
||||
#[arg(short, long, default_value = "langgraph.json")]
|
||||
config: String,
|
||||
|
||||
/// Tag for the docker image
|
||||
#[arg(short, long)]
|
||||
tag: String,
|
||||
|
||||
/// Skip pulling latest images before building
|
||||
#[arg(long)]
|
||||
no_pull: bool,
|
||||
|
||||
/// Base image for the LangGraph API server
|
||||
#[arg(long)]
|
||||
base_image: Option<String>,
|
||||
|
||||
/// API version of the LangGraph server
|
||||
#[arg(long)]
|
||||
api_version: Option<String>,
|
||||
|
||||
/// Custom install command
|
||||
#[arg(long)]
|
||||
install_command: Option<String>,
|
||||
|
||||
/// Custom build command
|
||||
#[arg(long)]
|
||||
build_command: Option<String>,
|
||||
|
||||
/// Additional arguments to pass to docker build
|
||||
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
|
||||
docker_build_args: Vec<String>,
|
||||
},
|
||||
|
||||
/// Generate a Dockerfile for the LangGraph API server
|
||||
Dockerfile {
|
||||
/// Path to save the generated Dockerfile
|
||||
save_path: String,
|
||||
|
||||
/// Path to configuration file
|
||||
#[arg(short, long, default_value = "langgraph.json")]
|
||||
config: String,
|
||||
|
||||
/// Add docker-compose.yml, .env, and .dockerignore files
|
||||
#[arg(long)]
|
||||
add_docker_compose: bool,
|
||||
|
||||
/// Base image for the LangGraph API server
|
||||
#[arg(long)]
|
||||
base_image: Option<String>,
|
||||
|
||||
/// API version of the LangGraph server
|
||||
#[arg(long)]
|
||||
api_version: Option<String>,
|
||||
},
|
||||
|
||||
/// Run LangGraph API server in development mode
|
||||
Dev {
|
||||
/// Network interface to bind to
|
||||
#[arg(long, default_value = "127.0.0.1")]
|
||||
host: String,
|
||||
|
||||
/// Port number
|
||||
#[arg(long, default_value_t = 2024)]
|
||||
port: u16,
|
||||
|
||||
/// Disable automatic reloading
|
||||
#[arg(long)]
|
||||
no_reload: bool,
|
||||
|
||||
/// Path to configuration file
|
||||
#[arg(short, long, default_value = "langgraph.json")]
|
||||
config: String,
|
||||
|
||||
/// Max concurrent jobs per worker
|
||||
#[arg(long)]
|
||||
n_jobs_per_worker: Option<u32>,
|
||||
|
||||
/// Skip opening browser
|
||||
#[arg(long)]
|
||||
no_browser: bool,
|
||||
|
||||
/// Enable remote debugging on specified port
|
||||
#[arg(long)]
|
||||
debug_port: Option<u16>,
|
||||
|
||||
/// Wait for debugger client to connect
|
||||
#[arg(long)]
|
||||
wait_for_client: bool,
|
||||
|
||||
/// URL of LangGraph Studio
|
||||
#[arg(long)]
|
||||
studio_url: Option<String>,
|
||||
|
||||
/// Allow synchronous I/O blocking operations
|
||||
#[arg(long)]
|
||||
allow_blocking: bool,
|
||||
|
||||
/// Expose via public tunnel
|
||||
#[arg(long)]
|
||||
tunnel: bool,
|
||||
|
||||
/// Log level for the API server
|
||||
#[arg(long, default_value = "WARNING")]
|
||||
server_log_level: String,
|
||||
},
|
||||
|
||||
/// Create a new LangGraph project from a template
|
||||
New {
|
||||
/// Path to create the project
|
||||
path: Option<String>,
|
||||
|
||||
/// Template to use
|
||||
#[arg(long)]
|
||||
template: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
let result = match cli.command {
|
||||
Commands::Up {
|
||||
config,
|
||||
port,
|
||||
docker_compose,
|
||||
verbose,
|
||||
watch,
|
||||
recreate,
|
||||
no_pull,
|
||||
wait,
|
||||
debugger_port,
|
||||
debugger_base_url,
|
||||
postgres_uri,
|
||||
api_version,
|
||||
image,
|
||||
base_image,
|
||||
} => commands::up::run(
|
||||
&config,
|
||||
port,
|
||||
docker_compose.as_deref(),
|
||||
verbose,
|
||||
watch,
|
||||
recreate,
|
||||
!no_pull,
|
||||
wait,
|
||||
debugger_port,
|
||||
debugger_base_url.as_deref(),
|
||||
postgres_uri.as_deref(),
|
||||
api_version.as_deref(),
|
||||
image.as_deref(),
|
||||
base_image.as_deref(),
|
||||
),
|
||||
Commands::Build {
|
||||
config,
|
||||
tag,
|
||||
no_pull,
|
||||
base_image,
|
||||
api_version,
|
||||
install_command,
|
||||
build_command,
|
||||
docker_build_args,
|
||||
} => commands::build_cmd::run(
|
||||
&config,
|
||||
&tag,
|
||||
!no_pull,
|
||||
base_image.as_deref(),
|
||||
api_version.as_deref(),
|
||||
install_command.as_deref(),
|
||||
build_command.as_deref(),
|
||||
&docker_build_args,
|
||||
),
|
||||
Commands::Dockerfile {
|
||||
save_path,
|
||||
config,
|
||||
add_docker_compose,
|
||||
base_image,
|
||||
api_version,
|
||||
} => commands::dockerfile::run(
|
||||
&save_path,
|
||||
&config,
|
||||
add_docker_compose,
|
||||
base_image.as_deref(),
|
||||
api_version.as_deref(),
|
||||
),
|
||||
Commands::Dev {
|
||||
host,
|
||||
port,
|
||||
no_reload,
|
||||
config,
|
||||
n_jobs_per_worker,
|
||||
no_browser,
|
||||
debug_port,
|
||||
wait_for_client,
|
||||
studio_url,
|
||||
allow_blocking,
|
||||
tunnel,
|
||||
server_log_level,
|
||||
} => commands::dev::run(
|
||||
&host,
|
||||
port,
|
||||
no_reload,
|
||||
&config,
|
||||
n_jobs_per_worker,
|
||||
no_browser,
|
||||
debug_port,
|
||||
wait_for_client,
|
||||
studio_url.as_deref(),
|
||||
allow_blocking,
|
||||
tunnel,
|
||||
&server_log_level,
|
||||
),
|
||||
Commands::New { path, template } => {
|
||||
commands::new::run(path.as_deref(), template.as_deref())
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = result {
|
||||
eprintln!("Error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use indicatif::{ProgressBar, ProgressStyle};
|
||||
|
||||
/// Terminal spinner using indicatif.
|
||||
///
|
||||
/// Wraps an indicatif spinner that displays an animated progress indicator
|
||||
/// with a configurable message.
|
||||
pub struct Progress {
|
||||
spinner: ProgressBar,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
/// Create a new spinner with the given initial message.
|
||||
pub fn new(message: &str) -> Self {
|
||||
let spinner = ProgressBar::new_spinner();
|
||||
spinner.set_style(
|
||||
ProgressStyle::default_spinner()
|
||||
.tick_strings(&["|", "/", "-", "\\", ""])
|
||||
.template("{spinner} {msg}")
|
||||
.unwrap_or_else(|_| ProgressStyle::default_spinner()),
|
||||
);
|
||||
spinner.set_message(message.to_string());
|
||||
spinner.enable_steady_tick(std::time::Duration::from_millis(100));
|
||||
Self { spinner }
|
||||
}
|
||||
|
||||
/// Update the spinner message.
|
||||
///
|
||||
/// If the message is empty, the spinner is effectively hidden but still running.
|
||||
pub fn set_message(&self, msg: &str) {
|
||||
self.spinner.set_message(msg.to_string());
|
||||
}
|
||||
|
||||
/// Stop the spinner and clear it from the terminal.
|
||||
pub fn finish(&self) {
|
||||
self.spinner.finish_and_clear();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Progress {
|
||||
fn drop(&mut self) {
|
||||
self.spinner.finish_and_clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Cursor, Read};
|
||||
use std::path::Path;
|
||||
|
||||
use console::style;
|
||||
use dialoguer::{Input, Select};
|
||||
|
||||
/// A project template definition.
|
||||
pub struct Template {
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
pub python: &'static str,
|
||||
pub js: &'static str,
|
||||
}
|
||||
|
||||
/// All available project templates.
|
||||
pub const TEMPLATES: &[Template] = &[
|
||||
Template {
|
||||
name: "New LangGraph Project",
|
||||
description: "A simple, minimal chatbot with memory.",
|
||||
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",
|
||||
},
|
||||
Template {
|
||||
name: "ReAct Agent",
|
||||
description: "A simple agent that can be flexibly extended to many tools.",
|
||||
python: "https://github.com/langchain-ai/react-agent/archive/refs/heads/main.zip",
|
||||
js: "https://github.com/langchain-ai/react-agent-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
Template {
|
||||
name: "Memory Agent",
|
||||
description: "A ReAct-style agent with an additional tool to store memories for use across conversational threads.",
|
||||
python: "https://github.com/langchain-ai/memory-agent/archive/refs/heads/main.zip",
|
||||
js: "https://github.com/langchain-ai/memory-agent-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
Template {
|
||||
name: "Retrieval Agent",
|
||||
description: "An agent that includes a retrieval-based question-answering system.",
|
||||
python: "https://github.com/langchain-ai/retrieval-agent-template/archive/refs/heads/main.zip",
|
||||
js: "https://github.com/langchain-ai/retrieval-agent-template-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
Template {
|
||||
name: "Data-enrichment Agent",
|
||||
description: "An agent that performs web searches and organizes its findings into a structured format.",
|
||||
python: "https://github.com/langchain-ai/data-enrichment/archive/refs/heads/main.zip",
|
||||
js: "https://github.com/langchain-ai/data-enrichment-js/archive/refs/heads/main.zip",
|
||||
},
|
||||
];
|
||||
|
||||
/// Mapping from template ID (e.g., "react-agent-python") to (template index, language, url).
|
||||
pub fn build_template_id_map() -> HashMap<String, (usize, &'static str, &'static str)> {
|
||||
let mut map = HashMap::new();
|
||||
for (idx, tmpl) in TEMPLATES.iter().enumerate() {
|
||||
let base = tmpl.name.to_lowercase().replace(' ', "-");
|
||||
map.insert(
|
||||
format!("{base}-python"),
|
||||
(idx, "python", tmpl.python),
|
||||
);
|
||||
map.insert(format!("{base}-js"), (idx, "js", tmpl.js));
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// Get a sorted list of all valid template IDs.
|
||||
pub fn template_ids() -> Vec<String> {
|
||||
let map = build_template_id_map();
|
||||
let mut ids: Vec<String> = map.keys().cloned().collect();
|
||||
ids.sort();
|
||||
ids
|
||||
}
|
||||
|
||||
/// Interactively choose a template. Returns the download URL.
|
||||
fn choose_template() -> Result<String, String> {
|
||||
eprintln!("{}", style("Please select a template:").bold().yellow());
|
||||
let items: Vec<String> = TEMPLATES
|
||||
.iter()
|
||||
.map(|t| format!("{} - {}", t.name, t.description))
|
||||
.collect();
|
||||
|
||||
let selection = Select::new()
|
||||
.with_prompt("Select a template")
|
||||
.items(&items)
|
||||
.default(0)
|
||||
.interact()
|
||||
.map_err(|e| format!("Template selection failed: {e}"))?;
|
||||
|
||||
let tmpl = &TEMPLATES[selection];
|
||||
eprintln!(
|
||||
"\n{}",
|
||||
style(format!("You selected: {} - {}", tmpl.name, tmpl.description)).green()
|
||||
);
|
||||
|
||||
let lang_items = vec!["Python", "JS/TS"];
|
||||
let lang_choice = Select::new()
|
||||
.with_prompt("Choose language")
|
||||
.items(&lang_items)
|
||||
.default(0)
|
||||
.interact()
|
||||
.map_err(|e| format!("Language selection failed: {e}"))?;
|
||||
|
||||
let url = if lang_choice == 0 {
|
||||
tmpl.python
|
||||
} else {
|
||||
tmpl.js
|
||||
};
|
||||
Ok(url.to_string())
|
||||
}
|
||||
|
||||
/// Download a zip archive from `url` and extract to `path`.
|
||||
fn download_repo(url: &str, path: &str) -> Result<(), String> {
|
||||
eprintln!(
|
||||
"{}",
|
||||
style("Downloading repository as a ZIP archive...").yellow()
|
||||
);
|
||||
eprintln!("{}", style(format!("URL: {url}")).yellow());
|
||||
|
||||
// Use blocking reqwest to download
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.map_err(|e| format!("Failed to download repository: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Failed to download repository. HTTP status: {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.map_err(|e| format!("Failed to read response body: {e}"))?;
|
||||
|
||||
let target_path = Path::new(path);
|
||||
if !target_path.exists() {
|
||||
std::fs::create_dir_all(target_path)
|
||||
.map_err(|e| format!("Failed to create directory {path}: {e}"))?;
|
||||
}
|
||||
|
||||
let cursor = Cursor::new(bytes);
|
||||
let mut archive =
|
||||
zip::ZipArchive::new(cursor).map_err(|e| format!("Failed to open ZIP archive: {e}"))?;
|
||||
|
||||
// Extract all files
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive
|
||||
.by_index(i)
|
||||
.map_err(|e| format!("Failed to read ZIP entry: {e}"))?;
|
||||
let name = file.name().to_string();
|
||||
|
||||
// Strip the top-level directory (e.g., "repo-main/")
|
||||
let stripped = match name.split_once('/') {
|
||||
Some((_, rest)) if !rest.is_empty() => rest.to_string(),
|
||||
_ => continue, // Skip the top-level directory entry itself
|
||||
};
|
||||
|
||||
let out_path = target_path.join(&stripped);
|
||||
|
||||
if file.is_dir() {
|
||||
std::fs::create_dir_all(&out_path)
|
||||
.map_err(|e| format!("Failed to create directory {}: {e}", out_path.display()))?;
|
||||
} else {
|
||||
if let Some(parent) = out_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
format!("Failed to create directory {}: {e}", parent.display())
|
||||
})?;
|
||||
}
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf)
|
||||
.map_err(|e| format!("Failed to read file from ZIP: {e}"))?;
|
||||
std::fs::write(&out_path, &buf)
|
||||
.map_err(|e| format!("Failed to write {}: {e}", out_path.display()))?;
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!("Downloaded and extracted repository to {path}")).green()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new LangGraph project at the specified path using the chosen template.
|
||||
///
|
||||
/// If `path` is None, the user is prompted interactively.
|
||||
/// If `template` is None, the user picks from an interactive menu.
|
||||
pub fn create_new(path: Option<&str>, template: Option<&str>) -> Result<(), String> {
|
||||
// Prompt for path if not provided
|
||||
let path = match path {
|
||||
Some(p) => p.to_string(),
|
||||
None => {
|
||||
let input: String = Input::new()
|
||||
.with_prompt("Please specify the path to create the application")
|
||||
.default(".".to_string())
|
||||
.interact_text()
|
||||
.map_err(|e| format!("Input failed: {e}"))?;
|
||||
input
|
||||
}
|
||||
};
|
||||
|
||||
let abs_path = std::path::Path::new(&path)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| std::path::PathBuf::from(&path));
|
||||
let abs_path_str = abs_path.to_string_lossy().to_string();
|
||||
|
||||
// If the path doesn't exist yet, that's fine. But if it exists and is non-empty, abort.
|
||||
if abs_path.exists() {
|
||||
let entries = std::fs::read_dir(&abs_path)
|
||||
.map_err(|e| format!("Could not read directory {abs_path_str}: {e}"))?;
|
||||
if entries.count() > 0 {
|
||||
return Err(
|
||||
"The specified directory already exists and is not empty. \
|
||||
Aborting to prevent overwriting files."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Get template URL either from command-line argument or through interactive selection
|
||||
let template_url = if let Some(tmpl_id) = template {
|
||||
let id_map = build_template_id_map();
|
||||
if let Some((_idx, _lang, url)) = id_map.get(tmpl_id) {
|
||||
url.to_string()
|
||||
} else {
|
||||
let ids = template_ids();
|
||||
let mut options = String::new();
|
||||
for id in &ids {
|
||||
let (_idx, _lang, _url) = id_map.get(id.as_str()).unwrap();
|
||||
let tmpl = &TEMPLATES[*_idx];
|
||||
options.push_str(&format!("- {id}: {}\n", tmpl.description));
|
||||
}
|
||||
return Err(format!(
|
||||
"Template '{tmpl_id}' not found.\n\
|
||||
Please select from the available options:\n{options}"
|
||||
));
|
||||
}
|
||||
} else {
|
||||
choose_template()?
|
||||
};
|
||||
|
||||
// Download and extract the template
|
||||
download_repo(&template_url, &abs_path_str)?;
|
||||
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(format!("New project created at {abs_path_str}"))
|
||||
.green()
|
||||
.bold()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use console::style;
|
||||
|
||||
/// Remove empty lines from a string.
|
||||
pub fn clean_empty_lines(input: &str) -> String {
|
||||
input
|
||||
.lines()
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// Show warning if image_distro is not set to 'wolfi'.
|
||||
pub fn warn_non_wolfi_distro(config: &serde_json::Value) {
|
||||
let image_distro = config
|
||||
.get("image_distro")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("debian");
|
||||
|
||||
if image_distro != "wolfi" {
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(
|
||||
"Warning: Security Recommendation: Consider switching to Wolfi Linux for enhanced security."
|
||||
)
|
||||
.yellow()
|
||||
.bold()
|
||||
);
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(
|
||||
" Wolfi is a security-oriented, minimal Linux distribution designed for containers."
|
||||
)
|
||||
.yellow()
|
||||
);
|
||||
eprintln!(
|
||||
"{}",
|
||||
style(
|
||||
" To switch, add '\"image_distro\": \"wolfi\"' to your langgraph.json config file."
|
||||
)
|
||||
.yellow()
|
||||
);
|
||||
eprintln!();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_clean_empty_lines() {
|
||||
assert_eq!(clean_empty_lines("line1\n\nline2\n\nline3"), "line1\nline2\nline3");
|
||||
assert_eq!(clean_empty_lines("line1\nline2\nline3"), "line1\nline2\nline3");
|
||||
assert_eq!(clean_empty_lines("\n\n\n"), "");
|
||||
assert_eq!(clean_empty_lines(""), "");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user