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
38fn 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 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 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
101const 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 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 let found_dockerfile = detect_dockerfile(&cwd);
166 let found_compose_image = detect_compose_image(&cwd);
167
168 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 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 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 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 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 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 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 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 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 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
489fn 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
515fn 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}