Skip to content
Open
Show file tree
Hide file tree
Changes from 7 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
23 changes: 14 additions & 9 deletions crates/cli/src/subcommands/delete.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::io;
use crate::common_args;
use crate::config::Config;
use crate::subcommands::db_arg_resolution::{load_config_db_targets, resolve_database_arg};
use crate::util::{add_auth_header_opt, database_identity, get_auth_header, y_or_n, AuthHeader};
use crate::util::{add_auth_header_opt, database_identity, get_auth_header, AuthHeader, YesOrNo};
use clap::{Arg, ArgMatches};
use http::StatusCode;
use itertools::Itertools as _;
Expand Down Expand Up @@ -51,10 +51,13 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
format!("{} ({identity})", resolved.database)
};

if !y_or_n(
force,
&format!("Are you sure you want to delete database {delete_target}? This action cannot be undone."),
)? {
if !YesOrNo::new(format!(
"Are you sure you want to delete database {delete_target}? This action cannot be undone."
))
.force(force)
.default(false)
.prompt()?
{
println!("Aborting");
return Ok(());
}
Expand All @@ -69,10 +72,12 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
StatusCode::PRECONDITION_REQUIRED => {
let confirm = response.json::<DatabaseDeleteConfirmationResponse>().await?;
println!("WARNING: Deleting the database {identity} will also delete its children!");
if !force {
print_database_tree_info(&confirm.database_tree).await?;
}
if force || y_or_n(false, "Do you want to proceed deleting above databases?")? {
print_database_tree_info(&confirm.database_tree).await?;
if YesOrNo::new("Do you want to proceed deleting above databases?")
.force(force)
.default(false)
.prompt()?
{

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: I changed printing behavior here. Now in non-interactive mode we still print the database tree info and the prompt (followed by "skipping confirmation due to --yes").

That just seems clearer and better to me? Totally fine to revert it though.

send_request(&client, &request_path, &auth_header, Some(confirm.confirmation_token))
.await?
.error_for_status()?;
Expand Down
8 changes: 6 additions & 2 deletions crates/cli/src/subcommands/generate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use crate::spacetime_config::{
};
use crate::tasks::csharp::dotnet_format;
use crate::tasks::rust::rustfmt;
use crate::util::y_or_n;
use crate::util::YesOrNo;
use crate::Config;
use crate::{build, common_args};
use clap::builder::PossibleValue;
Expand Down Expand Up @@ -591,7 +591,11 @@ pub async fn run_prepared_generate_configs(
println!(" {}", path.to_str().unwrap());
}

if y_or_n(force, "Are you sure you want to delete these files?")? {
if YesOrNo::new("Are you sure you want to delete these files?")
.force(force)
.default(false)
.prompt()?
{
for path in files_to_delete {
fs::remove_file(path)?;
}
Expand Down
28 changes: 18 additions & 10 deletions crates/cli/src/subcommands/publish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ use crate::spacetime_config::{
FlatTarget, Key, LoadedConfig, SpacetimeConfig,
};
use crate::util::{add_auth_header_opt, get_auth_header, strip_verbatim_prefix, AuthHeader, ResponseExt};
use crate::util::{decode_identity, y_or_n};
use crate::util::{decode_identity, YesOrNo};
use crate::{build, common_args};

/// Individual prompts that `--yes` can suppress. `All` is a shorthand for every category below.
Expand Down Expand Up @@ -406,10 +406,14 @@ fn confirm_and_clear(
"This will DESTROY the current {} module, and ALL corresponding data.",
name_or_identity
);
if !y_or_n(
skip_prompt,
format!("Are you sure you want to proceed? [deleting {}]", name_or_identity).as_str(),
)? {
if !YesOrNo::new(format!(
"Are you sure you want to proceed? [deleting {}]",
name_or_identity
))
.force(skip_prompt)
.default(false)
.prompt()?
{
anyhow::bail!("Aborted.");
}

Expand Down Expand Up @@ -680,7 +684,11 @@ 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(yes.publish_to_remote, "Are you sure you want to proceed?")? {
if !YesOrNo::new("Are you sure you want to proceed?")
.force(yes.publish_to_remote)
.default(false)
.prompt()?
{
anyhow::bail!("Publish aborted by user.");
}
}
Expand Down Expand Up @@ -927,10 +935,10 @@ async fn apply_pre_publish_if_needed(
// 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 || yes.break_clients,
"The above changes will BREAK existing clients. Do you want to proceed?",
)?
&& !YesOrNo::new("The above changes will BREAK existing clients. Do you want to proceed?")
.force(force_break_clients || yes.break_clients)
.default(false)
.prompt()?
{
println!("Aborting");
// Early exit: return an error or a special signal. Here we bail out by returning Err.
Expand Down
7 changes: 5 additions & 2 deletions crates/cli/src/subcommands/publish/environment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ pub(super) async fn publish_only(
input: Option<&Value>,
options: &super::EnvironmentOptions,
) -> anyhow::Result<()> {
use crate::util::{add_auth_header_opt, get_auth_header, y_or_n};
use crate::util::{add_auth_header_opt, get_auth_header, YesOrNo};
use spacetimedb_client_api_messages::publish::EnvironmentMetadata;

let host = config.get_host_url(server)?;
Expand All @@ -100,7 +100,10 @@ pub(super) async fn publish_only(
if hostname != "localhost" && hostname != "127.0.0.1" {
println!("You are about to publish environment values to a non-local server: {hostname}");
anyhow::ensure!(
y_or_n(yes.publish_to_remote, "Are you sure you want to proceed?")?,
YesOrNo::new("Are you sure you want to proceed?")
.force(yes.publish_to_remote)
.default(false)
.prompt()?,
"Publish aborted by user"
);
}
Expand Down
17 changes: 10 additions & 7 deletions crates/cli/src/subcommands/server.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use crate::{
common_args,
util::{host_or_url_to_host_and_protocol, spacetime_server_fingerprint, y_or_n, UNSTABLE_WARNING, VALID_PROTOCOLS},
util::{
host_or_url_to_host_and_protocol, spacetime_server_fingerprint, YesOrNo, UNSTABLE_WARNING, VALID_PROTOCOLS,
},
Config,
};
use anyhow::Context;
Expand Down Expand Up @@ -271,7 +273,7 @@ pub async fn exec_fingerprint(mut config: Config, args: &ArgMatches) -> Result<(
let force = args.get_flag("force");

if update_server_fingerprint(&mut config, Some(server)).await? {
if !y_or_n(force, "Continue?")? {
if !YesOrNo::new("Continue?").force(force).default(false).prompt()? {
anyhow::bail!("Aborted");
}

Expand Down Expand Up @@ -348,7 +350,7 @@ pub async fn exec_edit(mut config: Config, args: &ArgMatches) -> Result<(), anyh
}
}

if !y_or_n(force, "Continue?")? {
if !YesOrNo::new("Continue?").force(force).default(false).prompt()? {
anyhow::bail!("Aborted");
}

Expand All @@ -364,10 +366,11 @@ async fn exec_clear(_config: Config, paths: &SpacetimePaths, args: &ArgMatches)
if data_dir.0.exists() {
println!("Database path: {}", data_dir.display());

if !y_or_n(
force,
"Are you sure you want to delete all data from the local database?",
)? {
if !YesOrNo::new("Are you sure you want to delete all data from the local database?")
.force(force)
.default(false)
.prompt()?
{
println!("Aborting");
return Ok(());
}
Expand Down
97 changes: 80 additions & 17 deletions crates/cli/src/util.rs
Original file line number Diff line number Diff line change
Expand Up @@ -329,20 +329,53 @@ pub fn host_or_url_to_host_and_protocol(host_or_url: &str) -> (&str, Option<&str
}
}

/// Prompt the user for `y` or `n` from stdin.
///
/// Return `false` unless the input is `y`.
pub fn y_or_n(force: bool, prompt: &str) -> anyhow::Result<bool> {
if force {
println!("Skipping confirmation due to --yes");
return Ok(true);
/// A yes/no prompt with a required message, force setting, and default.
Comment thread
bfops marked this conversation as resolved.
Outdated
pub struct YesOrNo {
message: String,
force: Option<bool>,
default: Option<bool>,
}

impl YesOrNo {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
force: None,
default: None,
}
}

pub fn force(mut self, force: bool) -> Self {
self.force = Some(force);
self
}

/// Set the response for empty input, including EOF.
pub fn default(mut self, default: bool) -> Self {
self.default = Some(default);
self
}

pub fn prompt(self) -> anyhow::Result<bool> {
assert!(!self.message.trim().is_empty(), "YesOrNo requires a message");
let force = self.force.expect("YesOrNo requires an explicit force setting");
Comment thread
bfops marked this conversation as resolved.
let default = self.default.expect("YesOrNo requires an explicit default");
if force {
println!("Skipping confirmation due to --yes");
return Ok(true);
}
let choices = if default { "Y/n" } else { "y/N" };
print!("{} [{choices}]", self.message);
std::io::stdout().flush()?;
let mut input = String::new();
std::io::stdin().read_line(&mut input)?;
let input = input.trim().to_lowercase();
if input.is_empty() {
// Note that this includes EOF

@bfops bfops Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I kinda think that we should fail on EOF, since non-interactive contexts should be using force and not hitting this, but this is the previous behavior.

Thoughts?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, I probably wouldn't return Ok(default) here I would return an Err

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

return Ok(default);
}
Ok(input == "y" || input == "yes")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: this is the previous behavior, but since we now have an optional default, I could see an argument for requiring a clear yes or no, and ambiguous input causing a re-prompt.

I'm happy to update that here/now if we think it's worth it.

}
let mut input = String::new();
print!("{prompt} [y/N]");
std::io::stdout().flush()?;
std::io::stdin().read_line(&mut input)?;
let input = input.trim().to_lowercase();
Ok(input == "y" || input == "yes")
}

pub fn decode_identity(token: &String) -> anyhow::Result<String> {
Expand Down Expand Up @@ -371,14 +404,16 @@ pub async fn get_login_token_or_log_in(
return Ok(token.clone());
}

// Note: We pass `force: false` to `y_or_n` because if we're running non-interactively we want to default to "no", not yes!
// Note: We leave `force` false because if we're running non-interactively we want to default to "no", not yes!
let full_login = interactive
&& y_or_n(
false,
&& YesOrNo::new(
// It would be "ideal" if we could print the `spacetimedb.com` by deriving it from the `default_auth_host` constant,
// but this will change _so_ infrequently that it's not even worth the time to write that code and test it.
"You are not logged in. Would you like to log in with spacetimedb.com?",
)?;
)
.force(false)
.default(false)
.prompt()?;

if full_login {
let host = Url::parse(DEFAULT_AUTH_HOST)?;
Expand All @@ -398,3 +433,31 @@ pub fn resolve_sibling_binary(bin_name: &str) -> anyhow::Result<PathBuf> {
.with_extension(std::env::consts::EXE_EXTENSION);
Ok(bin_path)
}

#[cfg(test)]
mod yes_or_no_tests {
use super::YesOrNo;

#[test]
#[should_panic(expected = "YesOrNo requires a message")]
fn empty_message_is_rejected_even_when_forced() {
YesOrNo::new(" ").default(false).force(true).prompt().unwrap();
}

#[test]
#[should_panic(expected = "YesOrNo requires an explicit default")]
fn missing_default_is_rejected_even_when_forced() {
YesOrNo::new("Continue?").force(true).prompt().unwrap();
}

#[test]
#[should_panic(expected = "YesOrNo requires an explicit force setting")]
fn missing_force_is_rejected() {
YesOrNo::new("Continue?").default(false).prompt().unwrap();
}

#[test]
fn force_overrides_explicit_default() {
assert!(YesOrNo::new("Continue?").default(false).force(true).prompt().unwrap());
}
}
Loading