diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml new file mode 100644 index 0000000..1b2c047 --- /dev/null +++ b/.cargo/mutants.toml @@ -0,0 +1,20 @@ +# Exclusions allowed by the mutants spec — real network adapters and the +# process entry point. Every other mutant in the diff must be caught by +# `cargo test`. + +exclude_re = [ + # src/main.rs::main — process entry point; only wires the CLI and dispatches. + "src/main.rs:.*: replace main -> Result", + # src/main.rs::check_for_update — env-gated, silent, read-only startup notice. + "src/main.rs:.*: replace check_for_update with", + # updater::RealFetcher::with_timeout — builds the bounded-timeout real fetcher. + "RealFetcher::with_timeout", + # updater::::get — real HTTP release lookup. + "impl ReleaseFetcher for RealFetcher", + # updater::::download — real HTTP download. + "impl BinaryDownloader for RealDownloader", + # src/templates/agent_registry.rs::::get — real HTTP registry fetch; #[cfg(not(test))], so no test binary ever contains it. + "impl RegistryFetcher for HttpFetcher", + # src/templates/agentic.rs::resolved_platforms — production network resolution (cache path + resolve(&HttpFetcher)); #[cfg(not(test))], so no test binary ever contains it. + "replace resolved_platforms -> Vec", +] diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e4f745a..031582a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,7 @@ on: permissions: contents: write + actions: read # reuse the release binaries the PR to main already built jobs: release: diff --git a/Cargo.lock b/Cargo.lock index e17c573..389455f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -86,12 +86,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.13.0" @@ -122,12 +116,6 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" -[[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - [[package]] name = "bytes" version = "1.12.1" @@ -224,6 +212,15 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -253,15 +250,17 @@ dependencies = [ [[package]] name = "crossterm" -version = "0.25.0" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 1.3.2", + "bitflags", "crossterm_winapi", - "libc", - "mio 0.8.11", + "derive_more", + "document-features", + "mio", "parking_lot", + "rustix", "signal-hook", "signal-hook-mio", "winapi", @@ -342,6 +341,28 @@ dependencies = [ "syn", ] +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -385,6 +406,15 @@ dependencies = [ "syn", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dyn-clone" version = "1.0.20" @@ -512,15 +542,6 @@ dependencies = [ "thread_local", ] -[[package]] -name = "fxhash" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" -dependencies = [ - "byteorder", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -561,13 +582,14 @@ dependencies = [ [[package]] name = "ghscaff" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "base64", "blake2", "clap", "crypto_box", + "crypto_secretbox", "dirs", "flate2", "inquire", @@ -575,13 +597,13 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2", "tar", "tempfile", "toml", "urlencoding", "walkdir", "whoami", - "xsalsa20poly1305", ] [[package]] @@ -818,17 +840,14 @@ dependencies = [ [[package]] name = "inquire" -version = "0.7.5" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fddf93031af70e75410a2511ec04d49e758ed2f26dad3404a934e0fb45cc12a" +checksum = "6654738b8024300cf062d04a1c13c10c8e2cea598ec1c47dc9b6641159429756" dependencies = [ - "bitflags 2.13.0", + "bitflags", "crossterm", "dyn-clone", "fuzzy-matcher", - "fxhash", - "newline-converter", - "once_cell", "unicode-segmentation", "unicode-width", ] @@ -874,7 +893,7 @@ version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ - "bitflags 2.13.0", + "bitflags", "libc", "plain", "redox_syscall 0.9.0", @@ -892,6 +911,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -929,18 +954,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "mio" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" -dependencies = [ - "libc", - "log", - "wasi", - "windows-sys 0.48.0", -] - [[package]] name = "mio" version = "1.2.1" @@ -948,19 +961,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] -[[package]] -name = "newline-converter" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b6b097ecb1cbfed438542d16e84fd7ad9b0c76c8a65b7f9039212a3d14dc7f" -dependencies = [ - "unicode-segmentation", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -1167,7 +1172,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags", ] [[package]] @@ -1176,7 +1181,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" dependencies = [ - "bitflags 2.13.0", + "bitflags", ] [[package]] @@ -1265,7 +1270,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags", "errno", "libc", "linux-raw-sys", @@ -1274,9 +1279,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "once_cell", "ring", @@ -1298,9 +1303,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -1426,6 +1431,17 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1449,7 +1465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", - "mio 0.8.11", + "mio", "signal-hook", ] @@ -1626,7 +1642,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", - "mio 1.2.1", + "mio", "pin-project-lite", "socket2", "windows-sys 0.61.2", @@ -1704,7 +1720,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.0", + "bitflags", "bytes", "futures-util", "http", @@ -1773,9 +1789,9 @@ checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" -version = "0.1.14" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" [[package]] name = "universal-hash" @@ -1998,22 +2014,13 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -2025,67 +2032,34 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", "windows_i686_gnullvm", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2098,48 +2072,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2171,19 +2121,6 @@ dependencies = [ "rustix", ] -[[package]] -name = "xsalsa20poly1305" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02a6dad357567f81cd78ee75f7c61f1b30bb2fe4390be8fb7c69e2ac8dffb6c7" -dependencies = [ - "aead", - "poly1305", - "salsa20", - "subtle", - "zeroize", -] - [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 09bbe95..05d33af 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ghscaff" -version = "0.6.0" +version = "0.7.0" edition = "2021" description = "Interactive CLI wizard for creating and configuring GitHub repositories" license = "MIT" @@ -16,7 +16,7 @@ path = "src/main.rs" [dependencies] anyhow = "1" clap = { version = "4", features = ["derive"] } -inquire = "0.7" +inquire = "0.9" reqwest = { version = "0.12", features = ["blocking", "json", "rustls-tls"], default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" @@ -29,9 +29,10 @@ urlencoding = "2" toml = "0.8" crypto_box = "0.9" blake2 = "0.10" -xsalsa20poly1305 = "0.9" +crypto_secretbox = "0.1" whoami = "1" serde_yaml = "0.9.34" +sha2 = "0.10" [dev-dependencies] tempfile = "3" diff --git a/README.md b/README.md index f2fd5be..2b4b2e7 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Interactive CLI wizard for creating and configuring GitHub repositories. One bin - **🚀 Language templates** — Rust (v1), Python/Node.js/Java coming soon - **📝 Boilerplate files** — README, Cargo.toml, CI/CD workflows, LICENSE - **🔑 Template secrets** — Automatically configures required GitHub Actions secrets per template -- **⬆️ Self-update** — Replaces the running binary on startup; skips binaries installed with `cargo install` (use `cargo install --force` instead) +- **⬆️ Explicit updates** — `ghscaff update` replaces the running binary, but only after asking (default: no). Binaries installed with `cargo install` are never touched — use `cargo install --force ghscaff` instead. --- @@ -98,11 +98,37 @@ Check the [Releases](https://github.com/UniverLab/ghscaff/releases) page for pre ### Updates -Ghscaff checks for new releases when it starts. If a newer version is available, it prompts you to update. Choose "yes" to replace the running binary with the latest version. +Updating is always explicit — ghscaff never installs anything on its own: -If you installed ghscaff with `cargo install`, the auto-updater will refuse to touch the binary and instead direct you to run `cargo install --force ghscaff`. +```bash +ghscaff update # asks "Update to 0.7.0? [y/N]" — default is NO +ghscaff update --yes # skip the prompt +ghscaff update --check # report only: exit 0 = up to date, 1 = update available, 2 = the check could not be completed +ghscaff update --dry-run # same as --check: report only, never downloads or installs +``` -You can disable update checks with: +On "yes", ghscaff downloads the release asset for your platform, verifies it +against the release's `SHA256SUMS.txt` (verification is skipped only when the +release ships no checksum file) and atomically replaces the running binary. +On success it prints `✓ updated to ` — no restart needed, the running +binary is replaced in place and the next invocation already runs the new one. +Your `~/.ghscaff` state — encrypted vault and boilerplate cache — is never +touched: it is not the binary. + +Exit codes: `ghscaff update` (with or without `--check`) exits **2** when the +release check cannot be completed — no network, DNS or TLS failure, HTTP ≥ 400, +or an unparsable release-list response — and prints the cause on stderr; other +failures (download, checksum, permissions) are ordinary errors (exit 1). +`--check` exits 1 when an update is available, 0 when the binary is current. +`--dry-run` behaves like `--check`: it reports and never downloads or installs. + +If you installed ghscaff with `cargo install`, `ghscaff update` refuses to +touch the binary and instead prints: +`installed with cargo — run: cargo install --force ghscaff` + +On startup ghscaff only *notices* new releases and prints a one-line hint to +run `ghscaff update` — it never prompts and never installs. To silence the +notice: ```bash GHSCAFF_NO_UPDATE_CHECK=1 ghscaff ``` @@ -258,11 +284,16 @@ Each language template includes: - **README.md** — Template with placeholders for name and description - **.gitignore** — Language-specific (fetched from GitHub API) - **.github/workflows/ci.yml** — CI/CD workflow with basic checks -- **.github/workflows/release.yml** — Release workflow (published on Git tags) +- **.github/workflows/release.yml** — Release workflow (published on Git tags; omitted when no license is chosen) - **LICENSE** — Placeholder (user selects license type during wizard) All files are merged into a single atomic `chore: init repository` commit. +Choosing **None** for the license scaffolds an unpublishable project: a Rust +crate gets `publish = false` instead of `license-file` in `Cargo.toml`, the CI +workflow runs with `publish-check: false`, `release.yml` is not created, and a +Python project gets no `license = …` line in `pyproject.toml`. + --- ## Standard Label Set diff --git a/SECURITY.md b/SECURITY.md index 10958b8..e979c1f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -88,14 +88,14 @@ The tool never prints, logs, or transmits tokens. Token input uses `inquire::Pas ### Network Communications - All GitHub API calls use HTTPS (via `rustls-tls`) -- User-Agent is set to `ghscaff` (no identifying information beyond the tool name) -- The only outbound call besides GitHub API is the update check (`GET /repos/UniverLab/ghscaff/releases/latest`), which can be disabled with `GHSCAFF_NO_UPDATE_CHECK=1` +- User-Agent is `ghscaff` for GitHub API calls and `ghscaff-update` for update paths (no identifying information beyond the tool name) +- The startup notice fetches the release list (`GET /repos/UniverLab/ghscaff/releases?per_page=100`) and silently does nothing on any failure; it can be disabled with `GHSCAFF_NO_UPDATE_CHECK=1` (which never affects the explicit `ghscaff update` command). Only the explicit command downloads release assets (`https://github.com/UniverLab/ghscaff/releases/download/{tag}/{asset}` plus `SHA256SUMS.txt`). ### Self-Update Mechanism -The update checker fetches the latest release tag from GitHub API. If the user approves, it runs the install script from `raw.githubusercontent.com`. This is the same script used for initial installation. +Updating is always explicit: only `ghscaff update` downloads anything, and only after consent (prompt defaults to NO; `--yes` skips it). It selects the newest stable release strictly newer than the running binary (drafts, prereleases and non-semver tags excluded), downloads the `ghscaff-{tag}-{arch}-{os}.tar.gz` asset for the current target, verifies its SHA256 against the release's `SHA256SUMS.txt` when present (missing file skips verification; missing entry or mismatch aborts), then atomically replaces the running binary (`std::env::current_exe()`, same-directory temp file plus rename). Binaries under `~/.cargo/bin` are refused with `cargo install --force ghscaff`. The update never touches `~/.ghscaff` state. `ghscaff update --check` only reports (exit 1 = available, 0 = current) and downloads nothing; both modes exit 2 when the release check itself cannot be completed (network, DNS, TLS, HTTP ≥ 400, unparsable response), with the cause printed on stderr. -**Mitigation:** The update only runs with explicit user confirmation via interactive prompt. +**Mitigation:** The update only runs via the explicit `ghscaff update` command with user consent (default NO); the startup notice never prompts and never installs. ## Best Practices for Users diff --git a/demo/demo.rec b/demo/demo.rec index a8fe01b..afac5a0 100644 --- a/demo/demo.rec +++ b/demo/demo.rec @@ -1,377 +1,412 @@ -{"demostage": {"captions": [], "demo": {"name": "demo", "output_dir": "./dist", "prompt": "\\[\\e[1;32m\\]ghScaff@univerlab\\[\\e[0m\\]:\\[\\e[1;34m\\]~\\[\\e[0m\\]$ "}, "duration": 30.789236917, "faithful": false, "focuses": [[0.120907381, "main"], [21.376625013, "github-r1"]], "layout": {"background": "#0b0f14", "font_family": "IBM Plex Mono", "fps": 15, "height": 1080, "line_height": 1.2000000476837158, "panes": [{"font_family": "monospace", "font_size": 16, "height": 1080, "id": "main", "type": "terminal", "width": 1440, "x": 0, "y": 0}, {"height": 1080, "id": "github-r1", "reveal_at": 21.376625013, "theme": "dark", "type": "browser", "url": "https://github.com/jheisonmb/demo-ghscaff", "width": 1440, "x": 0, "y": 0}], "width": 1440}, "timeline": [{"action": "focus", "pane": "main"}, {"action": "type", "human_salt": true, "text": "ghscaff"}, {"action": "wait", "duration_ms": 200}, {"action": "keypress", "key": "enter"}, {"action": "wait", "duration_ms": 2500}, {"action": "secret", "prompt": "Vault passphrase:"}, {"action": "type", "human_salt": true, "text": "demo-ghscaff"}, {"action": "wait", "duration_ms": 200}, {"action": "keypress", "key": "enter"}, {"action": "wait", "duration_ms": 1498}, {"action": "type", "human_salt": true, "text": "a demo repository"}, {"action": "wait", "duration_ms": 200}, {"action": "keypress", "key": "enter"}, {"action": "wait", "duration_ms": 574}, {"action": "type", "human_salt": true, "text": "demo,ghscaff,temp"}, {"action": "wait", "duration_ms": 200}, {"action": "keypress", "key": "enter"}, {"action": "wait", "duration_ms": 200}, {"action": "keypress", "key": "enter"}, {"action": "wait", "duration_ms": 200}, {"action": "keypress", "key": "enter"}, {"action": "wait", "duration_ms": 1568}, {"action": "keypress", "key": "down"}, {"action": "wait", "duration_ms": 207}, {"action": "keypress", "key": "down"}, {"action": "wait", "duration_ms": 200}, {"action": "keypress", "key": "enter"}, {"action": "wait", "duration_ms": 200}, {"action": "keypress", "key": "enter"}, {"action": "wait", "duration_ms": 2225}, {"action": "type", "human_salt": true, "text": "y"}, {"action": "wait", "duration_ms": 200}, {"action": "keypress", "key": "enter"}, {"action": "wait", "duration_ms": 200}, {"action": "keypress", "key": "enter"}, {"action": "wait", "duration_ms": 200}, {"action": "keypress", "key": "enter"}, {"action": "wait", "duration_ms": 2101}, {"action": "type", "human_salt": true, "text": "y"}, {"action": "wait", "duration_ms": 200}, {"action": "keypress", "key": "enter"}, {"action": "wait_for_quiet", "quiet_ms": 500}, {"action": "type", "human_salt": true, "text": "n"}, {"action": "wait", "duration_ms": 200}, {"action": "keypress", "key": "enter"}, {"action": "wait_for_quiet", "quiet_ms": 1500}, {"action": "focus", "pane": "github-r1"}, {"action": "wait", "duration_ms": 6000}, {"action": "terminate"}], "typing": {"base_ms": 80, "salt_ms": 15}}, "env": {"TERM": "xterm-256color"}, "height": 54, "title": "demo", "version": 2, "width": 144} -[0.000147098,"o","\r\n\u001b[?2004l\r"] -[0.000345151,"o","\u001b]697;OSCUnlock=e7d40ddbbcf646adb2a3f991e2653752\u0007\u001b]697;Dir=/home/jheisonmblivecom\u0007"] -[0.000417713,"o","\u001b]697;Shell=bash\u0007\u001b]697;ShellPath=/usr/bin/bash\u0007\u001b]697;WSLDistro=Ubuntu\u0007"] -[0.000492426,"o","\u001b]697;PID=104000\u0007\u001b]697;ExitCode=0\u0007"] -[0.000559022,"o","\u001b]697;TTY=/dev/pts/3\u0007\u001b]697;Log=\u0007"] -[0.000627204,"o","\u001b]697;User=jheisonmblivecom\u0007"] -[0.002576649,"o","\u001b]697;OSCLock=e7d40ddbbcf646adb2a3f991e2653752\u0007\u001b]697;PreExec\u0007"] -[0.101931825,"o","\u001b[?2004h\u001b]697;StartPrompt\u0007\u001b[1;32mghScaff@univerlab\u001b[0m:\u001b[1;34m~\u001b[0m$ \u001b]697;EndPrompt\u0007\u001b]697;NewCmd=e7d40ddbbcf646adb2a3f991e2653752\u0007"] -[0.205172977,"o","g"] -[0.29225186,"o","h"] -[0.374357999,"o","s"] -[0.456410319,"o","c"] -[0.542515393,"o","a"] -[0.61660897,"o","f"] -[0.707714783,"o","f"] -[0.909197751,"o","\r\n\u001b[?2004l\r"] -[1.266174707,"o","\r\n █████ █████████ ██████ ██████ \r\n ░░███ ███░░░░░███ ███░░███ ███░░███\r\n ███████ ░███████ ░███ ░░░ ██████ ██████ ░███ ░░░ ░███ ░░░ \r\n ███░░███ ░███░░███ ░░█████████ ███░░███ ░░░░░███ ███████ ███████ \r\n░███ ░███ ░███ ░███ ░░░░░░░░███░███ ░░░ ███████ ░░░███░ ░░░███░ \r\n░███ ░███ ░███ ░███ ███ ░███░███ ███ ███░░███ ░███ ░███ \r\n░░███████ ████ █████░░█████████ ░░██████ ░░████████ █████ █████ \r\n ░░░░░███░░░░ ░░░░░ ░░░░░░░░░ ░░░░░░ ░░░░░░░░ ░░░░░ ░░░░░ \r\n ███ ░███ \r\n░░██████ \r\n ░░░░░░ \r\n\r\n Create a new GitHub repository\r\n\r\n"] -[1.266354182,"o","\u001b[?25l\u001b[38;5;10m?\u001b[39m Vault passphrase:\r\u001b[?25h"] -[3.630012013,"o","\u001b[?25l\r\u001b[?25h\u001b[?25l"] -[3.630017499,"o","\r\u001b[?25h"] -[3.630063655,"o","\u001b[?25l\r\u001b[?25h\u001b[?25l"] -[3.630096656,"o","\r\u001b[?25h"] -[3.630162667,"o","\u001b[?25l\r\u001b[?25h\u001b[?25l"] -[3.630191735,"o","\r\u001b[?25h"] -[3.630281662,"o","\u001b[?25l\u001b[38;5;10m>\u001b[39m Vault passphrase: \u001b[38;5;14m********\u001b[39m\u001b[K\r"] -[3.630308277,"o","\n\u001b[?25h"] -[3.630334412,"o","\u001b[?25h"] -[3.860340458,"o","d"] -[3.949454826,"o","e"] -[4.000983892,"o"," Validating token... ok (JheisonMB)\r\n\r\n"] -[4.001053495,"o","\u001b[?25l\u001b[38;5;10"] -[4.001099489,"o","m?\u001b[39m Repository name: \r\u001b[19C"] -[4.001162859,"o","\u001b[?25h\u001b[?25l"] -[4.001178394,"o","\u001b[19D\u001b[38;5;10"] -[4.001219234,"o","m?\u001b[39m Repository name: d \u001b[K\r\u001b[20C\u001b[?25h"] -[4.001310458,"o","\u001b[?25l\u001b[20D\u001b[38;5;10m?\u001b[39m Repository name:"] -[4.001347893,"o"," de \u001b[K\r\u001b["] -[4.001360545,"o","21C\u001b[?25h"] -[4.027625136,"o","\u001b[?25l\u001b[21D\u001b[38;5;10m?\u001b[39m"] -[4.027631412,"o"," Repository name: dem \u001b[K\r"] -[4.027633464,"o","\u001b[22C\u001b[?25h"] -[4.117776689,"o","\u001b[?25l\u001b[22D\u001b[38;5;10m?\u001b[39m Repository name: demo \u001b[K\r\u001b[23C\u001b[?25h"] -[4.194860015,"o","\u001b[?25l\u001b[23D\u001b[38;5;10m?\u001b[39m Repository name: demo- \u001b[K\r\u001b[24C\u001b[?25h"] -[4.288991133,"o","\u001b[?25l\u001b[24D\u001b[38;5;10m?\u001b[39m"] -[4.289007078,"o"," Repository name: demo-g \u001b[K\r"] -[4.2890194489999995,"o","\u001b[25C\u001b[?25h"] -[4.37808011,"o","\u001b[?25l\u001b[25D\u001b[38;5;10m?\u001b[39m Repository name:"] -[4.378085636,"o"," demo-gh \u001b[K\r"] -[4.378099989,"o","\u001b[26C\u001b[?25h"] -[4.445228035,"o","\u001b[?25l\u001b[26D\u001b[38;5;10m?\u001b[39m Repository name: demo-ghs "] -[4.445234241,"o","\u001b[K\r\u001b[27"] -[4.445281323,"o","C\u001b[?25h"] -[4.526351561,"o","\u001b[?25l\u001b[27D\u001b[38;5;10m?\u001b[39m"] -[4.526356228,"o"," Repository name:"] -[4.526358221,"o"," demo-ghsc"] -[4.526362057,"o"," \u001b[K"] -[4.526369779,"o","\r\u001b[28"] -[4.526371412,"o","C"] -[4.526377532,"o","\u001b[?25h"] -[4.592493337,"o","\u001b[?25l\u001b[28D\u001b[38;5;10m?\u001b[39m Repository name: "] -[4.592551599,"o","demo-ghsca \u001b[K\r\u001b[29C\u001b[?25h"] -[4.657644455,"o","\u001b[?25l\u001b[29D\u001b[38;5;10m?\u001b[39m Repository name: demo-ghscaf \u001b[K\r\u001b[30C"] -[4.657649923,"o","\u001b[?25h"] -[4.7387283369999995,"o","\u001b[?25l\u001b[30D\u001b[38;5;10m?\u001b[39"] -[4.738733596,"o","m"] -[4.738739375,"o"," "] -[4.738796003,"o","Repository name: demo-ghscaff \u001b[K\r\u001b[31C\u001b[?25h"] -[4.940644848,"o","\u001b[?25l\u001b[31D\u001b[38;5;10m>\u001b["] -[4.940650417,"o","39m Repository name: \u001b["] -[4.940682657,"o","38;5;14mdemo-ghscaff\u001b["] -[4.940714768,"o","39m\u001b[K\r\n"] -[4.940716731,"o","\u001b[?25h"] -[4.940788443,"o","\u001b[?25h\u001b[?25l"] -[4.940833414,"o","\u001b[38;5;10m?\u001b[39m Description: () \r\u001b[18"] -[4.940845873,"o","C\u001b[?25h"] -[6.576491743,"o","\u001b[?25l\u001b[18D\u001b[38;5;10m?\u001b[39m Description:"] -[6.576500746,"o"," () a \u001b[K\r\u001b[19C"] -[6.576514151,"o","\u001b[?25h"] -[6.655599002,"o","\u001b[?25l\u001b[19D\u001b[38;5;10m?\u001b[39"] -[6.655604005,"o","m Description: () "] -[6.65560564,"o","a "] -[6.655638164,"o"," \u001b[K\r\u001b[20C\u001b[?25h"] -[6.726767025,"o","\u001b[?25l\u001b[20D\u001b[38;5;10m?\u001b[39m Description: () a d"] -[6.7267727189999995,"o"," \u001b[K\r\u001b[21C"] -[6.726790024,"o","\u001b[?25h"] -[6.810925402,"o","\u001b[?25l\u001b[21D\u001b[38;5;10m?\u001b[39m Description: "] -[6.810929513,"o","() a de \u001b[K\r"] -[6.81097381,"o","\u001b[22C\u001b[?25h"] -[6.889076552,"o","\u001b[?25l"] -[6.889165974,"o","\u001b[22D\u001b[38;5;10m?\u001b[39m Description: () a dem \u001b[K\r\u001b["] -[6.889168992,"o","23"] -[6.889180271,"o","C\u001b[?25h"] -[6.984191096,"o","\u001b[?25l\u001b[23D\u001b[38;5;10m?\u001b[39m Description: () a demo "] -[6.984196761,"o","\u001b[K\r"] -[6.98421171,"o","\u001b[24C\u001b[?25h"] -[7.052346611,"o","\u001b[?25l\u001b[24D\u001b[38;5;10m?\u001b[39m Description: () a demo \u001b[K\r"] -[7.052352135,"o","\u001b[25C\u001b[?25h"] -[7.145451889,"o","\u001b[?25l\u001b[25D\u001b[38;5;10m?"] -[7.145457804,"o","\u001b[39m Description: ()"] -[7.14550726,"o"," a demo r \u001b[K\r\u001b[26C\u001b[?25h"] -[7.210679803,"o","\u001b[?25l\u001b[26D\u001b[38;5;10m?"] -[7.210730976,"o","\u001b[39m Description: () a demo re \u001b[K\r\u001b["] -[7.210733583,"o","27C"] -[7.210771589,"o","\u001b[?25h"] -[7.28677971,"o","\u001b[?25l\u001b[27D\u001b[38;5;10m?\u001b[39m Description: ()"] -[7.286795701,"o"," a demo rep \u001b[K\r\u001b[28"] -[7.286807472,"o","C"] -[7.286808906,"o","\u001b[?25h"] -[7.376969727,"o","\u001b[?25l\u001b[28D\u001b[38;5;10m?\u001b[39m Description: "] -[7.376975602,"o","() a demo repo \u001b[K"] -[7.376989238,"o","\r\u001b[29C\u001b[?25h"] -[7.462074706,"o","\u001b[?25l\u001b[29D\u001b[38;5;10m?\u001b[39m Description: () a demo repos \u001b[K\r"] -[7.46208035,"o","\u001b[30C\u001b[?25h"] -[7.531186492,"o","\u001b[?25l\u001b[30D\u001b[38;5;10m?\u001b[39m Description: "] -[7.531190936,"o","() "] -[7.53119252,"o","a demo reposi "] -[7.531206824,"o","\u001b[K\r\u001b[31C"] -[7.531217957,"o","\u001b[?25h"] -[7.601333731,"o","\u001b[?25l\u001b[31D\u001b[38;5;10m"] -[7.601338997,"o","?\u001b[39m Description: () a demo reposit "] -[7.601341274,"o","\u001b[K"] -[7.601351464,"o","\r\u001b[32C\u001b[?25h"] -[7.686556633,"o","\u001b[?25l\u001b[32D\u001b[38;5;10m?\u001b[39m"] -[7.68656214,"o"," Description: () a demo reposito \u001b[K\r"] -[7.686564186,"o","\u001b[33C\u001b[?25h"] -[7.780731264,"o","\u001b[?25l\u001b[33D\u001b[38;5;10m?\u001b[39m Description: () a demo repositor \u001b[K\r\u001b[34C\u001b[?25h"] -[7.84584117,"o","\u001b[?25l\u001b[34D\u001b[38;5;10m?\u001b["] -[7.845846206,"o","39m Description:"] -[7.845858392,"o"," () a demo repository \u001b[K"] -[7.845897791,"o","\r\u001b[35C\u001b[?25h"] -[8.047745334,"o","\u001b[?25l\u001b[35D\u001b[38;5;10m>\u001b[39m Description: \u001b[38;"] -[8.047761071,"o","5;14ma demo repository\u001b[39m\u001b[K\r"] -[8.047762726,"o","\n\u001b[?25h"] -[8.047842537,"o","\u001b[?25h"] -[8.04792716,"o","\u001b[?25l\u001b[38;5;10m?\u001b[39"] -[8.047957993,"o","m Topics:"] -[8.048001976,"o"," () \r\n"] -[8.048005758,"o","\u001b[38;"] -[8.048017684,"o","5;14m[\u001b["] -[8.048027212,"o","39m"] -[8.04803637,"o","\u001b[38;5;14"] -[8.048094526,"o","mcomma-separated, e.g. rust,cli,tool\u001b[39m\u001b[38;"] -[8.048111838,"o","5;14m]"] -[8.048124507,"o","\u001b["] -[8.048126182,"o","39m\r"] -[8.048138669,"o","\u001b[1A\u001b["] -[8.048150615,"o","13C\u001b[?25h"] -[8.764812041,"o","\u001b[?25l\u001b[13D\u001b[38;5;10m?\u001b[39m Topics: () d"] -[8.764819025,"o"," \u001b[K\r\n\r\u001b[1A"] -[8.764823299,"o","\u001b[14"] -[8.764840668,"o","C"] -[8.764862723,"o","\u001b[?25h"] -[8.844953819,"o","\u001b[?25l\u001b[14D\u001b[38;5;10m?\u001b[39m"] -[8.844959388,"o"," Topics: "] -[8.844983109,"o","()"] -[8.84502122,"o"," de \u001b[K\r\n\r\u001b[1A"] -[8.845061378,"o","\u001b["] -[8.845100242000001,"o","15C"] -[8.845126843,"o","\u001b[?25h"] -[8.937102176,"o","\u001b[?25l\u001b[15D\u001b[38;5;10m?\u001b[39m Topics: ()"] -[8.937107082,"o"," dem"] -[8.937108989,"o"," \u001b[K\r\n\r"] -[8.937160727,"o","\u001b[1A\u001b[16C\u001b[?25h"] -[9.015193416,"o","\u001b[?25l\u001b[16D\u001b[38;5;10m?\u001b[39m Topics: "] -[9.015198263,"o","() demo \u001b[K\r"] -[9.015254549,"o","\n\r\u001b[1A\u001b[17C\u001b[?25h"] -[9.085260826,"o","\u001b[?25l\u001b[17D\u001b[38;5;10m?"] -[9.085265783,"o","\u001b[39m "] -[9.085321856,"o","Topics: () demo, \u001b[K\r\n\r\u001b[1A"] -[9.085372769,"o","\u001b[18C\u001b[?25h"] -[9.165377851,"o","\u001b[?25l\u001b[18D\u001b["] -[9.16538364,"o","38;5;10m"] -[9.165388096,"o","?\u001b["] -[9.165401913,"o","39m Topics:"] -[9.165415339,"o"," "] -[9.165417065,"o","() "] -[9.165435679,"o","demo,g \u001b[K"] -[9.165444981,"o","\r"] -[9.165451423,"o","\n\r\u001b["] -[9.165453009,"o","1A"] -[9.165522115,"o","\u001b[19C\u001b[?25h"] -[9.249951593,"o","\u001b[?25l\u001b[19D\u001b[38;5;10m?\u001b[39m Topics: ()"] -[9.249957313,"o"," demo,gh \u001b[K\r"] -[9.249973679,"o","\n\r\u001b[1A"] -[9.249984225,"o","\u001b["] -[9.250031952,"o","20C\u001b[?25h"] -[9.332675485,"o","\u001b[?25l\u001b[20D\u001b["] -[9.33268258,"o","38;"] -[9.332723581,"o","5;10m"] -[9.3327408,"o","?\u001b[39m"] -[9.332751878,"o"," Topics: "] -[9.332778861,"o","() demo,ghs "] -[9.332814503,"o","\u001b[K\r\n\r"] -[9.332817042,"o","\u001b[1A"] -[9.332828963,"o","\u001b[21"] -[9.332844887,"o","C"] -[9.332871368,"o","\u001b[?25h"] -[9.410876867,"o","\u001b[?25l\u001b[21D\u001b[38;5;10m?\u001b[39m Topics:"] -[9.410882817,"o"," () demo,ghsc"] -[9.410884995,"o"," "] -[9.410899715,"o","\u001b[K\r\n\r\u001b["] -[9.410962149,"o","1A\u001b[22C\u001b[?25h"] -[9.495965933,"o","\u001b[?25l\u001b[22D\u001b[38;5;10m?\u001b[39m"] -[9.495972085,"o"," Topics: ()"] -[9.496033973,"o"," demo,ghsca \u001b[K\r\n\r\u001b[1A\u001b[23"] -[9.496037585,"o","C\u001b[?25h"] -[9.588155179,"o","\u001b[?25l\u001b[23D\u001b[38;5;10m?\u001b[39m Topics: () demo,ghscaf \u001b[K\r"] -[9.588161021,"o","\n\r\u001b[1"] -[9.588207716,"o","A\u001b[24C\u001b[?25h"] -[9.676256534,"o","\u001b[?25l\u001b[24D\u001b[38;5;10m?\u001b[39m Topics: () demo,ghscaff \u001b[K\r\n\r"] -[9.676262205,"o","\u001b[1A\u001b["] -[9.676263932,"o","25C"] -[9.676275435,"o","\u001b[?25h"] -[9.754384909,"o","\u001b[?25l\u001b[25D\u001b[38;5;10m?\u001b[39m Topics: () demo,ghscaff,"] -[9.754390781,"o"," \u001b[K\r\n\r"] -[9.754444097,"o","\u001b[1A\u001b[26C\u001b[?25h"] -[9.831520795,"o","\u001b[?25l\u001b[26D\u001b[38;5;10m?\u001b[39m"] -[9.831526075,"o"," Topics: () "] -[9.831527801,"o","demo,ghscaff,t "] -[9.831542858,"o","\u001b[K\r\n\r"] -[9.83155951,"o","\u001b["] -[9.831566918,"o","1A\u001b[27"] -[9.831568855,"o","C"] -[9.831570431,"o","\u001b[?25h"] -[9.89959196,"o","\u001b[?25l\u001b[27D"] -[9.8996186,"o","\u001b[38;5;10m?"] -[9.899642701,"o","\u001b[39m"] -[9.899652708,"o"," Topics:"] -[9.899664553000001,"o"," () demo,ghscaff,te \u001b[K\r\n"] -[9.899665998,"o","\r"] -[9.899691474,"o","\u001b[1A"] -[9.89970916,"o","\u001b["] -[9.899718064,"o","28C\u001b[?25h"] -[9.992794653,"o","\u001b[?25l\u001b[28D\u001b[38;5;10m?\u001b[39m Topics: () "] -[9.992800174,"o","demo,ghscaff,tem \u001b[K\r"] -[9.992823672,"o","\n"] -[9.99282562,"o","\r\u001b[1A"] -[9.992827858,"o","\u001b["] -[9.992830307,"o","29"] -[9.992837464,"o","C\u001b[?25h"] -[10.087891742,"o","\u001b[?25l\u001b[29D\u001b[38;5;10m?\u001b[39m"] -[10.087896761,"o"," Topics: ()"] -[10.087901188,"o"," demo,ghscaff,temp"] -[10.087917398,"o"," \u001b[K"] -[10.087932304,"o","\r\n\r"] -[10.087958192,"o","\u001b[1"] -[10.087997228,"o","A\u001b[30C\u001b[?25h"] -[10.289972626,"o","\u001b[?25l\u001b[30D\u001b[38;5;10m>\u001b[39m Topics: \u001b[38;5;14m"] -[10.289977806,"o","demo,ghscaff,temp\u001b[39m\u001b[K"] -[10.289980175,"o","\r\n"] -[10.290033878,"o","\u001b[2K\r\n\u001b[?25h\u001b[1A\u001b[?25h"] -[10.290124508,"o","\u001b[?25l\u001b[38;5;10m?\u001b[39m Visibility: \r\n\u001b[38;5;14m"] -[10.290129256,"o",">\u001b[39m"] -[10.290246012,"o"," \u001b[38;5;14mPublic\u001b[39m\r\n Private\r\n\u001b[38;5;14m[\u001b[39m\u001b[38;5;14m↑↓ to move, enter to select, type to filter\u001b[39m\u001b[38;5;14m]\u001b[39m\r\u001b[3A\u001b[14C\u001b[?25h"] -[10.552718984,"o","\u001b[?25l\u001b[14D\u001b[38;5;10m>\u001b[39m Visibility: "] -[10.552724426,"o","\u001b[38;5;14m"] -[10.552804759,"o","Public\u001b[39m\u001b[K\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[?25h"] -[10.552883145,"o","\u001b[3A\u001b[?25h"] -[10.726250306,"o","\u001b[?25l\u001b[38;5;10m?\u001b[39m Owner: \r\n\u001b[38;5;14m>\u001b[39m \u001b[38;5;14mJheisonMB"] -[10.726304827,"o","\u001b[39m\r\n UniverLab\r\n\u001b[38;5;14m[\u001b[39m\u001b["] -[10.726309245,"o","38;5;14"] -[10.726310791,"o","m"] -[10.726319607,"o","↑↓ to move, enter to select, type to filter\u001b[39"] -[10.726321424,"o","m"] -[10.726331555,"o","\u001b[38;5;14m"] -[10.726341023,"o","]\u001b["] -[10.72634261,"o","39m\r"] -[10.726391518,"o","\u001b[3A\u001b[9C\u001b[?25h"] -[10.815527153,"o","\u001b[?25l\u001b[9D\u001b[38;5;10m>\u001b[39m Owner: "] -[10.815533719,"o","\u001b[38;"] -[10.81554873,"o","5;14mJheisonMB\u001b[39m\u001b[K\r\n"] -[10.815570759,"o","\u001b[2K\r"] -[10.815685843,"o","\n\u001b[2K\r\n\u001b[2K\r\n\u001b[?25h\u001b[3A\u001b[?25h"] -[10.98869305,"o","\u001b[?25l\u001b[38;5;10m?\u001b[39m"] -[10.988699516,"o"," Template: "] -[10.988708,"o","\r\n\u001b[38;"] -[10.98871787,"o","5;14m>\u001b[39m"] -[10.988726947,"o"," \u001b[38;5;14"] -[10.988736154,"o","mpython-fastapi"] -[10.988744648,"o","\u001b[39m\r"] -[10.988746275,"o","\n"] -[10.988754859,"o"," python-module\r"] -[10.988756235,"o","\n"] -[10.988768826,"o"," rust\r\n "] -[10.988770392,"o","none\r"] -[10.988812743,"o","\n\u001b[38;5;14m[\u001b[39m\u001b[38;"] -[10.988815604,"o","5;14mDrives .gitignore, CI workflow, and boilerplate. 'none' = empty repo\u001b["] -[10.98882693,"o","39m"] -[10.988837623,"o","\u001b["] -[10.988927164,"o","38;5;14m]\u001b[39m\r\u001b[5"] -[10.98893112,"o","A\u001b[12C\u001b[?25h"] -[12.451554604,"o","\u001b[?25l\u001b[12D\r\n python-fastapi\u001b[K\r\n\u001b[38;5;14m>\u001b[39m \u001b[38;"] -[12.451559767,"o","5;14mpython-module"] -[12.451618071,"o","\u001b[39m\u001b[K\r\n\r\n\r\n\r\u001b[5A\u001b[12C\u001b[?25h"] -[12.724342924,"o","\u001b[?25l\u001b[12D\r\n\r\n python-module\u001b[K\r\n\u001b[38;5;14m>\u001b[39m \u001b[38;5;14m"] -[12.724348248,"o","rust\u001b[39m\u001b[K\r\n\r"] -[12.724429456,"o","\n\r\u001b[5A\u001b[12C\u001b[?25h"] -[12.98706699,"o","\u001b[?25l\u001b[12D\u001b[38;5;10"] -[12.987080149,"o","m"] -[12.987155236,"o",">\u001b[39m "] -[12.987172584,"o","Template: "] -[12.987174402,"o","\u001b[38;"] -[12.987182690000001,"o","5;14m"] -[12.98726816,"o","rust\u001b[39m\u001b[K\r\n\u001b[2K\r"] -[12.987284202,"o","\n\u001b[2K\r"] -[12.987372231,"o","\n\u001b[2K\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[?25h"] -[12.987441493,"o","\u001b[5"] -[12.987461091,"o","A\u001b[?25h"] -[12.98755217,"o","\u001b[?25l\u001b["] -[12.987572682,"o","38;5;10m"] -[12.98757432,"o","?"] -[12.987605239,"o","\u001b[39m Default branch: (main) "] -[12.987649638,"o"," "] -[12.987684455,"o","\r\u001b[25C\u001b[?25h"] -[13.249926669,"o","\u001b[?25l\u001b[25D\u001b[38;5;10m>\u001b[39m Default branch: \u001b[38;5;14mmain\u001b[39m\u001b[K\r\n\u001b[?25h"] -[13.249989652,"o","\u001b[?25h\u001b[?25l"] -[13.250008366,"o","\u001b[38;5;10m?\u001b[39m "] -[13.250010516,"o","Create develop branch? "] -[13.250067715,"o","(Y/n) \r\u001b[31C\u001b[?25h"] -[15.633726752,"o","\u001b[?25l\u001b[31D\u001b[38;5;10m?\u001b[39m Create develop branch? (Y/n) y"] -[15.633731234,"o"," \u001b[K\r\u001b[32"] -[15.633739676,"o","C\u001b[?25h"] -[15.835950786,"o","\u001b[?25l\u001b[32D\u001b[38;5;10m>\u001b[39"] -[15.835956595,"o","m Create develop branch?"] -[15.835958656,"o"," "] -[15.835998434,"o","\u001b[38;5;14mYes\u001b[39m\u001b[K\r\n\u001b[?25h"] -[15.836058112,"o","\u001b[?25h\u001b[?25l"] -[15.836108854999999,"o","\u001b[38;5;10m?\u001b[39m Features: \r\n\u001b[38;5;14"] -[15.836160581,"o","m>\u001b[39m"] -[15.836164048,"o"," \u001b[38;5;14m[x]\u001b[39m"] -[15.836202963,"o"," \u001b[38;5;14mLICENSE\u001b[39m\r\n"] -[15.836205425,"o"," "] -[15.836260769,"o"," \u001b[38;5;10m[x]\u001b[39m Standard labels\r\n\u001b[38;5;14m[\u001b[39m\u001b[38;"] -[15.836300175,"o","5;14mspace select enter confirm\u001b[39m\u001b[38;5;14m"] -[15.836303502,"o","]\u001b[39m\r\u001b["] -[15.836305241,"o","3"] -[15.836345381,"o","A\u001b[12C\u001b[?25h"] -[16.098887205,"o","\u001b[?25l\u001b[12D\u001b[38;5;10m>\u001b[39m Features: \u001b[38;5;14m"] -[16.098892281,"o","LICENSE, Standard labels\u001b[39m"] -[16.098902582,"o","\u001b[K\r\n\u001b[2K\r\n"] -[16.098917808,"o","\u001b[2K"] -[16.098953757,"o","\r\n\u001b[2K\r\n\u001b[?25h\u001b["] -[16.098995977,"o","3A\u001b[?25h"] -[16.099069836,"o","\u001b[?25l\u001b[38;5;10m?\u001b[39m License: \r\n\u001b[38;5;14m>\u001b[39m"] -[16.099080399,"o"," \u001b[38;5;14"] -[16.099096479,"o","mMIT"] -[16.099133946,"o","\u001b[39m\r\n Apache-2.0\r\n GPL-3.0\r\n None\r\n\u001b[38;5;14m"] -[16.099191272,"o","[\u001b[39m\u001b[38;5;14m↑↓ to move, enter to select, type to filter\u001b[39m\u001b[38;5;14m]\u001b[39"] -[16.099203724,"o","m\r\u001b[5"] -[16.099216277,"o","A"] -[16.099226628,"o","\u001b[11C\u001b[?25h"] -[16.361809303,"o","\u001b[?25l\u001b[11D\u001b[38;5;10m>\u001b[39m License: \u001b[38;5;14"] -[16.361813393,"o","mMIT"] -[16.361815383,"o","\u001b[39m"] -[16.361868777,"o","\u001b[K\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[2K\r"] -[16.361880767,"o","\n\u001b[?25h"] -[16.36195718,"o","\u001b[5A\u001b[?25h\r\n\u001b[?25l\u001b[38;5;10m"] -[16.361998486,"o","?\u001b[39m Apply these changes? (Y/n) \r\u001b[29C\u001b[?25h"] -[18.607080503,"o","\u001b[?25l\u001b[29D\u001b[38;5;10m?\u001b[39m Apply these changes? (Y/n)"] -[18.607084725,"o"," y \u001b[K"] -[18.607086686,"o","\r"] -[18.607095422,"o","\u001b[30C\u001b[?25h"] -[18.809167192,"o","\u001b[?25l\u001b[30D\u001b[38;5;10m>"] -[18.809171546,"o","\u001b[39m"] -[18.809182303,"o"," Apply these changes? "] -[18.809265539,"o","\u001b[38;5;14mYes\u001b[39m\u001b[K\r\n\u001b[?25h"] -[18.809340689,"o","\u001b[?25h"] -[18.809397523,"o","\r\n"] -[19.444674947,"o","n"] -[19.6468907,"o","\r\n"] -[19.859707271,"o"," Fetching boilerplate template... ok\r\n"] -[21.877216013,"o"," [1/8] create repo JheisonMB/demo-ghscaff... ok (https://github.com/JheisonMB/demo-ghscaff)\r\n"] -[27.678696307,"o"," [2/8] init repository... ok\r\n"] -[28.078691998,"o"," [3/8] create develop branch... ok\r\n"] -[28.56176072,"o"," [4/8] apply branch protection (main)... ok\r\n"] -[29.071846929,"o"," [5/8] apply branch protection (develop)... ok\r\n"] +{"demostage":{"captions":[],"demo":{"name":"demo","output_dir":"./dist","prompt":"\\[\\e[1;32m\\]ghScaff@univerlab\\[\\e[0m\\]:\\[\\e[1;34m\\]~\\[\\e[0m\\]$ ","speed":"2x","targets":["gif","mp4"]},"duration":46.568778885,"faithful":false,"focuses":[[0.121435009,"main"],[40.843355251,"github-r1"]],"layout":{"background":"#0b0f14","font_family":"IBM Plex Mono","fps":15,"height":1080,"line_height":1.2000000476837158,"panes":[{"font_family":"monospace","font_size":16,"height":1080,"id":"main","type":"terminal","width":1440,"x":0,"y":0},{"height":1080,"id":"github-r1","reveal_at":40.843355251,"theme":"dark","type":"browser","url":"https://github.com/jheisonmb/demo-ghscaff","width":1440,"x":0,"y":0}],"width":1440},"timeline":[{"action":"focus","pane":"main"},{"action":"type","human_salt":true,"text":"ghscaff"},{"action":"wait","duration_ms":200},{"action":"keypress","key":"enter"},{"action":"wait_for_screen","match":"Repository name:","timeout_ms":30000},{"action":"type","human_salt":true,"text":"demo-ghscaff"},{"action":"wait","duration_ms":200},{"action":"keypress","key":"enter"},{"action":"wait","duration_ms":1498},{"action":"type","human_salt":true,"text":"a demo repository"},{"action":"wait","duration_ms":200},{"action":"keypress","key":"enter"},{"action":"wait","duration_ms":574},{"action":"type","human_salt":true,"text":"demo,ghscaff,temp"},{"action":"wait","duration_ms":200},{"action":"keypress","key":"enter"},{"action":"wait","duration_ms":200},{"action":"keypress","key":"enter"},{"action":"wait","duration_ms":200},{"action":"keypress","key":"enter"},{"action":"wait","duration_ms":1568},{"action":"keypress","key":"down"},{"action":"wait","duration_ms":207},{"action":"keypress","key":"down"},{"action":"wait","duration_ms":200},{"action":"keypress","key":"enter"},{"action":"wait","duration_ms":200},{"action":"keypress","key":"enter"},{"action":"wait","duration_ms":2225},{"action":"type","human_salt":true,"text":"y"},{"action":"wait","duration_ms":200},{"action":"keypress","key":"enter"},{"action":"wait","duration_ms":200},{"action":"keypress","key":"enter"},{"action":"wait","duration_ms":200},{"action":"keypress","key":"enter"},{"action":"wait","duration_ms":2101},{"action":"type","human_salt":true,"text":"y"},{"action":"wait","duration_ms":200},{"action":"keypress","key":"enter"},{"action":"wait_for_screen","match":"Secret CARGO_REGISTRY_TOKEN","timeout_ms":180000},{"action":"wait","duration_ms":1200},{"action":"keypress","key":"esc"},{"action":"wait_for_screen","match":"Clone the repository with gitkit?","timeout_ms":60000},{"action":"type","human_salt":true,"text":"n"},{"action":"wait","duration_ms":200},{"action":"keypress","key":"enter"},{"action":"wait_for_screen","match":"Sponsor button","timeout_ms":30000},{"action":"type","human_salt":true,"text":"n"},{"action":"wait","duration_ms":200},{"action":"keypress","key":"enter"},{"action":"wait","duration_ms":1500},{"action":"focus","pane":"github-r1"},{"action":"wait","duration_ms":4000},{"action":"terminate"}],"typing":{"base_ms":80,"salt_ms":15}},"env":{"TERM":"xterm-256color"},"height":54,"title":"demo","version":2,"width":144} +[0.000158187,"o","\r\n\u001b[?2004l\r"] +[0.000486317,"o","\u001b]697;OSCUnlock=777bdb3a9a68487b8576acfff65d424c\u0007\u001b]697;Dir=/home/jheisonmblivecom\u0007"] +[0.000512565,"o","\u001b]697;Shell=bash\u0007"] +[0.000584413,"o","\u001b]697;ShellPath=/usr/bin/bash\u0007"] +[0.00063105,"o","\u001b]697;WSLDistro=Ubuntu\u0007\u001b]697;PID=1202499\u0007"] +[0.000677135,"o","\u001b]697;ExitCode=0\u0007\u001b]697;TTY=/dev/pts/9\u0007"] +[0.0007456,"o","\u001b]697;Log=\u0007\u001b]697;User=jheisonmblivecom\u0007"] +[0.002959139,"o","\u001b]697;OSCLock=777bdb3a9a68487b8576acfff65d424c\u0007\u001b]697;PreExec\u0007"] +[0.110862421,"o","\u001b[?2004h\u001b]697;StartPrompt\u0007\u001b[1;32mghScaff@univerlab\u001b[0m:\u001b[1;34m~\u001b[0m$ \u001b]697;EndPrompt\u0007\u001b]697;NewCmd=777bdb3a9a68487b8576acfff65d424c\u0007"] +[0.205739805,"o","g"] +[0.292929147,"o","h"] +[0.375171326,"o","s"] +[0.457207927,"o","c"] +[0.543397968,"o","a"] +[0.617555018,"o","f"] +[0.708733327,"o","f"] +[0.912124975,"o","\r\n\u001b[?2004l\r"] +[1.602219864,"o","\r\n █████ █████████ ██████ ██████ \r\n ░░███ ███░░░░░███ ███░░███ ███░░███\r\n ███████ ░███████ ░███ ░░░ ██████ ██████ ░███ ░░░ ░███ ░░░ \r\n ███░░███ ░███░░███ ░░█████████ ███░░███ ░░░░░███ ███████ ███████ \r\n░███ ░███ ░███ ░███ ░░░░░░░░███░███ ░░░ ███████ ░░░███░ ░░░███░ \r\n░███ ░███ ░███ ░███ ███ ░███░███ ███ ███░░███ ░███ ░███ \r\n░░███████ ████ █████░░█████████ ░░██████ ░░████████ █████ █████ \r\n ░░░░░███░░░░ ░░░░░ ░░░░░░░░░ ░░░░░░ ░░░░░░░░ ░░░░░ ░░░░░ \r\n ███ ░███ \r\n░░██████ \r\n ░░░░░░ \r\n\r\n Create a new GitHub repository\r\n\r\n"] +[1.989016672,"o"," Validating token... ok (JheisonMB)\r\n\r\n\u001b[?2004h"] +[1.98947676,"o","\u001b[?25l\u001b[38;5;10m?\u001b[39m Repository name: \r\u001b[19C\u001b[?25h"] +[2.071325072,"o","\u001b[?25l\u001b[19D\u001b[38;5;10m?\u001b[39m Repository name: d \u001b[K\r\u001b["] +[2.071338235,"o","20C\u001b[?25h"] +[2.160654302,"o","\u001b[?25l\u001b[20D\u001b[38;5;10m?\u001b[39m Repository name: de \u001b[K\r\u001b[21C\u001b[?25h"] +[2.238843459,"o","\u001b[?25l\u001b[21D"] +[2.238850142,"o","\u001b[38;5;10"] +[2.238852755,"o","m?"] +[2.238886214,"o","\u001b[39m "] +[2.238915948,"o","Repository name: dem"] +[2.238968039,"o"," \u001b[K\r\u001b[22C\u001b[?25h"] +[2.328985005,"o","\u001b[?25l\u001b[22D\u001b[38;5;10m?\u001b[39m Repository name: demo \u001b[K\r\u001b[23C\u001b[?25h"] +[2.40614052,"o","\u001b[?25l\u001b[23D\u001b[38;5;10m?\u001b[39"] +[2.406144479,"o","m Repository name: demo- \u001b[K\r"] +[2.406200602,"o","\u001b[24C\u001b[?25h"] +[2.500419295,"o","\u001b[?25l\u001b[24D\u001b[38;5;10m?\u001b[39m Repository name:"] +[2.500442183,"o"," "] +[2.500495779,"o","demo-g \u001b[K"] +[2.500530564,"o","\r\u001b[25C"] +[2.5005815030000003,"o","\u001b[?25h"] +[2.589471435,"o","\u001b[?25l\u001b[25D\u001b[38;5;10"] +[2.589475711,"o","m?\u001b[39m Repository name: demo-gh \u001b[K"] +[2.589477211,"o","\r"] +[2.589486833,"o","\u001b[26C\u001b[?25h"] +[2.656642989,"o","\u001b[?25l\u001b[26D\u001b[38;5;10m?\u001b[39"] +[2.656650203,"o","m Repository name: demo-ghs \u001b[K\r\u001b[27"] +[2.656699172,"o","C\u001b[?25h"] +[2.73789118,"o","\u001b[?25l\u001b[27D\u001b[38;5;10m?\u001b[39m Repository name: demo-ghsc \u001b[K\r\u001b[28C\u001b[?25h"] +[2.804115608,"o","\u001b[?25l\u001b[28D\u001b["] +[2.804120597,"o","38;5;10m"] +[2.804195617,"o","?\u001b[39m Repository name: demo-ghsca \u001b[K\r\u001b[29C\u001b[?25h"] +[2.869297774,"o","\u001b[?25l\u001b[29D\u001b[38;5;10m?\u001b["] +[2.8693039479999998,"o","39m"] +[2.869379861,"o"," Repository name: demo-ghscaf \u001b[K\r\u001b[30C\u001b[?25h"] +[2.950471526,"o","\u001b[?25l\u001b[30D\u001b[38;5;10m?\u001b[39m Repository name: demo-ghscaff \u001b[K"] +[2.9504760770000003,"o","\r\u001b[31"] +[2.9504943519999998,"o","C\u001b[?25h"] +[3.15486945,"o","\u001b[?25l\u001b[31D\u001b[38;5;10m>\u001b[39m Repository name: \u001b[38;"] +[3.154873756,"o","5;14mdemo-ghscaff\u001b["] +[3.154893082,"o","39m\u001b[K"] +[3.154894684,"o","\r"] +[3.154895787,"o","\n"] +[3.154906878,"o","\u001b[?25h"] +[3.154968806,"o","\u001b[?25h\u001b[?2004l\u001b[?2004h"] +[3.15502151,"o","\u001b[?25l\u001b[38;5;10m?\u001b[39m Description: () \r\u001b[18"] +[3.15502349,"o","C\u001b[?25h"] +[4.790462837,"o","\u001b[?25l\u001b[18D\u001b[38;5;10m?\u001b[39m"] +[4.790468245,"o"," Description: () a \u001b[K\r\u001b["] +[4.790487663,"o","19C\u001b[?25h"] +[4.869661195,"o","\u001b[?25l\u001b[19D\u001b[38;5;10m?\u001b[39m "] +[4.869669327,"o","Description: () "] +[4.869675786,"o","a \u001b[K\r\u001b["] +[4.869704052,"o","20C\u001b[?25h"] +[4.94096322,"o","\u001b[?25l\u001b[20D\u001b[38;5;10m?\u001b[39m Description: () a d \u001b[K\r\u001b[21C\u001b[?25h"] +[5.025122353,"o","\u001b[?25l\u001b[21D\u001b[38;"] +[5.025126924,"o","5;10m?\u001b["] +[5.02517371,"o","39m Description: () a de \u001b[K\r\u001b[22C"] +[5.025236648,"o","\u001b[?25h"] +[5.103475508,"o","\u001b[?25l\u001b[22D\u001b[38;5;10m?\u001b[39m Description: () a dem"] +[5.103481314,"o"," \u001b[K\r\u001b["] +[5.103498671,"o","23C\u001b[?25h"] +[5.19878564,"o","\u001b[?25l\u001b[23D\u001b["] +[5.198793202,"o","38;"] +[5.198823171,"o","5;10m?\u001b["] +[5.19889645,"o","39m Description: () a demo \u001b[K\r\u001b[24C\u001b[?25h"] +[5.267079962,"o","\u001b[?25l\u001b[24D\u001b[38;5;10m?\u001b["] +[5.267084993,"o","39m "] +[5.26711287,"o","Description: () a demo \u001b[K"] +[5.267139615,"o","\r\u001b[25"] +[5.267142156,"o","C"] +[5.267143921,"o","\u001b[?25h"] +[5.360480797,"o","\u001b[?25l\u001b[25D\u001b[38;5;10m?\u001b[39m Description: () a demo r \u001b[K\r\u001b[26"] +[5.360487532,"o","C\u001b[?25h"] +[5.425647968,"o","\u001b[?25l\u001b[26D\u001b["] +[5.425655376,"o","38;5;10m?\u001b["] +[5.425666029,"o","39m Description: "] +[5.425727789,"o","() a demo re \u001b[K\r\u001b[27"] +[5.425782207,"o","C\u001b[?25h"] +[5.502008406,"o","\u001b[?25l\u001b[27D\u001b[38;5;10m?\u001b[39m Description: () a demo rep "] +[5.502013039,"o","\u001b[K\r\u001b[28C\u001b[?25h"] +[5.592196734,"o","\u001b[?25l\u001b[28D\u001b[38;5;10m?\u001b[39m Description: () a demo repo \u001b[K\r\u001b[29C\u001b[?25h"] +[5.6775874,"o","\u001b[?25l\u001b[29D\u001b[38;5;10m?\u001b[39m Description: () a demo repos "] +[5.677594502,"o","\u001b[K\r\u001b[30C\u001b[?25h"] +[5.746954347,"o","\u001b[?25l\u001b[30D\u001b[38;5;10m?\u001b[39m Description: ()"] +[5.746959796,"o"," a demo reposi \u001b[K\r\u001b[31C\u001b[?25h"] +[5.817396205,"o","\u001b[?25l\u001b[31D\u001b[38;5;10m?\u001b[39m Description: () a demo reposit \u001b[K\r\u001b[32C\u001b[?25h"] +[5.902850117,"o","\u001b[?25l\u001b[32D\u001b[38;5;10m?\u001b[39m Description: () a demo reposito \u001b[K\r\u001b[33C\u001b[?25h"] +[5.997088948,"o","\u001b[?25l\u001b[33D\u001b[38;"] +[5.99711109,"o","5;10m?\u001b[39m Description: "] +[5.99713009,"o","()"] +[5.997139753,"o"," a demo repositor \u001b[K"] +[5.997148978,"o","\r\u001b[34"] +[5.99715808,"o","C\u001b[?25h"] +[6.062612753,"o","\u001b[?25l\u001b[34D"] +[6.062618131,"o","\u001b[38;"] +[6.06262008,"o","5;10m"] +[6.062641386,"o","?\u001b["] +[6.06265411,"o","39m"] +[6.062659151,"o"," Description:"] +[6.062694396,"o"," () a demo repository \u001b[K\r"] +[6.062757818,"o","\u001b[35C\u001b[?25h"] +[6.267525383,"o","\u001b[?25l\u001b[35D\u001b[38;5;10m>\u001b[39m Description: \u001b[38;5;14ma demo repository\u001b[39m"] +[6.267530516,"o","\u001b[K\r\n\u001b[?25h"] +[6.267626066,"o","\u001b[?25h\u001b[?2004l\u001b[?2004h"] +[6.267709055,"o","\u001b[?25l\u001b[38;5;10m?\u001b[39m Topics: () \r\n\u001b[38;5;14m[\u001b["] +[6.267785596,"o","39m\u001b[38;5;14mcomma-separated, e.g. rust,cli,tool\u001b[39m\u001b[38;5;14m]\u001b[39m\r\u001b[1A\u001b[13C"] +[6.267802677,"o","\u001b[?25h"] +[6.990482597,"o","\u001b[?25l\u001b[13D\u001b[38;5;10m?\u001b[39"] +[6.990487668,"o","m Topics: () d"] +[6.990501076,"o"," \u001b[K\r\n"] +[6.99051279,"o","\r"] +[6.990542331,"o","\u001b[1A\u001b[14C\u001b[?25h"] +[7.070847309,"o","\u001b[?25l\u001b[14D\u001b[38;5;10m?"] +[7.070852309,"o","\u001b[39m Topics: () de"] +[7.070866002,"o"," \u001b[K\r\n\r"] +[7.070868778,"o","\u001b[1A"] +[7.070905125,"o","\u001b[15C\u001b[?25h"] +[7.163051237,"o","\u001b[?25l\u001b[15D\u001b[38;5;10m?\u001b[39m Topics: () dem \u001b[K\r\n\r\u001b[1A\u001b[16C\u001b[?25h"] +[7.241262899,"o","\u001b[?25l\u001b[16D\u001b[38;5;10m?\u001b[39m Topics: () demo \u001b[K\r\n\r\u001b[1A\u001b["] +[7.241267644,"o","17C\u001b[?25h"] +[7.312039663,"o","\u001b[?25l\u001b[17D\u001b[38;5;10m?\u001b[39m Topics: () demo, "] +[7.312046612,"o","\u001b[K\r\n\r\u001b["] +[7.312101081,"o","1A\u001b[18C\u001b[?25h"] +[7.392833508,"o","\u001b[?25l\u001b[18D\u001b[38;5;10m?\u001b[39m Topics: () demo,g \u001b[K\r\n\r\u001b["] +[7.392846529,"o","1A\u001b[19"] +[7.392848263,"o","C\u001b[?25h"] +[7.477813781,"o","\u001b[?25l\u001b[19D\u001b[38;5;10m?\u001b[39m"] +[7.477821985,"o"," Topics: () demo,gh \u001b[K\r"] +[7.477873128,"o","\n\r\u001b[1A\u001b[20C\u001b[?25h"] +[7.560894852,"o","\u001b[?25l\u001b[20D"] +[7.560900546,"o","\u001b["] +[7.56091123,"o","38;5;10m"] +[7.5609778819999995,"o","?\u001b[39m Topics: () demo,ghs"] +[7.561009525,"o"," \u001b[K\r\n\r"] +[7.561097953,"o","\u001b[1A\u001b[21C\u001b[?25h"] +[7.639100437,"o","\u001b[?25l\u001b[21D\u001b[38;5;10m?\u001b[39m Topics: "] +[7.639105722,"o","() demo,ghsc \u001b[K\r"] +[7.639173957,"o","\n\r\u001b[1A\u001b[22C\u001b[?25h"] +[7.724387241,"o","\u001b[?25l\u001b[22D\u001b[38;5;10m?\u001b[39m Topics: () demo,ghsca \u001b[K\r\n\r\u001b[1A\u001b[23C"] +[7.724403945,"o","\u001b[?25h"] +[7.81657358,"o","\u001b[?25l\u001b[23D\u001b[38;5;10m?\u001b[39m Topics: () demo,ghscaf \u001b[K\r\n\r\u001b[1A\u001b["] +[7.81657958,"o","24C\u001b[?25h"] +[7.904690074,"o","\u001b[?25l\u001b[24D\u001b[38;5;10m?\u001b[39"] +[7.904694146,"o","m"] +[7.904697084,"o"," Topics: () "] +[7.904785012,"o","demo,ghscaff \u001b[K\r\n\r\u001b[1A\u001b[25C\u001b[?25h"] +[7.982954053,"o","\u001b[?25l\u001b[25D\u001b[38;"] +[7.982959767,"o","5;10m?"] +[7.982970829,"o","\u001b[39m "] +[7.982984522,"o","Topics: () "] +[7.983001481,"o","demo,ghscaff,"] +[7.983053491,"o"," \u001b[K\r\n\r\u001b["] +[7.983112603,"o","1A\u001b[26C\u001b[?25h"] +[8.060208296,"o","\u001b[?25l\u001b[26D\u001b[38;5;10m?\u001b[39m Topics: () demo,ghscaff,t \u001b[K\r\n\r\u001b[1A\u001b[27C\u001b[?25h"] +[8.128520546,"o","\u001b[?25l\u001b[27D\u001b[38;5;10m?"] +[8.128525209,"o","\u001b[39m Topics:"] +[8.128579066,"o"," () demo,ghscaff,te \u001b[K\r\n\r\u001b["] +[8.12860475,"o","1A\u001b["] +[8.128620556,"o","28C\u001b[?25h"] +[8.221746576,"o","\u001b[?25l\u001b[28D\u001b[38;5;10m?\u001b[39m Topics: () demo,ghscaff,tem \u001b[K\r\n"] +[8.221751791,"o","\r"] +[8.221761015,"o","\u001b[1A\u001b[29C\u001b[?25h"] +[8.31700622,"o","\u001b[?25l\u001b[29D\u001b[38;"] +[8.317014536,"o","5;10m?\u001b[39m Topics: () demo,ghscaff,temp "] +[8.317032995,"o","\u001b[K\r\n\r\u001b[1A\u001b["] +[8.317081969,"o","30C\u001b[?25h"] +[8.519637538,"o","\u001b[?25l\u001b[30D\u001b[38;5;10m>\u001b[39m Topics: \u001b[38;5;14mdemo,ghscaff,temp\u001b[39m\u001b[K"] +[8.519642436,"o","\r\n\u001b[2K"] +[8.519694864,"o","\r\n\u001b[?25h\u001b[1A\u001b[?25h\u001b[?2004l"] +[8.519698884,"o","\u001b[?2004h"] +[8.519798771,"o","\u001b[?25l\u001b[38;5;10m?\u001b[39m Visibility: \r\n\u001b[38;5;14m>\u001b[39m \u001b[38;5;14"] +[8.519801588,"o","mPublic\u001b["] +[8.519860393,"o","39m\r\n\u001b[38;5;14m \u001b[39m Private\r\n\u001b[38;5;14m[\u001b[39m\u001b[38;5;14m↑↓ to move, enter to select, type to filter\u001b[39m\u001b[38;5;14m]\u001b[39m\r"] +[8.519903515,"o","\u001b[3A\u001b[14C\u001b[?25h"] +[8.784530179,"o","\u001b[?25l\u001b[14D\u001b[38;5;10m>\u001b["] +[8.784551108,"o","39m Visibility: \u001b[38;"] +[8.784597608,"o","5;14mPublic\u001b[39m\u001b[K\r\n\u001b[2K\r\n\u001b[2K"] +[8.784600894,"o","\r\n\u001b[2K\r\n"] +[8.784602373,"o","\u001b[?25h"] +[8.784682184,"o","\u001b[3A\u001b[?25h\u001b[?2004l"] +[8.964983694,"o","\u001b[?2004h"] +[8.965070622,"o","\u001b[?25l\u001b[38;5;10m?\u001b[39m Owner: \r"] +[8.965074724,"o","\n"] +[8.965076735,"o","\u001b[38;"] +[8.965107153,"o","5;14m>\u001b["] +[8.965156738,"o","39m \u001b[38;5;14m"] +[8.96516181,"o","JheisonMB\u001b[39m\r"] +[8.96520159,"o","\n\u001b[38;5;14m \u001b[39m"] +[8.965218325,"o"," UniverLab\r"] +[8.965231722,"o","\n"] +[8.965290574,"o","\u001b[38;5;14m[\u001b[39m\u001b[38;5;14m"] +[8.965317084,"o","↑↓ to move, enter to select, type to filter\u001b["] +[8.965320146,"o","39"] +[8.965322309,"o","m"] +[8.965332401,"o","\u001b[38;5;14"] +[8.965334727,"o","m"] +[8.965394522,"o","]\u001b[39m\r\u001b[3A"] +[8.965397226,"o","\u001b[9C"] +[8.965437604,"o","\u001b[?25h"] +[9.050634939,"o","\u001b[?25l\u001b[9D\u001b[38;5;10m>\u001b[39m Owner: \u001b[38;5;14mJheisonMB\u001b[39m\u001b[K\r\n"] +[9.050640612,"o","\u001b[2K\r\n\u001b[2K\r"] +[9.050659357,"o","\n"] +[9.050677663,"o","\u001b[2K\r\n\u001b[?25h"] +[9.050740815,"o","\u001b[3A\u001b[?25h\u001b[?2004l"] +[9.248432891,"o","\u001b[?2004h"] +[9.24851288,"o","\u001b[?25l\u001b[38;5;10m?\u001b[39m Template: \r"] +[9.248517125,"o","\n\u001b[38;"] +[9.248520543,"o","5;14"] +[9.248521829,"o","m"] +[9.248541747,"o",">\u001b[39m "] +[9.248597108,"o","\u001b[38;5;14"] +[9.248619792,"o","mpython-fastapi"] +[9.248621353,"o","\u001b[39"] +[9.248636353,"o","m\r\n\u001b[38;"] +[9.248660465,"o","5;14"] +[9.248691516,"o","m \u001b[39"] +[9.248712301,"o","m"] +[9.248725658,"o"," python-module"] +[9.24872726,"o","\r\n"] +[9.248764403,"o","\u001b[38;5;14m \u001b[39m "] +[9.248789056,"o","rust\r"] +[9.248847688,"o","\n\u001b[38;5;14m "] +[9.248871637,"o","\u001b[39"] +[9.248883014,"o","m "] +[9.248930932,"o","none\r\n\u001b[38;5;14"] +[9.248958391,"o","m["] +[9.248960126,"o","\u001b["] +[9.248992575,"o","39m\u001b[38;5;14"] +[9.248995075,"o","mDrives .gitignore, CI workflow, and boilerplate. 'none' = empty repo"] +[9.249019197,"o","\u001b["] +[9.249022677,"o","39m"] +[9.249044656,"o","\u001b[38;5;14"] +[9.249057217,"o","m"] +[9.249059197,"o","]"] +[9.24906585,"o","\u001b["] +[9.249068289,"o","39"] +[9.249111978,"o","m\r\u001b["] +[9.249129069,"o","5A\u001b[12"] +[9.249175625,"o","C\u001b[?25h"] +[10.683396737,"o","\u001b[?25l\u001b[12D\r\n\u001b[38;"] +[10.683401656000001,"o","5;14m \u001b[39m python-fastapi\u001b[K\r\n"] +[10.683461972,"o","\u001b[38;5;14m>\u001b[39m \u001b[38;5;14m"] +[10.683497416,"o","python-module\u001b[39m\u001b[K\r\n\r"] +[10.683519481,"o","\n\r\n"] +[10.68352094,"o","\r"] +[10.683529779,"o","\u001b[5A"] +[10.683531156,"o","\u001b["] +[10.683532228,"o","12"] +[10.683569044,"o","C\u001b[?25h"] +[10.959525426,"o","\u001b[?25l\u001b[12D\r"] +[10.959536009,"o","\n\r\n\u001b[38;"] +[10.959607985,"o","5;14m \u001b[39m python-module\u001b[K\r\n\u001b[38;5;14m>\u001b["] +[10.959628968,"o","39m \u001b[38;5;14m"] +[10.95964098,"o","rust\u001b["] +[10.959653747,"o","39m"] +[10.959670821,"o","\u001b[K"] +[10.959709134,"o","\r\n\r\n\r\u001b["] +[10.959712226,"o","5"] +[10.959716135,"o","A\u001b["] +[10.959719778,"o","12C\u001b[?25h"] +[11.225684247,"o","\u001b[?25l\u001b[12D\u001b[38;5;10m>"] +[11.225689278,"o","\u001b[39m Template: \u001b[38;5;14m"] +[11.22569879,"o","rust\u001b[39m\u001b[K\r\n"] +[11.225744139,"o","\u001b[2K\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[?25h"] +[11.225748007,"o","\u001b[5A"] +[11.225749507,"o","\u001b[?25h\u001b[?2004l"] +[11.225817641,"o","\u001b[?2004h\u001b[?25l"] +[11.225870302,"o","\u001b[38;5;10m?\u001b[39m "] +[11.225927225,"o","Default branch: (main) \r\u001b[25C\u001b[?25h"] +[11.491051802,"o","\u001b[?25l\u001b[25D\u001b[38;5;10m>\u001b[39m Default branch:"] +[11.491057579,"o"," \u001b[38;5;14mmain"] +[11.491102515,"o","\u001b[39m\u001b[K\r\n\u001b[?25h"] +[11.491155105,"o","\u001b[?25h\u001b[?2004l\u001b[?2004h"] +[11.491164117,"o","\u001b[?25l\u001b["] +[11.49119602,"o","38;5;10m?\u001b[39m Create develop branch? (Y/n) "] +[11.491210236,"o"," "] +[11.491212022,"o","\r"] +[11.491222085,"o","\u001b[31C\u001b[?25h"] +[13.873328005,"o","\u001b[?25l\u001b[31D\u001b[38;5;10m?\u001b["] +[13.873335118,"o","39m Create develop branch? (Y/n) "] +[13.873337323,"o","y"] +[13.873339364,"o"," "] +[13.873341007,"o","\u001b[K"] +[13.873359765,"o","\r\u001b[32C"] +[13.873384289,"o","\u001b[?25h"] +[14.077622024,"o","\u001b[?25l\u001b[32D\u001b[38;5;10m>\u001b[39m "] +[14.077632036,"o","Create develop branch? \u001b[38;5;14mYes\u001b["] +[14.077724091,"o","39m\u001b[K\r\n\u001b[?25h\u001b[?25h\u001b[?2004l\u001b[?2004h"] +[14.077794255,"o","\u001b[?25l\u001b[38;5;10m?\u001b[39m Features: \r\n\u001b[38;5;14m>\u001b[39m \u001b["] +[14.077798348,"o","38;"] +[14.077801389,"o","5;14m[x]"] +[14.07780493,"o","\u001b[39"] +[14.077814411,"o","m \u001b[38;5;14"] +[14.077895511,"o","mLICENSE\u001b[39m\r\n\u001b[38;5;14m \u001b[39m \u001b[38;"] +[14.077947397,"o","5;10m[x]\u001b[39m Standard labels\r\n\u001b[38;5;14m["] +[14.078005799,"o","\u001b[39m\u001b[38;5;14mspace select enter confirm\u001b[39m\u001b[38;5;14m]\u001b[39m\r\u001b[3A\u001b[12"] +[14.078009208,"o","C\u001b[?25h"] +[14.343049506,"o","\u001b[?25l\u001b[12"] +[14.343057273,"o","D\u001b["] +[14.343058814,"o","38;5;10"] +[14.343076551,"o","m>\u001b["] +[14.343091482,"o","39m"] +[14.343093431,"o"," "] +[14.343101974,"o","Features: "] +[14.343105648,"o","\u001b[38;5;14"] +[14.343165371,"o","mLICENSE, Standard labels\u001b[39m\u001b[K\r\n\u001b[2K\r\n"] +[14.343224381,"o","\u001b[2K"] +[14.343321876,"o","\r\n\u001b[2K\r\n\u001b[?25h"] +[14.343548718,"o","\u001b[3A\u001b[?25h\u001b[?2004l\u001b[?2004h"] +[14.343789317,"o","\u001b[?25l\u001b[38;5;10m?"] +[14.343803136,"o","\u001b[39m"] +[14.343848929,"o"," License: \r\n\u001b["] +[14.34385193,"o","38;5;14"] +[14.343898264,"o","m>\u001b[39m "] +[14.343900968,"o","\u001b[38;5;14m"] +[14.343959324,"o","MIT\u001b[39m\r\n\u001b[38;5;14m \u001b[39m Apache-2.0\r\n\u001b[38;5;14m"] +[14.34397051,"o"," \u001b[39m"] +[14.34397847,"o"," "] +[14.343995697,"o","GPL-3.0"] +[14.344024937,"o","\r\n\u001b[38;5;14m \u001b[39m"] +[14.344048175,"o"," None\r\n\u001b["] +[14.344049614,"o","38;"] +[14.344054829,"o","5;14m["] +[14.344076394,"o","\u001b[39m"] +[14.344095162,"o","\u001b[38;5;14m"] +[14.344132633,"o","↑↓ to move, enter to select, type to filter\u001b[39m"] +[14.344135664,"o","\u001b["] +[14.344145676,"o","38;5;14m]\u001b["] +[14.34414739,"o","39"] +[14.344158014,"o","m\r\u001b[5"] +[14.344172139,"o","A\u001b["] +[14.344173486,"o","11"] +[14.344178222,"o","C\u001b[?25h"] +[14.6084604,"o","\u001b[?25l\u001b[11D"] +[14.608486261,"o","\u001b[38;5;10m>\u001b[39"] +[14.608488466,"o","m "] +[14.608500202,"o","License: \u001b[38;5;14"] +[14.608548506,"o","mMIT\u001b[39m\u001b[K\r\n\u001b[2K\r"] +[14.608551741,"o","\n\u001b[2K"] +[14.608607229,"o","\r\n\u001b[2K\r\n\u001b[2K\r\n\u001b[2K"] +[14.608626528,"o","\r\n\u001b[?25h"] +[14.608679241,"o","\u001b[5A\u001b[?25h"] +[14.608705122,"o","\u001b[?2004l"] +[14.60878785,"o","\r\n\u001b[?2004h"] +[14.608849452,"o","\u001b[?25l\u001b[38;5;10m?\u001b[39m Apply these changes? (Y/n) "] +[14.608854013,"o","\r\u001b[29"] +[14.608868536,"o","C\u001b[?25h"] +[16.862144717,"o","\u001b[?25l\u001b[29D\u001b[38;5;10m?\u001b[39m Apply these changes? (Y/n) y "] +[16.862155443,"o","\u001b[K\r\u001b[30C"] +[16.862230379,"o","\u001b[?25h"] +[17.066339728,"o","\u001b[?25l\u001b[30D\u001b[38;5;10m>\u001b[39m Apply these changes? \u001b[38;5;14mYes\u001b[39m\u001b[K\r\n\u001b[?25h"] +[17.066344729,"o","\u001b[?25h\u001b[?2004l"] +[17.066482679,"o","\r\n"] +[17.837088087,"o"," Fetching boilerplate template... ok\r\n"] +[21.711146154,"o"," [1/8] create repo JheisonMB/demo-ghscaff... ok (https://github.com/JheisonMB/demo-ghscaff)\r\n"] +[31.318549185,"o"," [2/8] init repository... ok\r\n"] +[31.7594222,"o"," [3/8] create develop branch... ok\r\n"] +[32.264673496,"o"," [4/8] apply branch protection (main)... ok\r\n"] +[32.709106556,"o"," [5/8] apply branch protection (develop)... ok\r\n"] +[36.817704491,"o"," [6/8] sync labels... ok\r\n"] +[37.229547993,"o"," [7/8] set topics... ok\r\n"] +[37.229649008,"o","\u001b[?2004h\u001b[?25l\u001b[38;5;10m?\u001b[39m Secret CARGO_REGISTRY_TOKEN: \r"] +[37.229718763,"o","\n\u001b[38;5;14m[\u001b[39m\u001b[38;5;14mAPI token for publishing to crates.io (get it at https://crates.io/me)\u001b[39m\u001b[38;5;14m]\u001b[39m\r\u001b[?25h"] +[38.443317156,"o","\u001b[?25l\u001b[1A\u001b[38;5;10m?\u001b[39m Secret CARGO_REGISTRY_TOKEN: \u001b[38;5;1m\u001b[39"] +[38.443325911,"o","m\u001b[K\r\n\u001b[2K\r\n"] +[38.443371364,"o","\u001b[?25h\u001b[1A\u001b[?25h\u001b[?2004l"] +[38.443787157,"o"," ⚠ Secret CARGO_REGISTRY_TOKEN not configured — re-run `ghscaff apply` to set it later\r\n\r\n Done — https://github.com/JheisonMB/demo-ghscaff\r\n\r\n\u001b[?2004h\u001b[?25l\u001b[38;5;10m?\u001b[39m Clone the repository with gitkit? (Y/n) \r\u001b[42C\u001b[?25h"] +[38.719471489,"o","\u001b[?25l\u001b[42D\u001b[38;5;10m"] +[38.719488427,"o","?\u001b[39"] +[38.719490866,"o","m"] +[38.719521611,"o"," Clone the repository with gitkit? (Y/n) n "] +[38.719572448,"o","\u001b[K\r\u001b[43C\u001b[?25h"] +[38.923408071,"o","\u001b[?25l\u001b[43D\u001b[38;5;10m>\u001b[39m Clone the repository with gitkit? \u001b[38;5;14mNo\u001b[39"] +[38.923413071,"o","m\u001b[K\r"] +[38.923444866,"o","\n\u001b[?25h"] +[38.923495938,"o","\u001b[?25h\u001b[?2004l"] +[38.923499193,"o","\u001b[?2004h"] +[38.923582294,"o","\u001b[?25l\u001b[38;5;10m?\u001b[39m "] +[38.923607773,"o","Enable the Sponsor button for this repository? (y/N)"] +[38.923638232,"o"," \r\u001b[55C"] +[38.923660079,"o","\u001b[?25h"] +[39.072230401,"o","\u001b[?25l\u001b[55D\u001b[38;5;10m"] +[39.072241177,"o","?"] +[39.072462787,"o","\u001b[39m Enable the Sponsor button for this repository? (y/N) n \u001b[K\r\u001b[56C\u001b[?25h"] +[39.277600471,"o","\u001b[?25l\u001b[56D\u001b[38;5;10m>\u001b[39m Enable the Sponsor button for this repository? \u001b[38;5;14mNo\u001b[39"] +[39.277617145,"o","m\u001b[K"] +[39.277624114,"o","\r\n\u001b[?25h"] +[39.27770338,"o","\u001b[?25h\u001b[?2004l"] +[39.280458516,"o","\u001b]697;OSCUnlock=777bdb3a9a68487b8576acfff65d424c\u0007\u001b]697;Dir=/home/jheisonmblivecom\u0007\u001b]697;Shell=bash\u0007"] +[39.280462557,"o","\u001b]697;ShellPath=/usr/bin/bash\u0007\u001b]697;WSLDistro=Ubuntu\u0007"] +[39.280553056,"o","\u001b]697;PID=1202499\u0007\u001b]697;ExitCode=0\u0007\u001b]697;TTY=/dev/pts/9\u0007\u001b]697;Log=\u0007\u001b]697;User=jheisonmblivecom\u0007"] +[39.282927368,"o","\u001b]697;OSCLock=777bdb3a9a68487b8576acfff65d424c\u0007\u001b]697;PreExec\u0007"] +[39.623607421,"o","\u001b[?2004h\u001b]697;StartPrompt\u0007\u001b[1;32mghScaff@univerlab\u001b[0m:\u001b[1;34m~\u001b[0m$ \u001b]697;EndPrompt\u0007\u001b]697;NewCmd=777bdb3a9a68487b8576acfff65d424c\u0007"] diff --git a/demo/demo.toml b/demo/demo.toml index 0748156..a397a66 100644 --- a/demo/demo.toml +++ b/demo/demo.toml @@ -1,6 +1,8 @@ [demo] name = "demo" output_dir = "./dist" +speed = "2x" +targets = ["gif", "mp4"] prompt = '\[\e[1;32m\]ghScaff@univerlab\[\e[0m\]:\[\e[1;34m\]~\[\e[0m\]$ ' [typing] @@ -63,13 +65,11 @@ duration_ms = 200 action = "keypress" key = "enter" +# The vault has no passphrase, so ghscaff goes straight to the repository name. [[timeline]] -action = "wait" -duration_ms = 2500 - -[[timeline]] -action = "secret" -prompt = "Vault passphrase:" +action = "wait_for_screen" +match = "Repository name:" +timeout_ms = 30000 [[timeline]] action = "type" @@ -216,9 +216,27 @@ duration_ms = 200 action = "keypress" key = "enter" +# Applying is a run of silent API calls: wait for the prompts that follow it, +# never for quiet, or the take cuts away (and ends) mid-scaffold. +# The rust template asks for CARGO_REGISTRY_TOKEN; skip it with esc (ghscaff +# then says to set it later with `ghscaff apply`). +[[timeline]] +action = "wait_for_screen" +match = "Secret CARGO_REGISTRY_TOKEN" +timeout_ms = 180000 + +[[timeline]] +action = "wait" +duration_ms = 1200 + +[[timeline]] +action = "keypress" +key = "esc" + [[timeline]] -action = "wait_for_quiet" -quiet_ms = 500 +action = "wait_for_screen" +match = "Clone the repository with gitkit?" +timeout_ms = 60000 [[timeline]] action = "type" @@ -234,8 +252,26 @@ action = "keypress" key = "enter" [[timeline]] -action = "wait_for_quiet" -quiet_ms = 1500 +action = "wait_for_screen" +match = "Sponsor button" +timeout_ms = 30000 + +[[timeline]] +action = "type" +text = "n" +human_salt = true + +[[timeline]] +action = "wait" +duration_ms = 200 + +[[timeline]] +action = "keypress" +key = "enter" + +[[timeline]] +action = "wait" +duration_ms = 1500 [[timeline]] action = "focus" diff --git a/demo/dist/demo.gif b/demo/dist/demo.gif index edef349..8d02df7 100644 Binary files a/demo/dist/demo.gif and b/demo/dist/demo.gif differ diff --git a/docs/cli-reference.md b/docs/cli-reference.md index f7889ab..6118c8e 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -21,13 +21,14 @@ Running `ghscaff` with no command starts the creation wizard (same as | `ghscaff apply [owner/repo]` | Configure an existing repository (auto-detected from git remote if omitted) | | `ghscaff doctor [owner/repo]` | Verify that required status checks can be satisfied (auto-detected from git remote if omitted) | | `ghscaff config` | Reconfigure credentials — wipes the vault and starts fresh | +| `ghscaff update [--check] [--yes]` | Update to the latest stable release; always asks first. Exit codes: 0 = up to date (or installed / declined / cargo refusal), 1 = update available (`--check`), 2 = update check could not be completed (cause on stderr; also applies to plain `update`). `--dry-run` behaves like `--check`: it reports and never downloads or installs. | ## Global flags | Flag | Description | |---|---| | `--sponsor OWNER/REPO` | Enable GitHub Sponsor button on an existing repository | -| `--dry-run` | Preview changes without making any API calls | +| `--dry-run` | Preview changes without making any API calls (`update --dry-run` reports like `--check`) | | `--help` | Show help for any command | | `--version` | Show ghscaff version | @@ -37,6 +38,7 @@ Running `ghscaff` with no command starts the creation wizard (same as |---|---| | `GITHUB_TOKEN` | Token override — takes precedence over the vault (CI/CD friendly) | | `CARGO_REGISTRY_TOKEN` | Resolved as a template secret for the Rust template | +| `GHSCAFF_NO_UPDATE_CHECK=1` | Silence the startup update notice (does not affect `ghscaff update`) | ## Files diff --git a/docs/index.md b/docs/index.md index 9248db6..4bee6dc 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,7 +29,8 @@ existing repositories at any time. - **Doctor** — validate that required status checks can be satisfied. - **Language templates** — Rust today; Python, Node.js and Java coming. - **Template secrets** — required Actions secrets configured automatically. -- **Self-update** — replaces the running binary; handles `cargo install` specially. +- **Explicit self-update** — `ghscaff update` always asks; cargo installs + untouched; your `~/.ghscaff` state is never modified. ## How the documentation is organized diff --git a/docs/installation.md b/docs/installation.md index e96ab39..15e20dc 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -55,14 +55,35 @@ x86_64 are published on the ## Self-update -Ghscaff checks for new releases on startup. When a newer version is available, it prompts you to update. Choose "yes" to replace the running binary with the latest version. +Updating is always explicit — ghscaff never installs anything on its own: -If you installed ghscaff with `cargo install`, the auto-updater will refuse to touch the binary and instead direct you to run: ```bash -cargo install --force ghscaff +ghscaff update # asks "Update to 0.7.0? [y/N]" — default is NO +ghscaff update --yes # skip the prompt +ghscaff update --check # report only: exit 0 = up to date, 1 = update available, 2 = the check could not be completed +ghscaff update --dry-run # same as --check: report only, never downloads or installs ``` -You can disable update checks by setting the environment variable: +On "yes", ghscaff downloads the release asset for your platform, verifies it +against the release's `SHA256SUMS.txt` (skipped only when the release ships +no checksum file) and atomically replaces the running binary. On success it +prints `✓ updated to ` — no restart needed, the running binary is +replaced in place and the next invocation already runs the new one. Your +`~/.ghscaff` state — encrypted vault and boilerplate cache — is never touched. + +Exit codes: `ghscaff update` (with or without `--check`) exits **2** when the +release check cannot be completed — no network, DNS or TLS failure, HTTP ≥ 400, +or an unparsable release-list response — and prints the cause on stderr; other +failures (download, checksum, permissions) are ordinary errors (exit 1). +`--check` exits 1 when an update is available, 0 when the binary is current. +`--dry-run` behaves like `--check`: it reports and never downloads or installs. + +If you installed ghscaff with `cargo install`, `ghscaff update` refuses to +touch the binary and instead prints: +`installed with cargo — run: cargo install --force ghscaff` + +At startup ghscaff only prints a one-line notice when a newer release exists. +Disable that notice with: ```bash GHSCAFF_NO_UPDATE_CHECK=1 ghscaff ``` diff --git a/docs/templates-and-conventions.md b/docs/templates-and-conventions.md index 528e562..884c2a4 100644 --- a/docs/templates-and-conventions.md +++ b/docs/templates-and-conventions.md @@ -16,37 +16,60 @@ Each language template includes: - **.gitignore** — GitHub's official template for the language, followed by an agentic block (see below). - **.github/workflows/ci.yml** — CI workflow with basic checks. -- **.github/workflows/release.yml** — release workflow (runs on git tags). +- **.github/workflows/release.yml** — release workflow (runs on git tags; skipped when no license is chosen). - **LICENSE** — fetched from the API based on the license selected during the wizard. All files land in a single atomic `chore: init repository` commit. +When the wizard's license choice is **None**, the scaffold is marked +unpublishable: `Cargo.toml` gets `publish = false` instead of `license-file`, +the `rust-ci` job runs with `publish-check: false`, no `release.yml` is +committed, and `pyproject.toml` keeps no `license = …` line. + ### The agentic .gitignore block GitHub's gitignore templates predate AI coding agents, so none of them ignore the directories those agents write. Every scaffolded repository therefore gets -a second block appended after the official template, separated by a blank line -and a comment marking the boundary: +a second block appended after the official template, separated by a blank +line. The block is derived from the canopy agent registry +(), so a harness the registry +lists — opencode, codebuddy, devin, kilo… — reaches new repositories as soon +as it appears there, with no ghscaff release in between: ```gitignore -# AI coding agents — mirrors gitkit's `agentic` builtin (src/ignore/mod.rs) -.kiro/ -.cursor/ +# AI coding agents (paths derived from the canopy registry) +.agents/* +.claude/* +.opencode/* +... + +# instruction files stay committable +!.claude/CLAUDE.md ... ``` -Two properties worth knowing: +Three properties worth knowing: - **The official template is never modified** — it is preserved verbatim as the prefix, and the agentic block only ever follows it. - **A failed fetch does not lose the block.** If GitHub's template cannot be retrieved the wizard warns and continues with an empty prefix, so the repository is still born ignoring agent state rather than failing outright. - -The list is kept textually identical to gitkit's `agentic` builtin. ghscaff -carries its own copy rather than depending on gitkit, because a machine that -scaffolds a repository may not have gitkit installed — but the two must be -updated together. +- **The registry is best-effort.** ghscaff fetches it on every scaffold and + keeps the merged result in its own cache directory; when the fetch fails it + falls back to that cache (if younger than 24 h) and then to a bundled + snapshot, printing one warning line that names the source used. + Scaffolding never fails because of the registry. + +The block is built with gitkit's own rules: directory paths become `/*` +so the directory stays trackable, `.github` and `.config` are never ignored, +and instruction files (`AGENTS.md`, `CLAUDE.md`, each platform's own) are +negated under `# instruction files stay committable`. The header is the exact +line `gitkit ignore add agentic` writes, so running gitkit later in a +scaffolded repository recognises the block and replaces it instead of +appending a second one. ghscaff carries its own copy of the registry snapshot +rather than depending on gitkit, because a machine that scaffolds a repository +may not have gitkit installed. Available today: **Rust**. Python, Node.js and Java are planned. diff --git a/src/apply.rs b/src/apply.rs index 55bbdc6..da006fd 100644 --- a/src/apply.rs +++ b/src/apply.rs @@ -246,27 +246,49 @@ pub struct SyncResult { pub deleted: usize, } -/// Main apply mode orchestrator -pub fn run_apply(repo_arg: Option<&str>, dry_run: bool) -> Result<()> { - // Get token - let (token, passphrase) = crate::github::client::resolve_token()?; - let client = crate::github::client::GithubClient::new(&token); +/// Everything `run_apply` resolves once up front: the API client, the +/// owner/repo being updated, and the vault passphrase used for template secrets. +struct ApplyTarget { + client: GithubClient, + owner: String, + repo_name: String, + passphrase: String, +} - // Determine repo - let (owner, repo_name) = if let Some(repo) = repo_arg { - parse_owner_repo(repo)? - } else { - auto_detect_repo()? - }; +impl ApplyTarget { + /// Resolve the token, build the client and determine the target repo. + fn resolve(repo_arg: Option<&str>) -> Result { + let (token, passphrase) = crate::github::client::resolve_token()?; + let client = GithubClient::new(&token); + + let (owner, repo_name) = if let Some(repo) = repo_arg { + parse_owner_repo(repo)? + } else { + auto_detect_repo()? + }; - println!(" Checking existing repo... {}/{}", owner, repo_name); - let ctx = get_repo_state(&client, &owner, &repo_name)?; + Ok(Self { + client, + owner, + repo_name, + passphrase, + }) + } +} + +/// Print the "checking" line, fetch the repo state and show the summary. +fn print_apply_preview(target: &ApplyTarget) -> Result { + println!( + " Checking existing repo... {}/{}", + target.owner, target.repo_name + ); + let ctx = get_repo_state(&target.client, &target.owner, &target.repo_name)?; // Display summary println!(); println!(" Summary of changes:"); println!(" ◆ Labels: checking..."); - let label_result = sync_labels(&client, &owner, &repo_name, true)?; // dry check + let label_result = sync_labels(&target.client, &target.owner, &target.repo_name, true)?; // dry check if label_result.created > 0 || label_result.updated > 0 || label_result.deleted > 0 { println!( " • {} to create, {} to update, {} to delete, {} up to date", @@ -303,76 +325,98 @@ pub fn run_apply(repo_arg: Option<&str>, dry_run: bool) -> Result<()> { } }); - if dry_run { - println!(); - println!(" [dry-run] No changes applied."); - return Ok(()); - } - - println!(); - let mut selected_teams: Vec = vec![]; + Ok(ctx) +} +/// Ask whether to grant org teams access and, if so, collect the selected +/// teams with the permission picked for each of them. +fn collect_apply_team_access(client: &GithubClient, owner: &str) -> Result> { let want_teams = inquire::Confirm::new("Add team access?") .with_default(false) .prompt()?; + collect_team_access_after(client, owner, want_teams, |team_names| { + Ok(inquire::MultiSelect::new("Select teams:", team_names) + .with_help_message("space select enter confirm") + .prompt_skippable()?) + }) +} - if want_teams { - if let Ok(org_teams) = list_org_teams(&client, &owner) { - if !org_teams.is_empty() { - let team_names: Vec = org_teams.iter().map(|t| t.name.clone()).collect(); - - if let Ok(Some(selections)) = - inquire::MultiSelect::new("Select teams:", team_names.clone()) - .with_help_message("space select enter confirm") - .prompt_skippable() - { - for selected_team_display in selections { - if let Some(team) = - org_teams.iter().find(|t| t.name == selected_team_display) - { - let permission = inquire::Select::new( - &format!("Permission for {} team:", team.name), - vec!["pull", "triage", "push", "admin"], - ) - .prompt()?; - - selected_teams.push(teams::TeamAccess { - team_slug: team.slug.clone(), - permission: permission.to_string(), - }); - } - } - } - } - } +/// The prompt-free remainder of [`collect_apply_team_access`]: list the org +/// teams and collect the selection once the user already said yes. The +/// team-selection prompt is injected so tests can drive every branch +/// without a TTY. +fn collect_team_access_after( + client: &GithubClient, + owner: &str, + want_teams: bool, + prompt_selections: impl Fn(Vec) -> Result>>, +) -> Result> { + if !want_teams { + return Ok(vec![]); } - println!(); - let confirmed = inquire::Confirm::new("Apply these changes?") - .with_default(true) - .prompt()?; + let Ok(org_teams) = list_org_teams(client, owner) else { + return Ok(vec![]); + }; + if org_teams.is_empty() { + return Ok(vec![]); + } - if !confirmed { - println!(" Aborted."); - return Ok(()); + let team_names: Vec = org_teams.iter().map(|t| t.name.clone()).collect(); + let Some(selections) = prompt_selections(team_names)? else { + return Ok(vec![]); + }; + + select_team_access(&org_teams, &selections, prompt_apply_team_permission) +} + +/// Match each selected display name against the org team list and resolve +/// the permission for every match through `permission_for`. +fn select_team_access( + org_teams: &[teams::Team], + selections: &[String], + permission_for: impl Fn(&teams::Team) -> Result, +) -> Result> { + let mut selected_teams = vec![]; + for selected_team_display in selections { + let Some(team) = org_teams.iter().find(|t| t.name == *selected_team_display) else { + continue; + }; + selected_teams.push(permission_for(team)?); } + Ok(selected_teams) +} - // Apply all changes - println!(); - println!(" Applying changes..."); +/// Ask which permission to grant one selected team. +fn prompt_apply_team_permission(team: &teams::Team) -> Result { + let permission = inquire::Select::new( + &format!("Permission for {} team:", team.name), + vec!["pull", "triage", "push", "admin"], + ) + .prompt()?; + + Ok(teams::TeamAccess { + team_slug: team.slug.clone(), + permission: permission.to_string(), + }) +} - // 1. Labels - sync_labels(&client, &owner, &repo_name, false)?; +/// Step 1 — sync the standard label set. +fn apply_labels_step(target: &ApplyTarget) -> Result<()> { + sync_labels(&target.client, &target.owner, &target.repo_name, false)?; println!(" ✓ Labels synced"); + Ok(()) +} - // 2. Branch protection (always apply to ensure correct config) — required - // contexts are derived from the repo's live workflow files, never hardcoded, - // so a renamed job can't leave a required check pointing at a name nothing - // will ever report. +/// Step 2 — branch protection (always applied to ensure the correct config). +/// Required contexts are derived from the repo's live workflow files, never +/// hardcoded, so a renamed job can't leave a required check pointing at a +/// name nothing will ever report. +fn apply_branch_protection_step(target: &ApplyTarget) -> Result<()> { let workflow_files = crate::github::contents::fetch_workflow_sources( - &client, - &owner, - &repo_name, + &target.client, + &target.owner, + &target.repo_name, &[".github/workflows/ci.yml"], ); let workflow_sources: Vec = workflow_files @@ -381,9 +425,9 @@ pub fn run_apply(repo_arg: Option<&str>, dry_run: bool) -> Result<()> { .collect(); let required_contexts = crate::checks::derive_required_contexts(&workflow_sources); match crate::github::branches::apply_branch_protection( - &client, - &owner, - &repo_name, + &target.client, + &target.owner, + &target.repo_name, "main", &required_contexts, ) { @@ -400,15 +444,28 @@ pub fn run_apply(repo_arg: Option<&str>, dry_run: bool) -> Result<()> { } } } + Ok(()) +} - // 3. Develop branch (if needed) - if !ctx.has_develop { - create_develop_branch(&client, &owner, &repo_name)?; - println!(" ✓ develop branch created"); +/// Step 3 — develop branch, when it doesn't exist yet. +fn apply_develop_branch_step(target: &ApplyTarget, ctx: &ApplyContext) -> Result<()> { + if ctx.has_develop { + return Ok(()); } + create_develop_branch(&target.client, &target.owner, &target.repo_name)?; + println!(" ✓ develop branch created"); + Ok(()) +} - // 4. Merge topics - match merge_topics(&client, &owner, &repo_name, &["github", "scaffold"], false) { +/// Step 4 — merge the scaffold topics into the repo's topic list. +fn apply_topics_step(target: &ApplyTarget) -> Result<()> { + match merge_topics( + &target.client, + &target.owner, + &target.repo_name, + &["github", "scaffold"], + false, + ) { Ok(true) => println!(" ✓ Topics updated"), Ok(false) => {} Err(e) => { @@ -420,13 +477,19 @@ pub fn run_apply(repo_arg: Option<&str>, dry_run: bool) -> Result<()> { } } } + Ok(()) +} - // 5. Team access - for team in &selected_teams { +/// Step 5 — grant the teams selected earlier access to the repo. +fn apply_team_access_step( + target: &ApplyTarget, + selected_teams: &[teams::TeamAccess], +) -> Result<()> { + for team in selected_teams { match add_team_to_repo( - &client, - &owner, - &repo_name, + &target.client, + &target.owner, + &target.repo_name, &team.team_slug, &team.permission, false, @@ -448,38 +511,105 @@ pub fn run_apply(repo_arg: Option<&str>, dry_run: bool) -> Result<()> { } } } + Ok(()) +} + +/// Step 6 — secrets from the template (detected from the repo's marker +/// files), skipping the ones the repo already has. +fn apply_missing_secrets_step(target: &ApplyTarget) -> Result<()> { + let secret_specs = detect_template_secrets(&target.client, &target.owner, &target.repo_name); + if secret_specs.is_empty() { + return Ok(()); + } - // 6. Secrets from template (detected from the repo's marker files) - let secret_specs = detect_template_secrets(&client, &owner, &repo_name); - if !secret_specs.is_empty() { - let existing = secrets::list_secret_names(&client, &owner, &repo_name).unwrap_or_default(); - let missing: Vec<_> = secret_specs - .iter() - .filter(|s| !existing.iter().any(|e| e == &s.name)) - .collect(); - if !missing.is_empty() { - println!(); - println!(" ◆ Secrets required by template:"); - for spec in &missing { - println!(" • {} — {}", spec.name, spec.description); - } - println!(); - for spec in missing { - let value = - if let Some(val) = crate::vault::resolve_secret(&spec.name, &passphrase)? { - Some(val) - } else { - crate::wizard::prompt_secret_value(spec, &passphrase)? - }; - if let Some(val) = value { - match secrets::set_secret(&client, &owner, &repo_name, &spec.name, &val) { - Ok(()) => println!(" ✓ Secret {} configured", spec.name), - Err(e) => println!(" ⚠ Failed to set {}: {e:#}", spec.name), - } - } + let existing = secrets::list_secret_names(&target.client, &target.owner, &target.repo_name) + .unwrap_or_default(); + let missing: Vec<_> = secret_specs + .iter() + .filter(|s| !existing.iter().any(|e| e == &s.name)) + .collect(); + if missing.is_empty() { + return Ok(()); + } + + println!(); + println!(" ◆ Secrets required by template:"); + for spec in &missing { + println!(" • {} — {}", spec.name, spec.description); + } + println!(); + for spec in missing { + let value = if let Some(val) = crate::vault::resolve_secret(&spec.name, &target.passphrase)? + { + Some(val) + } else { + crate::wizard::prompt_secret_value(spec, &target.passphrase)? + }; + if let Some(val) = value { + match secrets::set_secret( + &target.client, + &target.owner, + &target.repo_name, + &spec.name, + &val, + ) { + Ok(()) => println!(" ✓ Secret {} configured", spec.name), + Err(e) => println!(" ⚠ Failed to set {}: {e:#}", spec.name), } } } + Ok(()) +} + +/// Main apply mode orchestrator +pub fn run_apply(repo_arg: Option<&str>, dry_run: bool) -> Result<()> { + let target = ApplyTarget::resolve(repo_arg)?; + let ctx = print_apply_preview(&target)?; + apply_changes(&target, &ctx, dry_run) +} + +/// The prompt-free remainder of [`run_apply`] after the preview: honour +/// `dry_run`, then collect the team selection and the final confirmation. +fn apply_changes(target: &ApplyTarget, ctx: &ApplyContext, dry_run: bool) -> Result<()> { + if dry_run { + println!(); + println!(" [dry-run] No changes applied."); + return Ok(()); + } + + println!(); + let selected_teams = collect_apply_team_access(&target.client, &target.owner)?; + + println!(); + let confirmed = inquire::Confirm::new("Apply these changes?") + .with_default(true) + .prompt()?; + + apply_confirmed(target, ctx, &selected_teams, confirmed) +} + +/// Run every apply step once the user confirmed; a declined confirmation +/// aborts before any request is made. +fn apply_confirmed( + target: &ApplyTarget, + ctx: &ApplyContext, + selected_teams: &[teams::TeamAccess], + confirmed: bool, +) -> Result<()> { + if !confirmed { + println!(" Aborted."); + return Ok(()); + } + + // Apply all changes + println!(); + println!(" Applying changes..."); + apply_labels_step(target)?; + apply_branch_protection_step(target)?; + apply_develop_branch_step(target, ctx)?; + apply_topics_step(target)?; + apply_team_access_step(target, selected_teams)?; + apply_missing_secrets_step(target)?; println!(); println!(" Done!"); @@ -521,6 +651,9 @@ fn create_develop_branch(client: &GithubClient, owner: &str, repo_name: &str) -> Ok(()) } +#[cfg(test)] +mod step_tests; + #[cfg(test)] mod tests { use super::*; @@ -1492,7 +1625,9 @@ mod tests { #[test] fn detect_template_secrets_finds_rust_markers() { + use super::super::github::test_utils::env_lock; use base64::{engine::general_purpose::STANDARD, Engine}; + let _guard = env_lock(); let cargo_encoded = STANDARD.encode(b"[package]\nname = \"test\""); diff --git a/src/apply/step_tests.rs b/src/apply/step_tests.rs new file mode 100644 index 0000000..d9216bc --- /dev/null +++ b/src/apply/step_tests.rs @@ -0,0 +1,637 @@ +//! Step-function tests for `super` (see `src/apply.rs`). +//! +//! Every `*_step` helper is driven against the recording mock server and +//! asserts the exact `(method, path)` pairs it issues on the success path, +//! plus the error/guard branches. Prompt-touching wrappers are covered +//! through their prompt-free cores (`collect_team_access_after`, +//! `select_team_access`, `apply_changes`, `apply_confirmed`): under a +//! non-TTY stdin every `inquire` prompt returns `Err`, so run the suite as +//! `cargo test >>; + +fn target_for(url: &str) -> ApplyTarget { + ApplyTarget { + client: mock_client(url), + owner: "owner".into(), + repo_name: "repo".into(), + passphrase: String::new(), + } +} + +fn ctx_without_develop() -> ApplyContext { + ApplyContext { + owner: "owner".into(), + repo: "repo".into(), + current_labels: vec![], + has_develop: false, + branch_protection_enabled: false, + has_ci_workflow: false, + current_topics: vec![], + } +} + +fn snapshot(log: &CallLog) -> Vec<(String, String)> { + log.lock().unwrap().clone() +} + +fn has_call(log: &CallLog, method: &str, path_part: &str) -> bool { + snapshot(log) + .iter() + .any(|(m, p)| m == method && p.contains(path_part)) +} + +fn count_calls(log: &CallLog, method: &str, path_part: &str) -> usize { + snapshot(log) + .iter() + .filter(|(m, p)| m == method && p.contains(path_part)) + .count() +} + +fn has_put(log: &CallLog) -> bool { + snapshot(log).iter().any(|(m, _)| m == "PUT") +} + +fn not_found() -> (u16, String) { + (404, r#"{"message":"Not Found"}"#.to_string()) +} + +// ── apply_labels_step ───────────────────────────────────────────── + +#[test] +fn apply_labels_step_creates_standard_labels() { + let (url, log) = start_recording_mock_server(|method, path| { + if method == "GET" && path.contains("/labels?per_page=100") { + (200, "[]".to_string()) + } else if method == "POST" && path.ends_with("/labels") { + ( + 201, + r#"{"name":"x","color":"000000","description":"x"}"#.to_string(), + ) + } else { + not_found() + } + }); + let target = target_for(&url); + apply_labels_step(&target).unwrap(); + assert!(has_call( + &log, + "GET", + "/repos/owner/repo/labels?per_page=100" + )); + assert_eq!(count_calls(&log, "POST", "/repos/owner/repo/labels"), 7); +} + +#[test] +fn apply_labels_step_propagates_list_error() { + let (url, log) = + start_recording_mock_server(|_, _| (500, r#"{"message":"Server Error"}"#.to_string())); + let target = target_for(&url); + assert!(apply_labels_step(&target).is_err()); + assert!(has_call(&log, "GET", "/repos/owner/repo/labels")); +} + +// ── apply_branch_protection_step ────────────────────────────────── + +fn branch_protection_handler(put_status: u16) -> impl Fn(&str, &str) -> (u16, String) { + move |method, path| { + if method == "GET" && path.contains("/git/ref/heads/main") { + ( + 200, + r#"{"ref":"refs/heads/main","object":{"sha":"abc123"}}"#.to_string(), + ) + } else if method == "PUT" && path.contains("/branches/main/protection") { + (put_status, "{}".to_string()) + } else { + not_found() + } + } +} + +#[test] +fn apply_branch_protection_step_puts_protection() { + let (url, log) = start_recording_mock_server(branch_protection_handler(200)); + let target = target_for(&url); + apply_branch_protection_step(&target).unwrap(); + assert!(has_call( + &log, + "GET", + "/repos/owner/repo/git/ref/heads/main" + )); + assert!(has_call( + &log, + "PUT", + "/repos/owner/repo/branches/main/protection" + )); +} + +#[test] +fn apply_branch_protection_step_survives_error() { + let (url, log) = start_recording_mock_server(branch_protection_handler(500)); + let target = target_for(&url); + apply_branch_protection_step(&target).unwrap(); + assert!(has_call( + &log, + "PUT", + "/repos/owner/repo/branches/main/protection" + )); +} + +// ── apply_develop_branch_step ───────────────────────────────────── + +#[test] +fn apply_develop_branch_step_skips_when_present() { + let (url, log) = start_recording_mock_server(|_, path| { + panic!("no requests expected when develop exists, got: {path}") + }); + let target = target_for(&url); + let ctx = ApplyContext { + has_develop: true, + ..ctx_without_develop() + }; + apply_develop_branch_step(&target, &ctx).unwrap(); + assert!(snapshot(&log).is_empty()); +} + +#[test] +fn apply_develop_branch_step_creates_when_missing() { + let (url, log) = start_recording_mock_server(|method, path| { + if method == "GET" && path.contains("/git/ref/heads/main") { + ( + 200, + r#"{"ref":"refs/heads/main","object":{"sha":"abc123"}}"#.to_string(), + ) + } else if method == "POST" && path.contains("/git/refs") { + (201, r#"{"ref":"refs/heads/develop"}"#.to_string()) + } else { + not_found() + } + }); + let target = target_for(&url); + apply_develop_branch_step(&target, &ctx_without_develop()).unwrap(); + assert!(has_call( + &log, + "GET", + "/repos/owner/repo/git/ref/heads/main" + )); + assert!(has_call(&log, "POST", "/repos/owner/repo/git/refs")); +} + +#[test] +fn apply_develop_branch_step_propagates_error() { + let (url, _) = + start_recording_mock_server(|_, _| (500, r#"{"message":"Server Error"}"#.to_string())); + let target = target_for(&url); + assert!(apply_develop_branch_step(&target, &ctx_without_develop()).is_err()); +} + +// ── apply_topics_step ───────────────────────────────────────────── + +#[test] +fn apply_topics_step_updates_topics() { + let (url, log) = start_recording_mock_server(|method, path| { + if method == "GET" && path == "/repos/owner/repo" { + ( + 200, + r#"{"full_name":"owner/repo","html_url":"https://github.com/owner/repo","default_branch":"main","topics":[]}"#.to_string(), + ) + } else if method == "PUT" && path.contains("/topics") { + (200, r#"{"names":["github","scaffold"]}"#.to_string()) + } else { + not_found() + } + }); + let target = target_for(&url); + apply_topics_step(&target).unwrap(); + assert!(has_call(&log, "GET", "/repos/owner/repo")); + assert!(has_call(&log, "PUT", "/repos/owner/repo/topics")); +} + +#[test] +fn apply_topics_step_survives_error() { + let (url, log) = start_recording_mock_server(|method, path| { + if method == "GET" && path == "/repos/owner/repo" { + ( + 200, + r#"{"full_name":"owner/repo","html_url":"https://github.com/owner/repo","default_branch":"main","topics":[]}"#.to_string(), + ) + } else { + (500, r#"{"message":"Server Error"}"#.to_string()) + } + }); + let target = target_for(&url); + apply_topics_step(&target).unwrap(); + assert!(has_call(&log, "PUT", "/repos/owner/repo/topics")); +} + +// ── apply_team_access_step ──────────────────────────────────────── + +fn team_access(teams: &[(&str, &str)]) -> Vec { + teams + .iter() + .map(|(slug, permission)| teams::TeamAccess { + team_slug: slug.to_string(), + permission: permission.to_string(), + }) + .collect() +} + +#[test] +fn apply_team_access_step_adds_each_team() { + let (url, log) = start_recording_mock_server(|method, path| { + if method == "PUT" && path.contains("/teams/") { + (204, String::new()) + } else { + not_found() + } + }); + let target = target_for(&url); + apply_team_access_step( + &target, + &team_access(&[("backend", "push"), ("devops", "pull")]), + ) + .unwrap(); + assert!(has_call( + &log, + "PUT", + "/orgs/owner/teams/backend/repos/owner/repo" + )); + assert!(has_call( + &log, + "PUT", + "/orgs/owner/teams/devops/repos/owner/repo" + )); +} + +#[test] +fn apply_team_access_step_survives_error() { + let (url, log) = + start_recording_mock_server(|_, _| (500, r#"{"message":"Server Error"}"#.to_string())); + let target = target_for(&url); + apply_team_access_step(&target, &team_access(&[("backend", "push")])).unwrap(); + assert!(has_call( + &log, + "PUT", + "/orgs/owner/teams/backend/repos/owner/repo" + )); +} + +#[test] +fn apply_team_access_step_no_teams_no_requests() { + let (url, log) = start_recording_mock_server(|_, path| { + panic!("no requests expected without teams, got: {path}") + }); + let target = target_for(&url); + apply_team_access_step(&target, &[]).unwrap(); + assert!(snapshot(&log).is_empty()); +} + +// ── apply_missing_secrets_step ──────────────────────────────────── + +const STEP_SECRET: &str = "GHSCAFF_STEP_TEST_SECRET"; + +fn rust_secrets_toml() -> String { + format!( + "[[secrets]]\nname = \"{STEP_SECRET}\"\ndescription = \"step test secret\"\nrequired = false\n" + ) +} + +/// Point HOME at a temp dir holding `boilerplate/rust/secrets.toml`. +/// Returns the tempdir (keep alive for the test) and the previous HOME. +fn isolate_home_with_rust_secrets() -> (tempfile::TempDir, Option) { + let home = tempfile::tempdir().unwrap(); + let dir = home + .path() + .join(".ghscaff") + .join("boilerplate") + .join("rust"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("secrets.toml"), rust_secrets_toml()).unwrap(); + let prev = std::env::var_os("HOME"); + std::env::set_var("HOME", home.path()); + (home, prev) +} + +fn restore_home(prev: Option) { + match prev { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } +} + +fn set_step_secret_env() -> Option { + let prev = std::env::var_os(STEP_SECRET); + std::env::set_var(STEP_SECRET, "step-test-value"); + prev +} + +fn restore_step_secret_env(prev: Option) { + match prev { + Some(value) => std::env::set_var(STEP_SECRET, value), + None => std::env::remove_var(STEP_SECRET), + } +} + +fn public_key_json() -> String { + use base64::{engine::general_purpose::STANDARD, Engine}; + let key = STANDARD.encode([9u8; 32]); + format!(r#"{{"key_id":"step-test-key","key":"{key}"}}"#) +} + +fn secrets_handler(secrets_list: String) -> impl Fn(&str, &str) -> (u16, String) { + use base64::{engine::general_purpose::STANDARD, Engine}; + let cargo_encoded = STANDARD.encode(b"[package]\nname = \"test\""); + let public_key = public_key_json(); + move |method, path| { + if path.contains("/contents/Cargo.toml") { + ( + 200, + format!(r#"{{"content":"{cargo_encoded}","encoding":"base64"}}"#), + ) + } else if method == "GET" && path.contains("/actions/secrets?per_page=100") { + (200, secrets_list.clone()) + } else if method == "GET" && path.contains("/actions/secrets/public-key") { + (200, public_key.clone()) + } else if method == "PUT" && path.contains("/actions/secrets/") { + (204, String::new()) + } else { + not_found() + } + } +} + +#[test] +fn apply_missing_secrets_step_sets_missing_secret() { + let _guard = env_lock(); + let (_home, prev_home) = isolate_home_with_rust_secrets(); + let prev_secret = set_step_secret_env(); + + let (url, log) = start_recording_mock_server(secrets_handler(r#"{"secrets":[]}"#.to_string())); + let target = target_for(&url); + let result = apply_missing_secrets_step(&target); + + restore_step_secret_env(prev_secret); + restore_home(prev_home); + result.unwrap(); + assert!(has_call( + &log, + "PUT", + &format!("/repos/owner/repo/actions/secrets/{STEP_SECRET}") + )); +} + +#[test] +fn apply_missing_secrets_step_skips_secret_already_present() { + let _guard = env_lock(); + let (_home, prev_home) = isolate_home_with_rust_secrets(); + let prev_secret = set_step_secret_env(); + + let list = format!(r#"{{"secrets":[{{"name":"{STEP_SECRET}"}}]}}"#); + let (url, log) = start_recording_mock_server(secrets_handler(list)); + let target = target_for(&url); + let result = apply_missing_secrets_step(&target); + + restore_step_secret_env(prev_secret); + restore_home(prev_home); + result.unwrap(); + assert!( + !has_put(&log), + "a present secret must not be written again: {:?}", + snapshot(&log) + ); +} + +#[test] +fn apply_missing_secrets_step_no_markers_no_writes() { + let _guard = env_lock(); + let (_home, prev_home) = isolate_home_with_rust_secrets(); + let prev_secret = set_step_secret_env(); + + let (url, log) = start_recording_mock_server(|_, _| not_found()); + let target = target_for(&url); + let result = apply_missing_secrets_step(&target); + + restore_step_secret_env(prev_secret); + restore_home(prev_home); + result.unwrap(); + assert!( + !has_put(&log), + "without marker files nothing may be written: {:?}", + snapshot(&log) + ); +} + +// ── collect_apply_team_access / cores ───────────────────────────── + +#[test] +fn collect_apply_team_access_errors_without_tty() { + let url = start_mock_server_for_path(); + let client = mock_client(&url); + assert!(collect_apply_team_access(&client, "owner").is_err()); +} + +fn start_mock_server_for_path() -> String { + crate::github::test_utils::start_mock_server(|_| not_found()) +} + +#[test] +fn collect_team_access_after_false_wants_nothing() { + let (url, log) = start_recording_mock_server(|_, path| { + panic!("no requests expected when teams declined, got: {path}") + }); + let client = mock_client(&url); + let result = collect_team_access_after(&client, "owner", false, |_| { + panic!("must not prompt when teams declined") + }) + .unwrap(); + assert!(result.is_empty()); + assert!(snapshot(&log).is_empty()); +} + +fn one_backend_team() -> String { + r#"[{"name":"Backend","slug":"backend","description":null}]"#.to_string() +} + +#[test] +fn collect_team_access_after_true_empty_org_wants_nothing() { + let (url, log) = start_recording_mock_server(|method, path| { + if method == "GET" && path == "/user/teams" { + (200, "[]".to_string()) + } else { + not_found() + } + }); + let client = mock_client(&url); + let result = collect_team_access_after(&client, "owner", true, |_| { + panic!("must not prompt without org teams") + }) + .unwrap(); + assert!(result.is_empty()); + assert!(has_call(&log, "GET", "/user/teams")); +} + +#[test] +fn collect_team_access_after_true_skipped_selection_wants_nothing() { + let (url, log) = start_recording_mock_server(|method, path| { + if method == "GET" && path == "/user/teams" { + (200, one_backend_team()) + } else { + not_found() + } + }); + let client = mock_client(&url); + let result = collect_team_access_after(&client, "owner", true, |names| { + assert_eq!(names, vec!["Backend".to_string()]); + Ok(None) + }) + .unwrap(); + assert!(result.is_empty()); + assert!(has_call(&log, "GET", "/user/teams")); +} + +#[test] +fn collect_team_access_after_true_selection_propagates_permission_error() { + // The selection is injected; the per-team permission prompt has no TTY + // here, so resolving it fails — which is exactly the observable + // difference from the `Ok(vec![])` whole-function mutant. + let (url, log) = start_recording_mock_server(|method, path| { + if method == "GET" && path == "/user/teams" { + (200, one_backend_team()) + } else { + not_found() + } + }); + let client = mock_client(&url); + let result = collect_team_access_after(&client, "owner", true, |_| { + Ok(Some(vec!["Backend".to_string()])) + }); + assert!(result.is_err()); + assert!(has_call(&log, "GET", "/user/teams")); +} + +#[test] +fn collect_team_access_after_true_propagates_prompt_error() { + let (url, log) = start_recording_mock_server(|method, path| { + if method == "GET" && path == "/user/teams" { + (200, one_backend_team()) + } else { + not_found() + } + }); + let client = mock_client(&url); + let result = collect_team_access_after(&client, "owner", true, |_| { + Err::>, _>(anyhow::anyhow!("prompt unavailable")) + }); + assert!(result.is_err()); + assert!(has_call(&log, "GET", "/user/teams")); +} + +#[test] +fn select_team_access_matches_by_exact_name() { + let org_teams = vec![teams::Team { + name: "backend".to_string(), + slug: "backend".to_string(), + description: None, + }]; + let selections = vec!["backend".to_string()]; + let result = select_team_access(&org_teams, &selections, |team| { + Ok(teams::TeamAccess { + team_slug: team.slug.clone(), + permission: "push".to_string(), + }) + }) + .unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].team_slug, "backend"); + assert_eq!(result[0].permission, "push"); +} + +#[test] +fn select_team_access_skips_unknown_name() { + let org_teams = vec![teams::Team { + name: "backend".to_string(), + slug: "backend".to_string(), + description: None, + }]; + let selections = vec!["no-such-team".to_string()]; + let result = select_team_access(&org_teams, &selections, |_| { + panic!("permission must not be asked for unknown teams") + }) + .unwrap(); + assert!(result.is_empty()); +} + +#[test] +fn select_team_access_propagates_permission_error() { + let org_teams = vec![teams::Team { + name: "backend".to_string(), + slug: "backend".to_string(), + description: None, + }]; + let selections = vec!["backend".to_string()]; + let result = select_team_access(&org_teams, &selections, |_| { + Err(anyhow::anyhow!("permission prompt unavailable")) + }); + assert!(result.is_err()); +} + +// ── run_apply / cores ───────────────────────────────────────────── + +#[test] +fn run_apply_errors_without_token() { + let _guard = env_lock(); + let prev_token = std::env::var_os("GITHUB_TOKEN"); + std::env::remove_var("GITHUB_TOKEN"); + let home = tempfile::tempdir().unwrap(); + let prev_home = std::env::var_os("HOME"); + std::env::set_var("HOME", home.path()); + + let result = run_apply(Some("owner/repo"), false); + + match prev_token { + Some(value) => std::env::set_var("GITHUB_TOKEN", value), + None => std::env::remove_var("GITHUB_TOKEN"), + } + restore_home(prev_home); + assert!(result.is_err()); +} + +#[test] +fn apply_changes_errors_when_prompt_unavailable() { + let (url, _) = start_recording_mock_server(|_, _| not_found()); + let target = target_for(&url); + assert!(apply_changes(&target, &ctx_without_develop(), false).is_err()); +} + +#[test] +fn apply_changes_dry_run_makes_no_requests() { + let (url, log) = start_recording_mock_server(|_, path| { + panic!("no requests expected for a dry run, got: {path}") + }); + let target = target_for(&url); + apply_changes(&target, &ctx_without_develop(), true).unwrap(); + assert!(snapshot(&log).is_empty()); +} + +#[test] +fn apply_confirmed_aborts_when_declined() { + let (url, log) = start_recording_mock_server(|_, path| { + panic!("no requests expected after declining, got: {path}") + }); + let target = target_for(&url); + apply_confirmed(&target, &ctx_without_develop(), &[], false).unwrap(); + assert!(snapshot(&log).is_empty()); +} + +#[test] +fn apply_confirmed_propagates_step_error() { + let (url, log) = + start_recording_mock_server(|_, _| (500, r#"{"message":"Server Error"}"#.to_string())); + let target = target_for(&url); + assert!(apply_confirmed(&target, &ctx_without_develop(), &[], true).is_err()); + assert!(has_call(&log, "GET", "/repos/owner/repo/labels")); +} diff --git a/src/github/test_utils.rs b/src/github/test_utils.rs index ff60446..74b9762 100644 --- a/src/github/test_utils.rs +++ b/src/github/test_utils.rs @@ -1,17 +1,38 @@ use std::io::{BufRead, BufReader, Write}; use std::net::TcpListener; -use std::sync::Arc; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; use super::client::GithubClient; -/// Start a mock HTTP server that calls `handler` for each request. -/// Returns the base URL (e.g. "http://127.0.0.1:12345"). +/// The one crate-wide lock for tests that read or write process-global state +/// (`GITHUB_TOKEN`, `HOME`, secret-name env vars, the `~/.ghscaff` cache and +/// vault). Every such test — in `vault`, `updater`, `apply` and `wizard` — +/// must take *this* guard, never a module-local one, otherwise two suites +/// swap `HOME` at the same time and each observes the other's temp +/// directory. Tests run in parallel by default; hold the guard for the whole +/// test. +pub fn env_lock() -> MutexGuard<'static, ()> { + static ENV_LOCK: OnceLock> = OnceLock::new(); + ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|e| e.into_inner()) +} + +pub type RequestLog = Arc>>; + +/// Start a mock HTTP server that calls `handler` for each request and +/// records every `(method, path)` pair it serves. /// -/// The handler receives the request path and returns (status_code, response_body). -pub fn start_mock_server( - handler: impl Fn(&str) -> (u16, String) + Send + Sync + 'static, -) -> String { +/// Returns the base URL plus a shared log the test can read. `GithubClient` +/// uses keep-alive connections, so each connection is served in a loop until +/// the client closes it. +pub fn start_recording_mock_server( + handler: impl Fn(&str, &str) -> (u16, String) + Send + Sync + 'static, +) -> (String, RequestLog) { let handler = Arc::new(handler); + let log: RequestLog = Arc::new(Mutex::new(Vec::new())); + let logged = Arc::clone(&log); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); let url = format!("http://127.0.0.1:{}", addr.port()); @@ -36,16 +57,24 @@ pub fn start_mock_server( break; } } - let path = request_line.split_whitespace().nth(1).unwrap_or("/"); - let (status, body) = (handler)(path); + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or("GET"); + let path = parts.next().unwrap_or("/"); + logged + .lock() + .unwrap() + .push((method.to_string(), path.to_string())); + let (status, body) = (handler)(method, path); let status_text = match status { 200 => "OK", 201 => "Created", 204 => "No Content", 400 => "Bad Request", + 401 => "Unauthorized", 403 => "Forbidden", 404 => "Not Found", 422 => "Unprocessable Entity", + 500 => "Internal Server Error", _ => "Unknown", }; let response = format!( @@ -57,6 +86,17 @@ pub fn start_mock_server( } }); + (url, log) +} + +/// Start a mock HTTP server that calls `handler` for each request. +/// Returns the base URL (e.g. "http://127.0.0.1:12345"). +/// +/// The handler receives the request path and returns (status_code, response_body). +pub fn start_mock_server( + handler: impl Fn(&str) -> (u16, String) + Send + Sync + 'static, +) -> String { + let (url, _log) = start_recording_mock_server(move |_method, path| handler(path)); url } diff --git a/src/main.rs b/src/main.rs index 8f14635..df5b876 100644 --- a/src/main.rs +++ b/src/main.rs @@ -66,6 +66,21 @@ enum Command { /// owner/repo (auto-detected from git remote if omitted) repo: Option, }, + /// Update ghscaff to the latest stable release (always asks first) + /// + /// Exit codes: 0 = up to date (or update installed / declined / + /// cargo-managed refusal), 1 = an update is available (--check mode), + /// 2 = the update check could not be completed (network, HTTP, or an + /// unparsable response; the cause is printed on stderr). + /// `--dry-run` behaves like `--check`: it reports and never downloads or installs. + Update { + /// Only report whether an update is available (exit 0 = up to date, 1 = update available, 2 = check failed) + #[arg(long)] + check: bool, + /// Do not prompt; proceed if an update is available + #[arg(long)] + yes: bool, + }, } fn main() -> Result<()> { @@ -76,15 +91,40 @@ fn main() -> Result<()> { return sponsor_cmd::run_sponsor(target); } + // The explicit update command is handled before the silent startup + // notice: `--check` must not pay for a duplicate lookup, and + // GHSCAFF_NO_UPDATE_CHECK only silences the notice, never this. + if let Some(Command::Update { check, yes }) = &cli.command { + // `--dry-run` is a real dry run of the update: the same report as + // `--check`, never a download, an install or a prompt. + let code = updater::run_update( + update_check_mode(*check, cli.dry_run), + *yes, + &updater::RealFetcher::new(), + &updater::RealDownloader, + )?; + std::process::exit(code); + } + check_for_update(); match cli.command { None | Some(Command::New { .. }) => wizard::run(cli.dry_run), Some(Command::Apply { repo, dry_run }) => apply::run_apply(repo.as_deref(), dry_run), Some(Command::Config) => run_config(), Some(Command::Doctor { repo }) => doctor::run_doctor(repo.as_deref()), + Some(Command::Update { .. }) => unreachable!("ghscaff update is handled above"), } } +/// Resolve the read-only report mode of `update`: `--dry-run` implies +/// `--check`, so a dry run reports exactly like a check (exit 0 = up to date, +/// 1 = update available, 2 = check failed) and never reaches the prompt, +/// the download or the install. `--yes` stays inert in that mode, which is +/// what `run_update`'s early return guarantees. +fn update_check_mode(check: bool, dry_run: bool) -> bool { + check || dry_run +} + fn run_config() -> Result<()> { println!(); println!( @@ -126,74 +166,23 @@ fn run_config() -> Result<()> { Ok(()) } +/// Silent, read-only startup notice (CM34): when a newer stable release +/// exists, print one line pointing at `ghscaff update` and return. It never +/// prompts and never installs — the explicit command is the only path that +/// downloads anything, and it always asks first. Every failure (DNS, HTTP, +/// rate limit, JSON) is silent here. fn check_for_update() { if std::env::var("GHSCAFF_NO_UPDATE_CHECK").is_ok() { return; } - let Ok(client) = reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(3)) - .user_agent("ghscaff") - .build() - else { - return; - }; - let Ok(resp) = client - .get("https://api.github.com/repos/UniverLab/ghscaff/releases/latest") - .send() - else { - return; - }; - let Ok(json) = resp.json::() else { - return; - }; - let Some(latest_tag) = json["tag_name"].as_str() else { - return; - }; - let current = format!("v{}", env!("CARGO_PKG_VERSION")); - if !is_newer(¤t, latest_tag) { - return; - } - println!(" \x1b[33m⬆ Update available:\x1b[0m {current} → {latest_tag}"); - let Ok(install) = inquire::Confirm::new("Install now?") - .with_default(true) - .prompt() - else { - return; - }; - if !install { - println!(); - return; - } - run_installer(latest_tag); -} - -fn is_newer(current: &str, latest: &str) -> bool { - let parse = |v: &str| -> (u64, u64, u64) { - let v = v.trim_start_matches('v'); - let p: Vec = v.split('.').filter_map(|s| s.parse().ok()).collect(); - ( - *p.first().unwrap_or(&0), - *p.get(1).unwrap_or(&0), - *p.get(2).unwrap_or(&0), - ) - }; - parse(latest) > parse(current) -} - -fn run_installer(latest_tag: &str) { - #[cfg(target_os = "windows")] - { - let _ = latest_tag; - let _ = std::process::Command::new("powershell") - .args([ - "-Command", - "irm https://raw.githubusercontent.com/UniverLab/ghscaff/main/scripts/install.ps1 | iex", - ]) - .status(); - } - #[cfg(not(target_os = "windows"))] - { - updater::run_installer(latest_tag); + let fetcher = updater::RealFetcher::with_timeout(std::time::Duration::from_secs(3)); + let current = updater::current_version(); + if let Some(tag) = updater::check_notice(&fetcher, ¤t) { + println!( + " \x1b[33m⬆ Update available:\x1b[0m {} → {} — run 'ghscaff update'", + updater::display_version(¤t), + updater::display_version(&tag) + ); } } @@ -220,318 +209,77 @@ mod tests { } #[test] - fn test_is_newer_major_version() { - assert!(is_newer("v0.5.0", "v1.0.0")); - } - - #[test] - fn test_is_newer_minor_version() { - assert!(is_newer("v0.5.0", "v0.6.0")); - } - - #[test] - fn test_is_newer_patch_version() { - assert!(is_newer("v0.5.0", "v0.5.1")); - } - - #[test] - fn test_is_newer_same_version() { - assert!(!is_newer("v0.5.0", "v0.5.0")); - } - - #[test] - fn test_is_newer_older() { - assert!(!is_newer("v1.0.0", "v0.9.0")); - } - - #[test] - fn test_is_newer_without_v_prefix() { - assert!(is_newer("0.5.0", "0.6.0")); - } - - #[test] - fn test_is_newer_mixed_prefix() { - assert!(is_newer("v0.5.0", "1.0.0")); - } - - #[test] - fn test_is_newer_major_only() { - assert!(is_newer("v1.0.0", "v2.0.0")); - } - - #[test] - fn test_is_newer_minor_only() { - assert!(!is_newer("v2.0.0", "v1.9.0")); - } - - #[test] - fn test_is_newer_patch_bumps_not_enough() { - assert!(!is_newer("v1.0.1", "v1.0.0")); - } - - #[test] - fn test_is_newer_zero_versions() { - assert!(!is_newer("v0.0.0", "v0.0.0")); - } - - #[test] - fn test_is_newer_large_numbers() { - assert!(is_newer("v0.0.1", "v99.99.99")); - } - - #[test] - fn test_is_newer_incomplete_version_latest() { - assert!(is_newer("v0.5.0", "v1")); - } - - #[test] - fn test_is_newer_incomplete_version_current() { - assert!(!is_newer("v1", "v1.0.0")); - } - - #[test] - fn test_is_newer_both_incomplete() { - assert!(!is_newer("v1", "v1")); - } - - #[test] - fn test_is_newer_invalid_input() { - assert!(!is_newer("abc", "xyz")); - } - - #[test] - fn test_debug_mode_default() { - assert!(!is_debug()); - } - - #[test] - fn test_set_debug_enable() { - set_debug(true); - assert!(is_debug()); - set_debug(false); - } - - #[test] - fn test_set_debug_disable() { - set_debug(true); - set_debug(false); - assert!(!is_debug()); - } - - #[test] - fn test_is_newer_equal_major_minor() { - assert!(!is_newer("v1.2.3", "v1.2.3")); - } - - #[test] - fn test_is_newer_patch_only_difference() { - assert!(is_newer("v1.2.0", "v1.2.1")); - assert!(!is_newer("v1.2.1", "v1.2.0")); - } - - #[test] - fn test_is_newer_major_difference_overrides_minor() { - assert!(is_newer("v1.9.9", "v2.0.0")); - assert!(!is_newer("v2.0.0", "v1.9.9")); - } - - #[test] - fn test_is_newer_minor_difference_overrides_patch() { - assert!(is_newer("v1.1.9", "v1.2.0")); - assert!(!is_newer("v1.2.0", "v1.1.9")); - } - - #[test] - fn test_is_newer_leading_v_stripped() { - assert!(is_newer("v1.0.0", "v2.0.0")); - assert!(!is_newer("v2.0.0", "v1.0.0")); - } - - #[test] - fn test_is_newer_no_v_prefix() { - assert!(is_newer("1.0.0", "2.0.0")); - assert!(!is_newer("2.0.0", "1.0.0")); - } - - #[test] - fn test_is_newer_mixed_v_prefix() { - assert!(is_newer("v1.0.0", "2.0.0")); - assert!(is_newer("1.0.0", "v2.0.0")); + fn test_cli_parses_update() { + let cli = Cli::try_parse_from(["ghscaff", "update"]).unwrap(); + match cli.command { + Some(Command::Update { check, yes }) => { + assert!(!check, "--check defaults to false"); + assert!(!yes, "--yes defaults to false"); + } + _ => panic!("expected Command::Update"), + } } #[test] - fn test_is_newer_empty_strings() { - assert!(!is_newer("", "")); + fn test_cli_parses_update_check_yes() { + let cli = Cli::try_parse_from(["ghscaff", "update", "--check", "--yes"]).unwrap(); + match cli.command { + Some(Command::Update { check, yes }) => { + assert!(check); + assert!(yes); + } + _ => panic!("expected Command::Update"), + } } #[test] - fn test_is_newer_single_number() { - assert!(is_newer("v1", "v2")); - assert!(!is_newer("v2", "v1")); + fn test_cli_parses_update_dry_run_as_global_flag() { + let cli = Cli::try_parse_from(["ghscaff", "update", "--dry-run"]).unwrap(); + assert!(cli.dry_run, "global --dry-run is set"); + match cli.command { + Some(Command::Update { check, yes }) => { + assert!(!check, "--check defaults to false"); + assert!(!yes, "--yes defaults to false"); + } + _ => panic!("expected Command::Update"), + } } #[test] - fn test_is_newer_non_numeric_parts() { - assert!(!is_newer("vabc", "vdef")); + fn test_cli_parses_update_dry_run_before_subcommand() { + let cli = Cli::try_parse_from(["ghscaff", "--dry-run", "update"]).unwrap(); + assert!(cli.dry_run, "global --dry-run is set before the subcommand"); + assert!(matches!(cli.command, Some(Command::Update { .. }))); } #[test] - fn test_is_newer_very_large_versions() { - assert!(is_newer("v999.999.999", "v1000.0.0")); + fn update_dry_run_forces_check_mode() { + assert!( + update_check_mode(false, true), + "--dry-run alone reports like --check" + ); + assert!(update_check_mode(true, false), "--check alone still checks"); + assert!( + update_check_mode(true, true), + "--check --dry-run stays a report" + ); + assert!( + !update_check_mode(false, false), + "plain update still installs after asking" + ); } #[test] - fn test_is_newer_zero_vs_nonzero() { - assert!(is_newer("v0.0.0", "v0.0.1")); - assert!(!is_newer("v0.0.1", "v0.0.0")); - } - - #[test] - fn test_debug_mode_toggle() { - set_debug(false); - assert!(!is_debug()); - set_debug(true); - assert!(is_debug()); - set_debug(true); - assert!(is_debug()); + fn debug_flag_round_trip() { set_debug(false); - assert!(!is_debug()); - } - - #[test] - fn test_is_newer_major_minor_patch_all_different() { - assert!(is_newer("v1.2.3", "v2.3.4")); - assert!(!is_newer("v2.3.4", "v1.2.3")); - } - - #[test] - fn test_is_newer_major_minor_same_patch_different() { - assert!(is_newer("v1.1.0", "v1.1.1")); - assert!(!is_newer("v1.1.1", "v1.1.0")); - } - - #[test] - fn test_is_newer_major_same_minor_different() { - assert!(is_newer("v1.1.0", "v1.2.0")); - assert!(!is_newer("v1.2.0", "v1.1.0")); - } - - #[test] - fn test_is_newer_with_whitespace() { - assert!(!is_newer("v1.0.0 ", "v1.0.0")); - assert!(!is_newer("v1.0.0", " v1.0.0")); - } - - #[test] - fn test_is_newer_four_part_version() { - assert!(is_newer("v1.0.0.0", "v1.0.1.0")); - } - - #[test] - fn test_is_newer_latest_is_shorter() { - assert!(!is_newer("v1.0.0", "v1.0")); - } - - #[test] - fn test_is_newer_current_is_shorter() { - assert!(!is_newer("v1.0", "v1.0.0")); - } - - #[test] - fn test_is_newer_both_short() { - assert!(!is_newer("v1", "v1")); - } - - #[test] - fn test_is_newer_non_version_string() { - assert!(!is_newer("latest", "stable")); - } - - #[test] - fn test_set_debug_idempotent() { + assert!(!is_debug(), "flag starts false after an explicit reset"); set_debug(true); + assert!(is_debug(), "enable takes effect"); set_debug(true); - assert!(is_debug()); - set_debug(false); + assert!(is_debug(), "re-enabling stays true"); set_debug(false); - assert!(!is_debug()); - } - - #[test] - fn test_is_newer_boundary_values() { - assert!(is_newer("v0.0.0", "v1.0.0")); - assert!(!is_newer("v1.0.0", "v0.0.0")); - assert!(is_newer("v0.0.0", "v0.1.0")); - assert!(is_newer("v0.0.0", "v0.0.1")); - } - - #[test] - fn test_is_newer_with_extra_dots() { - assert!(!is_newer("v1..0", "v1..0")); - } - - #[test] - fn test_is_newer_with_leading_zeros() { - assert!(!is_newer("v01.00.00", "v1.0.0")); - } - - #[test] - fn test_is_newer_major_10_vs_9() { - assert!(is_newer("v9.0.0", "v10.0.0")); - } - - #[test] - fn test_is_newer_minor_99_vs_100() { - assert!(is_newer("v1.99.0", "v1.100.0")); - } - - #[test] - fn test_is_newer_patch_999_vs_1000() { - assert!(is_newer("v1.0.999", "v1.0.1000")); - } - - #[test] - fn test_is_newer_all_zero_vs_one() { - assert!(is_newer("v0.0.0", "v0.0.1")); - assert!(is_newer("v0.0.0", "v0.1.0")); - assert!(is_newer("v0.0.0", "v1.0.0")); - } - - #[test] - fn test_is_newer_identical_complex() { - assert!(!is_newer("v1.2.3", "v1.2.3")); - } - - #[test] - fn test_is_newer_current_greater_patch() { - assert!(!is_newer("v1.0.5", "v1.0.3")); - } - - #[test] - fn test_is_newer_current_greater_minor() { - assert!(!is_newer("v1.5.0", "v1.3.0")); - } - - #[test] - fn test_is_newer_current_greater_major() { - assert!(!is_newer("v5.0.0", "v3.0.0")); - } - - #[test] - fn test_debug_mode_default_is_false() { - set_debug(false); - assert!(!is_debug()); - } - - #[test] - fn test_debug_mode_toggle_sequence() { - set_debug(false); - assert!(!is_debug()); - set_debug(true); - assert!(is_debug()); - set_debug(true); - assert!(is_debug()); + assert!(!is_debug(), "disable takes effect"); set_debug(false); - assert!(!is_debug()); + assert!(!is_debug(), "re-disabling stays false"); } } diff --git a/src/templates/agent_registry.rs b/src/templates/agent_registry.rs new file mode 100644 index 0000000..168640a --- /dev/null +++ b/src/templates/agent_registry.rs @@ -0,0 +1,568 @@ +//! Canopy agent-registry: fetch, 24 h cache and bundled-snapshot fallback. +//! +//! Ported from gitkit's `src/ignore/agent_registry.rs` so a scaffolded repo +//! gets the same paths gitkit writes, whatever harnesses the registry lists. +//! The registry is best-effort data: `resolve` never fails, it falls back to +//! the cache and then to the embedded snapshot. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; + +pub(crate) const BASE_URL: &str = + "https://raw.githubusercontent.com/UniverLab/canopy-registry/main"; +pub(crate) const CACHE_NAME: &str = "agent-registry.toml"; +pub(crate) const FETCH_BUDGET: Duration = Duration::from_secs(5); +pub(crate) const CACHE_TTL_SECS: i64 = 24 * 3600; + +pub(crate) const SNAPSHOT: &str = include_str!("agent_registry_snapshot.toml"); + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct Platform { + pub(crate) name: String, + #[serde(default)] + pub(crate) project_paths: Vec, + #[serde(default)] + pub(crate) instruction_file: Option, + #[serde(default)] + pub(crate) instruction_precedence: Vec, +} + +/// The merged-TOML cache format: the index plus every platform file, flattened. +#[derive(Debug, Default, Serialize, Deserialize)] +pub(crate) struct MergedRegistry { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) fetched_at: Option, + #[serde(default)] + pub(crate) platforms: Vec, +} + +#[derive(Deserialize)] +struct Index { + #[serde(default)] + platforms: Vec, +} + +#[derive(Deserialize)] +struct IndexPlatform { + name: String, +} + +pub(crate) fn parse_merged(text: &str) -> Result> { + let registry: MergedRegistry = + toml::from_str(text).context("Failed to parse agent registry")?; + Ok(registry.platforms) +} + +pub(crate) fn snapshot_platforms() -> Vec { + parse_merged(SNAPSHOT).expect("embedded agent registry snapshot is corrupt") +} + +pub(crate) trait RegistryFetcher { + fn get(&self, url: &str, timeout: Duration) -> Result; +} + +#[cfg(not(test))] +pub(crate) struct HttpFetcher; + +#[cfg(not(test))] +impl RegistryFetcher for HttpFetcher { + fn get(&self, url: &str, timeout: Duration) -> Result { + reqwest::blocking::Client::builder() + .timeout(timeout) + .build() + .context("Failed to build agent registry HTTP client")? + .get(url) + .header("User-Agent", "ghscaff") + .send() + .context("Failed to fetch agent registry")? + .error_for_status() + .context("Agent registry request failed")? + .text() + .context("Failed to read agent registry response") + } +} + +/// The share of `budget` still left since `start`; `None` once it is spent, +/// which is what stops a slow registry fetch instead of stalling scaffolding. +pub(crate) fn remaining_timeout(start: Instant, budget: Duration) -> Option { + budget.checked_sub(start.elapsed()) +} + +pub(crate) fn now_epoch() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Fetches `index.toml` plus every platform file and returns the merged TOML +/// that is written to the cache. The whole fetch shares one `FETCH_BUDGET`. +pub(crate) fn fetch_merged(fetcher: &dyn RegistryFetcher) -> Result { + let start = Instant::now(); + let first = remaining_timeout(start, FETCH_BUDGET).context("registry fetch budget exceeded")?; + let index_text = fetcher.get(&format!("{BASE_URL}/index.toml"), first)?; + let index: Index = toml::from_str(&index_text).context("Failed to parse registry index")?; + let mut platforms = Vec::with_capacity(index.platforms.len()); + for entry in &index.platforms { + let remaining = + remaining_timeout(start, FETCH_BUDGET).context("registry fetch budget exceeded")?; + let url = format!("{BASE_URL}/platforms/{}.toml", entry.name); + platforms.push(parse_platform(&entry.name, &fetcher.get(&url, remaining)?)?); + } + if platforms.is_empty() { + bail!("registry index declares no platforms"); + } + let merged = MergedRegistry { + fetched_at: Some(now_epoch()), + platforms, + }; + toml::to_string(&merged).context("Failed to serialize agent registry") +} + +/// Only the three fields the `.gitignore` block needs are kept; the registry's +/// provider, CLI and template metadata is ignored. +fn parse_platform(name: &str, text: &str) -> Result { + #[derive(Deserialize)] + struct Raw { + #[serde(default)] + project_paths: Vec, + #[serde(default)] + instruction_file: Option, + #[serde(default)] + instruction_precedence: Vec, + } + let raw: Raw = toml::from_str(text) + .with_context(|| format!("Failed to parse registry platform '{name}'"))?; + Ok(Platform { + name: name.to_string(), + project_paths: raw.project_paths, + instruction_file: raw.instruction_file, + instruction_precedence: raw.instruction_precedence, + }) +} + +/// ghscaff's own cache: `dirs::cache_dir()/ghscaff/agent-registry.toml`. +/// Never gitkit's `~/.gitkit/`, so the two tools never fight over one file. +pub(crate) fn cache_path() -> Option { + dirs::cache_dir().map(|base| base.join("ghscaff").join(CACHE_NAME)) +} + +/// The cached platforms when the entry is younger than [`CACHE_TTL_SECS`]; +/// `None` for a missing, unreadable, malformed, empty or future-dated entry. +pub(crate) fn read_fresh_cache(path: &Path, now: i64) -> Option> { + let text = std::fs::read_to_string(path).ok()?; + let registry: MergedRegistry = toml::from_str(&text).ok()?; + let fetched_at = registry.fetched_at?; + let age = now.saturating_sub(fetched_at); + if !(0..CACHE_TTL_SECS).contains(&age) || registry.platforms.is_empty() { + return None; + } + Some(registry.platforms) +} + +pub(crate) struct Resolution { + pub(crate) platforms: Vec, + pub(crate) notice: Option, +} + +fn fallback(cache: Option<&Path>, reason: &str) -> Resolution { + if let Some(path) = cache { + if let Some(platforms) = read_fresh_cache(path, now_epoch()) { + return Resolution { + platforms, + notice: Some(format!( + "agentic: canopy registry unavailable ({reason}); using the cached registry at {}", + path.display() + )), + }; + } + } + Resolution { + platforms: snapshot_platforms(), + notice: Some(format!( + "agentic: canopy registry unavailable ({reason}); using the embedded registry snapshot" + )), + } +} + +/// Network first, then the fresh cache, then the embedded snapshot. Always +/// returns a resolution — scaffolding never fails because of the registry — +/// and `notice` names the source whenever it is not the live registry. +pub(crate) fn resolve(fetcher: &dyn RegistryFetcher, cache: Option<&Path>) -> Resolution { + match fetch_and_parse(fetcher) { + Ok((text, platforms)) => { + if let Some(path) = cache { + write_cache_best_effort(path, &text); + } + Resolution { + platforms, + notice: None, + } + } + Err(error) => fallback(cache, &short_reason(&error)), + } +} + +/// The fetched registry, still refused when it parses to no platform at all: +/// a truncated or emptied registry must not silently empty the block. +fn fetch_and_parse(fetcher: &dyn RegistryFetcher) -> Result<(String, Vec)> { + let text = fetch_merged(fetcher)?; + let platforms = parse_merged(&text)?; + if platforms.is_empty() { + bail!("empty registry"); + } + Ok((text, platforms)) +} + +fn short_reason(error: &anyhow::Error) -> String { + let message = format!("{error:#}"); + message + .lines() + .next() + .unwrap_or("unknown error") + .to_string() +} + +fn write_cache_best_effort(path: &Path, text: &str) { + if let Some(parent) = path.parent() { + if std::fs::create_dir_all(parent).is_err() { + return; + } + } + let _ = std::fs::write(path, text); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + struct FakeFetcher { + texts: HashMap, + error: Option, + } + + impl FakeFetcher { + fn ok(texts: HashMap) -> Self { + Self { texts, error: None } + } + + fn failing(message: &str) -> Self { + Self { + texts: HashMap::new(), + error: Some(message.to_string()), + } + } + } + + impl RegistryFetcher for FakeFetcher { + fn get(&self, url: &str, _timeout: Duration) -> Result { + if let Some(message) = &self.error { + bail!("{message}"); + } + self.texts + .get(url) + .cloned() + .with_context(|| format!("no fixture for {url}")) + } + } + + fn index_text(names: &[&str]) -> String { + let mut text = String::from("version = 8\n"); + for name in names { + text.push_str(&format!("\n[[platforms]]\nname = \"{name}\"\n")); + } + text + } + + fn two_platform_fetcher() -> FakeFetcher { + let mut texts = HashMap::new(); + texts.insert( + format!("{BASE_URL}/index.toml"), + index_text(&["foo", "bar"]), + ); + texts.insert( + format!("{BASE_URL}/platforms/foo.toml"), + "project_paths = [\".foo/\"]\ninstruction_file = \"AGENTS.md\"\n".to_string(), + ); + texts.insert( + format!("{BASE_URL}/platforms/bar.toml"), + "project_paths = [\"bar.json\"]\n".to_string(), + ); + FakeFetcher::ok(texts) + } + + fn cached_registry(dir: &tempfile::TempDir, age_secs: i64) -> PathBuf { + let path = dir.path().join(CACHE_NAME); + let fetched_at = now_epoch() - age_secs; + let text = format!( + "fetched_at = {fetched_at}\n\n[[platforms]]\nname = \"cached\"\nproject_paths = [\".cached/\"]\n" + ); + std::fs::write(&path, text).unwrap(); + path + } + + #[test] + fn parse_full_platform_file_ignores_unknown_fields() { + let text = "provider = \"Anthropic\"\ntool_name = \"Claude Code\"\nconfig_path = \".claude.json\"\n\ + mcp_servers_key = [\"mcpServers\"]\ninstruction_file = \"AGENTS.md\"\n\ + project_paths = [\".claude/\", \".mcp.json\", \"CLAUDE.local.md\"]\n\ + instruction_precedence = [\"CLAUDE.md\", \".claude/CLAUDE.md\", \"CLAUDE.local.md\"]\n\ + \n[cli]\nbinary = \"claude\"\n\n[required_fields]\nfoo = \"bar\"\n"; + let platform = parse_platform("claude", text).unwrap(); + assert_eq!(platform.name, "claude"); + assert_eq!( + platform.project_paths, + vec![".claude/", ".mcp.json", "CLAUDE.local.md"] + ); + assert_eq!(platform.instruction_file.as_deref(), Some("AGENTS.md")); + assert_eq!( + platform.instruction_precedence, + vec!["CLAUDE.md", ".claude/CLAUDE.md", "CLAUDE.local.md"] + ); + } + + #[test] + fn parse_merged_snapshot_has_all_platforms() { + let platforms = parse_merged(SNAPSHOT).unwrap(); + assert_eq!(platforms.len(), 21); + assert!(platforms.iter().all(|p| !p.project_paths.is_empty())); + } + + #[test] + fn snapshot_platforms_matches_parse() { + assert_eq!(snapshot_platforms(), parse_merged(SNAPSHOT).unwrap()); + } + + #[test] + fn fetch_merged_builds_cache_text() { + let text = fetch_merged(&two_platform_fetcher()).unwrap(); + let platforms = parse_merged(&text).unwrap(); + assert_eq!(platforms.len(), 2); + assert_eq!(platforms[0].name, "foo"); + assert_eq!(platforms[0].project_paths, vec![".foo/"]); + assert_eq!(platforms[1].project_paths, vec!["bar.json"]); + } + + /// `cache_path` decides where a fetched registry lands; without it the + /// cache is never read back or written. + #[test] + fn cache_path_resolves_to_the_ghscaff_registry_cache_file() { + let Some(path) = cache_path() else { + // No cache dir in this environment; production falls back to the + // snapshot and every other fallback test still applies. + return; + }; + assert_eq!( + path.file_name().and_then(|name| name.to_str()), + Some(CACHE_NAME), + "the cache must be the {CACHE_NAME} file, got {}", + path.display() + ); + assert!( + path.parent() + .is_some_and(|parent| parent.ends_with("ghscaff")), + "the cache must live in ghscaff's own cache dir, got {}", + path.display() + ); + } + + /// The test above tolerates a `None` cache dir; this one does not: whenever + /// the OS reports a cache dir, `cache_path` must derive the file from it + /// instead of collapsing to `None` (which would disable the cache entirely). + #[test] + fn cache_path_derives_from_the_os_cache_dir_when_one_exists() { + let Some(base) = dirs::cache_dir() else { + // No cache dir in this environment; nothing to derive from. + return; + }; + let path = + cache_path().expect("cache_path must be Some whenever the OS reports a cache dir"); + assert_eq!(path, base.join("ghscaff").join(CACHE_NAME)); + } + + /// The fallback notice embeds one line, so the reason must be the first + /// line of the formatted chain — neither a constant nor empty. + #[test] + fn short_reason_is_the_first_line_of_the_formatted_chain() { + assert_eq!( + short_reason(&anyhow::Error::msg("first line\nsecond line")), + "first line" + ); + let chained: anyhow::Error = Err::<(), _>(anyhow::anyhow!("boom")) + .context("outer") + .unwrap_err(); + assert_eq!(short_reason(&chained), "outer: boom"); + } + + /// Every cache-freshness test compares `now_epoch()` against itself, so + /// all of them keep passing if the clock helper collapses to a constant. + /// This pins it to the real wall clock the TTL is measured against. + #[test] + fn now_epoch_is_within_a_minute_of_the_wall_clock() { + let wall = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock is before the unix epoch") + .as_secs() as i64; + let epoch = now_epoch(); + assert!( + (epoch - wall).abs() < 60, + "now_epoch() returned {epoch}, wall clock is {wall}" + ); + } + + #[test] + fn remaining_timeout_shrinks_and_expires() { + let start = Instant::now(); + let remaining = remaining_timeout(start, Duration::from_secs(5)).unwrap(); + assert!(remaining <= Duration::from_secs(5)); + let past = start - Duration::from_secs(6); + assert!(remaining_timeout(past, Duration::from_secs(5)).is_none()); + } + + #[test] + fn read_fresh_cache_accepts_fresh_rejects_stale() { + let dir = tempfile::TempDir::new().unwrap(); + let path = cached_registry(&dir, 3600); + assert!(read_fresh_cache(&path, now_epoch()).is_some()); + let stale = cached_registry(&dir, 25 * 3600); + assert!(read_fresh_cache(&stale, now_epoch()).is_none()); + std::fs::write(&path, "not valid toml {{{").unwrap(); + assert!(read_fresh_cache(&path, now_epoch()).is_none()); + } + + /// The TTL is a full day: an entry half a day old is still fresh. This + /// pins the window itself (24 * 3600), not just its two ends — the fresh/ + /// stale pair above would accept a much shorter TTL too. + #[test] + fn read_fresh_cache_accepts_half_day_old_entry() { + let dir = tempfile::TempDir::new().unwrap(); + let path = cached_registry(&dir, 12 * 3600); + assert!( + read_fresh_cache(&path, now_epoch()).is_some(), + "12h is inside the {CACHE_TTL_SECS}s TTL" + ); + } + + #[test] + fn read_fresh_cache_rejects_future_timestamp() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join(CACHE_NAME); + let fetched_at = now_epoch() + 3600; + std::fs::write( + &path, + format!( + "fetched_at = {fetched_at}\n\n[[platforms]]\nname = \"x\"\nproject_paths = [\".x/\"]\n" + ), + ) + .unwrap(); + assert!(read_fresh_cache(&path, now_epoch()).is_none()); + } + + /// A registry entry with no platforms would empty the block, so the cache + /// is refused just like a stale one. + #[test] + fn read_fresh_cache_rejects_entry_without_platforms() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join(CACHE_NAME); + let fetched_at = now_epoch() - 60; + std::fs::write(&path, format!("fetched_at = {fetched_at}\n")).unwrap(); + assert!(read_fresh_cache(&path, now_epoch()).is_none()); + } + + #[test] + fn resolve_fetch_success_writes_cache() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join(CACHE_NAME); + let resolution = resolve(&two_platform_fetcher(), Some(&path)); + assert!(resolution.notice.is_none()); + assert!(path.exists()); + let cached = read_fresh_cache(&path, now_epoch()).unwrap(); + assert_eq!(cached.len(), 2); + } + + /// The fetch is attempted first, so a reachable registry always wins over a + /// cache entry — otherwise a newly listed harness could never appear. + #[test] + fn resolve_fetch_success_wins_over_a_fresh_cache() { + let dir = tempfile::TempDir::new().unwrap(); + let path = cached_registry(&dir, 60); + let resolution = resolve(&two_platform_fetcher(), Some(&path)); + assert!(resolution.notice.is_none()); + assert_eq!(resolution.platforms.len(), 2); + } + + #[test] + fn resolve_network_failure_uses_fresh_cache() { + let dir = tempfile::TempDir::new().unwrap(); + let path = cached_registry(&dir, 60); + let resolution = resolve(&FakeFetcher::failing("offline"), Some(&path)); + let notice = resolution.notice.as_deref().unwrap(); + assert!(notice.contains("cached registry"), "notice was {notice}"); + assert!(notice.contains(&path.display().to_string())); + assert_eq!(resolution.platforms.len(), 1); + assert_eq!(resolution.platforms[0].name, "cached"); + } + + #[test] + fn resolve_network_failure_without_cache_uses_snapshot() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("missing").join(CACHE_NAME); + let resolution = resolve(&FakeFetcher::failing("offline"), Some(&path)); + assert_eq!(resolution.platforms.len(), 21); + let notice = resolution.notice.as_deref().unwrap(); + assert!( + notice.contains("embedded registry snapshot"), + "notice was {notice}" + ); + } + + #[test] + fn resolve_stale_cache_uses_snapshot() { + let dir = tempfile::TempDir::new().unwrap(); + let path = cached_registry(&dir, 25 * 3600); + let resolution = resolve(&FakeFetcher::failing("offline"), Some(&path)); + assert_eq!(resolution.platforms.len(), 21); + let notice = resolution.notice.as_deref().unwrap(); + assert!( + notice.contains("embedded registry snapshot"), + "notice was {notice}" + ); + } + + /// A registry that does not parse is a failure like any other: the fresh + /// cache carries the block instead of the broken registry. + #[test] + fn resolve_parse_failure_uses_cache() { + let dir = tempfile::TempDir::new().unwrap(); + let path = cached_registry(&dir, 60); + let mut texts = HashMap::new(); + texts.insert(format!("{BASE_URL}/index.toml"), index_text(&["broken"])); + texts.insert( + format!("{BASE_URL}/platforms/broken.toml"), + "project_paths = [unclosed\n".to_string(), + ); + let resolution = resolve(&FakeFetcher::ok(texts), Some(&path)); + assert_eq!(resolution.platforms.len(), 1); + assert_eq!(resolution.platforms[0].name, "cached"); + assert!(resolution + .notice + .as_deref() + .is_some_and(|notice| notice.contains("cached registry"))); + } + + #[test] + fn resolve_empty_registry_falls_back() { + let mut texts = HashMap::new(); + texts.insert( + format!("{BASE_URL}/index.toml"), + "version = 8\n".to_string(), + ); + let resolution = resolve(&FakeFetcher::ok(texts), None); + assert_eq!(resolution.platforms.len(), 21); + assert!(resolution.notice.is_some()); + } +} diff --git a/src/templates/agent_registry_snapshot.toml b/src/templates/agent_registry_snapshot.toml new file mode 100644 index 0000000..50f0fa9 --- /dev/null +++ b/src/templates/agent_registry_snapshot.toml @@ -0,0 +1,112 @@ +# Merged canopy platform registry snapshot for the gitkit `agentic` template. +# Source: https://raw.githubusercontent.com/UniverLab/canopy-registry/main (index.toml + platforms/.toml). +# Do not edit by hand. Regenerate with: scripts/regen-agent-registry.sh +# Only name, project_paths, instruction_file and instruction_precedence are kept. +fetched_at = 1790808232 + +[[platforms]] +name = "kiro" +instruction_file = "AGENTS.md" +project_paths = [".kiro/"] + +[[platforms]] +name = "opencode" +instruction_file = "AGENTS.md" +project_paths = [".opencode/", "opencode.json", "opencode.jsonc"] + +[[platforms]] +name = "copilot" +instruction_file = ".github/copilot-instructions.md" +project_paths = [".copilot/"] + +[[platforms]] +name = "gemini" +instruction_file = "GEMINI.md" +project_paths = [".gemini/"] + +[[platforms]] +name = "claude" +instruction_file = "AGENTS.md" +project_paths = [".claude/", ".mcp.json", "CLAUDE.local.md"] +instruction_precedence = ["CLAUDE.md", ".claude/CLAUDE.md", "CLAUDE.local.md"] + +[[platforms]] +name = "codex" +instruction_file = "AGENTS.md" +project_paths = [".codex/"] +instruction_precedence = ["AGENTS.override.md"] + +[[platforms]] +name = "mistral" +instruction_file = "AGENTS.md" +project_paths = [".vibe/"] + +[[platforms]] +name = "blackbox" +instruction_file = "AGENTS.md" +project_paths = [".blackboxcli/"] + +[[platforms]] +name = "cursor" +instruction_file = ".cursor/rules" +project_paths = [".cursor/", ".cursorignore", ".cursorindexingignore"] + +[[platforms]] +name = "kilo" +instruction_file = "AGENTS.md" +project_paths = [".kilo/", ".kilocode/", "kilo.json", "kilo.jsonc"] + +[[platforms]] +name = "cline" +instruction_file = ".clinerules" +project_paths = [".cline/", ".clineignore"] + +[[platforms]] +name = "qwen" +instruction_file = "AGENTS.md" +project_paths = [".qwen/"] + +[[platforms]] +name = "continue" +instruction_file = ".continue/rules" +project_paths = [".continue/"] + +[[platforms]] +name = "antigravity" +instruction_file = "GEMINI.md" +project_paths = [".agents/"] + +[[platforms]] +name = "mimocode" +instruction_file = "AGENTS.md" +project_paths = [".mimocode/", "mimocode.json", "mimocode.jsonc"] + +[[platforms]] +name = "qoder" +instruction_file = "AGENTS.md" +project_paths = [".qoder/"] + +[[platforms]] +name = "devin" +instruction_file = "AGENTS.md" +project_paths = [".devin/"] + +[[platforms]] +name = "codebuddy" +instruction_file = "AGENTS.md" +project_paths = [".codebuddy/"] + +[[platforms]] +name = "commandcode" +instruction_file = "AGENTS.md" +project_paths = [".commandcode/"] + +[[platforms]] +name = "freebuff" +instruction_file = "knowledge.md" +project_paths = [".agents/"] + +[[platforms]] +name = "utim" +instruction_file = "AGENTS.md" +project_paths = [".utim/", ".utim_tmp/"] diff --git a/src/templates/agentic.rs b/src/templates/agentic.rs new file mode 100644 index 0000000..6b469c8 --- /dev/null +++ b/src/templates/agentic.rs @@ -0,0 +1,627 @@ +//! The `.gitignore` block for AI coding agents, derived from the canopy +//! registry rather than from a hardcoded list, so a new harness (opencode, +//! codebuddy, devin, kilo, antigravity…) reaches every scaffolded repo as +//! soon as the registry lists it. +//! +//! Ported from gitkit's `src/ignore/agentic.rs` minus the merge half: ghscaff +//! always writes a fresh `.gitignore`, so nothing here strips an old block. +//! The header line is gitkit's own, so a later `gitkit ignore add agentic` +//! still recognises this block. + +use std::collections::BTreeSet; + +#[cfg(test)] +use super::agent_registry::snapshot_platforms; +use super::agent_registry::Platform; +#[cfg(not(test))] +use super::agent_registry::{cache_path, resolve, HttpFetcher, Resolution}; + +/// The header `assemble()` emits, byte for byte the one +/// `gitkit ignore add agentic` writes. Its presence is what marks the block. +pub(crate) const CANONICAL_HEADER: &str = + "# AI coding agents (paths derived from the canopy registry)"; +/// Marks the negation tail of the block; nothing but `!` lines may follow it. +pub(crate) const NEG_HEADER: &str = "# instruction files stay committable"; + +/// The complete agentic block for a new `.gitignore`. Never fails: when the +/// registry is unreachable the cached or embedded registry is used and one +/// warning line names the source. +pub(crate) fn block() -> String { + let platforms = resolved_platforms(); + let (lines, warnings) = build(&platforms); + for warning in warnings { + eprintln!("{warning}"); + } + assemble(&lines) +} + +#[cfg(not(test))] +fn resolved_platforms() -> Vec { + let cache = cache_path(); + let Resolution { platforms, notice } = resolve(&HttpFetcher, cache.as_deref()); + if let Some(notice) = notice { + eprintln!("{notice}"); + } + platforms +} + +/// Tests always build from the embedded snapshot: no network, and the +/// assertions below pin exact output. +#[cfg(test)] +fn resolved_platforms() -> Vec { + snapshot_platforms() +} + +/// The registry-derived lines of an `agentic` block: ignore lines first, then +/// the negation lines, split the way the block is written. +pub(crate) fn build(platforms: &[Platform]) -> (Vec, Vec) { + let protected = protected_set(platforms); + let (mut ignore, warnings) = ignore_lines(platforms, &protected); + // Registry-independent: every agent may write this lockfile. + ignore.insert("skills-lock.json".to_string()); + let dirs: BTreeSet = ignore + .iter() + .filter(|line| line.ends_with("/*")) + .map(|line| line.trim_end_matches('*').to_string()) + .collect(); + let mut lines: Vec = ignore.into_iter().collect(); + lines.extend(negations(&protected, &dirs)); + (lines, warnings) +} + +#[cfg(test)] +pub(crate) fn render(platforms: &[Platform]) -> String { + let (lines, _) = build(platforms); + assemble(&lines) +} + +/// Canonical header, the lines, and — as soon as the first negation appears — +/// a blank line, [`NEG_HEADER`] and the negations. No leading blank line: the +/// block is appended by the caller, which owns the separator. +fn assemble(lines: &[String]) -> String { + let mut content = String::from(CANONICAL_HEADER); + content.push('\n'); + let mut in_negations = false; + for line in lines { + if !in_negations && line.starts_with('!') { + content.push('\n'); + content.push_str(NEG_HEADER); + content.push('\n'); + in_negations = true; + } + content.push_str(line); + content.push('\n'); + } + content +} + +/// Instruction files that stay committable: the two cross-agent names, each +/// platform's own instruction file, and every precedence entry that is not +/// also an ignored project path. +fn protected_set(platforms: &[Platform]) -> BTreeSet { + let project_paths: BTreeSet<&str> = platforms + .iter() + .flat_map(|platform| platform.project_paths.iter().map(String::as_str)) + .collect(); + let mut protected = BTreeSet::new(); + protected.insert("AGENTS.md".to_string()); + protected.insert("CLAUDE.md".to_string()); + for platform in platforms { + if let Some(file) = &platform.instruction_file { + protected.insert(file.clone()); + } + for entry in &platform.instruction_precedence { + if !project_paths.contains(entry.as_str()) { + protected.insert(entry.clone()); + } + } + } + protected +} + +/// Never-ignore roots: `.github` and `.config` hold configuration that must +/// stay visible, neither the bare root nor anything under it. +fn is_forbidden(path: &str) -> bool { + path == ".github" + || path == ".config" + || path.starts_with(".github/") + || path.starts_with(".config/") +} + +fn ignore_lines( + platforms: &[Platform], + protected: &BTreeSet, +) -> (BTreeSet, Vec) { + let mut lines = BTreeSet::new(); + let mut warnings = Vec::new(); + for platform in platforms { + for entry in &platform.project_paths { + if is_forbidden(entry) { + warnings.push(forbidden_warning(&platform.name, entry)); + continue; + } + if entry.ends_with('/') { + // The directory itself stays trackable; its contents do not. + lines.insert(format!("{entry}*")); + } else if protected.contains(entry) { + warnings.push(protected_warning(&platform.name, entry)); + } else { + lines.insert(entry.clone()); + } + } + } + (lines, warnings) +} + +fn forbidden_warning(platform: &str, entry: &str) -> String { + format!("agentic: {platform}: '{entry}' skipped — .github/.config are never ignored") +} + +fn protected_warning(platform: &str, entry: &str) -> String { + format!("agentic: {platform}: '{entry}' is a protected instruction file and stays committable") +} + +/// One negation per protected file that lives under an ignored directory, plus +/// the negation of every directory between it and that ignored root: git must +/// be walked back down to the file, which `!file` alone cannot do. +fn negations(protected: &BTreeSet, dirs: &BTreeSet) -> Vec { + let mut out = BTreeSet::new(); + for path in protected { + if !path.contains('/') { + continue; + } + let Some(ignored) = dirs.iter().find(|dir| path.starts_with(dir.as_str())) else { + continue; + }; + for item in ancestor_chain(path, &ancestor_dir(path), ignored) { + out.insert(format_negation(&item)); + } + } + out.into_iter().collect() +} + +/// `path` plus its ancestors, walking up until `ignored` is reached. +/// +/// The bound is `path.len()` steps, which the real chain never approaches +/// (every step shortens `current` and runs out first); it only stops a +/// non-productive [`ancestor_dir`] from spinning here. +fn ancestor_chain(path: &str, parent: &str, ignored: &str) -> Vec { + let mut current = parent.to_string(); + let mut chain = vec![path.to_string()]; + for _ in 0..=path.len() { + if current == ignored { + break; + } + chain.push(current.clone()); + current = ancestor_dir(¤t); + } + chain +} + +fn ancestor_dir(path: &str) -> String { + let trimmed = path.trim_end_matches('/'); + match trimmed.rfind('/') { + Some(index) => trimmed[..=index].to_string(), + None => String::new(), + } +} + +/// Files are negated as-is; a directory gets a trailing `/` so git treats the +/// negation as "this directory", not as a no-op file name. +fn format_negation(path: &str) -> String { + if path.ends_with('/') { + return format!("!{path}"); + } + let last = path.rsplit('/').next().unwrap_or(path); + if last.contains('.') { + format!("!{path}") + } else { + format!("!{path}/") + } +} + +#[cfg(test)] +mod tests { + use super::super::agent_registry::{parse_merged, snapshot_platforms, SNAPSHOT}; + use super::*; + + const FOO: &str = "[[platforms]]\nname = \"foo\"\nproject_paths = [\".foo/\"]\n"; + + fn platforms_of(text: &str) -> Vec { + parse_merged(text).unwrap() + } + + #[test] + fn build_fixture_new_platform_foo() { + let (lines, _) = build(&platforms_of(FOO)); + assert!(lines.contains(&".foo/*".to_string())); + } + + #[test] + fn build_fixture_github_path_warns_only() { + let text = "[[platforms]]\nname = \"evil\"\nproject_paths = [\".github/prompts/\"]\n"; + let (lines, warnings) = build(&platforms_of(text)); + assert!(!lines.iter().any(|line| { + let body = line.trim_start_matches('!'); + body == ".github" || body.starts_with(".github/") || body.starts_with(".github*") + })); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains("evil")); + for entry in [ + ".github", + ".config", + ".github/", + ".config/", + ".config/x/", + ".github/y", + ] { + let fixture = format!("[[platforms]]\nname = \"p\"\nproject_paths = [\"{entry}\"]\n"); + let (lines, warnings) = build(&platforms_of(&fixture)); + assert!(!lines.iter().any(|line| { + let body = line.trim_start_matches('!'); + body.starts_with(".github") || body.starts_with(".config") + })); + assert_eq!(warnings.len(), 1, "{entry} must warn exactly once"); + } + } + + #[test] + fn build_fixture_protected_root_file_dropped_with_warning() { + let text = "[[platforms]]\nname = \"p\"\nproject_paths = [\"AGENTS.md\"]\ninstruction_file = \"AGENTS.md\"\n"; + let (lines, warnings) = build(&platforms_of(text)); + assert!(!lines.contains(&"AGENTS.md".to_string())); + assert_eq!(warnings.len(), 1); + assert!(warnings[0].contains('p')); + } + + #[test] + fn protected_set_rules() { + let text = "[[platforms]]\nname = \"claude\"\nproject_paths = [\".claude/\", \"CLAUDE.local.md\"]\n\ + instruction_file = \"AGENTS.md\"\n\ + instruction_precedence = [\"CLAUDE.md\", \".claude/CLAUDE.md\", \"CLAUDE.local.md\"]\n"; + let protected = protected_set(&platforms_of(text)); + assert!(protected.contains("AGENTS.md")); + assert!(protected.contains("CLAUDE.md")); + assert!(protected.contains(".claude/CLAUDE.md")); + assert!(!protected.contains("CLAUDE.local.md")); + } + + /// `skills-lock.json` is not in any platform's paths, so a registry + /// refresh that drops every platform would still keep it. + #[test] + fn build_always_includes_skills_lock() { + let (_, warnings) = build(&platforms_of( + "[[platforms]]\nname = \"p\"\nproject_paths = []\n", + )); + let (lines, _) = build(&platforms_of( + "[[platforms]]\nname = \"p\"\nproject_paths = []\n", + )); + assert!(lines.contains(&"skills-lock.json".to_string())); + assert!(warnings.is_empty()); + } + + #[test] + fn render_snapshot_expected_lines() { + let content = render(&snapshot_platforms()); + for expected in [ + ".kilo/*", + "kilo.jsonc", + "kilo.json", + ".kilocode/*", + ".opencode/*", + "opencode.json", + "opencode.jsonc", + ".agents/*", + ".claude/*", + ".codebuddy/*", + ".devin/*", + "skills-lock.json", + ".mcp.json", + ".clineignore", + "!.claude/CLAUDE.md", + "!.continue/rules/", + "!.cursor/rules/", + ] { + assert!( + content.lines().any(|line| line == expected), + "missing {expected}" + ); + } + assert_eq!( + content.lines().filter(|line| *line == ".agents/*").count(), + 1, + ".agents/* must be deduplicated" + ); + assert!(!content.lines().any(|line| line == ".aider.chat.history.md")); + assert!(!content + .lines() + .any(|line| line == ".claude/settings.local.json")); + assert!(!content.lines().any(|line| line.trim() == ".claude/")); + assert!(!content.lines().any(|line| line.trim() == ".windsurf/")); + assert!(!content.lines().any(|line| line.trim() == ".zencoder/")); + for line in content.lines() { + if line.starts_with('!') || line.is_empty() || line.starts_with('#') { + continue; + } + assert_ne!(line, "AGENTS.md"); + assert_ne!(line, "CLAUDE.md"); + } + } + + #[test] + fn render_negation_block_last() { + let content = render(&snapshot_platforms()); + let mut seen_negation = false; + for line in content.lines() { + if line.is_empty() || line.starts_with('#') { + continue; + } + if line.starts_with('!') { + seen_negation = true; + } else if seen_negation { + panic!("ignore line after negation block: {line}"); + } + } + } + + #[test] + fn render_never_emits_github_or_config() { + let (lines, _) = build(&snapshot_platforms()); + for line in &lines { + let body = line.trim_start_matches('!'); + assert!(!is_forbidden(body), "forbidden line emitted: {line}"); + } + } + + #[test] + fn render_contains_only_expected_negations() { + let (lines, _) = build(&snapshot_platforms()); + let negs: Vec<&String> = lines.iter().filter(|line| line.starts_with('!')).collect(); + assert_eq!(negs.len(), 3); + assert!(negs + .iter() + .any(|line| line.as_str() == "!.claude/CLAUDE.md")); + assert!(negs.iter().any(|line| line.as_str() == "!.continue/rules/")); + assert!(negs.iter().any(|line| line.as_str() == "!.cursor/rules/")); + } + + /// The whole point of the port: from the embedded snapshot ghscaff + /// assembles the exact bytes `gitkit ignore add agentic` writes. + #[test] + fn block_matches_the_gitkit_snapshot_fixture() { + assert_eq!( + block(), + include_str!("../../tests/fixtures/agentic-block.txt") + ); + } + + #[test] + fn block_contains_negation_section_and_no_forbidden_root() { + let content = block(); + let lines: Vec<&str> = content.lines().collect(); + let header = lines.iter().position(|line| *line == CANONICAL_HEADER); + assert_eq!(header, Some(0), "the block opens with gitkit's header"); + let neg = lines + .iter() + .position(|line| *line == NEG_HEADER) + .expect("the negation section is part of the block"); + assert!(lines[neg + 1..].iter().all(|line| line.starts_with('!'))); + for line in &lines { + let body = line.trim_start_matches('!'); + assert!( + !body.starts_with(".github") && !body.starts_with(".config"), + ".github/.config must never be ignored: {line}" + ); + } + assert!(!lines.iter().any(|line| line.trim() == "AGENTS.md")); + assert!(!lines.iter().any(|line| line.trim() == "CLAUDE.md")); + } + + /// The comment that marks the negation block is emitted exactly once, on + /// the line before the first `!` entry — not before the first ignore line + /// and not dropped when the input already starts with a negation. + #[test] + fn assemble_comment_marks_the_negation_block_in_place() { + let content = assemble(&[ + ".foo/*".to_string(), + "bar.json".to_string(), + "!keep/one.md".to_string(), + "!keep/two.md".to_string(), + ]); + let lines: Vec<&str> = content.lines().collect(); + let hits: Vec = lines + .iter() + .enumerate() + .filter(|(_, line)| **line == NEG_HEADER) + .map(|(index, _)| index) + .collect(); + assert_eq!( + hits.len(), + 1, + "the marker must appear exactly once: {content:?}" + ); + let at = hits[0]; + assert!( + lines.get(at + 1).is_some_and(|next| next.starts_with('!')), + "the line after the marker must be the first negation: {content:?}" + ); + assert!( + lines[at + 1..].iter().all(|line| line.starts_with('!')), + "nothing but negations may follow the marker: {content:?}" + ); + assert!( + lines[..at].contains(&".foo/*"), + "ignore lines must precede the marker: {content:?}" + ); + } + + /// An input that opens with a negation still gets the marker, once, and + /// never an ignore line after it. + #[test] + fn assemble_comment_when_the_first_line_is_a_negation() { + let content = assemble(&["!keep/one.md".to_string()]); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines.iter().filter(|line| **line == NEG_HEADER).count(), 1); + assert!( + lines.contains(&"!keep/one.md"), + "the negation itself must be kept: {content:?}" + ); + assert!( + lines + .iter() + .skip_while(|line| **line != NEG_HEADER) + .skip(1) + .all(|line| line.starts_with('!')), + "nothing but negations may follow the marker: {content:?}" + ); + } + + /// The block is appended by the caller, which owns the separator line, so + /// the block itself must not start with a blank line. + #[test] + fn assemble_has_no_leading_blank_line() { + let content = assemble(&[".foo/*".to_string()]); + assert!(content.starts_with(CANONICAL_HEADER), "{content:?}"); + assert!(content.ends_with('\n')); + } + + /// `parse_merged` on the embedded snapshot is what every snapshot test + /// above feeds through `build`; keep it in view so a snapshot change is + /// obvious here. + #[test] + fn snapshot_is_the_source_of_the_rendered_block() { + assert_eq!(render(&snapshot_platforms()), block()); + assert_eq!(parse_merged(SNAPSHOT).unwrap().len(), 21); + } +} + +#[cfg(test)] +mod check_ignore_tests { + use super::super::agent_registry::{parse_merged, snapshot_platforms, SNAPSHOT}; + use super::*; + use std::collections::BTreeSet; + use std::path::Path; + use std::process::Command; + + fn is_ignored(repo: &Path, path: &str) -> bool { + let output = Command::new("git") + .args(["check-ignore", "--no-index", "--", path]) + .current_dir(repo) + .output() + .expect("git binary must be available"); + match output.status.code() { + Some(0) => true, + Some(1) => false, + other => panic!( + "git check-ignore failed ({other:?}): {}", + String::from_utf8_lossy(&output.stderr) + ), + } + } + + fn create_probe(repo: &Path, path: &str) { + let full = repo.join(path); + if path.ends_with('/') { + std::fs::create_dir_all(&full).unwrap(); + std::fs::write(full.join("probe.txt"), "x").unwrap(); + } else if let Some(parent) = full.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&full, "x").unwrap(); + } + } + + /// The block is only useful if git honours it: every registry path is + /// ignored, `.github` and the instruction files are not. Local `git`, no + /// network, no repository is created on GitHub. + #[test] + fn generated_block_via_git_check_ignore_is_behaved_exactly() { + let platforms = snapshot_platforms(); + let content = render(&platforms); + let dir = tempfile::TempDir::new().unwrap(); + let repo = dir.path(); + assert!(Command::new("git") + .args(["init", "-q"]) + .current_dir(repo) + .status() + .unwrap() + .success()); + std::fs::write(repo.join(".gitignore"), &content).unwrap(); + + let protected = protected_set(&platforms); + let mut project_entries: BTreeSet = BTreeSet::new(); + for platform in &platforms { + for entry in &platform.project_paths { + project_entries.insert(entry.clone()); + } + } + for entry in &project_entries { + if is_forbidden(entry) || protected.contains(entry) { + continue; + } + if entry.ends_with('/') { + create_probe(repo, entry); + assert!( + is_ignored(repo, &format!("{entry}probe.txt")), + "{entry}probe.txt should be ignored" + ); + } else { + create_probe(repo, entry); + assert!(is_ignored(repo, entry), "{entry} should be ignored"); + } + } + + for path in &protected { + let last = path.rsplit('/').next().unwrap_or(path); + let dir_like = path.ends_with('/') || (path.contains('/') && !last.contains('.')); + if dir_like { + let dir = if path.ends_with('/') { + path.clone() + } else { + format!("{path}/") + }; + create_probe(repo, &dir); + assert!( + !is_ignored(repo, &format!("{dir}probe.mdc")), + "{path} should stay committable" + ); + } else { + create_probe(repo, path); + assert!(!is_ignored(repo, path), "{path} should stay committable"); + } + } + create_probe(repo, ".github/copilot-instructions.md"); + assert!(!is_ignored(repo, ".github/copilot-instructions.md")); + + for ignored in [ + "CLAUDE.local.md", + "skills-lock.json", + ".mcp.json", + ".clineignore", + "kilo.jsonc", + "opencode.json", + ] { + create_probe(repo, ignored); + assert!(is_ignored(repo, ignored), "{ignored} should be ignored"); + } + for kept in [ + "AGENTS.md", + "CLAUDE.md", + "GEMINI.md", + "knowledge.md", + ".clinerules", + "AGENTS.override.md", + ] { + create_probe(repo, kept); + assert!(!is_ignored(repo, kept), "{kept} should stay committable"); + } + } + + /// The platform list the assertions above walk is the embedded snapshot, + /// not a test fixture that could drift from it. + #[test] + fn check_ignore_fixture_is_the_embedded_snapshot() { + assert_eq!(parse_merged(SNAPSHOT).unwrap(), snapshot_platforms()); + } +} diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 12243a2..53fa3a7 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -1,3 +1,5 @@ +mod agent_registry; +mod agentic; pub mod rust; use anyhow::{Context, Result}; @@ -8,6 +10,35 @@ use crate::github::client::GithubClient; const BOILERPLATE_REPO: &str = "UniverLab/ghscaff-boilerplate"; +/// Snake_case module name derived from the kebab-case project name: +/// lowercased with every `-` replaced by `_` (`astro-denoise` → +/// `astro_denoise`). Used for the `{{module}}` placeholder so python +/// boilerplates can ship `src/{{module}}/…` instead of a package literally +/// named `src`. +fn module_name(name: &str) -> String { + name.to_lowercase().replace('-', "_") +} + +/// SPDX identifier rendered by the `{{license}}` placeholder. The wizard +/// stores the choice as a display string; `GPL-3.0` is not an SPDX id, so it +/// maps to `GPL-3.0-only`. `None` (no license chosen) renders empty. +fn license_spdx_id(license: Option<&str>) -> &str { + match license { + Some("GPL-3.0") => "GPL-3.0-only", + Some(other) => other, + None => "", + } +} + +/// Printed once when the rust template is rendered with no license chosen. +pub const NO_LICENSE_NOTICE: &str = "No license chosen: the crate is not publishable (publish = false) and no release workflow was created. Add a LICENSE and restore them to publish."; + +/// True only for the rust no-license case, so the notice is printed once and +/// never for a python boilerplate or for a chosen license. +pub fn is_rust_without_license(language: &str, license: Option<&str>) -> bool { + language == "rust" && license.is_none() +} + // Files excluded from boilerplate_files() — handled separately or metadata const SKIP_FILES: &[&str] = &[ "template.toml", @@ -16,35 +47,10 @@ const SKIP_FILES: &[&str] = &[ ".gitignore", // replaced by GitHub's official gitignore template via API ]; -// Comment marking the boundary between the fetched GitHub template and the -// block below, so a reader of the assembled .gitignore can see where the -// official template ends. -const AGENTIC_GITIGNORE_HEADER: &str = - "# AI coding agents — mirrors gitkit's `agentic` builtin (src/ignore/mod.rs)\n"; - -// Patterns for AI coding agent scratch state, appended to every scaffolded -// repository's .gitignore regardless of language. Kept textually identical -// to gitkit's `AGENTIC` constant in `src/ignore/mod.rs` (the `builtins` -// module) — update both together. -const AGENTIC_GITIGNORE: &str = "\ -.kiro/ -.cursor/ -.windsurf/ -.claude/ -.continue/ -.copilot/ -.kilocode/ -.zencoder/ -.qwen/ -.agents/ -skills-lock.json -"; - -/// Appends the agentic-tooling ignore block to a fetched (or empty/failed) +/// Appends the registry-derived agentic block to a fetched (or empty/failed) /// GitHub gitignore template. `fetched` is preserved unmodified as a prefix; -/// the agentic block always follows, separated by a blank line and a -/// boundary comment, so it survives even when the template fetch failed and -/// `fetched` is empty. +/// the agentic block always follows, separated by a blank line, so it +/// survives even when the template fetch failed and `fetched` is empty. pub fn assemble_gitignore(fetched: &str) -> String { let mut out = String::from(fetched); if !out.is_empty() { @@ -53,14 +59,19 @@ pub fn assemble_gitignore(fetched: &str) -> String { } out.push('\n'); } - out.push_str(AGENTIC_GITIGNORE_HEADER); - out.push_str(AGENTIC_GITIGNORE); + out.push_str(&agentic::block()); out } pub trait LanguageTemplate { fn gitignore_name(&self) -> String; - fn boilerplate_files(&self, name: &str, description: &str, owner: &str) -> Vec; + fn boilerplate_files( + &self, + name: &str, + description: &str, + owner: &str, + license: Option<&str>, + ) -> Vec; #[allow(dead_code)] fn default_topics(&self) -> Vec; } @@ -81,12 +92,25 @@ impl RemoteTemplate { name: &str, description: &str, owner: &str, + license: &str, ) -> String { + let module = module_name(name); content .replace("{{name}}", name) .replace("{{description}}", description) .replace("{{github_org}}", owner) .replace("{{github_repo}}", name) + .replace("{{license}}", license) + .replace("{{module}}", &module) + } + + /// The boilerplate language, taken from the cache directory name that + /// [`resolve`] created (`cache_dir()?.join(language)`). + fn language(&self) -> String { + self.cache_dir + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() } fn gitignore_from_toml(&self) -> String { @@ -109,7 +133,15 @@ impl LanguageTemplate for RemoteTemplate { self.gitignore_from_toml() } - fn boilerplate_files(&self, name: &str, description: &str, owner: &str) -> Vec { + fn boilerplate_files( + &self, + name: &str, + description: &str, + owner: &str, + license: Option<&str>, + ) -> Vec { + let language = self.language(); + let spdx = license_spdx_id(license); let mut files = vec![]; for entry in walkdir::WalkDir::new(&self.cache_dir) .into_iter() @@ -127,8 +159,12 @@ impl LanguageTemplate for RemoteTemplate { let Ok(raw) = std::fs::read_to_string(path) else { continue; }; - let content = self.apply_placeholders(&raw, name, description, owner); - files.push(RepoFile { path: rel, content }); + let content = self.apply_placeholders(&raw, name, description, owner, spdx); + let path = self.apply_placeholders(&rel, name, description, owner, spdx); + let Some(content) = adjust_for_no_license(&language, &path, content, license) else { + continue; + }; + files.push(RepoFile { path, content }); } files.sort_by(|a, b| a.path.cmp(&b.path)); files @@ -208,6 +244,204 @@ fn cache_dir() -> Result { Ok(base.join(".ghscaff").join("boilerplate")) } +/// Adjust one already-rendered file for the "no license chosen" case. `None` +/// means the file must be omitted from the init commit; `Some(text)` is the +/// rewritten content. A chosen license leaves every file untouched. +/// +/// In addition, no license means no `LICENSE` file — and for rust no +/// `release.yml` — so the rendered docs stop describing them: every +/// language's `README.md` loses its `## License` section and, for rust, +/// `CONTRIBUTING.md` loses its `## Release process` section plus the rest of +/// its dropped-publish story (the `release.yml` clause in the CI paragraph and +/// the `CARGO_REGISTRY_TOKEN` in `### Required repository secrets`). +/// +/// * rust — the crate cannot be published without a LICENSE, so drop the +/// publish-only release workflow, stop referencing a LICENSE file, and pass +/// `publish-check: false` to the shared rust-ci workflow. +/// * python — drop the `license = …` line from `pyproject.toml`. +fn adjust_for_no_license( + language: &str, + rel: &str, + content: String, + license: Option<&str>, +) -> Option { + if license.is_some() { + return Some(content); + } + let content = match rel { + "README.md" => remove_markdown_section(&content, "## License"), + "CONTRIBUTING.md" if language == "rust" => strip_rust_release_docs(&content), + _ => content, + }; + if language == "rust" { + return match rel { + ".github/workflows/release.yml" => None, + "Cargo.toml" => Some(disable_rust_publish(&content)), + ".github/workflows/ci.yml" => Some(add_publish_check_false(&content)), + _ => Some(content), + }; + } + if language.starts_with("python") && rel == "pyproject.toml" { + return Some(strip_python_license(&content)); + } + Some(content) +} + +/// `license-file = "LICENSE"` would make cargo look for a file that is not +/// created; `publish = false` is the correct marker for an unpublished crate. +fn disable_rust_publish(cargo_toml: &str) -> String { + cargo_toml.replace("license-file = \"LICENSE\"", "publish = false") +} + +/// Remove the `license = …` line from a pyproject.toml. `license-files` is a +/// different key and is left untouched. +fn strip_python_license(pyproject: &str) -> String { + let kept: Vec = pyproject + .lines() + .filter(|line| !is_python_license_line(line)) + .map(str::to_string) + .collect(); + join_lines(kept, pyproject.ends_with('\n')) +} + +/// True for a TOML `license = …` / `license=…` key line, however much +/// whitespace surrounds the `=`. `license-files = […]` is a different key and +/// does not match: the text right after `license` must lead to `=`. +fn is_python_license_line(line: &str) -> bool { + line.trim_start() + .strip_prefix("license") + .is_some_and(|rest| rest.trim_start().starts_with('=')) +} + +/// Add `publish-check: false` to the `rust-ci` job. If the job already has a +/// `with:` block the flag goes under it; otherwise a `with:` block is inserted +/// right after the job's `uses:` line. An existing `publish-check` line is +/// rewritten to `false` — a `publish-check: true` left in place would keep +/// failing the first PR of a crate that cannot be published, which is exactly +/// what this transform exists to prevent. Insertion is scoped to the `rust-ci` +/// job so a later job is never modified, and the file's trailing newline is +/// preserved. +fn add_publish_check_false(ci_yml: &str) -> String { + let trailing = ci_yml.ends_with('\n'); + let mut lines: Vec = ci_yml.lines().map(str::to_string).collect(); + let Some(job) = lines.iter().position(|l| l.trim() == "rust-ci:") else { + return ci_yml.to_string(); + }; + let job_indent = indent_of(&lines[job]); + let end = job_block_end(&lines, job, job_indent); + if set_publish_check_false(&mut lines, job, end) { + return join_lines(lines, trailing); + } + insert_publish_check(&mut lines, job, end, job_indent); + join_lines(lines, trailing) +} + +/// Rewrite an existing `publish-check:` line inside the job to `false`, +/// keeping its indentation; `false` when the job has no such line yet (the +/// caller then inserts one). +fn set_publish_check_false(lines: &mut [String], job: usize, end: usize) -> bool { + let Some(i) = (job..end).find(|&i| lines[i].trim_start().starts_with("publish-check:")) else { + return false; + }; + let indent = indent_of(&lines[i]); + lines[i] = format!("{}publish-check: false", " ".repeat(indent)); + true +} + +fn insert_publish_check(lines: &mut Vec, job: usize, end: usize, job_indent: usize) { + let child = " ".repeat(job_indent + 2); + let leaf = " ".repeat(job_indent + 4); + let flag = format!("{leaf}publish-check: false"); + let with_line = format!("{child}with:"); + match (job..end).find(|&i| lines[i] == with_line) { + Some(i) => lines.insert(i + 1, flag), + None => { + let anchor = (job..end) + .find(|&i| lines[i].trim_start().starts_with("uses:")) + .unwrap_or(job); + lines.insert(anchor + 1, flag); + lines.insert(anchor + 1, with_line); + } + } +} + +/// Index one past the last line of the job starting at `job`: the next +/// non-blank line indented at or below the job's own indent, or end of file. +fn job_block_end(lines: &[String], job: usize, job_indent: usize) -> usize { + (job + 1..lines.len()) + .find(|&i| { + let t = lines[i].trim_start(); + !t.is_empty() && indent_of(&lines[i]) <= job_indent + }) + .unwrap_or(lines.len()) +} + +fn indent_of(line: &str) -> usize { + line.len() - line.trim_start().len() +} + +/// `str::lines()` drops the final newline, so it is re-added when the source +/// had one — otherwise the rewritten file would lose it. +fn join_lines(lines: Vec, trailing_newline: bool) -> String { + let mut out = lines.join("\n"); + if trailing_newline { + out.push('\n'); + } + out +} + +/// Remove a markdown section: the line equal to `heading` (after trimming +/// trailing whitespace), the body up to its last non-blank line — or to the +/// end of file when no `## ` heading follows — and the single blank line +/// directly above the heading if there is one. Blank lines separating the body +/// from the next `## ` heading are kept. Returns `text` unchanged when the +/// heading is absent. +fn remove_markdown_section(text: &str, heading: &str) -> String { + let lines: Vec<&str> = text.lines().collect(); + let Some(start) = lines.iter().position(|l| l.trim_end() == heading) else { + return text.to_string(); + }; + let body = start + 1; + let next = (body..lines.len()) + .find(|&i| lines[i].starts_with("## ")) + .unwrap_or(lines.len()); + // Keep the blank lines that separate the body from the next heading. + let end = if next < lines.len() { + (body..next) + .rev() + .find(|&i| !lines[i].trim_end().is_empty()) + .map_or(body, |i| i + 1) + } else { + next + }; + let cut = usize::from(start > 0 && lines[start - 1].trim_end().is_empty()); + let mut kept: Vec = lines[..start - cut] + .iter() + .copied() + .map(str::to_string) + .collect(); + kept.extend(lines[end..].iter().copied().map(str::to_string)); + join_lines(kept, text.ends_with('\n')) +} + +/// CONTRIBUTING.md tells contributors how the crate is published in three +/// places, all of which become false for an unpublished crate: the `## Release +/// process` section describing the dropped `release.yml` workflow, the "and +/// automated releases (`.github/workflows/release.yml`)" clause in the CI +/// paragraph, and the `### Required repository secrets` subsection whose only +/// row is the `CARGO_REGISTRY_TOKEN` publish token. Trim the two sections and +/// the dead clause, keeping the true ci.yml sentence and the section heading. +fn strip_rust_release_docs(contributing: &str) -> String { + let trimmed = remove_markdown_section( + &remove_markdown_section(contributing, "## Release process"), + "### Required repository secrets", + ); + trimmed.replace( + " and automated releases (`.github/workflows/release.yml`)", + "", + ) +} + #[allow(dead_code)] pub fn apply_placeholders(dir: &Path, name: &str, description: &str, author: &str) -> Result<()> { for entry in walkdir::WalkDir::new(dir) @@ -342,7 +576,7 @@ mod tests { #[test] fn test_resolve_rust_template_embedded() { let tmpl = RustTemplate; - let files = tmpl.boilerplate_files("my-app", "A test app", "myorg"); + let files = tmpl.boilerplate_files("my-app", "A test app", "myorg", Some("MIT")); assert!(!files.is_empty()); } @@ -387,6 +621,27 @@ mod tests { } } + /// With nothing fetched (the fetch failed or there is no template), the + /// assembled `.gitignore` is exactly the block `gitkit ignore add agentic` + /// writes from the same registry snapshot. + #[test] + fn test_assemble_gitignore_empty_fetched_is_exactly_the_gitkit_block() { + assert_eq!( + assemble_gitignore(""), + include_str!("../../tests/fixtures/agentic-block.txt") + ); + } + + #[test] + fn test_assemble_gitignore_nonempty_contains_the_gitkit_block() { + let result = assemble_gitignore("target/\n"); + assert!(result.starts_with("target/"), "{}", result); + assert!( + result.contains(include_str!("../../tests/fixtures/agentic-block.txt")), + "the agentic block must follow the fetched template verbatim" + ); + } + #[test] fn test_repo_file_struct() { let file = RepoFile { @@ -498,7 +753,7 @@ mod tests { #[test] fn test_resolve_rust_template_files_content() { let tmpl = RustTemplate; - let files = tmpl.boilerplate_files("my-app", "A test app", "myorg"); + let files = tmpl.boilerplate_files("my-app", "A test app", "myorg", Some("MIT")); let names: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); assert!(names.contains(&"Cargo.toml")); assert!(names.contains(&"src/main.rs")); @@ -520,6 +775,7 @@ mod tests { "myapp", "My App", "myorg", + "", ); assert_eq!(result, "Hello myapp, welcome to My App by myorg/myapp"); } @@ -529,7 +785,7 @@ mod tests { let tmpl = RemoteTemplate { cache_dir: tempfile::tempdir().unwrap().keep(), }; - let result = tmpl.apply_placeholders("plain text", "a", "b", "c"); + let result = tmpl.apply_placeholders("plain text", "a", "b", "c", ""); assert_eq!(result, "plain text"); } @@ -538,7 +794,7 @@ mod tests { let tmpl = RemoteTemplate { cache_dir: tempfile::tempdir().unwrap().keep(), }; - let result = tmpl.apply_placeholders("{{name}} and {{name}}", "x", "y", "z"); + let result = tmpl.apply_placeholders("{{name}} and {{name}}", "x", "y", "z", ""); assert_eq!(result, "x and x"); } @@ -598,7 +854,7 @@ mod tests { std::fs::write(cache.join("PLACEHOLDERS.md"), "docs").unwrap(); let tmpl = RemoteTemplate { cache_dir: cache }; - let files = tmpl.boilerplate_files("myrepo", "My description", "myorg"); + let files = tmpl.boilerplate_files("myrepo", "My description", "myorg", Some("MIT")); let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); assert!(paths.contains(&"Cargo.toml")); assert!(paths.contains(&"README.md")); @@ -615,7 +871,7 @@ mod tests { let cache = dir.path().join("empty"); std::fs::create_dir_all(&cache).unwrap(); let tmpl = RemoteTemplate { cache_dir: cache }; - let files = tmpl.boilerplate_files("repo", "desc", "owner"); + let files = tmpl.boilerplate_files("repo", "desc", "owner", Some("MIT")); assert!(files.is_empty()); } @@ -629,7 +885,7 @@ mod tests { std::fs::write(src_dir.join("main.rs"), "fn main() {}\n").unwrap(); let tmpl = RemoteTemplate { cache_dir: cache }; - let files = tmpl.boilerplate_files("repo", "desc", "owner"); + let files = tmpl.boilerplate_files("repo", "desc", "owner", Some("MIT")); let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); assert!(paths.contains(&"Cargo.toml")); assert!(paths.contains(&"src/main.rs")); @@ -773,7 +1029,7 @@ required = false std::fs::write(cache.join("binary.bin"), [0xFF, 0xFE, 0xFD]).unwrap(); let tmpl = RemoteTemplate { cache_dir: cache }; - let files = tmpl.boilerplate_files("repo", "desc", "owner"); + let files = tmpl.boilerplate_files("repo", "desc", "owner", Some("MIT")); // Only text.txt should be included; binary.bin skipped assert_eq!(files.len(), 1); assert_eq!(files[0].path, "text.txt"); @@ -836,6 +1092,7 @@ required = false "repo", "desc", "myorg", + "", ); assert_eq!(result, "org=myorg repo=repo"); } @@ -845,7 +1102,7 @@ required = false let tmpl = RemoteTemplate { cache_dir: tempfile::tempdir().unwrap().keep(), }; - let result = tmpl.apply_placeholders("{{name}}", "", "", ""); + let result = tmpl.apply_placeholders("{{name}}", "", "", "", ""); assert_eq!(result, ""); } @@ -854,7 +1111,7 @@ required = false let tmpl = RemoteTemplate { cache_dir: tempfile::tempdir().unwrap().keep(), }; - let result = tmpl.apply_placeholders("{{name}}", "my\"app", "desc", "owner"); + let result = tmpl.apply_placeholders("{{name}}", "my\"app", "desc", "owner", ""); assert_eq!(result, "my\"app"); } @@ -892,7 +1149,7 @@ required = false std::fs::write(cache.join("file.txt"), "original content {{name}}").unwrap(); let tmpl = RemoteTemplate { cache_dir: cache }; - let files = tmpl.boilerplate_files("replaced", "", ""); + let files = tmpl.boilerplate_files("replaced", "", "", Some("MIT")); assert_eq!(files[0].content, "original content replaced"); } @@ -923,7 +1180,7 @@ required = false #[test] fn test_rust_template_files_sorted() { let tmpl = RustTemplate; - let files = tmpl.boilerplate_files("app", "desc", "owner"); + let files = tmpl.boilerplate_files("app", "desc", "owner", Some("MIT")); let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); // RustTemplate returns files in a specific order, not necessarily alphabetical assert_eq!(paths.len(), 3); @@ -942,7 +1199,7 @@ required = false std::fs::write(cache.join("m_middle.txt"), "m").unwrap(); let tmpl = RemoteTemplate { cache_dir: cache }; - let files = tmpl.boilerplate_files("repo", "desc", "owner"); + let files = tmpl.boilerplate_files("repo", "desc", "owner", Some("MIT")); let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); let mut sorted = paths.clone(); sorted.sort(); @@ -1027,7 +1284,7 @@ required = false std::fs::write(benches.join("bench.rs"), "#[bench] fn b() {}").unwrap(); let tmpl = RemoteTemplate { cache_dir: cache }; - let files = tmpl.boilerplate_files("repo", "desc", "owner"); + let files = tmpl.boilerplate_files("repo", "desc", "owner", Some("MIT")); let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); assert!(paths.contains(&"Cargo.toml")); assert!(paths.contains(&"src/lib.rs")); @@ -1103,12 +1360,13 @@ required = false cache_dir: tempfile::tempdir().unwrap().keep(), }; let result = tmpl.apply_placeholders( - "{{name}}/{{description}}/{{github_org}}/{{github_repo}}", - "n", + "{{name}}/{{description}}/{{github_org}}/{{github_repo}}/{{license}}/{{module}}", + "my-app", "d", "o", + "MIT", ); - assert_eq!(result, "n/d/o/n"); + assert_eq!(result, "my-app/d/o/my-app/MIT/my_app"); } #[test] @@ -1116,7 +1374,7 @@ required = false let tmpl = RemoteTemplate { cache_dir: tempfile::tempdir().unwrap().keep(), }; - let result = tmpl.apply_placeholders("{{name}}{{description}}", "a", "b", "c"); + let result = tmpl.apply_placeholders("{{name}}{{description}}", "a", "b", "c", ""); assert_eq!(result, "ab"); } @@ -1125,7 +1383,7 @@ required = false let tmpl = RemoteTemplate { cache_dir: tempfile::tempdir().unwrap().keep(), }; - let result = tmpl.apply_placeholders("{{name}}ly is {{name}}s", "x", "y", "z"); + let result = tmpl.apply_placeholders("{{name}}ly is {{name}}s", "x", "y", "z", ""); assert_eq!(result, "xly is xs"); } @@ -1172,7 +1430,7 @@ required = false std::fs::write(cache.join(".hidden"), "secret").unwrap(); std::fs::write(cache.join("visible.txt"), "content").unwrap(); let tmpl = RemoteTemplate { cache_dir: cache }; - let files = tmpl.boilerplate_files("repo", "desc", "owner"); + let files = tmpl.boilerplate_files("repo", "desc", "owner", Some("MIT")); let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); assert!(paths.contains(&"visible.txt")); } @@ -1182,7 +1440,7 @@ required = false let tmpl = RemoteTemplate { cache_dir: tempfile::tempdir().unwrap().keep(), }; - let result = tmpl.apply_placeholders("{{name}}{{name}}{{name}}", "X", "", ""); + let result = tmpl.apply_placeholders("{{name}}{{name}}{{name}}", "X", "", "", ""); assert_eq!(result, "XXX"); } @@ -1233,10 +1491,862 @@ required = false std::fs::write(a_dir.join("a.txt"), "a").unwrap(); std::fs::write(b_dir.join("b.txt"), "b").unwrap(); let tmpl = RemoteTemplate { cache_dir: cache }; - let files = tmpl.boilerplate_files("repo", "desc", "owner"); + let files = tmpl.boilerplate_files("repo", "desc", "owner", Some("MIT")); let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); let mut sorted = paths.clone(); sorted.sort(); assert_eq!(paths, sorted); } + + // ── license choice: SPDX mapping, notice gate, no-license transforms ── + + /// The rust boilerplate as it exists upstream (`license-file = "LICENSE"` + /// and a publish-only release workflow). + const FIXTURE_CARGO_TOML: &str = r#"[package] +name = "{{name}}" +version = "0.1.0" +edition = "2021" +description = "{{description}}" +license-file = "LICENSE" +repository = "https://github.com/{{github_org}}/{{github_repo}}" +readme = "README.md" + +[dependencies] +"#; + + const FIXTURE_CI_YML: &str = r#"name: CI + +on: + pull_request: + workflow_dispatch: + +jobs: + rust-ci: + uses: UniverLab/workflows/.github/workflows/rust-ci.yml@main +"#; + + const FIXTURE_RELEASE_YML: &str = r#"name: Release + +on: + push: + tags: + - "v*" + +jobs: + release: + uses: UniverLab/workflows/.github/workflows/rust-release.yml@main +"#; + + /// Build a throwaway boilerplate cache whose directory is named after the + /// language (that name is how `RemoteTemplate::language()` reads it) and + /// return it with the `TempDir` that owns it. + fn fixture(language: &str, files: &[(&str, &str)]) -> (tempfile::TempDir, RemoteTemplate) { + let dir = tempfile::tempdir().unwrap(); + let cache = dir.path().join(language); + std::fs::create_dir_all(&cache).unwrap(); + for (rel, content) in files { + let target = cache.join(rel); + std::fs::create_dir_all(target.parent().unwrap()).unwrap(); + std::fs::write(target, content).unwrap(); + } + let tmpl = RemoteTemplate { cache_dir: cache }; + (dir, tmpl) + } + + fn rust_fixture() -> (tempfile::TempDir, RemoteTemplate) { + fixture( + "rust", + &[ + ("Cargo.toml", FIXTURE_CARGO_TOML), + (".github/workflows/ci.yml", FIXTURE_CI_YML), + (".github/workflows/release.yml", FIXTURE_RELEASE_YML), + ("src/main.rs", "fn main() {}\n"), + ("README.md", "# {{name}}\n\n{{description}}\n"), + ], + ) + } + + fn find<'a>(files: &'a [RepoFile], path: &str) -> &'a str { + files + .iter() + .find(|f| f.path == path) + .map(|f| f.content.as_str()) + .unwrap_or_else(|| panic!("{path} missing from rendered files")) + } + + #[test] + fn license_spdx_id_maps_wizard_choices() { + assert_eq!(license_spdx_id(None), ""); + assert_eq!(license_spdx_id(Some("MIT")), "MIT"); + assert_eq!(license_spdx_id(Some("Apache-2.0")), "Apache-2.0"); + assert_eq!(license_spdx_id(Some("GPL-3.0")), "GPL-3.0-only"); + } + + #[test] + fn is_rust_without_license_truth_table() { + assert!(is_rust_without_license("rust", None)); + assert!(!is_rust_without_license("rust", Some("MIT"))); + assert!(!is_rust_without_license("python-module", None)); + assert!(!is_rust_without_license("go", Some("MIT"))); + } + + #[test] + fn no_license_notice_is_the_documented_text() { + assert_eq!( + NO_LICENSE_NOTICE, + "No license chosen: the crate is not publishable (publish = false) and no release workflow was created. Add a LICENSE and restore them to publish." + ); + } + + #[test] + fn disable_rust_publish_replaces_license_file() { + let out = disable_rust_publish(FIXTURE_CARGO_TOML); + assert!(out.contains("publish = false")); + assert!(!out.contains("license-file")); + assert!(out.contains("name = \"{{name}}\"")); + assert!(out.ends_with("[dependencies]\n")); + } + + #[test] + fn strip_python_license_removes_license_line() { + let input = "[project]\nname = \"demo\"\nlicense = \"MIT\"\nlicense-files = [\"LICENSE\"]\nversion = \"1\"\n"; + let out = strip_python_license(input); + assert!(!out.contains("license =")); + assert!(!out.contains("license=\"")); + assert!(out.contains("license-files = [\"LICENSE\"]")); + assert_eq!( + out, + "[project]\nname = \"demo\"\nlicense-files = [\"LICENSE\"]\nversion = \"1\"\n" + ); + } + + #[test] + fn strip_python_license_handles_compact_and_trailing_newline() { + let out = strip_python_license("[project]\nlicense=\"MIT\"\nname = \"demo\""); + assert_eq!(out, "[project]\nname = \"demo\""); + } + + #[test] + fn strip_python_license_tolerates_extra_whitespace_around_equals() { + let input = "[project]\nname = \"demo\"\nlicense = \"MIT\"\nlicense\t= \"GPL\"\n"; + assert_eq!(strip_python_license(input), "[project]\nname = \"demo\"\n"); + } + + #[test] + fn add_publish_check_false_inserts_with_block() { + let out = add_publish_check_false(FIXTURE_CI_YML); + assert!(out.contains(" with:\n publish-check: false")); + assert!(out.ends_with("publish-check: false\n")); + assert!(!out.contains("with:\n with:")); + } + + #[test] + fn add_publish_check_false_uses_existing_with_block() { + let input = "jobs:\n rust-ci:\n uses: org/ci.yml@main\n with:\n args: --all\n"; + let out = add_publish_check_false(input); + assert_eq!( + out, + "jobs:\n rust-ci:\n uses: org/ci.yml@main\n with:\n publish-check: false\n args: --all\n" + ); + } + + #[test] + fn add_publish_check_false_rewrites_existing_publish_check() { + // A `publish-check: true` would keep failing the first PR of an + // unpublished crate, so it must be corrected, not preserved. + let input = + "jobs:\n rust-ci:\n uses: org/ci.yml@main\n with:\n publish-check: true\n"; + assert_eq!( + add_publish_check_false(input), + "jobs:\n rust-ci:\n uses: org/ci.yml@main\n with:\n publish-check: false\n" + ); + } + + #[test] + fn add_publish_check_false_is_idempotent_when_already_false() { + let input = + "jobs:\n rust-ci:\n uses: org/ci.yml@main\n with:\n publish-check: false\n"; + assert_eq!(add_publish_check_false(input), input); + } + + #[test] + fn add_publish_check_false_without_rust_ci_is_unchanged() { + let input = "jobs:\n python-ci:\n uses: org/ci.yml@main\n"; + assert_eq!(add_publish_check_false(input), input); + } + + #[test] + fn add_publish_check_false_stops_at_next_job() { + let input = "jobs:\n rust-ci:\n uses: org/ci.yml@main\n other:\n uses: org/other.yml@main\n"; + let out = add_publish_check_false(input); + assert_eq!( + out, + "jobs:\n rust-ci:\n uses: org/ci.yml@main\n with:\n publish-check: false\n other:\n uses: org/other.yml@main\n" + ); + } + + /// A blank line INSIDE the `rust-ci` job does not end it: the `with:` block + /// below the blank line still belongs to the job and its stale + /// `publish-check: true` must be rewritten, not duplicated. + #[test] + fn add_publish_check_false_reaches_past_an_inner_blank_line() { + let input = "jobs:\n rust-ci:\n uses: org/ci.yml@main\n\n with:\n publish-check: true\n other:\n uses: org/other.yml@main\n"; + assert_eq!( + add_publish_check_false(input), + "jobs:\n rust-ci:\n uses: org/ci.yml@main\n\n with:\n publish-check: false\n other:\n uses: org/other.yml@main\n" + ); + } + + /// The inserted `with:` key sits `job_indent + 2` under the job key, so a + /// job indented deeper than the usual two spaces still yields valid YAML. + #[test] + fn add_publish_check_false_indents_insertion_under_a_deep_job() { + let input = "name: CI\non: push\njobs:\n rust-ci:\n uses: org/ci.yml@main\n"; + assert_eq!( + add_publish_check_false(input), + "name: CI\non: push\njobs:\n rust-ci:\n uses: org/ci.yml@main\n with:\n publish-check: false\n" + ); + } + + /// Only `pyproject.toml` of a python project loses its license line: a + /// python project's other files and a non-python project's pyproject.toml + /// are left untouched (`&&`, not `||`). + #[test] + fn adjust_for_no_license_touches_only_python_pyproject_files() { + let body = "name = \"demo\"\nlicense = \"MIT\"\n"; + for (lang, rel) in [("python-module", "main.py"), ("go", "pyproject.toml")] { + assert_eq!( + adjust_for_no_license(lang, rel, body.to_string(), None).as_deref(), + Some(body), + "{lang}/{rel} must keep its license line untouched" + ); + } + } + + #[test] + fn adjust_for_no_license_routing() { + let cargo = adjust_for_no_license("rust", "Cargo.toml", FIXTURE_CARGO_TOML.into(), None) + .expect("Cargo.toml is kept"); + assert!(cargo.contains("publish = false")); + assert!(adjust_for_no_license( + "rust", + ".github/workflows/release.yml", + "jobs:".into(), + None + ) + .is_none()); + assert_eq!( + adjust_for_no_license("rust", "src/main.rs", "fn main() {}".into(), None).as_deref(), + Some("fn main() {}") + ); + for lang in ["python-module", "python-fastapi"] { + let out = adjust_for_no_license( + lang, + "pyproject.toml", + "[project]\nlicense = \"MIT\"\n".into(), + None, + ) + .expect("pyproject.toml is kept"); + assert_eq!(out, "[project]\n"); + } + assert_eq!( + adjust_for_no_license("go", "go.mod", "module x\n".into(), None).as_deref(), + Some("module x\n") + ); + } + + #[test] + fn adjust_for_no_license_with_chosen_license_is_identity() { + assert_eq!( + adjust_for_no_license("rust", "Cargo.toml", FIXTURE_CARGO_TOML.into(), Some("MIT")) + .as_deref(), + Some(FIXTURE_CARGO_TOML) + ); + assert_eq!( + adjust_for_no_license( + "rust", + ".github/workflows/release.yml", + "jobs:\n".into(), + Some("MIT") + ) + .as_deref(), + Some("jobs:\n") + ); + } + + #[test] + fn remote_template_apply_placeholders_replaces_license() { + let (_dir, tmpl) = fixture("rust", &[]); + assert_eq!( + tmpl.apply_placeholders("license = \"{{license}}\"", "n", "d", "o", "MIT"), + "license = \"MIT\"" + ); + assert_eq!( + tmpl.apply_placeholders("license = \"{{license}}\"", "n", "d", "o", ""), + "license = \"\"" + ); + } + + #[test] + fn render_rust_fixture_with_mit_keeps_publish_files() { + let (_dir, tmpl) = rust_fixture(); + let files = tmpl.boilerplate_files("myrepo", "My description", "myorg", Some("MIT")); + let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); + assert!(paths.contains(&".github/workflows/release.yml")); + + let cargo = find(&files, "Cargo.toml"); + assert!(cargo.contains("license-file = \"LICENSE\"")); + assert!(!cargo.contains("publish = false")); + assert!(cargo.contains("name = \"myrepo\"")); + assert!(cargo.contains("https://github.com/myorg/myrepo")); + + let ci = find(&files, ".github/workflows/ci.yml"); + assert!(!ci.contains("publish-check")); + assert_eq!(ci, FIXTURE_CI_YML); + } + + #[test] + fn render_rust_fixture_without_license_disables_publish() { + let (_dir, tmpl) = rust_fixture(); + let files = tmpl.boilerplate_files("myrepo", "My description", "myorg", None); + let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); + assert!(!paths.contains(&".github/workflows/release.yml")); + + let cargo = find(&files, "Cargo.toml"); + assert!(cargo.contains("publish = false")); + assert!(!cargo.contains("license-file")); + + assert!(find(&files, ".github/workflows/ci.yml") + .contains(" with:\n publish-check: false")); + assert_eq!(find(&files, "README.md"), "# myrepo\n\nMy description\n"); + assert_eq!(find(&files, "src/main.rs"), "fn main() {}\n"); + } + + #[test] + fn render_python_fixture_substitutes_license_placeholder() { + let (_dir, tmpl) = fixture( + "python-module", + &[( + "pyproject.toml", + "[project]\nname = \"{{name}}\"\nlicense = \"{{license}}\"\n", + )], + ); + let files = tmpl.boilerplate_files("demo", "d", "org", Some("Apache-2.0")); + assert_eq!( + find(&files, "pyproject.toml"), + "[project]\nname = \"demo\"\nlicense = \"Apache-2.0\"\n" + ); + } + + #[test] + fn render_python_fixture_without_license_has_no_license_line() { + let (_dir, tmpl) = fixture( + "python-module", + &[( + "pyproject.toml", + "[project]\nname = \"{{name}}\"\nlicense = \"{{license}}\"\n", + )], + ); + let files = tmpl.boilerplate_files("demo", "d", "org", None); + assert_eq!( + find(&files, "pyproject.toml"), + "[project]\nname = \"demo\"\n" + ); + assert!(!find(&files, "pyproject.toml") + .lines() + .any(|l| l.starts_with("license"))); + } + + #[test] + fn render_python_current_boilerplate_keeps_hardcoded_mit() { + let (_dir, tmpl) = fixture( + "python-module", + &[( + "pyproject.toml", + "[project]\nname = \"{{name}}\"\nlicense = \"MIT\"\n", + )], + ); + let files = tmpl.boilerplate_files("demo", "d", "org", Some("Apache-2.0")); + assert_eq!( + find(&files, "pyproject.toml"), + "[project]\nname = \"demo\"\nlicense = \"MIT\"\n" + ); + } + + #[test] + fn render_python_fastapi_fixture_strips_license() { + let (_dir, tmpl) = fixture( + "python-fastapi", + &[( + "pyproject.toml", + "[project]\nlicense = \"{{license}}\"\nversion = \"0.1.0\"\n", + )], + ); + let files = tmpl.boilerplate_files("api", "d", "org", None); + assert_eq!( + find(&files, "pyproject.toml"), + "[project]\nversion = \"0.1.0\"\n" + ); + } + + #[test] + fn module_name_converts_kebab_to_snake() { + assert_eq!(module_name("astro-denoise"), "astro_denoise"); + assert_eq!(module_name("my-app"), "my_app"); + assert_eq!(module_name("UPPER-Case"), "upper_case"); + assert_eq!(module_name("already_snake"), "already_snake"); + assert_eq!(module_name("a-b-c"), "a_b_c"); + } + + #[test] + fn remote_template_apply_placeholders_replaces_module() { + let (_dir, tmpl) = fixture("python-module", &[]); + assert_eq!( + tmpl.apply_placeholders("from {{module}}.core import x", "astro-denoise", "", "", ""), + "from astro_denoise.core import x" + ); + } + + #[test] + fn render_python_module_fixture_substitutes_path_and_entry_point() { + let (_dir, tmpl) = fixture( + "python-module", + &[ + ("src/{{module}}/__init__.py", ""), + ( + "pyproject.toml", + "[project]\nname = \"{{name}}\"\n[project.scripts]\n{{name}} = \"{{module}}.cli:main\"\n", + ), + ], + ); + let files = tmpl.boilerplate_files("astro-denoise", "Denoise spectra", "univerlab", None); + let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); + assert!( + paths.contains(&"src/astro_denoise/__init__.py"), + "paths: {paths:?}" + ); + assert!( + !paths.iter().any(|p| p.contains("{{module}}")), + "no path should still contain a placeholder: {paths:?}" + ); + let pyproject = find(&files, "pyproject.toml"); + assert!(pyproject.contains("\"astro_denoise.cli:main\"")); + assert!(!pyproject.contains("{{module}}")); + } + + #[test] + fn render_paths_without_placeholders_are_unchanged() { + let (_dir, tmpl) = rust_fixture(); + let files = tmpl.boilerplate_files("my-cool-app", "desc", "org", Some("MIT")); + let paths: Vec<&str> = files.iter().map(|f| f.path.as_str()).collect(); + assert_eq!( + paths, + &[ + ".github/workflows/ci.yml", + ".github/workflows/release.yml", + "Cargo.toml", + "README.md", + "src/main.rs", + ] + ); + } + + /// FR5 evidence — prints the rendered file list and the license-relevant + /// lines for MIT and for no license, from throwaway fixtures only. No + /// repository is created and no network call is made. + #[test] + fn evidence_license_rendering_rust_and_python() { + let (_d1, rust) = rust_fixture(); + let (_d2, python) = fixture( + "python-module", + &[( + "pyproject.toml", + "[project]\nname = \"{{name}}\"\nlicense = \"{{license}}\"\n", + )], + ); + + let mit = rust.boilerplate_files("demo", "A demo", "univerlab", Some("MIT")); + println!( + "[evidence] rust/MIT files: {}", + mit.iter() + .map(|f| f.path.as_str()) + .collect::>() + .join(", ") + ); + println!( + "[evidence] rust/MIT Cargo.toml: {}", + find(&mit, "Cargo.toml") + .lines() + .find(|l| l.starts_with("license") || l.starts_with("publish")) + .unwrap_or("") + ); + println!( + "[evidence] rust/MIT ci.yml: {}; release.yml: present", + if find(&mit, ".github/workflows/ci.yml").contains("publish-check") { + "publish-check present" + } else { + "no publish-check" + } + ); + + let none = rust.boilerplate_files("demo", "A demo", "univerlab", None); + println!( + "[evidence] rust/None files: {}", + none.iter() + .map(|f| f.path.as_str()) + .collect::>() + .join(", ") + ); + println!( + "[evidence] rust/None Cargo.toml: {}", + find(&none, "Cargo.toml") + .lines() + .find(|l| l.starts_with("license") || l.starts_with("publish")) + .unwrap_or("") + ); + println!( + "[evidence] rust/None ci.yml: {}", + find(&none, ".github/workflows/ci.yml") + .lines() + .filter(|l| l.trim() == "with:" || l.contains("publish-check")) + .collect::>() + .join(" / ") + ); + println!( + "[evidence] rust/None release.yml: {}", + if none + .iter() + .any(|f| f.path == ".github/workflows/release.yml") + { + "present" + } else { + "absent" + } + ); + + for license in [Some("Apache-2.0"), None] { + let label = license.unwrap_or("None"); + let files = python.boilerplate_files("demo", "A demo", "univerlab", license); + println!( + "[evidence] python/{label} pyproject.toml: {}", + find(&files, "pyproject.toml") + .lines() + .find(|l| l.starts_with("license")) + .unwrap_or("no license line") + ); + } + + // The printed lines above are also asserted, so the evidence cannot + // drift from behaviour. + assert!(mit + .iter() + .any(|f| f.path == ".github/workflows/release.yml")); + assert!(find(&mit, "Cargo.toml").contains("license-file = \"LICENSE\"")); + assert!(!find(&mit, ".github/workflows/ci.yml").contains("publish-check")); + assert!(!none + .iter() + .any(|f| f.path == ".github/workflows/release.yml")); + assert!(find(&none, "Cargo.toml").contains("publish = false")); + assert!(!find(&none, "Cargo.toml").contains("license-file")); + assert!(find(&none, ".github/workflows/ci.yml") + .contains(" with:\n publish-check: false")); + } + + // ── remove_markdown_section + doc trimming without a license ── + + /// Mirrors the real boilerplate README: `## License` is the last `## ` + /// section, preceded by a blank line. + const FIXTURE_README_LICENSE: &str = "# {{name}}\n\n{{description}}\n\n## Getting started\n\nRun `cargo run`.\n\n## License\n\nThis project is licensed under the MIT License — see LICENSE for details.\n"; + + /// Mirrors the real rust CONTRIBUTING outside the `{{...}}` placeholders: + /// the crate-publish story in `## Release process`, again in the CI + /// paragraph, and the `CARGO_REGISTRY_TOKEN` story in `### Required + /// repository secrets` — all of which stop being true without a license. + const FIXTURE_CONTRIBUTING_RELEASE: &str = concat!( + "# Contributing to {{name}}\n\nThank you for your interest in contributing!\n", + "\n## Development workflow\n\nFork, branch, PR.\n", + "\n## CI/CD and required secrets\n", + "\nThis project uses GitHub Actions for CI (`.github/workflows/ci.yml`)", + " and automated releases (`.github/workflows/release.yml`).\n", + "\n### Required repository secrets\n", + "\n| Secret | Description | Where to get it |\n|---|---|---|\n", + "| `CARGO_REGISTRY_TOKEN` | API token to publish crates to [crates.io](https://crates.io)", + " | [crates.io/me](https://crates.io/me) → API Tokens → New Token |\n", + "\n> **Tip:** If you use [ghscaff](https://github.com/UniverLab/ghscaff), you can run", + " `ghscaff apply` to configure missing secrets interactively, or set the env var", + " before running:\n> ```bash\n> export CARGO_REGISTRY_TOKEN=\n", + "> ghscaff apply\n> ```\n", + "\n## Release process\n\nReleases are automated via the `release.yml` workflow.\n", + "\nThe workflow builds binaries and publishes to crates.io.\n", + "\n## Code style\n\nRun `cargo fmt`.\n" + ); + + #[test] + fn remove_markdown_section_removes_middle_section() { + // The blank line above the heading leaves with the section; the + // blank line separating the body from the next heading stays. + assert_eq!( + remove_markdown_section("top\n\n## A\nbody\nmore\n\n## B\ntail\n", "## A"), + "top\n\n## B\ntail\n" + ); + } + + #[test] + fn remove_markdown_section_removes_end_of_file_section() { + assert_eq!( + remove_markdown_section("keep\n\n## License\n\nlicensed text\n", "## License"), + "keep\n" + ); + assert_eq!( + remove_markdown_section("keep\n\n## A\nbody", "## A"), + "keep" + ); + } + + /// With no `## ` heading following, the section runs to the end of file: + /// blank lines after the last body line leave with it, they are not kept + /// as the new tail of the document. + #[test] + fn remove_markdown_section_end_of_file_section_drops_trailing_blank_lines() { + assert_eq!( + remove_markdown_section("keep\n\n## License\n\nlicensed text\n\n\n", "## License"), + "keep\n" + ); + } + + #[test] + fn remove_markdown_section_heading_at_first_line_keeps_no_blank() { + assert_eq!(remove_markdown_section("## A\nbody", "## A"), ""); + } + + #[test] + fn remove_markdown_section_keeps_non_blank_line_above_heading() { + assert_eq!( + remove_markdown_section("keep\n## A\nbody\n", "## A"), + "keep\n" + ); + } + + #[test] + fn remove_markdown_section_absent_heading_is_unchanged() { + let text = "# Title\n\n## Other\n"; + assert_eq!(remove_markdown_section(text, "## License"), text); + } + + #[test] + fn remove_markdown_section_does_not_match_deeper_subsection() { + // `### License` is neither the heading nor a section end, so nothing + // is removed. + let text = "## Foo\n\n### License\n\nx\n"; + assert_eq!(remove_markdown_section(text, "## License"), text); + // Trailing whitespace on the heading line is trimmed before matching. + assert_eq!( + remove_markdown_section("keep\n\n## License \nbody\n", "## License"), + "keep\n" + ); + } + + fn docs_fixture(language: &str) -> (tempfile::TempDir, RemoteTemplate) { + fixture( + language, + &[ + ("README.md", FIXTURE_README_LICENSE), + ("CONTRIBUTING.md", FIXTURE_CONTRIBUTING_RELEASE), + ], + ) + } + + #[test] + fn render_rust_docs_fixture_without_license_drops_license_and_release_sections() { + let (_dir, tmpl) = docs_fixture("rust"); + let files = tmpl.boilerplate_files("myrepo", "My description", "myorg", None); + + let readme = find(&files, "README.md"); + assert!(!readme.contains("## License")); + assert!(!readme.contains("licensed under")); + assert!(readme.contains("## Getting started")); + + let contributing = find(&files, "CONTRIBUTING.md"); + assert!(!contributing.contains("## Release process")); + assert!(!contributing.contains("release.yml")); + assert!(!contributing.contains("automated releases")); + assert!(!contributing.contains("CARGO_REGISTRY_TOKEN")); + assert!(!contributing.contains("Required repository secrets")); + assert!(!contributing.contains("crates.io")); + assert!(contributing.contains("## Development workflow")); + assert!(contributing.contains("## CI/CD and required secrets")); + assert!(contributing + .contains("This project uses GitHub Actions for CI (`.github/workflows/ci.yml`).")); + assert!(contributing.contains("## Code style")); + assert!(contributing.contains("Fork, branch, PR.")); + // Every remaining heading stays separated from the line above it by + // exactly one blank line. + assert!(contributing.contains("Fork, branch, PR.\n\n## CI/CD and required secrets")); + assert!(contributing.contains("ci.yml`).\n\n## Code style")); + assert!(!contributing.contains("\n\n\n")); + } + + #[test] + fn render_rust_docs_fixture_with_mit_keeps_both_sections() { + let (_dir, tmpl) = docs_fixture("rust"); + let files = tmpl.boilerplate_files("myrepo", "My description", "myorg", Some("MIT")); + assert_eq!( + find(&files, "README.md"), + "# myrepo\n\nMy description\n\n## Getting started\n\nRun `cargo run`.\n\n## License\n\nThis project is licensed under the MIT License — see LICENSE for details.\n" + ); + assert_eq!( + find(&files, "CONTRIBUTING.md"), + concat!( + "# Contributing to myrepo\n\nThank you for your interest in contributing!\n", + "\n## Development workflow\n\nFork, branch, PR.\n", + "\n## CI/CD and required secrets\n", + "\nThis project uses GitHub Actions for CI (`.github/workflows/ci.yml`)", + " and automated releases (`.github/workflows/release.yml`).\n", + "\n### Required repository secrets\n", + "\n| Secret | Description | Where to get it |\n|---|---|---|\n", + "| `CARGO_REGISTRY_TOKEN` | API token to publish crates to [crates.io](https://crates.io)", + " | [crates.io/me](https://crates.io/me) → API Tokens → New Token |\n", + "\n> **Tip:** If you use [ghscaff](https://github.com/UniverLab/ghscaff), you can run", + " `ghscaff apply` to configure missing secrets interactively, or set the env var", + " before running:\n> ```bash\n> export CARGO_REGISTRY_TOKEN=\n", + "> ghscaff apply\n> ```\n", + "\n## Release process\n\nReleases are automated via the `release.yml` workflow.\n", + "\nThe workflow builds binaries and publishes to crates.io.\n", + "\n## Code style\n\nRun `cargo fmt`.\n" + ) + ); + } + + #[test] + fn render_python_docs_fixture_without_license_keeps_release_process() { + let (_dir, tmpl) = docs_fixture("python-module"); + + let none = tmpl.boilerplate_files("demo", "d", "org", None); + assert!(!find(&none, "README.md").contains("## License")); + assert!(find(&none, "CONTRIBUTING.md").contains("## Release process")); + + let mit = tmpl.boilerplate_files("demo", "d", "org", Some("MIT")); + assert!(find(&mit, "README.md").contains("## License")); + assert!(find(&mit, "CONTRIBUTING.md").contains("## Release process")); + } + + // ── FR4 evidence: the REAL boilerplates, rendered with and without a license ── + + /// Recursively copy a boilerplate language tree into the scratch dir, + /// keeping the language directory name (`RemoteTemplate::language()` reads + /// it from `cache_dir`). The originals are only read, never written. + fn copy_tree(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let target = dst.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_tree(&entry.path(), &target); + } else { + std::fs::copy(entry.path(), &target).unwrap(); + } + } + } + + fn print_headings(prefix: &str, content: &str) { + for line in content.lines().filter(|l| l.starts_with('#')) { + eprintln!("{prefix}{line}"); + } + } + + /// What the printed evidence must show, asserted so it cannot drift: + /// `None` drops `## License` from every README and `## Release process` + /// only from the rust CONTRIBUTING; a chosen license changes nothing. + fn assert_license_headings(lang: &str, label: &str, readme: &str, contributing: &str) { + let kept: &[&str] = if lang == "rust" { + &["## Getting started", "## Development"] + } else { + &[ + "## Getting started", + "## Development", + "## Project structure", + ] + }; + for heading in kept { + assert!( + readme.contains(heading), + "{lang}/{label} README must keep {heading}" + ); + } + if label == "MIT" { + assert!(readme.contains("## License"), "{lang}/MIT README"); + assert!( + contributing.contains("## Release process"), + "{lang}/MIT CONTRIBUTING" + ); + return; + } + assert!(!readme.contains("## License"), "{lang}/None README"); + assert!( + !readme.contains("LICENSE"), + "{lang}/None README mentions LICENSE" + ); + if lang == "rust" { + assert!(!contributing.contains("## Release process")); + // Nothing about the dropped publish flow may survive: no + // `release.yml` mention (the workflow does not exist), no + // `CARGO_REGISTRY_TOKEN` story, no "automated releases" claim — + // the CI paragraph keeps only its true ci.yml sentence. + assert!(!contributing.contains("release.yml")); + assert!(!contributing.contains("automated releases")); + assert!(!contributing.contains("CARGO_REGISTRY_TOKEN")); + for heading in [ + "## Development workflow", + "## CI/CD and required secrets", + "## Code style", + ] { + assert!(contributing.contains(heading), "rust/None keeps {heading}"); + } + assert!( + contributing.contains( + "This project uses GitHub Actions for CI (`.github/workflows/ci.yml`)." + ), + "rust/None CI paragraph must name only ci.yml" + ); + } else { + // python keeps its publish.yml story, so the section stays. + assert!(contributing.contains("## Release process")); + } + } + + /// FR4 evidence — renders the REAL `ghscaff-boilerplate` trees (copied + /// into a scratch dir under /tmp; the originals are untouched) with `MIT` + /// and with no license, printing every `#` heading of README.md and + /// CONTRIBUTING.md. Skips when the sibling checkout is absent. No network + /// and no GitHub repository is created. + #[test] + fn evidence_real_boilerplate_docs_with_and_without_license() { + let src = match dirs::home_dir() { + Some(home) => home.join("Projects/UniverLab/ghscaff-boilerplate"), + None => return, + }; + if !src.join("rust").is_dir() || !src.join("python-module").is_dir() { + eprintln!("[evidence-real] boilerplate checkout absent — skipped"); + return; + } + let scratch = tempfile::tempdir().unwrap(); + eprintln!("[evidence-real] scratch: {}", scratch.path().display()); + for lang in ["rust", "python-module"] { + copy_tree(&src.join(lang), &scratch.path().join(lang)); + let tmpl = RemoteTemplate { + cache_dir: scratch.path().join(lang), + }; + for license in [Some("MIT"), None] { + let label = license.unwrap_or("None"); + let files = tmpl.boilerplate_files("demo", "A demo", "univerlab", license); + let readme = find(&files, "README.md"); + let contributing = find(&files, "CONTRIBUTING.md"); + eprintln!("[evidence-real] {lang}/{label} README.md:"); + print_headings(" ", readme); + eprintln!("[evidence-real] {lang}/{label} CONTRIBUTING.md:"); + print_headings(" ", contributing); + assert_license_headings(lang, label, readme, contributing); + } + } + } } diff --git a/src/templates/rust.rs b/src/templates/rust.rs index 5498230..cc2e8c0 100644 --- a/src/templates/rust.rs +++ b/src/templates/rust.rs @@ -17,7 +17,13 @@ impl LanguageTemplate for RustTemplate { "Rust".into() } - fn boilerplate_files(&self, repo_name: &str, description: &str, _owner: &str) -> Vec { + fn boilerplate_files( + &self, + repo_name: &str, + description: &str, + _owner: &str, + _license: Option<&str>, + ) -> Vec { let cargo_toml = format!( "[package]\nname = \"{repo_name}\"\nversion = \"0.1.0\"\nedition = \"2021\"\ndescription = \"{description}\"\n\n[[bin]]\nname = \"{repo_name}\"\npath = \"src/main.rs\"\n\n[dependencies]\n" ); @@ -64,7 +70,7 @@ mod tests { #[test] fn test_rust_template_boilerplate_files() { let tmpl = RustTemplate; - let files = tmpl.boilerplate_files("my-app", "A test app", "myorg"); + let files = tmpl.boilerplate_files("my-app", "A test app", "myorg", None); assert_eq!(files.len(), 3, "Should generate 3 files"); let paths: Vec<_> = files.iter().map(|f| f.path.as_str()).collect(); @@ -76,7 +82,7 @@ mod tests { #[test] fn test_rust_template_cargo_toml_has_name_placeholder() { let tmpl = RustTemplate; - let files = tmpl.boilerplate_files("my-app", "A test app", "myorg"); + let files = tmpl.boilerplate_files("my-app", "A test app", "myorg", None); let cargo_toml = files.iter().find(|f| f.path == "Cargo.toml").unwrap(); assert!(cargo_toml.content.contains("my-app")); assert!(cargo_toml.content.contains("A test app")); @@ -85,7 +91,7 @@ mod tests { #[test] fn test_rust_template_main_rs_is_valid() { let tmpl = RustTemplate; - let files = tmpl.boilerplate_files("my-app", "A test app", "myorg"); + let files = tmpl.boilerplate_files("my-app", "A test app", "myorg", None); let main_rs = files.iter().find(|f| f.path == "src/main.rs").unwrap(); assert!(main_rs.content.contains("fn main()")); assert!(main_rs.content.contains("println!")); @@ -94,7 +100,7 @@ mod tests { #[test] fn test_rust_template_readme_has_name() { let tmpl = RustTemplate; - let files = tmpl.boilerplate_files("my-app", "A test app", "myorg"); + let files = tmpl.boilerplate_files("my-app", "A test app", "myorg", None); let readme = files.iter().find(|f| f.path == "README.md").unwrap(); assert!(readme.content.contains("my-app")); assert!(readme.content.contains("A test app")); diff --git a/src/updater.rs b/src/updater.rs index 4341c25..3d19143 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -1,63 +1,505 @@ -//! Self-update: replaces the binary that is currently running. +//! Explicit self-update + read-only release notice (CM34): always asks, +//! never installs on its own. Never touches `~/.ghscaff` state — the vault +//! and the boilerplate cache are not the binary. //! -//! Never writes to a hardcoded install directory. Resolves -//! `std::env::current_exe()` and either replaces that file in place or, -//! when it is managed by `cargo install`, leaves it untouched and tells -//! the user to run `cargo install --force` instead. +//! Only [`run_update`] — the explicit `ghscaff update` command — downloads +//! and replaces the running binary, and only after consent (default **NO**, +//! `--yes` skips the prompt). The startup notice in `main` is a silent, +//! read-only lookup that prints one line and returns. Every external fact +//! (release list, archive bytes, prompt answer, executable path, target) is +//! injected through [`UpdateDeps`], so the unit tests below never touch the +//! network. +//! +//! Exit codes of `ghscaff update [--check]`: +//! +//! | code | meaning | +//! |---|---| +//! | `0` | up to date / update installed / prompt declined / cargo refusal | +//! | `1` | `--check` only: a newer stable release **is available** | +//! | `2` | the release **check could not be completed** (network, DNS, TLS, HTTP ≥ 400, unparsable response); the cause is the single line printed on stderr. Applies to `--check` *and* plain `update`. | +//! +//! Failures *after* a successful check (download, checksum, permissions) +//! remain ordinary `Err`s and therefore exit 1 with the normal error print. -use anyhow::{Context, Result}; +use anyhow::{anyhow, bail, Context, Result}; +use serde::Deserialize; use std::path::{Path, PathBuf}; +use std::time::Duration; const GITHUB_REPO: &str = "UniverLab/ghscaff"; -enum Outcome { - Updated, - CargoManaged, +/// The exact remediation printed when the running binary lives below +/// `~/.cargo/bin`: cargo owns that file, so ghscaff refuses to replace it. +pub const CARGO_INSTALL_HINT: &str = "cargo install --force ghscaff"; + +/// `ghscaff update`: nothing installed (up to date, cargo-managed, +/// declined) — and, for `--check`, nothing newer found. +const EXIT_NO_UPDATE: i32 = 0; +/// `ghscaff update --check`: a newer stable release is available. +const EXIT_UPDATE_AVAILABLE: i32 = 1; +/// `ghscaff update [--check]`: the release check could not be completed +/// (network, DNS, TLS, HTTP ≥ 400, unparsable response). Cause on stderr. +const EXIT_CHECK_FAILED: i32 = 2; + +// ── Release lookup seams ──────────────────────────────────────── + +/// The release fields needed to select a stable, published binary. +#[derive(Deserialize, Debug, Clone, PartialEq)] +pub struct GitHubRelease { + pub tag_name: String, + #[serde(default)] + pub prerelease: bool, + #[serde(default)] + pub draft: bool, +} + +/// Injectable release-JSON lookup used by the update core and the notice. +pub trait ReleaseFetcher { + fn get(&self, url: &str) -> Result; } -/// Entry point, called after the user confirms the "Install now?" prompt. -pub fn run_installer(latest_tag: &str) { - match install(latest_tag) { - Ok(Outcome::Updated) => { - println!(" \x1b[32m✓\x1b[0m Updated! Restart your terminal to use the new version."); - std::process::exit(0); +/// Production release lookup. All network and HTTP-status handling lives +/// behind [`ReleaseFetcher`] so unit tests can use a deterministic fake. +/// +/// The notice path carries a short timeout (it runs before every +/// subcommand); the explicit command uses [`RealFetcher::new`] with no +/// timeout so a slow API never masquerades as a failure — errors there are +/// loud anyway. +pub struct RealFetcher { + timeout: Option, +} + +impl RealFetcher { + /// No request timeout: used by the explicit `ghscaff update` command. + pub fn new() -> Self { + Self { timeout: None } + } + + /// Bounded request timeout: used by the silent startup notice. + pub fn with_timeout(timeout: Duration) -> Self { + Self { + timeout: Some(timeout), } - Ok(Outcome::CargoManaged) => { - println!(" ℹ ghscaff was installed with cargo — the auto-updater won't touch it."); - println!(" Run this instead:"); - println!(); - println!(" cargo install --force ghscaff"); - println!(); + } +} + +impl Default for RealFetcher { + fn default() -> Self { + Self::new() + } +} + +impl ReleaseFetcher for RealFetcher { + fn get(&self, url: &str) -> Result { + let client = match self.timeout { + Some(timeout) => reqwest::blocking::Client::builder() + .timeout(timeout) + .build() + .context("failed to build HTTP client")?, + None => reqwest::blocking::Client::new(), + }; + let response = client + .get(url) + .header("User-Agent", "ghscaff-update") + .send() + .context("failed to fetch GitHub releases")?; + let status = response.status(); + if !status.is_success() { + bail!("GitHub releases request failed: HTTP {status}"); } - Err(e) => { - eprintln!(" ⚠ Update failed: {e:#}"); + response + .text() + .context("failed to read GitHub releases response") + } +} + +/// Injectable binary downloader. The archive (and `SHA256SUMS.txt`) are +/// decoded only after this seam returns, keeping the updater tests offline. +pub trait BinaryDownloader { + fn download(&self, url: &str) -> Result>; +} + +/// Production binary downloader. Deliberately timeout-free: a release +/// archive is a multi-MiB download and a slow link is not a dead link. +pub struct RealDownloader; + +impl BinaryDownloader for RealDownloader { + fn download(&self, url: &str) -> Result> { + let response = reqwest::blocking::Client::new() + .get(url) + .header("User-Agent", "ghscaff-update") + .send() + .with_context(|| format!("failed to download {url}"))?; + let status = response.status(); + if !status.is_success() { + bail!("download failed: HTTP {status} for {url}"); } + Ok(response + .bytes() + .with_context(|| format!("failed to read {url}"))? + .to_vec()) } } -fn install(latest_tag: &str) -> Result { - let current_exe = - std::env::current_exe().context("failed to resolve current executable path")?; +// ── Hermetic dependencies ─────────────────────────────────────── + +/// Dependencies for the hermetic update command path. The real command uses +/// the same flow with production I/O; tests provide every fallible external +/// fact here and never contact GitHub or write outside the temp dirs they +/// own. +pub struct UpdateDeps<'a> { + /// Current version tag, `v`-prefixed (e.g. `v0.6.0`). + pub current: &'a str, + /// Release-list lookup outcome: `Err(cause)` means the check could not + /// be completed — the core prints the one-line cause on stderr and + /// returns exit code `2` instead of surfacing an `Err`. + pub releases: std::result::Result, String>, + pub exe: &'a Path, + /// Cargo install root; `None` skips the cargo guard entirely. + pub cargo_bin: Option<&'a Path>, + pub target: std::result::Result<(&'static str, &'static str), String>, + pub downloader: &'a dyn BinaryDownloader, + pub confirm: &'a dyn Fn() -> bool, +} + +// ── Display helpers ───────────────────────────────────────────── + +/// Strip a leading `v` for human-facing version strings (`v0.6.0` → `0.6.0`). +/// Release and asset URLs keep the raw tag; this is *only* for output. +pub fn display_version(version: &str) -> &str { + version.strip_prefix('v').unwrap_or(version) +} + +/// The `ghscaff 0.0.1 → 0.6.0` update-available arrow. +fn arrow_line(current: &str, latest: &str) -> String { + format!( + "ghscaff {} → {}", + display_version(current), + display_version(latest) + ) +} - if is_cargo_managed(¤t_exe) { - return Ok(Outcome::CargoManaged); +/// The `ghscaff 0.6.0 is up to date` line. +fn up_to_date_line(current: &str) -> String { + format!("ghscaff {} is up to date", display_version(current)) +} + +/// The cargo-managed refusal: a full sentence, not a bare command. +fn cargo_refusal_line() -> String { + format!("installed with cargo — run: {CARGO_INSTALL_HINT}") +} + +/// The post-replace success line: no restart advice — the swap is in place +/// and the next invocation already runs the new binary. +fn success_line(latest: &str) -> String { + format!("✓ updated to {}", display_version(latest)) +} + +// ── Public update entry points ─────────────────────────────────── + +/// Check for and, after consent, install the latest stable release. +/// +/// The returned integer is the process exit code: `0` = up to date (or +/// update installed / declined / cargo-managed refusal), `1` = an update is +/// available in `--check` mode, `2` = the release check could not be +/// completed (network, DNS, TLS, HTTP ≥ 400, unparsable response) — in that +/// case the one-line cause goes to stderr. A check failure never becomes an +/// `Err`; only install-phase failures (download, checksum, permissions) do, +/// and those exit 1 through the normal error print. +pub fn run_update( + check: bool, + yes: bool, + fetcher: &dyn ReleaseFetcher, + downloader: &dyn BinaryDownloader, +) -> Result { + let current = current_version(); + // A check failure becomes data (one-line error chain), not an Err: the + // hermetic core turns it into exit 2 in both `--check` and plain mode. + let releases = fetch_releases_with(fetcher).map_err(|error| format!("{error:#}")); + let latest = releases + .as_ref() + .ok() + .and_then(|releases| select_latest_stable(releases, ¤t)); + + // The first pass is limited to the network result. The hermetic core + // below owns all output and consent, so `--check` cannot touch a local + // path or the target before it returns — and a failed check returns + // exit 2 here, before any of those facts are resolved. + if latest.is_none() || check { + let deps = UpdateDeps { + current: ¤t, + releases, + exe: Path::new("/tmp/ghscaff-update-test/ghscaff"), + cargo_bin: None, + target: Ok(("x86_64", "unknown-linux-musl")), + downloader, + confirm: &|| false, + }; + return run_update_with(check, yes, &deps); } - let dir = current_exe + // An actual install needs the executable and target facts. Resolve them + // only after the read-only pass established that a newer release exists. + let releases = releases.expect("a newer release was found above"); + let latest = latest.expect("newer release was established above"); + let exe = std::env::current_exe().context("failed to locate ghscaff executable")?; + let cargo_bin = cargo_install_root(); + // Resolved eagerly but *carried* as a result: the hermetic core decides + // when it surfaces, so the cargo guard still wins on a cargo-managed + // install and an unsupported target fails loudly before any download. + let target = detect_platform().map_err(|error| { + if cfg!(target_os = "windows") { + format!( + "ghscaff update does not support windows — run scripts/install.ps1 instead \ + (release asset: ghscaff-{latest}-x86_64-pc-windows-msvc.zip)" + ) + } else { + error.to_string() + } + }); + let deps = UpdateDeps { + current: ¤t, + releases: Ok(releases), + exe: &exe, + cargo_bin: cargo_bin.as_deref(), + target, + downloader, + confirm: &|| { + inquire::Confirm::new(&format!("Update to {}? [y/N]", display_version(&latest))) + .with_default(false) + .prompt() + .unwrap_or(false) + }, + }; + run_update_with(false, yes, &deps) +} + +/// Hermetic update flow used by unit tests and embedders. It has no +/// network or filesystem setup step; callers provide those facts through +/// [`UpdateDeps`]. Exit codes follow the contract of [`run_update`]: `Ok(2)` +/// with one stderr line when `deps.releases` is an `Err` (the check could +/// not be completed), `Ok(1)` for an available update under `--check`, +/// `Ok(0)` otherwise; local failures surface as `Err`. +pub fn run_update_with(check: bool, yes: bool, deps: &UpdateDeps<'_>) -> Result { + let releases = match &deps.releases { + Ok(releases) => releases, + Err(error) => { + eprintln!("update check failed: {error}"); + return Ok(EXIT_CHECK_FAILED); + } + }; + let Some(latest) = select_latest_stable(releases, deps.current) else { + println!("{}", up_to_date_line(deps.current)); + return Ok(EXIT_NO_UPDATE); + }; + println!("{}", arrow_line(deps.current, &latest)); + + // `--check` ends here: exit 1 = update available, 0 = already current. + // Nothing below this line — cargo guard, target, prompt, download — may + // run in read-only mode. + if check { + return Ok(EXIT_UPDATE_AVAILABLE); + } + + // Cargo owns `~/.cargo/bin`; refuse before anything is downloaded. + if deps + .cargo_bin + .is_some_and(|root| is_cargo_managed_with_root(deps.exe, Some(root.to_path_buf()))) + { + println!("{}", cargo_refusal_line()); + return Ok(EXIT_NO_UPDATE); + } + + let (arch, os) = *deps + .target + .as_ref() + .map_err(|error| anyhow!("target resolution failed: {error}"))?; + + if !yes && !(deps.confirm)() { + println!("Aborted."); + return Ok(EXIT_NO_UPDATE); + } + + let exe = deps.exe; + let dir = exe .parent() .context("executable path has no parent directory")?; - let file_name = current_exe + let file_name = exe .file_name() .and_then(|n| n.to_str()) .unwrap_or("ghscaff"); let tmp_path = dir.join(format!(".{file_name}.update")); - let (arch, os) = detect_platform()?; - update_binary_at(&tmp_path, ¤t_exe, |out| { - download_and_extract(latest_tag, arch, os, out) + update_binary_at(&tmp_path, exe, |out| { + download_verify_extract_with(deps.downloader, &latest, (arch, os), out) })?; - Ok(Outcome::Updated) + println!("{}", success_line(&latest)); + Ok(EXIT_NO_UPDATE) +} + +// ── Version helpers ───────────────────────────────────────────── + +/// The `v`-prefixed tag of the running binary, e.g. `v0.6.0`. +pub fn current_version() -> String { + format!("v{}", env!("CARGO_PKG_VERSION")) +} + +/// A tag is "stable" when it carries nothing but digits and dots after an +/// optional `v` — anything prerelease-shaped (`v1.0.0-rc1`) is skipped. +pub fn is_stable_version(tag: &str) -> bool { + let value = tag.trim_start_matches('v'); + !value.is_empty() && value.chars().all(|c| c.is_ascii_digit() || c == '.') +} + +/// Numeric, `v`-insensitive component-wise comparison; a missing component +/// counts as `0`, so `1.0` equals `1.0.0`. +pub fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering { + let parse = |s: &str| -> Vec { + s.trim_start_matches('v') + .split('.') + .filter_map(|part| part.parse().ok()) + .collect() + }; + let (pa, pb) = (parse(a), parse(b)); + let len = pa.len().max(pb.len()); + for index in 0..len { + let comparison = pa + .get(index) + .copied() + .unwrap_or(0) + .cmp(&pb.get(index).copied().unwrap_or(0)); + if comparison != std::cmp::Ordering::Equal { + return comparison; + } + } + std::cmp::Ordering::Equal +} + +/// Select the newest published stable release strictly newer than `current`. +/// Drafts, prereleases, and non-semver tags never win — and neither notice +/// nor command can ever disagree, because both call this. +pub fn select_latest_stable(releases: &[GitHubRelease], current: &str) -> Option { + releases + .iter() + .filter(|release| !release.draft && !release.prerelease) + .filter(|release| is_stable_version(&release.tag_name)) + .filter(|release| compare_versions(&release.tag_name, current).is_gt()) + .max_by(|a, b| compare_versions(&a.tag_name, &b.tag_name)) + .map(|release| release.tag_name.clone()) +} + +// ── Release and asset URLs ────────────────────────────────────── + +/// Full release list (not `/releases/latest`) so drafts, prereleases, and +/// non-semver tags can be filtered in code; `per_page=100` keeps the default +/// page-30 cut-off from hiding a stable tag. +fn releases_url() -> String { + format!("https://api.github.com/repos/{GITHUB_REPO}/releases?per_page=100") +} + +/// The exact release asset name, matching `.github/workflows/release.yml` +/// (delegating to `rust-release.yml`) and `scripts/install.sh`: +/// `ghscaff-{tag}-{arch}-{os}.tar.gz`, tag keeping its leading `v`. +pub fn asset_name(tag: &str, arch: &str, os: &str) -> String { + format!("ghscaff-{tag}-{arch}-{os}.tar.gz") +} + +fn asset_url(tag: &str, (arch, os): (&str, &str)) -> String { + format!( + "https://github.com/{GITHUB_REPO}/releases/download/{tag}/{}", + asset_name(tag, arch, os) + ) +} + +/// `SHA256SUMS.txt` uploaded next to every release asset. +pub fn sums_url(tag: &str) -> String { + format!("https://github.com/{GITHUB_REPO}/releases/download/{tag}/SHA256SUMS.txt") +} + +/// Fetch the release list through an injected fetcher. +pub fn fetch_releases_with(fetcher: &dyn ReleaseFetcher) -> Result> { + let body = fetcher.get(&releases_url())?; + serde_json::from_str(&body).context("failed to parse releases JSON") +} + +/// Fetch and select the newest stable release strictly newer than `current`. +pub fn fetch_latest_stable_with( + fetcher: &dyn ReleaseFetcher, + current: &str, +) -> Result> { + let releases = fetch_releases_with(fetcher)?; + Ok(select_latest_stable(&releases, current)) +} + +/// Silent startup notice: any network or parse failure is a `None`, never an +/// error. The explicit command is the only loud path. +pub fn check_notice(fetcher: &dyn ReleaseFetcher, current: &str) -> Option { + fetch_latest_stable_with(fetcher, current).ok().flatten() +} + +// ── Download, verification, extraction ────────────────────────── + +/// Outcome of the `SHA256SUMS.txt` check. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Check { + /// The release shipped a checksum line for this asset and it matched. + Verified, + /// The release ships no `SHA256SUMS.txt` — verification skipped. + Skipped, +} + +/// Verify the downloaded archive against the release's `SHA256SUMS.txt`. +/// +/// The rule mirrors `scripts/install.sh`: a missing sums file (fetch fails) +/// skips verification, a sums file present but with **no line for this +/// asset** aborts, and a mismatched digest aborts. The archive bytes are +/// hashed whole — this runs before anything is written next to the exe. +pub fn verify_checksum( + downloader: &dyn BinaryDownloader, + tag: &str, + asset: &str, + bytes: &[u8], +) -> Result { + let Ok(sums) = downloader.download(&sums_url(tag)) else { + return Ok(Check::Skipped); + }; + + let text = String::from_utf8_lossy(&sums); + let line = text + .lines() + .find(|line| line.split_whitespace().nth(1) == Some(asset)) + .filter(|line| !line.split_whitespace().next().unwrap_or("").is_empty()) + .with_context(|| format!("no checksum listed for {asset}"))?; + + let expected = line.split_whitespace().next().unwrap_or(""); + + use sha2::{Digest, Sha256}; + let actual: String = Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + if !actual.eq_ignore_ascii_case(expected) { + bail!("SHA256 mismatch for {asset}: expected {expected}, got {actual}"); + } + Ok(Check::Verified) +} + +/// Download the release asset for `tag`/`target`, verify it when the release +/// ships checksums, and unpack the `ghscaff` entry into `output` — which is +/// the same-directory staging file, never the running binary itself. +pub fn download_verify_extract_with( + downloader: &dyn BinaryDownloader, + tag: &str, + target: (&str, &str), + output: &Path, +) -> Result<()> { + let asset = asset_name(tag, target.0, target.1); + let bytes = downloader.download(&asset_url(tag, target))?; + if let Check::Skipped = verify_checksum(downloader, tag, &asset, &bytes)? { + println!(" ℹ checksum skipped (release ships no SHA256SUMS.txt)"); + } + extract_binary(std::io::Cursor::new(bytes), output) } // ── Cargo-managed detection ───────────────────────────────────── @@ -84,6 +526,8 @@ fn cargo_install_root() -> Option { ) } +/// Both paths are canonicalized; an uncanonicalizable path (missing exe, an +/// unresolvable root) is *not* cargo-managed rather than guessed. fn is_cargo_managed_with_root(exe_path: &Path, root: Option) -> bool { let Some(root) = root else { return false; @@ -95,6 +539,7 @@ fn is_cargo_managed_with_root(exe_path: &Path, root: Option) -> bool { } } +#[cfg(test)] fn is_cargo_managed(exe_path: &Path) -> bool { is_cargo_managed_with_root(exe_path, cargo_install_root()) } @@ -134,6 +579,8 @@ fn update_binary_at( result } +/// Atomic same-directory swap: the staging file is a sibling of the target +/// (`.{name}.update`), so the rename never crosses a filesystem. fn replace_binary(tmp_path: &Path, target: &Path) -> Result<()> { std::fs::rename(tmp_path, target).map_err(|e| { if e.kind() == std::io::ErrorKind::PermissionDenied { @@ -147,23 +594,6 @@ fn replace_binary(tmp_path: &Path, target: &Path) -> Result<()> { }) } -fn download_and_extract(tag: &str, arch: &str, os: &str, output: &Path) -> Result<()> { - let archive_name = format!("ghscaff-{tag}-{arch}-{os}.tar.gz"); - let url = format!("https://github.com/{GITHUB_REPO}/releases/download/{tag}/{archive_name}"); - - let resp = reqwest::blocking::Client::new() - .get(&url) - .header("User-Agent", "ghscaff-autoupdate") - .send() - .with_context(|| format!("failed to download {url}"))?; - - if !resp.status().is_success() { - anyhow::bail!("download failed: HTTP {} for {url}", resp.status()); - } - - extract_binary(resp, output) -} - fn extract_binary(reader: impl std::io::Read, output: &Path) -> Result<()> { let decoder = flate2::read::GzDecoder::new(reader); let mut archive = tar::Archive::new(decoder); @@ -200,6 +630,9 @@ fn extract_binary(reader: impl std::io::Read, output: &Path) -> Result<()> { Ok(()) } +/// `(arch, os)` pair for the running binary, matching the release matrix +/// (`rust-release.yml`). Windows ships as a `.zip`, which this updater does +/// not handle: refuse loudly before anything is downloaded. fn detect_platform() -> Result<(&'static str, &'static str)> { let arch = match std::env::consts::ARCH { "x86_64" => "x86_64", @@ -215,258 +648,4 @@ fn detect_platform() -> Result<(&'static str, &'static str)> { } #[cfg(test)] -mod tests { - use super::*; - use std::io::Write; - - fn make_tar_gz(entries: &[(&str, &[u8])]) -> Vec { - let mut tar_bytes = Vec::new(); - { - let mut builder = tar::Builder::new(&mut tar_bytes); - for (name, content) in entries { - let mut header = tar::Header::new_gnu(); - header.set_size(content.len() as u64); - header.set_mode(0o755); - header.set_cksum(); - builder.append_data(&mut header, name, *content).unwrap(); - } - builder.finish().unwrap(); - } - let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); - gz.write_all(&tar_bytes).unwrap(); - gz.finish().unwrap() - } - - #[test] - fn extract_binary_finds_named_entry() { - let archive = make_tar_gz(&[("ghscaff", b"fake-binary-contents")]); - let dir = tempfile::tempdir().unwrap(); - let output = dir.path().join("out"); - extract_binary(std::io::Cursor::new(archive), &output).unwrap(); - assert_eq!(std::fs::read(&output).unwrap(), b"fake-binary-contents"); - } - - #[test] - fn extract_binary_rejects_missing_entry() { - let archive = make_tar_gz(&[("other-file", b"contents")]); - let dir = tempfile::tempdir().unwrap(); - let output = dir.path().join("out"); - let err = extract_binary(std::io::Cursor::new(archive), &output).unwrap_err(); - assert!(err.to_string().contains("not found")); - assert!(!output.exists()); - } - - #[test] - fn extract_binary_rejects_empty_binary() { - let archive = make_tar_gz(&[("ghscaff", b"")]); - let dir = tempfile::tempdir().unwrap(); - let output = dir.path().join("out"); - let err = extract_binary(std::io::Cursor::new(archive), &output).unwrap_err(); - assert!(err.to_string().contains("empty")); - assert!(!output.exists()); - } - - #[test] - fn extract_binary_rejects_corrupt_archive() { - let dir = tempfile::tempdir().unwrap(); - let output = dir.path().join("out"); - let result = extract_binary(std::io::Cursor::new(b"not a gzip stream".to_vec()), &output); - assert!(result.is_err()); - assert!(!output.exists()); - } - - #[test] - fn update_binary_at_replaces_target_and_sets_executable() { - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("ghscaff"); - std::fs::write(&target, b"old-binary").unwrap(); - let tmp_path = dir.path().join(".ghscaff.update"); - - update_binary_at(&tmp_path, &target, |out| { - std::fs::write(out, b"new-binary")?; - Ok(()) - }) - .unwrap(); - - assert_eq!(std::fs::read(&target).unwrap(), b"new-binary"); - assert!(!tmp_path.exists()); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(&target).unwrap().permissions().mode(); - assert_eq!(mode & 0o111, 0o111); - } - } - - #[test] - fn update_binary_at_leaves_target_untouched_when_fetch_fails() { - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("ghscaff"); - std::fs::write(&target, b"original-binary").unwrap(); - let tmp_path = dir.path().join(".ghscaff.update"); - - let result = update_binary_at(&tmp_path, &target, |_out| { - anyhow::bail!("simulated download failure") - }); - - assert!(result.is_err()); - assert_eq!(std::fs::read(&target).unwrap(), b"original-binary"); - assert!(!tmp_path.exists()); - } - - #[test] - fn update_binary_at_cleans_up_tmp_file_when_extraction_writes_then_fails() { - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("ghscaff"); - std::fs::write(&target, b"original-binary").unwrap(); - let tmp_path = dir.path().join(".ghscaff.update"); - - let result = update_binary_at(&tmp_path, &target, |out| { - std::fs::write(out, b"partial-garbage")?; - anyhow::bail!("corrupt archive") - }); - - assert!(result.is_err()); - assert!(!tmp_path.exists()); - assert_eq!(std::fs::read(&target).unwrap(), b"original-binary"); - } - - #[test] - fn resolve_cargo_root_prefers_install_root() { - let root = resolve_cargo_root( - Some("/opt/install-root".to_string()), - Some("/opt/cargo-home".to_string()), - Some(PathBuf::from("/home/user")), - ); - assert_eq!(root, Some(PathBuf::from("/opt/install-root"))); - } - - #[test] - fn resolve_cargo_root_falls_back_to_cargo_home() { - let root = resolve_cargo_root( - None, - Some("/opt/cargo-home".to_string()), - Some(PathBuf::from("/home/user")), - ); - assert_eq!(root, Some(PathBuf::from("/opt/cargo-home"))); - } - - #[test] - fn resolve_cargo_root_falls_back_to_home_dot_cargo() { - let root = resolve_cargo_root(None, None, Some(PathBuf::from("/home/user"))); - assert_eq!(root, Some(PathBuf::from("/home/user/.cargo"))); - } - - #[test] - fn resolve_cargo_root_ignores_empty_env_values() { - let root = resolve_cargo_root( - Some(String::new()), - Some(String::new()), - Some(PathBuf::from("/home/user")), - ); - assert_eq!(root, Some(PathBuf::from("/home/user/.cargo"))); - } - - #[test] - fn is_cargo_managed_detects_path_inside_root() { - let dir = tempfile::tempdir().unwrap(); - let cargo_root = dir.path().join("cargo"); - let bin_dir = cargo_root.join("bin"); - std::fs::create_dir_all(&bin_dir).unwrap(); - let exe = bin_dir.join("ghscaff"); - std::fs::write(&exe, b"binary").unwrap(); - - assert!(is_cargo_managed_with_root(&exe, Some(cargo_root))); - } - - #[test] - fn is_cargo_managed_rejects_path_outside_root() { - let dir = tempfile::tempdir().unwrap(); - let cargo_root = dir.path().join("cargo"); - std::fs::create_dir_all(cargo_root.join("bin")).unwrap(); - let other_dir = dir.path().join("elsewhere"); - std::fs::create_dir_all(&other_dir).unwrap(); - let exe = other_dir.join("ghscaff"); - std::fs::write(&exe, b"binary").unwrap(); - - assert!(!is_cargo_managed_with_root(&exe, Some(cargo_root))); - } - - #[test] - fn is_cargo_managed_treats_uncanonicalizable_path_as_not_cargo() { - let dir = tempfile::tempdir().unwrap(); - let cargo_root = dir.path().join("cargo"); - std::fs::create_dir_all(cargo_root.join("bin")).unwrap(); - let missing_exe = dir.path().join("does-not-exist"); - - assert!(!is_cargo_managed_with_root(&missing_exe, Some(cargo_root))); - } - - #[test] - fn is_cargo_managed_with_no_resolvable_root_is_false() { - let dir = tempfile::tempdir().unwrap(); - let exe = dir.path().join("ghscaff"); - std::fs::write(&exe, b"binary").unwrap(); - - assert!(!is_cargo_managed_with_root(&exe, None)); - } - - #[test] - fn detect_platform_returns_supported_tuple_on_this_host() { - let result = detect_platform(); - if (cfg!(target_os = "linux") || cfg!(target_os = "macos")) - && (cfg!(target_arch = "x86_64") || cfg!(target_arch = "aarch64")) - { - assert!(result.is_ok()); - } - } - - #[test] - fn cargo_install_root_returns_something() { - // cargo_install_root reads env vars; on any dev machine it should - // resolve to at least Some(...) via CARGO_HOME or ~/.cargo. - let root = cargo_install_root(); - assert!(root.is_some()); - } - - #[test] - fn is_cargo_managed_delegates_correctly() { - let dir = tempfile::tempdir().unwrap(); - let exe = dir.path().join("ghscaff"); - std::fs::write(&exe, b"binary").unwrap(); - // Without a real cargo root, this should be false - assert!(!is_cargo_managed(&exe)); - } - - #[test] - fn extract_binary_with_multiple_entries_finds_correct_one() { - let archive = make_tar_gz(&[ - ("README.md", b"not the binary"), - ("ghscaff", b"real-binary-data"), - ("LICENSE", b"MIT"), - ]); - let dir = tempfile::tempdir().unwrap(); - let output = dir.path().join("out"); - extract_binary(std::io::Cursor::new(archive), &output).unwrap(); - assert_eq!(std::fs::read(&output).unwrap(), b"real-binary-data"); - } - - #[test] - fn update_binary_at_preserves_target_on_rename_failure() { - let dir = tempfile::tempdir().unwrap(); - let target = dir.path().join("ghscaff"); - std::fs::write(&target, b"original").unwrap(); - let tmp = dir.path().join(".update"); - - // Write to tmp, then try to rename to a non-existent directory - let bad_target = dir.path().join("nonexistent").join("ghscaff"); - let result = update_binary_at(&tmp, &bad_target, |out| { - std::fs::write(out, b"new-content")?; - Ok(()) - }); - - assert!(result.is_err()); - // Target unchanged (still original) - assert_eq!(std::fs::read(&target).unwrap(), b"original"); - } -} +mod tests; diff --git a/src/updater/tests.rs b/src/updater/tests.rs new file mode 100644 index 0000000..7c4e2d1 --- /dev/null +++ b/src/updater/tests.rs @@ -0,0 +1,1193 @@ +use super::*; +use std::cell::RefCell; +use std::io::Write; +use std::sync::MutexGuard; + +/// Tests that read or write process-wide environment serialize on the one +/// crate-wide lock, so a `HOME` swap here can never overlap with the `vault`, +/// `apply` or `wizard` tests that swap the same variable; tests run in +/// parallel by default. +fn env_lock() -> MutexGuard<'static, ()> { + crate::github::test_utils::env_lock() +} + +fn make_tar_gz(entries: &[(&str, &[u8])]) -> Vec { + let mut tar_bytes = Vec::new(); + { + let mut builder = tar::Builder::new(&mut tar_bytes); + for (name, content) in entries { + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o755); + header.set_cksum(); + builder.append_data(&mut header, name, *content).unwrap(); + } + builder.finish().unwrap(); + } + let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + gz.write_all(&tar_bytes).unwrap(); + gz.finish().unwrap() +} + +/// A fake release tag guaranteed newer than this binary, whatever the +/// current version is. Hardcoding "the next version" re-traps on every +/// version bump. +fn fake_newer_tag() -> String { + let mut parts = env!("CARGO_PKG_VERSION").split('.'); + let (major, minor, patch) = ( + parts.next().expect("semver major"), + parts.next().expect("semver minor"), + parts + .next() + .expect("semver patch") + .parse::() + .expect("numeric patch"), + ); + format!("v{major}.{minor}.{}", patch + 1) +} + +fn release(tag: &str) -> GitHubRelease { + GitHubRelease { + tag_name: tag.to_string(), + prerelease: false, + draft: false, + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + use sha2::{Digest, Sha256}; + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +/// Deterministic release-list fake for the fetcher seam. +struct FakeFetcher { + body: std::result::Result, +} + +impl ReleaseFetcher for FakeFetcher { + fn get(&self, _url: &str) -> Result { + self.body.clone().map_err(|error| anyhow!(error)) + } +} + +/// Offline downloader: serves a canned asset archive and a canned +/// `SHA256SUMS.txt`, recording every URL it is asked for. +struct RecordingDownloader { + asset: Vec, + sums: std::result::Result, String>, + calls: RefCell>, +} + +impl RecordingDownloader { + fn new(asset: Vec, sums: std::result::Result, String>) -> Self { + Self { + asset, + sums, + calls: RefCell::new(Vec::new()), + } + } + + fn calls(&self) -> Vec { + self.calls.borrow().clone() + } +} + +impl BinaryDownloader for RecordingDownloader { + fn download(&self, url: &str) -> Result> { + self.calls.borrow_mut().push(url.to_string()); + if url.ends_with("SHA256SUMS.txt") { + self.sums.clone().map_err(|error| anyhow!(error)) + } else { + Ok(self.asset.clone()) + } + } +} + +fn make_exe(dir: &Path) -> PathBuf { + let exe = dir.join("ghscaff"); + std::fs::write(&exe, b"old-binary").unwrap(); + exe +} + +fn tmp_staging_for(exe: &Path) -> PathBuf { + let name = exe.file_name().and_then(|n| n.to_str()).unwrap(); + exe.parent().unwrap().join(format!(".{name}.update")) +} + +/// Run the injected-fetcher path end to end the way `run_update` wires it: +/// fetch → one-line error string → hermetic core. Returns the exit code. +fn exit_when_release_lookup_fails(check: bool, body: std::result::Result) -> i32 { + let current = current_version(); + let dir = tempfile::tempdir().unwrap(); + let exe = make_exe(dir.path()); + let fetcher = FakeFetcher { body }; + let releases = fetch_releases_with(&fetcher).map_err(|error| format!("{error:#}")); + assert!(releases.is_err(), "test premise: the lookup must fail"); + let downloader = RecordingDownloader::new(Vec::new(), Err("unused".into())); + let deps = UpdateDeps { + current: ¤t, + releases, + exe: &exe, + cargo_bin: None, + target: Ok(("x86_64", "unknown-linux-musl")), + downloader: &downloader, + confirm: &|| panic!("must not prompt"), + }; + let code = run_update_with(check, false, &deps).unwrap(); + assert!( + downloader.calls().is_empty(), + "a failed check downloads nothing" + ); + assert_eq!( + std::fs::read(&exe).unwrap(), + b"old-binary", + "state untouched" + ); + code +} + +// ── ①② --check exit codes ─────────────────────────────────── + +#[test] +fn check_mode_exits_1_when_a_newer_release_exists() { + let current = current_version(); + let dir = tempfile::tempdir().unwrap(); + let exe = make_exe(dir.path()); + let downloader = RecordingDownloader::new(Vec::new(), Err("unused".into())); + let deps = UpdateDeps { + current: ¤t, + releases: Ok(vec![release(&fake_newer_tag()), release(¤t)]), + exe: &exe, + cargo_bin: None, + target: Ok(("x86_64", "unknown-linux-musl")), + downloader: &downloader, + confirm: &|| panic!("--check must not prompt"), + }; + + assert_eq!(run_update_with(true, false, &deps).unwrap(), 1); + assert!(downloader.calls().is_empty(), "--check downloads nothing"); + assert_eq!(std::fs::read(&exe).unwrap(), b"old-binary"); +} + +#[test] +fn check_mode_exits_0_when_current() { + let current = current_version(); + let dir = tempfile::tempdir().unwrap(); + let exe = make_exe(dir.path()); + let downloader = RecordingDownloader::new(Vec::new(), Err("unused".into())); + let deps = UpdateDeps { + current: ¤t, + releases: Ok(vec![release(¤t)]), + exe: &exe, + cargo_bin: None, + target: Ok(("x86_64", "unknown-linux-musl")), + downloader: &downloader, + confirm: &|| panic!("up to date must not prompt"), + }; + + assert_eq!(run_update_with(true, false, &deps).unwrap(), 0); + assert!(downloader.calls().is_empty()); +} + +// ── ③④ stable selection ───────────────────────────────────── + +#[test] +fn select_ignores_drafts_prereleases_and_rc_tags() { + let current = current_version(); + let newer = fake_newer_tag(); + let releases = vec![ + GitHubRelease { + tag_name: "v99.0.0".into(), + prerelease: false, + draft: true, + }, + GitHubRelease { + tag_name: "v98.0.0".into(), + prerelease: true, + draft: false, + }, + release(&format!("{newer}-rc1")), + release("nightly"), + release(¤t), + ]; + + assert_eq!(select_latest_stable(&releases, ¤t), None); +} + +#[test] +fn select_ignores_higher_drafts_even_in_check_mode() { + let current = current_version(); + let dir = tempfile::tempdir().unwrap(); + let exe = make_exe(dir.path()); + let downloader = RecordingDownloader::new(Vec::new(), Err("unused".into())); + let deps = UpdateDeps { + current: ¤t, + releases: Ok(vec![ + GitHubRelease { + tag_name: "v99.0.0".into(), + prerelease: false, + draft: true, + }, + GitHubRelease { + tag_name: "v98.0.0".into(), + prerelease: true, + draft: false, + }, + release(&format!("{}-rc1", fake_newer_tag())), + release(¤t), + ]), + exe: &exe, + cargo_bin: None, + target: Ok(("x86_64", "unknown-linux-musl")), + downloader: &downloader, + confirm: &|| panic!("must not prompt"), + }; + + assert_eq!(run_update_with(true, false, &deps).unwrap(), 0); + assert!(downloader.calls().is_empty()); +} + +#[test] +fn select_picks_max_stable_from_unsorted_list() { + let releases = vec![ + release("v0.7.0"), + release("v0.5.0"), + release("v0.9.0"), + release("v0.6.0"), + ]; + assert_eq!( + select_latest_stable(&releases, "v0.6.0"), + Some("v0.9.0".to_string()) + ); +} + +// ── ⑤ version comparison matrix (port of main.rs::is_newer) ─ + +/// Old `main.rs::is_newer(current, latest)` semantics, expressed on top +/// of the single comparator the notice and the command now share. +fn is_newer(current: &str, latest: &str) -> bool { + compare_versions(latest, current).is_gt() +} + +#[test] +fn compare_versions_orders_by_significance() { + assert!(is_newer("v0.5.0", "v1.0.0")); + assert!(is_newer("v0.5.0", "v0.6.0")); + assert!(is_newer("v0.5.0", "v0.5.1")); + assert!(!is_newer("v0.5.0", "v0.5.0")); + assert!(!is_newer("v1.0.0", "v0.9.0")); + assert!(is_newer("v1.0.0", "v2.0.0")); + assert!(!is_newer("v2.0.0", "v1.9.0")); + assert!(!is_newer("v1.0.1", "v1.0.0")); + assert!(!is_newer("v0.0.0", "v0.0.0")); + assert!(is_newer("v0.0.1", "v99.99.99")); +} + +#[test] +fn compare_versions_treats_missing_components_as_zero() { + assert!(!is_newer("v1.0.0", "v1.0")); + assert!(!is_newer("v1.0", "v1.0.0")); + assert!(!is_newer("v1", "v1")); + assert!(is_newer("v1", "v2")); + assert!(!is_newer("v2", "v1")); + assert!(!is_newer("v1.0.0 ", "v1.0.0")); + assert!(!is_newer("v1.0.0", " v1.0.0")); + assert!(is_newer("v1.0.0.0", "v1.0.1.0")); + assert!(!is_newer("v1..0", "v1..0")); + assert!(!is_newer("v01.00.00", "v1.0.0")); +} + +#[test] +fn compare_versions_ignores_v_prefix_everywhere() { + assert!(is_newer("0.5.0", "0.6.0")); + assert!(is_newer("v0.5.0", "1.0.0")); + assert!(is_newer("1.0.0", "2.0.0")); + assert!(!is_newer("2.0.0", "1.0.0")); + assert!(is_newer("v1.0.0", "2.0.0")); + assert!(is_newer("1.0.0", "v2.0.0")); +} + +#[test] +fn compare_versions_handles_non_numeric_input() { + assert!(!is_newer("abc", "xyz")); + assert!(!is_newer("", "")); + assert!(!is_newer("vabc", "vdef")); + assert!(!is_newer("latest", "stable")); +} + +#[test] +fn compare_versions_component_precedence() { + assert!(is_newer("v1.2.3", "v2.3.4")); + assert!(!is_newer("v2.3.4", "v1.2.3")); + assert!(is_newer("v1.1.0", "v1.1.1")); + assert!(!is_newer("v1.1.1", "v1.1.0")); + assert!(is_newer("v1.1.0", "v1.2.0")); + assert!(!is_newer("v1.2.0", "v1.1.0")); + assert!(is_newer("v1.9.9", "v2.0.0")); + assert!(!is_newer("v2.0.0", "v1.9.9")); + assert!(is_newer("v1.1.9", "v1.2.0")); + assert!(!is_newer("v1.2.0", "v1.1.9")); + assert!(is_newer("v9.0.0", "v10.0.0")); + assert!(is_newer("v1.99.0", "v1.100.0")); + assert!(is_newer("v1.0.999", "v1.0.1000")); + assert!(is_newer("v999.999.999", "v1000.0.0")); +} + +#[test] +fn compare_versions_boundaries() { + assert!(is_newer("v0.0.0", "v1.0.0")); + assert!(!is_newer("v1.0.0", "v0.0.0")); + assert!(is_newer("v0.0.0", "v0.1.0")); + assert!(is_newer("v0.0.0", "v0.0.1")); + assert!(!is_newer("v0.0.1", "v0.0.0")); + assert!(!is_newer("v0.1.0", "v0.0.1")); + assert!(!is_newer("v1.0.0", "v0.0.1")); + assert!(!is_newer("v1.0.5", "v1.0.3")); + assert!(!is_newer("v1.5.0", "v1.3.0")); + assert!(!is_newer("v5.0.0", "v3.0.0")); + assert!(!is_newer("v1.2.3", "v1.2.3")); +} + +#[test] +fn is_stable_version_rejects_prerelease_shapes() { + assert!(is_stable_version("v1.2.3")); + assert!(is_stable_version("1.2.3")); + assert!(is_stable_version("v1.2")); + assert!(!is_stable_version("v1.2.3-rc1")); + assert!(!is_stable_version("v1.2.3-beta.2")); + assert!(!is_stable_version("")); + assert!(!is_stable_version("nightly")); +} + +#[test] +fn current_version_is_v_prefixed_and_fake_tag_is_strictly_newer() { + let current = current_version(); + assert!(current.starts_with('v')); + assert_eq!(current, format!("v{}", env!("CARGO_PKG_VERSION"))); + assert!(compare_versions(&fake_newer_tag(), ¤t).is_gt()); +} + +// ── ⑥ cargo guard ─────────────────────────────────────────── + +#[test] +fn cargo_managed_binary_is_refused_before_any_download() { + let current = current_version(); + let dir = tempfile::tempdir().unwrap(); + let cargo_root = dir.path().join("cargo"); + let bin_dir = cargo_root.join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + let exe = bin_dir.join("ghscaff"); + std::fs::write(&exe, b"cargo-installed-binary").unwrap(); + + let downloader = RecordingDownloader::new(make_tar_gz(&[("ghscaff", b"new")]), Err("x".into())); + let deps = UpdateDeps { + current: ¤t, + releases: Ok(vec![release(&fake_newer_tag())]), + exe: &exe, + cargo_bin: Some(&cargo_root), + target: Ok(("x86_64", "unknown-linux-musl")), + downloader: &downloader, + confirm: &|| panic!("cargo-managed install must not prompt"), + }; + + assert_eq!(run_update_with(false, false, &deps).unwrap(), 0); + assert!( + downloader.calls().is_empty(), + "no download for cargo installs" + ); + assert_eq!(std::fs::read(&exe).unwrap(), b"cargo-installed-binary"); + assert!(!tmp_staging_for(&exe).exists()); +} + +#[test] +fn cargo_refusal_hint_is_the_exact_specified_command() { + assert_eq!(CARGO_INSTALL_HINT, "cargo install --force ghscaff"); +} + +// ── ⑦ declining the prompt ────────────────────────────────── + +#[test] +fn declining_the_prompt_leaves_the_binary_untouched() { + let current = current_version(); + let dir = tempfile::tempdir().unwrap(); + let exe = make_exe(dir.path()); + let downloader = RecordingDownloader::new(make_tar_gz(&[("ghscaff", b"new")]), Err("x".into())); + let deps = UpdateDeps { + current: ¤t, + releases: Ok(vec![release(&fake_newer_tag())]), + exe: &exe, + cargo_bin: None, + target: Ok(("x86_64", "unknown-linux-musl")), + downloader: &downloader, + confirm: &|| false, + }; + + assert_eq!(run_update_with(false, false, &deps).unwrap(), 0); + assert_eq!(std::fs::read(&exe).unwrap(), b"old-binary"); + assert!(downloader.calls().is_empty(), "no download before consent"); + assert!(!tmp_staging_for(&exe).exists()); +} + +// ── ⑧ accepted prompt replaces the binary ─────────────────── + +#[test] +fn accepting_replaces_the_binary_atomically_and_keeps_mode() { + let current = current_version(); + let dir = tempfile::tempdir().unwrap(); + let exe = make_exe(dir.path()); + let tag = fake_newer_tag(); + let archive = make_tar_gz(&[("ghscaff", b"brand-new-binary")]); + let sums = format!( + "{} {}\n", + sha256_hex(&archive), + asset_name(&tag, "x86_64", "unknown-linux-musl") + ); + let downloader = RecordingDownloader::new(archive, Ok(sums.into_bytes())); + let deps = UpdateDeps { + current: ¤t, + releases: Ok(vec![release(&tag)]), + exe: &exe, + cargo_bin: None, + target: Ok(("x86_64", "unknown-linux-musl")), + downloader: &downloader, + confirm: &|| true, + }; + + assert_eq!(run_update_with(false, false, &deps).unwrap(), 0); + assert_eq!(std::fs::read(&exe).unwrap(), b"brand-new-binary"); + assert!(!tmp_staging_for(&exe).exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&exe).unwrap().permissions().mode(); + assert_eq!(mode & 0o111, 0o111); + } + + // Asset naming follows rust-release.yml: ghscaff-{tag}-{arch}-{os}.tar.gz + let calls = downloader.calls(); + assert!( + calls[0].ends_with(&format!( + "/releases/download/{tag}/{}", + asset_name(&tag, "x86_64", "unknown-linux-musl") + )), + "unexpected asset url: {}", + calls[0] + ); +} + +#[test] +fn yes_flag_skips_the_prompt_entirely() { + let current = current_version(); + let dir = tempfile::tempdir().unwrap(); + let exe = make_exe(dir.path()); + let archive = make_tar_gz(&[("ghscaff", b"yes-binary")]); + let downloader = RecordingDownloader::new(archive, Err("no sums".into())); + let deps = UpdateDeps { + current: ¤t, + releases: Ok(vec![release(&fake_newer_tag())]), + exe: &exe, + cargo_bin: None, + target: Ok(("x86_64", "unknown-linux-musl")), + downloader: &downloader, + confirm: &|| panic!("--yes must not prompt"), + }; + + assert_eq!(run_update_with(false, true, &deps).unwrap(), 0); + assert_eq!(std::fs::read(&exe).unwrap(), b"yes-binary"); +} + +// ── ⑨⑩ failure paths ─────────────────────────────────────── + +#[test] +fn target_error_fails_before_touching_the_downloader() { + let current = current_version(); + let dir = tempfile::tempdir().unwrap(); + let exe = make_exe(dir.path()); + let downloader = RecordingDownloader::new(make_tar_gz(&[("ghscaff", b"new")]), Err("x".into())); + let deps = UpdateDeps { + current: ¤t, + releases: Ok(vec![release(&fake_newer_tag())]), + exe: &exe, + cargo_bin: None, + target: Err("unsupported OS: windows".into()), + downloader: &downloader, + confirm: &|| true, + }; + + let err = run_update_with(false, false, &deps).unwrap_err(); + assert!( + err.to_string().contains("target resolution failed"), + "{err:#}" + ); + assert!(downloader.calls().is_empty()); + assert_eq!(std::fs::read(&exe).unwrap(), b"old-binary"); +} + +#[test] +fn check_exits_2_when_the_release_lookup_fails() { + assert_eq!( + exit_when_release_lookup_fails(true, Err("dns failure".into())), + 2 + ); +} + +#[test] +fn check_exits_2_on_an_unparsable_response() { + assert_eq!( + exit_when_release_lookup_fails(true, Ok("not json".into())), + 2 + ); +} + +#[test] +fn plain_update_exits_2_when_the_release_lookup_fails() { + assert_eq!( + exit_when_release_lookup_fails(false, Err("HTTP 403: rate limit exceeded".into())), + 2 + ); +} + +// ── exit-2 wording: one stderr line, cause named ───────────── + +/// The check-error string is what lands on stderr: it must be a single +/// line that names the cause. +#[test] +fn check_failure_cause_is_a_single_stderr_line() { + for cause in [ + "failed to fetch GitHub releases: dns error: no such host", + "failed to fetch GitHub releases: error sending request (TLS handshake)", + "GitHub releases request failed: HTTP 403 Forbidden", + "failed to parse releases JSON: expected value at line 1 column 1", + ] { + let error = anyhow!(cause); + let line = format!("update check failed: {error:#}"); + assert!(!line.contains('\n'), "one line only: {line:?}"); + assert!(line.contains(cause), "names the cause: {line}"); + } +} + +// ── output wording: bare versions, no restart advice ───────── + +#[test] +fn display_version_strips_only_a_leading_v() { + assert_eq!(display_version("v0.6.0"), "0.6.0"); + assert_eq!(display_version("0.6.0"), "0.6.0"); + assert_eq!(display_version(""), ""); +} + +#[test] +fn success_line_has_bare_version_and_no_restart_advice() { + assert_eq!(success_line("v0.6.0"), "✓ updated to 0.6.0"); +} + +#[test] +fn arrow_line_drops_the_v_prefix() { + assert_eq!(arrow_line("v0.0.1", "v0.6.0"), "ghscaff 0.0.1 → 0.6.0"); +} + +#[test] +fn up_to_date_line_drops_the_v_prefix() { + assert_eq!(up_to_date_line("v0.6.0"), "ghscaff 0.6.0 is up to date"); +} + +#[test] +fn cargo_refusal_is_a_full_sentence() { + assert_eq!( + cargo_refusal_line(), + "installed with cargo — run: cargo install --force ghscaff" + ); +} + +// ── ⑪⑬⑭ checksum rules (install.sh semantics) ────────────── + +#[test] +fn matching_checksum_replaces_the_binary() { + let current = current_version(); + let dir = tempfile::tempdir().unwrap(); + let exe = make_exe(dir.path()); + let tag = fake_newer_tag(); + let archive = make_tar_gz(&[("ghscaff", b"verified-binary")]); + let asset = asset_name(&tag, "x86_64", "unknown-linux-musl"); + let sums = format!("{} {asset}\n0000000000000000000000000000000000000000000000000000000000000000 other-asset.tar.gz\n", sha256_hex(&archive)); + let downloader = RecordingDownloader::new(archive, Ok(sums.into_bytes())); + let deps = UpdateDeps { + current: ¤t, + releases: Ok(vec![release(&tag)]), + exe: &exe, + cargo_bin: None, + target: Ok(("x86_64", "unknown-linux-musl")), + downloader: &downloader, + confirm: &|| true, + }; + + assert_eq!(run_update_with(false, false, &deps).unwrap(), 0); + assert_eq!(std::fs::read(&exe).unwrap(), b"verified-binary"); +} + +#[test] +fn checksum_mismatch_aborts_with_the_binary_untouched() { + let current = current_version(); + let dir = tempfile::tempdir().unwrap(); + let exe = make_exe(dir.path()); + let tag = fake_newer_tag(); + let archive = make_tar_gz(&[("ghscaff", b"tampered")]); + let asset = asset_name(&tag, "x86_64", "unknown-linux-musl"); + let sums = format!("deadbeef {asset}\n"); + let downloader = RecordingDownloader::new(archive, Ok(sums.into_bytes())); + let deps = UpdateDeps { + current: ¤t, + releases: Ok(vec![release(&tag)]), + exe: &exe, + cargo_bin: None, + target: Ok(("x86_64", "unknown-linux-musl")), + downloader: &downloader, + confirm: &|| true, + }; + + let err = run_update_with(false, false, &deps).unwrap_err(); + assert!(err.to_string().contains("SHA256 mismatch"), "{err:#}"); + assert_eq!(std::fs::read(&exe).unwrap(), b"old-binary"); + assert!(!tmp_staging_for(&exe).exists()); +} + +#[test] +fn missing_sums_file_skips_verification_and_still_replaces() { + let current = current_version(); + let dir = tempfile::tempdir().unwrap(); + let exe = make_exe(dir.path()); + let archive = make_tar_gz(&[("ghscaff", b"unverifiable-but-fine")]); + let downloader = RecordingDownloader::new(archive, Err("HTTP 404".into())); + let deps = UpdateDeps { + current: ¤t, + releases: Ok(vec![release(&fake_newer_tag())]), + exe: &exe, + cargo_bin: None, + target: Ok(("x86_64", "unknown-linux-musl")), + downloader: &downloader, + confirm: &|| true, + }; + + assert_eq!(run_update_with(false, false, &deps).unwrap(), 0); + assert_eq!(std::fs::read(&exe).unwrap(), b"unverifiable-but-fine"); + assert_eq!( + downloader + .calls() + .iter() + .filter(|url| url.ends_with("SHA256SUMS.txt")) + .count(), + 1 + ); +} + +#[test] +fn sums_file_without_a_line_for_this_asset_aborts() { + let current = current_version(); + let dir = tempfile::tempdir().unwrap(); + let exe = make_exe(dir.path()); + let tag = fake_newer_tag(); + let archive = make_tar_gz(&[("ghscaff", b"new")]); + let sums = "0000000000000000000000000000000000000000000000000000000000000000 some-other-asset.tar.gz\n"; + let downloader = RecordingDownloader::new(archive, Ok(sums.as_bytes().to_vec())); + let deps = UpdateDeps { + current: ¤t, + releases: Ok(vec![release(&tag)]), + exe: &exe, + cargo_bin: None, + target: Ok(("x86_64", "unknown-linux-musl")), + downloader: &downloader, + confirm: &|| true, + }; + + let err = run_update_with(false, false, &deps).unwrap_err(); + assert!(err.to_string().contains("no checksum listed"), "{err:#}"); + assert_eq!(std::fs::read(&exe).unwrap(), b"old-binary"); + assert!(!tmp_staging_for(&exe).exists()); +} + +// ── ⑮ archive without the ghscaff entry ───────────────────── + +#[test] +fn archive_without_ghscaff_entry_fails_and_leaves_the_binary_untouched() { + let current = current_version(); + let dir = tempfile::tempdir().unwrap(); + let exe = make_exe(dir.path()); + let archive = make_tar_gz(&[("README.md", b"not the binary")]); + let downloader = RecordingDownloader::new(archive, Err("no sums".into())); + let deps = UpdateDeps { + current: ¤t, + releases: Ok(vec![release(&fake_newer_tag())]), + exe: &exe, + cargo_bin: None, + target: Ok(("x86_64", "unknown-linux-musl")), + downloader: &downloader, + confirm: &|| true, + }; + + let err = run_update_with(false, false, &deps).unwrap_err(); + assert!(err.to_string().contains("not found"), "{err:#}"); + assert_eq!(std::fs::read(&exe).unwrap(), b"old-binary"); + assert!(!tmp_staging_for(&exe).exists()); +} + +// ── ⑯ ~/.ghscaff state is never touched ───────────────────── + +#[test] +fn update_never_touches_ghscaff_state() { + let _guard = env_lock(); + let previous_home = std::env::var_os("HOME"); + + let home = tempfile::tempdir().unwrap(); + std::env::set_var("HOME", home.path()); + + // Sentinels: the encrypted vault and the boilerplate cache. + let state = home.path().join(".ghscaff"); + let boilerplate = state.join("boilerplate"); + std::fs::create_dir_all(&boilerplate).unwrap(); + std::fs::write(state.join("vault.enc"), b"encrypted-token-bytes").unwrap(); + std::fs::write(boilerplate.join("rust"), b"cached-boilerplate").unwrap(); + + let snapshot = || -> Vec<(PathBuf, Vec)> { + let mut entries = Vec::new(); + for path in walkdir::WalkDir::new(&state).sort_by_file_name() { + let path = path.unwrap().path().to_path_buf(); + if path.is_file() { + entries.push((path.clone(), std::fs::read(&path).unwrap())); + } + } + entries + }; + let before = snapshot(); + + // The binary lives somewhere else entirely — as in production. + let exe_dir = tempfile::tempdir().unwrap(); + let exe = make_exe(exe_dir.path()); + let current = current_version(); + let archive = make_tar_gz(&[("ghscaff", b"state-safe-binary")]); + let downloader = RecordingDownloader::new(archive, Err("no sums".into())); + let deps = UpdateDeps { + current: ¤t, + releases: Ok(vec![release(&fake_newer_tag())]), + exe: &exe, + cargo_bin: None, + target: Ok(("x86_64", "unknown-linux-musl")), + downloader: &downloader, + confirm: &|| true, + }; + + assert_eq!(run_update_with(false, false, &deps).unwrap(), 0); + assert_eq!(std::fs::read(&exe).unwrap(), b"state-safe-binary"); + + // State directory: same files, same bytes, nothing added or removed. + let after = snapshot(); + assert_eq!(before, after, "update must not touch ~/.ghscaff state"); + + match previous_home { + Some(value) => std::env::set_var("HOME", value), + None => std::env::remove_var("HOME"), + } +} + +// ── ⑰ asset naming pins (rust-release.yml) ────────────────── + +#[test] +fn asset_naming_matches_the_release_workflow() { + assert_eq!( + asset_name("v0.7.0", "x86_64", "unknown-linux-musl"), + "ghscaff-v0.7.0-x86_64-unknown-linux-musl.tar.gz" + ); + assert_eq!( + asset_name("v0.7.0", "aarch64", "apple-darwin"), + "ghscaff-v0.7.0-aarch64-apple-darwin.tar.gz" + ); + assert_eq!( + asset_url("v0.7.0", ("x86_64", "unknown-linux-musl")), + "https://github.com/UniverLab/ghscaff/releases/download/v0.7.0/ghscaff-v0.7.0-x86_64-unknown-linux-musl.tar.gz" + ); + assert_eq!( + sums_url("v0.7.0"), + "https://github.com/UniverLab/ghscaff/releases/download/v0.7.0/SHA256SUMS.txt" + ); + assert_eq!( + releases_url(), + "https://api.github.com/repos/UniverLab/ghscaff/releases?per_page=100" + ); +} + +// ── fetcher seam (notice + command share the selection) ───── + +#[test] +fn fetch_latest_stable_parses_the_release_list() { + let current = current_version(); + let newer = fake_newer_tag(); + let body = format!( + r#"[{{"tag_name":"{current}","prerelease":false,"draft":false}}, + {{"tag_name":"{newer}","prerelease":false,"draft":false}}, + {{"tag_name":"v99.0.0","prerelease":false,"draft":true}}]"# + ); + let fetcher = FakeFetcher { body: Ok(body) }; + assert_eq!( + fetch_latest_stable_with(&fetcher, ¤t).unwrap(), + Some(newer) + ); +} + +#[test] +fn check_notice_is_silent_on_every_failure() { + let failing = FakeFetcher { + body: Err("connection refused".into()), + }; + assert_eq!(check_notice(&failing, ¤t_version()), None); + + let unparsable = FakeFetcher { + body: Ok("not json".into()), + }; + assert_eq!(check_notice(&unparsable, ¤t_version()), None); + + let offline_shaped = FakeFetcher { + body: Ok("[{\"tag_name\":\"v0.0.1\"}]".into()), + }; + assert_eq!(check_notice(&offline_shaped, ¤t_version()), None); +} + +// ── retained helpers: extraction, replacement, cargo detect ─ + +#[test] +fn extract_binary_finds_named_entry() { + let archive = make_tar_gz(&[("ghscaff", b"fake-binary-contents")]); + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("out"); + extract_binary(std::io::Cursor::new(archive), &output).unwrap(); + assert_eq!(std::fs::read(&output).unwrap(), b"fake-binary-contents"); +} + +#[test] +fn extract_binary_rejects_missing_entry() { + let archive = make_tar_gz(&[("other-file", b"contents")]); + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("out"); + let err = extract_binary(std::io::Cursor::new(archive), &output).unwrap_err(); + assert!(err.to_string().contains("not found")); + assert!(!output.exists()); +} + +#[test] +fn extract_binary_rejects_empty_binary() { + let archive = make_tar_gz(&[("ghscaff", b"")]); + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("out"); + let err = extract_binary(std::io::Cursor::new(archive), &output).unwrap_err(); + assert!(err.to_string().contains("empty")); + assert!(!output.exists()); +} + +#[test] +fn extract_binary_rejects_corrupt_archive() { + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("out"); + let result = extract_binary(std::io::Cursor::new(b"not a gzip stream".to_vec()), &output); + assert!(result.is_err()); + assert!(!output.exists()); +} + +#[test] +fn extract_binary_with_multiple_entries_finds_correct_one() { + let archive = make_tar_gz(&[ + ("README.md", b"not the binary"), + ("ghscaff", b"real-binary-data"), + ("LICENSE", b"MIT"), + ]); + let dir = tempfile::tempdir().unwrap(); + let output = dir.path().join("out"); + extract_binary(std::io::Cursor::new(archive), &output).unwrap(); + assert_eq!(std::fs::read(&output).unwrap(), b"real-binary-data"); +} + +#[test] +fn update_binary_at_replaces_target_and_sets_executable() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("ghscaff"); + std::fs::write(&target, b"old-binary").unwrap(); + let tmp_path = dir.path().join(".ghscaff.update"); + + update_binary_at(&tmp_path, &target, |out| { + std::fs::write(out, b"new-binary")?; + Ok(()) + }) + .unwrap(); + + assert_eq!(std::fs::read(&target).unwrap(), b"new-binary"); + assert!(!tmp_path.exists()); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&target).unwrap().permissions().mode(); + assert_eq!(mode & 0o111, 0o111); + } +} + +#[test] +fn update_binary_at_leaves_target_untouched_when_fetch_fails() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("ghscaff"); + std::fs::write(&target, b"original-binary").unwrap(); + let tmp_path = dir.path().join(".ghscaff.update"); + + let result = update_binary_at(&tmp_path, &target, |_out| { + anyhow::bail!("simulated download failure") + }); + + assert!(result.is_err()); + assert_eq!(std::fs::read(&target).unwrap(), b"original-binary"); + assert!(!tmp_path.exists()); +} + +#[test] +fn update_binary_at_cleans_up_tmp_file_when_extraction_writes_then_fails() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("ghscaff"); + std::fs::write(&target, b"original-binary").unwrap(); + let tmp_path = dir.path().join(".ghscaff.update"); + + let result = update_binary_at(&tmp_path, &target, |out| { + std::fs::write(out, b"partial-garbage")?; + anyhow::bail!("corrupt archive") + }); + + assert!(result.is_err()); + assert!(!tmp_path.exists()); + assert_eq!(std::fs::read(&target).unwrap(), b"original-binary"); +} + +#[test] +fn update_binary_at_preserves_target_on_rename_failure() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("ghscaff"); + std::fs::write(&target, b"original").unwrap(); + let tmp = dir.path().join(".update"); + + // Write to tmp, then try to rename to a non-existent directory + let bad_target = dir.path().join("nonexistent").join("ghscaff"); + let result = update_binary_at(&tmp, &bad_target, |out| { + std::fs::write(out, b"new-content")?; + Ok(()) + }); + + assert!(result.is_err()); + assert_eq!(std::fs::read(&target).unwrap(), b"original"); +} + +#[test] +fn resolve_cargo_root_prefers_install_root() { + let root = resolve_cargo_root( + Some("/opt/install-root".to_string()), + Some("/opt/cargo-home".to_string()), + Some(PathBuf::from("/home/user")), + ); + assert_eq!(root, Some(PathBuf::from("/opt/install-root"))); +} + +#[test] +fn resolve_cargo_root_falls_back_to_cargo_home() { + let root = resolve_cargo_root( + None, + Some("/opt/cargo-home".to_string()), + Some(PathBuf::from("/home/user")), + ); + assert_eq!(root, Some(PathBuf::from("/opt/cargo-home"))); +} + +#[test] +fn resolve_cargo_root_falls_back_to_home_dot_cargo() { + let root = resolve_cargo_root(None, None, Some(PathBuf::from("/home/user"))); + assert_eq!(root, Some(PathBuf::from("/home/user/.cargo"))); +} + +#[test] +fn resolve_cargo_root_ignores_empty_env_values() { + let root = resolve_cargo_root( + Some(String::new()), + Some(String::new()), + Some(PathBuf::from("/home/user")), + ); + assert_eq!(root, Some(PathBuf::from("/home/user/.cargo"))); +} + +#[test] +fn is_cargo_managed_detects_path_inside_root() { + let dir = tempfile::tempdir().unwrap(); + let cargo_root = dir.path().join("cargo"); + let bin_dir = cargo_root.join("bin"); + std::fs::create_dir_all(&bin_dir).unwrap(); + let exe = bin_dir.join("ghscaff"); + std::fs::write(&exe, b"binary").unwrap(); + + assert!(is_cargo_managed_with_root(&exe, Some(cargo_root))); +} + +#[test] +fn is_cargo_managed_rejects_path_outside_root() { + let dir = tempfile::tempdir().unwrap(); + let cargo_root = dir.path().join("cargo"); + std::fs::create_dir_all(cargo_root.join("bin")).unwrap(); + let other_dir = dir.path().join("elsewhere"); + std::fs::create_dir_all(&other_dir).unwrap(); + let exe = other_dir.join("ghscaff"); + std::fs::write(&exe, b"binary").unwrap(); + + assert!(!is_cargo_managed_with_root(&exe, Some(cargo_root))); +} + +#[test] +fn is_cargo_managed_treats_uncanonicalizable_path_as_not_cargo() { + let dir = tempfile::tempdir().unwrap(); + let cargo_root = dir.path().join("cargo"); + std::fs::create_dir_all(cargo_root.join("bin")).unwrap(); + let missing_exe = dir.path().join("does-not-exist"); + + assert!(!is_cargo_managed_with_root(&missing_exe, Some(cargo_root))); +} + +#[test] +fn is_cargo_managed_with_no_resolvable_root_is_false() { + let dir = tempfile::tempdir().unwrap(); + let exe = dir.path().join("ghscaff"); + std::fs::write(&exe, b"binary").unwrap(); + + assert!(!is_cargo_managed_with_root(&exe, None)); +} + +#[test] +fn detect_platform_returns_supported_tuple_on_this_host() { + let result = detect_platform(); + if (cfg!(target_os = "linux") || cfg!(target_os = "macos")) + && (cfg!(target_arch = "x86_64") || cfg!(target_arch = "aarch64")) + { + assert!(result.is_ok()); + } +} + +#[test] +fn cargo_install_root_returns_something() { + // cargo_install_root reads env vars; on any dev machine it should + // resolve to at least Some(...) via CARGO_HOME or ~/.cargo. + let root = cargo_install_root(); + assert!(root.is_some()); +} + +#[test] +fn is_cargo_managed_delegates_correctly() { + let dir = tempfile::tempdir().unwrap(); + let exe = dir.path().join("ghscaff"); + std::fs::write(&exe, b"binary").unwrap(); + // Without a real cargo root, this should be false + assert!(!is_cargo_managed(&exe)); +} + +// ── run_update exit codes through the injected seams ──────────── + +fn release_list_body(tags: &[String]) -> String { + let entries: Vec = tags + .iter() + .map(|tag| format!(r#"{{"tag_name":"{tag}","prerelease":false,"draft":false}}"#)) + .collect(); + format!("[{}]", entries.join(",")) +} + +#[test] +fn check_notice_returns_some_for_newer_stable() { + let current = current_version(); + let newer = fake_newer_tag(); + let fetcher = FakeFetcher { + body: Ok(release_list_body(&[current.clone(), newer.clone()])), + }; + assert_eq!(check_notice(&fetcher, ¤t), Some(newer)); +} + +#[test] +fn run_update_up_to_date_exits_0() { + let current = current_version(); + let fetcher = FakeFetcher { + body: Ok(release_list_body(std::slice::from_ref(¤t))), + }; + let downloader = RecordingDownloader::new(Vec::new(), Err("unused".into())); + assert_eq!(run_update(false, false, &fetcher, &downloader).unwrap(), 0); + assert!( + downloader.calls().is_empty(), + "up to date downloads nothing" + ); +} + +#[test] +fn run_update_check_mode_exits_1_when_newer() { + let current = current_version(); + let fetcher = FakeFetcher { + body: Ok(release_list_body(&[current, fake_newer_tag()])), + }; + let downloader = RecordingDownloader::new(Vec::new(), Err("unused".into())); + assert_eq!(run_update(true, false, &fetcher, &downloader).unwrap(), 1); + assert!(downloader.calls().is_empty(), "--check downloads nothing"); +} + +#[test] +fn run_update_check_failure_exits_2() { + let fetcher = FakeFetcher { + body: Err("dns failure".into()), + }; + let downloader = RecordingDownloader::new(Vec::new(), Err("unused".into())); + assert_eq!(run_update(true, false, &fetcher, &downloader).unwrap(), 2); + assert!(downloader.calls().is_empty()); +} + +#[test] +fn run_update_plain_mode_check_failure_exits_2() { + let fetcher = FakeFetcher { + body: Err("HTTP 403: rate limit exceeded".into()), + }; + let downloader = RecordingDownloader::new(Vec::new(), Err("unused".into())); + assert_eq!(run_update(false, false, &fetcher, &downloader).unwrap(), 2); + assert!(downloader.calls().is_empty()); +} + +#[test] +fn run_update_unparsable_response_exits_2() { + let fetcher = FakeFetcher { + body: Ok("not json".into()), + }; + let downloader = RecordingDownloader::new(Vec::new(), Err("unused".into())); + assert_eq!(run_update(false, false, &fetcher, &downloader).unwrap(), 2); + assert!(downloader.calls().is_empty()); +} + +// ── dry-run path (main maps `update --dry-run` to check = true) ─── + +#[test] +fn dry_run_reports_available_update_without_downloading() { + let current = current_version(); + let fetcher = FakeFetcher { + body: Ok(release_list_body(&[current, fake_newer_tag()])), + }; + let downloader = RecordingDownloader::new(make_tar_gz(&[("ghscaff", b"new")]), Err("x".into())); + // `--yes` is ignored on the dry-run path: still a read-only report. + assert_eq!(run_update(true, true, &fetcher, &downloader).unwrap(), 1); + assert!( + downloader.calls().is_empty(), + "dry run never downloads or installs" + ); +} + +#[test] +fn dry_run_reports_up_to_date_without_downloading() { + let current = current_version(); + let fetcher = FakeFetcher { + body: Ok(release_list_body(std::slice::from_ref(¤t))), + }; + let downloader = RecordingDownloader::new(make_tar_gz(&[("ghscaff", b"new")]), Err("x".into())); + assert_eq!(run_update(true, true, &fetcher, &downloader).unwrap(), 0); + assert!( + downloader.calls().is_empty(), + "dry run never downloads or installs" + ); +} diff --git a/src/vault.rs b/src/vault.rs index 797a7a9..d613767 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -1,10 +1,10 @@ use anyhow::{Context, Result}; use blake2::digest::{Update, VariableOutput}; use blake2::Blake2bVar; +use crypto_secretbox::aead::KeyInit; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::{Path, PathBuf}; -use xsalsa20poly1305::KeyInit; const NONCE_LEN: usize = 24; const KEY_LEN: usize = 32; @@ -48,11 +48,11 @@ fn vault_path() -> Result { /// File format: [nonce:24][ciphertext+poly1305_tag] fn save_to_path(data: &VaultData, passphrase: &str, path: &Path) -> Result<()> { - use crypto_box::aead::{generic_array::GenericArray, rand_core::RngCore, Aead, OsRng}; + use crypto_secretbox::aead::{generic_array::GenericArray, rand_core::RngCore, Aead, OsRng}; let key_bytes = derive_key(passphrase)?; let key = GenericArray::from_slice(&key_bytes); - let cipher = xsalsa20poly1305::XSalsa20Poly1305::new(key); + let cipher = crypto_secretbox::XSalsa20Poly1305::new(key); let mut nonce_bytes = [0u8; NONCE_LEN]; OsRng.fill_bytes(&mut nonce_bytes); @@ -99,7 +99,7 @@ fn save_to_path(data: &VaultData, passphrase: &str, path: &Path) -> Result<()> { } fn load_from_path(passphrase: &str, path: &Path) -> Result> { - use crypto_box::aead::{generic_array::GenericArray, Aead}; + use crypto_secretbox::aead::{generic_array::GenericArray, Aead}; if !path.exists() { return Ok(None); @@ -113,7 +113,7 @@ fn load_from_path(passphrase: &str, path: &Path) -> Result> { let (nonce_bytes, ciphertext) = blob.split_at(NONCE_LEN); let key_bytes = derive_key(passphrase)?; let key = GenericArray::from_slice(&key_bytes); - let cipher = xsalsa20poly1305::XSalsa20Poly1305::new(key); + let cipher = crypto_secretbox::XSalsa20Poly1305::new(key); let nonce = GenericArray::from_slice(nonce_bytes); let plaintext = cipher @@ -318,6 +318,62 @@ mod tests { .contains("Decryption failed")); } + /// A value sealed directly with `crypto_secretbox` (the crate that + /// replaced `xsalsa20poly1305`) must open with the same key material and + /// file layout the vault uses: `[nonce:24][ciphertext + 16-byte tag]`, + /// key derived from the passphrase via `derive_key`. + #[test] + fn sealed_value_opens_with_same_key_material() { + use crypto_secretbox::aead::{ + generic_array::GenericArray, rand_core::RngCore, Aead, OsRng, + }; + + let passphrase = "sealed-with-new-crate"; + let key_bytes = derive_key(passphrase).unwrap(); + let cipher = crypto_secretbox::XSalsa20Poly1305::new(GenericArray::from_slice(&key_bytes)); + + let mut nonce_bytes = [0u8; NONCE_LEN]; + OsRng.fill_bytes(&mut nonce_bytes); + + let plaintext = serde_json::to_vec(&VaultData { + github_token: Some("ghp_sealed_probe".into()), + has_passphrase: true, + secrets: HashMap::from([("SEALED_KEY".into(), "sealed_value".into())]), + }) + .unwrap(); + + let ciphertext = cipher + .encrypt(GenericArray::from_slice(&nonce_bytes), plaintext.as_ref()) + .unwrap(); + // XSalsa20Poly1305 overhead: 16-byte Poly1305 tag, nonce stored beside it + assert_eq!(ciphertext.len(), plaintext.len() + 16); + + let mut blob = Vec::with_capacity(NONCE_LEN + ciphertext.len()); + blob.extend_from_slice(&nonce_bytes); + blob.extend_from_slice(&ciphertext); + + // Same key material opens it through the production reader. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.enc"); + std::fs::write(&path, &blob).unwrap(); + let loaded = load_from_path(passphrase, &path).unwrap().unwrap(); + assert_eq!(loaded.github_token.as_deref(), Some("ghp_sealed_probe")); + assert_eq!(loaded.secrets.get("SEALED_KEY").unwrap(), "sealed_value"); + + // Different key material must not open the same blob. + let (nonce_read, ciphertext_read) = blob.split_at(NONCE_LEN); + let wrong_key = derive_key("different-passphrase").unwrap(); + assert_ne!(wrong_key, key_bytes); + let wrong_cipher = + crypto_secretbox::XSalsa20Poly1305::new(GenericArray::from_slice(&wrong_key)); + assert!(wrong_cipher + .decrypt( + GenericArray::from_slice(nonce_read), + ciphertext_read.as_ref() + ) + .is_err()); + } + #[test] fn load_nonexistent_returns_none() { let dir = tempfile::tempdir().unwrap(); @@ -1226,8 +1282,7 @@ mod tests { let _result = exists(); } - use std::sync::Mutex; - static VAULT_MUTEX: Mutex<()> = Mutex::new(()); + use crate::github::test_utils::env_lock; /// Points `HOME` (and thus `vault_path()`) at a private temp directory /// for the lifetime of the guard, restoring the previous value on drop. @@ -1249,8 +1304,8 @@ mod tests { fn new() -> Self { let dir = tempfile::tempdir().unwrap(); let previous = std::env::var_os("HOME"); - // SAFETY: guarded by VAULT_MUTEX, so no other thread in this - // process observes or mutates HOME concurrently. + // SAFETY: guarded by the shared `env_lock`, so no other thread + // in this process observes or mutates HOME concurrently. unsafe { std::env::set_var("HOME", dir.path()) }; Self { _dir: dir, @@ -1261,7 +1316,7 @@ mod tests { impl Drop for HomeGuard { fn drop(&mut self) { - // SAFETY: same guarantee as in `new` — still under VAULT_MUTEX. + // SAFETY: same guarantee as in `new` — still under `env_lock`. match self.previous.take() { Some(home) => unsafe { std::env::set_var("HOME", home) }, None => unsafe { std::env::remove_var("HOME") }, @@ -1271,7 +1326,7 @@ mod tests { #[test] fn test_save_load_via_wrappers_with_backup() { - let _lock = VAULT_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _lock = env_lock(); let _home = HomeGuard::new(); let data = VaultData { @@ -1287,7 +1342,7 @@ mod tests { #[test] fn test_destroy_nonexistent_returns_false() { - let _lock = VAULT_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _lock = env_lock(); let _home = HomeGuard::new(); assert!(!exists()); @@ -1297,7 +1352,7 @@ mod tests { #[test] fn test_destroy_existing_returns_true() { - let _lock = VAULT_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _lock = env_lock(); let _home = HomeGuard::new(); save(&VaultData::default(), "").unwrap(); @@ -1309,7 +1364,7 @@ mod tests { #[test] fn test_save_secret_public_api_with_backup() { - let _lock = VAULT_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _lock = env_lock(); let _home = HomeGuard::new(); save_secret("GHSCAFF_PUB_SEC", "pub_val", "").unwrap(); @@ -1319,7 +1374,7 @@ mod tests { #[test] fn test_save_secret_overwrite_public_api() { - let _lock = VAULT_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _lock = env_lock(); let _home = HomeGuard::new(); save_secret("GHSCAFF_OW2", "first", "").unwrap(); diff --git a/src/wizard/execution.rs b/src/wizard/execution.rs new file mode 100644 index 0000000..46d0d59 --- /dev/null +++ b/src/wizard/execution.rs @@ -0,0 +1,426 @@ +use anyhow::Result; + +use crate::github::{branches, client::GithubClient, contents, labels, repo, secrets, teams}; +use crate::templates; + +use super::{ + count_steps, current_year, offer_gitkit_clone, offer_sponsor_button, prompt_secret_value, + WizardConfig, +}; + +#[cfg(test)] +mod tests; + +/// Counts and prints the wizard's "[n/total]" progress lines. In dry-run +/// mode the same lines are printed with a `[dry-run]` marker and the step +/// body is never run. +struct StepCounter { + current: usize, + total: usize, + dry_run: bool, +} + +impl StepCounter { + fn new(total: usize, dry_run: bool) -> Self { + Self { + current: 0, + total, + dry_run, + } + } + + /// Report the start of the next step and return its `(n, total)` numbers. + fn advance(&mut self) -> (usize, usize) { + self.current += 1; + (self.current, self.total) + } + + /// Print one progress line and, unless this is a dry run, run `op`. + fn run(&mut self, msg: &str, op: impl FnOnce() -> Result<()>) -> Result<()> { + let (n, total) = self.advance(); + if self.dry_run { + println!(" [{n}/{total}] [dry-run] {msg}"); + return Ok(()); + } + print!(" [{n}/{total}] {msg}... "); + op()?; + println!("ok"); + Ok(()) + } + + /// Count a step that printed nothing of its own (a skipped secret). + fn skip(&mut self) { + self.advance(); + } +} + +/// The boilerplate template the wizard fetched (when a language was chosen) +/// plus the secrets that template declares. +struct WizardInitPlan { + template: Option>, + secret_specs: Vec, +} + +/// Fetch the selected template (if any) and the secrets it needs. +fn fetch_wizard_template(c: &WizardConfig, token: &str) -> Result { + let template = if let Some(lang) = &c.language { + print!(" Fetching boilerplate template... "); + let t = templates::resolve(lang, token, true)?; + println!("ok"); + Some(t) + } else { + None + }; + let secret_specs = c + .language + .as_deref() + .map(templates::load_secrets) + .unwrap_or_default(); + Ok(WizardInitPlan { + template, + secret_specs, + }) +} + +/// Collect every file for the single init commit: template boilerplate plus +/// .gitignore, and the LICENSE with its copyright placeholders filled in. +fn build_wizard_init_files( + client: &GithubClient, + c: &WizardConfig, + template: Option<&dyn templates::LanguageTemplate>, +) -> Result> { + let mut init_files: Vec = vec![]; + + if let Some(tmpl) = template { + for f in tmpl.boilerplate_files(&c.name, &c.description, &c.owner, c.license.as_deref()) { + init_files.push(contents::TreeFile { + path: f.path, + content: f.content, + }); + } + + let gitignore = repo::get_gitignore_template(client, &tmpl.gitignore_name()) + .unwrap_or_else(|_| { + eprintln!(" ⚠ Could not fetch .gitignore template from GitHub"); + String::new() + }); + init_files.push(contents::TreeFile { + path: ".gitignore".into(), + content: templates::assemble_gitignore(&gitignore), + }); + } + + // LICENSE — full text from GitHub's license API, with copyright placeholders + // filled in (owner + current year). MIT uses [year]/[fullname]; the Apache + // and GPL appendices use [yyyy]/[name of copyright owner]. + if let Some(lic) = &c.license { + let year = current_year(); + let license_text = repo::get_license_template(client, &lic.to_lowercase())? + .replace("[year]", &year) + .replace("[yyyy]", &year) + .replace("[fullname]", &c.owner) + .replace("[name of copyright owner]", &c.owner); + init_files.push(contents::TreeFile { + path: "LICENSE".into(), + content: license_text, + }); + } + + Ok(init_files) +} + +/// Print the no-license notice once when the rust template was rendered +/// without a license, so the user knows why the crate is not publishable. +/// Returns whether the notice was printed. +fn warn_if_rust_without_license(c: &WizardConfig) -> bool { + let warned = c + .language + .as_deref() + .is_some_and(|lang| templates::is_rust_without_license(lang, c.license.as_deref())); + if warned { + println!(" {}", templates::NO_LICENSE_NOTICE); + } + warned +} + +/// Step 1 — create the repo (empty; the initial commit follows via the Trees API). +fn create_wizard_repo_step( + progress: &mut StepCounter, + client: &GithubClient, + c: &WizardConfig, +) -> Result> { + let (n, total) = progress.advance(); + if progress.dry_run { + println!( + " [{n}/{total}] [dry-run] create repo {}/{}", + c.owner, c.name + ); + return Ok(None); + } + print!(" [{n}/{total}] create repo {}/{}... ", c.owner, c.name); + let r = repo::create_repo( + client, + &c.owner, + &c.name, + &c.description, + c.private, + c.is_org, + )?; + println!("ok ({})", r.html_url); + Ok(Some(r)) +} + +/// Step 3 — one init commit with all the files (skipped for an empty repo +/// with no LICENSE); records the commit SHA the develop branch step needs. +fn commit_init_files_step( + progress: &mut StepCounter, + client: &GithubClient, + c: &WizardConfig, + init_files: &[contents::TreeFile], + init_sha: &mut String, +) -> Result<()> { + if init_files.is_empty() { + return Ok(()); + } + progress.run("init repository", || { + *init_sha = contents::create_tree_commit( + client, + &c.owner, + &c.name, + init_files, + "chore: init repository", + &c.default_branch, + )?; + Ok(()) + }) +} + +/// Step 4 — develop branch off the just-committed default branch. In dry-run +/// the branch SHA is not fetched: the repo was never created and the SHA is +/// only consumed by the op the dry-run skips. +fn create_develop_step( + progress: &mut StepCounter, + client: &GithubClient, + c: &WizardConfig, + init_sha: &mut String, +) -> Result<()> { + if !c.create_develop { + return Ok(()); + } + if init_sha.is_empty() && !progress.dry_run { + *init_sha = branches::get_branch_sha(client, &c.owner, &c.name, &c.default_branch)?; + } + let sha = init_sha.as_str(); + progress.run("create develop branch", || { + branches::create_branch(client, &c.owner, &c.name, "develop", sha)?; + Ok(()) + }) +} + +/// Step 5 — branch protection. Required contexts are derived from the +/// workflow files just committed, never hardcoded, so a renamed job can't +/// leave a required check pointing at a name nothing will ever report. +fn apply_wizard_protections_step( + progress: &mut StepCounter, + client: &GithubClient, + c: &WizardConfig, + init_files: &[contents::TreeFile], +) -> Result<()> { + let workflow_sources: Vec = init_files + .iter() + .map(|f| crate::checks::WorkflowSource { + path: &f.path, + content: &f.content, + }) + .collect(); + let required_contexts = crate::checks::derive_required_contexts(&workflow_sources); + + progress.run( + &format!("apply branch protection ({})", c.default_branch), + || { + branches::apply_branch_protection( + client, + &c.owner, + &c.name, + &c.default_branch, + &required_contexts, + )?; + Ok(()) + }, + )?; + if c.create_develop { + progress.run("apply branch protection (develop)", || { + branches::apply_branch_protection( + client, + &c.owner, + &c.name, + "develop", + &required_contexts, + )?; + Ok(()) + })?; + } + Ok(()) +} + +/// Step 6 — labels. +fn sync_wizard_labels_step( + progress: &mut StepCounter, + client: &GithubClient, + c: &WizardConfig, +) -> Result<()> { + if !c.create_labels { + return Ok(()); + } + progress.run("sync labels", || { + let existing = labels::list_labels(client, &c.owner, &c.name)?; + let standard = labels::standard_labels(); + for label in &standard { + if existing.iter().any(|e| e.name == label.name) { + labels::update_label(client, &c.owner, &c.name, &label.name, label)?; + } else { + labels::create_label(client, &c.owner, &c.name, label)?; + } + } + for existing_label in &existing { + if !standard.iter().any(|s| s.name == existing_label.name) { + let _ = labels::delete_label(client, &c.owner, &c.name, &existing_label.name); + } + } + Ok(()) + }) +} + +/// Step 7 — topics. +fn set_wizard_topics_step( + progress: &mut StepCounter, + client: &GithubClient, + c: &WizardConfig, +) -> Result<()> { + if c.topics.is_empty() { + return Ok(()); + } + progress.run("set topics", || { + repo::set_topics(client, &c.owner, &c.name, &c.topics)?; + Ok(()) + }) +} + +/// Step 8 — team access for each team picked during configuration. +fn grant_wizard_team_access_step( + progress: &mut StepCounter, + client: &GithubClient, + c: &WizardConfig, +) -> Result<()> { + for team in &c.team_access { + progress.run( + &format!( + "add team {} with {} access", + team.team_slug, team.permission + ), + || { + teams::add_team_to_repo( + client, + &c.owner, + &c.name, + &team.team_slug, + &team.permission, + )?; + Ok(()) + }, + )?; + } + Ok(()) +} + +/// Step 9 — template secrets: env → vault → prompt. +fn configure_wizard_secrets_step( + progress: &mut StepCounter, + client: &GithubClient, + c: &WizardConfig, + passphrase: &str, + secret_specs: &[templates::SecretSpec], +) -> Result<()> { + for spec in secret_specs { + let value = if let Some(val) = crate::vault::resolve_secret(&spec.name, passphrase)? { + println!(" ◆ Secret {}: found", spec.name); + Some(val) + } else { + prompt_secret_value(spec, passphrase)? + }; + if let Some(val) = value { + progress.run(&format!("configure secret {}", spec.name), || { + secrets::set_secret(client, &c.owner, &c.name, &spec.name, &val)?; + Ok(()) + })?; + } else { + progress.skip(); // keep total consistent even when skipped + } + } + Ok(()) +} + +/// The clone-offer / sponsor-button follow-ups run only on a real +/// (non dry-run) run that actually created a repo. +fn followups_enabled(dry_run: bool, created: bool) -> bool { + !dry_run && created +} + +pub(super) fn execute( + client: &GithubClient, + c: &WizardConfig, + dry_run: bool, + token: &str, + passphrase: &str, +) -> Result<()> { + println!(); + + // Fetch template if selected + let plan = fetch_wizard_template(c, token)?; + let total = count_steps(c, &plan.secret_specs); + let mut progress = StepCounter::new(total, dry_run); + + // 1. Create repo (empty — initial commit via Trees API below) + let created_repo = create_wizard_repo_step(&mut progress, client, c)?; + + // 2. Collect all boilerplate files for a single init commit + let init_files = build_wizard_init_files(client, c, plan.template.as_deref())?; + warn_if_rust_without_license(c); + + // 3. Single init commit with all files (skip if empty repo with no LICENSE) + let mut init_sha = String::new(); + commit_init_files_step(&mut progress, client, c, &init_files, &mut init_sha)?; + + // 4. develop branch + create_develop_step(&mut progress, client, c, &mut init_sha)?; + + // 5. Branch protection (default branch, then develop when requested) + apply_wizard_protections_step(&mut progress, client, c, &init_files)?; + + // 6. Labels + sync_wizard_labels_step(&mut progress, client, c)?; + + // 7. Topics + set_wizard_topics_step(&mut progress, client, c)?; + + // 8. Team access + grant_wizard_team_access_step(&mut progress, client, c)?; + + // 9. Template secrets: env → vault → prompt + configure_wizard_secrets_step(&mut progress, client, c, passphrase, &plan.secret_specs)?; + + println!(); + if let Some(r) = &created_repo { + println!(" Done — {}", r.html_url); + } else { + println!(" Done (dry-run)"); + } + println!(); + + if followups_enabled(dry_run, created_repo.is_some()) { + offer_gitkit_clone(&c.owner, &c.name); + offer_sponsor_button(client, &c.owner, &c.name); + } + + Ok(()) +} diff --git a/src/wizard/execution/tests.rs b/src/wizard/execution/tests.rs new file mode 100644 index 0000000..88015e5 --- /dev/null +++ b/src/wizard/execution/tests.rs @@ -0,0 +1,669 @@ +//! Step-function tests for `super` (see `src/wizard/execution.rs`). +//! +//! Every `*_step` helper is driven against the recording mock server and +//! asserts the exact `(method, path)` pairs it issues on the success path, +//! plus the error/guard branches. `inquire` prompts fail without a TTY, so +//! run the suite as `cargo test >>; + +fn snapshot(log: &CallLog) -> Vec<(String, String)> { + log.lock().unwrap().clone() +} + +fn has_call(log: &CallLog, method: &str, path_part: &str) -> bool { + snapshot(log) + .iter() + .any(|(m, p)| m == method && p.contains(path_part)) +} + +fn count_calls(log: &CallLog, method: &str, path_part: &str) -> usize { + snapshot(log) + .iter() + .filter(|(m, p)| m == method && p.contains(path_part)) + .count() +} + +fn not_found() -> (u16, String) { + (404, r#"{"message":"Not Found"}"#.to_string()) +} + +fn repo_json() -> String { + r#"{"full_name":"owner/repo","html_url":"https://github.com/owner/repo","default_branch":"main","topics":[]}"#.to_string() +} + +fn ref_json() -> String { + r#"{"ref":"refs/heads/main","object":{"sha":"abc123"}}"#.to_string() +} + +fn minimal_config() -> WizardConfig { + WizardConfig { + name: "repo".to_string(), + description: "test repo".to_string(), + topics: vec![], + private: false, + owner: "owner".to_string(), + is_org: false, + language: None, + default_branch: "main".to_string(), + create_develop: false, + license: None, + create_labels: false, + team_access: vec![], + } +} + +// ── StepCounter ─────────────────────────────────────────────────── + +#[test] +fn step_counter_advance_counts_up() { + let mut counter = StepCounter::new(3, false); + assert_eq!(counter.advance(), (1, 3)); + assert_eq!(counter.advance(), (2, 3)); +} + +#[test] +fn step_counter_run_propagates_error() { + let mut counter = StepCounter::new(1, false); + let result = counter.run("failing op", || Err(anyhow::anyhow!("boom"))); + assert!(result.is_err()); +} + +#[test] +fn step_counter_run_dry_run_skips_op() { + let mut counter = StepCounter::new(1, true); + counter + .run("skipped op", || panic!("must not run in dry-run")) + .unwrap(); +} + +#[test] +fn step_counter_skip_counts_without_output() { + let mut counter = StepCounter::new(5, false); + counter.skip(); + assert_eq!(counter.advance(), (2, 5)); +} + +// ── build_wizard_init_files ─────────────────────────────────────── + +#[test] +fn build_wizard_init_files_includes_boilerplate() { + let (url, _) = start_recording_mock_server(|method, path| { + if method == "GET" && path.contains("/gitignore/templates/") { + (200, r#"{"source":"*.log"}"#.to_string()) + } else { + not_found() + } + }); + let client = mock_client(&url); + let config = minimal_config(); + let template = crate::templates::rust::RustTemplate; + let files = build_wizard_init_files(&client, &config, Some(&template)).unwrap(); + assert!(!files.is_empty()); + assert!(files.iter().any(|f| f.path == "Cargo.toml")); + assert!(files.iter().any(|f| f.path == ".gitignore")); +} + +#[test] +fn build_wizard_init_files_empty_without_template_or_license() { + let (url, log) = start_recording_mock_server(|_, path| { + panic!("no requests expected without template or license, got: {path}") + }); + let client = mock_client(&url); + let config = minimal_config(); + let files = build_wizard_init_files(&client, &config, None).unwrap(); + assert!(files.is_empty()); + assert!(snapshot(&log).is_empty()); +} + +// ── create_wizard_repo_step ─────────────────────────────────────── + +#[test] +fn create_wizard_repo_step_creates_repo() { + let (url, log) = start_recording_mock_server(|method, path| { + if method == "POST" && path == "/user/repos" { + (201, repo_json()) + } else { + not_found() + } + }); + let client = mock_client(&url); + let config = minimal_config(); + let mut progress = StepCounter::new(2, false); + let repo = create_wizard_repo_step(&mut progress, &client, &config).unwrap(); + assert!(repo.is_some()); + assert!(has_call(&log, "POST", "/user/repos")); +} + +#[test] +fn create_wizard_repo_step_dry_run_returns_none() { + let (url, log) = start_recording_mock_server(|_, path| { + panic!("no requests expected for a dry run, got: {path}") + }); + let client = mock_client(&url); + let config = minimal_config(); + let mut progress = StepCounter::new(2, true); + let repo = create_wizard_repo_step(&mut progress, &client, &config).unwrap(); + assert!(repo.is_none()); + assert!(snapshot(&log).is_empty()); +} + +#[test] +fn create_wizard_repo_step_propagates_error() { + let (url, _) = + start_recording_mock_server(|_, _| (422, r#"{"message":"Validation Failed"}"#.to_string())); + let client = mock_client(&url); + let config = minimal_config(); + let mut progress = StepCounter::new(2, false); + assert!(create_wizard_repo_step(&mut progress, &client, &config).is_err()); +} + +// ── commit_init_files_step ──────────────────────────────────────── + +fn tree_commit_handler() -> impl Fn(&str, &str) -> (u16, String) { + move |method, path| { + if method == "GET" && path.contains("/git/refs/heads/") { + (200, r#"{"object":{"sha":"basesha"}}"#.to_string()) + } else if method == "GET" && path.contains("/git/commits/") { + ( + 200, + r#"{"sha":"basesha","tree":{"sha":"basetree"}}"#.to_string(), + ) + } else if method == "POST" && path.contains("/git/blobs") { + (201, r#"{"sha":"blobsha"}"#.to_string()) + } else if method == "POST" && path.contains("/git/trees") { + (201, r#"{"sha":"treesha"}"#.to_string()) + } else if method == "POST" && path.contains("/git/commits") { + ( + 201, + r#"{"sha":"newsha","tree":{"sha":"treesha"}}"#.to_string(), + ) + } else if method == "PATCH" && path.contains("/git/refs/heads/") { + (200, r#"{"sha":"newsha"}"#.to_string()) + } else { + not_found() + } + } +} + +fn one_file() -> Vec { + vec![contents::TreeFile { + path: "README.md".to_string(), + content: "# hi".to_string(), + }] +} + +#[test] +fn commit_init_files_step_commits_and_sets_sha() { + let (url, log) = start_recording_mock_server(tree_commit_handler()); + let client = mock_client(&url); + let config = minimal_config(); + let files = one_file(); + let mut progress = StepCounter::new(2, false); + let mut sha = String::new(); + commit_init_files_step(&mut progress, &client, &config, &files, &mut sha).unwrap(); + assert_eq!(sha, "newsha"); + assert!(has_call(&log, "POST", "/repos/owner/repo/git/blobs")); + assert!(has_call(&log, "POST", "/repos/owner/repo/git/trees")); + assert!(has_call(&log, "POST", "/repos/owner/repo/git/commits")); +} + +#[test] +fn commit_init_files_step_fails_on_server_error() { + let (url, log) = start_recording_mock_server(|method, path| { + if method == "GET" && path.contains("/git/refs/heads/") { + (200, r#"{"object":{"sha":"basesha"}}"#.to_string()) + } else if method == "GET" && path.contains("/git/commits/") { + ( + 200, + r#"{"sha":"basesha","tree":{"sha":"basetree"}}"#.to_string(), + ) + } else { + (500, r#"{"message":"Server Error"}"#.to_string()) + } + }); + let client = mock_client(&url); + let config = minimal_config(); + let files = one_file(); + let mut progress = StepCounter::new(2, false); + let mut sha = String::new(); + let result = commit_init_files_step(&mut progress, &client, &config, &files, &mut sha); + assert!(result.is_err(), "a 5xx on blob creation must surface"); + assert!(sha.is_empty(), "init sha must stay unset on failure"); + assert!(has_call(&log, "POST", "/repos/owner/repo/git/blobs")); +} + +#[test] +fn commit_init_files_step_skips_without_files() { + let (url, log) = start_recording_mock_server(|_, path| { + panic!("no requests expected without files, got: {path}") + }); + let client = mock_client(&url); + let config = minimal_config(); + let mut progress = StepCounter::new(2, false); + let mut sha = String::new(); + commit_init_files_step(&mut progress, &client, &config, &[], &mut sha).unwrap(); + assert!(sha.is_empty()); + assert!(snapshot(&log).is_empty()); +} + +// ── create_develop_step ─────────────────────────────────────────── + +#[test] +fn create_develop_step_creates_branch() { + let (url, log) = start_recording_mock_server(|method, path| { + if method == "POST" && path.contains("/git/refs") { + (201, r#"{"ref":"refs/heads/develop"}"#.to_string()) + } else { + not_found() + } + }); + let client = mock_client(&url); + let mut config = minimal_config(); + config.create_develop = true; + let mut progress = StepCounter::new(4, false); + let mut sha = "presha".to_string(); + create_develop_step(&mut progress, &client, &config, &mut sha).unwrap(); + assert_eq!(sha, "presha"); + assert!(has_call(&log, "POST", "/repos/owner/repo/git/refs")); +} + +#[test] +fn create_develop_step_skips_when_disabled() { + let (url, log) = start_recording_mock_server(|_, path| { + panic!("no requests expected when develop is disabled, got: {path}") + }); + let client = mock_client(&url); + let config = minimal_config(); + let mut progress = StepCounter::new(2, false); + let mut sha = String::new(); + create_develop_step(&mut progress, &client, &config, &mut sha).unwrap(); + assert!(sha.is_empty()); + assert!(snapshot(&log).is_empty()); +} + +#[test] +fn create_develop_step_propagates_error() { + let (url, _) = + start_recording_mock_server(|_, _| (500, r#"{"message":"Server Error"}"#.to_string())); + let client = mock_client(&url); + let mut config = minimal_config(); + config.create_develop = true; + let mut progress = StepCounter::new(4, false); + let mut sha = "presha".to_string(); + assert!(create_develop_step(&mut progress, &client, &config, &mut sha).is_err()); +} + +#[test] +fn create_develop_step_dry_run_fetches_no_branch_sha() { + // A dry run never created the repo, so reading `main` back would 404 and + // abort the whole dry run; the SHA is only consumed by the skipped op. + let (url, log) = start_recording_mock_server(|_, path| { + panic!("no requests expected for a dry run, got: {path}") + }); + let client = mock_client(&url); + let mut config = minimal_config(); + config.create_develop = true; + let mut progress = StepCounter::new(4, true); + let mut sha = String::new(); + create_develop_step(&mut progress, &client, &config, &mut sha).unwrap(); + assert!(sha.is_empty()); + assert!(snapshot(&log).is_empty()); +} + +// ── apply_wizard_protections_step ───────────────────────────────── + +fn protections_handler(put_status: u16) -> impl Fn(&str, &str) -> (u16, String) { + move |method, path| { + if method == "GET" && path.contains("/git/ref/heads/") { + (200, ref_json()) + } else if method == "PUT" && path.contains("/protection") { + (put_status, "{}".to_string()) + } else { + not_found() + } + } +} + +fn workflow_files() -> Vec { + vec![contents::TreeFile { + path: ".github/workflows/ci.yml".to_string(), + content: "name: CI\non: [push]\njobs:\n test:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n" + .to_string(), + }] +} + +#[test] +fn apply_wizard_protections_step_puts_main_and_develop() { + let (url, log) = start_recording_mock_server(protections_handler(200)); + let client = mock_client(&url); + let mut config = minimal_config(); + config.create_develop = true; + let files = workflow_files(); + let mut progress = StepCounter::new(4, false); + apply_wizard_protections_step(&mut progress, &client, &config, &files).unwrap(); + assert!(has_call( + &log, + "PUT", + "/repos/owner/repo/branches/main/protection" + )); + assert!(has_call( + &log, + "PUT", + "/repos/owner/repo/branches/develop/protection" + )); +} + +#[test] +fn apply_wizard_protections_step_propagates_error() { + let (url, log) = start_recording_mock_server(protections_handler(500)); + let client = mock_client(&url); + let config = minimal_config(); + let files = workflow_files(); + let mut progress = StepCounter::new(4, false); + assert!(apply_wizard_protections_step(&mut progress, &client, &config, &files).is_err()); + assert!(has_call( + &log, + "PUT", + "/repos/owner/repo/branches/main/protection" + )); +} + +// ── sync_wizard_labels_step ─────────────────────────────────────── + +fn wizard_labels_handler() -> impl Fn(&str, &str) -> (u16, String) { + move |method, path| { + if method == "GET" && path.contains("/labels?per_page=100") { + ( + 200, + r#"[{"name":"bug","color":"ff0000","description":"Old"},{"name":"stale","color":"000000","description":"Stale"}]"#.to_string(), + ) + } else if method == "PATCH" && path.contains("/labels/") { + ( + 200, + r#"{"name":"bug","color":"d73a4a","description":"Something isn't working"}"# + .to_string(), + ) + } else if method == "POST" && path.ends_with("/labels") { + ( + 201, + r#"{"name":"x","color":"000000","description":"x"}"#.to_string(), + ) + } else if method == "DELETE" && path.contains("/labels/") { + (204, String::new()) + } else { + not_found() + } + } +} + +#[test] +fn sync_wizard_labels_step_reconciles() { + let (url, log) = start_recording_mock_server(wizard_labels_handler()); + let client = mock_client(&url); + let mut config = minimal_config(); + config.create_labels = true; + let mut progress = StepCounter::new(3, false); + sync_wizard_labels_step(&mut progress, &client, &config).unwrap(); + assert!(has_call( + &log, + "GET", + "/repos/owner/repo/labels?per_page=100" + )); + assert_eq!( + count_calls(&log, "PATCH", "/repos/owner/repo/labels/bug"), + 1 + ); + assert_eq!(count_calls(&log, "POST", "/repos/owner/repo/labels"), 6); + assert!(has_call(&log, "DELETE", "/repos/owner/repo/labels/stale")); + assert!(!has_call(&log, "DELETE", "/repos/owner/repo/labels/bug")); +} + +#[test] +fn sync_wizard_labels_step_skips_when_disabled() { + let (url, log) = start_recording_mock_server(|_, path| { + panic!("no requests expected when labels are disabled, got: {path}") + }); + let client = mock_client(&url); + let config = minimal_config(); + let mut progress = StepCounter::new(2, false); + sync_wizard_labels_step(&mut progress, &client, &config).unwrap(); + assert!(snapshot(&log).is_empty()); +} + +#[test] +fn sync_wizard_labels_step_propagates_error() { + let (url, _) = + start_recording_mock_server(|_, _| (500, r#"{"message":"Server Error"}"#.to_string())); + let client = mock_client(&url); + let mut config = minimal_config(); + config.create_labels = true; + let mut progress = StepCounter::new(3, false); + assert!(sync_wizard_labels_step(&mut progress, &client, &config).is_err()); +} + +// ── set_wizard_topics_step ──────────────────────────────────────── + +#[test] +fn set_wizard_topics_step_sets_topics() { + let (url, log) = start_recording_mock_server(|method, path| { + if method == "PUT" && path.contains("/topics") { + (200, r#"{"names":["rust"]}"#.to_string()) + } else { + not_found() + } + }); + let client = mock_client(&url); + let mut config = minimal_config(); + config.topics = vec!["rust".to_string()]; + let mut progress = StepCounter::new(3, false); + set_wizard_topics_step(&mut progress, &client, &config).unwrap(); + assert!(has_call(&log, "PUT", "/repos/owner/repo/topics")); +} + +#[test] +fn set_wizard_topics_step_skips_when_empty() { + let (url, log) = start_recording_mock_server(|_, path| { + panic!("no requests expected without topics, got: {path}") + }); + let client = mock_client(&url); + let config = minimal_config(); + let mut progress = StepCounter::new(2, false); + set_wizard_topics_step(&mut progress, &client, &config).unwrap(); + assert!(snapshot(&log).is_empty()); +} + +#[test] +fn set_wizard_topics_step_propagates_error() { + let (url, _) = + start_recording_mock_server(|_, _| (500, r#"{"message":"Server Error"}"#.to_string())); + let client = mock_client(&url); + let mut config = minimal_config(); + config.topics = vec!["rust".to_string()]; + let mut progress = StepCounter::new(3, false); + assert!(set_wizard_topics_step(&mut progress, &client, &config).is_err()); +} + +// ── grant_wizard_team_access_step ───────────────────────────────── + +#[test] +fn grant_wizard_team_access_step_adds_teams() { + let (url, log) = start_recording_mock_server(|method, path| { + if method == "PUT" && path.contains("/teams/") { + (204, String::new()) + } else { + not_found() + } + }); + let client = mock_client(&url); + let mut config = minimal_config(); + config.team_access = vec![crate::github::teams::TeamAccess { + team_slug: "backend".to_string(), + permission: "push".to_string(), + }]; + let mut progress = StepCounter::new(3, false); + grant_wizard_team_access_step(&mut progress, &client, &config).unwrap(); + assert!(has_call( + &log, + "PUT", + "/orgs/owner/teams/backend/repos/owner/repo" + )); +} + +#[test] +fn grant_wizard_team_access_step_propagates_error() { + let (url, _) = + start_recording_mock_server(|_, _| (500, r#"{"message":"Server Error"}"#.to_string())); + let client = mock_client(&url); + let mut config = minimal_config(); + config.team_access = vec![crate::github::teams::TeamAccess { + team_slug: "backend".to_string(), + permission: "push".to_string(), + }]; + let mut progress = StepCounter::new(3, false); + assert!(grant_wizard_team_access_step(&mut progress, &client, &config).is_err()); +} + +// ── configure_wizard_secrets_step ───────────────────────────────── + +const WIZARD_SECRET: &str = "GHSCAFF_WIZARD_STEP_SECRET"; + +fn wizard_secret_spec() -> crate::templates::SecretSpec { + crate::templates::SecretSpec { + name: WIZARD_SECRET.to_string(), + description: "wizard step test secret".to_string(), + required: false, + } +} + +fn wizard_secrets_handler(put_status: u16) -> impl Fn(&str, &str) -> (u16, String) { + use base64::{engine::general_purpose::STANDARD, Engine}; + let key = STANDARD.encode([4u8; 32]); + let public_key = format!(r#"{{"key_id":"wizard-key","key":"{key}"}}"#); + move |method, path| { + if method == "GET" && path.contains("/actions/secrets/public-key") { + (200, public_key.clone()) + } else if method == "PUT" && path.contains("/actions/secrets/") { + (put_status, String::new()) + } else { + not_found() + } + } +} + +#[test] +fn configure_wizard_secrets_step_sets_secret_from_env() { + let _guard = env_lock(); + let prev = std::env::var_os(WIZARD_SECRET); + std::env::set_var(WIZARD_SECRET, "wizard-value"); + + let (url, log) = start_recording_mock_server(wizard_secrets_handler(204)); + let client = mock_client(&url); + let config = minimal_config(); + let mut progress = StepCounter::new(3, false); + let result = + configure_wizard_secrets_step(&mut progress, &client, &config, "", &[wizard_secret_spec()]); + + match prev { + Some(value) => std::env::set_var(WIZARD_SECRET, value), + None => std::env::remove_var(WIZARD_SECRET), + } + result.unwrap(); + assert!(has_call( + &log, + "GET", + "/repos/owner/repo/actions/secrets/public-key" + )); + assert!(has_call( + &log, + "PUT", + &format!("/repos/owner/repo/actions/secrets/{WIZARD_SECRET}") + )); +} + +#[test] +fn configure_wizard_secrets_step_propagates_error() { + let _guard = env_lock(); + let prev = std::env::var_os(WIZARD_SECRET); + std::env::set_var(WIZARD_SECRET, "wizard-value"); + + let (url, _) = start_recording_mock_server(wizard_secrets_handler(500)); + let client = mock_client(&url); + let config = minimal_config(); + let mut progress = StepCounter::new(3, false); + let result = + configure_wizard_secrets_step(&mut progress, &client, &config, "", &[wizard_secret_spec()]); + + match prev { + Some(value) => std::env::set_var(WIZARD_SECRET, value), + None => std::env::remove_var(WIZARD_SECRET), + } + assert!(result.is_err()); +} + +// ── execute ─────────────────────────────────────────────────────── + +#[test] +fn execute_runs_end_to_end_against_mock() { + let (url, log) = start_recording_mock_server(|method, path| { + if method == "POST" && path == "/user/repos" { + (201, repo_json()) + } else if method == "GET" && path.contains("/git/ref/heads/") { + (200, ref_json()) + } else if method == "PUT" && path.contains("/protection") { + (200, "{}".to_string()) + } else { + not_found() + } + }); + let client = mock_client(&url); + let config = minimal_config(); + execute(&client, &config, false, "token", "").unwrap(); + assert!(has_call(&log, "POST", "/user/repos")); + assert!(has_call( + &log, + "PUT", + "/repos/owner/repo/branches/main/protection" + )); +} + +// ── warn_if_rust_without_license ─────────────────────────────────── + +fn config_with(language: Option<&str>, license: Option<&str>) -> WizardConfig { + WizardConfig { + language: language.map(str::to_string), + license: license.map(str::to_string), + ..minimal_config() + } +} + +#[test] +fn warn_if_rust_without_license_only_for_rust_without_license() { + assert!(warn_if_rust_without_license(&config_with( + Some("rust"), + None + ))); + assert!(!warn_if_rust_without_license(&config_with( + Some("rust"), + Some("MIT") + ))); + assert!(!warn_if_rust_without_license(&config_with( + Some("python-module"), + None + ))); + assert!(!warn_if_rust_without_license(&config_with(None, None))); +} + +// ── followups_enabled ───────────────────────────────────────────── + +#[test] +fn followups_enabled_truth_table() { + assert!(followups_enabled(false, true)); + assert!(!followups_enabled(true, true)); + assert!(!followups_enabled(false, false)); + assert!(!followups_enabled(true, false)); +} diff --git a/src/wizard.rs b/src/wizard/mod.rs similarity index 80% rename from src/wizard.rs rename to src/wizard/mod.rs index d6e367f..fc88ef9 100644 --- a/src/wizard.rs +++ b/src/wizard/mod.rs @@ -2,12 +2,14 @@ use anyhow::Result; use inquire::{Confirm, MultiSelect, Password, Select, Text}; use crate::github::{ - branches, client::{resolve_token, GithubClient}, - contents, labels, repo, secrets, sponsors, teams, + repo, sponsors, teams, }; use crate::templates; +mod execution; +use execution::execute; + const BANNER: &str = r#" █████ █████████ ██████ ██████ ░░███ ███░░░░░███ ███░░███ ███░░███ @@ -114,18 +116,24 @@ fn collect_team_access(client: &GithubClient, _org: &str) -> Result Result { + if input.trim().is_empty() { + Ok(inquire::validator::Validation::Invalid( + "Repository name cannot be empty".into(), + )) + } else { + Ok(inquire::validator::Validation::Valid) + } +} + fn collect_config(client: &GithubClient, username: &str) -> Result { // Step 1 — Repository basics let name = Text::new("Repository name:") - .with_validator(|input: &str| { - if input.trim().is_empty() { - Ok(inquire::validator::Validation::Invalid( - "Repository name cannot be empty".into(), - )) - } else { - Ok(inquire::validator::Validation::Valid) - } - }) + .with_validator(validate_repo_name) .prompt()?; let description = Text::new("Description:").with_default("").prompt()?; let topics_raw = Text::new("Topics:") @@ -220,252 +228,6 @@ fn collect_config(client: &GithubClient, username: &str) -> Result }) } -fn execute( - client: &GithubClient, - c: &WizardConfig, - dry_run: bool, - token: &str, - passphrase: &str, -) -> Result<()> { - println!(); - - // Fetch template if selected - let tmpl = if let Some(lang) = &c.language { - print!(" Fetching boilerplate template... "); - let t = templates::resolve(lang, token, true)?; - println!("ok"); - Some(t) - } else { - None - }; - let secret_specs = c - .language - .as_deref() - .map(templates::load_secrets) - .unwrap_or_default(); - let total = count_steps(c, &secret_specs); - let mut step = 0usize; - - macro_rules! step { - ($msg:expr, $op:expr) => {{ - step += 1; - if dry_run { - println!(" [{step}/{total}] [dry-run] {}", $msg); - } else { - print!(" [{step}/{total}] {}... ", $msg); - $op?; - println!("ok"); - } - }}; - } - - // 1. Create repo (empty — initial commit via Trees API below) - let created_repo = if dry_run { - step += 1; - println!( - " [{step}/{total}] [dry-run] create repo {}/{}", - c.owner, c.name - ); - None - } else { - print!( - " [{}/{total}] create repo {}/{}... ", - step + 1, - c.owner, - c.name - ); - step += 1; - let r = repo::create_repo( - client, - &c.owner, - &c.name, - &c.description, - c.private, - c.is_org, - )?; - println!("ok ({})", r.html_url); - Some(r) - }; - - let owner = &c.owner; - let name = &c.name; - - // 2. Collect all boilerplate files for a single init commit - let mut init_files: Vec = vec![]; - - if let Some(tmpl) = &tmpl { - for f in tmpl.boilerplate_files(name, &c.description, owner) { - init_files.push(contents::TreeFile { - path: f.path, - content: f.content, - }); - } - - let gitignore = repo::get_gitignore_template(client, &tmpl.gitignore_name()) - .unwrap_or_else(|_| { - eprintln!(" ⚠ Could not fetch .gitignore template from GitHub"); - String::new() - }); - init_files.push(contents::TreeFile { - path: ".gitignore".into(), - content: templates::assemble_gitignore(&gitignore), - }); - } - - // LICENSE — full text from GitHub's license API, with copyright placeholders - // filled in (owner + current year). MIT uses [year]/[fullname]; the Apache - // and GPL appendices use [yyyy]/[name of copyright owner]. - if let Some(lic) = &c.license { - let year = current_year(); - let license_text = repo::get_license_template(client, &lic.to_lowercase())? - .replace("[year]", &year) - .replace("[yyyy]", &year) - .replace("[fullname]", owner) - .replace("[name of copyright owner]", owner); - init_files.push(contents::TreeFile { - path: "LICENSE".into(), - content: license_text, - }); - } - - // 3. Single init commit with all files (skip if empty repo with no LICENSE) - let mut init_sha = String::new(); - if !init_files.is_empty() { - step!("init repository", { - let sha = contents::create_tree_commit( - client, - owner, - name, - &init_files, - "chore: init repository", - &c.default_branch, - )?; - init_sha = sha; - Ok::<(), anyhow::Error>(()) - }); - } - - // 4. develop branch - if c.create_develop { - if init_sha.is_empty() { - init_sha = branches::get_branch_sha(client, owner, name, &c.default_branch)?; - } - step!("create develop branch", { - branches::create_branch(client, owner, name, "develop", &init_sha)?; - Ok::<(), anyhow::Error>(()) - }); - } - - // 5. Branch protection — required contexts are derived from the workflow - // files just committed, never hardcoded, so a renamed job can't leave a - // required check pointing at a name nothing will ever report. - let workflow_sources: Vec = init_files - .iter() - .map(|f| crate::checks::WorkflowSource { - path: &f.path, - content: &f.content, - }) - .collect(); - let required_contexts = crate::checks::derive_required_contexts(&workflow_sources); - step!( - &format!("apply branch protection ({})", c.default_branch), - { - branches::apply_branch_protection( - client, - owner, - name, - &c.default_branch, - &required_contexts, - )?; - Ok::<(), anyhow::Error>(()) - } - ); - if c.create_develop { - step!("apply branch protection (develop)", { - branches::apply_branch_protection(client, owner, name, "develop", &required_contexts)?; - Ok::<(), anyhow::Error>(()) - }); - } - - // 6. Labels - if c.create_labels { - step!("sync labels", { - let existing = labels::list_labels(client, owner, name)?; - let standard = labels::standard_labels(); - for label in &standard { - if existing.iter().any(|e| e.name == label.name) { - labels::update_label(client, owner, name, &label.name, label)?; - } else { - labels::create_label(client, owner, name, label)?; - } - } - for existing_label in &existing { - if !standard.iter().any(|s| s.name == existing_label.name) { - let _ = labels::delete_label(client, owner, name, &existing_label.name); - } - } - Ok::<(), anyhow::Error>(()) - }); - } - - // 7. Topics - if !c.topics.is_empty() { - step!("set topics", { - repo::set_topics(client, owner, name, &c.topics)?; - Ok::<(), anyhow::Error>(()) - }); - } - - // 8. Team access - for team in &c.team_access { - step!( - &format!( - "add team {} with {} access", - team.team_slug, team.permission - ), - { - teams::add_team_to_repo(client, owner, name, &team.team_slug, &team.permission)?; - Ok::<(), anyhow::Error>(()) - } - ); - } - - // 9. Template secrets: env → vault → prompt - for spec in &secret_specs { - let value = if let Some(val) = crate::vault::resolve_secret(&spec.name, passphrase)? { - println!(" ◆ Secret {}: found", spec.name); - Some(val) - } else { - prompt_secret_value(spec, passphrase)? - }; - if let Some(val) = value { - step!(&format!("configure secret {}", spec.name), { - secrets::set_secret(client, owner, name, &spec.name, &val)?; - Ok::<(), anyhow::Error>(()) - }); - } else { - step += 1; // keep total consistent even when skipped - } - } - - println!(); - if let Some(r) = &created_repo { - println!(" Done — {}", r.html_url); - } else { - println!(" Done (dry-run)"); - } - println!(); - - if !dry_run { - if let Some(_r) = &created_repo { - offer_gitkit_clone(owner, name); - offer_sponsor_button(client, owner, name); - } - } - - Ok(()) -} - /// Which way the user answered the Sponsor-button prompt, or that there was /// no way to ask (non-interactive run — no TTY, or an explicit cancel). enum SponsorAnswer { @@ -1255,4 +1017,20 @@ mod tests { let result: Result = Err(anyhow::anyhow!("boom")); assert_eq!(sponsor_outcome_message(&result), "failed: boom"); } + + #[test] + fn validate_repo_name_rejects_empty_accepts_name() { + use inquire::validator::Validation; + + let empty: inquire::validator::ErrorMessage = "Repository name cannot be empty".into(); + assert_eq!( + validate_repo_name("").unwrap(), + Validation::Invalid(empty.clone()) + ); + assert_eq!( + validate_repo_name(" ").unwrap(), + Validation::Invalid(empty) + ); + assert_eq!(validate_repo_name("my-repo").unwrap(), Validation::Valid); + } } diff --git a/tests/fixtures/agentic-block.txt b/tests/fixtures/agentic-block.txt new file mode 100644 index 0000000..34a245e --- /dev/null +++ b/tests/fixtures/agentic-block.txt @@ -0,0 +1,40 @@ +# AI coding agents (paths derived from the canopy registry) +.agents/* +.blackboxcli/* +.claude/* +.cline/* +.clineignore +.codebuddy/* +.codex/* +.commandcode/* +.continue/* +.copilot/* +.cursor/* +.cursorignore +.cursorindexingignore +.devin/* +.gemini/* +.kilo/* +.kilocode/* +.kiro/* +.mcp.json +.mimocode/* +.opencode/* +.qoder/* +.qwen/* +.utim/* +.utim_tmp/* +.vibe/* +CLAUDE.local.md +kilo.json +kilo.jsonc +mimocode.json +mimocode.jsonc +opencode.json +opencode.jsonc +skills-lock.json + +# instruction files stay committable +!.claude/CLAUDE.md +!.continue/rules/ +!.cursor/rules/