Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 127 additions & 1 deletion crates/cli/src/subcommands/dev.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use crate::common_args::ClearMode;
use crate::config::Config;
use crate::generate::Language;
use crate::subcommands::init;
use crate::subcommands::spacetime_config::{detect_client_command, SpacetimeConfig};
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,
Expand Down Expand Up @@ -29,6 +30,7 @@ use tabled::{
Table, Tabled,
};
use termcolor::{Color, ColorSpec, WriteColor};
use tokio::process::{Child, Command as TokioCommand};
use tokio::task::JoinHandle;
use tokio::time::sleep;

Expand Down Expand Up @@ -86,6 +88,18 @@ pub fn cli() -> Command {
.value_name("TEMPLATE")
.help("Template ID or GitHub repository (owner/repo or URL) for project initialization"),
)
.arg(
Arg::new("client-command")
.long("client-command")
.value_name("COMMAND")
.help("Command to run the client development server (overrides spacetime.json config)"),
)
.arg(
Arg::new("server-only")
.long("server-only")
.action(clap::ArgAction::SetTrue)
.help("Only run the server (module) without starting the client"),
)
}

#[derive(Deserialize)]
Expand Down Expand Up @@ -259,12 +273,44 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
);
}

// Determine client command: CLI flag > config file > auto-detect (and save)
let server_only = args.get_flag("server-only");
let client_command = if server_only {
None
} else if let Some(cmd) = args.get_one::<String>("client-command") {
// Explicit CLI flag takes priority
Some(cmd.clone())
} else if let Some(cmd) = SpacetimeConfig::load_from_dir(&project_dir)
.ok()
.flatten()
.and_then(|c| c.run)
{
// Config file exists with run command
Some(cmd)
} else if let Some((detected_cmd, _detected_pm)) = detect_client_command(&project_dir) {
// No config - detect and save for future runs
let config = SpacetimeConfig::with_run_command(&detected_cmd);
if let Ok(path) = config.save_to_dir(&project_dir) {
println!(
"{} Detected client command and saved to {}",
"✓".green(),
path.display()
);
}
Some(detected_cmd)
} else {
None
};

println!("\n{}", "Starting development mode...".green().bold());
println!("Database: {}", database_name.cyan());
println!(
"Watching for changes in: {}",
spacetimedb_dir.display().to_string().cyan()
);
if let Some(ref cmd) = client_command {
println!("Client command: {}", cmd.cyan());
}
println!("{}", "Press Ctrl+C to stop".dimmed());
println!();

Expand All @@ -286,6 +332,34 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
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?;

// Start the client development server if configured
let server_host_url = config.get_host_url(Some(resolved_server))?;
let _client_handle = if let Some(ref cmd) = client_command {
let mut child = start_client_process(cmd, &project_dir, &database_name, &server_host_url)?;

// Give the process a moment to fail fast (e.g., command not found, missing deps)
sleep(Duration::from_millis(200)).await;
match child.try_wait() {
Ok(Some(status)) if !status.success() => {
anyhow::bail!(
"Client command '{}' failed immediately with exit code: {}",
cmd,
status
.code()
.map(|c| c.to_string())
.unwrap_or_else(|| "unknown".to_string())
);
}
Err(e) => {
anyhow::bail!("Failed to check client process status: {}", e);
}
_ => {} // Still running or exited successfully (unusual but ok)
}
Some(child)
} else {
None
};

let (tx, rx) = channel();
let mut watcher: RecommendedWatcher = Watcher::new(
move |res: Result<Event, notify::Error>| {
Expand All @@ -301,8 +375,15 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E
notify::Config::default().with_poll_interval(Duration::from_millis(500)),
)?;

// Watch the appropriate directory based on project structure
// Rust/TypeScript modules have source in `src/`, C# modules have `*.cs` directly in the module dir
let src_dir = spacetimedb_dir.join("src");
watcher.watch(&src_dir, RecursiveMode::Recursive)?;
let watch_dir = if src_dir.exists() && src_dir.is_dir() {
src_dir
} else {
spacetimedb_dir.to_path_buf()
};
watcher.watch(&watch_dir, RecursiveMode::Recursive)?;

println!("{}", "Watching for file changes...".dimmed());

Expand Down Expand Up @@ -738,3 +819,48 @@ fn generate_database_name() -> String {
let mut generator = names::Generator::with_naming(names::Name::Numbered);
generator.next().unwrap()
}

/// Start the client development server as a child process.
/// The process inherits stdout/stderr so the user can see the output.
/// Sets SPACETIMEDB_DB_NAME and SPACETIMEDB_HOST environment variables for the client.
fn start_client_process(
command: &str,
working_dir: &Path,
database_name: &str,
host_url: &str,
) -> Result<Child, anyhow::Error> {
println!("{} {}", "Starting client:".cyan(), command.dimmed());

if command.trim().is_empty() {
anyhow::bail!("Empty client command");
}

// Use shell to handle PATH resolution and .cmd/.bat scripts on Windows
#[cfg(windows)]
let child = TokioCommand::new("cmd")
.args(["/C", command])
.current_dir(working_dir)
.env("SPACETIMEDB_DB_NAME", database_name)
.env("SPACETIMEDB_HOST", host_url)
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.stdin(std::process::Stdio::null())
.kill_on_drop(true)
.spawn()
.with_context(|| format!("Failed to start client command: {}", command))?;

#[cfg(not(windows))]
let child = TokioCommand::new("sh")
.args(["-c", command])
.current_dir(working_dir)
.env("SPACETIMEDB_DB_NAME", database_name)
.env("SPACETIMEDB_HOST", host_url)
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit())
.stdin(std::process::Stdio::null())
.kill_on_drop(true)
.spawn()
.with_context(|| format!("Failed to start client command: {}", command))?;

Ok(child)
}
70 changes: 28 additions & 42 deletions crates/cli/src/subcommands/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,13 @@ use reqwest::Url;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::{fmt, fs};
use toml_edit::{value, DocumentMut, Item};
use xmltree::{Element, XMLNode};

