cli/id: Use clap
This implementation works around the fact that `clap` does currently not support value parsers for a series of values, so representing `--payload` as a `Vec<Payload>` does not work. Instead, we parse eveything into a `Vec<String>` and do the validation on the application side. Using value parsers for a series of values will probably be supported in `clap` v5, though.
This commit is contained in:
parent
d3ed4bb497
commit
634866889f
|
|
@ -60,7 +60,10 @@ const COMMANDS: &[CommandItem] = &[
|
||||||
about: crate::commands::fork::ABOUT,
|
about: crate::commands::fork::ABOUT,
|
||||||
},
|
},
|
||||||
CommandItem::Lexopt(crate::commands::help::HELP),
|
CommandItem::Lexopt(crate::commands::help::HELP),
|
||||||
CommandItem::Lexopt(crate::commands::id::HELP),
|
CommandItem::Clap {
|
||||||
|
name: "id",
|
||||||
|
about: crate::commands::id::ABOUT,
|
||||||
|
},
|
||||||
CommandItem::Clap {
|
CommandItem::Clap {
|
||||||
name: "init",
|
name: "init",
|
||||||
about: crate::commands::init::ABOUT,
|
about: crate::commands::init::ABOUT,
|
||||||
|
|
|
||||||
|
|
@ -1,285 +1,34 @@
|
||||||
|
mod args;
|
||||||
|
|
||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
use std::{ffi::OsString, io};
|
|
||||||
|
|
||||||
use anyhow::{anyhow, Context};
|
use anyhow::{anyhow, Context};
|
||||||
|
|
||||||
use radicle::cob::identity::{self, IdentityMut, Revision, RevisionId};
|
use radicle::cob::identity::{self, IdentityMut, Revision, RevisionId};
|
||||||
use radicle::cob::Title;
|
use radicle::cob::Title;
|
||||||
use radicle::identity::doc::update;
|
use radicle::identity::doc::update;
|
||||||
use radicle::identity::doc::update::EditVisibility;
|
|
||||||
use radicle::identity::{doc, Doc, Identity, RawDoc};
|
use radicle::identity::{doc, Doc, Identity, RawDoc};
|
||||||
use radicle::node::device::Device;
|
use radicle::node::device::Device;
|
||||||
use radicle::node::NodeId;
|
use radicle::node::NodeId;
|
||||||
use radicle::prelude::{Did, RepoId};
|
|
||||||
use radicle::storage::{ReadStorage as _, WriteRepository};
|
use radicle::storage::{ReadStorage as _, WriteRepository};
|
||||||
use radicle::{cob, crypto, Profile};
|
use radicle::{cob, crypto, Profile};
|
||||||
use radicle_surf::diff::Diff;
|
use radicle_surf::diff::Diff;
|
||||||
use radicle_term::Element;
|
use radicle_term::Element;
|
||||||
use serde_json as json;
|
|
||||||
|
|
||||||
use crate::git::unified_diff::Encode as _;
|
use crate::git::unified_diff::Encode as _;
|
||||||
use crate::git::Rev;
|
use crate::git::Rev;
|
||||||
use crate::terminal as term;
|
use crate::terminal as term;
|
||||||
use crate::terminal::args::{Args, Error, Help};
|
use crate::terminal::args::Error;
|
||||||
use crate::terminal::patch::Message;
|
use crate::terminal::patch::Message;
|
||||||
use crate::terminal::Interactive;
|
|
||||||
|
|
||||||
pub const HELP: Help = Help {
|
pub use args::Args;
|
||||||
name: "id",
|
use args::Command;
|
||||||
description: "Manage repository identities",
|
pub(crate) use args::ABOUT;
|
||||||
version: env!("RADICLE_VERSION"),
|
|
||||||
usage: r#"
|
|
||||||
Usage
|
|
||||||
|
|
||||||
rad id list [<option>...]
|
pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
rad id update [--title <string>] [--description <string>]
|
|
||||||
[--delegate <did>] [--rescind <did>]
|
|
||||||
[--threshold <num>] [--visibility <private | public>]
|
|
||||||
[--allow <did>] [--disallow <did>]
|
|
||||||
[--no-confirm] [--payload <id> <key> <val>...] [--edit] [<option>...]
|
|
||||||
rad id edit <revision-id> [--title <string>] [--description <string>] [<option>...]
|
|
||||||
rad id show <revision-id> [<option>...]
|
|
||||||
rad id <accept | reject | redact> <revision-id> [<option>...]
|
|
||||||
|
|
||||||
The *rad id* command is used to manage and propose changes to the
|
|
||||||
identity of a Radicle repository.
|
|
||||||
|
|
||||||
See the rad-id(1) man page for more information.
|
|
||||||
|
|
||||||
Options
|
|
||||||
|
|
||||||
--repo <rid> Repository (defaults to the current repository)
|
|
||||||
--quiet, -q Don't print anything
|
|
||||||
--help Print help
|
|
||||||
"#,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
pub enum Operation {
|
|
||||||
Update {
|
|
||||||
title: Option<Title>,
|
|
||||||
description: Option<String>,
|
|
||||||
delegate: Vec<Did>,
|
|
||||||
rescind: Vec<Did>,
|
|
||||||
threshold: Option<usize>,
|
|
||||||
visibility: Option<EditVisibility>,
|
|
||||||
allow: BTreeSet<Did>,
|
|
||||||
disallow: BTreeSet<Did>,
|
|
||||||
payload: Vec<(doc::PayloadId, String, json::Value)>,
|
|
||||||
edit: bool,
|
|
||||||
},
|
|
||||||
AcceptRevision {
|
|
||||||
revision: Rev,
|
|
||||||
},
|
|
||||||
RejectRevision {
|
|
||||||
revision: Rev,
|
|
||||||
},
|
|
||||||
EditRevision {
|
|
||||||
revision: Rev,
|
|
||||||
title: Option<Title>,
|
|
||||||
description: Option<String>,
|
|
||||||
},
|
|
||||||
RedactRevision {
|
|
||||||
revision: Rev,
|
|
||||||
},
|
|
||||||
ShowRevision {
|
|
||||||
revision: Rev,
|
|
||||||
},
|
|
||||||
#[default]
|
|
||||||
ListRevisions,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, PartialEq, Eq)]
|
|
||||||
pub enum OperationName {
|
|
||||||
Accept,
|
|
||||||
Reject,
|
|
||||||
Edit,
|
|
||||||
Update,
|
|
||||||
Show,
|
|
||||||
Redact,
|
|
||||||
#[default]
|
|
||||||
List,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Options {
|
|
||||||
pub op: Operation,
|
|
||||||
pub rid: Option<RepoId>,
|
|
||||||
pub interactive: Interactive,
|
|
||||||
pub quiet: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Args for Options {
|
|
||||||
fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
|
|
||||||
use lexopt::prelude::*;
|
|
||||||
|
|
||||||
let mut parser = lexopt::Parser::from_args(args);
|
|
||||||
let mut op: Option<OperationName> = None;
|
|
||||||
let mut revision: Option<Rev> = None;
|
|
||||||
let mut rid: Option<RepoId> = None;
|
|
||||||
let mut title: Option<Title> = None;
|
|
||||||
let mut description: Option<String> = None;
|
|
||||||
let mut delegate: Vec<Did> = Vec::new();
|
|
||||||
let mut rescind: Vec<Did> = Vec::new();
|
|
||||||
let mut visibility: Option<EditVisibility> = None;
|
|
||||||
let mut allow: BTreeSet<Did> = BTreeSet::new();
|
|
||||||
let mut disallow: BTreeSet<Did> = BTreeSet::new();
|
|
||||||
let mut threshold: Option<usize> = None;
|
|
||||||
let mut interactive = Interactive::new(io::stdout());
|
|
||||||
let mut payload = Vec::new();
|
|
||||||
let mut edit = false;
|
|
||||||
let mut quiet = false;
|
|
||||||
|
|
||||||
while let Some(arg) = parser.next()? {
|
|
||||||
match arg {
|
|
||||||
Long("help") => {
|
|
||||||
return Err(Error::HelpManual { name: "rad-id" }.into());
|
|
||||||
}
|
|
||||||
Short('h') => {
|
|
||||||
return Err(Error::Help.into());
|
|
||||||
}
|
|
||||||
Long("title")
|
|
||||||
if op == Some(OperationName::Edit) || op == Some(OperationName::Update) =>
|
|
||||||
{
|
|
||||||
let val = parser.value()?;
|
|
||||||
title = Some(term::args::string(&val).try_into()?);
|
|
||||||
}
|
|
||||||
Long("description")
|
|
||||||
if op == Some(OperationName::Edit) || op == Some(OperationName::Update) =>
|
|
||||||
{
|
|
||||||
description = Some(parser.value()?.to_string_lossy().into());
|
|
||||||
}
|
|
||||||
Long("quiet") | Short('q') => {
|
|
||||||
quiet = true;
|
|
||||||
}
|
|
||||||
Long("no-confirm") => {
|
|
||||||
interactive = Interactive::No;
|
|
||||||
}
|
|
||||||
Value(val) if op.is_none() => match val.to_string_lossy().as_ref() {
|
|
||||||
"e" | "edit" => op = Some(OperationName::Edit),
|
|
||||||
"u" | "update" => op = Some(OperationName::Update),
|
|
||||||
"l" | "list" => op = Some(OperationName::List),
|
|
||||||
"s" | "show" => op = Some(OperationName::Show),
|
|
||||||
"a" | "accept" => op = Some(OperationName::Accept),
|
|
||||||
"r" | "reject" => op = Some(OperationName::Reject),
|
|
||||||
"d" | "redact" => op = Some(OperationName::Redact),
|
|
||||||
|
|
||||||
unknown => anyhow::bail!("unknown operation '{}'", unknown),
|
|
||||||
},
|
|
||||||
Long("repo") => {
|
|
||||||
let val = parser.value()?;
|
|
||||||
let val = term::args::rid(&val)?;
|
|
||||||
|
|
||||||
rid = Some(val);
|
|
||||||
}
|
|
||||||
Long("delegate") => {
|
|
||||||
let did = term::args::did(&parser.value()?)?;
|
|
||||||
delegate.push(did);
|
|
||||||
}
|
|
||||||
Long("rescind") => {
|
|
||||||
let did = term::args::did(&parser.value()?)?;
|
|
||||||
rescind.push(did);
|
|
||||||
}
|
|
||||||
Long("allow") => {
|
|
||||||
let value = parser.value()?;
|
|
||||||
let did = term::args::did(&value)?;
|
|
||||||
allow.insert(did);
|
|
||||||
}
|
|
||||||
Long("disallow") => {
|
|
||||||
let value = parser.value()?;
|
|
||||||
let did = term::args::did(&value)?;
|
|
||||||
disallow.insert(did);
|
|
||||||
}
|
|
||||||
Long("visibility") => {
|
|
||||||
let value = parser.value()?;
|
|
||||||
let value = term::args::parse_value("visibility", value)?;
|
|
||||||
|
|
||||||
visibility = Some(value);
|
|
||||||
}
|
|
||||||
Long("threshold") => {
|
|
||||||
threshold = Some(parser.value()?.to_string_lossy().parse()?);
|
|
||||||
}
|
|
||||||
Long("payload") => {
|
|
||||||
let mut values = parser.values()?;
|
|
||||||
let id = values
|
|
||||||
.next()
|
|
||||||
.ok_or(anyhow!("expected payload id, eg. `xyz.radicle.project`"))?;
|
|
||||||
let id: doc::PayloadId = term::args::parse_value("payload", id)?;
|
|
||||||
|
|
||||||
let key = values
|
|
||||||
.next()
|
|
||||||
.ok_or(anyhow!("expected payload key, eg. 'defaultBranch'"))?;
|
|
||||||
let key = term::args::string(&key);
|
|
||||||
|
|
||||||
let val = values
|
|
||||||
.next()
|
|
||||||
.ok_or(anyhow!("expected payload value, eg. '\"heartwood\"'"))?;
|
|
||||||
let val = val.to_string_lossy().to_string();
|
|
||||||
let val = json::from_str(val.as_str())
|
|
||||||
.map_err(|e| anyhow!("invalid JSON value `{val}`: {e}"))?;
|
|
||||||
|
|
||||||
payload.push((id, key, val));
|
|
||||||
}
|
|
||||||
Long("edit") => {
|
|
||||||
edit = true;
|
|
||||||
}
|
|
||||||
Value(val) => {
|
|
||||||
let val = term::args::rev(&val)?;
|
|
||||||
revision = Some(val);
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
return Err(anyhow!(arg.unexpected()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let op = match op.unwrap_or_default() {
|
|
||||||
OperationName::Accept => Operation::AcceptRevision {
|
|
||||||
revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
|
|
||||||
},
|
|
||||||
OperationName::Reject => Operation::RejectRevision {
|
|
||||||
revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
|
|
||||||
},
|
|
||||||
OperationName::Edit => Operation::EditRevision {
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
|
|
||||||
},
|
|
||||||
OperationName::Show => Operation::ShowRevision {
|
|
||||||
revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
|
|
||||||
},
|
|
||||||
OperationName::List => Operation::ListRevisions,
|
|
||||||
OperationName::Redact => Operation::RedactRevision {
|
|
||||||
revision: revision.ok_or_else(|| anyhow!("a revision must be provided"))?,
|
|
||||||
},
|
|
||||||
OperationName::Update => Operation::Update {
|
|
||||||
title,
|
|
||||||
description,
|
|
||||||
delegate,
|
|
||||||
rescind,
|
|
||||||
threshold,
|
|
||||||
visibility,
|
|
||||||
allow,
|
|
||||||
disallow,
|
|
||||||
payload,
|
|
||||||
edit,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
Ok((
|
|
||||||
Options {
|
|
||||||
rid,
|
|
||||||
op,
|
|
||||||
interactive,
|
|
||||||
quiet,
|
|
||||||
},
|
|
||||||
vec![],
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
|
||||||
let profile = ctx.profile()?;
|
let profile = ctx.profile()?;
|
||||||
let storage = &profile.storage;
|
let storage = &profile.storage;
|
||||||
let rid = if let Some(rid) = options.rid {
|
let rid = if let Some(rid) = args.repo {
|
||||||
rid
|
rid
|
||||||
} else {
|
} else {
|
||||||
let (_, rid) = radicle::rad::cwd()?;
|
let (_, rid) = radicle::rad::cwd()?;
|
||||||
|
|
@ -291,8 +40,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
let mut identity = Identity::load_mut(&repo)?;
|
let mut identity = Identity::load_mut(&repo)?;
|
||||||
let current = identity.current().clone();
|
let current = identity.current().clone();
|
||||||
|
|
||||||
match options.op {
|
let interactive = args.interactive();
|
||||||
Operation::AcceptRevision { revision } => {
|
let command = args.command.unwrap_or(Command::List);
|
||||||
|
|
||||||
|
match command {
|
||||||
|
Command::Accept { revision } => {
|
||||||
let revision = get(revision, &identity, &repo)?.clone();
|
let revision = get(revision, &identity, &repo)?.clone();
|
||||||
let id = revision.id;
|
let id = revision.id;
|
||||||
let signer = term::signer(&profile)?;
|
let signer = term::signer(&profile)?;
|
||||||
|
|
@ -301,10 +53,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
anyhow::bail!("cannot vote on revision that is {}", revision.state);
|
anyhow::bail!("cannot vote on revision that is {}", revision.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
if options
|
if interactive.confirm(format!("Accept revision {}?", term::format::tertiary(id))) {
|
||||||
.interactive
|
|
||||||
.confirm(format!("Accept revision {}?", term::format::tertiary(id)))
|
|
||||||
{
|
|
||||||
identity.accept(&revision.id, &signer)?;
|
identity.accept(&revision.id, &signer)?;
|
||||||
|
|
||||||
if let Some(revision) = identity.revision(&id) {
|
if let Some(revision) = identity.revision(&id) {
|
||||||
|
|
@ -314,14 +63,14 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
// TODO: Different output if canonical changed?
|
// TODO: Different output if canonical changed?
|
||||||
|
|
||||||
if !options.quiet {
|
if !args.quiet {
|
||||||
term::success!("Revision {id} accepted");
|
term::success!("Revision {id} accepted");
|
||||||
print_meta(revision, ¤t, &profile)?;
|
print_meta(revision, ¤t, &profile)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Operation::RejectRevision { revision } => {
|
Command::Reject { revision } => {
|
||||||
let revision = get(revision, &identity, &repo)?.clone();
|
let revision = get(revision, &identity, &repo)?.clone();
|
||||||
let signer = term::signer(&profile)?;
|
let signer = term::signer(&profile)?;
|
||||||
|
|
||||||
|
|
@ -329,19 +78,19 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
anyhow::bail!("cannot vote on revision that is {}", revision.state);
|
anyhow::bail!("cannot vote on revision that is {}", revision.state);
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.interactive.confirm(format!(
|
if interactive.confirm(format!(
|
||||||
"Reject revision {}?",
|
"Reject revision {}?",
|
||||||
term::format::tertiary(revision.id)
|
term::format::tertiary(revision.id)
|
||||||
)) {
|
)) {
|
||||||
identity.reject(revision.id, &signer)?;
|
identity.reject(revision.id, &signer)?;
|
||||||
|
|
||||||
if !options.quiet {
|
if !args.quiet {
|
||||||
term::success!("Revision {} rejected", revision.id);
|
term::success!("Revision {} rejected", revision.id);
|
||||||
print_meta(&revision, ¤t, &profile)?;
|
print_meta(&revision, ¤t, &profile)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Operation::EditRevision {
|
Command::Edit {
|
||||||
revision,
|
revision,
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
|
|
@ -357,11 +106,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
};
|
};
|
||||||
identity.edit(revision.id, title, description, &signer)?;
|
identity.edit(revision.id, title, description, &signer)?;
|
||||||
|
|
||||||
if !options.quiet {
|
if !args.quiet {
|
||||||
term::success!("Revision {} edited", revision.id);
|
term::success!("Revision {} edited", revision.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Operation::Update {
|
Command::Update {
|
||||||
title,
|
title,
|
||||||
description,
|
description,
|
||||||
delegate: delegates,
|
delegate: delegates,
|
||||||
|
|
@ -375,6 +124,9 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
} => {
|
} => {
|
||||||
let proposal = {
|
let proposal = {
|
||||||
let mut proposal = current.doc.clone().edit();
|
let mut proposal = current.doc.clone().edit();
|
||||||
|
let allow = allow.into_iter().collect::<BTreeSet<_>>();
|
||||||
|
let disallow = disallow.into_iter().collect::<BTreeSet<_>>();
|
||||||
|
|
||||||
proposal.threshold = threshold.unwrap_or(proposal.threshold);
|
proposal.threshold = threshold.unwrap_or(proposal.threshold);
|
||||||
|
|
||||||
let proposal = match visibility {
|
let proposal = match visibility {
|
||||||
|
|
@ -407,7 +159,11 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
update::payload(proposal, payload)?
|
// TODO(erikli): whenever `clap` starts supporting custom value parsers
|
||||||
|
// for a series of values, we can parse into `Payload` implicitly.
|
||||||
|
let payloads = args::parse_many_upserts(&payload).collect::<Result<Vec<_>, _>>()?;
|
||||||
|
|
||||||
|
update::payload(proposal, payloads)?
|
||||||
};
|
};
|
||||||
|
|
||||||
// If `--edit` is specified, the document can also be edited via a text edit.
|
// If `--edit` is specified, the document can also be edited via a text edit.
|
||||||
|
|
@ -431,7 +187,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
|
|
||||||
let proposal = update::verify(proposal)?;
|
let proposal = update::verify(proposal)?;
|
||||||
if proposal == current.doc {
|
if proposal == current.doc {
|
||||||
if !options.quiet {
|
if !args.quiet {
|
||||||
term::print(term::format::italic(
|
term::print(term::format::italic(
|
||||||
"Nothing to do. The document is up to date. See `rad inspect --identity`.",
|
"Nothing to do. The document is up to date. See `rad inspect --identity`.",
|
||||||
));
|
));
|
||||||
|
|
@ -445,7 +201,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
// Update the canonical head to point to the latest accepted revision.
|
// Update the canonical head to point to the latest accepted revision.
|
||||||
repo.set_identity_head_to(revision.id)?;
|
repo.set_identity_head_to(revision.id)?;
|
||||||
}
|
}
|
||||||
if options.quiet {
|
if args.quiet {
|
||||||
term::print(revision.id);
|
term::print(revision.id);
|
||||||
} else {
|
} else {
|
||||||
term::success!(
|
term::success!(
|
||||||
|
|
@ -455,7 +211,7 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
print(&revision, ¤t, &repo, &profile)?;
|
print(&revision, ¤t, &repo, &profile)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Operation::ListRevisions => {
|
Command::List => {
|
||||||
let mut revisions =
|
let mut revisions =
|
||||||
term::Table::<7, term::Label>::new(term::table::TableOptions::bordered());
|
term::Table::<7, term::Label>::new(term::table::TableOptions::bordered());
|
||||||
|
|
||||||
|
|
@ -489,25 +245,25 @@ pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
|
||||||
}
|
}
|
||||||
revisions.print();
|
revisions.print();
|
||||||
}
|
}
|
||||||
Operation::RedactRevision { revision } => {
|
Command::Redact { revision } => {
|
||||||
let revision = get(revision, &identity, &repo)?.clone();
|
let revision = get(revision, &identity, &repo)?.clone();
|
||||||
let signer = term::signer(&profile)?;
|
let signer = term::signer(&profile)?;
|
||||||
|
|
||||||
if revision.is_accepted() {
|
if revision.is_accepted() {
|
||||||
anyhow::bail!("cannot redact accepted revision");
|
anyhow::bail!("cannot redact accepted revision");
|
||||||
}
|
}
|
||||||
if options.interactive.confirm(format!(
|
if interactive.confirm(format!(
|
||||||
"Redact revision {}?",
|
"Redact revision {}?",
|
||||||
term::format::tertiary(revision.id)
|
term::format::tertiary(revision.id)
|
||||||
)) {
|
)) {
|
||||||
identity.redact(revision.id, &signer)?;
|
identity.redact(revision.id, &signer)?;
|
||||||
|
|
||||||
if !options.quiet {
|
if !args.quiet {
|
||||||
term::success!("Revision {} redacted", revision.id);
|
term::success!("Revision {} redacted", revision.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Operation::ShowRevision { revision } => {
|
Command::Show { revision } => {
|
||||||
let revision = get(revision, &identity, &repo)?;
|
let revision = get(revision, &identity, &repo)?;
|
||||||
let previous = revision.parent.unwrap_or(revision.id);
|
let previous = revision.parent.unwrap_or(revision.id);
|
||||||
let previous = identity
|
let previous = identity
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,326 @@
|
||||||
|
use std::io;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
use serde_json as json;
|
||||||
|
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
use radicle::cob::{Title, TypeNameParse};
|
||||||
|
use radicle::identity::doc::update::EditVisibility;
|
||||||
|
use radicle::identity::doc::update::PayloadUpsert;
|
||||||
|
use radicle::identity::doc::PayloadId;
|
||||||
|
use radicle::prelude::{Did, RepoId};
|
||||||
|
|
||||||
|
use crate::git::Rev;
|
||||||
|
|
||||||
|
use crate::terminal::Interactive;
|
||||||
|
|
||||||
|
pub(crate) const ABOUT: &str = "Manage repository identities";
|
||||||
|
const LONG_ABOUT: &str = r#"
|
||||||
|
The `id` command is used to manage and propose changes to the
|
||||||
|
identity of a Radicle repository.
|
||||||
|
|
||||||
|
See the rad-id(1) man page for more information.
|
||||||
|
"#;
|
||||||
|
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum PayloadUpsertParseError {
|
||||||
|
#[error("could not parse payload id: {0}")]
|
||||||
|
IdParse(#[from] TypeNameParse),
|
||||||
|
#[error("could not parse json value: {0}")]
|
||||||
|
Value(#[from] json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses a slice of all payload upserts as aggregated by `clap`
|
||||||
|
/// (see [`Command::Update::payload`]).
|
||||||
|
/// E.g. `["com.example.one", "name", "1", "com.example.two", "name2", "2"]`
|
||||||
|
/// will result in iterator over two [`PayloadUpsert`]s.
|
||||||
|
///
|
||||||
|
/// # Panics
|
||||||
|
///
|
||||||
|
/// If the length of `values` is not divisible by 3.
|
||||||
|
/// (To catch errors in the definition of the parser derived from
|
||||||
|
/// [`Command::Update`] or `clap` itself, and unexpected changes to
|
||||||
|
/// `clap`s behaviour in the future.)
|
||||||
|
pub(super) fn parse_many_upserts(
|
||||||
|
values: &[String],
|
||||||
|
) -> impl Iterator<Item = Result<PayloadUpsert, PayloadUpsertParseError>> + use<'_> {
|
||||||
|
// `clap` ensures we have 3 values per option occurrence,
|
||||||
|
// so we can chunk the aggregated slice exactly.
|
||||||
|
let chunks = values.chunks_exact(3);
|
||||||
|
|
||||||
|
assert!(chunks.remainder().is_empty());
|
||||||
|
|
||||||
|
chunks.map(|chunk| {
|
||||||
|
// Slice accesses will not panic, guaranteed by `chunks_exact(3)`.
|
||||||
|
Ok(PayloadUpsert {
|
||||||
|
id: PayloadId::from_str(&chunk[0])?,
|
||||||
|
key: chunk[1].to_owned(),
|
||||||
|
value: json::from_str(&chunk[2].to_owned())?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
struct EditVisibilityParser;
|
||||||
|
|
||||||
|
impl clap::builder::TypedValueParser for EditVisibilityParser {
|
||||||
|
type Value = EditVisibility;
|
||||||
|
|
||||||
|
fn parse_ref(
|
||||||
|
&self,
|
||||||
|
cmd: &clap::Command,
|
||||||
|
arg: Option<&clap::Arg>,
|
||||||
|
value: &std::ffi::OsStr,
|
||||||
|
) -> Result<Self::Value, clap::Error> {
|
||||||
|
<EditVisibility as std::str::FromStr>::from_str.parse_ref(cmd, arg, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn possible_values(
|
||||||
|
&self,
|
||||||
|
) -> Option<Box<dyn Iterator<Item = clap::builder::PossibleValue> + '_>> {
|
||||||
|
use clap::builder::PossibleValue;
|
||||||
|
Some(Box::new(
|
||||||
|
[PossibleValue::new("private"), PossibleValue::new("public")].into_iter(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Parser)]
|
||||||
|
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
|
||||||
|
pub struct Args {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub(super) command: Option<Command>,
|
||||||
|
|
||||||
|
/// Specify the repository to operate on. Defaults to the current repository
|
||||||
|
///
|
||||||
|
/// [example values: rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH, z3Tr6bC7ctEg2EHmLvknUr29mEDLH]
|
||||||
|
#[arg(long)]
|
||||||
|
#[arg(value_name = "RID", global = true)]
|
||||||
|
pub(super) repo: Option<RepoId>,
|
||||||
|
|
||||||
|
/// Do not ask for confirmation
|
||||||
|
#[arg(long)]
|
||||||
|
#[arg(global = true)]
|
||||||
|
no_confirm: bool,
|
||||||
|
|
||||||
|
/// Suppress output
|
||||||
|
#[arg(long, short)]
|
||||||
|
#[arg(global = true)]
|
||||||
|
pub(super) quiet: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Args {
|
||||||
|
pub(super) fn interactive(&self) -> Interactive {
|
||||||
|
if self.no_confirm {
|
||||||
|
Interactive::No
|
||||||
|
} else {
|
||||||
|
Interactive::new(io::stdout())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
pub(super) enum Command {
|
||||||
|
/// Accept a proposed revision to the identity document
|
||||||
|
#[clap(alias("a"))]
|
||||||
|
Accept {
|
||||||
|
/// Proposed revision to accept
|
||||||
|
#[arg(value_name = "REVISION_ID")]
|
||||||
|
revision: Rev,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Reject a proposed revision to the identity document
|
||||||
|
#[clap(alias("r"))]
|
||||||
|
Reject {
|
||||||
|
/// Proposed revision to reject
|
||||||
|
#[arg(value_name = "REVISION_ID")]
|
||||||
|
revision: Rev,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Edit an existing revision to the identity document
|
||||||
|
#[clap(alias("e"))]
|
||||||
|
Edit {
|
||||||
|
/// Proposed revision to edit
|
||||||
|
#[arg(value_name = "REVISION_ID")]
|
||||||
|
revision: Rev,
|
||||||
|
|
||||||
|
/// Title of the edit
|
||||||
|
#[arg(long)]
|
||||||
|
title: Option<Title>,
|
||||||
|
|
||||||
|
/// Description of the edit
|
||||||
|
#[arg(long)]
|
||||||
|
description: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Propose a new revision to the identity document
|
||||||
|
#[clap(alias("u"))]
|
||||||
|
Update {
|
||||||
|
/// Set the title for the new proposal
|
||||||
|
#[arg(long)]
|
||||||
|
title: Option<Title>,
|
||||||
|
|
||||||
|
/// Set the description for the new proposal
|
||||||
|
#[arg(long)]
|
||||||
|
description: Option<String>,
|
||||||
|
|
||||||
|
/// Update the identity by adding a new delegate, identified by their DID
|
||||||
|
#[arg(long, short)]
|
||||||
|
#[arg(value_name = "DID")]
|
||||||
|
#[arg(action = clap::ArgAction::Append)]
|
||||||
|
delegate: Vec<Did>,
|
||||||
|
|
||||||
|
/// Update the identity by removing a delegate, identified by their DID
|
||||||
|
#[arg(long, short)]
|
||||||
|
#[arg(value_name = "DID")]
|
||||||
|
#[arg(action = clap::ArgAction::Append)]
|
||||||
|
rescind: Vec<Did>,
|
||||||
|
|
||||||
|
/// Update the identity by setting the number of delegates required to accept a revision
|
||||||
|
#[arg(long)]
|
||||||
|
threshold: Option<usize>,
|
||||||
|
|
||||||
|
/// Update the identity by setting the repository's visibility to private or public
|
||||||
|
#[arg(long)]
|
||||||
|
#[arg(value_parser = EditVisibilityParser)]
|
||||||
|
visibility: Option<EditVisibility>,
|
||||||
|
|
||||||
|
/// Update the identity by giving a specific DID access to a private repository
|
||||||
|
#[arg(long)]
|
||||||
|
#[arg(value_name = "DID")]
|
||||||
|
#[arg(action = clap::ArgAction::Append)]
|
||||||
|
allow: Vec<Did>,
|
||||||
|
|
||||||
|
/// Update the identity by removing a specific DID's access from a private repository
|
||||||
|
#[arg(long)]
|
||||||
|
#[arg(value_name = "DID")]
|
||||||
|
#[arg(action = clap::ArgAction::Append)]
|
||||||
|
disallow: Vec<Did>,
|
||||||
|
|
||||||
|
/// Update the identity by setting metadata in one of the identity payloads
|
||||||
|
///
|
||||||
|
/// [example values: xyz.radicle.project name '"radicle-example"']
|
||||||
|
// TODO(erikili:) Value parsers do not operate on series of values, yet. This will
|
||||||
|
// change with clap v5, so we can hopefully use `Vec<Payload>`.
|
||||||
|
// - https://github.com/clap-rs/clap/discussions/5930#discussioncomment-12315889
|
||||||
|
// - https://docs.rs/clap/latest/clap/_derive/index.html#arg-types
|
||||||
|
#[arg(long)]
|
||||||
|
#[arg(value_names = ["TYPE", "KEY", "VALUE"], num_args = 3)]
|
||||||
|
payload: Vec<String>,
|
||||||
|
|
||||||
|
/// Opens your $EDITOR to edit the JSON contents directly
|
||||||
|
#[arg(long)]
|
||||||
|
edit: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Lists all proposed revisions to the identity document
|
||||||
|
#[clap(alias("l"))]
|
||||||
|
List,
|
||||||
|
|
||||||
|
/// Show a specific identity proposal
|
||||||
|
#[clap(alias("s"))]
|
||||||
|
Show {
|
||||||
|
/// Proposed revision to show
|
||||||
|
#[arg(value_name = "REVISION_ID")]
|
||||||
|
revision: Rev,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Redact a revision
|
||||||
|
#[clap(alias("d"))]
|
||||||
|
Redact {
|
||||||
|
/// Proposed revision to redact
|
||||||
|
#[arg(value_name = "REVISION_ID")]
|
||||||
|
revision: Rev,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod test {
|
||||||
|
use super::{parse_many_upserts, Args};
|
||||||
|
use clap::error::ErrorKind;
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_parse_single_payload() {
|
||||||
|
let args = Args::try_parse_from(["id", "update", "--payload", "key", "name", "value"]);
|
||||||
|
assert!(args.is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_not_parse_single_payload() {
|
||||||
|
let err = Args::try_parse_from(["id", "update", "--payload", "key", "name"]).unwrap_err();
|
||||||
|
assert_eq!(err.kind(), ErrorKind::WrongNumberOfValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_parse_multiple_payloads() {
|
||||||
|
let args = Args::try_parse_from([
|
||||||
|
"id",
|
||||||
|
"update",
|
||||||
|
"--payload",
|
||||||
|
"key_1",
|
||||||
|
"name_1",
|
||||||
|
"value_1",
|
||||||
|
"--payload",
|
||||||
|
"key_2",
|
||||||
|
"name_2",
|
||||||
|
"value_2",
|
||||||
|
]);
|
||||||
|
assert!(args.is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_not_parse_single_payloads() {
|
||||||
|
let err = Args::try_parse_from([
|
||||||
|
"id",
|
||||||
|
"update",
|
||||||
|
"--payload",
|
||||||
|
"key_1",
|
||||||
|
"name_1",
|
||||||
|
"value_1",
|
||||||
|
"--payload",
|
||||||
|
"key_2",
|
||||||
|
"name_2",
|
||||||
|
])
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(err.kind(), ErrorKind::WrongNumberOfValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_not_clobber_payload_args() {
|
||||||
|
let err = Args::try_parse_from([
|
||||||
|
"id",
|
||||||
|
"update",
|
||||||
|
"--payload",
|
||||||
|
"key_1",
|
||||||
|
"name_1",
|
||||||
|
"--payload", // ensure `--payload is not treated as an argument`
|
||||||
|
"key_2",
|
||||||
|
"name_2",
|
||||||
|
"value_2",
|
||||||
|
])
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(err.kind(), ErrorKind::WrongNumberOfValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn should_parse_into_payload() {
|
||||||
|
let payload: Result<Vec<_>, _> = parse_many_upserts(&[
|
||||||
|
"xyz.radicle.project".to_string(),
|
||||||
|
"name".to_string(),
|
||||||
|
"{}".to_string(),
|
||||||
|
])
|
||||||
|
.collect();
|
||||||
|
assert!(payload.is_ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[should_panic(expected = "assertion failed: chunks.remainder().is_empty()")]
|
||||||
|
fn should_not_parse_into_payload() {
|
||||||
|
let _: Result<Vec<_>, _> =
|
||||||
|
parse_many_upserts(&["xyz.radicle.project".to_string(), "name".to_string()]).collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -63,6 +63,7 @@ enum Commands {
|
||||||
Diff(Vec<OsString>),
|
Diff(Vec<OsString>),
|
||||||
|
|
||||||
Fork(fork::Args),
|
Fork(fork::Args),
|
||||||
|
Id(id::Args),
|
||||||
Init(init::Args),
|
Init(init::Args),
|
||||||
Issue(issue::Args),
|
Issue(issue::Args),
|
||||||
Ls(ls::Args),
|
Ls(ls::Args),
|
||||||
|
|
@ -249,7 +250,9 @@ pub(crate) fn run_other(exe: &str, args: &[OsString]) -> Result<(), Option<anyho
|
||||||
term::run_command_args::<help::Options, _>(help::HELP, help::run, args.to_vec());
|
term::run_command_args::<help::Options, _>(help::HELP, help::run, args.to_vec());
|
||||||
}
|
}
|
||||||
"id" => {
|
"id" => {
|
||||||
term::run_command_args::<id::Options, _>(id::HELP, id::run, args.to_vec());
|
if let Some(Commands::Id(args)) = CliArgs::parse().command {
|
||||||
|
term::run_command_fn(id::run, args);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
"inbox" => {
|
"inbox" => {
|
||||||
term::run_command_args::<inbox::Options, _>(inbox::HELP, inbox::run, args.to_vec())
|
term::run_command_args::<inbox::Options, _>(inbox::HELP, inbox::run, args.to_vec())
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue