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 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
73fn 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 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
111pub fn run() -> anyhow::Result<()> {
116 ui::print_banner();
117
118 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 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 if std::path::Path::new(".cellstate/config.toml").exists() {
169 write_env_file()?;
170 }
171
172 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
227pub 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}