cellstate/commands/
setup.rs

1//! `cellstate setup` — Interactive first-run installer.
2//!
3//! Detects your environment, asks what you're building with,
4//! installs only the SDKs you need, and optionally starts a local server.
5
6use crate::ui;
7use dialoguer::{theme::ColorfulTheme, Confirm, MultiSelect};
8use std::process::Command;
9use which::which;
10
11// ============================================================================
12// Environment detection
13// ============================================================================
14
15#[derive(Debug, Clone)]
16struct DetectedEnv {
17    docker: Option<String>,
18    docker_compose: Option<String>,
19    python: Option<String>,
20    node: Option<String>,
21    bun: Option<String>,
22    npm: Option<String>,
23    rust: Option<String>,
24}
25
26impl DetectedEnv {
27    fn detect() -> Self {
28        DetectedEnv {
29            docker: version_of(&["docker", "--version"], "Docker version "),
30            docker_compose: version_of(
31                &["docker", "compose", "version"],
32                "Docker Compose version ",
33            )
34            .or_else(|| version_of(&["docker-compose", "--version"], "docker-compose version ")),
35            python: version_of(&["python3", "--version"], "Python ")
36                .or_else(|| version_of(&["python", "--version"], "Python ")),
37            node: version_of(&["node", "--version"], "v"),
38            bun: version_of(&["bun", "--version"], ""),
39            npm: version_of(&["npm", "--version"], ""),
40            rust: version_of(&["rustc", "--version"], "rustc "),
41        }
42    }
43
44    fn has_js_package_manager(&self) -> bool {
45        self.bun.is_some() || self.npm.is_some()
46    }
47}
48
49/// Run a command and extract a version string from stdout, stripping a known prefix.
50fn version_of(cmd: &[&str], strip_prefix: &str) -> Option<String> {
51    if which(cmd[0]).is_err() {
52        return None;
53    }
54    let output = Command::new(cmd[0]).args(&cmd[1..]).output().ok()?;
55    let raw = String::from_utf8_lossy(&output.stdout);
56    let first_line = raw.lines().next()?;
57    Some(
58        first_line
59            .trim()
60            .trim_start_matches(strip_prefix)
61            .split_whitespace()
62            .next()
63            .unwrap_or(first_line.trim())
64            .to_string(),
65    )
66}
67
68// ============================================================================
69// SDK options
70// ============================================================================
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73enum Sdk {
74    TypeScript,
75    Python,
76    Rust,
77}
78
79impl Sdk {
80    fn label(&self) -> &str {
81        match self {
82            Sdk::TypeScript => "TypeScript / JavaScript",
83            Sdk::Python => "Python",
84            Sdk::Rust => "Rust",
85        }
86    }
87}
88
89// ============================================================================
90// Command entrypoint
91// ============================================================================
92
93pub fn run() -> anyhow::Result<()> {
94    ui::print_header();
95
96    ui::step("Detecting your environment...");
97    let env = DetectedEnv::detect();
98
99    // Print what we found
100    if let Some(v) = &env.docker {
101        ui::kv_ok("Docker", v);
102    } else {
103        ui::kv_missing("Docker");
104    }
105    if let Some(v) = &env.python {
106        ui::kv_ok("Python", v);
107    } else {
108        ui::kv_missing("Python");
109    }
110    if let Some(v) = &env.node {
111        ui::kv_ok("Node.js", v);
112    } else {
113        ui::kv_missing("Node.js");
114    }
115    if let Some(v) = &env.bun {
116        ui::kv_ok("Bun", v);
117    } else {
118        ui::kv_missing("Bun");
119    }
120    if let Some(v) = &env.rust {
121        ui::kv_ok("Rust", v);
122    } else {
123        ui::kv_missing("Rust");
124    }
125
126    // Build the SDK option list based on what's installed
127    let mut available_sdks: Vec<Sdk> = vec![];
128    let mut defaults: Vec<bool> = vec![];
129
130    if env.has_js_package_manager() {
131        available_sdks.push(Sdk::TypeScript);
132        defaults.push(true);
133    }
134    if env.python.is_some() {
135        available_sdks.push(Sdk::Python);
136        defaults.push(true);
137    }
138    if env.rust.is_some() {
139        available_sdks.push(Sdk::Rust);
140        defaults.push(false); // Rust SDK is opt-in — most users don't need it alongside TS/Python
141    }
142
143    if available_sdks.is_empty() {
144        ui::warn("No supported language runtimes found.");
145        ui::info("Install Python, Node.js, or Bun, then run `cellstate setup` again.");
146        return Ok(());
147    }
148
149    // Language selection
150    ui::step("What are you building with?");
151    let sdk_labels: Vec<&str> = available_sdks.iter().map(|s| s.label()).collect();
152    let selections = MultiSelect::with_theme(&ColorfulTheme::default())
153        .with_prompt("Select languages (space to toggle, enter to confirm)")
154        .items(&sdk_labels)
155        .defaults(&defaults)
156        .interact()?;
157
158    let chosen_sdks: Vec<Sdk> = selections.iter().map(|&i| available_sdks[i]).collect();
159
160    if chosen_sdks.is_empty() {
161        ui::warn("Nothing selected — skipping SDK installation.");
162    } else {
163        install_sdks(&chosen_sdks, &env)?;
164    }
165
166    // Start local server?
167    if env.docker.is_some() && env.docker_compose.is_some() {
168        println!();
169        let start = Confirm::with_theme(&ColorfulTheme::default())
170            .with_prompt("Start a local CELLSTATE server?")
171            .default(true)
172            .interact()?;
173
174        if start {
175            super::start::run()?;
176        }
177    } else {
178        ui::warn("Docker not found — skipping local server.");
179        ui::info("Install Docker, then run `cellstate start` to launch a local server.");
180        ui::info("Or use the managed cloud: https://cellstate.batterypack.dev");
181    }
182
183    // Summary
184    ui::success_box(
185        "CELLSTATE is ready.",
186        &[
187            "Docs:     https://cellstate.batterypack.dev/docs",
188            "Discord:  https://discord.gg/cellstate",
189            "Issues:   https://github.com/TheFreeBatteryFactory/CellState/issues",
190        ],
191    );
192
193    Ok(())
194}
195
196// ============================================================================
197// SDK installation
198// ============================================================================
199
200fn install_sdks(sdks: &[Sdk], env: &DetectedEnv) -> anyhow::Result<()> {
201    ui::step("Installing SDKs...");
202
203    for sdk in sdks {
204        match sdk {
205            Sdk::TypeScript => install_ts(env)?,
206            Sdk::Python => install_python(env)?,
207            Sdk::Rust => install_rust()?,
208        }
209    }
210
211    Ok(())
212}
213
214fn install_ts(env: &DetectedEnv) -> anyhow::Result<()> {
215    let pb = ui::spinner("Installing @cellstate/sdk...");
216
217    // Prefer bun over npm
218    let (cmd, args) = if env.bun.is_some() {
219        ("bun", vec!["add", "@cellstate/sdk"])
220    } else {
221        ("npm", vec!["install", "@cellstate/sdk"])
222    };
223
224    let result = Command::new(cmd).args(&args).output();
225
226    match result {
227        Ok(output) if output.status.success() => {
228            ui::finish_spinner(pb, "@cellstate/sdk installed");
229        }
230        Ok(output) => {
231            let stderr = String::from_utf8_lossy(&output.stderr);
232            ui::fail_spinner(pb, &format!("npm install failed: {}", stderr.trim()));
233        }
234        Err(e) => {
235            ui::fail_spinner(pb, &format!("Could not run npm: {e}"));
236        }
237    }
238
239    Ok(())
240}
241
242fn install_python(env: &DetectedEnv) -> anyhow::Result<()> {
243    let pb = ui::spinner("Installing cellstate (Python SDK)...");
244
245    // Detect pip vs uv vs poetry vs pipenv
246    let (cmd, args): (&str, Vec<&str>) = if which("uv").is_ok() {
247        ("uv", vec!["add", "cellstate"])
248    } else if which("poetry").is_ok() {
249        ("poetry", vec!["add", "cellstate"])
250    } else {
251        let pip = if env.python.is_some() && which("pip3").is_ok() {
252            "pip3"
253        } else {
254            "pip"
255        };
256        (pip, vec!["install", "cellstate"])
257    };
258
259    let result = Command::new(cmd).args(&args).output();
260
261    match result {
262        Ok(output) if output.status.success() => {
263            ui::finish_spinner(pb, "cellstate (Python SDK) installed");
264        }
265        Ok(output) => {
266            let stderr = String::from_utf8_lossy(&output.stderr);
267            ui::fail_spinner(pb, &format!("pip install failed: {}", stderr.trim()));
268        }
269        Err(e) => {
270            ui::fail_spinner(pb, &format!("Could not run pip: {e}"));
271        }
272    }
273
274    Ok(())
275}
276
277fn install_rust() -> anyhow::Result<()> {
278    let pb = ui::spinner("Adding cellstate to Cargo.toml...");
279
280    let result = Command::new("cargo").args(["add", "cellstate"]).output();
281
282    match result {
283        Ok(output) if output.status.success() => {
284            ui::finish_spinner(pb, "cellstate crate added");
285        }
286        Ok(output) => {
287            let stderr = String::from_utf8_lossy(&output.stderr);
288            ui::fail_spinner(pb, &format!("cargo add failed: {}", stderr.trim()));
289        }
290        Err(e) => {
291            ui::fail_spinner(pb, &format!("Could not run cargo: {e}"));
292        }
293    }
294
295    Ok(())
296}