cli: Show URL on `rad init`

This commit is contained in:
cloudhead 2023-11-28 14:06:10 +01:00
parent 6eb2772235
commit 13db55014b
No known key found for this signature in database
11 changed files with 355 additions and 40 deletions

View File

@ -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`.
```

View File

@ -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`.
```

View File

@ -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`.
```

View File

@ -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`.
```

View File

@ -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`.
```

View File

@ -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<()> {
#[derive(Debug)]
enum SyncResult<T> {
NodeStopped,
NoPeersConnected,
NotSynced,
Synced { result: T },
}
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()?;
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() {
if node.is_running() {
let mut spinner = term::spinner("Updating inventory..");
node.sync_inventory()?;
spinner.message("Announcing..");
node.announce_inventory()?;
spinner.message("Project successfully announced.");
spinner.finish();
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")
);
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!(

View File

@ -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",

View File

@ -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),
[],
@ -963,6 +964,93 @@ fn rad_clone_unknown() {
.unwrap();
}
#[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();

View File

@ -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))

View File

@ -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.

View File

@ -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)]