diff --git a/libs/cli/Cargo.lock b/libs/cli/Cargo.lock new file mode 100644 index 000000000..a2f89047e --- /dev/null +++ b/libs/cli/Cargo.lock @@ -0,0 +1,2593 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "assert_cmd" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bzip2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +dependencies = [ + "bzip2-sys", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.13+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "cc" +version = "1.2.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "clap" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "unicode-width", + "windows-sys 0.59.0", +] + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deflate64" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204" + +[[package]] +name = "deranged" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dialoguer" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" +dependencies = [ + "console", + "shell-words", + "tempfile", + "thiserror 1.0.69", + "zeroize", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "indicatif" +version = "0.17.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235" +dependencies = [ + "console", + "number_prefix", + "portable-atomic", + "unicode-width", + "web-time", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "langgraph-cli" +version = "0.2.10" +dependencies = [ + "assert_cmd", + "clap", + "console", + "dialoguer", + "dotenvy", + "indexmap", + "indicatif", + "open", + "predicates", + "regex", + "reqwest", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "which", + "zip", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.181" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "459427e2af2b9c839b132acb702a1c654d95e10f8c326bfc2ad11310e458b1c5" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lzma-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297e814c836ae64db86b36cf2a557ba54368d03f6afcd7d947c266692f71115e" +dependencies = [ + "byteorder", + "crc", +] + +[[package]] +name = "lzma-sys" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicode-ident" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "7.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d643ce3fd3e5b54854602a080f34fb10ab75e0b813ee32d00ca2b44fa74762" +dependencies = [ + "either", + "env_home", + "rustix", + "winsafe", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "xz2" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +dependencies = [ + "lzma-sys", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "aes", + "arbitrary", + "bzip2", + "constant_time_eq", + "crc32fast", + "crossbeam-utils", + "deflate64", + "displaydoc", + "flate2", + "getrandom 0.3.4", + "hmac", + "indexmap", + "lzma-rs", + "memchr", + "pbkdf2", + "sha1", + "thiserror 2.0.18", + "time", + "xz2", + "zeroize", + "zopfli", + "zstd", +] + +[[package]] +name = "zmij" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/libs/cli/Cargo.toml b/libs/cli/Cargo.toml new file mode 100644 index 000000000..1cb46eca1 --- /dev/null +++ b/libs/cli/Cargo.toml @@ -0,0 +1,32 @@ +[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" } +tokio = { version = "1", features = ["full"] } +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" diff --git a/libs/cli/build.rs b/libs/cli/build.rs new file mode 100644 index 000000000..e98200255 --- /dev/null +++ b/libs/cli/build.rs @@ -0,0 +1,7 @@ +fn main() { + // Make version available at compile time + println!( + "cargo:rustc-env=CARGO_PKG_VERSION={}", + env!("CARGO_PKG_VERSION") + ); +} diff --git a/libs/cli/src/analytics.rs b/libs/cli/src/analytics.rs new file mode 100644 index 000000000..afeec9c90 --- /dev/null +++ b/libs/cli/src/analytics.rs @@ -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) { + 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(); + }); +} diff --git a/libs/cli/src/commands/build_cmd.rs b/libs/cli/src/commands/build_cmd.rs new file mode 100644 index 000000000..f91a1b755 --- /dev/null +++ b/libs/cli/src/commands/build_cmd.rs @@ -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 = 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(()) +} diff --git a/libs/cli/src/commands/dev.rs b/libs/cli/src/commands/dev.rs new file mode 100644 index 000000000..5b7382714 --- /dev/null +++ b/libs/cli/src/commands/dev.rs @@ -0,0 +1,218 @@ +use std::collections::HashMap; +use std::path::Path; +use std::process::Command; + +use console::style; + +use crate::analytics; +use crate::config::validate_config_file; + +/// Run the LangGraph API server in development mode (in-memory, via Python subprocess). +/// +/// This spawns `python -c "from langgraph_api.cli import run_server; ..."` as a subprocess, +/// passing the parsed config as arguments. For JS graphs, an error is returned since the +/// in-memory server doesn't support them in this CLI. +#[allow(clippy::too_many_arguments)] +pub fn run( + host: &str, + port: u16, + no_reload: bool, + config: &str, + n_jobs_per_worker: Option, + no_browser: bool, + debug_port: Option, + 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(), + ); + } + + // Build the graphs JSON + let graphs_json = serde_json::to_string(&config_json.graphs) + .map_err(|e| format!("Failed to serialize graphs: {e}"))?; + + // Build env JSON (optional) + let env_json = config_json + .env + .as_ref() + .map(|e| serde_json::to_string(e).unwrap_or_else(|_| "null".to_string())) + .unwrap_or_else(|| "None".to_string()); + + // Build store JSON (optional) + let store_json = config_json + .store + .as_ref() + .map(|s| serde_json::to_string(s).unwrap_or_else(|_| "null".to_string())) + .unwrap_or_else(|| "None".to_string()); + + // Build auth JSON (optional) + let auth_json = config_json + .auth + .as_ref() + .map(|a| serde_json::to_string(a).unwrap_or_else(|_| "null".to_string())) + .unwrap_or_else(|| "None".to_string()); + + // Build http JSON (optional) + let http_json = config_json + .http + .as_ref() + .map(|h| serde_json::to_string(h).unwrap_or_else(|_| "null".to_string())) + .unwrap_or_else(|| "None".to_string()); + + // Build ui JSON (optional) + let ui_json = config_json + .ui + .as_ref() + .map(|u| serde_json::to_string(u).unwrap_or_else(|_| "null".to_string())) + .unwrap_or_else(|| "None".to_string()); + + // Build ui_config JSON (optional) + let ui_config_json = config_json + .ui_config + .as_ref() + .map(|u| serde_json::to_string(u).unwrap_or_else(|_| "null".to_string())) + .unwrap_or_else(|| "None".to_string()); + + // Build webhooks JSON (optional) + let webhooks_json = config_json + .webhooks + .as_ref() + .map(|w| serde_json::to_string(w).unwrap_or_else(|_| "null".to_string())) + .unwrap_or_else(|| "None".to_string()); + + // Build n_jobs_per_worker + let n_jobs_str = n_jobs_per_worker + .map(|n| n.to_string()) + .unwrap_or_else(|| "None".to_string()); + + // Build debug_port + let debug_port_str = debug_port + .map(|p| p.to_string()) + .unwrap_or_else(|| "None".to_string()); + + // Build studio_url + let studio_url_str = studio_url + .map(|s| format!("\"{}\"", s.replace('"', "\\\""))) + .unwrap_or_else(|| "None".to_string()); + + // Construct the Python code to execute + let python_code = format!( + r#" +import sys, os, json +cwd = os.getcwd() +sys.path.append(cwd) +deps = json.loads('{deps_json}') +for dep in deps: + import pathlib + dep_path = pathlib.Path(cwd) / dep + if dep_path.is_dir() and dep_path.exists(): + sys.path.append(str(dep_path)) +from langgraph_api.cli import run_server +graphs = json.loads('{graphs}') +run_server( + "{host}", + {port}, + {reload}, + graphs, + n_jobs_per_worker={n_jobs}, + open_browser={open_browser}, + debug_port={debug_port}, + env={env}, + store={store}, + wait_for_client={wait_for_client}, + auth={auth}, + http={http}, + ui={ui}, + ui_config={ui_config}, + webhooks={webhooks}, + studio_url={studio_url}, + allow_blocking={allow_blocking}, + tunnel={tunnel}, + server_level="{server_log_level}", +) +"#, + deps_json = serde_json::to_string(&config_json.dependencies) + .unwrap_or_else(|_| "[]".to_string()) + .replace('\'', "\\'"), + graphs = graphs_json.replace('\'', "\\'"), + host = host, + port = port, + reload = if no_reload { "False" } else { "True" }, + n_jobs = n_jobs_str, + open_browser = if no_browser { "False" } else { "True" }, + debug_port = debug_port_str, + env = env_json, + store = store_json, + wait_for_client = if wait_for_client { "True" } else { "False" }, + auth = auth_json, + http = http_json, + ui = ui_json, + ui_config = ui_config_json, + webhooks = webhooks_json, + studio_url = studio_url_str, + allow_blocking = if allow_blocking { "True" } else { "False" }, + tunnel = if tunnel { "True" } else { "False" }, + server_log_level = server_log_level, + ); + + eprintln!( + "{}", + style("Starting LangGraph API server in development mode...").green() + ); + + // Spawn Python subprocess + let status = Command::new("python") + .arg("-c") + .arg(&python_code) + .current_dir( + config_path + .parent() + .unwrap_or_else(|| Path::new(".")), + ) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .status() + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + "Python not found. The `langgraph dev` command requires Python >= 3.11 with \ + langgraph-cli[inmem] installed.\n\ + Install with: pip install -U \"langgraph-cli[inmem]\"" + .to_string() + } else { + format!("Failed to start Python: {e}") + } + })?; + + 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(()) +} diff --git a/libs/cli/src/commands/dockerfile.rs b/libs/cli/src/commands/dockerfile.rs new file mode 100644 index 000000000..8aef44374 --- /dev/null +++ b/libs/cli/src/commands/dockerfile.rs @@ -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 = 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(()) +} diff --git a/libs/cli/src/commands/mod.rs b/libs/cli/src/commands/mod.rs new file mode 100644 index 000000000..46fbbb492 --- /dev/null +++ b/libs/cli/src/commands/mod.rs @@ -0,0 +1,5 @@ +pub mod build_cmd; +pub mod dev; +pub mod dockerfile; +pub mod new; +pub mod up; diff --git a/libs/cli/src/commands/new.rs b/libs/cli/src/commands/new.rs new file mode 100644 index 000000000..c0514ac5c --- /dev/null +++ b/libs/cli/src/commands/new.rs @@ -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) +} diff --git a/libs/cli/src/commands/up.rs b/libs/cli/src/commands/up.rs new file mode 100644 index 000000000..ef2758b4c --- /dev/null +++ b/libs/cli/src/commands/up.rs @@ -0,0 +1,162 @@ +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}; +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, + 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 = 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()); + } + + progress.finish(); + + // Run docker compose with streaming output + run_command_streaming(compose_cmd[0], &cmd_args, Some(&compose_stdin), verbose)?; + + Ok(()) +} diff --git a/libs/cli/src/config/docker_tag.rs b/libs/cli/src/config/docker_tag.rs new file mode 100644 index 000000000..b53f55461 --- /dev/null +++ b/libs/cli/src/config/docker_tag.rs @@ -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", + ); + } +} diff --git a/libs/cli/src/config/local_deps.rs b/libs/cli/src/config/local_deps.rs new file mode 100644 index 000000000..368e610fd --- /dev/null +++ b/libs/cli/src/config/local_deps.rs @@ -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, + /// host_path -> (dependency_string, container_path) + pub faux_pkgs: IndexMap, + /// If "." is in dependencies, use it as working_dir + pub working_dir: Option, + /// Directories outside the config parent that need additional Docker build contexts + pub additional_contexts: Vec, +} + +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 { + 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 = RESERVED_PACKAGE_NAMES + .iter() + .map(|s| s.to_string()) + .collect(); + let mut counter: HashMap = HashMap::new(); + + let check_reserved = |name: &str, ref_str: &str, reserved: &mut HashSet| -> 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 = 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 = 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, + }) +} diff --git a/libs/cli/src/config/mod.rs b/libs/cli/src/config/mod.rs new file mode 100644 index 000000000..63ab5b29a --- /dev/null +++ b/libs/cli/src/config/mod.rs @@ -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 { + if version_str.contains('.') { + return Err(format!( + "Invalid Node.js version format: {version_str}. Use major version only (e.g., '20')." + )); + } + version_str + .parse::() + .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 { + 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::() + .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 { + 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::>() + )); + } + 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::(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")); + } +} diff --git a/libs/cli/src/config/path_rewrite.rs b/libs/cli/src/config/path_rewrite.rs new file mode 100644 index 000000000..443b90b90 --- /dev/null +++ b/libs/cli/src/config/path_rewrite.rs @@ -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 = 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 \":\"." + )); + } + }; + + // 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 \":\"." + )); + } + }; + + 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." + )) +} diff --git a/libs/cli/src/config/schema.rs b/libs/cli/src/config/schema.rs new file mode 100644 index 000000000..42645f2d1 --- /dev/null +++ b/libs/cli/src/config/schema.rs @@ -0,0 +1,193 @@ +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, + + #[serde(skip_serializing_if = "Option::is_none")] + pub node_version: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub api_version: Option, + + #[serde(rename = "_INTERNAL_docker_tag", skip_serializing_if = "Option::is_none")] + pub internal_docker_tag: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub base_image: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub image_distro: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub pip_config_file: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub pip_installer: Option, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub dockerfile_lines: Vec, + + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub dependencies: Vec, + + #[serde(default, skip_serializing_if = "IndexMap::is_empty")] + pub graphs: IndexMap, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub env: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub store: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub auth: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub encryption: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub http: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub webhooks: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub checkpointer: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub ui: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub ui_config: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub keep_pkg_tools: Option, +} + +/// 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), +} + +impl GraphSpec { + pub fn get_path(&self) -> Option<&str> { + match self { + GraphSpec::Path(s) => Some(s), + GraphSpec::Dict(m) => m.get("path").and_then(|v| v.as_str()), + } + } + + pub fn set_path(&mut self, new_path: String) { + match self { + GraphSpec::Path(s) => *s = new_path, + 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), + 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, + + #[serde(skip_serializing_if = "Option::is_none")] + pub disable_studio_auth: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub openapi: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub cache: Option, +} + +/// Encryption configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EncryptionConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +/// HTTP configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HttpConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub app: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub disable_assistants: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub disable_threads: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub disable_runs: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub disable_store: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub disable_mcp: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub disable_a2a: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub disable_meta: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub disable_ui: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub disable_webhooks: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub cors: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub configurable_headers: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub logging_headers: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub middleware_order: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub enable_custom_route_auth: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub mount_prefix: Option, +} + +/// 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), +} diff --git a/libs/cli/src/constants.rs b/libs/cli/src/constants.rs new file mode 100644 index 000000000..5e8c3afcc --- /dev/null +++ b/libs/cli/src/constants.rs @@ -0,0 +1,38 @@ +pub const DEFAULT_CONFIG: &str = "langgraph.json"; +pub const DEFAULT_PORT: u16 = 8123; + +pub const MIN_NODE_VERSION: u32 = 20; +pub const DEFAULT_NODE_VERSION: u32 = 20; + +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", +]; diff --git a/libs/cli/src/docker/capabilities.rs b/libs/cli/src/docker/capabilities.rs new file mode 100644 index 000000000..2f2f08dd8 --- /dev/null +++ b/libs/cli/src/docker/capabilities.rs @@ -0,0 +1,183 @@ +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 { + pub version_docker: Version, + 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 { + // 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) + ); + } +} diff --git a/libs/cli/src/docker/compose.rs b/libs/cli/src/docker/compose.rs new file mode 100644 index 000000000..e385957f1 --- /dev/null +++ b/libs/cli/src/docker/compose.rs @@ -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), + List(Vec), +} + +/// 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, 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, base_url: Option<&str>) -> IndexMap { + 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, + debugger_base_url: Option<&str>, + postgres_uri: Option<&str>, + image: Option<&str>, + _base_image: Option<&str>, + _api_version: Option<&str>, +) -> IndexMap { + 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, + 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); + } +} diff --git a/libs/cli/src/docker/dockerfile.rs b/libs/cli/src/docker/dockerfile.rs new file mode 100644 index 000000000..eb5291975 --- /dev/null +++ b/libs/cli/src/docker/dockerfile.rs @@ -0,0 +1,1965 @@ +use std::collections::HashSet; +use std::path::Path; + +use indexmap::IndexMap; + +use crate::config::docker_tag::docker_tag; +use crate::config::local_deps::{assemble_local_deps, LocalDeps}; +use crate::config::path_rewrite::{ + update_auth_path, update_encryption_path, update_graph_paths, update_http_app_path, +}; +use crate::config::schema::{Config, KeepPkgTools}; +use crate::constants::{BUILD_TOOLS, DEFAULT_NODE_VERSION}; + +use regex::Regex; + +/// Check if a base image supports uv. +fn image_supports_uv(base_image: &str) -> bool { + if base_image == "langchain/langgraph-trial" { + return false; + } + let re = Regex::new(r":(\d+(?:\.\d+)?(?:\.\d+)?)(?:-|$)").unwrap(); + match re.find(base_image) { + None => true, // Default image supports it + Some(m) => { + let raw = &base_image[m.start() + 1..m.end()]; + let version_str = raw.trim_end_matches('-'); + let parts: Vec = version_str.split('.').filter_map(|p: &str| p.parse().ok()).collect(); + let version = ( + *parts.first().unwrap_or(&0), + *parts.get(1).unwrap_or(&0), + *parts.get(2).unwrap_or(&0), + ); + version >= (0, 2, 47) + } + } +} + +/// Get build tools to uninstall based on config. +fn get_build_tools_to_uninstall(config: &Config) -> Vec { + match &config.keep_pkg_tools { + None => BUILD_TOOLS.iter().map(|s| s.to_string()).collect(), + Some(KeepPkgTools::Bool(true)) => vec![], + Some(KeepPkgTools::Bool(false)) => BUILD_TOOLS.iter().map(|s| s.to_string()).collect(), + Some(KeepPkgTools::List(keep)) => { + let keep_set: HashSet<&str> = keep.iter().map(|s| s.as_str()).collect(); + BUILD_TOOLS + .iter() + .filter(|t| !keep_set.contains(*t)) + .map(|s| s.to_string()) + .collect() + } + } +} + +/// Generate pip cleanup lines for the Dockerfile. +fn get_pip_cleanup_lines( + install_cmd: &str, + to_uninstall: &[String], + pip_installer: &str, +) -> String { + let mut commands = vec![format!( + "# -- Ensure user deps didn't inadvertently overwrite langgraph-api\n\ + RUN mkdir -p /api/langgraph_api /api/langgraph_runtime /api/langgraph_license && \\\n\ + touch /api/langgraph_api/__init__.py /api/langgraph_runtime/__init__.py /api/langgraph_license/__init__.py\n\ + RUN PYTHONDONTWRITEBYTECODE=1 {install_cmd} --no-cache-dir --no-deps -e /api\n\ + # -- End of ensuring user deps didn't inadvertently overwrite langgraph-api --\n\ + # -- Removing build deps from the final image ~<:===~~~ --" + )]; + + if !to_uninstall.is_empty() { + let mut sorted = to_uninstall.to_vec(); + sorted.sort(); + let packs_str = sorted.join(" "); + commands.push(format!("RUN pip uninstall -y {packs_str}")); + + let packages_rm: String = to_uninstall + .iter() + .map(|p| format!("/usr/local/lib/python*/site-packages/{p}*")) + .collect::>() + .join(" "); + let mut rm_cmd = format!("RUN rm -rf {packages_rm}"); + if to_uninstall.contains(&"pip".to_string()) { + rm_cmd.push_str(" && find /usr/local/bin -name \"pip*\" -delete || true"); + } + commands.push(rm_cmd); + + let wolfi_rm: String = to_uninstall + .iter() + .map(|p| format!("/usr/lib/python*/site-packages/{p}*")) + .collect::>() + .join(" "); + let mut wolfi_cmd = format!("RUN rm -rf {wolfi_rm}"); + if to_uninstall.contains(&"pip".to_string()) { + wolfi_cmd.push_str(" && find /usr/bin -name \"pip*\" -delete || true"); + } + commands.push(wolfi_cmd); + + if pip_installer == "uv" { + commands.push(format!( + "RUN uv pip uninstall --system {packs_str} && rm /usr/bin/uv /usr/bin/uvx" + )); + } + } else if pip_installer == "uv" { + commands.push( + "RUN rm /usr/bin/uv /usr/bin/uvx\n# -- End of build deps removal --".to_string(), + ); + } + + commands.join("\n") +} + +/// Detect the Node.js package manager install command. +fn get_node_pm_install_cmd(config_path: &Path, _config: &Config) -> String { + let parent = config_path.parent().unwrap(); + + let test_file = |name: &str| -> bool { parent.join(name).is_file() }; + + let npm = test_file("package-lock.json"); + let yarn = test_file("yarn.lock"); + let pnpm = test_file("pnpm-lock.yaml"); + let bun = test_file("bun.lockb"); + + if yarn { + "yarn install --frozen-lockfile".to_string() + } else if pnpm { + "pnpm i --frozen-lockfile".to_string() + } else if npm { + "npm ci".to_string() + } else if bun { + "bun i".to_string() + } else { + // Try to detect from package.json + let pkg_manager = get_pkg_manager_name(parent); + match pkg_manager.as_deref() { + Some("yarn") => "yarn install".to_string(), + Some("pnpm") => "pnpm i".to_string(), + Some("bun") => "bun i".to_string(), + _ => "npm i".to_string(), + } + } +} + +fn get_pkg_manager_name(dir: &Path) -> Option { + let pkg_path = dir.join("package.json"); + let content = std::fs::read_to_string(pkg_path).ok()?; + let pkg: serde_json::Value = serde_json::from_str(&content).ok()?; + + // Check packageManager field + if let Some(pm) = pkg.get("packageManager").and_then(|v| v.as_str()) { + let name = pm.trim_start_matches('^').split('@').next()?; + return Some(name.to_string()); + } + + // Check devEngines.packageManager.name + if let Some(name) = pkg + .get("devEngines") + .and_then(|de| de.get("packageManager")) + .and_then(|pm| pm.get("name")) + .and_then(|n| n.as_str()) + { + return Some(name.to_string()); + } + + None +} + +/// Generate a Dockerfile for a Python configuration. +pub fn python_config_to_docker( + config_path: &Path, + config: &mut Config, + base_image: &str, + api_version: Option<&str>, + escape_variables: bool, +) -> Result<(String, IndexMap), String> { + let build_tools_to_uninstall = get_build_tools_to_uninstall(config); + + let pip_installer_raw = config + .pip_installer + .as_deref() + .unwrap_or("auto") + .to_string(); + let pip_installer: String = if pip_installer_raw == "auto" { + if image_supports_uv(base_image) { + "uv".to_string() + } else { + "pip".to_string() + } + } else { + pip_installer_raw + }; + + let install_cmd = if pip_installer == "uv" { + "uv pip install --system" + } else { + "pip install" + }; + + // Configure pip + let local_reqs_pip_install = { + let base = format!("PYTHONDONTWRITEBYTECODE=1 {install_cmd} --no-cache-dir -c /api/constraints.txt"); + if config.pip_config_file.is_some() { + format!("PIP_CONFIG_FILE=/pipconfig.txt {base}") + } else { + base + } + }; + let global_reqs_pip_install = { + let base = format!("PYTHONDONTWRITEBYTECODE=1 {install_cmd} --no-cache-dir -c /api/constraints.txt"); + if config.pip_config_file.is_some() { + format!("PIP_CONFIG_FILE=/pipconfig.txt {base}") + } else { + base + } + }; + + let pip_config_file_str = config + .pip_config_file + .as_ref() + .map(|f| format!("ADD {f} /pipconfig.txt")) + .unwrap_or_default(); + + // Collect dependencies (owned to avoid borrowing config across mutable calls) + let pypi_deps: Vec = config + .dependencies + .iter() + .filter(|dep| !dep.starts_with('.')) + .cloned() + .collect(); + + let local_deps = assemble_local_deps(config_path, config)?; + + // Rewrite paths + update_graph_paths(config_path, config, &local_deps)?; + update_auth_path(config_path, config, &local_deps)?; + update_encryption_path(config_path, config, &local_deps)?; + update_http_app_path(config_path, config, &local_deps)?; + + let pip_pkgs_str = if !pypi_deps.is_empty() { + format!("RUN {local_reqs_pip_install} {}", pypi_deps.join(" ")) + } else { + String::new() + }; + + // Build pip requirements string + let pip_reqs_str = build_pip_reqs_str(&local_deps, config_path, &local_reqs_pip_install); + + // Build faux packages string + let faux_pkgs_str = build_faux_pkgs_str(&local_deps, config_path); + + // Build local packages string + let local_pkgs_str = build_local_pkgs_str(&local_deps, config_path); + + // Install Node.js if needed + let install_node_str = if (config.ui.is_some() || config.node_version.is_some()) + && local_deps.working_dir.is_some() + { + "RUN /storage/install-node.sh".to_string() + } else { + String::new() + }; + + // Combine install sections + let installs: Vec<&str> = [ + &install_node_str, + &pip_config_file_str, + &pip_pkgs_str, + &pip_reqs_str, + &local_pkgs_str, + &faux_pkgs_str, + ] + .iter() + .filter(|s| !s.is_empty()) + .map(|s| s.as_str()) + .collect(); + let installs = installs.join("\n\n"); + + // Environment variables + let mut env_vars = Vec::new(); + + if let Some(ref store) = config.store { + env_vars.push(format!( + "ENV LANGGRAPH_STORE='{}'", + serde_json::to_string(store).unwrap() + )); + } + if let Some(ref auth) = config.auth { + env_vars.push(format!( + "ENV LANGGRAPH_AUTH='{}'", + serde_json::to_string(auth).unwrap() + )); + } + if let Some(ref encryption) = config.encryption { + env_vars.push(format!( + "ENV LANGGRAPH_ENCRYPTION='{}'", + serde_json::to_string(encryption).unwrap() + )); + } + if let Some(ref http) = config.http { + env_vars.push(format!( + "ENV LANGGRAPH_HTTP='{}'", + serde_json::to_string(http).unwrap() + )); + } + if let Some(ref webhooks) = config.webhooks { + env_vars.push(format!( + "ENV LANGGRAPH_WEBHOOKS='{}'", + serde_json::to_string(webhooks).unwrap() + )); + } + if let Some(ref checkpointer) = config.checkpointer { + env_vars.push(format!( + "ENV LANGGRAPH_CHECKPOINTER='{}'", + serde_json::to_string(checkpointer).unwrap() + )); + } + if let Some(ref ui) = config.ui { + env_vars.push(format!( + "ENV LANGGRAPH_UI='{}'", + serde_json::to_string(ui).unwrap() + )); + } + if let Some(ref ui_config) = config.ui_config { + env_vars.push(format!( + "ENV LANGGRAPH_UI_CONFIG='{}'", + serde_json::to_string(ui_config).unwrap() + )); + } + env_vars.push(format!( + "ENV LANGSERVE_GRAPHS='{}'", + serde_json::to_string(&config.graphs).unwrap() + )); + + // JS install + let js_inst_str = + if (config.ui.is_some() || config.node_version.is_some()) && local_deps.working_dir.is_some() + { + let default_node_ver = DEFAULT_NODE_VERSION.to_string(); + let node_version = config + .node_version + .as_deref() + .unwrap_or(&default_node_ver); + let install_cmd_node = get_node_pm_install_cmd(config_path, config); + let wd = local_deps.working_dir.as_ref().unwrap(); + format!( + "# -- Installing JS dependencies --\n\ + ENV NODE_VERSION={node_version}\n\ + RUN cd {wd} && {install_cmd_node} && tsx /api/langgraph_api/js/build.mts\n\ + # -- End of JS dependencies install --" + ) + } else { + String::new() + }; + + let image_str = docker_tag(config, Some(base_image), api_version); + + // Build Dockerfile + let mut docker_file_contents = Vec::new(); + + // Syntax directive for additional contexts + if !local_deps.additional_contexts.is_empty() { + docker_file_contents.push("# syntax=docker/dockerfile:1.4".to_string()); + docker_file_contents.push(String::new()); + } + + let dep_vname = if escape_variables { "$$dep" } else { "$dep" }; + + docker_file_contents.extend_from_slice(&[ + format!("FROM {image_str}"), + String::new(), + config.dockerfile_lines.join("\n"), + String::new(), + installs, + String::new(), + "# -- Installing all local dependencies --".to_string(), + format!( + "RUN for dep in /deps/*; do \\\n\ + {0} echo \"Installing {dep_vname}\"; \\\n\ + {0} if [ -d \"{dep_vname}\" ]; then \\\n\ + {0} echo \"Installing {dep_vname}\"; \\\n\ + {0} (cd \"{dep_vname}\" && {global_reqs_pip_install} -e .); \\\n\ + {0} fi; \\\n\ + {0}done", + " " + ), + "# -- End of local dependencies install --".to_string(), + env_vars.join("\n"), + String::new(), + js_inst_str, + String::new(), + get_pip_cleanup_lines(install_cmd, &build_tools_to_uninstall, &pip_installer), + String::new(), + local_deps + .working_dir + .as_ref() + .map(|wd| format!("WORKDIR {wd}")) + .unwrap_or_default(), + ]); + + // Build additional contexts map + let mut additional_contexts = IndexMap::new(); + for p in &local_deps.additional_contexts { + if let Some((_, name)) = local_deps.real_pkgs.get(p) { + additional_contexts.insert(name.clone(), p.to_string_lossy().to_string()); + } else if local_deps.faux_pkgs.contains_key(p) { + let name = format!("outer-{}", p.file_name().unwrap().to_string_lossy()); + additional_contexts.insert(name, p.to_string_lossy().to_string()); + } + } + + Ok((docker_file_contents.join("\n"), additional_contexts)) +} + +/// Generate a Dockerfile for a Node.js configuration. +pub fn node_config_to_docker( + config_path: &Path, + config: &mut Config, + base_image: &str, + api_version: Option<&str>, + install_command: Option<&str>, + build_command: Option<&str>, + build_context: Option<&str>, +) -> Result<(String, IndexMap), String> { + let (faux_path, container_root) = if let Some(bc) = build_context { + let relative_workdir = calculate_relative_workdir(config_path, bc)?; + let container_name = Path::new(bc) + .file_name() + .unwrap() + .to_string_lossy() + .to_string(); + let fp = if relative_workdir.is_empty() { + format!("/deps/{container_name}") + } else { + format!("/deps/{container_name}/{relative_workdir}") + }; + (fp, Some(format!("/deps/{container_name}"))) + } else { + let config_dir_name = config_path + .parent() + .unwrap() + .file_name() + .unwrap() + .to_string_lossy() + .to_string(); + (format!("/deps/{config_dir_name}"), None) + }; + + let install_cmd = install_command + .map(|s| s.to_string()) + .unwrap_or_else(|| get_node_pm_install_cmd(config_path, config)); + + let image_str = docker_tag(config, Some(base_image), api_version); + + // Environment variables + let mut env_vars = Vec::new(); + if let Some(ref store) = config.store { + env_vars.push(format!( + "ENV LANGGRAPH_STORE='{}'", + serde_json::to_string(store).unwrap() + )); + } + if let Some(ref auth) = config.auth { + env_vars.push(format!( + "ENV LANGGRAPH_AUTH='{}'", + serde_json::to_string(auth).unwrap() + )); + } + if let Some(ref encryption) = config.encryption { + env_vars.push(format!( + "ENV LANGGRAPH_ENCRYPTION='{}'", + serde_json::to_string(encryption).unwrap() + )); + } + if let Some(ref http) = config.http { + env_vars.push(format!( + "ENV LANGGRAPH_HTTP='{}'", + serde_json::to_string(http).unwrap() + )); + } + if let Some(ref webhooks) = config.webhooks { + env_vars.push(format!( + "ENV LANGGRAPH_WEBHOOKS='{}'", + serde_json::to_string(webhooks).unwrap() + )); + } + if let Some(ref checkpointer) = config.checkpointer { + env_vars.push(format!( + "ENV LANGGRAPH_CHECKPOINTER='{}'", + serde_json::to_string(checkpointer).unwrap() + )); + } + if let Some(ref ui) = config.ui { + env_vars.push(format!( + "ENV LANGGRAPH_UI='{}'", + serde_json::to_string(ui).unwrap() + )); + } + if let Some(ref ui_config) = config.ui_config { + env_vars.push(format!( + "ENV LANGGRAPH_UI_CONFIG='{}'", + serde_json::to_string(ui_config).unwrap() + )); + } + env_vars.push(format!( + "ENV LANGSERVE_GRAPHS='{}'", + serde_json::to_string(&config.graphs).unwrap() + )); + + let (install_step, build_step) = if let Some(_bc) = build_context { + let cr = container_root.as_ref().unwrap(); + let inst = format!("RUN cd {cr} && {install_cmd}"); + let build = if let Some(bc) = build_command { + format!("RUN cd {faux_path} && {bc}") + } else { + "RUN (test ! -f /api/langgraph_api/js/build.mts && echo \"Prebuild script not found, skipping\") || tsx /api/langgraph_api/js/build.mts".to_string() + }; + (inst, build) + } else { + let inst = format!("RUN cd {faux_path} && {install_cmd}"); + let build = "RUN (test ! -f /api/langgraph_api/js/build.mts && echo \"Prebuild script not found, skipping\") || tsx /api/langgraph_api/js/build.mts".to_string(); + (inst, build) + }; + + let add_target = if build_context.is_some() { + container_root.as_ref().unwrap().clone() + } else { + faux_path.clone() + }; + + let docker_file_contents = vec![ + format!("FROM {image_str}"), + String::new(), + config.dockerfile_lines.join("\n"), + String::new(), + format!("ADD . {add_target}"), + String::new(), + install_step, + String::new(), + env_vars.join("\n"), + String::new(), + format!("WORKDIR {faux_path}"), + String::new(), + build_step, + ]; + + Ok((docker_file_contents.join("\n"), IndexMap::new())) +} + +/// Main entry point for Dockerfile generation. +pub fn config_to_docker( + config_path: &Path, + config: &mut Config, + base_image: Option<&str>, + api_version: Option<&str>, + install_command: Option<&str>, + build_command: Option<&str>, + build_context: Option<&str>, + escape_variables: bool, +) -> Result<(String, IndexMap), String> { + let base_image = base_image + .map(|s| s.to_string()) + .unwrap_or_else(|| crate::config::docker_tag::default_base_image(config)); + + if config.node_version.is_some() && config.python_version.is_none() { + node_config_to_docker( + config_path, + config, + &base_image, + api_version, + install_command, + build_command, + build_context, + ) + } else { + python_config_to_docker(config_path, config, &base_image, api_version, escape_variables) + } +} + +fn calculate_relative_workdir(config_path: &Path, build_context: &str) -> Result { + let config_dir = config_path.parent().unwrap().canonicalize().map_err(|e| { + format!( + "Could not resolve config directory: {e}" + ) + })?; + let build_context_path = Path::new(build_context).canonicalize().map_err(|e| { + format!("Could not resolve build context: {e}") + })?; + + config_dir + .strip_prefix(&build_context_path) + .map(|p| { + let s = p.to_string_lossy().to_string(); + if s == "." { String::new() } else { s } + }) + .map_err(|_| { + format!( + "Configuration file {} is not under the build context {}. \ + Please run the command from a directory that contains your langgraph.json file.", + config_path.display(), + build_context + ) + }) +} + +// Helper functions to build Dockerfile sections + +fn build_pip_reqs_str(local_deps: &LocalDeps, config_path: &Path, pip_install: &str) -> String { + if local_deps.pip_reqs.is_empty() { + return String::new(); + } + + let config_parent = config_path.parent().unwrap(); + let mut lines = Vec::new(); + + for (reqpath, destpath) in &local_deps.pip_reqs { + if local_deps + .additional_contexts + .iter() + .any(|ac| reqpath.starts_with(ac)) + { + let name = reqpath.parent().unwrap().file_name().unwrap().to_string_lossy(); + lines.push(format!( + "COPY --from=outer-{name} requirements.txt {destpath}" + )); + } else if let Ok(rel) = reqpath.strip_prefix(config_parent) { + lines.push(format!( + "ADD {} {destpath}", + rel.to_string_lossy().replace('\\', "/") + )); + } + } + + let req_args: Vec = local_deps + .pip_reqs + .iter() + .map(|(_, dest)| format!("-r {dest}")) + .collect(); + lines.push(format!("RUN {pip_install} {}", req_args.join(" "))); + + format!( + "# -- Installing local requirements --\n{}\n# -- End of local requirements install --", + lines.join("\n") + ) +} + +fn build_faux_pkgs_str(local_deps: &LocalDeps, config_path: &Path) -> String { + if local_deps.faux_pkgs.is_empty() { + return String::new(); + } + + let _config_parent = config_path.parent().unwrap(); + let mut sections = Vec::new(); + + for (fullpath, (relpath, destpath)) in &local_deps.faux_pkgs { + let name = fullpath.file_name().unwrap().to_string_lossy(); + + let add_line = if local_deps.additional_contexts.contains(fullpath) { + format!( + "# -- Adding non-package dependency {name} --\n\ + COPY --from=outer-{name} . {destpath}" + ) + } else { + format!( + "# -- Adding non-package dependency {name} --\n\ + ADD {relpath} {destpath}" + ) + }; + + let section = format!( + "{add_line}\n\ + RUN set -ex && \\\\\n\ + {s}for line in '[project]' \\\\\n\ + {s} 'name = \"{name}\"' \\\\\n\ + {s} 'version = \"0.1\"' \\\\\n\ + {s} '[tool.setuptools.package-data]' \\\\\n\ + {s} '\"*\" = [\"**/*\"]' \\\\\n\ + {s} '[build-system]' \\\\\n\ + {s} 'requires = [\"setuptools>=61\"]' \\\\\n\ + {s} 'build-backend = \"setuptools.build_meta\"'; do \\\\\n\ + {s} echo \"$line\" >> /deps/outer-{name}/pyproject.toml; \\\\\n\ + {s}done\n\ + # -- End of non-package dependency {name} --", + s = " " + ); + sections.push(section); + } + + sections.join("\n\n") +} + +fn build_local_pkgs_str(local_deps: &LocalDeps, config_path: &Path) -> String { + if local_deps.real_pkgs.is_empty() { + return String::new(); + } + + let _config_parent = config_path.parent().unwrap(); + let mut lines = Vec::new(); + + for (fullpath, (relpath, name)) in &local_deps.real_pkgs { + if local_deps.additional_contexts.contains(fullpath) { + lines.push(format!( + "# -- Adding local package {relpath} --\n\ + COPY --from={name} . /deps/{name}\n\ + # -- End of local package {relpath} --" + )); + } else { + lines.push(format!( + "# -- Adding local package {relpath} --\n\ + ADD {relpath} /deps/{name}\n\ + # -- End of local package {relpath} --" + )); + } + } + + lines.join("\n") +} + +/// Generate compose YAML fragment for config (used by the up command). +pub fn config_to_compose( + config_path: &Path, + config: &mut Config, + base_image: Option<&str>, + api_version: Option<&str>, + image: Option<&str>, + watch: bool, +) -> Result { + let base_image = base_image + .map(|s| s.to_string()) + .unwrap_or_else(|| crate::config::docker_tag::default_base_image(config)); + + let env_vars_str = match &config.env { + Some(crate::config::schema::EnvConfig::Dict(d)) => d + .iter() + .map(|(k, v)| format!(" {k}: \"{v}\"")) + .collect::>() + .join("\n"), + _ => String::new(), + }; + + let env_file_str = match &config.env { + Some(crate::config::schema::EnvConfig::File(f)) => format!("env_file: {f}"), + _ => String::new(), + }; + + let watch_str = if watch { + let dependencies = if config.dependencies.is_empty() { + vec![".".to_string()] + } else { + config.dependencies.clone() + }; + let config_name = config_path + .file_name() + .unwrap() + .to_string_lossy() + .to_string(); + let mut watch_paths = vec![config_name]; + for dep in &dependencies { + if dep.starts_with('.') { + watch_paths.push(dep.clone()); + } + } + let watch_actions: Vec = watch_paths + .iter() + .map(|p| format!(" - path: {p}\n action: rebuild")) + .collect(); + format!( + "\n develop:\n watch:\n{}\n", + watch_actions.join("\n") + ) + } else { + String::new() + }; + + if image.is_some() { + return Ok(format!( + "\n{env_vars_str}\n {env_file_str}\n {watch_str}\n" + )); + } + + let (dockerfile, additional_contexts) = config_to_docker( + config_path, + config, + Some(&base_image), + api_version, + None, + None, + None, + true, // escape_variables + )?; + + let additional_contexts_str = if !additional_contexts.is_empty() { + let lines: Vec = additional_contexts + .iter() + .map(|(name, path)| format!(" - {name}: {path}")) + .collect(); + format!( + "\n additional_contexts:\n{}", + lines.join("\n") + ) + } else { + String::new() + }; + + // Indent dockerfile for inline use + let indented_dockerfile = dockerfile + .lines() + .map(|line| format!(" {line}")) + .collect::>() + .join("\n"); + + Ok(format!( + "\n{env_vars_str}\n {env_file_str}\n pull_policy: build\n build:\n context: .{additional_contexts_str}\n dockerfile_inline: |\n{indented_dockerfile}\n {watch_str}\n" + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::validate_config; + use crate::config::schema::Config; + use crate::util::clean_empty_lines; + use serde_json::json; + + /// Helper: get the path to the test config file. + fn config_path() -> std::path::PathBuf { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + std::path::Path::new(manifest_dir).join("tests/unit_tests/test_config.json") + } + + /// Helper: build a validated Config from a JSON value. + fn make_config(val: serde_json::Value) -> Config { + let config: Config = serde_json::from_value(val).expect("Failed to deserialize config"); + validate_config(config).expect("Failed to validate config") + } + + // -- test_config_to_docker_nodejs -- + #[test] + fn test_config_to_docker_nodejs() { + let cp = config_path(); + let mut config = make_config(json!({ + "node_version": "20", + "graphs": {"agent": "./graphs/agent.js:graph"}, + "dockerfile_lines": ["ARG meow", "ARG foo"], + "auth": {"path": "./graphs/auth.mts:auth"}, + "ui": {"agent": "./graphs/agent.ui.jsx"}, + "ui_config": {"shared": ["nuqs"]} + })); + let (actual, additional_contexts) = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraphjs-api"), + None, + None, + None, + None, + false, + ) + .expect("config_to_docker failed"); + + let expected = "\ +FROM langchain/langgraphjs-api:20 +ARG meow +ARG foo +ADD . /deps/unit_tests +RUN cd /deps/unit_tests && npm i +ENV LANGGRAPH_AUTH='{\"path\":\"./graphs/auth.mts:auth\"}' +ENV LANGGRAPH_UI='{\"agent\":\"./graphs/agent.ui.jsx\"}' +ENV LANGGRAPH_UI_CONFIG='{\"shared\":[\"nuqs\"]}' +ENV LANGSERVE_GRAPHS='{\"agent\":\"./graphs/agent.js:graph\"}' +WORKDIR /deps/unit_tests +RUN (test ! -f /api/langgraph_api/js/build.mts && echo \"Prebuild script not found, skipping\") || tsx /api/langgraph_api/js/build.mts"; + + assert_eq!(clean_empty_lines(&actual), expected); + assert!(additional_contexts.is_empty()); + } + + // -- test_config_to_docker_nodejs_internal_docker_tag -- + #[test] + fn test_config_to_docker_nodejs_internal_docker_tag() { + let cp = config_path(); + let mut config = make_config(json!({ + "node_version": "20", + "graphs": {"agent": "./graphs/agent.js:graph"}, + "dockerfile_lines": ["ARG meow", "ARG foo"], + "auth": {"path": "./graphs/auth.mts:auth"}, + "ui": {"agent": "./graphs/agent.ui.jsx"}, + "ui_config": {"shared": ["nuqs"]}, + "_INTERNAL_docker_tag": "my-tag" + })); + let (actual, additional_contexts) = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraphjs-api"), + None, + None, + None, + None, + false, + ) + .expect("config_to_docker failed"); + + let expected = "\ +FROM langchain/langgraphjs-api:my-tag +ARG meow +ARG foo +ADD . /deps/unit_tests +RUN cd /deps/unit_tests && npm i +ENV LANGGRAPH_AUTH='{\"path\":\"./graphs/auth.mts:auth\"}' +ENV LANGGRAPH_UI='{\"agent\":\"./graphs/agent.ui.jsx\"}' +ENV LANGGRAPH_UI_CONFIG='{\"shared\":[\"nuqs\"]}' +ENV LANGSERVE_GRAPHS='{\"agent\":\"./graphs/agent.js:graph\"}' +WORKDIR /deps/unit_tests +RUN (test ! -f /api/langgraph_api/js/build.mts && echo \"Prebuild script not found, skipping\") || tsx /api/langgraph_api/js/build.mts"; + + assert_eq!(clean_empty_lines(&actual), expected); + assert!(additional_contexts.is_empty()); + } + + // -- test_config_to_docker_invalid_inputs -- + #[test] + fn test_config_to_docker_invalid_missing_dependency() { + let cp = config_path(); + let mut config = make_config(json!({ + "dependencies": ["./missing"], + "graphs": {"agent": "tests/unit_tests/agent.py:graph"} + })); + let result = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraph-api"), + None, + None, + None, + None, + false, + ); + assert!(result.is_err(), "Expected error for missing dependency"); + let err = result.unwrap_err(); + assert!( + err.contains("Could not find local dependency"), + "Error should mention missing dependency, got: {err}" + ); + } + + #[test] + fn test_config_to_docker_invalid_missing_module() { + let cp = config_path(); + let mut config = make_config(json!({ + "dependencies": ["."], + "graphs": {"agent": "./missing_agent.py:graph"} + })); + let result = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraph-api"), + None, + None, + None, + None, + false, + ); + assert!(result.is_err(), "Expected error for missing module"); + let err = result.unwrap_err(); + assert!( + err.contains("Could not find local module"), + "Error should mention missing module, got: {err}" + ); + } + + // -- test_config_to_docker_python_encryption -- + #[test] + fn test_config_to_docker_python_encryption() { + let config = make_config(json!({ + "python_version": "3.11", + "graphs": {"agent": "./agent.py:graph"}, + "dependencies": ["."], + "encryption": {"path": "./encryption.py:encryption"} + })); + assert!(config.encryption.is_some()); + assert_eq!( + config.encryption.as_ref().unwrap().path.as_deref(), + Some("./encryption.py:encryption") + ); + } + + // -- test_config_to_docker_python_encryption_bad_path -- + #[test] + fn test_config_to_docker_python_encryption_bad_path() { + let raw_config: Config = serde_json::from_value(json!({ + "python_version": "3.11", + "graphs": {"agent": "./agent.py:graph"}, + "dependencies": ["."], + "encryption": {"path": "./encryption.py"} + })) + .expect("Failed to deserialize config"); + + let result = validate_config(raw_config); + assert!(result.is_err(), "Expected error for bad encryption path"); + let err = result.unwrap_err(); + assert!( + err.contains("Invalid encryption.path format"), + "Error should mention invalid encryption.path format, got: {err}" + ); + } + + // -- test_config_to_docker_no_webhooks -- + #[test] + fn test_config_to_docker_no_webhooks() { + let cp = config_path(); + let mut config = make_config(json!({ + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"} + })); + let (dockerfile, _) = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraph-api"), + None, + None, + None, + None, + false, + ) + .expect("config_to_docker failed"); + + assert!( + !dockerfile.contains("ENV LANGGRAPH_WEBHOOKS="), + "Dockerfile should not contain LANGGRAPH_WEBHOOKS when no webhooks configured" + ); + } + + // -- test_config_to_docker_with_api_version -- + #[test] + fn test_config_to_docker_with_api_version_python() { + let cp = config_path(); + let mut config = make_config(json!({ + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"} + })); + let (actual, _) = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraph-api"), + Some("0.2.74"), + None, + None, + None, + false, + ) + .expect("config_to_docker failed"); + + let first_line = actual.lines().next().unwrap(); + assert_eq!( + first_line, + "FROM langchain/langgraph-api:0.2.74-py3.11", + "Python config with api_version should use versioned FROM" + ); + } + + #[test] + fn test_config_to_docker_with_api_version_nodejs() { + let cp = config_path(); + let mut config = make_config(json!({ + "node_version": "20", + "graphs": {"agent": "./graphs/agent.js:graph"} + })); + let (actual, _) = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraphjs-api"), + Some("0.2.74"), + None, + None, + None, + false, + ) + .expect("config_to_docker failed"); + + let first_line = actual.lines().next().unwrap(); + assert_eq!( + first_line, + "FROM langchain/langgraphjs-api:0.2.74-node20", + "Node.js config with api_version should use versioned FROM" + ); + } + + // -- test_config_to_docker_pip_installer -- + #[test] + fn test_config_to_docker_pip_installer_auto_uv() { + // auto + UV-supporting image (0.2.47) -> uv pip install + let cp = config_path(); + let mut config = make_config(json!({ + "python_version": "3.11", + "dependencies": ["."], + "graphs": {"agent": "./graphs/agent.py:graph"}, + "pip_installer": "auto" + })); + let (docker, _) = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraph-api:0.2.47"), + None, + None, + None, + None, + false, + ) + .expect("config_to_docker failed"); + + assert!( + docker.contains("uv pip install --system"), + "auto + UV image should use uv pip install" + ); + assert!( + docker.contains("rm /usr/bin/uv /usr/bin/uvx"), + "auto + UV image should remove uv after install" + ); + } + + #[test] + fn test_config_to_docker_pip_installer_explicit_pip() { + // explicit pip + UV-supporting image -> pip install (not uv) + let cp = config_path(); + let mut config = make_config(json!({ + "python_version": "3.11", + "dependencies": ["."], + "graphs": {"agent": "./graphs/agent.py:graph"}, + "pip_installer": "pip" + })); + let (docker, _) = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraph-api:0.2.47"), + None, + None, + None, + None, + false, + ) + .expect("config_to_docker failed"); + + assert!( + !docker.contains("uv pip install --system"), + "explicit pip should not use uv" + ); + assert!( + docker.contains("pip install"), + "explicit pip should use pip install" + ); + assert!( + !docker.contains("rm /usr/bin/uv"), + "explicit pip should not remove uv" + ); + } + + #[test] + fn test_config_to_docker_pip_installer_explicit_uv() { + // explicit uv + UV-supporting image -> uv pip install + let cp = config_path(); + let mut config = make_config(json!({ + "python_version": "3.11", + "dependencies": ["."], + "graphs": {"agent": "./graphs/agent.py:graph"}, + "pip_installer": "uv" + })); + let (docker, _) = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraph-api:0.2.47"), + None, + None, + None, + None, + false, + ) + .expect("config_to_docker failed"); + + assert!( + docker.contains("uv pip install --system"), + "explicit uv should use uv pip install" + ); + assert!( + docker.contains("rm /usr/bin/uv /usr/bin/uvx"), + "explicit uv should remove uv after install" + ); + } + + #[test] + fn test_config_to_docker_pip_installer_auto_old_image() { + // auto + older image (0.2.46) -> pip install (not uv) + let cp = config_path(); + let mut config = make_config(json!({ + "python_version": "3.11", + "dependencies": ["."], + "graphs": {"agent": "./graphs/agent.py:graph"}, + "pip_installer": "auto" + })); + let (docker, _) = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraph-api:0.2.46"), + None, + None, + None, + None, + false, + ) + .expect("config_to_docker failed"); + + assert!( + !docker.contains("uv pip install --system"), + "auto + old image should not use uv" + ); + assert!( + docker.contains("pip install"), + "auto + old image should use pip install" + ); + assert!( + !docker.contains("rm /usr/bin/uv"), + "auto + old image should not remove uv" + ); + } + + #[test] + fn test_config_to_docker_pip_installer_default_auto() { + // missing pip_installer defaults to auto -> uv on new image + let cp = config_path(); + let mut config = make_config(json!({ + "python_version": "3.11", + "dependencies": ["."], + "graphs": {"agent": "./graphs/agent.py:graph"} + })); + let (docker, _) = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraph-api:0.2.47"), + None, + None, + None, + None, + false, + ) + .expect("config_to_docker failed"); + + assert!( + docker.contains("uv pip install --system"), + "default (auto) + new image should use uv pip install" + ); + } + + // -- test_config_retain_build_tools -- + #[test] + fn test_config_retain_build_tools_keep_all() { + // keep_pkg_tools=true -> no pip uninstall, no rm of build tools + let cp = config_path(); + let mut config = make_config(json!({ + "python_version": "3.11", + "dependencies": ["."], + "graphs": {"agent": "./graphs/agent.py:graph"}, + "keep_pkg_tools": true + })); + let (docker, _) = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraph-api:0.2.47"), + None, + None, + None, + None, + false, + ) + .expect("config_to_docker failed"); + + for tool in crate::constants::BUILD_TOOLS { + let pattern = format!("/usr/local/lib/python*/site-packages/{tool}*"); + assert!( + !docker.contains(&pattern), + "keep_pkg_tools=true should not contain rm of {tool}" + ); + } + assert!( + !docker.contains("RUN pip uninstall -y pip setuptools wheel"), + "keep_pkg_tools=true should not uninstall build tools" + ); + } + + #[test] + fn test_config_retain_build_tools_keep_none() { + // keep_pkg_tools=false -> pip uninstall all build tools, rm all + let cp = config_path(); + let mut config = make_config(json!({ + "python_version": "3.11", + "dependencies": ["."], + "graphs": {"agent": "./graphs/agent.py:graph"}, + "keep_pkg_tools": false + })); + let (docker, _) = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraph-api:0.2.47"), + None, + None, + None, + None, + false, + ) + .expect("config_to_docker failed"); + + for tool in crate::constants::BUILD_TOOLS { + let pattern = format!("/usr/local/lib/python*/site-packages/{tool}*"); + assert!( + docker.contains(&pattern), + "keep_pkg_tools=false should contain rm of {tool}" + ); + } + assert!( + docker.contains("RUN pip uninstall -y pip setuptools wheel"), + "keep_pkg_tools=false should uninstall all build tools" + ); + } + + #[test] + fn test_config_retain_build_tools_keep_some() { + // keep_pkg_tools=["pip", "setuptools"] -> uninstall only "wheel", rm only wheel + let cp = config_path(); + let mut config = make_config(json!({ + "python_version": "3.11", + "dependencies": ["."], + "graphs": {"agent": "./graphs/agent.py:graph"}, + "keep_pkg_tools": ["pip", "setuptools"] + })); + let (docker, _) = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraph-api:0.2.47"), + None, + None, + None, + None, + false, + ) + .expect("config_to_docker failed"); + + // wheel should be removed + assert!( + docker.contains("/usr/local/lib/python*/site-packages/wheel*"), + "Should contain rm of wheel" + ); + // pip and setuptools should NOT be removed + assert!( + !docker.contains("/usr/local/lib/python*/site-packages/pip*"), + "Should not contain rm of pip" + ); + assert!( + !docker.contains("/usr/local/lib/python*/site-packages/setuptools*"), + "Should not contain rm of setuptools" + ); + assert!( + docker.contains("RUN pip uninstall -y wheel"), + "Should uninstall only wheel" + ); + assert!( + !docker.contains("RUN pip uninstall -y pip setuptools"), + "Should not uninstall pip or setuptools" + ); + } + + // -- test_config_to_docker_local_deps -- + #[test] + fn test_config_to_docker_local_deps() { + let cp = config_path(); + let mut config = make_config(json!({ + "dependencies": ["./graphs"], + "graphs": {"agent": "./graphs/agent.py:graph"} + })); + let (actual, additional_contexts) = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraph-api-custom"), + None, + None, + None, + None, + false, + ) + .expect("config_to_docker failed"); + + let cleaned = clean_empty_lines(&actual); + + // Verify FROM line + assert!( + cleaned.starts_with("FROM langchain/langgraph-api-custom:3.11"), + "Should start with custom base image and python version" + ); + + // Verify faux package for graphs directory + assert!( + cleaned.contains("# -- Adding non-package dependency graphs --"), + "Should add graphs as non-package dependency" + ); + assert!( + cleaned.contains("ADD ./graphs /deps/outer-graphs/src"), + "Should ADD graphs to /deps/outer-graphs/src" + ); + assert!( + cleaned.contains("name = \"graphs\""), + "Should create faux pyproject.toml for graphs" + ); + + // Verify graph path rewritten + assert!( + cleaned.contains("/deps/outer-graphs/src/agent.py:graph"), + "Graph path should be rewritten to container path" + ); + + assert!(additional_contexts.is_empty()); + } + + // -- test_config_to_docker_pipconfig -- + #[test] + fn test_config_to_docker_pipconfig() { + let cp = config_path(); + let mut config = make_config(json!({ + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"}, + "pip_config_file": "pipconfig.txt" + })); + let (actual, additional_contexts) = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraph-api"), + None, + None, + None, + None, + false, + ) + .expect("config_to_docker failed"); + + let cleaned = clean_empty_lines(&actual); + + assert!( + cleaned.contains("ADD pipconfig.txt /pipconfig.txt"), + "Should add pipconfig.txt" + ); + assert!( + cleaned.contains("PIP_CONFIG_FILE=/pipconfig.txt"), + "Should use PIP_CONFIG_FILE in install commands" + ); + + assert!(additional_contexts.is_empty()); + } + + // -- test_config_to_docker_end_to_end -- + #[test] + fn test_config_to_docker_end_to_end() { + let cp = config_path(); + let mut config = make_config(json!({ + "python_version": "3.12", + "dependencies": ["./graphs/", "langchain", "langchain_openai"], + "graphs": {"agent": "./graphs/agent.py:graph"}, + "pip_config_file": "pipconfig.txt", + "dockerfile_lines": ["ARG meow", "ARG foo"] + })); + let (actual, additional_contexts) = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraph-api"), + None, + None, + None, + None, + false, + ) + .expect("config_to_docker failed"); + + let cleaned = clean_empty_lines(&actual); + + // FROM line + assert!( + cleaned.starts_with("FROM langchain/langgraph-api:3.12"), + "Should start with FROM line using python 3.12" + ); + + // Dockerfile lines + assert!(cleaned.contains("ARG meow"), "Should contain dockerfile_lines"); + assert!(cleaned.contains("ARG foo"), "Should contain dockerfile_lines"); + + // pip config + assert!( + cleaned.contains("ADD pipconfig.txt /pipconfig.txt"), + "Should add pipconfig" + ); + + // PyPI deps + assert!( + cleaned.contains("langchain langchain_openai"), + "Should install pypi dependencies" + ); + + // Local graphs dep as faux package + assert!( + cleaned.contains("# -- Adding non-package dependency graphs --"), + "Should add graphs as non-package dependency" + ); + + assert!(additional_contexts.is_empty()); + } + + // -- test_config_to_docker_multiplatform -- + #[test] + fn test_config_to_docker_multiplatform() { + let cp = config_path(); + let mut config = make_config(json!({ + "node_version": "22", + "dependencies": ["."], + "graphs": { + "python": "./multiplatform/python.py:graph", + "js": "./multiplatform/js.mts:graph" + } + })); + let (actual, additional_contexts) = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraph-api"), + None, + None, + None, + None, + false, + ) + .expect("config_to_docker failed"); + + let cleaned = clean_empty_lines(&actual); + + // Should be Python path since python_version is set + assert!( + cleaned.starts_with("FROM langchain/langgraph-api:3.11"), + "Multiplatform should default to Python base image" + ); + + // Should install Node.js + assert!( + cleaned.contains("ENV NODE_VERSION=22"), + "Should set NODE_VERSION=22" + ); + assert!( + cleaned.contains("RUN /storage/install-node.sh"), + "Should install Node.js" + ); + + // Should have JS dependencies install section + assert!( + cleaned.contains("# -- Installing JS dependencies --"), + "Should have JS dependencies install section" + ); + + assert!(additional_contexts.is_empty()); + } + + // -- test_config_to_docker_gen_ui_python -- + #[test] + fn test_config_to_docker_gen_ui_python() { + let cp = config_path(); + let mut config = make_config(json!({ + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"}, + "ui": {"agent": "./graphs/agent.ui.jsx"}, + "ui_config": {"shared": ["nuqs"]} + })); + let (actual, additional_contexts) = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraph-api"), + None, + None, + None, + None, + false, + ) + .expect("config_to_docker failed"); + + let cleaned = clean_empty_lines(&actual); + + // Should install Node.js for UI + assert!( + cleaned.contains("RUN /storage/install-node.sh"), + "Should install Node.js for UI" + ); + + // Should have UI env vars + assert!( + cleaned.contains("ENV LANGGRAPH_UI="), + "Should set LANGGRAPH_UI" + ); + assert!( + cleaned.contains("ENV LANGGRAPH_UI_CONFIG="), + "Should set LANGGRAPH_UI_CONFIG" + ); + + // Should have JS install section + assert!( + cleaned.contains("# -- Installing JS dependencies --"), + "Should have JS dependencies section for UI" + ); + + assert!(additional_contexts.is_empty()); + } + + // -- test_config_to_docker_webhooks -- + #[test] + fn test_config_to_docker_webhooks_python() { + let cp = config_path(); + let webhooks = json!({ + "env_prefix": "LG_WEBHOOK_", + "url": { + "require_https": true, + "allowed_domains": ["hooks.example.com", "*.example.org"], + "allowed_ports": [443], + "max_url_length": 1024, + "disable_loopback": false + }, + "headers": { + "x-auth": "${{ env.LG_WEBHOOK_TOKEN }}", + "x-mixed": "Bearer ${{ env.LG_WEBHOOK_TOKEN }}-suffix" + } + }); + let mut config = make_config(json!({ + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"}, + "webhooks": webhooks + })); + let (dockerfile, _) = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraph-api"), + None, + None, + None, + None, + false, + ) + .expect("config_to_docker failed"); + + assert!( + dockerfile.contains("ENV LANGGRAPH_WEBHOOKS="), + "Dockerfile should contain LANGGRAPH_WEBHOOKS env" + ); + + // Extract and parse the JSON from the ENV line + for line in dockerfile.lines() { + if line.starts_with("ENV LANGGRAPH_WEBHOOKS='") && line.ends_with('\'') { + let json_str = &line["ENV LANGGRAPH_WEBHOOKS='".len()..line.len() - 1]; + let parsed: serde_json::Value = + serde_json::from_str(json_str).expect("Failed to parse webhook JSON"); + assert_eq!( + parsed["env_prefix"], "LG_WEBHOOK_", + "Webhook env_prefix should round-trip" + ); + assert_eq!( + parsed["url"]["require_https"], true, + "Webhook url.require_https should round-trip" + ); + return; + } + } + panic!("LANGGRAPH_WEBHOOKS ENV line not found"); + } + + #[test] + fn test_config_to_docker_webhooks_node() { + let cp = config_path(); + let webhooks = json!({ + "env_prefix": "LG_WEBHOOK_", + "url": {"require_https": true}, + "headers": {"x-auth": "${{ env.LG_WEBHOOK_TOKEN }}"} + }); + let mut config = make_config(json!({ + "node_version": "20", + "graphs": {"agent": "./graphs/agent.js:graph"}, + "webhooks": webhooks + })); + let (dockerfile, _) = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraphjs-api"), + None, + None, + None, + None, + false, + ) + .expect("config_to_docker failed"); + + assert!( + dockerfile.contains("ENV LANGGRAPH_WEBHOOKS="), + "Node.js Dockerfile should contain LANGGRAPH_WEBHOOKS env" + ); + + for line in dockerfile.lines() { + if line.starts_with("ENV LANGGRAPH_WEBHOOKS='") && line.ends_with('\'') { + let json_str = &line["ENV LANGGRAPH_WEBHOOKS='".len()..line.len() - 1]; + let parsed: serde_json::Value = + serde_json::from_str(json_str).expect("Failed to parse webhook JSON"); + assert_eq!(parsed["env_prefix"], "LG_WEBHOOK_"); + return; + } + } + panic!("LANGGRAPH_WEBHOOKS ENV line not found in Node.js Dockerfile"); + } + + // -- test_config_to_docker_python_encryption_formatted -- + #[test] + fn test_config_to_docker_python_encryption_formatted() { + let cp = config_path(); + let mut config = make_config(json!({ + "python_version": "3.11", + "dependencies": ["."], + "graphs": {"agent": "./graphs/agent.py:graph"}, + "encryption": {"path": "./agent.py:my_encryption"} + })); + let (actual, _) = config_to_docker( + &cp, + &mut config, + Some("langchain/langgraph-api"), + None, + None, + None, + None, + false, + ) + .expect("config_to_docker failed"); + + assert!( + actual.contains("LANGGRAPH_ENCRYPTION="), + "Dockerfile should contain LANGGRAPH_ENCRYPTION" + ); + assert!( + actual.contains("/deps/outer-unit_tests/unit_tests/agent.py:my_encryption"), + "Encryption path should be rewritten to container path" + ); + } + + // -- test_config_to_compose -- + #[test] + fn test_config_to_compose_simple() { + let cp = config_path(); + let mut config = make_config(json!({ + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"} + })); + let actual = config_to_compose( + &cp, + &mut config, + Some("langchain/langgraph-api"), + None, + None, + false, + ) + .expect("config_to_compose failed"); + + let cleaned = clean_empty_lines(&actual); + + assert!( + cleaned.contains("pull_policy: build"), + "Compose should contain pull_policy: build" + ); + assert!( + cleaned.contains("context: ."), + "Compose should contain context: ." + ); + assert!( + cleaned.contains("dockerfile_inline: |"), + "Compose should contain dockerfile_inline" + ); + assert!( + cleaned.contains("FROM langchain/langgraph-api:3.11"), + "Compose should contain the FROM line" + ); + // Escaped $$ for compose + assert!( + cleaned.contains("$$dep"), + "Compose should escape variables with $$" + ); + } + + #[test] + fn test_config_to_compose_env_vars() { + let cp = config_path(); + let mut config = make_config(json!({ + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"}, + "env": {"OPENAI_API_KEY": "key"} + })); + let actual = config_to_compose( + &cp, + &mut config, + Some("langchain/langgraph-api-custom"), + None, + None, + false, + ) + .expect("config_to_compose failed"); + + assert!( + actual.contains("OPENAI_API_KEY: \"key\""), + "Compose should contain environment variable" + ); + } + + #[test] + fn test_config_to_compose_env_file() { + let cp = config_path(); + let mut config = make_config(json!({ + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"}, + "env": ".env" + })); + let actual = config_to_compose( + &cp, + &mut config, + Some("langchain/langgraph-api"), + None, + None, + false, + ) + .expect("config_to_compose failed"); + + assert!( + actual.contains("env_file: .env"), + "Compose should contain env_file: .env" + ); + } + + #[test] + fn test_config_to_compose_watch() { + let cp = config_path(); + let mut config = make_config(json!({ + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"} + })); + let actual = config_to_compose( + &cp, + &mut config, + Some("langchain/langgraph-api"), + None, + None, + true, + ) + .expect("config_to_compose failed"); + + let cleaned = clean_empty_lines(&actual); + + assert!( + cleaned.contains("develop:"), + "Compose with watch should contain develop:" + ); + assert!( + cleaned.contains("watch:"), + "Compose with watch should contain watch:" + ); + assert!( + cleaned.contains("path: test_config.json"), + "Watch should include config file" + ); + assert!( + cleaned.contains("path: ."), + "Watch should include dependency path" + ); + assert!( + cleaned.contains("action: rebuild"), + "Watch action should be rebuild" + ); + } + + #[test] + fn test_config_to_compose_with_api_version() { + let cp = config_path(); + // Python + let mut config = make_config(json!({ + "dependencies": ["."], + "graphs": {"agent": "./agent.py:graph"} + })); + let actual = config_to_compose( + &cp, + &mut config, + Some("langchain/langgraph-api"), + Some("0.2.74"), + None, + false, + ) + .expect("config_to_compose failed"); + assert!( + actual.contains("FROM langchain/langgraph-api:0.2.74-py3.11"), + "Python compose should contain versioned FROM" + ); + + // Node.js + let mut config = make_config(json!({ + "node_version": "20", + "graphs": {"agent": "./graphs/agent.js:graph"} + })); + let actual = config_to_compose( + &cp, + &mut config, + Some("langchain/langgraphjs-api"), + Some("0.2.74"), + None, + false, + ) + .expect("config_to_compose failed"); + assert!( + actual.contains("FROM langchain/langgraphjs-api:0.2.74-node20"), + "Node.js compose should contain versioned FROM" + ); + } + + // -- test helpers: image_supports_uv -- + #[test] + fn test_image_supports_uv() { + assert!(image_supports_uv("langchain/langgraph-api:0.2.47")); + assert!(image_supports_uv("langchain/langgraph-api:0.2.48")); + assert!(image_supports_uv("langchain/langgraph-api:1.0.0")); + assert!(!image_supports_uv("langchain/langgraph-api:0.2.46")); + assert!(!image_supports_uv("langchain/langgraph-api:0.2.0")); + assert!(!image_supports_uv("langchain/langgraph-trial")); + // No version tag -> supports uv by default + assert!(image_supports_uv("langchain/langgraph-api")); + } + + // -- test helpers: get_build_tools_to_uninstall -- + #[test] + fn test_get_build_tools_to_uninstall() { + // None -> all tools + let config = Config::default(); + let tools = get_build_tools_to_uninstall(&config); + assert_eq!(tools, vec!["pip", "setuptools", "wheel"]); + + // Some(Bool(true)) -> empty + let config = Config { + keep_pkg_tools: Some(KeepPkgTools::Bool(true)), + ..Default::default() + }; + let tools = get_build_tools_to_uninstall(&config); + assert!(tools.is_empty()); + + // Some(Bool(false)) -> all tools + let config = Config { + keep_pkg_tools: Some(KeepPkgTools::Bool(false)), + ..Default::default() + }; + let tools = get_build_tools_to_uninstall(&config); + assert_eq!(tools, vec!["pip", "setuptools", "wheel"]); + + // Some(List) -> only tools not in list + let config = Config { + keep_pkg_tools: Some(KeepPkgTools::List(vec![ + "pip".to_string(), + "setuptools".to_string(), + ])), + ..Default::default() + }; + let tools = get_build_tools_to_uninstall(&config); + assert_eq!(tools, vec!["wheel"]); + } +} diff --git a/libs/cli/src/docker/mod.rs b/libs/cli/src/docker/mod.rs new file mode 100644 index 000000000..a3829c828 --- /dev/null +++ b/libs/cli/src/docker/mod.rs @@ -0,0 +1,3 @@ +pub mod capabilities; +pub mod compose; +pub mod dockerfile; diff --git a/libs/cli/src/exec.rs b/libs/cli/src/exec.rs new file mode 100644 index 000000000..26c47182c --- /dev/null +++ b/libs/cli/src/exec.rs @@ -0,0 +1,136 @@ +use std::io::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, Option)` on success, or an error message on failure. +pub fn run_command( + cmd: &str, + args: &[&str], + input: Option<&str>, + verbose: bool, +) -> Result<(Option, Option), 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(()) +} diff --git a/libs/cli/src/main.rs b/libs/cli/src/main.rs new file mode 100644 index 000000000..810cf0b10 --- /dev/null +++ b/libs/cli/src/main.rs @@ -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, + + /// 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, + + /// Pull latest images before running + #[arg(long, default_value_t = true)] + 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, + + /// Base URL for the debugger + #[arg(long)] + debugger_base_url: Option, + + /// Postgres connection URI + #[arg(long)] + postgres_uri: Option, + + /// API version of the LangGraph server + #[arg(long)] + api_version: Option, + + /// Pre-built image to use instead of building + #[arg(long)] + image: Option, + + /// Base image for the LangGraph API server + #[arg(long)] + base_image: Option, + }, + + /// 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, + + /// Pull latest images before building + #[arg(long, default_value_t = true)] + pull: bool, + + /// Base image for the LangGraph API server + #[arg(long)] + base_image: Option, + + /// API version of the LangGraph server + #[arg(long)] + api_version: Option, + + /// Custom install command + #[arg(long)] + install_command: Option, + + /// Custom build command + #[arg(long)] + build_command: Option, + + /// Additional arguments to pass to docker build + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + docker_build_args: Vec, + }, + + /// 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, + + /// API version of the LangGraph server + #[arg(long)] + api_version: Option, + }, + + /// 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(long, default_value = "langgraph.json")] + config: String, + + /// Max concurrent jobs per worker + #[arg(long)] + n_jobs_per_worker: Option, + + /// Skip opening browser + #[arg(long)] + no_browser: bool, + + /// Enable remote debugging on specified port + #[arg(long)] + debug_port: Option, + + /// Wait for debugger client to connect + #[arg(long)] + wait_for_client: bool, + + /// URL of LangGraph Studio + #[arg(long)] + studio_url: Option, + + /// 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, + + /// Template to use + #[arg(long)] + template: Option, + }, +} + +fn main() { + let cli = Cli::parse(); + + let result = match cli.command { + Commands::Up { + config, + port, + docker_compose, + verbose, + watch, + recreate, + 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, + 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, + pull, + base_image, + api_version, + install_command, + build_command, + docker_build_args, + } => commands::build_cmd::run( + &config, + &tag, + 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); + } +} diff --git a/libs/cli/src/progress.rs b/libs/cli/src/progress.rs new file mode 100644 index 000000000..e149a1602 --- /dev/null +++ b/libs/cli/src/progress.rs @@ -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(); + } +} diff --git a/libs/cli/src/templates.rs b/libs/cli/src/templates.rs new file mode 100644 index 000000000..a387bc56a --- /dev/null +++ b/libs/cli/src/templates.rs @@ -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 { + 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 { + let map = build_template_id_map(); + let mut ids: Vec = map.keys().cloned().collect(); + ids.sort(); + ids +} + +/// Interactively choose a template. Returns the download URL. +fn choose_template() -> Result { + eprintln!("{}", style("Please select a template:").bold().yellow()); + let items: Vec = 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(()) +} diff --git a/libs/cli/src/util.rs b/libs/cli/src/util.rs new file mode 100644 index 000000000..f31d2b1df --- /dev/null +++ b/libs/cli/src/util.rs @@ -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::>() + .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(""), ""); + } +}