FDS/OS 1.0
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum};
|
||||
use fds_burn::cli::{BurnCommand, FormatCommand};
|
||||
use fds_common::Bay;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(multicall = true)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub applet: Applet,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Applet {
|
||||
#[command(version, about = "FDS/OS system and cartridge control")]
|
||||
Fds(FdsArgs),
|
||||
/// Inspect a bay, image, or cartridge manifest.
|
||||
#[command(version)]
|
||||
FdsInspect {
|
||||
#[arg(long, global = true)]
|
||||
json: bool,
|
||||
#[command(flatten)]
|
||||
args: InspectArgs,
|
||||
},
|
||||
/// Unmount a cartridge before removal.
|
||||
#[command(version)]
|
||||
FdsEject {
|
||||
#[arg(long, global = true)]
|
||||
json: bool,
|
||||
#[command(flatten)]
|
||||
args: BayArgs,
|
||||
},
|
||||
/// Prepare native shutdown or inspect its status.
|
||||
#[command(version)]
|
||||
FdsPower {
|
||||
#[arg(long, global = true)]
|
||||
json: bool,
|
||||
#[command(flatten)]
|
||||
args: PowerArgs,
|
||||
},
|
||||
}
|
||||
impl Cli {
|
||||
pub fn into_command(self) -> (Option<Action>, bool) {
|
||||
match self.applet {
|
||||
Applet::Fds(args) => (args.command, args.json),
|
||||
Applet::FdsInspect { json, args } => (Some(Action::Inspect(args)), json),
|
||||
Applet::FdsEject { json, args } => (Some(Action::Eject(args)), json),
|
||||
Applet::FdsPower { json, args } => (Some(Action::Power(args)), json),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct FdsArgs {
|
||||
/// Emit machine-readable JSON where supported.
|
||||
#[arg(long, global = true)]
|
||||
pub json: bool,
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Action>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Action {
|
||||
/// Show the system and tool identity.
|
||||
Info,
|
||||
/// Show all twelve bays.
|
||||
Bays,
|
||||
/// Show one bay and its cartridge state.
|
||||
#[command(visible_alias = "cartridge")]
|
||||
Bay(BayArgs),
|
||||
/// Unmount a cartridge before removal.
|
||||
Eject(BayArgs),
|
||||
/// Select writable DATA.
|
||||
Data {
|
||||
#[command(subcommand)]
|
||||
command: DataCommand,
|
||||
},
|
||||
/// Start a managed DATA job or PROGRAM executable.
|
||||
Run {
|
||||
#[arg(value_parser = fds_burn::client::bay)]
|
||||
bay: Bay,
|
||||
/// Executable and its arguments; separate them from FDS options with --.
|
||||
#[arg(last = true, required = true, num_args = 1.., value_name = "EXECUTABLE_AND_ARGS")]
|
||||
arguments: Vec<String>,
|
||||
},
|
||||
/// Show desktop and network state.
|
||||
Profiles,
|
||||
/// Activate a desktop or return to the console.
|
||||
Profile {
|
||||
#[command(subcommand)]
|
||||
command: ProfileCommand,
|
||||
},
|
||||
/// Enable or stop DHCP networking.
|
||||
Network {
|
||||
#[arg(value_enum)]
|
||||
state: Switch,
|
||||
},
|
||||
/// Refresh cartridge inventory.
|
||||
Rescan,
|
||||
/// Show USB paths for bay calibration.
|
||||
Topology,
|
||||
/// Inspect a bay, image, or manifest file.
|
||||
Inspect(InspectArgs),
|
||||
/// Preview and confirm a cartridge write, or manage an existing operation.
|
||||
Burn {
|
||||
#[command(subcommand)]
|
||||
command: BurnCommand,
|
||||
},
|
||||
/// Create a cartridge and preview a confirmed write.
|
||||
Format {
|
||||
#[command(subcommand)]
|
||||
command: FormatCommand,
|
||||
},
|
||||
/// Manage persistent machine settings and saved diagnostics.
|
||||
Machine {
|
||||
#[command(subcommand)]
|
||||
command: Option<MachineCommand>,
|
||||
},
|
||||
/// Check DATA or confirm conservative repair from the root recovery console.
|
||||
Recovery {
|
||||
#[command(subcommand)]
|
||||
command: Option<RecoveryCommand>,
|
||||
},
|
||||
/// Verify DATA and request native shutdown.
|
||||
Poweroff,
|
||||
/// Verify DATA and request a reboot.
|
||||
Reboot,
|
||||
/// Show, resume, or request native shutdown preparation.
|
||||
Power(PowerArgs),
|
||||
/// Show measured boot events.
|
||||
BootProfile,
|
||||
/// Show the tool version.
|
||||
Version,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct BayArgs {
|
||||
#[arg(value_parser = fds_burn::client::bay)]
|
||||
pub bay: Bay,
|
||||
}
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum DataCommand {
|
||||
/// Select a DATA cartridge for /data.
|
||||
Use(BayArgs),
|
||||
}
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum ProfileCommand {
|
||||
/// Activate a profile.
|
||||
Activate {
|
||||
#[arg(value_parser = ["windowmaker", "cli"])]
|
||||
name: String,
|
||||
},
|
||||
/// Return to the console.
|
||||
Deactivate,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, ValueEnum)]
|
||||
pub enum Switch {
|
||||
On,
|
||||
Off,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct InspectArgs {
|
||||
pub target: Option<PathBuf>,
|
||||
pub command: Option<InspectCommand>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
#[command(subcommand_negates_reqs = true)]
|
||||
struct InspectParser {
|
||||
/// A bay number (1–12 or BAY1–BAY12), or a manifest file path.
|
||||
#[arg(required = true, value_name = "BAY_OR_MANIFEST")]
|
||||
target: Option<PathBuf>,
|
||||
#[command(subcommand)]
|
||||
command: Option<InspectCommand>,
|
||||
}
|
||||
|
||||
impl TryFrom<InspectParser> for InspectArgs {
|
||||
type Error = clap::Error;
|
||||
fn try_from(parsed: InspectParser) -> Result<Self, Self::Error> {
|
||||
// Clap's args_conflicts_with_subcommands also conflicts with global
|
||||
// --json. Check only these two typed alternatives during conversion.
|
||||
if parsed.target.is_some() && parsed.command.is_some() {
|
||||
return Err(clap::Error::raw(
|
||||
clap::error::ErrorKind::ArgumentConflict,
|
||||
"Choose a bay/manifest target or the image subcommand, not both",
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
target: parsed.target,
|
||||
command: parsed.command,
|
||||
})
|
||||
}
|
||||
}
|
||||
impl clap::FromArgMatches for InspectArgs {
|
||||
fn from_arg_matches(matches: &clap::ArgMatches) -> Result<Self, clap::Error> {
|
||||
InspectParser::from_arg_matches(matches)?.try_into()
|
||||
}
|
||||
fn update_from_arg_matches(&mut self, matches: &clap::ArgMatches) -> Result<(), clap::Error> {
|
||||
let mut parsed = InspectParser {
|
||||
target: self.target.clone(),
|
||||
command: self.command.clone(),
|
||||
};
|
||||
parsed.update_from_arg_matches(matches)?;
|
||||
*self = parsed.try_into()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl Args for InspectArgs {
|
||||
fn augment_args(command: clap::Command) -> clap::Command {
|
||||
InspectParser::augment_args(command)
|
||||
}
|
||||
fn augment_args_for_update(command: clap::Command) -> clap::Command {
|
||||
InspectParser::augment_args_for_update(command)
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Debug, Subcommand)]
|
||||
pub enum InspectCommand {
|
||||
/// Validate a regular cartridge image and calculate its SHA-256 digest.
|
||||
Image { image: PathBuf },
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct PowerArgs {
|
||||
#[command(subcommand)]
|
||||
pub command: Option<PowerCommand>,
|
||||
}
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum PowerCommand {
|
||||
/// Show preparation status or its blocking error (the default).
|
||||
Status,
|
||||
/// Resume operations before native shutdown starts.
|
||||
Resume,
|
||||
/// Verify DATA and request native shutdown.
|
||||
Poweroff,
|
||||
/// Verify DATA and request a reboot.
|
||||
Reboot,
|
||||
#[command(long_flag = "shutdown-hook", hide = true)]
|
||||
ShutdownHook,
|
||||
#[command(long_flag = "hold-shutdown", hide = true)]
|
||||
HoldShutdown,
|
||||
#[command(long_flag = "record-final", hide = true)]
|
||||
RecordFinal {
|
||||
#[arg(value_parser = ["failed"])]
|
||||
outcome: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum RecoveryCommand {
|
||||
/// Unmount and check DATA without repairs.
|
||||
Check(BayArgs),
|
||||
/// Preview repair, or confirm the exact token returned by the preview.
|
||||
Repair {
|
||||
#[arg(value_parser = fds_burn::client::bay)]
|
||||
bay: Bay,
|
||||
#[arg(long, value_name = "TOKEN")]
|
||||
confirm: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum MachineCommand {
|
||||
/// Show the active machine identity and settings source.
|
||||
Status,
|
||||
/// Export this boot's active settings into a new directory.
|
||||
Export { new_directory: PathBuf },
|
||||
/// Validate machine.toml, bays.toml and hardware-catalog.toml.
|
||||
Validate { directory: PathBuf },
|
||||
/// Validate and pack settings into a new JSON file.
|
||||
Pack {
|
||||
directory: PathBuf,
|
||||
new_json_file: PathBuf,
|
||||
},
|
||||
/// Install settings for the next boot (root recovery console only).
|
||||
Install { directory: PathBuf },
|
||||
/// Save an explicit diagnostic of up to 16 MiB (root only).
|
||||
Store { name: String, file: PathBuf },
|
||||
/// Retrieve a saved diagnostic into a new file (root only).
|
||||
Fetch { name: String, new_file: PathBuf },
|
||||
#[command(long_flag = "load", hide = true)]
|
||||
Load,
|
||||
}
|
||||
|
||||
pub fn help(subcommand: Option<&str>) -> std::io::Result<()> {
|
||||
let mut root = Cli::command();
|
||||
root.build();
|
||||
let fds = root.find_subcommand_mut("fds").expect("fds applet");
|
||||
let command = match subcommand {
|
||||
Some(name) => fds.find_subcommand_mut(name).expect("known subcommand"),
|
||||
None => fds,
|
||||
};
|
||||
command.print_help()?;
|
||||
println!();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn command_tree_and_installed_aliases() {
|
||||
Cli::command().debug_assert();
|
||||
for arguments in [
|
||||
vec!["fds", "--json", "inspect", "BAY12"],
|
||||
vec!["/usr/bin/fds-inspect", "BAY12", "--json"],
|
||||
vec!["fds-inspect", "image", "card.img"],
|
||||
vec!["fds-eject", "--json", "12"],
|
||||
vec!["fds-power", "status", "--json"],
|
||||
vec!["fds", "cartridge", "12"],
|
||||
vec![
|
||||
"fds",
|
||||
"recovery",
|
||||
"repair",
|
||||
"BAY1",
|
||||
"--confirm",
|
||||
"exact phrase",
|
||||
],
|
||||
vec!["fds", "machine", "pack", "settings", "new.json"],
|
||||
vec!["fds", "machine", "--load"],
|
||||
vec!["fds", "power", "--shutdown-hook"],
|
||||
vec!["fds-power", "--hold-shutdown"],
|
||||
vec!["fds-power", "--record-final", "failed"],
|
||||
] {
|
||||
assert!(Cli::try_parse_from(&arguments).is_ok(), "{arguments:?}");
|
||||
}
|
||||
for name in ["fds", "fds-inspect", "fds-eject", "fds-power"] {
|
||||
let error = Cli::try_parse_from([name, "--help"]).unwrap_err();
|
||||
assert_eq!(error.kind(), clap::error::ErrorKind::DisplayHelp);
|
||||
assert!(error.to_string().contains(&format!("Usage: {name}")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_preserves_every_child_argument_after_separator() {
|
||||
let (command, json) = Cli::try_parse_from([
|
||||
"fds", "run", "BAY2", "--", "/bin/app", "--json", "--help", "-x", "", "--", "a b",
|
||||
])
|
||||
.unwrap()
|
||||
.into_command();
|
||||
assert!(!json);
|
||||
let Some(Action::Run { bay, arguments }) = command else {
|
||||
panic!("run command")
|
||||
};
|
||||
assert_eq!(bay, "2".parse::<Bay>().unwrap());
|
||||
assert_eq!(
|
||||
arguments,
|
||||
["/bin/app", "--json", "--help", "-x", "", "--", "a b"]
|
||||
);
|
||||
let (_, json) = Cli::try_parse_from(["fds", "run", "--json", "2", "--", "/bin/app"])
|
||||
.unwrap()
|
||||
.into_command();
|
||||
assert!(json);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspect_image_accepts_global_options_in_every_position() {
|
||||
for arguments in [
|
||||
vec!["fds", "--json", "inspect", "image", "card.img"],
|
||||
vec!["fds", "inspect", "--json", "image", "card.img"],
|
||||
vec!["fds", "inspect", "image", "card.img", "--json"],
|
||||
vec!["fds-inspect", "--json", "image", "card.img"],
|
||||
vec!["fds-inspect", "image", "--json", "card.img"],
|
||||
] {
|
||||
let (command, json) = Cli::try_parse_from(&arguments).unwrap().into_command();
|
||||
assert!(json);
|
||||
assert!(matches!(
|
||||
command,
|
||||
Some(Action::Inspect(InspectArgs {
|
||||
command: Some(InspectCommand::Image { .. }),
|
||||
target: None,
|
||||
}))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_incomplete_or_conflicting_requests_before_execution() {
|
||||
for arguments in [
|
||||
vec!["fds", "eject", "0"],
|
||||
vec!["fds-eject", "13"],
|
||||
vec!["fds", "run", "1", "/bin/app"],
|
||||
vec!["fds", "run", "1", "--"],
|
||||
vec!["fds", "inspect", "image"],
|
||||
vec!["fds-inspect", "1", "image", "card.img"],
|
||||
vec!["fds", "recovery", "check", "1", "--confirm", "token"],
|
||||
vec!["fds", "recovery", "repair", "1", "--confirm"],
|
||||
vec![
|
||||
"fds",
|
||||
"recovery",
|
||||
"repair",
|
||||
"1",
|
||||
"--confirm",
|
||||
"one",
|
||||
"--confirm",
|
||||
"two",
|
||||
],
|
||||
vec!["fds", "power", "--shutdown-hook", "--record-final"],
|
||||
vec!["fds", "power", "--record-final", "success"],
|
||||
vec!["fds", "machine", "--load", "settings"],
|
||||
vec!["fds", "network", "maybe"],
|
||||
vec!["fds", "profile", "activate", "unknown"],
|
||||
vec!["fds", "unknown"],
|
||||
] {
|
||||
assert!(Cli::try_parse_from(&arguments).is_err(), "{arguments:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user