diff --git a/Cargo.lock b/Cargo.lock index 6ed83e61eb0..4dd5734b164 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3511,6 +3511,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" @@ -5125,6 +5136,49 @@ dependencies = [ "spacetimedb 2.0.0", ] +[[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" @@ -7531,10 +7585,12 @@ dependencies = [ "fs_extra", "futures", "git2", + "glob", "http 1.3.1", "indicatif", "is-terminal", "itertools 0.12.1", + "json5", "mimalloc", "names", "notify", @@ -9747,6 +9803,12 @@ dependencies = [ "spacetimedb 2.0.0", ] +[[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 4c94938a7db..be4bbaacff7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -218,6 +218,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/bindings-cpp/tests/client-comparison/README.md b/crates/bindings-cpp/tests/client-comparison/README.md index e754f28ffb0..d0c774211b7 100644 --- a/crates/bindings-cpp/tests/client-comparison/README.md +++ b/crates/bindings-cpp/tests/client-comparison/README.md @@ -68,7 +68,7 @@ cd crates/bindings-cpp/tests/client-comparison ```bash # Rust baseline cd rust-sdk-test -spacetime generate --lang rust --out-dir . --project-path ../../../modules/sdk-test +spacetime generate --lang rust --out-dir . --module-path ../../../modules/sdk-test # Rust baseline ./scripts/regenerate_rust_client.sh diff --git a/crates/bindings-cpp/tests/client-comparison/scripts/regenerate_rust_client.sh b/crates/bindings-cpp/tests/client-comparison/scripts/regenerate_rust_client.sh index 3eed2cad3c4..d712410a965 100644 --- a/crates/bindings-cpp/tests/client-comparison/scripts/regenerate_rust_client.sh +++ b/crates/bindings-cpp/tests/client-comparison/scripts/regenerate_rust_client.sh @@ -75,7 +75,7 @@ fi # Generate new Rust client echo "Generating new Rust client from sdk-test module..." cd "$RUST_DIR" -"$CLI_PATH" generate --lang rust --out-dir . --project-path "$SDK_TEST_DIR" >/dev/null 2>&1 +"$CLI_PATH" generate --lang rust --out-dir . --module-path "$SDK_TEST_DIR" >/dev/null 2>&1 if [ $? -eq 0 ]; then echo "" diff --git a/crates/bindings-typescript/test-app/package.json b/crates/bindings-typescript/test-app/package.json index 8ba058dad9d..79efec60038 100644 --- a/crates/bindings-typescript/test-app/package.json +++ b/crates/bindings-typescript/test-app/package.json @@ -14,7 +14,7 @@ "preview": "vite preview", "generate": "cargo run -p gen-bindings -- --replacement ../../../src/index && prettier --write src/module_bindings", "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --project-path server", - "spacetime:start": "spacetime start", + "spacetime:start": "spacetime start server", "spacetime:publish:local": "spacetime publish game --project-path server --server local", "spacetime:publish": "spacetime publish game --project-path server --server maincloud" }, diff --git a/crates/bindings-typescript/test-react-router-app/package.json b/crates/bindings-typescript/test-react-router-app/package.json index 8cde69ad138..ddaff3ccb89 100644 --- a/crates/bindings-typescript/test-react-router-app/package.json +++ b/crates/bindings-typescript/test-react-router-app/package.json @@ -13,10 +13,10 @@ "lint": "eslint . && prettier . --check --ignore-path ../../../.prettierignore", "preview": "vite preview", "generate": "cargo run -p gen-bindings -- --replacement ../../../src/index && prettier --write src/module_bindings", - "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --project-path server", + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --module-path server", "spacetime:start": "spacetime start server", - "spacetime:publish:local": "spacetime publish game --project-path server --server local", - "spacetime:publish": "spacetime publish game --project-path server --server maincloud" + "spacetime:publish:local": "spacetime publish game --module-path server --server local", + "spacetime:publish": "spacetime publish game --module-path server --server maincloud" }, "dependencies": { "react": "^18.3.1", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index c06537d5042..3348e1a26ca 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 @@ -76,6 +77,7 @@ wasmbin.workspace = true webbrowser.workspace = true clap-markdown.workspace = true git2.workspace = true +glob.workspace = true dialoguer = { workspace = true, features = ["fuzzy-select"] } rolldown.workspace = true rolldown_common.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..29b45b8765a --- /dev/null +++ b/crates/cli/src/spacetime_config.rs @@ -0,0 +1,2807 @@ +use anyhow::Context; +use clap::{ArgMatches, Command}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +/// 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 or CLI")] + MissingRequiredKey { key: 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. +/// +/// The root object IS a database entity. `generate` is per-database +/// (inherited by children), and `dev` is root-only. +/// +/// Example (simple): +/// ```json +/// { +/// "database": "my-database", +/// "server": "local", +/// "module-path": "./server", +/// "dev": { "run": "pnpm dev" }, +/// "generate": [ +/// { "language": "typescript", "out-dir": "./src/module_bindings" } +/// ] +/// } +/// ``` +/// +/// Example (multi-database): +/// ```json +/// { +/// "server": "local", +/// "module-path": "./server", +/// "children": [ +/// { "database": "region-1" }, +/// { "database": "region-2", "module-path": "./region-server" } +/// ] +/// } +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub struct SpacetimeConfig { + /// Configuration for the dev command. Root-level only, not inherited. + #[serde(skip_serializing_if = "Option::is_none")] + pub dev: Option, + + /// Per-database generate entries. Inherited by children unless overridden. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate: Option>>, + + /// Child database entities. + #[serde(skip_serializing_if = "Option::is_none")] + pub children: Option>, + + /// All other entity-level fields (database, module-path, server, etc.) + #[serde(flatten)] + pub additional_fields: HashMap, +} + +/// 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, +} + +/// A fully resolved database target after inheritance. +/// Contains all fields needed for both publish and generate operations. +#[derive(Debug, Clone)] +pub struct FlatTarget { + /// All entity-level fields (database, module-path, server, etc.) + pub fields: HashMap, + /// Generate entries for this target (inherited or overridden) + pub generate: Option>>, +} + +/// Result of loading config from one or more files. +pub struct LoadedConfig { + pub config: SpacetimeConfig, + pub config_dir: PathBuf, + /// Which files contributed to this config + pub loaded_files: Vec, + /// Whether a dev-specific file (spacetime.dev.json or spacetime.dev.local.json) was loaded + pub has_dev_file: bool, +} + +impl SpacetimeConfig { + /// Collect all database targets with parent→child inheritance. + /// Children inherit unset `additional_fields` and `generate` from their parent. + /// `dev` and `children` are NOT propagated to child targets. + /// Returns `Vec` with fully resolved fields. + pub fn collect_all_targets_with_inheritance(&self) -> Vec { + self.collect_targets_inner(None, None) + } + + fn collect_targets_inner( + &self, + parent_fields: Option<&HashMap>, + parent_generate: Option<&Vec>>, + ) -> Vec { + // Build this node's fields by inheriting from parent + let mut fields = self.additional_fields.clone(); + if let Some(parent) = parent_fields { + for (key, value) in parent { + if !fields.contains_key(key) { + fields.insert(key.clone(), value.clone()); + } + } + } + + // Generate: child's generate replaces parent's; if absent, inherit parent's + let effective_generate = if self.generate.is_some() { + self.generate.clone() + } else { + parent_generate.cloned() + }; + + let target = FlatTarget { + fields: fields.clone(), + generate: effective_generate.clone(), + }; + + let mut result = vec![target]; + + if let Some(children) = &self.children { + for child in children { + let child_targets = child.collect_targets_inner(Some(&fields), effective_generate.as_ref()); + result.extend(child_targets); + } + } + + result + } + + /// Iterate through all database targets (self + children recursively). + /// Note: Does NOT apply parent→child inheritance. Use + /// `collect_all_targets_with_inheritance()` for that. + 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, + /// 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(), + }); + } + } + + // 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()); + + // 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, + 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> { + // 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() + } + + /// Get user-facing CLI flags (e.g. `--bin-path`) for all module-specific options + /// that were explicitly provided via CLI. + pub fn module_specific_cli_flags(&self, command: &Command, matches: &ArgMatches) -> Vec { + self.module_specific_cli_args(matches) + .iter() + .map(|arg| { + let clap_name = self.clap_arg_name_for(arg); + command + .get_arguments() + .find(|a| a.get_id().as_str() == clap_name) + .and_then(|a| a.get_long()) + .map(|long| format!("--{long}")) + .unwrap_or_else(|| format!("--{}", clap_name.replace('_', "-"))) + }) + .collect() + } + + /// Validate that module-specific CLI flags are not used when operating on multiple targets. + pub fn validate_no_module_specific_cli_args_for_multiple_targets( + &self, + command: &Command, + matches: &ArgMatches, + target_count: usize, + operation_context: &str, + resolution_hint: &str, + ) -> anyhow::Result<()> { + if target_count <= 1 { + return Ok(()); + } + + let display_args = self.module_specific_cli_flags(command, matches); + if display_args.is_empty() { + return Ok(()); + } + + anyhow::bail!( + "Cannot use module-specific arguments ({}) when {}. {}", + display_args.join(", "), + operation_context, + resolution_hint + ); + } + + /// Get all generate-entry-specific keys that were provided via CLI. + pub fn generate_entry_specific_cli_args(&self, matches: &ArgMatches) -> Vec<&str> { + self.keys + .iter() + .filter(|k| k.generate_entry_specific && self.is_from_cli(matches, k.config_name())) + .map(|k| k.config_name()) + .collect() + } + + /// Get user-facing CLI flags for generate-entry-specific options provided via CLI. + pub fn generate_entry_specific_cli_flags(&self, command: &Command, matches: &ArgMatches) -> Vec { + self.generate_entry_specific_cli_args(matches) + .iter() + .map(|arg| { + let clap_name = self.clap_arg_name_for(arg); + command + .get_arguments() + .find(|a| a.get_id().as_str() == clap_name) + .and_then(|a| a.get_long()) + .map(|long| format!("--{long}")) + .unwrap_or_else(|| format!("--{}", clap_name.replace('_', "-"))) + }) + .collect() + } + + /// Validate that generate-entry-specific CLI flags are not used when operating on multiple generate entries. + pub fn validate_no_generate_entry_specific_cli_args( + &self, + command: &Command, + matches: &ArgMatches, + entry_count: usize, + ) -> anyhow::Result<()> { + if entry_count <= 1 { + return Ok(()); + } + + let display_args = self.generate_entry_specific_cli_flags(command, matches); + if display_args.is_empty() { + return Ok(()); + } + + anyhow::bail!( + "Cannot use generate-entry-specific arguments ({}) when generating for multiple entries. \ + Specify a database name to select a single target, or remove these arguments.", + display_args.join(", "), + ); + } + + /// Get the clap argument name for a config key. + pub fn clap_arg_name_for<'a>(&'a self, config_name: &'a str) -> &'a str { + self.config_to_clap + .get(config_name) + .map(|s| s.as_str()) + .unwrap_or(config_name) + } +} + +/// 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 (per-database) + module_specific: bool, + /// Whether this key is generate-entry-specific (per-generate-entry within a database) + generate_entry_specific: bool, + /// Whether this key is required in the config file + required: bool, +} + +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, + generate_entry_specific: false, + required: false, + } + } + + /// 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 generate-entry-specific. These keys (like `language`, `out_dir`) + /// only make sense when a single generate entry is targeted. If multiple generate + /// entries exist and this key is provided via CLI, it's an error. + pub fn generate_entry_specific(mut self) -> Self { + self.generate_entry_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 + } + + /// 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. + /// + /// 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 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) { + serde_json::from_value::(value.clone()) + .map_err(|e| CommandConfigError::ConversionError { + key: key.to_string(), + target_type: std::any::type_name::().to_string(), + source: e.into(), + }) + .map(Some) + } else { + Ok(None) + } + } + + /// 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 either config or CLI. + pub fn validate(&self) -> Result<(), CommandConfigError> { + for key in &self.schema.keys { + if key.is_required() + && !self.config_values.contains_key(key.config_name()) + && !self.schema.is_from_cli(self.matches, key.config_name()) + { + return Err(CommandConfigError::MissingRequiredKey { + key: key.config_name().to_string(), + }); + } + } + Ok(()) + } +} + +impl SpacetimeConfig { + /// Find and load a spacetime.json file (convenience wrapper for no env). + /// + /// 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> { + Ok(find_and_load_with_env_from(None, start_dir)?.map(|loaded| { + let config_path = loaded.config_dir.join(CONFIG_FILENAME); + (config_path, loaded.config) + })) + } + + /// 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) + } +} + +/// Find the config directory by walking up from start_dir looking for spacetime.json. +fn find_config_dir(start_dir: PathBuf) -> Option { + let mut current_dir = start_dir; + loop { + let config_path = current_dir.join("spacetime.json"); + if config_path.exists() { + return Some(current_dir); + } + if !current_dir.pop() { + break; + } + } + None +} + +/// Load a JSON5 file as a serde_json::Value, or None if the file doesn't exist. +fn load_json_value(path: &Path) -> anyhow::Result> { + if !path.exists() { + return Ok(None); + } + let content = + std::fs::read_to_string(path).with_context(|| format!("Failed to read config file: {}", path.display()))?; + let value: serde_json::Value = json5::from_str(&content) + .map_err(|e| anyhow::anyhow!("Failed to parse config file {}: {}", path.display(), e))?; + Ok(Some(value)) +} + +/// Overlay `overlay` values onto `base` using top-level key replacement (shallow merge). +fn overlay_json(base: &mut serde_json::Value, overlay: serde_json::Value) { + if let (Some(base_obj), Some(overlay_obj)) = (base.as_object_mut(), overlay.as_object()) { + for (key, value) in overlay_obj { + base_obj.insert(key.clone(), value.clone()); + } + } +} + +/// Find and load config with environment layering from the current directory. +/// +/// Loading order (each overlays the previous via top-level key replacement): +/// 1. `spacetime.json` (required) +/// 2. `spacetime..json` (if env specified and file exists) +/// 3. `spacetime.local.json` (if exists) +/// 4. `spacetime..local.json` (if env specified and file exists) +pub fn find_and_load_with_env(env: Option<&str>) -> anyhow::Result> { + find_and_load_with_env_from(env, std::env::current_dir()?) +} + +/// Find and load config with environment layering starting from a specific directory. +pub fn find_and_load_with_env_from(env: Option<&str>, start_dir: PathBuf) -> anyhow::Result> { + let config_dir = match find_config_dir(start_dir) { + Some(dir) => dir, + None => return Ok(None), + }; + + let base_path = config_dir.join("spacetime.json"); + let mut merged = load_json_value(&base_path)? + .ok_or_else(|| anyhow::anyhow!("spacetime.json not found in {}", config_dir.display()))?; + + let mut loaded_files = vec![base_path]; + let mut has_dev_file = false; + + // Overlay environment-specific file + if let Some(env_name) = env { + let env_path = config_dir.join(format!("spacetime.{env_name}.json")); + if let Some(env_value) = load_json_value(&env_path)? { + overlay_json(&mut merged, env_value); + loaded_files.push(env_path); + if env_name == "dev" { + has_dev_file = true; + } + } + } + + // Overlay local file + let local_path = config_dir.join("spacetime.local.json"); + if let Some(local_value) = load_json_value(&local_path)? { + overlay_json(&mut merged, local_value); + loaded_files.push(local_path); + } + + // Overlay environment-specific local file + if let Some(env_name) = env { + let env_local_path = config_dir.join(format!("spacetime.{env_name}.local.json")); + if let Some(env_local_value) = load_json_value(&env_local_path)? { + overlay_json(&mut merged, env_local_value); + loaded_files.push(env_local_path); + if env_name == "dev" { + has_dev_file = true; + } + } + } + + let config: SpacetimeConfig = serde_json::from_value(merged).context("Failed to deserialize merged config")?; + + Ok(Some(LoadedConfig { + config, + config_dir, + loaded_files, + has_dev_file, + })) +} + +/// 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" + }, + "database": "bitcraft", + "module-path": "spacetimedb", + "server": "local", + "generate": [ + { + "out-dir": "./foobar", + "module-path": "region-module", + "language": "csharp" + }, + { + "out-dir": "./global", + "module-path": "global-module", + "language": "csharp" + } + ], + "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")); + + assert_eq!( + config.additional_fields.get("database").and_then(|v| v.as_str()), + Some("bitcraft") + ); + assert_eq!( + config.additional_fields.get("module-path").and_then(|v| v.as_str()), + Some("spacetimedb") + ); + + let children = config.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.children.is_none()); + assert!(config.additional_fields.is_empty()); + } + + #[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_database_entity_config_extraction() { + use clap::{Arg, Command}; + + // Parse a database entity config from JSON (database-centric model) + let json = r#"{ + "database": "my-database", + "server": "local", + "module-path": "./my-module", + "build-options": "--features extra", + "break-clients": true, + "anonymous": false + }"#; + + let config: SpacetimeConfig = json5::from_str(json).unwrap(); + + // Verify children field + assert!(config.children.is_none()); + + // Verify all fields are in additional_fields + assert_eq!( + config.additional_fields.get("database").and_then(|v| v.as_str()), + Some("my-database") + ); + assert_eq!( + config.additional_fields.get("server").and_then(|v| v.as_str()), + Some("local") + ); + assert_eq!( + config.additional_fields.get("module-path").and_then(|v| v.as_str()), + Some("./my-module") + ); + assert_eq!( + config.additional_fields.get("build-options").and_then(|v| v.as_str()), + Some("--features extra") + ); + assert_eq!( + 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, 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_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)); + + // 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)), + ); + + // 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)), + ); + + // 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))); + + // 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)), + ); + + // 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_validate_module_specific_uses_user_facing_flag_names() { + use clap::{Arg, Command}; + use std::path::PathBuf; + + let cmd = Command::new("test").arg( + Arg::new("wasm_file") + .long("bin-path") + .value_parser(clap::value_parser!(PathBuf)), + ); + + let matches = cmd + .clone() + .get_matches_from(vec!["test", "--bin-path", "./module.wasm"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new("wasm_file").module_specific()) + .build(&cmd) + .unwrap(); + + let err = schema + .validate_no_module_specific_cli_args_for_multiple_targets( + &cmd, + &matches, + 2, + "testing multiple targets", + "Select a single target.", + ) + .unwrap_err(); + let err_msg = err.to_string(); + assert!( + err_msg.contains("--bin-path"), + "Expected --bin-path in error, got: {err_msg}" + ); + } + + #[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.children.is_none()); + assert!(config.generate.is_none()); + } + + #[test] + fn test_serde_deserialize_u8_from_config() { + // Verifies that serde_json::from_value handles u8 (num_replicas) correctly, + // which was broken with the old TypeId-based approach. + let cmd = Command::new("test").arg( + Arg::new("num_replicas") + .long("num-replicas") + .value_parser(clap::value_parser!(u8)), + ); + + let matches = cmd.clone().get_matches_from(vec!["test"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new("num_replicas")) + .build(&cmd) + .unwrap(); + + let mut config_values = HashMap::new(); + config_values.insert("num_replicas".to_string(), Value::Number(serde_json::Number::from(3u8))); + + let command_config = CommandConfig::new(&schema, config_values, &matches).unwrap(); + + assert_eq!(command_config.get_one::("num_replicas").unwrap(), Some(3u8)); + } + + #[test] + fn test_serde_deserialize_bool_from_config() { + // Verifies that bool values (like include_private) can be read from config. + let cmd = Command::new("test").arg( + Arg::new("include_private") + .long("include-private") + .action(clap::ArgAction::SetTrue), + ); + + let matches = cmd.clone().get_matches_from(vec!["test"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new("include_private")) + .build(&cmd) + .unwrap(); + + let mut config_values = HashMap::new(); + config_values.insert("include_private".to_string(), Value::Bool(true)); + + let command_config = CommandConfig::new(&schema, config_values, &matches).unwrap(); + + assert_eq!(command_config.get_one::("include_private").unwrap(), Some(true)); + } + + #[test] + fn test_validate_required_key_provided_via_cli_only() { + // Verifies that validate() passes when a required key is provided + // via CLI but not in the config file. + 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", "--database", "my-db"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new("database").required()) + .key(Key::new("server")) + .build(&cmd) + .unwrap(); + + // Config is empty - required key "database" is only in CLI + let command_config = CommandConfig::new(&schema, HashMap::new(), &matches).unwrap(); + + // Should pass validation because CLI provides the required key + assert!(command_config.validate().is_ok()); + } + + #[test] + fn test_parent_child_inheritance() { + // Verifies that children inherit unset fields from the parent. + let json = r#"{ + "database": "parent-db", + "server": "local", + "module-path": "./parent-module", + "build-options": "--release", + "children": [ + { + "database": "child-1", + "module-path": "./child-module" + }, + { + "database": "child-2", + "module-path": "./child-module", + "server": "maincloud" + } + ] + }"#; + + let config: SpacetimeConfig = json5::from_str(json).unwrap(); + let targets = config.collect_all_targets_with_inheritance(); + + // Should have 3 targets: parent + 2 children + assert_eq!(targets.len(), 3); + + // Parent target + assert_eq!( + targets[0].fields.get("database").and_then(|v| v.as_str()), + Some("parent-db") + ); + assert_eq!(targets[0].fields.get("server").and_then(|v| v.as_str()), Some("local")); + + // Child 1: inherits server and build-options from parent + assert_eq!( + targets[1].fields.get("database").and_then(|v| v.as_str()), + Some("child-1") + ); + assert_eq!( + targets[1].fields.get("server").and_then(|v| v.as_str()), + Some("local") // inherited from parent + ); + assert_eq!( + targets[1].fields.get("build-options").and_then(|v| v.as_str()), + Some("--release") // inherited from parent + ); + + // Child 2: overrides server, inherits build-options + assert_eq!( + targets[2].fields.get("database").and_then(|v| v.as_str()), + Some("child-2") + ); + assert_eq!( + targets[2].fields.get("server").and_then(|v| v.as_str()), + Some("maincloud") // overridden + ); + assert_eq!( + targets[2].fields.get("build-options").and_then(|v| v.as_str()), + Some("--release") // inherited from parent + ); + } + + #[test] + fn test_parent_child_inheritance_no_children() { + // When there are no children, collect_all_targets_with_inheritance + // returns just the parent. + let json = r#"{ + "database": "single-db", + "server": "local", + "module-path": "./module" + }"#; + + let config: SpacetimeConfig = json5::from_str(json).unwrap(); + let targets = config.collect_all_targets_with_inheritance(); + + assert_eq!(targets.len(), 1); + assert_eq!( + targets[0].fields.get("database").and_then(|v| v.as_str()), + Some("single-db") + ); + } + + #[test] + fn test_nested_inheritance_grandchildren() { + // Verifies that inheritance works recursively: grandchildren + // inherit from their parent (which already inherited from grandparent). + let json = r#"{ + "server": "production", + "build-options": "--release", + "database": "root", + "children": [ + { + "database": "mid", + "module-path": "./mid-module", + "children": [ + { + "database": "leaf" + } + ] + } + ] + }"#; + + let config: SpacetimeConfig = json5::from_str(json).unwrap(); + let targets = config.collect_all_targets_with_inheritance(); + + // root + mid + leaf = 3 + assert_eq!(targets.len(), 3); + + // Root + assert_eq!(targets[0].fields.get("database").and_then(|v| v.as_str()), Some("root")); + + // Mid: inherits server and build-options from root, has own module-path + assert_eq!(targets[1].fields.get("database").and_then(|v| v.as_str()), Some("mid")); + assert_eq!( + targets[1].fields.get("server").and_then(|v| v.as_str()), + Some("production") + ); + assert_eq!( + targets[1].fields.get("module-path").and_then(|v| v.as_str()), + Some("./mid-module") + ); + + // Leaf: inherits server and build-options (from root via mid), + // AND inherits module-path from mid + assert_eq!(targets[2].fields.get("database").and_then(|v| v.as_str()), Some("leaf")); + assert_eq!( + targets[2].fields.get("server").and_then(|v| v.as_str()), + Some("production") + ); + assert_eq!( + targets[2].fields.get("build-options").and_then(|v| v.as_str()), + Some("--release") + ); + assert_eq!( + targets[2].fields.get("module-path").and_then(|v| v.as_str()), + Some("./mid-module") + ); + } + + #[test] + fn test_generate_inheritance_from_parent() { + // Children inherit generate from parent if they don't define their own + let json = r#"{ + "database": "parent-db", + "server": "local", + "generate": [ + { "language": "typescript", "out-dir": "./client/src/bindings" } + ], + "children": [ + { "database": "child-1" }, + { + "database": "child-2", + "generate": [ + { "language": "csharp", "out-dir": "./csharp-bindings" } + ] + } + ] + }"#; + + let config: SpacetimeConfig = json5::from_str(json).unwrap(); + let targets = config.collect_all_targets_with_inheritance(); + + assert_eq!(targets.len(), 3); + + // Parent has its own generate + let parent_gen = targets[0].generate.as_ref().unwrap(); + assert_eq!(parent_gen.len(), 1); + assert_eq!( + parent_gen[0].get("language").and_then(|v| v.as_str()), + Some("typescript") + ); + + // Child 1 inherits parent's generate + let child1_gen = targets[1].generate.as_ref().unwrap(); + assert_eq!(child1_gen.len(), 1); + assert_eq!( + child1_gen[0].get("language").and_then(|v| v.as_str()), + Some("typescript") + ); + + // Child 2 overrides with its own generate + let child2_gen = targets[2].generate.as_ref().unwrap(); + assert_eq!(child2_gen.len(), 1); + assert_eq!(child2_gen[0].get("language").and_then(|v| v.as_str()), Some("csharp")); + } + + #[test] + fn test_find_and_load_with_env_layering() { + use std::fs; + use tempfile::TempDir; + + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + // Base config + fs::write( + root.join("spacetime.json"), + r#"{ "database": "my-db", "server": "local" }"#, + ) + .unwrap(); + + // Dev environment overlay - replaces server + fs::write(root.join("spacetime.dev.json"), r#"{ "server": "maincloud" }"#).unwrap(); + + // Load without env + let result = find_and_load_with_env_from(None, root.to_path_buf()).unwrap().unwrap(); + assert_eq!( + result.config.additional_fields.get("server").and_then(|v| v.as_str()), + Some("local") + ); + assert!(!result.has_dev_file); + + // Load with dev env + let result = find_and_load_with_env_from(Some("dev"), root.to_path_buf()) + .unwrap() + .unwrap(); + assert_eq!( + result.config.additional_fields.get("server").and_then(|v| v.as_str()), + Some("maincloud") + ); + assert_eq!( + result.config.additional_fields.get("database").and_then(|v| v.as_str()), + Some("my-db") + ); + assert!(result.has_dev_file); + assert_eq!(result.loaded_files.len(), 2); + } + + #[test] + fn test_find_and_load_with_env_local_overlay() { + use std::fs; + use tempfile::TempDir; + + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + // Base config + fs::write( + root.join("spacetime.json"), + r#"{ "database": "my-db", "server": "local" }"#, + ) + .unwrap(); + + // Local overlay + fs::write(root.join("spacetime.local.json"), r#"{ "database": "my-local-db" }"#).unwrap(); + + let result = find_and_load_with_env_from(None, root.to_path_buf()).unwrap().unwrap(); + // Local overlay replaces database + assert_eq!( + result.config.additional_fields.get("database").and_then(|v| v.as_str()), + Some("my-local-db") + ); + // Server is preserved from base + assert_eq!( + result.config.additional_fields.get("server").and_then(|v| v.as_str()), + Some("local") + ); + } + + #[test] + fn test_multi_level_env_layering_staging() { + // Full overlay order: base → staging → local → staging.local + use std::fs; + use tempfile::TempDir; + + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + // Base config + fs::write( + root.join("spacetime.json"), + r#"{ "database": "base-db", "server": "local", "module-path": "./server" }"#, + ) + .unwrap(); + + // Staging env overlay + fs::write( + root.join("spacetime.staging.json"), + r#"{ "server": "staging-server", "database": "staging-db" }"#, + ) + .unwrap(); + + // Local overlay (applies after env) + fs::write( + root.join("spacetime.local.json"), + r#"{ "database": "local-override-db" }"#, + ) + .unwrap(); + + // Staging local overlay (applies last) + fs::write( + root.join("spacetime.staging.local.json"), + r#"{ "database": "staging-local-db" }"#, + ) + .unwrap(); + + let result = find_and_load_with_env_from(Some("staging"), root.to_path_buf()) + .unwrap() + .unwrap(); + + // database: base-db → staging-db → local-override-db → staging-local-db + assert_eq!( + result.config.additional_fields.get("database").and_then(|v| v.as_str()), + Some("staging-local-db") + ); + // server: local → staging-server (not overridden by local files) + assert_eq!( + result.config.additional_fields.get("server").and_then(|v| v.as_str()), + Some("staging-server") + ); + // module-path: only in base, preserved through all overlays + assert_eq!( + result + .config + .additional_fields + .get("module-path") + .and_then(|v| v.as_str()), + Some("./server") + ); + // 4 files loaded + assert_eq!(result.loaded_files.len(), 4); + } + + #[test] + fn test_has_dev_file_false_for_non_dev_env() { + // has_dev_file should only be true for env="dev", not for other envs + use std::fs; + use tempfile::TempDir; + + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + fs::write(root.join("spacetime.json"), r#"{ "database": "my-db" }"#).unwrap(); + + fs::write(root.join("spacetime.staging.json"), r#"{ "server": "staging" }"#).unwrap(); + + let result = find_and_load_with_env_from(Some("staging"), root.to_path_buf()) + .unwrap() + .unwrap(); + assert!(!result.has_dev_file, "has_dev_file should be false for staging env"); + + // But dev env should set it + fs::write(root.join("spacetime.dev.json"), r#"{ "server": "local" }"#).unwrap(); + + let result = find_and_load_with_env_from(Some("dev"), root.to_path_buf()) + .unwrap() + .unwrap(); + assert!(result.has_dev_file, "has_dev_file should be true for dev env"); + } + + #[test] + fn test_dev_not_propagated_to_children() { + // dev is root-only and should NOT appear in child targets + let json = r#"{ + "database": "parent-db", + "server": "local", + "dev": { "run": "npm run dev" }, + "children": [ + { "database": "child-db" } + ] + }"#; + + let config: SpacetimeConfig = json5::from_str(json).unwrap(); + let targets = config.collect_all_targets_with_inheritance(); + + assert_eq!(targets.len(), 2); + + // Parent should have database and server in fields + assert_eq!( + targets[0].fields.get("database").and_then(|v| v.as_str()), + Some("parent-db") + ); + + // Child should inherit server but NOT have dev in fields + assert_eq!( + targets[1].fields.get("database").and_then(|v| v.as_str()), + Some("child-db") + ); + assert_eq!(targets[1].fields.get("server").and_then(|v| v.as_str()), Some("local")); + // dev should not be in additional_fields of FlatTarget + assert!( + !targets[1].fields.contains_key("dev"), + "dev should not be propagated to children via additional_fields" + ); + // Also verify parent's flat target doesn't leak dev into fields + assert!( + !targets[0].fields.contains_key("dev"), + "dev should not appear in FlatTarget fields (it's a typed field, not in additional_fields)" + ); + } + + #[test] + fn test_generate_dedup_with_inherited_generate() { + // Two sibling databases sharing parent's generate + same module path + // should deduplicate to a single generate entry + let json = r#"{ + "module-path": "./server", + "generate": [ + { "language": "typescript", "out-dir": "./client/src/bindings" } + ], + "children": [ + { "database": "region-1" }, + { "database": "region-2" } + ] + }"#; + + let config: SpacetimeConfig = json5::from_str(json).unwrap(); + let targets = config.collect_all_targets_with_inheritance(); + + // All 3 targets (parent + 2 children) share the same module-path and generate + assert_eq!(targets.len(), 3); + for target in &targets { + assert_eq!( + target.fields.get("module-path").and_then(|v| v.as_str()), + Some("./server") + ); + let gen = target.generate.as_ref().unwrap(); + assert_eq!(gen.len(), 1); + assert_eq!(gen[0].get("language").and_then(|v| v.as_str()), Some("typescript")); + } + + // All have the same (module-path, generate) so dedup should reduce to 1 + // (this is verified in generate.rs tests, but we confirm the data here) + } + + #[test] + fn test_iter_all_targets_includes_self_and_descendants() { + let json = r#"{ + "database": "root", + "children": [ + { + "database": "mid", + "children": [ + { "database": "leaf" } + ] + } + ] + }"#; + + let config: SpacetimeConfig = json5::from_str(json).unwrap(); + let all: Vec<_> = config.iter_all_targets().collect(); + assert_eq!(all.len(), 3); + assert_eq!( + all[0].additional_fields.get("database").and_then(|v| v.as_str()), + Some("root") + ); + assert_eq!( + all[1].additional_fields.get("database").and_then(|v| v.as_str()), + Some("mid") + ); + assert_eq!( + all[2].additional_fields.get("database").and_then(|v| v.as_str()), + Some("leaf") + ); + } + + #[test] + fn test_count_targets() { + let json = r#"{ + "database": "root", + "children": [ + { "database": "child-1" }, + { + "database": "child-2", + "children": [ + { "database": "grandchild" } + ] + } + ] + }"#; + + let config: SpacetimeConfig = json5::from_str(json).unwrap(); + assert_eq!(config.count_targets(), 4); // root + child-1 + child-2 + grandchild + } +} diff --git a/crates/cli/src/subcommands/build.rs b/crates/cli/src/subcommands/build.rs index 79ef9082ed9..11e977c524f 100644 --- a/crates/cli/src/subcommands/build.rs +++ b/crates/cli/src/subcommands/build.rs @@ -1,3 +1,4 @@ +use crate::util::find_module_path; use crate::Config; use clap::ArgAction::SetTrue; use clap::{Arg, ArgMatches}; @@ -8,12 +9,11 @@ pub fn cli() -> clap::Command { clap::Command::new("build") .about("Builds a spacetime module.") .arg( - Arg::new("project_path") - .long("project-path") + Arg::new("module_path") + .long("module-path") .short('p') .value_parser(clap::value_parser!(PathBuf)) - .default_value(".") - .help("The system path (absolute or relative) to the project you would like to build") + .help("The system path (absolute or relative) to the module project. Defaults to spacetimedb/ subdirectory, then current directory.") ) .arg( Arg::new("lint_dir") @@ -42,7 +42,15 @@ pub fn cli() -> clap::Command { } pub async fn exec(_config: Config, args: &ArgMatches) -> Result<(PathBuf, &'static str), anyhow::Error> { - let project_path = args.get_one::("project_path").unwrap(); + let project_path = match args.get_one::("module_path").cloned() { + Some(path) => path, + None => find_module_path(&std::env::current_dir()?).ok_or_else(|| { + anyhow::anyhow!( + "Could not find a SpacetimeDB module in spacetimedb/ or the current directory. \ + Use --module-path to specify the module location." + ) + })?, + }; let features = args.get_one::("features"); let lint_dir = args.get_one::("lint_dir").unwrap(); let lint_dir = if lint_dir.is_empty() { @@ -67,7 +75,7 @@ pub async fn exec(_config: Config, args: &ArgMatches) -> Result<(PathBuf, &'stat )); } - let result = crate::tasks::build(project_path, lint_dir.as_deref(), build_debug, features)?; + let result = crate::tasks::build(&project_path, lint_dir.as_deref(), build_debug, features)?; println!("Build finished successfully."); Ok(result) @@ -80,7 +88,7 @@ pub async fn exec_with_argstring( ) -> Result<(PathBuf, &'static str), anyhow::Error> { // Note: "build" must be the start of the string, because `build::cli()` is the entire build subcommand. // If we don't include this, the args will be misinterpreted (e.g. as commands). - let arg_string = format!("build {} --project-path {}", arg_string, project_path.display()); + let arg_string = format!("build {} --module-path {}", arg_string, project_path.display()); let arg_matches = cli().get_matches_from(arg_string.split_whitespace()); exec(config.clone(), &arg_matches).await } diff --git a/crates/cli/src/subcommands/dev.rs b/crates/cli/src/subcommands/dev.rs index c5f57d5204d..783b47f8a3f 100644 --- a/crates/cli/src/subcommands/dev.rs +++ b/crates/cli/src/subcommands/dev.rs @@ -1,6 +1,9 @@ use crate::common_args::ClearMode; use crate::config::Config; use crate::generate::Language; +use crate::spacetime_config::{ + detect_client_command, find_and_load_with_env_from, CommandConfig, CommandSchema, 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, @@ -9,6 +12,7 @@ use crate::util::{ use crate::{common_args, generate}; use crate::{publish, tasks}; use anyhow::Context; +use clap::parser::ValueSource; use clap::{Arg, ArgMatches, Command}; use colored::Colorize; use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect}; @@ -18,7 +22,9 @@ use indicatif::{ProgressBar, ProgressStyle}; use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher}; use regex::Regex; use serde::Deserialize; +use serde_json::json; use std::borrow::Cow; +use std::collections::HashMap; use std::fs; use std::io::IsTerminal; use std::path::{Path, PathBuf}; @@ -29,6 +35,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; @@ -64,11 +71,10 @@ pub fn cli() -> Command { // This is not a requirement in general, but is a requirement for all templates // i.e. `spacetime dev` is valid on non-templates. .arg( - Arg::new("module-project-path") - .long("module-project-path") + Arg::new("module-path") + .long("module-path") .value_parser(clap::value_parser!(PathBuf)) - .default_value("spacetimedb") - .help("The path to the SpacetimeDB server module project relative to the project directory, defaults to `/spacetimedb`"), + .help("Path to the SpacetimeDB server module, relative to current directory. Defaults to `/spacetimedb`."), ) .arg( Arg::new("client-lang") @@ -86,6 +92,42 @@ 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"), + ) + .arg( + Arg::new("no_config") + .long("no-config") + .action(clap::ArgAction::SetTrue) + .help("Ignore spacetime.json configuration"), + ) + .arg( + Arg::new("env") + .long("env") + .value_name("ENV") + .help("Environment name for config file layering (e.g., dev, staging). Defaults to 'dev'."), + ) + .arg( + Arg::new("skip_publish") + .long("skip-publish") + .action(clap::ArgAction::SetTrue) + .help("Skip the publish step"), + ) + .arg( + Arg::new("skip_generate") + .long("skip-generate") + .action(clap::ArgAction::SetTrue) + .help("Skip the generate step"), + ) } #[derive(Deserialize)] @@ -101,7 +143,7 @@ struct DatabaseRow { pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { let project_path = args.get_one::("project-path").unwrap(); - let spacetimedb_project_path = args.get_one::("module-project-path").unwrap(); + let module_path_from_cli = args.get_one::("module-path"); let module_bindings_path = args.get_one::("module-bindings-path").unwrap(); let client_language = args.get_one::("client-lang"); let clear_database = args @@ -112,11 +154,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."))?; @@ -127,13 +169,110 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E } let mut module_bindings_dir = project_dir.join(module_bindings_path); - if spacetimedb_project_path.is_absolute() { - anyhow::bail!("SpacetimeDB project path must be a relative path"); + let mut spacetimedb_dir = match module_path_from_cli { + Some(path) => { + if path.is_absolute() { + path.clone() + } else { + std::env::current_dir()?.join(path) + } + } + None => project_dir.join("spacetimedb"), + }; + + let no_config = args.get_flag("no_config"); + let skip_publish = args.get_flag("skip_publish"); + let skip_generate = args.get_flag("skip_generate"); + + // --env defaults to "dev" for spacetime dev + let env = args.get_one::("env").map(|s| s.as_str()).unwrap_or("dev"); + + // Load spacetime.json config early so we can use it for determining project + // directories + let loaded_config = if no_config { + None + } else { + find_and_load_with_env_from(Some(env), project_dir.clone()).with_context(|| "Failed to load spacetime.json")? + }; + let spacetime_config = loaded_config.as_ref().map(|lc| &lc.config); + let using_spacetime_config = spacetime_config.is_some(); + // A config has publish targets if it has a "database" field or children + let has_publish_targets_in_config = spacetime_config + .map(|c| c.additional_fields.contains_key("database") || c.children.is_some()) + .unwrap_or(false); + let generate_configs_from_file: Vec> = + spacetime_config.and_then(|c| c.generate.clone()).unwrap_or_default(); + let has_generate_targets_in_config = !generate_configs_from_file.is_empty(); + + let module_path_from_cli_flag = args.value_source("module-path") == Some(ValueSource::CommandLine); + let project_path_from_cli_flag = args.value_source("project-path") == Some(ValueSource::CommandLine); + let module_bindings_path_from_cli_flag = + args.value_source("module-bindings-path") == Some(ValueSource::CommandLine); + + if has_publish_targets_in_config && module_path_from_cli_flag { + anyhow::bail!( + "`--module-path` cannot be used when `spacetime.json` contains publish targets. \ + Remove `--module-path` or run without publish targets in config." + ); + } + + if has_generate_targets_in_config + && (module_path_from_cli_flag || project_path_from_cli_flag || module_bindings_path_from_cli_flag) + { + anyhow::bail!( + "`--module-path`, `--project-path`, and `--module-bindings-path` cannot be used when \ + `spacetime.json` contains generate targets. Remove these flags or remove generate targets from config." + ); + } + + // Fetch the database name if it was passed through a CLI arg + let database_name_from_cli: Option = args + .get_one::("database") + .or_else(|| args.get_one::("database-flag")) + .map(|name| { + if args.get_one::("database-flag").is_some() { + println!( + "{} {}", + "Warning:".yellow().bold(), + "--database flag is deprecated. Use positional argument instead: spacetime dev ".dimmed() + ); + } + name.clone() + }); + + // Build publish configs. It is easier to work with one type of data, + // so if we don't have publish configs from the config file, we build a single + // publish config based on the CLI args + let publish_cmd = publish::cli(); + let publish_schema = publish::build_publish_schema(&publish_cmd)?; + + // Create ArgMatches for publish command + let mut publish_argv: Vec = vec!["publish".to_string()]; + if let Some(db) = &database_name_from_cli { + publish_argv.push(db.clone()); + } + if let Some(srv) = args.get_one::("server") { + publish_argv.push("--server".to_string()); + publish_argv.push(srv.clone()); } - let mut spacetimedb_dir = project_dir.join(spacetimedb_project_path); - // Check if we are in a SpacetimeDB project directory - if !spacetimedb_dir.exists() || !spacetimedb_dir.is_dir() { + let publish_args = publish_cmd + .clone() + .try_get_matches_from(publish_argv) + .context("Failed to create publish arguments")?; + + let mut publish_configs = determine_publish_configs( + database_name_from_cli, + spacetime_config, + &publish_cmd, + &publish_schema, + &publish_args, + resolved_server, + )?; + + // Check if we are in a SpacetimeDB project directory, but only if we don't have any + // publish_configs that would specify desired modules + if publish_configs.is_empty() && (!spacetimedb_dir.exists() || !spacetimedb_dir.is_dir()) { println!("{}", "No SpacetimeDB project found in current directory.".yellow()); let should_init = Confirm::new() .with_prompt("Would you like to initialize a new project?") @@ -156,7 +295,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E let canonical_created_path = created_project_path .canonicalize() .context("Failed to canonicalize created project path")?; - spacetimedb_dir = canonical_created_path.join(spacetimedb_project_path); + spacetimedb_dir = canonical_created_path.join("spacetimedb"); module_bindings_dir = canonical_created_path.join(module_bindings_path); project_dir = canonical_created_path; @@ -173,6 +312,60 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E ); } + if let Some(config) = publish_configs.first() { + // if we have publish configs and we're past spacetimedb_dir manipulation, + // we should set spacetimedb_dir to the path of the first config as this will be + // later used for next steps + if let Some(path) = config + .get_one::("module_path") + .context("failed to read module_path from config")? + { + spacetimedb_dir = path; + } + } + + let use_local = resolved_server == "local"; + + // If we don't have any publish configs by now, we need to ask the user about the + // database they want to use. This should only happen if no configs are available + // in the config file and no database name has been passed through the CLI + if publish_configs.is_empty() { + println!("\n{}", "Found existing SpacetimeDB project.".green()); + println!("Now we need to select a database to publish to.\n"); + + 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 + let token = get_login_token_or_log_in(&mut config, Some(resolved_server), !force).await?; + + let choice = FuzzySelect::with_theme(&ColorfulTheme::default()) + .with_prompt("Database selection") + .items(&["Create new database with random name", "Select from existing databases"]) + .default(0) + .interact()?; + + if choice == 0 { + generate_database_name() + } else { + select_database(&config, resolved_server, &token).await? + } + }; + + println!("\n{} {}", "Selected database:".green().bold(), selected.cyan()); + println!( + "{} {}", + "Tip:".yellow().bold(), + format!("Use `spacetime dev {}` to skip this question next time", selected).dimmed() + ); + + let mut config_map = HashMap::new(); + config_map.insert("database".to_string(), json!(selected)); + config_map.insert("server".to_string(), json!(resolved_server)); + + publish_configs = vec![CommandConfig::new(&publish_schema, config_map, &publish_args)?]; + } + 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 +381,28 @@ 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 + .map(|c| { + c.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 { @@ -211,60 +420,92 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E get_login_token_or_log_in(&mut config, Some(resolved_server), !force).await?; } } - let use_local = resolved_server == "local"; - // Check positional argument first, then deprecated --database flag - let database_name = if let Some(name) = args - .get_one::("database") - .or_else(|| args.get_one::("database-flag")) - { - if args.get_one::("database-flag").is_some() { - println!( - "{} {}", - "Warning:".yellow().bold(), - "--database flag is deprecated. Use positional argument instead: spacetime dev ".dimmed() - ); + // 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 if let Some(sc) = spacetime_config { + // Reuse already-loaded config instead of loading again + if let Some(ref lc) = loaded_config { + let files: Vec<_> = lc.loaded_files.iter().map(|f| f.display().to_string()).collect(); + println!("{} Using configuration from {}", "✓".green(), files.join(", ")); } - name.clone() - } else { - println!("\n{}", "Found existing SpacetimeDB project.".green()); - println!("Now we need to select a database to publish to.\n"); - if use_local { - generate_database_name() + if sc.dev.as_ref().and_then(|d| d.run.as_ref()).is_none() { + detect_and_save_client_command(&project_dir, Some(sc.clone())) } else { - // If not logged in before, but login was successful just now, this will have the token - let token = get_login_token_or_log_in(&mut config, Some(resolved_server), !force).await?; - - let choice = FuzzySelect::with_theme(&ColorfulTheme::default()) - .with_prompt("Database selection") - .items(&["Create new database with random name", "Select from existing databases"]) - .default(0) - .interact()?; - - if choice == 0 { - generate_database_name() - } else { - select_database(&config, resolved_server, &token).await? - } + sc.dev.as_ref().and_then(|d| d.run.clone()) } + } else { + // No config file - try to detect and create new + detect_and_save_client_command(&project_dir, None) }; - if args.get_one::("database").is_none() && args.get_one::("database-flag").is_none() { - println!("\n{} {}", "Selected database:".green().bold(), database_name.cyan()); + // 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()) + .ok_or_else(|| anyhow::anyhow!("database is a required field in publish config")) + .map(|s| s.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()); + 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!( - "{} {}", - "Tip:".yellow().bold(), - format!("Use `spacetime dev {}` to skip this question next time", database_name).dimmed() + "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()); + } } - println!("\n{}", "Starting development mode...".green().bold()); - println!("Database: {}", database_name.cyan()); - println!( - "Watching for changes in: {}", - spacetimedb_dir.display().to_string().cyan() - ); + // Safety prompt: warn if publishing from spacetime.json (not a dev-specific config) + if let Some(ref lc) = loaded_config { + if !lc.has_dev_file && !force { + eprintln!( + "{} Publishing from spacetime.json (not a dev-specific config).", + "Warning:".yellow().bold() + ); + eprintln!( + "{}", + "Consider creating spacetime.dev.json for development settings.".dimmed() + ); + let should_continue = Confirm::new().with_prompt("Continue?").default(true).interact()?; + if !should_continue { + anyhow::bail!("Aborted."); + } + } + } + + if let Some(ref cmd) = client_command { + println!("Client command: {}", cmd.cyan()); + } println!("{}", "Press Ctrl+C to stop".dimmed()); println!(); @@ -273,19 +514,76 @@ 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, + &generate_configs_from_file, + using_spacetime_config, + server_from_cli, force, + skip_publish, + skip_generate, ) .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( @@ -302,10 +600,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 { @@ -323,11 +621,15 @@ 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, + &generate_configs_from_file, + using_spacetime_config, + server_from_cli, force, + skip_publish, + skip_generate, ) .await { @@ -341,6 +643,46 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E } } +fn determine_publish_configs<'a>( + database_name: Option, + spacetime_config: Option<&SpacetimeConfig>, + publish_cmd: &Command, + publish_schema: &'a CommandSchema, + publish_args: &'a ArgMatches, + resolved_server: &str, +) -> anyhow::Result>> { + // Build publish configs. It is easier to work with one type of data, + // so if we don't have publish configs from the config file, we build a single + // publish config based on the CLI args + let mut publish_configs: Vec = vec![]; + + if let Some(config) = spacetime_config { + // Get and filter publish configs if the config has database targets + if config.additional_fields.contains_key("database") || config.children.is_some() { + publish_configs = publish::get_filtered_publish_configs(config, publish_cmd, publish_schema, publish_args)?; + } + } + + if !publish_configs.is_empty() { + return Ok(publish_configs); + } + + // If we still have no configs, it means that filtering by the database name filtered out + // all configs, we assume the user wants to run with a different DB + if let Some(ref db_name) = database_name { + let mut config_map = HashMap::new(); + config_map.insert("database".to_string(), json!(db_name)); + config_map.insert("server".to_string(), json!(resolved_server)); + config_map.insert("module-path".to_string(), json!("spacetimedb")); + + Ok(vec![CommandConfig::new(publish_schema, config_map, publish_args)?]) + } else { + // If there is no provided database name nor publish configs return no + // configs, we will handle it by asking user for a database or auto-generate one + Ok(vec![]) + } +} + /// Upserts all SPACETIMEDB_DB_NAME and SPACETIMEDB_HOST variants into `.env.local`, /// preserving comments/formatting and leaving unrelated keys unchanged. fn upsert_env_db_names_and_hosts(env_path: &Path, server_host_url: &str, database_name: &str) -> anyhow::Result<()> { @@ -392,84 +734,181 @@ 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<'_>], + generate_configs: &[HashMap], + using_spacetime_config: bool, + server: Option<&str>, yes: bool, + skip_publish: bool, + skip_generate: bool, ) -> Result<(), anyhow::Error> { - let module_language = detect_module_language(spacetimedb_dir)?; - let client_language = client_language.unwrap_or(match module_language { - crate::util::ModuleLanguage::Rust => &Language::Rust, - crate::util::ModuleLanguage::Csharp => &Language::Csharp, - crate::util::ModuleLanguage::Javascript => &Language::TypeScript, - crate::util::ModuleLanguage::Cpp => &Language::Rust, - }); - let client_language_str = match client_language { - Language::Rust => "rust", - Language::Csharp => "csharp", - Language::TypeScript => "typescript", - Language::UnrealCpp => "unrealcpp", - }; - - if client_language == &Language::TypeScript { - // Update SPACETIMEDB_DBNAME environment variables in `.env.local` for TypeScript client - println!( - "{} {}...", - "Updating .env.local with database name".cyan(), - database_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)?; - } - println!("{}", "Building...".cyan()); let (_path_to_program, _host_type) = tasks::build(spacetimedb_dir, Some(Path::new("src")), false, None).context("Failed to build project")?; println!("{}", "Build complete!".green()); - println!("{}", "Generating module bindings...".cyan()); - let mut generate_argv = vec![ - "generate", - "--lang", - client_language_str, - "--project-path", - spacetimedb_dir.to_str().unwrap(), - "--out-dir", - module_bindings_dir.to_str().unwrap(), - ]; - if yes { - generate_argv.push("--yes"); + if skip_generate { + println!("{}", "Skipping generate step (--skip-generate).".dimmed()); + } else if using_spacetime_config { + if generate_configs.is_empty() { + println!( + "{}", + "No generate targets in spacetime.json. Skipping module bindings generation.".dimmed() + ); + } else { + println!("{}", "Generating module bindings from spacetime.json...".cyan()); + let mut generate_argv = vec!["generate"]; + if yes { + generate_argv.push("--yes"); + } + let generate_args = generate::cli().get_matches_from(generate_argv); + generate::exec_ex( + config.clone(), + &generate_args, + crate::generate::extract_descriptions, + true, + None, + ) + .await?; + } + } else { + let module_language = detect_module_language(spacetimedb_dir)?; + let client_language = client_language.unwrap_or(match module_language { + crate::util::ModuleLanguage::Rust => &Language::Rust, + crate::util::ModuleLanguage::Csharp => &Language::Csharp, + crate::util::ModuleLanguage::Javascript => &Language::TypeScript, + crate::util::ModuleLanguage::Cpp => &Language::Rust, + }); + let client_language_str = match client_language { + Language::Rust => "rust", + Language::Csharp => "csharp", + Language::TypeScript => "typescript", + Language::UnrealCpp => "unrealcpp", + }; + + // For TypeScript client, update .env.local with first database name + if client_language == &Language::TypeScript { + 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(), + first_db_name + ); + let env_path = project_dir.join(".env.local"); + 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!("{}", "Generating module bindings...".cyan()); + let spacetimedb_dir_str = spacetimedb_dir.to_str().context("non-UTF-8 path in spacetimedb_dir")?; + let module_bindings_dir_str = module_bindings_dir + .to_str() + .context("non-UTF-8 path in module_bindings_dir")?; + let mut generate_argv = vec![ + "generate", + "--lang", + client_language_str, + "--module-path", + spacetimedb_dir_str, + "--out-dir", + module_bindings_dir_str, + ]; + if yes { + generate_argv.push("--yes"); + } + let generate_args = generate::cli().get_matches_from(generate_argv); + generate::exec_ex( + config.clone(), + &generate_args, + crate::generate::extract_descriptions, + true, + None, + ) + .await?; } - let generate_args = generate::cli().get_matches_from(generate_argv); - generate::exec(config.clone(), &generate_args).await?; - println!("{}", "Publishing...".cyan()); + if skip_publish { + println!("{}", "Skipping publish step (--skip-publish).".dimmed()); + return Ok(()); + } - let project_path_str = spacetimedb_dir.to_str().unwrap(); + println!("{}", "Publishing...".cyan()); 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()) + .ok_or_else(|| anyhow::anyhow!("database is a required field in publish config"))?; + + // Read module_path from each config entry, falling back to the shared spacetimedb_dir + let entry_module_path = config_entry + .get_config_value("module_path") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let module_path_str = entry_module_path + .as_deref() + .unwrap_or_else(|| spacetimedb_dir.to_str().expect("spacetimedb_dir should be valid UTF-8")); + + if publish_configs.len() > 1 { + println!("{} {}...", "Publishing to".cyan(), db_name.cyan().bold()); + } - publish::exec(config.clone(), &publish_matches).await?; + let mut publish_args = vec![ + "publish".to_string(), + db_name.to_string(), + "--module-path".to_string(), + module_path_str.to_string(), + "--yes".to_string(), + format!("--delete-data={}", clear_flag), + ]; + + // Forward per-target server from config if set, or CLI server override + if let Some(srv) = server { + publish_args.extend_from_slice(&["--server".to_string(), srv.to_string()]); + } else if let Some(srv) = config_entry.get_config_value("server").and_then(|v| v.as_str()) { + publish_args.extend_from_slice(&["--server".to_string(), srv.to_string()]); + } + + // Forward per-target build options if set + if let Some(build_opts) = config_entry.get_config_value("build_options").and_then(|v| v.as_str()) { + if !build_opts.is_empty() { + publish_args.extend_from_slice(&["--build-options".to_string(), build_opts.to_string()]); + } + } + + // Forward break-clients if set + if config_entry + .get_config_value("break_clients") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + { + publish_args.push("--break-clients".to_string()); + } + + 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, None).await?; + } println!("{}", "Published successfully!".green().bold()); println!("{}", "---".dimmed()); @@ -590,6 +1029,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())?; @@ -597,7 +1037,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; @@ -612,6 +1052,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")); @@ -642,7 +1083,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(); } @@ -680,7 +1121,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:?} ")?; @@ -752,3 +1204,314 @@ 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 database-centric config with generate but no dev-run + let initial_config = r#"{ + "database": "test-db", + "server": "maincloud", + "generate": [ + { "out-dir": "./foo-client/src/module_bindings", "module-path": "foo", "language": "rust" } + ] + }"#; + + 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_eq!( + loaded_config.additional_fields.get("database").and_then(|v| v.as_str()), + Some("test-db") + ); + + // 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_eq!( + reloaded_config + .additional_fields + .get("database") + .and_then(|v| v.as_str()), + Some("test-db"), + "database field should be preserved" + ); + assert_eq!( + reloaded_config.additional_fields.get("server").and_then(|v| v.as_str()), + Some("maincloud"), + "server 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" + ); + } + + #[test] + fn test_determine_publish_configs_no_database_no_config() { + // When there's no config and no CLI database name, returns empty vec + // (dev will later prompt the user) + let publish_cmd = publish::cli(); + let publish_schema = publish::build_publish_schema(&publish_cmd).unwrap(); + let publish_args = publish_cmd.clone().get_matches_from(vec!["publish"]); + + let result = + determine_publish_configs(None, None, &publish_cmd, &publish_schema, &publish_args, "local").unwrap(); + + assert!(result.is_empty()); + } + + #[test] + fn test_determine_publish_configs_cli_database_no_config() { + // When CLI provides a database name but no config, creates a single publish config + let publish_cmd = publish::cli(); + let publish_schema = publish::build_publish_schema(&publish_cmd).unwrap(); + let publish_args = publish_cmd.clone().get_matches_from(vec!["publish", "my-custom-db"]); + + let result = determine_publish_configs( + Some("my-custom-db".to_string()), + None, + &publish_cmd, + &publish_schema, + &publish_args, + "local", + ) + .unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0].get_config_value("database").and_then(|v| v.as_str()), + Some("my-custom-db") + ); + assert_eq!( + result[0].get_config_value("server").and_then(|v| v.as_str()), + Some("local") + ); + } + + #[test] + fn test_determine_publish_configs_with_config_targets() { + // When config has database targets, returns those targets + let publish_cmd = publish::cli(); + let publish_schema = publish::build_publish_schema(&publish_cmd).unwrap(); + let publish_args = publish_cmd.clone().get_matches_from(vec!["publish"]); + + let config: SpacetimeConfig = serde_json::from_value(serde_json::json!({ + "database": "config-db", + "server": "maincloud", + "module-path": "./server" + })) + .unwrap(); + + let result = determine_publish_configs( + None, + Some(&config), + &publish_cmd, + &publish_schema, + &publish_args, + "local", + ) + .unwrap(); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0].get_one::("database").unwrap(), + Some("config-db".to_string()) + ); + } + + #[test] + fn test_determine_publish_configs_config_no_database_falls_through() { + // Config exists but has no database field or children → falls through to CLI database + let publish_cmd = publish::cli(); + let publish_schema = publish::build_publish_schema(&publish_cmd).unwrap(); + let publish_args = publish_cmd.clone().get_matches_from(vec!["publish", "cli-db"]); + + // Config with only dev and generate, no database + let config: SpacetimeConfig = serde_json::from_value(serde_json::json!({ + "dev": { "run": "npm run dev" }, + "generate": [{ "language": "typescript", "out-dir": "./bindings" }] + })) + .unwrap(); + + let result = determine_publish_configs( + Some("cli-db".to_string()), + Some(&config), + &publish_cmd, + &publish_schema, + &publish_args, + "local", + ) + .unwrap(); + + // Should fall through to CLI database since config has no publish targets + assert_eq!(result.len(), 1); + assert_eq!( + result[0].get_config_value("database").and_then(|v| v.as_str()), + Some("cli-db") + ); + } + + #[test] + fn test_cli_env_flag_defaults_to_dev() { + // Verify that the dev CLI defaults --env to "dev" + let cmd = cli(); + let matches = cmd.clone().get_matches_from(vec!["dev"]); + + // --env is not set, so it should return None from clap + let env_from_cli = matches.get_one::("env"); + assert!(env_from_cli.is_none(), "env should not be set by default in clap"); + + // But in exec(), we default to "dev": + let env = env_from_cli.map(|s| s.as_str()).unwrap_or("dev"); + assert_eq!(env, "dev"); + } + + #[test] + fn test_cli_skip_flags_exist() { + // Verify that --skip-publish and --skip-generate flags are registered + let cmd = cli(); + + let matches = cmd + .clone() + .get_matches_from(vec!["dev", "--skip-publish", "--skip-generate"]); + + assert!(matches.get_flag("skip_publish")); + assert!(matches.get_flag("skip_generate")); + } + + #[test] + fn test_cli_env_flag_accepts_value() { + let cmd = cli(); + let matches = cmd.clone().get_matches_from(vec!["dev", "--env", "staging"]); + + assert_eq!(matches.get_one::("env").map(|s| s.as_str()), Some("staging")); + } +} diff --git a/crates/cli/src/subcommands/generate.rs b/crates/cli/src/subcommands/generate.rs index d7e40b4f2de..4d45edec931 100644 --- a/crates/cli/src/subcommands/generate.rs +++ b/crates/cli/src/subcommands/generate.rs @@ -16,26 +16,169 @@ use spacetimedb_schema::def::ModuleDef; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use crate::spacetime_config::{ + find_and_load_with_env, CommandConfig, CommandSchema, CommandSchemaBuilder, Key, LoadedConfig, SpacetimeConfig, +}; use crate::tasks::csharp::dotnet_format; use crate::tasks::rust::rustfmt; -use crate::util::{resolve_sibling_binary, y_or_n}; +use crate::util::{find_module_path, resolve_sibling_binary, y_or_n}; use crate::Config; use crate::{build, common_args}; use clap::builder::PossibleValue; -use std::collections::BTreeSet; + +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() + .generate_entry_specific(), + ) + .key(Key::new("out_dir").generate_entry_specific()) + .key(Key::new("uproject_dir").generate_entry_specific()) + .key(Key::new("module_path").module_specific()) + .key(Key::new("wasm_file").module_specific()) + .key(Key::new("js_file").module_specific()) + .key(Key::new("namespace").generate_entry_specific()) + .key(Key::new("unreal_module_name").generate_entry_specific()) + .key(Key::new("build_options").module_specific()) + .key(Key::new("include_private")) + .exclude("json_module") + .exclude("force") + .exclude("no_config") + .exclude("env") + .exclude("database") + .build(command) + .map_err(Into::into) +} + +/// Get filtered generate configs based on CLI arguments. +/// +/// Uses the database-centric model: collects all targets with inheritance, +/// filters by database name (glob), then collects generate entries from matched targets. +/// Deduplicates by (canonical_module_path, serialized_generate_entry). +fn get_filtered_generate_configs<'a>( + spacetime_config: &SpacetimeConfig, + command: &clap::Command, + schema: &'a CommandSchema, + args: &'a clap::ArgMatches, +) -> Result>, anyhow::Error> { + // Get all database targets from config with parent→child inheritance + let all_targets = spacetime_config.collect_all_targets_with_inheritance(); + + if all_targets.is_empty() { + return Ok(vec![]); + } + + // Filter by database name pattern (glob) if provided via CLI + let filtered_targets = if let Some(cli_database) = args.get_one::("database") { + let pattern = + glob::Pattern::new(cli_database).with_context(|| format!("Invalid glob pattern: {cli_database}"))?; + + let matched: Vec<_> = all_targets + .into_iter() + .filter(|target| { + target + .fields + .get("database") + .and_then(|v| v.as_str()) + .is_some_and(|db| pattern.matches(db)) + }) + .collect(); + + if matched.is_empty() { + anyhow::bail!( + "No database target matches '{}'. Available databases: {}", + cli_database, + spacetime_config + .collect_all_targets_with_inheritance() + .iter() + .filter_map(|t| t.fields.get("database").and_then(|v| v.as_str())) + .collect::>() + .join(", ") + ); + } + + matched + } else { + all_targets + }; + + // Collect generate entries from matched targets, inheriting entity fields + // Deduplicate by (module_path, serialized_generate_entry) + let mut seen = std::collections::HashSet::new(); + let mut generate_configs = Vec::new(); + + for target in &filtered_targets { + let generate_entries = match &target.generate { + Some(entries) if !entries.is_empty() => entries, + _ => continue, + }; + + // Get module_path from the target's entity fields for dedup + let module_path = target.fields.get("module-path").and_then(|v| v.as_str()).unwrap_or(""); + + for entry in generate_entries { + // Deduplicate: same module path + same generate entry config = generate once + let dedup_key = format!("{}:{}", module_path, serde_json::to_string(entry).unwrap_or_default()); + if !seen.insert(dedup_key) { + continue; + } + + // Merge entity-level fields (module-path, etc.) with the generate entry + let mut merged = entry.clone(); + // Inherit module-path from the target entity if not set in the generate entry + if let Some(mp) = target.fields.get("module-path") { + merged.entry("module-path".to_string()).or_insert_with(|| mp.clone()); + } + + let command_config = CommandConfig::new(schema, merged, args)?; + command_config.validate()?; + generate_configs.push(command_config); + } + } + + if generate_configs.is_empty() { + return Ok(vec![]); + } + + // Validate generate-entry-specific flags when multiple entries + schema.validate_no_generate_entry_specific_cli_args(command, args, generate_configs.len())?; + + // Also validate module-specific flags + schema.validate_no_module_specific_cli_args_for_multiple_targets( + command, + args, + generate_configs.len(), + "generating for multiple targets", + "Please specify a database name to select a single target, or remove these arguments.", + )?; + + Ok(generate_configs) +} + pub fn cli() -> clap::Command { clap::Command::new("generate") .about("Generate client files for a spacetime module.") - .override_usage("spacetime generate --lang --out-dir [--project-path | --bin-path | --module-name | --uproject-dir | --include-private]") + .override_usage("spacetime generate [DATABASE] --lang --out-dir [--module-path | --bin-path | --unreal-module-name | --uproject-dir | --include-private]") + .arg( + Arg::new("database") + .help("Database name or glob pattern to filter which databases to generate for"), + ) .arg( Arg::new("wasm_file") .value_parser(clap::value_parser!(PathBuf)) .long("bin-path") .short('b') .group("source") - .conflicts_with("project_path") + .conflicts_with("module_path") .conflicts_with("build_options") .help("The system path (absolute or relative) to the compiled wasm binary we should inspect"), ) @@ -45,18 +188,17 @@ pub fn cli() -> clap::Command { .long("js-path") .short('j') .group("source") - .conflicts_with("project_path") + .conflicts_with("module_path") .conflicts_with("build_options") .help("The system path (absolute or relative) to the bundled javascript file we should inspect"), ) .arg( - Arg::new("project_path") + Arg::new("module_path") .value_parser(clap::value_parser!(PathBuf)) - .default_value(".") - .long("project-path") + .long("module-path") .short('p') .group("source") - .help("The system path (absolute or relative) to the project you would like to inspect"), + .help("The system path (absolute or relative) to the module project. Defaults to spacetimedb/ subdirectory, then current directory."), ) .arg( Arg::new("json_module") @@ -72,17 +214,13 @@ 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") .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,14 +229,13 @@ pub fn cli() -> clap::Command { .help("The namespace that should be used"), ) .arg( - Arg::new("module_name") - .long("module-name") + Arg::new("unreal_module_name") + .long("unreal-module-name") + .alias("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)) @@ -120,16 +257,24 @@ pub fn cli() -> clap::Command { .help("Include private tables and functions in generated code (types are always included)."), ) .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) + .arg( + Arg::new("no_config") + .long("no-config") + .action(SetTrue) + .help("Ignore spacetime.json configuration") + ) + .arg( + Arg::new("env") + .long("env") + .value_name("ENV") + .action(Set) + .help("Environment name for config file layering (e.g., dev, staging)") ) + .after_help("Run `spacetime help generate` for more detailed information.") } 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, None).await } /// Like `exec`, but lets you specify a custom a function to extract a schema from a file. @@ -137,161 +282,258 @@ pub async fn exec_ex( config: Config, args: &clap::ArgMatches, extract_descriptions: ExtractDescriptions, + quiet_config: bool, + pre_loaded_config: Option<&LoadedConfig>, ) -> 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(); - let include_private = args.get_flag("include_private"); - - 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 no_config = args.get_flag("no_config"); + let env = args.get_one::("env").map(|s| s.as_str()); - 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()? + // Get generate configs (from spacetime.json or empty) + let owned_loaded; + let loaded_config_ref = if no_config { + None + } else if let Some(pre) = pre_loaded_config { + Some(pre) } 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")? + owned_loaded = find_and_load_with_env(env)?; + owned_loaded.as_ref().inspect(|loaded| { + if !quiet_config { + for path in &loaded.loaded_files { + println!("Using configuration from {}", path.display()); + } + } + }) + }; + let (using_config, generate_configs) = if let Some(loaded) = loaded_config_ref { + let filtered = get_filtered_generate_configs(&loaded.config, &cmd, &schema, args)?; + if filtered.is_empty() { + anyhow::bail!( + "No matching generate target found in spacetime.json for the provided arguments. \ + Use --no-config to ignore the config file." + ); + } + (true, filtered) + } else { + (false, vec![CommandConfig::new(&schema, HashMap::new(), args)?]) }; - let private_tables = private_table_names(&module); - if !private_tables.is_empty() && !include_private { - println!("Skipping private tables during codegen: {}.", private_tables.join(", ")); - } + // 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 = match command_config.get_one::("module_path")? { + Some(path) => path, + None if using_config => { + anyhow::bail!("module-path must be specified for each generate target when using spacetime.json"); + } + None => find_module_path(&std::env::current_dir()?).ok_or_else(|| { + anyhow::anyhow!( + "Could not find a SpacetimeDB module in spacetimedb/ or the current directory. \ + Use --module-path to specify the module location." + ) + })?, + }; + 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 options = CodegenOptions::default(); - if include_private { - options.visibility = CodegenVisibility::IncludePrivate; - } + println!( + "Generating {} module bindings for module {}", + lang.display_name(), + project_path.display() + ); + + let namespace = command_config + .get_one::("namespace")? + .unwrap_or_else(|| "SpacetimeDB.Types".to_string()); + let module_name = command_config.get_one::("unreal_module_name")?; + let force = args.get_flag("force"); + let build_options = command_config + .get_one::("build_options")? + .unwrap_or_else(String::new); - fs::create_dir_all(out_dir)?; + // 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 mut paths = BTreeSet::new(); + // 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"))?; - let csharp_lang; - let unreal_cpp_lang; - let gen_lang = match lang { - Language::Csharp => { - csharp_lang = Csharp { namespace }; - &csharp_lang as &dyn Lang + // 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!("--unreal-module-name is required for --lang unrealcpp")); + } + } } - Language::UnrealCpp => { - unreal_cpp_lang = UnrealCpp { - module_name: module_name.as_ref().unwrap(), - uproject_dir: out_dir, + + 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())? }; - &unreal_cpp_lang as &dyn Lang - } - Language::Rust => &Rust, - Language::TypeScript => &TypeScript, - }; + 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")? + }; - for OutputFile { filename, code } in generate(&module, gen_lang, &options) { - 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()) { - println!("Creating directory {}", out_dir.join(parent).display()); - fs::create_dir_all(out_dir.join(parent))?; + fs::create_dir_all(&out_dir)?; + + let mut paths = BTreeSet::new(); + + let include_private = command_config.get_one::("include_private")?.unwrap_or(false); + let private_tables = private_table_names(&module); + if !private_tables.is_empty() && !include_private { + println!("Skipping private tables during codegen: {}.", private_tables.join(", ")); } - let path = out_dir.join(fname); - if !path.exists() || fs::read_to_string(&path)? != code { - println!("Writing file {}", path.display()); - fs::write(&path, code)?; + let mut options = CodegenOptions::default(); + if include_private { + options.visibility = CodegenVisibility::IncludePrivate; } - 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 csharp_lang; + let unreal_cpp_lang; + let gen_lang = match lang { + Language::Csharp => { + csharp_lang = Csharp { namespace: &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, + }; - // 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); + for OutputFile { filename, code } in generate(&module, gen_lang, &options) { + 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()) { + println!("Creating directory {}", out_dir.join(parent).display()); + fs::create_dir_all(out_dir.join(parent))?; } - let path = entry.into_path(); - // Don't delete regenerated files. - if paths.contains(&path) { - return Ok(None); + let path = out_dir.join(fname); + if !path.exists() || fs::read_to_string(&path)? != code { + println!("Writing file {}", path.display()); + fs::write(&path, code)?; } - // 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()), + 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(), + }; + + // 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::>>()?; + .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 !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)?; + 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(()) } -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq, serde::Deserialize)] +#[serde(rename_all = "lowercase")] pub enum Language { Csharp, TypeScript, Rust, + #[serde(alias = "uecpp", alias = "ue5cpp", alias = "unreal")] UnrealCpp, } @@ -310,6 +552,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)?, @@ -327,7 +579,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") @@ -338,3 +590,478 @@ 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; + + /// Helper to build a SpacetimeConfig with generate entries (database-centric) + fn make_gen_config( + fields: HashMap, + generate: Vec>, + ) -> SpacetimeConfig { + SpacetimeConfig { + generate: Some(generate), + additional_fields: fields, + ..Default::default() + } + } + + #[test] + fn test_filter_by_database_name() { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + + let mut db1_fields = HashMap::new(); + db1_fields.insert("database".to_string(), serde_json::json!("db1")); + db1_fields.insert("module-path".to_string(), serde_json::json!("./module1")); + + let gen1 = { + let mut m = HashMap::new(); + m.insert("language".to_string(), serde_json::json!("rust")); + m.insert("out_dir".to_string(), serde_json::json!("/tmp/out1")); + m + }; + + let mut db2_fields = HashMap::new(); + db2_fields.insert("database".to_string(), serde_json::json!("db2")); + db2_fields.insert("module-path".to_string(), serde_json::json!("./module2")); + + let gen2 = { + let mut m = HashMap::new(); + m.insert("language".to_string(), serde_json::json!("typescript")); + m.insert("out_dir".to_string(), serde_json::json!("/tmp/out2")); + m + }; + + let spacetime_config = SpacetimeConfig { + children: Some(vec![ + make_gen_config(db1_fields, vec![gen1]), + make_gen_config(db2_fields, vec![gen2]), + ]), + ..Default::default() + }; + + // Filter by db1 + let matches = cmd.clone().get_matches_from(vec!["generate", "db1"]); + let filtered = get_filtered_generate_configs(&spacetime_config, &cmd, &schema, &matches).unwrap(); + + assert_eq!(filtered.len(), 1, "Should only match db1's generate entry"); + } + + #[test] + fn test_no_filter_returns_all_generate_entries() { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + + let mut fields = HashMap::new(); + fields.insert("module-path".to_string(), serde_json::json!("./module")); + + let gen1 = { + let mut m = HashMap::new(); + m.insert("language".to_string(), serde_json::json!("rust")); + m.insert("out_dir".to_string(), serde_json::json!("/tmp/out1")); + m + }; + let gen2 = { + let mut m = HashMap::new(); + m.insert("language".to_string(), serde_json::json!("typescript")); + m.insert("out_dir".to_string(), serde_json::json!("/tmp/out2")); + m + }; + + let spacetime_config = make_gen_config(fields, vec![gen1, gen2]); + + let matches = cmd.clone().get_matches_from(vec!["generate"]); + let filtered = get_filtered_generate_configs(&spacetime_config, &cmd, &schema, &matches).unwrap(); + + assert_eq!(filtered.len(), 2); + } + + #[test] + fn test_generate_entry_inherits_module_path_from_parent() { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + + let mut parent_fields = HashMap::new(); + parent_fields.insert("module-path".to_string(), serde_json::json!("./server")); + + let mut child_fields = HashMap::new(); + child_fields.insert("database".to_string(), serde_json::json!("my-db")); + + let gen = { + let mut m = HashMap::new(); + m.insert("language".to_string(), serde_json::json!("rust")); + m.insert("out_dir".to_string(), serde_json::json!("/tmp/out")); + m + }; + + let spacetime_config = SpacetimeConfig { + additional_fields: parent_fields, + children: Some(vec![make_gen_config(child_fields, vec![gen])]), + ..Default::default() + }; + + let matches = cmd.clone().get_matches_from(vec!["generate", "my-db"]); + let filtered = get_filtered_generate_configs(&spacetime_config, &cmd, &schema, &matches).unwrap(); + + assert_eq!(filtered.len(), 1); + // module_path should be inherited from parent + assert_eq!( + filtered[0].get_one::("module_path").unwrap(), + Some(PathBuf::from("./server")) + ); + } + + #[test] + fn test_generate_deduplication() { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + + let gen = { + let mut m = HashMap::new(); + m.insert("language".to_string(), serde_json::json!("typescript")); + m.insert("out_dir".to_string(), serde_json::json!("/tmp/out")); + m + }; + + let mut parent_fields = HashMap::new(); + parent_fields.insert("module-path".to_string(), serde_json::json!("./server")); + + let spacetime_config = SpacetimeConfig { + additional_fields: parent_fields, + generate: Some(vec![gen]), + children: Some(vec![ + { + let mut f = HashMap::new(); + f.insert("database".to_string(), serde_json::json!("region-1")); + SpacetimeConfig { + additional_fields: f, + ..Default::default() + } + }, + { + let mut f = HashMap::new(); + f.insert("database".to_string(), serde_json::json!("region-2")); + SpacetimeConfig { + additional_fields: f, + ..Default::default() + } + }, + ]), + ..Default::default() + }; + + let matches = cmd.clone().get_matches_from(vec!["generate"]); + let filtered = get_filtered_generate_configs(&spacetime_config, &cmd, &schema, &matches).unwrap(); + + // Same module-path + same generate entry = deduplicated + assert_eq!( + filtered.len(), + 1, + "Expected deduplication: same module + same generate config = generate once" + ); + } + + #[test] + fn test_generate_entry_specific_args_error_with_multiple_entries() { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + + let mut fields = HashMap::new(); + fields.insert("module-path".to_string(), serde_json::json!("./module")); + + let gen1 = { + let mut m = HashMap::new(); + m.insert("language".to_string(), serde_json::json!("rust")); + m.insert("out_dir".to_string(), serde_json::json!("/tmp/out1")); + m + }; + let gen2 = { + let mut m = HashMap::new(); + m.insert("language".to_string(), serde_json::json!("typescript")); + m.insert("out_dir".to_string(), serde_json::json!("/tmp/out2")); + m + }; + + let spacetime_config = make_gen_config(fields, vec![gen1, gen2]); + + let matches = cmd + .clone() + .get_matches_from(vec!["generate", "--out-dir", "/tmp/override"]); + let err = get_filtered_generate_configs(&spacetime_config, &cmd, &schema, &matches).unwrap_err(); + let err_msg = err.to_string(); + assert!( + err_msg.contains("--out-dir"), + "Expected error to mention --out-dir, got: {err_msg}" + ); + } + + // 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; + + 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(); + 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_unreal_module_name() { + use crate::config::Config; + use spacetimedb_paths::cli::CliTomlPath; + use spacetimedb_paths::FromPathUnchecked; + + let cmd = cli(); + let config = Config::new_with_localhost(CliTomlPath::from_path_unchecked("/tmp/test-config.toml")); + + // Test missing --uproject-dir (use alias --module-name for backwards compat) + 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") || err_msg.contains("--out-dir"), + "Expected error about missing --uproject-dir or --out-dir, got: {err_msg}", + ); + + // Test missing --unreal-module-name + let matches = + cmd.clone() + .get_matches_from(vec!["generate", "--lang", "unrealcpp", "--uproject-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("--unreal-module-name is required for --lang unrealcpp"), + "Expected error about missing --unreal-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 unreal_module_name (via alias --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(); + + let uproject_dir = command_config.get_one::("uproject_dir").unwrap(); + let module_name = command_config.get_one::("unreal_module_name").unwrap(); + + assert_eq!(uproject_dir, Some(PathBuf::from("/config/path"))); + assert_eq!(module_name, Some("MyModule".to_string())); + } + + #[test] + fn test_generate_dedup_with_inherited_generate_from_parent() { + // Two sibling databases inheriting the same generate + same module-path from parent + // should deduplicate to a single generate entry + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + + let gen = { + let mut m = HashMap::new(); + m.insert("language".to_string(), serde_json::json!("typescript")); + m.insert("out_dir".to_string(), serde_json::json!("/tmp/bindings")); + m + }; + + let mut parent_fields = HashMap::new(); + parent_fields.insert("module-path".to_string(), serde_json::json!("./server")); + + let spacetime_config = SpacetimeConfig { + additional_fields: parent_fields, + generate: Some(vec![gen]), + children: Some(vec![ + { + let mut f = HashMap::new(); + f.insert("database".to_string(), serde_json::json!("region-1")); + SpacetimeConfig { + additional_fields: f, + ..Default::default() + } + }, + { + let mut f = HashMap::new(); + f.insert("database".to_string(), serde_json::json!("region-2")); + SpacetimeConfig { + additional_fields: f, + ..Default::default() + } + }, + ]), + ..Default::default() + }; + + // No filter — all 3 targets (parent + 2 children) share same module-path + same generate + let matches = cmd.clone().get_matches_from(vec!["generate"]); + let filtered = get_filtered_generate_configs(&spacetime_config, &cmd, &schema, &matches).unwrap(); + + // Should be deduplicated to 1 entry since all have same (module_path, generate_entry) + assert_eq!( + filtered.len(), + 1, + "Inherited generate entries with same module-path should be deduplicated" + ); + } + + #[test] + fn test_generate_glob_filter_matches_pattern() { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + + let gen = { + let mut m = HashMap::new(); + m.insert("language".to_string(), serde_json::json!("rust")); + m.insert("out_dir".to_string(), serde_json::json!("/tmp/out")); + m + }; + + let spacetime_config = SpacetimeConfig { + children: Some(vec![ + make_gen_config( + { + let mut m = HashMap::new(); + m.insert("database".to_string(), serde_json::json!("region-1")); + m.insert("module-path".to_string(), serde_json::json!("./m1")); + m + }, + vec![gen.clone()], + ), + make_gen_config( + { + let mut m = HashMap::new(); + m.insert("database".to_string(), serde_json::json!("region-2")); + m.insert("module-path".to_string(), serde_json::json!("./m2")); + m + }, + vec![gen.clone()], + ), + make_gen_config( + { + let mut m = HashMap::new(); + m.insert("database".to_string(), serde_json::json!("global")); + m.insert("module-path".to_string(), serde_json::json!("./m3")); + m + }, + vec![gen], + ), + ]), + ..Default::default() + }; + + // Glob: region-* should match region-1 and region-2 but not global + let matches = cmd.clone().get_matches_from(vec!["generate", "region-*"]); + let filtered = get_filtered_generate_configs(&spacetime_config, &cmd, &schema, &matches).unwrap(); + + // region-1 and region-2 have different module-paths, so no dedup → 2 entries + assert_eq!(filtered.len(), 2); + } + + #[test] + fn test_generate_error_when_glob_matches_nothing() { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + + let gen = { + let mut m = HashMap::new(); + m.insert("language".to_string(), serde_json::json!("rust")); + m.insert("out_dir".to_string(), serde_json::json!("/tmp/out")); + m + }; + + let spacetime_config = make_gen_config( + { + let mut m = HashMap::new(); + m.insert("database".to_string(), serde_json::json!("my-db")); + m.insert("module-path".to_string(), serde_json::json!("./server")); + m + }, + vec![gen], + ); + + let matches = cmd.clone().get_matches_from(vec!["generate", "nonexistent-*"]); + let result = get_filtered_generate_configs(&spacetime_config, &cmd, &schema, &matches); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("No database target matches"), + "Error should mention no match, got: {err_msg}" + ); + } + + #[test] + fn test_language_serde_deserialize_all_variants() { + // Verify all Language variants deserialize correctly from config JSON strings. + // This catches drift between the serde and clap ValueEnum impls. + assert_eq!( + serde_json::from_value::(serde_json::Value::String("csharp".into())).unwrap(), + Language::Csharp + ); + assert_eq!( + serde_json::from_value::(serde_json::Value::String("typescript".into())).unwrap(), + Language::TypeScript + ); + assert_eq!( + serde_json::from_value::(serde_json::Value::String("rust".into())).unwrap(), + Language::Rust + ); + assert_eq!( + serde_json::from_value::(serde_json::Value::String("unrealcpp".into())).unwrap(), + Language::UnrealCpp + ); + + // Aliases + assert_eq!( + serde_json::from_value::(serde_json::Value::String("uecpp".into())).unwrap(), + Language::UnrealCpp + ); + assert_eq!( + serde_json::from_value::(serde_json::Value::String("ue5cpp".into())).unwrap(), + Language::UnrealCpp + ); + assert_eq!( + serde_json::from_value::(serde_json::Value::String("unreal".into())).unwrap(), + Language::UnrealCpp + ); + + // Invalid language should error + assert!(serde_json::from_value::(serde_json::Value::String("java".into())).is_err()); + } +} diff --git a/crates/cli/src/subcommands/init.rs b/crates/cli/src/subcommands/init.rs index c123ac67302..df6a6a0e580 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 spacetimedb_data_structures::map::{HashCollectionExt as _, 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 { @@ -345,26 +346,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{}", @@ -490,6 +471,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) { @@ -497,34 +488,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) @@ -1320,41 +1305,41 @@ fn print_next_steps(config: &TemplateConfig, _project_path: &Path) -> anyhow::Re match (config.template_type, config.server_lang, config.client_lang) { (TemplateType::Builtin, Some(ServerLanguage::Rust), Some(ClientLanguage::Rust)) => { println!( - " spacetime publish --project-path spacetimedb {}{}", + " spacetime publish --module-path spacetimedb {}{}", if config.use_local { "--server local " } else { "" }, config.project_name ); - println!(" spacetime generate --lang rust --out-dir src/module_bindings --project-path spacetimedb"); + println!(" spacetime generate --lang rust --out-dir src/module_bindings --module-path spacetimedb"); println!(" cargo run"); } (TemplateType::Builtin, Some(ServerLanguage::TypeScript), Some(ClientLanguage::TypeScript)) => { println!(" npm install"); println!( - " spacetime publish --project-path spacetimedb {}{}", + " spacetime publish --module-path spacetimedb {}{}", if config.use_local { "--server local " } else { "" }, config.project_name ); - println!(" spacetime generate --lang typescript --out-dir src/module_bindings --project-path spacetimedb"); + println!(" spacetime generate --lang typescript --out-dir src/module_bindings --module-path spacetimedb"); println!(" npm run dev"); } (TemplateType::Builtin, Some(ServerLanguage::Csharp), Some(ClientLanguage::Csharp)) => { println!( - " spacetime publish --project-path spacetimedb {}{}", + " spacetime publish --module-path spacetimedb {}{}", if config.use_local { "--server local " } else { "" }, config.project_name ); - println!(" spacetime generate --lang csharp --out-dir module_bindings --project-path spacetimedb"); + println!(" spacetime generate --lang csharp --out-dir module_bindings --module-path spacetimedb"); } (TemplateType::Empty, _, Some(ClientLanguage::TypeScript)) => { println!(" npm install"); if config.server_lang.is_some() { println!( - " spacetime publish --project-path spacetimedb {}{}", + " spacetime publish --module-path spacetimedb {}{}", if config.use_local { "--server local " } else { "" }, config.project_name ); println!( - " spacetime generate --lang typescript --out-dir src/module_bindings --project-path spacetimedb" + " spacetime generate --lang typescript --out-dir src/module_bindings --module-path spacetimedb" ); } println!(" npm run dev"); @@ -1362,11 +1347,11 @@ fn print_next_steps(config: &TemplateConfig, _project_path: &Path) -> anyhow::Re (TemplateType::Empty, _, Some(ClientLanguage::Rust)) => { if config.server_lang.is_some() { println!( - " spacetime publish --project-path spacetimedb {}{}", + " spacetime publish --module-path spacetimedb {}{}", if config.use_local { "--server local " } else { "" }, config.project_name ); - println!(" spacetime generate --lang rust --out-dir src/module_bindings --project-path spacetimedb"); + println!(" spacetime generate --lang rust --out-dir src/module_bindings --module-path spacetimedb"); } println!(" cargo run"); } diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index 4c8a77f2fcb..4785cf4ac76 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -10,10 +10,109 @@ use std::{env, fs}; use crate::common_args::ClearMode; use crate::config::Config; -use crate::util::{add_auth_header_opt, get_auth_header, AuthHeader, ResponseExt}; +use crate::spacetime_config::{ + find_and_load_with_env, CommandConfig, CommandSchema, CommandSchemaBuilder, FlatTarget, Key, LoadedConfig, + SpacetimeConfig, +}; +use crate::util::{add_auth_header_opt, find_module_path, 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").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")) + .key(Key::new("organization")) + .exclude("clear-database") + .exclude("force") + .exclude("no_config") + .exclude("env") + .build(command) + .map_err(Into::into) +} + +/// Get filtered publish configs based on CLI arguments. +/// Uses glob matching on database names when a pattern is provided via CLI. +pub fn get_filtered_publish_configs<'a>( + spacetime_config: &SpacetimeConfig, + command: &clap::Command, + schema: &'a CommandSchema, + args: &'a ArgMatches, +) -> Result>, anyhow::Error> { + // Get all database targets from config with parent→child inheritance + let all_targets = spacetime_config.collect_all_targets_with_inheritance(); + + // If no targets, return empty (will use CLI args only) + if all_targets.is_empty() { + return Ok(vec![]); + } + + // Filter by database name pattern (glob) if provided via CLI + let filtered_targets: Vec = if schema.is_from_cli(args, "database") { + let cli_database = schema.get_clap_arg::(args, "database")?.unwrap_or_default(); + + let pattern = + glob::Pattern::new(&cli_database).with_context(|| format!("Invalid glob pattern: {cli_database}"))?; + + let matched: Vec = all_targets + .into_iter() + .filter(|target| { + target + .fields + .get("database") + .and_then(|v| v.as_str()) + .is_some_and(|db| pattern.matches(db)) + }) + .collect(); + + if matched.is_empty() { + anyhow::bail!( + "No database target matches '{}'. Available databases: {}", + cli_database, + spacetime_config + .collect_all_targets_with_inheritance() + .iter() + .filter_map(|t| t.fields.get("database").and_then(|v| v.as_str())) + .collect::>() + .join(", ") + ); + } + + matched + } else { + all_targets + }; + + // Build CommandConfig for each target + let configs: Vec = filtered_targets + .into_iter() + .map(|target| { + let config = CommandConfig::new(schema, target.fields, args)?; + config.validate()?; + Ok(config) + }) + .collect::, anyhow::Error>>()?; + + schema.validate_no_module_specific_cli_args_for_multiple_targets( + command, + args, + configs.len(), + "publishing to multiple targets", + "Please specify the database name or identity to select a single target, or remove these arguments.", + )?; + + Ok(configs) +} + pub fn cli() -> clap::Command { clap::Command::new("publish") .about("Create and update a SpacetimeDB database") @@ -30,19 +129,18 @@ pub fn cli() -> clap::Command { .help("Options to pass to the build command, for example --build-options='--lint-dir='") ) .arg( - Arg::new("project_path") + Arg::new("module_path") .value_parser(clap::value_parser!(PathBuf)) - .default_value(".") - .long("project-path") + .long("module-path") .short('p') - .help("The system path (absolute or relative) to the module project") + .help("The system path (absolute or relative) to the module project. Defaults to spacetimedb/ subdirectory, then current directory.") ) .arg( Arg::new("wasm_file") .value_parser(clap::value_parser!(PathBuf)) .long("bin-path") .short('b') - .conflicts_with("project_path") + .conflicts_with("module_path") .conflicts_with("build_options") .conflicts_with("js_file") .help("The system path (absolute or relative) to the compiled wasm binary we should publish, instead of building the project."), @@ -52,7 +150,7 @@ pub fn cli() -> clap::Command { .value_parser(clap::value_parser!(PathBuf)) .long("js-path") .short('j') - .conflicts_with("project_path") + .conflicts_with("module_path") .conflicts_with("build_options") .conflicts_with("wasm_file") .help("UNSTABLE: The system path (absolute or relative) to the javascript file we should publish, instead of building the project."), @@ -111,6 +209,19 @@ i.e. only lowercase ASCII letters and numbers, separated by dashes."), .arg( common_args::yes() ) + .arg( + Arg::new("no_config") + .long("no-config") + .action(SetTrue) + .help("Ignore spacetime.json configuration") + ) + .arg( + Arg::new("env") + .long("env") + .value_name("ENV") + .action(Set) + .help("Environment name for config file layering (e.g., dev, staging)") + ) .after_help("Run `spacetime help publish` for more detailed information.") } @@ -155,163 +266,264 @@ fn confirm_major_version_upgrade() -> Result<(), anyhow::Error> { anyhow::bail!("Aborting because major version upgrade was not accepted."); } -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"); - let org = args.get_one::("organization"); - - // 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, None).await +} + +/// 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, + pre_loaded_config: Option<&LoadedConfig>, +) -> Result<(), anyhow::Error> { + // Build schema + let cmd = cli(); + let schema = build_publish_schema(&cmd)?; - // 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") + let no_config = args.get_flag("no_config"); + let env = args.get_one::("env").map(|s| s.as_str()); + + // Get publish configs (from spacetime.json or empty) + let owned_loaded; + let loaded_config_ref = if no_config { + None + } else if let Some(pre) = pre_loaded_config { + Some(pre) } else { - build::exec_with_argstring(config.clone(), path_to_project, build_options).await? + owned_loaded = find_and_load_with_env(env)?; + owned_loaded.as_ref().inspect(|loaded| { + if !quiet_config { + for path in &loaded.loaded_files { + println!("Using configuration from {}", path.display()); + } + } + }) }; - let program_bytes = fs::read(path_to_program)?; - let server_address = { - let url = Url::parse(&database_host)?; - url.host_str().unwrap_or("").to_string() + let (using_config, publish_configs) = if let Some(loaded) = loaded_config_ref { + let filtered = get_filtered_publish_configs(&loaded.config, &cmd, &schema, args)?; + if filtered.is_empty() { + anyhow::bail!( + "No matching target found in spacetime.json for the provided arguments. \ + Use --no-config to ignore the config file." + ); + } + (true, filtered) + } else { + ( + false, + vec![CommandConfig::new(&schema, std::collections::HashMap::new(), args)?], + ) }; - 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 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 path_to_project = if wasm_file.is_some() || js_file.is_some() { + command_config.get_one::("module_path")? + } else { + Some(match command_config.get_one::("module_path")? { + Some(path) => path, + None if using_config => { + anyhow::bail!("module-path must be specified for each publish target when using spacetime.json"); + } + None => find_module_path(&std::env::current_dir()?).ok_or_else(|| { + anyhow::anyhow!( + "Could not find a SpacetimeDB module in spacetimedb/ or the current directory. \ + Use --module-path to specify the module location." + ) + })?, + }) + }; + + if using_config { + if let Some(path_to_project) = path_to_project.as_ref() { + println!( + "Publishing module {} to database '{}'", + path_to_project.display(), + name_or_identity.unwrap() + ); + } else { + println!( + "Publishing precompiled module to database '{}'", + name_or_identity.unwrap() + ); + } } - } + 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(); + let org_opt = command_config.get_one::("organization")?; + let org = org_opt.as_deref(); - println!( - "Uploading to {} => {}", - server.unwrap_or(config.default_server_name().unwrap_or("")), - database_host - ); + // 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 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)?; + let (name_or_identity, parent) = validate_name_and_parent(name_or_identity, parent)?; + + if let Some(path_to_project) = path_to_project.as_ref() { + if !path_to_project.exists() { + return Err(anyhow::anyhow!( + "Project path does not exist: {}", + path_to_project.display() + )); + } + } + + // 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, + build::exec_with_argstring( + config.clone(), + path_to_project + .as_ref() + .expect("path_to_project must exist when publishing from source"), + &build_options, ) - .await?; + .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)]); - } - if let Some(org) = org { - builder = builder.query(&[("org", org)]); - } + 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)]); + } + if let Some(org) = org { + builder = builder.query(&[("org", org)]); + } - // 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", - )); } } @@ -482,6 +694,7 @@ async fn call_pre_publish( mod tests { use pretty_assertions::assert_matches; use spacetimedb_lib::Identity; + use std::collections::HashMap; use super::*; @@ -540,4 +753,305 @@ mod tests { Ok(res) if res == (Some(&child), Some(&parent)) ); } + + /// Helper to build a SpacetimeConfig with additional_fields (database-centric). + fn make_config(fields: HashMap) -> SpacetimeConfig { + SpacetimeConfig { + additional_fields: fields, + ..Default::default() + } + } + + fn make_config_with_children( + fields: HashMap, + children: Vec, + ) -> SpacetimeConfig { + SpacetimeConfig { + additional_fields: fields, + children: Some(children), + ..Default::default() + } + } + + #[test] + fn test_filter_by_database_from_cli() { + use std::collections::HashMap; + + let cmd = cli(); + let schema = build_publish_schema(&cmd).unwrap(); + + let mut parent_fields = HashMap::new(); + parent_fields.insert("database".to_string(), serde_json::json!("parent-db")); + + let mut child1_fields = HashMap::new(); + child1_fields.insert("database".to_string(), serde_json::json!("db1")); + + let mut child2_fields = HashMap::new(); + child2_fields.insert("database".to_string(), serde_json::json!("db2")); + + let spacetime_config = make_config_with_children( + parent_fields, + vec![make_config(child1_fields), make_config(child2_fields)], + ); + + // Filter by db1 (should only match child1, not parent or child2) + let matches = cmd.clone().get_matches_from(vec!["publish", "db1"]); + let filtered = get_filtered_publish_configs(&spacetime_config, &cmd, &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 std::collections::HashMap; + + let cmd = cli(); + let schema = build_publish_schema(&cmd).unwrap(); + + let mut parent_fields = HashMap::new(); + parent_fields.insert("database".to_string(), serde_json::json!("parent-db")); + + let mut child1_fields = HashMap::new(); + child1_fields.insert("database".to_string(), serde_json::json!("db1")); + + let mut child2_fields = HashMap::new(); + child2_fields.insert("database".to_string(), serde_json::json!("db2")); + + let spacetime_config = make_config_with_children( + parent_fields, + vec![make_config(child1_fields), make_config(child2_fields)], + ); + + // No database provided via CLI + let matches = cmd.clone().get_matches_from(vec!["publish"]); + let filtered = get_filtered_publish_configs(&spacetime_config, &cmd, &schema, &matches).unwrap(); + + // Should return all configs (parent + 2 children) + assert_eq!(filtered.len(), 3); + } + + #[test] + fn test_error_when_filter_no_match() { + use std::collections::HashMap; + + let cmd = cli(); + let schema = build_publish_schema(&cmd).unwrap(); + + let mut parent_fields = HashMap::new(); + parent_fields.insert("database".to_string(), serde_json::json!("parent-db")); + + let mut child1_fields = HashMap::new(); + child1_fields.insert("database".to_string(), serde_json::json!("db1")); + + let spacetime_config = make_config_with_children(parent_fields, vec![make_config(child1_fields)]); + + // Filter by non-existent database — now errors instead of returning empty + let matches = cmd.clone().get_matches_from(vec!["publish", "nonexistent"]); + let result = get_filtered_publish_configs(&spacetime_config, &cmd, &schema, &matches); + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!(err_msg.contains("No database target matches")); + } + + #[test] + fn test_glob_filter_matches_pattern() { + use std::collections::HashMap; + + let cmd = cli(); + let schema = build_publish_schema(&cmd).unwrap(); + + let mut parent_fields = HashMap::new(); + parent_fields.insert("server".to_string(), serde_json::json!("local")); + + let spacetime_config = make_config_with_children( + parent_fields, + vec![ + make_config({ + let mut m = HashMap::new(); + m.insert("database".to_string(), serde_json::json!("region-1")); + m + }), + make_config({ + let mut m = HashMap::new(); + m.insert("database".to_string(), serde_json::json!("region-2")); + m + }), + make_config({ + let mut m = HashMap::new(); + m.insert("database".to_string(), serde_json::json!("global")); + m + }), + ], + ); + + // Glob: region-* should match region-1 and region-2 but not global + let matches = cmd.clone().get_matches_from(vec!["publish", "region-*"]); + let filtered = get_filtered_publish_configs(&spacetime_config, &cmd, &schema, &matches).unwrap(); + + assert_eq!(filtered.len(), 2); + } + + #[test] + fn test_publish_filter_inherits_parent_fields() { + use std::collections::HashMap; + + let cmd = cli(); + let schema = build_publish_schema(&cmd).unwrap(); + + let mut parent_fields = HashMap::new(); + parent_fields.insert("database".to_string(), serde_json::json!("parent-db")); + parent_fields.insert("server".to_string(), serde_json::json!("local")); + + let mut child_fields = HashMap::new(); + child_fields.insert("database".to_string(), serde_json::json!("child-db")); + // child does NOT set "server" — should inherit from parent + + let spacetime_config = make_config_with_children(parent_fields, vec![make_config(child_fields)]); + + // Filter to the child target + let matches = cmd.clone().get_matches_from(vec!["publish", "child-db"]); + let filtered = get_filtered_publish_configs(&spacetime_config, &cmd, &schema, &matches).unwrap(); + + assert_eq!(filtered.len(), 1); + // The child should have inherited "server" from the parent + assert_eq!( + filtered[0].get_one::("server").unwrap(), + Some("local".to_string()) + ); + } + + #[test] + fn test_glob_star_matches_all() { + use std::collections::HashMap; + + let cmd = cli(); + let schema = build_publish_schema(&cmd).unwrap(); + + let spacetime_config = make_config_with_children( + HashMap::new(), + vec![ + make_config({ + let mut m = HashMap::new(); + m.insert("database".to_string(), serde_json::json!("alpha")); + m + }), + make_config({ + let mut m = HashMap::new(); + m.insert("database".to_string(), serde_json::json!("beta")); + m + }), + make_config({ + let mut m = HashMap::new(); + m.insert("database".to_string(), serde_json::json!("gamma")); + m + }), + ], + ); + + // Glob: * should match all databases + let matches = cmd.clone().get_matches_from(vec!["publish", "*"]); + let filtered = get_filtered_publish_configs(&spacetime_config, &cmd, &schema, &matches).unwrap(); + + assert_eq!(filtered.len(), 3); + } + + #[test] + fn test_glob_exact_match() { + use std::collections::HashMap; + + let cmd = cli(); + let schema = build_publish_schema(&cmd).unwrap(); + + let spacetime_config = make_config_with_children( + HashMap::new(), + vec![ + make_config({ + let mut m = HashMap::new(); + m.insert("database".to_string(), serde_json::json!("region-1")); + m + }), + make_config({ + let mut m = HashMap::new(); + m.insert("database".to_string(), serde_json::json!("region-2")); + m + }), + ], + ); + + // Exact match should return only one + let matches = cmd.clone().get_matches_from(vec!["publish", "region-1"]); + let filtered = get_filtered_publish_configs(&spacetime_config, &cmd, &schema, &matches).unwrap(); + + assert_eq!(filtered.len(), 1); + assert_eq!( + filtered[0].get_one::("database").unwrap(), + Some("region-1".to_string()) + ); + } + + #[test] + fn test_glob_multiple_wildcards() { + use std::collections::HashMap; + + let cmd = cli(); + let schema = build_publish_schema(&cmd).unwrap(); + + let spacetime_config = make_config_with_children( + HashMap::new(), + vec![ + make_config({ + let mut m = HashMap::new(); + m.insert("database".to_string(), serde_json::json!("us-east-prod")); + m + }), + make_config({ + let mut m = HashMap::new(); + m.insert("database".to_string(), serde_json::json!("us-west-prod")); + m + }), + make_config({ + let mut m = HashMap::new(); + m.insert("database".to_string(), serde_json::json!("eu-east-staging")); + m + }), + make_config({ + let mut m = HashMap::new(); + m.insert("database".to_string(), serde_json::json!("us-east-staging")); + m + }), + ], + ); + + // Pattern with multiple wildcards: *-east-* + let matches = cmd.clone().get_matches_from(vec!["publish", "*-east-*"]); + let filtered = get_filtered_publish_configs(&spacetime_config, &cmd, &schema, &matches).unwrap(); + + assert_eq!(filtered.len(), 3); // us-east-prod, eu-east-staging, us-east-staging + } + + #[test] + fn test_glob_empty_pattern_error() { + use std::collections::HashMap; + + let cmd = cli(); + let schema = build_publish_schema(&cmd).unwrap(); + + let spacetime_config = make_config_with_children( + HashMap::new(), + vec![make_config({ + let mut m = HashMap::new(); + m.insert("database".to_string(), serde_json::json!("my-db")); + m + })], + ); + + // Empty string as pattern — won't match anything + let matches = cmd.clone().get_matches_from(vec!["publish", ""]); + let result = get_filtered_publish_configs(&spacetime_config, &cmd, &schema, &matches); + assert!(result.is_err()); + } } diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index fd5de755629..97a2916fa4d 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -221,6 +221,22 @@ impl clap::ValueEnum for ModuleLanguage { } } +/// Try to find a SpacetimeDB module directory, checking in order: +/// 1. `{project_dir}/spacetimedb/` subdirectory +/// 2. `{project_dir}` itself +/// +/// Returns the first path that contains a recognizable SpacetimeDB module, or `None`. +pub fn find_module_path(project_dir: &Path) -> Option { + let spacetimedb_subdir = project_dir.join("spacetimedb"); + if spacetimedb_subdir.is_dir() && detect_module_language(&spacetimedb_subdir).is_ok() { + return Some(spacetimedb_subdir); + } + if project_dir.is_dir() && detect_module_language(project_dir).is_ok() { + return Some(project_dir.to_path_buf()); + } + None +} + pub fn detect_module_language(path_to_project: &Path) -> anyhow::Result { // TODO: Possible add a config file durlng spacetime init with the language // check for Cargo.toml diff --git a/crates/codegen/src/UnrealCPP-README.md b/crates/codegen/src/UnrealCPP-README.md index 920af1e4217..47520517b5f 100644 --- a/crates/codegen/src/UnrealCPP-README.md +++ b/crates/codegen/src/UnrealCPP-README.md @@ -304,25 +304,25 @@ To generate UnrealCPP bindings for your SpacetimeDB module, use the SpacetimeDB ### Basic Command ```bash -cargo run --bin spacetimedb-cli -- generate --lang unrealcpp --uproject-dir --project-path --module-name +cargo run --bin spacetimedb-cli -- generate --lang unrealcpp --uproject-dir --module-path --unreal-module-name ``` ### Example ```bash -cargo run --bin spacetimedb-cli -- generate --lang unrealcpp --uproject-dir crates/sdk-unreal/examples/QuickstartChat --project-path modules/quickstart-chat --module-name QuickstartChat +cargo run --bin spacetimedb-cli -- generate --lang unrealcpp --uproject-dir crates/sdk-unreal/examples/QuickstartChat --module-path modules/quickstart-chat --unreal-module-name QuickstartChat ``` ### Parameters - `--lang unrealcpp`: Specifies the UnrealCPP code generator - `--uproject-dir`: Directory containing Unreal's .uproject or .uplugin file -- `--project-path`: Path to your SpacetimeDB module source code -- `--module-name`: **Required** - Name used for generated classes, API prefix and putting generated module bindings in the correct Module's Source +- `--module-path`: Path to your SpacetimeDB module source code +- `--unreal-module-name`: **Required** - Name used for generated classes, API prefix and putting generated module bindings in the correct Module's Source ### Why Module Name is Required -The `--module-name` parameter is **mandatory** for UnrealCPP generation because: +The `--unreal-module-name` parameter is **mandatory** for UnrealCPP generation because: 1. **Unreal Engine API Macro**: Generated classes use `MODULENAME_API` macros (e.g., `QUICKSTARTCHAT_API`) for proper DLL export/import in Unreal Engine 2. **Class Prefixing**: All the optional generated classes are prefixed with the module name to avoid naming conflicts (e.g., `FQuickstartChatOptionalString`) diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index d87dfd127f6..587ceecf482 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -169,6 +169,33 @@ pub fn workspace_root() -> PathBuf { .to_path_buf() } +/// Rewrites `spacetimedb` dependency in `/Cargo.toml` to use local workspace bindings. +pub fn patch_module_cargo_to_local_bindings(module_dir: &Path) -> Result<()> { + let cargo_toml_path = module_dir.join("Cargo.toml"); + let cargo_toml = fs::read_to_string(&cargo_toml_path) + .with_context(|| format!("Failed to read {}", cargo_toml_path.display()))?; + + let bindings_path = workspace_root().join("crates/bindings"); + let bindings_path_str = bindings_path.display().to_string().replace('\\', "/"); + let replacement = format!(r#"spacetimedb = {{ path = "{bindings_path_str}", features = ["unstable"] }}"#); + + let patched = cargo_toml + .lines() + .map(|line| { + if line.trim_start().starts_with("spacetimedb = ") { + replacement.as_str() + } else { + line + } + }) + .collect::>() + .join("\n"); + + fs::write(&cargo_toml_path, format!("{patched}\n")) + .with_context(|| format!("Failed to write {}", cargo_toml_path.display()))?; + Ok(()) +} + /// Returns the shared target directory for smoketest module builds. /// /// All tests share this directory to cache compiled dependencies. The warmup step @@ -766,7 +793,7 @@ log = "0.4" let cli_path = ensure_binaries_built(); let mut cmd = Command::new(&cli_path); - cmd.args(["build", "--project-path", project_path]) + cmd.args(["build", "--module-path", project_path]) .current_dir(self.project_dir.path()) .env("CARGO_TARGET_DIR", shared_target_dir()); @@ -842,7 +869,7 @@ log = "0.4" let mut build_cmd = Command::new(&cli_path); build_cmd - .args(["build", "--project-path", &project_path]) + .args(["build", "--module-path", &project_path]) .current_dir(self.project_dir.path()) .env("CARGO_TARGET_DIR", &target_dir); diff --git a/crates/smoketests/tests/cli/dev.rs b/crates/smoketests/tests/cli/dev.rs index 012d513212a..c2e81bf37fc 100644 --- a/crates/smoketests/tests/cli/dev.rs +++ b/crates/smoketests/tests/cli/dev.rs @@ -85,3 +85,56 @@ 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 output = cli_cmd() + .current_dir(temp_dir.path()) + .args(["dev", "test-db"]) + .output() + .expect("failed to execute"); + + assert!(!output.status.success(), "dev should fail with invalid config field"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("Failed to load spacetime.json"), + "stderr should mention Failed to load spacetime.json" + ); + assert!( + stderr.contains("unknown field `run_command`"), + "stderr should mention unknown field run_command" + ); +} diff --git a/crates/smoketests/tests/cli/generate.rs b/crates/smoketests/tests/cli/generate.rs new file mode 100644 index 00000000000..307d66c9e95 --- /dev/null +++ b/crates/smoketests/tests/cli/generate.rs @@ -0,0 +1,91 @@ +use predicates::prelude::*; +use spacetimedb_guard::ensure_binaries_built; +use spacetimedb_smoketests::patch_module_cargo_to_local_bindings; +use std::process::Command; + +fn cli_cmd() -> Command { + Command::new(ensure_binaries_built()) +} + +#[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 /spacetimedb/) + let output = cli_cmd() + .args([ + "init", + "--non-interactive", + "--lang", + "rust", + "--project-path", + temp_dir.path().to_str().unwrap(), + "test-project", + ]) + .current_dir(temp_dir.path()) + .output() + .expect("failed to execute"); + assert!( + output.status.success(), + "init failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let project_dir = temp_dir.path().to_path_buf(); + let module_dir = project_dir.join("spacetimedb"); + patch_module_cargo_to_local_bindings(&module_dir).expect("failed to patch module Cargo.toml"); + + // 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 output = cli_cmd() + .args(["build", "--module-path", module_dir.to_str().unwrap()]) + .output() + .expect("failed to execute"); + assert!( + output.status.success(), + "build failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + 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 output = cli_cmd() + .args([ + "generate", + "--lang", + "rust", + "--out-dir", + output_dir.to_str().unwrap(), + "--module-path", + module_dir.to_str().unwrap(), + ]) + .current_dir(&module_dir) + .output() + .expect("failed to execute"); + assert!( + output.status.success(), + "generate failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + // Verify files were generated in the CLI-specified output directory + assert!( + predicate::path::exists().eval(&output_dir.join("lib.rs")) + || predicate::path::exists().eval(&output_dir.join("mod.rs")), + "Generated files should exist in CLI-specified output directory" + ); +} diff --git a/crates/smoketests/tests/cli/mod.rs b/crates/smoketests/tests/cli/mod.rs index 9d88f6e0820..f9990ad2d38 100644 --- a/crates/smoketests/tests/cli/mod.rs +++ b/crates/smoketests/tests/cli/mod.rs @@ -1,3 +1,4 @@ pub mod dev; +pub mod generate; pub mod publish; pub mod server; diff --git a/crates/smoketests/tests/cli/publish.rs b/crates/smoketests/tests/cli/publish.rs index 74f92c5f1bf..b6e29e1fb57 100644 --- a/crates/smoketests/tests/cli/publish.rs +++ b/crates/smoketests/tests/cli/publish.rs @@ -1,6 +1,6 @@ //! CLI publish command tests -use spacetimedb_smoketests::{require_local_server, Smoketest}; +use spacetimedb_smoketests::{patch_module_cargo_to_local_bindings, require_local_server, Smoketest}; #[test] fn cli_can_publish_spacetimedb_on_disk() { @@ -16,26 +16,12 @@ fn cli_can_publish_spacetimedb_on_disk() { let dir = dir.to_string(); let _ = test - .spacetime(&[ - "publish", - "--project-path", - &dir, - "--server", - &test.server_url, - "foobar", - ]) + .spacetime(&["publish", "--module-path", &dir, "--server", &test.server_url, "foobar"]) .unwrap(); // Can republish without error to the same name let _ = test - .spacetime(&[ - "publish", - "--project-path", - &dir, - "--server", - &test.server_url, - "foobar", - ]) + .spacetime(&["publish", "--module-path", &dir, "--server", &test.server_url, "foobar"]) .unwrap(); } @@ -55,7 +41,7 @@ fn migration_test(module_name: &str, republish_args: &[&str], expect_success: bo let _ = test .spacetime(&[ "publish", - "--project-path", + "--module-path", &dir, "--server", &test.server_url, @@ -66,7 +52,7 @@ fn migration_test(module_name: &str, republish_args: &[&str], expect_success: bo let dir = dir.to_string(); let mut args = vec![ "publish", - "--project-path", + "--module-path", &dir, "--server", &test.server_url, @@ -209,3 +195,48 @@ 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 test = Smoketest::builder().autopublish(false).build(); + let temp_dir = tempfile::tempdir().expect("failed to create temp dir"); + + // Initialize a new project (creates /spacetimedb/) + test.spacetime(&[ + "init", + "--non-interactive", + "--lang", + "rust", + "--project-path", + temp_dir.path().to_str().unwrap(), + "test-project", + ]) + .unwrap(); + + let module_dir = temp_dir.path().join("spacetimedb"); + patch_module_cargo_to_local_bindings(&module_dir).expect("failed to patch module Cargo.toml"); + + // Build the module first + test.spacetime(&["build", "--module-path", module_dir.to_str().unwrap()]) + .unwrap(); + + // 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 + test.spacetime(&[ + "publish", + "--server", + &test.server_url, + "cli-db-name", + "--module-path", + module_dir.to_str().unwrap(), + ]) + .unwrap(); +} diff --git a/crates/smoketests/tests/namespaces.rs b/crates/smoketests/tests/namespaces.rs index b9582749e73..25912fb1aee 100644 --- a/crates/smoketests/tests/namespaces.rs +++ b/crates/smoketests/tests/namespaces.rs @@ -45,7 +45,7 @@ fn test_spacetimedb_ns_csharp() { "--out-dir", tmpdir.path().to_str().unwrap(), "--lang=csharp", - "--project-path", + "--module-path", project_path.to_str().unwrap(), ]) .unwrap(); @@ -85,7 +85,7 @@ fn test_custom_ns_csharp() { "--lang=csharp", "--namespace", namespace, - "--project-path", + "--module-path", project_path.to_str().unwrap(), ]) .unwrap(); diff --git a/crates/smoketests/tests/permissions.rs b/crates/smoketests/tests/permissions.rs index 6dc7348355c..c2037cab812 100644 --- a/crates/smoketests/tests/permissions.rs +++ b/crates/smoketests/tests/permissions.rs @@ -71,7 +71,7 @@ fn test_publish() { &identity, "--server", &test.server_url, - "--project-path", + "--module-path", project_path.to_str().unwrap(), "--delete-data", "--yes", @@ -87,7 +87,7 @@ fn test_publish() { &identity, "--server", &test.server_url, - "--project-path", + "--module-path", project_path.to_str().unwrap(), "--yes", ]); diff --git a/crates/smoketests/tests/quickstart.rs b/crates/smoketests/tests/quickstart.rs index 5d5b5b5683d..d0d2acd71c6 100644 --- a/crates/smoketests/tests/quickstart.rs +++ b/crates/smoketests/tests/quickstart.rs @@ -714,7 +714,7 @@ log = "0.4" "publish", "--server", &self.test.server_url, - "--project-path", + "--module-path", &project_path_str, "--yes", "--clear-database", @@ -755,7 +755,7 @@ log = "0.4" self.config.client_lang, "--out-dir", bindings_path.to_str().unwrap(), - "--project-path", + "--module-path", &project_path_str, ])?; diff --git a/crates/testing/src/lib.rs b/crates/testing/src/lib.rs index f99a46f4769..01dd9f6bf69 100644 --- a/crates/testing/src/lib.rs +++ b/crates/testing/src/lib.rs @@ -53,7 +53,7 @@ pub fn invoke_cli(paths: &SpacetimePaths, args: &[&str]) { RUNTIME .block_on(async { if cmd == "generate" { - spacetimedb_cli::generate::exec_ex(config, sub_args, extract_descriptions).await + spacetimedb_cli::generate::exec_ex(config, sub_args, extract_descriptions, false, None).await } else { spacetimedb_cli::exec_subcommand(config, paths, None, cmd, sub_args) .await diff --git a/demo/Blackholio/server-csharp/generate.bat b/demo/Blackholio/server-csharp/generate.bat index 9a58365cba9..a041229f82c 100644 --- a/demo/Blackholio/server-csharp/generate.bat +++ b/demo/Blackholio/server-csharp/generate.bat @@ -1,2 +1,2 @@ spacetime generate --out-dir ../client-unity/Assets/Scripts/autogen -y --lang cs -spacetime generate --lang unrealcpp --uproject-dir ../client-unreal --project-path ./ --module-name client_unreal +spacetime generate --lang unrealcpp --uproject-dir ../client-unreal --module-path ./ --module-name client_unreal diff --git a/demo/Blackholio/server-csharp/generate.sh b/demo/Blackholio/server-csharp/generate.sh index d21e860e0f0..e455f4bb7c9 100644 --- a/demo/Blackholio/server-csharp/generate.sh +++ b/demo/Blackholio/server-csharp/generate.sh @@ -3,4 +3,4 @@ set -euo pipefail spacetime generate --out-dir ../client-unity/Assets/Scripts/autogen --lang cs $@ -spacetime generate --lang unrealcpp --uproject-dir ../client-unreal --project-path ./ --module-name client_unreal +spacetime generate --lang unrealcpp --uproject-dir ../client-unreal --module-path ./ --module-name client_unreal diff --git a/demo/Blackholio/server-rust/generate.bat b/demo/Blackholio/server-rust/generate.bat index 9a58365cba9..a041229f82c 100644 --- a/demo/Blackholio/server-rust/generate.bat +++ b/demo/Blackholio/server-rust/generate.bat @@ -1,2 +1,2 @@ spacetime generate --out-dir ../client-unity/Assets/Scripts/autogen -y --lang cs -spacetime generate --lang unrealcpp --uproject-dir ../client-unreal --project-path ./ --module-name client_unreal +spacetime generate --lang unrealcpp --uproject-dir ../client-unreal --module-path ./ --module-name client_unreal diff --git a/demo/Blackholio/server-rust/generate.sh b/demo/Blackholio/server-rust/generate.sh index d21e860e0f0..e455f4bb7c9 100755 --- a/demo/Blackholio/server-rust/generate.sh +++ b/demo/Blackholio/server-rust/generate.sh @@ -3,4 +3,4 @@ set -euo pipefail spacetime generate --out-dir ../client-unity/Assets/Scripts/autogen --lang cs $@ -spacetime generate --lang unrealcpp --uproject-dir ../client-unreal --project-path ./ --module-name client_unreal +spacetime generate --lang unrealcpp --uproject-dir ../client-unreal --module-path ./ --module-name client_unreal diff --git a/docs/docs/00100-intro/00300-tutorials/00100-chat-app.md b/docs/docs/00100-intro/00300-tutorials/00100-chat-app.md index e97f772cf42..1405f7bac96 100644 --- a/docs/docs/00100-intro/00300-tutorials/00100-chat-app.md +++ b/docs/docs/00100-intro/00300-tutorials/00100-chat-app.md @@ -765,28 +765,28 @@ From the `quickstart-chat` directory: ```bash -spacetime publish --server local --project-path spacetimedb quickstart-chat +spacetime publish --server local --module-path spacetimedb quickstart-chat ``` ```bash -spacetime publish --server local --project-path spacetimedb quickstart-chat +spacetime publish --server local --module-path spacetimedb quickstart-chat ``` ```bash -spacetime publish --server local --project-path spacetimedb quickstart-chat +spacetime publish --server local --module-path spacetimedb quickstart-chat ``` ```bash -spacetime publish --server local --project-path spacetimedb quickstart-chat +spacetime publish --server local --module-path spacetimedb quickstart-chat ``` @@ -1320,7 +1320,7 @@ Before we can run the app, we need to generate the TypeScript bindings that `App In your `quickstart-chat` directory, run: ```bash -spacetime generate --lang typescript --out-dir src/module_bindings --project-path spacetimedb +spacetime generate --lang typescript --out-dir src/module_bindings --module-path spacetimedb ``` Take a look inside `src/module_bindings`. The CLI should have generated several files: @@ -1668,7 +1668,7 @@ The `spacetime` CLI's `generate` command will generate client-side interfaces fo In your `quickstart-chat` directory, run: ```bash -spacetime generate --lang csharp --out-dir module_bindings --project-path spacetimedb +spacetime generate --lang csharp --out-dir module_bindings --module-path spacetimedb ``` Take a look inside `module_bindings`. The CLI should have generated three folders and nine files: @@ -2235,7 +2235,7 @@ The `spacetime` CLI's `generate` command will generate client-side interfaces fo In your `quickstart-chat` directory, run: ```bash -spacetime generate --lang rust --out-dir src/module_bindings --project-path spacetimedb +spacetime generate --lang rust --out-dir src/module_bindings --module-path spacetimedb ``` Take a look inside `src/module_bindings`. The CLI should have generated a few files: diff --git a/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00300-part-2.md b/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00300-part-2.md index f9ad3f7b216..20b318df9c3 100644 --- a/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00300-part-2.md +++ b/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00300-part-2.md @@ -646,13 +646,13 @@ Let's generate our types for our module. In the `blackholio/spacetimedb` directo ```sh -spacetime generate --lang unrealcpp --uproject-dir ../../blackholio --project-path ./ --module-name blackholio +spacetime generate --lang unrealcpp --uproject-dir ../../blackholio --module-path ./ --unreal-module-name blackholio ``` This will generate a set of files in the `blackholio/Source/blackholio/Private/ModuleBindings` and `blackholio/Source/blackholio/Public/ModuleBindings` directories which contain the code generated types and reducer functions that are defined in your module, but usable on the client. :::note -`--uproject-dir` is straightforward as the path to the .uproject file. `--module-name` is the name of the Unreal module which in most projects is the name of the project, in this case `blackholio`. +`--uproject-dir` is straightforward as the path to the .uproject file. `--unreal-module-name` is the name of the Unreal module which in most projects is the name of the project, in this case `blackholio`. ::: ``` diff --git a/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00400-part-3.md b/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00400-part-3.md index 0cf5f095e86..d47033f7a07 100644 --- a/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00400-part-3.md +++ b/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00400-part-3.md @@ -2397,7 +2397,7 @@ Update **Event BeginPlay** as follows: At this point, you may need to regenerate your bindings the following command from the `blackholio/spacetimedb` directory. ```sh -spacetime generate --lang unrealcpp --uproject-dir .. --module-name blackholio +spacetime generate --lang unrealcpp --uproject-dir .. --unreal-module-name blackholio ``` diff --git a/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00500-part-4.md b/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00500-part-4.md index 8287ea2b07f..956a3de2958 100644 --- a/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00500-part-4.md +++ b/docs/docs/00100-intro/00300-tutorials/00400-unreal-tutorial/00500-part-4.md @@ -523,7 +523,7 @@ spacetime publish --server local blackholio --delete-data Regenerate your server bindings with: ```sh -spacetime generate --lang unrealcpp --uproject-dir .. --module-name blackholio +spacetime generate --lang unrealcpp --uproject-dir .. --unreal-module-name blackholio ``` ### Moving on the Client 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 1fa6c8eab67..6a35510663e 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 @@ -95,9 +95,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: @@ -116,6 +137,7 @@ my-project/ │ └── module_bindings/ # Generated client bindings ├── package.json ├── tsconfig.json +├── spacetime.json # SpacetimeDB configuration └── README.md ``` @@ -130,6 +152,7 @@ my-project/ ├── module_bindings/ # Generated client bindings ├── client.csproj ├── Program.cs +├── spacetime.json # SpacetimeDB configuration └── README.md ``` @@ -145,6 +168,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/00200-core-concepts/00600-client-sdk-languages/00200-codegen.md b/docs/docs/00200-core-concepts/00600-client-sdk-languages/00200-codegen.md index f3211032413..2451c973e4d 100644 --- a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00200-codegen.md +++ b/docs/docs/00200-core-concepts/00600-client-sdk-languages/00200-codegen.md @@ -29,7 +29,7 @@ Use the `spacetime generate` command to create bindings from your module: ```bash mkdir -p src/module_bindings -spacetime generate --lang typescript --out-dir src/module_bindings --project-path PATH-TO-MODULE-DIRECTORY +spacetime generate --lang typescript --out-dir src/module_bindings --module-path PATH-TO-MODULE-DIRECTORY ``` This generates TypeScript files in `src/module_bindings/`. Import them in your client: @@ -45,7 +45,7 @@ Replace **PATH-TO-MODULE-DIRECTORY** with the path to your module's directory, w ```bash mkdir -p module_bindings -spacetime generate --lang cs --out-dir module_bindings --project-path PATH-TO-MODULE-DIRECTORY +spacetime generate --lang cs --out-dir module_bindings --module-path PATH-TO-MODULE-DIRECTORY ``` This generates C# files in `module_bindings/`. The generated files are automatically included in your project. @@ -57,7 +57,7 @@ Replace **PATH-TO-MODULE-DIRECTORY** with the path to your module's directory, w ```bash mkdir -p src/module_bindings -spacetime generate --lang rust --out-dir client/src/module_bindings --project-path PATH-TO-MODULE-DIRECTORY +spacetime generate --lang rust --out-dir client/src/module_bindings --module-path PATH-TO-MODULE-DIRECTORY ``` This generates Rust files in `client/src/module_bindings/`. Import them in your client with: @@ -72,7 +72,7 @@ Replace **PATH-TO-MODULE-DIRECTORY** with the path to your module's directory, w ```bash -spacetime generate --lang unrealcpp --uproject-dir PATH-TO-UPROJECT --project-path PATH-TO-MODULE-DIRECTORY --module-name YOUR_MODULE_NAME +spacetime generate --lang unrealcpp --uproject-dir PATH-TO-UPROJECT --module-path PATH-TO-MODULE-DIRECTORY --unreal-module-name YOUR_MODULE_NAME ``` This generates Unreal C++ files in your project's `ModuleBindings` directory. The generated files are automatically included in your Unreal project. diff --git a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00500-rust-reference.md b/docs/docs/00200-core-concepts/00600-client-sdk-languages/00500-rust-reference.md index 1cb31e437cd..894914cb5f3 100644 --- a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00500-rust-reference.md +++ b/docs/docs/00200-core-concepts/00600-client-sdk-languages/00500-rust-reference.md @@ -43,7 +43,7 @@ Each SpacetimeDB client depends on some bindings specific to your module. Create mkdir -p src/module_bindings spacetime generate --lang rust \ --out-dir src/module_bindings \ - --project-path PATH-TO-MODULE-DIRECTORY + --module-path PATH-TO-MODULE-DIRECTORY ``` Replace `PATH-TO-MODULE-DIRECTORY` with the path to your SpacetimeDB module. diff --git a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00600-csharp-reference.md b/docs/docs/00200-core-concepts/00600-client-sdk-languages/00600-csharp-reference.md index c6e51d8cd5e..6068a63725f 100644 --- a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00600-csharp-reference.md +++ b/docs/docs/00200-core-concepts/00600-client-sdk-languages/00600-csharp-reference.md @@ -55,7 +55,7 @@ Each SpacetimeDB client depends on some bindings specific to your module. Create ```bash mkdir -p module_bindings -spacetime generate --lang cs --out-dir module_bindings --project-path PATH-TO-MODULE-DIRECTORY +spacetime generate --lang cs --out-dir module_bindings --module-path PATH-TO-MODULE-DIRECTORY ``` Replace `PATH-TO-MODULE-DIRECTORY` with the path to your SpacetimeDB module. diff --git a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00700-typescript-reference.md b/docs/docs/00200-core-concepts/00600-client-sdk-languages/00700-typescript-reference.md index 3a55e6ff463..5c1eeb42913 100644 --- a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00700-typescript-reference.md +++ b/docs/docs/00200-core-concepts/00600-client-sdk-languages/00700-typescript-reference.md @@ -81,7 +81,7 @@ Each SpacetimeDB client depends on some bindings specific to your module. Create mkdir -p client/src/module_bindings spacetime generate --lang typescript \ --out-dir client/src/module_bindings \ - --project-path PATH-TO-MODULE-DIRECTORY + --module-path PATH-TO-MODULE-DIRECTORY ``` Import the `module_bindings` in your client's _main_ file: diff --git a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00800-unreal-reference.md b/docs/docs/00200-core-concepts/00600-client-sdk-languages/00800-unreal-reference.md index eb34c2a954d..c0881d74d1d 100644 --- a/docs/docs/00200-core-concepts/00600-client-sdk-languages/00800-unreal-reference.md +++ b/docs/docs/00200-core-concepts/00600-client-sdk-languages/00800-unreal-reference.md @@ -34,7 +34,7 @@ Add the SpacetimeDB Unreal SDK to your project as a plugin. The SDK provides bot Each SpacetimeDB client depends on some bindings specific to your module. Generate the Unreal interface files using the Spacetime CLI. From your project directory, run: ```bash -spacetime generate --lang unrealcpp --uproject-dir --project-path --module-name +spacetime generate --lang unrealcpp --uproject-dir --module-path --unreal-module-name ``` Replace: @@ -46,7 +46,7 @@ Replace: **Example:** ```bash -spacetime generate --lang unrealcpp --uproject-dir /path/to/MyGame --project-path /path/to/quickstart-chat --module-name QuickstartChat +spacetime generate --lang unrealcpp --uproject-dir /path/to/MyGame --module-path /path/to/quickstart-chat --unreal-module-name QuickstartChat ``` This generates module-specific bindings in your project's `ModuleBindings` directory. 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 eb4e81bc371..88b3a780e7a 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 @@ -97,9 +97,7 @@ Run `spacetime help publish` for more detailed information. * `--build-options ` — Options to pass to the build command, for example --build-options='--lint-dir=' Default value: `` -* `-p`, `--project-path ` — The system path (absolute or relative) to the module project - - Default value: `.` +* `-p`, `--module-path ` — The system path (absolute or relative) to the module project. Defaults to spacetimedb/ subdirectory, then current directory. * `-b`, `--bin-path ` — The system path (absolute or relative) to the compiled wasm binary we should publish, instead of building the project. * `-j`, `--js-path ` — UNSTABLE: The system path (absolute or relative) to the javascript file we should publish, instead of building the project. * `--break-clients` — Allow breaking changes when publishing to an existing database identity. This will force publish even if it will break existing clients, but will NOT force publish if it would cause deletion of any data in the database. See --yes and --delete-data for details. @@ -114,6 +112,8 @@ Run `spacetime help publish` for more detailed information. An organization can only be set when a database is created, not when it is updated. * `-s`, `--server ` — The nickname, domain name or URL of the server to host the database. * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). +* `--no-config` — Ignore spacetime.json configuration +* `--env ` — Environment name for config file layering (e.g., dev, staging) @@ -233,9 +233,7 @@ Start development mode with auto-regenerate client module bindings, auto-rebuild * `--module-bindings-path ` — The path to the module bindings directory relative to the project directory, defaults to `/src/module_bindings` Default value: `src/module_bindings` -* `--module-project-path ` — The path to the SpacetimeDB server module project relative to the project directory, defaults to `/spacetimedb` - - Default value: `spacetimedb` +* `--module-path ` — Path to the SpacetimeDB server module, relative to current directory. Defaults to `/spacetimedb`. * `--client-lang ` — The programming language for the generated client module bindings (e.g., typescript, csharp, python). If not specified, it will be detected from the project. Possible values: `csharp`, `typescript`, `rust`, `unrealcpp` @@ -247,6 +245,12 @@ Start development mode with auto-regenerate client module bindings, auto-rebuild Possible values: `always`, `on-conflict`, `never` * `-t`, `--template