Skip to main content

sbox/
init.rs

1use std::fs;
2use std::path::{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 detect_dockerfile(cwd: &Path) -> Option<String> {
88    for name in &[
89        "Dockerfile",
90        "Dockerfile.dev",
91        "Dockerfile.local",
92        "dockerfile",
93    ] {
94        if cwd.join(name).exists() {
95            return Some(name.to_string());
96        }
97    }
98    None
99}
100
101/// Well-known infrastructure/sidecar image name fragments to skip when scanning compose files.
102/// We want the application service image, not postgres/redis/etc.
103const COMPOSE_SIDECAR_PREFIXES: &[&str] = &[
104    "postgres",
105    "mysql",
106    "mariadb",
107    "mongo",
108    "redis",
109    "rabbitmq",
110    "elasticsearch",
111    "kibana",
112    "grafana",
113    "prometheus",
114    "influxdb",
115    "nginx",
116    "traefik",
117    "caddy",
118    "haproxy",
119    "zookeeper",
120    "kafka",
121    "memcached",
122    "vault",
123];
124
125fn detect_compose_image(cwd: &Path) -> Option<String> {
126    for name in &[
127        "compose.yaml",
128        "compose.yml",
129        "docker-compose.yml",
130        "docker-compose.yaml",
131    ] {
132        let path = cwd.join(name);
133        if !path.exists() {
134            continue;
135        }
136        // Extract image values from the compose file — fast heuristic, no full YAML parse.
137        // Skip well-known infrastructure images (databases, caches, proxies) to avoid
138        // suggesting `postgres:16` as the app image when the db service appears first.
139        if let Ok(text) = fs::read_to_string(&path) {
140            for line in text.lines() {
141                let t = line.trim();
142                if let Some(rest) = t.strip_prefix("image:") {
143                    let img = rest.trim().trim_matches('"').trim_matches('\'');
144                    if img.is_empty() {
145                        continue;
146                    }
147                    let img_lower = img.to_lowercase();
148                    let is_sidecar = COMPOSE_SIDECAR_PREFIXES
149                        .iter()
150                        .any(|p| img_lower.starts_with(p));
151                    if !is_sidecar {
152                        return Some(img.to_string());
153                    }
154                }
155            }
156        }
157    }
158    None
159}
160
161fn execute_interactive_simple(theme: &ColorfulTheme) -> Result<String, SboxError> {
162    let cwd = std::env::current_dir().map_err(|source| SboxError::CurrentDirectory { source })?;
163
164    // ── Detect existing Docker infrastructure ─────────────────────────────────
165    let found_dockerfile = detect_dockerfile(&cwd);
166    let found_compose_image = detect_compose_image(&cwd);
167
168    // ── Package manager ───────────────────────────────────────────────────────
169    let pm_idx = Select::with_theme(theme)
170        .with_prompt("Package manager")
171        .items(&[
172            "npm", "yarn", "pnpm", "bun", "uv", "pip", "poetry", "cargo", "go",
173        ])
174        .default(0)
175        .interact()
176        .map_err(|_| SboxError::CurrentDirectory {
177            source: std::io::Error::other("prompt cancelled"),
178        })?;
179    let (pm_name, stock_image) = [
180        ("npm", "node:22-bookworm-slim"),
181        ("yarn", "node:22-bookworm-slim"),
182        ("pnpm", "node:22-bookworm-slim"),
183        ("bun", "oven/bun:1"),
184        ("uv", "ghcr.io/astral-sh/uv:python3.13-bookworm-slim"),
185        ("pip", "python:3.13-slim"),
186        ("poetry", "python:3.13-slim"),
187        ("cargo", "rust:1-bookworm"),
188        ("go", "golang:1.23-bookworm"),
189    ][pm_idx];
190
191    // ── Image — prefer existing Docker infrastructure over stock public images ─
192    let image_block: String = if let Some(ref dockerfile) = found_dockerfile {
193        let use_it = Confirm::with_theme(theme)
194            .with_prompt(format!(
195                "Found `{dockerfile}` — use it as the container image?"
196            ))
197            .default(true)
198            .interact()
199            .map_err(|_| SboxError::CurrentDirectory {
200                source: std::io::Error::other("prompt cancelled"),
201            })?;
202        if use_it {
203            format!("image:\n  build: {dockerfile}\n")
204        } else {
205            let img = prompt_image(theme, stock_image)?;
206            format!("image:\n  ref: {img}\n")
207        }
208    } else if let Some(ref compose_image) = found_compose_image {
209        let use_it = Confirm::with_theme(theme)
210            .with_prompt(format!(
211                "Found image `{compose_image}` in compose file — use it?"
212            ))
213            .default(true)
214            .interact()
215            .map_err(|_| SboxError::CurrentDirectory {
216                source: std::io::Error::other("prompt cancelled"),
217            })?;
218        if use_it {
219            format!("image:\n  ref: {compose_image}\n")
220        } else {
221            let img = prompt_image(theme, stock_image)?;
222            format!("image:\n  ref: {img}\n")
223        }
224    } else {
225        let img = prompt_image(theme, stock_image)?;
226        format!("image:\n  ref: {img}\n")
227    };
228
229    // ── Backend ───────────────────────────────────────────────────────────────
230    let backend_idx = Select::with_theme(theme)
231        .with_prompt("Container backend")
232        .items(&["auto (detect podman or docker)", "podman", "docker"])
233        .default(0)
234        .interact()
235        .map_err(|_| SboxError::CurrentDirectory {
236            source: std::io::Error::other("prompt cancelled"),
237        })?;
238    let runtime_block = match backend_idx {
239        1 => "runtime:\n  backend: podman\n  rootless: true\n",
240        2 => "runtime:\n  backend: docker\n  rootless: false\n",
241        _ => "",
242    };
243
244    let exclude_paths = default_exclude_paths(pm_name);
245
246    Ok(format!(
247        "version: 1
248
249{runtime_block}
250workspace:
251  mount: /workspace
252  writable: false
253  exclude_paths:
254{exclude_paths}
255{image_block}
256environment:
257  pass_through:
258    - TERM
259
260package_manager:
261  name: {pm_name}
262"
263    ))
264}
265
266fn prompt_image(theme: &ColorfulTheme, default: &str) -> Result<String, SboxError> {
267    Input::with_theme(theme)
268        .with_prompt("Container image")
269        .default(default.to_string())
270        .interact_text()
271        .map_err(|_| SboxError::CurrentDirectory {
272            source: std::io::Error::other("prompt cancelled"),
273        })
274}
275
276fn default_exclude_paths(pm_name: &str) -> String {
277    let common = vec!["    - \".ssh/*\"", "    - \".aws/*\""];
278    let extras: &[&str] = match pm_name {
279        "npm" | "yarn" | "pnpm" | "bun" => &[
280            "    - .env",
281            "    - .env.local",
282            "    - .env.production",
283            "    - .env.development",
284            "    - .npmrc",
285            "    - .netrc",
286        ],
287        "uv" | "pip" | "poetry" => &["    - .env", "    - .env.local", "    - .netrc"],
288        _ => &[],
289    };
290
291    let mut lines: Vec<&str> = extras.to_vec();
292    lines.extend_from_slice(&common);
293    lines.join("\n") + "\n"
294}
295
296fn execute_interactive_advanced(theme: &ColorfulTheme) -> Result<String, SboxError> {
297    let cwd = std::env::current_dir().map_err(|source| SboxError::CurrentDirectory { source })?;
298    let found_dockerfile = detect_dockerfile(&cwd);
299    let found_compose_image = detect_compose_image(&cwd);
300
301    // ── Backend ───────────────────────────────────────────────────────────────
302    let backend_idx = Select::with_theme(theme)
303        .with_prompt("Container backend")
304        .items(&["auto (detect podman or docker)", "podman", "docker"])
305        .default(0)
306        .interact()
307        .map_err(|_| SboxError::CurrentDirectory {
308            source: std::io::Error::other("prompt cancelled"),
309        })?;
310    let (backend_line, rootless_line) = match backend_idx {
311        1 => ("  backend: podman", "  rootless: true"),
312        2 => ("  backend: docker", "  rootless: false"),
313        _ => ("  # backend: auto-detected", "  rootless: true"),
314    };
315
316    // ── Preset / image ────────────────────────────────────────────────────────
317    // Build the ecosystem list, prepending detected local infrastructure.
318    let mut image_choices: Vec<String> = Vec::new();
319    if let Some(ref df) = found_dockerfile {
320        image_choices.push(format!("existing Dockerfile ({df})"));
321    }
322    if let Some(ref img) = found_compose_image {
323        image_choices.push(format!("image from compose ({img})"));
324    }
325    image_choices.extend_from_slice(&[
326        "node".into(),
327        "python".into(),
328        "rust".into(),
329        "go".into(),
330        "generic".into(),
331        "custom image".into(),
332    ]);
333
334    let image_idx = Select::with_theme(theme)
335        .with_prompt("Container image source")
336        .items(&image_choices)
337        .default(0)
338        .interact()
339        .map_err(|_| SboxError::CurrentDirectory {
340            source: std::io::Error::other("prompt cancelled"),
341        })?;
342
343    // Resolve offset caused by prepended Dockerfile/compose choices.
344    let offset = (found_dockerfile.is_some() as usize) + (found_compose_image.is_some() as usize);
345    let ecosystem_names = ["node", "python", "rust", "go", "generic", "custom"];
346
347    let (image_yaml, preset, default_writable_paths, default_dispatch) = if found_dockerfile
348        .is_some()
349        && image_idx == 0
350    {
351        let df = found_dockerfile.as_deref().unwrap();
352        (
353            format!("image:\n  build: {df}"),
354            "custom",
355            vec![],
356            String::new(),
357        )
358    } else if found_compose_image.is_some() && image_idx == (found_dockerfile.is_some() as usize) {
359        let img = found_compose_image.as_deref().unwrap();
360        (
361            format!("image:\n  ref: {img}"),
362            "custom",
363            vec![],
364            String::new(),
365        )
366    } else {
367        let preset = ecosystem_names[image_idx - offset];
368        let (default_image, writable, dispatch) = match preset {
369            "node" => (
370                "node:22-bookworm-slim",
371                vec!["node_modules", "package-lock.json", "dist"],
372                node_dispatch(),
373            ),
374            "python" => ("python:3.13-slim", vec![".venv"], python_dispatch()),
375            "rust" => ("rust:1-bookworm", vec!["target"], rust_dispatch()),
376            "go" => ("golang:1.23-bookworm", vec![], go_dispatch()),
377            _ => ("ubuntu:24.04", vec![], String::new()),
378        };
379        let img = prompt_image(theme, default_image)?;
380        (format!("image:\n  ref: {img}"), preset, writable, dispatch)
381    };
382
383    // ── Network ───────────────────────────────────────────────────────────────
384    let network_idx = Select::with_theme(theme)
385        .with_prompt("Default network access in sandbox")
386        .items(&[
387            "off  — no internet (recommended for installs)",
388            "on   — full internet access",
389        ])
390        .default(0)
391        .interact()
392        .map_err(|_| SboxError::CurrentDirectory {
393            source: std::io::Error::other("prompt cancelled"),
394        })?;
395    let network = if network_idx == 0 { "off" } else { "on" };
396
397    // ── Workspace writable paths ──────────────────────────────────────────────
398    let default_wp = default_writable_paths.join(", ");
399    let wp_input: String = Input::with_theme(theme)
400        .with_prompt("Writable paths in workspace (comma-separated)")
401        .default(default_wp)
402        .allow_empty(true)
403        .interact_text()
404        .map_err(|_| SboxError::CurrentDirectory {
405            source: std::io::Error::other("prompt cancelled"),
406        })?;
407    let writable_paths: Vec<String> = wp_input
408        .split(',')
409        .map(|s| s.trim().to_string())
410        .filter(|s| !s.is_empty())
411        .collect();
412
413    // ── Dispatch rules ────────────────────────────────────────────────────────
414    let add_dispatch = if !default_dispatch.is_empty() {
415        Confirm::with_theme(theme)
416            .with_prompt(format!("Add default dispatch rules for {preset}?"))
417            .default(true)
418            .interact()
419            .map_err(|_| SboxError::CurrentDirectory {
420                source: std::io::Error::other("prompt cancelled"),
421            })?
422    } else {
423        false
424    };
425
426    // ── Render ────────────────────────────────────────────────────────────────
427    let writable_paths_yaml = if writable_paths.is_empty() {
428        "    []".to_string()
429    } else {
430        writable_paths
431            .iter()
432            .map(|p| format!("    - {p}"))
433            .collect::<Vec<_>>()
434            .join("\n")
435    };
436
437    let workspace_writable = writable_paths.is_empty();
438    let dispatch_section = if add_dispatch {
439        format!("dispatch:\n{default_dispatch}")
440    } else {
441        "dispatch: {}".to_string()
442    };
443
444    Ok(format!(
445        "version: 1
446
447runtime:
448{backend_line}
449{rootless_line}
450
451workspace:
452  root: .
453  mount: /workspace
454  writable: {workspace_writable}
455  writable_paths:
456{writable_paths_yaml}
457  exclude_paths:
458    - .env
459    - .env.local
460    - .env.production
461    - .env.development
462    - \"*.pem\"
463    - \"*.key\"
464    - .npmrc
465    - .netrc
466    - \".ssh/*\"
467    - \".aws/*\"
468
469{image_yaml}
470
471environment:
472  pass_through:
473    - TERM
474  set: {{}}
475  deny: []
476
477profiles:
478  default:
479    mode: sandbox
480    network: {network}
481    writable: true
482    no_new_privileges: true
483
484{dispatch_section}
485"
486    ))
487}
488
489// ── Default dispatch rules per preset (advanced mode) ────────────────────────
490
491fn node_dispatch() -> String {
492    "  npm-install:\n    match:\n      - \"npm install*\"\n      - \"npm ci\"\n    profile: default\n  \
493     yarn-install:\n    match:\n      - \"yarn install*\"\n    profile: default\n  \
494     pnpm-install:\n    match:\n      - \"pnpm install*\"\n    profile: default\n"
495        .to_string()
496}
497
498fn python_dispatch() -> String {
499    "  pip-install:\n    match:\n      - \"pip install*\"\n      - \"pip3 install*\"\n    profile: default\n  \
500     uv-sync:\n    match:\n      - \"uv sync*\"\n    profile: default\n  \
501     poetry-install:\n    match:\n      - \"poetry install*\"\n    profile: default\n"
502        .to_string()
503}
504
505fn rust_dispatch() -> String {
506    "  cargo-build:\n    match:\n      - \"cargo build*\"\n      - \"cargo check*\"\n    profile: default\n"
507        .to_string()
508}
509
510fn go_dispatch() -> String {
511    "  go-get:\n    match:\n      - \"go get*\"\n      - \"go mod download*\"\n    profile: default\n"
512        .to_string()
513}
514
515// ── Non-interactive (--preset) ────────────────────────────────────────────────
516
517fn resolve_output_path(cli: &Cli, command: &InitCommand) -> Result<PathBuf, SboxError> {
518    let cwd = std::env::current_dir().map_err(|source| SboxError::CurrentDirectory { source })?;
519    let base = cli.workspace.clone().unwrap_or(cwd);
520
521    Ok(match &command.output {
522        Some(path) if path.is_absolute() => path.clone(),
523        Some(path) => base.join(path),
524        None => base.join("sbox.yaml"),
525    })
526}
527
528pub fn render_template(preset: &str) -> Result<String, SboxError> {
529    match preset {
530        "node" => Ok("version: 1
531
532workspace:
533  mount: /workspace
534  writable: false
535  exclude_paths:
536    - .env
537    - .env.local
538    - .env.production
539    - .env.development
540    - .npmrc
541    - .netrc
542    - \".ssh/*\"
543    - \".aws/*\"
544
545image:
546  ref: node:22-bookworm-slim
547
548environment:
549  pass_through:
550    - TERM
551
552package_manager:
553  name: npm
554"
555        .to_string()),
556
557        "python" => Ok("version: 1
558
559workspace:
560  mount: /workspace
561  writable: false
562  exclude_paths:
563    - .env
564    - .env.local
565    - .netrc
566    - \".ssh/*\"
567    - \".aws/*\"
568
569image:
570  ref: ghcr.io/astral-sh/uv:python3.13-bookworm-slim
571
572environment:
573  pass_through:
574    - TERM
575
576package_manager:
577  name: uv
578"
579        .to_string()),
580
581        "rust" => Ok("version: 1
582
583workspace:
584  mount: /workspace
585  writable: false
586  exclude_paths:
587    - \".ssh/*\"
588    - \".aws/*\"
589
590image:
591  ref: rust:1-bookworm
592
593environment:
594  pass_through:
595    - TERM
596
597package_manager:
598  name: cargo
599"
600        .to_string()),
601
602        "go" => Ok("version: 1
603
604workspace:
605  mount: /workspace
606  writable: false
607  exclude_paths:
608    - \".ssh/*\"
609    - \".aws/*\"
610
611image:
612  ref: golang:1.23-bookworm
613
614environment:
615  pass_through:
616    - TERM
617
618package_manager:
619  name: go
620"
621        .to_string()),
622
623        "generic" | "polyglot" => Ok("version: 1
624
625runtime:
626  backend: podman
627  rootless: true
628
629workspace:
630  root: .
631  mount: /workspace
632  writable: true
633  exclude_paths:
634    - \".ssh/*\"
635    - \".aws/*\"
636
637image:
638  ref: ubuntu:24.04
639
640environment:
641  pass_through:
642    - TERM
643  set: {}
644  deny: []
645
646profiles:
647  default:
648    mode: sandbox
649    network: off
650    writable: true
651    no_new_privileges: true
652
653  host:
654    mode: host
655    network: on
656    writable: true
657
658dispatch: {}
659"
660        .to_string()),
661
662        other => Err(SboxError::UnknownPreset {
663            name: other.to_string(),
664        }),
665    }
666}
667
668#[cfg(test)]
669mod tests {
670    use super::render_template;
671
672    #[test]
673    fn renders_node_template_with_package_manager() {
674        let rendered = render_template("node").expect("node preset should exist");
675        assert!(rendered.contains("ref: node:22-bookworm-slim"));
676        assert!(rendered.contains("package_manager:"));
677        assert!(rendered.contains("name: npm"));
678        assert!(!rendered.contains("profiles:"));
679    }
680
681    #[test]
682    fn renders_python_template_with_package_manager() {
683        let rendered = render_template("python").expect("python preset should exist");
684        assert!(rendered.contains("ghcr.io/astral-sh/uv:python3.13-bookworm-slim"));
685        assert!(rendered.contains("name: uv"));
686    }
687
688    #[test]
689    fn renders_rust_template_with_package_manager() {
690        let rendered = render_template("rust").expect("rust preset should exist");
691        assert!(rendered.contains("ref: rust:1-bookworm"));
692        assert!(rendered.contains("name: cargo"));
693    }
694
695    #[test]
696    fn renders_generic_template_with_profiles() {
697        let rendered = render_template("generic").expect("generic preset should exist");
698        assert!(rendered.contains("profiles:"));
699        assert!(!rendered.contains("package_manager:"));
700    }
701}