From e536cc3a023ec951654d3e25dad78d8c857e6df7 Mon Sep 17 00:00:00 2001 From: Piotr Sarnacki Date: Tue, 7 Oct 2025 12:41:45 +0200 Subject: [PATCH 01/11] implement new spacetime init command --- Cargo.lock | 57 + Cargo.toml | 1 + .../examples/basic-react/.gitignore | 24 + .../examples/basic-react/index.html | 12 + .../examples/basic-react/package.json | 25 + .../examples/basic-react/src/App.tsx | 67 ++ .../examples/basic-react/src/main.tsx | 35 + .../examples/basic-react/tsconfig.json | 23 + .../examples/basic-react/vite.config.ts | 6 + .../examples/empty/.gitignore | 24 + .../examples/empty/index.html | 12 + .../examples/empty/package.json | 18 + .../examples/empty/src/main.ts | 1 + .../examples/empty/tsconfig.json | 23 + .../examples/empty/vite.config.ts | 3 + crates/cli/.init-templates.json | 47 + crates/cli/.init-templates.yaml | 99 ++ crates/cli/Cargo.toml | 2 + crates/cli/build.rs | 2 +- crates/cli/src/subcommands/init.rs | 158 ++- crates/cli/src/subcommands/init/template.rs | 992 ++++++++++++++++++ .../src/subcommands/project/.spacetime._toml | 3 - .../subcommands/project/csharp/global._json | 1 - .../basic-c-sharp/server/.gitignore} | 0 .../basic-c-sharp/server/Lib.cs} | 0 .../basic-c-sharp/server/StdbModule.csproj} | 0 .../basic-c-sharp/server/global.json | 6 + crates/cli/templates/basic-react/client | 1 + .../templates/basic-react/server/package.json | 11 + .../templates/basic-react/server/src/index.ts | 23 + .../templates/basic-rust/client/Cargo.toml | 7 + .../cli/templates/basic-rust/client/README.md | 15 + .../templates/basic-rust/client/src/main.rs | 37 + .../basic-rust/server/.cargo/config.toml} | 0 .../basic-rust/server/.gitignore} | 2 +- .../basic-rust/server/Cargo.toml} | 0 .../basic-rust/server/src/lib.rs} | 2 +- crates/cli/templates/basic-typescript/client | 1 + .../basic-typescript/server/.gitignore | 4 + .../basic-typescript/server/index.ts | 33 + .../basic-typescript/server/tsconfig.json | 23 + .../templates/quickstart-chat-c-sharp/client | 1 + .../templates/quickstart-chat-c-sharp/server | 1 + .../cli/templates/quickstart-chat-rust/client | 1 + .../cli/templates/quickstart-chat-rust/server | 1 + 45 files changed, 1746 insertions(+), 58 deletions(-) create mode 100644 crates/bindings-typescript/examples/basic-react/.gitignore create mode 100644 crates/bindings-typescript/examples/basic-react/index.html create mode 100644 crates/bindings-typescript/examples/basic-react/package.json create mode 100644 crates/bindings-typescript/examples/basic-react/src/App.tsx create mode 100644 crates/bindings-typescript/examples/basic-react/src/main.tsx create mode 100644 crates/bindings-typescript/examples/basic-react/tsconfig.json create mode 100644 crates/bindings-typescript/examples/basic-react/vite.config.ts create mode 100644 crates/bindings-typescript/examples/empty/.gitignore create mode 100644 crates/bindings-typescript/examples/empty/index.html create mode 100644 crates/bindings-typescript/examples/empty/package.json create mode 100644 crates/bindings-typescript/examples/empty/src/main.ts create mode 100644 crates/bindings-typescript/examples/empty/tsconfig.json create mode 100644 crates/bindings-typescript/examples/empty/vite.config.ts create mode 100644 crates/cli/.init-templates.json create mode 100644 crates/cli/.init-templates.yaml create mode 100644 crates/cli/src/subcommands/init/template.rs delete mode 100644 crates/cli/src/subcommands/project/.spacetime._toml delete mode 120000 crates/cli/src/subcommands/project/csharp/global._json rename crates/cli/{src/subcommands/project/csharp/_gitignore => templates/basic-c-sharp/server/.gitignore} (100%) rename crates/cli/{src/subcommands/project/csharp/Lib._cs => templates/basic-c-sharp/server/Lib.cs} (100%) rename crates/cli/{src/subcommands/project/csharp/StdbModule._csproj => templates/basic-c-sharp/server/StdbModule.csproj} (100%) create mode 100644 crates/cli/templates/basic-c-sharp/server/global.json create mode 120000 crates/cli/templates/basic-react/client create mode 100644 crates/cli/templates/basic-react/server/package.json create mode 100644 crates/cli/templates/basic-react/server/src/index.ts create mode 100644 crates/cli/templates/basic-rust/client/Cargo.toml create mode 100644 crates/cli/templates/basic-rust/client/README.md create mode 100644 crates/cli/templates/basic-rust/client/src/main.rs rename crates/cli/{src/subcommands/project/rust/config._toml => templates/basic-rust/server/.cargo/config.toml} (100%) rename crates/cli/{src/subcommands/project/rust/_gitignore => templates/basic-rust/server/.gitignore} (97%) rename crates/cli/{src/subcommands/project/rust/Cargo._toml => templates/basic-rust/server/Cargo.toml} (100%) rename crates/cli/{src/subcommands/project/rust/lib._rs => templates/basic-rust/server/src/lib.rs} (97%) create mode 120000 crates/cli/templates/basic-typescript/client create mode 100644 crates/cli/templates/basic-typescript/server/.gitignore create mode 100644 crates/cli/templates/basic-typescript/server/index.ts create mode 100644 crates/cli/templates/basic-typescript/server/tsconfig.json create mode 120000 crates/cli/templates/quickstart-chat-c-sharp/client create mode 120000 crates/cli/templates/quickstart-chat-c-sharp/server create mode 120000 crates/cli/templates/quickstart-chat-rust/client create mode 120000 crates/cli/templates/quickstart-chat-rust/server diff --git a/Cargo.lock b/Cargo.lock index fa6cb645241..a5746ff26cd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2258,6 +2258,21 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "git2" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b903b73e45dc0c6c596f2d37eccece7c1c8bb6e4407b001096387c63d0d93724" +dependencies = [ + "bitflags 2.9.0", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + [[package]] name = "glob" version = "0.3.2" @@ -3291,6 +3306,20 @@ version = "0.2.172" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" +[[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.8" @@ -3339,6 +3368,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" @@ -3350,6 +3393,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" @@ -5792,12 +5847,14 @@ dependencies = [ "clap-markdown", "colored", "convert_case 0.6.0", + "dialoguer", "dirs", "duct", "email_address", "flate2", "fs-err", "futures", + "git2", "http 1.3.1", "indicatif", "is-terminal", diff --git a/Cargo.toml b/Cargo.toml index 52ea189d320..0a740ee4f78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -184,6 +184,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" 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..59af437a7ad --- /dev/null +++ b/crates/bindings-typescript/examples/basic-react/src/App.tsx @@ -0,0 +1,67 @@ +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..1265e9d24a1 --- /dev/null +++ b/crates/bindings-typescript/examples/basic-react/src/main.tsx @@ -0,0 +1,35 @@ +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 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('ws://localhost:3000') + .withModuleName('spacetime-module') + .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..dad2706aad6 --- /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"] +} 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..9ffcc675746 --- /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..1edf8ae01fb --- /dev/null +++ b/crates/bindings-typescript/examples/empty/src/main.ts @@ -0,0 +1 @@ +console.log('SpacetimeDB client ready'); diff --git a/crates/bindings-typescript/examples/empty/tsconfig.json b/crates/bindings-typescript/examples/empty/tsconfig.json new file mode 100644 index 00000000000..0511b9f0e04 --- /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"] +} 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/cli/.init-templates.json b/crates/cli/.init-templates.json new file mode 100644 index 00000000000..3dc68408ffc --- /dev/null +++ b/crates/cli/.init-templates.json @@ -0,0 +1,47 @@ +{ + "highlights": [ + { "name": "React", "template_id": "basic-react" }, + ], + "templates": [ + { + "id": "basic-rust", + "description": "Bare minimum client and server using Rust with only stubs for code", + "server_source": "clockworklabs/SpacetimeDB/crates/cli/templates/basic-rust/server", + "client_source": "clockworklabs/SpacetimeDB/crates/cli/templates/basic-rust/client", + "server_lang": "rust", + "client_lang": "rust" + }, + { + "id": "basic-typescript", + "description": "Bare minimum client and server using TypeScript with only stubs for code", + "server_source": "clockworklabs/SpacetimeDB/crates/cli/templates/basic-typescript/server", + "client_source": "clockworklabs/SpacetimeDB/crates/cli/templates/basic-typescript/client", + "server_lang": "typescript", + "client_lang": "typescript" + }, + { + "id": "basic-react", + "description": "React web app with TypeScript server", + "server_source": "clockworklabs/SpacetimeDB/crates/cli/templates/basic-react/server", + "client_source": "clockworklabs/SpacetimeDB/crates/cli/templates/basic-react/client", + "server_lang": "typescript", + "client_lang": "typescript" + }, + { + "id": "quickstart-chat-rust", + "description": "Rust server/client implementing quickstart chat", + "server_source": "clockworklabs/SpacetimeDB/crates/cli/templates/quickstart-chat-rust/server", + "client_source": "clockworklabs/SpacetimeDB/crates/cli/templates/quickstart-chat-rust/client", + "server_lang": "rust", + "client_lang": "rust" + }, + { + "id": "quickstart-chat-c-sharp", + "description": "C# server/client implementing quickstart chat", + "server_source": "clockworklabs/SpacetimeDB/crates/cli/templates/quickstart-chat-c-sharp/server", + "client_source": "clockworklabs/SpacetimeDB/crates/cli/templates/quickstart-chat-c-sharp/client", + "server_lang": "csharp", + "client_lang": "csharp" + } + ] +} diff --git a/crates/cli/.init-templates.yaml b/crates/cli/.init-templates.yaml new file mode 100644 index 00000000000..d9f00aade4b --- /dev/null +++ b/crates/cli/.init-templates.yaml @@ -0,0 +1,99 @@ +highlights: + - name: React + template_id: basic-react + - name: Unity + template_id: basic-unity + - name: Unreal + template_id: basic-unreal + +templates: + - id: basic-rust + description: Bare minimum client and server using Rust with only stubs for code + server_source: clockworklabs/SpacetimeDB/crates/cli/templates/basic-rust/server + client_source: clockworklabs/SpacetimeDB/crates/cli/templates/basic-rust/client + server_lang: rust + client_lang: rust + + - id: basic-c-sharp + description: Bare minimum client and server using C# with only stubs for code + server_source: clockworklabs/SpacetimeDB/crates/cli/templates/basic-c-sharp/server + client_source: clockworklabs/SpacetimeDB/crates/cli/templates/basic-c-sharp/client + server_lang: csharp + client_lang: csharp + + - id: basic-typescript + description: Bare minimum client and server using TypeScript with only stubs for code + server_source: clockworklabs/SpacetimeDB/crates/cli/templates/basic-typescript/server + client_source: clockworklabs/SpacetimeDB/crates/cli/templates/basic-typescript/client + server_lang: typescript + client_lang: typescript + + - id: basic-react + description: React web app with TypeScript server + server_source: clockworklabs/SpacetimeDB/crates/cli/templates/basic-react/server + client_source: clockworklabs/SpacetimeDB/crates/cli/templates/basic-react/client + server_lang: typescript + client_lang: typescript + + - id: basic-unity + description: Unity game project with C# server + server_source: clockworklabs/SpacetimeDB/crates/cli/templates/basic-unity/server + client_source: clockworklabs/SpacetimeDB/crates/cli/templates/basic-unity/client + server_lang: csharp + client_lang: csharp + + - id: basic-unreal + description: Unreal Engine project with Rust server + server_source: clockworklabs/SpacetimeDB/crates/cli/templates/basic-unreal/server + client_source: clockworklabs/SpacetimeDB/crates/cli/templates/basic-unreal/client + server_lang: rust + client_lang: none + + - id: quickstart-chat-rust + description: Rust server/client implementing quickstart chat + server_source: clockworklabs/SpacetimeDB/crates/cli/templates/quickstart-chat-rust/server + client_source: clockworklabs/SpacetimeDB/crates/cli/templates/quickstart-chat-rust/client + server_lang: rust + client_lang: rust + + - id: quickstart-chat-c-sharp + description: C# server/client implementing quickstart chat + server_source: clockworklabs/SpacetimeDB/crates/cli/templates/quickstart-chat-c-sharp/server + client_source: clockworklabs/SpacetimeDB/crates/cli/templates/quickstart-chat-c-sharp/client + server_lang: csharp + client_lang: csharp + + - id: quickstart-chat-typescript + description: TypeScript server/client implementing quickstart chat + server_source: clockworklabs/SpacetimeDB/crates/cli/templates/quickstart-chat-typescript/server + client_source: clockworklabs/SpacetimeDB/crates/cli/templates/quickstart-chat-typescript/client + server_lang: typescript + client_lang: typescript + + - id: quickstart-chat-react + description: TypeScript server with React client + server_source: clockworklabs/SpacetimeDB/crates/cli/templates/quickstart-chat-react/server + client_source: clockworklabs/SpacetimeDB/crates/cli/templates/quickstart-chat-react/client + server_lang: rust + client_lang: typescript + + - id: blackholio-unity + description: C# server/client set up with Blackholio project + server_source: clockworklabs/SpacetimeDB/crates/cli/templates/blackholio-unity/server + client_source: clockworklabs/SpacetimeDB/crates/cli/templates/blackholio-unity/client + server_lang: csharp + client_lang: csharp + + - id: blackholio-unreal + description: C++ server/client + server_source: clockworklabs/SpacetimeDB/crates/cli/templates/blackholio-unreal/server + client_source: clockworklabs/SpacetimeDB/crates/cli/templates/blackholio-unreal/client + server_lang: rust + client_lang: none + + - id: blackholio-unreal-blueprints + description: C++ server with Blueprints client + server_source: clockworklabs/SpacetimeDB/crates/cli/templates/blackholio-unreal-blueprints/server + client_source: clockworklabs/SpacetimeDB/crates/cli/templates/blackholio-unreal-blueprints/client + server_lang: rust + client_lang: none diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 1ec691324ad..c1ae4417459 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -74,6 +74,8 @@ walkdir.workspace = true wasmbin.workspace = true webbrowser.workspace = true clap-markdown.workspace = true +git2.workspace = true +dialoguer.workspace = true [target.'cfg(not(target_env = "msvc"))'.dependencies] tikv-jemallocator = { workspace = true } diff --git a/crates/cli/build.rs b/crates/cli/build.rs index 71e37105571..a04c7195f63 100644 --- a/crates/cli/build.rs +++ b/crates/cli/build.rs @@ -3,6 +3,6 @@ use std::process::Command; // 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}"); } diff --git a/crates/cli/src/subcommands/init.rs b/crates/cli/src/subcommands/init.rs index 8a087b81b9a..f1ac0ef4f4a 100644 --- a/crates/cli/src/subcommands/init.rs +++ b/crates/cli/src/subcommands/init.rs @@ -1,3 +1,4 @@ +mod template; use crate::util::ModuleLanguage; use crate::Config; use crate::{detect::find_executable, util::UNSTABLE_WARNING}; @@ -5,6 +6,7 @@ use anyhow::Context; use clap::{Arg, ArgMatches}; use colored::Colorize; use std::path::{Path, PathBuf}; +use template as init_template; pub fn cli() -> clap::Command { clap::Command::new("init") @@ -12,17 +14,54 @@ pub fn cli() -> clap::Command { .arg( Arg::new("project-path") .value_parser(clap::value_parser!(PathBuf)) - .default_value(".") .help("The path where we will create the spacetime project"), ) + .arg( + Arg::new("name") + .short('n') + .long("name") + .value_name("NAME") + .help("Project name (defaults to directory name if not provided)"), + ) .arg( Arg::new("lang") - .required(true) .short('l') .long("lang") .help("The spacetime module language.") .value_parser(clap::value_parser!(ModuleLanguage)), ) + .arg( + Arg::new("server-lang") + .long("server-lang") + .value_name("LANG") + .help("Server language: rust, csharp, typescript"), + ) + .arg( + Arg::new("template") + .short('t') + .long("template") + .value_name("TEMPLATE") + .help("Template ID or GitHub repository (owner/repo or URL)"), + ) + .arg( + Arg::new("client-lang") + .long("client-lang") + .value_name("LANG") + .help("Client language: rust, csharp, typescript, none"), + ) + .arg( + Arg::new("local") + .long("local") + .action(clap::ArgAction::SetTrue) + .help("Use local deployment instead of Maincloud (non-interactive mode only)"), + ) + .arg( + Arg::new("non-interactive") + .short('y') + .long("non-interactive") + .action(clap::ArgAction::SetTrue) + .help("Run in non-interactive mode with default or provided options"), + ) } fn check_for_cargo() -> bool { @@ -113,89 +152,106 @@ fn check_for_git() -> bool { false } -pub async fn exec(_config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { +pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { eprintln!("{UNSTABLE_WARNING}\n"); - let project_path = args.get_one::("project-path").unwrap(); - let project_lang = *args.get_one::("lang").unwrap(); - - // 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() - )); - } + let project_path = args.get_one::("project-path"); + let template = args.get_one::("template"); + let non_interactive = args.get_flag("non-interactive"); + let lang = args.get_one::("lang"); + let server_lang = args.get_one::("server-lang"); + let client_lang = args.get_one::("client-lang"); - 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() - )); - } - } else { - create_directory(project_path)?; - } + // Determine if we should run in non-interactive mode + let is_non_interactive = + non_interactive || template.is_some() || lang.is_some() || server_lang.is_some() || client_lang.is_some(); - match project_lang { - ModuleLanguage::Rust => exec_init_rust(args).await, - ModuleLanguage::Csharp => exec_init_csharp(args).await, + if is_non_interactive { + return init_template::exec_non_interactive_init(&mut config, args).await; } -} -pub async fn exec_init_rust(args: &ArgMatches) -> Result<(), anyhow::Error> { - let project_path = args.get_one::("project-path").unwrap(); + // Interactive mode + let path = project_path.cloned().unwrap_or_else(|| PathBuf::from(".")); + init_template::exec_interactive_init(&mut config, &path).await +} +pub fn init_rust_project(project_path: &Path) -> Result<(), anyhow::Error> { 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) -> Result<(), anyhow::Error> { 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)?; } + Ok(()) +} + +pub async fn exec_init_rust(args: &ArgMatches) -> Result<(), anyhow::Error> { + 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() + ); + + Ok(()) +} + +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() diff --git a/crates/cli/src/subcommands/init/template.rs b/crates/cli/src/subcommands/init/template.rs new file mode 100644 index 00000000000..f7bef218d58 --- /dev/null +++ b/crates/cli/src/subcommands/init/template.rs @@ -0,0 +1,992 @@ +use anyhow::{Context, Result}; +use clap::ArgMatches; +use colored::Colorize; +use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select}; +use git2::Repository; +use regex::Regex; +use reqwest::Url; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use crate::subcommands::login::{spacetimedb_login_force, DEFAULT_AUTH_HOST}; +use crate::Config; + +const DEFAULT_TEMPLATES_REPO: &str = "clockworklabs/SpacetimeDB"; +const DEFAULT_TEMPLATES_BRANCH: &str = env!("GIT_HASH"); +const TEMPLATES_FILE_PATH: &str = "crates/cli/.init-templates.json"; +const TYPESCRIPT_BINDINGS_PACKAGE_JSON: &str = include_str!("../../../../bindings-typescript/package.json"); + +#[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, + None, +} + +impl ServerLanguage { + fn as_str(&self) -> &'static str { + match self { + ServerLanguage::Rust => "rust", + ServerLanguage::Csharp => "csharp", + ServerLanguage::TypeScript => "typescript", + ServerLanguage::None => "none", + } + } + + fn from_str(s: &str) -> Self { + match s.to_lowercase().as_str() { + "rust" => ServerLanguage::Rust, + "csharp" | "c#" => ServerLanguage::Csharp, + "typescript" => ServerLanguage::TypeScript, + _ => ServerLanguage::None, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientLanguage { + Rust, + Csharp, + TypeScript, + None, +} + +impl ClientLanguage { + fn as_str(&self) -> &'static str { + match self { + ClientLanguage::Rust => "rust", + ClientLanguage::Csharp => "csharp", + ClientLanguage::TypeScript => "typescript", + ClientLanguage::None => "none", + } + } + + fn from_str(s: &str) -> Self { + match s.to_lowercase().as_str() { + "rust" => ClientLanguage::Rust, + "csharp" | "c#" => ClientLanguage::Csharp, + "typescript" => ClientLanguage::TypeScript, + _ => ClientLanguage::None, + } + } +} + +pub struct TemplateConfig { + pub project_name: String, + pub project_path: PathBuf, + pub template_type: TemplateType, + pub server_lang: ServerLanguage, + pub client_lang: ClientLanguage, + pub github_repo: Option, + pub template_def: Option, + pub use_local: bool, +} + +pub async fn fetch_templates_list() -> Result<(Vec, Vec)> { + let content = if let Ok(file_path) = env::var("SPACETIMEDB_CLI_TEMPLATES_FILE") { + eprintln!("Loading templates list from local file: {}", file_path); + std::fs::read_to_string(&file_path) + .with_context(|| format!("Failed to read templates file at {}", file_path))? + } else { + let repo = + env::var("SPACETIMEDB_CLI_TEMPLATES_LIST_REPO").unwrap_or_else(|_| DEFAULT_TEMPLATES_REPO.to_string()); + let branch = + env::var("SPACETIMEDB_CLI_TEMPLATES_LIST_BRANCH").unwrap_or_else(|_| DEFAULT_TEMPLATES_BRANCH.to_string()); + + let url = format!( + "https://raw.githubusercontent.com/{}/{}/{}", + repo, branch, TEMPLATES_FILE_PATH + ); + + eprintln!("Fetching templates list from {}...", url); + + let client = reqwest::Client::new(); + let response = client + .get(&url) + .send() + .await + .context("Failed to fetch templates list from GitHub")?; + + if !response.status().is_success() { + anyhow::bail!("Failed to fetch templates list: HTTP {}", response.status()); + } + + response + .text() + .await + .context("Failed to read templates list response")? + }; + + 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) -> Result { + if config.spacetimedb_token().is_some() { + eprintln!("{}", "You are logged in to SpacetimeDB.".green()); + return Ok(true); + } + + eprintln!("{}", "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?; + eprintln!("{}", "Successfully logged in!".green()); + Ok(true) + } else { + eprintln!("{}", "Continuing with local deployment.".yellow()); + Ok(false) + } +} + +pub async fn exec_interactive_init(config: &mut Config, _project_path: &Path) -> Result<()> { + let use_local = !check_and_prompt_login(config).await?; + + let mut template_config = interactive_init().await?; + template_config.use_local = use_local; + + ensure_empty_directory(&template_config.project_name, &template_config.project_path)?; + + init_from_template(&template_config, &template_config.project_path).await?; + + Ok(()) +} + +pub async fn exec_non_interactive_init(config: &mut Config, args: &ArgMatches) -> Result<()> { + use crate::util::ModuleLanguage; + + let use_local = if args.get_flag("local") { + true + } else { + !check_and_prompt_login(config).await? + }; + + // Get project name - required in non-interactive mode + let project_name = args + .get_one::("name") + .ok_or_else(|| anyhow::anyhow!("Project name is required in non-interactive mode. Use --name to specify it."))? + .clone(); + + // Determine project path + let actual_project_path = if let Some(path) = args.get_one::("project-path") { + path.clone() + } else { + PathBuf::from(&project_name) + }; + + // 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?; + + if let Some(template) = templates.iter().find(|t| t.id == *template_str) { + // Builtin template + let template_config = TemplateConfig { + project_name: project_name.clone(), + project_path: actual_project_path.clone(), + template_type: TemplateType::Builtin, + server_lang: ServerLanguage::from_str(template.server_lang.as_deref().unwrap_or("none")), + client_lang: ClientLanguage::from_str(template.client_lang.as_deref().unwrap_or("none")), + github_repo: None, + template_def: Some(template.clone()), + use_local, + }; + + ensure_empty_directory(&project_name, &actual_project_path)?; + init_from_template(&template_config, &actual_project_path).await?; + } else { + // GitHub template + let template_config = TemplateConfig { + project_name: project_name.clone(), + project_path: actual_project_path.clone(), + template_type: TemplateType::GitHub, + server_lang: ServerLanguage::Rust, + client_lang: ClientLanguage::None, + github_repo: Some(template_str.clone()), + template_def: None, + use_local, + }; + + ensure_empty_directory(&project_name, &actual_project_path)?; + init_from_template(&template_config, &actual_project_path).await?; + } + } else { + // No template - require at least one language option + let legacy_lang = args.get_one::("lang"); + let server_lang_str = args.get_one::("server-lang"); + let client_lang_str = args.get_one::("client-lang"); + + if legacy_lang.is_none() && server_lang_str.is_none() && client_lang_str.is_none() { + anyhow::bail!( + "Either --template, --server-lang, or --client-lang must be provided in non-interactive mode" + ); + } + + // Determine server language + let server_lang = if let Some(lang_str) = server_lang_str { + ServerLanguage::from_str(lang_str) + } else if let Some(lang) = legacy_lang { + match lang { + ModuleLanguage::Rust => ServerLanguage::Rust, + ModuleLanguage::Csharp => ServerLanguage::Csharp, + } + } else { + ServerLanguage::None + }; + + // Determine client language + let client_lang = if let Some(lang_str) = client_lang_str { + ClientLanguage::from_str(lang_str) + } else { + ClientLanguage::None + }; + + let template_config = TemplateConfig { + project_name: project_name.clone(), + project_path: actual_project_path.clone(), + template_type: TemplateType::Empty, + server_lang, + client_lang, + github_repo: None, + template_def: None, + use_local, + }; + + ensure_empty_directory(&project_name, &actual_project_path)?; + init_from_template(&template_config, &actual_project_path).await?; + } + + Ok(()) +} + +pub fn ensure_empty_directory(project_name: &str, project_path: &Path) -> 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 { + 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(()) +} + +pub async fn interactive_init() -> Result { + let theme = ColorfulTheme::default(); + + let project_name: String = 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(); + + let project_path: String = Input::with_theme(&theme) + .with_prompt("Project path") + .default(format!("./{}", 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 entries.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(); + + 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("other".to_string()); + client_choices.push("none".to_string()); + + let client_selection = Select::with_theme(&theme) + .with_prompt("Select client") + .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: PathBuf::from(project_path), + template_type: TemplateType::Builtin, + server_lang: ServerLanguage::from_str(template.server_lang.as_deref().unwrap_or("none")), + client_lang: ClientLanguage::from_str(template.client_lang.as_deref().unwrap_or("none")), + github_repo: None, + template_def: Some(template.clone()), + use_local: true, + }) + } else if client_selection == other_index { + loop { + let template_id: String = Input::::with_theme(&theme) + .with_prompt("Template ID or GitHub repository (owner/repo). Press 'l' to list available templates") + .interact_text()? + .trim() + .to_string(); + + if template_id == "l" || template_id == "L" { + eprintln!("\n{}", "Available templates:".bold()); + for template in &templates { + eprintln!(" {} - {}", template.id, template.description); + } + eprintln!(); + continue; + } + + if let Some(template) = templates.iter().find(|t| t.id == template_id) { + return Ok(TemplateConfig { + project_name: project_name.clone(), + project_path: PathBuf::from(&project_path), + template_type: TemplateType::Builtin, + server_lang: ServerLanguage::from_str(template.server_lang.as_deref().unwrap_or("none")), + client_lang: ClientLanguage::from_str(template.client_lang.as_deref().unwrap_or("none")), + github_repo: None, + template_def: Some(template.clone()), + use_local: true, + }); + } else { + return Ok(TemplateConfig { + project_name: project_name.clone(), + project_path: PathBuf::from(&project_path), + template_type: TemplateType::GitHub, + server_lang: ServerLanguage::Rust, + client_lang: ClientLanguage::None, + github_repo: Some(template_id), + template_def: None, + use_local: true, + }); + } + } + } else { + let server_lang_choices = vec!["Rust", "C#", "TypeScript"]; + let server_lang_selection = Select::with_theme(&theme) + .with_prompt("Select server language") + .items(&server_lang_choices) + .default(0) + .interact()?; + + let server_lang = match server_lang_selection { + 0 => ServerLanguage::Rust, + 1 => ServerLanguage::Csharp, + 2 => ServerLanguage::TypeScript, + _ => ServerLanguage::Rust, + }; + + Ok(TemplateConfig { + project_name, + project_path: PathBuf::from(project_path), + template_type: TemplateType::Empty, + server_lang, + client_lang: ClientLanguage::None, + github_repo: None, + template_def: None, + use_local: true, + }) + } +} + +fn clone_git_subdirectory(repo_url: &str, subdir: &str, target: &Path, branch: Option<&str>) -> Result<()> { + let temp_dir = tempfile::tempdir()?; + let temp_path = temp_dir.path(); + + let branch_display = branch.map(|b| format!(" (branch: {})", b)).unwrap_or_default(); + eprintln!(" Cloning repository from {}{}...", repo_url, branch_display); + + let mut builder = git2::build::RepoBuilder::new(); + + if let Some(branch_name) = branch { + builder.branch(branch_name); + } + + 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_path) + .context("Failed to clone repository")?; + + let source_path = temp_path.join(subdir); + if !source_path.exists() { + anyhow::bail!("Subdirectory '{}' not found in repository", subdir); + } + + copy_dir_all(&source_path, target)?; + + Ok(()) +} + +fn clone_github_template(repo_input: &str, target: &Path) -> Result<()> { + let repo_url = if repo_input.starts_with("http") { + 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 URL"); + }; + + eprintln!(" 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")?; + + copy_dir_all(temp_dir.path(), target)?; + + Ok(()) +} + +fn copy_dir_all(src: &Path, dst: &Path) -> 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 configure_rust_server(server_dir: &Path, project_name: &str) -> Result<()> { + let cargo_path = server_dir.join("Cargo.toml"); + if !cargo_path.exists() { + return Ok(()); + } + + let mut content = fs::read_to_string(&cargo_path)?; + + let safe_name = project_name + .chars() + .map(|c| if c.is_alphanumeric() || c == '_' { c } else { '_' }) + .collect::(); + + let name_regex = Regex::new(r#"(?m)^name = .*$"#)?; + content = name_regex + .replace(&content, format!(r#"name = "{}""#, safe_name)) + .to_string(); + + fs::write(&cargo_path, content)?; + Ok(()) +} + +fn create_root_package_json(root: &Path, project_name: &str, use_local: bool) -> Result<()> { + let package_json = json!({ + "name": project_name, + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "cd client && npm run dev", + "build": "cd spacetimedb && spacetime build && cd ../client && npm run build", + "deploy": format!("npm run build && spacetime publish --project-path spacetimedb --server maincloud {} && spacetime generate --project-path spacetimedb --lang typescript --out-dir client/src/module_bindings", project_name), + "local": format!("npm run build && spacetime publish --project-path spacetimedb --server local {} --yes && spacetime generate --project-path spacetimedb --lang typescript --out-dir client/src/module_bindings", project_name) + }, + "workspaces": ["client"] + }); + + let package_path = root.join("package.json"); + let content = serde_json::to_string_pretty(&package_json)?; + fs::write(package_path, content)?; + + Ok(()) +} + +fn get_spacetimedb_typescript_version() -> &'static str { + static VERSION: OnceLock = OnceLock::new(); + VERSION.get_or_init(|| { + let package: serde_json::Value = serde_json::from_str(TYPESCRIPT_BINDINGS_PACKAGE_JSON) + .expect("Failed to parse TypeScript bindings package.json"); + package["version"] + .as_str() + .expect("Version not found in package.json") + .to_string() + }) +} + +fn update_client_package_json(client_dir: &Path, project_name: &str) -> Result<()> { + let package_path = client_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!(format!("{}-client", project_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 update_typescript_client_config(client_dir: &Path, module_name: &str, use_local: bool) -> Result<()> { + let main_path = client_dir.join("src/main.tsx"); + if !main_path.exists() { + return Ok(()); + } + + let mut content = fs::read_to_string(&main_path)?; + + let target_uri = if use_local { + "ws://localhost:3000" + } else { + "wss://maincloud.spacetimedb.com" + }; + + let module_regex = Regex::new(r#"\.withModuleName\(['"][^'"]*['"]\)"#)?; + content = module_regex + .replace_all(&content, format!(r#".withModuleName('{}')"#, module_name)) + .to_string(); + + let uri_regex = Regex::new(r#"\.withUri\(['"]ws://localhost:3000['"]\)"#)?; + content = uri_regex + .replace_all(&content, format!(r#".withUri('{}')"#, target_uri)) + .to_string(); + + fs::write(main_path, content)?; + + Ok(()) +} + +async fn copy_cursorrules(project_path: &Path) -> Result<()> { + let repo = env::var("SPACETIMEDB_CLI_TEMPLATES_LIST_REPO").unwrap_or_else(|_| DEFAULT_TEMPLATES_REPO.to_string()); + let branch = + env::var("SPACETIMEDB_CLI_TEMPLATES_LIST_BRANCH").unwrap_or_else(|_| DEFAULT_TEMPLATES_BRANCH.to_string()); + + let url = format!( + "https://raw.githubusercontent.com/{}/{}/docs/.cursor/rules/spacetimedb.md", + repo, branch + ); + + let client = reqwest::Client::new(); + match client.get(&url).send().await { + Ok(response) if response.status().is_success() => { + let content = response.text().await?; + let cursorrules_path = project_path.join(".cursorrules"); + fs::write(cursorrules_path, content)?; + } + _ => { + // Silently skip if file doesn't exist or can't be fetched + } + } + + Ok(()) +} + +pub async fn init_from_template(config: &TemplateConfig, project_path: &Path) -> Result<()> { + eprintln!("{}", "Initializing project from template...".cyan()); + + match config.template_type { + TemplateType::Builtin => init_builtin(config, project_path)?, + TemplateType::GitHub => init_github_template(config, project_path)?, + TemplateType::Empty => init_empty(config, project_path)?, + } + + // Copy .cursorrules file from the repository + copy_cursorrules(project_path).await?; + + eprintln!("{}", "Project initialized successfully!".green()); + print_next_steps(config, project_path)?; + + Ok(()) +} + +fn init_builtin(config: &TemplateConfig, project_path: &Path) -> Result<()> { + let template_def = config + .template_def + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Template definition missing"))?; + + // Use the same branch as the templates list if specified + let branch = env::var("SPACETIMEDB_CLI_TEMPLATES_LIST_BRANCH").ok().or_else(|| { + if env::var("SPACETIMEDB_CLI_TEMPLATES_LIST_REPO").is_ok() { + None + } else { + Some(DEFAULT_TEMPLATES_BRANCH.to_string()) + } + }); + + eprintln!("Setting up client ({})...", config.client_lang.as_str()); + let client_source = &template_def.client_source; + let (repo, subdir) = parse_repo_source(client_source); + clone_git_subdirectory( + &format!("https://github.com/{}", repo), + subdir, + project_path, + branch.as_deref(), + )?; + + eprintln!("Setting up server ({})...", config.server_lang.as_str()); + let server_dir = project_path.join("spacetimedb"); + let server_source = &template_def.server_source; + let (repo, subdir) = parse_repo_source(server_source); + clone_git_subdirectory( + &format!("https://github.com/{}", repo), + subdir, + &server_dir, + branch.as_deref(), + )?; + + // TODO: figure out adjustments we may need to do for other client and server langs + if config.server_lang == ServerLanguage::Rust { + configure_rust_server(&server_dir, &config.project_name)?; + } + + if config.client_lang == ClientLanguage::TypeScript { + update_client_package_json(project_path, &config.project_name)?; + update_typescript_client_config(project_path, &config.project_name, config.use_local)?; + eprintln!( + "{}", + "Note: Run 'npm install' in the project directory to install dependencies".yellow() + ); + } + + Ok(()) +} + +fn parse_repo_source(source: &str) -> (String, &str) { + let parts: Vec<&str> = source.splitn(3, '/').collect(); + if parts.len() >= 3 { + let repo = format!("{}/{}", parts[0], parts[1]); + let subdir = parts[2]; + return (repo, subdir); + } + (source.to_string(), "") +} + +fn init_github_template(config: &TemplateConfig, project_path: &Path) -> Result<()> { + let repo = config.github_repo.as_ref().unwrap(); + clone_github_template(repo, project_path)?; + + 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)?; + } + + eprintln!("{}", "Note: Custom templates require manual configuration.".yellow()); + + Ok(()) +} + +fn init_empty(config: &TemplateConfig, project_path: &Path) -> Result<()> { + match config.server_lang { + ServerLanguage::Rust => { + eprintln!("Setting up Rust server..."); + let server_dir = project_path.join("spacetimedb"); + init_empty_rust_server(&server_dir, &config.project_name)?; + } + ServerLanguage::Csharp => { + eprintln!("Setting up C# server..."); + let server_dir = project_path.join("spacetimedb"); + init_empty_csharp_server(&server_dir, &config.project_name)?; + } + ServerLanguage::TypeScript => { + eprintln!("Setting up TypeScript server..."); + let server_dir = project_path.join("spacetimedb"); + init_empty_typescript_server(&server_dir, &config.project_name)?; + } + ServerLanguage::None => {} + } + + match config.client_lang { + ClientLanguage::TypeScript => { + eprintln!("Setting up TypeScript client..."); + let client_dir = project_path.join("client"); + + let branch = env::var("SPACETIMEDB_CLI_TEMPLATES_LIST_BRANCH").ok().or_else(|| { + if env::var("SPACETIMEDB_CLI_TEMPLATES_LIST_REPO").is_ok() { + None + } else { + Some(DEFAULT_TEMPLATES_BRANCH.to_string()) + } + }); + + clone_git_subdirectory( + "https://github.com/clockworklabs/SpacetimeDB", + "crates/bindings-typescript/examples/empty", + &client_dir, + branch.as_deref(), + )?; + + update_client_package_json(&client_dir, &config.project_name)?; + + if config.server_lang != ServerLanguage::None { + // Create package.json with boilerplate for working with the server (like + // `spacetime publish` + create_root_package_json(project_path, &config.project_name, config.use_local)?; + } + + eprintln!( + "{}", + "Note: Run 'npm install' in the project directory to install dependencies".yellow() + ); + } + ClientLanguage::Rust => { + eprintln!("Setting up Rust client..."); + eprintln!("{}", "Rust client setup not yet implemented".yellow()); + } + ClientLanguage::Csharp => { + eprintln!("Setting up C# client..."); + eprintln!("{}", "C# client setup not yet implemented".yellow()); + } + ClientLanguage::None => {} + } + + Ok(()) +} + +fn init_empty_rust_server(server_dir: &Path, _project_name: &str) -> Result<()> { + crate::subcommands::init::init_rust_project(server_dir) +} + +fn init_empty_csharp_server(server_dir: &Path, _project_name: &str) -> Result<()> { + crate::subcommands::init::init_csharp_project(server_dir) +} + +fn init_empty_typescript_server(_server_dir: &Path, _project_name: &str) -> Result<()> { + todo!() +} + +fn print_next_steps(config: &TemplateConfig, _project_path: &Path) -> Result<()> { + eprintln!(); + eprintln!("{}", "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("") { + eprintln!(" cd {}", rel_path.display()); + } + + match (config.template_type, config.server_lang, config.client_lang) { + (TemplateType::Builtin, ServerLanguage::Rust, ClientLanguage::Rust) => { + eprintln!( + " spacetime publish --project-path spacetimedb {}{}", + if config.use_local { "--server local " } else { "" }, + config.project_name + ); + eprintln!(" spacetime generate --lang rust --out-dir src/module_bindings --project-path spacetimedb"); + eprintln!(" cargo run"); + } + (TemplateType::Builtin, ServerLanguage::TypeScript, ClientLanguage::TypeScript) => { + eprintln!(" npm install"); + eprintln!( + " spacetime publish --project-path spacetimedb {}{}", + if config.use_local { "--server local " } else { "" }, + config.project_name + ); + eprintln!( + " spacetime generate --lang typescript --out-dir src/module_bindings --project-path spacetimedb" + ); + eprintln!(" npm run dev"); + } + (TemplateType::Builtin, ServerLanguage::Csharp, ClientLanguage::Csharp) => { + eprintln!( + " spacetime publish --project-path spacetimedb {}{}", + if config.use_local { "--server local " } else { "" }, + config.project_name + ); + eprintln!(" spacetime generate --lang csharp --out-dir src/module_bindings --project-path spacetimedb"); + } + (TemplateType::Empty, _, ClientLanguage::TypeScript) => { + eprintln!(" npm install"); + if config.server_lang != ServerLanguage::None { + eprintln!( + " spacetime publish --project-path spacetimedb {}{}", + if config.use_local { "--server local " } else { "" }, + config.project_name + ); + eprintln!( + " spacetime generate --lang typescript --out-dir src/module_bindings --project-path spacetimedb" + ); + } + eprintln!(" npm run dev"); + } + (TemplateType::Empty, _, ClientLanguage::Rust) => { + if config.server_lang != ServerLanguage::None { + eprintln!( + " spacetime publish --project-path spacetimedb {}{}", + if config.use_local { "--server local " } else { "" }, + config.project_name + ); + eprintln!(" spacetime generate --lang rust --out-dir src/module_bindings --project-path spacetimedb"); + } + eprintln!(" cargo run"); + } + (_, _, _) => { + eprintln!(" # Follow the template's README for setup instructions"); + } + } + + eprintln!(); + eprintln!("Learn more: {}", "https://spacetimedb.com/docs".cyan()); + + Ok(()) +} 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/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-react/client b/crates/cli/templates/basic-react/client new file mode 120000 index 00000000000..791376d289f --- /dev/null +++ b/crates/cli/templates/basic-react/client @@ -0,0 +1 @@ +../../../../crates/bindings-typescript/examples/basic-react \ No newline at end of file diff --git a/crates/cli/templates/basic-react/server/package.json b/crates/cli/templates/basic-react/server/package.json new file mode 100644 index 00000000000..f3fcd1a6495 --- /dev/null +++ b/crates/cli/templates/basic-react/server/package.json @@ -0,0 +1,11 @@ +{ + "name": "spacetimedb-server", + "license": "ISC", + "type": "module", + "scripts": { + "build": "spacetime build" + }, + "dependencies": { + "spacetimedb": "^1.5.0" + } +} diff --git a/crates/cli/templates/basic-react/server/src/index.ts b/crates/cli/templates/basic-react/server/src/index.ts new file mode 100644 index 00000000000..325136cfabf --- /dev/null +++ b/crates/cli/templates/basic-react/server/src/index.ts @@ -0,0 +1,23 @@ +import { spacetimedb, t } from 'spacetimedb/server'; + +export { + __call_reducer__, + __describe_module__, +} from 'spacetimedb/server'; + +const personRow = { + id: t.u32().primaryKey().autoInc(), + name: t.string(), +}; + +const person = spacetimedb.table('person', personRow); + +spacetimedb.reducer('add_person', { name: t.string() }, (ctx, { name }) => { + ctx.db.person.insert({ id: 0, name }); +}); + +spacetimedb.reducer('say_hello', {}, ctx => { + for (const p of ctx.db.person.iter()) { + console.info(`Hello, ${p.name}!`); + } +}); 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..37232552d9a --- /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.5.*" 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..24ce97a07b2 --- /dev/null +++ b/crates/cli/templates/basic-rust/client/src/main.rs @@ -0,0 +1,37 @@ +mod module_bindings; +use module_bindings::*; + +use spacetimedb_sdk::{DbConnection, Table}; + +const HOST: &str = "http://localhost:3000"; +const DB_NAME: &str = "my-db"; + +fn main() { + // 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/client b/crates/cli/templates/basic-typescript/client new file mode 120000 index 00000000000..b19ad51492f --- /dev/null +++ b/crates/cli/templates/basic-typescript/client @@ -0,0 +1 @@ +../../../bindings-typescript/examples/empty \ No newline at end of file diff --git a/crates/cli/templates/basic-typescript/server/.gitignore b/crates/cli/templates/basic-typescript/server/.gitignore new file mode 100644 index 00000000000..dd6e803c7f7 --- /dev/null +++ b/crates/cli/templates/basic-typescript/server/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +*.log +.DS_Store diff --git a/crates/cli/templates/basic-typescript/server/index.ts b/crates/cli/templates/basic-typescript/server/index.ts new file mode 100644 index 00000000000..0b1b52385e5 --- /dev/null +++ b/crates/cli/templates/basic-typescript/server/index.ts @@ -0,0 +1,33 @@ +import { schema, table, t } from 'spacetimedb'; + +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!'); +}); 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..b6f79b99474 --- /dev/null +++ b/crates/cli/templates/basic-typescript/server/tsconfig.json @@ -0,0 +1,23 @@ +/* + * 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": ["./**/*"] +} diff --git a/crates/cli/templates/quickstart-chat-c-sharp/client b/crates/cli/templates/quickstart-chat-c-sharp/client new file mode 120000 index 00000000000..0cf0d864c66 --- /dev/null +++ b/crates/cli/templates/quickstart-chat-c-sharp/client @@ -0,0 +1 @@ +../../../../sdks/csharp/examples~/quickstart-chat/client \ No newline at end of file diff --git a/crates/cli/templates/quickstart-chat-c-sharp/server b/crates/cli/templates/quickstart-chat-c-sharp/server new file mode 120000 index 00000000000..ada40e9188b --- /dev/null +++ b/crates/cli/templates/quickstart-chat-c-sharp/server @@ -0,0 +1 @@ +../../../../sdks/csharp/examples~/quickstart-chat/server \ No newline at end of file diff --git a/crates/cli/templates/quickstart-chat-rust/client b/crates/cli/templates/quickstart-chat-rust/client new file mode 120000 index 00000000000..0b99166155a --- /dev/null +++ b/crates/cli/templates/quickstart-chat-rust/client @@ -0,0 +1 @@ +../../../../sdks/rust/examples/quickstart-chat \ No newline at end of file diff --git a/crates/cli/templates/quickstart-chat-rust/server b/crates/cli/templates/quickstart-chat-rust/server new file mode 120000 index 00000000000..63a3349b38f --- /dev/null +++ b/crates/cli/templates/quickstart-chat-rust/server @@ -0,0 +1 @@ +../../../../modules/quickstart-chat \ No newline at end of file From 4fa6d6eab621302f24bc3e498f094552a19f9b72 Mon Sep 17 00:00:00 2001 From: Piotr Sarnacki Date: Wed, 15 Oct 2025 23:11:04 +0200 Subject: [PATCH 02/11] Fix lints --- crates/cli/src/subcommands/init.rs | 2 ++ crates/cli/src/subcommands/init/template.rs | 11 +++++------ 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/crates/cli/src/subcommands/init.rs b/crates/cli/src/subcommands/init.rs index f1ac0ef4f4a..8693b0b3144 100644 --- a/crates/cli/src/subcommands/init.rs +++ b/crates/cli/src/subcommands/init.rs @@ -1,4 +1,5 @@ mod template; + use crate::util::ModuleLanguage; use crate::Config; use crate::{detect::find_executable, util::UNSTABLE_WARNING}; @@ -6,6 +7,7 @@ use anyhow::Context; use clap::{Arg, ArgMatches}; use colored::Colorize; use std::path::{Path, PathBuf}; + use template as init_template; pub fn cli() -> clap::Command { diff --git a/crates/cli/src/subcommands/init/template.rs b/crates/cli/src/subcommands/init/template.rs index f7bef218d58..e8ce0dbe0c5 100644 --- a/crates/cli/src/subcommands/init/template.rs +++ b/crates/cli/src/subcommands/init/template.rs @@ -2,7 +2,6 @@ use anyhow::{Context, Result}; use clap::ArgMatches; use colored::Colorize; use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select}; -use git2::Repository; use regex::Regex; use reqwest::Url; use serde::{Deserialize, Serialize}; @@ -304,7 +303,7 @@ pub async fn exec_non_interactive_init(config: &mut Config, args: &ArgMatches) - Ok(()) } -pub fn ensure_empty_directory(project_name: &str, project_path: &Path) -> Result<()> { +pub fn ensure_empty_directory(_project_name: &str, project_path: &Path) -> Result<()> { if project_path.exists() { if !project_path.is_dir() { anyhow::bail!( @@ -399,7 +398,7 @@ pub async fn interactive_init() -> Result { .interact()?; let other_index = highlights.len(); - let none_index = highlights.len() + 1; + let _none_index = highlights.len() + 1; if client_selection < highlights.len() { let highlight = &highlights[client_selection]; @@ -503,7 +502,7 @@ fn clone_git_subdirectory(repo_url: &str, subdir: &str, target: &Path, branch: O let mut fetch_options = git2::FetchOptions::new(); let mut callbacks = git2::RemoteCallbacks::new(); - callbacks.credentials(|url, username_from_url, allowed_types| { + 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); @@ -553,7 +552,7 @@ fn clone_github_template(repo_input: &str, target: &Path) -> Result<()> { let mut fetch_options = git2::FetchOptions::new(); let mut callbacks = git2::RemoteCallbacks::new(); - callbacks.credentials(|url, username_from_url, allowed_types| { + 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); @@ -623,7 +622,7 @@ fn configure_rust_server(server_dir: &Path, project_name: &str) -> Result<()> { Ok(()) } -fn create_root_package_json(root: &Path, project_name: &str, use_local: bool) -> Result<()> { +fn create_root_package_json(root: &Path, project_name: &str, _use_local: bool) -> Result<()> { let package_json = json!({ "name": project_name, "version": "0.1.0", From 2e0dfba0b9387375d8d59ddfa20e1cb228fe9fa2 Mon Sep 17 00:00:00 2001 From: Piotr Sarnacki Date: Wed, 15 Oct 2025 23:12:51 +0200 Subject: [PATCH 03/11] Format typescript code --- .../examples/basic-react/src/App.tsx | 33 ++++++++--------- .../examples/basic-react/src/main.tsx | 35 ++++++++++--------- .../examples/basic-react/vite.config.ts | 6 ++-- 3 files changed, 39 insertions(+), 35 deletions(-) diff --git a/crates/bindings-typescript/examples/basic-react/src/App.tsx b/crates/bindings-typescript/examples/basic-react/src/App.tsx index 59af437a7ad..794e88af3d6 100644 --- a/crates/bindings-typescript/examples/basic-react/src/App.tsx +++ b/crates/bindings-typescript/examples/basic-react/src/App.tsx @@ -1,31 +1,32 @@ -import { useState } from 'react' -import { DbConnection, Person } from './module_bindings' -import { useSpacetimeDB, useTable } from 'spacetimedb/react' +import { useState } from 'react'; +import { DbConnection, Person } from './module_bindings'; +import { useSpacetimeDB, useTable } from 'spacetimedb/react'; function App() { - const [name, setName] = useState('') + const [name, setName] = useState(''); - const conn = useSpacetimeDB() - const { isActive: connected } = conn + const conn = useSpacetimeDB(); + const { isActive: connected } = conn; // Subscribe to all people in the database - const { rows: people } = useTable('person') + const { rows: people } = useTable('person'); const addPerson = (e: React.FormEvent) => { - e.preventDefault() - if (!name.trim() || !connected) return + e.preventDefault(); + if (!name.trim() || !connected) return; // Call the add reducer - conn.reducers.add(name) - setName('') - } + conn.reducers.add(name); + setName(''); + }; return (

