Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
142 changes: 138 additions & 4 deletions crates/cli/src/subcommands/start.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -40,9 +41,58 @@ enum Edition {
Cloud,
}

/// Check whether the forwarded args already contain `--listen-addr` or `-l`.
///
/// Handles all common forms:
/// - `--listen-addr <value>` (two separate tokens)
/// - `--listen-addr=<value>`
/// - `-l <value>` (two separate tokens)
/// - `-l<value>` (short flag with attached value, e.g. `-l0.0.0.0:4000`)
fn has_listen_addr_arg(args: impl Iterator<Item = impl AsRef<std::ffi::OsStr>>) -> bool {
for arg in args {
let s = arg.as_ref().to_string_lossy();
// --listen-addr or --listen-addr=<value>
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<Option<String>> {
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<ExitCode> {
let edition = args.get_one::<Edition>("edition").unwrap();
let args = args.get_many::<OsString>("args").unwrap_or_default();
let forwarded_args: Vec<OsString> = args.get_many::<OsString>("args").unwrap_or_default().cloned().collect();
let bin_name = match edition {
Edition::Standalone => "spacetimedb-standalone",
Edition::Cloud => "spacetimedb-cloud",
Expand All @@ -53,8 +103,19 @@ pub async fn exec(paths: &SpacetimePaths, args: &ArgMatches) -> anyhow::Result<E
.arg("--data-dir")
.arg(&paths.data_dir)
.arg("--jwt-key-dir")
.arg(&paths.cli_config_dir)
.args(args);
.arg(&paths.cli_config_dir);

// Resolve listen-addr with precedence: CLI > 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()))
}
Expand Down Expand Up @@ -103,3 +164,76 @@ pub(crate) fn exec_replace(cmd: &mut Command) -> io::Result<ExitCode> {
.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()
));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,28 @@ database running in data directory <b>/home/user/.local/share/spacetime/data</b>

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)
Expand Down