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
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 execute_interactive_simple(theme: &ColorfulTheme) -> Result<String, SboxError> {
88 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 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 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 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 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 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 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 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 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
334fn 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
360fn 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}