cellstate/commands/
start.rs

1//! `cellstate start` / `cellstate stop` — Local development server management.
2//!
3//! Writes a minimal docker-compose.yml to ~/.cellstate/ and runs it.
4//! No full dev stack (no Grafana, no Jaeger) — just Postgres + API.
5
6use crate::http;
7use crate::ui;
8use console::style;
9use dirs::home_dir;
10use std::fs;
11use std::path::PathBuf;
12use std::process::Command;
13use std::time::Duration;
14
15// ============================================================================
16// Minimal docker-compose template (prod-like, not dev stack)
17// ============================================================================
18
19const DOCKER_COMPOSE_TEMPLATE: &str = r#"# Generated by `cellstate start`. Do not edit manually.
20# To use the full dev stack (observability, etc): docker/docker-compose.yml
21
22services:
23  cellstate-pg:
24    image: pgvector/pgvector:pg18
25    container_name: cellstate-pg
26    environment:
27      POSTGRES_DB: cellstate
28      POSTGRES_USER: cellstate
29      POSTGRES_PASSWORD: cellstate_local
30    ports:
31      - "5432:5432"
32    volumes:
33      - cellstate-pg-data:/var/lib/postgresql/data
34    healthcheck:
35      test: ["CMD-SHELL", "pg_isready -U cellstate -d cellstate"]
36      interval: 5s
37      timeout: 5s
38      retries: 10
39    networks:
40      - cellstate-net
41
42  cellstate-api:
43    image: ghcr.io/cellstate/cellstate:latest
44    container_name: cellstate-api
45    depends_on:
46      cellstate-pg:
47        condition: service_healthy
48    environment:
49      DATABASE_URL: postgres://cellstate:cellstate_local@cellstate-pg:5432/cellstate
50      CELLSTATE_ENV: development
51      CELLSTATE_SECRET: local_dev_secret_change_in_production
52      CELLSTATE_CORS_ORIGINS: "http://localhost:3000,http://localhost:5173,http://localhost:5174"
53    ports:
54      - "3000:3000"
55    command: ["cellstate", "serve", "--migrate"]
56    healthcheck:
57      test: ["CMD", "curl", "-f", "http://localhost:3000/health/live"]
58      interval: 10s
59      timeout: 5s
60      retries: 12
61    networks:
62      - cellstate-net
63
64volumes:
65  cellstate-pg-data:
66
67networks:
68  cellstate-net:
69"#;
70
71const LOCAL_API_KEY: &str = "cst_local_dev_insecure_do_not_use_in_production";
72
73// ============================================================================
74// Helpers
75// ============================================================================
76
77fn cellstate_dir() -> PathBuf {
78    home_dir()
79        .unwrap_or_else(|| PathBuf::from("."))
80        .join(".cellstate")
81}
82
83fn compose_path() -> PathBuf {
84    cellstate_dir().join("docker-compose.yml")
85}
86
87fn ensure_compose_file() -> anyhow::Result<PathBuf> {
88    let dir = cellstate_dir();
89    fs::create_dir_all(&dir)?;
90    let path = compose_path();
91    if !path.exists() {
92        fs::write(&path, DOCKER_COMPOSE_TEMPLATE)?;
93    }
94    Ok(path)
95}
96
97fn docker_compose_cmd() -> (&'static str, Vec<&'static str>) {
98    // Prefer `docker compose` (v2 plugin) over `docker-compose` (v1 standalone)
99    if std::process::Command::new("docker")
100        .args(["compose", "version"])
101        .output()
102        .map(|o| o.status.success())
103        .unwrap_or(false)
104    {
105        ("docker", vec!["compose"])
106    } else {
107        ("docker-compose", vec![])
108    }
109}
110
111// ============================================================================
112// Start
113// ============================================================================
114
115pub fn run() -> anyhow::Result<()> {
116    ui::print_banner();
117
118    // Ensure Docker is available
119    if std::process::Command::new("docker")
120        .arg("info")
121        .output()
122        .map(|o| !o.status.success())
123        .unwrap_or(true)
124    {
125        anyhow::bail!(
126            "Docker is not running. Start Docker Desktop or the Docker daemon and try again."
127        );
128    }
129
130    let compose_file = ensure_compose_file()?;
131    ui::info(&format!("Using compose file: {}", compose_file.display()));
132
133    ui::step("Starting CELLSTATE...");
134
135    let (cmd, base_args) = docker_compose_cmd();
136    let pb = ui::spinner("Pulling images and starting containers...");
137
138    let compose_str = compose_file
139        .to_str()
140        .ok_or_else(|| anyhow::anyhow!("path contains invalid UTF-8"))?;
141    let status = Command::new(cmd)
142        .args(&base_args)
143        .args(["-f", compose_str, "up", "-d", "--pull", "always"])
144        .status()?;
145
146    if !status.success() {
147        ui::fail_spinner(pb, "docker compose up failed");
148        anyhow::bail!("Failed to start CELLSTATE. Run `docker compose logs` for details.");
149    }
150
151    ui::finish_spinner(pb, "Containers started");
152
153    // Wait for API to be ready
154    let health_url = format!("{}/health/live", http::DEFAULT_BASE_URL);
155    let pb = ui::spinner("Waiting for API to be ready...");
156    let ready = wait_for_api(&health_url, 60)?;
157
158    if ready {
159        ui::finish_spinner(pb, "API is ready");
160    } else {
161        ui::fail_spinner(pb, "API health check timed out");
162        ui::warn(&format!(
163            "CELLSTATE may still be starting. Try: curl {health_url}"
164        ));
165    }
166
167    // Write .env if in a project that has been initialized
168    if std::path::Path::new(".cellstate/config.toml").exists() {
169        write_env_file()?;
170    }
171
172    // Print connection info
173    println!();
174    println!("{}", style("CELLSTATE is running:").bold().green());
175    println!();
176    ui::kv("API", http::DEFAULT_BASE_URL);
177    ui::kv("Postgres", "localhost:5432 (cellstate/cellstate_local)");
178    ui::kv("API Key", LOCAL_API_KEY);
179    ui::kv("Health", &format!("{}/health/live", http::DEFAULT_BASE_URL));
180    ui::kv("Docs", &format!("{}/swagger-ui/", http::DEFAULT_BASE_URL));
181    println!();
182    println!("{}", style("To stop: cellstate stop").dim());
183    println!();
184
185    Ok(())
186}
187
188fn wait_for_api(url: &str, timeout_secs: u64) -> anyhow::Result<bool> {
189    let client = http::default_http_client(3)?;
190
191    let deadline = std::time::Instant::now() + Duration::from_secs(timeout_secs);
192    while std::time::Instant::now() < deadline {
193        if client
194            .get(url)
195            .send()
196            .map(|r| r.status().is_success())
197            .unwrap_or(false)
198        {
199            return Ok(true);
200        }
201        std::thread::sleep(Duration::from_secs(2));
202    }
203    Ok(false)
204}
205
206fn write_env_file() -> anyhow::Result<()> {
207    let env_path = std::path::Path::new(".env");
208    let entry = format!(
209        "\nCELLSTATE_API_KEY={LOCAL_API_KEY}\nCELLSTATE_BASE_URL={}\n",
210        http::DEFAULT_BASE_URL,
211    );
212
213    if env_path.exists() {
214        let existing = fs::read_to_string(env_path)?;
215        if !existing.contains("CELLSTATE_API_KEY") {
216            fs::write(env_path, format!("{}{}", existing, entry))?;
217            ui::ok(".env updated with CELLSTATE_API_KEY");
218        }
219    } else {
220        fs::write(env_path, entry.trim_start())?;
221        ui::ok(".env created with CELLSTATE_API_KEY");
222    }
223
224    Ok(())
225}
226
227// ============================================================================
228// Stop
229// ============================================================================
230
231pub fn stop() -> anyhow::Result<()> {
232    ui::print_banner();
233
234    let compose_file = compose_path();
235    if !compose_file.exists() {
236        ui::warn("No CELLSTATE server found. Nothing to stop.");
237        return Ok(());
238    }
239
240    ui::step("Stopping CELLSTATE...");
241
242    let (cmd, base_args) = docker_compose_cmd();
243    let pb = ui::spinner("Stopping containers...");
244
245    let compose_str = compose_file
246        .to_str()
247        .ok_or_else(|| anyhow::anyhow!("path contains invalid UTF-8"))?;
248    let status = Command::new(cmd)
249        .args(&base_args)
250        .args(["-f", compose_str, "down"])
251        .status()?;
252
253    if status.success() {
254        ui::finish_spinner(pb, "CELLSTATE stopped");
255    } else {
256        ui::fail_spinner(
257            pb,
258            "docker compose down failed — containers may still be running",
259        );
260    }
261
262    println!();
263
264    Ok(())
265}