use std::path::{Path, PathBuf};
use std::process::Command as StdCommand;
use clap::{Parser, Subcommand};
use shun::config::ShunConfig;
use shun::payload::pack_directory;
use shun::targets::flash::{FlashTarget, LogicalDrives};
#[derive(Parser)]
#[command(
name = "shun",
version,
about = "Flow-driven payload delivery — build tool for installers and flashers"
)]
struct Cli {
#[command(subcommand)]
command: CliCommand,
}
#[derive(Subcommand)]
enum CliCommand {
Pack {
payload_dir: PathBuf,
#[arg(short, long)]
out: Option<PathBuf>,
},
Config {
#[arg(long)]
path: PathBuf,
},
Icons {
#[arg(long)]
logo: PathBuf,
#[arg(long, default_value = "icons")]
out: PathBuf,
},
Sign {
#[arg(required = true)]
files: Vec<PathBuf>,
#[arg(long)]
manifest: PathBuf,
},
Build {
#[arg(long)]
manifest: PathBuf,
#[arg(long)]
shell_src: Option<PathBuf>,
#[arg(long, default_value = "dist")]
out: PathBuf,
#[arg(long)]
no_sign: bool,
},
Msix {
#[arg(long)]
manifest: PathBuf,
#[arg(long)]
out: Option<PathBuf>,
#[arg(long)]
logo: Option<PathBuf>,
#[arg(long)]
no_sign: bool,
},
FlashList {},
Stage {
#[arg(long)]
manifest: PathBuf,
#[arg(long, default_value = "release")]
profile: String,
#[arg(long)]
no_build: bool,
},
}
fn main() {
let cli = Cli::parse();
if let Err(err) = run(cli.command) {
eprintln!("error: {err}");
std::process::exit(1);
}
}
fn run(command: CliCommand) -> Result<(), String> {
match command {
CliCommand::Pack { payload_dir, out } => {
let archive = pack_directory(&payload_dir).map_err(|e| e.to_string())?;
let out = out.unwrap_or_else(|| {
let name = payload_dir
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "payload".to_string());
payload_dir.join(format!("{name}.shun"))
});
std::fs::write(&out, &archive).map_err(|e| e.to_string())?;
println!(
"packed {} ({} bytes) -> {}",
payload_dir.display(),
archive.len(),
out.display()
);
Ok(())
}
CliCommand::Config { path } => {
let config = resolve_config(&path)?;
let json = serde_json::to_vec_pretty(&config).map_err(|e| e.to_string())?;
println!("{}", String::from_utf8_lossy(&json));
Ok(())
}
CliCommand::Icons { logo, out } => {
icons::generate(&logo, &out).map_err(|e| e.to_string())?;
println!("icon sets written under {}", out.display());
Ok(())
}
CliCommand::Sign { files, manifest } => {
let config = resolve_config(&manifest)?;
let signing = config.signing.clone().unwrap_or_default();
sign::sign_files(&files, &signing).map_err(|e| e.to_string())?;
println!("signed {} file(s)", files.len());
Ok(())
}
CliCommand::Build {
manifest,
shell_src,
out,
no_sign,
} => {
let config = resolve_config(&manifest)?;
let product = config.product.clone();
let payload_dir = manifest.parent().unwrap_or(Path::new(".")).join(
config
.payload
.clone()
.unwrap_or_else(|| PathBuf::from("payload")),
);
let archive = pack_directory(&payload_dir).map_err(|e| e.to_string())?;
let shell_src = shell_src.unwrap_or_else(|| PathBuf::from("shell"));
let shell_manifest = shell_src.join("Cargo.toml");
let shell_package: toml::Value =
toml::from_str(&std::fs::read_to_string(&shell_manifest).map_err(|e| {
format!(
"cannot read shell manifest {}: {e}",
shell_manifest.display()
)
})?)
.map_err(|e| format!("shell manifest parse: {e}"))?;
let shell_bin = shell_package
.get("package")
.and_then(|p| p.get("name"))
.and_then(toml::Value::as_str)
.unwrap_or("shun-demo-shell")
.to_string();
let status = StdCommand::new("cargo")
.args([
"build",
"--release",
"--manifest-path",
&shell_manifest.display().to_string(),
])
.env(
"SHUN_MANIFEST",
std::fs::canonicalize(&manifest).map_err(|e| e.to_string())?,
)
.status()
.map_err(|e| format!("cargo build failed: {e}"))?;
if !status.success() {
return Err(format!("shell build failed (exit {status:?})"));
}
std::fs::create_dir_all(&out).map_err(|e| e.to_string())?;
let target_exe = shell_src
.join("..")
.join("target")
.join("release")
.join(format!("{shell_bin}.exe"));
let installer = out.join(format!(
"{}-{}-setup.exe",
product.name.to_lowercase().replace(' ', "-"),
product.version
));
std::fs::copy(&target_exe, &installer).map_err(|e| {
format!(
"copy installer failed (expected {}): {e}",
target_exe.display()
)
})?;
let package = out.join(format!(
"{}.shun",
product.name.to_lowercase().replace(' ', "-")
));
std::fs::write(&package, &archive).map_err(|e| e.to_string())?;
if !no_sign {
let signing = config.signing.clone().unwrap_or_default();
if let Some(windows) = &signing.windows {
if windows.enabled {
sign::sign_files(std::slice::from_ref(&installer), &signing)
.map_err(|e| e.to_string())?;
println!("signed {}", installer.display());
}
}
}
println!("build complete:");
println!(" installer package: {}", package.display());
println!(" installer binary: {}", installer.display());
Ok(())
}
CliCommand::Msix {
manifest,
out,
logo,
no_sign,
} => {
let config = resolve_config(&manifest)?;
let msix = config.msix.clone().ok_or_else(|| {
"the manifest declares no [msix] table — add identity-name, publisher and display-name to the delivery manifest"
.to_string()
})?;
let payload_dir = manifest.parent().unwrap_or(Path::new(".")).join(
config
.payload
.clone()
.unwrap_or_else(|| PathBuf::from("payload")),
);
let logo_png = logo
.as_deref()
.map(|path| logo_png_bytes(path, msix.logo_background.as_deref()))
.transpose()?;
let base = out.unwrap_or_else(|| PathBuf::from("dist"));
std::fs::create_dir_all(&base).map_err(|e| e.to_string())?;
let out_msix = base.join(format!(
"{}-{}-x64.msix",
msix.identity_name.to_lowercase().replace(' ', "-"),
config.product.version
));
let inputs = shun::msix::MsixInputs {
identity_name: &msix.identity_name,
publisher: &msix.publisher,
display_name: &msix.display_name,
description: msix.description.as_deref().unwrap_or(""),
version: &config.product.version,
executable: msix.executable.as_deref().ok_or(
"the [msix] table declares no executable — add the payload-relative \
entry point",
)?,
logo_png: logo_png.as_deref(),
logo_background: msix.logo_background.as_deref(),
};
shun::msix::build_msix(&payload_dir, &out_msix, &inputs).map_err(|e| e.to_string())?;
if !no_sign {
let signing = config.signing.clone().unwrap_or_default();
if let Some(windows) = &signing.windows {
if windows.enabled {
sign::sign_files(std::slice::from_ref(&out_msix), &signing)
.map_err(|e| e.to_string())?;
println!("signed {}", out_msix.display());
}
}
}
println!("msix ready: {}", out_msix.display());
Ok(())
}
CliCommand::FlashList {} => {
let devices = LogicalDrives.list_devices().map_err(|e| e.to_string())?;
if devices.is_empty() {
println!("no removable drives found");
return Ok(());
}
println!("{:<14} {:>14} LABEL", "ID", "SIZE (B)");
for device in devices {
println!("{:<14} {:>14} {}", device.id, device.size, device.label);
}
Ok(())
}
CliCommand::Stage {
manifest,
profile,
no_build,
} => {
stage_payload(&manifest, &profile, no_build).map_err(|e| e.to_string())?;
Ok(())
}
}
}
fn stage_payload(manifest: &Path, profile: &str, no_build: bool) -> Result<(), std::io::Error> {
let manifest_dir = manifest
.parent()
.map(std::path::Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let raw = std::fs::read_to_string(manifest)?;
let document: toml::Value =
toml::from_str(&raw).map_err(|e| std::io::Error::other(format!("manifest parse: {e}")))?;
let package = document
.get("package")
.and_then(|p| p.get("name"))
.and_then(toml::Value::as_str)
.ok_or_else(|| std::io::Error::other("manifest declares no [package] name"))?
.to_string();
let bin = document
.get("bin")
.and_then(|bins| bins.as_array())
.and_then(|bins| bins.first())
.and_then(|bin| bin.get("name"))
.and_then(toml::Value::as_str)
.unwrap_or(&package)
.to_string();
let config = resolve_config(manifest)
.map_err(|e| std::io::Error::other(format!("delivery manifest: {e}")))?;
let main_exe = config
.targets
.iter()
.find_map(|t| match t {
shun::config::TargetConfig::Install(install) => install.main_exe.clone(),
_ => None,
})
.or_else(|| {
config
.payload
.as_ref()
.map(|_| PathBuf::from(format_bin(&bin)))
})
.ok_or_else(|| {
std::io::Error::other("manifest declares no main-exe and no install target")
})?;
let payload_dir = config
.payload
.as_ref()
.map(|payload| manifest_dir.join(payload))
.ok_or_else(|| std::io::Error::other("manifest declares no payload directory"))?;
let destination = payload_dir.join(&main_exe);
if !no_build {
let status = StdCommand::new("cargo")
.args(["build", "--manifest-path"])
.arg(manifest)
.arg("--profile")
.arg(profile)
.status()?;
if !status.success() {
return Err(std::io::Error::other(format!(
"cargo build failed (exit {status:?})"
)));
}
}
let metadata = StdCommand::new("cargo")
.args([
"metadata",
"--no-deps",
"--format-version",
"1",
"--manifest-path",
])
.arg(manifest)
.output()?;
if !metadata.status.success() {
return Err(std::io::Error::other("cargo metadata failed"));
}
let metadata: serde_json::Value = serde_json::from_slice(&metadata.stdout)
.map_err(|e| std::io::Error::other(format!("cargo metadata parse: {e}")))?;
let target_dir = PathBuf::from(
metadata
.get("target_directory")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| std::io::Error::other("cargo metadata has no target_directory"))?,
);
let out_dir_name = if profile == "dev" { "debug" } else { profile };
let built_bin = target_dir.join(out_dir_name).join(format_bin(&bin));
if !built_bin.is_file() {
return Err(std::io::Error::other(format!(
"built binary not found at {} — run without --no-build",
built_bin.display()
)));
}
if let Some(parent) = destination.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::copy(&built_bin, &destination)?;
println!(
"staged {} -> {}",
built_bin.display(),
destination.display()
);
for entry in std::fs::read_dir(built_bin.parent().unwrap_or(&target_dir))? {
let entry = entry?;
let path = entry.path();
let is_dll = path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("dll"));
if is_dll {
let to = destination
.parent()
.unwrap_or(&payload_dir)
.join(entry.file_name());
std::fs::copy(&path, &to)?;
println!("staged sidecar {} -> {}", path.display(), to.display());
}
}
Ok(())
}
#[cfg(windows)]
fn format_bin(bin: &str) -> String {
format!("{bin}.exe")
}
#[cfg(not(windows))]
fn format_bin(bin: &str) -> String {
bin.to_string()
}
fn logo_png_bytes(logo: &Path, background: Option<&str>) -> Result<Vec<u8>, String> {
let img = image::open(logo)
.map_err(|e| format!("cannot open logo: {e}"))?
.into_rgba8();
let img = match background.map(parse_hex_color).transpose()? {
Some([r, g, b]) => {
let mut plate = image::RgbaImage::from_pixel(
img.width(),
img.height(),
image::Rgba([r, g, b, u8::MAX]),
);
image::imageops::overlay(&mut plate, &img, 0, 0);
plate
}
None => img,
};
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png)
.map_err(|e| format!("logo png encode: {e}"))?;
Ok(png)
}
fn parse_hex_color(hex: &str) -> Result<[u8; 3], String> {
let hex = hex.trim().trim_start_matches('#');
if hex.len() != 6 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) {
return Err(format!(
"invalid logo background {hex:?}: expected #RRGGBB hex color"
));
}
let mut channels = [0u8; 3];
for (slot, pair) in channels.iter_mut().zip(hex.as_bytes().chunks_exact(2)) {
let pair = std::str::from_utf8(pair).map_err(|e| e.to_string())?;
*slot = u8::from_str_radix(pair, 16).map_err(|e| e.to_string())?;
}
Ok(channels)
}
fn resolve_config(path: &Path) -> Result<ShunConfig, String> {
if path.file_name().and_then(|n| n.to_str()) == Some("Cargo.toml") {
ShunConfig::from_cargo_manifest(path)
} else {
ShunConfig::from_path(path)
}
.map_err(|e| e.to_string())
}
mod icons {
use std::path::Path;
use image::imageops::FilterType;
const SIZES: [[u32; 2]; 8] = [
[16, 16],
[24, 24],
[32, 32],
[48, 48],
[64, 64],
[128, 128],
[256, 256],
[512, 512],
];
pub fn generate(logo: &Path, out: &Path) -> Result<(), String> {
let img = image::open(logo).map_err(|e| format!("cannot open logo: {e}"))?;
let img = img.into_rgba8();
let largest = SIZES.iter().map(|s| s[0]).max().unwrap_or(512);
let img = if img.width() != largest || img.height() != largest {
image::imageops::resize(&img, largest, largest, FilterType::Lanczos3)
} else {
img
};
let windows_dir = out.join("windows");
std::fs::create_dir_all(&windows_dir).map_err(|e| e.to_string())?;
let ico = image::DynamicImage::ImageRgba8(img.clone());
ico.save_with_format(windows_dir.join("icon.ico"), image::ImageFormat::Ico)
.map_err(|e| format!("ico encode: {e}"))?;
let linux_dir = out.join("linux");
std::fs::create_dir_all(&linux_dir).map_err(|e| e.to_string())?;
for [w, h] in SIZES {
let scaled = image::imageops::resize(&img, w, h, FilterType::Lanczos3);
scaled
.save(linux_dir.join(format!("icon_{w}x{h}.png")))
.map_err(|e| format!("png encode: {e}"))?;
}
let macos_dir = out.join("macos").join("shun.iconset");
std::fs::create_dir_all(&macos_dir).map_err(|e| e.to_string())?;
for [w, h] in SIZES {
let scaled = image::imageops::resize(&img, w, h, FilterType::Lanczos3);
scaled
.save(macos_dir.join(format!("icon_{w}x{h}.png")))
.map_err(|e| format!("png encode: {e}"))?;
let (w2, h2) = (w * 2, h * 2);
let scaled2 = image::imageops::resize(&img, w2, h2, FilterType::Lanczos3);
scaled2
.save(macos_dir.join(format!("icon_{w}x{h}@2x.png")))
.map_err(|e| format!("png encode: {e}"))?;
}
Ok(())
}
}
mod sign {
#[cfg(windows)]
use std::path::Path;
use std::path::PathBuf;
#[cfg(windows)]
use super::StdCommand;
use shun::config::SigningConfig;
pub fn sign_files(files: &[PathBuf], signing: &SigningConfig) -> Result<(), String> {
#[cfg(windows)]
{
let Some(windows) = &signing.windows else {
return Err("no windows signing profile in the delivery manifest".into());
};
if !windows.enabled {
return Err("windows signing is disabled in the manifest".into());
}
let signtool = find_signtool().ok_or_else(|| {
"signtool.exe not found (install the Windows SDK or add it to PATH)".to_string()
})?;
for file in files {
let mut cmd = StdCommand::new(&signtool);
cmd.arg("sign").arg("/fd").arg("SHA256");
cmd.arg("/tr").arg(&windows.timestamp_url);
cmd.arg("/td").arg("SHA256");
if let Some(thumbprint) = &windows.thumbprint {
cmd.arg("/sha1").arg(thumbprint);
} else {
cmd.arg("/a");
}
cmd.arg(file);
let status = cmd
.status()
.map_err(|e| format!("cannot run signtool: {e}"))?;
if !status.success() {
return Err(format!("signtool failed for {}", file.display()));
}
println!("signed {}", file.display());
}
Ok(())
}
#[cfg(not(windows))]
{
let _ = (files, signing);
Err("signing is only implemented for windows in this build".into())
}
}
#[cfg(windows)]
fn find_signtool() -> Option<PathBuf> {
if let Ok(path_var) = std::env::var("PATH") {
for dir in std::env::split_paths(&path_var) {
let candidate = dir.join("signtool.exe");
if candidate.is_file() {
return Some(candidate);
}
}
}
let kits_root = std::env::var("ProgramFiles(x86)")
.map(|root| Path::new(&root).join("Windows Kits").join("10").join("bin"))
.ok()?;
let mut versions: Vec<PathBuf> = std::fs::read_dir(&kits_root)
.ok()?
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.is_dir())
.collect();
versions.sort();
for version in versions.iter().rev() {
let candidate = version.join("x64").join("signtool.exe");
if candidate.is_file() {
return Some(candidate);
}
}
None
}
}