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 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 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 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 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 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 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 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 node.announce_inventory()?;
447
448 spinner.message("Syncing..");
449
450 let mut replicas = HashSet::new();
451 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 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 }
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
615pub 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}