/ radicle-cli / src / commands / init.rs
init.rs
  1  #![allow(clippy::or_fun_call)]
  2  #![allow(clippy::collapsible_else_if)]
  3  use std::convert::TryFrom;
  4  use std::env;
  5  use std::ffi::OsString;
  6  use std::path::PathBuf;
  7  use std::str::FromStr;
  8  
  9  use anyhow::{anyhow, bail, Context as _};
 10  use serde_json as json;
 11  
 12  use radicle::crypto::{ssh, Verified};
 13  use radicle::git::RefString;
 14  use radicle::identity::Visibility;
 15  use radicle::node::tracking::Scope;
 16  use radicle::node::{Handle, NodeId};
 17  use radicle::prelude::Doc;
 18  use radicle::{profile, Node};
 19  
 20  use crate::git;
 21  use crate::terminal as term;
 22  use crate::terminal::args::{Args, Error, Help};
 23  use crate::terminal::Interactive;
 24  
 25  pub const HELP: Help = Help {
 26      name: "init",
 27      description: "Initialize a project from a git repository",
 28      version: env!("CARGO_PKG_VERSION"),
 29      usage: r#"
 30  Usage
 31  
 32      rad init [<path>] [<option>...]
 33  
 34  Options
 35  
 36          --name <string>            Name of the project
 37          --description <string>     Description of the project
 38          --default-branch <name>    The default branch of the project
 39          --scope <scope>            Tracking scope (default: all)
 40          --private                  Set repository visibility to *private*
 41          --public                   Set repository visibility to *public*
 42      -u, --set-upstream             Setup the upstream of the default branch
 43          --setup-signing            Setup the radicle key as a signing key for this repository
 44          --no-confirm               Don't ask for confirmation during setup
 45      -v, --verbose                  Verbose mode
 46          --help                     Print help
 47  "#,
 48  };
 49  
 50  #[derive(Default)]
 51  pub struct Options {
 52      pub path: Option<PathBuf>,
 53      pub name: Option<String>,
 54      pub description: Option<String>,
 55      pub branch: Option<String>,
 56      pub interactive: Interactive,
 57      pub visibility: Option<Visibility>,
 58      pub setup_signing: bool,
 59      pub scope: Scope,
 60      pub set_upstream: bool,
 61      pub verbose: bool,
 62      pub track: bool,
 63  }
 64  
 65  impl Args for Options {
 66      fn from_args(args: Vec<OsString>) -> anyhow::Result<(Self, Vec<OsString>)> {
 67          use lexopt::prelude::*;
 68  
 69          let mut parser = lexopt::Parser::from_args(args);
 70          let mut path: Option<PathBuf> = None;
 71  
 72          let mut name = None;
 73          let mut description = None;
 74          let mut branch = None;
 75          let mut interactive = Interactive::Yes;
 76          let mut set_upstream = false;
 77          let mut setup_signing = false;
 78          let mut scope = Scope::All;
 79          let mut track = true;
 80          let mut verbose = false;
 81          let mut visibility = None;
 82  
 83          while let Some(arg) = parser.next()? {
 84              match arg {
 85                  Long("name") if name.is_none() => {
 86                      let value = parser
 87                          .value()?
 88                          .to_str()
 89                          .ok_or(anyhow::anyhow!(
 90                              "invalid project name specified with `--name`"
 91                          ))?
 92                          .to_owned();
 93                      name = Some(value);
 94                  }
 95                  Long("description") if description.is_none() => {
 96                      let value = parser
 97                          .value()?
 98                          .to_str()
 99                          .ok_or(anyhow::anyhow!(
100                              "invalid project description specified with `--description`"
101                          ))?
102                          .to_owned();
103  
104                      description = Some(value);
105                  }
106                  Long("default-branch") if branch.is_none() => {
107                      let value = parser
108                          .value()?
109                          .to_str()
110                          .ok_or(anyhow::anyhow!(
111                              "invalid branch specified with `--default-branch`"
112                          ))?
113                          .to_owned();
114  
115                      branch = Some(value);
116                  }
117                  Long("scope") => {
118                      let value = parser.value()?;
119  
120                      scope = term::args::parse_value("scope", value)?;
121                  }
122                  Long("set-upstream") | Short('u') => {
123                      set_upstream = true;
124                  }
125                  Long("setup-signing") => {
126                      setup_signing = true;
127                  }
128                  Long("no-confirm") => {
129                      interactive = Interactive::No;
130                  }
131                  Long("no-track") => {
132                      track = false;
133                  }
134                  Long("private") => {
135                      visibility = Some(Visibility::private([]));
136                  }
137                  Long("public") => {
138                      visibility = Some(Visibility::Public);
139                  }
140                  Long("verbose") | Short('v') => {
141                      verbose = true;
142                  }
143                  Long("help") | Short('h') => {
144                      return Err(Error::Help.into());
145                  }
146                  Value(val) if path.is_none() => {
147                      path = Some(val.into());
148                  }
149                  _ => return Err(anyhow::anyhow!(arg.unexpected())),
150              }
151          }
152  
153          Ok((
154              Options {
155                  path,
156                  name,
157                  description,
158                  branch,
159                  scope,
160                  interactive,
161                  set_upstream,
162                  setup_signing,
163                  track,
164                  visibility,
165                  verbose,
166              },
167              vec![],
168          ))
169      }
170  }
171  
172  pub fn run(options: Options, ctx: impl term::Context) -> anyhow::Result<()> {
173      let profile = ctx.profile()?;
174  
175      init(options, &profile)
176  }
177  
178  pub fn init(options: Options, profile: &profile::Profile) -> anyhow::Result<()> {
179      let cwd = std::env::current_dir()?;
180      let path = options.path.unwrap_or_else(|| cwd.clone());
181      let path = path.as_path().canonicalize()?;
182      let interactive = options.interactive;
183      let repo = match git::Repository::open(&path) {
184          Ok(r) => r,
185          Err(e) if radicle::git::ext::is_not_found_err(&e) => {
186              anyhow::bail!("a Git repository was not found at the current path")
187          }
188          Err(e) => return Err(e.into()),
189      };
190  
191      if let Ok((remote, _)) = git::rad_remote(&repo) {
192          if let Some(remote) = remote.url() {
193              bail!("repository is already initialized with remote {remote}");
194          }
195      }
196  
197      let head: String = repo
198          .head()
199          .ok()
200          .and_then(|head| head.shorthand().map(|h| h.to_owned()))
201          .ok_or_else(|| anyhow!("repository head must point to a commit"))?;
202  
203      term::headline(format!(
204          "Initializing{}radicle 👾 project in {}",
205          if let Some(visibility) = &options.visibility {
206              term::format::spaced(term::format::visibility(visibility))
207          } else {
208              term::format::default(" ").into()
209          },
210          if path == cwd {
211              term::format::tertiary(".").to_string()
212          } else {
213              term::format::tertiary(path.display()).to_string()
214          }
215      ));
216  
217      let name = match options.name {
218          Some(name) => name,
219          None => {
220              let default = path.file_name().map(|f| f.to_string_lossy().to_string());
221              term::input(
222                  "Name",
223                  default,
224                  Some("The name of your repository, eg. 'acme'"),
225              )?
226          }
227      };
228      let description = match options.description {
229          Some(desc) => desc,
230          None => term::input("Description", None, Some("You may leave this blank"))?,
231      };
232      let branch = match options.branch {
233          Some(branch) => branch,
234          None if interactive.yes() => term::input(
235              "Default branch",
236              Some(head),
237              Some("Please specify an existing branch"),
238          )?,
239          None => head,
240      };
241      let branch = RefString::try_from(branch.clone())
242          .map_err(|e| anyhow!("invalid branch name {:?}: {}", branch, e))?;
243      let visibility = if let Some(v) = options.visibility {
244          v
245      } else {
246          let selected = term::select(
247              "Visibility",
248              &["public", "private"],
249              "Public repositories are accessible by anyone on the network after initialization",
250          )?;
251          Visibility::from_str(selected)?
252      };
253  
254      let signer = term::signer(profile)?;
255      let mut node = radicle::Node::new(profile.socket());
256      let mut spinner = term::spinner("Initializing...");
257      let mut push_cmd = String::from("git push");
258  
259      match radicle::rad::init(
260          &repo,
261          &name,
262          &description,
263          branch,
264          visibility,
265          &signer,
266          &profile.storage,
267      ) {
268          Ok((id, doc, _)) => {
269              let proj = doc.project()?;
270  
271              if options.track && node.is_running() {
272                  // It's important to track our own repositories to make sure that our node signals
273                  // interest for them. This ensures that messages relating to them are relayed to us.
274                  node.track_repo(id, options.scope)?;
275              }
276  
277              spinner.message(format!(
278                  "Project {} created.",
279                  term::format::highlight(proj.name())
280              ));
281              spinner.finish();
282  
283              if options.verbose {
284                  term::blob(json::to_string_pretty(&proj)?);
285              }
286  
287              if options.set_upstream || git::branch_remote(&repo, proj.default_branch()).is_err() {
288                  // Setup eg. `master` -> `rad/master`
289                  radicle::git::set_upstream(
290                      &repo,
291                      &*radicle::rad::REMOTE_NAME,
292                      proj.default_branch(),
293                      radicle::git::refs::workdir::branch(proj.default_branch()),
294                  )?;
295              } else {
296                  push_cmd = format!("git push {}", *radicle::rad::REMOTE_NAME);
297              }
298  
299              if options.setup_signing {
300                  // Setup radicle signing key.
301                  self::setup_signing(profile.id(), &repo, interactive)?;
302              }
303  
304              term::blank();
305              term::info!(
306                  "Your project's Repository ID {} is {}.",
307                  term::format::dim("(RID)"),
308                  term::format::highlight(id.urn())
309              );
310              term::info!(
311                  "You can show it any time by running {} from this directory.",
312                  term::format::command("rad .")
313              );
314              term::blank();
315  
316              // Announce inventory to network.
317              if let Err(e) = announce(doc, &mut node) {
318                  term::blank();
319                  term::warning(format!(
320                      "There was an error announcing your project to the network: {e}"
321                  ));
322                  term::warning("Try again with `rad sync --announce`, or check your logs with `rad node logs`.");
323                  term::blank();
324              }
325              term::info!("To push changes, run {}.", term::format::command(push_cmd));
326          }
327          Err(err) => {
328              spinner.failed();
329              anyhow::bail!(err);
330          }
331      }
332  
333      Ok(())
334  }
335  
336  pub fn announce(doc: Doc<Verified>, node: &mut Node) -> anyhow::Result<()> {
337      if doc.visibility.is_public() {
338          if node.is_running() {
339              let mut spinner = term::spinner("Updating inventory..");
340  
341              node.sync_inventory()?;
342              spinner.message("Announcing..");
343              node.announce_inventory()?;
344              spinner.message("Project successfully announced.");
345              spinner.finish();
346  
347              term::blank();
348              term::info!(
349                  "Your project has been announced to the network and is \
350                  now discoverable by peers.",
351              );
352          } else {
353              term::info!("Your project will be announced to the network when you start your node.");
354              term::info!(
355                  "You can start your node with {}.",
356                  term::format::command("rad node start")
357              );
358          }
359      } else {
360          term::info!(
361              "You have created a {} repository.",
362              term::format::visibility(&doc.visibility)
363          );
364          term::info!(
365              "This repository will only be visible to you, \
366              and to peers you explicitly allow.",
367          );
368          term::blank();
369          term::info!(
370              "To make it public, run {}.",
371              term::format::command("rad publish")
372          );
373      }
374  
375      Ok(())
376  }
377  
378  /// Setup radicle key as commit signing key in repository.
379  pub fn setup_signing(
380      node_id: &NodeId,
381      repo: &git::Repository,
382      interactive: Interactive,
383  ) -> anyhow::Result<()> {
384      let repo = repo
385          .workdir()
386          .ok_or(anyhow!("cannot setup signing in bare repository"))?;
387      let key = ssh::fmt::fingerprint(node_id);
388      let yes = if !git::is_signing_configured(repo)? {
389          term::headline(format!(
390              "Configuring radicle signing key {}...",
391              term::format::tertiary(key)
392          ));
393          true
394      } else if interactive.yes() {
395          term::confirm(format!(
396              "Configure radicle signing key {} in local checkout?",
397              term::format::tertiary(key),
398          ))
399      } else {
400          true
401      };
402  
403      if yes {
404          match git::write_gitsigners(repo, [node_id]) {
405              Ok(file) => {
406                  git::ignore(repo, file.as_path())?;
407  
408                  term::success!("Created {} file", term::format::tertiary(file.display()));
409              }
410              Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
411                  let ssh_key = ssh::fmt::key(node_id);
412                  let gitsigners = term::format::tertiary(".gitsigners");
413                  term::success!("Found existing {} file", gitsigners);
414  
415                  let ssh_keys =
416                      git::read_gitsigners(repo).context("error reading .gitsigners file")?;
417  
418                  if ssh_keys.contains(&ssh_key) {
419                      term::success!("Signing key is already in {gitsigners} file");
420                  } else if term::confirm(format!("Add signing key to {gitsigners}?")) {
421                      git::add_gitsigners(repo, [node_id])?;
422                  }
423              }
424              Err(err) => {
425                  return Err(err.into());
426              }
427          }
428          git::configure_signing(repo, node_id)?;
429  
430          term::success!(
431              "Signing configured in {}",
432              term::format::tertiary(".git/config")
433          );
434      }
435      Ok(())
436  }