Radish alpha
h
Radicle Heartwood Protocol & Stack
Radicle
Git (anonymous pull)
Log in to clone via SSH
cli: Show URL on `rad init`
cloudhead committed 2 years ago
commit 13db55014ba33feed75fbe0269d24047addbfa06
parent 6eb2772235b997f8a506784dee6f45a846e43c41
11 files changed +354 -39
added radicle-cli/examples/rad-init-sync-not-connected.md
@@ -0,0 +1,19 @@
+
When initializing a project without any peer connections, we get this output:
+

+
```
+
$ rad init --name heartwood --description "Radicle Heartwood Protocol & Stack" --no-confirm --public --scope followed
+

+
Initializing public radicle 👾 project in .
+

+
✓ Project heartwood created.
+

+
Your project's Repository ID (RID) is rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji.
+
You can show it any time by running `rad .` from this directory.
+

+
✗ Announcing.. <canceled>
+

+
You are not connected to any peers. Your project will be announced as soon as your node establishes a connection with the network.
+
Check for peer connections with `rad node status`.
+

+
To push changes, run `git push`.
+
```
added radicle-cli/examples/rad-init-sync-preferred.md
@@ -0,0 +1,21 @@
+
Let's try initializing a new project with a preferred seed configured.
+

+
```
+
$ rad init --name heartwood --description "Radicle Heartwood Protocol & Stack" --no-confirm --public --scope followed
+

+
Initializing public radicle 👾 project in .
+

+
✓ Project heartwood created.
+

+
Your project's Repository ID (RID) is rad:z3Rry7rpdWuGpfjPYGzdJKQADsoNW.
+
You can show it any time by running `rad .` from this directory.
+

+
✓ Project successfully synced to 1 node(s).
+

+
Your project has been synced to the network and is now discoverable by peers.
+
View it in your browser at:
+

+
    https://app.radicle.xyz/nodes/[...]/rad:z3Rry7rpdWuGpfjPYGzdJKQADsoNW
+

+
To push changes, run `git push`.
+
```
added radicle-cli/examples/rad-init-sync-timeout.md
@@ -0,0 +1,20 @@
+
Sometimes, `init` will fail to sync with the network. This is not a big deal,
+
as the node will keep attempting to sync in the background.
+

+
```
+
$ rad init --name heartwood --description "Radicle Heartwood Protocol & Stack" --no-confirm --public --scope followed
+

+
Initializing public radicle 👾 project in .
+

+
✓ Project heartwood created.
+

+
Your project's Repository ID (RID) is rad:z3Rry7rpdWuGpfjPYGzdJKQADsoNW.
+
You can show it any time by running `rad .` from this directory.
+

+
✓ Project successfully announced to the network.
+

+
Your project has been announced to the network and is now discoverable by peers.
+
You can check for any nodes that have replicated your project by running `rad sync status`.
+

+
To push changes, run `git push`.
+
```
modified radicle-cli/examples/rad-init-sync.md
@@ -12,8 +12,10 @@ Initializing public radicle 👾 project in .
Your project's Repository ID (RID) is rad:z42hL2jL4XNk6K8oHQaSWfMgCL7ji.
You can show it any time by running `rad .` from this directory.

-
✓ Project successfully announced.
+
✓ Project successfully announced to the network.

Your project has been announced to the network and is now discoverable by peers.
+
You can check for any nodes that have replicated your project by running `rad sync status`.
+

To push changes, run `git push`.
```
modified radicle-cli/examples/rad-patch-pull-update.md
@@ -12,9 +12,11 @@ Initializing public radicle 👾 project in .
Your project's Repository ID (RID) is rad:zhbMU4DUXrzB8xT6qAJh6yZ7bFMK.
You can show it any time by running `rad .` from this directory.

-
✓ Project successfully announced.
+
✓ Project successfully announced to the network.

Your project has been announced to the network and is now discoverable by peers.
+
You can check for any nodes that have replicated your project by running `rad sync status`.
+

To push changes, run `git push`.
```

