use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsStr;
use std::io::{self, IsTerminal, Write};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::thread;
use crossterm::cursor;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::terminal::{self, ClearType};
use crossterm::{execute, queue};
use semver::Version;
use crate::backends::archive::{install_download, resolved_target};
use crate::backends::typed::{
BackendContext, BackendOperation, install_operations, rustup_bootstrap_operation,
verification_operations,
};
use crate::config::schema::{ArchiveInstall, CheckSpec, InstallSpec, NetworkPolicy, RustupInstall};
use crate::error::ForgeError;
use crate::execution::command::{
ShellDisplayMode, ShellStep, run_invocation_capture, run_invocation_labeled, run_shell_capture,
run_shell_capture_timeout, run_shell_labeled, run_shell_labeled_limits_display_step,
};
use crate::execution::environment::{refresh_python_environment, refresh_rust_process_environment};
use crate::execution::managed_cargo::install as install_managed_cargo;
use crate::execution::managed_git::install as install_managed_git;
use crate::execution::policy::enforce_network_policy;
use crate::fsutil::{copy_dir, create_dir_all};
use crate::model::{
ArchiveFormat, InstallConfig, InstallKind, InstallOutcome, InstallPreview, InstallReport,
RegistryEntry, RegistryTarget, SkillDef, ToolDef, ToolInstallOutcome, ToolInstallResult,
ToolStatus,
};
use crate::paths::app_home;
use crate::planning::{ExecutionPlan, TargetPlatform, UnsupportedComponent};
use crate::skills::{agent_dir, discover};
use crate::state::append_registry;
use crate::ui::input::{OverwriteChoice, PromptOutcome, choose_overwrite};
use crate::ui::{
self, RawModeGuard, RenderOptions, StatusKind, fit_display_width as fit_ui_display_width,
};
use crate::util::{
first_line, fnv1a, looks_like_git, now_secs, shell_quote, shell_quote_str, source_name,
};
const SELECTION_MENU_FIXED_LINES: u16 = 4;
const MIN_SELECTION_MENU_HEIGHT: u16 = SELECTION_MENU_FIXED_LINES + 1;
const MIN_SELECTION_MENU_WIDTH: u16 = 20;
pub(crate) fn run_certificate_preflight(
plan: &ExecutionPlan,
display_mode: ShellDisplayMode,
) -> Result<(), ForgeError> {
let Some(preflight) = plan.certificate_preflight.as_ref() else {
return Ok(());
};
let tool = ToolDef {
name: "certificate-preflight".into(),
display_name: Some("Certificate preflight".into()),
version: None,
optional: false,
allow_insecure_hosts: Vec::new(),
detect: Some(preflight.detect.clone()),
install: Some(preflight.install.clone()),
verify: preflight.verify.clone(),
};
if check_tool(&tool).installed {
ui::stdout_status(
StatusKind::Success,
"Certificate preflight already satisfied.",
);
return Ok(());
}
ui::stdout_status(
StatusKind::Info,
"Certificate preflight missing; running configured installer.",
);
let result = install_typed_tool(
plan,
&tool,
&preflight.install,
display_mode,
None,
None,
None,
)?;
if result.outcome != ToolInstallOutcome::Installed {
return Err(ForgeError::Command(
"certificate preflight did not pass after installation".into(),
));
}
ui::stdout_status(StatusKind::Success, "Certificate preflight satisfied.");
Ok(())
}
pub(crate) fn finalize_scheduled_install(
config: &InstallConfig,
preview: &InstallPreview,
tool_results: Vec<ToolInstallResult>,
entries: Vec<RegistryEntry>,
started: std::time::Instant,
) -> Result<InstallReport, ForgeError> {
let outcome = install_outcome(&tool_results);
let tools = complete_tool_results(preview, tool_results);
let final_preview = refresh_changed_preview(config, preview, &tools)?;
Ok(InstallReport {
entries,
final_preview,
outcome,
tools,
duration_ms: started.elapsed().as_millis(),
})
}
fn refresh_changed_preview(
config: &InstallConfig,
initial: &InstallPreview,
results: &[ToolInstallResult],
) -> Result<InstallPreview, ForgeError> {
let changed: BTreeSet<&str> = results
.iter()
.filter(|result| result.outcome == ToolInstallOutcome::Installed)
.map(|result| result.name.as_str())
.collect();
let replacements: BTreeMap<String, ToolStatus> = config
.tools
.iter()
.filter(|tool| changed.contains(tool.name.as_str()))
.map(|tool| (tool.name.clone(), check_tool(tool)))
.collect();
let mut final_preview = initial.clone();
for status in &mut final_preview.tools {
if let Some(replacement) = replacements.get(&status.name) {
*status = replacement.clone();
}
}
for skill in &mut final_preview.skills {
skill.installed = skill
.agent_dir
.as_ref()
.is_some_and(|root| root.join(&skill.name).join("SKILL.md").is_file());
}
Ok(final_preview)
}
fn base_tool_results(preview: &InstallPreview) -> Vec<ToolInstallResult> {
preview
.tools
.iter()
.filter_map(|status| {
let outcome = status
.installed
.then_some(ToolInstallOutcome::AlreadyPresent)?;
Some(ToolInstallResult {
name: status.name.clone(),
outcome,
message: status.version.clone(),
})
})
.collect()
}
fn complete_tool_results(
preview: &InstallPreview,
mut executed: Vec<ToolInstallResult>,
) -> Vec<ToolInstallResult> {
let executed_names = executed
.iter()
.map(|result| result.name.as_str())
.collect::<BTreeSet<_>>();
let mut results = base_tool_results(preview)
.into_iter()
.filter(|result| !executed_names.contains(result.name.as_str()))
.collect::<Vec<_>>();
results.append(&mut executed);
results
}
fn install_outcome(results: &[ToolInstallResult]) -> InstallOutcome {
if results
.iter()
.any(|result| matches!(result.outcome, ToolInstallOutcome::VerificationFailed))
{
InstallOutcome::Failed
} else {
InstallOutcome::Success
}
}
pub(crate) fn check_tool(tool: &ToolDef) -> ToolStatus {
if let Some(check) = &tool.detect {
return tool_status_from_detection(tool, run_typed_check(&tool.name, check));
}
ToolStatus {
name: tool.name.clone(),
display_name: tool.display_name.clone(),
optional: tool.optional,
installed: false,
version: None,
required_version: offered_version(tool),
outdated: false,
installable: tool.install.is_some(),
}
}
fn tool_status_from_detection(tool: &ToolDef, result: Result<String, ForgeError>) -> ToolStatus {
let installed = result.is_ok();
let version = result.ok().and_then(|output| {
if tool
.detect
.as_ref()
.is_some_and(check_lists_installed_rustup_components)
{
return None;
}
let version = first_line(output);
(!version.is_empty()).then_some(version)
});
let required_version = offered_version(tool);
let outdated = installed
&& version
.as_deref()
.zip(required_version.as_deref())
.is_some_and(|(installed, required)| {
match (extract_version(installed), extract_version(required)) {
(Some(installed), Some(required)) => installed < required,
_ => false,
}
});
ToolStatus {
name: tool.name.clone(),
display_name: tool.display_name.clone(),
optional: tool.optional,
installed,
version,
required_version,
outdated,
installable: tool.install.is_some(),
}
}
fn offered_version(tool: &ToolDef) -> Option<String> {
tool.version
.clone()
.or_else(|| match tool.install.as_ref()? {
InstallSpec::Cargo(value) => Some(value.version.clone()),
InstallSpec::Npm(value) => Some(value.version.clone()),
InstallSpec::Pip(value) => Some(value.version.clone()),
InstallSpec::Rustup(value) => value.toolchain.clone(),
_ => None,
})
}
fn extract_version(value: &str) -> Option<Version> {
value
.split(|character: char| {
!(character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '+'))
})
.filter_map(|token| Version::parse(token.trim_start_matches(['v', 'V', '='])).ok())
.next()
}
fn check_lists_installed_rustup_components(check: &CheckSpec) -> bool {
match check {
CheckSpec::Command { program, args, .. } => {
program == "rustup"
&& args.first().is_some_and(|argument| argument == "component")
&& args.get(1).is_some_and(|argument| argument == "list")
&& args.iter().any(|argument| argument == "--installed")
}
_ => false,
}
}
fn run_typed_check(label: &str, check: &CheckSpec) -> Result<String, ForgeError> {
let mut outputs = Vec::new();
for operation in verification_operations(check, None) {
let output = match operation {
BackendOperation::Command(mut invocation) => {
invocation
.env
.insert("RUSTUP_AUTO_INSTALL".into(), "0".into());
run_invocation_capture(&invocation)?
}
BackendOperation::Shell {
command,
timeout_secs,
..
} => {
run_shell_capture_timeout(&command, timeout_secs, &[("RUSTUP_AUTO_INSTALL", "0")])?
}
BackendOperation::PathExists { path } => {
if path.exists() {
String::new()
} else {
return Err(ForgeError::Command(format!(
"{label} check path does not exist: {}",
path.display()
)));
}
}
BackendOperation::Archive { .. } => {
return Err(ForgeError::Config(format!(
"{label} check cannot produce an archive operation"
)));
}
};
let output = first_line(output);
if !output.is_empty() {
outputs.push(output);
}
}
Ok(outputs.join("; "))
}
pub(crate) fn verify_typed_tool(tool: &ToolDef) -> Result<Option<String>, ForgeError> {
let Some(check) = tool.verify.as_ref() else {
return Ok(None);
};
run_typed_check(&tool.name, check).map(|output| Some(first_line(output)))
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct InstallSelection {
tools: BTreeSet<String>,
skills: BTreeSet<String>,
}
impl InstallSelection {
fn all(preview: &InstallPreview) -> Self {
Self {
tools: preview
.missing_tools()
.into_iter()
.filter(|status| status.installable)
.map(|status| status.name.clone())
.collect(),
skills: preview
.missing_skills()
.into_iter()
.map(|status| status.name.clone())
.collect(),
}
}
fn is_empty(&self) -> bool {
self.tools.is_empty() && self.skills.is_empty()
}
#[cfg(test)]
fn includes_tool(&self, name: &str) -> bool {
self.tools.contains(name)
}
fn step_count(&self) -> usize {
self.tools.len() + self.skills.len()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SelectionKind {
Spacer,
Heading,
Tool(String),
Skill(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct SelectionChoice {
kind: SelectionKind,
label: String,
description: String,
details: Vec<String>,
selected: bool,
selectable: bool,
}
pub(crate) fn select_install_components(
preview: &InstallPreview,
plan: &ExecutionPlan,
) -> Result<Vec<String>, ForgeError> {
let mut choices = selectable_install_choices(preview, &plan.unsupported_components);
let graph = SelectionGraph::from_plan(plan);
let direct_components = plan
.components
.iter()
.filter(|component| {
component
.requested_by
.iter()
.any(|reason| reason.starts_with("profile:"))
})
.map(|component| component.id.clone())
.collect::<BTreeSet<_>>();
initialize_selection(&mut choices, &graph, &direct_components);
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
let selection = InstallSelection::all(preview);
return Ok(selection
.tools
.into_iter()
.chain(selection.skills)
.collect());
}
let Some(selection) =
run_interactive_selection_menu(&mut choices, Some(&graph), "Install components")?
else {
return Err(ForgeError::Command("return to launcher".to_string()));
};
Ok(selection
.tools
.into_iter()
.chain(selection.skills)
.collect())
}
pub(crate) fn select_outdated_components(
preview: &InstallPreview,
) -> Result<Vec<String>, ForgeError> {
let outdated = preview
.tools
.iter()
.filter(|status| status.outdated && status.installable)
.collect::<Vec<_>>();
if outdated.is_empty() {
return Ok(Vec::new());
}
if !io::stdin().is_terminal() || !io::stdout().is_terminal() {
return Err(ForgeError::Command(
"a non-interactive terminal cannot confirm available updates; pass --yes or run in a TTY"
.to_string(),
));
}
ui::stdout_status(
StatusKind::Warning,
&format!(
"{} installed component(s) have updates available.",
outdated.len()
),
);
let options = RenderOptions::stdout();
for status in &outdated {
let installed_version = status
.version
.as_deref()
.map(|version| ui::display_tool_version(&status.name, version))
.unwrap_or_else(|| "unknown".to_string());
let required_version = status
.required_version
.as_deref()
.map(|version| ui::display_tool_version(&status.name, version))
.unwrap_or_else(|| "configured".to_string());
let name = format!("{:<20}", ui::tool_status_name(status));
ui::stdout_line(&format!(
" {} {} {} {}",
ui::muted_text(&name, &options),
ui::muted_text(&installed_version, &options),
ui::muted_text("->", &options),
ui::warning_plain_text(&required_version, &options)
));
}
match choose_overwrite("Apply available updates?")? {
PromptOutcome::Confirmed(OverwriteChoice::All) => Ok(outdated
.into_iter()
.map(|status| status.name.clone())
.collect()),
PromptOutcome::Confirmed(OverwriteChoice::KeepExisting)
| PromptOutcome::Cancelled
| PromptOutcome::Unavailable => Ok(Vec::new()),
PromptOutcome::Confirmed(OverwriteChoice::Custom) => {
let mut choices = outdated
.into_iter()
.map(|status| SelectionChoice {
kind: SelectionKind::Tool(status.name.clone()),
label: ui::tool_status_name(status),
description: ui::tool_selection_description(status),
details: Vec::new(),
selected: true,
selectable: true,
})
.collect::<Vec<_>>();
let Some(selection) =
run_interactive_selection_menu(&mut choices, None, "Select updates to apply")?
else {
return Ok(Vec::new());
};
Ok(selection.tools.into_iter().collect())
}
PromptOutcome::Interrupted => Err(ForgeError::Command(
"operation interrupted by Ctrl-C".to_string(),
)),
}
}
#[derive(Debug, Default)]
struct SelectionGraph {
dependencies: BTreeMap<String, Vec<String>>,
dependents: BTreeMap<String, Vec<String>>,
}
impl SelectionGraph {
fn from_plan(plan: &ExecutionPlan) -> Self {
let dependencies = plan
.components
.iter()
.map(|component| (component.id.clone(), component.dependencies.clone()))
.collect::<BTreeMap<_, _>>();
let mut dependents = BTreeMap::<String, Vec<String>>::new();
for (component, required) in &dependencies {
for dependency in required {
dependents
.entry(dependency.clone())
.or_default()
.push(component.clone());
}
}
Self {
dependencies,
dependents,
}
}
}
fn initialize_selection(
choices: &mut [SelectionChoice],
graph: &SelectionGraph,
direct_components: &BTreeSet<String>,
) {
let roots = choices
.iter()
.filter(|choice| choice.selectable && choice.selected)
.filter_map(choice_name)
.filter(|name| direct_components.contains(*name))
.map(str::to_string)
.collect::<Vec<_>>();
set_all_selectable(choices, false);
for root in roots {
propagate_selection(choices, &root, true, &graph.dependencies);
}
}
fn selectable_install_choices(
preview: &InstallPreview,
unsupported: &[UnsupportedComponent],
) -> Vec<SelectionChoice> {
enum ToolEntry<'a> {
Supported(&'a ToolStatus),
Unsupported(&'a UnsupportedComponent),
}
let mut tools = preview
.tools
.iter()
.map(ToolEntry::Supported)
.chain(unsupported.iter().map(ToolEntry::Unsupported))
.collect::<Vec<_>>();
tools.sort_by_key(|entry| {
let id = match entry {
ToolEntry::Supported(status) => &status.name,
ToolEntry::Unsupported(component) => &component.id,
};
let presentation = ui::tool_presentation(id);
(ui::tool_category_order(presentation.1), id.as_str())
});
let mut choices = Vec::new();
let mut category = None;
for entry in tools {
let id = match &entry {
ToolEntry::Supported(status) => &status.name,
ToolEntry::Unsupported(component) => &component.id,
};
let presentation = ui::tool_presentation(id);
if category != Some(presentation.1) {
if category.is_some() {
choices.push(SelectionChoice {
kind: SelectionKind::Spacer,
label: String::new(),
description: String::new(),
details: Vec::new(),
selected: false,
selectable: false,
});
}
category = Some(presentation.1);
choices.push(SelectionChoice {
kind: SelectionKind::Heading,
label: presentation.1.to_string(),
description: String::new(),
details: Vec::new(),
selected: false,
selectable: false,
});
}
match entry {
ToolEntry::Supported(status) => {
let mandatory = presentation.1 == "Core toolchain";
let selectable = !status.installed && status.installable;
let state = if mandatory && !status.installed {
"required".to_string()
} else {
ui::tool_selection_description(status)
};
choices.push(SelectionChoice {
kind: SelectionKind::Tool(status.name.clone()),
label: ui::tool_status_name(status),
description: ui::choice_columns(&state, presentation.2),
details: tool_selection_details(status),
selected: mandatory || selectable,
selectable,
});
}
ToolEntry::Unsupported(component) => {
choices.push(SelectionChoice {
kind: match component.kind {
InstallKind::Skill => SelectionKind::Skill(component.id.clone()),
_ => SelectionKind::Tool(component.id.clone()),
},
label: component.display_name.as_deref().map_or_else(
|| component.id.clone(),
|name| format!("{name} ({})", component.id),
),
description: "unsupported on this platform".to_string(),
details: Vec::new(),
selected: false,
selectable: false,
});
}
}
}
if !preview.skills.is_empty() {
choices.push(SelectionChoice {
kind: SelectionKind::Heading,
label: "Agent skills".to_string(),
description: String::new(),
details: Vec::new(),
selected: false,
selectable: false,
});
}
choices.extend(preview.skills.iter().map(|status| SelectionChoice {
kind: SelectionKind::Skill(status.name.clone()),
label: ui::skill_status_name(status),
description: if status.installed {
"installed".to_string()
} else if !status.installable {
"unsupported".to_string()
} else if status.optional {
format!("skill -> {}, optional", status.agent.as_str())
} else {
format!("skill -> {}", status.agent.as_str())
},
details: Vec::new(),
selected: !status.installed && status.installable,
selectable: !status.installed && status.installable,
}));
choices
}
fn tool_selection_details(status: &ToolStatus) -> Vec<String> {
status
.installed
.then_some(status.version.as_deref())
.flatten()
.map(ui::composite_detection_details)
.unwrap_or_default()
}
fn run_interactive_selection_menu(
choices: &mut [SelectionChoice],
graph: Option<&SelectionGraph>,
title: &str,
) -> Result<Option<InstallSelection>, ForgeError> {
let _progress = ui::progress::pause();
let mut stdout = io::stdout();
let mut raw_mode = RawModeGuard::acquire().map_err(|error| {
ForgeError::Command(format!("failed to enable interactive selection: {error}"))
})?;
let mut screen = SelectionScreenGuard::enter(&mut stdout).map_err(|error| {
ForgeError::Command(format!("failed to draw install selection: {error}"))
})?;
let result = run_selection_event_loop(choices, graph, title, &mut stdout);
screen.restore(&mut stdout).map_err(|error| {
ForgeError::Command(format!("failed to restore install selection: {error}"))
})?;
raw_mode.restore().map_err(|error| {
ForgeError::Command(format!("failed to restore terminal input mode: {error}"))
})?;
let Some(selection) = result? else {
return Ok(None);
};
if selection.is_empty() {
ui::stdout_status(StatusKind::Info, "Selected components: none");
} else {
ui::stdout_status(
StatusKind::Info,
&format!("Selected components: {}", selection.step_count()),
);
}
Ok(Some(selection))
}
struct SelectionScreenGuard {
active: bool,
}
impl SelectionScreenGuard {
fn enter(stdout: &mut impl Write) -> io::Result<Self> {
if let Err(error) = execute!(
stdout,
terminal::EnterAlternateScreen,
terminal::Clear(ClearType::All),
cursor::MoveTo(0, 0),
cursor::Hide
) {
let _ = execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen);
return Err(error);
}
Ok(Self { active: true })
}
fn restore(&mut self, stdout: &mut impl Write) -> io::Result<()> {
if self.active {
execute!(stdout, cursor::Show, terminal::LeaveAlternateScreen)?;
self.active = false;
}
Ok(())
}
}
impl Drop for SelectionScreenGuard {
fn drop(&mut self) {
if self.active {
let _ = execute!(io::stdout(), cursor::Show, terminal::LeaveAlternateScreen);
self.active = false;
}
}
}
fn run_selection_event_loop(
choices: &mut [SelectionChoice],
graph: Option<&SelectionGraph>,
title: &str,
stdout: &mut io::Stdout,
) -> Result<Option<InstallSelection>, ForgeError> {
let mut cursor_index = first_selectable(choices).unwrap_or(0);
loop {
render_selection_menu(choices, cursor_index, title, stdout)?;
let event = event::read().map_err(|error| {
ForgeError::Command(format!("failed to read install selection input: {error}"))
})?;
let Event::Key(key) = event else {
continue;
};
if !is_actionable_key_event(key.kind) {
continue;
}
match reduce_selection_key_with_graph(choices, cursor_index, key.code, key.modifiers, graph)
{
SelectionTransition::Redraw(index) => cursor_index = index,
SelectionTransition::Confirm => return Ok(Some(selection_from_choices(choices))),
SelectionTransition::Back => return Ok(None),
SelectionTransition::Exit => {
return Err(ForgeError::Command("exit bot-forge".to_string()));
}
SelectionTransition::Interrupt => {
return Err(ForgeError::Command(
"operation interrupted by Ctrl-C".to_string(),
));
}
SelectionTransition::Ignored => {}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SelectionTransition {
Redraw(usize),
Confirm,
Back,
Exit,
Interrupt,
Ignored,
}
#[cfg(test)]
fn reduce_selection_key(
choices: &mut [SelectionChoice],
cursor_index: usize,
code: KeyCode,
modifiers: KeyModifiers,
) -> SelectionTransition {
reduce_selection_key_with_graph(choices, cursor_index, code, modifiers, None)
}
fn reduce_selection_key_with_graph(
choices: &mut [SelectionChoice],
cursor_index: usize,
code: KeyCode,
modifiers: KeyModifiers,
graph: Option<&SelectionGraph>,
) -> SelectionTransition {
if code == KeyCode::Char('c') && modifiers.contains(KeyModifiers::CONTROL) {
return SelectionTransition::Interrupt;
}
match code {
KeyCode::Up | KeyCode::Left | KeyCode::Char('k') | KeyCode::Char('K') => {
SelectionTransition::Redraw(previous_selectable(choices, cursor_index))
}
KeyCode::Down | KeyCode::Right | KeyCode::Tab | KeyCode::Char('j') | KeyCode::Char('J') => {
SelectionTransition::Redraw(next_selectable(choices, cursor_index))
}
KeyCode::Home => {
SelectionTransition::Redraw(first_selectable(choices).unwrap_or(cursor_index))
}
KeyCode::End => {
SelectionTransition::Redraw(last_selectable(choices).unwrap_or(cursor_index))
}
KeyCode::Char(' ') => {
toggle_selection(choices, cursor_index, graph);
SelectionTransition::Redraw(cursor_index)
}
KeyCode::Char('a') | KeyCode::Char('A') => {
set_all_selectable(choices, true);
SelectionTransition::Redraw(cursor_index)
}
KeyCode::Char('n') | KeyCode::Char('N') => {
set_all_selectable(choices, false);
SelectionTransition::Redraw(cursor_index)
}
KeyCode::Enter => SelectionTransition::Confirm,
KeyCode::Esc | KeyCode::Char('b') | KeyCode::Char('B') => SelectionTransition::Back,
KeyCode::Char('q') | KeyCode::Char('Q') => SelectionTransition::Exit,
_ => SelectionTransition::Ignored,
}
}
fn toggle_selection(
choices: &mut [SelectionChoice],
cursor_index: usize,
graph: Option<&SelectionGraph>,
) {
let Some(choice) = choices
.get(cursor_index)
.filter(|choice| choice.selectable && !is_mandatory_choice(choice))
else {
return;
};
let selected = !choice.selected;
let Some(name) = choice_name(choice).map(str::to_string) else {
return;
};
set_choice_selected(choices, &name, selected);
let Some(graph) = graph else { return };
let edges = if selected {
&graph.dependencies
} else {
&graph.dependents
};
propagate_selection(choices, &name, selected, edges);
}
fn propagate_selection(
choices: &mut [SelectionChoice],
name: &str,
selected: bool,
edges: &BTreeMap<String, Vec<String>>,
) {
set_choice_selected(choices, name, selected);
let mut pending = vec![name.to_string()];
let mut visited = BTreeSet::new();
while let Some(current) = pending.pop() {
if !visited.insert(current.clone()) {
continue;
}
for related in edges.get(¤t).into_iter().flatten() {
set_choice_selected(choices, related, selected);
pending.push(related.clone());
}
}
}
fn choice_name(choice: &SelectionChoice) -> Option<&str> {
match &choice.kind {
SelectionKind::Tool(name) | SelectionKind::Skill(name) => Some(name),
SelectionKind::Spacer | SelectionKind::Heading => None,
}
}
fn set_choice_selected(choices: &mut [SelectionChoice], name: &str, selected: bool) {
for choice in choices.iter_mut().filter(|choice| {
choice.selectable && choice_name(choice).is_some_and(|value| value == name)
}) {
choice.selected = is_mandatory_choice(choice) || selected;
}
}
fn set_all_selectable(choices: &mut [SelectionChoice], selected: bool) {
for choice in choices.iter_mut().filter(|choice| choice.selectable) {
choice.selected = is_mandatory_choice(choice) || selected;
}
}
fn is_mandatory_choice(choice: &SelectionChoice) -> bool {
matches!(&choice.kind, SelectionKind::Tool(name) if ui::tool_presentation(name).1 == "Core toolchain")
}
fn first_selectable(choices: &[SelectionChoice]) -> Option<usize> {
choices
.iter()
.position(|choice| choice.selectable && !is_mandatory_choice(choice))
}
fn last_selectable(choices: &[SelectionChoice]) -> Option<usize> {
choices
.iter()
.rposition(|choice| choice.selectable && !is_mandatory_choice(choice))
}
fn next_selectable(choices: &[SelectionChoice], current: usize) -> usize {
if choices.is_empty() {
return current;
}
(1..=choices.len())
.map(|offset| (current + offset) % choices.len())
.find(|index| choices[*index].selectable && !is_mandatory_choice(&choices[*index]))
.unwrap_or(current)
}
fn previous_selectable(choices: &[SelectionChoice], current: usize) -> usize {
if choices.is_empty() {
return current;
}
(1..=choices.len())
.map(|offset| (current + choices.len() - offset % choices.len()) % choices.len())
.find(|index| choices[*index].selectable && !is_mandatory_choice(&choices[*index]))
.unwrap_or(current)
}
fn is_actionable_key_event(kind: KeyEventKind) -> bool {
kind == KeyEventKind::Press
}
fn render_selection_menu(
choices: &[SelectionChoice],
cursor_index: usize,
title: &str,
stdout: &mut io::Stdout,
) -> Result<(), ForgeError> {
let (terminal_width, terminal_height) = terminal::size().unwrap_or((80, 24));
if terminal_width < MIN_SELECTION_MENU_WIDTH || terminal_height < MIN_SELECTION_MENU_HEIGHT {
return Err(ForgeError::Command(format!(
"terminal is too small; component selection requires at least {} columns and {} rows",
MIN_SELECTION_MENU_WIDTH, MIN_SELECTION_MENU_HEIGHT
)));
}
let width = usize::from(terminal_width - 1);
let visible_rows = selection_visible_count(terminal_height);
let (start, end) = selection_window_by_rows(cursor_index, choices, visible_rows);
queue!(
stdout,
cursor::MoveTo(0, 0),
terminal::Clear(ClearType::All)
)
.map_err(|error| ForgeError::Command(format!("failed to draw install selection: {error}")))?;
let options = RenderOptions {
width,
..RenderOptions::stdout()
};
write_menu_line(stdout, 0, &ui::section(title, &options), width)?;
write_menu_line(
stdout,
1,
&format!(" {}", ui::choice_key_help(&options)),
width,
)?;
write_menu_line(stdout, 2, "", width)?;
let footer_row = terminal_height - 1;
let mut row = 3;
for (index, choice) in choices.iter().enumerate().take(end).skip(start) {
if row >= footer_row {
break;
}
let line = match choice.kind {
SelectionKind::Spacer => String::new(),
SelectionKind::Heading => ui::category_heading(&choice.label, &options),
_ => ui::choice_line(
index == cursor_index,
choice.selected,
!choice.selectable || is_mandatory_choice(choice),
&choice.label,
&choice.description,
&options,
),
};
write_menu_line(stdout, row, &line, width)?;
row = row.saturating_add(1);
for detail in &choice.details {
if row >= footer_row {
break;
}
write_menu_line(
stdout,
row,
&ui::choice_detail_line(detail, &options),
width,
)?;
row = row.saturating_add(1);
}
}
let above = start;
let below = choices.len().saturating_sub(end);
write_menu_line(
stdout,
footer_row,
&selection_pagination_line(above, below, &options),
width,
)?;
stdout.flush().map_err(|error| {
ForgeError::Command(format!("failed to refresh install selection: {error}"))
})
}
fn selection_pagination_line(above: usize, below: usize, options: &RenderOptions) -> String {
if above == 0 && below == 0 {
return String::new();
}
ui::muted_text(&format!(" ↑ {above} more • ↓ {below} more"), options)
}
fn selection_visible_count(terminal_height: u16) -> usize {
usize::from(terminal_height - SELECTION_MENU_FIXED_LINES)
}
fn write_menu_line(
stdout: &mut io::Stdout,
row: u16,
text: &str,
width: usize,
) -> Result<(), ForgeError> {
queue!(
stdout,
cursor::MoveTo(0, row),
terminal::Clear(ClearType::CurrentLine)
)
.map_err(|error| ForgeError::Command(format!("failed to write install selection: {error}")))?;
write!(stdout, "{}", fit_display_width(text, width))
.map_err(|error| ForgeError::Command(format!("failed to write install selection: {error}")))
}
fn selection_window_by_rows(
cursor_index: usize,
choices: &[SelectionChoice],
visible_rows: usize,
) -> (usize, usize) {
if choices.is_empty() || visible_rows == 0 {
return (0, 0);
}
let cursor_index = cursor_index.min(choices.len() - 1);
let height = |choice: &SelectionChoice| 1 + choice.details.len();
let mut start = cursor_index;
let mut used = height(&choices[cursor_index]).min(visible_rows);
let before_target = visible_rows / 2;
let mut before_used = 0;
while start > 0 {
let candidate = height(&choices[start - 1]);
if before_used + candidate > before_target || used + candidate > visible_rows {
break;
}
start -= 1;
before_used += candidate;
used += candidate;
}
let mut end = cursor_index + 1;
while end < choices.len() {
let candidate = height(&choices[end]);
if used + candidate > visible_rows {
break;
}
used += candidate;
end += 1;
}
while start > 0 {
let candidate = height(&choices[start - 1]);
if used + candidate > visible_rows {
break;
}
start -= 1;
used += candidate;
}
(start, end)
}
fn fit_display_width(text: &str, max_width: usize) -> String {
fit_ui_display_width(text, max_width)
}
fn selection_from_choices(choices: &[SelectionChoice]) -> InstallSelection {
let mut selection = InstallSelection {
tools: BTreeSet::new(),
skills: BTreeSet::new(),
};
for choice in choices
.iter()
.filter(|choice| choice.selectable && choice.selected)
{
match &choice.kind {
SelectionKind::Spacer | SelectionKind::Heading => {}
SelectionKind::Tool(name) => {
selection.tools.insert(name.clone());
}
SelectionKind::Skill(name) => {
selection.skills.insert(name.clone());
}
}
}
selection
}
pub(crate) fn record_tool_install(tool: &ToolDef, profile: &str) -> Result<(), ForgeError> {
let backend = tool
.backend()
.ok_or_else(|| ForgeError::Config(format!("tool {} has no install backend", tool.name)))?;
let targets = match tool.install.as_ref() {
Some(InstallSpec::Archive(archive)) => {
vec![RegistryTarget {
path: resolved_target(archive),
binary: None,
}]
}
_ => Vec::new(),
};
append_registry(RegistryEntry {
name: tool.name.clone(),
kind: InstallKind::Tool,
source: format!("backend:{}", backend.as_str()),
profile: profile.to_string(),
targets,
installed_at: now_secs(),
artifact_id: None,
previous_artifact_id: None,
config_hash: None,
plan_hash: None,
source_revision: None,
backend: Some(backend),
})
}
pub(crate) fn install_typed_tool(
plan: &ExecutionPlan,
tool: &ToolDef,
specification: &InstallSpec,
display_mode: ShellDisplayMode,
step: Option<ShellStep>,
apt_source: Option<&Path>,
apt_lists: Option<&Path>,
) -> Result<ToolInstallResult, ForgeError> {
if let InstallSpec::Cargo(cargo) = specification {
return install_managed_cargo(plan, tool, cargo, display_mode, step);
}
if let InstallSpec::Git(git) = specification {
return install_managed_git(plan, tool, git, display_mode, step);
}
enforce_network_policy(plan.policy.network, specification)?;
let work = app_home().join("work").join(&tool.name);
create_dir_all(&work)?;
if let InstallSpec::Rustup(rustup) = specification {
ensure_rustup_available(plan, tool, rustup, &work, display_mode, step)?;
}
let target = work.join("target");
let context = BackendContext {
staging: &work,
target: &target,
cargo_jobs: thread::available_parallelism().map_or(1, usize::from),
allow_insecure_hosts: &tool.allow_insecure_hosts,
apt_source,
apt_lists,
};
for operation in install_operations(specification, &context)? {
let result = match operation {
BackendOperation::Command(invocation) => {
run_invocation_labeled(&tool.name, &invocation, display_mode, step)
}
BackendOperation::Archive { .. } => {
let InstallSpec::Archive(archive) = specification else {
return Err(ForgeError::Config(
"archive backend request type mismatch".into(),
));
};
install_download(archive, plan.policy.network).map(|_| ())
}
BackendOperation::PathExists { .. } => {
return Err(ForgeError::Config(format!(
"{} installation cannot produce a path check operation",
tool.name
)));
}
BackendOperation::Shell {
command,
timeout_secs,
inactivity_timeout_secs,
..
} => run_shell_labeled_limits_display_step(
&tool.name,
&command,
display_mode,
step,
timeout_secs.map(std::time::Duration::from_secs),
inactivity_timeout_secs.map(std::time::Duration::from_secs),
),
};
if let Err(error) = result {
rollback_typed_install(specification, display_mode, step, &error)?;
return Err(error);
}
}
if let InstallSpec::Pip(pip) = specification {
refresh_python_environment(&pip.environment);
}
let result = installation_verification_result(tool, step)?;
if result.outcome != ToolInstallOutcome::Installed {
rollback_typed_install(
specification,
display_mode,
step,
&ForgeError::Command("post-install verification failed".into()),
)?;
} else if let Err(error) = verify_typed_tool(tool) {
rollback_typed_install(specification, display_mode, step, &error)?;
return Err(ForgeError::Command(format!(
"tool {} verify check failed: {error}",
tool.name
)));
}
Ok(result)
}
fn ensure_rustup_available(
plan: &ExecutionPlan,
tool: &ToolDef,
rustup: &RustupInstall,
work: &Path,
display_mode: ShellDisplayMode,
step: Option<ShellStep>,
) -> Result<(), ForgeError> {
let check = CheckSpec::Command {
program: "rustup".into(),
args: vec!["--version".into()],
stdout_contains: None,
success_codes: vec![0],
timeout_secs: Some(10),
};
if run_typed_check("rustup bootstrap", &check).is_ok() {
return Ok(());
}
let bootstrap = rustup.bootstrap.as_ref().ok_or_else(|| {
ForgeError::Config(format!(
"tool {} uses the rustup backend, but rustup is not in PATH and no bootstrap is configured",
tool.name
))
})?;
let selector = plan.target.selector();
let sha256 = bootstrap.sha256.get(&selector).ok_or_else(|| {
ForgeError::Config(format!(
"tool {} rustup bootstrap is missing a target platform digest: {selector}",
tool.name
))
})?;
let target = rustup_target(&plan.target)?;
let executable = work.join(if plan.target.os == "windows" {
"rustup-init.exe"
} else {
"rustup-init"
});
let download = ArchiveInstall {
url: bootstrap.url.replace("{target}", target).replace(
"{exe}",
if plan.target.os == "windows" {
".exe"
} else {
""
},
),
sha256: sha256.clone(),
format: ArchiveFormat::File,
target: executable.clone(),
strip_components: 0,
allow_links: false,
};
install_download(&download, plan.policy.network)?;
#[cfg(unix)]
{
let mut permissions = std::fs::metadata(&executable)
.map_err(|source| ForgeError::Io {
path: executable.clone(),
source,
})?
.permissions();
permissions.set_mode(0o700);
std::fs::set_permissions(&executable, permissions).map_err(|source| ForgeError::Io {
path: executable.clone(),
source,
})?;
}
let BackendOperation::Command(invocation) = rustup_bootstrap_operation(&executable) else {
unreachable!("rustup bootstrap is always a typed command")
};
run_invocation_labeled("rustup bootstrap", &invocation, display_mode, step)?;
refresh_rust_process_environment();
Ok(())
}
fn rustup_target(target: &TargetPlatform) -> Result<&'static str, ForgeError> {
match (
target.os.as_str(),
target.arch.as_str(),
target.abi.as_str(),
) {
("linux", "x86_64", "gnu") => Ok("x86_64-unknown-linux-gnu"),
("linux", "aarch64", "gnu") => Ok("aarch64-unknown-linux-gnu"),
("windows", "x86_64", "msvc") => Ok("x86_64-pc-windows-msvc"),
("windows", "aarch64", "msvc") => Ok("aarch64-pc-windows-msvc"),
("macos", "x86_64", "native") => Ok("x86_64-apple-darwin"),
("macos", "aarch64", "native") => Ok("aarch64-apple-darwin"),
_ => Err(ForgeError::Config(format!(
"rustup bootstrap does not support target platform: {}",
target.selector()
))),
}
}
fn rollback_typed_install(
specification: &InstallSpec,
display_mode: ShellDisplayMode,
step: Option<ShellStep>,
cause: &ForgeError,
) -> Result<(), ForgeError> {
let InstallSpec::Shell(shell) = specification else {
return Ok(());
};
let Some(rollback) = shell.rollback.as_deref() else {
return Ok(());
};
ui::stdout_status(
StatusKind::Warning,
&format!("Shell install failed; starting rollback: {cause}"),
);
run_shell_labeled_limits_display_step(
"Shell rollback",
rollback,
display_mode,
step,
shell.timeout_secs.map(std::time::Duration::from_secs),
shell
.inactivity_timeout_secs
.map(std::time::Duration::from_secs),
)?;
Ok(())
}
pub(crate) fn installation_verification_result(
tool: &ToolDef,
step: Option<ShellStep>,
) -> Result<ToolInstallResult, ForgeError> {
let status = check_tool(tool);
if !status.installed || status.outdated {
let message = if status.outdated {
format!(
"the install command completed, but the detected version {} is still older than {}",
status
.version
.as_deref()
.map(|version| ui::display_tool_version(&status.name, version))
.as_deref()
.unwrap_or("unknown"),
status
.required_version
.as_deref()
.map(|version| ui::display_tool_version(&status.name, version))
.as_deref()
.unwrap_or("configured")
)
} else {
"the install command completed, but post-install checks did not find the tool"
.to_string()
};
ui::stdout_status(
StatusKind::Error,
&format!("{} · verification failed: {message}", tool.name),
);
return Ok(tool_result(
tool,
ToolInstallOutcome::VerificationFailed,
&message,
));
}
let version = status
.version
.filter(|version| !version.trim().is_empty())
.unwrap_or_else(|| "installed".to_string());
let display_version = ui::display_tool_version(&tool.name, &version);
ui::stdout_status(
StatusKind::Success,
&step_completion_message(step, &tool.name, &display_version),
);
Ok(tool_result(tool, ToolInstallOutcome::Installed, &version))
}
fn tool_result(tool: &ToolDef, outcome: ToolInstallOutcome, message: &str) -> ToolInstallResult {
ToolInstallResult {
name: tool.name.clone(),
outcome,
message: Some(message.to_string()),
}
}
fn step_completion_message(step: Option<ShellStep>, name: &str, version: &str) -> String {
match step {
Some(step) => format!("{}/{} {name} · {version}", step.current, step.total),
None => format!("{name} · {version}"),
}
}
pub(crate) fn install_skill_item(
item: &SkillDef,
profile: &str,
update_source: bool,
network: NetworkPolicy,
) -> Result<Vec<RegistryEntry>, ForgeError> {
if item.agents.is_empty() {
return Err(ForgeError::Config(format!(
"skill {} must configure agents",
item.name
)));
}
let source_dir = prepare_source(
&item.source,
item.revision.as_deref(),
update_source,
network,
)?;
let candidates = discover(&source_dir)?;
if candidates.is_empty() {
return Err(ForgeError::Config(format!(
"skill not found in {}",
source_dir.display()
)));
}
let mut targets = Vec::new();
for skill in candidates {
validate_managed_name(&skill.name, "skill")?;
for agent in &item.agents {
let Some(agent_root) = agent_dir(*agent) else {
ui::stdout_status(
StatusKind::Warning,
&format!(
"{} skill directory not found; skipped {}.",
agent.as_str(),
skill.name
),
);
continue;
};
let target = agent_root.join(&skill.name);
copy_dir(&skill.path, &target)?;
targets.push(RegistryTarget {
path: target,
binary: None,
});
}
}
if targets.is_empty() {
return Err(ForgeError::Config(format!(
"skill {} has no writable agent target directory",
item.name
)));
}
let entry = RegistryEntry {
name: item.name.clone(),
kind: InstallKind::Skill,
source: item.source.clone(),
profile: profile.to_string(),
targets,
installed_at: now_secs(),
artifact_id: None,
previous_artifact_id: None,
config_hash: None,
plan_hash: None,
source_revision: source_revision(&source_dir),
backend: None,
};
append_registry(entry.clone())?;
Ok(vec![entry])
}
fn source_revision(source_dir: &Path) -> Option<String> {
run_shell_capture(&format!(
"git -C {} rev-parse HEAD",
shell_quote(source_dir)
))
.ok()
.map(first_line)
.filter(|revision| !revision.is_empty())
}
fn validate_managed_name(name: &str, kind: &str) -> Result<(), ForgeError> {
let path = Path::new(name);
let valid = !name.is_empty()
&& path.components().count() == 1
&& path.file_name().and_then(OsStr::to_str) == Some(name)
&& name != "."
&& name != "..";
if valid {
Ok(())
} else {
Err(ForgeError::Config(format!(
"invalid {kind} name; path traversal is not allowed: {name}"
)))
}
}
fn prepare_source(
source: &str,
revision: Option<&str>,
update: bool,
network: NetworkPolicy,
) -> Result<PathBuf, ForgeError> {
let path = PathBuf::from(source);
if path.exists() {
checkout_revision(&path, revision)?;
return Ok(path);
}
if !looks_like_git(source) {
return Err(ForgeError::Config(format!(
"source does not exist and is not a git URL: {source}"
)));
}
if run_shell_capture("git --version").is_err() {
return Err(ForgeError::Command(
"installing a git source requires git, but git was not found".to_string(),
));
}
let cache_path = app_home()
.join("sources")
.join(format!("{:016x}", fnv1a(source)));
if cache_path.exists() {
if update && revision.is_none() {
if network != NetworkPolicy::Online {
return Err(ForgeError::Config(
"a non-online network policy forbids updating a skill Git source".into(),
));
}
run_shell_labeled(
&source_name(source),
&format!("git -C {} pull --ff-only", shell_quote(&cache_path)),
)?;
}
checkout_revision(&cache_path, revision)?;
return Ok(cache_path);
}
if network != NetworkPolicy::Online {
return Err(ForgeError::Config(format!(
"the network policy forbids fetching an uncached skill source: {source}"
)));
}
if let Some(parent) = cache_path.parent() {
create_dir_all(parent)?;
}
run_shell_labeled(
&source_name(source),
&format!(
"git clone --depth 1 {} {}",
shell_quote_str(source),
shell_quote(&cache_path)
),
)?;
checkout_revision(&cache_path, revision)?;
Ok(cache_path)
}
fn checkout_revision(source_dir: &Path, revision: Option<&str>) -> Result<(), ForgeError> {
let Some(revision) = revision else {
return Ok(());
};
if revision.trim().is_empty() || revision.starts_with('-') {
return Err(ForgeError::Config(
"revision cannot be empty or start with '-'".to_string(),
));
}
run_shell_capture(&format!(
"git -C {} checkout --detach {}",
shell_quote(source_dir),
shell_quote_str(revision)
))?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::config::schema::{
CertificatePreflightDef, CheckSpec, InstallSpec, NetworkPolicy, NpmInstall, PipInstall,
ShellInstall, UvToolInstall,
};
use crate::execution::TEST_ENV_LOCK;
#[cfg(unix)]
use crate::execution::install::verify_typed_tool;
use crate::execution::install::{
SELECTION_MENU_FIXED_LINES, SelectionChoice, SelectionGraph, SelectionKind,
SelectionScreenGuard, SelectionTransition, ShellDisplayMode, ShellStep, check_tool,
first_selectable, fit_display_width, initialize_selection,
installation_verification_result, is_actionable_key_event, last_selectable,
next_selectable, previous_selectable, reduce_selection_key,
reduce_selection_key_with_graph, run_certificate_preflight, selectable_install_choices,
selection_from_choices, selection_pagination_line, selection_visible_count,
selection_window_by_rows, set_all_selectable, step_completion_message, toggle_selection,
tool_status_from_detection,
};
use crate::execution::policy::enforce_network_policy;
use crate::execution::verifier::check_tools_bounded;
use crate::model::{InstallKind, InstallPreview, ToolDef, ToolInstallOutcome, ToolStatus};
use crate::planning::{ExecutionPlan, PlanPolicy, TargetPlatform, UnsupportedComponent};
use crate::ui::{
RenderOptions, composite_detection_details, display_width, tool_presentation,
tool_selection_description,
};
use crossterm::event::{KeyCode, KeyEventKind, KeyModifiers};
#[test]
fn selection_ignores_key_release_events() {
assert!(is_actionable_key_event(KeyEventKind::Press));
assert!(!is_actionable_key_event(KeyEventKind::Repeat));
assert!(!is_actionable_key_event(KeyEventKind::Release));
}
#[test]
fn selection_screen_uses_an_isolated_terminal_buffer() {
let mut output = Vec::new();
let mut screen = SelectionScreenGuard::enter(&mut output).unwrap();
screen.restore(&mut output).unwrap();
let output = String::from_utf8(output).unwrap();
assert!(output.contains("\u{1b}[?1049h"));
assert!(output.contains("\u{1b}[?25l"));
assert!(output.contains("\u{1b}[?25h"));
assert!(output.contains("\u{1b}[?1049l"));
assert!(!output.contains("\u{1b}7"));
assert!(!output.contains("\u{1b}8"));
}
#[test]
fn bounded_tool_checks_preserve_configuration_order() {
let _guard = TEST_ENV_LOCK.lock().unwrap();
let tools = (0..12)
.map(|index| ToolDef {
name: format!("tool-{index}"),
detect: Some(CheckSpec::Shell {
command: "true".to_string(),
timeout_secs: Some(10),
}),
..ToolDef::default()
})
.collect::<Vec<_>>();
let statuses = check_tools_bounded(&tools).unwrap();
assert_eq!(
statuses
.into_iter()
.map(|status| status.name)
.collect::<Vec<_>>(),
tools.into_iter().map(|tool| tool.name).collect::<Vec<_>>()
);
}
#[test]
fn non_online_policy_rejects_networked_backends_only() {
let npm = InstallSpec::Npm(NpmInstall {
package: "demo".into(),
version: "1.0.0".into(),
source: None,
});
assert!(enforce_network_policy(NetworkPolicy::Offline, &npm).is_err());
let pip = InstallSpec::Pip(PipInstall {
package: "uv".into(),
version: "0.12.3".into(),
python: "python3".into(),
environment: "$BOT_FORGE_HOME/python-tools/uv".into(),
index: None,
});
assert!(enforce_network_policy(NetworkPolicy::Offline, &pip).is_err());
let uv = InstallSpec::UvTool(UvToolInstall {
package: "git+https://gitcode.com/xuanwu/project-brain.git".into(),
bins: vec!["project-brain".into()],
force: true,
index: None,
});
assert!(enforce_network_policy(NetworkPolicy::Offline, &uv).is_err());
let local_shell = InstallSpec::Shell(ShellInstall {
command: "local-operation".into(),
resources: vec!["disk-io".into()],
rollback: None,
timeout_secs: Some(1),
inactivity_timeout_secs: Some(1),
});
assert!(enforce_network_policy(NetworkPolicy::CacheOnly, &local_shell).is_ok());
}
#[test]
fn certificate_preflight_installs_once_and_then_detects_success() {
let root = std::env::temp_dir().join(format!(
"bot-forge-certificate-preflight-{}",
std::process::id()
));
let marker = root.join("company.crt");
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
let marker_text = marker.display().to_string();
let (detect_program, detect_args, install_command) = if cfg!(windows) {
(
"powershell".to_string(),
vec![
"-NoProfile".to_string(),
"-NonInteractive".to_string(),
"-Command".to_string(),
format!(
"if (Test-Path -LiteralPath '{}') {{ exit 0 }} else {{ exit 1 }}",
marker_text.replace('\'', "''")
),
],
format!("echo installed>>\"{marker_text}\""),
)
} else {
(
"test".to_string(),
vec!["-e".to_string(), marker_text.clone()],
format!(
"printf 'installed\\n' >> '{}'",
marker_text.replace('\'', "'\\''")
),
)
};
let check = CheckSpec::Command {
program: detect_program,
args: detect_args,
stdout_contains: None,
success_codes: vec![0],
timeout_secs: Some(10),
};
let install = InstallSpec::Shell(ShellInstall {
command: install_command,
resources: vec!["system-certificate-store".into()],
rollback: None,
timeout_secs: Some(30),
inactivity_timeout_secs: Some(10),
});
let plan = ExecutionPlan {
profile: "test".into(),
target: TargetPlatform::host(),
config_hash: "a".repeat(64),
plan_hash: "b".repeat(64),
policy: PlanPolicy {
network: NetworkPolicy::Online,
max_parallel: 1,
max_downloads: 1,
max_memory_mib: None,
},
certificate_preflight: Some(CertificatePreflightDef {
platforms: vec!["*".into()],
detect: check,
install: install.clone(),
verify: None,
}),
components: Vec::new(),
unsupported_components: Vec::new(),
nodes: Vec::new(),
environment: Vec::new(),
apt_mirror: None,
origins: Default::default(),
};
run_certificate_preflight(&plan, ShellDisplayMode::Plain).unwrap();
run_certificate_preflight(&plan, ShellDisplayMode::Plain).unwrap();
assert_eq!(std::fs::read_to_string(&marker).unwrap().lines().count(), 1);
std::fs::remove_dir_all(root).unwrap();
}
#[cfg(unix)]
#[test]
fn typed_verify_is_a_required_runtime_check() {
let _guard = TEST_ENV_LOCK.lock().unwrap();
let mut tool = ToolDef {
name: "verified-tool".into(),
verify: Some(CheckSpec::Command {
program: "sh".into(),
args: vec!["-c".into(), "exit 9".into()],
stdout_contains: None,
success_codes: vec![0],
timeout_secs: Some(2),
}),
..ToolDef::default()
};
assert!(verify_typed_tool(&tool).is_err());
tool.verify = Some(CheckSpec::Command {
program: "sh".into(),
args: vec!["-c".into(), "printf verified; exit 9".into()],
stdout_contains: None,
success_codes: vec![9],
timeout_secs: Some(2),
});
assert_eq!(
verify_typed_tool(&tool).unwrap().as_deref(),
Some("verified")
);
}
#[test]
fn component_names_do_not_override_typed_detection() {
let _guard = TEST_ENV_LOCK.lock().unwrap();
let present = ToolDef {
name: "rust-toolchain".into(),
detect: Some(CheckSpec::Path {
path: std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml"),
}),
..ToolDef::default()
};
let absent = ToolDef {
name: "msvc".into(),
detect: Some(CheckSpec::Path {
path: std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("does-not-exist"),
}),
..ToolDef::default()
};
assert!(check_tool(&present).installed);
assert!(!check_tool(&absent).installed);
}
#[test]
fn selection_key_reducer_covers_actions_and_locked_items() {
let mut choices = vec![
SelectionChoice {
kind: SelectionKind::Tool("locked".to_string()),
label: "locked".to_string(),
description: "installed".to_string(),
details: Vec::new(),
selected: false,
selectable: false,
},
SelectionChoice {
kind: SelectionKind::Tool("open".to_string()),
label: "open".to_string(),
description: "tool".to_string(),
details: Vec::new(),
selected: false,
selectable: true,
},
];
assert_eq!(
reduce_selection_key(&mut choices, 0, KeyCode::Down, KeyModifiers::NONE),
SelectionTransition::Redraw(1)
);
reduce_selection_key(&mut choices, 0, KeyCode::Char(' '), KeyModifiers::NONE);
assert!(!choices[0].selected);
reduce_selection_key(&mut choices, 1, KeyCode::Char('a'), KeyModifiers::NONE);
assert!(!choices[0].selected);
assert!(choices[1].selected);
assert_eq!(
reduce_selection_key(&mut choices, 1, KeyCode::Enter, KeyModifiers::NONE),
SelectionTransition::Confirm
);
assert_eq!(
reduce_selection_key(&mut choices, 1, KeyCode::Esc, KeyModifiers::NONE),
SelectionTransition::Back
);
assert_eq!(
reduce_selection_key(&mut choices, 1, KeyCode::Char('c'), KeyModifiers::CONTROL,),
SelectionTransition::Interrupt
);
}
#[test]
fn selection_keyboard_table_covers_navigation_and_outcomes() {
let cases = [
(KeyCode::Up, KeyModifiers::NONE, "redraw"),
(KeyCode::Down, KeyModifiers::NONE, "redraw"),
(KeyCode::Left, KeyModifiers::NONE, "redraw"),
(KeyCode::Right, KeyModifiers::NONE, "redraw"),
(KeyCode::Tab, KeyModifiers::NONE, "redraw"),
(KeyCode::Home, KeyModifiers::NONE, "redraw"),
(KeyCode::End, KeyModifiers::NONE, "redraw"),
(KeyCode::Char(' '), KeyModifiers::NONE, "redraw"),
(KeyCode::Char('a'), KeyModifiers::NONE, "redraw"),
(KeyCode::Char('n'), KeyModifiers::NONE, "redraw"),
(KeyCode::Enter, KeyModifiers::NONE, "confirm"),
(KeyCode::Esc, KeyModifiers::NONE, "back"),
(KeyCode::Char('b'), KeyModifiers::NONE, "back"),
(KeyCode::Char('q'), KeyModifiers::NONE, "exit"),
(KeyCode::Char('c'), KeyModifiers::CONTROL, "interrupt"),
(KeyCode::F(1), KeyModifiers::NONE, "ignored"),
];
for (code, modifiers, expected) in cases {
let mut choices = vec![
SelectionChoice {
kind: SelectionKind::Tool("locked".into()),
label: "locked".into(),
description: "installed".into(),
details: Vec::new(),
selected: false,
selectable: false,
},
SelectionChoice {
kind: SelectionKind::Tool("open".into()),
label: "open".into(),
description: "tool".into(),
details: Vec::new(),
selected: true,
selectable: true,
},
];
let actual = match reduce_selection_key(&mut choices, 1, code, modifiers) {
SelectionTransition::Redraw(_) => "redraw",
SelectionTransition::Confirm => "confirm",
SelectionTransition::Back => "back",
SelectionTransition::Exit => "exit",
SelectionTransition::Interrupt => "interrupt",
SelectionTransition::Ignored => "ignored",
};
assert_eq!(actual, expected, "code={code:?}");
assert!(!choices[0].selected, "locked choice changed for {code:?}");
}
let mut empty = Vec::new();
assert!(matches!(
reduce_selection_key(&mut empty, 0, KeyCode::Down, KeyModifiers::NONE),
SelectionTransition::Redraw(0)
));
let all_locked = vec![SelectionChoice {
kind: SelectionKind::Tool("locked".into()),
label: "locked".into(),
description: "installed".into(),
details: Vec::new(),
selected: false,
selectable: false,
}];
assert_eq!(next_selectable(&all_locked, 0), 0);
assert_eq!(previous_selectable(&all_locked, 0), 0);
}
#[test]
fn selection_window_counts_composite_detail_rows() {
let choices = (0..5)
.map(|index| SelectionChoice {
kind: SelectionKind::Tool(format!("tool-{index}")),
label: format!("tool-{index}"),
description: "installed".into(),
details: if index == 2 {
vec!["detail-a".into(), "detail-b".into()]
} else {
Vec::new()
},
selected: false,
selectable: false,
})
.collect::<Vec<_>>();
assert_eq!(selection_window_by_rows(2, &choices, 5), (0, 3));
assert_eq!(selection_window_by_rows(4, &choices, 5), (2, 5));
}
#[test]
fn selectable_navigation_wraps_and_skips_locked_items() {
let choices = vec![
SelectionChoice {
kind: SelectionKind::Tool("locked-a".to_string()),
label: String::new(),
description: "installed".to_string(),
details: Vec::new(),
selected: false,
selectable: false,
},
SelectionChoice {
kind: SelectionKind::Tool("open".to_string()),
label: String::new(),
description: "tool".to_string(),
details: Vec::new(),
selected: true,
selectable: true,
},
SelectionChoice {
kind: SelectionKind::Tool("locked-b".to_string()),
label: String::new(),
description: "unsupported".to_string(),
details: Vec::new(),
selected: false,
selectable: false,
},
];
assert_eq!(first_selectable(&choices), Some(1));
assert_eq!(last_selectable(&choices), Some(1));
assert_eq!(next_selectable(&choices, 1), 1);
assert_eq!(previous_selectable(&choices, 1), 1);
}
#[test]
fn selection_menu_uses_only_the_available_terminal_rows() {
let terminal_height = 24;
assert_eq!(selection_visible_count(terminal_height), 20);
assert_eq!(
u16::try_from(selection_visible_count(terminal_height)).unwrap()
+ SELECTION_MENU_FIXED_LINES,
terminal_height
);
}
#[test]
fn selection_pagination_is_hidden_when_every_item_is_visible() {
let options = RenderOptions::no_color();
assert_eq!(selection_pagination_line(0, 0, &options), "");
assert_eq!(
selection_pagination_line(3, 7, &options),
" ↑ 3 more • ↓ 7 more"
);
assert!(selection_visible_count(24) >= 19);
}
#[test]
fn reports_tool_completion_with_step_and_version() {
assert_eq!(
step_completion_message(
Some(ShellStep {
current: 2,
total: 5,
}),
"cargo-geiger",
"cargo-geiger 0.13.0",
),
"2/5 cargo-geiger · cargo-geiger 0.13.0"
);
assert_eq!(
step_completion_message(None, "ninja", "1.13.2"),
"ninja · 1.13.2"
);
}
#[test]
fn menu_width_helpers_treat_cjk_as_wide() {
assert_eq!(display_width("工具:rust"), 10);
let fitted = fit_display_width("工具:rust-toolchain-extra-long-name", 12);
assert!(display_width(&fitted) <= 12);
assert!(fitted.ends_with("..."));
}
#[test]
fn selection_menu_lists_installed_items_as_disabled() {
let preview = InstallPreview {
tools: vec![
ToolStatus {
name: "installed-tool".to_string(),
display_name: None,
optional: false,
installed: true,
version: Some("installed-tool 1.0.0".to_string()),
required_version: None,
outdated: false,
installable: true,
},
ToolStatus {
name: "missing-tool".to_string(),
display_name: None,
optional: false,
installed: false,
version: None,
required_version: None,
outdated: false,
installable: true,
},
],
skills: Vec::new(),
};
let choices = selectable_install_choices(&preview, &[]);
assert_eq!(choices.len(), 3);
assert!(matches!(choices[0].kind, SelectionKind::Heading));
assert!(!choices[1].selectable);
assert!(!choices[1].selected);
assert_eq!(choices[1].label, "installed-tool");
assert_eq!(
choices[1].description,
format!("{:<48} Development tool", "installed (1.0.0)")
);
assert!(choices[2].selectable);
assert!(choices[2].selected);
let selection = selection_from_choices(&choices);
assert!(selection.includes_tool("missing-tool"));
assert!(!selection.includes_tool("installed-tool"));
}
#[test]
fn unsupported_platform_components_are_visible_but_never_selectable() {
let preview = InstallPreview {
tools: Vec::new(),
skills: Vec::new(),
};
let choices = selectable_install_choices(
&preview,
&[UnsupportedComponent {
id: "linux-only".into(),
display_name: Some("Linux helper".into()),
kind: InstallKind::Tool,
optional: false,
}],
);
let choice = choices
.iter()
.find(|choice| matches!(&choice.kind, SelectionKind::Tool(id) if id == "linux-only"))
.expect("unsupported component should be displayed");
assert!(!choice.selectable);
assert!(!choice.selected);
assert_eq!(choice.description, "unsupported on this platform");
assert!(!selection_from_choices(&choices).includes_tool("linux-only"));
}
#[test]
fn core_toolchain_components_cannot_be_cleared() {
let mut choices = vec![SelectionChoice {
kind: SelectionKind::Tool("rust-toolchain".into()),
label: "rust-toolchain".into(),
description: "required".into(),
details: Vec::new(),
selected: true,
selectable: true,
}];
set_all_selectable(&mut choices, false);
assert!(choices[0].selected);
assert_eq!(first_selectable(&choices), None);
assert_eq!(next_selectable(&choices, 0), 0);
toggle_selection(&mut choices, 0, None);
assert!(choices[0].selected);
}
#[test]
fn deselecting_a_dependency_cascades_to_all_selected_dependents() {
let mut choices = ["node", "npm-tool", "npm-addon"]
.into_iter()
.map(|name| SelectionChoice {
kind: SelectionKind::Tool(name.into()),
label: name.into(),
description: String::new(),
details: Vec::new(),
selected: true,
selectable: true,
})
.collect::<Vec<_>>();
let graph = SelectionGraph {
dependencies: std::collections::BTreeMap::from([
("npm-tool".into(), vec!["node".into()]),
("npm-addon".into(), vec!["npm-tool".into()]),
]),
dependents: std::collections::BTreeMap::from([
("node".into(), vec!["npm-tool".into()]),
("npm-tool".into(), vec!["npm-addon".into()]),
]),
};
reduce_selection_key_with_graph(
&mut choices,
0,
KeyCode::Char(' '),
KeyModifiers::NONE,
Some(&graph),
);
assert!(choices.iter().all(|choice| !choice.selected));
}
#[test]
fn selecting_a_dependent_restores_its_selectable_dependencies() {
let mut choices = ["node", "npm-tool"]
.into_iter()
.map(|name| SelectionChoice {
kind: SelectionKind::Tool(name.into()),
label: name.into(),
description: String::new(),
details: Vec::new(),
selected: false,
selectable: true,
})
.collect::<Vec<_>>();
let graph = SelectionGraph {
dependencies: std::collections::BTreeMap::from([(
"npm-tool".into(),
vec!["node".into()],
)]),
dependents: std::collections::BTreeMap::new(),
};
reduce_selection_key_with_graph(
&mut choices,
1,
KeyCode::Char(' '),
KeyModifiers::NONE,
Some(&graph),
);
assert!(choices.iter().all(|choice| choice.selected));
}
#[test]
fn declined_outdated_roots_do_not_leave_orphan_dependencies_selected() {
let mut choices = vec![
SelectionChoice {
kind: SelectionKind::Tool("runtime".into()),
label: "runtime".into(),
description: String::new(),
details: Vec::new(),
selected: true,
selectable: true,
},
SelectionChoice {
kind: SelectionKind::Tool("outdated-tool".into()),
label: "outdated-tool".into(),
description: String::new(),
details: Vec::new(),
selected: false,
selectable: false,
},
];
let graph = SelectionGraph {
dependencies: std::collections::BTreeMap::from([(
"outdated-tool".into(),
vec!["runtime".into()],
)]),
dependents: std::collections::BTreeMap::from([(
"runtime".into(),
vec!["outdated-tool".into()],
)]),
};
initialize_selection(
&mut choices,
&graph,
&std::collections::BTreeSet::from(["outdated-tool".into()]),
);
assert!(!choices[0].selected);
}
#[test]
fn initializing_selection_keeps_a_direct_component_selected_without_dependencies() {
let mut choices = vec![SelectionChoice {
kind: SelectionKind::Tool("terminal-test".into()),
label: "terminal-test".into(),
description: String::new(),
details: Vec::new(),
selected: true,
selectable: true,
}];
let graph = SelectionGraph::default();
initialize_selection(
&mut choices,
&graph,
&std::collections::BTreeSet::from(["terminal-test".into()]),
);
assert!(choices[0].selected);
}
#[test]
fn detected_versions_older_than_the_offered_version_are_marked_outdated() {
let tool = ToolDef {
name: "nodejs".into(),
version: Some("22.22.0".into()),
install: Some(InstallSpec::Npm(NpmInstall {
package: "placeholder".into(),
version: "1.0.0".into(),
source: None,
})),
..ToolDef::default()
};
let old = tool_status_from_detection(&tool, Ok("v20.19.4".into()));
let current = tool_status_from_detection(&tool, Ok("node v22.22.0".into()));
assert!(old.installed && old.outdated);
assert_eq!(old.required_version.as_deref(), Some("22.22.0"));
assert!(current.installed && !current.outdated);
}
#[test]
fn post_install_verification_rejects_a_version_that_remains_outdated() {
let tool = ToolDef {
name: "outdated-after-install".into(),
version: Some("2.0.0".into()),
detect: Some(CheckSpec::Shell {
command: "echo 1.0.0".into(),
timeout_secs: Some(2),
}),
..ToolDef::default()
};
let result = installation_verification_result(&tool, None).unwrap();
assert_eq!(result.outcome, ToolInstallOutcome::VerificationFailed);
assert!(
result
.message
.as_deref()
.is_some_and(|message| message.contains("still older"))
);
}
#[test]
fn tool_presentation_has_stable_categories_order_and_short_descriptions() {
assert_eq!(tool_presentation("rust-toolchain").0, 1);
assert_eq!(tool_presentation("rust-bot").0, 2);
assert_eq!(tool_presentation("rust-bot").1, "Core toolchain");
assert_eq!(tool_presentation("rust-bot").2, "RustBot skills management");
assert_eq!(tool_presentation("git").0, 10);
assert_eq!(
tool_presentation("llvm-tools-preview").1,
"Daily development"
);
assert_eq!(tool_presentation("cargo-expand").0, 20);
assert_eq!(tool_presentation("bot-metric").1, "Quality checks");
assert_eq!(tool_presentation("bot-metric").2, "Code quality metrics");
assert_eq!(tool_presentation("miri").0, 31);
assert_eq!(tool_presentation("tsx").0, 42);
assert_eq!(tool_presentation("openspec").0, 43);
assert_eq!(tool_presentation("openspec").1, "Developer automation");
assert_eq!(tool_presentation("uv").1, "Developer automation");
assert_eq!(tool_presentation("project-brain").1, "Developer automation");
assert_eq!(tool_presentation("cargo-nextest").1, "Quality checks");
assert_eq!(
tool_presentation("cargo-nextest").2,
"Fast Rust test runner"
);
assert_eq!(tool_presentation("unknown").1, "Other tools");
for name in [
"rust-build-base",
"rust-toolchain",
"rust-bot",
"git",
"cmake",
"ninja",
"python",
"llvm-toolchain",
"bindgen-cli",
"rust-analyzer",
"cargo-expand",
"cargo-nextest",
"cargo-audit",
"cargo-deny",
"cargo-geiger",
"llvm-tools-preview",
"cargo-llvm-cov",
"bot-gate",
"bot-metric",
"bot-compass",
"rust-src",
"miri",
"cargo-fuzz",
"valgrind",
"cargo-valgrind",
"nodejs",
"gitnexus",
"tsx",
"openspec",
"uv",
"project-brain",
] {
let description = tool_presentation(name).2;
assert!(description.split_whitespace().count() <= 7);
assert!(
description
.chars()
.next()
.is_some_and(|character| character.is_uppercase())
);
}
}
#[test]
fn rustup_component_list_output_is_not_presented_as_a_version() {
let status = ToolStatus {
name: "rust-src".into(),
display_name: None,
optional: false,
installed: true,
version: Some("cargo-aarch64-apple-darwin".into()),
required_version: None,
outdated: false,
installable: true,
};
assert_eq!(tool_selection_description(&status), "installed");
}
#[test]
fn installed_rustup_component_checks_have_no_fake_version() {
let tool = ToolDef {
name: "llvm-tools-preview".into(),
display_name: None,
version: None,
optional: false,
allow_insecure_hosts: Vec::new(),
detect: Some(CheckSpec::Command {
program: "rustup".into(),
args: vec![
"component".into(),
"list".into(),
"--installed".into(),
"--toolchain".into(),
"1.90.0".into(),
],
stdout_contains: Some("llvm-tools".into()),
success_codes: vec![0],
timeout_secs: Some(10),
}),
install: None,
verify: None,
};
let status = tool_status_from_detection(
&tool,
Ok("llvm-tools-x86_64-unknown-linux-gnu (installed)".into()),
);
assert!(status.installed);
assert_eq!(status.version, None);
}
#[test]
fn composite_detection_uses_two_detail_lines_in_the_selection_menu() {
let details = composite_detection_details(
"rustup 1.29.0 (2026-03-05); cargo 1.96.1 (356927216 2026-06-26); rustfmt 1.9.0-stable (31fca3adb2 2026-06-26); clippy 0.1.96 (31fca3adb2 2026-06-26)",
);
assert_eq!(
details,
[
"rustup 1.29.0 · cargo 1.96.1",
"rustfmt 1.9.0-stable · clippy 0.1.96"
]
);
}
}