diff --git a/crates/cli/src/spacetime_config.rs b/crates/cli/src/spacetime_config.rs index eb5019abd73..f8cac8cd5d1 100644 --- a/crates/cli/src/spacetime_config.rs +++ b/crates/cli/src/spacetime_config.rs @@ -5,7 +5,7 @@ use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::fmt; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use thiserror::Error; /// The filename for configuration @@ -730,6 +730,31 @@ impl<'a> CommandConfig<'a> { self.config_values.get(key) } + /// Get a path value and resolve it against `config_dir` if it came from config (not CLI). + pub fn get_resolved_path( + &self, + key: &str, + config_dir: Option<&Path>, + ) -> Result, CommandConfigError> { + let path = self.get_one::(key)?; + let from_cli = self.is_from_cli(key); + Ok(path.map(|p| { + let resolved = if p.is_absolute() || from_cli { + p + } else if let Some(base_dir) = config_dir { + base_dir.join(p) + } else { + p + }; + normalize_path_lexical(&resolved) + })) + } + + /// Returns true when this key was explicitly provided via CLI. + pub fn is_from_cli(&self, key: &str) -> bool { + self.schema.is_from_cli(self.matches, key) + } + /// Validate that all required keys are present in either config or CLI. pub fn validate(&self) -> Result<(), CommandConfigError> { for key in &self.schema.keys { @@ -746,6 +771,24 @@ impl<'a> CommandConfig<'a> { } } +fn normalize_path_lexical(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + other => normalized.push(other.as_os_str()), + } + } + if normalized.as_os_str().is_empty() { + PathBuf::from(".") + } else { + normalized + } +} + impl SpacetimeConfig { /// Find and load a spacetime.json file (convenience wrapper for no env). /// diff --git a/crates/cli/src/subcommands/dev.rs b/crates/cli/src/subcommands/dev.rs index 5b9f3524a6e..1187b9f2000 100644 --- a/crates/cli/src/subcommands/dev.rs +++ b/crates/cli/src/subcommands/dev.rs @@ -6,8 +6,8 @@ use crate::spacetime_config::{ }; use crate::subcommands::init; use crate::util::{ - add_auth_header_opt, database_identity, get_auth_header, get_login_token_or_log_in, spacetime_reverse_dns, - ResponseExt, + add_auth_header_opt, database_identity, find_module_path, get_auth_header, get_login_token_or_log_in, + spacetime_reverse_dns, ResponseExt, }; use crate::{common_args, generate}; use crate::{publish, tasks}; @@ -162,7 +162,12 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E .or(default_server_name.as_deref()) .ok_or_else(|| anyhow::anyhow!("Server not specified and no default server configured."))?; - let mut project_dir = project_path.clone(); + let cwd = std::env::current_dir()?; + let mut project_dir = if project_path.is_absolute() { + project_path.clone() + } else { + cwd.join(project_path) + }; if module_bindings_path.is_absolute() { anyhow::bail!("Module bindings path must be a relative path"); @@ -194,6 +199,17 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E } else { find_and_load_with_env_from(Some(env), project_dir.clone()).with_context(|| "Failed to load spacetime.json")? }; + + // If config was found while starting from a subdirectory (for example from `spacetimedb/`), + // treat the config directory as the project root for all relative defaults. + if let Some(lc) = loaded_config.as_ref() { + project_dir = lc.config_dir.clone(); + module_bindings_dir = project_dir.join(module_bindings_path); + if module_path_from_cli.is_none() { + spacetimedb_dir = project_dir.join("spacetimedb"); + } + } + let has_any_config_files = loaded_config.is_some(); // Config exists, but default module dir is missing: recover by asking for module-path @@ -348,6 +364,15 @@ 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 !has_any_config_files + && module_path_from_cli.is_none() + && (!spacetimedb_dir.exists() || !spacetimedb_dir.is_dir()) + { + if let Some(found_module) = find_module_path(&std::env::current_dir()?) { + spacetimedb_dir = found_module; + } + } + 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() @@ -641,10 +666,12 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E } println!("{}", "Press Ctrl+C to stop".dimmed()); println!(); + let loaded_config_dir = loaded_config.as_ref().map(|lc| lc.config_dir.clone()); generate_build_and_publish( &config, &project_dir, + loaded_config_dir.as_deref(), &spacetimedb_dir, &module_bindings_dir, client_language, @@ -752,6 +779,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E match generate_build_and_publish( &config, &project_dir, + loaded_config_dir.as_deref(), &spacetimedb_dir, &module_bindings_dir, client_language, @@ -865,6 +893,7 @@ fn upsert_env_db_names_and_hosts(env_path: &Path, server_host_url: &str, databas async fn generate_build_and_publish( config: &Config, project_dir: &Path, + config_dir: Option<&Path>, spacetimedb_dir: &Path, module_bindings_dir: &Path, client_language: Option<&Language>, @@ -892,7 +921,13 @@ async fn generate_build_and_publish( ); } else { println!("{}", "Generating module bindings from spacetime.json...".cyan()); - generate::exec_from_entries(generate_configs.to_vec(), crate::generate::extract_descriptions, yes).await?; + generate::exec_from_entries( + generate_configs.to_vec(), + crate::generate::extract_descriptions, + yes, + config_dir, + ) + .await?; } } else { let resolved_client_language = generate::resolve_language(spacetimedb_dir, client_language.copied())?; @@ -924,7 +959,13 @@ async fn generate_build_and_publish( Some(resolved_client_language), Some(module_bindings_dir), ); - generate::exec_from_entries(vec![generate_entry], crate::generate::extract_descriptions, yes).await?; + generate::exec_from_entries( + vec![generate_entry], + crate::generate::extract_descriptions, + yes, + config_dir, + ) + .await?; } if skip_publish { @@ -981,7 +1022,7 @@ async fn generate_build_and_publish( publish_entry.insert("break-clients".to_string(), json!(true)); } - publish::exec_from_entry(config.clone(), publish_entry, clear_database, yes).await?; + publish::exec_from_entry(config.clone(), publish_entry, config_dir, clear_database, yes).await?; } println!("{}", "Published successfully!".green().bold()); diff --git a/crates/cli/src/subcommands/generate.rs b/crates/cli/src/subcommands/generate.rs index 3f181706384..0e7c896d34c 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::{detect_module_language, resolve_sibling_binary, y_or_n, ModuleLanguage}; +use crate::util::{resolve_sibling_binary, y_or_n}; use crate::Config; use crate::{build, common_args}; use clap::builder::PossibleValue; @@ -293,13 +293,18 @@ pub struct GenerateRunConfig { fn prepare_generate_run_configs<'a>( generate_configs: Vec>, _using_config: bool, + config_dir: Option<&Path>, ) -> 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")); + .get_resolved_path("module_path", config_dir)? + .unwrap_or_else(|| { + config_dir + .map(|d| d.join("spacetimedb")) + .unwrap_or_else(|| PathBuf::from("spacetimedb")) + }); let wasm_file = command_config.get_one::("wasm_file")?; let js_file = command_config.get_one::("js_file")?; @@ -335,20 +340,26 @@ fn prepare_generate_run_configs<'a>( let lang = match requested_lang { Some(lang) => lang, None => { - let detected = detect_default_language(&project_path)?; + let client_project_dir = config_dir.unwrap_or_else(|| Path::new(".")); + let detected = detect_default_language(client_project_dir)?; println!( - "Detected client language '{}' from module '{}'. If this is not correct, pass --lang or add a generate target in spacetime.json.", + "Detected client language '{}' from '{}'. If this is not correct, pass --lang or add a generate target in spacetime.json.", language_cli_name(detected), - project_path.display() + client_project_dir.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)) + .get_resolved_path("out_dir", config_dir)? + .or_else(|| { + command_config + .get_resolved_path("uproject_dir", config_dir) + .ok() + .flatten() + }) + .or_else(|| default_out_dir_for_language(lang).map(|p| config_dir.map(|d| d.join(&p)).unwrap_or(p))) .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); @@ -369,23 +380,27 @@ fn prepare_generate_run_configs<'a>( 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 detect_default_language(client_project_dir: &Path) -> anyhow::Result { + if client_project_dir.join("package.json").exists() { + return Ok(Language::TypeScript); + } + if client_project_dir.join("Cargo.toml").exists() { + return Ok(Language::Rust); + } + if let Ok(entries) = fs::read_dir(client_project_dir) { + if entries + .flatten() + .any(|entry| entry.path().extension().is_some_and(|e| e == "csproj")) + { + return Ok(Language::Csharp); + } + } + + anyhow::bail!( + "Could not auto-detect client language from '{}'. \ + If this is not correct, pass --lang or add a generate target in spacetime.json.", + client_project_dir.display() + ); } fn language_cli_name(lang: Language) -> &'static str { @@ -613,7 +628,8 @@ pub async fn exec_ex( (false, vec![CommandConfig::new(&schema, HashMap::new(), args)?]) }; - let run_configs = prepare_generate_run_configs(generate_configs, using_config)?; + let config_dir = loaded_config_ref.map(|lc| lc.config_dir.as_path()); + let run_configs = prepare_generate_run_configs(generate_configs, using_config, config_dir)?; let json_module = args .get_many::("json_module") .map(|vals| vals.cloned().collect::>()); @@ -636,6 +652,7 @@ pub async fn exec_from_entries( entries: Vec>, extract_descriptions: ExtractDescriptions, force: bool, + config_dir: Option<&Path>, ) -> anyhow::Result<()> { let cmd = cli(); let schema = build_generate_config_schema(&cmd)?; @@ -650,7 +667,7 @@ pub async fn exec_from_entries( }) .collect::, anyhow::Error>>()?; - let run_configs = prepare_generate_run_configs(generate_configs, true)?; + let run_configs = prepare_generate_run_configs(generate_configs, true, config_dir)?; run_prepared_generate_configs(run_configs, extract_descriptions, None, force, false).await } @@ -950,7 +967,7 @@ mod tests { ); let command_config = CommandConfig::new(&schema, cfg, &matches).unwrap(); - let runs = prepare_generate_run_configs(vec![command_config], false).unwrap(); + let runs = prepare_generate_run_configs(vec![command_config], false, None).unwrap(); assert_eq!(runs.len(), 1); assert_eq!(runs[0].out_dir, PathBuf::from("src/module_bindings")); } @@ -963,10 +980,60 @@ mod tests { .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(); + let runs = prepare_generate_run_configs(vec![command_config], false, None).unwrap(); assert_eq!(runs[0].project_path, PathBuf::from("spacetimedb")); } + #[test] + fn test_defaults_resolve_relative_to_config_dir_when_config_exists() { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + let matches = cmd.clone().get_matches_from(vec!["generate", "--lang", "rust"]); + + let config_dir = tempfile::TempDir::new().unwrap(); + let module_dir = config_dir.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 command_config = CommandConfig::new(&schema, HashMap::new(), &matches).unwrap(); + let runs = prepare_generate_run_configs(vec![command_config], true, Some(config_dir.path())).unwrap(); + + assert_eq!(runs[0].project_path, module_dir); + assert_eq!(runs[0].out_dir, config_dir.path().join("src/module_bindings")); + } + + #[test] + fn test_cli_relative_out_dir_is_not_rebased_to_config_dir() { + let cmd = cli(); + let schema = build_generate_config_schema(&cmd).unwrap(); + let config_dir = tempfile::TempDir::new().unwrap(); + let module_dir = config_dir.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 matches = + cmd.clone() + .get_matches_from(vec!["generate", "--lang", "rust", "--out-dir", "src/module_bindings"]); + 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], true, Some(config_dir.path())).unwrap(); + + assert_eq!(runs[0].out_dir, PathBuf::from("src/module_bindings")); + } + #[test] fn test_typescript_defaults_out_dir() { let cmd = cli(); @@ -981,7 +1048,7 @@ mod tests { 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(); + let runs = prepare_generate_run_configs(vec![command_config], false, None).unwrap(); assert_eq!(runs[0].out_dir, PathBuf::from("src/module_bindings")); } @@ -999,48 +1066,48 @@ mod tests { 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(); + let runs = prepare_generate_run_configs(vec![command_config], false, None).unwrap(); assert_eq!(runs[0].out_dir, PathBuf::from("module_bindings")); } #[test] - fn test_detect_typescript_language_from_module() { + fn test_detect_typescript_language_from_client_project() { 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(); + std::fs::write(temp.path().join("package.json"), "{\"name\":\"client\"}").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(); + let runs = prepare_generate_run_configs(vec![command_config], true, Some(temp.path())).unwrap(); assert_eq!(runs[0].lang, Language::TypeScript); - assert_eq!(runs[0].out_dir, PathBuf::from("src/module_bindings")); + assert_eq!(runs[0].out_dir, temp.path().join("src/module_bindings")); } #[test] - fn test_detect_csharp_language_from_module() { + fn test_detect_csharp_language_from_client_project() { 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(); + std::fs::write(temp.path().join("Client.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(); + let runs = prepare_generate_run_configs(vec![command_config], true, Some(temp.path())).unwrap(); assert_eq!(runs[0].lang, Language::Csharp); - assert_eq!(runs[0].out_dir, PathBuf::from("module_bindings")); + assert_eq!(runs[0].out_dir, temp.path().join("module_bindings")); } #[test] @@ -1049,7 +1116,7 @@ mod tests { 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 err = prepare_generate_run_configs(vec![command_config], false, None).unwrap_err(); let msg = err.to_string(); assert!(msg.contains("Could not find module source at 'spacetimedb'")); assert!(msg.contains("--module-path")); @@ -1057,7 +1124,7 @@ mod tests { } #[test] - fn test_error_when_module_exists_but_language_cannot_be_detected() { + fn test_error_when_client_language_cannot_be_detected() { let cmd = cli(); let schema = build_generate_config_schema(&cmd).unwrap(); let matches = cmd.clone().get_matches_from(vec!["generate"]); @@ -1070,7 +1137,7 @@ mod tests { 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 err = prepare_generate_run_configs(vec![command_config], true, Some(temp.path())).unwrap_err(); let msg = err.to_string(); assert!(msg.contains("Could not auto-detect client language")); assert!(msg.contains("--lang")); diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index d11ee3a11fe..b563dd675e0 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -326,13 +326,23 @@ pub async fn exec_with_options( .copied() .unwrap_or(ClearMode::Never); let force = args.get_flag("force"); - - execute_publish_configs(&mut config, publish_configs, using_config, clear_database, force).await + let config_dir = loaded_config_ref.map(|lc| lc.config_dir.as_path()); + + execute_publish_configs( + &mut config, + publish_configs, + using_config, + config_dir, + clear_database, + force, + ) + .await } pub async fn exec_from_entry( mut config: Config, entry: HashMap, + config_dir: Option<&std::path::Path>, clear_database: ClearMode, force: bool, ) -> Result<(), anyhow::Error> { @@ -343,13 +353,22 @@ pub async fn exec_from_entry( let command_config = CommandConfig::new(&schema, entry, &matches)?; command_config.validate()?; - execute_publish_configs(&mut config, vec![command_config], true, clear_database, force).await + execute_publish_configs( + &mut config, + vec![command_config], + true, + config_dir, + clear_database, + force, + ) + .await } async fn execute_publish_configs<'a>( config: &mut Config, publish_configs: Vec>, using_config: bool, + config_dir: Option<&std::path::Path>, clear_database: ClearMode, force: bool, ) -> Result<(), anyhow::Error> { @@ -363,10 +382,11 @@ async fn execute_publish_configs<'a>( 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 resolved_module_path = command_config.get_resolved_path("module_path", config_dir)?; let path_to_project = if wasm_file.is_some() || js_file.is_some() { - command_config.get_one::("module_path")? + resolved_module_path } else { - Some(match command_config.get_one::("module_path")? { + Some(match resolved_module_path { Some(path) => path, None => default_publish_module_path(&std::env::current_dir()?), })