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