diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index eb559199daf..5c0989af58f 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -12,6 +12,7 @@ use toml_edit::ArrayOfTables; const DEFAULT_SERVER_KEY: &str = "default_server"; const WEB_SESSION_TOKEN_KEY: &str = "web_session_token"; const SPACETIMEDB_TOKEN_KEY: &str = "spacetimedb_token"; +const LISTEN_ADDR_KEY: &str = "listen_addr"; const SERVER_CONFIGS_KEY: &str = "server_configs"; const NICKNAME_KEY: &str = "nickname"; const HOST_KEY: &str = "host"; @@ -124,6 +125,7 @@ pub struct RawConfig { // TODO: Move these IDs/tokens out of config so we're no longer storing sensitive tokens in a human-edited file. web_session_token: Option, spacetimedb_token: Option, + listen_addr: Option, } #[derive(Debug, Clone)] @@ -173,6 +175,7 @@ impl RawConfig { server_configs: vec![maincloud, local], web_session_token: None, spacetimedb_token: None, + listen_addr: None, } } @@ -461,6 +464,7 @@ impl TryFrom<&toml_edit::DocumentMut> for RawConfig { let default_server = read_opt_str(value, DEFAULT_SERVER_KEY)?; let web_session_token = read_opt_str(value, WEB_SESSION_TOKEN_KEY)?; let spacetimedb_token = read_opt_str(value, SPACETIMEDB_TOKEN_KEY)?; + let listen_addr = read_opt_str(value, LISTEN_ADDR_KEY)?; let mut server_configs = Vec::new(); if let Some(arr) = read_table(value, SERVER_CONFIGS_KEY)? { @@ -474,6 +478,7 @@ impl TryFrom<&toml_edit::DocumentMut> for RawConfig { server_configs, web_session_token, spacetimedb_token, + listen_addr, }) } } @@ -483,6 +488,10 @@ impl Config { self.home.default_server.as_deref() } + pub fn start_listen_addr(&self) -> Option<&str> { + self.home.listen_addr.as_deref() + } + /// Add a `ServerConfig` to the home configuration. /// /// Returns an `Err` on name conflict, @@ -654,11 +663,13 @@ impl Config { server_configs: old_server_configs, web_session_token, spacetimedb_token, + listen_addr, } = &self.home; set_value(DEFAULT_SERVER_KEY, default_server.as_deref()); set_value(WEB_SESSION_TOKEN_KEY, web_session_token.as_deref()); set_value(SPACETIMEDB_TOKEN_KEY, spacetimedb_token.as_deref()); + set_value(LISTEN_ADDR_KEY, listen_addr.as_deref()); // Short-circuit if there are no servers. if old_server_configs.is_empty() { @@ -929,6 +940,10 @@ protocol = "https" "#; const CONFIG_EMPTY: &str = r#" # Comment end +"#; + const CONFIG_LISTEN_ADDR: &str = r#"listen_addr = "0.0.0.0:4000" + +# Comment end "#; const CONFIG_INVALID_START: &str = r#" this="not a valid key" @@ -992,6 +1007,10 @@ default_server = "local" fn test_config_adds() -> ResultTest<()> { check_config(CONFIG_FULL, CONFIG_FULL, |_| Ok(()))?; check_config(CONFIG_EMPTY, CONFIG_EMPTY, |_| Ok(()))?; + check_config(CONFIG_EMPTY, CONFIG_LISTEN_ADDR, |config| { + config.home.listen_addr = Some("0.0.0.0:4000".to_string()); + Ok(()) + })?; check_config(CONFIG_EMPTY, CONFIG_FULL_NO_COMMENT, |config| { config.home.default_server = Some("local".to_string()); @@ -1057,6 +1076,14 @@ default_server = "local" found: Box::new(toml_edit::value(1)), }, )?; + check_invalid( + r#"listen_addr =1"#, + CliError::ConfigType { + key: "listen_addr".to_string(), + kind: "string", + found: Box::new(toml_edit::value(1)), + }, + )?; check_invalid( r#" [server_configs] diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 48b67d5f149..c44b3d2a41e 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -64,7 +64,7 @@ pub async fn exec_subcommand( "build" => build::exec(config, args).await.map(drop), "server" => server::exec(config, paths, args).await, "subscribe" => subscribe::exec(config, args).await, - "start" => return start::exec(paths, args).await, + "start" => return start::exec(config, paths, args).await, "login" => login::exec(config, args).await, "logout" => logout::exec(config, args).await, "version" => return subcommands::version::exec(paths, root_dir, args).await, diff --git a/crates/cli/src/subcommands/start.rs b/crates/cli/src/subcommands/start.rs index 859bd86094d..9f2c6b128b7 100644 --- a/crates/cli/src/subcommands/start.rs +++ b/crates/cli/src/subcommands/start.rs @@ -7,6 +7,7 @@ use clap::{Arg, ArgMatches}; use spacetimedb_paths::SpacetimePaths; use crate::util::resolve_sibling_binary; +use crate::Config; pub fn cli() -> clap::Command { clap::Command::new("start") @@ -15,6 +16,11 @@ pub fn cli() -> clap::Command { "\ Start a local SpacetimeDB instance +Set a persistent default listen address in cli.toml with: + listen_addr = \"0.0.0.0:4000\" + +When present, `listen_addr` is used unless `--listen-addr` is passed explicitly. + Run `spacetime start --help` to see all options.", ) .disable_help_flag(true) @@ -40,9 +46,33 @@ enum Edition { Cloud, } -pub async fn exec(paths: &SpacetimePaths, args: &ArgMatches) -> anyhow::Result { +/// Check whether the forwarded args already contain `--listen-addr` or `-l`. +/// +/// Handles all common forms: +/// - `--listen-addr ` (two separate tokens) +/// - `--listen-addr=` +/// - `-l ` (two separate tokens) +/// - `-l` (short flag with attached value, e.g. `-l0.0.0.0:4000`) +fn has_listen_addr_arg(args: impl Iterator>) -> bool { + for arg in args { + let s = arg.as_ref().to_string_lossy(); + if s == "--listen-addr" || s.starts_with("--listen-addr=") { + return true; + } + if s == "-l" + || (s.starts_with("-l") + && !s.starts_with("--") + && s.as_bytes().get(2).is_some_and(|b| !b.is_ascii_alphabetic())) + { + return true; + } + } + false +} + +pub async fn exec(config: Config, paths: &SpacetimePaths, args: &ArgMatches) -> anyhow::Result { let edition = args.get_one::("edition").unwrap(); - let args = args.get_many::("args").unwrap_or_default(); + let forwarded_args: Vec = args.get_many::("args").unwrap_or_default().cloned().collect(); let bin_name = match edition { Edition::Standalone => "spacetimedb-standalone", Edition::Cloud => "spacetimedb-cloud", @@ -53,8 +83,15 @@ pub async fn exec(paths: &SpacetimePaths, args: &ArgMatches) -> anyhow::Result io::Result { .map(|status| ExitCode::from(status.code().unwrap_or(1).try_into().unwrap_or(1))) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn detects_long_flag_separate_value() { + assert!(has_listen_addr_arg(["--listen-addr", "0.0.0.0:4000"].iter())); + } + + #[test] + fn detects_long_flag_equals_value() { + assert!(has_listen_addr_arg(["--listen-addr=0.0.0.0:4000"].iter())); + } + + #[test] + fn detects_short_flag_separate_value() { + assert!(has_listen_addr_arg(["-l", "0.0.0.0:4000"].iter())); + } + + #[test] + fn detects_short_flag_attached_value() { + assert!(has_listen_addr_arg(["-l0.0.0.0:4000"].iter())); + } + + #[test] + fn detects_short_flag_attached_ipv6() { + assert!(has_listen_addr_arg(["-l[::1]:4000"].iter())); + } + + #[test] + fn ignores_unrelated_long_flag() { + assert!(!has_listen_addr_arg(["--data-dir", "/tmp"].iter())); + } + + #[test] + fn ignores_unrelated_short_flag() { + assert!(!has_listen_addr_arg(["-d", "/tmp"].iter())); + } + + #[test] + fn no_false_positive_on_hyphen_l_prefix_flag() { + assert!(!has_listen_addr_arg(["-log"].iter())); + } + + #[test] + fn no_false_positive_on_hyphen_li() { + assert!(!has_listen_addr_arg(["-li"].iter())); + } + + #[test] + fn returns_false_for_empty() { + let empty: Vec<&str> = vec![]; + assert!(!has_listen_addr_arg(empty.iter())); + } + + #[test] + fn detects_among_many_args() { + assert!(has_listen_addr_arg( + ["--data-dir", "/tmp", "--listen-addr", "0.0.0.0:4000", "--in-memory"].iter() + )); + } + + #[test] + fn detects_short_among_many_args() { + assert!(has_listen_addr_arg( + ["--data-dir", "/tmp", "-l", "127.0.0.1:5000"].iter() + )); + } +} 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 70b33a489e8..1bfcf0960b3 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 @@ -599,6 +599,14 @@ Subscribe to SQL queries on the database. WARNING: This command is UNSTABLE and Start a local SpacetimeDB instance +Set a persistent default listen address in `cli.toml` with: + +```toml +listen_addr = "0.0.0.0:4000" +``` + +When present, `listen_addr` is used unless `--listen-addr` is passed explicitly. Precedence is: explicit CLI flag, then `cli.toml`, then the standalone default `0.0.0.0:3000`. + Run `spacetime start --help` to see all options. **Usage:** `spacetime start [OPTIONS] [args]...`