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 }