diff --git a/.github/workflows/publish-website.yml b/.github/workflows/publish-website.yml index 0dc0421a..9ad2d143 100644 --- a/.github/workflows/publish-website.yml +++ b/.github/workflows/publish-website.yml @@ -45,25 +45,26 @@ jobs: - name: Check static site routes run: cargo run -p fission-cli --bin fission -- site check --project-dir documentation --release - - name: Build static site - run: cargo run -p fission-cli --bin fission -- site build --project-dir documentation --release + - name: Package static site + run: cargo run -p fission-cli --bin fission -- package --project-dir documentation --target site --format static --release - name: Verify generated static site run: | - test -f documentation/dist/site/index.html - test -f documentation/dist/site/site.css - test -f documentation/dist/site/sitemap.xml - test -f documentation/dist/site/robots.txt - test -f documentation/dist/site/search/search.js - test -f documentation/dist/site/search/manifest.json - test -f documentation/dist/site/search/docs.json - test -f documentation/dist/site/img/fission-mark.svg - test -f documentation/dist/site/img/charts/line-gradient-area.png + test -f documentation/target/fission/release/site/static/index.html + test -f documentation/target/fission/release/site/static/site.css + test -f documentation/target/fission/release/site/static/sitemap.xml + test -f documentation/target/fission/release/site/static/robots.txt + test -f documentation/target/fission/release/site/static/search/search.js + test -f documentation/target/fission/release/site/static/search/manifest.json + test -f documentation/target/fission/release/site/static/search/docs.json + test -f documentation/target/fission/release/site/static/img/fission-mark.svg + test -f documentation/target/fission/release/site/static/img/charts/line-gradient-area.png + test -f documentation/target/fission/release/site/static/artifact-manifest.json - name: Upload static site artifact uses: actions/upload-pages-artifact@v3 with: - path: documentation/dist/site + path: documentation/target/fission/release/site/static deploy: needs: build diff --git a/Cargo.lock b/Cargo.lock index a671f4eb..9eb9ab59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -30,6 +30,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + [[package]] name = "ahash" version = "0.8.12" @@ -183,6 +204,9 @@ name = "arbitrary" version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] [[package]] name = "arboard" @@ -251,6 +275,123 @@ dependencies = [ "libloading", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.2", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.2", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.2", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -306,12 +447,476 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "aws-config" +version = "1.8.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a8fc176d53d6fe85017f230405e3255cedb4a02221cb55ed6d76dccbbb099b2" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sdk-sso", + "aws-sdk-ssooidc", + "aws-sdk-sts", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "hex", + "http 1.4.0", + "ring", + "time", + "tokio", + "tracing", + "url", + "zeroize", +] + +[[package]] +name = "aws-credential-types" +version = "1.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e26bbf46abc608f2dc61fd6cb3b7b0665497cc259a21520151ed98f8b37d2c79" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "zeroize", +] + +[[package]] +name = "aws-lc-rs" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ec2f1fc3ec205783a5da9a7e6c1509cc69dedf09a1949e412c1e18469326d00" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2f9779ce85b93ab6170dd940ad0169b5766ff848247aff13bb788b832fe3f4" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-runtime" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f92058d22a46adf53ec57a6a96f34447daf02bff52e8fb956c66bcd5c6ac12" +dependencies = [ + "aws-credential-types", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "bytes-utils", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "percent-encoding", + "pin-project-lite", + "tracing", + "uuid", +] + +[[package]] +name = "aws-sdk-s3" +version = "1.123.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c018f22146966fdd493a664f62ee2483dff256b42a08c125ab6a084bde7b77fe" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-sigv4", + "aws-smithy-async", + "aws-smithy-checksums", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "bytes", + "fastrand", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "http-body 1.0.1", + "lru", + "percent-encoding", + "regex-lite", + "sha2", + "tracing", + "url", +] + +[[package]] +name = "aws-sdk-sso" +version = "1.94.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699da1961a289b23842d88fe2984c6ff68735fdf9bdcbc69ceaeb2491c9bf434" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-ssooidc" +version = "1.96.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e3a4cb3b124833eafea9afd1a6cc5f8ddf3efefffc6651ef76a03cbc6b4981" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sdk-sts" +version = "1.98.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89c4f19655ab0856375e169865c91264de965bd74c407c7f1e403184b1049409" +dependencies = [ + "aws-credential-types", + "aws-runtime", + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-json", + "aws-smithy-observability", + "aws-smithy-query", + "aws-smithy-runtime", + "aws-smithy-runtime-api", + "aws-smithy-types", + "aws-smithy-xml", + "aws-types", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "regex-lite", + "tracing", +] + +[[package]] +name = "aws-sigv4" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f6ae9b71597dc5fd115d52849d7a5556ad9265885ad3492ea8d73b93bbc46e" +dependencies = [ + "aws-credential-types", + "aws-smithy-eventstream", + "aws-smithy-http", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "crypto-bigint 0.5.5", + "form_urlencoded", + "hex", + "hmac", + "http 0.2.12", + "http 1.4.0", + "p256", + "percent-encoding", + "ring", + "sha2", + "subtle", + "time", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-async" +version = "1.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffcaf626bdda484571968400c326a244598634dc75fd451325a54ad1a59acfc" +dependencies = [ + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "aws-smithy-checksums" +version = "0.64.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a764fa7222922f6c0af8eea478b0ef1ba5ce1222af97e01f33ca5e957bd7f3b9" +dependencies = [ + "aws-smithy-http", + "aws-smithy-types", + "bytes", + "crc-fast", + "hex", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "md-5", + "pin-project-lite", + "sha1", + "sha2", + "tracing", +] + +[[package]] +name = "aws-smithy-eventstream" +version = "0.60.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c0b3e587fbaa5d7f7e870544508af8ce82ea47cd30376e69e1e37c4ac746f79" +dependencies = [ + "aws-smithy-types", + "bytes", + "crc32fast", +] + +[[package]] +name = "aws-smithy-http" +version = "0.63.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4a8a5fe3e4ac7ee871237c340bbce13e982d37543b65700f4419e039f5d78e" +dependencies = [ + "aws-smithy-eventstream", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "bytes-utils", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "percent-encoding", + "pin-project-lite", + "pin-utils", + "tracing", +] + +[[package]] +name = "aws-smithy-http-client" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0709f0083aa19b704132684bc26d3c868e06bd428ccc4373b0b55c3e8748a58b" +dependencies = [ + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "h2 0.3.27", + "h2 0.4.14", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper 1.9.0", + "hyper-rustls 0.24.2", + "hyper-rustls 0.27.9", + "hyper-util", + "pin-project-lite", + "rustls 0.21.12", + "rustls 0.23.37", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tracing", +] + +[[package]] +name = "aws-smithy-json" +version = "0.62.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b3a779093e18cad88bbae08dc4261e1d95018c4c5b9356a52bcae7c0b6e9bb" +dependencies = [ + "aws-smithy-types", +] + +[[package]] +name = "aws-smithy-observability" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3f39d5bb871aaf461d59144557f16d5927a5248a983a40654d9cf3b9ba183b" +dependencies = [ + "aws-smithy-runtime-api", +] + +[[package]] +name = "aws-smithy-query" +version = "0.60.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f76a580e3d8f8961e5d48763214025a2af65c2fa4cd1fb7f270a0e107a71b0" +dependencies = [ + "aws-smithy-types", + "urlencoding", +] + +[[package]] +name = "aws-smithy-runtime" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd3dfc18c1ce097cf81fced7192731e63809829c6cbf933c1ec47452d08e1aa" +dependencies = [ + "aws-smithy-async", + "aws-smithy-http", + "aws-smithy-http-client", + "aws-smithy-observability", + "aws-smithy-runtime-api", + "aws-smithy-types", + "bytes", + "fastrand", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "pin-project-lite", + "pin-utils", + "tokio", + "tracing", +] + +[[package]] +name = "aws-smithy-runtime-api" +version = "1.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c55e0837e9b8526f49e0b9bfa9ee18ddee70e853f5bc09c5d11ebceddcb0fec" +dependencies = [ + "aws-smithy-async", + "aws-smithy-types", + "bytes", + "http 0.2.12", + "http 1.4.0", + "pin-project-lite", + "tokio", + "tracing", + "zeroize", +] + +[[package]] +name = "aws-smithy-types" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "576b0d6991c9c32bc14fc340582ef148311f924d41815f641a308b5d11e8e7cd" +dependencies = [ + "base64-simd", + "bytes", + "bytes-utils", + "futures-core", + "http 0.2.12", + "http 1.4.0", + "http-body 0.4.6", + "http-body 1.0.1", + "http-body-util", + "itoa", + "num-integer", + "pin-project-lite", + "pin-utils", + "ryu", + "serde", + "time", + "tokio", + "tokio-util", +] + +[[package]] +name = "aws-smithy-xml" +version = "0.60.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce02add1aa3677d022f8adf81dcbe3046a95f17a1b1e8979c145cd21d3d22b3" +dependencies = [ + "xmlparser", +] + +[[package]] +name = "aws-types" +version = "1.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c50f3cdf47caa8d01f2be4a6663ea02418e892f9bbfd82c7b9a3a37eaccdd3a" +dependencies = [ + "aws-credential-types", + "aws-smithy-async", + "aws-smithy-runtime-api", + "aws-smithy-types", + "rustc_version", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349a06037c7bf932dd7e7d1f653678b2038b9ad46a74102f1fc7bd7872678cce" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bincode" version = "1.3.3" @@ -394,6 +999,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block-sys" version = "0.2.1" @@ -413,6 +1027,19 @@ dependencies = [ "objc2 0.4.1", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "built" version = "0.8.0" @@ -463,6 +1090,16 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "bytes-utils" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +dependencies = [ + "bytes", + "either", +] + [[package]] name = "calloop" version = "0.12.4" @@ -489,6 +1126,15 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.49" @@ -525,6 +1171,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chart-gallery" version = "0.1.0" @@ -552,6 +1222,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "clap" version = "4.5.60" @@ -601,6 +1282,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cocoa" version = "0.25.0" @@ -639,7 +1329,7 @@ checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" dependencies = [ "serde", "termcolor", - "unicode-width 0.1.14", + "unicode-width", ] [[package]] @@ -689,6 +1379,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -793,6 +1489,33 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc-fast" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d" +dependencies = [ + "crc", + "digest", + "rustversion", + "spin", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -858,6 +1581,28 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -865,6 +1610,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -884,11 +1630,70 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "dbus" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b3aa68d7e7abee336255bd7248ea965cc393f3e70411135a6f6a4b651345d4" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.59.0", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "aes", + "block-padding", + "cbc", + "dbus", + "fastrand", + "hkdf", + "num", + "once_cell", + "sha2", + "zeroize", +] + [[package]] name = "deltae" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + +[[package]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] [[package]] name = "digest" @@ -898,6 +1703,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -951,12 +1757,50 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.14.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413301934810f597c1d19ca71c8710e99a3f1ba28a0d2ebc01551a2daeea3c5c" +dependencies = [ + "der", + "elliptic-curve", + "rfc6979", + "signature", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +[[package]] +name = "elliptic-curve" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7bb888ab5300a19b8e5bceef25ac745ad065f3c9f7efc6de1b91958110891d3" +dependencies = [ + "base16ct", + "crypto-bigint 0.4.9", + "der", + "digest", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embed-3d" version = "0.1.0" @@ -987,6 +1831,33 @@ dependencies = [ "serde", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "equator" version = "0.4.2" @@ -1038,6 +1909,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "exr" version = "1.74.0" @@ -1099,6 +1991,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d013fc25338cc558c5c2cfbad646908fb23591e2404481826742b651c9af7160" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "filedescriptor" version = "0.8.3" @@ -1120,6 +2022,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.5" @@ -1187,12 +2099,25 @@ name = "fission-cli" version = "0.1.1" dependencies = [ "anyhow", + "aws-config", + "aws-sdk-s3", + "base64", + "chacha20poly1305", "clap", "fission", "fission-shell-site", + "flate2", + "getrandom 0.2.17", + "jsonwebtoken", + "keyring", + "reqwest", "serde", "serde_json", + "sha2", + "tar", + "tokio", "toml", + "zip", ] [[package]] @@ -1332,7 +2257,7 @@ dependencies = [ "parley", "peniko", "vello", - "web-time", + "web-time 0.2.4", ] [[package]] @@ -1418,7 +2343,7 @@ dependencies = [ "fission-theme", "image 0.25.9", "unicode-segmentation", - "unicode-width 0.2.2", + "unicode-width", ] [[package]] @@ -1469,7 +2394,7 @@ dependencies = [ "vello", "wasm-bindgen", "web-sys", - "web-time", + "web-time 0.2.4", "winit", ] @@ -1658,6 +2583,22 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.31" @@ -1675,6 +2616,65 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -1702,8 +2702,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1713,9 +2715,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1840,6 +2844,17 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "group" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfbfb3a6cfbd390d5c9564ab283a0349b9b9fcd46a706c1eb10e0db70bfbac7" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "guillotiere" version = "0.6.2" @@ -1850,6 +2865,44 @@ dependencies = [ "svg_fmt", ] +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -1887,53 +2940,238 @@ dependencies = [ ] [[package]] -name = "hashbrown" -version = "0.16.1" +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] -name = "heck" -version = "0.4.1" +name = "httpdate" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] -name = "heck" -version = "0.5.0" +name = "humansize" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] [[package]] -name = "hermit-abi" -version = "0.5.2" +name = "hyper" +version = "0.14.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] [[package]] -name = "hex" -version = "0.4.3" +name = "hyper" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.14", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] [[package]] -name = "hexf-parse" -version = "0.2.1" +name = "hyper-rustls" +version = "0.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "log", + "rustls 0.21.12", + "tokio", + "tokio-rustls 0.24.1", +] [[package]] -name = "humansize" -version = "2.1.3" +name = "hyper-rustls" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "libm", + "http 1.4.0", + "hyper 1.9.0", + "hyper-util", + "rustls 0.23.37", + "rustls-native-certs", + "tokio", + "tokio-rustls 0.26.4", + "tower-service", + "webpki-roots 1.0.6", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", ] [[package]] @@ -2177,6 +3415,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -2188,6 +3436,12 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2260,6 +3514,38 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "linux-keyutils", + "log", + "secret-service", + "security-framework 2.11.1", + "security-framework 3.6.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "khronos-egl" version = "6.0.0" @@ -2318,6 +3604,15 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libfuzzer-sys" version = "0.4.12" @@ -2361,6 +3656,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags 2.10.0", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2418,6 +3723,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "malloc_buf" version = "0.0.6" @@ -2437,6 +3748,16 @@ dependencies = [ "rayon", ] +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + [[package]] name = "memchr" version = "2.7.6" @@ -2458,6 +3779,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "metal" version = "0.32.0" @@ -2473,6 +3803,22 @@ dependencies = [ "paste", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2548,7 +3894,7 @@ dependencies = [ "log", "num-traits", "once_cell", - "rustc-hash", + "rustc-hash 1.1.0", "spirv", "thiserror 2.0.17", "unicode-ident", @@ -2621,6 +3967,7 @@ dependencies = [ "cfg-if", "cfg_aliases 0.2.1", "libc", + "memoffset", ] [[package]] @@ -2648,6 +3995,20 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2658,6 +4019,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-derive" version = "0.4.2" @@ -2678,6 +4054,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-rational" version = "0.4.2" @@ -2847,6 +4234,18 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "orbclient" version = "0.3.49" @@ -2874,6 +4273,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "owned_ttf_parser" version = "0.25.1" @@ -2883,6 +4298,23 @@ dependencies = [ "ttf-parser 0.25.1", ] +[[package]] +name = "p256" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" +dependencies = [ + "ecdsa", + "elliptic-curve", + "sha2", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -2932,6 +4364,16 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + [[package]] name = "peniko" version = "0.5.0" @@ -3089,6 +4531,33 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -3141,6 +4610,17 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.12.0" @@ -3186,6 +4666,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -3258,27 +4744,82 @@ dependencies = [ ] [[package]] -name = "qoi" -version = "0.4.1" +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ - "bytemuck", + "bytes", + "cfg_aliases 0.2.1", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.2", + "rustls 0.23.37", + "socket2 0.6.3", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time 1.1.0", ] [[package]] -name = "quick-error" -version = "2.0.1" +name = "quinn-proto" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash 2.1.2", + "rustls 0.23.37", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time 1.1.0", +] [[package]] -name = "quick-xml" -version = "0.37.5" +name = "quinn-udp" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ - "memchr", + "cfg_aliases 0.2.1", + "libc", + "once_cell", + "socket2 0.6.3", + "tracing", + "windows-sys 0.60.2", ] [[package]] @@ -3308,6 +4849,8 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ + "libc", + "rand_chacha 0.3.1", "rand_core 0.6.4", ] @@ -3317,10 +4860,20 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha", + "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -3336,6 +4889,9 @@ name = "rand_core" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] [[package]] name = "rand_core" @@ -3380,7 +4936,7 @@ dependencies = [ "paste", "profiling", "rand 0.9.2", - "rand_chacha", + "rand_chacha 0.9.0", "simd_helpers", "thiserror 2.0.17", "v_frame", @@ -3480,6 +5036,12 @@ dependencies = [ "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" version = "0.8.10" @@ -3492,6 +5054,58 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-rustls 0.27.9", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.37", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls 0.26.4", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots 1.0.6", +] + +[[package]] +name = "rfc6979" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +dependencies = [ + "crypto-bigint 0.4.9", + "hmac", + "zeroize", +] + [[package]] name = "rgb" version = "0.8.53" @@ -3549,6 +5163,21 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "0.38.44" @@ -3575,36 +5204,73 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "aws-lc-rs", "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.103.10", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.6.0", +] + [[package]] name = "rustls-pki-types" version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time 1.1.0", "zeroize", ] +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "rustls-webpki" version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -3616,6 +5282,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -3625,6 +5297,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -3637,6 +5318,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + [[package]] name = "sctk-adwaita" version = "0.8.3" @@ -3650,6 +5341,75 @@ dependencies = [ "tiny-skia", ] +[[package]] +name = "sec1" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be24c1842290c45df0a7bf069e0c268a747ad05a192f2fd7dcfdbc1cba40928" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "secret-service" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4d35ad99a181be0a60ffcbe85d680d98f87bdc4d7644ade319b87076b9dbfd4" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "hkdf", + "num", + "once_cell", + "rand 0.8.6", + "serde", + "sha2", + "zbus", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "semver" version = "1.0.28" @@ -3700,6 +5460,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -3709,6 +5480,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serial2" version = "0.2.36" @@ -3720,6 +5503,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3784,6 +5578,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.8" @@ -3799,6 +5603,18 @@ dependencies = [ "quote", ] +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.17", + "time", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -3870,6 +5686,32 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" + [[package]] name = "spirv" version = "0.3.0+sdk-1.3.268.0" @@ -3879,6 +5721,16 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3961,14 +5813,47 @@ dependencies = [ ] [[package]] -name = "synstructure" -version = "0.13.2" +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix 1.1.2", + "windows-sys 0.61.2", ] [[package]] @@ -4133,6 +6018,37 @@ dependencies = [ "zune-jpeg 0.4.21", ] +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -4200,7 +6116,46 @@ version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.37", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", "pin-project-lite", + "tokio", ] [[package]] @@ -4274,6 +6229,82 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + [[package]] name = "tree-sitter" version = "0.25.10" @@ -4304,6 +6335,12 @@ dependencies = [ "tree-sitter-language", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "ttf-parser" version = "0.21.1" @@ -4328,6 +6365,23 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -4349,12 +6403,6 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - [[package]] name = "unicode-width" version = "0.2.2" @@ -4367,6 +6415,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -4383,7 +6441,7 @@ dependencies = [ "flate2", "log", "once_cell", - "rustls", + "rustls 0.23.37", "rustls-pki-types", "url", "webpki-roots 0.26.11", @@ -4401,6 +6459,12 @@ dependencies = [ "serde", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4486,6 +6550,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "vtparse" version = "0.7.0" @@ -4504,6 +6574,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -4759,6 +6838,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" @@ -5005,7 +7094,7 @@ dependencies = [ "portable-atomic", "profiling", "raw-window-handle", - "rustc-hash", + "rustc-hash 1.1.0", "smallvec", "thiserror 2.0.17", "wgpu-core-deps-apple", @@ -5557,7 +7646,7 @@ dependencies = [ "wayland-protocols", "wayland-protocols-plasma", "web-sys", - "web-time", + "web-time 0.2.4", "windows-sys 0.48.0", "x11-dl", "x11rb", @@ -5714,12 +7803,32 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.2", +] + [[package]] name = "xcursor" version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" +[[package]] +name = "xdg-home" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1cdab258fb55c0da61328dc52c8764709b249011b2cad0454c72f0bf10a1f6" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "xkbcommon-dl" version = "0.4.2" @@ -5745,6 +7854,12 @@ version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +[[package]] +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + [[package]] name = "y4m" version = "0.8.0" @@ -5791,6 +7906,62 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb97012beadd29e654708a0fdb4c84bc046f537aecfde2c3ee0a9e4b4d48c725" +dependencies = [ + "async-broadcast", + "async-process", + "async-recursion", + "async-trait", + "enumflags2", + "event-listener", + "futures-core", + "futures-sink", + "futures-util", + "hex", + "nix 0.29.0", + "ordered-stream", + "rand 0.8.6", + "serde", + "serde_repr", + "sha1", + "static_assertions", + "tracing", + "uds_windows", + "windows-sys 0.52.0", + "xdg-home", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "4.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.111", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" +dependencies = [ + "serde", + "static_assertions", + "zvariant", +] + [[package]] name = "zeno" version = "0.3.3" @@ -5843,6 +8014,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] [[package]] name = "zerotrie" @@ -5878,12 +8063,41 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror 2.0.17", + "zopfli", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zune-core" version = "0.4.12" @@ -5922,3 +8136,40 @@ checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ "zune-core 0.5.1", ] + +[[package]] +name = "zvariant" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive", +] + +[[package]] +name = "zvariant_derive" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.111", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] diff --git a/crates/tools/fission-cli/Cargo.toml b/crates/tools/fission-cli/Cargo.toml index 0f1dfd4a..bbf55ee7 100644 --- a/crates/tools/fission-cli/Cargo.toml +++ b/crates/tools/fission-cli/Cargo.toml @@ -17,8 +17,21 @@ path = "src/bin/cargo-fission.rs" [dependencies] anyhow = "1.0" clap = { version = "4.5", features = ["derive"] } +flate2 = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +sha2 = "0.10" +tar = "0.4" toml = "0.8" fission = { path = "../../authoring/fission", version = "0.1.1", default-features = false, features = ["terminal-shell"] } fission-shell-site = { path = "../../shell/fission-shell-site", version = "0.1.1" } +base64 = "0.22" +chacha20poly1305 = "0.10" +getrandom = { version = "0.2", features = ["std"] } +keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native-sync-persistent", "crypto-rust"] } +reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "multipart", "rustls-tls"] } +aws-config = "1" +aws-sdk-s3 = "1" +tokio = { version = "1", features = ["rt", "rt-multi-thread"] } +jsonwebtoken = "9" +zip = { version = "2", default-features = false, features = ["deflate"] } diff --git a/crates/tools/fission-cli/src/cli.rs b/crates/tools/fission-cli/src/cli.rs new file mode 100644 index 00000000..d951bc64 --- /dev/null +++ b/crates/tools/fission-cli/src/cli.rs @@ -0,0 +1,327 @@ +use crate::{publish, release, Target}; +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +#[derive(Parser, Debug)] +#[command( + name = "fission", + version, + about = "Scaffold and manage Fission applications" +)] +pub(crate) struct Cli { + #[command(subcommand)] + pub(crate) command: Command, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum Command { + /// Create a new Fission application. + Init { + /// Directory to create. + path: PathBuf, + /// Crate/package name override. + #[arg(long)] + name: Option, + /// Application identifier used by mobile targets. + #[arg(long)] + app_id: Option, + /// Optional local Fission checkout to use as a path dependency. + #[arg(long)] + local_path: Option, + }, + /// Add one or more platform targets to an existing Fission app. + AddTarget { + #[arg(value_enum)] + targets: Vec, + /// Project directory; defaults to the current working directory. + #[arg(long, default_value = ".")] + project_dir: PathBuf, + }, + /// Check local toolchains and SDKs needed by Fission targets. + Doctor { + /// Targets to check; defaults to web, iOS, and Android. + #[arg(value_enum)] + targets: Vec, + /// Project directory; defaults to the current working directory. + #[arg(long, default_value = ".")] + project_dir: PathBuf, + /// Exit with a non-zero status when required checks fail. + #[arg(long)] + strict: bool, + }, + /// List runnable desktop, browser, simulator, emulator, and device targets. + Devices { + /// Project directory; defaults to the current working directory. + #[arg(long, default_value = ".")] + project_dir: PathBuf, + /// Emit machine-readable JSON. + #[arg(long)] + json: bool, + }, + /// Build and run the app on a selected device, attaching logs by default. + Run { + /// Restrict device selection to one target. + #[arg(long, value_enum)] + target: Option, + /// Device id or exact/prefix device name from `fission devices`. + #[arg(long)] + device: Option, + /// Project directory; defaults to the current working directory. + #[arg(long, default_value = ".")] + project_dir: PathBuf, + /// Start the app and return instead of attaching logs/process output. + #[arg(long)] + detach: bool, + /// Build in release mode. + #[arg(long)] + release: bool, + /// Host for the local web server. + #[arg(long, default_value = "127.0.0.1")] + host: String, + /// Port for the local web server. + #[arg(long, default_value_t = 8123)] + port: u16, + /// Do not open a browser, Simulator, or emulator UI where supported. + #[arg(long)] + no_open: bool, + /// Prefer headless simulator/emulator execution where supported. + #[arg(long)] + headless: bool, + }, + /// Build a configured target without launching it. + Build { + /// Target to build; defaults to the host desktop target. + #[arg(long, value_enum)] + target: Option, + /// Project directory; defaults to the current working directory. + #[arg(long, default_value = ".")] + project_dir: PathBuf, + /// Build in release mode. + #[arg(long)] + release: bool, + }, + /// Run the generated smoke test for a configured target. + Test { + /// Target to test; defaults to the host desktop target. + #[arg(long, value_enum)] + target: Option, + /// Project directory; defaults to the current working directory. + #[arg(long, default_value = ".")] + project_dir: PathBuf, + /// Prefer headless simulator/emulator execution where supported. + #[arg(long)] + headless: bool, + }, + /// Build, check, serve, or list routes for a static Fission site. + Site { + #[command(subcommand)] + command: SiteCommand, + }, + /// Package a build output into a distributable artifact. + Package { + /// Target to package. + #[arg(long, value_enum)] + target: Target, + /// Package format. + #[arg(long, value_enum)] + format: publish::PackageFormat, + /// Project directory; defaults to the current working directory. + #[arg(long, default_value = ".")] + project_dir: PathBuf, + /// Build/package in release mode. + #[arg(long)] + release: bool, + /// Emit machine-readable JSON. + #[arg(long)] + json: bool, + }, + /// Publish a packaged artifact to a configured distribution provider. + Distribute { + /// Lifecycle action; defaults to publish. + #[arg(value_enum)] + action: Option, + /// Distribution provider. + #[arg(long, value_enum)] + provider: publish::DistributionProvider, + /// Artifact manifest emitted by `fission package`. + #[arg(long)] + artifact: Option, + /// Named distribution site/profile from fission.toml. + #[arg(long, default_value = "production")] + site: String, + /// Deployment id used by promote/rollback/status operations. + #[arg(long)] + deploy: Option, + /// Provider track/channel/group, such as internal, testflight, or production. + #[arg(long)] + track: Option, + /// Show what would happen without mutating provider state. + #[arg(long)] + dry_run: bool, + /// Confirm overwrites or provider-side setup changes. + #[arg(long)] + yes: bool, + /// Project directory; defaults to the current working directory. + #[arg(long, default_value = ".")] + project_dir: PathBuf, + /// Emit machine-readable JSON. + #[arg(long)] + json: bool, + }, + /// Run package or distribution readiness checks. + Readiness { + /// Readiness area to check. + #[arg(value_enum)] + kind: publish::ReadinessKind, + /// Target to package/check. + #[arg(long, value_enum)] + target: Option, + /// Package format. + #[arg(long, value_enum)] + format: Option, + /// Distribution provider. + #[arg(long, value_enum)] + provider: Option, + /// Artifact manifest emitted by `fission package`. + #[arg(long)] + artifact: Option, + /// Named distribution site/profile from fission.toml. + #[arg(long, default_value = "production")] + site: String, + /// Provider track/channel/group, such as internal, testflight, or production. + #[arg(long)] + track: Option, + /// Project directory; defaults to the current working directory. + #[arg(long, default_value = ".")] + project_dir: PathBuf, + /// Emit machine-readable JSON. + #[arg(long)] + json: bool, + }, + /// Edit, validate, import, diff, or push release metadata. + ReleaseConfig { + #[command(subcommand)] + command: release::ReleaseConfigCommand, + }, + /// Capture, render, or validate release screenshots and store assets. + ReleaseContent { + #[command(subcommand)] + command: release::ReleaseContentCommand, + }, + /// Manage beta groups, testers, and beta distribution. + Beta { + #[command(subcommand)] + command: release::BetaCommand, + }, + /// Inspect or import signing assets for release builds. + Signing { + #[command(subcommand)] + command: release::SigningCommand, + }, + /// List and reply to provider store reviews. + Reviews { + #[command(subcommand)] + command: release::ReviewsCommand, + }, + /// Run project-defined release workflows. + ReleaseWorkflow { + #[command(subcommand)] + command: release::ReleaseWorkflowCommand, + }, + /// Manage release provider authentication. + Auth { + #[command(subcommand)] + command: release::AuthCommand, + }, + /// Attach to logs for an already-running Fission app. + Logs { + /// Restrict device selection to one target. + #[arg(long, value_enum)] + target: Option, + /// Device id or exact/prefix device name from `fission devices`. + #[arg(long)] + device: Option, + /// Project directory; defaults to the current working directory. + #[arg(long, default_value = ".")] + project_dir: PathBuf, + /// Continue following logs instead of printing the current buffer. + #[arg(long)] + follow: bool, + }, + /// Open the interactive Fission CLI terminal UI. + Ui { + /// Project directory; defaults to the current working directory. + #[arg(long, default_value = ".")] + project_dir: PathBuf, + /// Write a PNG screenshot of the rendered terminal frame. + #[arg(long)] + screenshot: Option, + /// Render once and exit; useful for screenshots and smoke tests. + #[arg(long)] + exit_after_render: bool, + /// Override terminal width in cells. + #[arg(long)] + width: Option, + /// Override terminal height in cells. + #[arg(long)] + height: Option, + }, + /// Hidden helper used by `fission run --target web --detach`. + #[command(hide = true)] + ServeWeb { + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long, default_value = "127.0.0.1")] + host: String, + #[arg(long, default_value_t = 8123)] + port: u16, + #[arg(long)] + open: bool, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum SiteCommand { + /// Build the static site into its configured output directory. + Build { + /// Project directory; defaults to the current working directory. + #[arg(long, default_value = ".")] + project_dir: PathBuf, + /// Build in release mode. + #[arg(long)] + release: bool, + }, + /// Check the static site by rendering all routes. + Check { + /// Project directory; defaults to the current working directory. + #[arg(long, default_value = ".")] + project_dir: PathBuf, + /// Build in release mode. + #[arg(long)] + release: bool, + }, + /// Serve the generated static site locally. + Serve { + /// Project directory; defaults to the current working directory. + #[arg(long, default_value = ".")] + project_dir: PathBuf, + /// Host for the local site server. + #[arg(long, default_value = "127.0.0.1")] + host: String, + /// Port for the local site server. + #[arg(long, default_value_t = 8123)] + port: u16, + /// Build in release mode before serving. + #[arg(long)] + release: bool, + /// Do not open a browser. + #[arg(long)] + no_open: bool, + }, + /// List custom and content routes. + Routes { + /// Project directory; defaults to the current working directory. + #[arg(long, default_value = ".")] + project_dir: PathBuf, + }, +} diff --git a/crates/tools/fission-cli/src/lib.rs b/crates/tools/fission-cli/src/lib.rs index 0e3d8faa..a51e0e64 100644 --- a/crates/tools/fission-cli/src/lib.rs +++ b/crates/tools/fission-cli/src/lib.rs @@ -1,290 +1,20 @@ -use anyhow::{bail, Context, Result}; -use clap::{Parser, Subcommand, ValueEnum}; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeSet; -use std::fs; -use std::path::{Path, PathBuf}; +use anyhow::Result; +use clap::Parser; +use std::path::Path; +mod cli; mod doctor; +mod project; +mod publish; +mod release; mod ui; mod workflow; -const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); -const DEFAULT_APP_ICON_PNG: &[u8] = include_bytes!("../../../../docs/fission_logo.png"); +pub(crate) use project::{ + cargo_package_name, ios_executable_name, read_project_config, FissionProject, Target, +}; -#[derive(Parser, Debug)] -#[command( - name = "fission", - version, - about = "Scaffold and manage Fission applications" -)] -struct Cli { - #[command(subcommand)] - command: Command, -} - -#[derive(Subcommand, Debug)] -enum Command { - /// Create a new Fission application. - Init { - /// Directory to create. - path: PathBuf, - /// Crate/package name override. - #[arg(long)] - name: Option, - /// Application identifier used by mobile targets. - #[arg(long)] - app_id: Option, - /// Optional local Fission checkout to use as a path dependency. - #[arg(long)] - local_path: Option, - }, - /// Add one or more platform targets to an existing Fission app. - AddTarget { - #[arg(value_enum)] - targets: Vec, - /// Project directory; defaults to the current working directory. - #[arg(long, default_value = ".")] - project_dir: PathBuf, - }, - /// Check local toolchains and SDKs needed by Fission targets. - Doctor { - /// Targets to check; defaults to web, iOS, and Android. - #[arg(value_enum)] - targets: Vec, - /// Project directory; defaults to the current working directory. - #[arg(long, default_value = ".")] - project_dir: PathBuf, - /// Exit with a non-zero status when required checks fail. - #[arg(long)] - strict: bool, - }, - /// List runnable desktop, browser, simulator, emulator, and device targets. - Devices { - /// Project directory; defaults to the current working directory. - #[arg(long, default_value = ".")] - project_dir: PathBuf, - /// Emit machine-readable JSON. - #[arg(long)] - json: bool, - }, - /// Build and run the app on a selected device, attaching logs by default. - Run { - /// Restrict device selection to one target. - #[arg(long, value_enum)] - target: Option, - /// Device id or exact/prefix device name from `fission devices`. - #[arg(long)] - device: Option, - /// Project directory; defaults to the current working directory. - #[arg(long, default_value = ".")] - project_dir: PathBuf, - /// Start the app and return instead of attaching logs/process output. - #[arg(long)] - detach: bool, - /// Build in release mode. - #[arg(long)] - release: bool, - /// Host for the local web server. - #[arg(long, default_value = "127.0.0.1")] - host: String, - /// Port for the local web server. - #[arg(long, default_value_t = 8123)] - port: u16, - /// Do not open a browser, Simulator, or emulator UI where supported. - #[arg(long)] - no_open: bool, - /// Prefer headless simulator/emulator execution where supported. - #[arg(long)] - headless: bool, - }, - /// Build a configured target without launching it. - Build { - /// Target to build; defaults to the host desktop target. - #[arg(long, value_enum)] - target: Option, - /// Project directory; defaults to the current working directory. - #[arg(long, default_value = ".")] - project_dir: PathBuf, - /// Build in release mode. - #[arg(long)] - release: bool, - }, - /// Run the generated smoke test for a configured target. - Test { - /// Target to test; defaults to the host desktop target. - #[arg(long, value_enum)] - target: Option, - /// Project directory; defaults to the current working directory. - #[arg(long, default_value = ".")] - project_dir: PathBuf, - /// Prefer headless simulator/emulator execution where supported. - #[arg(long)] - headless: bool, - }, - /// Build, check, serve, or list routes for a static Fission site. - Site { - #[command(subcommand)] - command: SiteCommand, - }, - /// Attach to logs for an already-running Fission app. - Logs { - /// Restrict device selection to one target. - #[arg(long, value_enum)] - target: Option, - /// Device id or exact/prefix device name from `fission devices`. - #[arg(long)] - device: Option, - /// Project directory; defaults to the current working directory. - #[arg(long, default_value = ".")] - project_dir: PathBuf, - /// Continue following logs instead of printing the current buffer. - #[arg(long)] - follow: bool, - }, - /// Open the interactive Fission CLI terminal UI. - Ui { - /// Project directory; defaults to the current working directory. - #[arg(long, default_value = ".")] - project_dir: PathBuf, - /// Write a PNG screenshot of the rendered terminal frame. - #[arg(long)] - screenshot: Option, - /// Render once and exit; useful for screenshots and smoke tests. - #[arg(long)] - exit_after_render: bool, - /// Override terminal width in cells. - #[arg(long)] - width: Option, - /// Override terminal height in cells. - #[arg(long)] - height: Option, - }, - /// Hidden helper used by `fission run --target web --detach`. - #[command(hide = true)] - ServeWeb { - #[arg(long, default_value = ".")] - project_dir: PathBuf, - #[arg(long, default_value = "127.0.0.1")] - host: String, - #[arg(long, default_value_t = 8123)] - port: u16, - #[arg(long)] - open: bool, - }, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, ValueEnum, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -enum Target { - Android, - Ios, - Linux, - Macos, - Site, - Web, - Windows, -} - -impl Target { - fn as_str(self) -> &'static str { - match self { - Self::Android => "android", - Self::Ios => "ios", - Self::Linux => "linux", - Self::Macos => "macos", - Self::Site => "site", - Self::Web => "web", - Self::Windows => "windows", - } - } - - fn scaffold_relative_path(self) -> &'static str { - match self { - Self::Android => "platforms/android/README.md", - Self::Ios => "platforms/ios/README.md", - Self::Linux => "platforms/linux/README.md", - Self::Macos => "platforms/macos/README.md", - Self::Site => "platforms/site/README.md", - Self::Web => "platforms/web/README.md", - Self::Windows => "platforms/windows/README.md", - } - } -} - -#[derive(Subcommand, Debug)] -enum SiteCommand { - /// Build the static site into its configured output directory. - Build { - /// Project directory; defaults to the current working directory. - #[arg(long, default_value = ".")] - project_dir: PathBuf, - /// Build in release mode. - #[arg(long)] - release: bool, - }, - /// Check the static site by rendering all routes. - Check { - /// Project directory; defaults to the current working directory. - #[arg(long, default_value = ".")] - project_dir: PathBuf, - /// Build in release mode. - #[arg(long)] - release: bool, - }, - /// Serve the generated static site locally. - Serve { - /// Project directory; defaults to the current working directory. - #[arg(long, default_value = ".")] - project_dir: PathBuf, - /// Host for the local site server. - #[arg(long, default_value = "127.0.0.1")] - host: String, - /// Port for the local site server. - #[arg(long, default_value_t = 8123)] - port: u16, - /// Build in release mode before serving. - #[arg(long)] - release: bool, - /// Do not open a browser. - #[arg(long)] - no_open: bool, - }, - /// List custom and content routes. - Routes { - /// Project directory; defaults to the current working directory. - #[arg(long, default_value = ".")] - project_dir: PathBuf, - }, -} - -#[derive(Debug, Serialize, Deserialize)] -struct FissionProject { - app: AppConfig, - targets: BTreeSet, -} - -#[derive(Debug, Serialize, Deserialize)] -struct AppConfig { - name: String, - app_id: String, -} - -#[derive(Debug, Deserialize)] -struct CargoManifest { - package: Option, -} - -#[derive(Debug, Deserialize)] -struct CargoPackage { - name: String, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum WritePolicy { - Overwrite, - PreserveExisting, -} +use cli::{Cli, Command, SiteCommand}; pub fn run(args: I) -> Result<()> where @@ -309,11 +39,11 @@ where name, app_id, local_path, - } => init_project(&path, name, app_id, local_path), + } => project::init_project(&path, name, app_id, local_path), Command::AddTarget { targets, project_dir, - } => add_targets(&project_dir, &targets), + } => project::add_targets(&project_dir, &targets), Command::Doctor { targets, project_dir, @@ -377,6 +107,70 @@ where } => workflow::site_serve(&project_dir, release, host, port, !no_open), SiteCommand::Routes { project_dir } => workflow::site_routes(&project_dir), }, + Command::Package { + target, + format, + project_dir, + release, + json, + } => publish::package(publish::PackageOptions { + project_dir, + target, + format, + release, + json, + }), + Command::Distribute { + action, + provider, + artifact, + site, + deploy, + track, + dry_run, + yes, + project_dir, + json, + } => publish::distribute(publish::DistributeOptions { + project_dir, + provider, + action: action.unwrap_or(publish::DistributeAction::Publish), + artifact, + site, + deploy, + track, + dry_run, + yes, + json, + }), + Command::Readiness { + kind, + target, + format, + provider, + artifact, + site, + track, + project_dir, + json, + } => publish::readiness(publish::ReadinessOptions { + project_dir, + kind, + target, + format, + provider, + artifact, + site, + track, + json, + }), + Command::ReleaseConfig { command } => release::release_config(command), + Command::ReleaseContent { command } => release::release_content(command), + Command::Beta { command } => release::beta(command), + Command::Signing { command } => release::signing(command), + Command::Reviews { command } => release::reviews(command), + Command::ReleaseWorkflow { command } => release::release_workflow(command), + Command::Auth { command } => release::auth(command), Command::Logs { target, device, @@ -415,1731 +209,10 @@ where } } -fn init_project( - root: &Path, - name: Option, - app_id: Option, - local_path: Option, -) -> Result<()> { - let existing_project = root.exists() && root.read_dir()?.next().is_some(); - fs::create_dir_all(root.join("src"))?; - - let write_policy = if existing_project { - WritePolicy::PreserveExisting - } else { - WritePolicy::Overwrite - }; - let project = initial_project_config(root, name, app_id)?; - - write_file_with_policy( - &root.join("Cargo.toml"), - &render_cargo_toml(&project, local_path.as_deref()), - write_policy, - )?; - write_file_with_policy( - &root.join("src/main.rs"), - &render_app_main(project.app.name.as_str()), - write_policy, - )?; - write_file_with_policy(&root.join("src/lib.rs"), APP_LIB, write_policy)?; - write_file_with_policy(&root.join("src/app.rs"), APP_RS, write_policy)?; - write_binary_file_with_policy( - &root.join("assets/app-icon.png"), - DEFAULT_APP_ICON_PNG, - write_policy, - )?; - write_file_with_policy( - &root.join("README.md"), - &render_project_readme(&project), - write_policy, - )?; - write_file_with_policy( - &root.join(".gitignore"), - "target/\nplatforms/*/build/\n", - write_policy, - )?; - write_project_config(root, &project)?; - - let targets = project.targets.iter().copied().collect::>(); - for target in targets { - scaffold_target_with_policy(root, &project, target, write_policy)?; - } - - Ok(()) -} - -fn initial_project_config( - root: &Path, - name: Option, - app_id: Option, -) -> Result { - let existing = if root.join("fission.toml").exists() { - Some(read_project_config(root)?) - } else { - None - }; - let cargo_name = cargo_package_name(root); - if let (Some(requested), Some(cargo_name)) = (&name, &cargo_name) { - let requested = normalize_crate_name(requested); - let cargo_name = normalize_crate_name(cargo_name); - if requested != cargo_name { - bail!( - "refusing to set app name `{requested}` for existing Cargo package `{cargo_name}`; rename the package in Cargo.toml first or omit --name" - ); - } - } - let project_name = cargo_name - .or(name) - .or_else(|| existing.as_ref().map(|project| project.app.name.clone())) - .unwrap_or_else(|| { - root.file_name() - .and_then(|value| value.to_str()) - .unwrap_or("fission-app") - .to_string() - }); - let normalized_name = normalize_crate_name(&project_name); - - let mut targets = existing - .as_ref() - .map(|project| project.targets.clone()) - .unwrap_or_default(); - targets.extend(detect_project_targets(root)); - if targets.is_empty() { - targets.extend([Target::Windows, Target::Macos, Target::Linux]); - } - - Ok(FissionProject { - app: AppConfig { - name: normalized_name.clone(), - app_id: app_id - .or_else(|| existing.map(|project| project.app.app_id)) - .unwrap_or_else(|| format!("com.example.{}", normalized_name.replace('-', "_"))), - }, - targets, - }) -} - -fn cargo_package_name(root: &Path) -> Option { - let manifest = fs::read_to_string(root.join("Cargo.toml")).ok()?; - let manifest: CargoManifest = toml::from_str(&manifest).ok()?; - manifest.package.map(|package| package.name) -} - -fn detect_project_targets(root: &Path) -> BTreeSet { - let mut targets = BTreeSet::new(); - if root.join("src/main.rs").exists() || root.join("src/lib.rs").exists() { - targets.extend([Target::Windows, Target::Macos, Target::Linux]); - } - for (target, relative) in [ - (Target::Android, "platforms/android"), - (Target::Ios, "platforms/ios"), - (Target::Linux, "platforms/linux"), - (Target::Macos, "platforms/macos"), - (Target::Site, "content"), - (Target::Web, "platforms/web"), - (Target::Windows, "platforms/windows"), - ] { - if root.join(relative).exists() { - targets.insert(target); - } - } - targets -} - -fn add_targets(project_dir: &Path, targets: &[Target]) -> Result<()> { - if targets.is_empty() { - bail!("no targets provided"); - } - let mut project = read_project_config(project_dir)?; - for target in targets { - let target_exists = - project.targets.contains(target) || target_scaffold_dir_exists(project_dir, *target); - project.targets.insert(*target); - let write_policy = if target_exists { - WritePolicy::PreserveExisting - } else { - WritePolicy::Overwrite - }; - scaffold_target_with_policy(project_dir, &project, *target, write_policy)?; - } - write_project_config(project_dir, &project)?; - update_cargo_fission_features(project_dir, &project)?; - write_file_with_policy( - &project_dir.join("README.md"), - &render_project_readme(&project), - WritePolicy::PreserveExisting, - )?; - Ok(()) -} - -fn target_scaffold_dir_exists(project_dir: &Path, target: Target) -> bool { - Path::new(target.scaffold_relative_path()) - .parent() - .is_some_and(|relative| project_dir.join(relative).exists()) -} - -fn write_project_config(root: &Path, project: &FissionProject) -> Result<()> { - let data = toml::to_string_pretty(project)?; - write_file(&root.join("fission.toml"), &(data + "\n")) -} - -fn read_project_config(root: &Path) -> Result { - let path = root.join("fission.toml"); - let data = fs::read_to_string(&path).with_context(|| { - format!( - "failed to read {}; run `fission init {}` to register this project without overwriting existing files", - path.display(), - root.display() - ) - })?; - toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display())) -} - -fn update_cargo_fission_features(root: &Path, project: &FissionProject) -> Result<()> { - let path = root.join("Cargo.toml"); - let Ok(text) = fs::read_to_string(&path) else { - return Ok(()); - }; - let feature_list = render_fission_feature_list(&project.targets); - let mut changed = false; - let mut out = Vec::new(); - for line in text.lines() { - if let Some(updated) = update_inline_fission_dependency(line, &feature_list) { - changed |= updated != line; - out.push(updated); - } else { - out.push(line.to_string()); - } - } - if changed { - fs::write(&path, out.join("\n") + "\n") - .with_context(|| format!("failed to update {}", path.display()))?; - } - Ok(()) -} - -fn update_inline_fission_dependency(line: &str, feature_list: &str) -> Option { - let trimmed = line.trim_start(); - if !trimmed.starts_with("fission =") { - return None; - } - let indent = &line[..line.len() - trimmed.len()]; - let value = trimmed.strip_prefix("fission =")?.trim(); - if value.starts_with('"') { - return Some(format!( - "{indent}fission = {{ version = {value}, default-features = false, features = [{feature_list}] }}" - )); - } - if !(value.starts_with('{') && value.ends_with('}')) { - return None; - } - let inner = value - .strip_prefix('{') - .and_then(|value| value.strip_suffix('}'))? - .trim(); - let mut fields = split_top_level_fields(inner) - .into_iter() - .filter(|field| { - let key = field - .split_once('=') - .map(|(key, _)| key.trim()) - .unwrap_or_default(); - key != "default-features" && key != "features" - }) - .collect::>(); - fields.push("default-features = false".to_string()); - fields.push(format!("features = [{feature_list}]")); - Some(format!("{indent}fission = {{ {} }}", fields.join(", "))) -} - -fn split_top_level_fields(input: &str) -> Vec { - let mut fields = Vec::new(); - let mut start = 0; - let mut bracket_depth = 0usize; - let mut in_string = false; - let mut escaped = false; - for (index, ch) in input.char_indices() { - if in_string { - if escaped { - escaped = false; - } else if ch == '\\' { - escaped = true; - } else if ch == '"' { - in_string = false; - } - continue; - } - match ch { - '"' => in_string = true, - '[' => bracket_depth += 1, - ']' => bracket_depth = bracket_depth.saturating_sub(1), - ',' if bracket_depth == 0 => { - let field = input[start..index].trim(); - if !field.is_empty() { - fields.push(field.to_string()); - } - start = index + ch.len_utf8(); - } - _ => {} - } - } - let field = input[start..].trim(); - if !field.is_empty() { - fields.push(field.to_string()); - } - fields -} - -fn scaffold_target_with_policy( - root: &Path, - project: &FissionProject, - target: Target, - write_policy: WritePolicy, -) -> Result<()> { - let relative = Path::new(target.scaffold_relative_path()); - let text = match target { - Target::Android => { - scaffold_android_bundle(root, project, write_policy)?; - platform_readme( - "Android", - "Runnable emulator target. The CLI generates a NativeActivity manifest plus shell scripts that build, install, and launch the Fission app on an Android emulator.", - &[ - "Install the Rust target: `rustup target add aarch64-linux-android`.", - "Run `cargo fission doctor android --project-dir .` to check SDK, NDK, emulator, and Rust target setup.", - "Run `cargo fission devices --project-dir .` to list connected Android devices and configured emulators.", - "Run `cargo fission run --target android --project-dir .` to build, install, launch, and attach to logs.", - "Run `cargo fission run --target android --device --project-dir .` to launch on a specific device.", - "Run `cargo fission test --target android --project-dir .` for an emulator launch plus test-control health check.", - "Run `./platforms/android/run-emulator.sh` from the project root to build, package, install, and launch the app on the configured emulator.", - "Override `ANDROID_HOME`, `ANDROID_NDK`, `ANDROID_MIN_API_LEVEL`, `ANDROID_TARGET_API_LEVEL`, `ANDROID_AVD_NAME`, or `ANDROID_SYSTEM_IMAGE` if your local SDK setup differs.", - "Set `ANDROID_EMULATOR_HEADLESS=1` for background/CI runs, or `ANDROID_EMULATOR_RESTART=1` to relaunch a hidden emulator visibly.", - "The generated package uses `assets/app-icon.png` as its default launcher icon.", - "Set `FISSION_TEST_CONTROL_PORT=` before `run-emulator.sh`; the script forwards it to the fixed in-app device port.", - ], - ) - } - Target::Ios => { - scaffold_ios_bundle(root, project, write_policy)?; - platform_readme( - "iOS", - "Simulator target. The CLI generates a simulator app bundle template plus shell scripts that build, install, launch, and smoke-test the Fission app with `simctl`.", - &[ - "Install the Rust targets: `rustup target add aarch64-apple-ios aarch64-apple-ios-sim`.", - "Run `cargo fission doctor ios --project-dir .` to check Xcode, simulator, and Rust target setup.", - "Confirm the simulator SDK path with `xcrun --sdk iphonesimulator --show-sdk-path`.", - "Run `cargo fission devices --project-dir .` to list available iOS simulators.", - "Run `cargo fission run --target ios --project-dir .` to build, install, launch, and attach to simulator logs.", - "Run `cargo fission run --target ios --device --project-dir .` to launch on a specific simulator.", - "Run `cargo fission test --target ios --project-dir .` for a simulator launch plus test-control health check.", - "Run `./platforms/ios/run-sim.sh` from the project root to build, install, and launch the app on the first available iPhone simulator.", - "The generated bundle uses `assets/app-icon.png` as its default app icon.", - "Set `FISSION_TEST_CONTROL_PORT=` before `run-sim.sh` to expose the in-app test control server on the host.", - "Set `IOS_SIM_DEVICE_ID=` if you want a specific simulator device.", - "Set `IOS_SIM_HEADLESS=1` for CI or background-only simulator runs; otherwise the script opens Simulator visibly.", - ], - ) - } - Target::Web => { - scaffold_web_bundle(root, project, write_policy)?; - platform_readme( - "Web", - "Runnable browser target. The CLI generates a WASM host page plus helper scripts that build the app with `wasm-pack` and serve it locally.", - &[ - "Install the Rust target: `rustup target add wasm32-unknown-unknown`.", - "Install `wasm-pack` once: `cargo install wasm-pack`.", - "Install Node.js 22+ so the smoke test can inspect Chrome/Chromium CDP runtime and console output.", - "Run `cargo fission doctor web --project-dir .` to check wasm-pack, Node.js, Chrome/Chromium, and Rust target setup.", - "Run `cargo fission devices --project-dir .` to confirm Chrome/Chromium detection.", - "Run `cargo fission run --target web --project-dir .` to build, serve, open, and attach to the local server.", - "Run `cargo fission run --target web --detach --project-dir .` to keep the local server running in the background.", - "Run `cargo fission test --target web --project-dir .` for a headless Chrome/Chromium CDP smoke test.", - "Run `./platforms/web/run-browser.sh` from the project root to build the wasm package and serve the app locally.", - "Set `FISSION_WEB_PORT=` or `FISSION_WEB_HOST=` if the default `127.0.0.1:8123` does not suit your machine.", - "Set `FISSION_WEB_OPEN=1` if you want the helper script to open a browser tab automatically.", - "The generated page uses `assets/app-icon.png` as its default favicon/app icon seed.", - ], - ) - } - Target::Site => { - write_file_with_policy( - &root.join("content/getting-started.md"), - "---\ntitle: Site content\ndescription: Static site content rendered by the Fission static site shell.\n---\n\n# Site content\n\nAdd Markdown files under `content/`. `cargo fission site build` renders them through real Fission widgets, lowers the nodes to Core IR, and emits static HTML.\n", - write_policy, - )?; - platform_readme( - "Static site", - "Static multi-page website target. The site shell renders Markdown content through real Fission widgets, lowers nodes to Core IR, and emits semantic static HTML.", - &[ - "Add Markdown or MDX content under `content/`.", - "Run `cargo fission site routes --project-dir .` to list generated routes.", - "Run `cargo fission site build --project-dir .` to render HTML into `target/fission/site`.", - "Run `cargo fission site serve --project-dir .` to build and serve the generated site locally.", - "Unsupported interactive widgets fail during the static render instead of silently falling back to JavaScript.", - ], - ) - } - Target::Linux | Target::Macos | Target::Windows => platform_readme( - match target { - Target::Linux => "Linux", - Target::Macos => "macOS", - Target::Windows => "Windows", - _ => unreachable!(), - }, - "Runnable target. Desktop platforms share the default `src/main.rs` entrypoint through `DesktopApp`.", - &[ - "Run `cargo fission run --project-dir .` from the project root to launch the desktop app and attach output.", - "Run `cargo fission build --project-dir . --release` for a release desktop build.", - "Run `cargo fission test --project-dir .` for the app crate's Rust tests.", - "This target uses the default Vello desktop shell path.", - ], - ), - }; - write_file_with_policy(&root.join(relative), &text, write_policy) -} - -fn scaffold_ios_bundle( - root: &Path, - project: &FissionProject, - write_policy: WritePolicy, -) -> Result<()> { - let executable = ios_executable_name(project); - let bundle_name = ios_bundle_name(project); - let plist = render_ios_plist(project, &executable); - let package_script = render_ios_package_script(project, &bundle_name, &executable); - let run_script = render_ios_run_script(project); - let test_script = render_ios_test_script(); - - write_file_with_policy(&root.join("platforms/ios/Info.plist"), &plist, write_policy)?; - write_file_with_policy( - &root.join("platforms/ios/package-sim.sh"), - &package_script, - write_policy, - )?; - write_file_with_policy( - &root.join("platforms/ios/run-sim.sh"), - &run_script, - write_policy, - )?; - write_file_with_policy( - &root.join("platforms/ios/test-sim.sh"), - &test_script, - write_policy, - )?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - for relative in [ - "platforms/ios/package-sim.sh", - "platforms/ios/run-sim.sh", - "platforms/ios/test-sim.sh", - ] { - let path = root.join(relative); - if path.exists() { - fs::set_permissions(path, fs::Permissions::from_mode(0o755))?; - } - } - } - Ok(()) -} - -fn scaffold_android_bundle( - root: &Path, - project: &FissionProject, - write_policy: WritePolicy, -) -> Result<()> { - let manifest = render_android_manifest(project); - let package_script = render_android_package_script(project); - let run_script = render_android_run_script(project); - let test_script = render_android_test_script(); - - write_file_with_policy( - &root.join("platforms/android/AndroidManifest.xml"), - &manifest, - write_policy, - )?; - write_file_with_policy( - &root.join("platforms/android/package-apk.sh"), - &package_script, - write_policy, - )?; - write_file_with_policy( - &root.join("platforms/android/run-emulator.sh"), - &run_script, - write_policy, - )?; - write_file_with_policy( - &root.join("platforms/android/test-emulator.sh"), - &test_script, - write_policy, - )?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - for relative in [ - "platforms/android/package-apk.sh", - "platforms/android/run-emulator.sh", - "platforms/android/test-emulator.sh", - ] { - let path = root.join(relative); - if path.exists() { - fs::set_permissions(path, fs::Permissions::from_mode(0o755))?; - } - } - } - Ok(()) -} - -fn scaffold_web_bundle( - root: &Path, - project: &FissionProject, - write_policy: WritePolicy, -) -> Result<()> { - let index_html = render_web_index(project); - let bootstrap = render_web_bootstrap(project); - let build_script = render_web_build_script(); - let run_script = render_web_run_script(project); - let test_script = render_web_test_script(project); - - write_file_with_policy( - &root.join("platforms/web/index.html"), - &index_html, - write_policy, - )?; - write_file_with_policy( - &root.join("platforms/web/bootstrap.mjs"), - &bootstrap, - write_policy, - )?; - write_file_with_policy( - &root.join("platforms/web/build-wasm.sh"), - &build_script, - write_policy, - )?; - write_file_with_policy( - &root.join("platforms/web/run-browser.sh"), - &run_script, - write_policy, - )?; - write_file_with_policy( - &root.join("platforms/web/test-browser.sh"), - &test_script, - write_policy, - )?; - - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - for relative in [ - "platforms/web/build-wasm.sh", - "platforms/web/run-browser.sh", - "platforms/web/test-browser.sh", - ] { - let path = root.join(relative); - if path.exists() { - let mut perms = fs::metadata(&path)?.permissions(); - perms.set_mode(0o755); - fs::set_permissions(path, perms)?; - } - } - } - - Ok(()) -} - -fn write_file(path: &Path, contents: &str) -> Result<()> { - write_file_with_policy(path, contents, WritePolicy::Overwrite) -} - -fn write_file_with_policy(path: &Path, contents: &str, write_policy: WritePolicy) -> Result<()> { - if write_policy == WritePolicy::PreserveExisting && path.exists() { - return Ok(()); - } - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(path, contents).with_context(|| format!("failed to write {}", path.display())) -} - -fn write_binary_file_with_policy( - path: &Path, - contents: &[u8], - write_policy: WritePolicy, -) -> Result<()> { - if write_policy == WritePolicy::PreserveExisting && path.exists() { - return Ok(()); - } - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(path, contents).with_context(|| format!("failed to write {}", path.display())) -} - -fn render_cargo_toml(project: &FissionProject, local_path: Option<&Path>) -> String { - let feature_list = render_fission_feature_list(&project.targets); - let deps = if let Some(root) = local_path { - let fission_path = root.join("crates/authoring/fission"); - format!( - "fission = {{ path = {:?}, default-features = false, features = [{}] }}\n", - fission_path.to_string_lossy().to_string(), - feature_list - ) - } else { - format!( - "fission = {{ version = \"{}\", default-features = false, features = [{}] }}\n", - CURRENT_VERSION, feature_list - ) - }; - let lib_name = project.app.name.replace('-', "_"); - - format!( - "[package]\nname = \"{}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[lib]\nname = \"{}\"\ncrate-type = [\"cdylib\", \"rlib\"]\n\n[dependencies]\nanyhow = \"1\"\nserde = {{ version = \"1\", features = [\"derive\"] }}\n{}\n[target.'cfg(target_arch = \"wasm32\")'.dependencies]\nconsole_error_panic_hook = \"0.1\"\nwasm-bindgen = \"0.2\"\n", - project.app.name, lib_name, deps - ) -} - -fn render_fission_feature_list(targets: &BTreeSet) -> String { - fission_features_for_targets(targets) - .into_iter() - .map(|feature| format!("\"{feature}\"")) - .collect::>() - .join(", ") -} - -fn fission_features_for_targets(targets: &BTreeSet) -> Vec<&'static str> { - let mut features = Vec::new(); - if targets - .iter() - .any(|target| matches!(target, Target::Linux | Target::Macos | Target::Windows)) - { - features.push("desktop"); - } - if targets.contains(&Target::Web) { - features.push("web"); - } - if targets.contains(&Target::Android) { - features.push("android"); - } - if targets.contains(&Target::Ios) { - features.push("ios"); - } - if targets.contains(&Target::Site) { - features.push("site"); - } - features -} - -fn render_project_readme(project: &FissionProject) -> String { - let mut targets = String::new(); - for target in &project.targets { - targets.push_str(&format!("- `{}`\n", target.as_str())); - } - format!( - "# {}\n\nGenerated by `fission init`.\n\n## Targets\n\n{}\n## Commands\n\n- `cargo fission doctor --project-dir .` -- check local SDKs, browsers, emulators, and Rust targets\n- `cargo fission devices --project-dir .` -- list runnable desktop, browser, simulator, emulator, and device targets\n- `cargo fission run --project-dir .` -- launch the desktop app and attach to output\n- `cargo fission run --target web --project-dir .` -- launch the web app and attach to the local server\n- `cargo fission run --target ios --project-dir .` -- build, install, launch, and attach to simulator logs\n- `cargo fission run --target android --project-dir .` -- build, install, launch, and attach to Android logs\n- `cargo fission run --target --device --detach --project-dir .` -- launch without attaching\n- `cargo fission logs --target --device --project-dir . --follow` -- attach later where supported\n- `cargo fission build --target --project-dir . --release` -- build a target without launching it\n- `cargo fission test --target --project-dir .` -- run the generated platform smoke test\n- `cargo fission add-target web ios android --project-dir .` -- scaffold more targets\n- `cat platforms//README.md` -- inspect target-specific prerequisites and environment variables\n\n## Assets\n\n- `assets/app-icon.png` is the default app icon seed copied from Fission's `docs/fission_logo.png`\n\n## Status\n\nDesktop, web, iOS simulator, and Android emulator workflows are runnable through `cargo fission run`. The platform scripts remain checked in so CI and advanced users can call the lower-level build, run, and smoke-test steps directly when needed.\n", - project.app.name, targets - ) -} - -fn platform_readme(title: &str, summary: &str, bullets: &[&str]) -> String { - let mut out = format!("# {} target\n\n{}\n", title, summary); - for bullet in bullets { - out.push_str(&format!("\n- {}", bullet)); - } - out.push('\n'); - out -} - -fn normalize_crate_name(name: &str) -> String { - name.chars() - .map(|ch| match ch { - 'A'..='Z' => ch.to_ascii_lowercase(), - 'a'..='z' | '0'..='9' => ch, - _ => '-', - }) - .collect::() - .trim_matches('-') - .to_string() -} - -fn ios_executable_name(project: &FissionProject) -> String { - project.app.name.replace('-', "_") -} - -fn ios_bundle_name(project: &FissionProject) -> String { - let mut out = String::new(); - let mut uppercase_next = true; - for ch in project.app.name.chars() { - match ch { - '-' | '_' | ' ' => uppercase_next = true, - _ if uppercase_next => { - out.extend(ch.to_uppercase()); - uppercase_next = false; - } - _ => out.push(ch), - } - } - if out.is_empty() { - "FissionApp".to_string() - } else { - out - } -} - -fn android_library_name(project: &FissionProject) -> String { - project.app.name.replace('-', "_") -} - -fn render_ios_plist(project: &FissionProject, executable: &str) -> String { - format!( - r#" - - - - CFBundleDevelopmentRegion - en - CFBundleDisplayName - {display_name} - CFBundleExecutable - {executable} - CFBundleIdentifier - {bundle_id} - CFBundleInfoDictionaryVersion - 6.0 - CFBundleName - {display_name} - CFBundlePackageType - APPL - CFBundleShortVersionString - 0.1.0 - CFBundleVersion - 1 - CFBundleIconFile - AppIcon - LSRequiresIPhoneOS - - MinimumOSVersion - 18.0 - UIDeviceFamily - - 1 - 2 - - - -"#, - display_name = ios_bundle_name(project), - executable = executable, - bundle_id = project.app.app_id, - ) -} - -fn render_ios_package_script( - project: &FissionProject, - bundle_name: &str, - executable: &str, -) -> String { - format!( - r#"#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd) -PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd) -TARGET="${{IOS_SIM_TARGET:-aarch64-apple-ios-sim}}" -PROFILE="${{IOS_SIM_PROFILE:-debug}}" -PACKAGE_NAME="{package_name}" -BUNDLE_ID="${{IOS_BUNDLE_ID:-{bundle_id}}}" -DISPLAY_NAME="${{IOS_DISPLAY_NAME:-{bundle_name}}}" -EXECUTABLE_NAME="${{IOS_EXECUTABLE_NAME:-{executable}}}" -BUNDLE_NAME="${{IOS_BUNDLE_NAME:-$DISPLAY_NAME.app}}" -BUILD_DIR="$SCRIPT_DIR/build/$PROFILE" -BUNDLE_DIR="$BUILD_DIR/$BUNDLE_NAME" - -BUILD_ARGS=(build --manifest-path "$PROJECT_DIR/Cargo.toml" --target "$TARGET" --package "$PACKAGE_NAME") -ARTIFACT_DIR=debug -if [[ "$PROFILE" == "release" ]]; then - BUILD_ARGS+=(--release) - ARTIFACT_DIR=release -fi - -cargo "${{BUILD_ARGS[@]}}" -TARGET_DIR=$(python3 - <<'PY' "$PROJECT_DIR/Cargo.toml" -import json -import subprocess -import sys - -manifest = sys.argv[1] -metadata = json.loads( - subprocess.check_output( - ["cargo", "metadata", "--manifest-path", manifest, "--format-version", "1", "--no-deps"] - ) -) -print(metadata["target_directory"]) -PY -) - -rm -rf "$BUNDLE_DIR" -mkdir -p "$BUNDLE_DIR" -cp "$TARGET_DIR/$TARGET/$ARTIFACT_DIR/$PACKAGE_NAME" "$BUNDLE_DIR/$EXECUTABLE_NAME" -chmod +x "$BUNDLE_DIR/$EXECUTABLE_NAME" -python3 - <<'PY' "$SCRIPT_DIR/Info.plist" "$BUNDLE_DIR/Info.plist" "$BUNDLE_ID" "$DISPLAY_NAME" "$EXECUTABLE_NAME" -import plistlib -import sys - -source, dest, bundle_id, display_name, executable_name = sys.argv[1:] -with open(source, "rb") as handle: - plist = plistlib.load(handle) -plist["CFBundleIdentifier"] = bundle_id -plist["CFBundleDisplayName"] = display_name -plist["CFBundleName"] = display_name -plist["CFBundleExecutable"] = executable_name -with open(dest, "wb") as handle: - plistlib.dump(plist, handle, sort_keys=False) -PY -cp "$PROJECT_DIR/assets/app-icon.png" "$BUNDLE_DIR/AppIcon.png" -printf 'APPL????' > "$BUNDLE_DIR/PkgInfo" -printf '%s\n' "$BUNDLE_DIR" -"#, - package_name = project.app.name, - bundle_id = project.app.app_id, - bundle_name = bundle_name, - executable = executable, - ) -} - -fn render_ios_run_script(project: &FissionProject) -> String { - format!( - r#"#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd) -BUNDLE_DIR=$("$SCRIPT_DIR/package-sim.sh") -BUNDLE_ID="${{IOS_BUNDLE_ID:-{bundle_id}}}" -DEVICE_ID="${{IOS_SIM_DEVICE_ID:-}}" - -if [[ -z "$DEVICE_ID" ]]; then - DEVICE_ID=$(python3 - <<'PY' -import json -import subprocess -payload = json.loads(subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "-j"])) -for runtime, devices in payload["devices"].items(): - if not runtime.startswith("com.apple.CoreSimulator.SimRuntime.iOS-"): - continue - for device in devices: - if device.get("isAvailable") and "iPhone" in device["name"]: - print(device["udid"]) - raise SystemExit(0) -raise SystemExit("no available iPhone simulator found") -PY -) -fi - -if [[ "${{IOS_SIM_HEADLESS:-0}}" != "1" ]] && command -v open >/dev/null 2>&1; then - open -a Simulator --args -CurrentDeviceUDID "$DEVICE_ID" >/dev/null 2>&1 \ - || open -a Simulator >/dev/null 2>&1 \ - || true -fi - -xcrun simctl boot "$DEVICE_ID" >/dev/null 2>&1 || true -xcrun simctl bootstatus "$DEVICE_ID" -b -xcrun simctl install "$DEVICE_ID" "$BUNDLE_DIR" - -if [[ -n "${{FISSION_TEST_CONTROL_PORT:-}}" ]]; then - SIMCTL_CHILD_FISSION_TEST_CONTROL_PORT="${{FISSION_TEST_CONTROL_PORT}}" \ - xcrun simctl launch --terminate-running-process "$DEVICE_ID" "$BUNDLE_ID" -else - xcrun simctl launch --terminate-running-process "$DEVICE_ID" "$BUNDLE_ID" -fi -"#, - bundle_id = project.app.app_id, - ) -} - -fn render_ios_test_script() -> String { - r#"#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd) -export FISSION_TEST_CONTROL_PORT="${FISSION_TEST_CONTROL_PORT:-48711}" - -"$SCRIPT_DIR/run-sim.sh" - -python3 - <<'PY' "$FISSION_TEST_CONTROL_PORT" -import sys -import time -import urllib.request - -port = sys.argv[1] -url = f"http://127.0.0.1:{port}/health" -deadline = time.time() + 90 -last_error = None -while time.time() < deadline: - try: - with urllib.request.urlopen(url, timeout=1) as response: - body = response.read().decode("utf-8", "replace") - if response.status == 200 and '"status":"ok"' in body: - print(f"iOS simulator test control is healthy on {url}") - raise SystemExit(0) - except Exception as error: - last_error = error - time.sleep(1) -raise SystemExit(f"iOS simulator test control did not become healthy on {url}: {last_error}") -PY -"# - .to_string() -} - -fn render_android_manifest(project: &FissionProject) -> String { - format!( - r#" - - - - - - - - - - - - - - - - - -"#, - app_id = project.app.app_id, - label = ios_bundle_name(project), - lib_name = android_library_name(project), - ) -} - -fn render_android_package_script(project: &FissionProject) -> String { - let lib_name = android_library_name(project); - format!( - r#"#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd) -PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd) -TARGET="${{ANDROID_TARGET_TRIPLE:-aarch64-linux-android}}" -PACKAGE_NAME="{package_name}" -LIB_NAME="{lib_name}" -PROFILE="${{ANDROID_PROFILE:-debug}}" -ANDROID_HOME="${{ANDROID_HOME:-${{ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}}}" -ANDROID_MIN_API_LEVEL="${{ANDROID_MIN_API_LEVEL:-${{ANDROID_API_LEVEL:-24}}}}" - -find_android_ndk() {{ - if [[ -n "${{ANDROID_NDK:-}}" ]]; then - printf '%s\n' "$ANDROID_NDK" - return - fi - local ndk_root="$ANDROID_HOME/ndk" - if [[ ! -d "$ndk_root" ]]; then - printf 'Android NDK not found. Set ANDROID_NDK or install one under %s.\n' "$ndk_root" >&2 - return 1 - fi - local ndk - ndk=$(find "$ndk_root" -maxdepth 1 -mindepth 1 -type d | sort -V | tail -1) - if [[ -z "$ndk" ]]; then - printf 'Android NDK not found. Set ANDROID_NDK or install one under %s.\n' "$ndk_root" >&2 - return 1 - fi - printf '%s\n' "$ndk" -}} - -detect_android_toolchain() {{ - local prebuilt_root="$ANDROID_NDK/toolchains/llvm/prebuilt" - local host - for host in darwin-aarch64 darwin-x86_64 linux-x86_64 windows-x86_64; do - if [[ -d "$prebuilt_root/$host/bin" ]]; then - printf '%s\n' "$prebuilt_root/$host/bin" - return - fi - done - local fallback - fallback=$(find "$prebuilt_root" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort | head -1 || true) - if [[ -n "$fallback" && -d "$fallback/bin" ]]; then - printf '%s\n' "$fallback/bin" - return - fi - printf 'No Android NDK LLVM prebuilt toolchain found under %s. Expected a prebuilt host directory such as darwin-x86_64 or linux-x86_64.\n' "$prebuilt_root" >&2 - return 1 -}} - -detect_latest_android_api() {{ - find "$ANDROID_HOME/platforms" -maxdepth 1 -type d -name 'android-*' 2>/dev/null \ - | sed 's#.*android-##' \ - | sort -n \ - | tail -1 -}} - -detect_build_tools_dir() {{ - if [[ -n "${{ANDROID_BUILD_TOOLS:-}}" ]]; then - if [[ -d "$ANDROID_BUILD_TOOLS" ]]; then - printf '%s\n' "$ANDROID_BUILD_TOOLS" - return - fi - if [[ -d "$ANDROID_HOME/build-tools/$ANDROID_BUILD_TOOLS" ]]; then - printf '%s\n' "$ANDROID_HOME/build-tools/$ANDROID_BUILD_TOOLS" - return - fi - fi - find "$ANDROID_HOME/build-tools" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort -V | tail -1 -}} - -ANDROID_TARGET_API_LEVEL="${{ANDROID_TARGET_API_LEVEL:-$(detect_latest_android_api)}}" -if [[ -z "$ANDROID_TARGET_API_LEVEL" ]]; then - printf 'No Android platform found under %s/platforms. Install one with sdkmanager "platforms;android-35" or newer.\n' "$ANDROID_HOME" >&2 - exit 1 -fi - -ANDROID_NDK=$(find_android_ndk) -ANDROID_TOOLCHAIN="${{ANDROID_TOOLCHAIN:-$(detect_android_toolchain)}}" -CC_aarch64_linux_android="${{CC_aarch64_linux_android:-$ANDROID_TOOLCHAIN/aarch64-linux-android${{ANDROID_MIN_API_LEVEL}}-clang}}" -AR_aarch64_linux_android="${{AR_aarch64_linux_android:-$ANDROID_TOOLCHAIN/llvm-ar}}" -CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="${{CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER:-$CC_aarch64_linux_android}}" -CARGO_TARGET_AARCH64_LINUX_ANDROID_AR="${{CARGO_TARGET_AARCH64_LINUX_ANDROID_AR:-$AR_aarch64_linux_android}}" -export ANDROID_HOME ANDROID_NDK ANDROID_MIN_API_LEVEL ANDROID_TARGET_API_LEVEL ANDROID_TOOLCHAIN CC_aarch64_linux_android AR_aarch64_linux_android -export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER CARGO_TARGET_AARCH64_LINUX_ANDROID_AR - -BUILD_TOOLS=$(detect_build_tools_dir) -if [[ -z "$BUILD_TOOLS" || ! -d "$BUILD_TOOLS" ]]; then - printf 'Android build-tools not found. Install them with sdkmanager "build-tools;35.0.0" or set ANDROID_BUILD_TOOLS.\n' >&2 - exit 1 -fi -ANDROID_JAR="$ANDROID_HOME/platforms/android-$ANDROID_TARGET_API_LEVEL/android.jar" -if [[ ! -f "$ANDROID_JAR" ]]; then - printf 'Android platform android-%s not found. Install it with sdkmanager "platforms;android-%s" or set ANDROID_TARGET_API_LEVEL.\n' "$ANDROID_TARGET_API_LEVEL" "$ANDROID_TARGET_API_LEVEL" >&2 - exit 1 -fi -AAPT="$BUILD_TOOLS/aapt" -ZIPALIGN="$BUILD_TOOLS/zipalign" -APKSIGNER="$BUILD_TOOLS/apksigner" -for tool in "$AAPT" "$ZIPALIGN" "$APKSIGNER"; do - if [[ ! -x "$tool" ]]; then - printf 'Required Android build tool is missing or not executable: %s\n' "$tool" >&2 - exit 1 - fi -done - -BUILD_ARGS=(build --manifest-path "$PROJECT_DIR/Cargo.toml" --lib --target "$TARGET" --package "$PACKAGE_NAME") -ARTIFACT_DIR=debug -if [[ "$PROFILE" == "release" ]]; then - BUILD_ARGS+=(--release) - ARTIFACT_DIR=release -fi - -cargo "${{BUILD_ARGS[@]}}" -TARGET_DIR=$(python3 - <<'PY' "$PROJECT_DIR/Cargo.toml" -import json -import subprocess -import sys - -manifest = sys.argv[1] -metadata = json.loads( - subprocess.check_output( - ["cargo", "metadata", "--manifest-path", manifest, "--format-version", "1", "--no-deps"] - ) -) -print(metadata["target_directory"]) -PY -) - -SO_PATH="$TARGET_DIR/$TARGET/$ARTIFACT_DIR/lib$LIB_NAME.so" -BUILD_DIR="$SCRIPT_DIR/build/$PROFILE" -APK_ROOT="$BUILD_DIR/apk-root" -UNALIGNED_APK="$BUILD_DIR/$PACKAGE_NAME-unaligned.apk" -ALIGNED_APK="$BUILD_DIR/$PACKAGE_NAME-aligned.apk" -SIGNED_APK="$BUILD_DIR/$PACKAGE_NAME.apk" -KEYSTORE="${{ANDROID_DEBUG_KEYSTORE:-$HOME/.android/debug.keystore}}" - -rm -rf "$APK_ROOT" -mkdir -p "$APK_ROOT/lib/arm64-v8a" "$APK_ROOT/res/drawable-nodpi" "$BUILD_DIR" -cp "$SO_PATH" "$APK_ROOT/lib/arm64-v8a/lib$LIB_NAME.so" -cp "$PROJECT_DIR/assets/app-icon.png" "$APK_ROOT/res/drawable-nodpi/app_icon.png" - -BUILD_MANIFEST="$BUILD_DIR/AndroidManifest.xml" -python3 - <<'PY' "$SCRIPT_DIR/AndroidManifest.xml" "$BUILD_MANIFEST" "$ANDROID_MIN_API_LEVEL" "$ANDROID_TARGET_API_LEVEL" -import re -import sys - -source, dest, min_api, target_api = sys.argv[1:] -manifest = open(source, encoding="utf-8").read() -manifest = re.sub(r'android:minSdkVersion="\d+"', f'android:minSdkVersion="{{min_api}}"', manifest) -manifest = re.sub(r'android:targetSdkVersion="\d+"', f'android:targetSdkVersion="{{target_api}}"', manifest) -open(dest, "w", encoding="utf-8").write(manifest) -PY - -"$AAPT" package -f -F "$UNALIGNED_APK" -M "$BUILD_MANIFEST" -S "$APK_ROOT/res" -I "$ANDROID_JAR" -(cd "$APK_ROOT" && zip -qr "$UNALIGNED_APK" lib) -"$ZIPALIGN" -f 4 "$UNALIGNED_APK" "$ALIGNED_APK" - -if [[ ! -f "$KEYSTORE" ]]; then - mkdir -p "$(dirname "$KEYSTORE")" - keytool -genkeypair -v \ - -keystore "$KEYSTORE" \ - -storepass android \ - -alias androiddebugkey \ - -keypass android \ - -dname "CN=Android Debug,O=Android,C=US" \ - -keyalg RSA \ - -keysize 2048 \ - -validity 10000 >/dev/null 2>&1 -fi - -"$APKSIGNER" sign \ - --ks "$KEYSTORE" \ - --ks-pass pass:android \ - --key-pass pass:android \ - --out "$SIGNED_APK" \ - "$ALIGNED_APK" - -printf '%s\n' "$SIGNED_APK" -"#, - package_name = project.app.name, - lib_name = lib_name, - ) -} - -fn render_android_run_script(project: &FissionProject) -> String { - format!( - r#"#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd) -ANDROID_HOME="${{ANDROID_HOME:-${{ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}}}" -ADB="$ANDROID_HOME/platform-tools/adb" -EMULATOR_BIN="$ANDROID_HOME/emulator/emulator" -AVDMANAGER="${{ANDROID_AVDMANAGER:-$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager}}" - -detect_latest_emulator_api() {{ - find "$ANDROID_HOME/system-images" -path '*/google_apis/arm64-v8a' -type d 2>/dev/null \ - | sed -n 's#.*system-images/android-\([0-9][0-9]*\)/google_apis/arm64-v8a#\1#p' \ - | sort -n \ - | tail -1 -}} - -android_system_image_path() {{ - local image="$1" - image="${{image#system-images;}}" - printf '%s/system-images/%s\n' "$ANDROID_HOME" "${{image//;/\/}}" -}} - -ANDROID_EMULATOR_API_LEVEL="${{ANDROID_EMULATOR_API_LEVEL:-$(detect_latest_emulator_api)}}" -if [[ -z "$ANDROID_EMULATOR_API_LEVEL" ]]; then - printf 'No Android arm64 google_apis emulator image found under %s/system-images.\nInstall one with sdkmanager "system-images;android-35;google_apis;arm64-v8a" or set ANDROID_SYSTEM_IMAGE.\n' "$ANDROID_HOME" >&2 - exit 1 -fi -AVD_NAME="${{ANDROID_AVD_NAME:-FissionApi${{ANDROID_EMULATOR_API_LEVEL}}Arm64}}" -SYSTEM_IMAGE="${{ANDROID_SYSTEM_IMAGE:-system-images;android-${{ANDROID_EMULATOR_API_LEVEL}};google_apis;arm64-v8a}}" -DEVICE_PORT="${{ANDROID_TEST_CONTROL_DEVICE_PORT:-48761}}" -HOST_PORT="${{FISSION_TEST_CONTROL_PORT:-48761}}" -HEADLESS="${{ANDROID_EMULATOR_HEADLESS:-0}}" -RESTART_EMULATOR="${{ANDROID_EMULATOR_RESTART:-0}}" - -for tool in "$ADB" "$EMULATOR_BIN" "$AVDMANAGER"; do - if [[ ! -x "$tool" ]]; then - printf 'Required Android tool is missing or not executable: %s\nRun `cargo fission doctor android --project-dir .` for setup help.\n' "$tool" >&2 - exit 1 - fi -done - -if ! "$AVDMANAGER" list avd | grep -q "Name: $AVD_NAME"; then - if [[ ! -d "$(android_system_image_path "$SYSTEM_IMAGE")" ]]; then - printf 'Android system image is not installed: %s\nInstall it with sdkmanager "%s" or set ANDROID_SYSTEM_IMAGE.\n' "$SYSTEM_IMAGE" "$SYSTEM_IMAGE" >&2 - exit 1 - fi - echo "no" | "$AVDMANAGER" create avd -n "$AVD_NAME" -k "$SYSTEM_IMAGE" --abi "google_apis/arm64-v8a" --device "pixel_5" -fi - -RUNNING_EMULATOR=$("$ADB" devices | awk '/^emulator-.*device$/ {{ print $1; exit }}') -if [[ -n "$RUNNING_EMULATOR" && "$RESTART_EMULATOR" == "1" ]]; then - "$ADB" -s "$RUNNING_EMULATOR" emu kill >/dev/null || true - until ! "$ADB" devices | grep -q '^emulator-'; do - sleep 1 - done - RUNNING_EMULATOR="" -fi - -if [[ -z "$RUNNING_EMULATOR" ]]; then - EMULATOR_ARGS=(-avd "$AVD_NAME" -gpu "${{ANDROID_EMULATOR_GPU:-swiftshader_indirect}}" -no-audio) - if [[ "$HEADLESS" == "1" ]]; then - EMULATOR_ARGS+=(-no-window) - fi - printf 'Launching emulator %s (%s)\n' "$AVD_NAME" "$([[ "$HEADLESS" == "1" ]] && echo headless || echo visible)" - "$EMULATOR_BIN" "${{EMULATOR_ARGS[@]}}" >/tmp/fission-android-emulator.log 2>&1 & - "$ADB" wait-for-device - until "$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' | grep -q '^1$'; do - sleep 1 - done -else - printf 'Using existing emulator %s\n' "$RUNNING_EMULATOR" - if [[ "$HEADLESS" != "1" ]]; then - printf 'If the window is not visible, restart with ANDROID_EMULATOR_RESTART=1 to relaunch a visible emulator.\n' - fi -fi - -APK=$("$SCRIPT_DIR/package-apk.sh") -"$ADB" install -r "$APK" -"$ADB" forward "tcp:$HOST_PORT" "tcp:$DEVICE_PORT" -"$ADB" shell am start -n {app_id}/android.app.NativeActivity >/dev/null -printf 'APK=%s\n' "$APK" -"#, - app_id = project.app.app_id, - ) -} - -fn render_android_test_script() -> String { - r#"#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd) -export FISSION_TEST_CONTROL_PORT="${FISSION_TEST_CONTROL_PORT:-48761}" - -"$SCRIPT_DIR/run-emulator.sh" - -python3 - <<'PY' "$FISSION_TEST_CONTROL_PORT" -import sys -import time -import urllib.request - -port = sys.argv[1] -url = f"http://127.0.0.1:{port}/health" -deadline = time.time() + 90 -last_error = None -while time.time() < deadline: - try: - with urllib.request.urlopen(url, timeout=1) as response: - body = response.read().decode("utf-8", "replace") - if response.status == 200 and '"status":"ok"' in body: - print(f"Android emulator test control is healthy on {url}") - raise SystemExit(0) - except Exception as error: - last_error = error - time.sleep(1) -raise SystemExit(f"Android emulator test control did not become healthy on {url}: {last_error}") -PY -"# - .to_string() -} - -fn render_web_index(project: &FissionProject) -> String { - let title = ios_bundle_name(project); - format!( - r#" - - - - - {title} - - - - -
- - - -"#, - title = title, - ) -} - -fn render_web_bootstrap(project: &FissionProject) -> String { - let module_name = project.app.name.replace('-', "_"); - format!( - "import init from \"./pkg/{}.js\";\n\nawait init();\n", - module_name - ) -} - -fn render_web_build_script() -> String { - r#"#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd) -PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd) -PROFILE="${FISSION_WEB_PROFILE:-dev}" -BUILD_ARGS=(build "$PROJECT_DIR" --target web --out-dir "$SCRIPT_DIR/pkg") - -if [[ "$PROFILE" == "release" ]]; then - BUILD_ARGS+=(--release) -else - BUILD_ARGS+=(--dev) -fi - -wasm-pack "${BUILD_ARGS[@]}" -"# - .to_string() -} - -fn render_web_run_script(_project: &FissionProject) -> String { - format!( - r#"#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd) -PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd) -HOST="${{FISSION_WEB_HOST:-127.0.0.1}}" -PORT="${{FISSION_WEB_PORT:-8123}}" -URL="http://${{HOST}}:${{PORT}}/platforms/web/" - -"$SCRIPT_DIR/build-wasm.sh" - -printf 'Serving %s\n' "$URL" -printf 'Press Ctrl+C to stop the local server.\n' -if [[ "${{FISSION_WEB_OPEN:-0}}" == "1" ]]; then - if command -v open >/dev/null 2>&1; then - open "$URL" - elif command -v xdg-open >/dev/null 2>&1; then - xdg-open "$URL" - elif command -v cmd.exe >/dev/null 2>&1; then - cmd.exe /C start "$URL" - else - printf 'No browser opener found. Open %s manually.\n' "$URL" - fi -fi - -cd "$PROJECT_DIR" -python3 -m http.server "$PORT" --bind "$HOST" -"# - ) -} - -fn render_web_test_script(_project: &FissionProject) -> String { - r#"#!/usr/bin/env bash -set -euo pipefail - -SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd) -PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd) -HOST="${FISSION_WEB_HOST:-127.0.0.1}" -PORT="${FISSION_WEB_PORT:-8123}" -CDP_PORT="${FISSION_WEB_CDP_PORT:-9222}" -URL="http://${HOST}:${PORT}/platforms/web/" -PROFILE_DIR="$SCRIPT_DIR/build/chrome-profile" - -require_node_websocket() { - if ! command -v node >/dev/null 2>&1; then - printf 'Node.js was not found. Install Node 22+ so the generated browser smoke test can inspect Chrome CDP console/runtime errors.\n' >&2 - exit 1 - fi - if ! node -e 'process.exit(typeof WebSocket === "function" ? 0 : 1)' >/dev/null 2>&1; then - printf 'Node.js is available but does not expose the built-in WebSocket client. Install Node 22+ for Chrome CDP smoke tests.\n' >&2 - exit 1 - fi -} - -detect_chrome() { - if [[ -n "${FISSION_CHROME:-}" && -x "$FISSION_CHROME" ]]; then - printf '%s\n' "$FISSION_CHROME" - return - fi - local candidate - for candidate in \ - "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ - "/Applications/Chromium.app/Contents/MacOS/Chromium" \ - "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"; do - if [[ -x "$candidate" ]]; then - printf '%s\n' "$candidate" - return - fi - done - for candidate in google-chrome chromium chromium-browser chrome; do - if command -v "$candidate" >/dev/null 2>&1; then - command -v "$candidate" - return - fi - done - return 1 -} - -require_node_websocket -"$SCRIPT_DIR/build-wasm.sh" - -mkdir -p "$SCRIPT_DIR/build" -cd "$PROJECT_DIR" -python3 -m http.server "$PORT" --bind "$HOST" >"$SCRIPT_DIR/build/web-server.log" 2>&1 & -SERVER_PID=$! - -cleanup() { - if [[ -n "${CHROME_PID:-}" ]]; then - kill "$CHROME_PID" >/dev/null 2>&1 || true - fi - kill "$SERVER_PID" >/dev/null 2>&1 || true -} -trap cleanup EXIT - -printf 'Running transient web smoke test at %s\n' "$URL" -printf 'The local server is stopped automatically when this script exits.\n' - -python3 - <<'PY' "$URL" -import sys -import time -import urllib.request - -url = sys.argv[1] -deadline = time.time() + 30 -last_error = None -while time.time() < deadline: - try: - with urllib.request.urlopen(url, timeout=1) as response: - if response.status == 200: - raise SystemExit(0) - except Exception as error: - last_error = error - time.sleep(0.5) -raise SystemExit(f"web server did not serve {url}: {last_error}") -PY - -CHROME=$(detect_chrome) || { - printf 'Chrome/Chromium was not found. Set FISSION_CHROME=/path/to/chrome or run `cargo fission doctor web --project-dir .`.\n' >&2 - exit 1 -} - -rm -rf "$PROFILE_DIR" -"$CHROME" \ - --headless=new \ - --no-first-run \ - --no-default-browser-check \ - --remote-debugging-port="$CDP_PORT" \ - --user-data-dir="$PROFILE_DIR" \ - "$URL" >"$SCRIPT_DIR/build/chrome.log" 2>&1 & -CHROME_PID=$! - -CDP_PORT="$CDP_PORT" FISSION_WEB_URL="$URL" node <<'NODE' -const cdpPort = process.env.CDP_PORT; -const expectedUrl = process.env.FISSION_WEB_URL; -const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - -async function waitForTarget() { - const deadline = Date.now() + 60_000; - let lastError = null; - while (Date.now() < deadline) { - try { - const response = await fetch(`http://127.0.0.1:${cdpPort}/json/list`); - const targets = await response.json(); - const target = targets.find((entry) => entry.type === 'page' && entry.url.startsWith(expectedUrl)); - if (target?.webSocketDebuggerUrl) { - return target.webSocketDebuggerUrl; - } - } catch (error) { - lastError = error; - } - await sleep(250); - } - throw new Error(`Chrome CDP target did not become ready for ${expectedUrl}: ${lastError?.message ?? lastError}`); -} - -class CdpClient { - constructor(url) { - this.url = url; - this.ws = null; - this.nextId = 1; - this.pending = new Map(); - this.errors = []; - } - - async open() { - await new Promise((resolve, reject) => { - const ws = new WebSocket(this.url); - this.ws = ws; - ws.addEventListener('open', resolve, { once: true }); - ws.addEventListener('error', (event) => reject(new Error(`CDP websocket error: ${event.message ?? 'unknown error'}`)), { once: true }); - ws.addEventListener('message', (event) => this.onMessage(event.data)); - ws.addEventListener('close', () => { - for (const { reject: rejectPending } of this.pending.values()) { - rejectPending(new Error('CDP websocket closed')); - } - this.pending.clear(); - }); - }); - } - - send(method, params = {}) { - const id = this.nextId++; - const message = { id, method, params }; - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - this.pending.delete(id); - reject(new Error(`CDP command timed out: ${method}`)); - }, 10_000); - this.pending.set(id, { resolve, reject, timeout, method }); - this.ws.send(JSON.stringify(message)); - }); - } - - onMessage(raw) { - const message = JSON.parse(raw); - if (message.id) { - const pending = this.pending.get(message.id); - if (!pending) return; - clearTimeout(pending.timeout); - this.pending.delete(message.id); - if (message.error) { - pending.reject(new Error(`${pending.method}: ${message.error.message}`)); - } else { - pending.resolve(message.result ?? {}); - } - return; - } - - if (message.method === 'Runtime.exceptionThrown') { - this.errors.push(formatException(message.params?.exceptionDetails)); - } else if (message.method === 'Runtime.consoleAPICalled') { - const type = message.params?.type; - if (type === 'error' || type === 'assert') { - this.errors.push(`console.${type}: ${(message.params?.args ?? []).map(formatRemoteObject).join(' ')}`); - } - } else if (message.method === 'Log.entryAdded') { - const entry = message.params?.entry; - if (entry?.level === 'error') { - this.errors.push(`browser log error: ${entry.text}${entry.url ? ` (${entry.url}:${entry.lineNumber ?? 0})` : ''}`); - } - } - } - - close() { - this.ws?.close(); - } -} - -function formatRemoteObject(value) { - if (!value) return ''; - if (Object.prototype.hasOwnProperty.call(value, 'value')) return JSON.stringify(value.value); - return value.description ?? value.unserializableValue ?? value.type ?? ''; -} - -function formatException(details) { - if (!details) return 'runtime exception: '; - const exception = details.exception?.description ?? details.exception?.value ?? details.text ?? 'unknown exception'; - const location = details.url ? ` at ${details.url}:${details.lineNumber ?? 0}:${details.columnNumber ?? 0}` : ''; - return `runtime exception: ${exception}${location}`; -} - -function errorBlock(errors) { - return errors.slice(0, 10).map((error, index) => `${index + 1}. ${error}`).join('\n'); -} - -async function readCanvas(client) { - const expression = `(() => { - const canvas = document.querySelector('canvas'); - if (!canvas) return { ready: false, reason: 'no canvas element' }; - const rect = canvas.getBoundingClientRect(); - return { - ready: rect.width > 0 && rect.height > 0, - width: Math.round(rect.width), - height: Math.round(rect.height), - gpu: typeof navigator.gpu !== 'undefined', - title: document.title, - }; - })()`; - const result = await client.send('Runtime.evaluate', { expression, returnByValue: true }); - if (result.exceptionDetails) { - throw new Error(formatException(result.exceptionDetails)); - } - return result.result?.value ?? { ready: false, reason: 'evaluation returned no value' }; -} - -async function main() { - const wsUrl = await waitForTarget(); - const client = new CdpClient(wsUrl); - await client.open(); - try { - await Promise.all([ - client.send('Runtime.enable'), - client.send('Log.enable'), - client.send('Page.enable'), - ]); - - const deadline = Date.now() + 60_000; - let readySince = null; - let lastCanvas = null; - while (Date.now() < deadline) { - if (client.errors.length > 0) { - throw new Error(`browser reported runtime/console errors:\n${errorBlock(client.errors)}`); - } - lastCanvas = await readCanvas(client); - if (lastCanvas.ready) { - readySince ??= Date.now(); - if (Date.now() - readySince >= 1_500) { - console.log(`Web app rendered canvas ${lastCanvas.width}x${lastCanvas.height}; no runtime console errors observed.`); - return; - } - } else { - readySince = null; - } - await sleep(250); - } - throw new Error(`web app did not render a non-empty canvas. Last canvas state: ${JSON.stringify(lastCanvas)}`); - } finally { - client.close(); - } -} - -main().catch((error) => { - console.error(error.stack ?? error.message ?? String(error)); - process.exit(1); -}); -NODE -"# - .to_string() -} -fn render_app_main(package_name: &str) -> String { - let lib_name = package_name.replace('-', "_"); - format!( - r#"#[cfg(target_os = "android")] -fn main() {{}} - -#[cfg(target_arch = "wasm32")] -fn main() {{}} - -#[cfg(target_os = "ios")] -fn main() -> anyhow::Result<()> {{ - {lib_name}::run_mobile() -}} - -#[cfg(not(any(target_arch = "wasm32", target_os = "ios", target_os = "android")))] -fn main() -> anyhow::Result<()> {{ - {lib_name}::run_desktop() -}} -"# - ) -} - -const APP_LIB: &str = r#"pub mod app; - -use crate::app::CounterApp; -use fission::prelude::*; - -#[cfg(target_os = "android")] -const ANDROID_TEST_CONTROL_PORT: u16 = 48761; - -#[cfg(any(target_os = "android", target_os = "ios"))] -fn mobile_app() -> MobileApp { - let app = MobileApp::new(CounterApp).with_title("Fission App"); - #[cfg(target_os = "android")] - let app = app.with_test_control_port(ANDROID_TEST_CONTROL_PORT); - app -} - -#[cfg(target_arch = "wasm32")] -fn web_app() -> WebApp { - WebApp::new(CounterApp).with_title("Fission App") -} - -#[cfg(not(any(target_arch = "wasm32", target_os = "android", target_os = "ios")))] -pub fn run_desktop() -> anyhow::Result<()> { - DesktopApp::new(CounterApp).run() -} - -#[cfg(any(target_os = "android", target_os = "ios"))] -pub fn run_mobile() -> anyhow::Result<()> { - mobile_app().run() -} - -#[cfg(target_os = "android")] -#[no_mangle] -fn android_main(app_handle: AndroidApp) { - let _ = mobile_app().run_with_android_app(app_handle); -} - -#[cfg(target_arch = "wasm32")] -#[wasm_bindgen::prelude::wasm_bindgen(start)] -pub fn run_web() -> Result<(), wasm_bindgen::JsValue> { - console_error_panic_hook::set_once(); - web_app() - .run() - .map_err(|error| wasm_bindgen::JsValue::from_str(&error.to_string())) -} -"#; - -const APP_RS: &str = r#"use fission::prelude::*; - -#[derive(Default, Debug, Clone, PartialEq)] -pub struct CounterState { - pub count: i32, -} - -impl AppState for CounterState {} - -#[fission_reducer(Increment)] -fn on_increment(state: &mut CounterState) { - state.count += 1; -} - -pub struct CounterApp; - -impl Widget for CounterApp { - fn build(&self, ctx: &mut BuildCtx, view: &View) -> Node { - let increment = with_reducer!(ctx, Increment, on_increment); - - Column { - gap: Some(16.0), - children: vec![ - Text::new(format!("Count: {}", view.state.count)).size(28.0).into_node(), - Button { - on_press: Some(increment), - child: Some(Box::new(Text::new("Increment").into_node())), - ..Default::default() - } - .into_node(), - ], - ..Default::default() - } - .into_node() - } -} -"#; - #[cfg(test)] mod tests { use super::*; + use std::{fs, path::PathBuf}; fn unique_dir(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!("fission-cli-{}-{}", name, std::process::id())); diff --git a/crates/tools/fission-cli/src/project.rs b/crates/tools/fission-cli/src/project.rs new file mode 100644 index 00000000..162cb589 --- /dev/null +++ b/crates/tools/fission-cli/src/project.rs @@ -0,0 +1,1797 @@ +use anyhow::{bail, Context, Result}; +use clap::ValueEnum; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; +use std::fs; +use std::path::{Path, PathBuf}; + +const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION"); +const DEFAULT_APP_ICON_PNG: &[u8] = include_bytes!("../../../../docs/fission_logo.png"); + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, ValueEnum, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub(crate) enum Target { + Android, + Ios, + Linux, + Macos, + Site, + Web, + Windows, +} + +impl Target { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Android => "android", + Self::Ios => "ios", + Self::Linux => "linux", + Self::Macos => "macos", + Self::Site => "site", + Self::Web => "web", + Self::Windows => "windows", + } + } + + pub(crate) fn scaffold_relative_path(self) -> &'static str { + match self { + Self::Android => "platforms/android/README.md", + Self::Ios => "platforms/ios/README.md", + Self::Linux => "platforms/linux/README.md", + Self::Macos => "platforms/macos/README.md", + Self::Site => "platforms/site/README.md", + Self::Web => "platforms/web/README.md", + Self::Windows => "platforms/windows/README.md", + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct FissionProject { + pub(crate) app: AppConfig, + pub(crate) targets: BTreeSet, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct AppConfig { + pub(crate) name: String, + pub(crate) app_id: String, +} + +#[derive(Debug, Deserialize)] +struct CargoManifest { + package: Option, +} + +#[derive(Debug, Deserialize)] +struct CargoPackage { + pub(crate) name: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WritePolicy { + Overwrite, + PreserveExisting, +} + +pub(crate) fn init_project( + root: &Path, + name: Option, + app_id: Option, + local_path: Option, +) -> Result<()> { + let existing_project = root.exists() && root.read_dir()?.next().is_some(); + fs::create_dir_all(root.join("src"))?; + + let write_policy = if existing_project { + WritePolicy::PreserveExisting + } else { + WritePolicy::Overwrite + }; + let project = initial_project_config(root, name, app_id)?; + + write_file_with_policy( + &root.join("Cargo.toml"), + &render_cargo_toml(&project, local_path.as_deref()), + write_policy, + )?; + write_file_with_policy( + &root.join("src/main.rs"), + &render_app_main(project.app.name.as_str()), + write_policy, + )?; + write_file_with_policy(&root.join("src/lib.rs"), APP_LIB, write_policy)?; + write_file_with_policy(&root.join("src/app.rs"), APP_RS, write_policy)?; + write_binary_file_with_policy( + &root.join("assets/app-icon.png"), + DEFAULT_APP_ICON_PNG, + write_policy, + )?; + write_file_with_policy( + &root.join("README.md"), + &render_project_readme(&project), + write_policy, + )?; + write_file_with_policy( + &root.join(".gitignore"), + "target/\nplatforms/*/build/\n", + write_policy, + )?; + write_project_config(root, &project)?; + + let targets = project.targets.iter().copied().collect::>(); + for target in targets { + scaffold_target_with_policy(root, &project, target, write_policy)?; + } + + Ok(()) +} + +fn initial_project_config( + root: &Path, + name: Option, + app_id: Option, +) -> Result { + let existing = if root.join("fission.toml").exists() { + Some(read_project_config(root)?) + } else { + None + }; + let cargo_name = cargo_package_name(root); + if let (Some(requested), Some(cargo_name)) = (&name, &cargo_name) { + let requested = normalize_crate_name(requested); + let cargo_name = normalize_crate_name(cargo_name); + if requested != cargo_name { + bail!( + "refusing to set app name `{requested}` for existing Cargo package `{cargo_name}`; rename the package in Cargo.toml first or omit --name" + ); + } + } + let project_name = cargo_name + .or(name) + .or_else(|| existing.as_ref().map(|project| project.app.name.clone())) + .unwrap_or_else(|| { + root.file_name() + .and_then(|value| value.to_str()) + .unwrap_or("fission-app") + .to_string() + }); + let normalized_name = normalize_crate_name(&project_name); + + let mut targets = existing + .as_ref() + .map(|project| project.targets.clone()) + .unwrap_or_default(); + targets.extend(detect_project_targets(root)); + if targets.is_empty() { + targets.extend([Target::Windows, Target::Macos, Target::Linux]); + } + + Ok(FissionProject { + app: AppConfig { + name: normalized_name.clone(), + app_id: app_id + .or_else(|| existing.map(|project| project.app.app_id)) + .unwrap_or_else(|| format!("com.example.{}", normalized_name.replace('-', "_"))), + }, + targets, + }) +} + +pub(crate) fn cargo_package_name(root: &Path) -> Option { + let manifest = fs::read_to_string(root.join("Cargo.toml")).ok()?; + let manifest: CargoManifest = toml::from_str(&manifest).ok()?; + manifest.package.map(|package| package.name) +} + +fn detect_project_targets(root: &Path) -> BTreeSet { + let mut targets = BTreeSet::new(); + if root.join("src/main.rs").exists() || root.join("src/lib.rs").exists() { + targets.extend([Target::Windows, Target::Macos, Target::Linux]); + } + for (target, relative) in [ + (Target::Android, "platforms/android"), + (Target::Ios, "platforms/ios"), + (Target::Linux, "platforms/linux"), + (Target::Macos, "platforms/macos"), + (Target::Site, "content"), + (Target::Web, "platforms/web"), + (Target::Windows, "platforms/windows"), + ] { + if root.join(relative).exists() { + targets.insert(target); + } + } + targets +} + +pub(crate) fn add_targets(project_dir: &Path, targets: &[Target]) -> Result<()> { + if targets.is_empty() { + bail!("no targets provided"); + } + let mut project = read_project_config(project_dir)?; + for target in targets { + let target_exists = + project.targets.contains(target) || target_scaffold_dir_exists(project_dir, *target); + project.targets.insert(*target); + let write_policy = if target_exists { + WritePolicy::PreserveExisting + } else { + WritePolicy::Overwrite + }; + scaffold_target_with_policy(project_dir, &project, *target, write_policy)?; + } + write_project_config(project_dir, &project)?; + update_cargo_fission_features(project_dir, &project)?; + write_file_with_policy( + &project_dir.join("README.md"), + &render_project_readme(&project), + WritePolicy::PreserveExisting, + )?; + Ok(()) +} + +fn target_scaffold_dir_exists(project_dir: &Path, target: Target) -> bool { + Path::new(target.scaffold_relative_path()) + .parent() + .is_some_and(|relative| project_dir.join(relative).exists()) +} + +fn write_project_config(root: &Path, project: &FissionProject) -> Result<()> { + let data = toml::to_string_pretty(project)?; + write_file(&root.join("fission.toml"), &(data + "\n")) +} + +pub(crate) fn read_project_config(root: &Path) -> Result { + let path = root.join("fission.toml"); + let data = fs::read_to_string(&path).with_context(|| { + format!( + "failed to read {}; run `fission init {}` to register this project without overwriting existing files", + path.display(), + root.display() + ) + })?; + toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display())) +} + +fn update_cargo_fission_features(root: &Path, project: &FissionProject) -> Result<()> { + let path = root.join("Cargo.toml"); + let Ok(text) = fs::read_to_string(&path) else { + return Ok(()); + }; + let feature_list = render_fission_feature_list(&project.targets); + let mut changed = false; + let mut out = Vec::new(); + for line in text.lines() { + if let Some(updated) = update_inline_fission_dependency(line, &feature_list) { + changed |= updated != line; + out.push(updated); + } else { + out.push(line.to_string()); + } + } + if changed { + fs::write(&path, out.join("\n") + "\n") + .with_context(|| format!("failed to update {}", path.display()))?; + } + Ok(()) +} + +fn update_inline_fission_dependency(line: &str, feature_list: &str) -> Option { + let trimmed = line.trim_start(); + if !trimmed.starts_with("fission =") { + return None; + } + let indent = &line[..line.len() - trimmed.len()]; + let value = trimmed.strip_prefix("fission =")?.trim(); + if value.starts_with('"') { + return Some(format!( + "{indent}fission = {{ version = {value}, default-features = false, features = [{feature_list}] }}" + )); + } + if !(value.starts_with('{') && value.ends_with('}')) { + return None; + } + let inner = value + .strip_prefix('{') + .and_then(|value| value.strip_suffix('}'))? + .trim(); + let mut fields = split_top_level_fields(inner) + .into_iter() + .filter(|field| { + let key = field + .split_once('=') + .map(|(key, _)| key.trim()) + .unwrap_or_default(); + key != "default-features" && key != "features" + }) + .collect::>(); + fields.push("default-features = false".to_string()); + fields.push(format!("features = [{feature_list}]")); + Some(format!("{indent}fission = {{ {} }}", fields.join(", "))) +} + +fn split_top_level_fields(input: &str) -> Vec { + let mut fields = Vec::new(); + let mut start = 0; + let mut bracket_depth = 0usize; + let mut in_string = false; + let mut escaped = false; + for (index, ch) in input.char_indices() { + if in_string { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_string = false; + } + continue; + } + match ch { + '"' => in_string = true, + '[' => bracket_depth += 1, + ']' => bracket_depth = bracket_depth.saturating_sub(1), + ',' if bracket_depth == 0 => { + let field = input[start..index].trim(); + if !field.is_empty() { + fields.push(field.to_string()); + } + start = index + ch.len_utf8(); + } + _ => {} + } + } + let field = input[start..].trim(); + if !field.is_empty() { + fields.push(field.to_string()); + } + fields +} + +fn scaffold_target_with_policy( + root: &Path, + project: &FissionProject, + target: Target, + write_policy: WritePolicy, +) -> Result<()> { + let relative = Path::new(target.scaffold_relative_path()); + let text = match target { + Target::Android => { + scaffold_android_bundle(root, project, write_policy)?; + platform_readme( + "Android", + "Runnable emulator target. The CLI generates a NativeActivity manifest plus shell scripts that build, install, and launch the Fission app on an Android emulator.", + &[ + "Install the Rust target: `rustup target add aarch64-linux-android`.", + "Run `cargo fission doctor android --project-dir .` to check SDK, NDK, emulator, and Rust target setup.", + "Run `cargo fission devices --project-dir .` to list connected Android devices and configured emulators.", + "Run `cargo fission run --target android --project-dir .` to build, install, launch, and attach to logs.", + "Run `cargo fission run --target android --device --project-dir .` to launch on a specific device.", + "Run `cargo fission test --target android --project-dir .` for an emulator launch plus test-control health check.", + "Run `./platforms/android/run-emulator.sh` from the project root to build, package, install, and launch the app on the configured emulator.", + "Override `ANDROID_HOME`, `ANDROID_NDK`, `ANDROID_MIN_API_LEVEL`, `ANDROID_TARGET_API_LEVEL`, `ANDROID_AVD_NAME`, or `ANDROID_SYSTEM_IMAGE` if your local SDK setup differs.", + "Set `ANDROID_EMULATOR_HEADLESS=1` for background/CI runs, or `ANDROID_EMULATOR_RESTART=1` to relaunch a hidden emulator visibly.", + "The generated package uses `assets/app-icon.png` as its default launcher icon.", + "Set `FISSION_TEST_CONTROL_PORT=` before `run-emulator.sh`; the script forwards it to the fixed in-app device port.", + ], + ) + } + Target::Ios => { + scaffold_ios_bundle(root, project, write_policy)?; + platform_readme( + "iOS", + "Simulator target. The CLI generates a simulator app bundle template plus shell scripts that build, install, launch, and smoke-test the Fission app with `simctl`.", + &[ + "Install the Rust targets: `rustup target add aarch64-apple-ios aarch64-apple-ios-sim`.", + "Run `cargo fission doctor ios --project-dir .` to check Xcode, simulator, and Rust target setup.", + "Confirm the simulator SDK path with `xcrun --sdk iphonesimulator --show-sdk-path`.", + "Run `cargo fission devices --project-dir .` to list available iOS simulators.", + "Run `cargo fission run --target ios --project-dir .` to build, install, launch, and attach to simulator logs.", + "Run `cargo fission run --target ios --device --project-dir .` to launch on a specific simulator.", + "Run `cargo fission test --target ios --project-dir .` for a simulator launch plus test-control health check.", + "Run `./platforms/ios/run-sim.sh` from the project root to build, install, and launch the app on the first available iPhone simulator.", + "The generated bundle uses `assets/app-icon.png` as its default app icon.", + "Set `FISSION_TEST_CONTROL_PORT=` before `run-sim.sh` to expose the in-app test control server on the host.", + "Set `IOS_SIM_DEVICE_ID=` if you want a specific simulator device.", + "Set `IOS_SIM_HEADLESS=1` for CI or background-only simulator runs; otherwise the script opens Simulator visibly.", + ], + ) + } + Target::Web => { + scaffold_web_bundle(root, project, write_policy)?; + platform_readme( + "Web", + "Runnable browser target. The CLI generates a WASM host page plus helper scripts that build the app with `wasm-pack` and serve it locally.", + &[ + "Install the Rust target: `rustup target add wasm32-unknown-unknown`.", + "Install `wasm-pack` once: `cargo install wasm-pack`.", + "Install Node.js 22+ so the smoke test can inspect Chrome/Chromium CDP runtime and console output.", + "Run `cargo fission doctor web --project-dir .` to check wasm-pack, Node.js, Chrome/Chromium, and Rust target setup.", + "Run `cargo fission devices --project-dir .` to confirm Chrome/Chromium detection.", + "Run `cargo fission run --target web --project-dir .` to build, serve, open, and attach to the local server.", + "Run `cargo fission run --target web --detach --project-dir .` to keep the local server running in the background.", + "Run `cargo fission test --target web --project-dir .` for a headless Chrome/Chromium CDP smoke test.", + "Run `./platforms/web/run-browser.sh` from the project root to build the wasm package and serve the app locally.", + "Set `FISSION_WEB_PORT=` or `FISSION_WEB_HOST=` if the default `127.0.0.1:8123` does not suit your machine.", + "Set `FISSION_WEB_OPEN=1` if you want the helper script to open a browser tab automatically.", + "The generated page uses `assets/app-icon.png` as its default favicon/app icon seed.", + ], + ) + } + Target::Site => { + write_file_with_policy( + &root.join("content/getting-started.md"), + "---\ntitle: Site content\ndescription: Static site content rendered by the Fission static site shell.\n---\n\n# Site content\n\nAdd Markdown files under `content/`. `cargo fission site build` renders them through real Fission widgets, lowers the nodes to Core IR, and emits static HTML.\n", + write_policy, + )?; + platform_readme( + "Static site", + "Static multi-page website target. The site shell renders Markdown content through real Fission widgets, lowers nodes to Core IR, and emits semantic static HTML.", + &[ + "Add Markdown or MDX content under `content/`.", + "Run `cargo fission site routes --project-dir .` to list generated routes.", + "Run `cargo fission site build --project-dir .` to render HTML into `target/fission/site`.", + "Run `cargo fission site serve --project-dir .` to build and serve the generated site locally.", + "Unsupported interactive widgets fail during the static render instead of silently falling back to JavaScript.", + ], + ) + } + Target::Linux | Target::Macos | Target::Windows => platform_readme( + match target { + Target::Linux => "Linux", + Target::Macos => "macOS", + Target::Windows => "Windows", + _ => unreachable!(), + }, + "Runnable target. Desktop platforms share the default `src/main.rs` entrypoint through `DesktopApp`.", + &[ + "Run `cargo fission run --project-dir .` from the project root to launch the desktop app and attach output.", + "Run `cargo fission build --project-dir . --release` for a release desktop build.", + "Run `cargo fission test --project-dir .` for the app crate's Rust tests.", + "This target uses the default Vello desktop shell path.", + ], + ), + }; + write_file_with_policy(&root.join(relative), &text, write_policy) +} + +fn scaffold_ios_bundle( + root: &Path, + project: &FissionProject, + write_policy: WritePolicy, +) -> Result<()> { + let executable = ios_executable_name(project); + let bundle_name = ios_bundle_name(project); + let plist = render_ios_plist(project, &executable); + let package_script = render_ios_package_script(project, &bundle_name, &executable); + let run_script = render_ios_run_script(project); + let test_script = render_ios_test_script(); + + write_file_with_policy(&root.join("platforms/ios/Info.plist"), &plist, write_policy)?; + write_file_with_policy( + &root.join("platforms/ios/package-sim.sh"), + &package_script, + write_policy, + )?; + write_file_with_policy( + &root.join("platforms/ios/run-sim.sh"), + &run_script, + write_policy, + )?; + write_file_with_policy( + &root.join("platforms/ios/test-sim.sh"), + &test_script, + write_policy, + )?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for relative in [ + "platforms/ios/package-sim.sh", + "platforms/ios/run-sim.sh", + "platforms/ios/test-sim.sh", + ] { + let path = root.join(relative); + if path.exists() { + fs::set_permissions(path, fs::Permissions::from_mode(0o755))?; + } + } + } + Ok(()) +} + +fn scaffold_android_bundle( + root: &Path, + project: &FissionProject, + write_policy: WritePolicy, +) -> Result<()> { + let manifest = render_android_manifest(project); + let package_script = render_android_package_script(project); + let run_script = render_android_run_script(project); + let test_script = render_android_test_script(); + + write_file_with_policy( + &root.join("platforms/android/AndroidManifest.xml"), + &manifest, + write_policy, + )?; + write_file_with_policy( + &root.join("platforms/android/package-apk.sh"), + &package_script, + write_policy, + )?; + write_file_with_policy( + &root.join("platforms/android/run-emulator.sh"), + &run_script, + write_policy, + )?; + write_file_with_policy( + &root.join("platforms/android/test-emulator.sh"), + &test_script, + write_policy, + )?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for relative in [ + "platforms/android/package-apk.sh", + "platforms/android/run-emulator.sh", + "platforms/android/test-emulator.sh", + ] { + let path = root.join(relative); + if path.exists() { + fs::set_permissions(path, fs::Permissions::from_mode(0o755))?; + } + } + } + Ok(()) +} + +fn scaffold_web_bundle( + root: &Path, + project: &FissionProject, + write_policy: WritePolicy, +) -> Result<()> { + let index_html = render_web_index(project); + let bootstrap = render_web_bootstrap(project); + let build_script = render_web_build_script(); + let run_script = render_web_run_script(project); + let test_script = render_web_test_script(project); + + write_file_with_policy( + &root.join("platforms/web/index.html"), + &index_html, + write_policy, + )?; + write_file_with_policy( + &root.join("platforms/web/bootstrap.mjs"), + &bootstrap, + write_policy, + )?; + write_file_with_policy( + &root.join("platforms/web/build-wasm.sh"), + &build_script, + write_policy, + )?; + write_file_with_policy( + &root.join("platforms/web/run-browser.sh"), + &run_script, + write_policy, + )?; + write_file_with_policy( + &root.join("platforms/web/test-browser.sh"), + &test_script, + write_policy, + )?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for relative in [ + "platforms/web/build-wasm.sh", + "platforms/web/run-browser.sh", + "platforms/web/test-browser.sh", + ] { + let path = root.join(relative); + if path.exists() { + let mut perms = fs::metadata(&path)?.permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms)?; + } + } + } + + Ok(()) +} + +fn write_file(path: &Path, contents: &str) -> Result<()> { + write_file_with_policy(path, contents, WritePolicy::Overwrite) +} + +fn write_file_with_policy(path: &Path, contents: &str, write_policy: WritePolicy) -> Result<()> { + if write_policy == WritePolicy::PreserveExisting && path.exists() { + return Ok(()); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, contents).with_context(|| format!("failed to write {}", path.display())) +} + +fn write_binary_file_with_policy( + path: &Path, + contents: &[u8], + write_policy: WritePolicy, +) -> Result<()> { + if write_policy == WritePolicy::PreserveExisting && path.exists() { + return Ok(()); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, contents).with_context(|| format!("failed to write {}", path.display())) +} + +fn render_cargo_toml(project: &FissionProject, local_path: Option<&Path>) -> String { + let feature_list = render_fission_feature_list(&project.targets); + let deps = if let Some(root) = local_path { + let fission_path = root.join("crates/authoring/fission"); + format!( + "fission = {{ path = {:?}, default-features = false, features = [{}] }}\n", + fission_path.to_string_lossy().to_string(), + feature_list + ) + } else { + format!( + "fission = {{ version = \"{}\", default-features = false, features = [{}] }}\n", + CURRENT_VERSION, feature_list + ) + }; + let lib_name = project.app.name.replace('-', "_"); + + format!( + "[package]\nname = \"{}\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[lib]\nname = \"{}\"\ncrate-type = [\"cdylib\", \"rlib\"]\n\n[dependencies]\nanyhow = \"1\"\nserde = {{ version = \"1\", features = [\"derive\"] }}\n{}\n[target.'cfg(target_arch = \"wasm32\")'.dependencies]\nconsole_error_panic_hook = \"0.1\"\nwasm-bindgen = \"0.2\"\n", + project.app.name, lib_name, deps + ) +} + +fn render_fission_feature_list(targets: &BTreeSet) -> String { + fission_features_for_targets(targets) + .into_iter() + .map(|feature| format!("\"{feature}\"")) + .collect::>() + .join(", ") +} + +fn fission_features_for_targets(targets: &BTreeSet) -> Vec<&'static str> { + let mut features = Vec::new(); + if targets + .iter() + .any(|target| matches!(target, Target::Linux | Target::Macos | Target::Windows)) + { + features.push("desktop"); + } + if targets.contains(&Target::Web) { + features.push("web"); + } + if targets.contains(&Target::Android) { + features.push("android"); + } + if targets.contains(&Target::Ios) { + features.push("ios"); + } + if targets.contains(&Target::Site) { + features.push("site"); + } + features +} + +fn render_project_readme(project: &FissionProject) -> String { + let mut targets = String::new(); + for target in &project.targets { + targets.push_str(&format!("- `{}`\n", target.as_str())); + } + format!( + "# {}\n\nGenerated by `fission init`.\n\n## Targets\n\n{}\n## Commands\n\n- `cargo fission doctor --project-dir .` -- check local SDKs, browsers, emulators, and Rust targets\n- `cargo fission devices --project-dir .` -- list runnable desktop, browser, simulator, emulator, and device targets\n- `cargo fission run --project-dir .` -- launch the desktop app and attach to output\n- `cargo fission run --target web --project-dir .` -- launch the web app and attach to the local server\n- `cargo fission run --target ios --project-dir .` -- build, install, launch, and attach to simulator logs\n- `cargo fission run --target android --project-dir .` -- build, install, launch, and attach to Android logs\n- `cargo fission run --target --device --detach --project-dir .` -- launch without attaching\n- `cargo fission logs --target --device --project-dir . --follow` -- attach later where supported\n- `cargo fission build --target --project-dir . --release` -- build a target without launching it\n- `cargo fission test --target --project-dir .` -- run the generated platform smoke test\n- `cargo fission add-target web ios android --project-dir .` -- scaffold more targets\n- `cat platforms//README.md` -- inspect target-specific prerequisites and environment variables\n\n## Assets\n\n- `assets/app-icon.png` is the default app icon seed copied from Fission's `docs/fission_logo.png`\n\n## Status\n\nDesktop, web, iOS simulator, and Android emulator workflows are runnable through `cargo fission run`. The platform scripts remain checked in so CI and advanced users can call the lower-level build, run, and smoke-test steps directly when needed.\n", + project.app.name, targets + ) +} + +fn platform_readme(title: &str, summary: &str, bullets: &[&str]) -> String { + let mut out = format!("# {} target\n\n{}\n", title, summary); + for bullet in bullets { + out.push_str(&format!("\n- {}", bullet)); + } + out.push('\n'); + out +} + +fn normalize_crate_name(name: &str) -> String { + name.chars() + .map(|ch| match ch { + 'A'..='Z' => ch.to_ascii_lowercase(), + 'a'..='z' | '0'..='9' => ch, + _ => '-', + }) + .collect::() + .trim_matches('-') + .to_string() +} + +pub(crate) fn ios_executable_name(project: &FissionProject) -> String { + project.app.name.replace('-', "_") +} + +fn ios_bundle_name(project: &FissionProject) -> String { + let mut out = String::new(); + let mut uppercase_next = true; + for ch in project.app.name.chars() { + match ch { + '-' | '_' | ' ' => uppercase_next = true, + _ if uppercase_next => { + out.extend(ch.to_uppercase()); + uppercase_next = false; + } + _ => out.push(ch), + } + } + if out.is_empty() { + "FissionApp".to_string() + } else { + out + } +} + +fn android_library_name(project: &FissionProject) -> String { + project.app.name.replace('-', "_") +} + +fn render_ios_plist(project: &FissionProject, executable: &str) -> String { + format!( + r#" + + + + CFBundleDevelopmentRegion + en + CFBundleDisplayName + {display_name} + CFBundleExecutable + {executable} + CFBundleIdentifier + {bundle_id} + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + {display_name} + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 1 + CFBundleIconFile + AppIcon + LSRequiresIPhoneOS + + MinimumOSVersion + 18.0 + UIDeviceFamily + + 1 + 2 + + + +"#, + display_name = ios_bundle_name(project), + executable = executable, + bundle_id = project.app.app_id, + ) +} + +fn render_ios_package_script( + project: &FissionProject, + bundle_name: &str, + executable: &str, +) -> String { + format!( + r#"#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd) +PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd) +TARGET="${{IOS_SIM_TARGET:-aarch64-apple-ios-sim}}" +PROFILE="${{IOS_SIM_PROFILE:-debug}}" +PACKAGE_NAME="{package_name}" +BUNDLE_ID="${{IOS_BUNDLE_ID:-{bundle_id}}}" +DISPLAY_NAME="${{IOS_DISPLAY_NAME:-{bundle_name}}}" +EXECUTABLE_NAME="${{IOS_EXECUTABLE_NAME:-{executable}}}" +BUNDLE_NAME="${{IOS_BUNDLE_NAME:-$DISPLAY_NAME.app}}" +BUILD_DIR="$SCRIPT_DIR/build/$PROFILE" +BUNDLE_DIR="$BUILD_DIR/$BUNDLE_NAME" + +BUILD_ARGS=(build --manifest-path "$PROJECT_DIR/Cargo.toml" --target "$TARGET" --package "$PACKAGE_NAME") +ARTIFACT_DIR=debug +if [[ "$PROFILE" == "release" ]]; then + BUILD_ARGS+=(--release) + ARTIFACT_DIR=release +fi + +cargo "${{BUILD_ARGS[@]}}" +TARGET_DIR=$(python3 - <<'PY' "$PROJECT_DIR/Cargo.toml" +import json +import subprocess +import sys + +manifest = sys.argv[1] +metadata = json.loads( + subprocess.check_output( + ["cargo", "metadata", "--manifest-path", manifest, "--format-version", "1", "--no-deps"] + ) +) +print(metadata["target_directory"]) +PY +) + +rm -rf "$BUNDLE_DIR" +mkdir -p "$BUNDLE_DIR" +cp "$TARGET_DIR/$TARGET/$ARTIFACT_DIR/$PACKAGE_NAME" "$BUNDLE_DIR/$EXECUTABLE_NAME" +chmod +x "$BUNDLE_DIR/$EXECUTABLE_NAME" +python3 - <<'PY' "$SCRIPT_DIR/Info.plist" "$BUNDLE_DIR/Info.plist" "$BUNDLE_ID" "$DISPLAY_NAME" "$EXECUTABLE_NAME" +import plistlib +import sys + +source, dest, bundle_id, display_name, executable_name = sys.argv[1:] +with open(source, "rb") as handle: + plist = plistlib.load(handle) +plist["CFBundleIdentifier"] = bundle_id +plist["CFBundleDisplayName"] = display_name +plist["CFBundleName"] = display_name +plist["CFBundleExecutable"] = executable_name +with open(dest, "wb") as handle: + plistlib.dump(plist, handle, sort_keys=False) +PY +cp "$PROJECT_DIR/assets/app-icon.png" "$BUNDLE_DIR/AppIcon.png" +printf 'APPL????' > "$BUNDLE_DIR/PkgInfo" +printf '%s\n' "$BUNDLE_DIR" +"#, + package_name = project.app.name, + bundle_id = project.app.app_id, + bundle_name = bundle_name, + executable = executable, + ) +} + +fn render_ios_run_script(project: &FissionProject) -> String { + format!( + r#"#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd) +BUNDLE_DIR=$("$SCRIPT_DIR/package-sim.sh") +BUNDLE_ID="${{IOS_BUNDLE_ID:-{bundle_id}}}" +DEVICE_ID="${{IOS_SIM_DEVICE_ID:-}}" + +if [[ -z "$DEVICE_ID" ]]; then + DEVICE_ID=$(python3 - <<'PY' +import json +import subprocess +payload = json.loads(subprocess.check_output(["xcrun", "simctl", "list", "devices", "available", "-j"])) +for runtime, devices in payload["devices"].items(): + if not runtime.startswith("com.apple.CoreSimulator.SimRuntime.iOS-"): + continue + for device in devices: + if device.get("isAvailable") and "iPhone" in device["name"]: + print(device["udid"]) + raise SystemExit(0) +raise SystemExit("no available iPhone simulator found") +PY +) +fi + +if [[ "${{IOS_SIM_HEADLESS:-0}}" != "1" ]] && command -v open >/dev/null 2>&1; then + open -a Simulator --args -CurrentDeviceUDID "$DEVICE_ID" >/dev/null 2>&1 \ + || open -a Simulator >/dev/null 2>&1 \ + || true +fi + +xcrun simctl boot "$DEVICE_ID" >/dev/null 2>&1 || true +xcrun simctl bootstatus "$DEVICE_ID" -b +xcrun simctl install "$DEVICE_ID" "$BUNDLE_DIR" + +if [[ -n "${{FISSION_TEST_CONTROL_PORT:-}}" ]]; then + SIMCTL_CHILD_FISSION_TEST_CONTROL_PORT="${{FISSION_TEST_CONTROL_PORT}}" \ + xcrun simctl launch --terminate-running-process "$DEVICE_ID" "$BUNDLE_ID" +else + xcrun simctl launch --terminate-running-process "$DEVICE_ID" "$BUNDLE_ID" +fi +"#, + bundle_id = project.app.app_id, + ) +} + +fn render_ios_test_script() -> String { + r#"#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd) +export FISSION_TEST_CONTROL_PORT="${FISSION_TEST_CONTROL_PORT:-48711}" + +"$SCRIPT_DIR/run-sim.sh" + +python3 - <<'PY' "$FISSION_TEST_CONTROL_PORT" +import sys +import time +import urllib.request + +port = sys.argv[1] +url = f"http://127.0.0.1:{port}/health" +deadline = time.time() + 90 +last_error = None +while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=1) as response: + body = response.read().decode("utf-8", "replace") + if response.status == 200 and '"status":"ok"' in body: + print(f"iOS simulator test control is healthy on {url}") + raise SystemExit(0) + except Exception as error: + last_error = error + time.sleep(1) +raise SystemExit(f"iOS simulator test control did not become healthy on {url}: {last_error}") +PY +"# + .to_string() +} + +fn render_android_manifest(project: &FissionProject) -> String { + format!( + r#" + + + + + + + + + + + + + + + + + +"#, + app_id = project.app.app_id, + label = ios_bundle_name(project), + lib_name = android_library_name(project), + ) +} + +fn render_android_package_script(project: &FissionProject) -> String { + let lib_name = android_library_name(project); + format!( + r#"#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd) +PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd) +TARGET="${{ANDROID_TARGET_TRIPLE:-aarch64-linux-android}}" +PACKAGE_NAME="{package_name}" +LIB_NAME="{lib_name}" +PROFILE="${{ANDROID_PROFILE:-debug}}" +ANDROID_HOME="${{ANDROID_HOME:-${{ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}}}" +ANDROID_MIN_API_LEVEL="${{ANDROID_MIN_API_LEVEL:-${{ANDROID_API_LEVEL:-24}}}}" + +find_android_ndk() {{ + if [[ -n "${{ANDROID_NDK:-}}" ]]; then + printf '%s\n' "$ANDROID_NDK" + return + fi + local ndk_root="$ANDROID_HOME/ndk" + if [[ ! -d "$ndk_root" ]]; then + printf 'Android NDK not found. Set ANDROID_NDK or install one under %s.\n' "$ndk_root" >&2 + return 1 + fi + local ndk + ndk=$(find "$ndk_root" -maxdepth 1 -mindepth 1 -type d | sort -V | tail -1) + if [[ -z "$ndk" ]]; then + printf 'Android NDK not found. Set ANDROID_NDK or install one under %s.\n' "$ndk_root" >&2 + return 1 + fi + printf '%s\n' "$ndk" +}} + +detect_android_toolchain() {{ + local prebuilt_root="$ANDROID_NDK/toolchains/llvm/prebuilt" + local host + for host in darwin-aarch64 darwin-x86_64 linux-x86_64 windows-x86_64; do + if [[ -d "$prebuilt_root/$host/bin" ]]; then + printf '%s\n' "$prebuilt_root/$host/bin" + return + fi + done + local fallback + fallback=$(find "$prebuilt_root" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort | head -1 || true) + if [[ -n "$fallback" && -d "$fallback/bin" ]]; then + printf '%s\n' "$fallback/bin" + return + fi + printf 'No Android NDK LLVM prebuilt toolchain found under %s. Expected a prebuilt host directory such as darwin-x86_64 or linux-x86_64.\n' "$prebuilt_root" >&2 + return 1 +}} + +detect_latest_android_api() {{ + find "$ANDROID_HOME/platforms" -maxdepth 1 -type d -name 'android-*' 2>/dev/null \ + | sed 's#.*android-##' \ + | sort -n \ + | tail -1 +}} + +detect_build_tools_dir() {{ + if [[ -n "${{ANDROID_BUILD_TOOLS:-}}" ]]; then + if [[ -d "$ANDROID_BUILD_TOOLS" ]]; then + printf '%s\n' "$ANDROID_BUILD_TOOLS" + return + fi + if [[ -d "$ANDROID_HOME/build-tools/$ANDROID_BUILD_TOOLS" ]]; then + printf '%s\n' "$ANDROID_HOME/build-tools/$ANDROID_BUILD_TOOLS" + return + fi + fi + find "$ANDROID_HOME/build-tools" -maxdepth 1 -mindepth 1 -type d 2>/dev/null | sort -V | tail -1 +}} + +ANDROID_TARGET_API_LEVEL="${{ANDROID_TARGET_API_LEVEL:-$(detect_latest_android_api)}}" +if [[ -z "$ANDROID_TARGET_API_LEVEL" ]]; then + printf 'No Android platform found under %s/platforms. Install one with sdkmanager "platforms;android-35" or newer.\n' "$ANDROID_HOME" >&2 + exit 1 +fi + +ANDROID_NDK=$(find_android_ndk) +ANDROID_TOOLCHAIN="${{ANDROID_TOOLCHAIN:-$(detect_android_toolchain)}}" +CC_aarch64_linux_android="${{CC_aarch64_linux_android:-$ANDROID_TOOLCHAIN/aarch64-linux-android${{ANDROID_MIN_API_LEVEL}}-clang}}" +AR_aarch64_linux_android="${{AR_aarch64_linux_android:-$ANDROID_TOOLCHAIN/llvm-ar}}" +CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="${{CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER:-$CC_aarch64_linux_android}}" +CARGO_TARGET_AARCH64_LINUX_ANDROID_AR="${{CARGO_TARGET_AARCH64_LINUX_ANDROID_AR:-$AR_aarch64_linux_android}}" +export ANDROID_HOME ANDROID_NDK ANDROID_MIN_API_LEVEL ANDROID_TARGET_API_LEVEL ANDROID_TOOLCHAIN CC_aarch64_linux_android AR_aarch64_linux_android +export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER CARGO_TARGET_AARCH64_LINUX_ANDROID_AR + +BUILD_TOOLS=$(detect_build_tools_dir) +if [[ -z "$BUILD_TOOLS" || ! -d "$BUILD_TOOLS" ]]; then + printf 'Android build-tools not found. Install them with sdkmanager "build-tools;35.0.0" or set ANDROID_BUILD_TOOLS.\n' >&2 + exit 1 +fi +ANDROID_JAR="$ANDROID_HOME/platforms/android-$ANDROID_TARGET_API_LEVEL/android.jar" +if [[ ! -f "$ANDROID_JAR" ]]; then + printf 'Android platform android-%s not found. Install it with sdkmanager "platforms;android-%s" or set ANDROID_TARGET_API_LEVEL.\n' "$ANDROID_TARGET_API_LEVEL" "$ANDROID_TARGET_API_LEVEL" >&2 + exit 1 +fi +AAPT="$BUILD_TOOLS/aapt" +ZIPALIGN="$BUILD_TOOLS/zipalign" +APKSIGNER="$BUILD_TOOLS/apksigner" +for tool in "$AAPT" "$ZIPALIGN" "$APKSIGNER"; do + if [[ ! -x "$tool" ]]; then + printf 'Required Android build tool is missing or not executable: %s\n' "$tool" >&2 + exit 1 + fi +done + +BUILD_ARGS=(build --manifest-path "$PROJECT_DIR/Cargo.toml" --lib --target "$TARGET" --package "$PACKAGE_NAME") +ARTIFACT_DIR=debug +if [[ "$PROFILE" == "release" ]]; then + BUILD_ARGS+=(--release) + ARTIFACT_DIR=release +fi + +cargo "${{BUILD_ARGS[@]}}" +TARGET_DIR=$(python3 - <<'PY' "$PROJECT_DIR/Cargo.toml" +import json +import subprocess +import sys + +manifest = sys.argv[1] +metadata = json.loads( + subprocess.check_output( + ["cargo", "metadata", "--manifest-path", manifest, "--format-version", "1", "--no-deps"] + ) +) +print(metadata["target_directory"]) +PY +) + +SO_PATH="$TARGET_DIR/$TARGET/$ARTIFACT_DIR/lib$LIB_NAME.so" +BUILD_DIR="$SCRIPT_DIR/build/$PROFILE" +APK_ROOT="$BUILD_DIR/apk-root" +UNALIGNED_APK="$BUILD_DIR/$PACKAGE_NAME-unaligned.apk" +ALIGNED_APK="$BUILD_DIR/$PACKAGE_NAME-aligned.apk" +SIGNED_APK="$BUILD_DIR/$PACKAGE_NAME.apk" +KEYSTORE="${{ANDROID_DEBUG_KEYSTORE:-$HOME/.android/debug.keystore}}" + +rm -rf "$APK_ROOT" +mkdir -p "$APK_ROOT/lib/arm64-v8a" "$APK_ROOT/res/drawable-nodpi" "$BUILD_DIR" +cp "$SO_PATH" "$APK_ROOT/lib/arm64-v8a/lib$LIB_NAME.so" +cp "$PROJECT_DIR/assets/app-icon.png" "$APK_ROOT/res/drawable-nodpi/app_icon.png" + +BUILD_MANIFEST="$BUILD_DIR/AndroidManifest.xml" +python3 - <<'PY' "$SCRIPT_DIR/AndroidManifest.xml" "$BUILD_MANIFEST" "$ANDROID_MIN_API_LEVEL" "$ANDROID_TARGET_API_LEVEL" +import re +import sys + +source, dest, min_api, target_api = sys.argv[1:] +manifest = open(source, encoding="utf-8").read() +manifest = re.sub(r'android:minSdkVersion="\d+"', f'android:minSdkVersion="{{min_api}}"', manifest) +manifest = re.sub(r'android:targetSdkVersion="\d+"', f'android:targetSdkVersion="{{target_api}}"', manifest) +open(dest, "w", encoding="utf-8").write(manifest) +PY + +"$AAPT" package -f -F "$UNALIGNED_APK" -M "$BUILD_MANIFEST" -S "$APK_ROOT/res" -I "$ANDROID_JAR" +(cd "$APK_ROOT" && zip -qr "$UNALIGNED_APK" lib) +"$ZIPALIGN" -f 4 "$UNALIGNED_APK" "$ALIGNED_APK" + +if [[ ! -f "$KEYSTORE" ]]; then + mkdir -p "$(dirname "$KEYSTORE")" + keytool -genkeypair -v \ + -keystore "$KEYSTORE" \ + -storepass android \ + -alias androiddebugkey \ + -keypass android \ + -dname "CN=Android Debug,O=Android,C=US" \ + -keyalg RSA \ + -keysize 2048 \ + -validity 10000 >/dev/null 2>&1 +fi + +"$APKSIGNER" sign \ + --ks "$KEYSTORE" \ + --ks-pass pass:android \ + --key-pass pass:android \ + --out "$SIGNED_APK" \ + "$ALIGNED_APK" + +printf '%s\n' "$SIGNED_APK" +"#, + package_name = project.app.name, + lib_name = lib_name, + ) +} + +fn render_android_run_script(project: &FissionProject) -> String { + format!( + r#"#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd) +ANDROID_HOME="${{ANDROID_HOME:-${{ANDROID_SDK_ROOT:-$HOME/Library/Android/sdk}}}}" +ADB="$ANDROID_HOME/platform-tools/adb" +EMULATOR_BIN="$ANDROID_HOME/emulator/emulator" +AVDMANAGER="${{ANDROID_AVDMANAGER:-$ANDROID_HOME/cmdline-tools/latest/bin/avdmanager}}" + +detect_latest_emulator_api() {{ + find "$ANDROID_HOME/system-images" -path '*/google_apis/arm64-v8a' -type d 2>/dev/null \ + | sed -n 's#.*system-images/android-\([0-9][0-9]*\)/google_apis/arm64-v8a#\1#p' \ + | sort -n \ + | tail -1 +}} + +android_system_image_path() {{ + local image="$1" + image="${{image#system-images;}}" + printf '%s/system-images/%s\n' "$ANDROID_HOME" "${{image//;/\/}}" +}} + +ANDROID_EMULATOR_API_LEVEL="${{ANDROID_EMULATOR_API_LEVEL:-$(detect_latest_emulator_api)}}" +if [[ -z "$ANDROID_EMULATOR_API_LEVEL" ]]; then + printf 'No Android arm64 google_apis emulator image found under %s/system-images.\nInstall one with sdkmanager "system-images;android-35;google_apis;arm64-v8a" or set ANDROID_SYSTEM_IMAGE.\n' "$ANDROID_HOME" >&2 + exit 1 +fi +AVD_NAME="${{ANDROID_AVD_NAME:-FissionApi${{ANDROID_EMULATOR_API_LEVEL}}Arm64}}" +SYSTEM_IMAGE="${{ANDROID_SYSTEM_IMAGE:-system-images;android-${{ANDROID_EMULATOR_API_LEVEL}};google_apis;arm64-v8a}}" +DEVICE_PORT="${{ANDROID_TEST_CONTROL_DEVICE_PORT:-48761}}" +HOST_PORT="${{FISSION_TEST_CONTROL_PORT:-48761}}" +HEADLESS="${{ANDROID_EMULATOR_HEADLESS:-0}}" +RESTART_EMULATOR="${{ANDROID_EMULATOR_RESTART:-0}}" + +for tool in "$ADB" "$EMULATOR_BIN" "$AVDMANAGER"; do + if [[ ! -x "$tool" ]]; then + printf 'Required Android tool is missing or not executable: %s\nRun `cargo fission doctor android --project-dir .` for setup help.\n' "$tool" >&2 + exit 1 + fi +done + +if ! "$AVDMANAGER" list avd | grep -q "Name: $AVD_NAME"; then + if [[ ! -d "$(android_system_image_path "$SYSTEM_IMAGE")" ]]; then + printf 'Android system image is not installed: %s\nInstall it with sdkmanager "%s" or set ANDROID_SYSTEM_IMAGE.\n' "$SYSTEM_IMAGE" "$SYSTEM_IMAGE" >&2 + exit 1 + fi + echo "no" | "$AVDMANAGER" create avd -n "$AVD_NAME" -k "$SYSTEM_IMAGE" --abi "google_apis/arm64-v8a" --device "pixel_5" +fi + +RUNNING_EMULATOR=$("$ADB" devices | awk '/^emulator-.*device$/ {{ print $1; exit }}') +if [[ -n "$RUNNING_EMULATOR" && "$RESTART_EMULATOR" == "1" ]]; then + "$ADB" -s "$RUNNING_EMULATOR" emu kill >/dev/null || true + until ! "$ADB" devices | grep -q '^emulator-'; do + sleep 1 + done + RUNNING_EMULATOR="" +fi + +if [[ -z "$RUNNING_EMULATOR" ]]; then + EMULATOR_ARGS=(-avd "$AVD_NAME" -gpu "${{ANDROID_EMULATOR_GPU:-swiftshader_indirect}}" -no-audio) + if [[ "$HEADLESS" == "1" ]]; then + EMULATOR_ARGS+=(-no-window) + fi + printf 'Launching emulator %s (%s)\n' "$AVD_NAME" "$([[ "$HEADLESS" == "1" ]] && echo headless || echo visible)" + "$EMULATOR_BIN" "${{EMULATOR_ARGS[@]}}" >/tmp/fission-android-emulator.log 2>&1 & + "$ADB" wait-for-device + until "$ADB" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r' | grep -q '^1$'; do + sleep 1 + done +else + printf 'Using existing emulator %s\n' "$RUNNING_EMULATOR" + if [[ "$HEADLESS" != "1" ]]; then + printf 'If the window is not visible, restart with ANDROID_EMULATOR_RESTART=1 to relaunch a visible emulator.\n' + fi +fi + +APK=$("$SCRIPT_DIR/package-apk.sh") +"$ADB" install -r "$APK" +"$ADB" forward "tcp:$HOST_PORT" "tcp:$DEVICE_PORT" +"$ADB" shell am start -n {app_id}/android.app.NativeActivity >/dev/null +printf 'APK=%s\n' "$APK" +"#, + app_id = project.app.app_id, + ) +} + +fn render_android_test_script() -> String { + r#"#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd) +export FISSION_TEST_CONTROL_PORT="${FISSION_TEST_CONTROL_PORT:-48761}" + +"$SCRIPT_DIR/run-emulator.sh" + +python3 - <<'PY' "$FISSION_TEST_CONTROL_PORT" +import sys +import time +import urllib.request + +port = sys.argv[1] +url = f"http://127.0.0.1:{port}/health" +deadline = time.time() + 90 +last_error = None +while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=1) as response: + body = response.read().decode("utf-8", "replace") + if response.status == 200 and '"status":"ok"' in body: + print(f"Android emulator test control is healthy on {url}") + raise SystemExit(0) + except Exception as error: + last_error = error + time.sleep(1) +raise SystemExit(f"Android emulator test control did not become healthy on {url}: {last_error}") +PY +"# + .to_string() +} + +fn render_web_index(project: &FissionProject) -> String { + let title = ios_bundle_name(project); + format!( + r#" + + + + + {title} + + + + +
+ + + +"#, + title = title, + ) +} + +fn render_web_bootstrap(project: &FissionProject) -> String { + let module_name = project.app.name.replace('-', "_"); + format!( + "import init from \"./pkg/{}.js\";\n\nawait init();\n", + module_name + ) +} + +fn render_web_build_script() -> String { + r#"#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd) +PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd) +PROFILE="${FISSION_WEB_PROFILE:-dev}" +BUILD_ARGS=(build "$PROJECT_DIR" --target web --out-dir "$SCRIPT_DIR/pkg") + +if [[ "$PROFILE" == "release" ]]; then + BUILD_ARGS+=(--release) +else + BUILD_ARGS+=(--dev) +fi + +wasm-pack "${BUILD_ARGS[@]}" +"# + .to_string() +} + +fn render_web_run_script(_project: &FissionProject) -> String { + format!( + r#"#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname "${{BASH_SOURCE[0]}}")" && pwd) +PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd) +HOST="${{FISSION_WEB_HOST:-127.0.0.1}}" +PORT="${{FISSION_WEB_PORT:-8123}}" +URL="http://${{HOST}}:${{PORT}}/platforms/web/" + +"$SCRIPT_DIR/build-wasm.sh" + +printf 'Serving %s\n' "$URL" +printf 'Press Ctrl+C to stop the local server.\n' +if [[ "${{FISSION_WEB_OPEN:-0}}" == "1" ]]; then + if command -v open >/dev/null 2>&1; then + open "$URL" + elif command -v xdg-open >/dev/null 2>&1; then + xdg-open "$URL" + elif command -v cmd.exe >/dev/null 2>&1; then + cmd.exe /C start "$URL" + else + printf 'No browser opener found. Open %s manually.\n' "$URL" + fi +fi + +cd "$PROJECT_DIR" +python3 -m http.server "$PORT" --bind "$HOST" +"# + ) +} + +fn render_web_test_script(_project: &FissionProject) -> String { + r#"#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname "${BASH_SOURCE[0]}")" && pwd) +PROJECT_DIR=$(cd -- "$SCRIPT_DIR/../.." && pwd) +HOST="${FISSION_WEB_HOST:-127.0.0.1}" +PORT="${FISSION_WEB_PORT:-8123}" +CDP_PORT="${FISSION_WEB_CDP_PORT:-9222}" +URL="http://${HOST}:${PORT}/platforms/web/" +PROFILE_DIR="$SCRIPT_DIR/build/chrome-profile" + +require_node_websocket() { + if ! command -v node >/dev/null 2>&1; then + printf 'Node.js was not found. Install Node 22+ so the generated browser smoke test can inspect Chrome CDP console/runtime errors.\n' >&2 + exit 1 + fi + if ! node -e 'process.exit(typeof WebSocket === "function" ? 0 : 1)' >/dev/null 2>&1; then + printf 'Node.js is available but does not expose the built-in WebSocket client. Install Node 22+ for Chrome CDP smoke tests.\n' >&2 + exit 1 + fi +} + +detect_chrome() { + if [[ -n "${FISSION_CHROME:-}" && -x "$FISSION_CHROME" ]]; then + printf '%s\n' "$FISSION_CHROME" + return + fi + local candidate + for candidate in \ + "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" \ + "/Applications/Chromium.app/Contents/MacOS/Chromium" \ + "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"; do + if [[ -x "$candidate" ]]; then + printf '%s\n' "$candidate" + return + fi + done + for candidate in google-chrome chromium chromium-browser chrome; do + if command -v "$candidate" >/dev/null 2>&1; then + command -v "$candidate" + return + fi + done + return 1 +} + +require_node_websocket +"$SCRIPT_DIR/build-wasm.sh" + +mkdir -p "$SCRIPT_DIR/build" +cd "$PROJECT_DIR" +python3 -m http.server "$PORT" --bind "$HOST" >"$SCRIPT_DIR/build/web-server.log" 2>&1 & +SERVER_PID=$! + +cleanup() { + if [[ -n "${CHROME_PID:-}" ]]; then + kill "$CHROME_PID" >/dev/null 2>&1 || true + fi + kill "$SERVER_PID" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +printf 'Running transient web smoke test at %s\n' "$URL" +printf 'The local server is stopped automatically when this script exits.\n' + +python3 - <<'PY' "$URL" +import sys +import time +import urllib.request + +url = sys.argv[1] +deadline = time.time() + 30 +last_error = None +while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=1) as response: + if response.status == 200: + raise SystemExit(0) + except Exception as error: + last_error = error + time.sleep(0.5) +raise SystemExit(f"web server did not serve {url}: {last_error}") +PY + +CHROME=$(detect_chrome) || { + printf 'Chrome/Chromium was not found. Set FISSION_CHROME=/path/to/chrome or run `cargo fission doctor web --project-dir .`.\n' >&2 + exit 1 +} + +rm -rf "$PROFILE_DIR" +"$CHROME" \ + --headless=new \ + --no-first-run \ + --no-default-browser-check \ + --remote-debugging-port="$CDP_PORT" \ + --user-data-dir="$PROFILE_DIR" \ + "$URL" >"$SCRIPT_DIR/build/chrome.log" 2>&1 & +CHROME_PID=$! + +CDP_PORT="$CDP_PORT" FISSION_WEB_URL="$URL" node <<'NODE' +const cdpPort = process.env.CDP_PORT; +const expectedUrl = process.env.FISSION_WEB_URL; +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function waitForTarget() { + const deadline = Date.now() + 60_000; + let lastError = null; + while (Date.now() < deadline) { + try { + const response = await fetch(`http://127.0.0.1:${cdpPort}/json/list`); + const targets = await response.json(); + const target = targets.find((entry) => entry.type === 'page' && entry.url.startsWith(expectedUrl)); + if (target?.webSocketDebuggerUrl) { + return target.webSocketDebuggerUrl; + } + } catch (error) { + lastError = error; + } + await sleep(250); + } + throw new Error(`Chrome CDP target did not become ready for ${expectedUrl}: ${lastError?.message ?? lastError}`); +} + +class CdpClient { + constructor(url) { + this.url = url; + this.ws = null; + this.nextId = 1; + this.pending = new Map(); + this.errors = []; + } + + async open() { + await new Promise((resolve, reject) => { + const ws = new WebSocket(this.url); + this.ws = ws; + ws.addEventListener('open', resolve, { once: true }); + ws.addEventListener('error', (event) => reject(new Error(`CDP websocket error: ${event.message ?? 'unknown error'}`)), { once: true }); + ws.addEventListener('message', (event) => this.onMessage(event.data)); + ws.addEventListener('close', () => { + for (const { reject: rejectPending } of this.pending.values()) { + rejectPending(new Error('CDP websocket closed')); + } + this.pending.clear(); + }); + }); + } + + send(method, params = {}) { + const id = this.nextId++; + const message = { id, method, params }; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`CDP command timed out: ${method}`)); + }, 10_000); + this.pending.set(id, { resolve, reject, timeout, method }); + this.ws.send(JSON.stringify(message)); + }); + } + + onMessage(raw) { + const message = JSON.parse(raw); + if (message.id) { + const pending = this.pending.get(message.id); + if (!pending) return; + clearTimeout(pending.timeout); + this.pending.delete(message.id); + if (message.error) { + pending.reject(new Error(`${pending.method}: ${message.error.message}`)); + } else { + pending.resolve(message.result ?? {}); + } + return; + } + + if (message.method === 'Runtime.exceptionThrown') { + this.errors.push(formatException(message.params?.exceptionDetails)); + } else if (message.method === 'Runtime.consoleAPICalled') { + const type = message.params?.type; + if (type === 'error' || type === 'assert') { + this.errors.push(`console.${type}: ${(message.params?.args ?? []).map(formatRemoteObject).join(' ')}`); + } + } else if (message.method === 'Log.entryAdded') { + const entry = message.params?.entry; + if (entry?.level === 'error') { + this.errors.push(`browser log error: ${entry.text}${entry.url ? ` (${entry.url}:${entry.lineNumber ?? 0})` : ''}`); + } + } + } + + close() { + this.ws?.close(); + } +} + +function formatRemoteObject(value) { + if (!value) return ''; + if (Object.prototype.hasOwnProperty.call(value, 'value')) return JSON.stringify(value.value); + return value.description ?? value.unserializableValue ?? value.type ?? ''; +} + +function formatException(details) { + if (!details) return 'runtime exception: '; + const exception = details.exception?.description ?? details.exception?.value ?? details.text ?? 'unknown exception'; + const location = details.url ? ` at ${details.url}:${details.lineNumber ?? 0}:${details.columnNumber ?? 0}` : ''; + return `runtime exception: ${exception}${location}`; +} + +function errorBlock(errors) { + return errors.slice(0, 10).map((error, index) => `${index + 1}. ${error}`).join('\n'); +} + +async function readCanvas(client) { + const expression = `(() => { + const canvas = document.querySelector('canvas'); + if (!canvas) return { ready: false, reason: 'no canvas element' }; + const rect = canvas.getBoundingClientRect(); + return { + ready: rect.width > 0 && rect.height > 0, + width: Math.round(rect.width), + height: Math.round(rect.height), + gpu: typeof navigator.gpu !== 'undefined', + title: document.title, + }; + })()`; + const result = await client.send('Runtime.evaluate', { expression, returnByValue: true }); + if (result.exceptionDetails) { + throw new Error(formatException(result.exceptionDetails)); + } + return result.result?.value ?? { ready: false, reason: 'evaluation returned no value' }; +} + +async function main() { + const wsUrl = await waitForTarget(); + const client = new CdpClient(wsUrl); + await client.open(); + try { + await Promise.all([ + client.send('Runtime.enable'), + client.send('Log.enable'), + client.send('Page.enable'), + ]); + + const deadline = Date.now() + 60_000; + let readySince = null; + let lastCanvas = null; + while (Date.now() < deadline) { + if (client.errors.length > 0) { + throw new Error(`browser reported runtime/console errors:\n${errorBlock(client.errors)}`); + } + lastCanvas = await readCanvas(client); + if (lastCanvas.ready) { + readySince ??= Date.now(); + if (Date.now() - readySince >= 1_500) { + console.log(`Web app rendered canvas ${lastCanvas.width}x${lastCanvas.height}; no runtime console errors observed.`); + return; + } + } else { + readySince = null; + } + await sleep(250); + } + throw new Error(`web app did not render a non-empty canvas. Last canvas state: ${JSON.stringify(lastCanvas)}`); + } finally { + client.close(); + } +} + +main().catch((error) => { + console.error(error.stack ?? error.message ?? String(error)); + process.exit(1); +}); +NODE +"# + .to_string() +} +fn render_app_main(package_name: &str) -> String { + let lib_name = package_name.replace('-', "_"); + format!( + r#"#[cfg(target_os = "android")] +fn main() {{}} + +#[cfg(target_arch = "wasm32")] +fn main() {{}} + +#[cfg(target_os = "ios")] +fn main() -> anyhow::Result<()> {{ + {lib_name}::run_mobile() +}} + +#[cfg(not(any(target_arch = "wasm32", target_os = "ios", target_os = "android")))] +fn main() -> anyhow::Result<()> {{ + {lib_name}::run_desktop() +}} +"# + ) +} + +const APP_LIB: &str = r#"pub mod app; + +use crate::app::CounterApp; +use fission::prelude::*; + +#[cfg(target_os = "android")] +const ANDROID_TEST_CONTROL_PORT: u16 = 48761; + +#[cfg(any(target_os = "android", target_os = "ios"))] +fn mobile_app() -> MobileApp { + let app = MobileApp::new(CounterApp).with_title("Fission App"); + #[cfg(target_os = "android")] + let app = app.with_test_control_port(ANDROID_TEST_CONTROL_PORT); + app +} + +#[cfg(target_arch = "wasm32")] +fn web_app() -> WebApp { + WebApp::new(CounterApp).with_title("Fission App") +} + +#[cfg(not(any(target_arch = "wasm32", target_os = "android", target_os = "ios")))] +pub fn run_desktop() -> anyhow::Result<()> { + DesktopApp::new(CounterApp).run() +} + +#[cfg(any(target_os = "android", target_os = "ios"))] +pub fn run_mobile() -> anyhow::Result<()> { + mobile_app().run() +} + +#[cfg(target_os = "android")] +#[no_mangle] +fn android_main(app_handle: AndroidApp) { + let _ = mobile_app().run_with_android_app(app_handle); +} + +#[cfg(target_arch = "wasm32")] +#[wasm_bindgen::prelude::wasm_bindgen(start)] +pub fn run_web() -> Result<(), wasm_bindgen::JsValue> { + console_error_panic_hook::set_once(); + web_app() + .run() + .map_err(|error| wasm_bindgen::JsValue::from_str(&error.to_string())) +} +"#; + +const APP_RS: &str = r#"use fission::prelude::*; + +#[derive(Default, Debug, Clone, PartialEq)] +pub struct CounterState { + pub count: i32, +} + +impl AppState for CounterState {} + +#[fission_reducer(Increment)] +fn on_increment(state: &mut CounterState) { + state.count += 1; +} + +pub struct CounterApp; + +impl Widget for CounterApp { + fn build(&self, ctx: &mut BuildCtx, view: &View) -> Node { + let increment = with_reducer!(ctx, Increment, on_increment); + + Column { + gap: Some(16.0), + children: vec![ + Text::new(format!("Count: {}", view.state.count)).size(28.0).into_node(), + Button { + on_press: Some(increment), + child: Some(Box::new(Text::new("Increment").into_node())), + ..Default::default() + } + .into_node(), + ], + ..Default::default() + } + .into_node() + } +} +"#; diff --git a/crates/tools/fission-cli/src/publish/files.rs b/crates/tools/fission-cli/src/publish/files.rs new file mode 100644 index 00000000..fc368c98 --- /dev/null +++ b/crates/tools/fission-cli/src/publish/files.rs @@ -0,0 +1,986 @@ +use super::*; +use crate::release; +use anyhow::{bail, Context, Result}; +use aws_config::{BehaviorVersion, Region}; +use aws_sdk_s3::primitives::ByteStream; +use aws_sdk_s3::types::ObjectCannedAcl; +use reqwest::blocking::Client; +use reqwest::header::{CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, LOCATION}; +use serde_json::{json, Value}; +use std::fs; +use std::path::{Path, PathBuf}; + +const DROPBOX_SIMPLE_UPLOAD_LIMIT: u64 = 150 * 1024 * 1024; +const DROPBOX_CHUNK_SIZE: usize = 8 * 1024 * 1024; + +struct UploadItem { + path: PathBuf, + relative_path: String, + mime_type: String, +} + +struct UploadedFile { + relative_path: String, + provider_id: Option, + url: Option, +} + +pub(super) fn publish_s3( + options: &DistributeOptions, + config: &PublishManifest, + artifact_path: &Path, + manifest: &ArtifactManifest, +) -> Result { + let cfg = s3_config(config, &options.site)?; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("failed to create S3 upload runtime")?; + let uploaded = rt.block_on(upload_s3(&cfg, manifest, artifact_path))?; + let canonical_url = + s3_canonical_url(&cfg, uploaded.first().and_then(|file| file.url.as_deref())); + Ok(upload_receipt( + options, + artifact_path, + "s3", + "published", + canonical_url, + uploaded, + )) +} + +pub(super) fn publish_google_drive( + options: &DistributeOptions, + config: &PublishManifest, + artifact_path: &Path, + manifest: &ArtifactManifest, +) -> Result { + let cfg = google_drive_config(config, &options.site)?; + let token = release::provider_secret( + DistributionProvider::GoogleDrive, + &["GOOGLE_DRIVE_ACCESS_TOKEN"], + )? + .context("Google Drive upload requires GOOGLE_DRIVE_ACCESS_TOKEN or a stored google-drive credential")?; + let client = Client::new(); + let mut uploaded = Vec::new(); + for item in upload_items(manifest, artifact_path)? { + uploaded.push(upload_google_drive_item(&client, &token, &cfg, &item)?); + } + Ok(upload_receipt( + options, + artifact_path, + "google-drive", + "published", + uploaded.iter().find_map(|file| file.url.clone()), + uploaded, + )) +} + +pub(super) fn publish_onedrive( + options: &DistributeOptions, + config: &PublishManifest, + artifact_path: &Path, + manifest: &ArtifactManifest, +) -> Result { + let cfg = onedrive_config(config, &options.site)?; + let token = + release::provider_secret(DistributionProvider::OneDrive, &["ONEDRIVE_ACCESS_TOKEN"])? + .context( + "OneDrive upload requires ONEDRIVE_ACCESS_TOKEN or a stored onedrive credential", + )?; + let client = Client::new(); + let mut uploaded = Vec::new(); + for item in upload_items(manifest, artifact_path)? { + uploaded.push(upload_onedrive_item(&client, &token, &cfg, &item)?); + } + Ok(upload_receipt( + options, + artifact_path, + "onedrive", + "published", + uploaded.iter().find_map(|file| file.url.clone()), + uploaded, + )) +} + +pub(super) fn publish_dropbox( + options: &DistributeOptions, + config: &PublishManifest, + artifact_path: &Path, + manifest: &ArtifactManifest, +) -> Result { + let cfg = dropbox_config(config, &options.site)?; + let token = release::provider_secret(DistributionProvider::Dropbox, &["DROPBOX_ACCESS_TOKEN"])? + .context("Dropbox upload requires DROPBOX_ACCESS_TOKEN or a stored dropbox credential")?; + let client = Client::new(); + let mut uploaded = Vec::new(); + for item in upload_items(manifest, artifact_path)? { + uploaded.push(upload_dropbox_item(&client, &token, &cfg, &item)?); + } + Ok(upload_receipt( + options, + artifact_path, + "dropbox", + "published", + uploaded.iter().find_map(|file| file.url.clone()), + uploaded, + )) +} + +pub(super) fn s3_status( + options: &DistributeOptions, + config: &PublishManifest, +) -> Result { + let cfg = s3_config(config, &options.site)?; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .context("failed to create S3 status runtime")?; + let value = rt.block_on(s3_status_value(&cfg))?; + Ok(file_status_receipt( + options, + "s3", + "ok", + s3_canonical_url(&cfg, None), + value, + )) +} + +pub(super) fn google_drive_status( + options: &DistributeOptions, + config: &PublishManifest, +) -> Result { + let cfg = google_drive_config(config, &options.site)?; + let token = release::provider_secret( + DistributionProvider::GoogleDrive, + &["GOOGLE_DRIVE_ACCESS_TOKEN"], + )? + .context("Google Drive status requires GOOGLE_DRIVE_ACCESS_TOKEN or a stored google-drive credential")?; + let client = Client::new(); + let value = if let Some(folder_id) = cfg.folder_id.as_deref().filter(|value| !value.is_empty()) + { + let response = client + .get(format!("https://www.googleapis.com/drive/v3/files/{folder_id}?fields=id,name,webViewLink,capabilities")) + .bearer_auth(token.trim()) + .send() + .context("failed to query Google Drive folder")?; + json_http_response(response, "Google Drive folder status")? + } else { + let response = client + .get("https://www.googleapis.com/drive/v3/about?fields=user,storageQuota") + .bearer_auth(token.trim()) + .send() + .context("failed to query Google Drive account")?; + json_http_response(response, "Google Drive account status")? + }; + Ok(file_status_receipt( + options, + "google-drive", + "ok", + value + .get("webViewLink") + .and_then(Value::as_str) + .map(str::to_string), + value, + )) +} + +pub(super) fn onedrive_status( + options: &DistributeOptions, + config: &PublishManifest, +) -> Result { + let cfg = onedrive_config(config, &options.site)?; + let token = + release::provider_secret(DistributionProvider::OneDrive, &["ONEDRIVE_ACCESS_TOKEN"])? + .context( + "OneDrive status requires ONEDRIVE_ACCESS_TOKEN or a stored onedrive credential", + )?; + let root = cfg + .root + .as_deref() + .unwrap_or("me/drive/root") + .trim_matches('/'); + let url = if let Some(prefix) = cfg + .path_prefix + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + format!( + "https://graph.microsoft.com/v1.0/{root}:/{}/", + encode_path(prefix.trim_matches('/')) + ) + } else { + format!("https://graph.microsoft.com/v1.0/{root}") + }; + let response = Client::new() + .get(url) + .bearer_auth(token.trim()) + .send() + .context("failed to query OneDrive destination")?; + let value = json_http_response(response, "OneDrive destination status")?; + Ok(file_status_receipt( + options, + "onedrive", + "ok", + value + .get("webUrl") + .and_then(Value::as_str) + .map(str::to_string), + value, + )) +} + +pub(super) fn dropbox_status( + options: &DistributeOptions, + _config: &PublishManifest, +) -> Result { + let token = release::provider_secret(DistributionProvider::Dropbox, &["DROPBOX_ACCESS_TOKEN"])? + .context("Dropbox status requires DROPBOX_ACCESS_TOKEN or a stored dropbox credential")?; + let response = Client::new() + .post("https://api.dropboxapi.com/2/users/get_current_account") + .bearer_auth(token.trim()) + .send() + .context("failed to query Dropbox account")?; + let value = json_http_response(response, "Dropbox account status")?; + Ok(file_status_receipt(options, "dropbox", "ok", None, value)) +} + +pub(super) fn readiness_s3( + site: &str, + config: &PublishManifest, + checks: &mut Vec, +) -> Result<()> { + let cfg = s3_config(config, site)?; + checks.push(required_value( + "release.s3.bucket_configured", + cfg.bucket.as_deref(), + "S3 bucket is configured", + "Set distribution.s3..bucket.", + )); + checks.push(secret_check( + "release.s3.credentials_available", + DistributionProvider::S3, + &["AWS_PROFILE", "AWS_ACCESS_KEY_ID"], + "AWS/S3 credentials are available", + "Set AWS_PROFILE, AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, or import S3 credentials into the Fission vault.", + )); + checks.push(check( + "release.s3.direct_rust_backend", + CheckSeverity::Info, + CheckStatus::Passed, + "S3 upload uses the Rust AWS SDK backend", + Some(format!( + "endpoint = {}, path_style = {}, visibility = {}, presign_ttl_seconds = {}", + cfg.endpoint.as_deref().unwrap_or(""), + cfg.path_style.unwrap_or(false), + cfg.visibility.as_deref().unwrap_or("private"), + cfg.presign_ttl_seconds + .map(|value| value.to_string()) + .unwrap_or_else(|| "".to_string()) + )), + Vec::new(), + )); + Ok(()) +} + +pub(super) fn readiness_google_drive( + site: &str, + config: &PublishManifest, + checks: &mut Vec, +) -> Result<()> { + let cfg = google_drive_config(config, site)?; + checks.push(secret_check( + "release.google_drive.token_available", + DistributionProvider::GoogleDrive, + &["GOOGLE_DRIVE_ACCESS_TOKEN"], + "Google Drive OAuth token is available", + "Run `fission auth import google-drive --from env:GOOGLE_DRIVE_ACCESS_TOKEN --yes` or set GOOGLE_DRIVE_ACCESS_TOKEN in CI.", + )); + checks.push(check( + "release.google_drive.folder_selected", + CheckSeverity::Info, + CheckStatus::Passed, + "Google Drive folder destination is selected", + Some( + cfg.folder_id + .unwrap_or_else(|| "root drive folder".to_string()), + ), + Vec::new(), + )); + Ok(()) +} + +pub(super) fn readiness_onedrive( + site: &str, + config: &PublishManifest, + checks: &mut Vec, +) -> Result<()> { + let cfg = onedrive_config(config, site)?; + checks.push(secret_check( + "release.onedrive.token_available", + DistributionProvider::OneDrive, + &["ONEDRIVE_ACCESS_TOKEN"], + "OneDrive OAuth token is available", + "Run `fission auth import onedrive --from env:ONEDRIVE_ACCESS_TOKEN --yes` or set ONEDRIVE_ACCESS_TOKEN in CI.", + )); + checks.push(check( + "release.onedrive.path_selected", + CheckSeverity::Info, + CheckStatus::Passed, + "OneDrive upload path is selected", + Some( + cfg.path_prefix + .unwrap_or_else(|| "Fission releases".to_string()), + ), + Vec::new(), + )); + Ok(()) +} + +pub(super) fn readiness_dropbox( + site: &str, + config: &PublishManifest, + checks: &mut Vec, +) -> Result<()> { + let cfg = dropbox_config(config, site)?; + checks.push(secret_check( + "release.dropbox.token_available", + DistributionProvider::Dropbox, + &["DROPBOX_ACCESS_TOKEN"], + "Dropbox OAuth token is available", + "Run `fission auth import dropbox --from env:DROPBOX_ACCESS_TOKEN --yes` or set DROPBOX_ACCESS_TOKEN in CI.", + )); + checks.push(check( + "release.dropbox.path_selected", + CheckSeverity::Info, + CheckStatus::Passed, + "Dropbox upload path is selected", + Some( + cfg.path_prefix + .unwrap_or_else(|| "/Fission releases".to_string()), + ), + Vec::new(), + )); + Ok(()) +} + +async fn s3_status_value(cfg: &S3Config) -> Result { + let bucket = cfg + .bucket + .as_deref() + .context("distribution.s3..bucket is required")?; + let mut loader = aws_config::defaults(BehaviorVersion::latest()); + if let Some(region) = cfg + .region + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + loader = loader.region(Region::new(region.to_string())); + } + if let Some(profile) = cfg + .profile + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + loader = loader.profile_name(profile); + } + if let Some(endpoint) = cfg + .endpoint + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + loader = loader.endpoint_url(endpoint); + } + let shared = loader.load().await; + let mut builder = aws_sdk_s3::config::Builder::from(&shared); + if cfg.path_style.unwrap_or(false) { + builder = builder.force_path_style(true); + } + let client = aws_sdk_s3::Client::from_conf(builder.build()); + let prefix = normalized_prefix(cfg.prefix.as_deref()); + let result = client + .list_objects_v2() + .bucket(bucket) + .prefix(prefix.clone()) + .max_keys(10) + .send() + .await + .with_context(|| format!("failed to list s3://{bucket}/{prefix}"))?; + Ok(json!({ + "bucket": bucket, + "prefix": prefix, + "key_count": result.key_count(), + "objects": result.contents().iter().map(|object| json!({ + "key": object.key(), + "size": object.size(), + "etag": object.e_tag(), + })).collect::>() + })) +} + +fn json_http_response(response: reqwest::blocking::Response, operation: &str) -> Result { + let status = response.status(); + let text = response.text()?; + ensure_success(status, text.clone(), operation)?; + serde_json::from_str(&text) + .with_context(|| format!("failed to parse {operation} response: {text}")) +} + +fn file_status_receipt( + options: &DistributeOptions, + provider: &str, + status: &str, + canonical_url: Option, + value: Value, +) -> DistributionReceipt { + DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: provider.to_string(), + site: options.site.clone(), + action: "status".to_string(), + artifact_manifest: None, + deployment_id: options.deploy.clone(), + canonical_url, + preview_url: None, + custom_domain: None, + status: status.to_string(), + stdout: serde_json::to_string_pretty(&value).ok(), + stderr: None, + manual_follow_up: Vec::new(), + } +} + +async fn upload_s3( + cfg: &S3Config, + manifest: &ArtifactManifest, + artifact_path: &Path, +) -> Result> { + let bucket = cfg + .bucket + .as_deref() + .context("distribution.s3..bucket is required")?; + let mut loader = aws_config::defaults(BehaviorVersion::latest()); + if let Some(region) = cfg + .region + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + loader = loader.region(Region::new(region.to_string())); + } + if let Some(profile) = cfg + .profile + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + loader = loader.profile_name(profile); + } + if let Some(endpoint) = cfg + .endpoint + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + loader = loader.endpoint_url(endpoint); + } + let shared = loader.load().await; + let mut builder = aws_sdk_s3::config::Builder::from(&shared); + if cfg.path_style.unwrap_or(false) { + builder = builder.force_path_style(true); + } + let client = aws_sdk_s3::Client::from_conf(builder.build()); + let prefix = normalized_prefix(cfg.prefix.as_deref()); + let mut uploaded = Vec::new(); + for item in upload_items(manifest, artifact_path)? { + let key = format!("{prefix}{}", item.relative_path); + let body = ByteStream::from_path(&item.path) + .await + .with_context(|| format!("failed to open {} for S3 upload", item.path.display()))?; + let mut request = client + .put_object() + .bucket(bucket) + .key(&key) + .body(body) + .content_type(item.mime_type.clone()); + if cfg.visibility.as_deref() == Some("public") { + request = request.acl(ObjectCannedAcl::PublicRead); + } + request.send().await.with_context(|| { + format!( + "failed to upload {} to s3://{bucket}/{key}", + item.path.display() + ) + })?; + uploaded.push(UploadedFile { + relative_path: item.relative_path, + provider_id: Some(format!("s3://{bucket}/{key}")), + url: s3_object_url(cfg, bucket, &key), + }); + } + Ok(uploaded) +} + +fn upload_google_drive_item( + client: &Client, + token: &str, + cfg: &GoogleDriveConfig, + item: &UploadItem, +) -> Result { + let metadata = if let Some(folder_id) = + cfg.folder_id.as_deref().filter(|value| !value.is_empty()) + { + json!({ "name": drive_name(cfg.name_prefix.as_deref(), &item.relative_path), "parents": [folder_id] }) + } else { + json!({ "name": drive_name(cfg.name_prefix.as_deref(), &item.relative_path) }) + }; + let len = fs::metadata(&item.path)?.len(); + let response = client + .post("https://www.googleapis.com/upload/drive/v3/files?uploadType=resumable&fields=id,name,webViewLink,webContentLink") + .bearer_auth(token.trim()) + .header(CONTENT_TYPE, "application/json; charset=utf-8") + .header("X-Upload-Content-Type", item.mime_type.as_str()) + .header("X-Upload-Content-Length", len.to_string()) + .json(&metadata) + .send() + .context("failed to start Google Drive resumable upload")?; + let status = response.status(); + let location = response_location(&response)?; + if !status.is_success() { + bail!( + "Google Drive upload start failed with {status}: {}", + response.text()? + ); + } + let bytes = fs::read(&item.path)?; + let response = client + .put(location) + .header(CONTENT_TYPE, item.mime_type.as_str()) + .header(CONTENT_LENGTH, bytes.len().to_string()) + .body(bytes) + .send() + .context("failed to upload file bytes to Google Drive")?; + let status = response.status(); + let text = response.text()?; + ensure_success(status, text.clone(), "Google Drive upload")?; + let value: Value = + serde_json::from_str(&text).context("failed to parse Google Drive upload response")?; + let id = value.get("id").and_then(Value::as_str).map(str::to_string); + if cfg.share.unwrap_or(false) { + if let Some(id) = id.as_deref() { + let response = client + .post(format!( + "https://www.googleapis.com/drive/v3/files/{id}/permissions" + )) + .bearer_auth(token.trim()) + .json(&json!({ "type": "anyone", "role": "reader" })) + .send() + .context("failed to create Google Drive sharing permission")?; + let status = response.status(); + let text = response.text()?; + ensure_success(status, text, "Google Drive sharing permission")?; + } + } + Ok(UploadedFile { + relative_path: item.relative_path.clone(), + provider_id: id, + url: value + .get("webViewLink") + .or_else(|| value.get("webContentLink")) + .and_then(Value::as_str) + .map(str::to_string), + }) +} + +fn upload_onedrive_item( + client: &Client, + token: &str, + cfg: &OneDriveConfig, + item: &UploadItem, +) -> Result { + let root = cfg + .root + .as_deref() + .unwrap_or("me/drive/root") + .trim_matches('/'); + let upload_path = joined_remote_path(cfg.path_prefix.as_deref(), &item.relative_path) + .trim_start_matches('/') + .to_string(); + let url = format!( + "https://graph.microsoft.com/v1.0/{root}:/{}/createUploadSession", + encode_path(&upload_path) + ); + let conflict = cfg.conflict_behavior.as_deref().unwrap_or("replace"); + let response = client + .post(url) + .bearer_auth(token.trim()) + .json(&json!({ "item": { "@microsoft.graph.conflictBehavior": conflict } })) + .send() + .context("failed to create OneDrive upload session")?; + let status = response.status(); + let text = response.text()?; + ensure_success(status, text.clone(), "OneDrive upload session")?; + let value: Value = + serde_json::from_str(&text).context("failed to parse OneDrive upload session")?; + let upload_url = value + .get("uploadUrl") + .and_then(Value::as_str) + .context("OneDrive upload session response did not contain uploadUrl")?; + let bytes = fs::read(&item.path)?; + if bytes.is_empty() { + bail!( + "OneDrive upload does not support empty file {} yet", + item.path.display() + ); + } + let range = format!("bytes 0-{}/{}", bytes.len() - 1, bytes.len()); + let response = client + .put(upload_url) + .header(CONTENT_LENGTH, bytes.len().to_string()) + .header(CONTENT_RANGE, range) + .body(bytes) + .send() + .context("failed to upload file bytes to OneDrive")?; + let status = response.status(); + let text = response.text()?; + ensure_success(status, text.clone(), "OneDrive upload")?; + let value: Value = + serde_json::from_str(&text).context("failed to parse OneDrive upload response")?; + Ok(UploadedFile { + relative_path: item.relative_path.clone(), + provider_id: value.get("id").and_then(Value::as_str).map(str::to_string), + url: value + .get("webUrl") + .and_then(Value::as_str) + .map(str::to_string), + }) +} + +fn upload_dropbox_item( + client: &Client, + token: &str, + cfg: &DropboxConfig, + item: &UploadItem, +) -> Result { + let remote_path = joined_remote_path(cfg.path_prefix.as_deref(), &item.relative_path); + let size = fs::metadata(&item.path)?.len(); + if size <= DROPBOX_SIMPLE_UPLOAD_LIMIT { + upload_dropbox_simple(client, token, cfg, item, &remote_path) + } else { + upload_dropbox_session(client, token, cfg, item, &remote_path) + } +} + +fn upload_dropbox_simple( + client: &Client, + token: &str, + cfg: &DropboxConfig, + item: &UploadItem, + remote_path: &str, +) -> Result { + let mode = cfg.mode.as_deref().unwrap_or("overwrite"); + let arg = json!({ + "path": remote_path, + "mode": mode, + "autorename": cfg.autorename.unwrap_or(false), + "mute": false, + "strict_conflict": false + }); + let response = client + .post("https://content.dropboxapi.com/2/files/upload") + .bearer_auth(token.trim()) + .header("Dropbox-API-Arg", arg.to_string()) + .header(CONTENT_TYPE, "application/octet-stream") + .body(fs::read(&item.path)?) + .send() + .context("failed to upload file to Dropbox")?; + let status = response.status(); + let text = response.text()?; + ensure_success(status, text.clone(), "Dropbox upload")?; + let value: Value = + serde_json::from_str(&text).context("failed to parse Dropbox upload response")?; + Ok(UploadedFile { + relative_path: item.relative_path.clone(), + provider_id: value.get("id").and_then(Value::as_str).map(str::to_string), + url: value + .get("path_display") + .and_then(Value::as_str) + .map(str::to_string), + }) +} + +fn upload_dropbox_session( + client: &Client, + token: &str, + cfg: &DropboxConfig, + item: &UploadItem, + remote_path: &str, +) -> Result { + let bytes = fs::read(&item.path)?; + let first_len = DROPBOX_CHUNK_SIZE.min(bytes.len()); + let response = client + .post("https://content.dropboxapi.com/2/files/upload_session/start") + .bearer_auth(token.trim()) + .header("Dropbox-API-Arg", json!({"close": false}).to_string()) + .header(CONTENT_TYPE, "application/octet-stream") + .body(bytes[..first_len].to_vec()) + .send() + .context("failed to start Dropbox upload session")?; + let status = response.status(); + let text = response.text()?; + ensure_success(status, text.clone(), "Dropbox upload session start")?; + let value: Value = + serde_json::from_str(&text).context("failed to parse Dropbox session response")?; + let session_id = value + .get("session_id") + .and_then(Value::as_str) + .context("Dropbox upload session did not return session_id")?; + let mut offset = first_len; + while offset + DROPBOX_CHUNK_SIZE < bytes.len() { + let next = offset + DROPBOX_CHUNK_SIZE; + let arg = json!({"cursor": {"session_id": session_id, "offset": offset}}); + let response = client + .post("https://content.dropboxapi.com/2/files/upload_session/append_v2") + .bearer_auth(token.trim()) + .header("Dropbox-API-Arg", arg.to_string()) + .header(CONTENT_TYPE, "application/octet-stream") + .body(bytes[offset..next].to_vec()) + .send() + .context("failed to append Dropbox upload session")?; + let status = response.status(); + let text = response.text()?; + ensure_success(status, text, "Dropbox upload session append")?; + offset = next; + } + let mode = cfg.mode.as_deref().unwrap_or("overwrite"); + let arg = json!({ + "cursor": {"session_id": session_id, "offset": offset}, + "commit": { + "path": remote_path, + "mode": mode, + "autorename": cfg.autorename.unwrap_or(false), + "mute": false, + "strict_conflict": false + } + }); + let response = client + .post("https://content.dropboxapi.com/2/files/upload_session/finish") + .bearer_auth(token.trim()) + .header("Dropbox-API-Arg", arg.to_string()) + .header(CONTENT_TYPE, "application/octet-stream") + .body(bytes[offset..].to_vec()) + .send() + .context("failed to finish Dropbox upload session")?; + let status = response.status(); + let text = response.text()?; + ensure_success(status, text.clone(), "Dropbox upload session finish")?; + let value: Value = + serde_json::from_str(&text).context("failed to parse Dropbox finish response")?; + Ok(UploadedFile { + relative_path: item.relative_path.clone(), + provider_id: value.get("id").and_then(Value::as_str).map(str::to_string), + url: value + .get("path_display") + .and_then(Value::as_str) + .map(str::to_string), + }) +} + +fn upload_receipt( + options: &DistributeOptions, + artifact_path: &Path, + provider: &str, + status: &str, + canonical_url: Option, + uploaded: Vec, +) -> DistributionReceipt { + let stdout = serde_json::to_string_pretty(&json!({ + "uploaded": uploaded.iter().map(|file| json!({ + "relative_path": file.relative_path, + "provider_id": file.provider_id, + "url": file.url, + })).collect::>() + })) + .ok(); + DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: provider.to_string(), + site: options.site.clone(), + action: "publish".to_string(), + artifact_manifest: Some(artifact_path.display().to_string()), + deployment_id: options.deploy.clone(), + canonical_url, + preview_url: uploaded.iter().find_map(|file| file.url.clone()), + custom_domain: None, + status: status.to_string(), + stdout, + stderr: None, + manual_follow_up: Vec::new(), + } +} + +fn upload_items(manifest: &ArtifactManifest, artifact_path: &Path) -> Result> { + let mut items = manifest + .artifacts + .iter() + .map(|file| UploadItem { + path: PathBuf::from(&file.path), + relative_path: file.relative_path.clone(), + mime_type: file.mime_type.clone(), + }) + .collect::>(); + items.push(UploadItem { + path: artifact_path.to_path_buf(), + relative_path: ARTIFACT_MANIFEST.to_string(), + mime_type: content_type(artifact_path).to_string(), + }); + Ok(items) +} + +fn secret_check( + id: &str, + provider: DistributionProvider, + env_names: &[&str], + summary: &str, + remediation: &str, +) -> ReadinessCheck { + let found_env = env_names + .iter() + .find(|name| std::env::var_os(name).is_some()); + let found_secret = release::provider_secret(provider, env_names) + .ok() + .flatten() + .is_some(); + check( + id, + CheckSeverity::Error, + if found_env.is_some() || found_secret { + CheckStatus::Passed + } else { + CheckStatus::Missing + }, + summary, + found_env + .map(|name| format!("environment: {name}")) + .or_else(|| found_secret.then(|| "vault credential".to_string())), + vec![remediation], + ) +} + +fn ensure_success(status: reqwest::StatusCode, body: String, context: &str) -> Result<()> { + if status.is_success() { + Ok(()) + } else { + bail!("{context} failed with {status}: {body}") + } +} + +fn response_location(response: &reqwest::blocking::Response) -> Result { + response + .headers() + .get(LOCATION) + .and_then(|value| value.to_str().ok()) + .map(str::to_string) + .context("resumable upload response did not include Location header") +} + +fn normalized_prefix(prefix: Option<&str>) -> String { + prefix + .unwrap_or("") + .trim_matches('/') + .split('/') + .filter(|part| !part.is_empty()) + .collect::>() + .join("/") + .pipe(|value| { + if value.is_empty() { + value + } else { + format!("{value}/") + } + }) +} + +fn joined_remote_path(prefix: Option<&str>, relative: &str) -> String { + let prefix = prefix.unwrap_or("").trim_matches('/'); + let relative = relative.trim_start_matches('/'); + if prefix.is_empty() { + format!("/{relative}") + } else { + format!("/{prefix}/{relative}") + } +} + +fn drive_name(prefix: Option<&str>, relative: &str) -> String { + let name = relative.replace('/', "__"); + match prefix.map(str::trim).filter(|value| !value.is_empty()) { + Some(prefix) => format!("{prefix}-{name}"), + None => name, + } +} + +fn encode_path(path: &str) -> String { + path.split('/') + .map(percent_encode_segment) + .collect::>() + .join("/") +} + +fn percent_encode_segment(segment: &str) -> String { + let mut out = String::new(); + for byte in segment.as_bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(*byte as char) + } + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} + +fn s3_object_url(cfg: &S3Config, bucket: &str, key: &str) -> Option { + if cfg.visibility.as_deref() != Some("public") { + return None; + } + if let Some(endpoint) = cfg + .endpoint + .as_deref() + .filter(|value| value.starts_with("http")) + { + if cfg.path_style.unwrap_or(false) { + Some(format!( + "{}/{}/{}", + endpoint.trim_end_matches('/'), + bucket, + key + )) + } else { + let endpoint = endpoint + .trim_start_matches("https://") + .trim_start_matches("http://") + .trim_end_matches('/'); + Some(format!("https://{bucket}.{endpoint}/{key}")) + } + } else { + let region = cfg.region.as_deref().unwrap_or("us-east-1"); + Some(format!("https://{bucket}.s3.{region}.amazonaws.com/{key}")) + } +} + +fn s3_canonical_url(cfg: &S3Config, fallback: Option<&str>) -> Option { + cfg.bucket.as_deref().and_then(|bucket| { + let prefix = normalized_prefix(cfg.prefix.as_deref()); + if prefix.is_empty() { + fallback.map(str::to_string) + } else { + s3_object_url(cfg, bucket, &prefix) + } + }) +} + +trait Pipe: Sized { + fn pipe(self, f: impl FnOnce(Self) -> T) -> T { + f(self) + } +} +impl Pipe for T {} diff --git a/crates/tools/fission-cli/src/publish/github_releases.rs b/crates/tools/fission-cli/src/publish/github_releases.rs new file mode 100644 index 00000000..bccf955d --- /dev/null +++ b/crates/tools/fission-cli/src/publish/github_releases.rs @@ -0,0 +1,669 @@ +use super::*; +use crate::release; +use anyhow::{bail, Context, Result}; +use serde_json::{json, Value}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +#[derive(Clone, Debug)] +struct ReleaseAsset { + path: PathBuf, + name: String, + mime_type: String, + sha256: Option, + size_bytes: u64, +} + +pub(super) fn setup(options: &DistributeOptions, config: &PublishManifest) -> Result<()> { + let cfg = github_releases_config(config, &options.site)?; + let owner = cfg + .owner + .clone() + .or_else(|| infer_github_owner(&options.project_dir)) + .unwrap_or_else(|| "".to_string()); + let repo = cfg + .repo + .clone() + .or_else(|| infer_github_repo(&options.project_dir)) + .unwrap_or_else(|| "".to_string()); + println!("GitHub Releases setup checks for `{}`", options.site); + println!("owner: {owner}"); + println!("repo: {repo}"); + println!( + "tag: {}", + cfg.tag + .as_deref() + .unwrap_or("") + ); + println!( + "Run `fission readiness distribute --provider github-releases --site {} --artifact --project-dir {}` before publishing.", + options.site, + options.project_dir.display() + ); + Ok(()) +} + +pub(super) fn readiness( + project_dir: &Path, + site: &str, + artifact: Option<&Path>, + config: &PublishManifest, + checks: &mut Vec, +) -> Result<()> { + let cfg = github_releases_config(config, site)?; + let owner = cfg + .owner + .clone() + .or_else(|| infer_github_owner(project_dir)); + let repo = cfg.repo.clone().or_else(|| infer_github_repo(project_dir)); + checks.push(required_value( + "release.github_releases.owner_configured", + owner.as_deref(), + "GitHub release owner is configured or inferable from git remote", + "Set distribution.github_releases..owner or configure an origin GitHub remote.", + )); + checks.push(required_value( + "release.github_releases.repo_configured", + repo.as_deref(), + "GitHub release repository is configured or inferable from git remote", + "Set distribution.github_releases..repo or configure an origin GitHub remote.", + )); + checks.push(check_tool( + "release.github_releases.gh_available", + "gh", + "Install GitHub CLI and authenticate with `gh auth login`.", + )); + checks.push(check( + "release.github_releases.auth_available", + CheckSeverity::Error, + if gh_auth_available(project_dir) { + CheckStatus::Passed + } else { + CheckStatus::Missing + }, + "GitHub CLI authentication is available", + Some("gh auth status, GH_TOKEN/GITHUB_TOKEN, or Fission vault credential".to_string()), + vec!["Run `gh auth login`, set GH_TOKEN/GITHUB_TOKEN, or import github-releases credentials into the Fission vault."], + )); + checks.push(check( + "release.github_releases.replace_assets_explicit", + CheckSeverity::Info, + if cfg.replace_assets.is_some() { + CheckStatus::Passed + } else { + CheckStatus::Skipped + }, + "duplicate release asset behavior is explicit", + cfg.replace_assets + .map(|value| format!("replace_assets = {value}")), + vec!["Set replace_assets = true to overwrite same-named release assets during republish, or false to fail safely."], + )); + if let Some(artifact) = artifact.filter(|path| path.exists()) { + let manifest = read_artifact_manifest(artifact)?; + let assets = release_assets(&manifest, artifact, &cfg)?; + checks.push(check( + "release.github_releases.assets_available", + CheckSeverity::Error, + if assets.is_empty() { + CheckStatus::Missing + } else { + CheckStatus::Passed + }, + "artifact manifest contains uploadable release assets", + Some(format!( + "{} uploadable assets for {} {}", + assets.len(), + manifest.target, + manifest.format + )), + vec!["Package the app artifact first. GitHub Releases accepts any artifact file, including .run, .pkg, .exe, .msi, .msix, .apk, .aab, .ipa, archives, symbol files, and zipped static-site outputs."], + )); + let tag = release_tag(&cfg, &manifest, None); + checks.push(check( + "release.github_releases.tag_resolved", + CheckSeverity::Error, + if tag.is_some() { + CheckStatus::Passed + } else { + CheckStatus::Missing + }, + "release tag can be resolved", + tag, + vec!["Set distribution.github_releases..tag, pass --deploy , or ensure Cargo.toml has package.version."], + )); + } + Ok(()) +} + +pub(super) fn status( + options: &DistributeOptions, + config: &PublishManifest, +) -> Result { + let cfg = github_releases_config(config, &options.site)?; + let (owner, repo) = github_repo(&options.project_dir, &cfg)?; + let tag = options.deploy.as_deref().or(cfg.tag.as_deref()); + let output = gh_release_view(&options.project_dir, &owner, &repo, tag)?; + let release: Value = serde_json::from_slice(&output.stdout) + .context("failed to parse gh release view JSON output")?; + Ok(DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: "github-releases".to_string(), + site: options.site.clone(), + action: "status".to_string(), + artifact_manifest: None, + deployment_id: release_id(&release), + canonical_url: release_url(&release), + preview_url: None, + custom_domain: None, + status: gh_release_state(&release), + stdout: Some(serde_json::to_string_pretty(&release)?), + stderr: (!output.stderr.is_empty()) + .then(|| String::from_utf8_lossy(&output.stderr).to_string()), + manual_follow_up: Vec::new(), + }) +} + +pub(super) fn publish( + options: &DistributeOptions, + config: &PublishManifest, + artifact_path: &Path, + manifest: &ArtifactManifest, +) -> Result { + let cfg = github_releases_config(config, &options.site)?; + let (owner, repo) = github_repo(&options.project_dir, &cfg)?; + let tag = release_tag(&cfg, manifest, options.deploy.as_deref()).context( + "GitHub Releases publishing requires a tag from --deploy, config tag, or artifact version", + )?; + let assets = release_assets(manifest, artifact_path, &cfg)?; + if assets.is_empty() { + bail!("GitHub Releases publishing requires at least one artifact file"); + } + if options.dry_run { + return Ok(DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: "github-releases".to_string(), + site: options.site.clone(), + action: "publish".to_string(), + artifact_manifest: Some(artifact_path.display().to_string()), + deployment_id: Some(tag.clone()), + canonical_url: Some(format!( + "https://github.com/{owner}/{repo}/releases/tag/{}", + url_segment(&tag) + )), + preview_url: None, + custom_domain: None, + status: "dry-run".to_string(), + stdout: Some(serde_json::to_string_pretty(&json!({ + "tag": tag, + "repo": repo_arg(&owner, &repo), + "assets": assets.iter().map(asset_json).collect::>(), + "command": "gh release create/edit/upload" + }))?), + stderr: None, + manual_follow_up: Vec::new(), + }); + } + + require_gh_authenticated(&options.project_dir)?; + let existing = gh_release_view(&options.project_dir, &owner, &repo, Some(&tag)); + let release_output = match existing { + Ok(_) => gh_release_edit(&options.project_dir, &owner, &repo, &tag, &cfg)?, + Err(error) if is_not_found_error(&error) => { + gh_release_create(&options.project_dir, &owner, &repo, &tag, &cfg)? + } + Err(error) => return Err(error), + }; + let uploaded = gh_release_upload( + &options.project_dir, + &owner, + &repo, + &tag, + &assets, + cfg.replace_assets.unwrap_or(false), + )?; + let view = gh_release_view(&options.project_dir, &owner, &repo, Some(&tag))?; + let release: Value = serde_json::from_slice(&view.stdout) + .context("failed to parse gh release view JSON output after publish")?; + let stdout = json!({ + "release": release, + "release_command": command_output_json(&release_output), + "upload_command": command_output_json(&uploaded), + "uploaded_assets": assets.iter().map(asset_json).collect::>(), + }); + Ok(DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: "github-releases".to_string(), + site: options.site.clone(), + action: "publish".to_string(), + artifact_manifest: Some(artifact_path.display().to_string()), + deployment_id: release_id(stdout.get("release").unwrap_or(&Value::Null)).or(Some(tag)), + canonical_url: release_url(stdout.get("release").unwrap_or(&Value::Null)), + preview_url: None, + custom_domain: None, + status: gh_release_state(stdout.get("release").unwrap_or(&Value::Null)), + stdout: Some(serde_json::to_string_pretty(&stdout)?), + stderr: None, + manual_follow_up: cfg + .draft + .unwrap_or(false) + .then(|| { + "Publish the draft release from GitHub when it is ready for users.".to_string() + }) + .into_iter() + .collect(), + }) +} + +fn github_repo(project_dir: &Path, cfg: &GithubReleasesConfig) -> Result<(String, String)> { + let owner = cfg + .owner + .clone() + .or_else(|| infer_github_owner(project_dir)) + .context("distribution.github_releases..owner or GitHub origin remote is required")?; + let repo = cfg + .repo + .clone() + .or_else(|| infer_github_repo(project_dir)) + .context("distribution.github_releases..repo or GitHub origin remote is required")?; + Ok((owner, repo)) +} + +fn release_tag( + cfg: &GithubReleasesConfig, + manifest: &ArtifactManifest, + override_tag: Option<&str>, +) -> Option { + override_tag + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .or_else(|| cfg.tag.clone().filter(|value| !value.trim().is_empty())) + .or_else(|| { + manifest + .project + .version + .as_ref() + .map(|version| format!("v{version}")) + }) +} + +fn gh_release_view( + project_dir: &Path, + owner: &str, + repo: &str, + tag: Option<&str>, +) -> Result { + let repo_arg = repo_arg(owner, repo); + let mut args = vec!["release", "view"]; + if let Some(tag) = tag.filter(|value| !value.trim().is_empty()) { + args.push(tag); + } + args.extend([ + "--repo", + &repo_arg, + "--json", + "apiUrl,assets,author,body,createdAt,databaseId,id,isDraft,isImmutable,isPrerelease,name,publishedAt,tagName,targetCommitish,uploadUrl,url,zipballUrl,tarballUrl", + ]); + run_gh(project_dir, &args) +} + +fn gh_release_create( + project_dir: &Path, + owner: &str, + repo: &str, + tag: &str, + cfg: &GithubReleasesConfig, +) -> Result { + let mut args = vec!["release".to_string(), "create".to_string(), tag.to_string()]; + args.extend(["--repo".to_string(), repo_arg(owner, repo)]); + args.extend(release_metadata_args(cfg, project_dir)?); + run_gh_owned(project_dir, &args) +} + +fn gh_release_edit( + project_dir: &Path, + owner: &str, + repo: &str, + tag: &str, + cfg: &GithubReleasesConfig, +) -> Result { + let mut args = vec!["release".to_string(), "edit".to_string(), tag.to_string()]; + args.extend(["--repo".to_string(), repo_arg(owner, repo)]); + args.extend(release_metadata_args(cfg, project_dir)?); + run_gh_owned(project_dir, &args) +} + +fn gh_release_upload( + project_dir: &Path, + owner: &str, + repo: &str, + tag: &str, + assets: &[ReleaseAsset], + replace_assets: bool, +) -> Result { + let mut args = vec!["release".to_string(), "upload".to_string(), tag.to_string()]; + args.extend(assets.iter().map(|asset| asset.path.display().to_string())); + args.extend(["--repo".to_string(), repo_arg(owner, repo)]); + if replace_assets { + args.push("--clobber".to_string()); + } + run_gh_owned(project_dir, &args) +} + +fn release_metadata_args(cfg: &GithubReleasesConfig, project_dir: &Path) -> Result> { + let mut args = Vec::new(); + if let Some(name) = cfg.name.as_deref().filter(|value| !value.trim().is_empty()) { + args.extend(["--title".to_string(), name.to_string()]); + } + if let Some(target) = cfg + .target_commitish + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + args.extend(["--target".to_string(), target.to_string()]); + } + if let Some(notes) = cfg.notes.as_ref().filter(|value| !value.trim().is_empty()) { + args.extend(["--notes".to_string(), notes.to_string()]); + } else if let Some(path) = cfg + .notes_file + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + args.extend([ + "--notes-file".to_string(), + resolve_project_path(project_dir, path.clone()) + .display() + .to_string(), + ]); + } + if cfg.draft.unwrap_or(false) { + args.push("--draft".to_string()); + } + if cfg.prerelease.unwrap_or(false) { + args.push("--prerelease".to_string()); + } + if let Some(latest) = cfg + .make_latest + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + match latest { + "true" => args.push("--latest".to_string()), + "false" => args.push("--latest=false".to_string()), + "legacy" => {} + other => bail!("unsupported github-releases make_latest value `{other}`; use true, false, or legacy"), + } + } + Ok(args) +} + +fn release_assets( + manifest: &ArtifactManifest, + artifact_path: &Path, + cfg: &GithubReleasesConfig, +) -> Result> { + let mut assets = manifest + .artifacts + .iter() + .filter_map(release_asset_from_manifest_file) + .collect::>(); + if cfg.upload_artifact_manifest.unwrap_or(true) { + let metadata = fs::metadata(artifact_path) + .with_context(|| format!("failed to read {}", artifact_path.display()))?; + assets.push(ReleaseAsset { + path: artifact_path.to_path_buf(), + name: artifact_path + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or(ARTIFACT_MANIFEST) + .to_string(), + mime_type: content_type(artifact_path).to_string(), + sha256: None, + size_bytes: metadata.len(), + }); + } + Ok(assets) +} + +fn release_asset_from_manifest_file(file: &ArtifactFile) -> Option { + let path = PathBuf::from(&file.path); + if !path.is_file() { + return None; + } + let name = path.file_name()?.to_str()?.to_string(); + Some(ReleaseAsset { + path, + name, + mime_type: file.mime_type.clone(), + sha256: Some(file.sha256.clone()), + size_bytes: file.size_bytes, + }) +} + +fn require_gh_authenticated(project_dir: &Path) -> Result<()> { + run_gh(project_dir, &["auth", "status"]) + .map(|_| ()) + .context("GitHub Releases requires an authenticated gh CLI session; run `gh auth login` or set GH_TOKEN/GITHUB_TOKEN") +} + +fn gh_auth_available(project_dir: &Path) -> bool { + env::var_os("GH_TOKEN").is_some() + || env::var_os("GITHUB_TOKEN").is_some() + || release::provider_secret(DistributionProvider::GithubReleases, &[]) + .ok() + .flatten() + .is_some() + || run_gh(project_dir, &["auth", "status"]).is_ok() +} + +fn run_gh(project_dir: &Path, args: &[&str]) -> Result { + let args = args + .iter() + .map(|arg| (*arg).to_string()) + .collect::>(); + run_gh_owned(project_dir, &args) +} + +fn run_gh_owned(project_dir: &Path, args: &[String]) -> Result { + let output = Command::new("gh") + .args(args) + .current_dir(project_dir) + .envs(gh_env()) + .output() + .with_context(|| { + "failed to run gh; install GitHub CLI and authenticate with `gh auth login`" + })?; + if output.status.success() { + Ok(output) + } else { + bail!( + "gh {} failed with {}: {}", + args.join(" "), + output.status, + String::from_utf8_lossy(&output.stderr).trim() + ) + } +} + +fn gh_env() -> Vec<(&'static str, String)> { + let mut envs = Vec::new(); + if env::var_os("GH_TOKEN").is_none() && env::var_os("GITHUB_TOKEN").is_none() { + if let Ok(Some(token)) = release::provider_secret(DistributionProvider::GithubReleases, &[]) + { + envs.push(("GH_TOKEN", token)); + } + } + envs +} + +fn is_not_found_error(error: &anyhow::Error) -> bool { + let text = format!("{error:#}").to_ascii_lowercase(); + text.contains("not found") || text.contains("http 404") || text.contains("could not find") +} + +fn release_id(value: &Value) -> Option { + value + .get("databaseId") + .and_then(Value::as_i64) + .map(|id| id.to_string()) + .or_else(|| value.get("id").and_then(Value::as_str).map(str::to_string)) + .or_else(|| { + value + .get("tagName") + .and_then(Value::as_str) + .map(str::to_string) + }) +} + +fn release_url(value: &Value) -> Option { + value.get("url").and_then(Value::as_str).map(str::to_string) +} + +fn gh_release_state(value: &Value) -> String { + if value + .get("isDraft") + .and_then(Value::as_bool) + .unwrap_or(false) + { + "draft".to_string() + } else if value + .get("isPrerelease") + .and_then(Value::as_bool) + .unwrap_or(false) + { + "prerelease".to_string() + } else { + value + .get("tagName") + .and_then(Value::as_str) + .map(|tag| format!("published:{tag}")) + .unwrap_or_else(|| "published".to_string()) + } +} + +fn command_output_json(output: &Output) -> Value { + json!({ + "status": output.status.code(), + "stdout": String::from_utf8_lossy(&output.stdout).trim(), + "stderr": String::from_utf8_lossy(&output.stderr).trim(), + }) +} + +fn repo_arg(owner: &str, repo: &str) -> String { + format!("{owner}/{repo}") +} + +fn asset_json(asset: &ReleaseAsset) -> Value { + json!({ + "name": asset.name, + "path": asset.path, + "mime_type": asset.mime_type, + "sha256": asset.sha256, + "size_bytes": asset.size_bytes, + }) +} + +fn url_segment(value: &str) -> String { + percent_encode(value, false) +} + +fn percent_encode(value: &str, encode_slash: bool) -> String { + let mut out = String::new(); + for byte in value.as_bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(*byte as char) + } + b'/' if !encode_slash => out.push('/'), + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn artifact(path: &Path, relative_path: &str, kind: &str) -> ArtifactFile { + ArtifactFile { + kind: kind.to_string(), + purpose: None, + platform: None, + upload_provider: None, + path: path.display().to_string(), + relative_path: relative_path.to_string(), + sha256: "abc".to_string(), + size_bytes: 3, + mime_type: content_type(path).to_string(), + } + } + + fn manifest(files: Vec) -> ArtifactManifest { + ArtifactManifest { + schema_version: 1, + created_at_unix_seconds: 0, + project: ArtifactProject { + app_id: "com.example.app".to_string(), + name: "app".to_string(), + version: Some("1.2.3".to_string()), + }, + target: "linux".to_string(), + format: "run".to_string(), + profile: "release".to_string(), + root_dir: "/tmp".to_string(), + artifacts: files, + validation: ArtifactValidation { + state: "passed".to_string(), + checks: Vec::new(), + }, + } + } + + #[test] + fn release_tag_defaults_to_version() { + let manifest = manifest(Vec::new()); + assert_eq!( + release_tag(&GithubReleasesConfig::default(), &manifest, None), + Some("v1.2.3".to_string()) + ); + assert_eq!( + release_tag(&GithubReleasesConfig::default(), &manifest, Some("nightly")), + Some("nightly".to_string()) + ); + } + + #[test] + fn release_assets_include_every_manifest_file_and_manifest() { + let dir = std::env::temp_dir().join(format!( + "fission-github-release-assets-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + let run = dir.join("app.run"); + let html = dir.join("index.html"); + fs::write(&run, b"run").unwrap(); + fs::write(&html, b"html").unwrap(); + let manifest = manifest(vec![ + artifact(&run, "app.run", "asset"), + artifact(&html, "index.html", "entry"), + ]); + let manifest_path = dir.join("artifact-manifest.json"); + fs::write(&manifest_path, b"{}").unwrap(); + let assets = + release_assets(&manifest, &manifest_path, &GithubReleasesConfig::default()).unwrap(); + let names = assets + .iter() + .map(|asset| asset.name.as_str()) + .collect::>(); + assert!(names.contains(&"app.run")); + assert!(names.contains(&"index.html")); + assert!(names.contains(&"artifact-manifest.json")); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/crates/tools/fission-cli/src/publish/mod.rs b/crates/tools/fission-cli/src/publish/mod.rs new file mode 100644 index 00000000..d3e1d4f1 --- /dev/null +++ b/crates/tools/fission-cli/src/publish/mod.rs @@ -0,0 +1,2895 @@ +use crate::{release, FissionProject, Target}; +use anyhow::{bail, Context, Result}; +use clap::ValueEnum; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::env; +use std::ffi::OsStr; +use std::fs; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; + +mod files; +mod github_releases; +mod package; +mod static_hosts; +mod stores; + +const ARTIFACT_MANIFEST: &str = "artifact-manifest.json"; + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub(crate) enum PackageFormat { + Aab, + Apk, + App, + Exe, + Ipa, + Msi, + Msix, + Pkg, + Run, + Static, +} + +impl PackageFormat { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::Aab => "aab", + Self::Apk => "apk", + Self::App => "app", + Self::Exe => "exe", + Self::Ipa => "ipa", + Self::Msi => "msi", + Self::Msix => "msix", + Self::Pkg => "pkg", + Self::Run => "run", + Self::Static => "static", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub(crate) enum DistributionProvider { + #[value(name = "app-store")] + AppStore, + #[value(name = "github-pages")] + GithubPages, + #[value(name = "github-releases")] + GithubReleases, + #[value(name = "cloudflare-pages")] + CloudflarePages, + Dropbox, + #[value(name = "google-drive")] + GoogleDrive, + #[value(name = "microsoft-store")] + MicrosoftStore, + Netlify, + #[value(name = "onedrive")] + OneDrive, + #[value(name = "play-store")] + PlayStore, + S3, +} + +impl DistributionProvider { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::AppStore => "app-store", + Self::GithubPages => "github-pages", + Self::GithubReleases => "github-releases", + Self::CloudflarePages => "cloudflare-pages", + Self::Dropbox => "dropbox", + Self::GoogleDrive => "google-drive", + Self::MicrosoftStore => "microsoft-store", + Self::Netlify => "netlify", + Self::OneDrive => "onedrive", + Self::PlayStore => "play-store", + Self::S3 => "s3", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub(crate) enum DistributeAction { + Setup, + Publish, + Status, + Promote, + Rollback, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)] +pub(crate) enum ReadinessKind { + Package, + Distribute, + Release, +} + +#[derive(Clone, Debug)] +pub(crate) struct PackageOptions { + pub(crate) project_dir: PathBuf, + pub(crate) target: Target, + pub(crate) format: PackageFormat, + pub(crate) release: bool, + pub(crate) json: bool, +} + +#[derive(Clone, Debug)] +pub(crate) struct DistributeOptions { + pub(crate) project_dir: PathBuf, + pub(crate) provider: DistributionProvider, + pub(crate) action: DistributeAction, + pub(crate) artifact: Option, + pub(crate) site: String, + pub(crate) deploy: Option, + pub(crate) track: Option, + pub(crate) dry_run: bool, + pub(crate) yes: bool, + pub(crate) json: bool, +} + +#[derive(Clone, Debug)] +pub(crate) struct ReadinessOptions { + pub(crate) project_dir: PathBuf, + pub(crate) kind: ReadinessKind, + pub(crate) target: Option, + pub(crate) format: Option, + pub(crate) provider: Option, + pub(crate) artifact: Option, + pub(crate) site: String, + pub(crate) track: Option, + pub(crate) json: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ArtifactManifest { + schema_version: u32, + created_at_unix_seconds: u64, + project: ArtifactProject, + target: String, + format: String, + profile: String, + root_dir: String, + artifacts: Vec, + validation: ArtifactValidation, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ArtifactProject { + app_id: String, + name: String, + version: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ArtifactFile { + kind: String, + purpose: Option, + platform: Option, + upload_provider: Option, + path: String, + relative_path: String, + sha256: String, + size_bytes: u64, + mime_type: String, +} + +#[derive(Debug, Serialize, Deserialize)] +struct ArtifactValidation { + state: String, + checks: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +struct ReadinessCheck { + id: String, + severity: CheckSeverity, + status: CheckStatus, + summary: String, + details: Option, + remediation: Vec, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum CheckSeverity { + Error, + Warning, + Info, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +enum CheckStatus { + Passed, + Missing, + Failed, + Warning, + Skipped, +} + +#[derive(Debug, Serialize)] +struct ReadinessReport { + project_dir: String, + target: Option, + format: Option, + provider: Option, + site: Option, + status: String, + checks: Vec, +} + +#[derive(Debug, Serialize)] +struct DistributionReceipt { + schema_version: u32, + created_at_unix_seconds: u64, + provider: String, + site: String, + action: String, + artifact_manifest: Option, + deployment_id: Option, + canonical_url: Option, + preview_url: Option, + custom_domain: Option, + status: String, + stdout: Option, + stderr: Option, + manual_follow_up: Vec, +} + +#[derive(Debug, Deserialize, Default)] +struct PublishManifest { + site: Option, + distribution: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct SiteManifest { + entry: Option, + out_dir: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct DistributionManifest { + #[serde(default)] + s3: BTreeMap, + #[serde(default)] + google_drive: BTreeMap, + #[serde(default)] + onedrive: BTreeMap, + #[serde(default)] + dropbox: BTreeMap, + play_store: Option, + app_store: Option, + microsoft_store: Option, + #[serde(default)] + github_pages: BTreeMap, + #[serde(default)] + github_releases: BTreeMap, + #[serde(default)] + cloudflare_pages: BTreeMap, + #[serde(default)] + netlify: BTreeMap, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct S3Config { + endpoint: Option, + region: Option, + bucket: Option, + prefix: Option, + profile: Option, + path_style: Option, + visibility: Option, + presign_ttl_seconds: Option, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct GoogleDriveConfig { + folder_id: Option, + name_prefix: Option, + share: Option, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct OneDriveConfig { + root: Option, + path_prefix: Option, + conflict_behavior: Option, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct DropboxConfig { + path_prefix: Option, + mode: Option, + autorename: Option, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct PlayStoreConfig { + package_name: Option, + default_track: Option, + service_account: Option, + release_status: Option, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct AppStoreConfig { + app_id: Option, + bundle_id: Option, + issuer_id: Option, + key_id: Option, + api_key_path: Option, + default_track: Option, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct MicrosoftStoreConfig { + product_id: Option, + package_identity_name: Option, + tenant_id: Option, + client_id: Option, + seller_id: Option, + package_url: Option, + package_type: Option, + flight_id: Option, + package_rollout_percentage: Option, + msstore_project: Option, + msstore_reconfigure: Option, + languages: Option>, + architectures: Option>, + is_silent_install: Option, + installer_parameters: Option, + generic_doc_url: Option, + submit: Option, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct GithubPagesConfig { + owner: Option, + repo: Option, + mode: Option, + source: Option, + source_branch: Option, + source_path: Option, + site_kind: Option, + base_path: Option, + custom_domain: Option, + enforce_https: Option, + remote: Option, + production_branch: Option, + workflow: Option, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct GithubReleasesConfig { + owner: Option, + repo: Option, + tag: Option, + name: Option, + target_commitish: Option, + notes: Option, + notes_file: Option, + draft: Option, + prerelease: Option, + make_latest: Option, + replace_assets: Option, + upload_artifact_manifest: Option, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct CloudflarePagesConfig { + account_id: Option, + project_name: Option, + environment: Option, + custom_domain: Option, + base_path: Option, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct NetlifyConfig { + site_id: Option, + team_slug: Option, + production: Option, + custom_domain: Option, + base_path: Option, +} + +pub(crate) fn package(options: PackageOptions) -> Result<()> { + let manifest = package::package_artifact(&options)?; + if options.json { + println!("{}", serde_json::to_string_pretty(&manifest)?); + } else { + println!( + "Packaged {} {} artifact into {}", + manifest.target, manifest.format, manifest.root_dir + ); + println!("{} files", manifest.artifacts.len()); + println!( + "{}", + Path::new(&manifest.root_dir) + .join(ARTIFACT_MANIFEST) + .display() + ); + } + Ok(()) +} + +pub(crate) fn distribute(options: DistributeOptions) -> Result<()> { + let config = load_publish_manifest(&options.project_dir)?; + match options.action { + DistributeAction::Setup => setup_provider(&options, &config), + DistributeAction::Status => provider_status(&options, &config), + DistributeAction::Promote | DistributeAction::Rollback => { + provider_lifecycle(&options, &config) + } + DistributeAction::Publish => publish_artifact(&options, &config), + } +} + +pub(crate) fn readiness(options: ReadinessOptions) -> Result<()> { + let checks = match options.kind { + ReadinessKind::Package => { + readiness_package(&options.project_dir, options.target, options.format) + } + ReadinessKind::Release => { + let config = load_publish_manifest(&options.project_dir)?; + let mut checks = + readiness_package(&options.project_dir, options.target, options.format)?; + let provider = options + .provider + .context("readiness release requires --provider")?; + checks.extend(readiness_distribute( + &options.project_dir, + provider, + &options.site, + options.track.as_deref(), + options.artifact.as_deref(), + &config, + )?); + Ok(checks) + } + ReadinessKind::Distribute => { + let config = load_publish_manifest(&options.project_dir)?; + let provider = options + .provider + .context("readiness distribute requires --provider")?; + let artifact = options.artifact.as_deref(); + readiness_distribute( + &options.project_dir, + provider, + &options.site, + options.track.as_deref(), + artifact, + &config, + ) + } + }?; + let report = ReadinessReport { + project_dir: options.project_dir.display().to_string(), + target: options.target.map(|target| target.as_str().to_string()), + format: options.format.map(|format| format.as_str().to_string()), + provider: options + .provider + .map(|provider| provider.as_str().to_string()), + site: matches!( + options.kind, + ReadinessKind::Distribute | ReadinessKind::Release + ) + .then(|| options.site.clone()), + status: report_status(&checks).to_string(), + checks, + }; + if options.json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + print_readiness_report(&report); + if report.status == "blocked" { + bail!("readiness checks failed"); + } + } + Ok(()) +} + +fn setup_provider(options: &DistributeOptions, config: &PublishManifest) -> Result<()> { + match options.provider { + DistributionProvider::GithubPages => setup_github_pages(options, config), + DistributionProvider::GithubReleases => github_releases::setup(options, config), + DistributionProvider::CloudflarePages => { + let cfg = cloudflare_config(config, &options.site)?; + println!("Cloudflare Pages setup checks for `{}`", options.site); + println!( + "account_id: {}", + cfg.account_id.as_deref().unwrap_or("") + ); + println!( + "project_name: {}", + cfg.project_name.as_deref().unwrap_or("") + ); + println!("Run `fission readiness distribute --provider cloudflare-pages --site {} --project-dir {}` before publishing.", options.site, options.project_dir.display()); + Ok(()) + } + DistributionProvider::Netlify => { + let cfg = netlify_config(config, &options.site)?; + println!("Netlify setup checks for `{}`", options.site); + println!("site_id: {}", cfg.site_id.as_deref().unwrap_or("")); + println!( + "team_slug: {}", + cfg.team_slug.as_deref().unwrap_or("") + ); + println!("Run `fission readiness distribute --provider netlify --site {} --project-dir {}` before publishing.", options.site, options.project_dir.display()); + Ok(()) + } + DistributionProvider::S3 + | DistributionProvider::GoogleDrive + | DistributionProvider::OneDrive + | DistributionProvider::Dropbox + | DistributionProvider::PlayStore + | DistributionProvider::AppStore + | DistributionProvider::MicrosoftStore => setup_non_static_provider(options, config), + } +} + +fn publish_artifact(options: &DistributeOptions, config: &PublishManifest) -> Result<()> { + let artifact_path = options + .artifact + .as_deref() + .map(PathBuf::from) + .unwrap_or_else(|| { + default_artifact_manifest_path(&options.project_dir, Target::Site, true) + }); + let manifest = read_artifact_manifest(&artifact_path)?; + let checks = readiness_distribute( + &options.project_dir, + options.provider, + &options.site, + options.track.as_deref(), + Some(&artifact_path), + config, + )?; + let errors = checks + .iter() + .filter(|check| { + check.severity == CheckSeverity::Error && check.status != CheckStatus::Passed + }) + .collect::>(); + if !errors.is_empty() { + print_checks(&checks); + bail!("distribution readiness failed"); + } + + let receipt = match options.provider { + DistributionProvider::GithubPages => { + publish_github_pages(options, config, &artifact_path, &manifest)? + } + DistributionProvider::GithubReleases => { + github_releases::publish(options, config, &artifact_path, &manifest)? + } + DistributionProvider::CloudflarePages => { + publish_cloudflare_pages(options, config, &artifact_path, &manifest)? + } + DistributionProvider::Netlify => { + static_hosts::publish_netlify(options, config, &artifact_path, &manifest)? + } + DistributionProvider::S3 => files::publish_s3(options, config, &artifact_path, &manifest)?, + DistributionProvider::GoogleDrive => { + files::publish_google_drive(options, config, &artifact_path, &manifest)? + } + DistributionProvider::OneDrive => { + files::publish_onedrive(options, config, &artifact_path, &manifest)? + } + DistributionProvider::Dropbox => { + files::publish_dropbox(options, config, &artifact_path, &manifest)? + } + DistributionProvider::PlayStore => { + stores::publish_play_store(options, config, &artifact_path, &manifest)? + } + DistributionProvider::AppStore => { + stores::publish_app_store(options, config, &artifact_path, &manifest)? + } + DistributionProvider::MicrosoftStore => { + stores::publish_microsoft_store(options, config, &artifact_path, &manifest)? + } + }; + write_receipt(&options.project_dir, &receipt)?; + print_distribution_receipt(options, &receipt) +} + +fn print_distribution_receipt( + options: &DistributeOptions, + receipt: &DistributionReceipt, +) -> Result<()> { + if options.json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + println!( + "{} {} status: {}", + receipt.provider, receipt.action, receipt.status + ); + if let Some(url) = &receipt.canonical_url { + println!("URL: {url}"); + } + for item in &receipt.manual_follow_up { + println!("Follow-up: {item}"); + } + } + Ok(()) +} + +fn provider_status(options: &DistributeOptions, config: &PublishManifest) -> Result<()> { + let receipt = match options.provider { + DistributionProvider::GithubPages => github_pages_status(options, config)?, + DistributionProvider::GithubReleases => github_releases::status(options, config)?, + DistributionProvider::CloudflarePages => cloudflare_pages_status(options, config)?, + DistributionProvider::Netlify => static_hosts::netlify_status(options, config)?, + DistributionProvider::PlayStore => stores::play_store_status(options, config)?, + DistributionProvider::S3 => files::s3_status(options, config)?, + DistributionProvider::GoogleDrive => files::google_drive_status(options, config)?, + DistributionProvider::OneDrive => files::onedrive_status(options, config)?, + DistributionProvider::Dropbox => files::dropbox_status(options, config)?, + DistributionProvider::AppStore => stores::app_store_status(options, config)?, + DistributionProvider::MicrosoftStore => stores::microsoft_store_status(options, config)?, + }; + if options.json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else { + println!("{} status: {}", receipt.provider, receipt.status); + if let Some(stdout) = &receipt.stdout { + print!("{stdout}"); + } + } + Ok(()) +} + +fn provider_lifecycle(options: &DistributeOptions, config: &PublishManifest) -> Result<()> { + let receipt = match options.provider { + DistributionProvider::Netlify => static_hosts::netlify_lifecycle(options, config)?, + DistributionProvider::CloudflarePages => cloudflare_pages_lifecycle(options, config)?, + _ => bail!( + "{} currently supports setup, publish, and status; {} is not exposed by this provider backend", + options.provider.as_str(), + match options.action { + DistributeAction::Promote => "promote", + DistributeAction::Rollback => "rollback", + _ => "this operation", + } + ), + }; + write_receipt(&options.project_dir, &receipt)?; + print_distribution_receipt(options, &receipt) +} + +fn cloudflare_pages_lifecycle( + options: &DistributeOptions, + config: &PublishManifest, +) -> Result { + let cfg = cloudflare_config(config, &options.site)?; + let account_id = cfg + .account_id + .clone() + .or_else(|| env::var("CLOUDFLARE_ACCOUNT_ID").ok()) + .context( + "distribution.cloudflare_pages..account_id or CLOUDFLARE_ACCOUNT_ID is required", + )?; + let project_name = cfg + .project_name + .as_deref() + .context("distribution.cloudflare_pages..project_name is required")?; + let deploy = options + .deploy + .as_deref() + .context("cloudflare-pages promote/rollback requires --deploy ")?; + if options.dry_run { + return Ok(DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: "cloudflare-pages".to_string(), + site: options.site.clone(), + action: match options.action { + DistributeAction::Promote => "promote", + DistributeAction::Rollback => "rollback", + _ => "lifecycle", + } + .to_string(), + artifact_manifest: None, + deployment_id: Some(deploy.to_string()), + canonical_url: cloudflare_url(&cfg), + preview_url: None, + custom_domain: non_empty(cfg.custom_domain.clone()), + status: "dry-run".to_string(), + stdout: None, + stderr: None, + manual_follow_up: vec![format!( + "Would make Cloudflare Pages deployment {deploy} live by calling the provider rollback endpoint." + )], + }); + } + let token = release::provider_secret( + DistributionProvider::CloudflarePages, + &["CLOUDFLARE_API_TOKEN"], + )? + .context("CLOUDFLARE_API_TOKEN or Fission vault credentials are required")?; + let url = format!( + "https://api.cloudflare.com/client/v4/accounts/{account_id}/pages/projects/{project_name}/deployments/{deploy}/rollback" + ); + let response = reqwest::blocking::Client::builder() + .user_agent("fission-cli-publish/0.1") + .build()? + .post(url) + .bearer_auth(token) + .send() + .context("failed to rollback Cloudflare Pages deployment")?; + let status = response.status(); + let text = response.text()?; + if !status.is_success() { + bail!("Cloudflare Pages rollback failed with {status}: {text}"); + } + let value: serde_json::Value = serde_json::from_str(&text) + .with_context(|| format!("failed to parse Cloudflare Pages rollback response: {text}"))?; + let result = value.get("result").unwrap_or(&value); + Ok(DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: "cloudflare-pages".to_string(), + site: options.site.clone(), + action: match options.action { + DistributeAction::Promote => "promote", + DistributeAction::Rollback => "rollback", + _ => "lifecycle", + } + .to_string(), + artifact_manifest: None, + deployment_id: result + .get("id") + .and_then(serde_json::Value::as_str) + .map(str::to_string) + .or_else(|| Some(deploy.to_string())), + canonical_url: cloudflare_url(&cfg), + preview_url: result + .get("url") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + custom_domain: non_empty(cfg.custom_domain.clone()), + status: result + .pointer("/latest_stage/status") + .or_else(|| result.get("status")) + .and_then(serde_json::Value::as_str) + .unwrap_or("rollback-requested") + .to_string(), + stdout: Some(serde_json::to_string_pretty(&value)?), + stderr: None, + manual_follow_up: Vec::new(), + }) +} + +fn setup_non_static_provider(options: &DistributeOptions, config: &PublishManifest) -> Result<()> { + let checks = readiness_distribute( + &options.project_dir, + options.provider, + &options.site, + options.track.as_deref(), + options.artifact.as_deref(), + config, + )?; + if options.json { + let report = ReadinessReport { + project_dir: options.project_dir.display().to_string(), + target: None, + format: None, + provider: Some(options.provider.as_str().to_string()), + site: Some(options.site.clone()), + status: report_status(&checks).to_string(), + checks, + }; + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!( + "{} setup checks for `{}`", + options.provider.as_str(), + options.site + ); + print_checks(&checks); + } + Ok(()) +} + +fn setup_github_pages(options: &DistributeOptions, config: &PublishManifest) -> Result<()> { + let cfg = github_config(config, &options.site)?; + let workflow = cfg + .workflow + .clone() + .unwrap_or_else(|| "fission-pages.yml".to_string()); + let workflow_path = github_workflow_path(&options.project_dir, &cfg, &workflow); + let content = render_github_pages_workflow(&options.project_dir, &cfg); + if options.dry_run { + println!("Would write {}:\n{}", workflow_path.display(), content); + return Ok(()); + } + if workflow_path.exists() && !options.yes { + bail!( + "{} already exists; pass --yes to overwrite or edit it manually", + workflow_path.display() + ); + } + if let Some(parent) = workflow_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&workflow_path, content) + .with_context(|| format!("failed to write {}", workflow_path.display()))?; + println!("Wrote {}", workflow_path.display()); + if cfg + .custom_domain + .as_deref() + .filter(|s| !s.is_empty()) + .is_some() + { + println!("Custom domains for GitHub Actions Pages must be configured in repository Pages settings or via the GitHub Pages API."); + } + Ok(()) +} + +fn publish_github_pages( + options: &DistributeOptions, + config: &PublishManifest, + artifact_path: &Path, + manifest: &ArtifactManifest, +) -> Result { + let cfg = github_config(config, &options.site)?; + let mode = cfg.mode.as_deref().unwrap_or("actions"); + match mode { + "branch" => publish_github_pages_branch(options, &cfg, artifact_path, manifest), + "actions" => { + let owner = cfg + .owner + .clone() + .or_else(|| infer_github_owner(&options.project_dir)); + let repo = cfg + .repo + .clone() + .or_else(|| infer_github_repo(&options.project_dir)); + let workflow = cfg + .workflow + .clone() + .unwrap_or_else(|| "fission-pages.yml".to_string()); + let mut follow_up = vec![format!( + "Commit the generated workflow and push the configured production branch so GitHub Actions deploys the exact static site build." + )]; + if let (Some(owner), Some(repo)) = (&owner, &repo) { + follow_up.push(format!( + "Repository Pages URL should be https://{owner}.github.io/{repo}/ unless a custom domain is configured." + )); + } + let workflow_path = github_workflow_path(&options.project_dir, &cfg, &workflow); + if !workflow_path.exists() { + follow_up.push(format!( + "Run `fission distribute setup --provider github-pages --site {} --project-dir {}` to generate {}.", + options.site, + options.project_dir.display(), + workflow_path.display() + )); + } + Ok(DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: "github-pages".to_string(), + site: options.site.clone(), + action: "publish".to_string(), + artifact_manifest: Some(artifact_path.display().to_string()), + deployment_id: None, + canonical_url: github_pages_url(&cfg, owner.as_deref(), repo.as_deref()), + preview_url: None, + custom_domain: non_empty(cfg.custom_domain.clone()), + status: "workflow-required".to_string(), + stdout: None, + stderr: None, + manual_follow_up: follow_up, + }) + } + other => bail!("unsupported github-pages mode `{other}`; expected actions or branch"), + } +} + +fn publish_github_pages_branch( + options: &DistributeOptions, + cfg: &GithubPagesConfig, + artifact_path: &Path, + manifest: &ArtifactManifest, +) -> Result { + let remote = cfg.remote.as_deref().unwrap_or("origin"); + let branch = cfg.source_branch.as_deref().unwrap_or("gh-pages"); + let source_path = cfg.source_path.as_deref().unwrap_or("/"); + let repo_root = git_output(&options.project_dir, ["rev-parse", "--show-toplevel"])?; + let repo_root = PathBuf::from(repo_root.trim()); + let remote_url = git_output(&repo_root, ["remote", "get-url", remote])?; + let worktree = options + .project_dir + .join("target/fission/publish/github-pages") + .join(&options.site); + if options.dry_run { + println!( + "Would publish {} to {remote}:{branch} at {}", + manifest.root_dir, source_path + ); + return Ok(DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: "github-pages".to_string(), + site: options.site.clone(), + action: "publish".to_string(), + artifact_manifest: Some(artifact_path.display().to_string()), + deployment_id: Some(format!("{remote}:{branch}")), + canonical_url: github_pages_url(cfg, cfg.owner.as_deref(), cfg.repo.as_deref()), + preview_url: None, + custom_domain: non_empty(cfg.custom_domain.clone()), + status: "dry-run".to_string(), + stdout: None, + stderr: None, + manual_follow_up: Vec::new(), + }); + } + if worktree.exists() { + fs::remove_dir_all(&worktree) + .with_context(|| format!("failed to clean {}", worktree.display()))?; + } + if let Some(parent) = worktree.parent() { + fs::create_dir_all(parent)?; + } + + let clone_status = Command::new("git") + .args([ + "clone", + "--depth", + "1", + "--branch", + branch, + remote_url.trim(), + ]) + .arg(&worktree) + .status() + .context("failed to run git clone for GitHub Pages branch")?; + if !clone_status.success() { + let status = Command::new("git") + .args(["clone", "--depth", "1", remote_url.trim()]) + .arg(&worktree) + .status() + .context("failed to run git clone for GitHub Pages repository")?; + if !status.success() { + bail!("failed to clone {remote} for GitHub Pages publishing"); + } + run_git(&worktree, ["checkout", "--orphan", branch])?; + } + + let publish_root = if source_path == "/" || source_path == "." { + worktree.clone() + } else { + worktree.join(source_path.trim_start_matches('/')) + }; + clean_publish_root(&publish_root)?; + copy_dir_contents(Path::new(&manifest.root_dir), &publish_root)?; + fs::write(publish_root.join(".nojekyll"), "")?; + if let Some(domain) = non_empty(cfg.custom_domain.clone()) { + fs::write(publish_root.join("CNAME"), format!("{}\n", domain.trim()))?; + } + + run_git(&worktree, ["add", "--all"])?; + let commit = Command::new("git") + .args(["commit", "-m", "Publish Fission static site"]) + .current_dir(&worktree) + .output() + .context("failed to run git commit for GitHub Pages")?; + if !commit.status.success() { + let stderr = String::from_utf8_lossy(&commit.stderr); + if !stderr.contains("nothing to commit") && !stderr.contains("no changes added") { + io::stderr().write_all(&commit.stderr).ok(); + bail!("git commit failed for GitHub Pages publish"); + } + } + run_git(&worktree, ["push", remote, branch])?; + Ok(DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: "github-pages".to_string(), + site: options.site.clone(), + action: "publish".to_string(), + artifact_manifest: Some(artifact_path.display().to_string()), + deployment_id: Some(format!("{remote}:{branch}")), + canonical_url: github_pages_url(cfg, cfg.owner.as_deref(), cfg.repo.as_deref()), + preview_url: None, + custom_domain: non_empty(cfg.custom_domain.clone()), + status: "published".to_string(), + stdout: None, + stderr: None, + manual_follow_up: github_pages_follow_up(cfg), + }) +} + +fn publish_cloudflare_pages( + options: &DistributeOptions, + config: &PublishManifest, + artifact_path: &Path, + manifest: &ArtifactManifest, +) -> Result { + let cfg = cloudflare_config(config, &options.site)?; + let project_name = cfg + .project_name + .as_deref() + .context("distribution.cloudflare_pages..project_name is required")?; + let mut args = vec![ + "pages".to_string(), + "deploy".to_string(), + manifest.root_dir.clone(), + "--project-name".to_string(), + project_name.to_string(), + ]; + if let Some(environment) = cfg + .environment + .as_deref() + .filter(|value| *value != "production") + { + args.push("--branch".to_string()); + args.push(environment.to_string()); + } + run_publish_command( + options, + "cloudflare-pages", + "wrangler", + args, + artifact_path, + || cloudflare_url(&cfg), + ) +} + +fn run_publish_command( + options: &DistributeOptions, + provider: &str, + program: &str, + args: Vec, + artifact_path: &Path, + canonical_url: F, +) -> Result +where + F: FnOnce() -> Option, +{ + if options.dry_run { + println!("Would run: {} {}", program, args.join(" ")); + return Ok(DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: provider.to_string(), + site: options.site.clone(), + action: "publish".to_string(), + artifact_manifest: Some(artifact_path.display().to_string()), + deployment_id: None, + canonical_url: canonical_url(), + preview_url: None, + custom_domain: None, + status: "dry-run".to_string(), + stdout: None, + stderr: None, + manual_follow_up: Vec::new(), + }); + } + let output = Command::new(program) + .args(&args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .with_context(|| { + format!("failed to run {program}; install it or run readiness for remediation") + })?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + if !output.status.success() { + eprint!("{stderr}"); + bail!("{provider} publish failed with {}", output.status); + } + Ok(DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: provider.to_string(), + site: options.site.clone(), + action: "publish".to_string(), + artifact_manifest: Some(artifact_path.display().to_string()), + deployment_id: None, + canonical_url: canonical_url(), + preview_url: first_url(&stdout), + custom_domain: None, + status: "published".to_string(), + stdout: Some(stdout), + stderr: (!stderr.trim().is_empty()).then_some(stderr), + manual_follow_up: Vec::new(), + }) +} + +fn github_pages_status( + options: &DistributeOptions, + config: &PublishManifest, +) -> Result { + let cfg = github_config(config, &options.site)?; + let owner = cfg + .owner + .clone() + .or_else(|| infer_github_owner(&options.project_dir)); + let repo = cfg + .repo + .clone() + .or_else(|| infer_github_repo(&options.project_dir)); + let (Some(owner), Some(repo)) = (owner, repo) else { + bail!("github-pages status requires owner and repo in fission.toml or a GitHub remote"); + }; + command_status_receipt( + options, + "github-pages", + "gh", + vec!["api".to_string(), format!("repos/{owner}/{repo}/pages")], + ) +} + +fn cloudflare_pages_status( + options: &DistributeOptions, + config: &PublishManifest, +) -> Result { + let cfg = cloudflare_config(config, &options.site)?; + let account_id = cfg + .account_id + .clone() + .or_else(|| env::var("CLOUDFLARE_ACCOUNT_ID").ok()) + .context( + "distribution.cloudflare_pages..account_id or CLOUDFLARE_ACCOUNT_ID is required", + )?; + let project_name = cfg + .project_name + .as_deref() + .context("distribution.cloudflare_pages..project_name is required")?; + let token = release::provider_secret( + DistributionProvider::CloudflarePages, + &["CLOUDFLARE_API_TOKEN"], + )? + .context("CLOUDFLARE_API_TOKEN or Fission vault credentials are required")?; + let url = format!( + "https://api.cloudflare.com/client/v4/accounts/{account_id}/pages/projects/{project_name}/deployments" + ); + let response = reqwest::blocking::Client::builder() + .user_agent("fission-cli-publish/0.1") + .build()? + .get(url) + .bearer_auth(token) + .send() + .context("failed to query Cloudflare Pages deployments")?; + let status = response.status(); + let text = response.text()?; + if !status.is_success() { + bail!("Cloudflare Pages status failed with {status}: {text}"); + } + let value: serde_json::Value = serde_json::from_str(&text) + .with_context(|| format!("failed to parse Cloudflare Pages status response: {text}"))?; + let latest = value + .get("result") + .and_then(serde_json::Value::as_array) + .and_then(|items| items.first()); + Ok(DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: "cloudflare-pages".to_string(), + site: options.site.clone(), + action: "status".to_string(), + artifact_manifest: None, + deployment_id: latest + .and_then(|item| item.get("id")) + .and_then(serde_json::Value::as_str) + .map(str::to_string), + canonical_url: cloudflare_url(&cfg), + preview_url: latest + .and_then(|item| item.get("url")) + .and_then(serde_json::Value::as_str) + .map(str::to_string), + custom_domain: non_empty(cfg.custom_domain.clone()), + status: latest + .and_then(|item| item.pointer("/latest_stage/status")) + .or_else(|| latest.and_then(|item| item.get("deployment_trigger"))) + .and_then(serde_json::Value::as_str) + .unwrap_or("ok") + .to_string(), + stdout: Some(serde_json::to_string_pretty(&value)?), + stderr: None, + manual_follow_up: Vec::new(), + }) +} + +fn command_status_receipt( + options: &DistributeOptions, + provider: &str, + program: &str, + args: Vec, +) -> Result { + let output = Command::new(program) + .args(&args) + .output() + .with_context(|| format!("failed to run {program}; install it or authenticate first"))?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + Ok(DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: provider.to_string(), + site: options.site.clone(), + action: "status".to_string(), + artifact_manifest: None, + deployment_id: options.deploy.clone(), + canonical_url: first_url(&stdout), + preview_url: None, + custom_domain: None, + status: if output.status.success() { + "ok" + } else { + "failed" + } + .to_string(), + stdout: Some(stdout), + stderr: (!stderr.trim().is_empty()).then_some(stderr), + manual_follow_up: Vec::new(), + }) +} + +fn readiness_package( + project_dir: &Path, + target: Option, + format: Option, +) -> Result> { + let target = target.unwrap_or(Target::Site); + let format = format.unwrap_or(PackageFormat::Static); + let mut checks = Vec::new(); + let format_supported = package_format_supported(target, format); + checks.push(check( + "release.package.format_supported", + CheckSeverity::Error, + if format_supported { + CheckStatus::Passed + } else { + CheckStatus::Failed + }, + "package format is supported for the selected target", + Some(format!( + "--target {} --format {}", + target.as_str(), + format.as_str() + )), + vec!["Use a valid target/format pair, such as site/static, linux/run, macos/app, macos/pkg, windows/exe, windows/msi, windows/msix, android/apk, android/aab, or ios/ipa."], + )); + checks.push(check_path( + "release.package.fission_toml_exists", + project_dir.join("fission.toml"), + "fission.toml exists", + "Run `fission init .` or point --project-dir at a Fission project.", + )); + if let Ok(project) = crate::read_project_config(project_dir) { + checks.push(check( + "release.package.target_configured", + CheckSeverity::Error, + if project.targets.contains(&target) { + CheckStatus::Passed + } else { + CheckStatus::Missing + }, + "target is configured in fission.toml", + Some(format!("target = {}", target.as_str())), + vec!["Run `fission add-target --project-dir .` before packaging."], + )); + } + if matches!(target, Target::Site) { + let has_content = project_dir.join("content").exists(); + let has_entry = load_publish_manifest(project_dir) + .ok() + .and_then(|manifest| manifest.site.and_then(|site| site.entry)) + .is_some_and(|entry| !entry.trim().is_empty()); + checks.push(check( + "release.package.site_content_or_entry", + CheckSeverity::Error, + if has_content || has_entry { + CheckStatus::Passed + } else { + CheckStatus::Missing + }, + "default content directory exists or custom site entry handles routing", + Some(format!( + "content: {}, site.entry: {}", + project_dir.join("content").display(), + has_entry + )), + vec!["Add content/ or configure [site].entry for a custom static site."], + )); + } + readiness_package_tools(project_dir, target, format, &mut checks); + package::readiness_secondary_artifacts(project_dir, &mut checks); + Ok(checks) +} + +fn package_format_supported(target: Target, format: PackageFormat) -> bool { + matches!( + (target, format), + (Target::Site, PackageFormat::Static) + | (Target::Web, PackageFormat::Static) + | (Target::Linux, PackageFormat::Run) + | (Target::Macos, PackageFormat::App) + | (Target::Macos, PackageFormat::Pkg) + | (Target::Windows, PackageFormat::Exe) + | (Target::Windows, PackageFormat::Msi) + | (Target::Windows, PackageFormat::Msix) + | (Target::Android, PackageFormat::Apk) + | (Target::Android, PackageFormat::Aab) + | (Target::Ios, PackageFormat::Ipa) + ) +} + +fn readiness_package_tools( + project_dir: &Path, + target: Target, + format: PackageFormat, + checks: &mut Vec, +) { + match (target, format) { + (Target::Site, PackageFormat::Static) | (Target::Web, PackageFormat::Static) => { + checks.push(check_tool( + "release.package.cargo_available", + "cargo", + "Install Rust from https://rustup.rs/ and ensure cargo is on PATH.", + )); + } + (Target::Linux, PackageFormat::Run) => { + checks.push(host_os_check("release.package.host_is_linux", "linux")); + checks.push(check_tool( + "release.package.cargo_available", + "cargo", + "Install Rust from https://rustup.rs/ and ensure cargo is on PATH.", + )); + } + (Target::Macos, PackageFormat::App) => { + checks.push(host_os_check("release.package.host_is_macos", "macos")); + checks.push(check_tool( + "release.package.cargo_available", + "cargo", + "Install Rust from https://rustup.rs/ and ensure cargo is on PATH.", + )); + checks.push(check_tool( + "release.package.codesign_available", + "codesign", + "Install Xcode command line tools so Fission can verify signed .app bundles.", + )); + } + (Target::Macos, PackageFormat::Pkg) => { + checks.push(host_os_check("release.package.host_is_macos", "macos")); + checks.push(check_tool( + "release.package.cargo_available", + "cargo", + "Install Rust from https://rustup.rs/ and ensure cargo is on PATH.", + )); + checks.push(check_tool( + "release.package.pkgbuild_available", + "pkgbuild", + "Install Xcode command line tools.", + )); + checks.push(check_tool( + "release.package.productbuild_available", + "productbuild", + "Install Xcode command line tools.", + )); + checks.push(check_tool( + "release.package.pkgutil_available", + "pkgutil", + "Install macOS package tools so Fission can inspect produced .pkg files.", + )); + } + (Target::Windows, PackageFormat::Exe) => { + checks.push(host_os_check("release.package.host_is_windows", "windows")); + checks.push(check_tool( + "release.package.cargo_available", + "cargo", + "Install Rust from https://rustup.rs/ and ensure cargo is on PATH.", + )); + } + (Target::Windows, PackageFormat::Msi) => { + checks.push(host_os_check("release.package.host_is_windows", "windows")); + checks.push(check_path( + "release.package.windows_msi_script_exists", + project_dir.join("platforms/windows/package-msi.ps1"), + "Windows MSI packaging script exists", + "Configure platforms/windows/package-msi.ps1 or install the Windows packaging target template.", + )); + checks.push(check_any_tool( + "release.package.windows_msi_builder_available", + &["wix", "candle"], + "WiX MSI packaging tooling is available", + "Install WiX Toolset or configure platforms/windows/package-msi.ps1 to call the approved MSI packager.", + )); + checks.push(check_tool( + "release.package.signtool_available", + "signtool", + "Install Windows SDK signing tools and ensure signtool is on PATH.", + )); + } + (Target::Windows, PackageFormat::Msix) => { + checks.push(host_os_check("release.package.host_is_windows", "windows")); + checks.push(check_path( + "release.package.windows_msix_script_exists", + project_dir.join("platforms/windows/package-msix.ps1"), + "Windows MSIX packaging script exists", + "Configure platforms/windows/package-msix.ps1 or install the Windows packaging target template.", + )); + checks.push(check_tool( + "release.package.makeappx_available", + "makeappx", + "Install Windows SDK MSIX packaging tools and ensure makeappx is on PATH.", + )); + checks.push(check_tool( + "release.package.signtool_available", + "signtool", + "Install Windows SDK signing tools and ensure signtool is on PATH.", + )); + } + (Target::Android, PackageFormat::Apk) => { + checks.push(check_path( + "release.package.android_apk_script_exists", + project_dir.join("platforms/android/package-apk.sh"), + "Android APK packaging script exists", + "Run `fission add-target android --project-dir .` or restore platforms/android/package-apk.sh.", + )); + android_packaging_checks(checks); + } + (Target::Android, PackageFormat::Aab) => { + checks.push(check_path( + "release.package.android_aab_script_exists", + project_dir.join("platforms/android/package-aab.sh"), + "Android AAB packaging script exists", + "Add platforms/android/package-aab.sh once release AAB packaging is configured.", + )); + android_packaging_checks(checks); + checks.push(check_env_or_tool( + "release.package.bundletool_available", + &["BUNDLETOOL"], + &["bundletool"], + "Android bundletool is available for AAB validation", + "Install bundletool or set BUNDLETOOL to the bundletool jar/path used by the project packaging script.", + )); + } + (Target::Ios, PackageFormat::Ipa) => { + checks.push(host_os_check("release.package.host_is_macos", "macos")); + checks.push(check_path( + "release.package.ios_ipa_script_exists", + project_dir.join("platforms/ios/package-ipa.sh"), + "iOS IPA packaging script exists", + "Add platforms/ios/package-ipa.sh once release IPA export is configured.", + )); + checks.push(check_tool( + "release.package.xcrun_available", + "xcrun", + "Install Xcode command line tools and select an Xcode installation.", + )); + checks.push(check_tool( + "release.package.xcodebuild_available", + "xcodebuild", + "Install Xcode so Fission can archive and export iOS IPA files.", + )); + checks.push(check_tool( + "release.package.codesign_available", + "codesign", + "Install Xcode command line tools so Fission can verify iOS signing.", + )); + } + _ => {} + } +} + +fn android_packaging_checks(checks: &mut Vec) { + checks.push(check_tool( + "release.package.cargo_available", + "cargo", + "Install Rust from https://rustup.rs/ and ensure cargo is on PATH.", + )); + checks.push(check_any_env( + "release.package.android_sdk_configured", + &["ANDROID_HOME", "ANDROID_SDK_ROOT"], + "Android SDK path is configured", + "Set ANDROID_HOME or ANDROID_SDK_ROOT to the installed Android SDK.", + )); + checks.push(check_any_env( + "release.package.android_ndk_configured", + &["ANDROID_NDK_HOME", "ANDROID_NDK_ROOT"], + "Android NDK path is configured", + "Set ANDROID_NDK_HOME or ANDROID_NDK_ROOT to the installed Android NDK used by Rust cross-compilation.", + )); + checks.push(check_tool( + "release.package.aapt2_available", + "aapt2", + "Install Android SDK build-tools and ensure aapt2 is on PATH.", + )); + checks.push(check_tool( + "release.package.zipalign_available", + "zipalign", + "Install Android SDK build-tools and ensure zipalign is on PATH.", + )); + checks.push(check_tool( + "release.package.apksigner_available", + "apksigner", + "Install Android SDK build-tools and ensure apksigner is on PATH.", + )); +} + +fn readiness_distribute( + project_dir: &Path, + provider: DistributionProvider, + site: &str, + track: Option<&str>, + artifact: Option<&Path>, + config: &PublishManifest, +) -> Result> { + let mut checks = Vec::new(); + if let Some(path) = artifact { + checks.push(check_path( + "release.distribution.artifact_manifest_exists", + path.to_path_buf(), + "artifact manifest exists", + "Run `fission package --target site --format static --release` first.", + )); + if path.exists() { + let manifest = read_artifact_manifest(path)?; + if provider_requires_static_root(provider) { + checks.push(check( + "release.distribution.static_root_exists", + CheckSeverity::Error, + if Path::new(&manifest.root_dir).join("index.html").exists() { + CheckStatus::Passed + } else { + CheckStatus::Missing + }, + "static artifact root contains index.html", + Some(manifest.root_dir), + vec!["Rebuild the static package and ensure the output includes index.html."], + )); + } + } + } + + match provider { + DistributionProvider::GithubPages => { + readiness_github_pages(project_dir, site, config, &mut checks)? + } + DistributionProvider::GithubReleases => { + github_releases::readiness(project_dir, site, artifact, config, &mut checks)? + } + DistributionProvider::CloudflarePages => { + readiness_cloudflare_pages(site, config, &mut checks)? + } + DistributionProvider::Netlify => readiness_netlify(site, config, &mut checks)?, + DistributionProvider::S3 => files::readiness_s3(site, config, &mut checks)?, + DistributionProvider::GoogleDrive => { + files::readiness_google_drive(site, config, &mut checks)? + } + DistributionProvider::OneDrive => files::readiness_onedrive(site, config, &mut checks)?, + DistributionProvider::Dropbox => files::readiness_dropbox(site, config, &mut checks)?, + DistributionProvider::PlayStore => { + stores::readiness_play_store(track, artifact, config, &mut checks)? + } + DistributionProvider::AppStore => { + stores::readiness_app_store(track, artifact, config, &mut checks)? + } + DistributionProvider::MicrosoftStore => { + stores::readiness_microsoft_store(track, artifact, config, &mut checks)? + } + } + Ok(checks) +} + +fn provider_requires_static_root(provider: DistributionProvider) -> bool { + matches!( + provider, + DistributionProvider::GithubPages + | DistributionProvider::CloudflarePages + | DistributionProvider::Netlify + ) +} + +fn readiness_github_pages( + project_dir: &Path, + site: &str, + config: &PublishManifest, + checks: &mut Vec, +) -> Result<()> { + let cfg = github_config(config, site)?; + let owner = cfg + .owner + .clone() + .or_else(|| infer_github_owner(project_dir)); + let repo = cfg.repo.clone().or_else(|| infer_github_repo(project_dir)); + checks.push(required_value( + "release.github_pages.owner_configured", + owner.as_deref(), + "GitHub owner is configured or inferable from git remote", + "Set distribution.github_pages..owner or configure an origin GitHub remote.", + )); + checks.push(required_value( + "release.github_pages.repo_configured", + repo.as_deref(), + "GitHub repository is configured or inferable from git remote", + "Set distribution.github_pages..repo or configure an origin GitHub remote.", + )); + let mode = cfg.mode.as_deref().unwrap_or("actions"); + checks.push(check( + "release.github_pages.mode_supported", + CheckSeverity::Error, + if matches!(mode, "actions" | "branch" | "manual") { + CheckStatus::Passed + } else { + CheckStatus::Failed + }, + "GitHub Pages mode is supported", + Some(mode.to_string()), + vec!["Use mode = \"actions\", \"branch\", or \"manual\"."], + )); + if mode == "branch" { + checks.push(check_tool( + "release.github_pages.git_available", + "git", + "Install Git and authenticate to the repository remote.", + )); + } else { + checks.push(check( + "release.github_pages.source_is_actions", + CheckSeverity::Warning, + if cfg.source.as_deref().unwrap_or("github-actions") == "github-actions" { + CheckStatus::Passed + } else { + CheckStatus::Warning + }, + "GitHub Pages source is configured for Actions publishing", + cfg.source.clone(), + vec!["Set distribution.github_pages..source = \"github-actions\" for Actions-based Pages publishing."], + )); + let workflow = cfg.workflow.as_deref().unwrap_or("fission-pages.yml"); + checks.push(check_path( + "release.github_pages.workflow_exists", + github_workflow_path(project_dir, &cfg, workflow), + "GitHub Pages workflow exists", + "Run `fission distribute setup --provider github-pages --site production` to generate it.", + )); + checks.push(check( + "release.github_pages.local_api_token_optional", + CheckSeverity::Info, + if env::var_os("GH_TOKEN").is_some() + || env::var_os("GITHUB_TOKEN").is_some() + || release::provider_secret(DistributionProvider::GithubPages, &[]) + .ok() + .flatten() + .is_some() + { + CheckStatus::Passed + } else { + CheckStatus::Skipped + }, + "GitHub API token is available for local status/domain setup", + None, + vec!["For local Pages status or future domain setup automation, set GH_TOKEN/GITHUB_TOKEN or import a GitHub credential into the Fission vault."], + )); + } + let base = cfg.base_path.as_deref().unwrap_or("/"); + let expected = expected_github_base_path(&cfg, repo.as_deref()); + checks.push(check( + "release.github_pages.base_path_matches_domain_mode", + CheckSeverity::Warning, + if base == expected { CheckStatus::Passed } else { CheckStatus::Warning }, + "GitHub Pages base path matches custom-domain/project-site mode", + Some(format!("configured {base}, expected {expected}")), + vec!["Set distribution.github_pages..base_path to the expected value or adjust the site renderer base URL."], + )); + checks.push(check( + "release.github_pages.https_policy_set", + CheckSeverity::Info, + if cfg.enforce_https.unwrap_or(true) { + CheckStatus::Passed + } else { + CheckStatus::Warning + }, + "GitHub Pages HTTPS policy is explicit", + Some(format!("enforce_https = {}", cfg.enforce_https.unwrap_or(true))), + vec!["Keep enforce_https = true for public production sites unless there is a provider limitation."], + )); + Ok(()) +} + +fn readiness_cloudflare_pages( + site: &str, + config: &PublishManifest, + checks: &mut Vec, +) -> Result<()> { + let cfg = cloudflare_config(config, site)?; + let env_account_id = env::var("CLOUDFLARE_ACCOUNT_ID").ok(); + checks.push(required_value( + "release.cloudflare_pages.account_id_configured", + cfg.account_id.as_deref().or(env_account_id.as_deref()), + "Cloudflare account id is configured", + "Set distribution.cloudflare_pages..account_id or CLOUDFLARE_ACCOUNT_ID.", + )); + checks.push(required_value( + "release.cloudflare_pages.project_name_configured", + cfg.project_name.as_deref(), + "Cloudflare Pages project name is configured", + "Set distribution.cloudflare_pages..project_name.", + )); + checks.push(required_provider_secret( + "release.cloudflare_pages.token_available", + DistributionProvider::CloudflarePages, + &["CLOUDFLARE_API_TOKEN"], + "Create a Cloudflare API token with Pages Edit permission and store it in CI secrets or the Fission release vault.", + )); + checks.push(check_tool( + "release.cloudflare_pages.wrangler_available", + "wrangler", + "Install Wrangler and authenticate it; Cloudflare Pages upload intentionally uses the provider CLI backend.", + )); + checks.push(base_path_check( + "release.cloudflare_pages.base_path_root", + cfg.base_path.as_deref(), + )); + Ok(()) +} + +fn readiness_netlify( + site: &str, + config: &PublishManifest, + checks: &mut Vec, +) -> Result<()> { + let cfg = netlify_config(config, site)?; + checks.push(required_value( + "release.netlify.site_configured", + cfg.site_id.as_deref(), + "Netlify site id is configured", + "Set distribution.netlify..site_id or run provider setup after creating a Netlify site.", + )); + checks.push(required_provider_secret( + "release.netlify.token_available", + DistributionProvider::Netlify, + &["NETLIFY_AUTH_TOKEN"], + "Create a Netlify access token and store it in CI secrets, your shell environment, or the Fission release vault.", + )); + checks.push(base_path_check( + "release.netlify.base_path_root", + cfg.base_path.as_deref(), + )); + Ok(()) +} + +fn build_artifact_manifest( + project: &FissionProject, + options: &PackageOptions, + root: &Path, + profile: &str, +) -> Result { + let mut files = Vec::new(); + collect_artifacts(root, root, &mut files)?; + files.sort_by(|a, b| a.relative_path.cmp(&b.relative_path)); + Ok(ArtifactManifest { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + project: ArtifactProject { + app_id: project.app.app_id.clone(), + name: project.app.name.clone(), + version: cargo_package_version(&options.project_dir), + }, + target: options.target.as_str().to_string(), + format: options.format.as_str().to_string(), + profile: profile.to_string(), + root_dir: root.display().to_string(), + artifacts: files, + validation: ArtifactValidation { + state: "passed".to_string(), + checks: Vec::new(), + }, + }) +} + +fn collect_artifacts(root: &Path, current: &Path, files: &mut Vec) -> Result<()> { + for entry in + fs::read_dir(current).with_context(|| format!("failed to read {}", current.display()))? + { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + if file_type.is_dir() { + if entry.file_name() == ".git" { + continue; + } + collect_artifacts(root, &path, files)?; + } else if file_type.is_file() { + if path.file_name().and_then(OsStr::to_str) == Some(ARTIFACT_MANIFEST) { + continue; + } + let relative = path + .strip_prefix(root)? + .to_string_lossy() + .replace('\\', "/"); + let (sha256, size_bytes) = hash_file(&path)?; + files.push(ArtifactFile { + kind: if relative == "index.html" { + "entry" + } else { + "asset" + } + .to_string(), + purpose: None, + platform: None, + upload_provider: None, + path: path.display().to_string(), + relative_path: relative, + sha256, + size_bytes, + mime_type: content_type(&path).to_string(), + }); + } + } + Ok(()) +} + +fn hash_file(path: &Path) -> Result<(String, u64)> { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut size = 0u64; + let mut buf = [0u8; 8192]; + loop { + let read = file.read(&mut buf)?; + if read == 0 { + break; + } + size += read as u64; + hasher.update(&buf[..read]); + } + Ok((hex_lower(&hasher.finalize()), size)) +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0xf) as usize] as char); + } + out +} + +fn content_type(path: &Path) -> &'static str { + match path.extension().and_then(OsStr::to_str).unwrap_or("") { + "html" => "text/html; charset=utf-8", + "css" => "text/css; charset=utf-8", + "js" | "mjs" => "text/javascript; charset=utf-8", + "wasm" => "application/wasm", + "json" | "webmanifest" => "application/json; charset=utf-8", + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "svg" => "image/svg+xml", + "ico" => "image/x-icon", + "txt" => "text/plain; charset=utf-8", + "xml" => "application/xml; charset=utf-8", + _ => "application/octet-stream", + } +} + +fn copy_dir_contents(source: &Path, dest: &Path) -> Result<()> { + for entry in + fs::read_dir(source).with_context(|| format!("failed to read {}", source.display()))? + { + let entry = entry?; + let source_path = entry.path(); + let dest_path = dest.join(entry.file_name()); + if entry.file_type()?.is_dir() { + fs::create_dir_all(&dest_path)?; + copy_dir_contents(&source_path, &dest_path)?; + } else { + if let Some(parent) = dest_path.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(&source_path, &dest_path).with_context(|| { + format!( + "failed to copy {} to {}", + source_path.display(), + dest_path.display() + ) + })?; + } + } + Ok(()) +} + +fn clean_publish_root(root: &Path) -> Result<()> { + fs::create_dir_all(root)?; + for entry in fs::read_dir(root)? { + let entry = entry?; + if entry.file_name() == ".git" { + continue; + } + let path = entry.path(); + if entry.file_type()?.is_dir() { + fs::remove_dir_all(path)?; + } else { + fs::remove_file(path)?; + } + } + Ok(()) +} + +fn load_publish_manifest(project_dir: &Path) -> Result { + let path = project_dir.join("fission.toml"); + let data = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display())) +} + +fn site_output_dir(project_dir: &Path) -> Result { + let manifest = load_publish_manifest(project_dir)?; + Ok(manifest + .site + .and_then(|site| site.out_dir) + .map(|path| resolve_project_path(project_dir, path)) + .unwrap_or_else(|| project_dir.join("target/fission/site"))) +} + +fn read_artifact_manifest(path: &Path) -> Result { + let data = fs::read_to_string(path) + .with_context(|| format!("failed to read artifact manifest {}", path.display()))?; + serde_json::from_str(&data) + .with_context(|| format!("failed to parse artifact manifest {}", path.display())) +} + +fn default_artifact_manifest_path(project_dir: &Path, target: Target, release: bool) -> PathBuf { + project_dir + .join("target/fission") + .join(if release { "release" } else { "debug" }) + .join(target.as_str()) + .join("static") + .join(ARTIFACT_MANIFEST) +} + +fn github_config(config: &PublishManifest, site: &str) -> Result { + Ok(config + .distribution + .as_ref() + .and_then(|distribution| distribution.github_pages.get(site)) + .cloned() + .unwrap_or_default()) +} + +fn github_releases_config(config: &PublishManifest, site: &str) -> Result { + config + .distribution + .as_ref() + .and_then(|distribution| distribution.github_releases.get(site)) + .cloned() + .with_context(|| format!("missing [distribution.github_releases.{site}] in fission.toml")) +} + +fn s3_config(config: &PublishManifest, site: &str) -> Result { + config + .distribution + .as_ref() + .and_then(|distribution| distribution.s3.get(site)) + .cloned() + .with_context(|| format!("missing [distribution.s3.{site}] in fission.toml")) +} + +fn google_drive_config(config: &PublishManifest, site: &str) -> Result { + Ok(config + .distribution + .as_ref() + .and_then(|distribution| distribution.google_drive.get(site)) + .cloned() + .unwrap_or_default()) +} + +fn onedrive_config(config: &PublishManifest, site: &str) -> Result { + Ok(config + .distribution + .as_ref() + .and_then(|distribution| distribution.onedrive.get(site)) + .cloned() + .unwrap_or_default()) +} + +fn dropbox_config(config: &PublishManifest, site: &str) -> Result { + Ok(config + .distribution + .as_ref() + .and_then(|distribution| distribution.dropbox.get(site)) + .cloned() + .unwrap_or_default()) +} + +fn cloudflare_config(config: &PublishManifest, site: &str) -> Result { + config + .distribution + .as_ref() + .and_then(|distribution| distribution.cloudflare_pages.get(site)) + .cloned() + .with_context(|| format!("missing [distribution.cloudflare_pages.{site}] in fission.toml")) +} + +fn netlify_config(config: &PublishManifest, site: &str) -> Result { + config + .distribution + .as_ref() + .and_then(|distribution| distribution.netlify.get(site)) + .cloned() + .with_context(|| format!("missing [distribution.netlify.{site}] in fission.toml")) +} + +fn github_workflow_path(project_dir: &Path, _cfg: &GithubPagesConfig, workflow: &str) -> PathBuf { + git_repo_root(project_dir) + .unwrap_or_else(|| project_dir.to_path_buf()) + .join(".github/workflows") + .join(workflow) +} + +fn project_dir_argument_for_workflow(project_dir: &Path) -> String { + let Some(repo_root) = git_repo_root(project_dir) else { + return ".".to_string(); + }; + let Ok(project_dir) = fs::canonicalize(project_dir) else { + return ".".to_string(); + }; + let Ok(repo_root) = fs::canonicalize(repo_root) else { + return ".".to_string(); + }; + if project_dir == repo_root { + ".".to_string() + } else { + project_dir + .strip_prefix(&repo_root) + .map(|path| path.to_string_lossy().replace('\\', "/")) + .unwrap_or_else(|_| ".".to_string()) + } +} + +fn render_github_pages_workflow(project_dir: &Path, cfg: &GithubPagesConfig) -> String { + let branch = cfg.production_branch.as_deref().unwrap_or("main"); + let package_project_dir = project_dir_argument_for_workflow(project_dir); + let artifact_path = if package_project_dir == "." { + "target/fission/release/site/static".to_string() + } else { + format!("{package_project_dir}/target/fission/release/site/static") + }; + format!( + r#"name: Publish Fission site + +on: + push: + branches: + - {branch} + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: github-pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + environment: + name: github-pages + steps: + - name: Check out repository + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + + - name: Build Fission static package + run: cargo fission package --project-dir {package_project_dir} --target site --format static --release + + - name: Upload GitHub Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: {artifact_path} + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{{{ steps.deployment.outputs.page_url }}}} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 +"#, + branch = branch, + package_project_dir = package_project_dir, + artifact_path = artifact_path, + ) +} + +fn write_receipt(project_dir: &Path, receipt: &DistributionReceipt) -> Result<()> { + let dir = project_dir + .join("target/fission/distribution") + .join(&receipt.provider) + .join(&receipt.site); + fs::create_dir_all(&dir)?; + let path = dir.join(format!( + "{}-{}.json", + receipt.action, receipt.created_at_unix_seconds + )); + fs::write(&path, serde_json::to_vec_pretty(receipt)?) + .with_context(|| format!("failed to write {}", path.display()))?; + Ok(()) +} + +fn print_readiness_report(report: &ReadinessReport) { + println!("Readiness: {}", report.status); + print_checks(&report.checks); +} + +fn print_checks(checks: &[ReadinessCheck]) { + for check in checks { + println!( + "[{:?}/{:?}] {} - {}", + check.severity, check.status, check.id, check.summary + ); + if let Some(details) = &check.details { + println!(" {details}"); + } + for remediation in &check.remediation { + println!(" fix: {remediation}"); + } + } +} + +fn report_status(checks: &[ReadinessCheck]) -> &'static str { + if checks + .iter() + .any(|check| check.severity == CheckSeverity::Error && check.status != CheckStatus::Passed) + { + "blocked" + } else if checks + .iter() + .any(|check| check.status == CheckStatus::Warning) + { + "warning" + } else { + "ready" + } +} + +fn check( + id: impl Into, + severity: CheckSeverity, + status: CheckStatus, + summary: impl Into, + details: Option, + remediation: Vec<&str>, +) -> ReadinessCheck { + ReadinessCheck { + id: id.into(), + severity, + status, + summary: summary.into(), + details, + remediation: remediation.into_iter().map(str::to_string).collect(), + } +} + +fn check_path(id: &str, path: PathBuf, summary: &str, remediation: &str) -> ReadinessCheck { + check( + id, + CheckSeverity::Error, + if path.exists() { + CheckStatus::Passed + } else { + CheckStatus::Missing + }, + summary, + Some(path.display().to_string()), + vec![remediation], + ) +} + +fn required_value( + id: &str, + value: Option<&str>, + summary: &str, + remediation: &str, +) -> ReadinessCheck { + check( + id, + CheckSeverity::Error, + if value.is_some_and(|value| !value.trim().is_empty()) { + CheckStatus::Passed + } else { + CheckStatus::Missing + }, + summary, + value.map(str::to_string), + vec![remediation], + ) +} + +fn required_provider_secret( + id: &str, + provider: DistributionProvider, + env_names: &[&str], + remediation: &str, +) -> ReadinessCheck { + let env_name = env_names.iter().find(|name| env::var_os(name).is_some()); + let vault_present = release::provider_secret(provider, &[]) + .ok() + .flatten() + .is_some(); + check( + id, + CheckSeverity::Error, + if env_name.is_some() || vault_present { + CheckStatus::Passed + } else { + CheckStatus::Missing + }, + "provider credentials are available", + env_name + .map(|name| format!("environment variable {name}")) + .or_else(|| vault_present.then(|| "Fission release vault".to_string())), + vec![remediation], + ) +} + +fn base_path_check(id: &str, base_path: Option<&str>) -> ReadinessCheck { + let value = base_path.unwrap_or("/"); + check( + id, + CheckSeverity::Warning, + if value == "/" { + CheckStatus::Passed + } else { + CheckStatus::Warning + }, + "static hosting provider base path is root", + Some(format!("base_path = {value}")), + vec!["Dedicated static hosting providers usually serve production sites from `/`; use a non-root base path only when deliberately hosting below a subpath."], + ) +} + +fn host_os_check(id: &str, expected: &str) -> ReadinessCheck { + let current = env::consts::OS; + check( + id, + CheckSeverity::Error, + if current == expected { + CheckStatus::Passed + } else { + CheckStatus::Failed + }, + format!("host operating system is {expected}"), + Some(format!("current host: {current}")), + vec!["Run this package format on the platform that owns the native packaging/signing toolchain."], + ) +} + +fn check_tool(id: &str, tool: &str, remediation: &str) -> ReadinessCheck { + check( + id, + CheckSeverity::Error, + if find_in_path(tool).is_some() { + CheckStatus::Passed + } else { + CheckStatus::Missing + }, + format!("{tool} is available on PATH"), + find_in_path(tool).map(|path| path.display().to_string()), + vec![remediation], + ) +} + +fn check_any_tool(id: &str, tools: &[&str], summary: &str, remediation: &str) -> ReadinessCheck { + let found = tools + .iter() + .find_map(|tool| find_in_path(tool).map(|path| (*tool, path))); + check( + id, + CheckSeverity::Error, + if found.is_some() { + CheckStatus::Passed + } else { + CheckStatus::Missing + }, + summary, + found + .map(|(tool, path)| format!("{tool}: {}", path.display())) + .or_else(|| Some(format!("checked: {}", tools.join(", ")))), + vec![remediation], + ) +} + +fn check_any_env(id: &str, names: &[&str], summary: &str, remediation: &str) -> ReadinessCheck { + let found = names.iter().find(|name| env::var_os(name).is_some()); + check( + id, + CheckSeverity::Error, + if found.is_some() { + CheckStatus::Passed + } else { + CheckStatus::Missing + }, + summary, + found.map(|name| format!("{name}={}", env::var(name).unwrap_or_default())), + vec![remediation], + ) +} + +fn check_env_or_tool( + id: &str, + env_names: &[&str], + tools: &[&str], + summary: &str, + remediation: &str, +) -> ReadinessCheck { + let found_env = env_names.iter().find(|name| env::var_os(name).is_some()); + let found_tool = tools + .iter() + .find_map(|tool| find_in_path(tool).map(|path| (*tool, path))); + check( + id, + CheckSeverity::Error, + if found_env.is_some() || found_tool.is_some() { + CheckStatus::Passed + } else { + CheckStatus::Missing + }, + summary, + found_env + .map(|name| format!("{name}={}", env::var(name).unwrap_or_default())) + .or_else(|| found_tool.map(|(tool, path)| format!("{tool}: {}", path.display()))), + vec![remediation], + ) +} + +fn find_in_path(name: &str) -> Option { + let path = env::var_os("PATH")?; + for dir in env::split_paths(&path) { + let candidate = dir.join(name); + if candidate.exists() { + return Some(candidate); + } + if cfg!(windows) { + for extension in ["exe", "cmd", "bat", "ps1"] { + let candidate = dir.join(format!("{name}.{extension}")); + if candidate.exists() { + return Some(candidate); + } + } + } + } + None +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn cargo_package_version(project_dir: &Path) -> Option { + let data = fs::read_to_string(project_dir.join("Cargo.toml")).ok()?; + let value: toml::Value = toml::from_str(&data).ok()?; + value + .get("package") + .and_then(|package| package.get("version")) + .and_then(|version| version.as_str()) + .map(str::to_string) +} + +fn resolve_project_path(project_dir: &Path, path: String) -> PathBuf { + let path = PathBuf::from(path); + if path.is_absolute() { + path + } else { + project_dir.join(path) + } +} + +fn non_empty(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_string()) + }) +} + +fn expected_github_base_path(cfg: &GithubPagesConfig, repo: Option<&str>) -> String { + if cfg + .custom_domain + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + { + "/".to_string() + } else if cfg.site_kind.as_deref() == Some("user") + || cfg.site_kind.as_deref() == Some("organization") + { + "/".to_string() + } else { + repo.map(|repo| format!("/{repo}/")) + .unwrap_or_else(|| "/".to_string()) + } +} + +fn github_pages_url( + cfg: &GithubPagesConfig, + owner: Option<&str>, + repo: Option<&str>, +) -> Option { + if let Some(domain) = cfg + .custom_domain + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + return Some(format!("https://{}", domain.trim())); + } + let owner = owner?; + if cfg.site_kind.as_deref() == Some("user") || cfg.site_kind.as_deref() == Some("organization") + { + Some(format!("https://{owner}.github.io/")) + } else { + repo.map(|repo| format!("https://{owner}.github.io/{repo}/")) + } +} + +fn cloudflare_url(cfg: &CloudflarePagesConfig) -> Option { + if let Some(domain) = cfg + .custom_domain + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + Some(format!("https://{}", domain.trim())) + } else { + cfg.project_name + .as_ref() + .map(|name| format!("https://{name}.pages.dev")) + } +} + +fn github_pages_follow_up(cfg: &GithubPagesConfig) -> Vec { + let mut follow_up = Vec::new(); + if cfg + .custom_domain + .as_deref() + .filter(|value| !value.is_empty()) + .is_some() + { + follow_up.push( + "Verify the GitHub Pages custom domain and HTTPS state in repository settings." + .to_string(), + ); + } + follow_up +} + +fn infer_github_owner(project_dir: &Path) -> Option { + parse_github_remote(project_dir).map(|(owner, _)| owner) +} + +fn infer_github_repo(project_dir: &Path) -> Option { + parse_github_remote(project_dir).map(|(_, repo)| repo) +} + +fn parse_github_remote(project_dir: &Path) -> Option<(String, String)> { + let remote = git_output(project_dir, ["remote", "get-url", "origin"]).ok()?; + let remote = remote.trim().trim_end_matches(".git"); + if let Some(rest) = remote.strip_prefix("git@github.com:") { + let (owner, repo) = rest.split_once('/')?; + return Some((owner.to_string(), repo.to_string())); + } + if let Some(rest) = remote.strip_prefix("https://github.com/") { + let (owner, repo) = rest.split_once('/')?; + return Some((owner.to_string(), repo.to_string())); + } + None +} + +fn git_repo_root(project_dir: &Path) -> Option { + git_output(project_dir, ["rev-parse", "--show-toplevel"]) + .ok() + .map(|value| PathBuf::from(value.trim())) +} + +fn git_output<'a, I>(dir: &Path, args: I) -> Result +where + I: IntoIterator, +{ + let output = Command::new("git") + .args(args) + .current_dir(dir) + .output() + .context("failed to run git")?; + if !output.status.success() { + bail!( + "git command failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn run_git<'a, I>(dir: &Path, args: I) -> Result<()> +where + I: IntoIterator, +{ + let status = Command::new("git") + .args(args) + .current_dir(dir) + .status() + .context("failed to run git")?; + if !status.success() { + bail!("git command failed with {status}"); + } + Ok(()) +} + +fn first_url(text: &str) -> Option { + text.split_whitespace() + .find(|part| part.starts_with("https://") || part.starts_with("http://")) + .map(|value| { + value + .trim_matches(|c| c == ',' || c == ')' || c == '(') + .to_string() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unique_dir(name: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("fission-publish-{}-{}", name, std::process::id())); + let _ = fs::remove_dir_all(&dir); + dir + } + + fn write_minimal_site(dir: &Path) { + fs::create_dir_all(dir.join("content")).unwrap(); + fs::write( + dir.join("fission.toml"), + r#"targets = ["site"] + +[app] +name = "site-demo" +app_id = "com.example.site_demo" + +[site] +title = "Site Demo" +out_dir = "dist/site" +generate_sitemap = false +generate_robots = false + +[distribution.github_pages.production] +owner = "example" +repo = "site-demo" +mode = "actions" +site_kind = "project" +base_path = "/site-demo/" + +[distribution.github_releases.production] +owner = "example" +repo = "site-demo" +tag = "v1.2.3" +name = "Site Demo 1.2.3" +draft = true +prerelease = false +replace_assets = true +upload_artifact_manifest = true +"#, + ) + .unwrap(); + fs::write( + dir.join("content/index.md"), + "---\ntitle: Home\n---\n# Home\n", + ) + .unwrap(); + } + + #[test] + fn static_package_builds_artifact_manifest() { + let dir = unique_dir("package"); + write_minimal_site(&dir); + let manifest = package::package_static(&PackageOptions { + project_dir: dir.clone(), + target: Target::Site, + format: PackageFormat::Static, + release: true, + json: false, + }) + .unwrap(); + assert_eq!(manifest.target, "site"); + assert!(dir + .join("target/fission/release/site/static/artifact-manifest.json") + .exists()); + assert!(manifest + .artifacts + .iter() + .any(|file| file.relative_path == "index.html")); + assert!(dir + .join("target/fission/release/site/static/fission-route-manifest.json") + .exists()); + assert!(dir + .join("target/fission/release/site/static/fission-mime-map.json") + .exists()); + assert!(manifest + .validation + .checks + .iter() + .any(|check| check.id == "release.package.artifact.primary_present")); + } + + #[test] + fn static_package_includes_configured_secondary_artifacts() { + let dir = unique_dir("secondary-artifacts"); + write_minimal_site(&dir); + fs::create_dir_all(dir.join("release-content/symbols")).unwrap(); + fs::write(dir.join("release-content/symbols/app.dSYM.zip"), b"symbols").unwrap(); + let mut toml = fs::read_to_string(dir.join("fission.toml")).unwrap(); + toml.push_str( + r#" +[[package.symbols]] +path = "release-content/symbols/app.dSYM.zip" +platform = "ios" +upload_provider = "crash-service" +"#, + ); + fs::write(dir.join("fission.toml"), toml).unwrap(); + + let manifest = package::package_static(&PackageOptions { + project_dir: dir.clone(), + target: Target::Site, + format: PackageFormat::Static, + release: true, + json: false, + }) + .unwrap(); + + let symbols = manifest + .artifacts + .iter() + .find(|file| file.kind == "debug_symbols") + .expect("debug symbols should be present"); + assert_eq!(symbols.platform.as_deref(), Some("ios")); + assert_eq!(symbols.upload_provider.as_deref(), Some("crash-service")); + } + + #[test] + fn github_pages_setup_writes_workflow() { + let dir = unique_dir("github-setup"); + write_minimal_site(&dir); + let config = load_publish_manifest(&dir).unwrap(); + setup_github_pages( + &DistributeOptions { + project_dir: dir.clone(), + provider: DistributionProvider::GithubPages, + action: DistributeAction::Setup, + artifact: None, + site: "production".to_string(), + deploy: None, + track: None, + dry_run: false, + yes: true, + json: false, + }, + &config, + ) + .unwrap(); + let workflow = fs::read_to_string(dir.join(".github/workflows/fission-pages.yml")).unwrap(); + assert!(workflow.contains("actions/upload-pages-artifact")); + assert!(workflow.contains("actions/deploy-pages")); + assert!(workflow.contains("cargo fission package")); + } + + #[test] + fn github_base_path_accounts_for_custom_domain() { + let cfg = GithubPagesConfig { + custom_domain: Some("docs.example.com".to_string()), + repo: Some("repo".to_string()), + ..Default::default() + }; + assert_eq!(expected_github_base_path(&cfg, Some("repo")), "/"); + let cfg = GithubPagesConfig { + repo: Some("repo".to_string()), + ..Default::default() + }; + assert_eq!(expected_github_base_path(&cfg, Some("repo")), "/repo/"); + } + + #[test] + fn android_aab_readiness_checks_official_toolchain() { + let dir = unique_dir("android-aab-readiness"); + write_minimal_site(&dir); + let checks = readiness_package(&dir, Some(Target::Android), Some(PackageFormat::Aab)) + .expect("readiness should produce checks even when blocked"); + for id in [ + "release.package.android_aab_script_exists", + "release.package.android_sdk_configured", + "release.package.android_ndk_configured", + "release.package.aapt2_available", + "release.package.zipalign_available", + "release.package.apksigner_available", + "release.package.bundletool_available", + ] { + assert!(checks.iter().any(|check| check.id == id), "missing {id}"); + } + } + + #[test] + fn cloudflare_readiness_requires_wrangler_backend() { + let dir = unique_dir("cloudflare-readiness"); + write_minimal_site(&dir); + let mut toml = fs::read_to_string(dir.join("fission.toml")).unwrap(); + toml.push_str( + r#" +[distribution.cloudflare_pages.production] +account_id = "account" +project_name = "site-demo" +"#, + ); + fs::write(dir.join("fission.toml"), toml).unwrap(); + let config = load_publish_manifest(&dir).unwrap(); + let checks = readiness_distribute( + &dir, + DistributionProvider::CloudflarePages, + "production", + None, + None, + &config, + ) + .unwrap(); + assert!(checks + .iter() + .any(|check| check.id == "release.cloudflare_pages.wrangler_available")); + } + + #[test] + fn github_releases_readiness_is_not_static_site_specific() { + let dir = unique_dir("github-releases-readiness"); + write_minimal_site(&dir); + let artifact_root = dir.join("target/fission/release/linux/run"); + fs::create_dir_all(&artifact_root).unwrap(); + let binary = artifact_root.join("site-demo.run"); + fs::write(&binary, b"run").unwrap(); + let manifest_path = artifact_root.join(ARTIFACT_MANIFEST); + fs::write( + &manifest_path, + serde_json::to_vec_pretty(&ArtifactManifest { + schema_version: 1, + created_at_unix_seconds: 0, + project: ArtifactProject { + app_id: "com.example.site_demo".to_string(), + name: "site-demo".to_string(), + version: Some("1.2.3".to_string()), + }, + target: "linux".to_string(), + format: "run".to_string(), + profile: "release".to_string(), + root_dir: artifact_root.display().to_string(), + artifacts: vec![ArtifactFile { + kind: "asset".to_string(), + purpose: None, + platform: None, + upload_provider: None, + path: binary.display().to_string(), + relative_path: "site-demo.run".to_string(), + sha256: "abc".to_string(), + size_bytes: 3, + mime_type: "application/octet-stream".to_string(), + }], + validation: ArtifactValidation { + state: "passed".to_string(), + checks: Vec::new(), + }, + }) + .unwrap(), + ) + .unwrap(); + let config = load_publish_manifest(&dir).unwrap(); + let checks = readiness_distribute( + &dir, + DistributionProvider::GithubReleases, + "production", + None, + Some(&manifest_path), + &config, + ) + .unwrap(); + assert!(checks.iter().any(|check| { + check.id == "release.github_releases.assets_available" + && check.status == CheckStatus::Passed + })); + assert!(!checks + .iter() + .any(|check| check.id == "release.distribution.static_root_exists")); + } + + #[test] + fn microsoft_store_msix_readiness_uses_msstore_not_package_url() { + let dir = unique_dir("microsoft-msix-readiness"); + write_minimal_site(&dir); + let mut toml = fs::read_to_string(dir.join("fission.toml")).unwrap(); + toml.push_str( + r#" +[distribution.microsoft_store] +product_id = "9N1234567890" +package_identity_name = "Example.SiteDemo" +package_type = "msix" +"#, + ); + fs::write(dir.join("fission.toml"), toml).unwrap(); + + let artifact_root = dir.join("target/fission/release/windows/msix"); + fs::create_dir_all(&artifact_root).unwrap(); + let package = artifact_root.join("site-demo.msixupload"); + fs::write(&package, b"msix").unwrap(); + let manifest_path = artifact_root.join(ARTIFACT_MANIFEST); + fs::write( + &manifest_path, + serde_json::to_vec_pretty(&ArtifactManifest { + schema_version: 1, + created_at_unix_seconds: 0, + project: ArtifactProject { + app_id: "com.example.site_demo".to_string(), + name: "site-demo".to_string(), + version: Some("1.2.3".to_string()), + }, + target: "windows".to_string(), + format: "msix".to_string(), + profile: "release".to_string(), + root_dir: artifact_root.display().to_string(), + artifacts: vec![ArtifactFile { + kind: "installer".to_string(), + purpose: Some("store-upload".to_string()), + platform: Some("windows".to_string()), + upload_provider: Some("microsoft-store".to_string()), + path: package.display().to_string(), + relative_path: "site-demo.msixupload".to_string(), + sha256: "abc".to_string(), + size_bytes: 4, + mime_type: "application/vnd.ms-appx".to_string(), + }], + validation: ArtifactValidation { + state: "passed".to_string(), + checks: Vec::new(), + }, + }) + .unwrap(), + ) + .unwrap(); + + let config = load_publish_manifest(&dir).unwrap(); + let checks = readiness_distribute( + &dir, + DistributionProvider::MicrosoftStore, + "production", + None, + Some(&manifest_path), + &config, + ) + .unwrap(); + + assert!(checks + .iter() + .any(|check| check.id == "release.microsoft_store.msstore_available")); + assert!(checks.iter().any(|check| { + check.id == "release.microsoft_store.msix_upload_artifact_present" + && check.status == CheckStatus::Passed + })); + assert!(!checks + .iter() + .any(|check| check.id == "release.microsoft_store.package_url_configured")); + } +} diff --git a/crates/tools/fission-cli/src/publish/package.rs b/crates/tools/fission-cli/src/publish/package.rs new file mode 100644 index 00000000..2a020c24 --- /dev/null +++ b/crates/tools/fission-cli/src/publish/package.rs @@ -0,0 +1,1382 @@ +use super::*; +use crate::{cargo_package_name, read_project_config, workflow, FissionProject, Target}; +use anyhow::{bail, Context, Result}; +use flate2::write::GzEncoder; +use flate2::Compression; +use serde::Deserialize; +use serde_json::json; +use std::env; +use std::ffi::OsStr; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use tar::Builder as TarBuilder; + +#[derive(Debug, Deserialize, Default)] +struct PackageManifest { + package: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct PackageRoot { + macos: Option, + #[serde(default)] + secondary_artifacts: Vec, + #[serde(default)] + symbols: Vec, + #[serde(default)] + crash_assets: Vec, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct SecondaryArtifactConfig { + kind: Option, + purpose: Option, + platform: Option, + path: Option, + upload_provider: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct MacosPackageConfig { + bundle_id: Option, + minimum_os: Option, + entitlements: Option, + signing_identity: Option, + installer_identity: Option, + notarize: Option, +} + +pub(super) fn package_artifact(options: &PackageOptions) -> Result { + match options.format { + PackageFormat::Static => package_static(options), + PackageFormat::Run => package_linux_run(options), + PackageFormat::App => package_macos_app(options), + PackageFormat::Pkg => package_macos_pkg(options), + PackageFormat::Exe => package_windows_exe(options), + PackageFormat::Apk => package_android_apk(options), + PackageFormat::Aab => package_with_project_script( + options, + Target::Android, + "platforms/android/package-aab.sh", + "aab", + ), + PackageFormat::Ipa => { + package_with_project_script(options, Target::Ios, "platforms/ios/package-ipa.sh", "ipa") + } + PackageFormat::Msi => package_with_project_script( + options, + Target::Windows, + "platforms/windows/package-msi.ps1", + "msi", + ), + PackageFormat::Msix => package_with_project_script( + options, + Target::Windows, + "platforms/windows/package-msix.ps1", + "msix", + ), + } +} + +pub(super) fn package_static(options: &PackageOptions) -> Result { + if options.format != PackageFormat::Static { + bail!("only --format static is currently supported"); + } + let project = read_project_config(&options.project_dir)?; + if !project.targets.contains(&options.target) { + bail!( + "target `{}` is not configured for this app; run `cargo fission add-target {} --project-dir {}`", + options.target.as_str(), + options.target.as_str(), + options.project_dir.display() + ); + } + + let source_dir = match options.target { + Target::Site => { + workflow::site_build(&options.project_dir, options.release)?; + site_output_dir(&options.project_dir)? + } + Target::Web => { + workflow::build_app(workflow::BuildOptions { + project_dir: options.project_dir.clone(), + target: Some(Target::Web), + release: options.release, + })?; + options.project_dir.join("platforms/web") + } + other => bail!( + "static packaging currently supports site and web targets, not `{}`", + other.as_str() + ), + }; + + if !source_dir.join("index.html").exists() { + bail!( + "static package source {} does not contain index.html", + source_dir.display() + ); + } + + let profile = profile_name(options.release); + let staging_dir = clean_package_dir(options)?; + copy_dir_contents(&source_dir, &staging_dir)?; + write_static_package_metadata(&options.project_dir, &staging_dir)?; + + finish_artifact_manifest(&project, options, &staging_dir, profile) +} + +fn package_linux_run(options: &PackageOptions) -> Result { + ensure_package_target(options, Target::Linux, PackageFormat::Run)?; + require_host_os(Target::Linux)?; + let project = read_project_config(&options.project_dir)?; + let profile = profile_name(options.release); + let staging_dir = clean_package_dir(options)?; + let payload_dir = staging_dir.join("payload"); + fs::create_dir_all(&payload_dir)?; + let binary = build_desktop_binary(&options.project_dir, options.release)?; + let executable_name = binary + .file_name() + .and_then(OsStr::to_str) + .unwrap_or("app") + .to_string(); + fs::copy(&binary, payload_dir.join(&executable_name)).with_context(|| { + format!( + "failed to copy {} to {}", + binary.display(), + payload_dir.display() + ) + })?; + copy_optional_assets(&options.project_dir, &payload_dir)?; + + let package_name = sanitize_file_stem(&project.app.name); + let run_path = staging_dir.join(format!( + "{package_name}-{}-{}.run", + cargo_package_version(&options.project_dir).unwrap_or_else(|| "0.0.0".to_string()), + profile + )); + write_linux_run(&payload_dir, &run_path, &project.app.name, &executable_name)?; + fs::remove_dir_all(&payload_dir).ok(); + finish_artifact_manifest(&project, options, &staging_dir, profile) +} + +fn package_macos_app(options: &PackageOptions) -> Result { + ensure_package_target(options, Target::Macos, PackageFormat::App)?; + require_host_os(Target::Macos)?; + let project = read_project_config(&options.project_dir)?; + let profile = profile_name(options.release); + let staging_dir = clean_package_dir(options)?; + let macos = macos_package_config(&options.project_dir)?; + let app_bundle = create_macos_app_bundle(options, &project, &staging_dir, &macos)?; + sign_macos_app_if_configured(&options.project_dir, &app_bundle, &macos)?; + println!("{}", app_bundle.display()); + finish_artifact_manifest(&project, options, &staging_dir, profile) +} + +fn package_macos_pkg(options: &PackageOptions) -> Result { + ensure_package_target(options, Target::Macos, PackageFormat::Pkg)?; + require_host_os(Target::Macos)?; + let project = read_project_config(&options.project_dir)?; + let profile = profile_name(options.release); + let staging_dir = clean_package_dir(options)?; + let app_staging = staging_dir.join("app-staging"); + let macos = macos_package_config(&options.project_dir)?; + let app_bundle = create_macos_app_bundle(options, &project, &app_staging, &macos)?; + sign_macos_app_if_configured(&options.project_dir, &app_bundle, &macos)?; + let pkg_path = staging_dir.join(format!( + "{}-{}.pkg", + sanitize_file_stem(&project.app.name), + cargo_package_version(&options.project_dir).unwrap_or_else(|| "0.0.0".to_string()) + )); + if find_in_path("pkgbuild").is_none() { + bail!("pkgbuild was not found; install Xcode command line tools to create macOS .pkg packages"); + } + let status = Command::new("pkgbuild") + .arg("--component") + .arg(&app_bundle) + .arg("--install-location") + .arg("/Applications") + .args(pkgbuild_signing_args(&macos)) + .arg(&pkg_path) + .status() + .context("failed to run pkgbuild")?; + if !status.success() { + bail!("pkgbuild failed with {status}"); + } + notarize_macos_artifact_if_configured(&pkg_path, &macos)?; + fs::remove_dir_all(&app_staging).ok(); + finish_artifact_manifest(&project, options, &staging_dir, profile) +} + +fn package_windows_exe(options: &PackageOptions) -> Result { + ensure_package_target(options, Target::Windows, PackageFormat::Exe)?; + require_host_os(Target::Windows)?; + let project = read_project_config(&options.project_dir)?; + let profile = profile_name(options.release); + let staging_dir = clean_package_dir(options)?; + let binary = build_desktop_binary(&options.project_dir, options.release)?; + let dest = staging_dir.join(binary.file_name().unwrap_or_else(|| OsStr::new("app.exe"))); + fs::copy(&binary, &dest) + .with_context(|| format!("failed to copy {} to {}", binary.display(), dest.display()))?; + copy_optional_assets(&options.project_dir, &staging_dir)?; + finish_artifact_manifest(&project, options, &staging_dir, profile) +} + +fn package_android_apk(options: &PackageOptions) -> Result { + ensure_package_target(options, Target::Android, PackageFormat::Apk)?; + let project = read_project_config(&options.project_dir)?; + let profile = profile_name(options.release); + let staging_dir = clean_package_dir(options)?; + let script = options.project_dir.join("platforms/android/package-apk.sh"); + let output_path = run_packaging_script(&options.project_dir, &script, options.release)? + .with_context(|| format!("{} did not print an .apk path", script.display()))?; + if output_path.extension().and_then(OsStr::to_str) != Some("apk") { + bail!( + "{} printed {}, expected an .apk artifact", + script.display(), + output_path.display() + ); + } + let dest = staging_dir.join( + output_path + .file_name() + .unwrap_or_else(|| OsStr::new("app.apk")), + ); + fs::copy(&output_path, &dest).with_context(|| { + format!( + "failed to copy Android APK {} to {}", + output_path.display(), + dest.display() + ) + })?; + finish_artifact_manifest(&project, options, &staging_dir, profile) +} + +fn package_with_project_script( + options: &PackageOptions, + target: Target, + relative_script: &str, + expected_extension: &str, +) -> Result { + ensure_package_target(options, target, options.format)?; + let project = read_project_config(&options.project_dir)?; + let profile = profile_name(options.release); + let staging_dir = clean_package_dir(options)?; + let script = options.project_dir.join(relative_script); + if !script.exists() { + bail!( + "{} packaging requires {}; this target packaging flow has not been configured for this project yet", + options.format.as_str(), + script.display() + ); + } + let output_path = run_packaging_script(&options.project_dir, &script, options.release)? + .with_context(|| format!("{} did not print a package path", script.display()))?; + if output_path.extension().and_then(OsStr::to_str) != Some(expected_extension) { + bail!( + "{} printed {}, expected a .{} artifact", + script.display(), + output_path.display(), + expected_extension + ); + } + let dest = staging_dir.join( + output_path + .file_name() + .unwrap_or_else(|| OsStr::new("artifact")), + ); + fs::copy(&output_path, &dest).with_context(|| { + format!( + "failed to copy package {} to {}", + output_path.display(), + dest.display() + ) + })?; + finish_artifact_manifest(&project, options, &staging_dir, profile) +} + +fn finish_artifact_manifest( + project: &FissionProject, + options: &PackageOptions, + staging_dir: &Path, + profile: &str, +) -> Result { + let mut manifest = build_artifact_manifest(project, options, staging_dir, profile)?; + add_configured_secondary_artifacts(&options.project_dir, &mut manifest)?; + manifest.validation.checks = package_artifact_checks(options, staging_dir, &manifest); + manifest.validation.state = manifest_validation_state(&manifest.validation.checks).to_string(); + let manifest_path = staging_dir.join(ARTIFACT_MANIFEST); + fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?).with_context(|| { + format!( + "failed to write artifact manifest {}", + manifest_path.display() + ) + })?; + Ok(manifest) +} + +fn package_artifact_checks( + options: &PackageOptions, + staging_dir: &Path, + manifest: &ArtifactManifest, +) -> Vec { + let mut checks = Vec::new(); + checks.push(package_primary_artifact_check( + options.format, + staging_dir, + manifest, + )); + checks.push(package_artifact_bytes_check(manifest)); + checks.extend(package_signature_checks(options, staging_dir, manifest)); + checks.push(package_install_smoke_check(options.format, staging_dir)); + checks +} + +fn package_primary_artifact_check( + format: PackageFormat, + staging_dir: &Path, + manifest: &ArtifactManifest, +) -> ReadinessCheck { + let found = match format { + PackageFormat::Static => staging_dir.join("index.html").exists(), + PackageFormat::App => has_child_with_extension(staging_dir, "app"), + PackageFormat::Run + | PackageFormat::Pkg + | PackageFormat::Exe + | PackageFormat::Apk + | PackageFormat::Aab + | PackageFormat::Ipa + | PackageFormat::Msi + | PackageFormat::Msix => manifest.artifacts.iter().any(|file| { + Path::new(&file.path).extension().and_then(OsStr::to_str) == Some(format.as_str()) + }), + }; + check( + "release.package.artifact.primary_present", + CheckSeverity::Error, + if found { + CheckStatus::Passed + } else { + CheckStatus::Missing + }, + "primary package artifact exists", + Some(format!( + "{} package output in {}", + format.as_str(), + staging_dir.display() + )), + vec![ + "Re-run the package command and ensure the packager emits the requested artifact type.", + ], + ) +} + +fn package_artifact_bytes_check(manifest: &ArtifactManifest) -> ReadinessCheck { + let empty = manifest + .artifacts + .iter() + .filter(|file| file.size_bytes == 0) + .map(|file| file.relative_path.as_str()) + .collect::>(); + check( + "release.package.artifact.files_non_empty", + CheckSeverity::Warning, + if empty.is_empty() { + CheckStatus::Passed + } else { + CheckStatus::Warning + }, + "artifact files have non-zero bytes", + (!empty.is_empty()).then(|| empty.join(", ")), + vec![ + "Inspect the listed zero-byte files and remove or regenerate them before distribution.", + ], + ) +} + +fn package_signature_checks( + options: &PackageOptions, + staging_dir: &Path, + manifest: &ArtifactManifest, +) -> Vec { + match options.format { + PackageFormat::App => vec![verify_with_tool( + "release.package.signature.macos_app", + "codesign", + &["--verify", "--deep", "--strict"], + primary_child_with_extension(staging_dir, "app"), + "macOS .app signature verifies", + "Sign the .app bundle with package.macos.signing_identity or disable signed distribution for this package.", + )], + PackageFormat::Pkg => vec![verify_with_tool( + "release.package.signature.macos_pkg", + "pkgutil", + &["--check-signature"], + primary_file_with_extension(manifest, "pkg"), + "macOS .pkg signature verifies", + "Sign the package with package.macos.installer_identity before distribution.", + )], + PackageFormat::Apk => vec![verify_with_tool( + "release.package.signature.android_apk", + "apksigner", + &["verify"], + primary_file_with_extension(manifest, "apk"), + "Android APK signature verifies", + "Configure Android signing and run the platform packager again.", + )], + PackageFormat::Aab => vec![verify_with_tool( + "release.package.signature.android_aab", + "jarsigner", + &["-verify"], + primary_file_with_extension(manifest, "aab"), + "Android AAB jar signature verifies", + "Configure Android upload signing and regenerate the AAB.", + )], + PackageFormat::Msix => vec![verify_with_tool( + "release.package.signature.windows_msix", + "signtool", + &["verify", "/pa"], + primary_file_with_extension(manifest, "msix"), + "Windows MSIX signature verifies", + "Sign the MSIX with the Windows package certificate before distribution.", + )], + PackageFormat::Msi => vec![verify_with_tool( + "release.package.signature.windows_msi", + "signtool", + &["verify", "/pa"], + primary_file_with_extension(manifest, "msi"), + "Windows MSI signature verifies", + "Sign the MSI with the Windows package certificate before distribution.", + )], + PackageFormat::Exe => vec![verify_with_tool( + "release.package.signature.windows_exe", + "signtool", + &["verify", "/pa"], + primary_file_with_extension(manifest, "exe"), + "Windows executable signature verifies", + "Sign the executable or installer with the Windows package certificate before distribution.", + )], + _ => Vec::new(), + } +} + +fn package_install_smoke_check(format: PackageFormat, staging_dir: &Path) -> ReadinessCheck { + if matches!(format, PackageFormat::Static) { + return check( + "release.package.install_smoke.not_required", + CheckSeverity::Info, + CheckStatus::Passed, + "install smoke receipt is not required for static packages", + Some(staging_dir.display().to_string()), + Vec::new(), + ); + } + let candidates = [ + staging_dir.join("install-smoke.json"), + staging_dir.join("package-validation/install-smoke.json"), + ]; + let receipt = candidates.iter().find(|path| path.exists()); + check( + "release.package.install_smoke.receipt", + CheckSeverity::Warning, + if receipt.is_some() { + CheckStatus::Passed + } else { + CheckStatus::Skipped + }, + "package install smoke receipt exists", + receipt + .map(|path| path.display().to_string()) + .or_else(|| Some(staging_dir.display().to_string())), + vec!["Run the platform install/smoke workflow and write install-smoke.json next to the artifact before release distribution."], + ) +} + +fn verify_with_tool( + id: &str, + tool: &str, + args: &[&str], + path: Option, + summary: &str, + remediation: &str, +) -> ReadinessCheck { + let Some(path) = path else { + return check( + id, + CheckSeverity::Warning, + CheckStatus::Skipped, + summary, + Some("primary artifact was not found".to_string()), + vec![remediation], + ); + }; + let Some(tool_path) = find_in_path(tool) else { + return check( + id, + CheckSeverity::Warning, + CheckStatus::Skipped, + summary, + Some(format!("{tool} is not available on PATH")), + vec![remediation], + ); + }; + let output = Command::new(&tool_path).args(args).arg(&path).output(); + match output { + Ok(output) => check( + id, + CheckSeverity::Error, + if output.status.success() { + CheckStatus::Passed + } else { + CheckStatus::Failed + }, + summary, + Some(format!( + "{} {} {}: {}{}", + tool_path.display(), + args.join(" "), + path.display(), + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim() + )), + vec![remediation], + ), + Err(error) => check( + id, + CheckSeverity::Warning, + CheckStatus::Skipped, + summary, + Some(error.to_string()), + vec![remediation], + ), + } +} + +fn manifest_validation_state(checks: &[ReadinessCheck]) -> &'static str { + if checks + .iter() + .any(|check| check.severity == CheckSeverity::Error && check.status != CheckStatus::Passed) + { + "failed" + } else if checks + .iter() + .any(|check| check.status == CheckStatus::Warning || check.status == CheckStatus::Skipped) + { + "warning" + } else { + "passed" + } +} + +fn has_child_with_extension(root: &Path, extension: &str) -> bool { + primary_child_with_extension(root, extension).is_some() +} + +fn primary_child_with_extension(root: &Path, extension: &str) -> Option { + fs::read_dir(root) + .ok()? + .filter_map(Result::ok) + .find_map(|entry| { + let path = entry.path(); + (path.extension().and_then(OsStr::to_str) == Some(extension)).then_some(path) + }) +} + +fn primary_file_with_extension(manifest: &ArtifactManifest, extension: &str) -> Option { + manifest.artifacts.iter().find_map(|file| { + let path = Path::new(&file.path); + (path.extension().and_then(OsStr::to_str) == Some(extension)).then(|| path.to_path_buf()) + }) +} + +fn add_configured_secondary_artifacts( + project_dir: &Path, + manifest: &mut ArtifactManifest, +) -> Result<()> { + let config = package_manifest(project_dir)?; + for artifact in configured_secondary_artifacts(&config) { + let Some(relative_path) = artifact + .path + .as_deref() + .filter(|value| !value.trim().is_empty()) + else { + continue; + }; + let source = resolve_project_path(project_dir, relative_path.to_string()); + if !source.exists() { + bail!( + "configured secondary artifact {} does not exist", + source.display() + ); + } + let kind = artifact + .kind + .clone() + .unwrap_or_else(|| "secondary_artifact".to_string()); + let purpose = artifact.purpose.clone().or_else(|| Some(kind.clone())); + collect_secondary_artifacts( + project_dir, + &source, + &source, + &kind, + purpose.as_deref(), + artifact.platform.as_deref(), + artifact.upload_provider.as_deref(), + manifest, + )?; + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn collect_secondary_artifacts( + project_dir: &Path, + root: &Path, + current: &Path, + kind: &str, + purpose: Option<&str>, + platform: Option<&str>, + upload_provider: Option<&str>, + manifest: &mut ArtifactManifest, +) -> Result<()> { + let metadata = fs::metadata(current)?; + if metadata.is_dir() { + for entry in fs::read_dir(current)? { + let entry = entry?; + collect_secondary_artifacts( + project_dir, + root, + &entry.path(), + kind, + purpose, + platform, + upload_provider, + manifest, + )?; + } + return Ok(()); + } + if !metadata.is_file() { + return Ok(()); + } + let relative_path = current + .strip_prefix(project_dir) + .unwrap_or_else(|_| current.strip_prefix(root).unwrap_or(current)) + .to_string_lossy() + .replace('\\', "/"); + let (sha256, size_bytes) = hash_file(current)?; + manifest.artifacts.push(ArtifactFile { + kind: kind.to_string(), + purpose: purpose.map(str::to_string), + platform: platform.map(str::to_string), + upload_provider: upload_provider.map(str::to_string), + path: current.display().to_string(), + relative_path, + sha256, + size_bytes, + mime_type: content_type(current).to_string(), + }); + Ok(()) +} + +fn configured_secondary_artifacts(config: &PackageManifest) -> Vec { + let Some(package) = config.package.as_ref() else { + return Vec::new(); + }; + let mut artifacts = Vec::new(); + artifacts.extend(package.secondary_artifacts.iter().cloned()); + artifacts.extend(package.symbols.iter().cloned().map(|mut item| { + item.kind.get_or_insert_with(|| "debug_symbols".to_string()); + item + })); + artifacts.extend(package.crash_assets.iter().cloned().map(|mut item| { + item.kind + .get_or_insert_with(|| "crash_diagnostics".to_string()); + item + })); + artifacts +} + +fn package_manifest(project_dir: &Path) -> Result { + let path = project_dir.join("fission.toml"); + let data = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display())) +} + +fn write_static_package_metadata(project_dir: &Path, staging_dir: &Path) -> Result<()> { + let fission_toml = project_dir.join("fission.toml"); + let doc = fs::read_to_string(&fission_toml) + .ok() + .and_then(|data| toml::from_str::(&data).ok()); + let site = doc.as_ref().and_then(|doc| doc.get("site")); + let base_path = site + .and_then(|site| site.get("base_path")) + .and_then(toml::Value::as_str) + .unwrap_or("/"); + let canonical_url = site + .and_then(|site| site.get("canonical_url")) + .and_then(toml::Value::as_str); + let cache_control = site + .and_then(|site| site.get("cache_control")) + .and_then(toml::Value::as_str) + .unwrap_or("public, max-age=31536000, immutable"); + + let routes = collect_static_routes(staging_dir, staging_dir)?; + let assets = collect_static_assets(staging_dir, staging_dir)?; + let mime_map = assets + .iter() + .map(|asset| { + json!({ + "path": asset, + "mime_type": content_type(&staging_dir.join(asset)) + }) + }) + .collect::>(); + + fs::write( + staging_dir.join("fission-route-manifest.json"), + serde_json::to_vec_pretty(&json!({ + "schema_version": 1, + "base_path": base_path, + "canonical_url": canonical_url, + "routes": routes + }))?, + )?; + fs::write( + staging_dir.join("fission-asset-manifest.json"), + serde_json::to_vec_pretty(&json!({ + "schema_version": 1, + "assets": assets + }))?, + )?; + fs::write( + staging_dir.join("fission-mime-map.json"), + serde_json::to_vec_pretty(&json!({ + "schema_version": 1, + "files": mime_map + }))?, + )?; + fs::write( + staging_dir.join("fission-cache-policy.json"), + serde_json::to_vec_pretty(&json!({ + "schema_version": 1, + "default": cache_control + }))?, + )?; + write_static_headers(staging_dir, cache_control)?; + Ok(()) +} + +fn collect_static_routes(root: &Path, current: &Path) -> Result> { + let mut routes = Vec::new(); + collect_static_routes_inner(root, current, &mut routes)?; + routes.sort(); + routes.dedup(); + Ok(routes) +} + +fn collect_static_routes_inner( + root: &Path, + current: &Path, + routes: &mut Vec, +) -> Result<()> { + for entry in fs::read_dir(current)? { + let entry = entry?; + let path = entry.path(); + if entry.file_type()?.is_dir() { + collect_static_routes_inner(root, &path, routes)?; + continue; + } + if path.extension().and_then(OsStr::to_str) != Some("html") { + continue; + } + let relative = path + .strip_prefix(root)? + .to_string_lossy() + .replace('\\', "/"); + let route = if relative == "index.html" { + "/".to_string() + } else if let Some(prefix) = relative.strip_suffix("/index.html") { + format!("/{prefix}/") + } else { + format!("/{}", relative.trim_end_matches(".html")) + }; + routes.push(route); + } + Ok(()) +} + +fn collect_static_assets(root: &Path, current: &Path) -> Result> { + let mut assets = Vec::new(); + collect_static_assets_inner(root, current, &mut assets)?; + assets.sort(); + Ok(assets) +} + +fn collect_static_assets_inner( + root: &Path, + current: &Path, + assets: &mut Vec, +) -> Result<()> { + for entry in fs::read_dir(current)? { + let entry = entry?; + let path = entry.path(); + if entry.file_type()?.is_dir() { + collect_static_assets_inner(root, &path, assets)?; + continue; + } + let relative = path + .strip_prefix(root)? + .to_string_lossy() + .replace('\\', "/"); + if !matches!( + relative.as_str(), + "fission-route-manifest.json" + | "fission-asset-manifest.json" + | "fission-mime-map.json" + | "fission-cache-policy.json" + ) { + assets.push(relative); + } + } + Ok(()) +} + +fn write_static_headers(staging_dir: &Path, cache_control: &str) -> Result<()> { + let body = format!( + r#"/assets/* + Cache-Control: {cache_control} + +/*.wasm + Content-Type: application/wasm + Cache-Control: {cache_control} + +/*.js + Content-Type: text/javascript; charset=utf-8 + Cache-Control: {cache_control} + +/*.css + Content-Type: text/css; charset=utf-8 + Cache-Control: {cache_control} +"# + ); + fs::write(staging_dir.join("_headers"), body)?; + Ok(()) +} + +pub(super) fn readiness_secondary_artifacts(project_dir: &Path, checks: &mut Vec) { + let Ok(config) = package_manifest(project_dir) else { + return; + }; + for artifact in configured_secondary_artifacts(&config) { + let id = artifact + .path + .as_deref() + .map(sanitize_file_stem) + .unwrap_or_else(|| "unnamed".to_string()); + let path = artifact + .path + .as_ref() + .map(|path| resolve_project_path(project_dir, path.to_string())); + checks.push(check( + format!("release.package.secondary_artifact.{id}.path"), + CheckSeverity::Error, + if path.as_ref().is_some_and(|path| path.exists()) { + CheckStatus::Passed + } else { + CheckStatus::Missing + }, + "configured secondary release artifact exists", + path.map(|path| path.display().to_string()), + vec!["Create the configured symbol/diagnostic artifact before packaging or remove the stale package artifact entry."], + )); + let kind = artifact.kind.as_deref().unwrap_or("secondary_artifact"); + if matches!(kind, "debug_symbols" | "crash_diagnostics" | "symbols") + && artifact + .upload_provider + .as_deref() + .filter(|value| !value.trim().is_empty()) + .is_none() + { + checks.push(check( + format!("release.package.secondary_artifact.{id}.upload_provider"), + CheckSeverity::Warning, + CheckStatus::Warning, + "debug/crash artifact has an upload provider", + Some(kind.to_string()), + vec!["Set upload_provider when symbols must be sent to a store or crash diagnostics backend."], + )); + } + } +} + +fn ensure_package_target( + options: &PackageOptions, + expected_target: Target, + expected_format: PackageFormat, +) -> Result<()> { + if options.target != expected_target || options.format != expected_format { + bail!( + "--target {} --format {} is required for this package path", + expected_target.as_str(), + expected_format.as_str() + ); + } + let project = read_project_config(&options.project_dir)?; + if !project.targets.contains(&options.target) { + bail!( + "target `{}` is not configured for this app; run `cargo fission add-target {} --project-dir {}`", + options.target.as_str(), + options.target.as_str(), + options.project_dir.display() + ); + } + Ok(()) +} + +fn profile_name(release: bool) -> &'static str { + if release { + "release" + } else { + "debug" + } +} + +fn clean_package_dir(options: &PackageOptions) -> Result { + let staging_dir = options + .project_dir + .join("target/fission") + .join(profile_name(options.release)) + .join(options.target.as_str()) + .join(options.format.as_str()); + if staging_dir.exists() { + fs::remove_dir_all(&staging_dir) + .with_context(|| format!("failed to clean {}", staging_dir.display()))?; + } + fs::create_dir_all(&staging_dir) + .with_context(|| format!("failed to create {}", staging_dir.display()))?; + Ok(staging_dir) +} + +fn require_host_os(target: Target) -> Result<()> { + let ok = match target { + Target::Linux => cfg!(target_os = "linux"), + Target::Macos => cfg!(target_os = "macos"), + Target::Windows => cfg!(target_os = "windows"), + _ => true, + }; + if ok { + Ok(()) + } else { + bail!( + "{} packaging must run on a {} host for now", + target.as_str(), + target.as_str() + ) + } +} + +fn build_desktop_binary(project_dir: &Path, release: bool) -> Result { + let mut command = Command::new("cargo"); + command.arg("build").current_dir(project_dir); + if release { + command.arg("--release"); + } + let status = command.status().context("failed to run cargo build")?; + if !status.success() { + bail!("desktop build failed with {status}"); + } + let name = cargo_package_name(project_dir).context("Cargo.toml package.name is required")?; + let executable = if cfg!(target_os = "windows") { + format!("{name}.exe") + } else { + name + }; + let path = project_dir + .join("target") + .join(profile_name(release)) + .join(executable); + if !path.exists() { + bail!("expected built binary at {}", path.display()); + } + Ok(path) +} + +fn create_macos_app_bundle( + options: &PackageOptions, + project: &FissionProject, + staging_dir: &Path, + macos: &MacosPackageConfig, +) -> Result { + let binary = build_desktop_binary(&options.project_dir, options.release)?; + let executable = binary + .file_name() + .and_then(OsStr::to_str) + .unwrap_or("app") + .to_string(); + let app_name = display_app_name(&project.app.name); + let app_bundle = staging_dir.join(format!("{app_name}.app")); + let contents = app_bundle.join("Contents"); + let macos_dir = contents.join("MacOS"); + let resources = contents.join("Resources"); + fs::create_dir_all(&macos_dir)?; + fs::create_dir_all(&resources)?; + fs::copy(&binary, macos_dir.join(&executable)).with_context(|| { + format!( + "failed to copy {} into {}", + binary.display(), + app_bundle.display() + ) + })?; + if let Some(icon) = app_icon_path(&options.project_dir) { + let _ = fs::copy(icon, resources.join("AppIcon.png")); + } + let version = + cargo_package_version(&options.project_dir).unwrap_or_else(|| "0.1.0".to_string()); + let plist = render_info_plist(project, &app_name, &executable, macos, &version); + fs::write(contents.join("Info.plist"), plist)?; + fs::write(contents.join("PkgInfo"), "APPL????")?; + Ok(app_bundle) +} + +fn render_info_plist( + project: &FissionProject, + app_name: &str, + executable: &str, + macos: &MacosPackageConfig, + version: &str, +) -> String { + let bundle_id = macos + .bundle_id + .as_deref() + .unwrap_or(project.app.app_id.as_str()); + let minimum_os = macos.minimum_os.as_deref().unwrap_or("13.0"); + format!( + r#" + + + + CFBundleIdentifier + {} + CFBundleName + {} + CFBundleDisplayName + {} + CFBundleExecutable + {} + CFBundlePackageType + APPL + CFBundleShortVersionString + {} + CFBundleVersion + {} + LSMinimumSystemVersion + {} + + +"#, + escape_xml(bundle_id), + escape_xml(app_name), + escape_xml(app_name), + escape_xml(executable), + escape_xml(version), + escape_xml(version), + escape_xml(minimum_os) + ) +} + +fn macos_package_config(project_dir: &Path) -> Result { + let path = project_dir.join("fission.toml"); + let data = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + let manifest: PackageManifest = + toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))?; + Ok(manifest + .package + .and_then(|package| package.macos) + .unwrap_or_default()) +} + +fn sign_macos_app_if_configured( + project_dir: &Path, + app_bundle: &Path, + macos: &MacosPackageConfig, +) -> Result<()> { + let Some(identity) = macos + .signing_identity + .as_deref() + .filter(|value| !value.trim().is_empty()) + else { + return Ok(()); + }; + let mut command = Command::new("codesign"); + command + .arg("--force") + .arg("--timestamp") + .arg("--options") + .arg("runtime") + .arg("--sign") + .arg(identity); + if let Some(entitlements) = macos + .entitlements + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + command + .arg("--entitlements") + .arg(resolve_project_path(project_dir, entitlements.to_string())); + } + let status = command + .arg(app_bundle) + .status() + .context("failed to run codesign")?; + if !status.success() { + bail!("codesign failed with {status}"); + } + let verify = Command::new("codesign") + .args(["--verify", "--deep", "--strict", "--verbose=2"]) + .arg(app_bundle) + .status() + .context("failed to verify macOS code signature")?; + if !verify.success() { + bail!("codesign verification failed with {verify}"); + } + Ok(()) +} + +fn pkgbuild_signing_args(macos: &MacosPackageConfig) -> Vec { + macos + .installer_identity + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(|identity| vec!["--sign".to_string(), identity.to_string()]) + .unwrap_or_default() +} + +fn notarize_macos_artifact_if_configured( + artifact: &Path, + macos: &MacosPackageConfig, +) -> Result<()> { + if !macos.notarize.unwrap_or(false) { + return Ok(()); + } + let key = env::var("APP_STORE_CONNECT_API_KEY_PATH") + .context("APP_STORE_CONNECT_API_KEY_PATH is required when package.macos.notarize = true")?; + let key_id = env::var("APP_STORE_CONNECT_KEY_ID") + .context("APP_STORE_CONNECT_KEY_ID is required when package.macos.notarize = true")?; + let issuer = env::var("APP_STORE_CONNECT_ISSUER_ID") + .context("APP_STORE_CONNECT_ISSUER_ID is required when package.macos.notarize = true")?; + let submit = Command::new("xcrun") + .args([ + "notarytool", + "submit", + artifact.to_string_lossy().as_ref(), + "--key", + &key, + "--key-id", + &key_id, + "--issuer", + &issuer, + "--wait", + ]) + .status() + .context("failed to run xcrun notarytool")?; + if !submit.success() { + bail!("notarytool submit failed with {submit}"); + } + let staple = Command::new("xcrun") + .args(["stapler", "staple"]) + .arg(artifact) + .status() + .context("failed to run xcrun stapler")?; + if !staple.success() { + bail!("stapler failed with {staple}"); + } + Ok(()) +} + +fn write_linux_run( + payload_dir: &Path, + run_path: &Path, + app_name: &str, + executable_name: &str, +) -> Result<()> { + let mut archive = Vec::new(); + { + let encoder = GzEncoder::new(&mut archive, Compression::default()); + let mut tar = TarBuilder::new(encoder); + tar.append_dir_all(".", payload_dir)?; + let encoder = tar.into_inner()?; + encoder.finish()?; + } + let mut file = fs::File::create(run_path)?; + writeln!( + file, + r#"#!/bin/sh +set -eu +APP_NAME="{app_name}" +EXECUTABLE="{executable_name}" +DEST="${{FISSION_INSTALL_DIR:-$HOME/.local/opt/$APP_NAME}}" +mkdir -p "$DEST" +ARCHIVE_LINE=$(awk '/^__FISSION_ARCHIVE_BELOW__$/ {{ print NR + 1; exit 0; }}' "$0") +tail -n +"$ARCHIVE_LINE" "$0" | tar -xz -C "$DEST" +chmod +x "$DEST/$EXECUTABLE" 2>/dev/null || true +echo "Installed $APP_NAME to $DEST" +echo "Run: $DEST/$EXECUTABLE" +exit 0 +__FISSION_ARCHIVE_BELOW__"# + )?; + file.write_all(&archive)?; + set_executable(run_path)?; + Ok(()) +} + +fn set_executable(path: &Path) -> Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = fs::metadata(path)?.permissions(); + perms.set_mode(perms.mode() | 0o755); + fs::set_permissions(path, perms)?; + } + Ok(()) +} + +fn copy_optional_assets(project_dir: &Path, dest: &Path) -> Result<()> { + let assets = project_dir.join("assets"); + if assets.exists() { + copy_dir_contents(&assets, &dest.join("assets"))?; + } + Ok(()) +} + +fn run_packaging_script( + project_dir: &Path, + script: &Path, + release: bool, +) -> Result> { + if !script.exists() { + bail!("packaging script is missing at {}", script.display()); + } + let extension = script.extension().and_then(OsStr::to_str); + let mut command = if extension == Some("ps1") { + let program = if cfg!(windows) { + "powershell" + } else if find_in_path("pwsh").is_some() { + "pwsh" + } else { + bail!( + "{} requires PowerShell; install pwsh or run this package format on Windows", + script.display() + ); + }; + let mut command = Command::new(program); + if cfg!(windows) { + command.args(["-ExecutionPolicy", "Bypass", "-File"]); + } else { + command.arg("-File"); + } + command.arg(script); + command + } else if cfg!(windows) || extension == Some("sh") { + let mut command = Command::new("bash"); + command.arg(script); + command + } else { + Command::new(script) + }; + command.current_dir(project_dir); + if release { + command.env("ANDROID_PROFILE", "release"); + command.env("IOS_PROFILE", "release"); + command.env("WINDOWS_PROFILE", "release"); + } + let output = command + .output() + .with_context(|| format!("failed to run {}", script.display()))?; + io::stderr().write_all(&output.stderr).ok(); + if !output.status.success() { + bail!("{} failed with {}", script.display(), output.status); + } + let stdout = String::from_utf8_lossy(&output.stdout); + Ok(stdout + .lines() + .rev() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(|line| { + let path = PathBuf::from(line); + if path.is_absolute() { + path + } else { + project_dir.join(path) + } + }) + .find(|path| path.exists())) +} + +fn sanitize_file_stem(value: &str) -> String { + let stem = value + .chars() + .map(|ch| match ch { + 'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' => ch, + _ => '-', + }) + .collect::() + .trim_matches(['-', '.', '_']) + .to_string(); + if stem.is_empty() { + "app".to_string() + } else { + stem + } +} + +fn display_app_name(value: &str) -> String { + let mut out = String::new(); + let mut uppercase_next = true; + for ch in value.chars() { + match ch { + '-' | '_' | '.' | ' ' => { + if !out.ends_with(' ') && !out.is_empty() { + out.push(' '); + } + uppercase_next = true; + } + _ if uppercase_next => { + out.extend(ch.to_uppercase()); + uppercase_next = false; + } + _ => out.push(ch), + } + } + if out.trim().is_empty() { + "Fission App".to_string() + } else { + out.trim().to_string() + } +} + +fn app_icon_path(project_dir: &Path) -> Option { + [ + "assets/app-icon.icns", + "assets/AppIcon.icns", + "assets/app-icon.png", + "assets/icon.png", + ] + .into_iter() + .map(|relative| project_dir.join(relative)) + .find(|path| path.exists()) +} + +fn escape_xml(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} diff --git a/crates/tools/fission-cli/src/publish/static_hosts.rs b/crates/tools/fission-cli/src/publish/static_hosts.rs new file mode 100644 index 00000000..888bf623 --- /dev/null +++ b/crates/tools/fission-cli/src/publish/static_hosts.rs @@ -0,0 +1,343 @@ +use super::*; +use crate::release; +use anyhow::{bail, Context, Result}; +use reqwest::blocking::Client; +use serde_json::Value; +use std::fs; +use std::io::{Cursor, Write}; +use std::path::Path; +use std::time::Duration; +use zip::write::SimpleFileOptions; + +const NETLIFY_API: &str = "https://api.netlify.com/api/v1"; + +pub(super) fn publish_netlify( + options: &DistributeOptions, + config: &PublishManifest, + artifact_path: &Path, + manifest: &ArtifactManifest, +) -> Result { + let cfg = netlify_config(config, &options.site)?; + let site_id = cfg + .site_id + .as_deref() + .context("distribution.netlify..site_id is required")?; + let token = netlify_token()?; + let zip = zip_directory(Path::new(&manifest.root_dir))?; + if options.dry_run { + return Ok(receipt( + options, + artifact_path, + "dry-run", + None, + netlify_configured_url(&cfg), + Some(format!( + "{} bytes zipped for Netlify site {site_id}", + zip.len() + )), + None, + Vec::new(), + )); + } + let client = http_client()?; + let deploy_url = if cfg.production.unwrap_or(true) { + format!("{NETLIFY_API}/sites/{site_id}/deploys") + } else { + format!("{NETLIFY_API}/sites/{site_id}/deploys?draft=true") + }; + let response = client + .post(deploy_url) + .bearer_auth(token) + .header("Content-Type", "application/zip") + .body(zip) + .send() + .context("failed to create Netlify deploy")?; + let value = json_response(response, "Netlify deploy")?; + let deployment_id = value.get("id").and_then(Value::as_str).map(str::to_string); + let canonical_url = value + .get("ssl_url") + .or_else(|| value.get("url")) + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| netlify_configured_url(&cfg)); + let preview_url = value + .get("deploy_ssl_url") + .or_else(|| value.get("deploy_url")) + .and_then(Value::as_str) + .map(str::to_string); + let status = value + .get("state") + .and_then(Value::as_str) + .unwrap_or("uploaded"); + Ok(receipt( + options, + artifact_path, + status, + deployment_id, + canonical_url, + Some(value.to_string()), + None, + preview_url + .into_iter() + .map(|url| format!("Preview URL: {url}")) + .collect(), + )) +} + +pub(super) fn netlify_status( + options: &DistributeOptions, + config: &PublishManifest, +) -> Result { + let cfg = netlify_config(config, &options.site)?; + let site_id = cfg + .site_id + .as_deref() + .context("distribution.netlify..site_id is required")?; + let client = http_client()?; + let token = netlify_token()?; + let url = if let Some(deploy) = options.deploy.as_deref() { + format!("{NETLIFY_API}/sites/{site_id}/deploys/{deploy}") + } else { + format!("{NETLIFY_API}/sites/{site_id}/deploys") + }; + let response = client + .get(url) + .bearer_auth(token) + .send() + .context("failed to fetch Netlify status")?; + let value = json_response(response, "Netlify status")?; + let first = value + .as_array() + .and_then(|items| items.first()) + .unwrap_or(&value); + Ok(DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: "netlify".to_string(), + site: options.site.clone(), + action: "status".to_string(), + artifact_manifest: None, + deployment_id: first.get("id").and_then(Value::as_str).map(str::to_string), + canonical_url: first + .get("ssl_url") + .or_else(|| first.get("url")) + .and_then(Value::as_str) + .map(str::to_string), + preview_url: first + .get("deploy_ssl_url") + .or_else(|| first.get("deploy_url")) + .and_then(Value::as_str) + .map(str::to_string), + custom_domain: cfg.custom_domain.clone(), + status: first + .get("state") + .and_then(Value::as_str) + .unwrap_or("ok") + .to_string(), + stdout: Some(value.to_string()), + stderr: None, + manual_follow_up: Vec::new(), + }) +} + +pub(super) fn netlify_lifecycle( + options: &DistributeOptions, + config: &PublishManifest, +) -> Result { + let cfg = netlify_config(config, &options.site)?; + let site_id = cfg + .site_id + .as_deref() + .context("distribution.netlify..site_id is required")?; + let deploy = options + .deploy + .as_deref() + .context("netlify promote/rollback requires --deploy ")?; + if options.dry_run { + return Ok(DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: "netlify".to_string(), + site: options.site.clone(), + action: action_name(options).to_string(), + artifact_manifest: None, + deployment_id: Some(deploy.to_string()), + canonical_url: netlify_configured_url(&cfg), + preview_url: None, + custom_domain: cfg.custom_domain.clone(), + status: "dry-run".to_string(), + stdout: None, + stderr: None, + manual_follow_up: vec![format!( + "Would restore Netlify deploy {deploy} as the live site." + )], + }); + } + let client = http_client()?; + let token = netlify_token()?; + let url = format!("{NETLIFY_API}/sites/{site_id}/deploys/{deploy}/restore"); + let response = client + .post(url) + .bearer_auth(token) + .send() + .context("failed to restore Netlify deploy")?; + let value = json_response(response, "Netlify restore deploy")?; + Ok(DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: "netlify".to_string(), + site: options.site.clone(), + action: action_name(options).to_string(), + artifact_manifest: None, + deployment_id: value.get("id").and_then(Value::as_str).map(str::to_string), + canonical_url: value + .get("ssl_url") + .or_else(|| value.get("url")) + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| netlify_configured_url(&cfg)), + preview_url: value + .get("deploy_ssl_url") + .or_else(|| value.get("deploy_url")) + .and_then(Value::as_str) + .map(str::to_string), + custom_domain: cfg.custom_domain.clone(), + status: value + .get("state") + .and_then(Value::as_str) + .unwrap_or("restored") + .to_string(), + stdout: Some(value.to_string()), + stderr: None, + manual_follow_up: Vec::new(), + }) +} + +fn zip_directory(root: &Path) -> Result> { + let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new())); + let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + add_zip_entries(root, root, &mut writer, options)?; + Ok(writer.finish()?.into_inner()) +} + +fn add_zip_entries( + root: &Path, + current: &Path, + writer: &mut zip::ZipWriter, + options: SimpleFileOptions, +) -> Result<()> { + for entry in + fs::read_dir(current).with_context(|| format!("failed to read {}", current.display()))? + { + let entry = entry?; + let path = entry.path(); + let relative = path + .strip_prefix(root)? + .to_string_lossy() + .replace('\\', "/"); + if entry.file_type()?.is_dir() { + if !relative.is_empty() { + writer.add_directory(format!("{relative}/"), options)?; + } + add_zip_entries(root, &path, writer, options)?; + } else if entry.file_type()?.is_file() { + writer.start_file(relative, options)?; + writer.write_all(&fs::read(&path)?)?; + } + } + Ok(()) +} + +fn netlify_token() -> Result { + release::provider_secret(DistributionProvider::Netlify, &["NETLIFY_AUTH_TOKEN"])? + .context("NETLIFY_AUTH_TOKEN or Fission vault credentials are required for Netlify") +} + +fn http_client() -> Result { + Client::builder() + .timeout(Duration::from_secs(300)) + .user_agent("fission-cli-release/0.1") + .build() + .context("failed to build Netlify HTTP client") +} + +fn json_response(response: reqwest::blocking::Response, operation: &str) -> Result { + let status = response.status(); + let text = response.text()?; + if !status.is_success() { + bail!("{operation} failed with {status}: {text}"); + } + serde_json::from_str(&text) + .with_context(|| format!("failed to parse {operation} response: {text}")) +} + +fn receipt( + options: &DistributeOptions, + artifact_path: &Path, + status: &str, + deployment_id: Option, + canonical_url: Option, + stdout: Option, + stderr: Option, + manual_follow_up: Vec, +) -> DistributionReceipt { + DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: "netlify".to_string(), + site: options.site.clone(), + action: "publish".to_string(), + artifact_manifest: Some(artifact_path.display().to_string()), + deployment_id, + canonical_url, + preview_url: None, + custom_domain: None, + status: status.to_string(), + stdout, + stderr, + manual_follow_up, + } +} + +fn action_name(options: &DistributeOptions) -> &'static str { + match options.action { + DistributeAction::Promote => "promote", + DistributeAction::Rollback => "rollback", + _ => "lifecycle", + } +} + +fn netlify_configured_url(cfg: &NetlifyConfig) -> Option { + cfg.custom_domain + .as_ref() + .filter(|value| !value.trim().is_empty()) + .map(|domain| format!("https://{}", domain.trim())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn unique_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "fission-static-hosts-{name}-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + #[test] + fn zip_directory_includes_nested_static_assets() { + let dir = unique_dir("zip"); + fs::create_dir_all(dir.join("assets")).unwrap(); + fs::write(dir.join("index.html"), "

Home

").unwrap(); + fs::write(dir.join("assets/site.css"), "body {}").unwrap(); + let zip = zip_directory(&dir).unwrap(); + let mut archive = zip::ZipArchive::new(Cursor::new(zip)).unwrap(); + assert!(archive.by_name("index.html").is_ok()); + assert!(archive.by_name("assets/site.css").is_ok()); + } +} diff --git a/crates/tools/fission-cli/src/publish/stores.rs b/crates/tools/fission-cli/src/publish/stores.rs new file mode 100644 index 00000000..62384016 --- /dev/null +++ b/crates/tools/fission-cli/src/publish/stores.rs @@ -0,0 +1,1736 @@ +use super::*; +use crate::release; +use anyhow::{bail, Context, Result}; +use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; +use reqwest::blocking::{Client, Response}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::time::Duration; + +const PLAY_API: &str = "https://androidpublisher.googleapis.com"; +const PLAY_UPLOAD_API: &str = "https://androidpublisher.googleapis.com/upload"; +const GOOGLE_PLAY_SCOPE: &str = "https://www.googleapis.com/auth/androidpublisher"; +const GOOGLE_TOKEN_URI: &str = "https://oauth2.googleapis.com/token"; +const APP_STORE_API_PRIVATE_KEYS_DIR: &str = "API_PRIVATE_KEYS_DIR"; +const MICROSOFT_STORE_API: &str = "https://api.store.microsoft.com"; +const APP_STORE_API: &str = "https://api.appstoreconnect.apple.com"; +const MICROSOFT_STORE_SCOPE: &str = "https://api.store.microsoft.com/.default"; +const MICROSOFT_STORE_MSIX_TYPES: &[&str] = &["msix", "msixupload"]; + +#[derive(Debug, Deserialize)] +struct GoogleServiceAccount { + client_email: String, + private_key: String, + #[serde(default)] + token_uri: Option, +} + +#[derive(Debug, Serialize)] +struct GoogleJwtClaims<'a> { + iss: &'a str, + scope: &'a str, + aud: &'a str, + iat: u64, + exp: u64, +} + +#[derive(Debug, Serialize)] +struct AppStoreJwtClaims<'a> { + iss: &'a str, + aud: &'a str, + iat: u64, + exp: u64, +} + +#[derive(Debug, Deserialize)] +struct OAuthTokenResponse { + access_token: String, + #[serde(default)] + token_type: Option, + #[serde(default)] + expires_in: Option, +} + +pub(super) fn publish_play_store( + options: &DistributeOptions, + config: &PublishManifest, + artifact_path: &Path, + manifest: &ArtifactManifest, +) -> Result { + let cfg = play_store_config(config); + let package_name = cfg + .package_name + .as_deref() + .or_else(|| package_name_from_project(manifest)) + .context("distribution.play_store.package_name is required")?; + let track = options + .track + .as_deref() + .or(cfg.default_track.as_deref()) + .unwrap_or("internal"); + let release_status = cfg.release_status.as_deref().unwrap_or("completed"); + let artifact = primary_artifact_with_extensions(manifest, &["aab", "apk"])?; + let artifact_kind = artifact + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if options.dry_run { + return Ok(store_receipt( + options, + "play-store", + artifact_path, + "dry-run", + None, + Some(format!( + "https://play.google.com/console/u/0/developers/app/{package_name}/tracks/{track}" + )), + vec![format!( + "Would upload {} to Google Play package {package_name} track {track} with release status {release_status}.", + artifact.display() + )], + )); + } + + let client = http_client()?; + let token = google_play_access_token(&cfg, &client)?; + let edit_id = create_play_edit(&client, &token, package_name)?; + let version_code = upload_play_artifact( + &client, + &token, + package_name, + &edit_id, + &artifact, + artifact_kind, + )?; + update_play_track( + &client, + &token, + package_name, + &edit_id, + track, + release_status, + &version_code, + )?; + validate_play_edit(&client, &token, package_name, &edit_id)?; + commit_play_edit(&client, &token, package_name, &edit_id)?; + + Ok(store_receipt( + options, + "play-store", + artifact_path, + "published", + Some(format!("edit:{edit_id}/version:{version_code}")), + Some(format!( + "https://play.google.com/console/u/0/developers/app/{package_name}/tracks/{track}" + )), + vec![format!( + "Google Play accepted version code {version_code} on track {track}; provider-side review or processing may still apply." + )], + )) +} + +pub(super) fn publish_app_store( + options: &DistributeOptions, + config: &PublishManifest, + artifact_path: &Path, + manifest: &ArtifactManifest, +) -> Result { + let cfg = app_store_config(config); + let issuer_id = env_value("APP_STORE_CONNECT_ISSUER_ID") + .or(cfg.issuer_id.clone()) + .context("distribution.app_store.issuer_id or APP_STORE_CONNECT_ISSUER_ID is required")?; + let key_id = env_value("APP_STORE_CONNECT_KEY_ID") + .or(cfg.key_id.clone()) + .context("distribution.app_store.key_id or APP_STORE_CONNECT_KEY_ID is required")?; + let api_key_path = env_value("APP_STORE_CONNECT_API_KEY_PATH").or(cfg.api_key_path.clone()); + let ipa = primary_artifact_with_extensions(manifest, &["ipa"])?; + let track = options + .track + .as_deref() + .or(cfg.default_track.as_deref()) + .unwrap_or("testflight"); + if options.dry_run { + return Ok(store_receipt( + options, + "app-store", + artifact_path, + "dry-run", + None, + Some("https://appstoreconnect.apple.com/apps".to_string()), + vec![format!( + "Would upload {} to App Store Connect with API key {key_id} for track {track}.", + ipa.display() + )], + )); + } + + let mut command = Command::new("xcrun"); + command + .args([ + "altool", + "--upload-app", + "-f", + ipa.to_string_lossy().as_ref(), + "-t", + "ios", + "--apiKey", + &key_id, + "--apiIssuer", + &issuer_id, + "--output-format", + "json", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + if let Some(api_key_path) = api_key_path + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + let path = Path::new(api_key_path); + if let Some(parent) = path.parent() { + command.env(APP_STORE_API_PRIVATE_KEYS_DIR, parent); + } + } + let output = command + .output() + .context("failed to run xcrun altool; install Xcode and App Store Connect upload tools")?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + if !output.status.success() { + bail!( + "App Store Connect upload failed with {}: {}", + output.status, + stderr.trim() + ); + } + + Ok(DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: "app-store".to_string(), + site: options.site.clone(), + action: "publish".to_string(), + artifact_manifest: Some(artifact_path.display().to_string()), + deployment_id: None, + canonical_url: Some("https://appstoreconnect.apple.com/apps".to_string()), + preview_url: None, + custom_domain: None, + status: "uploaded".to_string(), + stdout: (!stdout.trim().is_empty()).then_some(stdout), + stderr: (!stderr.trim().is_empty()).then_some(stderr), + manual_follow_up: vec![format!( + "App Store Connect accepted the upload; wait for build processing, then assign the build to {track} or App Review." + )], + }) +} + +pub(super) fn app_store_status( + options: &DistributeOptions, + config: &PublishManifest, +) -> Result { + let cfg = app_store_config(config); + let client = http_client()?; + let token = app_store_access_token(&cfg)?; + let app_id = app_store_app_id(&cfg, &client, &token)?; + let url = format!( + "{APP_STORE_API}/v1/apps/{app_id}/builds?limit=10&sort=-uploadedDate&fields[builds]=version,uploadedDate,processingState,expired,minOsVersion,usesNonExemptEncryption" + ); + let response = client + .get(url) + .bearer_auth(token) + .send() + .context("failed to query App Store Connect build status")?; + let value = json_response(response, "App Store Connect build status")?; + Ok(DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: "app-store".to_string(), + site: options.site.clone(), + action: "status".to_string(), + artifact_manifest: None, + deployment_id: Some(app_id), + canonical_url: Some("https://appstoreconnect.apple.com/apps".to_string()), + preview_url: None, + custom_domain: None, + status: "ok".to_string(), + stdout: Some(serde_json::to_string_pretty(&value)?), + stderr: None, + manual_follow_up: Vec::new(), + }) +} + +pub(super) fn publish_microsoft_store( + options: &DistributeOptions, + config: &PublishManifest, + artifact_path: &Path, + manifest: &ArtifactManifest, +) -> Result { + let cfg = microsoft_store_config(config); + let product_id = cfg + .product_id + .as_deref() + .context("distribution.microsoft_store.product_id is required")?; + let package_type = microsoft_store_package_type(&cfg, manifest); + if is_microsoft_store_msix_type(&package_type) { + return publish_microsoft_store_msix(options, &cfg, product_id, artifact_path, manifest); + } + + let seller_id = microsoft_store_seller_id(&cfg).context( + "distribution.microsoft_store.seller_id, MICROSOFT_STORE_SELLER_ID, or PARTNER_CENTER_SELLER_ID is required", + )?; + let package_url = options + .deploy + .as_deref() + .filter(|value| value.starts_with("https://") || value.starts_with("http://")) + .map(str::to_string) + .or(cfg.package_url.clone()) + .context("Microsoft Store MSI/EXE submission requires a package_url in fission.toml or --deploy ; publish the artifact to S3/static hosting first")?; + if !matches!(package_type.as_str(), "exe" | "msi") { + bail!( + "Microsoft Store direct package automation supports exe/msi through the Store submission API and msix/msixupload through msstore; package_type `{package_type}` is unsupported" + ); + } + if options.dry_run { + return Ok(store_receipt( + options, + "microsoft-store", + artifact_path, + "dry-run", + None, + Some(format!("https://partner.microsoft.com/dashboard/products/{product_id}")), + vec![format!( + "Would update Microsoft Store package metadata for product {product_id} with {package_url}." + )], + )); + } + + let client = http_client()?; + let token = microsoft_store_access_token(&cfg, &client)?; + let packages = json!({ + "packages": [{ + "packageUrl": package_url, + "languages": cfg.languages.clone().unwrap_or_else(|| vec!["en-us".to_string()]), + "architectures": cfg.architectures.clone().unwrap_or_else(|| vec!["Neutral".to_string()]), + "isSilentInstall": cfg.is_silent_install.unwrap_or(true), + "installerParameters": cfg.installer_parameters.clone().unwrap_or_default(), + "genericDocUrl": cfg.generic_doc_url.clone().unwrap_or_default(), + "packageType": package_type, + }] + }); + let packages_url = format!("{MICROSOFT_STORE_API}/submission/v1/product/{product_id}/packages"); + let package_response = client + .put(&packages_url) + .bearer_auth(&token) + .header("X-Seller-Account-Id", &seller_id) + .json(&packages) + .send() + .context("failed to update Microsoft Store package metadata")?; + let package_value = json_response(package_response, "Microsoft Store package update")?; + microsoft_store_success(&package_value, "Microsoft Store package update")?; + + let commit_url = + format!("{MICROSOFT_STORE_API}/submission/v1/product/{product_id}/packages/commit"); + let commit_response = client + .post(&commit_url) + .bearer_auth(&token) + .header("X-Seller-Account-Id", &seller_id) + .send() + .context("failed to commit Microsoft Store package metadata")?; + let commit_value = json_response(commit_response, "Microsoft Store package commit")?; + microsoft_store_success(&commit_value, "Microsoft Store package commit")?; + let polling_url = commit_value + .pointer("/responseData/pollingUrl") + .and_then(Value::as_str) + .map(|value| { + if value.starts_with("http") { + value.to_string() + } else { + format!("{MICROSOFT_STORE_API}{value}") + } + }); + + let mut follow_up = vec![ + "Microsoft Store package update was committed; poll Partner Center processing before submitting to certification.".to_string(), + ]; + let mut status = "package-committed".to_string(); + if cfg.submit.unwrap_or(false) || options.track.as_deref() == Some("public") && options.yes { + let submit_url = format!("{MICROSOFT_STORE_API}/submission/v1/product/{product_id}/submit"); + let submit_response = client + .post(&submit_url) + .bearer_auth(&token) + .header("X-Seller-Account-Id", &seller_id) + .send() + .context("failed to create Microsoft Store submission")?; + let submit_value = json_response(submit_response, "Microsoft Store submission")?; + microsoft_store_success(&submit_value, "Microsoft Store submission")?; + status = "submitted".to_string(); + follow_up.push("Microsoft Store submission was created; certification/review continues in Partner Center.".to_string()); + } else { + follow_up.push("Set distribution.microsoft_store.submit = true or pass --track public --yes when you are ready to submit the draft to certification.".to_string()); + } + + Ok(store_receipt( + options, + "microsoft-store", + artifact_path, + &status, + polling_url, + Some(format!( + "https://partner.microsoft.com/dashboard/products/{product_id}" + )), + follow_up, + )) +} + +fn publish_microsoft_store_msix( + options: &DistributeOptions, + cfg: &MicrosoftStoreConfig, + product_id: &str, + artifact_path: &Path, + manifest: &ArtifactManifest, +) -> Result { + let artifact = primary_artifact_with_extensions(manifest, MICROSOFT_STORE_MSIX_TYPES)?; + let flight_id = microsoft_store_flight_id(options.track.as_deref(), cfg)?; + let rollout = microsoft_store_rollout_percentage(cfg)?; + let should_submit = microsoft_store_should_submit(options, cfg); + let project_path = microsoft_store_msstore_project(options, cfg); + let publish_args = msstore_publish_args( + &project_path, + &artifact, + product_id, + flight_id.as_deref(), + rollout, + should_submit, + ); + + if options.dry_run { + return Ok(store_receipt( + options, + "microsoft-store", + artifact_path, + "dry-run", + None, + Some(format!( + "https://partner.microsoft.com/dashboard/products/{product_id}" + )), + vec![format!( + "Would run `{}` to publish {} through Microsoft Store Developer CLI.", + command_line("msstore", &publish_args), + artifact.display() + )], + )); + } + + let mut stdout_parts = Vec::new(); + let mut stderr_parts = Vec::new(); + if cfg.msstore_reconfigure.unwrap_or(false) { + let (stdout, stderr) = run_msstore_reconfigure(cfg)?; + if !stdout.trim().is_empty() { + stdout_parts.push(stdout); + } + if !stderr.trim().is_empty() { + stderr_parts.push(stderr); + } + } + + let (stdout, stderr) = run_msstore(&publish_args, "Microsoft Store MSIX publish")?; + if !stdout.trim().is_empty() { + stdout_parts.push(stdout); + } + if !stderr.trim().is_empty() { + stderr_parts.push(stderr); + } + + Ok(DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: "microsoft-store".to_string(), + site: options.site.clone(), + action: "publish".to_string(), + artifact_manifest: Some(artifact_path.display().to_string()), + deployment_id: flight_id + .map(|flight| format!("product:{product_id}/flight:{flight}")) + .or_else(|| Some(format!("product:{product_id}"))), + canonical_url: Some(format!( + "https://partner.microsoft.com/dashboard/products/{product_id}" + )), + preview_url: None, + custom_domain: None, + status: if should_submit { + "submitted".to_string() + } else { + "draft-updated".to_string() + }, + stdout: (!stdout_parts.is_empty()).then(|| stdout_parts.join("\n")), + stderr: (!stderr_parts.is_empty()).then(|| stderr_parts.join("\n")), + manual_follow_up: if should_submit { + vec!["Microsoft Store Developer CLI committed the submission; certification/review continues in Partner Center.".to_string()] + } else { + vec!["The MSIX submission remains a Partner Center draft because --noCommit was used. Set distribution.microsoft_store.submit = true or pass --track public --yes when you are ready to commit it.".to_string()] + }, + }) +} + +pub(super) fn play_store_status( + options: &DistributeOptions, + config: &PublishManifest, +) -> Result { + let cfg = play_store_config(config); + let package_name = cfg + .package_name + .as_deref() + .context("distribution.play_store.package_name is required")?; + let track = options + .track + .as_deref() + .or(cfg.default_track.as_deref()) + .unwrap_or("internal"); + let client = http_client()?; + let token = google_play_access_token(&cfg, &client)?; + let edit_id = create_play_edit(&client, &token, package_name)?; + let url = format!( + "{PLAY_API}/androidpublisher/v3/applications/{package_name}/edits/{edit_id}/tracks/{track}" + ); + let response = client + .get(url) + .bearer_auth(&token) + .send() + .with_context(|| format!("failed to read Google Play track {track}"))?; + let value = json_response(response, "Google Play track get")?; + Ok(DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: "play-store".to_string(), + site: options.site.clone(), + action: "status".to_string(), + artifact_manifest: None, + deployment_id: Some(format!("edit:{edit_id}/track:{track}")), + canonical_url: Some(format!( + "https://play.google.com/console/u/0/developers/app/{package_name}/tracks/{track}" + )), + preview_url: None, + custom_domain: None, + status: "ok".to_string(), + stdout: Some(serde_json::to_string_pretty(&value)?), + stderr: None, + manual_follow_up: Vec::new(), + }) +} + +pub(super) fn microsoft_store_status( + options: &DistributeOptions, + config: &PublishManifest, +) -> Result { + let cfg = microsoft_store_config(config); + let product_id = cfg + .product_id + .as_deref() + .context("distribution.microsoft_store.product_id is required")?; + if cfg + .package_type + .as_deref() + .map(|value| is_microsoft_store_msix_type(&value.to_ascii_lowercase())) + .unwrap_or(false) + { + return microsoft_store_msix_status(options, &cfg, product_id); + } + + let seller_id = microsoft_store_seller_id(&cfg).context( + "distribution.microsoft_store.seller_id, MICROSOFT_STORE_SELLER_ID, or PARTNER_CENTER_SELLER_ID is required", + )?; + let client = http_client()?; + let token = microsoft_store_access_token(&cfg, &client)?; + let url = options + .deploy + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(|value| { + if value.starts_with("http") { + value.to_string() + } else if value.starts_with('/') { + format!("{MICROSOFT_STORE_API}{value}") + } else { + format!("{MICROSOFT_STORE_API}/submission/v1/product/{product_id}/submission/{value}/status") + } + }) + .unwrap_or_else(|| format!("{MICROSOFT_STORE_API}/submission/v1/product/{product_id}/status")); + let response = client + .get(url) + .bearer_auth(&token) + .header("X-Seller-Account-Id", &seller_id) + .send() + .context("failed to query Microsoft Store submission status")?; + let value = json_response(response, "Microsoft Store status")?; + microsoft_store_success(&value, "Microsoft Store status")?; + let status = value + .pointer("/responseData/publishingStatus") + .or_else(|| value.pointer("/responseData/packageUploadStatus")) + .and_then(Value::as_str) + .unwrap_or("ok") + .to_ascii_lowercase(); + Ok(DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: "microsoft-store".to_string(), + site: options.site.clone(), + action: "status".to_string(), + artifact_manifest: None, + deployment_id: options.deploy.clone(), + canonical_url: Some(format!( + "https://partner.microsoft.com/dashboard/products/{product_id}" + )), + preview_url: None, + custom_domain: None, + status, + stdout: Some(serde_json::to_string_pretty(&value)?), + stderr: None, + manual_follow_up: Vec::new(), + }) +} + +fn microsoft_store_msix_status( + options: &DistributeOptions, + cfg: &MicrosoftStoreConfig, + product_id: &str, +) -> Result { + let flight_id = microsoft_store_flight_id(options.track.as_deref(), cfg)?; + let args = if let Some(flight_id) = flight_id.as_deref() { + vec![ + "flights".to_string(), + "submission".to_string(), + "status".to_string(), + product_id.to_string(), + flight_id.to_string(), + ] + } else { + vec![ + "submission".to_string(), + "status".to_string(), + product_id.to_string(), + ] + }; + let (stdout, stderr) = run_msstore(&args, "Microsoft Store MSIX submission status")?; + Ok(DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: "microsoft-store".to_string(), + site: options.site.clone(), + action: "status".to_string(), + artifact_manifest: None, + deployment_id: flight_id + .map(|flight| format!("product:{product_id}/flight:{flight}")) + .or_else(|| Some(format!("product:{product_id}"))), + canonical_url: Some(format!( + "https://partner.microsoft.com/dashboard/products/{product_id}" + )), + preview_url: None, + custom_domain: None, + status: "ok".to_string(), + stdout: (!stdout.trim().is_empty()).then_some(stdout), + stderr: (!stderr.trim().is_empty()).then_some(stderr), + manual_follow_up: Vec::new(), + }) +} + +pub(super) fn readiness_play_store( + track: Option<&str>, + artifact: Option<&Path>, + config: &PublishManifest, + checks: &mut Vec, +) -> Result<()> { + let cfg = play_store_config(config); + checks.push(required_value( + "release.play_store.package_name_configured", + cfg.package_name.as_deref(), + "Google Play package name is configured", + "Set distribution.play_store.package_name to the Android application id registered in Play Console.", + )); + checks.push(secret_check( + "release.play_store.credentials_available", + &["PLAY_STORE_ACCESS_TOKEN", "PLAY_STORE_SERVICE_ACCOUNT_JSON", "GOOGLE_APPLICATION_CREDENTIALS"], + DistributionProvider::PlayStore, + "Set PLAY_STORE_SERVICE_ACCOUNT_JSON to a service-account JSON path/value, set PLAY_STORE_ACCESS_TOKEN, or import credentials with `fission auth import play-store --from file: --yes`.", + )); + let selected_track = track.or(cfg.default_track.as_deref()).unwrap_or("internal"); + checks.push(check( + "release.play_store.track_supported", + CheckSeverity::Error, + if matches!(selected_track, "internal" | "closed" | "open" | "production") { + CheckStatus::Passed + } else { + CheckStatus::Failed + }, + "Google Play track is supported", + Some(selected_track.to_string()), + vec!["Use internal, closed, open, or production. Internal app sharing will be a separate explicit provider mode."], + )); + if let Some(path) = artifact.filter(|path| path.exists()) { + let manifest = read_artifact_manifest(path)?; + checks.push(artifact_format_check( + "release.play_store.artifact_format", + &manifest, + &["aab", "apk"], + "Google Play accepts Android App Bundles for production publishing and APKs for legacy/test flows.", + )); + } + checks.push(check( + "release.play_store.first_setup_manual_steps", + CheckSeverity::Warning, + CheckStatus::Warning, + "first Google Play setup may require Play Console work", + cfg.package_name.clone(), + vec!["Create the Play Console app, configure Play App Signing, complete policy/listing/data-safety requirements, and grant the service account access before first automation."], + )); + Ok(()) +} + +pub(super) fn readiness_app_store( + track: Option<&str>, + artifact: Option<&Path>, + config: &PublishManifest, + checks: &mut Vec, +) -> Result<()> { + let cfg = app_store_config(config); + checks.push(required_value( + "release.app_store.bundle_id_configured", + cfg.bundle_id.as_deref(), + "App Store bundle id is configured", + "Set distribution.app_store.bundle_id to the Bundle ID registered in App Store Connect.", + )); + checks.push(required_value( + "release.app_store.issuer_id_configured", + cfg.issuer_id + .as_deref() + .or_else(|| env_value_ref("APP_STORE_CONNECT_ISSUER_ID")), + "App Store Connect issuer id is configured", + "Set distribution.app_store.issuer_id or APP_STORE_CONNECT_ISSUER_ID.", + )); + checks.push(required_value( + "release.app_store.key_id_configured", + cfg.key_id + .as_deref() + .or_else(|| env_value_ref("APP_STORE_CONNECT_KEY_ID")), + "App Store Connect key id is configured", + "Set distribution.app_store.key_id or APP_STORE_CONNECT_KEY_ID.", + )); + checks.push(secret_check( + "release.app_store.credentials_available", + &["APP_STORE_CONNECT_API_KEY", "APP_STORE_CONNECT_API_KEY_PATH"], + DistributionProvider::AppStore, + "Set APP_STORE_CONNECT_API_KEY_PATH to AuthKey_.p8, set APP_STORE_CONNECT_API_KEY, or import credentials with `fission auth import app-store`.", + )); + checks.push(check_tool( + "release.app_store.xcrun_available", + "xcrun", + "Install Xcode and select it with xcode-select before uploading IPA files.", + )); + let selected_track = track + .or(cfg.default_track.as_deref()) + .unwrap_or("testflight"); + checks.push(check( + "release.app_store.track_supported", + CheckSeverity::Error, + if matches!( + selected_track, + "testflight" | "app-store-review" | "app-store-release" + ) { + CheckStatus::Passed + } else { + CheckStatus::Failed + }, + "App Store destination is supported", + Some(selected_track.to_string()), + vec!["Use testflight, app-store-review, or app-store-release."], + )); + if let Some(path) = artifact.filter(|path| path.exists()) { + let manifest = read_artifact_manifest(path)?; + checks.push(artifact_format_check( + "release.app_store.artifact_format", + &manifest, + &["ipa"], + "App Store Connect binary upload requires an IPA artifact.", + )); + } + checks.push(check( + "release.app_store.first_setup_manual_steps", + CheckSeverity::Warning, + CheckStatus::Warning, + "first App Store setup may require App Store Connect work", + cfg.bundle_id.clone(), + vec!["Create the Bundle ID, certificates, provisioning profiles, App Store Connect app record, metadata, privacy, pricing, and beta groups before first automation."], + )); + Ok(()) +} + +pub(super) fn readiness_microsoft_store( + track: Option<&str>, + artifact: Option<&Path>, + config: &PublishManifest, + checks: &mut Vec, +) -> Result<()> { + let cfg = microsoft_store_config(config); + let artifact_manifest = artifact + .filter(|path| path.exists()) + .map(read_artifact_manifest) + .transpose()?; + let package_type = artifact_manifest + .as_ref() + .map(|manifest| microsoft_store_package_type(&cfg, manifest)) + .or_else(|| { + cfg.package_type + .clone() + .map(|value| value.to_ascii_lowercase()) + }) + .unwrap_or_else(|| "exe".to_string()); + let uses_msix = is_microsoft_store_msix_type(&package_type); + + checks.push(required_value( + "release.microsoft_store.product_id_configured", + cfg.product_id.as_deref(), + "Microsoft Store product id is configured", + "Set distribution.microsoft_store.product_id after reserving the product in Partner Center.", + )); + checks.push(required_value( + "release.microsoft_store.package_identity_configured", + cfg.package_identity_name.as_deref(), + "Microsoft Store package identity name is configured", + "Set distribution.microsoft_store.package_identity_name to the Partner Center package identity.", + )); + + if uses_msix { + checks.push(check_tool( + "release.microsoft_store.msstore_available", + "msstore", + "Install Microsoft Store Developer CLI, run `msstore` once to configure it, or set distribution.microsoft_store.msstore_reconfigure = true with Partner Center credentials.", + )); + checks.push(check( + "release.microsoft_store.msix_uses_msstore", + CheckSeverity::Info, + CheckStatus::Passed, + "MSIX submission uses Microsoft Store Developer CLI", + Some(package_type.clone()), + vec!["Fission calls `msstore publish --inputFile ... --appId ...`; no durable package_url is required for MSIX/MSIXUPLOAD submissions."], + )); + if cfg.msstore_reconfigure.unwrap_or(false) { + checks.push(required_value( + "release.microsoft_store.seller_id_configured", + microsoft_store_seller_id(&cfg).as_deref(), + "Microsoft Store seller id is configured for msstore reconfigure", + "Set distribution.microsoft_store.seller_id, MICROSOFT_STORE_SELLER_ID, or PARTNER_CENTER_SELLER_ID.", + )); + checks.push(required_value( + "release.microsoft_store.tenant_id_configured", + microsoft_store_tenant_id(&cfg).as_deref(), + "Microsoft Entra tenant id is configured for msstore reconfigure", + "Set distribution.microsoft_store.tenant_id, AZURE_TENANT_ID, or PARTNER_CENTER_TENANT_ID.", + )); + checks.push(required_value( + "release.microsoft_store.client_id_configured", + microsoft_store_client_id(&cfg).as_deref(), + "Microsoft Entra client id is configured for msstore reconfigure", + "Set distribution.microsoft_store.client_id, AZURE_CLIENT_ID, or PARTNER_CENTER_CLIENT_ID.", + )); + checks.push(secret_check( + "release.microsoft_store.credentials_available", + &[ + "MICROSOFT_STORE_CLIENT_SECRET", + "PARTNER_CENTER_CLIENT_SECRET", + ], + DistributionProvider::MicrosoftStore, + "Set MICROSOFT_STORE_CLIENT_SECRET, PARTNER_CENTER_CLIENT_SECRET, or import the Partner Center client secret with `fission auth import microsoft-store --from env:MICROSOFT_STORE_CLIENT_SECRET --yes`.", + )); + } else { + checks.push(check( + "release.microsoft_store.msstore_config_external", + CheckSeverity::Warning, + CheckStatus::Warning, + "Microsoft Store Developer CLI credentials are managed by msstore", + None, + vec!["Run `msstore` interactively once, run `msstore reconfigure ...` in CI, or set distribution.microsoft_store.msstore_reconfigure = true so Fission configures msstore before publishing."], + )); + } + } else { + checks.push(required_value( + "release.microsoft_store.seller_id_configured", + microsoft_store_seller_id(&cfg).as_deref(), + "Microsoft Store seller id is configured", + "Set distribution.microsoft_store.seller_id, MICROSOFT_STORE_SELLER_ID, or PARTNER_CENTER_SELLER_ID.", + )); + checks.push(required_value( + "release.microsoft_store.tenant_id_configured", + microsoft_store_tenant_id(&cfg).as_deref(), + "Microsoft Entra tenant id is configured", + "Set distribution.microsoft_store.tenant_id, AZURE_TENANT_ID, or PARTNER_CENTER_TENANT_ID.", + )); + checks.push(required_value( + "release.microsoft_store.client_id_configured", + microsoft_store_client_id(&cfg).as_deref(), + "Microsoft Entra client id is configured", + "Set distribution.microsoft_store.client_id, AZURE_CLIENT_ID, or PARTNER_CENTER_CLIENT_ID.", + )); + checks.push(secret_check( + "release.microsoft_store.credentials_available", + &[ + "MICROSOFT_STORE_CLIENT_SECRET", + "PARTNER_CENTER_CLIENT_SECRET", + ], + DistributionProvider::MicrosoftStore, + "Set MICROSOFT_STORE_CLIENT_SECRET, PARTNER_CENTER_CLIENT_SECRET, or import the Partner Center client secret with `fission auth import microsoft-store --from env:MICROSOFT_STORE_CLIENT_SECRET --yes`.", + )); + checks.push(required_value( + "release.microsoft_store.package_url_configured", + cfg.package_url.as_deref(), + "Microsoft Store package URL is configured for MSI/EXE submissions", + "Upload the package to a durable HTTPS URL first, then set distribution.microsoft_store.package_url or pass --deploy .", + )); + } + + let selected_track = track.unwrap_or("public"); + if uses_msix && selected_track == "private" { + checks.push(required_value( + "release.microsoft_store.flight_id_configured", + cfg.flight_id.as_deref(), + "Microsoft Store package flight id is configured", + "Set distribution.microsoft_store.flight_id or pass the Partner Center package-flight id directly with --track .", + )); + } + checks.push(check( + "release.microsoft_store.track_supported", + CheckSeverity::Warning, + if selected_track == "public" + || selected_track == "private" + || uses_msix && !selected_track.trim().is_empty() + { + CheckStatus::Passed + } else { + CheckStatus::Warning + }, + "Microsoft Store destination is understood", + Some(selected_track.to_string()), + vec!["Use public, private, or an MSIX package-flight id when publishing through Microsoft Store Developer CLI."], + )); + + if let Some(manifest) = artifact_manifest.as_ref() { + checks.push(artifact_format_check( + "release.microsoft_store.artifact_format", + manifest, + if uses_msix { + &["msix", "msixupload"] + } else { + &["exe", "msi"] + }, + if uses_msix { + "Build a Windows MSIX artifact before publishing to the Microsoft Store MSIX path." + } else { + "Build a Windows MSI or EXE artifact before using the Store MSI/EXE submission API." + }, + )); + if uses_msix { + checks.push(check( + "release.microsoft_store.msix_upload_artifact_present", + CheckSeverity::Error, + if has_artifact_with_extension(manifest, MICROSOFT_STORE_MSIX_TYPES) { + CheckStatus::Passed + } else { + CheckStatus::Missing + }, + "artifact manifest contains an MSIX/MSIXUPLOAD file", + Some(format!("checked: {}", MICROSOFT_STORE_MSIX_TYPES.join(", "))), + vec!["Rebuild the Windows MSIX package and ensure the artifact manifest includes the .msix or .msixupload file."], + )); + } + } + checks.push(check( + "release.microsoft_store.first_setup_manual_steps", + CheckSeverity::Warning, + CheckStatus::Warning, + "first Microsoft Store setup may require Partner Center work", + cfg.product_id.clone(), + vec!["Reserve the app, complete first submission/ratings/pricing, associate the Entra app with Partner Center, and verify package identity before first automation."], + )); + Ok(()) +} + +fn create_play_edit(client: &Client, token: &str, package_name: &str) -> Result { + let url = format!("{PLAY_API}/androidpublisher/v3/applications/{package_name}/edits"); + let response = client + .post(url) + .bearer_auth(token) + .json(&json!({})) + .send() + .context("failed to create Google Play edit")?; + let value = json_response(response, "Google Play edit insert")?; + value + .get("id") + .and_then(Value::as_str) + .map(str::to_string) + .context("Google Play edit insert response did not contain id") +} + +fn upload_play_artifact( + client: &Client, + token: &str, + package_name: &str, + edit_id: &str, + path: &Path, + artifact_kind: &str, +) -> Result { + let endpoint = match artifact_kind { + "aab" => "bundles", + "apk" => "apks", + other => bail!("Google Play upload expected .aab or .apk, got .{other}"), + }; + let url = format!( + "{PLAY_UPLOAD_API}/androidpublisher/v3/applications/{package_name}/edits/{edit_id}/{endpoint}?uploadType=media" + ); + let bytes = fs::read(path).with_context(|| format!("failed to read {}", path.display()))?; + let response = client + .post(url) + .bearer_auth(token) + .header("Content-Type", "application/octet-stream") + .body(bytes) + .send() + .with_context(|| format!("failed to upload {} to Google Play", path.display()))?; + let value = json_response(response, "Google Play artifact upload")?; + let version = value + .get("versionCode") + .and_then(|value| { + value + .as_i64() + .map(|value| value.to_string()) + .or_else(|| value.as_str().map(str::to_string)) + }) + .context("Google Play upload response did not contain versionCode")?; + Ok(version) +} + +fn update_play_track( + client: &Client, + token: &str, + package_name: &str, + edit_id: &str, + track: &str, + release_status: &str, + version_code: &str, +) -> Result<()> { + let url = format!( + "{PLAY_API}/androidpublisher/v3/applications/{package_name}/edits/{edit_id}/tracks/{track}" + ); + let body = json!({ + "releases": [{ + "status": release_status, + "versionCodes": [version_code] + }] + }); + let response = client + .put(url) + .bearer_auth(token) + .json(&body) + .send() + .context("failed to update Google Play track")?; + json_response(response, "Google Play track update")?; + Ok(()) +} + +fn validate_play_edit( + client: &Client, + token: &str, + package_name: &str, + edit_id: &str, +) -> Result<()> { + let url = format!( + "{PLAY_API}/androidpublisher/v3/applications/{package_name}/edits/{edit_id}:validate" + ); + let response = client + .post(url) + .bearer_auth(token) + .send() + .context("failed to validate Google Play edit")?; + json_response(response, "Google Play edit validate")?; + Ok(()) +} + +fn commit_play_edit(client: &Client, token: &str, package_name: &str, edit_id: &str) -> Result<()> { + let url = format!( + "{PLAY_API}/androidpublisher/v3/applications/{package_name}/edits/{edit_id}:commit" + ); + let response = client + .post(url) + .bearer_auth(token) + .send() + .context("failed to commit Google Play edit")?; + json_response(response, "Google Play edit commit")?; + Ok(()) +} + +fn google_play_access_token(cfg: &PlayStoreConfig, client: &Client) -> Result { + if let Some(token) = env_value("PLAY_STORE_ACCESS_TOKEN") { + return Ok(token); + } + let secret_source = env_value("PLAY_STORE_SERVICE_ACCOUNT_JSON") + .or_else(|| env_value("GOOGLE_APPLICATION_CREDENTIALS")) + .or_else(|| cfg.service_account.clone()) + .or_else(|| { + release::provider_secret(DistributionProvider::PlayStore, &[]) + .ok() + .flatten() + }); + let Some(source) = secret_source else { + bail!("Google Play credentials are missing; set PLAY_STORE_SERVICE_ACCOUNT_JSON, PLAY_STORE_ACCESS_TOKEN, GOOGLE_APPLICATION_CREDENTIALS, or import play-store credentials into the Fission vault") + }; + if looks_like_bearer_token(&source) { + return Ok(source); + } + let service_account = load_google_service_account(&source)?; + service_account_access_token(&service_account, client) +} + +fn service_account_access_token(account: &GoogleServiceAccount, client: &Client) -> Result { + let token_uri = account.token_uri.as_deref().unwrap_or(GOOGLE_TOKEN_URI); + let iat = now_unix_seconds(); + let claims = GoogleJwtClaims { + iss: &account.client_email, + scope: GOOGLE_PLAY_SCOPE, + aud: token_uri, + iat, + exp: iat + 3600, + }; + let key = EncodingKey::from_rsa_pem(account.private_key.as_bytes()) + .context("failed to parse Google service account private_key as RSA PEM")?; + let jwt = encode(&Header::new(Algorithm::RS256), &claims, &key) + .context("failed to sign Google service account JWT")?; + let response = client + .post(token_uri) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), + ("assertion", jwt.as_str()), + ]) + .send() + .context("failed to exchange Google service account JWT")?; + let token: OAuthTokenResponse = response + .error_for_status() + .context("Google OAuth token exchange failed")? + .json() + .context("failed to parse Google OAuth token response")?; + let _ = (&token.token_type, token.expires_in); + Ok(token.access_token) +} + +fn app_store_access_token(cfg: &AppStoreConfig) -> Result { + if let Some(token) = env_value("APP_STORE_CONNECT_ACCESS_TOKEN") { + return Ok(token); + } + let issuer_id = env_value("APP_STORE_CONNECT_ISSUER_ID") + .or(cfg.issuer_id.clone()) + .context("distribution.app_store.issuer_id or APP_STORE_CONNECT_ISSUER_ID is required")?; + let key_id = env_value("APP_STORE_CONNECT_KEY_ID") + .or(cfg.key_id.clone()) + .context("distribution.app_store.key_id or APP_STORE_CONNECT_KEY_ID is required")?; + let key_source = env_value("APP_STORE_CONNECT_API_KEY") + .or_else(|| env_value("APP_STORE_CONNECT_API_KEY_PATH")) + .or(cfg.api_key_path.clone()) + .or_else(|| release::provider_secret(DistributionProvider::AppStore, &[]).ok().flatten()) + .context("APP_STORE_CONNECT_API_KEY, APP_STORE_CONNECT_API_KEY_PATH, distribution.app_store.api_key_path, or vault credentials are required")?; + if looks_like_bearer_token(&key_source) { + return Ok(key_source); + } + let key_text = if key_source.contains("-----BEGIN PRIVATE KEY-----") { + key_source + } else { + fs::read_to_string(&key_source).with_context(|| { + format!("failed to read App Store Connect API key from {key_source}") + })? + }; + let now = now_unix_seconds(); + let claims = AppStoreJwtClaims { + iss: &issuer_id, + aud: "appstoreconnect-v1", + iat: now, + exp: now + 20 * 60, + }; + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(key_id); + encode( + &header, + &claims, + &EncodingKey::from_ec_pem(key_text.as_bytes()) + .context("failed to parse App Store Connect .p8 key as EC private key")?, + ) + .context("failed to sign App Store Connect JWT") +} + +fn app_store_app_id(cfg: &AppStoreConfig, client: &Client, token: &str) -> Result { + if let Some(app_id) = cfg + .app_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + return Ok(app_id.to_string()); + } + let bundle_id = cfg.bundle_id.as_deref().context( + "distribution.app_store.app_id or bundle_id is required for App Store Connect status", + )?; + let url = format!("{APP_STORE_API}/v1/apps?filter[bundleId]={bundle_id}&limit=1"); + let response = client + .get(url) + .bearer_auth(token) + .send() + .context("failed to resolve App Store Connect app id from bundle id")?; + let value = json_response(response, "App Store app lookup")?; + value + .get("data") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(|item| item.get("id")) + .and_then(Value::as_str) + .map(str::to_string) + .with_context(|| { + format!("App Store Connect did not return an app for bundle id {bundle_id}") + }) +} + +fn microsoft_store_access_token(cfg: &MicrosoftStoreConfig, client: &Client) -> Result { + if let Some(token) = env_value("MICROSOFT_STORE_TOKEN") { + return Ok(token); + } + let tenant_id = microsoft_store_tenant_id(cfg).context( + "distribution.microsoft_store.tenant_id, AZURE_TENANT_ID, or PARTNER_CENTER_TENANT_ID is required", + )?; + let client_id = microsoft_store_client_id(cfg).context( + "distribution.microsoft_store.client_id, AZURE_CLIENT_ID, or PARTNER_CENTER_CLIENT_ID is required", + )?; + let client_secret = microsoft_store_client_secret().context( + "MICROSOFT_STORE_CLIENT_SECRET, PARTNER_CENTER_CLIENT_SECRET, or vault credentials are required", + )?; + let url = format!("https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"); + let response = client + .post(url) + .form(&[ + ("grant_type", "client_credentials"), + ("client_id", client_id.as_str()), + ("client_secret", client_secret.as_str()), + ("scope", MICROSOFT_STORE_SCOPE), + ]) + .send() + .context("failed to request Microsoft Store access token")?; + let token: OAuthTokenResponse = response + .error_for_status() + .context("Microsoft Store access token request failed")? + .json() + .context("failed to parse Microsoft Store access token response")?; + Ok(token.access_token) +} + +fn run_msstore_reconfigure(cfg: &MicrosoftStoreConfig) -> Result<(String, String)> { + let tenant_id = microsoft_store_tenant_id(cfg).context( + "distribution.microsoft_store.tenant_id, AZURE_TENANT_ID, or PARTNER_CENTER_TENANT_ID is required for msstore reconfigure", + )?; + let seller_id = microsoft_store_seller_id(cfg).context( + "distribution.microsoft_store.seller_id, MICROSOFT_STORE_SELLER_ID, or PARTNER_CENTER_SELLER_ID is required for msstore reconfigure", + )?; + let client_id = microsoft_store_client_id(cfg).context( + "distribution.microsoft_store.client_id, AZURE_CLIENT_ID, or PARTNER_CENTER_CLIENT_ID is required for msstore reconfigure", + )?; + let client_secret = microsoft_store_client_secret().context( + "MICROSOFT_STORE_CLIENT_SECRET, PARTNER_CENTER_CLIENT_SECRET, or vault credentials are required for msstore reconfigure", + )?; + let args = vec![ + "reconfigure".to_string(), + "--tenantId".to_string(), + tenant_id, + "--sellerId".to_string(), + seller_id, + "--clientId".to_string(), + client_id, + "--clientSecret".to_string(), + client_secret, + ]; + run_msstore(&args, "Microsoft Store Developer CLI reconfigure") +} + +fn run_msstore(args: &[String], operation: &str) -> Result<(String, String)> { + let output = Command::new("msstore") + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .with_context(|| { + format!( + "failed to run msstore; install Microsoft Store Developer CLI before {operation}" + ) + })?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + if !output.status.success() { + let detail = if stderr.trim().is_empty() { + stdout.trim() + } else { + stderr.trim() + }; + bail!("{operation} failed with {}: {}", output.status, detail); + } + Ok((stdout, stderr)) +} + +fn load_google_service_account(source: &str) -> Result { + let text = if source.trim_start().starts_with('{') { + source.to_string() + } else { + fs::read_to_string(source) + .with_context(|| format!("failed to read Google service account JSON from {source}"))? + }; + serde_json::from_str(&text).context("failed to parse Google service account JSON") +} + +fn json_response(response: Response, operation: &str) -> Result { + let status = response.status(); + let text = response + .text() + .with_context(|| format!("failed to read {operation} response"))?; + if !status.is_success() { + bail!("{operation} failed with {status}: {text}"); + } + if text.trim().is_empty() { + Ok(Value::Null) + } else { + serde_json::from_str(&text) + .with_context(|| format!("failed to parse {operation} JSON response: {text}")) + } +} + +fn microsoft_store_success(value: &Value, operation: &str) -> Result<()> { + if value + .get("isSuccess") + .and_then(Value::as_bool) + .unwrap_or(true) + { + Ok(()) + } else { + bail!("{operation} returned an unsuccessful response: {value}") + } +} + +fn http_client() -> Result { + Client::builder() + .timeout(Duration::from_secs(300)) + .user_agent("fission-cli-release/0.1") + .build() + .context("failed to build release HTTP client") +} + +fn play_store_config(config: &PublishManifest) -> PlayStoreConfig { + config + .distribution + .as_ref() + .and_then(|distribution| distribution.play_store.clone()) + .unwrap_or_default() +} + +fn app_store_config(config: &PublishManifest) -> AppStoreConfig { + config + .distribution + .as_ref() + .and_then(|distribution| distribution.app_store.clone()) + .unwrap_or_default() +} + +fn microsoft_store_config(config: &PublishManifest) -> MicrosoftStoreConfig { + config + .distribution + .as_ref() + .and_then(|distribution| distribution.microsoft_store.clone()) + .unwrap_or_default() +} + +fn primary_artifact_with_extensions(manifest: &ArtifactManifest, exts: &[&str]) -> Result { + manifest + .artifacts + .iter() + .map(|file| PathBuf::from(&file.path)) + .find(|path| { + path.extension() + .and_then(|value| value.to_str()) + .is_some_and(|ext| { + exts.iter() + .any(|candidate| ext.eq_ignore_ascii_case(candidate)) + }) + }) + .with_context(|| { + format!( + "artifact manifest does not contain one of: {}", + exts.join(", ") + ) + }) +} + +fn primary_artifact_extension(manifest: &ArtifactManifest) -> Option<&str> { + manifest + .artifacts + .iter() + .map(|file| Path::new(&file.path)) + .find_map(|path| path.extension().and_then(|value| value.to_str())) +} + +fn has_artifact_with_extension(manifest: &ArtifactManifest, exts: &[&str]) -> bool { + manifest.artifacts.iter().any(|file| { + Path::new(&file.path) + .extension() + .and_then(|value| value.to_str()) + .is_some_and(|ext| { + exts.iter() + .any(|candidate| ext.eq_ignore_ascii_case(candidate)) + }) + }) +} + +fn microsoft_store_package_type(cfg: &MicrosoftStoreConfig, manifest: &ArtifactManifest) -> String { + cfg.package_type + .as_deref() + .or_else(|| primary_artifact_extension(manifest)) + .unwrap_or("exe") + .to_ascii_lowercase() +} + +fn is_microsoft_store_msix_type(package_type: &str) -> bool { + MICROSOFT_STORE_MSIX_TYPES + .iter() + .any(|candidate| package_type.eq_ignore_ascii_case(candidate)) +} + +fn microsoft_store_msstore_project( + options: &DistributeOptions, + cfg: &MicrosoftStoreConfig, +) -> PathBuf { + cfg.msstore_project + .as_deref() + .map(|path| { + let path = PathBuf::from(path); + if path.is_absolute() { + path + } else { + options.project_dir.join(path) + } + }) + .unwrap_or_else(|| options.project_dir.clone()) +} + +fn msstore_publish_args( + project_path: &Path, + artifact: &Path, + product_id: &str, + flight_id: Option<&str>, + rollout: Option, + should_submit: bool, +) -> Vec { + let mut args = vec![ + "publish".to_string(), + project_path.display().to_string(), + "-i".to_string(), + artifact.display().to_string(), + "-id".to_string(), + product_id.to_string(), + ]; + if !should_submit { + args.push("-nc".to_string()); + } + if let Some(flight_id) = flight_id.filter(|value| !value.trim().is_empty()) { + args.push("-f".to_string()); + args.push(flight_id.to_string()); + } + if let Some(rollout) = rollout { + args.push("-prp".to_string()); + args.push(rollout.to_string()); + } + args +} + +fn microsoft_store_should_submit(options: &DistributeOptions, cfg: &MicrosoftStoreConfig) -> bool { + cfg.submit.unwrap_or(false) || options.track.as_deref() == Some("public") && options.yes +} + +fn microsoft_store_flight_id( + track: Option<&str>, + cfg: &MicrosoftStoreConfig, +) -> Result> { + match track.map(str::trim).filter(|value| !value.is_empty()) { + Some("public") | None => Ok(None), + Some("private") => cfg.flight_id.clone().map(Some).context( + "distribution.microsoft_store.flight_id is required when --track private is used for MSIX publishing", + ), + Some(flight_id) => Ok(Some(flight_id.to_string())), + } +} + +fn microsoft_store_rollout_percentage(cfg: &MicrosoftStoreConfig) -> Result> { + if let Some(rollout) = cfg.package_rollout_percentage { + if rollout > 100 { + bail!( + "distribution.microsoft_store.package_rollout_percentage must be between 0 and 100" + ); + } + } + Ok(cfg.package_rollout_percentage) +} + +fn microsoft_store_seller_id(cfg: &MicrosoftStoreConfig) -> Option { + env_value("MICROSOFT_STORE_SELLER_ID") + .or_else(|| env_value("PARTNER_CENTER_SELLER_ID")) + .or_else(|| cfg.seller_id.clone()) +} + +fn microsoft_store_tenant_id(cfg: &MicrosoftStoreConfig) -> Option { + env_value("AZURE_TENANT_ID") + .or_else(|| env_value("PARTNER_CENTER_TENANT_ID")) + .or_else(|| cfg.tenant_id.clone()) +} + +fn microsoft_store_client_id(cfg: &MicrosoftStoreConfig) -> Option { + env_value("AZURE_CLIENT_ID") + .or_else(|| env_value("PARTNER_CENTER_CLIENT_ID")) + .or_else(|| cfg.client_id.clone()) +} + +fn microsoft_store_client_secret() -> Option { + env_value("MICROSOFT_STORE_CLIENT_SECRET") + .or_else(|| env_value("PARTNER_CENTER_CLIENT_SECRET")) + .or_else(|| { + release::provider_secret(DistributionProvider::MicrosoftStore, &[]) + .ok() + .flatten() + }) +} + +fn command_line(program: &str, args: &[String]) -> String { + std::iter::once(program.to_string()) + .chain(args.iter().map(|arg| shell_word(arg))) + .collect::>() + .join(" ") +} + +fn shell_word(value: &str) -> String { + if value + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '/' | ':' | '\\')) + { + value.to_string() + } else { + format!("'{}'", value.replace('\'', "'\\''")) + } +} + +fn artifact_format_check( + id: &str, + manifest: &ArtifactManifest, + accepted: &[&str], + remediation: &str, +) -> ReadinessCheck { + check( + id, + CheckSeverity::Error, + if accepted.iter().any(|format| manifest.format == *format) { + CheckStatus::Passed + } else { + CheckStatus::Failed + }, + format!("artifact format is one of {}", accepted.join(", ")), + Some(format!("manifest format: {}", manifest.format)), + vec![remediation], + ) +} + +fn secret_check( + id: &str, + env_names: &[&str], + provider: DistributionProvider, + remediation: &str, +) -> ReadinessCheck { + let env_name = env_names.iter().find(|name| env::var_os(name).is_some()); + let vault_present = release::provider_secret(provider, &[]) + .ok() + .flatten() + .is_some(); + check( + id, + CheckSeverity::Error, + if env_name.is_some() || vault_present { + CheckStatus::Passed + } else { + CheckStatus::Missing + }, + "provider credentials are available", + env_name + .map(|name| format!("environment variable {name}")) + .or_else(|| vault_present.then(|| "Fission release vault".to_string())), + vec![remediation], + ) +} + +fn store_receipt( + options: &DistributeOptions, + provider: &str, + artifact_path: &Path, + status: &str, + deployment_id: Option, + canonical_url: Option, + manual_follow_up: Vec, +) -> DistributionReceipt { + DistributionReceipt { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: provider.to_string(), + site: options.site.clone(), + action: "publish".to_string(), + artifact_manifest: Some(artifact_path.display().to_string()), + deployment_id, + canonical_url, + preview_url: None, + custom_domain: None, + status: status.to_string(), + stdout: None, + stderr: None, + manual_follow_up, + } +} + +fn package_name_from_project(manifest: &ArtifactManifest) -> Option<&str> { + (!manifest.project.app_id.trim().is_empty()).then_some(manifest.project.app_id.as_str()) +} + +fn looks_like_bearer_token(value: &str) -> bool { + let trimmed = value.trim(); + !trimmed.starts_with('{') && !Path::new(trimmed).exists() && trimmed.matches('.').count() >= 2 +} + +fn env_value(name: &str) -> Option { + env::var(name).ok().filter(|value| !value.trim().is_empty()) +} + +fn env_value_ref(name: &str) -> Option<&'static str> { + if env::var_os(name).is_some() { + Some("set") + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn options(track: Option<&str>, yes: bool) -> DistributeOptions { + DistributeOptions { + project_dir: PathBuf::from("/project"), + provider: DistributionProvider::MicrosoftStore, + action: DistributeAction::Publish, + artifact: None, + site: "production".to_string(), + deploy: None, + track: track.map(str::to_string), + dry_run: false, + yes, + json: false, + } + } + + #[test] + fn msstore_publish_args_keep_submission_as_draft_by_default() { + let args = msstore_publish_args( + Path::new("/project"), + Path::new("/artifacts/app.msixupload"), + "9N123", + None, + None, + false, + ); + assert_eq!( + args, + vec![ + "publish", + "/project", + "-i", + "/artifacts/app.msixupload", + "-id", + "9N123", + "-nc" + ] + ); + } + + #[test] + fn msstore_publish_args_include_flight_and_rollout_when_configured() { + let args = msstore_publish_args( + Path::new("/project"), + Path::new("/artifacts/app.msix"), + "9N123", + Some("beta"), + Some(25), + true, + ); + assert_eq!( + args, + vec![ + "publish", + "/project", + "-i", + "/artifacts/app.msix", + "-id", + "9N123", + "-f", + "beta", + "-prp", + "25" + ] + ); + } + + #[test] + fn microsoft_store_private_track_uses_configured_flight_id() { + let cfg = MicrosoftStoreConfig { + flight_id: Some("insiders".to_string()), + ..Default::default() + }; + assert_eq!( + microsoft_store_flight_id(Some("private"), &cfg).unwrap(), + Some("insiders".to_string()) + ); + assert_eq!( + microsoft_store_flight_id(Some("preview"), &cfg).unwrap(), + Some("preview".to_string()) + ); + assert!(microsoft_store_flight_id(Some("public"), &cfg) + .unwrap() + .is_none()); + } + + #[test] + fn microsoft_store_submit_requires_explicit_commit_intent() { + let cfg = MicrosoftStoreConfig::default(); + assert!(!microsoft_store_should_submit(&options(None, false), &cfg)); + assert!(!microsoft_store_should_submit( + &options(Some("public"), false), + &cfg + )); + assert!(microsoft_store_should_submit( + &options(Some("public"), true), + &cfg + )); + let cfg = MicrosoftStoreConfig { + submit: Some(true), + ..Default::default() + }; + assert!(microsoft_store_should_submit(&options(None, false), &cfg)); + } + + #[test] + fn microsoft_store_rollout_rejects_out_of_range_percentages() { + let cfg = MicrosoftStoreConfig { + package_rollout_percentage: Some(101), + ..Default::default() + }; + assert!(microsoft_store_rollout_percentage(&cfg).is_err()); + } +} diff --git a/crates/tools/fission-cli/src/release.rs b/crates/tools/fission-cli/src/release.rs new file mode 100644 index 00000000..9e222eaf --- /dev/null +++ b/crates/tools/fission-cli/src/release.rs @@ -0,0 +1,1305 @@ +use crate::{publish, Target}; +use anyhow::{bail, Context, Result}; +use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine as _}; +use chacha20poly1305::{ + aead::{Aead, KeyInit}, + XChaCha20Poly1305, XNonce, +}; +use clap::Subcommand; +use serde::{Deserialize, Serialize}; +use std::env; +use std::fs; +use std::io::{self, IsTerminal, Read}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +mod content; +mod microsoft_store_ops; +mod model; +mod signing_ops; +mod store_ops; +mod workflow_ops; + +#[derive(Subcommand, Debug)] +pub(crate) enum ReleaseConfigCommand { + /// Open release configuration in an editor or the Fission terminal UI. + Edit { + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long)] + tui: bool, + }, + /// Import provider metadata into local release files. + Import { + #[arg(long, value_enum)] + provider: publish::DistributionProvider, + #[arg(long)] + locales: Option, + #[arg(long)] + yes: bool, + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long)] + json: bool, + }, + /// Diff local release metadata against provider state. + Diff { + #[arg(long, value_enum)] + provider: publish::DistributionProvider, + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long)] + json: bool, + }, + /// Validate fission.toml and referenced release files. + Validate { + #[arg(long, value_enum)] + provider: Option, + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long)] + json: bool, + }, + /// Push release metadata to a provider. + Push { + #[arg(long, value_enum)] + provider: publish::DistributionProvider, + #[arg(long)] + locales: Option, + #[arg(long)] + dry_run: bool, + #[arg(long)] + yes: bool, + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long)] + json: bool, + }, + /// Set a scalar field in fission.toml. + Set { + field: String, + value: String, + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long)] + yes: bool, + }, + /// Append a release entry to fission.toml. + AddRelease { + #[arg(long)] + version: String, + #[arg(long)] + build: u64, + #[arg(long)] + from: Option, + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long)] + yes: bool, + }, + /// Open or create a release metadata sidecar file. + EditFile { + #[arg(long)] + release: String, + #[arg(long)] + kind: String, + #[arg(long)] + locale: Option, + #[arg(long, default_value = ".")] + project_dir: PathBuf, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum ReleaseContentCommand { + /// Capture screenshots/videos from configured release scenarios. + Capture { + #[arg(long, value_enum)] + target: Target, + #[arg(long)] + set: String, + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long)] + json: bool, + }, + /// Render store-ready screenshot/video assets from raw captures. + Render { + #[arg(long, value_enum)] + provider: publish::DistributionProvider, + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long)] + json: bool, + }, + /// Validate release-content assets and manifests. + Validate { + #[arg(long, value_enum)] + provider: Option, + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long)] + json: bool, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum BetaCommand { + /// Manage beta groups/flights/tracks. + Groups { + #[command(subcommand)] + command: BetaGroupsCommand, + }, + /// Manage beta testers. + Testers { + #[command(subcommand)] + command: BetaTestersCommand, + }, + /// Distribute an artifact to a beta track/group. + Distribute { + #[arg(long, value_enum)] + provider: publish::DistributionProvider, + #[arg(long)] + artifact: PathBuf, + #[arg(long)] + group: Option, + #[arg(long)] + track: Option, + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long)] + dry_run: bool, + #[arg(long)] + json: bool, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum BetaGroupsCommand { + List { + #[arg(long, value_enum)] + provider: publish::DistributionProvider, + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long)] + json: bool, + }, + Sync { + #[arg(long, value_enum)] + provider: publish::DistributionProvider, + #[arg(long, default_value = "fission.toml")] + from: PathBuf, + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long)] + dry_run: bool, + #[arg(long)] + json: bool, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum BetaTestersCommand { + Import { + #[arg(long, value_enum)] + provider: publish::DistributionProvider, + #[arg(long)] + group: Option, + #[arg(long)] + track: Option, + #[arg(long)] + csv: PathBuf, + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long)] + dry_run: bool, + #[arg(long)] + json: bool, + }, + Export { + #[arg(long, value_enum)] + provider: publish::DistributionProvider, + #[arg(long)] + group: Option, + #[arg(long)] + track: Option, + #[arg(long)] + output: PathBuf, + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long)] + json: bool, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum SigningCommand { + Status { + #[arg(long, value_enum)] + target: Target, + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long)] + json: bool, + }, + Sync { + #[arg(long, value_enum)] + target: Target, + #[arg(long)] + readonly: bool, + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long)] + json: bool, + }, + Import { + #[arg(long, value_enum)] + target: Target, + #[arg(long)] + keystore: Option, + #[arg(long)] + alias: Option, + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long)] + json: bool, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum ReviewsCommand { + List { + #[arg(long, value_enum)] + provider: publish::DistributionProvider, + #[arg(long)] + since: Option, + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long)] + json: bool, + }, + Reply { + #[arg(long, value_enum)] + provider: publish::DistributionProvider, + #[arg(long)] + review: String, + #[arg(long)] + message_file: PathBuf, + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long)] + dry_run: bool, + #[arg(long)] + json: bool, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum ReleaseWorkflowCommand { + /// List configured release workflows. + List { + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long)] + json: bool, + }, + /// Run a named release workflow from fission.toml. + Run { + name: String, + #[arg(long, default_value = ".")] + project_dir: PathBuf, + #[arg(long)] + dry_run: bool, + #[arg(long)] + json: bool, + }, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum AuthCommand { + Setup { + #[arg(value_enum)] + provider: Option, + #[arg(long)] + json: bool, + }, + Login { + #[arg(value_enum)] + provider: publish::DistributionProvider, + }, + Status { + #[arg(value_enum)] + provider: Option, + #[arg(long)] + json: bool, + }, + Logout { + #[arg(value_enum)] + provider: publish::DistributionProvider, + #[arg(long)] + yes: bool, + }, + Import { + #[arg(value_enum)] + provider: publish::DistributionProvider, + #[arg(long)] + from: String, + #[arg(long)] + yes: bool, + }, + Rotate { + #[arg(value_enum)] + provider: publish::DistributionProvider, + }, + Audit { + #[arg(long)] + json: bool, + }, +} + +#[derive(Debug, Serialize)] +struct LifecycleReport { + area: String, + status: String, + provider: Option, + target: Option, + checks: Vec, +} + +#[derive(Debug, Serialize)] +struct LifecycleCheck { + id: String, + status: String, + summary: String, + details: Option, + remediation: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +struct VaultRecord { + schema_version: u32, + provider: String, + created_at_unix_seconds: u64, + nonce: String, + ciphertext: String, +} + +pub(crate) fn release_config(command: ReleaseConfigCommand) -> Result<()> { + match command { + ReleaseConfigCommand::Edit { project_dir, tui } => edit_release_config(&project_dir, tui), + ReleaseConfigCommand::Validate { + provider, + project_dir, + json, + } => print_report( + model::validate_release_config_model(&project_dir, provider)?, + json, + ), + ReleaseConfigCommand::Set { + field, + value, + project_dir, + yes, + } => set_release_field(&project_dir, &field, &value, yes), + ReleaseConfigCommand::AddRelease { + version, + build, + from, + project_dir, + yes, + } => add_release(&project_dir, &version, build, from.as_deref(), yes), + ReleaseConfigCommand::EditFile { + release, + kind, + locale, + project_dir, + } => edit_release_file(&project_dir, &release, &kind, locale.as_deref()), + ReleaseConfigCommand::Import { + provider, + locales, + yes, + project_dir, + json, + } => store_ops::release_config_import(provider, locales, yes, &project_dir, json), + ReleaseConfigCommand::Diff { + provider, + project_dir, + json, + } => store_ops::release_config_diff(provider, &project_dir, json), + ReleaseConfigCommand::Push { + provider, + locales, + dry_run, + yes, + project_dir, + json, + } => store_ops::release_config_push(provider, locales, dry_run, yes, &project_dir, json), + } +} + +pub(crate) fn release_content(command: ReleaseContentCommand) -> Result<()> { + match command { + ReleaseContentCommand::Validate { + provider, + project_dir, + json, + } => print_report( + content::validate_release_content_model(&project_dir, provider), + json, + ), + ReleaseContentCommand::Capture { + target, + set, + project_dir, + json, + } => print_report( + content::capture_release_content(&project_dir, target, &set)?, + json, + ), + ReleaseContentCommand::Render { + provider, + project_dir, + json, + } => print_report( + content::render_release_content(&project_dir, provider)?, + json, + ), + } +} + +pub(crate) fn beta(command: BetaCommand) -> Result<()> { + match command { + BetaCommand::Groups { command } => match command { + BetaGroupsCommand::List { + provider, + project_dir, + json, + } => store_ops::beta_groups_list(provider, &project_dir, json), + BetaGroupsCommand::Sync { + provider, + from, + project_dir, + dry_run, + json, + } => store_ops::beta_groups_sync(provider, &from, &project_dir, dry_run, json), + }, + BetaCommand::Testers { command } => match command { + BetaTestersCommand::Import { + provider, + group, + track, + csv, + project_dir, + dry_run, + json, + } => store_ops::beta_testers_import( + provider, + group.as_deref(), + track.as_deref(), + &csv, + &project_dir, + dry_run, + json, + ), + BetaTestersCommand::Export { + provider, + group, + track, + output, + project_dir, + json, + } => store_ops::beta_testers_export( + provider, + group.as_deref(), + track.as_deref(), + &output, + &project_dir, + json, + ), + }, + BetaCommand::Distribute { + provider, + artifact, + group, + track, + project_dir, + dry_run, + json, + } => publish::distribute(publish::DistributeOptions { + project_dir, + provider, + action: publish::DistributeAction::Publish, + artifact: Some(artifact), + site: group.unwrap_or_else(|| "beta".to_string()), + deploy: None, + track, + dry_run, + yes: true, + json, + }), + } +} + +pub(crate) fn signing(command: SigningCommand) -> Result<()> { + match command { + SigningCommand::Status { + target, + project_dir, + json, + } => signing_ops::status(&project_dir, target, json), + SigningCommand::Sync { + target, + readonly, + project_dir, + json, + } => signing_ops::sync(&project_dir, target, readonly, json), + SigningCommand::Import { + target, + keystore, + alias, + project_dir, + json, + } => signing_ops::import(&project_dir, target, keystore, alias, json), + } +} + +pub(crate) fn reviews(command: ReviewsCommand) -> Result<()> { + match command { + ReviewsCommand::List { + provider, + since, + project_dir, + json, + } => store_ops::reviews_list(provider, since, &project_dir, json), + ReviewsCommand::Reply { + provider, + review, + message_file, + project_dir, + dry_run, + json, + } => store_ops::reviews_reply( + provider, + &review, + &message_file, + &project_dir, + dry_run, + json, + ), + } +} + +pub(crate) fn release_workflow(command: ReleaseWorkflowCommand) -> Result<()> { + match command { + ReleaseWorkflowCommand::List { project_dir, json } => { + workflow_ops::list(&project_dir, json) + } + ReleaseWorkflowCommand::Run { + name, + project_dir, + dry_run, + json, + } => workflow_ops::run(&project_dir, &name, dry_run, json), + } +} + +pub(crate) fn auth(command: AuthCommand) -> Result<()> { + match command { + AuthCommand::Status { provider, json } => { + print_report(auth_report("auth.status", provider), json) + } + AuthCommand::Setup { provider, json } => print_report(auth_setup_report(provider), json), + AuthCommand::Audit { json } => print_report(auth_report("auth.audit", None), json), + AuthCommand::Login { provider } => login_provider(provider), + AuthCommand::Logout { provider, yes } => { + if !yes { + bail!( + "refusing to delete {} credentials without --yes", + provider.as_str() + ); + } + let path = vault_record_path(provider)?; + if path.exists() { + fs::remove_file(&path)?; + println!( + "Removed {} credentials from {}", + provider.as_str(), + path.display() + ); + } else { + println!("No stored {} credentials found", provider.as_str()); + } + Ok(()) + } + AuthCommand::Import { + provider, + from, + yes, + } => { + if !yes { + bail!( + "refusing to import {} credentials without --yes", + provider.as_str() + ); + } + if let Some(path) = from.strip_prefix("file:") { + fs::metadata(path) + .with_context(|| format!("credential file {path} does not exist"))?; + } + let secret = read_secret_source(&from)?; + store_provider_secret(provider, secret.as_bytes())?; + println!( + "Stored {} credentials in the encrypted Fission release vault", + provider.as_str() + ); + Ok(()) + } + AuthCommand::Rotate { provider } => { + rotate_provider_secret(provider)?; + println!("Rotated {} vault encryption record", provider.as_str()); + Ok(()) + } + } +} + +fn login_provider(provider: publish::DistributionProvider) -> Result<()> { + print_login_instructions(provider); + let secret = if io::stdin().is_terminal() { + println!("Paste the provider token, service-account JSON, API key contents, or a file:/env: source, then press Enter:"); + let mut line = String::new(); + io::stdin().read_line(&mut line)?; + line.trim().to_string() + } else { + let mut text = String::new(); + io::stdin().read_to_string(&mut text)?; + text.trim().to_string() + }; + if secret.is_empty() { + bail!("no credential was provided for {}", provider.as_str()); + } + let resolved = if secret.starts_with("env:") || secret.starts_with("file:") { + read_secret_source(&secret)? + } else { + secret + }; + store_provider_secret(provider, resolved.as_bytes())?; + println!( + "Stored {} credentials in the encrypted Fission release vault", + provider.as_str() + ); + Ok(()) +} + +fn print_login_instructions(provider: publish::DistributionProvider) { + match provider { + publish::DistributionProvider::PlayStore => println!( + "Google Play uses an Android Publisher API service-account JSON file or a short-lived access token." + ), + publish::DistributionProvider::AppStore => println!( + "App Store Connect uses an issuer id, key id, and .p8 API private key; paste the key contents or import APP_STORE_CONNECT_API_KEY_PATH separately." + ), + publish::DistributionProvider::MicrosoftStore => println!( + "Microsoft Store uses Partner Center/Entra credentials; paste the client secret or pipe it from your secret manager." + ), + publish::DistributionProvider::GithubPages => println!( + "GitHub Pages uses a GitHub token with repository Pages/workflow permissions when direct API access is needed." + ), + publish::DistributionProvider::GithubReleases => println!( + "GitHub Releases uses the GitHub CLI. Run `gh auth login`, set GH_TOKEN/GITHUB_TOKEN, or import a token into the Fission vault." + ), + publish::DistributionProvider::CloudflarePages => println!( + "Cloudflare Pages uses an API token with Pages project edit/deploy permissions." + ), + publish::DistributionProvider::Netlify => println!( + "Netlify uses a personal access token with deploy permissions for the configured site." + ), + publish::DistributionProvider::S3 => println!( + "S3-compatible uploads normally use AWS_PROFILE or access-key environment variables; paste a provider credential only for local vault-backed workflows." + ), + publish::DistributionProvider::GoogleDrive => println!( + "Google Drive uses an OAuth access token for the target account or service account flow you manage outside the project." + ), + publish::DistributionProvider::OneDrive => println!( + "OneDrive uses a Microsoft Graph OAuth access token for the target account." + ), + publish::DistributionProvider::Dropbox => println!( + "Dropbox uses an OAuth access token with files.content.write/read scopes." + ), + } +} + +pub(crate) fn provider_secret( + provider: publish::DistributionProvider, + env_names: &[&str], +) -> Result> { + if let Some(name) = env_names.iter().find(|name| env::var_os(name).is_some()) { + return env::var(name) + .map(Some) + .with_context(|| format!("environment variable {name} is not valid UTF-8")); + } + let path = vault_record_path(provider)?; + if !path.exists() { + return Ok(None); + } + let bytes = load_provider_secret(provider)?; + String::from_utf8(bytes) + .map(Some) + .context("stored provider credential is not valid UTF-8") +} + +fn edit_release_config(project_dir: &Path, tui: bool) -> Result<()> { + let path = project_dir.join("fission.toml"); + fs::metadata(&path).with_context(|| format!("{} does not exist", path.display()))?; + if tui { + return crate::ui::run_ui(crate::ui::UiOptions { + project_dir: project_dir.to_path_buf(), + screenshot: None, + exit_after_render: false, + width: None, + height: None, + }); + } + let editor = env::var("VISUAL") + .or_else(|_| env::var("EDITOR")) + .unwrap_or_else(|_| "vi".to_string()); + let status = Command::new(editor) + .arg(&path) + .status() + .context("failed to launch editor")?; + if !status.success() { + bail!("editor exited with {status}"); + } + Ok(()) +} + +fn set_release_field(project_dir: &Path, field: &str, value: &str, yes: bool) -> Result<()> { + if !yes { + bail!("set rewrites fission.toml; pass --yes after reviewing the field path"); + } + let path = project_dir.join("fission.toml"); + let data = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + let mut doc: toml::Value = + toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))?; + set_toml_path(&mut doc, field, toml::Value::String(value.to_string()))?; + fs::write(&path, toml::to_string_pretty(&doc)? + "\n") + .with_context(|| format!("failed to write {}", path.display()))?; + Ok(()) +} + +fn add_release( + project_dir: &Path, + version: &str, + build: u64, + from: Option<&str>, + yes: bool, +) -> Result<()> { + if !yes { + bail!("add-release appends to fission.toml; pass --yes after reviewing the release id"); + } + let path = project_dir.join("fission.toml"); + let mut text = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + let id = format!("{version}+{build}"); + text.push_str(&format!( + "\n[[releases]]\nid = \"{id}\"\nversion = \"{version}\"\nbuild = {build}\nstatus = \"candidate\"\nmetadata = \"release-content/metadata/{id}/release.toml\"\nrelease_notes = \"release-content/metadata/{id}/notes\"\nreview = \"release-content/metadata/{id}/review.toml\"\nprivacy = \"release-content/metadata/{id}/privacy.toml\"\n" + )); + if let Some(source) = from { + text.push_str(&format!("# copied_from = \"{source}\"\n")); + } + fs::write(&path, text).with_context(|| format!("failed to write {}", path.display()))?; + Ok(()) +} + +fn edit_release_file( + project_dir: &Path, + release: &str, + kind: &str, + locale: Option<&str>, +) -> Result<()> { + let relative = match (kind, locale) { + ("notes", Some(locale)) => format!("release-content/metadata/{release}/notes/{locale}.md"), + ("notes", None) => format!("release-content/metadata/{release}/notes/en-US.md"), + ("review", _) => format!("release-content/metadata/{release}/review.toml"), + ("privacy", _) => format!("release-content/metadata/{release}/privacy.toml"), + ("metadata", _) | ("release", _) => { + format!("release-content/metadata/{release}/release.toml") + } + other => bail!("unsupported release file kind `{}`", other.0), + }; + let path = project_dir.join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + if !path.exists() { + fs::write(&path, "")?; + } + let editor = env::var("VISUAL") + .or_else(|_| env::var("EDITOR")) + .unwrap_or_else(|_| "vi".to_string()); + let status = Command::new(editor).arg(&path).status()?; + if !status.success() { + bail!("editor exited with {status}"); + } + Ok(()) +} + +fn auth_report(area: &str, provider: Option) -> LifecycleReport { + let mut report = base_report(area, provider, None); + let providers = provider + .map(|provider| vec![provider]) + .unwrap_or_else(auth_providers); + for provider in providers { + report.checks.push(provider_env_check(provider)); + } + finalize_status(&mut report); + report +} + +fn auth_setup_report(provider: Option) -> LifecycleReport { + let mut report = base_report("auth.setup", provider, None); + let providers = provider + .map(|provider| vec![provider]) + .unwrap_or_else(auth_providers); + for provider in providers { + let spec = provider_auth_spec(provider); + report.checks.push(LifecycleCheck { + id: format!( + "auth.{}.credential_kind", + provider.as_str().replace('-', "_") + ), + status: "passed".to_string(), + summary: format!("{} credential kind is documented", provider.as_str()), + details: Some(spec.kind.to_string()), + remediation: Vec::new(), + }); + report.checks.push(LifecycleCheck { + id: format!("auth.{}.env", provider.as_str().replace('-', "_")), + status: "passed".to_string(), + summary: format!("{} accepted environment variables", provider.as_str()), + details: Some(spec.env.join(", ")), + remediation: Vec::new(), + }); + report.checks.push(LifecycleCheck { + id: format!("auth.{}.setup", provider.as_str().replace('-', "_")), + status: "passed".to_string(), + summary: format!("{} setup command", provider.as_str()), + details: Some(spec.command.to_string()), + remediation: Vec::new(), + }); + report.checks.push(LifecycleCheck { + id: format!("auth.{}.scopes", provider.as_str().replace('-', "_")), + status: "passed".to_string(), + summary: format!("{} required provider permissions", provider.as_str()), + details: Some(spec.permissions.to_string()), + remediation: Vec::new(), + }); + } + finalize_status(&mut report); + report +} + +fn auth_providers() -> Vec { + vec![ + publish::DistributionProvider::GithubPages, + publish::DistributionProvider::GithubReleases, + publish::DistributionProvider::CloudflarePages, + publish::DistributionProvider::Netlify, + publish::DistributionProvider::S3, + publish::DistributionProvider::GoogleDrive, + publish::DistributionProvider::OneDrive, + publish::DistributionProvider::Dropbox, + publish::DistributionProvider::PlayStore, + publish::DistributionProvider::AppStore, + publish::DistributionProvider::MicrosoftStore, + ] +} + +struct ProviderAuthSpec { + kind: &'static str, + env: &'static [&'static str], + command: &'static str, + permissions: &'static str, +} + +fn provider_auth_spec(provider: publish::DistributionProvider) -> ProviderAuthSpec { + match provider { + publish::DistributionProvider::GithubPages => ProviderAuthSpec { + kind: "GitHub token or GitHub App installation token", + env: &["GH_TOKEN", "GITHUB_TOKEN"], + command: "fission auth import github-pages --from env:GH_TOKEN --yes", + permissions: "repository contents/workflows/pages permissions for local API operations; Actions deployment uses repository workflow permissions", + }, + publish::DistributionProvider::GithubReleases => ProviderAuthSpec { + kind: "Authenticated GitHub CLI session, GitHub token, or GitHub App installation token", + env: &["GH_TOKEN", "GITHUB_TOKEN"], + command: "gh auth login", + permissions: "repository Contents write permission to create/update releases and upload/delete release assets", + }, + publish::DistributionProvider::CloudflarePages => ProviderAuthSpec { + kind: "Cloudflare API token plus Wrangler login/config for uploads", + env: &["CLOUDFLARE_API_TOKEN", "CLOUDFLARE_ACCOUNT_ID"], + command: "fission auth import cloudflare-pages --from env:CLOUDFLARE_API_TOKEN --yes", + permissions: "Pages edit/deploy permission for the target account/project", + }, + publish::DistributionProvider::Netlify => ProviderAuthSpec { + kind: "Netlify personal access token", + env: &["NETLIFY_AUTH_TOKEN"], + command: "fission auth import netlify --from env:NETLIFY_AUTH_TOKEN --yes", + permissions: "site read/deploy permissions for the configured site", + }, + publish::DistributionProvider::S3 => ProviderAuthSpec { + kind: "AWS/S3 profile or access key credentials", + env: &["AWS_PROFILE", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"], + command: "fission auth import s3 --from env:AWS_SECRET_ACCESS_KEY --yes", + permissions: "s3:PutObject, s3:ListBucket, and optional s3:PutObjectAcl for public artifacts", + }, + publish::DistributionProvider::GoogleDrive => ProviderAuthSpec { + kind: "Google OAuth access token or service-account flow managed outside fission.toml", + env: &["GOOGLE_DRIVE_ACCESS_TOKEN"], + command: "fission auth import google-drive --from env:GOOGLE_DRIVE_ACCESS_TOKEN --yes", + permissions: "Drive file create/update permission for the selected folder", + }, + publish::DistributionProvider::OneDrive => ProviderAuthSpec { + kind: "Microsoft Graph OAuth access token", + env: &["ONEDRIVE_ACCESS_TOKEN"], + command: "fission auth import onedrive --from env:ONEDRIVE_ACCESS_TOKEN --yes", + permissions: "Files.ReadWrite or equivalent delegated/application permission for the target drive", + }, + publish::DistributionProvider::Dropbox => ProviderAuthSpec { + kind: "Dropbox OAuth access token", + env: &["DROPBOX_ACCESS_TOKEN"], + command: "fission auth import dropbox --from env:DROPBOX_ACCESS_TOKEN --yes", + permissions: "files.content.write and files.metadata.read for the destination path", + }, + publish::DistributionProvider::PlayStore => ProviderAuthSpec { + kind: "Google Play Android Publisher service-account JSON or access token", + env: &["PLAY_STORE_SERVICE_ACCOUNT_JSON"], + command: "fission auth import play-store --from file:play-service-account.json --yes", + permissions: "Android Publisher API access to the configured package and release tracks", + }, + publish::DistributionProvider::AppStore => ProviderAuthSpec { + kind: "App Store Connect API private key plus issuer/key ids", + env: &[ + "APP_STORE_CONNECT_API_KEY", + "APP_STORE_CONNECT_API_KEY_PATH", + "APP_STORE_CONNECT_ISSUER_ID", + "APP_STORE_CONNECT_KEY_ID", + ], + command: "fission auth import app-store --from file:AuthKey.p8 --yes", + permissions: "App Manager or equivalent App Store Connect API role for metadata, uploads, TestFlight, and submissions", + }, + publish::DistributionProvider::MicrosoftStore => ProviderAuthSpec { + kind: "Partner Center/Entra application secret or access token", + env: &["MICROSOFT_STORE_TOKEN", "MICROSOFT_STORE_CLIENT_SECRET"], + command: "fission auth import microsoft-store --from env:MICROSOFT_STORE_CLIENT_SECRET --yes", + permissions: "Partner Center app submission permissions for the configured product", + }, + } +} + +fn provider_env_check(provider: publish::DistributionProvider) -> LifecycleCheck { + let vars: &[&str] = match provider { + publish::DistributionProvider::GithubPages => &["GH_TOKEN", "GITHUB_TOKEN"], + publish::DistributionProvider::GithubReleases => &["GH_TOKEN", "GITHUB_TOKEN"], + publish::DistributionProvider::CloudflarePages => &["CLOUDFLARE_API_TOKEN"], + publish::DistributionProvider::Netlify => &["NETLIFY_AUTH_TOKEN"], + publish::DistributionProvider::S3 => &["AWS_PROFILE", "AWS_ACCESS_KEY_ID"], + publish::DistributionProvider::GoogleDrive => &["GOOGLE_DRIVE_ACCESS_TOKEN"], + publish::DistributionProvider::OneDrive => &["ONEDRIVE_ACCESS_TOKEN"], + publish::DistributionProvider::Dropbox => &["DROPBOX_ACCESS_TOKEN"], + publish::DistributionProvider::PlayStore => &["PLAY_STORE_SERVICE_ACCOUNT_JSON"], + publish::DistributionProvider::AppStore => &["APP_STORE_CONNECT_API_KEY"], + publish::DistributionProvider::MicrosoftStore => &["MICROSOFT_STORE_TOKEN"], + }; + let found = vars.iter().find(|name| env::var_os(name).is_some()); + let vault_path = vault_record_path(provider).ok(); + let vault_present = vault_path.as_ref().is_some_and(|path| path.exists()); + LifecycleCheck { + id: format!("auth.{}.credentials", provider.as_str().replace('-', "_")), + status: if found.is_some() || vault_present { + "passed" + } else { + "missing" + } + .to_string(), + summary: format!("{} credentials are available", provider.as_str()), + details: found + .map(|name| format!("using {name}")) + .or_else(|| vault_path.map(|path| format!("vault: {}", path.display()))), + remediation: vec![format!( + "Set one of {} or use `fission auth import {} --from env: --yes` to store credentials in the encrypted local vault.", + vars.join(", "), + provider.as_str() + )], + } +} + +fn read_secret_source(source: &str) -> Result { + if let Some(name) = source.strip_prefix("env:") { + env::var(name).with_context(|| format!("environment variable {name} is not set")) + } else if let Some(path) = source.strip_prefix("file:") { + fs::read_to_string(path).with_context(|| format!("failed to read credential file {path}")) + } else { + bail!("credential source must be env: or file:") + } +} + +fn store_provider_secret(provider: publish::DistributionProvider, secret: &[u8]) -> Result<()> { + let key = vault_key(true)?; + let mut nonce = [0u8; 24]; + getrandom::getrandom(&mut nonce)?; + let cipher = XChaCha20Poly1305::new_from_slice(&key) + .map_err(|error| anyhow::anyhow!("failed to initialize vault cipher: {error}"))?; + let ciphertext = cipher + .encrypt(XNonce::from_slice(&nonce), secret) + .map_err(|error| anyhow::anyhow!("failed to encrypt credential record: {error}"))?; + let record = VaultRecord { + schema_version: 1, + provider: provider.as_str().to_string(), + created_at_unix_seconds: now_unix_seconds(), + nonce: STANDARD_NO_PAD.encode(nonce), + ciphertext: STANDARD_NO_PAD.encode(ciphertext), + }; + let path = vault_record_path(provider)?; + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, serde_json::to_vec_pretty(&record)?) + .with_context(|| format!("failed to write {}", path.display()))?; + Ok(()) +} + +fn load_provider_secret(provider: publish::DistributionProvider) -> Result> { + let path = vault_record_path(provider)?; + let record: VaultRecord = serde_json::from_slice( + &fs::read(&path).with_context(|| format!("failed to read {}", path.display()))?, + )?; + let nonce = STANDARD_NO_PAD + .decode(record.nonce) + .context("failed to decode vault nonce")?; + let ciphertext = STANDARD_NO_PAD + .decode(record.ciphertext) + .context("failed to decode vault ciphertext")?; + let key = vault_key(false)?; + let cipher = XChaCha20Poly1305::new_from_slice(&key) + .map_err(|error| anyhow::anyhow!("failed to initialize vault cipher: {error}"))?; + cipher + .decrypt(XNonce::from_slice(&nonce), ciphertext.as_ref()) + .map_err(|error| anyhow::anyhow!("failed to decrypt credential record: {error}")) +} + +fn rotate_provider_secret(provider: publish::DistributionProvider) -> Result<()> { + let secret = load_provider_secret(provider)?; + store_provider_secret(provider, &secret) +} + +fn vault_key(create: bool) -> Result<[u8; 32]> { + let entry = keyring::Entry::new("fission", "release-vault") + .context("failed to open OS credential store for the Fission release vault")?; + match entry.get_password() { + Ok(encoded) => decode_vault_key(&encoded), + Err(error) if create => { + let mut key = [0u8; 32]; + getrandom::getrandom(&mut key)?; + entry + .set_password(&STANDARD_NO_PAD.encode(key)) + .with_context(|| { + format!("failed to store Fission vault key in OS credential store: {error}") + })?; + Ok(key) + } + Err(error) => { + Err(error).context("Fission vault key does not exist in the OS credential store") + } + } +} + +fn decode_vault_key(encoded: &str) -> Result<[u8; 32]> { + let bytes = STANDARD_NO_PAD + .decode(encoded) + .context("failed to decode Fission vault key")?; + let key: [u8; 32] = bytes + .try_into() + .map_err(|_| anyhow::anyhow!("Fission vault key has the wrong length"))?; + Ok(key) +} + +fn vault_record_path(provider: publish::DistributionProvider) -> Result { + Ok(vault_dir()?.join(format!("{}.json", provider.as_str()))) +} + +fn vault_dir() -> Result { + let home = env::var_os("HOME") + .or_else(|| env::var_os("USERPROFILE")) + .context("HOME/USERPROFILE is not set")?; + Ok(PathBuf::from(home).join(".fission/vault")) +} + +fn now_unix_seconds() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn set_toml_path(root: &mut toml::Value, path: &str, value: toml::Value) -> Result<()> { + let mut current = root; + let parts = path.split('.').collect::>(); + if parts.is_empty() || parts.iter().any(|part| part.trim().is_empty()) { + bail!("field path must be dot-separated and non-empty"); + } + for part in &parts[..parts.len() - 1] { + let table = current + .as_table_mut() + .context("field path traversed through a non-table value")?; + current = table + .entry((*part).to_string()) + .or_insert_with(|| toml::Value::Table(Default::default())); + } + let table = current + .as_table_mut() + .context("field path parent is not a table")?; + table.insert(parts[parts.len() - 1].to_string(), value); + Ok(()) +} + +fn base_report( + area: &str, + provider: Option, + target: Option, +) -> LifecycleReport { + LifecycleReport { + area: area.to_string(), + status: "ready".to_string(), + provider: provider.map(|provider| provider.as_str().to_string()), + target: target.map(|target| target.as_str().to_string()), + checks: Vec::new(), + } +} + +fn path_check(id: &str, path: PathBuf, summary: &str) -> LifecycleCheck { + LifecycleCheck { + id: id.to_string(), + status: if path.exists() { "passed" } else { "missing" }.to_string(), + summary: summary.to_string(), + details: Some(path.display().to_string()), + remediation: vec![ + "Create the file/directory or update fission.toml to point at the correct path." + .to_string(), + ], + } +} + +fn value_path_check(value: &toml::Value, path: &str, id: &str, summary: &str) -> LifecycleCheck { + let exists = path + .split('.') + .try_fold(value, |current, segment| current.get(segment)) + .is_some(); + LifecycleCheck { + id: id.to_string(), + status: if exists { "passed" } else { "missing" }.to_string(), + summary: summary.to_string(), + details: Some(path.to_string()), + remediation: vec![ + "Add the missing release configuration or use fission release-config add-release/set." + .to_string(), + ], + } +} + +fn ok_check(id: &str, details: impl Into) -> LifecycleCheck { + LifecycleCheck { + id: id.to_string(), + status: "passed".to_string(), + summary: id.replace('_', " "), + details: Some(details.into()), + remediation: Vec::new(), + } +} + +fn warning_check(id: &str, details: String) -> LifecycleCheck { + LifecycleCheck { + id: id.to_string(), + status: "warning".to_string(), + summary: id.replace('_', " "), + details: Some(details), + remediation: vec![ + "Wire the provider backend before using this command to mutate remote state." + .to_string(), + ], + } +} + +fn failed_check(id: &str, details: String) -> LifecycleCheck { + LifecycleCheck { + id: id.to_string(), + status: "failed".to_string(), + summary: id.replace('_', " "), + details: Some(details), + remediation: vec!["Fix the reported error and rerun the command.".to_string()], + } +} + +fn finalize_status(report: &mut LifecycleReport) { + report.status = if report + .checks + .iter() + .any(|check| check.status == "failed" || check.status == "missing") + { + "blocked" + } else if report.checks.iter().any(|check| check.status == "warning") { + "warning" + } else { + "ready" + } + .to_string(); +} + +fn print_report(mut report: LifecycleReport, json: bool) -> Result<()> { + finalize_status(&mut report); + if json { + println!("{}", serde_json::to_string_pretty(&report)?); + } else { + println!("{}: {}", report.area, report.status); + for check in &report.checks { + println!("[{}] {} - {}", check.status, check.id, check.summary); + if let Some(details) = &check.details { + println!(" {details}"); + } + for remediation in &check.remediation { + println!(" fix: {remediation}"); + } + } + } + if report.status == "blocked" { + bail!("{} is blocked", report.area); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn auth_setup_documents_provider_credentials_without_secrets() { + let report = auth_setup_report(Some(publish::DistributionProvider::CloudflarePages)); + assert_eq!(report.status, "ready"); + assert!(report.checks.iter().any(|check| { + check.id == "auth.cloudflare_pages.env" + && check + .details + .as_deref() + .is_some_and(|details| details.contains("CLOUDFLARE_API_TOKEN")) + })); + assert!(report.checks.iter().any(|check| { + check.id == "auth.cloudflare_pages.scopes" + && check + .details + .as_deref() + .is_some_and(|details| details.contains("Pages")) + })); + } +} diff --git a/crates/tools/fission-cli/src/release/content.rs b/crates/tools/fission-cli/src/release/content.rs new file mode 100644 index 00000000..3cc0c881 --- /dev/null +++ b/crates/tools/fission-cli/src/release/content.rs @@ -0,0 +1,1477 @@ +use super::*; +use anyhow::{Context, Result}; +use base64::engine::general_purpose::STANDARD; +use reqwest::blocking::Client; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::net::TcpListener; +use std::path::Path; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +#[derive(Debug, Deserialize, Default)] +struct ContentToml { + release: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct ReleaseContentRoot { + screenshots: Option, + assets: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct ScreenshotConfig { + raw_dir: Option, + rendered_dir: Option, + #[serde(default)] + scenarios: Vec, +} + +#[derive(Debug, Deserialize, Default)] +struct ScreenshotScenario { + id: Option, + name: Option, + #[serde(default)] + targets: Vec, + script: Option, + command: Option, + test_port: Option, + timeout_ms: Option, + wait_for: Option, + #[serde(default)] + steps: Vec, +} + +#[derive(Debug, Deserialize, Default)] +struct ScreenshotStep { + cmd: String, + text: Option, + key: Option, + modifiers: Option, + ms: Option, + x: Option, + y: Option, + dx: Option, + dy: Option, + width: Option, + height: Option, + name: Option, + path: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct ProviderAssets { + app_store: Option, + play_store: Option, + microsoft_store: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct AppStoreAssets { + screenshot_sets_dir: Option, + app_previews_dir: Option, + #[serde(default)] + review_attachments: Vec, +} + +#[derive(Debug, Deserialize, Default)] +struct PlayStoreAssets { + screenshot_sets_dir: Option, + preview_video_dir: Option, + feature_graphic: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct MicrosoftStoreAssets { + screenshot_sets_dir: Option, + trailers_dir: Option, + logo_dir: Option, +} + +#[derive(Debug, Serialize)] +struct RenderManifest { + schema_version: u32, + created_at_unix_seconds: u64, + provider: String, + source_dir: String, + output_dir: String, + assets: Vec, +} + +#[derive(Debug, Serialize)] +struct RenderedAsset { + kind: String, + source: String, + output: String, + sha256: String, + size_bytes: u64, + width: Option, + height: Option, +} + +pub(super) fn validate_release_content_model( + project_dir: &Path, + provider: Option, +) -> LifecycleReport { + let mut report = base_report("release-content.validate", provider, None); + report.checks.push(path_check( + "release_content.root_exists", + project_dir.join("release-content"), + "release-content directory exists", + )); + let config = match load_content_config(project_dir) { + Ok(config) => { + report.checks.push(ok_check( + "release_content.config_parses", + "fission.toml release content config parses", + )); + config + } + Err(error) => { + report.checks.push(failed_check( + "release_content.config_parses", + error.to_string(), + )); + finalize_status(&mut report); + return report; + } + }; + validate_screenshots(project_dir, &config, provider, &mut report.checks); + validate_provider_assets(project_dir, &config, provider, &mut report.checks); + finalize_status(&mut report); + report +} + +pub(super) fn capture_release_content( + project_dir: &Path, + target: Target, + set: &str, +) -> Result { + let config = load_content_config(project_dir)?; + let mut report = base_report("release-content.capture", None, Some(target)); + let screenshots = config + .release + .as_ref() + .and_then(|release| release.screenshots.as_ref()) + .context("release.screenshots must be configured before capture")?; + let raw_dir = project_dir.join( + screenshots + .raw_dir + .as_deref() + .unwrap_or("release-content/screenshots/raw"), + ); + fs::create_dir_all(&raw_dir)?; + let scenarios = screenshots + .scenarios + .iter() + .filter(|scenario| scenario.targets.iter().any(|item| item == target.as_str())) + .collect::>(); + if scenarios.is_empty() { + report.checks.push(failed_check( + "release_content.capture.scenarios_available", + format!( + "no screenshot scenarios target {} for set {set}", + target.as_str() + ), + )); + finalize_status(&mut report); + return Ok(report); + } + for scenario in scenarios { + capture_scenario( + project_dir, + &raw_dir, + target, + set, + scenario, + &mut report.checks, + )?; + } + finalize_status(&mut report); + Ok(report) +} + +pub(super) fn render_release_content( + project_dir: &Path, + provider: publish::DistributionProvider, +) -> Result { + let config = load_content_config(project_dir)?; + let mut report = base_report("release-content.render", Some(provider), None); + let screenshots = config + .release + .as_ref() + .and_then(|release| release.screenshots.as_ref()) + .context("release.screenshots must be configured before render")?; + let raw_dir = project_dir.join( + screenshots + .raw_dir + .as_deref() + .unwrap_or("release-content/screenshots/raw"), + ); + let rendered_root = project_dir.join( + screenshots + .rendered_dir + .as_deref() + .unwrap_or("release-content/screenshots/rendered"), + ); + let output_dir = rendered_root.join(provider.as_str()); + fs::create_dir_all(&output_dir)?; + let mut assets = Vec::new(); + collect_render_assets(&raw_dir, &raw_dir, &output_dir, &mut assets)?; + let manifest = RenderManifest { + schema_version: 1, + created_at_unix_seconds: now_unix_seconds(), + provider: provider.as_str().to_string(), + source_dir: raw_dir.display().to_string(), + output_dir: output_dir.display().to_string(), + assets, + }; + let manifest_path = output_dir.join("release-content-manifest.json"); + fs::write(&manifest_path, serde_json::to_vec_pretty(&manifest)?)?; + report.checks.push(LifecycleCheck { + id: "release_content.render.manifest_written".to_string(), + status: "passed".to_string(), + summary: "render manifest was written".to_string(), + details: Some(manifest_path.display().to_string()), + remediation: Vec::new(), + }); + report.checks.push(LifecycleCheck { + id: "release_content.render.assets_present".to_string(), + status: if manifest.assets.is_empty() { + "missing" + } else { + "passed" + } + .to_string(), + summary: "rendered release assets exist".to_string(), + details: Some(format!("{} assets", manifest.assets.len())), + remediation: vec![ + "Run release-content capture or add raw screenshots/videos before rendering." + .to_string(), + ], + }); + finalize_status(&mut report); + Ok(report) +} + +fn capture_scenario( + project_dir: &Path, + raw_dir: &Path, + target: Target, + set: &str, + scenario: &ScreenshotScenario, + checks: &mut Vec, +) -> Result<()> { + let id = scenario.id.as_deref().unwrap_or("scenario"); + checks.push(required_text_check( + &format!("release_content.capture.{id}.id"), + scenario.id.as_deref(), + "scenario id is set", + )); + checks.push(required_text_check( + &format!("release_content.capture.{id}.name"), + scenario.name.as_deref(), + "scenario name is set", + )); + checks.push(required_text_check( + &format!("release_content.capture.{id}.wait_for"), + scenario.wait_for.as_deref(), + "scenario wait selector is set", + )); + if scenario.script.is_none() && scenario.command.is_none() { + checks.push(failed_check( + &format!("release_content.capture.{id}.driver"), + "scenario script or command is missing".to_string(), + )); + return Ok(()); + }; + if let Some(script) = scenario.script.as_deref() { + let script_path = project_dir.join(script); + checks.push(path_check( + &format!("release_content.capture.{id}.script_exists"), + script_path.clone(), + "scenario script exists", + )); + if !script_path.exists() { + return Ok(()); + } + return match script_path.extension().and_then(|value| value.to_str()) { + Some("sh") => run_capture_script( + "bash", + &[script_path.to_string_lossy().as_ref()], + project_dir, + raw_dir, + target, + set, + id, + checks, + ), + Some("ps1") => run_capture_script( + "pwsh", + &["-File", script_path.to_string_lossy().as_ref()], + project_dir, + raw_dir, + target, + set, + id, + checks, + ), + _ => { + let receipt = raw_dir.join(format!("{set}-{id}-capture-plan.json")); + let body = serde_json::json!({ + "schema_version": 1, + "target": target.as_str(), + "set": set, + "scenario": id, + "script": script_path, + "wait_for": scenario.wait_for, + "status": "planned", + "note": "Non-shell scenario files are validated and recorded; execution is handled by the Fission platform test runner." + }); + fs::write(&receipt, serde_json::to_vec_pretty(&body)?)?; + checks.push(ok_check( + &format!("release_content.capture.{id}.plan_written"), + receipt.display().to_string(), + )); + Ok(()) + } + }; + } + run_test_control_capture(project_dir, raw_dir, target, set, id, scenario, checks) +} + +fn run_capture_script( + program: &str, + args: &[&str], + project_dir: &Path, + raw_dir: &Path, + target: Target, + set: &str, + id: &str, + checks: &mut Vec, +) -> Result<()> { + let output = Command::new(program) + .args(args) + .current_dir(project_dir) + .env("FISSION_CAPTURE_OUTPUT", raw_dir) + .env("FISSION_CAPTURE_TARGET", target.as_str()) + .env("FISSION_CAPTURE_SET", set) + .env("FISSION_CAPTURE_SCENARIO", id) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .with_context(|| format!("failed to run capture script through {program}"))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + checks.push(LifecycleCheck { + id: format!("release_content.capture.{id}.script_ran"), + status: if output.status.success() { + "passed" + } else { + "failed" + } + .to_string(), + summary: "capture script completed".to_string(), + details: Some(format!( + "stdout: {}; stderr: {}", + stdout.trim(), + stderr.trim() + )), + remediation: vec![ + "Fix the scenario script or run it manually with the printed environment variables." + .to_string(), + ], + }); + Ok(()) +} + +fn run_test_control_capture( + project_dir: &Path, + raw_dir: &Path, + target: Target, + set: &str, + id: &str, + scenario: &ScreenshotScenario, + checks: &mut Vec, +) -> Result<()> { + let command = scenario + .command + .as_deref() + .context("scenario command is missing")?; + let port = scenario.test_port.unwrap_or_else(free_loopback_port); + let timeout = Duration::from_millis(scenario.timeout_ms.unwrap_or(20_000)); + let stdout_path = raw_dir.join(format!("{set}-{id}-stdout.log")); + let stderr_path = raw_dir.join(format!("{set}-{id}-stderr.log")); + let mut child = spawn_capture_command( + project_dir, + command, + target, + set, + id, + port, + &stdout_path, + &stderr_path, + )?; + let result = run_test_control_steps(raw_dir, set, id, scenario, port, timeout, checks); + terminate_capture_process(&mut child); + if let Err(error) = result { + let receipt = write_capture_failure_receipt( + raw_dir, + target, + set, + id, + scenario, + &stdout_path, + &stderr_path, + &error.to_string(), + )?; + checks.push(failed_check( + &format!("release_content.capture.{id}.test_control_failed"), + format!("{}; receipt: {}", error, receipt.display()), + )); + } + checks.push(LifecycleCheck { + id: format!("release_content.capture.{id}.logs"), + status: "passed".to_string(), + summary: "capture command logs were recorded".to_string(), + details: Some(format!( + "stdout: {}; stderr: {}", + stdout_path.display(), + stderr_path.display() + )), + remediation: Vec::new(), + }); + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn spawn_capture_command( + project_dir: &Path, + command: &str, + target: Target, + set: &str, + id: &str, + port: u16, + stdout_path: &Path, + stderr_path: &Path, +) -> Result { + let stdout = fs::File::create(stdout_path)?; + let stderr = fs::File::create(stderr_path)?; + let mut cmd = shell_command(command); + cmd.current_dir(project_dir) + .env("FISSION_TEST_CONTROL_PORT", port.to_string()) + .env("FISSION_CAPTURE_TARGET", target.as_str()) + .env("FISSION_CAPTURE_SET", set) + .env("FISSION_CAPTURE_SCENARIO", id) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)); + cmd.spawn() + .with_context(|| format!("failed to spawn capture command `{command}`")) +} + +fn run_test_control_steps( + raw_dir: &Path, + set: &str, + id: &str, + scenario: &ScreenshotScenario, + port: u16, + timeout: Duration, + checks: &mut Vec, +) -> Result<()> { + let client = Client::builder() + .timeout(Duration::from_secs(30)) + .user_agent("fission-cli-release-content/0.1") + .build()?; + wait_for_test_control(&client, port, timeout)?; + checks.push(ok_check( + &format!("release_content.capture.{id}.test_control_ready"), + format!("http://127.0.0.1:{port}"), + )); + let mut saw_screenshot = false; + for (index, step) in scenario.steps.iter().enumerate() { + let response = send_test_command(&client, port, &step_payload(step, raw_dir, set, id)?)?; + if step.cmd == "screenshot" || step.cmd == "capture_screenshot" { + write_screenshot_response(raw_dir, set, id, index, step, &response)?; + saw_screenshot = true; + } + checks.push(ok_check( + &format!("release_content.capture.{id}.step.{index}"), + step.cmd.clone(), + )); + } + if !saw_screenshot { + let response = send_test_command(&client, port, &json!({"cmd": "CaptureScreenshot"}))?; + write_screenshot_response( + raw_dir, + set, + id, + scenario.steps.len(), + &ScreenshotStep { + cmd: "capture_screenshot".to_string(), + name: Some("final".to_string()), + ..Default::default() + }, + &response, + )?; + } + let _ = send_test_command(&client, port, &json!({"cmd": "Quit"})); + Ok(()) +} + +fn wait_for_test_control(client: &Client, port: u16, timeout: Duration) -> Result<()> { + let start = Instant::now(); + let url = format!("http://127.0.0.1:{port}/health"); + loop { + if client + .get(&url) + .send() + .is_ok_and(|response| response.status().is_success()) + { + return Ok(()); + } + if start.elapsed() > timeout { + bail!("timed out waiting for Fission test control server at {url}"); + } + std::thread::sleep(Duration::from_millis(100)); + } +} + +fn send_test_command(client: &Client, port: u16, payload: &serde_json::Value) -> Result { + let response = client + .post(format!("http://127.0.0.1:{port}/cmd")) + .json(payload) + .send() + .with_context(|| format!("failed to send test command {payload}"))?; + let status = response.status(); + let text = response.text()?; + if !status.is_success() { + bail!("test command failed with {status}: {text}"); + } + let value: Value = serde_json::from_str(&text) + .with_context(|| format!("failed to parse test command response: {text}"))?; + if value.get("status").and_then(Value::as_str) == Some("Error") { + bail!( + "test command returned error: {}", + value + .get("message") + .and_then(Value::as_str) + .unwrap_or("unknown") + ); + } + Ok(value) +} + +fn step_payload(step: &ScreenshotStep, raw_dir: &Path, set: &str, id: &str) -> Result { + match step.cmd.as_str() { + "tap_text" => Ok(json!({"cmd": "TapText", "text": required_step_text(step, "text")?})), + "type_text" => Ok(json!({"cmd": "TypeText", "text": required_step_text(step, "text")?})), + "press_key" => Ok(json!({ + "cmd": "PressKey", + "key": required_step_text(step, "key")?, + "modifiers": step.modifiers.unwrap_or(0) + })), + "tap" => Ok(json!({ + "cmd": "Tap", + "x": required_step_f32(step.x, "x")?, + "y": required_step_f32(step.y, "y")? + })), + "scroll" => Ok(json!({ + "cmd": "Scroll", + "x": step.x.unwrap_or(0.0), + "y": step.y.unwrap_or(0.0), + "dx": step.dx.unwrap_or(0.0), + "dy": step.dy.unwrap_or(0.0) + })), + "wait" => Ok(json!({"cmd": "Wait", "ms": step.ms.unwrap_or(250)})), + "pump" => Ok(json!({"cmd": "Pump"})), + "resize" => Ok(json!({ + "cmd": "SimulateResize", + "width": step.width.context("resize step requires width")?, + "height": step.height.context("resize step requires height")? + })), + "screenshot" | "capture_screenshot" => { + let _ = screenshot_output_path(raw_dir, set, id, 0, step); + Ok(json!({"cmd": "CaptureScreenshot"})) + } + other => bail!("unsupported screenshot scenario step `{other}`"), + } +} + +fn write_screenshot_response( + raw_dir: &Path, + set: &str, + id: &str, + index: usize, + step: &ScreenshotStep, + response: &Value, +) -> Result<()> { + let payload = response + .get("png_base64") + .and_then(Value::as_str) + .context("CaptureScreenshot response did not include png_base64")?; + let bytes = STANDARD + .decode(payload) + .context("CaptureScreenshot response had invalid base64")?; + let path = screenshot_output_path(raw_dir, set, id, index, step); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, bytes).with_context(|| format!("failed to write {}", path.display()))?; + Ok(()) +} + +fn screenshot_output_path( + raw_dir: &Path, + set: &str, + id: &str, + index: usize, + step: &ScreenshotStep, +) -> std::path::PathBuf { + if let Some(path) = step + .path + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + return raw_dir.join(path); + } + let name = step + .name + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("{index:02}")); + raw_dir.join(format!("{set}-{id}-{name}.png")) +} + +fn required_step_text<'a>(step: &'a ScreenshotStep, field: &str) -> Result<&'a str> { + match field { + "text" => step.text.as_deref().context("step requires text"), + "key" => step.key.as_deref().context("step requires key"), + _ => bail!("unknown step text field {field}"), + } +} + +fn required_step_f32(value: Option, field: &str) -> Result { + value.with_context(|| format!("step requires {field}")) +} + +fn free_loopback_port() -> u16 { + TcpListener::bind(("127.0.0.1", 0)) + .ok() + .and_then(|listener| listener.local_addr().ok()) + .map(|addr| addr.port()) + .unwrap_or(19_900) +} + +fn shell_command(command: &str) -> Command { + if cfg!(windows) { + let mut cmd = Command::new("cmd"); + cmd.args(["/C", command]); + cmd + } else { + let mut cmd = Command::new("sh"); + cmd.args(["-c", command]); + cmd + } +} + +fn terminate_capture_process(child: &mut Child) { + if child.try_wait().ok().flatten().is_some() { + return; + } + let _ = child.kill(); + let _ = child.wait(); +} + +fn write_capture_failure_receipt( + raw_dir: &Path, + target: Target, + set: &str, + id: &str, + scenario: &ScreenshotScenario, + stdout_path: &Path, + stderr_path: &Path, + error: &str, +) -> Result { + let receipt = raw_dir.join(format!("{set}-{id}-capture-failure.json")); + let body = json!({ + "schema_version": 1, + "created_at_unix_seconds": now_unix_seconds(), + "target": target.as_str(), + "set": set, + "scenario": { + "id": scenario.id.as_deref(), + "name": scenario.name.as_deref(), + "wait_for": scenario.wait_for.as_deref(), + "command": scenario.command.as_deref(), + "test_port": scenario.test_port, + "timeout_ms": scenario.timeout_ms, + "step_count": scenario.steps.len(), + }, + "stdout": stdout_path.display().to_string(), + "stderr": stderr_path.display().to_string(), + "error": error, + }); + fs::write(&receipt, serde_json::to_vec_pretty(&body)?)?; + Ok(receipt) +} + +fn validate_screenshots( + project_dir: &Path, + config: &ContentToml, + provider: Option, + checks: &mut Vec, +) { + let screenshots = config + .release + .as_ref() + .and_then(|release| release.screenshots.as_ref()); + let Some(screenshots) = screenshots else { + checks.push(failed_check( + "release_content.screenshots_configured", + "[release.screenshots] is missing".to_string(), + )); + return; + }; + let raw_dir = project_dir.join( + screenshots + .raw_dir + .as_deref() + .unwrap_or("release-content/screenshots/raw"), + ); + let rendered_dir = project_dir.join( + screenshots + .rendered_dir + .as_deref() + .unwrap_or("release-content/screenshots/rendered"), + ); + checks.push(path_check( + "release_content.screenshots.raw_dir_exists", + raw_dir, + "raw screenshot directory exists", + )); + checks.push(path_check( + "release_content.screenshots.rendered_dir_exists", + rendered_dir.clone(), + "rendered screenshot directory exists", + )); + checks.push(LifecycleCheck { + id: "release_content.screenshots.scenarios_configured".to_string(), + status: if screenshots.scenarios.is_empty() { + "missing" + } else { + "passed" + } + .to_string(), + summary: "screenshot scenarios are configured".to_string(), + details: Some(format!("{} scenarios", screenshots.scenarios.len())), + remediation: vec![ + "Add [[release.screenshots.scenarios]] entries with id, targets, script, and wait_for." + .to_string(), + ], + }); + for scenario in &screenshots.scenarios { + let id = scenario.id.as_deref().unwrap_or("scenario"); + if let Some(script) = scenario.script.as_deref() { + checks.push(path_check( + &format!("release_content.screenshots.{id}.script_exists"), + project_dir.join(script), + "scenario script exists", + )); + } + } + if let Some(provider) = provider { + let provider_dir = rendered_dir.join(provider.as_str()); + let count = count_assets(&provider_dir).unwrap_or(0); + checks.push(LifecycleCheck { + id: format!("release_content.{}.rendered_assets", provider.as_str()), + status: if count > 0 { "passed" } else { "missing" }.to_string(), + summary: "provider rendered release assets exist".to_string(), + details: Some(format!("{} assets in {}", count, provider_dir.display())), + remediation: vec![format!( + "Run `fission release-content render --provider {}` after capture.", + provider.as_str() + )], + }); + validate_rendered_asset_rules(provider, &provider_dir, checks); + } +} + +fn validate_provider_assets( + project_dir: &Path, + config: &ContentToml, + provider: Option, + checks: &mut Vec, +) { + let assets = config + .release + .as_ref() + .and_then(|release| release.assets.as_ref()); + match provider { + Some(publish::DistributionProvider::PlayStore) => { + if let Some(play) = assets.and_then(|assets| assets.play_store.as_ref()) { + check_optional_path( + project_dir, + "release_content.play_store.feature_graphic", + play.feature_graphic.as_deref(), + "Play Store feature graphic exists", + checks, + ); + check_optional_path( + project_dir, + "release_content.play_store.screenshot_sets_dir", + play.screenshot_sets_dir.as_deref(), + "Play Store screenshot set directory exists", + checks, + ); + check_optional_path( + project_dir, + "release_content.play_store.preview_video_dir", + play.preview_video_dir.as_deref(), + "Play Store preview video directory exists", + checks, + ); + } + } + Some(publish::DistributionProvider::AppStore) => { + if let Some(app) = assets.and_then(|assets| assets.app_store.as_ref()) { + check_optional_path( + project_dir, + "release_content.app_store.screenshot_sets_dir", + app.screenshot_sets_dir.as_deref(), + "App Store screenshot set directory exists", + checks, + ); + check_optional_path( + project_dir, + "release_content.app_store.app_previews_dir", + app.app_previews_dir.as_deref(), + "App Store preview video directory exists", + checks, + ); + for path in &app.review_attachments { + checks.push(path_check( + "release_content.app_store.review_attachment", + project_dir.join(path), + "App Review attachment exists", + )); + } + } + } + Some(publish::DistributionProvider::MicrosoftStore) => { + if let Some(ms) = assets.and_then(|assets| assets.microsoft_store.as_ref()) { + check_optional_path( + project_dir, + "release_content.microsoft_store.screenshot_sets_dir", + ms.screenshot_sets_dir.as_deref(), + "Microsoft Store screenshot directory exists", + checks, + ); + check_optional_path( + project_dir, + "release_content.microsoft_store.trailers_dir", + ms.trailers_dir.as_deref(), + "Microsoft Store trailers directory exists", + checks, + ); + check_optional_path( + project_dir, + "release_content.microsoft_store.logo_dir", + ms.logo_dir.as_deref(), + "Microsoft Store logo directory exists", + checks, + ); + } + } + _ => {} + } +} + +fn collect_render_assets( + root: &Path, + current: &Path, + output_root: &Path, + assets: &mut Vec, +) -> Result<()> { + if !current.exists() { + return Ok(()); + } + for entry in fs::read_dir(current)? { + let entry = entry?; + let path = entry.path(); + if entry.file_type()?.is_dir() { + collect_render_assets(root, &path, output_root, assets)?; + continue; + } + if !is_release_asset(&path) { + continue; + } + let relative = path.strip_prefix(root).unwrap_or(&path); + let dest = output_root.join(relative); + if let Some(parent) = dest.parent() { + fs::create_dir_all(parent)?; + } + fs::copy(&path, &dest)?; + let size = fs::metadata(&dest)?.len(); + let sha256 = sha256_file(&dest)?; + let dimensions = image_dimensions(&dest).ok().flatten(); + assets.push(RenderedAsset { + kind: asset_kind(&dest).to_string(), + source: path.display().to_string(), + output: dest.display().to_string(), + sha256, + size_bytes: size, + width: dimensions.map(|(width, _)| width), + height: dimensions.map(|(_, height)| height), + }); + } + Ok(()) +} + +fn sha256_file(path: &Path) -> Result { + let bytes = fs::read(path)?; + let mut hasher = Sha256::new(); + hasher.update(bytes); + Ok(hex_lower(&hasher.finalize())) +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(HEX[(byte >> 4) as usize] as char); + out.push(HEX[(byte & 0x0f) as usize] as char); + } + out +} + +fn validate_rendered_asset_rules( + provider: publish::DistributionProvider, + provider_dir: &Path, + checks: &mut Vec, +) { + let Ok(files) = rendered_asset_files(provider_dir) else { + return; + }; + let image_files = files + .iter() + .filter(|path| asset_kind(path) == "image") + .collect::>(); + let video_files = files + .iter() + .filter(|path| asset_kind(path) == "video") + .collect::>(); + let (min_images, max_images) = provider_screenshot_count(provider); + checks.push(LifecycleCheck { + id: format!("release_content.{}.screenshot_count", provider.as_str()), + status: if image_files.len() >= min_images && image_files.len() <= max_images { + "passed" + } else { + "failed" + } + .to_string(), + summary: "provider screenshot count is within supported bounds".to_string(), + details: Some(format!( + "{} screenshots, expected {}..={}", + image_files.len(), + min_images, + max_images + )), + remediation: vec![format!( + "Render a provider screenshot set with between {min_images} and {max_images} images." + )], + }); + for path in image_files { + validate_image_asset(provider, path, checks); + } + for path in video_files { + validate_video_asset(provider, path, checks); + } +} + +fn rendered_asset_files(root: &Path) -> Result> { + let mut files = Vec::new(); + collect_rendered_asset_files(root, &mut files)?; + Ok(files) +} + +fn collect_rendered_asset_files(root: &Path, files: &mut Vec) -> Result<()> { + if !root.exists() { + return Ok(()); + } + for entry in fs::read_dir(root)? { + let entry = entry?; + let path = entry.path(); + if entry.file_type()?.is_dir() { + collect_rendered_asset_files(&path, files)?; + } else if is_release_asset(&path) { + files.push(path); + } + } + Ok(()) +} + +fn validate_image_asset( + provider: publish::DistributionProvider, + path: &Path, + checks: &mut Vec, +) { + let id_stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("image"); + let ext = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + let allowed = provider_image_extensions(provider); + checks.push(LifecycleCheck { + id: format!( + "release_content.{}.image.{id_stem}.format", + provider.as_str() + ), + status: if allowed.contains(&ext.as_str()) { + "passed" + } else { + "failed" + } + .to_string(), + summary: "image format is accepted by the provider".to_string(), + details: Some(path.display().to_string()), + remediation: vec![format!("Use one of: {}.", allowed.join(", "))], + }); + let size = fs::metadata(path) + .map(|metadata| metadata.len()) + .unwrap_or(0); + let max_bytes = provider_max_image_bytes(provider); + checks.push(LifecycleCheck { + id: format!("release_content.{}.image.{id_stem}.size", provider.as_str()), + status: if size > 0 && size <= max_bytes { + "passed" + } else { + "failed" + } + .to_string(), + summary: "image file size is accepted by the provider".to_string(), + details: Some(format!("{size} bytes; max {max_bytes} bytes")), + remediation: vec![ + "Re-render the image at an accepted resolution/compression level.".to_string(), + ], + }); + match image_dimensions(path) { + Ok(Some((width, height))) => { + let valid = provider_dimension_check(provider, width, height); + checks.push(LifecycleCheck { + id: format!( + "release_content.{}.image.{id_stem}.dimensions", + provider.as_str() + ), + status: if valid { "passed" } else { "failed" }.to_string(), + summary: "image dimensions are accepted by the provider".to_string(), + details: Some(format!("{width}x{height}")), + remediation: vec![ + "Capture/render the screenshot at a provider-supported device size." + .to_string(), + ], + }); + } + Ok(None) | Err(_) => checks.push(LifecycleCheck { + id: format!( + "release_content.{}.image.{id_stem}.dimensions", + provider.as_str() + ), + status: "failed".to_string(), + summary: "image dimensions can be read".to_string(), + details: Some(path.display().to_string()), + remediation: vec![ + "Replace the file with a valid PNG/JPEG/WebP screenshot asset.".to_string(), + ], + }), + } +} + +fn validate_video_asset( + provider: publish::DistributionProvider, + path: &Path, + checks: &mut Vec, +) { + let id_stem = path + .file_stem() + .and_then(|value| value.to_str()) + .unwrap_or("video"); + let ext = path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + let allowed = provider_video_extensions(provider); + checks.push(LifecycleCheck { + id: format!( + "release_content.{}.video.{id_stem}.format", + provider.as_str() + ), + status: if allowed.contains(&ext.as_str()) { + "passed" + } else { + "failed" + } + .to_string(), + summary: "video format is accepted by the provider".to_string(), + details: Some(path.display().to_string()), + remediation: vec![format!("Use one of: {}.", allowed.join(", "))], + }); +} + +fn provider_screenshot_count(provider: publish::DistributionProvider) -> (usize, usize) { + match provider { + publish::DistributionProvider::AppStore => (1, 10), + publish::DistributionProvider::PlayStore => (2, 8), + publish::DistributionProvider::MicrosoftStore => (1, 10), + _ => (1, usize::MAX), + } +} + +fn provider_image_extensions(provider: publish::DistributionProvider) -> &'static [&'static str] { + match provider { + publish::DistributionProvider::PlayStore => &["png", "jpg", "jpeg", "webp"], + publish::DistributionProvider::AppStore => &["png", "jpg", "jpeg"], + publish::DistributionProvider::MicrosoftStore => &["png", "jpg", "jpeg"], + _ => &["png", "jpg", "jpeg", "webp"], + } +} + +fn provider_video_extensions(provider: publish::DistributionProvider) -> &'static [&'static str] { + match provider { + publish::DistributionProvider::AppStore => &["mov", "m4v", "mp4"], + publish::DistributionProvider::MicrosoftStore => &["mp4"], + _ => &["mp4"], + } +} + +fn provider_max_image_bytes(provider: publish::DistributionProvider) -> u64 { + match provider { + publish::DistributionProvider::PlayStore => 8 * 1024 * 1024, + publish::DistributionProvider::AppStore => 10 * 1024 * 1024, + publish::DistributionProvider::MicrosoftStore => 50 * 1024 * 1024, + _ => 10 * 1024 * 1024, + } +} + +fn provider_dimension_check( + provider: publish::DistributionProvider, + width: u32, + height: u32, +) -> bool { + match provider { + publish::DistributionProvider::PlayStore => { + let min = width.min(height); + let max = width.max(height); + min >= 320 && max <= 3840 && max <= min * 2 + } + publish::DistributionProvider::AppStore => width >= 320 && height >= 320, + publish::DistributionProvider::MicrosoftStore => width >= 1366 && height >= 768, + _ => width > 0 && height > 0, + } +} + +fn check_optional_path( + project_dir: &Path, + id: &str, + value: Option<&str>, + summary: &str, + checks: &mut Vec, +) { + if let Some(value) = value.filter(|value| !value.trim().is_empty()) { + checks.push(path_check(id, project_dir.join(value), summary)); + } else { + checks.push(LifecycleCheck { + id: id.to_string(), + status: "missing".to_string(), + summary: summary.to_string(), + details: None, + remediation: vec!["Configure the provider asset path in fission.toml.".to_string()], + }); + } +} + +fn count_assets(path: &Path) -> Result { + let mut count = 0; + if !path.exists() { + return Ok(0); + } + for entry in fs::read_dir(path)? { + let entry = entry?; + let path = entry.path(); + if entry.file_type()?.is_dir() { + count += count_assets(&path)?; + } else if is_release_asset(&path) { + count += 1; + } + } + Ok(count) +} + +fn is_release_asset(path: &Path) -> bool { + matches!( + path.extension() + .and_then(|value| value.to_str()) + .map(|value| value.to_ascii_lowercase()) + .as_deref(), + Some("png" | "jpg" | "jpeg" | "webp" | "mp4" | "mov" | "m4v") + ) +} + +fn asset_kind(path: &Path) -> &'static str { + match path + .extension() + .and_then(|value| value.to_str()) + .unwrap_or("") + .to_ascii_lowercase() + .as_str() + { + "mp4" | "mov" | "m4v" => "video", + _ => "image", + } +} + +fn image_dimensions(path: &Path) -> Result> { + let bytes = fs::read(path)?; + if bytes.len() >= 24 && bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + let width = u32::from_be_bytes(bytes[16..20].try_into().unwrap()); + let height = u32::from_be_bytes(bytes[20..24].try_into().unwrap()); + return Ok(Some((width, height))); + } + if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + return Ok(webp_dimensions(&bytes)); + } + if bytes.len() >= 4 && bytes[0] == 0xff && bytes[1] == 0xd8 { + return Ok(jpeg_dimensions(&bytes)); + } + Ok(None) +} + +fn jpeg_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { + let mut index = 2usize; + while index + 9 < bytes.len() { + if bytes[index] != 0xff { + index += 1; + continue; + } + while index < bytes.len() && bytes[index] == 0xff { + index += 1; + } + if index >= bytes.len() { + return None; + } + let marker = bytes[index]; + index += 1; + if matches!(marker, 0xd8 | 0xd9 | 0x01) { + continue; + } + if index + 2 > bytes.len() { + return None; + } + let len = u16::from_be_bytes([bytes[index], bytes[index + 1]]) as usize; + if len < 2 || index + len > bytes.len() { + return None; + } + if matches!( + marker, + 0xc0 | 0xc1 + | 0xc2 + | 0xc3 + | 0xc5 + | 0xc6 + | 0xc7 + | 0xc9 + | 0xca + | 0xcb + | 0xcd + | 0xce + | 0xcf + ) && len >= 7 + { + let height = u16::from_be_bytes([bytes[index + 3], bytes[index + 4]]) as u32; + let width = u16::from_be_bytes([bytes[index + 5], bytes[index + 6]]) as u32; + return Some((width, height)); + } + index += len; + } + None +} + +fn webp_dimensions(bytes: &[u8]) -> Option<(u32, u32)> { + match bytes.get(12..16)? { + b"VP8X" if bytes.len() >= 30 => { + let width = 1 + u32::from_le_bytes([bytes[24], bytes[25], bytes[26], 0]); + let height = 1 + u32::from_le_bytes([bytes[27], bytes[28], bytes[29], 0]); + Some((width, height)) + } + b"VP8 " if bytes.len() >= 30 => { + let width = u16::from_le_bytes([bytes[26], bytes[27]]) as u32 & 0x3fff; + let height = u16::from_le_bytes([bytes[28], bytes[29]]) as u32 & 0x3fff; + Some((width, height)) + } + b"VP8L" if bytes.len() >= 25 => { + let b0 = bytes[21] as u32; + let b1 = bytes[22] as u32; + let b2 = bytes[23] as u32; + let b3 = bytes[24] as u32; + let width = 1 + (((b1 & 0x3f) << 8) | b0); + let height = 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6)); + Some((width, height)) + } + _ => None, + } +} + +fn required_text_check(id: &str, value: Option<&str>, summary: &str) -> LifecycleCheck { + LifecycleCheck { + id: id.to_string(), + status: if value.is_some_and(|value| !value.trim().is_empty()) { + "passed" + } else { + "missing" + } + .to_string(), + summary: summary.to_string(), + details: value.map(str::to_string), + remediation: vec!["Set the missing scenario field in fission.toml.".to_string()], + } +} + +fn load_content_config(project_dir: &Path) -> Result { + let path = project_dir.join("fission.toml"); + let data = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn unique_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "fission-release-content-{name}-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn write_content_project(dir: &Path) { + fs::create_dir_all(dir.join("release-content/screenshots/raw/en-US")).unwrap(); + fs::write( + dir.join("release-content/screenshots/raw/en-US/home.png"), + b"png", + ) + .unwrap(); + fs::create_dir_all(dir.join("tests/release_screenshots")).unwrap(); + fs::write( + dir.join("tests/release_screenshots/home.toml"), + "wait = true\n", + ) + .unwrap(); + fs::write( + dir.join("fission.toml"), + r#"[app] +name = "content-demo" +app_id = "com.example.content_demo" + +[release.screenshots] +raw_dir = "release-content/screenshots/raw" +rendered_dir = "release-content/screenshots/rendered" + +[[release.screenshots.scenarios]] +id = "home" +name = "Home" +targets = ["web"] +script = "tests/release_screenshots/home.toml" +wait_for = "semantic:home" + +[release.assets.play_store] +screenshot_sets_dir = "release-content/screenshots/rendered/play-store" +feature_graphic = "release-content/screenshots/raw/en-US/home.png" +"#, + ) + .unwrap(); + } + + fn png_header(width: u32, height: u32) -> Vec { + let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec(); + bytes.extend_from_slice(&13u32.to_be_bytes()); + bytes.extend_from_slice(b"IHDR"); + bytes.extend_from_slice(&width.to_be_bytes()); + bytes.extend_from_slice(&height.to_be_bytes()); + bytes.extend_from_slice(&[8, 6, 0, 0, 0]); + bytes.extend_from_slice(&0u32.to_be_bytes()); + bytes + } + + #[test] + fn render_release_content_copies_raw_assets_and_writes_manifest() { + let dir = unique_dir("render"); + write_content_project(&dir); + let report = + render_release_content(&dir, publish::DistributionProvider::PlayStore).unwrap(); + assert_ne!(report.status, "blocked"); + assert!(dir + .join("release-content/screenshots/rendered/play-store/en-US/home.png") + .exists()); + let manifest = dir + .join("release-content/screenshots/rendered/play-store/release-content-manifest.json"); + assert!(manifest.exists()); + let manifest: serde_json::Value = + serde_json::from_slice(&fs::read(manifest).unwrap()).unwrap(); + let sha = manifest["assets"][0]["sha256"].as_str().unwrap(); + assert_eq!(sha.len(), 64); + } + + #[test] + fn image_dimensions_reads_png_header() { + let dir = unique_dir("png-dimensions"); + let path = dir.join("screen.png"); + fs::write(&path, png_header(1440, 2560)).unwrap(); + assert_eq!(image_dimensions(&path).unwrap(), Some((1440, 2560))); + } + + #[test] + fn provider_asset_validation_reports_dimensions() { + let dir = unique_dir("asset-rules"); + let provider_dir = dir.join("release-content/screenshots/rendered/play-store/en-US"); + fs::create_dir_all(&provider_dir).unwrap(); + fs::write(provider_dir.join("one.png"), png_header(1440, 2560)).unwrap(); + fs::write(provider_dir.join("two.png"), png_header(1440, 2560)).unwrap(); + let mut checks = Vec::new(); + validate_rendered_asset_rules( + publish::DistributionProvider::PlayStore, + &dir.join("release-content/screenshots/rendered/play-store"), + &mut checks, + ); + assert!(checks.iter().any(|check| { + check.id == "release_content.play-store.screenshot_count" && check.status == "passed" + })); + assert!(checks + .iter() + .any(|check| { check.id.ends_with(".dimensions") && check.status == "passed" })); + } + + #[test] + fn screenshot_step_payload_uses_test_control_protocol() { + let step = ScreenshotStep { + cmd: "tap_text".to_string(), + text: Some("Save".to_string()), + ..Default::default() + }; + let payload = step_payload(&step, Path::new("/tmp"), "store", "save").unwrap(); + assert_eq!(payload["cmd"], "TapText"); + assert_eq!(payload["text"], "Save"); + } +} diff --git a/crates/tools/fission-cli/src/release/microsoft_store_ops.rs b/crates/tools/fission-cli/src/release/microsoft_store_ops.rs new file mode 100644 index 00000000..5c4bdaab --- /dev/null +++ b/crates/tools/fission-cli/src/release/microsoft_store_ops.rs @@ -0,0 +1,887 @@ +use super::*; +use anyhow::{bail, Context, Result}; +use reqwest::blocking::{Client, Response}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::env; +use std::fs; +use std::path::Path; +use std::time::Duration; + +const MICROSOFT_STORE_API: &str = "https://api.store.microsoft.com"; +const MICROSOFT_STORE_SCOPE: &str = "https://api.store.microsoft.com/.default"; + +#[derive(Debug, Deserialize, Default)] +struct ReleaseProviderToml { + distribution: Option, + release: Option, + #[serde(default)] + releases: Vec, +} + +#[derive(Debug, Deserialize, Default)] +struct DistributionToml { + microsoft_store: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct ReleaseRootToml { + active_release: Option, + #[serde(default)] + default_locales: Vec, + #[serde(default)] + store_listing: BTreeMap>, +} + +#[derive(Debug, Deserialize, Default)] +struct ReleaseEntryToml { + id: Option, + #[serde(default)] + locales: Vec, + metadata: Option, + release_notes: Option, +} + +#[derive(Clone, Debug, Deserialize, Default, Serialize, PartialEq, Eq)] +struct StoreListingToml { + title: Option, + name: Option, + short_description: Option, + subtitle: Option, + #[serde(default)] + keywords: Vec, + privacy_url: Option, + support_url: Option, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct ReleaseMetadataToml { + #[serde(default)] + microsoft_store: BTreeMap, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct MicrosoftReleaseMetadataToml { + description: Option, + #[serde(default)] + features: Vec, + #[serde(default)] + search_terms: Vec, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct MicrosoftStoreConfig { + product_id: Option, + tenant_id: Option, + client_id: Option, + seller_id: Option, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +struct MicrosoftListing { + language: String, + title: Option, + short_description: Option, + description: String, + keywords: Vec, + privacy_url: Option, + support_url: Option, + release_notes: Option, + features: Vec, + search_terms: Vec, +} + +#[derive(Debug, Deserialize)] +struct OAuthTokenResponse { + access_token: String, +} + +pub(super) fn release_config_import( + project_dir: &Path, + locales: Option<&str>, + yes: bool, + json_output: bool, +) -> Result<()> { + if !yes { + bail!("release-config import mutates fission.toml/release metadata; pass --yes after reviewing the provider and locales"); + } + let root = read_release_provider_toml(project_dir)?; + let cfg = microsoft_store_config(project_dir)?; + let product_id = product_id(&cfg)?; + let seller_id = seller_id(&cfg)?; + let client = http_client()?; + let token = microsoft_store_access_token(&cfg, &client)?; + let remote = fetch_microsoft_listings(&client, &token, &seller_id, product_id, locales)?; + write_imported_microsoft_listings(project_dir, &root, &remote)?; + let summary = json!({ + "provider": "microsoft-store", + "product_id": product_id, + "imported_locales": remote.iter().map(|item| item.language.as_str()).collect::>(), + "status": "imported" + }); + if json_output { + println!("{}", serde_json::to_string_pretty(&summary)?); + } else { + println!("Imported {} Microsoft Store listing(s)", remote.len()); + } + Ok(()) +} + +pub(super) fn release_config_diff(project_dir: &Path, json_output: bool) -> Result<()> { + let root = read_release_provider_toml(project_dir)?; + let cfg = microsoft_store_config(project_dir)?; + let product_id = product_id(&cfg)?; + let seller_id = seller_id(&cfg)?; + let locales = resolve_release_locales(&root, None)?; + let local = resolve_microsoft_listings(project_dir, &root, &locales)?; + let client = http_client()?; + let token = microsoft_store_access_token(&cfg, &client)?; + let remote = fetch_microsoft_listings(&client, &token, &seller_id, product_id, None)?; + let diff = microsoft_listing_diff(&local, &remote); + if json_output { + println!("{}", serde_json::to_string_pretty(&diff)?); + } else if diff.as_array().is_some_and(Vec::is_empty) { + println!( + "Microsoft Store metadata is in sync for {} locale(s)", + locales.len() + ); + } else { + println!("Microsoft Store metadata differences:"); + for item in diff.as_array().into_iter().flatten() { + println!( + "{} {}: local={:?} remote={:?}", + item.get("locale") + .and_then(Value::as_str) + .unwrap_or(""), + item.get("field") + .and_then(Value::as_str) + .unwrap_or(""), + item.get("local"), + item.get("remote") + ); + } + } + Ok(()) +} + +pub(super) fn release_config_push( + project_dir: &Path, + locales_arg: Option<&str>, + dry_run: bool, + yes: bool, + json_output: bool, +) -> Result<()> { + if !dry_run && !yes { + bail!("release-config push mutates provider metadata; pass --yes after reviewing `release-config diff`"); + } + let root = read_release_provider_toml(project_dir)?; + let cfg = microsoft_store_config(project_dir)?; + let product_id = product_id(&cfg)?; + let seller_id = seller_id(&cfg)?; + let locales = resolve_release_locales(&root, locales_arg)?; + let local = resolve_microsoft_listings(project_dir, &root, &locales)?; + if dry_run { + let value = json!({ + "provider": "microsoft-store", + "product_id": product_id, + "locales": locales, + "listings": local, + "status": "dry-run" + }); + if json_output { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + println!("Would push {} Microsoft Store listing(s)", local.len()); + } + return Ok(()); + } + + let client = http_client()?; + let token = microsoft_store_access_token(&cfg, &client)?; + let remote = fetch_microsoft_raw_listings(&client, &token, &seller_id, product_id, None)?; + let mut responses = Vec::new(); + for listing in &local { + let existing = find_microsoft_listing_value(&remote, &listing.language); + let payload = microsoft_listing_payload(existing, listing); + let response = client + .put(format!( + "{MICROSOFT_STORE_API}/submission/v1/product/{product_id}/metadata" + )) + .bearer_auth(&token) + .header("X-Seller-Account-Id", &seller_id) + .json(&payload) + .send() + .with_context(|| { + format!( + "failed to push Microsoft Store metadata for {}", + listing.language + ) + })?; + let value = json_response(response, "Microsoft Store metadata push")?; + microsoft_store_success(&value, "Microsoft Store metadata push")?; + responses.push(value); + } + let summary = json!({ + "provider": "microsoft-store", + "product_id": product_id, + "pushed_locales": local.iter().map(|item| item.language.as_str()).collect::>(), + "responses": responses, + "status": "pushed" + }); + if json_output { + println!("{}", serde_json::to_string_pretty(&summary)?); + } else { + println!("Pushed {} Microsoft Store listing(s)", local.len()); + } + Ok(()) +} + +fn fetch_microsoft_listings( + client: &Client, + token: &str, + seller_id: &str, + product_id: &str, + locales: Option<&str>, +) -> Result> { + let value = fetch_microsoft_raw_listings(client, token, seller_id, product_id, locales)?; + microsoft_listings_from_value(&value) +} + +fn fetch_microsoft_raw_listings( + client: &Client, + token: &str, + seller_id: &str, + product_id: &str, + locales: Option<&str>, +) -> Result { + let mut url = + format!("{MICROSOFT_STORE_API}/submission/v1/product/{product_id}/metadata/listings"); + if let Some(locales) = locales.filter(|value| !value.trim().is_empty()) { + url.push_str("?languages="); + url.push_str(&encode_query(locales)); + } + let response = client + .get(url) + .bearer_auth(token) + .header("X-Seller-Account-Id", seller_id) + .send() + .context("failed to fetch Microsoft Store listing metadata")?; + json_response(response, "Microsoft Store metadata listing") +} + +fn microsoft_listings_from_value(value: &Value) -> Result> { + let listings = value + .pointer("/responseData/listings") + .or_else(|| value.get("listings")) + .and_then(Value::as_array) + .context("Microsoft Store metadata response did not contain responseData.listings")?; + listings + .iter() + .map(microsoft_listing_from_value) + .collect::>>() +} + +fn microsoft_listing_from_value(value: &Value) -> Result { + let language = field_string(value, &["language", "locale"]) + .context("Microsoft Store listing did not contain language")?; + let keywords = field_array(value, &["keywords", "keywordTerms"]); + let search_terms = field_array(value, &["searchTerms", "search_terms"]); + let description = field_string(value, &["description"]).unwrap_or_default(); + Ok(MicrosoftListing { + language, + title: field_string(value, &["title", "name", "sortTitle"]), + short_description: field_string( + value, + &["shortDescription", "short_description", "subtitle"], + ), + description, + keywords, + privacy_url: field_string(value, &["privacyUrl", "privacyPolicyUrl", "privacy_url"]), + support_url: field_string(value, &["supportUrl", "support_url"]), + release_notes: field_string(value, &["whatsNew", "releaseNotes", "release_notes"]), + features: field_array(value, &["features", "productFeatures"]), + search_terms, + }) +} + +fn find_microsoft_listing_value<'a>(value: &'a Value, language: &str) -> Option<&'a Value> { + value + .pointer("/responseData/listings") + .or_else(|| value.get("listings")) + .and_then(Value::as_array)? + .iter() + .find(|item| { + field_string(item, &["language", "locale"]) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(language)) + }) +} + +fn resolve_microsoft_listings( + project_dir: &Path, + root: &ReleaseProviderToml, + locales: &[String], +) -> Result> { + locales + .iter() + .map(|locale| resolve_microsoft_listing(project_dir, root, locale)) + .collect() +} + +fn resolve_microsoft_listing( + project_dir: &Path, + root: &ReleaseProviderToml, + locale: &str, +) -> Result { + let release = active_release(root).context("release.active_release must point to a release")?; + let metadata_path = release + .metadata + .as_deref() + .context("active release metadata path is required for Microsoft Store metadata sync")?; + let metadata = read_release_metadata(project_dir, metadata_path)?; + let release_metadata = metadata + .microsoft_store + .get(locale) + .or_else(|| metadata.microsoft_store.get(&locale.to_ascii_lowercase())) + .with_context(|| { + format!("active release metadata [microsoft_store.{locale}].description is required") + })?; + let listing = root + .release + .as_ref() + .and_then(|release| release.store_listing.get("microsoft_store")) + .and_then(|store| { + store + .get(locale) + .or_else(|| store.get(&locale.to_ascii_lowercase())) + }) + .cloned() + .unwrap_or_default(); + let description = release_metadata + .description + .clone() + .filter(|value| !value.trim().is_empty()) + .with_context(|| { + format!("active release metadata [microsoft_store.{locale}].description is required") + })?; + let release_notes = release + .release_notes + .as_deref() + .map(|notes_dir| project_dir.join(notes_dir).join(format!("{locale}.md"))) + .filter(|path| path.exists()) + .map(fs::read_to_string) + .transpose()?; + Ok(MicrosoftListing { + language: locale.to_string(), + title: listing.title.or(listing.name), + short_description: listing.short_description.or(listing.subtitle), + description, + keywords: listing.keywords, + privacy_url: listing.privacy_url, + support_url: listing.support_url, + release_notes, + features: release_metadata.features.clone(), + search_terms: release_metadata.search_terms.clone(), + }) +} + +fn microsoft_listing_payload(existing: Option<&Value>, listing: &MicrosoftListing) -> Value { + let mut listing_value = existing.cloned().unwrap_or_else(|| json!({})); + set_json_field( + &mut listing_value, + "language", + Some(listing.language.clone()), + ); + set_json_field( + &mut listing_value, + "description", + Some(listing.description.clone()), + ); + set_json_field( + &mut listing_value, + "shortDescription", + listing.short_description.clone(), + ); + set_json_field(&mut listing_value, "sortTitle", listing.title.clone()); + set_json_field( + &mut listing_value, + "privacyPolicyUrl", + listing.privacy_url.clone(), + ); + set_json_field( + &mut listing_value, + "supportUrl", + listing.support_url.clone(), + ); + set_json_field( + &mut listing_value, + "whatsNew", + listing.release_notes.clone(), + ); + set_json_array(&mut listing_value, "keywords", &listing.keywords); + set_json_array(&mut listing_value, "searchTerms", &listing.search_terms); + set_json_array(&mut listing_value, "features", &listing.features); + json!({ "listings": listing_value }) +} + +fn write_imported_microsoft_listings( + project_dir: &Path, + root: &ReleaseProviderToml, + remote: &[MicrosoftListing], +) -> Result<()> { + let release = active_release(root).context("release.active_release must point to a release")?; + let metadata_path = release + .metadata + .as_deref() + .context("active release metadata path is required for Microsoft Store metadata import")?; + let toml_path = project_dir.join("fission.toml"); + let mut fission_doc: toml::Value = toml::from_str(&fs::read_to_string(&toml_path)?)?; + for listing in remote { + if let Some(title) = listing.title.clone() { + set_toml_path( + &mut fission_doc, + &format!( + "release.store_listing.microsoft_store.{}.title", + listing.language + ), + toml::Value::String(title), + )?; + } + if let Some(short_description) = listing.short_description.clone() { + set_toml_path( + &mut fission_doc, + &format!( + "release.store_listing.microsoft_store.{}.short_description", + listing.language + ), + toml::Value::String(short_description), + )?; + } + if let Some(privacy_url) = listing.privacy_url.clone() { + set_toml_path( + &mut fission_doc, + &format!( + "release.store_listing.microsoft_store.{}.privacy_url", + listing.language + ), + toml::Value::String(privacy_url), + )?; + } + } + fs::write(&toml_path, toml::to_string_pretty(&fission_doc)? + "\n")?; + + let metadata_abs = project_dir.join(metadata_path); + let mut metadata_doc: toml::Value = if metadata_abs.exists() { + toml::from_str(&fs::read_to_string(&metadata_abs)?)? + } else { + toml::Value::Table(Default::default()) + }; + for listing in remote { + set_toml_path( + &mut metadata_doc, + &format!("microsoft_store.{}.description", listing.language), + toml::Value::String(listing.description.clone()), + )?; + if !listing.features.is_empty() { + set_toml_path( + &mut metadata_doc, + &format!("microsoft_store.{}.features", listing.language), + toml::Value::Array( + listing + .features + .iter() + .cloned() + .map(toml::Value::String) + .collect(), + ), + )?; + } + if !listing.search_terms.is_empty() { + set_toml_path( + &mut metadata_doc, + &format!("microsoft_store.{}.search_terms", listing.language), + toml::Value::Array( + listing + .search_terms + .iter() + .cloned() + .map(toml::Value::String) + .collect(), + ), + )?; + } + } + if let Some(parent) = metadata_abs.parent() { + fs::create_dir_all(parent)?; + } + fs::write(metadata_abs, toml::to_string_pretty(&metadata_doc)? + "\n")?; + Ok(()) +} + +fn microsoft_listing_diff(local: &[MicrosoftListing], remote: &[MicrosoftListing]) -> Value { + let mut diffs = Vec::new(); + for local_listing in local { + let remote_listing = remote + .iter() + .find(|item| item.language.eq_ignore_ascii_case(&local_listing.language)); + let Some(remote_listing) = remote_listing else { + diffs.push(json!({ + "locale": local_listing.language, + "field": "listing", + "local": "present", + "remote": null + })); + continue; + }; + push_option_diff( + &mut diffs, + &local_listing.language, + "title", + local_listing.title.as_deref(), + remote_listing.title.as_deref(), + ); + push_option_diff( + &mut diffs, + &local_listing.language, + "short_description", + local_listing.short_description.as_deref(), + remote_listing.short_description.as_deref(), + ); + push_option_diff( + &mut diffs, + &local_listing.language, + "description", + Some(local_listing.description.as_str()), + Some(remote_listing.description.as_str()), + ); + push_option_diff( + &mut diffs, + &local_listing.language, + "privacy_url", + local_listing.privacy_url.as_deref(), + remote_listing.privacy_url.as_deref(), + ); + push_option_diff( + &mut diffs, + &local_listing.language, + "release_notes", + local_listing.release_notes.as_deref(), + remote_listing.release_notes.as_deref(), + ); + if local_listing.keywords != remote_listing.keywords { + diffs.push(json!({ + "locale": local_listing.language, + "field": "keywords", + "local": local_listing.keywords, + "remote": remote_listing.keywords + })); + } + if local_listing.features != remote_listing.features { + diffs.push(json!({ + "locale": local_listing.language, + "field": "features", + "local": local_listing.features, + "remote": remote_listing.features + })); + } + if local_listing.search_terms != remote_listing.search_terms { + diffs.push(json!({ + "locale": local_listing.language, + "field": "search_terms", + "local": local_listing.search_terms, + "remote": remote_listing.search_terms + })); + } + } + Value::Array(diffs) +} + +fn resolve_release_locales( + root: &ReleaseProviderToml, + explicit: Option<&str>, +) -> Result> { + if let Some(explicit) = explicit { + return parse_locale_list(explicit); + } + let release = active_release(root).context("release.active_release must point to a release")?; + let locales = if release.locales.is_empty() { + root.release + .as_ref() + .map(|release| release.default_locales.clone()) + .unwrap_or_default() + } else { + release.locales.clone() + }; + if locales.is_empty() { + bail!("active release must declare locales or release.default_locales") + } + Ok(locales) +} + +fn active_release(root: &ReleaseProviderToml) -> Option<&ReleaseEntryToml> { + let active = root.release.as_ref()?.active_release.as_deref()?; + root.releases + .iter() + .find(|release| release.id.as_deref() == Some(active)) +} + +fn parse_locale_list(locales: &str) -> Result> { + let mut values = locales + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect::>() + .into_iter() + .collect::>(); + if values.is_empty() { + bail!("locale list is empty") + } + values.sort(); + Ok(values) +} + +fn read_release_metadata(project_dir: &Path, relative: &str) -> Result { + let path = project_dir.join(relative); + let data = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display())) +} + +fn read_release_provider_toml(project_dir: &Path) -> Result { + let path = project_dir.join("fission.toml"); + let data = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display())) +} + +fn microsoft_store_config(project_dir: &Path) -> Result { + Ok(read_release_provider_toml(project_dir)? + .distribution + .and_then(|distribution| distribution.microsoft_store) + .unwrap_or_default()) +} + +fn product_id(cfg: &MicrosoftStoreConfig) -> Result<&str> { + cfg.product_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .context("distribution.microsoft_store.product_id is required") +} + +fn seller_id(cfg: &MicrosoftStoreConfig) -> Result { + env_value("MICROSOFT_STORE_SELLER_ID") + .or(cfg.seller_id.clone()) + .context("distribution.microsoft_store.seller_id or MICROSOFT_STORE_SELLER_ID is required") +} + +fn microsoft_store_access_token(cfg: &MicrosoftStoreConfig, client: &Client) -> Result { + if let Some(token) = env_value("MICROSOFT_STORE_TOKEN") { + return Ok(token); + } + let tenant_id = env_value("AZURE_TENANT_ID") + .or(cfg.tenant_id.clone()) + .context("distribution.microsoft_store.tenant_id or AZURE_TENANT_ID is required")?; + let client_id = env_value("AZURE_CLIENT_ID") + .or(cfg.client_id.clone()) + .context("distribution.microsoft_store.client_id or AZURE_CLIENT_ID is required")?; + let client_secret = env_value("MICROSOFT_STORE_CLIENT_SECRET") + .or_else(|| { + provider_secret(publish::DistributionProvider::MicrosoftStore, &[]) + .ok() + .flatten() + }) + .context("MICROSOFT_STORE_CLIENT_SECRET or vault credentials are required")?; + let url = format!("https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token"); + let response = client + .post(url) + .form(&[ + ("grant_type", "client_credentials"), + ("client_id", client_id.as_str()), + ("client_secret", client_secret.as_str()), + ("scope", MICROSOFT_STORE_SCOPE), + ]) + .send() + .context("failed to request Microsoft Store access token")?; + let token: OAuthTokenResponse = response + .error_for_status() + .context("Microsoft Store access token request failed")? + .json() + .context("failed to parse Microsoft Store access token response")?; + Ok(token.access_token) +} + +fn json_response(response: Response, operation: &str) -> Result { + let status = response.status(); + let text = response + .text() + .with_context(|| format!("failed to read {operation} response"))?; + if !status.is_success() { + bail!("{operation} failed with {status}: {text}"); + } + if text.trim().is_empty() { + Ok(Value::Null) + } else { + serde_json::from_str(&text) + .with_context(|| format!("failed to parse {operation} JSON response: {text}")) + } +} + +fn microsoft_store_success(value: &Value, operation: &str) -> Result<()> { + if value + .get("isSuccess") + .and_then(Value::as_bool) + .unwrap_or(true) + { + Ok(()) + } else { + bail!("{operation} returned an unsuccessful response: {value}") + } +} + +fn http_client() -> Result { + Client::builder() + .timeout(Duration::from_secs(300)) + .user_agent("fission-cli-release/0.1") + .build() + .context("failed to build release HTTP client") +} + +fn field_string(value: &Value, fields: &[&str]) -> Option { + fields.iter().find_map(|field| { + value + .get(*field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + }) +} + +fn field_array(value: &Value, fields: &[&str]) -> Vec { + fields + .iter() + .find_map(|field| value.get(*field).and_then(Value::as_array)) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default() +} + +fn set_json_field(value: &mut Value, field: &str, data: Option) { + let Some(object) = value.as_object_mut() else { + return; + }; + if let Some(data) = data.filter(|value| !value.trim().is_empty()) { + object.insert(field.to_string(), Value::String(data)); + } else { + object.remove(field); + } +} + +fn set_json_array(value: &mut Value, field: &str, data: &[String]) { + let Some(object) = value.as_object_mut() else { + return; + }; + if data.is_empty() { + object.remove(field); + } else { + object.insert( + field.to_string(), + Value::Array(data.iter().cloned().map(Value::String).collect()), + ); + } +} + +fn push_option_diff( + diffs: &mut Vec, + locale: &str, + field: &str, + local: Option<&str>, + remote: Option<&str>, +) { + if local != remote { + diffs.push(json!({ + "locale": locale, + "field": field, + "local": local, + "remote": remote + })); + } +} + +fn encode_query(value: &str) -> String { + let mut out = String::new(); + for byte in value.as_bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b',' => { + out.push(*byte as char) + } + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} + +fn env_value(name: &str) -> Option { + env::var(name).ok().filter(|value| !value.trim().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn microsoft_listing_payload_updates_existing_listing() { + let existing = json!({"language":"en-us","description":"old","keep":"yes"}); + let payload = microsoft_listing_payload( + Some(&existing), + &MicrosoftListing { + language: "en-us".to_string(), + title: Some("Todo".to_string()), + short_description: Some("Plan work".to_string()), + description: "A production task app.".to_string(), + keywords: vec!["todo".to_string(), "tasks".to_string()], + privacy_url: Some("https://example.com/privacy".to_string()), + support_url: None, + release_notes: Some("First release".to_string()), + features: vec!["Fast lists".to_string()], + search_terms: vec!["productivity".to_string()], + }, + ); + assert_eq!(payload["listings"]["language"], "en-us"); + assert_eq!(payload["listings"]["description"], "A production task app."); + assert_eq!(payload["listings"]["sortTitle"], "Todo"); + assert_eq!(payload["listings"]["keep"], "yes"); + assert_eq!(payload["listings"]["keywords"][0], "todo"); + } + + #[test] + fn microsoft_listing_parser_accepts_response_data_shape() { + let value = json!({ + "responseData": { + "listings": [{ + "language": "en-us", + "description": "A production app.", + "shortDescription": "Short", + "sortTitle": "Todo", + "privacyPolicyUrl": "https://example.com/privacy", + "whatsNew": "Changed", + "features": ["Fast"], + "searchTerms": ["tasks"] + }] + } + }); + let listings = microsoft_listings_from_value(&value).unwrap(); + assert_eq!(listings[0].language, "en-us"); + assert_eq!(listings[0].title.as_deref(), Some("Todo")); + assert_eq!(listings[0].features, vec!["Fast"]); + assert_eq!(listings[0].search_terms, vec!["tasks"]); + } +} diff --git a/crates/tools/fission-cli/src/release/model.rs b/crates/tools/fission-cli/src/release/model.rs new file mode 100644 index 00000000..4cf80544 --- /dev/null +++ b/crates/tools/fission-cli/src/release/model.rs @@ -0,0 +1,557 @@ +use super::*; +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Deserialize, Default)] +struct ReleaseToml { + app: Option, + release: Option, + #[serde(default)] + releases: Vec, +} + +#[derive(Debug, Deserialize, Default)] +struct ReleaseRoot { + active_release: Option, + metadata_root: Option, + content_output_dir: Option, + #[serde(default)] + default_locales: Vec, + #[serde(default)] + store_listing: BTreeMap>, +} + +#[derive(Debug, Deserialize, Default)] +struct ReleaseEntry { + id: Option, + version: Option, + build: Option, + status: Option, + #[serde(default)] + tracks: Vec, + #[serde(default)] + locales: Vec, + metadata: Option, + release_notes: Option, + review: Option, + privacy: Option, +} + +pub(super) fn validate_release_config_model( + project_dir: &Path, + provider: Option, +) -> Result { + let mut report = base_report("release-config.validate", provider, None); + let path = project_dir.join("fission.toml"); + report.checks.push(path_check( + "release_config.fission_toml_exists", + path.clone(), + "fission.toml exists", + )); + if !path.exists() { + finalize_status(&mut report); + return Ok(report); + } + + let data = fs::read_to_string(&path)?; + let value = match toml::from_str::(&data) { + Ok(value) => { + report.checks.push(ok_check( + "release_config.toml_parses", + "fission.toml parses", + )); + value + } + Err(error) => { + report.checks.push(failed_check( + "release_config.toml_parses", + error.to_string(), + )); + finalize_status(&mut report); + return Ok(report); + } + }; + let manifest: ReleaseToml = toml::from_str(&data).context("failed to parse release schema")?; + + report.checks.push(value_path_check( + &value, + "app", + "release_config.app_table", + "[app] table exists", + )); + report.checks.push(value_path_check( + &value, + "release", + "release_config.release_table", + "[release] table exists", + )); + report.checks.push(value_path_check( + &value, + "releases", + "release_config.releases", + "[[releases]] entries exist", + )); + + if manifest.app.is_none() { + report.checks.push(failed_check( + "release_config.app_required", + "[app] metadata is required for post-build lifecycle commands".to_string(), + )); + } + let Some(root) = manifest.release.as_ref() else { + finalize_status(&mut report); + return Ok(report); + }; + + report.checks.push(required_scalar_check( + "release_config.active_release", + root.active_release.as_deref(), + "release.active_release is set", + )); + report.checks.push(required_scalar_check( + "release_config.metadata_root", + root.metadata_root.as_deref(), + "release.metadata_root is set", + )); + report.checks.push(required_scalar_check( + "release_config.content_output_dir", + root.content_output_dir.as_deref(), + "release.content_output_dir is set", + )); + report.checks.push(list_check( + "release_config.default_locales", + &root.default_locales, + "release.default_locales contains at least one locale", + )); + + let active = root.active_release.as_deref(); + if let Some(active) = active { + let exists = manifest + .releases + .iter() + .any(|release| release.id.as_deref() == Some(active)); + report.checks.push(LifecycleCheck { + id: "release_config.active_release_exists".to_string(), + status: if exists { "passed" } else { "missing" }.to_string(), + summary: "release.active_release points at a [[releases]] entry".to_string(), + details: Some(active.to_string()), + remediation: vec![ + "Add a matching [[releases]] entry or update release.active_release.".to_string(), + ], + }); + } + + for (index, release) in manifest.releases.iter().enumerate() { + validate_release_entry( + project_dir, + root, + release, + index, + provider, + &mut report.checks, + ); + } + + if let Some(provider) = provider { + validate_provider_listing(provider, root, &mut report.checks); + } + + finalize_status(&mut report); + Ok(report) +} + +fn validate_release_entry( + project_dir: &Path, + root: &ReleaseRoot, + release: &ReleaseEntry, + index: usize, + provider: Option, + checks: &mut Vec, +) { + let id = release + .id + .as_deref() + .map(str::to_string) + .unwrap_or_else(|| format!("entry-{index}")); + checks.push(required_scalar_check( + &format!("release_config.release.{id}.version"), + release.version.as_deref(), + "release version is set", + )); + checks.push(LifecycleCheck { + id: format!("release_config.release.{id}.build"), + status: if release.build.is_some() { + "passed" + } else { + "missing" + } + .to_string(), + summary: "release build number is set".to_string(), + details: release.build.map(|build| build.to_string()), + remediation: vec![ + "Set build to the monotonically increasing platform build number.".to_string(), + ], + }); + checks.push(required_scalar_check( + &format!("release_config.release.{id}.status"), + release.status.as_deref(), + "release status is set", + )); + checks.push(list_check( + &format!("release_config.release.{id}.tracks"), + &release.tracks, + "release has at least one target track/channel", + )); + + let locales = if release.locales.is_empty() { + &root.default_locales + } else { + &release.locales + }; + checks.push(list_check( + &format!("release_config.release.{id}.locales"), + locales, + "release resolves at least one locale", + )); + + validate_release_path( + project_dir, + &id, + "metadata", + release.metadata.as_deref(), + true, + checks, + ); + validate_release_path( + project_dir, + &id, + "release_notes", + release.release_notes.as_deref(), + true, + checks, + ); + validate_release_path( + project_dir, + &id, + "review", + release.review.as_deref(), + false, + checks, + ); + validate_release_path( + project_dir, + &id, + "privacy", + release.privacy.as_deref(), + false, + checks, + ); + + if let Some(notes_dir) = release.release_notes.as_deref() { + for locale in locales { + let path = project_dir.join(notes_dir).join(format!("{locale}.md")); + checks.push(path_check( + &format!("release_config.release.{id}.notes.{locale}"), + path.clone(), + "localized release notes exist", + )); + if path.exists() { + validate_no_placeholder_text( + &format!("release_config.release.{id}.notes.{locale}.content"), + &path, + checks, + ); + } + } + } + + if provider.is_some() { + validate_tracks_match_provider(&id, provider, &release.tracks, checks); + } +} + +fn validate_release_path( + project_dir: &Path, + release_id: &str, + kind: &str, + relative: Option<&str>, + required: bool, + checks: &mut Vec, +) { + let Some(relative) = relative.filter(|value| !value.trim().is_empty()) else { + checks.push(LifecycleCheck { + id: format!("release_config.release.{release_id}.{kind}"), + status: if required { "missing" } else { "warning" }.to_string(), + summary: format!("release {kind} path is configured"), + details: None, + remediation: vec![format!( + "Set [[releases]].{kind} to a project-relative path." + )], + }); + return; + }; + let path = project_dir.join(relative); + checks.push(path_check( + &format!("release_config.release.{release_id}.{kind}_exists"), + path.clone(), + &format!("release {kind} path exists"), + )); + checks.push(project_relative_path_check( + &format!("release_config.release.{release_id}.{kind}_inside_project"), + project_dir, + &path, + )); + if path.is_file() { + validate_no_placeholder_text( + &format!("release_config.release.{release_id}.{kind}.content"), + &path, + checks, + ); + } +} + +fn validate_provider_listing( + provider: publish::DistributionProvider, + root: &ReleaseRoot, + checks: &mut Vec, +) { + let key = match provider { + publish::DistributionProvider::PlayStore => "play_store", + publish::DistributionProvider::AppStore => "app_store", + publish::DistributionProvider::MicrosoftStore => "microsoft_store", + _ => return, + }; + let listings = root.store_listing.get(key); + checks.push(LifecycleCheck { + id: format!("release_config.{key}.store_listing_exists"), + status: if listings.is_some() { + "passed" + } else { + "missing" + } + .to_string(), + summary: format!("release.store_listing.{key} has localized metadata"), + details: listings.map(|listings| format!("{} locales", listings.len())), + remediation: vec![format!( + "Add [release.store_listing.{key}.] entries for every release locale." + )], + }); + if let Some(listings) = listings { + for (locale, value) in listings { + validate_listing_value(key, locale, value, checks); + } + } +} + +fn validate_listing_value( + provider_key: &str, + locale: &str, + value: &toml::Value, + checks: &mut Vec, +) { + let table = value.as_table(); + for field in [ + "title", + "name", + "short_description", + "privacy_url", + "support_url", + ] { + if table.and_then(|table| table.get(field)).is_some() { + checks.push(ok_check( + &format!("release_config.{provider_key}.{locale}.{field}"), + format!("{field} configured"), + )); + } + } +} + +fn validate_tracks_match_provider( + release_id: &str, + provider: Option, + tracks: &[String], + checks: &mut Vec, +) { + let Some(provider) = provider else { + return; + }; + let prefix = provider.as_str(); + let matches = tracks.iter().any(|track| track.starts_with(prefix)); + checks.push(LifecycleCheck { + id: format!("release_config.release.{release_id}.tracks_include_provider"), + status: if matches { "passed" } else { "warning" }.to_string(), + summary: "release tracks include the selected provider".to_string(), + details: Some(format!( + "provider = {prefix}, tracks = {}", + tracks.join(",") + )), + remediation: vec![format!( + "Add a track entry such as {prefix}:internal/testflight/public." + )], + }); +} + +fn validate_no_placeholder_text(id: &str, path: &Path, checks: &mut Vec) { + let Ok(text) = fs::read_to_string(path) else { + return; + }; + let lowered = text.to_ascii_lowercase(); + let bad = ["todo", "tbd", "lorem ipsum", "placeholder"] + .iter() + .find(|needle| lowered.contains(**needle)); + checks.push(LifecycleCheck { + id: id.to_string(), + status: if bad.is_some() { "failed" } else { "passed" }.to_string(), + summary: "release content has no placeholder text".to_string(), + details: bad.map(|needle| format!("found {needle} in {}", path.display())), + remediation: vec![ + "Replace placeholder release text with final store-ready content.".to_string(), + ], + }); +} + +fn project_relative_path_check(id: &str, project_dir: &Path, path: &Path) -> LifecycleCheck { + let normalized = normalize_path(path); + let normalized_project = normalize_path(project_dir); + let inside = normalized.starts_with(&normalized_project); + LifecycleCheck { + id: id.to_string(), + status: if inside { "passed" } else { "failed" }.to_string(), + summary: "release content path stays inside the project".to_string(), + details: Some(path.display().to_string()), + remediation: vec!["Move release content under the project or explicitly configure an allowed workspace path once remote-builder support lands.".to_string()], + } +} + +fn required_scalar_check(id: &str, value: Option<&str>, summary: &str) -> LifecycleCheck { + LifecycleCheck { + id: id.to_string(), + status: if value.is_some_and(|value| !value.trim().is_empty()) { + "passed" + } else { + "missing" + } + .to_string(), + summary: summary.to_string(), + details: value.map(str::to_string), + remediation: vec!["Set the missing scalar field in fission.toml.".to_string()], + } +} + +fn list_check(id: &str, values: &[String], summary: &str) -> LifecycleCheck { + LifecycleCheck { + id: id.to_string(), + status: if values.is_empty() { + "missing" + } else { + "passed" + } + .to_string(), + summary: summary.to_string(), + details: Some(values.join(",")), + remediation: vec![ + "Add at least one value to the corresponding list in fission.toml.".to_string(), + ], + } +} + +fn normalize_path(path: &Path) -> PathBuf { + path.components().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn unique_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "fission-release-model-{name}-{}", + std::process::id() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn write_release_project(dir: &Path, notes: &str) { + fs::create_dir_all(dir.join("release-content/metadata/1.0.0+1/notes")).unwrap(); + fs::write( + dir.join("release-content/metadata/1.0.0+1/release.toml"), + "[play_store.en-US]\nfull_description = \"A focused release.\"\n", + ) + .unwrap(); + fs::write( + dir.join("release-content/metadata/1.0.0+1/notes/en-US.md"), + notes, + ) + .unwrap(); + fs::write( + dir.join("release-content/metadata/1.0.0+1/review.toml"), + "notes = \"No login is required.\"\n", + ) + .unwrap(); + fs::write( + dir.join("release-content/metadata/1.0.0+1/privacy.toml"), + "privacy_url = \"https://example.com/privacy\"\n", + ) + .unwrap(); + fs::write( + dir.join("fission.toml"), + r#"[app] +name = "release-demo" +app_id = "com.example.release_demo" + +[release] +active_release = "1.0.0+1" +metadata_root = "release-content/metadata" +content_output_dir = "release-content" +default_locales = ["en-US"] + +[[releases]] +id = "1.0.0+1" +version = "1.0.0" +build = 1 +status = "candidate" +tracks = ["play-store:internal"] +locales = ["en-US"] +metadata = "release-content/metadata/1.0.0+1/release.toml" +release_notes = "release-content/metadata/1.0.0+1/notes" +review = "release-content/metadata/1.0.0+1/review.toml" +privacy = "release-content/metadata/1.0.0+1/privacy.toml" + +[release.store_listing.play_store.en-US] +title = "Release Demo" +short_description = "A focused release build." +privacy_url = "https://example.com/privacy" +"#, + ) + .unwrap(); + } + + #[test] + fn release_config_validation_accepts_complete_release_files() { + let dir = unique_dir("valid"); + write_release_project(&dir, "A precise release note.\n"); + let report = + validate_release_config_model(&dir, Some(publish::DistributionProvider::PlayStore)) + .unwrap(); + assert_ne!(report.status, "blocked"); + } + + #[test] + fn release_config_validation_rejects_placeholder_release_notes() { + let dir = unique_dir("placeholder"); + write_release_project(&dir, "TODO fill this in.\n"); + let report = + validate_release_config_model(&dir, Some(publish::DistributionProvider::PlayStore)) + .unwrap(); + assert_eq!(report.status, "blocked"); + assert!(report + .checks + .iter() + .any(|check| check.id.ends_with("notes.en-US.content") && check.status == "failed")); + } +} diff --git a/crates/tools/fission-cli/src/release/signing_ops.rs b/crates/tools/fission-cli/src/release/signing_ops.rs new file mode 100644 index 00000000..94a8c573 --- /dev/null +++ b/crates/tools/fission-cli/src/release/signing_ops.rs @@ -0,0 +1,619 @@ +use super::*; +use anyhow::{Context, Result}; +use serde::Deserialize; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +#[derive(Debug, Deserialize, Default)] +struct SigningToml { + package: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct PackageToml { + android: Option, + ios: Option, + macos: Option, + windows: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct AndroidPackageToml { + keystore: Option, + upload_keystore: Option, + keystore_alias: Option, + package_name: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct ApplePackageToml { + bundle_id: Option, + team_id: Option, + entitlements: Option, + provisioning_profile: Option, + signing_identity: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct MacosPackageToml { + bundle_id: Option, + entitlements: Option, + signing_identity: Option, + installer_identity: Option, + notarize: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct WindowsPackageToml { + identity_name: Option, + publisher: Option, + certificate: Option, + certificate_thumbprint: Option, +} + +pub(super) fn status(project_dir: &Path, target: Target, json: bool) -> Result<()> { + print_report( + build_status_report("signing.status", project_dir, target), + json, + ) +} + +pub(super) fn sync(project_dir: &Path, target: Target, readonly: bool, json: bool) -> Result<()> { + let mut report = build_status_report("signing.sync", project_dir, target); + report.checks.push(ok_check( + "signing.sync.mode", + if readonly { + "readonly" + } else { + "write status snapshot" + }, + )); + if !readonly { + let output = project_dir + .join("release-content/signing") + .join(format!("{}.status.json", target.as_str())); + if let Some(parent) = output.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&output, serde_json::to_vec_pretty(&report)?) + .with_context(|| format!("failed to write {}", output.display()))?; + report.checks.push(ok_check( + "signing.sync.snapshot_written", + output.display().to_string(), + )); + } + print_report(report, json) +} + +pub(super) fn import( + project_dir: &Path, + target: Target, + keystore: Option, + alias: Option, + json: bool, +) -> Result<()> { + let mut report = base_report("signing.import", None, Some(target)); + report.checks.push(path_check( + "signing.project_config_exists", + project_dir.join("fission.toml"), + "fission.toml exists", + )); + match target { + Target::Android => import_android(project_dir, keystore, alias, &mut report)?, + Target::Ios | Target::Macos | Target::Windows => report.checks.push(failed_check( + "signing.import.target_requires_platform_store", + format!( + "{} signing import is intentionally read-only for now; use the platform certificate/keychain tooling and record references in fission.toml", + target.as_str() + ), + )), + _ => report.checks.push(warning_check( + "signing.import.target", + format!("{} does not require signing by default", target.as_str()), + )), + } + print_report(report, json) +} + +fn import_android( + project_dir: &Path, + keystore: Option, + alias: Option, + report: &mut LifecycleReport, +) -> Result<()> { + let keystore = keystore.context("signing import --target android requires --keystore")?; + let alias = alias.context("signing import --target android requires --alias")?; + report.checks.push(path_check( + "signing.android.keystore_exists", + keystore.clone(), + "Android upload keystore exists", + )); + if !keystore.exists() { + return Ok(()); + } + let relative = project_relative_or_absolute(project_dir, &keystore); + let path = project_dir.join("fission.toml"); + let data = fs::read_to_string(&path).unwrap_or_default(); + let mut root: toml::Value = if data.trim().is_empty() { + toml::Value::Table(Default::default()) + } else { + toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))? + }; + set_toml_path( + &mut root, + "package.android.keystore", + toml::Value::String(relative.clone()), + )?; + set_toml_path( + &mut root, + "package.android.keystore_alias", + toml::Value::String(alias.clone()), + )?; + fs::write(&path, toml::to_string_pretty(&root)? + "\n") + .with_context(|| format!("failed to write {}", path.display()))?; + report.checks.push(ok_check( + "signing.android.config_written", + format!("package.android.keystore = {relative}, alias = {alias}"), + )); + report.checks.push(warning_check( + "signing.android.password_not_imported", + "keystore passwords were not stored in fission.toml; use ANDROID_KEYSTORE_PASSWORD and ANDROID_KEY_PASSWORD in CI or an OS-backed secret store".to_string(), + )); + Ok(()) +} + +fn build_status_report(area: &str, project_dir: &Path, target: Target) -> LifecycleReport { + let mut report = base_report(area, None, Some(target)); + let config_path = project_dir.join("fission.toml"); + report.checks.push(path_check( + "signing.project_config_exists", + config_path.clone(), + "fission.toml exists", + )); + let config = signing_config(&config_path).unwrap_or_default(); + match target { + Target::Android => android_checks( + project_dir, + config.package.and_then(|p| p.android), + &mut report, + ), + Target::Ios => { + apple_ios_checks(project_dir, config.package.and_then(|p| p.ios), &mut report) + } + Target::Macos => macos_checks( + project_dir, + config.package.and_then(|p| p.macos), + &mut report, + ), + Target::Windows => windows_checks( + project_dir, + config.package.and_then(|p| p.windows), + &mut report, + ), + _ => report.checks.push(warning_check( + "signing.target", + format!("{} does not require signing by default", target.as_str()), + )), + } + finalize_status(&mut report); + report +} + +fn android_checks( + project_dir: &Path, + cfg: Option, + report: &mut LifecycleReport, +) { + let keystore = env::var("ANDROID_KEYSTORE") + .ok() + .or_else(|| cfg.as_ref().and_then(|cfg| cfg.keystore.clone())) + .or_else(|| cfg.as_ref().and_then(|cfg| cfg.upload_keystore.clone())); + report.checks.push(required_text( + "signing.android.package_name", + cfg.as_ref().and_then(|cfg| cfg.package_name.as_deref()), + "Android package name is configured", + "Set package.android.package_name in fission.toml.", + )); + report.checks.push(required_text( + "signing.android.alias", + cfg.as_ref().and_then(|cfg| cfg.keystore_alias.as_deref()), + "Android keystore alias is configured", + "Run `fission signing import --target android --keystore --alias `.", + )); + match keystore { + Some(path) => report.checks.push(path_check( + "signing.android.keystore_exists", + project_dir.join(path), + "Android upload keystore exists", + )), + None => report.checks.push(required_text( + "signing.android.keystore", + None, + "Android upload keystore is configured", + "Set ANDROID_KEYSTORE or package.android.keystore.", + )), + } + report.checks.push(env_or_warning( + "signing.android.keystore_password", + &["ANDROID_KEYSTORE_PASSWORD", "ANDROID_KEY_PASSWORD"], + "Android signing password source is configured", + "Set ANDROID_KEYSTORE_PASSWORD and ANDROID_KEY_PASSWORD in CI or an OS-backed secret store; do not write passwords to fission.toml.", + )); + report.checks.push(tool_check( + "signing.android.keytool_available", + "keytool", + "Install a JDK so Fission can inspect Android keystores.", + )); + report.checks.push(tool_check( + "signing.android.apksigner_available", + "apksigner", + "Install Android build-tools and ensure apksigner is on PATH.", + )); +} + +fn apple_ios_checks( + project_dir: &Path, + cfg: Option, + report: &mut LifecycleReport, +) { + report.checks.push(host_os_check_local( + "signing.apple.host_is_macos", + "Apple signing and provisioning checks require macOS.", + )); + report.checks.push(required_text( + "signing.ios.bundle_id", + cfg.as_ref().and_then(|cfg| cfg.bundle_id.as_deref()), + "iOS bundle identifier is configured", + "Set package.ios.bundle_id.", + )); + report.checks.push(required_text( + "signing.ios.team_id", + cfg.as_ref().and_then(|cfg| cfg.team_id.as_deref()), + "Apple team id is configured", + "Set package.ios.team_id.", + )); + check_optional_path( + project_dir, + &mut report.checks, + "signing.ios.entitlements", + cfg.as_ref().and_then(|cfg| cfg.entitlements.as_deref()), + "iOS entitlements file exists", + ); + check_optional_path( + project_dir, + &mut report.checks, + "signing.ios.provisioning_profile", + cfg.as_ref() + .and_then(|cfg| cfg.provisioning_profile.as_deref()), + "iOS provisioning profile exists", + ); + report.checks.push(tool_check( + "signing.apple.xcrun_available", + "xcrun", + "Install Xcode command line tools.", + )); + report.checks.push(tool_check( + "signing.apple.security_available", + "security", + "Run on macOS with the security tool available.", + )); + report.checks.push(apple_identity_check( + cfg.as_ref().and_then(|cfg| cfg.signing_identity.as_deref()), + )); +} + +fn macos_checks(project_dir: &Path, cfg: Option, report: &mut LifecycleReport) { + report.checks.push(host_os_check_local( + "signing.apple.host_is_macos", + "macOS signing and notarization checks require macOS.", + )); + report.checks.push(required_text( + "signing.macos.bundle_id", + cfg.as_ref().and_then(|cfg| cfg.bundle_id.as_deref()), + "macOS bundle identifier is configured", + "Set package.macos.bundle_id.", + )); + check_optional_path( + project_dir, + &mut report.checks, + "signing.macos.entitlements", + cfg.as_ref().and_then(|cfg| cfg.entitlements.as_deref()), + "macOS entitlements file exists", + ); + report.checks.push(required_text( + "signing.macos.identity", + cfg.as_ref().and_then(|cfg| cfg.signing_identity.as_deref()), + "Developer ID Application signing identity is configured", + "Set package.macos.signing_identity.", + )); + report.checks.push(tool_check( + "signing.apple.codesign_available", + "codesign", + "Run on macOS with Xcode command line tools installed.", + )); + report.checks.push(apple_identity_check( + cfg.as_ref().and_then(|cfg| cfg.signing_identity.as_deref()), + )); + if cfg.as_ref().and_then(|cfg| cfg.notarize).unwrap_or(false) { + report.checks.push(required_text( + "signing.macos.installer_identity", + cfg.as_ref() + .and_then(|cfg| cfg.installer_identity.as_deref()), + "Developer ID Installer identity is configured for pkg signing", + "Set package.macos.installer_identity when package.macos.notarize = true.", + )); + for (id, name) in [ + ( + "signing.apple.notary_key_path", + "APP_STORE_CONNECT_API_KEY_PATH", + ), + ("signing.apple.notary_key_id", "APP_STORE_CONNECT_KEY_ID"), + ( + "signing.apple.notary_issuer_id", + "APP_STORE_CONNECT_ISSUER_ID", + ), + ] { + report.checks.push(env_or_missing( + id, + &[name], + &format!("{name} is configured for notarization"), + &format!("Set {name} in the release environment."), + )); + } + } +} + +fn windows_checks( + project_dir: &Path, + cfg: Option, + report: &mut LifecycleReport, +) { + report.checks.push(required_text( + "signing.windows.identity_name", + cfg.as_ref().and_then(|cfg| cfg.identity_name.as_deref()), + "Windows package identity name is configured", + "Set package.windows.identity_name.", + )); + report.checks.push(required_text( + "signing.windows.publisher", + cfg.as_ref().and_then(|cfg| cfg.publisher.as_deref()), + "Windows publisher identity is configured", + "Set package.windows.publisher to the certificate subject.", + )); + if let Some(certificate) = cfg.as_ref().and_then(|cfg| cfg.certificate.as_deref()) { + report.checks.push(path_check( + "signing.windows.certificate_exists", + project_dir.join(certificate), + "Windows signing certificate file exists", + )); + } else { + report.checks.push(required_text( + "signing.windows.certificate_reference", + cfg.as_ref() + .and_then(|cfg| cfg.certificate_thumbprint.as_deref()), + "Windows signing certificate reference is configured", + "Set package.windows.certificate or package.windows.certificate_thumbprint.", + )); + } + report.checks.push(env_or_warning( + "signing.windows.certificate_password", + &["WINDOWS_CERTIFICATE_PASSWORD"], + "Windows certificate password source is configured", + "Set WINDOWS_CERTIFICATE_PASSWORD in CI or use an OS certificate store; do not write passwords to fission.toml.", + )); + report.checks.push(tool_check( + "signing.windows.signtool_available", + "signtool", + "Install Windows SDK signing tools and ensure signtool is on PATH.", + )); +} + +fn signing_config(path: &Path) -> Result { + if !path.exists() { + return Ok(SigningToml::default()); + } + let data = fs::read_to_string(path)?; + toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display())) +} + +fn check_optional_path( + project_dir: &Path, + checks: &mut Vec, + id: &str, + path: Option<&str>, + summary: &str, +) { + if let Some(path) = path.filter(|path| !path.trim().is_empty()) { + checks.push(path_check(id, project_dir.join(path), summary)); + } else { + checks.push(required_text( + id, + None, + summary, + "Configure this path in fission.toml if the app requires the capability.", + )); + } +} + +fn apple_identity_check(expected: Option<&str>) -> LifecycleCheck { + if !cfg!(target_os = "macos") { + return LifecycleCheck { + id: "signing.apple.identity_available".to_string(), + status: "warning".to_string(), + summary: "Apple code signing identity is available".to_string(), + details: Some("identity lookup requires macOS".to_string()), + remediation: vec![ + "Run this check on a macOS release machine or remote builder.".to_string(), + ], + }; + } + let output = Command::new("security") + .args(["find-identity", "-v", "-p", "codesigning"]) + .output(); + match output { + Ok(output) if output.status.success() => { + let stdout = String::from_utf8_lossy(&output.stdout); + let found = expected.is_some_and(|needle| stdout.contains(needle)); + LifecycleCheck { + id: "signing.apple.identity_available".to_string(), + status: if expected.is_none() || found { "passed" } else { "missing" }.to_string(), + summary: "Apple code signing identity is available".to_string(), + details: expected.map(|expected| format!("expected identity: {expected}")), + remediation: vec!["Install the certificate in the login keychain or update the configured signing identity.".to_string()], + } + } + Ok(output) => LifecycleCheck { + id: "signing.apple.identity_available".to_string(), + status: "failed".to_string(), + summary: "Apple code signing identity lookup succeeds".to_string(), + details: Some(String::from_utf8_lossy(&output.stderr).to_string()), + remediation: vec![ + "Unlock the keychain and ensure Xcode command line tools are installed." + .to_string(), + ], + }, + Err(error) => failed_check("signing.apple.identity_available", error.to_string()), + } +} + +fn required_text( + id: &str, + value: Option<&str>, + summary: &str, + remediation: &str, +) -> LifecycleCheck { + LifecycleCheck { + id: id.to_string(), + status: if value.is_some_and(|value| !value.trim().is_empty()) { + "passed" + } else { + "missing" + } + .to_string(), + summary: summary.to_string(), + details: value.map(str::to_string), + remediation: vec![remediation.to_string()], + } +} + +fn env_or_missing(id: &str, vars: &[&str], summary: &str, remediation: &str) -> LifecycleCheck { + let found = vars.iter().find(|name| env::var_os(name).is_some()); + LifecycleCheck { + id: id.to_string(), + status: if found.is_some() { "passed" } else { "missing" }.to_string(), + summary: summary.to_string(), + details: found.map(|name| (*name).to_string()), + remediation: vec![remediation.to_string()], + } +} + +fn env_or_warning(id: &str, vars: &[&str], summary: &str, remediation: &str) -> LifecycleCheck { + let found = vars.iter().find(|name| env::var_os(name).is_some()); + LifecycleCheck { + id: id.to_string(), + status: if found.is_some() { "passed" } else { "warning" }.to_string(), + summary: summary.to_string(), + details: found.map(|name| (*name).to_string()), + remediation: vec![remediation.to_string()], + } +} + +fn host_os_check_local(id: &str, remediation: &str) -> LifecycleCheck { + LifecycleCheck { + id: id.to_string(), + status: if cfg!(target_os = "macos") { + "passed" + } else { + "missing" + } + .to_string(), + summary: "host operating system supports this signing flow".to_string(), + details: Some(env::consts::OS.to_string()), + remediation: vec![remediation.to_string()], + } +} + +fn tool_check(id: &str, program: &str, remediation: &str) -> LifecycleCheck { + LifecycleCheck { + id: id.to_string(), + status: if command_exists(program) { + "passed" + } else { + "missing" + } + .to_string(), + summary: format!("{program} is available on PATH"), + details: env::var_os("PATH").map(|_| program.to_string()), + remediation: vec![remediation.to_string()], + } +} + +fn command_exists(program: &str) -> bool { + let Some(paths) = env::var_os("PATH") else { + return false; + }; + env::split_paths(&paths).any(|dir| { + let candidate = dir.join(program); + if candidate.is_file() { + return true; + } + if cfg!(windows) { + ["exe", "cmd", "bat"] + .iter() + .any(|ext| dir.join(format!("{program}.{ext}")).is_file()) + } else { + false + } + }) +} + +fn project_relative_or_absolute(project_dir: &Path, path: &Path) -> String { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + env::current_dir() + .unwrap_or_else(|_| project_dir.to_path_buf()) + .join(path) + }; + absolute + .strip_prefix(project_dir) + .unwrap_or(&absolute) + .to_string_lossy() + .trim_start_matches(std::path::MAIN_SEPARATOR) + .to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn android_import_writes_non_secret_references() { + let dir = std::env::temp_dir().join(format!( + "fission-signing-import-{}-{}", + std::process::id(), + now_unix_seconds() + )); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("upload.jks"), "not a real keystore").unwrap(); + fs::write( + &dir.join("fission.toml"), + "[package.android]\npackage_name = \"com.example.todo\"\n", + ) + .unwrap(); + let mut report = base_report("test", None, Some(Target::Android)); + import_android( + &dir, + Some(dir.join("upload.jks")), + Some("upload".to_string()), + &mut report, + ) + .unwrap(); + let text = fs::read_to_string(dir.join("fission.toml")).unwrap(); + assert!(text.contains("keystore = \"upload.jks\"")); + assert!(text.contains("keystore_alias = \"upload\"")); + let _ = fs::remove_dir_all(&dir); + } +} diff --git a/crates/tools/fission-cli/src/release/store_ops.rs b/crates/tools/fission-cli/src/release/store_ops.rs new file mode 100644 index 00000000..c51fa317 --- /dev/null +++ b/crates/tools/fission-cli/src/release/store_ops.rs @@ -0,0 +1,2550 @@ +use super::*; +use anyhow::{bail, Context, Result}; +use jsonwebtoken::{encode, Algorithm, EncodingKey, Header}; +use reqwest::blocking::{Client, Response}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::collections::{BTreeMap, BTreeSet}; +use std::env; +use std::fs; +use std::path::Path; +use std::time::Duration; + +const PLAY_API: &str = "https://androidpublisher.googleapis.com"; +const GOOGLE_PLAY_SCOPE: &str = "https://www.googleapis.com/auth/androidpublisher"; +const GOOGLE_TOKEN_URI: &str = "https://oauth2.googleapis.com/token"; +const APP_STORE_API: &str = "https://api.appstoreconnect.apple.com"; + +#[derive(Debug, Deserialize, Default)] +struct ReleaseProviderToml { + distribution: Option, + beta: Option, + release: Option, + #[serde(default)] + releases: Vec, +} + +#[derive(Debug, Deserialize, Default)] +struct DistributionToml { + play_store: Option, + app_store: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct BetaRootToml { + play_store: Option, +} + +#[derive(Debug, Deserialize, Default)] +struct PlayBetaToml { + #[serde(default)] + tracks: BTreeMap, +} + +#[derive(Debug, Deserialize, Default)] +struct PlayBetaTrackToml { + tester_source: Option, + group: Option, + #[serde(default)] + groups: Vec, +} + +#[derive(Debug, Deserialize, Default)] +struct ReleaseRootToml { + active_release: Option, + #[serde(default)] + default_locales: Vec, + #[serde(default)] + store_listing: BTreeMap>, +} + +#[derive(Debug, Deserialize, Default)] +struct ReleaseEntryToml { + id: Option, + version: Option, + #[serde(default)] + locales: Vec, + metadata: Option, + release_notes: Option, +} + +#[derive(Clone, Debug, Deserialize, Default, Serialize, PartialEq, Eq)] +struct StoreListingToml { + title: Option, + name: Option, + short_description: Option, + subtitle: Option, + #[serde(default)] + keywords: Vec, + support_url: Option, + marketing_url: Option, + privacy_url: Option, + video: Option, + video_url: Option, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct ReleaseMetadataToml { + #[serde(default)] + play_store: BTreeMap, + #[serde(default)] + app_store: BTreeMap, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct AppStoreReleaseMetadataToml { + description: Option, + promotional_text: Option, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct PlayReleaseMetadataToml { + full_description: Option, + description: Option, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +struct PlayListing { + locale: String, + title: String, + short_description: String, + full_description: String, + video: Option, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +struct AppStoreLocalization { + id: Option, + locale: String, + description: String, + keywords: Option, + marketing_url: Option, + promotional_text: Option, + support_url: Option, + whats_new: Option, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct PlayStoreConfig { + package_name: Option, + service_account: Option, +} + +#[derive(Clone, Debug, Deserialize, Default)] +struct AppStoreConfig { + app_id: Option, + bundle_id: Option, + issuer_id: Option, + key_id: Option, + api_key_path: Option, +} + +#[derive(Debug, Deserialize)] +struct GoogleServiceAccount { + client_email: String, + private_key: String, + #[serde(default)] + token_uri: Option, +} + +#[derive(Debug, Serialize)] +struct GoogleJwtClaims<'a> { + iss: &'a str, + scope: &'a str, + aud: &'a str, + iat: u64, + exp: u64, +} + +#[derive(Debug, Serialize)] +struct AppStoreJwtClaims<'a> { + iss: &'a str, + aud: &'a str, + iat: u64, + exp: u64, +} + +#[derive(Debug, Deserialize)] +struct OAuthTokenResponse { + access_token: String, +} + +pub(super) fn reviews_list( + provider: publish::DistributionProvider, + since: Option, + project_dir: &Path, + json_output: bool, +) -> Result<()> { + match provider { + publish::DistributionProvider::PlayStore => { + play_reviews_list(project_dir, since, json_output) + } + publish::DistributionProvider::AppStore => { + app_store_reviews_list(project_dir, since, json_output) + } + _ => unsupported_reviews(provider, "list"), + } +} + +pub(super) fn reviews_reply( + provider: publish::DistributionProvider, + review: &str, + message_file: &Path, + project_dir: &Path, + dry_run: bool, + json_output: bool, +) -> Result<()> { + match provider { + publish::DistributionProvider::PlayStore => { + play_reviews_reply(project_dir, review, message_file, dry_run, json_output) + } + publish::DistributionProvider::AppStore => { + app_store_reviews_reply(project_dir, review, message_file, dry_run, json_output) + } + _ => unsupported_reviews(provider, "reply"), + } +} + +pub(super) fn beta_groups_list( + provider: publish::DistributionProvider, + project_dir: &Path, + json_output: bool, +) -> Result<()> { + match provider { + publish::DistributionProvider::PlayStore => play_beta_groups_list(project_dir, json_output), + publish::DistributionProvider::AppStore => { + app_store_beta_groups_list(project_dir, json_output) + } + _ => unsupported_beta(provider, "groups list"), + } +} + +pub(super) fn beta_groups_sync( + provider: publish::DistributionProvider, + from: &Path, + project_dir: &Path, + dry_run: bool, + json_output: bool, +) -> Result<()> { + match provider { + publish::DistributionProvider::PlayStore => { + play_beta_groups_sync(project_dir, from, dry_run, json_output) + } + _ => unsupported_beta(provider, "groups sync"), + } +} + +pub(super) fn beta_testers_import( + provider: publish::DistributionProvider, + group: Option<&str>, + track: Option<&str>, + csv: &Path, + project_dir: &Path, + dry_run: bool, + json_output: bool, +) -> Result<()> { + match provider { + publish::DistributionProvider::PlayStore => { + play_beta_testers_import(project_dir, track, csv, dry_run, json_output) + } + publish::DistributionProvider::AppStore => { + app_store_beta_testers_import(project_dir, group, csv, dry_run, json_output) + } + _ => unsupported_beta(provider, "testers import"), + } +} + +pub(super) fn beta_testers_export( + provider: publish::DistributionProvider, + group: Option<&str>, + track: Option<&str>, + output: &Path, + project_dir: &Path, + json_output: bool, +) -> Result<()> { + match provider { + publish::DistributionProvider::PlayStore => { + play_beta_testers_export(project_dir, track, output, json_output) + } + publish::DistributionProvider::AppStore => { + app_store_beta_testers_export(project_dir, group, output, json_output) + } + _ => unsupported_beta(provider, "testers export"), + } +} + +pub(super) fn release_config_import( + provider: publish::DistributionProvider, + locales: Option, + yes: bool, + project_dir: &Path, + json_output: bool, +) -> Result<()> { + match provider { + publish::DistributionProvider::PlayStore => { + play_release_config_import(project_dir, locales.as_deref(), yes, json_output) + } + publish::DistributionProvider::AppStore => { + app_store_release_config_import(project_dir, locales.as_deref(), yes, json_output) + } + publish::DistributionProvider::MicrosoftStore => { + super::microsoft_store_ops::release_config_import( + project_dir, + locales.as_deref(), + yes, + json_output, + ) + } + _ => unsupported_release_config(provider, "import"), + } +} + +pub(super) fn release_config_diff( + provider: publish::DistributionProvider, + project_dir: &Path, + json_output: bool, +) -> Result<()> { + match provider { + publish::DistributionProvider::PlayStore => { + play_release_config_diff(project_dir, json_output) + } + publish::DistributionProvider::AppStore => { + app_store_release_config_diff(project_dir, json_output) + } + publish::DistributionProvider::MicrosoftStore => { + super::microsoft_store_ops::release_config_diff(project_dir, json_output) + } + _ => unsupported_release_config(provider, "diff"), + } +} + +pub(super) fn release_config_push( + provider: publish::DistributionProvider, + locales: Option, + dry_run: bool, + yes: bool, + project_dir: &Path, + json_output: bool, +) -> Result<()> { + match provider { + publish::DistributionProvider::PlayStore => { + play_release_config_push(project_dir, locales.as_deref(), dry_run, yes, json_output) + } + publish::DistributionProvider::AppStore => app_store_release_config_push( + project_dir, + locales.as_deref(), + dry_run, + yes, + json_output, + ), + publish::DistributionProvider::MicrosoftStore => { + super::microsoft_store_ops::release_config_push( + project_dir, + locales.as_deref(), + dry_run, + yes, + json_output, + ) + } + _ => unsupported_release_config(provider, "push"), + } +} + +fn app_store_release_config_import( + project_dir: &Path, + locales: Option<&str>, + yes: bool, + json_output: bool, +) -> Result<()> { + if !yes { + bail!("release-config import mutates fission.toml/release metadata; pass --yes after reviewing the provider and locales"); + } + let root = read_release_provider_toml(project_dir)?; + let cfg = app_store_config(project_dir)?; + let client = http_client()?; + let token = app_store_access_token(&cfg)?; + let app_id = app_store_app_id(&cfg, &client, &token)?; + let version_id = app_store_version_id(&root, &client, &token, &app_id)?; + let remote = fetch_app_store_version_localizations(&client, &token, &version_id)?; + write_imported_app_store_localizations(project_dir, &root, locales, &remote)?; + let summary = json!({ + "provider": "app-store", + "app_id": app_id, + "version_id": version_id, + "imported_locales": remote.iter().map(|item| item.locale.as_str()).collect::>(), + "status": "imported" + }); + if json_output { + println!("{}", serde_json::to_string_pretty(&summary)?); + } else { + println!("Imported {} App Store localization(s)", remote.len()); + } + Ok(()) +} + +fn app_store_release_config_diff(project_dir: &Path, json_output: bool) -> Result<()> { + let root = read_release_provider_toml(project_dir)?; + let cfg = app_store_config(project_dir)?; + let client = http_client()?; + let token = app_store_access_token(&cfg)?; + let app_id = app_store_app_id(&cfg, &client, &token)?; + let version_id = app_store_version_id(&root, &client, &token, &app_id)?; + let locales = resolve_release_locales(&root, None)?; + let local = resolve_app_store_localizations(project_dir, &root, &locales)?; + let remote = fetch_app_store_version_localizations(&client, &token, &version_id)?; + let diff = app_store_localization_diff(&local, &remote); + if json_output { + println!("{}", serde_json::to_string_pretty(&diff)?); + } else if diff.as_array().is_some_and(Vec::is_empty) { + println!( + "App Store metadata is in sync for {} locale(s)", + locales.len() + ); + } else { + println!("App Store metadata differences:"); + for item in diff.as_array().into_iter().flatten() { + println!( + "{} {}: local={:?} remote={:?}", + item.get("locale") + .and_then(Value::as_str) + .unwrap_or(""), + item.get("field") + .and_then(Value::as_str) + .unwrap_or(""), + item.get("local"), + item.get("remote") + ); + } + } + Ok(()) +} + +fn app_store_release_config_push( + project_dir: &Path, + locales_arg: Option<&str>, + dry_run: bool, + yes: bool, + json_output: bool, +) -> Result<()> { + if !dry_run && !yes { + bail!("release-config push mutates provider metadata; pass --yes after reviewing `release-config diff`"); + } + let root = read_release_provider_toml(project_dir)?; + let cfg = app_store_config(project_dir)?; + let locales = resolve_release_locales(&root, locales_arg)?; + let localizations = resolve_app_store_localizations(project_dir, &root, &locales)?; + if dry_run { + let value = json!({ + "provider": "app-store", + "locales": locales, + "localizations": localizations, + "status": "dry-run" + }); + if json_output { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + println!( + "Would push {} App Store localization(s)", + localizations.len() + ); + } + return Ok(()); + } + let client = http_client()?; + let token = app_store_access_token(&cfg)?; + let app_id = app_store_app_id(&cfg, &client, &token)?; + let version_id = app_store_version_id(&root, &client, &token, &app_id)?; + let remote = fetch_app_store_version_localizations(&client, &token, &version_id)?; + let mut responses = Vec::new(); + for localization in &localizations { + if let Some(existing) = remote + .iter() + .find(|item| item.locale == localization.locale) + { + let response = client + .patch(format!( + "{APP_STORE_API}/v1/appStoreVersionLocalizations/{}", + existing + .id + .as_deref() + .context("remote localization missing id")? + )) + .bearer_auth(&token) + .json(&app_store_localization_update_payload( + existing.id.as_deref().unwrap(), + localization, + )) + .send() + .with_context(|| { + format!( + "failed to update App Store localization {}", + localization.locale + ) + })?; + responses.push(json_response(response, "App Store localization update")?); + } else { + let response = client + .post(format!("{APP_STORE_API}/v1/appStoreVersionLocalizations")) + .bearer_auth(&token) + .json(&app_store_localization_create_payload( + &version_id, + localization, + )) + .send() + .with_context(|| { + format!( + "failed to create App Store localization {}", + localization.locale + ) + })?; + responses.push(json_response(response, "App Store localization create")?); + } + } + let value = json!({ + "provider": "app-store", + "app_id": app_id, + "version_id": version_id, + "updated_locales": localizations.iter().map(|item| item.locale.as_str()).collect::>(), + "responses": responses, + "status": "pushed" + }); + if json_output { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + println!("Pushed {} App Store localization(s)", localizations.len()); + } + Ok(()) +} + +fn play_release_config_import( + project_dir: &Path, + locales: Option<&str>, + yes: bool, + json_output: bool, +) -> Result<()> { + if !yes { + bail!("release-config import mutates fission.toml/release metadata; pass --yes after reviewing the provider and locales"); + } + let mut root = read_release_provider_toml(project_dir)?; + let cfg = root + .distribution + .as_ref() + .and_then(|distribution| distribution.play_store.clone()) + .unwrap_or_default(); + let package_name = cfg + .package_name + .as_deref() + .context("distribution.play_store.package_name is required for Play metadata import")?; + let client = http_client()?; + let token = google_play_access_token(&cfg, &client)?; + let edit_id = create_play_edit(&client, &token, package_name)?; + let remote = fetch_play_listings(&client, &token, package_name, &edit_id, locales)?; + write_imported_play_listings(project_dir, &mut root, &remote)?; + let summary = json!({ + "provider": "play-store", + "package_name": package_name, + "imported_locales": remote.iter().map(|listing| listing.locale.as_str()).collect::>(), + "status": "imported" + }); + if json_output { + println!("{}", serde_json::to_string_pretty(&summary)?); + } else { + println!( + "Imported {} Google Play listing locale(s) into fission.toml/release metadata", + remote.len() + ); + } + Ok(()) +} + +fn play_release_config_diff(project_dir: &Path, json_output: bool) -> Result<()> { + let root = read_release_provider_toml(project_dir)?; + let cfg = root + .distribution + .as_ref() + .and_then(|distribution| distribution.play_store.clone()) + .unwrap_or_default(); + let package_name = cfg + .package_name + .as_deref() + .context("distribution.play_store.package_name is required for Play metadata diff")?; + let locales = resolve_release_locales(&root, None)?; + let local = resolve_play_listings(project_dir, &root, &locales)?; + let client = http_client()?; + let token = google_play_access_token(&cfg, &client)?; + let edit_id = create_play_edit(&client, &token, package_name)?; + let remote = fetch_play_listings( + &client, + &token, + package_name, + &edit_id, + Some(&locales.join(",")), + )?; + let diff = play_listing_diff(&local, &remote); + if json_output { + println!("{}", serde_json::to_string_pretty(&diff)?); + } else if diff.as_array().is_some_and(Vec::is_empty) { + println!( + "Google Play listing metadata is in sync for {} locale(s)", + locales.len() + ); + } else { + println!("Google Play listing metadata differences:"); + for item in diff.as_array().into_iter().flatten() { + println!( + "{} {}: local={:?} remote={:?}", + item.get("locale") + .and_then(Value::as_str) + .unwrap_or(""), + item.get("field") + .and_then(Value::as_str) + .unwrap_or(""), + item.get("local"), + item.get("remote") + ); + } + } + Ok(()) +} + +fn play_release_config_push( + project_dir: &Path, + locales_arg: Option<&str>, + dry_run: bool, + yes: bool, + json_output: bool, +) -> Result<()> { + if !dry_run && !yes { + bail!("release-config push mutates provider metadata; pass --yes after reviewing `release-config diff`"); + } + let root = read_release_provider_toml(project_dir)?; + let cfg = root + .distribution + .as_ref() + .and_then(|distribution| distribution.play_store.clone()) + .unwrap_or_default(); + let package_name = cfg + .package_name + .as_deref() + .context("distribution.play_store.package_name is required for Play metadata push")?; + let locales = resolve_release_locales(&root, locales_arg)?; + let listings = resolve_play_listings(project_dir, &root, &locales)?; + if dry_run { + let value = json!({ + "provider": "play-store", + "package_name": package_name, + "locales": locales, + "listings": listings, + "status": "dry-run" + }); + if json_output { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + println!( + "Would push {} Google Play listing locale(s) for {package_name}", + listings.len() + ); + } + return Ok(()); + } + let client = http_client()?; + let token = google_play_access_token(&cfg, &client)?; + let edit_id = create_play_edit(&client, &token, package_name)?; + let mut responses = Vec::new(); + for listing in &listings { + let url = format!( + "{PLAY_API}/androidpublisher/v3/applications/{package_name}/edits/{edit_id}/listings/{}", + listing.locale + ); + let response = client + .put(url) + .bearer_auth(&token) + .json(&json!({ + "title": listing.title, + "shortDescription": listing.short_description, + "fullDescription": listing.full_description, + "video": listing.video, + })) + .send() + .with_context(|| format!("failed to update Google Play listing {}", listing.locale))?; + responses.push(json_response(response, "Google Play listing update")?); + } + validate_play_edit(&client, &token, package_name, &edit_id)?; + commit_play_edit(&client, &token, package_name, &edit_id)?; + let value = json!({ + "provider": "play-store", + "package_name": package_name, + "updated_locales": listings.iter().map(|listing| listing.locale.as_str()).collect::>(), + "responses": responses, + "status": "pushed" + }); + if json_output { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + println!( + "Pushed {} Google Play listing locale(s) for {package_name}", + listings.len() + ); + } + Ok(()) +} + +fn app_store_reviews_list( + project_dir: &Path, + since: Option, + json_output: bool, +) -> Result<()> { + let cfg = app_store_config(project_dir)?; + let client = http_client()?; + let token = app_store_access_token(&cfg)?; + let app_id = app_store_app_id(&cfg, &client, &token)?; + let url = format!( + "{APP_STORE_API}/v1/apps/{app_id}/customerReviews?limit=200&sort=-createdDate&fields[customerReviews]=rating,title,body,reviewerNickname,createdDate,territory,response" + ); + let response = client + .get(url) + .bearer_auth(token) + .send() + .context("failed to list App Store customer reviews")?; + let value = json_response(response, "App Store customer reviews list")?; + if json_output { + println!("{}", serde_json::to_string_pretty(&value)?); + return Ok(()); + } + println!("App Store reviews for app {app_id}"); + if let Some(since) = since { + println!("Requested window: {since} (App Store Connect returned newest-first; filter locally if needed)"); + } + for review in value + .get("data") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let id = review.get("id").and_then(Value::as_str).unwrap_or(""); + let attrs = review.get("attributes").unwrap_or(&Value::Null); + let rating = attrs + .get("rating") + .and_then(Value::as_i64) + .map(|rating| rating.to_string()) + .unwrap_or_else(|| "?".to_string()); + let title = attrs.get("title").and_then(Value::as_str).unwrap_or(""); + let body = attrs + .get("body") + .and_then(Value::as_str) + .unwrap_or("") + .replace('\n', " "); + println!("{id} [{rating}/5] {title}: {body}"); + } + Ok(()) +} + +fn app_store_reviews_reply( + project_dir: &Path, + review: &str, + message_file: &Path, + dry_run: bool, + json_output: bool, +) -> Result<()> { + let reply = fs::read_to_string(message_file) + .with_context(|| format!("failed to read {}", message_file.display()))?; + let payload = app_store_review_response_payload(review, reply.trim()); + if dry_run { + let value = json!({ + "provider": "app-store", + "review": review, + "reply_text_bytes": reply.len(), + "payload": payload, + "status": "dry-run" + }); + if json_output { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + println!("Would reply to App Store review {review}"); + } + return Ok(()); + } + let cfg = app_store_config(project_dir)?; + let client = http_client()?; + let token = app_store_access_token(&cfg)?; + let response = client + .post(format!("{APP_STORE_API}/v1/customerReviewResponses")) + .bearer_auth(token) + .json(&payload) + .send() + .context("failed to reply to App Store review")?; + let value = json_response(response, "App Store review reply")?; + if json_output { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + println!("Replied to App Store review {review}"); + } + Ok(()) +} + +fn app_store_review_response_payload(review: &str, response_body: &str) -> Value { + json!({ + "data": { + "type": "customerReviewResponses", + "attributes": { + "responseBody": response_body, + }, + "relationships": { + "review": { + "data": { + "type": "customerReviews", + "id": review, + } + } + } + } + }) +} + +fn play_reviews_list(project_dir: &Path, since: Option, json_output: bool) -> Result<()> { + let cfg = play_config(project_dir)?; + let package_name = cfg + .package_name + .as_deref() + .context("distribution.play_store.package_name is required for Play reviews")?; + let client = http_client()?; + let token = google_play_access_token(&cfg, &client)?; + let url = + format!("{PLAY_API}/androidpublisher/v3/applications/{package_name}/reviews?maxResults=50"); + let response = client + .get(url) + .bearer_auth(token) + .send() + .context("failed to list Google Play reviews")?; + let value = json_response(response, "Google Play reviews list")?; + if json_output { + println!("{}", serde_json::to_string_pretty(&value)?); + return Ok(()); + } + println!("Google Play reviews for {package_name}"); + if let Some(since) = since { + println!("Requested window: {since} (Google Play API pagination is returned newest-first; filter locally if needed)"); + } + for review in value + .get("reviews") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let id = review + .get("reviewId") + .and_then(Value::as_str) + .unwrap_or(""); + let author = review + .get("authorName") + .and_then(Value::as_str) + .unwrap_or(""); + let user = latest_user_comment(review); + let rating = user + .and_then(|comment| comment.get("starRating")) + .and_then(Value::as_i64) + .map(|rating| rating.to_string()) + .unwrap_or_else(|| "?".to_string()); + let text = user + .and_then(|comment| comment.get("text")) + .and_then(Value::as_str) + .unwrap_or("") + .replace('\n', " "); + println!("{id} [{rating}/5] {author}: {text}"); + } + Ok(()) +} + +fn play_reviews_reply( + project_dir: &Path, + review: &str, + message_file: &Path, + dry_run: bool, + json_output: bool, +) -> Result<()> { + let cfg = play_config(project_dir)?; + let package_name = cfg + .package_name + .as_deref() + .context("distribution.play_store.package_name is required for Play reviews")?; + let reply = fs::read_to_string(message_file) + .with_context(|| format!("failed to read {}", message_file.display()))?; + if dry_run { + let value = json!({ + "provider": "play-store", + "package_name": package_name, + "review": review, + "reply_text_bytes": reply.len(), + "status": "dry-run" + }); + if json_output { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + println!("Would reply to Google Play review {review} for {package_name}"); + } + return Ok(()); + } + let client = http_client()?; + let token = google_play_access_token(&cfg, &client)?; + let url = format!( + "{PLAY_API}/androidpublisher/v3/applications/{package_name}/reviews/{review}:reply" + ); + let response = client + .post(url) + .bearer_auth(token) + .json(&json!({ "replyText": reply.trim() })) + .send() + .context("failed to reply to Google Play review")?; + let value = json_response(response, "Google Play review reply")?; + if json_output { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + println!("Replied to Google Play review {review}"); + } + Ok(()) +} + +fn play_beta_groups_list(project_dir: &Path, json_output: bool) -> Result<()> { + let cfg = play_config(project_dir)?; + let package_name = cfg + .package_name + .as_deref() + .context("distribution.play_store.package_name is required for Play beta groups")?; + let client = http_client()?; + let token = google_play_access_token(&cfg, &client)?; + let edit_id = create_play_edit(&client, &token, package_name)?; + let mut tracks = Vec::new(); + for track in ["internal", "closed", "open"] { + let url = format!( + "{PLAY_API}/androidpublisher/v3/applications/{package_name}/edits/{edit_id}/testers/{track}" + ); + let response = client + .get(url) + .bearer_auth(&token) + .send() + .with_context(|| format!("failed to get Google Play testers for {track}"))?; + let value = json_response(response, "Google Play testers get")?; + tracks.push(json!({ + "track": track, + "googleGroups": value.get("googleGroups").cloned().unwrap_or_else(|| json!([])) + })); + } + let value = json!({ "package_name": package_name, "tracks": tracks }); + if json_output { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + println!("Google Play tester groups for {package_name}"); + for track in value + .get("tracks") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let name = track + .get("track") + .and_then(Value::as_str) + .unwrap_or(""); + let groups = track + .get("googleGroups") + .and_then(Value::as_array) + .map(|groups| { + groups + .iter() + .filter_map(Value::as_str) + .collect::>() + .join(", ") + }) + .unwrap_or_default(); + println!("{name}: {groups}"); + } + } + Ok(()) +} + +fn app_store_beta_groups_list(project_dir: &Path, json_output: bool) -> Result<()> { + let cfg = app_store_config(project_dir)?; + let client = http_client()?; + let token = app_store_access_token(&cfg)?; + let app_id = app_store_app_id(&cfg, &client, &token)?; + let value = app_store_beta_groups(&client, &token, &app_id)?; + if json_output { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + println!("App Store TestFlight groups for app {app_id}"); + for group in value + .get("data") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let id = group.get("id").and_then(Value::as_str).unwrap_or(""); + let attrs = group.get("attributes").unwrap_or(&Value::Null); + let name = attrs + .get("name") + .and_then(Value::as_str) + .unwrap_or(""); + let public_link = attrs + .get("publicLink") + .and_then(Value::as_str) + .unwrap_or(""); + println!("{id} {name} {public_link}"); + } + } + Ok(()) +} + +fn app_store_beta_testers_import( + project_dir: &Path, + group: Option<&str>, + csv: &Path, + dry_run: bool, + json_output: bool, +) -> Result<()> { + let group = group.context("App Store tester import requires --group ")?; + let testers = read_app_store_tester_csv(csv)?; + let cfg = app_store_config(project_dir)?; + let client = http_client()?; + let token = app_store_access_token(&cfg)?; + let app_id = app_store_app_id(&cfg, &client, &token)?; + let group_id = resolve_app_store_beta_group(&client, &token, &app_id, group)?; + if dry_run { + let value = json!({ + "provider": "app-store", + "app_id": app_id, + "group": group, + "group_id": group_id, + "testers": testers, + "status": "dry-run" + }); + if json_output { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + println!( + "Would import {} App Store TestFlight tester(s) into group {group}", + testers.len() + ); + } + return Ok(()); + } + let mut responses = Vec::new(); + for tester in &testers { + let response = client + .post(format!("{APP_STORE_API}/v1/betaTesters")) + .bearer_auth(&token) + .json(&app_store_beta_tester_payload(tester, &group_id)) + .send() + .with_context(|| format!("failed to create App Store beta tester {}", tester.email))?; + responses.push(json_response(response, "App Store beta tester create")?); + } + let value = json!({ + "provider": "app-store", + "app_id": app_id, + "group": group, + "group_id": group_id, + "created": responses.len(), + "responses": responses, + "status": "imported" + }); + if json_output { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + println!( + "Imported {} App Store TestFlight tester(s) into group {group}", + responses.len() + ); + } + Ok(()) +} + +fn app_store_beta_testers_export( + project_dir: &Path, + group: Option<&str>, + output: &Path, + json_output: bool, +) -> Result<()> { + let group = group.context("App Store tester export requires --group ")?; + let cfg = app_store_config(project_dir)?; + let client = http_client()?; + let token = app_store_access_token(&cfg)?; + let app_id = app_store_app_id(&cfg, &client, &token)?; + let group_id = resolve_app_store_beta_group(&client, &token, &app_id, group)?; + let url = format!( + "{APP_STORE_API}/v1/betaGroups/{group_id}/betaTesters?limit=200&fields[betaTesters]=email,firstName,lastName,inviteType,state" + ); + let response = client + .get(url) + .bearer_auth(&token) + .send() + .context("failed to list App Store beta testers")?; + let value = json_response(response, "App Store beta testers list")?; + let testers = value + .get("data") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(app_store_tester_from_value) + .collect::>(); + if let Some(parent) = output.parent() { + fs::create_dir_all(parent)?; + } + let mut csv = String::from("email,first_name,last_name\n"); + for tester in &testers { + csv.push_str(&format!( + "{},{},{}\n", + csv_cell(&tester.email), + csv_cell(tester.first_name.as_deref().unwrap_or("")), + csv_cell(tester.last_name.as_deref().unwrap_or("")) + )); + } + fs::write(output, csv)?; + if json_output { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "provider": "app-store", + "app_id": app_id, + "group": group, + "group_id": group_id, + "output": output, + "count": testers.len() + }))? + ); + } else { + println!( + "Exported {} App Store TestFlight tester(s) to {}", + testers.len(), + output.display() + ); + } + Ok(()) +} + +fn play_beta_groups_sync( + project_dir: &Path, + source: &Path, + dry_run: bool, + json_output: bool, +) -> Result<()> { + let source = if source.is_absolute() { + source.to_path_buf() + } else { + project_dir.join(source) + }; + let root = read_release_provider_toml_from_path(&source)?; + let tracks = root + .beta + .and_then(|beta| beta.play_store) + .map(|play| play.tracks) + .unwrap_or_default(); + if tracks.is_empty() { + bail!( + "{} does not contain [beta.play_store.tracks.] entries", + source.display() + ); + } + let updates = tracks + .into_iter() + .map(|(track, config)| { + let mut groups = config.groups; + if let Some(group) = config.group { + groups.push(group); + } + groups.retain(|group| !group.trim().is_empty()); + groups.sort(); + groups.dedup(); + if groups.is_empty() { + bail!("beta.play_store.tracks.{track} must set group or groups"); + } + if config + .tester_source + .as_deref() + .is_some_and(|source| source != "google_group") + { + bail!("Google Play beta group sync supports tester_source = \"google_group\" for track {track}"); + } + Ok((track, groups)) + }) + .collect::>>()?; + let cfg = play_config(project_dir)?; + let package_name = cfg + .package_name + .as_deref() + .context("distribution.play_store.package_name is required for Play beta group sync")?; + if dry_run { + let value = json!({ + "provider": "play-store", + "package_name": package_name, + "tracks": updates.iter().map(|(track, groups)| json!({"track": track, "googleGroups": groups})).collect::>(), + "status": "dry-run" + }); + if json_output { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + println!( + "Would sync Google Play tester groups for {} track(s)", + updates.len() + ); + } + return Ok(()); + } + let client = http_client()?; + let token = google_play_access_token(&cfg, &client)?; + let edit_id = create_play_edit(&client, &token, package_name)?; + let mut responses = Vec::new(); + for (track, groups) in &updates { + let url = format!( + "{PLAY_API}/androidpublisher/v3/applications/{package_name}/edits/{edit_id}/testers/{track}" + ); + let response = client + .put(url) + .bearer_auth(&token) + .json(&json!({ "googleGroups": groups })) + .send() + .with_context(|| format!("failed to update Google Play testers for {track}"))?; + responses.push(json_response(response, "Google Play testers update")?); + } + validate_play_edit(&client, &token, package_name, &edit_id)?; + commit_play_edit(&client, &token, package_name, &edit_id)?; + let value = json!({ + "provider": "play-store", + "package_name": package_name, + "tracks": updates.iter().map(|(track, groups)| json!({"track": track, "googleGroups": groups})).collect::>(), + "responses": responses, + "status": "synced" + }); + if json_output { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + println!( + "Synced Google Play tester groups for {} track(s)", + updates.len() + ); + } + Ok(()) +} + +fn play_beta_testers_import( + project_dir: &Path, + track: Option<&str>, + csv: &Path, + dry_run: bool, + json_output: bool, +) -> Result<()> { + let track = track.context("Google Play tester import requires --track internal|closed|open")?; + let groups = read_google_group_csv(csv)?; + let cfg = play_config(project_dir)?; + let package_name = cfg + .package_name + .as_deref() + .context("distribution.play_store.package_name is required for Play beta testers")?; + if dry_run { + let value = json!({ + "provider": "play-store", + "package_name": package_name, + "track": track, + "googleGroups": groups, + "status": "dry-run" + }); + if json_output { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + println!( + "Would set {} Google Groups on Play track {track}", + value + .get("googleGroups") + .and_then(Value::as_array) + .map(Vec::len) + .unwrap_or(0) + ); + } + return Ok(()); + } + let client = http_client()?; + let token = google_play_access_token(&cfg, &client)?; + let edit_id = create_play_edit(&client, &token, package_name)?; + let url = format!( + "{PLAY_API}/androidpublisher/v3/applications/{package_name}/edits/{edit_id}/testers/{track}" + ); + let response = client + .put(url) + .bearer_auth(&token) + .json(&json!({ "googleGroups": groups })) + .send() + .context("failed to update Google Play testers")?; + let value = json_response(response, "Google Play testers update")?; + validate_play_edit(&client, &token, package_name, &edit_id)?; + commit_play_edit(&client, &token, package_name, &edit_id)?; + if json_output { + println!("{}", serde_json::to_string_pretty(&value)?); + } else { + println!("Updated Google Play tester groups for {package_name} track {track}"); + } + Ok(()) +} + +fn play_beta_testers_export( + project_dir: &Path, + track: Option<&str>, + output: &Path, + json_output: bool, +) -> Result<()> { + let track = track.context("Google Play tester export requires --track internal|closed|open")?; + let cfg = play_config(project_dir)?; + let package_name = cfg + .package_name + .as_deref() + .context("distribution.play_store.package_name is required for Play beta testers")?; + let client = http_client()?; + let token = google_play_access_token(&cfg, &client)?; + let edit_id = create_play_edit(&client, &token, package_name)?; + let url = format!( + "{PLAY_API}/androidpublisher/v3/applications/{package_name}/edits/{edit_id}/testers/{track}" + ); + let response = client + .get(url) + .bearer_auth(&token) + .send() + .context("failed to get Google Play testers")?; + let value = json_response(response, "Google Play testers get")?; + let groups = value + .get("googleGroups") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .collect::>(); + if let Some(parent) = output.parent() { + fs::create_dir_all(parent)?; + } + fs::write(output, groups.join("\n") + "\n")?; + if json_output { + println!( + "{}", + serde_json::to_string_pretty(&json!({ + "provider": "play-store", + "package_name": package_name, + "track": track, + "output": output, + "googleGroups": groups + }))? + ); + } else { + println!( + "Exported {} Google Groups to {}", + groups.len(), + output.display() + ); + } + Ok(()) +} + +fn fetch_app_store_version_localizations( + client: &Client, + token: &str, + version_id: &str, +) -> Result> { + let response = client + .get(format!( + "{APP_STORE_API}/v1/appStoreVersions/{version_id}/appStoreVersionLocalizations?limit=200" + )) + .bearer_auth(token) + .send() + .context("failed to list App Store version localizations")?; + let value = json_response(response, "App Store localizations list")?; + value + .get("data") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(app_store_localization_from_value) + .collect() +} + +fn app_store_localization_from_value(value: &Value) -> Result { + let attrs = value.get("attributes").unwrap_or(&Value::Null); + Ok(AppStoreLocalization { + id: value.get("id").and_then(Value::as_str).map(str::to_string), + locale: attrs + .get("locale") + .and_then(Value::as_str) + .context("App Store localization missing locale")? + .to_string(), + description: attrs + .get("description") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + keywords: attrs + .get("keywords") + .and_then(Value::as_str) + .map(str::to_string), + marketing_url: attrs + .get("marketingUrl") + .and_then(Value::as_str) + .map(str::to_string), + promotional_text: attrs + .get("promotionalText") + .and_then(Value::as_str) + .map(str::to_string), + support_url: attrs + .get("supportUrl") + .and_then(Value::as_str) + .map(str::to_string), + whats_new: attrs + .get("whatsNew") + .and_then(Value::as_str) + .map(str::to_string), + }) +} + +fn resolve_app_store_localizations( + project_dir: &Path, + root: &ReleaseProviderToml, + locales: &[String], +) -> Result> { + let metadata = active_release(root) + .and_then(|release| release.metadata.as_deref()) + .map(|metadata| read_release_metadata(project_dir, metadata)) + .transpose()? + .unwrap_or_default(); + locales + .iter() + .map(|locale| resolve_app_store_localization(project_dir, root, &metadata, locale)) + .collect() +} + +fn resolve_app_store_localization( + project_dir: &Path, + root: &ReleaseProviderToml, + metadata: &ReleaseMetadataToml, + locale: &str, +) -> Result { + let listing = root + .release + .as_ref() + .and_then(|release| release.store_listing.get("app_store")) + .and_then(|listings| listings.get(locale)) + .cloned() + .unwrap_or_default(); + let meta = metadata.app_store.get(locale).cloned().unwrap_or_default(); + let description = meta.description.with_context(|| { + format!("active release metadata [app_store.{locale}].description is required") + })?; + let whats_new = active_release(root) + .and_then(|release| release.release_notes.as_deref()) + .map(|notes| project_dir.join(notes).join(format!("{locale}.md"))) + .filter(|path| path.exists()) + .map(fs::read_to_string) + .transpose()?; + Ok(AppStoreLocalization { + id: None, + locale: locale.to_string(), + description, + keywords: (!listing.keywords.is_empty()).then(|| listing.keywords.join(",")), + marketing_url: listing.marketing_url, + promotional_text: meta.promotional_text, + support_url: listing.support_url, + whats_new: whats_new + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + }) +} + +fn app_store_version_id( + root: &ReleaseProviderToml, + client: &Client, + token: &str, + app_id: &str, +) -> Result { + let version = active_release(root) + .and_then(|release| release.version.as_deref()) + .or_else(|| { + root.release + .as_ref()? + .active_release + .as_deref() + .and_then(|id| id.split('+').next()) + }) + .context("active [[releases]].version is required for App Store metadata sync")?; + let response = client + .get(format!( + "{APP_STORE_API}/v1/apps/{app_id}/appStoreVersions?filter[versionString]={version}&limit=1" + )) + .bearer_auth(token) + .send() + .context("failed to list App Store versions")?; + let value = json_response(response, "App Store versions list")?; + value + .get("data") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(|item| item.get("id")) + .and_then(Value::as_str) + .map(str::to_string) + .with_context(|| format!("App Store version {version} was not found for app {app_id}")) +} + +fn app_store_localization_update_payload(id: &str, localization: &AppStoreLocalization) -> Value { + json!({ + "data": { + "type": "appStoreVersionLocalizations", + "id": id, + "attributes": app_store_localization_attributes(localization) + } + }) +} + +fn app_store_localization_create_payload( + version_id: &str, + localization: &AppStoreLocalization, +) -> Value { + json!({ + "data": { + "type": "appStoreVersionLocalizations", + "attributes": app_store_localization_attributes(localization), + "relationships": { + "appStoreVersion": { + "data": {"type": "appStoreVersions", "id": version_id} + } + } + } + }) +} + +fn app_store_localization_attributes(localization: &AppStoreLocalization) -> Value { + let mut attrs = serde_json::Map::new(); + attrs.insert( + "locale".to_string(), + Value::String(localization.locale.clone()), + ); + attrs.insert( + "description".to_string(), + Value::String(localization.description.clone()), + ); + if let Some(value) = &localization.keywords { + attrs.insert("keywords".to_string(), Value::String(value.clone())); + } + if let Some(value) = &localization.marketing_url { + attrs.insert("marketingUrl".to_string(), Value::String(value.clone())); + } + if let Some(value) = &localization.promotional_text { + attrs.insert("promotionalText".to_string(), Value::String(value.clone())); + } + if let Some(value) = &localization.support_url { + attrs.insert("supportUrl".to_string(), Value::String(value.clone())); + } + if let Some(value) = &localization.whats_new { + attrs.insert("whatsNew".to_string(), Value::String(value.clone())); + } + Value::Object(attrs) +} + +fn write_imported_app_store_localizations( + project_dir: &Path, + root: &ReleaseProviderToml, + locales_arg: Option<&str>, + remote: &[AppStoreLocalization], +) -> Result<()> { + let selected = locales_arg.map(parse_locale_list).transpose()?; + let selected = selected.as_ref(); + let fission_path = project_dir.join("fission.toml"); + let metadata_path = active_release(root) + .and_then(|release| release.metadata.as_deref()) + .map(|metadata| project_dir.join(metadata)) + .context("active release metadata path is required for App Store metadata import")?; + let mut metadata_doc: toml::Value = if metadata_path.exists() { + toml::from_str(&fs::read_to_string(&metadata_path)?)? + } else { + toml::Value::Table(Default::default()) + }; + let mut fission_doc: toml::Value = toml::from_str(&fs::read_to_string(&fission_path)?)?; + for item in remote { + if selected.is_some_and(|selected| !selected.contains(&item.locale)) { + continue; + } + set_toml_path( + &mut metadata_doc, + &format!("app_store.{}.description", item.locale), + toml::Value::String(item.description.clone()), + )?; + if let Some(value) = &item.promotional_text { + set_toml_path( + &mut metadata_doc, + &format!("app_store.{}.promotional_text", item.locale), + toml::Value::String(value.clone()), + )?; + } + if let Some(value) = &item.support_url { + set_toml_path( + &mut fission_doc, + &format!( + "release.store_listing.app_store.{}.support_url", + item.locale + ), + toml::Value::String(value.clone()), + )?; + } + if let Some(value) = &item.marketing_url { + set_toml_path( + &mut fission_doc, + &format!( + "release.store_listing.app_store.{}.marketing_url", + item.locale + ), + toml::Value::String(value.clone()), + )?; + } + if let Some(value) = &item.keywords { + set_toml_path( + &mut fission_doc, + &format!("release.store_listing.app_store.{}.keywords", item.locale), + toml::Value::Array( + value + .split(',') + .map(|item| toml::Value::String(item.trim().to_string())) + .collect(), + ), + )?; + } + } + if let Some(parent) = metadata_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write( + &metadata_path, + toml::to_string_pretty(&metadata_doc)? + "\n", + )?; + fs::write(&fission_path, toml::to_string_pretty(&fission_doc)? + "\n")?; + Ok(()) +} + +fn app_store_localization_diff( + local: &[AppStoreLocalization], + remote: &[AppStoreLocalization], +) -> Value { + let mut remote_by_locale = remote + .iter() + .map(|item| (item.locale.as_str(), item)) + .collect::>(); + let mut diffs = Vec::new(); + for local_item in local { + match remote_by_locale.remove(local_item.locale.as_str()) { + Some(remote_item) => { + push_field_diff(&mut diffs, &local_item.locale, "description", &local_item.description, &remote_item.description); + push_option_diff(&mut diffs, &local_item.locale, "keywords", &local_item.keywords, &remote_item.keywords); + push_option_diff(&mut diffs, &local_item.locale, "marketing_url", &local_item.marketing_url, &remote_item.marketing_url); + push_option_diff(&mut diffs, &local_item.locale, "promotional_text", &local_item.promotional_text, &remote_item.promotional_text); + push_option_diff(&mut diffs, &local_item.locale, "support_url", &local_item.support_url, &remote_item.support_url); + push_option_diff(&mut diffs, &local_item.locale, "whats_new", &local_item.whats_new, &remote_item.whats_new); + } + None => diffs.push(json!({"locale": local_item.locale, "field": "localization", "local": "present", "remote": "missing"})), + } + } + Value::Array(diffs) +} + +fn push_option_diff( + diffs: &mut Vec, + locale: &str, + field: &str, + local: &Option, + remote: &Option, +) { + if local != remote { + diffs.push(json!({"locale": locale, "field": field, "local": local, "remote": remote})); + } +} + +fn fetch_play_listings( + client: &Client, + token: &str, + package_name: &str, + edit_id: &str, + locales: Option<&str>, +) -> Result> { + let locale_list = locales.map(parse_locale_list).transpose()?; + if let Some(locales) = locale_list { + return locales + .into_iter() + .map(|locale| fetch_play_listing(client, token, package_name, edit_id, &locale)) + .collect(); + } + let url = format!( + "{PLAY_API}/androidpublisher/v3/applications/{package_name}/edits/{edit_id}/listings" + ); + let response = client + .get(url) + .bearer_auth(token) + .send() + .context("failed to list Google Play listings")?; + let value = json_response(response, "Google Play listings list")?; + value + .get("listings") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(play_listing_from_value) + .collect() +} + +fn fetch_play_listing( + client: &Client, + token: &str, + package_name: &str, + edit_id: &str, + locale: &str, +) -> Result { + let url = format!( + "{PLAY_API}/androidpublisher/v3/applications/{package_name}/edits/{edit_id}/listings/{locale}" + ); + let response = client + .get(url) + .bearer_auth(token) + .send() + .with_context(|| format!("failed to get Google Play listing {locale}"))?; + play_listing_from_value(&json_response(response, "Google Play listing get")?) +} + +fn play_listing_from_value(value: &Value) -> Result { + let locale = value + .get("language") + .or_else(|| value.get("locale")) + .and_then(Value::as_str) + .context("Google Play listing response did not contain language")? + .to_string(); + Ok(PlayListing { + locale, + title: value + .get("title") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + short_description: value + .get("shortDescription") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + full_description: value + .get("fullDescription") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + video: value + .get("video") + .and_then(Value::as_str) + .map(str::to_string), + }) +} + +fn resolve_release_locales( + root: &ReleaseProviderToml, + locales_arg: Option<&str>, +) -> Result> { + if let Some(locales) = locales_arg { + return parse_locale_list(locales); + } + let active = active_release(root); + if let Some(release) = active + .as_ref() + .filter(|release| !release.locales.is_empty()) + { + return Ok(release.locales.clone()); + } + if let Some(release) = root + .release + .as_ref() + .filter(|release| !release.default_locales.is_empty()) + { + return Ok(release.default_locales.clone()); + } + let listing_locales = root + .release + .as_ref() + .and_then(|release| release.store_listing.get("play_store")) + .map(|listings| listings.keys().cloned().collect::>()) + .unwrap_or_default(); + if listing_locales.is_empty() { + bail!("no release locales configured; set release.default_locales, [[releases]].locales, or pass --locales") + } + Ok(listing_locales) +} + +fn resolve_play_listings( + project_dir: &Path, + root: &ReleaseProviderToml, + locales: &[String], +) -> Result> { + let metadata = active_release(root) + .and_then(|release| release.metadata.as_deref()) + .map(|metadata| read_release_metadata(project_dir, metadata)) + .transpose()? + .unwrap_or_default(); + locales + .iter() + .map(|locale| resolve_play_listing(root, &metadata, locale)) + .collect() +} + +fn resolve_play_listing( + root: &ReleaseProviderToml, + metadata: &ReleaseMetadataToml, + locale: &str, +) -> Result { + let listing = root + .release + .as_ref() + .and_then(|release| release.store_listing.get("play_store")) + .and_then(|listings| listings.get(locale)) + .cloned() + .unwrap_or_default(); + let meta = metadata.play_store.get(locale).cloned().unwrap_or_default(); + let title = listing.title.or(listing.name).with_context(|| { + format!("release.store_listing.play_store.{locale}.title or .name is required") + })?; + let short_description = listing + .short_description + .or(listing.subtitle) + .with_context(|| { + format!("release.store_listing.play_store.{locale}.short_description is required") + })?; + let full_description = meta + .full_description + .or(meta.description) + .with_context(|| { + format!("active release metadata [play_store.{locale}].full_description is required") + })?; + Ok(PlayListing { + locale: locale.to_string(), + title, + short_description, + full_description, + video: listing.video.or(listing.video_url), + }) +} + +fn write_imported_play_listings( + project_dir: &Path, + root: &mut ReleaseProviderToml, + listings: &[PlayListing], +) -> Result<()> { + let fission_path = project_dir.join("fission.toml"); + let data = fs::read_to_string(&fission_path) + .with_context(|| format!("failed to read {}", fission_path.display()))?; + let mut doc: toml::Value = toml::from_str(&data) + .with_context(|| format!("failed to parse {}", fission_path.display()))?; + for listing in listings { + set_toml_path( + &mut doc, + &format!("release.store_listing.play_store.{}.title", listing.locale), + toml::Value::String(listing.title.clone()), + )?; + set_toml_path( + &mut doc, + &format!( + "release.store_listing.play_store.{}.short_description", + listing.locale + ), + toml::Value::String(listing.short_description.clone()), + )?; + if let Some(video) = &listing.video { + set_toml_path( + &mut doc, + &format!("release.store_listing.play_store.{}.video", listing.locale), + toml::Value::String(video.clone()), + )?; + } + } + fs::write(&fission_path, toml::to_string_pretty(&doc)? + "\n") + .with_context(|| format!("failed to write {}", fission_path.display()))?; + + let metadata_path = active_release(root) + .and_then(|release| release.metadata.as_deref()) + .map(|metadata| project_dir.join(metadata)); + if let Some(metadata_path) = metadata_path { + let mut metadata_doc: toml::Value = if metadata_path.exists() { + toml::from_str(&fs::read_to_string(&metadata_path)?)? + } else { + toml::Value::Table(Default::default()) + }; + for listing in listings { + set_toml_path( + &mut metadata_doc, + &format!("play_store.{}.full_description", listing.locale), + toml::Value::String(listing.full_description.clone()), + )?; + } + if let Some(parent) = metadata_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write( + &metadata_path, + toml::to_string_pretty(&metadata_doc)? + "\n", + ) + .with_context(|| format!("failed to write {}", metadata_path.display()))?; + } + Ok(()) +} + +fn play_listing_diff(local: &[PlayListing], remote: &[PlayListing]) -> Value { + let mut remote_by_locale = remote + .iter() + .map(|listing| (listing.locale.as_str(), listing)) + .collect::>(); + let mut diffs = Vec::new(); + for local_listing in local { + let remote_listing = remote_by_locale.remove(local_listing.locale.as_str()); + match remote_listing { + Some(remote_listing) => { + push_field_diff(&mut diffs, &local_listing.locale, "title", &local_listing.title, &remote_listing.title); + push_field_diff(&mut diffs, &local_listing.locale, "short_description", &local_listing.short_description, &remote_listing.short_description); + push_field_diff(&mut diffs, &local_listing.locale, "full_description", &local_listing.full_description, &remote_listing.full_description); + if local_listing.video != remote_listing.video { + diffs.push(json!({"locale": local_listing.locale, "field": "video", "local": local_listing.video, "remote": remote_listing.video})); + } + } + None => diffs.push(json!({"locale": local_listing.locale, "field": "listing", "local": "present", "remote": "missing"})), + } + } + for remote_listing in remote_by_locale.values() { + diffs.push(json!({"locale": remote_listing.locale, "field": "listing", "local": "missing", "remote": "present"})); + } + Value::Array(diffs) +} + +fn push_field_diff(diffs: &mut Vec, locale: &str, field: &str, local: &str, remote: &str) { + if local != remote { + diffs.push(json!({"locale": locale, "field": field, "local": local, "remote": remote})); + } +} + +fn read_release_provider_toml(project_dir: &Path) -> Result { + read_release_provider_toml_from_path(&project_dir.join("fission.toml")) +} + +fn read_release_provider_toml_from_path(path: &Path) -> Result { + let data = + fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; + toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display())) +} + +fn read_release_metadata(project_dir: &Path, relative: &str) -> Result { + let path = project_dir.join(relative); + let data = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display())) +} + +fn active_release(root: &ReleaseProviderToml) -> Option<&ReleaseEntryToml> { + let active = root.release.as_ref()?.active_release.as_deref()?; + root.releases + .iter() + .find(|release| release.id.as_deref() == Some(active)) +} + +fn parse_locale_list(locales: &str) -> Result> { + let mut values = locales + .split(',') + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .collect::>() + .into_iter() + .collect::>(); + if values.is_empty() { + bail!("locale list is empty") + } + values.sort(); + Ok(values) +} + +fn unsupported_release_config(provider: publish::DistributionProvider, action: &str) -> Result<()> { + bail!( + "{} release-config {} is not exposed by the current provider API backend; Google Play, App Store, and Microsoft Store metadata import/diff/push are implemented", + provider.as_str(), + action + ) +} + +fn unsupported_reviews(provider: publish::DistributionProvider, action: &str) -> Result<()> { + bail!( + "{} review {} is not exposed by the current provider API backend; Google Play and App Store review list/reply are implemented", + provider.as_str(), + action + ) +} + +fn unsupported_beta(provider: publish::DistributionProvider, action: &str) -> Result<()> { + bail!( + "{} beta {} is not exposed by the current provider API backend; Google Play group management and App Store TestFlight group/tester management are implemented", + provider.as_str(), + action + ) +} + +fn create_play_edit(client: &Client, token: &str, package_name: &str) -> Result { + let url = format!("{PLAY_API}/androidpublisher/v3/applications/{package_name}/edits"); + let response = client + .post(url) + .bearer_auth(token) + .json(&json!({})) + .send() + .context("failed to create Google Play edit")?; + let value = json_response(response, "Google Play edit insert")?; + value + .get("id") + .and_then(Value::as_str) + .map(str::to_string) + .context("Google Play edit insert response did not contain id") +} + +fn validate_play_edit( + client: &Client, + token: &str, + package_name: &str, + edit_id: &str, +) -> Result<()> { + let url = format!( + "{PLAY_API}/androidpublisher/v3/applications/{package_name}/edits/{edit_id}:validate" + ); + let response = client + .post(url) + .bearer_auth(token) + .send() + .context("failed to validate Google Play edit")?; + json_response(response, "Google Play edit validate")?; + Ok(()) +} + +fn commit_play_edit(client: &Client, token: &str, package_name: &str, edit_id: &str) -> Result<()> { + let url = format!( + "{PLAY_API}/androidpublisher/v3/applications/{package_name}/edits/{edit_id}:commit" + ); + let response = client + .post(url) + .bearer_auth(token) + .send() + .context("failed to commit Google Play edit")?; + json_response(response, "Google Play edit commit")?; + Ok(()) +} + +fn read_google_group_csv(path: &Path) -> Result> { + let text = + fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; + let mut groups = Vec::new(); + for line in text.lines() { + for cell in line.split(',') { + let value = cell.trim().trim_matches('"'); + if value.contains('@') && !value.eq_ignore_ascii_case("email") && !value.is_empty() { + groups.push(value.to_string()); + } + } + } + groups.sort(); + groups.dedup(); + if groups.is_empty() { + bail!( + "{} did not contain any Google Group email addresses; Play API tester updates do not support individual email lists", + path.display() + ); + } + Ok(groups) +} + +fn latest_user_comment(review: &Value) -> Option<&Value> { + review + .get("comments") + .and_then(Value::as_array) + .and_then(|comments| { + comments + .iter() + .rev() + .find_map(|comment| comment.get("userComment")) + }) +} + +fn app_store_config(project_dir: &Path) -> Result { + Ok(read_release_provider_toml(project_dir)? + .distribution + .and_then(|distribution| distribution.app_store) + .unwrap_or_default()) +} + +fn app_store_access_token(cfg: &AppStoreConfig) -> Result { + if let Some(token) = env_value("APP_STORE_CONNECT_ACCESS_TOKEN") { + return Ok(token); + } + let issuer_id = env_value("APP_STORE_CONNECT_ISSUER_ID") + .or(cfg.issuer_id.clone()) + .context("distribution.app_store.issuer_id or APP_STORE_CONNECT_ISSUER_ID is required")?; + let key_id = env_value("APP_STORE_CONNECT_KEY_ID") + .or(cfg.key_id.clone()) + .context("distribution.app_store.key_id or APP_STORE_CONNECT_KEY_ID is required")?; + let key_source = env_value("APP_STORE_CONNECT_API_KEY") + .or_else(|| env_value("APP_STORE_CONNECT_API_KEY_PATH")) + .or(cfg.api_key_path.clone()) + .or_else(|| provider_secret(publish::DistributionProvider::AppStore, &[]).ok().flatten()) + .context("APP_STORE_CONNECT_API_KEY, APP_STORE_CONNECT_API_KEY_PATH, distribution.app_store.api_key_path, or vault credentials are required")?; + if looks_like_bearer_token(&key_source) { + return Ok(key_source); + } + let key_text = if key_source.contains("-----BEGIN PRIVATE KEY-----") { + key_source + } else { + fs::read_to_string(&key_source).with_context(|| { + format!("failed to read App Store Connect API key from {key_source}") + })? + }; + let now = now_unix_seconds(); + let claims = AppStoreJwtClaims { + iss: &issuer_id, + aud: "appstoreconnect-v1", + iat: now, + exp: now + 20 * 60, + }; + let mut header = Header::new(Algorithm::ES256); + header.kid = Some(key_id); + encode( + &header, + &claims, + &EncodingKey::from_ec_pem(key_text.as_bytes()) + .context("failed to parse App Store Connect .p8 key as EC private key")?, + ) + .context("failed to sign App Store Connect JWT") +} + +fn app_store_app_id(cfg: &AppStoreConfig, client: &Client, token: &str) -> Result { + if let Some(app_id) = cfg + .app_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + { + return Ok(app_id.to_string()); + } + let bundle_id = cfg + .bundle_id + .as_deref() + .context("distribution.app_store.app_id or bundle_id is required for App Store Connect review operations")?; + let url = format!("{APP_STORE_API}/v1/apps?filter[bundleId]={bundle_id}&limit=1"); + let response = client + .get(url) + .bearer_auth(token) + .send() + .context("failed to resolve App Store Connect app id from bundle id")?; + let value = json_response(response, "App Store app lookup")?; + value + .get("data") + .and_then(Value::as_array) + .and_then(|items| items.first()) + .and_then(|item| item.get("id")) + .and_then(Value::as_str) + .map(str::to_string) + .with_context(|| { + format!("App Store Connect did not return an app for bundle id {bundle_id}") + }) +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +struct AppStoreTester { + email: String, + first_name: Option, + last_name: Option, +} + +fn app_store_beta_groups(client: &Client, token: &str, app_id: &str) -> Result { + let url = format!( + "{APP_STORE_API}/v1/apps/{app_id}/betaGroups?limit=200&fields[betaGroups]=name,createdDate,isInternalGroup,hasAccessToAllBuilds,publicLinkEnabled,publicLink,feedbackEnabled" + ); + let response = client + .get(url) + .bearer_auth(token) + .send() + .context("failed to list App Store beta groups")?; + json_response(response, "App Store beta groups list") +} + +fn resolve_app_store_beta_group( + client: &Client, + token: &str, + app_id: &str, + group: &str, +) -> Result { + let groups = app_store_beta_groups(client, token, app_id)?; + groups + .get("data") + .and_then(Value::as_array) + .into_iter() + .flatten() + .find_map(|item| { + let id = item.get("id").and_then(Value::as_str)?; + let name = item + .pointer("/attributes/name") + .and_then(Value::as_str) + .unwrap_or_default(); + (id == group || name == group).then(|| id.to_string()) + }) + .with_context(|| { + format!("App Store TestFlight group `{group}` was not found for app {app_id}") + }) +} + +fn read_app_store_tester_csv(path: &Path) -> Result> { + let text = + fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; + let mut testers = Vec::new(); + for (index, line) in text.lines().enumerate() { + let cells = split_csv_line(line); + if cells.is_empty() || cells[0].eq_ignore_ascii_case("email") { + continue; + } + let email = cells[0].trim().to_string(); + if !email.contains('@') { + bail!( + "{} line {} does not start with a tester email address", + path.display(), + index + 1 + ); + } + testers.push(AppStoreTester { + email, + first_name: cells + .get(1) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + last_name: cells + .get(2) + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()), + }); + } + testers.sort_by(|left, right| left.email.cmp(&right.email)); + testers.dedup_by(|left, right| left.email == right.email); + if testers.is_empty() { + bail!( + "{} did not contain any tester email addresses", + path.display() + ); + } + Ok(testers) +} + +fn app_store_beta_tester_payload(tester: &AppStoreTester, group_id: &str) -> Value { + let mut attributes = serde_json::Map::new(); + attributes.insert("email".to_string(), Value::String(tester.email.clone())); + if let Some(first_name) = &tester.first_name { + attributes.insert("firstName".to_string(), Value::String(first_name.clone())); + } + if let Some(last_name) = &tester.last_name { + attributes.insert("lastName".to_string(), Value::String(last_name.clone())); + } + json!({ + "data": { + "type": "betaTesters", + "attributes": attributes, + "relationships": { + "betaGroups": { + "data": [{"type": "betaGroups", "id": group_id}] + } + } + } + }) +} + +fn app_store_tester_from_value(value: &Value) -> AppStoreTester { + let attrs = value.get("attributes").unwrap_or(&Value::Null); + AppStoreTester { + email: attrs + .get("email") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + first_name: attrs + .get("firstName") + .and_then(Value::as_str) + .map(str::to_string), + last_name: attrs + .get("lastName") + .and_then(Value::as_str) + .map(str::to_string), + } +} + +fn split_csv_line(line: &str) -> Vec { + line.split(',') + .map(|cell| cell.trim().trim_matches('"').to_string()) + .collect() +} + +fn csv_cell(value: &str) -> String { + if value.contains(',') || value.contains('"') || value.contains('\n') { + format!("\"{}\"", value.replace('"', "\"\"")) + } else { + value.to_string() + } +} + +fn play_config(project_dir: &Path) -> Result { + let path = project_dir.join("fission.toml"); + let data = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + let root: ReleaseProviderToml = + toml::from_str(&data).with_context(|| format!("failed to parse {}", path.display()))?; + Ok(root + .distribution + .and_then(|distribution| distribution.play_store) + .unwrap_or_default()) +} + +fn google_play_access_token(cfg: &PlayStoreConfig, client: &Client) -> Result { + if let Some(token) = env_value("PLAY_STORE_ACCESS_TOKEN") { + return Ok(token); + } + let secret_source = env_value("PLAY_STORE_SERVICE_ACCOUNT_JSON") + .or_else(|| env_value("GOOGLE_APPLICATION_CREDENTIALS")) + .or_else(|| cfg.service_account.clone()) + .or_else(|| { + provider_secret(publish::DistributionProvider::PlayStore, &[]) + .ok() + .flatten() + }); + let Some(source) = secret_source else { + bail!("Google Play credentials are missing; set PLAY_STORE_SERVICE_ACCOUNT_JSON, PLAY_STORE_ACCESS_TOKEN, GOOGLE_APPLICATION_CREDENTIALS, or import play-store credentials") + }; + if looks_like_bearer_token(&source) { + return Ok(source); + } + let service_account = load_google_service_account(&source)?; + service_account_access_token(&service_account, client) +} + +fn service_account_access_token(account: &GoogleServiceAccount, client: &Client) -> Result { + let token_uri = account.token_uri.as_deref().unwrap_or(GOOGLE_TOKEN_URI); + let iat = now_unix_seconds(); + let claims = GoogleJwtClaims { + iss: &account.client_email, + scope: GOOGLE_PLAY_SCOPE, + aud: token_uri, + iat, + exp: iat + 3600, + }; + let key = EncodingKey::from_rsa_pem(account.private_key.as_bytes()) + .context("failed to parse Google service account private_key as RSA PEM")?; + let jwt = encode(&Header::new(Algorithm::RS256), &claims, &key) + .context("failed to sign Google service account JWT")?; + let response = client + .post(token_uri) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), + ("assertion", jwt.as_str()), + ]) + .send() + .context("failed to exchange Google service account JWT")?; + let token: OAuthTokenResponse = response + .error_for_status() + .context("Google OAuth token exchange failed")? + .json() + .context("failed to parse Google OAuth token response")?; + Ok(token.access_token) +} + +fn load_google_service_account(source: &str) -> Result { + let text = if source.trim_start().starts_with('{') { + source.to_string() + } else { + fs::read_to_string(source) + .with_context(|| format!("failed to read Google service account JSON from {source}"))? + }; + serde_json::from_str(&text).context("failed to parse Google service account JSON") +} + +fn json_response(response: Response, operation: &str) -> Result { + let status = response.status(); + let text = response.text()?; + if !status.is_success() { + bail!("{operation} failed with {status}: {text}"); + } + serde_json::from_str(&text) + .with_context(|| format!("failed to parse {operation} response: {text}")) +} + +fn http_client() -> Result { + Client::builder() + .timeout(Duration::from_secs(300)) + .user_agent("fission-cli-release/0.1") + .build() + .context("failed to build release HTTP client") +} + +fn looks_like_bearer_token(value: &str) -> bool { + let trimmed = value.trim(); + !trimmed.starts_with('{') && !Path::new(trimmed).exists() && trimmed.matches('.').count() >= 2 +} + +fn env_value(name: &str) -> Option { + env::var(name).ok().filter(|value| !value.trim().is_empty()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn latest_user_comment_uses_newest_user_comment() { + let value = json!({ + "comments": [ + {"userComment": {"text": "old", "starRating": 2}}, + {"developerComment": {"text": "reply"}}, + {"userComment": {"text": "new", "starRating": 4}} + ] + }); + let comment = latest_user_comment(&value).unwrap(); + assert_eq!(comment.get("text").and_then(Value::as_str), Some("new")); + } + + #[test] + fn google_group_csv_reader_deduplicates_group_emails() { + let path = + std::env::temp_dir().join(format!("fission-play-groups-{}.csv", std::process::id())); + fs::write( + &path, + "email\nclosed-testers@example.com\nclosed-testers@example.com,other@example.com\n", + ) + .unwrap(); + let groups = read_google_group_csv(&path).unwrap(); + assert_eq!( + groups, + vec![ + "closed-testers@example.com".to_string(), + "other@example.com".to_string() + ] + ); + } + + #[test] + fn resolved_play_listing_merges_root_listing_and_release_metadata() { + let root: ReleaseProviderToml = toml::from_str( + r#" +[release] +active_release = "1.0.0+1" +default_locales = ["en-US"] + +[release.store_listing.play_store.en-US] +title = "Todo" +short_description = "Plan work" +video = "https://example.com/video" + +[[releases]] +id = "1.0.0+1" +metadata = "release-content/metadata/1.0.0+1/release.toml" +"#, + ) + .unwrap(); + let metadata: ReleaseMetadataToml = toml::from_str( + r#" +[play_store.en-US] +full_description = "A focused task manager." +"#, + ) + .unwrap(); + let listing = resolve_play_listing(&root, &metadata, "en-US").unwrap(); + assert_eq!(listing.title, "Todo"); + assert_eq!(listing.short_description, "Plan work"); + assert_eq!(listing.full_description, "A focused task manager."); + assert_eq!(listing.video.as_deref(), Some("https://example.com/video")); + } + + #[test] + fn beta_play_store_tracks_parse_group_and_groups() { + let root: ReleaseProviderToml = toml::from_str( + r#" +[beta.play_store.tracks.closed] +tester_source = "google_group" +group = "closed@example.com" +groups = ["qa@example.com"] +"#, + ) + .unwrap(); + let tracks = root.beta.unwrap().play_store.unwrap().tracks; + let closed = tracks.get("closed").unwrap(); + assert_eq!(closed.group.as_deref(), Some("closed@example.com")); + assert_eq!(closed.groups, vec!["qa@example.com".to_string()]); + } + + #[test] + fn app_store_review_response_payload_targets_review() { + let payload = app_store_review_response_payload("review-123", "Thanks for the report."); + assert_eq!( + payload.pointer("/data/type").and_then(Value::as_str), + Some("customerReviewResponses") + ); + assert_eq!( + payload + .pointer("/data/attributes/responseBody") + .and_then(Value::as_str), + Some("Thanks for the report.") + ); + assert_eq!( + payload + .pointer("/data/relationships/review/data/id") + .and_then(Value::as_str), + Some("review-123") + ); + } + + #[test] + fn app_store_beta_tester_payload_assigns_group() { + let tester = AppStoreTester { + email: "person@example.com".to_string(), + first_name: Some("Test".to_string()), + last_name: Some("User".to_string()), + }; + let payload = app_store_beta_tester_payload(&tester, "group-123"); + assert_eq!( + payload.pointer("/data/type").and_then(Value::as_str), + Some("betaTesters") + ); + assert_eq!( + payload + .pointer("/data/attributes/email") + .and_then(Value::as_str), + Some("person@example.com") + ); + assert_eq!( + payload + .pointer("/data/relationships/betaGroups/data/0/id") + .and_then(Value::as_str), + Some("group-123") + ); + } + + #[test] + fn app_store_localization_payload_uses_version_level_fields() { + let localization = AppStoreLocalization { + id: None, + locale: "en-US".to_string(), + description: "A focused task manager.".to_string(), + keywords: Some("todo,tasks".to_string()), + marketing_url: Some("https://example.com".to_string()), + promotional_text: Some("Better planning.".to_string()), + support_url: Some("https://example.com/support".to_string()), + whats_new: Some("New editor.".to_string()), + }; + let payload = app_store_localization_create_payload("version-123", &localization); + assert_eq!( + payload.pointer("/data/type").and_then(Value::as_str), + Some("appStoreVersionLocalizations") + ); + assert_eq!( + payload + .pointer("/data/attributes/locale") + .and_then(Value::as_str), + Some("en-US") + ); + assert_eq!( + payload + .pointer("/data/relationships/appStoreVersion/data/id") + .and_then(Value::as_str), + Some("version-123") + ); + } +} diff --git a/crates/tools/fission-cli/src/release/workflow_ops.rs b/crates/tools/fission-cli/src/release/workflow_ops.rs new file mode 100644 index 00000000..06adcded --- /dev/null +++ b/crates/tools/fission-cli/src/release/workflow_ops.rs @@ -0,0 +1,219 @@ +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::env; +use std::fs; +use std::path::Path; +use std::process::Command; + +#[derive(Debug, Deserialize, Default)] +struct WorkflowRootToml { + #[serde(default)] + release_workflows: BTreeMap, +} + +#[derive(Debug, Deserialize, Serialize, Default)] +struct ReleaseWorkflowToml { + #[serde(default)] + commands: Vec, +} + +#[derive(Debug, Serialize)] +struct WorkflowReceipt { + schema_version: u32, + workflow: String, + status: String, + dry_run: bool, + commands: Vec, +} + +#[derive(Debug, Serialize)] +struct WorkflowCommandReceipt { + index: usize, + command: String, + argv: Vec, + status: String, + exit_code: Option, +} + +pub(super) fn list(project_dir: &Path, json: bool) -> Result<()> { + let workflows = read_workflows(project_dir)?; + if json { + println!( + "{}", + serde_json::to_string_pretty(&workflows.release_workflows)? + ); + } else if workflows.release_workflows.is_empty() { + println!("No [release_workflows.] entries configured"); + } else { + for (name, workflow) in workflows.release_workflows { + println!("{name}: {} command(s)", workflow.commands.len()); + for command in workflow.commands { + println!(" {command}"); + } + } + } + Ok(()) +} + +pub(super) fn run(project_dir: &Path, name: &str, dry_run: bool, json: bool) -> Result<()> { + let workflows = read_workflows(project_dir)?; + let workflow = workflows + .release_workflows + .get(name) + .with_context(|| format!("release workflow `{name}` is not configured"))?; + if workflow.commands.is_empty() { + bail!("release workflow `{name}` has no commands"); + } + let exe = env::current_exe().context("failed to resolve current fission executable")?; + let mut receipt = WorkflowReceipt { + schema_version: 1, + workflow: name.to_string(), + status: "passed".to_string(), + dry_run, + commands: Vec::new(), + }; + for (index, command) in workflow.commands.iter().enumerate() { + let mut argv = split_command(command)?; + if argv.is_empty() { + continue; + } + if !has_project_dir(&argv) { + argv.push("--project-dir".to_string()); + argv.push(project_dir.display().to_string()); + } + if dry_run { + receipt.commands.push(WorkflowCommandReceipt { + index, + command: command.clone(), + argv, + status: "dry-run".to_string(), + exit_code: None, + }); + continue; + } + let status = Command::new(&exe) + .args(&argv) + .status() + .with_context(|| format!("failed to run release workflow command `{command}`"))?; + let success = status.success(); + receipt.commands.push(WorkflowCommandReceipt { + index, + command: command.clone(), + argv, + status: if success { "passed" } else { "failed" }.to_string(), + exit_code: status.code(), + }); + if !success { + receipt.status = "failed".to_string(); + write_workflow_receipt(project_dir, &receipt)?; + if json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } + bail!("release workflow `{name}` failed at command {}", index + 1); + } + } + write_workflow_receipt(project_dir, &receipt)?; + if json { + println!("{}", serde_json::to_string_pretty(&receipt)?); + } else if dry_run { + println!( + "Release workflow `{name}` dry run: {} command(s)", + receipt.commands.len() + ); + for command in &receipt.commands { + println!(" {}", command.argv.join(" ")); + } + } else { + println!("Release workflow `{name}` completed"); + } + Ok(()) +} + +fn read_workflows(project_dir: &Path) -> Result { + let path = project_dir.join("fission.toml"); + let text = + fs::read_to_string(&path).with_context(|| format!("failed to read {}", path.display()))?; + toml::from_str(&text).with_context(|| format!("failed to parse {}", path.display())) +} + +fn write_workflow_receipt(project_dir: &Path, receipt: &WorkflowReceipt) -> Result<()> { + let path = project_dir + .join("target/fission/release-workflows") + .join(format!("{}.json", receipt.workflow)); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&path, serde_json::to_vec_pretty(receipt)?) + .with_context(|| format!("failed to write {}", path.display())) +} + +fn has_project_dir(argv: &[String]) -> bool { + argv.iter() + .any(|arg| arg == "--project-dir" || arg.starts_with("--project-dir=")) +} + +fn split_command(command: &str) -> Result> { + let mut args = Vec::new(); + let mut current = String::new(); + let mut chars = command.chars().peekable(); + let mut quote = None; + while let Some(ch) = chars.next() { + match (quote, ch) { + (Some(q), c) if c == q => quote = None, + (Some(_), '\\') => { + if let Some(next) = chars.next() { + current.push(next); + } + } + (Some(_), c) => current.push(c), + (None, '\'' | '"') => quote = Some(ch), + (None, c) if c.is_whitespace() => { + if !current.is_empty() { + args.push(std::mem::take(&mut current)); + } + } + (None, c) => current.push(c), + } + } + if let Some(q) = quote { + bail!("unterminated {q} quote in workflow command `{command}`"); + } + if !current.is_empty() { + args.push(current); + } + Ok(args) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_command_keeps_quoted_values() { + let args = + split_command("release-config push --provider play-store --locales 'en-US,fr-FR'") + .unwrap(); + assert_eq!( + args, + vec![ + "release-config", + "push", + "--provider", + "play-store", + "--locales", + "en-US,fr-FR" + ] + ); + } + + #[test] + fn project_dir_detection_accepts_split_or_equals() { + assert!(has_project_dir(&[ + "--project-dir".to_string(), + ".".to_string() + ])); + assert!(has_project_dir(&["--project-dir=.".to_string()])); + assert!(!has_project_dir(&["release-config".to_string()])); + } +} diff --git a/docs/cli-and-targets.md b/docs/cli-and-targets.md index f8815798..ff76ce78 100644 --- a/docs/cli-and-targets.md +++ b/docs/cli-and-targets.md @@ -66,6 +66,36 @@ cargo fission test --project-dir my-app --target ios --headless cargo fission test --project-dir my-app --target android --headless ``` +Package, check, and publish release artifacts: + +```sh +cargo fission package --project-dir my-app --target site --format static --release +cargo fission package --project-dir my-app --target linux --format run --release +cargo fission package --project-dir my-app --target macos --format app --release +cargo fission package --project-dir my-app --target android --format apk --release +cargo fission readiness release --project-dir my-app --target site --format static --provider github-pages --site production +cargo fission readiness distribute --project-dir my-app --provider github-pages --site production --artifact my-app/target/fission/release/site/static/artifact-manifest.json +cargo fission distribute setup --project-dir my-app --provider github-pages --site production +cargo fission distribute --project-dir my-app --provider github-pages --site production --artifact my-app/target/fission/release/site/static/artifact-manifest.json +cargo fission distribute --project-dir my-app --provider play-store --track internal --artifact my-app/target/fission/release/android/aab/artifact-manifest.json +``` + +Every package command stages output under `target/fission///` and writes `artifact-manifest.json` with file hashes and MIME types. Static site/web publishing supports GitHub Pages, Cloudflare Pages, Netlify, direct S3-compatible object storage uploads through the Rust AWS SDK, and direct OAuth-backed uploads to Google Drive, OneDrive, and Dropbox. Store providers are represented in the lifecycle command surface so release metadata, beta groups, signing checks, review operations, and authentication can be validated from the same project root before provider-specific store APIs mutate remote state. + +Release lifecycle commands are intentionally separate from packaging: + +```sh +cargo fission release-config validate --project-dir my-app --provider play-store +cargo fission release-config add-release --project-dir my-app --version 1.2.3 --build 42 --yes +cargo fission release-content validate --project-dir my-app --provider app-store +cargo fission beta groups list --project-dir my-app --provider app-store +cargo fission signing status --project-dir my-app --target ios +cargo fission reviews list --project-dir my-app --provider play-store --since 30d +cargo fission auth status --json +``` + +The CLI keeps provider credentials out of `fission.toml`; auth commands can inspect environment-provided credentials or store imported secrets in an encrypted local vault whose key lives in the OS credential store. + The generated project contains: - `src/main.rs` desktop entrypoint diff --git a/docs/post-build-lifecycle.md b/docs/post-build-lifecycle.md index 640cb453..d5c8f7c0 100644 --- a/docs/post-build-lifecycle.md +++ b/docs/post-build-lifecycle.md @@ -67,6 +67,14 @@ fission distribute --project-dir . --provider s3 --artifact fission distribute --project-dir . --provider google-drive --artifact fission distribute --project-dir . --provider onedrive --artifact fission distribute --project-dir . --provider dropbox --artifact +fission distribute --project-dir . --provider github-releases --artifact --site production --deploy v1.2.3 +fission distribute --project-dir . --provider github-pages --artifact --site production +fission distribute --project-dir . --provider cloudflare-pages --artifact --site production +fission distribute --project-dir . --provider netlify --artifact --site production +fission distribute setup --project-dir . --provider github-pages --site production +fission distribute status --project-dir . --provider cloudflare-pages --site production +fission distribute promote --project-dir . --provider netlify --deploy --site production +fission distribute rollback --project-dir . --provider netlify --deploy --site production fission distribute --project-dir . --provider play-store --artifact --track internal fission distribute --project-dir . --provider app-store --artifact --track testflight fission distribute --project-dir . --provider microsoft-store --artifact --track public @@ -213,6 +221,52 @@ prefix = "todo/releases/{version}/" visibility = "presigned" presign_ttl_seconds = 604800 +[distribution.github_pages.production] +owner = "example" +repo = "todo-site" +mode = "actions" +source = "github-actions" +site_kind = "project" +base_path = "/todo-site/" +custom_domain = "" +enforce_https = true + +[distribution.github_pages.docs] +owner = "example" +repo = "todo" +mode = "branch" +source_branch = "gh-pages" +source_path = "/" +site_kind = "project" +base_path = "/todo/" +custom_domain = "docs.example.com" +enforce_https = true + +[distribution.github_releases.production] +owner = "example" +repo = "todo" +tag = "v1.2.3" +name = "Todo 1.2.3" +notes_file = "release-content/metadata/1.2.3+42/notes/en-US.md" +draft = true +prerelease = false +replace_assets = true +upload_artifact_manifest = true + +[distribution.cloudflare_pages.production] +account_id = "00000000000000000000000000000000" +project_name = "todo" +environment = "production" +custom_domain = "todo.example.com" +base_path = "/" + +[distribution.netlify.production] +site_id = "00000000-0000-0000-0000-000000000000" +team_slug = "example" +production = true +custom_domain = "todo.example.com" +base_path = "/" + [distribution.play_store] package_name = "com.example.todo" default_track = "internal" @@ -226,6 +280,14 @@ default_track = "testflight" [distribution.microsoft_store] product_id = "9N0000000000" package_identity_name = "ExampleSoftware.Todo" +package_type = "msix" +submit = false +# Optional private flight used when running `--track private`. +flight_id = "insiders" +# Optional staged package rollout percentage for committed submissions. +package_rollout_percentage = 25 +# Optional CI mode. When false, Fission expects `msstore` to already be configured. +msstore_reconfigure = false [release] default_locales = ["en-US"] @@ -325,6 +387,7 @@ The CLI MUST validate that: - release-content output roots are inside the project or an explicitly allowed workspace path. - screenshot scenarios reference runnable Fission test scripts or declarative app states. - beta group names, track names, and tester sources are valid for the selected provider. +- static-site distribution entries match the generated site's base path, custom-domain mode, and provider publishing source. - signing asset references point to keychain/certificate-store/vault entries or CI secret names, not plaintext secrets. ## 6. Artifact manifest @@ -479,6 +542,10 @@ fission_release signing_assets distribute s3 + github_releases + github_pages + cloudflare_pages + netlify google_drive onedrive dropbox @@ -510,6 +577,19 @@ trait Distributor { } ``` +Providers that support ongoing lifecycle management SHOULD also implement an extended static-hosting/provider lifecycle trait: + +```rust +trait DistributionLifecycle { + fn setup(&self, ctx: &ReleaseContext) -> anyhow::Result; + fn status(&self, ctx: &ReleaseContext) -> anyhow::Result; + fn promote(&self, ctx: &ReleaseContext, deployment: DeploymentId) -> anyhow::Result; + fn rollback(&self, ctx: &ReleaseContext, deployment: DeploymentId) -> anyhow::Result; +} +``` + +Unsupported operations must fail with stable diagnostics, not silent no-ops. For example, a provider that cannot rollback through an API must return `release.provider.rollback_unsupported` with the exact provider and deployment state. + Readiness checks MUST be available without building or packaging. Package validation MUST be available without uploading. Release-config validation MUST validate `fission.toml` and all referenced release files without contacting a store unless the user explicitly requests provider-side diffing. Release-content validation MUST validate rendered assets without contacting a store unless provider-side validation is requested. Beta tester and signing asset operations MUST have dry-run modes that show intended changes before modifying provider state. ## 8. Rust-first dependency policy @@ -528,6 +608,10 @@ The release tooling should prefer Rust for Fission-owned control flow and data h | Android AAB | bundle inputs, feature/module configuration, validation orchestration, and receipts | `bundletool` and Android SDK tools | `bundletool` is the underlying tool used by Android Studio, Android Gradle Plugin, and Google Play for app bundles [R1]. | | iOS IPA | app metadata, asset staging, provisioning selection, and readiness checks | Xcode signing/export tools and Transporter | Device/App Store IPAs require Apple signing assets and provisioning. | | S3-compatible upload | AWS SDK for Rust | none by default | AWS provides Rust SDK S3 examples for upload and multipart flows [R20]. | +| GitHub Releases | release metadata, artifact manifest interpretation, duplicate-asset policy, status, receipts, and `gh` command orchestration | GitHub CLI for release creation, editing, status, and asset upload | GitHub CLI exposes release create/view/edit/upload commands and uses the same authenticated account model developers already use for GitHub Pages workflows [R64][R65]. | +| GitHub Pages | GitHub REST/GraphQL clients, workflow generation, branch publish staging, DNS/readiness checks, and receipts | GitHub Actions for artifact deployment when `mode = "actions"` | GitHub Pages supports branch sources and custom GitHub Actions workflows; Actions deployments use `configure-pages`, `upload-pages-artifact`, and `deploy-pages` with `pages: write` and `id-token: write` permissions [R54][R55]. | +| Cloudflare Pages | Cloudflare API client for projects, deployments, domains, status, credentials, and receipts; Wrangler as the explicit upload backend | Cloudflare-provided tooling for prebuilt upload | Cloudflare Pages supports Direct Upload of prebuilt assets through Wrangler and API-token based Pages API access [R58][R59]. | +| Netlify | Netlify API client for site lookup/create, atomic deploys, domains, status polling, and receipts | Netlify CLI only as a fallback when a new API capability is not yet implemented | Netlify supports API deployments using file digests or ZIP uploads and supports custom domain management through UI/API flows [R61][R62][R63]. | | Google Drive | `reqwest` + OAuth crates | none by default | Drive supports resumable uploads for large files [R21]. | | OneDrive | `reqwest` + OAuth crates | none by default | Microsoft Graph supports upload sessions for large files [R23]. | | Dropbox | `reqwest` + OAuth crates | none by default | Dropbox supports OAuth and upload sessions [R24]. | @@ -814,15 +898,21 @@ Microsoft's MSIX signing documentation states that self-signed certificates are fission distribute --provider microsoft-store --artifact --track public ``` -Fission MUST support Microsoft Store submission automation where Microsoft's APIs allow it. Microsoft documents Store submission APIs for programmatic package submissions and Partner Center workflows [R18][R19]. The CLI MUST also support guided manual first setup because product creation, name reservation, account verification, age rating, pricing, and policy fields may require Partner Center interaction. +Fission MUST support Microsoft Store submission automation where Microsoft's platform tooling and APIs allow it. Microsoft documents Store submission APIs for programmatic MSI/EXE package submissions and Partner Center workflows [R18][R19]. Microsoft also provides the Microsoft Store Developer CLI for publishing MSIX packages, package flights, and submission status from local and CI environments [R66][R67]. Fission uses those official paths instead of implementing a custom MSIX uploader. Microsoft Store release automation MUST handle listing metadata from `fission.toml`, screenshots, logos, trailers, and flight configuration. Microsoft documents screenshots, logos, trailers, and other Store listing image assets for MSIX submissions, including required and optional screenshot counts by device family [R50]. Microsoft also documents package flights through the Store submission APIs [R51]. Fission's Microsoft Store provider must therefore support public submissions and private/test flights through the same artifact/content manifest model. +MSI/EXE submissions use the Store submission API and require a durable HTTPS `package_url` because the Store pulls the installer package from that location. MSIX and MSIXUPLOAD submissions use `msstore publish` against the package file recorded in the artifact manifest. For MSIX, `distribution.microsoft_store.package_type = "msix"` selects this path; `--track public` publishes to the public submission, `--track private` uses `distribution.microsoft_store.flight_id`, and any other non-empty `--track ` is treated as the Partner Center package-flight id. + +By default, Fission keeps MSIX submissions as drafts by passing the no-commit option to the Store developer CLI. A committed submission requires explicit release intent: set `distribution.microsoft_store.submit = true` or run with `--track public --yes`. If `package_rollout_percentage` is present, Fission passes it to the Store developer CLI during MSIX publishing. `msstore_project` can point at the project directory that `msstore publish` should use; if it is omitted, Fission passes the Fission project directory and relies on the explicit `--inputFile` artifact. If `msstore_reconfigure = true`, Fission configures the Store developer CLI from `tenant_id`, `client_id`, `seller_id`, and the Partner Center client secret before publishing; otherwise it assumes the developer or CI runner has already configured the tool. + Readiness MUST check: - Partner Center authentication; - product id or reserved app name; - package identity name matches the Store product identity; +- Microsoft Store Developer CLI availability for MSIX/MSIXUPLOAD artifacts; +- `package_url` only for MSI/EXE submissions; - required visual assets exist; - `fission.toml` release root entries and referenced release metadata files exist for each configured language; - screenshot/trailer assets match Microsoft Store requirements for the selected app type; @@ -1017,13 +1107,185 @@ Readiness MUST check: - service worker/PWA settings when enabled; - icon sizes and web manifest fields. -Distribution providers for web may be S3-compatible object stores, Google Drive, OneDrive, Dropbox, or later dedicated hosting/CDN providers. Static web distribution MUST preserve content type metadata. A `.wasm` object uploaded as `application/octet-stream` when the hosting provider requires `application/wasm` is a release bug. +Static-site packages MUST include a route manifest, asset manifest, MIME map, cache policy, redirect/header files where the selected provider supports them, and the resolved canonical URL/base path used during rendering. The renderer must know whether the site will live at `/`, at a repository subpath such as `/todo/`, or behind a custom domain. Links, canonical URLs, Open Graph images, service-worker scope, and web manifest URLs must be generated against that resolved public base. + +Distribution providers for web include dedicated static hosting providers, S3-compatible object stores, Google Drive, OneDrive, and Dropbox. Dedicated static hosting providers are preferred when the output is a public website because they support production URLs, preview deployments, custom domains, HTTPS, cache behavior, deployment status, and rollback/status APIs. Object/file providers are still valid when the desired output is a downloadable archive, private review link, or internal distribution folder. + +Static web distribution MUST preserve content type metadata. A `.wasm` object uploaded as `application/octet-stream` when the hosting provider requires `application/wasm` is a release bug. Distribution receipts MUST record the deployed canonical URL, provider preview URL where available, provider deployment ID, custom-domain state, HTTPS state, and any DNS or provider-side manual work still required. + +## 15. Static hosting and cloud/file distribution providers + +Static hosting and cloud/file providers use `fission distribute`. They upload artifacts and return durable links, provider IDs, deployment status, and receipts. Public website providers also participate in post-build lifecycle management: domain readiness, HTTPS readiness, preview/production promotion, rollback metadata, deployment status, and link reporting. GitHub Releases is deliberately separate from GitHub Pages: it is an artifact distribution channel for binaries, installers, bundles, archives, debug symbols, checksums, and optionally a zipped static-site package. + +Static hosting providers MUST implement the lifecycle operations they can support: + +- `setup`: create or validate the provider-side site/project, configure the selected publishing source, and attach custom domains where provider APIs allow it; +- `distribute`: upload or deploy the artifact manifest output; +- `status`: fetch the latest deployment, domain, HTTPS, and provider processing state; +- `promote`: move a preview/draft deployment to production where the provider supports it; +- `rollback`: restore a previous deployment where the provider supports it, or emit a precise unsupported-operation diagnostic; +- `observe`: record provider events, build/deploy logs, URLs, and pending manual actions into receipts. + +### 15.1 GitHub Releases + +```text +fission distribute --provider github-releases --artifact --site production --deploy v1.2.3 +``` + +GitHub Releases distribution MUST work for any package artifact emitted by `fission package`. It is not a static-site provider and must not require `index.html`, route manifests, web base paths, or custom-domain configuration. It can distribute Linux `.run` installers, macOS `.app` archives or `.pkg` installers, Windows `.exe`, `.msi`, and `.msix` installers, Android `.apk` and `.aab` files, iOS `.ipa` files, zipped static-site output, debug symbols, crash diagnostics, checksums, SBOMs, and any other files listed in the artifact manifest. + +Configuration: + +```toml +[distribution.github_releases.production] +owner = "example" +repo = "todo" +tag = "v1.2.3" +name = "Todo 1.2.3" +notes_file = "release-content/metadata/1.2.3+42/notes/en-US.md" +draft = true +prerelease = false +replace_assets = true +upload_artifact_manifest = true +``` + +Publishing behavior: + +- resolve owner/repository from `distribution.github_releases.` or the GitHub origin remote; +- resolve the release tag from `--deploy`, `distribution.github_releases..tag`, or the package version as `v`; +- create the release if the tag has no release yet, otherwise update the existing release metadata; +- upload every file listed in the artifact manifest, not just static-site files or a hard-coded extension list; +- optionally upload `artifact-manifest.json` itself so consumers and CI can verify hashes, sizes, MIME types, target, format, profile, and validation state; +- fail if a same-named asset already exists unless `replace_assets = true`, in which case the existing asset is deleted and re-uploaded; +- preserve draft/prerelease/latest behavior through explicit config rather than inferring release status from filenames; +- write a distribution receipt containing the GitHub release ID, release URL, uploaded asset JSON, and any manual follow-up. + +Fission MUST use the GitHub CLI for GitHub Releases rather than implementing direct REST calls in the first-party provider. This keeps GitHub authentication consistent with GitHub Pages/local repository workflows: a developer who has already run `gh auth login` can publish releases without separately teaching Fission about GitHub credentials. Internally Fission still owns package-manifest validation, tag resolution, release metadata resolution, duplicate-asset policy, receipts, and dry-run output. The provider backend invokes `gh release view`, `gh release create`, `gh release edit`, and `gh release upload --clobber` as needed [R64][R65]. + +Readiness MUST check: + +- owner and repository are configured or inferable; +- `gh` is installed and authenticated, or `GH_TOKEN`/`GITHUB_TOKEN`/Fission vault credentials are available for the GitHub CLI process; +- a tag can be resolved before publishing; +- the artifact manifest exists and contains at least one existing uploadable file; +- duplicate-asset policy is explicit when republishing is expected; +- release notes file exists when configured. + +Authentication: + +- GitHub Releases primarily uses `gh auth login`. +- CI can use `GH_TOKEN` or `GITHUB_TOKEN`, and the Fission vault may inject `GH_TOKEN` for the `gh` subprocess when no token is already present. +- Publishing requires repository Contents write permission because release and release-asset operations mutate repository release state [R64][R65]. +- Public status checks may work unauthenticated, but Fission should still prefer the user's `gh` authentication so private repositories and draft releases behave consistently. + +### 15.2 GitHub Pages + +```text +fission distribute --provider github-pages --artifact --site production +``` + +GitHub Pages distribution MUST support publishing both without a custom domain and with a custom domain. + +Supported modes: + +- `mode = "actions"`: Fission generates or validates a GitHub Actions workflow that builds the static site package, uploads the Pages artifact, and deploys through GitHub's Pages Actions path. +- `mode = "branch"`: Fission publishes the packaged static output to a configured branch and folder, normally `gh-pages:/`, for repositories that want branch-source publishing. +- `mode = "manual"`: Fission emits setup instructions and readiness checks only, useful when an organization owns deployment workflows outside Fission. + +`mode = "actions"` SHOULD be the default for repositories hosted on GitHub. GitHub documents custom workflows for Pages using `configure-pages`, `upload-pages-artifact`, and `deploy-pages`; the deploy job requires `pages: write` and `id-token: write` permissions [R55]. This mode avoids committing generated static files into the source repository and works with Fission's static site builder. Readiness MUST check that the repository Pages source is set to GitHub Actions, or emit a remediation explaining how to set it. The generated workflow must run `fission site build --release`, upload the produced static directory as the Pages artifact, and deploy it only from the configured production branch. + +`mode = "branch"` is still required because some projects and organizations prefer branch-source publishing. GitHub documents publishing from a selected branch and folder [R54]. Fission MUST generate `.nojekyll` in the published output unless the user explicitly disables it. If the site uses a custom domain in branch mode, Fission MUST write a root `CNAME` file containing exactly that domain because branch-source GitHub Pages uses that file as part of the custom-domain workflow. If the branch is updated from GitHub Actions, Fission MUST warn that commits made with the default `GITHUB_TOKEN` do not trigger a Pages build in the branch-source path [R54]. + +Custom-domain behavior: + +- without `custom_domain`, a project site defaults to `https://.github.io//`, so `base_path` normally must be `//`; +- without `custom_domain`, a user or organization site defaults to `https://.github.io/`, so `base_path` normally must be `/`; +- with `custom_domain`, `base_path` normally must be `/` unless the user intentionally serves below a path; +- for Actions-based Pages publishing, GitHub states that a `CNAME` file is not required and existing `CNAME` files are ignored; Fission MUST configure or validate the custom domain through repository settings/API instead [R56]; +- for branch-source publishing, Fission MUST keep the `CNAME` file in the publishing root and verify that the repository Pages settings agree with it; +- readiness MUST run GitHub's Pages DNS health check API where credentials permit it and must report required DNS records when it cannot fix them automatically [R57]. + +Authentication: + +- CI Actions mode SHOULD use the built-in workflow token with explicit `pages: write` and `id-token: write` permissions. +- Local mode SHOULD use `gh` authentication when available or a GitHub App installation token/fine-grained personal access token stored in the Fission vault. +- Secrets MUST not be written to the workflow. Generated workflows should rely on GitHub's token where possible. + +Required behavior: + +- verify repository ownership and Pages availability; +- verify the selected source mode; +- verify no generated site secrets are included in the artifact; +- verify base path and custom-domain consistency; +- upload/deploy the exact artifact manifest output; +- poll deployment/build status; +- record page URL, custom-domain URL, deployment ID, source branch/workflow run, and DNS/HTTPS status in the receipt. + +### 15.3 Cloudflare Pages + +```text +fission distribute --provider cloudflare-pages --artifact --site production +``` + +Cloudflare Pages is a good first dedicated static-hosting provider because it supports Direct Upload for prebuilt assets through the provider CLI and API-token based management. Cloudflare documents Direct Upload for prebuilt assets and CI use, including `wrangler pages deploy --project-name=` with `CLOUDFLARE_ACCOUNT_ID` and an API token [R58]. Cloudflare's Pages REST API uses bearer API tokens and documents Pages Read/Write permissions for project/deployment access [R59]. -## 15. Cloud/file distribution providers +Implementation approach: + +- Fission SHOULD use the Cloudflare API directly for account, project, deployment, custom-domain, DNS, status, and receipt operations. +- Fission MUST invoke Wrangler as the Cloudflare Pages upload backend. This is not a fallback path; it is the supported provider-owned upload tool for prebuilt Pages assets. +- Fission MUST store Cloudflare API tokens only in the vault or CI secrets. + +Custom-domain behavior: + +- without `custom_domain`, the canonical site URL is the Pages-provided `https://.pages.dev` URL; +- with a subdomain custom domain, readiness MUST require a CNAME pointing the desired host to `.pages.dev` unless the zone is managed by the same Cloudflare account and can be changed automatically [R60]; +- with an apex custom domain, readiness MUST require that the apex domain is a Cloudflare zone in the same account before automation can complete [R60]; +- when the zone is managed by the same account, Fission MAY create or update the required DNS records after explicit confirmation or non-interactive `--yes`; +- readiness MUST check CAA records where Cloudflare reports certificate issuance problems, because CAA can block certificate issuance [R60]. + +Required behavior: + +- verify account ID, project name, token scopes, and project existence; +- verify provider upload limits against the artifact manifest before starting an upload; +- create the project only when explicitly requested by `fission distribute setup` or equivalent setup command; +- deploy the static output to production or preview environment; +- set or validate custom domains; +- poll deployment status; +- report preview URL, production URL, custom-domain status, deployment ID, and any DNS/certificate remediation. + +### 15.3 Netlify + +```text +fission distribute --provider netlify --artifact --site production +``` + +Netlify is a good first API-driven static-hosting provider because it exposes site/deploy APIs, supports direct manual deploys without Git integration, supports draft deploys, and uses bearer access tokens. Netlify documents API deployment using either file digests plus uploads or ZIP uploads, and documents polling deploy state until it becomes ready [R61]. Netlify also documents manual deploys and production deploys from the CLI, while the API remains the right integration point for Fission automation [R62]. + +Implementation approach: + +- Fission SHOULD use the Netlify API directly with `reqwest` and the vault-managed access token. +- Fission SHOULD prefer the file-digest deploy API for large repeated deploys because it avoids uploading files Netlify already has. +- Fission MAY use ZIP upload for the first implementation or for small sites where the simpler path is acceptable. +- Fission MUST poll deploy state and fail with provider diagnostics if processing fails. + +Custom-domain behavior: + +- without `custom_domain`, the canonical URL is the Netlify site URL returned by the provider; +- with `custom_domain`, readiness MUST verify the domain is attached to the site or emit exact setup steps; +- if Netlify DNS manages the domain, Fission MAY manage DNS records through Netlify APIs; +- if DNS is external, Fission MUST report the required records and verify them where provider APIs expose enough information; +- readiness MUST distinguish "domain attached", "DNS configured", "certificate ready", and "production deploy live". + +Required behavior: -Cloud providers use `fission distribute`. They upload artifacts and return durable links or provider IDs. +- verify token, team/site access, and site ID or site slug; +- optionally create a site during setup; +- deploy as draft or production according to config; +- support deploy previews for pull-request/review flows; +- poll deploy state; +- return deploy ID, deploy URL, production URL, SSL URL, custom-domain status, and provider state in the receipt. -### 15.1 S3-compatible object stores +### 15.4 S3-compatible object stores ```text fission distribute --provider s3 --artifact --profile production @@ -1043,7 +1305,7 @@ Required behavior: Readiness MUST check credentials, bucket existence/access, prefix writability, public/presigned mode, object overwrite policy, and clock skew if presigned URLs are used. -### 15.2 Google Drive +### 15.5 Google Drive Google Drive distribution MUST use OAuth and Drive resumable upload for large artifacts. Google documents resumable upload as the appropriate Drive upload type for files larger than 5 MB or unstable network conditions [R21]. Sharing links require permissions to be created or updated through the Drive permissions API [R22]. @@ -1056,7 +1318,7 @@ Required behavior: - create or update sharing permissions if `visibility = "link"`; - return file IDs and web links. -### 15.3 OneDrive +### 15.6 OneDrive OneDrive distribution MUST use Microsoft Graph. Microsoft Graph supports `createUploadSession` for large file uploads [R23]. Microsoft identity platform supports OAuth device code flow, which is useful for CLI and headless sign-in [R34]. @@ -1068,7 +1330,7 @@ Required behavior: - create sharing links when requested; - return drive item IDs and web URLs. -### 15.4 Dropbox +### 15.7 Dropbox Dropbox distribution MUST use Dropbox OAuth and upload sessions for large artifacts. Dropbox documents OAuth for API authorization and upload session endpoints for chunked uploads [R24]. @@ -1127,7 +1389,7 @@ The ciphertext MUST contain: ```json { "kind": "oauth_refresh_token | service_account_json | api_private_key | signing_password | access_key_pair", - "provider": "google-drive | onedrive | dropbox | play-store | app-store | microsoft-store | s3", + "provider": "github-pages | cloudflare-pages | netlify | google-drive | onedrive | dropbox | play-store | app-store | microsoft-store | s3", "account_label": "work-google", "scopes": ["..."], "expires_at": null, @@ -1556,6 +1818,16 @@ release.beta.tester_import_invalid release.store.metadata_placeholder_text release.symbols.not_uploaded release.s3.bucket_not_writable +release.github_pages.source_not_actions +release.github_pages.custom_domain_dns_unhealthy +release.github_pages.base_path_mismatch +release.cloudflare_pages.token_missing +release.cloudflare_pages.domain_not_active +release.cloudflare_pages.upload_failed +release.netlify.site_missing +release.netlify.deploy_not_ready +release.netlify.domain_not_configured +release.provider.rollback_unsupported release.oauth.device_flow_timeout release.vault.keyring_unavailable ``` @@ -1586,12 +1858,15 @@ These are implementation milestones, not partial product definitions. The final 12. Implement store metadata import/diff/validate/push for supported providers using `fission.toml` plus referenced release files as the authoritative inputs. 13. Implement beta group/tester/flight management for supported providers. 14. Implement version-state queries, release recipes, and provider-side status observation. -15. Implement S3-compatible distribution and receipts. -16. Implement Google Drive, OneDrive, and Dropbox distribution. -17. Implement store distribution for Google Play, App Store Connect/TestFlight, and Microsoft Store. -18. Implement review/customer-feedback list/reply where provider APIs support it. -19. Add install/upload/screenshot smoke tests and CI coverage for non-secret paths. -20. Document provider setup walkthroughs and first-release manual checklists. +15. Implement GitHub Pages distribution for Actions and branch-source modes, including custom-domain readiness and DNS health checks. +16. Implement Cloudflare Pages distribution with API-token auth, Wrangler-based prebuilt upload, project/domain readiness, and receipts. +17. Implement Netlify distribution with token auth, API deploys, draft/production deploys, custom-domain readiness, and receipts. +18. Implement S3-compatible distribution and receipts. +19. Implement Google Drive, OneDrive, and Dropbox distribution. +20. Implement store distribution for Google Play, App Store Connect/TestFlight, and Microsoft Store. +21. Implement review/customer-feedback list/reply where provider APIs support it. +22. Add install/upload/screenshot smoke tests and CI coverage for non-secret paths. +23. Document provider setup walkthroughs and first-release manual checklists. Each milestone MUST land with unit tests for config/readiness logic, integration tests for local packaging where possible, and mocked provider tests for distribution APIs. @@ -1672,3 +1947,17 @@ The post-build lifecycle work is accepted when the following are true: [R51] Microsoft Learn, Manage package flights: https://learn.microsoft.com/en-us/windows/uwp/monetize/manage-flights [R52] Google Play Developer API, Reply to reviews: https://developers.google.com/android-publisher/reply-to-reviews [R53] Apple Developer, Create or update a response to a customer review: https://developer.apple.com/documentation/appstoreconnectapi/post-v1-customerreviewresponses +[R54] GitHub Docs, Configuring a publishing source for your GitHub Pages site: https://docs.github.com/en/pages/getting-started-with-github-pages/configuring-a-publishing-source-for-your-github-pages-site +[R55] GitHub Docs, Using custom workflows with GitHub Pages: https://docs.github.com/en/pages/getting-started-with-github-pages/using-custom-workflows-with-github-pages +[R56] GitHub Docs, Managing a custom domain for your GitHub Pages site: https://docs.github.com/en/pages/configuring-a-custom-domain-for-your-github-pages-site/managing-a-custom-domain-for-your-github-pages-site +[R57] GitHub Docs, REST API endpoints for GitHub Pages: https://docs.github.com/en/rest/pages +[R58] Cloudflare Docs, Use Direct Upload with continuous integration: https://developers.cloudflare.com/pages/how-to/use-direct-upload-with-continuous-integration/ +[R59] Cloudflare Docs, Pages REST API: https://developers.cloudflare.com/pages/configuration/api/ +[R60] Cloudflare Docs, Pages custom domains: https://developers.cloudflare.com/pages/configuration/custom-domains/ +[R61] Netlify Docs, Get started with the Netlify API: https://docs.netlify.com/api-and-cli-guides/api-guides/get-started-with-api/ +[R62] Netlify Docs, Create deploys: https://docs.netlify.com/deploy/create-deploys/ +[R63] Netlify Docs, Manage domains for a site or app: https://docs.netlify.com/domains/manage-domains/manage-domains-for-a-site-app/ +[R64] GitHub CLI manual, `gh release create`: https://cli.github.com/manual/gh_release_create +[R65] GitHub CLI manual, `gh release upload`: https://cli.github.com/manual/gh_release_upload +[R66] Microsoft Learn, Microsoft Store Developer CLI overview: https://learn.microsoft.com/en-us/windows/apps/publish/msstore-dev-cli/overview +[R67] Microsoft Learn, Microsoft Store Developer CLI commands: https://learn.microsoft.com/en-us/windows/apps/publish/msstore-dev-cli/commands diff --git a/docs/rfc-developer-tooling.md b/docs/rfc-developer-tooling.md new file mode 100644 index 00000000..2dce7dd7 --- /dev/null +++ b/docs/rfc-developer-tooling.md @@ -0,0 +1,898 @@ +# RFC: Developer Tooling and Ecosystem + +Status: proposal +Audience: Fission runtime, shells, compiler, CLI, documentation, testing, and IDE integration implementers +Scope: developer-facing tools that make Fission applications easier to build, inspect, debug, test, profile, package, and maintain + +## 1. Summary + +Fission needs a coherent developer tooling story that makes the framework feel like a complete application platform, not only a rendering library. A developer should be able to create an app, run it on any supported target, inspect what the framework built, diagnose layout and performance problems, record tests, check accessibility, package releases, and work from their preferred editor without learning disconnected tools. + +The core design should be protocol-first. Fission should expose one stable developer tooling protocol from running development builds, and every surface should consume that protocol: + +- the standalone `fission devtools` UI; +- IDE plugins; +- the terminal `fission ui`; +- test recorders; +- CI trace viewers; +- future visual design tools. + +This keeps the tooling architecture aligned with Fission's existing optional instrumentation model. The app and shells expose observable snapshots and event streams only when developer tooling is enabled. Production builds should not pay for this instrumentation unless the application explicitly opts in. + +The developer tooling must be honest about Fission's architecture. It should inspect the authored widget tree, lowered Core IR, layout, display list, semantics, actions, reducers, jobs, services, capabilities, resources, shell events, and generated assets. It must not invent a separate UI model that bypasses `Widget::build`, reducers, the router, design-system tokens, or the shell pipeline. + +## 2. Goals + +- Provide a first-class inspection and debugging workflow for desktop, web, Android, iOS, terminal, and static site targets. +- Build one Fission developer tooling protocol that can be consumed by CLI, IDE, browser, and CI tools. +- Make the widget-to-output pipeline explainable: authored widget tree, Core IR, layout, paint/display list, semantics, hit testing, and shell output. +- Provide action, reducer, state, effect, job, service, capability, resource, and network timelines. +- Provide profiling tools for frame time, CPU spans, memory/resource growth, app size, rendering costs, and shell-specific bottlenecks. +- Provide a visual preview/designer workflow that edits Rust, DSP design-system data, `fission.toml`, Markdown content, or other real project files instead of becoming a separate source of truth. +- Integrate with established editor protocols where possible: Language Server Protocol for language/project intelligence and Debug Adapter Protocol for debugger integration. +- Support VS Code-family editors and JetBrains IDEs as first-class rich integrations. +- Keep Neovim, Helix, Emacs, Zed, and other editors usable through CLI, LSP, DAP, and machine-readable diagnostics. +- Keep instrumentation opt-in, bounded, secure, and removable from production builds. + +## 3. Non-goals + +- Do not build a full Rust IDE or replace `rust-analyzer`. +- Do not make the visual designer the authoritative app format. +- Do not require a hosted service for local development. +- Do not require a specific commercial IDE. +- Do not pretend that arbitrary network traffic can be inspected unless it flows through Fission-owned APIs or explicit instrumentation. +- Do not make release builds expose inspection ports by default. +- Do not put JSON or other debug serialization on the runtime hot path when developer tooling is disabled. +- Do not copy external tool workflows exactly. Fission should provide equivalent capability in a way that fits Rust, Fission's architecture, and Fission's CLI. + +## 4. Research baseline + +Mature cross-platform application frameworks provide more than a run command. The useful pattern is a layered toolchain: + +- a command-line tool that owns project creation, run, device selection, diagnostics, test, build, and package workflows; +- a browser or desktop devtools UI for runtime inspection, profiling, logs, networking, memory, and app-size analysis; +- IDE plugins that integrate the same tooling into the editor; +- preview and property-editing tools that shorten the UI iteration loop; +- common protocols so language and debugging features are not rebuilt for each editor. + +The following external references are relevant design inputs, not compatibility targets: + +- Flutter DevTools lists UI/state inspection, jank diagnosis, CPU profiling, network profiling, source debugging, memory debugging, logs, app size, and deep-link validation as core capabilities [R1]. +- Flutter editor support focuses on VS Code, Android Studio, and IntelliJ plugins, while still allowing other editors through command-line tooling plus LSP/DAP-style integration [R2]. +- Flutter's Property Editor and Widget Previewer show that property editing and isolated widget previews materially improve UI iteration, but they also demonstrate the need for careful source mapping and runtime integration [R3][R4]. +- LSP exists to reuse language intelligence across editors instead of implementing editor-specific analyzers repeatedly [R5]. +- DAP exists to reuse debugger integration across editors through debug adapters [R6]. +- VS Code webviews are suitable for custom UI, but the platform guidance is to use them only when normal editor UI is insufficient and to keep them themeable and accessible [R7]. +- JetBrains tool windows are the correct extension surface for persistent project/run/debug tooling inside JetBrains IDEs [R8]. +- Chrome DevTools Protocol is relevant for the web shell because it provides browser instrumentation domains such as network, performance, runtime, accessibility, and tracing [R9]. +- `rust-analyzer` is already the Rust language server and provides IDE features such as go-to-definition, references, refactoring, completion, formatting, and diagnostics [R10]. + +## 5. Tooling architecture + +### 5.1 Fission Developer Tooling Protocol + +Fission should define a versioned Fission Developer Tooling Protocol, abbreviated FDTP in this RFC. FDTP is the contract between a running development app and tooling clients. + +FDTP is not an app runtime ABI. It is a developer-only observation and control protocol. The protocol can use a developer-friendly encoding such as JSON-RPC or a compact binary encoding, but the encoding choice must not affect production runtime behavior. When FDTP is disabled, the app should not allocate snapshots, retain trace history, open sockets, or perform serialization. + +FDTP should support: + +- capability discovery; +- session negotiation; +- target and shell identification; +- frame snapshots; +- incremental diffs; +- event streams; +- source provenance; +- diagnostic events; +- bounded trace capture; +- commands for selecting nodes, highlighting nodes, toggling overlays, requesting profiles, and recording tests. + +The protocol should be implemented by a small set of crates: + +```text +crates/tools/fission-devtools-protocol +crates/tools/fission-devtools-server +crates/tools/fission-devtools-ui +crates/tools/fission-lsp +crates/tools/fission-dap +``` + +The exact crate names can change, but the responsibility split should remain: + +- protocol schemas are shared and stable; +- shell/runtime servers expose live sessions; +- the UI is reusable by CLI, browser, and IDE plugins; +- editor language services complement `rust-analyzer`; +- debug adapters integrate Fission-specific runtime debugging without replacing native Rust debugging. + +### 5.2 Transport + +Default local transport should be: + +- localhost WebSocket for desktop and web development; +- Unix domain socket on Unix-like systems where appropriate; +- named pipe on Windows where appropriate; +- CLI-mediated bridge for Android and iOS devices or simulators; +- in-process channel for the terminal `fission ui` where it owns the child session. + +Remote inspection must be explicit: + +```text +fission run --target ios --devtools +fission devtools attach --device "iPhone 16 Pro" --session +``` + +Development sessions should use random session tokens and bind to localhost by default. Remote device attach should tunnel through the CLI instead of exposing unauthenticated ports on a network. + +### 5.3 Capability discovery + +Every shell should report what it can expose. + +```rust +struct DevtoolsCapabilities { + widget_tree: bool, + core_ir: bool, + layout: bool, + display_list: bool, + semantics: bool, + hit_test: bool, + actions: bool, + reducers: bool, + effects: bool, + resources: bool, + jobs: bool, + services: bool, + capabilities: bool, + network: bool, + performance: bool, + memory: bool, + app_size: bool, + screenshots: bool, + test_recording: bool, + visual_preview: bool, + shell_specific: Vec, +} +``` + +This prevents tools from pretending every target supports the same output. A static site route can expose HTML, CSS, metadata, link validation, and search-index diagnostics. A terminal app can expose cell buffers, focus traversal, semantics, and terminal input events. A desktop app can expose GPU layers, native window metadata, and device scale. The common protocol should make target differences visible instead of hiding them behind weak abstractions. + +### 5.4 Snapshot model + +FDTP snapshots should be frame-scoped and source-linked. + +```rust +struct DevFrame { + session_id: DevSessionId, + frame_id: FrameId, + sequence: u64, + shell: ShellTarget, + viewport: DevViewport, + widget_tree_ref: SnapshotRef, + core_ir_ref: SnapshotRef, + layout_ref: SnapshotRef, + display_list_ref: SnapshotRef, + semantics_ref: SnapshotRef, + diagnostics_ref: SnapshotRef, +} + +struct SourceProvenance { + crate_name: String, + module_path: String, + file: String, + line: u32, + column: u32, + symbol: Option, +} +``` + +Snapshots should be immutable after capture. Historical retention should be bounded by configuration. Tooling clients can request full snapshots, diffs, or summary views depending on the pane they are showing. + +## 6. Required developer tool categories + +### 6.1 Project and target manager + +Developers need a single view of the project before debugging begins. + +Capabilities: + +- inspect `fission.toml`; +- list enabled targets; +- add, remove, or repair targets; +- list connected devices, simulators, browsers, and terminal targets; +- validate SDKs, NDKs, signing identities, browser drivers, and shell dependencies; +- surface feature-gating problems, such as Android dependencies being pulled into a static site build; +- launch `run`, `test`, `site serve`, `package`, and `distribute` operations. + +CLI commands: + +```text +fission doctor +fission devices +fission run +fission ui +fission devtools +``` + +IDE integrations should call these commands or use the same internal libraries. The IDE should not become a second implementation of target discovery. + +### 6.2 Runtime app inspector + +The app inspector is the starting point for a live session. + +It should show: + +- app name, version, target, shell, PID/process/session, device, viewport, scale factor, theme mode, locale, and active route; +- feature flags and enabled instrumentation categories; +- Fission crate versions and shell/runtime versions; +- WOF or static-site artifact identity when applicable; +- design-system identity and loaded token package; +- active handles for resources, services, jobs, capabilities, media, embeds, and host integrations. + +This answers the basic question: "What exactly am I looking at?" + +### 6.3 Widget tree inspector + +The widget inspector should show the authored Fission widget tree, not only the final draw output. + +Capabilities: + +- tree view of widgets with stable node IDs; +- selected widget highlight in the running app; +- source jump to the `Widget::build` implementation or constructor call where possible; +- display of widget properties, theme values, environment inputs, and selector outputs; +- parent/child traversal; +- filter to show app widgets, framework widgets, or all widgets; +- explain rebuild reasons; +- show whether a widget produced Core IR directly, used a custom lowerer, or provided semantic fallback. + +The widget tree is useful because it matches the developer's mental model. It must preserve source provenance so selecting a widget in DevTools can open the source location in the IDE. + +### 6.4 Core IR inspector + +Fission's architecture depends on lowering widgets into a framework-owned intermediate representation. The Core IR inspector should make that lowering visible. + +Capabilities: + +- show Core nodes and operations; +- map each Core node back to source provenance and widget provenance; +- diff Core IR between frames; +- detect unstable node IDs; +- flag unsupported target operations before the shell falls back or fails; +- expose where custom render nodes enter the pipeline. + +This pane is required to keep Fission honest. If a shell output is wrong, developers need to know whether the problem is in widget construction, lowering, layout, paint, shell adaptation, or platform output. + +### 6.5 Layout, constraints, and hit-test inspector + +Layout bugs are one of the most expensive UI problems to diagnose. The layout inspector should expose the constraints and computed geometry that produced the visible screen. + +Capabilities: + +- view constraints, measured size, final rect, padding, margin, alignment, flex/grid values, scroll extents, clipping, transforms, and z-order; +- highlight overflow; +- show independent scroll regions and scroll offsets; +- explain why a node took a given size; +- show baseline and text layout metrics; +- show hit-test path for the last pointer/click/touch event; +- replay a hit test at a chosen coordinate; +- compare logical coordinates, device pixels, CSS pixels, terminal cells, and platform coordinates where relevant. + +This directly addresses issues such as iOS simulator hit-test offsets, desktop scale-factor mismatches, and terminal scroll handling problems. + +### 6.6 Paint, display list, layer, and output inspector + +The paint inspector should explain what was drawn and in what order. + +Capabilities: + +- display list tree with draw operation bounds; +- layer tree and compositing reasons where the shell has layers; +- clip, transform, opacity, shadow, gradient, image, text, embed, and custom-render operations; +- overdraw visualization; +- repaint region visualization; +- shell-specific output: + - desktop/mobile: GPU layers and surface scale; + - web: DOM/canvas/WebGPU/WebGL output details as applicable; + - static site: generated HTML and extracted CSS; + - terminal: terminal cell buffer, style spans, focus cells, and mouse regions. + +The inspector should not force a pixel renderer abstraction onto all targets. It should show the target's real output model. + +### 6.7 Semantics and accessibility inspector + +Accessibility support should be testable while building the UI, not after the app is complete. + +Capabilities: + +- semantics tree with roles, names, descriptions, states, values, actions, and focus order; +- platform accessibility mapping; +- missing labels and ambiguous names; +- keyboard navigation order; +- focus traps; +- contrast checks based on resolved design-system colors; +- target-size checks; +- terminal supportability diagnostics based on semantics fallback; +- screen-reader preview where shell/platform support exists. + +This inspector should share data with accessibility tests and CI checks. + +### 6.8 Design-system and style inspector + +Fission now supports design-system packages and generated theme data. Developers need tooling to make token resolution explainable. + +Capabilities: + +- inspect the active design system, theme mode, resolved component variants, and state styles; +- show where a color, typography value, spacing value, radius, border, shadow, transition, or component token came from; +- show overridden tokens and fallback paths; +- preview light/dark/high-contrast modes; +- edit DSP JSON or generated theme source through safe code actions; +- validate missing tokens, inaccessible contrast, and inconsistent component sizes; +- compare app output against the design-system source package. + +This should be one of Fission's differentiators. The developer should not have to guess why a button or text field looks wrong. + +### 6.9 Action, reducer, and state timeline + +Fission's application model uses actions and reducers. The timeline should make that model debuggable. + +Capabilities: + +- list dispatched actions in order; +- show action source: pointer, keyboard, text input, command, job, service, capability, timer, route, or test; +- show reducer function and source location; +- show state before/after where state is serializable or diffable; +- show selector recomputation and outputs; +- show route changes; +- support breakpoints on action type, reducer, selector, route, or state predicate where feasible; +- support replay/time-travel only when the state and effects declare deterministic replay support. + +Time travel must be honest. If an action caused an external effect that cannot be replayed deterministically, DevTools should mark that boundary instead of pretending replay is exact. + +### 6.10 Effects, async, resources, jobs, services, and capabilities inspector + +Modern applications fail around asynchronous work as often as they fail around layout. + +Capabilities: + +- show resource invocations, in-flight state, cache state, retries, failures, cancellation, and result delivery; +- show command lifecycle, suspend/resume, and reducer callbacks; +- show job start/progress/completion/cancellation; +- show service start/stop/events; +- show capability calls and permission decisions; +- show dependency graph between user action, effect, result, and UI update; +- flag long-running synchronous work on the UI loop. + +This is the correct place to debug `SHOW_ALERT`, future file pickers, authentication, camera/media sessions, platform permissions, background jobs, and user-defined capabilities. + +### 6.11 Network inspector + +The network inspector should cover traffic Fission can actually observe. + +Supported sources: + +- Fission resource APIs that perform HTTP/WebSocket work; +- Fission capability APIs that perform network work; +- Fission-provided HTTP clients; +- shell-provided fetch layers; +- browser network events for the web shell when a Chromium-backed test or development browser is used; +- explicit user instrumentation adapters for external clients. + +Capabilities: + +- request timeline; +- method, URL, status, timing, size, retries, redirects, cache behavior; +- request and response headers with redaction; +- body preview with redaction and size limits; +- WebSocket open/message/close events; +- correlation to action/reducer/effect timeline; +- export as trace artifact. + +The inspector should not claim to capture arbitrary `reqwest`, platform SDK, or third-party native network traffic unless those clients are routed through Fission instrumentation or an explicit adapter. + +### 6.12 Performance, frame, and jank profiler + +Fission needs a frame-oriented profiler. + +Capabilities: + +- timeline for input, action dispatch, reducer time, build time, lowering, diff, layout, paint, raster/output, shell present, async callbacks, and idle time; +- frame budget markers; +- jank detection; +- animation timing and dropped-frame analysis; +- slow build/lower/layout/paint nodes; +- expensive selector/reducer detection; +- shell-specific timing: + - browser: browser performance/CDP trace integration; + - mobile: device frame timing where platform APIs expose it; + - terminal: repaint diff size and terminal write time; + - static site: build time per route and markdown/render/minify time. + +Native CPU profiling should reuse platform profilers where possible. Fission should annotate spans and correlate them with app concepts instead of replacing every profiler. + +### 6.13 Memory and resource inspector + +Memory tooling should answer whether the app is retaining too much and where. + +Capabilities: + +- app state size estimates where available; +- node/Core IR/layout/display-list snapshot sizes; +- image, font, glyph, chart, media, and embed caches; +- host handles and VM handles; +- resource/job/service/capability lifetimes; +- retained snapshot and trace buffers; +- leak warnings for handles that survive expected lifecycle boundaries; +- object allocation sampling where enabled. + +Memory tools must be sampling/bounded by default. Full heap inspection can be shell/platform-specific. + +### 6.14 Logs and diagnostics console + +Fission already has structured diagnostics. The developer tools should make those events useful. + +Capabilities: + +- structured log viewer; +- filters by category, level, frame, route, node, action, reducer, resource, and shell; +- jump from diagnostic to source or inspector pane; +- export JSONL traces; +- ring-buffer display for in-app or terminal use; +- CI artifact viewer for failed tests. + +This should unify `fission-diagnostics`, CLI logs, shell logs, device logs, browser console messages, and test harness output. + +### 6.15 Test recorder and test inspector + +The developer should be able to record an interaction once, turn it into a maintainable test, and inspect why it failed. + +Capabilities: + +- record clicks/touches, keyboard input, text input, scrolls, route changes, and assertions; +- prefer semantic selectors over coordinate selectors; +- show generated test code before writing it; +- record screenshots/goldens where the target supports visual output; +- compare screenshots with tolerances; +- inspect failed hit tests and missing selectors; +- replay tests against desktop, web, Android, iOS, terminal, and static site targets where applicable. + +The recorder should use the same Fission test protocol. It should not automate apps through brittle screen scraping when Fission semantics are available. + +### 6.16 Widget preview and property editor + +Fission needs isolated preview support for components and screens. + +Capabilities: + +- preview a widget without launching the full app route graph; +- provide fixture state, environment, theme, locale, viewport, platform, and input mode; +- show multiple preview variants at once; +- edit simple properties through source-safe actions; +- jump between preview, inspector, and source; +- support screenshots and golden generation from previews. + +The property editor should be conservative. It can edit constructor arguments, struct fields, DSP tokens, or `fission.toml` values when it can make a deterministic source edit. If it cannot safely preserve source structure, it should show a patch or explanation instead of rewriting code destructively. + +### 6.17 Visual designer + +The visual designer should help developers assemble and tune UI, but Rust remains the source of truth. + +Capabilities: + +- drag/select/reorder widgets when the source can be updated safely; +- create new screens/components from templates; +- inspect constraints, spacing, alignment, theme tokens, and accessibility labels; +- switch device sizes, platform targets, color schemes, locales, text scale, and input modes; +- generate Rust component structs implementing `Widget`, not ad-hoc functions returning `Node` unless the user explicitly chooses that style; +- update DSP design-system JSON for token edits; +- update Markdown/content/site metadata for static-site pages where applicable. + +The designer should be built on the same preview and inspector protocol. It should not introduce a second "designer document" format that drifts away from the application code. + +### 6.18 Static site tooling + +The static site target needs developer tools that are not meaningful for an app window. + +Capabilities: + +- route graph viewer; +- content collection viewer; +- front matter validation; +- Markdown AST/HTML preview; +- generated HTML and CSS inspector; +- search index inspector; +- sitemap and robots validation; +- link checker; +- structured data validator; +- metadata/social-card preview; +- accessibility and heading-order checks; +- production asset budget and minification reports. + +These should appear as shell-specific panes under the same DevTools UI. + +### 6.19 Packaging and distribution readiness + +Packaging and distribution are part of the developer story, not an afterthought. + +Capabilities: + +- read release metadata rooted in `fission.toml`; +- validate app identity, icons, signing, entitlements, privacy manifests, SDK requirements, screenshots, previews, release notes, store metadata, provider credentials, and target-specific package prerequisites; +- guide the developer through missing steps; +- produce CI-friendly JSON output; +- surface release blockers in IDE Problems panes. + +This should connect to the post-build lifecycle design instead of creating a separate release tool. + +### 6.20 Migration and upgrade assistant + +As Fission evolves, users need safe upgrade help. + +Capabilities: + +- detect incompatible Fission versions, shell versions, DSP schema versions, and target templates; +- provide codemods where possible; +- provide explicit manual migration instructions where codemods are unsafe; +- update `fission.toml` target config idempotently; +- explain breaking changes in terms of the user's project. + +This should be exposed through: + +```text +fission upgrade check +fission upgrade apply --dry-run +fission fix +``` + +and through IDE code actions. + +### 6.21 Fast edit-run loop + +Developers expect the app to react quickly while they are working. Fission should provide the fastest loop it can without pretending Rust has the same runtime replacement model as an interpreted UI language. + +Capabilities: + +- file watching; +- automatic `cargo check` and target validation; +- automatic rebuild/relaunch for changed binaries; +- app restart with route, viewport, theme, locale, and selected device preserved where safe; +- WOF reload where a target uses compiled Fission app artifacts; +- static-site rebuild and browser live reload; +- widget preview refresh for isolated components; +- clear distinction between: + - rebuild: source changed and compilation is required; + - hot restart: the app process restarts but tooling/session context is preserved; + - state-preserving reload: only allowed when the runtime can prove the state shape and external handles remain compatible. + +The tooling should optimize for short feedback loops, but it must not hide unsafe state reuse. If a reducer, action, state type, capability, or resource signature changed, the devtools UI should explain why a full restart is required. + +### 6.22 Build, bundle, and app-size analyzer + +Fission should make build output and dependency weight visible. + +Capabilities: + +- show crate feature graph and target-specific dependency graph; +- identify platform dependencies pulled into the wrong target; +- show binary/package size by crate, asset type, font, image, chart/data file, generated code, and shell component; +- compare debug and release artifacts; +- compare app-size deltas between commits or builds; +- inspect generated static-site asset budgets; +- inspect generated mobile/desktop package contents; +- recommend feature-gating or asset changes when obvious. + +This is especially important because Fission supports many targets. A web or static-site build should not accidentally carry desktop, Android, or terminal-only dependencies. + +## 7. IDE integration plan + +### 7.1 Tier 1: VS Code-family extension + +Fission should build a VS Code extension first. + +Reasons: + +- it is widely used by Rust, web, and cross-platform developers; +- it has strong task/debug/status-bar integration; +- webviews can host the shared Fission DevTools UI; +- tree views can expose project targets, devices, routes, tests, and diagnostics; +- Code OSS-compatible editors can often consume the same extension packaging. + +Required features: + +- project detection from `fission.toml`; +- commands for init, add target, run, test, site serve, package readiness, and devtools attach; +- status-bar target/device selector; +- Problems integration from `fission check`, `fission site check`, diagnostics, and target readiness; +- DevTools webview; +- Widget Preview webview; +- generated test recorder integration; +- launch configurations for native Rust debugging plus Fission devtools attach; +- snippets for `Widget` structs, reducers, actions, routes, resources, commands, jobs, services, capabilities, design-system setup, and static site pages; +- syntax/schema support for `fission.toml`, DSP JSON, release metadata, content front matter, and trace files. + +The extension should rely on `rust-analyzer` for Rust language intelligence. Fission-specific language services should add project semantics, not duplicate Rust parsing. + +### 7.2 Tier 1: JetBrains platform plugin + +Fission should build a JetBrains plugin for RustRover, IntelliJ IDEA, CLion, and Android Studio. + +Reasons: + +- JetBrains users expect integrated project, run, debug, and inspection tool windows; +- RustRover and CLion are important for Rust developers; +- Android Studio matters for Android target workflows; +- the same plugin architecture can cover multiple JetBrains IDEs with product-specific adaptation. + +Required features: + +- Fission tool window for targets, devices, runs, tests, traces, and package readiness; +- embedded DevTools UI or native panes where appropriate; +- run configurations for Fission targets; +- Problems/inspection integration; +- code actions for project config and migrations; +- preview/designer window; +- schema support for `fission.toml`, DSP JSON, release metadata, content front matter, and trace files. + +The plugin should not try to replace JetBrains' Rust support. It should integrate with it. + +### 7.3 Tier 2: Neovim, Helix, Emacs, and Zed + +These editors should be supported through protocols and CLI rather than heavy bespoke UI at first. + +Required features: + +- `fission-lsp` for project files, DSP JSON, front matter, trace files, generated diagnostics, and command/code-action hooks; +- `fission-dap` for Fission-specific VM/action/reducer debugging where applicable; +- documented tasks/commands; +- machine-readable output from every CLI command that an editor needs; +- `fission devtools --open-browser` for full visual tooling outside the editor; +- stable file formats for traces and snapshots. + +Zed can move to richer plugin support once its extension APIs cover the required UI surfaces. Until then, LSP/tasks/CLI are the correct baseline. + +### 7.4 Tier 3: Xcode and Visual Studio + +Fission should not start by building full plugins for Xcode or Visual Studio. + +Required support instead: + +- generate platform projects where required by iOS/macOS/Windows workflows; +- make those projects debuggable with native tools; +- integrate through CLI, logs, generated schemes/configurations, and documentation; +- provide source maps/provenance so native crashes can be correlated back to Fission code where possible. + +Full IDE plugins can be reconsidered when Fission has enough platform-specific users to justify the maintenance cost. + +### 7.5 Browser development integration + +For the web shell, Fission should integrate with browser tooling rather than fighting it. + +Required features: + +- launch a controlled development browser from `fission run --target web --devtools`; +- collect browser console errors; +- collect network/performance/runtime information via browser devtools protocols where available; +- map browser-level diagnostics back to Fission route/widget/source provenance where possible; +- keep browser instrumentation optional and separate from the normal app runtime. + +## 8. Fission language service + +Fission needs a companion language service, but it should be scoped narrowly. + +It should own: + +- `fission.toml` schema validation and completion; +- target-specific configuration diagnostics; +- DSP JSON schema validation and completion; +- release metadata validation; +- static-site front matter validation; +- generated route/content diagnostics; +- Fission trace/snapshot file viewing support; +- commands/code actions that call the CLI; +- snippets and templates; +- links from diagnostics to docs. + +It should not own: + +- Rust name resolution; +- Rust type inference; +- Rust completion; +- Rust refactoring; +- Cargo workspace analysis already handled by Rust tooling. + +`rust-analyzer` remains the Rust language intelligence provider. Fission tooling should cooperate with it through source spans, generated diagnostics, and editor commands. + +## 9. Fission debug adapter + +Fission should provide a debug adapter only for Fission-specific runtime concepts. + +Good DAP use cases: + +- attach to a running Fission development session; +- break on action dispatch; +- break on reducer entry/exit; +- break on route change; +- break on failed resource/job/service/capability result; +- inspect current action payload and reducer state snapshot where available; +- step through a recorded action timeline; +- inspect WOF/VM execution where Worka/Fission VM integration is active. + +Native Rust source debugging should stay with existing native debuggers and editor integrations. Fission's DAP should complement native debugging by exposing UI/runtime semantics. + +## 10. Command-line experience + +The CLI remains the foundation. IDEs should be thin integrations over the CLI and protocol, not separate products. + +Required commands: + +```text +fission devtools +fission devtools attach +fission inspect +fission trace record +fission trace open +fission trace export +fission preview +fission designer +fission test record +fission test replay +fission fix +fission upgrade check +fission upgrade apply +fission package readiness +fission distribute readiness +``` + +All commands that can be used by IDEs or CI must support: + +```text +--json +--project-dir +--target +--device +--no-interactive +``` + +The terminal `fission ui` should expose the same capabilities for developers who prefer a guided interface, but it should call the same command implementations and protocol clients. + +## 11. Trace files and CI artifacts + +Developer tooling should produce durable artifacts for bug reports and CI. + +Proposed artifacts: + +```text +target/fission/traces/.fission-trace/ + manifest.json + frames/ + snapshots/ + diagnostics.jsonl + screenshots/ + profiles/ + network/ + accessibility/ + site/ +``` + +Trace archives should: + +- be redacted by default; +- include schema versions; +- include project and dependency versions; +- include shell/target metadata; +- include enough source provenance to explain failures; +- allow size limits and sampling; +- be viewable with `fission trace open`. + +CI should upload these artifacts on test failure so developers can inspect the failure locally without rerunning the exact device environment. + +## 12. Security and privacy + +Developer tools can expose sensitive data. Fission should make safe defaults explicit. + +Rules: + +- no devtools server in release builds unless explicitly enabled; +- bind to localhost by default; +- require a random session token for every attach; +- require CLI-mediated tunnels for remote devices; +- redact authorization headers, cookies, known secret fields, credential paths, and configured patterns; +- cap body capture by size and content type; +- allow per-project redaction rules in `fission.toml`; +- never store secrets in trace files by default; +- make trace export show a redaction summary. + +Example: + +```toml +[devtools.redaction] +headers = ["authorization", "cookie", "x-api-key"] +fields = ["password", "token", "secret", "client_secret"] +max_body_bytes = 65536 +``` + +## 13. Developer ecosystem beyond tools + +The mature ecosystem needs more than inspectors. + +Required ecosystem pieces: + +- official templates for app, library, component package, DSP design system, static site, terminal app, chart-heavy app, and plugin/capability package; +- a documented package/plugin model for widgets, capabilities, resources, shells, renderers, chart extensions, and design systems; +- API reference generated from real Rust docs and linked from guides; +- a searchable example gallery with runnable desktop/web/mobile/static/terminal examples; +- a component gallery that shows every built-in widget with states, themes, accessibility notes, and source; +- a design-system gallery and token browser; +- migration guides and automated fixes; +- CI templates for GitHub Actions and other common systems; +- release lifecycle templates and readiness checks; +- crash/error reporting integration interfaces; +- observability hooks for logs, metrics, and traces without binding Fission to a single vendor; +- community extension publishing conventions; +- compatibility policy covering Fission versions, DSP schema versions, shell target support, and generated project templates. + +## 14. Implementation order + +The implementation should proceed in this order because each item unlocks the next without inventing parallel systems: + +1. Define FDTP schemas and capability discovery. +2. Wire runtime/shell instrumentation to produce widget tree, Core IR, layout, display list, semantics, hit-test, action, reducer, diagnostics, and log streams. +3. Implement `fission devtools attach` and a minimal standalone UI that can inspect one running desktop app. +4. Add trace record/open/export support. +5. Add web, Android, iOS, terminal, and static-site attach support through shell-specific bridges. +6. Add layout/hit-test, semantics/accessibility, action/reducer, effects/resources/jobs/services/capabilities, and logs panes. +7. Add performance/frame profiler, memory/resource inspector, and app-size reports. +8. Add network inspector for Fission-owned network/resource APIs and web-shell browser integration. +9. Add test recorder and replay integration. +10. Add fast edit-run loop support: file watch, rebuild, relaunch, hot restart, static-site live reload, and safe state-preserving reload where provable. +11. Add widget preview and property editor. +12. Add design-system inspector and token editing. +13. Add build, bundle, dependency, and app-size analyzer reports. +14. Build the VS Code extension over the CLI and shared DevTools UI. +15. Build the JetBrains plugin over the CLI and shared DevTools UI. +16. Add `fission-lsp` and `fission-dap` surfaces for editor-neutral integrations. +17. Add visual designer workflows once preview, source provenance, and safe source edits are reliable. + +## 15. Acceptance criteria + +The developer story reaches the first mature milestone when: + +- `fission run --devtools` starts an app and exposes an inspectable development session. +- `fission devtools` can attach to desktop, web, Android, iOS, terminal, and static site targets where applicable. +- Selecting a visible UI element shows its widget, Core IR node, layout rect, semantics node, paint/display operation, hit-test path, and source location. +- A layout or hit-test bug can be diagnosed from captured coordinates, constraints, scale factors, and shell output data. +- An action can be traced from input event to reducer to state update to rebuilt UI. +- Resource/job/service/capability work is visible and correlated to the action that triggered it. +- Network calls made through Fission-owned APIs are visible with redaction. +- Performance tooling identifies slow frames and the pipeline stage responsible. +- Accessibility tooling identifies missing labels, focus problems, contrast problems, and target-size problems. +- A failed UI test produces a trace artifact that can be opened locally. +- The edit-run loop automatically rebuilds and restarts or reloads with explicit safety diagnostics. +- The app-size analyzer explains which crates, features, shell pieces, and assets contribute to the final artifact. +- VS Code and JetBrains plugins can run the app, attach DevTools, show diagnostics, open traces, and jump from inspector nodes to source. +- Neovim, Helix, Emacs, Zed, and other editors can use CLI/LSP/DAP outputs without custom per-editor logic. +- Release/package readiness diagnostics can appear in CLI, CI, and IDE Problems panes. +- Production builds have no devtools listener and no instrumentation overhead unless explicitly enabled. + +## 16. Open questions + +- What encoding should FDTP use by default: JSON-RPC for easier tooling, a compact binary protocol for large traces, or both? +- Should trace archives use a directory format, a single compressed archive, or both? +- Which source-editing backend should the property editor use for Rust edits: rust-analyzer assists, a Fission-specific syntax edit layer, or generated patch files? +- How much native mobile performance data can be collected without requiring heavyweight platform-profiler dependencies? +- Should the visual designer ship as part of `fission devtools`, as a separate `fission designer`, or as an IDE-only tool window? +- What is the minimum safe state snapshot trait for action/reducer replay without forcing every app state to implement heavyweight serialization? + +## 17. References + +- [R1] Flutter and Dart DevTools, https://docs.flutter.dev/tools/devtools +- [R2] Flutter editor support, https://docs.flutter.dev/tools/editors +- [R3] Flutter Property Editor, https://docs.flutter.dev/tools/property-editor +- [R4] Flutter Widget Previewer, https://docs.flutter.dev/tools/widget-previewer +- [R5] Language Server Protocol, https://microsoft.github.io/language-server-protocol/ +- [R6] Debug Adapter Protocol, https://microsoft.github.io/debug-adapter-protocol/ +- [R7] VS Code Webviews UX guidance, https://code.visualstudio.com/api/ux-guidelines/webviews +- [R8] IntelliJ Platform Plugin SDK: Tool Window, https://plugins.jetbrains.com/docs/intellij/tool-window.html +- [R9] Chrome DevTools Protocol, https://chromedevtools.github.io/devtools-protocol/ +- [R10] rust-analyzer manual, https://rust-analyzer.github.io/book/index.html diff --git a/documentation/fission.toml b/documentation/fission.toml index 7f7f7c0e..ec780cc7 100644 --- a/documentation/fission.toml +++ b/documentation/fission.toml @@ -25,6 +25,18 @@ enabled = true [site.search] enabled = true +[distribution.github_pages.production] +owner = "worka-ai" +repo = "fission" +mode = "actions" +source = "github-actions" +site_kind = "project" +base_path = "/fission/" +custom_domain = "" +enforce_https = true +workflow = "publish-website.yml" +production_branch = "main" + [[site.nav]] title = "Learn" href = "/docs/learn/overview/"