spacetime publish --yes now takes optional arguments (#4885)

# Description of Changes

<!-- Please describe your change, mention any related tickets, and so on
here. -->

This was prompted from another request from the discord. Conversation
context:
https://discord.com/channels/1037340874172014652/1138987509834059867/1496964407278698656

We've had the ticket to make `--yes` take an enum value:
https://github.com/clockworklabs/spacetimedb/issues/3784 . Since we can
do this in a non-API/ABI breaking way we're just implementing this
ticket.

*Disclaimer: I used claude to write ~90% of this PR.*

# API and ABI breaking changes

<!-- If this is an API or ABI breaking change, please apply the
corresponding GitHub label. -->

None. `--yes` now just takes an optional argument string that we parse,
existing CLI commands that use `--yes` will be unaffected.

# Expected complexity level and risk

1 - This is just modifying the `--yes` argument and I've included tests
in the PR.

<!--
How complicated do you think these changes are? Grade on a scale from 1
to 5,
where 1 is a trivial change, and 5 is a deep-reaching and complex
change.

This complexity rating applies not only to the complexity apparent in
the diff,
but also to its interactions with existing and future code.

If you answered more than a 2, explain what is complex about the PR,
and what other components it interacts with in potentially concerning
ways. -->

# Testing

<!-- Describe any testing you've done, and any testing you'd like your
reviewers to do,
so that you're confident that all the changes work as expected! -->

- [x] `spacetime publish --yes` still works as expected
- [x] `spacetime publish --yes=all` does the same thing as `spacetime
publish --yes`
- [x] `spacetime publish --yes=remote` only skips asking if publishing
to a remote server is ok.
- [x] `spacetime publish --yes=remote` only skips asking if publishing
to a remote server is ok.
- [x] `spacetime publish --yes=migrate|remote` skips both the migrate
prompt and the remote server prompt.
- [x] new tests are passing
This commit is contained in:
John Detter
2026-04-29 20:13:12 -05:00
committed by GitHub
parent e3060d2602
commit 50f5ed034e
2 changed files with 208 additions and 31 deletions
+191 -29
View File
@@ -1,7 +1,7 @@
use anyhow::{ensure, Context};
use clap::Arg;
use clap::ArgAction::{Set, SetTrue};
use clap::ArgMatches;
use clap::ArgAction::{self, Set, SetTrue};
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,68 @@ 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,
/// Skip the destructive "this will DESTROY ... ALL corresponding data" confirmation.
DeleteData,
}
/// Decoded `--yes` selections, used at every prompt site.
#[derive(Clone, Copy, Debug, Default)]
pub struct YesFlags {
pub publish_to_remote: bool,
pub migrate_major_version: bool,
pub break_clients: bool,
pub skip_login: bool,
pub delete_data: bool,
}
impl YesFlags {
pub fn all() -> Self {
Self {
publish_to_remote: true,
migrate_major_version: true,
break_clients: true,
skip_login: true,
delete_data: true,
}
}
fn merge(&mut self, value: YesValue) {
match value {
YesValue::All => *self = YesFlags::all(),
YesValue::Remote => self.publish_to_remote = true,
YesValue::Migrate => self.migrate_major_version = true,
YesValue::BreakClients => self.break_clients = true,
YesValue::SkipLogin => self.skip_login = true,
YesValue::DeleteData => self.delete_data = 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::<YesValue>("yes") {
for v in values {
flags.merge(*v);
}
}
flags
}
/// Build the CommandSchema for publish command
pub fn build_publish_schema(command: &clap::Command) -> Result<CommandSchema, anyhow::Error> {
CommandSchemaBuilder::new()
@@ -35,7 +97,7 @@ pub fn build_publish_schema(command: &clap::Command) -> Result<CommandSchema, an
.key(Key::new("organization"))
.key(Key::new("native_aot").module_specific())
.exclude("clear-database")
.exclude("force")
.exclude("yes")
.exclude("no_config")
.exclude("env")
.build(command)
@@ -179,7 +241,7 @@ pub fn cli() -> 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 +282,23 @@ 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')
.action(ArgAction::Append)
.num_args(0..=1)
.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, delete-data. \
Multiple values can be comma-separated (--yes=migrate,break-clients) or \
given via repeated flags (--yes=migrate --yes=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 +324,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<reqwest::RequestBuilder, anyhow::Error> {
println!(
@@ -254,7 +332,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 +342,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 +350,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 +435,7 @@ pub async fn exec_with_options(
.get_one::<ClearMode>("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 +444,7 @@ pub async fn exec_with_options(
using_config,
config_dir,
clear_database,
force,
yes,
)
.await
}
@@ -385,15 +463,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>(
@@ -402,7 +474,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 +526,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 +574,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.publish_to_remote, "Are you sure you want to proceed?")? {
anyhow::bail!("Publish aborted by user.");
}
}
@@ -523,7 +595,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.delete_data, builder)?;
} else {
builder = apply_pre_publish_if_needed(
builder,
@@ -536,7 +608,7 @@ async fn execute_publish_configs<'a>(
&auth_header,
clear_database,
force_break_clients,
force,
yes,
)
.await?;
}
@@ -694,7 +766,7 @@ async fn apply_pre_publish_if_needed(
auth_header: &AuthHeader,
clear_database: ClearMode,
force_break_clients: bool,
force: bool,
yes: YesFlags,
) -> Result<reqwest::RequestBuilder, anyhow::Error> {
// The caller enforces this
assert!(clear_database != ClearMode::Always);
@@ -714,7 +786,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_major_version)?;
}
match pre {
@@ -727,7 +799,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.delete_data, builder)?;
}
PrePublishResult::AutoMigrate(auto) => {
println!("{}", auto.migrate_plan);
@@ -735,7 +807,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 +1256,94 @@ 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.publish_to_remote
&& yes.migrate_major_version
&& yes.break_clients
&& yes.skip_login
&& yes.delete_data
);
}
#[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.publish_to_remote
&& yes.migrate_major_version
&& yes.break_clients
&& yes.skip_login
&& yes.delete_data
);
}
#[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.publish_to_remote);
assert!(!yes.migrate_major_version && !yes.break_clients && !yes.skip_login && !yes.delete_data);
}
#[test]
fn test_yes_flag_comma_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_major_version && yes.skip_login);
assert!(!yes.publish_to_remote && !yes.break_clients && !yes.delete_data);
}
#[test]
fn test_yes_flag_repeated_invocations_accumulate() {
let matches = cli().get_matches_from(vec!["publish", "--yes=migrate", "--yes=skip-login"]);
let yes = yes_flags_from_args(&matches);
assert!(yes.migrate_major_version && yes.skip_login);
assert!(!yes.publish_to_remote && !yes.break_clients && !yes.delete_data);
}
#[test]
fn test_yes_flag_delete_data_only() {
let matches = cli().get_matches_from(vec!["publish", "--yes=delete-data"]);
let yes = yes_flags_from_args(&matches);
assert!(yes.delete_data);
assert!(!yes.publish_to_remote && !yes.migrate_major_version && !yes.break_clients && !yes.skip_login);
}
#[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.delete_data && yes.publish_to_remote);
assert_eq!(
matches.get_one::<String>("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.publish_to_remote
&& !yes.migrate_major_version
&& !yes.break_clients
&& !yes.skip_login
&& !yes.delete_data
);
}
}
@@ -97,7 +97,7 @@ Run `spacetime help publish` for more detailed information.
* `-p`, `--module-path <MODULE_PATH>` — The system path (absolute or relative) to the module project. Defaults to spacetimedb/ subdirectory, then current directory.
* `-b`, `--bin-path <WASM_FILE>` — The system path (absolute or relative) to the compiled wasm binary we should publish, instead of building the project.
* `-j`, `--js-path <JS_FILE>` — 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 <PARENT>` — A valid domain or identity of an existing database that should be the parent of this database.
@@ -108,7 +108,22 @@ 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 <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 <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, delete-data. Multiple values can be comma-separated (--yes=migrate,break-clients) or given via repeated flags (--yes=migrate --yes=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
- `delete-data`:
Skip the destructive "this will DESTROY ... ALL corresponding data" confirmation
* `--no-config` — Ignore spacetime.json configuration
* `--env <ENV>` — Environment name for config file layering (e.g., dev, staging)
* `--native-aot` — Use NativeAOT-LLVM compilation for C# modules (experimental, Windows only)