From 10ba1ff044e222401367f9c865f0698829c15fcb Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Fri, 10 Apr 2026 11:36:53 -0500 Subject: [PATCH 01/13] feat: add encrypted vault for secure local token storage --- Cargo.lock | 34 ++++- Cargo.toml | 2 + src/vault.rs | 354 +++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 src/vault.rs 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..e33c4c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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/src/vault.rs b/src/vault.rs new file mode 100644 index 0000000..a628027 --- /dev/null +++ b/src/vault.rs @@ -0,0 +1,354 @@ +use anyhow::{Context, Result}; +use blake2::digest::{Update, VariableOutput}; +use blake2::Blake2bVar; +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; +const DOMAIN_SEPARATOR: &[u8] = b"|ghscaff-vault-v1"; + +#[derive(Serialize, Deserialize, Default, Debug, PartialEq)] +pub struct VaultData { + #[serde(default)] + pub github_token: Option, + #[serde(default)] + pub has_passphrase: bool, + #[serde(default)] + pub secrets: HashMap, +} + +/// Blake2b-256(username ‖ hostname ‖ binary_path ‖ passphrase ‖ domain) +fn derive_key(passphrase: &str) -> Result<[u8; KEY_LEN]> { + let mut hasher = Blake2bVar::new(KEY_LEN).expect("valid output size"); + Update::update(&mut hasher, whoami::username().as_bytes()); + Update::update(&mut hasher, b"|"); + Update::update( + &mut hasher, + whoami::fallible::hostname().unwrap_or_default().as_bytes(), + ); + Update::update(&mut hasher, b"|"); + Update::update( + &mut hasher, + std::env::current_exe() + .unwrap_or_default() + .to_string_lossy() + .as_bytes(), + ); + Update::update(&mut hasher, b"|"); + Update::update(&mut hasher, passphrase.as_bytes()); + Update::update(&mut hasher, DOMAIN_SEPARATOR); + + let mut key = [0u8; KEY_LEN]; + hasher + .finalize_variable(&mut key) + .expect("buffer matches output size"); + Ok(key) +} + +fn vault_path() -> Result { + let home = dirs::home_dir().context("Cannot resolve home directory")?; + Ok(home.join(".ghscaff").join("vault.enc")) +} + +/// 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}; + + let key_bytes = derive_key(passphrase)?; + let key = GenericArray::from_slice(&key_bytes); + let cipher = xsalsa20poly1305::XSalsa20Poly1305::new(key); + + let mut nonce_bytes = [0u8; NONCE_LEN]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = GenericArray::from_slice(&nonce_bytes); + + let plaintext = serde_json::to_vec(data).context("Failed to serialize vault")?; + let ciphertext = cipher + .encrypt(nonce, plaintext.as_ref()) + .map_err(|_| anyhow::anyhow!("Encryption failed"))?; + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let mut blob = Vec::with_capacity(NONCE_LEN + ciphertext.len()); + blob.extend_from_slice(&nonce_bytes); + blob.extend_from_slice(&ciphertext); + std::fs::write(path, &blob)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + } + + Ok(()) +} + +fn load_from_path(passphrase: &str, path: &Path) -> Result> { + use crypto_box::aead::{generic_array::GenericArray, Aead}; + + if !path.exists() { + return Ok(None); + } + + let blob = std::fs::read(path).context("Failed to read vault file")?; + if blob.len() < NONCE_LEN { + anyhow::bail!("Corrupt vault file"); + } + + 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 nonce = GenericArray::from_slice(nonce_bytes); + + let plaintext = cipher + .decrypt(nonce, ciphertext) + .map_err(|_| anyhow::anyhow!("Decryption failed — wrong passphrase or corrupt vault"))?; + + serde_json::from_slice(&plaintext) + .map(Some) + .context("Failed to parse vault contents") +} + +pub fn save(data: &VaultData, passphrase: &str) -> Result<()> { + save_to_path(data, passphrase, &vault_path()?) +} + +pub fn load(passphrase: &str) -> Result> { + load_from_path(passphrase, &vault_path()?) +} + +pub fn destroy() -> Result { + let path = vault_path()?; + if !path.exists() { + return Ok(false); + } + std::fs::remove_file(&path)?; + Ok(true) +} + +pub fn exists() -> bool { + vault_path().map(|p| p.exists()).unwrap_or(false) +} + +/// Try without passphrase first; prompt if vault has one. +pub fn load_interactive() -> Result> { + if !exists() { + return Ok(None); + } + + if let Ok(Some(data)) = load("") { + if !data.has_passphrase { + return Ok(Some((data, String::new()))); + } + } + + let passphrase = inquire::Password::new("Vault passphrase:") + .without_confirmation() + .prompt() + .context("Failed to read passphrase")?; + + let data = load(&passphrase)? + .ok_or_else(|| anyhow::anyhow!("Failed to decrypt vault — wrong passphrase"))?; + Ok(Some((data, passphrase))) +} + +pub fn resolve_github_token() -> Result> { + if let Ok(token) = std::env::var("GITHUB_TOKEN") { + return Ok(Some((token, String::new()))); + } + + if let Some((data, passphrase)) = load_interactive()? { + if let Some(token) = data.github_token { + return Ok(Some((token, passphrase))); + } + } + + Ok(None) +} + +pub fn resolve_secret(name: &str, passphrase: &str) -> Result> { + if let Ok(val) = std::env::var(name) { + return Ok(Some(val)); + } + + if let Some(data) = load(passphrase)? { + if let Some(val) = data.secrets.get(name) { + return Ok(Some(val.clone())); + } + } + + Ok(None) +} + +pub fn prompt_and_save_github_token() -> Result<(String, String)> { + let token = inquire::Password::new("GitHub token (ghp_...):") + .with_help_message("Required scopes: repo, workflow — https://github.com/settings/tokens") + .without_confirmation() + .prompt() + .context("Failed to read token")?; + + if token.is_empty() { + anyhow::bail!("Token cannot be empty"); + } + + let passphrase = if exists() { + load_interactive()? + .ok_or_else(|| anyhow::anyhow!("Cannot read existing vault"))? + .1 + } else { + ask_optional_passphrase()? + }; + + let mut data = load(&passphrase)?.unwrap_or_default(); + data.github_token = Some(token.clone()); + data.has_passphrase = !passphrase.is_empty(); + save(&data, &passphrase)?; + + println!(" \x1b[32m✓\x1b[0m Token saved to encrypted vault (~/.ghscaff/vault.enc)"); + Ok((token, passphrase)) +} + +fn ask_optional_passphrase() -> Result { + let want = inquire::Confirm::new("Add an optional passphrase to protect the vault?") + .with_default(false) + .with_help_message("If set, you'll need to enter it each time ghscaff runs") + .prompt()?; + + if !want { + return Ok(String::new()); + } + + Ok(inquire::Password::new("Passphrase:") + .prompt() + .unwrap_or_default()) +} + +pub fn save_secret(name: &str, value: &str, passphrase: &str) -> Result<()> { + let mut data = load(passphrase)?.unwrap_or_default(); + data.secrets.insert(name.to_string(), value.to_string()); + data.has_passphrase = !passphrase.is_empty(); + save(&data, passphrase) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_vault_data() -> VaultData { + let mut secrets = HashMap::new(); + secrets.insert("CARGO_REGISTRY_TOKEN".into(), "crates_token_123".into()); + VaultData { + github_token: Some("ghp_test_token_abc".into()), + has_passphrase: false, + secrets, + } + } + + #[test] + fn save_load_roundtrip_no_passphrase() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.enc"); + let data = test_vault_data(); + + save_to_path(&data, "", &path).unwrap(); + let loaded = load_from_path("", &path).unwrap().unwrap(); + + assert_eq!(loaded.github_token, data.github_token); + assert_eq!(loaded.secrets, data.secrets); + assert!(!loaded.has_passphrase); + } + + #[test] + fn save_load_roundtrip_with_passphrase() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.enc"); + let mut data = test_vault_data(); + data.has_passphrase = true; + + save_to_path(&data, "my-secret-pass", &path).unwrap(); + let loaded = load_from_path("my-secret-pass", &path).unwrap().unwrap(); + + assert_eq!(loaded.github_token, data.github_token); + assert_eq!(loaded.secrets, data.secrets); + assert!(loaded.has_passphrase); + } + + #[test] + fn wrong_passphrase_fails() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.enc"); + + save_to_path(&test_vault_data(), "correct", &path).unwrap(); + let result = load_from_path("wrong", &path); + + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("Decryption failed")); + } + + #[test] + fn load_nonexistent_returns_none() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("nonexistent.enc"); + + let result = load_from_path("", &path).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn corrupt_file_fails() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.enc"); + std::fs::write(&path, b"short").unwrap(); + + let result = load_from_path("", &path); + assert!(result.is_err()); + } + + #[test] + fn derive_key_differs_with_passphrase() { + let key_empty = derive_key("").unwrap(); + let key_pass = derive_key("secret").unwrap(); + assert_ne!(key_empty, key_pass); + } + + #[test] + fn derive_key_deterministic() { + let key1 = derive_key("test").unwrap(); + let key2 = derive_key("test").unwrap(); + assert_eq!(key1, key2); + } + + #[test] + fn vault_data_default() { + let data = VaultData::default(); + assert!(data.github_token.is_none()); + assert!(!data.has_passphrase); + assert!(data.secrets.is_empty()); + } + + #[test] + fn save_overwrites_existing() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("vault.enc"); + + let mut data1 = VaultData::default(); + data1.github_token = Some("token_v1".into()); + save_to_path(&data1, "", &path).unwrap(); + + let mut data2 = VaultData::default(); + data2.github_token = Some("token_v2".into()); + save_to_path(&data2, "", &path).unwrap(); + + let loaded = load_from_path("", &path).unwrap().unwrap(); + assert_eq!(loaded.github_token.unwrap(), "token_v2"); + } +} From 67ad69b75a0f660358ff8b77a107dd6b3157166a Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Fri, 10 Apr 2026 11:37:02 -0500 Subject: [PATCH 02/13] feat: integrate vault into wizard, apply and add config subcommand --- src/apply.rs | 61 ++++++++++++++++++++++++++++++++++---------- src/github/client.rs | 31 ++++++++++++++++++---- src/main.rs | 44 ++++++++++++++++++++++++++++++++ src/wizard.rs | 54 +++++++++++++++++++++++++++------------ 4 files changed, 155 insertions(+), 35 deletions(-) diff --git a/src/apply.rs b/src/apply.rs index d8d23c9..fb9feab 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"); @@ -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/client.rs b/src/github/client.rs index 9d1fa7e..f9bb64c 100644 --- a/src/github/client.rs +++ b/src/github/client.rs @@ -100,11 +100,32 @@ impl GithubClient { .json() .context("Failed to parse response") } + + pub fn delete(&self, path: &str) -> Result<()> { + let url = format!("https://api.github.com{path}"); + self.client + .delete(&url) + .header("Authorization", format!("token {}", self.token)) + .header("User-Agent", "ghscaff") + .header("Accept", "application/vnd.github+json") + .send() + .context("HTTP DELETE failed")? + .error_for_status() + .context("GitHub API error")?; + 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/main.rs b/src/main.rs index b680b87..03be4fb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ use clap::{Parser, Subcommand}; mod apply; mod github; mod templates; +mod vault; mod wizard; #[derive(Parser)] @@ -35,6 +36,8 @@ enum Command { #[arg(long)] dry_run: bool, }, + /// Reconfigure ghscaff credentials (wipes vault and starts fresh) + Config, } fn main() -> Result<()> { @@ -43,9 +46,50 @@ fn main() -> Result<()> { 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(), } } +fn run_config() -> Result<()> { + println!(); + println!( + " \x1b[33m⚠ This will delete ALL stored credentials and secrets from the vault.\x1b[0m" + ); + println!(" \x1b[33m This action cannot be undone.\x1b[0m"); + println!(); + + let confirmed = inquire::Confirm::new("Continue with reconfiguration?") + .with_default(false) + .prompt()?; + + if !confirmed { + println!(" Aborted."); + return Ok(()); + } + + if vault::destroy()? { + println!(" \x1b[32m✓\x1b[0m Vault deleted"); + } else { + println!(" ℹ No vault found"); + } + + println!(); + let (token, _) = vault::prompt_and_save_github_token()?; + + // Validate + let client = github::client::GithubClient::new(&token); + print!(" Validating token... "); + let user = github::repo::get_user(&client)?; + println!("ok ({})", user.login); + + println!(); + println!( + " \x1b[32m✓\x1b[0m ghscaff reconfigured. Template secrets will be requested on next run." + ); + println!(); + Ok(()) +} + fn check_for_update() { if std::env::var("GHSCAFF_NO_UPDATE_CHECK").is_ok() { return; diff --git a/src/wizard.rs b/src/wizard.rs index e4644bf..d16734d 100644 --- a/src/wizard.rs +++ b/src/wizard.rs @@ -3,7 +3,7 @@ use inquire::{Confirm, MultiSelect, Password, Select, Text}; use crate::github::{ branches, - client::{token_from_env, GithubClient}, + client::{resolve_token, GithubClient}, contents, labels, repo, secrets, teams, }; use crate::templates; @@ -43,7 +43,7 @@ pub fn run(dry_run: bool) -> Result<()> { println!(" Create a new GitHub repository\n"); // Fail fast — validate token before asking anything - let token = token_from_env()?; + let (token, passphrase) = resolve_token()?; let client = GithubClient::new(&token); print!(" Validating token... "); @@ -63,7 +63,7 @@ pub fn run(dry_run: bool) -> Result<()> { return Ok(()); } - execute(&client, &config, dry_run, &token) + execute(&client, &config, dry_run, &token, &passphrase) } fn collect_team_access(client: &GithubClient, _org: &str) -> Result> { @@ -203,7 +203,13 @@ fn collect_config(client: &GithubClient, username: &str) -> Result }) } -fn execute(client: &GithubClient, c: &WizardConfig, dry_run: bool, token: &str) -> Result<()> { +fn execute( + client: &GithubClient, + c: &WizardConfig, + dry_run: bool, + token: &str, + passphrase: &str, +) -> Result<()> { println!(); // Always download fresh template for `new` so cache is never stale @@ -350,6 +356,11 @@ fn execute(client: &GithubClient, c: &WizardConfig, dry_run: bool, token: &str) 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>(()) }); } @@ -376,24 +387,35 @@ fn execute(client: &GithubClient, c: &WizardConfig, dry_run: bool, token: &str) ); } - // 9. Secrets from template — read from env first, prompt if missing, warn if skipped + // 9. Template secrets: env → vault → prompt for spec in &secret_specs { - let value = if let Ok(env_val) = std::env::var(&spec.name) { - println!(" ◆ Secret {}: using value from environment", spec.name); - Some(env_val) + let value = if let Some(val) = crate::vault::resolve_secret(&spec.name, passphrase)? { + println!(" ◆ Secret {}: found", spec.name); + Some(val) } else { let ans = Password::new(&format!("Secret {} (enter to skip):", spec.name)) .with_help_message(&spec.description) .without_confirmation() .prompt_skippable()?; - if ans.as_deref().map(str::is_empty).unwrap_or(true) { - println!( - " ⚠ Secret {} not configured — set ${} and re-run `ghscaff apply`", - spec.name, spec.name - ); - None - } else { - ans + match ans.as_deref() { + Some(v) if !v.is_empty() => { + let save_it = Confirm::new(" Save this secret in the vault for future use?") + .with_default(true) + .prompt() + .unwrap_or(false); + if save_it { + crate::vault::save_secret(&spec.name, v, passphrase)?; + println!(" \x1b[32m✓\x1b[0m Secret saved to vault"); + } + Some(v.to_string()) + } + _ => { + println!( + " ⚠ Secret {} not configured — re-run `ghscaff apply` to set it later", + spec.name + ); + None + } } }; if let Some(val) = value { From 6ce65dfecbdfe680fa71c6e4a4dd76ae60c7001b Mon Sep 17 00:00:00 2001 From: Jheison Martinez Bolivar Date: Fri, 10 Apr 2026 11:37:09 -0500 Subject: [PATCH 03/13] feat: replace good-first-issue with target branch labels and enforce standard set --- src/github/labels.rs | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) 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