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'}
+
+
+
+
+
+
+
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