diff --git a/crates/bindings-csharp/NATIVEAOT-LLVM.md b/crates/bindings-csharp/NATIVEAOT-LLVM.md new file mode 100644 index 00000000000..3c1dc2438d3 --- /dev/null +++ b/crates/bindings-csharp/NATIVEAOT-LLVM.md @@ -0,0 +1,174 @@ +# Using NativeAOT-LLVM with SpacetimeDB C# Modules + +This guide provides instructions for enabling NativeAOT-LLVM compilation for C# SpacetimeDB modules, which can provide performance improvements. + +## Overview + +NativeAOT-LLVM compiles C# modules to native WebAssembly (WASM) instead of using the Mono runtime. + +> [!WARNING] +> This is currently only supported for Windows server modules and is experimental. + +## Prerequisites + +- **.NET SDK 8.x** (same version used by SpacetimeDB) +- **Emscripten SDK (EMSDK)** installed (must contain `upstream/emscripten/emcc.bat`) +- **(Optional) Binaryen (wasm-opt)** installed and on `PATH` (recommended: `version_116`) +- **Windows** - NativeAOT-LLVM is currently only supported for Windows server modules + +## Prerequisites Installation + +### Install Emscripten SDK (EMSDK) + +The Emscripten SDK is required for NativeAOT-LLVM compilation: + +1. **Download and extract** the Emscripten SDK from `https://github.com/emscripten-core/emsdk` + - Example path: `D:\Tools\emsdk` + +2. **Set environment variable** (optional - the CLI will detect it automatically): + ``` + $env:EMSDK="D:\Tools\emsdk" + ``` + +### Install Binaryen (Optional) + +Binaryen provides `wasm-opt` for WASM optimization (recommended for performance): + +1. Download Binaryen https://github.com/WebAssembly/binaryen/releases/tag/version_116 for Windows +2. Extract to e.g. `D:\Tools\binaryen` +3. Add `D:\Tools\binaryen\bin` to `PATH` + + To temporarily add to your current PowerShell session: + ``` + $env:PATH += ";D:\Tools\binaryen\bin" + ``` +4. Verify: + ``` + wasm-opt --version + ``` + +## Creating a New NativeAOT Project + +When creating a new C# project, use the `--native-aot` flag: + +``` +spacetime init --lang csharp --native-aot my-native-aot-project +``` + +This automatically: +- Creates a C# project with the required package references +- Generates a `spacetime.json` with `"native-aot": true` +- Configures the project for NativeAOT-LLVM compilation + +## Converting an Existing Project + +1. **Update spacetime.json** + Add `"native-aot": true` to your `spacetime.json`: + ```json + { + "module": "your-module-name", + "native-aot": true + } + ``` + + **Note:** Once `spacetime.json` has `"native-aot": true`, you can simply run `spacetime publish` without the `--native-aot` flag. The CLI will automatically detect the configuration and use NativeAOT compilation. + +2. **Ensure NuGet feed is configured** + NativeAOT-LLVM packages come from **dotnet-experimental**. Add to `NuGet.Config`: + ```xml + + + + + + + + + ``` + +3. **Add NativeAOT package references** + Add this `ItemGroup` to your `.csproj`: + ```xml + + + + + + ``` + + Your complete `.csproj` should look like: + ```xml + + + net8.0 + wasi-wasm + enable + enable + + + + + + + + + + + ``` + +## Publishing Your NativeAOT Module + +After completing either the **Creating a New NativeAOT Project** or **Converting an Existing Project** steps above, you can publish your module normally: + +``` +# From your project directory +spacetime publish your-database-name +``` + +If you have `"native-aot": true` in your `spacetime.json`, the CLI will automatically detect this and use NativeAOT compilation. Alternatively, you can use: + +``` +spacetime publish --native-aot your-database-name +``` + +The CLI will display "Using NativeAOT-LLVM compilation (experimental)" when NativeAOT is enabled. + +## Troubleshooting + +### Package source mapping enabled +If you have **package source mapping** enabled in `NuGet.Config`, add mappings for the LLVM packages: + +```xml + + + + + + + + + + + + + + + +``` + +### wasi-experimental workload install fails +If the CLI cannot install the `wasi-experimental` workload automatically, install it manually: + +``` +dotnet workload install wasi-experimental +``` + +### Duplicate PackageReference warning +You may see a `NU1504` warning about duplicate `PackageReference` items. This is expected and non-blocking. + +### Code generation failed +If you see errors like "Code generation failed for method", ensure: +1. You're using `SpacetimeDB.Runtime` version 2.0.4 or newer +2. All required package references are in your `.csproj` +3. The `dotnet-experimental` feed is configured in `NuGet.Config` + diff --git a/crates/bindings-csharp/Runtime/Internal/Module.cs b/crates/bindings-csharp/Runtime/Internal/Module.cs index 8dcf6cebb4e..fcfe683e5a0 100644 --- a/crates/bindings-csharp/Runtime/Internal/Module.cs +++ b/crates/bindings-csharp/Runtime/Internal/Module.cs @@ -206,6 +206,36 @@ internal RawModuleDefV10 BuildModuleDefinition() public static class Module { + // Workaround for NativeAOT-LLVM IL scanner bug: + // The scanner fails to compute vtables for TaggedEnum base types when + // concrete subtypes are only encountered indirectly (e.g., through Equals + // calls on types containing TaggedEnum fields). This occurs when no user + // table has indexes/primary keys, so RawIndexAlgorithm is never directly + // constructed in user code. + // By referencing concrete TaggedEnum subtypes here, we ensure the IL scanner + // always processes their vtables. One variant per TaggedEnum is sufficient. + [System.Runtime.CompilerServices.MethodImpl( + System.Runtime.CompilerServices.MethodImplOptions.NoInlining + )] + private static void EnsureNativeAotTypeRoots() + { + // These constructions are never executed at runtime — they exist solely + // to make the IL scanner compute vtables for TaggedEnum subtypes. + // The condition is always false but the scanner must assume it could be true. + if (Environment.TickCount < 0 && Environment.TickCount > 0) + { + _ = new RawIndexAlgorithm.BTree(null!); + _ = new RawConstraintDataV9.Unique(null!); + _ = new RawModuleDef.V10(null!); + _ = new RawModuleDefV10Section.Typespace(null!); + _ = new ExplicitNameEntry.Table(null!); + _ = new MiscModuleExport.TypeAlias(null!); + _ = new RawMiscModuleExportV9.ColumnDefaultValue(null!); + _ = new SpacetimeDB.Filter.Sql(null!); + _ = new ViewResultHeader.RowData(default); + } + } + private static readonly RawModuleDefV10 moduleDef = new(); private static readonly List reducers = []; @@ -419,6 +449,7 @@ private static void Write(this BytesSink sink, byte[] bytes) public static void __describe_module__(BytesSink description) { + EnsureNativeAotTypeRoots(); try { var module = moduleDef.BuildModuleDefinition(); diff --git a/crates/bindings-csharp/Runtime/Runtime.csproj b/crates/bindings-csharp/Runtime/Runtime.csproj index 2f4350379ee..eb28d3481b6 100644 --- a/crates/bindings-csharp/Runtime/Runtime.csproj +++ b/crates/bindings-csharp/Runtime/Runtime.csproj @@ -12,6 +12,7 @@ true SpacetimeDB true + https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-experimental/nuget/v3/index.json;$(RestoreAdditionalProjectSources) @@ -25,6 +26,13 @@ + + + + + + + diff --git a/crates/bindings-csharp/Runtime/build/SpacetimeDB.Runtime.targets b/crates/bindings-csharp/Runtime/build/SpacetimeDB.Runtime.targets index b57f5041c4d..5f183e0e040 100644 --- a/crates/bindings-csharp/Runtime/build/SpacetimeDB.Runtime.targets +++ b/crates/bindings-csharp/Runtime/build/SpacetimeDB.Runtime.targets @@ -1,32 +1,50 @@ + + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/crates/cli/src/subcommands/init.rs b/crates/cli/src/subcommands/init.rs index b4701b41164..31d927720f1 100644 --- a/crates/cli/src/subcommands/init.rs +++ b/crates/cli/src/subcommands/init.rs @@ -116,6 +116,7 @@ pub struct TemplateConfig { pub github_repo: Option, pub template_def: Option, pub use_local: bool, + pub native_aot: bool, } #[derive(Debug, Clone, Default)] @@ -131,6 +132,8 @@ pub struct InitOptions { pub non_interactive: bool, /// When true, suppress the "Next steps" message after init (e.g. when called from `spacetime dev`). pub skip_next_steps: bool, + /// When true, configure C# projects for NativeAOT-LLVM compilation. + pub native_aot: bool, } impl InitOptions { @@ -146,6 +149,7 @@ impl InitOptions { local: args.get_flag("local"), non_interactive: args.get_flag("non-interactive"), skip_next_steps: false, + native_aot: args.get_flag("native-aot"), } } } @@ -189,6 +193,12 @@ pub fn cli() -> clap::Command { .action(clap::ArgAction::SetTrue) .help("Run in non-interactive mode"), ) + .arg( + Arg::new("native-aot") + .long("native-aot") + .action(clap::ArgAction::SetTrue) + .help("Configure C# project for NativeAOT-LLVM compilation (experimental, Windows only)"), + ) } pub async fn fetch_templates_list() -> anyhow::Result> { @@ -346,6 +356,7 @@ fn create_template_config_from_template_str( github_repo: None, template_def: Some(template.clone()), use_local: true, + native_aot: false, }) } else { // GitHub template @@ -358,6 +369,7 @@ fn create_template_config_from_template_str( github_repo: Some(template_str.to_string()), template_def: None, use_local: true, + native_aot: false, }) } } @@ -525,7 +537,14 @@ pub async fn exec_with_options(config: &mut Config, options: &InitOptions) -> an )?; 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)? { + // Add NativeAOT-LLVM package references to C# projects if --native-aot was specified + if options.native_aot && template_config.server_lang == Some(ServerLanguage::Csharp) { + let server_dir = template_config.project_path.join("spacetimedb"); + add_native_aot_packages_to_csproj(&server_dir)?; + } + + let default_server = config.default_server_name().unwrap_or("maincloud"); + if let Some(path) = create_default_spacetime_config_if_missing(&project_path, options.native_aot, default_server)? { println!("{} Created {}", "✓".green(), path.display()); } @@ -605,7 +624,11 @@ fn get_local_database_name(options: &InitOptions, project_name: &str, is_interac Ok(database_name) } -fn create_default_spacetime_config_if_missing(project_path: &Path) -> anyhow::Result> { +fn create_default_spacetime_config_if_missing( + project_path: &Path, + native_aot: bool, + default_server: &str, +) -> anyhow::Result> { let config_path = project_path.join(CONFIG_FILENAME); if config_path.exists() { return Ok(None); @@ -614,7 +637,7 @@ fn create_default_spacetime_config_if_missing(project_path: &Path) -> anyhow::Re let mut config = SpacetimeConfig::default(); config .additional_fields - .insert("server".to_string(), json!("maincloud")); + .insert("server".to_string(), json!(default_server)); if project_path.join("spacetimedb").is_dir() { config @@ -622,6 +645,10 @@ fn create_default_spacetime_config_if_missing(project_path: &Path) -> anyhow::Re .insert("module-path".to_string(), json!("./spacetimedb")); } + if native_aot { + config.additional_fields.insert("native-aot".to_string(), json!(true)); + } + Ok(Some(config.save_to_dir(project_path)?)) } @@ -696,6 +723,7 @@ async fn get_template_config_non_interactive( github_repo: None, template_def: None, use_local: true, + native_aot: false, }) } @@ -761,6 +789,7 @@ async fn get_template_config_interactive( github_repo: None, template_def: None, use_local: true, + native_aot: false, }); } @@ -845,6 +874,7 @@ async fn get_template_config_interactive( github_repo: None, template_def: Some(template.clone()), use_local: true, + native_aot: false, }); } else if client_selection == github_clone_index { return loop { @@ -889,6 +919,7 @@ async fn get_template_config_interactive( github_repo: None, template_def: None, use_local: true, + native_aot: false, }); } else { unreachable!("Invalid selection index"); @@ -1648,6 +1679,21 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> anyhow::Result anyhow::Result<()> { Ok(()) } +/// Adds NativeAOT-LLVM package references to an existing C# .csproj file and creates NuGet.Config. +/// This is called when `--native-aot` is specified during `spacetime init`. +fn add_native_aot_packages_to_csproj(project_path: &Path) -> anyhow::Result<()> { + let csproj_path = project_path.join("StdbModule.csproj"); + if !csproj_path.exists() { + anyhow::bail!("Could not find StdbModule.csproj at {}", csproj_path.display()); + } + + let content = std::fs::read_to_string(&csproj_path)?; + + // The NativeAOT-LLVM ItemGroup to add + let native_aot_item_group = r#" + + + + + +"#; + + // Insert the ItemGroup before the closing tag + let new_content = if let Some(pos) = content.rfind("") { + let (before, after) = content.split_at(pos); + format!("{}{}{}", before.trim_end(), native_aot_item_group, after) + } else { + anyhow::bail!("Invalid .csproj file: missing tag"); + }; + + std::fs::write(&csproj_path, new_content)?; + println!( + "{} Added NativeAOT-LLVM package references to {}", + "✓".green(), + csproj_path.display() + ); + + // Create NuGet.Config with the dotnet-experimental feed required for NativeAOT-LLVM packages + let nuget_config_path = project_path.join("NuGet.Config"); + let nuget_config_content = r#" + + + + + + + +"#; + + std::fs::write(&nuget_config_path, nuget_config_content)?; + println!( + "{} Created {} with dotnet-experimental feed", + "✓".green(), + nuget_config_path.display() + ); + + Ok(()) +} + pub fn init_typescript_project(project_path: &Path) -> anyhow::Result<()> { let export_files = vec![ ( @@ -2015,7 +2117,7 @@ mod tests { 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) + let created = create_default_spacetime_config_if_missing(project_path, false, "maincloud") .unwrap() .expect("expected config to be created"); assert_eq!(created, project_path.join("spacetime.json")); @@ -2028,6 +2130,23 @@ mod tests { parsed.get("module-path").and_then(|v| v.as_str()), Some("./spacetimedb") ); + assert!(parsed.get("native-aot").is_none()); + } + + #[test] + fn test_create_default_spacetime_config_with_native_aot() { + 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, true, "maincloud") + .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_eq!(parsed.get("native-aot").and_then(|v| v.as_bool()), Some(true)); } #[test] diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index 30faf5e6052..05247b93044 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -12,8 +12,8 @@ use std::{env, fs}; use crate::common_args::ClearMode; use crate::config::Config; use crate::spacetime_config::{ - find_and_load_with_env, CommandConfig, CommandSchema, CommandSchemaBuilder, FlatTarget, Key, LoadedConfig, - SpacetimeConfig, + find_and_load_with_env, find_and_load_with_env_from, CommandConfig, CommandSchema, CommandSchemaBuilder, + FlatTarget, Key, LoadedConfig, SpacetimeConfig, }; use crate::util::{add_auth_header_opt, get_auth_header, strip_verbatim_prefix, AuthHeader, ResponseExt}; use crate::util::{decode_identity, y_or_n}; @@ -33,6 +33,7 @@ pub fn build_publish_schema(command: &clap::Command) -> Result( .collect(); if matched.is_empty() { - anyhow::bail!( - "No database target matches '{}'. Available databases: {}", - cli_database, - spacetime_config - .collect_all_targets_with_inheritance() - .iter() - .filter_map(|t| t.fields.get("database").and_then(|v| v.as_str())) - .collect::>() - .join(", ") - ); + // When there is exactly one target in the config and the CLI-provided + // database name doesn't match it, use that target's settings (e.g. + // native-aot, module-path, build-options) and let CommandConfig merge + // the CLI database name on top. This handles the common case where + // `spacetime init` generated a random database suffix that differs + // from the name the user passes on the CLI, while still picking up + // module-specific config. + let all_targets = spacetime_config.collect_all_targets_with_inheritance(); + if all_targets.len() == 1 { + all_targets + } else { + anyhow::bail!( + "No database target matches '{}'. Available databases: {}", + cli_database, + all_targets + .iter() + .filter_map(|t| t.fields.get("database").and_then(|v| v.as_str())) + .collect::>() + .join(", ") + ); + } + } else { + matched } - - matched } else { all_targets }; @@ -223,6 +235,12 @@ i.e. only lowercase ASCII letters and numbers, separated by dashes."), .action(Set) .help("Environment name for config file layering (e.g., dev, staging)") ) + .arg( + Arg::new("native_aot") + .long("native-aot") + .action(SetTrue) + .help("Use NativeAOT-LLVM compilation for C# modules (experimental, Windows only)") + ) .after_help("Run `spacetime help publish` for more detailed information.") } @@ -293,13 +311,23 @@ pub async fn exec_with_options( let env = args.get_one::("env").map(|s| s.as_str()); // Get publish configs (from spacetime.json or empty) - let owned_loaded; + let mut owned_loaded; let loaded_config_ref = if no_config { None } else if let Some(pre) = pre_loaded_config { Some(pre) } else { + // First, try to load config from current directory owned_loaded = find_and_load_with_env(env)?; + + // If no config found and --module-path is specified, try loading from module path. + if owned_loaded.is_none() + && args.contains_id("module_path") + && let Some(module_path) = args.get_one::("module_path") + { + owned_loaded = find_and_load_with_env_from(env, module_path.clone())?; + } + owned_loaded.as_ref().inspect(|loaded| { if !quiet_config { for path in &loaded.loaded_files { @@ -420,6 +448,7 @@ async fn execute_publish_configs<'a>( let parent = parent_opt.as_deref(); let org_opt = command_config.get_one::("organization")?; let org = org_opt.as_deref(); + let native_aot = command_config.get_one::("native_aot")?.unwrap_or(false); // If the user didn't specify an identity and we didn't specify an anonymous identity, then // we want to use the default identity @@ -447,6 +476,16 @@ async fn execute_publish_configs<'a>( println!("(JS) Skipping build. Instead we are publishing {}", path.display()); (path.clone(), "Js") } else { + // Set EXPERIMENTAL_WASM_AOT environment variable if native_aot is enabled + // This is read by the C# build system (MSBuild) and by csharp.rs to determine output paths + if native_aot { + println!("Using NativeAOT-LLVM compilation (experimental)"); + // SAFETY: We are single-threaded at this point and no other code is reading + // this environment variable concurrently. + unsafe { + env::set_var("EXPERIMENTAL_WASM_AOT", "1"); + } + } build::exec_with_argstring( path_to_project .as_ref() diff --git a/crates/smoketests/tests/smoketests/cli/publish.rs b/crates/smoketests/tests/smoketests/cli/publish.rs index b6e29e1fb57..50b32ce3e80 100644 --- a/crates/smoketests/tests/smoketests/cli/publish.rs +++ b/crates/smoketests/tests/smoketests/cli/publish.rs @@ -223,9 +223,7 @@ fn cli_publish_with_config_but_no_match_uses_cli_args() { // Create a config with a different database name let config_content = r#"{ - "publish": { - "database": "config-db-name" - } + "database": "config-db-name" }"#; std::fs::write(module_dir.join("spacetime.json"), config_content).expect("failed to write config"); diff --git a/crates/smoketests/tests/smoketests/csharp_aot_module.rs b/crates/smoketests/tests/smoketests/csharp_aot_module.rs new file mode 100644 index 00000000000..56df1100861 --- /dev/null +++ b/crates/smoketests/tests/smoketests/csharp_aot_module.rs @@ -0,0 +1,63 @@ +#![allow(clippy::disallowed_macros)] +use spacetimedb_guard::ensure_binaries_built; +use spacetimedb_smoketests::{have_emscripten, require_dotnet, workspace_root}; +use std::process::Command; + +/// Test NativeAOT-LLVM build path for C# modules. +/// Requires emscripten to be installed. +/// Only runs on Windows since runtime.linux-x64.Microsoft.DotNet.ILCompiler.LLVM +/// is not available on the dotnet-experimental NuGet feed. +#[test] +fn test_build_csharp_module_aot() { + require_dotnet!(); + + // NativeAOT-LLVM is only available on Windows + if std::env::consts::OS != "windows" { + eprintln!("Skipping AOT test - NativeAOT-LLVM for .NET 8 only available on Windows"); + return; + } + + // Check for emscripten - fail with helpful message if not available + // Uses have_emscripten() which checks for both `emcc` and `emcc.bat` on Windows + if !have_emscripten() { + panic!( + "NativeAOT-LLVM test requires emscripten but it was not found.\n\ + Install from: https://emscripten.org/docs/getting_started/downloads.html\n\ + Or ensure `emcc` is in your PATH." + ); + } + + let workspace = workspace_root(); + let _cli_path = ensure_binaries_built(); + + // Create isolated NuGet packages folder to avoid file lock conflicts + // NativeAOT-LLVM packages contain DLLs that stay locked and interfere with other tests + let nuget_packages_dir = tempfile::tempdir().expect("Failed to create temp directory for NuGet packages"); + + // Set EXPERIMENTAL_WASM_AOT=1 for this specific build + // Build sdk-test-cs with NativeAOT-LLVM + let mut cmd = Command::new("dotnet"); + cmd.arg("publish") + .arg("-c") + .arg("Release") + .current_dir(workspace.join("modules/sdk-test-cs")) + .env("EXPERIMENTAL_WASM_AOT", "1") + .env("NUGET_PACKAGES", nuget_packages_dir.path()); + + let output = cmd.output().expect("Failed to run dotnet publish"); + + assert!( + output.status.success(), + "NativeAOT-LLVM publish failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + // Clean up temp dir explicitly to verify no file locks remain + // This ensures subsequent tests can clear NuGet locals without conflicts + drop(nuget_packages_dir); + + // Verify StdbModule.wasm was produced + let wasm_path = workspace.join("modules/sdk-test-cs/bin/Release/net8.0/wasi-wasm/publish/StdbModule.wasm"); + assert!(wasm_path.exists(), "StdbModule.wasm not found at {:?}", wasm_path); +} diff --git a/crates/smoketests/tests/smoketests/mod.rs b/crates/smoketests/tests/smoketests/mod.rs index b8342e9f41a..ce26d29d09f 100644 --- a/crates/smoketests/tests/smoketests/mod.rs +++ b/crates/smoketests/tests/smoketests/mod.rs @@ -9,6 +9,7 @@ mod client_connection_errors; mod confirmed_reads; mod connect_disconnect_from_cli; mod create_project; +mod csharp_aot_module; mod csharp_module; mod default_module_clippy; mod delete_database; diff --git a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md index 8c43c9573b2..fe76039a240 100644 --- a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md +++ b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md @@ -111,6 +111,7 @@ Run `spacetime help publish` for more detailed information. * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). * `--no-config` — Ignore spacetime.json configuration * `--env ` — Environment name for config file layering (e.g., dev, staging) +* `--native-aot` — Use NativeAOT-LLVM compilation for C# modules (experimental, Windows only) @@ -414,6 +415,7 @@ Initializes a new spacetime project. * `-t`, `--template