diff --git a/crates/cli/src/spacetime_config.rs b/crates/cli/src/spacetime_config.rs index 29b45b876..eb5019abd 100644 --- a/crates/cli/src/spacetime_config.rs +++ b/crates/cli/src/spacetime_config.rs @@ -874,11 +874,42 @@ fn load_json_value(path: &Path) -> anyhow::Result> { Ok(Some(value)) } -/// Overlay `overlay` values onto `base` using top-level key replacement (shallow merge). +fn overlay_children_arrays(base_children: &mut [serde_json::Value], overlay_children: Vec) { + let merge_len = std::cmp::min(base_children.len(), overlay_children.len()); + for (idx, overlay_child) in overlay_children.into_iter().enumerate().take(merge_len) { + let base_child = &mut base_children[idx]; + match overlay_child { + serde_json::Value::Object(_) if base_child.is_object() => { + // Recursively apply overlay semantics to child objects. + overlay_json(base_child, overlay_child); + } + other => { + // For non-object child entries, replace value directly. + *base_child = other; + } + } + } +} + +/// Overlay `overlay` values onto `base`. +/// Most keys use top-level replacement. `children` is merged recursively by index, +/// up to the lower of base/overlay lengths. fn overlay_json(base: &mut serde_json::Value, overlay: serde_json::Value) { if let (Some(base_obj), Some(overlay_obj)) = (base.as_object_mut(), overlay.as_object()) { for (key, value) in overlay_obj { - base_obj.insert(key.clone(), value.clone()); + let value_owned = value.clone(); + if key == "children" { + match (base_obj.get_mut("children"), value_owned) { + (Some(serde_json::Value::Array(base_children)), serde_json::Value::Array(overlay_children)) => { + overlay_children_arrays(base_children, overlay_children); + } + (_, other) => { + base_obj.insert(key.clone(), other); + } + } + } else { + base_obj.insert(key.clone(), value_owned); + } } } } @@ -887,8 +918,8 @@ fn overlay_json(base: &mut serde_json::Value, overlay: serde_json::Value) { /// /// Loading order (each overlays the previous via top-level key replacement): /// 1. `spacetime.json` (required) -/// 2. `spacetime..json` (if env specified and file exists) -/// 3. `spacetime.local.json` (if exists) +/// 2. `spacetime.local.json` (if exists) +/// 3. `spacetime..json` (if env specified and file exists) /// 4. `spacetime..local.json` (if env specified and file exists) pub fn find_and_load_with_env(env: Option<&str>) -> anyhow::Result> { find_and_load_with_env_from(env, std::env::current_dir()?) @@ -908,6 +939,13 @@ pub fn find_and_load_with_env_from(env: Option<&str>, start_dir: PathBuf) -> any let mut loaded_files = vec![base_path]; let mut has_dev_file = false; + // Overlay local file + let local_path = config_dir.join("spacetime.local.json"); + if let Some(local_value) = load_json_value(&local_path)? { + overlay_json(&mut merged, local_value); + loaded_files.push(local_path); + } + // Overlay environment-specific file if let Some(env_name) = env { let env_path = config_dir.join(format!("spacetime.{env_name}.json")); @@ -920,13 +958,6 @@ pub fn find_and_load_with_env_from(env: Option<&str>, start_dir: PathBuf) -> any } } - // Overlay local file - let local_path = config_dir.join("spacetime.local.json"); - if let Some(local_value) = load_json_value(&local_path)? { - overlay_json(&mut merged, local_value); - loaded_files.push(local_path); - } - // Overlay environment-specific local file if let Some(env_name) = env { let env_local_path = config_dir.join(format!("spacetime.{env_name}.local.json")); @@ -958,6 +989,10 @@ pub fn setup_for_project( client_lang: Option<&str>, package_manager: Option, ) -> anyhow::Result> { + if project_path.join(CONFIG_FILENAME).exists() { + return Ok(None); + } + if let Some(lang) = client_lang { let config = SpacetimeConfig::for_client_lang(lang, package_manager); return Ok(Some(config.save_to_dir(project_path)?)); @@ -2589,9 +2624,160 @@ mod tests { ); } + #[test] + fn test_children_overlay_merges_by_index_with_lower_count() { + use std::fs; + use tempfile::TempDir; + + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + fs::write( + root.join("spacetime.json"), + r#"{ + "database": "root", + "children": [ + { "database": "db-a", "server": "base-a" }, + { "database": "db-b", "server": "base-b" } + ] + }"#, + ) + .unwrap(); + + fs::write( + root.join("spacetime.local.json"), + r#"{ + "children": [ + { "server": "local-a", "module-path": "./a" }, + { "server": "local-b", "module-path": "./b" }, + { "database": "db-extra", "server": "extra" } + ] + }"#, + ) + .unwrap(); + + let result = find_and_load_with_env_from(None, root.to_path_buf()).unwrap().unwrap(); + let children = result.config.children.as_ref().unwrap(); + assert_eq!(children.len(), 2, "child count should remain from base config"); + + assert_eq!( + children[0].additional_fields.get("database").and_then(|v| v.as_str()), + Some("db-a") + ); + assert_eq!( + children[0].additional_fields.get("server").and_then(|v| v.as_str()), + Some("local-a") + ); + assert_eq!( + children[0] + .additional_fields + .get("module-path") + .and_then(|v| v.as_str()), + Some("./a") + ); + + assert_eq!( + children[1].additional_fields.get("database").and_then(|v| v.as_str()), + Some("db-b") + ); + assert_eq!( + children[1].additional_fields.get("server").and_then(|v| v.as_str()), + Some("local-b") + ); + assert_eq!( + children[1] + .additional_fields + .get("module-path") + .and_then(|v| v.as_str()), + Some("./b") + ); + } + + #[test] + fn test_children_overlay_merges_recursively_for_nested_children() { + use std::fs; + use tempfile::TempDir; + + let temp = TempDir::new().unwrap(); + let root = temp.path(); + + fs::write( + root.join("spacetime.json"), + r#"{ + "database": "root", + "children": [ + { + "database": "parent-a", + "children": [ + { "database": "grand-a1", "server": "base-g1" }, + { "database": "grand-a2", "server": "base-g2" } + ] + } + ] + }"#, + ) + .unwrap(); + + fs::write( + root.join("spacetime.local.json"), + r#"{ + "children": [ + { + "children": [ + { "server": "local-g1", "module-path": "./nested" } + ] + } + ] + }"#, + ) + .unwrap(); + + let result = find_and_load_with_env_from(None, root.to_path_buf()).unwrap().unwrap(); + let children = result.config.children.as_ref().unwrap(); + let grandchildren = children[0].children.as_ref().unwrap(); + assert_eq!(grandchildren.len(), 2); + + assert_eq!( + grandchildren[0] + .additional_fields + .get("database") + .and_then(|v| v.as_str()), + Some("grand-a1") + ); + assert_eq!( + grandchildren[0] + .additional_fields + .get("server") + .and_then(|v| v.as_str()), + Some("local-g1") + ); + assert_eq!( + grandchildren[0] + .additional_fields + .get("module-path") + .and_then(|v| v.as_str()), + Some("./nested") + ); + + assert_eq!( + grandchildren[1] + .additional_fields + .get("database") + .and_then(|v| v.as_str()), + Some("grand-a2") + ); + assert_eq!( + grandchildren[1] + .additional_fields + .get("server") + .and_then(|v| v.as_str()), + Some("base-g2") + ); + } + #[test] fn test_multi_level_env_layering_staging() { - // Full overlay order: base → staging → local → staging.local + // Full overlay order: base → local → staging → staging.local use std::fs; use tempfile::TempDir; @@ -2605,14 +2791,14 @@ mod tests { ) .unwrap(); - // Staging env overlay + // Staging env overlay (applies after local) fs::write( root.join("spacetime.staging.json"), r#"{ "server": "staging-server", "database": "staging-db" }"#, ) .unwrap(); - // Local overlay (applies after env) + // Local overlay (applies before env) fs::write( root.join("spacetime.local.json"), r#"{ "database": "local-override-db" }"#, @@ -2630,7 +2816,7 @@ mod tests { .unwrap() .unwrap(); - // database: base-db → staging-db → local-override-db → staging-local-db + // database: base-db → local-override-db → staging-db → staging-local-db assert_eq!( result.config.additional_fields.get("database").and_then(|v| v.as_str()), Some("staging-local-db") @@ -2651,6 +2837,14 @@ mod tests { ); // 4 files loaded assert_eq!(result.loaded_files.len(), 4); + assert_eq!( + result.loaded_files[1].file_name().and_then(|s| s.to_str()), + Some("spacetime.local.json") + ); + assert_eq!( + result.loaded_files[2].file_name().and_then(|s| s.to_str()), + Some("spacetime.staging.json") + ); } #[test] diff --git a/crates/cli/src/subcommands/build.rs b/crates/cli/src/subcommands/build.rs index 11e977c52..d33fc1a9d 100644 --- a/crates/cli/src/subcommands/build.rs +++ b/crates/cli/src/subcommands/build.rs @@ -42,7 +42,7 @@ pub fn cli() -> clap::Command { } pub async fn exec(_config: Config, args: &ArgMatches) -> Result<(PathBuf, &'static str), anyhow::Error> { - let project_path = match args.get_one::("module_path").cloned() { + let module_path = match args.get_one::("module_path").cloned() { Some(path) => path, None => find_module_path(&std::env::current_dir()?).ok_or_else(|| { anyhow::anyhow!( @@ -59,30 +59,39 @@ pub async fn exec(_config: Config, args: &ArgMatches) -> Result<(PathBuf, &'stat Some(PathBuf::from(lint_dir)) }; let build_debug = args.get_flag("debug"); + let features = features.cloned(); + run_build(module_path, lint_dir, build_debug, features) +} + +pub fn run_build( + module_path: PathBuf, + lint_dir: Option, + build_debug: bool, + features: Option, +) -> Result<(PathBuf, &'static str), anyhow::Error> { // Create the project path, or make sure the target project path is empty. - if project_path.exists() { - if !project_path.is_dir() { + if module_path.exists() { + if !module_path.is_dir() { return Err(anyhow::anyhow!( "Fatal Error: path {} exists but is not a directory.", - project_path.display() + module_path.display() )); } } else { return Err(anyhow::anyhow!( "Fatal Error: path {} does not exist.", - project_path.display() + module_path.display() )); } - let result = crate::tasks::build(&project_path, lint_dir.as_deref(), build_debug, features)?; + let result = crate::tasks::build(&module_path, lint_dir.as_deref(), build_debug, features.as_ref())?; println!("Build finished successfully."); Ok(result) } pub async fn exec_with_argstring( - config: Config, project_path: &Path, arg_string: &str, ) -> Result<(PathBuf, &'static str), anyhow::Error> { @@ -90,5 +99,19 @@ pub async fn exec_with_argstring( // If we don't include this, the args will be misinterpreted (e.g. as commands). 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 + + let module_path = arg_matches + .get_one::("module_path") + .cloned() + .ok_or_else(|| anyhow::anyhow!("module_path is required"))?; + let features = arg_matches.get_one::("features").cloned(); + let lint_dir = arg_matches.get_one::("lint_dir").unwrap(); + let lint_dir = if lint_dir.is_empty() { + None + } else { + Some(PathBuf::from(lint_dir)) + }; + let build_debug = arg_matches.get_flag("debug"); + + run_build(module_path, lint_dir, build_debug, features) } diff --git a/crates/cli/src/subcommands/dev.rs b/crates/cli/src/subcommands/dev.rs index 783b47f8a..5b9f3524a 100644 --- a/crates/cli/src/subcommands/dev.rs +++ b/crates/cli/src/subcommands/dev.rs @@ -2,12 +2,12 @@ use crate::common_args::ClearMode; use crate::config::Config; use crate::generate::Language; use crate::spacetime_config::{ - detect_client_command, find_and_load_with_env_from, CommandConfig, CommandSchema, SpacetimeConfig, + detect_client_command, find_and_load_with_env_from, CommandConfig, CommandSchema, SpacetimeConfig, CONFIG_FILENAME, }; 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, - spacetime_reverse_dns, ResponseExt, + add_auth_header_opt, database_identity, get_auth_header, get_login_token_or_log_in, spacetime_reverse_dns, + ResponseExt, }; use crate::{common_args, generate}; use crate::{publish, tasks}; @@ -15,7 +15,7 @@ use anyhow::Context; use clap::parser::ValueSource; use clap::{Arg, ArgMatches, Command}; use colored::Colorize; -use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect}; +use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect, Input}; use futures::stream::{self, StreamExt}; use futures::{AsyncBufReadExt, TryStreamExt}; use indicatif::{ProgressBar, ProgressStyle}; @@ -189,20 +189,95 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E // Load spacetime.json config early so we can use it for determining project // directories - let loaded_config = if no_config { + let mut loaded_config = if no_config { None } else { find_and_load_with_env_from(Some(env), project_dir.clone()).with_context(|| "Failed to load spacetime.json")? }; + let has_any_config_files = loaded_config.is_some(); + + // Config exists, but default module dir is missing: recover by asking for module-path + // and persisting it on the root config. + if !no_config && has_any_config_files && (!spacetimedb_dir.exists() || !spacetimedb_dir.is_dir()) { + let merged_has_module_path = loaded_config + .as_ref() + .and_then(|lc| lc.config.additional_fields.get("module-path")) + .and_then(|v| v.as_str()) + .is_some(); + + if !merged_has_module_path && module_path_from_cli.is_none() { + let files = loaded_config + .as_ref() + .map(|lc| { + lc.loaded_files + .iter() + .map(|f| f.display().to_string()) + .collect::>() + .join(", ") + }) + .unwrap_or_else(|| "spacetime.json".to_string()); + println!("{} {}", "Found config files:".yellow().bold(), files.dimmed()); + println!( + "{}", + "Could not determine module path because no `module-path` was found and `./spacetimedb` does not exist." + .yellow() + ); + let should_provide = Confirm::new() + .with_prompt("Would you like to provide --module-path now?") + .default(true) + .interact()?; + if !should_provide { + anyhow::bail!("Cannot continue without a module path."); + } + + let config_dir = loaded_config + .as_ref() + .map(|lc| lc.config_dir.clone()) + .ok_or_else(|| anyhow::anyhow!("Missing loaded config directory"))?; + + let provided_module_path: String = Input::with_theme(&ColorfulTheme::default()) + .with_prompt("Module path") + .default("spacetimedb".to_string()) + .validate_with({ + let config_dir = config_dir.clone(); + move |input: &String| -> Result<(), String> { + let candidate = PathBuf::from(input); + let resolved = if candidate.is_absolute() { + candidate + } else { + config_dir.join(&candidate) + }; + if resolved.exists() { + Ok(()) + } else { + Err(format!( + "Path does not exist: {} (resolved to {})", + input, + resolved.display() + )) + } + } + }) + .interact_text()?; + + // Save to root `spacetime.json` (not env/local overlays), then reload merged config. + let saved_path = save_root_module_path_to_spacetime_json(&config_dir, &provided_module_path)?; + println!("{} Updated {}", "✓".green(), saved_path.display()); + + loaded_config = find_and_load_with_env_from(Some(env), project_dir.clone()) + .with_context(|| "Failed to reload spacetime.json after updating module-path")?; + } + } + let spacetime_config = loaded_config.as_ref().map(|lc| &lc.config); - let using_spacetime_config = spacetime_config.is_some(); // A config has publish targets if it has a "database" field or children let has_publish_targets_in_config = spacetime_config .map(|c| c.additional_fields.contains_key("database") || c.children.is_some()) .unwrap_or(false); - let generate_configs_from_file: Vec> = - spacetime_config.and_then(|c| c.generate.clone()).unwrap_or_default(); - let has_generate_targets_in_config = !generate_configs_from_file.is_empty(); + let has_generate_targets_in_config = spacetime_config + .and_then(|c| c.generate.as_ref()) + .map(|g| !g.is_empty()) + .unwrap_or(false); let module_path_from_cli_flag = args.value_source("module-path") == Some(ValueSource::CommandLine); let project_path_from_cli_flag = args.value_source("project-path") == Some(ValueSource::CommandLine); @@ -239,6 +314,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E } name.clone() }); + let database_name_from_cli_for_init = database_name_from_cli.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 @@ -272,7 +348,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E // 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()) { + if !has_any_config_files && (!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?") @@ -280,17 +356,14 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E .interact()?; if should_init { - let mut init_argv = vec!["init"]; - if resolved_server == "local" { - init_argv.push("--local"); - } - let template = args.get_one::("template"); - if let Some(template_str) = template { - init_argv.push("--template"); - init_argv.push(template_str); - } - let init_args = init::cli().get_matches_from(init_argv); - let created_project_path = init::exec(config.clone(), &init_args).await?; + let init_options = init::InitOptions { + local: resolved_server == "local", + template: args.get_one::("template").cloned(), + project_name_default: database_name_from_cli_for_init.clone(), + database_name_default: database_name_from_cli_for_init.clone(), + ..Default::default() + }; + let created_project_path = init::exec_with_options(&mut config, &init_options).await?; let canonical_created_path = created_project_path .canonicalize() @@ -320,12 +393,46 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E .get_one::("module_path") .context("failed to read module_path from config")? { - spacetimedb_dir = path; + spacetimedb_dir = if path.is_absolute() { + path + } else { + project_dir.join(path) + }; } } + // Refresh layered config after potential init/config creation so downstream behavior + // uses the latest spacetime.json + local/env overlays. + if !no_config { + loaded_config = find_and_load_with_env_from(Some(env), project_dir.clone()) + .with_context(|| "Failed to reload spacetime.json after initialization")?; + } + + let spacetime_config = loaded_config.as_ref().map(|lc| &lc.config); + let using_spacetime_config = spacetime_config.is_some(); + let generate_configs_from_file: Vec> = + spacetime_config.and_then(|c| c.generate.clone()).unwrap_or_default(); + + // Re-resolve publish targets now that config files may have been created by init. + if publish_configs.is_empty() { + publish_configs = determine_publish_configs( + database_name_from_cli_for_init.clone(), + spacetime_config, + &publish_cmd, + &publish_schema, + &publish_args, + resolved_server, + )?; + } + let use_local = resolved_server == "local"; + if !no_config { + if let Some(path) = create_default_spacetime_config_if_missing(&project_dir)? { + println!("{} Created {}", "✓".green(), path.display()); + } + } + // 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 @@ -366,6 +473,20 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E publish_configs = vec![CommandConfig::new(&publish_schema, config_map, &publish_args)?]; } + if !no_config { + let db_to_persist = database_name_from_cli_for_init.as_deref().or_else(|| { + publish_configs + .first() + .and_then(|cfg| cfg.get_config_value("database")) + .and_then(|v| v.as_str()) + }); + if let Some(db_name) = db_to_persist { + if let Some(path) = create_local_spacetime_config_if_missing(&project_dir, db_name)? { + println!("{} Created {}", "✓".green(), path.display()); + } + } + } + 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(|| { @@ -429,6 +550,9 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E } else if let Some(cmd) = args.get_one::("run") { // Explicit CLI flag takes priority Some(cmd.clone()) + } else if no_config { + // --no-config means "don't read or write spacetime config files". + detect_client_command(&project_dir).map(|(cmd, _)| cmd) } else if let Some(sc) = spacetime_config { // Reuse already-loaded config instead of loading again if let Some(ref lc) = loaded_config { @@ -487,7 +611,16 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E // Safety prompt: warn if publishing from spacetime.json (not a dev-specific config) if let Some(ref lc) = loaded_config { - if !lc.has_dev_file && !force { + // Treat local overrides as dev-safe to avoid warning when per-user config is present. + // TODO: Should this also accept other env local files (for example: spacetime.staging.local.json)? + let has_local_override = lc.loaded_files.iter().any(|p| { + p.file_name() + .and_then(|s| s.to_str()) + .map(|name| name == "spacetime.local.json" || name == "spacetime.dev.local.json") + .unwrap_or(false) + }); + + if !lc.has_dev_file && !has_local_override && !force { eprintln!( "{} Publishing from spacetime.json (not a dev-specific config).", "Warning:".yellow().bold() @@ -759,37 +892,13 @@ async fn generate_build_and_publish( ); } else { println!("{}", "Generating module bindings from spacetime.json...".cyan()); - let mut generate_argv = vec!["generate"]; - if yes { - generate_argv.push("--yes"); - } - let generate_args = generate::cli().get_matches_from(generate_argv); - generate::exec_ex( - config.clone(), - &generate_args, - crate::generate::extract_descriptions, - true, - None, - ) - .await?; + generate::exec_from_entries(generate_configs.to_vec(), crate::generate::extract_descriptions, yes).await?; } } else { - let module_language = detect_module_language(spacetimedb_dir)?; - let client_language = client_language.unwrap_or(match module_language { - crate::util::ModuleLanguage::Rust => &Language::Rust, - crate::util::ModuleLanguage::Csharp => &Language::Csharp, - crate::util::ModuleLanguage::Javascript => &Language::TypeScript, - crate::util::ModuleLanguage::Cpp => &Language::Rust, - }); - let client_language_str = match client_language { - Language::Rust => "rust", - Language::Csharp => "csharp", - Language::TypeScript => "typescript", - Language::UnrealCpp => "unrealcpp", - }; + let resolved_client_language = generate::resolve_language(spacetimedb_dir, client_language.copied())?; // For TypeScript client, update .env.local with first database name - if client_language == &Language::TypeScript { + if resolved_client_language == Language::TypeScript { let first_config = publish_configs.first().expect("publish_configs cannot be empty"); let first_db_name = first_config .get_config_value("database") @@ -810,31 +919,12 @@ async fn generate_build_and_publish( } println!("{}", "Generating module bindings...".cyan()); - let spacetimedb_dir_str = spacetimedb_dir.to_str().context("non-UTF-8 path in spacetimedb_dir")?; - let module_bindings_dir_str = module_bindings_dir - .to_str() - .context("non-UTF-8 path in module_bindings_dir")?; - let mut generate_argv = vec![ - "generate", - "--lang", - client_language_str, - "--module-path", - spacetimedb_dir_str, - "--out-dir", - module_bindings_dir_str, - ]; - if yes { - generate_argv.push("--yes"); - } - let generate_args = generate::cli().get_matches_from(generate_argv); - generate::exec_ex( - config.clone(), - &generate_args, - crate::generate::extract_descriptions, - true, - None, - ) - .await?; + let generate_entry = generate::build_generate_entry( + Some(spacetimedb_dir), + Some(resolved_client_language), + Some(module_bindings_dir), + ); + generate::exec_from_entries(vec![generate_entry], crate::generate::extract_descriptions, yes).await?; } if skip_publish { @@ -844,12 +934,6 @@ async fn generate_build_and_publish( println!("{}", "Publishing...".cyan()); - let clear_flag = match clear_database { - ClearMode::Always => "always", - ClearMode::Never => "never", - ClearMode::OnConflict => "on-conflict", - }; - // Loop through all publish configs for config_entry in publish_configs { let db_name = config_entry @@ -870,26 +954,21 @@ async fn generate_build_and_publish( println!("{} {}...", "Publishing to".cyan(), db_name.cyan().bold()); } - let mut publish_args = vec![ - "publish".to_string(), - db_name.to_string(), - "--module-path".to_string(), - module_path_str.to_string(), - "--yes".to_string(), - format!("--delete-data={}", clear_flag), - ]; + let mut publish_entry = HashMap::new(); + publish_entry.insert("database".to_string(), json!(db_name)); + publish_entry.insert("module-path".to_string(), json!(module_path_str)); // Forward per-target server from config if set, or CLI server override if let Some(srv) = server { - publish_args.extend_from_slice(&["--server".to_string(), srv.to_string()]); + publish_entry.insert("server".to_string(), json!(srv)); } else if let Some(srv) = config_entry.get_config_value("server").and_then(|v| v.as_str()) { - publish_args.extend_from_slice(&["--server".to_string(), srv.to_string()]); + publish_entry.insert("server".to_string(), json!(srv)); } // Forward per-target build options if set if let Some(build_opts) = config_entry.get_config_value("build_options").and_then(|v| v.as_str()) { if !build_opts.is_empty() { - publish_args.extend_from_slice(&["--build-options".to_string(), build_opts.to_string()]); + publish_entry.insert("build-options".to_string(), json!(build_opts)); } } @@ -899,15 +978,10 @@ async fn generate_build_and_publish( .and_then(|v| v.as_bool()) .unwrap_or(false) { - publish_args.push("--break-clients".to_string()); + publish_entry.insert("break-clients".to_string(), json!(true)); } - let publish_cmd = publish::cli(); - let publish_matches = publish_cmd - .try_get_matches_from(publish_args) - .context("Failed to create publish arguments")?; - - publish::exec_with_options(config.clone(), &publish_matches, true, None).await?; + publish::exec_from_entry(config.clone(), publish_entry, clear_database, yes).await?; } println!("{}", "Published successfully!".green().bold()); @@ -1232,12 +1306,22 @@ fn extract_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 + // Update provided config, config on disk, 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 if project_dir.join(CONFIG_FILENAME).exists() { + match SpacetimeConfig::load(&project_dir.join(CONFIG_FILENAME)) { + Ok(mut config) => { + config.dev = Some(crate::spacetime_config::DevConfig { + run: Some(detected_cmd.clone()), + }); + config + } + Err(_) => SpacetimeConfig::with_run_command(&detected_cmd), + } } else { SpacetimeConfig::with_run_command(&detected_cmd) }; @@ -1255,6 +1339,75 @@ fn detect_and_save_client_command(project_dir: &Path, existing_config: Option anyhow::Result> { + let config_path = project_dir.join(CONFIG_FILENAME); + if config_path.exists() { + return Ok(None); + } + + let mut config = SpacetimeConfig::default(); + config + .additional_fields + .insert("server".to_string(), json!("maincloud")); + + if project_dir.join("spacetimedb").is_dir() { + config + .additional_fields + .insert("module-path".to_string(), json!("./spacetimedb")); + } + + Ok(Some(config.save_to_dir(project_dir)?)) +} + +fn create_local_spacetime_config_if_missing( + project_dir: &Path, + database_name: &str, +) -> anyhow::Result> { + let main_config_path = project_dir.join(CONFIG_FILENAME); + if !main_config_path.exists() { + return Ok(None); + } + + let local_config_path = project_dir.join("spacetime.local.json"); + if local_config_path.exists() { + let mut local_config = SpacetimeConfig::load(&local_config_path) + .with_context(|| format!("Failed to load {}", local_config_path.display()))?; + if local_config.additional_fields.contains_key("database") { + return Ok(None); + } + local_config + .additional_fields + .insert("database".to_string(), json!(database_name)); + local_config.save(&local_config_path)?; + return Ok(Some(local_config_path)); + } + + let mut local_config = SpacetimeConfig::default(); + local_config + .additional_fields + .insert("database".to_string(), json!(database_name)); + local_config.save(&local_config_path)?; + + Ok(Some(local_config_path)) +} + +// Persist the root module-path so subsequent layered loads resolve module location +// without interactive prompts. +fn save_root_module_path_to_spacetime_json(config_dir: &Path, module_path: &str) -> anyhow::Result { + let config_path = config_dir.join(CONFIG_FILENAME); + let mut config = SpacetimeConfig::load(&config_path).with_context(|| { + format!( + "Failed to load root config for writing module-path: {}", + config_path.display() + ) + })?; + config + .additional_fields + .insert("module-path".to_string(), json!(module_path)); + config.save(&config_path)?; + Ok(config_path) +} + /// 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. @@ -1514,4 +1667,138 @@ mod tests { assert_eq!(matches.get_one::("env").map(|s| s.as_str()), Some("staging")); } + + #[test] + fn test_create_default_spacetime_config_if_missing_creates_expected_config() { + let temp = TempDir::new().unwrap(); + let project_path = temp.path(); + std::fs::create_dir_all(project_path.join("spacetimedb")).unwrap(); + + let created = create_default_spacetime_config_if_missing(project_path) + .unwrap() + .expect("expected config to be created"); + assert_eq!(created, project_path.join("spacetime.json")); + + let content = std::fs::read_to_string(&created).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert!(parsed.get("database").is_none()); + assert_eq!(parsed.get("server").and_then(|v| v.as_str()), Some("maincloud")); + assert_eq!( + parsed.get("module-path").and_then(|v| v.as_str()), + Some("./spacetimedb") + ); + } + + #[test] + fn test_create_local_spacetime_config_if_missing_creates_database_override() { + let temp = TempDir::new().unwrap(); + let project_path = temp.path(); + + std::fs::write(project_path.join("spacetime.json"), "{}").unwrap(); + + let created = create_local_spacetime_config_if_missing(project_path, "my-app-123456") + .unwrap() + .expect("expected local config to be created"); + assert_eq!(created, project_path.join("spacetime.local.json")); + + let content = std::fs::read_to_string(&created).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + let db = parsed + .get("database") + .and_then(|v| v.as_str()) + .expect("database should be present"); + + assert_eq!(db, "my-app-123456"); + + let obj = parsed.as_object().expect("local config should be a JSON object"); + assert_eq!(obj.len(), 1, "local config should only contain database"); + } + + #[test] + fn test_create_local_spacetime_config_if_missing_upserts_missing_database() { + let temp = TempDir::new().unwrap(); + let project_path = temp.path(); + + std::fs::write(project_path.join("spacetime.json"), "{}").unwrap(); + std::fs::write(project_path.join("spacetime.local.json"), r#"{ "server": "local" }"#).unwrap(); + + let updated = create_local_spacetime_config_if_missing(project_path, "my-cli-db") + .unwrap() + .expect("expected local config to be updated"); + assert_eq!(updated, project_path.join("spacetime.local.json")); + + let content = std::fs::read_to_string(project_path.join("spacetime.local.json")).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(parsed.get("server").and_then(|v| v.as_str()), Some("local")); + assert_eq!(parsed.get("database").and_then(|v| v.as_str()), Some("my-cli-db")); + } + + #[test] + fn test_detect_and_save_merges_into_existing_file_when_no_existing_config_passed() { + let temp = TempDir::new().unwrap(); + let config_path = temp.path().join("spacetime.json"); + fs::write( + &config_path, + r#"{ + "server": "maincloud", + "module-path": "./spacetimedb" + }"#, + ) + .unwrap(); + fs::write( + temp.path().join("package.json"), + r#"{ + "name": "test", + "scripts": { + "dev": "vite" + } + }"#, + ) + .unwrap(); + + let detected = detect_and_save_client_command(temp.path(), None); + assert!(detected.is_some()); + + let reloaded = SpacetimeConfig::load(&config_path).unwrap(); + assert_eq!( + reloaded.additional_fields.get("server").and_then(|v| v.as_str()), + Some("maincloud") + ); + assert_eq!( + reloaded.additional_fields.get("module-path").and_then(|v| v.as_str()), + Some("./spacetimedb") + ); + assert_eq!( + reloaded.dev.as_ref().and_then(|d| d.run.as_deref()), + Some("npm run dev") + ); + } + + #[test] + fn test_save_root_module_path_to_spacetime_json_updates_root_config() { + let temp = TempDir::new().unwrap(); + let config_path = temp.path().join("spacetime.json"); + fs::write( + &config_path, + r#"{ + "server": "maincloud", + "children": [{ "database": "child-db" }] + }"#, + ) + .unwrap(); + + let saved = save_root_module_path_to_spacetime_json(temp.path(), "./custom-module").unwrap(); + assert_eq!(saved, config_path); + + let reloaded = SpacetimeConfig::load(&config_path).unwrap(); + assert_eq!( + reloaded.additional_fields.get("module-path").and_then(|v| v.as_str()), + Some("./custom-module") + ); + assert_eq!( + reloaded.additional_fields.get("server").and_then(|v| v.as_str()), + Some("maincloud") + ); + assert!(reloaded.children.is_some()); + } } diff --git a/crates/cli/src/subcommands/generate.rs b/crates/cli/src/subcommands/generate.rs index f7ef86dc1..3f1817063 100644 --- a/crates/cli/src/subcommands/generate.rs +++ b/crates/cli/src/subcommands/generate.rs @@ -21,7 +21,7 @@ use crate::spacetime_config::{ }; use crate::tasks::csharp::dotnet_format; use crate::tasks::rust::rustfmt; -use crate::util::{find_module_path, resolve_sibling_binary, y_or_n}; +use crate::util::{detect_module_language, resolve_sibling_binary, y_or_n, ModuleLanguage}; use crate::Config; use crate::{build, common_args}; use clap::builder::PossibleValue; @@ -277,9 +277,303 @@ pub async fn exec(config: Config, args: &clap::ArgMatches) -> anyhow::Result<()> exec_ex(config, args, extract_descriptions, false, None).await } +#[derive(Debug, Clone)] +pub struct GenerateRunConfig { + pub project_path: PathBuf, + pub wasm_file: Option, + pub js_file: Option, + pub lang: Language, + pub namespace: String, + pub module_name: Option, + pub build_options: String, + pub out_dir: PathBuf, + pub include_private: bool, +} + +fn prepare_generate_run_configs<'a>( + generate_configs: Vec>, + _using_config: bool, +) -> anyhow::Result> { + let mut runs = Vec::with_capacity(generate_configs.len()); + + for command_config in generate_configs { + let project_path = command_config + .get_one::("module_path")? + .unwrap_or_else(|| PathBuf::from("spacetimedb")); + + let wasm_file = command_config.get_one::("wasm_file")?; + let js_file = command_config.get_one::("js_file")?; + let requested_lang = command_config.get_one::("language")?; + let namespace = command_config + .get_one::("namespace")? + .unwrap_or_else(|| "SpacetimeDB.Types".to_string()); + let module_name = command_config.get_one::("unreal_module_name")?; + let build_options = command_config + .get_one::("build_options")? + .unwrap_or_else(String::new); + + // Validate Unreal-specific args first to preserve focused errors for this mode. + if requested_lang == Some(Language::UnrealCpp) { + if command_config.get_one::("uproject_dir")?.is_none() { + return Err(anyhow::anyhow!("--uproject-dir is required for --lang unrealcpp")); + } + if module_name.is_none() { + return Err(anyhow::anyhow!("--unreal-module-name is required for --lang unrealcpp")); + } + } + + // Validate module source path for source-based generation. + if wasm_file.is_none() && js_file.is_none() && !project_path.is_dir() { + anyhow::bail!( + "Could not find module source at '{}'. \ + If this is not correct, pass --module-path or add module-path in spacetime.json. \ + You can also pass --bin-path/--js-path to generate without building from source.", + project_path.display() + ); + } + + let lang = match requested_lang { + Some(lang) => lang, + None => { + let detected = detect_default_language(&project_path)?; + println!( + "Detected client language '{}' from module '{}'. If this is not correct, pass --lang or add a generate target in spacetime.json.", + language_cli_name(detected), + project_path.display() + ); + detected + } + }; + + let out_dir = command_config + .get_one::("out_dir")? + .or_else(|| command_config.get_one::("uproject_dir").ok().flatten()) + .or_else(|| default_out_dir_for_language(lang)) + .ok_or_else(|| anyhow::anyhow!("Either --out-dir or --uproject-dir is required"))?; + + let include_private = command_config.get_one::("include_private")?.unwrap_or(false); + + runs.push(GenerateRunConfig { + project_path, + wasm_file, + js_file, + lang, + namespace, + module_name, + build_options, + out_dir, + include_private, + }); + } + + Ok(runs) +} + +fn detect_default_language(module_path: &Path) -> anyhow::Result { + let module_lang = detect_module_language(module_path).map_err(|err| { + anyhow::anyhow!( + "Could not auto-detect client language from module '{}': {}. \ + If this is not correct, pass --lang or add a generate target in spacetime.json.", + module_path.display(), + err + ) + })?; + + Ok(match module_lang { + ModuleLanguage::Rust => Language::Rust, + ModuleLanguage::Csharp => Language::Csharp, + ModuleLanguage::Javascript => Language::TypeScript, + // For C++ modules we generate Rust client bindings by default. + ModuleLanguage::Cpp => Language::Rust, + }) +} + +fn language_cli_name(lang: Language) -> &'static str { + match lang { + Language::Rust => "rust", + Language::Csharp => "csharp", + Language::TypeScript => "typescript", + Language::UnrealCpp => "unrealcpp", + } +} + +pub fn default_out_dir_for_language(lang: Language) -> Option { + match lang { + Language::Rust | Language::TypeScript => Some(PathBuf::from("src/module_bindings")), + Language::Csharp => Some(PathBuf::from("module_bindings")), + Language::UnrealCpp => None, + } +} + +pub fn resolve_language(module_path: &Path, requested: Option) -> anyhow::Result { + match requested { + Some(lang) => Ok(lang), + None => detect_default_language(module_path), + } +} + +pub fn build_generate_entry( + module_path: Option<&Path>, + language: Option, + out_dir: Option<&Path>, +) -> HashMap { + let mut entry = HashMap::new(); + if let Some(lang) = language { + entry.insert("language".to_string(), serde_json::json!(language_cli_name(lang))); + } + if let Some(path) = module_path { + entry.insert("module-path".to_string(), serde_json::json!(path)); + } + if let Some(path) = out_dir { + entry.insert("out-dir".to_string(), serde_json::json!(path)); + } + entry +} + +pub async fn run_prepared_generate_configs( + run_configs: Vec, + extract_descriptions: ExtractDescriptions, + json_module: Option>, + force: bool, + namespace_from_cli: bool, +) -> anyhow::Result<()> { + for run in run_configs { + println!( + "Generating {} module bindings for module {}", + run.lang.display_name(), + run.project_path.display() + ); + + if namespace_from_cli && run.lang != Language::Csharp { + return Err(anyhow::anyhow!("--namespace is only supported with --lang csharp")); + } + + let module: ModuleDef = if let Some(paths) = &json_module { + let DeserializeWrapper::(module) = if let Some(path) = paths.first() { + serde_json::from_slice(&fs::read(path)?)? + } else { + serde_json::from_reader(std::io::stdin().lock())? + }; + module.try_into()? + } else { + let path = if let Some(path) = &run.wasm_file { + println!("Skipping build. Instead we are inspecting {}", path.display()); + path.clone() + } else if let Some(path) = &run.js_file { + println!("Skipping build. Instead we are inspecting {}", path.display()); + path.clone() + } else { + let (path, _) = build::exec_with_argstring(&run.project_path, &run.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")? + }; + + fs::create_dir_all(&run.out_dir)?; + + let mut paths = BTreeSet::new(); + let private_tables = private_table_names(&module); + if !private_tables.is_empty() && !run.include_private { + println!("Skipping private tables during codegen: {}.", private_tables.join(", ")); + } + let mut options = CodegenOptions::default(); + if run.include_private { + options.visibility = CodegenVisibility::IncludePrivate; + } + + let csharp_lang; + let unreal_cpp_lang; + let gen_lang = match run.lang { + Language::Csharp => { + csharp_lang = Csharp { + namespace: &run.namespace, + }; + &csharp_lang as &dyn Lang + } + Language::UnrealCpp => { + unreal_cpp_lang = UnrealCpp { + module_name: run.module_name.as_ref().unwrap(), + uproject_dir: &run.out_dir, + }; + &unreal_cpp_lang as &dyn Lang + } + Language::Rust => &Rust, + Language::TypeScript => &TypeScript, + }; + + for OutputFile { filename, code } in generate(&module, gen_lang, &options) { + let fname = Path::new(&filename); + if let Some(parent) = fname.parent().filter(|p| !p.as_os_str().is_empty()) { + println!("Creating directory {}", run.out_dir.join(parent).display()); + fs::create_dir_all(run.out_dir.join(parent))?; + } + let path = run.out_dir.join(fname); + if !path.exists() || fs::read_to_string(&path)? != code { + println!("Writing file {}", path.display()); + fs::write(&path, code)?; + } + paths.insert(path); + } + + let cleanup_root = match run.lang { + Language::UnrealCpp => run.out_dir.join("Source").join(run.module_name.as_ref().unwrap()), + _ => run.out_dir.clone(), + }; + + 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?; + if !entry.file_type().is_file() { + return Ok(None); + } + let path = entry.into_path(); + if paths.contains(&path) { + return Ok(None); + } + let mut file = fs::File::open(&path)?; + Ok(match file.read_exact(&mut auto_generated_buf) { + Ok(()) => (auto_generated_buf == AUTO_GENERATED_PREFIX.as_bytes()).then_some(path), + Err(err) if err.kind() == std::io::ErrorKind::UnexpectedEof => None, + Err(err) => return Err(err.into()), + }) + }) + .filter_map(Result::transpose) + .collect::>>()?; + + if !files_to_delete.is_empty() { + println!("The following files were not generated by this command and will be deleted:"); + for path in &files_to_delete { + println!(" {}", path.to_str().unwrap()); + } + + if y_or_n(force, "Are you sure you want to delete these files?")? { + for path in files_to_delete { + fs::remove_file(path)?; + } + println!("Files deleted successfully."); + } else { + println!("Files not deleted."); + } + } + + if let Err(err) = run.lang.format_files(&run.out_dir, paths) { + eprintln!("Could not format generated files: {err}"); + } + + println!("Generate finished successfully."); + } + + Ok(()) +} + /// Like `exec`, but lets you specify a custom a function to extract a schema from a file. pub async fn exec_ex( - config: Config, + _config: Config, args: &clap::ArgMatches, extract_descriptions: ExtractDescriptions, quiet_config: bool, @@ -311,220 +605,53 @@ pub async fn exec_ex( let (using_config, generate_configs) = if let Some(loaded) = loaded_config_ref { let filtered = get_filtered_generate_configs(&loaded.config, &cmd, &schema, args)?; if filtered.is_empty() { - anyhow::bail!( - "No matching generate target found in spacetime.json for the provided arguments. \ - Use --no-config to ignore the config file." - ); + (false, vec![CommandConfig::new(&schema, HashMap::new(), args)?]) + } else { + (true, filtered) } - (true, filtered) } else { (false, vec![CommandConfig::new(&schema, HashMap::new(), args)?]) }; - // 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 run_configs = prepare_generate_run_configs(generate_configs, using_config)?; + let json_module = args + .get_many::("json_module") + .map(|vals| vals.cloned().collect::>()); + let force = args.get_flag("force"); + let namespace_from_cli = args.value_source("namespace") == Some(ValueSource::CommandLine); - println!( - "Generating {} module bindings for module {}", - lang.display_name(), - project_path.display() - ); + run_prepared_generate_configs( + run_configs, + extract_descriptions, + json_module, + force, + namespace_from_cli, + ) + .await +} - let namespace = command_config - .get_one::("namespace")? - .unwrap_or_else(|| "SpacetimeDB.Types".to_string()); - let module_name = command_config.get_one::("unreal_module_name")?; - let force = args.get_flag("force"); - let build_options = command_config - .get_one::("build_options")? - .unwrap_or_else(String::new); +/// Execute generate from explicit entries without parsing generate CLI arguments +/// or loading any config files from disk. +pub async fn exec_from_entries( + entries: Vec>, + extract_descriptions: ExtractDescriptions, + force: bool, +) -> anyhow::Result<()> { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd)?; + let empty_matches = cmd.get_matches_from(vec!["generate"]); - // Validate namespace is only used with csharp - if args.value_source("namespace") == Some(ValueSource::CommandLine) && lang != Language::Csharp { - return Err(anyhow::anyhow!("--namespace is only supported with --lang csharp")); - } + let generate_configs: Vec = entries + .into_iter() + .map(|entry| { + let command_config = CommandConfig::new(&schema, entry, &empty_matches)?; + command_config.validate()?; + Ok(command_config) + }) + .collect::, anyhow::Error>>()?; - // Get output directory (either out_dir or uproject_dir) - let out_dir = command_config - .get_one::("out_dir")? - .or_else(|| command_config.get_one::("uproject_dir").ok().flatten()) - .ok_or_else(|| anyhow::anyhow!("Either --out-dir or --uproject-dir is required"))?; - - // Validate language-specific requirements - match lang { - Language::Rust | Language::Csharp | Language::TypeScript => { - // These languages require out_dir (not uproject_dir) - if command_config.get_one::("out_dir")?.is_none() { - return Err(anyhow::anyhow!( - "--out-dir is required for --lang {}", - match lang { - Language::Rust => "rust", - Language::Csharp => "csharp", - Language::TypeScript => "typescript", - _ => unreachable!(), - } - )); - } - } - Language::UnrealCpp => { - // UnrealCpp requires uproject_dir and module_name - if command_config.get_one::("uproject_dir")?.is_none() { - return Err(anyhow::anyhow!("--uproject-dir is required for --lang unrealcpp")); - } - if module_name.is_none() { - return Err(anyhow::anyhow!("--unreal-module-name is required for --lang unrealcpp")); - } - } - } - - let module: ModuleDef = if let Some(mut json_module) = json_module { - let DeserializeWrapper::(module) = if let Some(path) = json_module.next() { - serde_json::from_slice(&fs::read(path)?)? - } else { - serde_json::from_reader(std::io::stdin().lock())? - }; - module.try_into()? - } else { - let path = if let Some(path) = wasm_file { - println!("Skipping build. Instead we are inspecting {}", path.display()); - path.clone() - } else if let Some(path) = js_file { - println!("Skipping build. Instead we are inspecting {}", path.display()); - path.clone() - } else { - let (path, _) = build::exec_with_argstring(config.clone(), &project_path, &build_options).await?; - path - }; - let spinner = indicatif::ProgressBar::new_spinner(); - spinner.enable_steady_tick(std::time::Duration::from_millis(60)); - spinner.set_message(format!("Extracting schema from {}...", path.display())); - extract_descriptions(&path).context("could not extract schema")? - }; - - fs::create_dir_all(&out_dir)?; - - let mut paths = BTreeSet::new(); - - let include_private = command_config.get_one::("include_private")?.unwrap_or(false); - let private_tables = private_table_names(&module); - if !private_tables.is_empty() && !include_private { - println!("Skipping private tables during codegen: {}.", private_tables.join(", ")); - } - let mut options = CodegenOptions::default(); - if include_private { - options.visibility = CodegenVisibility::IncludePrivate; - } - - 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, - }; - - 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 = out_dir.join(fname); - if !path.exists() || fs::read_to_string(&path)? != code { - println!("Writing file {}", path.display()); - fs::write(&path, code)?; - } - 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::>>()?; - - if !files_to_delete.is_empty() { - println!("The following files were not generated by this command and will be deleted:"); - for path in &files_to_delete { - println!(" {}", path.to_str().unwrap()); - } - - if y_or_n(force, "Are you sure you want to delete these files?")? { - for path in files_to_delete { - fs::remove_file(path)?; - } - println!("Files deleted successfully."); - } else { - println!("Files not deleted."); - } - } - - 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."); - } - - Ok(()) + let run_configs = prepare_generate_run_configs(generate_configs, true)?; + run_prepared_generate_configs(run_configs, extract_descriptions, None, force, false).await } #[derive(Debug, Clone, Copy, PartialEq, serde::Deserialize)] @@ -801,25 +928,153 @@ mod tests { // 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; - + #[test] + fn test_rust_defaults_out_dir() { let cmd = cli(); - let config = Config::new_with_localhost(CliTomlPath::from_path_unchecked("/tmp/test-config.toml")); - - // Missing --out-dir for rust + let schema = build_generate_config_schema(&cmd).unwrap(); let matches = cmd.clone().get_matches_from(vec!["generate", "--lang", "rust"]); - let result = exec(config, &matches).await; - assert!(result.is_err()); - let err_msg = result.unwrap_err().to_string(); - assert!( - err_msg.contains("--out-dir") || err_msg.contains("--uproject-dir"), - "Expected error about missing output directory, got: {err_msg}" + let temp = tempfile::TempDir::new().unwrap(); + let module_dir = temp.path().join("spacetimedb"); + std::fs::create_dir_all(&module_dir).unwrap(); + std::fs::write( + module_dir.join("Cargo.toml"), + "[package]\nname = \"m\"\nversion = \"0.1.0\"\n", + ) + .unwrap(); + + let mut cfg = HashMap::new(); + cfg.insert( + "module-path".to_string(), + serde_json::Value::String(module_dir.display().to_string()), ); + + let command_config = CommandConfig::new(&schema, cfg, &matches).unwrap(); + let runs = prepare_generate_run_configs(vec![command_config], false).unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].out_dir, PathBuf::from("src/module_bindings")); + } + + #[test] + fn test_module_path_defaults_to_spacetimedb() { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + let matches = cmd + .clone() + .get_matches_from(vec!["generate", "--lang", "rust", "--bin-path", "dummy.wasm"]); + let command_config = CommandConfig::new(&schema, HashMap::new(), &matches).unwrap(); + let runs = prepare_generate_run_configs(vec![command_config], false).unwrap(); + assert_eq!(runs[0].project_path, PathBuf::from("spacetimedb")); + } + + #[test] + fn test_typescript_defaults_out_dir() { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + let matches = cmd.clone().get_matches_from(vec!["generate", "--lang", "typescript"]); + let temp = tempfile::TempDir::new().unwrap(); + let module_dir = temp.path().join("spacetimedb"); + std::fs::create_dir_all(&module_dir).unwrap(); + let mut cfg = HashMap::new(); + cfg.insert( + "module-path".to_string(), + serde_json::Value::String(module_dir.display().to_string()), + ); + let command_config = CommandConfig::new(&schema, cfg, &matches).unwrap(); + let runs = prepare_generate_run_configs(vec![command_config], false).unwrap(); + assert_eq!(runs[0].out_dir, PathBuf::from("src/module_bindings")); + } + + #[test] + fn test_csharp_defaults_out_dir() { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + let matches = cmd.clone().get_matches_from(vec!["generate", "--lang", "csharp"]); + let temp = tempfile::TempDir::new().unwrap(); + let module_dir = temp.path().join("spacetimedb"); + std::fs::create_dir_all(&module_dir).unwrap(); + let mut cfg = HashMap::new(); + cfg.insert( + "module-path".to_string(), + serde_json::Value::String(module_dir.display().to_string()), + ); + let command_config = CommandConfig::new(&schema, cfg, &matches).unwrap(); + let runs = prepare_generate_run_configs(vec![command_config], false).unwrap(); + assert_eq!(runs[0].out_dir, PathBuf::from("module_bindings")); + } + + #[test] + fn test_detect_typescript_language_from_module() { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + let matches = cmd.clone().get_matches_from(vec!["generate"]); + let temp = tempfile::TempDir::new().unwrap(); + let module_dir = temp.path().join("spacetimedb"); + std::fs::create_dir_all(&module_dir).unwrap(); + std::fs::write(module_dir.join("package.json"), "{\"name\":\"m\"}").unwrap(); + let mut cfg = HashMap::new(); + cfg.insert( + "module-path".to_string(), + serde_json::Value::String(module_dir.display().to_string()), + ); + let command_config = CommandConfig::new(&schema, cfg, &matches).unwrap(); + let runs = prepare_generate_run_configs(vec![command_config], false).unwrap(); + assert_eq!(runs[0].lang, Language::TypeScript); + assert_eq!(runs[0].out_dir, PathBuf::from("src/module_bindings")); + } + + #[test] + fn test_detect_csharp_language_from_module() { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + let matches = cmd.clone().get_matches_from(vec!["generate"]); + let temp = tempfile::TempDir::new().unwrap(); + let module_dir = temp.path().join("spacetimedb"); + std::fs::create_dir_all(&module_dir).unwrap(); + std::fs::write(module_dir.join("Module.csproj"), "").unwrap(); + let mut cfg = HashMap::new(); + cfg.insert( + "module-path".to_string(), + serde_json::Value::String(module_dir.display().to_string()), + ); + let command_config = CommandConfig::new(&schema, cfg, &matches).unwrap(); + let runs = prepare_generate_run_configs(vec![command_config], false).unwrap(); + assert_eq!(runs[0].lang, Language::Csharp); + assert_eq!(runs[0].out_dir, PathBuf::from("module_bindings")); + } + + #[test] + fn test_error_when_default_module_path_missing_and_lang_not_set() { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + let matches = cmd.clone().get_matches_from(vec!["generate"]); + let command_config = CommandConfig::new(&schema, HashMap::new(), &matches).unwrap(); + let err = prepare_generate_run_configs(vec![command_config], false).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("Could not find module source at 'spacetimedb'")); + assert!(msg.contains("--module-path")); + assert!(msg.contains("spacetime.json")); + } + + #[test] + fn test_error_when_module_exists_but_language_cannot_be_detected() { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + let matches = cmd.clone().get_matches_from(vec!["generate"]); + let temp = tempfile::TempDir::new().unwrap(); + let module_dir = temp.path().join("spacetimedb"); + std::fs::create_dir_all(&module_dir).unwrap(); + let mut cfg = HashMap::new(); + cfg.insert( + "module-path".to_string(), + serde_json::Value::String(module_dir.display().to_string()), + ); + let command_config = CommandConfig::new(&schema, cfg, &matches).unwrap(); + let err = prepare_generate_run_configs(vec![command_config], false).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("Could not auto-detect client language")); + assert!(msg.contains("--lang")); + assert!(msg.contains("spacetime.json")); } #[tokio::test] diff --git a/crates/cli/src/subcommands/init.rs b/crates/cli/src/subcommands/init.rs index df6a6a0e5..2c8be8189 100644 --- a/crates/cli/src/subcommands/init.rs +++ b/crates/cli/src/subcommands/init.rs @@ -9,13 +9,15 @@ use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select}; use reqwest::Url; use serde::{Deserialize, Serialize}; use serde_json::json; +use spacetimedb_client_api_messages::name::parse_database_name; use spacetimedb_data_structures::map::{HashCollectionExt as _, HashMap}; use std::fs; use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; use toml_edit::{value, DocumentMut, Item}; use xmltree::{Element, XMLNode}; -use crate::spacetime_config::PackageManager; +use crate::spacetime_config::{PackageManager, SpacetimeConfig, CONFIG_FILENAME}; use crate::subcommands::login::{spacetimedb_login_force, DEFAULT_AUTH_HOST}; mod embedded { @@ -120,6 +122,35 @@ pub struct TemplateConfig { pub use_local: bool, } +#[derive(Debug, Clone, Default)] +pub struct InitOptions { + pub project_path: Option, + pub project_name: Option, + pub project_name_default: Option, + pub database_name_default: Option, + pub server_only: bool, + pub lang: Option, + pub template: Option, + pub local: bool, + pub non_interactive: bool, +} + +impl InitOptions { + pub fn from_args(args: &ArgMatches) -> Self { + Self { + project_path: args.get_one::("project-path").cloned(), + project_name: args.get_one::("project-name").cloned(), + project_name_default: None, + database_name_default: None, + server_only: args.get_flag("server-only"), + lang: args.get_one::("lang").cloned(), + template: args.get_one::("template").cloned(), + local: args.get_flag("local"), + non_interactive: args.get_flag("non-interactive"), + } + } +} + pub fn cli() -> clap::Command { clap::Command::new("init") .about(format!("Initializes a new spacetime project. {UNSTABLE_WARNING}")) @@ -197,8 +228,8 @@ fn slugify(name: &str) -> String { name.to_case(Case::Kebab) } -async fn get_project_name(args: &ArgMatches, is_interactive: bool) -> anyhow::Result { - if let Some(name) = args.get_one::("project-name") { +async fn get_project_name(options: &InitOptions, is_interactive: bool) -> anyhow::Result { + if let Some(name) = &options.project_name { if is_interactive { println!("{} {}", "Project name:".bold(), name); } @@ -209,10 +240,15 @@ async fn get_project_name(args: &ArgMatches, is_interactive: bool) -> anyhow::Re anyhow::bail!("PROJECT_NAME is required in non-interactive mode"); } + let default_project_name = options + .project_name_default + .clone() + .unwrap_or_else(|| "my-spacetime-app".to_string()); + let theme = ColorfulTheme::default(); let name = Input::with_theme(&theme) .with_prompt("Project name") - .default("my-spacetime-app".to_string()) + .default(default_project_name) .validate_with(|input: &String| -> Result<(), String> { if input.trim().is_empty() { return Err("Project name cannot be empty".to_string()); @@ -227,12 +263,12 @@ async fn get_project_name(args: &ArgMatches, is_interactive: bool) -> anyhow::Re } async fn get_project_path( - args: &ArgMatches, + options: &InitOptions, project_name: &str, is_interactive: bool, is_server_only: bool, ) -> anyhow::Result { - if let Some(path) = args.get_one::("project-path") { + if let Some(path) = &options.project_path { if is_interactive { println!("{} {}", "Project path:".bold(), path.display()); } @@ -441,8 +477,10 @@ pub fn install_typescript_dependencies( Ok(()) } -pub async fn exec_init(config: &mut Config, args: &ArgMatches, is_interactive: bool) -> anyhow::Result { - let use_local = if args.get_flag("local") { +pub async fn exec_with_options(config: &mut Config, options: &InitOptions) -> anyhow::Result { + let is_interactive = !options.non_interactive; + + let use_local = if options.local { true } else if is_interactive { !check_and_prompt_login(config).await? @@ -451,15 +489,16 @@ pub async fn exec_init(config: &mut Config, args: &ArgMatches, is_interactive: b config.spacetimedb_token().is_none() }; - let is_server_only = args.get_flag("server-only"); + let is_server_only = options.server_only; - let project_name = get_project_name(args, is_interactive).await?; - let project_path = get_project_path(args, &project_name, is_interactive, is_server_only).await?; + let project_name = get_project_name(options, is_interactive).await?; + let project_path = get_project_path(options, &project_name, is_interactive, is_server_only).await?; + let local_database_name = get_local_database_name(options, &project_name, is_interactive)?; let mut template_config = if is_interactive { - get_template_config_interactive(args, project_name, project_path.clone()).await? + get_template_config_interactive(options, project_name, project_path.clone()).await? } else { - get_template_config_non_interactive(args, project_name, project_path.clone()).await? + get_template_config_non_interactive(options, project_name, project_path.clone()).await? }; template_config.use_local = use_local; @@ -471,6 +510,10 @@ 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?; + if let Some(path) = create_default_spacetime_config_if_missing(&project_path)? { + println!("{} Created {}", "✓".green(), path.display()); + } + // Determine package manager for TypeScript projects let uses_typescript = template_config.server_lang == Some(ServerLanguage::TypeScript) || template_config.client_lang == Some(ClientLanguage::TypeScript); @@ -512,23 +555,114 @@ pub async fn exec_init(config: &mut Config, args: &ArgMatches, is_interactive: b } } + if let Some(path) = create_local_spacetime_config_if_missing(&project_path, &local_database_name)? { + println!("{} Created {}", "✓".green(), path.display()); + } + Ok(project_path) } +fn get_local_database_name(options: &InitOptions, project_name: &str, is_interactive: bool) -> anyhow::Result { + let default_database = options + .database_name_default + .clone() + .unwrap_or_else(|| format!("{project_name}-{}", random_suffix(5))); + if !is_interactive { + return Ok(default_database); + } + + let theme = ColorfulTheme::default(); + let database_name = Input::with_theme(&theme) + .with_prompt("Database name") + .default(default_database) + .validate_with(|input: &String| -> Result<(), String> { + parse_database_name(input.trim()).map_err(|e| e.to_string())?; + Ok(()) + }) + .interact_text()? + .trim() + .to_string(); + + Ok(database_name) +} + +fn create_default_spacetime_config_if_missing(project_path: &Path) -> anyhow::Result> { + let config_path = project_path.join(CONFIG_FILENAME); + if config_path.exists() { + return Ok(None); + } + + let mut config = SpacetimeConfig::default(); + config + .additional_fields + .insert("server".to_string(), json!("maincloud")); + + if project_path.join("spacetimedb").is_dir() { + config + .additional_fields + .insert("module-path".to_string(), json!("./spacetimedb")); + } + + Ok(Some(config.save_to_dir(project_path)?)) +} + +fn create_local_spacetime_config_if_missing( + project_path: &Path, + database_name: &str, +) -> anyhow::Result> { + let main_config_path = project_path.join(CONFIG_FILENAME); + if !main_config_path.exists() { + return Ok(None); + } + + let local_config_path = project_path.join("spacetime.local.json"); + if local_config_path.exists() { + return Ok(None); + } + + let mut local_config = SpacetimeConfig::default(); + local_config + .additional_fields + .insert("database".to_string(), json!(database_name)); + local_config.save(&local_config_path)?; + + Ok(Some(local_config_path)) +} + +fn random_suffix(len: usize) -> String { + const ALNUM: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + let pid = std::process::id() as u64; + let mut state = now ^ (pid << 16); + let mut out = String::with_capacity(len); + for _ in 0..len { + // Simple xorshift to derive pseudo-random chars without extra deps. + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + let idx = (state % ALNUM.len() as u64) as usize; + out.push(ALNUM[idx] as char); + } + out +} + async fn get_template_config_non_interactive( - args: &ArgMatches, + options: &InitOptions, project_name: String, project_path: PathBuf, ) -> anyhow::Result { // Check if template is provided - if let Some(template_str) = args.get_one::("template") { + if let Some(template_str) = options.template.as_ref() { // Check if it's a builtin template let (_, templates) = fetch_templates_list().await?; return create_template_config_from_template_str(project_name, project_path, template_str, &templates); } // No template - require at least one language option - let server_lang_str = args.get_one::("lang").cloned(); + let server_lang_str = options.lang.clone(); if server_lang_str.is_none() { anyhow::bail!("Either --template or --lang must be provided in non-interactive mode"); @@ -578,21 +712,21 @@ pub fn ensure_empty_directory(_project_name: &str, project_path: &Path, is_serve } async fn get_template_config_interactive( - args: &ArgMatches, + options: &InitOptions, project_name: String, project_path: PathBuf, ) -> anyhow::Result { let theme = ColorfulTheme::default(); // Check if template is provided - if let Some(template_str) = args.get_one::("template") { + if let Some(template_str) = options.template.as_ref() { println!("{} {}", "Template:".bold(), template_str); let (_, templates) = fetch_templates_list().await?; return create_template_config_from_template_str(project_name, project_path, template_str, &templates); } - let server_lang_arg = args.get_one::("lang"); + let server_lang_arg = options.lang.as_ref(); if server_lang_arg.is_some() { let server_lang = parse_server_lang(&server_lang_arg.cloned())?; if let Some(lang_str) = server_lang_arg { @@ -1457,10 +1591,11 @@ fn check_for_git() -> bool { pub async fn exec(mut config: Config, args: &ArgMatches) -> anyhow::Result { println!("{UNSTABLE_WARNING}\n"); - let is_interactive = !args.get_flag("non-interactive"); - let template = args.get_one::("template"); - let server_lang = args.get_one::("lang"); - let project_name_arg = args.get_one::("project-name"); + let options = InitOptions::from_args(args); + let is_interactive = !options.non_interactive; + let template = options.template.as_ref(); + let server_lang = options.lang.as_ref(); + let project_name_arg = options.project_name.as_ref(); // Validate that template and lang options are not used together if template.is_some() && server_lang.is_some() { @@ -1477,7 +1612,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> anyhow::Result anyhow::Result<()> { @@ -1823,3 +1958,65 @@ fn check_for_emscripten_and_cmake() -> bool { false } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_create_default_spacetime_config_if_missing_creates_expected_config() { + let temp = tempfile::TempDir::new().unwrap(); + let project_path = temp.path(); + std::fs::create_dir_all(project_path.join("spacetimedb")).unwrap(); + + let created = create_default_spacetime_config_if_missing(project_path) + .unwrap() + .expect("expected config to be created"); + assert_eq!(created, project_path.join("spacetime.json")); + + let content = std::fs::read_to_string(&created).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert!(parsed.get("database").is_none()); + assert_eq!(parsed.get("server").and_then(|v| v.as_str()), Some("maincloud")); + assert_eq!( + parsed.get("module-path").and_then(|v| v.as_str()), + Some("./spacetimedb") + ); + } + + #[test] + fn test_create_local_spacetime_config_if_missing_creates_database_override() { + let temp = tempfile::TempDir::new().unwrap(); + let project_path = temp.path(); + + std::fs::write(project_path.join("spacetime.json"), "{}").unwrap(); + + let created = create_local_spacetime_config_if_missing(project_path, "my-app-abc12") + .unwrap() + .expect("expected local config to be created"); + assert_eq!(created, project_path.join("spacetime.local.json")); + + let content = std::fs::read_to_string(&created).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + let db = parsed + .get("database") + .and_then(|v| v.as_str()) + .expect("database should be present"); + + assert_eq!(db, "my-app-abc12"); + + let obj = parsed.as_object().expect("local config should be a JSON object"); + assert_eq!(obj.len(), 1, "local config should only contain database"); + } + + #[test] + fn test_get_local_database_name_uses_explicit_default_without_suffix() { + let options = InitOptions { + database_name_default: Some("my-explicit-db".to_string()), + ..Default::default() + }; + + let db = get_local_database_name(&options, "my-project", false).unwrap(); + assert_eq!(db, "my-explicit-db"); + } +} diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index 4785cf4ac..d11ee3a11 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -5,6 +5,7 @@ use clap::ArgMatches; use reqwest::{StatusCode, Url}; use spacetimedb_client_api_messages::name::{is_identity, parse_database_name, PublishResult}; use spacetimedb_client_api_messages::name::{DatabaseNameError, PrePublishResult, PrettyPrintStyle, PublishOp}; +use std::collections::HashMap; use std::path::PathBuf; use std::{env, fs}; @@ -14,7 +15,7 @@ use crate::spacetime_config::{ find_and_load_with_env, CommandConfig, CommandSchema, CommandSchemaBuilder, FlatTarget, Key, LoadedConfig, SpacetimeConfig, }; -use crate::util::{add_auth_header_opt, find_module_path, get_auth_header, AuthHeader, ResponseExt}; +use crate::util::{add_auth_header_opt, get_auth_header, AuthHeader, ResponseExt}; use crate::util::{decode_identity, y_or_n}; use crate::{build, common_args}; @@ -320,6 +321,38 @@ pub async fn exec_with_options( ) }; + let clear_database = args + .get_one::("clear-database") + .copied() + .unwrap_or(ClearMode::Never); + let force = args.get_flag("force"); + + execute_publish_configs(&mut config, publish_configs, using_config, clear_database, force).await +} + +pub async fn exec_from_entry( + mut config: Config, + entry: HashMap, + clear_database: ClearMode, + force: bool, +) -> Result<(), anyhow::Error> { + let cmd = cli(); + let schema = build_publish_schema(&cmd)?; + let matches = cmd.get_matches_from(vec!["publish"]); + + let command_config = CommandConfig::new(&schema, entry, &matches)?; + command_config.validate()?; + + execute_publish_configs(&mut config, vec![command_config], true, clear_database, force).await +} + +async fn execute_publish_configs<'a>( + config: &mut Config, + publish_configs: Vec>, + using_config: bool, + clear_database: ClearMode, + force: bool, +) -> Result<(), anyhow::Error> { // Execute publish for each config for command_config in publish_configs { // Get values using command_config.get_one() which merges CLI + config @@ -327,11 +360,6 @@ pub async fn exec_with_options( let server = server_opt.as_deref(); let name_or_identity_opt = command_config.get_one::("database")?; let name_or_identity = name_or_identity_opt.as_deref(); - let clear_database = args - .get_one::("clear-database") - .copied() - .unwrap_or(ClearMode::Never); - let force = args.get_flag("force"); let anon_identity = command_config.get_one::("anon_identity")?.unwrap_or(false); let wasm_file = command_config.get_one::("wasm_file")?; let js_file = command_config.get_one::("js_file")?; @@ -340,15 +368,7 @@ pub async fn exec_with_options( } else { Some(match command_config.get_one::("module_path")? { Some(path) => path, - None if using_config => { - anyhow::bail!("module-path must be specified for each publish target when using spacetime.json"); - } - None => find_module_path(&std::env::current_dir()?).ok_or_else(|| { - anyhow::anyhow!( - "Could not find a SpacetimeDB module in spacetimedb/ or the current directory. \ - Use --module-path to specify the module location." - ) - })?, + None => default_publish_module_path(&std::env::current_dir()?), }) }; @@ -381,7 +401,7 @@ pub async fn exec_with_options( // 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 auth_header = get_auth_header(config, anon_identity, server, !force).await?; let (name_or_identity, parent) = validate_name_and_parent(name_or_identity, parent)?; @@ -404,7 +424,6 @@ pub async fn exec_with_options( (path.clone(), "Js") } else { build::exec_with_argstring( - config.clone(), path_to_project .as_ref() .expect("path_to_project must exist when publishing from source"), @@ -502,11 +521,17 @@ pub async fn exec_with_options( PublishOp::Created => "Created new", PublishOp::Updated => "Updated", }; - if let Some(domain) = domain { + if let Some(ref domain) = domain { println!("{op} database with name: {domain}, identity: {database_identity}"); } else { println!("{op} database with identity: {database_identity}"); } + + if is_maincloud_host(&database_host) { + if let Some(domain) = domain.as_ref() { + println!("Dashboard: https://spacetimedb.com/{}", domain.as_ref()); + } + } } PublishResult::PermissionDenied { name } => { if anon_identity { @@ -530,6 +555,25 @@ pub async fn exec_with_options( Ok(()) } +fn default_publish_module_path(current_dir: &std::path::Path) -> PathBuf { + let spacetimedb_dir = current_dir.join("spacetimedb"); + if spacetimedb_dir.is_dir() { + spacetimedb_dir + } else { + current_dir.to_path_buf() + } +} + +fn is_maincloud_host(database_host: &str) -> bool { + Url::parse(database_host) + .ok() + .and_then(|url| { + url.host_str() + .map(|h| h.eq_ignore_ascii_case("maincloud.spacetimedb.com")) + }) + .unwrap_or(false) +} + fn validate_name_or_identity(name_or_identity: &str) -> Result<(), DatabaseNameError> { if is_identity(name_or_identity) { Ok(()) @@ -857,6 +901,37 @@ mod tests { assert!(err_msg.contains("No database target matches")); } + #[test] + fn test_default_publish_module_path_prefers_spacetimedb_dir() { + let temp = tempfile::TempDir::new().unwrap(); + let cwd = temp.path().to_path_buf(); + let spacetimedb_dir = cwd.join("spacetimedb"); + std::fs::create_dir_all(&spacetimedb_dir).unwrap(); + + let resolved = default_publish_module_path(&cwd); + assert_eq!(resolved, spacetimedb_dir); + } + + #[test] + fn test_default_publish_module_path_falls_back_to_current_dir() { + let temp = tempfile::TempDir::new().unwrap(); + let cwd = temp.path().to_path_buf(); + + let resolved = default_publish_module_path(&cwd); + assert_eq!(resolved, cwd); + } + + #[test] + fn test_is_maincloud_host_true_for_maincloud_url() { + assert!(is_maincloud_host("https://maincloud.spacetimedb.com")); + } + + #[test] + fn test_is_maincloud_host_false_for_non_maincloud_url() { + assert!(!is_maincloud_host("http://localhost:3000")); + assert!(!is_maincloud_host("https://testnet.spacetimedb.com")); + } + #[test] fn test_glob_filter_matches_pattern() { use std::collections::HashMap; diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 97a2916fa..b8e691427 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -242,10 +242,12 @@ pub fn detect_module_language(path_to_project: &Path) -> anyhow::Result