diff --git a/.gitignore b/.gitignore index 674c4987da2..32bf1df4385 100644 --- a/.gitignore +++ b/.gitignore @@ -221,5 +221,8 @@ new.json # Test data !crates/core/testdata/ +# Temporary templates dir for CLI's init command +/crates/cli/.templates + # Symlinked output from `nix build` result diff --git a/Cargo.lock b/Cargo.lock index e6258738d43..66f26c0f526 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2408,6 +2408,21 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "git2" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b903b73e45dc0c6c596f2d37eccece7c1c8bb6e4407b001096387c63d0d93724" +dependencies = [ + "bitflags 2.10.0", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + [[package]] name = "glob" version = "0.3.3" @@ -3448,6 +3463,20 @@ version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "libgit2-sys" +version = "0.17.0+1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10472326a8a6477c3c20a64547b0059e4b0d086869eee31e6d7da728a8eb7224" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + [[package]] name = "libloading" version = "0.8.9" @@ -3496,6 +3525,20 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + [[package]] name = "libtest-mimic" version = "0.6.1" @@ -3507,6 +3550,18 @@ dependencies = [ "threadpool", ] +[[package]] +name = "libz-sys" +version = "1.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linked-hash-map" version = "0.5.6" @@ -7014,18 +7069,21 @@ dependencies = [ "clap-markdown", "colored", "convert_case 0.6.0", + "dialoguer", "dirs 5.0.1", "duct", "email_address", "flate2", "fs-err", "futures", + "git2", "http 1.3.1", "indicatif", "is-terminal", "itertools 0.12.1", "mimalloc", "percent-encoding", + "quick-xml 0.31.0", "regex", "reqwest 0.12.24", "rolldown", @@ -7062,6 +7120,7 @@ dependencies = [ "wasmbin", "webbrowser", "windows-sys 0.59.0", + "xmltree", ] [[package]] @@ -10468,6 +10527,21 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a67300977d3dc3f8034dae89778f502b6ba20b269527b3223ba59c0cf393bb8a" +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" + +[[package]] +name = "xmltree" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b619f8c85654798007fb10afa5125590b43b088c225a25fc2fec100a9fad0fc6" +dependencies = [ + "xml-rs", +] + [[package]] name = "xxhash-rust" version = "0.8.15" diff --git a/Cargo.toml b/Cargo.toml index 30f1ddde62f..c6c9a2f93b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -181,6 +181,7 @@ futures = "0.3" futures-channel = "0.3" futures-util = "0.3" getrandom02 = { package = "getrandom", version = "0.2" } +git2 = "0.19" glob = "0.3.1" hashbrown = { version = "0.15", default-features = false, features = ["equivalent", "inline-more"] } headers = "0.4" @@ -223,6 +224,7 @@ prometheus = "0.13.0" proptest = "1.4" proptest-derive = "0.5" quick-junit = { version = "0.3.2" } +quick-xml = "0.31" quote = "1.0.8" rand08 = { package = "rand", version = "0.8" } rand = "0.9" @@ -298,6 +300,7 @@ wasmbin = "0.6" webbrowser = "1.0.2" windows-sys = "0.59" xdg = "2.5" +xmltree = "0.11" tikv-jemallocator = { version = "0.6.0", features = ["profiling", "stats"] } tikv-jemalloc-ctl = { version = "0.6.0", features = ["stats"] } jemalloc_pprof = { version = "0.8", features = ["symbolize", "flamegraph"] } diff --git a/crates/bindings-typescript/examples/basic-react/.gitignore b/crates/bindings-typescript/examples/basic-react/.gitignore new file mode 100644 index 00000000000..a547bf36d8d --- /dev/null +++ b/crates/bindings-typescript/examples/basic-react/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/crates/bindings-typescript/examples/basic-react/index.html b/crates/bindings-typescript/examples/basic-react/index.html new file mode 100644 index 00000000000..3aed514cb20 --- /dev/null +++ b/crates/bindings-typescript/examples/basic-react/index.html @@ -0,0 +1,12 @@ + + + + + + SpacetimeDB React App + + +
+ + + diff --git a/crates/bindings-typescript/examples/basic-react/package.json b/crates/bindings-typescript/examples/basic-react/package.json new file mode 100644 index 00000000000..cb30120ed8f --- /dev/null +++ b/crates/bindings-typescript/examples/basic-react/package.json @@ -0,0 +1,25 @@ +{ + "name": "@clockworklabs/basic-react", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "local": "spacetime publish --project-path spacetimedb --server local && spacetime generate --lang typescript --out-dir src/module_bindings --project-path spacetimedb", + "deploy": "spacetime publish --project-path spacetimedb && spacetime generate --lang typescript --out-dir src/module_bindings --project-path spacetimedb" + }, + "dependencies": { + "spacetimedb": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^5.0.2", + "typescript": "~5.6.2", + "vite": "^7.1.5" + } +} diff --git a/crates/bindings-typescript/examples/basic-react/src/App.tsx b/crates/bindings-typescript/examples/basic-react/src/App.tsx new file mode 100644 index 00000000000..794e88af3d6 --- /dev/null +++ b/crates/bindings-typescript/examples/basic-react/src/App.tsx @@ -0,0 +1,68 @@ +import { useState } from 'react'; +import { DbConnection, Person } from './module_bindings'; +import { useSpacetimeDB, useTable } from 'spacetimedb/react'; + +function App() { + const [name, setName] = useState(''); + + const conn = useSpacetimeDB(); + const { isActive: connected } = conn; + + // Subscribe to all people in the database + const { rows: people } = useTable('person'); + + const addPerson = (e: React.FormEvent) => { + e.preventDefault(); + if (!name.trim() || !connected) return; + + // Call the add reducer + conn.reducers.add(name); + setName(''); + }; + + return ( +
+

SpacetimeDB React App

+ +
+ Status:{' '} + + {connected ? 'Connected' : 'Disconnected'} + +
+ +
+ setName(e.target.value)} + style={{ padding: '0.5rem', marginRight: '0.5rem' }} + disabled={!connected} + /> + +
+ +
+

People ({people.length})

+ {people.length === 0 ? ( +

No people yet. Add someone above!

+ ) : ( +
    + {people.map((person, index) => ( +
  • {person.name}
  • + ))} +
+ )} +
+
+ ); +} + +export default App; diff --git a/crates/bindings-typescript/examples/basic-react/src/main.tsx b/crates/bindings-typescript/examples/basic-react/src/main.tsx new file mode 100644 index 00000000000..f176f6ef300 --- /dev/null +++ b/crates/bindings-typescript/examples/basic-react/src/main.tsx @@ -0,0 +1,41 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import App from './App.tsx'; +import { Identity } from 'spacetimedb'; +import { SpacetimeDBProvider } from 'spacetimedb/react'; +import { DbConnection, ErrorContext } from './module_bindings/index.ts'; + +const HOST = import.meta.env.VITE_SPACETIMEDB_HOST ?? 'ws://localhost:3000'; +const DB_NAME = import.meta.env.VITE_SPACETIMEDB_DB_NAME ?? 'my-db'; + +const onConnect = (conn: DbConnection, identity: Identity, token: string) => { + localStorage.setItem('auth_token', token); + console.log( + 'Connected to SpacetimeDB with identity:', + identity.toHexString() + ); +}; + +const onDisconnect = () => { + console.log('Disconnected from SpacetimeDB'); +}; + +const onConnectError = (_ctx: ErrorContext, err: Error) => { + console.log('Error connecting to SpacetimeDB:', err); +}; + +const connectionBuilder = DbConnection.builder() + .withUri(HOST) + .withModuleName(DB_NAME) + .withToken(localStorage.getItem('auth_token') || undefined) + .onConnect(onConnect) + .onDisconnect(onDisconnect) + .onConnectError(onConnectError); + +createRoot(document.getElementById('root')!).render( + + + + + +); diff --git a/crates/bindings-typescript/examples/basic-react/tsconfig.json b/crates/bindings-typescript/examples/basic-react/tsconfig.json new file mode 100644 index 00000000000..c7224f57541 --- /dev/null +++ b/crates/bindings-typescript/examples/basic-react/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/crates/bindings-typescript/examples/basic-react/vite.config.ts b/crates/bindings-typescript/examples/basic-react/vite.config.ts new file mode 100644 index 00000000000..0466183af6a --- /dev/null +++ b/crates/bindings-typescript/examples/basic-react/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/crates/bindings-typescript/examples/empty/.gitignore b/crates/bindings-typescript/examples/empty/.gitignore new file mode 100644 index 00000000000..a547bf36d8d --- /dev/null +++ b/crates/bindings-typescript/examples/empty/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/crates/bindings-typescript/examples/empty/index.html b/crates/bindings-typescript/examples/empty/index.html new file mode 100644 index 00000000000..bb9b1865597 --- /dev/null +++ b/crates/bindings-typescript/examples/empty/index.html @@ -0,0 +1,12 @@ + + + + + + SpacetimeDB App + + +

SpacetimeDB App

+ + + diff --git a/crates/bindings-typescript/examples/empty/package.json b/crates/bindings-typescript/examples/empty/package.json new file mode 100644 index 00000000000..78d933bf832 --- /dev/null +++ b/crates/bindings-typescript/examples/empty/package.json @@ -0,0 +1,18 @@ +{ + "name": "@clockworklabs/empty-client", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "spacetimedb": "^1.5.0" + }, + "devDependencies": { + "typescript": "~5.6.2", + "vite": "^7.1.5" + } +} diff --git a/crates/bindings-typescript/examples/empty/src/main.ts b/crates/bindings-typescript/examples/empty/src/main.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/crates/bindings-typescript/examples/empty/tsconfig.json b/crates/bindings-typescript/examples/empty/tsconfig.json new file mode 100644 index 00000000000..a9f71bc427b --- /dev/null +++ b/crates/bindings-typescript/examples/empty/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src", "vite.config.ts"] +} diff --git a/crates/bindings-typescript/examples/empty/vite.config.ts b/crates/bindings-typescript/examples/empty/vite.config.ts new file mode 100644 index 00000000000..c049f46e10a --- /dev/null +++ b/crates/bindings-typescript/examples/empty/vite.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({}); diff --git a/crates/bindings-typescript/examples/quickstart-chat/src/main.tsx b/crates/bindings-typescript/examples/quickstart-chat/src/main.tsx index c19b3ce512e..b6a025ed7a7 100644 --- a/crates/bindings-typescript/examples/quickstart-chat/src/main.tsx +++ b/crates/bindings-typescript/examples/quickstart-chat/src/main.tsx @@ -6,6 +6,9 @@ import { Identity } from 'spacetimedb'; import { SpacetimeDBProvider } from 'spacetimedb/react'; import { DbConnection, ErrorContext } from './module_bindings/index.ts'; +const HOST = import.meta.env.VITE_SPACETIMEDB_HOST ?? 'ws://localhost:3000'; +const DB_NAME = import.meta.env.VITE_SPACETIMEDB_DB_NAME ?? 'quickstart-chat'; + const onConnect = (conn: DbConnection, identity: Identity, token: string) => { localStorage.setItem('auth_token', token); console.log( @@ -26,8 +29,8 @@ const onConnectError = (_ctx: ErrorContext, err: Error) => { }; const connectionBuilder = DbConnection.builder() - .withUri('ws://localhost:3000') - .withModuleName('quickstart-chat') + .withUri(HOST) + .withModuleName(DB_NAME) .withToken(localStorage.getItem('auth_token') || undefined) .onConnect(onConnect) .onDisconnect(onDisconnect) diff --git a/crates/bindings/README.md b/crates/bindings/README.md index 1e73873b65f..607acd55ac6 100644 --- a/crates/bindings/README.md +++ b/crates/bindings/README.md @@ -86,7 +86,7 @@ Tables and reducers in Rust modules can use any type that implements the [`Space To create a Rust module, install the [`spacetime` CLI tool](https://spacetimedb.com/install) in your preferred shell. Navigate to your work directory and run the following command: ```text -spacetime init --lang rust my-project-directory +spacetime init --lang rust --project-path my-project-directory my-project ``` This creates a Cargo project in `my-project-directory` with the following `Cargo.toml`: diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 365f049b25c..efd41596af8 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -74,8 +74,12 @@ walkdir.workspace = true wasmbin.workspace = true webbrowser.workspace = true clap-markdown.workspace = true +git2.workspace = true +dialoguer.workspace = true rolldown.workspace = true rolldown_utils.workspace = true +xmltree.workspace = true +quick-xml.workspace = true [target.'cfg(not(target_env = "msvc"))'.dependencies] tikv-jemallocator = { workspace = true } @@ -84,5 +88,9 @@ tikv-jemalloc-ctl = { workspace = true } [target.'cfg(windows)'.dependencies] windows-sys = { workspace = true, features = ["Win32_System_Console"] } +[build-dependencies] +serde_json.workspace = true +toml.workspace = true + [lints] workspace = true diff --git a/crates/cli/build.rs b/crates/cli/build.rs index 71e37105571..ee905f082fd 100644 --- a/crates/cli/build.rs +++ b/crates/cli/build.rs @@ -1,8 +1,362 @@ +use std::collections::BTreeMap; +use std::fs; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; use std::process::Command; +use toml::Value; -// https://stackoverflow.com/questions/43753491/include-git-commit-hash-as-string-into-rust-program fn main() { let output = Command::new("git").args(["rev-parse", "HEAD"]).output().unwrap(); - let git_hash = String::from_utf8(output.stdout).unwrap(); + let git_hash = String::from_utf8(output.stdout).unwrap().trim().to_string(); println!("cargo:rustc-env=GIT_HASH={git_hash}"); + + generate_template_files(); +} + +// This method generates functions with data used in `spacetime init`: +// +// * `get_templates_json` - returns contents of the JSON file with the list of templates +// * `get_template_files` - returns a HashMap with templates contents based on the +// templates list at crates/cli/templates/templates-list.json +// * `get_cursorrules` - returns contents of a cursorrules file +fn generate_template_files() { + let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap(); + let manifest_path = Path::new(&manifest_dir); + let templates_json_path = manifest_path.join("templates/templates-list.json"); + let out_dir = std::env::var("OUT_DIR").unwrap(); + let dest_path = Path::new(&out_dir).join("embedded_templates.rs"); + + println!("cargo:rerun-if-changed=templates/templates-list.json"); + + let templates_json = + fs::read_to_string(&templates_json_path).expect("Failed to read templates/templates-list.json"); + + let templates: serde_json::Value = + serde_json::from_str(&templates_json).expect("Failed to parse templates/templates-list.json"); + + let mut generated_code = String::new(); + generated_code.push_str("use std::collections::HashMap;\n\n"); + + generated_code.push_str("pub fn get_templates_json() -> &'static str {\n"); + generated_code + .push_str(" include_str!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/templates/templates-list.json\"))\n"); + generated_code.push_str("}\n\n"); + + generated_code + .push_str("pub fn get_template_files() -> HashMap<&'static str, HashMap<&'static str, &'static str>> {\n"); + generated_code.push_str(" let mut templates = HashMap::new();\n\n"); + + if let Some(template_list) = templates["templates"].as_array() { + for template in template_list { + let server_source = template["server_source"].as_str().unwrap(); + let client_source = template["client_source"].as_str().unwrap(); + + let server_path = PathBuf::from(server_source); + let client_path = PathBuf::from(client_source); + + let server_full_path = Path::new(&manifest_dir).join(&server_path); + let client_full_path = Path::new(&manifest_dir).join(&client_path); + + if server_full_path.exists() { + generate_template_entry(&mut generated_code, &server_path, server_source, &manifest_dir); + } + + if client_full_path.exists() { + generate_template_entry(&mut generated_code, &client_path, client_source, &manifest_dir); + } + } + } + + generated_code.push_str(" templates\n"); + generated_code.push_str("}\n\n"); + + let repo_root = get_repo_root(); + let workspace_cargo = repo_root.join("Cargo.toml"); + println!("cargo:rerun-if-changed={}", workspace_cargo.display()); + + let (workspace_edition, workspace_versions) = + extract_workspace_metadata(&workspace_cargo).expect("Failed to extract workspace metadata"); + + let ts_bindings_package = repo_root.join("crates/bindings-typescript/package.json"); + println!("cargo:rerun-if-changed={}", ts_bindings_package.display()); + let ts_bindings_version = + extract_ts_bindings_version(&ts_bindings_package).expect("Failed to read TypeScript bindings version"); + + let cursorrules_path = repo_root.join("docs/.cursor/rules/spacetimedb.mdc"); + if cursorrules_path.exists() { + generated_code.push_str("pub fn get_cursorrules() -> &'static str {\n"); + generated_code.push_str(" include_str!(\""); + generated_code.push_str(&cursorrules_path.to_str().unwrap().replace("\\", "\\\\")); + generated_code.push_str("\")\n"); + generated_code.push_str("}\n"); + + println!("cargo:rerun-if-changed={}", cursorrules_path.display()); + } else { + panic!("Could not find \"docs/.cursor/rules/spacetimedb.mdc\" file."); + } + + // Expose workspace metadata so `spacetime init` can rewrite template manifests without hardcoding versions. + generated_code.push_str("pub fn get_workspace_edition() -> &'static str {\n"); + generated_code.push_str(&format!(" \"{}\"\n", workspace_edition.escape_default())); + generated_code.push_str("}\n\n"); + + generated_code.push_str("pub fn get_workspace_dependency_version(name: &str) -> Option<&'static str> {\n"); + generated_code.push_str(" match name {\n"); + for (name, version) in &workspace_versions { + generated_code.push_str(&format!( + " \"{}\" => Some(\"{}\"),\n", + name.escape_default(), + version.escape_default() + )); + } + generated_code.push_str(" _ => None,\n"); + generated_code.push_str(" }\n"); + generated_code.push_str("}\n"); + + generated_code.push('\n'); + generated_code.push_str("pub fn get_typescript_bindings_version() -> &'static str {\n"); + generated_code.push_str(&format!(" \"{}\"\n", ts_bindings_version.escape_default())); + generated_code.push_str("}\n"); + + write_if_changed(&dest_path, generated_code.as_bytes()).expect("Failed to write embedded_templates.rs"); +} + +fn generate_template_entry(code: &mut String, template_path: &Path, source: &str, manifest_dir: &str) { + let (git_files, resolved_base) = get_git_tracked_files(template_path, manifest_dir); + + if git_files.is_empty() { + panic!("Template '{}' has no git-tracked files! Check that the directory exists and contains files tracked by git.", source); + } + + // Example: /Users/user/SpacetimeDB + let repo_root = get_repo_root(); + let repo_root_canonical = std::fs::canonicalize(&repo_root).unwrap(); + // Example: /Users/user/SpacetimeDB/crates/cli + let manifest_canonical = Path::new(manifest_dir).canonicalize().unwrap(); + // Example: crates/cli + let manifest_rel = manifest_canonical.strip_prefix(&repo_root_canonical).unwrap(); + + // Example for inside crate: /Users/user/SpacetimeDB/crates/cli/templates/basic-rust/server + // Example for outside crate: /Users/user/SpacetimeDB/modules/quickstart-chat + let resolved_canonical = repo_root.join(&resolved_base).canonicalize().unwrap(); + + // If the files are outside of the cli crate we need to copy them to the crate directory, + // so they're included properly even when the crate is published + let local_copy_dir = if resolved_canonical.strip_prefix(&manifest_canonical).is_err() { + // Example source: "../../modules/quickstart-chat" + // Sanitized: "parent_parent_modules_quickstart-chat" + let sanitized_source = source.replace("/", "_").replace("\\", "_").replace("..", "parent"); + // Example: /Users/user/SpacetimeDB/crates/cli/.templates/parent_parent_modules_quickstart-chat + let copy_dir = Path::new(manifest_dir).join(".templates").join(&sanitized_source); + fs::create_dir_all(©_dir).expect("Failed to create .templates directory"); + + Some(copy_dir) + } else { + None + }; + + code.push_str(" {\n"); + code.push_str(" let mut files = HashMap::new();\n"); + + for file_path in git_files { + // Example file_path: modules/quickstart-chat/src/lib.rs (relative to repo root) + // Example resolved_base: modules/quickstart-chat + // Example relative_path: src/lib.rs + let relative_path = match file_path.strip_prefix(&resolved_base) { + Ok(p) => p, + Err(_) => { + eprintln!( + "Warning: Could not strip prefix '{}' from '{}' for source '{}'", + resolved_base.display(), + file_path.display(), + source + ); + continue; + } + }; + // Example: "src/lib.rs" + let relative_str = relative_path.to_str().unwrap().replace("\\", "/"); + + // Example: /Users/user/SpacetimeDB/modules/quickstart-chat/src/lib.rs + let full_path = repo_root.join(&file_path); + if full_path.exists() && full_path.is_file() { + let include_path = if let Some(ref copy_dir) = local_copy_dir { + // Outside crate: copy to .templates + // Example dest_file: /Users/user/SpacetimeDB/crates/cli/.templates/parent_parent_modules_quickstart-chat/src/lib.rs + let dest_file = copy_dir.join(relative_path); + fs::create_dir_all(dest_file.parent().unwrap()).expect("Failed to create parent directory"); + copy_if_changed(&full_path, &dest_file) + .unwrap_or_else(|_| panic!("Failed to copy file {:?} to {:?}", full_path, dest_file)); + + // Example relative_to_manifest: .templates/parent_parent_modules_quickstart-chat/src/lib.rs + let relative_to_manifest = dest_file.strip_prefix(manifest_dir).unwrap(); + let path_str = relative_to_manifest.to_str().unwrap().replace("\\", "/"); + // Watch the original file for changes + // Example: modules/quickstart-chat/src/lib.rs + println!("cargo:rerun-if-changed={}", full_path.display()); + path_str + } else { + // Inside crate: use path relative to CARGO_MANIFEST_DIR + // Example file_path: crates/cli/templates/basic-rust/server/src/lib.rs + // Example manifest_rel: crates/cli + // Result: templates/basic-rust/server/src/lib.rs + let relative_to_manifest = file_path.strip_prefix(manifest_rel).unwrap(); + let path_str = relative_to_manifest.to_str().unwrap().replace("\\", "/"); + // Example: crates/cli/templates/basic-rust/server/src/lib.rs + println!("cargo:rerun-if-changed={}", full_path.display()); + path_str + }; + + // Example include_path (inside crate): "templates/basic-rust/server/src/lib.rs" + // Example include_path (outside crate): ".templates/parent_parent_modules_quickstart-chat/src/lib.rs" + // Example relative_str: "src/lib.rs" + code.push_str(&format!( + " files.insert(\"{}\", include_str!(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/{}\")));\n", + relative_str, include_path + )); + } + } + + code.push_str(&format!(" templates.insert(\"{}\", files);\n", source)); + code.push_str(" }\n\n"); +} + +// Get a list of files tracked by git from a given directory +fn get_git_tracked_files(path: &Path, manifest_dir: &str) -> (Vec, PathBuf) { + let full_path = Path::new(manifest_dir).join(path); + + let repo_root = get_repo_root(); + let repo_canonical = repo_root.canonicalize().unwrap(); + + let canonical = full_path.canonicalize().unwrap_or_else(|e| { + panic!("Failed to canonicalize path {}: {}", full_path.display(), e); + }); + let resolved_path = canonical + .strip_prefix(&repo_canonical) + .map(|p| p.to_path_buf()) + .unwrap_or_else(|_| { + panic!( + "Path {} is outside repo root {}", + canonical.display(), + repo_canonical.display() + ) + }); + + let output = Command::new("git") + .args(["ls-files", resolved_path.to_str().unwrap()]) + .current_dir(repo_root) + .output() + .expect("Failed to execute git ls-files"); + + if !output.status.success() { + return (Vec::new(), resolved_path); + } + + let stdout = String::from_utf8(output.stdout).unwrap(); + let files: Vec = stdout + .lines() + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .collect(); + + (files, resolved_path) +} + +fn get_repo_root() -> PathBuf { + let output = Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .output() + .expect("Failed to get git repo root"); + let path = String::from_utf8(output.stdout).unwrap().trim().to_string(); + PathBuf::from(path) +} + +fn extract_workspace_metadata(path: &Path) -> io::Result<(String, BTreeMap)> { + let content = fs::read_to_string(path)?; + let parsed: Value = content + .parse() + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + + let table = parsed + .as_table() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "workspace manifest is not a table"))?; + + let workspace = table + .get("workspace") + .and_then(Value::as_table) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "workspace section missing"))?; + + let edition = workspace + .get("package") + .and_then(Value::as_table) + .and_then(|pkg| pkg.get("edition")) + .and_then(Value::as_str) + .unwrap_or("2021") + .to_string(); + + let mut versions = BTreeMap::new(); + if let Some(deps) = workspace.get("dependencies").and_then(Value::as_table) { + for (name, value) in deps { + let version_opt = match value { + Value::String(s) => Some(normalize_version(s)), + Value::Table(table) => table.get("version").and_then(Value::as_str).map(normalize_version), + _ => None, + }; + + if let Some(version) = version_opt { + versions.insert(name.clone(), version); + } + } + } + + Ok((edition, versions)) +} + +fn extract_ts_bindings_version(path: &Path) -> io::Result { + let content = fs::read_to_string(path)?; + let parsed: serde_json::Value = + serde_json::from_str(&content).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + parsed + .get("version") + .and_then(serde_json::Value::as_str) + .map(|s| s.to_string()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + "Missing \"version\" field in TypeScript bindings package.json", + ) + }) +} + +fn normalize_version(version: &str) -> String { + version.trim().trim_start_matches('=').to_string() +} + +fn write_if_changed(path: &Path, contents: &[u8]) -> io::Result<()> { + match fs::read(path) { + Ok(existing) if existing == contents => Ok(()), + _ => { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut file = fs::File::create(path)?; + file.write_all(contents) + } + } +} + +fn copy_if_changed(src: &Path, dst: &Path) -> io::Result<()> { + let src_bytes = fs::read(src)?; + if let Ok(existing) = fs::read(dst) { + if existing == src_bytes { + return Ok(()); + } + } + + if let Some(parent) = dst.parent() { + fs::create_dir_all(parent)?; + } + + let mut file = fs::File::create(dst)?; + file.write_all(&src_bytes) } diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 1eb7b322232..c8faec42819 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -59,7 +59,7 @@ pub async fn exec_subcommand( "rename" => dns::exec(config, args).await, "generate" => generate::exec(config, args).await, "list" => list::exec(config, args).await, - "init" => init::exec(config, args).await, + "init" => init::exec(config, args).await.map(|_| ()), "build" => build::exec(config, args).await.map(drop), "server" => server::exec(config, paths, args).await, "subscribe" => subscribe::exec(config, args).await, diff --git a/crates/cli/src/subcommands/init.rs b/crates/cli/src/subcommands/init.rs index 8810fbf57fb..19cec472996 100644 --- a/crates/cli/src/subcommands/init.rs +++ b/crates/cli/src/subcommands/init.rs @@ -1,28 +1,1367 @@ -use crate::util::ModuleLanguage; use crate::Config; use crate::{detect::find_executable, util::UNSTABLE_WARNING}; +use anyhow::anyhow; use anyhow::Context; use clap::{Arg, ArgMatches}; use colored::Colorize; +use convert_case::{Case, Casing}; +use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select}; +use reqwest::Url; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::{fmt, fs}; +use toml_edit::{value, DocumentMut, Item}; +use xmltree::{Element, XMLNode}; + +use crate::subcommands::login::{spacetimedb_login_force, DEFAULT_AUTH_HOST}; + +mod embedded { + include!(concat!(env!("OUT_DIR"), "/embedded_templates.rs")); +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TemplateDefinition { + pub id: String, + pub description: String, + pub server_source: String, + pub client_source: String, + #[serde(default)] + pub server_lang: Option, + #[serde(default)] + pub client_lang: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct HighlightDefinition { + pub name: String, + pub template_id: String, +} + +#[derive(Debug, Deserialize)] +struct TemplatesList { + highlights: Vec, + templates: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TemplateType { + Builtin, + GitHub, + Empty, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServerLanguage { + Rust, + Csharp, + TypeScript, +} + +impl ServerLanguage { + fn as_str(&self) -> &'static str { + match self { + ServerLanguage::Rust => "rust", + ServerLanguage::Csharp => "csharp", + ServerLanguage::TypeScript => "typescript", + } + } + + fn from_str(s: &str) -> anyhow::Result> { + match s.to_lowercase().as_str() { + "rust" => Ok(Some(ServerLanguage::Rust)), + "csharp" | "c#" => Ok(Some(ServerLanguage::Csharp)), + "typescript" => Ok(Some(ServerLanguage::TypeScript)), + _ => Err(anyhow!("Unknown server language: {}", s)), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientLanguage { + Rust, + Csharp, + TypeScript, +} + +impl ClientLanguage { + fn as_str(&self) -> &'static str { + match self { + ClientLanguage::Rust => "rust", + ClientLanguage::Csharp => "csharp", + ClientLanguage::TypeScript => "typescript", + } + } + + fn from_str(s: &str) -> anyhow::Result> { + match s.to_lowercase().as_str() { + "rust" => Ok(Some(ClientLanguage::Rust)), + "csharp" | "c#" => Ok(Some(ClientLanguage::Csharp)), + "typescript" => Ok(Some(ClientLanguage::TypeScript)), + _ => Err(anyhow!("Unknown client language: {}", s)), + } + } +} + +pub struct TemplateConfig { + pub project_name: String, + pub project_path: PathBuf, + pub template_type: TemplateType, + pub server_lang: Option, + pub client_lang: Option, + pub github_repo: Option, + pub template_def: Option, + pub use_local: bool, +} pub fn cli() -> clap::Command { clap::Command::new("init") .about(format!("Initializes a new spacetime project. {UNSTABLE_WARNING}")) .arg( Arg::new("project-path") + .long("project-path") + .value_name("PATH") .value_parser(clap::value_parser!(PathBuf)) - .default_value(".") - .help("The path where we will create the spacetime project"), + .help("Directory where the project will be created (defaults to ./)"), + ) + .arg(Arg::new("project-name").value_name("PROJECT_NAME").help("Project name")) + .arg( + Arg::new("server-only") + .long("server-only") + .help("Initialize server only from the template (no client)") + .action(clap::ArgAction::SetTrue), + ) + .arg( + Arg::new("lang").long("lang").value_name("LANG").help( + "Server language: rust, csharp, typescript (it can only be used when --template is not specified)", + ), + ) + .arg( + Arg::new("template") + .short('t') + .long("template") + .value_name("TEMPLATE") + .help("Template ID or GitHub repository (owner/repo or URL)"), ) .arg( - Arg::new("lang") - .required(true) - .short('l') - .long("lang") - .help("The spacetime module language.") - .value_parser(clap::value_parser!(ModuleLanguage)), + Arg::new("local") + .long("local") + .action(clap::ArgAction::SetTrue) + .help("Use local deployment instead of Maincloud"), ) + .arg( + Arg::new("non-interactive") + .long("non-interactive") + .action(clap::ArgAction::SetTrue) + .help("Run in non-interactive mode"), + ) +} + +pub async fn fetch_templates_list() -> anyhow::Result<(Vec, Vec)> { + let content = embedded::get_templates_json(); + let templates_list: TemplatesList = serde_json::from_str(content).context("Failed to parse templates list JSON")?; + + Ok((templates_list.highlights, templates_list.templates)) +} + +pub async fn check_and_prompt_login(config: &mut Config) -> anyhow::Result { + if config.spacetimedb_token().is_some() { + println!("{}", "You are logged in to SpacetimeDB.".green()); + return Ok(true); + } + + println!("{}", "You are not logged in to SpacetimeDB.".yellow()); + + let theme = ColorfulTheme::default(); + let should_login = Confirm::with_theme(&theme) + .with_prompt("Would you like to log in? (required for Maincloud deployment)") + .default(true) + .interact()?; + + if should_login { + let host = Url::parse(DEFAULT_AUTH_HOST)?; + spacetimedb_login_force(config, &host, false).await?; + println!("{}", "Successfully logged in!".green()); + Ok(true) + } else { + println!("{}", "Continuing with local deployment.".yellow()); + Ok(false) + } +} + +fn slugify(name: &str) -> String { + name.to_case(Case::Kebab) +} + +async fn get_project_name(args: &ArgMatches, is_interactive: bool) -> anyhow::Result { + if let Some(name) = args.get_one::("project-name") { + if is_interactive { + println!("{} {}", "Project name:".bold(), name); + } + return Ok(name.clone()); + } + + if !is_interactive { + anyhow::bail!("PROJECT_NAME is required in non-interactive mode"); + } + + let theme = ColorfulTheme::default(); + let name = Input::with_theme(&theme) + .with_prompt("Project name") + .default("my-spacetime-app".to_string()) + .validate_with(|input: &String| -> Result<(), String> { + if input.trim().is_empty() { + return Err("Project name cannot be empty".to_string()); + } + Ok(()) + }) + .interact_text()? + .trim() + .to_string(); + + Ok(name) +} + +async fn get_project_path( + args: &ArgMatches, + project_name: &str, + is_interactive: bool, + is_server_only: bool, +) -> anyhow::Result { + if let Some(path) = args.get_one::("project-path") { + if is_interactive { + println!("{} {}", "Project path:".bold(), path.display()); + } + return Ok(path.clone()); + } + + if !is_interactive { + return Ok(PathBuf::from(slugify(project_name))); + } + + let theme = ColorfulTheme::default(); + let path_str = Input::with_theme(&theme) + .with_prompt("Project path") + .default(format!("./{}", slugify(project_name))) + .validate_with(|input: &String| -> Result<(), String> { + if input.trim().is_empty() { + return Err("Project path cannot be empty".to_string()); + } + + let path = Path::new(input); + if path.exists() { + if !path.is_dir() { + return Err(format!("A file exists at '{}'. Please choose a different path.", input)); + } + match std::fs::read_dir(path) { + Ok(entries) => { + // If server-only, allow non-empty directories (client files won't be created) + // but only if the `spacetimedb` subdirectory does not already exist + let entries_vec = entries.collect::>(); + if is_server_only + && !entries_vec.iter().any(|e| match e { + Ok(dir_entry) => dir_entry.file_name() == "spacetimedb", + Err(_) => false, + }) + { + return Ok(()); + } + if entries_vec.iter().filter(|e| e.is_ok()).count() > 0 { + return Err(format!( + "Directory '{}' already exists and is not empty. Please choose a different path.", + input + )); + } + } + Err(_) => { + return Err(format!( + "Cannot access directory '{}'. Please choose a different path.", + input + )); + } + } + } + Ok(()) + }) + .interact_text()? + .trim() + .to_string(); + + Ok(PathBuf::from(path_str)) +} + +fn create_template_config_from_template_str( + project_name: String, + project_path: PathBuf, + template_str: &str, + templates: &[TemplateDefinition], +) -> anyhow::Result { + if let Some(template) = templates.iter().find(|t| t.id == template_str) { + // Builtin template + Ok(TemplateConfig { + project_name, + project_path, + template_type: TemplateType::Builtin, + server_lang: parse_server_lang(&template.server_lang)?, + client_lang: parse_client_lang(&template.client_lang)?, + github_repo: None, + template_def: Some(template.clone()), + use_local: true, + }) + } else { + // GitHub template + Ok(TemplateConfig { + project_name, + project_path, + template_type: TemplateType::GitHub, + server_lang: None, + client_lang: None, + github_repo: Some(template_str.to_string()), + template_def: None, + use_local: true, + }) + } +} + +#[cfg(windows)] +fn run_pm(pm: PackageManager, args: &[&str], cwd: &Path) -> std::io::Result { + // Use cmd to resolve .cmd/.bat/.exe shims properly on Windows + std::process::Command::new("cmd") + .arg("/C") + .arg(pm.to_string()) + .args(args) + .current_dir(cwd) + .status() +} + +#[cfg(not(windows))] +fn run_pm(pm: PackageManager, args: &[&str], cwd: &Path) -> std::io::Result { + std::process::Command::new(pm.to_string()) + .args(args) + .current_dir(cwd) + .status() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum PackageManager { + Npm, + Pnpm, + Yarn, + Bun, +} + +impl fmt::Display for PackageManager { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let s = match self { + PackageManager::Npm => "npm", + PackageManager::Pnpm => "pnpm", + PackageManager::Yarn => "yarn", + PackageManager::Bun => "bun", + }; + write!(f, "{s}") + } +} + +pub fn prompt_for_typescript_package_manager() -> anyhow::Result> { + println!( + "\n{}", + "TypeScript server requires dependencies to be installed before publishing.".yellow() + ); + + // Prompt for package manager + let theme = ColorfulTheme::default(); + let choices = vec!["npm", "pnpm", "yarn", "bun", "none"]; + let selection = Select::with_theme(&theme) + .with_prompt("Which package manager would you like to use?") + .items(&choices) + .default(0) + .interact()?; + + Ok(match selection { + 0 => Some(PackageManager::Npm), + 1 => Some(PackageManager::Pnpm), + 2 => Some(PackageManager::Yarn), + 3 => Some(PackageManager::Bun), + _ => None, + }) +} + +pub fn install_typescript_dependencies( + package_dir: &Path, + package_manager: Option, +) -> anyhow::Result<()> { + if let Some(pm) = package_manager { + println!("Installing dependencies with {}...", pm); + + // Command arguments + let mut args_map: HashMap<&str, Vec<&str>> = HashMap::new(); + args_map.insert("npm", vec!["install", "--no-fund", "--no-audit", "--loglevel=error"]); + args_map.insert("yarn", vec!["install", "--no-fund"]); + args_map.insert( + "pnpm", + vec!["install", "--ignore-workspace", "--config.ignore-scripts=false"], + ); + args_map.insert("bun", vec!["install"]); + + let args: &[&str] = args_map + .get(pm.to_string().as_str()) + .map(|v| v.as_slice()) + .unwrap_or(&[]); + + // Run and stream output cross-platform + let status = run_pm(pm, args, package_dir); + + match status { + Ok(s) if s.success() => { + println!("{}", "Dependencies installed successfully!".green()); + } + Ok(s) => { + eprintln!( + "{}", + format!("Installation failed (exit code {}).", s.code().unwrap_or(-1)).red() + ); + println!( + "{}", + format!("Please run '{} install' manually in {}.", pm, package_dir.display()).yellow() + ); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + eprintln!( + "{}", + format!("Failed to find '{}'. Is it installed and on PATH?", pm).red() + ); + println!( + "{}", + format!("Please run '{} install' manually in {}.", pm, package_dir.display()).yellow() + ); + } + Err(e) => { + eprintln!("{}", format!("Failed to execute {}: {}", pm, e).red()); + println!( + "{}", + format!("Please run '{} install' manually in {}.", pm, package_dir.display()).yellow() + ); + } + } + } else { + println!( + "{}", + format!( + "You have chosen not to use a package manager. Please install dependencies manually in {}.", + package_dir.display() + ) + .yellow() + ); + } + + Ok(()) +} + +pub async fn exec_init(config: &mut Config, args: &ArgMatches, is_interactive: bool) -> anyhow::Result { + let use_local = if args.get_flag("local") { + true + } else if is_interactive { + !check_and_prompt_login(config).await? + } else { + // In non-interactive mode, default to local deployment if not logged in + config.spacetimedb_token().is_none() + }; + + let is_server_only = args.get_flag("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 mut template_config = if is_interactive { + get_template_config_interactive(args, project_name, project_path.clone()).await? + } else { + get_template_config_non_interactive(args, project_name, project_path.clone()).await? + }; + + template_config.use_local = use_local; + + ensure_empty_directory( + &template_config.project_name, + &template_config.project_path, + is_server_only, + )?; + init_from_template(&template_config, &template_config.project_path, is_server_only).await?; + + if template_config.server_lang == Some(ServerLanguage::TypeScript) + && template_config.client_lang == Some(ClientLanguage::TypeScript) + { + // If server & client are TypeScript, handle dependency installation + // NOTE: All server templates must have their server code in `spacetimedb/` directory + // This is not a requirement in general, but is a requirement for all templates + // i.e. `spacetime dev` is valid on non-templates. + let pm = if is_interactive { + prompt_for_typescript_package_manager()? + } else { + None + }; + let client_dir = template_config.project_path; + let server_dir = client_dir.join("spacetimedb"); + install_typescript_dependencies(&server_dir, pm)?; + install_typescript_dependencies(&client_dir, pm)?; + } else if template_config.client_lang == Some(ClientLanguage::TypeScript) { + let pm = if is_interactive { + prompt_for_typescript_package_manager()? + } else { + None + }; + let client_dir = template_config.project_path; + install_typescript_dependencies(&client_dir, pm)?; + } else if template_config.server_lang == Some(ServerLanguage::TypeScript) { + let pm = if is_interactive { + prompt_for_typescript_package_manager()? + } else { + None + }; + // NOTE: All server templates must have their server code in `spacetimedb/` directory + // This is not a requirement in general, but is a requirement for all templates + // i.e. `spacetime dev` is valid on non-templates. + let server_dir = template_config.project_path.join("spacetimedb"); + install_typescript_dependencies(&server_dir, pm)?; + } + + Ok(project_path) +} + +async fn get_template_config_non_interactive( + args: &ArgMatches, + project_name: String, + project_path: PathBuf, +) -> anyhow::Result { + // Check if template is provided + if let Some(template_str) = args.get_one::("template") { + // Check if it's a builtin template + let (_, templates) = fetch_templates_list().await?; + return create_template_config_from_template_str(project_name, project_path, template_str, &templates); + } + + // No template - require at least one language option + let server_lang_str = args.get_one::("lang").cloned(); + + if server_lang_str.is_none() { + anyhow::bail!("Either --template or --lang must be provided in non-interactive mode"); + } + + Ok(TemplateConfig { + project_name, + project_path, + template_type: TemplateType::Empty, + server_lang: parse_server_lang(&server_lang_str)?, + client_lang: None, + github_repo: None, + template_def: None, + use_local: true, + }) +} + +pub fn ensure_empty_directory(_project_name: &str, project_path: &Path, is_server_only: bool) -> anyhow::Result<()> { + if project_path.exists() { + if !project_path.is_dir() { + anyhow::bail!( + "Path {} exists but is not a directory. A new SpacetimeDB project must be initialized in an empty directory.", + project_path.display() + ); + } + + if std::fs::read_dir(project_path).unwrap().count() > 0 { + if is_server_only { + let server_dir = project_path.join("spacetimedb"); + if server_dir.exists() && std::fs::read_dir(server_dir).unwrap().count() > 0 { + anyhow::bail!( + "A SpacetimeDB module already exists in the target directory: {}", + project_path.display() + ); + } + } else { + anyhow::bail!( + "Cannot create new SpacetimeDB project in non-empty directory: {}", + project_path.display() + ); + } + } + } else { + fs::create_dir_all(project_path).context("Failed to create directory")?; + } + Ok(()) +} + +async fn get_template_config_interactive( + args: &ArgMatches, + project_name: String, + project_path: PathBuf, +) -> anyhow::Result { + let theme = ColorfulTheme::default(); + + // Check if template is provided + if let Some(template_str) = args.get_one::("template") { + println!("{} {}", "Template:".bold(), template_str); + + let (_, templates) = fetch_templates_list().await?; + return create_template_config_from_template_str(project_name, project_path, template_str, &templates); + } + + let server_lang_arg = args.get_one::("lang"); + if server_lang_arg.is_some() { + let server_lang = parse_server_lang(&server_lang_arg.cloned())?; + if let Some(lang_str) = server_lang_arg { + println!("{} {}", "Server language:".bold(), lang_str); + } + + return Ok(TemplateConfig { + project_name, + project_path, + template_type: TemplateType::Empty, + server_lang, + client_lang: None, + github_repo: None, + template_def: None, + use_local: true, + }); + } + + // Fully interactive mode - prompt for template/language selection + let (highlights, templates) = fetch_templates_list().await?; + + let mut client_choices: Vec = highlights + .iter() + .map(|h| { + let template = templates.iter().find(|t| t.id == h.template_id); + match template { + Some(t) => format!("{} - {}", h.name, t.description), + None => h.name.clone(), + } + }) + .collect(); + client_choices.push("Use Template - Choose from a list of built-in template projects or clone an existing SpacetimeDB project from GitHub".to_string()); + client_choices.push("None".to_string()); + + let client_selection = Select::with_theme(&theme) + .with_prompt("Select a client type for your project (you can add other clients later)") + .items(&client_choices) + .default(0) + .interact()?; + + let other_index = highlights.len(); + let none_index = highlights.len() + 1; + + if client_selection < highlights.len() { + let highlight = &highlights[client_selection]; + let template = templates + .iter() + .find(|t| t.id == highlight.template_id) + .ok_or_else(|| anyhow::anyhow!("Template {} not found", highlight.template_id))?; + + Ok(TemplateConfig { + project_name, + project_path, + template_type: TemplateType::Builtin, + server_lang: parse_server_lang(&template.server_lang)?, + client_lang: parse_client_lang(&template.client_lang)?, + github_repo: None, + template_def: Some(template.clone()), + use_local: true, + }) + } else if client_selection == other_index { + println!("\n{}", "Available built-in templates:".bold()); + for template in &templates { + println!(" {} - {}", template.id, template.description); + } + println!(); + + loop { + let template_id = Input::::with_theme(&theme) + .with_prompt("Template ID or GitHub repository (owner/repo) or git URL") + .interact_text()? + .trim() + .to_string(); + let template_config = create_template_config_from_template_str( + project_name.clone(), + project_path.clone(), + &template_id, + &templates, + ); + // If template_id looks like a builtin template ID (e.g. kebab-case, all lowercase, no slashes, alphanumeric and dashes only) + // then ensure that it is a valid builtin template ID, if not reprompt + let is_builtin_like = |s: &str| { + !s.is_empty() + && !s.contains('/') + && s.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + }; + if !is_builtin_like(&template_id) { + break template_config; + } + if templates.iter().any(|t| t.id == template_id) { + break template_config; + } + eprintln!( + "{}", + "Unrecognized format. Enter a built-in ID (e.g. \"rust-chat\"), a GitHub repo (\"owner/repo\"), or a git URL." + .bold() + ); + } + } else if client_selection == none_index { + // Ask for server language only + let server_lang_choices = vec!["Rust", "C#", "TypeScript"]; + let server_selection = Select::with_theme(&theme) + .with_prompt("Select server language") + .items(&server_lang_choices) + .default(0) + .interact()?; + + let server_lang = match server_selection { + 0 => Some(ServerLanguage::Rust), + 1 => Some(ServerLanguage::Csharp), + 2 => Some(ServerLanguage::TypeScript), + _ => unreachable!("Invalid server language selection"), + }; + + Ok(TemplateConfig { + project_name, + project_path, + template_type: TemplateType::Empty, + server_lang, + client_lang: None, + github_repo: None, + template_def: None, + use_local: true, + }) + } else { + unreachable!("Invalid selection index") + } +} + +fn clone_github_template(repo_input: &str, target: &Path, is_server_only: bool) -> anyhow::Result<()> { + let is_git_url = |s: &str| { + s.starts_with("git@") || s.starts_with("ssh://") || s.starts_with("http://") || s.starts_with("https://") + }; + + let repo_url = if is_git_url(repo_input) { + repo_input.to_string() + } else if repo_input.contains('/') { + format!("https://github.com/{}", repo_input) + } else { + anyhow::bail!("Invalid repository format. Use 'owner/repo' or full git clone URL"); + }; + + println!(" Cloning from {}...", repo_url); + + let temp_dir = tempfile::tempdir()?; + + let mut builder = git2::build::RepoBuilder::new(); + + let mut fetch_options = git2::FetchOptions::new(); + let mut callbacks = git2::RemoteCallbacks::new(); + + callbacks.credentials(|_url, username_from_url, allowed_types| { + if allowed_types.contains(git2::CredentialType::SSH_KEY) { + if let Some(username) = username_from_url { + return git2::Cred::ssh_key_from_agent(username); + } + } + if allowed_types.contains(git2::CredentialType::USER_PASS_PLAINTEXT) { + return git2::Cred::userpass_plaintext("", ""); + } + if allowed_types.contains(git2::CredentialType::DEFAULT) { + return git2::Cred::default(); + } + Err(git2::Error::from_str("no auth method available")) + }); + + fetch_options.remote_callbacks(callbacks); + builder.fetch_options(fetch_options); + + builder + .clone(&repo_url, temp_dir.path()) + .context("Failed to clone repository")?; + + if is_server_only { + let server_subdir = temp_dir.path().join("spacetimedb"); + let server_subdir_target = target.join("spacetimedb"); + copy_dir_all(&server_subdir, &server_subdir_target)?; + } else { + copy_dir_all(temp_dir.path(), target)?; + } + + Ok(()) +} + +fn copy_dir_all(src: &Path, dst: &Path) -> anyhow::Result<()> { + fs::create_dir_all(dst)?; + for entry in fs::read_dir(src)? { + let entry = entry?; + let ty = entry.file_type()?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + + if entry.file_name() == ".git" { + continue; + } + + if ty.is_dir() { + copy_dir_all(&src_path, &dst_path)?; + } else { + fs::copy(&src_path, &dst_path)?; + } + } + Ok(()) +} + +fn get_spacetimedb_typescript_version() -> &'static str { + embedded::get_typescript_bindings_version() +} + +fn update_package_json(dir: &Path, package_name: &str) -> anyhow::Result<()> { + let package_path = dir.join("package.json"); + if !package_path.exists() { + return Ok(()); + } + + let content = fs::read_to_string(&package_path)?; + let mut package: serde_json::Value = serde_json::from_str(&content)?; + + package["name"] = json!(package_name); + + // Update spacetimedb version if it exists in dependencies + if let Some(deps) = package.get_mut("dependencies") { + if deps.get("spacetimedb").is_some() { + deps["spacetimedb"] = json!(format!("^{}", get_spacetimedb_typescript_version())); + } + } + + let updated_content = serde_json::to_string_pretty(&package)?; + fs::write(package_path, updated_content)?; + + Ok(()) +} + +fn to_patch_wildcard(ver: &str) -> String { + let mut parts: Vec<&str> = ver.split('.').collect(); + if parts.len() >= 3 { + parts[2] = "*"; + } + parts.join(".") +} + +fn update_cargo_toml_name(dir: &Path, package_name: &str) -> anyhow::Result<()> { + let version = env!("CARGO_PKG_VERSION"); + let patch_wildcard = to_patch_wildcard(version); + let cargo_path = dir.join("Cargo.toml"); + if !cargo_path.exists() { + return Ok(()); + } + + let original = fs::read_to_string(&cargo_path)?; + let mut doc: DocumentMut = original.parse()?; + + let safe_name = package_name + .chars() + .map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' }) + .collect::(); + + if let Some(package_item) = doc.get_mut("package") { + if let Some(package_table) = package_item.as_table_mut() { + package_table["name"] = value(safe_name); + if let Some(edition_item) = package_table.get_mut("edition") { + if edition_uses_workspace(edition_item) { + *edition_item = value(embedded::get_workspace_edition()); + } + } + } + } + + if let Some(deps_item) = doc.get_mut("dependencies") { + if let Some(deps_table) = deps_item.as_table_mut() { + let keys: Vec = deps_table.iter().map(|(k, _)| k.to_string()).collect(); + for key in keys { + if let Some(dep_item) = deps_table.get_mut(&key) { + if dependency_uses_workspace(dep_item) { + if has_path(dep_item) { + if key == "spacetimedb" { + if let Some(version) = embedded::get_workspace_dependency_version(&key) { + set_dependency_version(dep_item, version, true); + } + } else if key == "spacetimedb-sdk" { + set_dependency_version(dep_item, patch_wildcard.as_str(), true); + } + continue; + } + + if uses_workspace(dep_item) { + if let Some(version) = embedded::get_workspace_dependency_version(&key) { + set_dependency_version(dep_item, version, key == "spacetimedb"); + } + } + } + } + } + } + } + + let updated = doc.to_string(); + if updated != original { + fs::write(cargo_path, updated)?; + } + Ok(()) +} + +pub fn update_csproj_server_to_nuget(dir: &Path) -> anyhow::Result<()> { + if let Some(csproj_path) = find_first_csproj(dir)? { + let original = + fs::read_to_string(&csproj_path).with_context(|| format!("reading {}", csproj_path.display()))?; + let mut root: Element = + Element::parse(original.as_bytes()).with_context(|| format!("parsing xml {}", csproj_path.display()))?; + + upsert_packageref( + &mut root, + "SpacetimeDB.Runtime", + &get_spacetimedb_csharp_runtime_version(), + ); + remove_all_project_references(&mut root); + + write_if_changed(csproj_path, original, root)?; + } + Ok(()) +} + +pub fn update_csproj_client_to_nuget(dir: &Path) -> anyhow::Result<()> { + if let Some(csproj_path) = find_first_csproj(dir)? { + let original = + fs::read_to_string(&csproj_path).with_context(|| format!("reading {}", csproj_path.display()))?; + let mut root: Element = + Element::parse(original.as_bytes()).with_context(|| format!("parsing xml {}", csproj_path.display()))?; + + upsert_packageref( + &mut root, + "SpacetimeDB.ClientSDK", + &get_spacetimedb_csharp_clientsdk_version(), + ); + remove_all_project_references(&mut root); + + write_if_changed(csproj_path, original, root)?; + } + Ok(()) +} + +// Helpers + +fn write_if_changed(path: PathBuf, original: String, root: Element) -> anyhow::Result<()> { + let mut out = Vec::new(); + root.write(&mut out)?; + let compact = String::from_utf8(out)?; + let updated = pretty_format_xml(&compact)?; + if updated != original { + fs::write(&path, updated).with_context(|| format!("writing {}", path.display()))?; + } + Ok(()) +} + +fn find_first_csproj(dir: &Path) -> anyhow::Result> { + if !dir.is_dir() { + return Ok(None); + } + for entry in fs::read_dir(dir)? { + let p = entry?.path(); + if p.extension().map(|e| e == "csproj").unwrap_or(false) { + return Ok(Some(p)); + } + } + Ok(None) +} + +/// Remove every under any +fn remove_all_project_references(project: &mut Element) { + for node in project.children.iter_mut() { + if let XMLNode::Element(item_group) = node { + if item_group.name == "ItemGroup" { + item_group + .children + .retain(|n| !matches!(n, XMLNode::Element(el) if el.name == "ProjectReference")); + } + } + } + // Optional: prune empty ItemGroups + project.children.retain(|n| { + if let XMLNode::Element(el) = n { + if el.name == "ItemGroup" { + return el.children.iter().any(|c| matches!(c, XMLNode::Element(_))); + } + } + true + }); +} + +/// Insert or update +fn upsert_packageref(project: &mut Element, include: &str, version: &str) { + // Try to find an existing PackageReference + for node in project.children.iter_mut() { + if let XMLNode::Element(item_group) = node { + if item_group.name == "ItemGroup" { + if let Some(XMLNode::Element(existing)) = item_group.children.iter_mut().find(|n| { + matches!(n, + XMLNode::Element(e) + if e.name == "PackageReference" + && e.attributes.get("Include").map(|v| v == include).unwrap_or(false) + ) + }) { + existing.attributes.insert("Version".to_string(), version.to_string()); + return; + } + } + } + } + // Otherwise create one in (or create) an ItemGroup + let item_group = get_or_create_direct_child(project, "ItemGroup"); + let mut pr = Element::new("PackageReference"); + pr.attributes.insert("Include".into(), include.to_string()); + pr.attributes.insert("Version".into(), version.to_string()); + item_group.children.push(XMLNode::Element(pr)); +} + +fn get_or_create_direct_child<'a>(parent: &'a mut Element, name: &str) -> &'a mut Element { + // First, scan IMMUTABLY to find the index of an existing child. + if let Some(idx) = parent.children.iter().enumerate().find_map(|(i, n)| match n { + XMLNode::Element(e) if e.name == name => Some(i), + _ => None, + }) { + // Now borrow MUTABLY by index. + if let XMLNode::Element(el) = &mut parent.children[idx] { + return el; + } + unreachable!("Matched non-element while checking by name"); + } + + // Not found: create, then borrow by index. + parent.children.push(XMLNode::Element(Element::new(name))); + let idx = parent.children.len() - 1; + match &mut parent.children[idx] { + XMLNode::Element(el) => el, + _ => unreachable!("just pushed an Element"), + } +} + +/// Pretty-print XML with indentation. +/// Keeps UTF-8 declaration if present. +fn pretty_format_xml(xml: &str) -> anyhow::Result { + use quick_xml::events::Event; + use quick_xml::Reader; + use quick_xml::Writer; + use std::io::Cursor; + + let mut reader = Reader::from_str(xml); + reader.trim_text(true); + let mut buf = Vec::new(); + let mut writer = Writer::new_with_indent(Cursor::new(Vec::new()), b' ', 2); + + loop { + match reader.read_event_into(&mut buf)? { + Event::Eof => break, + e => writer.write_event(e)?, + } + buf.clear(); + } + + let result = writer.into_inner().into_inner(); + Ok(String::from_utf8(result)?) +} + +/// Just do 1.* for now +fn get_spacetimedb_csharp_runtime_version() -> String { + "1.*".to_string() +} + +fn get_spacetimedb_csharp_clientsdk_version() -> String { + "1.*".to_string() +} + +/// Writes a `.env.local` file that includes all common +/// frontend environment variable variants for SpacetimeDB. +fn write_typescript_client_env_file(client_dir: &Path, module_name: &str, use_local: bool) -> anyhow::Result<()> { + let env_path = client_dir.join(".env.local"); + + let db_name = module_name; + let host = if use_local { + "ws://localhost:3000" + } else { + "wss://maincloud.spacetimedb.com" + }; + + // Framework-agnostic variants + let env_content = format!( + "\ +# Generic / backend +SPACETIMEDB_DB_NAME={db_name} +SPACETIMEDB_HOST={host} + +# Vite +VITE_SPACETIMEDB_DB_NAME={db_name} +VITE_SPACETIMEDB_HOST={host} + +# Next.js +NEXT_PUBLIC_SPACETIMEDB_DB_NAME={db_name} +NEXT_PUBLIC_SPACETIMEDB_HOST={host} + +# Create React App +REACT_APP_SPACETIMEDB_DB_NAME={db_name} +REACT_APP_SPACETIMEDB_HOST={host} + +# Expo +EXPO_PUBLIC_SPACETIMEDB_DB_NAME={db_name} +EXPO_PUBLIC_SPACETIMEDB_HOST={host} + +# SvelteKit +PUBLIC_SPACETIMEDB_DB_NAME={db_name} +PUBLIC_SPACETIMEDB_HOST={host} +" + ); + + fs::write(&env_path, env_content)?; + + println!("✅ Wrote environment configuration to {}", env_path.display()); + Ok(()) +} + +pub async fn init_from_template( + config: &TemplateConfig, + project_path: &Path, + is_server_only: bool, +) -> anyhow::Result<()> { + println!("{}", "Initializing project from template...".cyan()); + + match config.template_type { + TemplateType::Builtin => init_builtin(config, project_path, is_server_only)?, + TemplateType::GitHub => init_github_template(config, project_path, is_server_only)?, + TemplateType::Empty => init_empty(config, project_path)?, + } + + let cursorrules_content = embedded::get_cursorrules(); + let cursorrules_path = project_path.join(".cursor/rules/spacetimedb.mdc"); + fs::create_dir_all(cursorrules_path.parent().unwrap())?; + fs::write(cursorrules_path, cursorrules_content)?; + + println!("{}", "Project initialized successfully!".green()); + print_next_steps(config, project_path)?; + + Ok(()) +} + +fn init_builtin(config: &TemplateConfig, project_path: &Path, is_server_only: bool) -> anyhow::Result<()> { + let template_def = config + .template_def + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Template definition missing"))?; + + let template_files = embedded::get_template_files(); + + if !is_server_only { + println!( + "Setting up client ({})...", + config.client_lang.map(|l| l.as_str()).unwrap_or("none") + ); + let client_source = &template_def.client_source; + if let Some(files) = template_files.get(client_source.as_str()) { + copy_embedded_files(files, project_path)?; + } else { + anyhow::bail!("Client template not found: {}", client_source); + } + + // Update client name + match config.client_lang { + Some(ClientLanguage::TypeScript) => { + update_package_json(project_path, &config.project_name)?; + write_typescript_client_env_file(project_path, &config.project_name, config.use_local)?; + println!( + "{}", + "Note: Run 'npm install' in the project directory to install dependencies".yellow() + ); + } + Some(ClientLanguage::Rust) => { + update_cargo_toml_name(project_path, &config.project_name)?; + } + Some(ClientLanguage::Csharp) => { + update_csproj_client_to_nuget(project_path)?; + } + None => {} + } + } + + println!( + "Setting up server ({})...", + config.server_lang.map(|l| l.as_str()).unwrap_or("none") + ); + let server_dir = project_path.join("spacetimedb"); + let server_source = &template_def.server_source; + if let Some(files) = template_files.get(server_source.as_str()) { + copy_embedded_files(files, &server_dir)?; + } else { + anyhow::bail!("Server template not found: {}", server_source); + } + + // Update server name + match config.server_lang { + Some(ServerLanguage::TypeScript) => { + update_package_json(&server_dir, &config.project_name)?; + } + Some(ServerLanguage::Rust) => { + update_cargo_toml_name(&server_dir, &config.project_name)?; + } + Some(ServerLanguage::Csharp) => { + update_csproj_server_to_nuget(&server_dir)?; + } + None => {} + } + + Ok(()) +} + +fn copy_embedded_files(files: &HashMap<&str, &str>, target_dir: &Path) -> anyhow::Result<()> { + for (file_path, content) in files { + let full_path = target_dir.join(file_path); + if let Some(parent) = full_path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&full_path, content)?; + } + Ok(()) +} + +fn init_github_template(config: &TemplateConfig, project_path: &Path, is_server_only: bool) -> anyhow::Result<()> { + let repo = config.github_repo.as_ref().unwrap(); + clone_github_template(repo, project_path, is_server_only)?; + + let package_path = project_path.join("package.json"); + if package_path.exists() { + let content = fs::read_to_string(&package_path)?; + let mut package: serde_json::Value = serde_json::from_str(&content)?; + package["name"] = json!(config.project_name.clone()); + let updated_content = serde_json::to_string_pretty(&package)?; + fs::write(package_path, updated_content)?; + } + + println!("{}", "Note: Custom templates require manual configuration.".yellow()); + + Ok(()) +} + +fn init_empty(config: &TemplateConfig, project_path: &Path) -> anyhow::Result<()> { + match config.server_lang { + Some(ServerLanguage::Rust) => { + println!("Setting up Rust server..."); + let server_dir = project_path.join("spacetimedb"); + init_empty_rust_server(&server_dir, &config.project_name)?; + } + Some(ServerLanguage::Csharp) => { + println!("Setting up C# server..."); + let server_dir = project_path.join("spacetimedb"); + init_empty_csharp_server(&server_dir, &config.project_name)?; + } + Some(ServerLanguage::TypeScript) => { + println!("Setting up TypeScript server..."); + let server_dir = project_path.join("spacetimedb"); + init_empty_typescript_server(&server_dir, &config.project_name)?; + } + None => {} + } + + Ok(()) +} + +fn init_empty_rust_server(server_dir: &Path, project_name: &str) -> anyhow::Result<()> { + init_rust_project(server_dir)?; + update_cargo_toml_name(server_dir, project_name)?; + Ok(()) +} + +fn init_empty_csharp_server(server_dir: &Path, _project_name: &str) -> anyhow::Result<()> { + init_csharp_project(server_dir) +} + +fn init_empty_typescript_server(server_dir: &Path, project_name: &str) -> anyhow::Result<()> { + init_typescript_project(server_dir)?; + update_package_json(server_dir, project_name)?; + Ok(()) +} + +fn print_next_steps(config: &TemplateConfig, _project_path: &Path) -> anyhow::Result<()> { + println!(); + println!("{}", "Next steps:".bold()); + + let rel_path = config + .project_path + .strip_prefix(std::env::current_dir()?) + .unwrap_or(&config.project_path); + + if rel_path != Path::new(".") && rel_path != Path::new("") { + println!(" cd {}", rel_path.display()); + } + + match (config.template_type, config.server_lang, config.client_lang) { + (TemplateType::Builtin, Some(ServerLanguage::Rust), Some(ClientLanguage::Rust)) => { + println!( + " spacetime publish --project-path spacetimedb {}{}", + if config.use_local { "--server local " } else { "" }, + config.project_name + ); + println!(" spacetime generate --lang rust --out-dir src/module_bindings --project-path spacetimedb"); + println!(" cargo run"); + } + (TemplateType::Builtin, Some(ServerLanguage::TypeScript), Some(ClientLanguage::TypeScript)) => { + println!(" npm install"); + println!( + " spacetime publish --project-path spacetimedb {}{}", + if config.use_local { "--server local " } else { "" }, + config.project_name + ); + println!(" spacetime generate --lang typescript --out-dir src/module_bindings --project-path spacetimedb"); + println!(" npm run dev"); + } + (TemplateType::Builtin, Some(ServerLanguage::Csharp), Some(ClientLanguage::Csharp)) => { + println!( + " spacetime publish --project-path spacetimedb {}{}", + if config.use_local { "--server local " } else { "" }, + config.project_name + ); + println!(" spacetime generate --lang csharp --out-dir module_bindings --project-path spacetimedb"); + } + (TemplateType::Empty, _, Some(ClientLanguage::TypeScript)) => { + println!(" npm install"); + if config.server_lang.is_some() { + println!( + " spacetime publish --project-path spacetimedb {}{}", + if config.use_local { "--server local " } else { "" }, + config.project_name + ); + println!( + " spacetime generate --lang typescript --out-dir src/module_bindings --project-path spacetimedb" + ); + } + println!(" npm run dev"); + } + (TemplateType::Empty, _, Some(ClientLanguage::Rust)) => { + if config.server_lang.is_some() { + println!( + " spacetime publish --project-path spacetimedb {}{}", + if config.use_local { "--server local " } else { "" }, + config.project_name + ); + println!(" spacetime generate --lang rust --out-dir src/module_bindings --project-path spacetimedb"); + } + println!(" cargo run"); + } + (_, _, _) => { + println!(" # Follow the template's README for setup instructions"); + } + } + + println!(); + println!("Learn more: {}", "https://spacetimedb.com/docs".cyan()); + + Ok(()) } fn check_for_cargo() -> bool { @@ -113,119 +1452,128 @@ fn check_for_git() -> bool { false } -pub async fn exec(_config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { - eprintln!("{UNSTABLE_WARNING}\n"); +pub async fn exec(mut config: Config, args: &ArgMatches) -> anyhow::Result { + println!("{UNSTABLE_WARNING}\n"); - let project_path = args.get_one::("project-path").unwrap(); - let project_lang = *args.get_one::("lang").unwrap(); + let is_interactive = !args.get_flag("non-interactive"); + let template = args.get_one::("template"); + let server_lang = args.get_one::("lang"); + let project_name_arg = args.get_one::("project-name"); - // Create the project path, or make sure the target project path is empty. - if project_path.exists() { - if !project_path.is_dir() { - return Err(anyhow::anyhow!( - "Path {} exists but is not a directory. A new SpacetimeDB project must be initialized in an empty directory.", - project_path.display() - )); - } + // Validate that template and lang options are not used together + if template.is_some() && server_lang.is_some() { + anyhow::bail!("Cannot specify both --template and --lang. Language is determined by the template."); + } - if std::fs::read_dir(project_path).unwrap().count() > 0 { - return Err(anyhow::anyhow!( - "Cannot create new SpacetimeDB project in non-empty directory: {}", - project_path.display() - )); + if !is_interactive { + // In non-interactive mode, validate all required args are present + if project_name_arg.is_none() { + anyhow::bail!("PROJECT_NAME is required in non-interactive mode"); + } + if template.is_none() && server_lang.is_none() { + anyhow::bail!("Either --template or --lang must be provided in non-interactive mode"); } - } else { - create_directory(project_path)?; } - match project_lang { - ModuleLanguage::Rust => exec_init_rust(args).await, - ModuleLanguage::Csharp => exec_init_csharp(args).await, - ModuleLanguage::Javascript => exec_init_typescript(args).await, - } + exec_init(&mut config, args, is_interactive).await } -pub async fn exec_init_rust(args: &ArgMatches) -> Result<(), anyhow::Error> { - let project_path = args.get_one::("project-path").unwrap(); - +pub fn init_rust_project(project_path: &Path) -> anyhow::Result<()> { let export_files = vec![ - (include_str!("project/rust/Cargo._toml"), "Cargo.toml"), - (include_str!("project/rust/lib._rs"), "src/lib.rs"), - (include_str!("project/rust/_gitignore"), ".gitignore"), - (include_str!("project/rust/config._toml"), ".cargo/config.toml"), + ( + include_str!("../../templates/basic-rust/server/Cargo.toml"), + "Cargo.toml", + ), + ( + include_str!("../../templates/basic-rust/server/src/lib.rs"), + "src/lib.rs", + ), + ( + include_str!("../../templates/basic-rust/server/.gitignore"), + ".gitignore", + ), + ( + include_str!("../../templates/basic-rust/server/.cargo/config.toml"), + ".cargo/config.toml", + ), ]; for data_file in export_files { let path = project_path.join(data_file.1); - create_directory(path.parent().unwrap())?; - std::fs::write(path, data_file.0)?; } - // Check all dependencies check_for_cargo(); check_for_git(); - println!( - "{}", - format!("Project successfully created at path: {}", project_path.display()).green() - ); - Ok(()) } -pub async fn exec_init_csharp(args: &ArgMatches) -> anyhow::Result<()> { - let project_path = args.get_one::("project-path").unwrap(); - +pub fn init_csharp_project(project_path: &Path) -> anyhow::Result<()> { let export_files = vec![ - (include_str!("project/csharp/StdbModule._csproj"), "StdbModule.csproj"), - (include_str!("project/csharp/Lib._cs"), "Lib.cs"), - (include_str!("project/csharp/_gitignore"), ".gitignore"), - (include_str!("project/csharp/global._json"), "global.json"), + ( + include_str!("../../templates/basic-c-sharp/server/StdbModule.csproj"), + "StdbModule.csproj", + ), + (include_str!("../../templates/basic-c-sharp/server/Lib.cs"), "Lib.cs"), + ( + include_str!("../../templates/basic-c-sharp/server/.gitignore"), + ".gitignore", + ), + ( + include_str!("../../templates/basic-c-sharp/server/global.json"), + "global.json", + ), ]; - // Check all dependencies check_for_dotnet(); check_for_git(); for data_file in export_files { let path = project_path.join(data_file.1); - create_directory(path.parent().unwrap())?; - std::fs::write(path, data_file.0)?; } - println!( - "{}", - format!("Project successfully created at path: {}", project_path.display()).green() - ); - Ok(()) } -pub async fn exec_init_typescript(args: &ArgMatches) -> anyhow::Result<()> { - let project_path = args.get_one::("project-path").unwrap(); - +pub fn init_typescript_project(project_path: &Path) -> anyhow::Result<()> { let export_files = vec![ - (include_str!("project/typescript/package._json"), "package.json"), - (include_str!("project/typescript/tsconfig._json"), "tsconfig.json"), - (include_str!("project/typescript/index._ts"), "src/index.ts"), - (include_str!("project/typescript/_gitignore"), ".gitignore"), + ( + include_str!("../../templates/basic-typescript/server/package.json"), + "package.json", + ), + ( + include_str!("../../templates/basic-typescript/server/tsconfig.json"), + "tsconfig.json", + ), + ( + include_str!("../../templates/basic-typescript/server/src/index.ts"), + "src/index.ts", + ), + ( + include_str!("../../templates/basic-typescript/server/.gitignore"), + ".gitignore", + ), ]; - // Check all dependencies check_for_git(); - for (contents, file) in export_files { - let path = project_path.join(file); - + for data_file in export_files { + let path = project_path.join(data_file.1); create_directory(path.parent().unwrap())?; - - std::fs::write(path, contents)?; + std::fs::write(path, data_file.0)?; } + Ok(()) +} + +pub async fn exec_init_rust(args: &ArgMatches) -> anyhow::Result<()> { + let project_path = args.get_one::("project-path").unwrap(); + init_rust_project(project_path)?; + println!( "{}", format!("Project successfully created at path: {}", project_path.display()).green() @@ -234,6 +1582,93 @@ pub async fn exec_init_typescript(args: &ArgMatches) -> anyhow::Result<()> { Ok(()) } -fn create_directory(path: &Path) -> Result<(), anyhow::Error> { +pub async fn exec_init_csharp(args: &ArgMatches) -> anyhow::Result<()> { + let project_path = args.get_one::("project-path").unwrap(); + init_csharp_project(project_path)?; + + println!( + "{}", + format!("Project successfully created at path: {}", project_path.display()).green() + ); + + Ok(()) +} + +fn create_directory(path: &Path) -> anyhow::Result<()> { std::fs::create_dir_all(path).context("Failed to create directory") } + +pub fn parse_server_lang(lang: &Option) -> anyhow::Result> { + match lang.as_deref() { + Some(s) => Ok(ServerLanguage::from_str(s)?), + None => Ok(None), + } +} + +pub fn parse_client_lang(lang: &Option) -> anyhow::Result> { + match lang.as_deref() { + Some(s) => Ok(ClientLanguage::from_str(s)?), + None => Ok(None), + } +} + +fn edition_uses_workspace(item: &Item) -> bool { + match item { + Item::Value(val) => val + .as_inline_table() + .map(|table| table.get("workspace").is_some()) + .unwrap_or(false), + Item::Table(table) => table.get("workspace").is_some(), + _ => false, + } +} + +fn dependency_uses_workspace(item: &Item) -> bool { + uses_workspace(item) || has_path(item) +} + +fn uses_workspace(item: &Item) -> bool { + match item { + Item::Value(val) => val + .as_inline_table() + .map(|table| table.get("workspace").is_some()) + .unwrap_or(false), + Item::Table(table) => table.get("workspace").is_some(), + _ => false, + } +} + +fn has_path(item: &Item) -> bool { + match item { + Item::Value(val) => val + .as_inline_table() + .map(|table| table.get("path").is_some()) + .unwrap_or(false), + Item::Table(table) => table.get("path").is_some(), + _ => false, + } +} + +fn set_dependency_version(item: &mut Item, version: &str, remove_path: bool) { + if let Item::Value(val) = item { + if let Some(inline) = val.as_inline_table_mut() { + inline.remove("workspace"); + if remove_path { + inline.remove("path"); + } + inline.insert("version", toml_edit::Value::from(version.to_string())); + return; + } + } + + if let Item::Table(table) = item { + table.remove("workspace"); + if remove_path { + table.remove("path"); + } + table["version"] = value(version.to_string()); + return; + } + + *item = value(version.to_string()); +} diff --git a/crates/cli/src/subcommands/project/.spacetime._toml b/crates/cli/src/subcommands/project/.spacetime._toml deleted file mode 100644 index b56003fb4a5..00000000000 --- a/crates/cli/src/subcommands/project/.spacetime._toml +++ /dev/null @@ -1,3 +0,0 @@ -host = '' -identity = '' -address = '' \ No newline at end of file diff --git a/crates/cli/src/subcommands/project/csharp/global._json b/crates/cli/src/subcommands/project/csharp/global._json deleted file mode 120000 index c246c932c31..00000000000 --- a/crates/cli/src/subcommands/project/csharp/global._json +++ /dev/null @@ -1 +0,0 @@ -../../../../../../global.json \ No newline at end of file diff --git a/crates/cli/src/tasks/javascript.rs b/crates/cli/src/tasks/javascript.rs index 71b136e8b54..22573e52111 100644 --- a/crates/cli/src/tasks/javascript.rs +++ b/crates/cli/src/tasks/javascript.rs @@ -39,7 +39,7 @@ pub(crate) fn build_javascript(project_path: &Path, build_debug: bool) -> anyhow let cwd = fs::canonicalize(project_path)?; let mut bundler = Bundler::new(BundlerOptions { input: Some(vec!["./src/index.ts".to_string().into()]), - cwd: Some(cwd), + cwd: Some(cwd.clone()), sourcemap: Some(SourceMapType::Inline), external: Some(rolldown::IsExternal::StringOrRegex(vec![ // Mark the bindings as external so we don't get a warning. @@ -162,7 +162,7 @@ pub(crate) fn build_javascript(project_path: &Path, build_debug: bool) -> anyhow top_level_var: Some(false), // This is the safer choice since we'll keep vars scoped to modules minify_internal_exports: Some(true), // Sure context: None, // We don't want a top level `this` in modules - tsconfig: Some(project_path.join("tsconfig.json").to_string_lossy().into_owned()), + tsconfig: Some(cwd.join("tsconfig.json").to_string_lossy().into_owned()), })?; let bundle_output = run_blocking(async move { bundler.write().await })?; diff --git a/crates/cli/templates/basic-c-sharp/client/.gitignore b/crates/cli/templates/basic-c-sharp/client/.gitignore new file mode 100644 index 00000000000..7a73d109b63 --- /dev/null +++ b/crates/cli/templates/basic-c-sharp/client/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +module_bindings/ diff --git a/crates/cli/templates/basic-c-sharp/client/Program.cs b/crates/cli/templates/basic-c-sharp/client/Program.cs new file mode 100644 index 00000000000..33c4941ae93 --- /dev/null +++ b/crates/cli/templates/basic-c-sharp/client/Program.cs @@ -0,0 +1,78 @@ +using System; +using System.Threading; +using SpacetimeDB; +using SpacetimeDB.Types; + +/// The URI of the SpacetimeDB instance hosting our chat module. +string HOST = Environment.GetEnvironmentVariable("SPACETIMEDB_HOST") ?? "http://localhost:3000"; + +/// The module name we chose when we published our module. +string DB_NAME = Environment.GetEnvironmentVariable("SPACETIMEDB_DB_NAME") ?? "my-db"; + +void Main() +{ + // Initialize the AuthToken module + AuthToken.Init(".spacetime_csharp"); + + // Build and connect to the database + var conn = DbConnection.Builder() + .WithUri(HOST) + .WithModuleName(DB_NAME) + .WithToken(AuthToken.Token) + .OnConnect(OnConnected) + .OnConnectError(OnConnectError) + .OnDisconnect(OnDisconnected) + .Build(); + + // Keep the connection alive and process updates + try + { + while (true) + { + conn.FrameTick(); + Thread.Sleep(100); + } + } + finally + { + conn.Disconnect(); + } +} + +void OnConnected(DbConnection conn, Identity identity, string authToken) +{ + Console.WriteLine($"Connected to {DB_NAME}"); + Console.WriteLine($"Identity: {identity}"); + + // Save credentials for future sessions + AuthToken.SaveToken(authToken); + + // Subscribe to all tables to receive updates + conn.SubscriptionBuilder() + .OnApplied(OnSubscriptionApplied) + .SubscribeToAllTables(); +} + +void OnConnectError(Exception e) +{ + Console.WriteLine($"Connection error: {e.Message}"); +} + +void OnDisconnected(DbConnection conn, Exception? e) +{ + if (e != null) + { + Console.WriteLine($"Disconnected with error: {e.Message}"); + } + else + { + Console.WriteLine("Disconnected"); + } +} + +void OnSubscriptionApplied(SubscriptionEventContext ctx) +{ + Console.WriteLine("Subscription applied - ready to receive updates"); +} + +Main(); diff --git a/crates/cli/templates/basic-c-sharp/client/client.csproj b/crates/cli/templates/basic-c-sharp/client/client.csproj new file mode 100644 index 00000000000..66352fee999 --- /dev/null +++ b/crates/cli/templates/basic-c-sharp/client/client.csproj @@ -0,0 +1,22 @@ + + + + Exe + net8.0 + false + disable + enable + + false + true + + + + + + + + + + + diff --git a/crates/cli/src/subcommands/project/csharp/_gitignore b/crates/cli/templates/basic-c-sharp/server/.gitignore similarity index 100% rename from crates/cli/src/subcommands/project/csharp/_gitignore rename to crates/cli/templates/basic-c-sharp/server/.gitignore diff --git a/crates/cli/src/subcommands/project/csharp/Lib._cs b/crates/cli/templates/basic-c-sharp/server/Lib.cs similarity index 100% rename from crates/cli/src/subcommands/project/csharp/Lib._cs rename to crates/cli/templates/basic-c-sharp/server/Lib.cs diff --git a/crates/cli/src/subcommands/project/csharp/StdbModule._csproj b/crates/cli/templates/basic-c-sharp/server/StdbModule.csproj similarity index 100% rename from crates/cli/src/subcommands/project/csharp/StdbModule._csproj rename to crates/cli/templates/basic-c-sharp/server/StdbModule.csproj diff --git a/crates/cli/templates/basic-c-sharp/server/global.json b/crates/cli/templates/basic-c-sharp/server/global.json new file mode 100644 index 00000000000..4e550c173fd --- /dev/null +++ b/crates/cli/templates/basic-c-sharp/server/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "8.0.400", + "rollForward": "latestMinor" + } +} diff --git a/crates/cli/templates/basic-rust/client/Cargo.toml b/crates/cli/templates/basic-rust/client/Cargo.toml new file mode 100644 index 00000000000..35168a60fd8 --- /dev/null +++ b/crates/cli/templates/basic-rust/client/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "spacetimedb-client" +version = "0.1.0" +edition = "2021" + +[dependencies] +spacetimedb-sdk = "1.6.*" diff --git a/crates/cli/templates/basic-rust/client/README.md b/crates/cli/templates/basic-rust/client/README.md new file mode 100644 index 00000000000..968af7be799 --- /dev/null +++ b/crates/cli/templates/basic-rust/client/README.md @@ -0,0 +1,15 @@ +# SpacetimeDB Rust Client + +A basic Rust client for SpacetimeDB. + +## Setup + +1. Build and publish your server module +2. Generate bindings: + ``` + spacetime generate --lang rust --out-dir src/module_bindings + ``` +3. Run the client: + ``` + cargo run + ``` diff --git a/crates/cli/templates/basic-rust/client/src/main.rs b/crates/cli/templates/basic-rust/client/src/main.rs new file mode 100644 index 00000000000..314d10e4a70 --- /dev/null +++ b/crates/cli/templates/basic-rust/client/src/main.rs @@ -0,0 +1,41 @@ +mod module_bindings; +use module_bindings::*; +use std::env; + +use spacetimedb_sdk::{DbConnection, Table}; + +fn main() { + // The URI of the SpacetimeDB instance hosting our chat module. + let host: String = env::var("SPACETIMEDB_HOST").unwrap_or("http://localhost:3000".to_string()); + + // The module name we chose when we published our module. + let db_name: String = env::var("SPACETIMEDB_DB_NAME").unwrap_or("my-db".to_string()); + + // Connect to the database + let conn = DbConnection::builder() + .with_module_name(db_name) + .with_host(host) + .on_connect(|_, _, _| { + println!("Connected to SpacetimeDB"); + }) + .on_connect_error(|e| { + eprintln!("Connection error: {:?}", e); + std::process::exit(1); + }) + .build() + .expect("Failed to connect"); + + // Subscribe to the person table + conn.subscribe(&[ + "SELECT * FROM person" + ]); + + // Register a callback for when rows are inserted into the person table + Person::on_insert(|_ctx, person| { + println!("New person: {}", person.name); + }); + + // Run the connection on the current thread + // This will block and handle all database events + conn.run(); +} diff --git a/crates/cli/src/subcommands/project/rust/config._toml b/crates/cli/templates/basic-rust/server/.cargo/config.toml similarity index 100% rename from crates/cli/src/subcommands/project/rust/config._toml rename to crates/cli/templates/basic-rust/server/.cargo/config.toml diff --git a/crates/cli/src/subcommands/project/rust/_gitignore b/crates/cli/templates/basic-rust/server/.gitignore similarity index 97% rename from crates/cli/src/subcommands/project/rust/_gitignore rename to crates/cli/templates/basic-rust/server/.gitignore index 31b13f058aa..264a779a3f8 100644 --- a/crates/cli/src/subcommands/project/rust/_gitignore +++ b/crates/cli/templates/basic-rust/server/.gitignore @@ -14,4 +14,4 @@ Cargo.lock *.pdb # Spacetime ignore -/.spacetime \ No newline at end of file +/.spacetime diff --git a/crates/cli/src/subcommands/project/rust/Cargo._toml b/crates/cli/templates/basic-rust/server/Cargo.toml similarity index 100% rename from crates/cli/src/subcommands/project/rust/Cargo._toml rename to crates/cli/templates/basic-rust/server/Cargo.toml diff --git a/crates/cli/src/subcommands/project/rust/lib._rs b/crates/cli/templates/basic-rust/server/src/lib.rs similarity index 97% rename from crates/cli/src/subcommands/project/rust/lib._rs rename to crates/cli/templates/basic-rust/server/src/lib.rs index b5477b73c98..814d93a9e54 100644 --- a/crates/cli/src/subcommands/project/rust/lib._rs +++ b/crates/cli/templates/basic-rust/server/src/lib.rs @@ -2,7 +2,7 @@ use spacetimedb::{ReducerContext, Table}; #[spacetimedb::table(name = person)] pub struct Person { - name: String + name: String, } #[spacetimedb::reducer(init)] diff --git a/crates/cli/templates/basic-typescript/server/.gitignore b/crates/cli/templates/basic-typescript/server/.gitignore new file mode 100644 index 00000000000..e69de29bb2d diff --git a/crates/cli/templates/basic-typescript/server/package.json b/crates/cli/templates/basic-typescript/server/package.json new file mode 100644 index 00000000000..47be347a920 --- /dev/null +++ b/crates/cli/templates/basic-typescript/server/package.json @@ -0,0 +1,15 @@ +{ + "name": "spacetime-module", + "version": "1.0.0", + "description": "", + "scripts": { + "build": "spacetime build", + "publish": "spacetime publish" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "spacetimedb": "1.6.*" + } +} \ No newline at end of file diff --git a/crates/cli/templates/basic-typescript/server/src/index.ts b/crates/cli/templates/basic-typescript/server/src/index.ts new file mode 100644 index 00000000000..0142eefd44f --- /dev/null +++ b/crates/cli/templates/basic-typescript/server/src/index.ts @@ -0,0 +1,33 @@ +import { schema, table, t } from 'spacetimedb/server'; + +export const spacetimedb = schema( + table( + { name: 'person' }, + { + name: t.string(), + } + ) +); + +spacetimedb.reducer('init', (_ctx) => { + // Called when the module is initially published +}); + +spacetimedb.reducer('client_connected', (_ctx) => { + // Called every time a new client connects +}); + +spacetimedb.reducer('client_disconnected', (_ctx) => { + // Called every time a client disconnects +}); + +spacetimedb.reducer('add', { name: t.string() }, (ctx, { name }) => { + ctx.db.person.insert({ name }); +}); + +spacetimedb.reducer('say_hello', (ctx) => { + for (const person of ctx.db.person.iter()) { + console.info(`Hello, ${person.name}!`); + } + console.info('Hello, World!'); +}); \ No newline at end of file diff --git a/crates/cli/templates/basic-typescript/server/tsconfig.json b/crates/cli/templates/basic-typescript/server/tsconfig.json new file mode 100644 index 00000000000..77c6124cb16 --- /dev/null +++ b/crates/cli/templates/basic-typescript/server/tsconfig.json @@ -0,0 +1,24 @@ + +/* + * This tsconfig is used for TypeScript projects created with `spacetimedb init + * --lang typescript`. You can modify it as needed for your project, although + * some options are required by SpacetimeDB. + */ +{ + "compilerOptions": { + "strict": true, + "skipLibCheck": true, + "moduleResolution": "bundler", + "jsx": "react-jsx", + + /* The following options are required by SpacetimeDB + * and should not be modified + */ + "target": "ESNext", + "lib": ["ES2021", "dom"], + "module": "ESNext", + "isolatedModules": true, + "noEmit": true + }, + "include": ["./**/*"] +} \ No newline at end of file diff --git a/crates/cli/templates/templates-list.json b/crates/cli/templates/templates-list.json new file mode 100644 index 00000000000..ab6602744b2 --- /dev/null +++ b/crates/cli/templates/templates-list.json @@ -0,0 +1,63 @@ +{ + "highlights": [ + { "name": "React", "template_id": "basic-react" } + ], + "templates": [ + { + "id": "basic-typescript", + "description": "A basic TypeScript client and server template with only stubs for code", + "server_source": "templates/basic-typescript/server", + "client_source": "../../crates/bindings-typescript/examples/empty", + "server_lang": "typescript", + "client_lang": "typescript" + }, + { + "id": "basic-c-sharp", + "description": "A basic C# client and server template with only stubs for code", + "server_source": "templates/basic-c-sharp/server", + "client_source": "templates/basic-c-sharp/client", + "server_lang": "csharp", + "client_lang": "csharp" + }, + { + "id": "basic-rust", + "description": "A basic Rust client and server template with only stubs for code", + "server_source": "templates/basic-rust/server", + "client_source": "templates/basic-rust/client", + "server_lang": "rust", + "client_lang": "rust" + }, + { + "id": "basic-react", + "description": "React web app with TypeScript server", + "server_source": "templates/basic-typescript/server", + "client_source": "../../crates/bindings-typescript/examples/basic-react", + "server_lang": "typescript", + "client_lang": "typescript" + }, + { + "id": "quickstart-chat-rust", + "description": "Rust server/client implementing quickstart chat", + "server_source": "../../modules/quickstart-chat", + "client_source": "../../sdks/rust/examples/quickstart-chat", + "server_lang": "rust", + "client_lang": "rust" + }, + { + "id": "quickstart-chat-c-sharp", + "description": "C# server/client implementing quickstart chat", + "server_source": "../../sdks/csharp/examples~/quickstart-chat/server", + "client_source": "../../sdks/csharp/examples~/quickstart-chat/client", + "server_lang": "csharp", + "client_lang": "csharp" + }, + { + "id": "quickstart-chat-typescript", + "description": "TypeScript server/client implementing quickstart chat", + "server_source": "../../modules/quickstart-chat-ts", + "client_source": "../../crates/bindings-typescript/examples/quickstart-chat", + "server_lang": "typescript", + "client_lang": "typescript" + } + ] +} diff --git a/docs/.cursor/rules/spacetimedb.md b/docs/.cursor/rules/spacetimedb.mdc similarity index 100% rename from docs/.cursor/rules/spacetimedb.md rename to docs/.cursor/rules/spacetimedb.mdc diff --git a/docs/docs/03-Unity Tutorial/02-part-1.md b/docs/docs/03-Unity Tutorial/02-part-1.md index e2293d4e40d..65d9baeb7b7 100644 --- a/docs/docs/03-Unity Tutorial/02-part-1.md +++ b/docs/docs/03-Unity Tutorial/02-part-1.md @@ -3,6 +3,11 @@ title: 1 - Setup slug: /unity/part-1 --- +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Setup + ![Unity Tutorial Hero Image](/images/unity/part-1-hero-image.png) Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! @@ -11,22 +16,6 @@ Need help with the tutorial? [Join our Discord server](https://discord.gg/spacet > > [https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio](https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio) -## Prepare Project Structure - -This project is separated into two subdirectories; - -1. Server (module) code -2. Client code - -First, we'll create a project root directory (you can choose the name): - -```bash -mkdir blackholio -cd blackholio -``` - -We'll start by populating the client directory. - ## Setting up the Tutorial Unity Project In this section, we will guide you through the process of setting up a Unity Project that will serve as the starting point for our tutorial. By the end of this section, you will have a basic Unity project and be ready to implement the server functionality. @@ -40,12 +29,10 @@ Open Unity and create a new project by selecting "New" from the Unity Hub or goi ![Unity Hub New Project](/images/unity/part-1-unity-hub-new-project.jpg) :::warning - -**Choose the `Universal 2D`** template to select a template which uses the Unity Universal Render Pipeline. - +**Make sure to choose the `Universal 2D` template for your new project.** ::: -For `Project Name` use `client-unity`. For Project Location make sure that you use your `blackholio` directory. This is the directory that we created in a previous step. +For `Project Name` use `blackholio`. For `Project Location` select a directory that you can navigate to via the CLI because we will need to do so in part 2. ![Universal 2D Template](/images/unity/part-1-universal-2d-template.png) diff --git a/docs/docs/03-Unity Tutorial/03-part-2.md b/docs/docs/03-Unity Tutorial/03-part-2.md index b0b760bf17f..df7f0a68cfa 100644 --- a/docs/docs/03-Unity Tutorial/03-part-2.md +++ b/docs/docs/03-Unity Tutorial/03-part-2.md @@ -6,37 +6,58 @@ slug: /unity/part-2 import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -# Unity Tutorial - Part 2 - Connecting to SpacetimeDB +# Connecting to SpacetimeDB Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! This progressive tutorial is continued from [part 1](/unity/part-1). +## Project Structure + +Now that we have our client project setup we can configure the module directory. Regardless of what language you choose, your module will always go into a `spacetimedb` directory within your client directory like this: + +``` +blackholio/ # This is the directory for your Unity project lives +├── Assembly-CSharp.csproj +├── Assets/ +│ └── module_bindings/ # This directory contains the client logic to communicate with the module +├── Library/ +├── ... # rest of the Unity files +└── spacetimedb/ # This is where your server module lives +``` + +Your `module_bindings` directory can go wherever you want as long as it is inside of `Assets/` in your Unity project. We'll configure this in a later step. For now we will create a new module in the `blackholio` directory which will generate the `spacetimedb` directory for us. + + ## Create a Server Module If you have not already installed the `spacetime` CLI, check out our [Getting Started](/getting-started) guide for instructions on how to install. -In your `blackholio` directory, run the following command to initialize the SpacetimeDB server module project with your desired language: +In the same directory that contains your `blackholio` project, run the following command to initialize the SpacetimeDB server module project with your desired language: + +:::warning +The `blackholio` directory specified here is the same `blackholio` directory you created during part 1. +::: Run the following command to initialize the SpacetimeDB server module project with Rust as the language: ```bash -spacetime init --lang=rust server-rust +spacetime init --lang rust --server-only blackholio ``` -This command creates a new folder named `server-rust` alongside your Unity project `client-unity` directory and sets up the SpacetimeDB server project with Rust as the programming language. +This command creates a new folder named `spacetimedb` inside of your Unity project `blackholio` directory and sets up the SpacetimeDB server project with Rust as the programming language. Run the following command to initialize the SpacetimeDB server module project with C# as the language: ```bash -spacetime init --lang=csharp server-csharp +spacetime init --lang csharp --server-only blackholio ``` -This command creates a new folder named `server-csharp` alongside your Unity project `client-unity` directory and sets up the SpacetimeDB server project with C# as the programming language. +This command creates a new folder named `spacetimedb` inside of your Unity project `blackholio` directory and sets up the SpacetimeDB server project with C# as the programming language. @@ -45,15 +66,15 @@ This command creates a new folder named `server-csharp` alongside your Unity pro -In this section we'll be making some edits to the file `server-rust/src/lib.rs`. We recommend you open up this file in an IDE like VSCode or RustRover. +In this section we'll be making some edits to the file `blackholio/spacetimedb/src/lib.rs`. We recommend you open up this file in an IDE like VSCode or RustRover. -**Important: Open the `server-rust/src/lib.rs` file and delete its contents. We will be writing it from scratch here.** +**Important: Open the `blackholio/spacetimedb/src/lib.rs` file and delete its contents. We will be writing it from scratch here.** -In this section we'll be making some edits to the file `server-csharp/Lib.cs`. We recommend you open up this file in an IDE like VSCode or Rider. +In this section we'll be making some edits to the file `blackholio/spacetimedb/Lib.cs`. We recommend you open up this file in an IDE like VSCode or Rider. -**Important: Open the `server-csharp/Lib.cs` file and delete its contents. We will be writing it from scratch here.** +**Important: Open the `blackholio/spacetimedb/Lib.cs` file and delete its contents. We will be writing it from scratch here.** @@ -369,18 +390,7 @@ This following log output indicates that SpacetimeDB is successfully running on Starting SpacetimeDB listening on 127.0.0.1:3000 ``` - - - Now that SpacetimeDB is running we can publish our module to the SpacetimeDB - host. In a separate terminal window, navigate to the - `blackholio/server-rust` directory. - - - Now that SpacetimeDB is running we can publish our module to the SpacetimeDB - host. In a separate terminal window, navigate to the - `blackholio/server-csharp` directory. - - +Now that SpacetimeDB is running we can publish our module to the SpacetimeDB host. In a separate terminal window, navigate to the `blackholio/spacetimedb` directory. If you are not already logged in to the `spacetime` CLI, run the `spacetime login` command to log in to your SpacetimeDB website account. Once you are logged in, run `spacetime publish --server local blackholio`. This will publish our Blackholio server logic to SpacetimeDB. @@ -401,7 +411,7 @@ Created new database with name: blackholio, identity: c200d2c69b4524292b91822afa Next, use the `spacetime` command to call our newly defined `debug` reducer: ```sh -spacetime call blackholio debug +spacetime call --server local blackholio debug ``` @@ -409,7 +419,7 @@ spacetime call blackholio debug Next, use the `spacetime` command to call our newly defined `Debug` reducer: ```sh -spacetime call blackholio Debug +spacetime call --server local blackholio Debug ``` @@ -418,7 +428,7 @@ spacetime call blackholio Debug If the call completed successfully, that command will have no output, but we can see the debug logs by running: ```sh -spacetime logs blackholio +spacetime logs --server local blackholio ``` You should see something like the following output: @@ -494,7 +504,7 @@ The `spacetime` CLI has built in functionality to let us generate C# types that directory run the following command: - Let's generate our types for our module. In the `blackholio/server-csharp` + Let's generate our types for our module. In the `blackholio/spacetimedb` directory run the following command: diff --git a/docs/docs/03-Unity Tutorial/04-part-3.md b/docs/docs/03-Unity Tutorial/04-part-3.md index 1f53c921c80..7d8ef993131 100644 --- a/docs/docs/03-Unity Tutorial/04-part-3.md +++ b/docs/docs/03-Unity Tutorial/04-part-3.md @@ -6,7 +6,7 @@ slug: /unity/part-3 import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -# Unity Tutorial - Part 3 - Gameplay +# Gameplay Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! @@ -1235,19 +1235,10 @@ Lastly modify the `GameManager.SetupArena` method to set the `WorldSize` on the ### Entering the Game - - - At this point, you may need to regenerate your bindings the following - command from the `server-rust` directory. - - - At this point, you may need to regenerate your bindings the following - command from the `server-csharp` directory. - - +At this point, you may need to regenerate your bindings the following command from the `blackholio/spacetimedb` directory. ```sh -spacetime generate --lang csharp --out-dir ../client-unity/Assets/autogen +spacetime generate --lang csharp --out-dir ../Assets/module_bindings ``` The last step is to call the `enter_game` reducer on the server, passing in a username for our player, which will spawn a circle for our player. For the sake of simplicity, let's call the `enter_game` reducer from the `HandleSubscriptionApplied` callback with the name "3Blave". @@ -1282,7 +1273,7 @@ The label won't be centered at this point. Feel free to adjust it if you like. W ### Troubleshooting -- If you get an error when running the generate command, make sure you have an empty subfolder in your Unity project Assets folder called `autogen` +- If you get an error when running the generate command, make sure you have an empty subfolder in your Unity project Assets folder called `module_bindings` - If you get an error in your Unity console when starting the game, double check that you have published your module and you have the correct module name specified in your `GameManager`. diff --git a/docs/docs/03-Unity Tutorial/05-part-4.md b/docs/docs/03-Unity Tutorial/05-part-4.md index 9ac31f67986..2da82f8d1c1 100644 --- a/docs/docs/03-Unity Tutorial/05-part-4.md +++ b/docs/docs/03-Unity Tutorial/05-part-4.md @@ -6,7 +6,7 @@ slug: /unity/part-4 import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -# Unity Tutorial - Part 4 - Moving and Colliding +# Moving and Colliding Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! @@ -363,7 +363,7 @@ spacetime publish --server local blackholio --delete-data Regenerate your server bindings with: ```sh -spacetime generate --lang csharp --out-dir ../client-unity/Assets/autogen +spacetime generate --lang csharp --out-dir ../Assets/module_bindings ``` ### Moving on the Client diff --git a/docs/docs/04-Unreal Tutorial/02-part-1.md b/docs/docs/04-Unreal Tutorial/02-part-1.md index ed1e5b74172..f31ffdc6013 100644 --- a/docs/docs/04-Unreal Tutorial/02-part-1.md +++ b/docs/docs/04-Unreal Tutorial/02-part-1.md @@ -6,7 +6,7 @@ slug: /unreal/part-1 import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -# Unreal Tutorial - Part 1 - Setup +# Setup Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! @@ -14,26 +14,6 @@ Need help with the tutorial? [Join our Discord server](https://discord.gg/spacet > > [https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio](https://github.com/clockworklabs/SpacetimeDB/tree/master/demo/Blackholio) -## Prepare Project Structure - -:::note -Ensure you have SpacetimeDB version >=1.4.0 installed to enable Unreal Engine code generation support. -::: - -This project is separated into two subdirectories; - -1. Server (module) code -2. Client code - -First, we'll create a project root directory (you can choose the name): - -```bash -mkdir blackholio -cd blackholio -``` - -We'll start by populating the client directory. - ## Setting up the Tutorial Unreal Project In this section, we will guide you through the process of setting up a Unreal Project that will serve as the starting point for our tutorial. By the end of this section, you will have a basic Unreal project and be ready to implement the server functionality. @@ -47,8 +27,7 @@ Launch Unreal 5.6 and create a new project by selecting Games from the Unreal Pr :::warning Select the **Blank** template and in **Project Defaults** select **C++**. ::: -For **Project Name** use `client_unreal`. -For **Project Location**, use your `blackholio` directory (created in the previous step). +For **Project Name** use `blackholio`. Click **Create** to generate the blank project. @@ -71,13 +50,13 @@ Before beginning make sure to close the Unreal project and IDE. 1. Navigate to your Unreal project directory and create a `Plugins` folder if it doesn’t already exist: ```bash - cd client_unreal + cd blackholio mkdir Plugins ``` 2. Download or clone the SDK from GitHub and copy the SpacetimeDbSdk folder into your new Plugins directory. - - This should create `/client_unreal/Plugins/SpacetimeDbSdk`. -3. In the root of the Unreal project, right click the client_unreal.uproject and select **Generate Visual Studio project files**. On Windows 11 you may need to expand **Show more options** to select the generate option. + - This should create `/blackholio/Plugins/SpacetimeDbSdk`. +3. In the root of the Unreal project, right click the blackholio.uproject and select **Generate Visual Studio project files**. On Windows 11 you may need to expand **Show more options** to select the generate option. ![Generate project files](/images/unreal/part-1-02-01-generate-project.png) ![Generate project files](/images/unreal/part-1-02-02-generate-project.png) @@ -86,7 +65,7 @@ Before beginning make sure to close the Unreal project and IDE. -1. Open the `client_unreal` project in your IDE (Visual Studio or JetBrains Rider) and run the project to launch the Unreal Editor. +1. Open the `blackholio` project in your IDE (Visual Studio or JetBrains Rider) and run the project to launch the Unreal Editor. - This will enable **Live Coding**, making the workflow a bit smoother. - Unreal will prompt you to build the `SpacetimeDbSdk` plugin. Do so. 2. Open **Tools -> New C++ Class** in the top menu, select **Actor** as the parent and click **Next** diff --git a/docs/docs/04-Unreal Tutorial/03-part-2.md b/docs/docs/04-Unreal Tutorial/03-part-2.md index c303db7c810..c3319cd707b 100644 --- a/docs/docs/04-Unreal Tutorial/03-part-2.md +++ b/docs/docs/04-Unreal Tutorial/03-part-2.md @@ -6,36 +6,61 @@ slug: /unreal/part-2 import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -# Unreal Tutorial - Part 2 - Connecting to SpacetimeDB +# Connecting to SpacetimeDB Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! This progressive tutorial is continued from [part 1](/unreal/part-1). +## Project Structure + +Now that we have our client project setup we can configure the module directory. Regardless of what language you choose, your module will always go into a `spacetimedb` directory within your client directory like this: + +``` +blackholio/ # Unreal project root +├── Binaries/ +├── blackholio.sln +├── blackholio.uproject +├── Config/ +├── Content/ +├── Plugins/ +│ └── SpacetimeDbSdk/ # This is where the SpacetimeDB Unreal SDK lives +├── ... rest of Unreal files +└── spacetimedb/ # This is where the server module lives +``` + ## Create a Server Module +:::note +Ensure you have SpacetimeDB version >=1.4.0 installed to enable Unreal Engine code generation support. You can use `spacetime --version` to check your version and you can use `spacetime version upgrade` to install the latest version. +::: + If you have not already installed the `spacetime` CLI, check out our [Getting Started](/getting-started) guide for instructions on how to install. -In your `blackholio` directory, run the following command to initialize the SpacetimeDB server module project with your desired language: +In the same directory that contains your `blackholio` project, run the following command to initialize the SpacetimeDB server module project with your desired language: + +:::warning +The `blackholio` directory specified here is the same `blackholio` directory you created during part 1. +::: Run the following command to initialize the SpacetimeDB server module project with Rust as the language: ```bash -spacetime init --lang=rust server-rust +spacetime init --lang rust --server-only blackholio ``` -This command creates a new folder named `server-rust` alongside your Unreal project `client_unreal` directory and sets up the SpacetimeDB server project with Rust as the programming language. +This command creates a new folder named `blackholio` inside of your Unreal project `blackholio` directory and sets up the SpacetimeDB server project with Rust as the programming language. Run the following command to initialize the SpacetimeDB server module project with C# as the language: ```bash -spacetime init --lang=csharp server-csharp +spacetime init --lang csharp --server-only blackholio ``` -This command creates a new folder named `server-csharp` alongside your Unreal project `client-unreal` directory and sets up the SpacetimeDB server project with C# as the programming language. +This command creates a new folder named `blackholio` inside of your Unreal project `blackholio` directory and sets up the SpacetimeDB server project with C# as the programming language. @@ -43,14 +68,14 @@ This command creates a new folder named `server-csharp` alongside your Unreal pr -In this section we'll be making some edits to the file `server-rust/src/lib.rs`. We recommend you open up this file in an IDE like VSCode or RustRover. +In this section we'll be making some edits to the file `blackholio/spacetimedb/src/lib.rs`. We recommend you open up this file in an IDE like VSCode or RustRover. -**Important: Open the `server-rust/src/lib.rs` file and delete its contents. We will be writing it from scratch here.** +**Important: Open the `blackholio/spacetimedb/src/lib.rs` file and delete its contents. We will be writing it from scratch here.** -In this section we'll be making some edits to the file `server-csharp/Lib.cs`. We recommend you open up this file in an IDE like VSCode or Rider. +In this section we'll be making some edits to the file `blackholio/spacetimedb/Lib.cs`. We recommend you open up this file in an IDE like VSCode or Rider. -**Important: Open the `server-csharp/Lib.cs` file and delete its contents. We will be writing it from scratch here.** +**Important: Open the `blackholio/spacetimedb/Lib.cs` file and delete its contents. We will be writing it from scratch here.** @@ -362,14 +387,7 @@ This following log output indicates that SpacetimeDB is successfully running on Starting SpacetimeDB listening on 127.0.0.1:3000 ``` - - -Now that SpacetimeDB is running we can publish our module to the SpacetimeDB host. In a separate terminal window, navigate to the `blackholio/server-rust` directory. - - -Now that SpacetimeDB is running we can publish our module to the SpacetimeDB host. In a separate terminal window, navigate to the `blackholio/server-csharp` directory. - - +Now that SpacetimeDB is running we can publish our module to the SpacetimeDB host. In a separate terminal window, navigate to the `blackholio/spacetimedb` directory. If you are not already logged in to the `spacetime` CLI, run the `spacetime login` command to log in to your SpacetimeDB website account. Once you are logged in, run `spacetime publish --server local blackholio`. This will publish our Blackholio server logic to SpacetimeDB. @@ -388,7 +406,7 @@ Created new database with name: blackholio, identity: c200d2c69b4524292b91822afa ```sh -spacetime call blackholio debug +spacetime call --server local blackholio debug ``` @@ -396,7 +414,7 @@ spacetime call blackholio debug Next, use the `spacetime` command to call our newly defined `Debug` reducer: ```sh -spacetime call blackholio Debug +spacetime call --server local blackholio Debug ``` @@ -405,7 +423,7 @@ spacetime call blackholio Debug If the call completed successfully, that command will have no output, but we can see the debug logs by running: ```sh -spacetime logs blackholio +spacetime logs --server local blackholio ``` You should see something like the following output: @@ -477,21 +495,21 @@ The `spacetime` CLI has built in functionality to let us generate Unreal C++ typ -Let's generate our types for our module. In the `blackholio/server-rust` directory run the following command: +Let's generate our types for our module. In the `blackholio/spacetimedb` directory run the following command: -Let's generate our types for our module. In the `blackholio/server-csharp` directory run the following command: +Let's generate our types for our module. In the `blackholio/spacetimedb` directory run the following command: ```sh -spacetime generate --lang unrealcpp --uproject-dir ../client_unreal --project-path ./ --module-name client_unreal +spacetime generate --lang unrealcpp --uproject-dir ../../blackholio --project-path ./ --module-name blackholio ``` -This will generate a set of files in the `client_unreal/Source/client_unreal/Private/ModuleBindings` and `client_unreal/Source/client_unreal/Public/ModuleBindings` directories which contain the code generated types and reducer functions that are defined in your module, but usable on the client. +This will generate a set of files in the `blackholio/Source/blackholio/Private/ModuleBindings` and `blackholio/Source/blackholio/Public/ModuleBindings` directories which contain the code generated types and reducer functions that are defined in your module, but usable on the client. :::note -`--uproject-dir` is straightforward as the path to the .uproject file. `--module-name` is the name of the Unreal module which in most projects is the name of the project, in this case `client_unreal`. +`--uproject-dir` is straightforward as the path to the .uproject file. `--module-name` is the name of the Unreal module which in most projects is the name of the project, in this case `blackholio`. ::: ``` @@ -514,11 +532,11 @@ This will generate a set of files in the `client_unreal/Source/client_unreal/Pri └── SpacetimeDBClient.g.h ``` -This will also generate a file in the `client_unreal/Source/client_unreal/Private/ModuleBindings/SpacetimeDBClient.g.h` directory with a type aware `UDbConnection` class. We will use this class to connect to your database from Unreal. +This will also generate a file in the `blackholio/Source/blackholio/Private/ModuleBindings/SpacetimeDBClient.g.h` directory with a type aware `UDbConnection` class. We will use this class to connect to your database from Unreal. ### Connecting to the Database -Update `client_unreal.Build.cs` to include the `SpacetimeDbSdk`. Add `SpacetimeDbSdk` and `Paper2D` to `PublicDependencyModuleNames`, and confirm that `PrivateDependencyModuleNames` includes the following modules for current and future needs: +Update `blackholio.Build.cs` to include the `SpacetimeDbSdk`. Add `SpacetimeDbSdk` and `Paper2D` to `PublicDependencyModuleNames`, and confirm that `PrivateDependencyModuleNames` includes the following modules for current and future needs: ```cpp PublicDependencyModuleNames.AddRange(new string[] diff --git a/docs/docs/04-Unreal Tutorial/04-part-3.md b/docs/docs/04-Unreal Tutorial/04-part-3.md index 15f633961bc..7952f1a01d4 100644 --- a/docs/docs/04-Unreal Tutorial/04-part-3.md +++ b/docs/docs/04-Unreal Tutorial/04-part-3.md @@ -6,7 +6,7 @@ slug: /unreal/part-3 import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -# Unreal Tutorial - Part 3 - Gameplay +# Gameplay Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! @@ -2115,17 +2115,10 @@ Update **Event BeginPlay** as follows: ### Entering the Game - - -At this point, you may need to regenerate your bindings the following command from the `server-rust` directory. - - -At this point, you may need to regenerate your bindings the following command from the `server-csharp` directory. - - +At this point, you may need to regenerate your bindings the following command from the `blackholio/spacetimedb` directory. ```sh -spacetime generate --lang unrealcpp --uproject-dir ../client_unreal --project-path ./ --module-name client_unreal +spacetime generate --lang unrealcpp --uproject-dir .. --module-name blackholio ``` diff --git a/docs/docs/04-Unreal Tutorial/05-part-4.md b/docs/docs/04-Unreal Tutorial/05-part-4.md index e5656ad25e0..87977fcde44 100644 --- a/docs/docs/04-Unreal Tutorial/05-part-4.md +++ b/docs/docs/04-Unreal Tutorial/05-part-4.md @@ -6,7 +6,7 @@ slug: /unreal/part-4 import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -# Unreal Tutorial - Part 4 - Moving and Colliding +# Moving and Colliding Need help with the tutorial? [Join our Discord server](https://discord.gg/spacetimedb)! @@ -18,7 +18,7 @@ At this point, we're very close to having a working game. All we have to do is m -Let's start by building out a simple math library to help us do collision calculations. Create a new `math.rs` file in the `server-rust/src` directory and add the following contents. Let's also move the `DbVector2` type from `lib.rs` into this file. +Let's start by building out a simple math library to help us do collision calculations. Create a new `math.rs` file in the `blackholio/spacetimedb/src` directory and add the following contents. Let's also move the `DbVector2` type from `lib.rs` into this file. ```rust use spacetimedb::SpacetimeType; @@ -362,7 +362,7 @@ spacetime publish --server local blackholio --delete-data Regenerate your server bindings with: ```sh -spacetime generate --lang unrealcpp --uproject-dir ../client_unreal --project-path ./ --module-name client_unreal +spacetime generate --lang unrealcpp --uproject-dir .. --module-name blackholio ``` ### Moving on the Client diff --git a/docs/docs/05-CLI Reference/01-cli-reference.md b/docs/docs/05-CLI Reference/01-cli-reference.md index 558e9436c2c..689172a854e 100644 --- a/docs/docs/05-CLI Reference/01-cli-reference.md +++ b/docs/docs/05-CLI Reference/01-cli-reference.md @@ -334,19 +334,19 @@ Show the current login info Initializes a new spacetime project. WARNING: This command is UNSTABLE and subject to breaking changes. -**Usage:** `spacetime init --lang [project-path]` +**Usage:** `spacetime init [OPTIONS] ` ###### Arguments: -- `` — The path where we will create the spacetime project - - Default value: `.` +* `` — Project name ###### Options: -- `-l`, `--lang ` — The spacetime module language. - - Possible values: `csharp`, `rust` +* `--project-path ` — Directory where the project will be created (defaults to ./\) +* `--lang ` — Server language: rust, csharp, typescript (it can only be used when --template is not specified) +* `-t`, `--template