radicle_cli/commands/
init.rs

1#![allow(clippy::or_fun_call)]
2#![allow(clippy::collapsible_else_if)]
3use std::collections::HashSet;
4use std::convert::TryFrom;
5use std::env;
6use std::ffi::OsString;
7use std::path::PathBuf;
8use std::str::FromStr;
9
10use anyhow::{anyhow, bail, Context as _};
11use serde_json as json;
12
13use radicle::crypto::ssh;
14use radicle::explorer::ExplorerUrl;
15use radicle::git::RefString;
16use radicle::identity::project::ProjectName;
17use radicle::identity::{Doc, RepoId, Visibility};
18use radicle::node::events::UploadPack;
19use radicle::node::policy::Scope;
20use radicle::node::{Event, Handle, NodeId, DEFAULT_SUBSCRIBE_TIMEOUT};
21use radicle::storage::ReadStorage as _;
22use radicle::{profile, Node};
23
24use crate::commands;
25use crate::git;
26use crate::terminal as term;
27use crate::terminal::args::{Args, Error, Help};
28use crate::terminal::Interactive;
29
30pub const HELP: Help = Help {
31    name: "init",
32    description: "Initialize a Radicle repository",
33    version: env!("RADICLE_VERSION"),
34    usage: r#"
35Usage
36
37    rad init [<path>] [<option>...]
38
39Options
40
41        --name <string>            Name of the repository
42        --description <string>     Description of the repository
43        --default-branch <name>    The default branch of the repository
44        --scope <scope>            Repository follow scope: `followed` or `all` (default: all)
45        --private                  Set repository visibility to *private*
46        --public                   Set repository visibility to *public*
47        --existing <rid>           Setup repository as an existing Radicle repository
48    -u, --set-upstream             Setup the upstream of the default branch
49        --setup-signing            Setup the radicle key as a signing key for this repository
50        --no-confirm               Don't ask for confirmation during setup
51        --no-seed                  Don't seed this repository after initializing it
52    -v, --verbose                  Verbose mode
53        --help                     Print help
54"#,
55};
56
57#[derive(Default)]
58pub struct Options {
59    pub path: Option<PathBuf>,
60    pub name: Option<ProjectName>,
61    pub description: Option<String>,
62    pub branch: Option<String>,
63    pub interactive: Interactive,
64    pub visibility: Option<Visibility>,
65    pub existing: Option<RepoId>,
66    pub setup_signing: bool,
67    pub scope: Scope,
68    pub set_upstream: bool,
69    pub verbose: bool,
70    pub seed: bool,
71}
72
73impl Args for Options {
74    fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
75        use lexopt::prelude::*;
76
77        let mut parser = lexopt::Parser::from_args(args);
78        let mut path: Option<PathBuf> = None;
79
80        let mut name = None;
81        let mut description = None;
82        let mut branch = None;
83        let mut interactive = Interactive::Yes;
84        let mut set_upstream = false;
85        let mut setup_signing = false;
86        let mut scope = Scope::All;
87        let mut existing = None;
88        let mut seed = true;
89        let mut verbose = false;
90        let mut visibility = None;
91
92        while let Some(arg) = parser.next()? {
93            match arg {
94                Long("name") if name.is_none() => {
95                    let value = parser.value()?;
96                    let value = term::args::string(&value);
97                    let value = ProjectName::try_from(value)?;
98
99                    name = Some(value);
100                }
101                Long("description") if description.is_none() => {
102                    let value = parser
103                        .value()?
104                        .to_str()
105                        .ok_or(anyhow::anyhow!(
106                            "invalid repository description specified with `--description`"
107                        ))?
108                        .to_owned();
109
110                    description = Some(value);
111                }
112                Long("default-branch") if branch.is_none() => {
113                    let value = parser
114                        .value()?
115                        .to_str()
116                        .ok_or(anyhow::anyhow!(
117                            "invalid branch specified with `--default-branch`"
118                        ))?
119                        .to_owned();
120
121                    branch = Some(value);
122                }
123                Long("scope") => {
124                    let value = parser.value()?;
125
126                    scope = term::args::parse_value("scope", value)?;
127                }
128                Long("set-upstream") | Short('u') => {
129                    set_upstream = true;
130                }
131                Long("setup-signing") => {
132                    setup_signing = true;
133                }
134                Long("no-confirm") => {
135                    interactive = Interactive::No;
136                }
137                Long("no-seed") => {
138                    seed = false;
139                }
140                Long("private") => {
141                    visibility = Some(Visibility::private([]));
142                }
143                Long("public") => {
144                    visibility = Some(Visibility::Public);
145                }
146                Long("existing") if existing.is_none() => {
147                    let val = parser.value()?;
148                    let rid = term::args::rid(&val)?;
149
150                    existing = Some(rid);
151                }
152                Long("verbose") | Short('v') => {
153                    verbose = true;
154                }
155                Long("help") | Short('h') => {
156                    return Err(Error::Help.into());
157                }
158                Value(val) if path.is_none() => {
159                    path = Some(val.into());
160                }
161                _ => return Err(anyhow::anyhow!(arg.unexpected())),
162            }
163        }
164
165        Ok((
166            Options {
167                path,
168                name,
169                description,
170                branch,
171                scope,
172                existing,
173                interactive,
174                set_upstream,
175                setup_signing,
176                seed,
177                visibility,
178                verbose,
179            },
180            vec![],
181        ))
182    }
183}
184
185pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
186    let profile = ctx.profile()?;
187    let cwd = env::current_dir()?;
188    let path = options.path.as_deref().unwrap_or(cwd.as_path());
189    let repo = match git::Repository::open(path) {
190        Ok(r) => r,
191        Err(e) if radicle::git::ext::is_not_found_err(&e) => {
192            anyhow::bail!("a Git repository was not found at the given path")
193        }
194        Err(e) => return Err(e.into()),
195    };
196    if let Ok((remote, _)) = git::rad_remote(&repo) {
197        if let Some(remote) = remote.url() {
198            bail!("repository is already initialized with remote {remote}");
199        }
200    }
201
202    if let Some(rid) = options.existing {
203        init_existing(repo, rid, options, &profile)
204    } else {
205        init(repo, options, &profile)
206    }
207}
208
209pub fn init(
210    repo: git::Repository,
211    options: Options,
212    profile: &profile::Profile,
213) -> anyhow::Result<()> {
214    let path = repo
215        .workdir()
216        .unwrap_or_else(|| repo.path())
217        .canonicalize()?;
218    let interactive = options.interactive;
219    let head: String = repo
220        .head()
221        .ok()
222        .and_then(|head| head.shorthand().map(|h| h.to_owned()))
223        .ok_or_else(|| anyhow!("repository head must point to a commit"))?;
224
225    term::headline(format!(
226        "Initializing{}radicle 👾 repository in {}..",
227        if let Some(visibility) = &options.visibility {
228            term::format::spaced(term::format::visibility(visibility))
229        } else {
230            term::format::default(" ").into()
231        },
232        term::format::dim(path.display())
233    ));
234
235    let name: ProjectName = match options.name {
236        Some(name) => name,
237        None => {
238            let default = path.file_name().map(|f| f.to_string_lossy().to_string());
239            term::input(
240                "Name",
241                default,
242                Some("The name of your repository, eg. 'acme'"),
243            )?
244            .try_into()?
245        }
246    };
247    let description = match options.description {
248        Some(desc) => desc,
249        None => term::input("Description", None, Some("You may leave this blank"))?,
250    };
251    let branch = match options.branch {
252        Some(branch) => branch,
253        None if interactive.yes() => term::input(
254            "Default branch",
255            Some(head),
256            Some("Please specify an existing branch"),
257        )?,
258        None => head,
259    };
260    let branch = RefString::try_from(branch.clone())
261        .map_err(|e| anyhow!("invalid branch name {:?}: {}", branch, e))?;
262    let visibility = if let Some(v) = options.visibility {
263        v
264    } else {
265        let selected = term::select(
266            "Visibility",
267            &["public", "private"],
268            "Public repositories are accessible by anyone on the network after initialization",
269        )?;
270        Visibility::from_str(selected)?
271    };
272
273    let signer = term::signer(profile)?;
274    let mut node = radicle::Node::new(profile.socket());
275    let mut spinner = term::spinner("Initializing...");
276    let mut push_cmd = String::from("git push");
277
278    match radicle::rad::init(
279        &repo,
280        name,
281        &description,
282        branch.clone(),
283        visibility,
284        &signer,
285        &profile.storage,
286    ) {
287        Ok((rid, doc, _)) => {
288            let proj = doc.project()?;
289
290            spinner.message(format!(
291                "Repository {} created.",
292                term::format::highlight(proj.name())
293            ));
294            spinner.finish();
295
296            if options.verbose {
297                term::blob(json::to_string_pretty(&proj)?);
298            }
299            // It's important to seed our own repositories to make sure that our node signals
300            // interest for them. This ensures that messages relating to them are relayed to us.
301            if options.seed {
302                profile.seed(rid, options.scope, &mut node)?;
303
304                if doc.is_public() {
305                    profile.add_inventory(rid, &mut node)?;
306                }
307            }
308
309            if options.set_upstream || git::branch_remote(&repo, proj.default_branch()).is_err() {
310                // Setup eg. `master` -> `rad/master`
311                radicle::git::set_upstream(
312                    &repo,
313                    &*radicle::rad::REMOTE_NAME,
314                    proj.default_branch(),
315                    radicle::git::refs::workdir::branch(proj.default_branch()),
316                )?;
317            } else {
318                push_cmd = format!("git push {} {branch}", *radicle::rad::REMOTE_NAME);
319            }
320
321            if options.setup_signing {
322                // Setup radicle signing key.
323                self::setup_signing(profile.id(), &repo, interactive)?;
324            }
325
326            term::blank();
327            term::info!(
328                "Your Repository ID {} is {}.",
329                term::format::dim("(RID)"),
330                term::format::highlight(rid.urn())
331            );
332            let directory = if path == env::current_dir()? {
333                "this directory".to_owned()
334            } else {
335                term::format::tertiary(path.display()).to_string()
336            };
337            term::info!(
338                "You can show it any time by running {} from {directory}.",
339                term::format::command("rad .")
340            );
341            term::blank();
342
343            // Announce inventory to network.
344            if let Err(e) = announce(rid, doc, &mut node, &profile.config) {
345                term::blank();
346                term::warning(format!(
347                    "There was an error announcing your repository to the network: {e}"
348                ));
349                term::warning("Try again with `rad sync --announce`, or check your logs with `rad node logs`.");
350                term::blank();
351            }
352            term::info!("To push changes, run {}.", term::format::command(push_cmd));
353        }
354        Err(err) => {
355            spinner.failed();
356            anyhow::bail!(err);
357        }
358    }
359
360    Ok(())
361}
362
363pub fn init_existing(
364    working: git::Repository,
365    rid: RepoId,
366    options: Options,
367    profile: &profile::Profile,
368) -> anyhow::Result<()> {
369    let stored = profile.storage.repository(rid)?;
370    let project = stored.project()?;
371    let url = radicle::git::Url::from(rid);
372
373    radicle::git::configure_repository(&working)?;
374    radicle::git::configure_remote(
375        &working,
376        &radicle::rad::REMOTE_NAME,
377        &url,
378        &url.clone().with_namespace(profile.public_key),
379    )?;
380
381    if options.set_upstream {
382        // Setup eg. `master` -> `rad/master`
383        radicle::git::set_upstream(
384            &working,
385            &*radicle::rad::REMOTE_NAME,
386            project.default_branch(),
387            radicle::git::refs::workdir::branch(project.default_branch()),
388        )?;
389    }
390
391    term::success!(
392        "Initialized existing repository {} in {}..",
393        term::format::tertiary(rid),
394        term::format::dim(
395            working
396                .workdir()
397                .unwrap_or_else(|| working.path())
398                .display()
399        ),
400    );
401
402    Ok(())
403}
404
405#[derive(Debug)]
406enum SyncResult<T> {
407    NodeStopped,
408    NoPeersConnected,
409    NotSynced,
410    Synced { result: T },
411}
412
413fn sync(
414    rid: RepoId,
415    node: &mut Node,
416    config: &profile::Config,
417) -> Result<SyncResult<Option<ExplorerUrl>>, radicle::node::Error> {
418    if !node.is_running() {
419        return Ok(SyncResult::NodeStopped);
420    }
421    let mut spinner = term::spinner("Updating inventory..");
422    // N.b. indefinitely subscribe to events and set a lower timeout on events
423    // below.
424    let events = node.subscribe(DEFAULT_SUBSCRIBE_TIMEOUT)?;
425    let sessions = node.sessions()?;
426
427    spinner.message("Announcing..");
428
429    if !sessions.iter().any(|s| s.is_connected()) {
430        return Ok(SyncResult::NoPeersConnected);
431    }
432
433    // Connect to preferred seeds in case we aren't connected.
434    for seed in config.preferred_seeds.iter() {
435        if !sessions.iter().any(|s| s.nid == seed.id) {
436            commands::rad_node::control::connect(
437                node,
438                seed.id,
439                seed.addr.clone(),
440                radicle::node::DEFAULT_TIMEOUT,
441            )
442            .ok();
443        }
444    }
445    // Announce our new inventory to connected nodes.
446    node.announce_inventory()?;
447
448    spinner.message("Syncing..");
449
450    let mut replicas = HashSet::new();
451    // Start upload pack as None and set it if we encounter an event
452    let mut upload_pack = term::upload_pack::UploadPack::new();
453
454    for e in events {
455        match e {
456            Ok(Event::RefsSynced {
457                remote, rid: rid_, ..
458            }) if rid == rid_ => {
459                term::success!("Repository successfully synced to {remote}");
460                replicas.insert(remote);
461                // If we manage to replicate to one of our preferred seeds, we can stop waiting.
462                if config.preferred_seeds.iter().any(|s| s.id == remote) {
463                    break;
464                }
465            }
466            Ok(Event::UploadPack(UploadPack::Write {
467                rid: rid_,
468                remote,
469                progress,
470            })) if rid == rid_ => {
471                log::debug!("Upload progress for {remote}: {progress}");
472            }
473            Ok(Event::UploadPack(UploadPack::PackProgress {
474                rid: rid_,
475                remote,
476                transmitted,
477            })) if rid == rid_ => spinner.message(upload_pack.transmitted(remote, transmitted)),
478            Ok(Event::UploadPack(UploadPack::Done {
479                rid: rid_,
480                remote,
481                status,
482            })) if rid == rid_ => {
483                log::debug!("Upload done for {rid} to {remote} with status: {status}");
484                spinner.message(upload_pack.done(&remote));
485            }
486            Ok(Event::UploadPack(UploadPack::Error {
487                rid: rid_,
488                remote,
489                err,
490            })) if rid == rid_ => {
491                term::warning(format!("Upload error for {rid} to {remote}: {err}"));
492            }
493            Ok(_) => {
494                // Some other irrelevant event received.
495            }
496            Err(radicle::node::Error::TimedOut) => {
497                break;
498            }
499            Err(e) => {
500                spinner.error(&e);
501                return Err(e);
502            }
503        }
504    }
505
506    if !replicas.is_empty() {
507        spinner.message(format!(
508            "Repository successfully synced to {} node(s).",
509            replicas.len()
510        ));
511        spinner.finish();
512
513        for seed in config.preferred_seeds.iter() {
514            if replicas.contains(&seed.id) {
515                return Ok(SyncResult::Synced {
516                    result: Some(config.public_explorer.url(seed.addr.host.to_string(), rid)),
517                });
518            }
519        }
520        Ok(SyncResult::Synced { result: None })
521    } else {
522        spinner.message("Repository successfully announced to the network.");
523        spinner.finish();
524
525        Ok(SyncResult::NotSynced)
526    }
527}
528
529pub fn announce(
530    rid: RepoId,
531    doc: Doc,
532    node: &mut Node,
533    config: &profile::Config,
534) -> anyhow::Result<()> {
535    if doc.is_public() {
536        match sync(rid, node, config) {
537            Ok(SyncResult::Synced {
538                result: Some(url), ..
539            }) => {
540                term::blank();
541                term::info!(
542                    "Your repository has been synced to the network and is \
543                    now discoverable by peers.",
544                );
545                term::info!("View it in your browser at:");
546                term::blank();
547                term::indented(term::format::tertiary(url));
548                term::blank();
549            }
550            Ok(SyncResult::Synced { result: None, .. }) => {
551                term::blank();
552                term::info!(
553                    "Your repository has been synced to the network and is \
554                    now discoverable by peers.",
555                );
556                if !config.preferred_seeds.is_empty() {
557                    term::info!(
558                        "Unfortunately, you were unable to replicate your repository to \
559                        your preferred seeds."
560                    );
561                }
562            }
563            Ok(SyncResult::NotSynced) => {
564                term::blank();
565                term::info!(
566                    "Your repository has been announced to the network and is \
567                    now discoverable by peers.",
568                );
569                term::info!(
570                    "You can check for any nodes that have replicated your repository by running \
571                    `rad sync status`."
572                );
573                term::blank();
574            }
575            Ok(SyncResult::NoPeersConnected) => {
576                term::blank();
577                term::info!(
578                    "You are not connected to any peers. Your repository will be announced as soon as \
579                    your node establishes a connection with the network.");
580                term::info!("Check for peer connections with `rad node status`.");
581                term::blank();
582            }
583            Ok(SyncResult::NodeStopped) => {
584                term::info!(
585                    "Your repository will be announced to the network when you start your node."
586                );
587                term::info!(
588                    "You can start your node with {}.",
589                    term::format::command("rad node start")
590                );
591            }
592            Err(e) => {
593                return Err(e.into());
594            }
595        }
596    } else {
597        term::info!(
598            "You have created a {} repository.",
599            term::format::visibility(doc.visibility())
600        );
601        term::info!(
602            "This repository will only be visible to you, \
603            and to peers you explicitly allow.",
604        );
605        term::blank();
606        term::info!(
607            "To make it public, run {}.",
608            term::format::command("rad publish")
609        );
610    }
611
612    Ok(())
613}
614
615/// Setup radicle key as commit signing key in repository.
616pub fn setup_signing(
617    node_id: &NodeId,
618    repo: &git::Repository,
619    interactive: Interactive,
620) -> anyhow::Result<()> {
621    let repo = repo
622        .workdir()
623        .ok_or(anyhow!("cannot setup signing in bare repository"))?;
624    let key = ssh::fmt::fingerprint(node_id);
625    let yes = if !git::is_signing_configured(repo)? {
626        term::headline(format!(
627            "Configuring radicle signing key {}...",
628            term::format::tertiary(key)
629        ));
630        true
631    } else if interactive.yes() {
632        term::confirm(format!(
633            "Configure radicle signing key {} in local checkout?",
634            term::format::tertiary(key),
635        ))
636    } else {
637        true
638    };
639
640    if yes {
641        match git::write_gitsigners(repo, [node_id]) {
642            Ok(file) => {
643                git::ignore(repo, file.as_path())?;
644
645                term::success!("Created {} file", term::format::tertiary(file.display()));
646            }
647            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
648                let ssh_key = ssh::fmt::key(node_id);
649                let gitsigners = term::format::tertiary(".gitsigners");
650                term::success!("Found existing {} file", gitsigners);
651
652                let ssh_keys =
653                    git::read_gitsigners(repo).context("error reading .gitsigners file")?;
654
655                if ssh_keys.contains(&ssh_key) {
656                    term::success!("Signing key is already in {gitsigners} file");
657                } else if term::confirm(format!("Add signing key to {gitsigners}?")) {
658                    git::add_gitsigners(repo, [node_id])?;
659                }
660            }
661            Err(err) => {
662                return Err(err.into());
663            }
664        }
665        git::configure_signing(repo, node_id)?;
666
667        term::success!(
668            "Signing configured in {}",
669            term::format::tertiary(".git/config")
670        );
671    }
672    Ok(())
673}