Skip to main content

sbox/
bootstrap.rs

1use std::process::ExitCode;
2
3use crate::cli::Cli;
4use crate::config::model::ExecutionMode;
5use crate::config::{LoadOptions, load_config};
6use crate::error::SboxError;
7use crate::exec::{execute_host, execute_sandbox, run_pre_run_commands, validate_execution_safety};
8use crate::resolve::{ResolutionTarget, resolve_execution_plan};
9
10/// `sbox bootstrap` — generate the package lockfile inside the sandbox without running
11/// install scripts. Requires `package_manager:` to be set in sbox.yaml.
12///
13/// After bootstrap succeeds, run `sbox run -- <rebuild-command>` to execute install scripts
14/// with network disabled (two-phase install pattern).
15pub fn execute(cli: &Cli) -> Result<ExitCode, SboxError> {
16    let loaded = load_config(&LoadOptions {
17        workspace: cli.workspace.clone(),
18        config: cli.config.clone(),
19    })?;
20
21    let pm = loaded
22        .config
23        .package_manager
24        .as_ref()
25        .ok_or_else(|| SboxError::ConfigValidation {
26            message: "sbox bootstrap requires `package_manager:` in sbox.yaml\n\
27                  for manually-configured profiles, generate the lockfile directly:\n\
28                    sbox run -- <pm> lock  (or equivalent)"
29                .to_string(),
30        })?;
31
32    let bootstrap_cmd = bootstrap_command_for(&pm.name)?;
33
34    // Resolve against the install profile (dispatch will route the bootstrap command there).
35    // Run with strict_security=false: the whole point of bootstrap is to generate the lockfile
36    // that strict mode would require, so we must not refuse on its absence.
37    let plan = resolve_execution_plan(cli, &loaded, ResolutionTarget::Run, &bootstrap_cmd)?;
38    validate_execution_safety(&plan, false)?;
39    run_pre_run_commands(&plan)?;
40
41    eprintln!(
42        "sbox bootstrap: generating {} lockfile inside sandbox...",
43        pm.name
44    );
45
46    let exit = match plan.mode {
47        ExecutionMode::Host => execute_host(&plan)?,
48        ExecutionMode::Sandbox => execute_sandbox(&plan)?,
49    };
50
51    if exit == ExitCode::SUCCESS {
52        eprintln!("\nlockfile generated.");
53        eprintln!("next: sbox run -- {}", rebuild_hint(&pm.name));
54    }
55
56    Ok(exit)
57}
58
59/// Maps package manager names to their lockfile-generation command (no scripts executed).
60fn bootstrap_command_for(pm_name: &str) -> Result<Vec<String>, SboxError> {
61    let cmd: &[&str] = match pm_name {
62        "npm" => &["npm", "install", "--ignore-scripts"],
63        "yarn" => &["yarn", "install", "--ignore-scripts"],
64        "pnpm" => &["pnpm", "install", "--ignore-scripts"],
65        "bun" => &["bun", "install", "--no-scripts"],
66        "uv" => &["uv", "lock"],
67        "pip" => {
68            // pip has no lockfile-only mode. Direct users to pip-compile (pip-tools) or uv.
69            return Err(SboxError::ConfigValidation {
70                message: "pip does not support lockfile-only bootstrap.\n\
71                          To generate a pinned requirements.txt safely:\n\
72                            pip-compile requirements.in           (install pip-tools first)\n\
73                            uv pip compile requirements.in -o requirements.txt\n\
74                          Or switch to the `uv` preset which supports `sbox bootstrap` natively."
75                    .to_string(),
76            });
77        }
78        "poetry" => &["poetry", "lock"],
79        "cargo" => &["cargo", "fetch"],
80        "go" => &["go", "mod", "download"],
81        _ => {
82            return Err(SboxError::ConfigValidation {
83                message: format!(
84                    "no bootstrap command known for package manager `{pm_name}`; \
85                     generate the lockfile manually and run `sbox run` directly"
86                ),
87            });
88        }
89    };
90    Ok(cmd.iter().map(|s| s.to_string()).collect())
91}
92
93/// Suggests the next command to run after bootstrap (runs scripts with network off).
94fn rebuild_hint(pm_name: &str) -> &'static str {
95    match pm_name {
96        "npm" => "npm rebuild                  # runs install scripts, network off",
97        "yarn" => "yarn install                 # runs install scripts, network off",
98        "pnpm" => "pnpm rebuild                 # runs install scripts, network off",
99        "bun" => "bun install                  # runs install scripts, network off",
100        "uv" => "uv sync                      # install from lockfile, network off",
101        "pip" => "pip install -r requirements.txt  # install from requirements",
102        "poetry" => "poetry install               # install from lockfile, network off",
103        "cargo" => "cargo build                  # compile from fetched sources",
104        "go" => "go build ./...               # compile from downloaded modules",
105        _ => "<install-command>",
106    }
107}