diff --git a/Cargo.lock b/Cargo.lock index f407e133a50..1ccdba3de72 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 1.12.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" @@ -7534,6 +7588,7 @@ dependencies = [ "indicatif", "is-terminal", "itertools 0.12.1", + "json5", "mimalloc", "names", "notify", @@ -9745,6 +9800,12 @@ dependencies = [ "spacetimedb 1.12.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 eee9c7d5980..5f253732650 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 79efec60038..572d484ac72 100644 --- a/crates/bindings-typescript/test-app/package.json +++ b/crates/bindings-typescript/test-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/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..ca28e7aa34f 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -49,6 +49,7 @@ http.workspace = true is-terminal.workspace = true itertools.workspace = true indicatif.workspace = true +json5.workspace = true jsonwebtoken.workspace = true mimalloc.workspace = true percent-encoding.workspace = true diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 51338dfe289..175bcf14fe6 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -4,6 +4,7 @@ mod config; pub(crate) mod detect; mod edit_distance; mod errors; +pub mod spacetime_config; mod subcommands; mod tasks; pub mod util; diff --git a/crates/cli/src/spacetime_config.rs b/crates/cli/src/spacetime_config.rs new file mode 100644 index 00000000000..539ca4f9cf8 --- /dev/null +++ b/crates/cli/src/spacetime_config.rs @@ -0,0 +1,2100 @@ +use anyhow::Context; +use clap::{ArgMatches, Command, ValueEnum}; +use json5; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::any::TypeId; +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +use crate::subcommands::generate::Language; + +/// The filename for configuration +pub const CONFIG_FILENAME: &str = "spacetime.json"; + +/// Supported package managers for JavaScript/TypeScript projects +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PackageManager { + Npm, + Pnpm, + Yarn, + Bun, +} + +impl fmt::Display for PackageManager { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + PackageManager::Npm => "npm", + PackageManager::Pnpm => "pnpm", + PackageManager::Yarn => "yarn", + PackageManager::Bun => "bun", + }; + write!(f, "{s}") + } +} + +impl PackageManager { + /// Get the command to run a dev script + pub fn run_dev_command(&self) -> &'static str { + match self { + PackageManager::Npm => "npm run dev", + PackageManager::Pnpm => "pnpm run dev", + PackageManager::Yarn => "yarn dev", + PackageManager::Bun => "bun run dev", + } + } +} + +/// Errors that can occur when building or using CommandConfig +#[derive(Debug, Error)] +pub enum CommandConfigError { + #[error("The option `--{arg_name}` is defined in Clap, but not in the config. If this is intentional and the option shouldn't be available in the config, you can exclude it with the `CommandConfigBuilder::exclude` function")] + ClapArgNotDefined { arg_name: String }, + + #[error("Key '{config_name}' references clap argument '{clap_name}' which doesn't exist in the Command. If the config key should be different than the clap argument, use from_clap()")] + InvalidClapReference { config_name: String, clap_name: String }, + + #[error("Key '{config_name}' has alias '{alias}' which doesn't exist in the Command")] + InvalidAliasReference { config_name: String, alias: String }, + + #[error("Excluded key '{key}' doesn't exist in the clap Command")] + InvalidExclusion { key: String }, + + #[error("Config key '{config_key}' is not supported in the config file. Available keys: {available_keys}")] + UnsupportedConfigKey { config_key: String, available_keys: String }, + + #[error("Required key '{key}' is missing from the config file")] + MissingRequiredKey { key: String }, + + #[error("Mismatch between definition and access of `{key}`. Could not downcast to {requested_type}, need to downcast to {expected_type}")] + TypeMismatch { + key: String, + requested_type: String, + expected_type: String, + }, + + #[error("Failed to convert config value for key '{key}' to type {target_type}")] + ConversionError { + key: String, + target_type: String, + #[source] + source: anyhow::Error, + }, +} + +/// Project configuration loaded from spacetime.json. +/// +/// Example: +/// ```json +/// { +/// "dev": { +/// "run": "pnpm dev" +/// }, +/// "generate": [ +/// { +/// "language": "typescript", +/// "out-dir": "./src/module_bindings" +/// } +/// ], +/// "publish": { +/// "database": "my-database", +/// "server": "https://testnet.spacetimedb.com" +/// } +/// } +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct SpacetimeConfig { + /// Configuration for the dev command. + #[serde(skip_serializing_if = "Option::is_none")] + pub dev: Option, + + /// List of generate configurations for creating client bindings. + /// Each entry configures code generation for a specific language. + #[serde(skip_serializing_if = "Option::is_none")] + pub generate: Option>>, + + /// Configuration for publishing the database. + /// Can include nested children for multi-database configurations. + #[serde(skip_serializing_if = "Option::is_none")] + pub publish: Option, +} + +/// Configuration for `spacetime dev` command. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case", deny_unknown_fields)] +pub struct DevConfig { + /// The command to run the client development server. + /// This is used by `spacetime dev` to start the client after publishing. + /// Example: "npm run dev", "pnpm dev", "cargo run" + #[serde(skip_serializing_if = "Option::is_none")] + pub run: Option, +} + +/// Configuration for `spacetime publish` command. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub struct PublishConfig { + /// Child databases + #[serde(skip_serializing_if = "Option::is_none")] + pub children: Option>, + + /// Configuration fields + #[serde(flatten)] + pub additional_fields: HashMap, +} + +impl PublishConfig { + /// Iterate through all publish targets (self + children recursively). + /// Returns an iterator that yields references to PublishConfig instances. + pub fn iter_all_targets(&self) -> Box + '_> { + Box::new( + std::iter::once(self).chain( + self.children + .iter() + .flat_map(|children| children.iter()) + .flat_map(|child| child.iter_all_targets()), + ), + ) + } + + /// Count total number of targets (self + all descendants) + pub fn count_targets(&self) -> usize { + 1 + self + .children + .as_ref() + .map(|children| children.iter().map(|child| child.count_targets()).sum()) + .unwrap_or(0) + } +} + +/// A unified config that merges clap arguments with config file values. +/// Provides a `get_one::(key)` interface similar to clap's ArgMatches. +/// CLI arguments take precedence over config file values. +#[derive(Debug)] +pub struct CommandConfig<'a> { + /// Schema defining the contract between CLI and config + schema: &'a CommandSchema, + /// Config file values + config_values: HashMap, + /// CLI arguments + matches: &'a ArgMatches, +} + +/// Schema that defines the contract between CLI arguments and config file keys. +/// Does not hold ArgMatches - methods take matches as a parameter instead. +#[derive(Debug)] +pub struct CommandSchema { + /// Key definitions + keys: Vec, + /// Type information for validation (keyed by config name) + type_map: HashMap, + /// Map from config name to clap arg name (for from_clap mapping) + config_to_clap: HashMap, + /// Map from config name to alias (for alias mapping) + config_to_alias: HashMap, +} + +/// Builder for creating a CommandSchema with custom mappings and exclusions. +pub struct CommandSchemaBuilder { + /// Keys defined for this command + keys: Vec, + /// Set of keys to exclude from being read from the config file + excluded_keys: HashSet, +} + +impl CommandSchemaBuilder { + pub fn new() -> Self { + Self { + keys: Vec::new(), + excluded_keys: HashSet::new(), + } + } + + /// Add a key definition to the builder. + /// Example: `.key(Key::new::("server"))` + pub fn key(mut self, key: Key) -> Self { + self.keys.push(key); + self + } + + /// Exclude a key from being read from the config file. + /// This is useful for keys that should only come from CLI arguments. + pub fn exclude(mut self, key: impl Into) -> Self { + self.excluded_keys.insert(key.into()); + self + } + + /// Build a CommandSchema by validating against the clap Command. + /// + /// # Arguments + /// * `command` - The clap Command to validate against + pub fn build(self, command: &Command) -> Result { + // Collect all clap argument names for validation + let clap_arg_names: HashSet = command + .get_arguments() + .map(|arg| arg.get_id().as_str().to_string()) + .collect(); + + // Check that all the defined keys exist in clap + for key in &self.keys { + if !clap_arg_names.contains(key.clap_arg_name()) { + return Err(CommandConfigError::InvalidClapReference { + config_name: key.config_name().to_string(), + clap_name: key.clap_arg_name().to_string(), + }); + } + + // Validate alias if present + if let Some(alias) = &key.clap_alias { + if !clap_arg_names.contains(alias) { + return Err(CommandConfigError::InvalidAliasReference { + config_name: key.config_name().to_string(), + alias: alias.clone(), + }); + } + } + } + + // Validate exclusions reference valid clap arguments + for excluded_key in &self.excluded_keys { + if !clap_arg_names.contains(excluded_key) { + return Err(CommandConfigError::InvalidExclusion { + key: excluded_key.clone(), + }); + } + } + + let mut type_map = HashMap::new(); + // A list of clap args that are referenced by the config keys + let mut referenced_clap_args = HashSet::new(); + let mut config_to_clap_map = HashMap::new(); + let mut config_to_alias_map = HashMap::new(); + + for key in &self.keys { + let config_name = key.config_name().to_string(); + let clap_name = key.clap_arg_name().to_string(); + + referenced_clap_args.insert(clap_name.clone()); + type_map.insert(config_name.clone(), key.type_id()); + + // Track the mapping from config name to clap arg name (if using from_clap) + if key.clap_name.is_some() { + config_to_clap_map.insert(config_name.clone(), clap_name.clone()); + } + + // Register the alias if present + if let Some(alias) = &key.clap_alias { + referenced_clap_args.insert(alias.clone()); + config_to_alias_map.insert(config_name.clone(), alias.clone()); + } + } + + // Check that all clap arguments are either referenced or excluded + for arg in command.get_arguments() { + let arg_name = arg.get_id().as_str(); + + // Skip clap's built-in arguments + if arg_name == "help" || arg_name == "version" { + continue; + } + + if !referenced_clap_args.contains(arg_name) && !self.excluded_keys.contains(arg_name) { + return Err(CommandConfigError::ClapArgNotDefined { + arg_name: arg_name.to_string(), + }); + } + } + + Ok(CommandSchema { + keys: self.keys, + type_map, + config_to_clap: config_to_clap_map, + config_to_alias: config_to_alias_map, + }) + } +} + +impl Default for CommandSchemaBuilder { + fn default() -> Self { + Self::new() + } +} + +impl CommandSchema { + /// Get a value from clap arguments only (not from config). + /// Useful for filtering or checking if a value was provided via CLI. + pub fn get_clap_arg( + &self, + matches: &ArgMatches, + config_name: &str, + ) -> Result, CommandConfigError> { + let requested_type_id = TypeId::of::(); + + // Validate type if we have type information + if let Some(&expected_type_id) = self.type_map.get(config_name) { + if requested_type_id != expected_type_id { + let expected_type_name = type_name_from_id(expected_type_id); + let requested_type_name = std::any::type_name::(); + + return Err(CommandConfigError::TypeMismatch { + key: config_name.to_string(), + requested_type: requested_type_name.to_string(), + expected_type: expected_type_name.to_string(), + }); + } + } + + // Check clap with mapped name (if from_clap was used, use that name, otherwise use config name) + let clap_name = self + .config_to_clap + .get(config_name) + .map(|s| s.as_str()) + .unwrap_or(config_name); + + // Only return the value if it was actually provided by the user, not from defaults + if let Some(source) = matches.value_source(clap_name) { + if source == clap::parser::ValueSource::CommandLine { + if let Some(value) = matches.get_one::(clap_name) { + return Ok(Some(value.clone())); + } + } + } + + // Try clap with the alias if it exists + if let Some(alias) = self.config_to_alias.get(config_name) { + if let Some(source) = matches.value_source(alias) { + if source == clap::parser::ValueSource::CommandLine { + if let Some(value) = matches.get_one::(alias) { + return Ok(Some(value.clone())); + } + } + } + } + + Ok(None) + } + + /// Check if a value was provided via CLI (not from config). + /// Only returns true if the user explicitly provided the value, not if it came from a default. + pub fn is_from_cli(&self, matches: &ArgMatches, config_name: &str) -> bool { + // Check clap with mapped name + let clap_name = self + .config_to_clap + .get(config_name) + .map(|s| s.as_str()) + .unwrap_or(config_name); + + // Use value_source to check if the value was actually provided by the user + if let Some(source) = matches.value_source(clap_name) { + if source == clap::parser::ValueSource::CommandLine { + return true; + } + } + + // Check clap with alias + if let Some(alias) = self.config_to_alias.get(config_name) { + if let Some(source) = matches.value_source(alias) { + if source == clap::parser::ValueSource::CommandLine { + return true; + } + } + } + + false + } + + /// Get all module-specific keys that were provided via CLI. + pub fn module_specific_cli_args(&self, matches: &ArgMatches) -> Vec<&str> { + self.keys + .iter() + .filter(|k| k.module_specific && self.is_from_cli(matches, k.config_name())) + .map(|k| k.config_name()) + .collect() + } +} + +/// Configuration for a single key in the CommandConfig. +#[derive(Debug, Clone)] +pub struct Key { + /// The key name in the config file (e.g., "module-path") + config_name: String, + /// The corresponding clap argument name (e.g., "project-path"), if different + clap_name: Option, + /// Alias for a clap argument, useful for example if we have to deprecate a clap + /// argument and still allow to use it in the CLI args, but not in the config file + clap_alias: Option, + /// Whether this key is module-specific + module_specific: bool, + /// Whether this key is required in the config file + required: bool, + /// The expected TypeId for this key + type_id: TypeId, +} + +impl Key { + /// Returns a new Key instance + pub fn new(name: impl Into) -> Self { + Self { + config_name: name.into(), + clap_name: None, + clap_alias: None, + module_specific: false, + required: false, + type_id: TypeId::of::(), + } + } + + /// Map this config key to a different clap argument name. When fetching values + /// the key that is defined should be used. + /// Example: Key::new::("module-path").from_clap("project-path") + /// - in this case the value for either project-path in clap or + /// for module-path in the config file will be fetched + pub fn from_clap(mut self, clap_arg_name: impl Into) -> Self { + self.clap_name = Some(clap_arg_name.into()); + self + } + + /// Add an alias for a clap argument name that also maps to this key. + /// This is useful for backwards compatibility when renaming arguments. + /// Example: Key::new::("module-path").alias("project-path") + /// + /// This allows both --module-path and --project-path to map to the same config key. + /// The value should then be accessed by using `module-path` + /// + /// The difference between from_clap and alias is that from_clap will work by mapping + /// a single value from clap, whereas alias will check both of them in the CLI args + pub fn alias(mut self, alias_name: impl Into) -> Self { + self.clap_alias = Some(alias_name.into()); + self + } + + /// Mark this key as module-specific. For example, the `js-bin` config option makes sense + /// only when applied to a single module. The `server` config option makes sense for + /// multiple publish targets + pub fn module_specific(mut self) -> Self { + self.module_specific = true; + self + } + + /// Mark this key as required in the config file. If a config file is provided but + /// this key is missing, an error will be returned. + pub fn required(mut self) -> Self { + self.required = true; + self + } + + /// Get the clap argument name (either the mapped name or the config name) + pub fn clap_arg_name(&self) -> &str { + self.clap_name.as_deref().unwrap_or(&self.config_name) + } + + /// Get the config name + pub fn config_name(&self) -> &str { + &self.config_name + } + + /// Get the type_id + pub fn type_id(&self) -> TypeId { + self.type_id + } + + /// Check if this key is required + pub fn is_required(&self) -> bool { + self.required + } +} + +impl<'a> CommandConfig<'a> { + /// Create a new CommandConfig by validating config values against a schema. + /// + /// # Arguments + /// * `schema` - The command schema that defines valid keys and types + /// * `config_values` - Values from the config file + /// * `matches` - CLI arguments + /// + /// # Errors + /// Returns an error if any config keys are not defined in the schema. + /// Note: Required key validation happens when get_one() is called, not during construction. + pub fn new( + schema: &'a CommandSchema, + config_values: HashMap, + matches: &'a ArgMatches, + ) -> Result { + // Normalize keys from kebab-case to snake_case to match clap's Arg::new() convention + let normalized_values: HashMap = config_values + .into_iter() + .map(|(k, v)| (k.replace('-', "_"), v)) + .collect(); + + // Build set of valid config keys from schema + let valid_config_keys: HashSet = schema.keys.iter().map(|k| k.config_name().to_string()).collect(); + + // Check that all keys in config file are defined in schema + for config_key in normalized_values.keys() { + if !valid_config_keys.contains(config_key) { + return Err(CommandConfigError::UnsupportedConfigKey { + config_key: config_key.clone(), + available_keys: valid_config_keys + .iter() + .map(|s| s.as_str()) + .collect::>() + .join(", "), + }); + } + } + + Ok(CommandConfig { + schema, + config_values: normalized_values, + matches, + }) + } + + /// Get a single value from the config as a specific type. + /// First checks clap args (via schema), then falls back to config values. + /// Validates that the requested type matches the schema definition. + /// + /// Returns: + /// - Ok(Some(T)) if the value exists and can be converted + /// - Ok(None) if the value doesn't exist in either clap or config + /// - Err if the type doesn't match or conversion fails + pub fn get_one(&self, key: &str) -> Result, CommandConfigError> { + // Try clap arguments first (CLI takes precedence) via schema + let from_cli = self.schema.get_clap_arg::(self.matches, key)?; + if let Some(ref value) = from_cli { + return Ok(Some(value.clone())); + } + + // Fall back to config values using the config name + if let Some(value) = self.config_values.get(key) { + from_json_value::(value) + .map_err(|source| CommandConfigError::ConversionError { + key: key.to_string(), + target_type: std::any::type_name::().to_string(), + source, + }) + .map(Some) + } else { + Ok(None) + } + } + + /// Check if a key exists in either clap or config. + pub fn contains(&self, matches: &ArgMatches, key: &str) -> bool { + // Check if provided via CLI using schema + if self.schema.is_from_cli(matches, key) { + return true; + } + + // Check config key + self.config_values.contains_key(key) + } + + /// Get a config value (from config file only, not merged with CLI). + /// + /// This is useful for filtering scenarios where you need to compare + /// CLI values against config file values. + pub fn get_config_value(&self, key: &str) -> Option<&Value> { + self.config_values.get(key) + } + + /// Validate that all required keys are present in the config file. + /// Note: This only checks config file keys. CLI required validation is handled by clap. + pub fn validate(&self) -> Result<(), CommandConfigError> { + for key in &self.schema.keys { + if key.is_required() && !self.config_values.contains_key(key.config_name()) { + return Err(CommandConfigError::MissingRequiredKey { + key: key.config_name().to_string(), + }); + } + } + Ok(()) + } +} + +/// Helper to get a human-readable type name from a TypeId +fn type_name_from_id(type_id: TypeId) -> &'static str { + if type_id == TypeId::of::() { + "alloc::string::String" + } else if type_id == TypeId::of::() { + "std::path::PathBuf" + } else if type_id == TypeId::of::() { + "bool" + } else if type_id == TypeId::of::() { + "i64" + } else if type_id == TypeId::of::() { + "u64" + } else if type_id == TypeId::of::() { + "f64" + } else if type_id == TypeId::of::() { + "spacetimedb_cli::subcommands::generate::Language" + } else { + "unknown" + } +} + +/// Helper to convert JSON values to Rust types (for config file values) +fn from_json_value(value: &Value) -> anyhow::Result { + let type_id = TypeId::of::(); + + let any: Box = match type_id { + t if t == TypeId::of::() => Box::new(value.as_str().context("Expected string value")?.to_string()), + t if t == TypeId::of::() => Box::new(PathBuf::from( + value.as_str().context("Expected string value for PathBuf")?, + )), + t if t == TypeId::of::() => Box::new(value.as_bool().context("Expected boolean value")?), + t if t == TypeId::of::() => Box::new(value.as_i64().context("Expected i64 value")?), + t if t == TypeId::of::() => Box::new(value.as_u64().context("Expected u64 value")?), + t if t == TypeId::of::() => Box::new(value.as_f64().context("Expected f64 value")?), + t if t == TypeId::of::() => { + let s = value.as_str().context("Expected string value for Language")?; + // Use ValueEnum's from_str method which handles aliases automatically + let lang = Language::from_str(s, true).map_err(|_| anyhow::anyhow!("Invalid language: {}", s))?; + Box::new(lang) + } + _ => anyhow::bail!("Unsupported type for conversion from JSON"), + }; + + // Now downcast to T + any.downcast::() + .map(|boxed| *boxed) + .map_err(|_| anyhow::anyhow!("Failed to downcast value")) +} + +impl SpacetimeConfig { + /// Find and load a spacetime.json file. + /// + /// Searches for spacetime.json starting from the current directory + /// and walking up the directory tree until found or filesystem root is reached. + /// + /// Returns `Ok(Some((path, config)))` if found and successfully parsed. + /// Returns `Ok(None)` if not found. + /// Returns `Err` if found but failed to parse. + pub fn find_and_load() -> anyhow::Result> { + Self::find_and_load_from(std::env::current_dir()?) + } + + /// Find and load a spacetime.json file starting from a specific directory. + /// + /// Searches for spacetime.json starting from `start_dir` + /// and walking up the directory tree until found or filesystem root is reached. + pub fn find_and_load_from(start_dir: PathBuf) -> anyhow::Result> { + let mut current_dir = start_dir; + loop { + let config_path = current_dir.join("spacetime.json"); + if config_path.exists() { + let config = Self::load(&config_path) + .with_context(|| format!("Failed to parse config file: {}", config_path.display()))?; + return Ok(Some((config_path, config))); + } + + // Try to go up one directory + if !current_dir.pop() { + // Reached filesystem root + break; + } + } + Ok(None) + } + + /// Load a spacetime.json file from a specific path. + /// + /// The file must exist and be valid JSON5 format (supports comments). + pub fn load(path: &Path) -> anyhow::Result { + let content = + std::fs::read_to_string(path).with_context(|| format!("Failed to read config file: {}", path.display()))?; + + let config: Self = json5::from_str(&content) + .map_err(|e| anyhow::anyhow!("Failed to parse config file {}: {}", path.display(), e))?; + + Ok(config) + } + + /// Save the config to a file. + /// + /// The config will be serialized as pretty-printed JSON. + pub fn save(&self, path: &Path) -> anyhow::Result<()> { + let json = serde_json::to_string_pretty(self).context("Failed to serialize config")?; + + std::fs::write(path, json).with_context(|| format!("Failed to write config file: {}", path.display()))?; + + Ok(()) + } + + /// Create a spacetime.json file in the current directory with the given config. + pub fn create_in_current_dir(&self) -> anyhow::Result { + let config_path = std::env::current_dir()?.join("spacetime.json"); + self.save(&config_path)?; + Ok(config_path) + } + + /// Create a configuration with a run command for dev + pub fn with_run_command(run_command: impl Into) -> Self { + Self { + dev: Some(DevConfig { + run: Some(run_command.into()), + }), + ..Default::default() + } + } + + /// Create a configuration for a specific client language. + /// Determines the appropriate run command based on the language and package manager. + pub fn for_client_lang(client_lang: &str, package_manager: Option) -> Self { + let run_command = match client_lang.to_lowercase().as_str() { + "typescript" => package_manager.map(|pm| pm.run_dev_command()).unwrap_or("npm run dev"), + "rust" => "cargo run", + "csharp" | "c#" => "dotnet run", + _ => "npm run dev", // default fallback + }; + Self { + dev: Some(DevConfig { + run: Some(run_command.to_string()), + }), + ..Default::default() + } + } + + /// Load configuration from a directory. + /// Returns `None` if no config file exists. + pub fn load_from_dir(dir: &Path) -> anyhow::Result> { + let config_path = dir.join(CONFIG_FILENAME); + if config_path.exists() { + Self::load(&config_path).map(Some) + } else { + Ok(None) + } + } + + /// Save configuration to `spacetime.json` in the specified directory. + pub fn save_to_dir(&self, dir: &Path) -> anyhow::Result { + let path = dir.join(CONFIG_FILENAME); + self.save(&path)?; + Ok(path) + } +} + +/// Set up a spacetime.json config for a project. +/// If `client_lang` is provided, creates a config for that language. +/// Otherwise, attempts to auto-detect from package.json. +/// Returns the path to the created config, or None if no config was created. +pub fn setup_for_project( + project_path: &Path, + client_lang: Option<&str>, + package_manager: Option, +) -> anyhow::Result> { + if let Some(lang) = client_lang { + let config = SpacetimeConfig::for_client_lang(lang, package_manager); + return Ok(Some(config.save_to_dir(project_path)?)); + } + + if let Some((detected_cmd, _)) = detect_client_command(project_path) { + return Ok(Some( + SpacetimeConfig::with_run_command(&detected_cmd).save_to_dir(project_path)?, + )); + } + + Ok(None) +} + +/// Detect the package manager from lock files in the project directory. +pub fn detect_package_manager(project_dir: &Path) -> Option { + // Check for lock files in order of preference + if project_dir.join("pnpm-lock.yaml").exists() { + return Some(PackageManager::Pnpm); + } + if project_dir.join("yarn.lock").exists() { + return Some(PackageManager::Yarn); + } + if project_dir.join("bun.lockb").exists() || project_dir.join("bun.lock").exists() { + return Some(PackageManager::Bun); + } + if project_dir.join("package-lock.json").exists() { + return Some(PackageManager::Npm); + } + // Default to npm if package.json exists but no lock file + if project_dir.join("package.json").exists() { + return Some(PackageManager::Npm); + } + None +} + +/// Simple auto-detection for projects without `spacetime.json`. +/// Returns the client command and optionally the detected package manager. +pub fn detect_client_command(project_dir: &Path) -> Option<(String, Option)> { + // JavaScript/TypeScript: package.json with "dev" script + let package_json = project_dir.join("package.json"); + if package_json.exists() { + if let Ok(content) = fs::read_to_string(&package_json) { + if let Ok(json) = serde_json::from_str::(&content) { + let has_dev = json.get("scripts").and_then(|s| s.get("dev")).is_some(); + if has_dev { + let pm = detect_package_manager(project_dir); + let cmd = pm.map(|p| p.run_dev_command()).unwrap_or("npm run dev"); + return Some((cmd.to_string(), pm)); + } + } + } + } + + // Rust: Cargo.toml + if project_dir.join("Cargo.toml").exists() { + return Some(("cargo run".to_string(), None)); + } + + // C#: .csproj file + if let Ok(entries) = fs::read_dir(project_dir) { + for entry in entries.flatten() { + if entry.path().extension().is_some_and(|e| e == "csproj") { + return Some(("dotnet run".to_string(), None)); + } + } + } + + None +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::Arg; + + #[test] + fn test_deserialize_full_config() { + let json = r#"{ + "dev": { + "run": "pnpm dev" + }, + "generate": [ + { + "out-dir": "./foobar", + "module-path": "region-module", + "language": "csharp" + }, + { + "out-dir": "./global", + "module-path": "global-module", + "language": "csharp" + } + ], + "publish": { + "database": "bitcraft", + "module-path": "spacetimedb", + "server": "local", + "children": [ + { + "database": "region-1", + "module-path": "region-module" + }, + { + "database": "region-2", + "module-path": "region-module" + } + ] + } + }"#; + + let config: SpacetimeConfig = json5::from_str(json).unwrap(); + + assert_eq!(config.dev.as_ref().and_then(|d| d.run.as_deref()), Some("pnpm dev")); + + let generate = config.generate.as_ref().unwrap(); + assert_eq!(generate.len(), 2); + assert_eq!(generate[0].get("out-dir").and_then(|v| v.as_str()), Some("./foobar")); + assert_eq!(generate[0].get("language").and_then(|v| v.as_str()), Some("csharp")); + + let publish = config.publish.as_ref().unwrap(); + assert_eq!( + publish.additional_fields.get("database").and_then(|v| v.as_str()), + Some("bitcraft") + ); + assert_eq!( + publish.additional_fields.get("module-path").and_then(|v| v.as_str()), + Some("spacetimedb") + ); + + let children = publish.children.as_ref().unwrap(); + assert_eq!(children.len(), 2); + assert_eq!( + children[0].additional_fields.get("database").and_then(|v| v.as_str()), + Some("region-1") + ); + assert_eq!( + children[1].additional_fields.get("database").and_then(|v| v.as_str()), + Some("region-2") + ); + } + + #[test] + fn test_deserialize_with_comments() { + let json = r#"{ + // This is a comment + "dev": { + "run": "npm start" + }, + /* Multi-line comment */ + "generate": [ + { + "out-dir": "./src/bindings", // inline comment + "language": "typescript" + } + ] + }"#; + + let config: SpacetimeConfig = json5::from_str(json).unwrap(); + assert_eq!(config.dev.as_ref().and_then(|d| d.run.as_deref()), Some("npm start")); + } + + #[test] + fn test_minimal_config() { + let json = r#"{}"#; + let config: SpacetimeConfig = json5::from_str(json).unwrap(); + + assert!(config.dev.is_none()); + assert!(config.generate.is_none()); + assert!(config.publish.is_none()); + } + + #[test] + fn test_project_config_builder() { + use clap::{Arg, Command}; + + // Create a simple clap command with some arguments + let cmd = Command::new("test") + .arg(Arg::new("out-dir").long("out-dir").value_name("DIR")) + .arg(Arg::new("lang").long("lang").value_name("LANG")) + .arg(Arg::new("server").long("server").value_name("SERVER")); + + let matches = cmd + .clone() + .get_matches_from(vec!["test", "--out-dir", "./bindings", "--lang", "typescript"]); + + // Build schema + let schema = CommandSchemaBuilder::new() + .key(Key::new::("language").from_clap("lang")) + .key(Key::new::("out-dir")) + .key(Key::new::("server")) + .build(&cmd) + .unwrap(); + + // Simulate config file values + let mut config_values = HashMap::new(); + config_values.insert("language".to_string(), Value::String("rust".to_string())); + config_values.insert("server".to_string(), Value::String("local".to_string())); + + // Create CommandConfig with schema + let command_config = CommandConfig::new(&schema, config_values, &matches).unwrap(); + + // CLI args should override config values + assert_eq!( + command_config.get_one::("out-dir").unwrap(), + Some("./bindings".to_string()) + ); + assert_eq!( + command_config.get_one::("language").unwrap(), + Some("typescript".to_string()) + ); // CLI overrides (use config name, not clap name) + assert_eq!( + command_config.get_one::("server").unwrap(), + Some("local".to_string()) + ); // from config + } + + #[test] + fn test_publish_config_extraction() { + use clap::{Arg, Command}; + + // Parse a PublishConfig from JSON + let json = r#"{ + "database": "my-database", + "server": "local", + "module-path": "./my-module", + "build-options": "--features extra", + "break-clients": true, + "anonymous": false + }"#; + + let publish_config: PublishConfig = json5::from_str(json).unwrap(); + + // Verify children field + assert!(publish_config.children.is_none()); + + // Verify all fields are in additional_fields + assert_eq!( + publish_config + .additional_fields + .get("database") + .and_then(|v| v.as_str()), + Some("my-database") + ); + assert_eq!( + publish_config.additional_fields.get("server").and_then(|v| v.as_str()), + Some("local") + ); + assert_eq!( + publish_config + .additional_fields + .get("module-path") + .and_then(|v| v.as_str()), + Some("./my-module") + ); + assert_eq!( + publish_config + .additional_fields + .get("build-options") + .and_then(|v| v.as_str()), + Some("--features extra") + ); + assert_eq!( + publish_config + .additional_fields + .get("break-clients") + .and_then(|v| v.as_bool()), + Some(true) + ); + + // Now test merging with clap args + let cmd = Command::new("test") + .arg(Arg::new("database").long("database")) + .arg(Arg::new("server").long("server")) + .arg(Arg::new("module_path").long("module-path")) + .arg(Arg::new("build_options").long("build-options")) + .arg(Arg::new("break_clients").long("break-clients")) + .arg(Arg::new("anon_identity").long("anonymous")); + + // CLI overrides the server + let matches = cmd.clone().get_matches_from(vec!["test", "--server", "maincloud"]); + + // Build schema with snake_case keys + let schema = CommandSchemaBuilder::new() + .key(Key::new::("database")) + .key(Key::new::("server")) + .key(Key::new::("module_path")) + .key(Key::new::("build_options")) + .key(Key::new::("break_clients")) + // Config uses "anonymous", clap uses "anon_identity" + .key(Key::new::("anonymous").from_clap("anon_identity")) + .build(&cmd) + .unwrap(); + + // Just pass the additional_fields directly - they will be normalized from kebab to snake_case + let command_config = CommandConfig::new(&schema, publish_config.additional_fields, &matches).unwrap(); + + // database comes from config + assert_eq!( + command_config.get_one::("database").unwrap(), + Some("my-database".to_string()) + ); + // server comes from CLI (overrides config) + assert_eq!( + command_config.get_one::("server").unwrap(), + Some("maincloud".to_string()) + ); + // module_path comes from config (kebab-case in JSON was normalized to snake_case) + assert_eq!( + command_config.get_one::("module_path").unwrap(), + Some("./my-module".to_string()) + ); + // build_options comes from config + assert_eq!( + command_config.get_one::("build_options").unwrap(), + Some("--features extra".to_string()) + ); + } + + #[test] + fn test_type_mismatch_error() { + use clap::{Arg, Command}; + + let cmd = Command::new("test").arg( + Arg::new("server") + .long("server") + .value_parser(clap::value_parser!(String)), + ); + + let matches = cmd.clone().get_matches_from(vec!["test", "--server", "local"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("server")) + .build(&cmd) + .unwrap(); + + let command_config = CommandConfig::new(&schema, HashMap::new(), &matches).unwrap(); + + // Trying to get as i64 when it's defined as String should error + let result = command_config.get_one::("server"); + assert!(matches!( + result.unwrap_err(), + CommandConfigError::TypeMismatch { key, requested_type, expected_type } + if key == "server" && requested_type.contains("i64") && expected_type.contains("String") + )); + } + + #[test] + fn test_schema_missing_key_definition_error() { + use clap::{Arg, Command}; + + // Define clap command with some arguments + let cmd = Command::new("test") + .arg( + Arg::new("server") + .long("server") + .value_parser(clap::value_parser!(String)), + ) + .arg(Arg::new("yes").long("yes").action(clap::ArgAction::SetTrue)); + + // 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_kebab_case_normalization() { + use clap::{Arg, Command}; + + let cmd = Command::new("test").arg( + Arg::new("build_options") + .long("build-options") + .value_parser(clap::value_parser!(String)), + ); + + let matches = cmd.clone().get_matches_from(vec!["test"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("build_options")) + .build(&cmd) + .unwrap(); + + // Config file uses kebab-case + let mut config_values = HashMap::new(); + config_values.insert("build-options".to_string(), Value::String("--release".to_string())); + + // The normalization in CommandConfig::new should convert build-options to build_options + let command_config = CommandConfig::new(&schema, config_values, &matches).unwrap(); + + // Should be able to access via snake_case key + assert_eq!( + command_config.get_one::("build_options").unwrap(), + Some("--release".to_string()) + ); + } + + // CommandSchema Tests + + #[test] + fn test_invalid_clap_reference_caught() { + let cmd = Command::new("test").arg( + Arg::new("valid_arg") + .long("valid-arg") + .value_parser(clap::value_parser!(String)), + ); + + let result = CommandSchemaBuilder::new() + .key(Key::new::("nonexistent_arg")) + .build(&cmd); + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + CommandConfigError::InvalidClapReference { .. } + )); + } + + #[test] + fn test_invalid_alias_reference_caught() { + let cmd = Command::new("test").arg(Arg::new("name").long("name").value_parser(clap::value_parser!(String))); + + // Reference a valid arg (name) but add invalid alias (nonexistent) via .alias() + let result = CommandSchemaBuilder::new() + .key(Key::new::("my_key").from_clap("name").alias("nonexistent")) + .build(&cmd); + + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err, CommandConfigError::InvalidAliasReference { .. })); + } + + // CommandConfig Tests + + #[test] + fn test_get_one_returns_none_when_missing_from_both_sources() { + let cmd = Command::new("test").arg( + Arg::new("some_arg") + .long("some-arg") + .value_parser(clap::value_parser!(String)), + ); + + let matches = cmd.clone().get_matches_from(vec!["test"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("some_arg")) + .build(&cmd) + .unwrap(); + + let command_config = CommandConfig::new(&schema, HashMap::new(), &matches).unwrap(); + + assert_eq!(command_config.get_one::("some_arg").unwrap(), None); + } + + #[test] + fn test_get_one_with_aliased_keys() { + let cmd = Command::new("test").arg(Arg::new("name|identity").value_parser(clap::value_parser!(String))); + + let matches = cmd.clone().get_matches_from(vec!["test", "my-database"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("database").from_clap("name|identity")) + .build(&cmd) + .unwrap(); + + let command_config = CommandConfig::new(&schema, HashMap::new(), &matches).unwrap(); + + assert_eq!( + command_config.get_one::("database").unwrap(), + Some("my-database".to_string()) + ); + } + + #[test] + fn test_is_from_cli_identifies_sources_correctly() { + let cmd = Command::new("test") + .arg( + Arg::new("cli_arg") + .long("cli-arg") + .value_parser(clap::value_parser!(String)), + ) + .arg( + Arg::new("default_arg") + .long("default-arg") + .default_value("default") + .value_parser(clap::value_parser!(String)), + ) + .arg( + Arg::new("config_arg") + .long("config-arg") + .value_parser(clap::value_parser!(String)), + ); + + let matches = cmd.clone().get_matches_from(vec!["test", "--cli-arg", "from-cli"]); + + let schema = CommandSchemaBuilder::new() + .key(Key::new::("cli_arg")) + .key(Key::new::("default_arg")) + .key(Key::new::("config_arg")) + .build(&cmd) + .unwrap(); + + // CLI arg should be detected + assert!(schema.is_from_cli(&matches, "cli_arg")); + + // Default arg should NOT be detected as CLI + assert!(!schema.is_from_cli(&matches, "default_arg")); + + // Config arg (not provided anywhere) should NOT be detected as CLI + assert!(!schema.is_from_cli(&matches, "config_arg")); + } + + // SpacetimeConfig Tests + + #[test] + fn test_find_and_load_walks_up_directory_tree() { + use std::fs; + use tempfile::TempDir; + + let temp = TempDir::new().unwrap(); + let root = temp.path(); + let subdir1 = root.join("level1"); + let subdir2 = subdir1.join("level2"); + fs::create_dir_all(&subdir2).unwrap(); + + // Create config in root + let config = SpacetimeConfig { + dev: Some(DevConfig { + run: Some("test".to_string()), + }), + ..Default::default() + }; + config.save(&root.join("spacetime.json")).unwrap(); + + // Search from subdir2 - should find config in root + let result = SpacetimeConfig::find_and_load_from(subdir2).unwrap(); + assert!(result.is_some()); + let (found_path, found_config) = result.unwrap(); + assert_eq!(found_path, root.join("spacetime.json")); + assert_eq!(found_config.dev.as_ref().and_then(|d| d.run.as_deref()), Some("test")); + } + + #[test] + fn test_malformed_json_returns_error() { + use std::fs; + use tempfile::TempDir; + + let temp = TempDir::new().unwrap(); + let config_path = temp.path().join("spacetime.json"); + + fs::write(&config_path, "{ invalid json }").unwrap(); + + let result = SpacetimeConfig::find_and_load_from(temp.path().to_path_buf()); + assert!(result.is_err()); + } + + #[test] + fn test_missing_file_returns_none() { + use tempfile::TempDir; + + let temp = TempDir::new().unwrap(); + + let result = SpacetimeConfig::find_and_load_from(temp.path().to_path_buf()).unwrap(); + assert!(result.is_none()); + } + + #[test] + fn test_empty_config_file_handled() { + use std::fs; + use tempfile::TempDir; + + let temp = TempDir::new().unwrap(); + let config_path = temp.path().join("spacetime.json"); + + fs::write(&config_path, "{}").unwrap(); + + let result = SpacetimeConfig::find_and_load_from(temp.path().to_path_buf()).unwrap(); + assert!(result.is_some()); + let (_, config) = result.unwrap(); + assert!(config.dev.is_none()); + assert!(config.publish.is_none()); + assert!(config.generate.is_none()); + } +} diff --git a/crates/cli/src/subcommands/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..8f884b7b99e 100644 --- a/crates/cli/src/subcommands/dev.rs +++ b/crates/cli/src/subcommands/dev.rs @@ -1,6 +1,7 @@ use crate::common_args::ClearMode; use crate::config::Config; use crate::generate::Language; +use crate::spacetime_config::{detect_client_command, CommandConfig, 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, @@ -18,7 +19,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 +32,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 +68,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 +89,18 @@ pub fn cli() -> Command { .value_name("TEMPLATE") .help("Template ID or GitHub repository (owner/repo or URL) for project initialization"), ) + .arg( + Arg::new("run") + .long("run") + .value_name("COMMAND") + .help("Command to run the client development server (overrides spacetime.json config)"), + ) + .arg( + Arg::new("server-only") + .long("server-only") + .action(clap::ArgAction::SetTrue) + .help("Only run the server (module) without starting the client"), + ) } #[derive(Deserialize)] @@ -101,7 +116,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 +127,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 +142,73 @@ 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"), + }; + + // Load spacetime.json config early so we can use it for determining project + // directories + let spacetime_config = match SpacetimeConfig::load_from_dir(&project_dir) { + Ok(config) => config, + Err(e) => { + eprintln!("{} Failed to load spacetime.json: {}", "✗".red(), e); + std::process::exit(1); + } + }; + + // 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 to use with get_one() + 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(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.as_ref(), + &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 +231,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 +248,58 @@ 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 + spacetimedb_dir = config + .get_one::("module_path") + .expect("module_path") + .expect("module_path is required"); + } + + 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 +315,30 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E ); } - if resolved_server == "maincloud" && config.spacetimedb_token().is_none() { + // Check if we need to login to maincloud + // Either because --server maincloud was provided, or because any of the publish configs use maincloud + let needs_maincloud_login = resolved_server == "maincloud" + || spacetime_config + .as_ref() + .and_then(|c| c.publish.as_ref()) + .map(|publish| { + publish.iter_all_targets().any(|target| { + target + .additional_fields + .get("server") + .and_then(|v| v.as_str()) + .map(|s| s == "maincloud") + .unwrap_or(false) + }) + }) + .unwrap_or(false); + + if needs_maincloud_login && config.spacetimedb_token().is_none() { let should_login = Confirm::new() .with_prompt("Would you like to sign in now?") .default(true) .interact()?; - if !should_login && server.is_some() { + if !should_login && server_from_cli.is_some() { // The user explicitly provided --server maincloud but doesn't want to log in anyhow::bail!("Login required to publish to maincloud server"); } else if !should_login { @@ -211,60 +356,84 @@ 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() - ); - } - name.clone() - } else { - println!("\n{}", "Found existing SpacetimeDB project.".green()); - println!("Now we need to select a database to publish to.\n"); + // Determine client command: CLI flag > config file > auto-detect (and save) + let server_only = args.get_flag("server-only"); - 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? + let client_command = if server_only { + None + } else if let Some(cmd) = args.get_one::("run") { + // Explicit CLI flag takes priority + Some(cmd.clone()) + } else { + // Try to load config, handling errors properly + match SpacetimeConfig::load_from_dir(&project_dir) { + Ok(Some(config)) => { + // Config file exists and parsed successfully + let config_path = project_dir.join("spacetime.json"); + println!("{} Using configuration from {}", "✓".green(), config_path.display()); + + // If config exists but dev.run is None, try to detect and update + if config.dev.as_ref().and_then(|d| d.run.as_ref()).is_none() { + detect_and_save_client_command(&project_dir, Some(config)) + } else { + config.dev.and_then(|d| d.run) + } + } + Ok(None) => { + // No config file - try to detect and create new + detect_and_save_client_command(&project_dir, None) + } + Err(e) => { + // Config file exists but failed to parse - show error and exit + eprintln!("{} Failed to load spacetime.json: {}", "✗".red(), e); + std::process::exit(1); } } }; - 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()) + .expect("database is a required field") + .to_string() + }) + .collect(); + + // Use first database for client process + let db_name_for_client = &db_names_for_logging[0]; + + // Extract watch directories from publish configs + let watch_dirs = extract_watch_dirs(&publish_configs, &spacetimedb_dir); + + println!("\n{}", "Starting development mode...".green().bold()); + 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() - ); + if let Some(ref cmd) = client_command { + println!("Client command: {}", cmd.cyan()); + } println!("{}", "Press Ctrl+C to stop".dimmed()); println!(); @@ -273,10 +442,10 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E &project_dir, &spacetimedb_dir, &module_bindings_dir, - &database_name, client_language, - resolved_server, clear_database, + &publish_configs, + server_from_cli, force, ) .await?; @@ -284,8 +453,61 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E // 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 +524,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,10 +545,10 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E &project_dir, &spacetimedb_dir, &module_bindings_dir, - &database_name, client_language, - resolved_server, clear_database, + &publish_configs, + server_from_cli, force, ) .await @@ -341,6 +563,45 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E } } +fn determine_publish_configs<'a>( + database_name: Option, + spacetime_config: Option<&'a SpacetimeConfig>, + 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 config.publish.is_some() { + publish_configs = publish::get_filtered_publish_configs(config, 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,10 +653,10 @@ async fn generate_build_and_publish( project_dir: &Path, spacetimedb_dir: &Path, module_bindings_dir: &Path, - database_name: &str, client_language: Option<&Language>, - server: &str, clear_database: ClearMode, + publish_configs: &[CommandConfig<'_>], + server: Option<&str>, yes: bool, ) -> Result<(), anyhow::Error> { let module_language = detect_module_language(spacetimedb_dir)?; @@ -412,16 +673,25 @@ async fn generate_build_and_publish( Language::UnrealCpp => "unrealcpp", }; + // For TypeScript client, update .env.local with first database name if client_language == &Language::TypeScript { - // Update SPACETIMEDB_DBNAME environment variables in `.env.local` for TypeScript client + let first_config = publish_configs.first().expect("publish_configs cannot be empty"); + let first_db_name = first_config + .get_config_value("database") + .and_then(|v| v.as_str()) + .expect("database is a required field"); + + // CLI server takes precedence, otherwise use server from config + let server_for_env = server.or_else(|| first_config.get_config_value("server").and_then(|v| v.as_str())); + println!( "{} {}...", "Updating .env.local with database name".cyan(), - database_name + first_db_name ); let env_path = project_dir.join(".env.local"); - let server_host_url = config.get_host_url(Some(server))?; - upsert_env_db_names_and_hosts(&env_path, &server_host_url, database_name)?; + let server_host_url = config.get_host_url(server_for_env)?; + upsert_env_db_names_and_hosts(&env_path, &server_host_url, first_db_name)?; } println!("{}", "Building...".cyan()); @@ -443,33 +713,55 @@ async fn generate_build_and_publish( generate_argv.push("--yes"); } let generate_args = generate::cli().get_matches_from(generate_argv); - generate::exec(config.clone(), &generate_args).await?; + generate::exec_ex( + config.clone(), + &generate_args, + crate::generate::extract_descriptions, + true, + ) + .await?; println!("{}", "Publishing...".cyan()); let project_path_str = spacetimedb_dir.to_str().unwrap(); - let clear_flag = match clear_database { ClearMode::Always => "always", ClearMode::Never => "never", ClearMode::OnConflict => "on-conflict", }; - let mut publish_args = vec![ - "publish".to_string(), - database_name.to_string(), - "--project-path".to_string(), - project_path_str.to_string(), - "--yes".to_string(), - format!("--delete-data={}", clear_flag), - ]; - publish_args.extend_from_slice(&["--server".to_string(), server.to_string()]); - let publish_cmd = publish::cli(); - let publish_matches = publish_cmd - .try_get_matches_from(publish_args) - .context("Failed to create publish arguments")?; + // Loop through all publish configs + for config_entry in publish_configs { + let db_name = config_entry + .get_config_value("database") + .and_then(|v| v.as_str()) + .expect("database is a required field"); - publish::exec(config.clone(), &publish_matches).await?; + if publish_configs.len() > 1 { + println!("{} {}...", "Publishing to".cyan(), db_name.cyan().bold()); + } + + let mut publish_args = vec![ + "publish".to_string(), + db_name.to_string(), + "--project-path".to_string(), + project_path_str.to_string(), + "--yes".to_string(), + format!("--delete-data={}", clear_flag), + ]; + + // Only pass --server if it was explicitly provided via CLI + if let Some(srv) = server { + publish_args.extend_from_slice(&["--server".to_string(), srv.to_string()]); + } + + let publish_cmd = publish::cli(); + let publish_matches = publish_cmd + .try_get_matches_from(publish_args) + .context("Failed to create publish arguments")?; + + publish::exec_with_options(config.clone(), &publish_matches, true).await?; + } println!("{}", "Published successfully!".green().bold()); println!("{}", "---".dimmed()); @@ -590,6 +882,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 +890,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 +905,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 +936,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 +974,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 +1057,167 @@ fn generate_database_name() -> String { let mut generator = names::Generator::with_naming(names::Name::Numbered); generator.next().unwrap() } + +/// Extract unique watch directories from publish configs +fn extract_watch_dirs( + publish_configs: &[CommandConfig<'_>], + default_spacetimedb_dir: &Path, +) -> std::collections::HashSet { + use std::collections::HashSet; + let mut watch_dirs = HashSet::new(); + + for config_entry in publish_configs { + let module_path = config_entry + .get_config_value("module_path") + .and_then(|v| v.as_str()) + .map(PathBuf::from) + .unwrap_or_else(|| default_spacetimedb_dir.to_path_buf()); + + // Canonicalize to handle relative paths + let canonical_path = module_path.canonicalize().unwrap_or(module_path); + + watch_dirs.insert(canonical_path); + } + + watch_dirs +} + +/// Detect client command and save to config (updating existing config if present) +fn detect_and_save_client_command(project_dir: &Path, existing_config: Option) -> Option { + if let Some((detected_cmd, _detected_pm)) = detect_client_command(project_dir) { + // Update existing config or create new one + let config_to_save = if let Some(mut config) = existing_config { + config.dev = Some(crate::spacetime_config::DevConfig { + run: Some(detected_cmd.clone()), + }); + config + } else { + SpacetimeConfig::with_run_command(&detected_cmd) + }; + + if let Ok(path) = config_to_save.save_to_dir(project_dir) { + println!( + "{} Detected client command and saved to {}", + "✓".green(), + path.display() + ); + } + Some(detected_cmd) + } else { + None + } +} + +/// Start the client development server as a child process. +/// The process inherits stdout/stderr so the user can see the output. +/// Sets SPACETIMEDB_DB_NAME and SPACETIMEDB_HOST environment variables for the client. +fn start_client_process( + command: &str, + working_dir: &Path, + database_name: &str, + host_url: &str, +) -> Result { + println!("{} {}", "Starting client:".cyan(), command.dimmed()); + + if command.trim().is_empty() { + anyhow::bail!("Empty client command"); + } + + // Use shell to handle PATH resolution and .cmd/.bat scripts on Windows + #[cfg(windows)] + let child = TokioCommand::new("cmd") + .args(["/C", command]) + .current_dir(working_dir) + .env("SPACETIMEDB_DB_NAME", database_name) + .env("SPACETIMEDB_HOST", host_url) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .stdin(std::process::Stdio::null()) + .kill_on_drop(true) + .spawn() + .with_context(|| format!("Failed to start client command: {}", command))?; + + #[cfg(not(windows))] + let child = TokioCommand::new("sh") + .args(["-c", command]) + .current_dir(working_dir) + .env("SPACETIMEDB_DB_NAME", database_name) + .env("SPACETIMEDB_HOST", host_url) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .stdin(std::process::Stdio::null()) + .kill_on_drop(true) + .spawn() + .with_context(|| format!("Failed to start client command: {}", command))?; + + Ok(child) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_detect_and_save_preserves_existing_config() { + let temp = TempDir::new().unwrap(); + + // Create a config with generate and publish but no dev-run + let initial_config = r#"{ + "generate": [ + { "out-dir": "./foo-client/src/module_bindings", "module-path": "foo", "language": "rust" } + ], + "publish": { + "database": "test-db", + "server": "maincloud" + } + }"#; + + let config_path = temp.path().join("spacetime.json"); + fs::write(&config_path, initial_config).unwrap(); + + // Create a package.json to enable detection + let package_json = r#"{ + "name": "test", + "scripts": { + "dev": "vite" + } + }"#; + fs::write(temp.path().join("package.json"), package_json).unwrap(); + + // Load the config + let loaded_config = SpacetimeConfig::load(&config_path).unwrap(); + assert!(loaded_config.dev.is_none()); + assert!(loaded_config.generate.is_some()); + assert!(loaded_config.publish.is_some()); + + // Call detect_and_save_client_command which should detect "npm run dev" + let detected = detect_and_save_client_command(temp.path(), Some(loaded_config)); + assert!(detected.is_some(), "Should detect client command from package.json"); + + // Load again and verify all fields are preserved + let reloaded_config = SpacetimeConfig::load(&config_path).unwrap(); + assert!( + reloaded_config.dev.as_ref().and_then(|d| d.run.as_ref()).is_some(), + "dev.run should be set" + ); + assert!(reloaded_config.generate.is_some(), "generate field should be preserved"); + assert!(reloaded_config.publish.is_some(), "publish field should be preserved"); + + // Verify the generate array has the expected content + let generate = reloaded_config.generate.unwrap(); + assert_eq!(generate.len(), 1); + assert_eq!( + generate[0].get("out-dir").unwrap().as_str().unwrap(), + "./foo-client/src/module_bindings" + ); + + // Verify the publish object has the expected content + let publish = reloaded_config.publish.unwrap(); + assert_eq!( + publish.additional_fields.get("database").unwrap().as_str().unwrap(), + "test-db" + ); + } +} diff --git a/crates/cli/src/subcommands/generate.rs b/crates/cli/src/subcommands/generate.rs index d7e40b4f2de..39334ab0f7c 100644 --- a/crates/cli/src/subcommands/generate.rs +++ b/crates/cli/src/subcommands/generate.rs @@ -16,26 +16,112 @@ use spacetimedb_schema::def::ModuleDef; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; +use crate::spacetime_config::{CommandConfig, CommandSchema, CommandSchemaBuilder, Key, SpacetimeConfig}; use crate::tasks::csharp::dotnet_format; use crate::tasks::rust::rustfmt; -use crate::util::{resolve_sibling_binary, y_or_n}; +use crate::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 serde_json::Value; +use std::collections::{BTreeSet, HashMap}; use std::io::Read; +/// Build the CommandSchema for generate command configuration. +/// +/// This schema is used to validate and merge values from both the config file +/// and CLI arguments, with CLI arguments taking precedence over config values. +fn build_generate_config_schema(command: &clap::Command) -> Result { + CommandSchemaBuilder::new() + .key(Key::new::("language").from_clap("lang").required()) + .key(Key::new::("out_dir")) + .key(Key::new::("uproject_dir")) + .key(Key::new::("module_path")) + .key(Key::new::("wasm_file")) + .key(Key::new::("js_file")) + .key(Key::new::("namespace")) + .key(Key::new::("module_name")) + .key(Key::new::("build_options")) + .exclude("json_module") + .exclude("force") + .build(command) + .map_err(Into::into) +} + +/// Get filtered generate configs based on CLI arguments. When the user sets +/// the module path as a CLI argument and the config file is available, +/// we should only run the generate command for config entries that match +/// the module path +fn get_filtered_generate_configs<'a>( + spacetime_config: &'a SpacetimeConfig, + schema: &'a CommandSchema, + args: &'a clap::ArgMatches, +) -> Result>, anyhow::Error> { + // Get all generate configs from spacetime.json + let all_configs: Vec> = spacetime_config.generate.as_ref().cloned().unwrap_or_default(); + + // If no config file, return empty (will use CLI args only) + if all_configs.is_empty() { + return Ok(vec![]); + } + + // Build CommandConfig for each generate config - this merges any arguments passed + // through the CLI with the values from the config file + let all_command_configs: Vec = all_configs + .into_iter() + .map(|config| { + let command_config = CommandConfig::new(schema, config, args)?; + command_config.validate()?; + Ok(command_config) + }) + .collect::, anyhow::Error>>()?; + + // Filter by module_path if provided via CLI + let filtered_configs: Vec = if schema.is_from_cli(args, "module_path") { + let cli_module_path = schema.get_clap_arg::(args, "module_path")?; + // Canonicalize the CLI path for comparison (if it exists) + let cli_canonical = cli_module_path.as_ref().and_then(|p| p.canonicalize().ok()); + + all_command_configs + .into_iter() + .filter(|config| { + // Get module_path from CONFIG ONLY (not merged with CLI) + let config_module_path = config + .get_config_value("module_path") + .and_then(|v| v.as_str()) + .map(PathBuf::from); + + // If we have a canonical CLI path, try to canonicalize config path and compare + if let Some(ref cli_canon) = cli_canonical { + if let Some(ref config_path) = config_module_path { + if let Ok(config_canon) = config_path.canonicalize() { + return cli_canon == &config_canon; + } + } + } + + // Fallback to direct comparison if canonicalization fails + config_module_path.as_ref() == cli_module_path.as_ref() + }) + .collect() + } else { + all_command_configs + }; + + Ok(filtered_configs) +} + pub fn cli() -> clap::Command { clap::Command::new("generate") .about("Generate client files for a spacetime module.") - .override_usage("spacetime generate --lang --out-dir [--project-path | --bin-path | --module-name | --uproject-dir | --include-private]") + .override_usage("spacetime generate --lang --out-dir [--module-path | --bin-path | --module-name | --uproject-dir | --include-private]") .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 +131,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 +157,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") @@ -94,11 +175,9 @@ pub fn cli() -> clap::Command { Arg::new("module_name") .long("module-name") .help("The module name that should be used for DLL export macros (required for lang unrealcpp)") - .required_if_eq("lang", "unrealcpp") ) .arg( Arg::new("lang") - .required(true) .long("lang") .short('l') .value_parser(clap::value_parser!(Language)) @@ -121,15 +200,10 @@ pub fn cli() -> clap::Command { ) .arg(common_args::yes()) .after_help("Run `spacetime help publish` for more detailed information.") - .group( - clap::ArgGroup::new("output_dir") - .args(["out_dir", "uproject_dir"]) - .required(true) - ) } pub async fn exec(config: Config, args: &clap::ArgMatches) -> anyhow::Result<()> { - exec_ex(config, args, extract_descriptions).await + exec_ex(config, args, extract_descriptions, false).await } /// Like `exec`, but lets you specify a custom a function to extract a schema from a file. @@ -137,153 +211,232 @@ pub async fn exec_ex( config: Config, args: &clap::ArgMatches, extract_descriptions: ExtractDescriptions, + quiet_config: bool, ) -> anyhow::Result<()> { - let project_path = args.get_one::("project_path").unwrap(); - let wasm_file = args.get_one::("wasm_file").cloned(); - let js_file = args.get_one::("js_file").cloned(); - let json_module = args.get_many::("json_module"); - let lang = *args.get_one::("lang").unwrap(); - let namespace = args.get_one::("namespace").unwrap(); - let module_name = args.get_one::("module_name"); - let force = args.get_flag("force"); - let build_options = args.get_one::("build_options").unwrap(); - 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 module: ModuleDef = if let Some(mut json_module) = json_module { - let DeserializeWrapper::(module) = if let Some(path) = json_module.next() { - serde_json::from_slice(&fs::read(path)?)? + // Get generate configs (from spacetime.json or empty) + let spacetime_config_opt = SpacetimeConfig::find_and_load()?; + let (using_config, generate_configs) = if let Some((config_path, ref spacetime_config)) = spacetime_config_opt { + if !quiet_config { + println!("Using configuration from {}", config_path.display()); + } + let filtered = get_filtered_generate_configs(spacetime_config, &schema, args)?; + // If filtering resulted in no matches, use CLI args with empty config + if filtered.is_empty() { + (false, vec![CommandConfig::new(&schema, HashMap::new(), args)?]) } else { - serde_json::from_reader(std::io::stdin().lock())? - }; - module.try_into()? + (true, filtered) + } } else { - let path = if let Some(path) = wasm_file { - println!("Skipping build. Instead we are inspecting {}", path.display()); - path.clone() - } else if let Some(path) = js_file { - println!("Skipping build. Instead we are inspecting {}", path.display()); - path.clone() - } else { - let (path, _) = build::exec_with_argstring(config.clone(), project_path, build_options).await?; - path - }; - let spinner = indicatif::ProgressBar::new_spinner(); - spinner.enable_steady_tick(std::time::Duration::from_millis(60)); - spinner.set_message(format!("Extracting schema from {}...", path.display())); - extract_descriptions(&path).context("could not extract schema")? + (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() + ); - fs::create_dir_all(out_dir)?; + let namespace = command_config + .get_one::("namespace")? + .unwrap_or_else(|| "SpacetimeDB.Types".to_string()); + let module_name = command_config.get_one::("module_name")?; + let force = args.get_flag("force"); + let build_options = command_config + .get_one::("build_options")? + .unwrap_or_else(String::new); - let mut paths = BTreeSet::new(); + // 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")); + } + + // 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!("--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 = args.get_flag("include_private"); + 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(()) } @@ -310,6 +463,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 +490,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 +501,255 @@ fn extract_descriptions(wasm_file: &Path) -> anyhow::Result { let sats::serde::SerdeWrapper::(module) = serde_json::from_reader(child.stdout.unwrap())?; Ok(module.try_into()?) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::spacetime_config::*; + use std::collections::HashMap; + + // get_filtered_generate_configs Tests + + #[test] + fn test_filter_by_module_path_from_cli() { + use tempfile::TempDir; + let temp = TempDir::new().unwrap(); + let module1 = temp.path().join("module1"); + let module2 = temp.path().join("module2"); + std::fs::create_dir_all(&module1).unwrap(); + std::fs::create_dir_all(&module2).unwrap(); + + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + + let mut config1 = HashMap::new(); + config1.insert("language".to_string(), serde_json::Value::String("rust".to_string())); + config1.insert( + "module_path".to_string(), + serde_json::Value::String(module1.display().to_string()), + ); + config1.insert( + "out_dir".to_string(), + serde_json::Value::String("/tmp/out1".to_string()), + ); + + let mut config2 = HashMap::new(); + config2.insert( + "language".to_string(), + serde_json::Value::String("typescript".to_string()), + ); + config2.insert( + "module_path".to_string(), + serde_json::Value::String(module2.display().to_string()), + ); + config2.insert( + "out_dir".to_string(), + serde_json::Value::String("/tmp/out2".to_string()), + ); + + let spacetime_config = SpacetimeConfig { + generate: Some(vec![config1, config2]), + ..Default::default() + }; + + // Filter by module1 + let matches = cmd.clone().get_matches_from(vec![ + "generate", + "--project-path", + module1.to_str().unwrap(), + "--lang", + "rust", + "--out-dir", + "/tmp/out", + ]); + + let filtered = get_filtered_generate_configs(&spacetime_config, &schema, &matches).unwrap(); + + // The filtering should match module1 config only + assert_eq!( + filtered.len(), + 1, + "Expected 1 config but got {}. Filter should only match module1.", + filtered.len() + ); + + // Verify it's the correct config (module1) + let filtered_module_path = filtered[0].get_one::("module_path").unwrap().unwrap(); + assert_eq!(filtered_module_path, module1); + } + + #[test] + fn test_no_filter_when_module_path_not_from_cli() { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + + let mut config1 = HashMap::new(); + config1.insert("language".to_string(), serde_json::Value::String("rust".to_string())); + config1.insert( + "module_path".to_string(), + serde_json::Value::String("./module1".to_string()), + ); + config1.insert( + "out_dir".to_string(), + serde_json::Value::String("/tmp/out1".to_string()), + ); + + let mut config2 = HashMap::new(); + config2.insert( + "language".to_string(), + serde_json::Value::String("typescript".to_string()), + ); + config2.insert( + "module_path".to_string(), + serde_json::Value::String("./module2".to_string()), + ); + config2.insert( + "out_dir".to_string(), + serde_json::Value::String("/tmp/out2".to_string()), + ); + + let spacetime_config = SpacetimeConfig { + generate: Some(vec![config1, config2]), + ..Default::default() + }; + + // No module_path provided via CLI + let matches = cmd.clone().get_matches_from(vec!["generate"]); + let filtered = get_filtered_generate_configs(&spacetime_config, &schema, &matches).unwrap(); + + // Should return all configs + assert_eq!(filtered.len(), 2); + } + + #[test] + fn test_path_normalization_in_filtering() { + use tempfile::TempDir; + let temp = TempDir::new().unwrap(); + let module_dir = temp.path().join("mymodule"); + std::fs::create_dir_all(&module_dir).unwrap(); + + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + + // Config uses absolute path + let mut config = HashMap::new(); + config.insert("language".to_string(), serde_json::Value::String("rust".to_string())); + config.insert( + "module_path".to_string(), + serde_json::Value::String(module_dir.display().to_string()), + ); + config.insert("out_dir".to_string(), serde_json::Value::String("/tmp/out".to_string())); + + let spacetime_config = SpacetimeConfig { + generate: Some(vec![config]), + ..Default::default() + }; + + // CLI uses path with ./ and .. + let cli_path = module_dir.join("..").join("mymodule"); + let matches = cmd.clone().get_matches_from(vec![ + "generate", + "--project-path", + cli_path.to_str().unwrap(), + "--lang", + "rust", + "--out-dir", + "/tmp/out", + ]); + let filtered = get_filtered_generate_configs(&spacetime_config, &schema, &matches).unwrap(); + + // Should match despite different path representations + assert_eq!(filtered.len(), 1); + } + + // Language-Specific Validation Tests + + #[tokio::test] + async fn test_rust_requires_out_dir() { + use crate::config::Config; + use spacetimedb_paths::cli::CliTomlPath; + use spacetimedb_paths::FromPathUnchecked; + + let cmd = cli(); + let config = Config::new_with_localhost(CliTomlPath::from_path_unchecked("/tmp/test-config.toml")); + + // Missing --out-dir for rust + let matches = cmd.clone().get_matches_from(vec!["generate", "--lang", "rust"]); + let result = exec(config, &matches).await; + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + // The error should be about missing output directory + assert!( + err_msg.contains("--out-dir") || err_msg.contains("--uproject-dir"), + "Expected error about missing output directory, got: {err_msg}" + ); + } + + #[tokio::test] + async fn test_unrealcpp_requires_uproject_dir_and_module_name() { + use crate::config::Config; + use spacetimedb_paths::cli::CliTomlPath; + use spacetimedb_paths::FromPathUnchecked; + + let cmd = cli(); + let config = Config::new_with_localhost(CliTomlPath::from_path_unchecked("/tmp/test-config.toml")); + + // Test missing --uproject-dir + let matches = + cmd.clone() + .get_matches_from(vec!["generate", "--lang", "unrealcpp", "--module-name", "MyModule"]); + let result = exec(config.clone(), &matches).await; + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("--uproject-dir is required for --lang unrealcpp"), + "Expected error about missing --uproject-dir, got: {err_msg}", + ); + + // Test missing --module-name + let matches = cmd + .clone() + .get_matches_from(vec!["generate", "--lang", "unrealcpp", "--out-dir", "/tmp/out"]); + let result = exec(config, &matches).await; + + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("--module-name is required for --lang unrealcpp"), + "Expected error about missing --module-name, got: {err_msg}" + ); + } + + #[test] + fn test_validation_considers_both_cli_and_config() { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + + // Config provides uproject_dir + let mut config = HashMap::new(); + config.insert( + "language".to_string(), + serde_json::Value::String("unrealcpp".to_string()), + ); + config.insert( + "uproject_dir".to_string(), + serde_json::Value::String("/config/path".to_string()), + ); + + // CLI provides module_name + let matches = + cmd.clone() + .get_matches_from(vec!["generate", "--lang", "unrealcpp", "--module-name", "MyModule"]); + + let command_config = CommandConfig::new(&schema, config, &matches).unwrap(); + + // Both should be available (one from CLI, one from config) + let uproject_dir = command_config.get_one::("uproject_dir").unwrap(); + let module_name = command_config.get_one::("module_name").unwrap(); + + assert_eq!(uproject_dir, Some(PathBuf::from("/config/path"))); + assert_eq!(module_name, Some("MyModule".to_string())); + } +} diff --git a/crates/cli/src/subcommands/init.rs b/crates/cli/src/subcommands/init.rs index 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..d29221e3347 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -10,10 +10,92 @@ 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::{CommandConfig, CommandSchema, CommandSchemaBuilder, Key, 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") + .build(command) + .map_err(Into::into) +} + +/// Get filtered publish configs based on CLI arguments +pub fn get_filtered_publish_configs<'a>( + spacetime_config: &'a SpacetimeConfig, + schema: &'a CommandSchema, + args: &'a ArgMatches, +) -> Result>, anyhow::Error> { + // Get all publish targets from config + let all_targets: Vec<_> = spacetime_config + .publish + .as_ref() + .map(|p| p.iter_all_targets().collect()) + .unwrap_or_default(); + + // If no config file, return empty (will use CLI args only) + if all_targets.is_empty() { + return Ok(vec![]); + } + + // Build CommandConfig for each target + let all_configs: Vec = all_targets + .into_iter() + .map(|target| { + let config = CommandConfig::new(schema, target.additional_fields.clone(), args)?; + config.validate()?; + Ok(config) + }) + .collect::, anyhow::Error>>()?; + + // Filter by database name if provided via CLI + let filtered_configs: Vec = if schema.is_from_cli(args, "database") { + let cli_database = schema.get_clap_arg::(args, "database")?; + all_configs + .into_iter() + .filter(|config| { + // Get config-only value (not merged with CLI) for filtering + let config_database = config + .get_config_value("database") + .and_then(|v| v.as_str()) + .map(String::from); + config_database.as_deref() == cli_database.as_deref() + }) + .collect() + } else { + all_configs + }; + + // Validate module-specific args aren't used with multiple targets + if filtered_configs.len() > 1 { + let module_specific_args = schema.module_specific_cli_args(args); + if !module_specific_args.is_empty() { + anyhow::bail!( + "Cannot use module-specific arguments ({}) when publishing to multiple targets. \ + Please specify a database filter (--database) or remove these arguments.", + module_specific_args.join(", ") + ); + } + } + + Ok(filtered_configs) +} + pub fn cli() -> clap::Command { clap::Command::new("publish") .about("Create and update a SpacetimeDB database") @@ -30,19 +112,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 +133,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."), @@ -155,163 +236,226 @@ 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).await +} - // Decide program file path and read program. - // Optionally build the program. - let (path_to_program, host_type) = if let Some(path) = wasm_file { - println!("(WASM) Skipping build. Instead we are publishing {}", path.display()); - (path.clone(), "Wasm") - } else if let Some(path) = js_file { - println!("(JS) Skipping build. Instead we are publishing {}", path.display()); - (path.clone(), "Js") +/// This function can be used when calling publish programatically rather than straight from the +/// CLI, like we do in `spacetime dev`. When calling from `spacetime dev` we don't want to display +/// information about using the `spacetime.json` file as it's already announced as part of the +/// `dev` command +pub async fn exec_with_options(mut config: Config, args: &ArgMatches, quiet_config: bool) -> Result<(), anyhow::Error> { + // Build schema + let cmd = cli(); + let schema = build_publish_schema(&cmd)?; + + // Get publish configs (from spacetime.json or empty) + let spacetime_config_opt = SpacetimeConfig::find_and_load()?; + let (using_config, publish_configs) = if let Some((config_path, ref spacetime_config)) = spacetime_config_opt { + if !quiet_config { + println!("Using configuration from {}", config_path.display()); + } + let filtered = get_filtered_publish_configs(spacetime_config, &schema, args)?; + // If filtering resulted in no matches, use CLI args with empty config + if filtered.is_empty() { + ( + false, + vec![CommandConfig::new(&schema, std::collections::HashMap::new(), args)?], + ) + } else { + (true, filtered) + } } else { - build::exec_with_argstring(config.clone(), path_to_project, build_options).await? + ( + false, + vec![CommandConfig::new(&schema, std::collections::HashMap::new(), args)?], + ) }; - let program_bytes = fs::read(path_to_program)?; - let server_address = { - let url = Url::parse(&database_host)?; - url.host_str().unwrap_or("").to_string() - }; - if server_address != "localhost" && server_address != "127.0.0.1" { - println!("You are about to publish to a non-local server: {server_address}"); - if !y_or_n(force, "Are you sure you want to proceed?")? { - println!("Aborting"); - return Ok(()); - } - } + // Execute publish for each config + for command_config in publish_configs { + // Get values using command_config.get_one() which merges CLI + config + let server_opt = command_config.get_one::("server")?; + let server = server_opt.as_deref(); + let name_or_identity_opt = command_config.get_one::("database")?; + let name_or_identity = name_or_identity_opt.as_deref(); + let path_to_project = 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." + ) + })?, + }; - println!( - "Uploading to {} => {}", - server.unwrap_or(config.default_server_name().unwrap_or("")), - database_host - ); + if using_config { + println!( + "Publishing module {} to database '{}'", + path_to_project.display(), + name_or_identity.unwrap() + ); + } + let clear_database = args + .get_one::("clear-database") + .copied() + .unwrap_or(ClearMode::Never); + let force = args.get_flag("force"); + let anon_identity = command_config.get_one::("anon_identity")?.unwrap_or(false); + let wasm_file = command_config.get_one::("wasm_file")?; + let js_file = command_config.get_one::("js_file")?; + let database_host = config.get_host_url(server)?; + let build_options = command_config + .get_one::("build_options")? + .unwrap_or_else(String::new); + let num_replicas = command_config.get_one::("num_replicas")?; + let force_break_clients = command_config.get_one::("break_clients")?.unwrap_or(false); + let parent_opt = command_config.get_one::("parent")?; + let parent = parent_opt.as_deref(); + let org_opt = command_config.get_one::("organization")?; + let org = org_opt.as_deref(); + + // If the user didn't specify an identity and we didn't specify an anonymous identity, then + // we want to use the default identity + // TODO(jdetter): We should maybe have some sort of user prompt here for them to be able to + // easily create a new identity with an email + let auth_header = get_auth_header(&mut config, anon_identity, server, !force).await?; + + let (name_or_identity, parent) = validate_name_and_parent(name_or_identity, parent)?; + + if !path_to_project.exists() { + return Err(anyhow::anyhow!( + "Project path does not exist: {}", + path_to_project.display() + )); + } - let client = reqwest::Client::new(); - // If a name was given, ensure to percent-encode it. - // We also use PUT with a name or identity, and POST otherwise. - let mut builder = if let Some(name_or_identity) = name_or_identity { - let encode_set = const { &percent_encoding::NON_ALPHANUMERIC.remove(b'_').remove(b'-') }; - let domain = percent_encoding::percent_encode(name_or_identity.as_bytes(), encode_set); - let mut builder = client.put(format!("{database_host}/v1/database/{domain}")); - - // note that this only happens in the case where we've passed a `name_or_identity`, but that's required if we pass `--clear-database`. - if clear_database == ClearMode::Always { - builder = confirm_and_clear(name_or_identity, force, builder)?; + // Decide program file path and read program. + // Optionally build the program. + let (path_to_program, host_type) = if let Some(path) = wasm_file { + println!("(WASM) Skipping build. Instead we are publishing {}", path.display()); + (path.clone(), "Wasm") + } else if let Some(path) = js_file { + println!("(JS) Skipping build. Instead we are publishing {}", path.display()); + (path.clone(), "Js") } else { - builder = apply_pre_publish_if_needed( - builder, - &client, - &database_host, - name_or_identity, - &domain.to_string(), - host_type, - &program_bytes, - &auth_header, - clear_database, - force_break_clients, - force, - ) - .await?; + build::exec_with_argstring(config.clone(), &path_to_project, &build_options).await? + }; + let program_bytes = fs::read(path_to_program)?; + + let server_address = { + let url = Url::parse(&database_host)?; + url.host_str().unwrap_or("").to_string() + }; + if server_address != "localhost" && server_address != "127.0.0.1" { + println!("You are about to publish to a non-local server: {server_address}"); + if !y_or_n(force, "Are you sure you want to proceed?")? { + println!("Aborting"); + return Ok(()); + } } - builder - } else { - client.post(format!("{database_host}/v1/database")) - }; + println!( + "Uploading to {} => {}", + server.unwrap_or(config.default_server_name().unwrap_or("")), + database_host + ); - if let Some(n) = num_replicas { - eprintln!("WARNING: Use of unstable option `--num-replicas`.\n"); - builder = builder.query(&[("num_replicas", *n)]); - } - if let Some(parent) = parent { - builder = builder.query(&[("parent", parent)]); - } - 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}")); + + // 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 + } else { + client.post(format!("{database_host}/v1/database")) + }; - println!("Publishing module..."); + 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)]); + } - builder = add_auth_header_opt(builder, &auth_header); + println!("Publishing module..."); - // Set the host type. - builder = builder.query(&[("host_type", host_type)]); + builder = add_auth_header_opt(builder, &auth_header); - // 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!(""); - } + // Set the host type. + builder = builder.query(&[("host_type", host_type)]); - 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}"); - } + // 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", - )); } } @@ -540,4 +684,132 @@ mod tests { Ok(res) if res == (Some(&child), Some(&parent)) ); } + + #[test] + fn test_filter_by_database_from_cli() { + use crate::spacetime_config::*; + use std::collections::HashMap; + + let cmd = cli(); + let schema = build_publish_schema(&cmd).unwrap(); + + let mut config1 = HashMap::new(); + config1.insert("database".to_string(), serde_json::Value::String("db1".to_string())); + + let mut config2 = HashMap::new(); + config2.insert("database".to_string(), serde_json::Value::String("db2".to_string())); + + let mut parent_config = HashMap::new(); + parent_config.insert( + "database".to_string(), + serde_json::Value::String("parent-db".to_string()), + ); + + let spacetime_config = SpacetimeConfig { + publish: Some(PublishConfig { + additional_fields: parent_config, + children: Some(vec![ + PublishConfig { + additional_fields: config1, + children: None, + }, + PublishConfig { + additional_fields: config2, + children: None, + }, + ]), + }), + ..Default::default() + }; + + // Filter by db1 (should only match config1, not parent or config2) + let matches = cmd.clone().get_matches_from(vec!["publish", "db1"]); + let filtered = get_filtered_publish_configs(&spacetime_config, &schema, &matches).unwrap(); + + assert_eq!(filtered.len(), 1, "Should only match db1"); + assert_eq!( + filtered[0].get_one::("database").unwrap(), + Some("db1".to_string()) + ); + } + + #[test] + fn test_no_filter_when_database_not_from_cli() { + use crate::spacetime_config::*; + use std::collections::HashMap; + + let cmd = cli(); + let schema = build_publish_schema(&cmd).unwrap(); + + let mut config1 = HashMap::new(); + config1.insert("database".to_string(), serde_json::Value::String("db1".to_string())); + + let mut config2 = HashMap::new(); + config2.insert("database".to_string(), serde_json::Value::String("db2".to_string())); + + let mut parent_config = HashMap::new(); + parent_config.insert( + "database".to_string(), + serde_json::Value::String("parent-db".to_string()), + ); + + let spacetime_config = SpacetimeConfig { + publish: Some(PublishConfig { + additional_fields: parent_config, + children: Some(vec![ + PublishConfig { + additional_fields: config1, + children: None, + }, + PublishConfig { + additional_fields: config2, + children: None, + }, + ]), + }), + ..Default::default() + }; + + // No database provided via CLI + let matches = cmd.clone().get_matches_from(vec!["publish"]); + let filtered = get_filtered_publish_configs(&spacetime_config, &schema, &matches).unwrap(); + + // Should return all configs (parent + 2 children) + assert_eq!(filtered.len(), 3); + } + + #[test] + fn test_empty_result_when_filter_no_match() { + use crate::spacetime_config::*; + use std::collections::HashMap; + + let cmd = cli(); + let schema = build_publish_schema(&cmd).unwrap(); + + let mut config1 = HashMap::new(); + config1.insert("database".to_string(), serde_json::Value::String("db1".to_string())); + + let mut parent_config = HashMap::new(); + parent_config.insert( + "database".to_string(), + serde_json::Value::String("parent-db".to_string()), + ); + + let spacetime_config = SpacetimeConfig { + publish: Some(PublishConfig { + additional_fields: parent_config, + children: Some(vec![PublishConfig { + additional_fields: config1, + children: None, + }]), + }), + ..Default::default() + }; + + // Filter by non-existent database + let matches = cmd.clone().get_matches_from(vec!["publish", "nonexistent"]); + let filtered = get_filtered_publish_configs(&spacetime_config, &schema, &matches).unwrap(); + + assert_eq!(filtered.len(), 0); + } } diff --git a/crates/cli/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..145701af914 100644 --- a/crates/codegen/src/UnrealCPP-README.md +++ b/crates/codegen/src/UnrealCPP-README.md @@ -304,20 +304,20 @@ 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 --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 --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-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 ### Why Module Name is Required diff --git a/crates/smoketests/modules/add-remove-index-indexed/src/lib.rs b/crates/smoketests/modules/add-remove-index-indexed/src/lib.rs index 61ca3204d23..7eb9d0055e0 100644 --- a/crates/smoketests/modules/add-remove-index-indexed/src/lib.rs +++ b/crates/smoketests/modules/add-remove-index-indexed/src/lib.rs @@ -1,10 +1,16 @@ use spacetimedb::{ReducerContext, Table}; #[spacetimedb::table(name = t1)] -pub struct T1 { #[index(btree)] id: u64 } +pub struct T1 { + #[index(btree)] + id: u64, +} #[spacetimedb::table(name = t2)] -pub struct T2 { #[index(btree)] id: u64 } +pub struct T2 { + #[index(btree)] + id: u64, +} #[spacetimedb::reducer(init)] pub fn init(ctx: &ReducerContext) { diff --git a/crates/smoketests/modules/add-remove-index/src/lib.rs b/crates/smoketests/modules/add-remove-index/src/lib.rs index 9da55e6b50d..67dd2076691 100644 --- a/crates/smoketests/modules/add-remove-index/src/lib.rs +++ b/crates/smoketests/modules/add-remove-index/src/lib.rs @@ -1,10 +1,14 @@ use spacetimedb::{ReducerContext, Table}; #[spacetimedb::table(name = t1)] -pub struct T1 { id: u64 } +pub struct T1 { + id: u64, +} #[spacetimedb::table(name = t2)] -pub struct T2 { id: u64 } +pub struct T2 { + id: u64, +} #[spacetimedb::reducer(init)] pub fn init(ctx: &ReducerContext) { diff --git a/crates/smoketests/modules/call-reducer-procedure/src/lib.rs b/crates/smoketests/modules/call-reducer-procedure/src/lib.rs index da300398ff7..66f03290223 100644 --- a/crates/smoketests/modules/call-reducer-procedure/src/lib.rs +++ b/crates/smoketests/modules/call-reducer-procedure/src/lib.rs @@ -12,5 +12,7 @@ pub fn say_hello(_ctx: &ReducerContext) { #[spacetimedb::procedure] pub fn return_person(_ctx: &mut ProcedureContext) -> Person { - return Person { name: "World".to_owned() }; + return Person { + name: "World".to_owned(), + }; } diff --git a/crates/smoketests/modules/delete-database/src/lib.rs b/crates/smoketests/modules/delete-database/src/lib.rs index fbb51b1112d..a1a5d1714d3 100644 --- a/crates/smoketests/modules/delete-database/src/lib.rs +++ b/crates/smoketests/modules/delete-database/src/lib.rs @@ -1,10 +1,10 @@ -use spacetimedb::{ReducerContext, Table, duration}; +use spacetimedb::{duration, ReducerContext, Table}; #[spacetimedb::table(name = counter, public)] pub struct Counter { #[primary_key] id: u64, - val: u64 + val: u64, } #[spacetimedb::table(name = scheduled_counter, public, scheduled(inc, at = sched_at))] diff --git a/crates/smoketests/modules/filtering/src/lib.rs b/crates/smoketests/modules/filtering/src/lib.rs index 40597ee8b78..55e8d2d46cd 100644 --- a/crates/smoketests/modules/filtering/src/lib.rs +++ b/crates/smoketests/modules/filtering/src/lib.rs @@ -13,16 +13,24 @@ pub struct Person { #[spacetimedb::reducer] pub fn insert_person(ctx: &ReducerContext, id: i32, name: String, nick: String) { - ctx.db.person().insert(Person { id, name, nick} ); + ctx.db.person().insert(Person { id, name, nick }); } #[spacetimedb::reducer] pub fn insert_person_twice(ctx: &ReducerContext, id: i32, name: String, nick: String) { // We'd like to avoid an error due to a set-semantic error. let name2 = format!("{name}2"); - ctx.db.person().insert(Person { id, name, nick: nick.clone()} ); - match ctx.db.person().try_insert(Person { id, name: name2, nick: nick.clone()}) { - Ok(_) => {}, + ctx.db.person().insert(Person { + id, + name, + nick: nick.clone(), + }); + match ctx.db.person().try_insert(Person { + id, + name: name2, + nick: nick.clone(), + }) { + Ok(_) => {} Err(_) => { log::info!("UNIQUE CONSTRAINT VIOLATION ERROR: id = {}, nick = {}", id, nick) } @@ -85,7 +93,7 @@ pub struct NonuniquePerson { #[spacetimedb::reducer] pub fn insert_nonunique_person(ctx: &ReducerContext, id: i32, name: String, is_human: bool) { - ctx.db.nonunique_person().insert(NonuniquePerson { id, name, is_human } ); + ctx.db.nonunique_person().insert(NonuniquePerson { id, name, is_human }); } #[spacetimedb::reducer] @@ -158,7 +166,11 @@ struct IndexedPerson { #[spacetimedb::reducer] fn insert_indexed_person(ctx: &ReducerContext, id: i32, given_name: String, surname: String) { - ctx.db.indexed_person().insert(IndexedPerson { id, given_name, surname }); + ctx.db.indexed_person().insert(IndexedPerson { + id, + given_name, + surname, + }); } #[spacetimedb::reducer] @@ -169,7 +181,12 @@ fn delete_indexed_person(ctx: &ReducerContext, id: i32) { #[spacetimedb::reducer] fn find_indexed_people(ctx: &ReducerContext, surname: String) { for person in ctx.db.indexed_person().surname().filter(&surname) { - log::info!("INDEXED FOUND: id {}: {}, {}", person.id, person.surname, person.given_name); + log::info!( + "INDEXED FOUND: id {}: {}, {}", + person.id, + person.surname, + person.given_name + ); } } @@ -177,6 +194,11 @@ fn find_indexed_people(ctx: &ReducerContext, surname: String) { fn find_indexed_people_read_only(ctx: &ReducerContext, surname: String) { let ctx = ctx.as_read_only(); for person in ctx.db.indexed_person().surname().filter(&surname) { - log::info!("INDEXED FOUND: id {}: {}, {}", person.id, person.surname, person.given_name); + log::info!( + "INDEXED FOUND: id {}: {}, {}", + person.id, + person.surname, + person.given_name + ); } } diff --git a/crates/smoketests/modules/module-nested-op/src/lib.rs b/crates/smoketests/modules/module-nested-op/src/lib.rs index 888afb44d05..8b6ceba664d 100644 --- a/crates/smoketests/modules/module-nested-op/src/lib.rs +++ b/crates/smoketests/modules/module-nested-op/src/lib.rs @@ -15,7 +15,7 @@ pub struct Friends { #[spacetimedb::reducer] pub fn create_account(ctx: &ReducerContext, account_id: i32, name: String) { - ctx.db.account().insert(Account { id: account_id, name } ); + ctx.db.account().insert(Account { id: account_id, name }); } #[spacetimedb::reducer] @@ -23,7 +23,10 @@ pub fn add_friend(ctx: &ReducerContext, my_id: i32, their_id: i32) { // Make sure our friend exists for account in ctx.db.account().iter() { if account.id == their_id { - ctx.db.friends().insert(Friends { friend_1: my_id, friend_2: their_id }); + ctx.db.friends().insert(Friends { + friend_1: my_id, + friend_2: their_id, + }); return; } } diff --git a/crates/smoketests/modules/new-user-flow/src/lib.rs b/crates/smoketests/modules/new-user-flow/src/lib.rs index 44ec244e73f..36c6926b612 100644 --- a/crates/smoketests/modules/new-user-flow/src/lib.rs +++ b/crates/smoketests/modules/new-user-flow/src/lib.rs @@ -2,7 +2,7 @@ use spacetimedb::{log, ReducerContext, Table}; #[spacetimedb::table(name = person)] pub struct Person { - name: String + name: String, } #[spacetimedb::reducer] diff --git a/crates/smoketests/modules/pg-wire/src/lib.rs b/crates/smoketests/modules/pg-wire/src/lib.rs index 53729e3155c..4ee80507d25 100644 --- a/crates/smoketests/modules/pg-wire/src/lib.rs +++ b/crates/smoketests/modules/pg-wire/src/lib.rs @@ -1,5 +1,5 @@ use spacetimedb::sats::{i256, u256}; -use spacetimedb::{ConnectionId, Identity, ReducerContext, SpacetimeType, Table, Timestamp, TimeDuration, Uuid}; +use spacetimedb::{ConnectionId, Identity, ReducerContext, SpacetimeType, Table, TimeDuration, Timestamp, Uuid}; #[derive(Copy, Clone)] #[spacetimedb::table(name = t_ints, public)] @@ -50,7 +50,7 @@ pub struct TOthers { #[spacetimedb::table(name = t_others_tuple, public)] pub struct TOthersTuple { - tuple: TOthers + tuple: TOthers, } #[derive(SpacetimeType, Debug, Clone, Copy)] @@ -79,9 +79,9 @@ pub struct TEnum { #[spacetimedb::table(name = t_nested, public)] pub struct TNested { - en: TEnum, - se: TSimpleEnum, - ints: TInts, + en: TEnum, + se: TSimpleEnum, + ints: TInts, } #[derive(Clone)] @@ -127,7 +127,7 @@ pub fn test(ctx: &ReducerContext) { f32: 594806.58906, f64: -3454353.345389043278459, str: "This is spacetimedb".to_string(), - bytes: vec!(1, 2, 3, 4, 5, 6, 7), + bytes: vec![1, 2, 3, 4, 5, 6, 7], identity: Identity::ONE, connection_id: ConnectionId::ZERO, timestamp: Timestamp::UNIX_EPOCH, @@ -137,14 +137,29 @@ pub fn test(ctx: &ReducerContext) { ctx.db.t_others().insert(tuple.clone()); ctx.db.t_others_tuple().insert(TOthersTuple { tuple }); - ctx.db.t_simple_enum().insert(TSimpleEnum { id: 1, action: Action::Inactive }); - ctx.db.t_simple_enum().insert(TSimpleEnum { id: 2, action: Action::Active }); + ctx.db.t_simple_enum().insert(TSimpleEnum { + id: 1, + action: Action::Inactive, + }); + ctx.db.t_simple_enum().insert(TSimpleEnum { + id: 2, + action: Action::Active, + }); - ctx.db.t_enum().insert(TEnum { id: 1, color: Color::Gray(128) }); + ctx.db.t_enum().insert(TEnum { + id: 1, + color: Color::Gray(128), + }); ctx.db.t_nested().insert(TNested { - en: TEnum { id: 1, color: Color::Gray(128) }, - se: TSimpleEnum { id: 2, action: Action::Active }, + en: TEnum { + id: 1, + color: Color::Gray(128), + }, + se: TSimpleEnum { + id: 2, + action: Action::Active, + }, ints, }); diff --git a/crates/smoketests/modules/schedule-cancel/src/lib.rs b/crates/smoketests/modules/schedule-cancel/src/lib.rs index 2b1395fb2be..dfd0841981d 100644 --- a/crates/smoketests/modules/schedule-cancel/src/lib.rs +++ b/crates/smoketests/modules/schedule-cancel/src/lib.rs @@ -7,14 +7,17 @@ fn init(ctx: &ReducerContext) { scheduled_id: 0, scheduled_at: duration!(100ms).into(), }); - ctx.db.scheduled_reducer_args().scheduled_id().delete(&schedule.scheduled_id); + ctx.db + .scheduled_reducer_args() + .scheduled_id() + .delete(&schedule.scheduled_id); let schedule = ctx.db.scheduled_reducer_args().insert(ScheduledReducerArgs { - num: 2, - scheduled_id: 0, - scheduled_at: duration!(1000ms).into(), - }); - do_cancel(ctx, schedule.scheduled_id); + num: 2, + scheduled_id: 0, + scheduled_at: duration!(1000ms).into(), + }); + do_cancel(ctx, schedule.scheduled_id); } #[spacetimedb::table(name = scheduled_reducer_args, public, scheduled(reducer))] diff --git a/crates/smoketests/modules/schedule-subscribe/src/lib.rs b/crates/smoketests/modules/schedule-subscribe/src/lib.rs index d332979b0c7..c536c53838b 100644 --- a/crates/smoketests/modules/schedule-subscribe/src/lib.rs +++ b/crates/smoketests/modules/schedule-subscribe/src/lib.rs @@ -1,4 +1,4 @@ -use spacetimedb::{log, duration, ReducerContext, Table, Timestamp}; +use spacetimedb::{duration, log, ReducerContext, Table, Timestamp}; #[spacetimedb::table(name = scheduled_table, public, scheduled(my_reducer, at = sched_at))] pub struct ScheduledTable { @@ -11,15 +11,27 @@ pub struct ScheduledTable { #[spacetimedb::reducer] fn schedule_reducer(ctx: &ReducerContext) { - ctx.db.scheduled_table().insert(ScheduledTable { prev: Timestamp::from_micros_since_unix_epoch(0), scheduled_id: 2, sched_at: Timestamp::from_micros_since_unix_epoch(0).into(), }); + ctx.db.scheduled_table().insert(ScheduledTable { + prev: Timestamp::from_micros_since_unix_epoch(0), + scheduled_id: 2, + sched_at: Timestamp::from_micros_since_unix_epoch(0).into(), + }); } #[spacetimedb::reducer] fn schedule_repeated_reducer(ctx: &ReducerContext) { - ctx.db.scheduled_table().insert(ScheduledTable { prev: Timestamp::from_micros_since_unix_epoch(0), scheduled_id: 1, sched_at: duration!(100ms).into(), }); + ctx.db.scheduled_table().insert(ScheduledTable { + prev: Timestamp::from_micros_since_unix_epoch(0), + scheduled_id: 1, + sched_at: duration!(100ms).into(), + }); } #[spacetimedb::reducer] pub fn my_reducer(ctx: &ReducerContext, arg: ScheduledTable) { - log::info!("Invoked: ts={:?}, delta={:?}", ctx.timestamp, ctx.timestamp.duration_since(arg.prev)); + log::info!( + "Invoked: ts={:?}, delta={:?}", + ctx.timestamp, + ctx.timestamp.duration_since(arg.prev) + ); } diff --git a/crates/smoketests/modules/sql-format/src/lib.rs b/crates/smoketests/modules/sql-format/src/lib.rs index 46be87cda38..773b0f1548c 100644 --- a/crates/smoketests/modules/sql-format/src/lib.rs +++ b/crates/smoketests/modules/sql-format/src/lib.rs @@ -1,5 +1,5 @@ use spacetimedb::sats::{i256, u256}; -use spacetimedb::{ConnectionId, Identity, ReducerContext, Table, Timestamp, TimeDuration, SpacetimeType, Uuid}; +use spacetimedb::{ConnectionId, Identity, ReducerContext, SpacetimeType, Table, TimeDuration, Timestamp, Uuid}; #[derive(Copy, Clone)] #[spacetimedb::table(name = t_ints)] @@ -44,13 +44,13 @@ pub struct TOthers { identity: Identity, connection_id: ConnectionId, timestamp: Timestamp, - duration: TimeDuration, + duration: TimeDuration, uuid: Uuid, } #[spacetimedb::table(name = t_others_tuple)] pub struct TOthersTuple { - tuple: TOthers + tuple: TOthers, } #[derive(SpacetimeType, Debug, Clone, Copy)] @@ -101,7 +101,7 @@ pub fn test(ctx: &ReducerContext) { f32: 594806.58906, f64: -3454353.345389043278459, str: "This is spacetimedb".to_string(), - bytes: vec!(1, 2, 3, 4, 5, 6, 7), + bytes: vec![1, 2, 3, 4, 5, 6, 7], identity: Identity::ONE, connection_id: ConnectionId::ZERO, timestamp: Timestamp::UNIX_EPOCH, diff --git a/crates/smoketests/modules/upload-module-2/src/lib.rs b/crates/smoketests/modules/upload-module-2/src/lib.rs index ee897c9aad4..64e583f13ae 100644 --- a/crates/smoketests/modules/upload-module-2/src/lib.rs +++ b/crates/smoketests/modules/upload-module-2/src/lib.rs @@ -1,4 +1,4 @@ -use spacetimedb::{log, duration, ReducerContext, Table, Timestamp}; +use spacetimedb::{duration, log, ReducerContext, Table, Timestamp}; #[spacetimedb::table(name = scheduled_message, public, scheduled(my_repeating_reducer))] pub struct ScheduledMessage { @@ -20,5 +20,9 @@ fn init(ctx: &ReducerContext) { #[spacetimedb::reducer] pub fn my_repeating_reducer(ctx: &ReducerContext, arg: ScheduledMessage) { - log::info!("Invoked: ts={:?}, delta={:?}", ctx.timestamp, ctx.timestamp.duration_since(arg.prev)); + log::info!( + "Invoked: ts={:?}, delta={:?}", + ctx.timestamp, + ctx.timestamp.duration_since(arg.prev) + ); } diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index d87dfd127f6..9a6041c2837 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -766,7 +766,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 +842,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..3d91673c97a --- /dev/null +++ b/crates/smoketests/tests/cli/generate.rs @@ -0,0 +1,81 @@ +use predicates::prelude::*; +use spacetimedb_guard::ensure_binaries_built; +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 test-project/spacetimedb/) + let output = cli_cmd() + .args(["init", "--non-interactive", "--lang", "rust", "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().join("test-project"); + let module_dir = project_dir.join("spacetimedb"); + + // Create a config with a different module-path filter + let config_content = r#"{ + "generate": [ + { + "language": "typescript", + "out-dir": "./config-output", + "module-path": "config-module-path" + } + ] +}"#; + std::fs::write(module_dir.join("spacetime.json"), config_content).expect("failed to write config"); + + // Build the module first + let 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..412fc3ce304 100644 --- a/crates/smoketests/tests/cli/publish.rs +++ b/crates/smoketests/tests/cli/publish.rs @@ -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,45 @@ 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 test-project/spacetimedb/) + test.spacetime(&[ + "init", + "--non-interactive", + "--lang", + "rust", + temp_dir.path().join("test-project").to_str().unwrap(), + ]) + .unwrap(); + + let module_dir = temp_dir.path().join("test-project").join("spacetimedb"); + + // 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/csharp_module.rs b/crates/smoketests/tests/csharp_module.rs index 6ad79e001be..e71f6776843 100644 --- a/crates/smoketests/tests/csharp_module.rs +++ b/crates/smoketests/tests/csharp_module.rs @@ -48,7 +48,7 @@ fn test_build_csharp_module() { "init", "--non-interactive", "--lang=csharp", - "--project-path", + "--module-path", tmpdir.path().to_str().unwrap(), "csharp-project", ]) 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..f1190f6fb58 100644 --- a/crates/smoketests/tests/quickstart.rs +++ b/crates/smoketests/tests/quickstart.rs @@ -514,7 +514,7 @@ impl QuickstartTest { "--non-interactive", "--lang", self.config.lang, - "--project-path", + "--module-path", server_path.to_str().unwrap(), "spacetimedb-project", ])?; @@ -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..baf529d09ed 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).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 93d66da8f62..bd2860174e6 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: @@ -1673,7 +1673,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: @@ -2240,7 +2240,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 62342b03433..ff1fe0d819e 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,7 +646,7 @@ 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 ./ --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. 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..c2e0aef09b6 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 --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 8422bece4d3..57580726c83 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 @@ -79,7 +79,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..de98c767313 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 --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 --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..e497ec3fee4 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,7 +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 +* `-p`, `--module-path ` — The system path (absolute or relative) to the module project Default value: `.` * `-b`, `--bin-path ` — The system path (absolute or relative) to the compiled wasm binary we should publish, instead of building the project. @@ -233,7 +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` +* `--module-path ` — The path to the SpacetimeDB server module project relative to the project directory, defaults to `/spacetimedb` Default value: `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. @@ -247,6 +247,8 @@ Start development mode with auto-regenerate client module bindings, auto-rebuild Possible values: `always`, `on-conflict`, `never` * `-t`, `--template