Skip to main content

sbox/
init.rs

1use std::fs;
2use std::path::PathBuf;
3use std::process::ExitCode;
4
5use dialoguer::{Confirm, Input, Select, theme::ColorfulTheme};
6
7use crate::cli::{Cli, InitCommand};
8use crate::error::SboxError;
9
10pub fn execute(cli: &Cli, command: &InitCommand) -> Result<ExitCode, SboxError> {
11    if command.interactive {
12        return execute_interactive(cli, command);
13    }
14
15    let target = resolve_output_path(cli, command)?;
16    if target.exists() && !command.force {
17        return Err(SboxError::InitConfigExists { path: target });
18    }
19
20    if let Some(parent) = target.parent() {
21        fs::create_dir_all(parent).map_err(|source| SboxError::InitWrite {
22            path: target.clone(),
23            source,
24        })?;
25    }
26
27    let preset = command.preset.as_deref().unwrap_or("generic");
28    let template = render_template(preset)?;
29    fs::write(&target, template).map_err(|source| SboxError::InitWrite {
30        path: target.clone(),
31        source,
32    })?;
33
34    println!("created {}", target.display());
35    Ok(ExitCode::SUCCESS)
36}
37
38// ── Interactive wizard ────────────────────────────────────────────────────────
39
40fn execute_interactive(cli: &Cli, command: &InitCommand) -> Result<ExitCode, SboxError> {
41    let target = resolve_output_path(cli, command)?;
42    if target.exists() && !command.force {
43        return Err(SboxError::InitConfigExists { path: target });
44    }
45
46    let theme = ColorfulTheme::default();
47    println!("sbox interactive setup");
48    println!("──────────────────────");
49    println!("Use arrow keys to select, Enter to confirm.\n");
50
51    // ── Simple vs Advanced ────────────────────────────────────────────────────
52    let mode_idx = Select::with_theme(&theme)
53        .with_prompt("Setup mode")
54        .items(&[
55            "simple   — package_manager preset (recommended)",
56            "advanced — manual profiles and dispatch rules",
57        ])
58        .default(0)
59        .interact()
60        .map_err(|_| SboxError::CurrentDirectory {
61            source: std::io::Error::other("prompt cancelled"),
62        })?;
63
64    let config = if mode_idx == 0 {
65        execute_interactive_simple(&theme)?
66    } else {
67        execute_interactive_advanced(&theme)?
68    };
69
70    // ── Write ─────────────────────────────────────────────────────────────────
71    if let Some(parent) = target.parent() {
72        fs::create_dir_all(parent).map_err(|source| SboxError::InitWrite {
73            path: target.clone(),
74            source,
75        })?;
76    }
77    fs::write(&target, &config).map_err(|source| SboxError::InitWrite {
78        path: target.clone(),
79        source,
80    })?;
81
82    println!("\ncreated {}", target.display());
83    println!("Run `sbox plan -- <command>` to preview the resolved policy.");
84    Ok(ExitCode::SUCCESS)
85}
86
87fn execute_interactive_simple(theme: &ColorfulTheme) -> Result<String, SboxError> {
88    // ── Package manager ───────────────────────────────────────────────────────
89    let pm_idx = Select::with_theme(theme)
90        .with_prompt("Package manager")
91        .items(&["npm", "yarn", "pnpm", "bun", "uv", "pip", "poetry", "cargo", "go"])
92        .default(0)
93        .interact()
94        .map_err(|_| SboxError::CurrentDirectory {
95            source: std::io::Error::other("prompt cancelled"),
96        })?;
97    let (pm_name, default_image) = [
98        ("npm",    "node:22-bookworm-slim"),
99        ("yarn",   "node:22-bookworm-slim"),
100        ("pnpm",   "node:22-bookworm-slim"),
101        ("bun",    "oven/bun:latest"),
102        ("uv",     "python:3.13-slim"),
103        ("pip",    "python:3.13-slim"),
104        ("poetry", "python:3.13-slim"),
105        ("cargo",  "rust:1-bookworm"),
106        ("go",     "golang:1.23-bookworm"),
107    ][pm_idx];
108
109    // ── Image ─────────────────────────────────────────────────────────────────
110    let image: String = Input::with_theme(theme)
111        .with_prompt("Container image")
112        .default(default_image.to_string())
113        .interact_text()
114        .map_err(|_| SboxError::CurrentDirectory {
115            source: std::io::Error::other("prompt cancelled"),
116        })?;
117
118    // ── Backend ───────────────────────────────────────────────────────────────
119    let backend_idx = Select::with_theme(theme)
120        .with_prompt("Container backend")
121        .items(&["auto (detect podman or docker)", "podman", "docker"])
122        .default(0)
123        .interact()
124        .map_err(|_| SboxError::CurrentDirectory {
125            source: std::io::Error::other("prompt cancelled"),
126        })?;
127    let runtime_block = match backend_idx {
128        1 => "runtime:\n  backend: podman\n  rootless: true\n",
129        2 => "runtime:\n  backend: docker\n  rootless: false\n",
130        _ => "",
131    };
132
133    let exclude_paths = default_exclude_paths(pm_name);
134
135    Ok(format!(
136        "version: 1\n\
137         \n\
138         {runtime_block}\
139         \n\
140         workspace:\n\
141           mount: /workspace\n\
142           writable: false\n\
143           exclude_paths:\n\
144         {exclude_paths}\
145         \n\
146         image:\n\
147           ref: {image}\n\
148         \n\
149         environment:\n\
150           pass_through:\n\
151             - TERM\n\
152         \n\
153         package_manager:\n\
154           name: {pm_name}\n"
155    ))
156}
157
158fn default_exclude_paths(pm_name: &str) -> String {
159    let common = vec![
160        "    - \".ssh/*\"",
161        "    - \".aws/*\"",
162    ];
163    let extras: &[&str] = match pm_name {
164        "npm" | "yarn" | "pnpm" | "bun" => &[
165            "    - .env",
166            "    - .env.local",
167            "    - .env.production",
168            "    - .env.development",
169            "    - .npmrc",
170            "    - .netrc",
171        ],
172        "uv" | "pip" | "poetry" => &[
173            "    - .env",
174            "    - .env.local",
175            "    - .netrc",
176        ],
177        _ => &[],
178    };
179
180    let mut lines: Vec<&str> = extras.to_vec();
181    lines.extend_from_slice(&common);
182    lines.join("\n") + "\n"
183}
184
185fn execute_interactive_advanced(theme: &ColorfulTheme) -> Result<String, SboxError> {
186    // ── Backend ───────────────────────────────────────────────────────────────
187    let backend_idx = Select::with_theme(theme)
188        .with_prompt("Container backend")
189        .items(&["auto (detect podman or docker)", "podman", "docker"])
190        .default(0)
191        .interact()
192        .map_err(|_| SboxError::CurrentDirectory {
193            source: std::io::Error::other("prompt cancelled"),
194        })?;
195    let (backend_line, rootless_line) = match backend_idx {
196        1 => ("  backend: podman", "  rootless: true"),
197        2 => ("  backend: docker", "  rootless: false"),
198        _ => ("  # backend: auto-detected", "  rootless: true"),
199    };
200
201    // ── Preset / image ────────────────────────────────────────────────────────
202    let preset_idx = Select::with_theme(theme)
203        .with_prompt("Language / ecosystem")
204        .items(&["node", "python", "rust", "go", "generic", "custom image"])
205        .default(0)
206        .interact()
207        .map_err(|_| SboxError::CurrentDirectory {
208            source: std::io::Error::other("prompt cancelled"),
209        })?;
210
211    let preset = ["node", "python", "rust", "go", "generic", "custom"][preset_idx];
212
213    let (default_image, default_writable_paths, default_dispatch) = match preset {
214        "node"   => ("node:22-bookworm-slim", vec!["node_modules", "package-lock.json", "dist"], node_dispatch()),
215        "python" => ("python:3.13-slim",       vec![".venv"],                                   python_dispatch()),
216        "rust"   => ("rust:1-bookworm",         vec!["target"],                                  rust_dispatch()),
217        "go"     => ("golang:1.23-bookworm",    vec![],                                          go_dispatch()),
218        _        => ("ubuntu:24.04",            vec![],                                          String::new()),
219    };
220
221    let image: String = Input::with_theme(theme)
222        .with_prompt("Container image")
223        .default(default_image.to_string())
224        .interact_text()
225        .map_err(|_| SboxError::CurrentDirectory {
226            source: std::io::Error::other("prompt cancelled"),
227        })?;
228
229    // ── Network ───────────────────────────────────────────────────────────────
230    let network_idx = Select::with_theme(theme)
231        .with_prompt("Default network access in sandbox")
232        .items(&[
233            "off  — no internet (recommended for installs)",
234            "on   — full internet access",
235        ])
236        .default(0)
237        .interact()
238        .map_err(|_| SboxError::CurrentDirectory {
239            source: std::io::Error::other("prompt cancelled"),
240        })?;
241    let network = if network_idx == 0 { "off" } else { "on" };
242
243    // ── Workspace writable paths ──────────────────────────────────────────────
244    let default_wp = default_writable_paths.join(", ");
245    let wp_input: String = Input::with_theme(theme)
246        .with_prompt("Writable paths in workspace (comma-separated)")
247        .default(default_wp)
248        .allow_empty(true)
249        .interact_text()
250        .map_err(|_| SboxError::CurrentDirectory {
251            source: std::io::Error::other("prompt cancelled"),
252        })?;
253    let writable_paths: Vec<String> = wp_input
254        .split(',')
255        .map(|s| s.trim().to_string())
256        .filter(|s| !s.is_empty())
257        .collect();
258
259    // ── Dispatch rules ────────────────────────────────────────────────────────
260    let add_dispatch = if !default_dispatch.is_empty() {
261        Confirm::with_theme(theme)
262            .with_prompt(format!("Add default dispatch rules for {preset}?"))
263            .default(true)
264            .interact()
265            .map_err(|_| SboxError::CurrentDirectory {
266                source: std::io::Error::other("prompt cancelled"),
267            })?
268    } else {
269        false
270    };
271
272    // ── Render ────────────────────────────────────────────────────────────────
273    let writable_paths_yaml = if writable_paths.is_empty() {
274        "    []".to_string()
275    } else {
276        writable_paths
277            .iter()
278            .map(|p| format!("    - {p}"))
279            .collect::<Vec<_>>()
280            .join("\n")
281    };
282
283    let workspace_writable = writable_paths.is_empty();
284    let dispatch_section = if add_dispatch {
285        format!("dispatch:\n{default_dispatch}")
286    } else {
287        "dispatch: {}".to_string()
288    };
289
290    Ok(format!("version: 1
291
292runtime:
293{backend_line}
294{rootless_line}
295
296workspace:
297  root: .
298  mount: /workspace
299  writable: {workspace_writable}
300  writable_paths:
301{writable_paths_yaml}
302  exclude_paths:
303    - .env
304    - .env.local
305    - .env.production
306    - .env.development
307    - \"*.pem\"
308    - \"*.key\"
309    - .npmrc
310    - .netrc
311    - \".ssh/*\"
312    - \".aws/*\"
313
314image:
315  ref: {image}
316
317environment:
318  pass_through:
319    - TERM
320  set: {{}}
321  deny: []
322
323profiles:
324  default:
325    mode: sandbox
326    network: {network}
327    writable: true
328    no_new_privileges: true
329
330{dispatch_section}
331"))
332}
333
334// ── Default dispatch rules per preset (advanced mode) ────────────────────────
335
336fn node_dispatch() -> String {
337    "  npm-install:\n    match:\n      - \"npm install*\"\n      - \"npm ci\"\n    profile: default\n  \
338     yarn-install:\n    match:\n      - \"yarn install*\"\n    profile: default\n  \
339     pnpm-install:\n    match:\n      - \"pnpm install*\"\n    profile: default\n"
340        .to_string()
341}
342
343fn python_dispatch() -> String {
344    "  pip-install:\n    match:\n      - \"pip install*\"\n      - \"pip3 install*\"\n    profile: default\n  \
345     uv-sync:\n    match:\n      - \"uv sync*\"\n    profile: default\n  \
346     poetry-install:\n    match:\n      - \"poetry install*\"\n    profile: default\n"
347        .to_string()
348}
349
350fn rust_dispatch() -> String {
351    "  cargo-build:\n    match:\n      - \"cargo build*\"\n      - \"cargo check*\"\n    profile: default\n"
352        .to_string()
353}
354
355fn go_dispatch() -> String {
356    "  go-get:\n    match:\n      - \"go get*\"\n      - \"go mod download*\"\n    profile: default\n"
357        .to_string()
358}
359
360// ── Non-interactive (--preset) ────────────────────────────────────────────────
361
362fn resolve_output_path(cli: &Cli, command: &InitCommand) -> Result<PathBuf, SboxError> {
363    let cwd = std::env::current_dir().map_err(|source| SboxError::CurrentDirectory { source })?;
364    let base = cli.workspace.clone().unwrap_or(cwd);
365
366    Ok(match &command.output {
367        Some(path) if path.is_absolute() => path.clone(),
368        Some(path) => base.join(path),
369        None => base.join("sbox.yaml"),
370    })
371}
372
373pub fn render_template(preset: &str) -> Result<String, SboxError> {
374    match preset {
375        "node" => Ok(
376"version: 1
377
378workspace:
379  mount: /workspace
380  writable: false
381  exclude_paths:
382    - .env
383    - .env.local
384    - .env.production
385    - .env.development
386    - .npmrc
387    - .netrc
388    - \".ssh/*\"
389    - \".aws/*\"
390
391image:
392  ref: node:22-bookworm-slim
393
394environment:
395  pass_through:
396    - TERM
397
398package_manager:
399  name: npm
400".to_string()),
401
402        "python" => Ok(
403"version: 1
404
405workspace:
406  mount: /workspace
407  writable: false
408  exclude_paths:
409    - .env
410    - .env.local
411    - .netrc
412    - \".ssh/*\"
413    - \".aws/*\"
414
415image:
416  ref: python:3.13-slim
417
418environment:
419  pass_through:
420    - TERM
421
422package_manager:
423  name: uv
424".to_string()),
425
426        "rust" => Ok(
427"version: 1
428
429workspace:
430  mount: /workspace
431  writable: false
432  exclude_paths:
433    - \".ssh/*\"
434    - \".aws/*\"
435
436image:
437  ref: rust:1-bookworm
438
439environment:
440  pass_through:
441    - TERM
442
443package_manager:
444  name: cargo
445".to_string()),
446
447        "go" => Ok(
448"version: 1
449
450workspace:
451  mount: /workspace
452  writable: false
453  exclude_paths:
454    - \".ssh/*\"
455    - \".aws/*\"
456
457image:
458  ref: golang:1.23-bookworm
459
460environment:
461  pass_through:
462    - TERM
463
464package_manager:
465  name: go
466".to_string()),
467
468        "generic" | "polyglot" => Ok(
469"version: 1
470
471runtime:
472  backend: podman
473  rootless: true
474
475workspace:
476  root: .
477  mount: /workspace
478  writable: true
479  exclude_paths:
480    - \".ssh/*\"
481    - \".aws/*\"
482
483image:
484  ref: ubuntu:24.04
485
486environment:
487  pass_through:
488    - TERM
489  set: {}
490  deny: []
491
492profiles:
493  default:
494    mode: sandbox
495    network: off
496    writable: true
497    no_new_privileges: true
498
499  host:
500    mode: host
501    network: on
502    writable: true
503
504dispatch: {}
505".to_string()),
506
507        other => Err(SboxError::UnknownPreset {
508            name: other.to_string(),
509        }),
510    }
511}
512
513#[cfg(test)]
514mod tests {
515    use super::render_template;
516
517    #[test]
518    fn renders_node_template_with_package_manager() {
519        let rendered = render_template("node").expect("node preset should exist");
520        assert!(rendered.contains("ref: node:22-bookworm-slim"));
521        assert!(rendered.contains("package_manager:"));
522        assert!(rendered.contains("name: npm"));
523        assert!(!rendered.contains("profiles:"));
524    }
525
526    #[test]
527    fn renders_python_template_with_package_manager() {
528        let rendered = render_template("python").expect("python preset should exist");
529        assert!(rendered.contains("ref: python:3.13-slim"));
530        assert!(rendered.contains("name: uv"));
531    }
532
533    #[test]
534    fn renders_rust_template_with_package_manager() {
535        let rendered = render_template("rust").expect("rust preset should exist");
536        assert!(rendered.contains("ref: rust:1-bookworm"));
537        assert!(rendered.contains("name: cargo"));
538    }
539
540    #[test]
541    fn renders_generic_template_with_profiles() {
542        let rendered = render_template("generic").expect("generic preset should exist");
543        assert!(rendered.contains("profiles:"));
544        assert!(!rendered.contains("package_manager:"));
545    }
546}