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
125/// Well-known service names that are almost certainly the primary application service.
126const APP_SERVICE_NAMES: &[&str] = &[
127    "app",
128    "web",
129    "api",
130    "backend",
131    "server",
132    "frontend",
133    "application",
134    "service",
135];
136
137fn detect_compose_image(cwd: &Path) -> Option<String> {
138    for name in &[
139        "compose.yaml",
140        "compose.yml",
141        "docker-compose.yml",
142        "docker-compose.yaml",
143    ] {
144        let path = cwd.join(name);
145        if !path.exists() {
146            continue;
147        }
148
149        let text = match fs::read_to_string(&path) {
150            Ok(t) => t,
151            Err(_) => continue,
152        };
153
154        // Scan the compose file tracking service context so we can disambiguate
155        // multi-service files (e.g. app + postgres). We do not do a full YAML parse —
156        // instead we use indentation to detect service-level keys and associate each
157        // `image:` line with the current service name.
158        //
159        // We detect the service-level indent dynamically: the first indented key we see
160        // under `services:` establishes the service-name indent depth. This handles 2-space,
161        // 4-space, and tab indentation without hard-coding 2 spaces.
162        let mut candidates: Vec<(String, String)> = Vec::new();
163        let mut current_service = String::new();
164        let mut in_services = false;
165        let mut service_indent: Option<usize> = None;
166
167        for line in text.lines() {
168            let trimmed = line.trim();
169            if trimmed.is_empty() || trimmed.starts_with('#') {
170                continue;
171            }
172
173            if trimmed == "services:" {
174                in_services = true;
175                service_indent = None;
176                continue;
177            }
178            // A top-level key (no leading whitespace) that isn't `services:` ends the block.
179            if !line.starts_with(' ') && !line.starts_with('\t') {
180                in_services = false;
181                continue;
182            }
183
184            if !in_services {
185                continue;
186            }
187
188            let indent = line.len() - line.trim_start().len();
189
190            // The first indented key under `services:` tells us the service-name depth.
191            let svc_indent = *service_indent.get_or_insert(indent);
192
193            // A line at exactly the service-name depth ending in `:` is a service name.
194            if indent == svc_indent && trimmed.ends_with(':') && !trimmed.contains(' ') {
195                current_service = trimmed.trim_end_matches(':').to_string();
196                continue;
197            }
198
199            // `image:` lines appear one level deeper than the service name.
200            if indent > svc_indent {
201                if let Some(rest) = trimmed.strip_prefix("image:") {
202                    let img = rest.trim().trim_matches('"').trim_matches('\'');
203                    if img.is_empty() {
204                        continue;
205                    }
206                    let img_lower = img.to_lowercase();
207                    let is_sidecar = COMPOSE_SIDECAR_PREFIXES
208                        .iter()
209                        .any(|p| img_lower.starts_with(p));
210                    if !is_sidecar {
211                        candidates.push((current_service.clone(), img.to_string()));
212                    }
213                }
214            }
215        }
216
217        if candidates.is_empty() {
218            continue;
219        }
220
221        // Single candidate — done.
222        if candidates.len() == 1 {
223            return Some(candidates.remove(0).1);
224        }
225
226        // Multiple app services: prefer well-known primary service names.
227        for &preferred in APP_SERVICE_NAMES {
228            if let Some((_, img)) = candidates.iter().find(|(svc, _)| svc == preferred) {
229                return Some(img.clone());
230            }
231        }
232
233        // Fall back to the first candidate found.
234        return Some(candidates.remove(0).1);
235    }
236    None
237}
238
239fn execute_interactive_simple(theme: &ColorfulTheme) -> Result<String, SboxError> {
240    let cwd = std::env::current_dir().map_err(|source| SboxError::CurrentDirectory { source })?;
241
242    // ── Detect existing Docker infrastructure ─────────────────────────────────
243    let found_dockerfile = detect_dockerfile(&cwd);
244    let found_compose_image = detect_compose_image(&cwd);
245
246    // ── Package manager ───────────────────────────────────────────────────────
247    let pm_idx = Select::with_theme(theme)
248        .with_prompt("Package manager")
249        .items(&[
250            "npm", "yarn", "pnpm", "bun", "uv", "pip", "poetry", "cargo", "go",
251        ])
252        .default(0)
253        .interact()
254        .map_err(|_| SboxError::CurrentDirectory {
255            source: std::io::Error::other("prompt cancelled"),
256        })?;
257    let (pm_name, stock_image) = [
258        ("npm", "node:22-bookworm-slim"),
259        ("yarn", "node:22-bookworm-slim"),
260        ("pnpm", "node:22-bookworm-slim"),
261        ("bun", "oven/bun:1"),
262        ("uv", "ghcr.io/astral-sh/uv:python3.13-bookworm-slim"),
263        ("pip", "python:3.13-slim"),
264        ("poetry", "python:3.13-slim"),
265        ("cargo", "rust:1-bookworm"),
266        ("go", "golang:1.23-bookworm"),
267    ][pm_idx];
268
269    // ── Image — prefer existing Docker infrastructure over stock public images ─
270    let image_block: String = if let Some(ref dockerfile) = found_dockerfile {
271        let use_it = Confirm::with_theme(theme)
272            .with_prompt(format!(
273                "Found `{dockerfile}` — use it as the container image?"
274            ))
275            .default(true)
276            .interact()
277            .map_err(|_| SboxError::CurrentDirectory {
278                source: std::io::Error::other("prompt cancelled"),
279            })?;
280        if use_it {
281            format!("image:\n  build: {dockerfile}\n")
282        } else {
283            let img = prompt_image(theme, stock_image)?;
284            format!("image:\n  ref: {img}\n")
285        }
286    } else if let Some(ref compose_image) = found_compose_image {
287        let use_it = Confirm::with_theme(theme)
288            .with_prompt(format!(
289                "Found image `{compose_image}` in compose file — use it?"
290            ))
291            .default(true)
292            .interact()
293            .map_err(|_| SboxError::CurrentDirectory {
294                source: std::io::Error::other("prompt cancelled"),
295            })?;
296        if use_it {
297            format!("image:\n  ref: {compose_image}\n")
298        } else {
299            let img = prompt_image(theme, stock_image)?;
300            format!("image:\n  ref: {img}\n")
301        }
302    } else {
303        let img = prompt_image(theme, stock_image)?;
304        format!("image:\n  ref: {img}\n")
305    };
306
307    // ── Backend ───────────────────────────────────────────────────────────────
308    let backend_idx = Select::with_theme(theme)
309        .with_prompt("Container backend")
310        .items(&["auto (detect podman or docker)", "podman", "docker"])
311        .default(0)
312        .interact()
313        .map_err(|_| SboxError::CurrentDirectory {
314            source: std::io::Error::other("prompt cancelled"),
315        })?;
316    let runtime_block = match backend_idx {
317        1 => "runtime:\n  backend: podman\n  rootless: true\n",
318        2 => "runtime:\n  backend: docker\n  rootless: false\n",
319        _ => "",
320    };
321
322    let exclude_paths = default_exclude_paths(pm_name);
323
324    Ok(format!(
325        "version: 1
326
327{runtime_block}
328workspace:
329  mount: /workspace
330  writable: false
331  exclude_paths:
332{exclude_paths}
333{image_block}
334environment:
335  pass_through:
336    - TERM
337
338package_manager:
339  name: {pm_name}
340"
341    ))
342}
343
344fn prompt_image(theme: &ColorfulTheme, default: &str) -> Result<String, SboxError> {
345    Input::with_theme(theme)
346        .with_prompt("Container image")
347        .default(default.to_string())
348        .interact_text()
349        .map_err(|_| SboxError::CurrentDirectory {
350            source: std::io::Error::other("prompt cancelled"),
351        })
352}
353
354fn default_exclude_paths(pm_name: &str) -> String {
355    let common = vec!["    - \".ssh/*\"", "    - \".aws/*\""];
356    let extras: &[&str] = match pm_name {
357        "npm" | "yarn" | "pnpm" | "bun" => &[
358            "    - .env",
359            "    - .env.local",
360            "    - .env.production",
361            "    - .env.development",
362            "    - .npmrc",
363            "    - .netrc",
364        ],
365        "uv" | "pip" | "poetry" => &["    - .env", "    - .env.local", "    - .netrc"],
366        _ => &[],
367    };
368
369    let mut lines: Vec<&str> = extras.to_vec();
370    lines.extend_from_slice(&common);
371    lines.join("\n") + "\n"
372}
373
374fn execute_interactive_advanced(theme: &ColorfulTheme) -> Result<String, SboxError> {
375    let cwd = std::env::current_dir().map_err(|source| SboxError::CurrentDirectory { source })?;
376    let found_dockerfile = detect_dockerfile(&cwd);
377    let found_compose_image = detect_compose_image(&cwd);
378
379    // ── Backend ───────────────────────────────────────────────────────────────
380    let backend_idx = Select::with_theme(theme)
381        .with_prompt("Container backend")
382        .items(&["auto (detect podman or docker)", "podman", "docker"])
383        .default(0)
384        .interact()
385        .map_err(|_| SboxError::CurrentDirectory {
386            source: std::io::Error::other("prompt cancelled"),
387        })?;
388    let (backend_line, rootless_line) = match backend_idx {
389        1 => ("  backend: podman", "  rootless: true"),
390        2 => ("  backend: docker", "  rootless: false"),
391        _ => ("  # backend: auto-detected", "  rootless: true"),
392    };
393
394    // ── Preset / image ────────────────────────────────────────────────────────
395    // Build the ecosystem list, prepending detected local infrastructure.
396    let mut image_choices: Vec<String> = Vec::new();
397    if let Some(ref df) = found_dockerfile {
398        image_choices.push(format!("existing Dockerfile ({df})"));
399    }
400    if let Some(ref img) = found_compose_image {
401        image_choices.push(format!("image from compose ({img})"));
402    }
403    image_choices.extend_from_slice(&[
404        "node".into(),
405        "python".into(),
406        "rust".into(),
407        "go".into(),
408        "generic".into(),
409        "custom image".into(),
410    ]);
411
412    let image_idx = Select::with_theme(theme)
413        .with_prompt("Container image source")
414        .items(&image_choices)
415        .default(0)
416        .interact()
417        .map_err(|_| SboxError::CurrentDirectory {
418            source: std::io::Error::other("prompt cancelled"),
419        })?;
420
421    // Resolve offset caused by prepended Dockerfile/compose choices.
422    let offset = (found_dockerfile.is_some() as usize) + (found_compose_image.is_some() as usize);
423    let ecosystem_names = ["node", "python", "rust", "go", "generic", "custom"];
424
425    let (image_yaml, preset, default_writable_paths, default_dispatch) = if found_dockerfile
426        .is_some()
427        && image_idx == 0
428    {
429        let df = found_dockerfile.as_deref().unwrap();
430        (
431            format!("image:\n  build: {df}"),
432            "custom",
433            vec![],
434            String::new(),
435        )
436    } else if found_compose_image.is_some() && image_idx == (found_dockerfile.is_some() as usize) {
437        let img = found_compose_image.as_deref().unwrap();
438        (
439            format!("image:\n  ref: {img}"),
440            "custom",
441            vec![],
442            String::new(),
443        )
444    } else {
445        let preset = ecosystem_names[image_idx - offset];
446        let (default_image, writable, dispatch) = match preset {
447            "node" => (
448                "node:22-bookworm-slim",
449                vec!["node_modules", "package-lock.json", "dist"],
450                node_dispatch(),
451            ),
452            "python" => ("python:3.13-slim", vec![".venv"], python_dispatch()),
453            "rust" => ("rust:1-bookworm", vec!["target"], rust_dispatch()),
454            "go" => ("golang:1.23-bookworm", vec![], go_dispatch()),
455            _ => ("ubuntu:24.04", vec![], String::new()),
456        };
457        let img = prompt_image(theme, default_image)?;
458        (format!("image:\n  ref: {img}"), preset, writable, dispatch)
459    };
460
461    // ── Network ───────────────────────────────────────────────────────────────
462    let network_idx = Select::with_theme(theme)
463        .with_prompt("Default network access in sandbox")
464        .items(&[
465            "off  — no internet (recommended for installs)",
466            "on   — full internet access",
467        ])
468        .default(0)
469        .interact()
470        .map_err(|_| SboxError::CurrentDirectory {
471            source: std::io::Error::other("prompt cancelled"),
472        })?;
473    let network = if network_idx == 0 { "off" } else { "on" };
474
475    // ── Workspace writable paths ──────────────────────────────────────────────
476    let default_wp = default_writable_paths.join(", ");
477    let wp_input: String = Input::with_theme(theme)
478        .with_prompt("Writable paths in workspace (comma-separated)")
479        .default(default_wp)
480        .allow_empty(true)
481        .interact_text()
482        .map_err(|_| SboxError::CurrentDirectory {
483            source: std::io::Error::other("prompt cancelled"),
484        })?;
485    let writable_paths: Vec<String> = wp_input
486        .split(',')
487        .map(|s| s.trim().to_string())
488        .filter(|s| !s.is_empty())
489        .collect();
490
491    // ── Dispatch rules ────────────────────────────────────────────────────────
492    let add_dispatch = if !default_dispatch.is_empty() {
493        Confirm::with_theme(theme)
494            .with_prompt(format!("Add default dispatch rules for {preset}?"))
495            .default(true)
496            .interact()
497            .map_err(|_| SboxError::CurrentDirectory {
498                source: std::io::Error::other("prompt cancelled"),
499            })?
500    } else {
501        false
502    };
503
504    // ── Render ────────────────────────────────────────────────────────────────
505    let writable_paths_yaml = if writable_paths.is_empty() {
506        "    []".to_string()
507    } else {
508        writable_paths
509            .iter()
510            .map(|p| format!("    - {p}"))
511            .collect::<Vec<_>>()
512            .join("\n")
513    };
514
515    let workspace_writable = writable_paths.is_empty();
516    let dispatch_section = if add_dispatch {
517        format!("dispatch:\n{default_dispatch}")
518    } else {
519        "dispatch: {}".to_string()
520    };
521
522    Ok(format!(
523        "version: 1
524
525runtime:
526{backend_line}
527{rootless_line}
528
529workspace:
530  root: .
531  mount: /workspace
532  writable: {workspace_writable}
533  writable_paths:
534{writable_paths_yaml}
535  exclude_paths:
536    - .env
537    - .env.local
538    - .env.production
539    - .env.development
540    - \"*.pem\"
541    - \"*.key\"
542    - .npmrc
543    - .netrc
544    - \".ssh/*\"
545    - \".aws/*\"
546
547{image_yaml}
548
549environment:
550  pass_through:
551    - TERM
552  set: {{}}
553  deny: []
554
555profiles:
556  default:
557    mode: sandbox
558    network: {network}
559    writable: true
560    no_new_privileges: true
561
562{dispatch_section}
563"
564    ))
565}
566
567// ── Default dispatch rules per preset (advanced mode) ────────────────────────
568
569fn node_dispatch() -> String {
570    "  npm-install:\n    match:\n      - \"npm install*\"\n      - \"npm ci\"\n    profile: default\n  \
571     yarn-install:\n    match:\n      - \"yarn install*\"\n    profile: default\n  \
572     pnpm-install:\n    match:\n      - \"pnpm install*\"\n    profile: default\n"
573        .to_string()
574}
575
576fn python_dispatch() -> String {
577    "  pip-install:\n    match:\n      - \"pip install*\"\n      - \"pip3 install*\"\n    profile: default\n  \
578     uv-sync:\n    match:\n      - \"uv sync*\"\n    profile: default\n  \
579     poetry-install:\n    match:\n      - \"poetry install*\"\n    profile: default\n"
580        .to_string()
581}
582
583fn rust_dispatch() -> String {
584    "  cargo-build:\n    match:\n      - \"cargo build*\"\n      - \"cargo check*\"\n    profile: default\n"
585        .to_string()
586}
587
588fn go_dispatch() -> String {
589    "  go-get:\n    match:\n      - \"go get*\"\n      - \"go mod download*\"\n    profile: default\n"
590        .to_string()
591}
592
593// ── Non-interactive (--preset) ────────────────────────────────────────────────
594
595fn resolve_output_path(cli: &Cli, command: &InitCommand) -> Result<PathBuf, SboxError> {
596    let cwd = std::env::current_dir().map_err(|source| SboxError::CurrentDirectory { source })?;
597    let base = cli.workspace.clone().unwrap_or(cwd);
598
599    Ok(match &command.output {
600        Some(path) if path.is_absolute() => path.clone(),
601        Some(path) => base.join(path),
602        None => base.join("sbox.yaml"),
603    })
604}
605
606pub fn render_template(preset: &str) -> Result<String, SboxError> {
607    match preset {
608        "node" => Ok("version: 1
609
610workspace:
611  mount: /workspace
612  writable: false
613  exclude_paths:
614    - .env
615    - .env.local
616    - .env.production
617    - .env.development
618    - .npmrc
619    - .netrc
620    - \".ssh/*\"
621    - \".aws/*\"
622
623image:
624  ref: node:22-bookworm-slim
625
626environment:
627  pass_through:
628    - TERM
629
630package_manager:
631  name: npm
632"
633        .to_string()),
634
635        "python" => Ok("version: 1
636
637workspace:
638  mount: /workspace
639  writable: false
640  exclude_paths:
641    - .env
642    - .env.local
643    - .netrc
644    - \".ssh/*\"
645    - \".aws/*\"
646
647image:
648  ref: ghcr.io/astral-sh/uv:python3.13-bookworm-slim
649
650environment:
651  pass_through:
652    - TERM
653
654package_manager:
655  name: uv
656"
657        .to_string()),
658
659        "rust" => Ok("version: 1
660
661workspace:
662  mount: /workspace
663  writable: false
664  exclude_paths:
665    - \".ssh/*\"
666    - \".aws/*\"
667
668image:
669  ref: rust:1-bookworm
670
671environment:
672  pass_through:
673    - TERM
674
675package_manager:
676  name: cargo
677"
678        .to_string()),
679
680        "go" => Ok("version: 1
681
682workspace:
683  mount: /workspace
684  writable: false
685  exclude_paths:
686    - \".ssh/*\"
687    - \".aws/*\"
688
689image:
690  ref: golang:1.23-bookworm
691
692environment:
693  pass_through:
694    - TERM
695
696package_manager:
697  name: go
698"
699        .to_string()),
700
701        "generic" | "polyglot" => Ok("version: 1
702
703runtime:
704  backend: podman
705  rootless: true
706
707workspace:
708  root: .
709  mount: /workspace
710  writable: true
711  exclude_paths:
712    - \".ssh/*\"
713    - \".aws/*\"
714
715image:
716  ref: ubuntu:24.04
717
718environment:
719  pass_through:
720    - TERM
721  set: {}
722  deny: []
723
724profiles:
725  default:
726    mode: sandbox
727    network: off
728    writable: true
729    no_new_privileges: true
730
731  host:
732    mode: host
733    network: on
734    writable: true
735
736dispatch: {}
737"
738        .to_string()),
739
740        other => Err(SboxError::UnknownPreset {
741            name: other.to_string(),
742        }),
743    }
744}
745
746#[cfg(test)]
747mod tests {
748    use super::render_template;
749
750    #[test]
751    fn renders_node_template_with_package_manager() {
752        let rendered = render_template("node").expect("node preset should exist");
753        assert!(rendered.contains("ref: node:22-bookworm-slim"));
754        assert!(rendered.contains("package_manager:"));
755        assert!(rendered.contains("name: npm"));
756        assert!(!rendered.contains("profiles:"));
757    }
758
759    #[test]
760    fn renders_python_template_with_package_manager() {
761        let rendered = render_template("python").expect("python preset should exist");
762        assert!(rendered.contains("ghcr.io/astral-sh/uv:python3.13-bookworm-slim"));
763        assert!(rendered.contains("name: uv"));
764    }
765
766    #[test]
767    fn renders_rust_template_with_package_manager() {
768        let rendered = render_template("rust").expect("rust preset should exist");
769        assert!(rendered.contains("ref: rust:1-bookworm"));
770        assert!(rendered.contains("name: cargo"));
771    }
772
773    #[test]
774    fn renders_generic_template_with_profiles() {
775        let rendered = render_template("generic").expect("generic preset should exist");
776        assert!(rendered.contains("profiles:"));
777        assert!(!rendered.contains("package_manager:"));
778    }
779}