Radish alpha
h
rad:z3gqcJUoA1n9HaHKufZs5FCSGazv5
Radicle Heartwood Protocol & Stack
Radicle
Git
cli: interactive `rad publish`
Open fintohaps opened 2 months ago

Since rad publish is an irreversible action, add a confirmation flow to make sure the user is asked if they are sure before running.

The confirmation prompt looks like:

? 
! rad:z4ZMHfa9vubWdx7bkRBaRxgZwFBtW is about to be made public which makes it available to the Radicle network.
! Are you sure you want to make rad:z4ZMHfa9vubWdx7bkRBaRxgZwFBtW public? (Y/n)
9 files changed +129 -45 423cf604 788d54c5
modified crates/radicle-cli/src/commands/clean.rs
@@ -17,7 +17,7 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
        anyhow::bail!("repository {rid} was not found");
    }

-
    if args.no_confirm || term::confirm(format!("Clean {rid}?")) {
+
    if args.no_confirm || term::confirm(format!("Clean {rid}?"), term::DefaultConfirmation::No) {
        let cleaned = storage.clean(rid)?;
        for remote in cleaned {
            term::info!("Removed {remote}");
modified crates/radicle-cli/src/commands/id.rs
@@ -53,7 +53,10 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
                anyhow::bail!("cannot vote on revision that is {}", revision.state);
            }

-
            if interactive.confirm(format!("Accept revision {}?", term::format::tertiary(id))) {
+
            if interactive.confirm(
+
                format!("Accept revision {}?", term::format::tertiary(id)),
+
                term::DefaultConfirmation::No,
+
            ) {
                identity.accept(&revision.id, &signer)?;

                if let Some(revision) = identity.revision(&id) {
@@ -78,10 +81,10 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
                anyhow::bail!("cannot vote on revision that is {}", revision.state);
            }

-
            if interactive.confirm(format!(
-
                "Reject revision {}?",
-
                term::format::tertiary(revision.id)
-
            )) {
+
            if interactive.confirm(
+
                format!("Reject revision {}?", term::format::tertiary(revision.id)),
+
                term::DefaultConfirmation::No,
+
            ) {
                identity.reject(revision.id, &signer)?;

                if !args.quiet {
@@ -258,10 +261,10 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
            if revision.is_accepted() {
                anyhow::bail!("cannot redact accepted revision");
            }
-
            if interactive.confirm(format!(
-
                "Redact revision {}?",
-
                term::format::tertiary(revision.id)
-
            )) {
+
            if interactive.confirm(
+
                format!("Redact revision {}?", term::format::tertiary(revision.id)),
+
                term::DefaultConfirmation::No,
+
            ) {
                identity.redact(revision.id, &signer)?;

                if !args.quiet {
modified crates/radicle-cli/src/commands/init.rs
@@ -500,11 +500,14 @@ pub fn setup_signing(
        ));
        true
    } else if interactive.yes() {
-
        term::confirm(format!(
-
            "Configure radicle signing key {} in {}?",
-
            term::format::tertiary(key),
-
            term::format::tertiary(config.display()),
-
        ))
+
        term::confirm(
+
            format!(
+
                "Configure radicle signing key {} in {}?",
+
                term::format::tertiary(key),
+
                term::format::tertiary(config.display()),
+
            ),
+
            term::DefaultConfirmation::Yes,
+
        )
    } else {
        true
    };
@@ -536,7 +539,10 @@ pub fn setup_signing(

                if ssh_keys.contains(&ssh_key) {
                    term::success!("Signing key is already in {gitsigners} file");
-
                } else if term::confirm(format!("Add signing key to {gitsigners}?")) {
+
                } else if term::confirm(
+
                    format!("Add signing key to {gitsigners}?"),
+
                    term::DefaultConfirmation::Yes,
+
                ) {
                    git::add_gitsigners(repo, [node_id])?;
                }
            }
modified crates/radicle-cli/src/commands/publish.rs
@@ -6,6 +6,7 @@ use radicle::cob;
use radicle::identity::{Identity, Visibility};
use radicle::node::Handle as _;
use radicle::storage::{SignRepository, ValidateRepository, WriteRepository, WriteStorage};
+
use radicle_term::PREFIX_WARNING;

use crate::terminal as term;

@@ -44,29 +45,57 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {
    let signer = profile.signer()?;

    // Update identity document.
-
    let doc = doc.clone().with_edits(|doc| {
+
    let proposal = doc.clone().with_edits(|doc| {
        doc.visibility = Visibility::Public;
    })?;

-
    // SAFETY: the `Title` here is guaranteed to be nonempty and does not
-
    // contain `\n` or `\r`.
-
    #[allow(clippy::unwrap_used)]
-
    identity.update(
-
        cob::Title::new("Publish repository").unwrap(),
-
        "",
-
        &doc,
-
        &signer,
-
    )?;
-
    repo.sign_refs(&signer)?;
-
    repo.set_identity_head()?;
-
    let validations = repo.validate()?;
-

-
    if !validations.is_empty() {
-
        for err in validations {
-
            term::error(format!("validation error: {err}"));
+
    let interactive = args.interactive();
+

+
    if interactive.yes() {
+
        term::info!(
+
            "{PREFIX_WARNING} You are about to change the visibility of {rid} from '{}' to '{}'",
+
            term::format::visibility(doc.visibility()),
+
            term::format::visibility(proposal.visibility())
+
        );
+
        term::info!(
+
            "{PREFIX_WARNING} Once published, any node on the Radicle network will be allowed to fetch it."
+
        );
+
        term::info!(
+
            "{PREFIX_WARNING} You are currently connected to the network '{}'",
+
            term::format::network(&profile.config.node.network)
+
        );
+
    }
+
    if interactive.confirm(
+
        format!("Are you sure you want to publish {rid}?"),
+
        term::DefaultConfirmation::No,
+
    ) {
+
        // SAFETY: the `Title` here is guaranteed to be nonempty and does not
+
        // contain `\n` or `\r`.
+
        #[allow(clippy::unwrap_used)]
+
        identity.update(
+
            cob::Title::new("Publish repository").unwrap(),
+
            "",
+
            &proposal,
+
            &signer,
+
        )?;
+
        repo.sign_refs(&signer)?;
+
        repo.set_identity_head()?;
+
        let validations = repo.validate()?;
+

+
        if !validations.is_empty() {
+
            for err in validations {
+
                term::error(format!("validation error: {err}"));
+
            }
+
            anyhow::bail!("fatal: repository storage is corrupt");
        }
-
        anyhow::bail!("fatal: repository storage is corrupt");
+
    } else {
+
        term::success!(
+
            "Repository will remain {}",
+
            term::format::visibility(doc.visibility())
+
        );
+
        return Ok(());
    }
+

    let mut node = radicle::Node::new(profile.socket());
    let spinner = term::spinner("Updating inventory..");

@@ -76,7 +105,7 @@ pub fn run(args: Args, ctx: impl term::Context) -> anyhow::Result<()> {

    term::success!(
        "Repository is now {}",
-
        term::format::visibility(doc.visibility())
+
        term::format::visibility(proposal.visibility())
    );

    if !node.is_running() {
modified crates/radicle-cli/src/commands/publish/args.rs
@@ -1,4 +1,9 @@
+
use std::io;
+

+
use clap::Parser;
+

use radicle::identity::RepoId;
+
use radicle_term::Interactive;

const ABOUT: &str = "Publish a repository to the network";

@@ -14,7 +19,7 @@ single delegate. The delegate must be the currently authenticated
user. For repositories with more than one delegate, the `rad id`
command must be used."#;

-
#[derive(Debug, clap::Parser)]
+
#[derive(Debug, Parser)]
#[command(about = ABOUT, long_about = LONG_ABOUT, disable_version_flag = true)]
pub struct Args {
    /// The Repository ID of the repository to publish
@@ -22,6 +27,21 @@ pub struct Args {
    /// [example values: rad:z3Tr6bC7ctEg2EHmLvknUr29mEDLH, z3Tr6bC7ctEg2EHmLvknUr29mEDLH]
    #[arg(value_name = "RID")]
    pub(super) rid: Option<RepoId>,
+

+
    /// Do not ask for confirmation to publish the repository.
+
    #[arg(long)]
+
    #[arg(global = true)]
+
    no_confirm: bool,
+
}
+

+
impl Args {
+
    pub(super) fn interactive(&self) -> Interactive {
+
        if self.no_confirm {
+
            Interactive::No
+
        } else {
+
            Interactive::new(io::stdout())
+
        }
+
    }
}

#[cfg(test)]
modified crates/radicle-cli/src/terminal/format.rs
@@ -7,6 +7,7 @@ pub use radicle_term::{style, Paint};

use radicle::cob::ObjectId;
use radicle::identity::Visibility;
+
use radicle::node::config::Network;
use radicle::node::policy::Policy;
use radicle::node::{Address, Alias, AliasStore, HostName, NodeId};
use radicle::prelude::Did;
@@ -105,6 +106,12 @@ pub fn policy(p: &Policy) -> Paint<String> {
    }
}

+
/// Format a network name.
+
#[must_use]
+
pub fn network(n: &Network) -> Paint<String> {
+
    bold(term::format::secondary(n).to_string())
+
}
+

/// Format a timestamp.
pub fn timestamp(time: impl Into<LocalTime>) -> Paint<String> {
    let time = time.into();
modified crates/radicle-term/src/io.rs
@@ -11,7 +11,7 @@ use inquire::{ui::Color, ui::RenderConfig, Confirm, CustomType, Password};
use thiserror::Error;
use zeroize::Zeroizing;

-
use crate::format;
+
use crate::{format, DefaultConfirmation};
use crate::{style, Paint, Size};

pub use inquire;
@@ -238,12 +238,11 @@ pub fn ask<D: fmt::Display>(prompt: D, default: bool) -> bool {
        .unwrap_or_default()
}

-
pub fn confirm<D: fmt::Display>(prompt: D) -> bool {
-
    ask(prompt, true)
-
}
-

-
pub fn abort<D: fmt::Display>(prompt: D) -> bool {
-
    ask(prompt, false)
+
pub fn confirm<D: fmt::Display>(prompt: D, default: DefaultConfirmation) -> bool {
+
    match default {
+
        DefaultConfirmation::Yes => ask(prompt, true),
+
        DefaultConfirmation::No => ask(prompt, false),
+
    }
}

#[non_exhaustive]
modified crates/radicle-term/src/lib.rs
@@ -35,6 +35,17 @@ pub enum Interactive {
    No,
}

+
/// When asking for interactive confirmation from the user, choose which default
+
/// should be chosen when the user presses enter with no input.
+
#[derive(Debug, PartialEq, Eq, Copy, Clone, Default)]
+
pub enum DefaultConfirmation {
+
    /// Equivalent to `Y/n`.
+
    Yes,
+
    /// Equivalent to `y/N`.
+
    #[default]
+
    No,
+
}
+

impl Interactive {
    pub fn new(term: impl IsTerminal) -> Self {
        Self::from(term.is_terminal())
@@ -48,9 +59,9 @@ impl Interactive {
        !self.yes()
    }

-
    pub fn confirm(&self, prompt: impl fmt::Display) -> bool {
+
    pub fn confirm(&self, prompt: impl fmt::Display, default: DefaultConfirmation) -> bool {
        if self.yes() {
-
            confirm(prompt)
+
            confirm(prompt, default)
        } else {
            true
        }
modified crates/radicle/src/node/config.rs
@@ -78,6 +78,15 @@ pub enum Network {
    Test,
}

+
impl fmt::Display for Network {
+
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+
        match self {
+
            Network::Main => f.write_str("main"),
+
            Network::Test => f.write_str("test"),
+
        }
+
    }
+
}
+

impl Network {
    /// Bootstrap nodes for this network.
    pub fn bootstrap(&self) -> Vec<(Alias, ProtocolVersion, Vec<ConnectAddress>)> {