diff --git a/crates/cli/src/subcommands/dev.rs b/crates/cli/src/subcommands/dev.rs index 37ce4d3752a..e4fba2484ec 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.json 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.run) + { + // Config file exists with run command + Some(cmd) + } else if let Some((detected_cmd, _detected_pm)) = detect_client_command(&project_dir) { + // No config - detect and save for future runs + let config = SpacetimeConfig::with_run_command(&detected_cmd); + if let Ok(path) = config.save_to_dir(&project_dir) { + println!( + "{} Detected client command and saved to {}", + "✓".green(), + path.display() + ); + } + Some(detected_cmd) + } 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,34 @@ 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 server_host_url = config.get_host_url(Some(resolved_server))?; + let _client_handle = if let Some(ref cmd) = client_command { + let mut child = start_client_process(cmd, &project_dir, &database_name, &server_host_url)?; + + // Give the process a moment to fail fast (e.g., command not found, missing deps) + sleep(Duration::from_millis(200)).await; + match child.try_wait() { + Ok(Some(status)) if !status.success() => { + anyhow::bail!( + "Client command '{}' failed immediately with exit code: {}", + cmd, + status + .code() + .map(|c| c.to_string()) + .unwrap_or_else(|| "unknown".to_string()) + ); + } + Err(e) => { + anyhow::bail!("Failed to check client process status: {}", e); + } + _ => {} // Still running or exited successfully (unusual but ok) + } + Some(child) + } else { + None + }; + let (tx, rx) = channel(); let mut watcher: RecommendedWatcher = Watcher::new( move |res: Result| { @@ -301,8 +375,15 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E notify::Config::default().with_poll_interval(Duration::from_millis(500)), )?; + // Watch the appropriate directory based on project structure + // Rust/TypeScript modules have source in `src/`, C# modules have `*.cs` directly in the module dir let src_dir = spacetimedb_dir.join("src"); - watcher.watch(&src_dir, RecursiveMode::Recursive)?; + let watch_dir = if src_dir.exists() && src_dir.is_dir() { + src_dir + } else { + spacetimedb_dir.to_path_buf() + }; + watcher.watch(&watch_dir, RecursiveMode::Recursive)?; println!("{}", "Watching for file changes...".dimmed()); @@ -738,3 +819,48 @@ 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. +/// Sets SPACETIMEDB_DB_NAME and SPACETIMEDB_HOST environment variables for the client. +fn start_client_process( + command: &str, + working_dir: &Path, + database_name: &str, + host_url: &str, +) -> Result { + println!("{} {}", "Starting client:".cyan(), command.dimmed()); + + if command.trim().is_empty() { + anyhow::bail!("Empty client command"); + } + + // Use shell to handle PATH resolution and .cmd/.bat scripts on Windows + #[cfg(windows)] + let child = TokioCommand::new("cmd") + .args(["/C", command]) + .current_dir(working_dir) + .env("SPACETIMEDB_DB_NAME", database_name) + .env("SPACETIMEDB_HOST", host_url) + .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))?; + + #[cfg(not(windows))] + let child = TokioCommand::new("sh") + .args(["-c", command]) + .current_dir(working_dir) + .env("SPACETIMEDB_DB_NAME", database_name) + .env("SPACETIMEDB_HOST", host_url) + .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 bae22796d3c..4df7fe3203e 100644 --- a/crates/cli/src/subcommands/init.rs +++ b/crates/cli/src/subcommands/init.rs @@ -10,12 +10,13 @@ use reqwest::Url; use serde::{Deserialize, Serialize}; use serde_json::json; use std::collections::HashMap; +use std::fs; use std::path::{Path, PathBuf}; -use std::{fmt, fs}; 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::PackageManager; mod embedded { include!(concat!(env!("OUT_DIR"), "/embedded_templates.rs")); @@ -343,26 +344,6 @@ fn run_pm(pm: PackageManager, args: &[&str], cwd: &Path) -> std::io::Result) -> fmt::Result { - let s = match self { - PackageManager::Npm => "npm", - PackageManager::Pnpm => "pnpm", - PackageManager::Yarn => "yarn", - PackageManager::Bun => "bun", - }; - write!(f, "{s}") - } -} - pub fn prompt_for_typescript_package_manager() -> anyhow::Result> { println!( "\n{}", @@ -488,6 +469,16 @@ pub async fn exec_init(config: &mut Config, args: &ArgMatches, is_interactive: b )?; init_from_template(&template_config, &template_config.project_path, is_server_only).await?; + // Determine package manager for TypeScript projects + let uses_typescript = template_config.server_lang == Some(ServerLanguage::TypeScript) + || template_config.client_lang == Some(ClientLanguage::TypeScript); + + let package_manager = if uses_typescript && is_interactive { + prompt_for_typescript_package_manager()? + } else { + None + }; + if template_config.server_lang == Some(ServerLanguage::TypeScript) && template_config.client_lang == Some(ClientLanguage::TypeScript) { @@ -495,34 +486,29 @@ pub async fn exec_init(config: &mut Config, args: &ArgMatches, is_interactive: b // NOTE: All server templates must have their server code in `spacetimedb/` directory // This is not a requirement in general, but is a requirement for all templates // i.e. `spacetime dev` is valid on non-templates. - let pm = if is_interactive { - prompt_for_typescript_package_manager()? - } else { - None - }; - let client_dir = template_config.project_path; + let client_dir = &template_config.project_path; let server_dir = client_dir.join("spacetimedb"); - install_typescript_dependencies(&server_dir, pm)?; - install_typescript_dependencies(&client_dir, pm)?; + install_typescript_dependencies(&server_dir, package_manager)?; + install_typescript_dependencies(client_dir, package_manager)?; } else if template_config.client_lang == Some(ClientLanguage::TypeScript) { - let pm = if is_interactive { - prompt_for_typescript_package_manager()? - } else { - None - }; - let client_dir = template_config.project_path; - install_typescript_dependencies(&client_dir, pm)?; + let client_dir = &template_config.project_path; + install_typescript_dependencies(client_dir, package_manager)?; } else if template_config.server_lang == Some(ServerLanguage::TypeScript) { - let pm = if is_interactive { - prompt_for_typescript_package_manager()? - } else { - None - }; // NOTE: All server templates must have their server code in `spacetimedb/` directory // This is not a requirement in general, but is a requirement for all templates // i.e. `spacetime dev` is valid on non-templates. let server_dir = template_config.project_path.join("spacetimedb"); - install_typescript_dependencies(&server_dir, pm)?; + install_typescript_dependencies(&server_dir, package_manager)?; + } + + // Configure client dev command if a client is present + if !is_server_only { + let client_lang_str = template_config.client_lang.as_ref().map(|l| l.as_str()); + if let Some(path) = + crate::subcommands::spacetime_config::setup_for_project(&project_path, client_lang_str, package_manager)? + { + println!("{} Created {}", "✓".green(), path.display()); + } } Ok(project_path) 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..356ef4a2abe --- /dev/null +++ b/crates/cli/src/subcommands/spacetime_config.rs @@ -0,0 +1,191 @@ +//! SpacetimeDB project configuration file handling. +//! +//! This module handles loading and saving `spacetime.json` 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::fmt; +use std::fs; +use std::path::{Path, PathBuf}; + +/// The filename for configuration +pub const CONFIG_FILENAME: &str = "spacetime.json"; + +/// Supported package managers for JavaScript/TypeScript projects +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PackageManager { + Npm, + Pnpm, + Yarn, + Bun, +} + +impl fmt::Display for PackageManager { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + PackageManager::Npm => "npm", + PackageManager::Pnpm => "pnpm", + PackageManager::Yarn => "yarn", + PackageManager::Bun => "bun", + }; + write!(f, "{s}") + } +} + +impl PackageManager { + /// Get the command to run a dev script + pub fn run_dev_command(&self) -> &'static str { + match self { + PackageManager::Npm => "npm run dev", + PackageManager::Pnpm => "pnpm run dev", + PackageManager::Yarn => "yarn dev", + PackageManager::Bun => "bun run dev", + } + } +} + +/// Root configuration structure for spacetime.json +/// +/// Example: +/// ```json +/// { +/// "run": "pnpm dev" +/// } +/// ``` +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct SpacetimeConfig { + /// The command to run the client development server. + /// This is used by `spacetime dev` to start the client after publishing. + /// Example: "npm run dev", "pnpm dev", "cargo run" + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run: Option, +} + +impl SpacetimeConfig { + /// Create a configuration with a run command + pub fn with_run_command(run_command: impl Into) -> Self { + Self { + run: Some(run_command.into()), + } + } + + /// Create a configuration for a specific client language. + /// Determines the appropriate run command based on the language and package manager. + pub fn for_client_lang(client_lang: &str, package_manager: Option) -> Self { + let run_command = match client_lang.to_lowercase().as_str() { + "typescript" => package_manager.map(|pm| pm.run_dev_command()).unwrap_or("npm run dev"), + "rust" => "cargo run", + "csharp" | "c#" => "dotnet run", + _ => "npm run dev", // default fallback + }; + Self { + run: Some(run_command.to_string()), + } + } + + /// 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 = + serde_json::from_str(&content).with_context(|| format!("Failed to parse {}", config_path.display()))?; + return Ok(Some(config)); + } + + Ok(None) + } + + /// Save configuration to `spacetime.json` in the specified directory. + pub fn save_to_dir(&self, dir: &Path) -> anyhow::Result { + let path = dir.join(CONFIG_FILENAME); + let content = serde_json::to_string_pretty(self).context("Failed to serialize configuration")?; + fs::write(&path, content).with_context(|| format!("Failed to write {}", path.display()))?; + Ok(path) + } +} + +/// Set up a spacetime.json config for a project. +/// If `client_lang` is provided, creates a config for that language. +/// Otherwise, attempts to auto-detect from package.json. +/// Returns the path to the created config, or None if no config was created. +pub fn setup_for_project( + project_path: &Path, + client_lang: Option<&str>, + package_manager: Option, +) -> anyhow::Result> { + if let Some(lang) = client_lang { + let config = SpacetimeConfig::for_client_lang(lang, package_manager); + return Ok(Some(config.save_to_dir(project_path)?)); + } + + if let Some((detected_cmd, _)) = detect_client_command(project_path) { + return Ok(Some( + SpacetimeConfig::with_run_command(&detected_cmd).save_to_dir(project_path)?, + )); + } + + Ok(None) +} + +/// Detect the package manager from lock files in the project directory. +pub fn detect_package_manager(project_dir: &Path) -> Option { + // Check for lock files in order of preference + if project_dir.join("pnpm-lock.yaml").exists() { + return Some(PackageManager::Pnpm); + } + if project_dir.join("yarn.lock").exists() { + return Some(PackageManager::Yarn); + } + if project_dir.join("bun.lockb").exists() || project_dir.join("bun.lock").exists() { + return Some(PackageManager::Bun); + } + if project_dir.join("package-lock.json").exists() { + return Some(PackageManager::Npm); + } + // Default to npm if package.json exists but no lock file + if project_dir.join("package.json").exists() { + return Some(PackageManager::Npm); + } + None +} + +/// Simple auto-detection for projects without `spacetime.json`. +/// Returns the client command and optionally the detected package manager. +pub fn detect_client_command(project_dir: &Path) -> Option<(String, 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) { + let has_dev = json.get("scripts").and_then(|s| s.get("dev")).is_some(); + if has_dev { + let pm = detect_package_manager(project_dir); + let cmd = pm.map(|p| p.run_dev_command()).unwrap_or("npm run dev"); + return Some((cmd.to_string(), pm)); + } + } + } + } + + // Rust: Cargo.toml + if project_dir.join("Cargo.toml").exists() { + return Some(("cargo run".to_string(), None)); + } + + // 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)); + } + } + } + + None +} 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..214a1b86f3c 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,28 @@ 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.json` file in your project root: + +```json +{ + "run": "npm run dev" +} +``` + +The client command can be: +- Auto-detected from your project (package.json, Cargo.toml, .csproj) +- Configured in `spacetime.json` +- 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, a default client command is automatically configured in `spacetime.json` based on your project type. + ### Project Structure After initialization, your project will contain: @@ -114,6 +133,7 @@ my-project/ │ └── module_bindings/ # Generated client bindings ├── package.json ├── tsconfig.json +├── spacetime.json # SpacetimeDB configuration └── README.md ``` @@ -128,6 +148,7 @@ my-project/ ├── module_bindings/ # Generated client bindings ├── client.csproj ├── Program.cs +├── spacetime.json # SpacetimeDB configuration └── README.md ``` @@ -143,6 +164,7 @@ my-project/ ├── src/ # Client code │ └── module_bindings/ # Generated client bindings ├── Cargo.toml +├── spacetime.json # 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..c8a9f299144 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