SpacetimeDB React App

- Status: + Status:{' '} + {connected ? 'Connected' : 'Disconnected'}
@@ -35,7 +36,7 @@ function App() { type="text" placeholder="Enter name" value={name} - onChange={(e) => setName(e.target.value)} + onChange={e => setName(e.target.value)} style={{ padding: '0.5rem', marginRight: '0.5rem' }} disabled={!connected} /> @@ -61,7 +62,7 @@ function App() { )}
- ) + ); } -export default App +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 index 1265e9d24a1..a863da3eb88 100644 --- a/crates/bindings-typescript/examples/basic-react/src/main.tsx +++ b/crates/bindings-typescript/examples/basic-react/src/main.tsx @@ -1,22 +1,25 @@ -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' +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 onConnect = (conn: DbConnection, identity: Identity, token: string) => { - localStorage.setItem('auth_token', token) - console.log('Connected to SpacetimeDB with identity:', identity.toHexString()) -} + localStorage.setItem('auth_token', token); + console.log( + 'Connected to SpacetimeDB with identity:', + identity.toHexString() + ); +}; const onDisconnect = () => { - console.log('Disconnected from SpacetimeDB') -} + console.log('Disconnected from SpacetimeDB'); +}; const onConnectError = (_ctx: ErrorContext, err: Error) => { - console.log('Error connecting to SpacetimeDB:', err) -} + console.log('Error connecting to SpacetimeDB:', err); +}; const connectionBuilder = DbConnection.builder() .withUri('ws://localhost:3000') @@ -24,12 +27,12 @@ const connectionBuilder = DbConnection.builder() .withToken(localStorage.getItem('auth_token') || undefined) .onConnect(onConnect) .onDisconnect(onDisconnect) - .onConnectError(onConnectError) + .onConnectError(onConnectError); createRoot(document.getElementById('root')!).render( - , -) + +); diff --git a/crates/bindings-typescript/examples/basic-react/vite.config.ts b/crates/bindings-typescript/examples/basic-react/vite.config.ts index 9ffcc675746..0466183af6a 100644 --- a/crates/bindings-typescript/examples/basic-react/vite.config.ts +++ b/crates/bindings-typescript/examples/basic-react/vite.config.ts @@ -1,6 +1,6 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; export default defineConfig({ plugins: [react()], -}) +}); From cc118cca04dce7baa73ed05fe9f767cf2e03a02a Mon Sep 17 00:00:00 2001 From: Piotr Sarnacki Date: Wed, 15 Oct 2025 23:34:39 +0200 Subject: [PATCH 04/11] Update eslint config --- crates/bindings-typescript/examples/basic-react/tsconfig.json | 2 +- crates/bindings-typescript/examples/empty/tsconfig.json | 2 +- eslint.config.js | 2 ++ 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/bindings-typescript/examples/basic-react/tsconfig.json b/crates/bindings-typescript/examples/basic-react/tsconfig.json index dad2706aad6..c7224f57541 100644 --- a/crates/bindings-typescript/examples/basic-react/tsconfig.json +++ b/crates/bindings-typescript/examples/basic-react/tsconfig.json @@ -19,5 +19,5 @@ "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true }, - "include": ["src"] + "include": ["src", "vite.config.ts"] } diff --git a/crates/bindings-typescript/examples/empty/tsconfig.json b/crates/bindings-typescript/examples/empty/tsconfig.json index 0511b9f0e04..a9f71bc427b 100644 --- a/crates/bindings-typescript/examples/empty/tsconfig.json +++ b/crates/bindings-typescript/examples/empty/tsconfig.json @@ -19,5 +19,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src", "vite.config.ts"] } diff --git a/eslint.config.js b/eslint.config.js index 8399745dc12..3ca05584ff7 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -38,6 +38,8 @@ export default tseslint.config( './tsconfig.json', './crates/bindings-typescript/tsconfig.json', './crates/bindings-typescript/test-app/tsconfig.json', + './crates/bindings-typescript/examples/basic-react/tsconfig.json', + './crates/bindings-typescript/examples/empty/tsconfig.json', './crates/bindings-typescript/examples/quickstart-chat/tsconfig.json', './docs/tsconfig.json', ], From b0410e86ad8017d5b2276504e23a2060db102f27 Mon Sep 17 00:00:00 2001 From: Piotr Sarnacki Date: Thu, 16 Oct 2025 14:17:36 +0200 Subject: [PATCH 05/11] Small chnages in init args --- crates/cli/src/subcommands/init.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/cli/src/subcommands/init.rs b/crates/cli/src/subcommands/init.rs index 8693b0b3144..e4836dca227 100644 --- a/crates/cli/src/subcommands/init.rs +++ b/crates/cli/src/subcommands/init.rs @@ -16,6 +16,7 @@ pub fn cli() -> clap::Command { .arg( Arg::new("project-path") .value_parser(clap::value_parser!(PathBuf)) + .default_value(".") .help("The path where we will create the spacetime project"), ) .arg( @@ -55,11 +56,10 @@ pub fn cli() -> clap::Command { Arg::new("local") .long("local") .action(clap::ArgAction::SetTrue) - .help("Use local deployment instead of Maincloud (non-interactive mode only)"), + .help("Use local deployment instead of Maincloud"), ) .arg( Arg::new("non-interactive") - .short('y') .long("non-interactive") .action(clap::ArgAction::SetTrue) .help("Run in non-interactive mode with default or provided options"), From 2393b43ba6c0637b8bcd4bbbd42d7d75b211dcdf Mon Sep 17 00:00:00 2001 From: Piotr Sarnacki Date: Thu, 16 Oct 2025 14:28:25 +0200 Subject: [PATCH 06/11] Fix smoke tests --- smoketests/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/smoketests/__init__.py b/smoketests/__init__.py index 63486c09b08..6f2c5b39247 100644 --- a/smoketests/__init__.py +++ b/smoketests/__init__.py @@ -24,8 +24,8 @@ STDB_CONFIG = TEST_DIR / "config.toml" # the contents of files for the base smoketest project template -TEMPLATE_LIB_RS = open(STDB_DIR / "crates/cli/src/subcommands/project/rust/lib._rs").read() -TEMPLATE_CARGO_TOML = open(STDB_DIR / "crates/cli/src/subcommands/project/rust/Cargo._toml").read() +TEMPLATE_LIB_RS = open(STDB_DIR / "crates/cli/templates/basic-rust/server/src/lib.rs").read() +TEMPLATE_CARGO_TOML = open(STDB_DIR / "crates/cli/templates/basic-rust/server/Cargo.toml").read() bindings_path = (STDB_DIR / "crates/bindings").absolute() escaped_bindings_path = str(bindings_path).replace('\\', '\\\\\\\\') # double escape for re.sub + toml TEMPLATE_CARGO_TOML = (re.compile(r"^spacetimedb\s*=.*$", re.M) \ From f5839a64e48242d6b8cc1be897a343660890876b Mon Sep 17 00:00:00 2001 From: Piotr Sarnacki Date: Thu, 16 Oct 2025 14:29:37 +0200 Subject: [PATCH 07/11] Fix cli refernece docs --- docs/docs/cli-reference.md | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/docs/docs/cli-reference.md b/docs/docs/cli-reference.md index 11045a959f1..c37a0f8eb81 100644 --- a/docs/docs/cli-reference.md +++ b/docs/docs/cli-reference.md @@ -321,19 +321,26 @@ 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]` - -###### Arguments: - -* `` — The path where we will create the spacetime project - ++**Usage:** `spacetime init [OPTIONS] [project-path]` + + ###### Arguments: + + * `` — The path where we will create the spacetime project + Default value: `.` -###### Options: - -* `-l`, `--lang ` — The spacetime module language. - - Possible values: `csharp`, `rust` + ###### Options: + +* `-n`, `--name ` — Project name (defaults to directory name if not provided) + * `-l`, `--lang ` — The spacetime module language. + + Possible values: `csharp`, `rust` +* `--server-lang ` — Server language: rust, csharp, typescript +* `-t`, `--template