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:
Piotr Sarnacki
2026-02-18 23:54:56 +01:00
committed by GitHub
parent d0b9254ba7
commit 39bf47e025
19 changed files with 1444 additions and 397 deletions
+209 -15
View File
@@ -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]
+31 -8
View File
@@ -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
View File
@@ -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());
}
}
+477 -222
View File
@@ -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]
+221 -24
View File
@@ -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");
}
}
+93 -18
View File
@@ -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;
+6 -4
View File
@@ -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 -1
View File
@@ -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
+1 -1
View File
@@ -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 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2
View File
@@ -42,3 +42,5 @@ __screenshots__/
# System files
.DS_Store
Thumbs.db
spacetime.local.json
+2 -1
View File
@@ -18,4 +18,5 @@ CMakeFiles/
# Compiled WASM files
*.wasm
*.opt.wasm
*.opt.wasm
spacetime.local.json
+2
View File
@@ -37,3 +37,5 @@ dist-ssr
*.sln
*.sw?
/runs
spacetime.local.json
@@ -1,2 +1,4 @@
.env.local
spacetime.local.json
+2 -1
View File
@@ -14,4 +14,5 @@ Cargo.lock
*.pdb
# Spacetime ignore
/.spacetime
/.spacetime
spacetime.local.json
+2
View File
@@ -6,3 +6,5 @@
.env.keys
.env.local
.env.*.local
spacetime.local.json
+2
View File
@@ -1,3 +1,5 @@
node_modules
.output
dist
spacetime.local.json
+2
View File
@@ -7,3 +7,5 @@ node_modules
.tanstack
dist
*.log
spacetime.local.json