diff --git a/crates/cli/src/subcommands/db_arg_resolution.rs b/crates/cli/src/subcommands/db_arg_resolution.rs index 7661d2258ab..2d5ccc3e137 100644 --- a/crates/cli/src/subcommands/db_arg_resolution.rs +++ b/crates/cli/src/subcommands/db_arg_resolution.rs @@ -13,6 +13,10 @@ // | subscribe | resolve_optional_database_parts | No (variable args) | No | // | describe | resolve_database_with_optional_parts | No (optional args)| No | // +// Commands that address a server rather than a database (`list`, `rename`, `mcp`) have no +// database argument to resolve, so they take the project's server from the config directly +// via `resolve_config_server`. +// // "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). // @@ -21,7 +25,7 @@ // 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 crate::spacetime_config::{find_and_load_with_env, SpacetimeConfig}; use itertools::Itertools; #[derive(Debug, Clone, PartialEq, Eq)] @@ -81,6 +85,31 @@ pub(crate) fn load_config_db_targets(no_config: bool) -> anyhow::Result anyhow::Result> { + if no_config { + return Ok(None); + } + Ok(find_and_load_with_env(None)?.and_then(|loaded| single_config_server(&loaded.config))) +} + +/// The one server a config points at, or `None` if it names zero or several. +fn single_config_server(config: &SpacetimeConfig) -> Option { + config + .collect_all_targets_with_inheritance() + .iter() + .filter_map(|target| target.fields.get("server").and_then(|v| v.as_str())) + .unique() + .exactly_one() + .ok() + .map(str::to_string) +} + pub(crate) fn resolve_optional_database_parts( raw_parts: &[String], config_targets: Option<&[ConfigDbTarget]>, @@ -262,8 +291,44 @@ pub(crate) fn resolve_database_with_optional_parts( #[cfg(test)] mod tests { use super::{ - resolve_database_arg, resolve_database_with_optional_parts, resolve_optional_database_parts, ConfigDbTarget, + resolve_database_arg, resolve_database_with_optional_parts, resolve_optional_database_parts, + single_config_server, ConfigDbTarget, }; + use crate::spacetime_config::SpacetimeConfig; + + fn parse_config(json: &str) -> SpacetimeConfig { + serde_json::from_str(json).unwrap() + } + + #[test] + fn root_server_is_used_when_config_has_no_database() { + let config = parse_config(r#"{ "server": "local", "module-path": "./spacetimedb" }"#); + assert_eq!(single_config_server(&config).as_deref(), Some("local")); + } + + #[test] + fn children_inherit_the_root_server() { + let config = + parse_config(r#"{ "server": "local", "children": [{ "database": "foo" }, { "database": "bar" }] }"#); + assert_eq!(single_config_server(&config).as_deref(), Some("local")); + } + + #[test] + fn no_server_when_config_omits_it() { + let config = parse_config(r#"{ "database": "foo" }"#); + assert_eq!(single_config_server(&config), None); + } + + #[test] + fn no_server_when_children_disagree() { + let config = parse_config( + r#"{ "children": [ + { "database": "foo", "server": "local" }, + { "database": "bar", "server": "maincloud" } + ] }"#, + ); + assert_eq!(single_config_server(&config), None); + } #[test] fn single_db_infers_database() { diff --git a/crates/cli/src/subcommands/dns.rs b/crates/cli/src/subcommands/dns.rs index 59e049b038b..1a7a4e67ad9 100644 --- a/crates/cli/src/subcommands/dns.rs +++ b/crates/cli/src/subcommands/dns.rs @@ -1,5 +1,6 @@ use crate::common_args; use crate::config::Config; +use crate::subcommands::db_arg_resolution::resolve_config_server; use crate::util::{add_auth_header_opt, get_auth_header, ResponseExt}; use clap::ArgMatches; use clap::{Arg, Command}; @@ -22,14 +23,28 @@ pub fn cli() -> Command { ) .arg(common_args::server().help("The nickname, host name or URL of the server on which to set the name")) .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 rename --help` for more detailed information.\n") } pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { let domain = args.get_one::("new-name").unwrap(); let database_identity = args.get_one::("database-identity").unwrap(); - let server = args.get_one::("server").map(|s| s.as_ref()); let force = args.get_flag("force"); + let no_config = args.get_flag("no_config"); + + // `rename` addresses the database by identity, so there is no database name to + // resolve against the config -- take the project's server directly. + let config_server = resolve_config_server(no_config)?; + let server = args + .get_one::("server") + .map(|s| s.as_str()) + .or(config_server.as_deref()); let auth_header = get_auth_header(&mut config, false, server, !force).await?; let domain: DomainName = domain.parse()?; diff --git a/crates/cli/src/subcommands/list.rs b/crates/cli/src/subcommands/list.rs index 889b6a93346..99e896e5739 100644 --- a/crates/cli/src/subcommands/list.rs +++ b/crates/cli/src/subcommands/list.rs @@ -1,11 +1,12 @@ use crate::common_args; +use crate::subcommands::db_arg_resolution::resolve_config_server; use crate::util; use crate::util::get_login_token_or_log_in; use crate::util::ResponseExt; use crate::util::UNSTABLE_WARNING; use crate::Config; use anyhow::Context; -use clap::{ArgMatches, Command}; +use clap::{Arg, ArgMatches, Command}; use futures::future::join_all; use serde::Deserialize; use spacetimedb_lib::Identity; @@ -21,6 +22,12 @@ pub fn cli() -> Command { )) .arg(common_args::server().help("The nickname, host name or URL of the server from which to list databases")) .arg(common_args::yes()) + .arg( + Arg::new("no_config") + .long("no-config") + .action(clap::ArgAction::SetTrue) + .help("Ignore spacetime.json configuration"), + ) } #[derive(Deserialize)] @@ -39,8 +46,17 @@ struct DatabaseRow { pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { eprintln!("{UNSTABLE_WARNING}\n"); - let server = args.get_one::("server").map(|s| s.as_ref()); let force = args.get_flag("force"); + let no_config = args.get_flag("no_config"); + + // `list` has no database argument, so unlike `call`/`logs`/`sql` it picks up the + // project's server directly from spacetime.json rather than from a database target. + let config_server = resolve_config_server(no_config)?; + let server = args + .get_one::("server") + .map(|s| s.as_str()) + .or(config_server.as_deref()); + let token = get_login_token_or_log_in(&mut config, server, !force).await?; let identity = util::decode_identity(&token)?; diff --git a/crates/cli/src/subcommands/mcp.rs b/crates/cli/src/subcommands/mcp.rs index 7d679c121c7..b66658479ec 100644 --- a/crates/cli/src/subcommands/mcp.rs +++ b/crates/cli/src/subcommands/mcp.rs @@ -1,6 +1,7 @@ use crate::api::{build_client, Connection}; use crate::common_args; use crate::config::Config; +use crate::subcommands::db_arg_resolution::resolve_config_server; use crate::util::{auth_header_from_saved_token, database_identity, ResponseExt, UNSTABLE_WARNING}; use anyhow::Context; use clap::{Arg, ArgMatches}; @@ -17,6 +18,12 @@ pub fn cli() -> clap::Command { )) .arg(common_args::server().help("The nickname, host name or URL of the server hosting the database")) .arg(common_args::anonymous()) + .arg( + Arg::new("no_config") + .long("no-config") + .action(clap::ArgAction::SetTrue) + .help("Ignore spacetime.json configuration"), + ) .after_help("Run `spacetime help mcp` for more detailed information.\n") } @@ -24,8 +31,16 @@ pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error eprintln!("{UNSTABLE_WARNING}\n"); let database = args.get_one::("database"); - let server = args.get_one::("server").map(|s| s.as_ref()); let anon_identity = args.get_flag("anon_identity"); + let no_config = args.get_flag("no_config"); + + // The database arg stays as given (omitting it deliberately serves the whole + // server), but the server it is served from follows the project's config. + let config_server = resolve_config_server(no_config)?; + let server = args + .get_one::("server") + .map(|s| s.as_str()) + .or(config_server.as_deref()); let conn = Connection { host: config.get_host_url(server)?,