From 1d455ec0ea1b887c805a983f1a928dbdbed62a8d Mon Sep 17 00:00:00 2001 From: John Detter <4099508+jdetter@users.noreply.github.com> Date: Mon, 27 Apr 2026 07:53:44 -0500 Subject: [PATCH 1/3] publish: make --yes accept selective values Implements clockworklabs/spacetimedb#3784. `--yes` is now an enum-valued flag accepting all|remote|migrate|break-clients|skip-login, combinable with '|' (e.g. --yes='migrate|skip-login'). With no value it defaults to all, matching the previous behavior. Each prompt site reads from the corresponding YesFlags field. The legacy --break-clients flag is preserved as an alias for --yes=break-clients. Uses require_equals so `spacetime publish --yes my-db` parses my-db as the database name rather than as a value to --yes. --- crates/cli/src/subcommands/publish.rs | 175 ++++++++++++++++-- .../00100-cli-reference.md | 17 +- 2 files changed, 170 insertions(+), 22 deletions(-) diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index 05247b93044..4fa01c734b6 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() @@ -35,7 +96,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() @@ -220,7 +281,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 values from {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") @@ -246,7 +321,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!( @@ -254,7 +329,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."); @@ -264,7 +339,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" ); @@ -272,7 +347,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(()); } @@ -357,7 +432,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( @@ -366,7 +441,7 @@ pub async fn exec_with_options( using_config, config_dir, clear_database, - force, + yes, ) .await } @@ -385,13 +460,15 @@ pub async fn exec_from_entry( let command_config = CommandConfig::new(&schema, entry, &matches)?; command_config.validate()?; + let yes = if force { YesFlags::all() } else { YesFlags::default() }; + execute_publish_configs( &mut config, vec![command_config], true, config_dir, clear_database, - force, + yes, ) .await } @@ -402,7 +479,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 { @@ -454,7 +531,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)?; @@ -502,7 +579,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."); } } @@ -523,7 +600,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, @@ -536,7 +613,7 @@ async fn execute_publish_configs<'a>( &auth_header, clear_database, force_break_clients, - force, + yes, ) .await?; } @@ -694,7 +771,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); @@ -714,7 +791,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 { @@ -727,7 +804,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); @@ -735,7 +812,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?", )? { @@ -1184,4 +1261,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 b65656de110..715b7264a8f 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 @@ -101,7 +101,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. @@ -112,7 +112,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 values from {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) * `--native-aot` — Use NativeAOT-LLVM compilation for C# modules (experimental, Windows only) From 8b15feb9960b44aa22f6f3c2884fe1b473230b69 Mon Sep 17 00:00:00 2001 From: John Detter <4099508+jdetter@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:33:12 -0500 Subject: [PATCH 2/3] Rustfmt --- crates/cli/src/subcommands/publish.rs | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index 4fa01c734b6..f545742cfdb 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -462,15 +462,7 @@ pub async fn exec_from_entry( let yes = if force { YesFlags::all() } else { YesFlags::default() }; - execute_publish_configs( - &mut config, - vec![command_config], - true, - config_dir, - clear_database, - yes, - ) - .await + execute_publish_configs(&mut config, vec![command_config], true, config_dir, clear_database, yes).await } async fn execute_publish_configs<'a>( From 384d4e0be7c00ebc126d1379a947ab8e073bd120 Mon Sep 17 00:00:00 2001 From: John Detter <4099508+jdetter@users.noreply.github.com> Date: Mon, 27 Apr 2026 21:31:50 -0500 Subject: [PATCH 3/3] publish: avoid curly braces in --yes help text MDX interprets `{...}` as a JSX expression, which broke `cargo ci docs` when rendering the regenerated CLI reference. Reword the value list to avoid braces. --- crates/cli/src/subcommands/publish.rs | 4 ++-- .../00100-cli-reference/00100-cli-reference.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index f545742cfdb..f306cab41d8 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -292,8 +292,8 @@ i.e. only lowercase ASCII letters and numbers, separated by dashes."), .help( "Skip confirmation prompts. With no value, defaults to 'all' \ (equivalent to --yes=all). To skip specific prompts, pass one or \ - more values from {all, remote, migrate, break-clients, skip-login} \ - joined by '|', e.g. --yes='migrate|break-clients'. The value must be \ + 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)." ) ) 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 715b7264a8f..a33f083b542 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 @@ -112,7 +112,7 @@ 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 ` — Skip confirmation prompts. With no value, defaults to 'all' (equivalent to --yes=all). To skip specific prompts, pass one or more values from {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). +* `-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`: