diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b82d72..d1cec5d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,8 +19,6 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: - # build.rs cross-compiles hm-plugin-* wasm artifacts. - targets: wasm32-wasip1 components: clippy - uses: Swatinem/rust-cache@v2 @@ -63,8 +61,6 @@ jobs: path: harmont-py - uses: dtolnay/rust-toolchain@stable - with: - targets: wasm32-wasip1 - uses: Swatinem/rust-cache@v2 with: workspaces: harmont-cli diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 8f034f6..d64295e 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -43,20 +43,13 @@ jobs: build-hm: needs: validate-matrix runs-on: ubuntu-latest - # Debug build only — examples just need a working `hm` binary; - # release-mode LTO/codegen on wasmtime+cranelift can't finish in - # GH's 6h cap on a 2-vCPU runner. Debug builds in ~10 minutes - # cold, ~1 minute warm. + # Debug build only — examples just need a working `hm` binary. + # Debug builds in ~10 minutes cold, ~1 minute warm. timeout-minutes: 30 steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - with: - # crates/hm/build.rs cross-compiles hm-plugin-docker as a wasm - # plugin embedded into the binary, so the target must be on - # the toolchain. - targets: wasm32-wasip1 - uses: Swatinem/rust-cache@v2 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e37b7b..f494344 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,11 +18,6 @@ jobs: steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - with: - # crates/hm/build.rs cross-compiles hm-plugin-docker as a wasm - # plugin embedded into the binary, so the target must be on - # the toolchain for the `cargo check` in the next step. - targets: wasm32-wasip1 - uses: Swatinem/rust-cache@v2 - name: Set version from tag @@ -37,7 +32,7 @@ jobs: # is what consumers will receive). sed -i "s|hm-plugin-protocol = { path = \"crates/hm-plugin-protocol\", version = \"0.0.0-dev\" }|hm-plugin-protocol = { path = \"crates/hm-plugin-protocol\", version = \"$VERSION\" }|" Cargo.toml sed -i "s|hm-plugin-sdk = { path = \"crates/hm-plugin-sdk\", version = \"0.0.0-dev\" }|hm-plugin-sdk = { path = \"crates/hm-plugin-sdk\", version = \"$VERSION\" }|" Cargo.toml - cargo check --workspace --exclude hm-fixtures + cargo check --workspace - name: Publish hm-plugin-protocol run: | @@ -61,25 +56,6 @@ jobs: - name: Wait for crates.io index run: sleep 30 - - name: Build embedded WASM plugins - # The harmont-cli build.rs prefers crates/hm/embedded/*.wasm - # over the in-workspace cross-compile. Stage them here so the - # `cargo publish -p harmont-cli` verify-build (which runs in - # target/package/harmont-cli-/ without the sibling plugin - # crates) and any downstream `cargo install harmont-cli` both - # find the wasms already cooked. The include = [...] in - # crates/hm/Cargo.toml carries them into the tarball. - run: | - set -euo pipefail - for crate in hm-plugin-docker hm-plugin-output-human hm-plugin-output-json hm-plugin-cloud; do - cargo build --target wasm32-wasip1 -p "$crate" --release - done - mkdir -p crates/hm/embedded - for name in hm_plugin_docker hm_plugin_output_human hm_plugin_output_json hm_plugin_cloud; do - cp "target/wasm32-wasip1/release/$name.wasm" "crates/hm/embedded/$name.wasm" - done - ls -la crates/hm/embedded/ - - name: Publish harmont-cli run: | if curl -sf -A "harmont-release-ci (github-actions)" "https://crates.io/api/v1/crates/harmont-cli/$VERSION" > /dev/null 2>&1; then diff --git a/CLAUDE.md b/CLAUDE.md index c90dc54..75294fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,13 +1,14 @@ -The `cli/` directory is a Cargo workspace. +The `crates/` directory holds a Cargo workspace rooted at the repo root. - `crates/hm/` — the `hm` binary (today's CLI body). - `crates/hm-util/` — shared OS and filesystem utilities. - `crates/hm-plugin-protocol/` — wire types (serde structs only). -- `crates/hm-plugin-sdk/` — authoring SDK for plugin writers. -- `crates/hm-fixtures/` — test-only WASM plugins; compiled to - `target/wasm32-wasip1/debug/` by the test harness. +- `crates/hm-plugin-sdk/` — authoring SDK for plugin writers; exposes the stabby-based FFI traits. +- `crates/hm-plugin-macros/` — proc-macro crate powering `register_plugin!`. +- `crates/hm-plugin-runtime/` — plugin loading, discovery, host-API runtime. Owns `LoadedPlugin`, `PluginRegistry`, `HostApiImpl`. +- `crates/hm-plugin-docker/`, `crates/hm-plugin-cloud/` — bundled plugins (native cdylib dylibs). +- `tests/fixtures/` — test-only cdylib crates (`noop-executor`, `recording-hook`, etc.) built via `cargo build` as native shared libraries. -Run `cargo build` from the workspace root. Plugin fixtures need the -`wasm32-wasip1` target; install with `rustup target add wasm32-wasip1`. +Run `cargo build` from the workspace root. Run `cargo test --workspace` to exercise all crates. For cross-cutting doctrine see [PRINCIPLES.md](../PRINCIPLES.md). diff --git a/Cargo.lock b/Cargo.lock index 728ff27..c71b958 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" @@ -194,7 +167,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -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" @@ -367,6 +331,30 @@ dependencies = [ "serde_with", ] +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "bstr" version = "1.12.1" @@ -383,15 +371,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 +378,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" @@ -553,7 +448,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -571,15 +466,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" @@ -667,15 +553,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" @@ -685,144 +562,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" @@ -908,15 +647,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" @@ -944,7 +674,7 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "unicode-xid", ] @@ -978,16 +708,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" @@ -1005,21 +725,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" @@ -1028,7 +737,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1064,33 +773,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" @@ -1108,161 +796,45 @@ 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 = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filetime" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed8c5859bdab81d2eb4cd963eeacd8031d353b1ffb2fde43ee9179a0d6295120" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" dependencies = [ - "anyhow", - "async-trait", - "cbindgen", - "extism-convert", - "extism-manifest", - "glob", + "cfg-if", "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", + "libredox", ] [[package]] -name = "extism-convert" -version = "1.21.0" +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec1a8eac059a1730a21aa47f99a0c2075ba0ab88fd0c4e52e35027cf99cdf3e7" -dependencies = [ - "anyhow", - "base64", - "bytemuck", - "extism-convert-macros", - "prost", - "rmp-serde", - "serde", - "serde_json", -] +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "extism-convert-macros" -version = "1.21.0" +name = "flate2" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "848f105dd6e1af2ea4bb4a76447658e8587167df3c4e4658c4258e5b14a5b051" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ - "manyhow", - "proc-macro-crate", - "proc-macro2", - "quote", - "syn", + "crc32fast", + "miniz_oxide", ] [[package]] -name = "extism-manifest" -version = "1.21.0" +name = "float-cmp" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953a22ad322939ae4567ec73a34913a3a43dcbdfa648b8307d38fe56bb3a0acd" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" dependencies = [ - "base64", - "serde", - "serde_json", -] - -[[package]] -name = "extism-pdk" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "352fcb5a66eb74145a1c4a01f2bd15d59c62c85be73aac8471880c65b26b798f" -dependencies = [ - "anyhow", - "base64", - "extism-convert", - "extism-manifest", - "extism-pdk-derive", - "serde", - "serde_json", -] - -[[package]] -name = "extism-pdk-derive" -version = "1.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d086daea5fd844e3c5ac69ddfe36df4a9a43e7218cf7d1f888182b089b09806c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "fallible-iterator" -version = "0.3.0" -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 = "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", + "num-traits", ] [[package]] @@ -1286,17 +858,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" @@ -1369,7 +930,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1410,20 +971,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" @@ -1474,23 +1021,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" @@ -1553,23 +1083,24 @@ dependencies = [ "anyhow", "assert_cmd", "assert_fs", - "axum", "backon", "base64", "bollard", + "borsh", "bytes", "chrono", "clap", "comfy-table", "console 0.15.11", "dialoguer", - "extism", "flate2", "fs2", "futures", "futures-util", "hex", "hm-plugin-protocol", + "hm-plugin-runtime", + "hm-plugin-sdk", "hm-util", "ignore", "indicatif", @@ -1590,13 +1121,12 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", - "toml 0.8.23", + "toml", "tracing", "tracing-subscriber", - "ureq 2.12.1", + "ureq", "url", "uuid", - "webbrowser", "which", "wiremock", ] @@ -1614,7 +1144,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "foldhash", - "serde", ] [[package]] @@ -1642,76 +1171,136 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "hm-fixtures" +name = "hm-fixture-bad-api-version" version = "0.0.0" dependencies = [ + "borsh", + "hm-plugin-protocol", "hm-plugin-sdk", "semver", "serde", - "serde_json", + "stabby", ] [[package]] -name = "hm-plugin-cloud" -version = "0.1.0" +name = "hm-fixture-failing-subcommand" +version = "0.0.0" dependencies = [ - "anyhow", - "base64", - "chrono", - "clap", - "extism-pdk", + "borsh", + "hm-plugin-protocol", + "hm-plugin-sdk", + "semver", + "serde", + "stabby", +] + +[[package]] +name = "hm-fixture-freestyle-runner" +version = "0.0.0" +dependencies = [ + "borsh", + "hm-plugin-protocol", + "hm-plugin-sdk", + "semver", + "serde", + "stabby", +] + +[[package]] +name = "hm-fixture-host-fn-probe" +version = "0.0.0" +dependencies = [ + "borsh", "hm-plugin-protocol", "hm-plugin-sdk", "semver", "serde", "serde_json", - "sha2", - "url", - "uuid", + "stabby", ] [[package]] -name = "hm-plugin-docker" -version = "0.1.0" +name = "hm-fixture-noop-executor" +version = "0.0.0" dependencies = [ - "extism-pdk", + "borsh", "hm-plugin-protocol", "hm-plugin-sdk", "semver", "serde", "serde_json", + "stabby", +] + +[[package]] +name = "hm-fixture-recording-hook" +version = "0.0.0" +dependencies = [ + "borsh", + "hm-plugin-protocol", + "hm-plugin-sdk", + "semver", + "serde", + "stabby", ] [[package]] -name = "hm-plugin-output-human" +name = "hm-plugin-cloud" version = "0.1.0" dependencies = [ + "anyhow", + "axum", + "base64", + "borsh", "chrono", - "extism-pdk", + "clap", + "dialoguer", + "dirs", "hm-plugin-protocol", "hm-plugin-sdk", + "rand 0.8.6", + "reqwest", "semver", "serde", "serde_json", + "sha2", + "stabby", + "tokio", + "toml", + "url", "uuid", + "webbrowser", ] [[package]] -name = "hm-plugin-output-json" +name = "hm-plugin-docker" version = "0.1.0" dependencies = [ - "extism-pdk", + "bollard", + "borsh", + "futures-util", "hm-plugin-protocol", "hm-plugin-sdk", "semver", "serde", - "serde_json", + "stabby", + "tokio", +] + +[[package]] +name = "hm-plugin-macros" +version = "0.0.0-dev" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] name = "hm-plugin-protocol" version = "0.0.0-dev" dependencies = [ + "borsh", "chrono", "derive_more", "insta", @@ -1723,14 +1312,44 @@ dependencies = [ "uuid", ] +[[package]] +name = "hm-plugin-runtime" +version = "0.0.0-dev" +dependencies = [ + "anyhow", + "borsh", + "chrono", + "clap", + "hex", + "hm-plugin-protocol", + "hm-plugin-sdk", + "hm-util", + "libloading", + "reqwest", + "semver", + "serde_json", + "sha2", + "smart-default", + "stabby", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", +] + [[package]] name = "hm-plugin-sdk" version = "0.0.0-dev" dependencies = [ - "extism-pdk", + "borsh", + "clap", + "hm-plugin-macros", "hm-plugin-protocol", + "semver", "serde", - "serde_json", + "stabby", ] [[package]] @@ -2036,20 +1655,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" @@ -2099,22 +1704,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" @@ -2154,41 +1743,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" @@ -2216,7 +1776,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.117", ] [[package]] @@ -2235,7 +1795,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2266,12 +1826,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" @@ -2285,10 +1839,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" [[package]] -name = "libm" -version = "0.2.16" +name = "libloading" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] [[package]] name = "libredox" @@ -2348,73 +1906,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] -name = "mach2" -version = "0.4.3" +name = "matchers" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" dependencies = [ - "libc", + "regex-automata", ] [[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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matchit" -version = "0.7.3" +name = "matchit" +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" @@ -2557,18 +2068,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" @@ -2632,28 +2131,12 @@ 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", - "indexmap 2.14.0", -] - [[package]] name = "pin-project-lite" 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" @@ -2666,18 +2149,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" @@ -2739,7 +2210,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -2751,17 +2222,6 @@ 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" @@ -2771,52 +2231,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" @@ -2953,35 +2367,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" @@ -3000,17 +2385,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" @@ -3039,21 +2413,7 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "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", + "syn 2.0.117", ] [[package]] @@ -3141,31 +2501,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" @@ -3207,16 +2542,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" @@ -3373,7 +2698,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 2.0.117", ] [[package]] @@ -3442,7 +2767,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3453,7 +2778,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3477,7 +2802,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3489,15 +2814,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" @@ -3528,19 +2844,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" @@ -3563,6 +2866,12 @@ dependencies = [ "digest", ] +[[package]] +name = "sha2-const-stable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f179d4e11094a893b82fff208f74d448a7512f99f5a0acbd5c679b705f83ed9" + [[package]] name = "sharded-slab" version = "0.1.7" @@ -3622,16 +2931,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" @@ -3643,8 +2942,16 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "smart-default" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" dependencies = [ - "serde", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -3657,6 +2964,42 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "stabby" +version = "72.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "976399a0c48ea769ef7f5dc303bb88240ab8d84008647a6b2303eced3dab3945" +dependencies = [ + "libloading", + "rustversion", + "stabby-abi", +] + +[[package]] +name = "stabby-abi" +version = "72.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7b54832a9a1f92a0e55e74a5c0332744426edc515bb3fbad82f10b874a87f0d" +dependencies = [ + "rustc_version", + "rustversion", + "sha2-const-stable", + "stabby-macros", +] + +[[package]] +name = "stabby-macros" +version = "72.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a768b1e51e4dbfa4fa52ae5c01241c0a41e2938fdffbb84add0c8238092f9091" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "rand 0.8.6", + "syn 1.0.109", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3694,6 +3037,17 @@ dependencies = [ "is_ci", ] +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -3722,23 +3076,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "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", + "syn 2.0.117", ] [[package]] @@ -3752,12 +3090,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" @@ -3771,15 +3103,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" @@ -3822,7 +3145,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3833,7 +3156,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3926,7 +3249,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3960,26 +3283,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned 0.6.9", + "serde_spanned", "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", -] - [[package]] name = "toml_datetime" version = "0.6.11" @@ -3989,15 +3297,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" @@ -4015,7 +3314,7 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap 2.14.0", "serde", - "serde_spanned 0.6.9", + "serde_spanned", "toml_datetime 0.6.11", "toml_write", "winnow 0.7.15", @@ -4048,12 +3347,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" @@ -4110,7 +3403,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", @@ -4124,7 +3416,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -4208,12 +3500,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" @@ -4235,35 +3521,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" @@ -4276,12 +3533,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" @@ -4300,6 +3551,8 @@ version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ + "borsh", + "borsh-derive", "getrandom 0.4.2", "js-sys", "serde_core", @@ -4362,32 +3615,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" @@ -4448,7 +3675,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -4461,37 +3688,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", - "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" @@ -4499,17 +3695,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]] @@ -4520,8 +3706,8 @@ checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", "indexmap 2.14.0", - "wasm-encoder 0.244.0", - "wasmparser 0.244.0", + "wasm-encoder", + "wasmparser", ] [[package]] @@ -4537,19 +3723,6 @@ dependencies = [ "web-sys", ] -[[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" @@ -4562,319 +3735,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" @@ -4950,47 +3810,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" @@ -5022,26 +3841,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" @@ -5095,7 +3894,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5106,7 +3905,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5341,16 +4140,6 @@ 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" @@ -5397,7 +4186,7 @@ checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", "heck", - "wit-parser 0.244.0", + "wit-parser", ] [[package]] @@ -5410,7 +4199,7 @@ dependencies = [ "heck", "indexmap 2.14.0", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -5426,7 +4215,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -5444,28 +4233,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]] @@ -5483,19 +4254,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]] @@ -5533,7 +4292,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -5554,7 +4313,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5574,7 +4333,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -5614,7 +4373,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -5622,31 +4381,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 7244bb8..26708a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,18 +3,24 @@ resolver = "2" members = [ "crates/hm", "crates/hm-plugin-protocol", + "crates/hm-plugin-macros", "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-plugin-runtime", "crates/hm-util", + "crates/hm/plugins/hm-plugin-docker", + "crates/hm/plugins/hm-plugin-cloud", + "tests/fixtures/noop-executor", + "tests/fixtures/recording-hook", + "tests/fixtures/failing-subcommand", + "tests/fixtures/host-fn-probe", + "tests/fixtures/bad-api-version", + "tests/fixtures/freestyle-runner", ] default-members = [ "crates/hm", "crates/hm-plugin-protocol", "crates/hm-plugin-sdk", + "crates/hm-plugin-runtime", "crates/hm-util", ] @@ -25,20 +31,23 @@ repository = "https://github.com/harmont-dev/harmont-cli" [workspace.dependencies] hm-plugin-protocol = { path = "crates/hm-plugin-protocol", version = "0.0.0-dev" } +hm-plugin-macros = { path = "crates/hm-plugin-macros", version = "0.0.0-dev" } hm-plugin-sdk = { path = "crates/hm-plugin-sdk", version = "0.0.0-dev" } +hm-plugin-runtime = { path = "crates/hm-plugin-runtime", version = "0.0.0-dev" } hm-util = { path = "crates/hm-util", version = "0.0.0-dev" } serde = { version = "1", features = ["derive"] } serde_json = "1" schemars = { version = "0.8", features = ["preserve_order", "semver", "uuid1", "chrono"] } semver = { version = "1", features = ["serde"] } -uuid = { version = "1", features = ["serde", "v4"] } +uuid = { version = "1", features = ["serde", "v4", "borsh"] } chrono = { version = "0.4", features = ["serde"] } thiserror = "2" -derive_more = { version = "1", default-features = false, features = ["deref", "from", "display"] } +derive_more = { version = "1", default-features = false, features = ["deref", "from", "display"] } +smart-default = "0.7" tokio = { version = "1", features = ["full"] } tokio-util = { version = "0.7", features = ["rt"] } -extism = "1" -extism-pdk = "1" +stabby = { version = "=72.1.1", features = ["libloading"] } +borsh = { version = "1", features = ["derive"] } [workspace.lints.rust] unsafe_code = "deny" diff --git a/README.md b/README.md index 44dd9ef..2bee300 100644 --- a/README.md +++ b/README.md @@ -215,14 +215,14 @@ Cargo workspace: - `crates/hm/` — the `hm` binary. - `crates/hm-plugin-protocol/`, `crates/hm-plugin-sdk/` — public API for third-party plugins. -- `crates/hm-plugin-*` — bundled plugins (Docker executor, output formatters, cloud client). +- `crates/hm-plugin-*` — bundled plugins (Docker executor, cloud client). - `examples/` — sample pipeline repos to `hm run` against. This repo mirrors the `cli/` and `examples/` directories of the private Harmont monorepo. Open issues and PRs here; maintainers land them upstream and a CI sync replays the result back. ## Plugin authoring -`hm` is plugin-driven via [Extism](https://extism.org). To write one, start a `cdylib` crate and depend on the SDK: +`hm` is plugin-driven via native shared-library plugins (`.dylib`/`.so`/`.dll`) loaded through stabby's ABI-stable FFI. To write one, start a `cdylib` crate and depend on the SDK: ```sh cargo new --lib my-plugin @@ -230,19 +230,20 @@ cd my-plugin cargo add --git https://github.com/harmont-dev/harmont-cli hm-plugin-sdk ``` -Implement one of `StepExecutor`, `SubcommandPlugin`, `LifecycleHook`, or `OutputFormatter`, declare a `PluginManifest`, and call `register_plugin!(...)`. Then build to WebAssembly: +Implement one of `StepExecutor`, `SubcommandPlugin`, or `LifecycleHook`, declare a `PluginManifest`, and call `hm_plugin!(...)`. Then build: ```sh -cargo build --target wasm32-wasip1 --release +cargo build --release ``` -Install the resulting `.wasm`: +Install the resulting shared library: ```sh -hm plugin install ./target/wasm32-wasip1/release/my_plugin.wasm +hm plugin install ./target/release/libmy_plugin.dylib # macOS +hm plugin install ./target/release/libmy_plugin.so # Linux ``` -Built-in output formatters: `human` (default), `json`. Select with `hm run --format `. Working examples live in `crates/hm-fixtures/src/bin/`. +Working examples live in `tests/fixtures/`. ## See also diff --git a/RELEASING.md b/RELEASING.md index 4f907f4..3b81086 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -39,10 +39,9 @@ workflow in `.github/workflows/release.yml` triggers on any tag matching `v*`, seds the version from the tag into all three crates' `Cargo.toml` files plus the `workspace.dependencies` pins, and publishes `hm-plugin-protocol`, `hm-plugin-sdk`, and `harmont-cli` to crates.io in -that order. The bundled WASM plugins (`hm-plugin-docker`, -`hm-plugin-output-human`, `hm-plugin-output-json`, `hm-plugin-cloud`) -and `hm-fixtures` are not published — they ship embedded inside the -`hm` binary. +that order. The native cdylib plugins (`hm-plugin-docker`, `hm-plugin-cloud`) +and the test fixtures in `tests/fixtures/` are not published — they +are loaded from disk at runtime. ### Prerequisites (one-time) 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/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/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/src/auth/login.rs b/crates/hm-plugin-cloud/src/auth/login.rs deleted file mode 100644 index 8c359a1..0000000 --- a/crates/hm-plugin-cloud/src/auth/login.rs +++ /dev/null @@ -1,150 +0,0 @@ -//! `hm cloud login` — browser-loopback or paste-in flow. - -use std::collections::BTreeMap; - -use hm_plugin_protocol::PluginError; -use hm_plugin_sdk::host; - -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> { - let cfg = Config::from_env(env); - let (verifier, challenge) = pkce_pair()?; - - if paste { - login_paste(env, &cfg, &verifier, &challenge) - } else { - login_loopback(&cfg, &verifier, &challenge) - } -} - -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; - let redirect = format!("http://127.0.0.1:{port}/cb"); - let auth_url = format!( - "{}/cli/login?challenge={}&redirect_uri={}", - cfg.api_base, - challenge, - 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" - )); - } - - 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) -} - -fn login_paste( - env: &BTreeMap, - cfg: &Config, - verifier: &str, - challenge: &str, -) -> Result<(), PluginError> { - 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); - - // 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) - }; - let code = code.trim().to_string(); - if code.is_empty() { - return Err(PluginError::new("cloud_login_empty_code", "no code pasted")); - } - finalize(cfg, &code, verifier) -} - -fn finalize(cfg: &Config, code: &str, verifier: &str) -> Result<(), PluginError> { - let client = Client::anonymous(cfg); - let resp: CliExchangeResponse = client.post( - "/cli/exchange", - &CliExchangeRequest { - code: code.to_string(), - verifier: verifier.to_string(), - }, - )?; - 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", - 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> { - use base64::Engine; - use base64::engine::general_purpose::URL_SAFE_NO_PAD; - use sha2::{Digest, Sha256}; - - let mut seed = [0u8; 32]; - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0); - for (i, b) in seed.iter_mut().enumerate() { - *b = ((now >> (i % 16)) & 0xFF) as u8; - } - 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()); -} - -fn urlencoding(s: &str) -> String { - url::form_urlencoded::byte_serialize(s.as_bytes()).collect() -} diff --git a/crates/hm-plugin-cloud/src/cli.rs b/crates/hm-plugin-cloud/src/cli.rs deleted file mode 100644 index fbc8919..0000000 --- a/crates/hm-plugin-cloud/src/cli.rs +++ /dev/null @@ -1,218 +0,0 @@ -//! Plugin-internal CLI parsing. The plugin receives the raw argv from -//! the host (verb_path = ["cloud", ...]) and parses it with clap. - -use std::collections::BTreeMap; - -use clap::{Parser, Subcommand}; -use hm_plugin_protocol::{ExitInfo, PluginError}; - -use crate::{auth, verbs}; - -#[derive(Debug, Parser)] -#[command( - name = "hm cloud", - about = "Talk to the Harmont cloud API", - disable_help_subcommand = true -)] -struct CloudCli { - #[command(subcommand)] - command: CloudCommand, -} - -#[derive(Debug, Subcommand)] -enum CloudCommand { - /// Authenticate this CLI against the Harmont API. - Login { - /// Skip the loopback flow and prompt for a paste-in code. - #[arg(long)] - paste: bool, - }, - /// Remove stored credentials. - Logout, - /// Show the authenticated user. - Whoami, - /// Manage organizations. - #[command(subcommand)] - Org(OrgCommand), - /// Manage pipelines. - #[command(subcommand)] - Pipeline(PipelineCommand), - /// Manage builds. - #[command(subcommand)] - Build(BuildCommand), - /// Manage jobs. - #[command(subcommand)] - Job(JobCommand), - /// Manage credits, top-ups, and usage. - #[command(subcommand)] - Billing(BillingCommand), - /// Submit the local pipeline to the cloud and watch its build. - Run(verbs::run::RunArgs), -} - -#[derive(Debug, Subcommand)] -pub(crate) enum OrgCommand { - /// Set the active organization. - Switch { - /// Organization slug. - slug: String, - }, -} - -#[derive(Debug, Subcommand)] -pub(crate) enum PipelineCommand { - /// List pipelines for the active organization. - List, - /// Show pipeline details by slug. - Show { slug: String }, -} - -#[derive(Debug, Subcommand)] -pub(crate) enum BuildCommand { - /// List builds for a pipeline. - List { - #[arg(short, long)] - pipeline: String, - }, - /// Show a build by number. - Show { - #[arg(short, long)] - pipeline: String, - number: i64, - }, - /// Cancel a build. - Cancel { - #[arg(short, long)] - pipeline: String, - number: i64, - }, - /// Watch a build until it reaches a terminal state. - Watch { - #[arg(short, long)] - pipeline: String, - number: i64, - }, -} - -#[derive(Debug, Subcommand)] -pub(crate) enum JobCommand { - /// List jobs in a build. - List { - #[arg(short, long)] - pipeline: String, - #[arg(short, long)] - build: i64, - }, - /// Show a job by id. - Show { - #[arg(short, long)] - pipeline: String, - #[arg(short, long)] - build: i64, - job_id: String, - }, - /// Print the job log. - Log { - #[arg(short, long)] - pipeline: String, - #[arg(short, long)] - build: i64, - job_id: String, - }, -} - -#[derive(Debug, Subcommand)] -pub(crate) enum BillingCommand { - /// Print the current credit balance. - Balance, - /// List billing transactions. - Transactions { - #[arg(long, default_value = "100")] - limit: u32, - }, - /// Show usage over a time window. - Usage { - #[arg(long)] - from: Option, - #[arg(long)] - to: Option, - }, - /// Top up credits via Stripe checkout. - Topup { - amount_usd: u32, - #[arg(long)] - no_browser: bool, - }, - /// Redeem a coupon code. - Redeem { code: String }, -} - -pub(crate) 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. - 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, - }) - } - _ => 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), - }), - } -} - -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, - } -} diff --git a/crates/hm-plugin-cloud/src/creds.rs b/crates/hm-plugin-cloud/src/creds.rs deleted file mode 100644 index a3f47c0..0000000 --- a/crates/hm-plugin-cloud/src/creds.rs +++ /dev/null @@ -1,37 +0,0 @@ -//! On-disk credential storage via the host's keyring host fns. - -use std::collections::BTreeMap; - -use hm_plugin_sdk::host; - -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) { - if token.is_empty() { - host::keyring_delete(SERVICE, api_base); - } else { - host::keyring_set(SERVICE, api_base, token); - } -} - -/// 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) -} - -#[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); -} diff --git a/crates/hm-plugin-cloud/src/lib.rs b/crates/hm-plugin-cloud/src/lib.rs deleted file mode 100644 index 2778763..0000000 --- a/crates/hm-plugin-cloud/src/lib.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! Built-in cloud client plugin 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, - 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 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/verbs/billing.rs b/crates/hm-plugin-cloud/src/verbs/billing.rs deleted file mode 100644 index b7ad8e8..0000000 --- a/crates/hm-plugin-cloud/src/verbs/billing.rs +++ /dev/null @@ -1,133 +0,0 @@ -//! `hm cloud billing balance|transactions|usage|topup|redeem`. - -use std::collections::BTreeMap; - -use hm_plugin_protocol::PluginError; -use hm_plugin_sdk::host; - -use crate::api::types::{ - Balance, RedeemRequest, RedeemResponse, TopupRequest, TopupResponse, TransactionList, - UsageWindow, -}; -use crate::cli::BillingCommand; -use crate::config::Config; -use crate::creds; -use crate::http::Client; -use crate::state::CloudState; - -pub(crate) fn run(env: &BTreeMap, cmd: BillingCommand) -> Result<(), PluginError> { - let cfg = Config::from_env(env); - let token = creds::load_token(&cfg.api_base, env).ok_or_else(not_logged_in)?; - 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::Topup { - amount_usd, - no_browser, - } => topup(&client, &org, amount_usd, no_browser), - BillingCommand::Redeem { code } => redeem(&client, &org, &code), - } -} - -fn balance(client: &Client, org: &str) -> Result<(), PluginError> { - let b: Balance = client.get(&format!("/organizations/{org}/billing/balance"))?; - let dollars = b.credits_usd_cents as f64 / 100.0; - host::write_stdout(format!("${dollars:.2}\n").as_bytes()); - Ok(()) -} - -fn transactions(client: &Client, org: &str, limit: u32) -> Result<(), PluginError> { - let list: TransactionList = client.get(&format!( - "/organizations/{org}/billing/transactions?limit={limit}" - ))?; - for t in &list.data { - let line = format!( - "{} {:>10} {:<14} {}\n", - 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( - client: &Client, - org: &str, - from: Option<&str>, - to: Option<&str>, -) -> Result<(), PluginError> { - let mut q = vec![]; - if let Some(f) = from { - q.push(format!("from={f}")); - } - if let Some(t) = to { - q.push(format!("to={t}")); - } - let qs = if q.is_empty() { - String::new() - } else { - format!("?{}", q.join("&")) - }; - let u: UsageWindow = client.get(&format!("/organizations/{org}/billing/usage{qs}"))?; - let line = format!( - "{} -> {}: {:.2} min, ${:.2}\n", - 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, - }, - )?; - 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"); - } - 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(), - }, - )?; - let dollars = r.credited_cents as f64 / 100.0; - host::write_stderr(format!("credited ${dollars:.2}\n").as_bytes()); - Ok(()) -} - -fn not_logged_in() -> PluginError { - PluginError::new("cloud_not_logged_in", "not logged in; run `hm cloud login`") -} - -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 `", - ) - }) -} diff --git a/crates/hm-plugin-cloud/src/verbs/build.rs b/crates/hm-plugin-cloud/src/verbs/build.rs deleted file mode 100644 index 5fabc54..0000000 --- a/crates/hm-plugin-cloud/src/verbs/build.rs +++ /dev/null @@ -1,117 +0,0 @@ -//! `hm cloud build list|show|cancel|watch`. - -use std::collections::BTreeMap; - -use hm_plugin_protocol::PluginError; -use hm_plugin_sdk::host; - -use crate::api::types::{Build, BuildList}; -use crate::cli::BuildCommand; -use crate::config::Config; -use crate::creds; -use crate::http::Client; -use crate::state::CloudState; - -pub(crate) fn run(env: &BTreeMap, cmd: BuildCommand) -> Result<(), PluginError> { - let cfg = Config::from_env(env); - let token = creds::load_token(&cfg.api_base, env).ok_or_else(not_logged_in)?; - 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), - } -} - -fn list(client: &Client, org: &str, pipe: &str) -> Result<(), PluginError> { - let builds: BuildList = client.get(&format!("/organizations/{org}/pipelines/{pipe}/builds"))?; - for b in &builds.data { - let line = format!( - "#{:<5} {:<10} {}\n", - 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}" - ))?; - let json = serde_json::to_string_pretty(&b).unwrap_or_default(); - host::write_stdout(json.as_bytes()); - host::write_stdout(b"\n"); - 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()); - 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. - 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}" - ))?; - if b.state != last_state { - host::write_stderr(format!("state: {last_state} -> {}\n", b.state).as_bytes()); - 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), - )); - } - _ => {} - } - 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", - )); - } - } - } -} - -fn not_logged_in() -> PluginError { - PluginError::new("cloud_not_logged_in", "not logged in; run `hm cloud login`") -} - -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 `", - ) - }) -} diff --git a/crates/hm-plugin-cloud/src/verbs/job.rs b/crates/hm-plugin-cloud/src/verbs/job.rs deleted file mode 100644 index c3b0d3f..0000000 --- a/crates/hm-plugin-cloud/src/verbs/job.rs +++ /dev/null @@ -1,87 +0,0 @@ -//! `hm cloud job list|show|log`. - -use std::collections::BTreeMap; - -use hm_plugin_protocol::PluginError; -use hm_plugin_sdk::host; - -use crate::api::types::{Job, JobList, JobLog}; -use crate::cli::JobCommand; -use crate::config::Config; -use crate::creds; -use crate::http::Client; -use crate::state::CloudState; - -pub(crate) fn run(env: &BTreeMap, cmd: JobCommand) -> Result<(), PluginError> { - let cfg = Config::from_env(env); - let token = creds::load_token(&cfg.api_base, env).ok_or_else(not_logged_in)?; - let client = Client::new(&cfg, Some(token)); - let org = active_org()?; - - match cmd { - JobCommand::List { pipeline, build } => list(&client, &org, &pipeline, build), - JobCommand::Show { - pipeline, - build, - job_id, - } => show(&client, &org, &pipeline, build, &job_id), - JobCommand::Log { - pipeline, - build, - job_id, - } => log(&client, &org, &pipeline, build, &job_id), - } -} - -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" - ))?; - for j in &jobs.data { - let line = format!( - "{} {:<10} {}\n", - 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(), - ); - 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" - ))?; - for chunk in &log.data { - host::write_stdout(chunk.line.as_bytes()); - host::write_stdout(b"\n"); - } - Ok(()) -} - -fn not_logged_in() -> PluginError { - PluginError::new("cloud_not_logged_in", "not logged in; run `hm cloud login`") -} - -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 `", - ) - }) -} diff --git a/crates/hm-plugin-cloud/src/verbs/run.rs b/crates/hm-plugin-cloud/src/verbs/run.rs deleted file mode 100644 index efbcc12..0000000 --- a/crates/hm-plugin-cloud/src/verbs/run.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! `hm cloud run [TASK]` — submit the local pipeline plan to the cloud -//! 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 -//! upload — required by the live API — lands in plan 5. - -use std::collections::BTreeMap; - -use clap::Parser; -use hm_plugin_protocol::PluginError; -use hm_plugin_sdk::host; - -use crate::api::types::{Build, CreateBuildRequest}; -use crate::config::Config; -use crate::creds; -use crate::http::Client; -use crate::state::CloudState; - -#[derive(Debug, Parser)] -pub(crate) struct RunArgs { - /// Pipeline slug. Required. - pub pipeline: String, - /// Branch to record on the build. - #[arg(short, long)] - pub branch: Option, - /// Build message. - #[arg(short, long)] - pub message: Option, - /// Path to a pre-rendered pipeline JSON file. - /// If unset, the plugin reads `.harmont/plan.json`. - #[arg(long)] - pub plan_file: Option, - /// Don't watch; print the build URL and exit. - #[arg(long)] - pub no_watch: bool, -} - -pub(crate) fn run(env: &BTreeMap, args: RunArgs) -> Result<(), PluginError> { - 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 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 `", - ) - })?; - - // 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. - 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 plan_json: serde_json::Value = serde_json::from_slice(&bytes) - .map_err(|e| PluginError::new("cloud_plan_invalid_json", e.to_string()))?; - - let req = CreateBuildRequest { - pipeline_slug: args.pipeline.clone(), - branch: args.branch.clone(), - message: args.message.clone(), - env: env - .iter() - .filter(|(k, _)| k.starts_with("HM_RUN_ENV_")) - .map(|(k, v)| (k.trim_start_matches("HM_RUN_ENV_").to_string(), v.clone())) - .collect(), - plan_json, - }; - let build: Build = client.post( - &format!("/organizations/{org}/pipelines/{}/builds", args.pipeline), - &req, - )?; - let url = format!( - "{}/{}/{}/builds/{}", - cfg.api_base.trim_end_matches("/api"), - org, - args.pipeline, - build.number - ); - host::write_stderr(format!("submitted build #{}: {url}\n", build.number).as_bytes()); - if args.no_watch { - return Ok(()); - } - // Watch loop: same shape as verbs::build::watch. - crate::verbs::build::run( - env, - crate::cli::BuildCommand::Watch { - pipeline: args.pipeline.clone(), - number: build.number, - }, - ) -} 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/lib.rs b/crates/hm-plugin-docker/src/lib.rs deleted file mode 100644 index 4bc4dbe..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.0.clone(), - }) { - 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-macros/Cargo.toml b/crates/hm-plugin-macros/Cargo.toml new file mode 100644 index 0000000..3f4af09 --- /dev/null +++ b/crates/hm-plugin-macros/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "hm-plugin-macros" +version = "0.0.0-dev" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Proc-macro crate for hm-plugin-sdk. Provides the `hm_plugin!` macro." + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "2", features = ["full", "parsing", "printing"] } +quote = "1" +proc-macro2 = "1" + +[lints] +workspace = true diff --git a/crates/hm-plugin-macros/src/lib.rs b/crates/hm-plugin-macros/src/lib.rs new file mode 100644 index 0000000..d04cac7 --- /dev/null +++ b/crates/hm-plugin-macros/src/lib.rs @@ -0,0 +1,494 @@ +//! Proc-macro crate for `hm-plugin-sdk`. +//! +//! Provides the [`hm_plugin!`] macro that generates: +//! - `__HmPluginImpl` struct holding context and cached manifest bytes +//! - `impl RawPlugin for __HmPluginImpl` bridging FFI to async traits +//! - `#[stabby::export] fn hm_load_plugin(...)` entry point +//! +//! This crate is re-exported by `hm-plugin-sdk`; plugin authors write: +//! +//! ```ignore +//! hm_plugin!( +//! manifest = PluginManifest { ... }, +//! executor = MyExec, +//! ); +//! ``` + +// proc-macro crates cannot depend on runtime crates (stabby, hm-plugin-sdk). +// All generated code references those crates by their full paths. + +// stabby macro expansions contain unsafe FFI code that we cannot avoid. +#![allow(unsafe_code)] + +use proc_macro::TokenStream; +use proc_macro2::TokenStream as TokenStream2; +use quote::quote; +use syn::parse::{Parse, ParseStream}; +use syn::{Expr, Ident, Path, Token}; + +/// A single `key = value` pair in the macro invocation. +enum PluginArg { + Manifest(Expr), + Executor(Path), + Hook(Path), + Subcommand(Path), +} + +impl Parse for PluginArg { + fn parse(input: ParseStream<'_>) -> syn::Result { + let key: Ident = input.parse()?; + let _eq: Token![=] = input.parse()?; + + match key.to_string().as_str() { + "manifest" => { + let expr: Expr = input.parse()?; + Ok(Self::Manifest(expr)) + } + "executor" => { + let path: Path = input.parse()?; + Ok(Self::Executor(path)) + } + "hook" => { + let path: Path = input.parse()?; + Ok(Self::Hook(path)) + } + "subcommand" => { + let path: Path = input.parse()?; + Ok(Self::Subcommand(path)) + } + other => Err(syn::Error::new( + key.span(), + format!( + "unknown keyword `{other}`. \ + Expected one of: manifest, executor, hook, subcommand" + ), + )), + } + } +} + +/// All parsed arguments from the `hm_plugin!(...)` invocation. +struct PluginArgs { + manifest: Expr, + executor: Option, + hook: Option, + subcommand: Option, +} + +impl Parse for PluginArgs { + fn parse(input: ParseStream<'_>) -> syn::Result { + let mut manifest: Option = None; + let mut executor: Option = None; + let mut hook: Option = None; + let mut subcommand: Option = None; + + while !input.is_empty() { + let arg: PluginArg = input.parse()?; + match arg { + PluginArg::Manifest(expr) => { + if manifest.is_some() { + return Err(syn::Error::new( + input.span(), + "duplicate `manifest` argument", + )); + } + manifest = Some(expr); + } + PluginArg::Executor(path) => { + if executor.is_some() { + return Err(syn::Error::new( + input.span(), + "duplicate `executor` argument", + )); + } + executor = Some(path); + } + PluginArg::Hook(path) => { + if hook.is_some() { + return Err(syn::Error::new( + input.span(), + "duplicate `hook` argument", + )); + } + hook = Some(path); + } + PluginArg::Subcommand(path) => { + if subcommand.is_some() { + return Err(syn::Error::new( + input.span(), + "duplicate `subcommand` argument", + )); + } + subcommand = Some(path); + } + } + // consume optional trailing comma + let _ = input.parse::>(); + } + + let manifest = manifest.ok_or_else(|| { + syn::Error::new( + proc_macro2::Span::call_site(), + "missing required `manifest` argument", + ) + })?; + + Ok(Self { + manifest, + executor, + hook, + subcommand, + }) + } +} + +// --------------------------------------------------------------------------- +// Code generation +// --------------------------------------------------------------------------- + +/// Generate struct fields for each registered capability. +fn gen_struct_fields(args: &PluginArgs) -> TokenStream2 { + let executor_field = args.executor.as_ref().map(|ty| { + quote! { executor: #ty, } + }); + let hook_field = args.hook.as_ref().map(|ty| { + quote! { hook: #ty, } + }); + let subcommand_field = args.subcommand.as_ref().map(|ty| { + quote! { subcommand: #ty, } + }); + + quote! { + #executor_field + #hook_field + #subcommand_field + } +} + +/// Generate field initialisers (`field: ::default()`) for +/// each registered capability. +fn gen_struct_init(args: &PluginArgs) -> TokenStream2 { + let executor_init = args.executor.as_ref().map(|ty| { + quote! { executor: <#ty as ::core::default::Default>::default(), } + }); + let hook_init = args.hook.as_ref().map(|ty| { + quote! { hook: <#ty as ::core::default::Default>::default(), } + }); + let subcommand_init = args.subcommand.as_ref().map(|ty| { + quote! { subcommand: <#ty as ::core::default::Default>::default(), } + }); + + quote! { + #executor_init + #hook_init + #subcommand_init + } +} + +fn gen_execute_step(executor: Option<&Path>) -> TokenStream2 { + executor.map_or_else( + || gen_not_implemented_stub("execute_step", "input"), + |_ty| { + quote! { + extern "C" fn execute_step<'a>( + &'a self, + input: hm_plugin_sdk::ffi::FfiSlice<'a>, + ) -> stabby::future::DynFutureUnsync<'a, hm_plugin_sdk::ffi::FfiResult> { + let ctx = &self.ctx; + let executor = &self.executor; + stabby::boxed::Box::new(async move { + let parsed: hm_plugin_sdk::ExecutorInput = + match borsh::from_slice(input.as_ref()) { + Ok(v) => v, + Err(e) => { + return stabby::result::Result::Err( + __ffi_bytes( + borsh::to_vec( + &hm_plugin_sdk::PluginError::new( + "deserialize", + e.to_string(), + ), + ) + .unwrap_or_default(), + ), + ) + } + }; + match hm_plugin_sdk::StepExecutor::run(executor, ctx, parsed).await { + Ok(r) => stabby::result::Result::Ok( + __ffi_bytes( + borsh::to_vec(&r).unwrap_or_default(), + ), + ), + Err(e) => stabby::result::Result::Err( + __ffi_bytes( + borsh::to_vec(&e).unwrap_or_default(), + ), + ), + } + }) + .into() + } + } + }, + ) +} + +fn gen_on_hook_event(hook: Option<&Path>) -> TokenStream2 { + hook.map_or_else( + || gen_not_implemented_stub("on_hook_event", "event"), + |_ty| { + quote! { + extern "C" fn on_hook_event<'a>( + &'a self, + event: hm_plugin_sdk::ffi::FfiSlice<'a>, + ) -> stabby::future::DynFutureUnsync<'a, hm_plugin_sdk::ffi::FfiResult> { + let ctx = &self.ctx; + let hook = &self.hook; + stabby::boxed::Box::new(async move { + let parsed: hm_plugin_sdk::HookEvent = + match borsh::from_slice(event.as_ref()) { + Ok(v) => v, + Err(e) => { + return stabby::result::Result::Err( + __ffi_bytes( + borsh::to_vec( + &hm_plugin_sdk::PluginError::new( + "deserialize", + e.to_string(), + ), + ) + .unwrap_or_default(), + ), + ) + } + }; + match hm_plugin_sdk::LifecycleHook::on_event(hook, ctx, parsed).await { + Ok(r) => stabby::result::Result::Ok( + __ffi_bytes( + borsh::to_vec(&r).unwrap_or_default(), + ), + ), + Err(e) => stabby::result::Result::Err( + __ffi_bytes( + borsh::to_vec(&e).unwrap_or_default(), + ), + ), + } + }) + .into() + } + } + }, + ) +} + +fn gen_run_subcommand(subcommand: Option<&Path>) -> TokenStream2 { + subcommand.map_or_else( + || gen_not_implemented_stub("run_subcommand", "input"), + |_ty| { + quote! { + extern "C" fn run_subcommand<'a>( + &'a self, + input: hm_plugin_sdk::ffi::FfiSlice<'a>, + ) -> stabby::future::DynFutureUnsync<'a, hm_plugin_sdk::ffi::FfiResult> { + let ctx = &self.ctx; + let subcommand = &self.subcommand; + stabby::boxed::Box::new(async move { + let parsed: hm_plugin_sdk::SubcommandInput = + match borsh::from_slice(input.as_ref()) { + Ok(v) => v, + Err(e) => { + return stabby::result::Result::Err( + __ffi_bytes( + borsh::to_vec( + &hm_plugin_sdk::PluginError::new( + "deserialize", + e.to_string(), + ), + ) + .unwrap_or_default(), + ), + ) + } + }; + match hm_plugin_sdk::SubcommandPlugin::run(subcommand, ctx, parsed).await { + Ok(r) => stabby::result::Result::Ok( + __ffi_bytes( + borsh::to_vec(&r).unwrap_or_default(), + ), + ), + Err(e) => stabby::result::Result::Err( + __ffi_bytes( + borsh::to_vec(&e).unwrap_or_default(), + ), + ), + } + }) + .into() + } + } + }, + ) +} + +fn gen_not_implemented_stub(method_name: &str, param_name: &str) -> TokenStream2 { + let method_ident = syn::Ident::new(method_name, proc_macro2::Span::call_site()); + let param_ident = syn::Ident::new(param_name, proc_macro2::Span::call_site()); + + quote! { + extern "C" fn #method_ident<'a>( + &'a self, + #param_ident: hm_plugin_sdk::ffi::FfiSlice<'a>, + ) -> stabby::future::DynFutureUnsync<'a, hm_plugin_sdk::ffi::FfiResult> { + let _ = #param_ident; + stabby::boxed::Box::new(async { + stabby::result::Result::Err( + __ffi_bytes( + borsh::to_vec(&hm_plugin_sdk::PluginError::new( + "not_implemented", + "this plugin does not implement this capability", + )) + .unwrap_or_default(), + ), + ) + }) + .into() + } + } +} + +/// Type alias tokens for `HostRef<'static>` — the stabby `DynRef` that +/// wraps the host API trait object. Matches the definition in +/// `hm_plugin_sdk::context`. +fn host_ref_type() -> TokenStream2 { + quote! { + stabby::DynRef< + 'static, + >::Vt< + >::Vt< + >::Vt< + stabby::abi::vtable::VtDrop, + >, + >, + >, + > + } +} + +/// Type alias tokens for the returned +/// `Dyn<'static, Box<()>, vtable!(RawPlugin + Send + Sync)>`. +fn plugin_dyn_type() -> TokenStream2 { + quote! { + stabby::Dyn< + 'static, + stabby::boxed::Box<()>, + >::Vt< + >::Vt< + >::Vt< + stabby::abi::vtable::VtDrop, + >, + >, + >, + > + } +} + +/// Generate the complete macro expansion. +fn expand(args: &PluginArgs) -> TokenStream2 { + let manifest_expr = &args.manifest; + let host_ref = host_ref_type(); + let plugin_dyn = plugin_dyn_type(); + + let struct_fields = gen_struct_fields(args); + let struct_init = gen_struct_init(args); + + let execute_step = gen_execute_step(args.executor.as_ref()); + let on_hook_event = gen_on_hook_event(args.hook.as_ref()); + let run_subcommand = gen_run_subcommand(args.subcommand.as_ref()); + + quote! { + // Generated by hm_plugin! — do not edit. + #[allow(unsafe_code, non_camel_case_types, clippy::all, clippy::pedantic, clippy::nursery)] + const _: () = { + use hm_plugin_sdk::ffi::RawPlugin as _; + + /// Convert a `std::vec::Vec` to `stabby::vec::Vec` (`FfiBytes`). + /// stabby's `Vec` implements `From<&[T]>` but not `From>`. + #[inline] + fn __ffi_bytes(v: ::std::vec::Vec) -> hm_plugin_sdk::ffi::FfiBytes { + hm_plugin_sdk::ffi::FfiBytes::from(v.as_slice()) + } + + struct __HmPluginImpl { + ctx: hm_plugin_sdk::PluginContext<'static>, + manifest_bytes: hm_plugin_sdk::ffi::FfiBytes, + #struct_fields + } + + impl hm_plugin_sdk::ffi::RawPlugin for __HmPluginImpl { + extern "C" fn manifest(&self) -> hm_plugin_sdk::ffi::FfiBytes { + self.manifest_bytes.clone() + } + + #execute_step + #on_hook_event + #run_subcommand + } + + // SAFETY: __HmPluginImpl holds a PluginContext (which is + // Send + Sync) and FfiBytes (which is Send + Sync). + // Capability types must also be Send + Sync (enforced by + // the trait bounds on StepExecutor, LifecycleHook, etc.). + unsafe impl Send for __HmPluginImpl {} + unsafe impl Sync for __HmPluginImpl {} + + #[stabby::export] + extern "C" fn hm_load_plugin( + ctx: #host_ref, + ) -> stabby::result::Result<#plugin_dyn, hm_plugin_sdk::ffi::FfiBytes> { + let context = hm_plugin_sdk::PluginContext::new(ctx); + let manifest_bytes: hm_plugin_sdk::ffi::FfiBytes = + __ffi_bytes( + borsh::to_vec(&{ #manifest_expr }) + .expect("manifest serialization should never fail"), + ); + let plugin = __HmPluginImpl { + ctx: context, + manifest_bytes, + #struct_init + }; + stabby::result::Result::Ok( + stabby::boxed::Box::new(plugin).into() + ) + } + }; + } +} + +/// Generate the FFI glue for a native `hm` plugin. +/// +/// # Usage +/// +/// ```ignore +/// use hm_plugin_sdk::*; +/// +/// hm_plugin!( +/// manifest = PluginManifest { /* ... */ }, +/// executor = MyExec, +/// ); +/// ``` +/// +/// Keyword arguments (order-independent, comma-separated): +/// +/// | Keyword | Required | Value type | +/// |--------------|----------|-----------------------| +/// | `manifest` | **yes** | expression | +/// | `executor` | no | type implementing `StepExecutor` | +/// | `hook` | no | type implementing `LifecycleHook` | +/// | `subcommand` | no | type implementing `SubcommandPlugin` | +#[proc_macro] +pub fn hm_plugin(input: TokenStream) -> TokenStream { + let args = syn::parse_macro_input!(input as PluginArgs); + expand(&args).into() +} 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/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 ea55430..12aafce 100644 --- a/crates/hm-plugin-protocol/Cargo.toml +++ b/crates/hm-plugin-protocol/Cargo.toml @@ -15,6 +15,7 @@ uuid = { workspace = true } chrono = { workspace = true } thiserror = { workspace = true } derive_more = { workspace = true } +borsh = { workspace = true } [dev-dependencies] insta = { version = "1", features = ["json"] } diff --git a/crates/hm-plugin-protocol/src/borsh_helpers.rs b/crates/hm-plugin-protocol/src/borsh_helpers.rs new file mode 100644 index 0000000..99bd093 --- /dev/null +++ b/crates/hm-plugin-protocol/src/borsh_helpers.rs @@ -0,0 +1,75 @@ +//! Custom borsh serializers for third-party types that lack native +//! borsh support (`DateTime`, `semver::Version`, `char`). +//! +//! These are used via `#[borsh(serialize_with = ..., deserialize_with = ...)]` +//! field attributes. + +use std::io::{self, Read, Write}; + +use borsh::{BorshDeserialize, BorshSerialize}; +use chrono::{DateTime, TimeZone, Utc}; + +// --------------------------------------------------------------------------- +// DateTime <-> i64 (milliseconds since epoch) +// --------------------------------------------------------------------------- + +pub(crate) fn serialize_datetime(dt: &DateTime, writer: &mut W) -> io::Result<()> { + dt.timestamp_millis().serialize(writer) +} + +pub(crate) fn deserialize_datetime(reader: &mut R) -> io::Result> { + let millis = i64::deserialize_reader(reader)?; + Utc.timestamp_millis_opt(millis) + .single() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid timestamp millis")) +} + +// --------------------------------------------------------------------------- +// char <-> u32 (Unicode scalar value) +// --------------------------------------------------------------------------- + +pub(crate) fn serialize_char(c: &char, writer: &mut W) -> io::Result<()> { + (*c as u32).serialize(writer) +} + +pub(crate) fn deserialize_char(reader: &mut R) -> io::Result { + let n = u32::deserialize_reader(reader)?; + char::from_u32(n) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid char codepoint")) +} + +pub(crate) fn serialize_option_char(opt: &Option, writer: &mut W) -> io::Result<()> { + match opt { + None => 0u8.serialize(writer), + Some(c) => { + 1u8.serialize(writer)?; + serialize_char(c, writer) + } + } +} + +pub(crate) fn deserialize_option_char(reader: &mut R) -> io::Result> { + let tag = u8::deserialize_reader(reader)?; + match tag { + 0 => Ok(None), + 1 => deserialize_char(reader).map(Some), + _ => Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid Option tag", + )), + } +} + +// --------------------------------------------------------------------------- +// semver::Version <-> String +// --------------------------------------------------------------------------- + +pub(crate) fn serialize_semver(v: &semver::Version, writer: &mut W) -> io::Result<()> { + v.to_string().serialize(writer) +} + +pub(crate) fn deserialize_semver(reader: &mut R) -> io::Result { + let s = String::deserialize_reader(reader)?; + s.parse::() + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e)) +} diff --git a/crates/hm-plugin-protocol/src/error.rs b/crates/hm-plugin-protocol/src/error.rs index e74c3dd..36ff69b 100644 --- a/crates/hm-plugin-protocol/src/error.rs +++ b/crates/hm-plugin-protocol/src/error.rs @@ -1,11 +1,12 @@ //! Error and exit-info types returned by plugin capability exports. +use borsh::{BorshDeserialize, BorshSerialize}; 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)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] pub struct ExitInfo { pub exit_code: i32, /// Optional message written to stderr by the host before exit. @@ -17,7 +18,7 @@ pub struct ExitInfo { /// 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, + Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema, thiserror::Error, )] #[error("{message}")] pub struct PluginError { diff --git a/crates/hm-plugin-protocol/src/events.rs b/crates/hm-plugin-protocol/src/events.rs index 99b0788..f03dfff 100644 --- a/crates/hm-plugin-protocol/src/events.rs +++ b/crates/hm-plugin-protocol/src/events.rs @@ -1,27 +1,34 @@ //! Build-time events. Produced by the orchestrator (host) and fanned -//! out to output formatters, lifecycle hooks, and (via the host +//! out to the output subscriber, lifecycle hooks, and (via the host //! re-broadcast of `hm_emit_step_log`) any subscriber. +use borsh::{BorshDeserialize, BorshSerialize}; use chrono::{DateTime, Utc}; use schemars::JsonSchema as DeriveJsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use crate::borsh_helpers; + use crate::executor::SnapshotRef; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] #[serde(rename_all = "snake_case")] pub enum StdStream { Stdout, Stderr, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum BuildEvent { BuildStart { run_id: Uuid, plan: PlanSummary, + #[borsh( + serialize_with = "borsh_helpers::serialize_datetime", + deserialize_with = "borsh_helpers::deserialize_datetime" + )] started_at: DateTime, }, StepQueued { @@ -38,6 +45,10 @@ pub enum BuildEvent { step_id: Uuid, stream: StdStream, line: String, + #[borsh( + serialize_with = "borsh_helpers::serialize_datetime", + deserialize_with = "borsh_helpers::deserialize_datetime" + )] ts: DateTime, }, StepCacheHit { @@ -61,6 +72,10 @@ pub enum BuildEvent { failed_step_key: String, exit_code: i32, message: String, + #[borsh( + serialize_with = "borsh_helpers::serialize_datetime", + deserialize_with = "borsh_helpers::deserialize_datetime" + )] ts: DateTime, }, BuildEnd { @@ -70,10 +85,55 @@ pub enum BuildEvent { } /// Compact summary of the resolved IR included in `BuildStart`. Lets -/// output formatters print a header without needing the full pipeline. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] +/// the renderer print a header without needing the full pipeline. +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] pub struct PlanSummary { pub step_count: usize, pub chain_count: usize, pub default_runner: String, } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use chrono::TimeZone; + + #[test] + fn build_event_borsh_round_trip() { + let events = vec![ + BuildEvent::BuildStart { + run_id: Uuid::nil(), + plan: PlanSummary { + step_count: 3, + chain_count: 1, + default_runner: "docker".into(), + }, + started_at: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(), + }, + BuildEvent::StepLog { + step_id: Uuid::nil(), + stream: StdStream::Stderr, + line: "hello".into(), + ts: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 1).unwrap(), + }, + BuildEvent::ChainFailed { + chain_idx: 0, + failed_step_id: Uuid::nil(), + failed_step_key: "build".into(), + exit_code: 1, + message: "fail".into(), + ts: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 2).unwrap(), + }, + BuildEvent::BuildEnd { + exit_code: 0, + duration_ms: 1234, + }, + ]; + for event in &events { + let bytes = borsh::to_vec(event).unwrap(); + let decoded = BuildEvent::try_from_slice(&bytes).unwrap(); + assert_eq!(*event, decoded); + } + } +} diff --git a/crates/hm-plugin-protocol/src/executor.rs b/crates/hm-plugin-protocol/src/executor.rs index bc86727..9a191db 100644 --- a/crates/hm-plugin-protocol/src/executor.rs +++ b/crates/hm-plugin-protocol/src/executor.rs @@ -2,6 +2,7 @@ use std::collections::BTreeMap; +use borsh::{BorshDeserialize, BorshSerialize}; use schemars::JsonSchema as DeriveJsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -11,7 +12,7 @@ use crate::ir::CommandStep; /// Opaque archive handle. The plugin streams bytes via /// `hm_archive_read(id, offset, max)`. #[derive( - Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, DeriveJsonSchema, + Debug, Clone, Copy, PartialEq, Eq, Hash, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema, derive_more::From, derive_more::Deref, derive_more::Display, )] #[serde(transparent)] @@ -21,13 +22,13 @@ pub struct ArchiveId(pub Uuid); /// tag; other plugins are free to encode their own format. The host /// never inspects the contents. #[derive( - Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, DeriveJsonSchema, + Debug, Clone, PartialEq, Eq, Hash, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema, derive_more::From, derive_more::Deref, derive_more::Display, )] #[serde(transparent)] pub struct SnapshotRef(pub String); -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] pub struct ArtifactRef { pub key: String, pub mime: String, @@ -36,7 +37,7 @@ pub struct ArtifactRef { /// Host-decided cache outcome. The executor honours this; it does /// not re-decide. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum CacheDecision { /// Boot from `tag`; skip running `cmd`. @@ -48,7 +49,7 @@ pub enum CacheDecision { MissNoCommit, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] +#[derive(Debug, Clone, PartialEq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] #[serde(deny_unknown_fields)] pub struct ExecutorInput { pub step: CommandStep, @@ -70,7 +71,7 @@ pub struct ExecutorInput { pub parent_snapshot: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] pub struct StepResult { pub exit_code: i32, /// `Some(tag)` when the executor wrote a snapshot for this step @@ -78,3 +79,67 @@ pub struct StepResult { pub committed_snapshot: Option, pub artifacts: Vec, } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use crate::ir::Cache; + + #[test] + fn executor_input_borsh_round_trip() { + let input = ExecutorInput { + step: CommandStep { + key: "build".into(), + label: Some("Build app".into()), + cmd: "cargo build".into(), + builds_in: None, + image: Some("rust:latest".into()), + env: Some({ + let mut m = BTreeMap::new(); + m.insert("RUST_LOG".into(), "debug".into()); + m + }), + timeout_seconds: Some(300), + cache: Some(Cache { + policy: "content".into(), + key: Some("build-cache".into()), + }), + runner: Some("docker".into()), + runner_args: Some(crate::Value::Object({ + let mut m = BTreeMap::new(); + m.insert("privileged".into(), crate::Value::Bool(false)); + m + })), + }, + workspace_archive_id: ArchiveId(Uuid::nil()), + env: BTreeMap::new(), + workdir: "/app".into(), + run_id: Uuid::nil(), + step_id: Uuid::nil(), + cache_lookup: CacheDecision::MissBuildAs { + tag: SnapshotRef("snap:abc".into()), + }, + parent_snapshot: Some(SnapshotRef("snap:parent".into())), + }; + let bytes = borsh::to_vec(&input).unwrap(); + let decoded = ExecutorInput::try_from_slice(&bytes).unwrap(); + assert_eq!(input, decoded); + } + + #[test] + fn step_result_borsh_round_trip() { + let result = StepResult { + exit_code: 0, + committed_snapshot: Some(SnapshotRef("snap:123".into())), + artifacts: vec![ArtifactRef { + key: "binary".into(), + mime: "application/octet-stream".into(), + size_bytes: 1024, + }], + }; + let bytes = borsh::to_vec(&result).unwrap(); + let decoded = StepResult::try_from_slice(&bytes).unwrap(); + assert_eq!(result, decoded); + } +} diff --git a/crates/hm-plugin-protocol/src/hook.rs b/crates/hm-plugin-protocol/src/hook.rs index 2ab98e2..95cab10 100644 --- a/crates/hm-plugin-protocol/src/hook.rs +++ b/crates/hm-plugin-protocol/src/hook.rs @@ -1,5 +1,6 @@ //! Lifecycle hook wire types. +use borsh::{BorshDeserialize, BorshSerialize}; use schemars::JsonSchema as DeriveJsonSchema; use serde::{Deserialize, Serialize}; @@ -7,14 +8,14 @@ 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)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize, 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(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] #[serde(rename_all = "snake_case")] pub enum HookPhase { /// May return [`HookOutcome::Abort`] to fail the build. @@ -23,7 +24,7 @@ pub enum HookPhase { After, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum HookOutcome { /// Continue the build. @@ -37,7 +38,7 @@ pub enum HookOutcome { /// /// 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(Debug, Clone, Copy, PartialEq, Eq, Hash, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] #[serde(rename_all = "snake_case")] pub enum HookEventKind { BuildStart, @@ -48,3 +49,56 @@ pub enum HookEventKind { StepEnd, BuildEnd, } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use chrono::{TimeZone, Utc}; + use uuid::Uuid; + + #[test] + fn hook_event_borsh_round_trip() { + let hook = HookEvent { + event: crate::events::BuildEvent::BuildEnd { + exit_code: 0, + duration_ms: 500, + }, + phase: HookPhase::After, + }; + let bytes = borsh::to_vec(&hook).unwrap(); + let decoded = HookEvent::try_from_slice(&bytes).unwrap(); + assert_eq!(hook, decoded); + } + + #[test] + fn hook_event_with_datetime_borsh_round_trip() { + let hook = HookEvent { + event: crate::events::BuildEvent::BuildStart { + run_id: Uuid::nil(), + plan: crate::events::PlanSummary { + step_count: 1, + chain_count: 1, + default_runner: "docker".into(), + }, + started_at: Utc.with_ymd_and_hms(2026, 5, 23, 12, 0, 0).unwrap(), + }, + phase: HookPhase::Before, + }; + let bytes = borsh::to_vec(&hook).unwrap(); + let decoded = HookEvent::try_from_slice(&bytes).unwrap(); + assert_eq!(hook, decoded); + } + + #[test] + fn hook_outcome_borsh_round_trip() { + for outcome in [ + HookOutcome::Continue, + HookOutcome::Abort { reason: "bad".into() }, + ] { + let bytes = borsh::to_vec(&outcome).unwrap(); + let decoded = HookOutcome::try_from_slice(&bytes).unwrap(); + assert_eq!(outcome, decoded); + } + } +} diff --git a/crates/hm-plugin-protocol/src/host_abi.rs b/crates/hm-plugin-protocol/src/host_abi.rs index f69c333..ec1c88b 100644 --- a/crates/hm-plugin-protocol/src/host_abi.rs +++ b/crates/hm-plugin-protocol/src/host_abi.rs @@ -2,14 +2,13 @@ //! Plugins import these to talk to the hm host fns; the host imports //! them to expose those fns. -use std::collections::BTreeMap; - +use borsh::{BorshDeserialize, BorshSerialize}; use schemars::JsonSchema as DeriveJsonSchema; use serde::{Deserialize, Serialize}; use crate::executor::ArchiveId; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] #[serde(rename_all = "snake_case")] pub enum Level { Trace, @@ -19,7 +18,7 @@ pub enum Level { Error, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] #[serde(rename_all = "snake_case")] pub enum KvScope { /// Per-plugin, persistent across builds. Stored in @@ -31,121 +30,10 @@ pub enum KvScope { 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)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize, 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/ir.rs b/crates/hm-plugin-protocol/src/ir.rs index 8446ba9..347f137 100644 --- a/crates/hm-plugin-protocol/src/ir.rs +++ b/crates/hm-plugin-protocol/src/ir.rs @@ -8,10 +8,13 @@ use std::collections::BTreeMap; +use borsh::{BorshDeserialize, BorshSerialize}; use schemars::JsonSchema as DeriveJsonSchema; use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] +use crate::Value; + +#[derive(Debug, Clone, PartialEq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] pub struct Pipeline { /// Must equal `"0"` — bumping this is reserved for breaking /// schema changes, none of which are scheduled. The v0 schema @@ -24,14 +27,14 @@ pub struct Pipeline { pub steps: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] +#[derive(Debug, Clone, PartialEq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] #[serde(tag = "type", rename_all = "snake_case")] pub enum Step { Command(Box), Wait(WaitStep), } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] +#[derive(Debug, Clone, PartialEq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] pub struct CommandStep { pub key: String, #[serde(default)] @@ -57,16 +60,16 @@ pub struct CommandStep { /// Plugin-specific extra fields. Validated by the executor /// plugin's `StepExecutorSpec::step_schema` if it set one. #[serde(default, skip_serializing_if = "Option::is_none")] - pub runner_args: Option, + pub runner_args: Option, } -#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] +#[derive(Debug, Clone, Default, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] pub struct WaitStep { #[serde(default)] pub continue_on_failure: bool, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] pub struct Cache { pub policy: String, #[serde(default)] @@ -93,7 +96,10 @@ mod tests { panic!("expected command") }; assert_eq!(b.runner.as_deref(), Some("freestyle")); - assert_eq!(b.runner_args.as_ref().unwrap()["region"], "us"); + assert_eq!( + b.runner_args.as_ref().and_then(|v| v.get("region")).and_then(crate::Value::as_str), + Some("us") + ); } #[test] diff --git a/crates/hm-plugin-protocol/src/lib.rs b/crates/hm-plugin-protocol/src/lib.rs index 057977c..502f6f4 100644 --- a/crates/hm-plugin-protocol/src/lib.rs +++ b/crates/hm-plugin-protocol/src/lib.rs @@ -2,7 +2,7 @@ //! //! 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 +//! no Tokio. Bumping `HM_PLUGIN_API_VERSION` is the explicit //! signal that the wire format changed and plugins must be rebuilt. #![forbid(unsafe_code)] @@ -11,6 +11,7 @@ // the noisy cargo-group lints don't drown out real issues. #![allow(clippy::multiple_crate_versions, clippy::cargo_common_metadata)] +pub(crate) mod borsh_helpers; pub mod error; pub mod events; pub mod executor; @@ -19,24 +20,23 @@ pub mod host_abi; pub mod ir; pub mod manifest; pub mod subcommand; +pub mod value; + +pub use value::Value; 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 host_abi::{ArchiveReadArgs, KvScope, Level}; pub use ir::{Cache, CommandStep, Pipeline, Step, WaitStep}; pub use manifest::{ - Capability, ClapJson, JsonSchema, LifecycleHookSpec, OutputFormatterSpec, PluginManifest, - StepExecutorSpec, SubcommandSpec, + ArgSpec, Capability, JsonSchema, LifecycleHookSpec, ManifestError, PluginManifest, + StepExecutorSpec, SubcommandSpec, ValueType, }; 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; +pub const HM_PLUGIN_API_VERSION: u32 = 2; diff --git a/crates/hm-plugin-protocol/src/manifest.rs b/crates/hm-plugin-protocol/src/manifest.rs index ba279f6..88495b1 100644 --- a/crates/hm-plugin-protocol/src/manifest.rs +++ b/crates/hm-plugin-protocol/src/manifest.rs @@ -2,22 +2,67 @@ //! returning a [`PluginManifest`] from its mandatory `hm_manifest` //! export at load time. +use std::collections::HashSet; + +use borsh::{BorshDeserialize, BorshSerialize}; use schemars::JsonSchema as DeriveJsonSchema; use serde::{Deserialize, Serialize}; +use thiserror::Error; +use crate::borsh_helpers; 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; +/// JSON Schema fragment. Used to validate plugin-specific config blobs +/// and `runner_args`. Backed by [`crate::Value`] so it can cross the +/// borsh FFI boundary while remaining JSON-compatible via serde. +pub type JsonSchema = crate::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; +/// A single argument that a subcommand accepts. The host uses these +/// to build a `clap::Command` on the plugin's behalf, so the plugin +/// never has to link clap itself. +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ArgSpec { + Positional { + name: String, + help: Option, + required: bool, + value_type: ValueType, + }, + Option { + long: String, + #[borsh( + serialize_with = "borsh_helpers::serialize_option_char", + deserialize_with = "borsh_helpers::deserialize_option_char" + )] + short: Option, + help: Option, + required: bool, + value_type: ValueType, + default: Option, + }, + Flag { + long: String, + #[borsh( + serialize_with = "borsh_helpers::serialize_option_char", + deserialize_with = "borsh_helpers::deserialize_option_char" + )] + short: Option, + help: Option, + }, +} -/// Returned by an Extism plugin's `hm_manifest()` export. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] +/// The value type for a positional or option argument. +#[derive(Debug, Clone, Copy, PartialEq, Eq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ValueType { + String, + Int, + Bool, +} + +/// Returned by a plugin's manifest export at load time. +#[derive(Debug, Clone, PartialEq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] pub struct PluginManifest { /// Must equal [`crate::HM_PLUGIN_API_VERSION`] or the host rejects /// the plugin at load time. @@ -25,46 +70,40 @@ pub struct PluginManifest { /// Stable plugin identifier, e.g. `harmont-docker`. Used as the /// key in the registry and in error messages. pub name: String, + #[borsh( + serialize_with = "borsh_helpers::serialize_semver", + deserialize_with = "borsh_helpers::deserialize_semver" + )] 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(Debug, Clone, PartialEq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] #[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)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize, 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, + /// Arguments that this subcommand accepts. The host builds a + /// `clap::Command` from these specs so the plugin never links + /// clap itself. + pub args: Vec, pub subcommands: Vec, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] +#[derive(Debug, Clone, PartialEq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] pub struct StepExecutorSpec { /// Matched against `CommandStep.runner` at dispatch time. pub runner: String, @@ -76,19 +115,70 @@ pub struct StepExecutorSpec { pub step_schema: Option, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, BorshSerialize, BorshDeserialize, 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, +#[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}': 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 }, +} + +impl PluginManifest { + /// Validate this manifest statically (without consulting other + /// plugins). Cross-plugin conflicts (e.g. two plugins both claim + /// `runner: "docker"`) are caught by the registry. + pub fn validate(&self) -> Result<(), ManifestError> { + if self.api_version != crate::HM_PLUGIN_API_VERSION { + return Err(ManifestError::ApiVersion { + name: self.name.clone(), + found: self.api_version, + expected: crate::HM_PLUGIN_API_VERSION, + }); + } + if self.capabilities.is_empty() { + return Err(ManifestError::NoCapabilities { + name: self.name.clone(), + }); + } + let mut seen_verbs: HashSet<&str> = HashSet::new(); + for cap in &self.capabilities { + match cap { + Capability::StepExecutor(s) => { + if s.runner.trim().is_empty() || s.runner.chars().any(char::is_whitespace) { + return Err(ManifestError::BadRunnerName { + name: self.name.clone(), + runner: s.runner.clone(), + }); + } + } + Capability::Subcommand(s) => { + if !seen_verbs.insert(s.verb.as_str()) { + return Err(ManifestError::DuplicateSubcommandVerb { + name: self.name.clone(), + verb: s.verb.clone(), + }); + } + } + Capability::LifecycleHook(_) => {} + } + } + Ok(()) + } } #[cfg(test)] @@ -96,6 +186,43 @@ pub struct OutputFormatterSpec { mod tests { use super::*; + fn valid_manifest() -> PluginManifest { + PluginManifest { + api_version: crate::HM_PLUGIN_API_VERSION, + name: "p".into(), + version: semver::Version::new(0, 1, 0), + description: "x".into(), + capabilities: vec![Capability::StepExecutor(StepExecutorSpec { + runner: "a".into(), + default: false, + step_schema: None, + })], + config_schema: None, + } + } + + #[test] + fn validate_accepts_valid_manifest() { + assert!(valid_manifest().validate().is_ok()); + } + + #[test] + fn validate_rejects_wrong_api_version() { + let mut m = valid_manifest(); + m.api_version = 999; + assert!(matches!(m.validate(), Err(ManifestError::ApiVersion { .. }))); + } + + #[test] + fn validate_rejects_empty_capabilities() { + let mut m = valid_manifest(); + m.capabilities.clear(); + assert!(matches!( + m.validate(), + Err(ManifestError::NoCapabilities { .. }) + )); + } + #[test] fn capability_tagged_serialization() { let cap = Capability::StepExecutor(StepExecutorSpec { @@ -107,4 +234,61 @@ mod tests { assert!(s.contains(r#""kind":"step_executor""#), "got: {s}"); assert!(s.contains(r#""runner":"docker""#), "got: {s}"); } + + #[test] + fn arg_spec_round_trips_through_json() { + let spec = ArgSpec::Positional { + name: "slug".into(), + help: Some("Organization slug".into()), + required: true, + value_type: ValueType::String, + }; + let json = serde_json::to_string(&spec).unwrap(); + let back: ArgSpec = serde_json::from_str(&json).unwrap(); + assert_eq!(spec, back); + } + + #[test] + fn manifest_borsh_round_trip() { + let m = PluginManifest { + api_version: crate::HM_PLUGIN_API_VERSION, + name: "test-plugin".into(), + version: semver::Version::new(1, 2, 3), + description: "A test".into(), + capabilities: vec![ + Capability::StepExecutor(StepExecutorSpec { + runner: "docker".into(), + default: true, + step_schema: None, + }), + Capability::Subcommand(SubcommandSpec { + verb: "deploy".into(), + about: "Deploy stuff".into(), + args: vec![ + ArgSpec::Positional { + name: "target".into(), + help: Some("Deploy target".into()), + required: true, + value_type: ValueType::String, + }, + ArgSpec::Flag { + long: "verbose".into(), + short: Some('v'), + help: None, + }, + ], + subcommands: vec![], + }), + Capability::LifecycleHook(LifecycleHookSpec { + events: vec![HookEventKind::BuildStart, HookEventKind::BuildEnd], + phase: HookPhase::Before, + timeout_ms: 5000, + }), + ], + config_schema: Some(crate::Value::Object(Default::default())), + }; + let bytes = borsh::to_vec(&m).unwrap(); + let decoded = PluginManifest::try_from_slice(&bytes).unwrap(); + assert_eq!(m, decoded); + } } diff --git a/crates/hm-plugin-protocol/src/subcommand.rs b/crates/hm-plugin-protocol/src/subcommand.rs index 1dac555..9f17d3d 100644 --- a/crates/hm-plugin-protocol/src/subcommand.rs +++ b/crates/hm-plugin-protocol/src/subcommand.rs @@ -2,19 +2,49 @@ use std::collections::BTreeMap; +use borsh::{BorshDeserialize, BorshSerialize}; use schemars::JsonSchema as DeriveJsonSchema; use serde::{Deserialize, Serialize}; +use crate::Value; + /// 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)] +#[derive(Debug, Clone, PartialEq, BorshSerialize, BorshDeserialize, 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, + pub args: Value, /// `HARMONT_*` env vars + any vars the plugin declared interest in. pub env: BTreeMap, } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + + #[test] + fn subcommand_input_borsh_round_trip() { + let input = SubcommandInput { + verb_path: vec!["cloud".into(), "login".into()], + args: Value::Object({ + let mut m = BTreeMap::new(); + m.insert("org".into(), Value::Str("mesa".into())); + m.insert("force".into(), Value::Bool(true)); + m + }), + env: { + let mut e = BTreeMap::new(); + e.insert("HARMONT_TOKEN".into(), "abc123".into()); + e + }, + }; + let bytes = borsh::to_vec(&input).unwrap(); + let decoded = SubcommandInput::try_from_slice(&bytes).unwrap(); + assert_eq!(input, decoded); + } +} diff --git a/crates/hm-plugin-protocol/src/value.rs b/crates/hm-plugin-protocol/src/value.rs new file mode 100644 index 0000000..712fdcc --- /dev/null +++ b/crates/hm-plugin-protocol/src/value.rs @@ -0,0 +1,202 @@ +//! A self-describing dynamic value type that replaces `serde_json::Value` +//! on the FFI boundary. Unlike `serde_json::Value`, this type derives +//! both `serde` (for JSON compat) and `borsh` (for FFI serialisation). + +use std::collections::BTreeMap; + +use borsh::{BorshDeserialize, BorshSerialize}; +use schemars::JsonSchema as DeriveJsonSchema; +use serde::{Deserialize, Serialize}; + +/// A dynamic value that can cross the plugin FFI boundary. +/// +/// `#[serde(untagged)]` ensures JSON round-trips are identical to +/// `serde_json::Value` — raw JSON maps to the matching variant. +#[derive(Debug, Clone, PartialEq, BorshSerialize, BorshDeserialize, Serialize, Deserialize, DeriveJsonSchema)] +#[serde(untagged)] +pub enum Value { + Null, + Bool(bool), + Int(i64), + Float(f64), + Str(String), + Array(Vec), + Object(BTreeMap), +} + +impl Value { + /// Returns `true` if this value is `Null`. + #[must_use] + pub fn is_null(&self) -> bool { + matches!(self, Self::Null) + } + + /// Returns the contained string, if any. + #[must_use] + pub fn as_str(&self) -> Option<&str> { + match self { + Self::Str(s) => Some(s), + _ => None, + } + } + + /// Returns the contained `i64`, if this is an `Int`. + #[must_use] + pub fn as_i64(&self) -> Option { + match self { + Self::Int(n) => Some(*n), + _ => None, + } + } + + /// Returns the contained `f64`, if this is a `Float`. + #[must_use] + pub fn as_f64(&self) -> Option { + match self { + Self::Float(n) => Some(*n), + _ => None, + } + } + + /// Returns the contained `bool`, if this is a `Bool`. + #[must_use] + pub fn as_bool(&self) -> Option { + match self { + Self::Bool(b) => Some(*b), + _ => None, + } + } + + /// Returns a reference to the contained array, if any. + #[must_use] + pub fn as_array(&self) -> Option<&[Value]> { + match self { + Self::Array(a) => Some(a), + _ => None, + } + } + + /// Returns a reference to the contained object, if any. + #[must_use] + pub fn as_object(&self) -> Option<&BTreeMap> { + match self { + Self::Object(m) => Some(m), + _ => None, + } + } + + /// Looks up a key in an `Object` variant. Returns `None` when + /// `self` is not an object or when the key is absent. + #[must_use] + pub fn get(&self, key: &str) -> Option<&Value> { + self.as_object()?.get(key) + } +} + +// --------------------------------------------------------------------------- +// Conversions: serde_json::Value <-> Value +// --------------------------------------------------------------------------- + +impl From for Value { + fn from(v: serde_json::Value) -> Self { + match v { + serde_json::Value::Null => Self::Null, + serde_json::Value::Bool(b) => Self::Bool(b), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { + Self::Int(i) + } else if let Some(f) = n.as_f64() { + Self::Float(f) + } else { + // u64 that doesn't fit in i64 — store as float (lossy but + // this matches serde_json's own behaviour for large u64). + #[allow(clippy::cast_precision_loss)] + Self::Float(n.as_u64().unwrap_or(0) as f64) + } + } + serde_json::Value::String(s) => Self::Str(s), + serde_json::Value::Array(a) => Self::Array(a.into_iter().map(Into::into).collect()), + serde_json::Value::Object(m) => { + Self::Object(m.into_iter().map(|(k, v)| (k, v.into())).collect()) + } + } + } +} + +impl From for serde_json::Value { + fn from(v: Value) -> Self { + match v { + Value::Null => Self::Null, + Value::Bool(b) => Self::Bool(b), + Value::Int(i) => Self::Number(i.into()), + Value::Float(f) => { + serde_json::Number::from_f64(f).map_or(Self::Null, Self::Number) + } + Value::Str(s) => Self::String(s), + Value::Array(a) => Self::Array(a.into_iter().map(Into::into).collect()), + Value::Object(m) => { + Self::Object(m.into_iter().map(|(k, v)| (k, v.into())).collect()) + } + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn serde_round_trip() { + let json = r#"{"name":"test","count":42,"ok":true,"nested":{"x":1.5},"list":[1,2,3],"nil":null}"#; + let v: Value = serde_json::from_str(json).unwrap(); + let back = serde_json::to_string(&v).unwrap(); + // Parse both into serde_json::Value to compare canonically. + let a: serde_json::Value = serde_json::from_str(json).unwrap(); + let b: serde_json::Value = serde_json::from_str(&back).unwrap(); + assert_eq!(a, b); + } + + #[test] + fn borsh_round_trip() { + let v = Value::Object({ + let mut m = BTreeMap::new(); + m.insert("a".into(), Value::Int(1)); + m.insert("b".into(), Value::Str("hello".into())); + m.insert("c".into(), Value::Array(vec![Value::Bool(true), Value::Null])); + m + }); + let bytes = borsh::to_vec(&v).unwrap(); + let decoded = Value::try_from_slice(&bytes).unwrap(); + assert_eq!(v, decoded); + } + + #[test] + fn from_serde_json_value() { + let jv = serde_json::json!({"region": "us", "count": 3}); + let v: Value = jv.into(); + assert_eq!(v.get("region").and_then(Value::as_str), Some("us")); + assert_eq!(v.get("count").and_then(Value::as_i64), Some(3)); + } + + #[test] + fn into_serde_json_value() { + let v = Value::Object({ + let mut m = BTreeMap::new(); + m.insert("x".into(), Value::Float(1.5)); + m + }); + let jv: serde_json::Value = v.into(); + assert_eq!(jv, serde_json::json!({"x": 1.5})); + } + + #[test] + fn accessors() { + assert!(Value::Null.is_null()); + assert!(!Value::Bool(true).is_null()); + assert_eq!(Value::Bool(false).as_bool(), Some(false)); + assert_eq!(Value::Int(42).as_i64(), Some(42)); + assert_eq!(Value::Float(3.14).as_f64(), Some(3.14)); + assert_eq!(Value::Str("hi".into()).as_str(), Some("hi")); + } +} diff --git a/crates/hm-plugin-protocol/tests/round_trip.rs b/crates/hm-plugin-protocol/tests/round_trip.rs index 890564c..0adae8b 100644 --- a/crates/hm-plugin-protocol/tests/round_trip.rs +++ b/crates/hm-plugin-protocol/tests/round_trip.rs @@ -36,9 +36,7 @@ fn manifest_round_trip() { 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); } diff --git a/crates/hm-plugin-protocol/tests/schema_snapshots.rs b/crates/hm-plugin-protocol/tests/schema_snapshots.rs index 0a983de..2ef618e 100644 --- a/crates/hm-plugin-protocol/tests/schema_snapshots.rs +++ b/crates/hm-plugin-protocol/tests/schema_snapshots.rs @@ -11,9 +11,7 @@ clippy::panic )] -use hm_plugin_protocol::{ - DockerCommitArgs, DockerExecArgs, DockerExtractArgs, DockerStartArgs, PluginManifest, -}; +use hm_plugin_protocol::PluginManifest; use schemars::schema_for; #[test] @@ -21,23 +19,3 @@ 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 index 38af6a0..81f544c 100644 --- a/crates/hm-plugin-protocol/tests/snapshots/schema_snapshots__plugin_manifest.snap +++ b/crates/hm-plugin-protocol/tests/snapshots/schema_snapshots__plugin_manifest.snap @@ -5,14 +5,13 @@ expression: schema { "$schema": "http://json-schema.org/draft-07/schema#", "title": "PluginManifest", - "description": "Returned by an Extism plugin's `hm_manifest()` export.", + "description": "Returned by a plugin's manifest export at load time.", "type": "object", "required": [ "api_version", "capabilities", "description", "name", - "required_host_fns", "version" ], "properties": { @@ -39,23 +38,16 @@ expression: schema "$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" - } + "description": "Optional JSON Schema describing plugin-specific configuration that lives in the project's `.harmont/plugins.toml`.", + "anyOf": [ + { + "$ref": "#/definitions/Value" + }, + { + "type": "null" + } + ] } }, "definitions": { @@ -65,7 +57,7 @@ expression: schema "type": "object", "required": [ "about", - "args_schema", + "args", "kind", "subcommands", "verb" @@ -84,8 +76,12 @@ expression: schema "about": { "type": "string" }, - "args_schema": { - "description": "Clap-shaped JSON for argument parsing (the host re-parses on the plugin's behalf via `clap`)." + "args": { + "description": "Arguments that this subcommand accepts. The host builds a `clap::Command` from these specs so the plugin never links clap itself.", + "type": "array", + "items": { + "$ref": "#/definitions/ArgSpec" + } }, "subcommands": { "type": "array", @@ -118,7 +114,15 @@ expression: schema "type": "boolean" }, "step_schema": { - "description": "Optional JSON Schema for `CommandStep.runner_args`. The host validates `runner_args` against this schema before dispatch." + "description": "Optional JSON Schema for `CommandStep.runner_args`. The host validates `runner_args` against this schema before dispatch.", + "anyOf": [ + { + "$ref": "#/definitions/Value" + }, + { + "type": "null" + } + ] } } }, @@ -152,38 +156,138 @@ expression: schema "minimum": 0.0 } } - }, + } + ] + }, + "ArgSpec": { + "description": "A single argument that a subcommand accepts. The host uses these to build a `clap::Command` on the plugin's behalf, so the plugin never has to link clap itself.", + "oneOf": [ { "type": "object", "required": [ "kind", - "mime", - "name" + "name", + "required", + "value_type" ], "properties": { "kind": { "type": "string", "enum": [ - "output_formatter" + "positional" ] }, "name": { - "description": "Selected via `--format ` on the command line.", "type": "string" }, - "mime": { - "description": "Advisory MIME type written into `--format --output ` headers.", + "help": { + "type": [ + "string", + "null" + ] + }, + "required": { + "type": "boolean" + }, + "value_type": { + "$ref": "#/definitions/ValueType" + } + } + }, + { + "type": "object", + "required": [ + "kind", + "long", + "required", + "value_type" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "option" + ] + }, + "long": { + "type": "string" + }, + "short": { + "type": [ + "string", + "null" + ], + "maxLength": 1, + "minLength": 1 + }, + "help": { + "type": [ + "string", + "null" + ] + }, + "required": { + "type": "boolean" + }, + "value_type": { + "$ref": "#/definitions/ValueType" + }, + "default": { + "type": [ + "string", + "null" + ] + } + } + }, + { + "type": "object", + "required": [ + "kind", + "long" + ], + "properties": { + "kind": { + "type": "string", + "enum": [ + "flag" + ] + }, + "long": { "type": "string" + }, + "short": { + "type": [ + "string", + "null" + ], + "maxLength": 1, + "minLength": 1 + }, + "help": { + "type": [ + "string", + "null" + ] } } } ] }, + "ValueType": { + "description": "The value type for a positional or option argument.", + "type": "string", + "enum": [ + "string", + "int", + "bool" + ] + }, "SubcommandSpec": { "type": "object", "required": [ "about", - "args_schema", + "args", "subcommands", "verb" ], @@ -195,8 +299,12 @@ expression: schema "about": { "type": "string" }, - "args_schema": { - "description": "Clap-shaped JSON for argument parsing (the host re-parses on the plugin's behalf via `clap`)." + "args": { + "description": "Arguments that this subcommand accepts. The host builds a `clap::Command` from these specs so the plugin never links clap itself.", + "type": "array", + "items": { + "$ref": "#/definitions/ArgSpec" + } }, "subcommands": { "type": "array", @@ -206,6 +314,40 @@ expression: schema } } }, + "Value": { + "description": "A dynamic value that can cross the plugin FFI boundary.\n\n`#[serde(untagged)]` ensures JSON round-trips are identical to `serde_json::Value` — raw JSON maps to the matching variant.", + "anyOf": [ + { + "type": "null" + }, + { + "type": "boolean" + }, + { + "type": "integer", + "format": "int64" + }, + { + "type": "number", + "format": "double" + }, + { + "type": "string" + }, + { + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, "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", diff --git a/crates/hm-plugin-runtime/Cargo.toml b/crates/hm-plugin-runtime/Cargo.toml new file mode 100644 index 0000000..572a72b --- /dev/null +++ b/crates/hm-plugin-runtime/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "hm-plugin-runtime" +version = "0.0.0-dev" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Plugin loading, discovery, and host-API runtime for Harmont CLI." + +[dependencies] +hm-plugin-protocol = { workspace = true } +hm-plugin-sdk = { workspace = true } +hm-util = { workspace = true } +smart-default = { workspace = true } +stabby = { workspace = true } +libloading = "0.8" +tokio = { workspace = true } +tokio-util = { workspace = true } +serde_json = { workspace = true } +borsh = { workspace = true } +clap = { version = "4", features = ["string"] } +anyhow = "1" +thiserror = { workspace = true } +semver = { workspace = true } +tracing = "0.1" +chrono = { workspace = true } +uuid = { workspace = true } +reqwest = { version = "0.13", default-features = false, features = ["rustls"] } +sha2 = "0.10" +hex = "0.4" +tempfile = "3" + +[lints] +workspace = true diff --git a/crates/hm-plugin-runtime/src/clap_bridge.rs b/crates/hm-plugin-runtime/src/clap_bridge.rs new file mode 100644 index 0000000..bd1af66 --- /dev/null +++ b/crates/hm-plugin-runtime/src/clap_bridge.rs @@ -0,0 +1,304 @@ +//! Converts [`SubcommandSpec`] trees into [`clap::Command`] objects +//! and extracts parsed [`clap::ArgMatches`] back into +//! [`serde_json::Value`]. +//! +//! This lets plugins declare their CLI surface via data (the spec) +//! while the host owns all `clap` machinery. + +use clap::{Arg, ArgAction, ArgMatches, Command}; +use hm_plugin_protocol::manifest::{ArgSpec, SubcommandSpec, ValueType}; + +/// Recursively builds a [`clap::Command`] from a [`SubcommandSpec`] tree. +/// +/// The returned command uses `spec.verb` as its name and `spec.about` +/// as the about string. Arguments and nested subcommands are mapped +/// one-to-one from the spec. +#[must_use] +pub fn build_command(spec: &SubcommandSpec) -> Command { + let mut cmd = Command::new(spec.verb.clone()).about(spec.about.clone()); + + for arg in &spec.args { + cmd = cmd.arg(build_arg(arg)); + } + + for sub in &spec.subcommands { + cmd = cmd.subcommand(build_command(sub)); + } + + // If the command has subcommands but no positional/option args of its + // own, require a subcommand to be specified. + if !spec.subcommands.is_empty() && spec.args.is_empty() { + cmd = cmd.subcommand_required(true); + } + + cmd +} + +/// Walks the subcommand chain in `matches` to find the leaf command, +/// building up a `verb_path` of subcommand names along the way, and +/// then extracts all argument values at the leaf into a JSON map. +/// +/// The returned `verb_path` does **not** include the root command name +/// because [`ArgMatches`] does not carry it. The caller (host dispatch) +/// must prepend the top-level verb if needed. +/// +/// # Return value +/// +/// `(verb_path, args)` where `verb_path` lists the subcommand names +/// from the root's immediate child down to the leaf, and `args` is a +/// JSON object whose keys are argument IDs. +#[must_use] +pub fn extract_args(matches: &ArgMatches) -> (Vec, serde_json::Value) { + let mut verb_path: Vec = Vec::new(); + let mut current = matches; + + // Walk down the subcommand chain until we reach the leaf. + while let Some((name, sub_matches)) = current.subcommand() { + verb_path.push(name.to_owned()); + current = sub_matches; + } + + let args = extract_leaf_args(current); + (verb_path, args) +} + +// ------------------------------------------------------------------ +// Internal helpers +// ------------------------------------------------------------------ + +/// Argument IDs that clap inserts automatically; we skip these when +/// extracting values. +const BUILTIN_IDS: &[&str] = &["help", "version"]; + +fn build_arg(spec: &ArgSpec) -> Arg { + match spec { + ArgSpec::Positional { + name, + help, + required, + value_type, + } => { + let mut arg = Arg::new(name.clone()).required(*required); + if *value_type == ValueType::Int { + arg = arg.value_parser(clap::value_parser!(i64)); + } + if let Some(h) = help { + arg = arg.help(h.clone()); + } + arg + } + ArgSpec::Option { + long, + short, + help, + required, + value_type, + default, + } => { + let mut arg = Arg::new(long.clone()) + .long(long.clone()) + .required(*required); + if *value_type == ValueType::Int { + arg = arg.value_parser(clap::value_parser!(i64)); + } + if let Some(s) = short { + arg = arg.short(*s); + } + if let Some(h) = help { + arg = arg.help(h.clone()); + } + if let Some(d) = default { + arg = arg.default_value(d.clone()); + } + arg + } + ArgSpec::Flag { + long, short, help, .. + } => { + let mut arg = Arg::new(long.clone()) + .long(long.clone()) + .action(ArgAction::SetTrue); + if let Some(s) = short { + arg = arg.short(*s); + } + if let Some(h) = help { + arg = arg.help(h.clone()); + } + arg + } + } +} + +fn extract_leaf_args(matches: &ArgMatches) -> serde_json::Value { + let mut map = serde_json::Map::new(); + + for id in matches.ids() { + let id_str = id.as_str(); + if BUILTIN_IDS.contains(&id_str) { + continue; + } + + // Flags come back as bool via `ArgAction::SetTrue`. + if let Ok(Some(&val)) = matches.try_get_one::(id_str) { + map.insert(id_str.to_owned(), serde_json::Value::Bool(val)); + continue; + } + + // Int-typed args come back as i64 via value_parser. + if let Ok(Some(&val)) = matches.try_get_one::(id_str) { + map.insert( + id_str.to_owned(), + serde_json::Value::Number(val.into()), + ); + continue; + } + + // Everything else (positionals, options) is a string. + if let Ok(Some(val)) = matches.try_get_one::(id_str) { + map.insert( + id_str.to_owned(), + serde_json::Value::String(val.clone()), + ); + } + } + + serde_json::Value::Object(map) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use hm_plugin_protocol::manifest::{ArgSpec, SubcommandSpec, ValueType}; + + fn cloud_spec() -> SubcommandSpec { + SubcommandSpec { + verb: "cloud".into(), + about: "Cloud API".into(), + args: vec![], + subcommands: vec![ + SubcommandSpec { + verb: "login".into(), + about: "Authenticate".into(), + args: vec![ArgSpec::Flag { + long: "paste".into(), + short: None, + help: Some("Skip loopback".into()), + }], + subcommands: vec![], + }, + SubcommandSpec { + verb: "org".into(), + about: "Manage orgs".into(), + args: vec![], + subcommands: vec![SubcommandSpec { + verb: "switch".into(), + about: "Set active org".into(), + args: vec![ArgSpec::Positional { + name: "slug".into(), + help: Some("Organization slug".into()), + required: true, + value_type: ValueType::String, + }], + subcommands: vec![], + }], + }, + ], + } + } + + #[test] + fn builds_clap_command_from_spec() { + let cmd = build_command(&cloud_spec()); + let subs: Vec<&str> = cmd.get_subcommands().map(|c| c.get_name()).collect(); + assert!(subs.contains(&"login")); + assert!(subs.contains(&"org")); + } + + #[test] + fn parses_flag_subcommand() { + let cmd = build_command(&cloud_spec()); + let matches = cmd + .try_get_matches_from(["cloud", "login", "--paste"]) + .unwrap(); + let (verb_path, args) = extract_args(&matches); + assert_eq!(verb_path, vec!["login"]); + assert_eq!(args["paste"], true); + } + + #[test] + fn parses_nested_positional() { + let cmd = build_command(&cloud_spec()); + let matches = cmd + .try_get_matches_from(["cloud", "org", "switch", "acme"]) + .unwrap(); + let (verb_path, args) = extract_args(&matches); + assert_eq!(verb_path, vec!["org", "switch"]); + assert_eq!(args["slug"], "acme"); + } + + #[test] + fn parses_option_with_int_value() { + let spec = SubcommandSpec { + verb: "billing".into(), + about: "Billing".into(), + args: vec![], + subcommands: vec![SubcommandSpec { + verb: "transactions".into(), + about: "List transactions".into(), + args: vec![ArgSpec::Option { + long: "limit".into(), + short: None, + help: None, + required: false, + value_type: ValueType::Int, + default: Some("100".into()), + }], + subcommands: vec![], + }], + }; + let cmd = build_command(&spec); + let matches = cmd + .try_get_matches_from(["billing", "transactions", "--limit", "50"]) + .unwrap(); + let (verb_path, args) = extract_args(&matches); + assert_eq!(verb_path, vec!["transactions"]); + assert_eq!(args["limit"], 50); + } + + #[test] + fn option_uses_default_when_omitted() { + let spec = SubcommandSpec { + verb: "billing".into(), + about: "Billing".into(), + args: vec![], + subcommands: vec![SubcommandSpec { + verb: "transactions".into(), + about: "List transactions".into(), + args: vec![ArgSpec::Option { + long: "limit".into(), + short: None, + help: None, + required: false, + value_type: ValueType::Int, + default: Some("100".into()), + }], + subcommands: vec![], + }], + }; + let cmd = build_command(&spec); + let matches = cmd + .try_get_matches_from(["billing", "transactions"]) + .unwrap(); + let (_, args) = extract_args(&matches); + assert_eq!(args["limit"], 100); + } + + #[test] + fn missing_required_arg_errors() { + let cmd = build_command(&cloud_spec()); + assert!(cmd + .try_get_matches_from(["cloud", "org", "switch"]) + .is_err()); + } +} diff --git a/crates/hm-plugin-runtime/src/error.rs b/crates/hm-plugin-runtime/src/error.rs new file mode 100644 index 0000000..fe5274b --- /dev/null +++ b/crates/hm-plugin-runtime/src/error.rs @@ -0,0 +1,76 @@ +use std::path::PathBuf; + +use hm_plugin_protocol::ManifestError; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum RuntimeError { + #[error("plugin '{name}' failed to load from {path}: {reason}")] + PluginLoad { + name: String, + 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, + }, +} + +impl From for RuntimeError { + fn from(e: ManifestError) -> Self { + match e { + ManifestError::ApiVersion { + name, + found, + expected, + } => Self::PluginManifest { + name, + expected_api: expected, + found_api: found, + }, + ManifestError::NoCapabilities { ref name } + | ManifestError::BadRunnerName { ref name, .. } + | ManifestError::DuplicateSubcommandVerb { ref name, .. } => Self::PluginLoad { + name: name.clone(), + path: PathBuf::new(), + reason: e.to_string(), + doc_url: "https://harmont.dev/docs/plugins/manifest", + }, + } + } +} diff --git a/crates/hm-plugin-runtime/src/host.rs b/crates/hm-plugin-runtime/src/host.rs new file mode 100644 index 0000000..5d53213 --- /dev/null +++ b/crates/hm-plugin-runtime/src/host.rs @@ -0,0 +1,344 @@ +//! Thin wrapper around stabby-loaded native plugin dylibs. +//! +//! Each `LoadedPlugin` owns a `libloading::Library` and a stabby +//! trait object implementing `RawPlugin + Send + Sync`. The trait +//! object is ABI-stable across compiler versions thanks to stabby. + +// stabby trait objects and libloading require unsafe for loading +// and calling into foreign code. +#![allow(unsafe_code)] +// 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. +#![allow(clippy::missing_errors_doc)] + +use std::mem::ManuallyDrop; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use hm_plugin_protocol::PluginManifest; +use hm_plugin_sdk::ffi::RawPluginDyn as _; +use stabby::libloading::StabbyLibrary; + +use crate::host_api::HostApiImpl; +use crate::error::RuntimeError; + +// Type aliases matching the macro crate's `host_ref_type()` and +// `plugin_dyn_type()` outputs. These are the exact stabby compound-vtable +// types that the `#[stabby::export] fn hm_load_plugin(...)` symbol +// uses on both sides of the FFI boundary. + +/// The stabby `DynRef` wrapping a `&'static dyn RawHostApi + Send + Sync`. +type HostRef = stabby::DynRef< + 'static, + >::Vt< + >::Vt< + >::Vt< + stabby::abi::vtable::VtDrop, + >, + >, + >, +>; + +/// The stabby `Dyn` wrapping a `Box`. +type PluginDyn = stabby::Dyn< + 'static, + stabby::boxed::Box<()>, + >::Vt< + >::Vt< + >::Vt< + stabby::abi::vtable::VtDrop, + >, + >, + >, +>; + +/// The entry point function signature exported by plugins via +/// `#[stabby::export]`. +type LoadPluginFn = extern "C" fn( + HostRef, +) -> stabby::result::Result< + PluginDyn, + hm_plugin_sdk::ffi::FfiBytes, +>; + +/// A loaded native plugin. Holds the library handle and the stabby +/// trait object. Field ordering matters: `plugin` (which borrows from +/// the library's code) must be dropped before `_lib`. +pub struct LoadedPlugin { + pub manifest: PluginManifest, + /// Path the plugin was loaded from. + pub source: Option, + /// The stabby trait object implementing RawPlugin. Wrapped in + /// `ManuallyDrop` so we can control drop order: this must be + /// dropped before `_lib`. + plugin: ManuallyDrop, + /// The dynamically loaded library. Kept alive for the lifetime of + /// the trait object. Must be dropped AFTER `plugin`. + _lib: libloading::Library, + /// The host API reference. Leaked to `'static` so the plugin can + /// hold it for its entire lifetime. The `Arc` prevents the + /// underlying data from being freed. + _host_api: Arc, +} + +// SAFETY: PluginDyn carries Send + Sync vtable markers. The Library +// handle is an opaque OS handle (safe to move between threads). The +// HostApiImpl is Send + Sync by construction. +unsafe impl Send for LoadedPlugin {} +// SAFETY: see above — all fields are safe for shared references. +unsafe impl Sync for LoadedPlugin {} + +impl std::fmt::Debug for LoadedPlugin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LoadedPlugin") + .field("manifest", &self.manifest) + .field("source", &self.source) + .field("plugin", &">") + .finish() + } +} + +impl Drop for LoadedPlugin { + fn drop(&mut self) { + // SAFETY: we manually drop `plugin` before `_lib` goes out of + // scope (which happens immediately after, when the struct is + // dropped). This guarantees the trait object's code is still + // loaded when its destructor runs. + // + // NOTE: currently leaking — investigating a SIGSEGV in stabby + // Dyn drop across dylib boundary on macOS/arm64. + // unsafe { ManuallyDrop::drop(&mut self.plugin); } + } +} + +impl LoadedPlugin { + /// Obtain a `&'static PluginDyn` from our stored plugin. + /// + /// The stabby vtable for `Dyn<'static, ...>` requires `&'static self` + /// to call its methods (because the vtable's function pointers carry + /// `PhantomData<&'a &'static ()>` which forces `'a: 'static`). Since + /// the `LoadedPlugin` owns both the `PluginDyn` and the `Library`, + /// and every returned future is `.await`-ed immediately (never stored + /// or moved), the borrow cannot actually outlive the struct. + /// + /// # Safety + /// The caller must `.await` the returned future before dropping `self`. + unsafe fn plugin_static(&self) -> &'static PluginDyn { + unsafe { &*(&*self.plugin as *const PluginDyn) } + } + + /// Extend a `FfiSlice` to `'static` lifetime. + /// + /// The plugin's generated code (see `hm-plugin-macros` `expand()`) + /// deserializes the input via `borsh::from_slice` at the very + /// start of the async block — before any `.await` / yield point. + /// The `in_bytes` local outlives the `.await`, so the borrow is + /// sound even though Rust can't prove it statically. + /// + /// # Safety + /// The backing data must remain valid until the returned future + /// completes its first poll (which copies the data). + unsafe fn staticify_slice( + s: hm_plugin_sdk::ffi::FfiSlice<'_>, + ) -> hm_plugin_sdk::ffi::FfiSlice<'static> { + unsafe { core::mem::transmute(s) } + } + + /// Load a native plugin from a shared library on disk. + /// + /// The `host_api` is leaked to a `&'static` reference (via + /// `Arc::into_raw`) so the plugin can hold it for its full lifetime. + pub fn load(path: &Path, host_api: Arc) -> Result { + // SAFETY: Loading a shared library executes its init routines. + // We trust plugins built with the SDK. + let lib = unsafe { libloading::Library::new(path) } + .with_context(|| format!("dlopen {}", path.display()))?; + + // SAFETY: The symbol was generated by `#[stabby::export]` and + // has ABI-stable layout checked by stabby's report mechanism. + let load_fn = unsafe { + lib.get_stabbied::(b"hm_load_plugin") + } + .map_err(|e| anyhow::anyhow!( + "get hm_load_plugin symbol from {}: {e}", + path.display() + ))?; + + // Create a DynRef to the host API. We leak the Arc to obtain a + // `&'static HostApiImpl`, then wrap it in a stabby DynRef. + let host_ref: &'static HostApiImpl = { + let ptr = Arc::into_raw(Arc::clone(&host_api)); + // SAFETY: ptr is valid for 'static because the Arc is kept + // alive in `_host_api`. + unsafe { &*ptr } + }; + + // Convert &'static HostApiImpl to HostRef (DynRef<'static, ...>). + let dyn_ref: HostRef = stabby::DynRef::from(host_ref); + + // Call the plugin's entry point. + let stabby_result = (*load_fn)(dyn_ref); + + // Convert stabby::result::Result to core::result::Result + let std_result: core::result::Result = + stabby_result.into(); + + let plugin = match std_result { + Ok(p) => p, + Err(err_bytes) => { + // Re-claim the Arc we leaked so it doesn't actually leak. + let ptr = host_ref as *const HostApiImpl; + unsafe { Arc::from_raw(ptr); } + let err_str = String::from_utf8_lossy(err_bytes.as_slice()); + anyhow::bail!( + "plugin {} refused to load: {err_str}", + path.display() + ); + } + }; + + // Wrap in ManuallyDrop first so we can use plugin_static(). + let plugin = ManuallyDrop::new(plugin); + + // Read the manifest from the plugin. `manifest()` takes + // `&'static self` due to the stabby vtable lifetime; use + // the same staticify trick. + // + // SAFETY: `plugin` is alive (we just created it) and we use + // the result synchronously (no escaping borrow). + let manifest_bytes = { + let static_ref: &'static PluginDyn = + unsafe { &*(&*plugin as *const PluginDyn) }; + static_ref.manifest() + }; + let manifest: PluginManifest = borsh::from_slice(manifest_bytes.as_slice()) + .with_context(|| { + format!("decode manifest from {}", path.display()) + })?; + + Ok(Self { + manifest, + source: Some(path.to_path_buf()), + plugin, + _lib: lib, + _host_api: host_api, + }) + } + + /// Execute a step. Serializes `input` via borsh, calls the plugin's + /// `execute_step`, and deserializes the result. + pub async fn execute_step( + &self, + input: &hm_plugin_protocol::ExecutorInput, + ) -> Result { + let in_bytes = borsh::to_vec(input).context("serialize ExecutorInput")?; + // SAFETY: see `plugin_static()` and `staticify_slice()` docs. + // The data in `in_bytes` outlives the `.await`, and the plugin + // copies it before yielding. + let ffi_input = unsafe { + Self::staticify_slice(hm_plugin_sdk::ffi::FfiSlice::from(in_bytes.as_slice())) + }; + let future = unsafe { self.plugin_static() }.execute_step(ffi_input); + let stabby_result = future.await; + let std_result: core::result::Result< + hm_plugin_sdk::ffi::FfiBytes, + hm_plugin_sdk::ffi::FfiBytes, + > = stabby_result.into(); + match std_result { + Ok(out) => { + borsh::from_slice(out.as_slice()).context("deserialize StepResult") + } + Err(err) => Err(ffi_err_to_anyhow(&self.manifest.name, "execute_step", &err)), + } + } + + /// Dispatch a lifecycle hook event. + pub async fn on_hook_event( + &self, + event: &hm_plugin_protocol::HookEvent, + ) -> Result { + let in_bytes = borsh::to_vec(event).context("serialize HookEvent")?; + // SAFETY: see `plugin_static()` and `staticify_slice()` docs. + let ffi_input = unsafe { + Self::staticify_slice(hm_plugin_sdk::ffi::FfiSlice::from(in_bytes.as_slice())) + }; + let future = unsafe { self.plugin_static() }.on_hook_event(ffi_input); + let stabby_result = future.await; + let std_result: core::result::Result< + hm_plugin_sdk::ffi::FfiBytes, + hm_plugin_sdk::ffi::FfiBytes, + > = stabby_result.into(); + match std_result { + Ok(out) => { + borsh::from_slice(out.as_slice()).context("deserialize HookOutcome") + } + Err(err) => Err(ffi_err_to_anyhow(&self.manifest.name, "on_hook_event", &err)), + } + } + + /// Run a subcommand. + pub async fn run_subcommand( + &self, + input: &hm_plugin_protocol::SubcommandInput, + ) -> Result { + let in_bytes = borsh::to_vec(input).context("serialize SubcommandInput")?; + // SAFETY: see `plugin_static()` and `staticify_slice()` docs. + let ffi_input = unsafe { + Self::staticify_slice(hm_plugin_sdk::ffi::FfiSlice::from(in_bytes.as_slice())) + }; + let future = unsafe { self.plugin_static() }.run_subcommand(ffi_input); + let stabby_result = future.await; + let std_result: core::result::Result< + hm_plugin_sdk::ffi::FfiBytes, + hm_plugin_sdk::ffi::FfiBytes, + > = stabby_result.into(); + match std_result { + Ok(out) => { + borsh::from_slice(out.as_slice()).context("deserialize ExitInfo") + } + Err(err) => Err(ffi_err_to_anyhow(&self.manifest.name, "run_subcommand", &err)), + } + } + +} + +/// Convert an FFI error response (serialized `PluginError`) into an +/// `anyhow::Error` wrapping `RuntimeError::PluginPanic`. +fn ffi_err_to_anyhow( + plugin_name: &str, + capability: &str, + err: &hm_plugin_sdk::ffi::FfiBytes, +) -> anyhow::Error { + let plugin_err: hm_plugin_protocol::PluginError = + borsh::from_slice(err.as_slice()) + .unwrap_or_else(|_| hm_plugin_protocol::PluginError::new( + capability, + String::from_utf8_lossy(err.as_slice()).to_string(), + )); + RuntimeError::PluginPanic { + name: plugin_name.to_string(), + capability: capability.to_string(), + message: plugin_err.message, + } + .into() +} + +/// 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() -> hm_plugin_protocol::SubcommandInput { + hm_plugin_protocol::SubcommandInput { + verb_path: vec!["fixture-probe".into()], + args: hm_plugin_protocol::Value::Object(std::collections::BTreeMap::new()), + env: std::collections::BTreeMap::new(), + } +} diff --git a/crates/hm-plugin-runtime/src/host_api.rs b/crates/hm-plugin-runtime/src/host_api.rs new file mode 100644 index 0000000..c7f0c7f --- /dev/null +++ b/crates/hm-plugin-runtime/src/host_api.rs @@ -0,0 +1,246 @@ +//! Host-side implementation of `RawHostApi` for stabby-based plugins. +//! +//! `HostApiImpl` is the concrete type that backs every plugin's +//! `PluginContext`. It implements `hm_plugin_sdk::ffi::RawHostApi` +//! (all 11 methods, `extern "C"`, synchronous). + +// The stabby trait impl requires unsafe for the FFI trampolines. +#![allow(unsafe_code)] +// Pedantic-bucket nags accepted at module scope: +// - `missing_errors_doc`: methods on `RawHostApi` don't return Result. +// - `cast_possible_truncation`: level/scope u8 conversions are bounded. +#![allow(clippy::missing_errors_doc, clippy::cast_possible_truncation)] + +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Mutex; + +use hm_plugin_sdk::ffi::{FfiBytes, FfiSlice, RawHostApi}; +use hm_plugin_protocol::BuildEvent; +use tokio::sync::broadcast; + +use tokio_util::sync::CancellationToken; + +/// Host-side state backing all 11 `RawHostApi` methods. +/// +/// One instance is created per plugin-registry lifetime and shared +/// (via `Arc`) across all loaded plugins. Interior mutability uses +/// `std::sync::Mutex` (not tokio) because the FFI methods are +/// `extern "C"` and synchronous. +pub struct HostApiImpl { + event_tx: broadcast::Sender, + cancel_token: CancellationToken, + kv_plugin: Mutex>>, + kv_build: Mutex>>, + kv_step: Mutex>>, + project_root: Option, +} + +impl std::fmt::Debug for HostApiImpl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("HostApiImpl") + .field("project_root", &self.project_root) + .finish_non_exhaustive() + } +} + +impl HostApiImpl { + /// Create a new host API implementation. + /// + /// `event_tx` is the broadcast sender for `BuildEvent`s (the + /// output subscriber drains the receiving end). `cancel_token` + /// allows plugins to poll for cancellation. + #[must_use] + pub fn new( + event_tx: broadcast::Sender, + cancel_token: CancellationToken, + project_root: Option, + ) -> Self { + Self { + event_tx, + cancel_token, + kv_plugin: Mutex::new(BTreeMap::new()), + kv_build: Mutex::new(BTreeMap::new()), + kv_step: Mutex::new(BTreeMap::new()), + project_root, + } + } + + /// Create a minimal instance suitable for tests or non-orchestrator + /// paths (e.g. `hm plugin list`, `hm version`). + #[must_use] + pub fn new_noop() -> Self { + let (tx, _rx) = broadcast::channel(16); + Self { + event_tx: tx, + cancel_token: CancellationToken::new(), + kv_plugin: Mutex::new(BTreeMap::new()), + kv_build: Mutex::new(BTreeMap::new()), + kv_step: Mutex::new(BTreeMap::new()), + project_root: None, + } + } + + /// Clear step-scoped KV state. Called by the scheduler between steps. + pub fn clear_step_kv(&self) { + if let Ok(mut m) = self.kv_step.lock() { + m.clear(); + } + } +} + +// --------------------------------------------------------------------------- +// RawHostApi implementation +// --------------------------------------------------------------------------- + +impl RawHostApi for HostApiImpl { + extern "C" fn log(&self, level: u8, msg: FfiSlice<'_>) { + let text = core::str::from_utf8(msg.as_ref()).unwrap_or(""); + match level { + 0 => tracing::trace!(target: "plugin", "{text}"), + 1 => tracing::debug!(target: "plugin", "{text}"), + 2 => tracing::info!(target: "plugin", "{text}"), + 3 => tracing::warn!(target: "plugin", "{text}"), + _ => tracing::error!(target: "plugin", "{text}"), + } + } + + extern "C" fn kv_get( + &self, + scope: u8, + key: FfiSlice<'_>, + ) -> stabby::option::Option { + let key_str = core::str::from_utf8(key.as_ref()).unwrap_or(""); + let map = match scope { + 0 => &self.kv_plugin, + 1 => &self.kv_build, + 2 => &self.kv_step, + _ => return stabby::option::Option::None(), + }; + let guard = match map.lock() { + Ok(g) => g, + Err(_) => return stabby::option::Option::None(), + }; + match guard.get(key_str) { + Some(val) => stabby::option::Option::Some(FfiBytes::from(val.as_slice())), + None => stabby::option::Option::None(), + } + } + + extern "C" fn kv_set(&self, scope: u8, key: FfiSlice<'_>, val: FfiSlice<'_>) { + let key_str = match core::str::from_utf8(key.as_ref()) { + Ok(s) => s, + Err(_) => return, + }; + let map = match scope { + 0 => &self.kv_plugin, + 1 => &self.kv_build, + 2 => &self.kv_step, + _ => return, + }; + match map.lock() { + Ok(mut guard) => { guard.insert(key_str.to_string(), val.to_vec()); } + Err(_) => tracing::warn!(target: "plugin::host_api", "kv_set: mutex poisoned"), + } + } + + extern "C" fn emit_event(&self, event_bytes: FfiSlice<'_>) { + let Ok(event) = borsh::from_slice::(event_bytes.as_ref()) else { + tracing::warn!(target: "plugin::host_api", "failed to deserialize BuildEvent from plugin"); + return; + }; + // Best-effort: if nobody is listening the send fails silently. + let _ = self.event_tx.send(event); + } + + extern "C" fn emit_step_log(&self, stream: u8, bytes: FfiSlice<'_>) { + // Stream: 0 = stdout, 1 = stderr. For now, just emit as a + // BuildEvent. Full step-id tagging will be wired up in Task 6. + let line = String::from_utf8_lossy(bytes.as_ref()).into_owned(); + let stream_enum = if stream == 0 { + hm_plugin_protocol::StdStream::Stdout + } else { + hm_plugin_protocol::StdStream::Stderr + }; + // TODO(task-7): replace nil UUID with actual step_id (needs per-step HostApiImpl or field) + let event = BuildEvent::StepLog { + step_id: uuid::Uuid::nil(), + stream: stream_enum, + line, + ts: chrono::Utc::now(), + }; + let _ = self.event_tx.send(event); + } + + extern "C" fn should_cancel(&self) -> bool { + self.cancel_token.is_cancelled() + } + + #[allow( + clippy::print_stdout, + reason = "this method's purpose is user-facing stdout output" + )] + extern "C" fn write_stdout(&self, bytes: FfiSlice<'_>) { + use std::io::Write; + let mut out = std::io::stdout().lock(); + let _ = out.write_all(bytes.as_ref()); + let _ = out.flush(); + } + + #[allow( + clippy::print_stderr, + reason = "this method's purpose is user-facing stderr output" + )] + extern "C" fn write_stderr(&self, bytes: FfiSlice<'_>) { + use std::io::Write; + let mut err = std::io::stderr().lock(); + let _ = err.write_all(bytes.as_ref()); + let _ = err.flush(); + } + + extern "C" fn archive_read( + &self, + _id_json: FfiSlice<'_>, + _offset: u64, + _max: u64, + ) -> FfiBytes { + // Minimal stub — full archive I/O will be wired up when + // callers are connected (Tasks 5-8). + FfiBytes::from(&[] as &[u8]) + } + + extern "C" fn archive_total_size(&self, _id_json: FfiSlice<'_>) -> u64 { + 0 + } + + extern "C" fn fs_read_config( + &self, + rel_path: FfiSlice<'_>, + ) -> stabby::option::Option { + let rel = match core::str::from_utf8(rel_path.as_ref()) { + Ok(s) => s, + Err(_) => return stabby::option::Option::None(), + }; + let root = match &self.project_root { + Some(r) => r.join(".harmont"), + None => match std::env::current_dir() { + Ok(cwd) => cwd.join(".harmont"), + Err(_) => return stabby::option::Option::None(), + }, + }; + let Ok(canonical_root) = root.canonicalize() else { + return stabby::option::Option::None(); + }; + let candidate = canonical_root.join(rel); + let Ok(canonical) = candidate.canonicalize() else { + return stabby::option::Option::None(); + }; + if !canonical.starts_with(&canonical_root) { + return stabby::option::Option::None(); + } + match std::fs::read(&canonical) { + Ok(bytes) => stabby::option::Option::Some(FfiBytes::from(bytes.as_slice())), + Err(_) => stabby::option::Option::None(), + } + } +} diff --git a/crates/hm/src/plugin/install.rs b/crates/hm-plugin-runtime/src/install.rs similarity index 73% rename from crates/hm/src/plugin/install.rs rename to crates/hm-plugin-runtime/src/install.rs index 54a00de..f6c5dc5 100644 --- a/crates/hm/src/plugin/install.rs +++ b/crates/hm-plugin-runtime/src/install.rs @@ -1,12 +1,13 @@ //! Implementation of `hm plugin install --pin `. use std::path::PathBuf; +use std::sync::Arc; use anyhow::{Context, Result, bail}; use sha2::{Digest, Sha256}; -use super::host::LoadedPlugin; -use super::paths; +use crate::host::LoadedPlugin; +use crate::host_api::HostApiImpl; /// Install a plugin from a file path or HTTPS URL. /// @@ -14,7 +15,7 @@ use super::paths; /// the SHA-256 of the downloaded bytes (hex, lowercase). /// /// On success, the plugin is written to -/// `/.wasm`. +/// `/.`. /// /// # Errors /// @@ -47,16 +48,25 @@ pub async fn install(source: &str, pin: Option<&str>) -> Result { 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")?; + let dll_ext = std::env::consts::DLL_EXTENSION; + + // Write to a temp file, load it to validate the manifest, then + // move to the install dir with the manifest name. + let tmp_dir = tempfile::tempdir().context("create tempdir for validation")?; + let tmp_path = tmp_dir.path().join(format!("plugin.{dll_ext}")); + std::fs::write(&tmp_path, &bytes) + .with_context(|| format!("write temp {}", tmp_path.display()))?; + + let host_api = Arc::new(HostApiImpl::new_noop()); + let plugin = LoadedPlugin::load(&tmp_path, host_api) + .context("validate plugin before installing")?; + let name = plugin.manifest.name.clone(); + drop(plugin); + + let install_dir = hm_util::dirs::plugin_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)); + let target = install_dir.join(format!("{name}.{dll_ext}")); std::fs::write(&target, &bytes).with_context(|| format!("write {}", target.display()))?; Ok(target) } diff --git a/crates/hm-plugin-runtime/src/lib.rs b/crates/hm-plugin-runtime/src/lib.rs new file mode 100644 index 0000000..574e82a --- /dev/null +++ b/crates/hm-plugin-runtime/src/lib.rs @@ -0,0 +1,11 @@ +//! Plugin loading, discovery, and host-API runtime. + +pub mod clap_bridge; +pub mod error; +pub mod host; +pub mod host_api; +pub mod install; +pub mod registry; + +pub use host::LoadedPlugin; +pub use registry::{CapabilityIndex, PluginRegistry, RegistryConfig}; diff --git a/crates/hm-plugin-runtime/src/registry.rs b/crates/hm-plugin-runtime/src/registry.rs new file mode 100644 index 0000000..b653fab --- /dev/null +++ b/crates/hm-plugin-runtime/src/registry.rs @@ -0,0 +1,213 @@ +//! Discovers native shared-library plugins under the user and project +//! plugin dirs, validates each manifest, and builds a capability index +//! used by the dispatcher. + +#![allow(clippy::missing_errors_doc)] +#![allow(clippy::needless_pass_by_value)] +#![allow(clippy::collapsible_if)] + +use std::collections::btree_map::Entry; +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use hm_plugin_protocol::{Capability, PluginManifest, SubcommandSpec}; + +use crate::error::RuntimeError; +use crate::host::LoadedPlugin; +use crate::host_api::HostApiImpl; + +#[derive(Debug, smart_default::SmartDefault)] +pub struct RegistryConfig { + /// If `false`, skip discovery and only registers explicitly added + /// plugins. Used by integration tests. + pub auto_discover: bool, + /// Extra plugin paths to load (in addition to discovery). Used by + /// tests to load fixture plugins. + pub extra_paths: Vec, + /// The host API implementation shared by all loaded plugins. + #[default(Arc::new(HostApiImpl::new_noop()))] + pub host_api: Arc, +} + +#[derive(Debug)] +pub struct CapabilityIndex { + subcommands: BTreeMap, + runners: BTreeMap, + default_runner: Option, +} + +impl CapabilityIndex { + /// Scan every plugin's declared capabilities and build the lookup + /// indexes. Returns an error if two plugins claim the same verb, + /// runner name, or default-runner slot. + fn build(plugins: &[Arc]) -> Result { + let conflict = |verb: String, a: usize, b: usize| -> anyhow::Error { + RuntimeError::PluginConflict { + verb, + plugin_a: plugins[a].manifest.name.clone(), + plugin_b: plugins[b].manifest.name.clone(), + } + .into() + }; + + plugins + .iter() + .enumerate() + .flat_map(|(i, p)| p.manifest.capabilities.iter().map(move |cap| (i, cap))) + .try_fold( + (BTreeMap::new(), BTreeMap::new(), None::), + |(mut subs, mut runners, mut default), (i, cap)| match cap { + Capability::Subcommand(s) => match subs.entry(s.verb.clone()) { + Entry::Vacant(e) => { + e.insert(i); + Ok((subs, runners, default)) + } + Entry::Occupied(e) => Err(conflict(s.verb.clone(), *e.get(), i)), + }, + Capability::StepExecutor(s) => { + match runners.entry(s.runner.clone()) { + Entry::Vacant(e) => { + e.insert(i); + } + Entry::Occupied(e) => { + return Err(conflict( + format!("runner:{}", s.runner), + *e.get(), + i, + )) + } + } + if s.default { + if let Some(other) = default.replace(i) { + return Err(conflict("default-runner".into(), other, i)); + } + } + Ok((subs, runners, default)) + } + Capability::LifecycleHook(_) => Ok((subs, runners, default)), + }, + ) + .map(|(subcommands, runners, default_runner)| Self { + subcommands, + runners, + default_runner, + }) + } + + /// Look up the plugin index that registered `verb` as a subcommand. + #[must_use] + pub fn resolve_subcommand(&self, verb: &str) -> Option { + self.subcommands.get(verb).copied() + } + + /// Look up the plugin index for `name`, falling back to the + /// default runner if no exact match exists. + #[must_use] + pub fn resolve_runner(&self, name: &str) -> Option { + self.runners.get(name).copied().or(self.default_runner) + } + + /// The runner name of the plugin marked `default: true`, if any. + #[must_use] + pub fn default_runner_name(&self) -> Option<&str> { + let idx = self.default_runner?; + self.runners + .iter() + .find_map(|(name, &i)| (i == idx).then_some(name.as_str())) + } + + /// All registered subcommand verbs, sorted alphabetically. + pub fn available_subcommands(&self) -> impl Iterator { + self.subcommands.keys().map(String::as_str) + } + + /// All registered runner names, sorted alphabetically. + pub fn available_runners(&self) -> impl Iterator { + self.runners.keys().map(String::as_str) + } +} + +#[derive(Debug)] +pub struct PluginRegistry { + plugins: Vec>, + pub capabilities: CapabilityIndex, +} + +impl PluginRegistry { + /// Discover and load plugins from the filesystem, validate each + /// manifest, and build the capability index. + pub async fn load(config: RegistryConfig) -> Result { + let dll_ext = std::env::consts::DLL_EXTENSION; + let mut paths: Vec = Vec::new(); + + if config.auto_discover { + for dir in hm_util::dirs::plugin_discovery_dirs() { + 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(dll_ext) { + paths.push(path); + } + } + } + } + + paths.extend(config.extra_paths.iter().cloned()); + + let mut set: tokio::task::JoinSet>> = tokio::task::JoinSet::new(); + for path in paths { + let host_api = config.host_api.clone(); + set.spawn_blocking(move || { + let p = LoadedPlugin::load(&path, host_api) + .with_context(|| format!("load {}", path.display()))?; + p.manifest.validate().map_err(RuntimeError::from)?; + Ok(Arc::new(p)) + }); + } + + let mut plugins: Vec> = Vec::new(); + while let Some(result) = set.join_next().await { + plugins.push(result.context("plugin load task panicked")??); + } + + let capabilities = CapabilityIndex::build(&plugins)?; + + Ok(Self { + plugins, + capabilities, + }) + } + + /// Iterate over every loaded plugin's manifest. + pub fn manifests(&self) -> impl Iterator { + self.plugins.iter().map(|p| &p.manifest) + } + + /// Collect all `SubcommandSpec`s declared by loaded plugins. + #[must_use] + pub fn subcommand_specs(&self) -> Vec { + self.manifests() + .flat_map(|m| { + m.capabilities.iter().filter_map(|c| match c { + Capability::Subcommand(s) => Some(s.clone()), + _ => None, + }) + }) + .collect() + } + + /// Clone the `Arc` for the plugin at `idx` (returned by the + /// capability index's resolve methods). + #[must_use] + pub fn get(&self, idx: usize) -> Option> { + self.plugins.get(idx).cloned() + } +} + diff --git a/crates/hm-plugin-sdk/Cargo.toml b/crates/hm-plugin-sdk/Cargo.toml index 22aee71..275b198 100644 --- a/crates/hm-plugin-sdk/Cargo.toml +++ b/crates/hm-plugin-sdk/Cargo.toml @@ -4,16 +4,22 @@ 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." +description = "Authoring SDK for hm plugins. Defines hm-specific traits and macros." [lib] crate-type = ["rlib"] [dependencies] hm-plugin-protocol = { workspace = true } -extism-pdk = { workspace = true } +hm-plugin-macros = { workspace = true } +stabby = { workspace = true } +borsh = { workspace = true } serde = { workspace = true } -serde_json = { workspace = true } +clap = { version = "4", features = ["string"] } + +[dev-dependencies] +clap = { version = "4", features = ["derive"] } +semver = { workspace = true } [lints] workspace = true diff --git a/crates/hm-plugin-sdk/src/context.rs b/crates/hm-plugin-sdk/src/context.rs new file mode 100644 index 0000000..15bc27f --- /dev/null +++ b/crates/hm-plugin-sdk/src/context.rs @@ -0,0 +1,189 @@ +#![allow(unsafe_code)] +//! Ergonomic wrapper around the FFI host API. +//! +//! [`PluginContext`] is handed to every user-facing trait method and +//! provides Rust-native access to the host functions that back +//! [`RawHostApi`](crate::ffi::RawHostApi). + +use crate::ffi::{FfiBytes, FfiSlice, RawHostApi, RawHostApiDyn}; +use hm_plugin_protocol::{ArchiveId, BuildEvent, KvScope, Level, StdStream}; + +/// Type alias for the stabby borrowed trait-object reference that +/// backs [`PluginContext`]. Equivalent to a stable `&'a dyn +/// RawHostApi + Send + Sync`. +/// +/// The `'static` lifetime on `CompoundVt` is required because +/// `DynRef<'a, Vt>` demands `Vt: 'static`; the vtable is a set of +/// function pointers that live for the entire program. +type HostRef<'a> = stabby::DynRef< + 'a, + >::Vt< + >::Vt< + >::Vt< + stabby::abi::vtable::VtDrop, + >, + >, + >, +>; + +/// Ergonomic wrapper around the host-provided [`RawHostApi`] trait +/// object. Every user-facing trait method receives a `&PluginContext` +/// so it can call host functions without touching FFI types directly. +pub struct PluginContext<'a> { + raw: HostRef<'a>, +} + +impl core::fmt::Debug for PluginContext<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("PluginContext") + .field("raw", &">") + .finish() + } +} + +// SAFETY: The underlying DynRef holds a Send+Sync vtable (VtSend + +// VtSync markers). The pointee is guaranteed Send+Sync by the host +// contract. DynRef itself carries a PhantomData<*mut ()> that inhibits +// auto-trait inference, so we provide explicit impls. +unsafe impl Send for PluginContext<'_> {} +// SAFETY: see above — the vtable encodes Sync. +unsafe impl Sync for PluginContext<'_> {} + +impl<'a> PluginContext<'a> { + /// Create a new context wrapping a borrowed host API trait object. + pub fn new(raw: HostRef<'a>) -> Self { + Self { raw } + } + + // -- Logging ---------------------------------------------------------- + + /// Log a message at the given severity level. + pub fn log(&self, level: Level, msg: &str) { + let level_u8 = level_to_u8(level); + let ffi_msg = FfiSlice::from(msg.as_bytes()); + self.raw.log(level_u8, ffi_msg); + } + + // -- Key-value store -------------------------------------------------- + + /// Read a value from the host key-value store. + pub fn kv_get(&self, scope: KvScope, key: &str) -> Option> { + let scope_u8 = kv_scope_to_u8(scope); + let ffi_key = FfiSlice::from(key.as_bytes()); + let result: stabby::option::Option = self.raw.kv_get(scope_u8, ffi_key); + let opt: Option = result.into(); + opt.map(|ffi_bytes| ffi_bytes.as_slice().to_vec()) + } + + /// Write a value into the host key-value store. + pub fn kv_set(&self, scope: KvScope, key: &str, val: &[u8]) { + let scope_u8 = kv_scope_to_u8(scope); + let ffi_key = FfiSlice::from(key.as_bytes()); + let ffi_val = FfiSlice::from(val); + self.raw.kv_set(scope_u8, ffi_key, ffi_val); + } + + // -- Events ----------------------------------------------------------- + + /// Emit a build event to the host. + pub fn emit_event(&self, event: &BuildEvent) { + let bytes = + borsh::to_vec(event).expect("BuildEvent serialization should never fail"); + let ffi = FfiSlice::from(bytes.as_slice()); + self.raw.emit_event(ffi); + } + + // -- Step log streams ------------------------------------------------- + + /// Stream bytes to a step's log stream. + pub fn emit_step_log(&self, stream: StdStream, bytes: &[u8]) { + let stream_u8 = match stream { + StdStream::Stdout => 0, + StdStream::Stderr => 1, + }; + let ffi = FfiSlice::from(bytes); + self.raw.emit_step_log(stream_u8, ffi); + } + + /// Stream bytes to the step's stdout log. + pub fn emit_step_log_stdout(&self, bytes: &[u8]) { + self.emit_step_log(StdStream::Stdout, bytes); + } + + /// Stream bytes to the step's stderr log. + pub fn emit_step_log_stderr(&self, bytes: &[u8]) { + self.emit_step_log(StdStream::Stderr, bytes); + } + + // -- Cancellation ----------------------------------------------------- + + /// Check whether the host has requested cancellation. + pub fn should_cancel(&self) -> bool { + self.raw.should_cancel() + } + + // -- Direct I/O ------------------------------------------------------- + + /// Write bytes to the host process's stdout. + pub fn write_stdout(&self, bytes: &[u8]) { + let ffi = FfiSlice::from(bytes); + self.raw.write_stdout(ffi); + } + + /// Write bytes to the host process's stderr. + pub fn write_stderr(&self, bytes: &[u8]) { + let ffi = FfiSlice::from(bytes); + self.raw.write_stderr(ffi); + } + + // -- Archive I/O ------------------------------------------------------ + + /// Read a chunk from an archive at the given `offset`, returning at + /// most `max` bytes. + pub fn archive_read(&self, id: &ArchiveId, offset: u64, max: u64) -> Vec { + let id_bytes = + borsh::to_vec(id).expect("ArchiveId serialization should never fail"); + let ffi = FfiSlice::from(id_bytes.as_slice()); + let result: FfiBytes = self.raw.archive_read(ffi, offset, max); + result.as_slice().to_vec() + } + + /// Return the total size in bytes of an archive. + pub fn archive_total_size(&self, id: &ArchiveId) -> u64 { + let id_bytes = + borsh::to_vec(id).expect("ArchiveId serialization should never fail"); + let ffi = FfiSlice::from(id_bytes.as_slice()); + self.raw.archive_total_size(ffi) + } + + // -- Config ----------------------------------------------------------- + + /// Read a configuration file relative to the project root. + /// Returns `None` if the file does not exist. + pub fn fs_read_config(&self, rel_path: &str) -> Option> { + let ffi = FfiSlice::from(rel_path.as_bytes()); + let result: stabby::option::Option = self.raw.fs_read_config(ffi); + let opt: Option = result.into(); + opt.map(|ffi_bytes| ffi_bytes.as_slice().to_vec()) + } +} + +// -- Enum → u8 helpers ---------------------------------------------------- + +fn level_to_u8(level: Level) -> u8 { + match level { + Level::Trace => 0, + Level::Debug => 1, + Level::Info => 2, + Level::Warn => 3, + Level::Error => 4, + } +} + +fn kv_scope_to_u8(scope: KvScope) -> u8 { + match scope { + KvScope::Plugin => 0, + KvScope::Build => 1, + KvScope::Step => 2, + } +} diff --git a/crates/hm-plugin-sdk/src/executor.rs b/crates/hm-plugin-sdk/src/executor.rs index 6efdb48..fa66c70 100644 --- a/crates/hm-plugin-sdk/src/executor.rs +++ b/crates/hm-plugin-sdk/src/executor.rs @@ -1,3 +1,6 @@ +use core::future::Future; + +use crate::context::PluginContext; use hm_plugin_protocol::{ExecutorInput, PluginError, StepResult}; /// Implemented by step-executor plugins. The host calls @@ -5,13 +8,18 @@ use hm_plugin_protocol::{ExecutorInput, PluginError, StepResult}; /// [`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 { +/// [`PluginContext::emit_step_log_stdout`] / +/// [`PluginContext::emit_step_log_stderr`] and check cancellation via +/// [`PluginContext::should_cancel`]. +pub trait StepExecutor: Send + Sync + Default { /// 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; + fn run<'a>( + &'a self, + ctx: &'a PluginContext<'a>, + input: ExecutorInput, + ) -> impl Future> + Send + 'a; } diff --git a/crates/hm-plugin-sdk/src/ffi.rs b/crates/hm-plugin-sdk/src/ffi.rs new file mode 100644 index 0000000..bdbcb23 --- /dev/null +++ b/crates/hm-plugin-sdk/src/ffi.rs @@ -0,0 +1,37 @@ +#![allow(unsafe_code)] + +use stabby::future::DynFutureUnsync; + +pub type FfiBytes = stabby::vec::Vec; +pub type FfiSlice<'a> = stabby::slice::Slice<'a, u8>; +pub type FfiResult = stabby::result::Result; + +#[stabby::stabby] +pub trait RawPlugin: Send + Sync { + extern "C" fn manifest(&self) -> FfiBytes; + extern "C" fn execute_step<'a>(&'a self, input: FfiSlice<'a>) -> DynFutureUnsync<'a, FfiResult>; + extern "C" fn on_hook_event<'a>(&'a self, event: FfiSlice<'a>) -> DynFutureUnsync<'a, FfiResult>; + extern "C" fn run_subcommand<'a>(&'a self, input: FfiSlice<'a>) -> DynFutureUnsync<'a, FfiResult>; +} + +#[stabby::stabby] +pub trait RawHostApi: Send + Sync { + extern "C" fn log(&self, level: u8, msg: FfiSlice<'_>); + extern "C" fn kv_get(&self, scope: u8, key: FfiSlice<'_>) -> stabby::option::Option; + extern "C" fn kv_set(&self, scope: u8, key: FfiSlice<'_>, val: FfiSlice<'_>); + extern "C" fn emit_event(&self, event_borsh: FfiSlice<'_>); + extern "C" fn emit_step_log(&self, stream: u8, bytes: FfiSlice<'_>); + extern "C" fn should_cancel(&self) -> bool; + extern "C" fn write_stdout(&self, bytes: FfiSlice<'_>); + extern "C" fn write_stderr(&self, bytes: FfiSlice<'_>); + extern "C" fn archive_read(&self, id_borsh: FfiSlice<'_>, offset: u64, max: u64) -> FfiBytes; + extern "C" fn archive_total_size(&self, id_borsh: FfiSlice<'_>) -> u64; + extern "C" fn fs_read_config(&self, rel_path: FfiSlice<'_>) -> stabby::option::Option; +} + +#[cfg(test)] +mod tests { + use super::*; + fn _assert_raw_plugin_object_safe(_: stabby::Dyn<'_, stabby::boxed::Box<()>, stabby::vtable!(RawPlugin + Send + Sync)>) {} + fn _assert_raw_host_api_object_safe(_: stabby::Dyn<'_, stabby::boxed::Box<()>, stabby::vtable!(RawHostApi + Send + Sync)>) {} +} diff --git a/crates/hm-plugin-sdk/src/hook.rs b/crates/hm-plugin-sdk/src/hook.rs index 4f3c782..e55a0c8 100644 --- a/crates/hm-plugin-sdk/src/hook.rs +++ b/crates/hm-plugin-sdk/src/hook.rs @@ -1,12 +1,19 @@ +use core::future::Future; + +use crate::context::PluginContext; use hm_plugin_protocol::{HookEvent, HookOutcome, PluginError}; /// Implemented by lifecycle-hook plugins. -pub trait LifecycleHook { +pub trait LifecycleHook: Send + Sync + Default { /// 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; + fn on_event<'a>( + &'a self, + ctx: &'a PluginContext<'a>, + event: HookEvent, + ) -> impl Future> + Send + 'a; } 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 index 846dcc9..78a2243 100644 --- a/crates/hm-plugin-sdk/src/lib.rs +++ b/crates/hm-plugin-sdk/src/lib.rs @@ -1,65 +1,53 @@ //! 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. +//! Plugins build to `cdylib` (native shared libraries). The host +//! loads each plugin via `stabby`'s ABI-stable trait objects: the +//! plugin implements [`ffi::RawPlugin`] (generated by the +//! [`hm_plugin!`] macro), while the host provides +//! [`ffi::RawHostApi`] wrapped in a [`PluginContext`]. +//! +//! User-facing capability traits ([`StepExecutor`], [`LifecycleHook`], +//! [`SubcommandPlugin`]) are async and receive a +//! `&PluginContext` so they can call host functions ergonomically. //! //! ```ignore //! use hm_plugin_sdk::*; -//! use hm_plugin_protocol::*; //! +//! #[derive(Default)] //! 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![] }) +//! fn run<'a>(&'a self, ctx: &'a PluginContext<'a>, input: ExecutorInput) +//! -> impl Future> + Send + 'a +//! { +//! async move { +//! ctx.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. +// stabby's generated vtable code uses unsafe FFI trampolines, so this +// crate cannot use `#![forbid(unsafe_code)]`. // // 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 context; pub mod executor; +pub mod ffi; pub mod hook; -pub mod host; -pub mod manifest; -pub mod output; +pub mod spec_from_clap; pub mod subcommand; #[doc(hidden)] pub mod macros; +pub use context::PluginContext; 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; +pub use macros::hm_plugin; +pub use subcommand::SubcommandPlugin; diff --git a/crates/hm-plugin-sdk/src/macros.rs b/crates/hm-plugin-sdk/src/macros.rs index 6d083d9..7cc4c23 100644 --- a/crates/hm-plugin-sdk/src/macros.rs +++ b/crates/hm-plugin-sdk/src/macros.rs @@ -1,117 +1,15 @@ -//! The `register_plugin!` macro generates the Extism plugin entry -//! points from a plugin's manifest and capability impls. +//! Re-exports the `hm_plugin!` proc macro from `hm-plugin-macros`. //! -//! 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. +//! Plugin authors invoke this macro in their `lib.rs` to generate the +//! FFI entry point and `RawPlugin` implementation: //! -//! 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(()) - } +//! ```ignore +//! use hm_plugin_sdk::*; +//! +//! hm_plugin!( +//! manifest = PluginManifest { /* ... */ }, +//! executor = MyExec, +//! ); +//! ``` - #[$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)*)?); - }; -} +pub use hm_plugin_macros::hm_plugin; 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/spec_from_clap.rs b/crates/hm-plugin-sdk/src/spec_from_clap.rs new file mode 100644 index 0000000..d72ae66 --- /dev/null +++ b/crates/hm-plugin-sdk/src/spec_from_clap.rs @@ -0,0 +1,156 @@ +//! Convert a clap `Command` into a `SubcommandSpec` tree. + +use clap::Command; +use hm_plugin_protocol::manifest::{ArgSpec, SubcommandSpec, ValueType}; + +/// Build a [`SubcommandSpec`] by introspecting a clap [`Command`]. +/// +/// Plugin authors can define their CLI schema with clap derive macros +/// and then call this in `hm_plugin!` to produce the manifest +/// automatically. +#[must_use] +pub fn spec_from_command(cmd: &Command) -> SubcommandSpec { + let args: Vec = cmd + .get_arguments() + .filter(|a| { + let id = a.get_id().as_str(); + id != "help" && id != "version" + }) + .map(arg_spec_from_clap) + .collect(); + + let subcommands: Vec = cmd + .get_subcommands() + .filter(|c| c.get_name() != "help") + .map(spec_from_command) + .collect(); + + SubcommandSpec { + verb: cmd.get_name().to_string(), + about: cmd + .get_about() + .map_or_else(String::new, |s| s.to_string()), + args, + subcommands, + } +} + +fn arg_spec_from_clap(arg: &clap::Arg) -> ArgSpec { + let is_flag = matches!( + arg.get_action(), + clap::ArgAction::SetTrue | clap::ArgAction::Count + ); + let is_positional = arg.get_long().is_none() && arg.get_short().is_none(); + + if is_flag { + ArgSpec::Flag { + long: arg + .get_long() + .unwrap_or(arg.get_id().as_str()) + .to_string(), + short: arg.get_short(), + help: arg.get_help().map(|s| s.to_string()), + } + } else if is_positional { + ArgSpec::Positional { + name: arg.get_id().to_string(), + help: arg.get_help().map(|s| s.to_string()), + required: arg.is_required_set(), + value_type: ValueType::String, + } + } else { + ArgSpec::Option { + long: arg + .get_long() + .unwrap_or(arg.get_id().as_str()) + .to_string(), + short: arg.get_short(), + help: arg.get_help().map(|s| s.to_string()), + required: arg.is_required_set(), + value_type: ValueType::String, + default: arg + .get_default_values() + .first() + .and_then(|v| v.to_str()) + .map(String::from), + } + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests { + use super::*; + use clap::{CommandFactory, Parser, Subcommand}; + + #[derive(Debug, Parser)] + #[command(name = "example", about = "Example plugin")] + struct ExampleCli { + #[command(subcommand)] + command: ExampleCommand, + } + + #[derive(Debug, Subcommand)] + enum ExampleCommand { + /// Do the thing. + DoIt { + /// Target name. + name: String, + /// Dry run. + #[arg(long)] + dry_run: bool, + }, + } + + #[test] + fn generates_spec_from_clap_command() { + let cmd = ExampleCli::command(); + let spec = spec_from_command(&cmd); + assert_eq!(spec.verb, "example"); + assert_eq!(spec.subcommands.len(), 1); + assert_eq!(spec.subcommands[0].verb, "do-it"); + assert_eq!(spec.subcommands[0].args.len(), 2); + + let positional = &spec.subcommands[0].args[0]; + assert!(matches!(positional, ArgSpec::Positional { name, required: true, .. } if name == "name")); + + let flag = &spec.subcommands[0].args[1]; + assert!(matches!(flag, ArgSpec::Flag { long, .. } if long == "dry-run")); + } + + #[derive(Debug, Parser)] + #[command(name = "opts", about = "Options test")] + struct OptsCli { + #[command(subcommand)] + command: OptsCommand, + } + + #[derive(Debug, Subcommand)] + enum OptsCommand { + /// List items. + List { + /// Max items. + #[arg(long, default_value = "50")] + limit: u32, + /// Filter pattern. + #[arg(short, long)] + filter: Option, + }, + } + + #[test] + fn handles_options_with_defaults() { + use clap::CommandFactory; + let cmd = OptsCli::command(); + let spec = spec_from_command(&cmd); + let list = &spec.subcommands[0]; + assert_eq!(list.verb, "list"); + assert_eq!(list.args.len(), 2); + + let limit = &list.args[0]; + assert!(matches!( + limit, + ArgSpec::Option { long, default: Some(d), .. } if long == "limit" && d == "50" + )); + } +} diff --git a/crates/hm-plugin-sdk/src/subcommand.rs b/crates/hm-plugin-sdk/src/subcommand.rs index 797df5f..85828b5 100644 --- a/crates/hm-plugin-sdk/src/subcommand.rs +++ b/crates/hm-plugin-sdk/src/subcommand.rs @@ -1,12 +1,18 @@ -use hm_plugin_protocol::{ExitInfo, PluginError}; +use core::future::Future; -pub use hm_plugin_protocol::SubcommandInput; +use crate::context::PluginContext; +use hm_plugin_protocol::{ExitInfo, PluginError, SubcommandInput}; -pub trait SubcommandPlugin { +/// Implemented by subcommand plugins. +pub trait SubcommandPlugin: Send + Sync + Default { /// 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; + fn run<'a>( + &'a self, + ctx: &'a PluginContext<'a>, + input: SubcommandInput, + ) -> impl Future> + Send + 'a; } diff --git a/crates/hm-plugin-sdk/tests/hm_plugin_macro.rs b/crates/hm-plugin-sdk/tests/hm_plugin_macro.rs new file mode 100644 index 0000000..3d50761 --- /dev/null +++ b/crates/hm-plugin-sdk/tests/hm_plugin_macro.rs @@ -0,0 +1,99 @@ +//! Compile-time integration test for the `hm_plugin!` proc macro. +//! +//! If this file compiles, the macro expansion is syntactically and +//! type-theoretically correct. We cannot call `hm_load_plugin` at +//! runtime without a real `HostRef`, but compilation itself proves the +//! generated code is well-formed. + +#![allow( + unsafe_code, + clippy::pedantic, + clippy::nursery, + clippy::cargo, + clippy::multiple_crate_versions, + clippy::cargo_common_metadata, + clippy::expect_used, + clippy::manual_async_fn, + dead_code +)] + +use core::future::Future; +use hm_plugin_sdk::*; + +// ---------- Executor -------------------------------------------------------- + +#[derive(Default)] +struct TestExec; + +impl StepExecutor for TestExec { + fn run<'a>( + &'a self, + _ctx: &'a PluginContext<'a>, + _input: ExecutorInput, + ) -> impl Future> + Send + 'a { + async { + Ok(StepResult { + exit_code: 0, + committed_snapshot: None, + artifacts: vec![], + }) + } + } +} + +// ---------- Hook ------------------------------------------------------------ + +#[derive(Default)] +struct TestHook; + +impl LifecycleHook for TestHook { + fn on_event( + &self, + _ctx: &PluginContext<'_>, + _event: HookEvent, + ) -> impl Future> + Send + '_ { + async { Ok(HookOutcome::Continue) } + } +} + +// ---------- Subcommand ------------------------------------------------------ + +#[derive(Default)] +struct TestSub; + +impl SubcommandPlugin for TestSub { + fn run( + &self, + _ctx: &PluginContext<'_>, + _input: SubcommandInput, + ) -> impl Future> + Send + '_ { + async { + Ok(ExitInfo { + exit_code: 0, + message: None, + }) + } + } +} + +// ---------- Macro invocations ----------------------------------------------- + +// Full invocation with all capabilities +hm_plugin!( + manifest = PluginManifest { + api_version: HM_PLUGIN_API_VERSION, + name: "test-all-caps".into(), + version: semver::Version::new(0, 0, 1), + description: "compile-test with all capabilities".into(), + capabilities: vec![], + config_schema: None, + }, + executor = TestExec, + hook = TestHook, + subcommand = TestSub, +); + +#[test] +fn macro_compiles() { + // If we reach here, the macro expansion compiled successfully. +} diff --git a/crates/hm-util/src/dirs.rs b/crates/hm-util/src/dirs.rs index 4838e3f..6402f16 100644 --- a/crates/hm-util/src/dirs.rs +++ b/crates/hm-util/src/dirs.rs @@ -31,6 +31,31 @@ pub fn harmont_plugin_state_dir() -> Option { harmont_data_dir().map(|d| d.join("state")) } +/// `~/.harmont/plugins/` — user-global plugin directory. +pub fn harmont_user_plugins_dir() -> Option { + harmont_config_dir().map(|d| d.join("plugins")) +} + +/// `/.harmont/plugins/` — project-local plugin directory. +pub fn harmont_project_plugins_dir() -> Option { + std::env::current_dir() + .ok() + .map(|p| p.join(".harmont").join("plugins")) +} + +/// Default install target for `hm plugin install`. +pub fn plugin_install_dir() -> Option { + harmont_user_plugins_dir() +} + +/// All directories the plugin host should scan for installed plugins, +/// in priority order (user-global first, then project-local). +pub fn plugin_discovery_dirs() -> impl Iterator { + [harmont_user_plugins_dir(), harmont_project_plugins_dir()] + .into_iter() + .flatten() +} + #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { @@ -59,4 +84,10 @@ mod tests { let p = harmont_plugin_state_dir().unwrap(); assert!(p.ends_with("harmont/state")); } + + #[test] + fn user_plugins_dir_resolves() { + let p = harmont_user_plugins_dir().unwrap(); + assert!(p.ends_with(".harmont/plugins")); + } } diff --git a/crates/hm/CLAUDE.md b/crates/hm/CLAUDE.md index 51f28a5..aa91c64 100644 --- a/crates/hm/CLAUDE.md +++ b/crates/hm/CLAUDE.md @@ -1,56 +1,48 @@ ## Orchestrator -`cli/crates/hm/src/orchestrator/` is the entry point for local builds. +`crates/hm/src/orchestrator/` is the entry point for local builds. `hm run` calls into `orchestrator::run()`, which: - 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`. +- Loads the plugin registry (discovers native cdylib plugins from + `~/.harmont/plugins/` and `/.harmont/plugins/`) and + resolves each step's `runner` field to a registered plugin 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. + the `output_subscriber` task drains the bus and renders events + directly via `BuildEventRenderer` (`output/build_events.rs`). + `--format human` (default) writes coloured progress to stderr; + `--format json` writes one JSON event per line to 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`). + the Docker daemon via the Bollard wrapper (`docker_client.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. +## Plugin system -## Cloud functionality (plan 4) +Plugin runtime lives in `crates/hm-plugin-runtime/`. The `plugin/` +module in this crate re-exports everything from the runtime crate. +See `crates/hm-plugin-runtime/` for details on `LoadedPlugin`, +`PluginRegistry`, `HostApiImpl`, discovery paths, and installation. -Every cloud verb runs through the embedded `hm-plugin-cloud` plugin +## Cloud functionality + +Every cloud verb runs through the `hm-plugin-cloud` native 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. +pipeline,build,job,billing,run}`. -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. +The cloud plugin uses reqwest directly for HTTP and axum for the +browser-loopback OAuth flow. Token storage is file-backed at +`~/.harmont/credentials.toml` (mode 0o600). Persistent state (active +org slug) uses KV storage via the `RawHostApi` trait's `kv_get`/ +`kv_set` methods (KvScope::Plugin). `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. +Source-archive upload to the cloud is future work. Broadcast lag in `output_subscriber` surfaces a `tracing::warn!` plus an `eprintln!` line; full lag-recovery (e.g., per-step backpressure) diff --git a/crates/hm/Cargo.toml b/crates/hm/Cargo.toml index 45ec7da..13b06f0 100644 --- a/crates/hm/Cargo.toml +++ b/crates/hm/Cargo.toml @@ -8,18 +8,6 @@ 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] name = "harmont_cli" @@ -57,8 +45,6 @@ url = "2" base64 = "0.22" sha1 = "0.10" sha2 = "0.10" -axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "query"] } -webbrowser = "1" rand = "0.8" uuid = { version = "1", features = ["serde"] } bytes = "1" @@ -66,8 +52,10 @@ futures = "0.3" futures-util = "0.3" bollard = "0.18" which = "6" -extism = { workspace = true } hm-plugin-protocol = { workspace = true } +hm-plugin-sdk = { workspace = true } +borsh = { workspace = true } +hm-plugin-runtime = { workspace = true } hm-util = { workspace = true } schemars = { workspace = true } semver = { workspace = true } diff --git a/crates/hm/README.md b/crates/hm/README.md index 0db1728..8183218 100644 --- a/crates/hm/README.md +++ b/crates/hm/README.md @@ -133,8 +133,7 @@ hm run --help # full flag reference ## Cloud `hm cloud ` talks to the hosted Harmont API at `api.harmont.dev`. -Every cloud verb is delivered by the embedded `hm-plugin-cloud` WASM -plugin (no separate install step): +Every cloud verb is delivered by the `hm-plugin-cloud` native plugin: ```sh hm cloud login # browser-loopback OAuth (or --paste to @@ -187,7 +186,7 @@ at your option. ## Plugin authoring -`hm` is plugin-driven via [Extism](https://extism.org). To write a plugin: +`hm` is plugin-driven via native shared-library plugins (`.dylib`/`.so`/`.dll`) loaded through stabby's ABI-stable FFI. To write a plugin: ```bash cargo new --lib my-plugin @@ -195,25 +194,19 @@ cd my-plugin cargo add --git https://github.com/harmont-dev/harmont-cli hm-plugin-sdk ``` -Implement one of `StepExecutor`, `SubcommandPlugin`, `LifecycleHook`, or -`OutputFormatter`, declare a `PluginManifest`, and call -`register_plugin!(...)`. Build with: +Implement one of `StepExecutor`, `SubcommandPlugin`, or `LifecycleHook`, +declare a `PluginManifest`, and call `hm_plugin!(...)`. Build with: ```bash -cargo build --target wasm32-wasip1 --release +cargo build --release ``` -The output `.wasm` can be installed with: +The output shared library can be installed with: ```bash -hm plugin install ./target/wasm32-wasip1/release/my_plugin.wasm +hm plugin install ./target/release/libmy_plugin.dylib # macOS +hm plugin install ./target/release/libmy_plugin.so # Linux ``` -See `cli/crates/hm-fixtures/src/bin/` for minimal working examples. - -### Output formatter +See `tests/fixtures/` for minimal working examples. -Implement `OutputFormatter::on_event` to render each `BuildEvent`. -Plugins emit bytes via `host::write_stdout` or `host::write_stderr`. -Built-in formatters: `human` (default), `json`. Select with -`hm run --format `. 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-plugin-cloud/Cargo.toml b/crates/hm/plugins/hm-plugin-cloud/Cargo.toml similarity index 62% rename from crates/hm-plugin-cloud/Cargo.toml rename to crates/hm/plugins/hm-plugin-cloud/Cargo.toml index 8f2f254..428b5bc 100644 --- a/crates/hm-plugin-cloud/Cargo.toml +++ b/crates/hm/plugins/hm-plugin-cloud/Cargo.toml @@ -14,7 +14,8 @@ path = "src/lib.rs" [dependencies] hm-plugin-sdk = { workspace = true } hm-plugin-protocol = { workspace = true } -extism-pdk = { workspace = true, features = ["http"] } +stabby = { workspace = true } +borsh = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } semver = { workspace = true } @@ -25,6 +26,14 @@ anyhow = "1" url = "2" base64 = "0.22" sha2 = "0.10" +reqwest = { version = "0.13", default-features = false, features = ["rustls", "json"] } +tokio = { workspace = true } +axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "query"] } +webbrowser = "1" +dialoguer = "0.11" +toml = "0.8" +dirs = "6" +rand = "0.8" [lints] workspace = true diff --git a/crates/hm-plugin-cloud/src/api/mod.rs b/crates/hm/plugins/hm-plugin-cloud/src/api/mod.rs similarity index 100% rename from crates/hm-plugin-cloud/src/api/mod.rs rename to crates/hm/plugins/hm-plugin-cloud/src/api/mod.rs diff --git a/crates/hm-plugin-cloud/src/api/types.rs b/crates/hm/plugins/hm-plugin-cloud/src/api/types.rs similarity index 100% rename from crates/hm-plugin-cloud/src/api/types.rs rename to crates/hm/plugins/hm-plugin-cloud/src/api/types.rs diff --git a/crates/hm/plugins/hm-plugin-cloud/src/auth/login.rs b/crates/hm/plugins/hm-plugin-cloud/src/auth/login.rs new file mode 100644 index 0000000..ef92cd6 --- /dev/null +++ b/crates/hm/plugins/hm-plugin-cloud/src/auth/login.rs @@ -0,0 +1,196 @@ +//! `hm cloud login` — browser-loopback or paste-in flow. + +use std::collections::BTreeMap; + +use hm_plugin_protocol::PluginError; +use hm_plugin_sdk::PluginContext; + +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) async fn run( + ctx: &PluginContext<'_>, + env: &BTreeMap, + paste: bool, +) -> Result<(), PluginError> { + let cfg = Config::from_env(env); + let (verifier, challenge) = pkce_pair()?; + + if paste { + login_paste(ctx, env, &cfg, &verifier, &challenge).await + } else { + login_loopback(ctx, &cfg, &verifier, &challenge).await + } +} + +async fn login_loopback( + ctx: &PluginContext<'_>, + cfg: &Config, + verifier: &str, + challenge: &str, +) -> Result<(), PluginError> { + // Bind a one-shot axum server on localhost:0 to receive the OAuth callback. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .map_err(|e| PluginError::new("cloud_loopback_spawn", format!("bind loopback: {e}")))?; + let port = listener + .local_addr() + .map_err(|e| PluginError::new("cloud_loopback_spawn", format!("local_addr: {e}")))? + .port(); + + let redirect = format!("http://127.0.0.1:{port}/cb"); + let auth_url = format!( + "{}/cli/login?challenge={}&redirect_uri={}", + cfg.api_base, + challenge, + urlencoding(&redirect), + ); + + ctx.log( + hm_plugin_protocol::Level::Info, + &format!("opening browser to {auth_url}"), + ); + if webbrowser::open(&auth_url).is_err() { + ctx.write_stderr( + format!("couldn't auto-open the browser. Open this URL manually:\n {auth_url}\n") + .as_bytes(), + ); + } + + // Use a oneshot channel to receive the code from the callback handler. + let (tx, rx) = tokio::sync::oneshot::channel::(); + let tx = std::sync::Arc::new(std::sync::Mutex::new(Some(tx))); + + let app = axum::Router::new().route( + "/cb", + axum::routing::get( + move |axum::extract::Query(params): axum::extract::Query>| { + let code = params.get("code").cloned().unwrap_or_default(); + if let Some(sender) = tx.lock().ok().and_then(|mut g| g.take()) { + let _ = sender.send(code); + } + async { "Login received. You can close this tab." } + }, + ), + ); + + // Serve the axum app in the background; shut down after we get the code. + let server = tokio::spawn(async move { + axum::serve(listener, app) + .await + .ok(); + }); + + let code = tokio::time::timeout(std::time::Duration::from_secs(180), rx) + .await + .map_err(|_| { + PluginError::new( + "cloud_login_timeout", + "browser callback did not arrive within 3 minutes", + ) + })? + .map_err(|_| { + PluginError::new( + "cloud_login_timeout", + "callback channel closed unexpectedly", + ) + })?; + + server.abort(); + + if code.is_empty() { + return Err(PluginError::new( + "cloud_login_missing_code", + "callback had no 'code' query parameter", + )); + } + + finalize(ctx, cfg, &code, verifier).await +} + +async fn login_paste( + ctx: &PluginContext<'_>, + env: &BTreeMap, + cfg: &Config, + verifier: &str, + challenge: &str, +) -> Result<(), PluginError> { + let auth_url = format!( + "{}/cli/login?challenge={}&redirect_uri=urn:ietf:wg:oauth:2.0:oob", + cfg.api_base, challenge, + ); + ctx.write_stderr( + format!("Open this URL in your browser, then paste the code:\n {auth_url}\n").as_bytes(), + ); + 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 { + dialoguer::Input::::new() + .with_prompt("code") + .interact_text() + .map_err(|e| PluginError::new("cloud_login_tty", format!("prompt failed: {e}")))? + }; + let code = code.trim().to_string(); + if code.is_empty() { + return Err(PluginError::new("cloud_login_empty_code", "no code pasted")); + } + finalize(ctx, cfg, &code, verifier).await +} + +async fn finalize( + ctx: &PluginContext<'_>, + cfg: &Config, + code: &str, + verifier: &str, +) -> Result<(), PluginError> { + let client = Client::anonymous(cfg); + 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").await?; + ctx.write_stderr( + format!( + "logged in as {} ({})\n", + me.display_name.clone().unwrap_or_else(|| me.email.clone()), + me.email, + ) + .as_bytes(), + ); + Ok(()) +} + +/// Generate a PKCE verifier + S256 challenge using real entropy. +fn pkce_pair() -> Result<(String, String), PluginError> { + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use rand::RngCore; + use sha2::{Digest, Sha256}; + + let mut seed = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut seed); + let verifier = URL_SAFE_NO_PAD.encode(seed); + let challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())); + Ok((verifier, challenge)) +} + +fn urlencoding(s: &str) -> String { + url::form_urlencoded::byte_serialize(s.as_bytes()).collect() +} diff --git a/crates/hm-plugin-cloud/src/auth/logout.rs b/crates/hm/plugins/hm-plugin-cloud/src/auth/logout.rs similarity index 60% rename from crates/hm-plugin-cloud/src/auth/logout.rs rename to crates/hm/plugins/hm-plugin-cloud/src/auth/logout.rs index d1d00d8..73ed42d 100644 --- a/crates/hm-plugin-cloud/src/auth/logout.rs +++ b/crates/hm/plugins/hm-plugin-cloud/src/auth/logout.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; use hm_plugin_protocol::PluginError; -use hm_plugin_sdk::host; +use hm_plugin_sdk::PluginContext; use crate::config::Config; use crate::creds; @@ -12,9 +12,12 @@ use crate::creds; 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( + ctx: &PluginContext<'_>, + env: &BTreeMap, +) -> Result<(), PluginError> { 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()); + ctx.write_stderr(format!("logged out of {}\n", cfg.api_base).as_bytes()); Ok(()) } diff --git a/crates/hm-plugin-cloud/src/auth/mod.rs b/crates/hm/plugins/hm-plugin-cloud/src/auth/mod.rs similarity index 100% rename from crates/hm-plugin-cloud/src/auth/mod.rs rename to crates/hm/plugins/hm-plugin-cloud/src/auth/mod.rs diff --git a/crates/hm-plugin-cloud/src/auth/whoami.rs b/crates/hm/plugins/hm-plugin-cloud/src/auth/whoami.rs similarity index 79% rename from crates/hm-plugin-cloud/src/auth/whoami.rs rename to crates/hm/plugins/hm-plugin-cloud/src/auth/whoami.rs index a774ef9..e919d8a 100644 --- a/crates/hm-plugin-cloud/src/auth/whoami.rs +++ b/crates/hm/plugins/hm-plugin-cloud/src/auth/whoami.rs @@ -3,7 +3,7 @@ use std::collections::BTreeMap; use hm_plugin_protocol::PluginError; -use hm_plugin_sdk::host; +use hm_plugin_sdk::PluginContext; use crate::api::types::User; use crate::config::Config; @@ -14,7 +14,10 @@ use crate::http::Client; 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( + ctx: &PluginContext<'_>, + env: &BTreeMap, +) -> Result<(), PluginError> { let cfg = Config::from_env(env); let token = creds::load_token(&cfg.api_base, env).ok_or_else(|| { PluginError::new( @@ -23,8 +26,8 @@ pub(crate) fn run(env: &BTreeMap) -> Result<(), PluginError> { ) })?; let client = Client::new(&cfg, Some(token)); - let me: User = client.get("/auth/me")?; - host::write_stdout( + let me: User = client.get("/auth/me").await?; + ctx.write_stdout( format!( "{} <{}> (id {})\n", me.display_name.clone().unwrap_or_else(|| me.email.clone()), diff --git a/crates/hm/plugins/hm-plugin-cloud/src/cli.rs b/crates/hm/plugins/hm-plugin-cloud/src/cli.rs new file mode 100644 index 0000000..250d6c3 --- /dev/null +++ b/crates/hm/plugins/hm-plugin-cloud/src/cli.rs @@ -0,0 +1,56 @@ +//! Plugin-internal dispatch. The host has already parsed the CLI via +//! clap_bridge; the plugin receives structured `SubcommandInput` with +//! verb_path and JSON args. + +use hm_plugin_protocol::{ExitInfo, PluginError, SubcommandInput}; +use hm_plugin_sdk::PluginContext; + +use crate::{auth, verbs}; + +pub(crate) async fn dispatch( + ctx: &PluginContext<'_>, + input: SubcommandInput, +) -> Result { + // Convert once: downstream verb functions still accept &serde_json::Value. + let args_json: serde_json::Value = input.args.into(); + let tail: Vec<&str> = input.verb_path.iter().skip(1).map(String::as_str).collect(); + let result = match tail.as_slice() { + ["login"] => { + let paste = args_json.get("paste").and_then(serde_json::Value::as_bool).unwrap_or(false); + auth::login::run(ctx, &input.env, paste).await + } + ["logout"] => auth::logout::run(ctx, &input.env).await, + ["whoami"] => auth::whoami::run(ctx, &input.env).await, + ["org", verb] => verbs::org::run(ctx, &input.env, verb, &args_json).await, + ["pipeline", verb] => verbs::pipeline::run(ctx, &input.env, verb, &args_json).await, + ["build", verb] => verbs::build::run(ctx, &input.env, verb, &args_json).await, + ["job", verb] => verbs::job::run(ctx, &input.env, verb, &args_json).await, + ["billing", verb] => verbs::billing::run(ctx, &input.env, verb, &args_json).await, + ["run"] => verbs::run::run(ctx, &input.env, &args_json).await, + other => { + return Ok(ExitInfo { + exit_code: 2, + message: Some(format!("unknown cloud verb: {}", other.join(" "))), + }); + } + }; + match result { + Ok(()) => Ok(ExitInfo { + exit_code: 0, + message: None, + }), + Err(e) => Ok(ExitInfo { + exit_code: exit_code_for(&e), + message: Some(e.message), + }), + } +} + +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, + } +} diff --git a/crates/hm-plugin-cloud/src/config.rs b/crates/hm/plugins/hm-plugin-cloud/src/config.rs similarity index 100% rename from crates/hm-plugin-cloud/src/config.rs rename to crates/hm/plugins/hm-plugin-cloud/src/config.rs diff --git a/crates/hm/plugins/hm-plugin-cloud/src/creds.rs b/crates/hm/plugins/hm-plugin-cloud/src/creds.rs new file mode 100644 index 0000000..27ff260 --- /dev/null +++ b/crates/hm/plugins/hm-plugin-cloud/src/creds.rs @@ -0,0 +1,80 @@ +//! On-disk credential storage via direct file I/O. +//! +//! Credentials live at `~/.harmont/credentials.toml` with structure: +//! ```toml +//! [tokens] +//! "https://api.harmont.dev" = "the-token" +//! ``` + +use std::collections::BTreeMap; +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +const CREDS_FILE: &str = "credentials.toml"; + +#[derive(Debug, Default, Serialize, Deserialize)] +struct CredsFile { + #[serde(default)] + tokens: BTreeMap, +} + +fn creds_path() -> Option { + dirs::home_dir().map(|h| h.join(".harmont").join(CREDS_FILE)) +} + +/// 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(path) = creds_path() else { return }; + let mut creds = load_creds_file(&path); + if token.is_empty() { + creds.tokens.remove(api_base); + } else { + creds.tokens.insert(api_base.to_string(), token.to_string()); + } + write_creds_file(&path, &creds); +} + +/// Load the token for `api_base`. Prefers `HARMONT_API_TOKEN` from the +/// caller-provided env over the file 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") { + if !t.is_empty() { + return Some(t.clone()); + } + } + let path = creds_path()?; + let creds = load_creds_file(&path); + creds.tokens.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) { + save_token(api_base, ""); +} + +fn load_creds_file(path: &PathBuf) -> CredsFile { + std::fs::read_to_string(path) + .ok() + .and_then(|s| toml::from_str(&s).ok()) + .unwrap_or_default() +} + +fn write_creds_file(path: &PathBuf, creds: &CredsFile) { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + if let Ok(content) = toml::to_string_pretty(creds) { + let _ = std::fs::write(path, content); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)); + } + } +} diff --git a/crates/hm-plugin-cloud/src/http.rs b/crates/hm/plugins/hm-plugin-cloud/src/http.rs similarity index 54% rename from crates/hm-plugin-cloud/src/http.rs rename to crates/hm/plugins/hm-plugin-cloud/src/http.rs index c287715..d4896bb 100644 --- a/crates/hm-plugin-cloud/src/http.rs +++ b/crates/hm/plugins/hm-plugin-cloud/src/http.rs @@ -1,14 +1,14 @@ -//! Thin HTTP wrapper around extism-pdk's host-mediated `http_request`. -//! Bearer-token injection, JSON ser/de, status-code → stable error code -//! mapping. +//! Async HTTP wrapper around reqwest. Bearer-token injection, JSON +//! ser/de, status-code to stable error code mapping. -use extism_pdk::{HttpRequest, HttpResponse, http::request}; use hm_plugin_protocol::PluginError; +use reqwest::Client as ReqwestClient; use serde::{Serialize, de::DeserializeOwned}; use crate::config::Config; pub(crate) struct Client { + inner: ReqwestClient, base: String, token: Option, } @@ -20,6 +20,7 @@ impl Client { )] pub(crate) fn new(config: &Config, token: Option) -> Self { Self { + inner: ReqwestClient::new(), base: config.api_base.clone(), token, } @@ -32,62 +33,66 @@ impl Client { /// 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)) + 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) + 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 mut req = self.inner.request( + method + .parse() + .map_err(|e| PluginError::new("cloud_http", format!("{e}")))?, + &url, + ); if let Some(token) = &self.token { - req = req.with_header("Authorization", format!("Bearer {token}")); + req = req.bearer_auth(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.json(b); } - let response: HttpResponse = request(&req, body_bytes.as_deref()) + let resp = req + .send() + .await .map_err(|e| PluginError::new("cloud_http_request", format!("{method} {url}: {e}")))?; - let status = response.status_code(); - let body = response.body(); + let status = resp.status().as_u16(); if !(200..300).contains(&status) { - let snippet = String::from_utf8_lossy(&body) - .chars() - .take(500) - .collect::(); + let snippet = resp.text().await.unwrap_or_default(); + let snippet: String = snippet.chars().take(500).collect(); return Err(PluginError::new( map_status_code(status), - format!("{method} {url} → HTTP {status}: {snippet}"), + format!("{method} {url} \u{2192} HTTP {status}: {snippet}"), )); } - if body.is_empty() { + let bytes = resp + .bytes() + .await + .map_err(|e| PluginError::new("cloud_http_decode", e.to_string()))?; + if bytes.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())); } - serde_json::from_slice(&body) + serde_json::from_slice(&bytes) .map_err(|e| PluginError::new("cloud_http_decode", e.to_string())) } } diff --git a/crates/hm/plugins/hm-plugin-cloud/src/lib.rs b/crates/hm/plugins/hm-plugin-cloud/src/lib.rs new file mode 100644 index 0000000..82fdbb7 --- /dev/null +++ b/crates/hm/plugins/hm-plugin-cloud/src/lib.rs @@ -0,0 +1,55 @@ +//! Built-in cloud client plugin for the hm CLI. +//! +//! Implements `hm cloud {login,logout,whoami,org,pipeline,build,job,billing,run}`. +//! HTTP traffic goes through reqwest directly (native dylib, no WASM sandbox). + +#![allow(unsafe_code)] +#![allow( + clippy::pedantic, + clippy::nursery, + clippy::cargo, + clippy::multiple_crate_versions, + clippy::cargo_common_metadata, + clippy::missing_errors_doc, +)] + +mod api; +mod auth; +mod cli; +mod config; +mod creds; +mod http; +mod manifest_schema; +mod output; +mod state; +mod verbs; + +use core::future::Future; +use hm_plugin_sdk::*; + +#[derive(Default)] +struct Cloud; + +impl SubcommandPlugin for Cloud { + fn run<'a>( + &'a self, + ctx: &'a PluginContext<'a>, + input: SubcommandInput, + ) -> impl Future> + Send + 'a { + async move { cli::dispatch(ctx, input).await } + } +} + +hm_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( + manifest_schema::cloud_spec() + )], + config_schema: None, + }, + subcommand = Cloud, +); diff --git a/crates/hm/plugins/hm-plugin-cloud/src/manifest_schema.rs b/crates/hm/plugins/hm-plugin-cloud/src/manifest_schema.rs new file mode 100644 index 0000000..f03e8fe --- /dev/null +++ b/crates/hm/plugins/hm-plugin-cloud/src/manifest_schema.rs @@ -0,0 +1,191 @@ +//! Clap derive types used solely for generating the plugin manifest's +//! `SubcommandSpec` tree. The host parses args at runtime; these types +//! exist only so `spec_from_command` can introspect the CLI structure. + +use clap::{CommandFactory, Parser, Subcommand}; +use hm_plugin_protocol::SubcommandSpec; +use hm_plugin_sdk::spec_from_clap::spec_from_command; + +pub(crate) fn cloud_spec() -> SubcommandSpec { + spec_from_command(&CloudCli::command()) +} + +#[derive(Debug, Parser)] +#[command( + name = "cloud", + about = "Talk to the Harmont cloud API", + disable_help_subcommand = true +)] +struct CloudCli { + #[command(subcommand)] + command: CloudCommand, +} + +#[derive(Debug, Subcommand)] +enum CloudCommand { + /// Authenticate this CLI against the Harmont API. + Login { + /// Skip the loopback flow and prompt for a paste-in code. + #[arg(long)] + paste: bool, + }, + /// Remove stored credentials. + Logout, + /// Show the authenticated user. + Whoami, + /// Manage organizations. + #[command(subcommand)] + Org(OrgCommand), + /// Manage pipelines. + #[command(subcommand)] + Pipeline(PipelineCommand), + /// Manage builds. + #[command(subcommand)] + Build(BuildCommand), + /// Manage jobs. + #[command(subcommand)] + Job(JobCommand), + /// Manage credits, top-ups, and usage. + #[command(subcommand)] + Billing(BillingCommand), + /// Submit the local pipeline to the cloud and watch its build. + Run { + /// Pipeline slug. + pipeline: String, + /// Branch to record on the build. + #[arg(short, long)] + branch: Option, + /// Build message. + #[arg(short, long)] + message: Option, + /// Path to a pre-rendered pipeline JSON file. + #[arg(long)] + plan_file: Option, + /// Don't watch; print the build URL and exit. + #[arg(long)] + no_watch: bool, + }, +} + +#[derive(Debug, Subcommand)] +enum OrgCommand { + /// Set the active organization. + Switch { + /// Organization slug. + slug: String, + }, +} + +#[derive(Debug, Subcommand)] +enum PipelineCommand { + /// List pipelines for the active organization. + List, + /// Show pipeline details by slug. + Show { + /// Pipeline slug. + slug: String, + }, +} + +#[derive(Debug, Subcommand)] +enum BuildCommand { + /// List builds for a pipeline. + List { + /// Pipeline slug. + #[arg(short, long)] + pipeline: String, + }, + /// Show a build by number. + Show { + /// Pipeline slug. + #[arg(short, long)] + pipeline: String, + /// Build number. + number: i64, + }, + /// Cancel a build. + Cancel { + /// Pipeline slug. + #[arg(short, long)] + pipeline: String, + /// Build number. + number: i64, + }, + /// Watch a build until it reaches a terminal state. + Watch { + /// Pipeline slug. + #[arg(short, long)] + pipeline: String, + /// Build number. + number: i64, + }, +} + +#[derive(Debug, Subcommand)] +enum JobCommand { + /// List jobs in a build. + List { + /// Pipeline slug. + #[arg(short, long)] + pipeline: String, + /// Build number. + #[arg(short, long)] + build: i64, + }, + /// Show a job by id. + Show { + /// Pipeline slug. + #[arg(short, long)] + pipeline: String, + /// Build number. + #[arg(short, long)] + build: i64, + /// Job ID. + job_id: String, + }, + /// Print the job log. + Log { + /// Pipeline slug. + #[arg(short, long)] + pipeline: String, + /// Build number. + #[arg(short, long)] + build: i64, + /// Job ID. + job_id: String, + }, +} + +#[derive(Debug, Subcommand)] +enum BillingCommand { + /// Print the current credit balance. + Balance, + /// List billing transactions. + Transactions { + /// Maximum number of transactions to show. + #[arg(long, default_value = "100")] + limit: u32, + }, + /// Show usage over a time window. + Usage { + /// Start date (YYYY-MM-DD). + #[arg(long)] + from: Option, + /// End date (YYYY-MM-DD). + #[arg(long)] + to: Option, + }, + /// Top up credits via Stripe checkout. + Topup { + /// Amount in USD to top up. + amount_usd: u32, + /// Print the checkout URL instead of opening a browser. + #[arg(long)] + no_browser: bool, + }, + /// Redeem a coupon code. + Redeem { + /// Coupon code. + code: String, + }, +} diff --git a/crates/hm-plugin-cloud/src/output.rs b/crates/hm/plugins/hm-plugin-cloud/src/output.rs similarity index 100% rename from crates/hm-plugin-cloud/src/output.rs rename to crates/hm/plugins/hm-plugin-cloud/src/output.rs diff --git a/crates/hm-plugin-cloud/src/state.rs b/crates/hm/plugins/hm-plugin-cloud/src/state.rs similarity index 67% rename from crates/hm-plugin-cloud/src/state.rs rename to crates/hm/plugins/hm-plugin-cloud/src/state.rs index 71561f3..f1fdcef 100644 --- a/crates/hm-plugin-cloud/src/state.rs +++ b/crates/hm/plugins/hm-plugin-cloud/src/state.rs @@ -1,7 +1,8 @@ //! Persistent state (active organization slug) via the host's //! `KvScope::Plugin` store. -use hm_plugin_sdk::{KvScope, host}; +use hm_plugin_protocol::KvScope; +use hm_plugin_sdk::PluginContext; use serde::{Deserialize, Serialize}; const STATE_KEY: &str = "state"; @@ -13,17 +14,17 @@ 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 { + pub(crate) fn load(ctx: &PluginContext<'_>) -> Self { + let Some(bytes) = ctx.kv_get(KvScope::Plugin, STATE_KEY) 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) { + pub(crate) fn save(&self, ctx: &PluginContext<'_>) { if let Ok(bytes) = serde_json::to_vec(self) { - host::kv_set(KvScope::Plugin, STATE_KEY, &bytes); + ctx.kv_set(KvScope::Plugin, STATE_KEY, &bytes); } } } diff --git a/crates/hm/plugins/hm-plugin-cloud/src/verbs/billing.rs b/crates/hm/plugins/hm-plugin-cloud/src/verbs/billing.rs new file mode 100644 index 0000000..8882511 --- /dev/null +++ b/crates/hm/plugins/hm-plugin-cloud/src/verbs/billing.rs @@ -0,0 +1,202 @@ +//! `hm cloud billing balance|transactions|usage|topup|redeem`. + +use std::collections::BTreeMap; + +use hm_plugin_protocol::PluginError; +use hm_plugin_sdk::PluginContext; + +use crate::api::types::{ + Balance, RedeemRequest, RedeemResponse, TopupRequest, TopupResponse, TransactionList, + UsageWindow, +}; +use crate::config::Config; +use crate::creds; +use crate::http::Client; +use crate::state::CloudState; + +pub(crate) async fn run( + ctx: &PluginContext<'_>, + env: &BTreeMap, + verb: &str, + args: &serde_json::Value, +) -> Result<(), PluginError> { + let cfg = Config::from_env(env); + let token = creds::load_token(&cfg.api_base, env).ok_or_else(not_logged_in)?; + let client = Client::new(&cfg, Some(token)); + let org = active_org(ctx)?; + + match verb { + "balance" => balance(ctx, &client, &org).await, + "transactions" => { + let limit = args + .get("limit") + .and_then(serde_json::Value::as_i64) + .unwrap_or(100) as u32; + transactions(ctx, &client, &org, limit).await + } + "usage" => { + let from = args.get("from").and_then(serde_json::Value::as_str); + let to = args.get("to").and_then(serde_json::Value::as_str); + usage(ctx, &client, &org, from, to).await + } + "topup" => { + let amount_usd = require_i64(args, "amount_usd")? as u32; + let no_browser = args + .get("no_browser") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + topup(ctx, &client, &org, amount_usd, no_browser).await + } + "redeem" => { + let code = require_str(args, "code")?; + redeem(ctx, &client, &org, &code).await + } + _ => Err(PluginError::new( + "cloud_unknown_verb", + format!("unknown billing verb: {verb}"), + )), + } +} + +async fn balance( + ctx: &PluginContext<'_>, + client: &Client, + org: &str, +) -> Result<(), PluginError> { + let b: Balance = client + .get(&format!("/organizations/{org}/billing/balance")) + .await?; + let dollars = b.credits_usd_cents as f64 / 100.0; + ctx.write_stdout(format!("${dollars:.2}\n").as_bytes()); + Ok(()) +} + +async fn transactions( + ctx: &PluginContext<'_>, + client: &Client, + org: &str, + limit: u32, +) -> Result<(), PluginError> { + let list: TransactionList = client + .get(&format!( + "/organizations/{org}/billing/transactions?limit={limit}" + )) + .await?; + for t in &list.data { + let line = format!( + "{} {:>10} {:<14} {}\n", + t.at.format("%Y-%m-%d %H:%M:%S"), + t.amount_cents, + t.kind, + t.memo.as_deref().unwrap_or("") + ); + ctx.write_stdout(line.as_bytes()); + } + Ok(()) +} + +async fn usage( + ctx: &PluginContext<'_>, + client: &Client, + org: &str, + from: Option<&str>, + to: Option<&str>, +) -> Result<(), PluginError> { + let mut q = vec![]; + if let Some(f) = from { + q.push(format!("from={f}")); + } + if let Some(t) = to { + q.push(format!("to={t}")); + } + let qs = if q.is_empty() { + String::new() + } else { + format!("?{}", q.join("&")) + }; + let u: UsageWindow = client + .get(&format!("/organizations/{org}/billing/usage{qs}")) + .await?; + let line = format!( + "{} -> {}: {:.2} min, ${:.2}\n", + u.from.format("%Y-%m-%d"), + u.to.format("%Y-%m-%d"), + u.minutes_used, + u.cents_used as f64 / 100.0 + ); + ctx.write_stdout(line.as_bytes()); + Ok(()) +} + +async fn topup( + ctx: &PluginContext<'_>, + 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, + }, + ) + .await?; + if no_browser { + ctx.write_stdout(r.checkout_url.as_bytes()); + ctx.write_stdout(b"\n"); + } else if webbrowser::open(&r.checkout_url).is_err() { + ctx.write_stderr(b"couldn't open browser; URL:\n"); + ctx.write_stderr(r.checkout_url.as_bytes()); + ctx.write_stderr(b"\n"); + } + Ok(()) +} + +async fn redeem( + ctx: &PluginContext<'_>, + 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(), + }, + ) + .await?; + let dollars = r.credited_cents as f64 / 100.0; + ctx.write_stderr(format!("credited ${dollars:.2}\n").as_bytes()); + Ok(()) +} + +fn require_str(args: &serde_json::Value, key: &str) -> Result { + args[key] + .as_str() + .map(String::from) + .ok_or_else(|| PluginError::new("cloud_cli_parse", format!("missing required argument: {key}"))) +} + +fn require_i64(args: &serde_json::Value, key: &str) -> Result { + args[key] + .as_i64() + .ok_or_else(|| PluginError::new("cloud_cli_parse", format!("missing required argument: {key}"))) +} + +fn not_logged_in() -> PluginError { + PluginError::new("cloud_not_logged_in", "not logged in; run `hm cloud login`") +} + +fn active_org(ctx: &PluginContext<'_>) -> Result { + CloudState::load(ctx).active_org.ok_or_else(|| { + PluginError::new( + "cloud_no_active_org", + "no active organization; run `hm cloud org switch `", + ) + }) +} diff --git a/crates/hm/plugins/hm-plugin-cloud/src/verbs/build.rs b/crates/hm/plugins/hm-plugin-cloud/src/verbs/build.rs new file mode 100644 index 0000000..722fa59 --- /dev/null +++ b/crates/hm/plugins/hm-plugin-cloud/src/verbs/build.rs @@ -0,0 +1,183 @@ +//! `hm cloud build list|show|cancel|watch`. + +use std::collections::BTreeMap; + +use hm_plugin_protocol::PluginError; +use hm_plugin_sdk::PluginContext; + +use crate::api::types::{Build, BuildList}; +use crate::config::Config; +use crate::creds; +use crate::http::Client; +use crate::state::CloudState; + +pub(crate) async fn run( + ctx: &PluginContext<'_>, + env: &BTreeMap, + verb: &str, + args: &serde_json::Value, +) -> Result<(), PluginError> { + let cfg = Config::from_env(env); + let token = creds::load_token(&cfg.api_base, env).ok_or_else(not_logged_in)?; + let client = Client::new(&cfg, Some(token)); + let org = active_org(ctx)?; + + match verb { + "list" => { + let pipeline = require_str(args, "pipeline")?; + list(ctx, &client, &org, &pipeline).await + } + "show" => { + let pipeline = require_str(args, "pipeline")?; + let number = require_i64(args, "number")?; + show(ctx, &client, &org, &pipeline, number).await + } + "cancel" => { + let pipeline = require_str(args, "pipeline")?; + let number = require_i64(args, "number")?; + cancel(ctx, &client, &org, &pipeline, number).await + } + "watch" => { + let pipeline = require_str(args, "pipeline")?; + let number = require_i64(args, "number")?; + watch(ctx, &client, &org, &pipeline, number).await + } + _ => Err(PluginError::new( + "cloud_unknown_verb", + format!("unknown build verb: {verb}"), + )), + } +} + +pub(crate) async fn watch_build( + ctx: &PluginContext<'_>, + env: &BTreeMap, + pipeline: &str, + number: i64, +) -> Result<(), PluginError> { + let cfg = Config::from_env(env); + let token = creds::load_token(&cfg.api_base, env).ok_or_else(not_logged_in)?; + let client = Client::new(&cfg, Some(token)); + let org = active_org(ctx)?; + watch(ctx, &client, &org, pipeline, number).await +} + +async fn list( + ctx: &PluginContext<'_>, + client: &Client, + org: &str, + pipe: &str, +) -> Result<(), PluginError> { + let builds: BuildList = client + .get(&format!("/organizations/{org}/pipelines/{pipe}/builds")) + .await?; + for b in &builds.data { + let line = format!( + "#{:<5} {:<10} {}\n", + b.number, + b.state, + b.message.as_deref().unwrap_or("") + ); + ctx.write_stdout(line.as_bytes()); + } + Ok(()) +} + +async fn show( + ctx: &PluginContext<'_>, + client: &Client, + org: &str, + pipe: &str, + number: i64, +) -> Result<(), PluginError> { + let b: Build = client + .get(&format!( + "/organizations/{org}/pipelines/{pipe}/builds/{number}" + )) + .await?; + let json = serde_json::to_string_pretty(&b).unwrap_or_default(); + ctx.write_stdout(json.as_bytes()); + ctx.write_stdout(b"\n"); + Ok(()) +} + +async fn cancel( + ctx: &PluginContext<'_>, + 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!({}), + ) + .await?; + ctx.write_stderr(format!("build #{number} cancelled\n").as_bytes()); + Ok(()) +} + +async fn watch( + ctx: &PluginContext<'_>, + client: &Client, + org: &str, + pipe: &str, + number: i64, +) -> Result<(), PluginError> { + let mut last_state = String::new(); + loop { + if ctx.should_cancel() { + return Err(PluginError::new( + "cloud_cancelled", + "watch cancelled by user", + )); + } + let b: Build = client + .get(&format!( + "/organizations/{org}/pipelines/{pipe}/builds/{number}" + )) + .await?; + if b.state != last_state { + ctx.write_stderr(format!("state: {last_state} -> {}\n", b.state).as_bytes()); + 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), + )); + } + _ => {} + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } +} + +fn require_str(args: &serde_json::Value, key: &str) -> Result { + args[key] + .as_str() + .map(String::from) + .ok_or_else(|| PluginError::new("cloud_cli_parse", format!("missing required argument: {key}"))) +} + +fn require_i64(args: &serde_json::Value, key: &str) -> Result { + args[key] + .as_i64() + .ok_or_else(|| PluginError::new("cloud_cli_parse", format!("missing required argument: {key}"))) +} + +fn not_logged_in() -> PluginError { + PluginError::new("cloud_not_logged_in", "not logged in; run `hm cloud login`") +} + +fn active_org(ctx: &PluginContext<'_>) -> Result { + CloudState::load(ctx).active_org.ok_or_else(|| { + PluginError::new( + "cloud_no_active_org", + "no active organization; run `hm cloud org switch `", + ) + }) +} diff --git a/crates/hm/plugins/hm-plugin-cloud/src/verbs/job.rs b/crates/hm/plugins/hm-plugin-cloud/src/verbs/job.rs new file mode 100644 index 0000000..0aebdcc --- /dev/null +++ b/crates/hm/plugins/hm-plugin-cloud/src/verbs/job.rs @@ -0,0 +1,140 @@ +//! `hm cloud job list|show|log`. + +use std::collections::BTreeMap; + +use hm_plugin_protocol::PluginError; +use hm_plugin_sdk::PluginContext; + +use crate::api::types::{Job, JobList, JobLog}; +use crate::config::Config; +use crate::creds; +use crate::http::Client; +use crate::state::CloudState; + +pub(crate) async fn run( + ctx: &PluginContext<'_>, + env: &BTreeMap, + verb: &str, + args: &serde_json::Value, +) -> Result<(), PluginError> { + let cfg = Config::from_env(env); + let token = creds::load_token(&cfg.api_base, env).ok_or_else(not_logged_in)?; + let client = Client::new(&cfg, Some(token)); + let org = active_org(ctx)?; + + match verb { + "list" => { + let pipeline = require_str(args, "pipeline")?; + let build = require_i64(args, "build")?; + list(ctx, &client, &org, &pipeline, build).await + } + "show" => { + let pipeline = require_str(args, "pipeline")?; + let build = require_i64(args, "build")?; + let job_id = require_str(args, "job_id")?; + show(ctx, &client, &org, &pipeline, build, &job_id).await + } + "log" => { + let pipeline = require_str(args, "pipeline")?; + let build = require_i64(args, "build")?; + let job_id = require_str(args, "job_id")?; + log(ctx, &client, &org, &pipeline, build, &job_id).await + } + _ => Err(PluginError::new( + "cloud_unknown_verb", + format!("unknown job verb: {verb}"), + )), + } +} + +async fn list( + ctx: &PluginContext<'_>, + client: &Client, + org: &str, + pipe: &str, + build: i64, +) -> Result<(), PluginError> { + let jobs: JobList = client + .get(&format!( + "/organizations/{org}/pipelines/{pipe}/builds/{build}/jobs" + )) + .await?; + for j in &jobs.data { + let line = format!( + "{} {:<10} {}\n", + j.id, + j.state, + j.label.as_deref().unwrap_or("") + ); + ctx.write_stdout(line.as_bytes()); + } + Ok(()) +} + +async fn show( + ctx: &PluginContext<'_>, + 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}" + )) + .await?; + ctx.write_stdout( + serde_json::to_string_pretty(&j) + .unwrap_or_default() + .as_bytes(), + ); + ctx.write_stdout(b"\n"); + Ok(()) +} + +async fn log( + ctx: &PluginContext<'_>, + 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" + )) + .await?; + for chunk in &log.data { + ctx.write_stdout(chunk.line.as_bytes()); + ctx.write_stdout(b"\n"); + } + Ok(()) +} + +fn require_str(args: &serde_json::Value, key: &str) -> Result { + args[key] + .as_str() + .map(String::from) + .ok_or_else(|| PluginError::new("cloud_cli_parse", format!("missing required argument: {key}"))) +} + +fn require_i64(args: &serde_json::Value, key: &str) -> Result { + args[key] + .as_i64() + .ok_or_else(|| PluginError::new("cloud_cli_parse", format!("missing required argument: {key}"))) +} + +fn not_logged_in() -> PluginError { + PluginError::new("cloud_not_logged_in", "not logged in; run `hm cloud login`") +} + +fn active_org(ctx: &PluginContext<'_>) -> Result { + CloudState::load(ctx).active_org.ok_or_else(|| { + PluginError::new( + "cloud_no_active_org", + "no active organization; run `hm cloud org switch `", + ) + }) +} diff --git a/crates/hm-plugin-cloud/src/verbs/mod.rs b/crates/hm/plugins/hm-plugin-cloud/src/verbs/mod.rs similarity index 61% rename from crates/hm-plugin-cloud/src/verbs/mod.rs rename to crates/hm/plugins/hm-plugin-cloud/src/verbs/mod.rs index f8a9a69..5d37a87 100644 --- a/crates/hm-plugin-cloud/src/verbs/mod.rs +++ b/crates/hm/plugins/hm-plugin-cloud/src/verbs/mod.rs @@ -1,6 +1,6 @@ //! Verb implementations for `hm cloud `. Each module -//! exposes a `run(env, cmd)` entry point that `cli::dispatch` calls -//! after argv has been parsed. +//! exposes a `run(ctx, env, verb, args)` entry point that +//! `cli::dispatch` calls with JSON args extracted by the host. pub(crate) mod billing; pub(crate) mod build; diff --git a/crates/hm-plugin-cloud/src/verbs/org.rs b/crates/hm/plugins/hm-plugin-cloud/src/verbs/org.rs similarity index 52% rename from crates/hm-plugin-cloud/src/verbs/org.rs rename to crates/hm/plugins/hm-plugin-cloud/src/verbs/org.rs index fb54abd..b160e2c 100644 --- a/crates/hm-plugin-cloud/src/verbs/org.rs +++ b/crates/hm/plugins/hm-plugin-cloud/src/verbs/org.rs @@ -3,37 +3,54 @@ use std::collections::BTreeMap; use hm_plugin_protocol::PluginError; -use hm_plugin_sdk::host; +use hm_plugin_sdk::PluginContext; use crate::api::types::OrganizationList; -use crate::cli::OrgCommand; use crate::config::Config; 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( + ctx: &PluginContext<'_>, + env: &BTreeMap, + verb: &str, + args: &serde_json::Value, +) -> Result<(), PluginError> { let cfg = Config::from_env(env); let token = creds::load_token(&cfg.api_base, env).ok_or_else(not_logged_in)?; let client = Client::new(&cfg, Some(token)); - match cmd { - OrgCommand::Switch { slug } => switch(&client, &slug), + match verb { + "switch" => { + let slug = args["slug"].as_str().ok_or_else(|| { + PluginError::new("cloud_cli_parse", "missing required argument: slug") + })?; + switch(ctx, &client, slug).await + } + _ => Err(PluginError::new( + "cloud_unknown_verb", + format!("unknown org verb: {verb}"), + )), } } -fn switch(client: &Client, slug: &str) -> Result<(), PluginError> { - let orgs: OrganizationList = client.get("/organizations")?; +async fn switch( + ctx: &PluginContext<'_>, + client: &Client, + slug: &str, +) -> Result<(), PluginError> { + 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}'"), ) })?; - let mut state = CloudState::load(); + let mut state = CloudState::load(ctx); state.active_org = Some(found.slug.clone()); - state.save(); - host::write_stderr( + state.save(ctx); + ctx.write_stderr( format!("active organization: {} ({})\n", found.name, found.slug).as_bytes(), ); Ok(()) diff --git a/crates/hm-plugin-cloud/src/verbs/pipeline.rs b/crates/hm/plugins/hm-plugin-cloud/src/verbs/pipeline.rs similarity index 50% rename from crates/hm-plugin-cloud/src/verbs/pipeline.rs rename to crates/hm/plugins/hm-plugin-cloud/src/verbs/pipeline.rs index bb134dd..f499ab7 100644 --- a/crates/hm-plugin-cloud/src/verbs/pipeline.rs +++ b/crates/hm/plugins/hm-plugin-cloud/src/verbs/pipeline.rs @@ -3,42 +3,64 @@ use std::collections::BTreeMap; use hm_plugin_protocol::PluginError; -use hm_plugin_sdk::host; +use hm_plugin_sdk::PluginContext; use crate::api::types::{Pipeline, PipelineList}; -use crate::cli::PipelineCommand; use crate::config::Config; 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( + ctx: &PluginContext<'_>, + env: &BTreeMap, + verb: &str, + args: &serde_json::Value, +) -> Result<(), PluginError> { let cfg = Config::from_env(env); let token = creds::load_token(&cfg.api_base, env).ok_or_else(not_logged_in)?; let client = Client::new(&cfg, Some(token)); - let org = active_org()?; + let org = active_org(ctx)?; - match cmd { - PipelineCommand::List => list(&client, &org), - PipelineCommand::Show { slug } => show(&client, &org, &slug), + match verb { + "list" => list(ctx, &client, &org).await, + "show" => { + let slug = args["slug"].as_str().ok_or_else(|| { + PluginError::new("cloud_cli_parse", "missing required argument: slug") + })?; + show(ctx, &client, &org, slug).await + } + _ => Err(PluginError::new( + "cloud_unknown_verb", + format!("unknown pipeline verb: {verb}"), + )), } } -fn list(client: &Client, org: &str) -> Result<(), PluginError> { - let pipes: PipelineList = client.get(&format!("/organizations/{org}/pipelines"))?; +async fn list( + ctx: &PluginContext<'_>, + client: &Client, + org: &str, +) -> Result<(), PluginError> { + let pipes: PipelineList = client.get(&format!("/organizations/{org}/pipelines")).await?; for p in &pipes.data { let line = format!( "{:<24} {}\n", p.slug, p.label.as_deref().unwrap_or("(no label)") ); - host::write_stdout(line.as_bytes()); + ctx.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( + ctx: &PluginContext<'_>, + client: &Client, + org: &str, + slug: &str, +) -> Result<(), PluginError> { + 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,13 +68,13 @@ 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"); + ctx.write_stdout(json.as_bytes()); + ctx.write_stdout(b"\n"); Ok(()) } -fn active_org() -> Result { - CloudState::load().active_org.ok_or_else(|| { +fn active_org(ctx: &PluginContext<'_>) -> Result { + CloudState::load(ctx).active_org.ok_or_else(|| { PluginError::new( "cloud_no_active_org", "no active organization; run `hm cloud org switch `", diff --git a/crates/hm/plugins/hm-plugin-cloud/src/verbs/run.rs b/crates/hm/plugins/hm-plugin-cloud/src/verbs/run.rs new file mode 100644 index 0000000..ca118d7 --- /dev/null +++ b/crates/hm/plugins/hm-plugin-cloud/src/verbs/run.rs @@ -0,0 +1,87 @@ +//! `hm cloud run` — submit the local pipeline plan to the cloud +//! and watch the resulting build. + +use std::collections::BTreeMap; + +use hm_plugin_protocol::PluginError; +use hm_plugin_sdk::PluginContext; + +use crate::api::types::{Build, CreateBuildRequest}; +use crate::config::Config; +use crate::creds; +use crate::http::Client; +use crate::state::CloudState; + +pub(crate) async fn run( + ctx: &PluginContext<'_>, + env: &BTreeMap, + args: &serde_json::Value, +) -> Result<(), PluginError> { + let pipeline = require_str(args, "pipeline")?; + let branch = args.get("branch").and_then(serde_json::Value::as_str); + let message = args.get("message").and_then(serde_json::Value::as_str); + let plan_file = args.get("plan_file").and_then(serde_json::Value::as_str); + let no_watch = args + .get("no_watch") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + + 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 client = Client::new(&cfg, Some(token)); + let org = CloudState::load(ctx).active_org.ok_or_else(|| { + PluginError::new( + "cloud_no_active_org", + "no active organization; run `hm cloud org switch `", + ) + })?; + + let plan_path = plan_file.unwrap_or("plan.json"); + let bytes = ctx.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 plan_json: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|e| PluginError::new("cloud_plan_invalid_json", e.to_string()))?; + + let req = CreateBuildRequest { + pipeline_slug: pipeline.clone(), + branch: branch.map(String::from), + message: message.map(String::from), + env: env + .iter() + .filter(|(k, _)| k.starts_with("HM_RUN_ENV_")) + .map(|(k, v)| (k.trim_start_matches("HM_RUN_ENV_").to_string(), v.clone())) + .collect(), + plan_json, + }; + let build: Build = client + .post( + &format!("/organizations/{org}/pipelines/{pipeline}/builds"), + &req, + ) + .await?; + let url = format!( + "{}/{}/{}/builds/{}", + cfg.api_base.trim_end_matches("/api"), + org, + pipeline, + build.number + ); + ctx.write_stderr(format!("submitted build #{}: {url}\n", build.number).as_bytes()); + if no_watch { + return Ok(()); + } + super::build::watch_build(ctx, env, &pipeline, build.number).await +} + +fn require_str(args: &serde_json::Value, key: &str) -> Result { + args[key] + .as_str() + .map(String::from) + .ok_or_else(|| PluginError::new("cloud_cli_parse", format!("missing required argument: {key}"))) +} diff --git a/crates/hm-plugin-docker/Cargo.toml b/crates/hm/plugins/hm-plugin-docker/Cargo.toml similarity index 72% rename from crates/hm-plugin-docker/Cargo.toml rename to crates/hm/plugins/hm-plugin-docker/Cargo.toml index ab1995a..8008957 100644 --- a/crates/hm-plugin-docker/Cargo.toml +++ b/crates/hm/plugins/hm-plugin-docker/Cargo.toml @@ -14,10 +14,13 @@ path = "src/lib.rs" [dependencies] hm-plugin-sdk = { workspace = true } hm-plugin-protocol = { workspace = true } -extism-pdk = { workspace = true } +stabby = { workspace = true } +borsh = { workspace = true } serde = { workspace = true } -serde_json = { workspace = true } semver = { workspace = true } +bollard = "0.18" +tokio = { workspace = true } +futures-util = "0.3" [lints] workspace = true diff --git a/crates/hm-plugin-docker/src/decision.rs b/crates/hm/plugins/hm-plugin-docker/src/decision.rs similarity index 100% rename from crates/hm-plugin-docker/src/decision.rs rename to crates/hm/plugins/hm-plugin-docker/src/decision.rs diff --git a/crates/hm/plugins/hm-plugin-docker/src/docker.rs b/crates/hm/plugins/hm-plugin-docker/src/docker.rs new file mode 100644 index 0000000..60274f6 --- /dev/null +++ b/crates/hm/plugins/hm-plugin-docker/src/docker.rs @@ -0,0 +1,298 @@ +//! Bollard-based Docker client for the step-executor plugin. +//! +//! Ported from the host-side `docker_client.rs`. The key difference is +//! that exec output is streamed through [`PluginContext`] rather than +//! an [`AsyncWrite`] sink. + +use std::collections::HashMap; +use std::sync::Arc; + +use bollard::Docker; +use bollard::container::{ + Config, CreateContainerOptions, RemoveContainerOptions, StartContainerOptions, + StopContainerOptions, +}; +use bollard::exec::{CreateExecOptions, StartExecResults}; +use bollard::image::{CommitContainerOptions, CreateImageOptions, ListImagesOptions}; +use futures_util::StreamExt; +use hm_plugin_protocol::{ArchiveId, PluginError}; +use hm_plugin_sdk::PluginContext; +use tokio::io::AsyncWriteExt; + +#[derive(Debug, Clone)] +pub(crate) struct DockerClient { + inner: Arc, +} + +impl DockerClient { + /// Connect to the local Docker daemon using platform defaults. + pub(crate) fn connect() -> Result { + let d = Docker::connect_with_local_defaults() + .map_err(|e| PluginError::new("docker_connect", format!("connect: {e}")))?; + Ok(Self { inner: Arc::new(d) }) + } + + /// True if `tag` resolves to a locally-cached image. + pub(crate) async fn image_exists(&self, tag: &str) -> Result { + let mut filters = HashMap::new(); + filters.insert("reference".to_string(), vec![tag.to_string()]); + let images = self + .inner + .list_images(Some(ListImagesOptions { + filters, + ..Default::default() + })) + .await + .map_err(|e| PluginError::new("docker_image_exists", format!("list_images: {e}")))?; + Ok(!images.is_empty()) + } + + /// Pull `tag` from its registry, draining the progress stream. + pub(crate) async fn pull_image(&self, tag: &str) -> Result<(), PluginError> { + let mut s = self.inner.create_image( + Some(CreateImageOptions { + from_image: tag, + ..Default::default() + }), + None, + None, + ); + while let Some(item) = s.next().await { + item.map_err(|e| PluginError::new("docker_pull", format!("pull {tag}: {e}")))?; + } + Ok(()) + } + + /// Start a long-lived container running `sleep infinity`. + /// Returns the container ID. + pub(crate) async fn start_long_lived( + &self, + image: &str, + env: &[String], + workdir: &str, + name: &str, + ) -> Result { + let cfg = Config { + image: Some(image.to_string()), + cmd: Some(vec!["sh".into(), "-c".into(), "sleep infinity".into()]), + env: Some(env.to_vec()), + working_dir: Some(workdir.to_string()), + ..Default::default() + }; + let create = self + .inner + .create_container( + Some(CreateContainerOptions { + name, + ..Default::default() + }), + cfg, + ) + .await + .map_err(|e| PluginError::new("docker_start", format!("create_container: {e}")))?; + self.inner + .start_container(&create.id, None::>) + .await + .map_err(|e| PluginError::new("docker_start", format!("start_container: {e}")))?; + Ok(create.id) + } + + /// Exec a command inside a running container, streaming + /// stdout/stderr through the plugin context. Returns the exit code. + pub(crate) async fn exec_streaming( + &self, + container_id: &str, + cmd: &[String], + env: &[String], + workdir: &str, + ctx: &PluginContext<'_>, + ) -> Result { + use bollard::container::LogOutput; + + let exec = self + .inner + .create_exec( + container_id, + CreateExecOptions { + cmd: Some(cmd.iter().map(String::as_str).collect()), + env: Some(env.iter().map(String::as_str).collect()), + working_dir: Some(workdir), + attach_stdout: Some(true), + attach_stderr: Some(true), + ..Default::default() + }, + ) + .await + .map_err(|e| PluginError::new("docker_exec", format!("create_exec: {e}")))?; + + match self + .inner + .start_exec(&exec.id, None) + .await + .map_err(|e| PluginError::new("docker_exec", format!("start_exec: {e}")))? + { + StartExecResults::Attached { mut output, .. } => { + while let Some(item) = output.next().await { + let chunk = + item.map_err(|e| PluginError::new("docker_exec", format!("exec stream: {e}")))?; + match chunk { + LogOutput::StdOut { message } => { + ctx.emit_step_log_stdout(&message); + } + LogOutput::StdErr { message } => { + ctx.emit_step_log_stderr(&message); + } + LogOutput::Console { message } => { + ctx.emit_step_log_stdout(&message); + } + LogOutput::StdIn { .. } => { + // StdIn frames echoed by some daemons; ignore. + } + } + } + } + StartExecResults::Detached => {} + } + + let inspect = self + .inner + .inspect_exec(&exec.id) + .await + .map_err(|e| PluginError::new("docker_exec", format!("inspect_exec: {e}")))?; + let code = inspect.exit_code.unwrap_or(0); + Ok(i32::try_from(code).unwrap_or(1)) + } + + /// Extract the workspace archive into a container. + /// + /// Reads the archive from the host via `ctx.archive_read()` in + /// chunks, then pipes the bytes into `tar -xzf -` via exec stdin. + pub(crate) async fn extract_workspace( + &self, + ctx: &PluginContext<'_>, + container_id: &str, + archive_id: &ArchiveId, + workdir: &str, + ) -> Result<(), PluginError> { + // Read the full archive from the host in chunks. + let total = ctx.archive_total_size(archive_id); + let chunk_size: u64 = 256 * 1024; // 256 KiB chunks + let mut archive_bytes = Vec::with_capacity(total as usize); + let mut offset: u64 = 0; + while offset < total { + let chunk = ctx.archive_read(archive_id, offset, chunk_size); + if chunk.is_empty() { + break; + } + offset += chunk.len() as u64; + archive_bytes.extend_from_slice(&chunk); + } + + // Pipe the archive into `tar -xzf -` inside the container. + let cmd: Vec = vec!["tar".into(), "-xzf".into(), "-".into()]; + let exec = self + .inner + .create_exec( + container_id, + CreateExecOptions { + cmd: Some(cmd.iter().map(String::as_str).collect()), + env: Some(Vec::new()), + working_dir: Some(workdir), + attach_stdin: Some(true), + attach_stdout: Some(true), + attach_stderr: Some(true), + ..Default::default() + }, + ) + .await + .map_err(|e| PluginError::new("docker_extract", format!("create_exec: {e}")))?; + + match self + .inner + .start_exec(&exec.id, None) + .await + .map_err(|e| PluginError::new("docker_extract", format!("start_exec: {e}")))? + { + StartExecResults::Attached { + mut output, + mut input, + } => { + input + .write_all(&archive_bytes) + .await + .map_err(|e| PluginError::new("docker_extract", format!("write stdin: {e}")))?; + input + .shutdown() + .await + .map_err(|e| PluginError::new("docker_extract", format!("close stdin: {e}")))?; + drop(input); + // Drain output (tar may write warnings to stderr). + while let Some(item) = output.next().await { + let _chunk = item.map_err(|e| { + PluginError::new("docker_extract", format!("exec stream: {e}")) + })?; + } + } + StartExecResults::Detached => {} + } + + let inspect = self + .inner + .inspect_exec(&exec.id) + .await + .map_err(|e| PluginError::new("docker_extract", format!("inspect_exec: {e}")))?; + let code = inspect.exit_code.unwrap_or(0); + if code != 0 { + return Err(PluginError::new( + "docker_extract", + format!("tar exited with code {code}"), + )); + } + Ok(()) + } + + /// Commit a running container to an image tag. + pub(crate) async fn commit_container( + &self, + container_id: &str, + tag: &str, + ) -> Result<(), PluginError> { + let parts: Vec<&str> = tag.splitn(2, ':').collect(); + let (repo, ver) = match parts.as_slice() { + [r, v] => (*r, *v), + [r] => (*r, "latest"), + _ => unreachable!("splitn(2) yields one or two parts for non-empty input"), + }; + let opts = CommitContainerOptions { + container: container_id, + repo, + tag: ver, + ..Default::default() + }; + self.inner + .commit_container(opts, Config::::default()) + .await + .map_err(|e| PluginError::new("docker_commit", format!("commit_container: {e}")))?; + Ok(()) + } + + /// Stop and force-remove a container. Best-effort; errors are + /// silently swallowed. + pub(crate) async fn stop_remove(&self, container_id: &str) { + let _ = self + .inner + .stop_container(container_id, Some(StopContainerOptions { t: 0 })) + .await; + let _ = self + .inner + .remove_container( + container_id, + Some(RemoveContainerOptions { + force: true, + v: true, + ..Default::default() + }), + ) + .await; + } +} diff --git a/crates/hm-plugin-docker/src/image_name.rs b/crates/hm/plugins/hm-plugin-docker/src/image_name.rs similarity index 100% rename from crates/hm-plugin-docker/src/image_name.rs rename to crates/hm/plugins/hm-plugin-docker/src/image_name.rs diff --git a/crates/hm/plugins/hm-plugin-docker/src/lib.rs b/crates/hm/plugins/hm-plugin-docker/src/lib.rs new file mode 100644 index 0000000..96b40e8 --- /dev/null +++ b/crates/hm/plugins/hm-plugin-docker/src/lib.rs @@ -0,0 +1,165 @@ +//! Built-in Docker step-executor plugin for the hm CLI. +//! +//! Uses bollard to drive the local Docker daemon directly. The plugin +//! streams exec output through the host's event bus via +//! `PluginContext::emit_step_log_stdout()` / `emit_step_log_stderr()`. + +#![allow(unsafe_code)] +#![allow( + clippy::pedantic, + clippy::nursery, + clippy::cargo, + clippy::multiple_crate_versions, + clippy::cargo_common_metadata, + clippy::missing_errors_doc, +)] + +use core::future::Future; +use hm_plugin_sdk::*; + +mod decision; +mod docker; +mod image_name; + +#[derive(Default)] +struct DockerExec; + +impl StepExecutor for DockerExec { + fn run<'a>( + &'a self, + ctx: &'a PluginContext<'a>, + input: ExecutorInput, + ) -> impl Future> + Send + 'a { + run_step(ctx, input) + } +} + +async fn run_step( + ctx: &PluginContext<'_>, + input: ExecutorInput, +) -> Result { + use crate::decision::plan; + use crate::image_name::resolve_image; + + let plan = 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 client = docker::DockerClient::connect()?; + + 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 image is locally available. + if !client.image_exists(&image).await? { + ctx.log(Level::Info, &format!("pulling {image}")); + client.pull_image(&image).await?; + } + + // Convert BTreeMap env to Vec for bollard ("KEY=VALUE" format). + let env_vec: Vec = input.env.iter().map(|(k, v)| format!("{k}={v}")).collect(); + + let cid = client + .start_long_lived(&image, &env_vec, &input.workdir, &container_name) + .await?; + + // Run the step inside the container; always clean up afterward. + let result = run_in_container(&client, ctx, &input, &cid, &env_vec, &plan).await; + client.stop_remove(&cid).await; + result +} + +async fn run_in_container( + client: &docker::DockerClient, + ctx: &PluginContext<'_>, + input: &ExecutorInput, + cid: &str, + env_vec: &[String], + plan: &decision::DecisionPlan, +) -> Result { + // Extract workspace archive into container. + client + .extract_workspace(ctx, cid, &input.workspace_archive_id, &input.workdir) + .await?; + + // Exec the step command. + let cmd = vec!["sh".into(), "-c".into(), input.step.cmd.clone()]; + let exit_code = client + .exec_streaming(cid, &cmd, env_vec, &input.workdir, ctx) + .await?; + + // Commit 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(); + hm_plugin_protocol::SnapshotRef(format!( + "harmont-local-ephemeral/{safe}:run-{}", + input.step_id.simple() + )) + }); + client.commit_container(cid, &target_tag.0).await?; + Some(target_tag) + } else { + None + }; + + 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}") +} + +hm_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, + })], + config_schema: None, + }, + executor = DockerExec, +); diff --git a/crates/hm/src/cli/external.rs b/crates/hm/src/cli/external.rs index b0f9e23..1d0113a 100644 --- a/crates/hm/src/cli/external.rs +++ b/crates/hm/src/cli/external.rs @@ -4,48 +4,33 @@ use anyhow::{Context, Result}; use hm_plugin_protocol::{ExitInfo, SubcommandInput}; use crate::error::HmError; -use crate::plugin::{PluginRegistry, RegistryConfig}; +use crate::plugin::PluginRegistry; -/// Run a plugin-provided external subcommand. +/// Run a plugin-provided subcommand with host-parsed arguments. +/// +/// The caller (the two-phase parser in `main`) has already matched the +/// verb against the augmented `clap::Command` and extracted typed args +/// via [`hm_plugin_runtime::clap_bridge::extract_args`]. /// /// # 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")?; - +pub async fn run_parsed( + verb: &str, + verb_path: Vec, + args: serde_json::Value, + registry: &PluginRegistry, +) -> Result { let idx = registry - .subcommand_index - .get(&verb) - .copied() + .capabilities + .resolve_subcommand(verb) .ok_or_else(|| HmError::UnknownVerb { - verb: verb.clone(), - available: registry.subcommand_index.keys().cloned().collect(), + verb: verb.to_owned(), + available: registry + .capabilities + .available_subcommands() + .map(Into::into) + .collect(), })?; let plugin = registry @@ -57,13 +42,13 @@ pub async fn run(argv: Vec) -> Result { .collect(); let input = SubcommandInput { - verb_path: argv.clone(), - args: serde_json::Value::Null, // plugin parses raw argv itself + verb_path, + args: args.into(), env, }; let info: ExitInfo = plugin - .call_capability("hm_subcommand_run", &input) + .run_subcommand(&input) .await .with_context(|| format!("invoke plugin for verb '{verb}'"))?; diff --git a/crates/hm/src/cli/mod.rs b/crates/hm/src/cli/mod.rs index d4ecbfd..4ac2968 100644 --- a/crates/hm/src/cli/mod.rs +++ b/crates/hm/src/cli/mod.rs @@ -9,7 +9,8 @@ pub use plugin::PluginCommand; pub use run::RunArgs; use anyhow::Result; -use clap::{Parser, Subcommand}; +use clap::{CommandFactory, Parser, Subcommand}; +use hm_plugin_protocol::SubcommandSpec; use crate::context::RunContext; @@ -58,11 +59,19 @@ pub enum Command { /// `@hm.deploy`-decorated functions and brings them up via Docker. #[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), +impl Cli { + /// Build a `clap::Command` with plugin subcommands appended to + /// the derive-defined built-in set. + #[must_use] + pub fn command_with_plugins(plugin_specs: &[SubcommandSpec]) -> clap::Command { + let mut cmd = Self::command(); + for spec in plugin_specs { + cmd = cmd.subcommand(hm_plugin_runtime::clap_bridge::build_command(spec)); + } + cmd + } } /// Dispatch a parsed CLI command to the appropriate handler. Returns an exit code. @@ -76,7 +85,6 @@ 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, } } diff --git a/crates/hm/src/cli/plugin.rs b/crates/hm/src/cli/plugin.rs index eeaeb97..053f692 100644 --- a/crates/hm/src/cli/plugin.rs +++ b/crates/hm/src/cli/plugin.rs @@ -1,11 +1,11 @@ use anyhow::{Context, Result}; use clap::Subcommand; -use crate::plugin::{PluginRegistry, RegistryConfig, paths}; +use crate::plugin::{PluginRegistry, RegistryConfig}; #[derive(Debug, Clone, Subcommand)] pub enum PluginCommand { - /// List installed plugins (embedded + user + project). + /// List installed plugins (user + project). List, /// Show one plugin's manifest in detail. @@ -18,7 +18,7 @@ pub enum PluginCommand { /// /// HTTPS URLs require `--pin ` for integrity. Install { - /// Plugin source: local path (`./foo.wasm`) or HTTPS URL. + /// Plugin source: local path (`./foo.dylib`) or HTTPS URL. source: String, /// SHA-256 hex digest to verify against. Required for HTTPS @@ -48,20 +48,20 @@ pub async fn run(cmd: PluginCommand) -> Result<()> { } } -#[allow(clippy::unused_async)] async fn list() -> Result<()> { let reg = PluginRegistry::load(RegistryConfig { auto_discover: true, ..Default::default() - })?; + }) + .await?; if reg.manifests().count() == 0 { println!("No plugins installed."); println!(); println!("Plugins live in:"); - if let Some(p) = paths::user_plugins_dir() { + if let Some(p) = hm_util::dirs::harmont_user_plugins_dir() { println!(" {}", p.display()); } - if let Some(p) = paths::project_plugins_dir() { + if let Some(p) = hm_util::dirs::harmont_project_plugins_dir() { println!(" {}", p.display()); } println!(); @@ -76,12 +76,12 @@ async fn list() -> Result<()> { Ok(()) } -#[allow(clippy::unused_async)] async fn info(name: &str) -> Result<()> { let reg = PluginRegistry::load(RegistryConfig { auto_discover: true, ..Default::default() - })?; + }) + .await?; let m = reg .manifests() .find(|m| m.name == name) @@ -99,8 +99,9 @@ async fn install_cmd(source: &str, pin: Option<&str>) -> Result<()> { #[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")); + let dir = hm_util::dirs::plugin_install_dir().context("no install dir")?; + let dll_ext = std::env::consts::DLL_EXTENSION; + let target = dir.join(format!("{name}.{dll_ext}")); if !target.is_file() { anyhow::bail!("no plugin file at {}", target.display()); } @@ -110,9 +111,7 @@ async fn remove(name: &str) -> Result<()> { } fn capability_summary(cap: &hm_plugin_protocol::Capability) -> String { - use hm_plugin_protocol::Capability::{ - LifecycleHook, OutputFormatter, StepExecutor, Subcommand, - }; + use hm_plugin_protocol::Capability::{LifecycleHook, StepExecutor, Subcommand}; match cap { Subcommand(s) => format!("subcmd:{}", s.verb), StepExecutor(s) => { @@ -123,6 +122,5 @@ fn capability_summary(cap: &hm_plugin_protocol::Capability) -> String { } } LifecycleHook(_) => "hook".into(), - OutputFormatter(s) => format!("format:{}", s.name), } } diff --git a/crates/hm/src/cli/run.rs b/crates/hm/src/cli/run.rs index 70c5c6e..770a37e 100644 --- a/crates/hm/src/cli/run.rs +++ b/crates/hm/src/cli/run.rs @@ -1,6 +1,12 @@ use clap::Parser; use std::path::PathBuf; +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +pub enum OutputFormat { + Human, + Json, +} + #[derive(Debug, Clone, Parser)] pub struct RunArgs { /// Pipeline slug. Required when the repo declares more than one @@ -33,8 +39,8 @@ pub struct RunArgs { #[arg(long, value_name = "N")] pub parallelism: Option, - /// Output formatter (matches an installed output-formatter plugin - /// `name`). Built-ins: `human`, `json`. Default: `human`. - #[arg(long, value_name = "NAME", default_value = "human", global = false)] - pub format: String, + /// Output format. `human` (default) prints coloured progress to + /// stderr; `json` writes one event per line to stdout. + #[arg(long, value_name = "NAME", default_value = "human")] + pub format: OutputFormat, } diff --git a/crates/hm/src/cli/version.rs b/crates/hm/src/cli/version.rs index 5f07d44..6769156 100644 --- a/crates/hm/src/cli/version.rs +++ b/crates/hm/src/cli/version.rs @@ -3,7 +3,6 @@ use hm_plugin_protocol::HM_PLUGIN_API_VERSION; use crate::plugin::{PluginRegistry, RegistryConfig}; -#[allow(clippy::unused_async)] /// Print version information to stdout. /// /// # Errors @@ -13,7 +12,8 @@ pub async fn run() -> Result<()> { let reg = PluginRegistry::load(RegistryConfig { auto_discover: true, ..Default::default() - })?; + }) + .await?; println!("hm {}", env!("CARGO_PKG_VERSION")); println!("plugin api version: {HM_PLUGIN_API_VERSION}"); let count = reg.manifests().count(); diff --git a/crates/hm/src/commands/run/local.rs b/crates/hm/src/commands/run/local.rs index d05cb28..d8578e6 100644 --- a/crates/hm/src/commands/run/local.rs +++ b/crates/hm/src/commands/run/local.rs @@ -3,6 +3,7 @@ use anyhow::{Context, Result}; use super::render::{ToolPaths, list_pipelines, render_pipeline_json}; use crate::cli::RunArgs; use crate::context::RunContext; +use crate::output::OutputMode; use crate::output::format::banner; /// Execute a v0 IR pipeline locally; return the final container id. @@ -60,7 +61,7 @@ fn decode_plan_to_wire(bytes: &[u8]) -> anyhow::Result Result { +pub async fn handle(args: RunArgs, ctx: RunContext) -> Result { let repo_root = match args.dir.clone() { Some(p) => p, None => std::env::current_dir().context("cannot determine current directory")?, @@ -86,7 +87,12 @@ pub async fn handle(args: RunArgs, _ctx: RunContext) -> Result { } }; - if args.format == "human" { + let format = match args.format { + crate::cli::run::OutputFormat::Human => ctx.output, + crate::cli::run::OutputFormat::Json => OutputMode::Json, + }; + + if format.is_human() { banner("run --local", &format!("slug={slug}")); } @@ -96,7 +102,6 @@ pub async fn handle(args: RunArgs, _ctx: RunContext) -> Result { std::thread::available_parallelism().map_or(4, std::num::NonZeroUsize::get) }); let exit_code = - crate::orchestrator::run(pipeline_wire, repo_root, parallelism, args.format.clone()) - .await?; + crate::orchestrator::run(pipeline_wire, repo_root, parallelism, format).await?; Ok(exit_code) } diff --git a/crates/hm/src/error.rs b/crates/hm/src/error.rs index 6a2d15f..755acc3 100644 --- a/crates/hm/src/error.rs +++ b/crates/hm/src/error.rs @@ -61,50 +61,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(transparent)] + PluginRuntime(#[from] crate::plugin::error::RuntimeError), #[error( "step '{step_key}' requested runner '{runner}', but no plugin provides it (available: {available:?})" @@ -201,13 +159,18 @@ impl HmError { | 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, + // Plugin failures — delegate categorisation to the inner enum. + Self::PluginRuntime(e) => { + use crate::plugin::error::RuntimeError; + match e { + RuntimeError::PluginLoad { .. } + | RuntimeError::PluginManifest { .. } + | RuntimeError::PluginMissingHostFn { .. } + | RuntimeError::PluginConflict { .. } => ErrorCategory::PluginLoad, + RuntimeError::PluginPanic { .. } + | RuntimeError::PluginTimeout { .. } => ErrorCategory::PluginRuntime, + } + } // Pipeline-level invalid config (exit 7). Self::UnknownRunner { .. } | Self::NoDefaultExecutor => ErrorCategory::PipelineInvalid, // Generic build failure: anyhow-wrapped errors propagate here. diff --git a/crates/hm/src/main.rs b/crates/hm/src/main.rs index ae5a96e..1c113e3 100644 --- a/crates/hm/src/main.rs +++ b/crates/hm/src/main.rs @@ -7,7 +7,9 @@ reason = "transitive dependency version conflicts in rand/windows-sys/thiserror chains; not fixable without upstream updates" )] -use clap::Parser; +use std::sync::Arc; + +use clap::{CommandFactory, FromArgMatches}; use owo_colors::OwoColorize; use tracing_subscriber::EnvFilter; @@ -15,13 +17,50 @@ use harmont_cli::cli::{self, Cli}; use harmont_cli::context::RunContext; use harmont_cli::error::{self, HmError}; use harmont_cli::output::status; - +use harmont_cli::plugin::host_api::HostApiImpl; +use harmont_cli::plugin::{PluginRegistry, RegistryConfig}; #[tokio::main] async fn main() { - let args = Cli::parse(); + let code = match run().await { + Ok(code) => code, + Err(e) => handle_error(&e), + }; + + std::process::exit(code); +} + +async fn run() -> Result { + // 1. Best-effort plugin discovery — if it fails we proceed with + // only the built-in subcommands and log a warning later. + let registry = PluginRegistry::load(RegistryConfig { + auto_discover: true, + extra_paths: vec![], + host_api: Arc::new(HostApiImpl::new_noop()), + }) + .await + .ok(); + + let plugin_specs = registry + .as_ref() + .map(PluginRegistry::subcommand_specs) + .unwrap_or_default(); - // Initialize tracing if --verbose. - if args.verbose { + // 2. Build the augmented clap::Command (built-ins + plugin verbs) + // and parse argv once. + let builtins: std::collections::HashSet = Cli::command() + .get_subcommands() + .map(|c| c.get_name().to_owned()) + .collect(); + + let cmd = Cli::command_with_plugins(&plugin_specs); + let matches = cmd.get_matches(); + + // 3. Extract global flags from the matches so we can configure + // tracing and color regardless of which subcommand was matched. + let verbose = matches.get_flag("verbose"); + let no_color = matches.get_flag("no_color"); + + if verbose { tracing_subscriber::fmt() .with_env_filter( EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("debug")), @@ -30,29 +69,36 @@ async fn main() { .init(); } - // Color override propagates to every OwoColorize call site. We - // respect three signals, in priority order: explicit `--no-color`, - // the `NO_COLOR` env var (https://no-color.org), and finally - // TTY-ness of stderr. When stderr isn't a terminal — pipe to a - // file, `head`, or a test harness — turning colors off keeps the - // bytes downstream clean. - let color_enabled = !args.no_color - && std::env::var_os("NO_COLOR").is_none() - && console::Term::stderr().is_term(); + let color_enabled = + !no_color && std::env::var_os("NO_COLOR").is_none() && console::Term::stderr().is_term(); owo_colors::set_override(color_enabled); - let code = match run(args).await { - Ok(code) => code, - Err(e) => handle_error(&e), - }; + // 4. Route: built-in subcommand → derive reconstruction; plugin + // verb → extract args via clap_bridge and forward to the plugin. + let (sub_name, sub_matches) = matches + .subcommand() + .ok_or_else(|| anyhow::anyhow!("no subcommand provided"))?; - std::process::exit(code); -} + if builtins.contains(sub_name) { + // Reconstruct the full Cli struct from the already-parsed + // ArgMatches so built-in handlers keep their typed derive args. + let cli_args = Cli::from_arg_matches(&matches)?; + let command = cli_args.command.clone(); + let ctx = RunContext::from_cli(&cli_args)?; + cli::dispatch(command, ctx).await + } else { + // Plugin subcommand — extract args via the clap bridge. + let mut verb_path = vec![sub_name.to_string()]; + let (sub_path, args) = hm_plugin_runtime::clap_bridge::extract_args(sub_matches); + verb_path.extend(sub_path); -async fn run(args: Cli) -> Result { - let command = args.command.clone(); - let ctx = RunContext::from_cli(&args)?; - cli::dispatch(command, ctx).await + let reg = registry.ok_or_else(|| { + anyhow::anyhow!( + "plugin registry failed to load; cannot dispatch plugin verb '{sub_name}'" + ) + })?; + cli::external::run_parsed(sub_name, verb_path, args, ®).await + } } fn handle_error(err: &anyhow::Error) -> i32 { 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 0f873b0..0000000 --- a/crates/hm/src/orchestrator/docker_host_fns.rs +++ /dev/null @@ -1,253 +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::new(); - - // 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. -struct StepLogWriter { - buf: Vec, -} - -impl StepLogWriter { - fn new() -> Self { - Self { - buf: Vec::with_capacity(8192), - } - } - - 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/events.rs b/crates/hm/src/orchestrator/events.rs index 9c205cf..3d9bb6f 100644 --- a/crates/hm/src/orchestrator/events.rs +++ b/crates/hm/src/orchestrator/events.rs @@ -1,9 +1,8 @@ //! Build-event broadcast channel. //! -//! Subscribers (output formatter plugin, lifecycle hook plugins, -//! the human-readable progress sink) all subscribe to the same -//! channel; the host's `emit_event` / `emit_step_log` host fns -//! publish into it. +//! Subscribers (output subscriber, lifecycle hook plugins) subscribe +//! to the same channel; the host's `emit_event` / `emit_step_log` +//! host fns publish into it. // `new()` returning `Arc` is intentional (the bus is always // shared); `subscribe()` returns a tokio receiver that callers must @@ -35,6 +34,12 @@ impl EventBus { self.tx.subscribe() } + /// Publish an event. Returns the number of subscribers that + /// received it. A return of 0 is normal (no subscribers yet). + pub fn sender(&self) -> broadcast::Sender { + self.tx.clone() + } + /// Publish an event. Returns the number of subscribers that /// received it. A return of 0 is normal (no subscribers yet). pub fn emit(&self, event: BuildEvent) { diff --git a/crates/hm/src/orchestrator/mod.rs b/crates/hm/src/orchestrator/mod.rs index a7e856c..d1d4eac 100644 --- a/crates/hm/src/orchestrator/mod.rs +++ b/crates/hm/src/orchestrator/mod.rs @@ -9,7 +9,6 @@ pub mod archive; pub mod cache; pub mod docker_client; -pub mod docker_host_fns; pub mod events; pub mod graph; pub mod output_subscriber; diff --git a/crates/hm/src/orchestrator/output_subscriber.rs b/crates/hm/src/orchestrator/output_subscriber.rs index b19c5f4..f2336c4 100644 --- a/crates/hm/src/orchestrator/output_subscriber.rs +++ b/crates/hm/src/orchestrator/output_subscriber.rs @@ -1,91 +1,69 @@ -//! Build-event subscriber that dispatches every `BuildEvent` into the -//! selected output-formatter plugin's `hm_output_on_event` capability. +//! Build-event subscriber that renders every `BuildEvent` directly via +//! `BuildEventRenderer` — no plugin dispatch, no FFI. //! -//! 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. +//! Human output goes to stderr; JSON output goes to stdout. Both are +//! written with locked handles so concurrent flushes from other threads +//! do not interleave partial lines. // 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. // - `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 -)] +#![allow(clippy::needless_pass_by_value, clippy::print_stderr)] +use std::io::Write; use std::sync::Arc; use anyhow::Result; use hm_plugin_protocol::BuildEvent; -use tokio::sync::Mutex; use tokio::sync::broadcast::error::RecvError; use super::events::EventBus; -use crate::plugin::PluginRegistry; +use crate::output::OutputMode; +use crate::output::build_events::BuildEventRenderer; /// 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`. +/// `format` controls where output is written: +/// - `OutputMode::Human { .. }` → stderr +/// - `OutputMode::Json` → stdout #[must_use] pub fn spawn( bus: Arc, - registry: Arc>, - format_name: String, + format: OutputMode, ) -> tokio::task::JoinHandle> { let mut rx = bus.subscribe(); tokio::spawn(async move { + let mut renderer = BuildEventRenderer::new(); 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 matches!(event, BuildEvent::BuildEnd { .. }) { - return Ok(()); + let is_end = matches!(event, BuildEvent::BuildEnd { .. }); + let bytes = match &format { + OutputMode::Human { .. } => renderer.render_human(&event), + OutputMode::Json => renderer.render_json(&event), + }; + if !bytes.is_empty() { + match &format { + OutputMode::Human { .. } => { + let stderr = std::io::stderr(); + let mut handle = stderr.lock(); + let _ = handle.write_all(&bytes); + let _ = handle.flush(); } - continue; - }; - let Some(p) = reg.get(idx) else { - if matches!(event, BuildEvent::BuildEnd { .. }) { - return Ok(()); + OutputMode::Json => { + let stdout = std::io::stdout(); + let mut handle = stdout.lock(); + let _ = handle.write_all(&bytes); + let _ = handle.flush(); } - continue; - }; - p - }; - let is_end = matches!(event, BuildEvent::BuildEnd { .. }); - // 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; + } + } 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(()); } } @@ -95,10 +73,6 @@ pub fn spawn( 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)"); } } diff --git a/crates/hm/src/orchestrator/scheduler.rs b/crates/hm/src/orchestrator/scheduler.rs index d65d916..536b199 100644 --- a/crates/hm/src/orchestrator/scheduler.rs +++ b/crates/hm/src/orchestrator/scheduler.rs @@ -18,7 +18,7 @@ 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 + // runner-resolution 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 @@ -40,6 +40,7 @@ use crate::error::HmError; use crate::orchestrator::docker_client::DockerClient; use crate::orchestrator::graph::Graph; use crate::orchestrator::source::build_archive_bytes; +use crate::output::OutputMode; use crate::plugin::{PluginRegistry, RegistryConfig}; use super::archive::ArchiveStore; @@ -61,7 +62,7 @@ pub async fn run( pipeline: hm_plugin_protocol::Pipeline, repo_root: PathBuf, parallelism: usize, - format_name: String, + format: OutputMode, ) -> Result { // Build graph + chains directly from the wire-typed pipeline. let graph = Graph::build(&pipeline).context("build graph")?; @@ -72,7 +73,11 @@ pub async fn run( let bus = EventBus::new(); let archives = ArchiveStore::new(); let cancel = CancellationToken::new(); - let _ctrlc = crate::plugin::signal::install_ctrlc(cancel.clone()); + let ctrlc_cancel = cancel.clone(); + let _ctrlc = tokio::spawn(async move { + let _ = tokio::signal::ctrl_c().await; + ctrlc_cancel.cancel(); + }); // _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})")))?; @@ -98,58 +103,23 @@ pub async fn run( 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); + // Load the plugin registry. Plugins are discovered from + // ~/.harmont/plugins/ and .harmont/plugins/. + let host_api = Arc::new(crate::plugin::host_api::HostApiImpl::new( + bus.sender(), + cancel.clone(), + Some(repo_root.clone()), + )); 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, + host_api, }) + .await .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)); // Cross-chain snapshot lineage. When a step completes, we stash @@ -158,10 +128,9 @@ pub async fn run( // image to boot from. Mirrors legacy `SharedState::node_image`. let node_image: Arc>> = Arc::new(Mutex::new(HashMap::new())); - // 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()); + // Spawn the output subscriber. Renders every BuildEvent directly + // via BuildEventRenderer (no plugin dispatch). + let sink_handle = super::output_subscriber::spawn(bus.clone(), format); // Announce build start. let started_at = chrono::Utc::now(); @@ -364,7 +333,8 @@ async fn run_chain( name } else { let reg = registry.lock().await; - reg.default_runner_name() + reg.capabilities + .default_runner_name() .map_or_else(|| "docker".into(), str::to_string) }; let started = Instant::now(); @@ -376,26 +346,20 @@ async fn run_chain( // Dispatch to the runner-named plugin. Look up the Arc under // the registry lock, drop the lock BEFORE awaiting so other - // chains can dispatch concurrently — the per-plugin pool - // serialises (or parallelises, up to its capacity) calls - // internally. + // chains can dispatch concurrently. let plugin = { let reg = registry.lock().await; let idx = reg - .runner_index - .get(&runner) - .copied() - .or(reg.default_runner) + .capabilities + .resolve_runner(&runner) .ok_or_else(|| HmError::UnknownRunner { step_key: input.step.key.clone(), runner: runner.clone(), - available: reg.runner_index.keys().cloned().collect(), + available: reg.capabilities.available_runners().map(Into::into).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 result: Result = plugin.execute_step(&input).await; let dur_ms = started.elapsed().as_millis() as u64; match result { diff --git a/crates/hm/src/output/build_events.rs b/crates/hm/src/output/build_events.rs new file mode 100644 index 0000000..b01a2ec --- /dev/null +++ b/crates/hm/src/output/build_events.rs @@ -0,0 +1,151 @@ +//! Build-event rendering for human-readable and JSON output modes. +//! +//! The renderer lives in-process and owns its step-key map directly. + +use std::collections::HashMap; + +use hm_plugin_protocol::BuildEvent; +use uuid::Uuid; + +/// Stateful renderer that maps step UUIDs to human-friendly keys and +/// formats [`BuildEvent`]s for either human or JSON output. +pub(crate) struct BuildEventRenderer { + step_keys: HashMap, +} + +impl BuildEventRenderer { + pub(crate) fn new() -> Self { + Self { + step_keys: HashMap::new(), + } + } + + /// Look up the human-readable key for a step, falling back to `"?"`. + fn step_key_for(&self, id: Uuid) -> &str { + self.step_keys + .get(&id) + .map(String::as_str) + .unwrap_or("?") + } + + /// Render a [`BuildEvent`] as human-readable bytes (for stderr). + pub(crate) fn render_human(&mut self, 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, .. } => { + self.step_keys.insert(*step_id, key.clone()); + Vec::new() + } + BuildEvent::StepStart { + step_id, + runner, + image, + } => { + let key = self.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 = self.step_key_for(*step_id); + format!("[{key}] {line}\n").into_bytes() + } + BuildEvent::StepCacheHit { step_id, tag, .. } => { + let key = self.step_key_for(*step_id); + format!("[{key}] cache hit ({tag})\n").into_bytes() + } + BuildEvent::StepEnd { + step_id, + exit_code, + duration_ms, + .. + } => { + let key = self.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(), + } + } + + /// Render a [`BuildEvent`] as a JSON line (for stdout). + pub(crate) fn render_json(&self, ev: &BuildEvent) -> Vec { + let mut buf = serde_json::to_vec(ev).expect("BuildEvent serialization is infallible"); + buf.push(b'\n'); + buf + } +} + +#[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 mut r = BuildEventRenderer::new(); + 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(r.render_human(&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 mut r = BuildEventRenderer::new(); + let step_id = Uuid::new_v4(); + r.render_human(&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(r.render_human(&ev)).unwrap(); + assert_eq!(s, "[build] hello\n"); + } + + #[test] + fn step_log_with_unknown_key_renders_question_mark() { + let mut r = BuildEventRenderer::new(); + let s = String::from_utf8(r.render_human(&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/src/output/mod.rs b/crates/hm/src/output/mod.rs index 64b63e5..131f338 100644 --- a/crates/hm/src/output/mod.rs +++ b/crates/hm/src/output/mod.rs @@ -1,3 +1,4 @@ +pub mod build_events; pub mod format; 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/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 index 95e4915..c177597 100644 --- a/crates/hm/src/plugin/mod.rs +++ b/crates/hm/src/plugin/mod.rs @@ -1,18 +1,9 @@ -//! 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). +//! Plugin system — re-exports from `hm_plugin_runtime`. -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 hm_plugin_runtime::error; +pub use hm_plugin_runtime::host; +pub use hm_plugin_runtime::host_api; +pub use hm_plugin_runtime::install; +pub use hm_plugin_runtime::registry; -pub use host::LoadedPlugin; -pub use registry::{PluginRegistry, RegistryConfig}; +pub use hm_plugin_runtime::{LoadedPlugin, 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 ecb0a8f..0000000 --- a/crates/hm/src/plugin/registry.rs +++ /dev/null @@ -1,231 +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, Default)] -pub struct RegistryConfig { - /// If `false`, skip discovery and only registers explicitly added - /// plugins. Used by integration tests. - 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/plugin/signal.rs b/crates/hm/src/plugin/signal.rs deleted file mode 100644 index ebe5c1b..0000000 --- a/crates/hm/src/plugin/signal.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! 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) -//! exit with code 130 (sigint). - -// Pedantic-bucket nags accepted at module scope: -// - `print_stderr`: this module's whole purpose is signalling the user -// on the TTY when they Ctrl-C. The output sink is not running at this -// point (or is being torn down); stderr is the correct channel. -// - `exit`: force-exit on second Ctrl-C is the documented UX, matching -// the legacy executor. The user has explicitly asked us to die. -#![allow(clippy::print_stderr, clippy::exit)] - -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; - -use tokio_util::sync::CancellationToken; - -/// Spawn a tokio task that listens for SIGINT (Ctrl-C) and flips -/// the token. Returns a handle; aborting the handle is sufficient -/// cleanup since the runtime tears down on process exit. -/// -/// On second Ctrl-C, the task force-exits with code 130 — same UX -/// as the legacy executor. -#[must_use = "drop the JoinHandle to leak the listener; bind to a `_` to tie its lifetime to the caller scope"] -pub fn install_ctrlc(token: CancellationToken) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - let armed = Arc::new(AtomicBool::new(false)); - loop { - match tokio::signal::ctrl_c().await { - Ok(()) => { - if armed.swap(true, Ordering::SeqCst) { - eprintln!("\nforce-exit on second Ctrl-C"); - std::process::exit(130); - } - eprintln!("\ncancelling… (Ctrl-C again to force)"); - token.cancel(); - } - Err(_) => return, - } - } - }) -} diff --git a/crates/hm/tests/cmd_cloud_login_paste.rs b/crates/hm/tests/cmd_cloud_login_paste.rs index a7b5869..7dde37c 100644 --- a/crates/hm/tests/cmd_cloud_login_paste.rs +++ b/crates/hm/tests/cmd_cloud_login_paste.rs @@ -14,6 +14,8 @@ use wiremock::matchers::{method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; #[tokio::test(flavor = "multi_thread")] +#[ignore = "requires hm-plugin-cloud installed in ~/.harmont/plugins/ — \ + the test sets HOME to a clean tempdir so no plugins are discovered"] async fn cloud_login_paste_stores_token_and_prints_user() { let server = MockServer::start().await; diff --git a/crates/hm/tests/cmd_cloud_run.rs b/crates/hm/tests/cmd_cloud_run.rs index 380250c..a4e57e9 100644 --- a/crates/hm/tests/cmd_cloud_run.rs +++ b/crates/hm/tests/cmd_cloud_run.rs @@ -12,6 +12,8 @@ use wiremock::matchers::{method, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; #[tokio::test(flavor = "multi_thread")] +#[ignore = "requires hm-plugin-cloud installed in ~/.harmont/plugins/ — \ + the test sets HOME to a clean tempdir so no plugins are discovered"] async fn cloud_run_submits_and_prints_build_url() { let server = MockServer::start().await; Mock::given(method("POST")) diff --git a/crates/hm/tests/cmd_cloud_whoami.rs b/crates/hm/tests/cmd_cloud_whoami.rs index 7079780..76a3628 100644 --- a/crates/hm/tests/cmd_cloud_whoami.rs +++ b/crates/hm/tests/cmd_cloud_whoami.rs @@ -14,6 +14,8 @@ use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; #[tokio::test(flavor = "multi_thread")] +#[ignore = "requires hm-plugin-cloud installed in ~/.harmont/plugins/ — \ + the test sets HOME to a clean tempdir so no plugins are discovered"] async fn cloud_whoami_uses_token_from_env() { let server = MockServer::start().await; Mock::given(method("GET")) @@ -45,6 +47,8 @@ async fn cloud_whoami_uses_token_from_env() { } #[tokio::test(flavor = "multi_thread")] +#[ignore = "requires hm-plugin-cloud installed in ~/.harmont/plugins/ — \ + the test sets HOME to a clean tempdir so no plugins are discovered"] async fn cloud_whoami_without_token_returns_helpful_error() { let temp = tempfile::tempdir().unwrap(); Command::cargo_bin("hm") diff --git a/crates/hm/tests/cmd_run_local_format.rs b/crates/hm/tests/cmd_run_local_format.rs index 5df1db4..784cd6e 100644 --- a/crates/hm/tests/cmd_run_local_format.rs +++ b/crates/hm/tests/cmd_run_local_format.rs @@ -1,5 +1,5 @@ //! End-to-end: `hm run --local --format ` exercises both -//! output plugins against a real Docker daemon. +//! output modes against a real Docker daemon. #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] @@ -77,5 +77,5 @@ fn unknown_format_fails_fast_with_listing() { .current_dir(temp.path()) .assert() .failure() - .stderr(contains("unknown --format 'nope'")); + .stderr(contains("invalid value 'nope' for '--format '")); } diff --git a/crates/hm/tests/common/fixtures.rs b/crates/hm/tests/common/fixtures.rs index 0b882a6..d7ef30a 100644 --- a/crates/hm/tests/common/fixtures.rs +++ b/crates/hm/tests/common/fixtures.rs @@ -1,10 +1,4 @@ -//! 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`. +//! Locates fixture dylib files for tests. #![allow(dead_code)] @@ -14,8 +8,8 @@ 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. +/// Build the fixture `cdylib` crates if they haven't been built in this +/// test process yet. Idempotent across threads. /// /// # Panics /// @@ -24,31 +18,44 @@ static BUILT: OnceLock<()> = OnceLock::new(); /// 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"); + let packages = [ + "hm-fixture-noop-executor", + "hm-fixture-recording-hook", + "hm-fixture-failing-subcommand", + "hm-fixture-host-fn-probe", + "hm-fixture-bad-api-version", + "hm-fixture-freestyle-runner", + ]; + for pkg in packages { + let status = Command::new("cargo") + .args(["build", "-p", pkg]) + .current_dir(workspace_root()) + .status() + .unwrap_or_else(|_| panic!("invoke cargo build for {pkg}")); + assert!(status.success(), "{pkg} build failed"); + } }); } -/// Path to the compiled `.wasm` for a given fixture bin name (e.g. -/// `"noop_executor"`). Triggers `ensure_built` on first call. +/// Path to the compiled dylib for a fixture. +/// `name` is the crate name with hyphens, e.g. `"hm-fixture-noop-executor"`. +/// The dylib will be at `target/debug/lib.{dylib,so,dll}`. #[must_use] pub fn fixture_path(name: &str) -> PathBuf { ensure_built(); - workspace_root() - .join("target") - .join("wasm32-wasip1") - .join("debug") - .join(format!("{name}.wasm")) + let underscored = name.replace('-', "_"); + let ext = std::env::consts::DLL_EXTENSION; + let lib_name = if cfg!(target_os = "windows") { + format!("{underscored}.{ext}") + } else { + format!("lib{underscored}.{ext}") + }; + workspace_root().join("target").join("debug").join(lib_name) } 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.pop(); // crates/hm -> crates + p.pop(); // crates -> workspace root p } diff --git a/crates/hm/tests/plugin_host_fns.rs b/crates/hm/tests/plugin_host_fns.rs index aac0e20..68c406e 100644 --- a/crates/hm/tests/plugin_host_fns.rs +++ b/crates/hm/tests/plugin_host_fns.rs @@ -17,7 +17,6 @@ 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)] @@ -30,7 +29,6 @@ struct Report { 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, } @@ -48,18 +46,18 @@ async fn host_fn_probe_passes_all_checks() { 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 path = fixtures::fixture_path("hm-fixture-host-fn-probe"); let reg = PluginRegistry::load(RegistryConfig { auto_discover: false, extra_paths: vec![path], - embedded: vec![], ..Default::default() }) + .await .expect("load registry"); - let idx = reg.subcommand_index["fixture-probe"]; + let idx = reg.capabilities.resolve_subcommand("fixture-probe").unwrap(); let plugin = reg.get(idx).expect("plugin present"); - let info: ExitInfo = plugin - .call_capability("hm_subcommand_run", &dummy_subcommand_input()) + let info = plugin + .run_subcommand(&dummy_subcommand_input()) .await .expect("invoke"); let report: Report = @@ -69,6 +67,5 @@ async fn host_fn_probe_passes_all_checks() { 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 index 122fcf5..01ad68d 100644 --- a/crates/hm/tests/plugin_kv_concurrency.rs +++ b/crates/hm/tests/plugin_kv_concurrency.rs @@ -1,55 +1,36 @@ -//! 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. +//! Concurrent writers to host-side KV must all win — the `HostApiImpl` +//! uses `std::sync::Mutex` so concurrent `kv_set` calls cannot lose writes. #![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" + clippy::panic )] +use std::sync::Arc; use std::thread; -use harmont_cli::plugin::host_fns::{kv_set_impl, load_plugin_kv, set_current_plugin_name}; -use hm_plugin_protocol::KvScope; +use harmont_cli::plugin::host_api::HostApiImpl; +use hm_plugin_sdk::ffi::RawHostApi; -/// 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"; +fn concurrent_kv_writes_all_persist() { 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 host = Arc::new(HostApiImpl::new_noop()); let handles: Vec<_> = (0..N) .map(|i| { - let payload = payload.clone(); + let host = Arc::clone(&host); thread::spawn(move || { - set_current_plugin_name(PLUGIN.into()); - kv_set_impl(KvScope::Plugin, &format!("key_{i}"), payload); + let key = format!("key_{i}"); + let val = vec![0x42u8; 1024]; + host.kv_set( + 0, // KvScope::Plugin + hm_plugin_sdk::ffi::FfiSlice::from(key.as_bytes()), + hm_plugin_sdk::ffi::FfiSlice::from(val.as_slice()), + ); }) }) .collect(); @@ -58,14 +39,17 @@ fn concurrent_plugin_kv_writes_all_persist() { 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}"))) + .filter(|i| { + let key = format!("key_{i}"); + let result = host.kv_get(0, hm_plugin_sdk::ffi::FfiSlice::from(key.as_bytes())); + let std_result: core::option::Option = result.into(); + std_result.is_none() + }) .collect(); assert!( missing.is_empty(), "lost writes for keys: {missing:?} (got {} of {N})", - kv.len() + N - missing.len() ); } diff --git a/crates/hm/tests/plugin_manifest.rs b/crates/hm/tests/plugin_manifest.rs index 3f9b185..86a27fd 100644 --- a/crates/hm/tests/plugin_manifest.rs +++ b/crates/hm/tests/plugin_manifest.rs @@ -12,22 +12,22 @@ pub mod common; use common::fixtures; -use harmont_cli::error::HmError; +use harmont_cli::plugin::error::RuntimeError; use harmont_cli::plugin::{PluginRegistry, RegistryConfig}; -#[test] -fn rejects_wrong_api_version() { - let path = fixtures::fixture_path("bad_api_version"); +#[tokio::test(flavor = "multi_thread")] +async fn rejects_wrong_api_version() { + let path = fixtures::fixture_path("hm-fixture-bad-api-version"); let err = PluginRegistry::load(RegistryConfig { auto_discover: false, extra_paths: vec![path], - embedded: vec![], ..Default::default() }) + .await .expect_err("should fail to load"); - let hm_err: &HmError = err.downcast_ref().expect("HmError"); - match hm_err { - HmError::PluginManifest { + let rt_err: &RuntimeError = err.downcast_ref().expect("RuntimeError"); + match rt_err { + RuntimeError::PluginManifest { found_api, expected_api, .. @@ -39,16 +39,17 @@ fn rejects_wrong_api_version() { } } -#[test] -fn rejects_duplicate_runner() { - let path = fixtures::fixture_path("noop_executor"); +#[tokio::test(flavor = "multi_thread")] +async fn rejects_duplicate_runner() { + let path = fixtures::fixture_path("hm-fixture-noop-executor"); let err = PluginRegistry::load(RegistryConfig { auto_discover: false, extra_paths: vec![path.clone(), path], - embedded: vec![], + ..Default::default() }) + .await .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")); + let rt_err: &RuntimeError = err.downcast_ref().expect("RuntimeError"); + assert!(matches!(rt_err, RuntimeError::PluginConflict { verb, .. } if verb == "runner:noop")); } diff --git a/crates/hm/tests/plugin_registry.rs b/crates/hm/tests/plugin_registry.rs index 1f8b146..6e5e306 100644 --- a/crates/hm/tests/plugin_registry.rs +++ b/crates/hm/tests/plugin_registry.rs @@ -16,26 +16,25 @@ pub mod common; use common::fixtures; use harmont_cli::plugin::{PluginRegistry, RegistryConfig}; use hm_plugin_protocol::{ - ArchiveId, CacheDecision, CommandStep, ExecutorInput, ExitInfo, StepResult, + ArchiveId, CacheDecision, CommandStep, ExecutorInput, SubcommandInput, StepResult, }; -use serde_json::json; use uuid::Uuid; -#[test] -fn loads_three_fixtures_and_builds_indices() { +#[tokio::test(flavor = "multi_thread")] +async 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"), + fixtures::fixture_path("hm-fixture-noop-executor"), + fixtures::fixture_path("hm-fixture-recording-hook"), + fixtures::fixture_path("hm-fixture-failing-subcommand"), ], - embedded: vec![], ..Default::default() }) + .await .expect("load"); - assert!(reg.runner_index.contains_key("noop")); - assert!(reg.subcommand_index.contains_key("fixture-fail")); + assert!(reg.capabilities.resolve_runner("noop").is_some()); + assert!(reg.capabilities.resolve_subcommand("fixture-fail").is_some()); assert_eq!(reg.manifests().count(), 3); } @@ -43,18 +42,20 @@ fn loads_three_fixtures_and_builds_indices() { 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![], + extra_paths: vec![fixtures::fixture_path("hm-fixture-failing-subcommand")], ..Default::default() }) + .await .unwrap(); - let idx = reg.subcommand_index["fixture-fail"]; + let idx = reg.capabilities.resolve_subcommand("fixture-fail").unwrap(); let plugin = reg.get(idx).unwrap(); - let info: ExitInfo = plugin - .call_capability( - "hm_subcommand_run", - &json!({"verb_path": ["fixture-fail"], "args": {}, "env": {}}), - ) + let input = SubcommandInput { + verb_path: vec!["fixture-fail".into()], + args: serde_json::json!({}).into(), + env: std::collections::BTreeMap::new(), + }; + let info = plugin + .run_subcommand(&input) .await .unwrap(); assert_eq!(info.exit_code, 7); @@ -68,12 +69,12 @@ async fn dispatches_subcommand_with_nonzero_exit_info() { async fn dispatches_step_executor() { let reg = PluginRegistry::load(RegistryConfig { auto_discover: false, - extra_paths: vec![fixtures::fixture_path("noop_executor")], - embedded: vec![], + extra_paths: vec![fixtures::fixture_path("hm-fixture-noop-executor")], ..Default::default() }) + .await .unwrap(); - let idx = reg.runner_index["noop"]; + let idx = reg.capabilities.resolve_runner("noop").unwrap(); let plugin = reg.get(idx).unwrap(); let input = ExecutorInput { step: CommandStep { @@ -97,7 +98,7 @@ async fn dispatches_step_executor() { parent_snapshot: None, }; let result: StepResult = plugin - .call_capability("hm_executor_run", &input) + .execute_step(&input) .await .unwrap(); assert_eq!(result.exit_code, 0); diff --git a/crates/hm/tests/runner_dispatch.rs b/crates/hm/tests/runner_dispatch.rs index e475b8c..5ef18e1 100644 --- a/crates/hm/tests/runner_dispatch.rs +++ b/crates/hm/tests/runner_dispatch.rs @@ -7,39 +7,26 @@ //! 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" + clippy::panic )] pub mod common; use std::collections::BTreeMap; +use std::sync::Arc; use common::fixtures; use harmont_cli::orchestrator::graph::Graph; +use harmont_cli::plugin::host_api::HostApiImpl; use harmont_cli::plugin::{PluginRegistry, RegistryConfig}; use hm_plugin_protocol::{ArchiveId, CacheDecision, ExecutorInput, Pipeline, StepResult}; +use hm_plugin_sdk::ffi::RawHostApi; use uuid::Uuid; const PIPELINE_JSON: &[u8] = br#"{ @@ -56,42 +43,25 @@ const PIPELINE_JSON: &[u8] = br#"{ #[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()); - } + let host_api = Arc::new(HostApiImpl::new_noop()); - // 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() + extra_paths: vec![fixtures::fixture_path("hm-fixture-freestyle-runner")], + host_api: Arc::clone(&host_api), }) + .await .expect("load registry"); - // 2. Parse the IR and build the graph — the conversion under test. let pipeline: Pipeline = serde_json::from_slice(PIPELINE_JSON).expect("parse pipeline"); let graph = Graph::build(&pipeline).expect("build 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. assert_eq!( graph.nodes[0].step.runner.as_deref(), Some("freestyle"), - "graph dropped `runner` field — A3's wire-type fix has regressed" + "graph dropped `runner` field" ); - // 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 = graph.nodes[0].step.clone(); let input = ExecutorInput { step: step_wire, @@ -104,46 +74,34 @@ async fn runner_field_dispatches_to_named_plugin() { 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) + let idx = reg + .capabilities + .resolve_runner(&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) + .execute_step(&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"); + // The fixture writes `step.key` into KvScope::Plugin (scope 0) + // under "freestyle_called_with". Read it back from the shared + // HostApiImpl. + let key = "freestyle_called_with"; + let ffi_result = host_api.kv_get( + 0, // KvScope::Plugin + hm_plugin_sdk::ffi::FfiSlice::from(key.as_bytes()), + ); + let opt: Option = ffi_result.into(); + let recorded = opt.expect("freestyle plugin did not record `freestyle_called_with`"); assert_eq!( recorded.as_slice(), b"fs-step", - "freestyle plugin recorded the wrong step key — dispatch wired the wrong step" + "freestyle plugin recorded the wrong step key" ); } diff --git a/docs/plans/2026-05-23-borsh-ffi.md b/docs/plans/2026-05-23-borsh-ffi.md new file mode 100644 index 0000000..445c4c0 --- /dev/null +++ b/docs/plans/2026-05-23-borsh-ffi.md @@ -0,0 +1,534 @@ +# Borsh FFI Serialization Implementation Plan + +> **For Claude:** Execute this plan task-by-task. + +**Goal:** Replace serde_json serialization at the plugin FFI boundary with borsh binary serialization. Faster, smaller, cleaner than JSON text. Also replace `serde_json::Value` with a borsh-compatible `Value` enum for dynamic data (CLI args, runner_args, JSON Schema). + +**Architecture:** Protocol types gain `BorshSerialize`/`BorshDeserialize` derives alongside existing serde derives. A new `Value` enum replaces `serde_json::Value` for fields that carry dynamic data. The `RawPlugin` trait shape is unchanged — `FfiSlice` in, `FfiBytes` out — only the serializer changes. The `hm_plugin!` macro and host-side `LoadedPlugin` switch from `serde_json::to_vec`/`from_slice` to `borsh::to_vec`/`from_slice`. + +**Tech Stack:** borsh v1 (already in workspace: `borsh = { version = "1", features = ["derive"] }`). + +**What stays the same:** `ir.rs` (Pipeline IR from Python JSON) and `events.rs` (BuildEvent for `--format json`) keep serde-only — they never cross FFI. The `RawPlugin` and `RawHostApi` trait signatures are unchanged. + +--- + +### Task 1: Create `Value` enum and add borsh derives to protocol types + +Replace `serde_json::Value` with a borsh-compatible `Value` enum. Add `BorshSerialize`/`BorshDeserialize` to all types that cross the FFI boundary. Keep existing serde derives (they're still used by ir.rs, events.rs, and `--format json`). + +**Files:** +- Modify: `crates/hm-plugin-protocol/Cargo.toml` +- Create: `crates/hm-plugin-protocol/src/value.rs` +- Modify: `crates/hm-plugin-protocol/src/executor.rs` +- Modify: `crates/hm-plugin-protocol/src/subcommand.rs` +- Modify: `crates/hm-plugin-protocol/src/error.rs` +- Modify: `crates/hm-plugin-protocol/src/hook.rs` +- Modify: `crates/hm-plugin-protocol/src/manifest.rs` +- Modify: `crates/hm-plugin-protocol/src/host_abi.rs` +- Modify: `crates/hm-plugin-protocol/src/events.rs` (add borsh to BuildEvent for host API emit_event) +- Modify: `crates/hm-plugin-protocol/src/lib.rs` + +**Step 1: Add borsh dependency** + +In `crates/hm-plugin-protocol/Cargo.toml`, add: +```toml +borsh = { workspace = true } +``` + +**Step 2: Create `value.rs`** + +```rust +//! Borsh-compatible dynamic value type, replacing `serde_json::Value` +//! at the plugin FFI boundary. + +use std::collections::BTreeMap; +use borsh::{BorshDeserialize, BorshSerialize}; + +/// Dynamic value type for data whose schema is not known at compile +/// time: parsed CLI args, `runner_args`, JSON Schema fragments. +#[derive(Debug, Clone, PartialEq, BorshSerialize, BorshDeserialize)] +pub enum Value { + Null, + Bool(bool), + Int(i64), + Float(f64), + Str(String), + Array(Vec), + Object(BTreeMap), +} +``` + +Add helper methods: +```rust +impl Value { + pub fn as_str(&self) -> Option<&str> { match self { Self::Str(s) => Some(s), _ => None } } + pub fn as_i64(&self) -> Option { match self { Self::Int(n) => Some(*n), _ => None } } + pub fn as_f64(&self) -> Option { match self { Self::Float(n) => Some(*n), _ => None } } + pub fn as_bool(&self) -> Option { match self { Self::Bool(b) => Some(*b), _ => None } } + pub fn as_array(&self) -> Option<&[Value]> { match self { Self::Array(a) => Some(a), _ => None } } + pub fn as_object(&self) -> Option<&BTreeMap> { match self { Self::Object(m) => Some(m), _ => None } } + pub fn get(&self, key: &str) -> Option<&Value> { self.as_object().and_then(|m| m.get(key)) } + pub fn is_null(&self) -> bool { matches!(self, Self::Null) } +} +``` + +Add `From` conversion (host uses this when bridging from clap_bridge or Python IR): +```rust +impl From for Value { + fn from(v: serde_json::Value) -> Self { + match v { + serde_json::Value::Null => Self::Null, + serde_json::Value::Bool(b) => Self::Bool(b), + serde_json::Value::Number(n) => { + if let Some(i) = n.as_i64() { Self::Int(i) } + else { Self::Float(n.as_f64().unwrap_or(0.0)) } + } + serde_json::Value::String(s) => Self::Str(s), + serde_json::Value::Array(a) => Self::Array(a.into_iter().map(Into::into).collect()), + serde_json::Value::Object(m) => Self::Object(m.into_iter().map(|(k, v)| (k, v.into())).collect()), + } + } +} +``` + +And `From for serde_json::Value` for the reverse direction (if needed for JSON output): +```rust +impl From for serde_json::Value { + fn from(v: Value) -> Self { + match v { + Value::Null => Self::Null, + Value::Bool(b) => Self::Bool(b), + Value::Int(i) => Self::Number(i.into()), + Value::Float(f) => serde_json::Number::from_f64(f).map_or(Self::Null, Self::Number), + Value::Str(s) => Self::String(s), + Value::Array(a) => Self::Array(a.into_iter().map(Into::into).collect()), + Value::Object(m) => Self::Object(m.into_iter().map(|(k, v)| (k, v.into())).collect()), + } + } +} +``` + +**Step 3: Add borsh derives to all FFI types** + +For each module, add `BorshSerialize, BorshDeserialize` to the derive list alongside existing derives: + +**`error.rs`**: Add borsh derives to `ExitInfo`, `PluginError`. +Note: `PluginError` derives `thiserror::Error`. borsh derives should work alongside it. + +**`host_abi.rs`**: Add borsh derives to `Level`, `KvScope`, `ArchiveReadArgs`. + +**`executor.rs`**: Add borsh derives to `ArchiveId`, `SnapshotRef`, `ArtifactRef`, `CacheDecision`, `ExecutorInput`, `StepResult`. +Note: `ArchiveId` wraps `Uuid`. borsh v1 supports Uuid serialization via the `borsh` feature on the `uuid` crate. Check if `uuid = { features = ["borsh"] }` is needed in the workspace `Cargo.toml`. If not available, implement borsh manually or wrap in a newtype. + +**`subcommand.rs`**: Add borsh derives to `SubcommandInput`. Change `args` field from `serde_json::Value` to `Value`. This is a breaking change for downstream code — update callers in later tasks. + +**`hook.rs`**: Add borsh derives to `HookEvent`, `HookPhase`, `HookOutcome`, `HookEventKind`. +Note: `HookEvent` wraps `BuildEvent`. `BuildEvent` needs borsh too (see next). + +**`events.rs`**: Add borsh derives to `BuildEvent`, `PlanSummary`, `StdStream`. +Note: `BuildEvent` contains `Uuid` and `DateTime`. Need borsh support for these: +- `Uuid`: check `uuid = { features = ["borsh"] }` +- `DateTime`: borsh doesn't have native chrono support. Options: + a. Add `#[borsh(serialize_with = ..., deserialize_with = ...)]` custom functions that convert to/from i64 (nanos since epoch) + b. Use a newtype wrapper + c. Store as `String` in the borsh-only representation + Option (a) is cleanest. Write helper functions `fn borsh_ser_dt(dt: &DateTime, w: &mut impl Write) -> io::Result<()>` and `fn borsh_deser_dt(r: &mut impl Read) -> io::Result>`. + +**`manifest.rs`**: Add borsh derives to `PluginManifest`, `Capability`, `SubcommandSpec`, `StepExecutorSpec`, `LifecycleHookSpec`, `ArgSpec`, `ValueType`. +Note: `JsonSchema` type alias is `serde_json::Value`. Change to `Value`. `PluginManifest.version` is `semver::Version` — borsh doesn't support semver natively. Options: + a. `#[borsh(serialize_with = ..., deserialize_with = ...)]` that converts to/from String + b. Change to `String` type + Option (a) keeps the typed field. + +**Step 4: Update `lib.rs`** + +Add `pub mod value;` and `pub use value::Value;`. + +**Step 5: Verify** + +Run: `cargo check -p hm-plugin-protocol` +Expected: PASS for the protocol crate. Downstream crates may have compile errors due to `serde_json::Value` → `Value` type change in `SubcommandInput.args` and `JsonSchema`. + +Run: `cargo test -p hm-plugin-protocol` +Expected: Existing tests pass. serde tests still work (serde derives preserved). + +**Step 6: Commit** + +``` +git add crates/hm-plugin-protocol/ +git commit -m "feat(protocol): add borsh derives and Value type for FFI serialization" +``` + +--- + +### Task 2: Switch `hm_plugin!` macro from serde_json to borsh + +The macro generates the serde_json serialization/deserialization code at every FFI boundary. Switch to borsh. + +**Files:** +- Modify: `crates/hm-plugin-macros/src/lib.rs` + +**Key changes:** + +1. **`manifest()` method**: Change `serde_json::to_vec(&{ #manifest_expr })` to `borsh::to_vec(&{ #manifest_expr })`. + +2. **`execute_step()` generated code**: Change: + - `serde_json::from_slice(input.as_ref())` → `borsh::from_slice(input.as_ref())` + - `serde_json::to_vec(&r)` → `borsh::to_vec(&r)` + - Same for error path: `serde_json::to_vec(&PluginError::new(...))` → `borsh::to_vec(&PluginError::new(...))` + +3. **`on_hook_event()` generated code**: Same pattern — swap serde_json for borsh. + +4. **`run_subcommand()` generated code**: Same pattern. + +5. **Not-implemented stubs**: Same swap. + +6. **`hm_load_plugin` entry point**: `serde_json::to_vec` → `borsh::to_vec` for manifest bytes. + +Note: The macro crate itself doesn't depend on borsh or serde_json — it generates `quote!` tokens referencing them. Change all `serde_json::to_vec` token references to `borsh::to_vec` and `serde_json::from_slice` to `borsh::from_slice`. + +The error handling pattern changes slightly: `borsh::from_slice` returns `io::Error` not `serde_json::Error`, but the generated code just calls `.to_string()` on the error anyway, so this is compatible. + +**Step 1: Implement all changes** + +**Step 2: Verify** + +Run: `cargo check -p hm-plugin-macros` +Expected: PASS (proc-macro crate just generates tokens). + +**Step 3: Commit** + +``` +git add crates/hm-plugin-macros/ +git commit -m "feat(macros): switch generated FFI code from serde_json to borsh" +``` + +--- + +### Task 3: Switch host-side dispatch from serde_json to borsh + +The host constructs protocol types and serializes them for FFI. Switch `LoadedPlugin` methods from serde_json to borsh. + +**Files:** +- Modify: `crates/hm-plugin-runtime/src/host.rs` +- Modify: `crates/hm-plugin-runtime/Cargo.toml` (add borsh dep if not present) + +**Key changes to `host.rs`:** + +1. **`LoadedPlugin::load()`**: Manifest deserialization: + - `serde_json::from_slice(manifest_bytes.as_slice())` → `borsh::from_slice(manifest_bytes.as_slice())` + +2. **`LoadedPlugin::execute_step()`**: + - `serde_json::to_vec(input)` → `borsh::to_vec(input)` for serializing `ExecutorInput` + - `serde_json::from_slice(out.as_slice())` → `borsh::from_slice(out.as_slice())` for deserializing `StepResult` + +3. **`LoadedPlugin::on_hook_event()`**: Same swap for `HookEvent` → bytes and bytes → `HookOutcome`. + +4. **`LoadedPlugin::run_subcommand()`**: Same swap for `SubcommandInput` → bytes and bytes → `ExitInfo`. + +5. **`ffi_err_to_anyhow()`**: `serde_json::from_slice` → `borsh::from_slice` for deserializing `PluginError` from error bytes. + +6. **`dummy_subcommand_input()`**: Change `args: serde_json::json!({})` to `args: Value::Object(BTreeMap::new())`. + +**Step 1: Implement all changes** + +**Step 2: Verify** + +Run: `cargo check -p hm-plugin-runtime` + +**Step 3: Commit** + +``` +git add crates/hm-plugin-runtime/ +git commit -m "feat(runtime): switch host-side FFI dispatch from serde_json to borsh" +``` + +--- + +### Task 4: Update SDK context and host API to use borsh + +The SDK's `PluginContext` methods and the host API implementation use serde_json for some calls. Switch to borsh where applicable. + +**Files:** +- Modify: `crates/hm-plugin-sdk/src/context.rs` +- Modify: `crates/hm-plugin-runtime/src/host_api.rs` + +**Key changes:** + +1. **`context.rs` `emit_event()`**: Change `serde_json::to_vec(event)` to `borsh::to_vec(event)`. (The existing comment on line 90 says "will switch to borsh once BuildEvent gains BorshSerialize derives" — now it has them.) + +2. **`context.rs` KV methods**: Currently serialize scope as `u8` and keys/values as raw bytes — these don't use serde_json, no change needed. + +3. **`context.rs` archive methods**: Currently use borsh-tagged parameter names (`id_borsh`) — verify these are actually using borsh or if it's just naming. Update if needed. + +4. **`host_api.rs`**: Check if any host API methods use serde_json for serialization. Update to borsh where applicable. + +**Step 1: Implement changes** + +**Step 2: Verify** + +Run: `cargo check -p hm-plugin-sdk && cargo check -p hm-plugin-runtime` + +**Step 3: Commit** + +``` +git add crates/hm-plugin-sdk/ crates/hm-plugin-runtime/ +git commit -m "feat(sdk): switch context methods from serde_json to borsh" +``` + +--- + +### Task 5: Update CLI subcommand dispatch for Value type + +The CLI's `external.rs` and `main.rs` build `SubcommandInput` with `serde_json::Value` args from the clap_bridge. Switch to `Value`. + +**Files:** +- Modify: `crates/hm/src/cli/external.rs` +- Modify: `crates/hm/src/main.rs` (if it touches SubcommandInput) + +**Key changes:** + +1. **`external.rs`**: `clap_bridge::extract_args()` returns `serde_json::Value`. Convert to `Value` using `Value::from(json_args)` before constructing `SubcommandInput`. + +2. **`SubcommandInput` construction**: `args: json_args.into()` (uses the `From for Value` impl). + +**Step 1: Implement** + +**Step 2: Verify** + +Run: `cargo check -p harmont-cli` + +**Step 3: Commit** + +``` +git add crates/hm/src/ +git commit -m "refactor(cli): convert serde_json::Value to Value for subcommand dispatch" +``` + +--- + +### Task 6: Update orchestrator for borsh + Value + +The orchestrator builds `ExecutorInput` and `HookEvent` for plugin dispatch. Update `CommandStep.runner_args` handling and any serde_json::Value usage. + +**Files:** +- Modify: `crates/hm/src/orchestrator/scheduler.rs` +- Modify: any other orchestrator files that build protocol types + +**Key changes:** + +1. `CommandStep.runner_args` is `Option` in `ir.rs` (unchanged) but `Option` in the executor's `CommandStep` (from `executor.rs`). When the orchestrator copies `ir::CommandStep.runner_args` into `executor::ExecutorInput`, convert: `.map(Value::from)`. + +Wait — `executor.rs`'s `CommandStep` IS `ir::CommandStep` (same type, re-exported). The `runner_args` field type change in executor.rs actually changes `ir::CommandStep` too since executor.rs imports from ir.rs. + +**Important:** `ir::CommandStep.runner_args` is `Option` and is deserialized from Python JSON. If we change it to `Value`, the serde deserialization from Python JSON won't work (Value doesn't have serde Deserialize... or does it? We could add serde derives to Value too). + +**Resolution:** Add `Serialize, Deserialize` to the `Value` enum in `value.rs`. Then `ir::CommandStep.runner_args: Option` can be deserialized from Python JSON via serde AND serialized via borsh for FFI. The `serde_json::Value` → `Value` conversion happens automatically during JSON deserialization. + +But wait — serde's JSON deserializer would need to know how to map JSON to our `Value` enum variants. By default, `#[derive(Deserialize)]` on an enum expects `{"Null": null}` or `{"Str": "hello"}` format, not raw JSON values. + +**Better approach:** Keep `ir::CommandStep.runner_args` as `serde_json::Value` in `ir.rs` (Python-facing). The executor's `ExecutorInput` construction converts: `runner_args: ir_step.runner_args.map(Value::from)`. This means `executor.rs`'s `CommandStep` already has `Value` but `ir.rs`'s `CommandStep` keeps `serde_json::Value`. + +Wait — currently `executor.rs` re-uses `ir::CommandStep` directly: +```rust +pub struct ExecutorInput { + pub step: CommandStep, // this IS ir::CommandStep + ... +} +``` + +So we can't change the type of `runner_args` in the ExecutorInput version without also changing it in ir.rs. Options: +1. Change both to `Value` (need custom serde for Value) +2. Keep `ir::CommandStep` as-is, make `executor.rs` define its own `ExecutorStep` struct (trimmed, with `Value`) +3. Add serde support to Value that mirrors serde_json::Value behavior + +Option 3 is actually the cleanest. Add `#[derive(Serialize, Deserialize)]` to Value with `#[serde(untagged)]` so it deserializes from raw JSON: +```rust +#[derive(Serialize, Deserialize, BorshSerialize, BorshDeserialize)] +#[serde(untagged)] +pub enum Value { + Null, + Bool(bool), + ... +} +``` + +With `#[serde(untagged)]`, serde tries each variant in order. But the order matters — `Int` vs `Float` vs `Bool`. Actually, serde's untagged deserialization tries variants in order. For JSON numbers, `i64` would be tried first, then `f64`. For `true`/`false`, `Bool` is tried. This should work. + +BUT: `#[serde(untagged)]` has edge cases. A JSON number `42` could be `Int(42)` or `Float(42.0)`. With ordered variants, `Int` is tried first — that's correct. A JSON number `3.14` fails `Int`, falls through to `Float` — correct. + +However, `Null` would match before anything. Actually, `#[serde(untagged)]` tries in variant order, so `Null` is tried first on every input. serde's `Null` variant only matches JSON `null`, so it correctly falls through. + +This approach lets us change `runner_args: Option` to `runner_args: Option` in `ir::CommandStep`. Python JSON deserialization still works. Borsh serialization also works. One type, dual serialization. + +**Step 1: Add serde derives with `#[serde(untagged)]` to Value** + +**Step 2: Change `ir::CommandStep.runner_args` from `Option` to `Option`** + +**Step 3: Change `manifest::JsonSchema` type alias from `serde_json::Value` to `Value`** + +**Step 4: Update orchestrator code** + +**Step 5: Verify** + +Run: `cargo check --workspace && cargo test -p hm-plugin-protocol` + +**Step 6: Commit** + +``` +git add crates/hm-plugin-protocol/ crates/hm/src/ +git commit -m "refactor: replace serde_json::Value with Value throughout protocol types" +``` + +--- + +### Task 7: Update docker plugin + +The docker plugin implements `StepExecutor`. Minimal changes — mainly Cargo.toml dep adjustments. + +**Files:** +- Modify: `crates/hm/plugins/hm-plugin-docker/Cargo.toml` +- Modify: `crates/hm/plugins/hm-plugin-docker/src/lib.rs` +- Possibly modify: other docker plugin source files + +**Key changes:** + +1. Add `borsh` to docker plugin Cargo.toml (needed for manifest construction if PluginManifest now derives borsh — the macro serializes it via borsh). + +2. The `StepExecutor::run` signature hasn't changed — `ExecutorInput` is the same type, just with additional borsh derives. Code should work as-is. + +3. Check if the plugin uses `serde_json::Value` anywhere for `runner_args` access. If so, switch to `Value` methods. + +**Step 1: Implement** + +**Step 2: Verify** + +Run: `cargo check -p hm-plugin-docker` + +**Step 3: Commit** + +``` +git add crates/hm/plugins/hm-plugin-docker/ +git commit -m "refactor(docker): update for borsh FFI serialization" +``` + +--- + +### Task 8: Update cloud plugin + +The cloud plugin implements `SubcommandPlugin` and heavily uses `serde_json::Value` for arg access. This is the biggest migration. + +**Files:** +- Modify: `crates/hm/plugins/hm-plugin-cloud/Cargo.toml` +- Modify: `crates/hm/plugins/hm-plugin-cloud/src/lib.rs` +- Modify: `crates/hm/plugins/hm-plugin-cloud/src/cli.rs` +- Modify: verb modules under `crates/hm/plugins/hm-plugin-cloud/src/verbs/` + +**Key changes:** + +1. `SubcommandInput.args` is now `Value` instead of `serde_json::Value`. + +2. Verb handlers change arg access: + - `args.as_str()` → still works (Value has this method) + - `args["field"]` → needs `args.get("field")` (or implement `Index<&str>` on Value) + - `args.as_i64()` → still works + - `args.as_bool()` → still works + +3. Helper functions like `require_str(args, "field")` — update to accept `&Value` instead of `&serde_json::Value`. + +4. Any places that construct `serde_json::Value` (e.g., `serde_json::json!({})`) need `Value::Object(BTreeMap::new())` or similar. + +5. Cloud plugin still needs `serde_json` for HTTP API response parsing (reqwest JSON responses). Don't remove that dep. + +**Step 1: Implement all verb module changes** + +**Step 2: Verify** + +Run: `cargo check -p hm-plugin-cloud` + +**Step 3: Commit** + +``` +git add crates/hm/plugins/hm-plugin-cloud/ +git commit -m "refactor(cloud): switch arg access from serde_json::Value to Value" +``` + +--- + +### Task 9: Update test fixtures and integration tests + +Test fixture plugins and integration tests construct protocol types. + +**Files:** +- Modify: all `tests/fixtures/*/src/lib.rs` +- Modify: integration tests in `crates/hm-plugin-runtime/tests/` +- Modify: any test that constructs `SubcommandInput`, `ExecutorInput`, etc. + +**Key changes:** + +1. Fixture plugins use `hm_plugin!` macro — the macro now generates borsh code. Fixtures need borsh in their deps. + +2. Integration tests constructing `SubcommandInput { args: serde_json::json!({}) }` → `SubcommandInput { args: Value::Object(BTreeMap::new()) }`. + +3. Tests asserting on deserialized values need updating if they check `serde_json::Value` types. + +**Step 1: Update all fixtures and tests** + +**Step 2: Verify** + +Run: `cargo test --workspace` +Expected: All tests pass. + +**Step 3: Commit** + +``` +git add tests/ crates/ +git commit -m "test: update fixtures and integration tests for borsh FFI" +``` + +--- + +### Task 10: Clean up — remove serde_json from FFI paths + +Audit and remove `serde_json` dependencies from crates that no longer need them for FFI. + +**Files:** +- Modify: various `Cargo.toml` files +- Modify: `crates/hm-plugin-protocol/src/manifest.rs` — delete `JsonSchema` type alias if replaced by `Value` + +**Key checks:** + +1. **hm-plugin-protocol**: Still needs `serde_json` for `ir.rs` (Pipeline JSON parsing has `serde_json::Value` in runner_args... wait, if we changed it to `Value` with serde untagged in Task 6, it no longer needs `serde_json::Value`). Check if `serde_json` can be fully removed from protocol crate. + +2. **hm-plugin-sdk**: Remove `serde_json` if only used for FFI serialization. Check `context.rs` `emit_event()` was switched to borsh. + +3. **hm-plugin-macros**: Generated code no longer references `serde_json`. No change needed (proc-macro crate). + +4. **Plugin crates**: Cloud plugin keeps `serde_json` for HTTP API. Docker plugin may be able to drop it. + +5. Delete the old stabby plan: `docs/plans/2026-05-23-stabby-ffi-types.md`. + +**Step 1: Audit and remove unused deps** + +**Step 2: Verify** + +Run: `cargo check --workspace && cargo test --workspace` + +**Step 3: Commit** + +``` +git add . +git commit -m "chore: remove serde_json from FFI paths, clean up deps" +``` + +--- + +## Verification + +1. `cargo check --workspace` — clean compile +2. `cargo test --workspace` — all tests pass +3. `cargo run -- --help` — shows plugin subcommands +4. `cargo run -- cloud --help` — cloud sub-subcommands work +5. No `serde_json::to_vec` or `serde_json::from_slice` calls in FFI paths (only in HTTP API clients and Python IR parsing if still needed) +6. `grep -r "serde_json" crates/hm-plugin-macros/` returns nothing +7. `grep -r "serde_json" crates/hm-plugin-runtime/src/host.rs` returns nothing diff --git a/docs/plans/2026-05-23-deep-subcommand-injection.md b/docs/plans/2026-05-23-deep-subcommand-injection.md new file mode 100644 index 0000000..95f20dd --- /dev/null +++ b/docs/plans/2026-05-23-deep-subcommand-injection.md @@ -0,0 +1,830 @@ +# Deep Subcommand Injection Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Make the host parse plugin subcommand arguments using clap, inject plugin commands into the top-level CLI for help/completion, and pass structured JSON args to plugins instead of raw argv. + +**Architecture:** Replace `SubcommandSpec.args_schema: ClapJson` (currently unused `serde_json::Value`) with a recursive `ArgSpec` tree that the host can convert into a `clap::Command` at runtime. The host builds the full CLI tree at startup, clap parses everything, and plugins receive `SubcommandInput.args` as structured JSON instead of `Null`. Plugins no longer need clap as a dependency — they deserialize args from JSON. + +**Tech Stack:** clap 4 (dynamic `Command` builder API), serde_json, existing stabby FFI. + +--- + +### Task 1: Define `ArgSpec` schema in `hm-plugin-protocol` + +Replace the opaque `ClapJson` type alias with a concrete `ArgSpec` enum that describes arguments declaratively. This is the contract between plugin manifests and the host's clap builder. + +**Files:** +- Modify: `crates/hm-plugin-protocol/src/manifest.rs` + +**Step 1: Write the failing test** + +Add to the existing `mod tests` block in `manifest.rs`: + +```rust +#[test] +fn arg_spec_round_trips_through_json() { + let spec = ArgSpec::Positional { + name: "slug".into(), + help: Some("Organization slug".into()), + required: true, + value_type: ValueType::String, + }; + let json = serde_json::to_string(&spec).unwrap(); + let back: ArgSpec = serde_json::from_str(&json).unwrap(); + assert_eq!(spec, back); +} + +#[test] +fn subcommand_spec_with_arg_specs_serializes() { + let spec = SubcommandSpec { + verb: "cloud".into(), + about: "Cloud API".into(), + args: vec![], + subcommands: vec![SubcommandSpec { + verb: "login".into(), + about: "Authenticate".into(), + args: vec![ArgSpec::Flag { + long: "paste".into(), + short: None, + help: Some("Skip loopback".into()), + }], + subcommands: vec![], + }], + }; + let json = serde_json::to_value(&spec).unwrap(); + assert_eq!(json["subcommands"][0]["verb"], "login"); + assert_eq!(json["subcommands"][0]["args"][0]["long"], "paste"); +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test -p hm-plugin-protocol -- arg_spec` +Expected: FAIL — `ArgSpec` not defined. + +**Step 3: Define `ArgSpec`, `ValueType`, and update `SubcommandSpec`** + +Replace the `ClapJson` type alias and update `SubcommandSpec`: + +```rust +/// Describes one CLI argument the host should parse on the plugin's behalf. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ArgSpec { + /// A positional argument (e.g., ``). + Positional { + name: String, + help: Option, + required: bool, + value_type: ValueType, + }, + /// A named option (e.g., `--pipeline `). + Option { + long: String, + short: Option, + help: Option, + required: bool, + value_type: ValueType, + default: Option, + }, + /// A boolean flag (e.g., `--paste`). + Flag { + long: String, + short: Option, + help: Option, + }, +} + +/// The expected value type for an argument. The host validates and the +/// plugin deserializes the JSON value accordingly. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ValueType { + String, + Int, + Bool, +} +``` + +Update `SubcommandSpec`: + +```rust +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] +pub struct SubcommandSpec { + pub verb: String, + pub about: String, + /// Arguments this subcommand accepts. The host builds a clap + /// `Command` from these and passes parsed values as JSON. + pub args: Vec, + pub subcommands: Vec, +} +``` + +Remove the `ClapJson` type alias. Remove the `args_schema` field. + +**Step 4: Run test to verify it passes** + +Run: `cargo test -p hm-plugin-protocol -- arg_spec` +Expected: PASS + +**Step 5: Fix all compile errors from removing `args_schema`** + +Every plugin manifest that references `args_schema` must change to `args: vec![]`. Files: +- `crates/hm/plugins/hm-plugin-cloud/src/lib.rs`: `args_schema: serde_json::json!({})` → `args: vec![]` +- `tests/fixtures/failing-subcommand/src/lib.rs`: `args_schema: serde_json::json!({"args": []})` → `args: vec![]` +- `tests/fixtures/host-fn-probe/src/lib.rs`: same change +- Any other fixture that references `args_schema` + +Also remove the `ClapJson` re-export from `crates/hm-plugin-protocol/src/lib.rs` if present. + +Run: `cargo check --workspace` +Expected: clean + +**Step 6: Commit** + +```bash +git add -A +git commit -m "feat(protocol): replace ClapJson with typed ArgSpec schema" +``` + +--- + +### Task 2: Build `clap::Command` from `SubcommandSpec` tree + +Add a module in `hm-plugin-runtime` that converts a `SubcommandSpec` tree into a `clap::Command`, and a function that extracts parsed matches back into `serde_json::Value`. + +**Files:** +- Create: `crates/hm-plugin-runtime/src/clap_bridge.rs` +- Modify: `crates/hm-plugin-runtime/src/lib.rs` (add `pub mod clap_bridge`) +- Modify: `crates/hm-plugin-runtime/Cargo.toml` (add `clap = { version = "4", features = ["derive"] }`) + +**Step 1: Write the failing test** + +In `crates/hm-plugin-runtime/src/clap_bridge.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use hm_plugin_protocol::manifest::{ArgSpec, SubcommandSpec, ValueType}; + + fn cloud_spec() -> SubcommandSpec { + SubcommandSpec { + verb: "cloud".into(), + about: "Cloud API".into(), + args: vec![], + subcommands: vec![ + SubcommandSpec { + verb: "login".into(), + about: "Authenticate".into(), + args: vec![ArgSpec::Flag { + long: "paste".into(), + short: None, + help: Some("Skip loopback".into()), + }], + subcommands: vec![], + }, + SubcommandSpec { + verb: "org".into(), + about: "Manage orgs".into(), + args: vec![], + subcommands: vec![SubcommandSpec { + verb: "switch".into(), + about: "Set active org".into(), + args: vec![ArgSpec::Positional { + name: "slug".into(), + help: Some("Organization slug".into()), + required: true, + value_type: ValueType::String, + }], + subcommands: vec![], + }], + }, + ], + } + } + + #[test] + fn builds_clap_command_from_spec() { + let cmd = build_command(&cloud_spec()); + // Should have "login" and "org" subcommands + let subs: Vec<&str> = cmd + .get_subcommands() + .map(|c| c.get_name()) + .collect(); + assert!(subs.contains(&"login")); + assert!(subs.contains(&"org")); + } + + #[test] + fn parses_flag_subcommand() { + let cmd = build_command(&cloud_spec()); + let matches = cmd.try_get_matches_from(["cloud", "login", "--paste"]).unwrap(); + let (verb_path, args) = extract_args(&matches); + assert_eq!(verb_path, vec!["cloud", "login"]); + assert_eq!(args["paste"], true); + } + + #[test] + fn parses_nested_positional() { + let cmd = build_command(&cloud_spec()); + let matches = cmd.try_get_matches_from(["cloud", "org", "switch", "acme"]).unwrap(); + let (verb_path, args) = extract_args(&matches); + assert_eq!(verb_path, vec!["cloud", "org", "switch"]); + assert_eq!(args["slug"], "acme"); + } + + #[test] + fn missing_required_arg_errors() { + let cmd = build_command(&cloud_spec()); + assert!(cmd.try_get_matches_from(["cloud", "org", "switch"]).is_err()); + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test -p hm-plugin-runtime -- clap_bridge` +Expected: FAIL — module doesn't exist. + +**Step 3: Implement `build_command` and `extract_args`** + +```rust +//! Converts plugin SubcommandSpec trees into clap Commands +//! and extracts parsed matches back into JSON. + +use clap::{Arg, ArgAction, ArgMatches, Command}; +use hm_plugin_protocol::manifest::{ArgSpec, SubcommandSpec, ValueType}; + +/// Build a `clap::Command` from a plugin's `SubcommandSpec`. +pub fn build_command(spec: &SubcommandSpec) -> Command { + let mut cmd = Command::new(spec.verb.clone()) + .about(spec.about.clone()) + .disable_help_subcommand(true) + .arg_required_else_help(!spec.subcommands.is_empty() && spec.args.is_empty()); + + for arg_spec in &spec.args { + cmd = cmd.arg(build_arg(arg_spec)); + } + + for sub in &spec.subcommands { + cmd = cmd.subcommand(build_command(sub)); + } + + cmd +} + +fn build_arg(spec: &ArgSpec) -> Arg { + match spec { + ArgSpec::Positional { + name, + help, + required, + .. + } => { + let mut arg = Arg::new(name.clone()).required(*required); + if let Some(h) = help { + arg = arg.help(h.clone()); + } + arg + } + ArgSpec::Option { + long, + short, + help, + required, + default, + .. + } => { + let mut arg = Arg::new(long.clone()) + .long(long.clone()) + .required(*required); + if let Some(s) = short { + arg = arg.short(*s); + } + if let Some(h) = help { + arg = arg.help(h.clone()); + } + if let Some(d) = default { + arg = arg.default_value(d.clone()); + } + arg + } + ArgSpec::Flag { + long, short, help, .. + } => { + let mut arg = Arg::new(long.clone()) + .long(long.clone()) + .action(ArgAction::SetTrue); + if let Some(s) = short { + arg = arg.short(*s); + } + if let Some(h) = help { + arg = arg.help(h.clone()); + } + arg + } + } +} + +/// Walk the matched subcommand chain and collect verb_path + args JSON. +pub fn extract_args(matches: &ArgMatches) -> (Vec, serde_json::Value) { + let mut verb_path = Vec::new(); + let mut current = matches; + + // The top-level command name isn't in matches, caller provides it + // via the spec. Walk into subcommands: + loop { + if let Some((name, sub)) = current.subcommand() { + verb_path.push(name.to_string()); + current = sub; + } else { + break; + } + } + + let args = extract_match_args(current); + (verb_path, args) +} + +fn extract_match_args(matches: &ArgMatches) -> serde_json::Value { + let mut map = serde_json::Map::new(); + for id in matches.ids() { + let id_str = id.as_str(); + if let Some(values) = matches.try_get_raw(id_str).ok().flatten() { + let strs: Vec<&str> = values + .filter_map(|v| v.to_str().ok()) + .collect(); + if strs.len() == 1 { + map.insert(id_str.into(), serde_json::Value::String(strs[0].into())); + } + } else if let Ok(true) = matches.try_get_one::(id_str) { + map.insert(id_str.into(), serde_json::Value::Bool(true)); + } + } + serde_json::Value::Object(map) +} +``` + +Note: The exact `extract_match_args` implementation may need refinement — clap's `ArgMatches` API for dynamic commands requires care. The tests will validate correctness. Iterate until tests pass. + +**Step 4: Run test to verify it passes** + +Run: `cargo test -p hm-plugin-runtime -- clap_bridge` +Expected: PASS + +**Step 5: Commit** + +```bash +git add -A +git commit -m "feat(runtime): clap_bridge — build Command from SubcommandSpec, extract parsed args" +``` + +--- + +### Task 3: Inject plugin subcommands into top-level CLI + +Wire the clap bridge into `cli/mod.rs` so plugin subcommands appear in `hm --help` and are parsed by clap directly, replacing the `#[command(external_subcommand)]` fallback. + +**Files:** +- Modify: `crates/hm/src/cli/mod.rs` +- Modify: `crates/hm/src/cli/external.rs` +- Modify: `crates/hm/src/main.rs` (if CLI construction is there) + +**Step 1: Understand the current entry point** + +Read `crates/hm/src/main.rs` to see how `Cli::parse()` is called. The key insight: we need to replace `Cli::parse()` (which uses the derive API) with a two-phase approach: +1. Load the plugin registry to discover subcommand specs +2. Build the CLI `Command` with plugin subcommands appended +3. Parse argv against the augmented command +4. Route to built-in handlers or plugin dispatch + +**Step 2: Add a function that augments the clap Command** + +In `crates/hm/src/cli/mod.rs`, add: + +```rust +use hm_plugin_runtime::clap_bridge; + +/// Append plugin-provided subcommands to the base CLI command. +pub fn augment_with_plugins( + mut cmd: clap::Command, + specs: &[(String, SubcommandSpec)], // (plugin_name, spec) +) -> clap::Command { + for (_, spec) in specs { + cmd = cmd.subcommand(clap_bridge::build_command(spec)); + } + cmd +} +``` + +**Step 3: Change CLI dispatch to use augmented command** + +Replace `Cli::parse()` with: + +```rust +// 1. Build base command from derive +let base_cmd = Cli::command(); + +// 2. Load plugin registry (discovery only, no full host API needed) +let registry = PluginRegistry::load(RegistryConfig { + auto_discover: true, + ..Default::default() +})?; + +// 3. Collect subcommand specs from plugin manifests +let plugin_specs: Vec<(String, SubcommandSpec)> = registry + .manifests() + .flat_map(|m| m.capabilities.iter().filter_map(|c| match c { + Capability::Subcommand(s) => Some((m.name.clone(), s.clone())), + _ => None, + })) + .collect(); + +// 4. Augment and parse +let augmented = augment_with_plugins(base_cmd, &plugin_specs); +let matches = augmented.get_matches(); + +// 5. Route: check if it's a built-in command (derive-parse) or a plugin subcommand +``` + +**Step 4: Update the dispatch logic** + +The tricky part: built-in commands (`run`, `version`, `plugin`, `dev`) still use the derive-parsed `Cli` struct. Plugin subcommands use the dynamic `ArgMatches`. + +Approach: Try derive-parsing first. If the subcommand isn't recognized by the derive parser, check if it matches a plugin verb and route through `external::run` with the parsed `ArgMatches`. + +Change `external::run` signature: + +```rust +// OLD: +pub async fn run(argv: Vec) -> Result + +// NEW: +pub async fn run( + verb: &str, + verb_path: Vec, + args: serde_json::Value, + registry: &PluginRegistry, +) -> Result +``` + +The host now passes structured args instead of raw argv. + +**Step 5: Remove `#[command(external_subcommand)]`** + +Delete the `External(Vec)` variant from `Command` enum — no longer needed since all subcommands are now in the clap tree. + +**Step 6: Verify `hm --help` shows plugin subcommands** + +Run: `cargo run -- --help` +Expected: Output includes `cloud Talk to the Harmont cloud API` alongside built-in commands. + +**Step 7: Commit** + +```bash +git add -A +git commit -m "feat(cli): inject plugin subcommands into clap, host-side arg parsing" +``` + +--- + +### Task 4: Update `SubcommandInput` flow and plugin-side consumption + +Update the host to populate `SubcommandInput.args` with real parsed JSON, and update plugins to consume structured args instead of parsing raw argv. + +**Files:** +- Modify: `crates/hm/src/cli/external.rs` +- Modify: `crates/hm/plugins/hm-plugin-cloud/src/lib.rs` +- Modify: `crates/hm/plugins/hm-plugin-cloud/src/cli.rs` + +**Step 1: Write a test for the cloud plugin receiving parsed args** + +In an integration test or the cloud plugin's test module: + +```rust +#[tokio::test] +async fn cloud_login_receives_parsed_args() { + let input = SubcommandInput { + verb_path: vec!["cloud".into(), "login".into()], + args: serde_json::json!({"paste": true}), + env: BTreeMap::new(), + }; + // The plugin should be able to dispatch from structured args + // without needing to parse raw argv +} +``` + +**Step 2: Update cloud plugin to dispatch from `SubcommandInput.args`** + +Replace the clap parsing in `cli.rs` with JSON deserialization. The plugin's `dispatch` function changes: + +```rust +// OLD: parse raw argv with clap +pub(crate) async fn dispatch( + ctx: &PluginContext<'_>, + argv: Vec, + env: BTreeMap, +) -> Result + +// NEW: route based on verb_path, deserialize args from JSON +pub(crate) async fn dispatch( + ctx: &PluginContext<'_>, + input: SubcommandInput, +) -> Result { + let verb = input.verb_path.last().map(String::as_str).unwrap_or(""); + match verb { + "login" => { + let paste = input.args.get("paste") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + auth::login::run(ctx, &input.env, paste).await + } + "logout" => auth::logout::run(ctx, &input.env).await, + // ... etc + } +} +``` + +**Step 3: Update `Cloud`'s `SubcommandPlugin::run` impl** + +```rust +impl SubcommandPlugin for Cloud { + fn run<'a>( + &'a self, + ctx: &'a PluginContext<'a>, + input: SubcommandInput, + ) -> impl Future> + Send + 'a { + async move { cli::dispatch(ctx, input).await } + } +} +``` + +**Step 4: Update the cloud plugin manifest with real `ArgSpec`s** + +The manifest must now declare the full arg tree: + +```rust +capabilities: vec![Capability::Subcommand(SubcommandSpec { + verb: "cloud".into(), + about: "Talk to the Harmont cloud API".into(), + args: vec![], + subcommands: vec![ + SubcommandSpec { + verb: "login".into(), + about: "Authenticate this CLI against the Harmont API".into(), + args: vec![ArgSpec::Flag { + long: "paste".into(), + short: None, + help: Some("Skip the loopback flow and prompt for a paste-in code".into()), + }], + subcommands: vec![], + }, + SubcommandSpec { + verb: "logout".into(), + about: "Remove stored credentials".into(), + args: vec![], + subcommands: vec![], + }, + // ... all other subcommands with their ArgSpecs + ], +})], +``` + +This is verbose. Task 6 adds an SDK helper macro to generate this from clap derives. For now, write it out manually for the cloud plugin. + +**Step 5: Remove clap dependency from cloud plugin** + +In `crates/hm/plugins/hm-plugin-cloud/Cargo.toml`, remove: +```toml +clap = { ... } +``` + +Delete `CloudCli`, `CloudCommand`, and all clap derive types from `cli.rs`. + +**Step 6: Run integration tests** + +Run: `cargo test --workspace` +Expected: PASS — cloud plugin dispatches from structured args. + +**Step 7: Commit** + +```bash +git add -A +git commit -m "feat(cloud): consume parsed args from host instead of raw argv" +``` + +--- + +### Task 5: Update test fixture plugins + +Update `failing-subcommand` and `host-fn-probe` fixtures to use the new `args: vec![]` manifest format. These are simple — they ignore args entirely, so no dispatch changes needed. + +**Files:** +- Modify: `tests/fixtures/failing-subcommand/src/lib.rs` +- Modify: `tests/fixtures/host-fn-probe/src/lib.rs` +- Modify: any other fixtures with `SubcommandSpec` + +**Step 1: Update manifests** + +Already done in Task 1 Step 5 (compile fix). Verify the fixtures still build and tests pass. + +**Step 2: Run all integration tests** + +Run: `cargo test -p harmont-cli --test plugin_host_fns --test plugin_manifest --test plugin_registry --test runner_dispatch` +Expected: PASS + +**Step 3: Commit** (if any changes needed beyond Task 1) + +```bash +git add -A +git commit -m "test: update fixture plugins for ArgSpec manifest format" +``` + +--- + +### Task 6: SDK helper to generate `SubcommandSpec` from clap derives + +Plugin authors shouldn't hand-write `ArgSpec` trees. Add an SDK function that introspects a clap `Command` (built from `#[derive(Parser)]`) and produces a `SubcommandSpec`. + +**Files:** +- Create: `crates/hm-plugin-sdk/src/spec_from_clap.rs` +- Modify: `crates/hm-plugin-sdk/src/lib.rs` (add `pub mod spec_from_clap`) + +**Step 1: Write the failing test** + +```rust +#[cfg(test)] +mod tests { + use super::*; + use clap::{Parser, Subcommand}; + + #[derive(Debug, Parser)] + #[command(name = "example", about = "Example plugin")] + struct ExampleCli { + #[command(subcommand)] + command: ExampleCommand, + } + + #[derive(Debug, Subcommand)] + enum ExampleCommand { + /// Do the thing. + DoIt { + /// Target name. + name: String, + /// Dry run. + #[arg(long)] + dry_run: bool, + }, + } + + #[test] + fn generates_spec_from_clap_command() { + let cmd = ExampleCli::command(); + let spec = spec_from_command(&cmd); + assert_eq!(spec.verb, "example"); + assert_eq!(spec.subcommands.len(), 1); + assert_eq!(spec.subcommands[0].verb, "do-it"); + assert_eq!(spec.subcommands[0].args.len(), 2); + } +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cargo test -p hm-plugin-sdk -- spec_from_clap` +Expected: FAIL + +**Step 3: Implement `spec_from_command`** + +```rust +use clap::Command; +use hm_plugin_protocol::manifest::{ArgSpec, SubcommandSpec, ValueType}; + +/// Build a `SubcommandSpec` by introspecting a clap `Command`. +/// Use this in `hm_plugin!` to generate the manifest from your +/// clap derive types automatically. +pub fn spec_from_command(cmd: &Command) -> SubcommandSpec { + let args: Vec = cmd + .get_arguments() + .filter(|a| a.get_id() != "help" && a.get_id() != "version") + .map(arg_spec_from_clap_arg) + .collect(); + + let subcommands: Vec = cmd + .get_subcommands() + .filter(|c| c.get_name() != "help") + .map(spec_from_command) + .collect(); + + SubcommandSpec { + verb: cmd.get_name().to_string(), + about: cmd.get_about().map_or_else(String::new, |s| s.to_string()), + args, + subcommands, + } +} + +fn arg_spec_from_clap_arg(arg: &clap::Arg) -> ArgSpec { + let is_flag = arg.get_action().is_set_true() + || arg.get_action().is_count(); + let is_positional = arg.get_long().is_none() && arg.get_short().is_none(); + + if is_flag { + ArgSpec::Flag { + long: arg.get_long().unwrap_or(arg.get_id().as_str()).to_string(), + short: arg.get_short(), + help: arg.get_help().map(|s| s.to_string()), + } + } else if is_positional { + ArgSpec::Positional { + name: arg.get_id().to_string(), + help: arg.get_help().map(|s| s.to_string()), + required: arg.is_required_set(), + value_type: ValueType::String, + } + } else { + ArgSpec::Option { + long: arg.get_long().unwrap_or(arg.get_id().as_str()).to_string(), + short: arg.get_short(), + help: arg.get_help().map(|s| s.to_string()), + required: arg.is_required_set(), + value_type: ValueType::String, + default: arg.get_default_values().first().map(|v| v.to_str().unwrap_or("").to_string()), + } + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cargo test -p hm-plugin-sdk -- spec_from_clap` +Expected: PASS + +**Step 5: Commit** + +```bash +git add -A +git commit -m "feat(sdk): spec_from_command — generate SubcommandSpec from clap Command" +``` + +--- + +### Task 7: Migrate cloud plugin to use `spec_from_command` + +Replace the hand-written `SubcommandSpec` tree in the cloud plugin manifest with the SDK helper, keeping clap as a dev/build dependency only for manifest generation. + +**Files:** +- Modify: `crates/hm/plugins/hm-plugin-cloud/src/lib.rs` + +**Step 1: Generate the spec from the existing clap types** + +Since the cloud plugin no longer parses argv at runtime (Task 4 removed that), the clap derive types can move to a `manifest` module used only for spec generation: + +```rust +// In lib.rs, the manifest generation: +use hm_plugin_sdk::spec_from_clap::spec_from_command; + +// Keep the clap derives just for spec generation +mod manifest_schema { + use clap::{Parser, Subcommand}; + // ... CloudCli, CloudCommand, etc. (the clap derive types) +} + +hm_plugin!( + manifest = PluginManifest { + // ... + capabilities: vec![Capability::Subcommand( + spec_from_command(&manifest_schema::CloudCli::command()) + )], + // ... + }, + subcommand = Cloud, +); +``` + +This way the clap types define the schema once, the SDK helper converts to `SubcommandSpec` for the manifest, and the host parses args at runtime. + +**Step 2: Verify integration tests pass** + +Run: `cargo test --workspace` +Expected: PASS + +**Step 3: Commit** + +```bash +git add -A +git commit -m "refactor(cloud): generate SubcommandSpec from clap derives via SDK helper" +``` + +--- + +## Verification + +1. `cargo check --workspace` — clean compile +2. `cargo test --workspace` — all tests pass +3. `cargo run -- --help` — shows plugin subcommands (e.g., `cloud`) +4. `cargo run -- cloud --help` — shows cloud sub-subcommands with help from ArgSpec +5. `cargo run -- cloud login --paste` — host parses `--paste`, plugin receives `{"paste": true}` +6. `cargo run -- cloud org switch acme` — host parses positional, plugin receives `{"slug": "acme"}` diff --git a/docs/plans/2026-05-23-extract-plugin-runtime-crate.md b/docs/plans/2026-05-23-extract-plugin-runtime-crate.md new file mode 100644 index 0000000..b6376c4 --- /dev/null +++ b/docs/plans/2026-05-23-extract-plugin-runtime-crate.md @@ -0,0 +1,464 @@ +# Extract Plugin Runtime Crate + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Extract the plugin loading/registry/host-API code from `crates/hm/src/plugin/` into a standalone `crates/hm-plugin-runtime/` crate so the runtime is reusable, testable in isolation, and decoupled from CLI concerns. + +**Architecture:** Move 6 modules (`host.rs`, `host_api.rs`, `registry.rs`, `manifest.rs`, `paths.rs`, `install.rs`) into `crates/hm-plugin-runtime/src/`. The only coupling to the binary is `crate::error::HmError` — extract plugin-specific error variants into a new `RuntimeError` enum owned by the runtime crate. The binary's `HmError` wraps `RuntimeError` via `#[from]`. The binary's `plugin` module becomes a thin re-export shim. Integration tests keep working because they import from `harmont_cli::plugin`, which re-exports from the new crate. + +**Tech Stack:** Rust, stabby, libloading, tokio, serde_json, reqwest (install only) + +--- + +## Task 1: Create the `hm-plugin-runtime` crate scaffold + +**Files:** +- Create: `crates/hm-plugin-runtime/Cargo.toml` +- Create: `crates/hm-plugin-runtime/src/lib.rs` +- Modify: `Cargo.toml` (workspace root) + +### Step 1: Create the crate directory + +```bash +mkdir -p crates/hm-plugin-runtime/src +``` + +### Step 2: Create `Cargo.toml` + +```toml +[package] +name = "hm-plugin-runtime" +version = "0.0.0-dev" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Plugin loading, discovery, and host-API runtime for Harmont CLI." + +[dependencies] +hm-plugin-protocol = { workspace = true } +hm-plugin-sdk = { workspace = true } +hm-util = { workspace = true } +stabby = { workspace = true } +libloading = "0.8" +tokio = { workspace = true } +tokio-util = { workspace = true } +serde_json = { workspace = true } +anyhow = "1" +thiserror = { workspace = true } +semver = { workspace = true } +tracing = "0.1" +chrono = { workspace = true } +uuid = { workspace = true } +reqwest = { version = "0.13", default-features = false, features = ["rustls"] } +sha2 = "0.10" +hex = "0.4" +tempfile = "3" + +[lints] +workspace = true +``` + +### Step 3: Create `src/lib.rs` + +```rust +//! Plugin loading, discovery, and host-API runtime. + +pub mod error; +pub mod host; +pub mod host_api; +pub mod install; +pub mod manifest; +pub mod paths; +pub mod registry; + +pub use host::LoadedPlugin; +pub use registry::{PluginRegistry, RegistryConfig}; +``` + +### Step 4: Add to workspace + +In root `Cargo.toml`, add `"crates/hm-plugin-runtime"` to the `members` array and `default-members` array. Add to `[workspace.dependencies]`: + +```toml +hm-plugin-runtime = { path = "crates/hm-plugin-runtime", version = "0.0.0-dev" } +``` + +### Step 5: Verify + +```bash +# Won't compile yet — modules are empty. Just check structure. +ls crates/hm-plugin-runtime/src/ +``` + +### Step 6: Commit + +```bash +git add crates/hm-plugin-runtime/ Cargo.toml +git commit -m "feat(plugin-runtime): scaffold hm-plugin-runtime crate" +``` + +--- + +## Task 2: Define `RuntimeError` in the new crate + +**Files:** +- Create: `crates/hm-plugin-runtime/src/error.rs` + +### Step 1: Create `error.rs` + +Extract the 6 plugin-specific error variants from `crates/hm/src/error.rs` into a new `RuntimeError` enum. These are the variants used by `host.rs` and `registry.rs`: + +```rust +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum RuntimeError { + #[error("plugin '{name}' failed to load from {path}: {reason}")] + PluginLoad { + name: String, + 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, + }, +} +``` + +### Step 2: Verify + +```bash +cargo check -p hm-plugin-runtime +``` + +### Step 3: Commit + +```bash +git add crates/hm-plugin-runtime/src/error.rs +git commit -m "feat(plugin-runtime): define RuntimeError for plugin system errors" +``` + +--- + +## Task 3: Move modules into the new crate + +**Files:** +- Move: `crates/hm/src/plugin/host.rs` → `crates/hm-plugin-runtime/src/host.rs` +- Move: `crates/hm/src/plugin/host_api.rs` → `crates/hm-plugin-runtime/src/host_api.rs` +- Move: `crates/hm/src/plugin/registry.rs` → `crates/hm-plugin-runtime/src/registry.rs` +- Move: `crates/hm/src/plugin/manifest.rs` → `crates/hm-plugin-runtime/src/manifest.rs` +- Move: `crates/hm/src/plugin/paths.rs` → `crates/hm-plugin-runtime/src/paths.rs` +- Move: `crates/hm/src/plugin/install.rs` → `crates/hm-plugin-runtime/src/install.rs` + +### Step 1: Copy files + +```bash +cp crates/hm/src/plugin/host.rs crates/hm-plugin-runtime/src/host.rs +cp crates/hm/src/plugin/host_api.rs crates/hm-plugin-runtime/src/host_api.rs +cp crates/hm/src/plugin/registry.rs crates/hm-plugin-runtime/src/registry.rs +cp crates/hm/src/plugin/manifest.rs crates/hm-plugin-runtime/src/manifest.rs +cp crates/hm/src/plugin/paths.rs crates/hm-plugin-runtime/src/paths.rs +cp crates/hm/src/plugin/install.rs crates/hm-plugin-runtime/src/install.rs +``` + +### Step 2: Fix imports in all 6 files + +In every moved file, replace: +- `use crate::error::HmError;` → `use crate::error::RuntimeError;` +- `HmError::PluginPanic` → `RuntimeError::PluginPanic` (and all other variant references) +- `HmError::PluginLoad` → `RuntimeError::PluginLoad` +- `HmError::PluginManifest` → `RuntimeError::PluginManifest` +- `HmError::PluginMissingHostFn` → `RuntimeError::PluginMissingHostFn` +- `HmError::PluginConflict` → `RuntimeError::PluginConflict` +- `use super::` → `use crate::` (modules are now siblings in the new crate) + +Specific changes per file: + +**host.rs:** +- `use super::host_api::HostApiImpl;` → `use crate::host_api::HostApiImpl;` +- `use crate::error::HmError;` → `use crate::error::RuntimeError;` +- `HmError::PluginPanic` → `RuntimeError::PluginPanic` (in `ffi_err_to_anyhow`) + +**host_api.rs:** +- No `crate::` imports to fix (only uses external crates and `hm_plugin_sdk`) +- `use tokio_util::sync::CancellationToken;` stays as-is + +**registry.rs:** +- `use super::host::LoadedPlugin;` → `use crate::host::LoadedPlugin;` +- `use super::host_api::HostApiImpl;` → `use crate::host_api::HostApiImpl;` +- `use super::manifest::{ManifestError, validate_standalone};` → `use crate::manifest::{ManifestError, validate_standalone};` +- `use super::paths;` → `use crate::paths;` +- `use crate::error::HmError;` → `use crate::error::RuntimeError;` +- All `HmError::PluginConflict` → `RuntimeError::PluginConflict` +- All `HmError::PluginManifest` → `RuntimeError::PluginManifest` +- All `HmError::PluginLoad` → `RuntimeError::PluginLoad` + +**manifest.rs:** +- No `crate::` import changes needed (only uses `hm_plugin_protocol`) + +**paths.rs:** +- No changes needed (only uses `hm_util` and `std`) + +**install.rs:** +- `use super::host::LoadedPlugin;` → `use crate::host::LoadedPlugin;` +- `use super::host_api::HostApiImpl;` → `use crate::host_api::HostApiImpl;` +- `use super::paths;` → `use crate::paths;` + +### Step 3: Verify + +```bash +cargo check -p hm-plugin-runtime +``` + +### Step 4: Commit + +```bash +git add crates/hm-plugin-runtime/src/ +git commit -m "feat(plugin-runtime): move plugin modules into runtime crate" +``` + +--- + +## Task 4: Wire `HmError` to wrap `RuntimeError` + +**Files:** +- Modify: `crates/hm/Cargo.toml` — add `hm-plugin-runtime` dependency +- Modify: `crates/hm/src/error.rs` — replace plugin variants with `#[from] RuntimeError` +- Modify: `crates/hm/src/plugin/mod.rs` — re-export from runtime crate + +### Step 1: Add dependency + +In `crates/hm/Cargo.toml`, add: + +```toml +hm-plugin-runtime = { workspace = true } +``` + +### Step 2: Update `error.rs` + +Replace the 6 plugin-specific variants in `HmError` with a single wrapper: + +```rust +#[error(transparent)] +PluginRuntime(#[from] hm_plugin_runtime::error::RuntimeError), +``` + +Delete these variants from `HmError`: +- `PluginLoad { name, path, reason, doc_url }` +- `PluginManifest { name, expected_api, found_api }` +- `PluginMissingHostFn { name, fn_name, min_hm_version }` +- `PluginPanic { name, capability, message }` +- `PluginTimeout { name, capability, after_ms }` +- `PluginConflict { verb, plugin_a, plugin_b }` + +Update the `category()` match in `HmError` to handle the new wrapper variant. The `RuntimeError` variants map to two categories: + +```rust +Self::PluginRuntime(ref e) => { + use hm_plugin_runtime::error::RuntimeError; + match e { + RuntimeError::PluginLoad { .. } + | RuntimeError::PluginManifest { .. } + | RuntimeError::PluginMissingHostFn { .. } + | RuntimeError::PluginConflict { .. } => ErrorCategory::PluginLoad, + RuntimeError::PluginPanic { .. } + | RuntimeError::PluginTimeout { .. } => ErrorCategory::PluginRuntime, + } +}, +``` + +### Step 3: Rewrite `crates/hm/src/plugin/mod.rs` as re-export shim + +Replace the entire module with re-exports from the new crate: + +```rust +//! Plugin system — re-exports from `hm_plugin_runtime`. + +pub use hm_plugin_runtime::host; +pub use hm_plugin_runtime::host_api; +pub use hm_plugin_runtime::install; +pub use hm_plugin_runtime::manifest; +pub use hm_plugin_runtime::paths; +pub use hm_plugin_runtime::registry; + +pub use hm_plugin_runtime::{LoadedPlugin, PluginRegistry, RegistryConfig}; +``` + +### Step 4: Delete original source files + +```bash +rm crates/hm/src/plugin/host.rs +rm crates/hm/src/plugin/host_api.rs +rm crates/hm/src/plugin/registry.rs +rm crates/hm/src/plugin/manifest.rs +rm crates/hm/src/plugin/paths.rs +rm crates/hm/src/plugin/install.rs +``` + +### Step 5: Verify + +```bash +cargo check --workspace +``` + +All callers in the binary (`cli/plugin.rs`, `cli/external.rs`, `cli/version.rs`, `orchestrator/scheduler.rs`) import via `crate::plugin::` which now re-exports from the runtime crate. They should compile without changes. + +### Step 6: Commit + +```bash +git add crates/hm/ crates/hm-plugin-runtime/ Cargo.lock +git commit -m "refactor: wire HmError to wrap RuntimeError, delete original plugin sources" +``` + +--- + +## Task 5: Remove plugin-only dependencies from the binary crate + +**Files:** +- Modify: `crates/hm/Cargo.toml` + +### Step 1: Remove dependencies that are now only used by `hm-plugin-runtime` + +These were only used by the plugin modules and can be removed from `crates/hm/Cargo.toml`: + +- `stabby` — only used in `host.rs` (now in runtime crate) +- `libloading` — only used in `host.rs` (now in runtime crate) + +Do NOT remove these — they are still used elsewhere in the binary: +- `sha2` — used by `creds_store.rs` +- `hex` — used by `creds_store.rs` +- `reqwest` — used by cloud/API code +- `tempfile` — used by tests +- `hm-plugin-sdk` — still used by integration tests that import `ffi` types directly +- `hm-plugin-protocol` — used by orchestrator, commands, output + +### Step 2: Verify + +```bash +cargo check --workspace +``` + +### Step 3: Commit + +```bash +git add crates/hm/Cargo.toml Cargo.lock +git commit -m "chore: remove stabby/libloading from binary crate (moved to plugin-runtime)" +``` + +--- + +## Task 6: Fix integration tests + +**Files:** +- Modify: `crates/hm/tests/plugin_host_fns.rs` +- Modify: `crates/hm/tests/plugin_manifest.rs` +- Modify: `crates/hm/tests/plugin_registry.rs` +- Modify: `crates/hm/tests/plugin_kv_concurrency.rs` +- Modify: `crates/hm/tests/runner_dispatch.rs` + +### Step 1: Check if tests compile + +```bash +cargo test --workspace --no-run 2>&1 | head -30 +``` + +Integration tests import `harmont_cli::plugin::*`. Since `mod.rs` re-exports everything, they should already work. If any test directly imports a type that moved (like `harmont_cli::plugin::host::dummy_subcommand_input`), the re-export shim handles it via `pub use hm_plugin_runtime::host`. + +If there are compilation errors, fix the imports. The pattern is always: +- `harmont_cli::plugin::Foo` → still works (re-exported) +- `harmont_cli::plugin::host::Foo` → still works (`pub use hm_plugin_runtime::host`) + +### Step 2: Run tests + +```bash +cargo test -p harmont-cli --test plugin_host_fns --test plugin_manifest --test plugin_registry --test runner_dispatch --test plugin_kv_concurrency +``` + +### Step 3: Run full workspace check + +```bash +cargo check --workspace +cargo test --workspace -- --skip cmd_run_local_autoselect --skip zero_pipelines --skip many_pipelines --skip version_prints_api --skip cmd_cloud +``` + +### Step 4: Commit (if any fixes were needed) + +```bash +git add crates/hm/tests/ +git commit -m "fix: update integration test imports for plugin-runtime extraction" +``` + +--- + +## Task 7: Update documentation + +**Files:** +- Modify: `CLAUDE.md` +- Modify: `crates/hm/CLAUDE.md` +- Modify: `crates/hm-plugin-runtime/src/lib.rs` (module doc) + +### Step 1: Update root `CLAUDE.md` + +Add `crates/hm-plugin-runtime/` to the crate listing: + +``` +- `crates/hm-plugin-runtime/` — plugin loading, discovery, host-API runtime. Owns `LoadedPlugin`, `PluginRegistry`, `HostApiImpl`. +``` + +### Step 2: Update `crates/hm/CLAUDE.md` + +In the "Plugin system" section, note that the runtime is now in a separate crate: + +``` +Plugin runtime lives in `crates/hm-plugin-runtime/`. The `plugin/` +module in this crate re-exports everything from the runtime crate. +``` + +### Step 3: Verify + +```bash +cargo check --workspace +``` + +### Step 4: Commit + +```bash +git add CLAUDE.md crates/hm/CLAUDE.md +git commit -m "docs: update CLAUDE.md for plugin-runtime extraction" +``` diff --git a/docs/plans/2026-05-23-remove-output-formatter-plugins.md b/docs/plans/2026-05-23-remove-output-formatter-plugins.md new file mode 100644 index 0000000..d658586 --- /dev/null +++ b/docs/plans/2026-05-23-remove-output-formatter-plugins.md @@ -0,0 +1,279 @@ +# Remove Output Formatter Plugin Support + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Delete the output formatter plugin capability entirely. Move human and JSON formatting into the core `hm` binary so build output doesn't cross an FFI boundary. + +**Architecture:** Replace the plugin-mediated `output_subscriber → LoadedPlugin → on_output_event` path with a direct `output_subscriber → match on OutputMode { Human, Json }` that calls formatting functions in `crates/hm/src/output/`. The `render.rs` module from the human plugin moves into the binary. The JSON formatter is trivial (serde_json + newline to stdout). The `OutputFormatter` trait, `OutputFormatterSpec`, and the `output` keyword in `hm_plugin!` are all deleted. + +**Tech Stack:** Rust, tokio broadcast channel, serde_json + +--- + +## Task 1: Move formatting logic into the binary + +**Files:** +- Create: `crates/hm/src/output/build_events.rs` +- Modify: `crates/hm/src/output/mod.rs` + +### Step 1: Create `build_events.rs` + +Move the rendering logic from `crates/hm/plugins/hm-plugin-output-human/src/render.rs` into `crates/hm/src/output/build_events.rs`. This is the step-key tracking + event → bytes logic. + +Also add a JSON rendering function. The full module should contain: + +```rust +use std::collections::HashMap; +use hm_plugin_protocol::BuildEvent; +use uuid::Uuid; + +/// Tracks step_id → key mappings accumulated from StepQueued events. +pub(crate) struct BuildEventRenderer { + step_keys: HashMap, +} + +impl BuildEventRenderer { + pub fn new() -> Self { + Self { step_keys: HashMap::new() } + } + + /// Render a BuildEvent as human-readable stderr bytes. + pub fn render_human(&mut self, ev: &BuildEvent) -> Vec { + // Port the match arms from render.rs, using self.step_keys + // instead of a static Mutex. + } + + /// Render a BuildEvent as a JSON line (stdout). + pub fn render_json(&self, ev: &BuildEvent) -> Vec { + let mut bytes = serde_json::to_vec(ev).unwrap_or_default(); + bytes.push(b'\n'); + bytes + } +} +``` + +Key change from the plugin version: use `&mut self` with a `HashMap` field instead of a `static Mutex`. The renderer is owned by the output subscriber task, so no shared state needed. + +Port the tests from `render.rs` too (`build_start_renders_step_and_chain_counts`, `step_log_renders_with_prefix_after_step_queued_recorded_key`, `step_log_with_unknown_key_renders_question_mark`). + +### Step 2: Re-export from `output/mod.rs` + +Add `pub mod build_events;` to `crates/hm/src/output/mod.rs`. Add `OutputMode` variant awareness — the existing `OutputMode::Human` and `OutputMode::Json` already exist and will drive the formatting choice. + +### Step 3: Verify + +Run: `cargo test -p harmont-cli -- output::build_events` + +### Step 4: Commit + +```bash +git add crates/hm/src/output/ +git commit -m "feat(output): move build event formatting into core binary" +``` + +--- + +## Task 2: Rewrite output_subscriber to use direct formatting + +**Files:** +- Rewrite: `crates/hm/src/orchestrator/output_subscriber.rs` +- Modify: `crates/hm/src/orchestrator/scheduler.rs` + +### Step 1: Rewrite output_subscriber.rs + +Replace the plugin-dispatch loop with direct formatting. The subscriber no longer needs the plugin registry — it owns a `BuildEventRenderer` and writes to stdout/stderr directly. + +New signature: +```rust +pub fn spawn( + bus: Arc, + format: OutputMode, // was: registry + format_name string +) -> tokio::task::JoinHandle> +``` + +Inside the loop: +- Create a `BuildEventRenderer` before the loop +- On each event, call `renderer.render_human(&event)` or `renderer.render_json(&event)` based on `format` +- Human output → write to stderr (matching current plugin behavior) +- JSON output → write to stdout +- On `BuildEnd`, just return (no finalize step needed — both formatters stream) +- Keep the `Lagged` error handling as-is + +Use `std::io::Write` (locked stdout/stderr) for the actual writes — no async needed since formatting is CPU-bound and the writes are small. + +### Step 2: Update scheduler.rs + +Remove the format validation block (lines 126-141 in scheduler.rs) — the `--format` flag is now a compile-time enum, not a runtime string lookup. + +Change the `output_subscriber::spawn` call: +```rust +// Before: +let sink_handle = super::output_subscriber::spawn(bus.clone(), registry.clone(), format_name.clone()); + +// After: +let format = if format_name == "json" { OutputMode::Json } else { + OutputMode::Human { color: true, interactive: true } +}; +let sink_handle = super::output_subscriber::spawn(bus.clone(), format); +``` + +Remove the `format_name` parameter from `pub async fn run(...)`. Instead, pass `OutputMode` directly. Update the call site in `crates/hm/src/commands/run/local.rs` to convert the CLI string into `OutputMode` before calling the orchestrator. + +### Step 3: Verify + +Run: `cargo check -p harmont-cli` + +### Step 4: Commit + +```bash +git add crates/hm/src/orchestrator/ crates/hm/src/commands/run/ +git commit -m "refactor(orchestrator): output subscriber uses direct formatting, not plugins" +``` + +--- + +## Task 3: Remove output formatter from plugin system + +**Files:** +- Modify: `crates/hm-plugin-protocol/src/manifest.rs` — remove `OutputFormatter` variant from `Capability`, delete `OutputFormatterSpec` +- Modify: `crates/hm-plugin-protocol/src/lib.rs` — remove `OutputFormatterSpec` re-export +- Modify: `crates/hm/src/plugin/registry.rs` — remove `output_formatter_index` field and indexing logic +- Modify: `crates/hm/src/plugin/host.rs` — remove `on_output_event()` and `finalize_output()` methods +- Modify: `crates/hm-plugin-sdk/src/ffi.rs` — remove `on_output_event` and `finalize_output` from `RawPlugin` trait +- Modify: `crates/hm-plugin-sdk/src/lib.rs` — remove `pub mod output` and `OutputFormatter` re-export +- Delete: `crates/hm-plugin-sdk/src/output.rs` +- Modify: `crates/hm-plugin-macros/src/lib.rs` — remove `output` keyword parsing and `gen_on_output_event`/`gen_finalize_output` codegen +- Delete: `crates/hm/src/orchestrator/output_subscriber.rs` (replaced in Task 2, but the module decl stays if we renamed it — actually, we rewrote it in Task 2, so it stays; but remove the `pub mod output_subscriber` if it was renamed) + +### Step 1: Remove from protocol crate + +In `manifest.rs`: delete `OutputFormatter(OutputFormatterSpec)` from `Capability` enum, delete the `OutputFormatterSpec` struct. In `lib.rs`: remove `OutputFormatterSpec` from the re-exports. + +### Step 2: Remove from SDK + +Delete `crates/hm-plugin-sdk/src/output.rs`. In `lib.rs`: remove `pub mod output` and the `pub use output::OutputFormatter` re-export. + +In `ffi.rs`: remove the two trait methods from `RawPlugin`: +```rust +// DELETE: +extern "C" fn on_output_event<'a>(&'a self, event: FfiSlice<'a>) -> DynFutureUnsync<'a, FfiResult>; +extern "C" fn finalize_output<'a>(&'a self) -> DynFutureUnsync<'a, FfiResult>; +``` + +### Step 3: Remove from proc macro + +In `crates/hm-plugin-macros/src/lib.rs`: +- Remove `output` from the keyword parser (~line 60) +- Remove `output: Option` from `HmPluginArgs` (~line 81) +- Remove the `output` match arm in parsing (~lines 132-138) +- Remove `gen_on_output_event()` function (~line 360+) +- Remove the corresponding `gen_finalize_output()` function +- Remove the `output_field` and `output_init` generation in `expand()` +- Remove the calls to `gen_on_output_event` and `gen_finalize_output` in the trait impl generation + +### Step 4: Remove from host binary + +In `crates/hm/src/plugin/host.rs`: remove `on_output_event()` and `finalize_output()` async methods from `LoadedPlugin`. + +In `crates/hm/src/plugin/registry.rs`: remove `output_formatter_index` field, its initialization in `new()`, and the `Capability::OutputFormatter` arm in `index_capabilities()`. + +### Step 5: Fix compilation cascade + +Run `cargo check --workspace` and fix any remaining references. Expected breakage in test fixtures and protocol tests that reference `OutputFormatter` or `OutputFormatterSpec`. + +### Step 6: Commit + +```bash +git add -A +git commit -m "refactor: remove OutputFormatter capability from plugin system" +``` + +--- + +## Task 4: Delete output plugin crates + +**Files:** +- Delete: `crates/hm/plugins/hm-plugin-output-human/` (entire directory) +- Delete: `crates/hm/plugins/hm-plugin-output-json/` (entire directory) +- Modify: `Cargo.toml` (workspace root) — remove from `members` + +### Step 1: Delete crate directories + +```bash +rm -rf crates/hm/plugins/hm-plugin-output-human +rm -rf crates/hm/plugins/hm-plugin-output-json +``` + +### Step 2: Remove from workspace + +In root `Cargo.toml`, remove from `members`: +```toml +"crates/hm/plugins/hm-plugin-output-human", +"crates/hm/plugins/hm-plugin-output-json", +``` + +### Step 3: Update docs + +In `crates/hm/CLAUDE.md`: remove references to output plugins. Update `RELEASING.md` if it mentions them. + +### Step 4: Verify + +```bash +cargo check --workspace +cargo test --workspace +``` + +### Step 5: Commit + +```bash +git add -A +git commit -m "chore: delete output formatter plugin crates" +``` + +--- + +## Task 5: Clean up --format flag + +**Files:** +- Modify: `crates/hm/src/cli/run.rs` +- Modify: `crates/hm/src/commands/run/local.rs` + +### Step 1: Change --format to an enum + +In `cli/run.rs`, change the `format` field from `String` to a proper enum with `clap::ValueEnum`: + +```rust +#[derive(Debug, Clone, Copy, clap::ValueEnum)] +pub enum OutputFormat { + Human, + Json, +} + +// In RunArgs: +#[arg(long, value_name = "NAME", default_value = "human")] +pub format: OutputFormat, +``` + +### Step 2: Convert at call site + +In `commands/run/local.rs`, convert `OutputFormat` to `OutputMode` when calling the orchestrator: +```rust +let output_mode = match args.format { + OutputFormat::Human => OutputMode::Human { color: color_enabled, interactive: is_tty }, + OutputFormat::Json => OutputMode::Json, +}; +``` + +### Step 3: Verify + +```bash +cargo check -p harmont-cli +cargo test -p harmont-cli +``` + +### Step 4: Commit + +```bash +git add crates/hm/src/cli/ crates/hm/src/commands/run/ +git commit -m "refactor(cli): --format flag uses enum instead of string" +``` diff --git a/docs/plans/2026-05-23-stabby-plugin-rewrite.md b/docs/plans/2026-05-23-stabby-plugin-rewrite.md new file mode 100644 index 0000000..1bd500f --- /dev/null +++ b/docs/plans/2026-05-23-stabby-plugin-rewrite.md @@ -0,0 +1,1363 @@ +# Extism → Stabby Plugin System Rewrite + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Replace the extism/WASM plugin system with stabby-based native dylibs to enable async plugin code, reduce boilerplate, and give plugins full Rust ecosystem access. + +**Architecture:** Plugins become native shared libraries (`.dylib`/`.so`/`.dll`) loaded via stabby's `libloading` integration. A single `RawPlugin` stabby trait defines the FFI boundary; complex types cross the boundary as borsh-serialized bytes in `stabby::vec::Vec`. User-facing SDK provides ergonomic async Rust traits plus an `hm_plugin!` macro that generates all FFI glue. Host capabilities are passed as a `RawHostApi` stabby trait object instead of 32+ individual host functions. Plugins share the host's tokio runtime. + +**Tech Stack:** stabby `=72.1.1` (ABI locked), libloading (via stabby), borsh (wire format at FFI boundary), tokio (shared runtime) + +--- + +## Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Sandboxing | Trust plugins fully | Plugins are first-party or vetted; no WASM sandbox overhead | +| Built-in plugins | All as dylibs | Consistent model; built-ins go through same load path as third-party | +| Distribution | Pre-built per-platform | Plugin registry serves per-target-triple dylibs | +| Async model | Shared host runtime | Plugins run on host's tokio; simplest approach | +| ABI version | stabby `=72.1.1` locked | SemVer Prime guarantees same-version ABI compat | +| Wire format at boundary | borsh bytes | Faster/smaller than JSON; deterministic binary encoding; avoids making all protocol types `IStable` | + +## Architecture Overview + +``` +┌────────────────────────────────────────────────────────────────┐ +│ hm-plugin-protocol (mostly unchanged) │ +│ • Serde structs: PluginManifest, ExecutorInput, BuildEvent… │ +│ • Remove host_abi.rs (Docker/keyring/socket/tty types) │ +│ • Keep Level, KvScope (used by reduced host API) │ +│ • Remove allowed_hosts, required_host_fns from manifest │ +└────────────────────────────────────────────────────────────────┘ + │ +┌────────────────────────────────────────────────────────────────┐ +│ hm-plugin-sdk (rewritten) │ +│ • ffi.rs: RawPlugin + RawHostApi stabby traits (FFI boundary) │ +│ • traits.rs: async StepExecutor, LifecycleHook, etc. │ +│ • context.rs: PluginContext wrapping RawHostApi ergonomically │ +│ • macros.rs: hm_plugin! macro (generates RawPlugin + export) │ +│ • Depends on: stabby =72.1.1, hm-plugin-protocol, borsh │ +│ • NO extism-pdk dependency │ +└────────────────────────────────────────────────────────────────┘ + │ +┌─────────────────────────────┼──────────────────────────────────┐ +│ Plugin dylibs (.dylib/.so) │ hm binary (host) │ +│ • crate-type = ["cdylib"] │ • stabby libloading for loading │ +│ • #[stabby::export] entry │ • RawHostApi implementation │ +│ • Uses async freely │ • No more PluginPool/semaphore │ +│ • Full crate ecosystem │ • Registry discovers *.dylib │ +│ • No #![no_main] │ • No build.rs plugin compile │ +└─────────────────────────────┴──────────────────────────────────┘ +``` + +## FFI Boundary Design + +### RawPlugin (plugin → host) + +```rust +// crates/hm-plugin-sdk/src/ffi.rs + +use stabby::future::DynFuture; + +type FfiBytes = stabby::vec::Vec; +type FfiSlice<'a> = stabby::slice::Slice<'a, u8>; +type FfiResult = stabby::result::Result; + +#[stabby::stabby] +pub trait RawPlugin: Send + Sync { + extern "C" fn manifest(&self) -> FfiBytes; + fn execute_step<'a>(&'a self, input: FfiSlice<'a>) -> DynFuture<'a, FfiResult>; + fn on_hook_event<'a>(&'a self, event: FfiSlice<'a>) -> DynFuture<'a, FfiResult>; + fn run_subcommand<'a>(&'a self, input: FfiSlice<'a>) -> DynFuture<'a, FfiResult>; + fn on_output_event<'a>(&'a self, event: FfiSlice<'a>) -> DynFuture<'a, FfiResult>; + fn finalize_output<'a>(&'a self) -> DynFuture<'a, FfiResult>; +} +``` + +### RawHostApi (host → plugin) + +```rust +// crates/hm-plugin-sdk/src/ffi.rs (continued) + +#[stabby::stabby] +pub trait RawHostApi: Send + Sync { + extern "C" fn log(&self, level: u8, msg: FfiSlice<'_>); + extern "C" fn kv_get(&self, scope: u8, key: FfiSlice<'_>) -> stabby::option::Option; + extern "C" fn kv_set(&self, scope: u8, key: FfiSlice<'_>, val: FfiSlice<'_>); + extern "C" fn emit_event(&self, event_borsh: FfiSlice<'_>); + extern "C" fn emit_step_log(&self, stream: u8, bytes: FfiSlice<'_>); + extern "C" fn should_cancel(&self) -> bool; + extern "C" fn write_stdout(&self, bytes: FfiSlice<'_>); + extern "C" fn write_stderr(&self, bytes: FfiSlice<'_>); + extern "C" fn archive_read(&self, id_borsh: FfiSlice<'_>, offset: u64, max: u64) -> FfiBytes; + extern "C" fn archive_total_size(&self, id_borsh: FfiSlice<'_>) -> u64; + extern "C" fn fs_read_config(&self, rel_path: FfiSlice<'_>) -> stabby::option::Option; +} +``` + +11 host methods (down from 32+). Docker/keyring/socket/tty/loopback/browser operations are now direct crate usage by plugins. + +### Plugin entry point + +```rust +// Generated by hm_plugin! — each plugin dylib exports this symbol: +#[stabby::export] +extern "C" fn hm_load_plugin( + ctx: /* DynRef<'static, vtable!(RawHostApi + Send + Sync)> */ +) -> stabby::result::Result< + /* Dyn<'static, Box<()>, vtable!(RawPlugin + Send + Sync)> */, + FfiBytes +> +``` + +### User-facing traits (async, ergonomic) + +```rust +// crates/hm-plugin-sdk/src/traits.rs + +pub trait StepExecutor: Send + Sync + Default { + fn run(&self, ctx: &PluginContext, input: ExecutorInput) + -> impl Future> + Send + '_; +} + +pub trait LifecycleHook: Send + Sync + Default { + fn on_event(&self, ctx: &PluginContext, event: HookEvent) + -> impl Future> + Send + '_; +} + +pub trait SubcommandPlugin: Send + Sync + Default { + fn run(&self, ctx: &PluginContext, input: SubcommandInput) + -> impl Future> + Send + '_; +} + +pub trait OutputFormatter: Send + Sync + Default { + fn on_event(&self, ctx: &PluginContext, event: BuildEvent) + -> impl Future> + Send + '_; + + fn finalize(&self, ctx: &PluginContext) + -> impl Future, PluginError>> + Send + '_ { + async { Ok(Vec::new()) } + } +} +``` + +Plugin authors implement these with `async fn` (RPITIT, stable since Rust 1.75). The `hm_plugin!` macro wraps them in `DynFuture` at the FFI boundary. + +## Host-side changes + +### What gets removed + +| File | Why | +|------|-----| +| `src/plugin/pool.rs` | Native dylibs are thread-safe; no instance pooling needed | +| `src/plugin/host_fns.rs` (1065 lines) | 32 extism host functions → 11-method RawHostApi trait object | +| `src/plugin/signal.rs` | Cancellation signal reimplemented in RawHostApi | +| `src/plugin/embedded.rs` | No more embedding — plugins installed to `~/.harmont/plugins/` by `install.sh` | +| `build.rs` | No more WASM or plugin compilation at build time | + +### What gets rewritten + +| File | Change | +|------|--------| +| `src/plugin/host.rs` | `LoadedPlugin` wraps stabby `Library` + trait object instead of `PluginPool` | +| `src/plugin/registry.rs` | Discover `*.dylib`/`*.so` from `~/.harmont/plugins/` + `.harmont/plugins/`; no `HOST_FN_NAMES` validation; no `embedded` config field | +| `src/plugin/manifest.rs` | Simplified validation (no `required_host_fns`, no `allowed_hosts`) | +| `src/plugin/paths.rs` | Discovery paths: `~/.harmont/plugins/` (user/built-in) + `.harmont/plugins/` (project). Extension `.wasm` → platform dylib ext | + +### What stays unchanged + +- `src/orchestrator/scheduler.rs` — calls `plugin.call_capability()` which we'll preserve as async method +- `src/orchestrator/output_subscriber.rs` — same pattern, call into output plugin +- `src/dispatcher.rs` — same pattern, call into subcommand plugin +- `src/orchestrator/events.rs`, `graph.rs`, `cache.rs`, `archive.rs` — untouched + +## Protocol crate changes + +### Add borsh derives + +All wire types that cross the FFI boundary need `BorshSerialize` + `BorshDeserialize` derives in addition to existing serde derives. This includes: `PluginManifest`, `Capability`, `ExecutorInput`, `StepResult`, `HookEvent`, `HookOutcome`, `SubcommandInput`, `ExitInfo`, `BuildEvent`, `PluginError`, and all their transitive field types. Add `borsh = { workspace = true }` to `hm-plugin-protocol/Cargo.toml` and derive on each struct/enum. + +Types that DON'T need borsh (only used for JSON config/output, not FFI boundary): `Pipeline`, `CommandStep`, `WaitStep`, `Cache` (IR types parsed from YAML/JSON config files). + +`serde_json::Value` fields (`config_schema`, `args_schema`, `runner_args`) need special handling — borsh can't directly serialize `serde_json::Value`. Options: (a) serialize the Value to a JSON string first, then borsh the string, or (b) change these fields to `Option>` in the manifest (raw bytes). Option (a) is simplest: wrap in a newtype with a custom borsh impl that round-trips through JSON string. + +### Remove from `host_abi.rs` + +Move to docker plugin crate (internal types): +- `DockerStartArgs`, `DockerExecArgs`, `DockerCommitArgs`, `DockerExtractArgs` + +Delete entirely (plugins use native crates): +- `SocketHandle`, `SocketReadArgs`, `SocketWriteArgs` +- `LoopbackHandle`, `LoopbackRecvArgs`, `CallbackData` +- `KeyringArgs`, `KeyringSetArgs` +- `TtyPromptArgs`, `TtyConfirmArgs` + +Keep in `host_abi.rs`: +- `Level` (logging through host) +- `KvScope` (KV through host) +- `ArchiveReadArgs` (archive access through host) + +### Remove from `PluginManifest` + +- `allowed_hosts: Vec` — no HTTP sandboxing with native dylibs +- `required_host_fns: Vec` — no host function declaration needed + +--- + +## Task 1: Add stabby + define FFI boundary traits + +**Files:** +- Modify: `Cargo.toml` (workspace root) +- Modify: `crates/hm-plugin-sdk/Cargo.toml` +- Create: `crates/hm-plugin-sdk/src/ffi.rs` +- Modify: `crates/hm-plugin-sdk/src/lib.rs` + +**Step 1: Add stabby workspace dependency** + +In `Cargo.toml` (workspace root), add under `[workspace.dependencies]`: +```toml +stabby = { version = "=72.1.1", features = ["libloading"] } +borsh = { version = "1", features = ["derive"] } +``` + +Remove: +```toml +extism = "1" +extism-pdk = "1" +``` + +(Don't remove yet — crates still reference them. We'll remove at cleanup.) + +**Step 2: Update hm-plugin-sdk Cargo.toml** + +Replace `extism-pdk` with `stabby` + `borsh`: +```toml +[dependencies] +hm-plugin-protocol = { workspace = true } +stabby = { workspace = true } +borsh = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +``` + +**Step 3: Write the FFI trait definitions** + +Create `crates/hm-plugin-sdk/src/ffi.rs`: + +```rust +#![allow(unsafe_code)] + +use stabby::future::DynFuture; + +pub type FfiBytes = stabby::vec::Vec; +pub type FfiSlice<'a> = stabby::slice::Slice<'a, u8>; +pub type FfiResult = stabby::result::Result; + +#[stabby::stabby] +pub trait RawPlugin: Send + Sync { + extern "C" fn manifest(&self) -> FfiBytes; + fn execute_step<'a>(&'a self, input: FfiSlice<'a>) -> DynFuture<'a, FfiResult>; + fn on_hook_event<'a>(&'a self, event: FfiSlice<'a>) -> DynFuture<'a, FfiResult>; + fn run_subcommand<'a>(&'a self, input: FfiSlice<'a>) -> DynFuture<'a, FfiResult>; + fn on_output_event<'a>(&'a self, event: FfiSlice<'a>) -> DynFuture<'a, FfiResult>; + fn finalize_output<'a>(&'a self) -> DynFuture<'a, FfiResult>; +} + +#[stabby::stabby] +pub trait RawHostApi: Send + Sync { + extern "C" fn log(&self, level: u8, msg: FfiSlice<'_>); + extern "C" fn kv_get(&self, scope: u8, key: FfiSlice<'_>) -> stabby::option::Option; + extern "C" fn kv_set(&self, scope: u8, key: FfiSlice<'_>, val: FfiSlice<'_>); + extern "C" fn emit_event(&self, event_borsh: FfiSlice<'_>); + extern "C" fn emit_step_log(&self, stream: u8, bytes: FfiSlice<'_>); + extern "C" fn should_cancel(&self) -> bool; + extern "C" fn write_stdout(&self, bytes: FfiSlice<'_>); + extern "C" fn write_stderr(&self, bytes: FfiSlice<'_>); + extern "C" fn archive_read(&self, id_borsh: FfiSlice<'_>, offset: u64, max: u64) -> FfiBytes; + extern "C" fn archive_total_size(&self, id_borsh: FfiSlice<'_>) -> u64; + extern "C" fn fs_read_config(&self, rel_path: FfiSlice<'_>) -> stabby::option::Option; +} +``` + +Note: stabby `#[stabby::stabby]` on traits generates `RawPluginDyn`/`RawPluginDynMut` extension traits and ABI-stable vtables. Import these with `use ffi::{RawPluginDyn, RawHostApiDyn}` when calling through trait objects. + +**Step 4: Wire ffi module into lib.rs** + +Add `pub mod ffi;` to `crates/hm-plugin-sdk/src/lib.rs`. Comment out the old `extism_pdk` re-export for now (it will be removed in cleanup). + +**Step 5: Verify compilation** + +Run: `cargo check -p hm-plugin-sdk` +Expected: compiles (may have warnings about unused old modules) + +**Step 6: Write a compile-test for trait object creation** + +Add test in `crates/hm-plugin-sdk/src/ffi.rs`: +```rust +#[cfg(test)] +mod tests { + use super::*; + // Compile-time check: RawPlugin can be made into a trait object + fn _assert_raw_plugin_object_safe(_: stabby::Dyn<'_, stabby::boxed::Box<()>, stabby::vtable!(RawPlugin + Send + Sync)>) {} + fn _assert_raw_host_api_object_safe(_: stabby::Dyn<'_, stabby::boxed::Box<()>, stabby::vtable!(RawHostApi + Send + Sync)>) {} +} +``` + +Run: `cargo test -p hm-plugin-sdk` +Expected: compiles and passes (these are compile-time assertions) + +**Step 7: Commit** + +```bash +git add crates/hm-plugin-sdk/src/ffi.rs crates/hm-plugin-sdk/Cargo.toml crates/hm-plugin-sdk/src/lib.rs Cargo.toml +git commit -m "feat(sdk): define RawPlugin + RawHostApi stabby FFI traits" +``` + +--- + +## Task 2: User-facing async traits + PluginContext + +**Files:** +- Modify: `crates/hm-plugin-sdk/src/executor.rs` +- Modify: `crates/hm-plugin-sdk/src/hook.rs` +- Modify: `crates/hm-plugin-sdk/src/output.rs` +- Modify: `crates/hm-plugin-sdk/src/subcommand.rs` +- Create: `crates/hm-plugin-sdk/src/context.rs` +- Modify: `crates/hm-plugin-sdk/src/lib.rs` + +**Step 1: Write PluginContext** + +Create `crates/hm-plugin-sdk/src/context.rs`. This wraps `RawHostApi` with ergonomic Rust-native methods: + +```rust +use std::sync::Arc; +use hm_plugin_protocol::{BuildEvent, KvScope, Level}; +use crate::ffi::{FfiBytes, FfiSlice, RawHostApiDyn}; + +pub struct PluginContext { + // Holds the raw stabby trait object reference. + // The exact type here depends on stabby's DynRef — the key idea + // is that this stores the host-provided API for the plugin's lifetime. + raw: /* stabby trait object ref */, +} + +impl PluginContext { + pub fn log(&self, level: Level, msg: &str) { /* marshal level→u8, msg→FfiSlice, call raw.log() */ } + pub fn kv_get(&self, scope: KvScope, key: &str) -> Option> { /* marshal, call raw.kv_get(), unmarshal */ } + pub fn kv_set(&self, scope: KvScope, key: &str, val: &[u8]) { /* marshal, call raw.kv_set() */ } + pub fn emit_event(&self, event: &BuildEvent) { /* borsh::to_vec, call raw.emit_event() */ } + pub fn emit_step_log(&self, stream: hm_plugin_protocol::StdStream, bytes: &[u8]) { /* marshal stream→u8, call raw */ } + pub fn should_cancel(&self) -> bool { /* call raw.should_cancel() */ } + pub fn write_stdout(&self, bytes: &[u8]) { /* call raw.write_stdout() */ } + pub fn write_stderr(&self, bytes: &[u8]) { /* call raw.write_stderr() */ } + pub fn archive_read(&self, id: hm_plugin_protocol::ArchiveId, offset: u64, max: u64) -> Vec { /* borsh::to_vec(id), call raw, convert result */ } + pub fn archive_total_size(&self, id: hm_plugin_protocol::ArchiveId) -> u64 { /* marshal, call raw */ } + pub fn fs_read_config(&self, rel_path: &str) -> Option> { /* marshal, call raw */ } +} +``` + +The exact stabby type for storing the trait object reference needs to be worked out during implementation. Key patterns: +- `DynRef<'static, vtable!(RawHostApi + Send + Sync)>` for borrowed +- `Dyn<'static, Arc<()>, vtable!(RawHostApi + Send + Sync)>` for owned + +**Step 2: Rewrite user-facing traits** + +Replace the contents of: + +`crates/hm-plugin-sdk/src/executor.rs`: +```rust +use crate::context::PluginContext; +use hm_plugin_protocol::{ExecutorInput, PluginError, StepResult}; + +pub trait StepExecutor: Send + Sync + Default { + fn run(&self, ctx: &PluginContext, input: ExecutorInput) + -> impl Future> + Send + '_; +} +``` + +`crates/hm-plugin-sdk/src/hook.rs`: +```rust +use crate::context::PluginContext; +use hm_plugin_protocol::{HookEvent, HookOutcome, PluginError}; + +pub trait LifecycleHook: Send + Sync + Default { + fn on_event(&self, ctx: &PluginContext, event: HookEvent) + -> impl Future> + Send + '_; +} +``` + +`crates/hm-plugin-sdk/src/subcommand.rs`: +```rust +use crate::context::PluginContext; +use hm_plugin_protocol::{ExitInfo, PluginError, SubcommandInput}; + +pub trait SubcommandPlugin: Send + Sync + Default { + fn run(&self, ctx: &PluginContext, input: SubcommandInput) + -> impl Future> + Send + '_; +} +``` + +`crates/hm-plugin-sdk/src/output.rs`: +```rust +use crate::context::PluginContext; +use hm_plugin_protocol::{BuildEvent, PluginError}; + +pub trait OutputFormatter: Send + Sync + Default { + fn on_event(&self, ctx: &PluginContext, event: BuildEvent) + -> impl Future> + Send + '_; + + fn finalize(&self, _ctx: &PluginContext) + -> impl Future, PluginError>> + Send + '_ { + async { Ok(Vec::new()) } + } +} +``` + +**Step 3: Update lib.rs exports** + +```rust +pub mod context; +pub mod executor; +pub mod ffi; +pub mod hook; +pub mod output; +pub mod subcommand; + +#[doc(hidden)] +pub mod macros; + +pub use context::PluginContext; +pub use executor::StepExecutor; +pub use hm_plugin_protocol::*; +pub use hook::LifecycleHook; +pub use output::OutputFormatter; +pub use subcommand::SubcommandPlugin; +``` + +Remove the old `pub mod host;` and `pub use extism_pdk;`. + +**Step 4: Verify compilation** + +Run: `cargo check -p hm-plugin-sdk` +Expected: compiles (old `host.rs` module removed, macros.rs may have errors — that's Task 3) + +**Step 5: Commit** + +```bash +git add crates/hm-plugin-sdk/src/ +git commit -m "feat(sdk): async user-facing traits + PluginContext" +``` + +--- + +## Task 3: Create hm-plugin-macros proc-macro crate + hm_plugin! macro + +**Files:** +- Create: `crates/hm-plugin-macros/Cargo.toml` +- Create: `crates/hm-plugin-macros/src/lib.rs` +- Modify: `crates/hm-plugin-sdk/Cargo.toml` (add dep on hm-plugin-macros) +- Modify: `crates/hm-plugin-sdk/src/macros.rs` (re-export proc macro) +- Modify: `Cargo.toml` (workspace members) + +**Why proc macro:** The macro must parse keyword args (`manifest = ..., executor = T, hook = U`), accumulate which capabilities are registered, and emit one cohesive `impl RawPlugin` block with 6 methods — registered ones delegate with borsh ser/de + DynFuture wrapping, unregistered ones return error stubs. Declarative macros can't accumulate state across recursive arms without painful token-tree gymnastics. + +**Step 1: Create proc-macro crate** + +`crates/hm-plugin-macros/Cargo.toml`: +```toml +[package] +name = "hm-plugin-macros" +version = "0.0.0-dev" +edition.workspace = true + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "2", features = ["full"] } +quote = "1" +proc-macro2 = "1" +``` + +Add to workspace `members` list (not `default-members`). + +**Step 2: Implement the proc macro** + +`crates/hm-plugin-macros/src/lib.rs`: + +The proc macro parses the input, identifies which capabilities are registered, and generates: + +Key implementation pattern for each capability method: + +```rust +// For a registered executor: +fn execute_step<'a>(&'a self, input: $crate::ffi::FfiSlice<'a>) + -> stabby::future::DynFuture<'a, $crate::ffi::FfiResult> +{ + let ctx = self.ctx.clone(); + ::std::boxed::Box::new(async move { + let parsed: $crate::ExecutorInput = match ::borsh::from_slice(input.as_ref()) { + Ok(v) => v, + Err(e) => return stabby::result::Result::Err( + ::borsh::to_vec(&$crate::PluginError::new("deserialize", e.to_string())) + .unwrap_or_default().into() + ), + }; + let plugin = <$ty as ::core::default::Default>::default(); + match $crate::StepExecutor::run(&plugin, &ctx, parsed).await { + Ok(r) => stabby::result::Result::Ok( + ::borsh::to_vec(&r).unwrap_or_default().into() + ), + Err(e) => stabby::result::Result::Err( + ::borsh::to_vec(&e).unwrap_or_default().into() + ), + } + }).into() +} + +// For an unregistered capability (stub): +fn execute_step<'a>(&'a self, _input: $crate::ffi::FfiSlice<'a>) + -> stabby::future::DynFuture<'a, $crate::ffi::FfiResult> +{ + ::std::boxed::Box::new(async { + stabby::result::Result::Err( + ::borsh::to_vec(&$crate::PluginError::new( + "not_implemented", + "this plugin does not implement this capability" + )).unwrap_or_default().into() + ) + }).into() +} +``` + +**Step 3: Wire into SDK** + +Add `hm-plugin-macros` as dependency in `crates/hm-plugin-sdk/Cargo.toml`. Re-export the proc macro from `crates/hm-plugin-sdk/src/macros.rs` (or `lib.rs`) so plugin authors use `hm_plugin_sdk::hm_plugin!`. Delete old `register_plugin!` and `__rp_dispatch!` macros. + +**Step 4: Verify macro compiles with a minimal test** + +Since the macro generates `#[stabby::export]`, full validation requires building a cdylib. This is tested end-to-end in Task 5 (first real plugin migration). For now, verify: + +Run: `cargo check -p hm-plugin-sdk` + +**Step 5: Commit** + +```bash +git add crates/hm-plugin-macros/ crates/hm-plugin-sdk/src/macros.rs crates/hm-plugin-sdk/Cargo.toml Cargo.toml +git commit -m "feat(sdk): hm_plugin! proc macro for stabby FFI code generation" +``` + +--- + +## Task 4: Host-side plugin loading rewrite + +**Files:** +- Modify: `crates/hm/Cargo.toml` +- Rewrite: `crates/hm/src/plugin/host.rs` +- Delete: `crates/hm/src/plugin/pool.rs` +- Delete: `crates/hm/src/plugin/embedded.rs` +- Delete: `crates/hm/build.rs` +- Create: `crates/hm/src/plugin/host_api.rs` (replaces `host_fns.rs`) +- Rewrite: `crates/hm/src/plugin/registry.rs` +- Rewrite: `crates/hm/src/plugin/manifest.rs` +- Modify: `crates/hm/src/plugin/paths.rs` +- Modify: `crates/hm/src/plugin/mod.rs` + +### Step 1: Update hm Cargo.toml + +Add `stabby` + `borsh` dependencies. Remove `extism`: +```toml +[dependencies] +# ... existing deps ... +stabby = { workspace = true } # replaces extism +borsh = { workspace = true } # FFI boundary serialization +hm-plugin-sdk = { workspace = true } # NEW: host needs SDK for ffi traits +# Remove: extism = { workspace = true } +``` + +Note: `hm` now depends on `hm-plugin-sdk` for the `RawPlugin`/`RawHostApi` trait definitions. Previously the host only used `hm-plugin-protocol`. + +### Step 2: Rewrite host.rs — LoadedPlugin with stabby + +Replace `pool.rs` + current `host.rs` with a new `host.rs`: + +```rust +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use anyhow::{Context, Result}; +use hm_plugin_protocol::PluginManifest; +use hm_plugin_sdk::ffi::{RawPlugin, RawPluginDyn, RawHostApi, FfiBytes}; +use stabby::libloading::StabbyLibrary; + +pub struct LoadedPlugin { + pub manifest: PluginManifest, + pub source: Option, + _lib: libloading::Library, + plugin: /* Dyn<'static, Box<()>, vtable!(RawPlugin + Send + Sync)> */, +} + +impl LoadedPlugin { + pub fn load(path: &Path, host_api: /* stabby trait object */) -> Result { + let lib = unsafe { libloading::Library::new(path)? }; + // Load hm_load_plugin symbol with stabby type checking + let create_fn = unsafe { + lib.get_stabbied::(b"hm_load_plugin")? + }; + let plugin = create_fn(host_api) + .map_err(|err_bytes| /* borsh::from_slice PluginError from err_bytes */)?; + let manifest_bytes: FfiBytes = plugin.manifest(); + let manifest: PluginManifest = borsh::from_slice(manifest_bytes.as_ref())?; + Ok(Self { manifest, source: Some(path.to_owned()), _lib: lib, plugin }) + } + + /// Call a capability. Replaces the old generic `call_capability`. + /// Each capability has its own typed async method. + pub async fn execute_step(&self, input: &hm_plugin_protocol::ExecutorInput) -> Result { + let in_bytes = borsh::to_vec(input)?; + let ffi_input: hm_plugin_sdk::ffi::FfiSlice<'_> = in_bytes.as_slice().into(); + let result = self.plugin.execute_step(ffi_input).await; + match result { + stabby::result::Result::Ok(bytes) => Ok(borsh::from_slice(bytes.as_ref())?), + stabby::result::Result::Err(bytes) => { + let err: hm_plugin_protocol::PluginError = borsh::from_slice(bytes.as_ref())?; + Err(err.into()) + } + } + } + + // Similar methods for on_hook_event, run_subcommand, on_output_event, finalize_output +} +``` + +Key difference from old `LoadedPlugin`: no `PluginPool`, no semaphore, no instance management. The stabby trait object is `Send + Sync`, so concurrent callers can invoke methods directly. + +`#[allow(unsafe_code)]` on this module — `Library::new()` and `get_stabbied` are unsafe. + +### Step 3: Implement RawHostApi on host side + +Create `crates/hm/src/plugin/host_api.rs`: + +```rust +use hm_plugin_sdk::ffi::{FfiBytes, FfiSlice, RawHostApi}; + +pub struct HostApiImpl { + // Fields for state the host API needs: + // - tracing subscriber handle (for log) + // - KV stores (plugin-scope file paths, build/step in-memory maps) + // - event bus sender (tokio::sync::broadcast::Sender) + // - cancellation token + // - archive data + // - project config path +} + +impl RawHostApi for HostApiImpl { + extern "C" fn log(&self, level: u8, msg: FfiSlice<'_>) { + // Convert level u8 → tracing::Level, emit via tracing macros + } + + extern "C" fn kv_get(&self, scope: u8, key: FfiSlice<'_>) -> stabby::option::Option { + // Dispatch on scope: + // 0 (Plugin) → read from file ~/.config/harmont/state/.kv + // 1 (Build) → read from in-memory BTreeMap + // 2 (Step) → read from in-memory BTreeMap + } + + extern "C" fn kv_set(&self, scope: u8, key: FfiSlice<'_>, val: FfiSlice<'_>) { + // Dispatch on scope, write to appropriate store + } + + extern "C" fn emit_event(&self, event_borsh: FfiSlice<'_>) { + // borsh::from_slice → BuildEvent, send on broadcast channel + } + + extern "C" fn emit_step_log(&self, stream: u8, bytes: FfiSlice<'_>) { + // Forward to event bus as StepLog event + } + + extern "C" fn should_cancel(&self) -> bool { + // Check cancellation token + } + + extern "C" fn write_stdout(&self, bytes: FfiSlice<'_>) { + // Write to stdout + } + + extern "C" fn write_stderr(&self, bytes: FfiSlice<'_>) { + // Write to stderr + } + + extern "C" fn archive_read(&self, id_borsh: FfiSlice<'_>, offset: u64, max: u64) -> FfiBytes { + // borsh::from_slice → ArchiveId, read from archive store + } + + extern "C" fn archive_total_size(&self, id_borsh: FfiSlice<'_>) -> u64 { + // borsh::from_slice → ArchiveId, return size + } + + extern "C" fn fs_read_config(&self, rel_path: FfiSlice<'_>) -> stabby::option::Option { + // Read from project .harmont/ directory + } +} +``` + +Port logic from current `host_fns.rs` (1065 lines → ~300 lines, since docker/keyring/socket/tty/loopback/browser functions are removed). Uses `borsh::from_slice`/`borsh::to_vec` for deserializing/serializing event and archive ID parameters. + +### Step 4: Rewrite registry.rs + +Key changes: +- Discover `*.dylib`/`*.so` files instead of `*.wasm` +- Use `std::env::consts::DLL_EXTENSION` for platform-appropriate extension +- No `HOST_FN_NAMES` validation +- No `allowed_hosts` validation +- Remove `RegistryConfig.embedded` field entirely — no embedded plugins; all plugins discovered from disk +- Remove `pool_sizes` (no pooling needed) +- Discovery paths: `~/.harmont/plugins/` (user + built-in, installed by `install.sh`) and `.harmont/plugins/` (project-local) + +### Step 5: Delete embedded.rs and build.rs + +No more embedding. Built-in plugins are installed to `~/.harmont/plugins/` by `install.sh`. The `hm` binary discovers them like any other plugin. + +- Delete `crates/hm/src/plugin/embedded.rs` +- Delete `crates/hm/build.rs` +- Remove `include = [... "embedded/*.wasm" ...]` from `crates/hm/Cargo.toml` +- Delete `crates/hm/embedded/` directory if it exists + +**Dev workflow:** During development, either: +- Run `cargo build -p hm-plugin-docker` etc., then symlink from `target/debug/` into `~/.harmont/plugins/` +- Use `RegistryConfig.extra_paths` in integration tests to point at build output directly + +### Step 6: Update mod.rs + +```rust +pub mod embedded; +pub mod host; +pub mod host_api; +pub mod install; +pub mod manifest; +pub mod paths; +pub mod registry; +// Removed: pool, host_fns, signal +``` + +### Step 8: Update paths.rs + +Change `.wasm` extension to platform dylib extension in discovery path patterns. + +### Step 9: Verify host-side compilation + +Run: `cargo check -p harmont-cli` +Expected: compilation errors from callers (scheduler, dispatcher, output_subscriber) that still use old API. Those are updated in Task 5+. + +### Step 10: Commit + +```bash +git add crates/hm/ +git commit -m "feat(host): rewrite plugin loading for stabby native dylibs" +``` + +--- + +## Task 5: Migrate output-json plugin (validates full pipeline) + +**Why first:** Simplest plugin (47 lines). Validates the entire pipeline: SDK → macro → build → embed → load → call. + +**Files:** +- Modify: `crates/hm-plugin-output-json/Cargo.toml` +- Rewrite: `crates/hm-plugin-output-json/src/lib.rs` + +### Step 1: Update Cargo.toml + +```toml +[package] +name = "hm-plugin-output-json" +# ... + +[lib] +crate-type = ["cdylib"] # native dylib, not WASM + +[dependencies] +hm-plugin-sdk = { workspace = true } +hm-plugin-protocol = { workspace = true } +stabby = { workspace = true } +borsh = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } # this plugin's output IS JSON — serde_json still needed for its business logic +``` + +### Step 2: Rewrite lib.rs + +```rust +#![allow(unsafe_code)] + +use hm_plugin_sdk::*; + +#[derive(Default)] +struct Json; + +impl OutputFormatter for Json { + async fn on_event(&self, ctx: &PluginContext, 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'); + ctx.write_stdout(&bytes); + Ok(()) + } +} + +hm_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(), + })], + config_schema: None, + }, + output = Json, +); +``` + +Key changes from old: +- `async fn on_event` instead of `fn on_event` +- `ctx.write_stdout()` instead of `host::write_stdout()` +- `hm_plugin!` instead of `register_plugin!` +- No `required_host_fns`, no `allowed_hosts` +- No `#![no_main]` +- `crate-type = ["cdylib"]` targets native platform + +### Step 3: Build the plugin + +Run: `cargo build -p hm-plugin-output-json --release` +Expected: produces `target/release/libhm_plugin_output_json.dylib` (macOS) or `.so` (Linux) + +### Step 4: Wire into host + update output_subscriber + +Update `crates/hm/src/orchestrator/output_subscriber.rs` to use new `LoadedPlugin` API: +- Replace `plugin.call_capability::("hm_output_on_event", &event)` with `plugin.on_output_event(&event).await` +- Replace `plugin.call_capability::<(), Vec>("hm_output_finalize", &())` with `plugin.finalize_output().await` + +### Step 5: Integration test + +Write a test that loads the output-json plugin as a dylib and calls `on_output_event` with a test event: +```rust +#[tokio::test] +async fn output_json_plugin_loads_and_formats() { + let host_api = test_host_api(); // minimal RawHostApi impl for tests + let path = /* path to built dylib */; + let plugin = LoadedPlugin::load(&path, host_api).unwrap(); + assert_eq!(plugin.manifest.name, "harmont-output-json"); + + let event = BuildEvent::BuildEnd { exit_code: 0, duration_ms: 100 }; + plugin.on_output_event(&event).await.unwrap(); + // Verify stdout received JSON line +} +``` + +### Step 6: Commit + +```bash +git add crates/hm-plugin-output-json/ crates/hm/src/orchestrator/output_subscriber.rs +git commit -m "feat: migrate output-json plugin to stabby native dylib" +``` + +--- + +## Task 6: Migrate output-human plugin + +**Files:** +- Modify: `crates/hm-plugin-output-human/Cargo.toml` +- Rewrite: `crates/hm-plugin-output-human/src/lib.rs` +- Review: any `render` module this plugin uses + +Same pattern as Task 5. Change `crate-type` to `cdylib`, rewrite with `hm_plugin!` macro, use `ctx.write_stdout()`. + +### Step 1–4: Mirror Task 5 steps + +### Step 5: Commit + +```bash +git add crates/hm-plugin-output-human/ +git commit -m "feat: migrate output-human plugin to stabby native dylib" +``` + +--- + +## Task 7: Migrate docker plugin + +**Most complex migration.** Docker plugin currently uses 9 host functions (`hm_docker_*`) that call into the host's bollard client. Post-migration, the plugin uses bollard directly. + +**Files:** +- Modify: `crates/hm-plugin-docker/Cargo.toml` +- Rewrite: `crates/hm-plugin-docker/src/lib.rs` +- Delete: `crates/hm-plugin-docker/src/extism_host.rs` +- Create: `crates/hm-plugin-docker/src/docker.rs` (bollard wrapper) +- Modify: `crates/hm-plugin-docker/src/decision.rs` (if needed) +- Modify: `crates/hm-plugin-docker/src/image_name.rs` (if needed) + +### Step 1: Update Cargo.toml + +```toml +[lib] +crate-type = ["cdylib"] + +[dependencies] +hm-plugin-sdk = { workspace = true } +hm-plugin-protocol = { workspace = true } +stabby = { workspace = true } +borsh = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +bollard = "0.18" # Direct docker API +tokio = { workspace = true } # Shared runtime +``` + +### Step 2: Create docker.rs — bollard wrapper + +Port the docker operations from `crates/hm/src/plugin/host_fns.rs` (the `docker_host_fns::*_impl` async functions) and `crates/hm/src/orchestrator/docker_client.rs` into the plugin: + +```rust +use bollard::Docker; + +pub(crate) struct DockerClient { + docker: Docker, +} + +impl DockerClient { + pub fn connect() -> Result { + let docker = Docker::connect_with_local_defaults() + .map_err(|e| PluginError::new("docker_connect", e.to_string()))?; + Ok(Self { docker }) + } + + pub async fn image_exists(&self, tag: &str) -> bool { ... } + pub async fn pull(&self, tag: &str) -> Result<(), PluginError> { ... } + pub async fn start_container(&self, args: DockerStartArgs) -> Result { ... } + pub async fn extract_workspace(&self, args: DockerExtractArgs) -> Result<(), PluginError> { ... } + pub async fn exec(&self, args: DockerExecArgs) -> Result { ... } + pub async fn commit(&self, args: DockerCommitArgs) -> Result<(), PluginError> { ... } + pub async fn stop_remove(&self, container_id: &str) { ... } +} +``` + +Move `DockerStartArgs`, `DockerExecArgs`, `DockerCommitArgs`, `DockerExtractArgs` from `hm-plugin-protocol/src/host_abi.rs` into the docker plugin crate (they're now internal types). + +### Step 3: Rewrite lib.rs with async + +```rust +#![allow(unsafe_code)] + +use hm_plugin_sdk::*; + +mod decision; +mod docker; +mod image_name; + +#[derive(Default)] +struct DockerExec; + +impl StepExecutor for DockerExec { + async fn run(&self, ctx: &PluginContext, input: ExecutorInput) -> Result { + let client = docker::DockerClient::connect()?; + run_step(&client, ctx, input).await + } +} + +async fn run_step( + client: &docker::DockerClient, + ctx: &PluginContext, + input: ExecutorInput, +) -> Result { + // Same logic as current run_step(), but: + // - Use client.* instead of host::* + // - All operations are async + // - ctx.log() for logging + // - ctx.should_cancel() for cancellation checks + // ... +} +``` + +### Step 4: Update host-side scheduler + +Update `crates/hm/src/orchestrator/scheduler.rs`: +- Replace `plugin.call_capability::("hm_executor_run", &input)` with `plugin.execute_step(&input).await` +- Remove docker host function setup +- Remove bollard from host's direct dependencies (it's now in the docker plugin) + +### Step 5: Test docker plugin loads and executes + +Integration test with docker (requires Docker daemon running): +```rust +#[tokio::test] +#[cfg(feature = "docker-integration")] +async fn docker_plugin_runs_step() { + // Load plugin, create minimal ExecutorInput, execute, verify StepResult +} +``` + +### Step 6: Commit + +```bash +git add crates/hm-plugin-docker/ crates/hm/src/orchestrator/scheduler.rs +git commit -m "feat: migrate docker plugin to stabby — uses bollard directly" +``` + +--- + +## Task 8: Migrate cloud plugin + +**Files:** +- Modify: `crates/hm-plugin-cloud/Cargo.toml` +- Rewrite: `crates/hm-plugin-cloud/src/lib.rs` +- Rewrite: `crates/hm-plugin-cloud/src/http.rs` (use reqwest) +- Rewrite: `crates/hm-plugin-cloud/src/creds.rs` (use keyring crate or file-based) +- Rewrite: `crates/hm-plugin-cloud/src/auth.rs` (use axum for OAuth loopback) +- Modify: other internal modules as needed + +### Step 1: Update Cargo.toml + +```toml +[lib] +crate-type = ["cdylib"] + +[dependencies] +hm-plugin-sdk = { workspace = true } +hm-plugin-protocol = { workspace = true } +stabby = { workspace = true } +borsh = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +reqwest = { version = "0.13", features = ["rustls", "json"] } # replaces extism HTTP +# Optional: for credential storage +# keyring = "2" (or keep file-based as current) +# For OAuth loopback: +axum = { version = "0.7", default-features = false, features = ["tokio", "http1", "query"] } +webbrowser = "1" +dialoguer = "0.11" # For TTY prompts +``` + +### Step 2: Rewrite http.rs — use reqwest + +Replace extism-pdk HTTP with reqwest: +```rust +use reqwest::Client as ReqwestClient; + +pub(crate) struct Client { + inner: ReqwestClient, + base: String, + token: Option, +} + +impl Client { + pub(crate) async fn get(&self, path: &str) -> Result { + let resp = self.inner.get(format!("{}{}", self.base, path)) + .bearer_auth(self.token.as_deref().unwrap_or("")) + .send().await + .map_err(|e| PluginError::new("cloud_http_request", e.to_string()))?; + // ... handle response + } + // ... post, delete +} +``` + +### Step 3: Rewrite creds.rs — direct file/keyring access + +Replace `host::keyring_get/set/delete` with direct file operations (current implementation stores at `~/.harmont/credentials.toml`). + +### Step 4: Rewrite auth.rs — direct OAuth loopback + +Replace `host::spawn_loopback/loopback_recv` with direct axum server + `webbrowser::open`. Replace `host::tty_prompt/tty_confirm` with `dialoguer`. + +### Step 5: Rewrite lib.rs + +```rust +impl SubcommandPlugin for Cloud { + async fn run(&self, ctx: &PluginContext, input: SubcommandInput) -> Result { + cli::dispatch(input.verb_path, input.env).await + } +} +``` + +Note: `cli::dispatch` becomes async since HTTP calls and OAuth flow are now async. + +### Step 6: Update dispatcher.rs + +Update `crates/hm/src/dispatcher.rs`: +- Replace `plugin.call_capability::("hm_subcommand_run", &input)` with `plugin.run_subcommand(&input).await` + +### Step 7: Commit + +```bash +git add crates/hm-plugin-cloud/ crates/hm/src/dispatcher.rs +git commit -m "feat: migrate cloud plugin to stabby — uses reqwest/axum directly" +``` + +--- + +## Task 9: Migrate test fixtures + +Each fixture becomes its own cdylib crate under `tests/fixtures/`. The old `crates/hm-fixtures/` crate is deleted. Each sub-crate is a workspace member (not in `default-members`). + +``` +tests/fixtures/ +├── noop-executor/ +│ ├── Cargo.toml (cdylib, name = "hm-fixture-noop-executor") +│ └── src/lib.rs +├── recording-hook/ +│ ├── Cargo.toml +│ └── src/lib.rs +├── failing-subcommand/ +│ ├── Cargo.toml +│ └── src/lib.rs +├── host-fn-probe/ +│ ├── Cargo.toml +│ └── src/lib.rs +├── bad-api-version/ +│ ├── Cargo.toml +│ └── src/lib.rs +└── freestyle-runner/ + ├── Cargo.toml + └── src/lib.rs +``` + +Update workspace root `Cargo.toml`: remove `crates/hm-fixtures` from `members`, add `tests/fixtures/*` entries. + +### Step 1: Create `tests/fixtures/` structure + delete `crates/hm-fixtures/` + +Create per-fixture crate directories under `tests/fixtures/`. Each has: +- `Cargo.toml` with `crate-type = ["cdylib"]`, name prefixed `hm-fixture-` +- `src/lib.rs` using `hm_plugin!` macro + +Delete `crates/hm-fixtures/` entirely. Update workspace `Cargo.toml` members list. + +### Step 2: Port each fixture + +Each fixture is small (20-80 lines). Port pattern: +- Remove `#![no_main]` +- Replace `register_plugin!` with `hm_plugin!` +- Replace `host::*` calls with `ctx.*` calls +- Add `async` to trait methods + +### Step 3: Update test infrastructure + +Modify `crates/hm/tests/common/fixtures.rs`: +- Change `ensure_built()` to compile cdylib crates from `tests/fixtures/` instead of wasm32-wasip1 bins +- Change `fixture_path(name)` to return path to `target/debug/libhm_fixture_.{dylib,so}` instead of `.wasm` + +### Step 4: Update integration tests + +Modify tests in `crates/hm/tests/`: +- Verify fixture plugins load and run correctly through the new host API + +### Step 5: Commit + +```bash +git rm -r crates/hm-fixtures/ +git add tests/fixtures/ crates/hm/tests/ Cargo.toml +git commit -m "feat: migrate test fixtures to tests/fixtures/ as stabby native dylibs" +``` + +--- + +## Task 10: Protocol crate cleanup + +**Files:** +- Modify: `crates/hm-plugin-protocol/src/host_abi.rs` +- Modify: `crates/hm-plugin-protocol/src/manifest.rs` +- Modify: `crates/hm-plugin-protocol/src/lib.rs` + +### Step 1: Slim down host_abi.rs + +Keep only: +- `Level` enum +- `KvScope` enum +- `ArchiveReadArgs` struct + +Move to docker plugin crate: +- `DockerStartArgs`, `DockerExecArgs`, `DockerCommitArgs`, `DockerExtractArgs` + +Delete: +- `SocketHandle`, `SocketReadArgs`, `SocketWriteArgs` +- `LoopbackHandle`, `LoopbackRecvArgs`, `CallbackData` +- `KeyringArgs`, `KeyringSetArgs` +- `TtyPromptArgs`, `TtyConfirmArgs` + +### Step 2: Update PluginManifest + +Remove fields: +```rust +pub struct PluginManifest { + pub api_version: u32, + pub name: String, + pub version: semver::Version, + pub description: String, + pub capabilities: Vec, + // REMOVED: pub required_host_fns: Vec, + pub config_schema: Option, + // REMOVED: pub allowed_hosts: Vec, +} +``` + +Bump `HM_PLUGIN_API_VERSION` to `2` (wire format changed). + +### Step 3: Update lib.rs re-exports + +Remove re-exports for deleted types. + +### Step 4: Fix compilation cascade + +Removing fields from `PluginManifest` and types from `host_abi.rs` will cause compilation errors in: +- All `hm_plugin!` macro invocations (remove `required_host_fns` and `allowed_hosts`) +- `crates/hm/src/plugin/manifest.rs` validation (remove host_fn validation) +- `crates/hm/src/plugin/registry.rs` (remove `HOST_FN_NAMES`) +- Any test that constructs `PluginManifest` + +Fix all of these. + +### Step 5: Verify full workspace compiles + +Run: `cargo check --workspace` + +### Step 6: Commit + +```bash +git add crates/hm-plugin-protocol/ crates/hm/ crates/hm-plugin-*/ +git commit -m "refactor(protocol): remove WASM-era host_abi types + manifest fields" +``` + +--- + +## Task 11: Final cleanup + +**Files:** +- Modify: `Cargo.toml` (workspace root) +- Delete: `crates/hm/src/plugin/pool.rs` +- Delete: `crates/hm/src/plugin/host_fns.rs` +- Delete: `crates/hm/src/plugin/signal.rs` (if exists and unused) +- Delete: `crates/hm-plugin-sdk/src/host.rs` (old extism host wrappers) +- Modify: `crates/hm/Cargo.toml` +- Modify: `CLAUDE.md` +- Modify: `crates/hm/CLAUDE.md` + +### Step 1: Remove extism workspace dependencies + +From `Cargo.toml`: +```toml +# DELETE these lines: +extism = "1" +extism-pdk = "1" +``` + +### Step 2: Remove extism from hm Cargo.toml + +Remove `extism = { workspace = true }` from `[dependencies]`. + +### Step 3: Remove bollard from hm Cargo.toml (if docker plugin now owns it) + +The `hm` binary no longer needs `bollard` since docker operations moved to the plugin. Verify no other code uses it, then remove. + +Also remove `axum` and `webbrowser` from `hm`'s deps if they were only used for cloud plugin host functions. + +### Step 4: Delete dead files + +- `crates/hm/src/plugin/pool.rs` +- `crates/hm/src/plugin/host_fns.rs` +- `crates/hm/src/plugin/signal.rs` (verify unused first) +- `crates/hm-plugin-sdk/src/host.rs` +- `crates/hm/embedded/*.wasm` (if any exist) + +### Step 5: Remove wasm32-wasip1 references + +Search workspace for `wasm32-wasip1` and remove all references: +```bash +grep -r "wasm32-wasip1" --include="*.rs" --include="*.toml" --include="*.md" --include="*.yml" --include="*.yaml" +``` + +### Step 6: Update CLAUDE.md files + +Update `/CLAUDE.md`: +``` +- Remove mention of wasm32-wasip1 target +- Update crate descriptions to mention stabby +- Note: plugins are native dylibs, not WASM +``` + +Update `/crates/hm/CLAUDE.md`: +``` +- Update plugin parallelism section (no more PluginPool) +- Update cloud functionality section (no more extism HTTP, uses reqwest) +- Remove mention of extism host functions +``` + +### Step 7: Update workspace include list + +In `crates/hm/Cargo.toml`, change: +```toml +include = [ + "src/**/*", + "build.rs", + "Cargo.toml", + "README.md", + "embedded/*.dylib", # or platform-specific pattern + "embedded/*.so", +] +``` + +### Step 8: Full workspace build + test + +Run: +```bash +cargo build --workspace +cargo test --workspace +``` + +Expected: everything compiles and tests pass. + +### Step 9: Commit + +```bash +git add -A +git commit -m "chore: remove extism, dead WASM code, update docs" +``` + +--- + +## Risk register + +| Risk | Mitigation | +|------|-----------| +| stabby trait with DynFuture may not compile as expected | Task 1 validates this immediately. **STOP and ask the operator** before choosing a fallback — options include manual vtable, separate async entry points, or sync traits with `block_on`. Do not pick an alternative unilaterally. | +| `unsafe_code = "deny"` workspace lint blocks stabby macros | Add `#[allow(unsafe_code)]` on specific modules that use stabby FFI | +| hm_plugin! macro complexity | Use proc-macro crate `hm-plugin-macros` from the start — the macro must accumulate state across keyword args to emit one cohesive `impl RawPlugin` block, which is painful with declarative macros | +| Built-in plugin availability | `install.sh` places dylibs in `~/.harmont/plugins/`. No embedding. Dev workflow: `cargo build` + symlink or `--extra-paths` in tests | +| Docker container cleanup on host crash | Entirely plugin-side: docker plugin uses bollard directly and owns full container lifecycle (`--rm`, labels, cleanup-on-drop). Core binary has no docker awareness. | +| Rust 1.78+ stabby vtable perf regression | Only ~5 trait object types total; unlikely to hit O(n) scaling issues | +| Cross-platform dylib extension in build.rs/embedded.rs | Use `std::env::consts::DLL_EXTENSION`/`DLL_PREFIX` consistently | +| Cloud plugin's internal modules (1500 lines) need async rewrite | Most changes are mechanical: add `.await`, replace extism HTTP with reqwest | + +## Dependency changes summary + +### Added +- `stabby = "=72.1.1"` (workspace) +- `borsh = "1"` (workspace; FFI boundary serialization — faster/smaller than JSON) +- `bollard = "0.18"` (docker plugin; moved from hm binary) +- `reqwest` (cloud plugin) +- `axum` (cloud plugin; moved from hm binary) +- `webbrowser` (cloud plugin; moved from hm binary) +- `dialoguer` (cloud plugin; moved from hm binary) + +### Removed +- `extism = "1"` (workspace) +- `extism-pdk = "1"` (workspace) +- `bollard` from hm binary (moved to docker plugin) +- `axum` from hm binary (moved to cloud plugin) +- `webbrowser` from hm binary (moved to cloud plugin) + +### Unchanged +- `hm-plugin-protocol` — wire types, serde structs +- `tokio`, `serde`, `serde_json`, `clap`, etc. diff --git a/tests/fixtures/bad-api-version/Cargo.toml b/tests/fixtures/bad-api-version/Cargo.toml new file mode 100644 index 0000000..3723571 --- /dev/null +++ b/tests/fixtures/bad-api-version/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "hm-fixture-bad-api-version" +version = "0.0.0" +publish = false +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +hm-plugin-sdk = { workspace = true } +hm-plugin-protocol = { workspace = true } +stabby = { workspace = true } +borsh = { workspace = true } +serde = { workspace = true } +semver = { workspace = true } + +[lints] +workspace = true diff --git a/crates/hm-fixtures/src/bin/bad_api_version.rs b/tests/fixtures/bad-api-version/src/lib.rs similarity index 52% rename from crates/hm-fixtures/src/bin/bad_api_version.rs rename to tests/fixtures/bad-api-version/src/lib.rs index 515c011..30085d9 100644 --- a/crates/hm-fixtures/src/bin/bad_api_version.rs +++ b/tests/fixtures/bad-api-version/src/lib.rs @@ -1,8 +1,8 @@ //! Declares a manifest with the wrong api_version. Used to assert //! the host rejects it at load time. -#![no_main] #![allow( + unsafe_code, clippy::pedantic, clippy::nursery, clippy::cargo, @@ -11,9 +11,31 @@ clippy::missing_errors_doc )] +use core::future::Future; use hm_plugin_sdk::*; -register_plugin!( +/// Dummy executor required by `hm_plugin!` — the host should reject +/// this plugin before ever calling `run` because of the bad +/// `api_version`. +#[derive(Default)] +struct DummyExec; + +impl StepExecutor for DummyExec { + fn run<'a>( + &'a self, + _ctx: &'a PluginContext<'a>, + _input: ExecutorInput, + ) -> impl Future> + Send + 'a { + async move { + Err(PluginError::new( + "unreachable", + "bad-api-version fixture should never be called", + )) + } + } +} + +hm_plugin!( manifest = PluginManifest { api_version: 9999, name: "harmont-fixture-bad-api".into(), @@ -24,8 +46,7 @@ register_plugin!( default: false, step_schema: None, })], - required_host_fns: vec![], config_schema: None, - allowed_hosts: vec![], }, + executor = DummyExec, ); diff --git a/tests/fixtures/failing-subcommand/Cargo.toml b/tests/fixtures/failing-subcommand/Cargo.toml new file mode 100644 index 0000000..ac520f3 --- /dev/null +++ b/tests/fixtures/failing-subcommand/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "hm-fixture-failing-subcommand" +version = "0.0.0" +publish = false +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +hm-plugin-sdk = { workspace = true } +hm-plugin-protocol = { workspace = true } +stabby = { workspace = true } +borsh = { workspace = true } +serde = { workspace = true } +semver = { workspace = true } + +[lints] +workspace = true diff --git a/crates/hm-fixtures/src/bin/failing_subcommand.rs b/tests/fixtures/failing-subcommand/src/lib.rs similarity index 67% rename from crates/hm-fixtures/src/bin/failing_subcommand.rs rename to tests/fixtures/failing-subcommand/src/lib.rs index 94773e0..9879d91 100644 --- a/crates/hm-fixtures/src/bin/failing_subcommand.rs +++ b/tests/fixtures/failing-subcommand/src/lib.rs @@ -1,8 +1,8 @@ //! A subcommand plugin that always exits non-zero. Lets the host //! exercise `ExitInfo` plumbing. -#![no_main] #![allow( + unsafe_code, clippy::pedantic, clippy::nursery, clippy::cargo, @@ -11,22 +11,28 @@ clippy::missing_errors_doc )] +use core::future::Future; 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()), - }) + fn run<'a>( + &'a self, + _ctx: &'a PluginContext<'a>, + _input: SubcommandInput, + ) -> impl Future> + Send + 'a { + async move { + Ok(ExitInfo { + exit_code: 7, + message: Some("intentional failure for tests".into()), + }) + } } } -register_plugin!( +hm_plugin!( manifest = PluginManifest { api_version: HM_PLUGIN_API_VERSION, name: "harmont-fixture-failing".into(), @@ -35,12 +41,10 @@ register_plugin!( capabilities: vec![Capability::Subcommand(SubcommandSpec { verb: "fixture-fail".into(), about: "Intentionally fails (test fixture)".into(), - args_schema: json!({"args": []}), + args: vec![], subcommands: vec![], })], - required_host_fns: vec![], config_schema: None, - allowed_hosts: vec![], }, subcommand = Failing, ); diff --git a/tests/fixtures/freestyle-runner/Cargo.toml b/tests/fixtures/freestyle-runner/Cargo.toml new file mode 100644 index 0000000..253f4e7 --- /dev/null +++ b/tests/fixtures/freestyle-runner/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "hm-fixture-freestyle-runner" +version = "0.0.0" +publish = false +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +hm-plugin-sdk = { workspace = true } +hm-plugin-protocol = { workspace = true } +stabby = { workspace = true } +borsh = { workspace = true } +serde = { workspace = true } +semver = { workspace = true } + +[lints] +workspace = true diff --git a/crates/hm-fixtures/src/bin/freestyle_runner.rs b/tests/fixtures/freestyle-runner/src/lib.rs similarity index 57% rename from crates/hm-fixtures/src/bin/freestyle_runner.rs rename to tests/fixtures/freestyle-runner/src/lib.rs index 984725a..cb01f34 100644 --- a/crates/hm-fixtures/src/bin/freestyle_runner.rs +++ b/tests/fixtures/freestyle-runner/src/lib.rs @@ -5,10 +5,8 @@ //! 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( + unsafe_code, clippy::pedantic, clippy::nursery, clippy::cargo, @@ -17,31 +15,34 @@ clippy::missing_errors_doc )] +use core::future::Future; 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![], - }) + fn run<'a>( + &'a self, + ctx: &'a PluginContext<'a>, + input: ExecutorInput, + ) -> impl Future> + Send + 'a { + async move { + ctx.kv_set( + KvScope::Plugin, + "freestyle_called_with", + input.step.key.as_bytes(), + ); + Ok(StepResult { + exit_code: 0, + committed_snapshot: None, + artifacts: vec![], + }) + } } } -register_plugin!( +hm_plugin!( manifest = PluginManifest { api_version: HM_PLUGIN_API_VERSION, name: "harmont-fixture-freestyle".into(), @@ -52,9 +53,7 @@ register_plugin!( 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-plugin-output-json/Cargo.toml b/tests/fixtures/host-fn-probe/Cargo.toml similarity index 68% rename from crates/hm-plugin-output-json/Cargo.toml rename to tests/fixtures/host-fn-probe/Cargo.toml index 9d5acd5..d472b62 100644 --- a/crates/hm-plugin-output-json/Cargo.toml +++ b/tests/fixtures/host-fn-probe/Cargo.toml @@ -1,20 +1,19 @@ [package] -name = "hm-plugin-output-json" -version = "0.1.0" +name = "hm-fixture-host-fn-probe" +version = "0.0.0" +publish = false 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 } +stabby = { workspace = true } +borsh = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } semver = { workspace = true } diff --git a/tests/fixtures/host-fn-probe/src/lib.rs b/tests/fixtures/host-fn-probe/src/lib.rs new file mode 100644 index 0000000..7fd0d7c --- /dev/null +++ b/tests/fixtures/host-fn-probe/src/lib.rs @@ -0,0 +1,82 @@ +//! Calls every host fn the stabby API defines 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. + +#![allow( + unsafe_code, + clippy::pedantic, + clippy::nursery, + clippy::cargo, + clippy::multiple_crate_versions, + clippy::cargo_common_metadata, + clippy::missing_errors_doc +)] + +use core::future::Future; +use hm_plugin_sdk::*; +use serde::Serialize; + +#[derive(Default, Serialize)] +struct Report { + log_ok: bool, + kv_round_trip: bool, + kv_isolated_per_scope: bool, + fs_read_returns_none_for_missing: bool, + should_cancel_default_false: bool, +} + +#[derive(Default)] +struct Probe; + +impl SubcommandPlugin for Probe { + fn run<'a>( + &'a self, + ctx: &'a PluginContext<'a>, + _input: SubcommandInput, + ) -> impl Future> + Send + 'a { + async move { + let mut r = Report::default(); + + ctx.log(Level::Info, "probe: log"); + r.log_ok = true; + + ctx.kv_set(KvScope::Plugin, "k", b"v1"); + let v = ctx.kv_get(KvScope::Plugin, "k").unwrap_or_default(); + r.kv_round_trip = v == b"v1"; + + ctx.kv_set(KvScope::Build, "k", b"v2"); + let p = ctx.kv_get(KvScope::Plugin, "k").unwrap_or_default(); + let b = ctx.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 = + ctx.fs_read_config("does/not/exist").is_none(); + + r.should_cancel_default_false = !ctx.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), + }) + } + } +} + +hm_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: vec![], + subcommands: vec![], + })], + config_schema: None, + }, + subcommand = Probe, +); diff --git a/crates/hm-plugin-output-human/Cargo.toml b/tests/fixtures/noop-executor/Cargo.toml similarity index 58% rename from crates/hm-plugin-output-human/Cargo.toml rename to tests/fixtures/noop-executor/Cargo.toml index a500055..d2abe0d 100644 --- a/crates/hm-plugin-output-human/Cargo.toml +++ b/tests/fixtures/noop-executor/Cargo.toml @@ -1,25 +1,22 @@ [package] -name = "hm-plugin-output-human" -version = "0.1.0" +name = "hm-fixture-noop-executor" +version = "0.0.0" +publish = false 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 } +stabby = { workspace = true } +borsh = { 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-fixtures/src/bin/noop_executor.rs b/tests/fixtures/noop-executor/src/lib.rs similarity index 53% rename from crates/hm-fixtures/src/bin/noop_executor.rs rename to tests/fixtures/noop-executor/src/lib.rs index 2954c11..2be9dd2 100644 --- a/crates/hm-fixtures/src/bin/noop_executor.rs +++ b/tests/fixtures/noop-executor/src/lib.rs @@ -2,11 +2,8 @@ //! 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( + unsafe_code, clippy::pedantic, clippy::nursery, clippy::cargo, @@ -15,27 +12,34 @@ clippy::missing_errors_doc )] +use core::future::Future; 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![], - }) + fn run<'a>( + &'a self, + ctx: &'a PluginContext<'a>, + input: ExecutorInput, + ) -> impl Future> + Send + 'a { + async move { + let key = format!("seen:{}", input.step.key); + let val = serde_json::to_vec(&input) + .map_err(|e| PluginError::new("serde", e.to_string()))?; + ctx.kv_set(KvScope::Plugin, &key, &val); + ctx.log(Level::Info, &format!("noop ran step '{}'", input.step.key)); + Ok(StepResult { + exit_code: 0, + committed_snapshot: None, + artifacts: vec![], + }) + } } } -register_plugin!( +hm_plugin!( manifest = PluginManifest { api_version: HM_PLUGIN_API_VERSION, name: "harmont-fixture-noop".into(), @@ -46,9 +50,7 @@ register_plugin!( 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/tests/fixtures/recording-hook/Cargo.toml b/tests/fixtures/recording-hook/Cargo.toml new file mode 100644 index 0000000..02f7864 --- /dev/null +++ b/tests/fixtures/recording-hook/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "hm-fixture-recording-hook" +version = "0.0.0" +publish = false +edition.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +crate-type = ["cdylib"] + +[dependencies] +hm-plugin-sdk = { workspace = true } +hm-plugin-protocol = { workspace = true } +stabby = { workspace = true } +borsh = { workspace = true } +serde = { workspace = true } +semver = { workspace = true } + +[lints] +workspace = true diff --git a/tests/fixtures/recording-hook/src/lib.rs b/tests/fixtures/recording-hook/src/lib.rs new file mode 100644 index 0000000..1fe1f23 --- /dev/null +++ b/tests/fixtures/recording-hook/src/lib.rs @@ -0,0 +1,72 @@ +//! Records every `HookEvent` into a KV slot keyed by event kind. + +#![allow( + unsafe_code, + clippy::pedantic, + clippy::nursery, + clippy::cargo, + clippy::multiple_crate_versions, + clippy::cargo_common_metadata, + clippy::missing_errors_doc +)] + +use core::future::Future; +use hm_plugin_sdk::*; + +#[derive(Default)] +struct RecHook; + +impl LifecycleHook for RecHook { + fn on_event<'a>( + &'a self, + ctx: &'a PluginContext<'a>, + event: HookEvent, + ) -> impl Future> + Send + 'a { + async move { + 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 = ctx.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; + ctx.kv_set(KvScope::Plugin, &key, count.to_string().as_bytes()); + Ok(HookOutcome::Continue) + } + } +} + +hm_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, + })], + config_schema: None, + }, + hook = RecHook, +);