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
125const 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 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 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 let svc_indent = *service_indent.get_or_insert(indent);
192
193 if indent == svc_indent && trimmed.ends_with(':') && !trimmed.contains(' ') {
195 current_service = trimmed.trim_end_matches(':').to_string();
196 continue;
197 }
198
199 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 if candidates.len() == 1 {
223 return Some(candidates.remove(0).1);
224 }
225
226 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 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 let found_dockerfile = detect_dockerfile(&cwd);
244 let found_compose_image = detect_compose_image(&cwd);
245
246 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 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 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 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 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 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 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 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 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 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
567fn 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
593fn 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}