From 173c269d2d88e347b34713e603f396e7b34516d6 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 26 Aug 2026 23:14:11 -0500 Subject: [PATCH 01/23] chore(demo): record the export speed and targets in the score --- demo/demo.rec | 2 +- demo/demo.toml | 2 ++ demo/dist/demo.gif | Bin 164056 -> 165391 bytes 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/demo/demo.rec b/demo/demo.rec index a8fe01b..bd6ebea 100644 --- a/demo/demo.rec +++ b/demo/demo.rec @@ -1,4 +1,4 @@ -{"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} +{"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": 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"] diff --git a/demo/demo.toml b/demo/demo.toml index 0748156..74d88ea 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] diff --git a/demo/dist/demo.gif b/demo/dist/demo.gif index edef3496b2719b6d58eca139b4917cf8932c457e..c588835c5347ba965d4eca203a7dcad867baae85 100644 GIT binary patch delta 71742 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 From 51e9e3ee17b1fecbae132f53f2e917fc2070c00a Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Sun, 27 Sep 2026 04:45:34 -0500 Subject: [PATCH 02/23] sv-ghscaff-update: explicit update command with consent-first self-update --- Cargo.lock | 12 + Cargo.toml | 1 + README.md | 26 +- docs/cli-reference.md | 4 +- docs/index.md | 3 +- docs/installation.md | 19 +- src/main.rs | 366 ++---------- src/updater.rs | 1279 ++++++++++++++++++++++++++++++++++++++--- 8 files changed, 1299 insertions(+), 411 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e17c573..4cf32ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -575,6 +575,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2", "tar", "tempfile", "toml", @@ -1426,6 +1427,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" diff --git a/Cargo.toml b/Cargo.toml index 09bbe95..35de7ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ blake2 = "0.10" xsalsa20poly1305 = "0.9" whoami = "1" serde_yaml = "0.9.34" +sha2 = "0.10" [dev-dependencies] tempfile = "3" diff --git a/README.md b/README.md index f2fd5be..0d9125d 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,29 @@ 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 v0.7.0? [y/N]" — default is NO +ghscaff update --yes # skip the prompt +ghscaff update --check # report only: exit 1 = update available, 0 = current +``` + +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. +Your `~/.ghscaff` state — encrypted vault and boilerplate cache — is never +touched: it is not the binary. + +If you installed ghscaff with `cargo install`, `ghscaff update` refuses to +touch the binary and instead tells you to run: +```bash +cargo install --force ghscaff +``` -You can disable update checks with: +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 ``` diff --git a/docs/cli-reference.md b/docs/cli-reference.md index f7889ab..468084c 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. `--check` only reports (exit 1 = available, 0 = current) | ## 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 (rejected by `update`, which makes no API calls) | | `--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..04ed426 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -55,14 +55,27 @@ 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 +ghscaff update # asks "Update to v0.7.0? [y/N]" — default is NO +ghscaff update --yes # skip the prompt +ghscaff update --check # report only: exit 1 = update available, 0 = current +``` + +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. Your `~/.ghscaff` +state — encrypted vault and boilerplate cache — is never touched. + +If you installed ghscaff with `cargo install`, `ghscaff update` refuses to +touch the binary and instead direct you to run: ```bash cargo install --force ghscaff ``` -You can disable update checks by setting the environment variable: +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/src/main.rs b/src/main.rs index 8f14635..efafc75 100644 --- a/src/main.rs +++ b/src/main.rs @@ -66,6 +66,15 @@ enum Command { /// owner/repo (auto-detected from git remote if omitted) repo: Option, }, + /// Update ghscaff to the latest stable release (always asks first) + Update { + /// Only report whether an update is available (exit 1 = yes, 0 = no) + #[arg(long)] + check: bool, + /// Do not prompt; proceed if an update is available + #[arg(long)] + yes: bool, + }, } fn main() -> Result<()> { @@ -76,12 +85,24 @@ 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 { + if cli.dry_run { + anyhow::bail!("--dry-run has no effect on update"); + } + let code = updater::run_update(*check, *yes)?; + 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"), } } @@ -126,74 +147,19 @@ 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 {current} → {tag} — run 'ghscaff update'"); } } @@ -220,83 +186,27 @@ 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")); + 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_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")); + 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] @@ -318,74 +228,6 @@ mod tests { 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")); - } - - #[test] - fn test_is_newer_empty_strings() { - assert!(!is_newer("", "")); - } - - #[test] - fn test_is_newer_single_number() { - assert!(is_newer("v1", "v2")); - assert!(!is_newer("v2", "v1")); - } - - #[test] - fn test_is_newer_non_numeric_parts() { - assert!(!is_newer("vabc", "vdef")); - } - - #[test] - fn test_is_newer_very_large_versions() { - assert!(is_newer("v999.999.999", "v1000.0.0")); - } - - #[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); @@ -398,125 +240,15 @@ mod tests { 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() { set_debug(true); set_debug(true); assert!(is_debug()); set_debug(false); - 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); @@ -529,8 +261,6 @@ mod tests { assert!(!is_debug()); set_debug(true); assert!(is_debug()); - set_debug(true); - assert!(is_debug()); set_debug(false); assert!(!is_debug()); } diff --git a/src/updater.rs b/src/updater.rs index 4341c25..02ab524 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -1,63 +1,429 @@ -//! 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. - -use anyhow::{Context, Result}; +//! 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. + +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"; + +// ── 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, + 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, +} + +// ── Public update entry points ─────────────────────────────────── + +/// Check for and, after consent, install the latest stable release. +/// +/// The returned integer is the process exit code: `0` means no update was +/// installed (already current, a declined prompt, or a cargo-managed +/// install) and `1` means an update was available in `--check` mode. +/// Network and API errors surface as `Err` — the explicit command fails +/// loudly, unlike the silent startup notice. +pub fn run_update(check: bool, yes: bool) -> Result { + let current = current_version(); + let releases = fetch_releases_with(&RealFetcher::new())?; + + // 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. + let latest = select_latest_stable(&releases, ¤t); + if latest.is_none() || check { + let deps = UpdateDeps { + current: ¤t, + releases: Ok(releases), + exe: Path::new("/tmp/ghscaff-update-test/ghscaff"), + cargo_bin: None, + target: Ok(("x86_64", "unknown-linux-musl")), + downloader: &RealDownloader, + confirm: &|| false, + }; + return run_update_with(check, yes, &deps); + } + + // An actual install needs the executable and target facts. Resolve them + // only after the read-only pass established that a newer release exists. + 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-{current}-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: &RealDownloader, + confirm: &|| { + inquire::Confirm::new(&format!("Update to {latest}? [y/N]")) + .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`]. +pub fn run_update_with(check: bool, yes: bool, deps: &UpdateDeps<'_>) -> Result { + let releases = deps + .releases + .as_ref() + .map_err(|error| anyhow!("release lookup failed: {error}"))?; + let Some(latest) = select_latest_stable(releases, deps.current) else { + println!("ghscaff {} is up to date", deps.current); + return Ok(0); + }; + println!("ghscaff {} → {latest}", deps.current); + + // `--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(1); + } + + // 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_INSTALL_HINT}"); + return Ok(0); + } + + let (arch, os) = *deps + .target + .as_ref() + .map_err(|error| anyhow!("target resolution failed: {error}"))?; - if is_cargo_managed(¤t_exe) { - return Ok(Outcome::CargoManaged); + if !yes && !(deps.confirm)() { + println!("Aborted."); + return Ok(0); } - let dir = current_exe + 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!(" ✓ Updated ghscaff to {latest} — restart your terminal to use it."); + Ok(0) +} + +// ── 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 verify_checksum(downloader, tag, &asset, &bytes)? == Check::Skipped { + println!(" ℹ checksum skipped (release ships no SHA256SUMS.txt)"); + } + extract_binary(std::io::Cursor::new(bytes), output) } // ── Cargo-managed detection ───────────────────────────────────── @@ -84,6 +450,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 +463,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 +503,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 +518,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 +554,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", @@ -217,7 +574,20 @@ fn detect_platform() -> Result<(&'static str, &'static str)> { #[cfg(test)] mod tests { use super::*; + use std::cell::RefCell; use std::io::Write; + use std::sync::{Mutex, OnceLock}; + + /// Tests that read or write process-wide environment (only the + /// state-untouched test does) serialize on this lock; tests run in + /// parallel by default. + fn env_lock() -> std::sync::MutexGuard<'static, ()> { + static ENV_LOCK: OnceLock> = OnceLock::new(); + ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|e| e.into_inner()) + } fn make_tar_gz(entries: &[(&str, &[u8])]) -> Vec { let mut tar_bytes = Vec::new(); @@ -237,6 +607,748 @@ mod tests { 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")) + } + + // ── ①② --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 release_lookup_error_is_loud() { + 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: Err("HTTP 403: rate limit exceeded".into()), + exe: &exe, + cargo_bin: None, + target: Ok(("x86_64", "unknown-linux-musl")), + downloader: &downloader, + confirm: &|| panic!("must not prompt"), + }; + + let err = run_update_with(false, false, &deps).unwrap_err(); + assert!(err.to_string().contains("release lookup failed"), "{err:#}"); + assert!(downloader.calls().is_empty()); + } + + // ── ⑪⑬⑭ 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")]); @@ -275,6 +1387,19 @@ mod tests { 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(); @@ -331,6 +1456,24 @@ mod tests { 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( @@ -437,36 +1580,4 @@ mod tests { // 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"); - } } From 26a720becb0e970ae88b9e037980ffb876e24b3d Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Sun, 27 Sep 2026 04:58:59 -0500 Subject: [PATCH 03/23] fix(update): split the updater module so no file crosses the size gate --- SECURITY.md | 8 +- docs/cli-reference.md | 2 +- docs/installation.md | 2 +- src/updater.rs | 1012 +---------------------------------------- src/updater/tests.rs | 1004 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 1012 insertions(+), 1016 deletions(-) create mode 100644 src/updater/tests.rs diff --git a/SECURITY.md b/SECURITY.md index 10958b8..45ee9a9 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. -**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/docs/cli-reference.md b/docs/cli-reference.md index 468084c..96ffa1e 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -28,7 +28,7 @@ Running `ghscaff` with no command starts the creation wizard (same as | Flag | Description | |---|---| | `--sponsor OWNER/REPO` | Enable GitHub Sponsor button on an existing repository | -| `--dry-run` | Preview changes without making any API calls (rejected by `update`, which makes no API calls) | +| `--dry-run` | Preview changes without making any API calls (rejected by `update`) | | `--help` | Show help for any command | | `--version` | Show ghscaff version | diff --git a/docs/installation.md b/docs/installation.md index 04ed426..dc6ed24 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -69,7 +69,7 @@ no checksum file) and atomically replaces the running binary. Your `~/.ghscaff` state — encrypted vault and boilerplate cache — is never touched. If you installed ghscaff with `cargo install`, `ghscaff update` refuses to -touch the binary and instead direct you to run: +touch the binary and instead tells you to run: ```bash cargo install --force ghscaff ``` diff --git a/src/updater.rs b/src/updater.rs index 02ab524..fdba303 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -181,7 +181,7 @@ pub fn run_update(check: bool, yes: bool) -> Result { if cfg!(target_os = "windows") { format!( "ghscaff update does not support windows — run scripts/install.ps1 instead \ - (release asset: ghscaff-{current}-x86_64-pc-windows-msvc.zip)" + (release asset: ghscaff-{latest}-x86_64-pc-windows-msvc.zip)" ) } else { error.to_string() @@ -572,1012 +572,4 @@ fn detect_platform() -> Result<(&'static str, &'static str)> { } #[cfg(test)] -mod tests { - use super::*; - use std::cell::RefCell; - use std::io::Write; - use std::sync::{Mutex, OnceLock}; - - /// Tests that read or write process-wide environment (only the - /// state-untouched test does) serialize on this lock; tests run in - /// parallel by default. - fn env_lock() -> std::sync::MutexGuard<'static, ()> { - static ENV_LOCK: OnceLock> = OnceLock::new(); - ENV_LOCK - .get_or_init(|| Mutex::new(())) - .lock() - .unwrap_or_else(|e| e.into_inner()) - } - - 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")) - } - - // ── ①② --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 release_lookup_error_is_loud() { - 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: Err("HTTP 403: rate limit exceeded".into()), - exe: &exe, - cargo_bin: None, - target: Ok(("x86_64", "unknown-linux-musl")), - downloader: &downloader, - confirm: &|| panic!("must not prompt"), - }; - - let err = run_update_with(false, false, &deps).unwrap_err(); - assert!(err.to_string().contains("release lookup failed"), "{err:#}"); - assert!(downloader.calls().is_empty()); - } - - // ── ⑪⑬⑭ 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)); - } -} +mod tests; diff --git a/src/updater/tests.rs b/src/updater/tests.rs new file mode 100644 index 0000000..0b1864c --- /dev/null +++ b/src/updater/tests.rs @@ -0,0 +1,1004 @@ +use super::*; +use std::cell::RefCell; +use std::io::Write; +use std::sync::{Mutex, OnceLock}; + +/// Tests that read or write process-wide environment (only the +/// state-untouched test does) serialize on this lock; tests run in +/// parallel by default. +fn env_lock() -> std::sync::MutexGuard<'static, ()> { + static ENV_LOCK: OnceLock> = OnceLock::new(); + ENV_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|e| e.into_inner()) +} + +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")) +} + +// ── ①② --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 release_lookup_error_is_loud() { + 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: Err("HTTP 403: rate limit exceeded".into()), + exe: &exe, + cargo_bin: None, + target: Ok(("x86_64", "unknown-linux-musl")), + downloader: &downloader, + confirm: &|| panic!("must not prompt"), + }; + + let err = run_update_with(false, false, &deps).unwrap_err(); + assert!(err.to_string().contains("release lookup failed"), "{err:#}"); + assert!(downloader.calls().is_empty()); +} + +// ── ⑪⑬⑭ 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)); +} From 6168a6bd1af7816fa524b6d157c89ec018f4c521 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 30 Sep 2026 02:00:37 -0500 Subject: [PATCH 04/23] fix(q-ghscaff-strict-gate): pass strict quality gate with secure deps and clippy refactors --- Cargo.lock | 233 ++++++------------ Cargo.toml | 4 +- src/apply.rs | 314 +++++++++++++++--------- src/vault.rs | 66 ++++- src/wizard/execution.rs | 400 +++++++++++++++++++++++++++++++ src/{wizard.rs => wizard/mod.rs} | 292 +++------------------- 6 files changed, 779 insertions(+), 530 deletions(-) create mode 100644 src/wizard/execution.rs rename src/{wizard.rs => wizard/mod.rs} (80%) diff --git a/Cargo.lock b/Cargo.lock index 4cf32ae..58c6d53 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" @@ -568,6 +589,7 @@ dependencies = [ "blake2", "clap", "crypto_box", + "crypto_secretbox", "dirs", "flate2", "inquire", @@ -582,7 +604,6 @@ dependencies = [ "urlencoding", "walkdir", "whoami", - "xsalsa20poly1305", ] [[package]] @@ -819,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", ] @@ -875,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", @@ -893,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" @@ -930,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" @@ -949,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" @@ -1168,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]] @@ -1177,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]] @@ -1266,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", @@ -1275,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", @@ -1299,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", @@ -1461,7 +1465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", - "mio 0.8.11", + "mio", "signal-hook", ] @@ -1638,7 +1642,7 @@ checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", - "mio 1.2.1", + "mio", "pin-project-lite", "socket2", "windows-sys 0.61.2", @@ -1716,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", @@ -1785,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" @@ -2010,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]] @@ -2037,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" @@ -2110,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" @@ -2183,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 35de7ad..800dcac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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,7 +29,7 @@ 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" diff --git a/src/apply.rs b/src/apply.rs index 55bbdc6..e967449 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); - println!(" Checking existing repo... {}/{}", owner, repo_name); - let ctx = get_repo_state(&client, &owner, &repo_name)?; + let (owner, repo_name) = if let Some(repo) = repo_arg { + parse_owner_repo(repo)? + } else { + auto_detect_repo()? + }; + + 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,74 @@ 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()?; + if !want_teams { + return Ok(vec![]); + } - 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(), - }); - } - } - } - } - } + let Ok(org_teams) = list_org_teams(client, owner) else { + return Ok(vec![]); + }; + if org_teams.is_empty() { + return Ok(vec![]); } - println!(); - let confirmed = inquire::Confirm::new("Apply these changes?") - .with_default(true) - .prompt()?; + let team_names: Vec = org_teams.iter().map(|t| t.name.clone()).collect(); + let Ok(Some(selections)) = inquire::MultiSelect::new("Select teams:", team_names) + .with_help_message("space select enter confirm") + .prompt_skippable() + else { + return Ok(vec![]); + }; - if !confirmed { - println!(" Aborted."); - return Ok(()); + 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(prompt_apply_team_permission(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 +401,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 +420,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 +453,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 +487,89 @@ pub fn run_apply(repo_arg: Option<&str>, dry_run: bool) -> Result<()> { } } } + 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), - } - } +/// 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(()); + } + + 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)?; + + 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()?; + + 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!"); diff --git a/src/vault.rs b/src/vault.rs index 797a7a9..61c276d 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(); diff --git a/src/wizard/execution.rs b/src/wizard/execution.rs new file mode 100644 index 0000000..75955c0 --- /dev/null +++ b/src/wizard/execution.rs @@ -0,0 +1,400 @@ +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, +}; + +/// 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) { + 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) +} + +/// 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. +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() { + *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(()) +} + +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())?; + + // 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 !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.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); + } } From 9c62cc1e8622bf6d91ba7b13da8ada00f8100e54 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 30 Sep 2026 02:30:03 -0500 Subject: [PATCH 05/23] feat(gs-update-check-contract): exit 2 on failed update check with cause on stderr --- README.md | 18 ++++--- SECURITY.md | 2 +- docs/cli-reference.md | 2 +- docs/installation.md | 22 +++++--- src/main.rs | 13 ++++- src/updater.rs | 119 ++++++++++++++++++++++++++++++++--------- src/updater/tests.rs | 120 ++++++++++++++++++++++++++++++++++++------ 7 files changed, 237 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index 0d9125d..1003c9b 100644 --- a/README.md +++ b/README.md @@ -101,22 +101,28 @@ Check the [Releases](https://github.com/UniverLab/ghscaff/releases) page for pre Updating is always explicit — ghscaff never installs anything on its own: ```bash -ghscaff update # asks "Update to v0.7.0? [y/N]" — default is NO +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 1 = update available, 0 = current +ghscaff update --check # report only: exit 0 = up to date, 1 = update available, 2 = the check could not be completed ``` 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. + If you installed ghscaff with `cargo install`, `ghscaff update` refuses to -touch the binary and instead tells you to run: -```bash -cargo install --force ghscaff -``` +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 diff --git a/SECURITY.md b/SECURITY.md index 45ee9a9..e979c1f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -93,7 +93,7 @@ The tool never prints, logs, or transmits tokens. Token input uses `inquire::Pas ### Self-Update Mechanism -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. +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 via the explicit `ghscaff update` command with user consent (default NO); the startup notice never prompts and never installs. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 96ffa1e..0ec9812 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -21,7 +21,7 @@ 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. `--check` only reports (exit 1 = available, 0 = current) | +| `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`) | ## Global flags diff --git a/docs/installation.md b/docs/installation.md index dc6ed24..22ff060 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -58,21 +58,27 @@ x86_64 are published on the Updating is always explicit — ghscaff never installs anything on its own: ```bash -ghscaff update # asks "Update to v0.7.0? [y/N]" — default is NO +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 1 = update available, 0 = current +ghscaff update --check # report only: exit 0 = up to date, 1 = update available, 2 = the check could not be completed ``` 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. Your `~/.ghscaff` -state — encrypted vault and boilerplate cache — is never touched. +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. If you installed ghscaff with `cargo install`, `ghscaff update` refuses to -touch the binary and instead tells you to run: -```bash -cargo install --force ghscaff -``` +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: diff --git a/src/main.rs b/src/main.rs index efafc75..a9c3909 100644 --- a/src/main.rs +++ b/src/main.rs @@ -67,8 +67,13 @@ enum Command { 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). Update { - /// Only report whether an update is available (exit 1 = yes, 0 = no) + /// 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 @@ -159,7 +164,11 @@ fn check_for_update() { 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 {current} → {tag} — run 'ghscaff update'"); + println!( + " \x1b[33m⬆ Update available:\x1b[0m {} → {} — run 'ghscaff update'", + updater::display_version(¤t), + updater::display_version(&tag) + ); } } diff --git a/src/updater.rs b/src/updater.rs index fdba303..8705662 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -9,6 +9,17 @@ //! (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::{anyhow, bail, Context, Result}; use serde::Deserialize; @@ -21,6 +32,15 @@ const GITHUB_REPO: &str = "UniverLab/ghscaff"; /// `~/.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. @@ -130,6 +150,9 @@ impl BinaryDownloader for RealDownloader { 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. @@ -139,27 +162,68 @@ pub struct UpdateDeps<'a> { 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) + ) +} + +/// 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` means no update was -/// installed (already current, a declined prompt, or a cargo-managed -/// install) and `1` means an update was available in `--check` mode. -/// Network and API errors surface as `Err` — the explicit command fails -/// loudly, unlike the silent startup notice. +/// 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) -> Result { let current = current_version(); - let releases = fetch_releases_with(&RealFetcher::new())?; + // 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(&RealFetcher::new()).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. - let latest = select_latest_stable(&releases, ¤t); + // 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: Ok(releases), + releases, exe: Path::new("/tmp/ghscaff-update-test/ghscaff"), cargo_bin: None, target: Ok(("x86_64", "unknown-linux-musl")), @@ -171,6 +235,7 @@ pub fn run_update(check: bool, yes: bool) -> Result { // 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(); @@ -195,7 +260,7 @@ pub fn run_update(check: bool, yes: bool) -> Result { target, downloader: &RealDownloader, confirm: &|| { - inquire::Confirm::new(&format!("Update to {latest}? [y/N]")) + inquire::Confirm::new(&format!("Update to {}? [y/N]", display_version(&latest))) .with_default(false) .prompt() .unwrap_or(false) @@ -206,23 +271,29 @@ pub fn run_update(check: bool, yes: bool) -> Result { /// 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`]. +/// [`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 = deps - .releases - .as_ref() - .map_err(|error| anyhow!("release lookup failed: {error}"))?; + 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!("ghscaff {} is up to date", deps.current); - return Ok(0); + println!("{}", up_to_date_line(deps.current)); + return Ok(EXIT_NO_UPDATE); }; - println!("ghscaff {} → {latest}", deps.current); + 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(1); + return Ok(EXIT_UPDATE_AVAILABLE); } // Cargo owns `~/.cargo/bin`; refuse before anything is downloaded. @@ -230,8 +301,8 @@ pub fn run_update_with(check: bool, yes: bool, deps: &UpdateDeps<'_>) -> Result< .cargo_bin .is_some_and(|root| is_cargo_managed_with_root(deps.exe, Some(root.to_path_buf()))) { - println!("{CARGO_INSTALL_HINT}"); - return Ok(0); + println!("{}", cargo_refusal_line()); + return Ok(EXIT_NO_UPDATE); } let (arch, os) = *deps @@ -241,7 +312,7 @@ pub fn run_update_with(check: bool, yes: bool, deps: &UpdateDeps<'_>) -> Result< if !yes && !(deps.confirm)() { println!("Aborted."); - return Ok(0); + return Ok(EXIT_NO_UPDATE); } let exe = deps.exe; @@ -258,8 +329,8 @@ pub fn run_update_with(check: bool, yes: bool, deps: &UpdateDeps<'_>) -> Result< download_verify_extract_with(deps.downloader, &latest, (arch, os), out) })?; - println!(" ✓ Updated ghscaff to {latest} — restart your terminal to use it."); - Ok(0) + println!("{}", success_line(&latest)); + Ok(EXIT_NO_UPDATE) } // ── Version helpers ───────────────────────────────────────────── diff --git a/src/updater/tests.rs b/src/updater/tests.rs index 0b1864c..7ec313b 100644 --- a/src/updater/tests.rs +++ b/src/updater/tests.rs @@ -120,6 +120,38 @@ fn tmp_staging_for(exe: &Path) -> PathBuf { 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] @@ -498,24 +530,78 @@ fn target_error_fails_before_touching_the_downloader() { } #[test] -fn release_lookup_error_is_loud() { - 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: Err("HTTP 403: rate limit exceeded".into()), - exe: &exe, - cargo_bin: None, - target: Ok(("x86_64", "unknown-linux-musl")), - downloader: &downloader, - confirm: &|| panic!("must not prompt"), - }; +fn check_exits_2_when_the_release_lookup_fails() { + assert_eq!( + exit_when_release_lookup_fails(true, Err("dns failure".into())), + 2 + ); +} - let err = run_update_with(false, false, &deps).unwrap_err(); - assert!(err.to_string().contains("release lookup failed"), "{err:#}"); - assert!(downloader.calls().is_empty()); +#[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) ────────────── From 0e7aa0f5bb4d1eafb48b5052f9c7e164050a94b7 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 30 Sep 2026 06:33:18 -0500 Subject: [PATCH 06/23] test(gs-mutants-diff): cover surviving mutants with testable seams --- .cargo/mutants.toml | 16 + src/apply.rs | 69 +++- src/apply/step_tests.rs | 637 ++++++++++++++++++++++++++++++++++ src/github/test_utils.rs | 58 +++- src/main.rs | 7 +- src/updater.rs | 15 +- src/updater/tests.rs | 90 ++++- src/vault.rs | 19 +- src/wizard/execution.rs | 11 +- src/wizard/execution/tests.rs | 625 +++++++++++++++++++++++++++++++++ 10 files changed, 1500 insertions(+), 47 deletions(-) create mode 100644 .cargo/mutants.toml create mode 100644 src/apply/step_tests.rs create mode 100644 src/wizard/execution/tests.rs diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml new file mode 100644 index 0000000..c35d65f --- /dev/null +++ b/.cargo/mutants.toml @@ -0,0 +1,16 @@ +# 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", +] diff --git a/src/apply.rs b/src/apply.rs index e967449..da006fd 100644 --- a/src/apply.rs +++ b/src/apply.rs @@ -334,6 +334,23 @@ fn collect_apply_team_access(client: &GithubClient, owner: &str) -> Result) -> Result>>, +) -> Result> { if !want_teams { return Ok(vec![]); } @@ -346,19 +363,26 @@ fn collect_apply_team_access(client: &GithubClient, owner: &str) -> Result = org_teams.iter().map(|t| t.name.clone()).collect(); - let Ok(Some(selections)) = inquire::MultiSelect::new("Select teams:", team_names) - .with_help_message("space select enter confirm") - .prompt_skippable() - else { + 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 { + let Some(team) = org_teams.iter().find(|t| t.name == *selected_team_display) else { continue; }; - selected_teams.push(prompt_apply_team_permission(team)?); + selected_teams.push(permission_for(team)?); } Ok(selected_teams) } @@ -541,7 +565,12 @@ fn apply_missing_secrets_step(target: &ApplyTarget) -> Result<()> { 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."); @@ -556,6 +585,17 @@ pub fn run_apply(repo_arg: Option<&str>, dry_run: bool) -> Result<()> { .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(()); @@ -564,12 +604,12 @@ pub fn run_apply(repo_arg: Option<&str>, dry_run: bool) -> Result<()> { // 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)?; + 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!"); @@ -611,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::*; @@ -1582,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 a9c3909..a1bd7cb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -97,7 +97,12 @@ fn main() -> Result<()> { if cli.dry_run { anyhow::bail!("--dry-run has no effect on update"); } - let code = updater::run_update(*check, *yes)?; + let code = updater::run_update( + *check, + *yes, + &updater::RealFetcher::new(), + &updater::RealDownloader, + )?; std::process::exit(code); } diff --git a/src/updater.rs b/src/updater.rs index 8705662..3d19143 100644 --- a/src/updater.rs +++ b/src/updater.rs @@ -206,11 +206,16 @@ fn success_line(latest: &str) -> String { /// 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) -> Result { +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(&RealFetcher::new()).map_err(|error| format!("{error:#}")); + let releases = fetch_releases_with(fetcher).map_err(|error| format!("{error:#}")); let latest = releases .as_ref() .ok() @@ -227,7 +232,7 @@ pub fn run_update(check: bool, yes: bool) -> Result { exe: Path::new("/tmp/ghscaff-update-test/ghscaff"), cargo_bin: None, target: Ok(("x86_64", "unknown-linux-musl")), - downloader: &RealDownloader, + downloader, confirm: &|| false, }; return run_update_with(check, yes, &deps); @@ -258,7 +263,7 @@ pub fn run_update(check: bool, yes: bool) -> Result { exe: &exe, cargo_bin: cargo_bin.as_deref(), target, - downloader: &RealDownloader, + downloader, confirm: &|| { inquire::Confirm::new(&format!("Update to {}? [y/N]", display_version(&latest))) .with_default(false) @@ -491,7 +496,7 @@ pub fn download_verify_extract_with( ) -> Result<()> { let asset = asset_name(tag, target.0, target.1); let bytes = downloader.download(&asset_url(tag, target))?; - if verify_checksum(downloader, tag, &asset, &bytes)? == Check::Skipped { + 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) diff --git a/src/updater/tests.rs b/src/updater/tests.rs index 7ec313b..ba5e1a1 100644 --- a/src/updater/tests.rs +++ b/src/updater/tests.rs @@ -1,17 +1,14 @@ use super::*; use std::cell::RefCell; use std::io::Write; -use std::sync::{Mutex, OnceLock}; +use std::sync::MutexGuard; -/// Tests that read or write process-wide environment (only the -/// state-untouched test does) serialize on this lock; tests run in +/// 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() -> std::sync::MutexGuard<'static, ()> { - static ENV_LOCK: OnceLock> = OnceLock::new(); - ENV_LOCK - .get_or_init(|| Mutex::new(())) - .lock() - .unwrap_or_else(|e| e.into_inner()) +fn env_lock() -> MutexGuard<'static, ()> { + crate::github::test_utils::env_lock() } fn make_tar_gz(entries: &[(&str, &[u8])]) -> Vec { @@ -1088,3 +1085,78 @@ fn is_cargo_managed_delegates_correctly() { // 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()); +} diff --git a/src/vault.rs b/src/vault.rs index 61c276d..d613767 100644 --- a/src/vault.rs +++ b/src/vault.rs @@ -1282,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. @@ -1305,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, @@ -1317,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") }, @@ -1327,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 { @@ -1343,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()); @@ -1353,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(); @@ -1365,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(); @@ -1375,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 index 75955c0..fc223a0 100644 --- a/src/wizard/execution.rs +++ b/src/wizard/execution.rs @@ -8,6 +8,9 @@ use super::{ 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. @@ -341,6 +344,12 @@ fn configure_wizard_secrets_step( 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, @@ -391,7 +400,7 @@ pub(super) fn execute( } println!(); - if !dry_run && created_repo.is_some() { + if followups_enabled(dry_run, created_repo.is_some()) { offer_gitkit_clone(&c.owner, &c.name); offer_sponsor_button(client, &c.owner, &c.name); } diff --git a/src/wizard/execution/tests.rs b/src/wizard/execution/tests.rs new file mode 100644 index 0000000..fdda961 --- /dev/null +++ b/src/wizard/execution/tests.rs @@ -0,0 +1,625 @@ +//! 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()); +} + +// ── 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" + )); +} + +// ── 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)); +} From dbae08e07a5128188f7505344272ce68991b7cd5 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 30 Sep 2026 07:18:26 -0500 Subject: [PATCH 07/23] test(gs-debug-flag-test-race): consolidate debug flag tests to avoid race --- src/main.rs | 54 ++++++----------------------------------------------- 1 file changed, 6 insertions(+), 48 deletions(-) diff --git a/src/main.rs b/src/main.rs index a1bd7cb..8c36f51 100644 --- a/src/main.rs +++ b/src/main.rs @@ -224,58 +224,16 @@ mod tests { } #[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_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_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()); - } - - #[test] - fn test_debug_mode_default_is_false() { + assert!(is_debug(), "re-enabling stays true"); 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()); + assert!(!is_debug(), "disable takes effect"); set_debug(false); - assert!(!is_debug()); + assert!(!is_debug(), "re-disabling stays false"); } } From 9b852060b77acd5683493002ac06d8e69746cd40 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Wed, 30 Sep 2026 12:05:19 -0500 Subject: [PATCH 08/23] ci(release): grant actions read so the release reuses the PR build --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) 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: From a8d4b04527482555a8e2919a403b8a0f89ea850e Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 1 Oct 2026 01:46:48 -0500 Subject: [PATCH 09/23] feat(gs-update-dry-run): make update --dry-run report like --check --- docs/cli-reference.md | 4 ++-- docs/installation.md | 2 ++ src/main.rs | 54 +++++++++++++++++++++++++++++++++++++++---- src/updater/tests.rs | 31 +++++++++++++++++++++++++ 4 files changed, 85 insertions(+), 6 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 0ec9812..6118c8e 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -21,14 +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`) | +| `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 (rejected by `update`) | +| `--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 | diff --git a/docs/installation.md b/docs/installation.md index 22ff060..15e20dc 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -61,6 +61,7 @@ Updating is always explicit — ghscaff never installs anything on its own: 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 ``` On "yes", ghscaff downloads the release asset for your platform, verifies it @@ -75,6 +76,7 @@ release check cannot be completed — no network, DNS or TLS failure, HTTP ≥ 4 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: diff --git a/src/main.rs b/src/main.rs index 8c36f51..df5b876 100644 --- a/src/main.rs +++ b/src/main.rs @@ -72,6 +72,7 @@ enum Command { /// 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)] @@ -94,11 +95,10 @@ fn main() -> Result<()> { // 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 { - if cli.dry_run { - anyhow::bail!("--dry-run has no effect on update"); - } + // `--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( - *check, + update_check_mode(*check, cli.dry_run), *yes, &updater::RealFetcher::new(), &updater::RealDownloader, @@ -116,6 +116,15 @@ fn main() -> Result<()> { } } +/// 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!( @@ -223,6 +232,43 @@ mod tests { } } + #[test] + 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_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 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 debug_flag_round_trip() { set_debug(false); diff --git a/src/updater/tests.rs b/src/updater/tests.rs index ba5e1a1..7c4e2d1 100644 --- a/src/updater/tests.rs +++ b/src/updater/tests.rs @@ -1160,3 +1160,34 @@ fn run_update_unparsable_response_exits_2() { 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" + ); +} From c599dce7e88b2769f732c69a6d9d04b8d97487de Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 1 Oct 2026 01:50:29 -0500 Subject: [PATCH 10/23] fix(gs-update-dry-run): reviewer 2 fixes --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 1003c9b..5c1c2f5 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,7 @@ Updating is always explicit — ghscaff never installs anything on its own: 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 ``` On "yes", ghscaff downloads the release asset for your platform, verifies it @@ -119,6 +120,7 @@ release check cannot be completed — no network, DNS or TLS failure, HTTP ≥ 4 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: From 2024576fde82918b9b8427517fcdb08f3f82a241 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 1 Oct 2026 13:44:42 -0500 Subject: [PATCH 11/23] feat(gs-license-placeholder): render license placeholder with no-license handling --- src/templates/mod.rs | 652 ++++++++++++++++++++++++++++++++-- src/templates/rust.rs | 16 +- src/wizard/execution.rs | 17 +- src/wizard/execution/tests.rs | 27 ++ 4 files changed, 684 insertions(+), 28 deletions(-) diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 12243a2..973ed3d 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -8,6 +8,26 @@ use crate::github::client::GithubClient; const BOILERPLATE_REPO: &str = "UniverLab/ghscaff-boilerplate"; +/// 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", @@ -60,7 +80,13 @@ pub fn assemble_gitignore(fetched: &str) -> String { 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 +107,23 @@ impl RemoteTemplate { name: &str, description: &str, owner: &str, + license: &str, ) -> String { content .replace("{{name}}", name) .replace("{{description}}", description) .replace("{{github_org}}", owner) .replace("{{github_repo}}", name) + .replace("{{license}}", license) + } + + /// 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 +146,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,7 +172,10 @@ impl LanguageTemplate for RemoteTemplate { let Ok(raw) = std::fs::read_to_string(path) else { continue; }; - let content = self.apply_placeholders(&raw, name, description, owner); + let content = self.apply_placeholders(&raw, name, description, owner, spdx); + let Some(content) = adjust_for_no_license(&language, &rel, content, license) else { + continue; + }; files.push(RepoFile { path: rel, content }); } files.sort_by(|a, b| a.path.cmp(&b.path)); @@ -208,6 +256,127 @@ 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. +/// +/// * 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); + } + 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')) +} + +fn is_python_license_line(line: &str) -> bool { + let t = line.trim_start(); + t.starts_with("license =") || t.starts_with("license=") +} + +/// 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` is left +/// alone. 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 has_publish_check(&lines[job..end]) { + return ci_yml.to_string(); + } + insert_publish_check(&mut lines, job, end, job_indent); + join_lines(lines, trailing) +} + +fn has_publish_check(job_block: &[String]) -> bool { + job_block + .iter() + .any(|l| l.trim_start().starts_with("publish-check:")) +} + +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 +} + #[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 +511,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()); } @@ -498,7 +667,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 +689,7 @@ mod tests { "myapp", "My App", "myorg", + "", ); assert_eq!(result, "Hello myapp, welcome to My App by myorg/myapp"); } @@ -529,7 +699,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 +708,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 +768,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 +785,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 +799,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 +943,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 +1006,7 @@ required = false "repo", "desc", "myorg", + "", ); assert_eq!(result, "org=myorg repo=repo"); } @@ -845,7 +1016,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 +1025,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 +1063,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 +1094,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 +1113,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 +1198,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")); @@ -1107,6 +1278,7 @@ required = false "n", "d", "o", + "", ); assert_eq!(result, "n/d/o/n"); } @@ -1116,7 +1288,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 +1297,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 +1344,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 +1354,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 +1405,446 @@ 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 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_keeps_existing_publish_check() { + 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), 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" + ); + } + + #[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" + ); + } + + /// 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")); + } } 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/wizard/execution.rs b/src/wizard/execution.rs index fc223a0..f8e93dd 100644 --- a/src/wizard/execution.rs +++ b/src/wizard/execution.rs @@ -92,7 +92,7 @@ fn build_wizard_init_files( let mut init_files: Vec = vec![]; if let Some(tmpl) = template { - for f in tmpl.boilerplate_files(&c.name, &c.description, &c.owner) { + 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, @@ -129,6 +129,20 @@ fn build_wizard_init_files( 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, @@ -369,6 +383,7 @@ pub(super) fn execute( // 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(); diff --git a/src/wizard/execution/tests.rs b/src/wizard/execution/tests.rs index fdda961..97745f3 100644 --- a/src/wizard/execution/tests.rs +++ b/src/wizard/execution/tests.rs @@ -614,6 +614,33 @@ fn execute_runs_end_to_end_against_mock() { )); } +// ── 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] From 156eaf4861c1db7c12283fe23e465b3eb90d907a Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 1 Oct 2026 14:14:32 -0500 Subject: [PATCH 12/23] fix(gs-license-placeholder): reviewer 2 fixes --- README.md | 7 +++- docs/templates-and-conventions.md | 7 +++- src/templates/mod.rs | 55 ++++++++++++++++++++++++------- 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 5c1c2f5..2b4b2e7 100644 --- a/README.md +++ b/README.md @@ -284,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/docs/templates-and-conventions.md b/docs/templates-and-conventions.md index 528e562..5b70da8 100644 --- a/docs/templates-and-conventions.md +++ b/docs/templates-and-conventions.md @@ -16,11 +16,16 @@ 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 diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 973ed3d..dc61ec9 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -304,16 +304,23 @@ fn strip_python_license(pyproject: &str) -> String { 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 { - let t = line.trim_start(); - t.starts_with("license =") || t.starts_with("license=") + 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` is left -/// alone. Insertion is scoped to the `rust-ci` job so a later job is never -/// modified, and the file's trailing newline is preserved. +/// 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(); @@ -322,17 +329,23 @@ fn add_publish_check_false(ci_yml: &str) -> String { }; let job_indent = indent_of(&lines[job]); let end = job_block_end(&lines, job, job_indent); - if has_publish_check(&lines[job..end]) { - return ci_yml.to_string(); + 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) } -fn has_publish_check(job_block: &[String]) -> bool { - job_block - .iter() - .any(|l| l.trim_start().starts_with("publish-check:")) +/// 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) { @@ -1540,6 +1553,12 @@ jobs: 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); @@ -1559,9 +1578,21 @@ jobs: } #[test] - fn add_publish_check_false_keeps_existing_publish_check() { + 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); } From 510b126dca9e9591514e7e8891b7eae97ee4f112 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 1 Oct 2026 14:38:56 -0500 Subject: [PATCH 13/23] feat(gs-agentic-from-registry): derive agentic gitignore block from registry --- src/templates/agent_registry.rs | 540 ++++++++++++++++++ src/templates/agent_registry_snapshot.toml | 112 ++++ src/templates/agentic.rs | 627 +++++++++++++++++++++ src/templates/mod.rs | 57 +- tests/fixtures/agentic-block.txt | 40 ++ 5 files changed, 1346 insertions(+), 30 deletions(-) create mode 100644 src/templates/agent_registry.rs create mode 100644 src/templates/agent_registry_snapshot.toml create mode 100644 src/templates/agentic.rs create mode 100644 tests/fixtures/agentic-block.txt diff --git a/src/templates/agent_registry.rs b/src/templates/agent_registry.rs new file mode 100644 index 0000000..e482add --- /dev/null +++ b/src/templates/agent_registry.rs @@ -0,0 +1,540 @@ +//! 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() + ); + } + + /// 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 dc61ec9..b57c5a3 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}; @@ -36,35 +38,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() { @@ -73,8 +50,7 @@ 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 } @@ -569,6 +545,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 { 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/ From 2fee870c97e9de979a9e45759e8202c18be28295 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 1 Oct 2026 14:56:17 -0500 Subject: [PATCH 14/23] fix(gs-agentic-from-registry): reviewer 2 fixes --- docs/templates-and-conventions.md | 40 ++++++++++++++++++++++--------- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/docs/templates-and-conventions.md b/docs/templates-and-conventions.md index 5b70da8..884c2a4 100644 --- a/docs/templates-and-conventions.md +++ b/docs/templates-and-conventions.md @@ -30,28 +30,46 @@ committed, and `pyproject.toml` keeps no `license = …` line. 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. From 8e5b8d71a13e3446913a6072222cf2522d6db17e Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 1 Oct 2026 15:16:19 -0500 Subject: [PATCH 15/23] feat(gs-python-module-name): render module placeholder from project name --- src/templates/mod.rs | 78 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 2 deletions(-) diff --git a/src/templates/mod.rs b/src/templates/mod.rs index b57c5a3..2abc763 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -10,6 +10,15 @@ 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. @@ -85,12 +94,14 @@ impl RemoteTemplate { 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 @@ -149,10 +160,11 @@ impl LanguageTemplate for RemoteTemplate { continue; }; let content = self.apply_placeholders(&raw, name, description, owner, spdx); - let Some(content) = adjust_for_no_license(&language, &rel, content, license) else { + 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: rel, content }); + files.push(RepoFile { path, content }); } files.sort_by(|a, b| a.path.cmp(&b.path)); files @@ -1775,6 +1787,68 @@ jobs: ); } + #[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. From ad701fde06004cc98f616cf21fbd6d575e944400 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 1 Oct 2026 15:25:46 -0500 Subject: [PATCH 16/23] fix(gs-python-module-name): reviewer 2 fixes --- src/templates/mod.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 2abc763..e4e3c4a 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -1296,13 +1296,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] From 9b1099acf8708ba749949441b0e4aa59ffec8d89 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 1 Oct 2026 16:17:15 -0500 Subject: [PATCH 17/23] refactor(quality): quality pass fixes --- .cargo/mutants.toml | 4 ++++ src/templates/agent_registry.rs | 28 ++++++++++++++++++++++++ src/templates/mod.rs | 38 +++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index c35d65f..1b2c047 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -13,4 +13,8 @@ exclude_re = [ "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/src/templates/agent_registry.rs b/src/templates/agent_registry.rs index e482add..168640a 100644 --- a/src/templates/agent_registry.rs +++ b/src/templates/agent_registry.rs @@ -370,6 +370,34 @@ mod tests { ); } + /// 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. diff --git a/src/templates/mod.rs b/src/templates/mod.rs index e4e3c4a..377beb9 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -1621,6 +1621,44 @@ jobs: ); } + /// 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) From 04fd335cf5660041a6dabefdd93b7f8a4e6ee526 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 1 Oct 2026 17:05:44 -0500 Subject: [PATCH 18/23] feat(gs-license-docs): drop license and release docs without a license --- src/templates/mod.rs | 250 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 250 insertions(+) diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 377beb9..8cff64b 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -248,6 +248,11 @@ fn cache_dir() -> Result { /// 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. +/// /// * 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. @@ -261,6 +266,13 @@ fn adjust_for_no_license( if license.is_some() { return Some(content); } + let content = match rel { + "README.md" => remove_markdown_section(&content, "## License"), + "CONTRIBUTING.md" if language == "rust" => { + remove_markdown_section(&content, "## Release process") + } + _ => content, + }; if language == "rust" { return match rel { ".github/workflows/release.yml" => None, @@ -378,6 +390,29 @@ fn join_lines(lines: Vec, trailing_newline: bool) -> String { out } +/// Remove a markdown section: the line equal to `heading` (after trimming +/// trailing whitespace), every line up to (not including) the next line +/// starting with `## ` or the end of file, and the single blank line directly +/// above the heading if there is one. 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 end = (start + 1..lines.len()) + .find(|&i| lines[i].starts_with("## ")) + .unwrap_or(lines.len()); + 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')) +} + #[allow(dead_code)] pub fn apply_placeholders(dir: &Path, name: &str, description: &str, author: &str) -> Result<()> { for entry in walkdir::WalkDir::new(dir) @@ -1987,4 +2022,219 @@ jobs: 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: `## Release process` is a middle + /// section ending at `## Code style`. + const FIXTURE_CONTRIBUTING_RELEASE: &str = "# Contributing to {{name}}\n\n## Development workflow\n\nFork, branch, PR.\n\n## Release process\n\nReleases are automated via the `release.yml` workflow.\n\n## Code style\n\nRun `cargo fmt`.\n"; + + #[test] + fn remove_markdown_section_removes_middle_section() { + // The single blank line above the heading leaves with the section, so + // the next heading ends up glued to the preceding line (spec rule). + assert_eq!( + remove_markdown_section("top\n\n## A\nbody\nmore\n\n## B\ntail\n", "## A"), + "top\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" + ); + } + + #[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("## Development workflow")); + assert!(contributing.contains("## Code style")); + // The blank line above the heading went with the section, so the next + // heading follows the previous body line directly. + assert!(contributing.contains("Fork, branch, PR.\n## Code style")); + } + + #[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"), + "# Contributing to myrepo\n\n## Development workflow\n\nFork, branch, PR.\n\n## Release process\n\nReleases are automated via the `release.yml` workflow.\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")); + for heading in [ + "## Development workflow", + "## CI/CD and required secrets", + "## Code style", + ] { + assert!(contributing.contains(heading), "rust/None keeps {heading}"); + } + } 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); + } + } + } } From 6b2a54e91f6038360b3c17c96289e50993ed3056 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 1 Oct 2026 17:42:55 -0500 Subject: [PATCH 19/23] fix(gs-license-docs): reviewer 2 fixes --- src/templates/mod.rs | 98 +++++++++++++++++++++++++++++++---- src/wizard/execution.rs | 6 ++- src/wizard/execution/tests.rs | 17 ++++++ 3 files changed, 108 insertions(+), 13 deletions(-) diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 8cff64b..f8b37a5 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -251,7 +251,9 @@ fn cache_dir() -> Result { /// 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. +/// `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 @@ -268,9 +270,7 @@ fn adjust_for_no_license( } let content = match rel { "README.md" => remove_markdown_section(&content, "## License"), - "CONTRIBUTING.md" if language == "rust" => { - remove_markdown_section(&content, "## Release process") - } + "CONTRIBUTING.md" if language == "rust" => strip_rust_release_docs(&content), _ => content, }; if language == "rust" { @@ -413,6 +413,24 @@ fn remove_markdown_section(text: &str, heading: &str) -> 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) @@ -2029,9 +2047,28 @@ jobs: /// 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: `## Release process` is a middle - /// section ending at `## Code style`. - const FIXTURE_CONTRIBUTING_RELEASE: &str = "# Contributing to {{name}}\n\n## Development workflow\n\nFork, branch, PR.\n\n## Release process\n\nReleases are automated via the `release.yml` workflow.\n\n## Code style\n\nRun `cargo fmt`.\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() { @@ -2097,11 +2134,20 @@ jobs: 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")); - // The blank line above the heading went with the section, so the next - // heading follows the previous body line directly. - assert!(contributing.contains("Fork, branch, PR.\n## Code style")); + assert!(contributing.contains("Fork, branch, PR.")); + // The blank line above each removed heading went with the section, so + // what follows follows the preceding body line directly (spec rule). + assert!(contributing.contains("Fork, branch, PR.\n\n## CI/CD and required secrets")); + assert!(contributing.contains("ci.yml`).\n## Code style")); } #[test] @@ -2114,7 +2160,24 @@ jobs: ); assert_eq!( find(&files, "CONTRIBUTING.md"), - "# Contributing to myrepo\n\n## Development workflow\n\nFork, branch, PR.\n\n## Release process\n\nReleases are automated via the `release.yml` workflow.\n\n## Code style\n\nRun `cargo fmt`.\n" + 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" + ) ); } @@ -2189,6 +2252,13 @@ jobs: ); 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", @@ -2196,6 +2266,12 @@ jobs: ] { 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")); diff --git a/src/wizard/execution.rs b/src/wizard/execution.rs index f8e93dd..46d0d59 100644 --- a/src/wizard/execution.rs +++ b/src/wizard/execution.rs @@ -195,7 +195,9 @@ fn commit_init_files_step( }) } -/// Step 4 — develop branch off the just-committed default branch. +/// 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, @@ -205,7 +207,7 @@ fn create_develop_step( if !c.create_develop { return Ok(()); } - if init_sha.is_empty() { + 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(); diff --git a/src/wizard/execution/tests.rs b/src/wizard/execution/tests.rs index 97745f3..88015e5 100644 --- a/src/wizard/execution/tests.rs +++ b/src/wizard/execution/tests.rs @@ -298,6 +298,23 @@ fn create_develop_step_propagates_error() { 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) { From de3d97dd55c4bbe872e8b396253f7499b6c5450d Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 1 Oct 2026 18:20:56 -0500 Subject: [PATCH 20/23] refactor(quality): quality pass fixes --- src/templates/mod.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/templates/mod.rs b/src/templates/mod.rs index f8b37a5..a4f0673 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -2092,6 +2092,19 @@ jobs: ); } + #[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"; From 6fe904ae6fec57b464fca48c21f959c6fea0964b Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 1 Oct 2026 20:43:41 -0500 Subject: [PATCH 21/23] fix(gs-docs-blank-line): keep blank line before next heading when removing section --- src/templates/mod.rs | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/templates/mod.rs b/src/templates/mod.rs index a4f0673..2a8652a 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -391,18 +391,28 @@ fn join_lines(lines: Vec, trailing_newline: bool) -> String { } /// Remove a markdown section: the line equal to `heading` (after trimming -/// trailing whitespace), every line up to (not including) the next line -/// starting with `## ` or the end of file, and the single blank line directly -/// above the heading if there is one. Returns `text` unchanged when the +/// 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 end = (start + 1..lines.len()) + let next = (start + 1..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() { + (start + 1..next) + .rev() + .find(|&i| !lines[i].trim_end().is_empty()) + .map_or(start + 1, |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() @@ -2072,11 +2082,11 @@ jobs: #[test] fn remove_markdown_section_removes_middle_section() { - // The single blank line above the heading leaves with the section, so - // the next heading ends up glued to the preceding line (spec rule). + // 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## B\ntail\n" + "top\n\n## B\ntail\n" ); } @@ -2157,10 +2167,11 @@ jobs: .contains("This project uses GitHub Actions for CI (`.github/workflows/ci.yml`).")); assert!(contributing.contains("## Code style")); assert!(contributing.contains("Fork, branch, PR.")); - // The blank line above each removed heading went with the section, so - // what follows follows the preceding body line directly (spec rule). + // 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## Code style")); + assert!(contributing.contains("ci.yml`).\n\n## Code style")); + assert!(!contributing.contains("\n\n\n")); } #[test] From 9506ae0438a660b3ecb210d659205b4d628102b2 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Thu, 1 Oct 2026 21:34:33 -0500 Subject: [PATCH 22/23] refactor(quality): quality pass fixes --- src/templates/mod.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/templates/mod.rs b/src/templates/mod.rs index 2a8652a..53fa3a7 100644 --- a/src/templates/mod.rs +++ b/src/templates/mod.rs @@ -401,15 +401,16 @@ fn remove_markdown_section(text: &str, heading: &str) -> String { let Some(start) = lines.iter().position(|l| l.trim_end() == heading) else { return text.to_string(); }; - let next = (start + 1..lines.len()) + 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() { - (start + 1..next) + (body..next) .rev() .find(|&i| !lines[i].trim_end().is_empty()) - .map_or(start + 1, |i| i + 1) + .map_or(body, |i| i + 1) } else { next }; @@ -2102,6 +2103,17 @@ jobs: ); } + /// 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"), ""); From 6cc150bd32f578f254b58b8dccff8a360001da51 Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Mon, 5 Oct 2026 19:46:12 -0500 Subject: [PATCH 23/23] chore(release): re-record the demo against the current wizard and bump ghscaff to 0.7.0 --- Cargo.lock | 2 +- Cargo.toml | 2 +- demo/demo.rec | 789 +++++++++++++++++++++++---------------------- demo/demo.toml | 54 +++- demo/dist/demo.gif | Bin 165391 -> 203003 bytes 5 files changed, 458 insertions(+), 389 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 58c6d53..389455f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -582,7 +582,7 @@ dependencies = [ [[package]] name = "ghscaff" -version = "0.6.0" +version = "0.7.0" dependencies = [ "anyhow", "base64", diff --git a/Cargo.toml b/Cargo.toml index 800dcac..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" diff --git a/demo/demo.rec b/demo/demo.rec index bd6ebea..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\\]$ ", "speed": "2x", "targets": ["gif", "mp4"]}, "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 74d88ea..a397a66 100644 --- a/demo/demo.toml +++ b/demo/demo.toml @@ -65,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" @@ -218,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" @@ -236,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 c588835c5347ba965d4eca203a7dcad867baae85..8d02df79bc1d16a698f5b8a693919db623751dfa 100644 GIT binary patch delta 123556 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 KNJ*y>5CA*mFuvLV delta 90982 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 Z@8{nV-~a!h*sE_q0t+N?zL^3706WRKbh-cl