From a5819b2a752763ea297e529a53af1a83fe18285c Mon Sep 17 00:00:00 2001 From: bradleyshep <148254416+bradleyshep@users.noreply.github.com> Date: Fri, 16 Jan 2026 15:42:14 -0500 Subject: [PATCH 01/13] feat(cli): add client dev server support to spacetime dev - Add --client-command flag to specify client dev command - Add --server-only flag to skip client execution - Add spacetime.toml config file for project settings - Auto-detect client command from package.json/Cargo.toml/.csproj - Save detected command to spacetime.toml for future runs - Update spacetime init to set default client command - Add spacetime.toml to all templates with appropriate defaults - Update documentation --- crates/cli/src/subcommands/dev.rs | 95 +++++++++++ crates/cli/src/subcommands/init.rs | 21 +++ crates/cli/src/subcommands/mod.rs | 1 + .../cli/src/subcommands/spacetime_config.rs | 155 ++++++++++++++++++ .../00100-databases/00200-spacetime-dev.md | 21 +++ .../00100-cli-reference.md | 2 + templates/basic-c-sharp/spacetime.toml | 6 + templates/basic-react/spacetime.toml | 6 + templates/basic-rust/spacetime.toml | 6 + templates/basic-typescript/spacetime.toml | 6 + .../quickstart-chat-c-sharp/spacetime.toml | 6 + templates/quickstart-chat-rust/spacetime.toml | 6 + .../quickstart-chat-typescript/spacetime.toml | 6 + 13 files changed, 337 insertions(+) create mode 100644 crates/cli/src/subcommands/spacetime_config.rs create mode 100644 templates/basic-c-sharp/spacetime.toml create mode 100644 templates/basic-react/spacetime.toml create mode 100644 templates/basic-rust/spacetime.toml create mode 100644 templates/basic-typescript/spacetime.toml create mode 100644 templates/quickstart-chat-c-sharp/spacetime.toml create mode 100644 templates/quickstart-chat-rust/spacetime.toml create mode 100644 templates/quickstart-chat-typescript/spacetime.toml diff --git a/crates/cli/src/subcommands/dev.rs b/crates/cli/src/subcommands/dev.rs index 37ce4d3752a..417a9410f83 100644 --- a/crates/cli/src/subcommands/dev.rs +++ b/crates/cli/src/subcommands/dev.rs @@ -2,6 +2,7 @@ use crate::common_args::ClearMode; use crate::config::Config; use crate::generate::Language; use crate::subcommands::init; +use crate::subcommands::spacetime_config::{detect_client_command, SpacetimeConfig}; use crate::util::{ add_auth_header_opt, database_identity, detect_module_language, get_auth_header, get_login_token_or_log_in, spacetime_reverse_dns, ResponseExt, @@ -29,6 +30,7 @@ use tabled::{ Table, Tabled, }; use termcolor::{Color, ColorSpec, WriteColor}; +use tokio::process::{Child, Command as TokioCommand}; use tokio::task::JoinHandle; use tokio::time::sleep; @@ -86,6 +88,18 @@ pub fn cli() -> Command { .value_name("TEMPLATE") .help("Template ID or GitHub repository (owner/repo or URL) for project initialization"), ) + .arg( + Arg::new("client-command") + .long("client-command") + .value_name("COMMAND") + .help("Command to run the client development server (overrides spacetime.toml config)"), + ) + .arg( + Arg::new("server-only") + .long("server-only") + .action(clap::ArgAction::SetTrue) + .help("Only run the server (module) without starting the client"), + ) } #[derive(Deserialize)] @@ -259,12 +273,44 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E ); } + // Determine client command: CLI flag > config file > auto-detect (and save) + let server_only = args.get_flag("server-only"); + let client_command = if server_only { + None + } else if let Some(cmd) = args.get_one::("client-command") { + // Explicit CLI flag takes priority + Some(cmd.clone()) + } else if let Some(cmd) = SpacetimeConfig::load_from_dir(&project_dir) + .ok() + .flatten() + .and_then(|c| c.dev.client_command) + { + // Config file exists with client_command + Some(cmd) + } else if let Some(detected) = detect_client_command(&project_dir) { + // No config - detect and save for future runs + let config = SpacetimeConfig::with_client_command(&detected); + if let Ok(path) = config.save_to_dir(&project_dir) { + println!( + "{} Detected client command and saved to {}", + "✓".green(), + path.display() + ); + } + Some(detected) + } else { + None + }; + println!("\n{}", "Starting development mode...".green().bold()); println!("Database: {}", database_name.cyan()); println!( "Watching for changes in: {}", spacetimedb_dir.display().to_string().cyan() ); + if let Some(ref cmd) = client_command { + println!("Client command: {}", cmd.cyan()); + } println!("{}", "Press Ctrl+C to stop".dimmed()); println!(); @@ -286,6 +332,13 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E let db_identity = database_identity(&config, &database_name, Some(resolved_server)).await?; let _log_handle = start_log_stream(config.clone(), db_identity.to_hex().to_string(), Some(resolved_server)).await?; + // Start the client development server if configured + let _client_handle = if let Some(ref cmd) = client_command { + Some(start_client_process(cmd, &project_dir)?) + } else { + None + }; + let (tx, rx) = channel(); let mut watcher: RecommendedWatcher = Watcher::new( move |res: Result| { @@ -738,3 +791,45 @@ fn generate_database_name() -> String { let mut generator = names::Generator::with_naming(names::Name::Numbered); generator.next().unwrap() } + +/// Start the client development server as a child process. +/// The process inherits stdout/stderr so the user can see the output. +fn start_client_process(command: &str, working_dir: &Path) -> Result { + println!("{} {}", "Starting client:".cyan(), command.to_string().dimmed()); + + let parts: Vec<&str> = command.split_whitespace().collect(); + if parts.is_empty() { + anyhow::bail!("Empty client command"); + } + + let program = parts[0]; + let args = &parts[1..]; + + // On Windows, use cmd /C to resolve .cmd/.bat scripts (like npm) + #[cfg(windows)] + let child = TokioCommand::new("cmd") + .arg("/C") + .arg(program) + .args(args) + .current_dir(working_dir) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .stdin(std::process::Stdio::null()) + .kill_on_drop(true) + .spawn() + .with_context(|| format!("Failed to start client command: {}", command))?; + + // On Unix, run the program directly + #[cfg(not(windows))] + let child = TokioCommand::new(program) + .args(args) + .current_dir(working_dir) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .stdin(std::process::Stdio::null()) + .kill_on_drop(true) + .spawn() + .with_context(|| format!("Failed to start client command: {}", command))?; + + Ok(child) +} diff --git a/crates/cli/src/subcommands/init.rs b/crates/cli/src/subcommands/init.rs index 8530bde2933..f5ae521ed07 100644 --- a/crates/cli/src/subcommands/init.rs +++ b/crates/cli/src/subcommands/init.rs @@ -16,6 +16,7 @@ use toml_edit::{value, DocumentMut, Item}; use xmltree::{Element, XMLNode}; use crate::subcommands::login::{spacetimedb_login_force, DEFAULT_AUTH_HOST}; +use crate::subcommands::spacetime_config::SpacetimeConfig; mod embedded { include!(concat!(env!("OUT_DIR"), "/embedded_templates.rs")); @@ -525,6 +526,13 @@ pub async fn exec_init(config: &mut Config, args: &ArgMatches, is_interactive: b install_typescript_dependencies(&server_dir, pm)?; } + // Configure client dev command if a client is present + if !is_server_only { + if let Some(client_lang) = &template_config.client_lang { + setup_spacetime_config(&project_path, client_lang)?; + } + } + Ok(project_path) } @@ -1722,3 +1730,16 @@ fn strip_mdc_frontmatter(content: &str) -> &str { } content } + +/// Set up the spacetime.toml configuration file with the client dev command. +fn setup_spacetime_config(project_path: &Path, client_lang: &ClientLanguage) -> anyhow::Result<()> { + let client_command = match client_lang { + ClientLanguage::TypeScript => "npm run dev", + ClientLanguage::Rust => "cargo run", + ClientLanguage::Csharp => "dotnet run", + }; + + let config = SpacetimeConfig::with_client_command(client_command); + config.save_to_dir(project_path)?; + Ok(()) +} diff --git a/crates/cli/src/subcommands/mod.rs b/crates/cli/src/subcommands/mod.rs index a50910ce8bd..346cbabd229 100644 --- a/crates/cli/src/subcommands/mod.rs +++ b/crates/cli/src/subcommands/mod.rs @@ -14,6 +14,7 @@ pub mod logs; pub mod publish; pub mod repl; pub mod server; +pub mod spacetime_config; pub mod sql; pub mod start; pub mod subscribe; diff --git a/crates/cli/src/subcommands/spacetime_config.rs b/crates/cli/src/subcommands/spacetime_config.rs new file mode 100644 index 00000000000..0d3012670fc --- /dev/null +++ b/crates/cli/src/subcommands/spacetime_config.rs @@ -0,0 +1,155 @@ +//! SpacetimeDB project configuration file handling. +//! +//! This module handles loading and saving `spacetime.toml` configuration files. +//! The config file is placed in the project root (same level as `spacetimedb/` directory). + +use anyhow::Context; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::{Path, PathBuf}; + +/// The filename for configuration +pub const CONFIG_FILENAME: &str = "spacetime.toml"; + +/// Development mode configuration +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct DevConfig { + /// The command to run the client development server. + /// Example: "npm run dev", "pnpm dev", "cargo run" + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_command: Option, +} + +/// Root configuration structure +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SpacetimeConfig { + /// Development mode configuration + #[serde(default, skip_serializing_if = "DevConfig::is_empty")] + pub dev: DevConfig, +} + +impl DevConfig { + fn is_empty(&self) -> bool { + self.client_command.is_none() + } +} + +impl SpacetimeConfig { + /// Create a new empty configuration + pub fn new() -> Self { + Self::default() + } + + /// Create a configuration with a client command + pub fn with_client_command(client_command: impl Into) -> Self { + Self { + dev: DevConfig { + client_command: Some(client_command.into()), + }, + } + } + + /// Load configuration from a directory. + /// Returns `None` if no config file exists. + pub fn load_from_dir(dir: &Path) -> anyhow::Result> { + let config_path = dir.join(CONFIG_FILENAME); + + if config_path.exists() { + let content = + fs::read_to_string(&config_path).with_context(|| format!("Failed to read {}", config_path.display()))?; + let config: SpacetimeConfig = + toml::from_str(&content).with_context(|| format!("Failed to parse {}", config_path.display()))?; + return Ok(Some(config)); + } + + Ok(None) + } + + /// Save configuration to `spacetime.toml` in the specified directory. + pub fn save_to_dir(&self, dir: &Path) -> anyhow::Result { + let path = dir.join(CONFIG_FILENAME); + let content = toml::to_string_pretty(self).context("Failed to serialize configuration")?; + fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display()))?; + Ok(path) + } + + /// Check if a config file exists in the directory + pub fn exists_in_dir(dir: &Path) -> bool { + dir.join(CONFIG_FILENAME).exists() + } + + /// Get the path to the config file if it exists + pub fn get_config_path(dir: &Path) -> Option { + let path = dir.join(CONFIG_FILENAME); + if path.exists() { + return Some(path); + } + None + } +} + +/// Simple auto-detection for projects without `spacetime.toml`. +pub fn detect_client_command(project_dir: &Path) -> Option { + // JavaScript/TypeScript: package.json with "dev" script + let package_json = project_dir.join("package.json"); + if package_json.exists() { + if let Ok(content) = fs::read_to_string(&package_json) { + if let Ok(json) = serde_json::from_str::(&content) { + if json.get("scripts").and_then(|s| s.get("dev")).is_some() { + return Some("npm run dev".to_string()); + } + } + } + } + + // Rust: Cargo.toml + if project_dir.join("Cargo.toml").exists() { + return Some("cargo run".to_string()); + } + + // C#: .csproj file + if let Ok(entries) = fs::read_dir(project_dir) { + for entry in entries.flatten() { + if entry.path().extension().is_some_and(|e| e == "csproj") { + return Some("dotnet run".to_string()); + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn test_save_and_load() { + let dir = tempdir().unwrap(); + let config = SpacetimeConfig::with_client_command("npm run dev"); + + config.save_to_dir(dir.path()).unwrap(); + + let loaded = SpacetimeConfig::load_from_dir(dir.path()).unwrap().unwrap(); + assert_eq!(loaded.dev.client_command, Some("npm run dev".to_string())); + } + + #[test] + fn test_load_missing_config() { + let dir = tempdir().unwrap(); + let loaded = SpacetimeConfig::load_from_dir(dir.path()).unwrap(); + assert!(loaded.is_none()); + } + + #[test] + fn test_exists_in_dir() { + let dir = tempdir().unwrap(); + assert!(!SpacetimeConfig::exists_in_dir(dir.path())); + + let config = SpacetimeConfig::with_client_command("npm run dev"); + config.save_to_dir(dir.path()).unwrap(); + + assert!(SpacetimeConfig::exists_in_dir(dir.path())); + } +} diff --git a/docs/docs/00200-core-concepts/00100-databases/00200-spacetime-dev.md b/docs/docs/00200-core-concepts/00100-databases/00200-spacetime-dev.md index 4a3eb6ffa21..e9d67fd0ca8 100644 --- a/docs/docs/00200-core-concepts/00100-databases/00200-spacetime-dev.md +++ b/docs/docs/00200-core-concepts/00100-databases/00200-spacetime-dev.md @@ -93,9 +93,27 @@ After completing setup, `spacetime dev`: - Builds and publishes your module to the database - Watches your source files for changes - Automatically rebuilds and republishes when you save changes +- **Runs your client development server** (if configured) Your database will be available at `https://maincloud.spacetimedb.com`. +### Client Development Server + +`spacetime dev` can automatically run your client's development server alongside the SpacetimeDB module. This is configured via the `spacetime.toml` file in your project root: + +```toml +[dev] +client_command = "npm run dev" +``` + +The client command can be: +- Auto-detected from your project (package.json, Cargo.toml, .csproj) +- Configured in `spacetime.toml` +- Overridden via CLI flag: `spacetime dev --client-command "yarn dev"` +- Disabled with: `spacetime dev --server-only` + +When you run `spacetime init` with a client template, you'll be prompted to configure the client command, which is then saved to `spacetime.toml`. + ### Project Structure After initialization, your project will contain: @@ -114,6 +132,7 @@ my-project/ │ └── module_bindings/ # Generated client bindings ├── package.json ├── tsconfig.json +├── spacetime.toml # SpacetimeDB configuration └── README.md ``` @@ -128,6 +147,7 @@ my-project/ ├── module_bindings/ # Generated client bindings ├── client.csproj ├── Program.cs +├── spacetime.toml # SpacetimeDB configuration └── README.md ``` @@ -143,6 +163,7 @@ my-project/ ├── src/ # Client code │ └── module_bindings/ # Generated client bindings ├── Cargo.toml +├── spacetime.toml # SpacetimeDB configuration ├── .gitignore └── README.md ``` 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 508f87a399f..25eb6fca118 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 @@ -243,6 +243,8 @@ Start development mode with auto-regenerate client module bindings, auto-rebuild Possible values: `always`, `on-conflict`, `never` * `-t`, `--template