mirror of
https://github.com/clockworklabs/SpacetimeDB.git
synced 2026-05-06 07:26:43 -04:00
spacetime.json related fixes and improvements (#4332)
# Description of Changes This PR fixes a bunch of issues and brings some improvements related to `spacetime.json` config file # API and ABI breaking changes - # Expected complexity level and risk 3 This PR is changing several places that are called during many of the CLI commands. # Testing - [x] Added some automated tests - [ ] Tested locally
This commit is contained in:
@@ -874,11 +874,42 @@ fn load_json_value(path: &Path) -> anyhow::Result<Option<serde_json::Value>> {
|
||||
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<serde_json::Value>) {
|
||||
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.<env>.json` (if env specified and file exists)
|
||||
/// 3. `spacetime.local.json` (if exists)
|
||||
/// 2. `spacetime.local.json` (if exists)
|
||||
/// 3. `spacetime.<env>.json` (if env specified and file exists)
|
||||
/// 4. `spacetime.<env>.local.json` (if env specified and file exists)
|
||||
pub fn find_and_load_with_env(env: Option<&str>) -> anyhow::Result<Option<LoadedConfig>> {
|
||||
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<PackageManager>,
|
||||
) -> anyhow::Result<Option<PathBuf>> {
|
||||
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]
|
||||
|
||||
@@ -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::<PathBuf>("module_path").cloned() {
|
||||
let module_path = match args.get_one::<PathBuf>("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<PathBuf>,
|
||||
build_debug: bool,
|
||||
features: Option<OsString>,
|
||||
) -> 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::<PathBuf>("module_path")
|
||||
.cloned()
|
||||
.ok_or_else(|| anyhow::anyhow!("module_path is required"))?;
|
||||
let features = arg_matches.get_one::<OsString>("features").cloned();
|
||||
let lint_dir = arg_matches.get_one::<OsString>("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)
|
||||
}
|
||||
|
||||
+387
-100
@@ -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::<Vec<_>>()
|
||||
.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<HashMap<String, serde_json::Value>> =
|
||||
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::<String>("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::<String>("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::<PathBuf>("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<HashMap<String, serde_json::Value>> =
|
||||
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::<String>("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<SpacetimeConfig>) -> Option<String> {
|
||||
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<Sp
|
||||
}
|
||||
}
|
||||
|
||||
fn create_default_spacetime_config_if_missing(project_dir: &Path) -> anyhow::Result<Option<PathBuf>> {
|
||||
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<Option<PathBuf>> {
|
||||
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<PathBuf> {
|
||||
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::<String>("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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
pub js_file: Option<PathBuf>,
|
||||
pub lang: Language,
|
||||
pub namespace: String,
|
||||
pub module_name: Option<String>,
|
||||
pub build_options: String,
|
||||
pub out_dir: PathBuf,
|
||||
pub include_private: bool,
|
||||
}
|
||||
|
||||
fn prepare_generate_run_configs<'a>(
|
||||
generate_configs: Vec<CommandConfig<'a>>,
|
||||
_using_config: bool,
|
||||
) -> anyhow::Result<Vec<GenerateRunConfig>> {
|
||||
let mut runs = Vec::with_capacity(generate_configs.len());
|
||||
|
||||
for command_config in generate_configs {
|
||||
let project_path = command_config
|
||||
.get_one::<PathBuf>("module_path")?
|
||||
.unwrap_or_else(|| PathBuf::from("spacetimedb"));
|
||||
|
||||
let wasm_file = command_config.get_one::<PathBuf>("wasm_file")?;
|
||||
let js_file = command_config.get_one::<PathBuf>("js_file")?;
|
||||
let requested_lang = command_config.get_one::<Language>("language")?;
|
||||
let namespace = command_config
|
||||
.get_one::<String>("namespace")?
|
||||
.unwrap_or_else(|| "SpacetimeDB.Types".to_string());
|
||||
let module_name = command_config.get_one::<String>("unreal_module_name")?;
|
||||
let build_options = command_config
|
||||
.get_one::<String>("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::<PathBuf>("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::<PathBuf>("out_dir")?
|
||||
.or_else(|| command_config.get_one::<PathBuf>("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::<bool>("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<Language> {
|
||||
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<PathBuf> {
|
||||
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<Language>) -> anyhow::Result<Language> {
|
||||
match requested {
|
||||
Some(lang) => Ok(lang),
|
||||
None => detect_default_language(module_path),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_generate_entry(
|
||||
module_path: Option<&Path>,
|
||||
language: Option<Language>,
|
||||
out_dir: Option<&Path>,
|
||||
) -> HashMap<String, serde_json::Value> {
|
||||
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<GenerateRunConfig>,
|
||||
extract_descriptions: ExtractDescriptions,
|
||||
json_module: Option<Vec<PathBuf>>,
|
||||
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::<RawModuleDef>(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::<anyhow::Result<Vec<_>>>()?;
|
||||
|
||||
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::<PathBuf>("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::<PathBuf>("wasm_file")?;
|
||||
let js_file = command_config.get_one::<PathBuf>("js_file")?;
|
||||
let json_module = args.get_many::<PathBuf>("json_module");
|
||||
let lang = command_config
|
||||
.get_one::<Language>("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::<PathBuf>("json_module")
|
||||
.map(|vals| vals.cloned().collect::<Vec<_>>());
|
||||
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::<String>("namespace")?
|
||||
.unwrap_or_else(|| "SpacetimeDB.Types".to_string());
|
||||
let module_name = command_config.get_one::<String>("unreal_module_name")?;
|
||||
let force = args.get_flag("force");
|
||||
let build_options = command_config
|
||||
.get_one::<String>("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<HashMap<String, serde_json::Value>>,
|
||||
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<CommandConfig> = entries
|
||||
.into_iter()
|
||||
.map(|entry| {
|
||||
let command_config = CommandConfig::new(&schema, entry, &empty_matches)?;
|
||||
command_config.validate()?;
|
||||
Ok(command_config)
|
||||
})
|
||||
.collect::<Result<Vec<_>, anyhow::Error>>()?;
|
||||
|
||||
// Get output directory (either out_dir or uproject_dir)
|
||||
let out_dir = command_config
|
||||
.get_one::<PathBuf>("out_dir")?
|
||||
.or_else(|| command_config.get_one::<PathBuf>("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::<PathBuf>("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::<PathBuf>("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::<RawModuleDef>(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::<bool>("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::<anyhow::Result<Vec<_>>>()?;
|
||||
|
||||
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"), "<Project/>").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]
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
pub project_name: Option<String>,
|
||||
pub project_name_default: Option<String>,
|
||||
pub database_name_default: Option<String>,
|
||||
pub server_only: bool,
|
||||
pub lang: Option<String>,
|
||||
pub template: Option<String>,
|
||||
pub local: bool,
|
||||
pub non_interactive: bool,
|
||||
}
|
||||
|
||||
impl InitOptions {
|
||||
pub fn from_args(args: &ArgMatches) -> Self {
|
||||
Self {
|
||||
project_path: args.get_one::<PathBuf>("project-path").cloned(),
|
||||
project_name: args.get_one::<String>("project-name").cloned(),
|
||||
project_name_default: None,
|
||||
database_name_default: None,
|
||||
server_only: args.get_flag("server-only"),
|
||||
lang: args.get_one::<String>("lang").cloned(),
|
||||
template: args.get_one::<String>("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<String> {
|
||||
if let Some(name) = args.get_one::<String>("project-name") {
|
||||
async fn get_project_name(options: &InitOptions, is_interactive: bool) -> anyhow::Result<String> {
|
||||
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<PathBuf> {
|
||||
if let Some(path) = args.get_one::<PathBuf>("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<PathBuf> {
|
||||
let use_local = if args.get_flag("local") {
|
||||
pub async fn exec_with_options(config: &mut Config, options: &InitOptions) -> anyhow::Result<PathBuf> {
|
||||
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<String> {
|
||||
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<Option<PathBuf>> {
|
||||
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<Option<PathBuf>> {
|
||||
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<TemplateConfig> {
|
||||
// Check if template is provided
|
||||
if let Some(template_str) = args.get_one::<String>("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::<String>("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<TemplateConfig> {
|
||||
let theme = ColorfulTheme::default();
|
||||
|
||||
// Check if template is provided
|
||||
if let Some(template_str) = args.get_one::<String>("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::<String>("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<PathBuf> {
|
||||
println!("{UNSTABLE_WARNING}\n");
|
||||
|
||||
let is_interactive = !args.get_flag("non-interactive");
|
||||
let template = args.get_one::<String>("template");
|
||||
let server_lang = args.get_one::<String>("lang");
|
||||
let project_name_arg = args.get_one::<String>("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<PathB
|
||||
}
|
||||
}
|
||||
|
||||
exec_init(&mut config, args, is_interactive).await
|
||||
exec_with_options(&mut config, &options).await
|
||||
}
|
||||
|
||||
pub fn init_rust_project(project_path: &Path) -> 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<ClearMode>("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<String, serde_json::Value>,
|
||||
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<CommandConfig<'a>>,
|
||||
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::<String>("database")?;
|
||||
let name_or_identity = name_or_identity_opt.as_deref();
|
||||
let clear_database = args
|
||||
.get_one::<ClearMode>("clear-database")
|
||||
.copied()
|
||||
.unwrap_or(ClearMode::Never);
|
||||
let force = args.get_flag("force");
|
||||
let anon_identity = command_config.get_one::<bool>("anon_identity")?.unwrap_or(false);
|
||||
let wasm_file = command_config.get_one::<PathBuf>("wasm_file")?;
|
||||
let js_file = command_config.get_one::<PathBuf>("js_file")?;
|
||||
@@ -340,15 +368,7 @@ pub async fn exec_with_options(
|
||||
} else {
|
||||
Some(match command_config.get_one::<PathBuf>("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;
|
||||
|
||||
@@ -242,10 +242,12 @@ pub fn detect_module_language(path_to_project: &Path) -> anyhow::Result<ModuleLa
|
||||
// check for Cargo.toml
|
||||
if path_to_project.join("Cargo.toml").exists() {
|
||||
Ok(ModuleLanguage::Rust)
|
||||
} else if path_to_project
|
||||
.read_dir()
|
||||
.unwrap()
|
||||
.any(|entry| entry.unwrap().path().extension() == Some("csproj".as_ref()))
|
||||
} else if path_to_project.is_dir()
|
||||
&& path_to_project
|
||||
.read_dir()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to read directory {}: {}", path_to_project.display(), e))?
|
||||
.flatten()
|
||||
.any(|entry| entry.path().extension() == Some("csproj".as_ref()))
|
||||
{
|
||||
Ok(ModuleLanguage::Csharp)
|
||||
} else if path_to_project.join("package.json").exists() {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
spacetime generate --out-dir ../client-unity/Assets/Scripts/autogen -y --lang cs
|
||||
spacetime generate --out-dir ../client-unity/Assets/Scripts/autogen -y --lang cs --module-path ./
|
||||
spacetime generate --lang unrealcpp --uproject-dir ../client-unreal --module-path ./ --module-name client_unreal
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
spacetime generate --out-dir ../client-unity/Assets/Scripts/autogen --lang cs $@
|
||||
spacetime generate --out-dir ../client-unity/Assets/Scripts/autogen --lang cs --module-path ./ $@
|
||||
spacetime generate --lang unrealcpp --uproject-dir ../client-unreal --module-path ./ --module-name client_unreal
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
spacetime generate --out-dir ../client-unity/Assets/Scripts/autogen -y --lang cs
|
||||
spacetime generate --out-dir ../client-unity/Assets/Scripts/autogen -y --lang cs --module-path ./
|
||||
spacetime generate --lang unrealcpp --uproject-dir ../client-unreal --module-path ./ --module-name client_unreal
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
spacetime generate --out-dir ../client-unity/Assets/Scripts/autogen --lang cs $@
|
||||
spacetime generate --out-dir ../client-unity/Assets/Scripts/autogen --lang cs --module-path ./ $@
|
||||
spacetime generate --lang unrealcpp --uproject-dir ../client-unreal --module-path ./ --module-name client_unreal
|
||||
|
||||
@@ -42,3 +42,5 @@ __screenshots__/
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
spacetime.local.json
|
||||
|
||||
@@ -18,4 +18,5 @@ CMakeFiles/
|
||||
|
||||
# Compiled WASM files
|
||||
*.wasm
|
||||
*.opt.wasm
|
||||
*.opt.wasm
|
||||
spacetime.local.json
|
||||
|
||||
@@ -37,3 +37,5 @@ dist-ssr
|
||||
*.sln
|
||||
*.sw?
|
||||
/runs
|
||||
|
||||
spacetime.local.json
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
|
||||
.env.local
|
||||
|
||||
spacetime.local.json
|
||||
|
||||
@@ -14,4 +14,5 @@ Cargo.lock
|
||||
*.pdb
|
||||
|
||||
# Spacetime ignore
|
||||
/.spacetime
|
||||
/.spacetime
|
||||
spacetime.local.json
|
||||
|
||||
@@ -6,3 +6,5 @@
|
||||
.env.keys
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
spacetime.local.json
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
node_modules
|
||||
.output
|
||||
dist
|
||||
|
||||
spacetime.local.json
|
||||
|
||||
@@ -7,3 +7,5 @@ node_modules
|
||||
.tanstack
|
||||
dist
|
||||
*.log
|
||||
|
||||
spacetime.local.json
|
||||
|
||||
Reference in New Issue
Block a user