diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index 30faf5e6052..74022716780 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -1,7 +1,7 @@ use anyhow::{ensure, Context}; use clap::Arg; use clap::ArgAction::{Set, SetTrue}; -use clap::ArgMatches; +use clap::{value_parser, ArgMatches, ValueEnum}; use reqwest::{StatusCode, Url}; use spacetimedb_client_api_messages::name::{is_identity, parse_database_name, PublishResult}; use spacetimedb_client_api_messages::name::{DatabaseNameError, PrePublishResult, PrettyPrintStyle, PublishOp}; @@ -19,6 +19,67 @@ use crate::util::{add_auth_header_opt, get_auth_header, strip_verbatim_prefix, A use crate::util::{decode_identity, y_or_n}; use crate::{build, common_args}; +/// Individual prompts that `--yes` can suppress. `All` is a shorthand for every category below. +#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)] +#[clap(rename_all = "kebab-case")] +pub enum YesValue { + /// Equivalent to passing every other option. This is the default when `--yes` is given without a value. + All, + /// Skip the "publish to a non-local server?" confirmation. + Remote, + /// Skip migration confirmations (e.g. major version upgrade). + Migrate, + /// Skip the "this will BREAK existing clients" confirmation. + BreakClients, + /// Don't prompt the user to log in; act non-interactively for auth. + SkipLogin, +} + +/// Decoded `--yes` selections, used at every prompt site. +#[derive(Clone, Copy, Debug, Default)] +pub struct YesFlags { + pub remote: bool, + pub migrate: bool, + pub break_clients: bool, + pub skip_login: bool, + /// True iff `--yes` was passed with no value or `--yes=all`. Used for destructive prompts + /// not represented by an explicit option (e.g. `confirm_and_clear`) to preserve back-compat. + pub all: bool, +} + +impl YesFlags { + pub fn all() -> Self { + Self { + remote: true, + migrate: true, + break_clients: true, + skip_login: true, + all: true, + } + } + + fn merge(&mut self, value: YesValue) { + match value { + YesValue::All => *self = YesFlags::all(), + YesValue::Remote => self.remote = true, + YesValue::Migrate => self.migrate = true, + YesValue::BreakClients => self.break_clients = true, + YesValue::SkipLogin => self.skip_login = true, + } + } +} + +/// Read the parsed `--yes` selections off the matched args. +fn yes_flags_from_args(args: &ArgMatches) -> YesFlags { + let mut flags = YesFlags::default(); + if let Some(values) = args.get_many::("yes") { + for v in values { + flags.merge(*v); + } + } + flags +} + /// Build the CommandSchema for publish command pub fn build_publish_schema(command: &clap::Command) -> Result { CommandSchemaBuilder::new() @@ -34,7 +95,7 @@ pub fn build_publish_schema(command: &clap::Command) -> Result clap::Command { Arg::new("break_clients") .long("break-clients") .action(SetTrue) - .help("Allow breaking changes when publishing to an existing database identity. This will force publish even if it will break existing clients, but will NOT force publish if it would cause deletion of any data in the database. See --yes and --delete-data for details.") + .help("Allow breaking changes when publishing to an existing database identity. Equivalent to --yes=break-clients: skips the \"this will BREAK existing clients\" prompt, but will NOT force publish if it would cause deletion of any data in the database. See --yes and --delete-data for details.") ) .arg( common_args::anonymous() @@ -208,7 +269,21 @@ i.e. only lowercase ASCII letters and numbers, separated by dashes."), .help("The nickname, domain name or URL of the server to host the database."), ) .arg( - common_args::yes() + Arg::new("yes") + .long("yes") + .short('y') + .num_args(0..=4) + .require_equals(true) + .value_delimiter('|') + .default_missing_value("all") + .value_parser(value_parser!(YesValue)) + .help( + "Skip confirmation prompts. With no value, defaults to 'all' \ + (equivalent to --yes=all). To skip specific prompts, pass one or \ + more of: all, remote, migrate, break-clients, skip-login -- joined \ + by '|', e.g. --yes='migrate|break-clients'. The value must be \ + attached with '=' (so `--yes my-db` treats `my-db` as the database name)." + ) ) .arg( Arg::new("no_config") @@ -228,7 +303,7 @@ i.e. only lowercase ASCII letters and numbers, separated by dashes."), fn confirm_and_clear( name_or_identity: &str, - force: bool, + skip_prompt: bool, mut builder: reqwest::RequestBuilder, ) -> Result { println!( @@ -236,7 +311,7 @@ fn confirm_and_clear( name_or_identity ); if !y_or_n( - force, + skip_prompt, format!("Are you sure you want to proceed? [deleting {}]", name_or_identity).as_str(), )? { anyhow::bail!("Aborted."); @@ -246,7 +321,7 @@ fn confirm_and_clear( Ok(builder) } -fn confirm_major_version_upgrade(force: bool) -> Result<(), anyhow::Error> { +fn confirm_major_version_upgrade(skip_prompt: bool) -> Result<(), anyhow::Error> { println!( "It looks like you're trying to do a major version upgrade from 1.0 to 2.0. We recommend first looking at the upgrade notes before committing to this upgrade: https://spacetimedb.com/docs/upgrade" ); @@ -254,7 +329,7 @@ fn confirm_major_version_upgrade(force: bool) -> Result<(), anyhow::Error> { println!("WARNING: Once you publish you cannot revert back to version 1.0."); println!(); - if force { + if skip_prompt { return Ok(()); } @@ -329,7 +404,7 @@ pub async fn exec_with_options( .get_one::("clear-database") .copied() .unwrap_or(ClearMode::Never); - let force = args.get_flag("force"); + let yes = yes_flags_from_args(args); let config_dir = loaded_config_ref.map(|lc| lc.config_dir.as_path()); execute_publish_configs( @@ -338,7 +413,7 @@ pub async fn exec_with_options( using_config, config_dir, clear_database, - force, + yes, ) .await } @@ -357,15 +432,9 @@ pub async fn exec_from_entry( let command_config = CommandConfig::new(&schema, entry, &matches)?; command_config.validate()?; - execute_publish_configs( - &mut config, - vec![command_config], - true, - config_dir, - clear_database, - force, - ) - .await + let yes = if force { YesFlags::all() } else { YesFlags::default() }; + + execute_publish_configs(&mut config, vec![command_config], true, config_dir, clear_database, yes).await } async fn execute_publish_configs<'a>( @@ -374,7 +443,7 @@ async fn execute_publish_configs<'a>( using_config: bool, config_dir: Option<&std::path::Path>, clear_database: ClearMode, - force: bool, + yes: YesFlags, ) -> Result<(), anyhow::Error> { // Execute publish for each config for command_config in publish_configs { @@ -425,7 +494,7 @@ async fn execute_publish_configs<'a>( // we want to use the default identity // TODO(jdetter): We should maybe have some sort of user prompt here for them to be able to // easily create a new identity with an email - let auth_header = get_auth_header(config, anon_identity, server, !force).await?; + let auth_header = get_auth_header(config, anon_identity, server, !yes.skip_login).await?; let (name_or_identity, parent) = validate_name_and_parent(name_or_identity, parent)?; @@ -463,7 +532,7 @@ async fn execute_publish_configs<'a>( }; if server_address != "localhost" && server_address != "127.0.0.1" { println!("You are about to publish to a non-local server: {server_address}"); - if !y_or_n(force, "Are you sure you want to proceed?")? { + if !y_or_n(yes.remote, "Are you sure you want to proceed?")? { anyhow::bail!("Publish aborted by user."); } } @@ -484,7 +553,7 @@ async fn execute_publish_configs<'a>( // note that this only happens in the case where we've passed a `name_or_identity`, but that's required if we pass `--clear-database`. if clear_database == ClearMode::Always { - builder = confirm_and_clear(name_or_identity, force, builder)?; + builder = confirm_and_clear(name_or_identity, yes.all, builder)?; } else { builder = apply_pre_publish_if_needed( builder, @@ -497,7 +566,7 @@ async fn execute_publish_configs<'a>( &auth_header, clear_database, force_break_clients, - force, + yes, ) .await?; } @@ -655,7 +724,7 @@ async fn apply_pre_publish_if_needed( auth_header: &AuthHeader, clear_database: ClearMode, force_break_clients: bool, - force: bool, + yes: YesFlags, ) -> Result { // The caller enforces this assert!(clear_database != ClearMode::Always); @@ -675,7 +744,7 @@ async fn apply_pre_publish_if_needed( PrePublishResult::ManualMigrate(manual) => manual.major_version_upgrade, }; if major_version_upgrade { - confirm_major_version_upgrade(force)?; + confirm_major_version_upgrade(yes.migrate)?; } match pre { @@ -688,7 +757,7 @@ async fn apply_pre_publish_if_needed( println!("{}", manual.reason); println!("Proceeding with database clear due to --delete-data=on-conflict."); - builder = confirm_and_clear(name_or_identity, force, builder)?; + builder = confirm_and_clear(name_or_identity, yes.all, builder)?; } PrePublishResult::AutoMigrate(auto) => { println!("{}", auto.migrate_plan); @@ -696,7 +765,7 @@ async fn apply_pre_publish_if_needed( // conflict that required manual migration. if auto.break_clients && !y_or_n( - force_break_clients || force, + force_break_clients || yes.break_clients, "The above changes will BREAK existing clients. Do you want to proceed?", )? { @@ -1145,4 +1214,62 @@ mod tests { let result = get_filtered_publish_configs(&spacetime_config, &cmd, &schema, &matches); assert!(result.is_err()); } + + #[test] + fn test_yes_flag_no_value_is_all() { + let matches = cli().get_matches_from(vec!["publish", "--yes"]); + let yes = yes_flags_from_args(&matches); + assert!(yes.all); + assert!(yes.remote && yes.migrate && yes.break_clients && yes.skip_login); + } + + #[test] + fn test_yes_flag_explicit_all() { + let matches = cli().get_matches_from(vec!["publish", "--yes=all"]); + let yes = yes_flags_from_args(&matches); + assert!(yes.all); + assert!(yes.remote && yes.migrate && yes.break_clients && yes.skip_login); + } + + #[test] + fn test_yes_flag_single_value() { + let matches = cli().get_matches_from(vec!["publish", "--yes=remote"]); + let yes = yes_flags_from_args(&matches); + assert!(yes.remote); + assert!(!yes.migrate && !yes.break_clients && !yes.skip_login && !yes.all); + } + + #[test] + fn test_yes_flag_pipe_separated_multiple_values() { + let matches = cli().get_matches_from(vec!["publish", "--yes=migrate|skip-login"]); + let yes = yes_flags_from_args(&matches); + assert!(yes.migrate && yes.skip_login); + assert!(!yes.remote && !yes.break_clients && !yes.all); + } + + #[test] + fn test_yes_flag_unattached_token_is_database_name() { + // `--yes` with a following bare token is parsed as `--yes` (no value, defaults to all) + // plus the token treated as the positional database name. require_equals enforces this. + let matches = cli().get_matches_from(vec!["publish", "--yes", "my-db"]); + let yes = yes_flags_from_args(&matches); + assert!(yes.all); + assert_eq!( + matches.get_one::("name|identity").map(String::as_str), + Some("my-db"), + ); + } + + #[test] + fn test_yes_flag_invalid_value_errors() { + let result = cli().try_get_matches_from(vec!["publish", "--yes=bogus"]); + assert!(result.is_err()); + } + + #[test] + fn test_yes_flag_omitted_means_no_skips() { + let matches = cli().get_matches_from(vec!["publish", "my-db"]); + let yes = yes_flags_from_args(&matches); + assert!(!yes.all && !yes.remote && !yes.migrate && !yes.break_clients && !yes.skip_login); + } } diff --git a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md index 8c43c9573b2..18a5201f66d 100644 --- a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md +++ b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md @@ -97,7 +97,7 @@ Run `spacetime help publish` for more detailed information. * `-p`, `--module-path ` — The system path (absolute or relative) to the module project. Defaults to spacetimedb/ subdirectory, then current directory. * `-b`, `--bin-path ` — The system path (absolute or relative) to the compiled wasm binary we should publish, instead of building the project. * `-j`, `--js-path ` — UNSTABLE: The system path (absolute or relative) to the javascript file we should publish, instead of building the project. -* `--break-clients` — Allow breaking changes when publishing to an existing database identity. This will force publish even if it will break existing clients, but will NOT force publish if it would cause deletion of any data in the database. See --yes and --delete-data for details. +* `--break-clients` — Allow breaking changes when publishing to an existing database identity. Equivalent to --yes=break-clients: skips the "this will BREAK existing clients" prompt, but will NOT force publish if it would cause deletion of any data in the database. See --yes and --delete-data for details. * `--anonymous` — Perform this action with an anonymous identity * `--parent ` — A valid domain or identity of an existing database that should be the parent of this database. @@ -108,7 +108,20 @@ Run `spacetime help publish` for more detailed information. If an organization is given, the organization member's permissions apply to the new database. An organization can only be set when a database is created, not when it is updated. * `-s`, `--server ` — The nickname, domain name or URL of the server to host the database. -* `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). +* `-y`, `--yes ` — Skip confirmation prompts. With no value, defaults to 'all' (equivalent to --yes=all). To skip specific prompts, pass one or more of: all, remote, migrate, break-clients, skip-login -- joined by '|', e.g. --yes='migrate|break-clients'. The value must be attached with '=' (so `--yes my-db` treats `my-db` as the database name). + + Possible values: + - `all`: + Equivalent to passing every other option. This is the default when `--yes` is given without a value + - `remote`: + Skip the "publish to a non-local server?" confirmation + - `migrate`: + Skip migration confirmations (e.g. major version upgrade) + - `break-clients`: + Skip the "this will BREAK existing clients" confirmation + - `skip-login`: + Don't prompt the user to log in; act non-interactively for auth + * `--no-config` — Ignore spacetime.json configuration * `--env ` — Environment name for config file layering (e.g., dev, staging)