diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index efa5cac..ec44e2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,6 +2,7 @@ name: CI on: pull_request: + workflow_dispatch: jobs: rust-ci: diff --git a/Cargo.lock b/Cargo.lock index 820630e..df4fef9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -561,7 +561,7 @@ dependencies = [ [[package]] name = "ghscaff" -version = "0.3.2" +version = "0.4.0" dependencies = [ "anyhow", "base64", @@ -579,6 +579,8 @@ dependencies = [ "toml", "urlencoding", "walkdir", + "whoami", + "xsalsa20poly1305", ] [[package]] @@ -1924,6 +1926,12 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasm-bindgen" version = "0.2.117" @@ -2042,6 +2050,17 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" @@ -2340,6 +2359,19 @@ 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.2" diff --git a/Cargo.toml b/Cargo.toml index e7c67ea..32340c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ghscaff" -version = "0.3.2" +version = "0.4.0" edition = "2021" description = "Interactive CLI wizard for creating and configuring GitHub repositories" license = "MIT" @@ -28,6 +28,8 @@ urlencoding = "2" toml = "0.8" crypto_box = "0.9" blake2 = "0.10" +xsalsa20poly1305 = "0.9" +whoami = "1" [dev-dependencies] tempfile = "3" diff --git a/README.md b/README.md index 3c0061f..4faa29a 100644 --- a/README.md +++ b/README.md @@ -31,13 +31,13 @@ Interactive CLI wizard for creating and configuring GitHub repositories. One bin - **🪄 Interactive wizard** — Create GitHub repos with a conversational guided flow - **⚡ Zero dependencies** — Single binary, no runtime requirements +- **🔒 Encrypted vault** — Tokens stored locally with XSalsa20-Poly1305, never in env vars or plain text - **🔄 Idempotent apply mode** — Configure existing repos without recreation - **👥 Team access control** — Assign repositories to organization teams with custom permissions (read, triage, write, admin) -- **🏷️ Smart labels** — Auto-create 6 core issue labels +- **🏷️ Enforced labels** — 7 standard labels synced on every run (non-standard labels are removed) - **🛡️ Branch protection** — Enforce reviews, status checks, and workflow validation - **🚀 Language templates** — Rust (v1), Python/Node.js/Java coming soon - **📝 Boilerplate files** — README, Cargo.toml, CI/CD workflows, LICENSE -- **🔐 Token validation** — Fail-fast authentication checks - **🔑 Template secrets** — Automatically configures required GitHub Actions secrets per template - **⬆️ Self-update** — Detects new releases on startup and offers one-command upgrade @@ -96,7 +96,7 @@ Check the [Releases](https://github.com/UniverLab/ghscaff/releases) page for pre ```bash rm -f ~/.local/bin/ghscaff # ghscaff binary -rm -rf ~/.ghscaff/ # boilerplate cache +rm -rf ~/.ghscaff/ # boilerplate cache + encrypted vault ``` --- @@ -104,10 +104,8 @@ rm -rf ~/.ghscaff/ # boilerplate cache ## Quick Start ```bash -# Set your GitHub token -export GITHUB_TOKEN=ghp_xxxxxxxxxxxx - # Interactive wizard — create a new repo +# (token is requested on first run and stored in the encrypted vault) ghscaff # Or directly with a subcommand @@ -118,10 +116,52 @@ ghscaff apply owner/repo # Preview changes without API calls ghscaff --dry-run + +# Reconfigure credentials +ghscaff config ``` --- +## Authentication + +ghscaff resolves the GitHub token in this order: + +1. **`GITHUB_TOKEN` env var** — for CI/CD and backward compatibility +2. **Encrypted vault** (`~/.ghscaff/vault.enc`) — for secure local usage +3. **Interactive prompt** — on first run, asks for the token and saves it to the vault + +### Encrypted Vault + +Tokens are encrypted with **XSalsa20-Poly1305** and a key derived from: + +| Factor | Purpose | +|--------|---------| +| Username | Only your OS user can decrypt | +| Hostname | Copying the vault to another machine won't work | +| Binary path | Other programs can't derive the same key | +| Passphrase (optional) | Extra protection if desired | + +The vault file (`~/.ghscaff/vault.enc`) has `0600` permissions and the directory has `0700`. Writes are atomic (temp file + rename) to prevent corruption. + +### Reconfiguring + +```bash +ghscaff config +``` + +This wipes the vault (with confirmation) and starts fresh — new token, optional passphrase. Template secrets will be requested on the next run. + +### Required token scopes + +- `repo` — Repository access +- `workflow` — GitHub Actions access +- `read:org` — (Optional) Organization and team access + +**Note on team access:** If your token lacks the `read:org` scope, the wizard will skip the team selection step with a warning, but the rest of the repository setup will continue normally. + +--- + ## Wizard Flow The wizard guides you through **7 interactive steps**: @@ -139,8 +179,8 @@ Then **automatically**: - Commits all boilerplate files in a single atomic commit (`chore: init repository`) - Applies branch protection to main (and develop if created) - Adds selected teams with their assigned permissions -- Syncs labels, topics, and CI/CD workflows -- Configures required GitHub Actions secrets from `secrets.toml` +- Enforces standard labels (creates missing, updates changed, removes non-standard) +- Configures required GitHub Actions secrets (from vault, env, or interactive prompt) --- @@ -158,10 +198,10 @@ ghscaff apply Applies: - ✅ Atomic single commit with all boilerplate files (no individual file commits) -- ✅ Labels (creates missing, updates existing) +- ✅ Labels enforced (creates missing, updates changed, **removes non-standard**) - ✅ Branch protection on `main` and `develop` (if created) - ✅ Topics (merges with existing) -- ✅ GitHub Actions secrets (from template's `secrets.toml`) +- ✅ GitHub Actions secrets (from vault, env, or interactive prompt) - ✅ CI/CD workflows (included in boilerplate) - ✅ `develop` branch (creates if absent) @@ -182,28 +222,6 @@ ghscaff apply owner/repo --dry-run --- -## Authentication - -`ghscaff` reads the GitHub token exclusively from the `GITHUB_TOKEN` environment variable: - -```bash -export GITHUB_TOKEN=ghp_xxxxxxxxxxxx -ghscaff -``` - -**Required token scopes:** -- `repo` — Repository access -- `workflow` — GitHub Actions access -- `read:org` — (Optional, for team access feature) Organization and team access - -If the token is missing or invalid, ghscaff fails immediately with a clear error message before prompting anything else. - -**Note on team access:** If your token lacks the `read:org` scope, the wizard will skip the team selection step with a warning, but the rest of the repository setup will continue normally. - -**Security note:** Never hardcode tokens. Use environment variables or secret managers. - ---- - ## Boilerplate Templates Each language template includes: @@ -221,7 +239,7 @@ All files are merged into a single atomic `chore: init repository` commit. ## Standard Label Set -6 core labels are auto-created with every new repo: +7 labels are enforced on every repo. Non-standard labels are removed. | Label | Color | Description | |-------|-------|-------------| @@ -229,7 +247,8 @@ All files are merged into a single atomic `chore: init repository` commit. | `feature` | `#a2eeef` | New feature or request | | `documentation` | `#0075ca` | Improvements to docs | | `breaking-change` | `#e4e669` | Introduces breaking changes | -| `good first issue` | `#7057ff` | Good for newcomers | +| `target:main` | `#1d76db` | Targets the main branch | +| `target:develop` | `#0e8a16` | Targets the develop branch | | `help wanted` | `#008672` | Extra attention needed | --- @@ -242,15 +261,18 @@ When enabled, applies to the default branch: - ✅ Dismiss stale reviews - ✅ Disallow force-push --- +--- ### Secrets Configuration -If you're extending `ghscaff` with new templates or modifying the release workflow, you may need to set up GitHub Actions secrets for your development fork: +Templates can declare required secrets in `secrets.toml`. ghscaff resolves them in order: + +1. **Encrypted vault** — previously saved secrets +2. **Environment variable** — e.g. `CARGO_REGISTRY_TOKEN` +3. **Interactive prompt** — with option to save to vault for future use -- **`CARGO_REGISTRY_TOKEN`** — Required for publishing Rust crates to crates.io - - Get your token from [crates.io/me](https://crates.io/me) - - Add it as a repository secret in GitHub (`Settings > Secrets and variables > Actions`) +For the Rust template: +- **`CARGO_REGISTRY_TOKEN`** — Required for publishing to crates.io ([get one here](https://crates.io/me)) --- diff --git a/src/apply.rs b/src/apply.rs index d8d23c9..c0f3c83 100644 --- a/src/apply.rs +++ b/src/apply.rs @@ -149,31 +149,40 @@ pub fn sync_labels( let mut created = 0; let mut updated = 0; let mut up_to_date = 0; + let mut deleted = 0; - for std_label in standard { + for std_label in &standard { if let Some(existing) = current.iter().find(|l| l.name == std_label.name) { - // Check if needs update if existing.color != std_label.color || existing.description != std_label.description { if !dry_run { - labels::update_label(client, owner, repo_name, &std_label.name, &std_label)?; + labels::update_label(client, owner, repo_name, &std_label.name, std_label)?; } updated += 1; } else { up_to_date += 1; } } else { - // Create new label if !dry_run { - labels::create_label(client, owner, repo_name, &std_label)?; + labels::create_label(client, owner, repo_name, std_label)?; } created += 1; } } + for existing in ¤t { + if !standard.iter().any(|s| s.name == existing.name) { + if !dry_run { + let _ = labels::delete_label(client, owner, repo_name, &existing.name); + } + deleted += 1; + } + } + Ok(SyncResult { created, updated, up_to_date, + deleted, }) } @@ -208,12 +217,13 @@ pub struct SyncResult { pub created: usize, pub updated: usize, pub up_to_date: usize, + pub deleted: usize, } /// Main apply mode orchestrator pub fn run_apply(repo_arg: Option<&str>, dry_run: bool) -> Result<()> { // Get token - let token = crate::github::client::token_from_env()?; + let (token, passphrase) = crate::github::client::resolve_token()?; let client = crate::github::client::GithubClient::new(&token); // Determine repo @@ -231,10 +241,13 @@ pub fn run_apply(repo_arg: Option<&str>, dry_run: bool) -> Result<()> { println!(" Summary of changes:"); println!(" ◆ Labels: checking..."); let label_result = sync_labels(&client, &owner, &repo_name, true)?; // dry check - if label_result.created > 0 || label_result.updated > 0 { + if label_result.created > 0 || label_result.updated > 0 || label_result.deleted > 0 { println!( - " • {} to create, {} to update, {} up to date", - label_result.created, label_result.updated, label_result.up_to_date + " • {} to create, {} to update, {} to delete, {} up to date", + label_result.created, + label_result.updated, + label_result.deleted, + label_result.up_to_date ); } else { println!(" • all up to date"); @@ -332,7 +345,7 @@ pub fn run_apply(repo_arg: Option<&str>, dry_run: bool) -> Result<()> { &owner, &repo_name, "main", - "rust-ci / Format, Lint & Test", + Some("rust-ci / Format, Lint & Test"), ) { Ok(()) => println!(" ✓ Branch protection applied"), Err(e) => { @@ -412,10 +425,13 @@ pub fn run_apply(repo_arg: Option<&str>, dry_run: bool) -> Result<()> { } println!(); for spec in missing { - if let Ok(env_val) = std::env::var(&spec.name) { - match secrets::set_secret(&client, &owner, &repo_name, &spec.name, &env_val) { + if let Some(val) = crate::vault::resolve_secret(&spec.name, &passphrase)? { + match secrets::set_secret(&client, &owner, &repo_name, &spec.name, &val) { Ok(()) => { - println!(" ✓ Secret {} configured (from environment)", spec.name) + println!( + " ✓ Secret {} configured (from vault/environment)", + spec.name + ) } Err(e) => println!(" ⚠ Failed to set {}: {e:#}", spec.name), } @@ -427,14 +443,29 @@ pub fn run_apply(repo_arg: Option<&str>, dry_run: bool) -> Result<()> { .prompt_skippable()?; match ans.as_deref() { Some(v) if !v.is_empty() => { + let save_it = inquire::Confirm::new( + " Save this secret in the vault for future use?", + ) + .with_default(true) + .prompt() + .unwrap_or(false); + if save_it { + if let Err(e) = + crate::vault::save_secret(&spec.name, v, &passphrase) + { + println!(" ⚠ Could not save to vault: {e}"); + } else { + println!(" \x1b[32m✓\x1b[0m Secret saved to vault"); + } + } match secrets::set_secret(&client, &owner, &repo_name, &spec.name, v) { Ok(()) => println!(" ✓ Secret {} configured", spec.name), Err(e) => println!(" ⚠ Failed to set {}: {e:#}", spec.name), } } _ => println!( - " ⚠ Secret {} skipped — set ${} and re-run `ghscaff apply`", - spec.name, spec.name + " ⚠ Secret {} skipped — re-run `ghscaff apply` to set it later", + spec.name ), } } @@ -544,9 +575,11 @@ mod tests { created: 2, updated: 1, up_to_date: 9, + deleted: 3, }; assert_eq!(result.created, 2); assert_eq!(result.updated, 1); assert_eq!(result.up_to_date, 9); + assert_eq!(result.deleted, 3); } } diff --git a/src/github/branches.rs b/src/github/branches.rs index c02c256..8af1e3d 100644 --- a/src/github/branches.rs +++ b/src/github/branches.rs @@ -50,7 +50,7 @@ pub fn apply_branch_protection( owner: &str, repo: &str, branch: &str, - ci_check: &str, + ci_check: Option<&str>, ) -> Result<()> { #[derive(Serialize)] struct Body<'a> { @@ -80,7 +80,7 @@ pub fn apply_branch_protection( let body = Body { required_status_checks: RequiredChecks { strict: true, - contexts: vec![ci_check], + contexts: ci_check.into_iter().collect(), }, enforce_admins: false, required_pull_request_reviews: Reviews { diff --git a/src/github/client.rs b/src/github/client.rs index 9d1fa7e..7f98169 100644 --- a/src/github/client.rs +++ b/src/github/client.rs @@ -1,5 +1,5 @@ use anyhow::{Context, Result}; -use reqwest::blocking::Client; +use reqwest::blocking::{Client, Response}; use serde::de::DeserializeOwned; pub struct GithubClient { @@ -7,6 +7,20 @@ pub struct GithubClient { token: String, } +fn check_status(resp: Response) -> Result { + let status = resp.status(); + if status.is_success() { + return Ok(resp); + } + let url = resp.url().to_string(); + let body = resp.text().unwrap_or_default(); + let message = serde_json::from_str::(&body) + .ok() + .and_then(|v| v["message"].as_str().map(String::from)) + .unwrap_or(body); + anyhow::bail!("{status} — {message}\n URL: {url}") +} + impl GithubClient { pub fn new(token: &str) -> Self { Self { @@ -15,17 +29,21 @@ impl GithubClient { } } - pub fn get(&self, path: &str) -> Result { + fn request(&self, method: reqwest::Method, path: &str) -> reqwest::blocking::RequestBuilder { let url = format!("https://api.github.com{path}"); self.client - .get(&url) + .request(method, &url) .header("Authorization", format!("token {}", self.token)) .header("User-Agent", "ghscaff") .header("Accept", "application/vnd.github+json") + } + + pub fn get(&self, path: &str) -> Result { + let resp = self + .request(reqwest::Method::GET, path) .send() - .context("HTTP GET failed")? - .error_for_status() - .context("GitHub API error")? + .context("HTTP GET failed")?; + check_status(resp)? .json() .context("Failed to parse response") } @@ -35,49 +53,34 @@ impl GithubClient { path: &str, body: &B, ) -> Result { - let url = format!("https://api.github.com{path}"); - self.client - .post(&url) - .header("Authorization", format!("token {}", self.token)) - .header("User-Agent", "ghscaff") - .header("Accept", "application/vnd.github+json") + let resp = self + .request(reqwest::Method::POST, path) .json(body) .send() - .context("HTTP POST failed")? - .error_for_status() - .context("GitHub API error")? + .context("HTTP POST failed")?; + check_status(resp)? .json() .context("Failed to parse response") } pub fn put(&self, path: &str, body: &B) -> Result { - let url = format!("https://api.github.com{path}"); - self.client - .put(&url) - .header("Authorization", format!("token {}", self.token)) - .header("User-Agent", "ghscaff") - .header("Accept", "application/vnd.github+json") + let resp = self + .request(reqwest::Method::PUT, path) .json(body) .send() - .context("HTTP PUT failed")? - .error_for_status() - .context("GitHub API error")? + .context("HTTP PUT failed")?; + check_status(resp)? .json() .context("Failed to parse response") } pub fn put_no_response(&self, path: &str, body: &B) -> Result<()> { - let url = format!("https://api.github.com{path}"); - self.client - .put(&url) - .header("Authorization", format!("token {}", self.token)) - .header("User-Agent", "ghscaff") - .header("Accept", "application/vnd.github+json") + let resp = self + .request(reqwest::Method::PUT, path) .json(body) .send() - .context("HTTP PUT failed")? - .error_for_status() - .context("GitHub API error")?; + .context("HTTP PUT failed")?; + check_status(resp)?; Ok(()) } @@ -86,25 +89,36 @@ impl GithubClient { path: &str, body: &B, ) -> Result { - let url = format!("https://api.github.com{path}"); - self.client - .patch(&url) - .header("Authorization", format!("token {}", self.token)) - .header("User-Agent", "ghscaff") - .header("Accept", "application/vnd.github+json") + let resp = self + .request(reqwest::Method::PATCH, path) .json(body) .send() - .context("HTTP PATCH failed")? - .error_for_status() - .context("GitHub API error")? + .context("HTTP PATCH failed")?; + check_status(resp)? .json() .context("Failed to parse response") } + + pub fn delete(&self, path: &str) -> Result<()> { + let resp = self + .request(reqwest::Method::DELETE, path) + .send() + .context("HTTP DELETE failed")?; + check_status(resp)?; + Ok(()) + } } -/// Read GITHUB_TOKEN from env. Fail fast with a clear message. -pub fn token_from_env() -> Result { - std::env::var("GITHUB_TOKEN").context( - "GITHUB_TOKEN not set. Export your token:\n export GITHUB_TOKEN=ghp_xxxxxxxxxxxx\n\nRequired scopes (classic PAT): repo, workflow\nRequired permissions (fine-grained PAT): Contents=write, Workflows=write, Administration=write, Metadata=read" - ) +/// Env var → vault → inline prompt. Returns (token, vault_passphrase). +pub fn resolve_token() -> Result<(String, String)> { + if let Ok(token) = std::env::var("GITHUB_TOKEN") { + return Ok((token, String::new())); + } + + if let Some(pair) = crate::vault::resolve_github_token()? { + return Ok(pair); + } + + println!(" No GitHub token found.\n"); + crate::vault::prompt_and_save_github_token() } diff --git a/src/github/contents.rs b/src/github/contents.rs index 0bea784..3193a95 100644 --- a/src/github/contents.rs +++ b/src/github/contents.rs @@ -102,11 +102,19 @@ pub fn create_tree_commit( branch: &str, ) -> Result { // GitHub initializes the git database asynchronously after repo creation. - // Wait until the default branch ref is reachable before proceeding. + // The ref may exist before the Git Database API is ready, so we verify + // by attempting a lightweight API call that requires the git DB. const READY_DELAYS_MS: &[u64] = &[1000, 2000, 3000, 5000, 8000]; for &delay_ms in READY_DELAYS_MS { if get_branch_sha_opt(client, owner, repo, branch).is_some() { - break; + // Verify git DB is actually ready by checking the commit is fetchable + let sha = get_branch_sha_opt(client, owner, repo, branch).unwrap(); + if client + .get::(&format!("/repos/{owner}/{repo}/git/commits/{sha}")) + .is_ok() + { + break; + } } std::thread::sleep(std::time::Duration::from_millis(delay_ms)); } diff --git a/src/github/labels.rs b/src/github/labels.rs index 70c70a9..990ab6b 100644 --- a/src/github/labels.rs +++ b/src/github/labels.rs @@ -31,6 +31,11 @@ pub fn update_label( Ok(()) } +pub fn delete_label(client: &GithubClient, owner: &str, repo: &str, name: &str) -> Result<()> { + let encoded = urlencoding::encode(name); + client.delete(&format!("/repos/{owner}/{repo}/labels/{encoded}")) +} + pub fn standard_labels() -> Vec