use crate::subcommands::login::{spacetimedb_login_force, DEFAULT_AUTH_HOST};
use crate::subcommands::spacetime_config::PackageManager;

mod embedded {
include!(concat!(env!("OUT_DIR"), "/embedded_templates.rs"));
Expand Down Expand Up @@ -343,26 +344,6 @@ fn run_pm(pm: PackageManager, args: &[&str], cwd: &Path) -> std::io::Result<std:
.status()
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum PackageManager {
Npm,
Pnpm,
Yarn,
Bun,
}

impl fmt::Display for PackageManager {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match self {
PackageManager::Npm => "npm",
PackageManager::Pnpm => "pnpm",
PackageManager::Yarn => "yarn",
PackageManager::Bun => "bun",
};
write!(f, "{s}")
}
}

pub fn prompt_for_typescript_package_manager() -> anyhow::Result<Option<PackageManager>> {
println!(
"\n{}",
Expand Down Expand Up @@ -488,41 +469,46 @@ pub async fn exec_init(config: &mut Config, args: &ArgMatches, is_interactive: b
)?;
init_from_template(&template_config, &template_config.project_path, is_server_only).await?;

// Determine package manager for TypeScript projects
let uses_typescript = template_config.server_lang == Some(ServerLanguage::TypeScript)
|| template_config.client_lang == Some(ClientLanguage::TypeScript);

let package_manager = if uses_typescript && is_interactive {
prompt_for_typescript_package_manager()?
} else {
None
};

if template_config.server_lang == Some(ServerLanguage::TypeScript)
&& template_config.client_lang == Some(ClientLanguage::TypeScript)
{
// If server & client are TypeScript, handle dependency installation
// NOTE: All server templates must have their server code in `spacetimedb/` directory
// This is not a requirement in general, but is a requirement for all templates
// i.e. `spacetime dev` is valid on non-templates.
let pm = if is_interactive {
prompt_for_typescript_package_manager()?
} else {
None
};
let client_dir = template_config.project_path;
let client_dir = &template_config.project_path;
let server_dir = client_dir.join("spacetimedb");
install_typescript_dependencies(&server_dir, pm)?;
install_typescript_dependencies(&client_dir, pm)?;
install_typescript_dependencies(&server_dir, package_manager)?;
install_typescript_dependencies(client_dir, package_manager)?;
} else if template_config.client_lang == Some(ClientLanguage::TypeScript) {
let pm = if is_interactive {
prompt_for_typescript_package_manager()?
} else {
None
};
let client_dir = template_config.project_path;
install_typescript_dependencies(&client_dir, pm)?;
let client_dir = &template_config.project_path;
install_typescript_dependencies(client_dir, package_manager)?;
} else if template_config.server_lang == Some(ServerLanguage::TypeScript) {
let pm = if is_interactive {
prompt_for_typescript_package_manager()?
} else {
None
};
// NOTE: All server templates must have their server code in `spacetimedb/` directory
// This is not a requirement in general, but is a requirement for all templates
// i.e. `spacetime dev` is valid on non-templates.
let server_dir = template_config.project_path.join("spacetimedb");
install_typescript_dependencies(&server_dir, pm)?;
install_typescript_dependencies(&server_dir, package_manager)?;
}

// Configure client dev command if a client is present
if !is_server_only {
let client_lang_str = template_config.client_lang.as_ref().map(|l| l.as_str());
if let Some(path) =
crate::subcommands::spacetime_config::setup_for_project(&project_path, client_lang_str, package_manager)?
{
println!("{} Created {}", "✓".green(), path.display());
}
}

Ok(project_path)
Expand Down
1 change: 1 addition & 0 deletions crates/cli/src/subcommands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ pub mod logs;
pub mod publish;
pub mod repl;
pub mod server;
pub mod spacetime_config;
pub mod sql;
pub mod start;
pub mod subscribe;
Expand Down
Loading
Loading