cellstate/commands/
start.rs1use 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
15const 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
91fn 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 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
134pub fn run() -> anyhow::Result<()> {
139 ui::print_banner();
140
141 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 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 if std::path::Path::new(".cellstate/config.toml").exists() {
192 write_env_file()?;
193 }
194
195 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
250pub 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}