From 2649ff9df47f74e779c492ae6f7e1ad9af65de96 Mon Sep 17 00:00:00 2001 From: 0monish <0monishkhandelwal@gmail.com> Date: Fri, 6 Mar 2026 18:29:01 +0530 Subject: [PATCH] feat(cli): support persistent default listen-addr in spacetime.json Allow \`spacetime start\` to use a project-configured default listen address from \`spacetime.json\`, with proper precedence: CLI flag > config > built-in default. - Add \`has_listen_addr_arg()\` helper to detect explicit --listen-addr or -l - Add \`resolve_listen_addr_from_config()\` to load listening address from config - Inject config-backed address only when user didn't pass explicit CLI flag - Add 12 unit tests covering all argument detection edge cases - Update standalone config documentation with precedence and examples Changes are backward compatible: existing users without config still get 0.0.0.0:3000, and explicit CLI flags continue to take precedence. --- crates/cli/src/subcommands/start.rs | 142 +++++++++++++++++- .../00200-standalone-config.md | 22 +++ 2 files changed, 160 insertions(+), 4 deletions(-) diff --git a/crates/cli/src/subcommands/start.rs b/crates/cli/src/subcommands/start.rs index 859bd86094d..65cd6dc2d37 100644 --- a/crates/cli/src/subcommands/start.rs +++ b/crates/cli/src/subcommands/start.rs @@ -2,10 +2,11 @@ use std::ffi::OsString; use std::io; use std::process::{Command, ExitCode}; -use anyhow::Context; +use anyhow::{anyhow, Context}; use clap::{Arg, ArgMatches}; use spacetimedb_paths::SpacetimePaths; +use crate::spacetime_config::find_and_load_with_env; use crate::util::resolve_sibling_binary; pub fn cli() -> clap::Command { @@ -40,9 +41,58 @@ enum Edition { Cloud, } +/// 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(); + // --listen-addr or --listen-addr= + if s == "--listen-addr" || s.starts_with("--listen-addr=") { + return true; + } + // Exactly `-l` (value in next token) or `-l` followed by a non-alphabetic + // char (attached value like `-l0.0.0.0:4000`). This avoids false positives + // on hypothetical flags like `-log` while correctly matching the `-l` short + // flag for `--listen-addr`. + 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 +} + +/// Resolve the listen address from config (`spacetime.json`). +/// +/// Returns `Some(addr)` if a `listen-addr` key is found in the project config, +/// or `None` if no config file exists or the key is absent. +fn resolve_listen_addr_from_config() -> anyhow::Result> { + let Some(loaded) = find_and_load_with_env(None)? else { + return Ok(None); + }; + let Some(value) = loaded.config.additional_fields.get("listen-addr") else { + return Ok(None); + }; + + let listen_addr = value + .as_str() + .ok_or_else(|| anyhow!("invalid `listen-addr` in spacetime.json: expected a string, got {value}"))? + .to_owned(); + + Ok(Some(listen_addr)) +} + pub async fn exec(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 +103,19 @@ pub async fn exec(paths: &SpacetimePaths, args: &ArgMatches) -> anyhow::Result config > built-in default. + // If the user already passed --listen-addr / -l in the forwarded args, pass + // everything through unchanged. Otherwise, check spacetime.json for a + // configured default and inject it. + if !has_listen_addr_arg(forwarded_args.iter()) + && let Some(config_addr) = resolve_listen_addr_from_config()? + { + cmd.arg("--listen-addr").arg(&config_addr); + } + + cmd.args(&forwarded_args); exec_replace(&mut cmd).with_context(|| format!("exec failed for {}", bin_path.display())) } @@ -103,3 +164,76 @@ pub(crate) fn exec_replace(cmd: &mut Command) -> io::Result { .map(|status| ExitCode::from(status.code().unwrap_or(1).try_into().unwrap_or(1))) } } + +#[cfg(test)] +mod tests { + use super::*; + + // ── has_listen_addr_arg tests ────────────────────────────────────── + + #[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() { + // A hypothetical flag like `-log` should not be detected. + 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/00200-standalone-config.md b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00200-standalone-config.md index dce6afe8c5f..3e292e27f83 100644 --- a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00200-standalone-config.md +++ b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00200-standalone-config.md @@ -12,6 +12,28 @@ database running in data directory /home/user/.local/share/spacetime/data On Linux and macOS, this directory is by default `~/.local/share/spacetime/data`. On Windows, it's `%LOCALAPPDATA%\SpacetimeDB\data`. +## Project-level configuration (`spacetime.json`) + +You can set a persistent default listen address for `spacetime start` in your project's `spacetime.json` file. This is useful when port `3000` is already occupied (e.g. by a frontend dev server) or when your team standardizes on a different local port. + +### `listen-addr` + +```json +{ + "listen-addr": "0.0.0.0:4000" +} +``` + +When present, `spacetime start` will use this address instead of the built-in default `0.0.0.0:3000`. + +**Precedence (highest to lowest):** + +1. Explicit CLI flag: `spacetime start --listen-addr 127.0.0.1:5000` +2. Value from `spacetime.json`: `"listen-addr": "0.0.0.0:4000"` +3. Built-in default: `0.0.0.0:3000` + +The config is discovered by searching the current directory and its parent directories for `spacetime.json`. If present, `spacetime.local.json` is layered on top of it before `listen-addr` is read. + ## `config.toml` - [`certificate-authority`](#certificate-authority)