Merge branch 'cli-enh' into 'master'

Some improvement as a standalone CLI

See merge request redox-os/installer!67
This commit is contained in:
Jeremy Soller
2026-01-30 20:28:01 -07:00
8 changed files with 205 additions and 104 deletions
-1
View File
@@ -21,5 +21,4 @@ cargo-test:
- apt update && apt install -y fuse3 libfuse3-dev
- cargo build --locked
- cargo test
- fallocate -l 256MiB test.bin
- ./target/debug/redox_installer -c res/test.toml test.bin --no-mount
+29 -8
View File
@@ -10,22 +10,44 @@ use arg_parser::ArgParser;
use redox_installer::{Config, PackageConfig};
const HELP_STR: &str = r#"
redox_installer - Redox Installer.
Refer to link below for filesystem config reference:
https://doc.redox-os.org/book/configuration-settings.html
Using redox_installer as an installer:
redox_installer <diskpath.img> [--config=file.toml] [--write-bootloader=file.img] [--live] [--no-mount] [--skip-partition]
<diskpath.img> Disk file to write
--config Path to filesystem config TOML
--write-bootloader Path to write separate EFI partition
--skip-partition Skip writing GPT partition tables
Use this only if you plan to use other partition tool
--live Use bootloader configured for live disk
--no-mount Use RedoxFS AR instead of FUSE to write files
--cookbook Use local Redox OS build system rather than downloading packages
Using redox_installer as a configuration parser:
redox_installer --config=file.toml [--list-packages|--filesystem-size|--output-config path]
--list-packages List packages will be installed
--filesystem-size Output filesystem size in MB
--output-config Path to write the parsed config as another TOML
"#;
fn main() {
let mut parser = ArgParser::new(4)
.add_opt("b", "cookbook")
.add_opt("c", "config")
.add_opt("o", "output-config")
.add_opt("", "write-bootloader")
.add_flag(&["skip-partition"])
.add_flag(&["filesystem-size"])
.add_flag(&["r", "repo-binary"])
.add_flag(&["r", "repo-binary"]) // TODO: Remove
.add_flag(&["l", "list-packages"])
.add_flag(&["live"])
.add_flag(&["no-mount"]);
parser.parse(env::args());
// Use pre-built binaries for packages as the default.
// If not set on the command line or the filesystem config, then build packages from source.
let repo_binary = parser.found("repo-binary");
let skip_partition = parser.found("skip-partition");
let mut config = if let Some(path) = parser.get_opt("config") {
match Config::from_file(Path::new(&path)) {
@@ -55,9 +77,8 @@ fn main() {
..Default::default()
});
// Add command line flags to config, command line takes priority
if repo_binary {
config.general.repo_binary = Some(true);
if skip_partition {
config.general.skip_partitions = Some(true);
}
if parser.found("filesystem-size") {
@@ -148,7 +169,7 @@ fn main() {
process::exit(1);
}
} else {
eprintln!("installer: output or list-packages not found");
eprint!("{}", HELP_STR);
process::exit(1);
}
}
+29 -28
View File
@@ -11,7 +11,11 @@ use std::{
path::{Path, PathBuf},
process,
};
use termion::input::TermRead;
// TODO: This is not the TUI a regular user would expect it does
// 1. Linux: Implement disk listing, use "dd" to write into whole disk
// 2. Allow partitioning to allow dual boot, possibly an integration with systemd-boot/grub
// 3. Prompt everything (disk password, users, preconfigured packages, import from existing img)
#[cfg(not(target_os = "redox"))]
fn disk_paths(_paths: &mut Vec<(PathBuf, u64)>) {}
@@ -32,7 +36,7 @@ fn disk_paths(paths: &mut Vec<(PathBuf, u64)>) {
}
}
Err(err) => {
eprintln!("installer_tui: failed to list schemes: {}", err);
eprintln!("redox_installer_tui: failed to list schemes: {}", err);
}
}
@@ -60,7 +64,7 @@ fn disk_paths(paths: &mut Vec<(PathBuf, u64)>) {
}
Err(err) => {
eprintln!(
"installer_tui: failed to list '{}': {}",
"redox_installer_tui: failed to list '{}': {}",
scheme.display(),
err
);
@@ -221,7 +225,8 @@ fn choose_disk() -> PathBuf {
}
if paths.is_empty() {
eprintln!("installer_tui: no drives found");
eprintln!("redox_installer_tui: no RedoxFS partition found");
eprintln!("redox_installer_tui: this tool is used to overwrite unmounted RedoxFS disk in Redox OS");
process::exit(1);
} else {
eprint!("Select a drive from 1 to {}: ", paths.len());
@@ -229,12 +234,12 @@ fn choose_disk() -> PathBuf {
let mut line = String::new();
match io::stdin().read_line(&mut line) {
Ok(0) => {
eprintln!("installer_tui: failed to read line: end of input");
eprintln!("redox_installer_tui: failed to read line: end of input");
process::exit(1);
}
Ok(_) => (),
Err(err) => {
eprintln!("installer_tui: failed to read line: {}", err);
eprintln!("redox_installer_tui: failed to read line: {}", err);
process::exit(1);
}
}
@@ -255,29 +260,17 @@ fn choose_disk() -> PathBuf {
}
}
fn choose_password() -> Option<String> {
eprint!("installer_tui: redoxfs password (empty for none): ");
let password = io::stdin()
.read_passwd(&mut io::stderr())
.unwrap()
.unwrap_or(String::new());
eprintln!();
if password.is_empty() {
return None;
}
Some(password)
}
fn main() {
let root_path = Path::new("/");
let disk_path = choose_disk();
let password_opt = choose_password();
let Ok(password_opt) = redox_installer::prompt_password(
"redox_installer_tui: redoxfs password (empty for none)",
"redox_installer_tui: confirm password",
) else {
process::exit(1);
};
let instant = std::time::Instant::now();
@@ -287,7 +280,11 @@ fn main() {
match fs::read(&path) {
Ok(ok) => ok,
Err(err) => {
eprintln!("installer_tui: {}: failed to read: {}", path.display(), err);
eprintln!(
"redox_installer_tui: {}: failed to read: {}",
path.display(),
err
);
process::exit(1);
}
}
@@ -302,7 +299,11 @@ fn main() {
match fs::read(&path) {
Ok(ok) => ok,
Err(err) => {
eprintln!("installer_tui: {}: failed to read: {}", path.display(), err);
eprintln!(
"redox_installer_tui: {}: failed to read: {}",
path.display(),
err
);
process::exit(1);
}
}
@@ -378,13 +379,13 @@ fn main() {
match res {
Ok(()) => {
eprintln!(
"installer_tui: installed successfully in {:?}",
"redox_installer_tui: installed successfully in {:?}",
instant.elapsed()
);
process::exit(0);
}
Err(err) => {
eprintln!("installer_tui: failed to install: {}", err);
eprintln!("redox_installer_tui: failed to install: {}", err);
process::exit(1);
}
}
+35
View File
@@ -1,3 +1,5 @@
use std::fmt::Display;
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct FileConfig {
pub path: String,
@@ -46,3 +48,36 @@ impl FileConfig {
self
}
}
impl Display for FileConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.path)?;
if self.symlink {
write!(f, " -> {}", self.data)?;
} else if self.directory {
write!(f, " type=dir")?;
if self.recursive_chown {
write!(f, " chown=yes")?;
}
} else {
write!(
f,
" size={}B",
arg_parser::to_human_readable_string(self.data.len() as u64)
)?;
if self.postinstall {
write!(f, "!")?;
}
}
if let Some(uid) = self.uid {
write!(f, " uid={}", uid)?;
}
if let Some(uid) = self.uid {
write!(f, " gid={}", uid)?;
}
if let Some(mode) = self.mode {
write!(f, " mode={:3o}", mode)?;
}
Ok(())
}
}
+2 -7
View File
@@ -2,18 +2,15 @@
pub struct GeneralConfig {
/// Specify a path where cookbook exists, all packages will be installed locally
pub cookbook: Option<String>,
/// Allow prompts for missing information such as user password, true by default
/// Allow prompts for missing information such as user password
pub prompt: Option<bool>,
/// Allow config to specify cookbook recipe or binary package as default
/// note: Not read by installer anymore, exists only for legacy reasons
pub repo_binary: Option<bool>,
/// Total filesystem size in MB
pub filesystem_size: Option<u32>,
/// EFI partition size in MB, default to 2MB
pub efi_partition_size: Option<u32>,
/// Skip disk partitioning, assume whole disk is a partition
pub skip_partitions: Option<bool>,
/// Set a plain text password to encrypt the disk, or empty string to prompt
/// Set a plain text password to encrypt the disk
pub encrypt_disk: Option<String>,
/// Use live disk for bootloader config, default is false
pub live_disk: Option<bool>,
@@ -30,8 +27,6 @@ impl GeneralConfig {
if let Some(cookbook) = other.cookbook {
self.cookbook = Some(cookbook);
}
self.prompt = other.prompt.or(self.prompt);
self.repo_binary = other.repo_binary.or(self.repo_binary);
self.filesystem_size = other.filesystem_size.or(self.filesystem_size);
self.efi_partition_size = other.efi_partition_size.or(self.efi_partition_size);
self.skip_partitions = other.skip_partitions.or(self.skip_partitions);
+27 -3
View File
@@ -1,9 +1,11 @@
use std::collections::BTreeMap;
use std::fmt::Display;
use std::fs;
use std::mem;
use std::path::{Path, PathBuf};
use anyhow::bail;
use anyhow::Context;
use anyhow::Result;
pub mod file;
@@ -35,11 +37,11 @@ impl Config {
Ok(config_data) => match toml::from_str(&config_data) {
Ok(config) => config,
Err(err) => {
bail!("{}: failed to decode: {}", path.display(), err);
bail!("failed to decode '{}': {}", path.display(), err);
}
},
Err(err) => {
bail!("{}: failed to read: {}", path.display(), err);
bail!("failed to read '{}': {}", path.display(), err);
}
};
@@ -47,7 +49,10 @@ impl Config {
let mut configs = mem::take(&mut config.include)
.into_iter()
.map(|path| Config::from_file(&config_dir.join(path)))
.map(|path| {
Config::from_file(&config_dir.join(&path))
.with_context(|| format!("Importing from {}", path.display()))
})
.collect::<Result<Vec<Config>>>()?;
configs.push(config); // Put ourself last to ensure that it overwrites anything else.
@@ -90,3 +95,22 @@ impl Config {
}
}
}
impl Display for Config {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
writeln!(f, "files:")?;
for file in &self.files {
writeln!(f, "- {}", file)?;
}
writeln!(f, "users:")?;
for (name, user) in &self.users {
writeln!(f, "- {}:{}", name, user)?;
}
write!(f, "packages: ")?;
for name in self.packages.keys() {
write!(f, " {}", name)?;
}
writeln!(f, "")?;
Ok(())
}
}
+27
View File
@@ -1,3 +1,5 @@
use std::fmt::Display;
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct UserConfig {
pub password: Option<String>,
@@ -14,3 +16,28 @@ pub struct GroupConfig {
// FIXME move this to the UserConfig struct as extra_groups
pub members: Vec<String>,
}
impl Display for UserConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(uid) = &self.uid {
write!(f, " uid={}", uid)?;
}
if let Some(gid) = &self.gid {
write!(f, " gid={}", gid)?;
}
if let Some(name) = &self.name {
write!(f, " name={}", name)?;
}
if let Some(home) = &self.home {
write!(f, " home={}", home)?;
}
if let Some(shell) = &self.shell {
write!(f, " shell={}", shell)?;
}
if self.password.as_ref().is_some_and(|s| !s.is_empty()) {
write!(f, " password=yes")?;
}
Ok(())
}
}
+56 -57
View File
@@ -33,6 +33,7 @@ pub struct DiskOption<'a> {
}
fn get_target() -> String {
// TODO: Configurable from filesystem config?
env::var("TARGET").unwrap_or(
option_env!("TARGET").map_or("x86_64-unknown-redox".to_string(), |x| x.to_string()),
)
@@ -56,23 +57,35 @@ fn syscall_error(err: syscall::Error) -> io::Error {
}
/// Returns a password collected from the user (plaintext)
fn prompt_password(prompt: &str, confirm_prompt: &str) -> Result<String> {
pub fn prompt_password(prompt: &str, confirm_prompt: &str) -> Result<Option<String>> {
let stdin = io::stdin();
let mut stdin = stdin.lock();
let stdout = io::stdout();
let mut stdout = stdout.lock();
print!("{}", prompt);
let password = stdin.read_passwd(&mut stdout)?;
for i in 0..3 {
print!("{}", prompt);
let mut password = stdin.read_passwd(&mut stdout)?;
if let Some(password) = password.as_mut() {
*password = password.trim().to_string();
}
password.take_if(|s| s.is_empty());
print!("\n{}", confirm_prompt);
let confirm_password = stdin.read_passwd(&mut stdout)?;
if password.is_none() {
return Ok(None);
}
// Note: Actually comparing two Option<String> values
if confirm_password != password {
bail!("passwords do not match");
print!("\n{}", confirm_prompt);
let confirm_password = stdin.read_passwd(&mut stdout)?;
// Note: Actually comparing two Option<String> values
if confirm_password == password {
return Ok(password);
} else if i < 2 {
eprintln!("passwords do not match, please try again");
}
}
Ok(password.unwrap_or("".to_string()))
bail!("passwords do not match, giving up");
}
fn install_local_pkgar(cookbook: &str, target: &str, packagename: &str, dest: &Path) -> Result<()> {
@@ -132,12 +145,20 @@ fn install_packages(config: &Config, dest: &Path, cookbook: Option<&str>) -> any
}
} else {
let mut library = Library::new(dest, target, Rc::new(RefCell::new(callback)))?;
let mut package_len = packages.len();
for packagename in packages {
if !get_head_path(packagename, dest).exists() {
println!("Installing package from remote: {packagename}");
if package_len == 1 {
println!("Installing package from remote: {packagename}");
}
library.install(vec![pkg::PackageName::new(packagename)?])?;
} else {
package_len -= 1;
}
}
if package_len != 1 {
println!("Installing {} packages from remote", package_len);
}
library.apply()?;
}
@@ -149,29 +170,6 @@ pub fn install_dir(
output_dir: impl AsRef<Path>,
cookbook: Option<&str>,
) -> Result<()> {
//let mut context = liner::Context::new();
macro_rules! prompt {
($dst:expr, $def:expr, $($arg:tt)*) => {
if config.general.prompt.unwrap_or(true) {
Err(io::Error::new(
io::ErrorKind::Other,
"prompt not currently supported",
))
// match unwrap_or_prompt($dst, &mut context, &format!($($arg)*)) {
// Ok(res) => if res.is_empty() {
// Ok($def)
// } else {
// Ok(res)
// },
// Err(err) => Err(err)
// }
} else {
Ok($dst.unwrap_or($def))
}
};
}
let output_dir = output_dir.as_ref();
let output_dir = output_dir.to_owned();
@@ -206,6 +204,7 @@ pub fn install_dir(
&format!("{}: enter password: ", username),
&format!("{}: confirm password: ", username),
)?
.unwrap_or_default()
} else {
String::new()
};
@@ -222,29 +221,16 @@ pub fn install_dir(
next_gid = gid + 1;
}
let name = prompt!(
user.name,
username.clone(),
"{}: name (GECOS) [{}]: ",
username,
username
)?;
let home = prompt!(
user.home,
format!("/home/{}", username),
"{}: home [/home/{}]: ",
username,
username
)?;
let shell = prompt!(
user.shell,
"/bin/ion".to_string(),
"{}: shell [/bin/ion]: ",
username
)?;
let name = user.name.unwrap_or(username.clone());
let home = user.home.unwrap_or(format!("/home/{}", username));
let shell = user.shell.unwrap_or("/bin/ion".into());
println!("Adding user {username}:");
println!("\tPassword: {password}");
if password.is_empty() {
println!("\tPassword: unset");
} else {
println!("\tPassword: set");
}
println!("\tUID: {uid}");
println!("\tGID: {gid}");
println!("\tName: {name}");
@@ -817,12 +803,26 @@ pub fn try_fast_install<D: redoxfs::Disk, F: FnMut(u64, u64)>(
}
fn install_inner(config: Config, output: &Path) -> Result<()> {
println!("Install {config:#?} to {}", output.display());
println!("Installing to {}:\n{}", output.display(), config);
let cookbook = config.general.cookbook.clone();
let cookbook = cookbook.as_ref().map(|p| p.as_str());
if output.is_dir() {
install_dir(config, output, cookbook)
} else {
if !output.is_file() {
let fs_size = config.general.filesystem_size.unwrap_or(0) as u64;
// arbitrary size approximately fit just for initfs
if fs_size < 32 {
bail!("Refusing to create image disk less than 32 MB");
}
eprintln!(
"Creating a new file to {} with size {} MB",
output.display(),
fs_size
);
let file = fs::File::create(output)?;
file.set_len(fs_size * 1024 * 1024)?;
}
let live = config.general.live_disk.unwrap_or(false);
let password_opt = config.general.encrypt_disk.clone();
let password_opt = password_opt.as_ref().map(|p| p.as_bytes());
@@ -852,8 +852,7 @@ fn install_inner(config: Config, output: &Path) -> Result<()> {
}
/// Install RedoxFS into a new disk file, or a sysroot directory.
/// This function assumes all interactive prompts resolved by the caller,
/// so "prompt" option is ignored from this function onward.
/// This function assumes all interactive prompts resolved by the caller.
pub fn install(config: Config, output: impl AsRef<Path>) -> Result<()> {
install_inner(config, output.as_ref())
}