modified radicle-cli/src/commands/init.rs
@@ -1,23 +1,25 @@
#![allow(clippy::or_fun_call)]
#![allow(clippy::collapsible_else_if)]
+
use std::collections::HashSet;
use std::convert::TryFrom;
-
use std::env;
use std::ffi::OsString;
use std::path::PathBuf;
use std::str::FromStr;
+
use std::{env, io, time};

use anyhow::{anyhow, bail, Context as _};
use serde_json as json;

use radicle::crypto::{ssh, Verified};
use radicle::git::RefString;
-
use radicle::identity::Visibility;
+
use radicle::identity::{Id, Visibility};
use radicle::node::policy::Scope;
-
use radicle::node::{Handle, NodeId};
+
use radicle::node::{Event, Handle, NodeId};
use radicle::prelude::Doc;
use radicle::{profile, Node};

use crate as cli;
+
use crate::commands;
use crate::git;
use crate::terminal as term;
use crate::terminal::args::{Args, Error, Help};
@@ -274,7 +276,7 @@ pub fn init(options: Options, profile: &profile::Profile) -> anyhow::Result<()>
        &signer,
        &profile.storage,
    ) {
-
        Ok((id, doc, _)) => {
+
        Ok((rid, doc, _)) => {
            let proj = doc.project()?;

            spinner.message(format!(
@@ -290,7 +292,7 @@ pub fn init(options: Options, profile: &profile::Profile) -> anyhow::Result<()>
            // It's important to seed our own repositories to make sure that our node signals
            // interest for them. This ensures that messages relating to them are relayed to us.
            if options.seed {
-
                cli::project::seed(id, options.scope, &mut node, profile)?;
+
                cli::project::seed(rid, options.scope, &mut node, profile)?;
            }

            if options.set_upstream || git::branch_remote(&repo, proj.default_branch()).is_err() {
@@ -314,7 +316,7 @@ pub fn init(options: Options, profile: &profile::Profile) -> anyhow::Result<()>
            term::info!(
                "Your project's Repository ID {} is {}.",
                term::format::dim("(RID)"),
-
                term::format::highlight(id.urn())
+
                term::format::highlight(rid.urn())
            );
            term::info!(
                "You can show it any time by running {} from this directory.",
@@ -323,7 +325,7 @@ pub fn init(options: Options, profile: &profile::Profile) -> anyhow::Result<()>
            term::blank();

            // Announce inventory to network.
-
            if let Err(e) = announce(doc, &mut node) {
+
            if let Err(e) = announce(rid, doc, &mut node, &profile.config) {
                term::blank();
                term::warning(format!(
                    "There was an error announcing your project to the network: {e}"
@@ -342,28 +344,168 @@ pub fn init(options: Options, profile: &profile::Profile) -> anyhow::Result<()>
    Ok(())
}

-
pub fn announce(doc: Doc<Verified>, node: &mut Node) -> anyhow::Result<()> {
-
    if doc.visibility.is_public() {
-
        if node.is_running() {
-
            let mut spinner = term::spinner("Updating inventory..");
+
#[derive(Debug)]
+
enum SyncResult<T> {
+
    NodeStopped,
+
    NoPeersConnected,
+
    NotSynced,
+
    Synced { result: T },
+
}

-
            node.sync_inventory()?;
-
            spinner.message("Announcing..");
-
            node.announce_inventory()?;
-
            spinner.message("Project successfully announced.");
-
            spinner.finish();
+
fn sync(
+
    rid: Id,
+
    node: &mut Node,
+
    config: &profile::Config,
+
) -> Result<SyncResult<Option<String>>, radicle::node::Error> {
+
    if !node.is_running() {
+
        return Ok(SyncResult::NodeStopped);
+
    }
+
    let mut spinner = term::spinner("Updating inventory..");
+
    let events = node.subscribe(time::Duration::from_secs(3))?;
+
    let sessions = node.sessions()?;

-
            term::blank();
-
            term::info!(
-
                "Your project has been announced to the network and is \
-
                now discoverable by peers.",
-
            );
-
        } else {
-
            term::info!("Your project will be announced to the network when you start your node.");
-
            term::info!(
-
                "You can start your node with {}.",
-
                term::format::command("rad node start")
-
            );
+
    node.sync_inventory()?;
+
    spinner.message("Announcing..");
+

+
    if !sessions.iter().any(|s| s.is_connected()) {
+
        return Ok(SyncResult::NoPeersConnected);
+
    }
+

+
    // Connect to preferred seeds in case we aren't connected.
+
    for seed in &config.preferred_seeds {
+
        if !sessions.iter().any(|s| s.nid == seed.id) {
+
            commands::rad_node::control::connect(
+
                node,
+
                seed.id,
+
                seed.addr.clone(),
+
                radicle::node::DEFAULT_TIMEOUT,
+
            )
+
            .ok();
+
        }
+
    }
+
    // Announce our new inventory to connected nodes.
+
    node.announce_inventory()?;
+

+
    spinner.message("Syncing..");
+

+
    let mut replicas = HashSet::new();
+
    for e in events {
+
        match e {
+
            Ok(Event::RefsSynced {
+
                remote, rid: rid_, ..
+
            }) if rid == rid_ => {
+
                replicas.insert(remote);
+
                // If we manage to replicate to one of our preferred seeds, we can stop waiting.
+
                if config.preferred_seeds.iter().any(|s| s.id == remote) {
+
                    break;
+
                }
+
            }
+
            Ok(_) => {
+
                // Some other irrelevant event received.
+
            }
+
            Err(e) if e.kind() == io::ErrorKind::TimedOut => {
+
                break;
+
            }
+
            Err(e) => {
+
                spinner.error(&e);
+
                return Err(e.into());
+
            }
+
        }
+
    }
+

+
    if !replicas.is_empty() {
+
        spinner.message(format!(
+
            "Project successfully synced to {} node(s).",
+
            replicas.len()
+
        ));
+
        spinner.finish();
+

+
        for seed in &config.preferred_seeds {
+
            if replicas.contains(&seed.id) {
+
                return Ok(SyncResult::Synced {
+
                    result: Some(
+
                        config
+
                            .public_explorer
+
                            .url(seed.addr.host.to_string().as_str(), &rid),
+
                    ),
+
                });
+
            }
+
        }
+
        Ok(SyncResult::Synced { result: None })
+
    } else {
+
        spinner.message("Project successfully announced to the network.");
+
        spinner.finish();
+

+
        Ok(SyncResult::NotSynced)
+
    }
+
}
+

+
pub fn announce(
+
    rid: Id,
+
    doc: Doc<Verified>,
+
    node: &mut Node,
+
    config: &profile::Config,
+
) -> anyhow::Result<()> {
+
    if doc.visibility.is_public() {
+
        match sync(rid, node, config) {
+
            Ok(SyncResult::Synced {
+
                result: Some(url), ..
+
            }) => {
+
                term::blank();
+
                term::info!(
+
                    "Your project has been synced to the network and is \
+
                    now discoverable by peers.",
+
                );
+
                term::info!("View it in your browser at:");
+
                term::blank();
+
                term::indented(term::format::tertiary(url));
+
                term::blank();
+
            }
+
            Ok(SyncResult::Synced { result: None, .. }) => {
+
                term::blank();
+
                term::info!(
+
                    "Your project has been synced to the network and is \
+
                    now discoverable by peers.",
+
                );
+
                if !config.preferred_seeds.is_empty() {
+
                    term::info!(
+
                        "Unfortunately, you were unable to replicate your project to \
+
                        your preferred seeds."
+
                    );
+
                }
+
            }
+
            Ok(SyncResult::NotSynced) => {
+
                term::blank();
+
                term::info!(
+
                    "Your project has been announced to the network and is \
+
                    now discoverable by peers.",
+
                );
+
                term::info!(
+
                    "You can check for any nodes that have replicated your project by running \
+
                    `rad sync status`."
+
                );
+
                term::blank();
+
            }
+
            Ok(SyncResult::NoPeersConnected) => {
+
                term::blank();
+
                term::info!(
+
                    "You are not connected to any peers. Your project will be announced as soon as \
+
                    your node establishes a connection with the network.");
+
                term::info!("Check for peer connections with `rad node status`.");
+
                term::blank();
+
            }
+
            Ok(SyncResult::NodeStopped) => {
+
                term::info!(
+
                    "Your project will be announced to the network when you start your node."
+
                );
+
                term::info!(
+
                    "You can start your node with {}.",
+
                    term::format::command("rad node start")
+
                );
+
            }
+
            Err(e) => {
+
                return Err(e.into());
+
            }
        }
    } else {
        term::info!(
modified radicle-cli/src/commands/node.rs
@@ -11,13 +11,13 @@ use crate::terminal::args::{Args, Error, Help};
use crate::terminal::Element as _;

#[path = "node/control.rs"]
-
mod control;
+
pub mod control;
#[path = "node/events.rs"]
-
mod events;
+
pub mod events;
#[path = "node/policies.rs"]
-
mod policies;
+
pub mod policies;
#[path = "node/routing.rs"]
-
mod routing;
+
pub mod routing;

pub const HELP: Help = Help {
    name: "node",
modified radicle-cli/tests/commands.rs
@@ -9,6 +9,7 @@ use radicle::node::routing::Store as _;
use radicle::node::Handle as _;
use radicle::node::{Alias, DEFAULT_TIMEOUT};
use radicle::prelude::Id;
+
use radicle::profile;
use radicle::profile::Home;
use radicle::storage::{ReadStorage, RemoteRepository};
use radicle::test::fixtures;
@@ -16,7 +17,7 @@ use radicle::test::fixtures;
use radicle_cli_test::TestFormula;
use radicle_node::service::policy::{Policy, Scope};
use radicle_node::service::Event;
-
use radicle_node::test::environment::{Config, Environment};
+
use radicle_node::test::environment::{Config, Environment, Node};
#[allow(unused_imports)]
use radicle_node::test::logger;

@@ -432,7 +433,7 @@ fn rad_node() {
    fixtures::repository(working.path().join("alice"));

    test(
-
        "examples/rad-init-sync.md",
+
        "examples/rad-init-sync-not-connected.md",
        &working.path().join("alice"),
        Some(&alice.home),
        [],
@@ -964,6 +965,93 @@ fn rad_clone_unknown() {
}

#[test]
+
fn rad_init_sync_not_connected() {
+
    let mut environment = Environment::new();
+
    let alice = environment.node(Config::test(Alias::new("alice")));
+
    let working = tempfile::tempdir().unwrap();
+
    let alice = alice.spawn();
+

+
    fixtures::repository(working.path().join("alice"));
+

+
    test(
+
        "examples/rad-init-sync-not-connected.md",
+
        &working.path().join("alice"),
+
        Some(&alice.home),
+
        [],
+
    )
+
    .unwrap();
+
}
+

+
#[test]
+
fn rad_init_sync_preferred() {
+
    let mut environment = Environment::new();
+
    let mut alice = environment
+
        .node(Config {
+
            policy: Policy::Allow,
+
            ..Config::test(Alias::new("alice"))
+
        })
+
        .spawn();
+

+
    let bob = environment.profile(profile::Config {
+
        preferred_seeds: vec![alice.address()],
+
        ..config::profile("bob")
+
    });
+
    let mut bob = Node::new(bob).spawn();
+
    let working = environment.tmp().join("working");
+

+
    bob.connect(&alice);
+
    alice.handle.follow(bob.id, None).unwrap();
+

+
    fixtures::repository(working.join("bob"));
+

+
    // Necessary for now, if we don't want the new inventry announcement to be considered stale
+
    // for Alice.
+
    // TODO: Find a way to advance internal clocks instead.
+
    thread::sleep(time::Duration::from_millis(3));
+

+
    // Bob initializes a repo after her node has started, and after bob has connected to it.
+
    test(
+
        "examples/rad-init-sync-preferred.md",
+
        &working.join("bob"),
+
        Some(&bob.home),
+
        [],
+
    )
+
    .unwrap();
+
}
+

+
#[test]
+
fn rad_init_sync_timeout() {
+
    let mut environment = Environment::new();
+
    let mut alice = environment
+
        .node(Config {
+
            policy: Policy::Block,
+
            ..Config::test(Alias::new("alice"))
+
        })
+
        .spawn();
+

+
    let bob = environment.profile(profile::Config {
+
        preferred_seeds: vec![alice.address()],
+
        ..config::profile("bob")
+
    });
+
    let mut bob = Node::new(bob).spawn();
+
    let working = environment.tmp().join("working");
+

+
    bob.connect(&alice);
+
    alice.handle.follow(bob.id, None).unwrap();
+

+
    fixtures::repository(working.join("bob"));
+

+
    // Bob initializes a repo after her node has started, and after bob has connected to it.
+
    test(
+
        "examples/rad-init-sync-timeout.md",
+
        &working.join("bob"),
+
        Some(&bob.home),
+
        [],
+
    )
+
    .unwrap();
+
}
+

+
#[test]
fn rad_init_sync_and_clone() {
    let mut environment = Environment::new();
    let alice = environment.node(Config::test(Alias::new("alice")));
modified radicle-node/src/test/environment.rs
@@ -17,6 +17,7 @@ use radicle::crypto::{KeyPair, Seed, Signer};
use radicle::git;
use radicle::git::refname;
use radicle::identity::{Id, Visibility};
+
use radicle::node::config::ConnectAddress;
use radicle::node::policy::store as policy;
use radicle::node::routing::Store;
use radicle::node::Database;
@@ -152,7 +153,7 @@ pub struct Node<G> {
}

impl Node<MemorySigner> {
-
    fn new(profile: Profile) -> Self {
+
    pub fn new(profile: Profile) -> Self {
        let signer = MemorySigner::load(&profile.keystore, None).unwrap();
        let id = *profile.id();
        let policies_db = profile.home.node().join(POLICIES_DB_FILE);
@@ -227,6 +228,11 @@ impl<G: Signer + cyphernet::Ecdh> NodeHandle<G> {
        self
    }

+
    /// Get the full address of this node.
+
    pub fn address(&self) -> ConnectAddress {
+
        (self.id, node::Address::from(self.addr)).into()
+
    }
+

    /// Get routing table entries.
    pub fn routing(&self) -> impl Iterator<Item = (Id, NodeId)> {
        Database::reader(self.home.node().join(node::NODE_DB_FILE))
modified radicle-term/src/spinner.rs
@@ -1,4 +1,4 @@
-
use std::io::Write;
+
use std::io::{IsTerminal, Write};
use std::mem::ManuallyDrop;
use std::sync::{Arc, Mutex};
use std::{fmt, io, thread, time};
@@ -105,7 +105,13 @@ impl Spinner {
/// Create a new spinner with the given message. Sends animation output to `stderr` and success or
/// failure messages to `stdout`.
pub fn spinner(message: impl ToString) -> Spinner {
-
    spinner_to(message, io::stdout(), io::stderr())
+
    let (stdout, stderr) = (io::stdout(), io::stderr());
+

+
    if stderr.is_terminal() {
+
        spinner_to(message, stdout, stderr)
+
    } else {
+
        spinner_to(message, stdout, io::sink())
+
    }
}

/// Create a new spinner with the given message, and send output to the given writers.
modified radicle/src/node.rs
@@ -275,7 +275,7 @@ impl FromStr for Alias {
}

/// Options passed to the "connect" node command.
-
#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ConnectOptions {
    /// Establish a persistent connection.
    pub persistent: bool,
@@ -283,6 +283,15 @@ pub struct ConnectOptions {
    pub timeout: time::Duration,
}

+
impl Default for ConnectOptions {
+
    fn default() -> Self {
+
        Self {
+
            persistent: false,
+
            timeout: DEFAULT_TIMEOUT,
+
        }
+
    }
+
}
+

/// Result of a command, on the node control socket.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]