diff --git a/Cargo.lock b/Cargo.lock index a8b001bd7a2..b1c73efb22b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3534,6 +3534,17 @@ dependencies = [ "memchr", ] +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + [[package]] name = "junction" version = "1.3.0" @@ -5115,6 +5126,49 @@ dependencies = [ "spacetimedb 1.11.3", ] +[[package]] +name = "pest" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.107", +] + +[[package]] +name = "pest_meta" +version = "2.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365" +dependencies = [ + "pest", + "sha2", +] + [[package]] name = "petgraph" version = "0.6.5" @@ -7475,6 +7529,7 @@ dependencies = [ "indicatif", "is-terminal", "itertools 0.12.1", + "json5", "mimalloc", "names", "notify 7.0.0", @@ -9637,6 +9692,12 @@ dependencies = [ "combine", ] +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unarray" version = "0.1.4" diff --git a/Cargo.toml b/Cargo.toml index a63949f1fcd..1da834ed4cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -213,6 +213,7 @@ insta = { version = "1.21.0", features = ["toml", "filters"] } is-terminal = "0.4" itertools = "0.12" itoa = "1" +json5 = "0.4" jsonwebtoken = { package = "spacetimedb-jsonwebtoken", version = "9.3.0" } junction = "1" jwks = { package = "spacetimedb-jwks", version = "0.1.3" } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 82a81706439..a7bea4513df 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -49,6 +49,7 @@ http.workspace = true is-terminal.workspace = true itertools.workspace = true indicatif.workspace = true +json5.workspace = true jsonwebtoken.workspace = true mimalloc.workspace = true percent-encoding.workspace = true diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 51338dfe289..175bcf14fe6 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -4,6 +4,7 @@ mod config; pub(crate) mod detect; mod edit_distance; mod errors; +pub mod spacetime_config; mod subcommands; mod tasks; pub mod util; diff --git a/crates/cli/src/spacetime_config.rs b/crates/cli/src/spacetime_config.rs new file mode 100644 index 00000000000..fb3da6bc24a --- /dev/null +++ b/crates/cli/src/spacetime_config.rs @@ -0,0 +1,2110 @@ +use anyhow::Context; +use clap::{ArgMatches, Command, ValueEnum}; +use json5; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::any::TypeId; +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +use crate::subcommands::generate::Language; + +/// 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", + } + } +} + +/// Errors that can occur when building or using CommandConfig +#[derive(Debug, Error)] +pub enum CommandConfigError { + #[error("The option `--{arg_name}` is defined in Clap, but not in the config. If this is intentional and the option shouldn't be available in the config, you can exclude it with the `CommandConfigBuilder::exclude` function")] + ClapArgNotDefined { arg_name: String }, + + #[error("Key '{config_name}' references clap argument '{clap_name}' which doesn't exist in the Command. If the config key should be different than the clap argument, use from_clap()")] + InvalidClapReference { config_name: String, clap_name: String }, + + #[error("Key '{config_name}' has alias '{alias}' which doesn't exist in the Command")] + InvalidAliasReference { config_name: String, alias: String }, + + #[error("Excluded key '{key}' doesn't exist in the clap Command")] + InvalidExclusion { key: String }, + + #[error("Config key '{config_key}' is not supported in the config file. Available keys: {available_keys}")] + UnsupportedConfigKey { config_key: String, available_keys: String }, + + #[error("Required key '{key}' is missing from the config file")] + MissingRequiredKey { key: String }, + + #[error("Mismatch between definition and access of `{key}`. Could not downcast to {requested_type}, need to downcast to {expected_type}")] + TypeMismatch { + key: String, + requested_type: String, + expected_type: String, + }, + + #[error("Failed to convert config value for key '{key}' to type {target_type}")] + ConversionError { + key: String, + target_type: String, + #[source] + source: anyhow::Error, + }, +} + +/// Project configuration loaded from spacetime.json. +/// +/// Example: +/// ```json +/// { +/// "dev": { +/// "run": "pnpm dev" +/// }, +/// "generate": [ +/// { +/// "language": "typescript", +/// "out-dir": "./src/module_bindings" +/// } +/// ], +/// "publish": { +/// "database": "my-database", +/// "server": "https://testnet.spacetimedb.com" +/// } +/// } +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct SpacetimeConfig { + /// Configuration for the dev command. + #[serde(skip_serializing_if = "Option::is_none")] + pub dev: Option, + + /// List of generate configurations for creating client bindings. + /// Each entry configures code generation for a specific language. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate: Option>>, + + /// Configuration for publishing the database. + /// Can include nested children for multi-database configurations. + #[serde(skip_serializing_if = "Option::is_none")] + pub publish: Option, +} + +/// Configuration for `spacetime dev` command. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct DevConfig { + /// 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(skip_serializing_if = "Option::is_none")] + pub run: Option, +} + +/// Configuration for `spacetime publish` command. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub struct PublishConfig { + /// Child databases + #[serde(skip_serializing_if = "Option::is_none")] + pub children: Option>, + + /// Configuration fields + #[serde(flatten)] + pub additional_fields: HashMap, +} + +impl PublishConfig { + /// Iterate through all publish targets (self + children recursively). + /// Returns an iterator that yields references to PublishConfig instances. + pub fn iter_all_targets(&self) -> Box + '_> { + Box::new( + std::iter::once(self).chain( + self.children + .iter() + .flat_map(|children| children.iter()) + .flat_map(|child| child.iter_all_targets()), + ), + ) + } + + /// Count total number of targets (self + all descendants) + pub fn count_targets(&self) -> usize { + 1 + self + .children + .as_ref() + .map(|children| children.iter().map(|child| child.count_targets()).sum()) + .unwrap_or(0) + } +} + +/// A unified config that merges clap arguments with config file values. +/// Provides a `get_one::(key)` interface similar to clap's ArgMatches. +/// CLI arguments take precedence over config file values. +#[derive(Debug)] +pub struct CommandConfig<'a> { + /// Schema defining the contract between CLI and config + schema: &'a CommandSchema, + /// Config file values + config_values: HashMap, + /// CLI arguments + matches: &'a ArgMatches, +} + +/// Schema that defines the contract between CLI arguments and config file keys. +/// Does not hold ArgMatches - methods take matches as a parameter instead. +#[derive(Debug)] +pub struct CommandSchema { + /// Key definitions + keys: Vec, + /// Type information for validation (keyed by config name) + type_map: HashMap, + /// Map from config name to clap arg name (for from_clap mapping) + config_to_clap: HashMap, + /// Map from config name to alias (for alias mapping) + config_to_alias: HashMap, +} + +/// Builder for creating a CommandSchema with custom mappings and exclusions. +pub struct CommandSchemaBuilder { + /// Keys defined for this command + keys: Vec, + /// Set of keys to exclude from being read from the config file + excluded_keys: HashSet, +} + +impl CommandSchemaBuilder { + pub fn new() -> Self { + Self { + keys: Vec::new(), + excluded_keys: HashSet::new(), + } + } + + /// Add a key definition to the builder. + /// Example: `.key(Key::new::("server"))` + pub fn key(mut self, key: Key) -> Self { + self.keys.push(key); + self + } + + /// Exclude a key from being read from the config file. + /// This is useful for keys that should only come from CLI arguments. + pub fn exclude(mut self, key: impl Into) -> Self { + self.excluded_keys.insert(key.into()); + self + } + + /// Build a CommandSchema by validating against the clap Command. + /// + /// # Arguments + /// * `command` - The clap Command to validate against + pub fn build(self, command: &Command) -> Result { + // Collect all clap argument names for validation + let clap_arg_names: HashSet = command + .get_arguments() + .map(|arg| arg.get_id().as_str().to_string()) + .collect(); + + // Check that all the defined keys exist in clap + for key in &self.keys { + if !clap_arg_names.contains(key.clap_arg_name()) { + return Err(CommandConfigError::InvalidClapReference { + config_name: key.config_name().to_string(), + clap_name: key.clap_arg_name().to_string(), + }); + } + + // Validate alias if present + if let Some(alias) = &key.clap_alias { + if !clap_arg_names.contains(alias) { + return Err(CommandConfigError::InvalidAliasReference { + config_name: key.config_name().to_string(), + alias: alias.clone(), + }); + } + } + } + + // Validate exclusions reference valid clap arguments + for excluded_key in &self.excluded_keys { + if !clap_arg_names.contains(excluded_key) { + return Err(CommandConfigError::InvalidExclusion { + key: excluded_key.clone(), + }); + } + } + + let mut type_map = HashMap::new(); + // A list of clap args that are referenced by the config keys + let mut referenced_clap_args = HashSet::new(); + let mut config_to_clap_map = HashMap::new(); + let mut config_to_alias_map = HashMap::new(); + + for key in &self.keys { + let config_name = key.config_name().to_string(); + let clap_name = key.clap_arg_name().to_string(); + + referenced_clap_args.insert(clap_name.clone()); + type_map.insert(config_name.clone(), key.type_id()); + + // Track the mapping from config name to clap arg name (if using from_clap) + if key.clap_name.is_some() { + config_to_clap_map.insert(config_name.clone(), clap_name.clone()); + } + + // Register the alias if present + if let Some(alias) = &key.clap_alias { + referenced_clap_args.insert(alias.clone()); + config_to_alias_map.insert(config_name.clone(), alias.clone()); + } + } + + // Check that all clap arguments are either referenced or excluded + for arg in command.get_arguments() { + let arg_name = arg.get_id().as_str(); + + // Skip clap's built-in arguments + if arg_name == "help" || arg_name == "version" { + continue; + } + + if !referenced_clap_args.contains(arg_name) && !self.excluded_keys.contains(arg_name) { + return Err(CommandConfigError::ClapArgNotDefined { + arg_name: arg_name.to_string(), + }); + } + } + + Ok(CommandSchema { + keys: self.keys, + type_map, + config_to_clap: config_to_clap_map, + config_to_alias: config_to_alias_map, + }) + } +} + +impl Default for CommandSchemaBuilder { + fn default() -> Self { + Self::new() + } +} + +impl CommandSchema { + /// Get a value from clap arguments only (not from config). + /// Useful for filtering or checking if a value was provided via CLI. + pub fn get_clap_arg( + &self, + matches: &ArgMatches, + config_name: &str, + ) -> Result, CommandConfigError> { + let requested_type_id = TypeId::of::(); + + // Validate type if we have type information + if let Some(&expected_type_id) = self.type_map.get(config_name) { + if requested_type_id != expected_type_id { + let expected_type_name = type_name_from_id(expected_type_id); + let requested_type_name = std::any::type_name::(); + + return Err(CommandConfigError::TypeMismatch { + key: config_name.to_string(), + requested_type: requested_type_name.to_string(), + expected_type: expected_type_name.to_string(), + }); + } + } + + // Check clap with mapped name (if from_clap was used, use that name, otherwise use config name) + let clap_name = self + .config_to_clap + .get(config_name) + .map(|s| s.as_str()) + .unwrap_or(config_name); + + // Only return the value if it was actually provided by the user, not from defaults + if let Some(source) = matches.value_source(clap_name) { + if source == clap::parser::ValueSource::CommandLine { + if let Some(value) = matches.get_one::(clap_name) { + return Ok(Some(value.clone())); + } + } + } + + // Try clap with the alias if it exists + if let Some(alias) = self.config_to_alias.get(config_name) { + if let Some(source) = matches.value_source(alias) { + if source == clap::parser::ValueSource::CommandLine { + if let Some(value) = matches.get_one::(alias) { + return Ok(Some(value.clone())); + } + } + } + } + + Ok(None) + } + + /// Check if a value was provided via CLI (not from config). + /// Only returns true if the user explicitly provided the value, not if it came from a default. + pub fn is_from_cli(&self, matches: &ArgMatches, config_name: &str) -> bool { + // Check clap with mapped name + let clap_name = self + .config_to_clap + .get(config_name) + .map(|s| s.as_str()) + .unwrap_or(config_name); + + // Use value_source to check if the value was actually provided by the user + if let Some(source) = matches.value_source(clap_name) { + if source == clap::parser::ValueSource::CommandLine { + return true; + } + } + + // Check clap with alias + if let Some(alias) = self.config_to_alias.get(config_name) { + if let Some(source) = matches.value_source(alias) { + if source == clap::parser::ValueSource::CommandLine { + return true; + } + } + } + + false + } + + /// Get all module-specific keys that were provided via CLI. + pub fn module_specific_cli_args(&self, matches: &ArgMatches) -> Vec<&str> { + self.keys + .iter() + .filter(|k| k.module_specific && self.is_from_cli(matches, k.config_name())) + .map(|k| k.config_name()) + .collect() + } +} + +/// Configuration for a single key in the CommandConfig. +#[derive(Debug, Clone)] +pub struct Key { + /// The key name in the config file (e.g., "module-path") + config_name: String, + /// The corresponding clap argument name (e.g., "project-path"), if different + clap_name: Option, + /// Alias for a clap argument, useful for example if we have to deprecate a clap + /// argument and still allow to use it in the CLI args, but not in the config file + clap_alias: Option, + /// Whether this key is module-specific + module_specific: bool, + /// Whether this key is required in the config file + required: bool, + /// The expected TypeId for this key + type_id: TypeId, +} + +impl Key { + /// Returns a new Key instance + pub fn new(name: impl Into) -> Self { + Self { + config_name: name.into(), + clap_name: None, + clap_alias: None, + module_specific: false, + required: false, + type_id: TypeId::of::(), + } + } + + /// Map this config key to a different clap argument name. When fetching values + /// the key that is defined should be used. + /// Example: Key::new::("module-path").from_clap("project-path") + /// - in this case the value for either project-path in clap or + /// for module-path in the config file will be fetched + pub fn from_clap(mut self, clap_arg_name: impl Into) -> Self { + self.clap_name = Some(clap_arg_name.into()); + self + } + + /// Add an alias for a clap argument name that also maps to this key. + /// This is useful for backwards compatibility when renaming arguments. + /// Example: Key::new::("module-path").alias("project-path") + /// + /// This allows both --module-path and --project-path to map to the same config key. + /// The value should then be accessed by using `module-path` + /// + /// The difference between from_clap and alias is that from_clap will work by mapping + /// a single value from clap, whereas alias will check both of them in the CLI args + pub fn alias(mut self, alias_name: impl Into) -> Self { + self.clap_alias = Some(alias_name.into()); + self + } + + /// Mark this key as module-specific. For example, the `js-bin` config option makes sense + /// only when applied to a single module. The `server` config option makes sense for + /// multiple publish targets + pub fn module_specific(mut self) -> Self { + self.module_specific = true; + self + } + + /// Mark this key as required in the config file. If a config file is provided but + /// this key is missing, an error will be returned. + pub fn required(mut self) -> Self { + self.required = true; + self + } + + /// Get the clap argument name (either the mapped name or the config name) + pub fn clap_arg_name(&self) -> &str { + self.clap_name.as_deref().unwrap_or(&self.config_name) + } + + /// Get the config name + pub fn config_name(&self) -> &str { + &self.config_name + } + + /// Get the type_id + pub fn type_id(&self) -> TypeId { + self.type_id + } + + /// Check if this key is required + pub fn is_required(&self) -> bool { + self.required + } +} + +impl<'a> CommandConfig<'a> { + /// Create a new CommandConfig by validating config values against a schema. + /// + /// # Arguments + /// * `schema` - The command schema that defines valid keys and types + /// * `config_values` - Values from the config file + /// * `matches` - CLI arguments + /// + /// # Errors + /// Returns an error if any config keys are not defined in the schema. + /// Note: Required key validation happens when get_one() is called, not during construction. + pub fn new( + schema: &'a CommandSchema, + config_values: HashMap, + matches: &'a ArgMatches, + ) -> Result { + // Normalize keys from kebab-case to snake_case to match clap's Arg::new() convention + let normalized_values: HashMap = config_values + .into_iter() + .map(|(k, v)| (k.replace('-', "_"), v)) + .collect(); + + // Build set of valid config keys from schema + let valid_config_keys: HashSet = schema.keys.iter().map(|k| k.config_name().to_string()).collect(); + + // Check that all keys in config file are defined in schema + for config_key in normalized_values.keys() { + if !valid_config_keys.contains(config_key) { + return Err(CommandConfigError::UnsupportedConfigKey { + config_key: config_key.clone(), + available_keys: valid_config_keys + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", "), + }); + } + } + + Ok(CommandConfig { + schema, + config_values: normalized_values, + matches, + }) + } + + /// Get a single value from the config as a specific type. + /// First checks clap args (via schema), then falls back to config values. + /// Validates that the requested type matches the schema definition. + /// + /// Returns: + /// - Ok(Some(T)) if the value exists and can be converted + /// - Ok(None) if the value doesn't exist in either clap or config + /// - Err if the type doesn't match or conversion fails + pub fn get_one(&self, key: &str) -> Result, CommandConfigError> { + // Try clap arguments first (CLI takes precedence) via schema + let from_cli = self.schema.get_clap_arg::(self.matches, key)?; + if let Some(ref value) = from_cli { + return Ok(Some(value.clone())); + } + + // Fall back to config values using the config name + if let Some(value) = self.config_values.get(key) { + from_json_value::(value) + .map_err(|source| CommandConfigError::ConversionError { + key: key.to_string(), + target_type: std::any::type_name::().to_string(), + source, + }) + .map(Some) + } else { + Ok(None) + } + } + + /// Check if a key exists in either clap or config. + pub fn contains(&self, matches: &ArgMatches, key: &str) -> bool { + // Check if provided via CLI using schema + if self.schema.is_from_cli(matches, key) { + return true; + } + + // Check config key + self.config_values.contains_key(key) + } + + /// Get a config value (from config file only, not merged with CLI). + /// + /// This is useful for filtering scenarios where you need to compare + /// CLI values against config file values. + pub fn get_config_value(&self, key: &str) -> Option<&Value> { + self.config_values.get(key) + } + + /// Validate that all required keys are present in the config file. + /// Note: This only checks config file keys. CLI required validation is handled by clap. + pub fn validate(&self) -> Result<(), CommandConfigError> { + for key in &self.schema.keys { + if key.is_required() && !self.config_values.contains_key(key.config_name()) { + return Err(CommandConfigError::MissingRequiredKey { + key: key.config_name().to_string(), + }); + } + } + Ok(()) + } +} + +/// Helper to get a human-readable type name from a TypeId +fn type_name_from_id(type_id: TypeId) -> &'static str { + if type_id == TypeId::of::() { + "alloc::string::String" + } else if type_id == TypeId::of::() { + "std::path::PathBuf" + } else if type_id == TypeId::of::() { + "bool" + } else if type_id == TypeId::of::() { + "i64" + } else if type_id == TypeId::of::() { + "u64" + } else if type_id == TypeId::of::() { + "f64" + } else if type_id == TypeId::of::() { + "spacetimedb_cli::subcommands::generate::Language" + } else { + "unknown" + } +} + +/// Helper to convert JSON values to Rust types (for config file values) +fn from_json_value(value: &Value) -> anyhow::Result { + let type_id = TypeId::of::(); + + let any: Box = match type_id { + t if t == TypeId::of::() => Box::new(value.as_str().context("Expected string value")?.to_string()), + t if t == TypeId::of::() => Box::new(PathBuf::from( + value.as_str().context("Expected string value for PathBuf")?, + )), + t if t == TypeId::of::() => Box::new(value.as_bool().context("Expected boolean value")?), + t if t == TypeId::of::() => Box::new(value.as_i64().context("Expected i64 value")?), + t if t == TypeId::of::() => Box::new(value.as_u64().context("Expected u64 value")?), + t if t == TypeId::of::() => Box::new(value.as_f64().context("Expected f64 value")?), + t if t == TypeId::of::() => { + let s = value.as_str().context("Expected string value for Language")?; + // Use ValueEnum's from_str method which handles aliases automatically + let lang = Language::from_str(s, true).map_err(|_| anyhow::anyhow!("Invalid language: {}", s))?; + Box::new(lang) + } + _ => anyhow::bail!("Unsupported type for conversion from JSON"), + }; + + // Now downcast to T + any.downcast::() + .map(|boxed| *boxed) + .map_err(|_| anyhow::anyhow!("Failed to downcast value")) +} + +impl SpacetimeConfig { + /// Find and load a spacetime.json file. + /// + /// Searches for spacetime.json starting from the current directory + /// and walking up the directory tree until found or filesystem root is reached. + /// + /// Returns `Ok(Some((path, config)))` if found and successfully parsed. + /// Returns `Ok(None)` if not found. + /// Returns `Err` if found but failed to parse. + pub fn find_and_load() -> anyhow::Result> { + Self::find_and_load_from(std::env::current_dir()?) + } + + /// Find and load a spacetime.json file starting from a specific directory. + /// + /// Searches for spacetime.json starting from `start_dir` + /// and walking up the directory tree until found or filesystem root is reached. + pub fn find_and_load_from(start_dir: PathBuf) -> anyhow::Result> { + let mut current_dir = start_dir; + loop { + let config_path = current_dir.join("spacetime.json"); + if config_path.exists() { + let config = Self::load(&config_path) + .with_context(|| format!("Failed to parse config file: {}", config_path.display()))?; + return Ok(Some((config_path, config))); + } + + // Try to go up one directory + if !current_dir.pop() { + // Reached filesystem root + break; + } + } + Ok(None) + } + + /// Load a spacetime.json file from a specific path. + /// + /// The file must exist and be valid JSON5 format (supports comments). + pub fn load(path: &Path) -> anyhow::Result { + let content = + std::fs::read_to_string(path).with_context(|| format!("Failed to read config file: {}", path.display()))?; + + let config: Self = json5::from_str(&content) + .map_err(|e| anyhow::anyhow!("Failed to parse config file {}: {}", path.display(), e))?; + + Ok(config) + } + + /// Save the config to a file. + /// + /// The config will be serialized as pretty-printed JSON. + pub fn save(&self, path: &Path) -> anyhow::Result<()> { + let json = serde_json::to_string_pretty(self).context("Failed to serialize config")?; + + std::fs::write(path, json).with_context(|| format!("Failed to write config file: {}", path.display()))?; + + Ok(()) + } + + /// Create a spacetime.json file in the current directory with the given config. + pub fn create_in_current_dir(&self) -> anyhow::Result { + let config_path = std::env::current_dir()?.join("spacetime.json"); + self.save(&config_path)?; + Ok(config_path) + } + + /// Create a configuration with a run command for dev + pub fn with_run_command(run_command: impl Into) -> Self { + Self { + dev: Some(DevConfig { + run: Some(run_command.into()), + }), + ..Default::default() + } + } + + /// 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 { + dev: Some(DevConfig { + run: Some(run_command.to_string()), + }), + ..Default::default() + } + } + + /// 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() { + Self::load(&config_path).map(Some) + } else { + 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); + self.save(&path)?; + 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 +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Arg; + + #[test] + fn test_deserialize_full_config() { + let json = r#"{ + "dev": { + "run": "pnpm dev" + }, + "generate": [ + { + "out-dir": "./foobar", + "module-path": "region-module", + "language": "csharp" + }, + { + "out-dir": "./global", + "module-path": "global-module", + "language": "csharp" + } + ], + "publish": { + "database": "bitcraft", + "module-path": "spacetimedb", + "server": "local", + "children": [ + { + "database": "region-1", + "module-path": "region-module" + }, + { + "database": "region-2", + "module-path": "region-module" + } + ] + } + }"#; + + let config: SpacetimeConfig = json5::from_str(json).unwrap(); + + assert_eq!(config.dev.as_ref().and_then(|d| d.run.as_deref()), Some("pnpm dev")); + + let generate = config.generate.as_ref().unwrap(); + assert_eq!(generate.len(), 2); + assert_eq!(generate[0].get("out-dir").and_then(|v| v.as_str()), Some("./foobar")); + assert_eq!(generate[0].get("language").and_then(|v| v.as_str()), Some("csharp")); + + let publish = config.publish.as_ref().unwrap(); + assert_eq!( + publish.additional_fields.get("database").and_then(|v| v.as_str()), + Some("bitcraft") + ); + assert_eq!( + publish.additional_fields.get("module-path").and_then(|v| v.as_str()), + Some("spacetimedb") + ); + + let children = publish.children.as_ref().unwrap(); + assert_eq!(children.len(), 2); + assert_eq!( + children[0].additional_fields.get("database").and_then(|v| v.as_str()), + Some("region-1") + ); + assert_eq!( + children[1].additional_fields.get("database").and_then(|v| v.as_str()), + Some("region-2") + ); + } + + #[test] + fn test_deserialize_with_comments() { + let json = r#"{ + // This is a comment + "dev": { + "run": "npm start" + }, + /* Multi-line comment */ + "generate": [ + { + "out-dir": "./src/bindings", // inline comment + "language": "typescript" + } + ] + }"#; + + let config: SpacetimeConfig = json5::from_str(json).unwrap(); + assert_eq!(config.dev.as_ref().and_then(|d| d.run.as_deref()), Some("npm start")); + } + + #[test] + fn test_minimal_config() { + let json = r#"{}"#; + let config: SpacetimeConfig = json5::from_str(json).unwrap(); + + assert!(config.dev.is_none()); + assert!(config.generate.is_none()); + assert!(config.publish.is_none()); + } + + #[test] + fn test_project_config_builder() { + use clap::{Arg, Command}; + + // Create a simple clap command with some arguments + let cmd = Command::new("test") + .arg(Arg::new("out-dir").long("out-dir").value_name("DIR")) + .arg(Arg::new("lang").long("lang").value_name("LANG")) + .arg(Arg::new("server").long("server").value_name("SERVER")); + + let matches = cmd + .clone() + .get_matches_from(vec!["test", "--out-dir", "./bindings", "--lang", "typescript"]); + + // Build schema + let schema = CommandSchemaBuilder::new() + .key(Key::new::("language").from_clap("lang")) + .key(Key::new::("out-dir")) + .key(Key::new::("server")) + .build(&cmd) + .unwrap(); + + // Simulate config file values + let mut config_values = HashMap::new(); + config_values.insert("language".to_string(), Value::String("rust".to_string())); + config_values.insert("server".to_string(), Value::String("local".to_string())); + + // Create CommandConfig with schema + let command_config = CommandConfig::new(&schema, config_values, &matches).unwrap(); + + // CLI args should override config values + assert_eq!( + command_config.get_one::("out-dir").unwrap(), + Some("./bindings".to_string()) + ); + assert_eq!( + command_config.get_one::("language").unwrap(), + Some("typescript".to_string()) + ); // CLI overrides (use config name, not clap name) + assert_eq!( + command_config.get_one::("server").unwrap(), + Some("local".to_string()) + ); // from config + } + + #[test] + fn test_publish_config_extraction() { + use clap::{Arg, Command}; + + // Parse a PublishConfig from JSON + let json = r#"{ + "database": "my-database", + "server": "local", + "module-path": "./my-module", + "build-options": "--features extra", + "break-clients": true, + "anonymous": false + }"#; + + let publish_config: PublishConfig = json5::from_str(json).unwrap(); + + // Verify children field + assert!(publish_config.children.is_none()); + + // Verify all fields are in additional_fields + assert_eq!( + publish_config + .additional_fields + .get("database") + .and_then(|v| v.as_str()), + Some("my-database") + ); + assert_eq!( + publish_config.additional_fields.get("server").and_then(|v| v.as_str()), + Some("local") + ); + assert_eq!( + publish_config + .additional_fields + .get("module-path") + .and_then(|v| v.as_str()), + Some("./my-module") + ); + assert_eq!( + publish_config + .additional_fields + .get("build-options") + .and_then(|v| v.as_str()), + Some("--features extra") + ); + assert_eq!( + publish_config + .additional_fields + .get("break-clients") + .and_then(|v| v.as_bool()), + Some(true) + ); + + // Now test merging with clap args + let cmd = Command::new("test") + .arg(Arg::new("database").long("database")) + .arg(Arg::new("server").long("server")) + .arg(Arg::new("module_path").long("module-path")) + .arg(Arg::new("build_options").long("build-options")) + .arg(Arg::new("break_clients").long("break-clients")) + .arg(Arg::new("anon_identity").long("anonymous")); + + // CLI overrides the server + let matches = cmd.clone().get_matches_from(vec!["test", "--server", "maincloud"]); + + // Build schema with snake_case keys + let schema = CommandSchemaBuilder::new() + .key(Key::new::("database")) + .key(Key::new::("server")) + .key(Key::new::("module_path")) + .key(Key::new::("build_options")) + .key(Key::new::("break_clients")) + // Config uses "anonymous", clap uses "anon_identity" + .key(Key::new::("anonymous").from_clap("anon_identity")) + .build(&cmd) + .unwrap(); + + // Just pass the additional_fields directly - they will be normalized from kebab to snake_case + let command_config = CommandConfig::new(&schema, publish_config.additional_fields, &matches).unwrap(); + + // database comes from config + assert_eq!( + command_config.get_one::("database").unwrap(), + Some("my-database".to_string()) + ); + // server comes from CLI (overrides config) + assert_eq!( + command_config.get_one::("server").unwrap(), + Some("maincloud".to_string()) + ); + // module_path comes from config (kebab-case in JSON was normalized to snake_case) + assert_eq!( + command_config.get_one::("module_path").unwrap(), + Some("./my-module".to_string()) + ); + // build_options comes from config + assert_eq!( + command_config.get_one::("build_options").unwrap(), + Some("--features extra".to_string()) + ); + } + + #[test] + fn test_type_mismatch_error() { + use clap::{Arg, Command}; + + let cmd = Command::new("test").arg( + Arg::new("server") + .long("server") + .value_parser(clap::value_parser!(String)), + ); + + let matches = cmd.clone().get_matches_from(vec!["test", "--server", "local"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("server")) + .build(&cmd) + .unwrap(); + + let command_config = CommandConfig::new(&schema, HashMap::new(), &matches).unwrap(); + + // Trying to get as i64 when it's defined as String should error + let result = command_config.get_one::("server"); + assert!(matches!( + result.unwrap_err(), + CommandConfigError::TypeMismatch { key, requested_type, expected_type } + if key == "server" && requested_type.contains("i64") && expected_type.contains("String") + )); + } + + #[test] + fn test_schema_missing_key_definition_error() { + use clap::{Arg, Command}; + + // Define clap command with some arguments + let cmd = Command::new("test") + .arg( + Arg::new("server") + .long("server") + .value_parser(clap::value_parser!(String)), + ) + .arg(Arg::new("yes").long("yes").action(clap::ArgAction::SetTrue)); + + let matches = cmd.clone().get_matches_from(vec!["test"]); + + // Try to build schema but don't define all keys (missing "server" key) + let result = CommandSchemaBuilder::new() + .key(Key::new::("yes")) + // Missing .key(Key::new::("server")) + .build(&cmd); + + // This should error because "server" is in clap but not defined in the builder + // and not excluded + assert!(matches!( + result.unwrap_err(), + CommandConfigError::ClapArgNotDefined { arg_name } if arg_name == "server" + )); + } + + #[test] + fn test_key_with_clap_name_mapping() { + use clap::{Arg, Command}; + + // Clap uses "project-path" but config uses "module-path" + let cmd = Command::new("test").arg( + Arg::new("project-path") + .long("project-path") + .value_parser(clap::value_parser!(String)), + ); + + let matches = cmd + .clone() + .get_matches_from(vec!["test", "--project-path", "./my-project"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("module_path").from_clap("project-path")) + .build(&cmd) + .unwrap(); + + // Config file uses "module-path" (kebab-case, will be normalized to module_path) + let mut config_values = HashMap::new(); + config_values.insert("module-path".to_string(), Value::String("./config-project".to_string())); + + let command_config = CommandConfig::new(&schema, config_values, &matches).unwrap(); + + // CLI should override config, accessed via config name "module_path" (snake_case) + assert_eq!( + command_config.get_one::("module_path").unwrap(), + Some("./my-project".to_string()) + ); + } + + #[test] + fn test_clap_argument_with_alias() { + use clap::{Arg, Command}; + + // Argument with both long name and alias + let cmd = Command::new("test").arg( + Arg::new("module-path") + .long("module-path") + .alias("project-path") + .value_parser(clap::value_parser!(String)), + ); + + // Use the alias + let matches = cmd + .clone() + .get_matches_from(vec!["test", "--project-path", "./my-project"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("module-path")) + .build(&cmd) + .unwrap(); + + let command_config = CommandConfig::new(&schema, HashMap::new(), &matches).unwrap(); + + // Should be accessible via the primary name + assert_eq!( + command_config.get_one::("module-path").unwrap(), + Some("./my-project".to_string()) + ); + } + + #[test] + fn test_optional_argument_not_provided() { + use clap::{Arg, Command}; + + let cmd = Command::new("test").arg( + Arg::new("server") + .long("server") + .value_parser(clap::value_parser!(String)), + ); + + let matches = cmd.clone().get_matches_from(vec!["test"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("server")) + .build(&cmd) + .unwrap(); + + let command_config = CommandConfig::new(&schema, HashMap::new(), &matches).unwrap(); + + // Should return Ok(None) when optional argument not provided + assert_eq!(command_config.get_one::("server").unwrap(), None); + } + + #[test] + fn test_alias_support() { + use clap::{Arg, Command}; + + // Clap has both module-path and deprecated project-path + let cmd = Command::new("test") + .arg( + Arg::new("module-path") + .long("module-path") + .value_parser(clap::value_parser!(String)), + ) + .arg( + Arg::new("project-path") + .long("project-path") + .value_parser(clap::value_parser!(String)), + ); + + // User uses the deprecated --project-path flag + let matches = cmd + .clone() + .get_matches_from(vec!["test", "--project-path", "./deprecated"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("module-path").alias("project-path")) + .build(&cmd) + .unwrap(); + + let command_config = CommandConfig::new(&schema, HashMap::new(), &matches).unwrap(); + + // Should be able to get the value via the canonical name + assert_eq!( + command_config.get_one::("module-path").unwrap(), + Some("./deprecated".to_string()) + ); + } + + #[test] + fn test_alias_canonical_takes_precedence() { + use clap::{Arg, Command}; + + // Clap has both module-path and deprecated project-path + let cmd = Command::new("test") + .arg( + Arg::new("module-path") + .long("module-path") + .value_parser(clap::value_parser!(String)), + ) + .arg( + Arg::new("project-path") + .long("project-path") + .value_parser(clap::value_parser!(String)), + ); + + // User provides BOTH flags (shouldn't happen but let's test precedence) + let matches = cmd.clone().get_matches_from(vec![ + "test", + "--module-path", + "./canonical", + "--project-path", + "./deprecated", + ]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("module-path").alias("project-path")) + .build(&cmd) + .unwrap(); + + let command_config = CommandConfig::new(&schema, HashMap::new(), &matches).unwrap(); + + // Canonical name should take precedence + assert_eq!( + command_config.get_one::("module-path").unwrap(), + Some("./canonical".to_string()) + ); + } + + #[test] + fn test_alias_with_config_fallback() { + use clap::{Arg, Command}; + + // Clap has both module_path and deprecated project-path as alias + let cmd = Command::new("test") + .arg( + Arg::new("module_path") + .long("module-path") + .value_parser(clap::value_parser!(String)), + ) + .arg( + Arg::new("project-path") + .long("project-path") + .value_parser(clap::value_parser!(String)), + ); + + // User doesn't provide CLI args + let matches = cmd.clone().get_matches_from(vec!["test"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("module_path").alias("project-path")) + .build(&cmd) + .unwrap(); + + // Config has the value (kebab-case will be normalized) + let mut config_values = HashMap::new(); + config_values.insert("module-path".to_string(), Value::String("./from-config".to_string())); + + let command_config = CommandConfig::new(&schema, config_values, &matches).unwrap(); + + // Should fall back to config + assert_eq!( + command_config.get_one::("module_path").unwrap(), + Some("./from-config".to_string()) + ); + } + + #[test] + fn test_schema_invalid_from_clap_reference() { + use clap::{Arg, Command}; + + let cmd = Command::new("test").arg( + Arg::new("server") + .long("server") + .value_parser(clap::value_parser!(String)), + ); + + let matches = cmd.clone().get_matches_from(vec!["test"]); + + // Try to map to a non-existent clap arg + let result = CommandSchemaBuilder::new() + .key(Key::new::("module-path").from_clap("non-existent")) + .exclude("server") // Exclude the server arg we're not using + .build(&cmd); + + assert!(matches!( + result.unwrap_err(), + CommandConfigError::InvalidClapReference { config_name, clap_name } + if config_name == "module-path" && clap_name == "non-existent" + )); + } + + #[test] + fn test_schema_invalid_alias_reference() { + use clap::{Arg, Command}; + + let cmd = Command::new("test").arg( + Arg::new("module-path") + .long("module-path") + .value_parser(clap::value_parser!(String)), + ); + + let matches = cmd.clone().get_matches_from(vec!["test"]); + + // Try to alias a non-existent clap arg + let result = CommandSchemaBuilder::new() + .key(Key::new::("module-path").alias("non-existent-alias")) + .build(&cmd); + + assert!(matches!( + result.unwrap_err(), + CommandConfigError::InvalidAliasReference { config_name, alias } + if config_name == "module-path" && alias == "non-existent-alias" + )); + } + + #[test] + fn test_undefined_config_key_error() { + use clap::{Arg, Command}; + + let cmd = Command::new("test").arg( + Arg::new("server") + .long("server") + .value_parser(clap::value_parser!(String)), + ); + + let matches = cmd.clone().get_matches_from(vec!["test"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("server")) + .build(&cmd) + .unwrap(); + + // Config has a key that's not defined in CommandConfig + let mut config_values = HashMap::new(); + config_values.insert("server".to_string(), Value::String("local".to_string())); + config_values.insert("undefined-key".to_string(), Value::String("value".to_string())); + + let result = CommandConfig::new(&schema, config_values, &matches); + + // After normalization, "undefined-key" becomes "undefined_key" + assert!(matches!( + result.unwrap_err(), + CommandConfigError::UnsupportedConfigKey { config_key, .. } + if config_key == "undefined_key" + )); + } + + #[test] + fn test_schema_from_clap_with_wrong_arg_name() { + use clap::{Arg, Command}; + + // Command has "lang" argument + let cmd = Command::new("test").arg(Arg::new("lang").long("lang").value_parser(clap::value_parser!(String))); + + let matches = cmd.clone().get_matches_from(vec!["test"]); + + // Try to create a key that references "language" via from_clap, but clap has "lang" + let result = CommandSchemaBuilder::new() + .key(Key::new::("lang").from_clap("language")) + .build(&cmd); + + // Should fail because "language" doesn't exist in the Command + assert!(matches!( + result.unwrap_err(), + CommandConfigError::InvalidClapReference { config_name, clap_name } + if config_name == "lang" && clap_name == "language" + )); + } + + #[test] + fn test_excluded_key_in_config_should_error() { + use clap::{Arg, Command}; + + let cmd = Command::new("test") + .arg(Arg::new("yes").long("yes").action(clap::ArgAction::SetTrue)) + .arg(Arg::new("server").long("server").value_name("SERVER")); + + let matches = cmd.clone().get_matches_from(vec!["test"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("server")) + .exclude("yes") + .build(&cmd) + .unwrap(); + + // Config has yes, which is excluded + let mut config_values = HashMap::new(); + config_values.insert("yes".to_string(), Value::Bool(true)); + config_values.insert("server".to_string(), Value::String("local".to_string())); + + let result = CommandConfig::new(&schema, config_values, &matches); + + // Should error because "yes" is excluded and shouldn't be in config + assert!(matches!( + result.unwrap_err(), + CommandConfigError::UnsupportedConfigKey { config_key, .. } + if config_key == "yes" + )); + } + + #[test] + fn test_schema_get_clap_arg() { + use clap::{Arg, Command}; + + let cmd = Command::new("test") + .arg( + Arg::new("server") + .long("server") + .value_parser(clap::value_parser!(String)), + ) + .arg(Arg::new("port").long("port").value_parser(clap::value_parser!(i64))); + + let matches = cmd + .clone() + .get_matches_from(vec!["test", "--server", "localhost", "--port", "8080"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("server")) + .key(Key::new::("port")) + .build(&cmd) + .unwrap(); + + // Should get values from CLI + assert_eq!( + schema.get_clap_arg::(&matches, "server").unwrap(), + Some("localhost".to_string()) + ); + assert_eq!(schema.get_clap_arg::(&matches, "port").unwrap(), Some(8080)); + } + + #[test] + fn test_schema_is_from_cli() { + use clap::{Arg, Command}; + + let cmd = Command::new("test") + .arg( + Arg::new("server") + .long("server") + .value_parser(clap::value_parser!(String)), + ) + .arg(Arg::new("port").long("port").value_parser(clap::value_parser!(i64))); + + let matches = cmd.clone().get_matches_from(vec!["test", "--server", "localhost"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("server")) + .key(Key::new::("port")) + .build(&cmd) + .unwrap(); + + // server was provided via CLI + assert!(schema.is_from_cli(&matches, "server")); + // port was not provided + assert!(!schema.is_from_cli(&matches, "port")); + } + + #[test] + fn test_schema_module_specific_cli_args() { + use clap::{Arg, Command}; + + let cmd = Command::new("test") + .arg( + Arg::new("server") + .long("server") + .value_parser(clap::value_parser!(String)), + ) + .arg( + Arg::new("module-path") + .long("module-path") + .value_parser(clap::value_parser!(String)), + ) + .arg( + Arg::new("database") + .long("database") + .value_parser(clap::value_parser!(String)), + ); + + let matches = cmd + .clone() + .get_matches_from(vec!["test", "--module-path", "./module", "--server", "local"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("server")) + .key(Key::new::("module-path").module_specific()) + .key(Key::new::("database")) + .build(&cmd) + .unwrap(); + + let module_specific = schema.module_specific_cli_args(&matches); + assert_eq!(module_specific.len(), 1); + assert!(module_specific.contains(&"module-path")); + } + + #[test] + fn test_schema_get_clap_arg_with_from_clap() { + use clap::{Arg, Command}; + + let cmd = Command::new("test").arg(Arg::new("name").long("name").value_parser(clap::value_parser!(String))); + + let matches = cmd.clone().get_matches_from(vec!["test", "--name", "my-db"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("database").from_clap("name")) + .build(&cmd) + .unwrap(); + + // Should get value using config name, which maps to clap arg "name" + assert_eq!( + schema.get_clap_arg::(&matches, "database").unwrap(), + Some("my-db".to_string()) + ); + } + + #[test] + fn test_schema_get_clap_arg_with_alias() { + use clap::{Arg, Command}; + + let cmd = Command::new("test") + .arg( + Arg::new("module-path") + .long("module-path") + .value_parser(clap::value_parser!(String)), + ) + .arg( + Arg::new("project-path") + .long("project-path") + .value_parser(clap::value_parser!(String)), + ); + + let matches = cmd + .clone() + .get_matches_from(vec!["test", "--project-path", "./my-project"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("module-path").alias("project-path")) + .build(&cmd) + .unwrap(); + + // Should get value from alias + assert_eq!( + schema.get_clap_arg::(&matches, "module-path").unwrap(), + Some("./my-project".to_string()) + ); + } + + #[test] + fn test_schema_invalid_exclusion() { + use clap::{Arg, Command}; + + let cmd = Command::new("test").arg( + Arg::new("server") + .long("server") + .value_parser(clap::value_parser!(String)), + ); + + let matches = cmd.clone().get_matches_from(vec!["test"]); + + // Try to exclude a non-existent arg + let result = CommandSchemaBuilder::new() + .key(Key::new::("server")) + .exclude("non-existent") + .build(&cmd); + + assert!(matches!( + result.unwrap_err(), + CommandConfigError::InvalidExclusion { key } if key == "non-existent" + )); + } + + #[test] + fn test_config_value_type_conversion_error() { + use clap::{Arg, Command}; + + let cmd = Command::new("test").arg(Arg::new("port").long("port").value_parser(clap::value_parser!(i64))); + + let matches = cmd.clone().get_matches_from(vec!["test"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("port")) + .build(&cmd) + .unwrap(); + + // Config has a string value for port, but clap expects i64 + let mut config_values = HashMap::new(); + config_values.insert("port".to_string(), Value::String("not-a-number".to_string())); + + let command_config = CommandConfig::new(&schema, config_values, &matches).unwrap(); + + // Should error when trying to convert invalid value + let result = command_config.get_one::("port"); + assert!(matches!( + result.unwrap_err(), + CommandConfigError::ConversionError { key, target_type, .. } + if key == "port" && target_type.contains("i64") + )); + } + + #[test] + fn test_validate_required_key_missing() { + use clap::{Arg, Command}; + + let cmd = Command::new("test") + .arg( + Arg::new("database") + .long("database") + .value_parser(clap::value_parser!(String)), + ) + .arg( + Arg::new("server") + .long("server") + .value_parser(clap::value_parser!(String)), + ); + + let matches = cmd.clone().get_matches_from(vec!["test"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("database").required()) + .key(Key::new::("server")) + .build(&cmd) + .unwrap(); + + // Config is missing the required "database" key + let config_values = HashMap::new(); + let command_config = CommandConfig::new(&schema, config_values, &matches).unwrap(); + + // Should error on validation + let result = command_config.validate(); + assert!(matches!( + result.unwrap_err(), + CommandConfigError::MissingRequiredKey { key } + if key == "database" + )); + } + + #[test] + fn test_validate_required_key_present() { + use clap::{Arg, Command}; + + let cmd = Command::new("test") + .arg( + Arg::new("database") + .long("database") + .value_parser(clap::value_parser!(String)), + ) + .arg( + Arg::new("server") + .long("server") + .value_parser(clap::value_parser!(String)), + ); + + let matches = cmd.clone().get_matches_from(vec!["test"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("database").required()) + .key(Key::new::("server")) + .build(&cmd) + .unwrap(); + + // Config has the required database key + let mut config_values = HashMap::new(); + config_values.insert("database".to_string(), Value::String("my-db".to_string())); + + let command_config = CommandConfig::new(&schema, config_values, &matches).unwrap(); + + // Should succeed on validation + assert!(command_config.validate().is_ok()); + } + + #[test] + fn test_validate_no_required_keys() { + use clap::{Arg, Command}; + + let cmd = Command::new("test").arg( + Arg::new("server") + .long("server") + .value_parser(clap::value_parser!(String)), + ); + + let matches = cmd.clone().get_matches_from(vec!["test"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("server")) + .build(&cmd) + .unwrap(); + + // No required keys, empty config should be fine + let config_values = HashMap::new(); + let command_config = CommandConfig::new(&schema, config_values, &matches).unwrap(); + + // Should succeed on validation + assert!(command_config.validate().is_ok()); + } + + #[test] + fn test_default_values_not_treated_as_cli() { + use clap::{Arg, Command}; + use std::path::PathBuf; + + // Create a command with a default value + let cmd = Command::new("test") + .arg( + Arg::new("project_path") + .long("project-path") + .value_parser(clap::value_parser!(PathBuf)) + .default_value("."), + ) + .arg( + Arg::new("build_options") + .long("build-options") + .value_parser(clap::value_parser!(String)) + .default_value(""), + ); + + // Get matches WITHOUT providing the arguments + let matches = cmd.clone().get_matches_from(vec!["test"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("project_path")) + .key(Key::new::("build_options")) + .build(&cmd) + .unwrap(); + + // Config file has values + let mut config_values = HashMap::new(); + config_values.insert("project_path".to_string(), Value::String("./my-module".to_string())); + config_values.insert("build_options".to_string(), Value::String("--release".to_string())); + + let command_config = CommandConfig::new(&schema, config_values, &matches).unwrap(); + + // Default values should NOT override config values + assert_eq!( + command_config.get_one::("project_path").unwrap(), + Some(PathBuf::from("./my-module")) + ); + assert_eq!( + command_config.get_one::("build_options").unwrap(), + Some("--release".to_string()) + ); + + // is_from_cli should return false for default values + assert!(!schema.is_from_cli(&matches, "project_path")); + assert!(!schema.is_from_cli(&matches, "build_options")); + } + + #[test] + fn test_module_specific_only_checks_cli() { + use clap::{Arg, Command}; + use std::path::PathBuf; + + let cmd = Command::new("test") + .arg( + Arg::new("project_path") + .long("project-path") + .value_parser(clap::value_parser!(PathBuf)) + .default_value("."), + ) + .arg( + Arg::new("build_options") + .long("build-options") + .value_parser(clap::value_parser!(String)) + .default_value(""), + ); + + // Test 1: No CLI args provided (only defaults) + let matches_no_cli = cmd.clone().get_matches_from(vec!["test"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("project_path").module_specific()) + .key(Key::new::("build_options").module_specific()) + .build(&cmd) + .unwrap(); + + // module_specific_cli_args should be empty when only defaults are present + let module_specific = schema.module_specific_cli_args(&matches_no_cli); + assert!(module_specific.is_empty()); + + // Test 2: CLI args actually provided + let matches_with_cli = cmd.clone().get_matches_from(vec![ + "test", + "--project-path", + "./custom", + "--build-options", + "release-mode", + ]); + + let module_specific = schema.module_specific_cli_args(&matches_with_cli); + assert_eq!(module_specific.len(), 2); + assert!(module_specific.contains(&"project_path")); + assert!(module_specific.contains(&"build_options")); + } + + #[test] + fn test_kebab_case_normalization() { + use clap::{Arg, Command}; + + let cmd = Command::new("test").arg( + Arg::new("build_options") + .long("build-options") + .value_parser(clap::value_parser!(String)), + ); + + let matches = cmd.clone().get_matches_from(vec!["test"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("build_options")) + .build(&cmd) + .unwrap(); + + // Config file uses kebab-case + let mut config_values = HashMap::new(); + config_values.insert("build-options".to_string(), Value::String("--release".to_string())); + + // The normalization in CommandConfig::new should convert build-options to build_options + let command_config = CommandConfig::new(&schema, config_values, &matches).unwrap(); + + // Should be able to access via snake_case key + assert_eq!( + command_config.get_one::("build_options").unwrap(), + Some("--release".to_string()) + ); + } + + // CommandSchema Tests + + #[test] + fn test_invalid_clap_reference_caught() { + let cmd = Command::new("test").arg( + Arg::new("valid_arg") + .long("valid-arg") + .value_parser(clap::value_parser!(String)), + ); + + let result = CommandSchemaBuilder::new() + .key(Key::new::("nonexistent_arg")) + .build(&cmd); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + CommandConfigError::InvalidClapReference { .. } + )); + } + + #[test] + fn test_invalid_alias_reference_caught() { + let cmd = Command::new("test").arg(Arg::new("name").long("name").value_parser(clap::value_parser!(String))); + + // Reference a valid arg (name) but add invalid alias (nonexistent) via .alias() + let result = CommandSchemaBuilder::new() + .key(Key::new::("my_key").from_clap("name").alias("nonexistent")) + .build(&cmd); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, CommandConfigError::InvalidAliasReference { .. })); + } + + // CommandConfig Tests + + #[test] + fn test_get_one_returns_none_when_missing_from_both_sources() { + let cmd = Command::new("test").arg( + Arg::new("some_arg") + .long("some-arg") + .value_parser(clap::value_parser!(String)), + ); + + let matches = cmd.clone().get_matches_from(vec!["test"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("some_arg")) + .build(&cmd) + .unwrap(); + + let command_config = CommandConfig::new(&schema, HashMap::new(), &matches).unwrap(); + + assert_eq!(command_config.get_one::("some_arg").unwrap(), None); + } + + #[test] + fn test_get_one_with_aliased_keys() { + let cmd = Command::new("test").arg(Arg::new("name|identity").value_parser(clap::value_parser!(String))); + + let matches = cmd.clone().get_matches_from(vec!["test", "my-database"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("database").from_clap("name|identity")) + .build(&cmd) + .unwrap(); + + let command_config = CommandConfig::new(&schema, HashMap::new(), &matches).unwrap(); + + assert_eq!( + command_config.get_one::("database").unwrap(), + Some("my-database".to_string()) + ); + } + + #[test] + fn test_is_from_cli_identifies_sources_correctly() { + let cmd = Command::new("test") + .arg( + Arg::new("cli_arg") + .long("cli-arg") + .value_parser(clap::value_parser!(String)), + ) + .arg( + Arg::new("default_arg") + .long("default-arg") + .default_value("default") + .value_parser(clap::value_parser!(String)), + ) + .arg( + Arg::new("config_arg") + .long("config-arg") + .value_parser(clap::value_parser!(String)), + ); + + let matches = cmd.clone().get_matches_from(vec!["test", "--cli-arg", "from-cli"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("cli_arg")) + .key(Key::new::("default_arg")) + .key(Key::new::("config_arg")) + .build(&cmd) + .unwrap(); + + // CLI arg should be detected + assert!(schema.is_from_cli(&matches, "cli_arg")); + + // Default arg should NOT be detected as CLI + assert!(!schema.is_from_cli(&matches, "default_arg")); + + // Config arg (not provided anywhere) should NOT be detected as CLI + assert!(!schema.is_from_cli(&matches, "config_arg")); + } + + // SpacetimeConfig Tests + + #[test] + fn test_find_and_load_walks_up_directory_tree() { + use std::fs; + use tempfile::TempDir; + + let temp = TempDir::new().unwrap(); + let root = temp.path(); + let subdir1 = root.join("level1"); + let subdir2 = subdir1.join("level2"); + fs::create_dir_all(&subdir2).unwrap(); + + // Create config in root + let config = SpacetimeConfig { + dev: Some(DevConfig { + run: Some("test".to_string()), + }), + ..Default::default() + }; + config.save(&root.join("spacetime.json")).unwrap(); + + // Search from subdir2 - should find config in root + let result = SpacetimeConfig::find_and_load_from(subdir2).unwrap(); + assert!(result.is_some()); + let (found_path, found_config) = result.unwrap(); + assert_eq!(found_path, root.join("spacetime.json")); + assert_eq!(found_config.dev.as_ref().and_then(|d| d.run.as_deref()), Some("test")); + } + + #[test] + fn test_malformed_json_returns_error() { + use std::fs; + use tempfile::TempDir; + + let temp = TempDir::new().unwrap(); + let config_path = temp.path().join("spacetime.json"); + + fs::write(&config_path, "{ invalid json }").unwrap(); + + let result = SpacetimeConfig::find_and_load_from(temp.path().to_path_buf()); + assert!(result.is_err()); + } + + #[test] + fn test_missing_file_returns_none() { + use tempfile::TempDir; + + let temp = TempDir::new().unwrap(); + + let result = SpacetimeConfig::find_and_load_from(temp.path().to_path_buf()).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_empty_config_file_handled() { + use std::fs; + use tempfile::TempDir; + + let temp = TempDir::new().unwrap(); + let config_path = temp.path().join("spacetime.json"); + + fs::write(&config_path, "{}").unwrap(); + + let result = SpacetimeConfig::find_and_load_from(temp.path().to_path_buf()).unwrap(); + assert!(result.is_some()); + let (_, config) = result.unwrap(); + assert!(config.dev.is_none()); + assert!(config.publish.is_none()); + assert!(config.generate.is_none()); + } +} diff --git a/crates/cli/src/subcommands/dev.rs b/crates/cli/src/subcommands/dev.rs index 37ce4d3752a..edb7ef62c6b 100644 --- a/crates/cli/src/subcommands/dev.rs +++ b/crates/cli/src/subcommands/dev.rs @@ -1,6 +1,7 @@ use crate::common_args::ClearMode; use crate::config::Config; use crate::generate::Language; +use crate::spacetime_config::{detect_client_command, CommandConfig, SpacetimeConfig}; use crate::subcommands::init; use crate::util::{ add_auth_header_opt, database_identity, detect_module_language, get_auth_header, get_login_token_or_log_in, @@ -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("run") + .long("run") + .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)] @@ -112,11 +126,11 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E // If you don't specify a server, we default to your default server // If you don't have one of those, we default to "maincloud" - let server = args.get_one::("server").map(|s| s.as_str()); + let server_from_cli = args.get_one::("server").map(|s| s.as_str()); let default_server_name = config.default_server_name().map(|s| s.to_string()); - let mut resolved_server = server + let mut resolved_server = server_from_cli .or(default_server_name.as_deref()) .ok_or_else(|| anyhow::anyhow!("Server not specified and no default server configured."))?; @@ -173,6 +187,16 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E ); } + // Load spacetime.json config early for potential filtering + // If the file exists but fails to parse, we should error immediately + let spacetime_config = match SpacetimeConfig::load_from_dir(&project_dir) { + Ok(config) => config, + Err(e) => { + eprintln!("{} Failed to load spacetime.json: {}", "✗".red(), e); + std::process::exit(1); + } + }; + if !module_bindings_dir.exists() { // Create the module bindings directory if it doesn't exist std::fs::create_dir_all(&module_bindings_dir).with_context(|| { @@ -188,12 +212,30 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E ); } - if resolved_server == "maincloud" && config.spacetimedb_token().is_none() { + // Check if we need to login to maincloud + // Either because --server maincloud was provided, or because any of the publish configs use maincloud + let needs_maincloud_login = resolved_server == "maincloud" + || spacetime_config + .as_ref() + .and_then(|c| c.publish.as_ref()) + .map(|publish| { + publish.iter_all_targets().any(|target| { + target + .additional_fields + .get("server") + .and_then(|v| v.as_str()) + .map(|s| s == "maincloud") + .unwrap_or(false) + }) + }) + .unwrap_or(false); + + if needs_maincloud_login && config.spacetimedb_token().is_none() { let should_login = Confirm::new() .with_prompt("Would you like to sign in now?") .default(true) .interact()?; - if !should_login && server.is_some() { + if !should_login && server_from_cli.is_some() { // The user explicitly provided --server maincloud but doesn't want to log in anyhow::bail!("Login required to publish to maincloud server"); } else if !should_login { @@ -213,8 +255,8 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E } let use_local = resolved_server == "local"; - // Check positional argument first, then deprecated --database flag - let database_name = if let Some(name) = args + // Check positional argument first, then deprecated --database flag, then config file + let database_name: Option = if let Some(name) = args .get_one::("database") .or_else(|| args.get_one::("database-flag")) { @@ -225,12 +267,16 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E "--database flag is deprecated. Use positional argument instead: spacetime dev ".dimmed() ); } - name.clone() + Some(name.clone()) + } else if spacetime_config.as_ref().and_then(|c| c.publish.as_ref()).is_some() { + // If we have publish configs in spacetime.json, skip the database prompt + // The actual database names will be taken from the configs during publish + None } else { println!("\n{}", "Found existing SpacetimeDB project.".green()); println!("Now we need to select a database to publish to.\n"); - if use_local { + let selected = if use_local { generate_database_name() } else { // If not logged in before, but login was successful just now, this will have the token @@ -247,24 +293,155 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E } else { select_database(&config, resolved_server, &token).await? } - } - }; + }; - if args.get_one::("database").is_none() && args.get_one::("database-flag").is_none() { - println!("\n{} {}", "Selected database:".green().bold(), database_name.cyan()); + println!("\n{} {}", "Selected database:".green().bold(), selected.cyan()); println!( "{} {}", "Tip:".yellow().bold(), - format!("Use `spacetime dev {}` to skip this question next time", database_name).dimmed() + format!("Use `spacetime dev {}` to skip this question next time", selected).dimmed() ); + + Some(selected) + }; + + // Build unified publish configs + // Either from spacetime.json (filtered by database_name if provided), + // or create a single config from database_name CLI argument + let publish_cmd = publish::cli(); + let publish_schema = publish::build_publish_schema(&publish_cmd)?; + + // Create ArgMatches for publish command to use with get_one() + let mut publish_argv = vec!["publish"]; + let db_str; + let server_str; + if let Some(ref db) = database_name { + db_str = db.clone(); + publish_argv.push(&db_str); } + if let Some(srv) = args.get_one::("server") { + publish_argv.push("--server"); + server_str = srv.clone(); + publish_argv.push(&server_str); + } + + let publish_args = publish_cmd + .clone() + .try_get_matches_from(publish_argv) + .context("Failed to create publish arguments")?; + + let publish_configs: Vec = if let Some(ref config) = spacetime_config { + if config.publish.is_some() { + // Filter publish configs based on database arg (if provided) + let filtered = publish::get_filtered_publish_configs(config, &publish_schema, &publish_args)?; + + if filtered.is_empty() { + anyhow::bail!("No publish configurations found in spacetime.json"); + } + + filtered + } else if let Some(ref db_name) = database_name { + // No publish config in file, create from database_name + use serde_json::json; + use std::collections::HashMap; + + let mut config_map = HashMap::new(); + config_map.insert("database".to_string(), json!(db_name)); + + vec![CommandConfig::new(&publish_schema, config_map, &publish_args)?] + } else { + anyhow::bail!("No database name provided and no publish configurations found"); + } + } else if let Some(ref db_name) = database_name { + // No config file at all, create from database_name + use serde_json::json; + use std::collections::HashMap; + + let mut config_map = HashMap::new(); + config_map.insert("database".to_string(), json!(db_name)); + + vec![CommandConfig::new(&publish_schema, config_map, &publish_args)?] + } else { + anyhow::bail!("No database name provided and no publish configurations found"); + }; + + // 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::("run") { + // Explicit CLI flag takes priority + Some(cmd.clone()) + } else { + // Try to load config, handling errors properly + match SpacetimeConfig::load_from_dir(&project_dir) { + Ok(Some(config)) => { + // Config file exists and parsed successfully + let config_path = project_dir.join("spacetime.json"); + println!("{} Using configuration from {}", "✓".green(), config_path.display()); + + // If config exists but dev.run is None, try to detect and update + if config.dev.as_ref().and_then(|d| d.run.as_ref()).is_none() { + detect_and_save_client_command(&project_dir, Some(config)) + } else { + config.dev.and_then(|d| d.run) + } + } + Ok(None) => { + // No config file - try to detect and create new + detect_and_save_client_command(&project_dir, None) + } + Err(e) => { + // Config file exists but failed to parse - show error and exit + eprintln!("{} Failed to load spacetime.json: {}", "✗".red(), e); + std::process::exit(1); + } + } + }; + + // Extract database names from publish configs for log streaming + let db_names_for_logging: Vec = publish_configs + .iter() + .map(|config| { + config + .get_config_value("database") + .and_then(|v| v.as_str()) + .expect("database is a required field") + .to_string() + }) + .collect(); + + // Use first database for client process + let db_name_for_client = &db_names_for_logging[0]; + + // Extract watch directories from publish configs + let watch_dirs = extract_watch_dirs(&publish_configs, &spacetimedb_dir); println!("\n{}", "Starting development mode...".green().bold()); - println!("Database: {}", database_name.cyan()); - println!( - "Watching for changes in: {}", - spacetimedb_dir.display().to_string().cyan() - ); + if db_names_for_logging.len() == 1 { + println!("Database: {}", db_names_for_logging[0].cyan()); + } else { + println!("Databases: {}", db_names_for_logging.join(", ").cyan()); + } + + // Announce watch directories + if watch_dirs.len() == 1 { + println!( + "Watching for changes in: {}", + watch_dirs.iter().next().unwrap().display().to_string().cyan() + ); + } else { + let watch_dirs_vec: Vec<_> = watch_dirs.iter().collect(); + println!("Watching for changes in {} directories:", watch_dirs.len()); + for dir in &watch_dirs_vec { + println!(" - {}", 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!(); @@ -273,18 +450,71 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E &project_dir, &spacetimedb_dir, &module_bindings_dir, - &database_name, client_language, - resolved_server, clear_database, + &publish_configs, + server_from_cli, ) .await?; // Sleep for a second to allow the database to be published on Maincloud sleep(Duration::from_secs(1)).await; - 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 log streams for all targets + let use_prefix = db_names_for_logging.len() > 1; + let mut log_handles = Vec::new(); + for config_entry in &publish_configs { + let db_name = config_entry + .get_config_value("database") + .and_then(|v| v.as_str()) + .expect("database is a required field"); + + let server_opt = config_entry.get_one::("server")?; + let server_for_db = server_opt.as_deref().unwrap_or(resolved_server); + + let db_identity = database_identity(&config, db_name, Some(server_for_db)).await?; + let prefix = if use_prefix { Some(db_name.to_string()) } else { None }; + let handle = start_log_stream( + config.clone(), + db_identity.to_hex().to_string(), + Some(server_for_db), + prefix, + ) + .await?; + log_handles.push(handle); + } + + // Start the client development server if configured + let server_opt_client = publish_configs + .first() + .and_then(|c| c.get_one::("server").ok().flatten()); + let server_for_client = server_opt_client.as_deref().unwrap_or(resolved_server); + let server_host_url = config.get_host_url(Some(server_for_client))?; + let _client_handle = if let Some(ref cmd) = client_command { + let mut child = start_client_process(cmd, &project_dir, db_name_for_client, &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( @@ -301,10 +531,10 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E notify::Config::default().with_poll_interval(Duration::from_millis(500)), )?; - let src_dir = spacetimedb_dir.join("src"); - watcher.watch(&src_dir, RecursiveMode::Recursive)?; - - println!("{}", "Watching for file changes...".dimmed()); + // Watch all directories + for watch_dir in &watch_dirs { + watcher.watch(watch_dir, RecursiveMode::Recursive)?; + } let mut debounce_timer; loop { @@ -322,10 +552,10 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E &project_dir, &spacetimedb_dir, &module_bindings_dir, - &database_name, client_language, - resolved_server, clear_database, + &publish_configs, + server_from_cli, ) .await { @@ -390,10 +620,10 @@ async fn generate_build_and_publish( project_dir: &Path, spacetimedb_dir: &Path, module_bindings_dir: &Path, - database_name: &str, client_language: Option<&Language>, - server: &str, clear_database: ClearMode, + publish_configs: &[CommandConfig<'_>], + server: Option<&str>, ) -> Result<(), anyhow::Error> { let module_language = detect_module_language(spacetimedb_dir)?; let client_language = client_language.unwrap_or(match module_language { @@ -408,16 +638,25 @@ async fn generate_build_and_publish( Language::UnrealCpp => "unrealcpp", }; + // For TypeScript client, update .env.local with first database name if client_language == &Language::TypeScript { - // Update SPACETIMEDB_DBNAME environment variables in `.env.local` for TypeScript client + let first_config = publish_configs.first().expect("publish_configs cannot be empty"); + let first_db_name = first_config + .get_config_value("database") + .and_then(|v| v.as_str()) + .expect("database is a required field"); + + // CLI server takes precedence, otherwise use server from config + let server_for_env = server.or_else(|| first_config.get_config_value("server").and_then(|v| v.as_str())); + println!( "{} {}...", "Updating .env.local with database name".cyan(), - database_name + first_db_name ); let env_path = project_dir.join(".env.local"); - let server_host_url = config.get_host_url(Some(server))?; - upsert_env_db_names_and_hosts(&env_path, &server_host_url, database_name)?; + let server_host_url = config.get_host_url(server_for_env)?; + upsert_env_db_names_and_hosts(&env_path, &server_host_url, first_db_name)?; } println!("{}", "Building...".cyan()); @@ -435,33 +674,55 @@ async fn generate_build_and_publish( "--out-dir", module_bindings_dir.to_str().unwrap(), ]); - generate::exec(config.clone(), &generate_args).await?; + generate::exec_ex( + config.clone(), + &generate_args, + crate::generate::extract_descriptions, + true, + ) + .await?; println!("{}", "Publishing...".cyan()); let project_path_str = spacetimedb_dir.to_str().unwrap(); - let clear_flag = match clear_database { ClearMode::Always => "always", ClearMode::Never => "never", ClearMode::OnConflict => "on-conflict", }; - let mut publish_args = vec![ - "publish".to_string(), - database_name.to_string(), - "--project-path".to_string(), - project_path_str.to_string(), - "--yes".to_string(), - format!("--delete-data={}", clear_flag), - ]; - publish_args.extend_from_slice(&["--server".to_string(), server.to_string()]); - let publish_cmd = publish::cli(); - let publish_matches = publish_cmd - .try_get_matches_from(publish_args) - .context("Failed to create publish arguments")?; + // Loop through all publish configs + for config_entry in publish_configs { + let db_name = config_entry + .get_config_value("database") + .and_then(|v| v.as_str()) + .expect("database is a required field"); + + if publish_configs.len() > 1 { + println!("{} {}...", "Publishing to".cyan(), db_name.cyan().bold()); + } + + let mut publish_args = vec![ + "publish".to_string(), + db_name.to_string(), + "--project-path".to_string(), + project_path_str.to_string(), + "--yes".to_string(), + format!("--delete-data={}", clear_flag), + ]; + + // Only pass --server if it was explicitly provided via CLI + if let Some(srv) = server { + publish_args.extend_from_slice(&["--server".to_string(), srv.to_string()]); + } - publish::exec(config.clone(), &publish_matches).await?; + let publish_cmd = publish::cli(); + let publish_matches = publish_cmd + .try_get_matches_from(publish_args) + .context("Failed to create publish arguments")?; + + publish::exec_with_options(config.clone(), &publish_matches, true).await?; + } println!("{}", "Published successfully!".green().bold()); println!("{}", "---".dimmed()); @@ -582,6 +843,7 @@ async fn start_log_stream( mut config: Config, database_identity: String, server: Option<&str>, + prefix: Option, ) -> Result, anyhow::Error> { let server = server.map(|s| s.to_string()); let host_url = config.get_host_url(server.as_deref())?; @@ -589,7 +851,7 @@ async fn start_log_stream( let handle = tokio::spawn(async move { loop { - if let Err(e) = stream_logs(&host_url, &database_identity, &auth_header).await { + if let Err(e) = stream_logs(&host_url, &database_identity, &auth_header, prefix.as_deref()).await { eprintln!("\n{} Log streaming error: {}", "Error:".red().bold(), e); eprintln!("{}", "Reconnecting in 10 seconds...".yellow()); tokio::time::sleep(Duration::from_secs(10)).await; @@ -604,6 +866,7 @@ async fn stream_logs( host_url: &str, database_identity: &str, auth_header: &crate::util::AuthHeader, + prefix: Option<&str>, ) -> Result<(), anyhow::Error> { let client = reqwest::Client::new(); let builder = client.get(format!("{host_url}/v1/database/{database_identity}/logs")); @@ -628,7 +891,7 @@ async fn stream_logs( let record = serde_json::from_str::>(&line)?; let out = termcolor::StandardStream::stdout(term_color); let mut out = out.lock(); - format_log_record(&mut out, &record)?; + format_log_record(&mut out, &record, prefix)?; drop(out); line.clear(); } @@ -666,7 +929,18 @@ struct LogRecord<'a> { message: Cow<'a, str>, } -fn format_log_record(out: &mut W, record: &LogRecord<'_>) -> Result<(), std::io::Error> { +fn format_log_record( + out: &mut W, + record: &LogRecord<'_>, + prefix: Option<&str>, +) -> Result<(), std::io::Error> { + // Write prefix if provided + if let Some(prefix) = prefix { + out.set_color(ColorSpec::new().set_fg(Some(Color::Cyan)).set_bold(true))?; + write!(out, "[{}] ", prefix)?; + out.reset()?; + } + if let Some(ts) = record.ts { out.set_color(ColorSpec::new().set_dimmed(true))?; write!(out, "{ts:?} ")?; @@ -738,3 +1012,167 @@ fn generate_database_name() -> String { let mut generator = names::Generator::with_naming(names::Name::Numbered); generator.next().unwrap() } + +/// Extract unique watch directories from publish configs +fn extract_watch_dirs( + publish_configs: &[CommandConfig<'_>], + default_spacetimedb_dir: &Path, +) -> std::collections::HashSet { + use std::collections::HashSet; + let mut watch_dirs = HashSet::new(); + + for config_entry in publish_configs { + let module_path = config_entry + .get_config_value("module_path") + .and_then(|v| v.as_str()) + .map(PathBuf::from) + .unwrap_or_else(|| default_spacetimedb_dir.to_path_buf()); + + // Canonicalize to handle relative paths + let canonical_path = module_path.canonicalize().unwrap_or(module_path); + + watch_dirs.insert(canonical_path); + } + + watch_dirs +} + +/// Detect client command and save to config (updating existing config if present) +fn detect_and_save_client_command(project_dir: &Path, existing_config: Option) -> Option { + if let Some((detected_cmd, _detected_pm)) = detect_client_command(project_dir) { + // Update existing config or create new one + let config_to_save = if let Some(mut config) = existing_config { + config.dev = Some(crate::spacetime_config::DevConfig { + run: Some(detected_cmd.clone()), + }); + config + } else { + SpacetimeConfig::with_run_command(&detected_cmd) + }; + + if let Ok(path) = config_to_save.save_to_dir(project_dir) { + println!( + "{} Detected client command and saved to {}", + "✓".green(), + path.display() + ); + } + Some(detected_cmd) + } else { + None + } +} + +/// 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) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_detect_and_save_preserves_existing_config() { + let temp = TempDir::new().unwrap(); + + // Create a config with generate and publish but no dev-run + let initial_config = r#"{ + "generate": [ + { "out-dir": "./foo-client/src/module_bindings", "module-path": "foo", "language": "rust" } + ], + "publish": { + "database": "test-db", + "server": "maincloud" + } + }"#; + + let config_path = temp.path().join("spacetime.json"); + fs::write(&config_path, initial_config).unwrap(); + + // Create a package.json to enable detection + let package_json = r#"{ + "name": "test", + "scripts": { + "dev": "vite" + } + }"#; + fs::write(temp.path().join("package.json"), package_json).unwrap(); + + // Load the config + let loaded_config = SpacetimeConfig::load(&config_path).unwrap(); + assert!(loaded_config.dev.is_none()); + assert!(loaded_config.generate.is_some()); + assert!(loaded_config.publish.is_some()); + + // Call detect_and_save_client_command which should detect "npm run dev" + let detected = detect_and_save_client_command(temp.path(), Some(loaded_config)); + assert!(detected.is_some(), "Should detect client command from package.json"); + + // Load again and verify all fields are preserved + let reloaded_config = SpacetimeConfig::load(&config_path).unwrap(); + assert!( + reloaded_config.dev.as_ref().and_then(|d| d.run.as_ref()).is_some(), + "dev.run should be set" + ); + assert!(reloaded_config.generate.is_some(), "generate field should be preserved"); + assert!(reloaded_config.publish.is_some(), "publish field should be preserved"); + + // Verify the generate array has the expected content + let generate = reloaded_config.generate.unwrap(); + assert_eq!(generate.len(), 1); + assert_eq!( + generate[0].get("out-dir").unwrap().as_str().unwrap(), + "./foo-client/src/module_bindings" + ); + + // Verify the publish object has the expected content + let publish = reloaded_config.publish.unwrap(); + assert_eq!( + publish.additional_fields.get("database").unwrap().as_str().unwrap(), + "test-db" + ); + } +} diff --git a/crates/cli/src/subcommands/generate.rs b/crates/cli/src/subcommands/generate.rs index 4e6fb5ec124..48dc3699def 100644 --- a/crates/cli/src/subcommands/generate.rs +++ b/crates/cli/src/subcommands/generate.rs @@ -13,15 +13,101 @@ use spacetimedb_schema::def::ModuleDef; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use crate::spacetime_config::{CommandConfig, CommandSchema, CommandSchemaBuilder, Key, SpacetimeConfig}; use crate::tasks::csharp::dotnet_format; use crate::tasks::rust::rustfmt; use crate::util::{resolve_sibling_binary, y_or_n}; use crate::Config; use crate::{build, common_args}; use clap::builder::PossibleValue; -use std::collections::BTreeSet; +use serde_json::Value; +use std::collections::{BTreeSet, HashMap}; use std::io::Read; +/// Build the CommandSchema for generate command configuration. +/// +/// This schema is used to validate and merge values from both the config file +/// and CLI arguments, with CLI arguments taking precedence over config values. +fn build_generate_config_schema(command: &clap::Command) -> Result { + CommandSchemaBuilder::new() + .key(Key::new::("language").from_clap("lang").required()) + .key(Key::new::("out_dir")) + .key(Key::new::("uproject_dir")) + .key(Key::new::("module_path").from_clap("project_path")) + .key(Key::new::("wasm_file")) + .key(Key::new::("js_file")) + .key(Key::new::("namespace")) + .key(Key::new::("module_name")) + .key(Key::new::("build_options")) + .exclude("json_module") + .exclude("force") + .build(command) + .map_err(Into::into) +} + +/// Get filtered generate configs based on CLI arguments. When the user sets +/// the module path as a CLI argument and the config file is available, +/// we should only run the generate command for config entries that match +/// the module path +fn get_filtered_generate_configs<'a>( + spacetime_config: &'a SpacetimeConfig, + schema: &'a CommandSchema, + args: &'a clap::ArgMatches, +) -> Result>, anyhow::Error> { + // Get all generate configs from spacetime.json + let all_configs: Vec> = spacetime_config.generate.as_ref().cloned().unwrap_or_default(); + + // If no config file, return empty (will use CLI args only) + if all_configs.is_empty() { + return Ok(vec![]); + } + + // Build CommandConfig for each generate config - this merges any arguments passed + // through the CLI with the values from the config file + let all_command_configs: Vec = all_configs + .into_iter() + .map(|config| { + let command_config = CommandConfig::new(schema, config, args)?; + command_config.validate()?; + Ok(command_config) + }) + .collect::, anyhow::Error>>()?; + + // Filter by module_path if provided via CLI + let filtered_configs: Vec = if schema.is_from_cli(args, "module_path") { + let cli_module_path = schema.get_clap_arg::(args, "module_path")?; + // Canonicalize the CLI path for comparison (if it exists) + let cli_canonical = cli_module_path.as_ref().and_then(|p| p.canonicalize().ok()); + + all_command_configs + .into_iter() + .filter(|config| { + // Get module_path from CONFIG ONLY (not merged with CLI) + let config_module_path = config + .get_config_value("module_path") + .and_then(|v| v.as_str()) + .map(PathBuf::from); + + // If we have a canonical CLI path, try to canonicalize config path and compare + if let Some(ref cli_canon) = cli_canonical { + if let Some(ref config_path) = config_module_path { + if let Ok(config_canon) = config_path.canonicalize() { + return cli_canon == &config_canon; + } + } + } + + // Fallback to direct comparison if canonicalization fails + config_module_path.as_ref() == cli_module_path.as_ref() + }) + .collect() + } else { + all_command_configs + }; + + Ok(filtered_configs) +} + pub fn cli() -> clap::Command { clap::Command::new("generate") .about("Generate client files for a spacetime module.") @@ -69,17 +155,14 @@ pub fn cli() -> clap::Command { .value_parser(clap::value_parser!(PathBuf)) .long("out-dir") .short('o') - .help("The system path (absolute or relative) to the generate output directory") - .required_if_eq("lang", "rust") - .required_if_eq("lang", "csharp") - .required_if_eq("lang", "typescript"), + .help("The system path (absolute or relative) to the generate output directory"), ) .arg( Arg::new("uproject_dir") .value_parser(clap::value_parser!(PathBuf)) .long("uproject-dir") + .long("foo-bar") .help("Path to the Unreal project directory, replaces --out-dir for Unreal generation (only used with --lang unrealcpp)") - .required_if_eq("lang", "unrealcpp") ) .arg( Arg::new("namespace") @@ -91,11 +174,9 @@ pub fn cli() -> clap::Command { Arg::new("module_name") .long("module-name") .help("The module name that should be used for DLL export macros (required for lang unrealcpp)") - .required_if_eq("lang", "unrealcpp") ) .arg( Arg::new("lang") - .required(true) .long("lang") .short('l') .value_parser(clap::value_parser!(Language)) @@ -111,15 +192,10 @@ pub fn cli() -> clap::Command { ) .arg(common_args::yes()) .after_help("Run `spacetime help publish` for more detailed information.") - .group( - clap::ArgGroup::new("output_dir") - .args(["out_dir", "uproject_dir"]) - .required(true) - ) } pub async fn exec(config: Config, args: &clap::ArgMatches) -> anyhow::Result<()> { - exec_ex(config, args, extract_descriptions).await + exec_ex(config, args, extract_descriptions, false).await } /// Like `exec`, but lets you specify a custom a function to extract a schema from a file. @@ -127,140 +203,211 @@ pub async fn exec_ex( config: Config, args: &clap::ArgMatches, extract_descriptions: ExtractDescriptions, + quiet_config: bool, ) -> anyhow::Result<()> { - let project_path = args.get_one::("project_path").unwrap(); - let wasm_file = args.get_one::("wasm_file").cloned(); - let js_file = args.get_one::("js_file").cloned(); - let json_module = args.get_many::("json_module"); - let lang = *args.get_one::("lang").unwrap(); - let namespace = args.get_one::("namespace").unwrap(); - let module_name = args.get_one::("module_name"); - let force = args.get_flag("force"); - let build_options = args.get_one::("build_options").unwrap(); - - if args.value_source("namespace") == Some(ValueSource::CommandLine) && lang != Language::Csharp { - return Err(anyhow::anyhow!("--namespace is only supported with --lang csharp")); - } + // Build schema + let cmd = cli(); + let schema = build_generate_config_schema(&cmd)?; - let out_dir = args - .get_one::("out_dir") - .or_else(|| args.get_one::("uproject_dir")) - .unwrap(); - - let module: ModuleDef = if let Some(mut json_module) = json_module { - let DeserializeWrapper::(module) = if let Some(path) = json_module.next() { - serde_json::from_slice(&fs::read(path)?)? + // Get generate configs (from spacetime.json or empty) + let spacetime_config_opt = SpacetimeConfig::find_and_load()?; + let generate_configs = if let Some((config_path, ref spacetime_config)) = spacetime_config_opt { + if !quiet_config { + println!("Using configuration from {}", config_path.display()); + } + let filtered = get_filtered_generate_configs(spacetime_config, &schema, args)?; + // If filtering resulted in no matches, use CLI args with empty config + if filtered.is_empty() { + vec![CommandConfig::new(&schema, HashMap::new(), args)?] } else { - serde_json::from_reader(std::io::stdin().lock())? - }; - module.try_into()? + filtered + } } else { - let path = if let Some(path) = wasm_file { - println!("Skipping build. Instead we are inspecting {}", path.display()); - path.clone() - } else if let Some(path) = js_file { - println!("Skipping build. Instead we are inspecting {}", path.display()); - path.clone() - } else { - let (path, _) = build::exec_with_argstring(config.clone(), project_path, build_options).await?; - path - }; - let spinner = indicatif::ProgressBar::new_spinner(); - spinner.enable_steady_tick(std::time::Duration::from_millis(60)); - spinner.set_message(format!("Extracting schema from {}...", path.display())); - extract_descriptions(&path).context("could not extract schema")? + vec![CommandConfig::new(&schema, HashMap::new(), args)?] }; - fs::create_dir_all(out_dir)?; + // Execute generate for each config + for command_config in generate_configs { + // Get values using command_config.get_one() which merges CLI + config + let project_path = command_config + .get_one::("module_path")? + .unwrap_or_else(|| PathBuf::from(".")); + let wasm_file = command_config.get_one::("wasm_file")?; + let js_file = command_config.get_one::("js_file")?; + let json_module = args.get_many::("json_module"); + let lang = command_config + .get_one::("language")? + .ok_or_else(|| anyhow::anyhow!("Language is required (use --lang or add to config)"))?; - let mut paths = BTreeSet::new(); + println!( + "Generating {} module bindings for module {}", + lang.display_name(), + project_path.display() + ); - let csharp_lang; - let unreal_cpp_lang; - let gen_lang = match lang { - Language::Csharp => { - csharp_lang = Csharp { namespace }; - &csharp_lang as &dyn Lang - } - Language::UnrealCpp => { - unreal_cpp_lang = UnrealCpp { - module_name: module_name.as_ref().unwrap(), - uproject_dir: out_dir, - }; - &unreal_cpp_lang as &dyn Lang - } - Language::Rust => &Rust, - Language::TypeScript => &TypeScript, - }; + let namespace = command_config + .get_one::("namespace")? + .unwrap_or_else(|| "SpacetimeDB.Types".to_string()); + let module_name = command_config.get_one::("module_name")?; + let force = args.get_flag("force"); + let build_options = command_config + .get_one::("build_options")? + .unwrap_or_else(|| String::new()); - for OutputFile { filename, code } in generate(&module, gen_lang) { - let fname = Path::new(&filename); - // If a generator asks for a file in a subdirectory, create the subdirectory first. - if let Some(parent) = fname.parent().filter(|p| !p.as_os_str().is_empty()) { - fs::create_dir_all(out_dir.join(parent))?; + // Validate namespace is only used with csharp + if args.value_source("namespace") == Some(ValueSource::CommandLine) && lang != Language::Csharp { + return Err(anyhow::anyhow!("--namespace is only supported with --lang csharp")); } - let path = out_dir.join(fname); - if !path.exists() || fs::read_to_string(&path)? != code { - fs::write(&path, code)?; + + // Get output directory (either out_dir or uproject_dir) + let out_dir = command_config + .get_one::("out_dir")? + .or_else(|| command_config.get_one::("uproject_dir").ok().flatten()) + .ok_or_else(|| anyhow::anyhow!("Either --out-dir or --uproject-dir is required"))?; + + // Validate language-specific requirements + match lang { + Language::Rust | Language::Csharp | Language::TypeScript => { + // These languages require out_dir (not uproject_dir) + if command_config.get_one::("out_dir")?.is_none() { + return Err(anyhow::anyhow!( + "--out-dir is required for --lang {}", + match lang { + Language::Rust => "rust", + Language::Csharp => "csharp", + Language::TypeScript => "typescript", + _ => unreachable!(), + } + )); + } + } + Language::UnrealCpp => { + // UnrealCpp requires uproject_dir and module_name + if command_config.get_one::("uproject_dir")?.is_none() { + return Err(anyhow::anyhow!("--uproject-dir is required for --lang unrealcpp")); + } + if module_name.is_none() { + return Err(anyhow::anyhow!("--module-name is required for --lang unrealcpp")); + } + } } - paths.insert(path); - } - // For Unreal, we want to clean up just the module directory, not the entire uproject directory tree. - let cleanup_root = match lang { - Language::UnrealCpp => out_dir.join("Source").join(module_name.as_ref().unwrap()), - _ => out_dir.clone(), - }; + let module: ModuleDef = if let Some(mut json_module) = json_module { + let DeserializeWrapper::(module) = if let Some(path) = json_module.next() { + serde_json::from_slice(&fs::read(path)?)? + } else { + serde_json::from_reader(std::io::stdin().lock())? + }; + module.try_into()? + } else { + let path = if let Some(path) = wasm_file { + println!("Skipping build. Instead we are inspecting {}", path.display()); + path.clone() + } else if let Some(path) = js_file { + println!("Skipping build. Instead we are inspecting {}", path.display()); + path.clone() + } else { + let (path, _) = build::exec_with_argstring(config.clone(), &project_path, &build_options).await?; + path + }; + let spinner = indicatif::ProgressBar::new_spinner(); + spinner.enable_steady_tick(std::time::Duration::from_millis(60)); + spinner.set_message(format!("Extracting schema from {}...", path.display())); + extract_descriptions(&path).context("could not extract schema")? + }; - // TODO: We should probably just delete all generated files before we generate any, rather than selectively deleting some afterward. - let mut auto_generated_buf: [u8; AUTO_GENERATED_PREFIX.len()] = [0; AUTO_GENERATED_PREFIX.len()]; - let files_to_delete = walkdir::WalkDir::new(&cleanup_root) - .into_iter() - .map(|entry_result| { - let entry = entry_result?; - // Only delete files. - if !entry.file_type().is_file() { - return Ok(None); + fs::create_dir_all(&out_dir)?; + + let mut paths = BTreeSet::new(); + + let csharp_lang; + let unreal_cpp_lang; + let gen_lang = match lang { + Language::Csharp => { + csharp_lang = Csharp { namespace: &namespace }; + &csharp_lang as &dyn Lang } - let path = entry.into_path(); - // Don't delete regenerated files. - if paths.contains(&path) { - return Ok(None); + Language::UnrealCpp => { + unreal_cpp_lang = UnrealCpp { + module_name: module_name.as_ref().unwrap(), + uproject_dir: &out_dir, + }; + &unreal_cpp_lang as &dyn Lang } - // Only delete files that start with the auto-generated prefix. - let mut file = fs::File::open(&path)?; - Ok(match file.read_exact(&mut auto_generated_buf) { - Ok(()) => (auto_generated_buf == AUTO_GENERATED_PREFIX.as_bytes()).then_some(path), - Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => None, - Err(err) => return Err(err.into()), - }) - }) - .filter_map(Result::transpose) - .collect::>>()?; + Language::Rust => &Rust, + Language::TypeScript => &TypeScript, + }; - if !files_to_delete.is_empty() { - println!("The following files were not generated by this command and will be deleted:"); - for path in &files_to_delete { - println!(" {}", path.to_str().unwrap()); + for OutputFile { filename, code } in generate(&module, gen_lang) { + let fname = Path::new(&filename); + // If a generator asks for a file in a subdirectory, create the subdirectory first. + if let Some(parent) = fname.parent().filter(|p| !p.as_os_str().is_empty()) { + fs::create_dir_all(out_dir.join(parent))?; + } + let path = out_dir.join(fname); + if !path.exists() || fs::read_to_string(&path)? != code { + fs::write(&path, code)?; + } + paths.insert(path); } - if y_or_n(force, "Are you sure you want to delete these files?")? { - for path in files_to_delete { - fs::remove_file(path)?; + // For Unreal, we want to clean up just the module directory, not the entire uproject directory tree. + let cleanup_root = match lang { + Language::UnrealCpp => out_dir.join("Source").join(module_name.as_ref().unwrap()), + _ => out_dir.clone(), + }; + + // TODO: We should probably just delete all generated files before we generate any, rather than selectively deleting some afterward. + let mut auto_generated_buf: [u8; AUTO_GENERATED_PREFIX.len()] = [0; AUTO_GENERATED_PREFIX.len()]; + let files_to_delete = walkdir::WalkDir::new(&cleanup_root) + .into_iter() + .map(|entry_result| { + let entry = entry_result?; + // Only delete files. + if !entry.file_type().is_file() { + return Ok(None); + } + let path = entry.into_path(); + // Don't delete regenerated files. + if paths.contains(&path) { + return Ok(None); + } + // Only delete files that start with the auto-generated prefix. + let mut file = fs::File::open(&path)?; + Ok(match file.read_exact(&mut auto_generated_buf) { + Ok(()) => (auto_generated_buf == AUTO_GENERATED_PREFIX.as_bytes()).then_some(path), + Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => None, + Err(err) => return Err(err.into()), + }) + }) + .filter_map(Result::transpose) + .collect::>>()?; + + if !files_to_delete.is_empty() { + println!("The following files were not generated by this command and will be deleted:"); + for path in &files_to_delete { + println!(" {}", path.to_str().unwrap()); + } + + if y_or_n(force, "Are you sure you want to delete these files?")? { + for path in files_to_delete { + fs::remove_file(path)?; + } + println!("Files deleted successfully."); + } else { + println!("Files not deleted."); } - println!("Files deleted successfully."); - } else { - println!("Files not deleted."); } - } - if let Err(err) = lang.format_files(out_dir, paths) { - // If we couldn't format the files, print a warning but don't fail the entire - // task as the output should still be usable, just less pretty. - eprintln!("Could not format generated files: {err}"); + if let Err(err) = lang.format_files(&out_dir, paths) { + // If we couldn't format the files, print a warning but don't fail the entire + // task as the output should still be usable, just less pretty. + eprintln!("Could not format generated files: {err}"); + } + + println!("Generate finished successfully."); } - println!("Generate finished successfully."); Ok(()) } @@ -287,6 +434,16 @@ impl clap::ValueEnum for Language { } impl Language { + /// Returns the display name for the language + pub fn display_name(&self) -> &'static str { + match self { + Language::Rust => "Rust", + Language::Csharp => "C#", + Language::TypeScript => "TypeScript", + Language::UnrealCpp => "Unreal C++", + } + } + fn format_files(&self, project_dir: &Path, generated_files: BTreeSet) -> anyhow::Result<()> { match self { Language::Rust => rustfmt(generated_files)?, @@ -304,7 +461,7 @@ impl Language { } pub type ExtractDescriptions = fn(&Path) -> anyhow::Result; -fn extract_descriptions(wasm_file: &Path) -> anyhow::Result { +pub fn extract_descriptions(wasm_file: &Path) -> anyhow::Result { let bin_path = resolve_sibling_binary("spacetimedb-standalone")?; let child = Command::new(&bin_path) .arg("extract-schema") @@ -315,3 +472,260 @@ fn extract_descriptions(wasm_file: &Path) -> anyhow::Result { let sats::serde::SerdeWrapper::(module) = serde_json::from_reader(child.stdout.unwrap())?; Ok(module.try_into()?) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::spacetime_config::*; + use std::collections::HashMap; + + // get_filtered_generate_configs Tests + + #[test] + fn test_filter_by_module_path_from_cli() { + use tempfile::TempDir; + let temp = TempDir::new().unwrap(); + let module1 = temp.path().join("module1"); + let module2 = temp.path().join("module2"); + std::fs::create_dir_all(&module1).unwrap(); + std::fs::create_dir_all(&module2).unwrap(); + + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + + let mut config1 = HashMap::new(); + config1.insert("language".to_string(), serde_json::Value::String("rust".to_string())); + config1.insert( + "module_path".to_string(), + serde_json::Value::String(module1.display().to_string()), + ); + config1.insert( + "out_dir".to_string(), + serde_json::Value::String("/tmp/out1".to_string()), + ); + + let mut config2 = HashMap::new(); + config2.insert( + "language".to_string(), + serde_json::Value::String("typescript".to_string()), + ); + config2.insert( + "module_path".to_string(), + serde_json::Value::String(module2.display().to_string()), + ); + config2.insert( + "out_dir".to_string(), + serde_json::Value::String("/tmp/out2".to_string()), + ); + + let spacetime_config = SpacetimeConfig { + generate: Some(vec![config1, config2]), + ..Default::default() + }; + + // Filter by module1 + let matches = cmd.clone().get_matches_from(vec![ + "generate", + "--project-path", + module1.to_str().unwrap(), + "--lang", + "rust", + "--out-dir", + "/tmp/out", + ]); + + let filtered = get_filtered_generate_configs(&spacetime_config, &schema, &matches).unwrap(); + + // The filtering should match module1 config only + assert_eq!( + filtered.len(), + 1, + "Expected 1 config but got {}. Filter should only match module1.", + filtered.len() + ); + + // Verify it's the correct config (module1) + let filtered_module_path = filtered[0].get_one::("module_path").unwrap().unwrap(); + assert_eq!(filtered_module_path, module1); + } + + #[test] + fn test_no_filter_when_module_path_not_from_cli() { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + + let mut config1 = HashMap::new(); + config1.insert("language".to_string(), serde_json::Value::String("rust".to_string())); + config1.insert( + "module_path".to_string(), + serde_json::Value::String("./module1".to_string()), + ); + config1.insert( + "out_dir".to_string(), + serde_json::Value::String("/tmp/out1".to_string()), + ); + + let mut config2 = HashMap::new(); + config2.insert( + "language".to_string(), + serde_json::Value::String("typescript".to_string()), + ); + config2.insert( + "module_path".to_string(), + serde_json::Value::String("./module2".to_string()), + ); + config2.insert( + "out_dir".to_string(), + serde_json::Value::String("/tmp/out2".to_string()), + ); + + let spacetime_config = SpacetimeConfig { + generate: Some(vec![config1, config2]), + ..Default::default() + }; + + // No module_path provided via CLI + let matches = cmd.clone().get_matches_from(vec!["generate"]); + let filtered = get_filtered_generate_configs(&spacetime_config, &schema, &matches).unwrap(); + + // Should return all configs + assert_eq!(filtered.len(), 2); + } + + #[test] + fn test_path_normalization_in_filtering() { + use tempfile::TempDir; + let temp = TempDir::new().unwrap(); + let module_dir = temp.path().join("mymodule"); + std::fs::create_dir_all(&module_dir).unwrap(); + + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + + // Config uses absolute path + let mut config = HashMap::new(); + config.insert("language".to_string(), serde_json::Value::String("rust".to_string())); + config.insert( + "module_path".to_string(), + serde_json::Value::String(module_dir.display().to_string()), + ); + config.insert("out_dir".to_string(), serde_json::Value::String("/tmp/out".to_string())); + + let spacetime_config = SpacetimeConfig { + generate: Some(vec![config]), + ..Default::default() + }; + + // CLI uses path with ./ and .. + let cli_path = module_dir.join("..").join("mymodule"); + let matches = cmd.clone().get_matches_from(vec![ + "generate", + "--project-path", + cli_path.to_str().unwrap(), + "--lang", + "rust", + "--out-dir", + "/tmp/out", + ]); + let filtered = get_filtered_generate_configs(&spacetime_config, &schema, &matches).unwrap(); + + // Should match despite different path representations + assert_eq!(filtered.len(), 1); + } + + // Language-Specific Validation Tests + + #[tokio::test] + async fn test_rust_requires_out_dir() { + use crate::config::Config; + use spacetimedb_paths::cli::CliTomlPath; + use spacetimedb_paths::FromPathUnchecked; + use std::path::PathBuf; + + let cmd = cli(); + let config = Config::new_with_localhost(CliTomlPath::from_path_unchecked("/tmp/test-config.toml")); + + // Missing --out-dir for rust + let matches = cmd.clone().get_matches_from(vec!["generate", "--lang", "rust"]); + let result = exec(config, &matches).await; + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + // The error should be about missing output directory + assert!( + err_msg.contains("--out-dir") || err_msg.contains("--uproject-dir"), + "Expected error about missing output directory, got: {}", + err_msg + ); + } + + #[tokio::test] + async fn test_unrealcpp_requires_uproject_dir_and_module_name() { + use crate::config::Config; + use spacetimedb_paths::cli::CliTomlPath; + use spacetimedb_paths::FromPathUnchecked; + use std::path::PathBuf; + + let cmd = cli(); + let config = Config::new_with_localhost(CliTomlPath::from_path_unchecked("/tmp/test-config.toml")); + + // Test missing --uproject-dir + let matches = + cmd.clone() + .get_matches_from(vec!["generate", "--lang", "unrealcpp", "--module-name", "MyModule"]); + let result = exec(config.clone(), &matches).await; + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("--uproject-dir is required for --lang unrealcpp"), + "Expected error about missing --uproject-dir, got: {}", + err_msg + ); + + // Test missing --module-name + let matches = cmd + .clone() + .get_matches_from(vec!["generate", "--lang", "unrealcpp", "--out-dir", "/tmp/out"]); + let result = exec(config, &matches).await; + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("--module-name is required for --lang unrealcpp"), + "Expected error about missing --module-name, got: {}", + err_msg + ); + } + + #[test] + fn test_validation_considers_both_cli_and_config() { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + + // Config provides uproject_dir + let mut config = HashMap::new(); + config.insert( + "language".to_string(), + serde_json::Value::String("unrealcpp".to_string()), + ); + config.insert( + "uproject_dir".to_string(), + serde_json::Value::String("/config/path".to_string()), + ); + + // CLI provides module_name + let matches = + cmd.clone() + .get_matches_from(vec!["generate", "--lang", "unrealcpp", "--module-name", "MyModule"]); + + let command_config = CommandConfig::new(&schema, config, &matches).unwrap(); + + // Both should be available (one from CLI, one from config) + let uproject_dir = command_config.get_one::("uproject_dir").unwrap(); + let module_name = command_config.get_one::("module_name").unwrap(); + + assert_eq!(uproject_dir, Some(PathBuf::from("/config/path"))); + assert_eq!(module_name, Some("MyModule".to_string())); + } +} diff --git a/crates/cli/src/subcommands/init.rs b/crates/cli/src/subcommands/init.rs index bae22796d3c..9a555cc5323 100644 --- a/crates/cli/src/subcommands/init.rs +++ b/crates/cli/src/subcommands/init.rs @@ -10,11 +10,12 @@ 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::spacetime_config::PackageManager; use crate::subcommands::login::{spacetimedb_login_force, DEFAULT_AUTH_HOST}; mod embedded { @@ -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,28 @@ 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::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/publish.rs b/crates/cli/src/subcommands/publish.rs index 2cfd5916f7e..f93c5e898c4 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -10,10 +10,95 @@ use std::{env, fs}; use crate::common_args::ClearMode; use crate::config::Config; +use crate::spacetime_config::{CommandConfig, CommandSchema, CommandSchemaBuilder, Key, SpacetimeConfig}; use crate::util::{add_auth_header_opt, get_auth_header, AuthHeader, ResponseExt}; use crate::util::{decode_identity, y_or_n}; use crate::{build, common_args}; +/// Build the CommandSchema for publish command +pub fn build_publish_schema(command: &clap::Command) -> Result { + CommandSchemaBuilder::new() + .key(Key::new::("database").from_clap("name|identity").required()) + .key(Key::new::("server")) + .key( + Key::new::("module_path") + .from_clap("project_path") + .module_specific(), + ) + .key(Key::new::("build_options").module_specific()) + .key(Key::new::("wasm_file").module_specific()) + .key(Key::new::("js_file").module_specific()) + .key(Key::new::("num_replicas")) + .key(Key::new::("break_clients")) + .key(Key::new::("anon_identity")) + .key(Key::new::("parent")) + .exclude("clear-database") + .exclude("force") + .build(command) + .map_err(Into::into) +} + +/// Get filtered publish configs based on CLI arguments +pub fn get_filtered_publish_configs<'a>( + spacetime_config: &'a SpacetimeConfig, + schema: &'a CommandSchema, + args: &'a ArgMatches, +) -> Result>, anyhow::Error> { + // Get all publish targets from config + let all_targets: Vec<_> = spacetime_config + .publish + .as_ref() + .map(|p| p.iter_all_targets().collect()) + .unwrap_or_default(); + + // If no config file, return empty (will use CLI args only) + if all_targets.is_empty() { + return Ok(vec![]); + } + + // Build CommandConfig for each target + let all_configs: Vec = all_targets + .into_iter() + .map(|target| { + let config = CommandConfig::new(schema, target.additional_fields.clone(), args)?; + config.validate()?; + Ok(config) + }) + .collect::, anyhow::Error>>()?; + + // Filter by database name if provided via CLI + let filtered_configs: Vec = if schema.is_from_cli(args, "database") { + let cli_database = schema.get_clap_arg::(args, "database")?; + all_configs + .into_iter() + .filter(|config| { + // Get config-only value (not merged with CLI) for filtering + let config_database = config + .get_config_value("database") + .and_then(|v| v.as_str()) + .map(String::from); + config_database.as_deref() == cli_database.as_deref() + }) + .collect() + } else { + all_configs + }; + + // Validate module-specific args aren't used with multiple targets + if filtered_configs.len() > 1 { + let module_specific_args = schema.module_specific_cli_args(args); + if !module_specific_args.is_empty() { + anyhow::bail!( + "Cannot use module-specific arguments ({}) when publishing to multiple targets. \ + Please specify a database filter (--database) or remove these arguments.", + module_specific_args.join(", ") + ); + } + } + + Ok(filtered_configs) +} + pub fn cli() -> clap::Command { clap::Command::new("publish") .about("Create and update a SpacetimeDB database") @@ -122,159 +207,212 @@ fn confirm_and_clear( Ok(builder) } -pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { - let server = args.get_one::("server").map(|s| s.as_str()); - let name_or_identity = args.get_one::("name|identity"); - let path_to_project = args.get_one::("project_path").unwrap(); - let clear_database = args - .get_one::("clear-database") - .copied() - .unwrap_or(ClearMode::Never); - let force = args.get_flag("force"); - let anon_identity = args.get_flag("anon_identity"); - let wasm_file = args.get_one::("wasm_file"); - let js_file = args.get_one::("js_file"); - let database_host = config.get_host_url(server)?; - let build_options = args.get_one::("build_options").unwrap(); - let num_replicas = args.get_one::("num_replicas"); - let force_break_clients = args.get_flag("break_clients"); - let parent = args.get_one::("parent"); - - // If the user didn't specify an identity and we didn't specify an anonymous identity, then - // we want to use the default identity - // TODO(jdetter): We should maybe have some sort of user prompt here for them to be able to - // easily create a new identity with an email - let auth_header = get_auth_header(&mut config, anon_identity, server, !force).await?; - - let (name_or_identity, parent) = - validate_name_and_parent(name_or_identity.map(String::as_str), parent.map(String::as_str))?; - - if !path_to_project.exists() { - return Err(anyhow::anyhow!( - "Project path does not exist: {}", - path_to_project.display() - )); - } +pub async fn exec(config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + exec_with_options(config, args, false).await +} - // Decide program file path and read program. - // Optionally build the program. - let (path_to_program, host_type) = if let Some(path) = wasm_file { - println!("(WASM) Skipping build. Instead we are publishing {}", path.display()); - (path.clone(), "Wasm") - } else if let Some(path) = js_file { - println!("(JS) Skipping build. Instead we are publishing {}", path.display()); - (path.clone(), "Js") +/// This function can be used when calling publish programatically rather than straight from the +/// CLI, like we do in `spacetime dev`. When calling from `spacetime dev` we don't want to display +/// information about using the `spacetime.json` file as it's already announced as part of the +/// `dev` command +pub async fn exec_with_options(mut config: Config, args: &ArgMatches, quiet_config: bool) -> Result<(), anyhow::Error> { + // Build schema + let cmd = cli(); + let schema = build_publish_schema(&cmd)?; + + // Get publish configs (from spacetime.json or empty) + let spacetime_config_opt = SpacetimeConfig::find_and_load()?; + let (using_config, publish_configs) = if let Some((config_path, ref spacetime_config)) = spacetime_config_opt { + if !quiet_config { + println!("Using configuration from {}", config_path.display()); + } + let filtered = get_filtered_publish_configs(spacetime_config, &schema, args)?; + // If filtering resulted in no matches, use CLI args with empty config + if filtered.is_empty() { + ( + false, + vec![CommandConfig::new(&schema, std::collections::HashMap::new(), args)?], + ) + } else { + (true, filtered) + } } else { - build::exec_with_argstring(config.clone(), path_to_project, build_options).await? + ( + false, + vec![CommandConfig::new(&schema, std::collections::HashMap::new(), args)?], + ) }; - let program_bytes = fs::read(path_to_program)?; - let server_address = { - let url = Url::parse(&database_host)?; - url.host_str().unwrap_or("").to_string() - }; - if server_address != "localhost" && server_address != "127.0.0.1" { - println!("You are about to publish to a non-local server: {server_address}"); - if !y_or_n(force, "Are you sure you want to proceed?")? { - println!("Aborting"); - return Ok(()); + // Execute publish for each config + for command_config in publish_configs { + // Get values using command_config.get_one() which merges CLI + config + let server_opt = command_config.get_one::("server")?; + let server = server_opt.as_deref(); + let name_or_identity_opt = command_config.get_one::("database")?; + let name_or_identity = name_or_identity_opt.as_deref(); + let path_to_project = command_config + .get_one::("module_path")? + .unwrap_or_else(|| PathBuf::from(".")); + + if using_config { + println!( + "Publishing module {} to database '{}'", + path_to_project.display(), + name_or_identity.unwrap() + ); + } + let clear_database = args + .get_one::("clear-database") + .copied() + .unwrap_or(ClearMode::Never); + let force = args.get_flag("force"); + let anon_identity = command_config.get_one::("anon_identity")?.unwrap_or(false); + let wasm_file = command_config.get_one::("wasm_file")?; + let js_file = command_config.get_one::("js_file")?; + let database_host = config.get_host_url(server)?; + let build_options = command_config + .get_one::("build_options")? + .unwrap_or_else(|| String::new()); + let num_replicas = command_config.get_one::("num_replicas")?; + let force_break_clients = command_config.get_one::("break_clients")?.unwrap_or(false); + let parent_opt = command_config.get_one::("parent")?; + let parent = parent_opt.as_deref(); + + // If the user didn't specify an identity and we didn't specify an anonymous identity, then + // we want to use the default identity + // TODO(jdetter): We should maybe have some sort of user prompt here for them to be able to + // easily create a new identity with an email + let auth_header = get_auth_header(&mut config, anon_identity, server, !force).await?; + + let (name_or_identity, parent) = validate_name_and_parent(name_or_identity, parent)?; + + if !path_to_project.exists() { + return Err(anyhow::anyhow!( + "Project path does not exist: {}", + path_to_project.display() + )); } - } - - println!( - "Uploading to {} => {}", - server.unwrap_or(config.default_server_name().unwrap_or("")), - database_host - ); - let client = reqwest::Client::new(); - // If a name was given, ensure to percent-encode it. - // We also use PUT with a name or identity, and POST otherwise. - let mut builder = if let Some(name_or_identity) = name_or_identity { - let encode_set = const { &percent_encoding::NON_ALPHANUMERIC.remove(b'_').remove(b'-') }; - let domain = percent_encoding::percent_encode(name_or_identity.as_bytes(), encode_set); - let mut builder = client.put(format!("{database_host}/v1/database/{domain}")); - - // note that this only happens in the case where we've passed a `name_or_identity`, but that's required if we pass `--clear-database`. - if clear_database == ClearMode::Always { - builder = confirm_and_clear(name_or_identity, force, builder)?; + // Decide program file path and read program. + // Optionally build the program. + let (path_to_program, host_type) = if let Some(path) = wasm_file { + println!("(WASM) Skipping build. Instead we are publishing {}", path.display()); + (path.clone(), "Wasm") + } else if let Some(path) = js_file { + println!("(JS) Skipping build. Instead we are publishing {}", path.display()); + (path.clone(), "Js") } else { - builder = apply_pre_publish_if_needed( - builder, - &client, - &database_host, - name_or_identity, - &domain.to_string(), - host_type, - &program_bytes, - &auth_header, - clear_database, - force_break_clients, - force, - ) - .await?; + build::exec_with_argstring(config.clone(), &path_to_project, &build_options).await? + }; + let program_bytes = fs::read(path_to_program)?; + + let server_address = { + let url = Url::parse(&database_host)?; + url.host_str().unwrap_or("").to_string() + }; + if server_address != "localhost" && server_address != "127.0.0.1" { + println!("You are about to publish to a non-local server: {server_address}"); + if !y_or_n(force, "Are you sure you want to proceed?")? { + println!("Aborting"); + return Ok(()); + } } - builder - } else { - client.post(format!("{database_host}/v1/database")) - }; + println!( + "Uploading to {} => {}", + server.unwrap_or(config.default_server_name().unwrap_or("")), + database_host + ); - if let Some(n) = num_replicas { - eprintln!("WARNING: Use of unstable option `--num-replicas`.\n"); - builder = builder.query(&[("num_replicas", *n)]); - } - if let Some(parent) = parent { - builder = builder.query(&[("parent", parent)]); - } + let client = reqwest::Client::new(); + // If a name was given, ensure to percent-encode it. + // We also use PUT with a name or identity, and POST otherwise. + let mut builder = if let Some(name_or_identity) = name_or_identity { + let encode_set = const { &percent_encoding::NON_ALPHANUMERIC.remove(b'_').remove(b'-') }; + let domain = percent_encoding::percent_encode(name_or_identity.as_bytes(), encode_set); + let mut builder = client.put(format!("{database_host}/v1/database/{domain}")); - println!("Publishing module..."); + // note that this only happens in the case where we've passed a `name_or_identity`, but that's required if we pass `--clear-database`. + if clear_database == ClearMode::Always { + builder = confirm_and_clear(name_or_identity, force, builder)?; + } else { + builder = apply_pre_publish_if_needed( + builder, + &client, + &database_host, + name_or_identity, + &domain.to_string(), + host_type, + &program_bytes, + &auth_header, + clear_database, + force_break_clients, + force, + ) + .await?; + } - builder = add_auth_header_opt(builder, &auth_header); + builder + } else { + client.post(format!("{database_host}/v1/database")) + }; - // Set the host type. - builder = builder.query(&[("host_type", host_type)]); + if let Some(n) = num_replicas { + eprintln!("WARNING: Use of unstable option `--num-replicas`.\n"); + builder = builder.query(&[("num_replicas", n)]); + } + if let Some(parent) = parent { + builder = builder.query(&[("parent", parent)]); + } - // JS/TS is beta quality atm. - if host_type == "Js" { - println!("JavaScript / TypeScript support is currently in BETA."); - println!("There may be bugs. Please file issues if you encounter any."); - println!(""); - } + println!("Publishing module..."); - let res = builder.body(program_bytes).send().await?; - let response: PublishResult = res.json_or_error().await?; - match response { - PublishResult::Success { - domain, - database_identity, - op, - } => { - let op = match op { - PublishOp::Created => "Created new", - PublishOp::Updated => "Updated", - }; - if let Some(domain) = domain { - println!("{op} database with name: {domain}, identity: {database_identity}"); - } else { - println!("{op} database with identity: {database_identity}"); - } + builder = add_auth_header_opt(builder, &auth_header); + + // Set the host type. + builder = builder.query(&[("host_type", host_type)]); + + // JS/TS is beta quality atm. + if host_type == "Js" { + println!("JavaScript / TypeScript support is currently in BETA."); + println!("There may be bugs. Please file issues if you encounter any."); + println!(""); } - PublishResult::PermissionDenied { name } => { - if anon_identity { - anyhow::bail!("You need to be logged in as the owner of {name} to publish to {name}",); + + let res = builder.body(program_bytes).send().await?; + let response: PublishResult = res.json_or_error().await?; + match response { + PublishResult::Success { + domain, + database_identity, + op, + } => { + let op = match op { + PublishOp::Created => "Created new", + PublishOp::Updated => "Updated", + }; + if let Some(domain) = domain { + println!("{op} database with name: {domain}, identity: {database_identity}"); + } else { + println!("{op} database with identity: {database_identity}"); + } + } + PublishResult::PermissionDenied { name } => { + if anon_identity { + anyhow::bail!("You need to be logged in as the owner of {name} to publish to {name}",); + } + // If we're not in the `anon_identity` case, then we have already forced the user to log in above (using `get_auth_header`), so this should be safe to unwrap. + let token = config.spacetimedb_token().unwrap(); + let identity = decode_identity(token)?; + //TODO(jdetter): Have a nice name generator here, instead of using some abstract characters + // we should perhaps generate fun names like 'green-fire-dragon' instead + let suggested_tld: String = identity.chars().take(12).collect(); + return Err(anyhow::anyhow!( + "The database {name} is not registered to the identity you provided.\n\ + We suggest you push to either a domain owned by you, or a new domain like:\n\ + \tspacetime publish {suggested_tld}\n", + )); } - // If we're not in the `anon_identity` case, then we have already forced the user to log in above (using `get_auth_header`), so this should be safe to unwrap. - let token = config.spacetimedb_token().unwrap(); - let identity = decode_identity(token)?; - //TODO(jdetter): Have a nice name generator here, instead of using some abstract characters - // we should perhaps generate fun names like 'green-fire-dragon' instead - let suggested_tld: String = identity.chars().take(12).collect(); - return Err(anyhow::anyhow!( - "The database {name} is not registered to the identity you provided.\n\ - We suggest you push to either a domain owned by you, or a new domain like:\n\ - \tspacetime publish {suggested_tld}\n", - )); } } @@ -495,4 +633,132 @@ mod tests { Ok(res) if res == (Some(&child), Some(&parent)) ); } + + #[test] + fn test_filter_by_database_from_cli() { + use crate::spacetime_config::*; + use std::collections::HashMap; + + let cmd = cli(); + let schema = build_publish_schema(&cmd).unwrap(); + + let mut config1 = HashMap::new(); + config1.insert("database".to_string(), serde_json::Value::String("db1".to_string())); + + let mut config2 = HashMap::new(); + config2.insert("database".to_string(), serde_json::Value::String("db2".to_string())); + + let mut parent_config = HashMap::new(); + parent_config.insert( + "database".to_string(), + serde_json::Value::String("parent-db".to_string()), + ); + + let spacetime_config = SpacetimeConfig { + publish: Some(PublishConfig { + additional_fields: parent_config, + children: Some(vec![ + PublishConfig { + additional_fields: config1, + children: None, + }, + PublishConfig { + additional_fields: config2, + children: None, + }, + ]), + }), + ..Default::default() + }; + + // Filter by db1 (should only match config1, not parent or config2) + let matches = cmd.clone().get_matches_from(vec!["publish", "db1"]); + let filtered = get_filtered_publish_configs(&spacetime_config, &schema, &matches).unwrap(); + + assert_eq!(filtered.len(), 1, "Should only match db1"); + assert_eq!( + filtered[0].get_one::("database").unwrap(), + Some("db1".to_string()) + ); + } + + #[test] + fn test_no_filter_when_database_not_from_cli() { + use crate::spacetime_config::*; + use std::collections::HashMap; + + let cmd = cli(); + let schema = build_publish_schema(&cmd).unwrap(); + + let mut config1 = HashMap::new(); + config1.insert("database".to_string(), serde_json::Value::String("db1".to_string())); + + let mut config2 = HashMap::new(); + config2.insert("database".to_string(), serde_json::Value::String("db2".to_string())); + + let mut parent_config = HashMap::new(); + parent_config.insert( + "database".to_string(), + serde_json::Value::String("parent-db".to_string()), + ); + + let spacetime_config = SpacetimeConfig { + publish: Some(PublishConfig { + additional_fields: parent_config, + children: Some(vec![ + PublishConfig { + additional_fields: config1, + children: None, + }, + PublishConfig { + additional_fields: config2, + children: None, + }, + ]), + }), + ..Default::default() + }; + + // No database provided via CLI + let matches = cmd.clone().get_matches_from(vec!["publish"]); + let filtered = get_filtered_publish_configs(&spacetime_config, &schema, &matches).unwrap(); + + // Should return all configs (parent + 2 children) + assert_eq!(filtered.len(), 3); + } + + #[test] + fn test_empty_result_when_filter_no_match() { + use crate::spacetime_config::*; + use std::collections::HashMap; + + let cmd = cli(); + let schema = build_publish_schema(&cmd).unwrap(); + + let mut config1 = HashMap::new(); + config1.insert("database".to_string(), serde_json::Value::String("db1".to_string())); + + let mut parent_config = HashMap::new(); + parent_config.insert( + "database".to_string(), + serde_json::Value::String("parent-db".to_string()), + ); + + let spacetime_config = SpacetimeConfig { + publish: Some(PublishConfig { + additional_fields: parent_config, + children: Some(vec![PublishConfig { + additional_fields: config1, + children: None, + }]), + }), + ..Default::default() + }; + + // Filter by non-existent database + let matches = cmd.clone().get_matches_from(vec!["publish", "nonexistent"]); + let filtered = get_filtered_publish_configs(&spacetime_config, &schema, &matches).unwrap(); + + assert_eq!(filtered.len(), 0); + } } diff --git a/crates/cli/tests/dev.rs b/crates/cli/tests/dev.rs index 0bb0c913557..1e671daeffd 100644 --- a/crates/cli/tests/dev.rs +++ b/crates/cli/tests/dev.rs @@ -70,3 +70,47 @@ fn cli_init_with_template_creates_project() { ); assert!(project_dir.join("src").exists(), "src directory should exist"); } + +#[test] +fn config_with_invalid_field_shows_error() { + // Test that using invalid field names shows a helpful error message + let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); + + // Create a config with an invalid field name in dev + let config_content = r#"{ + "dev": { + "run_command": "npm run dev" + }, + "publish": { + "database": "test-db" + } +}"#; + std::fs::write(temp_dir.path().join("spacetime.json"), config_content).expect("failed to write config"); + + // Create minimal spacetimedb module + std::fs::create_dir(temp_dir.path().join("spacetimedb")).expect("failed to create spacetimedb dir"); + std::fs::create_dir(temp_dir.path().join("spacetimedb/src")).expect("failed to create src dir"); + std::fs::write( + temp_dir.path().join("spacetimedb/Cargo.toml"), + r#"[package] +name = "test" +version = "0.1.0" + +[dependencies] +spacetimedb = "1.0" + +[lib] +crate-type = ["cdylib"] +"#, + ) + .expect("failed to write Cargo.toml"); + std::fs::write(temp_dir.path().join("spacetimedb/src/lib.rs"), "").expect("failed to write lib.rs"); + + let mut cmd = cargo_bin_cmd!("spacetimedb-cli"); + cmd.current_dir(temp_dir.path()) + .args(["dev", "test-db"]) + .assert() + .failure() + .stderr(predicate::str::contains("Failed to load spacetime.json")) + .stderr(predicate::str::contains("unknown field `run_command`")); +} diff --git a/crates/cli/tests/generate.rs b/crates/cli/tests/generate.rs new file mode 100644 index 00000000000..1068801a18e --- /dev/null +++ b/crates/cli/tests/generate.rs @@ -0,0 +1,61 @@ +use assert_cmd::cargo::cargo_bin_cmd; + +#[test] +fn cli_generate_with_config_but_no_match_uses_cli_args() { + // Test that when config exists but doesn't match CLI args, we use CLI args + let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); + + // Initialize a new project (creates test-project/spacetimedb/) + let mut init_cmd = cargo_bin_cmd!("spacetimedb-cli"); + init_cmd + .args(["init", "--non-interactive", "--lang", "rust", "test-project"]) + .current_dir(temp_dir.path()) + .assert() + .success(); + + let project_dir = temp_dir.path().join("test-project"); + let module_dir = project_dir.join("spacetimedb"); + + // Create a config with a different module-path filter + let config_content = r#"{ + "generate": [ + { + "language": "typescript", + "out-dir": "./config-output", + "module-path": "config-module-path" + } + ] +}"#; + std::fs::write(module_dir.join("spacetime.json"), config_content).expect("failed to write config"); + + // Build the module first + let mut build_cmd = cargo_bin_cmd!("spacetimedb-cli"); + build_cmd + .args(["build", "--project-path", module_dir.to_str().unwrap()]) + .assert() + .success(); + + let output_dir = module_dir.join("cli-output"); + std::fs::create_dir(&output_dir).expect("failed to create output dir"); + + // Generate with different module-path from CLI - should use CLI args, not config + let mut cmd = cargo_bin_cmd!("spacetimedb-cli"); + cmd.args([ + "generate", + "--lang", + "rust", + "--out-dir", + output_dir.to_str().unwrap(), + "--project-path", + module_dir.to_str().unwrap(), + ]) + .current_dir(&module_dir) + .assert() + .success(); + + // Verify files were generated in the CLI-specified output directory + assert!( + output_dir.join("lib.rs").exists() || output_dir.join("mod.rs").exists(), + "Generated files should exist in CLI-specified output directory" + ); +} diff --git a/crates/cli/tests/publish.rs b/crates/cli/tests/publish.rs index 3ef8eab4a36..c3e62ea8283 100644 --- a/crates/cli/tests/publish.rs +++ b/crates/cli/tests/publish.rs @@ -177,3 +177,50 @@ fn cli_can_publish_breaking_change_with_on_conflict_flag() { true, ); } + +#[test] +fn cli_publish_with_config_but_no_match_uses_cli_args() { + // Test that when config exists but doesn't match CLI args, we use CLI args + let spacetime = SpacetimeDbGuard::spawn_in_temp_data_dir(); + let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); + + // Initialize a new project (creates test-project/spacetimedb/) + let mut init_cmd = cargo_bin_cmd!("spacetimedb-cli"); + init_cmd + .args(["init", "--non-interactive", "--lang", "rust", "test-project"]) + .current_dir(temp_dir.path()) + .assert() + .success(); + + let project_dir = temp_dir.path().join("test-project"); + let module_dir = project_dir.join("spacetimedb"); + + // Build the module first + let mut build_cmd = cargo_bin_cmd!("spacetimedb-cli"); + build_cmd + .args(["build", "--project-path", module_dir.to_str().unwrap()]) + .assert() + .success(); + + // Create a config with a different database name + let config_content = r#"{ + "publish": { + "database": "config-db-name" + } +}"#; + std::fs::write(module_dir.join("spacetime.json"), config_content).expect("failed to write config"); + + // Publish with a different database name from CLI - should use CLI args, not config + let mut cmd = cargo_bin_cmd!("spacetimedb-cli"); + cmd.args([ + "publish", + "--server", + &spacetime.host_url.to_string(), + "cli-db-name", + "--project-path", + module_dir.to_str().unwrap(), + ]) + .current_dir(&module_dir) + .assert() + .success(); +} 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..b7148916641 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,30 @@ 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 +{ + "dev": { + "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 --run "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 +135,7 @@ my-project/ │ └── module_bindings/ # Generated client bindings ├── package.json ├── tsconfig.json +├── spacetime.json # SpacetimeDB configuration └── README.md ``` @@ -128,6 +150,7 @@ my-project/ ├── module_bindings/ # Generated client bindings ├── client.csproj ├── Program.cs +├── spacetime.json # SpacetimeDB configuration └── README.md ``` @@ -143,6 +166,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..33f3b1ff211 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