diff --git a/.env.sample b/.env.sample deleted file mode 100644 index 716fcb631d0..00000000000 --- a/.env.sample +++ /dev/null @@ -1,2 +0,0 @@ -SENDGRID_API_KEY= -SENDGRID_SENDER= diff --git a/Cargo.lock b/Cargo.lock index 66f26c0f526..ed929d1e017 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1685,6 +1685,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "658bce805d770f407bc62102fca7c2c64ceef2fbcb2b8bd19d2765ce093980de" dependencies = [ "console", + "fuzzy-matcher", "shell-words", "thiserror 1.0.69", ] @@ -2331,6 +2332,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "fxhash" version = "0.2.1" @@ -3147,6 +3157,17 @@ dependencies = [ "str_stack", ] +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + [[package]] name = "inotify" version = "0.11.0" @@ -3819,6 +3840,16 @@ dependencies = [ "syn 2.0.107", ] +[[package]] +name = "names" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bddcd3bf5144b6392de80e04c347cd7fab2508f6df16a85fc496ecd5cec39bc" +dependencies = [ + "clap 3.2.23", + "rand 0.8.5", +] + [[package]] name = "native-tls" version = "0.2.14" @@ -3905,6 +3936,25 @@ version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.10.0", + "filetime", + "fsevent-sys", + "inotify 0.10.2", + "kqueue", + "libc", + "log", + "mio 1.1.0", + "notify-types 1.0.1", + "walkdir", + "windows-sys 0.52.0", +] + [[package]] name = "notify" version = "8.2.0" @@ -3912,12 +3962,12 @@ source = "git+https://github.com/sapphi-red/notify?rev=refs%2Fheads%2Fpatches#cf dependencies = [ "bitflags 2.10.0", "fsevent-sys", - "inotify", + "inotify 0.11.0", "kqueue", "libc", "log", "mio 1.1.0", - "notify-types", + "notify-types 2.0.0", "walkdir", "windows-sys 0.60.2", ] @@ -3929,11 +3979,20 @@ source = "git+https://github.com/sapphi-red/notify?rev=refs%2Fheads%2Fpatches#cf dependencies = [ "file-id", "log", - "notify", - "notify-types", + "notify 8.2.0", + "notify-types 2.0.0", "walkdir", ] +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + [[package]] name = "notify-types" version = "2.0.0" @@ -5878,7 +5937,7 @@ dependencies = [ "itertools 0.14.0", "itoa", "memchr", - "notify", + "notify 8.2.0", "oxc", "oxc_allocator", "oxc_ecmascript", @@ -6196,7 +6255,7 @@ name = "rolldown_watcher" version = "0.1.0" source = "git+https://github.com/rolldown/rolldown.git?tag=v1.0.0-beta.42#596339764e63f3403e425c2cd8b23a1bc7170f77" dependencies = [ - "notify", + "notify 8.2.0", "notify-debouncer-full", "rolldown_error", ] @@ -7082,6 +7141,8 @@ dependencies = [ "is-terminal", "itertools 0.12.1", "mimalloc", + "names", + "notify 7.0.0", "percent-encoding", "quick-xml 0.31.0", "regex", diff --git a/Cargo.toml b/Cargo.toml index c6c9a2f93b6..5bb1d180aed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -207,7 +207,9 @@ lazy_static = "1.4.0" log = "0.4.17" memchr = "2" mimalloc = "0.1.39" +names = "0.14" nohash-hasher = "0.2" +notify = "7.0" nix = "0.30" once_cell = "1.16" parking_lot = { version = "0.12.1", features = ["send_guard", "arc_lock"] } diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index efd41596af8..04399c00090 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -75,11 +75,13 @@ wasmbin.workspace = true webbrowser.workspace = true clap-markdown.workspace = true git2.workspace = true -dialoguer.workspace = true +dialoguer = { workspace = true, features = ["fuzzy-select"] } rolldown.workspace = true rolldown_utils.workspace = true xmltree.workspace = true quick-xml.workspace = true +names.workspace = true +notify.workspace = true [target.'cfg(not(target_env = "msvc"))'.dependencies] tikv-jemallocator = { workspace = true } diff --git a/crates/cli/src/config.rs b/crates/cli/src/config.rs index 0c7114e18ce..93b5ff03121 100644 --- a/crates/cli/src/config.rs +++ b/crates/cli/src/config.rs @@ -169,8 +169,8 @@ impl RawConfig { ecdsa_public_key: None, }; RawConfig { - default_server: local.nickname.clone(), - server_configs: vec![local, maincloud], + default_server: maincloud.nickname.clone(), + server_configs: vec![maincloud, local], web_session_token: None, spacetimedb_token: None, } diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index c8faec42819..51338dfe289 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -25,6 +25,7 @@ pub fn get_subcommands() -> Vec { logs::cli(), call::cli(), describe::cli(), + dev::cli(), energy::cli(), sql::cli(), dns::cli(), @@ -51,6 +52,7 @@ pub async fn exec_subcommand( match cmd { "call" => call::exec(config, args).await, "describe" => describe::exec(config, args).await, + "dev" => dev::exec(config, args).await, "energy" => energy::exec(config, args).await, "publish" => publish::exec(config, args).await, "delete" => delete::exec(config, args).await, diff --git a/crates/cli/src/subcommands/dev.rs b/crates/cli/src/subcommands/dev.rs new file mode 100644 index 00000000000..868df9c8fe8 --- /dev/null +++ b/crates/cli/src/subcommands/dev.rs @@ -0,0 +1,681 @@ +use crate::config::Config; +use crate::generate::Language; +use crate::subcommands::init; +use crate::util::{ + add_auth_header_opt, database_identity, detect_module_language, get_auth_header, get_login_token_or_log_in, + spacetime_reverse_dns, ResponseExt, +}; +use crate::{common_args, generate}; +use crate::{publish, tasks}; +use anyhow::Context; +use clap::{Arg, ArgMatches, Command}; +use colored::Colorize; +use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect}; +use futures::stream::{self, StreamExt}; +use futures::{AsyncBufReadExt, TryStreamExt}; +use indicatif::{ProgressBar, ProgressStyle}; +use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher}; +use regex::Regex; +use serde::Deserialize; +use std::borrow::Cow; +use std::fs; +use std::io::IsTerminal; +use std::path::{Path, PathBuf}; +use std::sync::mpsc::channel; +use std::time::Duration; +use tabled::{ + settings::{object::Columns, Alignment, Modify, Style}, + Table, Tabled, +}; +use termcolor::{Color, ColorSpec, WriteColor}; +use tokio::task::JoinHandle; +use tokio::time::sleep; + +pub fn cli() -> Command { + Command::new("dev") + .about("Start development mode with auto-regenerate client module bindings, auto-rebuild, and auto-publish on file changes.") + .arg( + Arg::new("database") + .long("database") + .help("The database name/identity to publish to (optional, will prompt if not provided)"), + ) + .arg( + Arg::new("project-path") + .long("project-path") + .value_parser(clap::value_parser!(PathBuf)) + .default_value(".") + .help("The path to the project directory"), + ) + .arg( + Arg::new("module-bindings-path") + .long("module-bindings-path") + .value_parser(clap::value_parser!(PathBuf)) + .default_value("src/module_bindings") + .help("The path to the module bindings directory relative to the project directory, defaults to `/src/module_bindings`"), + ) + // NOTE: All server templates must have their server code in `spacetimedb/` directory + // This is not a requirement in general, but is a requirement for all templates + // i.e. `spacetime dev` is valid on non-templates. + .arg( + Arg::new("module-project-path") + .long("module-project-path") + .value_parser(clap::value_parser!(PathBuf)) + .default_value("spacetimedb") + .help("The path to the SpacetimeDB server module project relative to the project directory, defaults to `/spacetimedb`"), + ) + .arg( + Arg::new("client-lang") + .long("client-lang") + .value_parser(clap::value_parser!(Language)) + .help("The programming language for the generated client module bindings (e.g., typescript, csharp, python). If not specified, it will be detected from the project."), + ) + .arg(common_args::server().help("The nickname, host name or URL of the server to publish to")) + .arg(common_args::yes()) +} + +#[derive(Deserialize)] +struct DatabasesResult { + pub identities: Vec, +} + +#[derive(Tabled, Clone)] +struct DatabaseRow { + pub identity: String, + pub name: String, +} + +pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::Error> { + let project_path = args.get_one::("project-path").unwrap(); + let spacetimedb_project_path = args.get_one::("module-project-path").unwrap(); + let module_bindings_path = args.get_one::("module-bindings-path").unwrap(); + let client_language = args.get_one::("client-lang"); + let force = args.get_flag("force"); + + // If you don't specify a server, we default to your default server + // If you don't have one of those, we default to "maincloud" + let server = args.get_one::("server").map(|s| s.as_str()); + + let default_server_name = config.default_server_name().map(|s| s.to_string()); + + let mut resolved_server = server + .or(default_server_name.as_deref()) + .ok_or_else(|| anyhow::anyhow!("Server not specified and no default server configured."))?; + + let mut project_dir = project_path.clone(); + + if module_bindings_path.is_absolute() { + anyhow::bail!("Module bindings path must be a relative path"); + } + let mut module_bindings_dir = project_dir.join(module_bindings_path); + + if spacetimedb_project_path.is_absolute() { + anyhow::bail!("SpacetimeDB project path must be a relative path"); + } + let mut spacetimedb_dir = project_dir.join(spacetimedb_project_path); + + // Check if we are in a SpacetimeDB project directory + if !spacetimedb_dir.exists() || !spacetimedb_dir.is_dir() { + println!("{}", "No SpacetimeDB project found in current directory.".yellow()); + let should_init = Confirm::new() + .with_prompt("Would you like to initialize a new project?") + .default(true) + .interact()?; + + if should_init { + let init_args = init::cli().get_matches_from(if resolved_server == "local" { + vec!["init", "--local"] + } else { + vec!["init"] + }); + let created_project_path = init::exec(config.clone(), &init_args).await?; + + let canonical_created_path = created_project_path + .canonicalize() + .context("Failed to canonicalize created project path")?; + spacetimedb_dir = canonical_created_path.join(spacetimedb_project_path); + module_bindings_dir = canonical_created_path.join(module_bindings_path); + project_dir = canonical_created_path; + + if !spacetimedb_dir.exists() { + anyhow::bail!("Project initialization did not create spacetimedb directory"); + } + } else { + anyhow::bail!("Not in a SpacetimeDB project directory"); + } + } + + if !module_bindings_dir.exists() { + // Create the module bindings directory if it doesn't exist + std::fs::create_dir_all(&module_bindings_dir).with_context(|| { + format!( + "Failed to create module bindings path {}", + module_bindings_dir.display() + ) + })?; + } else if !module_bindings_dir.is_dir() { + anyhow::bail!( + "Module bindings path {} exists but is not a directory.", + module_bindings_path.display() + ); + } + + if resolved_server == "maincloud" && config.spacetimedb_token().is_none() { + let should_login = Confirm::new() + .with_prompt("Would you like to sign in now?") + .default(true) + .interact()?; + if !should_login && server.is_some() { + // The user explicitly provided --server maincloud but doesn't want to log in + anyhow::bail!("Login required to publish to maincloud server"); + } else if !should_login { + // Print warning saying that without logging in we will use local server regardless + // of what their default server is in their config + println!( + "{} {}", + "Warning:".yellow().bold(), + "Without logging in, the local server will be used regardless of your default server.".dimmed() + ); + // Switch the server to local + resolved_server = "local"; + } else { + // Login + get_login_token_or_log_in(&mut config, Some(resolved_server), !force).await?; + } + } + let use_local = resolved_server == "local"; + + let database_name = if let Some(name) = args.get_one::("database") { + name.clone() + } else { + println!("\n{}", "Found existing SpacetimeDB project.".green()); + println!("Now we need to select a database to publish to.\n"); + + if use_local { + generate_database_name() + } else { + // If not logged in before, but login was successful just now, this will have the token + let token = get_login_token_or_log_in(&mut config, Some(resolved_server), !force).await?; + + let choice = FuzzySelect::with_theme(&ColorfulTheme::default()) + .with_prompt("Database selection") + .items(&["Create new database with random name", "Select from existing databases"]) + .default(0) + .interact()?; + + if choice == 0 { + generate_database_name() + } else { + select_database(&config, resolved_server, &token).await? + } + } + }; + + if !args.contains_id("database") { + println!("\n{} {}", "Selected database:".green().bold(), database_name.cyan()); + println!( + "{} {}", + "Tip:".yellow().bold(), + format!("Use `--database {}` to skip this question next time", database_name).dimmed() + ); + } + + println!("\n{}", "Starting development mode...".green().bold()); + println!("Database: {}", database_name.cyan()); + println!( + "Watching for changes in: {}", + spacetimedb_dir.display().to_string().cyan() + ); + println!("{}", "Press Ctrl+C to stop".dimmed()); + println!(); + + generate_build_and_publish( + &config, + &project_dir, + &spacetimedb_dir, + &module_bindings_dir, + &database_name, + client_language, + resolved_server, + ) + .await?; + + // Sleep for a second to allow the database to be published on Maincloud + sleep(Duration::from_secs(1)).await; + + let db_identity = database_identity(&config, &database_name, Some(resolved_server)).await?; + let _log_handle = start_log_stream(config.clone(), db_identity.to_hex().to_string(), Some(resolved_server)).await?; + + let (tx, rx) = channel(); + let mut watcher: RecommendedWatcher = Watcher::new( + move |res: Result| { + if let Ok(event) = res { + if matches!( + event.kind, + notify::EventKind::Modify(_) | notify::EventKind::Create(_) | notify::EventKind::Remove(_) + ) { + let _ = tx.send(()); + } + } + }, + notify::Config::default().with_poll_interval(Duration::from_millis(500)), + )?; + + let src_dir = spacetimedb_dir.join("src"); + watcher.watch(&src_dir, RecursiveMode::Recursive)?; + + println!("{}", "Watching for file changes...".dimmed()); + + let mut debounce_timer; + loop { + if rx.recv().is_ok() { + debounce_timer = std::time::Instant::now(); + while debounce_timer.elapsed() < Duration::from_millis(300) { + if rx.recv_timeout(Duration::from_millis(100)).is_ok() { + debounce_timer = std::time::Instant::now(); + } + } + + println!("\n{}", "File change detected, rebuilding...".yellow()); + match generate_build_and_publish( + &config, + &project_dir, + &spacetimedb_dir, + &module_bindings_dir, + &database_name, + client_language, + resolved_server, + ) + .await + { + Ok(_) => {} + Err(e) => { + eprintln!("{} {}", "Error:".red().bold(), e); + println!("{}", "Waiting for next change...".dimmed()); + } + } + } + } +} + +/// Upserts all SPACETIMEDB_DB_NAME and SPACETIMEDB_HOST variants into `.env.local`, +/// preserving comments/formatting and leaving unrelated keys unchanged. +fn upsert_env_db_names_and_hosts(env_path: &Path, server_host_url: &str, database_name: &str) -> anyhow::Result<()> { + // Framework-agnostic variants (same list for both DB_NAME and HOST) + let prefixes = [ + "SPACETIMEDB", // generic / backend + "VITE_SPACETIMEDB", // Vite + "NEXT_PUBLIC_SPACETIMEDB", // Next.js + "REACT_APP_SPACETIMEDB", // CRA + "EXPO_PUBLIC_SPACETIMEDB", // Expo + "PUBLIC_SPACETIMEDB", // SvelteKit + ]; + + let mut contents = if env_path.exists() { + fs::read_to_string(env_path)? + } else { + String::new() + }; + + for prefix in prefixes { + for (suffix, value) in [("DB_NAME", database_name), ("HOST", server_host_url)] { + let key = format!("{prefix}_{suffix}"); + let re = Regex::new(&format!(r"(?m)^(?P\s*{key}\s*=\s*)(?P.*)$"))?; + if re.is_match(&contents) { + contents = re.replace_all(&contents, format!("${{prefix}}{value}")).to_string(); + } else { + if !contents.is_empty() && !contents.ends_with('\n') { + contents.push('\n'); + } + contents.push_str(&format!("{key}={value}\n")); + } + } + } + + if !contents.ends_with('\n') { + contents.push('\n'); + } + + fs::write(env_path, contents)?; + Ok(()) +} + +async fn generate_build_and_publish( + config: &Config, + project_dir: &Path, + spacetimedb_dir: &Path, + module_bindings_dir: &Path, + database_name: &str, + client_language: Option<&Language>, + server: &str, +) -> Result<(), anyhow::Error> { + let module_language = detect_module_language(spacetimedb_dir)?; + let client_language = client_language.unwrap_or(match module_language { + crate::util::ModuleLanguage::Rust => &Language::Rust, + crate::util::ModuleLanguage::Csharp => &Language::Csharp, + crate::util::ModuleLanguage::Javascript => &Language::TypeScript, + }); + let client_language_str = match client_language { + Language::Rust => "rust", + Language::Csharp => "csharp", + Language::TypeScript => "typescript", + Language::UnrealCpp => "unrealcpp", + }; + + if client_language == &Language::TypeScript { + // Update SPACETIMEDB_DBNAME environment variables in `.env.local` for TypeScript client + println!( + "{} {}...", + "Updating .env.local with database name".cyan(), + database_name + ); + let env_path = project_dir.join(".env.local"); + let server_host_url = config.get_host_url(Some(server))?; + upsert_env_db_names_and_hosts(&env_path, &server_host_url, database_name)?; + } + + println!("{}", "Building...".cyan()); + let (_path_to_program, _host_type) = + tasks::build(spacetimedb_dir, Some(Path::new("src")), false).context("Failed to build project")?; + println!("{}", "Build complete!".green()); + + println!("{}", "Generating module bindings...".cyan()); + let generate_args = generate::cli().get_matches_from(vec![ + "generate", + "--lang", + client_language_str, + "--project-path", + spacetimedb_dir.to_str().unwrap(), + "--out-dir", + module_bindings_dir.to_str().unwrap(), + ]); + generate::exec(config.clone(), &generate_args).await?; + + println!("{}", "Publishing...".cyan()); + + let project_path_str = spacetimedb_dir.to_str().unwrap(); + + let mut publish_args = vec!["publish", database_name, "--project-path", project_path_str, "--yes"]; + publish_args.extend_from_slice(&["--server", server]); + + let publish_cmd = publish::cli(); + let publish_matches = publish_cmd + .try_get_matches_from(publish_args) + .context("Failed to create publish arguments")?; + + publish::exec(config.clone(), &publish_matches).await?; + + println!("{}", "Published successfully!".green().bold()); + println!("{}", "---".dimmed()); + + Ok(()) +} + +async fn select_database(config: &Config, server: &str, token: &str) -> Result { + let identity = crate::util::decode_identity(&token.to_string())?; + + let spinner = ProgressBar::new_spinner(); + spinner.set_style( + ProgressStyle::default_spinner() + .template("{spinner:.cyan} {msg}") + .unwrap(), + ); + spinner.set_message("Fetching database list..."); + spinner.enable_steady_tick(Duration::from_millis(100)); + + let client = reqwest::Client::new(); + let res = client + .get(format!( + "{}/v1/identity/{}/databases", + config.get_host_url(Some(server))?, + identity + )) + .bearer_auth(token) + .send() + .await?; + + let result: DatabasesResult = res + .json_or_error() + .await + .context("Unable to retrieve databases for identity")?; + + if result.identities.is_empty() { + spinner.finish_and_clear(); + println!("{}", "No existing databases found.".yellow()); + Ok(generate_database_name()) + } else { + let total = result.identities.len(); + spinner.set_message(format!("Fetching names for {} databases...", total)); + + // Fetch database names with HTTP queries to /database/{identity}/names + // It's parallelyzed in case a user has a lot of databases + // TODO: we should introduce an endpoint that returns user's databases with names + let databases: Vec = stream::iter(result.identities.into_iter()) + .map(|identity_str| { + let config = config.clone(); + async move { + let names_response = spacetime_reverse_dns(&config, &identity_str, Some(server)).await?; + let name = if names_response.names.is_empty() { + identity_str.clone() + } else { + names_response.names[0].as_ref().to_string() + }; + Ok::(DatabaseRow { + identity: identity_str, + name, + }) + } + }) + .buffer_unordered(30) + .collect::>() + .await + .into_iter() + .collect::, _>>()?; + + spinner.finish_and_clear(); + + let display_limit = 10; + if databases.len() <= display_limit { + let mut table = Table::new(&databases); + table + .with(Style::psql()) + .with(Modify::new(Columns::first()).with(Alignment::left())); + println!("\nYour databases:\n"); + println!("{table}"); + println!(); + } else { + let display_databases: Vec<_> = databases.iter().take(display_limit).cloned().collect(); + let mut table = Table::new(&display_databases); + table + .with(Style::psql()) + .with(Modify::new(Columns::first()).with(Alignment::left())); + println!("\nYour databases (showing {} of {}):\n", display_limit, databases.len()); + println!("{table}"); + println!(); + } + + let items: Vec = databases + .iter() + .map(|db| { + let truncated_identity = truncate_identity(&db.identity); + format!("{} ({})", db.name, truncated_identity) + }) + .collect(); + + let selection = FuzzySelect::with_theme(&ColorfulTheme::default()) + .with_prompt("Select database (type to filter)") + .items(&items) + .default(0) + .interact()?; + + Ok(databases[selection].name.clone()) + } +} + +fn truncate_identity(identity: &str) -> String { + if identity.len() <= 16 { + identity.to_string() + } else { + format!("{}...{}", &identity[..8], &identity[identity.len() - 8..]) + } +} + +async fn start_log_stream( + mut config: Config, + database_identity: String, + server: Option<&str>, +) -> Result, anyhow::Error> { + let server = server.map(|s| s.to_string()); + let host_url = config.get_host_url(server.as_deref())?; + let auth_header = get_auth_header(&mut config, false, server.as_deref(), false).await?; + + let handle = tokio::spawn(async move { + loop { + if let Err(e) = stream_logs(&host_url, &database_identity, &auth_header).await { + eprintln!("\n{} Log streaming error: {}", "Error:".red().bold(), e); + eprintln!("{}", "Reconnecting in 10 seconds...".yellow()); + tokio::time::sleep(Duration::from_secs(10)).await; + } + } + }); + + Ok(handle) +} + +async fn stream_logs( + host_url: &str, + database_identity: &str, + auth_header: &crate::util::AuthHeader, +) -> Result<(), anyhow::Error> { + let client = reqwest::Client::new(); + let builder = client.get(format!("{host_url}/v1/database/{database_identity}/logs")); + let builder = add_auth_header_opt(builder, auth_header); + let res = builder.query(&[("num_lines", "10"), ("follow", "true")]).send().await?; + + let status = res.status(); + if status.is_client_error() || status.is_server_error() { + let err = res.text().await?; + anyhow::bail!(err) + } + + let term_color = if std::io::stdout().is_terminal() { + termcolor::ColorChoice::Auto + } else { + termcolor::ColorChoice::Never + }; + + let mut rdr = res.bytes_stream().map_err(std::io::Error::other).into_async_read(); + let mut line = String::new(); + while rdr.read_line(&mut line).await? != 0 { + let record = serde_json::from_str::>(&line)?; + let out = termcolor::StandardStream::stdout(term_color); + let mut out = out.lock(); + format_log_record(&mut out, &record)?; + drop(out); + line.clear(); + } + + Ok(()) +} + +const SENTINEL: &str = "__spacetimedb__"; + +#[derive(serde::Deserialize)] +enum LogLevel { + Error, + Warn, + Info, + Debug, + Trace, + Panic, +} + +#[serde_with::serde_as] +#[derive(serde::Deserialize)] +struct LogRecord<'a> { + #[serde_as(as = "Option")] + ts: Option>, + level: LogLevel, + #[serde(borrow)] + #[allow(unused)] + target: Option>, + #[serde(borrow)] + filename: Option>, + line_number: Option, + #[serde(borrow)] + function: Option>, + #[serde(borrow)] + message: Cow<'a, str>, +} + +fn format_log_record(out: &mut W, record: &LogRecord<'_>) -> Result<(), std::io::Error> { + if let Some(ts) = record.ts { + out.set_color(ColorSpec::new().set_dimmed(true))?; + write!(out, "{ts:?} ")?; + } + let mut color = ColorSpec::new(); + let level = match record.level { + LogLevel::Error => { + color.set_fg(Some(Color::Red)); + "ERROR" + } + LogLevel::Warn => { + color.set_fg(Some(Color::Yellow)); + "WARN" + } + LogLevel::Info => { + color.set_fg(Some(Color::Blue)); + "INFO" + } + LogLevel::Debug => { + color.set_dimmed(true).set_bold(true); + "DEBUG" + } + LogLevel::Trace => { + color.set_dimmed(true); + "TRACE" + } + LogLevel::Panic => { + color.set_fg(Some(Color::Red)).set_bold(true).set_intense(true); + "PANIC" + } + }; + out.set_color(&color)?; + write!(out, "{level:>5}: ")?; + out.reset()?; + let mut need_space_before_filename = false; + let mut need_colon_sep = false; + let dimmed = ColorSpec::new().set_dimmed(true).clone(); + if let Some(function) = &record.function { + if function.as_ref() != SENTINEL { + out.set_color(&dimmed)?; + write!(out, "{function}")?; + out.reset()?; + need_space_before_filename = true; + need_colon_sep = true; + } + } + if let Some(filename) = &record.filename { + if filename.as_ref() != SENTINEL { + out.set_color(&dimmed)?; + if need_space_before_filename { + write!(out, " ")?; + } + write!(out, "{filename}")?; + if let Some(line) = record.line_number { + write!(out, ":{line}")?; + } + out.reset()?; + need_colon_sep = true; + } + } + if need_colon_sep { + write!(out, ": ")?; + } + writeln!(out, "{}", record.message)?; + Ok(()) +} + +fn generate_database_name() -> String { + let mut generator = names::Generator::with_naming(names::Name::Numbered); + generator.next().unwrap() +} diff --git a/crates/cli/src/subcommands/generate.rs b/crates/cli/src/subcommands/generate.rs index f8da4eb731d..c090baa046a 100644 --- a/crates/cli/src/subcommands/generate.rs +++ b/crates/cli/src/subcommands/generate.rs @@ -262,7 +262,7 @@ pub async fn exec_ex( Ok(()) } -#[derive(Clone, Copy, PartialEq)] +#[derive(Debug, Clone, Copy, PartialEq)] pub enum Language { Csharp, TypeScript, diff --git a/crates/cli/src/subcommands/mod.rs b/crates/cli/src/subcommands/mod.rs index debdac865e3..a50910ce8bd 100644 --- a/crates/cli/src/subcommands/mod.rs +++ b/crates/cli/src/subcommands/mod.rs @@ -2,6 +2,7 @@ pub mod build; pub mod call; pub mod delete; pub mod describe; +pub mod dev; pub mod dns; pub mod energy; pub mod generate; diff --git a/crates/cli/src/subcommands/start.rs b/crates/cli/src/subcommands/start.rs index b6df94917f1..859bd86094d 100644 --- a/crates/cli/src/subcommands/start.rs +++ b/crates/cli/src/subcommands/start.rs @@ -95,7 +95,7 @@ pub(crate) fn exec_replace(cmd: &mut Command) -> io::Result { } unsafe { if SetConsoleCtrlHandler(Some(ctrlc_handler), TRUE) == FALSE { - return Err(io::Error::new(io::ErrorKind::Other, "Unable to set console handler")); + return Err(io::Error::other("Unable to set console handler")); } } diff --git a/docs/docs/03-Unity Tutorial/03-part-2.md b/docs/docs/03-Unity Tutorial/03-part-2.md index df7f0a68cfa..4dca41cfc4b 100644 --- a/docs/docs/03-Unity Tutorial/03-part-2.md +++ b/docs/docs/03-Unity Tutorial/03-part-2.md @@ -461,7 +461,7 @@ The `client_connected` argument to the `spacetimedb::reducer` macro indicates to > SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. > -> - `init` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --delete-data`. +> - `init` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --server local --delete-data`. > - `client_connected` - Called when a user connects to the SpacetimeDB database. Their identity can be found in the `sender` value of the `ReducerContext`. > - `client_disconnected` - Called when a user disconnects from the SpacetimeDB database. @@ -481,7 +481,7 @@ The `ReducerKind.ClientConnected` argument to the `SpacetimeDB.Reducer` attribut > SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. > -> - `ReducerKind.Init` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --delete-data`. +> - `ReducerKind.Init` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --server local --delete-data`. > - `ReducerKind.ClientConnected` - Called when a user connects to the SpacetimeDB database. Their identity can be found in the `Sender` value of the `ReducerContext`. > - `ReducerKind.ClientDisconnected` - Called when a user disconnects from the SpacetimeDB database. @@ -679,7 +679,7 @@ Subscription applied indicates that the SpacetimeDB SDK has evaluated your subsc We can also see that the server has logged the connection as well. ```sh -spacetime logs blackholio +spacetime logs --server local blackholio ... 2025-01-10T03:51:02.078700Z DEBUG: src/lib.rs:63: c200fb5be9524bfb8289c351516a1d9ea800f70a17a9a6937f11c0ed3854087d just connected. ``` diff --git a/docs/docs/03-Unity Tutorial/05-part-4.md b/docs/docs/03-Unity Tutorial/05-part-4.md index 2da82f8d1c1..83f38992a85 100644 --- a/docs/docs/03-Unity Tutorial/05-part-4.md +++ b/docs/docs/03-Unity Tutorial/05-part-4.md @@ -604,7 +604,7 @@ Notice that the food automatically respawns as you vaccuum them up. This is beca ## Connecting to Maincloud -- Publish to Maincloud `spacetime publish -s maincloud --delete-data` +- Publish to Maincloud `spacetime publish --server maincloud --delete-data` - `` This name should be unique and cannot contain any special characters other than internal hyphens (`-`). - Update the URL in the Unity project to: `https://maincloud.spacetimedb.com` - Update the module name in the Unity project to ``. @@ -626,7 +626,7 @@ private void Start() } ``` -To delete your Maincloud database, you can run: `spacetime delete -s maincloud ` +To delete your Maincloud database, you can run: `spacetime delete --server maincloud ` # Conclusion diff --git a/docs/docs/04-Unreal Tutorial/03-part-2.md b/docs/docs/04-Unreal Tutorial/03-part-2.md index c3319cd707b..46e54c95282 100644 --- a/docs/docs/04-Unreal Tutorial/03-part-2.md +++ b/docs/docs/04-Unreal Tutorial/03-part-2.md @@ -456,7 +456,7 @@ The `client_connected` argument to the `spacetimedb::reducer` macro indicates to > SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. > -> - `init` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --delete-data`. +> - `init` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --server local --delete-data`. > - `client_connected` - Called when a user connects to the SpacetimeDB database. Their identity can be found in the `sender` value of the `ReducerContext`. > - `client_disconnected` - Called when a user disconnects from the SpacetimeDB database. @@ -476,7 +476,7 @@ The `ReducerKind.ClientConnected` argument to the `SpacetimeDB.Reducer` attribut > SpacetimeDB gives you the ability to define custom reducers that automatically trigger when certain events occur. > -> - `ReducerKind.Init` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --delete-data`. +> - `ReducerKind.Init` - Called the first time you publish your module and anytime you clear the database with `spacetime publish --server local --delete-data`. > - `ReducerKind.ClientConnected` - Called when a user connects to the SpacetimeDB database. Their identity can be found in the `Sender` value of the `ReducerContext`. > - `ReducerKind.ClientDisconnected` - Called when a user disconnects from the SpacetimeDB database. @@ -836,7 +836,7 @@ Subscription applied indicates that the SpacetimeDB SDK has evaluated your subsc We can also see that the server has logged the connection as well. ```sh -spacetime logs blackholio +spacetime logs --server local blackholio ... 2025-01-10T03:51:02.078700Z DEBUG: src/lib.rs:63: c200fb5be9524bfb8289c351516a1d9ea800f70a17a9a6937f11c0ed3854087d just connected. ``` diff --git a/docs/docs/04-Unreal Tutorial/05-part-4.md b/docs/docs/04-Unreal Tutorial/05-part-4.md index 87977fcde44..3340f004076 100644 --- a/docs/docs/04-Unreal Tutorial/05-part-4.md +++ b/docs/docs/04-Unreal Tutorial/05-part-4.md @@ -667,7 +667,7 @@ Notice that the food automatically respawns as you vaccuum them up. This is beca ## Connecting to Maincloud -- Publish to Maincloud `spacetime publish -s maincloud --delete-data` +- Publish to Maincloud `spacetime publish --server maincloud --delete-data` - `` This name should be unique and cannot contain any special characters other than internal hyphens (`-`). - Update the URL in the Unreal project to: `https://maincloud.spacetimedb.com` - Update the module name in the Unreal project to ``. @@ -675,7 +675,7 @@ Notice that the food automatically respawns as you vaccuum them up. This is beca ![Maincloud Setup](/images/unreal/part-4-01-maincloud.png) -To delete your Maincloud database, you can run: `spacetime delete -s maincloud ` +To delete your Maincloud database, you can run: `spacetime delete --server maincloud ` ## Conclusion diff --git a/docs/docs/06-Server Module Languages/02-rust-quickstart.md b/docs/docs/06-Server Module Languages/02-rust-quickstart.md index 7e64f13cc0b..87e921032e8 100644 --- a/docs/docs/06-Server Module Languages/02-rust-quickstart.md +++ b/docs/docs/06-Server Module Languages/02-rust-quickstart.md @@ -245,7 +245,7 @@ And that's all of our module code! We'll run `spacetime publish` to compile our From the `quickstart-chat` directory, run in another tab: ```bash -spacetime publish --project-path spacetimedb quickstart-chat +spacetime publish --server local --project-path spacetimedb quickstart-chat ``` ## Call Reducers @@ -253,13 +253,13 @@ spacetime publish --project-path spacetimedb quickstart-chat You can use the CLI (command line interface) to run reducers. The arguments to the reducer are passed in JSON format. ```bash -spacetime call quickstart-chat send_message "Hello, World!" +spacetime call --server local quickstart-chat send_message "Hello, World!" ``` Once we've called our `send_message` reducer, we can check to make sure it ran by running the `logs` command. ```bash -spacetime logs quickstart-chat +spacetime logs --server local quickstart-chat ``` You should now see the output that your module printed in the database. @@ -276,7 +276,7 @@ You should now see the output that your module printed in the database. SpacetimeDB supports a subset of the SQL syntax so that you can easily query the data of your database. We can run a query using the `sql` command. ```bash -spacetime sql quickstart-chat "SELECT * FROM message" +spacetime sql --server local quickstart-chat "SELECT * FROM message" ``` ```bash diff --git a/docs/docs/06-Server Module Languages/04-csharp-quickstart.md b/docs/docs/06-Server Module Languages/04-csharp-quickstart.md index 9eee6640e0f..0097d22eb99 100644 --- a/docs/docs/06-Server Module Languages/04-csharp-quickstart.md +++ b/docs/docs/06-Server Module Languages/04-csharp-quickstart.md @@ -264,7 +264,7 @@ And that's all of our module code! We'll run `spacetime publish` to compile our From the `quickstart-chat` directory, run: ```bash -spacetime publish --project-path spacetimedb quickstart-chat +spacetime publish --server local --project-path spacetimedb quickstart-chat ``` Note: If the WebAssembly optimizer `wasm-opt` is installed, `spacetime publish` will automatically optimize the Web Assembly output of the published module. Instruction for installing the `wasm-opt` binary can be found in [Rust's wasm-opt documentation](https://docs.rs/wasm-opt/latest/wasm_opt/). @@ -274,13 +274,13 @@ Note: If the WebAssembly optimizer `wasm-opt` is installed, `spacetime publish` You can use the CLI (command line interface) to run reducers. The arguments to the reducer are passed in JSON format. ```bash -spacetime call quickstart-chat SendMessage "Hello, World!" +spacetime call --server local quickstart-chat SendMessage "Hello, World!" ``` Once we've called our `SendMessage` reducer, we can check to make sure it ran by running the `logs` command. ```bash -spacetime logs quickstart-chat +spacetime logs --server local quickstart-chat ``` You should now see the output that your module printed in the database. @@ -294,7 +294,7 @@ info: Hello, World! SpacetimeDB supports a subset of the SQL syntax so that you can easily query the data of your database. We can run a query using the `sql` command. ```bash -spacetime sql quickstart-chat "SELECT * FROM message" +spacetime sql --server local quickstart-chat "SELECT * FROM message" ``` ```bash diff --git a/docs/docs/06-Server Module Languages/05-typescript-quickstart.md b/docs/docs/06-Server Module Languages/05-typescript-quickstart.md index fe19c127b79..1331a92e2a7 100644 --- a/docs/docs/06-Server Module Languages/05-typescript-quickstart.md +++ b/docs/docs/06-Server Module Languages/05-typescript-quickstart.md @@ -194,7 +194,7 @@ From the `spacetimedb/` directory you can lint/typecheck locally if you like, bu From the `quickstart-chat` directory (the parent of `spacetimedb/`): ```bash -spacetime publish --project-path spacetimedb quickstart-chat +spacetime publish --server local --project-path spacetimedb quickstart-chat ``` You can choose any unique, URL-safe database name in place of `quickstart-chat`. The CLI will show the database **Identity** (a hex string) as well; you can use either the name or identity with CLI commands. @@ -206,13 +206,13 @@ Use the CLI to call reducers. Arguments are passed as JSON (strings may be given Send a message: ```bash -spacetime call quickstart-chat send_message "Hello, World!" +spacetime call --server local quickstart-chat send_message "Hello, World!" ``` Check that it ran by viewing logs (owner-only): ```bash -spacetime logs quickstart-chat +spacetime logs --server local quickstart-chat ``` You should see output similar to: @@ -229,7 +229,7 @@ You should see output similar to: SpacetimeDB supports a subset of SQL so you can query your data: ```bash -spacetime sql quickstart-chat "SELECT * FROM message" +spacetime sql --server local quickstart-chat "SELECT * FROM message" ``` Output will resemble: diff --git a/sdks/csharp/tools~/run-regression-tests.sh b/sdks/csharp/tools~/run-regression-tests.sh index 451afaacb73..c193caa341b 100644 --- a/sdks/csharp/tools~/run-regression-tests.sh +++ b/sdks/csharp/tools~/run-regression-tests.sh @@ -15,13 +15,13 @@ STDB_PATH="$SDK_PATH/../.." cargo build --manifest-path "$STDB_PATH/crates/standalone/Cargo.toml" # Publish module for btree test -cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- publish -c -y -p "$SDK_PATH/examples~/regression-tests/server" btree-repro +cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- publish -c -y --server local -p "$SDK_PATH/examples~/regression-tests/server" btree-repro # Publish module for republishing module test -cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- publish -c -y -p "$SDK_PATH/examples~/regression-tests/republishing/server-initial" republish-test -cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" call republish-test Insert 1 -cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- publish -p "$SDK_PATH/examples~/regression-tests/republishing/server-republish" --break-clients republish-test -cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" call republish-test Insert 2 +cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- publish -c -y --server local -p "$SDK_PATH/examples~/regression-tests/republishing/server-initial" republish-test +cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" call --server local republish-test Insert 1 +cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" -- publish --server local -p "$SDK_PATH/examples~/regression-tests/republishing/server-republish" --break-clients republish-test +cargo run --manifest-path "$STDB_PATH/crates/cli/Cargo.toml" call --server local republish-test Insert 2 # Run client for btree test cd "$SDK_PATH/examples~/regression-tests/client" && dotnet run -c Debug