diff --git a/crates/cli/src/subcommands/call.rs b/crates/cli/src/subcommands/call.rs index d77d9b6f27e..777fa044890 100644 --- a/crates/cli/src/subcommands/call.rs +++ b/crates/cli/src/subcommands/call.rs @@ -2,7 +2,9 @@ use crate::api::ClientApi; use crate::common_args; use crate::config::Config; use crate::edit_distance::{edit_distance, find_best_match_for_name}; +use crate::subcommands::db_arg_resolution::{load_config_db_targets, resolve_optional_database_parts}; use crate::util::UNSTABLE_WARNING; +use crate::util::{database_identity, get_auth_header}; use anyhow::{bail, Context, Error}; use clap::{Arg, ArgMatches}; use convert_case::{Case, Casing}; @@ -13,27 +15,25 @@ use spacetimedb_lib::{Identity, ProductTypeElement}; use spacetimedb_schema::def::{ModuleDef, ProcedureDef, ReducerDef}; use std::fmt::Write; -use super::sql::parse_req; - pub fn cli() -> clap::Command { clap::Command::new("call") .about(format!( "Invokes a function (reducer or procedure) in a database. {UNSTABLE_WARNING}" )) .arg( - Arg::new("database") - .required(true) - .help("The database name or identity to use to invoke the call"), - ) - .arg( - Arg::new("function_name") - .required(true) - .help("The name of the function to call"), + Arg::new("call_parts") + .help("Call arguments: [DATABASE] [ARGUMENTS...]") + .num_args(1..), ) - .arg(Arg::new("arguments").help("arguments formatted as JSON").num_args(1..)) .arg(common_args::server().help("The nickname, host name or URL of the server hosting the database")) .arg(common_args::anonymous()) .arg(common_args::yes()) + .arg( + Arg::new("no_config") + .long("no-config") + .action(clap::ArgAction::SetTrue) + .help("Ignore spacetime.json configuration"), + ) .after_help("Run `spacetime help call` for more detailed information.\n") } @@ -65,10 +65,37 @@ impl<'a> CallDef<'a> { pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), Error> { eprintln!("{UNSTABLE_WARNING}\n"); - let reducer_procedure_name = args.get_one::("function_name").unwrap(); - let arguments = args.get_many::("arguments"); - - let conn = parse_req(config, args).await?; + let server = args.get_one::("server").map(|s| s.as_ref()); + let force = args.get_flag("force"); + let anon_identity = args.get_flag("anon_identity"); + let no_config = args.get_flag("no_config"); + + let raw_parts: Vec = args + .get_many::("call_parts") + .map(|vals| vals.cloned().collect()) + .unwrap_or_default(); + + let config_targets = load_config_db_targets(no_config)?; + let resolved = resolve_optional_database_parts( + &raw_parts, + config_targets.as_deref(), + "function_name", + "spacetime call [database] ... (or --no-config for legacy behavior)", + )?; + let reducer_procedure_name = resolved + .remaining_args + .first() + .ok_or_else(|| anyhow::anyhow!("internal error: function_name should be present after argument resolution"))?; + let call_arguments = resolved.remaining_args.iter().skip(1); + let resolved_server = server.or(resolved.server.as_deref()); + + let mut config = config; + let conn = crate::api::Connection { + host: config.get_host_url(resolved_server)?, + auth_header: get_auth_header(&mut config, anon_identity, resolved_server, !force).await?, + database_identity: database_identity(&config, &resolved.database, resolved_server).await?, + database: resolved.database.clone(), + }; let api = ClientApi::new(conn); let database_identity = api.con.database_identity; @@ -92,8 +119,7 @@ pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), Error> { }; // String quote any arguments that should be quoted - let arguments = arguments - .unwrap_or_default() + let arguments = call_arguments .zip(&call_def.params().elements) .map(|(argument, element)| match &element.algebraic_type { AlgebraicType::String if !argument.starts_with('\"') || !argument.ends_with('\"') => { @@ -364,3 +390,8 @@ mod write_type { Ok(()) } } + +#[cfg(test)] +mod tests { + // Resolution tests live in db_arg_resolution.rs +} diff --git a/crates/cli/src/subcommands/db_arg_resolution.rs b/crates/cli/src/subcommands/db_arg_resolution.rs new file mode 100644 index 00000000000..2a58b37780e --- /dev/null +++ b/crates/cli/src/subcommands/db_arg_resolution.rs @@ -0,0 +1,381 @@ +// Database argument resolution for CLI commands. +// +// When a spacetime.json config file is present, CLI commands resolve database arguments +// from the config. If the user provides a database name that doesn't match any configured +// target, the behavior depends on whether the database argument is unambiguous: +// +// | Command | Resolution function | DB arg unambiguous? | Auto-fallthrough? | +// |-------------|-------------------------------------|---------------------|-------------------| +// | logs | resolve_database_arg | Yes (dedicated arg) | Yes | +// | delete | resolve_database_arg | Yes (dedicated arg) | Yes | +// | sql | resolve_optional_database_parts | Only with 2+ args | Yes (at call site)| +// | call | resolve_optional_database_parts | No (variable args) | No | +// | subscribe | resolve_optional_database_parts | No (variable args) | No | +// | describe | resolve_database_with_optional_parts | No (optional args)| No | +// +// "Auto-fallthrough" means: if the provided database name doesn't match any config target, +// treat it as an ad-hoc database outside the project (equivalent to --no-config for that arg). +// +// Commands using `resolve_database_arg` always have an unambiguous `` arg, so we +// can safely fall through. For `sql`, the call site knows that exactly 1 query arg is expected, +// so 2+ positional args means the first must be a database. For `call`/`subscribe`/`describe`, +// the first positional could be a non-database argument, so we must error to avoid misinterpreting it. + +use crate::spacetime_config::find_and_load_with_env; +use itertools::Itertools; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ConfigDbTarget { + pub database: String, + pub server: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ResolvedDbArgs { + pub database: String, + pub server: Option, + pub remaining_args: Vec, +} + +/// Build an error for when the first positional arg doesn't match any configured database target. +fn unknown_database_error(db: &str, config_targets: &[ConfigDbTarget]) -> anyhow::Error { + let known: Vec<&str> = config_targets.iter().map(|t| t.database.as_str()).collect(); + if known.len() > 1 { + anyhow::anyhow!( + "Multiple databases found in config: {}. Please specify which database to use, \ + or pass --no-config to use '{}' directly.", + known.join(", "), + db + ) + } else { + anyhow::anyhow!( + "Database '{}' is not in the config file. \ + If you want to run against a database outside of the current project, pass --no-config.", + db + ) + } +} + +pub(crate) fn load_config_db_targets(no_config: bool) -> anyhow::Result>> { + if no_config { + return Ok(None); + } + + Ok(find_and_load_with_env(None)? + .map(|loaded| { + loaded + .config + .collect_all_targets_with_inheritance() + .iter() + .filter_map(|t| { + let database = t.fields.get("database").and_then(|v| v.as_str())?; + let server = t.fields.get("server").and_then(|v| v.as_str()).map(|s| s.to_string()); + Some(ConfigDbTarget { + database: database.to_string(), + server, + }) + }) + .unique_by(|t| t.database.clone()) + .collect::>() + }) + .filter(|targets| !targets.is_empty())) +} + +pub(crate) fn resolve_optional_database_parts( + raw_parts: &[String], + config_targets: Option<&[ConfigDbTarget]>, + required_arg_name: &str, + usage: &str, +) -> anyhow::Result { + let require_arg = |name: &str| { + anyhow::anyhow!( + "the following required arguments were not provided:\n <{}>\n\nUsage: {}", + name, + usage + ) + }; + + let Some(config_targets) = config_targets else { + if raw_parts.len() < 2 { + return if raw_parts.is_empty() { + Err(require_arg("database")) + } else { + Err(require_arg(required_arg_name)) + }; + } + return Ok(ResolvedDbArgs { + database: raw_parts[0].clone(), + server: None, + remaining_args: raw_parts[1..].to_vec(), + }); + }; + + if config_targets.len() == 1 { + let target = &config_targets[0]; + if raw_parts.is_empty() { + return Err(require_arg(required_arg_name)); + } + if raw_parts[0] == target.database { + if raw_parts.len() < 2 { + return Err(require_arg(required_arg_name)); + } + return Ok(ResolvedDbArgs { + database: target.database.clone(), + server: target.server.clone(), + remaining_args: raw_parts[1..].to_vec(), + }); + } + return Ok(ResolvedDbArgs { + database: target.database.clone(), + server: target.server.clone(), + remaining_args: raw_parts.to_vec(), + }); + } + + let db = &raw_parts[0]; + let Some(target) = config_targets.iter().find(|t| t.database == *db) else { + return Err(unknown_database_error(db, config_targets)); + }; + if raw_parts.len() < 2 { + return Err(require_arg(required_arg_name)); + } + + Ok(ResolvedDbArgs { + database: db.clone(), + server: target.server.clone(), + remaining_args: raw_parts[1..].to_vec(), + }) +} + +pub(crate) fn resolve_database_arg( + raw_database: Option<&str>, + config_targets: Option<&[ConfigDbTarget]>, + usage: &str, +) -> anyhow::Result { + let require_database = || { + anyhow::anyhow!( + "the following required arguments were not provided:\n \n\nUsage: {}", + usage + ) + }; + + let Some(config_targets) = config_targets else { + let database = raw_database.ok_or_else(require_database)?; + return Ok(ResolvedDbArgs { + database: database.to_string(), + server: None, + remaining_args: vec![], + }); + }; + + if config_targets.len() == 1 { + let target = &config_targets[0]; + if let Some(db) = raw_database { + if db != target.database { + // The database arg is unambiguous, so treat it as an ad-hoc database + // outside the project config (auto-fallthrough). + return Ok(ResolvedDbArgs { + database: db.to_string(), + server: None, + remaining_args: vec![], + }); + } + } + return Ok(ResolvedDbArgs { + database: target.database.clone(), + server: target.server.clone(), + remaining_args: vec![], + }); + } + + let db = raw_database.ok_or_else(require_database)?; + let Some(target) = config_targets.iter().find(|t| t.database == db) else { + // The database arg is unambiguous, so treat it as an ad-hoc database + // outside the project config (auto-fallthrough). + return Ok(ResolvedDbArgs { + database: db.to_string(), + server: None, + remaining_args: vec![], + }); + }; + + Ok(ResolvedDbArgs { + database: target.database.clone(), + server: target.server.clone(), + remaining_args: vec![], + }) +} + +pub(crate) fn resolve_database_with_optional_parts( + raw_parts: &[String], + config_targets: Option<&[ConfigDbTarget]>, + usage: &str, +) -> anyhow::Result { + let require_database = || { + anyhow::anyhow!( + "the following required arguments were not provided:\n \n\nUsage: {}", + usage + ) + }; + + let Some(config_targets) = config_targets else { + let Some(database) = raw_parts.first() else { + return Err(require_database()); + }; + return Ok(ResolvedDbArgs { + database: database.clone(), + server: None, + remaining_args: raw_parts[1..].to_vec(), + }); + }; + + if config_targets.len() == 1 { + let target = &config_targets[0]; + if raw_parts.first().is_some_and(|db| db == &target.database) { + return Ok(ResolvedDbArgs { + database: target.database.clone(), + server: target.server.clone(), + remaining_args: raw_parts[1..].to_vec(), + }); + } + return Ok(ResolvedDbArgs { + database: target.database.clone(), + server: target.server.clone(), + remaining_args: raw_parts.to_vec(), + }); + } + + let Some(db) = raw_parts.first() else { + return Err(require_database()); + }; + let Some(target) = config_targets.iter().find(|t| t.database == *db) else { + return Err(unknown_database_error(db, config_targets)); + }; + + Ok(ResolvedDbArgs { + database: db.clone(), + server: target.server.clone(), + remaining_args: raw_parts[1..].to_vec(), + }) +} + +#[cfg(test)] +mod tests { + use super::{ + resolve_database_arg, resolve_database_with_optional_parts, resolve_optional_database_parts, ConfigDbTarget, + }; + + #[test] + fn single_db_infers_database() { + let parts = vec!["reducer".to_string(), "arg1".to_string()]; + let targets = vec![ConfigDbTarget { + database: "foo".to_string(), + server: Some("maincloud".to_string()), + }]; + let parsed = resolve_optional_database_parts( + &parts, + Some(&targets), + "function_name", + "spacetime call [database] ...", + ) + .unwrap(); + assert_eq!(parsed.database, "foo"); + assert_eq!(parsed.server.as_deref(), Some("maincloud")); + assert_eq!(parsed.remaining_args, parts); + } + + #[test] + fn single_db_accepts_explicit_db_prefix() { + let parts = vec!["foo".to_string(), "SELECT 1".to_string()]; + let targets = vec![ConfigDbTarget { + database: "foo".to_string(), + server: Some("local".to_string()), + }]; + let parsed = resolve_optional_database_parts( + &parts, + Some(&targets), + "query", + "spacetime subscribe [database] [query...]", + ) + .unwrap(); + assert_eq!(parsed.database, "foo"); + assert_eq!(parsed.server.as_deref(), Some("local")); + assert_eq!(parsed.remaining_args, vec!["SELECT 1".to_string()]); + } + + #[test] + fn multi_db_rejects_unknown_database() { + let parts = vec!["baz".to_string(), "SELECT 1".to_string()]; + let targets = vec![ + ConfigDbTarget { + database: "foo".to_string(), + server: Some("maincloud".to_string()), + }, + ConfigDbTarget { + database: "bar".to_string(), + server: Some("local".to_string()), + }, + ]; + let err = resolve_optional_database_parts( + &parts, + Some(&targets), + "query", + "spacetime subscribe [database] [query...]", + ) + .unwrap_err(); + assert!(err.to_string().contains("Multiple databases found in config: foo, bar")); + assert!(err.to_string().contains("--no-config")); + } + + #[test] + fn resolve_database_arg_single_target_uses_config_database() { + let targets = vec![ConfigDbTarget { + database: "foo".to_string(), + server: Some("maincloud".to_string()), + }]; + let resolved = resolve_database_arg(None, Some(&targets), "spacetime logs [database]").unwrap(); + assert_eq!(resolved.database, "foo"); + assert_eq!(resolved.server.as_deref(), Some("maincloud")); + } + + #[test] + fn resolve_database_with_optional_parts_single_target_allows_no_parts() { + let targets = vec![ConfigDbTarget { + database: "foo".to_string(), + server: Some("maincloud".to_string()), + }]; + let resolved = + resolve_database_with_optional_parts(&[], Some(&targets), "spacetime describe [database] [entity]") + .unwrap(); + assert_eq!(resolved.database, "foo"); + assert!(resolved.remaining_args.is_empty()); + } + + #[test] + fn resolve_database_arg_single_target_falls_through_for_unknown_db() { + let targets = vec![ConfigDbTarget { + database: "foo".to_string(), + server: Some("maincloud".to_string()), + }]; + let resolved = resolve_database_arg(Some("other-db"), Some(&targets), "spacetime logs [database]").unwrap(); + assert_eq!(resolved.database, "other-db"); + assert_eq!(resolved.server, None); + } + + #[test] + fn resolve_database_arg_multi_target_falls_through_for_unknown_db() { + let targets = vec![ + ConfigDbTarget { + database: "foo".to_string(), + server: Some("maincloud".to_string()), + }, + ConfigDbTarget { + database: "bar".to_string(), + server: Some("local".to_string()), + }, + ]; + let resolved = resolve_database_arg(Some("other-db"), Some(&targets), "spacetime logs [database]").unwrap(); + assert_eq!(resolved.database, "other-db"); + assert_eq!(resolved.server, None); + } +} diff --git a/crates/cli/src/subcommands/delete.rs b/crates/cli/src/subcommands/delete.rs index 12b6d2432f1..2f91e3ab30f 100644 --- a/crates/cli/src/subcommands/delete.rs +++ b/crates/cli/src/subcommands/delete.rs @@ -2,6 +2,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 clap::{Arg, ArgMatches}; use http::StatusCode; @@ -16,20 +17,34 @@ pub fn cli() -> clap::Command { .about("Deletes a SpacetimeDB database") .arg( Arg::new("database") - .required(true) + .required(false) .help("The name or identity of the database to delete"), ) .arg(common_args::server().help("The nickname, host name or URL of the server hosting the database")) .arg(common_args::yes()) + .arg( + Arg::new("no_config") + .long("no-config") + .action(clap::ArgAction::SetTrue) + .help("Ignore spacetime.json configuration"), + ) .after_help("Run `spacetime help delete` for more detailed information.\n") } pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { - let server = args.get_one::("server").map(|s| s.as_ref()); - let database = args.get_one::("database").unwrap(); + let server_from_cli = args.get_one::("server").map(|s| s.as_ref()); + let no_config = args.get_flag("no_config"); + let database_arg = args.get_one::("database").map(|s| s.as_str()); + let config_targets = load_config_db_targets(no_config)?; + let resolved = resolve_database_arg( + database_arg, + config_targets.as_deref(), + "spacetime delete [database] [--no-config]", + )?; + let server = server_from_cli.or(resolved.server.as_deref()); let force = args.get_flag("force"); - let identity = database_identity(&config, database, server).await?; + let identity = database_identity(&config, &resolved.database, server).await?; let host_url = config.get_host_url(server)?; let request_path = format!("{host_url}/v1/database/{identity}"); let auth_header = get_auth_header(&mut config, false, server, !force).await?; diff --git a/crates/cli/src/subcommands/describe.rs b/crates/cli/src/subcommands/describe.rs index 4659c2f78f3..e774224855c 100644 --- a/crates/cli/src/subcommands/describe.rs +++ b/crates/cli/src/subcommands/describe.rs @@ -1,8 +1,9 @@ use crate::api::ClientApi; use crate::common_args; use crate::config::Config; -use crate::sql::parse_req; +use crate::subcommands::db_arg_resolution::{load_config_db_targets, resolve_database_with_optional_parts}; use crate::util::UNSTABLE_WARNING; +use crate::util::{database_identity, get_auth_header}; use anyhow::Context; use clap::{Arg, ArgAction, ArgMatches}; use spacetimedb_lib::sats; @@ -13,20 +14,9 @@ pub fn cli() -> clap::Command { "Describe the structure of a database or entities within it. {UNSTABLE_WARNING}" )) .arg( - Arg::new("database") - .required(true) - .help("The name or identity of the database to describe"), - ) - .arg( - Arg::new("entity_type") - .value_parser(clap::value_parser!(EntityType)) - .requires("entity_name") - .help("Whether to describe a reducer or table"), - ) - .arg( - Arg::new("entity_name") - .requires("entity_type") - .help("The name of the entity to describe"), + Arg::new("describe_parts") + .num_args(0..) + .help("Describe arguments: [DATABASE] [ENTITY_TYPE ENTITY_NAME]"), ) .arg( Arg::new("json") @@ -42,6 +32,12 @@ pub fn cli() -> clap::Command { .arg(common_args::anonymous()) .arg(common_args::server().help("The nickname, host name or URL of the server hosting the database")) .arg(common_args::yes()) + .arg( + Arg::new("no_config") + .long("no-config") + .action(ArgAction::SetTrue) + .help("Ignore spacetime.json configuration"), + ) .after_help("Run `spacetime help describe` for more detailed information.\n") } @@ -54,12 +50,51 @@ enum EntityType { pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { eprintln!("{UNSTABLE_WARNING}\n"); - let entity_name = args.get_one::("entity_name"); - let entity_type = args.get_one::("entity_type"); - let entity = entity_type.zip(entity_name); let json = args.get_flag("json"); + let no_config = args.get_flag("no_config"); + let raw_parts: Vec = args + .get_many::("describe_parts") + .map(|vals| vals.cloned().collect()) + .unwrap_or_default(); + let config_targets = load_config_db_targets(no_config)?; + let resolved = resolve_database_with_optional_parts( + &raw_parts, + config_targets.as_deref(), + "spacetime describe [database] [entity_type entity_name] --json [--no-config]", + )?; + let entity = match resolved.remaining_args.as_slice() { + [] => None, + [entity_type, entity_name] => { + let entity_type = match entity_type.as_str() { + "reducer" => EntityType::Reducer, + "table" => EntityType::Table, + _ => { + anyhow::bail!( + "Invalid entity_type '{}'. Expected one of: reducer, table.", + entity_type + ) + } + }; + Some((entity_type, entity_name.as_str())) + } + _ => { + anyhow::bail!( + "Invalid describe arguments.\nUsage: spacetime describe [database] [entity_type entity_name] --json [--no-config]" + ); + } + }; - let conn = parse_req(config, args).await?; + let mut config = config; + let server_from_cli = args.get_one::("server").map(|s| s.as_ref()); + let server = server_from_cli.or(resolved.server.as_deref()); + let force = args.get_flag("force"); + let anon_identity = args.get_flag("anon_identity"); + let conn = crate::api::Connection { + host: config.get_host_url(server)?, + auth_header: get_auth_header(&mut config, anon_identity, server, !force).await?, + database_identity: database_identity(&config, &resolved.database, server).await?, + database: resolved.database, + }; let api = ClientApi::new(conn); let module_def = api.module_def().await?; @@ -73,7 +108,7 @@ pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error let reducer = module_def .reducers .iter() - .find(|r| *r.name == **reducer_name) + .find(|r| *r.name == *reducer_name) .context("no such reducer")?; sats_to_json(reducer)? } @@ -81,7 +116,7 @@ pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error let table = module_def .tables .iter() - .find(|t| *t.name == **table_name) + .find(|t| *t.name == *table_name) .context("no such table")?; sats_to_json(table)? } diff --git a/crates/cli/src/subcommands/logs.rs b/crates/cli/src/subcommands/logs.rs index 4f8ed6a5b01..7f7fd43d632 100644 --- a/crates/cli/src/subcommands/logs.rs +++ b/crates/cli/src/subcommands/logs.rs @@ -3,6 +3,7 @@ use std::io::{self, Write}; 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}; use clap::{Arg, ArgAction, ArgMatches}; use futures::{AsyncBufReadExt, TryStreamExt}; @@ -15,7 +16,7 @@ pub fn cli() -> clap::Command { .about("Prints logs from a SpacetimeDB database") .arg( Arg::new("database") - .required(true) + .required(false) .help("The name or identity of the database to print logs from"), ) .arg( @@ -48,6 +49,12 @@ pub fn cli() -> clap::Command { .help("Output format for the logs") ) .arg(common_args::yes()) + .arg( + Arg::new("no_config") + .long("no-config") + .action(ArgAction::SetTrue) + .help("Ignore spacetime.json configuration"), + ) .after_help("Run `spacetime help logs` for more detailed information.\n") } @@ -119,16 +126,24 @@ impl clap::ValueEnum for Format { } pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { - let server = args.get_one::("server").map(|s| s.as_ref()); + let server_from_cli = args.get_one::("server").map(|s| s.as_ref()); + let no_config = args.get_flag("no_config"); + let database_arg = args.get_one::("database").map(|s| s.as_str()); + let config_targets = load_config_db_targets(no_config)?; + let resolved = resolve_database_arg( + database_arg, + config_targets.as_deref(), + "spacetime logs [database] [--no-config]", + )?; + let server = server_from_cli.or(resolved.server.as_deref()); let force = args.get_flag("force"); let mut num_lines = args.get_one::("num_lines").copied(); - let database = args.get_one::("database").unwrap(); let follow = args.get_flag("follow"); let format = *args.get_one::("format").unwrap(); let auth_header = get_auth_header(&mut config, false, server, !force).await?; - let database_identity = database_identity(&config, database, server).await?; + let database_identity = database_identity(&config, &resolved.database, server).await?; if follow && num_lines.is_none() { // We typically don't want logs from the very beginning if we're also following. diff --git a/crates/cli/src/subcommands/mod.rs b/crates/cli/src/subcommands/mod.rs index a50910ce8bd..4756a3fee81 100644 --- a/crates/cli/src/subcommands/mod.rs +++ b/crates/cli/src/subcommands/mod.rs @@ -1,5 +1,6 @@ pub mod build; pub mod call; +pub mod db_arg_resolution; pub mod delete; pub mod describe; pub mod dev; diff --git a/crates/cli/src/subcommands/sql.rs b/crates/cli/src/subcommands/sql.rs index 2455feab181..25ab32576ca 100644 --- a/crates/cli/src/subcommands/sql.rs +++ b/crates/cli/src/subcommands/sql.rs @@ -5,6 +5,9 @@ use std::time::{Duration, Instant}; use crate::api::{from_json_seed, ClientApi, Connection, SqlStmtResult, StmtStats}; use crate::common_args; use crate::config::Config; +use crate::subcommands::db_arg_resolution::{ + load_config_db_targets, resolve_database_arg, resolve_optional_database_parts, ResolvedDbArgs, +}; use crate::util::{database_identity, get_auth_header, ResponseExt, UNSTABLE_WARNING}; use anyhow::Context; use clap::{Arg, ArgAction, ArgMatches}; @@ -17,35 +20,40 @@ pub fn cli() -> clap::Command { clap::Command::new("sql") .about(format!("Runs a SQL query on the database. {UNSTABLE_WARNING}")) .arg( - Arg::new("database") - .required(true) - .help("The name or identity of the database you would like to query"), - ) - .arg( - Arg::new("query") - .action(ArgAction::Set) - .required(true) + Arg::new("sql_parts") + .num_args(0..) .conflicts_with("interactive") - .help("The SQL query to execute"), + .help("SQL arguments: [DATABASE] "), ) .arg( Arg::new("interactive") .long("interactive") .action(ArgAction::SetTrue) - .conflicts_with("query") + .conflicts_with("sql_parts") .help("Instead of using a query, run an interactive command prompt for `SQL` expressions"), ) .arg(common_args::confirmed()) .arg(common_args::anonymous()) .arg(common_args::server().help("The nickname, host name or URL of the server hosting the database")) .arg(common_args::yes()) + .arg( + Arg::new("no_config") + .long("no-config") + .action(ArgAction::SetTrue) + .help("Ignore spacetime.json configuration"), + ) } -pub(crate) async fn parse_req(mut config: Config, args: &ArgMatches) -> Result { - let server = args.get_one::("server").map(|s| s.as_ref()); +pub(crate) async fn parse_req( + mut config: Config, + args: &ArgMatches, + database_name_or_identity: &str, + server_from_config: Option<&str>, +) -> Result { + let server_from_cli = args.get_one::("server").map(|s| s.as_ref()); let force = args.get_flag("force"); - let database_name_or_identity = args.get_one::("database").unwrap(); let anon_identity = args.get_flag("anon_identity"); + let server = server_from_cli.or(server_from_config); Ok(Connection { host: config.get_host_url(server)?, @@ -175,21 +183,69 @@ fn stmt_result_to_table(client: PsqlClient, stmt_result: &SqlStmtResult) -> anyh pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { eprintln!("{UNSTABLE_WARNING}\n"); let interactive = args.get_one::("interactive").unwrap_or(&false); + let no_config = args.get_flag("no_config"); + let raw_parts: Vec = args + .get_many::("sql_parts") + .map(|vals| vals.cloned().collect()) + .unwrap_or_default(); + let config_targets = load_config_db_targets(no_config)?; + if *interactive { - let con = parse_req(config, args).await?; + if raw_parts.len() > 1 { + anyhow::bail!( + "Too many positional arguments for interactive mode.\nUsage: spacetime sql [database] --interactive [--no-config]" + ); + } + let resolved = resolve_database_arg( + raw_parts.first().map(|s| s.as_str()), + config_targets.as_deref(), + "spacetime sql [database] --interactive [--no-config]", + )?; + let con = parse_req(config, args, &resolved.database, resolved.server.as_deref()).await?; crate::repl::exec(con).await?; } else { - let query = args.get_one::("query").unwrap(); + let resolved = resolve_optional_database_parts( + &raw_parts, + config_targets.as_deref(), + "query", + "spacetime sql [database] [--no-config]", + ) + .or_else(|e| { + // `sql` expects exactly 1 query arg, so if we have 2+ positional args the first + // must be a database name. If it didn't match any config target, treat it as an + // ad-hoc database outside the project (auto-fallthrough). + if raw_parts.len() >= 2 { + Ok(ResolvedDbArgs { + database: raw_parts[0].clone(), + server: None, + remaining_args: raw_parts[1..].to_vec(), + }) + } else if raw_parts.len() == 1 && raw_parts[0].contains(' ') { + // The single arg contains spaces, so it's almost certainly a SQL query, + // not a database name. Give a clearer error than "missing ". + let targets = config_targets.as_deref().unwrap_or_default(); + let known: Vec<&str> = targets.iter().map(|t| t.database.as_str()).collect(); + Err(anyhow::anyhow!( + "Multiple databases found in config: {}. Please specify which database to query:\n \ + spacetime sql \"{}\"", + known.join(", "), + raw_parts[0] + )) + } else { + Err(e) + } + })?; + let query = resolved.remaining_args.join(" "); let confirmed = args.get_flag("confirmed"); - let con = parse_req(config, args).await?; + let con = parse_req(config, args, &resolved.database, resolved.server.as_deref()).await?; let mut api = ClientApi::new(con).sql(); if confirmed { api = api.query(&[("confirmed", "true")]); } - run_sql(api, query, false).await?; + run_sql(api, &query, false).await?; } Ok(()) } diff --git a/crates/cli/src/subcommands/subscribe.rs b/crates/cli/src/subcommands/subscribe.rs index a11e1084b43..915235d04f3 100644 --- a/crates/cli/src/subcommands/subscribe.rs +++ b/crates/cli/src/subcommands/subscribe.rs @@ -18,23 +18,18 @@ use tokio_tungstenite::tungstenite::{Error as WsError, Message as WsMessage}; use crate::api::ClientApi; use crate::common_args; -use crate::sql::parse_req; +use crate::subcommands::db_arg_resolution::{load_config_db_targets, resolve_optional_database_parts}; use crate::util::UNSTABLE_WARNING; +use crate::util::{database_identity, get_auth_header}; use crate::Config; pub fn cli() -> clap::Command { clap::Command::new("subscribe") .about(format!("Subscribe to SQL queries on the database. {UNSTABLE_WARNING}")) .arg( - Arg::new("database") - .required(true) - .help("The name or identity of the database you would like to query"), - ) - .arg( - Arg::new("query") - .required(true) + Arg::new("subscribe_parts") .num_args(1..) - .help("The SQL query to execute"), + .help("Subscribe arguments: [DATABASE] [QUERY...]"), ) .arg( Arg::new("num-updates") @@ -68,6 +63,12 @@ pub fn cli() -> clap::Command { .arg(common_args::confirmed()) .arg(common_args::anonymous()) .arg(common_args::yes()) + .arg( + Arg::new("no_config") + .long("no-config") + .action(ArgAction::SetTrue) + .help("Ignore spacetime.json configuration"), + ) .arg(common_args::server().help("The nickname, host name or URL of the server hosting the database")) } @@ -126,13 +127,37 @@ struct SubscriptionTable { pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { eprintln!("{UNSTABLE_WARNING}\n"); - let queries = args.get_many::("query").unwrap(); + let server = args.get_one::("server").map(|s| s.as_ref()); + let force = args.get_flag("force"); + let anon_identity = args.get_flag("anon_identity"); + let no_config = args.get_flag("no_config"); + + let raw_parts: Vec = args + .get_many::("subscribe_parts") + .map(|vals| vals.cloned().collect()) + .unwrap_or_default(); + let config_targets = load_config_db_targets(no_config)?; + let resolved = resolve_optional_database_parts( + &raw_parts, + config_targets.as_deref(), + "query", + "spacetime subscribe [database] [query...] (or --no-config for legacy behavior)", + )?; + let queries: Vec = resolved.remaining_args; + let num = args.get_one::("num-updates").copied(); let timeout = args.get_one::("timeout").copied(); let print_initial_update = args.get_flag("print_initial_update"); let confirmed = args.get_flag("confirmed"); + let resolved_server = server.or(resolved.server.as_deref()); - let conn = parse_req(config, args).await?; + let mut config = config; + let conn = crate::api::Connection { + host: config.get_host_url(resolved_server)?, + auth_header: get_auth_header(&mut config, anon_identity, resolved_server, !force).await?, + database_identity: database_identity(&config, &resolved.database, resolved_server).await?, + database: resolved.database.clone(), + }; let api = ClientApi::new(conn); let module_def = api.module_def().await?; @@ -161,7 +186,7 @@ pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error let mut ws = tokio_tungstenite::connect_async(req).await.map(|(ws, _)| ws)?; let task = async { - subscribe(&mut ws, queries.cloned().map(Into::into).collect()).await?; + subscribe(&mut ws, queries.iter().cloned().map(Into::into).collect()).await?; await_initial_update(&mut ws, print_initial_update.then_some(&module_def)).await?; consume_transaction_updates(&mut ws, num, &module_def).await }; 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 0dbbf1202b7..2ea1f9eecbb 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 @@ -121,7 +121,7 @@ Run `spacetime help publish` for more detailed information. Deletes a SpacetimeDB database -**Usage:** `spacetime delete [OPTIONS] ` +**Usage:** `spacetime delete [OPTIONS] [database]` Run `spacetime help delete` for more detailed information. @@ -134,6 +134,7 @@ Run `spacetime help delete` for more detailed information. * `-s`, `--server ` — The nickname, host name or URL of the server hosting 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). +* `--no-config` — Ignore spacetime.json configuration @@ -141,7 +142,7 @@ Run `spacetime help delete` for more detailed information. Prints logs from a SpacetimeDB database -**Usage:** `spacetime logs [OPTIONS] ` +**Usage:** `spacetime logs [OPTIONS] [database]` Run `spacetime help logs` for more detailed information. @@ -162,6 +163,7 @@ Run `spacetime help logs` for more detailed information. Possible values: `text`, `json` * `-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). +* `--no-config` — Ignore spacetime.json configuration @@ -169,22 +171,21 @@ Run `spacetime help logs` for more detailed information. Invokes a function (reducer or procedure) in a database. WARNING: This command is UNSTABLE and subject to breaking changes. -**Usage:** `spacetime call [OPTIONS] [arguments]...` +**Usage:** `spacetime call [OPTIONS] [call_parts]...` Run `spacetime help call` for more detailed information. ###### **Arguments:** -* `` — The database name or identity to use to invoke the call -* `` — The name of the function to call -* `` — arguments formatted as JSON +* `` — Call arguments: [DATABASE] \ [ARGUMENTS...] ###### **Options:** * `-s`, `--server ` — The nickname, host name or URL of the server hosting the database * `--anonymous` — Perform this action with an anonymous identity * `-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). +* `--no-config` — Ignore spacetime.json configuration @@ -192,19 +193,14 @@ Run `spacetime help call` for more detailed information. Describe the structure of a database or entities within it. WARNING: This command is UNSTABLE and subject to breaking changes. -**Usage:** `spacetime describe [OPTIONS] --json [entity_type] [entity_name]` +**Usage:** `spacetime describe [OPTIONS] --json [describe_parts]...` Run `spacetime help describe` for more detailed information. ###### **Arguments:** -* `` — The name or identity of the database to describe -* `` — Whether to describe a reducer or table - - Possible values: `reducer`, `table` - -* `` — The name of the entity to describe +* `` — Describe arguments: [DATABASE] [ENTITY_TYPE ENTITY_NAME] ###### **Options:** @@ -212,6 +208,7 @@ Run `spacetime help describe` for more detailed information. * `--anonymous` — Perform this action with an anonymous identity * `-s`, `--server ` — The nickname, host name or URL of the server hosting 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). +* `--no-config` — Ignore spacetime.json configuration @@ -259,7 +256,7 @@ Start development mode with auto-regenerate client module bindings, auto-rebuild Invokes commands related to database budgets. WARNING: This command is UNSTABLE and subject to breaking changes. **Usage:** `spacetime energy - energy ` + energy \` ###### **Subcommands:** @@ -285,12 +282,11 @@ Show current energy balance for an identity Runs a SQL query on the database. WARNING: This command is UNSTABLE and subject to breaking changes. -**Usage:** `spacetime sql [OPTIONS] ` +**Usage:** `spacetime sql [OPTIONS] [sql_parts]...` ###### **Arguments:** -* `` — The name or identity of the database you would like to query -* `` — The SQL query to execute +* `` — SQL arguments: [DATABASE] \ ###### **Options:** @@ -299,6 +295,7 @@ Runs a SQL query on the database. WARNING: This command is UNSTABLE and subject * `--anonymous` — Perform this action with an anonymous identity * `-s`, `--server ` — The nickname, host name or URL of the server hosting 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). +* `--no-config` — Ignore spacetime.json configuration @@ -380,7 +377,7 @@ Lists the databases attached to an identity. WARNING: This command is UNSTABLE a Manage your login to the SpacetimeDB CLI **Usage:** `spacetime login [OPTIONS] - login ` + login \` ###### **Subcommands:** @@ -463,7 +460,7 @@ Builds a spacetime module. Manage the connection to the SpacetimeDB server. WARNING: This command is UNSTABLE and subject to breaking changes. **Usage:** `spacetime server - server ` + server \` ###### **Subcommands:** @@ -596,12 +593,11 @@ Deletes all data from all local databases Subscribe to SQL queries on the database. WARNING: This command is UNSTABLE and subject to breaking changes. -**Usage:** `spacetime subscribe [OPTIONS] ...` +**Usage:** `spacetime subscribe [OPTIONS] [subscribe_parts]...` ###### **Arguments:** -* `` — The name or identity of the database you would like to query -* `` — The SQL query to execute +* `` — Subscribe arguments: [DATABASE] \ [QUERY...] ###### **Options:** @@ -612,6 +608,7 @@ Subscribe to SQL queries on the database. WARNING: This command is UNSTABLE and * `--confirmed` — Instruct the server to deliver only updates of confirmed transactions * `--anonymous` — Perform this action with an anonymous identity * `-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). +* `--no-config` — Ignore spacetime.json configuration * `-s`, `--server ` — The nickname, host name or URL of the server hosting the database diff --git a/docs/scripts/generate-cli-docs.mjs b/docs/scripts/generate-cli-docs.mjs index 131c32b3002..cf8f30426a0 100755 --- a/docs/scripts/generate-cli-docs.mjs +++ b/docs/scripts/generate-cli-docs.mjs @@ -38,6 +38,29 @@ function runCargoAndAppend({ cwd, outFilePath }) { }); } +/** + * Escape `` patterns that appear outside backtick code spans + * so MDX doesn't treat them as JSX tags. + * + * Splits each line on backtick boundaries, and only escapes in the non-code + * segments. E.g. `` (inside backticks) is left alone, but bare + * becomes \. + */ +function escapeAngleBracketsOutsideBackticks(content) { + return content + .split('\n') + .map(line => { + // Split on backtick-delimited code spans, alternating: text, code, text, code, ... + const parts = line.split('`'); + for (let i = 0; i < parts.length; i += 2) { + // Even indices are outside backticks + parts[i] = parts[i].replace(/<([A-Z][A-Z0-9_-]*(?:\s+[A-Z][A-Z0-9_-]*)*)>/g, '\\<$1\\>'); + } + return parts.join('`'); + }) + .join('\n'); +} + async function main() { const repoRoot = getRepoRoot(); @@ -55,6 +78,10 @@ async function main() { await fs.writeFile(outFile, header, 'utf8'); await runCargoAndAppend({ cwd: repoRoot, outFilePath: outFile }); + + // Post-process: escape angle-bracket placeholders outside backtick spans for MDX + const raw = await fs.readFile(outFile, 'utf8'); + await fs.writeFile(outFile, escapeAngleBracketsOutsideBackticks(raw), 'utf8'); } main().catch(err => {