Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 155 additions & 28 deletions crates/cli/src/subcommands/publish.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand All @@ -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::<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()
Expand All @@ -34,7 +95,7 @@ pub fn build_publish_schema(command: &clap::Command) -> Result<CommandSchema, an
.key(Key::new("parent"))
.key(Key::new("organization"))
.exclude("clear-database")
.exclude("force")
.exclude("yes")
.exclude("no_config")
.exclude("env")
.build(command)
Expand Down Expand Up @@ -167,7 +228,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()
Expand Down Expand Up @@ -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")
Expand All @@ -228,15 +303,15 @@ 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!(
"This will DESTROY the current {} module, and ALL corresponding data.",
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.");
Expand All @@ -246,15 +321,15 @@ 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"
);
println!();
println!("WARNING: Once you publish you cannot revert back to version 1.0.");
println!();

if force {
if skip_prompt {
return Ok(());
}

Expand Down Expand Up @@ -329,7 +404,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(
Expand All @@ -338,7 +413,7 @@ pub async fn exec_with_options(
using_config,
config_dir,
clear_database,
force,
yes,
)
.await
}
Expand All @@ -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>(
Expand All @@ -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 {
Expand Down Expand Up @@ -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)?;

Expand Down Expand Up @@ -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.");
}
}
Expand All @@ -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,
Expand All @@ -497,7 +566,7 @@ async fn execute_publish_configs<'a>(
&auth_header,
clear_database,
force_break_clients,
force,
yes,
)
.await?;
}
Expand Down Expand Up @@ -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<reqwest::RequestBuilder, anyhow::Error> {
// The caller enforces this
assert!(clear_database != ClearMode::Always);
Expand All @@ -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 {
Expand All @@ -688,15 +757,15 @@ 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);
// We only arrive here if you have not specified ClearMode::Always AND there was no
// 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?",
)?
{
Expand Down Expand Up @@ -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::<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.all && !yes.remote && !yes.migrate && !yes.break_clients && !yes.skip_login);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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 <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 -- 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 <ENV>` — Environment name for config file layering (e.g., dev, staging)

Expand Down
Loading