mirror of
https://seed.radicle.dev/rad:z39mP9rQAaGmERfUMPULfPUi473tY.git
synced 2026-11-16 00:10:38 +08:00
lib: Add support for unified commands with args
This commit is contained in:
Generated
+7
-6
@@ -682,9 +682,9 @@ checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.0.26"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b9429470923de8e8cbd4d2dc513535400b4b3fef0319fb5c4e1f520a7bef743"
|
||||
checksum = "46303f565772937ffe1d394a4fac6f411c6013172fadde9dcdb1e147a086940e"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
@@ -1662,6 +1662,7 @@ dependencies = [
|
||||
"radicle-term",
|
||||
"simple-logging",
|
||||
"textwrap 0.16.0",
|
||||
"thiserror",
|
||||
"timeago",
|
||||
"tui-realm-stdlib",
|
||||
"tui-realm-textarea",
|
||||
@@ -2143,9 +2144,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.39"
|
||||
version = "0.4.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec96d2ffad078296368d46ff1cb309be1c23c513b4ab0e22a45de0185275ac96"
|
||||
checksum = "b16afcea1f22891c49a00c751c7b63b2233284064f11a200fc624137c51e2ddb"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
@@ -2798,9 +2799,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "0.2.3"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d1526bbe5aaeb5eb06885f4d987bcdfa5e23187055de9b83fe00156a821fabc"
|
||||
checksum = "fbc6ab6ec1907d1a901cdbcd2bd4cb9e7d64ce5c9739cbb97d3c391acd8c7fae"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
+2
-1
@@ -15,12 +15,13 @@ anyhow = { version = "1" }
|
||||
inquire = { version = "0.6.2", default-features = false, features = ["termion", "editor"] }
|
||||
lexopt = { version = "0.2" }
|
||||
log = { version = "0.4.19" }
|
||||
simple-logging = { version = "2.0.2" }
|
||||
radicle = { git = "https://github.com/radicle-dev/heartwood" }
|
||||
radicle-term = { git = "https://github.com/radicle-dev/heartwood", package = "radicle-term" }
|
||||
radicle-surf = { version = "0.18.0" }
|
||||
simple-logging = { version = "2.0.2" }
|
||||
timeago = { version = "0.4.1" }
|
||||
textwrap = { version = "0.16.0" }
|
||||
thiserror = { version = "1" }
|
||||
tuirealm = { version = "1.9.0", default-features = false, features = [ "with-termion" ] }
|
||||
tui-realm-stdlib = { version = "1.2.0", default-features = false, features = [ "with-termion" ] }
|
||||
tui-realm-textarea = { git = "https://github.com/erak/tui-realm-textarea.git", default-features = false, features = [ "with-termion", "clipboard" ] }
|
||||
|
||||
+5
-1
@@ -1,3 +1,7 @@
|
||||
// use radicle::profile;
|
||||
|
||||
mod terminal;
|
||||
|
||||
fn main() {
|
||||
|
||||
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
pub mod args;
|
||||
pub use args::{Args, Error, Help};
|
||||
pub mod io;
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::process;
|
||||
|
||||
use radicle_term as term;
|
||||
|
||||
use radicle::profile::Profile;
|
||||
|
||||
/// Context passed to all commands.
|
||||
pub trait Context {
|
||||
/// Return the currently active profile, or an error if no profile is active.
|
||||
fn profile(&self) -> Result<Profile, anyhow::Error>;
|
||||
}
|
||||
|
||||
impl Context for Profile {
|
||||
fn profile(&self) -> Result<Profile, anyhow::Error> {
|
||||
Ok(self.clone())
|
||||
}
|
||||
}
|
||||
|
||||
impl<F> Context for F
|
||||
where
|
||||
F: Fn() -> Result<Profile, anyhow::Error>,
|
||||
{
|
||||
fn profile(&self) -> Result<Profile, anyhow::Error> {
|
||||
self()
|
||||
}
|
||||
}
|
||||
|
||||
/// A command that can be run.
|
||||
pub trait Command<A: Args, C: Context> {
|
||||
/// Run the command, given arguments and a context.
|
||||
fn run(self, args: A, context: C) -> anyhow::Result<()>;
|
||||
}
|
||||
|
||||
impl<F, A: Args, C: Context> Command<A, C> for F
|
||||
where
|
||||
F: FnOnce(A, C) -> anyhow::Result<()>,
|
||||
{
|
||||
fn run(self, args: A, context: C) -> anyhow::Result<()> {
|
||||
self(args, context)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_command<A, C>(help: Help, cmd: C) -> !
|
||||
where
|
||||
A: Args,
|
||||
C: Command<A, fn() -> anyhow::Result<Profile>>,
|
||||
{
|
||||
let args = std::env::args_os().skip(1).collect();
|
||||
|
||||
run_command_args(help, cmd, args)
|
||||
}
|
||||
|
||||
pub fn run_command_args<A, C>(help: Help, cmd: C, args: Vec<OsString>) -> !
|
||||
where
|
||||
A: Args,
|
||||
C: Command<A, fn() -> anyhow::Result<Profile>>,
|
||||
{
|
||||
let options = match A::from_args(args) {
|
||||
Ok((opts, unparsed)) => {
|
||||
if let Err(err) = args::finish(unparsed) {
|
||||
term::error(err);
|
||||
process::exit(1);
|
||||
}
|
||||
opts
|
||||
}
|
||||
Err(err) => {
|
||||
let hint = match err.downcast_ref::<Error>() {
|
||||
Some(Error::Help) => {
|
||||
term::help(help.name, help.version, help.description, help.usage);
|
||||
process::exit(0);
|
||||
}
|
||||
Some(Error::HelpManual { name }) => {
|
||||
let Ok(status) = term::manual(name) else {
|
||||
term::error(format!("rad {}: failed to load manual page", help.name));
|
||||
process::exit(1);
|
||||
};
|
||||
process::exit(status.code().unwrap_or(0));
|
||||
}
|
||||
Some(Error::Usage) => {
|
||||
term::usage(help.name, help.usage);
|
||||
process::exit(1);
|
||||
}
|
||||
Some(Error::WithHint { hint, .. }) => Some(hint),
|
||||
None => None,
|
||||
};
|
||||
term::error(format!("rad {}: {err}", help.name));
|
||||
|
||||
if let Some(hint) = hint {
|
||||
term::hint(hint);
|
||||
}
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match cmd.run(options, self::profile) {
|
||||
Ok(()) => process::exit(0),
|
||||
Err(err) => {
|
||||
fail(help.name, &err);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the default profile. Fails if there is no profile.
|
||||
pub fn profile() -> Result<Profile, anyhow::Error> {
|
||||
match Profile::load() {
|
||||
Ok(profile) => Ok(profile),
|
||||
Err(radicle::profile::Error::NotFound(path)) => Err(args::Error::WithHint {
|
||||
err: anyhow::anyhow!("Radicle profile not found in '{}'.", path.display()),
|
||||
hint: "To setup your radicle profile, run `rad auth`.",
|
||||
}
|
||||
.into()),
|
||||
Err(radicle::profile::Error::Config(e)) => Err(e.into()),
|
||||
Err(e) => Err(anyhow::anyhow!("Could not load radicle profile: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fail(_name: &str, error: &anyhow::Error) {
|
||||
let err = error.to_string();
|
||||
let err = err.trim_end();
|
||||
|
||||
for line in err.lines() {
|
||||
term::error(line);
|
||||
}
|
||||
|
||||
if let Some(Error::WithHint { hint, .. }) = error.downcast_ref::<Error>() {
|
||||
term::hint(hint);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
use std::ffi::OsString;
|
||||
use std::str::FromStr;
|
||||
use std::time;
|
||||
|
||||
use anyhow::anyhow;
|
||||
|
||||
use radicle::cob::{self, issue, patch};
|
||||
use radicle::crypto;
|
||||
use radicle::git::RefString;
|
||||
use radicle::node::{Address, Alias};
|
||||
use radicle::prelude::{Did, Id, NodeId};
|
||||
|
||||
/// Git revision parameter. Supports extended SHA-1 syntax.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Rev(String);
|
||||
|
||||
impl From<String> for Rev {
|
||||
fn from(value: String) -> Self {
|
||||
Rev(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(thiserror::Error, Debug)]
|
||||
pub enum Error {
|
||||
/// If this error is returned from argument parsing, help is displayed.
|
||||
#[error("help invoked")]
|
||||
Help,
|
||||
/// If this error is returned from argument parsing, the manual page is displayed.
|
||||
#[error("help manual invoked")]
|
||||
HelpManual { name: &'static str },
|
||||
/// If this error is returned from argument parsing, usage is displayed.
|
||||
#[error("usage invoked")]
|
||||
Usage,
|
||||
/// An error with a hint.
|
||||
#[error("{err}")]
|
||||
WithHint {
|
||||
err: anyhow::Error,
|
||||
hint: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct Help {
|
||||
pub name: &'static str,
|
||||
pub description: &'static str,
|
||||
pub version: &'static str,
|
||||
pub usage: &'static str,
|
||||
}
|
||||
|
||||
pub trait Args: Sized {
|
||||
fn from_env() -> anyhow::Result<Self> {
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
|
||||
match Self::from_args(args) {
|
||||
Ok((opts, unparsed)) => {
|
||||
self::finish(unparsed)?;
|
||||
|
||||
Ok(opts)
|
||||
}
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)>;
|
||||
}
|
||||
|
||||
pub fn parse_value<T: FromStr>(flag: &str, value: OsString) -> anyhow::Result<T>
|
||||
where
|
||||
<T as FromStr>::Err: std::error::Error,
|
||||
{
|
||||
value
|
||||
.into_string()
|
||||
.map_err(|_| anyhow!("the value specified for '--{}' is not valid UTF-8", flag))?
|
||||
.parse()
|
||||
.map_err(|e| anyhow!("invalid value specified for '--{}' ({})", flag, e))
|
||||
}
|
||||
|
||||
pub fn format(arg: lexopt::Arg) -> OsString {
|
||||
match arg {
|
||||
lexopt::Arg::Long(flag) => format!("--{flag}").into(),
|
||||
lexopt::Arg::Short(flag) => format!("-{flag}").into(),
|
||||
lexopt::Arg::Value(val) => val,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn finish(unparsed: Vec<OsString>) -> anyhow::Result<()> {
|
||||
if let Some(arg) = unparsed.first() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"unexpected argument `{}`",
|
||||
arg.to_string_lossy()
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn refstring(flag: &str, value: OsString) -> anyhow::Result<RefString> {
|
||||
RefString::try_from(
|
||||
value
|
||||
.into_string()
|
||||
.map_err(|_| anyhow!("the value specified for '--{}' is not valid UTF-8", flag))?,
|
||||
)
|
||||
.map_err(|_| {
|
||||
anyhow!(
|
||||
"the value specified for '--{}' is not a valid ref string",
|
||||
flag
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn did(val: &OsString) -> anyhow::Result<Did> {
|
||||
let val = val.to_string_lossy();
|
||||
let Ok(peer) = Did::from_str(&val) else {
|
||||
if crypto::PublicKey::from_str(&val).is_ok() {
|
||||
return Err(anyhow!("expected DID, did you mean 'did:key:{val}'?"));
|
||||
} else {
|
||||
return Err(anyhow!("invalid DID '{}', expected 'did:key'", val));
|
||||
}
|
||||
};
|
||||
Ok(peer)
|
||||
}
|
||||
|
||||
pub fn nid(val: &OsString) -> anyhow::Result<NodeId> {
|
||||
let val = val.to_string_lossy();
|
||||
NodeId::from_str(&val).map_err(|_| anyhow!("invalid Node ID '{}'", val))
|
||||
}
|
||||
|
||||
pub fn rid(val: &OsString) -> anyhow::Result<Id> {
|
||||
let val = val.to_string_lossy();
|
||||
Id::from_str(&val).map_err(|_| anyhow!("invalid Repository ID '{}'", val))
|
||||
}
|
||||
|
||||
pub fn pubkey(val: &OsString) -> anyhow::Result<NodeId> {
|
||||
let Ok(did) = did(val) else {
|
||||
let nid = nid(val)?;
|
||||
return Ok(nid);
|
||||
};
|
||||
Ok(did.as_key().to_owned())
|
||||
}
|
||||
|
||||
pub fn addr(val: &OsString) -> anyhow::Result<Address> {
|
||||
let val = val.to_string_lossy();
|
||||
Address::from_str(&val).map_err(|_| anyhow!("invalid address '{}'", val))
|
||||
}
|
||||
|
||||
pub fn number(val: &OsString) -> anyhow::Result<usize> {
|
||||
let val = val.to_string_lossy();
|
||||
usize::from_str(&val).map_err(|_| anyhow!("invalid number '{}'", val))
|
||||
}
|
||||
|
||||
pub fn seconds(val: &OsString) -> anyhow::Result<time::Duration> {
|
||||
let val = val.to_string_lossy();
|
||||
let secs = u64::from_str(&val).map_err(|_| anyhow!("invalid number of seconds '{}'", val))?;
|
||||
|
||||
Ok(time::Duration::from_secs(secs))
|
||||
}
|
||||
|
||||
pub fn string(val: &OsString) -> String {
|
||||
val.to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
pub fn rev(val: &OsString) -> anyhow::Result<Rev> {
|
||||
let s = val.to_str().ok_or(anyhow!("invalid git rev {val:?}"))?;
|
||||
Ok(Rev::from(s.to_owned()))
|
||||
}
|
||||
|
||||
pub fn oid(val: &OsString) -> anyhow::Result<Rev> {
|
||||
let s = string(val);
|
||||
let _ = radicle::git::Oid::from_str(&s).map_err(|_| anyhow!("invalid git oid '{s}'"))?;
|
||||
|
||||
Ok(Rev::from(s))
|
||||
}
|
||||
|
||||
pub fn alias(val: &OsString) -> anyhow::Result<Alias> {
|
||||
let val = val.as_os_str();
|
||||
let val = val
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow!("alias must be valid UTF-8"))?;
|
||||
|
||||
Alias::from_str(val).map_err(|e| e.into())
|
||||
}
|
||||
|
||||
pub fn issue(val: &OsString) -> anyhow::Result<issue::IssueId> {
|
||||
let val = val.to_string_lossy();
|
||||
issue::IssueId::from_str(&val).map_err(|_| anyhow!("invalid Issue ID '{}'", val))
|
||||
}
|
||||
|
||||
pub fn patch(val: &OsString) -> anyhow::Result<patch::PatchId> {
|
||||
let val = val.to_string_lossy();
|
||||
patch::PatchId::from_str(&val).map_err(|_| anyhow!("invalid Patch ID '{}'", val))
|
||||
}
|
||||
|
||||
pub fn cob(val: &OsString) -> anyhow::Result<cob::ObjectId> {
|
||||
let val = val.to_string_lossy();
|
||||
cob::ObjectId::from_str(&val).map_err(|_| anyhow!("invalid Object ID '{}'", val))
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
use radicle::crypto::ssh::keystore::MemorySigner;
|
||||
use radicle::crypto::{ssh::Keystore, Signer};
|
||||
use radicle::profile::env::RAD_PASSPHRASE;
|
||||
use radicle::profile::Profile;
|
||||
|
||||
use radicle_term::io::*;
|
||||
use radicle_term::spinner;
|
||||
|
||||
use inquire::validator;
|
||||
|
||||
/// Validates secret key passphrases.
|
||||
#[derive(Clone)]
|
||||
pub struct PassphraseValidator {
|
||||
keystore: Keystore,
|
||||
}
|
||||
|
||||
impl PassphraseValidator {
|
||||
/// Create a new validator.
|
||||
pub fn new(keystore: Keystore) -> Self {
|
||||
Self { keystore }
|
||||
}
|
||||
}
|
||||
|
||||
impl inquire::validator::StringValidator for PassphraseValidator {
|
||||
fn validate(
|
||||
&self,
|
||||
input: &str,
|
||||
) -> Result<validator::Validation, inquire::error::CustomUserError> {
|
||||
let passphrase = Passphrase::from(input.to_owned());
|
||||
if self.keystore.is_valid_passphrase(&passphrase)? {
|
||||
Ok(validator::Validation::Valid)
|
||||
} else {
|
||||
Ok(validator::Validation::Invalid(
|
||||
validator::ErrorMessage::from("Invalid passphrase, please try again"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the signer. First we try getting it from ssh-agent, otherwise we prompt the user,
|
||||
/// if we're connected to a TTY.
|
||||
pub fn signer(profile: &Profile) -> anyhow::Result<Box<dyn Signer>> {
|
||||
if let Ok(signer) = profile.signer() {
|
||||
return Ok(signer);
|
||||
}
|
||||
let validator = PassphraseValidator::new(profile.keystore.clone());
|
||||
let passphrase = match passphrase(validator) {
|
||||
Ok(p) => p,
|
||||
Err(inquire::InquireError::NotTTY) => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"running in non-interactive mode, please set `{RAD_PASSPHRASE}` to unseal your key",
|
||||
));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let spinner = spinner("Unsealing key...");
|
||||
let signer = MemorySigner::load(&profile.keystore, Some(passphrase))?;
|
||||
|
||||
spinner.finish();
|
||||
|
||||
Ok(signer.boxed())
|
||||
}
|
||||
Reference in New Issue
Block a user