diff --git a/rye/src/cli/publish.rs b/rye/src/cli/publish.rs index 45bc4756e2..8a17185ac0 100644 --- a/rye/src/cli/publish.rs +++ b/rye/src/cli/publish.rs @@ -42,6 +42,9 @@ pub struct Args { /// Path to alternate CA bundle. #[arg(long)] cert: Option, + /// Skip saving to credentials file. + #[arg(long)] + skip_save_credentials: bool, /// Skip prompts. #[arg(short, long)] yes: bool, @@ -68,6 +71,10 @@ pub fn execute(cmd: Args) -> Result<(), Error> { None => vec![project.workspace_path().join("dist").join("*")], }; + // TODO(cnpryer): Updating resolution + // - Allow twine dispatch without passwords + // - Control over save creds, better flow + // a. Get token from arguments and offer encryption, then store in credentials file. // b. Get token from ~/.rye/credentials keyed by provided repository and provide decryption option. // c. Otherwise prompt for token and provide encryption option, storing the result in credentials. @@ -97,67 +104,81 @@ pub fn execute(cmd: Args) -> Result<(), Error> { bail!("invalid pypi url {} (use -h for help)", repository_url); } - let username = match cmd.username { - Some(username) => username, - None => credentials + let username = cmd.username.or(credentials + .get(repository) + .and_then(|table| table.get("username")) + .map(Item::to_string) + .map(escape_string)); + + let mut password = cmd.token.map(Secret::new); + + if password.is_none() { + if let Some(it) = credentials .get(repository) - .and_then(|table| table.get("username")) - .map(|username| username.to_string()) + .and_then(|table| table.get("token")) + .map(Item::to_string) .map(escape_string) - .unwrap_or("__token__".to_string()), - }; + .map(Secret::new) + { + let phrase = if cmd.yes { + Secret::new(String::new()) + } else { + prompt_decrypt_passphrase()? + }; + password = Some(decrypt(&it, &phrase)?) + } + } - let token = if let Some(token) = cmd.token { - let secret = Secret::new(token); - let maybe_encrypted = maybe_encrypt(&secret, cmd.yes)?; - let maybe_encoded = maybe_encode(&secret, &maybe_encrypted); - credentials[repository]["token"] = Item::Value(maybe_encoded.expose_secret().into()); - write_credentials(&credentials)?; + if password.is_none() && !cmd.yes { + // TODO(cnpryer): Reword? + echo!("No access token found, generate one at: https://pypi.org/manage/account/token/"); + let token = Secret::new(prompt_for_token()?); + password = Some(token) + }; - secret - } else if let Some(token) = credentials - .get(repository) - .and_then(|table| table.get("token")) - .map(|token| token.to_string()) - .map(escape_string) - { - let secret = Secret::new(token); + if !cmd.skip_save_credentials { + if let Some(pwd) = password.as_ref() { + // Encryption step can be skipped using an empty passphrase with --yes form cli + let phrase = if cmd.yes { + Secret::new(String::new()) + } else { + prompt_encrypt_passphrase()? + }; + + let maybe_encrypted = encrypt(pwd, phrase)?; + let maybe_encoded = maybe_encode(pwd, &maybe_encrypted); + credentials[repository]["token"] = Item::Value(maybe_encoded.expose_secret().into()); + } - maybe_decrypt(&secret, cmd.yes)? - } else { - echo!("No access token found, generate one at: https://pypi.org/manage/account/token/"); - let token = if !cmd.yes { - prompt_for_token()? - } else { - "".to_string() - }; - if token.is_empty() { - bail!("an access token is required") + if let Some(usr) = username.as_ref() { + credentials[repository]["username"] = Item::Value(usr.clone().into()); } - let secret = Secret::new(token); - let maybe_encrypted = maybe_encrypt(&secret, cmd.yes)?; - let maybe_encoded = maybe_encode(&secret, &maybe_encrypted); - credentials[repository]["token"] = Item::Value(maybe_encoded.expose_secret().into()); - secret - }; + credentials[repository]["repository-url"] = Item::Value(repository_url.to_string().into()); - credentials[repository]["repository-url"] = Item::Value(repository_url.to_string().into()); - credentials[repository]["username"] = Item::Value(username.clone().into()); - write_credentials(&credentials)?; + write_credentials(&credentials)?; + } let mut publish_cmd = Command::new(get_venv_python_bin(&venv)); + // TODO(cnpryer): Add --non-interactive if --yes? + publish_cmd .arg("-mtwine") .arg("--no-color") .arg("upload") .args(files) - .arg("--username") - .arg(username) - .arg("--password") - .arg(token.expose_secret()) .arg("--repository-url") .arg(repository_url.to_string()); + + // If a username is provided use it, if a password is provided without a username then use __token__ with Twine. + if let Some(usr) = username.or(password.as_ref().map(|_| "__token__".to_string())) { + publish_cmd.arg("--username").arg(usr); + } + + if let Some(pwd) = password.as_ref() { + publish_cmd.arg("--password").arg(pwd.expose_secret()); + } + if cmd.sign { publish_cmd.arg("--sign"); } @@ -188,18 +209,7 @@ fn prompt_for_token() -> Result { Ok(token) } -fn maybe_encrypt(secret: &Secret, yes: bool) -> Result>, Error> { - let phrase = if !yes { - dialoguer::Password::with_theme(tui_theme()) - .with_prompt("Encrypt with passphrase (optional)") - .allow_empty_password(true) - .report(false) - .interact() - .map(Secret::new)? - } else { - Secret::new("".to_string()) - }; - +fn encrypt(secret: &Secret, phrase: Secret) -> Result>, Error> { let token = if phrase.expose_secret().is_empty() { secret.expose_secret().as_bytes().to_vec() } else { @@ -216,18 +226,16 @@ fn maybe_encrypt(secret: &Secret, yes: bool) -> Result>, Ok(Secret::new(token.to_vec())) } -fn maybe_decrypt(secret: &Secret, yes: bool) -> Result, Error> { - let phrase = if !yes { - dialoguer::Password::with_theme(tui_theme()) - .with_prompt("Decrypt with passphrase (optional)") - .allow_empty_password(true) - .report(false) - .interact() - .map(Secret::new)? - } else { - Secret::new("".to_string()) - }; +fn prompt_encrypt_passphrase() -> Result, Error> { + Ok(dialoguer::Password::with_theme(tui_theme()) + .with_prompt("Encrypt with passphrase (optional)") + .allow_empty_password(true) + .report(false) + .interact() + .map(Secret::new)?) +} +fn decrypt(secret: &Secret, phrase: &Secret) -> Result, Error> { if phrase.expose_secret().is_empty() { return Ok(secret.clone()); } @@ -237,7 +245,7 @@ fn maybe_decrypt(secret: &Secret, yes: bool) -> Result, E if let Decryptor::Passphrase(decryptor) = Decryptor::new(bytes.as_slice())? { // Do the decryption let mut decrypted = vec![]; - let mut reader = decryptor.decrypt(&phrase, None)?; + let mut reader = decryptor.decrypt(phrase, None)?; reader.read_to_end(&mut decrypted)?; let token = String::from_utf8(decrypted).context("failed to parse utf-8")?; @@ -249,6 +257,15 @@ fn maybe_decrypt(secret: &Secret, yes: bool) -> Result, E bail!("failed to decrypt") } +fn prompt_decrypt_passphrase() -> Result, Error> { + Ok(dialoguer::Password::with_theme(tui_theme()) + .with_prompt("Decrypt with passphrase (optional)") + .allow_empty_password(true) + .report(false) + .interact() + .map(Secret::new)?) +} + fn get_trimmed_user_input() -> Result { std::io::stderr().flush()?; let mut input = String::new();