use std::{path::PathBuf, time::Duration};
use anyhow::{Result, bail};
use rsclaw_cli::ToolsCommand;
fn use_color() -> bool {
std::env::var("NO_COLOR").is_err()
}
fn green(s: &str) -> String {
if use_color() {
format!("\x1b[32m{s}\x1b[0m")
} else {
s.to_owned()
}
}
fn yellow(s: &str) -> String {
if use_color() {
format!("\x1b[33m{s}\x1b[0m")
} else {
s.to_owned()
}
}
fn red(s: &str) -> String {
if use_color() {
format!("\x1b[31m{s}\x1b[0m")
} else {
s.to_owned()
}
}
fn bold(s: &str) -> String {
if use_color() {
format!("\x1b[1m{s}\x1b[0m")
} else {
s.to_owned()
}
}
fn dim(s: &str) -> String {
if use_color() {
format!("\x1b[2m{s}\x1b[0m")
} else {
s.to_owned()
}
}
fn cyan(s: &str) -> String {
if use_color() {
format!("\x1b[36m{s}\x1b[0m")
} else {
s.to_owned()
}
}
fn banner(title: &str) {
let arch = std::env::consts::ARCH;
let os = std::env::consts::OS;
let c = use_color();
let r = if c { "\x1b[31m" } else { "" }; let b = if c { "\x1b[1m" } else { "" }; let d = if c { "\x1b[2m" } else { "" }; let n = if c { "\x1b[0m" } else { "" };
println!(
r#"
{r}. .{n}
{r} \ /{n} {b} ____ ____ ____ _ _ __ __{n}
{r}( )---( ){n} {b}| _ \/ ___| / ___| | / \ \ \ / /{n}
{r} / . . \{n} {b}| |_) \___ \| | | | / _ \ \ \ /\ / /{n}
{r}| \___/ |{n} {b}| _ < ___) | |___| |___ / ___ \ \ V V /{n}
{r} \_____/{n} {b}|_| \_\____/ \____|_____/_/ \_\ \_/\_/{n}
{r}/ \{n}
{r}( ){n} {d}-- {title} --{n}
"#
);
println!(" {d}[{n} {b}core{n} {d}]{n}");
println!(" {d}>{n} engine: {title} {d}(Rust 2024 Edition){n}");
println!(" {d}>{n} platform: {arch} on {os}");
println!(" {d}>{n} compat: OpenClaw drop-in replacement");
println!();
println!(" {d}{}{n}", "-".repeat(56));
println!();
}
fn ok(msg: &str) {
println!(" {} {msg}", green("[ok]"));
}
fn warn_msg(msg: &str) {
println!(" {} {msg}", yellow("[warn]"));
}
fn err_msg(msg: &str) {
println!(" {} {msg}", red("[error]"));
}
const MANIFEST_URL: &str = "https://hub.rsclaw.ai/tools/manifest.json";
const META_URL: &str = "https://hub.rsclaw.ai/meta.json";
struct ToolDef {
name: &'static str,
display: &'static str,
detect_cmd: &'static [&'static str],
local_bin: &'static str, optional: bool,
}
const TOOLS: &[ToolDef] = &[
ToolDef {
name: "chrome",
display: "Chrome for Testing (browser automation)",
detect_cmd: &["google-chrome", "chromium", "chromium-browser", "chrome"],
local_bin: "chrome",
optional: false,
},
ToolDef {
name: "ffmpeg",
display: "ffmpeg (audio/video processing)",
detect_cmd: &["ffmpeg"],
local_bin: "ffmpeg",
optional: false,
},
ToolDef {
name: "node",
display: "Node.js (plugin runtime)",
detect_cmd: &["node"],
local_bin: "node",
optional: false,
},
ToolDef {
name: "bun",
display: "Bun (fast JS plugin runtime; node alternative)",
detect_cmd: &["bun"],
local_bin: "bun",
optional: true,
},
ToolDef {
name: "python",
display: "Python 3 (skill/plugin runtime)",
detect_cmd: &["python3", "python"],
local_bin: "python",
optional: false,
},
ToolDef {
name: "sherpa-onnx",
display: "sherpa-onnx (STT + TTS engine)",
detect_cmd: &[
"sherpa-onnx-offline-tts",
"sherpa-onnx-offline",
"sherpa-onnx",
],
local_bin: "sherpa-onnx",
optional: false,
},
ToolDef {
name: "opencode",
display: "OpenCode (AI coding agent)",
detect_cmd: &["opencode"],
local_bin: "opencode",
optional: false,
},
ToolDef {
name: "claude-code",
display: "Claude Code (AI coding agent, optional)",
detect_cmd: &["claude"],
local_bin: "claude-code",
optional: true,
},
ToolDef {
name: "openclaude",
display: "OpenClaude (Claude-compatible coding agent, optional)",
detect_cmd: &["openclaude"],
local_bin: "openclaude",
optional: true,
},
ToolDef {
name: "codex",
display: "Codex (OpenAI coding agent, optional)",
detect_cmd: &["codex"],
local_bin: "codex",
optional: true,
},
ToolDef {
name: "aider",
display: "Aider (AI pair-programming coding agent, optional)",
detect_cmd: &["aider"],
local_bin: "aider",
optional: true,
},
ToolDef {
name: "qoder",
display: "Qoder CLI (AI coding agent, optional)",
detect_cmd: &["qodercli", "qoder"],
local_bin: "qoder",
optional: true,
},
];
fn tools_dir() -> PathBuf {
rsclaw_config::loader::base_dir().join("tools")
}
fn manifest_cache_path(tools_dir: &std::path::Path) -> PathBuf {
tools_dir.join(".manifest.json")
}
fn write_manifest_cache(tools_dir: &std::path::Path, manifest: &serde_json::Value) -> Result<()> {
std::fs::create_dir_all(tools_dir)?;
std::fs::write(
manifest_cache_path(tools_dir),
serde_json::to_vec_pretty(manifest)?,
)?;
Ok(())
}
fn load_manifest_cache(tools_dir: &std::path::Path) -> Option<serde_json::Value> {
let bytes = std::fs::read(manifest_cache_path(tools_dir)).ok()?;
serde_json::from_slice(&bytes).ok()
}
fn write_version_marker(tools_dir: &std::path::Path, subdir: &str, version: &str) -> Result<()> {
let dir = tools_dir.join(subdir);
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join(".version"), version.trim().as_bytes())?;
Ok(())
}
fn installed_version(tools_dir: &std::path::Path, subdir: &str) -> Option<String> {
let s = std::fs::read_to_string(tools_dir.join(subdir).join(".version")).ok()?;
let s = s.trim();
if s.is_empty() {
None
} else {
Some(s.to_owned())
}
}
fn manifest_tool_version(manifest: &serde_json::Value, tool: &str) -> Option<String> {
manifest
.get("tools")?
.get(tool)?
.get("version")?
.as_str()
.map(|s| s.to_owned())
}
pub fn available_tools(cache: Option<&serde_json::Value>) -> Vec<String> {
let mut names: Vec<String> = TOOLS.iter().map(|d| d.name.to_owned()).collect();
if let Some(obj) = cache
.and_then(|v| v.get("tools"))
.and_then(|v| v.as_object())
{
names.extend(obj.keys().cloned());
}
names.sort();
names.dedup();
names
}
pub fn installed_tools(tools_dir: &std::path::Path) -> Vec<(String, Option<String>)> {
let mut out: Vec<(String, Option<String>)> = TOOLS
.iter()
.filter(|d| local_tool_binary_exists(tools_dir, d))
.map(|d| (d.name.to_owned(), installed_version(tools_dir, d.local_bin)))
.collect();
out.sort_by(|a, b| a.0.cmp(&b.0));
out
}
#[derive(serde::Serialize)]
pub struct ToolCatalogEntry {
pub name: String,
pub description: String,
pub version: String,
pub installed: bool,
pub installed_version: Option<String>,
}
pub fn tools_catalog() -> Vec<ToolCatalogEntry> {
let dir = tools_dir();
let cache = load_manifest_cache(&dir);
let installed = installed_tools(&dir);
available_tools(cache.as_ref())
.into_iter()
.map(|name| {
let description = TOOLS
.iter()
.find(|d| d.name == name)
.map(|d| d.display.to_owned())
.unwrap_or_default();
let version = cache
.as_ref()
.and_then(|c| c.pointer(&format!("/tools/{name}/version")))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_owned();
let inst = installed.iter().find(|(n, _)| n == &name);
ToolCatalogEntry {
installed: inst.is_some(),
installed_version: inst.and_then(|(_, v)| v.clone()),
name,
description,
version,
}
})
.collect()
}
pub async fn ensure_manifest_cached() {
fetch_manifest_if_missing().await;
}
fn should_fetch_manifest(tools_dir: &std::path::Path) -> bool {
!manifest_cache_path(tools_dir).exists()
}
pub async fn fetch_manifest_if_missing() {
let dir = tools_dir();
if !should_fetch_manifest(&dir) {
return;
}
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => c,
Err(_) => return,
};
match client.get(MANIFEST_URL).send().await {
Ok(resp) if resp.status().is_success() => {
if let Ok(m) = resp.json::<serde_json::Value>().await {
let _ = write_manifest_cache(&dir, &m);
}
}
_ => { }
}
}
pub fn tools_dir_pub() -> PathBuf {
tools_dir()
}
pub fn sync_tool_shims() {
#[cfg(unix)]
sync_tool_shims_unix();
}
#[cfg(unix)]
fn sync_tool_shims_unix() {
let tools_dir = tools_dir();
let bin_dir = tools_dir.join("bin");
if std::fs::create_dir_all(&bin_dir).is_err() {
return;
}
let mut shimmed: std::collections::HashSet<String> = std::collections::HashSet::new();
for def in TOOLS {
let sub = tools_dir.join(def.local_bin);
if !sub.is_dir() {
continue;
}
for name in def.detect_cmd {
let mut candidates: Vec<PathBuf> = Vec::new();
if def.name == "claude-code" {
let subpkg = native_claude_subpkg();
if !subpkg.is_empty() {
candidates.push(
sub.join("node_modules")
.join("@anthropic-ai")
.join(format!("claude-code-{subpkg}"))
.join(name),
);
}
}
if def.name == "qoder" {
let subpkg = native_claude_subpkg();
if !subpkg.is_empty() {
candidates.push(
sub.join("node_modules")
.join("@qoder-ai")
.join(format!("qodercli-{subpkg}"))
.join(name),
);
}
}
candidates.push(sub.join(name));
candidates.push(sub.join("bin").join(name));
let Some(target) = candidates.into_iter().find(|p| is_executable_file(p)) else {
continue;
};
link_shim(&bin_dir, name, &target);
shimmed.insert((*name).to_owned());
}
}
for name in CODING_AGENT_NAMES {
if shimmed.contains(*name) {
continue;
}
if let Some(target) = resolve_command(name, &bin_dir) {
link_shim(&bin_dir, name, &target);
} else {
let _ = std::fs::remove_file(bin_dir.join(name));
}
}
}
#[cfg(unix)]
const CODING_AGENT_NAMES: &[&str] = &["opencode", "claude", "openclaude", "codex", "aider", "qodercli", "qoder"];
#[cfg(unix)]
fn native_claude_subpkg() -> &'static str {
if cfg!(all(target_os = "macos", target_arch = "aarch64")) {
"darwin-arm64"
} else if cfg!(all(target_os = "macos", target_arch = "x86_64")) {
"darwin-x64"
} else if cfg!(all(target_os = "linux", target_arch = "aarch64")) {
"linux-arm64"
} else if cfg!(all(target_os = "linux", target_arch = "x86_64")) {
"linux-x64"
} else {
""
}
}
#[cfg(unix)]
fn link_shim(bin_dir: &std::path::Path, name: &str, target: &std::path::Path) {
let link = bin_dir.join(name);
if link == target {
return;
}
let _ = std::fs::remove_file(&link); let _ = std::os::unix::fs::symlink(target, &link);
}
#[cfg(unix)]
fn resolve_command(name: &str, shim_dir: &std::path::Path) -> Option<PathBuf> {
let mut dirs: Vec<PathBuf> = std::env::var_os("PATH")
.map(|p| std::env::split_paths(&p).collect())
.unwrap_or_default();
if let Some(home) = dirs_next::home_dir() {
for rel in [".opencode/bin", ".local/bin", ".bun/bin", ".cargo/bin", "bin"] {
dirs.push(home.join(rel));
}
}
for d in ["/usr/local/bin", "/opt/homebrew/bin", "/opt/homebrew/sbin"] {
dirs.push(PathBuf::from(d));
}
for d in dirs {
if d == shim_dir {
continue;
}
let cand = d.join(name);
if is_executable_file(&cand) {
return Some(cand);
}
}
None
}
#[cfg(unix)]
fn is_executable_file(p: &std::path::Path) -> bool {
use std::os::unix::fs::PermissionsExt;
std::fs::metadata(p)
.map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
pub fn load_manifest_cache_pub(tools_dir: &std::path::Path) -> Option<serde_json::Value> {
load_manifest_cache(tools_dir)
}
fn is_tool_in_path(def: &ToolDef) -> bool {
for cmd in def.detect_cmd {
if which::which(cmd).is_ok() {
return true;
}
}
false
}
fn is_tool_installed_locally(def: &ToolDef) -> bool {
local_tool_binary_exists(&tools_dir(), def)
}
fn local_tool_binary_exists(tools_dir: &std::path::Path, def: &ToolDef) -> bool {
local_tool_binary_probes(tools_dir, def)
.iter()
.any(|p| p.is_file())
}
fn local_tool_binary_probes(tools_dir: &std::path::Path, def: &ToolDef) -> Vec<PathBuf> {
let dir = tools_dir.join(def.local_bin);
let mut probes = Vec::new();
for cmd in def.detect_cmd {
probes.push(dir.join(cmd));
probes.push(dir.join("bin").join(cmd));
probes.push(dir.join("node_modules").join(".bin").join(cmd));
#[cfg(target_os = "windows")]
{
probes.push(dir.join(format!("{cmd}.exe")));
probes.push(dir.join("bin").join(format!("{cmd}.exe")));
probes.push(dir.join(format!("{cmd}.cmd")));
probes.push(dir.join("bin").join(format!("{cmd}.cmd")));
probes.push(
dir.join("node_modules")
.join(".bin")
.join(format!("{cmd}.cmd")),
);
}
}
if def.name == "chrome" {
#[cfg(target_os = "macos")]
{
probes.extend([
dir.join("Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"),
dir.join("Chromium.app/Contents/MacOS/Chromium"),
dir.join("Google Chrome.app/Contents/MacOS/Google Chrome"),
]);
}
#[cfg(target_os = "windows")]
{
probes.extend([
dir.join("chrome.exe"),
dir.join("Google Chrome for Testing.exe"),
]);
}
}
probes
}
fn tool_status(def: &ToolDef) -> &'static str {
if is_tool_installed_locally(def) {
"installed"
} else if is_tool_in_path(def) {
"system"
} else {
"missing"
}
}
pub fn tools_summary_line() -> String {
TOOLS
.iter()
.map(|def| {
let status = tool_status(def);
let icon = match (status, def.optional) {
("missing", true) => "·",
("missing", false) => "✗",
_ => "✓",
};
format!("{} {}", def.name, icon)
})
.collect::<Vec<_>>()
.join(" ")
}
pub fn tools_count() -> (usize, usize) {
let required = TOOLS.iter().filter(|d| !d.optional);
let total = required.clone().count();
let available = required.filter(|d| tool_status(d) != "missing").count();
(available, total)
}
pub fn tools_missing() -> Vec<&'static str> {
TOOLS
.iter()
.filter(|d| !d.optional && tool_status(d) == "missing")
.map(|d| d.name)
.collect()
}
pub async fn cmd_tools(sub: ToolsCommand) -> Result<()> {
match sub {
ToolsCommand::List => {
cmd_list();
Ok(())
}
ToolsCommand::Status => {
cmd_status();
Ok(())
}
ToolsCommand::Install { name, force } => cmd_install(&name, force).await,
}
}
fn cmd_list() {
banner(&format!(
"rsclaw tools v{}",
option_env!("RSCLAW_BUILD_VERSION").unwrap_or("dev")
));
println!();
let dir = tools_dir();
let mut found = false;
for def in TOOLS {
let local_dir = dir.join(def.local_bin);
if local_dir.exists() {
println!(" {} {}", green("✓"), bold(def.name));
println!(" {}", dim(&local_dir.display().to_string()));
found = true;
}
}
if !found {
warn_msg("no tools installed locally");
println!();
println!(" Run: rsclaw tools install <name>");
println!(" Available: chrome, ffmpeg, node, python, opencode, claude-code, all");
}
}
fn cmd_status() {
banner(&format!(
"rsclaw tools v{}",
option_env!("RSCLAW_BUILD_VERSION").unwrap_or("dev")
));
println!();
for def in TOOLS {
let status = tool_status(def);
let (icon, label) = match (status, def.optional) {
("system", _) => (green("✓"), green("system PATH")),
("installed", _) => (green("✓"), cyan("~/.rsclaw/tools")),
("missing", true) => (dim("·"), dim("not installed (optional)")),
_ => (red("✗"), red("not found")),
};
println!(
" {} {:<14} {} {}",
icon,
bold(def.name),
label,
dim(def.display)
);
}
let missing: Vec<&str> = tools_missing();
if !missing.is_empty() {
println!();
println!(
" Install missing tools: {} or download from {}",
bold("rsclaw tools install <name>"),
cyan("https://gitfast.io"),
);
}
}
fn find_node_binary(tools_dir: &std::path::Path) -> Option<String> {
let local = tools_dir.join("node").join("bin").join("node");
if local.exists() {
return Some(local.to_string_lossy().to_string());
}
let local_win = tools_dir.join("node").join("node.exe");
if local_win.exists() {
return Some(local_win.to_string_lossy().to_string());
}
which::which("node")
.ok()
.map(|p| p.to_string_lossy().to_string())
}
fn resolve_tool_name(name: &str) -> &str {
match name {
"chromium" | "chromium-browser" | "google-chrome" => "chrome",
"python3" => "python",
"nodejs" | "node.js" => "node",
"open-code" | "opencode-cli" => "opencode",
"claude" | "claude-agent" | "claudecode" => "claude-code",
"qodercli" | "qoder-cli" => "qoder",
_ => name,
}
}
pub async fn cmd_install(name: &str, force: bool) -> Result<()> {
let name = resolve_tool_name(name);
let names: Vec<&str> = if name == "all" {
TOOLS.iter().map(|d| d.name).collect()
} else {
if !TOOLS.iter().any(|d| d.name == name) {
bail!(
"Unknown tool: {name}. Available: {}",
TOOLS.iter().map(|d| d.name).collect::<Vec<_>>().join(", ")
);
}
vec![name]
};
println!("Fetching tool manifest from {} ...", dim(MANIFEST_URL));
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()?;
let manifest_txt = match client.get(MANIFEST_URL).send().await {
Ok(resp) if resp.status().is_success() => resp.text().await?,
Ok(resp) => bail!("manifest fetch failed: HTTP {}", resp.status()),
Err(e) => {
err_msg(&format!("Cannot reach hub: {e}"));
println!();
println!(
" Please download manually from: {}",
bold("https://gitfast.io")
);
println!(
" Then extract to: {}",
bold(&tools_dir().display().to_string())
);
bail!("Cannot reach hub: {e}");
}
};
{
let meta_txt = client
.get(META_URL)
.send()
.await
.and_then(|r| r.error_for_status())
.map_err(|e| anyhow::anyhow!("fetch signed meta.json: {e}"))?
.text()
.await?;
let (_, _, tools_sha) = rsclaw_skill::sig::verify_signed_meta(&meta_txt)?;
use sha2::{Digest, Sha256};
let got = format!("{:x}", Sha256::digest(manifest_txt.as_bytes()));
if tools_sha.is_empty() || got != tools_sha {
bail!("tools manifest sha256 != signed meta.sha256.tools — refusing");
}
}
let manifest: serde_json::Value = serde_json::from_str(&manifest_txt)
.map_err(|e| anyhow::anyhow!("parse tools manifest: {e}"))?;
let dir = tools_dir();
std::fs::create_dir_all(&dir)?;
let _ = write_manifest_cache(&dir, &manifest);
let platform = detect_platform();
println!("Platform: {}", bold(platform));
println!();
for tool_name in &names {
let def = TOOLS.iter().find(|d| d.name == *tool_name).unwrap();
if !force && is_tool_in_path(def) {
println!(
" {} {} {}",
green("✓"),
bold(def.name),
dim("(already in system PATH, skipping)")
);
continue;
}
if !force && is_tool_installed_locally(def) {
println!(
" {} {} {}",
green("✓"),
bold(def.name),
dim("(already installed, skipping)")
);
continue;
}
let npm_package = match *tool_name {
"claude-code" => Some("@anthropic-ai/claude-code"),
"qoder" => Some("@qoder-ai/qodercli"),
"codex" => Some("@openai/codex"),
_ => None,
};
if let Some(pkg) = npm_package {
let dest_dir = dir.join(def.local_bin);
std::fs::create_dir_all(&dest_dir)?;
println!(" Installing {} via npm ...", bold(def.name));
let node_bin = find_node_binary(&dir);
let npm_basename = if cfg!(target_os = "windows") {
"npm.cmd"
} else {
"npm"
};
let npm_bin = node_bin
.as_deref()
.map(|n| {
let p = std::path::Path::new(n)
.parent()
.unwrap_or(std::path::Path::new(""));
p.join(npm_basename).to_string_lossy().to_string()
})
.unwrap_or_else(|| npm_basename.to_owned());
#[allow(unused_mut)]
let mut npm_cmd = std::process::Command::new(&npm_bin);
npm_cmd.args(["install", "--prefix", &dest_dir.to_string_lossy(), pkg]);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
npm_cmd.creation_flags(0x08000000);
}
let status = npm_cmd.status();
match status {
Ok(s) if s.success() => {
if !is_tool_installed_locally(def) {
bail!(
"{} install completed but no runnable binary was found in {}",
def.name,
dest_dir.display()
);
}
if let Some(v) = manifest_tool_version(&manifest, def.name) {
let _ = write_version_marker(&dir, def.local_bin, &v);
}
ok(&format!("{} installed to {}", def.name, dest_dir.display()))
}
Ok(s) => bail!("{}: npm install exited with {s}", def.name),
Err(e) => {
bail!(
"{}: npm not found ({e}). Install node first: rsclaw tools install node",
def.name
);
}
}
continue;
}
let download_url = resolve_download_url(&manifest, tool_name, platform);
let Some(url) = download_url else {
bail!(
"{}: no download available for platform {platform}. Download from https://gitfast.io",
def.name
);
};
println!(" Installing {} ...", bold(def.name));
println!(" {}", dim(&url));
let dest_dir = dir.join(def.local_bin);
std::fs::create_dir_all(&dest_dir)?;
let expected_sha = tool_platform(&manifest, def.name, platform)
.and_then(|p| p.get("sha256"))
.and_then(|v| v.as_str());
match download_and_extract(&client, &url, &dest_dir, expected_sha).await {
Ok(()) => {
if !is_tool_installed_locally(def) {
bail!(
"{} install completed but no runnable binary was found in {}",
def.name,
dest_dir.display()
);
}
if let Some(v) = manifest_tool_version(&manifest, def.name) {
let _ = write_version_marker(&dir, def.local_bin, &v);
}
ok(&format!("{} installed to {}", def.name, dest_dir.display()));
}
Err(e) => {
err_msg(&format!("{}: {e}", def.name));
println!(" Download manually from: {}", bold("https://gitfast.io"));
bail!("{}: {e}", def.name);
}
}
}
sync_tool_shims();
Ok(())
}
fn detect_platform() -> &'static str {
let os = std::env::consts::OS;
let arch = std::env::consts::ARCH;
match (os, arch) {
("linux", "x86_64") => "linux-x64",
("linux", "aarch64") => "linux-arm64",
("macos", "x86_64") => "mac-x64",
("macos", "aarch64") => "mac-arm64",
("windows", "x86_64") => "win-x64",
_ => "unknown",
}
}
fn tool_platform<'a>(
manifest: &'a serde_json::Value,
tool: &str,
platform: &str,
) -> Option<&'a serde_json::Value> {
manifest
.get("tools")?
.get(tool)?
.get("platforms")?
.get(platform)
}
fn resolve_download_url(
manifest: &serde_json::Value,
tool: &str,
platform: &str,
) -> Option<String> {
tool_platform(manifest, tool, platform)?
.get("url")?
.as_str()
.map(str::to_owned)
}
fn sha256_file_hex(path: &std::path::Path) -> Result<String> {
use sha2::{Digest, Sha256};
let data = std::fs::read(path)?;
Ok(format!("{:x}", Sha256::digest(&data)))
}
pub async fn download_and_extract_public(
client: &reqwest::Client,
url: &str,
dest: &std::path::Path,
) -> Result<()> {
download_and_extract(client, url, dest, None).await
}
async fn download_and_extract(
client: &reqwest::Client,
url: &str,
dest: &std::path::Path,
expected_sha256: Option<&str>,
) -> Result<()> {
let tmp_dir = tempfile::tempdir()?;
let filename = url.rsplit('/').next().unwrap_or("download");
let tmp_path = tmp_dir.path().join(filename);
download_resumable(client, url, &tmp_path, filename).await?;
if let Some(exp) = expected_sha256 {
let got = sha256_file_hex(&tmp_path)?;
if !got.eq_ignore_ascii_case(exp) {
bail!("sha256 mismatch for {filename}: expected {exp}, got {got} — refusing");
}
}
if url.ends_with(".zip") {
extract_zip(&tmp_path, dest)?;
} else if url.ends_with(".tar.xz") {
extract_tar_xz(&tmp_path, dest)?;
} else if url.ends_with(".tar.gz") || url.ends_with(".tgz") {
extract_tar_gz(&tmp_path, dest)?;
} else if url.ends_with(".tar.bz2") {
extract_tar_bz2(&tmp_path, dest)?;
} else {
std::fs::rename(&tmp_path, dest.join(filename))?;
}
Ok(())
}
pub async fn download_resumable(
client: &reqwest::Client,
url: &str,
out_path: &std::path::Path,
label: &str,
) -> Result<u64> {
use futures::StreamExt;
use indicatif::{ProgressBar, ProgressStyle};
use tokio::io::AsyncWriteExt;
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let meta_path = out_path.with_extension(format!(
"{}meta",
out_path
.extension()
.and_then(|e| e.to_str())
.map(|e| format!("{e}."))
.unwrap_or_default()
));
let mut already = std::fs::metadata(out_path).map(|m| m.len()).unwrap_or(0);
let stashed: Option<(u64, String)> = if already > 0 {
std::fs::read_to_string(&meta_path)
.ok()
.and_then(|s| {
s.split_once('\t')
.map(|(a, b)| (a.to_owned(), b.to_owned()))
})
.and_then(|(len_s, tok)| len_s.parse::<u64>().ok().map(|n| (n, tok)))
} else {
None
};
if already > 0 && stashed.is_none() {
let _ = std::fs::remove_file(out_path);
let _ = std::fs::remove_file(&meta_path);
already = 0;
}
let mut req = client.get(url).timeout(Duration::from_secs(600));
if let Some((_stashed_len, ref token)) = stashed {
req = req
.header(reqwest::header::RANGE, format!("bytes={already}-"))
.header(reqwest::header::IF_RANGE, token.as_str());
}
let resp = req.send().await?;
let status = resp.status();
let resume = match status.as_u16() {
206 => true,
200 => false, 416 => {
let _ = std::fs::remove_file(&meta_path);
return Ok(already);
}
_ => {
anyhow::bail!(
"download {url} failed: HTTP {status} {}",
status.canonical_reason().unwrap_or("")
);
}
};
let total_size = if resume {
resp.headers()
.get(reqwest::header::CONTENT_RANGE)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.rsplit('/').next())
.and_then(|s| s.parse::<u64>().ok())
.or_else(|| resp.content_length().map(|n| already + n))
} else {
resp.content_length()
};
let version_token: Option<String> = resp
.headers()
.get(reqwest::header::ETAG)
.or_else(|| resp.headers().get(reqwest::header::LAST_MODIFIED))
.or_else(|| resp.headers().get(reqwest::header::DATE))
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let restart_due_to_size_mismatch = resume
&& match (stashed.as_ref().map(|(n, _)| *n), total_size) {
(Some(stash_total), Some(now_total)) => stash_total != now_total,
_ => false,
};
let restart_due_to_local_overshoot = resume
&& match total_size {
Some(now_total) => already > now_total,
None => false,
};
let resume = resume && !restart_due_to_size_mismatch && !restart_due_to_local_overshoot;
if restart_due_to_size_mismatch {
tracing::warn!(
url,
"download_resumable: server total size changed since previous start; restarting from byte 0"
);
}
if restart_due_to_local_overshoot {
tracing::warn!(
url,
already,
?total_size,
"download_resumable: local partial larger than upstream total; restarting from byte 0"
);
}
let draw_target = if std::io::IsTerminal::is_terminal(&std::io::stderr()) {
indicatif::ProgressDrawTarget::stderr()
} else {
indicatif::ProgressDrawTarget::hidden()
};
let bar = if let Some(total) = total_size {
let bar = ProgressBar::with_draw_target(Some(total), draw_target);
bar.set_style(
ProgressStyle::with_template(
" {prefix:>12} [{bar:30.cyan/blue}] {bytes:>10}/{total_bytes:>10} {bytes_per_sec:>10} ETA {eta:>5}",
)
.unwrap_or_else(|_| ProgressStyle::default_bar())
.progress_chars("=> "),
);
bar.set_prefix(label.to_owned());
if resume {
bar.set_position(already);
}
bar
} else {
let bar = ProgressBar::with_draw_target(None, draw_target);
bar.set_prefix(label.to_owned());
bar
};
let mut file = if resume {
tokio::fs::OpenOptions::new()
.append(true)
.open(out_path)
.await?
} else {
let _ = std::fs::remove_file(out_path);
let _ = std::fs::remove_file(&meta_path);
tokio::fs::File::create(out_path).await?
};
if let (Some(total), Some(token)) = (total_size, version_token.as_ref()) {
let _ = std::fs::write(&meta_path, format!("{total}\t{token}"));
}
let mut written = if resume { already } else { 0 };
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
file.write_all(&chunk).await?;
written += chunk.len() as u64;
bar.set_position(written);
}
file.flush().await?;
bar.finish_and_clear();
let _ = std::fs::remove_file(&meta_path);
Ok(written)
}
pub fn extract_zip_public(archive_path: &std::path::Path, dest: &std::path::Path) -> Result<()> {
extract_zip(archive_path, dest)
}
fn extract_zip(archive_path: &std::path::Path, dest: &std::path::Path) -> Result<()> {
let file = std::fs::File::open(archive_path)?;
let mut archive = zip::ZipArchive::new(file)?;
for i in 0..archive.len() {
let mut file = archive.by_index(i)?;
let Some(name) = file.enclosed_name().map(|n| n.to_owned()) else {
continue;
};
let components: Vec<_> = name.components().collect();
let rel_path = if components.len() > 1 {
components[1..].iter().collect::<PathBuf>()
} else {
name.clone()
};
let out_path = dest.join(&rel_path);
if file.is_dir() {
std::fs::create_dir_all(&out_path)?;
} else {
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut out_file = std::fs::File::create(&out_path)?;
std::io::copy(&mut file, &mut out_file)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Some(mode) = file.unix_mode() {
std::fs::set_permissions(&out_path, std::fs::Permissions::from_mode(mode))?;
}
}
}
}
Ok(())
}
fn extract_tar_xz(archive_path: &std::path::Path, dest: &std::path::Path) -> Result<()> {
let file = std::fs::File::open(archive_path)?;
let buf = std::io::BufReader::new(file);
let xz_reader = xz2::read::XzDecoder::new(buf);
extract_tar(xz_reader, dest)
}
fn extract_tar_gz(archive_path: &std::path::Path, dest: &std::path::Path) -> Result<()> {
let file = std::fs::File::open(archive_path)?;
let buf = std::io::BufReader::new(file);
let gz_reader = flate2::read::GzDecoder::new(buf);
extract_tar(gz_reader, dest)
}
fn extract_tar_bz2(archive_path: &std::path::Path, dest: &std::path::Path) -> Result<()> {
let file = std::fs::File::open(archive_path)?;
let buf = std::io::BufReader::new(file);
let bz2_reader = bzip2::read::BzDecoder::new(buf);
extract_tar(bz2_reader, dest)
}
fn extract_tar<R: std::io::Read>(reader: R, dest: &std::path::Path) -> Result<()> {
let mut archive = tar::Archive::new(reader);
for entry in archive.entries()? {
let mut entry = entry?;
let path = entry.path()?.to_owned();
let components: Vec<_> = path.components().collect();
let rel_path = if components.len() > 1 {
components[1..].iter().collect::<PathBuf>()
} else {
path.to_path_buf()
};
if rel_path.as_os_str().is_empty() {
continue;
}
let out_path = dest.join(&rel_path);
if entry.header().entry_type().is_dir() {
std::fs::create_dir_all(&out_path)?;
} else {
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent)?;
}
entry.unpack(&out_path)?;
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn manifest_cache_round_trip() {
let tmp = tempfile::tempdir().unwrap();
let m = json!({"bun": {"version": "1.3.14"}});
write_manifest_cache(tmp.path(), &m).unwrap();
assert_eq!(load_manifest_cache(tmp.path()), Some(m));
}
#[test]
fn manifest_cache_missing_is_none() {
let tmp = tempfile::tempdir().unwrap();
assert_eq!(load_manifest_cache(tmp.path()), None);
}
#[test]
fn manifest_cache_corrupt_is_none() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join(".manifest.json"), b"{ not json").unwrap();
assert_eq!(load_manifest_cache(tmp.path()), None);
}
#[test]
fn local_install_requires_runnable_binary_not_empty_dir() {
let tmp = tempfile::tempdir().unwrap();
let bun = TOOLS.iter().find(|d| d.name == "bun").unwrap();
std::fs::create_dir_all(tmp.path().join("bun")).unwrap();
assert!(!local_tool_binary_exists(tmp.path(), bun));
let bin = tmp.path().join("bun").join("bin").join("bun");
std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
std::fs::write(&bin, b"").unwrap();
assert!(local_tool_binary_exists(tmp.path(), bun));
}
}