diff --git a/Cargo.lock b/Cargo.lock index 0e83673..cb92135 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" @@ -26,18 +17,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "ambient-authority" -version = "0.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" - [[package]] name = "android_system_properties" version = "0.1.5" @@ -122,12 +101,6 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" -[[package]] -name = "arbitrary" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" - [[package]] name = "arrayvec" version = "0.7.6" @@ -305,15 +278,6 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" -[[package]] -name = "bitmaps" -version = "2.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "031043d04099746d8db04daf1fa424b2bc8bd69d92b25962dcde24da39ab64a2" -dependencies = [ - "typenum", -] - [[package]] name = "block-buffer" version = "0.10.4" @@ -383,15 +347,6 @@ name = "bumpalo" version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" -dependencies = [ - "allocator-api2", -] - -[[package]] -name = "bytemuck" -version = "1.25.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "bytes" @@ -399,90 +354,6 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" -[[package]] -name = "cap-fs-ext" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5528f85b1e134ae811704e41ef80930f56e795923f866813255bc342cc20654" -dependencies = [ - "cap-primitives", - "cap-std", - "io-lifetimes", - "windows-sys 0.59.0", -] - -[[package]] -name = "cap-primitives" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" -dependencies = [ - "ambient-authority", - "fs-set-times", - "io-extras", - "io-lifetimes", - "ipnet", - "maybe-owned", - "rustix 1.1.4", - "rustix-linux-procfs", - "windows-sys 0.59.0", - "winx", -] - -[[package]] -name = "cap-rand" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d" -dependencies = [ - "ambient-authority", - "rand 0.8.6", -] - -[[package]] -name = "cap-std" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" -dependencies = [ - "cap-primitives", - "io-extras", - "io-lifetimes", - "rustix 1.1.4", -] - -[[package]] -name = "cap-time-ext" -version = "3.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "def102506ce40c11710a9b16e614af0cde8e76ae51b1f48c04b8d79f4b671a80" -dependencies = [ - "ambient-authority", - "cap-primitives", - "iana-time-zone", - "once_cell", - "rustix 1.1.4", - "winx", -] - -[[package]] -name = "cbindgen" -version = "0.29.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "befbfd072a8e81c02f8c507aefce431fe5e7d051f83d48a23ffc9b9fe5a11799" -dependencies = [ - "heck", - "indexmap 2.14.0", - "log", - "proc-macro2", - "quote", - "serde", - "serde_json", - "syn", - "tempfile", - "toml 0.9.12+spec-1.1.0", -] - [[package]] name = "cc" version = "1.2.60" @@ -571,15 +442,6 @@ dependencies = [ "cc", ] -[[package]] -name = "cobs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" -dependencies = [ - "thiserror 2.0.18", -] - [[package]] name = "colorchoice" version = "1.0.5" @@ -676,15 +538,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpp_demangle" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" -dependencies = [ - "cfg-if", -] - [[package]] name = "cpufeatures" version = "0.2.17" @@ -694,144 +547,6 @@ dependencies = [ "libc", ] -[[package]] -name = "cranelift-assembler-x64" -version = "0.128.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50a04121a197fde2fe896f8e7cac9812fc41ed6ee9c63e1906090f9f497845f6" -dependencies = [ - "cranelift-assembler-x64-meta", -] - -[[package]] -name = "cranelift-assembler-x64-meta" -version = "0.128.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a09e699a94f477303820fb2167024f091543d6240783a2d3b01a3f21c42bc744" -dependencies = [ - "cranelift-srcgen", -] - -[[package]] -name = "cranelift-bforest" -version = "0.128.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f07732c662a9755529e332d86f8c5842171f6e98ba4d5976a178043dad838654" -dependencies = [ - "cranelift-entity", -] - -[[package]] -name = "cranelift-bitset" -version = "0.128.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18391da761cf362a06def7a7cf11474d79e55801dd34c2e9ba105b33dc0aef88" -dependencies = [ - "serde", - "serde_derive", -] - -[[package]] -name = "cranelift-codegen" -version = "0.128.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b3a09b3042c69810d255aef59ddc3b3e4c0644d1d90ecfd6e3837798cc88a3c" -dependencies = [ - "bumpalo", - "cranelift-assembler-x64", - "cranelift-bforest", - "cranelift-bitset", - "cranelift-codegen-meta", - "cranelift-codegen-shared", - "cranelift-control", - "cranelift-entity", - "cranelift-isle", - "gimli", - "hashbrown 0.15.5", - "log", - "pulley-interpreter", - "regalloc2", - "rustc-hash", - "serde", - "smallvec", - "target-lexicon", - "wasmtime-internal-math", -] - -[[package]] -name = "cranelift-codegen-meta" -version = "0.128.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75817926ec812241889208d1b190cadb7fedded4592a4bb01b8524babb9e4849" -dependencies = [ - "cranelift-assembler-x64-meta", - "cranelift-codegen-shared", - "cranelift-srcgen", - "heck", - "pulley-interpreter", -] - -[[package]] -name = "cranelift-codegen-shared" -version = "0.128.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "859158f87a59476476eda3884d883c32e08a143cf3d315095533b362a3250a63" - -[[package]] -name = "cranelift-control" -version = "0.128.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03b65a9aec442d715cbf54d14548b8f395476c09cef7abe03e104a378291ab88" -dependencies = [ - "arbitrary", -] - -[[package]] -name = "cranelift-entity" -version = "0.128.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8334c99a7e86060c24028732efd23bac84585770dcb752329c69f135d64f2fc1" -dependencies = [ - "cranelift-bitset", - "serde", - "serde_derive", -] - -[[package]] -name = "cranelift-frontend" -version = "0.128.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43ac6c095aa5b3e845d7ca3461e67e2b65249eb5401477a5ff9100369b745111" -dependencies = [ - "cranelift-codegen", - "log", - "smallvec", - "target-lexicon", -] - -[[package]] -name = "cranelift-isle" -version = "0.128.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d3d992870ed4f0f2e82e2175275cb3a123a46e9660c6558c46417b822c91fa" - -[[package]] -name = "cranelift-native" -version = "0.128.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee32e36beaf80f309edb535274cfe0349e1c5cf5799ba2d9f42e828285c6b52e" -dependencies = [ - "cranelift-codegen", - "libc", - "target-lexicon", -] - -[[package]] -name = "cranelift-srcgen" -version = "0.128.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "903adeaf4938e60209a97b53a2e4326cd2d356aab9764a1934630204bae381c9" - [[package]] name = "crc32fast" version = "1.5.0" @@ -905,7 +620,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "804169db156b21258a2545757336922d93dfa229892c75911a0ad141aa0ff241" dependencies = [ - "petgraph 0.8.3", + "petgraph", "serde", ] @@ -927,15 +642,6 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" -[[package]] -name = "debugid" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" -dependencies = [ - "uuid", -] - [[package]] name = "deranged" version = "0.5.8" @@ -998,16 +704,6 @@ dependencies = [ "crypto-common", ] -[[package]] -name = "directories-next" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "339ee130d97a610ea5a5872d2bbb130fdf68884ff09d3028b81bec8a1ac23bbc" -dependencies = [ - "cfg-if", - "dirs-sys-next", -] - [[package]] name = "dirs" version = "6.0.0" @@ -1025,21 +721,10 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.2", + "redox_users", "windows-sys 0.61.2", ] -[[package]] -name = "dirs-sys-next" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" -dependencies = [ - "libc", - "redox_users 0.4.6", - "winapi", -] - [[package]] name = "displaydoc" version = "0.2.5" @@ -1084,33 +769,12 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "embedded-io" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" - -[[package]] -name = "embedded-io" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" - [[package]] name = "encode_unicode" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -1128,174 +792,58 @@ dependencies = [ ] [[package]] -name = "extism" -version = "1.21.0" +name = "fastrand" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed8c5859bdab81d2eb4cd963eeacd8031d353b1ffb2fde43ee9179a0d6295120" -dependencies = [ - "anyhow", - "async-trait", - "cbindgen", - "extism-convert", - "extism-manifest", - "glob", - "libc", - "serde", - "serde_json", - "sha2", - "toml 0.9.12+spec-1.1.0", - "tracing", - "tracing-subscriber", - "ureq 3.3.0", - "url", - "uuid", - "wasi-common", - "wasmtime", - "wiggle", -] +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] -name = "extism-convert" -version = "1.21.0" +name = "filetime" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec1a8eac059a1730a21aa47f99a0c2075ba0ab88fd0c4e52e35027cf99cdf3e7" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" dependencies = [ - "anyhow", - "base64", - "bytemuck", - "extism-convert-macros", - "prost", - "rmp-serde", - "serde", - "serde_json", + "cfg-if", + "libc", + "libredox", ] [[package]] -name = "extism-convert-macros" -version = "1.21.0" +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "848f105dd6e1af2ea4bb4a76447658e8587167df3c4e4658c4258e5b14a5b051" -dependencies = [ - "manyhow", - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", -] +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "extism-manifest" -version = "1.21.0" +name = "fixedbitset" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953a22ad322939ae4567ec73a34913a3a43dcbdfa648b8307d38fe56bb3a0acd" -dependencies = [ - "base64", - "serde", - "serde_json", -] +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] -name = "extism-pdk" -version = "1.4.1" +name = "flate2" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "352fcb5a66eb74145a1c4a01f2bd15d59c62c85be73aac8471880c65b26b798f" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ - "anyhow", - "base64", - "extism-convert", - "extism-manifest", - "extism-pdk-derive", - "serde", - "serde_json", + "crc32fast", + "miniz_oxide", ] [[package]] -name = "extism-pdk-derive" -version = "1.4.1" +name = "float-cmp" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d086daea5fd844e3c5ac69ddfe36df4a9a43e7218cf7d1f888182b089b09806c" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" dependencies = [ - "proc-macro2", - "quote", - "syn", + "num-traits", ] [[package]] -name = "fallible-iterator" -version = "0.3.0" +name = "fnv" +version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "fd-lock" -version = "4.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" -dependencies = [ - "cfg-if", - "rustix 1.1.4", - "windows-sys 0.59.0", -] - -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "fixedbitset" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" - -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - -[[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 = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" [[package]] name = "foldhash" @@ -1312,17 +860,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "fs-set-times" -version = "0.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" -dependencies = [ - "io-lifetimes", - "rustix 1.1.4", - "windows-sys 0.59.0", -] - [[package]] name = "fs2" version = "0.4.3" @@ -1436,20 +973,6 @@ dependencies = [ "thread_local", ] -[[package]] -name = "fxprof-processed-profile" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" -dependencies = [ - "bitflags", - "debugid", - "rustc-hash", - "serde", - "serde_derive", - "serde_json", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -1500,23 +1023,6 @@ dependencies = [ "wasip3", ] -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" -dependencies = [ - "fallible-iterator", - "indexmap 2.14.0", - "stable_deref_trait", -] - -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - [[package]] name = "globset" version = "0.4.18" @@ -1590,13 +1096,13 @@ dependencies = [ "console 0.15.11", "daggy", "dialoguer", - "extism", "flate2", "fs2", "futures", "futures-util", "hex", "hm-pipeline-ir", + "hm-plugin-cloud", "hm-plugin-protocol", "hm-util", "ignore", @@ -1619,10 +1125,10 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", - "toml 0.8.23", + "toml", "tracing", "tracing-subscriber", - "ureq 2.12.1", + "ureq", "url", "uuid", "webbrowser", @@ -1643,7 +1149,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "foldhash", - "serde", ] [[package]] @@ -1670,16 +1175,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "hm-fixtures" -version = "0.0.0" -dependencies = [ - "hm-plugin-sdk", - "semver", - "serde", - "serde_json", -] - [[package]] name = "hm-pipeline-ir" version = "0.0.0-dev" @@ -1699,53 +1194,18 @@ dependencies = [ "base64", "chrono", "clap", - "extism-pdk", - "hm-plugin-protocol", - "hm-plugin-sdk", - "semver", + "dialoguer", + "hm-util", + "reqwest", "serde", "serde_json", "sha2", + "tokio", + "toml", + "tracing", "url", "uuid", -] - -[[package]] -name = "hm-plugin-docker" -version = "0.1.0" -dependencies = [ - "extism-pdk", - "hm-plugin-protocol", - "hm-plugin-sdk", - "semver", - "serde", - "serde_json", -] - -[[package]] -name = "hm-plugin-output-human" -version = "0.1.0" -dependencies = [ - "chrono", - "extism-pdk", - "hm-plugin-protocol", - "hm-plugin-sdk", - "semver", - "serde", - "serde_json", - "uuid", -] - -[[package]] -name = "hm-plugin-output-json" -version = "0.1.0" -dependencies = [ - "extism-pdk", - "hm-plugin-protocol", - "hm-plugin-sdk", - "semver", - "serde", - "serde_json", + "webbrowser", ] [[package]] @@ -1755,25 +1215,12 @@ dependencies = [ "chrono", "derive_more", "hm-pipeline-ir", - "insta", "schemars 0.8.22", - "semver", "serde", "serde_json", - "thiserror 2.0.18", "uuid", ] -[[package]] -name = "hm-plugin-sdk" -version = "0.0.0-dev" -dependencies = [ - "extism-pdk", - "hm-plugin-protocol", - "serde", - "serde_json", -] - [[package]] name = "hm-util" version = "0.0.0-dev" @@ -2077,20 +1524,6 @@ dependencies = [ "winapi-util", ] -[[package]] -name = "im-rc" -version = "15.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1955a75fa080c677d3972822ec4bad316169ab1cfc6c257a942c2265dbe5fe" -dependencies = [ - "bitmaps", - "rand_core 0.6.4", - "rand_xoshiro", - "sized-chunks", - "typenum", - "version_check", -] - [[package]] name = "indexmap" version = "1.9.3" @@ -2140,22 +1573,6 @@ dependencies = [ "tempfile", ] -[[package]] -name = "io-extras" -version = "0.18.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" -dependencies = [ - "io-lifetimes", - "windows-sys 0.59.0", -] - -[[package]] -name = "io-lifetimes" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" - [[package]] name = "ipnet" version = "2.12.0" @@ -2195,41 +1612,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "ittapi" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b996fe614c41395cdaedf3cf408a9534851090959d90d54a535f675550b64b1" -dependencies = [ - "anyhow", - "ittapi-sys", - "log", -] - -[[package]] -name = "ittapi-sys" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5385394064fa2c886205dba02598013ce83d3e92d33dbdc0c52fe0e7bf4fc" -dependencies = [ - "cc", -] - [[package]] name = "jni" version = "0.22.4" @@ -2307,12 +1695,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" -[[package]] -name = "leb128" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cc46bac87ef8093eed6f272babb833b6443374399985ac8ed28471ee0918545" - [[package]] name = "leb128fmt" version = "0.1.0" @@ -2325,12 +1707,6 @@ version = "0.2.185" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - [[package]] name = "libredox" version = "0.1.16" @@ -2388,38 +1764,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "mach2" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" -dependencies = [ - "libc", -] - -[[package]] -name = "manyhow" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587" -dependencies = [ - "manyhow-macros", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "manyhow-macros" -version = "0.11.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495" -dependencies = [ - "proc-macro-utils", - "proc-macro2", - "quote", -] - [[package]] name = "matchers" version = "0.2.0" @@ -2435,27 +1779,12 @@ version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" -[[package]] -name = "maybe-owned" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" - [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "memfd" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad38eb12aea514a0466ea40a80fd8cc83637065948eb4a426e4aa46261175227" -dependencies = [ - "rustix 1.1.4", -] - [[package]] name = "mime" version = "0.3.17" @@ -2598,18 +1927,6 @@ dependencies = [ "objc2", ] -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "crc32fast", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "memchr", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -2673,23 +1990,13 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "petgraph" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" -dependencies = [ - "fixedbitset 0.4.2", - "indexmap 2.14.0", -] - [[package]] name = "petgraph" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ - "fixedbitset 0.5.7", + "fixedbitset", "hashbrown 0.15.5", "indexmap 2.14.0", "serde", @@ -2702,12 +2009,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - [[package]] name = "plain" version = "0.2.3" @@ -2720,18 +2021,6 @@ version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" -[[package]] -name = "postcard" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" -dependencies = [ - "cobs", - "embedded-io 0.4.0", - "embedded-io 0.6.1", - "serde", -] - [[package]] name = "potential_utf" version = "0.1.5" @@ -2796,26 +2085,6 @@ dependencies = [ "syn", ] -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", -] - -[[package]] -name = "proc-macro-utils" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071" -dependencies = [ - "proc-macro2", - "quote", - "smallvec", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -2825,52 +2094,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "prost" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-derive" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" -dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "pulley-interpreter" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9812652c1feb63cf39f8780cecac154a32b22b3665806c733cd4072547233a4" -dependencies = [ - "cranelift-bitset", - "log", - "pulley-macros", - "wasmtime-internal-math", -] - -[[package]] -name = "pulley-macros" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56000349b6896e3d44286eb9c330891237f40b27fd43c1ccc84547d0b463cb40" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "quinn" version = "0.11.9" @@ -3007,35 +2230,6 @@ dependencies = [ "getrandom 0.3.4", ] -[[package]] -name = "rand_xoshiro" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f97cdb2a36ed4183de61b2f824cc45c9f1037f28afe0a322e9fff4c108b5aaa" -dependencies = [ - "rand_core 0.6.4", -] - -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -3054,17 +2248,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "redox_users" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" -dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 1.0.69", -] - [[package]] name = "redox_users" version = "0.5.2" @@ -3096,20 +2279,6 @@ dependencies = [ "syn", ] -[[package]] -name = "regalloc2" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08effbc1fa53aaebff69521a5c05640523fab037b34a4a2c109506bc938246fa" -dependencies = [ - "allocator-api2", - "bumpalo", - "hashbrown 0.15.5", - "log", - "rustc-hash", - "smallvec", -] - [[package]] name = "regex" version = "1.12.3" @@ -3195,31 +2364,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rmp" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" -dependencies = [ - "num-traits", -] - -[[package]] -name = "rmp-serde" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" -dependencies = [ - "rmp", - "serde", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" - [[package]] name = "rustc-hash" version = "2.1.2" @@ -3261,16 +2405,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustix-linux-procfs" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" -dependencies = [ - "once_cell", - "rustix 1.1.4", -] - [[package]] name = "rustls" version = "0.23.38" @@ -3543,15 +2677,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_spanned" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" -dependencies = [ - "serde_core", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3582,19 +2707,6 @@ dependencies = [ "time", ] -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap 2.14.0", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - [[package]] name = "sha1" version = "0.10.6" @@ -3676,16 +2788,6 @@ version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" -[[package]] -name = "sized-chunks" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16d69225bde7a69b235da73377861095455d298f2b970996eec25ddbb42b3d1e" -dependencies = [ - "bitmaps", - "typenum", -] - [[package]] name = "slab" version = "0.4.12" @@ -3697,9 +2799,6 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -dependencies = [ - "serde", -] [[package]] name = "smart-default" @@ -3790,22 +2889,6 @@ dependencies = [ "syn", ] -[[package]] -name = "system-interface" -version = "0.27.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" -dependencies = [ - "bitflags", - "cap-fs-ext", - "cap-std", - "fd-lock", - "io-lifetimes", - "rustix 0.38.44", - "windows-sys 0.59.0", - "winx", -] - [[package]] name = "tar" version = "0.4.45" @@ -3817,12 +2900,6 @@ dependencies = [ "xattr", ] -[[package]] -name = "target-lexicon" -version = "0.13.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" - [[package]] name = "tempfile" version = "3.27.0" @@ -3836,15 +2913,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "termcolor" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" -dependencies = [ - "winapi-util", -] - [[package]] name = "terminal_size" version = "0.4.4" @@ -4025,24 +3093,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", - "toml_edit 0.22.27", -] - -[[package]] -name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap 2.14.0", - "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", + "serde_spanned", + "toml_datetime", + "toml_edit", ] [[package]] @@ -4054,24 +3107,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_datetime" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_edit" version = "0.22.27" @@ -4080,31 +3115,10 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap 2.14.0", "serde", - "serde_spanned 0.6.9", - "toml_datetime 0.6.11", + "serde_spanned", + "toml_datetime", "toml_write", - "winnow 0.7.15", -] - -[[package]] -name = "toml_edit" -version = "0.25.11+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" -dependencies = [ - "indexmap 2.14.0", - "toml_datetime 1.1.1+spec-1.1.0", - "toml_parser", - "winnow 1.0.1", -] - -[[package]] -name = "toml_parser" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" -dependencies = [ - "winnow 1.0.1", + "winnow", ] [[package]] @@ -4113,12 +3127,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" -[[package]] -name = "toml_writer" -version = "1.1.1+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" - [[package]] name = "tower" version = "0.5.3" @@ -4175,7 +3183,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -4273,12 +3280,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" @@ -4300,35 +3301,6 @@ dependencies = [ "webpki-roots 0.26.11", ] -[[package]] -name = "ureq" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" -dependencies = [ - "base64", - "flate2", - "log", - "percent-encoding", - "rustls", - "rustls-pki-types", - "ureq-proto", - "utf8-zero", - "webpki-roots 1.0.7", -] - -[[package]] -name = "ureq-proto" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" -dependencies = [ - "base64", - "http", - "httparse", - "log", -] - [[package]] name = "url" version = "2.5.8" @@ -4341,12 +3313,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -4427,32 +3393,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi-common" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f49ffbbd04665d04028f66aee8f24ae7a1f46063f59a28fddfa52ca3091754a2" -dependencies = [ - "anyhow", - "async-trait", - "bitflags", - "cap-fs-ext", - "cap-rand", - "cap-std", - "cap-time-ext", - "fs-set-times", - "io-extras", - "io-lifetimes", - "log", - "rustix 1.1.4", - "system-interface", - "thiserror 2.0.18", - "tracing", - "wasmtime", - "wiggle", - "windows-sys 0.61.2", -] - [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -4526,37 +3466,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-compose" -version = "0.243.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af801b6f36459023eaec63fdbaedad2fd5a4ab7dc74ecc110a8b5d375c5775e4" -dependencies = [ - "anyhow", - "heck", - "im-rc", - "indexmap 2.14.0", - "log", - "petgraph 0.6.5", - "serde", - "serde_derive", - "serde_yaml", - "smallvec", - "wasm-encoder 0.243.0", - "wasmparser 0.243.0", - "wat", -] - -[[package]] -name = "wasm-encoder" -version = "0.243.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c55db9c896d70bd9fa535ce83cd4e1f2ec3726b0edd2142079f594fc3be1cb35" -dependencies = [ - "leb128fmt", - "wasmparser 0.243.0", -] - [[package]] name = "wasm-encoder" version = "0.244.0" @@ -4564,17 +3473,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" dependencies = [ "leb128fmt", - "wasmparser 0.244.0", -] - -[[package]] -name = "wasm-encoder" -version = "0.249.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69830ccbbf41c55eb585991659fb70867ef628193af3a495f09a6956f7615e59" -dependencies = [ - "leb128fmt", - "wasmparser 0.249.0", + "wasmparser", ] [[package]] @@ -4585,8 +3484,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap 2.14.0", - "wasm-encoder 0.244.0", - "wasmparser 0.244.0", + "wasm-encoder", + "wasmparser", ] [[package]] @@ -4604,20 +3503,7 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.243.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6d8db401b0528ec316dfbe579e6ab4152d61739cfe076706d2009127970159d" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", - "serde", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ @@ -4627,319 +3513,6 @@ dependencies = [ "semver", ] -[[package]] -name = "wasmparser" -version = "0.249.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30538cae9a794215f490b532df01c557e2e2bfac92569482554acd0992a102ea" -dependencies = [ - "bitflags", - "indexmap 2.14.0", - "semver", -] - -[[package]] -name = "wasmprinter" -version = "0.243.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb2b6035559e146114c29a909a3232928ee488d6507a1504d8934e8607b36d7b" -dependencies = [ - "anyhow", - "termcolor", - "wasmparser 0.243.0", -] - -[[package]] -name = "wasmtime" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2a83182bf04af87571b4c642300479501684f26bab5597f68f68cded5b098fd" -dependencies = [ - "addr2line", - "anyhow", - "async-trait", - "bitflags", - "bumpalo", - "cc", - "cfg-if", - "encoding_rs", - "futures", - "fxprof-processed-profile", - "gimli", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "ittapi", - "libc", - "log", - "mach2", - "memfd", - "object", - "once_cell", - "postcard", - "pulley-interpreter", - "rayon", - "rustix 1.1.4", - "semver", - "serde", - "serde_derive", - "serde_json", - "smallvec", - "target-lexicon", - "tempfile", - "wasm-compose", - "wasm-encoder 0.243.0", - "wasmparser 0.243.0", - "wasmtime-environ", - "wasmtime-internal-cache", - "wasmtime-internal-component-macro", - "wasmtime-internal-component-util", - "wasmtime-internal-cranelift", - "wasmtime-internal-fiber", - "wasmtime-internal-jit-debug", - "wasmtime-internal-jit-icache-coherence", - "wasmtime-internal-math", - "wasmtime-internal-slab", - "wasmtime-internal-unwinder", - "wasmtime-internal-versioned-export-macros", - "wasmtime-internal-winch", - "wat", - "windows-sys 0.61.2", -] - -[[package]] -name = "wasmtime-environ" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb201c41aa23a3642365cfb2e4a183573d85127a3c9d528f56b9997c984541ab" -dependencies = [ - "anyhow", - "cpp_demangle", - "cranelift-bitset", - "cranelift-entity", - "gimli", - "indexmap 2.14.0", - "log", - "object", - "postcard", - "rustc-demangle", - "semver", - "serde", - "serde_derive", - "smallvec", - "target-lexicon", - "wasm-encoder 0.243.0", - "wasmparser 0.243.0", - "wasmprinter", - "wasmtime-internal-component-util", -] - -[[package]] -name = "wasmtime-internal-cache" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5b3069d1a67ba5969d0eb1ccd7e141367d4e713f4649aa90356c98e8f19bea" -dependencies = [ - "base64", - "directories-next", - "log", - "postcard", - "rustix 1.1.4", - "serde", - "serde_derive", - "sha2", - "toml 0.9.12+spec-1.1.0", - "wasmtime-environ", - "windows-sys 0.61.2", - "zstd", -] - -[[package]] -name = "wasmtime-internal-component-macro" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c924400db7b6ca996fef1b23beb0f41d5c809836b1ec60fc25b4057e2d25d9b" -dependencies = [ - "anyhow", - "proc-macro2", - "quote", - "syn", - "wasmtime-internal-component-util", - "wasmtime-internal-wit-bindgen", - "wit-parser 0.243.0", -] - -[[package]] -name = "wasmtime-internal-component-util" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d3f65daf4bf3d74ca2fbbe20af0589c42e2b398a073486451425d94fd4afef4" - -[[package]] -name = "wasmtime-internal-cranelift" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "633e889cdae76829738db0114ab3b02fce51ea4a1cd9675a67a65fce92e8b418" -dependencies = [ - "cfg-if", - "cranelift-codegen", - "cranelift-control", - "cranelift-entity", - "cranelift-frontend", - "cranelift-native", - "gimli", - "itertools", - "log", - "object", - "pulley-interpreter", - "smallvec", - "target-lexicon", - "thiserror 2.0.18", - "wasmparser 0.243.0", - "wasmtime-environ", - "wasmtime-internal-math", - "wasmtime-internal-unwinder", - "wasmtime-internal-versioned-export-macros", -] - -[[package]] -name = "wasmtime-internal-fiber" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "deb126adc5d0c72695cfb77260b357f1b81705a0f8fa30b3944e7c2219c17341" -dependencies = [ - "cc", - "cfg-if", - "libc", - "rustix 1.1.4", - "wasmtime-environ", - "wasmtime-internal-versioned-export-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "wasmtime-internal-jit-debug" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e66ff7f90a8002187691ff6237ffd09f954a0ebb9de8b2ff7f5c62632134120" -dependencies = [ - "cc", - "object", - "rustix 1.1.4", - "wasmtime-internal-versioned-export-macros", -] - -[[package]] -name = "wasmtime-internal-jit-icache-coherence" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b96df23179ae16d54fb3a420f84ffe4383ec9dd06fad3e5bc782f85f66e8e08" -dependencies = [ - "anyhow", - "cfg-if", - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "wasmtime-internal-math" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86d1380926682b44c383e9a67f47e7a95e60c6d3fa8c072294dab2c7de6168a0" -dependencies = [ - "libm", -] - -[[package]] -name = "wasmtime-internal-slab" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b63cbea1c0192c7feb7c0dfb35f47166988a3742f29f46b585ef57246c65764" - -[[package]] -name = "wasmtime-internal-unwinder" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f25c392c7e5fb891a7416e3c34cfbd148849271e8c58744fda875dde4bec4d6a" -dependencies = [ - "cfg-if", - "cranelift-codegen", - "log", - "object", - "wasmtime-environ", -] - -[[package]] -name = "wasmtime-internal-versioned-export-macros" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70f8b9796a3f0451a7b702508b303d654de640271ac80287176de222f187a237" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "wasmtime-internal-winch" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0063e61f1d0b2c20e9cfc58361a6513d074a23c80b417aac3033724f51648a0" -dependencies = [ - "cranelift-codegen", - "gimli", - "log", - "object", - "target-lexicon", - "wasmparser 0.243.0", - "wasmtime-environ", - "wasmtime-internal-cranelift", - "winch-codegen", -] - -[[package]] -name = "wasmtime-internal-wit-bindgen" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "587699ca7cae16b4a234ffcc834f37e75675933d533809919b52975f5609e2ef" -dependencies = [ - "anyhow", - "bitflags", - "heck", - "indexmap 2.14.0", - "wit-parser 0.243.0", -] - -[[package]] -name = "wast" -version = "35.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" -dependencies = [ - "leb128", -] - -[[package]] -name = "wast" -version = "249.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2474a321bf9ae2808e9fa23ac4ec2b27300e70985e30bcb5a38d43b76bfc901a" -dependencies = [ - "bumpalo", - "leb128fmt", - "memchr", - "unicode-width", - "wasm-encoder 0.249.0", -] - -[[package]] -name = "wat" -version = "1.249.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28af699d0a9c7e4e250b7b8e36167ae5215fbb4b7ae526bb4ce7b234ba0afc90" -dependencies = [ - "wast 249.0.0", -] - [[package]] name = "web-sys" version = "0.3.95" @@ -5015,47 +3588,6 @@ dependencies = [ "winsafe", ] -[[package]] -name = "wiggle" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69a60bcbe1475c5dc9ec89210ade54823d44f742e283cba64f98f89697c4cec" -dependencies = [ - "anyhow", - "bitflags", - "thiserror 2.0.18", - "tracing", - "wasmtime", - "wiggle-macro", - "witx", -] - -[[package]] -name = "wiggle-generate" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21f3dc0fd4dcfc7736434bb216179a2147835309abc09bf226736a40d484548f" -dependencies = [ - "anyhow", - "heck", - "proc-macro2", - "quote", - "syn", - "witx", -] - -[[package]] -name = "wiggle-macro" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fea2aea744eded58ae092bf57110c27517dab7d5a300513ff13897325c5c5021" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "wiggle-generate", -] - [[package]] name = "winapi" version = "0.3.9" @@ -5087,26 +3619,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "winch-codegen" -version = "41.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c55de3ac5b8bd71e5f6c87a9e511dd3ceb194bdb58183c6a7bf21cd8c0e46fbc" -dependencies = [ - "anyhow", - "cranelift-assembler-x64", - "cranelift-codegen", - "gimli", - "regalloc2", - "smallvec", - "target-lexicon", - "thiserror 2.0.18", - "wasmparser 0.243.0", - "wasmtime-environ", - "wasmtime-internal-cranelift", - "wasmtime-internal-math", -] - [[package]] name = "windows" version = "0.62.2" @@ -5391,31 +3903,12 @@ dependencies = [ "memchr", ] -[[package]] -name = "winnow" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" -dependencies = [ - "memchr", -] - [[package]] name = "winsafe" version = "0.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" -[[package]] -name = "winx" -version = "0.36.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" -dependencies = [ - "bitflags", - "windows-sys 0.59.0", -] - [[package]] name = "wiremock" version = "0.6.5" @@ -5462,7 +3955,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck", - "wit-parser 0.244.0", + "wit-parser", ] [[package]] @@ -5509,28 +4002,10 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "wasm-encoder 0.244.0", + "wasm-encoder", "wasm-metadata", - "wasmparser 0.244.0", - "wit-parser 0.244.0", -] - -[[package]] -name = "wit-parser" -version = "0.243.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df983a8608e513d8997f435bb74207bf0933d0e49ca97aa9d8a6157164b9b7fc" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser 0.243.0", + "wasmparser", + "wit-parser", ] [[package]] @@ -5548,19 +4023,7 @@ dependencies = [ "serde_derive", "serde_json", "unicode-xid", - "wasmparser 0.244.0", -] - -[[package]] -name = "witx" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" -dependencies = [ - "anyhow", - "log", - "thiserror 1.0.69", - "wast 35.0.2", + "wasmparser", ] [[package]] @@ -5687,31 +4150,3 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" - -[[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/Cargo.toml b/Cargo.toml index b75da5d..71a66fc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,20 +4,15 @@ members = [ "crates/hm", "crates/hm-plugin-protocol", "crates/hm-pipeline-ir", - "crates/hm-plugin-sdk", - "crates/hm-plugin-docker", - "crates/hm-plugin-output-human", - "crates/hm-plugin-output-json", - "crates/hm-plugin-cloud", - "crates/hm-fixtures", "crates/hm-util", + "crates/hm-plugin-cloud", ] default-members = [ "crates/hm", "crates/hm-plugin-protocol", "crates/hm-pipeline-ir", - "crates/hm-plugin-sdk", "crates/hm-util", + "crates/hm-plugin-cloud", ] [workspace.package] @@ -28,7 +23,6 @@ repository = "https://github.com/harmont-dev/harmont-cli" [workspace.dependencies] hm-plugin-protocol = { path = "crates/hm-plugin-protocol", version = "0.0.0-dev" } hm-pipeline-ir = { path = "crates/hm-pipeline-ir", version = "0.0.0-dev" } -hm-plugin-sdk = { path = "crates/hm-plugin-sdk", version = "0.0.0-dev" } hm-util = { path = "crates/hm-util", version = "0.0.0-dev" } anyhow = "1" daggy = { version = "0.9", features = ["serde-1"] } @@ -43,8 +37,6 @@ derive_more = { version = "1", default-features = false, features = ["full"] } smart-default = "0.7" tokio = { version = "1", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } -extism = "1" -extism-pdk = "1" [workspace.lints.rust] unsafe_code = "deny" diff --git a/crates/hm-fixtures/Cargo.toml b/crates/hm-fixtures/Cargo.toml deleted file mode 100644 index 07eda04..0000000 --- a/crates/hm-fixtures/Cargo.toml +++ /dev/null @@ -1,50 +0,0 @@ -[package] -name = "hm-fixtures" -version = "0.0.0" -publish = false -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Test fixtures (WASM plugins) for the hm crate. Built via `cargo build --target wasm32-wasip1 -p hm-fixtures`; not consumed on the host target." - -[lib] -path = "src/lib.rs" - -# Each fixture is its own [[bin]] so it compiles to a separate .wasm. -[[bin]] -name = "noop_executor" -path = "src/bin/noop_executor.rs" - -[[bin]] -name = "recording_hook" -path = "src/bin/recording_hook.rs" - -[[bin]] -name = "failing_subcommand" -path = "src/bin/failing_subcommand.rs" - -[[bin]] -name = "host_fn_probe" -path = "src/bin/host_fn_probe.rs" - -[[bin]] -name = "bad_api_version" -path = "src/bin/bad_api_version.rs" - -[[bin]] -name = "freestyle_runner" -path = "src/bin/freestyle_runner.rs" - -[dependencies] -hm-plugin-sdk = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -semver = { workspace = true } - -[lints] -workspace = true - -[package.metadata.fixtures] -# Read by the test helper in the consuming crate (hm). The fixtures -# themselves don't compile to wasm on `cargo build` from the workspace -# root — they need an explicit `--target wasm32-wasip1`. diff --git a/crates/hm-fixtures/src/bin/bad_api_version.rs b/crates/hm-fixtures/src/bin/bad_api_version.rs deleted file mode 100644 index 515c011..0000000 --- a/crates/hm-fixtures/src/bin/bad_api_version.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! Declares a manifest with the wrong api_version. Used to assert -//! the host rejects it at load time. - -#![no_main] -#![allow( - clippy::pedantic, - clippy::nursery, - clippy::cargo, - clippy::multiple_crate_versions, - clippy::cargo_common_metadata, - clippy::missing_errors_doc -)] - -use hm_plugin_sdk::*; - -register_plugin!( - manifest = PluginManifest { - api_version: 9999, - name: "harmont-fixture-bad-api".into(), - version: semver::Version::new(0, 1, 0), - description: "always fails to load".into(), - capabilities: vec![Capability::StepExecutor(StepExecutorSpec { - runner: "bad".into(), - default: false, - step_schema: None, - })], - required_host_fns: vec![], - config_schema: None, - allowed_hosts: vec![], - }, -); diff --git a/crates/hm-fixtures/src/bin/failing_subcommand.rs b/crates/hm-fixtures/src/bin/failing_subcommand.rs deleted file mode 100644 index 94773e0..0000000 --- a/crates/hm-fixtures/src/bin/failing_subcommand.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! A subcommand plugin that always exits non-zero. Lets the host -//! exercise `ExitInfo` plumbing. - -#![no_main] -#![allow( - clippy::pedantic, - clippy::nursery, - clippy::cargo, - clippy::multiple_crate_versions, - clippy::cargo_common_metadata, - clippy::missing_errors_doc -)] - -use hm_plugin_sdk::*; -use serde_json::json; - -#[derive(Default)] -struct Failing; - -impl SubcommandPlugin for Failing { - fn run(&self, _input: SubcommandInput) -> Result { - Ok(ExitInfo { - exit_code: 7, - message: Some("intentional failure for tests".into()), - }) - } -} - -register_plugin!( - manifest = PluginManifest { - api_version: HM_PLUGIN_API_VERSION, - name: "harmont-fixture-failing".into(), - version: semver::Version::new(0, 1, 0), - description: "Test fixture: always exits 7.".into(), - capabilities: vec![Capability::Subcommand(SubcommandSpec { - verb: "fixture-fail".into(), - about: "Intentionally fails (test fixture)".into(), - args_schema: json!({"args": []}), - subcommands: vec![], - })], - required_host_fns: vec![], - config_schema: None, - allowed_hosts: vec![], - }, - subcommand = Failing, -); diff --git a/crates/hm-fixtures/src/bin/freestyle_runner.rs b/crates/hm-fixtures/src/bin/freestyle_runner.rs deleted file mode 100644 index 984725a..0000000 --- a/crates/hm-fixtures/src/bin/freestyle_runner.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! Fixture: registers as `runner: "freestyle"` and records the step -//! key it was invoked with into `Plugin`-scoped KV under -//! `freestyle_called_with`. The host-side test asserts this KV value -//! to prove that a step declaring `runner: "freestyle"` actually -//! lands here (and not on the docker default) — the regression guard -//! for PR #22's runner-field-drop bug. - -#![no_main] -// Test fixtures: relax the workspace's pedantic/nursery lints so the -// manifest construction (`"...".into()`, `vec![...]`) reads cleanly. -#![allow( - clippy::pedantic, - clippy::nursery, - clippy::cargo, - clippy::multiple_crate_versions, - clippy::cargo_common_metadata, - clippy::missing_errors_doc -)] - -use hm_plugin_sdk::*; - -#[derive(Default)] -struct Freestyle; - -impl StepExecutor for Freestyle { - fn run(&self, input: ExecutorInput) -> Result { - // Persistent (disk-backed) Plugin-scope KV: the host-side test - // can read this back from `/harmont/state/ - // harmont-fixture-freestyle.kv`. Build-scope KV is in-memory - // and not host-accessible from tests. - host::kv_set( - KvScope::Plugin, - "freestyle_called_with", - input.step.key.as_bytes(), - ); - Ok(StepResult { - exit_code: 0, - committed_snapshot: None, - artifacts: vec![], - }) - } -} - -register_plugin!( - manifest = PluginManifest { - api_version: HM_PLUGIN_API_VERSION, - name: "harmont-fixture-freestyle".into(), - version: semver::Version::new(0, 1, 0), - description: "Test fixture: records step key under runner=freestyle.".into(), - capabilities: vec![Capability::StepExecutor(StepExecutorSpec { - runner: "freestyle".into(), - default: false, - step_schema: None, - })], - required_host_fns: vec!["hm_kv_set".into()], - config_schema: None, - allowed_hosts: vec![], - }, - executor = Freestyle, -); diff --git a/crates/hm-fixtures/src/bin/host_fn_probe.rs b/crates/hm-fixtures/src/bin/host_fn_probe.rs deleted file mode 100644 index 2671755..0000000 --- a/crates/hm-fixtures/src/bin/host_fn_probe.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! Calls every host fn the spec defines (section 3.3) and reports back -//! what happened. Used by `tests/plugin_host_fns.rs` to assert each host -//! fn is wired up and produces the expected behaviour. - -#![no_main] -#![allow( - clippy::pedantic, - clippy::nursery, - clippy::cargo, - clippy::multiple_crate_versions, - clippy::cargo_common_metadata, - clippy::missing_errors_doc -)] - -use hm_plugin_sdk::*; -use serde::Serialize; -use serde_json::json; - -#[derive(Default, Serialize)] -struct Report { - log_ok: bool, - kv_round_trip: bool, - kv_isolated_per_scope: bool, - fs_read_returns_none_for_missing: bool, - keyring_round_trip: bool, - should_cancel_default_false: bool, -} - -#[derive(Default)] -struct Probe; - -impl SubcommandPlugin for Probe { - fn run(&self, _input: SubcommandInput) -> Result { - let mut r = Report::default(); - - host::log(Level::Info, "probe: log"); - r.log_ok = true; - - host::kv_set(KvScope::Plugin, "k", b"v1"); - let v = host::kv_get(KvScope::Plugin, "k").unwrap_or_default(); - r.kv_round_trip = v == b"v1"; - - host::kv_set(KvScope::Build, "k", b"v2"); - let p = host::kv_get(KvScope::Plugin, "k").unwrap_or_default(); - let b = host::kv_get(KvScope::Build, "k").unwrap_or_default(); - r.kv_isolated_per_scope = p == b"v1" && b == b"v2"; - - r.fs_read_returns_none_for_missing = host::fs_read_config("does/not/exist").is_none(); - - // Keyring uses a probe-scoped service+account so we don't - // collide with the user's real secrets. - host::keyring_set("harmont-probe", "test", "secret"); - r.keyring_round_trip = - host::keyring_get("harmont-probe", "test").as_deref() == Some("secret"); - host::keyring_delete("harmont-probe", "test"); - - r.should_cancel_default_false = !host::should_cancel(); - - let json = - serde_json::to_string(&r).map_err(|e| PluginError::new("serde", e.to_string()))?; - Ok(ExitInfo { - exit_code: 0, - message: Some(json), - }) - } -} - -register_plugin!( - manifest = PluginManifest { - api_version: HM_PLUGIN_API_VERSION, - name: "harmont-fixture-probe".into(), - version: semver::Version::new(0, 1, 0), - description: "Test fixture: exercises every host fn.".into(), - capabilities: vec![Capability::Subcommand(SubcommandSpec { - verb: "fixture-probe".into(), - about: "Probe host-fn surface".into(), - args_schema: json!({"args": []}), - subcommands: vec![], - })], - required_host_fns: vec![ - "hm_log".into(), - "hm_kv_get".into(), - "hm_kv_set".into(), - "hm_fs_read_config".into(), - "hm_keyring_get".into(), - "hm_keyring_set".into(), - "hm_keyring_delete".into(), - "hm_should_cancel".into(), - ], - config_schema: None, - allowed_hosts: vec![], - }, - subcommand = Probe, -); diff --git a/crates/hm-fixtures/src/bin/noop_executor.rs b/crates/hm-fixtures/src/bin/noop_executor.rs deleted file mode 100644 index 2954c11..0000000 --- a/crates/hm-fixtures/src/bin/noop_executor.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Minimal step-executor plugin. Records every `ExecutorInput` it -//! receives into a `Plugin`-scoped KV slot so tests can inspect it -//! after invocation. - -#![no_main] -// Test fixtures: relax the workspace's pedantic/nursery lints so the -// manifest construction (`"...".into()`, `vec![...]`) and one-shot -// `serde_json::to_vec` reads cleanly. -#![allow( - clippy::pedantic, - clippy::nursery, - clippy::cargo, - clippy::multiple_crate_versions, - clippy::cargo_common_metadata, - clippy::missing_errors_doc -)] - -use hm_plugin_sdk::*; - -#[derive(Default)] -struct NoopExec; - -impl StepExecutor for NoopExec { - fn run(&self, input: ExecutorInput) -> Result { - let key = format!("seen:{}", input.step.key); - let val = - serde_json::to_vec(&input).map_err(|e| PluginError::new("serde", e.to_string()))?; - host::kv_set(KvScope::Plugin, &key, &val); - host::log(Level::Info, &format!("noop ran step '{}'", input.step.key)); - Ok(StepResult { - exit_code: 0, - committed_snapshot: None, - artifacts: vec![], - }) - } -} - -register_plugin!( - manifest = PluginManifest { - api_version: HM_PLUGIN_API_VERSION, - name: "harmont-fixture-noop".into(), - version: semver::Version::new(0, 1, 0), - description: "Test fixture: records ExecutorInput, returns 0.".into(), - capabilities: vec![Capability::StepExecutor(StepExecutorSpec { - runner: "noop".into(), - default: false, - step_schema: None, - })], - required_host_fns: vec!["hm_log".into(), "hm_kv_set".into()], - config_schema: None, - allowed_hosts: vec![], - }, - executor = NoopExec, -); diff --git a/crates/hm-fixtures/src/bin/recording_hook.rs b/crates/hm-fixtures/src/bin/recording_hook.rs deleted file mode 100644 index 102b5ce..0000000 --- a/crates/hm-fixtures/src/bin/recording_hook.rs +++ /dev/null @@ -1,67 +0,0 @@ -//! Records every `HookEvent` into a KV slot keyed by event kind. - -#![no_main] -#![allow( - clippy::pedantic, - clippy::nursery, - clippy::cargo, - clippy::multiple_crate_versions, - clippy::cargo_common_metadata, - clippy::missing_errors_doc -)] - -use hm_plugin_sdk::*; - -#[derive(Default)] -struct RecHook; - -impl LifecycleHook for RecHook { - fn on_event(&self, event: HookEvent) -> Result { - let kind = match &event.event { - BuildEvent::BuildStart { .. } => "build_start", - BuildEvent::StepQueued { .. } => "step_queued", - BuildEvent::StepStart { .. } => "step_start", - BuildEvent::StepLog { .. } => "step_log", - BuildEvent::StepCacheHit { .. } => "step_cache_hit", - BuildEvent::StepEnd { .. } => "step_end", - BuildEvent::BuildEnd { .. } => "build_end", - BuildEvent::ChainFailed { .. } => "chain_failed", - }; - let key = format!("hook:{kind}"); - let v = host::kv_get(KvScope::Plugin, &key).unwrap_or_default(); - let mut count: u64 = if v.is_empty() { - 0 - } else { - String::from_utf8_lossy(&v).parse().unwrap_or(0) - }; - count += 1; - host::kv_set(KvScope::Plugin, &key, count.to_string().as_bytes()); - Ok(HookOutcome::Continue) - } -} - -register_plugin!( - manifest = PluginManifest { - api_version: HM_PLUGIN_API_VERSION, - name: "harmont-fixture-rec-hook".into(), - version: semver::Version::new(0, 1, 0), - description: "Test fixture: counts HookEvents per kind.".into(), - capabilities: vec![Capability::LifecycleHook(LifecycleHookSpec { - events: vec![ - HookEventKind::BuildStart, - HookEventKind::StepQueued, - HookEventKind::StepStart, - HookEventKind::StepLog, - HookEventKind::StepCacheHit, - HookEventKind::StepEnd, - HookEventKind::BuildEnd, - ], - phase: HookPhase::After, - timeout_ms: 5000, - })], - required_host_fns: vec!["hm_kv_get".into(), "hm_kv_set".into()], - config_schema: None, - allowed_hosts: vec![], - }, - hook = RecHook, -); diff --git a/crates/hm-fixtures/src/lib.rs b/crates/hm-fixtures/src/lib.rs deleted file mode 100644 index fd5e7cf..0000000 --- a/crates/hm-fixtures/src/lib.rs +++ /dev/null @@ -1,6 +0,0 @@ -// Fixtures are pure binaries; nothing lives in the lib. -// -// schemars 0.8 pulls older indexmap and wit-bindgen via its transitive tree. -// Match the crate-level allows used in the sibling protocol/sdk crates so -// the workspace's `cargo` lint group doesn't drown out real issues. -#![allow(clippy::multiple_crate_versions, clippy::cargo_common_metadata)] diff --git a/crates/hm-plugin-cloud/Cargo.toml b/crates/hm-plugin-cloud/Cargo.toml index 8f2f254..8280fc2 100644 --- a/crates/hm-plugin-cloud/Cargo.toml +++ b/crates/hm-plugin-cloud/Cargo.toml @@ -4,20 +4,16 @@ version = "0.1.0" edition.workspace = true license.workspace = true repository.workspace = true -description = "Built-in cloud client plugin for the hm CLI." +description = "Cloud client library for the hm CLI." publish = false [lib] -crate-type = ["cdylib"] path = "src/lib.rs" [dependencies] -hm-plugin-sdk = { workspace = true } -hm-plugin-protocol = { workspace = true } -extism-pdk = { workspace = true, features = ["http"] } +hm-util = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } -semver = { workspace = true } chrono = { workspace = true } uuid = { workspace = true } clap = { version = "4", features = ["derive"] } @@ -25,6 +21,12 @@ anyhow = "1" url = "2" base64 = "0.22" sha2 = "0.10" +toml = "0.8" +reqwest = { version = "0.13", default-features = false, features = ["rustls", "json"] } +tokio = { version = "1", features = ["net", "time", "sync"] } +webbrowser = "1" +dialoguer = "0.11" +tracing = "0.1" [lints] workspace = true diff --git a/crates/hm-plugin-cloud/src/api/types.rs b/crates/hm-plugin-cloud/src/api/types.rs index 5f9e2d2..ccb9035 100644 --- a/crates/hm-plugin-cloud/src/api/types.rs +++ b/crates/hm-plugin-cloud/src/api/types.rs @@ -1,14 +1,7 @@ //! Wire types for the API endpoints the cloud plugin consumes. //! //! Hand-written from the API's OpenAPI spec for the specific paths -//! we hit. The legacy progenitor-generated client carried the whole -//! schema; we only need a subset. -//! -//! Field names match `cli/crates/hm/openapi.json` (the canonical -//! source for the live API) — not necessarily the plan's example -//! names. Where the plan and the API disagreed (e.g. plan said -//! `display_name`; API says `name`), the API wins because that's -//! what's on the wire. +//! we hit. Field names match the canonical source for the live API. use std::collections::BTreeMap; @@ -18,31 +11,22 @@ use uuid::Uuid; // ─── Auth ────────────────────────────────────────────────────────────── -/// Body of `POST /cli/exchange` (the plan's PKCE-style exchange). The -/// API's closest analog is `POST /api/v0/auth/cli/redeem`, which only -/// carries a `code` — but the plan-4 flow includes a PKCE verifier. -/// Plan 5 reconciles these; for now, we send what the plan dictates. #[derive(Debug, Serialize)] -#[allow(dead_code, reason = "consumed by auth::login in this cluster")] pub(crate) struct CliExchangeRequest { pub code: String, pub verifier: String, } #[derive(Debug, Deserialize)] -#[allow(dead_code, reason = "consumed by auth::login in this cluster")] pub(crate) struct CliExchangeResponse { pub token: String, } /// Inner user record. Matches `UserResponse` in the OpenAPI spec. #[derive(Debug, Deserialize)] -#[allow(dead_code, reason = "consumed by auth verbs")] pub(crate) struct User { pub id: Uuid, pub email: String, - /// The API field is `name`. The plan calls it `display_name` — - /// rename so the plan's call sites still work. #[serde(rename = "name")] pub display_name: Option, } @@ -50,15 +34,14 @@ pub(crate) struct User { // ─── Organizations ───────────────────────────────────────────────────── #[derive(Debug, Deserialize)] -#[allow(dead_code, reason = "consumed by verbs in a later cluster")] pub(crate) struct Organization { + #[allow(dead_code)] pub id: Uuid, pub slug: String, pub name: String, } #[derive(Debug, Deserialize)] -#[allow(dead_code, reason = "consumed by verbs in a later cluster")] pub(crate) struct OrganizationList { pub data: Vec, } @@ -66,18 +49,15 @@ pub(crate) struct OrganizationList { // ─── Pipelines ───────────────────────────────────────────────────────── #[derive(Debug, Deserialize)] -#[allow(dead_code, reason = "consumed by verbs in a later cluster")] pub(crate) struct Pipeline { pub id: Uuid, pub slug: String, - /// The API field is `name`. The plan calls it `label` — rename. #[serde(rename = "name")] pub label: Option, pub default_branch: Option, } #[derive(Debug, Deserialize)] -#[allow(dead_code, reason = "consumed by verbs in a later cluster")] pub(crate) struct PipelineList { pub data: Vec, } @@ -85,7 +65,6 @@ pub(crate) struct PipelineList { // ─── Builds ──────────────────────────────────────────────────────────── #[derive(Debug, Deserialize, Serialize)] -#[allow(dead_code, reason = "consumed by verbs in a later cluster")] pub(crate) struct Build { pub id: Uuid, pub number: i64, @@ -97,19 +76,11 @@ pub(crate) struct Build { } #[derive(Debug, Deserialize)] -#[allow(dead_code, reason = "consumed by verbs in a later cluster")] pub(crate) struct BuildList { pub data: Vec, } -/// Body of the create-build POST. The plan's shape carries -/// `pipeline_slug`, `env`, and `plan_json`; the API today takes -/// `branch`, `commit`, `message`, `source`, `source_archive_b64`, -/// and `source_archive_sha256`. We keep the plan's shape — plan-5 -/// reconciles this with the live API; cluster 8 (`verbs/run.rs`) -/// adapts the call site. #[derive(Debug, Serialize)] -#[allow(dead_code, reason = "consumed by verbs in a later cluster")] pub(crate) struct CreateBuildRequest { pub pipeline_slug: String, pub branch: Option, @@ -121,45 +92,35 @@ pub(crate) struct CreateBuildRequest { // ─── Jobs ────────────────────────────────────────────────────────────── #[derive(Debug, Deserialize, Serialize)] -#[allow(dead_code, reason = "consumed by verbs in a later cluster")] pub(crate) struct Job { pub id: Uuid, pub state: String, - /// The API field is `name`. The plan calls it `label` — rename. #[serde(rename = "name")] pub label: Option, pub exit_code: Option, } #[derive(Debug, Deserialize)] -#[allow(dead_code, reason = "consumed by verbs in a later cluster")] pub(crate) struct JobList { pub data: Vec, } -/// `GET /api/v0/.../jobs/{job_id}/log`. The API envelope is -/// `{chunks, next_idx}`. The plan's draft type called the inner field -/// `data`; rename so deserialisation works against the real server. #[derive(Debug, Deserialize)] -#[allow(dead_code, reason = "consumed by verbs in a later cluster")] pub(crate) struct JobLog { + #[allow(dead_code)] pub job_id: Uuid, #[serde(rename = "chunks")] pub data: Vec, } -/// One contiguous segment of a job's log. The API uses -/// `{idx, content, at}`; the plan's draft used `{stream, line, ts}`. -/// Rename to match the wire. #[derive(Debug, Deserialize)] -#[allow(dead_code, reason = "consumed by verbs in a later cluster")] pub(crate) struct JobLogChunk { - /// API has no per-chunk stream field; the plan kept the name for - /// future use. We default to "stdout" when absent. + #[allow(dead_code)] #[serde(default = "default_stream")] pub stream: String, #[serde(rename = "content")] pub line: String, + #[allow(dead_code)] #[serde(rename = "at")] pub ts: DateTime, } @@ -170,21 +131,15 @@ fn default_stream() -> String { // ─── Billing ─────────────────────────────────────────────────────────── -/// `GET /api/v0/organizations/{org_slug}/billing/balance`. The API -/// field is `balance_cents`; the plan called it `credits_usd_cents`. #[derive(Debug, Deserialize)] -#[allow(dead_code, reason = "consumed by verbs in a later cluster")] pub(crate) struct Balance { #[serde(rename = "balance_cents")] pub credits_usd_cents: i64, } -/// `TransactionResponse`. The API uses `uuid`, `amount_cents`, -/// `source`, `description`, `created_at`. The plan's draft used -/// `id`, `kind`, `amount_cents`, `at`, `memo`. Rename to match. #[derive(Debug, Deserialize)] -#[allow(dead_code, reason = "consumed by verbs in a later cluster")] pub(crate) struct Transaction { + #[allow(dead_code)] #[serde(rename = "uuid")] pub id: Uuid, #[serde(rename = "source")] @@ -197,18 +152,11 @@ pub(crate) struct Transaction { } #[derive(Debug, Deserialize)] -#[allow(dead_code, reason = "consumed by verbs in a later cluster")] pub(crate) struct TransactionList { pub data: Vec, } -/// `UsageResponse`. The API uses `window_start`, `window_end`, `items`, -/// `total_cost_cents`. The plan's draft used `from`, `to`, -/// `minutes_used`, `cents_used`. We keep the plan's field names and -/// rename to match the wire; `minutes_used` has no direct API analog, -/// so we make it optional and default to zero. #[derive(Debug, Deserialize)] -#[allow(dead_code, reason = "consumed by verbs in a later cluster")] pub(crate) struct UsageWindow { #[serde(rename = "window_start")] pub from: DateTime, @@ -220,33 +168,24 @@ pub(crate) struct UsageWindow { pub cents_used: i64, } -/// `CreateCheckoutRequest`. The API takes `{org_slug, amount_cents}`; -/// the plan called the shape `TopupRequest { amount_usd }`. We send -/// the API's shape on the wire but keep the plan's type name; cluster -/// 8's `verbs/billing.rs` populates both fields. #[derive(Debug, Serialize)] -#[allow(dead_code, reason = "consumed by verbs in a later cluster")] pub(crate) struct TopupRequest { pub org_slug: String, pub amount_cents: i64, } #[derive(Debug, Deserialize)] -#[allow(dead_code, reason = "consumed by verbs in a later cluster")] pub(crate) struct TopupResponse { pub checkout_url: String, } -/// `RedeemCouponRequest`. API takes `{org_slug, code}`. #[derive(Debug, Serialize)] -#[allow(dead_code, reason = "consumed by verbs in a later cluster")] pub(crate) struct RedeemRequest { pub org_slug: String, pub code: String, } #[derive(Debug, Deserialize)] -#[allow(dead_code, reason = "consumed by verbs in a later cluster")] pub(crate) struct RedeemResponse { pub credited_cents: i64, } diff --git a/crates/hm-plugin-cloud/src/auth/login.rs b/crates/hm-plugin-cloud/src/auth/login.rs index 8c359a1..5b6ca81 100644 --- a/crates/hm-plugin-cloud/src/auth/login.rs +++ b/crates/hm-plugin-cloud/src/auth/login.rs @@ -2,37 +2,35 @@ use std::collections::BTreeMap; -use hm_plugin_protocol::PluginError; -use hm_plugin_sdk::host; +use anyhow::{Result, bail}; use crate::api::types::{CliExchangeRequest, CliExchangeResponse, User}; use crate::config::Config; use crate::creds; use crate::http::Client; -#[allow( - dead_code, - reason = "wired by `cli::dispatch` in the next cluster (Task 15)" -)] -pub(crate) fn run(env: &BTreeMap, paste: bool) -> Result<(), PluginError> { +pub(crate) async fn run(env: &BTreeMap, paste: bool) -> Result<()> { let cfg = Config::from_env(env); - let (verifier, challenge) = pkce_pair()?; + let (verifier, challenge) = pkce_pair(); if paste { - login_paste(env, &cfg, &verifier, &challenge) + login_paste(env, &cfg, &verifier, &challenge).await } else { - login_loopback(&cfg, &verifier, &challenge) + login_loopback(env, &cfg, &verifier, &challenge).await } } -fn login_loopback(cfg: &Config, verifier: &str, challenge: &str) -> Result<(), PluginError> { - let handle = host::spawn_loopback(None).ok_or_else(|| { - PluginError::new( - "cloud_loopback_spawn", - "host could not bind a loopback socket", - ) - })?; - let port = handle.0; +async fn login_loopback( + _env: &BTreeMap, + cfg: &Config, + verifier: &str, + challenge: &str, +) -> Result<()> { + use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; + + // Bind a local TCP listener on a random port. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let port = listener.local_addr()?.port(); let redirect = format!("http://127.0.0.1:{port}/cb"); let auth_url = format!( "{}/cli/login?challenge={}&redirect_uri={}", @@ -41,89 +39,111 @@ fn login_loopback(cfg: &Config, verifier: &str, challenge: &str) -> Result<(), P urlencoding(&redirect), ); - host::log( - hm_plugin_protocol::Level::Info, - &format!("opening browser to {auth_url}"), - ); - if !host::browser_open(&auth_url) { - write_stderr(&format!( - "couldn't auto-open the browser. Open this URL manually:\n {auth_url}\n" - )); + tracing::info!("opening browser to {auth_url}"); + if webbrowser::open(&auth_url).is_err() { + eprintln!( + "couldn't auto-open the browser. Open this URL manually:\n {auth_url}" + ); } - let data = host::loopback_recv(handle, 180_000).ok_or_else(|| { - PluginError::new( - "cloud_login_timeout", - "browser callback did not arrive within 3 minutes", - ) - })?; - let code = data.query.get("code").cloned().ok_or_else(|| { - PluginError::new( - "cloud_login_missing_code", - "callback had no 'code' query parameter", - ) - })?; - - finalize(cfg, &code, verifier) + // Wait for a single connection with a 180-second timeout. + let code = tokio::time::timeout(std::time::Duration::from_secs(180), async { + let (stream, _addr) = listener.accept().await?; + let (reader, mut writer) = stream.into_split(); + let mut buf_reader = BufReader::new(reader); + let mut request_line = String::new(); + buf_reader.read_line(&mut request_line).await?; + + // Parse "GET /cb?code=XYZ HTTP/1.1" + let mut code_value: Option = None; + if let Some(path) = request_line.split_whitespace().nth(1) + && let Some(query) = path.split('?').nth(1) + { + for param in query.split('&') { + if let Some(val) = param.strip_prefix("code=") { + code_value = Some(val.to_string()); + } + } + } + + // Send a minimal HTTP response. + let body = if code_value.is_some() { + "Login successful. You can close this tab." + } else { + "Login failed: no code received." + }; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + writer.write_all(response.as_bytes()).await.ok(); + writer.shutdown().await.ok(); + + Ok::, anyhow::Error>(code_value) + }) + .await + .map_err(|_| anyhow::anyhow!("browser callback did not arrive within 3 minutes"))??; + + let code = code.ok_or_else(|| anyhow::anyhow!("callback had no 'code' query parameter"))?; + + finalize(cfg, &code, verifier).await } -fn login_paste( +async fn login_paste( env: &BTreeMap, cfg: &Config, verifier: &str, challenge: &str, -) -> Result<(), PluginError> { +) -> Result<()> { let auth_url = format!( "{}/cli/login?challenge={}&redirect_uri=urn:ietf:wg:oauth:2.0:oob", cfg.api_base, challenge, ); - write_stderr(&format!( - "Open this URL in your browser, then paste the code:\n {auth_url}\n" - )); - let _ = host::browser_open(&auth_url); + eprintln!("Open this URL in your browser, then paste the code:\n {auth_url}"); + let _ = webbrowser::open(&auth_url); // Tests inject the code via `HARMONT_LOGIN_CODE` to avoid TTY. let code = if let Some(c) = env.get("HARMONT_LOGIN_CODE") { c.clone() } else { - host::tty_prompt("code: ", false) + dialoguer::Input::::new() + .with_prompt("code") + .interact() + .map_err(|e| anyhow::anyhow!("failed to read code: {e}"))? }; let code = code.trim().to_string(); if code.is_empty() { - return Err(PluginError::new("cloud_login_empty_code", "no code pasted")); + bail!("no code pasted"); } - finalize(cfg, &code, verifier) + finalize(cfg, &code, verifier).await } -fn finalize(cfg: &Config, code: &str, verifier: &str) -> Result<(), PluginError> { +async fn finalize(cfg: &Config, code: &str, verifier: &str) -> Result<()> { let client = Client::anonymous(cfg); - let resp: CliExchangeResponse = client.post( - "/cli/exchange", - &CliExchangeRequest { - code: code.to_string(), - verifier: verifier.to_string(), - }, - )?; + let resp: CliExchangeResponse = client + .post( + "/cli/exchange", + &CliExchangeRequest { + code: code.to_string(), + verifier: verifier.to_string(), + }, + ) + .await?; creds::save_token(&cfg.api_base, &resp.token); let auth_client = Client::new(cfg, Some(resp.token)); - let me: User = auth_client.get("/auth/me")?; - write_stderr(&format!( - "logged in as {} ({})\n", + let me: User = auth_client.get("/auth/me").await?; + eprintln!( + "logged in as {} ({})", me.display_name.clone().unwrap_or_else(|| me.email.clone()), me.email, - )); + ); Ok(()) } /// Generate a PKCE verifier + S256 challenge. -/// -/// WASM has no entropy source, so we derive 32 bytes from the system -/// clock's nanos. This is INSECURE for production — replace with a -/// proper host fn `hm_random_bytes` in a follow-up. -/// -/// TODO(plan-5): add `hm_random_bytes(len) -> Vec` host fn. -fn pkce_pair() -> Result<(String, String), PluginError> { +fn pkce_pair() -> (String, String) { use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use sha2::{Digest, Sha256}; @@ -138,11 +158,7 @@ fn pkce_pair() -> Result<(String, String), PluginError> { } let verifier = URL_SAFE_NO_PAD.encode(seed); let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); - Ok((verifier, challenge)) -} - -fn write_stderr(msg: &str) { - host::write_stderr(msg.as_bytes()); + (verifier, challenge) } fn urlencoding(s: &str) -> String { diff --git a/crates/hm-plugin-cloud/src/auth/logout.rs b/crates/hm-plugin-cloud/src/auth/logout.rs index d1d00d8..596a078 100644 --- a/crates/hm-plugin-cloud/src/auth/logout.rs +++ b/crates/hm-plugin-cloud/src/auth/logout.rs @@ -2,19 +2,14 @@ use std::collections::BTreeMap; -use hm_plugin_protocol::PluginError; -use hm_plugin_sdk::host; +use anyhow::Result; use crate::config::Config; use crate::creds; -#[allow( - dead_code, - reason = "wired by `cli::dispatch` in the next cluster (Task 15)" -)] -pub(crate) fn run(env: &BTreeMap) -> Result<(), PluginError> { +pub(crate) async fn run(env: &BTreeMap) -> Result<()> { let cfg = Config::from_env(env); creds::clear_token(&cfg.api_base); - host::write_stderr(format!("logged out of {}\n", cfg.api_base).as_bytes()); + eprintln!("logged out of {}", cfg.api_base); Ok(()) } diff --git a/crates/hm-plugin-cloud/src/auth/whoami.rs b/crates/hm-plugin-cloud/src/auth/whoami.rs index a774ef9..8845f7e 100644 --- a/crates/hm-plugin-cloud/src/auth/whoami.rs +++ b/crates/hm-plugin-cloud/src/auth/whoami.rs @@ -2,36 +2,28 @@ use std::collections::BTreeMap; -use hm_plugin_protocol::PluginError; -use hm_plugin_sdk::host; +use anyhow::Result; use crate::api::types::User; use crate::config::Config; use crate::creds; use crate::http::Client; -#[allow( - dead_code, - reason = "wired by `cli::dispatch` in the next cluster (Task 15)" -)] -pub(crate) fn run(env: &BTreeMap) -> Result<(), PluginError> { +pub(crate) async fn run(env: &BTreeMap) -> Result<()> { let cfg = Config::from_env(env); let token = creds::load_token(&cfg.api_base, env).ok_or_else(|| { - PluginError::new( - "cloud_not_logged_in", - format!("not logged in to {}\n fix: `hm cloud login`", cfg.api_base), + anyhow::anyhow!( + "not logged in to {}\n fix: `hm cloud login`", + cfg.api_base ) })?; let client = Client::new(&cfg, Some(token)); - let me: User = client.get("/auth/me")?; - host::write_stdout( - format!( - "{} <{}> (id {})\n", - me.display_name.clone().unwrap_or_else(|| me.email.clone()), - me.email, - me.id, - ) - .as_bytes(), + let me: User = client.get("/auth/me").await?; + println!( + "{} <{}> (id {})", + me.display_name.clone().unwrap_or_else(|| me.email.clone()), + me.email, + me.id, ); Ok(()) } diff --git a/crates/hm-plugin-cloud/src/cli.rs b/crates/hm-plugin-cloud/src/cli.rs index fbc8919..9cfbe3d 100644 --- a/crates/hm-plugin-cloud/src/cli.rs +++ b/crates/hm-plugin-cloud/src/cli.rs @@ -1,10 +1,9 @@ -//! Plugin-internal CLI parsing. The plugin receives the raw argv from -//! the host (verb_path = ["cloud", ...]) and parses it with clap. +//! CLI parsing for cloud subcommands. use std::collections::BTreeMap; +use anyhow::Result; use clap::{Parser, Subcommand}; -use hm_plugin_protocol::{ExitInfo, PluginError}; use crate::{auth, verbs}; @@ -19,8 +18,8 @@ struct CloudCli { command: CloudCommand, } -#[derive(Debug, Subcommand)] -enum CloudCommand { +#[derive(Debug, Clone, Subcommand)] +pub enum CloudCommand { /// Authenticate this CLI against the Harmont API. Login { /// Skip the loopback flow and prompt for a paste-in code. @@ -50,8 +49,8 @@ enum CloudCommand { Run(verbs::run::RunArgs), } -#[derive(Debug, Subcommand)] -pub(crate) enum OrgCommand { +#[derive(Debug, Clone, Subcommand)] +pub enum OrgCommand { /// Set the active organization. Switch { /// Organization slug. @@ -59,16 +58,16 @@ pub(crate) enum OrgCommand { }, } -#[derive(Debug, Subcommand)] -pub(crate) enum PipelineCommand { +#[derive(Debug, Clone, Subcommand)] +pub enum PipelineCommand { /// List pipelines for the active organization. List, /// Show pipeline details by slug. Show { slug: String }, } -#[derive(Debug, Subcommand)] -pub(crate) enum BuildCommand { +#[derive(Debug, Clone, Subcommand)] +pub enum BuildCommand { /// List builds for a pipeline. List { #[arg(short, long)] @@ -94,8 +93,8 @@ pub(crate) enum BuildCommand { }, } -#[derive(Debug, Subcommand)] -pub(crate) enum JobCommand { +#[derive(Debug, Clone, Subcommand)] +pub enum JobCommand { /// List jobs in a build. List { #[arg(short, long)] @@ -121,8 +120,8 @@ pub(crate) enum JobCommand { }, } -#[derive(Debug, Subcommand)] -pub(crate) enum BillingCommand { +#[derive(Debug, Clone, Subcommand)] +pub enum BillingCommand { /// Print the current credit balance. Balance, /// List billing transactions. @@ -147,72 +146,55 @@ pub(crate) enum BillingCommand { Redeem { code: String }, } -pub(crate) fn dispatch( +/// Dispatch from raw argv (used if calling from an external-subcommand +/// pattern). Returns an exit code. +pub async fn dispatch( argv: Vec, env: BTreeMap, -) -> Result { - // clap expects argv[0] to be the binary name; the host passes - // the verb path which starts with "cloud". Replace argv[0] with - // "hm cloud" so clap discards it as the program name and parses - // the remaining tokens (the cloud subcommand + args) correctly. +) -> Result { let mut full: Vec = vec!["hm cloud".to_string()]; full.extend(argv.into_iter().skip(1)); let parsed = match CloudCli::try_parse_from(&full) { Ok(p) => p, Err(e) => { - // clap surfaces `--help` / `--version` as errors with - // specific kinds; render them as a successful exit so the - // user sees the help text without an error code. - // - // TODO: route help/version through host::write_stdout so - // output framing matches the rest of the plugin. For now - // `eprintln!` is fine because clap's renderer is wired to - // stderr/stdout via std::io which the host captures. use clap::error::ErrorKind; let msg = e.to_string(); return match e.kind() { ErrorKind::DisplayHelp | ErrorKind::DisplayVersion => { - hm_plugin_sdk::host::write_stdout(msg.as_bytes()); - Ok(ExitInfo { - exit_code: 0, - message: None, - }) + print!("{msg}"); + Ok(0) + } + _ => { + eprint!("{msg}"); + Ok(2) } - _ => Ok(ExitInfo { - exit_code: 2, - message: Some(msg), - }), }; } }; - let result = match parsed.command { - CloudCommand::Login { paste } => auth::login::run(&env, paste), - CloudCommand::Logout => auth::logout::run(&env), - CloudCommand::Whoami => auth::whoami::run(&env), - CloudCommand::Org(cmd) => verbs::org::run(&env, cmd), - CloudCommand::Pipeline(cmd) => verbs::pipeline::run(&env, cmd), - CloudCommand::Build(cmd) => verbs::build::run(&env, cmd), - CloudCommand::Job(cmd) => verbs::job::run(&env, cmd), - CloudCommand::Billing(cmd) => verbs::billing::run(&env, cmd), - CloudCommand::Run(args) => verbs::run::run(&env, args), - }; - match result { - Ok(()) => Ok(ExitInfo { - exit_code: 0, - message: None, - }), - Err(e) => Ok(ExitInfo { - exit_code: exit_code_for(&e), - message: Some(e.message), - }), - } + dispatch_command(parsed.command, env).await } -fn exit_code_for(e: &PluginError) -> i32 { - match e.code.as_str() { - "cloud_auth" | "cloud_not_logged_in" => 3, - "cloud_http" | "cloud_http_request" => 4, - "cloud_cli_parse" => 2, - _ => 1, +/// Dispatch from a pre-parsed `CloudCommand`. Returns an exit code. +pub async fn dispatch_command( + command: CloudCommand, + env: BTreeMap, +) -> Result { + let result = match command { + CloudCommand::Login { paste } => auth::login::run(&env, paste).await, + CloudCommand::Logout => auth::logout::run(&env).await, + CloudCommand::Whoami => auth::whoami::run(&env).await, + CloudCommand::Org(cmd) => verbs::org::run(&env, cmd).await, + CloudCommand::Pipeline(cmd) => verbs::pipeline::run(&env, cmd).await, + CloudCommand::Build(cmd) => verbs::build::run(&env, cmd).await, + CloudCommand::Job(cmd) => verbs::job::run(&env, cmd).await, + CloudCommand::Billing(cmd) => verbs::billing::run(&env, cmd).await, + CloudCommand::Run(args) => verbs::run::run(&env, args).await, + }; + match result { + Ok(()) => Ok(0), + Err(e) => { + eprintln!("{e:#}"); + Ok(1) + } } } diff --git a/crates/hm-plugin-cloud/src/config.rs b/crates/hm-plugin-cloud/src/config.rs index d792209..3ab8a47 100644 --- a/crates/hm-plugin-cloud/src/config.rs +++ b/crates/hm-plugin-cloud/src/config.rs @@ -1,12 +1,7 @@ -//! Runtime configuration. API base URL and any other knobs the -//! plugin reads at start-of-call. +//! Runtime configuration. API base URL and any other knobs. use std::collections::BTreeMap; -#[allow( - dead_code, - reason = "consumed by `Config::from_env` once verbs land in a later cluster" -)] pub(crate) const DEFAULT_API_BASE: &str = "https://api.harmont.dev"; pub(crate) struct Config { @@ -14,7 +9,6 @@ pub(crate) struct Config { } impl Config { - #[allow(dead_code, reason = "consumed by verbs in a later cluster")] pub(crate) fn from_env(env: &BTreeMap) -> Self { let api_base = env .get("HARMONT_API_URL") diff --git a/crates/hm-plugin-cloud/src/creds.rs b/crates/hm-plugin-cloud/src/creds.rs index a3f47c0..6eb6167 100644 --- a/crates/hm-plugin-cloud/src/creds.rs +++ b/crates/hm-plugin-cloud/src/creds.rs @@ -1,37 +1,74 @@ -//! On-disk credential storage via the host's keyring host fns. +//! File-backed credential storage. use std::collections::BTreeMap; +use std::io::Write; -use hm_plugin_sdk::host; +const CREDS_FILE: &str = "credentials.toml"; -const SERVICE: &str = "harmont-cli"; - -/// Stash `token` for `api_base`. Empty token clears the entry. -#[allow(dead_code, reason = "consumed by the `login` verb in a later cluster")] pub(crate) fn save_token(api_base: &str, token: &str) { + let Some(dir) = hm_util::dirs::harmont_config_dir() else { + return; + }; + let path = dir.join(CREDS_FILE); + let mut table = load_table(&path); if token.is_empty() { - host::keyring_delete(SERVICE, api_base); + table.remove(api_base); } else { - host::keyring_set(SERVICE, api_base, token); + table.insert(api_base.to_owned(), token.to_owned()); } + write_table(&path, &table); } -/// Load the token for `api_base`. Prefers `HARMONT_API_TOKEN` from the -/// caller-provided env over the keyring entry. -#[allow( - dead_code, - reason = "consumed by the auth/verb modules in a later cluster" -)] pub(crate) fn load_token(api_base: &str, env: &BTreeMap) -> Option { if let Some(t) = env.get("HARMONT_API_TOKEN") && !t.is_empty() { return Some(t.clone()); } - host::keyring_get(SERVICE, api_base) + let dir = hm_util::dirs::harmont_config_dir()?; + let path = dir.join(CREDS_FILE); + let table = load_table(&path); + table.get(api_base).cloned() } -#[allow(dead_code, reason = "consumed by the `logout` verb in a later cluster")] pub(crate) fn clear_token(api_base: &str) { - host::keyring_delete(SERVICE, api_base); + save_token(api_base, ""); +} + +fn load_table(path: &std::path::Path) -> BTreeMap { + let Ok(content) = std::fs::read_to_string(path) else { + return BTreeMap::new(); + }; + let Ok(val) = content.parse::() else { + return BTreeMap::new(); + }; + let Some(table) = val.as_table() else { + return BTreeMap::new(); + }; + let mut map = BTreeMap::new(); + for (k, v) in table { + if let Some(s) = v.as_str() { + map.insert(k.clone(), s.to_owned()); + } + } + map +} + +fn write_table(path: &std::path::Path, table: &BTreeMap) { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let mut content = String::new(); + for (k, v) in table { + content.push_str(&format!("{k} = {v:?}\n")); + } + if let Ok(mut f) = std::fs::File::create(path) { + let _ = f.write_all(content.as_bytes()); + // Set 0600 on Unix + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = f.set_permissions(std::fs::Permissions::from_mode(0o600)); + } + } } diff --git a/crates/hm-plugin-cloud/src/http.rs b/crates/hm-plugin-cloud/src/http.rs index c287715..31421e3 100644 --- a/crates/hm-plugin-cloud/src/http.rs +++ b/crates/hm-plugin-cloud/src/http.rs @@ -1,103 +1,74 @@ -//! Thin HTTP wrapper around extism-pdk's host-mediated `http_request`. -//! Bearer-token injection, JSON ser/de, status-code → stable error code -//! mapping. +//! HTTP client using reqwest. -use extism_pdk::{HttpRequest, HttpResponse, http::request}; -use hm_plugin_protocol::PluginError; +use anyhow::{Context, Result, bail}; use serde::{Serialize, de::DeserializeOwned}; use crate::config::Config; pub(crate) struct Client { + inner: reqwest::Client, base: String, token: Option, } impl Client { - #[allow( - dead_code, - reason = "consumed by authenticated verbs in a later cluster" - )] pub(crate) fn new(config: &Config, token: Option) -> Self { Self { + inner: reqwest::Client::new(), base: config.api_base.clone(), token, } } - #[allow(dead_code, reason = "consumed by the `login` verb in a later cluster")] pub(crate) fn anonymous(config: &Config) -> Self { Self::new(config, None) } - /// Issue a GET. Body deserialised as `O`. - #[allow(dead_code, reason = "consumed by verbs in a later cluster")] - pub(crate) fn get(&self, path: &str) -> Result { - self.send::<(), O>("GET", path, None) + pub(crate) async fn get(&self, path: &str) -> Result { + self.send::<(), O>("GET", path, None).await } - #[allow(dead_code, reason = "consumed by verbs in a later cluster")] - pub(crate) fn post( + pub(crate) async fn post( &self, path: &str, body: &I, - ) -> Result { - self.send::("POST", path, Some(body)) + ) -> Result { + self.send::("POST", path, Some(body)).await } - #[allow(dead_code, reason = "consumed by verbs in a later cluster")] - pub(crate) fn delete(&self, path: &str) -> Result { - self.send::<(), O>("DELETE", path, None) + #[allow(dead_code)] + pub(crate) async fn delete(&self, path: &str) -> Result { + self.send::<(), O>("DELETE", path, None).await } - fn send(&self, method: &str, path: &str, body: Option<&I>) -> Result + async fn send(&self, method: &str, path: &str, body: Option<&I>) -> Result where I: Serialize, O: DeserializeOwned, { let url = format!("{}{path}", self.base); - let mut req = HttpRequest::new(&url).with_method(method); + let method_parsed = method + .parse() + .map_err(|e| anyhow::anyhow!("invalid HTTP method '{method}': {e}"))?; + let mut req = self.inner.request(method_parsed, &url); if let Some(token) = &self.token { - req = req.with_header("Authorization", format!("Bearer {token}")); + req = req.header("Authorization", format!("Bearer {token}")); } - req = req.with_header("Accept", "application/json"); - let body_bytes: Option> = body - .map(serde_json::to_vec) - .transpose() - .map_err(|e| PluginError::new("cloud_http_serialize", e.to_string()))?; - if body_bytes.is_some() { - req = req.with_header("Content-Type", "application/json"); + req = req.header("Accept", "application/json"); + if let Some(b) = body { + req = req.header("Content-Type", "application/json").json(b); } - let response: HttpResponse = request(&req, body_bytes.as_deref()) - .map_err(|e| PluginError::new("cloud_http_request", format!("{method} {url}: {e}")))?; - let status = response.status_code(); - let body = response.body(); + let resp = req.send().await.with_context(|| format!("{method} {url}"))?; + let status = resp.status().as_u16(); if !(200..300).contains(&status) { - let snippet = String::from_utf8_lossy(&body) - .chars() - .take(500) - .collect::(); - return Err(PluginError::new( - map_status_code(status), - format!("{method} {url} → HTTP {status}: {snippet}"), - )); + let text = resp.text().await.unwrap_or_default(); + let snippet: String = text.chars().take(500).collect(); + bail!("{method} {url} → HTTP {status}: {snippet}"); } - if body.is_empty() { - // Treat as unit type if `O` accepts `null` (e.g., `()`). - return serde_json::from_slice(b"null") - .map_err(|e| PluginError::new("cloud_http_decode", e.to_string())); + let bytes = resp.bytes().await?; + if bytes.is_empty() { + return serde_json::from_slice(b"null").context("decode empty response"); } - serde_json::from_slice(&body) - .map_err(|e| PluginError::new("cloud_http_decode", e.to_string())) - } -} - -fn map_status_code(status: u16) -> &'static str { - match status { - 401 | 403 => "cloud_auth", - 404 => "cloud_not_found", - 429 => "cloud_rate_limited", - 500..=599 => "cloud_server", - _ => "cloud_http", + serde_json::from_slice(&bytes).context("decode response JSON") } } diff --git a/crates/hm-plugin-cloud/src/lib.rs b/crates/hm-plugin-cloud/src/lib.rs index 2778763..5ed4831 100644 --- a/crates/hm-plugin-cloud/src/lib.rs +++ b/crates/hm-plugin-cloud/src/lib.rs @@ -1,10 +1,7 @@ -//! Built-in cloud client plugin for the hm CLI. +//! Cloud client library for the hm CLI. //! //! Implements `hm cloud {login,logout,whoami,org,pipeline,build,job,billing,run}`. -//! All HTTP traffic goes through extism-pdk's host-mediated http_request -//! (enforced by the manifest's allowed_hosts list). -#![allow(unsafe_code, reason = "extism-pdk host_fn imports require unsafe")] #![allow( clippy::pedantic, clippy::nursery, @@ -12,73 +9,17 @@ clippy::multiple_crate_versions, clippy::cargo_common_metadata, clippy::missing_errors_doc, - reason = "matches the test-fixtures allow-list; plugin authoring crate" + clippy::print_stdout, + clippy::print_stderr, + reason = "quick migration from plugin crate; polish later" )] +pub mod cli; + mod api; mod auth; -mod cli; mod config; mod creds; mod http; -mod output; mod state; mod verbs; - -use hm_plugin_sdk::*; - -#[derive(Default)] -struct Cloud; - -impl SubcommandPlugin for Cloud { - fn run(&self, input: SubcommandInput) -> Result { - // Parse argv inside the plugin. input.verb_path[0] is "cloud"; - // the rest is the nested verb + args. - let argv = input.verb_path.clone(); - cli::dispatch(argv, input.env) - } -} - -register_plugin!( - manifest = PluginManifest { - api_version: HM_PLUGIN_API_VERSION, - name: "harmont-cloud".into(), - version: semver::Version::new(0, 1, 0), - description: "Cloud client: login, whoami, org, pipeline, build, job, billing, run.".into(), - capabilities: vec![Capability::Subcommand(SubcommandSpec { - verb: "cloud".into(), - about: "Talk to the Harmont cloud API".into(), - args_schema: serde_json::json!({}), - subcommands: vec![], - })], - required_host_fns: vec![ - "hm_log".into(), - "hm_write_stdout".into(), - "hm_write_stderr".into(), - "hm_tty_prompt".into(), - "hm_tty_confirm".into(), - "hm_browser_open".into(), - "hm_spawn_loopback".into(), - "hm_loopback_recv".into(), - "hm_keyring_get".into(), - "hm_keyring_set".into(), - "hm_keyring_delete".into(), - "hm_kv_get".into(), - "hm_kv_set".into(), - "hm_should_cancel".into(), - ], - config_schema: None, - allowed_hosts: vec![ - "api.harmont.dev".into(), - "*.harmont.dev".into(), - // Test-only: wiremock binds 127.0.0.1 on a random port. - // extism's HTTP gate matches by host, not port, so adding - // these patterns lets integration tests target a local - // mock server via `HARMONT_API_URL=http://127.0.0.1:` - // without compromising the prod allowlist. - "127.0.0.1".into(), - "localhost".into(), - ], - }, - subcommand = Cloud, -); diff --git a/crates/hm-plugin-cloud/src/output.rs b/crates/hm-plugin-cloud/src/output.rs deleted file mode 100644 index 179adb7..0000000 --- a/crates/hm-plugin-cloud/src/output.rs +++ /dev/null @@ -1 +0,0 @@ -//! placeholder diff --git a/crates/hm-plugin-cloud/src/state.rs b/crates/hm-plugin-cloud/src/state.rs index 71561f3..a4b7081 100644 --- a/crates/hm-plugin-cloud/src/state.rs +++ b/crates/hm-plugin-cloud/src/state.rs @@ -1,10 +1,8 @@ -//! Persistent state (active organization slug) via the host's -//! `KvScope::Plugin` store. +//! Persistent state (active organization slug) via file storage. -use hm_plugin_sdk::{KvScope, host}; use serde::{Deserialize, Serialize}; -const STATE_KEY: &str = "state"; +const STATE_FILE: &str = "cloud-state.json"; #[derive(Debug, Default, Serialize, Deserialize)] pub(crate) struct CloudState { @@ -12,18 +10,25 @@ pub(crate) struct CloudState { } impl CloudState { - #[allow(dead_code, reason = "consumed by the org/run verbs in a later cluster")] pub(crate) fn load() -> Self { - let Some(bytes) = host::kv_get(KvScope::Plugin, STATE_KEY) else { + let Some(dir) = hm_util::dirs::harmont_config_dir() else { + return Self::default(); + }; + let path = dir.join(STATE_FILE); + let Ok(bytes) = std::fs::read(&path) else { return Self::default(); }; serde_json::from_slice(&bytes).unwrap_or_default() } - #[allow(dead_code, reason = "consumed by the org/run verbs in a later cluster")] pub(crate) fn save(&self) { - if let Ok(bytes) = serde_json::to_vec(self) { - host::kv_set(KvScope::Plugin, STATE_KEY, &bytes); + let Some(dir) = hm_util::dirs::harmont_config_dir() else { + return; + }; + let _ = std::fs::create_dir_all(&dir); + let path = dir.join(STATE_FILE); + if let Ok(bytes) = serde_json::to_vec_pretty(self) { + let _ = std::fs::write(&path, bytes); } } } diff --git a/crates/hm-plugin-cloud/src/verbs/billing.rs b/crates/hm-plugin-cloud/src/verbs/billing.rs index b7ad8e8..faba1e1 100644 --- a/crates/hm-plugin-cloud/src/verbs/billing.rs +++ b/crates/hm-plugin-cloud/src/verbs/billing.rs @@ -2,8 +2,7 @@ use std::collections::BTreeMap; -use hm_plugin_protocol::PluginError; -use hm_plugin_sdk::host; +use anyhow::Result; use crate::api::types::{ Balance, RedeemRequest, RedeemResponse, TopupRequest, TopupResponse, TransactionList, @@ -15,54 +14,60 @@ use crate::creds; use crate::http::Client; use crate::state::CloudState; -pub(crate) fn run(env: &BTreeMap, cmd: BillingCommand) -> Result<(), PluginError> { +pub(crate) async fn run(env: &BTreeMap, cmd: BillingCommand) -> Result<()> { let cfg = Config::from_env(env); - let token = creds::load_token(&cfg.api_base, env).ok_or_else(not_logged_in)?; + let token = creds::load_token(&cfg.api_base, env) + .ok_or_else(|| anyhow::anyhow!("not logged in; run `hm cloud login`"))?; let client = Client::new(&cfg, Some(token)); let org = active_org()?; match cmd { - BillingCommand::Balance => balance(&client, &org), - BillingCommand::Transactions { limit } => transactions(&client, &org, limit), - BillingCommand::Usage { from, to } => usage(&client, &org, from.as_deref(), to.as_deref()), + BillingCommand::Balance => balance(&client, &org).await, + BillingCommand::Transactions { limit } => transactions(&client, &org, limit).await, + BillingCommand::Usage { from, to } => { + usage(&client, &org, from.as_deref(), to.as_deref()).await + } BillingCommand::Topup { amount_usd, no_browser, - } => topup(&client, &org, amount_usd, no_browser), - BillingCommand::Redeem { code } => redeem(&client, &org, &code), + } => topup(&client, &org, amount_usd, no_browser).await, + BillingCommand::Redeem { code } => redeem(&client, &org, &code).await, } } -fn balance(client: &Client, org: &str) -> Result<(), PluginError> { - let b: Balance = client.get(&format!("/organizations/{org}/billing/balance"))?; +async fn balance(client: &Client, org: &str) -> Result<()> { + let b: Balance = client + .get(&format!("/organizations/{org}/billing/balance")) + .await?; let dollars = b.credits_usd_cents as f64 / 100.0; - host::write_stdout(format!("${dollars:.2}\n").as_bytes()); + println!("${dollars:.2}"); Ok(()) } -fn transactions(client: &Client, org: &str, limit: u32) -> Result<(), PluginError> { - let list: TransactionList = client.get(&format!( - "/organizations/{org}/billing/transactions?limit={limit}" - ))?; +async fn transactions(client: &Client, org: &str, limit: u32) -> Result<()> { + let list: TransactionList = client + .get(&format!( + "/organizations/{org}/billing/transactions?limit={limit}" + )) + .await?; for t in &list.data { - let line = format!( - "{} {:>10} {:<14} {}\n", + println!( + "{} {:>10} {:<14} {}", t.at.format("%Y-%m-%d %H:%M:%S"), t.amount_cents, t.kind, t.memo.as_deref().unwrap_or("") ); - host::write_stdout(line.as_bytes()); } Ok(()) } -fn usage( +async fn usage( client: &Client, org: &str, from: Option<&str>, to: Option<&str>, -) -> Result<(), PluginError> { +) -> Result<()> { let mut q = vec![]; if let Some(f) = from { q.push(format!("from={f}")); @@ -75,59 +80,55 @@ fn usage( } else { format!("?{}", q.join("&")) }; - let u: UsageWindow = client.get(&format!("/organizations/{org}/billing/usage{qs}"))?; - let line = format!( - "{} -> {}: {:.2} min, ${:.2}\n", + let u: UsageWindow = client + .get(&format!("/organizations/{org}/billing/usage{qs}")) + .await?; + println!( + "{} -> {}: {:.2} min, ${:.2}", u.from.format("%Y-%m-%d"), u.to.format("%Y-%m-%d"), u.minutes_used, u.cents_used as f64 / 100.0 ); - host::write_stdout(line.as_bytes()); Ok(()) } -fn topup(client: &Client, org: &str, amount_usd: u32, no_browser: bool) -> Result<(), PluginError> { - let r: TopupResponse = client.post( - &format!("/organizations/{org}/billing/topup"), - &TopupRequest { - org_slug: org.to_string(), - amount_cents: i64::from(amount_usd) * 100, - }, - )?; +async fn topup(client: &Client, org: &str, amount_usd: u32, no_browser: bool) -> Result<()> { + let r: TopupResponse = client + .post( + &format!("/organizations/{org}/billing/topup"), + &TopupRequest { + org_slug: org.to_string(), + amount_cents: i64::from(amount_usd) * 100, + }, + ) + .await?; if no_browser { - host::write_stdout(r.checkout_url.as_bytes()); - host::write_stdout(b"\n"); - } else if !host::browser_open(&r.checkout_url) { - host::write_stderr(b"couldn't open browser; URL:\n"); - host::write_stderr(r.checkout_url.as_bytes()); - host::write_stderr(b"\n"); + println!("{}", r.checkout_url); + } else if webbrowser::open(&r.checkout_url).is_err() { + eprintln!("couldn't open browser; URL:"); + eprintln!("{}", r.checkout_url); } Ok(()) } -fn redeem(client: &Client, org: &str, code: &str) -> Result<(), PluginError> { - let r: RedeemResponse = client.post( - &format!("/organizations/{org}/billing/redeem"), - &RedeemRequest { - org_slug: org.to_string(), - code: code.to_string(), - }, - )?; +async fn redeem(client: &Client, org: &str, code: &str) -> Result<()> { + let r: RedeemResponse = client + .post( + &format!("/organizations/{org}/billing/redeem"), + &RedeemRequest { + org_slug: org.to_string(), + code: code.to_string(), + }, + ) + .await?; let dollars = r.credited_cents as f64 / 100.0; - host::write_stderr(format!("credited ${dollars:.2}\n").as_bytes()); + eprintln!("credited ${dollars:.2}"); Ok(()) } -fn not_logged_in() -> PluginError { - PluginError::new("cloud_not_logged_in", "not logged in; run `hm cloud login`") -} - -fn active_org() -> Result { +fn active_org() -> Result { CloudState::load().active_org.ok_or_else(|| { - PluginError::new( - "cloud_no_active_org", - "no active organization; run `hm cloud org switch `", - ) + anyhow::anyhow!("no active organization; run `hm cloud org switch `") }) } diff --git a/crates/hm-plugin-cloud/src/verbs/build.rs b/crates/hm-plugin-cloud/src/verbs/build.rs index 5fabc54..8797b69 100644 --- a/crates/hm-plugin-cloud/src/verbs/build.rs +++ b/crates/hm-plugin-cloud/src/verbs/build.rs @@ -2,8 +2,7 @@ use std::collections::BTreeMap; -use hm_plugin_protocol::PluginError; -use hm_plugin_sdk::host; +use anyhow::{Result, bail}; use crate::api::types::{Build, BuildList}; use crate::cli::BuildCommand; @@ -12,106 +11,87 @@ use crate::creds; use crate::http::Client; use crate::state::CloudState; -pub(crate) fn run(env: &BTreeMap, cmd: BuildCommand) -> Result<(), PluginError> { +pub(crate) async fn run(env: &BTreeMap, cmd: BuildCommand) -> Result<()> { let cfg = Config::from_env(env); - let token = creds::load_token(&cfg.api_base, env).ok_or_else(not_logged_in)?; + let token = creds::load_token(&cfg.api_base, env) + .ok_or_else(|| anyhow::anyhow!("not logged in; run `hm cloud login`"))?; let client = Client::new(&cfg, Some(token)); let org = active_org()?; match cmd { - BuildCommand::List { pipeline } => list(&client, &org, &pipeline), - BuildCommand::Show { pipeline, number } => show(&client, &org, &pipeline, number), - BuildCommand::Cancel { pipeline, number } => cancel(&client, &org, &pipeline, number), - BuildCommand::Watch { pipeline, number } => watch(&client, &org, &pipeline, number), + BuildCommand::List { pipeline } => list(&client, &org, &pipeline).await, + BuildCommand::Show { pipeline, number } => show(&client, &org, &pipeline, number).await, + BuildCommand::Cancel { pipeline, number } => { + cancel(&client, &org, &pipeline, number).await + } + BuildCommand::Watch { pipeline, number } => { + watch(&client, &org, &pipeline, number).await + } } } -fn list(client: &Client, org: &str, pipe: &str) -> Result<(), PluginError> { - let builds: BuildList = client.get(&format!("/organizations/{org}/pipelines/{pipe}/builds"))?; +async fn list(client: &Client, org: &str, pipe: &str) -> Result<()> { + let builds: BuildList = client + .get(&format!("/organizations/{org}/pipelines/{pipe}/builds")) + .await?; for b in &builds.data { - let line = format!( - "#{:<5} {:<10} {}\n", + println!( + "#{:<5} {:<10} {}", b.number, b.state, b.message.as_deref().unwrap_or("") ); - host::write_stdout(line.as_bytes()); } Ok(()) } -fn show(client: &Client, org: &str, pipe: &str, number: i64) -> Result<(), PluginError> { - let b: Build = client.get(&format!( - "/organizations/{org}/pipelines/{pipe}/builds/{number}" - ))?; +async fn show(client: &Client, org: &str, pipe: &str, number: i64) -> Result<()> { + let b: Build = client + .get(&format!( + "/organizations/{org}/pipelines/{pipe}/builds/{number}" + )) + .await?; let json = serde_json::to_string_pretty(&b).unwrap_or_default(); - host::write_stdout(json.as_bytes()); - host::write_stdout(b"\n"); + println!("{json}"); Ok(()) } -fn cancel(client: &Client, org: &str, pipe: &str, number: i64) -> Result<(), PluginError> { - let _: serde_json::Value = client.post( - &format!("/organizations/{org}/pipelines/{pipe}/builds/{number}/cancel"), - &serde_json::json!({}), - )?; - host::write_stderr(format!("build #{number} cancelled\n").as_bytes()); +async fn cancel(client: &Client, org: &str, pipe: &str, number: i64) -> Result<()> { + let _: serde_json::Value = client + .post( + &format!("/organizations/{org}/pipelines/{pipe}/builds/{number}/cancel"), + &serde_json::json!({}), + ) + .await?; + eprintln!("build #{number} cancelled"); Ok(()) } -fn watch(client: &Client, org: &str, pipe: &str, number: i64) -> Result<(), PluginError> { - // Poll the build's state every 2 seconds; print state transitions - // to stderr. Exit when terminal (passed/failed/canceled). - // - // TODO(plan-5+): replace this busy-wait with an `hm_sleep_ms` host - // fn. WASM has no native sleep, so for now we spin while polling - // `host::should_cancel`. Crude but adequate for short intervals. +async fn watch(client: &Client, org: &str, pipe: &str, number: i64) -> Result<()> { let mut last_state = String::new(); loop { - if host::should_cancel() { - return Err(PluginError::new( - "cloud_cancelled", - "watch cancelled by user", - )); - } - let b: Build = client.get(&format!( - "/organizations/{org}/pipelines/{pipe}/builds/{number}" - ))?; + let b: Build = client + .get(&format!( + "/organizations/{org}/pipelines/{pipe}/builds/{number}" + )) + .await?; if b.state != last_state { - host::write_stderr(format!("state: {last_state} -> {}\n", b.state).as_bytes()); + eprintln!("state: {last_state} -> {}", b.state); last_state = b.state.clone(); } match b.state.as_str() { "passed" => return Ok(()), "failed" | "canceled" => { - return Err(PluginError::new( - "cloud_build_failed", - format!("build {} ({})", b.state, number), - )); + bail!("build {} ({})", b.state, number); } _ => {} } - let start = std::time::SystemTime::now(); - while start.elapsed().map(|d| d.as_secs() < 2).unwrap_or(true) { - if host::should_cancel() { - return Err(PluginError::new( - "cloud_cancelled", - "watch cancelled by user", - )); - } - } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; } } -fn not_logged_in() -> PluginError { - PluginError::new("cloud_not_logged_in", "not logged in; run `hm cloud login`") -} - -fn active_org() -> Result { +fn active_org() -> Result { CloudState::load().active_org.ok_or_else(|| { - PluginError::new( - "cloud_no_active_org", - "no active organization; run `hm cloud org switch `", - ) + anyhow::anyhow!("no active organization; run `hm cloud org switch `") }) } diff --git a/crates/hm-plugin-cloud/src/verbs/job.rs b/crates/hm-plugin-cloud/src/verbs/job.rs index c3b0d3f..c1d33d6 100644 --- a/crates/hm-plugin-cloud/src/verbs/job.rs +++ b/crates/hm-plugin-cloud/src/verbs/job.rs @@ -2,8 +2,7 @@ use std::collections::BTreeMap; -use hm_plugin_protocol::PluginError; -use hm_plugin_sdk::host; +use anyhow::Result; use crate::api::types::{Job, JobList, JobLog}; use crate::cli::JobCommand; @@ -12,76 +11,84 @@ use crate::creds; use crate::http::Client; use crate::state::CloudState; -pub(crate) fn run(env: &BTreeMap, cmd: JobCommand) -> Result<(), PluginError> { +pub(crate) async fn run(env: &BTreeMap, cmd: JobCommand) -> Result<()> { let cfg = Config::from_env(env); - let token = creds::load_token(&cfg.api_base, env).ok_or_else(not_logged_in)?; + let token = creds::load_token(&cfg.api_base, env) + .ok_or_else(|| anyhow::anyhow!("not logged in; run `hm cloud login`"))?; let client = Client::new(&cfg, Some(token)); let org = active_org()?; match cmd { - JobCommand::List { pipeline, build } => list(&client, &org, &pipeline, build), + JobCommand::List { pipeline, build } => list(&client, &org, &pipeline, build).await, JobCommand::Show { pipeline, build, job_id, - } => show(&client, &org, &pipeline, build, &job_id), + } => show(&client, &org, &pipeline, build, &job_id).await, JobCommand::Log { pipeline, build, job_id, - } => log(&client, &org, &pipeline, build, &job_id), + } => log_cmd(&client, &org, &pipeline, build, &job_id).await, } } -fn list(client: &Client, org: &str, pipe: &str, build: i64) -> Result<(), PluginError> { - let jobs: JobList = client.get(&format!( - "/organizations/{org}/pipelines/{pipe}/builds/{build}/jobs" - ))?; +async fn list(client: &Client, org: &str, pipe: &str, build: i64) -> Result<()> { + let jobs: JobList = client + .get(&format!( + "/organizations/{org}/pipelines/{pipe}/builds/{build}/jobs" + )) + .await?; for j in &jobs.data { - let line = format!( - "{} {:<10} {}\n", + println!( + "{} {:<10} {}", j.id, j.state, j.label.as_deref().unwrap_or("") ); - host::write_stdout(line.as_bytes()); } Ok(()) } -fn show(client: &Client, org: &str, pipe: &str, build: i64, jid: &str) -> Result<(), PluginError> { - let j: Job = client.get(&format!( - "/organizations/{org}/pipelines/{pipe}/builds/{build}/jobs/{jid}" - ))?; - host::write_stdout( - serde_json::to_string_pretty(&j) - .unwrap_or_default() - .as_bytes(), +async fn show( + client: &Client, + org: &str, + pipe: &str, + build: i64, + jid: &str, +) -> Result<()> { + let j: Job = client + .get(&format!( + "/organizations/{org}/pipelines/{pipe}/builds/{build}/jobs/{jid}" + )) + .await?; + println!( + "{}", + serde_json::to_string_pretty(&j).unwrap_or_default() ); - host::write_stdout(b"\n"); Ok(()) } -fn log(client: &Client, org: &str, pipe: &str, build: i64, jid: &str) -> Result<(), PluginError> { - let log: JobLog = client.get(&format!( - "/organizations/{org}/pipelines/{pipe}/builds/{build}/jobs/{jid}/log" - ))?; +async fn log_cmd( + client: &Client, + org: &str, + pipe: &str, + build: i64, + jid: &str, +) -> Result<()> { + let log: JobLog = client + .get(&format!( + "/organizations/{org}/pipelines/{pipe}/builds/{build}/jobs/{jid}/log" + )) + .await?; for chunk in &log.data { - host::write_stdout(chunk.line.as_bytes()); - host::write_stdout(b"\n"); + println!("{}", chunk.line); } Ok(()) } -fn not_logged_in() -> PluginError { - PluginError::new("cloud_not_logged_in", "not logged in; run `hm cloud login`") -} - -fn active_org() -> Result { +fn active_org() -> Result { CloudState::load().active_org.ok_or_else(|| { - PluginError::new( - "cloud_no_active_org", - "no active organization; run `hm cloud org switch `", - ) + anyhow::anyhow!("no active organization; run `hm cloud org switch `") }) } diff --git a/crates/hm-plugin-cloud/src/verbs/org.rs b/crates/hm-plugin-cloud/src/verbs/org.rs index fb54abd..b3e70da 100644 --- a/crates/hm-plugin-cloud/src/verbs/org.rs +++ b/crates/hm-plugin-cloud/src/verbs/org.rs @@ -2,8 +2,7 @@ use std::collections::BTreeMap; -use hm_plugin_protocol::PluginError; -use hm_plugin_sdk::host; +use anyhow::Result; use crate::api::types::OrganizationList; use crate::cli::OrgCommand; @@ -12,33 +11,25 @@ use crate::creds; use crate::http::Client; use crate::state::CloudState; -pub(crate) fn run(env: &BTreeMap, cmd: OrgCommand) -> Result<(), PluginError> { +pub(crate) async fn run(env: &BTreeMap, cmd: OrgCommand) -> Result<()> { let cfg = Config::from_env(env); - let token = creds::load_token(&cfg.api_base, env).ok_or_else(not_logged_in)?; + let token = creds::load_token(&cfg.api_base, env) + .ok_or_else(|| anyhow::anyhow!("not logged in; run `hm cloud login`"))?; let client = Client::new(&cfg, Some(token)); match cmd { - OrgCommand::Switch { slug } => switch(&client, &slug), + OrgCommand::Switch { slug } => switch(&client, &slug).await, } } -fn switch(client: &Client, slug: &str) -> Result<(), PluginError> { - let orgs: OrganizationList = client.get("/organizations")?; +async fn switch(client: &Client, slug: &str) -> Result<()> { + let orgs: OrganizationList = client.get("/organizations").await?; let found = orgs.data.iter().find(|o| o.slug == slug).ok_or_else(|| { - PluginError::new( - "cloud_org_not_found", - format!("no organization with slug '{slug}'"), - ) + anyhow::anyhow!("no organization with slug '{slug}'") })?; let mut state = CloudState::load(); state.active_org = Some(found.slug.clone()); state.save(); - host::write_stderr( - format!("active organization: {} ({})\n", found.name, found.slug).as_bytes(), - ); + eprintln!("active organization: {} ({})", found.name, found.slug); Ok(()) } - -fn not_logged_in() -> PluginError { - PluginError::new("cloud_not_logged_in", "not logged in; run `hm cloud login`") -} diff --git a/crates/hm-plugin-cloud/src/verbs/pipeline.rs b/crates/hm-plugin-cloud/src/verbs/pipeline.rs index bb134dd..d152737 100644 --- a/crates/hm-plugin-cloud/src/verbs/pipeline.rs +++ b/crates/hm-plugin-cloud/src/verbs/pipeline.rs @@ -2,8 +2,7 @@ use std::collections::BTreeMap; -use hm_plugin_protocol::PluginError; -use hm_plugin_sdk::host; +use anyhow::Result; use crate::api::types::{Pipeline, PipelineList}; use crate::cli::PipelineCommand; @@ -12,33 +11,35 @@ use crate::creds; use crate::http::Client; use crate::state::CloudState; -pub(crate) fn run(env: &BTreeMap, cmd: PipelineCommand) -> Result<(), PluginError> { +pub(crate) async fn run(env: &BTreeMap, cmd: PipelineCommand) -> Result<()> { let cfg = Config::from_env(env); - let token = creds::load_token(&cfg.api_base, env).ok_or_else(not_logged_in)?; + let token = creds::load_token(&cfg.api_base, env) + .ok_or_else(|| anyhow::anyhow!("not logged in; run `hm cloud login`"))?; let client = Client::new(&cfg, Some(token)); let org = active_org()?; match cmd { - PipelineCommand::List => list(&client, &org), - PipelineCommand::Show { slug } => show(&client, &org, &slug), + PipelineCommand::List => list(&client, &org).await, + PipelineCommand::Show { slug } => show(&client, &org, &slug).await, } } -fn list(client: &Client, org: &str) -> Result<(), PluginError> { - let pipes: PipelineList = client.get(&format!("/organizations/{org}/pipelines"))?; +async fn list(client: &Client, org: &str) -> Result<()> { + let pipes: PipelineList = client.get(&format!("/organizations/{org}/pipelines")).await?; for p in &pipes.data { - let line = format!( - "{:<24} {}\n", + println!( + "{:<24} {}", p.slug, p.label.as_deref().unwrap_or("(no label)") ); - host::write_stdout(line.as_bytes()); } Ok(()) } -fn show(client: &Client, org: &str, slug: &str) -> Result<(), PluginError> { - let p: Pipeline = client.get(&format!("/organizations/{org}/pipelines/{slug}"))?; +async fn show(client: &Client, org: &str, slug: &str) -> Result<()> { + let p: Pipeline = client + .get(&format!("/organizations/{org}/pipelines/{slug}")) + .await?; let json = serde_json::to_string_pretty(&serde_json::json!({ "id": p.id, "slug": p.slug, @@ -46,20 +47,12 @@ fn show(client: &Client, org: &str, slug: &str) -> Result<(), PluginError> { "default_branch": p.default_branch, })) .unwrap_or_default(); - host::write_stdout(json.as_bytes()); - host::write_stdout(b"\n"); + println!("{json}"); Ok(()) } -fn active_org() -> Result { +fn active_org() -> Result { CloudState::load().active_org.ok_or_else(|| { - PluginError::new( - "cloud_no_active_org", - "no active organization; run `hm cloud org switch `", - ) + anyhow::anyhow!("no active organization; run `hm cloud org switch `") }) } - -fn not_logged_in() -> PluginError { - PluginError::new("cloud_not_logged_in", "not logged in; run `hm cloud login`") -} diff --git a/crates/hm-plugin-cloud/src/verbs/run.rs b/crates/hm-plugin-cloud/src/verbs/run.rs index efbcc12..2b703e1 100644 --- a/crates/hm-plugin-cloud/src/verbs/run.rs +++ b/crates/hm-plugin-cloud/src/verbs/run.rs @@ -2,14 +2,13 @@ //! and watch the resulting build. //! //! For plan 4 the caller supplies a pre-rendered plan JSON via -//! `--plan-file` (or `.harmont/plan.json` by convention). Source-archive +//! `--plan-file` (or `plan.json` by convention). Source-archive //! upload — required by the live API — lands in plan 5. use std::collections::BTreeMap; +use anyhow::Result; use clap::Parser; -use hm_plugin_protocol::PluginError; -use hm_plugin_sdk::host; use crate::api::types::{Build, CreateBuildRequest}; use crate::config::Config; @@ -17,8 +16,8 @@ use crate::creds; use crate::http::Client; use crate::state::CloudState; -#[derive(Debug, Parser)] -pub(crate) struct RunArgs { +#[derive(Debug, Clone, Parser)] +pub struct RunArgs { /// Pipeline slug. Required. pub pipeline: String, /// Branch to record on the build. @@ -28,7 +27,7 @@ pub(crate) struct RunArgs { #[arg(short, long)] pub message: Option, /// Path to a pre-rendered pipeline JSON file. - /// If unset, the plugin reads `.harmont/plan.json`. + /// If unset, reads `plan.json`. #[arg(long)] pub plan_file: Option, /// Don't watch; print the build URL and exit. @@ -36,31 +35,21 @@ pub(crate) struct RunArgs { pub no_watch: bool, } -pub(crate) fn run(env: &BTreeMap, args: RunArgs) -> Result<(), PluginError> { +pub(crate) async fn run(env: &BTreeMap, args: RunArgs) -> Result<()> { let cfg = Config::from_env(env); - let token = creds::load_token(&cfg.api_base, env).ok_or_else(|| { - PluginError::new("cloud_not_logged_in", "not logged in; run `hm cloud login`") - })?; + let token = creds::load_token(&cfg.api_base, env) + .ok_or_else(|| anyhow::anyhow!("not logged in; run `hm cloud login`"))?; let client = Client::new(&cfg, Some(token)); let org = CloudState::load().active_org.ok_or_else(|| { - PluginError::new( - "cloud_no_active_org", - "no active organization; run `hm cloud org switch `", - ) + anyhow::anyhow!("no active organization; run `hm cloud org switch `") })?; - // Read the pipeline plan. plan-4 has no in-plugin renderer; the - // host's existing rendering pipeline (or the user) is responsible - // for materialising the JSON. + // Read the pipeline plan. let plan_path = args.plan_file.as_deref().unwrap_or("plan.json"); - let bytes = host::fs_read_config(plan_path).ok_or_else(|| { - PluginError::new( - "cloud_plan_missing", - format!("could not read plan file '{plan_path}'; render the plan first"), - ) - })?; + let bytes = std::fs::read(plan_path) + .map_err(|e| anyhow::anyhow!("could not read plan file '{plan_path}': {e}"))?; let plan_json: serde_json::Value = serde_json::from_slice(&bytes) - .map_err(|e| PluginError::new("cloud_plan_invalid_json", e.to_string()))?; + .map_err(|e| anyhow::anyhow!("invalid JSON in plan file '{plan_path}': {e}"))?; let req = CreateBuildRequest { pipeline_slug: args.pipeline.clone(), @@ -73,10 +62,12 @@ pub(crate) fn run(env: &BTreeMap, args: RunArgs) -> Result<(), P .collect(), plan_json, }; - let build: Build = client.post( - &format!("/organizations/{org}/pipelines/{}/builds", args.pipeline), - &req, - )?; + let build: Build = client + .post( + &format!("/organizations/{org}/pipelines/{}/builds", args.pipeline), + &req, + ) + .await?; let url = format!( "{}/{}/{}/builds/{}", cfg.api_base.trim_end_matches("/api"), @@ -84,11 +75,11 @@ pub(crate) fn run(env: &BTreeMap, args: RunArgs) -> Result<(), P args.pipeline, build.number ); - host::write_stderr(format!("submitted build #{}: {url}\n", build.number).as_bytes()); + eprintln!("submitted build #{}: {url}", build.number); if args.no_watch { return Ok(()); } - // Watch loop: same shape as verbs::build::watch. + // Watch loop: reuse build::run. crate::verbs::build::run( env, crate::cli::BuildCommand::Watch { @@ -96,4 +87,5 @@ pub(crate) fn run(env: &BTreeMap, args: RunArgs) -> Result<(), P number: build.number, }, ) + .await } diff --git a/crates/hm-plugin-docker/Cargo.toml b/crates/hm-plugin-docker/Cargo.toml deleted file mode 100644 index ab1995a..0000000 --- a/crates/hm-plugin-docker/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "hm-plugin-docker" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Built-in Docker step-executor plugin for the hm CLI." -publish = false - -[lib] -crate-type = ["cdylib"] -path = "src/lib.rs" - -[dependencies] -hm-plugin-sdk = { workspace = true } -hm-plugin-protocol = { workspace = true } -extism-pdk = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -semver = { workspace = true } - -[lints] -workspace = true diff --git a/crates/hm-plugin-docker/src/decision.rs b/crates/hm-plugin-docker/src/decision.rs deleted file mode 100644 index f02fdb3..0000000 --- a/crates/hm-plugin-docker/src/decision.rs +++ /dev/null @@ -1,32 +0,0 @@ -//! Honor a CacheDecision. Returns whether the plugin should run the -//! step's command (false ⇒ cache hit, just record). - -use hm_plugin_protocol::{CacheDecision, SnapshotRef}; - -#[derive(Debug, Clone)] -pub(crate) struct DecisionPlan { - pub(crate) run_command: bool, - pub(crate) commit_to: Option, - pub(crate) hit_tag: Option, -} - -#[must_use] -pub(crate) fn plan(decision: &CacheDecision) -> DecisionPlan { - match decision { - CacheDecision::Hit { tag } => DecisionPlan { - run_command: false, - commit_to: None, - hit_tag: Some(tag.clone()), - }, - CacheDecision::MissBuildAs { tag } => DecisionPlan { - run_command: true, - commit_to: Some(tag.clone()), - hit_tag: None, - }, - CacheDecision::MissNoCommit => DecisionPlan { - run_command: true, - commit_to: None, - hit_tag: None, - }, - } -} diff --git a/crates/hm-plugin-docker/src/extism_host.rs b/crates/hm-plugin-docker/src/extism_host.rs deleted file mode 100644 index 49f6252..0000000 --- a/crates/hm-plugin-docker/src/extism_host.rs +++ /dev/null @@ -1,61 +0,0 @@ -//! Raw `host_fn!` imports for the `hm_docker_*` host fns. The -//! generic host fns from `hm_plugin_sdk::host` cover everything -//! else; this module covers the docker-specific surface. - -use extism_pdk::*; -use hm_plugin_protocol::{DockerCommitArgs, DockerExecArgs, DockerExtractArgs, DockerStartArgs}; - -#[host_fn] -extern "ExtismHost" { - pub fn hm_docker_ping() -> u32; - pub fn hm_docker_image_exists(tag: String) -> u32; - pub fn hm_docker_pull(tag: String); - pub fn hm_docker_start_container(args: Json) -> String; - pub fn hm_docker_extract_workspace(args: Json); - pub fn hm_docker_exec(args: Json) -> i32; - pub fn hm_docker_commit(args: Json) -> String; - pub fn hm_docker_remove_image(tag: String); - pub fn hm_docker_stop_remove(container_id: String); -} - -// Safe wrappers. - -#[allow(dead_code, reason = "host fn surface; not used by run_step yet")] -pub(crate) fn ping() -> bool { - unsafe { hm_docker_ping() }.map(|n| n != 0).unwrap_or(false) -} - -pub(crate) fn image_exists(tag: &str) -> bool { - unsafe { hm_docker_image_exists(tag.to_string()) } - .map(|n| n != 0) - .unwrap_or(false) -} - -pub(crate) fn pull(tag: &str) -> Result<(), Error> { - unsafe { hm_docker_pull(tag.to_string()) } -} - -pub(crate) fn start_container(args: DockerStartArgs) -> Result { - unsafe { hm_docker_start_container(Json(args)) } -} - -pub(crate) fn extract_workspace(args: DockerExtractArgs) -> Result<(), Error> { - unsafe { hm_docker_extract_workspace(Json(args)) } -} - -pub(crate) fn exec(args: DockerExecArgs) -> Result { - unsafe { hm_docker_exec(Json(args)) } -} - -pub(crate) fn commit(args: DockerCommitArgs) -> Result { - unsafe { hm_docker_commit(Json(args)) } -} - -#[allow(dead_code, reason = "host fn surface; not used by run_step yet")] -pub(crate) fn remove_image(tag: &str) { - let _ = unsafe { hm_docker_remove_image(tag.to_string()) }; -} - -pub(crate) fn stop_remove(container_id: &str) { - let _ = unsafe { hm_docker_stop_remove(container_id.to_string()) }; -} diff --git a/crates/hm-plugin-docker/src/image_name.rs b/crates/hm-plugin-docker/src/image_name.rs deleted file mode 100644 index 51de9af..0000000 --- a/crates/hm-plugin-docker/src/image_name.rs +++ /dev/null @@ -1,83 +0,0 @@ -//! Resolve which Docker image a step should boot from. - -use hm_plugin_protocol::{CommandStep, SnapshotRef}; - -/// Pick the base image for a step at boot time. -/// -/// Priority (high → low): -/// 1. Cache `hit_tag` — the host already located a satisfying -/// snapshot; boot from it. -/// 2. `parent_snapshot` — the previous step in this chain (or the -/// fork parent) committed a snapshot; chain-lineage requires we -/// boot from it so filesystem mutations propagate downstream. -/// 3. The step's `image` field. -/// 4. Fall back to `"alpine:latest"`. Root steps that want a -/// different default are tagged with `step.image = default_image` -/// by the host before dispatch (see -/// `orchestrator::graph::Graph::build`), so the plugin only -/// reaches the alpine fallback when both the pipeline and step -/// omit an image — a misconfiguration we surface rather than -/// paper over. -#[must_use] -pub(crate) fn resolve_image( - step: &CommandStep, - hit_tag: Option<&SnapshotRef>, - parent_snapshot: Option<&SnapshotRef>, -) -> String { - if let Some(tag) = hit_tag { - return tag.to_string(); - } - if let Some(snap) = parent_snapshot { - return snap.to_string(); - } - if let Some(image) = &step.image { - return image.clone(); - } - "alpine:latest".to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn step_with_image(image: Option<&str>) -> CommandStep { - CommandStep { - key: "k".into(), - label: None, - cmd: "true".into(), - image: image.map(String::from), - env: None, - timeout_seconds: None, - cache: None, - runner: None, - runner_args: None, - } - } - - #[test] - fn hit_tag_wins() { - let s = step_with_image(Some("rust:1.82")); - let hit = SnapshotRef("cache:tag".into()); - let parent = SnapshotRef("parent:tag".into()); - assert_eq!(resolve_image(&s, Some(&hit), Some(&parent)), "cache:tag"); - } - - #[test] - fn parent_snapshot_beats_step_image() { - let s = step_with_image(Some("rust:1.82")); - let parent = SnapshotRef("parent:tag".into()); - assert_eq!(resolve_image(&s, None, Some(&parent)), "parent:tag"); - } - - #[test] - fn step_image_otherwise() { - let s = step_with_image(Some("rust:1.82")); - assert_eq!(resolve_image(&s, None, None), "rust:1.82"); - } - - #[test] - fn fallback_alpine_when_unset() { - let s = step_with_image(None); - assert_eq!(resolve_image(&s, None, None), "alpine:latest"); - } -} diff --git a/crates/hm-plugin-docker/src/lib.rs b/crates/hm-plugin-docker/src/lib.rs deleted file mode 100644 index 43fcbbc..0000000 --- a/crates/hm-plugin-docker/src/lib.rs +++ /dev/null @@ -1,201 +0,0 @@ -//! Built-in Docker step-executor plugin for the hm CLI. -//! -//! The host registers this plugin embedded (via `include_bytes!`) and -//! dispatches every `CommandStep` whose `runner` is `None` or -//! `"docker"` to it. - -#![allow(unsafe_code, reason = "extism-pdk host_fn imports require unsafe")] -#![allow( - clippy::pedantic, - clippy::nursery, - clippy::cargo, - clippy::multiple_crate_versions, - clippy::cargo_common_metadata, - clippy::missing_errors_doc, - reason = "matches the test-fixtures allow-list; plugin authoring crate" -)] - -use hm_plugin_sdk::*; - -mod decision; -mod extism_host; -mod image_name; - -#[derive(Default)] -struct DockerExec; - -impl StepExecutor for DockerExec { - fn run(&self, input: ExecutorInput) -> Result { - run_step(input) - } -} - -fn run_step(input: ExecutorInput) -> Result { - use crate::decision::plan; - use crate::extism_host as host; - use crate::image_name::resolve_image; - use hm_plugin_protocol::{ - DockerCommitArgs, DockerExecArgs, DockerExtractArgs, DockerStartArgs, - }; - - let plan = plan(&input.cache_lookup); - - // Cache hit shortcut: no container, no exec; we still hand back - // the hit tag so chain-downstream steps can boot from it. - if !plan.run_command { - return Ok(StepResult { - exit_code: 0, - committed_snapshot: plan.hit_tag.clone(), - artifacts: vec![], - }); - } - - let image = resolve_image( - &input.step, - plan.hit_tag.as_ref(), - input.parent_snapshot.as_ref(), - ); - let container_name = sanitize_container_name(&input.run_id.to_string(), &input.step.key); - - // Ensure the image is locally available — pull if needed. - if !host::image_exists(&image) { - host::pull(&image) - .map_err(|e| PluginError::new("docker_pull_failed", format!("pull '{image}': {e}")))?; - } - - let cid = host::start_container(DockerStartArgs { - image: image.clone(), - env: input.env.clone(), - workdir: input.workdir.clone(), - name_hint: container_name, - }) - .map_err(|e| PluginError::new("docker_start_failed", e.to_string()))?; - - // RAII-equivalent cleanup tracker. We don't have Drop in WASM - // host-fn land (panics there aren't recoverable cleanly), so use - // an explicit cleanup helper at every early-return. - macro_rules! cleanup_and_return { - ($result:expr) => {{ - host::stop_remove(&cid); - return $result; - }}; - } - - // Extract the user's source archive onto /workspace. - if let Err(e) = host::extract_workspace(DockerExtractArgs { - container_id: cid.clone(), - archive_id: input.workspace_archive_id, - workdir: input.workdir.clone(), - }) { - cleanup_and_return!(Err(PluginError::new( - "docker_extract_failed", - e.to_string() - ))); - } - - // Exec the step's command. Logs stream live into the event bus - // via the host's StepLogWriter — the plugin only sees the exit - // code. - let exit_code = match host::exec(DockerExecArgs { - container_id: cid.clone(), - cmd: vec!["sh".into(), "-c".into(), input.step.cmd.clone()], - env: input.env.clone(), - workdir: input.workdir.clone(), - stdin_archive_id: None, - }) { - Ok(rc) => rc, - Err(e) => cleanup_and_return!(Err(PluginError::new("docker_exec_failed", e.to_string()))), - }; - - // Always commit on success — under the new orchestrator the - // scheduler threads the committed snapshot to the next step in - // the chain (and to fork children). If the host already chose a - // tag (cache-build path), use it; otherwise mint an ephemeral - // tag scoped by step_id so concurrent / replayed runs don't - // collide. - let committed = if exit_code == 0 { - let target_tag = plan.commit_to.clone().unwrap_or_else(|| { - let safe: String = input - .step - .key - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '_' || c == '-' { - c - } else { - '-' - } - }) - .collect(); - hm_plugin_protocol::SnapshotRef::from(format!( - "harmont-local-ephemeral/{safe}:run-{}", - input.step_id.simple() - )) - }); - match host::commit(DockerCommitArgs { - container_id: cid.clone(), - tag: target_tag.to_string(), - }) { - Ok(_) => Some(target_tag), - Err(e) => { - cleanup_and_return!(Err(PluginError::new("docker_commit_failed", e.to_string()))) - } - } - } else { - None - }; - - host::stop_remove(&cid); - - Ok(StepResult { - exit_code, - committed_snapshot: committed, - artifacts: vec![], - }) -} - -fn sanitize_container_name(run_id: &str, step_key: &str) -> String { - let run_short: String = run_id.chars().take(8).collect(); - let key: String = step_key - .chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '_' || c == '-' { - c - } else { - '-' - } - }) - .collect(); - format!("harmont-{run_short}-{key}") -} - -register_plugin!( - manifest = PluginManifest { - api_version: HM_PLUGIN_API_VERSION, - name: "harmont-docker".into(), - version: semver::Version::new(0, 1, 0), - description: "Docker step executor (default runner).".into(), - capabilities: vec![Capability::StepExecutor(StepExecutorSpec { - runner: "docker".into(), - default: true, - step_schema: None, - })], - required_host_fns: vec![ - "hm_log".into(), - "hm_emit_step_log".into(), - "hm_should_cancel".into(), - "hm_docker_ping".into(), - "hm_docker_image_exists".into(), - "hm_docker_pull".into(), - "hm_docker_start_container".into(), - "hm_docker_extract_workspace".into(), - "hm_docker_exec".into(), - "hm_docker_commit".into(), - "hm_docker_remove_image".into(), - "hm_docker_stop_remove".into(), - ], - config_schema: None, - allowed_hosts: vec![], - }, - executor = DockerExec, -); diff --git a/crates/hm-plugin-output-human/Cargo.toml b/crates/hm-plugin-output-human/Cargo.toml deleted file mode 100644 index a500055..0000000 --- a/crates/hm-plugin-output-human/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -name = "hm-plugin-output-human" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Built-in human-readable output formatter for the hm CLI." -publish = false - -[lib] -crate-type = ["cdylib"] -path = "src/lib.rs" - -[dependencies] -hm-plugin-sdk = { workspace = true } -hm-plugin-protocol = { workspace = true } -extism-pdk = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -semver = { workspace = true } -uuid = { workspace = true } -chrono = { workspace = true } - -[lints] -workspace = true diff --git a/crates/hm-plugin-output-human/src/lib.rs b/crates/hm-plugin-output-human/src/lib.rs deleted file mode 100644 index 5168e4f..0000000 --- a/crates/hm-plugin-output-human/src/lib.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! Built-in human-readable output formatter for the hm CLI. -//! -//! Subscribes to the orchestrator's BuildEvent stream via the -//! `hm_output_on_event` capability export; writes prefixed step logs -//! and brief status lines to stderr. - -#![allow(unsafe_code, reason = "extism-pdk host_fn imports require unsafe")] -#![allow( - clippy::pedantic, - clippy::nursery, - clippy::cargo, - clippy::multiple_crate_versions, - clippy::cargo_common_metadata, - clippy::missing_errors_doc, - reason = "matches the test-fixtures allow-list; plugin authoring crate" -)] - -mod render; - -use hm_plugin_sdk::*; - -#[derive(Default)] -struct Human; - -impl OutputFormatter for Human { - fn on_event(&self, event: BuildEvent) -> Result<(), PluginError> { - let bytes = render::render(&event); - if !bytes.is_empty() { - host::write_stderr(&bytes); - } - Ok(()) - } -} - -register_plugin!( - manifest = PluginManifest { - api_version: HM_PLUGIN_API_VERSION, - name: "harmont-output-human".into(), - version: semver::Version::new(0, 1, 0), - description: "Human-readable build output formatter.".into(), - capabilities: vec![Capability::OutputFormatter(OutputFormatterSpec { - name: "human".into(), - mime: "text/plain".into(), - })], - required_host_fns: vec!["hm_write_stderr".into()], - config_schema: None, - allowed_hosts: vec![], - }, - output = Human, -); diff --git a/crates/hm-plugin-output-human/src/render.rs b/crates/hm-plugin-output-human/src/render.rs deleted file mode 100644 index 8d869d0..0000000 --- a/crates/hm-plugin-output-human/src/render.rs +++ /dev/null @@ -1,147 +0,0 @@ -//! Pure-function rendering of BuildEvents to stderr bytes. Held -//! deliberately stateless so render() can be unit-tested without -//! Extism. -//! -//! Step keys are tracked per-plugin instance because the wire -//! BuildEvents carry step_id (Uuid) only; the plugin builds a -//! step_id → key map from the StepQueued events it sees. - -use hm_plugin_protocol::BuildEvent; -use std::collections::HashMap; -use std::sync::Mutex; -use uuid::Uuid; - -static STEP_KEYS: Mutex = Mutex::new(HmKeyMap { inner: None }); - -struct HmKeyMap { - inner: Option>, -} - -impl HmKeyMap { - fn ensure(&mut self) -> &mut HashMap { - self.inner.get_or_insert_with(HashMap::new) - } -} - -fn record_step_key(id: Uuid, key: String) { - let Ok(mut g) = STEP_KEYS.lock() else { return }; - g.ensure().insert(id, key); -} - -fn step_key_for(id: Uuid) -> String { - STEP_KEYS - .lock() - .ok() - .and_then(|g| g.inner.as_ref().and_then(|m| m.get(&id).cloned())) - .unwrap_or_else(|| "?".to_string()) -} - -pub(crate) fn render(ev: &BuildEvent) -> Vec { - match ev { - BuildEvent::BuildStart { plan, .. } => format!( - "build: {} steps in {} chain(s)\n", - plan.step_count, plan.chain_count - ) - .into_bytes(), - BuildEvent::StepQueued { step_id, key, .. } => { - record_step_key(*step_id, key.clone()); - Vec::new() // queue itself doesn't produce visible output - } - BuildEvent::StepStart { - step_id, - runner, - image, - } => { - let key = step_key_for(*step_id); - let line = match image { - Some(img) => format!("[{key}] start (runner={runner} image={img})\n"), - None => format!("[{key}] start (runner={runner})\n"), - }; - line.into_bytes() - } - BuildEvent::StepLog { step_id, line, .. } => { - let key = step_key_for(*step_id); - format!("[{key}] {line}\n").into_bytes() - } - BuildEvent::StepCacheHit { step_id, tag, .. } => { - let key = step_key_for(*step_id); - format!("[{key}] cache hit ({tag})\n").into_bytes() - } - BuildEvent::StepEnd { - step_id, - exit_code, - duration_ms, - .. - } => { - let key = step_key_for(*step_id); - format!("[{key}] end exit={exit_code} duration={duration_ms}ms\n").into_bytes() - } - BuildEvent::BuildEnd { - exit_code, - duration_ms, - } => format!("build: end exit={exit_code} duration={duration_ms}ms\n").into_bytes(), - BuildEvent::ChainFailed { - chain_idx, - failed_step_key, - exit_code, - message, - .. - } => format!( - "chain {chain_idx}: FAILED at step '{failed_step_key}' (exit={exit_code}): {message}\n" - ) - .into_bytes(), - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod tests { - use super::*; - use hm_plugin_protocol::{PlanSummary, StdStream}; - - #[test] - fn build_start_renders_step_and_chain_counts() { - let ev = BuildEvent::BuildStart { - run_id: Uuid::nil(), - plan: PlanSummary { - step_count: 3, - chain_count: 2, - default_runner: "docker".into(), - }, - started_at: chrono::Utc::now(), - }; - let s = String::from_utf8(render(&ev)).unwrap(); - assert!(s.contains("3 steps")); - assert!(s.contains("2 chain")); - } - - #[test] - fn step_log_renders_with_prefix_after_step_queued_recorded_key() { - let step_id = Uuid::new_v4(); - render(&BuildEvent::StepQueued { - step_id, - key: "build".into(), - chain_idx: 0, - }); - let ev = BuildEvent::StepLog { - step_id, - stream: StdStream::Stdout, - line: "hello".into(), - ts: chrono::Utc::now(), - }; - let s = String::from_utf8(render(&ev)).unwrap(); - assert_eq!(s, "[build] hello\n"); - } - - #[test] - fn step_log_with_unknown_key_renders_question_mark() { - let s = String::from_utf8(render(&BuildEvent::StepLog { - step_id: Uuid::new_v4(), - stream: StdStream::Stdout, - line: "x".into(), - ts: chrono::Utc::now(), - })) - .unwrap(); - assert!(s.starts_with("[?] ")); - } -} diff --git a/crates/hm-plugin-output-json/Cargo.toml b/crates/hm-plugin-output-json/Cargo.toml deleted file mode 100644 index 9d5acd5..0000000 --- a/crates/hm-plugin-output-json/Cargo.toml +++ /dev/null @@ -1,23 +0,0 @@ -[package] -name = "hm-plugin-output-json" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Built-in JSON-lines output formatter for the hm CLI." -publish = false - -[lib] -crate-type = ["cdylib"] -path = "src/lib.rs" - -[dependencies] -hm-plugin-sdk = { workspace = true } -hm-plugin-protocol = { workspace = true } -extism-pdk = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } -semver = { workspace = true } - -[lints] -workspace = true diff --git a/crates/hm-plugin-output-json/src/lib.rs b/crates/hm-plugin-output-json/src/lib.rs deleted file mode 100644 index 39a4a36..0000000 --- a/crates/hm-plugin-output-json/src/lib.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! Built-in JSON-lines output formatter. -//! -//! Each `BuildEvent` is serialised to JSON on a single line and -//! written to stdout. Stderr is reserved for plugin/host diagnostics. - -#![allow(unsafe_code, reason = "extism-pdk host_fn imports require unsafe")] -#![allow( - clippy::pedantic, - clippy::nursery, - clippy::cargo, - clippy::multiple_crate_versions, - clippy::cargo_common_metadata, - clippy::missing_errors_doc, - reason = "matches the test-fixtures allow-list; plugin authoring crate" -)] - -use hm_plugin_sdk::*; - -#[derive(Default)] -struct Json; - -impl OutputFormatter for Json { - fn on_event(&self, event: BuildEvent) -> Result<(), PluginError> { - let mut bytes = serde_json::to_vec(&event) - .map_err(|e| PluginError::new("output_json_serde", e.to_string()))?; - bytes.push(b'\n'); - host::write_stdout(&bytes); - Ok(()) - } -} - -register_plugin!( - manifest = PluginManifest { - api_version: HM_PLUGIN_API_VERSION, - name: "harmont-output-json".into(), - version: semver::Version::new(0, 1, 0), - description: "JSON-lines build output formatter.".into(), - capabilities: vec![Capability::OutputFormatter(OutputFormatterSpec { - name: "json".into(), - mime: "application/x-ndjson".into(), - })], - required_host_fns: vec!["hm_write_stdout".into()], - config_schema: None, - allowed_hosts: vec![], - }, - output = Json, -); diff --git a/crates/hm-plugin-protocol/Cargo.toml b/crates/hm-plugin-protocol/Cargo.toml index f168ac7..a03ef3d 100644 --- a/crates/hm-plugin-protocol/Cargo.toml +++ b/crates/hm-plugin-protocol/Cargo.toml @@ -11,14 +11,9 @@ hm-pipeline-ir = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } schemars = { workspace = true } -semver = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } -thiserror = { workspace = true } derive_more = { workspace = true } -[dev-dependencies] -insta = { version = "1", features = ["json"] } - [lints] workspace = true diff --git a/crates/hm-plugin-protocol/src/error.rs b/crates/hm-plugin-protocol/src/error.rs deleted file mode 100644 index e74c3dd..0000000 --- a/crates/hm-plugin-protocol/src/error.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! Error and exit-info types returned by plugin capability exports. - -use schemars::JsonSchema as DeriveJsonSchema; -use serde::{Deserialize, Serialize}; - -/// Returned by a subcommand plugin from `hm_subcommand_run`. The host -/// translates `exit_code` into the process exit code. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] -pub struct ExitInfo { - pub exit_code: i32, - /// Optional message written to stderr by the host before exit. - /// Used for the rare case where the plugin wants to add context - /// beyond the bytes it already streamed via `hm_log`. - pub message: Option, -} - -/// Error returned from any capability export. The host renders these -/// with the `code` field; downstream tooling matches on it. -#[derive( - Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema, thiserror::Error, -)] -#[error("{message}")] -pub struct PluginError { - /// Stable `snake_case` identifier scoped to the plugin, e.g. - /// `cloud_auth_token_invalid`. Downstream tooling matches on this. - pub code: String, - pub message: String, - /// Optional URL the host renders alongside the message. - pub doc_url: Option, -} - -impl PluginError { - #[must_use] - pub fn new(code: impl Into, message: impl Into) -> Self { - Self { - code: code.into(), - message: message.into(), - doc_url: None, - } - } - - #[must_use] - pub fn with_doc(mut self, url: impl Into) -> Self { - self.doc_url = Some(url.into()); - self - } -} diff --git a/crates/hm-plugin-protocol/src/hook.rs b/crates/hm-plugin-protocol/src/hook.rs deleted file mode 100644 index 4cf6d95..0000000 --- a/crates/hm-plugin-protocol/src/hook.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! Lifecycle hook wire types. - -use schemars::JsonSchema as DeriveJsonSchema; -use serde::{Deserialize, Serialize}; - -use crate::events::BuildEvent; - -/// Hook entry-point input. The host wraps a `BuildEvent` and tells -/// the plugin which phase this call is. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] -#[serde(deny_unknown_fields)] -pub struct HookEvent { - pub event: BuildEvent, - pub phase: HookPhase, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema, derive_more::IsVariant)] -#[serde(rename_all = "snake_case")] -pub enum HookPhase { - /// May return [`HookOutcome::Abort`] to fail the build. - Before, - /// Read-only; the return value is discarded. - After, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema, derive_more::IsVariant)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum HookOutcome { - /// Continue the build. - Continue, - /// Abort the build. Only honoured for `phase: Before`; ignored on - /// `After` (with a host-side warning). - Abort { reason: String }, -} - -/// Subset of [`crate::hook::HookEvent`] discriminants used at manifest time. -/// -/// The manifest declares *what* events the plugin wants, not the per-event -/// payload. Kept in this file so plugin authors only import one module. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, DeriveJsonSchema, derive_more::IsVariant)] -#[serde(rename_all = "snake_case")] -pub enum HookEventKind { - BuildStart, - StepQueued, - StepStart, - StepLog, - StepCacheHit, - StepEnd, - BuildEnd, -} diff --git a/crates/hm-plugin-protocol/src/host_abi.rs b/crates/hm-plugin-protocol/src/host_abi.rs deleted file mode 100644 index d4fe10d..0000000 --- a/crates/hm-plugin-protocol/src/host_abi.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Wire types used as host-function arguments and return values. -//! Plugins import these to talk to the hm host fns; the host imports -//! them to expose those fns. - -use std::collections::BTreeMap; - -use schemars::JsonSchema as DeriveJsonSchema; -use serde::{Deserialize, Serialize}; - -use crate::executor::ArchiveId; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema, derive_more::IsVariant)] -#[serde(rename_all = "snake_case")] -pub enum Level { - Trace, - Debug, - Info, - Warn, - Error, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema, derive_more::IsVariant)] -#[serde(rename_all = "snake_case")] -pub enum KvScope { - /// Per-plugin, persistent across builds. Stored in - /// `~/.config/harmont/state/.kv`. - Plugin, - /// Per-build, in memory. Lost when the build ends. - Build, - /// Per-step, in memory. Lost when the step ends. - Step, -} - -/// Opaque socket handle returned by `hm_unix_socket_connect`. Bound -/// to the plugin instance that opened it. -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, DeriveJsonSchema, - derive_more::From, derive_more::Deref, derive_more::Display, -)] -#[serde(transparent)] -pub struct SocketHandle(pub u64); - -/// Opaque handle returned by `hm_spawn_loopback`. Bound to the plugin -/// instance. -#[derive( - Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, DeriveJsonSchema, - derive_more::From, derive_more::Deref, derive_more::Display, -)] -#[serde(transparent)] -pub struct LoopbackHandle(pub u64); - -/// Host-fn argument struct for the corresponding `hm_archive_read` host function. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ArchiveReadArgs { - pub id: ArchiveId, - pub offset: u64, - pub max: u64, -} - -/// Host-fn argument struct for the corresponding `hm_loopback_recv` host function. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CallbackData { - pub path: String, - pub query: BTreeMap, -} - -/// Host-fn argument struct for the corresponding `hm_keyring_get` / `hm_keyring_delete` host function. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct KeyringArgs { - pub service: String, - pub account: String, -} - -/// Host-fn argument struct for the corresponding `hm_keyring_set` host function. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct KeyringSetArgs { - pub service: String, - pub account: String, - pub secret: String, -} - -/// Host-fn argument struct for the corresponding `hm_loopback_recv` host function. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct LoopbackRecvArgs { - pub h: LoopbackHandle, - pub timeout_ms: u32, -} - -/// Host-fn argument struct for the corresponding `hm_socket_read` host function. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct SocketReadArgs { - pub h: SocketHandle, - pub max: u64, -} - -/// Host-fn argument struct for the corresponding `hm_socket_write` host function. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct SocketWriteArgs { - pub h: SocketHandle, - pub bytes: Vec, -} - -/// Host-fn argument struct for the corresponding `hm_tty_confirm` host function. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TtyConfirmArgs { - pub msg: String, - pub default: bool, -} - -/// Host-fn argument struct for the corresponding `hm_tty_prompt` host function. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct TtyPromptArgs { - pub msg: String, - pub mask: bool, -} - -/// Host-fn argument struct for `hm_docker_start_container`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] -pub struct DockerStartArgs { - pub image: String, - pub env: std::collections::BTreeMap, - pub workdir: String, - pub name_hint: String, -} - -/// Host-fn argument struct for `hm_docker_exec`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] -pub struct DockerExecArgs { - pub container_id: String, - pub cmd: Vec, - pub env: std::collections::BTreeMap, - pub workdir: String, - /// When `Some`, piped into the exec'd process's stdin (closed after - /// the write so the process sees EOF). Used for tar-extract. - pub stdin_archive_id: Option, -} - -/// Host-fn argument struct for `hm_docker_commit`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] -pub struct DockerCommitArgs { - pub container_id: String, - pub tag: String, -} - -/// Host-fn argument struct for `hm_docker_extract_workspace`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] -pub struct DockerExtractArgs { - pub container_id: String, - pub archive_id: crate::ArchiveId, - pub workdir: String, -} diff --git a/crates/hm-plugin-protocol/src/lib.rs b/crates/hm-plugin-protocol/src/lib.rs index b755825..f3c3745 100644 --- a/crates/hm-plugin-protocol/src/lib.rs +++ b/crates/hm-plugin-protocol/src/lib.rs @@ -1,42 +1,14 @@ -//! Wire-level types shared between the `hm` binary and `hm` plugins. +//! Wire-level types shared between `hm` crate internals. //! -//! This crate is pure data: serde structs, enums, and the -//! [`HM_PLUGIN_API_VERSION`] constant. It has no runtime — no async, -//! no Extism, no Tokio. Bumping `HM_PLUGIN_API_VERSION` is the explicit -//! signal that the wire format changed and plugins must be rebuilt. +//! This crate is pure data: serde structs and enums with no runtime. #![forbid(unsafe_code)] -// schemars 0.8 pulls older indexmap and wit-bindgen via its transitive tree. -// We can't fix that without bumping schemars itself; allow at crate scope so -// the noisy cargo-group lints don't drown out real issues. #![allow(clippy::multiple_crate_versions, clippy::cargo_common_metadata)] -pub mod error; pub mod events; pub mod executor; -pub mod hook; -pub mod host_abi; pub mod ir; -pub mod manifest; -pub mod subcommand; -pub use error::{ExitInfo, PluginError}; pub use events::{BuildEvent, PlanSummary, StdStream}; pub use executor::{ArchiveId, ArtifactRef, CacheDecision, ExecutorInput, SnapshotRef, StepResult}; -pub use hook::{HookEvent, HookEventKind, HookOutcome, HookPhase}; -pub use host_abi::{ - ArchiveReadArgs, CallbackData, DockerCommitArgs, DockerExecArgs, DockerExtractArgs, - DockerStartArgs, KeyringArgs, KeyringSetArgs, KvScope, Level, LoopbackHandle, LoopbackRecvArgs, - SocketHandle, SocketReadArgs, SocketWriteArgs, TtyConfirmArgs, TtyPromptArgs, -}; pub use ir::{Cache, CommandStep}; -pub use manifest::{ - Capability, ClapJson, JsonSchema, LifecycleHookSpec, OutputFormatterSpec, PluginManifest, - StepExecutorSpec, SubcommandSpec, -}; -pub use subcommand::SubcommandInput; - -/// Wire-format version. Plugins whose manifest reports a different -/// version are rejected at load time. Bump when adding *any* new -/// required field to any wire-level struct. -pub const HM_PLUGIN_API_VERSION: u32 = 1; diff --git a/crates/hm-plugin-protocol/src/manifest.rs b/crates/hm-plugin-protocol/src/manifest.rs deleted file mode 100644 index a7a9eb9..0000000 --- a/crates/hm-plugin-protocol/src/manifest.rs +++ /dev/null @@ -1,110 +0,0 @@ -//! Plugin manifest types. A plugin advertises what it provides by -//! returning a [`PluginManifest`] from its mandatory `hm_manifest` -//! export at load time. - -use schemars::JsonSchema as DeriveJsonSchema; -use serde::{Deserialize, Serialize}; - -use crate::hook::{HookEventKind, HookPhase}; - -/// JSON Schema fragment (serde-passthrough). Used to validate -/// plugin-specific config blobs and `runner_args`. -pub type JsonSchema = serde_json::Value; - -/// Clap-derived JSON describing a subcommand's argument schema. -/// Produced by the SDK helper [`crate::manifest::clap_json_from`] -/// (added in [`hm-plugin-sdk`]). -pub type ClapJson = serde_json::Value; - -/// Returned by an Extism plugin's `hm_manifest()` export. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] -pub struct PluginManifest { - /// Must equal [`crate::HM_PLUGIN_API_VERSION`] or the host rejects - /// the plugin at load time. - pub api_version: u32, - /// Stable plugin identifier, e.g. `harmont-docker`. Used as the - /// key in the registry and in error messages. - pub name: String, - pub version: semver::Version, - pub description: String, - pub capabilities: Vec, - /// Host functions the plugin needs. Load fails fast if any are - /// not exported by this build of `hm`. - pub required_host_fns: Vec, - /// Optional JSON Schema describing plugin-specific configuration - /// that lives in the project's `.harmont/plugins.toml`. - pub config_schema: Option, - /// HTTPS hosts the plugin is permitted to contact via - /// `extism_pdk::http::request`. Defaults to empty (no HTTP). - /// The host wires this into extism's per-instance manifest at - /// load time; attempting to contact a host not in this list - /// fails inside the plugin. - #[serde(default)] - pub allowed_hosts: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema, derive_more::IsVariant)] -#[serde(tag = "kind", rename_all = "snake_case")] -pub enum Capability { - Subcommand(SubcommandSpec), - StepExecutor(StepExecutorSpec), - LifecycleHook(LifecycleHookSpec), - OutputFormatter(OutputFormatterSpec), -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] -pub struct SubcommandSpec { - /// Top-level verb under `hm`. Two plugins may not claim the - /// same `verb`. - pub verb: String, - pub about: String, - /// Clap-shaped JSON for argument parsing (the host re-parses on - /// the plugin's behalf via `clap`). - pub args_schema: ClapJson, - pub subcommands: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] -pub struct StepExecutorSpec { - /// Matched against `CommandStep.runner` at dispatch time. - pub runner: String, - /// At most one plugin may set `default: true`. The host runs that - /// executor when a step omits `runner`. - pub default: bool, - /// Optional JSON Schema for `CommandStep.runner_args`. The host - /// validates `runner_args` against this schema before dispatch. - pub step_schema: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] -pub struct LifecycleHookSpec { - pub events: Vec, - pub phase: HookPhase, - pub timeout_ms: u32, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] -pub struct OutputFormatterSpec { - /// Selected via `--format ` on the command line. - pub name: String, - /// Advisory MIME type written into `--format --output ` headers. - pub mime: String, -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod tests { - use super::*; - - #[test] - fn capability_tagged_serialization() { - let cap = Capability::StepExecutor(StepExecutorSpec { - runner: "docker".into(), - default: true, - step_schema: None, - }); - let s = serde_json::to_string(&cap).unwrap(); - assert!(s.contains(r#""kind":"step_executor""#), "got: {s}"); - assert!(s.contains(r#""runner":"docker""#), "got: {s}"); - } -} diff --git a/crates/hm-plugin-protocol/src/subcommand.rs b/crates/hm-plugin-protocol/src/subcommand.rs deleted file mode 100644 index 1dac555..0000000 --- a/crates/hm-plugin-protocol/src/subcommand.rs +++ /dev/null @@ -1,20 +0,0 @@ -//! Wire type for subcommand invocations. - -use std::collections::BTreeMap; - -use schemars::JsonSchema as DeriveJsonSchema; -use serde::{Deserialize, Serialize}; - -/// Carried into the plugin's subcommand entry point. The host has -/// already parsed argv on the plugin's behalf using the schema the -/// plugin declared in its manifest. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] -#[serde(deny_unknown_fields)] -pub struct SubcommandInput { - /// Verb path: `["cloud", "org", "switch"]` for `hm cloud org switch`. - pub verb_path: Vec, - /// Positional + option args, already parsed and JSON-encoded. - pub args: serde_json::Value, - /// `HARMONT_*` env vars + any vars the plugin declared interest in. - pub env: BTreeMap, -} diff --git a/crates/hm-plugin-protocol/tests/round_trip.rs b/crates/hm-plugin-protocol/tests/round_trip.rs index a871bc5..ae1bc7e 100644 --- a/crates/hm-plugin-protocol/tests/round_trip.rs +++ b/crates/hm-plugin-protocol/tests/round_trip.rs @@ -11,7 +11,6 @@ )] use hm_plugin_protocol::*; -use semver::Version; use uuid::Uuid; fn rt(v: &T) -> T @@ -24,25 +23,6 @@ where back } -#[test] -fn manifest_round_trip() { - let m = PluginManifest { - api_version: HM_PLUGIN_API_VERSION, - name: "harmont-docker".into(), - version: Version::parse("0.1.0").unwrap(), - description: "Docker step executor".into(), - capabilities: vec![Capability::StepExecutor(StepExecutorSpec { - runner: "docker".into(), - default: true, - step_schema: None, - })], - required_host_fns: vec!["hm_log".into(), "hm_unix_socket_connect".into()], - config_schema: None, - allowed_hosts: vec![], - }; - rt(&m); -} - #[test] fn executor_input_round_trip() { let inp = ExecutorInput { @@ -135,11 +115,3 @@ fn cache_decision_round_trip_all_variants() { }); rt(&CacheDecision::MissNoCommit); } - -#[test] -fn hook_outcome_round_trip() { - rt(&HookOutcome::Continue); - rt(&HookOutcome::Abort { - reason: "policy".into(), - }); -} diff --git a/crates/hm-plugin-protocol/tests/schema_snapshots.rs b/crates/hm-plugin-protocol/tests/schema_snapshots.rs deleted file mode 100644 index 0a983de..0000000 --- a/crates/hm-plugin-protocol/tests/schema_snapshots.rs +++ /dev/null @@ -1,43 +0,0 @@ -//! JSON Schema snapshot test. Catches any unintentional change to the -//! wire format (field rename, type swap, required-vs-optional flip). -//! Run `cargo insta accept -p hm-plugin-protocol` to refresh after an -//! intended schema change. - -#![allow( - clippy::cargo_common_metadata, - clippy::multiple_crate_versions, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic -)] - -use hm_plugin_protocol::{ - DockerCommitArgs, DockerExecArgs, DockerExtractArgs, DockerStartArgs, PluginManifest, -}; -use schemars::schema_for; - -#[test] -fn plugin_manifest_schema_is_stable() { - let schema = schema_for!(PluginManifest); - insta::assert_json_snapshot!("plugin_manifest", schema); -} - -#[test] -fn docker_start_args_schema_is_stable() { - insta::assert_json_snapshot!("docker_start_args", schema_for!(DockerStartArgs)); -} - -#[test] -fn docker_exec_args_schema_is_stable() { - insta::assert_json_snapshot!("docker_exec_args", schema_for!(DockerExecArgs)); -} - -#[test] -fn docker_commit_args_schema_is_stable() { - insta::assert_json_snapshot!("docker_commit_args", schema_for!(DockerCommitArgs)); -} - -#[test] -fn docker_extract_args_schema_is_stable() { - insta::assert_json_snapshot!("docker_extract_args", schema_for!(DockerExtractArgs)); -} diff --git a/crates/hm-plugin-protocol/tests/snapshots/schema_snapshots__docker_commit_args.snap b/crates/hm-plugin-protocol/tests/snapshots/schema_snapshots__docker_commit_args.snap deleted file mode 100644 index 74430c4..0000000 --- a/crates/hm-plugin-protocol/tests/snapshots/schema_snapshots__docker_commit_args.snap +++ /dev/null @@ -1,22 +0,0 @@ ---- -source: crates/hm-plugin-protocol/tests/schema_snapshots.rs -expression: schema_for!(DockerCommitArgs) ---- -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "DockerCommitArgs", - "description": "Host-fn argument struct for `hm_docker_commit`.", - "type": "object", - "required": [ - "container_id", - "tag" - ], - "properties": { - "container_id": { - "type": "string" - }, - "tag": { - "type": "string" - } - } -} diff --git a/crates/hm-plugin-protocol/tests/snapshots/schema_snapshots__docker_exec_args.snap b/crates/hm-plugin-protocol/tests/snapshots/schema_snapshots__docker_exec_args.snap deleted file mode 100644 index 0b1cf94..0000000 --- a/crates/hm-plugin-protocol/tests/snapshots/schema_snapshots__docker_exec_args.snap +++ /dev/null @@ -1,44 +0,0 @@ ---- -source: crates/hm-plugin-protocol/tests/schema_snapshots.rs -expression: schema_for!(DockerExecArgs) ---- -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "DockerExecArgs", - "description": "Host-fn argument struct for `hm_docker_exec`.", - "type": "object", - "required": [ - "cmd", - "container_id", - "env", - "workdir" - ], - "properties": { - "container_id": { - "type": "string" - }, - "cmd": { - "type": "array", - "items": { - "type": "string" - } - }, - "env": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "workdir": { - "type": "string" - }, - "stdin_archive_id": { - "description": "When `Some`, piped into the exec'd process's stdin (closed after the write so the process sees EOF). Used for tar-extract.", - "type": [ - "string", - "null" - ], - "format": "uuid" - } - } -} diff --git a/crates/hm-plugin-protocol/tests/snapshots/schema_snapshots__docker_extract_args.snap b/crates/hm-plugin-protocol/tests/snapshots/schema_snapshots__docker_extract_args.snap deleted file mode 100644 index 8dec96f..0000000 --- a/crates/hm-plugin-protocol/tests/snapshots/schema_snapshots__docker_extract_args.snap +++ /dev/null @@ -1,27 +0,0 @@ ---- -source: crates/hm-plugin-protocol/tests/schema_snapshots.rs -expression: schema_for!(DockerExtractArgs) ---- -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "DockerExtractArgs", - "description": "Host-fn argument struct for `hm_docker_extract_workspace`.", - "type": "object", - "required": [ - "archive_id", - "container_id", - "workdir" - ], - "properties": { - "container_id": { - "type": "string" - }, - "archive_id": { - "type": "string", - "format": "uuid" - }, - "workdir": { - "type": "string" - } - } -} diff --git a/crates/hm-plugin-protocol/tests/snapshots/schema_snapshots__docker_start_args.snap b/crates/hm-plugin-protocol/tests/snapshots/schema_snapshots__docker_start_args.snap deleted file mode 100644 index b66a241..0000000 --- a/crates/hm-plugin-protocol/tests/snapshots/schema_snapshots__docker_start_args.snap +++ /dev/null @@ -1,33 +0,0 @@ ---- -source: crates/hm-plugin-protocol/tests/schema_snapshots.rs -expression: schema_for!(DockerStartArgs) ---- -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "DockerStartArgs", - "description": "Host-fn argument struct for `hm_docker_start_container`.", - "type": "object", - "required": [ - "env", - "image", - "name_hint", - "workdir" - ], - "properties": { - "image": { - "type": "string" - }, - "env": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "workdir": { - "type": "string" - }, - "name_hint": { - "type": "string" - } - } -} diff --git a/crates/hm-plugin-protocol/tests/snapshots/schema_snapshots__plugin_manifest.snap b/crates/hm-plugin-protocol/tests/snapshots/schema_snapshots__plugin_manifest.snap deleted file mode 100644 index 38af6a0..0000000 --- a/crates/hm-plugin-protocol/tests/snapshots/schema_snapshots__plugin_manifest.snap +++ /dev/null @@ -1,241 +0,0 @@ ---- -source: crates/hm-plugin-protocol/tests/schema_snapshots.rs -expression: schema ---- -{ - "$schema": "http://json-schema.org/draft-07/schema#", - "title": "PluginManifest", - "description": "Returned by an Extism plugin's `hm_manifest()` export.", - "type": "object", - "required": [ - "api_version", - "capabilities", - "description", - "name", - "required_host_fns", - "version" - ], - "properties": { - "api_version": { - "description": "Must equal [`crate::HM_PLUGIN_API_VERSION`] or the host rejects the plugin at load time.", - "type": "integer", - "format": "uint32", - "minimum": 0.0 - }, - "name": { - "description": "Stable plugin identifier, e.g. `harmont-docker`. Used as the key in the registry and in error messages.", - "type": "string" - }, - "version": { - "type": "string", - "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" - }, - "description": { - "type": "string" - }, - "capabilities": { - "type": "array", - "items": { - "$ref": "#/definitions/Capability" - } - }, - "required_host_fns": { - "description": "Host functions the plugin needs. Load fails fast if any are not exported by this build of `hm`.", - "type": "array", - "items": { - "type": "string" - } - }, - "config_schema": { - "description": "Optional JSON Schema describing plugin-specific configuration that lives in the project's `.harmont/plugins.toml`." - }, - "allowed_hosts": { - "description": "HTTPS hosts the plugin is permitted to contact via `extism_pdk::http::request`. Defaults to empty (no HTTP). The host wires this into extism's per-instance manifest at load time; attempting to contact a host not in this list fails inside the plugin.", - "default": [], - "type": "array", - "items": { - "type": "string" - } - } - }, - "definitions": { - "Capability": { - "oneOf": [ - { - "type": "object", - "required": [ - "about", - "args_schema", - "kind", - "subcommands", - "verb" - ], - "properties": { - "kind": { - "type": "string", - "enum": [ - "subcommand" - ] - }, - "verb": { - "description": "Top-level verb under `hm`. Two plugins may not claim the same `verb`.", - "type": "string" - }, - "about": { - "type": "string" - }, - "args_schema": { - "description": "Clap-shaped JSON for argument parsing (the host re-parses on the plugin's behalf via `clap`)." - }, - "subcommands": { - "type": "array", - "items": { - "$ref": "#/definitions/SubcommandSpec" - } - } - } - }, - { - "type": "object", - "required": [ - "default", - "kind", - "runner" - ], - "properties": { - "kind": { - "type": "string", - "enum": [ - "step_executor" - ] - }, - "runner": { - "description": "Matched against `CommandStep.runner` at dispatch time.", - "type": "string" - }, - "default": { - "description": "At most one plugin may set `default: true`. The host runs that executor when a step omits `runner`.", - "type": "boolean" - }, - "step_schema": { - "description": "Optional JSON Schema for `CommandStep.runner_args`. The host validates `runner_args` against this schema before dispatch." - } - } - }, - { - "type": "object", - "required": [ - "events", - "kind", - "phase", - "timeout_ms" - ], - "properties": { - "kind": { - "type": "string", - "enum": [ - "lifecycle_hook" - ] - }, - "events": { - "type": "array", - "items": { - "$ref": "#/definitions/HookEventKind" - } - }, - "phase": { - "$ref": "#/definitions/HookPhase" - }, - "timeout_ms": { - "type": "integer", - "format": "uint32", - "minimum": 0.0 - } - } - }, - { - "type": "object", - "required": [ - "kind", - "mime", - "name" - ], - "properties": { - "kind": { - "type": "string", - "enum": [ - "output_formatter" - ] - }, - "name": { - "description": "Selected via `--format ` on the command line.", - "type": "string" - }, - "mime": { - "description": "Advisory MIME type written into `--format --output ` headers.", - "type": "string" - } - } - } - ] - }, - "SubcommandSpec": { - "type": "object", - "required": [ - "about", - "args_schema", - "subcommands", - "verb" - ], - "properties": { - "verb": { - "description": "Top-level verb under `hm`. Two plugins may not claim the same `verb`.", - "type": "string" - }, - "about": { - "type": "string" - }, - "args_schema": { - "description": "Clap-shaped JSON for argument parsing (the host re-parses on the plugin's behalf via `clap`)." - }, - "subcommands": { - "type": "array", - "items": { - "$ref": "#/definitions/SubcommandSpec" - } - } - } - }, - "HookEventKind": { - "description": "Subset of [`crate::hook::HookEvent`] discriminants used at manifest time.\n\nThe manifest declares *what* events the plugin wants, not the per-event payload. Kept in this file so plugin authors only import one module.", - "type": "string", - "enum": [ - "build_start", - "step_queued", - "step_start", - "step_log", - "step_cache_hit", - "step_end", - "build_end" - ] - }, - "HookPhase": { - "oneOf": [ - { - "description": "May return [`HookOutcome::Abort`] to fail the build.", - "type": "string", - "enum": [ - "before" - ] - }, - { - "description": "Read-only; the return value is discarded.", - "type": "string", - "enum": [ - "after" - ] - } - ] - } - } -} diff --git a/crates/hm-plugin-sdk/Cargo.toml b/crates/hm-plugin-sdk/Cargo.toml deleted file mode 100644 index 22aee71..0000000 --- a/crates/hm-plugin-sdk/Cargo.toml +++ /dev/null @@ -1,19 +0,0 @@ -[package] -name = "hm-plugin-sdk" -version = "0.0.0-dev" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Authoring SDK for hm plugins. Wraps extism-pdk with hm-specific traits and macros." - -[lib] -crate-type = ["rlib"] - -[dependencies] -hm-plugin-protocol = { workspace = true } -extism-pdk = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } - -[lints] -workspace = true diff --git a/crates/hm-plugin-sdk/src/executor.rs b/crates/hm-plugin-sdk/src/executor.rs deleted file mode 100644 index 6efdb48..0000000 --- a/crates/hm-plugin-sdk/src/executor.rs +++ /dev/null @@ -1,17 +0,0 @@ -use hm_plugin_protocol::{ExecutorInput, PluginError, StepResult}; - -/// Implemented by step-executor plugins. The host calls -/// [`StepExecutor::run`] exactly once per step; the plugin returns a -/// [`StepResult`] or a [`PluginError`]. -/// -/// During the call the plugin may stream logs via -/// [`crate::host::emit_step_log`] and check cancellation via -/// [`crate::host::should_cancel`]. -pub trait StepExecutor { - /// Execute a single step. - /// - /// # Errors - /// Returns a [`PluginError`] describing the failure. The host - /// converts errors into build events and a non-zero step exit. - fn run(&self, input: ExecutorInput) -> Result; -} diff --git a/crates/hm-plugin-sdk/src/hook.rs b/crates/hm-plugin-sdk/src/hook.rs deleted file mode 100644 index 4f3c782..0000000 --- a/crates/hm-plugin-sdk/src/hook.rs +++ /dev/null @@ -1,12 +0,0 @@ -use hm_plugin_protocol::{HookEvent, HookOutcome, PluginError}; - -/// Implemented by lifecycle-hook plugins. -pub trait LifecycleHook { - /// React to a lifecycle event. - /// - /// # Errors - /// Returns a [`PluginError`] describing the failure. The host - /// converts errors into build events; whether the build aborts - /// depends on the hook's declared `phase`. - fn on_event(&self, event: HookEvent) -> Result; -} diff --git a/crates/hm-plugin-sdk/src/host.rs b/crates/hm-plugin-sdk/src/host.rs deleted file mode 100644 index 1fa7663..0000000 --- a/crates/hm-plugin-sdk/src/host.rs +++ /dev/null @@ -1,205 +0,0 @@ -//! Safe wrappers around the host functions imported via Extism's -//! `host_fn!` block. Plugin code calls these instead of touching -//! `extism-pdk` directly. - -// The `extern "ExtismHost"` block below is FFI to host imports; calling -// those externs requires `unsafe`. The safe wrappers in this module are -// the whole point of the file. -#![allow(unsafe_code)] -// The `extism_pdk::*` wildcard pulls in `Json`, `host_fn`, and other items -// the `host_fn!` macro expansion expects to find in scope; enumerating them -// here would duplicate the PDK's internal contract. -#![allow(clippy::wildcard_imports)] -// Every wrapper below returns a value that the plugin obviously wants -// (`#[must_use]` on every getter is noise — the call sites are short and -// the patterns are immediately recognisable). -#![allow(clippy::must_use_candidate)] -// `should_cancel` deliberately maps over the Result to extract the cancel -// flag and falls back to `false` on host-fn error; `is_ok_and` would lose -// the intent ("treat host-fn failure as 'not cancelled'"). -#![allow(clippy::map_unwrap_or)] - -use extism_pdk::*; -use hm_plugin_protocol::host_abi::*; -use hm_plugin_protocol::{BuildEvent, StdStream}; - -#[host_fn] -extern "ExtismHost" { - fn hm_log(level: Json, msg: String); - fn hm_emit_step_log(stream: Json, bytes: Vec); - fn hm_emit_event(event: Json); - - fn hm_kv_get(scope: Json, key: String) -> Json>>; - fn hm_kv_set(scope: Json, key: String, val: Vec); - - fn hm_archive_read(args: Json) -> Vec; - fn hm_archive_total_size(id: Json) -> u64; - fn hm_fs_read_config(rel_path: String) -> Json>>; - - fn hm_unix_socket_connect(path: String) -> Json; - fn hm_socket_write(args: Json) -> u64; - fn hm_socket_read(args: Json) -> Vec; - fn hm_socket_close(h: Json); - - fn hm_keyring_get(args: Json) -> Json>; - fn hm_keyring_set(args: Json); - fn hm_keyring_delete(args: Json); - - fn hm_tty_prompt(args: Json) -> String; - fn hm_tty_confirm(args: Json) -> bool; - fn hm_browser_open(url: String) -> bool; - fn hm_spawn_loopback(port: Json>) -> Json; - fn hm_loopback_recv(args: Json) -> Json>; - - fn hm_should_cancel() -> u32; - - fn hm_write_stdout(bytes: Vec); - fn hm_write_stderr(bytes: Vec); -} - -pub use hm_plugin_protocol::ArchiveId; - -// ─── Safe API used by plugin code ─────────────────────────────────────────── - -/// Log a diagnostic line into the host's tracing subscriber. -/// -/// # Panics -/// Never panics — Extism propagates host-fn errors as `Err` values, -/// which we trap and ignore (logs are best-effort). -pub fn log(level: Level, msg: &str) { - let _ = unsafe { hm_log(Json(level), msg.to_string()) }; -} - -pub fn emit_step_log(stream: StdStream, bytes: &[u8]) { - let _ = unsafe { hm_emit_step_log(Json(stream), bytes.to_vec()) }; -} - -pub fn emit_event(event: BuildEvent) { - let _ = unsafe { hm_emit_event(Json(event)) }; -} - -pub fn kv_get(scope: KvScope, key: &str) -> Option> { - let Json(v) = unsafe { hm_kv_get(Json(scope), key.into()) }.unwrap_or(Json(None)); - v -} - -pub fn kv_set(scope: KvScope, key: &str, val: &[u8]) { - let _ = unsafe { hm_kv_set(Json(scope), key.into(), val.to_vec()) }; -} - -pub fn archive_total_size(id: ArchiveId) -> u64 { - unsafe { hm_archive_total_size(Json(id)) }.unwrap_or(0) -} - -pub fn archive_read(id: ArchiveId, offset: u64, max: u64) -> Vec { - unsafe { hm_archive_read(Json(ArchiveReadArgs { id, offset, max })) }.unwrap_or_default() -} - -pub fn fs_read_config(rel_path: &str) -> Option> { - let Json(v) = unsafe { hm_fs_read_config(rel_path.into()) }.unwrap_or(Json(None)); - v -} - -pub fn unix_socket_connect(path: &str) -> Option { - unsafe { hm_unix_socket_connect(path.into()) } - .ok() - .map(|Json(h)| h) -} - -pub fn socket_write(h: SocketHandle, bytes: &[u8]) -> u64 { - unsafe { - hm_socket_write(Json(SocketWriteArgs { - h, - bytes: bytes.to_vec(), - })) - } - .unwrap_or(0) -} - -pub fn socket_read(h: SocketHandle, max: u64) -> Vec { - unsafe { hm_socket_read(Json(SocketReadArgs { h, max })) }.unwrap_or_default() -} - -pub fn socket_close(h: SocketHandle) { - let _ = unsafe { hm_socket_close(Json(h)) }; -} - -pub fn keyring_get(service: &str, account: &str) -> Option { - let Json(v) = unsafe { - hm_keyring_get(Json(KeyringArgs { - service: service.into(), - account: account.into(), - })) - } - .unwrap_or(Json(None)); - v -} - -pub fn keyring_set(service: &str, account: &str, secret: &str) { - let _ = unsafe { - hm_keyring_set(Json(KeyringSetArgs { - service: service.into(), - account: account.into(), - secret: secret.into(), - })) - }; -} - -pub fn keyring_delete(service: &str, account: &str) { - let _ = unsafe { - hm_keyring_delete(Json(KeyringArgs { - service: service.into(), - account: account.into(), - })) - }; -} - -pub fn tty_prompt(msg: &str, mask: bool) -> String { - unsafe { - hm_tty_prompt(Json(TtyPromptArgs { - msg: msg.into(), - mask, - })) - } - .unwrap_or_default() -} - -pub fn tty_confirm(msg: &str, default: bool) -> bool { - unsafe { - hm_tty_confirm(Json(TtyConfirmArgs { - msg: msg.into(), - default, - })) - } - .unwrap_or(default) -} - -pub fn browser_open(url: &str) -> bool { - unsafe { hm_browser_open(url.into()) }.unwrap_or(false) -} - -pub fn write_stdout(bytes: &[u8]) { - let _ = unsafe { hm_write_stdout(bytes.to_vec()) }; -} - -pub fn write_stderr(bytes: &[u8]) { - let _ = unsafe { hm_write_stderr(bytes.to_vec()) }; -} - -pub fn spawn_loopback(port: Option) -> Option { - unsafe { hm_spawn_loopback(Json(port)) } - .ok() - .map(|Json(h)| h) -} - -pub fn loopback_recv(h: LoopbackHandle, timeout_ms: u32) -> Option { - let Json(v) = - unsafe { hm_loopback_recv(Json(LoopbackRecvArgs { h, timeout_ms })) }.unwrap_or(Json(None)); - v -} - -pub fn should_cancel() -> bool { - unsafe { hm_should_cancel() } - .map(|n| n != 0) - .unwrap_or(false) -} diff --git a/crates/hm-plugin-sdk/src/lib.rs b/crates/hm-plugin-sdk/src/lib.rs deleted file mode 100644 index 846dcc9..0000000 --- a/crates/hm-plugin-sdk/src/lib.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! Authoring SDK for `hm` plugins. -//! -//! Plugins build to `cdylib` and target `wasm32-wasip1`. The canonical -//! plugin entry point is the [`register_plugin!`] macro, which wires -//! every capability the plugin implements to the right Extism export. -//! -//! ```ignore -//! use hm_plugin_sdk::*; -//! use hm_plugin_protocol::*; -//! -//! struct MyExec; -//! impl StepExecutor for MyExec { -//! fn run(&self, input: ExecutorInput) -> Result { -//! host::log(Level::Info, &format!("running {}", input.step.key)); -//! Ok(StepResult { exit_code: 0, committed_snapshot: None, artifacts: vec![] }) -//! } -//! } -//! -//! register_plugin!( -//! manifest = PluginManifest { -//! api_version: HM_PLUGIN_API_VERSION, -//! name: "my-exec".into(), -//! version: semver::Version::parse("0.1.0").unwrap(), -//! description: "demo".into(), -//! capabilities: vec![Capability::StepExecutor(StepExecutorSpec { -//! runner: "demo".into(), default: false, step_schema: None, -//! })], -//! required_host_fns: vec!["hm_log".into()], -//! config_schema: None, -//! allowed_hosts: vec![], -//! }, -//! executor = MyExec, -//! ); -//! ``` - -// The SDK calls into `extern "ExtismHost"` host functions declared via -// `extism-pdk`'s `host_fn!` macro. Those imports are inherently unsafe FFI, -// so this crate cannot use `#![forbid(unsafe_code)]` the way `hm-plugin-protocol` -// does — the only unsafe blocks live in `host.rs`, gated by an explicit -// module-level allow. -// -// schemars 0.8 pulls older indexmap and wit-bindgen via its transitive tree -// (inherited through hm-plugin-protocol). Keep the same crate-level allows as -// the protocol crate so noisy cargo-group lints don't drown out real issues. -#![allow(clippy::multiple_crate_versions, clippy::cargo_common_metadata)] - -pub mod executor; -pub mod hook; -pub mod host; -pub mod manifest; -pub mod output; -pub mod subcommand; - -#[doc(hidden)] -pub mod macros; - -pub use executor::StepExecutor; -pub use hm_plugin_protocol::*; -pub use hook::LifecycleHook; -pub use output::OutputFormatter; -pub use subcommand::{SubcommandInput, SubcommandPlugin}; - -// Re-export the PDK so plugin authors don't need to add it as a -// separate dep. -pub use extism_pdk; diff --git a/crates/hm-plugin-sdk/src/macros.rs b/crates/hm-plugin-sdk/src/macros.rs deleted file mode 100644 index 6d083d9..0000000 --- a/crates/hm-plugin-sdk/src/macros.rs +++ /dev/null @@ -1,117 +0,0 @@ -//! The `register_plugin!` macro generates the Extism plugin entry -//! points from a plugin's manifest and capability impls. -//! -//! A plugin can pass zero or more of `subcommand`, `executor`, `hook`, -//! `output` to register concrete implementations, in any order. Any -//! capability the plugin declares in its manifest but does not register -//! here is a compile-time omission — the host will call into an -//! unimplemented export at runtime and fail loudly. -//! -//! Two capability entries of the same kind (e.g. `executor = A, -//! executor = B`) are not detected by the macro itself, but each kind -//! emits a uniquely-named extern fn (`hm_executor_run`, etc.). Two of -//! the same kind therefore fails at type-check with a clean -//! "duplicate definition" error from rustc. - -/// Generate `hm_manifest` + capability exports for a plugin. -/// -/// # Example -/// -/// ```ignore -/// register_plugin!( -/// manifest = ..., -/// executor = MyExec, -/// hook = MyHook, -/// ); -/// -/// // Order-independent: this is equivalent. -/// register_plugin!( -/// manifest = ..., -/// hook = MyHook, -/// executor = MyExec, -/// ); -/// ``` -#[macro_export] -macro_rules! register_plugin { - (manifest = $manifest:expr $(, $($tail:tt)*)?) => { - #[$crate::extism_pdk::plugin_fn] - pub fn hm_manifest(_: ()) -> $crate::extism_pdk::FnResult<$crate::extism_pdk::Json<$crate::PluginManifest>> { - Ok($crate::extism_pdk::Json($manifest)) - } - - $crate::__rp_dispatch!($($($tail)*)?); - }; -} - -/// Dispatch loop for capability impls. Consumes one `key = $ty` pair -/// at a time and recurses on the tail. Order-independent because every -/// arm matches by keyword. -#[macro_export] -#[doc(hidden)] -macro_rules! __rp_dispatch { - // Base case: nothing left (with or without trailing comma). - () => {}; - (,) => {}; - - (subcommand = $ty:ty $(, $($rest:tt)*)?) => { - #[$crate::extism_pdk::plugin_fn] - pub fn hm_subcommand_run( - $crate::extism_pdk::Json(input): $crate::extism_pdk::Json<$crate::SubcommandInput>, - ) -> $crate::extism_pdk::FnResult<$crate::extism_pdk::Json<$crate::ExitInfo>> { - let plugin = <$ty as ::core::default::Default>::default(); - match $crate::SubcommandPlugin::run(&plugin, input) { - Ok(info) => Ok($crate::extism_pdk::Json(info)), - Err(e) => Err($crate::extism_pdk::WithReturnCode::new(e.into(), 1)), - } - } - $crate::__rp_dispatch!($($($rest)*)?); - }; - - (executor = $ty:ty $(, $($rest:tt)*)?) => { - #[$crate::extism_pdk::plugin_fn] - pub fn hm_executor_run( - $crate::extism_pdk::Json(input): $crate::extism_pdk::Json<$crate::ExecutorInput>, - ) -> $crate::extism_pdk::FnResult<$crate::extism_pdk::Json<$crate::StepResult>> { - let plugin = <$ty as ::core::default::Default>::default(); - match $crate::StepExecutor::run(&plugin, input) { - Ok(r) => Ok($crate::extism_pdk::Json(r)), - Err(e) => Err($crate::extism_pdk::WithReturnCode::new(e.into(), 1)), - } - } - $crate::__rp_dispatch!($($($rest)*)?); - }; - - (hook = $ty:ty $(, $($rest:tt)*)?) => { - #[$crate::extism_pdk::plugin_fn] - pub fn hm_hook_on_event( - $crate::extism_pdk::Json(event): $crate::extism_pdk::Json<$crate::HookEvent>, - ) -> $crate::extism_pdk::FnResult<$crate::extism_pdk::Json<$crate::HookOutcome>> { - let plugin = <$ty as ::core::default::Default>::default(); - match $crate::LifecycleHook::on_event(&plugin, event) { - Ok(o) => Ok($crate::extism_pdk::Json(o)), - Err(e) => Err($crate::extism_pdk::WithReturnCode::new(e.into(), 1)), - } - } - $crate::__rp_dispatch!($($($rest)*)?); - }; - - (output = $ty:ty $(, $($rest:tt)*)?) => { - #[$crate::extism_pdk::plugin_fn] - pub fn hm_output_on_event( - $crate::extism_pdk::Json(event): $crate::extism_pdk::Json<$crate::BuildEvent>, - ) -> $crate::extism_pdk::FnResult<()> { - let plugin = <$ty as ::core::default::Default>::default(); - $crate::OutputFormatter::on_event(&plugin, event) - .map_err(|e| $crate::extism_pdk::WithReturnCode::new(e.into(), 1))?; - Ok(()) - } - - #[$crate::extism_pdk::plugin_fn] - pub fn hm_output_finalize(_: ()) -> $crate::extism_pdk::FnResult> { - let plugin = <$ty as ::core::default::Default>::default(); - $crate::OutputFormatter::finalize(&plugin) - .map_err(|e| $crate::extism_pdk::WithReturnCode::new(e.into(), 1)) - } - $crate::__rp_dispatch!($($($rest)*)?); - }; -} diff --git a/crates/hm-plugin-sdk/src/manifest.rs b/crates/hm-plugin-sdk/src/manifest.rs deleted file mode 100644 index 829f415..0000000 --- a/crates/hm-plugin-sdk/src/manifest.rs +++ /dev/null @@ -1,7 +0,0 @@ -//! Build helpers for plugin manifests. Today this file is a re-export -//! shim; future expansion will add a `manifest! {}` declarative macro. - -pub use hm_plugin_protocol::{ - Capability, ClapJson, HookEventKind, HookPhase, JsonSchema, LifecycleHookSpec, - OutputFormatterSpec, PluginManifest, StepExecutorSpec, SubcommandSpec, -}; diff --git a/crates/hm-plugin-sdk/src/output.rs b/crates/hm-plugin-sdk/src/output.rs deleted file mode 100644 index be8ad14..0000000 --- a/crates/hm-plugin-sdk/src/output.rs +++ /dev/null @@ -1,27 +0,0 @@ -use hm_plugin_protocol::{BuildEvent, PluginError}; - -/// Implemented by output-formatter plugins. -/// -/// The host invokes [`OutputFormatter::on_event`] for every build event -/// in order, then once at the end calls [`OutputFormatter::finalize`] -/// for formatters that accumulate (`JUnit` XML, JSON arrays). -pub trait OutputFormatter { - /// Handle a single build event. - /// - /// # Errors - /// Returns a [`PluginError`] if the formatter cannot process the - /// event (e.g. malformed input). The host renders the error and - /// aborts the formatter; the build itself is unaffected. - fn on_event(&self, event: BuildEvent) -> Result<(), PluginError>; - - /// Optional. Default returns empty bytes. Streaming formatters - /// (human, json-lines) leave this alone; accumulating formatters - /// (junit) return the full document here. - /// - /// # Errors - /// Returns a [`PluginError`] if the formatter cannot serialise its - /// accumulated state. - fn finalize(&self) -> Result, PluginError> { - Ok(Vec::new()) - } -} diff --git a/crates/hm-plugin-sdk/src/subcommand.rs b/crates/hm-plugin-sdk/src/subcommand.rs deleted file mode 100644 index 797df5f..0000000 --- a/crates/hm-plugin-sdk/src/subcommand.rs +++ /dev/null @@ -1,12 +0,0 @@ -use hm_plugin_protocol::{ExitInfo, PluginError}; - -pub use hm_plugin_protocol::SubcommandInput; - -pub trait SubcommandPlugin { - /// Run the subcommand. - /// - /// # Errors - /// Returns a [`PluginError`] describing the failure. The host - /// renders the error and exits the process with code 1. - fn run(&self, input: SubcommandInput) -> Result; -} diff --git a/crates/hm/CLAUDE.md b/crates/hm/CLAUDE.md index 51f28a5..506c537 100644 --- a/crates/hm/CLAUDE.md +++ b/crates/hm/CLAUDE.md @@ -5,53 +5,30 @@ - Builds a wire-typed `Graph` (`graph.rs`) from the parsed `Pipeline` and partitions it into chains for scheduling. -- Loads the plugin registry (the embedded docker plugin is baked in - via `build.rs`) and resolves each step's `runner` field to a - registered plugin in `scheduler.rs`. +- Receives a `RunnerRegistry` (containing `DockerRunner` and any + future runners) and resolves each step's `runner` field to a + registered runner in `scheduler.rs`. - Publishes `BuildEvent`s on a `tokio::sync::broadcast` (`events.rs`); the `output_subscriber` task drains the bus and invokes the selected - output plugin's `hm_output_on_event` per event (`hm-plugin-output-human` - or `hm-plugin-output-json`, both embedded via `build.rs`). Default - `--format` is `human`; `--format json` writes one JSON event per - line on stdout. -- Streams cache decisions host-side (`cache.rs`), reads the workspace - archive once into memory (`archive.rs` + `source.rs`), and drives - the Docker daemon via the Bollard wrapper (`docker_client.rs`, - exposed to step plugins through `docker_host_fns.rs`). -- Owns run-wide cancellation (`tokio_util::sync::CancellationToken`) and shared mutable state - (`state.rs`) so step plugins can coordinate without reaching across - module boundaries. - -Plugin parallelism is bounded by `PluginPool`. Each `LoadedPlugin` -owns a pool sized to the run's `--parallelism`, so concurrent chains -don't serialise on the same Extism `Plugin` instance. - -## Cloud functionality (plan 4) - -Every cloud verb runs through the embedded `hm-plugin-cloud` plugin -under the `hm cloud` namespace: `hm cloud {login,logout,whoami,org, -pipeline,build,job,billing,run}`. Legacy cli/src/{client,credentials, -generated}.rs and the matching command modules are deleted. - -The plugin uses extism-pdk's host-mediated HTTP, restricted by the -manifest's `allowed_hosts: ["api.harmont.dev", "*.harmont.dev"]`. The -host fns `hm_keyring_*` back token storage (file-backed at -`~/.harmont/credentials.toml`, mode 0o600 — no OS keyring / D-Bus); -`hm_kv_*` (KvScope::Plugin) backs persistent state (active org slug); -`hm_spawn_loopback` + `hm_loopback_recv` support the browser-loopback -OAuth flow. - -`hm cloud run` is partial: it submits a pre-rendered plan JSON -(default path: `.harmont/plan.json`, override with `--plan-file`). -Source-archive upload to the cloud is plan-5 work. The legacy -`commands/run/remote.rs` source-tar logic is gone. - -Known follow-ups for plan 5 or later: -- `hm_random_bytes(len) -> Vec` host fn so the cloud plugin's - PKCE verifier uses real entropy. -- `hm_sleep_ms(ms)` host fn so `cloud build watch` doesn't busy-wait. -- `cloud run` source-archive upload. - -Broadcast lag in `output_subscriber` surfaces a `tracing::warn!` plus -an `eprintln!` line; full lag-recovery (e.g., per-step backpressure) -is a future concern. + `OutputRenderer` (human or JSON, both in `src/output/`). +- Reads the workspace archive once into memory (`archive.rs` + + `source.rs`), and drives the Docker daemon via the Bollard wrapper + (`docker_client.rs`). +- The `DockerRunner` (`src/runner/docker.rs`) executes steps directly + via `DockerClient` — no FFI, no WASM, no host functions. +- Owns run-wide cancellation (`tokio_util::sync::CancellationToken`) + via `signal.rs`. + +## Runner system (static DI) + +`src/runner/mod.rs` defines `StepRunner` (async trait), `OutputRenderer`, +`RunContext`, and `RunnerRegistry`. `DockerRunner` is the sole executor. +The registry is constructed in `commands/run/local.rs` and passed to +`scheduler::run` — no global state, no plugin loading. + +## Cloud functionality + +`hm cloud` subcommands are implemented in the `hm-plugin-cloud` library +crate (direct dependency, no FFI). HTTP goes through `reqwest`, +credentials are file-backed at `~/.harmont/credentials.toml`, and +organization state lives in `~/.harmont/cloud-state.json`. diff --git a/crates/hm/Cargo.toml b/crates/hm/Cargo.toml index a433733..49b0842 100644 --- a/crates/hm/Cargo.toml +++ b/crates/hm/Cargo.toml @@ -8,17 +8,10 @@ description = "Command-line client for the Harmont CI platform." readme = "README.md" keywords = ["ci", "harmont", "cli"] categories = ["command-line-utilities", "development-tools"] -# Explicit include list: cargo's default ("everything tracked by git") -# would drop crates/hm/embedded/*.wasm (gitignored — they are CI-built -# artifacts, not source). The release workflow stages the four -# embedded plugin wasms into embedded/ before invoking `cargo publish`, -# and this glob carries them into the tarball. include = [ "src/**/*", - "build.rs", "Cargo.toml", "README.md", - "embedded/*.wasm", ] [lib] @@ -66,10 +59,10 @@ futures = "0.3" futures-util = "0.3" bollard = "0.18" which = "6" -extism = { workspace = true } hm-pipeline-ir = { workspace = true } hm-plugin-protocol = { workspace = true } hm-util = { workspace = true } +hm-plugin-cloud = { path = "../hm-plugin-cloud" } daggy = { workspace = true } schemars = { workspace = true } semver = { workspace = true } diff --git a/crates/hm/build.rs b/crates/hm/build.rs deleted file mode 100644 index e07112d..0000000 --- a/crates/hm/build.rs +++ /dev/null @@ -1,75 +0,0 @@ -//! Build script: compiles the embedded WASM plugins shipped with `hm` -//! (`hm-plugin-docker`, `hm-plugin-output-human`, `hm-plugin-output-json`, -//! `hm-plugin-cloud`) and stages their artifacts under `$OUT_DIR` so the -//! host can `include_bytes!` them at runtime. -#![allow( - clippy::expect_used, - clippy::panic, - clippy::print_stdout, - reason = "build scripts terminate the build via panic/expect; stdout is cargo:rerun-if-changed directives" -)] - -use std::env; -use std::fs; -use std::path::PathBuf; -use std::process::Command; - -fn main() { - build_embedded_plugins(); -} - -fn build_wasm_plugin(crate_name: &str) { - let underscore = crate_name.replace('-', "_"); - let out_dir = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")); - let dest = out_dir.join(format!("{underscore}.wasm")); - - // Bundled-wasm path: `cargo publish` extracts this crate into an - // isolated `target/package/harmont-cli-/` and runs build.rs - // there. The sibling plugin crates aren't reachable from that - // sandbox (and aren't reachable for `cargo install harmont-cli` - // end users either). Release CI pre-builds the wasms and stages - // them under `crates/hm/embedded/` before invoking `cargo publish`, - // and the `include = [...]` in Cargo.toml carries them into the - // tarball. When build.rs sees a pre-built file there, just copy. - let bundled = PathBuf::from(format!("embedded/{underscore}.wasm")); - println!("cargo:rerun-if-changed={}", bundled.display()); - if bundled.is_file() { - fs::copy(&bundled, &dest).unwrap_or_else(|e| { - panic!("copy bundled {} -> {}: {e}", bundled.display(), dest.display()) - }); - return; - } - - // Dev path: cross-compile from the sibling crate in the workspace. - let src = format!("../{crate_name}/src"); - let cargo_toml = format!("../{crate_name}/Cargo.toml"); - println!("cargo:rerun-if-changed={src}"); - println!("cargo:rerun-if-changed={cargo_toml}"); - - let status = Command::new(env::var("CARGO").as_deref().unwrap_or("cargo")) - .args([ - "build", - "--target", - "wasm32-wasip1", - "-p", - crate_name, - "--release", - ]) - .current_dir("../..") - .status() - .unwrap_or_else(|e| panic!("invoke cargo build for {crate_name}: {e}")); - assert!(status.success(), "{crate_name} wasm build failed"); - - let src_wasm = PathBuf::from(format!( - "../../target/wasm32-wasip1/release/{underscore}.wasm" - )); - fs::copy(&src_wasm, &dest) - .unwrap_or_else(|e| panic!("copy {} -> {}: {e}", src_wasm.display(), dest.display())); -} - -fn build_embedded_plugins() { - build_wasm_plugin("hm-plugin-docker"); - build_wasm_plugin("hm-plugin-output-human"); - build_wasm_plugin("hm-plugin-output-json"); - build_wasm_plugin("hm-plugin-cloud"); -} diff --git a/crates/hm/src/cli/external.rs b/crates/hm/src/cli/external.rs deleted file mode 100644 index b0f9e23..0000000 --- a/crates/hm/src/cli/external.rs +++ /dev/null @@ -1,74 +0,0 @@ -use std::collections::BTreeMap; - -use anyhow::{Context, Result}; -use hm_plugin_protocol::{ExitInfo, SubcommandInput}; - -use crate::error::HmError; -use crate::plugin::{PluginRegistry, RegistryConfig}; - -/// Run a plugin-provided external subcommand. -/// -/// # Errors -/// -/// Returns an error if plugin lookup or invocation fails. -pub async fn run(argv: Vec) -> Result { - let verb = argv - .first() - .cloned() - .ok_or_else(|| anyhow::anyhow!("dispatcher called with empty argv (clap bug)"))?; - - let registry = PluginRegistry::load(RegistryConfig { - auto_discover: true, - extra_paths: vec![], - embedded: vec![ - ( - "harmont-docker", - crate::plugin::embedded::DOCKER_PLUGIN_WASM, - ), - ( - "harmont-output-human", - crate::plugin::embedded::OUTPUT_HUMAN_PLUGIN_WASM, - ), - ( - "harmont-output-json", - crate::plugin::embedded::OUTPUT_JSON_PLUGIN_WASM, - ), - ("harmont-cloud", crate::plugin::embedded::CLOUD_PLUGIN_WASM), - ], - pool_sizes: BTreeMap::new(), - }) - .context("load plugin registry")?; - - let idx = registry - .subcommand_index - .get(&verb) - .copied() - .ok_or_else(|| HmError::UnknownVerb { - verb: verb.clone(), - available: registry.subcommand_index.keys().cloned().collect(), - })?; - - let plugin = registry - .get(idx) - .context("plugin moved away during dispatch")?; - - let env: BTreeMap = std::env::vars() - .filter(|(k, _)| k.starts_with("HARMONT_")) - .collect(); - - let input = SubcommandInput { - verb_path: argv.clone(), - args: serde_json::Value::Null, // plugin parses raw argv itself - env, - }; - - let info: ExitInfo = plugin - .call_capability("hm_subcommand_run", &input) - .await - .with_context(|| format!("invoke plugin for verb '{verb}'"))?; - - if let Some(msg) = info.message { - eprintln!("{msg}"); - } - Ok(info.exit_code) -} diff --git a/crates/hm/src/cli/mod.rs b/crates/hm/src/cli/mod.rs index d4ecbfd..80c9d07 100644 --- a/crates/hm/src/cli/mod.rs +++ b/crates/hm/src/cli/mod.rs @@ -1,5 +1,4 @@ pub mod dev; -pub mod external; pub mod plugin; pub mod run; pub mod version; @@ -46,7 +45,7 @@ pub enum Command { /// Run a pipeline locally via Docker. Run(RunArgs), - /// Show hm version and plugin protocol API version. + /// Show hm version. Version, /// Manage plugins. @@ -59,10 +58,9 @@ pub enum Command { #[command(subcommand)] Dev(DevCommand), - /// Plugin-provided subcommand. Captured raw; the dispatcher - /// looks it up in the registry and invokes the matching plugin. - #[command(external_subcommand)] - External(Vec), + /// Interact with the Harmont cloud API. + #[command(subcommand)] + Cloud(hm_plugin_cloud::cli::CloudCommand), } /// Dispatch a parsed CLI command to the appropriate handler. Returns an exit code. @@ -76,7 +74,11 @@ pub async fn dispatch(command: Command, ctx: RunContext) -> Result { Command::Dev(cmd) => dev::dispatch(cmd, ctx).await, Command::Version => version::run().await.map(|()| 0), Command::Plugin(cmd) => plugin::run(cmd).await.map(|()| 0), - Command::External(argv) => external::run(argv).await, + Command::Cloud(cmd) => { + let env: std::collections::BTreeMap = std::env::vars() + .filter(|(k, _)| k.starts_with("HARMONT_") || k.starts_with("HM_")) + .collect(); + hm_plugin_cloud::cli::dispatch_command(cmd, env).await + } } } - diff --git a/crates/hm/src/cli/plugin.rs b/crates/hm/src/cli/plugin.rs index b232bee..6af333a 100644 --- a/crates/hm/src/cli/plugin.rs +++ b/crates/hm/src/cli/plugin.rs @@ -1,37 +1,10 @@ -use anyhow::{Context, Result}; +use anyhow::Result; use clap::Subcommand; -use crate::plugin::{PluginRegistry, RegistryConfig, paths}; - #[derive(Debug, Clone, Subcommand)] pub enum PluginCommand { - /// List installed plugins (embedded + user + project). + /// List registered runners. List, - - /// Show one plugin's manifest in detail. - Info { - /// Plugin name (matches `name` field of the manifest). - name: String, - }, - - /// Install a plugin from a file path or HTTPS URL. - /// - /// HTTPS URLs require `--pin ` for integrity. - Install { - /// Plugin source: local path (`./foo.wasm`) or HTTPS URL. - source: String, - - /// SHA-256 hex digest to verify against. Required for HTTPS - /// sources; optional for local paths. - #[arg(long, value_name = "SHA256_HEX")] - pin: Option, - }, - - /// Remove an installed plugin by name. - Remove { - /// Plugin name. - name: String, - }, } /// Run an `hm plugin` subcommand. @@ -42,81 +15,12 @@ pub enum PluginCommand { pub async fn run(cmd: PluginCommand) -> Result<()> { match cmd { PluginCommand::List => list().await, - PluginCommand::Info { name } => info(&name).await, - PluginCommand::Install { source, pin } => install_cmd(&source, pin.as_deref()).await, - PluginCommand::Remove { name } => remove(&name).await, } } #[allow(clippy::unused_async)] async fn list() -> Result<()> { - let reg = PluginRegistry::load(RegistryConfig::default())?; - if reg.manifests().count() == 0 { - println!("No plugins installed."); - println!(); - println!("Plugins live in:"); - if let Some(p) = paths::user_plugins_dir() { - println!(" {}", p.display()); - } - if let Some(p) = paths::project_plugins_dir() { - println!(" {}", p.display()); - } - println!(); - println!("Install one with `hm plugin install `."); - return Ok(()); - } - println!("{:<28} {:>10} capabilities", "name", "version"); - for m in reg.manifests() { - let caps: Vec = m.capabilities.iter().map(capability_summary).collect(); - println!("{:<28} {:>10} {}", m.name, m.version, caps.join(", ")); - } - Ok(()) -} - -#[allow(clippy::unused_async)] -async fn info(name: &str) -> Result<()> { - let reg = PluginRegistry::load(RegistryConfig::default())?; - let m = reg - .manifests() - .find(|m| m.name == name) - .with_context(|| format!("no plugin named '{name}' is installed"))?; - let json = serde_json::to_string_pretty(m)?; - println!("{json}"); + println!("Registered runners:"); + println!(" docker (default, built-in)"); Ok(()) } - -async fn install_cmd(source: &str, pin: Option<&str>) -> Result<()> { - let path = crate::plugin::install::install(source, pin).await?; - println!("Installed plugin to {}", path.display()); - Ok(()) -} - -#[allow(clippy::unused_async)] -async fn remove(name: &str) -> Result<()> { - let dir = crate::plugin::paths::install_dir().context("no install dir")?; - let target = dir.join(format!("{name}.wasm")); - if !target.is_file() { - anyhow::bail!("no plugin file at {}", target.display()); - } - std::fs::remove_file(&target).context("remove plugin")?; - println!("Removed {}", target.display()); - Ok(()) -} - -fn capability_summary(cap: &hm_plugin_protocol::Capability) -> String { - use hm_plugin_protocol::Capability::{ - LifecycleHook, OutputFormatter, StepExecutor, Subcommand, - }; - match cap { - Subcommand(s) => format!("subcmd:{}", s.verb), - StepExecutor(s) => { - if s.default { - format!("runner:{}(*)", s.runner) - } else { - format!("runner:{}", s.runner) - } - } - LifecycleHook(_) => "hook".into(), - OutputFormatter(s) => format!("format:{}", s.name), - } -} diff --git a/crates/hm/src/cli/version.rs b/crates/hm/src/cli/version.rs index 5297478..cf8554a 100644 --- a/crates/hm/src/cli/version.rs +++ b/crates/hm/src/cli/version.rs @@ -1,19 +1,12 @@ use anyhow::Result; -use hm_plugin_protocol::HM_PLUGIN_API_VERSION; - -use crate::plugin::{PluginRegistry, RegistryConfig}; #[allow(clippy::unused_async)] /// Print version information to stdout. /// /// # Errors /// -/// Returns an error if the plugin registry cannot be loaded. +/// Returns an error on I/O failure. pub async fn run() -> Result<()> { - let reg = PluginRegistry::load(RegistryConfig::default())?; println!("hm {}", env!("CARGO_PKG_VERSION")); - println!("plugin api version: {HM_PLUGIN_API_VERSION}"); - let count = reg.manifests().count(); - println!("plugins loaded: {count}"); Ok(()) } diff --git a/crates/hm/src/commands/run/local.rs b/crates/hm/src/commands/run/local.rs index 5828d04..27cdded 100644 --- a/crates/hm/src/commands/run/local.rs +++ b/crates/hm/src/commands/run/local.rs @@ -1,9 +1,12 @@ +use std::sync::Arc; + use anyhow::{Context, Result}; use super::render::{ToolPaths, list_pipelines, render_pipeline_json}; use crate::cli::RunArgs; use crate::context::RunContext; use crate::output::format::banner; +use crate::runner::{RunnerRegistry, docker::DockerRunner}; /// Execute a v0 IR pipeline locally; return the final container id. /// @@ -95,8 +98,18 @@ pub async fn handle(args: RunArgs, _ctx: RunContext) -> Result { let parallelism = args.parallelism.unwrap_or_else(|| { std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get) }); + + let mut runner_registry = RunnerRegistry::new(); + runner_registry.register(Arc::new(DockerRunner), true); + let runner_registry = Arc::new(runner_registry); + + let renderer: Box = match args.format.as_str() { + "json" => Box::new(crate::output::json::JsonRenderer::new(std::io::stdout())), + _ => Box::new(crate::output::human::HumanRenderer::new(std::io::stderr())), + }; + let exit_code = - crate::orchestrator::run(graph, repo_root, parallelism, args.format.clone()) + crate::orchestrator::run(graph, repo_root, parallelism, runner_registry, renderer) .await?; Ok(exit_code) } diff --git a/crates/hm/src/error.rs b/crates/hm/src/error.rs index 6a2d15f..62c6998 100644 --- a/crates/hm/src/error.rs +++ b/crates/hm/src/error.rs @@ -7,12 +7,6 @@ pub const EXIT_USAGE: i32 = 2; pub const EXIT_AUTH: i32 = 3; pub const EXIT_NETWORK: i32 = 4; pub const EXIT_API: i32 = 5; -/// Plugin load/validation failure (manifest, conflicts, missing host fns). -/// Shares the same numeric code as `EXIT_API`; named separately so plugin -/// call-sites read clearly. -pub const EXIT_PLUGIN_LOAD: i32 = 5; -/// Plugin runtime failure (panic in capability call, timeout). -pub const EXIT_PLUGIN_RUNTIME: i32 = 6; /// Pipeline-level invalid configuration (unknown runner, no default executor). pub const EXIT_PIPELINE_INVALID: i32 = 7; @@ -38,12 +32,6 @@ pub enum HmError { #[error("pipeline not found: {slug}\n → list available pipelines with `hm pipeline list`")] PipelineNotFound { slug: String }, - /// 403 with `code = pipeline_manual_disabled` from - /// `POST /api/v0/organizations/{org}/pipelines/{slug}/builds`. - /// - /// The message is the literal § 5 shape; `print_error` will prepend - /// its ✘ glyph and the body carries the `error:` prefix and trailing - /// `hm run --help` footer the task spec asks for verbatim. #[error( "error: manual builds are disabled for this pipeline\n \u{2192} ask the pipeline owner to set allow_manual=True\n\nhm run --help for more" )] @@ -61,53 +49,8 @@ pub enum HmError { #[error("local scheduler error: {0}")] LocalScheduling(String), - #[error("plugin '{name}' failed to load from {path}: {reason}")] - PluginLoad { - name: String, - path: std::path::PathBuf, - reason: String, - doc_url: &'static str, - }, - - #[error("plugin '{name}': API version mismatch (plugin={found_api}, host={expected_api})")] - PluginManifest { - name: String, - expected_api: u32, - found_api: u32, - }, - #[error( - "plugin '{name}': required host fn '{fn_name}' is unavailable (this hm build is too old; needs >= {min_hm_version})" - )] - PluginMissingHostFn { - name: String, - fn_name: String, - min_hm_version: semver::Version, - }, - - #[error("plugin '{name}' panicked during '{capability}': {message}")] - PluginPanic { - name: String, - capability: String, - message: String, - }, - - #[error("plugin '{name}' timed out after {after_ms}ms during '{capability}'")] - PluginTimeout { - name: String, - capability: String, - after_ms: u32, - }, - - #[error("plugin conflict: both '{plugin_a}' and '{plugin_b}' claim '{verb}'")] - PluginConflict { - verb: String, - plugin_a: String, - plugin_b: String, - }, - - #[error( - "step '{step_key}' requested runner '{runner}', but no plugin provides it (available: {available:?})" + "step '{step_key}' requested runner '{runner}', but no runner provides it (available: {available:?})" )] UnknownRunner { step_key: String, @@ -115,48 +58,25 @@ pub enum HmError { available: Vec, }, - #[error("no default step-executor plugin is registered (need exactly one with default=true)")] + #[error("no default step executor is registered")] NoDefaultExecutor, - #[error( - "unknown command '{verb}'\n available: {available:?}\n fix: `hm plugin install ` to add a plugin that provides this command" - )] - UnknownVerb { - verb: String, - available: Vec, - }, - #[error("{0}")] Other(#[from] anyhow::Error), } /// Coarse error category. -/// -/// Each variant maps 1:1 to a single CLI exit code. Categorising via -/// this intermediate enum lets `exit_code` stay a five-arm match -/// regardless of how many error variants share a code. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ErrorCategory { - /// Generic failure, exit 1. BuildFailed, - /// User-facing usage / configuration error, exit 2. Usage, - /// Authentication / authorization error, exit 3. Auth, - /// Network or daemon-reachability error, exit 4. Network, - /// Server- or evaluator-side API error, exit 5. Api, - /// Plugin load/manifest/conflict failure, exit 5 (`EXIT_PLUGIN_LOAD`). - PluginLoad, - /// Plugin runtime failure, exit 6. - PluginRuntime, - /// Pipeline-level invalid config (unknown runner, no default executor), exit 7. PipelineInvalid, } impl ErrorCategory { - /// CLI exit code for this category. #[must_use] pub const fn exit_code(self) -> i32 { match self { @@ -164,58 +84,32 @@ impl ErrorCategory { Self::Usage => EXIT_USAGE, Self::Auth => EXIT_AUTH, Self::Network => EXIT_NETWORK, - Self::Api | Self::PluginLoad => EXIT_API, - Self::PluginRuntime => EXIT_PLUGIN_RUNTIME, + Self::Api => EXIT_API, Self::PipelineInvalid => EXIT_PIPELINE_INVALID, } } } -/// Map an error to its exit-code category. The four meaningful -/// categories are auth, network, API, and "the build failed" — anything -/// else maps to `EXIT_USAGE`. Keep this aligned with the `EXIT_*` -/// constants at the top of this file. impl HmError { - /// Map an error variant to its broad category. - /// - /// Patterns are merged by category to satisfy `clippy::match_same_arms`; - /// the comment after each arm names the variants in source order so - /// the mapping stays inspectable from this site. #[must_use] pub const fn category(&self) -> ErrorCategory { match self { - // Auth: NotAuthenticated, Api{401|403} Self::NotAuthenticated => ErrorCategory::Auth, Self::Api { status, .. } if *status == 401 || *status == 403 => ErrorCategory::Auth, - // Usage: NoOrganization, ArchiveTooLarge, Config, PipelineRender, UnknownVerb Self::NoOrganization | Self::ArchiveTooLarge { .. } | Self::Config(_) - | Self::PipelineRender(_) - | Self::UnknownVerb { .. } => ErrorCategory::Usage, - // Api (server-side): Api{*}, PipelineNotFound, - // PipelineManualDisabled, LocalScheduling + | Self::PipelineRender(_) => ErrorCategory::Usage, Self::Api { .. } | Self::PipelineNotFound { .. } | Self::PipelineManualDisabled | Self::LocalScheduling(_) => ErrorCategory::Api, - // Network: Network (reqwest), Docker (daemon unreachable) Self::Network(_) | Self::Docker(_) => ErrorCategory::Network, - // Plugin load failures (exit 5). - Self::PluginLoad { .. } - | Self::PluginManifest { .. } - | Self::PluginMissingHostFn { .. } - | Self::PluginConflict { .. } => ErrorCategory::PluginLoad, - // Plugin runtime failures (exit 6). - Self::PluginPanic { .. } | Self::PluginTimeout { .. } => ErrorCategory::PluginRuntime, - // Pipeline-level invalid config (exit 7). Self::UnknownRunner { .. } | Self::NoDefaultExecutor => ErrorCategory::PipelineInvalid, - // Generic build failure: anyhow-wrapped errors propagate here. Self::Other(_) => ErrorCategory::BuildFailed, } } - /// Map an error to its CLI exit code. #[must_use] pub const fn exit_code(&self) -> i32 { self.category().exit_code() @@ -226,9 +120,6 @@ impl HmError { mod tests { use super::{ErrorCategory, HmError}; - /// The § 5 error shape is part of the user-facing contract: scripts - /// and humans both rely on the exact wording, so the rendered string - /// is checked byte-for-byte rather than with a substring match. #[test] fn pipeline_manual_disabled_renders_section5_shape() { let s = format!("{}", HmError::PipelineManualDisabled); diff --git a/crates/hm/src/lib.rs b/crates/hm/src/lib.rs index 9ae6fca..7a80679 100644 --- a/crates/hm/src/lib.rs +++ b/crates/hm/src/lib.rs @@ -21,4 +21,4 @@ pub mod creds_store; pub mod error; pub mod orchestrator; pub mod output; -pub mod plugin; +pub mod runner; diff --git a/crates/hm/src/orchestrator/docker_host_fns.rs b/crates/hm/src/orchestrator/docker_host_fns.rs deleted file mode 100644 index 46f6b66..0000000 --- a/crates/hm/src/orchestrator/docker_host_fns.rs +++ /dev/null @@ -1,249 +0,0 @@ -//! Bollard-backed implementations of the `hm_docker_*` host fns. -//! -//! These wrap [`crate::orchestrator::docker_client::DockerClient`]. The -//! docker step-executor plugin calls these via Extism host-fn imports. - -use anyhow::{Context, Result}; -use hm_plugin_protocol::{DockerCommitArgs, DockerExecArgs, DockerExtractArgs, DockerStartArgs}; - -use super::state::current; - -// Workspace extract must be idempotent across snapshot reuse: when a -// parent snapshot is shared across different repos (e.g. an apt-base -// step's image cached on apt-package set only, then reused by two -// example projects), the previous repo's files in $WORKDIR would -// otherwise leak into the new run because `tar -xzf` overlays rather -// than mirrors. To keep this surgical, every extract writes a manifest -// of the paths it laid down to `$WORKDIR/.harmont-extracted`. The next -// extract reads that manifest, deletes only the paths the previous -// extract added (longest first so files go before their parent dirs), -// then unpacks the new archive (writing a fresh manifest). Files -// created inside the container by a step's command (e.g. `node_modules` -// after `npm ci`, build artifacts under `build/`) are not in any -// manifest, so they survive untouched — preserving the intra-chain -// artifact-passing semantics that toolchains rely on. -const EXTRACT_CMD_SH: &str = r#"set -e -mkdir -p "$WORKDIR" -cd "$WORKDIR" -manifest="$WORKDIR/.harmont-extracted" -if [ -f "$manifest" ]; then - # Longest paths first: removes nested entries before their parents. - sort -r "$manifest" | while IFS= read -r p; do - [ -n "$p" ] || continue - if [ -d "$p" ] && [ ! -L "$p" ]; then - rmdir "$p" 2>/dev/null || true - else - rm -f "$p" 2>/dev/null || true - fi - done - rm -f "$manifest" -fi -# Stream the archive into a temp file so we can both list and extract. -tmp=$(mktemp) -trap 'rm -f "$tmp"' EXIT -cat > "$tmp" -tar -tzf "$tmp" > "$manifest" -tar -xzf "$tmp" -"#; - -pub(crate) async fn ping_impl() -> bool { - let Some(s) = current() else { - return false; - }; - s.docker.ping().await.is_ok() -} - -pub(crate) async fn image_exists_impl(tag: String) -> bool { - let Some(s) = current() else { return false }; - s.docker.image_exists(&tag).await.unwrap_or(false) -} - -pub(crate) async fn pull_impl(tag: String) -> Result<()> { - let s = current().context("no orchestrator state")?; - let cancel = s.cancel.clone(); - let docker = s.docker.clone(); - let pull_fut = async move { docker.pull_image(&tag).await }; - tokio::select! { - result = pull_fut => result, - () = wait_cancel(&cancel) => Err(anyhow::anyhow!("cancelled during image pull")), - } -} - -pub(crate) async fn start_container_impl(args: DockerStartArgs) -> Result { - let s = current().context("no orchestrator state")?; - let env_vec: Vec = args - .env - .into_iter() - .map(|(k, v)| format!("{k}={v}")) - .collect(); - s.docker - .start_long_lived(&args.image, &env_vec, &args.workdir, &args.name_hint) - .await -} - -pub(crate) async fn extract_workspace_impl(args: DockerExtractArgs) -> Result<()> { - let s = current().context("no orchestrator state")?; - let archive = s.archives.read(args.archive_id, 0, u64::MAX); - if archive.is_empty() { - anyhow::bail!("archive {} is empty or unknown", args.archive_id); - } - let cancel = s.cancel.clone(); - let docker = s.docker.clone(); - let cid = args.container_id; - let workdir = args.workdir; - let cmd = vec![ - "sh".to_string(), - "-c".to_string(), - EXTRACT_CMD_SH.replace("$WORKDIR", &workdir), - ]; - let extract_fut = async move { - let mut sink = tokio::io::sink(); - let rc = docker - .exec_streaming_stdin(&cid, &cmd, &[], "/", &archive, &mut sink) - .await?; - if rc != 0 { - anyhow::bail!("tar extract exited {rc}"); - } - Ok::<(), anyhow::Error>(()) - }; - tokio::select! { - result = extract_fut => result, - () = wait_cancel(&cancel) => Err(anyhow::anyhow!("cancelled during workspace extract")), - } -} - -pub(crate) async fn exec_impl(args: DockerExecArgs) -> Result { - let s = current().context("no orchestrator state")?; - let env_vec: Vec = args - .env - .into_iter() - .map(|(k, v)| format!("{k}={v}")) - .collect(); - // Emit StepLog events for each line written; the writer below - // forwards bytes into the event bus tagged with the current - // thread-local step_id set by the scheduler. - let mut writer = StepLogWriter::default(); - - // Future doing the exec; we race it against cancellation. - let cancel = s.cancel.clone(); - let docker = s.docker.clone(); - let cid = args.container_id.clone(); - let cmd = args.cmd.clone(); - let workdir = args.workdir.clone(); - let archive_opt = args.stdin_archive_id; - let archive_bytes = archive_opt.map(|id| s.archives.read(id, 0, u64::MAX)); - - let exec_fut = async move { - let rc = match archive_bytes { - Some(bytes) => { - docker - .exec_streaming_stdin(&cid, &cmd, &env_vec, &workdir, &bytes, &mut writer) - .await? - } - None => { - docker - .exec_streaming(&cid, &cmd, &env_vec, &workdir, &mut writer) - .await? - } - }; - writer.flush_remaining(); - Ok::(rc) - }; - - let rc = tokio::select! { - result = exec_fut => result?, - () = wait_cancel(&cancel) => { - // Cancelled. Try to bail with the conventional sigint code. - return Ok(130); - } - }; - i32::try_from(rc).context("docker exit code out of i32 range") -} - -async fn wait_cancel(cancel: &tokio_util::sync::CancellationToken) { - cancel.cancelled().await; -} - -pub(crate) async fn commit_impl(args: DockerCommitArgs) -> Result { - let s = current().context("no orchestrator state")?; - s.docker - .commit_container(&args.container_id, &args.tag) - .await -} - -pub(crate) async fn remove_image_impl(tag: String) -> Result<()> { - let s = current().context("no orchestrator state")?; - s.docker.remove_image(&tag).await -} - -pub(crate) async fn stop_remove_impl(container_id: String) { - if let Some(s) = current() { - s.docker.stop_remove(&container_id).await; - } -} - -/// Streams bytes from a Docker exec into per-line `StepLog` events on -/// the event bus. Buffers partial lines until a `\n` arrives. -#[derive(smart_default::SmartDefault)] -struct StepLogWriter { - #[default(Vec::with_capacity(8192))] - buf: Vec, -} - -impl StepLogWriter { - fn flush_line(line: &[u8]) { - let Some(state) = current() else { return }; - let Some(step_id) = crate::plugin::host_fns::current_step_id() else { - return; - }; - state - .event_bus - .emit(hm_plugin_protocol::BuildEvent::StepLog { - step_id, - stream: hm_plugin_protocol::StdStream::Stdout, - line: String::from_utf8_lossy(line).into_owned(), - ts: chrono::Utc::now(), - }); - } - - fn flush_remaining(&mut self) { - if !self.buf.is_empty() { - let line = std::mem::take(&mut self.buf); - Self::flush_line(&line); - } - } -} - -impl tokio::io::AsyncWrite for StepLogWriter { - fn poll_write( - mut self: std::pin::Pin<&mut Self>, - _cx: &mut std::task::Context<'_>, - buf: &[u8], - ) -> std::task::Poll> { - let len = buf.len(); - for b in buf { - if *b == b'\n' { - let line = std::mem::take(&mut self.buf); - Self::flush_line(&line); - } else { - self.buf.push(*b); - } - } - std::task::Poll::Ready(Ok(len)) - } - - fn poll_flush( - self: std::pin::Pin<&mut Self>, - _cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - std::task::Poll::Ready(Ok(())) - } - - fn poll_shutdown( - mut self: std::pin::Pin<&mut Self>, - _cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - self.flush_remaining(); - std::task::Poll::Ready(Ok(())) - } -} diff --git a/crates/hm/src/orchestrator/mod.rs b/crates/hm/src/orchestrator/mod.rs index 2f9fe93..7dcb987 100644 --- a/crates/hm/src/orchestrator/mod.rs +++ b/crates/hm/src/orchestrator/mod.rs @@ -9,15 +9,13 @@ pub mod archive; pub mod cache; pub mod docker_client; -pub mod docker_host_fns; pub mod events; pub mod output_subscriber; pub mod scheduler; +pub mod signal; pub mod source; -pub mod state; pub use scheduler::run; -pub use state::OrchestratorState; /// Build a Docker image by running a v0 IR pipeline as a one-shot build /// container and committing the result to `image_tag`. diff --git a/crates/hm/src/orchestrator/output_subscriber.rs b/crates/hm/src/orchestrator/output_subscriber.rs index eefe584..18fad53 100644 --- a/crates/hm/src/orchestrator/output_subscriber.rs +++ b/crates/hm/src/orchestrator/output_subscriber.rs @@ -1,104 +1,48 @@ -//! Build-event subscriber that dispatches every `BuildEvent` into the -//! selected output-formatter plugin's `hm_output_on_event` capability. +//! Build-event subscriber that passes events to an [`OutputRenderer`]. //! -//! Replaces the plan-2 stop-gap `stderr_sink`. The subscriber acquires -//! an `Arc` from the registry per event; the actual -//! `call_capability` await happens AFTER the registry lock is dropped -//! so concurrent step-executor invocations do not contend with it. -//! Output plugins live in their own pool slot (default size 1) — only -//! this one subscriber task drains the bus, so a pool of 1 suffices. +//! Replaces the plan-2 plugin-based output subscriber with a simple +//! loop that calls [`OutputRenderer::on_event`] for each bus event. // Pedantic-bucket nags accepted at module scope: // - `needless_pass_by_value` on `bus`: the owned `Arc` makes -// the bus->subscriber handoff explicit at the call site, mirrors the -// plan-2 `stderr_sink::spawn_stderr_sink` shape. -// - `significant_drop_tightening`: the registry `MutexGuard` is held -// only across the synchronous `get` lookup; the `else` arms return -// from the spawn task and the happy path moves the `Arc` out and -// drops the guard naturally at the end of the inner block. The lint -// would have us sprinkle `drop(reg)` calls which add no clarity. +// the bus->subscriber handoff explicit at the call site. // - `print_stderr`: the Lagged arm intentionally bypasses the event // bus (which is the source of the lag) to surface a user-visible // drop signal, so an `eprintln!` direct to stderr is correct. #![allow( clippy::needless_pass_by_value, - clippy::significant_drop_tightening, clippy::print_stderr )] use std::sync::Arc; -use anyhow::Result; -use tokio::sync::Mutex; use tokio::sync::broadcast::error::RecvError; use super::events::EventBus; -use crate::plugin::PluginRegistry; +use crate::runner::OutputRenderer; /// Spawn the subscriber task. Returns a join handle the orchestrator /// awaits at shutdown so the `BuildEnd` event is fully drained. -/// -/// `format_name` must already exist in `registry.output_formatter_index` -/// — `scheduler::run` validates this before emitting `BuildStart`, so -/// a missing entry here means we lost a race against a concurrent -/// registry mutation (impossible in single-run orchestration). We drop -/// events silently in that case and exit on `BuildEnd`. #[must_use] pub fn spawn( bus: Arc, - registry: Arc>, - format_name: String, -) -> tokio::task::JoinHandle> { + mut renderer: Box, +) -> tokio::task::JoinHandle<()> { let mut rx = bus.subscribe(); tokio::spawn(async move { loop { match rx.recv().await { Ok(event) => { - // Resolve the plugin under the registry lock, then - // drop the lock before awaiting `call_capability` - // so concurrent step-executor calls keep flowing. - let plugin = { - let reg = registry.lock().await; - let Some(&idx) = reg.output_formatter_index.get(&format_name) else { - // No plugin for this format; CLI parser - // should have caught this. Drain silently. - if event.is_build_end() { - return Ok(()); - } - continue; - }; - let Some(p) = reg.get(idx) else { - if event.is_build_end() { - return Ok(()); - } - continue; - }; - p - }; let is_end = event.is_build_end(); - // Log-and-continue on formatter failures: a broken - // output plugin shouldn't fail the build. - let _: Result<()> = plugin.call_capability("hm_output_on_event", &event).await; + renderer.on_event(&event); if is_end { - // Finalise if the plugin exports it. Tolerate - // missing/erroring export — most streaming - // formatters don't implement it. - let _: Result> = - plugin.call_capability("hm_output_finalize", &()).await; - return Ok(()); + return; } } - Err(RecvError::Closed) => return Ok(()), + Err(RecvError::Closed) => return, Err(RecvError::Lagged(n)) => { - tracing::warn!( - target: "orchestrator", - "output_subscriber: dropped {n} build events (subscriber fell behind)" - ); - // Also surface to the user: send a synthetic stderr line via - // the host's write_stderr fn directly. This bypasses the - // event bus (which is the source of the lag), so it can't - // contribute to the lag we're reporting. - eprintln!("[output] dropped {n} build events (subscriber fell behind)"); + tracing::warn!("output: dropped {n} events"); + eprintln!("[output] dropped {n} build events"); } } } diff --git a/crates/hm/src/orchestrator/scheduler.rs b/crates/hm/src/orchestrator/scheduler.rs index fab51cd..2be4273 100644 --- a/crates/hm/src/orchestrator/scheduler.rs +++ b/crates/hm/src/orchestrator/scheduler.rs @@ -3,7 +3,7 @@ //! Walks the pipeline DAG in topological order, spawning a shared //! future per step. Each future awaits its predecessors, acquires a //! parallelism permit, and dispatches the step to its registered -//! executor plugin (Docker by default). +//! runner (Docker by default). // Pedantic-bucket nags accepted at module scope: // - `cast_possible_truncation`: every `as u64` here is a millisecond @@ -19,12 +19,7 @@ clippy::cast_possible_truncation, clippy::expect_used, clippy::too_many_lines, - clippy::missing_panics_doc, - // `significant_drop_tightening`: the registry MutexGuard in the - // --format validation block is held only across constant-time - // hash-map lookups; the lint would have us scatter `drop(reg)` - // calls that add no clarity. - clippy::significant_drop_tightening + clippy::missing_panics_doc )] use std::collections::HashMap; @@ -40,7 +35,6 @@ use anyhow::{Context, Result}; use hm_plugin_protocol::{ ArchiveId, BuildEvent, ExecutorInput, PlanSummary, SnapshotRef, StepResult, }; -use tokio::sync::Mutex; use uuid::Uuid; use hm_pipeline_ir::{EdgeKind, PipelineGraph, Transition}; @@ -48,13 +42,12 @@ use hm_pipeline_ir::{EdgeKind, PipelineGraph, Transition}; use crate::error::HmError; use crate::orchestrator::docker_client::DockerClient; use crate::orchestrator::source::build_archive_bytes; -use crate::plugin::{PluginRegistry, RegistryConfig}; +use crate::runner::{OutputRenderer, RunContext, RunnerRegistry}; use super::archive::ArchiveStore; use super::cache; use tokio_util::sync::CancellationToken; use super::events::EventBus; -use super::state::{self, OrchestratorState}; #[derive(Clone)] struct StepOutcome { @@ -69,21 +62,22 @@ type StepFuture = futures::future::Shared>; /// when any step exited non-zero). /// /// # Errors -/// Returns an error if plugin discovery fails, the source archive -/// cannot be built, the Docker daemon is unreachable, or any -/// scheduler-level failure occurs. Non-zero step exit codes are -/// surfaced via the returned `i32`, not as an Err. +/// Returns an error if the source archive cannot be built, the Docker +/// daemon is unreachable, or any scheduler-level failure occurs. +/// Non-zero step exit codes are surfaced via the returned `i32`, not +/// as an Err. pub async fn run( graph: PipelineGraph, repo_root: PathBuf, parallelism: usize, - format_name: String, + runner_registry: Arc, + renderer: Box, ) -> Result { // Set up per-run state. let bus = EventBus::new(); - let archives = ArchiveStore::new(); + let archives = Arc::new(ArchiveStore::new()); let cancel = CancellationToken::new(); - let _ctrlc = crate::plugin::signal::install_ctrlc(cancel.clone()); + let _ctrlc = super::signal::install_ctrlc(cancel.clone()); // _ctrlc dropped at end of `run`; runtime tear-down kills the task. let docker = DockerClient::connect() .map_err(|e| HmError::Docker(format!("daemon unreachable — is Docker running? ({e})")))?; @@ -97,76 +91,20 @@ pub async fn run( let archive_bytes = build_archive_bytes(&repo_root).context("build source archive")?; let archive_id = archives.register(archive_bytes); - // Install per-run state for host fns to read. - let state_arc = Arc::new(OrchestratorState { + let run_ctx = RunContext { + docker: docker.clone(), event_bus: bus.clone(), - archives, + archives: archives.clone(), cancel: cancel.clone(), - docker: docker.clone(), - run_id, - }); - state::install(state_arc.clone()); + }; let parallelism = parallelism.max(1); - // Load the plugin registry with the embedded docker plugin. - // The docker runner's pool gets pre-sized to `parallelism` so - // concurrent chains can run truly in parallel rather than - // serialising on a single plugin instance. - let mut pool_sizes: std::collections::BTreeMap = - std::collections::BTreeMap::new(); - pool_sizes.insert("docker".to_string(), parallelism); - let registry = Arc::new(Mutex::new( - PluginRegistry::load(RegistryConfig { - auto_discover: true, - extra_paths: vec![], - embedded: vec![ - ( - "harmont-docker", - crate::plugin::embedded::DOCKER_PLUGIN_WASM, - ), - ( - "harmont-output-human", - crate::plugin::embedded::OUTPUT_HUMAN_PLUGIN_WASM, - ), - ( - "harmont-output-json", - crate::plugin::embedded::OUTPUT_JSON_PLUGIN_WASM, - ), - ], - pool_sizes, - }) - .context("load plugin registry")?, - )); - - // Validate the requested output format BEFORE emitting BuildStart - // so an invalid `--format` fails fast without producing any output. - // We materialise the available list under the lock and then drop - // the guard before the (rare) bail to satisfy - // `clippy::significant_drop_tightening`. - let bad_format: Option> = { - let reg = registry.lock().await; - if reg.output_formatter_index.contains_key(&format_name) { - None - } else { - let mut names: Vec = reg.output_formatter_index.keys().cloned().collect(); - names.sort(); - Some(names) - } - }; - if let Some(available) = bad_format { - anyhow::bail!( - "unknown --format '{format_name}'; available: {}", - available.join(", ") - ); - } - let semaphore = Arc::new(tokio::sync::Semaphore::new(parallelism)); // Spawn the output subscriber. Dispatches every BuildEvent to the - // selected output-formatter plugin (default: `human`). - let sink_handle = - super::output_subscriber::spawn(bus.clone(), registry.clone(), format_name.clone()); + // pre-constructed renderer. + let sink_handle = super::output_subscriber::spawn(bus.clone(), renderer); let dag = graph.dag(); let chain_info = compute_chain_info(dag); @@ -180,7 +118,10 @@ pub async fn run( plan: PlanSummary { step_count: graph.node_count(), chain_count: chain_info.chain_count, - default_runner: "docker".into(), + default_runner: runner_registry + .default_runner_name() + .unwrap_or("docker") + .into(), }, started_at, }); @@ -200,9 +141,10 @@ pub async fn run( let chain_id = chain_info.node_chain_id[&n]; let chain_pos = chain_info.node_chain_pos[&n]; let sem = semaphore.clone(); - let reg = registry.clone(); + let reg = runner_registry.clone(); let bus = bus.clone(); let cancel = cancel.clone(); + let run_ctx = run_ctx.clone(); let fut: StepFuture = async move { // Await all predecessors. @@ -237,6 +179,7 @@ pub async fn run( chain_pos, archive_id, run_id, + run_ctx, reg, bus, cancel, @@ -272,8 +215,6 @@ pub async fn run( let _ = tokio::time::timeout(std::time::Duration::from_secs(2), sink_handle).await; - state::clear(); - drop(state_arc); Ok(overall) } @@ -281,7 +222,7 @@ pub async fn run( /// /// On cache hit the function returns early with exit code 0 and the /// cached snapshot so downstream nodes receive the correct -/// `parent_snapshot` without running the plugin at all. +/// `parent_snapshot` without running the runner at all. /// /// On non-zero exit the cancellation token is cancelled so sibling /// tasks observe the failure promptly. @@ -294,7 +235,8 @@ async fn execute_step( chain_pos: usize, archive_id: ArchiveId, run_id: Uuid, - registry: Arc>, + run_ctx: RunContext, + runner_registry: Arc, bus: Arc, cancel: CancellationToken, ) -> Result { @@ -310,10 +252,7 @@ async fn execute_step( }); // Decide cache outcome host-side. - let decision = { - let s = state::current().context("no orchestrator state")?; - cache::decide(&s.docker, &step_wire).await? - }; + let decision = cache::decide(&run_ctx.docker, &step_wire).await?; if let hm_plugin_protocol::CacheDecision::Hit { tag } = &decision { bus.emit(BuildEvent::StepCacheHit { step_id, @@ -325,7 +264,7 @@ async fn execute_step( tag: tag.0.clone(), }); // Short-circuit: the cached image already exists locally, so - // there is nothing for the executor plugin to do. Return the + // there is nothing for the executor to do. Return the // snapshot so downstream nodes can use it as their parent. return Ok(StepOutcome { exit_code: 0, @@ -344,43 +283,36 @@ async fn execute_step( parent_snapshot, }; - // Resolve the runner plugin name. Steps that didn't declare a - // runner fall back to whichever plugin registered as - // `default: true` (docker, in the embedded binary). - let runner = if let Some(name) = input.step.runner.clone() { - name - } else { - let reg = registry.lock().await; - reg.default_runner_name() - .map_or_else(|| "docker".into(), str::to_string) - }; + // Resolve the runner by name. Steps that didn't declare a runner + // fall back to whichever runner was registered as default (docker). + let runner_name = input + .step + .runner + .as_deref() + .or_else(|| runner_registry.default_runner_name()) + .unwrap_or("docker") + .to_owned(); + let started = Instant::now(); bus.emit(BuildEvent::StepStart { step_id, - runner: runner.clone(), + runner: runner_name.clone(), image: input.step.image.clone(), }); - // Dispatch to the runner-named plugin. Look up the Arc under the - // registry lock, drop the lock BEFORE awaiting so other tasks can - // dispatch concurrently. - let plugin = { - let reg = registry.lock().await; - let idx = reg - .runner_index - .get(&runner) - .copied() - .or(reg.default_runner) - .ok_or_else(|| HmError::UnknownRunner { - step_key: input.step.key.clone(), - runner: runner.clone(), - available: reg.runner_index.keys().cloned().collect(), - })?; - reg.get(idx).context("plugin moved away under us")? - }; - crate::plugin::host_fns::set_current_step_id(step_id); - let result: Result = plugin.call_capability("hm_executor_run", &input).await; - crate::plugin::host_fns::clear_current_step_id(); + let runner = runner_registry + .resolve(input.step.runner.as_deref()) + .ok_or_else(|| HmError::UnknownRunner { + step_key: input.step.key.clone(), + runner: runner_name.clone(), + available: runner_registry + .runner_names() + .into_iter() + .map(str::to_owned) + .collect(), + })?; + + let result: Result = runner.execute(&run_ctx, input).await; let dur_ms = started.elapsed().as_millis() as u64; match result { diff --git a/crates/hm/src/plugin/signal.rs b/crates/hm/src/orchestrator/signal.rs similarity index 96% rename from crates/hm/src/plugin/signal.rs rename to crates/hm/src/orchestrator/signal.rs index ebe5c1b..f5f6917 100644 --- a/crates/hm/src/plugin/signal.rs +++ b/crates/hm/src/orchestrator/signal.rs @@ -1,7 +1,7 @@ //! Bridges OS signals to the orchestrator's `CancellationToken`. //! //! Today's hm process: a single tokio runtime serving one CLI command. -//! Ctrl-C should: (1) flip the token so plugins drain quickly; (2) +//! Ctrl-C should: (1) flip the token so runners drain quickly; (2) //! exit with code 130 (sigint). // Pedantic-bucket nags accepted at module scope: diff --git a/crates/hm/src/orchestrator/state.rs b/crates/hm/src/orchestrator/state.rs deleted file mode 100644 index e5776a0..0000000 --- a/crates/hm/src/orchestrator/state.rs +++ /dev/null @@ -1,72 +0,0 @@ -//! Per-run state visible to host functions. -//! -//! The plan-1 host fns global `HostState` does not carry per-run -//! context — the orchestrator does. Host fns that consult per-run -//! state read it from this `OnceLock>` that -//! the orchestrator installs at the start of each run. - -// `clear()` is currently a no-op but is part of the lifecycle API -// the orchestrator calls at end-of-run; flipping it to `const fn` -// would break callers when we add an `OnceLock::take()`-like body -// in a future plan. -#![allow(clippy::missing_const_for_fn)] -// `expect`/`panic!` on the OnceLock install is the documented panic -// path for the "concurrent orchestrator runs" invariant; using `?` -// or returning a Result would force every caller into error handling -// for a programming-error case. -#![allow(clippy::expect_used)] -#![allow(clippy::panic)] - -use std::sync::{Arc, OnceLock}; - -use uuid::Uuid; - -use crate::orchestrator::docker_client::DockerClient; - -use super::archive::ArchiveStore; -use tokio_util::sync::CancellationToken; -use super::events::EventBus; - -/// Live state visible to every host fn while an orchestrator run is -/// active. -/// -/// Hosted via the [`current()`] / [`install()`] / [`clear()`] trio so -/// host-fn implementations can read it without an extra -/// argument-passing channel. -#[derive(Debug)] -pub struct OrchestratorState { - pub event_bus: Arc, - pub archives: ArchiveStore, - pub cancel: CancellationToken, - pub docker: DockerClient, - pub run_id: Uuid, -} - -static CURRENT: OnceLock> = OnceLock::new(); - -/// Install per-run state for the duration of an orchestrator run. -/// -/// # Panics -/// -/// Panics if state is installed twice — the host runs one -/// orchestrator at a time for plan 2. -pub fn install(state: Arc) { - assert!( - CURRENT.set(state).is_ok(), - "OrchestratorState already installed; concurrent orchestrator runs are not supported" - ); -} - -/// Clear the installed state. Idempotent (no-op when nothing was -/// installed). Use at end-of-run. -pub fn clear() { - // OnceLock has no take(); we leak the Arc on each run. The orchestrator - // is invoked once per process lifetime today, so this is fine. - // Long-running daemons that orchestrate would need a different shape. -} - -/// Get a handle to the live state, if any. -#[must_use] -pub fn current() -> Option> { - CURRENT.get().cloned() -} diff --git a/crates/hm/src/output/human.rs b/crates/hm/src/output/human.rs new file mode 100644 index 0000000..5ef4c75 --- /dev/null +++ b/crates/hm/src/output/human.rs @@ -0,0 +1,198 @@ +//! Human-readable [`OutputRenderer`] — replaces the former +//! `hm-plugin-output-human` WASM plugin with a plain struct that +//! writes formatted lines to any [`std::io::Write`] target. + +use std::collections::HashMap; +use std::fmt; +use std::io::Write; + +use hm_plugin_protocol::BuildEvent; +use uuid::Uuid; + +use crate::runner::OutputRenderer; + +/// Renders [`BuildEvent`]s as human-readable log lines. +/// +/// Generic over the writer so tests can capture output into a +/// `Vec` while production code writes to `stderr`. +#[derive(Debug)] +pub struct HumanRenderer { + out: W, + step_keys: HashMap, +} + +impl HumanRenderer { + /// Create a new renderer writing to `out`. + #[must_use] + pub fn new(out: W) -> Self { + Self { + out, + step_keys: HashMap::new(), + } + } +} + +impl HumanRenderer +where + W: Write, +{ + /// Look up the human-readable key for a step, falling back to `"?"`. + fn step_key(&self, id: &Uuid) -> &str { + self.step_keys.get(id).map_or("?", String::as_str) + } +} + +impl OutputRenderer for HumanRenderer +where + W: Write + Send + fmt::Debug, +{ + fn on_event(&mut self, event: &BuildEvent) { + let bytes: Vec = match event { + BuildEvent::BuildStart { plan, .. } => format!( + "build: {} steps in {} chain(s)\n", + plan.step_count, plan.chain_count, + ) + .into_bytes(), + + BuildEvent::StepQueued { step_id, key, .. } => { + self.step_keys.insert(*step_id, key.clone()); + return; // no visible output + } + + BuildEvent::StepStart { + step_id, + runner, + image, + } => { + let key = self.step_key(step_id); + image.as_ref().map_or_else( + || format!("[{key}] start (runner={runner})\n"), + |img| format!("[{key}] start (runner={runner} image={img})\n"), + ) + .into_bytes() + } + + BuildEvent::StepLog { + step_id, line, .. + } => { + let key = self.step_key(step_id); + format!("[{key}] {line}\n").into_bytes() + } + + BuildEvent::StepCacheHit { + step_id, tag, .. + } => { + let key = self.step_key(step_id); + format!("[{key}] cache hit ({tag})\n").into_bytes() + } + + BuildEvent::StepEnd { + step_id, + exit_code, + duration_ms, + .. + } => { + let key = self.step_key(step_id); + format!("[{key}] end exit={exit_code} duration={duration_ms}ms\n").into_bytes() + } + + BuildEvent::BuildEnd { + exit_code, + duration_ms, + } => { + format!("build: end exit={exit_code} duration={duration_ms}ms\n").into_bytes() + } + + BuildEvent::ChainFailed { + chain_idx, + failed_step_key, + exit_code, + message, + .. + } => format!( + "chain {chain_idx}: FAILED at step '{failed_step_key}' (exit={exit_code}): {message}\n" + ) + .into_bytes(), + }; + + let _ = self.out.write_all(&bytes); + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use hm_plugin_protocol::{PlanSummary, StdStream}; + + /// Helper: create a renderer backed by an in-memory buffer. + fn renderer() -> HumanRenderer> { + HumanRenderer::new(Vec::new()) + } + + /// Helper: drain the buffer as a UTF-8 string. + fn output(r: &HumanRenderer>) -> String { + String::from_utf8(r.out.clone()).unwrap() + } + + #[test] + fn build_start_renders_counts() { + let mut r = renderer(); + r.on_event(&BuildEvent::BuildStart { + run_id: Uuid::nil(), + plan: PlanSummary { + step_count: 5, + chain_count: 3, + default_runner: "docker".into(), + }, + started_at: chrono::Utc::now(), + }); + + let s = output(&r); + assert!(s.contains("5 steps"), "expected step count: {s}"); + assert!(s.contains("3 chain(s)"), "expected chain count: {s}"); + } + + #[test] + fn step_log_with_key() { + let mut r = renderer(); + let step_id = Uuid::new_v4(); + + // Queue the step so the key is recorded. + r.on_event(&BuildEvent::StepQueued { + step_id, + key: "build".into(), + chain_idx: 0, + }); + + r.on_event(&BuildEvent::StepLog { + step_id, + stream: StdStream::Stdout, + line: "compiling...".into(), + ts: chrono::Utc::now(), + }); + + let s = output(&r); + assert_eq!(s, "[build] compiling...\n"); + } + + #[test] + fn step_log_unknown_key() { + let mut r = renderer(); + + // Emit a log without a prior StepQueued. + r.on_event(&BuildEvent::StepLog { + step_id: Uuid::new_v4(), + stream: StdStream::Stdout, + line: "orphan line".into(), + ts: chrono::Utc::now(), + }); + + let s = output(&r); + assert!(s.starts_with("[?]"), "expected [?] prefix: {s}"); + } +} diff --git a/crates/hm/src/output/json.rs b/crates/hm/src/output/json.rs new file mode 100644 index 0000000..559567a --- /dev/null +++ b/crates/hm/src/output/json.rs @@ -0,0 +1,37 @@ +//! JSON-lines [`OutputRenderer`] — replaces the former +//! `hm-plugin-output-json` WASM plugin. Serialises each +//! [`BuildEvent`] as a single JSON line. + +use std::fmt; +use std::io::Write; + +use hm_plugin_protocol::BuildEvent; + +use crate::runner::OutputRenderer; + +/// Renders [`BuildEvent`]s as newline-delimited JSON (one object per +/// line). Suitable for piping into `jq` or other machine consumers. +#[derive(Debug)] +pub struct JsonRenderer { + out: W, +} + +impl JsonRenderer { + /// Create a new renderer writing to `out`. + #[must_use] + pub const fn new(out: W) -> Self { + Self { out } + } +} + +impl OutputRenderer for JsonRenderer +where + W: Write + Send + fmt::Debug, +{ + fn on_event(&mut self, event: &BuildEvent) { + if let Ok(mut bytes) = serde_json::to_vec(event) { + bytes.push(b'\n'); + let _ = self.out.write_all(&bytes); + } + } +} diff --git a/crates/hm/src/output/mod.rs b/crates/hm/src/output/mod.rs index 64b63e5..1d3f8bb 100644 --- a/crates/hm/src/output/mod.rs +++ b/crates/hm/src/output/mod.rs @@ -1,4 +1,6 @@ pub mod format; +pub mod human; +pub mod json; pub mod spinner; pub mod status; diff --git a/crates/hm/src/plugin/embedded.rs b/crates/hm/src/plugin/embedded.rs deleted file mode 100644 index 46d918a..0000000 --- a/crates/hm/src/plugin/embedded.rs +++ /dev/null @@ -1,21 +0,0 @@ -//! Embedded plugin bytes. Compiled by `build.rs`. - -/// Bytes of the in-tree Docker step-executor plugin. Always loaded -/// by the orchestrator at run start. -pub static DOCKER_PLUGIN_WASM: &[u8] = - include_bytes!(concat!(env!("OUT_DIR"), "/hm_plugin_docker.wasm")); - -/// Bytes of the in-tree human-readable output-formatter plugin. -/// Loaded when `--format human` (the default) is selected. -pub static OUTPUT_HUMAN_PLUGIN_WASM: &[u8] = - include_bytes!(concat!(env!("OUT_DIR"), "/hm_plugin_output_human.wasm")); - -/// Bytes of the in-tree JSON-lines output-formatter plugin. -/// Loaded when `--format json` is selected. -pub static OUTPUT_JSON_PLUGIN_WASM: &[u8] = - include_bytes!(concat!(env!("OUT_DIR"), "/hm_plugin_output_json.wasm")); - -/// Bytes of the in-tree cloud client plugin (`hm cloud …`). Loaded by -/// the host dispatcher whenever the user invokes the `cloud` verb. -pub static CLOUD_PLUGIN_WASM: &[u8] = - include_bytes!(concat!(env!("OUT_DIR"), "/hm_plugin_cloud.wasm")); diff --git a/crates/hm/src/plugin/host.rs b/crates/hm/src/plugin/host.rs deleted file mode 100644 index 46e8614..0000000 --- a/crates/hm/src/plugin/host.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! Thin wrapper around `extism::Plugin` instances loaded into a -//! per-plugin pool. Concurrent invocations from chain tasks acquire -//! a pool slot rather than blocking on a single plugin instance. - -// Pedantic-bucket nags that don't add safety on this module: -// - `missing_errors_doc`: every public fn here returns `anyhow::Result` -// with a context message; an `# Errors` section would just restate it. -// - `significant_drop_tightening` on `call_capability`: the `PoolGuard` -// intentionally lives until after `serde_json::from_slice` returns, -// because the `&[u8]` we just borrowed from the plugin's memory -// only stays valid while the plugin instance is in scope. -#![allow(clippy::missing_errors_doc, clippy::significant_drop_tightening)] - -use std::path::PathBuf; - -use anyhow::{Context, Result}; -use hm_plugin_protocol::PluginManifest; - -use super::pool::PluginPool; -use crate::error::HmError; - -#[derive(Debug)] -pub struct LoadedPlugin { - pub manifest: PluginManifest, - /// Path the plugin was loaded from. `None` if loaded from embedded - /// bytes (`include_bytes!`). - pub source: Option, - pool: PluginPool, -} - -impl LoadedPlugin { - /// Build a plugin from an on-disk `.wasm` file. The Extism manifest - /// disables WASI filesystem access entirely (host-mediated reads - /// only). - /// - /// Two-phase load: instantiate with no allowed hosts, read the - /// plugin's [`PluginManifest`], then rebuild the pool with the - /// allowlist the plugin declared. The throwaway pool is dropped - /// before the real one is built. - pub fn from_file(path: PathBuf, max_instances: usize) -> Result { - let probe = PluginPool::from_file(path.clone(), max_instances) - .with_context(|| format!("load plugin from {}", path.display()))?; - let manifest = read_manifest(&probe)?; - drop(probe); - let pool = PluginPool::from_file_with_hosts( - path.clone(), - max_instances, - manifest.allowed_hosts.clone(), - ) - .with_context(|| format!("reload plugin from {} with allowed_hosts", path.display()))?; - Ok(Self { - manifest, - source: Some(path), - pool, - }) - } - - /// Build a plugin from embedded bytes (used for in-tree builtins). - /// - /// Two-phase load: see [`LoadedPlugin::from_file`]. - pub fn from_bytes(bytes: &'static [u8], max_instances: usize) -> Result { - let probe = PluginPool::from_bytes(bytes, max_instances).context("load embedded plugin")?; - let manifest = read_manifest(&probe)?; - drop(probe); - let pool = - PluginPool::from_bytes_with_hosts(bytes, max_instances, manifest.allowed_hosts.clone()) - .context("reload embedded plugin with allowed_hosts")?; - Ok(Self { - manifest, - source: None, - pool, - }) - } - - /// Call a capability export. Acquires a pool slot for the duration - /// of the call, then returns it. Generic over the input/output - /// types. - /// - /// The `Send + Sync` bound on `I` is required so the returned - /// future is `Send` — chain tasks await this future across a - /// `tokio::spawn` boundary. - pub async fn call_capability(&self, export: &str, input: &I) -> Result - where - I: serde::Serialize + Sync, - O: serde::de::DeserializeOwned, - { - let in_bytes = serde_json::to_vec(input).context("serialise capability input")?; - let mut guard = self - .pool - .acquire() - .await - .context("acquire plugin instance")?; - // Set the per-plugin thread-local so `hm_kv_*` host fns can - // resolve `KvScope::Plugin` to the right on-disk file. - crate::plugin::host_fns::set_current_plugin_name(self.manifest.name.clone()); - let call_result = guard.plugin().call::, &[u8]>(export, in_bytes); - crate::plugin::host_fns::clear_current_plugin_name(); - let out_bytes = call_result.map_err(|e| HmError::PluginPanic { - name: self.manifest.name.clone(), - capability: export.to_string(), - message: e.to_string(), - })?; - serde_json::from_slice(out_bytes).context("decode capability output") - } -} - -/// Test helper: synthesises a `SubcommandInput` shaped JSON value for -/// the `host_fn_probe` fixture and any other integration test that -/// needs a minimal valid input to `hm_subcommand_run`. -/// -/// `#[doc(hidden)]` because this is not part of the production public -/// API; it exists so `tests/*.rs` integration tests (which see only -/// the public surface) can call into it without a separate feature -/// flag. -#[doc(hidden)] -#[must_use] -pub fn dummy_subcommand_input() -> serde_json::Value { - serde_json::json!({ - "verb_path": ["fixture-probe"], - "args": {}, - "env": {} - }) -} - -/// Read the manifest from a freshly-instantiated plugin. Runs the -/// `hm_manifest` export and decodes the JSON. -/// -/// Loading happens synchronously from startup paths (`hm version`, -/// `hm plugin list`) as well as from inside an existing tokio runtime -/// (`orchestrator::scheduler::run`). Use the current handle if -/// present; otherwise spin up a small single-threaded runtime. -fn read_manifest(pool: &PluginPool) -> Result { - let task = async { - let mut guard = pool.acquire().await?; - let bytes = guard - .plugin() - .call::<&str, &[u8]>("hm_manifest", "") - .context("call hm_manifest")? - .to_vec(); - let manifest: PluginManifest = - serde_json::from_slice(&bytes).context("decode hm_manifest output")?; - Ok::(manifest) - }; - if let Ok(handle) = tokio::runtime::Handle::try_current() { - tokio::task::block_in_place(|| handle.block_on(task)) - } else { - // No runtime; spin up a tiny one. Happens only when - // `LoadedPlugin::from_*` is called from a truly synchronous - // entry point (none in production today — kept for robustness - // and unit tests that drive `LoadedPlugin` directly). - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .context("build adhoc tokio runtime for manifest read")?; - rt.block_on(task) - } -} diff --git a/crates/hm/src/plugin/host_fns.rs b/crates/hm/src/plugin/host_fns.rs deleted file mode 100644 index d7c1f7e..0000000 --- a/crates/hm/src/plugin/host_fns.rs +++ /dev/null @@ -1,1068 +0,0 @@ -//! All host functions exported to plugins. The exhaustive list lives -//! in the design spec §3.3; this file is the single source of truth -//! for which fn names exist and what types they accept. - -// `extism::host_fn!` expands to plain `pub fn` items whose bodies do -// `plugin.memory_get_val(&inputs[i])` for each arg. The macro produces -// expressions clippy wants to grumble about (needless pass-by-value of -// `Json` newtypes; non-erroring `Ok(())` wrappers); we accept the -// macro idiom rather than fight it at every call site. Scope is this file. -#![allow(clippy::needless_pass_by_value)] -#![allow(clippy::unnecessary_wraps)] -#![allow(clippy::wildcard_imports)] -#![allow(clippy::missing_errors_doc)] -// extism wraps every host-fn arg/ret through `MemoryHandle`, which is a -// 64-bit pointer; cast-precision warnings are not actionable here. -#![allow(clippy::cast_possible_truncation)] -#![allow(clippy::cast_sign_loss)] -// `all()` is intentionally a single Vec literal — splitting it would obscure -// the 1:1 mapping between HOST_FN_NAMES and the constructed Function set. -#![allow(clippy::too_many_lines)] -// The tiny `pty` helper sits adjacent to its only call site inside `all()`; -// hoisting it to module scope would force readers to jump out of the table. -#![allow(clippy::items_after_statements)] -// Several `*_impl` fns are no-op stubs that could be `const fn` today -// but will gain side-effecting bodies in Plan 2; flipping them now would -// mean another churn pass. -#![allow(clippy::missing_const_for_fn)] -// `GLOBAL.lock().map(|s| s.cancel).unwrap_or(false)` reads as -// "treat host-fn failure as 'not cancelled'"; collapsing to `is_ok_and` -// would obscure the fallback intent. -#![allow(clippy::map_unwrap_or)] -// `Lazy::new(|| …)` is the once_cell idiom we use across the workspace; -// the `LazyLock` migration is a separate sweep. -#![allow(clippy::incompatible_msrv)] -#![allow(clippy::non_std_lazy_statics)] -// `GLOBAL.lock()` returns a guard with significant `Drop`; clippy flags -// holding it across the `.get(key).cloned()` call. The lock IS the -// scrutinee on purpose — we want a coherent read. -#![allow(clippy::significant_drop_in_scrutinee)] -#![allow(clippy::significant_drop_tightening)] - -use std::collections::{BTreeMap, HashMap}; -use std::sync::{Arc, Mutex}; - -use extism::convert::Json; -use extism::{Function, PTR, UserData, ValType, host_fn}; -use hm_plugin_protocol::host_abi::{ - ArchiveReadArgs, CallbackData, KeyringArgs, KeyringSetArgs, KvScope, Level, LoopbackHandle, - LoopbackRecvArgs, SocketHandle, SocketReadArgs, SocketWriteArgs, TtyConfirmArgs, TtyPromptArgs, -}; -use hm_plugin_protocol::{ - ArchiveId, BuildEvent, DockerCommitArgs, DockerExecArgs, DockerExtractArgs, DockerStartArgs, - StdStream, -}; -use once_cell::sync::Lazy; - -/// The canonical list of host fns we expose. Plugin manifests are -/// validated against this set at load time. -pub const HOST_FN_NAMES: &[&str] = &[ - "hm_log", - "hm_emit_step_log", - "hm_emit_event", - "hm_kv_get", - "hm_kv_set", - "hm_archive_read", - "hm_archive_total_size", - "hm_fs_read_config", - "hm_unix_socket_connect", - "hm_socket_write", - "hm_socket_read", - "hm_socket_close", - "hm_keyring_get", - "hm_keyring_set", - "hm_keyring_delete", - "hm_tty_prompt", - "hm_tty_confirm", - "hm_browser_open", - "hm_spawn_loopback", - "hm_loopback_recv", - "hm_should_cancel", - "hm_docker_ping", - "hm_docker_image_exists", - "hm_docker_pull", - "hm_docker_start_container", - "hm_docker_extract_workspace", - "hm_docker_exec", - "hm_docker_commit", - "hm_docker_remove_image", - "hm_docker_stop_remove", - "hm_write_stdout", - "hm_write_stderr", -]; - -// ─── host_fn! declarations ────────────────────────────────────────────────── -// -// Each `host_fn!` invocation expands to a plain `pub fn name(...)` matching -// extism's host-fn signature. We wire each into a `Function` value below. - -host_fn!(pub _hm_log(_user_data: (); level: Json, msg: String) { - let Json(level) = level; - log_impl(level, &msg); - Ok(()) -}); - -host_fn!(pub _hm_emit_step_log(_user_data: (); stream: Json, bytes: Vec) { - let Json(stream) = stream; - emit_step_log_impl(stream, &bytes); - Ok(()) -}); - -host_fn!(pub _hm_emit_event(_user_data: (); event: Json) { - let Json(event) = event; - emit_event_impl(event); - Ok(()) -}); - -host_fn!(pub _hm_kv_get(_user_data: (); scope: Json, key: String) -> Json>> { - let Json(scope) = scope; - Ok(Json(kv_get_impl(scope, &key))) -}); - -host_fn!(pub _hm_kv_set(_user_data: (); scope: Json, key: String, val: Vec) { - let Json(scope) = scope; - kv_set_impl(scope, &key, val); - Ok(()) -}); - -host_fn!(pub _hm_archive_read(_user_data: (); args: Json) -> Vec { - let Json(args) = args; - Ok(archive_read_impl(args)) -}); - -host_fn!(pub _hm_archive_total_size(_user_data: (); id: Json) -> u64 { - let Json(id) = id; - Ok(archive_total_size_impl(id)) -}); - -host_fn!(pub _hm_fs_read_config(_user_data: (); rel_path: String) -> Json>> { - Ok(Json(fs_read_config_impl(&rel_path))) -}); - -host_fn!(pub _hm_unix_socket_connect(_user_data: (); path: String) -> Json { - Ok(Json(unix_socket_connect_impl(&path))) -}); - -host_fn!(pub _hm_socket_write(_user_data: (); args: Json) -> u64 { - let Json(args) = args; - Ok(socket_write_impl(args)) -}); - -host_fn!(pub _hm_socket_read(_user_data: (); args: Json) -> Vec { - let Json(args) = args; - Ok(socket_read_impl(args)) -}); - -host_fn!(pub _hm_socket_close(_user_data: (); h: Json) { - let Json(h) = h; - socket_close_impl(h); - Ok(()) -}); - -host_fn!(pub _hm_keyring_get(_user_data: (); args: Json) -> Json> { - let Json(args) = args; - Ok(Json(keyring_get_impl(&args.service, &args.account))) -}); - -host_fn!(pub _hm_keyring_set(_user_data: (); args: Json) { - let Json(args) = args; - keyring_set_impl(&args.service, &args.account, &args.secret); - Ok(()) -}); - -host_fn!(pub _hm_keyring_delete(_user_data: (); args: Json) { - let Json(args) = args; - keyring_delete_impl(&args.service, &args.account); - Ok(()) -}); - -host_fn!(pub _hm_tty_prompt(_user_data: (); args: Json) -> String { - let Json(args) = args; - Ok(tty_prompt_impl(&args.msg, args.mask)) -}); - -host_fn!(pub _hm_tty_confirm(_user_data: (); args: Json) -> u32 { - let Json(args) = args; - Ok(u32::from(tty_confirm_impl(&args.msg, args.default))) -}); - -host_fn!(pub _hm_browser_open(_user_data: (); url: String) -> u32 { - Ok(u32::from(browser_open_impl(&url))) -}); - -host_fn!(pub _hm_spawn_loopback(_user_data: (); port: Json>) -> Json { - let Json(port) = port; - let handle = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(spawn_loopback_impl_async(port)) - })?; - Ok(Json(handle)) -}); - -host_fn!(pub _hm_loopback_recv(_user_data: (); args: Json) -> Json> { - let Json(args) = args; - let data = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current().block_on(loopback_recv_impl_async(args)) - }); - Ok(Json(data)) -}); - -host_fn!(pub _hm_should_cancel(_user_data: ();) -> u32 { - Ok(u32::from(should_cancel_impl())) -}); - -host_fn!(pub _hm_docker_ping(_user_data: ();) -> u32 { - let ok = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current() - .block_on(crate::orchestrator::docker_host_fns::ping_impl()) - }); - Ok(u32::from(ok)) -}); - -host_fn!(pub _hm_docker_image_exists(_user_data: (); tag: String) -> u32 { - let exists = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current() - .block_on(crate::orchestrator::docker_host_fns::image_exists_impl(tag)) - }); - Ok(u32::from(exists)) -}); - -host_fn!(pub _hm_docker_pull(_user_data: (); tag: String) { - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current() - .block_on(crate::orchestrator::docker_host_fns::pull_impl(tag)) - })?; - Ok(()) -}); - -host_fn!(pub _hm_docker_start_container(_user_data: (); args: Json) -> String { - let Json(args) = args; - let id = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current() - .block_on(crate::orchestrator::docker_host_fns::start_container_impl(args)) - })?; - Ok(id) -}); - -host_fn!(pub _hm_docker_extract_workspace(_user_data: (); args: Json) { - let Json(args) = args; - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current() - .block_on(crate::orchestrator::docker_host_fns::extract_workspace_impl(args)) - })?; - Ok(()) -}); - -host_fn!(pub _hm_docker_exec(_user_data: (); args: Json) -> i32 { - let Json(args) = args; - let rc = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current() - .block_on(crate::orchestrator::docker_host_fns::exec_impl(args)) - })?; - Ok(rc) -}); - -host_fn!(pub _hm_docker_commit(_user_data: (); args: Json) -> String { - let Json(args) = args; - let tag = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current() - .block_on(crate::orchestrator::docker_host_fns::commit_impl(args)) - })?; - Ok(tag) -}); - -host_fn!(pub _hm_docker_remove_image(_user_data: (); tag: String) { - let _ = tokio::task::block_in_place(|| { - tokio::runtime::Handle::current() - .block_on(crate::orchestrator::docker_host_fns::remove_image_impl(tag)) - }); - Ok(()) -}); - -host_fn!(pub _hm_docker_stop_remove(_user_data: (); container_id: String) { - tokio::task::block_in_place(|| { - tokio::runtime::Handle::current() - .block_on(crate::orchestrator::docker_host_fns::stop_remove_impl(container_id)); - }); - Ok(()) -}); - -host_fn!(pub _hm_write_stdout(_user_data: (); bytes: Vec) { - write_stdout_impl(&bytes); - Ok(()) -}); - -host_fn!(pub _hm_write_stderr(_user_data: (); bytes: Vec) { - write_stderr_impl(&bytes); - Ok(()) -}); - -/// Returns the host function table passed into every `Plugin::new`. -/// -/// extism wraps every host-fn argument and return value as a 64-bit -/// memory handle (`PTR == ValType::I64`), regardless of the underlying -/// Rust type. So every `params: …` and `returns: …` list below is just -/// `[PTR; N]` where `N` is the arg/return arity. -pub fn all() -> Vec { - let ud: UserData<()> = UserData::default(); - fn pty(n: usize) -> Vec { - (0..n).map(|_| PTR).collect() - } - vec![ - Function::new("hm_log", pty(2), pty(0), ud.clone(), _hm_log), - Function::new( - "hm_emit_step_log", - pty(2), - pty(0), - ud.clone(), - _hm_emit_step_log, - ), - Function::new("hm_emit_event", pty(1), pty(0), ud.clone(), _hm_emit_event), - Function::new("hm_kv_get", pty(2), pty(1), ud.clone(), _hm_kv_get), - Function::new("hm_kv_set", pty(3), pty(0), ud.clone(), _hm_kv_set), - Function::new( - "hm_archive_read", - pty(1), - pty(1), - ud.clone(), - _hm_archive_read, - ), - Function::new( - "hm_archive_total_size", - pty(1), - pty(1), - ud.clone(), - _hm_archive_total_size, - ), - Function::new( - "hm_fs_read_config", - pty(1), - pty(1), - ud.clone(), - _hm_fs_read_config, - ), - Function::new( - "hm_unix_socket_connect", - pty(1), - pty(1), - ud.clone(), - _hm_unix_socket_connect, - ), - Function::new( - "hm_socket_write", - pty(1), - pty(1), - ud.clone(), - _hm_socket_write, - ), - Function::new( - "hm_socket_read", - pty(1), - pty(1), - ud.clone(), - _hm_socket_read, - ), - Function::new( - "hm_socket_close", - pty(1), - pty(0), - ud.clone(), - _hm_socket_close, - ), - Function::new( - "hm_keyring_get", - pty(1), - pty(1), - ud.clone(), - _hm_keyring_get, - ), - Function::new( - "hm_keyring_set", - pty(1), - pty(0), - ud.clone(), - _hm_keyring_set, - ), - Function::new( - "hm_keyring_delete", - pty(1), - pty(0), - ud.clone(), - _hm_keyring_delete, - ), - Function::new("hm_tty_prompt", pty(1), pty(1), ud.clone(), _hm_tty_prompt), - Function::new( - "hm_tty_confirm", - pty(1), - pty(1), - ud.clone(), - _hm_tty_confirm, - ), - Function::new( - "hm_browser_open", - pty(1), - pty(1), - ud.clone(), - _hm_browser_open, - ), - Function::new( - "hm_spawn_loopback", - pty(1), - pty(1), - ud.clone(), - _hm_spawn_loopback, - ), - Function::new( - "hm_loopback_recv", - pty(1), - pty(1), - ud.clone(), - _hm_loopback_recv, - ), - Function::new( - "hm_should_cancel", - pty(0), - pty(1), - ud.clone(), - _hm_should_cancel, - ), - Function::new( - "hm_docker_ping", - pty(0), - pty(1), - ud.clone(), - _hm_docker_ping, - ), - Function::new( - "hm_docker_image_exists", - pty(1), - pty(1), - ud.clone(), - _hm_docker_image_exists, - ), - Function::new( - "hm_docker_pull", - pty(1), - pty(0), - ud.clone(), - _hm_docker_pull, - ), - Function::new( - "hm_docker_start_container", - pty(1), - pty(1), - ud.clone(), - _hm_docker_start_container, - ), - Function::new( - "hm_docker_extract_workspace", - pty(1), - pty(0), - ud.clone(), - _hm_docker_extract_workspace, - ), - Function::new( - "hm_docker_exec", - pty(1), - pty(1), - ud.clone(), - _hm_docker_exec, - ), - Function::new( - "hm_docker_commit", - pty(1), - pty(1), - ud.clone(), - _hm_docker_commit, - ), - Function::new( - "hm_docker_remove_image", - pty(1), - pty(0), - ud.clone(), - _hm_docker_remove_image, - ), - Function::new( - "hm_docker_stop_remove", - pty(1), - pty(0), - ud, - _hm_docker_stop_remove, - ), - Function::new( - "hm_write_stdout", - [ValType::I64], - [], - UserData::default(), - _hm_write_stdout, - ), - Function::new( - "hm_write_stderr", - [ValType::I64], - [], - UserData::default(), - _hm_write_stderr, - ), - ] -} - -// ─── Implementations (minimal, correct, lockable). ────────────────────────── -// "Minimal" here means: the simple host-side behaviour that fixture -// tests in Task 28 will exercise. Heavier behaviours (real cancellation -// propagation, archive byte-streaming under load) get hardened in -// later plans when real plugins drive them. - -static GLOBAL: Lazy> = Lazy::new(|| Mutex::new(HostState::default())); - -#[derive(Debug, Default)] -struct HostState { - build_kv: BTreeMap>, - step_kv: BTreeMap>, - // `SocketHandle` only implements `Hash + Eq`, not `Ord`, so a - // `HashMap` is the right shape here. - sockets: HashMap>, - next_socket: u64, - /// Live loopback listeners. Keyed by the bound port (also the - /// returned `LoopbackHandle.0`). The `Arc` is shared - /// between the axum task and `loopback_recv_impl_async`. - /// `LoopbackHandle` implements `Hash + Eq` but not `Ord`, so this - /// is a `HashMap` rather than `BTreeMap` (same shape as `sockets`). - loopback_slots: HashMap>, -} - -/// Per-handle state for an in-flight loopback listener. -/// -/// `receiver` is `Some(_)` until the first `hm_loopback_recv` consumes -/// it; subsequent calls with the same handle return `None`. `shutdown_token` -/// is cancelled by the axum route closure after the first callback is -/// captured, which causes `axum::serve(..).with_graceful_shutdown(..)` to -/// return and the listener to close. -#[derive(Debug)] -struct LoopbackSlot { - receiver: tokio::sync::Mutex>>, - #[allow( - dead_code, - reason = "held to keep the token alive; cancellation is driven by the route closure's clone" - )] - shutdown_token: tokio_util::sync::CancellationToken, -} - -fn log_impl(level: Level, msg: &str) { - match level { - Level::Trace => tracing::trace!(target: "plugin", "{msg}"), - Level::Debug => tracing::debug!(target: "plugin", "{msg}"), - Level::Info => tracing::info!(target: "plugin", "{msg}"), - Level::Warn => tracing::warn!(target: "plugin", "{msg}"), - Level::Error => tracing::error!(target: "plugin", "{msg}"), - } -} - -fn emit_step_log_impl(stream: StdStream, bytes: &[u8]) { - let Some(state) = crate::orchestrator::state::current() else { - return; - }; - let Some(step_id) = current_step_id() else { - return; - }; - let line = String::from_utf8_lossy(bytes).into_owned(); - state.event_bus.emit(BuildEvent::StepLog { - step_id, - stream, - line, - ts: chrono::Utc::now(), - }); -} - -fn emit_event_impl(event: BuildEvent) { - if let Some(state) = crate::orchestrator::state::current() { - state.event_bus.emit(event); - } -} - -fn kv_get_impl(scope: KvScope, key: &str) -> Option> { - match scope { - KvScope::Plugin => load_plugin_kv().get(key).cloned(), - KvScope::Build | KvScope::Step => { - let s = GLOBAL.lock().ok()?; - let m = match scope { - KvScope::Build => &s.build_kv, - KvScope::Step => &s.step_kv, - KvScope::Plugin => unreachable!(), - }; - m.get(key).cloned() - } - } -} - -#[doc(hidden)] // pub for integration tests; not stable API -pub fn kv_set_impl(scope: KvScope, key: &str, val: Vec) { - match scope { - KvScope::Plugin => { - // Hold an exclusive advisory lock for the full read-modify-write - // window. Without this, concurrent writers each load the same map, - // insert into their own copy, and the second writer's atomic save - // clobbers the first writer's insert. See plugin_kv_concurrency.rs. - // - // If lock acquisition fails (no config dir, no current plugin - // name, fs error), we fall back to the prior unprotected write — - // better than dropping the value entirely. This matches the - // existing best-effort framing of save_plugin_kv. - let lock = lock_plugin_kv(); - if lock.is_none() { - tracing::warn!( - target: "plugin::kv", - "plugin-scope KV lock acquisition failed; \ - falling back to unprotected write. Concurrent \ - writers may lose updates." - ); - } - let mut kv = load_plugin_kv(); - kv.insert(key.to_string(), val); - save_plugin_kv(&kv); - // `lock` drops here, releasing the file lock. - } - KvScope::Build | KvScope::Step => { - let Ok(mut s) = GLOBAL.lock() else { return }; - let m = match scope { - KvScope::Build => &mut s.build_kv, - KvScope::Step => &mut s.step_kv, - KvScope::Plugin => unreachable!(), - }; - m.insert(key.to_string(), val); - } - } -} - -// ─── Disk-backed Plugin-scope KV ──────────────────────────────────────────── -// -// `KvScope::Plugin` persists across hm invocations so plugins (e.g. the -// cloud plugin) can stash the active org slug, last-seen tokens, etc. -// Path: `/harmont/state/.kv`. Per-plugin -// isolation is enforced by the `CURRENT_PLUGIN_NAME` thread-local, -// which `LoadedPlugin::call_capability` sets around every call. -// -// Concurrency: write operations (`KvScope::Plugin`) take an exclusive -// advisory lock on a per-plugin `.lock` sibling file via -// `fs2::FileExt::lock_exclusive`. Readers do NOT lock — -// `load_plugin_kv` is read-only and works against the atomically -// written `.kv` file (tmp + rename in `save_plugin_kv`), so a reader -// either sees the pre-write or post-write state, never a torn map. -// Concurrent invocations of `hm` against the same plugin's KV -// serialise on the `.lock` file; the held window is small (load + -// insert + atomic write of a typically-small JSON map) so contention -// is not a practical concern. - -fn plugin_state_path() -> Option { - let dir = hm_util::dirs::harmont_plugin_state_dir()?; - let plugin = current_plugin_name()?; - Some(dir.join(format!("{plugin}.kv"))) -} - -/// Acquire an exclusive advisory lock on `/harmont/state/.lock`. -/// -/// Returns `None` if `plugin_state_path()` couldn't resolve (no config -/// dir or no current plugin name). The returned `File` releases the -/// lock on drop — so the caller holds the lock for the lifetime of -/// the binding. -fn lock_plugin_kv() -> Option { - use fs2::FileExt; - let kv_path = plugin_state_path()?; - let lock_path = kv_path.with_extension("lock"); - if let Some(parent) = lock_path.parent() { - let _ = std::fs::create_dir_all(parent); - } - let f = std::fs::OpenOptions::new() - .create(true) - .truncate(false) - .read(true) - .write(true) - .open(&lock_path) - .ok()?; - f.lock_exclusive().ok()?; - Some(f) -} - -fn current_plugin_name() -> Option { - CURRENT_PLUGIN_NAME.with(|c| c.borrow().clone()) -} - -thread_local! { - pub(crate) static CURRENT_PLUGIN_NAME: std::cell::RefCell> = - const { std::cell::RefCell::new(None) }; -} - -#[doc(hidden)] // pub for integration tests; not stable API -pub fn set_current_plugin_name(name: String) { - CURRENT_PLUGIN_NAME.with(|c| *c.borrow_mut() = Some(name)); -} - -pub(crate) fn clear_current_plugin_name() { - CURRENT_PLUGIN_NAME.with(|c| *c.borrow_mut() = None); -} - -#[doc(hidden)] // pub for integration tests; not stable API -#[must_use] -pub fn load_plugin_kv() -> BTreeMap> { - let Some(path) = plugin_state_path() else { - return BTreeMap::default(); - }; - let Ok(bytes) = std::fs::read(&path) else { - return BTreeMap::default(); - }; - serde_json::from_slice(&bytes).unwrap_or_default() -} - -fn save_plugin_kv(kv: &BTreeMap>) { - let Some(path) = plugin_state_path() else { - return; - }; - let Some(parent) = path.parent() else { return }; - let _ = std::fs::create_dir_all(parent); - if let Ok(bytes) = serde_json::to_vec(kv) { - // Atomic write: tmpfile + rename. If rename fails the old file - // persists; best-effort. - let tmp = path.with_extension("kv.tmp"); - if std::fs::write(&tmp, &bytes).is_ok() { - let _ = std::fs::rename(&tmp, &path); - } - } -} - -fn archive_read_impl(args: ArchiveReadArgs) -> Vec { - crate::orchestrator::state::current() - .map(|s| s.archives.read(args.id, args.offset, args.max)) - .unwrap_or_default() -} - -fn archive_total_size_impl(id: ArchiveId) -> u64 { - crate::orchestrator::state::current() - .map(|s| s.archives.total_size(id)) - .unwrap_or(0) -} - -fn fs_read_config_impl(rel_path: &str) -> Option> { - let root_unresolved = std::env::current_dir().ok()?.join(".harmont"); - let root = root_unresolved.canonicalize().ok()?; - let candidate = root.join(rel_path); - let canonical = candidate.canonicalize().ok()?; - if !canonical.starts_with(&root) { - return None; - } - std::fs::read(canonical).ok() -} - -fn unix_socket_connect_impl(_path: &str) -> SocketHandle { - let Ok(mut s) = GLOBAL.lock() else { - return SocketHandle(0); - }; - s.next_socket += 1; - let h = SocketHandle(s.next_socket); - s.sockets.insert(h, Vec::new()); - h -} - -fn socket_write_impl(args: SocketWriteArgs) -> u64 { - let Ok(mut s) = GLOBAL.lock() else { return 0 }; - if let Some(buf) = s.sockets.get_mut(&args.h) { - buf.extend_from_slice(&args.bytes); - args.bytes.len() as u64 - } else { - 0 - } -} - -fn socket_read_impl(_args: SocketReadArgs) -> Vec { - // Plan 1: in-memory loopback for tests. Plan 2 swaps in a real - // tokio UnixStream. - Vec::new() -} - -fn socket_close_impl(h: SocketHandle) { - let Ok(mut s) = GLOBAL.lock() else { return }; - s.sockets.remove(&h); -} - -fn keyring_get_impl(service: &str, account: &str) -> Option { - crate::creds_store::get(service, account) -} - -fn keyring_set_impl(service: &str, account: &str, secret: &str) { - crate::creds_store::set(service, account, secret); -} - -fn keyring_delete_impl(service: &str, account: &str) { - crate::creds_store::delete(service, account); -} - -fn tty_prompt_impl(msg: &str, mask: bool) -> String { - use dialoguer::{Input, Password}; - if mask { - Password::new() - .with_prompt(msg) - .interact() - .unwrap_or_default() - } else { - Input::::new() - .with_prompt(msg) - .interact_text() - .unwrap_or_default() - } -} - -fn tty_confirm_impl(msg: &str, default: bool) -> bool { - use dialoguer::Confirm; - Confirm::new() - .with_prompt(msg) - .default(default) - .interact() - .unwrap_or(default) -} - -fn browser_open_impl(url: &str) -> bool { - webbrowser::open(url).is_ok() -} - -/// Bind a real axum oneshot on `127.0.0.1:` (or any free port if -/// `port` is `None`). The first request to ANY path captures the URI's -/// `(path, query)` into a oneshot, then cancels the shutdown token so -/// the listener exits. Returns the bound port as a `LoopbackHandle`. -/// -/// The plugin uses `handle.0` both as the recv handle and as the port -/// number to embed in its OAuth redirect URI (`http://127.0.0.1:/cb`). -async fn spawn_loopback_impl_async(port: Option) -> anyhow::Result { - use anyhow::Context; - use axum::Router; - use axum::routing::get; - use std::net::SocketAddr; - - let addr = SocketAddr::from(([127, 0, 0, 1], port.unwrap_or(0))); - let listener = tokio::net::TcpListener::bind(addr) - .await - .with_context(|| format!("bind loopback on {addr}"))?; - let bound_port = listener.local_addr()?.port(); - - let (tx, rx) = tokio::sync::oneshot::channel::(); - // The sender is moved into the route closure, which uses `.take()` - // to ensure only the FIRST callback fires the channel. Wrapping in - // `Arc>>` makes the closure `Clone` (axum needs the - // closure to be `FnOnce + Clone` for fallback handlers). - let tx_for_route: Arc>>> = - Arc::new(tokio::sync::Mutex::new(Some(tx))); - let shutdown = tokio_util::sync::CancellationToken::new(); - - let shutdown_for_route = shutdown.clone(); - let app = Router::new().fallback(get(move |uri: axum::http::Uri| { - let tx = tx_for_route.clone(); - let shutdown = shutdown_for_route.clone(); - async move { - let path = uri.path().to_string(); - let mut query: BTreeMap = BTreeMap::new(); - if let Some(q) = uri.query() { - for (k, v) in url::form_urlencoded::parse(q.as_bytes()) { - query.insert(k.into_owned(), v.into_owned()); - } - } - let data = CallbackData { path, query }; - if let Some(t) = tx.lock().await.take() { - let _ = t.send(data); - } - shutdown.cancel(); - axum::response::Html( - "

You can close this tab.

", - ) - } - })); - - let shutdown_for_server = shutdown.clone(); - tokio::spawn(async move { - let _ = axum::serve(listener, app) - .with_graceful_shutdown(shutdown_for_server.cancelled_owned()) - .await; - }); - - let handle = LoopbackHandle(u64::from(bound_port)); - let slot = Arc::new(LoopbackSlot { - receiver: tokio::sync::Mutex::new(Some(rx)), - shutdown_token: shutdown, - }); - { - let mut g = GLOBAL - .lock() - .map_err(|_| anyhow::anyhow!("global host state lock poisoned"))?; - g.loopback_slots.insert(handle, slot); - } - Ok(handle) -} - -/// Await the matching slot's oneshot receiver for up to `timeout_ms` -/// milliseconds. Returns `None` on timeout, on unknown handle, or if the -/// receiver has already been consumed. -async fn loopback_recv_impl_async(args: LoopbackRecvArgs) -> Option { - let slot = { - let g = GLOBAL.lock().ok()?; - g.loopback_slots.get(&args.h).cloned() - }?; - // Hold the slot's async mutex only long enough to `.take()` the - // receiver — the actual await happens outside the lock so a second - // caller doesn't block while the first waits. - let rx_opt = { - let mut rx_guard = slot.receiver.lock().await; - rx_guard.take() - }; - let rx = rx_opt?; - match tokio::time::timeout( - std::time::Duration::from_millis(u64::from(args.timeout_ms)), - rx, - ) - .await - { - Ok(Ok(data)) => Some(data), - _ => None, - } -} - -fn should_cancel_impl() -> bool { - crate::orchestrator::state::current() - .map(|s| s.cancel.is_cancelled()) - .unwrap_or(false) -} - -#[allow( - clippy::print_stdout, - reason = "this fn's purpose is user-facing stdout output" -)] -fn write_stdout_impl(bytes: &[u8]) { - use std::io::Write; - let mut out = std::io::stdout().lock(); - // Best-effort: drop on error rather than panic. A broken stdout - // (e.g. SIGPIPE) is reported elsewhere by the parent process. - let _ = out.write_all(bytes); - let _ = out.flush(); -} - -#[allow( - clippy::print_stderr, - reason = "this fn's purpose is user-facing stderr output" -)] -fn write_stderr_impl(bytes: &[u8]) { - use std::io::Write; - let mut err = std::io::stderr().lock(); - let _ = err.write_all(bytes); - let _ = err.flush(); -} - -// ─── Per-step thread-local context ───────────────────────────────────────── -// -// The scheduler sets `CURRENT_STEP_ID` around each -// `call_capability("hm_executor_run", …)` invocation so host fns like -// `emit_step_log` can tag emitted events with the right step. Outside an -// orchestrator-driven run the cell stays `None`, and those host fns -// short-circuit to a no-op. - -thread_local! { - static CURRENT_STEP_ID: std::cell::Cell> = - const { std::cell::Cell::new(None) }; -} - -// Callers land in cluster 10 (scheduler); these setters are part of -// the public-within-crate API the scheduler will wire up. -#[allow(dead_code)] -pub(crate) fn set_current_step_id(id: uuid::Uuid) { - CURRENT_STEP_ID.with(|c| c.set(Some(id))); -} - -#[allow(dead_code)] -pub(crate) fn clear_current_step_id() { - CURRENT_STEP_ID.with(|c| c.set(None)); -} - -pub(crate) fn current_step_id() -> Option { - CURRENT_STEP_ID.with(std::cell::Cell::get) -} - -#[cfg(test)] -#[allow( - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - unsafe_code, - reason = "tests poke env vars via std::env::set_var, which is unsafe in Rust 2024" -)] -mod plugin_kv_tests { - use super::*; - - // Both tests mutate the process-wide `XDG_CONFIG_HOME` env var, - // which the platform config_dir lookup reads. Serialize them so - // parallel test threads don't race on that global. - static ENV_MUTEX: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - #[test] - fn plugin_kv_round_trip_through_disk() { - let _lock = ENV_MUTEX.lock().unwrap(); - let temp = tempfile::tempdir().unwrap(); - // SAFETY: in-process env var set; serialized by ENV_MUTEX. - unsafe { - std::env::set_var("XDG_CONFIG_HOME", temp.path()); - } - set_current_plugin_name("test-plugin".into()); - - kv_set_impl(KvScope::Plugin, "key", b"value".to_vec()); - assert_eq!(kv_get_impl(KvScope::Plugin, "key"), Some(b"value".to_vec())); - - let again = kv_get_impl(KvScope::Plugin, "key"); - assert_eq!(again, Some(b"value".to_vec())); - - clear_current_plugin_name(); - } - - #[test] - fn plugin_kv_isolated_per_plugin_name() { - let _lock = ENV_MUTEX.lock().unwrap(); - let temp = tempfile::tempdir().unwrap(); - // SAFETY: in-process env var set; serialized by ENV_MUTEX. - unsafe { - std::env::set_var("XDG_CONFIG_HOME", temp.path()); - } - - set_current_plugin_name("alpha".into()); - kv_set_impl(KvScope::Plugin, "k", b"a".to_vec()); - - set_current_plugin_name("beta".into()); - assert_eq!(kv_get_impl(KvScope::Plugin, "k"), None); - - set_current_plugin_name("alpha".into()); - assert_eq!(kv_get_impl(KvScope::Plugin, "k"), Some(b"a".to_vec())); - - clear_current_plugin_name(); - } -} - -#[cfg(test)] -#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod loopback_tests { - use super::*; - - #[tokio::test(flavor = "multi_thread")] - async fn spawn_then_recv_callback() { - let handle = spawn_loopback_impl_async(None).await.unwrap(); - let port = handle.0; - - // Issue the callback against the bound port. Detached: the - // listener captures the URI and shuts down after responding; - // whether the client sees a clean close or a reset doesn't - // matter for our assertion. - let url = format!("http://127.0.0.1:{port}/cb?code=xyz&state=abc"); - let _client = tokio::spawn(async move { - let _ = reqwest::get(&url).await; - }); - - let data = loopback_recv_impl_async(LoopbackRecvArgs { - h: handle, - timeout_ms: 5000, - }) - .await - .expect("got callback"); - assert_eq!(data.path, "/cb"); - assert_eq!(data.query.get("code"), Some(&"xyz".to_string())); - assert_eq!(data.query.get("state"), Some(&"abc".to_string())); - } -} diff --git a/crates/hm/src/plugin/install.rs b/crates/hm/src/plugin/install.rs deleted file mode 100644 index 54a00de..0000000 --- a/crates/hm/src/plugin/install.rs +++ /dev/null @@ -1,92 +0,0 @@ -//! Implementation of `hm plugin install --pin `. - -use std::path::PathBuf; - -use anyhow::{Context, Result, bail}; -use sha2::{Digest, Sha256}; - -use super::host::LoadedPlugin; -use super::paths; - -/// Install a plugin from a file path or HTTPS URL. -/// -/// For HTTPS URLs, `--pin ` is required. The pin must equal -/// the SHA-256 of the downloaded bytes (hex, lowercase). -/// -/// On success, the plugin is written to -/// `/.wasm`. -/// -/// # Errors -/// -/// Returns an error if the source cannot be fetched, the pin does not -/// verify, the plugin manifest fails validation, or the install dir -/// cannot be written to. -pub async fn install(source: &str, pin: Option<&str>) -> Result { - let bytes = if source.starts_with("https://") { - let pin = pin.context("--pin is required for HTTPS sources")?; - let body = reqwest::get(source) - .await - .with_context(|| format!("GET {source}"))? - .error_for_status()? - .bytes() - .await - .context("read response body")?; - verify_pin(&body, pin)?; - body.to_vec() - } else if source.starts_with("http://") { - bail!("plain http:// is not allowed; use https:// or a local file path"); - } else { - let path = PathBuf::from(source); - if !path.is_file() { - bail!("no file at {}", path.display()); - } - let bytes = std::fs::read(&path).with_context(|| format!("read {}", path.display()))?; - if let Some(pin) = pin { - verify_pin(&bytes, pin)?; - } - bytes - }; - - // Load the plugin to extract its manifest name (used as the - // installed filename). Any plugin that fails validation here is - // not installed. - let leaked: &'static [u8] = Box::leak(bytes.clone().into_boxed_slice()); - let plugin = - LoadedPlugin::from_bytes(leaked, 1).context("validate plugin before installing")?; - let install_dir = paths::install_dir().context("resolve install dir")?; - std::fs::create_dir_all(&install_dir) - .with_context(|| format!("create {}", install_dir.display()))?; - let target = install_dir.join(format!("{}.wasm", plugin.manifest.name)); - std::fs::write(&target, &bytes).with_context(|| format!("write {}", target.display()))?; - Ok(target) -} - -fn verify_pin(bytes: &[u8], expected_hex: &str) -> Result<()> { - let mut h = Sha256::new(); - h.update(bytes); - let got = h.finalize(); - let got_hex = hex::encode(got); - if !got_hex.eq_ignore_ascii_case(expected_hex.trim()) { - bail!( - "SHA-256 mismatch: expected {expected_hex}, downloaded {got_hex}\n\ - fix: re-fetch the source or correct the --pin value" - ); - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use sha2::{Digest, Sha256}; - - #[test] - fn pin_verification_round_trip() { - let body = b"hello plugin"; - let mut h = Sha256::new(); - h.update(body); - let hex_digest = hex::encode(h.finalize()); - assert!(verify_pin(body, &hex_digest).is_ok()); - assert!(verify_pin(body, "deadbeef").is_err()); - } -} diff --git a/crates/hm/src/plugin/manifest.rs b/crates/hm/src/plugin/manifest.rs deleted file mode 100644 index c8946d6..0000000 --- a/crates/hm/src/plugin/manifest.rs +++ /dev/null @@ -1,174 +0,0 @@ -//! Validates plugin manifests as they're loaded. - -// Pedantic nags suppressed scope-wide: -// - `missing_errors_doc`: the only fn returning Result is -// `validate_standalone`, whose errors are typed as `ManifestError` -// and each variant carries its own message. -// - `implicit_hasher`: `available_host_fns` is intentionally typed -// `&HashSet<&str>` (default hasher) — the registry constructs it -// that way; generalising over hashers buys nothing. -// - `collapsible_if`: keeping the inner `if` separate from the outer -// `match` makes the validation rules easier to read one-per-line. -// - `single_match_else` style: see same rationale. -#![allow(clippy::missing_errors_doc)] -#![allow(clippy::implicit_hasher)] -#![allow(clippy::collapsible_if)] -#![allow(clippy::collapsible_match)] -// The first doc paragraph explains both what `validate_standalone` does -// and what it deliberately leaves to the registry; splitting that -// across paragraphs would scatter the contract. -#![allow(clippy::too_long_first_doc_paragraph)] -// `["hm_log"].into_iter().collect()` keeps the visual shape of the -// broader case (the same pattern adds N host fns when needed); the -// `iter_on_single_items` rewrite would hide that. -#![allow(clippy::iter_on_single_items)] - -use std::collections::HashSet; - -use hm_plugin_protocol::{Capability, HM_PLUGIN_API_VERSION, PluginManifest}; -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum ManifestError { - #[error("plugin '{name}': api_version mismatch (plugin: {found}, host: {expected})")] - ApiVersion { - name: String, - found: u32, - expected: u32, - }, - #[error("plugin '{name}': required host fn '{fn_name}' is not available in this hm build")] - MissingHostFn { name: String, fn_name: String }, - #[error("plugin '{name}': declared no capabilities")] - NoCapabilities { name: String }, - #[error("plugin '{name}': StepExecutorSpec.runner '{runner}' is empty or contains whitespace")] - BadRunnerName { name: String, runner: String }, - #[error("plugin '{name}': declared the same subcommand verb twice ('{verb}')")] - DuplicateSubcommandVerb { name: String, verb: String }, -} - -/// Returns Ok(()) iff `manifest` passes every check we can do -/// statically (i.e. without consulting other plugins). Cross-plugin -/// conflicts (e.g. two plugins both claim `runner: "docker"`) are -/// caught by [`super::registry`]. -pub fn validate_standalone( - manifest: &PluginManifest, - available_host_fns: &HashSet<&str>, -) -> Result<(), ManifestError> { - if manifest.api_version != HM_PLUGIN_API_VERSION { - return Err(ManifestError::ApiVersion { - name: manifest.name.clone(), - found: manifest.api_version, - expected: HM_PLUGIN_API_VERSION, - }); - } - for fn_name in &manifest.required_host_fns { - if !available_host_fns.contains(fn_name.as_str()) { - return Err(ManifestError::MissingHostFn { - name: manifest.name.clone(), - fn_name: fn_name.clone(), - }); - } - } - if manifest.capabilities.is_empty() { - return Err(ManifestError::NoCapabilities { - name: manifest.name.clone(), - }); - } - let mut seen_verbs: HashSet<&str> = HashSet::new(); - for cap in &manifest.capabilities { - match cap { - Capability::StepExecutor(s) => { - if s.runner.trim().is_empty() || s.runner.chars().any(char::is_whitespace) { - return Err(ManifestError::BadRunnerName { - name: manifest.name.clone(), - runner: s.runner.clone(), - }); - } - } - Capability::Subcommand(s) => { - if !seen_verbs.insert(s.verb.as_str()) { - return Err(ManifestError::DuplicateSubcommandVerb { - name: manifest.name.clone(), - verb: s.verb.clone(), - }); - } - } - _ => {} - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use hm_plugin_protocol::{Capability, StepExecutorSpec}; - use semver::Version; - - fn host_fns() -> HashSet<&'static str> { - ["hm_log"].into_iter().collect() - } - - #[test] - fn rejects_wrong_api_version() { - let m = PluginManifest { - api_version: 999, - name: "p".into(), - version: Version::new(0, 1, 0), - description: "x".into(), - capabilities: vec![Capability::StepExecutor(StepExecutorSpec { - runner: "a".into(), - default: false, - step_schema: None, - })], - required_host_fns: vec![], - config_schema: None, - allowed_hosts: vec![], - }; - assert!(matches!( - validate_standalone(&m, &host_fns()), - Err(ManifestError::ApiVersion { .. }) - )); - } - - #[test] - fn rejects_missing_host_fn() { - let m = PluginManifest { - api_version: HM_PLUGIN_API_VERSION, - name: "p".into(), - version: Version::new(0, 1, 0), - description: "x".into(), - capabilities: vec![Capability::StepExecutor(StepExecutorSpec { - runner: "a".into(), - default: false, - step_schema: None, - })], - required_host_fns: vec!["hm_quantum_teleport".into()], - config_schema: None, - allowed_hosts: vec![], - }; - assert!(matches!( - validate_standalone(&m, &host_fns()), - Err(ManifestError::MissingHostFn { fn_name, .. }) if fn_name == "hm_quantum_teleport" - )); - } - - #[test] - fn accepts_minimal_valid_manifest() { - let m = PluginManifest { - api_version: HM_PLUGIN_API_VERSION, - name: "p".into(), - version: Version::new(0, 1, 0), - description: "x".into(), - capabilities: vec![Capability::StepExecutor(StepExecutorSpec { - runner: "a".into(), - default: false, - step_schema: None, - })], - required_host_fns: vec!["hm_log".into()], - config_schema: None, - allowed_hosts: vec![], - }; - assert!(validate_standalone(&m, &host_fns()).is_ok()); - } -} diff --git a/crates/hm/src/plugin/mod.rs b/crates/hm/src/plugin/mod.rs deleted file mode 100644 index 95e4915..0000000 --- a/crates/hm/src/plugin/mod.rs +++ /dev/null @@ -1,18 +0,0 @@ -//! In-process plugin host. -//! -//! Loads `.wasm` plugins via Extism, validates their manifests, exposes the -//! host-fn surface from the design spec (see -//! `docs/superpowers/specs/2026-05-18-hm-local-first-redesign-design.md` §3.3). - -pub mod embedded; -pub mod host; -pub mod host_fns; -pub mod install; -pub mod manifest; -pub mod paths; -pub mod pool; -pub mod registry; -pub mod signal; - -pub use host::LoadedPlugin; -pub use registry::{PluginRegistry, RegistryConfig}; diff --git a/crates/hm/src/plugin/paths.rs b/crates/hm/src/plugin/paths.rs deleted file mode 100644 index b89895c..0000000 --- a/crates/hm/src/plugin/paths.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Filesystem locations the plugin host inspects. - -// `#[must_use]` would be noise on these three single-line `Option` -// helpers — the names already describe the only thing the caller can do -// with the return value. -#![allow(clippy::must_use_candidate)] -// The single test asserts the path resolved on this host; if config_dir -// can't produce anything, the test environment is the bug. -#![cfg_attr(test, allow(clippy::expect_used))] - -use std::path::PathBuf; - -/// `~/.config/harmont/plugins/` (or the platform's XDG equivalent). -/// User-global plugins live here. -pub fn user_plugins_dir() -> Option { - hm_util::dirs::harmont_plugins_dir() -} - -/// `/.harmont/plugins/`. Project-local plugins live here. -pub fn project_plugins_dir() -> Option { - std::env::current_dir() - .ok() - .map(|p| p.join(".harmont").join("plugins")) -} - -/// Where `hm plugin install` writes plugins. -pub fn install_dir() -> Option { - user_plugins_dir() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn user_plugins_dir_resolves() { - let p = user_plugins_dir().expect("config dir resolves"); - assert!(p.ends_with("harmont/plugins")); - } -} diff --git a/crates/hm/src/plugin/pool.rs b/crates/hm/src/plugin/pool.rs deleted file mode 100644 index 92cdddd..0000000 --- a/crates/hm/src/plugin/pool.rs +++ /dev/null @@ -1,196 +0,0 @@ -//! Instance pool for a loaded plugin. -//! -//! Each `LoadedPlugin` owns a `PluginPool`. Concurrent calls into the -//! plugin acquire an instance from the pool (creating one on demand -//! up to a pre-set max); when the call finishes, the instance returns -//! to the pool for reuse. Bounded by a `tokio::sync::Semaphore` so the -//! orchestrator's parallelism doesn't exceed `max_instances`. - -// Pedantic-bucket nags accepted at module scope: -// - `missing_errors_doc`: every fallible fn returns `anyhow::Result` -// with rich `context` messages. -// - `missing_panics_doc` on `PluginPool::from_*`: the only panic path -// is the `try_lock().expect()` on a Mutex we just constructed; it -// cannot be contended. Documenting it would be noise. -// - `expect_used`: same — these are on freshly-created Mutexes and -// are infallible by construction. -// - `collapsible_if`: the nested `if g.len() < self.max_instances` -// reads more clearly one rule per line. -// - `needless_pass_by_value` on `from_file(path: PathBuf, ...)`: we -// clone the path into `bytes` AND store the original in the pool -// field; passing by value avoids forcing every caller to clone. -// Suppressed at the call site below. -// - `missing_const_for_fn`/`missing_panics_doc` on `PoolGuard::plugin`: -// the `expect` lives on an `Option` we control; the guard contract -// guarantees the plugin is present until drop. -#![allow( - clippy::missing_errors_doc, - clippy::missing_panics_doc, - clippy::expect_used, - clippy::collapsible_if, - clippy::missing_const_for_fn, - clippy::needless_pass_by_value -)] - -use std::path::PathBuf; -use std::sync::Arc; - -use anyhow::{Context, Result}; -use extism::{Manifest as ExtismManifest, Plugin, Wasm}; -use tokio::sync::{Mutex, Semaphore}; - -use super::host_fns; - -#[derive(Debug, Clone)] -enum PluginBytes { - Embedded(&'static [u8]), - Disk(PathBuf), -} - -#[derive(Debug)] -pub struct PluginPool { - bytes: PluginBytes, - instances: Mutex>, - semaphore: Arc, - max_instances: usize, - /// HTTPS hosts the plugin is permitted to contact via extism's - /// HTTP host fn. Threaded into the per-instance - /// [`ExtismManifest`] at spawn time. Empty means "no outbound - /// HTTP", which is the default until the plugin's own manifest - /// declares otherwise. - allowed_hosts: Vec, -} - -impl PluginPool { - pub fn from_bytes(bytes: &'static [u8], max_instances: usize) -> Result { - Self::from_bytes_with_hosts(bytes, max_instances, Vec::new()) - } - - pub fn from_bytes_with_hosts( - bytes: &'static [u8], - max_instances: usize, - allowed_hosts: Vec, - ) -> Result { - let max_instances = max_instances.max(1); - let pool = Self { - bytes: PluginBytes::Embedded(bytes), - instances: Mutex::new(Vec::with_capacity(max_instances)), - semaphore: Arc::new(Semaphore::new(max_instances)), - max_instances, - allowed_hosts, - }; - // Pre-instantiate one — the first acquire is the most latency-sensitive. - let plugin = pool - .spawn_instance() - .context("preallocate first plugin instance")?; - pool.instances - .try_lock() - .expect("just-created mutex is uncontended") - .push(plugin); - Ok(pool) - } - - pub fn from_file(path: PathBuf, max_instances: usize) -> Result { - Self::from_file_with_hosts(path, max_instances, Vec::new()) - } - - pub fn from_file_with_hosts( - path: PathBuf, - max_instances: usize, - allowed_hosts: Vec, - ) -> Result { - let max_instances = max_instances.max(1); - let pool = Self { - bytes: PluginBytes::Disk(path.clone()), - instances: Mutex::new(Vec::with_capacity(max_instances)), - semaphore: Arc::new(Semaphore::new(max_instances)), - max_instances, - allowed_hosts, - }; - let plugin = pool.spawn_instance().with_context(|| { - format!("preallocate first plugin instance from {}", path.display()) - })?; - pool.instances - .try_lock() - .expect("just-created mutex is uncontended") - .push(plugin); - Ok(pool) - } - - fn spawn_instance(&self) -> Result { - let wasm = match &self.bytes { - PluginBytes::Embedded(b) => Wasm::data(*b), - PluginBytes::Disk(p) => Wasm::file(p), - }; - let manifest = - ExtismManifest::new([wasm]).with_allowed_hosts(self.allowed_hosts.iter().cloned()); - Plugin::new(&manifest, host_fns::all(), true).context("spawn extism plugin instance") - } - - /// Acquire an instance. Returns a guard that holds the instance - /// until dropped; on drop, the instance returns to the pool. - /// - /// If the pool is at capacity, blocks on the semaphore until a - /// slot is freed. - pub async fn acquire(&self) -> Result> { - // Reserve a slot. - let permit = self - .semaphore - .clone() - .acquire_owned() - .await - .context("semaphore closed")?; - // Take an instance from the pool, or spawn a fresh one. - let plugin = { - let mut g = self.instances.lock().await; - g.pop() - }; - let plugin = if let Some(p) = plugin { - p - } else { - self.spawn_instance()? - }; - Ok(PoolGuard { - pool: self, - plugin: Some(plugin), - _permit: permit, - }) - } - - fn put_back(&self, plugin: Plugin) { - // Best-effort: if the pool is full (more than max), drop on floor. - // The semaphore guarantees we never have more than `max_instances` - // outstanding, so the pool can hold up to `max_instances` safely. - if let Ok(mut g) = self.instances.try_lock() - && g.len() < self.max_instances - { - g.push(plugin); - } - } - - #[must_use] - pub fn max_instances(&self) -> usize { - self.max_instances - } -} - -#[derive(Debug)] -pub struct PoolGuard<'a> { - pool: &'a PluginPool, - plugin: Option, - _permit: tokio::sync::OwnedSemaphorePermit, -} - -impl PoolGuard<'_> { - pub fn plugin(&mut self) -> &mut Plugin { - self.plugin.as_mut().expect("plugin present until drop") - } -} - -impl Drop for PoolGuard<'_> { - fn drop(&mut self) { - if let Some(p) = self.plugin.take() { - self.pool.put_back(p); - } - } -} diff --git a/crates/hm/src/plugin/registry.rs b/crates/hm/src/plugin/registry.rs deleted file mode 100644 index 2afad9d..0000000 --- a/crates/hm/src/plugin/registry.rs +++ /dev/null @@ -1,232 +0,0 @@ -//! Discovers `.wasm` plugins under the user and project plugin dirs, -//! validates each manifest, and builds a capability index used by -//! the dispatcher. - -// Pedantic-bucket nags accepted at module scope: -// - `missing_errors_doc`: every fallible fn returns `anyhow::Result` -// with rich `with_context` messages. -// - `needless_pass_by_value`: `RegistryConfig` is intentionally moved -// into `load` so callers can't reuse a config they expected to -// consume. -// - `collapsible_if`: the nested `if s.default { … }` reads more clearly -// one rule per line. -#![allow(clippy::missing_errors_doc)] -#![allow(clippy::needless_pass_by_value)] -#![allow(clippy::collapsible_if)] - -use std::collections::{BTreeMap, HashSet}; -use std::path::PathBuf; -use std::sync::Arc; - -use anyhow::{Context, Result}; -use hm_plugin_protocol::{Capability, PluginManifest}; - -use super::host::LoadedPlugin; -use super::host_fns::HOST_FN_NAMES; -use super::manifest::{ManifestError, validate_standalone}; -use super::paths; -use crate::error::HmError; - -#[derive(Debug, smart_default::SmartDefault)] -pub struct RegistryConfig { - /// If `false`, skip discovery and only registers explicitly added - /// plugins. Used by integration tests. - #[default = true] - pub auto_discover: bool, - /// Extra plugin paths to load (in addition to discovery). Used by - /// tests to load fixture plugins. - pub extra_paths: Vec, - /// Embedded plugin bytes — registered first, before disk plugins. - /// Plan 2 onward stuffs `docker.wasm`, etc. in here. - pub embedded: Vec<(&'static str, &'static [u8])>, - /// Per-runner instance pool size override. Keyed by `runner` name. - /// Defaults to 1 when a runner isn't present here. The orchestrator - /// sets this to `parallelism` for the default-runner plugin so - /// concurrent chains stop serialising on a single plugin instance. - pub pool_sizes: BTreeMap, -} - -#[derive(Debug)] -pub struct PluginRegistry { - plugins: Vec>, - pub subcommand_index: BTreeMap, - pub runner_index: BTreeMap, - pub output_formatter_index: BTreeMap, - pub default_runner: Option, -} - -impl PluginRegistry { - pub fn load(config: RegistryConfig) -> Result { - let host_fns: HashSet<&str> = HOST_FN_NAMES.iter().copied().collect(); - let mut plugins: Vec> = Vec::new(); - - // Chicken-and-egg: we'd need the manifest to know if a plugin - // is a step executor before sizing its pool. Resolve by using - // the max pool size across all declared runners — the - // semaphore guarantees we never exceed it, and non-step - // plugins simply never grow past their single pre-allocated - // instance. - let max_instances = config - .pool_sizes - .values() - .copied() - .max() - .unwrap_or(1) - .max(1); - - for (name, bytes) in &config.embedded { - let p = LoadedPlugin::from_bytes(bytes, max_instances) - .with_context(|| format!("embedded plugin '{name}'"))?; - validate(&p.manifest, &host_fns)?; - plugins.push(Arc::new(p)); - } - - if config.auto_discover { - for dir in [paths::user_plugins_dir(), paths::project_plugins_dir()] - .into_iter() - .flatten() - { - if !dir.is_dir() { - continue; - } - let entries = - std::fs::read_dir(&dir).with_context(|| format!("read {}", dir.display()))?; - for ent in entries { - let Ok(ent) = ent else { continue }; - let path = ent.path(); - if path.extension().and_then(|s| s.to_str()) != Some("wasm") { - continue; - } - let p = LoadedPlugin::from_file(path.clone(), max_instances) - .with_context(|| format!("load {}", path.display()))?; - validate(&p.manifest, &host_fns)?; - plugins.push(Arc::new(p)); - } - } - } - - for path in &config.extra_paths { - let p = LoadedPlugin::from_file(path.clone(), max_instances) - .with_context(|| format!("load {}", path.display()))?; - validate(&p.manifest, &host_fns)?; - plugins.push(Arc::new(p)); - } - - let mut me = Self { - plugins, - subcommand_index: BTreeMap::new(), - runner_index: BTreeMap::new(), - output_formatter_index: BTreeMap::new(), - default_runner: None, - }; - me.index_capabilities()?; - Ok(me) - } - - fn index_capabilities(&mut self) -> Result<()> { - for (i, p) in self.plugins.iter().enumerate() { - for cap in &p.manifest.capabilities { - match cap { - Capability::Subcommand(s) => { - if let Some(other) = self.subcommand_index.insert(s.verb.clone(), i) { - return Err(HmError::PluginConflict { - verb: s.verb.clone(), - plugin_a: self.plugins[other].manifest.name.clone(), - plugin_b: p.manifest.name.clone(), - } - .into()); - } - } - Capability::StepExecutor(s) => { - if let Some(other) = self.runner_index.insert(s.runner.clone(), i) { - return Err(HmError::PluginConflict { - verb: format!("runner:{}", s.runner), - plugin_a: self.plugins[other].manifest.name.clone(), - plugin_b: p.manifest.name.clone(), - } - .into()); - } - if s.default { - if let Some(other) = self.default_runner.replace(i) { - return Err(HmError::PluginConflict { - verb: "default-runner".into(), - plugin_a: self.plugins[other].manifest.name.clone(), - plugin_b: p.manifest.name.clone(), - } - .into()); - } - } - } - Capability::OutputFormatter(s) => { - if let Some(other) = self.output_formatter_index.insert(s.name.clone(), i) { - return Err(HmError::PluginConflict { - verb: format!("format:{}", s.name), - plugin_a: self.plugins[other].manifest.name.clone(), - plugin_b: p.manifest.name.clone(), - } - .into()); - } - } - Capability::LifecycleHook(_) => { - // Hooks can stack; no conflict possible. - } - } - } - } - Ok(()) - } - - pub fn manifests(&self) -> impl Iterator { - self.plugins.iter().map(|p| &p.manifest) - } - - /// Return a cheap clone of the plugin at `idx`. Callers should - /// drop any registry-level lock they hold before awaiting on the - /// returned plugin — the per-plugin pool is what serialises - /// concurrent calls, not the registry. - #[must_use] - pub fn get(&self, idx: usize) -> Option> { - self.plugins.get(idx).cloned() - } - - /// Returns the runner name of the plugin marked `default: true` at - /// registration time, if any. Used by the scheduler to resolve - /// steps that don't declare a `runner` field. - #[must_use] - pub fn default_runner_name(&self) -> Option<&str> { - let idx = self.default_runner?; - self.runner_index - .iter() - .find_map(|(name, &i)| (i == idx).then_some(name.as_str())) - } -} - -fn validate(m: &PluginManifest, host_fns: &HashSet<&str>) -> Result<()> { - validate_standalone(m, host_fns).map_err(|e| match e { - ManifestError::ApiVersion { - name, - found, - expected, - } => HmError::PluginManifest { - name, - expected_api: expected, - found_api: found, - } - .into(), - ManifestError::MissingHostFn { name, fn_name } => HmError::PluginMissingHostFn { - name, - fn_name, - min_hm_version: semver::Version::new(0, 0, 0), - } - .into(), - ManifestError::NoCapabilities { ref name } - | ManifestError::BadRunnerName { ref name, .. } - | ManifestError::DuplicateSubcommandVerb { ref name, .. } => HmError::PluginLoad { - name: name.clone(), - path: std::path::PathBuf::new(), - reason: e.to_string(), - doc_url: "https://harmont.dev/docs/plugins/manifest", - } - .into(), - }) -} diff --git a/crates/hm/src/runner/docker.rs b/crates/hm/src/runner/docker.rs new file mode 100644 index 0000000..ecbe1c7 --- /dev/null +++ b/crates/hm/src/runner/docker.rs @@ -0,0 +1,529 @@ +//! Docker-based step runner. +//! +//! Replaces the old `hm-plugin-docker` WASM plugin with direct Bollard +//! calls. All Docker orchestration (pull, start, extract, exec, commit, +//! stop+remove) runs through [`RunContext::docker`] with cancellation +//! support via [`RunContext::cancel`]. + +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use hm_plugin_protocol::{ + BuildEvent, CacheDecision, CommandStep, ExecutorInput, SnapshotRef, StdStream, StepResult, +}; +use uuid::Uuid; + +use super::{RunContext, StepRunner}; +use crate::orchestrator::events::EventBus; + +// --------------------------------------------------------------------------- +// EXTRACT_CMD_SH +// --------------------------------------------------------------------------- + +/// Shell script for idempotent workspace extraction. Reads a `.harmont-extracted` +/// manifest to clean up files from a previous extract, then unpacks the new +/// archive and writes a fresh manifest. Files created by the step command +/// (e.g. `node_modules`, build artifacts) are not tracked and survive untouched. +const EXTRACT_CMD_SH: &str = r#"set -e +mkdir -p "$WORKDIR" +cd "$WORKDIR" +manifest="$WORKDIR/.harmont-extracted" +if [ -f "$manifest" ]; then + # Longest paths first: removes nested entries before their parents. + sort -r "$manifest" | while IFS= read -r p; do + [ -n "$p" ] || continue + if [ -d "$p" ] && [ ! -L "$p" ]; then + rmdir "$p" 2>/dev/null || true + else + rm -f "$p" 2>/dev/null || true + fi + done + rm -f "$manifest" +fi +# Stream the archive into a temp file so we can both list and extract. +tmp=$(mktemp) +trap 'rm -f "$tmp"' EXIT +cat > "$tmp" +tar -tzf "$tmp" > "$manifest" +tar -xzf "$tmp" +"#; + +// --------------------------------------------------------------------------- +// DockerRunner +// --------------------------------------------------------------------------- + +/// Step runner that executes pipeline steps inside Docker containers +/// via the local daemon (Bollard). +#[derive(Debug)] +pub struct DockerRunner; + +impl StepRunner for DockerRunner { + fn name(&self) -> &'static str { + "docker" + } + + fn execute( + &self, + ctx: &RunContext, + input: ExecutorInput, + ) -> Pin> + Send + '_>> { + let ctx = ctx.clone(); + Box::pin(async move { run_step(&ctx, input).await }) + } +} + +// --------------------------------------------------------------------------- +// Core orchestration +// --------------------------------------------------------------------------- + +async fn run_step(ctx: &RunContext, input: ExecutorInput) -> Result { + let plan = decision_plan(&input.cache_lookup); + + // Cache hit shortcut: no container, no exec; hand back the hit + // tag so downstream steps can boot from it. + if !plan.run_command { + return Ok(StepResult { + exit_code: 0, + committed_snapshot: plan.hit_tag.clone(), + artifacts: vec![], + }); + } + + let image = resolve_image(&input.step, plan.hit_tag.as_ref(), input.parent_snapshot.as_ref()); + let container_name = sanitize_container_name(&input.run_id.to_string(), &input.step.key); + let env_vec: Vec = input.env.iter().map(|(k, v)| format!("{k}={v}")).collect(); + + // Ensure the image is locally available. + if !ctx.docker.image_exists(&image).await.unwrap_or(false) { + let docker = ctx.docker.clone(); + let cancel = ctx.cancel.clone(); + let img = image.clone(); + let pull_fut = async move { docker.pull_image(&img).await }; + tokio::select! { + result = pull_fut => result.with_context(|| format!("pull '{image}'"))?, + () = cancel.cancelled() => anyhow::bail!("cancelled during image pull"), + } + } + + let cid = ctx + .docker + .start_long_lived(&image, &env_vec, &input.workdir, &container_name) + .await + .context("docker start failed")?; + + // Always stop+remove the container, even on error. + let result = run_in_container(ctx, &cid, &input, &env_vec, &plan).await; + ctx.docker.stop_remove(&cid).await; + result +} + +/// Inner body executed with a running container. Separated so the +/// caller can unconditionally clean up the container in all paths. +async fn run_in_container( + ctx: &RunContext, + cid: &str, + input: &ExecutorInput, + env_vec: &[String], + plan: &DecisionPlan, +) -> Result { + // --- Extract workspace archive --- + let archive = ctx.archives.read(input.workspace_archive_id, 0, u64::MAX); + if archive.is_empty() { + anyhow::bail!( + "archive {} is empty or unknown", + input.workspace_archive_id + ); + } + + let docker = ctx.docker.clone(); + let cancel = ctx.cancel.clone(); + let cid_owned = cid.to_owned(); + let workdir = input.workdir.clone(); + let cmd = vec![ + "sh".to_string(), + "-c".to_string(), + EXTRACT_CMD_SH.replace("$WORKDIR", &workdir), + ]; + let extract_fut = async move { + let mut sink = tokio::io::sink(); + let rc = docker + .exec_streaming_stdin(&cid_owned, &cmd, &[], "/", &archive, &mut sink) + .await?; + if rc != 0 { + anyhow::bail!("tar extract exited {rc}"); + } + Ok::<(), anyhow::Error>(()) + }; + tokio::select! { + result = extract_fut => result.context("workspace extract failed")?, + () = cancel.cancelled() => anyhow::bail!("cancelled during workspace extract"), + } + + // --- Exec step command --- + let mut writer = StepLogWriter::new(input.step_id, Arc::clone(&ctx.event_bus)); + let docker = ctx.docker.clone(); + let cancel = ctx.cancel.clone(); + let cid_owned = cid.to_owned(); + let cmd = vec!["sh".into(), "-c".into(), input.step.cmd.clone()]; + let workdir = input.workdir.clone(); + let env_owned = env_vec.to_vec(); + let exec_fut = async move { + let rc = docker + .exec_streaming(&cid_owned, &cmd, &env_owned, &workdir, &mut writer) + .await?; + writer.flush_remaining(); + Ok::(rc) + }; + + let rc = tokio::select! { + result = exec_fut => result.context("docker exec failed")?, + () = cancel.cancelled() => { + return Ok(StepResult { + exit_code: 130, + committed_snapshot: None, + artifacts: vec![], + }); + } + }; + + #[allow(clippy::cast_possible_truncation, reason = "docker exit codes fit in i32")] + let exit_code = rc as i32; + + // --- Commit snapshot on success --- + let committed = if exit_code == 0 { + let target_tag = plan.commit_to.clone().unwrap_or_else(|| { + let safe: String = input + .step + .key + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' { + c + } else { + '-' + } + }) + .collect(); + SnapshotRef::from(format!( + "harmont-local-ephemeral/{safe}:run-{}", + input.step_id.simple() + )) + }); + ctx.docker + .commit_container(cid, &target_tag.to_string()) + .await + .context("docker commit failed")?; + Some(target_tag) + } else { + None + }; + + Ok(StepResult { + exit_code, + committed_snapshot: committed, + artifacts: vec![], + }) +} + +// --------------------------------------------------------------------------- +// DecisionPlan +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +struct DecisionPlan { + run_command: bool, + commit_to: Option, + hit_tag: Option, +} + +fn decision_plan(decision: &CacheDecision) -> DecisionPlan { + match decision { + CacheDecision::Hit { tag } => DecisionPlan { + run_command: false, + commit_to: None, + hit_tag: Some(tag.clone()), + }, + CacheDecision::MissBuildAs { tag } => DecisionPlan { + run_command: true, + commit_to: Some(tag.clone()), + hit_tag: None, + }, + CacheDecision::MissNoCommit => DecisionPlan { + run_command: true, + commit_to: None, + hit_tag: None, + }, + } +} + +// --------------------------------------------------------------------------- +// resolve_image +// --------------------------------------------------------------------------- + +/// Pick the base image for a step at boot time. +/// +/// Priority (high to low): +/// 1. Cache `hit_tag` — the host already located a satisfying snapshot. +/// 2. `parent_snapshot` — the previous step in this chain committed a +/// snapshot; chain-lineage requires we boot from it. +/// 3. The step's `image` field. +/// 4. Fall back to `"alpine:latest"`. +fn resolve_image( + step: &CommandStep, + hit_tag: Option<&SnapshotRef>, + parent_snapshot: Option<&SnapshotRef>, +) -> String { + if let Some(tag) = hit_tag { + return tag.to_string(); + } + if let Some(snap) = parent_snapshot { + return snap.to_string(); + } + if let Some(image) = &step.image { + return image.clone(); + } + "alpine:latest".to_string() +} + +// --------------------------------------------------------------------------- +// sanitize_container_name +// --------------------------------------------------------------------------- + +fn sanitize_container_name(run_id: &str, step_key: &str) -> String { + let run_short: String = run_id.chars().take(8).collect(); + let key: String = step_key + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' { + c + } else { + '-' + } + }) + .collect(); + format!("harmont-{run_short}-{key}") +} + +// --------------------------------------------------------------------------- +// StepLogWriter +// --------------------------------------------------------------------------- + +/// Streams bytes from a Docker exec into per-line [`BuildEvent::StepLog`] +/// events on the [`EventBus`]. Buffers partial lines until a `\n` arrives. +struct StepLogWriter { + step_id: Uuid, + bus: Arc, + buf: Vec, +} + +impl StepLogWriter { + fn new(step_id: Uuid, bus: Arc) -> Self { + Self { + step_id, + bus, + buf: Vec::with_capacity(8192), + } + } + + fn flush_line(&self, line: &[u8]) { + self.bus.emit(BuildEvent::StepLog { + step_id: self.step_id, + stream: StdStream::Stdout, + line: String::from_utf8_lossy(line).into_owned(), + ts: chrono::Utc::now(), + }); + } + + fn flush_remaining(&mut self) { + if !self.buf.is_empty() { + let line = std::mem::take(&mut self.buf); + self.flush_line(&line); + } + } +} + +impl tokio::io::AsyncWrite for StepLogWriter { + fn poll_write( + mut self: Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + let len = buf.len(); + for b in buf { + if *b == b'\n' { + let line = std::mem::take(&mut self.buf); + self.flush_line(&line); + } else { + self.buf.push(*b); + } + } + std::task::Poll::Ready(Ok(len)) + } + + fn poll_flush( + self: Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn poll_shutdown( + mut self: Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.flush_remaining(); + std::task::Poll::Ready(Ok(())) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + use hm_plugin_protocol::CacheDecision; + + fn step_with_image(image: Option<&str>) -> CommandStep { + CommandStep { + key: "k".into(), + label: None, + cmd: "true".into(), + image: image.map(String::from), + env: None, + timeout_seconds: None, + cache: None, + runner: None, + runner_args: None, + } + } + + // -- resolve_image ------------------------------------------------------- + + #[test] + fn resolve_image_hit_tag_wins() { + let s = step_with_image(Some("rust:1.82")); + let hit = SnapshotRef("cache:tag".into()); + let parent = SnapshotRef("parent:tag".into()); + assert_eq!(resolve_image(&s, Some(&hit), Some(&parent)), "cache:tag"); + } + + #[test] + fn resolve_image_parent_snapshot_beats_step_image() { + let s = step_with_image(Some("rust:1.82")); + let parent = SnapshotRef("parent:tag".into()); + assert_eq!(resolve_image(&s, None, Some(&parent)), "parent:tag"); + } + + #[test] + fn resolve_image_step_image_used() { + let s = step_with_image(Some("rust:1.82")); + assert_eq!(resolve_image(&s, None, None), "rust:1.82"); + } + + #[test] + fn resolve_image_fallback_alpine() { + let s = step_with_image(None); + assert_eq!(resolve_image(&s, None, None), "alpine:latest"); + } + + // -- decision_plan ------------------------------------------------------- + + #[test] + fn decision_hit_skips_command() { + let plan = decision_plan(&CacheDecision::Hit { + tag: SnapshotRef("cached:v1".into()), + }); + assert!(!plan.run_command); + assert!(plan.commit_to.is_none()); + assert_eq!(plan.hit_tag.as_ref().unwrap().0, "cached:v1"); + } + + #[test] + fn decision_miss_build_as_runs_and_commits() { + let plan = decision_plan(&CacheDecision::MissBuildAs { + tag: SnapshotRef("build:v2".into()), + }); + assert!(plan.run_command); + assert_eq!(plan.commit_to.as_ref().unwrap().0, "build:v2"); + assert!(plan.hit_tag.is_none()); + } + + #[test] + fn decision_miss_no_commit() { + let plan = decision_plan(&CacheDecision::MissNoCommit); + assert!(plan.run_command); + assert!(plan.commit_to.is_none()); + assert!(plan.hit_tag.is_none()); + } + + // -- sanitize_container_name --------------------------------------------- + + #[test] + fn sanitize_container_name_replaces_special_chars() { + let name = sanitize_container_name("abcdef12-3456-7890", "my/step.key:v1"); + assert_eq!(name, "harmont-abcdef12-my-step-key-v1"); + } + + #[test] + fn sanitize_container_name_preserves_valid_chars() { + let name = sanitize_container_name("run-id-1234", "normal_step-key"); + assert_eq!(name, "harmont-run-id-1-normal_step-key"); + } + + // -- StepLogWriter ------------------------------------------------------- + + #[tokio::test] + async fn step_log_writer_emits_on_newline() { + use tokio::io::AsyncWriteExt; + + let bus = EventBus::new(); + let mut rx = bus.subscribe(); + let step_id = Uuid::new_v4(); + + let mut writer = StepLogWriter::new(step_id, bus); + writer.write_all(b"hello\nworld\n").await.unwrap(); + + let ev1 = rx.recv().await.unwrap(); + let ev2 = rx.recv().await.unwrap(); + + match ev1 { + BuildEvent::StepLog { + step_id: sid, line, .. + } => { + assert_eq!(sid, step_id); + assert_eq!(line, "hello"); + } + other => panic!("expected StepLog, got {other:?}"), + } + match ev2 { + BuildEvent::StepLog { line, .. } => assert_eq!(line, "world"), + other => panic!("expected StepLog, got {other:?}"), + } + } + + #[tokio::test] + async fn step_log_writer_flushes_remaining_on_shutdown() { + use tokio::io::AsyncWriteExt; + + let bus = EventBus::new(); + let mut rx = bus.subscribe(); + let step_id = Uuid::new_v4(); + + let mut writer = StepLogWriter::new(step_id, bus); + // Write partial line without trailing newline. + writer.write_all(b"partial").await.unwrap(); + writer.shutdown().await.unwrap(); + + let ev = rx.recv().await.unwrap(); + match ev { + BuildEvent::StepLog { line, .. } => assert_eq!(line, "partial"), + other => panic!("expected StepLog, got {other:?}"), + } + } +} diff --git a/crates/hm/src/runner/mod.rs b/crates/hm/src/runner/mod.rs new file mode 100644 index 0000000..aefeec8 --- /dev/null +++ b/crates/hm/src/runner/mod.rs @@ -0,0 +1,246 @@ +//! Static runner interface. +//! +//! This module replaces the old WASM plugin system with a static DI +//! approach. Step executors implement [`StepRunner`]; output formatters +//! implement [`OutputRenderer`]. A [`RunnerRegistry`] maps runner names +//! to concrete implementations at startup. + +use std::collections::HashMap; +use std::fmt; +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use anyhow::Result; +use hm_plugin_protocol::{BuildEvent, ExecutorInput, StepResult}; +use tokio_util::sync::CancellationToken; + +use crate::orchestrator::archive::ArchiveStore; +use crate::orchestrator::docker_client::DockerClient; +use crate::orchestrator::events::EventBus; + +pub mod docker; + +// --------------------------------------------------------------------------- +// RunContext +// --------------------------------------------------------------------------- + +/// Shared context threaded into every runner invocation. +/// +/// Replaces the monolithic `OrchestratorState` that the old plugin +/// system passed as opaque host memory. All fields are cheaply +/// cloneable (`Arc` / `CancellationToken` / `DockerClient`). +#[derive(Clone, Debug)] +pub struct RunContext { + pub docker: DockerClient, + pub event_bus: Arc, + pub archives: Arc, + pub cancel: CancellationToken, +} + +// --------------------------------------------------------------------------- +// StepRunner trait +// --------------------------------------------------------------------------- + +/// Async trait implemented by step executors (e.g. the Docker runner). +/// +/// Each runner is identified by a string [`Self::name`] that pipeline +/// authors reference in their step definitions. +/// +/// The `execute` method returns a boxed future so the trait remains +/// dyn-compatible (async fn in trait is not object-safe). +pub trait StepRunner: Send + Sync + fmt::Debug { + /// Unique name for this runner (e.g. `"docker"`). + fn name(&self) -> &str; + + /// Execute a single pipeline step. + /// + /// # Errors + /// + /// Implementations should return `Err` for infrastructure failures + /// (container boot failure, network error, etc.). A non-zero exit + /// code from the user command is **not** an error — it is reported + /// via [`StepResult::exit_code`]. + fn execute( + &self, + ctx: &RunContext, + input: ExecutorInput, + ) -> Pin> + Send + '_>>; +} + +// --------------------------------------------------------------------------- +// OutputRenderer trait +// --------------------------------------------------------------------------- + +/// Synchronous observer of [`BuildEvent`]s. +/// +/// Implementations format events for human consumption (progress bars, +/// coloured log lines) or machine consumption (JSON-lines). +pub trait OutputRenderer: Send + fmt::Debug { + /// Called once per event in emission order. + fn on_event(&mut self, event: &BuildEvent); +} + +// --------------------------------------------------------------------------- +// RunnerRegistry +// --------------------------------------------------------------------------- + +/// Maps runner names to [`StepRunner`] implementations. +/// +/// Constructed once at startup and shared immutably for the duration +/// of the run. +#[derive(Default)] +pub struct RunnerRegistry { + runners: HashMap>, + default: Option, +} + +impl RunnerRegistry { + /// Create an empty registry. + #[must_use] + pub fn new() -> Self { + Self { + runners: HashMap::new(), + default: None, + } + } + + /// Register a runner. When `is_default` is true the runner's name + /// becomes the fallback used by [`Self::resolve`] when no explicit + /// name is given. + pub fn register(&mut self, runner: Arc, is_default: bool) { + let name = runner.name().to_owned(); + if is_default { + self.default = Some(name.clone()); + } + self.runners.insert(name, runner); + } + + /// Look up a runner by name, falling back to the default when + /// `name` is `None`. + #[must_use] + pub fn resolve(&self, name: Option<&str>) -> Option> { + let key = name.or(self.default.as_deref())?; + self.runners.get(key).cloned() + } + + /// The name of the current default runner, if one has been set. + #[must_use] + pub fn default_runner_name(&self) -> Option<&str> { + self.default.as_deref() + } + + /// Sorted list of all registered runner names. + #[must_use] + pub fn runner_names(&self) -> Vec<&str> { + let mut names: Vec<&str> = self.runners.keys().map(String::as_str).collect(); + names.sort_unstable(); + names + } +} + +impl fmt::Debug for RunnerRegistry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RunnerRegistry") + .field("runners", &self.runners.keys().collect::>()) + .field("default", &self.default) + .finish() + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + /// Minimal stub runner for unit tests. + #[derive(Debug)] + struct StubRunner { + runner_name: String, + } + + impl StubRunner { + fn new(name: &str) -> Self { + Self { + runner_name: name.to_owned(), + } + } + } + + impl StepRunner for StubRunner { + fn name(&self) -> &str { + &self.runner_name + } + + fn execute( + &self, + _ctx: &RunContext, + _input: ExecutorInput, + ) -> Pin> + Send + '_>> { + Box::pin(async { + Ok(StepResult { + exit_code: 0, + committed_snapshot: None, + artifacts: vec![], + }) + }) + } + } + + #[test] + fn resolve_by_name() { + let mut reg = RunnerRegistry::new(); + reg.register(Arc::new(StubRunner::new("docker")), false); + reg.register(Arc::new(StubRunner::new("local")), false); + + let runner = reg.resolve(Some("docker")).unwrap(); + assert_eq!(runner.name(), "docker"); + + let runner = reg.resolve(Some("local")).unwrap(); + assert_eq!(runner.name(), "local"); + + assert!(reg.resolve(Some("nope")).is_none()); + } + + #[test] + fn resolve_default() { + let mut reg = RunnerRegistry::new(); + reg.register(Arc::new(StubRunner::new("docker")), true); + reg.register(Arc::new(StubRunner::new("local")), false); + + // `None` name falls back to default. + let runner = reg.resolve(None).unwrap(); + assert_eq!(runner.name(), "docker"); + assert_eq!(reg.default_runner_name(), Some("docker")); + } + + #[test] + fn no_default_returns_none() { + let mut reg = RunnerRegistry::new(); + reg.register(Arc::new(StubRunner::new("docker")), false); + + assert!(reg.resolve(None).is_none()); + assert!(reg.default_runner_name().is_none()); + } + + #[test] + fn runner_names_sorted() { + let mut reg = RunnerRegistry::new(); + reg.register(Arc::new(StubRunner::new("zeta")), false); + reg.register(Arc::new(StubRunner::new("alpha")), false); + reg.register(Arc::new(StubRunner::new("mid")), false); + + assert_eq!(reg.runner_names(), vec!["alpha", "mid", "zeta"]); + } + + #[test] + fn debug_impl() { + let reg = RunnerRegistry::new(); + // Just ensure it doesn't panic. + let _ = format!("{reg:?}"); + } +} diff --git a/crates/hm/tests/cmd_cloud_login_paste.rs b/crates/hm/tests/cmd_cloud_login_paste.rs deleted file mode 100644 index a7b5869..0000000 --- a/crates/hm/tests/cmd_cloud_login_paste.rs +++ /dev/null @@ -1,55 +0,0 @@ -//! Integration test: hm cloud login --paste against a wiremock API. -//! -//! Stubs the two endpoints the paste flow hits — `POST /cli/exchange` -//! (token redemption) and `GET /auth/me` (display name) — and uses -//! `HARMONT_LOGIN_CODE` to inject the "pasted" code without a TTY. -//! Token persistence is delegated to the host keyring; this test -//! asserts on the stderr message ("logged in as Test User"), which is -//! the user-facing signifier that both calls succeeded. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use assert_cmd::Command; -use wiremock::matchers::{method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -#[tokio::test(flavor = "multi_thread")] -async fn cloud_login_paste_stores_token_and_prints_user() { - let server = MockServer::start().await; - - Mock::given(method("POST")) - .and(path("/cli/exchange")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "token": "test-token" - }))) - .mount(&server) - .await; - - Mock::given(method("GET")) - .and(path("/auth/me")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "id": "00000000-0000-0000-0000-000000000001", - "email": "test@example.com", - // Wire field is `name` (see api/types.rs); the plan's draft - // used `display_name`, which serde silently dropped → the - // login banner fell back to the email. - "name": "Test User" - }))) - .mount(&server) - .await; - - let temp = tempfile::tempdir().unwrap(); - let assert = Command::cargo_bin("hm") - .unwrap() - .args(["cloud", "login", "--paste"]) - .env("HARMONT_API_URL", server.uri()) - .env("HARMONT_LOGIN_CODE", "fake-pasted-code") - .env("XDG_CONFIG_HOME", temp.path()) - .env("HOME", temp.path()) - .current_dir(temp.path()) - .assert(); - - assert - .success() - .stderr(predicates::str::contains("logged in as Test User")); -} diff --git a/crates/hm/tests/cmd_cloud_run.rs b/crates/hm/tests/cmd_cloud_run.rs deleted file mode 100644 index 380250c..0000000 --- a/crates/hm/tests/cmd_cloud_run.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! Integration test: hm cloud run. -//! -//! Wiremocks the build-create endpoint, seeds `.harmont/plan.json` and -//! the plugin's KV state file (for the active org slug), and runs with -//! `--no-watch` so we don't hit the watch loop. Asserts on the stderr -//! "submitted build #42" signifier. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use assert_cmd::Command; -use wiremock::matchers::{method, path_regex}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -#[tokio::test(flavor = "multi_thread")] -async fn cloud_run_submits_and_prints_build_url() { - let server = MockServer::start().await; - Mock::given(method("POST")) - .and(path_regex(r"^/organizations/[^/]+/pipelines/[^/]+/builds$")) - .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({ - "id": "00000000-0000-0000-0000-000000000001", - "number": 42, - "state": "scheduled", - "branch": null, - "message": null, - "started_at": null, - "finished_at": null - }))) - .mount(&server) - .await; - - let temp = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(temp.path().join(".harmont")).unwrap(); - std::fs::write( - temp.path().join(".harmont/plan.json"), - r#"{"version":"0","steps":[]}"#, - ) - .unwrap(); - - // Pre-populate active_org via the cloud plugin's KV file. The host - // stores `KvScope::Plugin` state at - // `$XDG_CONFIG_HOME/harmont/state/.kv`. The on-disk - // shape is a JSON map `{: }`; the cloud plugin reads a - // single key `"state"` whose bytes are the JSON of `CloudState`. - let kv_dir = temp.path().join(".config/harmont/state"); - std::fs::create_dir_all(&kv_dir).unwrap(); - let state_json = serde_json::json!({ "active_org": "test" }); - let inner_bytes = serde_json::to_vec(&state_json).unwrap(); - let outer = serde_json::json!({ "state": inner_bytes }); - std::fs::write( - kv_dir.join("harmont-cloud.kv"), - serde_json::to_vec(&outer).unwrap(), - ) - .unwrap(); - - Command::cargo_bin("hm") - .unwrap() - .args([ - "cloud", - "run", - "p", - "--no-watch", - "--plan-file", - "plan.json", - ]) - .env("HARMONT_API_URL", server.uri()) - .env("HARMONT_API_TOKEN", "test-token") - .env("XDG_CONFIG_HOME", temp.path().join(".config")) - .env("HOME", temp.path()) - .current_dir(temp.path()) - .assert() - .success() - .stderr(predicates::str::contains("submitted build #42")); -} diff --git a/crates/hm/tests/cmd_cloud_whoami.rs b/crates/hm/tests/cmd_cloud_whoami.rs deleted file mode 100644 index 7079780..0000000 --- a/crates/hm/tests/cmd_cloud_whoami.rs +++ /dev/null @@ -1,61 +0,0 @@ -//! Integration test: hm cloud whoami. -//! -//! Two cases: -//! 1. With `HARMONT_API_TOKEN` in env the plugin pulls the token from -//! env (not the keyring), hits `GET /auth/me` with a `Bearer` -//! header, and prints the email on stdout. -//! 2. Without any token, the plugin returns a structured error whose -//! message contains "not logged in"; exit is non-zero. - -#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - -use assert_cmd::Command; -use wiremock::matchers::{header, method, path}; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -#[tokio::test(flavor = "multi_thread")] -async fn cloud_whoami_uses_token_from_env() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/auth/me")) - .and(header("authorization", "Bearer env-token")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "id": "00000000-0000-0000-0000-000000000001", - "email": "alice@example.com", - // Wire field is `name`, not `display_name` (api/types.rs - // renames). With `null` here the whoami output falls back - // to the email — exactly what we assert below. - "name": null - }))) - .mount(&server) - .await; - - let temp = tempfile::tempdir().unwrap(); - Command::cargo_bin("hm") - .unwrap() - .args(["cloud", "whoami"]) - .env("HARMONT_API_URL", server.uri()) - .env("HARMONT_API_TOKEN", "env-token") - .env("XDG_CONFIG_HOME", temp.path()) - .env("HOME", temp.path()) - .current_dir(temp.path()) - .assert() - .success() - .stdout(predicates::str::contains("alice@example.com")); -} - -#[tokio::test(flavor = "multi_thread")] -async fn cloud_whoami_without_token_returns_helpful_error() { - let temp = tempfile::tempdir().unwrap(); - Command::cargo_bin("hm") - .unwrap() - .args(["cloud", "whoami"]) - .env("HARMONT_API_URL", "https://example.invalid") - .env_remove("HARMONT_API_TOKEN") - .env("XDG_CONFIG_HOME", temp.path()) - .env("HOME", temp.path()) - .current_dir(temp.path()) - .assert() - .failure() - .stderr(predicates::str::contains("not logged in")); -} diff --git a/crates/hm/tests/cmd_plugin.rs b/crates/hm/tests/cmd_plugin.rs index 0427eff..2c7aa34 100644 --- a/crates/hm/tests/cmd_plugin.rs +++ b/crates/hm/tests/cmd_plugin.rs @@ -1,5 +1,4 @@ -//! `hm plugin list` smoke. Real fixture-driven list/info tests live -//! in `plugin_registry.rs` once Phase F fixtures exist. +//! `hm plugin list` smoke test. #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] @@ -7,22 +6,12 @@ use assert_cmd::Command; use predicates::str::contains; #[test] -fn plugin_list_with_no_plugins_prints_help() { - let temp = tempfile::tempdir().unwrap(); +fn plugin_list_shows_registered_runners() { Command::cargo_bin("hm") .unwrap() .arg("plugin") .arg("list") - // Point XDG at a clean dir so no user plugins leak in. - // HOME is also set because some platforms ignore XDG_CONFIG_HOME - // without a HOME pointing somewhere. - .env("XDG_CONFIG_HOME", temp.path()) - .env("HOME", temp.path()) - // `project_plugins_dir()` uses cwd, so launch from the tempdir - // too — otherwise a `.harmont/plugins/` next to the dev tree - // would leak in. - .current_dir(temp.path()) .assert() .success() - .stdout(contains("No plugins installed.")); + .stdout(contains("docker")); } diff --git a/crates/hm/tests/cmd_version.rs b/crates/hm/tests/cmd_version.rs index ee564c4..c3d28b6 100644 --- a/crates/hm/tests/cmd_version.rs +++ b/crates/hm/tests/cmd_version.rs @@ -1,4 +1,4 @@ -//! `hm version` should exit 0 and print the version + API version. +//! `hm version` should exit 0 and print the version. #![allow(clippy::unwrap_used)] @@ -6,11 +6,11 @@ use assert_cmd::Command; use predicates::str::contains; #[test] -fn version_prints_api_version() { +fn version_prints_version() { Command::cargo_bin("hm") .unwrap() .arg("version") .assert() .success() - .stdout(contains("plugin api version: 1")); + .stdout(contains("hm ")); } diff --git a/crates/hm/tests/common/fixtures.rs b/crates/hm/tests/common/fixtures.rs deleted file mode 100644 index 0b882a6..0000000 --- a/crates/hm/tests/common/fixtures.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Locates fixture `.wasm` files for tests. -//! -//! We do not depend on the `hm-fixtures` crate as a normal -//! dependency because its target is `wasm32-wasip1`. Instead, tests -//! invoke `cargo build --target wasm32-wasip1 -p hm-fixtures` -//! lazily and read the output from -//! `cli/target/wasm32-wasip1/debug/.wasm`. - -#![allow(dead_code)] - -use std::path::PathBuf; -use std::process::Command; -use std::sync::OnceLock; - -static BUILT: OnceLock<()> = OnceLock::new(); - -/// Build the `hm-fixtures` crate for `wasm32-wasip1` if it hasn't been -/// built in this test process yet. Idempotent across threads. -/// -/// # Panics -/// -/// Panics if `cargo build` cannot be invoked or returns a non-zero -/// exit. Tests can't proceed without the artifacts, so failing loudly -/// is the right behaviour. -pub fn ensure_built() { - BUILT.get_or_init(|| { - let status = Command::new("cargo") - .args(["build", "--target", "wasm32-wasip1", "-p", "hm-fixtures"]) - .current_dir(workspace_root()) - .status() - .expect("invoke cargo build for hm-fixtures"); - assert!(status.success(), "hm-fixtures wasm build failed"); - }); -} - -/// Path to the compiled `.wasm` for a given fixture bin name (e.g. -/// `"noop_executor"`). Triggers `ensure_built` on first call. -#[must_use] -pub fn fixture_path(name: &str) -> PathBuf { - ensure_built(); - workspace_root() - .join("target") - .join("wasm32-wasip1") - .join("debug") - .join(format!("{name}.wasm")) -} - -fn workspace_root() -> PathBuf { - // cli/crates/hm/tests/common/fixtures.rs → cli/ - let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - p.pop(); // crates/hm → crates - p.pop(); // crates → cli - p -} diff --git a/crates/hm/tests/common/mod.rs b/crates/hm/tests/common/mod.rs index 30d93aa..bf71d67 100644 --- a/crates/hm/tests/common/mod.rs +++ b/crates/hm/tests/common/mod.rs @@ -1,7 +1,5 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -pub mod fixtures; - /// Construct a Command pointing at the freshly-built `hm` binary. /// /// # Panics diff --git a/crates/hm/tests/fixtures/pipelines/cached.py b/crates/hm/tests/fixtures/pipelines/cached.py deleted file mode 100644 index a7e1dd3..0000000 --- a/crates/hm/tests/fixtures/pipelines/cached.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Cached step: re-running should be a hit on the second invocation.""" -from datetime import timedelta - -import harmont as hm - - -@hm.pipeline("cached", default_image="alpine:3.20") -def cached() -> hm.Step: - t = hm.sh( - "date +%s > /tmp/ts && cat /tmp/ts", - label="t", image="alpine:3.20", - cache=hm.ttl(timedelta(days=1)), - ) - return t.sh("cat /tmp/ts", label="r") diff --git a/crates/hm/tests/fixtures/pipelines/chain.py b/crates/hm/tests/fixtures/pipelines/chain.py deleted file mode 100644 index 7bf62eb..0000000 --- a/crates/hm/tests/fixtures/pipelines/chain.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Linear chain: each step builds_in its predecessor; the chained -filesystem state must be visible (test -f /tmp/a).""" -import harmont as hm - - -@hm.pipeline("chain", default_image="alpine:3.20") -def chain() -> hm.Step: - a = hm.sh("touch /tmp/a && echo a", label="a", image="alpine:3.20") - b = a.sh("test -f /tmp/a && echo b-saw-a", label="b") - return b.sh("test -f /tmp/a && echo c-also-saw-a", label="c") diff --git a/crates/hm/tests/fixtures/pipelines/failing_step.py b/crates/hm/tests/fixtures/pipelines/failing_step.py deleted file mode 100644 index b0f6096..0000000 --- a/crates/hm/tests/fixtures/pipelines/failing_step.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Pipeline whose first step exits non-zero — used to test failure propagation.""" -import harmont as hm - - -@hm.pipeline("failing-step", default_image="alpine:3.20") -def failing_step() -> hm.Step: - return hm.sh("exit 7", label="boom", image="alpine:3.20") diff --git a/crates/hm/tests/fixtures/pipelines/fork.py b/crates/hm/tests/fixtures/pipelines/fork.py deleted file mode 100644 index 0bb7be2..0000000 --- a/crates/hm/tests/fixtures/pipelines/fork.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Fork: two children of the same parent, both inherit its state.""" -import harmont as hm - - -@hm.pipeline("fork", default_image="alpine:3.20") -def fork() -> tuple[hm.Step, hm.Step]: - base = hm.sh("touch /tmp/x && echo base", label="base", - image="alpine:3.20") - left = base.sh("test -f /tmp/x && echo left", label="left") - right = base.sh("test -f /tmp/x && echo right", label="right") - return (left, right) diff --git a/crates/hm/tests/fixtures/pipelines/mid_chain_cache.py b/crates/hm/tests/fixtures/pipelines/mid_chain_cache.py deleted file mode 100644 index ee53374..0000000 --- a/crates/hm/tests/fixtures/pipelines/mid_chain_cache.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Cache attached to a non-leaf step in a chain.""" -from datetime import timedelta - -import harmont as hm - - -@hm.pipeline("mid-chain-cache", default_image="alpine:3.20") -def mid_chain_cache() -> hm.Step: - a = hm.sh("echo a > /tmp/a", label="a", image="alpine:3.20") - b = a.sh( - "cat /tmp/a && date +%s > /tmp/b", - label="b", - cache=hm.ttl(timedelta(hours=1)), - ) - return b.sh("cat /tmp/b", label="c") diff --git a/crates/hm/tests/fixtures/pipelines/scratch.py b/crates/hm/tests/fixtures/pipelines/scratch.py deleted file mode 100644 index e7718a3..0000000 --- a/crates/hm/tests/fixtures/pipelines/scratch.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Single-step pipeline: simplest end-to-end test.""" -import harmont as hm - - -@hm.pipeline("scratch", default_image="alpine:3.20") -def scratch() -> hm.Step: - return hm.sh("echo hi", label="hi", image="alpine:3.20") diff --git a/crates/hm/tests/fixtures/pyrightconfig.json b/crates/hm/tests/fixtures/pyrightconfig.json deleted file mode 100644 index 40dd7b1..0000000 --- a/crates/hm/tests/fixtures/pyrightconfig.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extraPaths": ["../../../cidsl/py"], - "reportMissingImports": "warning" -} diff --git a/crates/hm/tests/plugin_host_fns.rs b/crates/hm/tests/plugin_host_fns.rs deleted file mode 100644 index aac0e20..0000000 --- a/crates/hm/tests/plugin_host_fns.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! End-to-end: load the `host_fn_probe` fixture, run its subcommand, -//! and parse its `Report`. Every host fn in `HOST_FN_NAMES` must -//! either be exercised here or by a downstream plan's tests. - -#![allow( - clippy::cargo_common_metadata, - clippy::multiple_crate_versions, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - unsafe_code, - reason = "test pokes XDG_CONFIG_HOME via std::env::set_var, which is unsafe in Rust 2024" -)] - -pub mod common; - -use common::fixtures; -use harmont_cli::plugin::host::dummy_subcommand_input; -use harmont_cli::plugin::{PluginRegistry, RegistryConfig}; -use hm_plugin_protocol::ExitInfo; -use serde::Deserialize; - -#[derive(Debug, Deserialize)] -#[allow( - clippy::struct_excessive_bools, - reason = "this is a test report struct" -)] -struct Report { - log_ok: bool, - kv_round_trip: bool, - kv_isolated_per_scope: bool, - fs_read_returns_none_for_missing: bool, - keyring_round_trip: bool, - should_cancel_default_false: bool, -} - -#[tokio::test(flavor = "multi_thread")] -async fn host_fn_probe_passes_all_checks() { - // KvScope::Plugin is persisted under /harmont/state/ and - // the credential store under /.harmont/credentials.toml. Point - // both at a tempdir so this test doesn't touch the developer's real - // config tree. - let temp = tempfile::tempdir().expect("tempdir"); - // SAFETY: process-wide env vars set during a test; the tempdir is - // unique per run and the test doesn't unset it (other tests use - // their own tempdirs). - unsafe { - std::env::set_var("XDG_CONFIG_HOME", temp.path()); - std::env::set_var("HOME", temp.path()); - } - let path = fixtures::fixture_path("host_fn_probe"); - let reg = PluginRegistry::load(RegistryConfig { - auto_discover: false, - extra_paths: vec![path], - embedded: vec![], - ..Default::default() - }) - .expect("load registry"); - let idx = reg.subcommand_index["fixture-probe"]; - let plugin = reg.get(idx).expect("plugin present"); - let info: ExitInfo = plugin - .call_capability("hm_subcommand_run", &dummy_subcommand_input()) - .await - .expect("invoke"); - let report: Report = - serde_json::from_str(info.message.as_deref().expect("report message present")) - .expect("parse Report json"); - assert!(report.log_ok); - assert!(report.kv_round_trip); - assert!(report.kv_isolated_per_scope); - assert!(report.fs_read_returns_none_for_missing); - assert!(report.keyring_round_trip); - assert!(report.should_cancel_default_false); -} diff --git a/crates/hm/tests/plugin_kv_concurrency.rs b/crates/hm/tests/plugin_kv_concurrency.rs deleted file mode 100644 index 122fcf5..0000000 --- a/crates/hm/tests/plugin_kv_concurrency.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! Concurrent writers to `KvScope::Plugin` must all win — load → insert → -//! save without a lock loses writes. This test FAILS on the pre-fix -//! tree; Task C2 adds an advisory file lock that makes it pass. - -#![allow( - clippy::cargo_common_metadata, - clippy::multiple_crate_versions, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - unsafe_code, - reason = "test pokes XDG_CONFIG_HOME via std::env::set_var, which is unsafe in Rust 2024" -)] - -use std::thread; - -use harmont_cli::plugin::host_fns::{kv_set_impl, load_plugin_kv, set_current_plugin_name}; -use hm_plugin_protocol::KvScope; - -/// Drives N threads concurrently into the plugin-scope KV and asserts -/// every key persists. Without a lock around the RMW window the -/// second-writer's atomic save clobbers the first-writer's insert. -/// -/// Ignored by default because: -/// 1. On the unfixed tree it would fail-spam the default test suite. -/// 2. After Task C2 fixes the race it passes — but `set_var` is -/// process-global and would race with other tests that touch -/// `XDG_CONFIG_HOME`. Run explicitly via `cargo test --test -/// plugin_kv_concurrency -- --ignored` after C2 lands. -#[test] -#[ignore = "reveals race; pre-C2 fails; post-C2 passes"] -fn concurrent_plugin_kv_writes_all_persist() { - const PLUGIN: &str = "concurrency-test-plugin"; - const N: usize = 16; - - let tmp = tempfile::tempdir().unwrap(); - // SAFETY: process-global. The test is `#[ignore]`d so it's invoked - // explicitly via --ignored and the user controls when it runs. - unsafe { - std::env::set_var("XDG_CONFIG_HOME", tmp.path()); - } - - // Make payloads non-trivial so the save window widens enough for - // the race to be reproducible. - let payload = vec![0x42u8; 1024]; - - let handles: Vec<_> = (0..N) - .map(|i| { - let payload = payload.clone(); - thread::spawn(move || { - set_current_plugin_name(PLUGIN.into()); - kv_set_impl(KvScope::Plugin, &format!("key_{i}"), payload); - }) - }) - .collect(); - - for h in handles { - h.join().unwrap(); - } - - set_current_plugin_name(PLUGIN.into()); - let kv = load_plugin_kv(); - let missing: Vec = (0..N) - .filter(|i| !kv.contains_key(&format!("key_{i}"))) - .collect(); - assert!( - missing.is_empty(), - "lost writes for keys: {missing:?} (got {} of {N})", - kv.len() - ); -} diff --git a/crates/hm/tests/plugin_manifest.rs b/crates/hm/tests/plugin_manifest.rs deleted file mode 100644 index 3f9b185..0000000 --- a/crates/hm/tests/plugin_manifest.rs +++ /dev/null @@ -1,54 +0,0 @@ -//! Manifest validation: hosts must reject wrong API versions, missing -//! host fns, and duplicate runners. - -#![allow( - clippy::cargo_common_metadata, - clippy::multiple_crate_versions, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic -)] - -pub mod common; - -use common::fixtures; -use harmont_cli::error::HmError; -use harmont_cli::plugin::{PluginRegistry, RegistryConfig}; - -#[test] -fn rejects_wrong_api_version() { - let path = fixtures::fixture_path("bad_api_version"); - let err = PluginRegistry::load(RegistryConfig { - auto_discover: false, - extra_paths: vec![path], - embedded: vec![], - ..Default::default() - }) - .expect_err("should fail to load"); - let hm_err: &HmError = err.downcast_ref().expect("HmError"); - match hm_err { - HmError::PluginManifest { - found_api, - expected_api, - .. - } => { - assert_eq!(*found_api, 9999); - assert_eq!(*expected_api, hm_plugin_protocol::HM_PLUGIN_API_VERSION); - } - other => panic!("expected PluginManifest variant, got {other:?}"), - } -} - -#[test] -fn rejects_duplicate_runner() { - let path = fixtures::fixture_path("noop_executor"); - let err = PluginRegistry::load(RegistryConfig { - auto_discover: false, - extra_paths: vec![path.clone(), path], - embedded: vec![], - ..Default::default() - }) - .expect_err("should detect duplicate"); - let hm_err: &HmError = err.downcast_ref().expect("HmError"); - assert!(matches!(hm_err, HmError::PluginConflict { verb, .. } if verb == "runner:noop")); -} diff --git a/crates/hm/tests/plugin_registry.rs b/crates/hm/tests/plugin_registry.rs deleted file mode 100644 index 635ba4e..0000000 --- a/crates/hm/tests/plugin_registry.rs +++ /dev/null @@ -1,104 +0,0 @@ -//! Capability indexing. -//! -//! After loading `noop_executor` + `recording_hook` + `failing_subcommand`, -//! the registry has the expected indices and we can dispatch through them. - -#![allow( - clippy::cargo_common_metadata, - clippy::multiple_crate_versions, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic -)] - -pub mod common; - -use common::fixtures; -use harmont_cli::plugin::{PluginRegistry, RegistryConfig}; -use hm_plugin_protocol::{ - ArchiveId, CacheDecision, CommandStep, ExecutorInput, ExitInfo, StepResult, -}; -use serde_json::json; -use uuid::Uuid; - -#[test] -fn loads_three_fixtures_and_builds_indices() { - let reg = PluginRegistry::load(RegistryConfig { - auto_discover: false, - extra_paths: vec![ - fixtures::fixture_path("noop_executor"), - fixtures::fixture_path("recording_hook"), - fixtures::fixture_path("failing_subcommand"), - ], - embedded: vec![], - ..Default::default() - }) - .expect("load"); - assert!(reg.runner_index.contains_key("noop")); - assert!(reg.subcommand_index.contains_key("fixture-fail")); - assert_eq!(reg.manifests().count(), 3); -} - -#[tokio::test(flavor = "multi_thread")] -async fn dispatches_subcommand_with_nonzero_exit_info() { - let reg = PluginRegistry::load(RegistryConfig { - auto_discover: false, - extra_paths: vec![fixtures::fixture_path("failing_subcommand")], - embedded: vec![], - ..Default::default() - }) - .unwrap(); - let idx = reg.subcommand_index["fixture-fail"]; - let plugin = reg.get(idx).unwrap(); - let info: ExitInfo = plugin - .call_capability( - "hm_subcommand_run", - &json!({"verb_path": ["fixture-fail"], "args": {}, "env": {}}), - ) - .await - .unwrap(); - assert_eq!(info.exit_code, 7); - assert_eq!( - info.message.as_deref(), - Some("intentional failure for tests") - ); -} - -#[tokio::test(flavor = "multi_thread")] -async fn dispatches_step_executor() { - let reg = PluginRegistry::load(RegistryConfig { - auto_discover: false, - extra_paths: vec![fixtures::fixture_path("noop_executor")], - embedded: vec![], - ..Default::default() - }) - .unwrap(); - let idx = reg.runner_index["noop"]; - let plugin = reg.get(idx).unwrap(); - let input = ExecutorInput { - step: CommandStep { - key: "build".into(), - label: None, - cmd: "true".into(), - image: None, - env: None, - timeout_seconds: None, - cache: None, - runner: Some("noop".into()), - runner_args: None, - }, - workspace_archive_id: ArchiveId(Uuid::nil()), - env: std::collections::BTreeMap::new(), - workdir: "/workspace".into(), - run_id: Uuid::nil(), - step_id: Uuid::nil(), - cache_lookup: CacheDecision::MissNoCommit, - parent_snapshot: None, - }; - let result: StepResult = plugin - .call_capability("hm_executor_run", &input) - .await - .unwrap(); - assert_eq!(result.exit_code, 0); - assert!(result.committed_snapshot.is_none()); -} diff --git a/crates/hm/tests/runner_dispatch.rs b/crates/hm/tests/runner_dispatch.rs deleted file mode 100644 index d66c24d..0000000 --- a/crates/hm/tests/runner_dispatch.rs +++ /dev/null @@ -1,159 +0,0 @@ -//! Regression test: a `CommandStep` declaring `runner: "freestyle"` -//! must dispatch to the freestyle plugin, not the docker default. -//! -//! Background — PR #22: an earlier conversion path between the wire -//! `Pipeline` and the scheduler's `Node`/`ExecutorInput` round-trip -//! silently dropped the `runner` field, so every step landed on the -//! docker executor regardless of what the IR declared. A3 made the -//! orchestrator graph consume wire types directly so `runner` survives -//! end-to-end. This test pins that behaviour. -//! -//! Shape: -//! 1. Parse a JSON `Pipeline` with one step declaring `runner: "freestyle"`. -//! 2. Build a `Graph` from it (the conversion path under test). -//! 3. Construct an `ExecutorInput` from `graph.nodes[0].step.clone()` -//! — mirroring exactly what the scheduler does — and derive the -//! runner via the scheduler's `runner.clone().unwrap_or("docker")` -//! pattern. -//! 4. Dispatch through the registry's `runner_index`. -//! 5. Read back the persistent KV slot the freestyle fixture wrote. -//! -//! If a future change drops `runner` through the graph, step 3 falls -//! back to `"docker"`, dispatch lands on the docker plugin (which is -//! not loaded here), and the assertion in step 5 fails. - -#![allow( - clippy::cargo_common_metadata, - clippy::multiple_crate_versions, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - unsafe_code, - reason = "test pokes XDG_CONFIG_HOME via std::env::set_var, which is unsafe in Rust 2024" -)] - -pub mod common; - -use std::collections::BTreeMap; - -use daggy::petgraph::visit::IntoNodeReferences; - -use common::fixtures; -use hm_pipeline_ir::PipelineGraph; -use harmont_cli::plugin::{PluginRegistry, RegistryConfig}; -use hm_plugin_protocol::{ArchiveId, CacheDecision, ExecutorInput, StepResult}; -use uuid::Uuid; - -const PIPELINE_JSON: &[u8] = br#"{ - "version": "0", - "graph": { - "nodes": [ - { - "step": { - "key": "fs-step", - "cmd": "irrelevant; fixture ignores cmd", - "runner": "freestyle" - }, - "env": {} - } - ], - "edge_property": "directed", - "edges": [] - } -}"#; - -#[tokio::test(flavor = "multi_thread")] -async fn runner_field_dispatches_to_named_plugin() { - // The freestyle fixture writes to KvScope::Plugin, which the host - // persists at /harmont/state/.kv. Pin the - // config dir to a tempdir so this test is hermetic and doesn't - // touch the developer's real state. - let temp = tempfile::tempdir().expect("tempdir"); - // SAFETY: process-wide env var set during a test; the tempdir is - // unique per run. Mirrors the pattern in `plugin_host_fns.rs`. - unsafe { - std::env::set_var("XDG_CONFIG_HOME", temp.path()); - } - - // 1. Load the freestyle fixture into a clean registry. - let reg = PluginRegistry::load(RegistryConfig { - auto_discover: false, - extra_paths: vec![fixtures::fixture_path("freestyle_runner")], - embedded: vec![], - ..Default::default() - }) - .expect("load registry"); - - // 2. Deserialize the graph directly from JSON — the new wire format. - let graph: PipelineGraph = serde_json::from_slice(PIPELINE_JSON).expect("parse graph"); - - // Sanity check: the graph must preserve `runner` from the IR. - // This is the cheap fast-fail; the dispatch check below is the - // load-bearing one. - let (_, first_transition) = graph.dag().graph().node_references() - .find(|(_, t)| t.step.key == "fs-step") - .unwrap(); - assert_eq!( - first_transition.step.runner.as_deref(), - Some("freestyle"), - "graph dropped `runner` field — A3's wire-type fix has regressed" - ); - - // 3. Build the executor input exactly as the scheduler does - // (orchestrator/scheduler.rs::run_chain). Cloning the wire - // step preserves `runner` and `runner_args` verbatim. - let step_wire = first_transition.step.clone(); - let input = ExecutorInput { - step: step_wire, - workspace_archive_id: ArchiveId(Uuid::nil()), - env: BTreeMap::new(), - workdir: "/workspace".into(), - run_id: Uuid::nil(), - step_id: Uuid::nil(), - cache_lookup: CacheDecision::MissNoCommit, - parent_snapshot: None, - }; - - // 4. Derive the runner the same way the scheduler does. If a - // future change makes the scheduler stop honouring - // `input.step.runner`, this lookup falls back to "docker", the - // `runner_index` lookup misses (docker isn't loaded), and the - // test fails loudly. - let runner = input.step.runner.clone().unwrap_or_else(|| "docker".into()); - assert_eq!(runner, "freestyle", "runner derivation lost the field"); - - let idx = *reg - .runner_index - .get(&runner) - .unwrap_or_else(|| panic!("runner '{runner}' not in registry")); - let plugin = reg.get(idx).expect("plugin present at index"); - - // 5. Dispatch and assert the freestyle plugin actually ran. - let result: StepResult = plugin - .call_capability("hm_executor_run", &input) - .await - .expect("dispatch freestyle"); - assert_eq!(result.exit_code, 0); - - // The fixture wrote `step.key` into KvScope::Plugin under the key - // `freestyle_called_with`. Read it back via the persisted file: - // /harmont/state/.kv is the JSON - // serialisation of a BTreeMap>. - let kv_path = temp - .path() - .join("harmont") - .join("state") - .join("harmont-fixture-freestyle.kv"); - let bytes = std::fs::read(&kv_path) - .unwrap_or_else(|_| panic!("freestyle plugin KV file missing at {kv_path:?}")); - let kv: BTreeMap> = - serde_json::from_slice(&bytes).expect("parse freestyle plugin KV"); - let recorded = kv - .get("freestyle_called_with") - .expect("freestyle plugin did not record `freestyle_called_with` — dispatch missed"); - assert_eq!( - recorded.as_slice(), - b"fs-step", - "freestyle plugin recorded the wrong step key — dispatch wired the wrong step" - ); -}