1use choir_hash::ContentHash;
53use choir_identity::{ActorKey, Registry};
54use choir_node::platform::hex_decode;
55use choir_view::{
56 reviewer_operator, ArchiveAuthorization, CheckStatus, CreateAuthorization, OpKind, Verdict,
57 ViewOp,
58};
59
60#[derive(Clone, Copy)]
61struct AuthOptions<'a> {
62 file: Option<&'a str>,
63 user: Option<&'a str>,
64 explicit: bool,
72}
73
74static DEFAULT_AUTH: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
80
81fn discovered_auth_file() -> Option<String> {
86 if let Some(named) = configured("auth") {
87 return Some(named);
88 }
89 let path = std::path::PathBuf::from(std::env::var_os("HOME")?)
90 .join(".choir")
91 .join("auth");
92 path.exists().then(|| path.display().to_string())
93}
94
95fn may_hold_credential(api: &str) -> bool {
104 let rest = api.split_once("://").map_or(api, |(_, rest)| rest);
105 let host = rest.split('/').next().unwrap_or("");
106 let name = host.rsplit_once(':').map_or(host, |(name, _)| name);
107 if matches!(name, "127.0.0.1" | "localhost" | "::1" | "[::1]") {
108 return true;
109 }
110 let same = |url: &str| url.trim_end_matches('/') == api.trim_end_matches('/');
111 configured("node").is_some_and(|node| same(&node))
115 || configured_seeds().iter().any(|seed| same(seed))
116}
117
118fn configured_seeds() -> Vec<String> {
121 configured("seeds")
122 .map(|line| {
123 line.split(',')
124 .map(|url| url.trim().trim_end_matches('/').to_string())
125 .filter(|url| !url.is_empty())
126 .collect()
127 })
128 .unwrap_or_default()
129}
130
131fn not_home(status: u16, body: &str) -> Option<String> {
134 if status != 421 {
135 return None;
136 }
137 let value: serde_json::Value = serde_json::from_str(body).ok()?;
138 if value["code"] != "not_home" {
139 return None;
140 }
141 value["home"]
142 .as_str()
143 .map(|home| home.trim_end_matches('/').to_string())
144}
145
146fn follow_home(api: &str, home: &str) -> bool {
154 if home == api.trim_end_matches('/') {
155 return false;
156 }
157 if may_hold_credential(home) {
158 eprintln!("choir: {api} is a seed of {home}; sent this to the home instead");
159 true
160 } else {
161 eprintln!(
162 "choir: {api} is a seed of {home}, and writes go there. Not following it: \
163 {home} is not the node .choir/config names, and this command's credential \
164 would go with it. Rerun against {home}."
165 );
166 false
167 }
168}
169
170impl<'a> AuthOptions<'a> {
171 fn is_empty(self) -> bool {
172 !self.explicit
173 }
174
175 fn file_for(self, api: &str) -> Option<&'a str> {
182 if self.file.is_some() {
183 return self.file;
184 }
185 if !may_hold_credential(api) {
186 return None;
187 }
188 DEFAULT_AUTH.get_or_init(discovered_auth_file).as_deref()
189 }
190}
191
192fn note(heading: &str, rows: &[(&str, String)]) {
204 let style = choir_cli::style::Style::for_stderr();
205 if !style.is_painted() {
206 return;
207 }
208 let width = rows.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
209 eprintln!("\n {}", style.green(heading));
210 for (key, value) in rows {
211 let key = format!("{key:width$}");
212 eprintln!(" {} {value}", style.dim(&key));
213 }
214 eprintln!();
215}
216
217fn is_help(argument: &str) -> bool {
223 argument == "--help" || argument == "-h"
224}
225
226fn invoked_command() -> Option<String> {
234 let args: Vec<String> = std::env::args().skip(1).collect();
235 let mut index = 0;
238 while matches!(
239 args.get(index).map(String::as_str),
240 Some("--auth-file" | "--auth-user")
241 ) {
242 index += 2;
243 }
244 let first = args.get(index)?.clone();
245 if let Some(second) = args.get(index + 1) {
253 let two = format!("{first} {second}");
254 if choir_cli::surface::COMMANDS.iter().any(|c| c.name == two) {
255 return Some(two);
256 }
257 }
258 if choir_cli::surface::COMMANDS
262 .iter()
263 .any(|c| c.name.starts_with(&format!("{first} ")))
264 {
265 return Some(first);
266 }
267 Some(first)
268}
269
270fn usage() -> ! {
280 let style = choir_cli::style::Style::for_stderr();
281 match invoked_command() {
282 None if choir_cli::join::Role::of(&state_dir(), discovered_auth_file().as_deref())
287 == choir_cli::join::Role::Nothing =>
288 {
289 eprint!("{}", choir_cli::join::orientation(style));
290 }
291 None => eprint!("{}", choir_cli::surface::usage_in(style)),
292 Some(name) => match choir_cli::surface::command_help_in(&name, style) {
293 Some(help) => {
294 eprintln!(
295 "{} those arguments do not match `{}`. It takes:\n",
296 style.red("choir:"),
297 name
298 );
299 eprint!("{help}");
300 }
301 None => {
302 let under: Vec<&str> = choir_cli::surface::COMMANDS
309 .iter()
310 .map(|c| c.name)
311 .filter(|n| n.starts_with(&format!("{name} ")))
312 .collect();
313 if under.is_empty() {
314 eprintln!("{} `{}` is not a choir command.", style.red("choir:"), name);
315 let names = choir_cli::surface::COMMANDS.iter().map(|c| c.name);
316 if let Some(near) = choir_cli::style::nearest(&name, names) {
317 eprintln!(" did you mean {}?", style.cyan(near));
318 }
319 } else {
320 eprintln!(
321 "{} `{}` is not a command on its own. It has:",
322 style.red("choir:"),
323 name
324 );
325 for one in under {
326 eprintln!(" {}", style.cyan(one));
327 }
328 }
329 eprintln!(" {} lists every command.", style.cyan("choir --help"));
330 }
331 },
332 }
333 std::process::exit(2);
334}
335
336fn hex_encode(bytes: &[u8]) -> String {
337 bytes.iter().map(|b| format!("{b:02x}")).collect()
338}
339
340fn require_node_key_file(path: &str) {
348 if !std::path::Path::new(path).is_file() {
349 eprintln!("choir: <node-key-file> must name the node's existing key file");
350 std::process::exit(2);
351 }
352}
353
354fn actor_id_from_hex(key_hex: &str) -> choir_hash::ContentHash {
360 let bytes: Option<Vec<u8>> = (key_hex.len() == 64)
361 .then(|| {
362 (0..64)
363 .step_by(2)
364 .map(|i| u8::from_str_radix(&key_hex[i..i + 2], 16).ok())
365 .collect()
366 })
367 .flatten();
368 let Some(bytes) = bytes else {
369 eprintln!("choir: <key-hex> must be a 64-character ed25519 public key hex");
370 std::process::exit(2);
371 };
372 choir_hash::ContentHash::blake3(&bytes)
373}
374
375fn repair(log_file: &str, flags: &[&str]) {
384 let path = std::path::Path::new(log_file);
385 let verify = flags.contains(&"--verify");
386 let truncate = flags.contains(&"--truncate-tail");
387 if verify == truncate {
388 eprintln!(
392 "choir repair <log-file> --verify | --truncate-tail\n\
393 \n\
394 --verify walk the chain and report; changes nothing\n\
395 --truncate-tail quarantine a partly written final record and\n\
396 cut the log back to the last complete one\n\
397 \n\
398 Exactly one mode, and no default: which of these happens to your\n\
399 log is not a decision this tool should make for you."
400 );
401 std::process::exit(2);
402 }
403
404 let report = match choir_oplog::repair::verify(path) {
405 Ok(report) => report,
406 Err(error) => {
407 eprintln!("choir: cannot read {log_file}: {error:?}");
408 std::process::exit(1);
409 }
410 };
411
412 println!("{log_file}");
413 println!(" intact records: {}", report.intact_records);
414 match &report.head {
415 Some(head) => println!(" head: {}", head.to_hex()),
416 None => println!(" head: (empty log)"),
417 }
418 if report.torn_tail_bytes > 0 {
419 println!(
420 " torn tail: {} bytes, never acknowledged to any client",
421 report.torn_tail_bytes
422 );
423 }
424
425 if let Some(fault) = &report.fault {
426 println!(" FAULT: {fault}");
431 eprintln!(
432 "\nThis is damage to a record that was written whole, not an interrupted\n\
433 write, so cutting the end of the file cannot repair it: every record\n\
434 after position {} was acknowledged to a client.\n\
435 \n\
436 Restore from backup:\n\
437 1. stop the node (it will refuse to start on this log anyway)\n\
438 2. keep this file -- do not delete it; it is the only copy of\n\
439 whatever is still readable\n\
440 3. restore the log from the most recent backup\n\
441 4. verify the restored copy with `choir repair <log> --verify`\n\
442 before starting the node on it",
443 fault.position()
444 );
445 std::process::exit(1);
446 }
447
448 if verify {
449 if report.torn_tail_bytes > 0 {
450 println!(
451 "\nUsable. The torn tail is repairable: re-run with --truncate-tail,\n\
452 or simply start the node, which repairs it on open."
453 );
454 } else {
455 println!("\nIntact.");
456 }
457 return;
458 }
459
460 match choir_oplog::repair::truncate_tail(path) {
461 Ok(None) => println!("\nNothing to repair; the log already ends on a record boundary."),
462 Ok(Some(repaired)) => println!(
463 "\nRepaired.\n quarantined: {} ({} bytes)\n log length: {}\n\n\
464 The removed bytes are in that file, not deleted. Verify before\n\
465 starting the node: choir repair {log_file} --verify",
466 repaired.quarantine.display(),
467 repaired.bytes,
468 repaired.length
469 ),
470 Err(error) => {
471 eprintln!("choir: repair refused: {error:?}");
472 std::process::exit(1);
473 }
474 }
475}
476
477fn load_key(path: &str) -> ActorKey {
479 if std::path::Path::new(path).exists() {
480 let bytes = std::fs::read(path).expect("read key file");
481 ActorKey::from_secret_bytes(&bytes.as_slice().try_into().expect("32-byte key file"))
482 } else {
483 let key = ActorKey::generate();
484 choir_fs::write_atomic_private(std::path::Path::new(path), key.secret_bytes())
487 .expect("write key file");
488 key
489 }
490}
491
492fn http(
494 api: &str,
495 auth: AuthOptions<'_>,
496 tool: &str,
497 arguments: serde_json::Value,
498) -> (u16, String) {
499 let (status, body) = http_once(api, auth, tool, &arguments);
500 match not_home(status, &body) {
504 Some(home) if follow_home(api, &home) => http_once(&home, auth, tool, &arguments),
505 _ => (status, body),
506 }
507}
508
509fn http_once(
510 api: &str,
511 auth: AuthOptions<'_>,
512 tool: &str,
513 arguments: &serde_json::Value,
514) -> (u16, String) {
515 let client = match choir_cli::mcp::HttpClient::new(
516 api,
517 auth.file_for(api).map(std::path::Path::new),
518 auth.user,
519 ) {
520 Ok(client) => client,
521 Err(error) => {
522 eprintln!("choir: {error}");
523 std::process::exit(2);
524 }
525 };
526 let endpoint = choir_cli::surface::mcp_endpoint(tool).expect("CLI endpoint is in the table");
527 match client.request(endpoint, arguments) {
528 Ok(response) => response,
529 Err(error) => {
530 eprintln!("choir: {error}");
531 std::process::exit(1);
532 }
533 }
534}
535
536fn derived_view(
543 api: &str,
544 auth: AuthOptions<'_>,
545 derive: impl Fn(&serde_json::Value) -> serde_json::Value,
546) -> String {
547 let (status, body) = http(api, auth, "choir_view", serde_json::json!({}));
548 if !(200..300).contains(&status) {
549 eprintln!("choir: GET /api/view returned {status}: {body}");
550 std::process::exit(1);
551 }
552 let view: serde_json::Value = match serde_json::from_str(&body) {
553 Ok(view) => view,
554 Err(error) => {
555 eprintln!("choir: /api/view response is not JSON: {error}");
556 std::process::exit(1);
557 }
558 };
559 serde_json::to_string_pretty(&derive(&view)).expect("derived documents are serializable")
560}
561
562fn operator_call(
570 api: &str,
571 auth: AuthOptions<'_>,
572 method: &str,
573 path: &'static str,
574 body: &serde_json::Value,
575) -> serde_json::Value {
576 let endpoint = choir_cli::surface::endpoint(method, path)
577 .unwrap_or_else(|| panic!("{path} is in the endpoint table"));
578 let send = |api: &str| {
579 let client = match choir_cli::mcp::HttpClient::new(
580 api,
581 auth.file_for(api).map(std::path::Path::new),
582 auth.user,
583 ) {
584 Ok(client) => client,
585 Err(error) => {
586 eprintln!("choir: {error}");
587 std::process::exit(2);
588 }
589 };
590 match client.request(endpoint, body) {
591 Ok(response) => response,
592 Err(error) => {
593 eprintln!("choir: {error}");
594 std::process::exit(1);
595 }
596 }
597 };
598 let (status, text) = send(api);
599 let (status, text) = match not_home(status, &text) {
600 Some(home) if follow_home(api, &home) => send(&home),
601 _ => (status, text),
602 };
603 let parsed = serde_json::from_str::<serde_json::Value>(&text).unwrap_or_default();
604 if !(200..300).contains(&status) {
605 eprintln!(
606 "choir: {method} {path} returned {status}: {}",
607 parsed["error"].as_str().unwrap_or(text.trim())
608 );
609 if status == 403 || status == 401 {
610 eprintln!("this needs a credential holding `@node write`.");
611 }
612 std::process::exit(1);
613 }
614 parsed
615}
616
617fn grant_line(repo: &str, level: &str) -> String {
623 let repo = repo.strip_suffix(".git").unwrap_or(repo);
624 format!("{repo}.git {level}")
625}
626
627fn checked_level(level: &str) -> &str {
629 match level {
630 "read" | "propose" | "write" => level,
631 other => {
632 eprintln!("choir: `{other}` is not a level; use read, propose or write");
633 std::process::exit(2);
634 }
635 }
636}
637
638fn host(rest: &[&str]) -> ! {
661 let style = choir_cli::style::Style::for_stderr();
662 let options = match choir_cli::host::parse(rest, state_dir()) {
663 Ok(options) => options,
664 Err(error) => {
665 eprintln!("{} {error}", style.red("choir host:"));
666 std::process::exit(2);
667 }
668 };
669 let layout = choir_cli::serve::Layout::new(&options.state, options.port);
670 let user = choir_cli::host::username();
671 let mut done: Vec<String> = Vec::new();
672 let retry = rerun_line(rest);
673 eprintln!();
674
675 if layout.missing().is_empty() {
680 step(
681 "state",
682 &format!("{} (already here, kept)", options.state.display()),
683 );
684 } else {
685 let plan = choir_cli::init::Plan::new(&options.state, options.port);
686 if let Err(error) = choir_cli::init::run(&plan, false) {
687 host_failed(&done, "state", &error, &retry);
688 }
689 step(
690 "state",
691 &format!(
692 "{} — credential, key, trusted keys",
693 options.state.display()
694 ),
695 );
696 }
697 done.push("state".to_string());
698
699 if !layout.acl.exists() {
711 let acl = "# Who may reach what (D29). `<user> <repo|*|@node> <level>`,\n\
712 # levels read < propose < write < own. Issued grants (D36) are\n\
713 # merged with this file; `own` and `@node` are granted here only.\n\
714 choir * own\n\
715 choir @node write\n";
716 if let Err(error) = choir_fs::write_atomic_private(&layout.acl, acl) {
717 host_failed(
718 &done,
719 "acl",
720 &format!("{}: {error}", layout.acl.display()),
721 &retry,
722 );
723 }
724 }
725 if !layout.accounts.exists() {
726 if let Err(error) = choir_fs::write_atomic_private(&layout.accounts, "") {
727 host_failed(
728 &done,
729 "accounts",
730 &format!("{}: {error}", layout.accounts.display()),
731 &retry,
732 );
733 }
734 }
735
736 let name = options.exposure.name();
738 match &name {
739 None => step("certificate", "not needed — this node binds loopback only"),
740 Some(name) => {
741 let sudo = tls_line(name, &user, options.port, options.dry_run);
742 match layout.tls() {
743 Some((cert, _)) if !options.dry_run && std::fs::File::open(&cert).is_ok() => {
744 match choir_cli::tls::expiry(&cert) {
745 Ok(when) => step(
746 "certificate",
747 &format!("already issued, valid until {when}"),
748 ),
749 Err(_) => step(
750 "certificate",
751 &format!("already issued — {}", cert.display()),
752 ),
753 }
754 }
755 _ => {
756 let firewall = choir_cli::host::firewall_hint(options.port, true);
757 let mut paste = vec![sudo];
758 if let Some(line) = firewall {
759 paste.push(line);
760 }
761 handover(
762 &done,
763 &format!(
764 "a certificate for {name} has to be issued as root.\n \
765 certbot writes /etc/letsencrypt, and the renewal hook that keeps\n \
766 this working for the next two years lives there too. Paste this:"
767 ),
768 &paste,
769 &retry,
770 );
771 }
772 }
773 done.push("certificate".to_string());
774 }
775 }
776
777 match choir_cli::host::linger(&user) {
781 None | Some(true) => {}
782 Some(false) if options.yes => {
783 eprintln!(
784 " {} {:14} off — this node will stop when {user} logs out",
785 style.cyan("!!"),
786 style.dim("linger")
787 );
788 }
789 Some(false) => handover(
790 &done,
791 &format!(
792 "linger is off for {user}, so systemd stops this node at logout.\n \
793 One line fixes it for the life of the machine:"
794 ),
795 &[format!("sudo loginctl enable-linger {user}")],
796 &format!("{retry} (or add --yes to accept a node that dies at logout)"),
797 ),
798 }
799 if choir_cli::host::linger(&user) == Some(true) {
800 step("linger", &format!("on for {user}"));
801 }
802
803 let url = options.exposure.url(options.port);
808 if let Err(error) = choir_fs::write_atomic(&layout.public_url, format!("{url}\n")) {
809 host_failed(
810 &done,
811 "address",
812 &format!("{}: {error}", layout.public_url.display()),
813 &retry,
814 );
815 }
816 if let Err(error) = choir_fs::write_atomic(
817 std::path::Path::new(".choir/config"),
818 format!(
819 "# Which node the `choir` commands talk to when they are not\n\
820 # given one. Written by `choir host`: this machine is the node.\n\
821 node = {url}\n"
822 ),
823 ) {
824 eprintln!(" {} .choir/config: {error}", style.cyan("!!"));
825 }
826 step("address", &url);
827
828 if options.foreground {
835 step(
836 "foreground",
837 "becoming the daemon; the runtime supervises it",
838 );
839 eprintln!();
840 let program = match choir_cli::serve::find_daemon() {
841 Ok(program) => program,
842 Err(error) => host_failed(&done, "foreground", &error, &retry),
843 };
844 match choir_cli::serve::plan(program, &layout, &[], &options.extra) {
845 Ok(invocation) => {
846 let error = choir_cli::serve::exec(&invocation);
847 host_failed(&done, "foreground", &error, &retry);
848 }
849 Err(error) => host_failed(&done, "foreground", &error, &retry),
850 }
851 }
852 match install_unit(&options.state, options.port, &options.extra) {
853 Ok((unit, _)) => step("supervised", &unit.display().to_string()),
854 Err(error) => host_failed(&done, "supervised", &error, &retry),
855 }
856 done.push("supervised".to_string());
857
858 let client = match choir_cli::mcp::HttpClient::new(&url, Some(&layout.auth), None) {
861 Ok(client) => client,
862 Err(error) => host_failed(&done, "healthy", &error, &retry),
863 };
864 match choir_cli::host::wait_healthy(&client, 30) {
865 Ok(()) => step("healthy", &format!("{url}/healthz")),
866 Err(error) => host_failed(
871 &done,
872 "healthy",
873 &format!(
874 "{error}\n it says why in {}",
875 layout.log.display()
876 ),
877 &format!("choir node logs --state {}", options.state.display()),
878 ),
879 }
880 done.push("healthy".to_string());
881
882 if let Some(repo) = &options.repo {
884 let endpoint = choir_cli::surface::endpoint("POST", "/api/repo")
885 .expect("the repo endpoint is in the table");
886 match client.request(endpoint, &serde_json::json!({ "name": repo })) {
887 Ok((status, _)) if (200..300).contains(&status) => {
888 step("repository", &format!("{url}/{repo}"));
889 }
890 Ok((409, _)) => step("repository", &format!("{repo} was already there")),
891 Ok((status, body)) => {
892 host_failed(&done, "repository", &format!("{status}: {body}"), &retry)
893 }
894 Err(error) => host_failed(&done, "repository", &error, &retry),
895 }
896 done.push("repository".to_string());
897 }
898 let mut link: Option<String> = None;
899 if let Some(who) = &options.invite {
900 let repo = options.repo.clone().unwrap_or_default();
901 let endpoint = choir_cli::surface::endpoint("POST", "/api/accounts/invite")
902 .expect("the invite endpoint is in the table");
903 let body = serde_json::json!({
904 "display_name": who,
905 "grants": [grant_line(&repo, "write")],
906 });
907 match client.request(endpoint, &body) {
908 Ok((status, text)) if (200..300).contains(&status) => {
909 let answer: serde_json::Value = serde_json::from_str(&text).unwrap_or_default();
910 let issued = answer["join_url"]
911 .as_str()
912 .or_else(|| answer["invite"].as_str())
913 .unwrap_or_default()
914 .to_string();
915 step("invited", who);
916 link = Some(issued);
917 }
918 Ok((status, body)) => {
919 host_failed(&done, "invited", &format!("{status}: {body}"), &retry)
920 }
921 Err(error) => host_failed(&done, "invited", &error, &retry),
922 }
923 }
924
925 eprintln!();
928 if name.is_some() {
929 if let Some(line) = choir_cli::host::firewall_hint(options.port, true) {
930 eprintln!(
931 " {} a firewall is running here and this command did not touch it.\n {line}\n",
932 style.cyan("note:")
933 );
934 }
935 } else {
936 eprintln!(
937 " {} nobody outside this machine can reach a loopback node. To share it:\n {}\n",
938 style.dim("share:"),
939 choir_cli::host::share_hint(options.port)
940 );
941 }
942 eprintln!(" {} choir doctor\n", style.dim("check it:"));
943
944 println!("{url}");
946 if let Some(link) = link {
947 println!("{link}");
948 }
949 std::process::exit(0)
950}
951
952fn seed(rest: &[&str]) -> ! {
958 let style = choir_cli::style::Style::for_stderr();
959 let options = match choir_cli::seed::parse(rest, state_dir()) {
960 Ok(options) => options,
961 Err(error) => {
962 eprintln!("{} {error}", style.red("choir seed:"));
963 std::process::exit(2);
964 }
965 };
966 let layout = choir_cli::serve::Layout::new(&options.state, options.port);
967 let mut done: Vec<String> = Vec::new();
968 let retry = {
969 let mut line = "choir seed".to_string();
970 for argument in rest {
971 line.push(' ');
972 line.push_str(argument);
973 }
974 line
975 };
976 eprintln!();
977
978 let key_path = choir_cli::seed::key_path(&layout.repos);
983 let existed = key_path.exists();
984 let key = match choir_cli::seed::identity(&key_path) {
985 Ok(key) => key,
986 Err(error) => host_failed(&done, "identity", &error, &retry),
987 };
988 let hex = choir_cli::seed::public_hex(&key);
989 step(
990 "identity",
991 &format!(
992 "{} {}{}",
993 options.name,
994 &hex[..16],
995 if existed {
996 "… (already here, kept)"
997 } else {
998 "…"
999 }
1000 ),
1001 );
1002 done.push("identity".to_string());
1003
1004 let Some(from) = &options.credential else {
1006 let paste = choir_cli::seed::registration(&options.name, &hex, &options.home);
1007 handover(
1008 &done,
1009 &format!(
1010 "the home has to admit this seed before it can read a page. On {},
1011 its operator adds these to files the node already has (nothing
1012 seed-specific), then hands you the auth line as a 0600 file:",
1013 options.home
1014 ),
1015 &paste,
1016 &format!("{retry} --credential <that file>"),
1017 );
1018 };
1019 let user = match choir_cli::seed::place_credential(from, &layout.seed_credential) {
1020 Ok(user) => user,
1021 Err(error) => host_failed(&done, "credential", &error, &retry),
1022 };
1023 step(
1024 "credential",
1025 &format!("{user} at {}", layout.seed_credential.display()),
1026 );
1027 done.push("credential".to_string());
1028
1029 if !options.archival {
1033 if !layout.auth.exists() {
1034 let token = match choir_cli::init::mint_token() {
1035 Ok(token) => token,
1036 Err(error) => host_failed(&done, "readers", &error, &retry),
1037 };
1038 if let Err(error) = choir_fs::write_atomic_private(
1039 &layout.auth,
1040 format!(
1041 "choir:{token}
1042"
1043 ),
1044 ) {
1045 host_failed(
1046 &done,
1047 "readers",
1048 &format!("{}: {error}", layout.auth.display()),
1049 &retry,
1050 );
1051 }
1052 }
1053 if !layout.acl.exists() {
1054 let acl = "# Who may read what on this seed (D29). Every write is answered
1055 # 421 not_home whatever this says; `own` here is the operator's
1056 # read of everything the seed holds.
1057 choir * own
1058 choir @node auditor
1059";
1060 if let Err(error) = choir_fs::write_atomic_private(&layout.acl, acl) {
1061 host_failed(
1062 &done,
1063 "readers",
1064 &format!("{}: {error}", layout.acl.display()),
1065 &retry,
1066 );
1067 }
1068 }
1069 step(
1070 "readers",
1071 &format!("{} (0600), {}", layout.auth.display(), layout.acl.display()),
1072 );
1073 }
1074
1075 let marker = choir_cli::serve::Seed {
1077 home: options.home.clone(),
1078 credential: layout.seed_credential.clone(),
1079 serve: !options.archival,
1080 };
1081 if let Err(error) = choir_fs::write_atomic(&layout.seed_marker, marker.render()) {
1082 host_failed(
1083 &done,
1084 "marker",
1085 &format!("{}: {error}", layout.seed_marker.display()),
1086 &retry,
1087 );
1088 }
1089 step("seed of", &options.home);
1090 done.push("marker".to_string());
1091
1092 let url = format!("http://127.0.0.1:{}", options.port);
1097 if !options.archival {
1098 if let Err(error) = choir_fs::write_atomic(
1099 &layout.public_url,
1100 format!(
1101 "{url}
1102"
1103 ),
1104 ) {
1105 host_failed(
1106 &done,
1107 "address",
1108 &format!("{}: {error}", layout.public_url.display()),
1109 &retry,
1110 );
1111 }
1112 let config = std::path::Path::new(".choir/config");
1113 if !config.exists() {
1114 if let Err(error) = choir_fs::write_atomic(
1115 config,
1116 format!(
1117 "# Written by `choir seed`: writes go to the home, and `choir doctor`
1118 # compares this seed's statement with what the home shows.
1119 node = {}
1120 seeds = {url}
1121",
1122 options.home
1123 ),
1124 ) {
1125 eprintln!(" {} .choir/config: {error}", style.cyan("!!"));
1126 }
1127 }
1128 step("address", &url);
1129 }
1130
1131 if options.foreground {
1133 step(
1134 "foreground",
1135 "becoming the daemon; the runtime supervises it",
1136 );
1137 eprintln!();
1138 let program = match choir_cli::serve::find_daemon() {
1139 Ok(program) => program,
1140 Err(error) => host_failed(&done, "foreground", &error, &retry),
1141 };
1142 match choir_cli::serve::plan(program, &layout, &[], &options.extra) {
1143 Ok(invocation) => {
1144 let error = choir_cli::serve::exec(&invocation);
1145 host_failed(&done, "foreground", &error, &retry);
1146 }
1147 Err(error) => host_failed(&done, "foreground", &error, &retry),
1148 }
1149 }
1150 match install_unit(&options.state, options.port, &options.extra) {
1151 Ok((unit, _)) => step("supervised", &unit.display().to_string()),
1152 Err(error) => host_failed(&done, "supervised", &error, &retry),
1153 }
1154 done.push("supervised".to_string());
1155
1156 if options.archival {
1159 eprintln!();
1160 eprintln!(
1161 " {} choir node logs --state {}
1162",
1163 style.dim("watch it:"),
1164 options.state.display()
1165 );
1166 std::process::exit(0);
1167 }
1168 let client = match choir_cli::mcp::HttpClient::new(&url, Some(&layout.auth), None) {
1169 Ok(client) => client,
1170 Err(error) => host_failed(&done, "healthy", &error, &retry),
1171 };
1172 match choir_cli::host::wait_healthy(&client, 30) {
1173 Ok(()) => step("healthy", &format!("{url}/healthz")),
1174 Err(error) => host_failed(
1175 &done,
1176 "healthy",
1177 &format!(
1178 "{error}
1179 it says why in {}",
1180 layout.log.display()
1181 ),
1182 &format!("choir node logs --state {}", options.state.display()),
1183 ),
1184 }
1185 eprintln!();
1186 eprintln!(
1187 " {} choir doctor
1188",
1189 style.dim("check it:")
1190 );
1191 println!("{url}");
1192 std::process::exit(0)
1193}
1194
1195fn node_upgrade(rest: &[&str]) -> ! {
1198 let style = choir_cli::style::Style::for_stderr();
1199 let command = "choir node upgrade";
1200 let options = match choir_cli::upgrade::parse(rest, state_dir()) {
1201 Ok(options) => options,
1202 Err(error) => {
1203 eprintln!("{} {error}", style.red(&format!("{command}:")));
1204 std::process::exit(2);
1205 }
1206 };
1207 let fail = |what: &str, why: &str| -> ! {
1208 eprintln!(
1209 "
1210 {} {what}: {why}
1211",
1212 style.red("failed")
1213 );
1214 std::process::exit(1)
1215 };
1216 eprintln!();
1217
1218 let into = match &options.into {
1220 Some(into) => into.clone(),
1221 None => {
1222 let exe = std::env::current_exe().unwrap_or_default();
1223 if choir_cli::supervise::in_build_directory(&exe) {
1224 fail(
1225 "into",
1226 &format!(
1227 "this `choir` lives in a build directory:
1228 {}
1229 name the directory the node runs from: {command} ... --into <dir>",
1230 exe.display()
1231 ),
1232 );
1233 }
1234 exe.parent()
1235 .map(std::path::Path::to_path_buf)
1236 .unwrap_or_default()
1237 }
1238 };
1239 let before = choir_cli::upgrade::version_of(&into.join("choir"))
1240 .map(|(line, _)| line)
1241 .unwrap_or_else(|| "nothing installed".to_string());
1242 step("into", &format!("{} ({before})", into.display()));
1243
1244 let layout = choir_cli::serve::Layout::new(&options.state, 8417);
1247 let api = layout
1248 .public()
1249 .or_else(configured_node)
1250 .unwrap_or_else(|| "http://127.0.0.1:8417".to_string());
1251 let running_before = choir_cli::node::status(&api, Some(&layout.auth))
1252 .ok()
1253 .and_then(|(_, view)| view["build"]["commit"].as_str().map(str::to_string));
1254 step(
1255 "running",
1256 &format!(
1257 "{api} {}",
1258 running_before
1259 .as_deref()
1260 .map(|c| c.chars().take(12).collect::<String>())
1261 .unwrap_or_else(|| "not answering".to_string())
1262 ),
1263 );
1264
1265 if options.dry_run {
1266 let plan = match &options.source {
1267 choir_cli::upgrade::Source::Shelf(node) => {
1268 format!(
1269 "sh <({node}/download/install.sh) choir-cli choir-node, into {}",
1270 into.display()
1271 )
1272 }
1273 choir_cli::upgrade::Source::Checkout(dir) => format!(
1274 "cargo build --release in {}, then {} placed into {}",
1275 dir.display(),
1276 choir_cli::upgrade::BINARIES.join(", "),
1277 into.display()
1278 ),
1279 };
1280 step("would", &plan);
1281 step(
1282 "then",
1283 "choir node restart, and the stamp read back from the daemon",
1284 );
1285 eprintln!();
1286 std::process::exit(0);
1287 }
1288
1289 match &options.source {
1291 choir_cli::upgrade::Source::Shelf(node) => {
1292 let cargo_home = match choir_cli::upgrade::cargo_home_for(&into) {
1293 Ok(home) => home,
1294 Err(error) => fail("from", &error),
1295 };
1296 match choir_cli::upgrade::install_from_shelf(node, &cargo_home) {
1297 Ok(said) => {
1298 for line in said.lines() {
1299 eprintln!(" {}", style.dim(line));
1300 }
1301 step("fetched", &format!("{node}/download/"));
1302 }
1303 Err(error) => fail("from", &error),
1304 }
1305 }
1306 choir_cli::upgrade::Source::Checkout(dir) => {
1307 eprintln!(
1308 " {} {:14} cargo build --release in {}",
1309 style.dim(".."),
1310 style.dim("building"),
1311 dir.display()
1312 );
1313 let (built, head) = match choir_cli::upgrade::build(dir) {
1314 Ok(built) => built,
1315 Err(error) => fail("build", &error),
1316 };
1317 step("built", &head.chars().take(12).collect::<String>());
1318 match choir_cli::upgrade::place(&built, &into) {
1319 Ok(placed) => step("placed", &placed.join(", ")),
1320 Err(error) => fail("place", &error),
1321 }
1322 }
1323 }
1324 let (after, wanted) = choir_cli::upgrade::version_of(&into.join("choir"))
1325 .unwrap_or_else(|| ("unreadable".to_string(), None));
1326 step("installed", &after);
1327
1328 let Some(supervisor) = choir_cli::supervise::Supervisor::detect() else {
1330 eprintln!(
1331 "
1332 {} no service manager here; restart the node yourself
1333",
1334 style.cyan("note:")
1335 );
1336 std::process::exit(0);
1337 };
1338 let home = home_dir();
1339 if !supervisor.unit_path(&home).exists() {
1340 eprintln!(
1341 "
1342 {} no unit installed here; restart the node yourself, then: choir node status
1343",
1344 style.cyan("note:")
1345 );
1346 std::process::exit(0);
1347 }
1348 let steps = supervisor.commands(choir_cli::supervise::Action::Install, &home);
1349 let last = steps.len().saturating_sub(1);
1350 for (at, cmd) in steps.iter().enumerate() {
1351 if !run_step(cmd, at != last) {
1352 fail("restart", "the service manager refused");
1353 }
1354 }
1355 step(
1356 "restarted",
1357 &supervisor.unit_path(&home).display().to_string(),
1358 );
1359
1360 let client = match choir_cli::mcp::HttpClient::new(&api, Some(&layout.auth), None) {
1362 Ok(client) => client,
1363 Err(error) => fail("healthy", &error),
1364 };
1365 if let Err(error) = choir_cli::host::wait_healthy(&client, 30) {
1366 fail(
1367 "healthy",
1368 &format!(
1369 "{error}
1370 it says why in {}",
1371 layout.log.display()
1372 ),
1373 );
1374 }
1375 let running = choir_cli::node::status(&api, Some(&layout.auth))
1376 .ok()
1377 .and_then(|(_, view)| view["build"]["commit"].as_str().map(str::to_string));
1378 match (running, wanted) {
1379 (Some(running), Some(wanted))
1380 if running.starts_with(&wanted) || wanted.starts_with(&running) =>
1381 {
1382 step("serving", &running.chars().take(12).collect::<String>());
1383 eprintln!();
1384 println!("{}", running.chars().take(12).collect::<String>());
1385 std::process::exit(0)
1386 }
1387 (Some(running), Some(wanted)) => fail(
1388 "serving",
1389 &format!(
1390 "the node reports {} but the installed choir is {wanted}
1391 is the unit pointing at {}? choir node status",
1392 running.chars().take(12).collect::<String>(),
1393 into.display()
1394 ),
1395 ),
1396 (Some(running), None) => {
1397 step(
1398 "serving",
1399 &format!(
1400 "{} (the installed binary carries no stamp to compare)",
1401 running.chars().take(12).collect::<String>()
1402 ),
1403 );
1404 std::process::exit(0)
1405 }
1406 (None, _) => fail(
1407 "serving",
1408 "the node answers, but its view does not name a build",
1409 ),
1410 }
1411}
1412
1413fn repo_follower(rest: &[&str]) -> ! {
1415 let style = choir_cli::style::Style::for_stdout();
1416 let command = "choir repo follower";
1417 let mut state: Option<String> = None;
1418 let mut positional: Vec<&str> = Vec::new();
1419 let mut i = 0;
1420 while i < rest.len() {
1421 match rest[i] {
1422 "--state" => {
1423 let Some(value) = rest.get(i + 1) else {
1424 eprintln!("{command}: --state needs a value");
1425 std::process::exit(2);
1426 };
1427 state = Some((*value).to_string());
1428 i += 2;
1429 }
1430 other if other.starts_with('-') => {
1431 eprintln!("{command}: unknown option {other:?}");
1432 std::process::exit(2);
1433 }
1434 arg => {
1435 positional.push(arg);
1436 i += 1;
1437 }
1438 }
1439 }
1440 let state = state
1441 .map(std::path::PathBuf::from)
1442 .unwrap_or_else(state_dir);
1443 let layout = choir_cli::serve::Layout::new(&state, 0);
1444 match positional.as_slice() {
1445 ["add", repo, name, url] => match choir_cli::follower::add(&layout, repo, name, url) {
1446 Ok(first) => {
1447 note(
1448 "follower added",
1449 &[
1450 ("repository", choir_cli::follower::repository_name(repo)),
1451 ("remote", format!("{name} -> {url}")),
1452 ],
1453 );
1454 if first {
1455 eprintln!(
1456 " {} the node pushes after every landing once restarted:
1457 choir node restart
1458",
1459 style.cyan("next:")
1460 );
1461 }
1462 eprintln!(
1463 " {} choir repo follower push {}
1464",
1465 style.dim("push now:"),
1466 choir_cli::follower::repository_name(repo)
1467 );
1468 std::process::exit(0)
1469 }
1470 Err(error) => {
1471 eprintln!("{} {error}", style.red(&format!("{command} add:")));
1472 std::process::exit(1);
1473 }
1474 },
1475 ["list"] => {
1476 let listed = choir_cli::follower::list(&layout);
1477 if listed.is_empty() {
1478 eprintln!(
1479 "no repositories listed in {}",
1480 layout.state.join("repos.list").display()
1481 );
1482 std::process::exit(1);
1483 }
1484 for (repo, remotes) in &listed {
1485 if remotes.is_empty() {
1486 println!("{repo} {}", style.cyan("no remote: no second copy yet"));
1487 }
1488 for (name, url) in remotes {
1489 println!("{repo} {name} -> {url}");
1490 }
1491 }
1492 eprintln!(
1493 "
1494 {} {}
1495",
1496 style.dim("after each landing:"),
1497 if choir_cli::follower::following(&layout) {
1498 "pushed (--followers)"
1499 } else {
1500 "not pushed; `choir repo follower add` turns it on"
1501 }
1502 );
1503 std::process::exit(0)
1504 }
1505 ["push"] | ["push", _] => {
1506 let only = positional.get(1).copied();
1507 let outcomes = choir_cli::follower::push(&layout, only);
1508 if outcomes.is_empty() {
1509 eprintln!("nothing to push: no repository listed");
1510 std::process::exit(1);
1511 }
1512 let mut failed = false;
1513 for outcome in &outcomes {
1514 let line = match outcome.event {
1515 "pushed" => format!(
1516 "{} {} -> {}",
1517 style.green("pushed"),
1518 outcome.repo,
1519 outcome.remote
1520 ),
1521 "no_remote" => format!(
1522 "{} {}: {}",
1523 style.cyan("skipped"),
1524 outcome.repo,
1525 outcome.detail
1526 ),
1527 _ => {
1528 failed = true;
1529 format!(
1530 "{} {} -> {}: {}",
1531 style.red("FAILED"),
1532 outcome.repo,
1533 outcome.remote,
1534 outcome.detail
1535 )
1536 }
1537 };
1538 println!("{line}");
1539 }
1540 std::process::exit(i32::from(failed))
1541 }
1542 _ => {
1543 eprintln!(
1544 "{command}: add <owner/repo.git> <name> <url> | list | push [<owner/repo.git>]"
1545 );
1546 std::process::exit(2);
1547 }
1548 }
1549}
1550
1551fn backup_options(command: &str, rest: &[&str]) -> (std::path::PathBuf, std::path::PathBuf, u64) {
1553 let mut dest: Option<String> = None;
1554 let mut state: Option<String> = None;
1555 let mut every: u64 = 3600;
1556 let mut i = 0;
1557 while i < rest.len() {
1558 match rest[i] {
1559 "--state" | "--every" => {
1560 let Some(value) = rest.get(i + 1) else {
1561 eprintln!("{command}: {} needs a value", rest[i]);
1562 std::process::exit(2);
1563 };
1564 if rest[i] == "--state" {
1565 state = Some((*value).to_string());
1566 } else {
1567 every = match value.parse::<u64>() {
1568 Ok(n) if n >= 60 => n,
1569 _ => {
1570 eprintln!("{command}: --every is a number of seconds, at least 60, not {value:?}");
1571 std::process::exit(2);
1572 }
1573 };
1574 }
1575 i += 2;
1576 }
1577 other if other.starts_with('-') => {
1578 eprintln!("{command}: unknown option {other:?}");
1579 std::process::exit(2);
1580 }
1581 positional => {
1582 if dest.is_some() {
1583 eprintln!("{command}: one backup directory, not two");
1584 std::process::exit(2);
1585 }
1586 dest = Some(positional.to_string());
1587 i += 1;
1588 }
1589 }
1590 }
1591 let Some(dest) = dest else {
1592 eprintln!(
1593 "{command}: where should the backup go?
1594
1595 {command} <backup-dir>"
1596 );
1597 std::process::exit(2);
1598 };
1599 let dest = std::path::PathBuf::from(dest);
1600 let dest = std::path::absolute(&dest).unwrap_or(dest);
1601 let state = state
1602 .map(std::path::PathBuf::from)
1603 .unwrap_or_else(state_dir);
1604 let state = std::path::absolute(&state).unwrap_or(state);
1605 (dest, state, every)
1606}
1607
1608fn backup_take(rest: &[&str]) -> ! {
1610 let style = choir_cli::style::Style::for_stdout();
1611 let command = "choir backup take";
1612 let (dest, state, _) = backup_options(command, rest);
1613 if dest.starts_with(&state) {
1617 eprintln!(
1618 "{} {} is inside the state directory it would back up
1619
1620 a backup on the disk it is meant to survive is not one; give another path",
1621 style.red(&format!("{command}:")),
1622 dest.display()
1623 );
1624 std::process::exit(2);
1625 }
1626 let layout = choir_cli::serve::Layout::new(&state, 0);
1627 let daemon = choir_cli::serve::find_daemon().ok();
1628 let taken = match choir_cli::backup::take(&layout, &dest, daemon.as_deref()) {
1629 Ok(taken) => taken,
1630 Err(error) => {
1631 eprintln!("{} {error}", style.red(&format!("{command}:")));
1632 std::process::exit(1);
1633 }
1634 };
1635 let mut rows: Vec<(&str, String)> = vec![
1636 ("into", taken.dest.display().to_string()),
1637 (
1638 "log",
1639 format!("{} ops, next seq {}", taken.ops, taken.next_seq),
1640 ),
1641 ];
1642 if let Some((was, now)) = taken.grew {
1643 rows.push(("grew", format!("{was} -> {now} bytes")));
1644 }
1645 for line in &taken.bundles {
1646 rows.push(("bundle", line.clone()));
1647 }
1648 note("taken", &rows);
1649 for warning in &taken.warnings {
1650 eprintln!(" {} {warning}", style.cyan("!!"));
1651 }
1652 if !taken.warnings.is_empty() {
1653 eprintln!();
1654 }
1655 let checks = choir_cli::backup::verify(&dest, daemon.as_deref());
1656 print!("{}", choir_cli::doctor::report(&checks, style));
1657 std::process::exit(i32::from(!choir_cli::backup::restorable(&checks)))
1658}
1659
1660fn backup_schedule(rest: &[&str]) -> ! {
1662 let style = choir_cli::style::Style::for_stdout();
1663 let command = "choir backup schedule";
1664 let (dest, state, every) = backup_options(command, rest);
1665 let supervisor = supervisor(command);
1666 let exe = match std::env::current_exe() {
1667 Ok(exe) => exe,
1668 Err(_) => {
1669 eprintln!(
1670 "{} cannot find my own path",
1671 style.red(&format!("{command}:"))
1672 );
1673 std::process::exit(1);
1674 }
1675 };
1676 if choir_cli::supervise::in_build_directory(&exe) {
1677 eprintln!(
1678 "{} this `choir` lives in a build directory:
1679 {}
1680
1681 a timer pointing there stops working at the next `cargo clean`. install it first.",
1682 style.red(&format!("{command}:")),
1683 exe.display()
1684 );
1685 std::process::exit(1);
1686 }
1687 let home = home_dir();
1688 let units = supervisor.backup_units(&home);
1689 let bodies = supervisor.render_backup(&exe, &state, &dest, every);
1690 for (unit, body) in units.iter().zip(bodies) {
1691 if let Some(parent) = unit.parent() {
1692 if let Err(error) = std::fs::create_dir_all(parent) {
1693 eprintln!(
1694 "{} create {}: {error}",
1695 style.red(&format!("{command}:")),
1696 parent.display()
1697 );
1698 std::process::exit(1);
1699 }
1700 }
1701 if let Err(error) = choir_fs::write_atomic(unit, body) {
1702 eprintln!(
1703 "{} write {}: {error}",
1704 style.red(&format!("{command}:")),
1705 unit.display()
1706 );
1707 std::process::exit(1);
1708 }
1709 }
1710 let steps = supervisor.backup_commands(choir_cli::supervise::Action::Install, &home);
1711 let last = steps.len().saturating_sub(1);
1712 for (at, step) in steps.iter().enumerate() {
1713 if !run_step(step, at != last) {
1714 eprintln!(
1715 "{} the service manager refused",
1716 style.red(&format!("{command}:"))
1717 );
1718 std::process::exit(1);
1719 }
1720 }
1721 let rows: Vec<(&str, String)> = vec![
1722 ("every", format!("{every}s")),
1723 ("into", dest.display().to_string()),
1724 (
1725 "unit",
1726 units
1727 .iter()
1728 .map(|u| u.display().to_string())
1729 .collect::<Vec<_>>()
1730 .join(", "),
1731 ),
1732 ("log", state.join("backup.log").display().to_string()),
1733 ];
1734 note("scheduled", &rows);
1735 eprintln!(
1736 " {} choir backup verify {}
1737",
1738 style.dim("check one"),
1739 dest.display()
1740 );
1741 std::process::exit(0)
1742}
1743
1744fn tls_line(domain: &str, user: &str, port: u16, dry_run: bool) -> String {
1746 let mut line = format!("sudo choir node tls {domain} --user {user} --port {port}");
1747 if dry_run {
1748 line.push_str(" --dry-run");
1749 }
1750 line
1751}
1752
1753fn rerun_line(rest: &[&str]) -> String {
1755 let mut line = "choir host".to_string();
1756 for argument in rest {
1757 line.push(' ');
1758 line.push_str(argument);
1759 }
1760 line
1761}
1762
1763fn node_tls(rest: &[&str]) -> ! {
1772 let style = choir_cli::style::Style::for_stderr();
1773 let command = "choir node tls";
1774 let mut domain: Option<&str> = None;
1775 let mut user: Option<String> = None;
1776 let mut port = 8417u16;
1777 let mut issuance = choir_cli::tls::Issuance::Live;
1778 let mut i = 0;
1779 while i < rest.len() {
1780 match rest[i] {
1781 "--dry-run" => {
1782 issuance = choir_cli::tls::Issuance::DryRun;
1783 i += 1;
1784 }
1785 "--staging" => {
1786 issuance = choir_cli::tls::Issuance::Staging;
1787 i += 1;
1788 }
1789 flag @ ("--user" | "--port") => {
1790 let Some(value) = rest.get(i + 1) else {
1791 eprintln!("{command}: {flag} needs a value");
1792 std::process::exit(2);
1793 };
1794 match flag {
1795 "--user" => user = Some((*value).to_string()),
1796 _ => match value.parse() {
1797 Ok(n) => port = n,
1798 Err(_) => {
1799 eprintln!("{command}: --port needs a port number, not {value:?}");
1800 std::process::exit(2);
1801 }
1802 },
1803 }
1804 i += 2;
1805 }
1806 other if !other.starts_with('-') && domain.is_none() => {
1807 domain = Some(other);
1808 i += 1;
1809 }
1810 other => {
1811 eprintln!("{command}: unknown option {other:?}");
1812 std::process::exit(2);
1813 }
1814 }
1815 }
1816 let Some(domain) = domain else {
1817 eprintln!(
1818 "{command}: which name is the certificate for?\n\n \
1819 sudo choir node tls <domain> --user <the account the node runs as>"
1820 );
1821 std::process::exit(2);
1822 };
1823 let Some(user) = user else {
1829 eprintln!(
1830 "{command}: --user is required — which unprivileged account does the node\n \
1831 run as? Under sudo this process is root, and guessing would put the\n \
1832 certificate in the wrong home while the node kept serving plaintext.\n\n \
1833 sudo choir node tls {domain} --user $(id -un)"
1834 );
1835 std::process::exit(2);
1836 };
1837 let home = home_of(&user).unwrap_or_else(|| {
1838 eprintln!("{command}: no such user: {user}");
1839 std::process::exit(1);
1840 });
1841 let plan = choir_cli::tls::Plan::new(domain, port, &user, &home);
1842 let challenge = choir_cli::tls::Challenge::for_state(&plan.state);
1843 let uid = choir_cli::tls::uid();
1844 if let Err(problems) = choir_cli::tls::preflight(&plan, challenge, &uid) {
1845 eprintln!("\n {} {problems}\n", style.red(&format!("{command}:")));
1846 std::process::exit(1);
1847 }
1848 eprintln!();
1849 if challenge == choir_cli::tls::Challenge::Http01 {
1850 eprintln!(
1851 " {} HTTP-01: port 80 must be reachable from the internet now and at\n \
1852 every renewal. There is no $HOME/.choir/cloudflare.ini here, which is\n \
1853 what selects DNS-01 instead.\n",
1854 style.dim("method:")
1855 );
1856 }
1857 let (steps, failure) = match choir_cli::tls::apply(&plan, challenge, issuance, &uid) {
1858 Ok(steps) => (steps, None),
1859 Err((steps, error)) => (steps, Some(error)),
1860 };
1861 for one in &steps {
1862 match &one.outcome {
1863 Ok(detail) => step(&one.what, detail),
1864 Err(error) => eprintln!(
1865 " {} {:14} {error}",
1866 style.red("--"),
1867 style.dim(&one.what)
1868 ),
1869 }
1870 }
1871 if let Some(error) = failure {
1872 eprintln!(
1873 "\n {} {error}\n\n \
1874 nothing the node reads was changed, so it is still serving whatever it\n \
1875 was serving before.\n",
1876 style.red("stopped:")
1877 );
1878 std::process::exit(1);
1879 }
1880 if issuance == choir_cli::tls::Issuance::DryRun {
1881 eprintln!(
1882 "\n {} the path works and no certificate was issued. Run it again\n \
1883 without --dry-run.\n",
1884 style.green("dry run:")
1885 );
1886 std::process::exit(0);
1887 }
1888 eprintln!(
1889 "\n {} renewal is certbot's own timer; the hook above re-projects the pair\n \
1890 and restarts the node, because the daemon reads its certificate once at\n \
1891 bind and has no reload.\n\n \
1892 {} back as {user}: choir host --domain {domain} --port {port}\n",
1893 style.dim("renewal:"),
1894 style.dim("then:")
1895 );
1896 println!("{}", plan.url());
1897 std::process::exit(0)
1898}
1899
1900fn home_of(user: &str) -> Option<std::path::PathBuf> {
1906 if let Some(out) = std::process::Command::new("getent")
1907 .args(["passwd", user])
1908 .output()
1909 .ok()
1910 .filter(|out| out.status.success())
1911 {
1912 let text = String::from_utf8_lossy(&out.stdout);
1913 let field = text.trim().split(':').nth(5)?;
1914 if !field.is_empty() {
1915 return Some(std::path::PathBuf::from(field));
1916 }
1917 }
1918 let out = std::process::Command::new("dscl")
1919 .args([".", "-read", &format!("/Users/{user}"), "NFSHomeDirectory"])
1920 .output()
1921 .ok()
1922 .filter(|out| out.status.success())?;
1923 let text = String::from_utf8_lossy(&out.stdout);
1924 let field = text.trim().strip_prefix("NFSHomeDirectory:")?.trim();
1925 match field.is_empty() {
1926 true => None,
1927 false => Some(std::path::PathBuf::from(field)),
1928 }
1929}
1930
1931fn invite(api: &str, auth: AuthOptions<'_>, name: &str, repo: &str, level: &str) -> ! {
1932 let answer = operator_call(
1933 api,
1934 auth,
1935 "POST",
1936 "/api/accounts/invite",
1937 &serde_json::json!({
1938 "display_name": name,
1939 "grants": [grant_line(repo, checked_level(level))],
1940 }),
1941 );
1942 match answer["join_url"].as_str() {
1943 Some(url) => println!("{url}"),
1944 None => println!("{}", answer["invite"].as_str().unwrap_or_default()),
1948 }
1949 std::process::exit(0)
1950}
1951
1952fn asks(api: &str, auth: AuthOptions<'_>) -> ! {
1957 let answer = operator_call(api, auth, "GET", "/api/accounts", &serde_json::json!({}));
1958 let rows = answer["requests"]
1959 .as_array()
1960 .map(Vec::as_slice)
1961 .unwrap_or_default();
1962 if rows.is_empty() {
1963 println!("Nobody is waiting.");
1964 std::process::exit(0);
1965 }
1966 let now = std::time::SystemTime::now()
1967 .duration_since(std::time::UNIX_EPOCH)
1968 .map_or(0, |d| d.as_secs());
1969 for row in rows {
1970 let asked_at = row["asked_at"].as_u64().unwrap_or(now);
1971 let waited = now.saturating_sub(asked_at) / 3600;
1972 println!(
1973 "{} {} ({waited}h) {}",
1974 row["request_id"].as_str().unwrap_or("?"),
1975 row["display_name"].as_str().unwrap_or("?"),
1976 row["about"].as_str().unwrap_or("")
1977 );
1978 }
1979 std::process::exit(0)
1980}
1981
1982fn grant(api: &str, auth: AuthOptions<'_>, id: &str, repo: &str, level: &str) -> ! {
1988 let answer = operator_call(
1989 api,
1990 auth,
1991 "POST",
1992 "/api/accounts/request/grant",
1993 &serde_json::json!({
1994 "request_id": id,
1995 "grants": [grant_line(repo, checked_level(level))],
1996 }),
1997 );
1998 println!(
1999 "{} is in as {}. The link they already hold now works; send nothing.",
2000 answer["display_name"].as_str().unwrap_or("they"),
2001 answer["user"].as_str().unwrap_or("?")
2002 );
2003 std::process::exit(0)
2004}
2005
2006fn decline(api: &str, auth: AuthOptions<'_>, id: &str) -> ! {
2008 operator_call(
2009 api,
2010 auth,
2011 "POST",
2012 "/api/accounts/request/decline",
2013 &serde_json::json!({ "request_id": id }),
2014 );
2015 println!("Declined. Their link now reads as one that was never valid.");
2016 std::process::exit(0)
2017}
2018
2019fn acl_render(api: &str, auth: AuthOptions<'_>, acl_file: &str) -> ! {
2034 let endpoint = choir_cli::surface::endpoint("GET", "/api/accounts")
2035 .expect("the accounts roster is in the endpoint table");
2036 let client = match choir_cli::mcp::HttpClient::new(
2037 api,
2038 auth.file_for(api).map(std::path::Path::new),
2039 auth.user,
2040 ) {
2041 Ok(client) => client,
2042 Err(error) => {
2043 eprintln!("choir: {error}");
2044 std::process::exit(2);
2045 }
2046 };
2047 let (status, body) = match client.request(endpoint, &serde_json::json!({})) {
2048 Ok(response) => response,
2049 Err(error) => {
2050 eprintln!("choir: {error}");
2051 std::process::exit(1);
2052 }
2053 };
2054 if !(200..300).contains(&status) {
2055 eprintln!(
2056 "choir: GET /api/accounts returned {status}: {body}\n\
2057 reading the roster needs a credential with `@node auditor`."
2058 );
2059 std::process::exit(1);
2060 }
2061 let roster = match serde_json::from_str::<serde_json::Value>(&body) {
2062 Ok(doc) => doc["accounts"]
2063 .as_array()
2064 .map(Vec::as_slice)
2065 .unwrap_or_default()
2066 .iter()
2067 .filter_map(|account| {
2068 Some((
2069 account["user"].as_str()?.to_string(),
2070 account["display_name"].as_str()?.to_string(),
2071 ))
2072 })
2073 .collect::<choir_cli::acl::Roster>(),
2074 Err(error) => {
2075 eprintln!("choir: /api/accounts response is not JSON: {error}");
2076 std::process::exit(1);
2077 }
2078 };
2079
2080 let path = std::path::Path::new(acl_file);
2081 let before = match std::fs::read_to_string(path) {
2082 Ok(text) => text,
2083 Err(error) => {
2084 eprintln!("choir: cannot read {acl_file}: {error}");
2085 std::process::exit(1);
2086 }
2087 };
2088 let after = choir_cli::acl::render(&before, &roster);
2089 let (grants, named) = choir_cli::acl::counts(&before, &roster);
2090 let wrote = after != before;
2091 if wrote {
2092 if let Err(error) = choir_fs::write_atomic_private(path, &after) {
2098 eprintln!("choir: cannot write {acl_file}: {error}");
2099 std::process::exit(1);
2100 }
2101 }
2102 let doc = serde_json::json!({
2103 "path": path.display().to_string(),
2104 "wrote": wrote,
2105 "grants": grants,
2106 "named": named,
2107 "unresolved": grants - named,
2108 });
2109 finish(200, &doc.to_string());
2110}
2111
2112enum Invite<'a> {
2120 File(&'a str),
2122 Pair(String, String),
2124}
2125
2126fn ensure_state_dir() -> Result<(), String> {
2133 let dir = state_dir();
2134 if !dir.is_dir() {
2135 std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
2136 }
2137 #[cfg(unix)]
2138 {
2139 use std::os::unix::fs::PermissionsExt;
2140 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
2141 .map_err(|e| format!("chmod 700 {}: {e}", dir.display()))?;
2142 }
2143 Ok(())
2144}
2145
2146fn refusal_next(body: &str) -> Option<String> {
2153 let doc: serde_json::Value = serde_json::from_str(body).ok()?;
2154 doc.get("next")?.as_str().map(str::to_string)
2155}
2156
2157fn redeem_next(body: &str) -> String {
2166 let message = serde_json::from_str::<serde_json::Value>(body)
2167 .ok()
2168 .and_then(|doc| doc.get("error")?.as_str().map(str::to_string))
2169 .unwrap_or_default();
2170 if message.contains("no such invite") {
2171 return "ask the operator for a new link; this one was never valid, or has been used"
2172 .to_string();
2173 }
2174 if message.contains("expired") {
2175 return "ask the operator for a new link; this one has expired".to_string();
2176 }
2177 if message.contains("--invite-binds-keys") {
2178 return "send the operator the line `choir key <key-file> <channel>` prints, \
2179 and ask them to register it"
2180 .to_string();
2181 }
2182 if message.contains("already taken") || message.contains("username") {
2183 return "choir join '<link>' --user <another-name>".to_string();
2184 }
2185 "send the operator that message; it describes their node, not your machine".to_string()
2186}
2187
2188fn join(api: &str, invite: Invite<'_>, key_file: Option<&str>, rest: &[&str]) -> ! {
2216 let (mut channel, mut ssh_key, mut token_file, mut chosen_user) = (None, None, None, None);
2217 let mut key_flag = None;
2218 let mut no_clone = false;
2219 let mut index = 0;
2220 while index < rest.len() {
2221 if rest[index] == "--no-clone" && !no_clone {
2224 no_clone = true;
2225 index += 1;
2226 continue;
2227 }
2228 let Some(value) = rest.get(index + 1).copied() else {
2229 usage();
2230 };
2231 let slot = match rest[index] {
2232 "--channel" if channel.is_none() => &mut channel,
2233 "--ssh-key" if ssh_key.is_none() => &mut ssh_key,
2234 "--token-file" if token_file.is_none() => &mut token_file,
2235 "--key-file" if key_flag.is_none() => &mut key_flag,
2239 "--user" if chosen_user.is_none() => &mut chosen_user,
2243 _ => usage(),
2244 };
2245 *slot = Some(value);
2246 index += 2;
2247 }
2248 let from_link = key_file.is_none();
2249 if from_link {
2250 if let Err(error) = ensure_state_dir() {
2251 eprintln!("choir join: {error}\nnext: choir join '<link>' --key-file <path>");
2252 std::process::exit(1);
2253 }
2254 }
2255 let default_key = state_dir().join("agent.key").display().to_string();
2270 let key_file = match (key_flag, key_file) {
2271 (Some(named), _) | (None, Some(named)) => named.to_string(),
2272 (None, None) => {
2273 let joined =
2274 std::path::Path::new(&default_key).exists() && state_dir().join("auth").exists();
2275 if let (true, Invite::Pair(id, _)) = (joined, &invite) {
2282 let home = state_dir().join("config");
2283 if configured_in(&home, "node").as_deref() == Some(api.trim_end_matches('/'))
2284 && configured_in(&home, "invite").as_deref() == Some(id.as_str())
2285 {
2286 rejoin(api, no_clone);
2287 }
2288 }
2289 if joined {
2290 eprintln!(
2291 "choir join: this machine has already joined a node: there is a key at \
2292 {default_key} and a token beside it.\n\
2293 A key is an identity the node has bound, so this will not replace one.\n\
2294 next: choir join '<link>' --key-file <another-path> --token-file <another-path>"
2295 );
2296 std::process::exit(1);
2297 }
2298 default_key
2299 }
2300 };
2301 let key_file = key_file.as_str();
2302 let invite_file = match invite {
2303 Invite::File(path) => {
2304 if !std::path::Path::new(path).is_file() {
2305 eprintln!(
2306 "choir join: {path} does not exist.\n\
2307 Write the invite the operator sent you into it, as one line: <id>:<secret>\n\
2308 next: choir join '<link>' (the link needs no file at all)"
2309 );
2310 std::process::exit(2);
2311 }
2312 Some(path)
2313 }
2314 Invite::Pair(..) => None,
2315 };
2316 let key = load_key(key_file);
2321 let mut body = serde_json::json!({ "actor_key": hex_encode(&key.public_key_bytes()) });
2322 if let Some(user) = chosen_user {
2323 body["user"] = serde_json::json!(user);
2324 }
2325 if let Some(channel) = channel {
2326 body["channel"] = serde_json::json!(channel);
2327 }
2328 if let Some(path) = ssh_key {
2329 match std::fs::read_to_string(path) {
2330 Ok(line) => body["ssh_key"] = serde_json::json!(line.trim()),
2331 Err(error) => {
2332 eprintln!("choir join: cannot read {path}: {error}");
2333 std::process::exit(2);
2334 }
2335 }
2336 }
2337
2338 let endpoint = choir_cli::surface::endpoint("POST", "/api/accounts/redeem")
2339 .expect("redemption is in the endpoint table");
2340 let client = match &invite {
2341 Invite::File(_) => {
2342 choir_cli::mcp::HttpClient::new(api, invite_file.map(std::path::Path::new), None)
2343 }
2344 Invite::Pair(id, secret) => choir_cli::mcp::HttpClient::with_credential(api, id, secret),
2345 };
2346 let client = match client {
2347 Ok(client) => client,
2348 Err(error) => {
2349 eprintln!("choir join: {error}\nnext: choir join '<link>'");
2350 std::process::exit(2);
2351 }
2352 };
2353 let send = |body: &serde_json::Value| match client.request(endpoint, body) {
2354 Ok(response) => response,
2355 Err(error) => {
2356 eprintln!(
2357 "choir join: {error}\n\
2358 next: choir doctor (it says which of curl, the network or the node is at fault)"
2359 );
2360 std::process::exit(1);
2361 }
2362 };
2363 let (mut status, mut response) = send(&body);
2364 if status == 400 && response.contains("this invite lets you pick your name") {
2370 match choir_cli::prompt::ask("Pick the name this account keeps (letters, digits, - and _):")
2371 {
2372 Some(name) => {
2373 body["user"] = serde_json::json!(name);
2374 (status, response) = send(&body);
2375 }
2376 None => {
2377 eprintln!(
2378 "choir join: this invite lets you pick your name, and nothing here can \
2379 ask for one.\n\
2380 next: choir join '<link>' --user <name>"
2381 );
2382 std::process::exit(2);
2383 }
2384 }
2385 }
2386 if !(200..300).contains(&status) {
2387 println!("{response}");
2388 eprintln!(
2395 "\nnext: {}",
2396 refusal_next(&response).unwrap_or_else(|| redeem_next(&response))
2397 );
2398 std::process::exit(1);
2399 }
2400 let account: serde_json::Value = match serde_json::from_str(&response) {
2401 Ok(account) => account,
2402 Err(error) => {
2403 eprintln!(
2407 "choir join: the node's answer did not parse: {error}\n\
2408 next: send the operator that line; their node answered something \
2409 this version cannot read"
2410 );
2411 std::process::exit(1);
2412 }
2413 };
2414 let (Some(user), Some(token)) = (account["user"].as_str(), account["token"].as_str()) else {
2415 eprintln!(
2416 "choir join: the node issued no token\n\
2417 next: send the operator this; the invite was accepted and nothing was handed back"
2418 );
2419 std::process::exit(1);
2420 };
2421
2422 let token_path = match (token_file, from_link) {
2432 (Some(named), _) => std::path::PathBuf::from(named),
2433 (None, true) => state_dir().join("auth"),
2434 (None, false) => std::path::Path::new(key_file)
2435 .parent()
2436 .unwrap_or(std::path::Path::new("."))
2437 .join("choir.auth"),
2438 };
2439 if let Err(error) = choir_fs::write_atomic_private(&token_path, format!("{user}:{token}\n")) {
2440 eprintln!(
2441 "choir join: the node issued a token but it could not be stored at {}: {error}\n\
2442 The invite is spent, so this token cannot be reissued.\n\
2443 next: ask the operator for another link, then run \
2444 `choir join '<link>' --token-file <a-writable-path>`",
2445 token_path.display()
2446 );
2447 std::process::exit(1);
2448 }
2449
2450 let bound = account["actor_key_bound"] == serde_json::Value::Bool(true);
2451 let channel = account["channel"].as_str().unwrap_or(user);
2452 let grants: Vec<&str> = account["grants"]
2453 .as_array()
2454 .map(|rows| rows.iter().filter_map(serde_json::Value::as_str).collect())
2455 .unwrap_or_default();
2456 let (git_config, home_config) = match (from_link, &invite) {
2462 (true, Invite::Pair(id, _)) => (
2463 configure_git_credential(api, &token_path.display().to_string()),
2464 write_home_config(api, channel, key_file, id, &grants),
2465 ),
2466 _ => (None, None),
2467 };
2468 let next = if bound {
2469 "choir propose".to_string()
2470 } else {
2471 format!("choir key {key_file} {channel} — send the operator that line")
2475 };
2476 let summary = serde_json::json!({
2477 "user": user,
2478 "channel": account["channel"],
2479 "grants": account["grants"],
2480 "auth_file": token_path.display().to_string(),
2481 "key_file": key_file,
2482 "actor_key_bound": bound,
2483 "git_config": git_config,
2484 "config": home_config,
2485 "next": next,
2486 });
2487 if !from_link {
2488 note(
2489 &format!("joined as {user}"),
2490 &[
2491 ("token", format!("{} (0600)", token_path.display())),
2492 ("key", key_file.to_string()),
2493 ("next", next),
2494 ],
2495 );
2496 finish(
2497 200,
2498 &serde_json::to_string_pretty(&summary).expect("summary is serializable"),
2499 );
2500 }
2501
2502 let style = choir_cli::style::Style::for_stdout();
2507 println!("\n {}\n", style.green(&format!("Joined {api} as {user}.")));
2508 println!(" {} {}", style.dim("channel "), channel);
2509 println!(
2510 " {} {} {}",
2511 style.dim("key "),
2512 key_file,
2513 style.dim("(0600)")
2514 );
2515 println!(
2516 " {} {} {}",
2517 style.dim("token "),
2518 token_path.display(),
2519 style.dim("(0600)")
2520 );
2521 if let Some(path) = &home_config {
2522 println!(
2523 " {} {} {}",
2524 style.dim("node "),
2525 path,
2526 style.dim("(so no command has to be told the node again)")
2527 );
2528 }
2529 say_git(git_config.as_deref(), api, &token_path, style);
2530 let copies = if no_clone {
2531 Vec::new()
2532 } else {
2533 clone_granted(api, &grants, style)
2534 };
2535 if bound {
2536 say_where_to_go(&copies, style);
2537 } else {
2538 println!(
2539 "\n {}\n {}\n",
2540 style.dim(
2541 "This node does not register keys at redemption. Send the operator this line:"
2542 ),
2543 style.cyan(&format!("choir key {key_file} {channel}")),
2544 );
2545 }
2546 std::process::exit(0);
2547}
2548
2549fn rejoin(api: &str, no_clone: bool) -> ! {
2560 let token_path = state_dir().join("auth");
2561 let user = std::fs::read_to_string(&token_path)
2562 .ok()
2563 .and_then(|line| line.split_once(':').map(|(user, _)| user.to_string()))
2564 .unwrap_or_default();
2565 let repos = configured_in(&state_dir().join("config"), "repos").unwrap_or_default();
2566 let repos: Vec<&str> = repos.split_whitespace().collect();
2567 let style = choir_cli::style::Style::for_stdout();
2568 println!(
2569 "\n {}\n",
2570 style.green(&format!("Already joined {api} as {user}."))
2571 );
2572 let git_config = configure_git_credential(api, &token_path.display().to_string());
2573 say_git(git_config.as_deref(), api, &token_path, style);
2574 let copies = if no_clone {
2575 Vec::new()
2576 } else {
2577 clone_granted(api, &repos, style)
2578 };
2579 say_where_to_go(&copies, style);
2580 std::process::exit(0);
2581}
2582
2583fn say_git(
2586 git_config: Option<&str>,
2587 api: &str,
2588 token_path: &std::path::Path,
2589 style: choir_cli::style::Style,
2590) {
2591 match git_config {
2592 Some(path) => println!(
2593 " {} {} {}",
2594 style.dim("git "),
2595 path,
2596 style.dim("(clone and push need no token in the URL)")
2597 ),
2598 None => println!(
2599 " {} {}",
2600 style.dim("git "),
2601 style.cyan(&format!(
2602 "not configured; run: git config --global credential.{api}.helper \
2603 '!choir git-credential {}'",
2604 token_path.display()
2605 )),
2606 ),
2607 }
2608}
2609
2610fn say_where_to_go(copies: &[String], style: choir_cli::style::Style) {
2612 match copies.first() {
2613 Some(folder) => println!(
2614 "\n {}\n {}\n {}\n",
2615 style.dim(if copies.len() == 1 {
2616 "Go into your copy, commit on a branch as you always would, then propose it:"
2617 } else {
2618 "Go into a copy, commit on a branch as you always would, then propose it:"
2619 }),
2620 style.cyan(&format!("cd {folder}")),
2621 style.cyan("choir propose")
2622 ),
2623 None => println!(
2624 "\n {}\n {}\n",
2625 style.dim("Clone anything you were granted, commit on a branch, then:"),
2626 style.cyan("choir propose")
2627 ),
2628 }
2629}
2630
2631fn clone_granted(api: &str, grants: &[&str], style: choir_cli::style::Style) -> Vec<String> {
2644 let label = style.dim("copy ");
2645 let mut copies = Vec::new();
2646 for (repo, folder) in choir_cli::join::repositories(grants) {
2647 let url = format!("{}/{repo}.git", api.trim_end_matches('/'));
2648 if std::path::Path::new(folder).exists() {
2649 let origin = std::process::Command::new("git")
2650 .args(["-C", folder, "remote", "get-url", "origin"])
2651 .output()
2652 .ok()
2653 .filter(|out| out.status.success())
2654 .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_string());
2655 if origin.as_deref() == Some(url.as_str()) {
2656 println!(
2657 " {label} ./{folder} {}",
2658 style.dim("is already your copy")
2659 );
2660 copies.push(folder.to_string());
2661 } else {
2662 println!(
2663 " {label} ./{folder} {}",
2664 style.dim("is already here, so it was left as it was")
2665 );
2666 }
2667 continue;
2668 }
2669 let cloned = std::process::Command::new("git")
2673 .args(["clone", "-q", "--", &url, folder])
2674 .env("GIT_TERMINAL_PROMPT", "0")
2675 .stdin(std::process::Stdio::null())
2676 .output();
2677 match cloned {
2678 Ok(out) if out.status.success() => {
2679 println!(" {label} ./{folder} {}", style.dim(&format!("({repo})")));
2680 copies.push(folder.to_string());
2681 }
2682 outcome => {
2683 let why = match &outcome {
2684 Ok(out) => String::from_utf8_lossy(&out.stderr)
2685 .lines()
2686 .rfind(|line| !line.trim().is_empty())
2687 .unwrap_or("git gave no reason")
2688 .trim()
2689 .to_string(),
2690 Err(error) => format!("git did not run: {error}"),
2691 };
2692 println!(" {label} {repo} was not cloned: {why}");
2693 println!(
2694 " {:8} {}",
2695 "",
2696 style.cyan(&format!("to try again: git clone {url}"))
2697 );
2698 }
2699 }
2700 }
2701 copies
2702}
2703
2704fn configure_git_credential(api: &str, auth_file: &str) -> Option<String> {
2717 let exe = std::env::current_exe()
2718 .map(|p| p.display().to_string())
2719 .unwrap_or_else(|_| "choir".to_string());
2720 let origin = api.trim_end_matches('/');
2721 let out = std::process::Command::new("git")
2722 .args([
2723 "config",
2724 "--global",
2725 &format!("credential.{origin}.helper"),
2726 &format!("!{exe} git-credential {auth_file}"),
2727 ])
2728 .output()
2729 .ok()?;
2730 if !out.status.success() {
2731 return None;
2732 }
2733 let out = std::process::Command::new("git")
2734 .args([
2735 "config",
2736 "--global",
2737 "--list",
2738 "--show-origin",
2739 "--name-only",
2740 ])
2741 .output()
2742 .ok()?;
2743 String::from_utf8_lossy(&out.stdout)
2748 .lines()
2749 .next()
2750 .and_then(|line| line.split('\t').next())
2751 .and_then(|origin| origin.strip_prefix("file:"))
2752 .map(str::to_string)
2753}
2754
2755fn write_home_config(
2770 api: &str,
2771 channel: &str,
2772 key_file: &str,
2773 invite: &str,
2774 grants: &[&str],
2775) -> Option<String> {
2776 let path = state_dir().join("config");
2777 let existing = std::fs::read_to_string(&path).unwrap_or_default();
2778 let mut out = String::new();
2779 let repos: Vec<&str> = choir_cli::join::repositories(grants)
2780 .into_iter()
2781 .map(|(repo, _)| repo)
2782 .collect();
2783 let repos = repos.join(" ");
2784 let written = [
2785 ("node", api.trim_end_matches('/')),
2786 ("channel", channel),
2787 ("key", key_file),
2788 ("invite", invite),
2789 ("repos", repos.as_str()),
2790 ];
2791 for line in existing.lines() {
2792 let key = line.split_once('=').map(|(k, _)| k.trim()).unwrap_or("");
2793 if !written.iter().any(|(name, _)| *name == key) {
2794 out.push_str(line);
2795 out.push('\n');
2796 }
2797 }
2798 for (name, value) in written {
2799 out.push_str(&format!("{name} = {value}\n"));
2800 }
2801 choir_fs::write_atomic_private(&path, out).ok()?;
2802 Some(path.display().to_string())
2803}
2804
2805fn git_credential(auth_file: &str, user: Option<&str>, operation: &str) -> ! {
2827 match operation {
2828 "store" | "erase" => std::process::exit(0),
2832 "get" => {}
2833 _ => {
2834 eprintln!("choir git-credential: unknown operation `{operation}`");
2835 std::process::exit(2);
2836 }
2837 }
2838 let mut request = String::new();
2843 use std::io::Read;
2844 if std::io::stdin().read_to_string(&mut request).is_err() {
2845 std::process::exit(1);
2846 }
2847 let (user, token) = match choir_cli::mcp::credential_pair(std::path::Path::new(auth_file), user)
2848 {
2849 Ok(pair) => pair,
2850 Err(error) => {
2851 eprintln!("choir git-credential: {error}");
2856 std::process::exit(0);
2857 }
2858 };
2859 println!("username={user}");
2860 println!("password={token}");
2861 std::process::exit(0);
2862}
2863
2864fn git_capture(dir: &std::path::Path, args: &[&str]) -> Result<String, String> {
2871 let out = std::process::Command::new("git")
2872 .args(args)
2873 .current_dir(dir)
2874 .output()
2875 .map_err(|e| format!("cannot run git: {e}"))?;
2876 if !out.status.success() {
2877 return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
2878 }
2879 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
2880}
2881
2882fn git_run(dir: &std::path::Path, args: &[&str]) -> Result<(), String> {
2885 let status = std::process::Command::new("git")
2886 .args(args)
2887 .current_dir(dir)
2888 .status()
2889 .map_err(|e| format!("cannot run git: {e}"))?;
2890 if status.success() {
2891 Ok(())
2892 } else {
2893 Err(format!("git {} failed", args.join(" ")))
2894 }
2895}
2896
2897fn propose_abort(step: &str, detail: &str) -> ! {
2905 eprintln!("choir propose: {step}: {detail}");
2906 if !detail.contains("next:") {
2914 let next = match step {
2915 "checkout" => "run this from inside a git checkout",
2916 "identity" => "choir join '<link>'",
2917 "remote" | "base" => {
2918 "git fetch, then re-run; or name it: choir propose --api <url> --repo <owner/repo>"
2919 }
2920 "push" => "check the push error above; the credential comes from ~/.choir/auth",
2921 _ => "choir doctor",
2922 };
2923 eprintln!("next: {next}");
2924 }
2925 std::process::exit(1);
2926}
2927
2928struct ProposeOptions<'a> {
2930 api: Option<&'a str>,
2931 repo: Option<&'a str>,
2932 remote: &'a str,
2933 onto: Option<&'a str>,
2934 change: Option<&'a str>,
2935 key_file: Option<&'a str>,
2936 channel: Option<&'a str>,
2937 cone: Vec<String>,
2938 reviewers: Vec<String>,
2939}
2940
2941fn positional_propose<'a>(rest: &'a [&'a str]) -> Option<(&'a str, &'a str, &'a [&'a str])> {
2953 let [key_file, channel, tail @ ..] = rest else {
2954 return None;
2955 };
2956 let positional = !key_file.starts_with("--")
2957 && !channel.starts_with("--")
2958 && std::path::Path::new(key_file).is_file();
2959 positional.then_some((key_file, channel, tail))
2960}
2961
2962fn parse_propose<'a>(rest: &[&'a str]) -> ProposeOptions<'a> {
2963 let (mut api, mut repo, mut onto, mut change) = (None, None, None, None);
2964 let (mut key_file, mut channel) = (None, None);
2965 let mut remote = "origin";
2966 let (mut cone, mut reviewers) = (Vec::new(), Vec::new());
2967 let mut index = 0;
2968 while index < rest.len() {
2969 let flag = rest[index];
2970 if !flag.starts_with("--") {
2971 reviewers.push(flag.to_string());
2972 index += 1;
2973 continue;
2974 }
2975 let Some(value) = rest.get(index + 1).copied() else {
2976 usage();
2977 };
2978 match flag {
2979 "--api" if api.is_none() => api = Some(value),
2980 "--repo" if repo.is_none() => repo = Some(value),
2981 "--remote" => remote = value,
2982 "--onto" if onto.is_none() => onto = Some(value),
2983 "--change" if change.is_none() => change = Some(value),
2984 "--path" => cone.push(value.to_string()),
2985 "--key-file" if key_file.is_none() => key_file = Some(value),
2989 "--channel" if channel.is_none() => channel = Some(value),
2990 _ => usage(),
2991 }
2992 index += 2;
2993 }
2994 cone.sort();
2999 cone.dedup();
3000 ProposeOptions {
3001 api,
3002 repo,
3003 remote,
3004 onto,
3005 change,
3006 key_file,
3007 channel,
3008 cone,
3009 reviewers,
3010 }
3011}
3012
3013fn propose(rest: &[&str], auth: AuthOptions<'_>) -> ! {
3032 let positional = positional_propose(rest);
3033 let options = parse_propose(positional.map_or(rest, |(_, _, tail)| tail));
3034 let key_file = positional
3040 .map(|(key_file, _, _)| key_file.to_string())
3041 .or_else(|| options.key_file.map(str::to_string))
3042 .or_else(|| configured("key"))
3043 .unwrap_or_else(|| state_dir().join("agent.key").display().to_string());
3044 if !std::path::Path::new(&key_file).is_file() {
3045 propose_abort(
3046 "identity",
3047 &format!(
3048 "no key at {key_file}\nnext: choir join '<link>' \
3049 (or name one: choir propose --key-file <path>)"
3050 ),
3051 );
3052 }
3053 let channel = positional
3058 .map(|(_, channel, _)| channel.to_string())
3059 .or_else(|| options.channel.map(str::to_string))
3060 .or_else(|| configured("channel"))
3061 .or_else(|| {
3062 let path = discovered_auth_file()?;
3063 choir_cli::mcp::credential_pair(std::path::Path::new(&path), auth.user)
3064 .ok()
3065 .map(|(user, _)| user)
3066 })
3067 .unwrap_or_else(|| {
3068 propose_abort(
3069 "identity",
3070 "nothing here says which channel to sign as\nnext: choir propose --channel <name>",
3071 )
3072 });
3073 let (key_file, channel) = (key_file.as_str(), channel.as_str());
3074 let cwd = std::env::current_dir().unwrap_or_else(|e| propose_abort("checkout", &e.to_string()));
3075 let top = match git_capture(&cwd, &["rev-parse", "--show-toplevel"]) {
3076 Ok(top) => std::path::PathBuf::from(top),
3077 Err(error) => propose_abort("checkout", &error),
3078 };
3079
3080 let remote_url = git_capture(&top, &["remote", "get-url", options.remote]);
3083 let inferred = match (&options.api, &options.repo, &remote_url) {
3084 (Some(api), Some(repo), _) => choir_cli::propose::Remote {
3087 api: (*api).to_string(),
3088 repo: (*repo).to_string(),
3089 },
3090 (_, _, Ok(url)) => match choir_cli::propose::Remote::parse(url) {
3091 Ok(remote) => remote,
3092 Err(failure) => propose_abort("remote", &failure.message),
3093 },
3094 (_, _, Err(error)) => propose_abort("remote", error),
3095 };
3096 let api = options.api.map_or(inferred.api, str::to_string);
3097 let repo = options.repo.map_or(inferred.repo, str::to_string);
3098
3099 let branch =
3103 git_capture(&top, &["symbolic-ref", "--quiet", "--short", "HEAD"]).unwrap_or_default();
3104 let onto = options.onto.map_or_else(
3105 || {
3106 git_capture(
3111 &top,
3112 &[
3113 "symbolic-ref",
3114 "--short",
3115 &format!("refs/remotes/{}/HEAD", options.remote),
3116 ],
3117 )
3118 .ok()
3119 .and_then(|head| head.rsplit('/').next().map(str::to_string))
3120 .unwrap_or_else(|| "main".to_string())
3121 },
3122 str::to_string,
3123 );
3124 let proposal = match choir_cli::propose::Proposal::derive(&repo, &branch, &onto) {
3125 Ok(proposal) => proposal,
3126 Err(failure) => propose_abort("branch", &failure.message),
3127 };
3128 let change_id = options
3129 .change
3130 .map_or(proposal.identity.change_id.clone(), str::to_string);
3131 let head = match git_capture(&top, &["rev-parse", "HEAD"]) {
3132 Ok(head) => head,
3133 Err(error) => propose_abort("checkout", &error),
3134 };
3135
3136 let (status, body) = http(&api, auth, "choir_view", serde_json::json!({}));
3141 if !(200..300).contains(&status) {
3142 propose_abort("view", &format!("GET /api/view returned {status}: {body}"));
3143 }
3144 let view: serde_json::Value = serde_json::from_str(&body)
3145 .unwrap_or_else(|e| propose_abort("view", &format!("response is not JSON: {e}")));
3146 let review_open = view["reviews"]
3150 .get(&change_id)
3151 .is_some_and(|review| !review.is_null() && review["status"] != "archived");
3152 let existing = view["changes"]
3153 .get(&change_id)
3154 .filter(|state| !state.is_null());
3155 let workspace_id = match existing {
3156 Some(state) => {
3157 let workspace = state["active_workspace"]
3158 .as_str()
3159 .unwrap_or_else(|| {
3160 propose_abort(
3161 "change",
3162 "this change's workspace has been archived; propose from a new branch",
3163 )
3164 })
3165 .to_string();
3166 eprintln!(
3167 "choir propose: updating change {}",
3168 choir_cli::propose::short_change_id(&change_id)
3169 );
3170 workspace
3171 }
3172 None => {
3173 let base = git_capture(
3177 &top,
3178 &["merge-base", "HEAD", &format!("{}/{onto}", options.remote)],
3179 )
3180 .unwrap_or_else(|error| propose_abort(
3181 "base",
3182 &format!("{error}\ncannot find where this branch left {}/{onto}; fetch first, or pass --onto", options.remote),
3183 ));
3184 let workspace_name = proposal.identity.workspace_name.clone();
3185 let mut body = serde_json::json!({
3186 "repo": repo,
3187 "name": workspace_name,
3188 "base": base,
3189 "owner": channel,
3190 "change": change_id,
3191 "idempotency_key": proposal.identity.idempotency_key,
3192 });
3193 let Some(base_revision) = choir_hash::ContentHash::from_git_oid(&base) else {
3194 propose_abort("base", "merge-base did not return a git object id");
3195 };
3196 let authorization = CreateAuthorization::new(
3197 change_id.clone(),
3198 channel.into(),
3199 format!("{repo}/{workspace_name}"),
3200 base_revision,
3201 proposal.identity.idempotency_key.clone(),
3202 )
3203 .with_cone(options.cone.clone());
3204 let signed = signed_payload_body(key_file, channel, &authorization.to_payload());
3205 for field in ["channel", "payload_hex", "key_id", "signature_hex"] {
3206 body[field] = signed[field].clone();
3207 }
3208 let (status, response) = http(&api, auth, "choir_workspace", body);
3209 if !(200..300).contains(&status) {
3210 propose_abort("create change", &response);
3211 }
3212 eprintln!(
3213 "choir propose: created change {} on {base}",
3214 choir_cli::propose::short_change_id(&change_id)
3215 );
3216 format!("{repo}/{workspace_name}")
3217 }
3218 };
3219
3220 let revision_ref = proposal.revision_ref(&head);
3226 let refspec = format!("HEAD:{revision_ref}");
3227 if let Err(error) = git_run(&top, &["push", options.remote, &refspec]) {
3228 propose_abort("push", &error);
3229 }
3230
3231 let Some(revision) = choir_hash::ContentHash::from_git_oid(&head) else {
3236 propose_abort("checkpoint", "HEAD is not a git object id");
3237 };
3238 let prev_revision = current_change_revision(&api, auth, &change_id);
3239 if prev_revision != revision {
3240 let op = ViewOp::new(OpKind::CheckpointChange {
3241 id: change_id.clone(),
3242 workspace: workspace_id.clone(),
3243 revision: revision.clone(),
3244 prev_revision,
3245 });
3246 let signed = signed_body(&api, key_file, channel, &op, auth);
3247 let (status, response) = http(&api, auth, "choir_submit", signed);
3248 if !(200..300).contains(&status) {
3249 propose_abort("checkpoint", &response);
3250 }
3251 }
3252
3253 if !review_open {
3260 let op = ViewOp::new(OpKind::RequestReview {
3261 id: change_id.clone(),
3262 target: revision,
3263 reviewers: options.reviewers.clone(),
3264 target_ref: Some(proposal.review_target(&repo)),
3265 });
3266 let signed = signed_body(&api, key_file, channel, &op, auth);
3267 let (status, response) = http(&api, auth, "choir_submit", signed);
3268 if !(200..300).contains(&status) {
3269 propose_abort("request review", &response);
3270 }
3271 }
3272
3273 let summary = serde_json::json!({
3274 "change": change_id,
3275 "workspace": workspace_id,
3276 "commit": head,
3277 "pushed_ref": revision_ref,
3278 "fetch": format!("git fetch {} {revision_ref}", options.remote),
3279 "target_ref": proposal.review_target(&repo),
3280 "reviewers": if options.reviewers.is_empty() {
3281 serde_json::json!("drawn by the node")
3282 } else {
3283 serde_json::json!(options.reviewers)
3284 },
3285 "review": if review_open {
3286 "already open; it now needs re-review against this revision"
3291 } else {
3292 "opened"
3293 },
3294 "next": format!("choir state {api} {channel}"),
3295 });
3296 finish(
3297 200,
3298 &serde_json::to_string_pretty(&summary).expect("summary is serializable"),
3299 );
3300}
3301
3302fn check_exit(subject: &ContentHash, body: &str) -> ! {
3316 let view: serde_json::Value = match serde_json::from_str(body) {
3317 Ok(view) => view,
3318 Err(error) => {
3319 eprintln!("choir checks: the node's view did not parse: {error}");
3320 std::process::exit(1);
3321 }
3322 };
3323 let prefix = format!("{}:", subject.to_hex());
3324 let rows: serde_json::Map<String, serde_json::Value> = view
3325 .get("checks")
3326 .and_then(serde_json::Value::as_object)
3327 .map(|checks| {
3328 checks
3329 .iter()
3330 .filter(|(key, _)| key.starts_with(&prefix))
3331 .map(|(key, value)| (key[prefix.len()..].to_string(), value.clone()))
3332 .collect()
3333 })
3334 .unwrap_or_default();
3335 let status_of = |value: &serde_json::Value| {
3336 value
3337 .get("status")
3338 .and_then(serde_json::Value::as_str)
3339 .map(str::to_lowercase)
3340 };
3341 let (verdict, code) = if rows.is_empty() {
3347 ("unreported", 1)
3348 } else if rows
3349 .values()
3350 .any(|v| status_of(v).as_deref() == Some("failed"))
3351 {
3352 ("failed", 1)
3353 } else if rows
3354 .values()
3355 .any(|v| status_of(v).as_deref() == Some("errored"))
3356 {
3357 ("errored", 4)
3358 } else if rows
3359 .values()
3360 .any(|v| status_of(v).as_deref() == Some("running"))
3361 {
3362 ("running", 3)
3363 } else {
3364 ("passed", 0)
3365 };
3366 println!(
3367 "{}",
3368 serde_json::json!({
3369 "subject": subject.to_hex(),
3370 "verdict": verdict,
3371 "checks": rows,
3372 })
3373 );
3374 std::process::exit(code);
3375}
3376
3377fn finish(status: u16, body: &str) -> ! {
3379 println!("{body}");
3380 if !(200..300).contains(&status) {
3385 if let Some(next) = refusal_next(body) {
3386 eprintln!("\nnext: {next}");
3387 }
3388 }
3389 std::process::exit(if (200..300).contains(&status) { 0 } else { 1 });
3390}
3391
3392fn current_binding(api: &str, auth: AuthOptions<'_>, actor_id: &str) -> Option<serde_json::Value> {
3400 let (status, body) = http(api, auth, "choir_view", serde_json::json!({}));
3401 if !(200..300).contains(&status) {
3402 return None;
3403 }
3404 let view: serde_json::Value = serde_json::from_str(&body).ok()?;
3405 let binding = view.get("bindings")?.get(actor_id)?;
3406 (!binding.is_null()).then(|| binding.clone())
3407}
3408
3409fn latest_snapshot(api: &str, auth: AuthOptions<'_>) -> ContentHash {
3428 let (status, body) = http(api, auth, "choir_view", serde_json::json!({}));
3429 let fail = |why: &str| -> ! {
3430 eprintln!("choir: cannot read the ref-state attestation from {api}: {why}");
3431 eprintln!("choir: not signing a witness statement about a snapshot nobody read.");
3432 std::process::exit(1);
3433 };
3434 if !(200..300).contains(&status) {
3435 fail(&format!("GET /api/view returned {status}"));
3436 }
3437 let view: serde_json::Value = match serde_json::from_str(&body) {
3438 Ok(view) => view,
3439 Err(error) => fail(&format!("response is not JSON: {error}")),
3440 };
3441 match view["snapshot"]["id"].as_str().and_then(hash_from_hex) {
3442 Some(id) => id,
3443 None => fail(
3449 "no `snapshot.id` in the view: either the node has attested no ref-state yet, \
3450 or this credential may not read node-wide sections (`@node auditor`)",
3451 ),
3452 }
3453}
3454
3455fn log_scope(api: &str, auth: AuthOptions<'_>) -> (ContentHash, Option<ContentHash>) {
3456 let (status, body) = http(api, auth, "choir_view", serde_json::json!({}));
3457 let fail = |why: &str| -> ! {
3458 eprintln!("choir: cannot read the log scope from {api}: {why}");
3459 eprintln!("choir: not signing an op that names no log. Retry when the node answers.");
3460 std::process::exit(1);
3461 };
3462 if !(200..300).contains(&status) {
3463 fail(&format!("GET /api/view returned {status}"));
3464 }
3465 let view: serde_json::Value = match serde_json::from_str(&body) {
3466 Ok(view) => view,
3467 Err(error) => fail(&format!("response is not JSON: {error}")),
3468 };
3469 let Some(node) = view["log"]["node"].as_str().and_then(hash_from_hex) else {
3470 fail("response has no `log.node`; this node predates op scopes");
3471 };
3472 let head = match view["log"]["head"].as_str() {
3473 Some(hex) => match hash_from_hex(hex) {
3474 Some(head) => Some(head),
3475 None => fail("`log.head` is not a content hash"),
3476 },
3477 None => None,
3480 };
3481 (node, head)
3482}
3483
3484fn hash_from_hex(hex: &str) -> Option<ContentHash> {
3486 let (codec, digest) = hex.split_once('-')?;
3487 let codec = u8::from_str_radix(codec, 16).ok()?;
3488 let digest: Option<Vec<u8>> = (0..digest.len())
3489 .step_by(2)
3490 .map(|i| u8::from_str_radix(digest.get(i..i + 2)?, 16).ok())
3491 .collect();
3492 Some(ContentHash {
3493 codec,
3494 digest: digest?,
3495 })
3496}
3497
3498fn signed_body(
3503 api: &str,
3504 key_file: &str,
3505 channel: &str,
3506 op: &ViewOp,
3507 auth: AuthOptions<'_>,
3508) -> serde_json::Value {
3509 let (node, head) = log_scope(api, auth);
3510 let op = op.clone().in_scope(node, head);
3511 signed_payload_body(key_file, channel, &op.to_payload())
3512}
3513
3514fn signed_payload_body(key_file: &str, channel: &str, payload: &[u8]) -> serde_json::Value {
3518 let key = load_key(key_file);
3519 let sig = key.sign_submission(channel, payload);
3520 serde_json::json!({
3521 "channel": channel,
3522 "payload_hex": hex_encode(payload),
3523 "key_id": sig.key_id,
3524 "signature_hex": hex_encode(&sig.signature),
3525 })
3526}
3527
3528fn submit(api: &str, key_file: &str, channel: &str, op: &ViewOp, auth: AuthOptions<'_>) -> ! {
3529 let body = signed_body(api, key_file, channel, op, auth);
3530 let (status, resp) = http(api, auth, "choir_submit", body);
3531 finish(status, &resp);
3532}
3533
3534fn load_registry(path: &str) -> Registry {
3542 let text = match std::fs::read_to_string(path) {
3543 Ok(text) => text,
3544 Err(error) => {
3545 eprintln!("choir log: {path}: {error}");
3546 std::process::exit(2);
3547 }
3548 };
3549 let mut registry = Registry::new();
3550 for (number, line) in text.lines().enumerate() {
3551 let line = line.split('#').next().unwrap_or("").trim();
3552 if line.is_empty() {
3553 continue;
3554 }
3555 let hex = line.split_whitespace().last().unwrap_or_default();
3558 let Some(bytes) = hex_decode(hex).and_then(|b| <[u8; 32]>::try_from(b).ok()) else {
3559 eprintln!("choir log: {path}:{}: not 64 hex characters", number + 1);
3560 std::process::exit(2);
3561 };
3562 if registry.register(&bytes).is_err() {
3563 eprintln!("choir log: {path}:{}: not a valid ed25519 key", number + 1);
3564 std::process::exit(2);
3565 }
3566 }
3567 registry
3568}
3569
3570fn revocations(api: &str, auth: AuthOptions<'_>) -> choir_identity::sync::Revocations {
3609 let (status, body) = http(api, auth, "choir_view", serde_json::json!({}));
3610 if !(200..300).contains(&status) {
3611 eprintln!("choir log: cannot read bindings ({status}); revocations not checked");
3612 return choir_identity::sync::Revocations::new();
3613 }
3614 let view: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();
3615 view["bindings"]
3616 .as_object()
3617 .map(|bindings| {
3618 bindings
3619 .iter()
3620 .filter_map(|(key_id, binding)| {
3621 Some((key_id.clone(), binding["revoked"]["at"].as_u64()?))
3622 })
3623 .collect()
3624 })
3625 .unwrap_or_default()
3626}
3627
3628fn log(api: &str, from: u64, verify: bool, keys: Option<&str>, auth: AuthOptions<'_>) -> ! {
3629 let (status, body) = http(api, auth, "choir_log", serde_json::json!({ "from": from }));
3630 if !(200..300).contains(&status) {
3631 println!("{body}");
3635 std::process::exit(1);
3636 }
3637 let page: serde_json::Value = match serde_json::from_str(&body) {
3638 Ok(page) => page,
3639 Err(error) => {
3640 eprintln!("choir log: response is not JSON: {error}");
3641 std::process::exit(1);
3642 }
3643 };
3644 let entries = page["entries"].as_array().cloned().unwrap_or_default();
3645 for entry in &entries {
3646 println!("{entry}");
3647 }
3648 if !verify {
3649 eprintln!("choir log: {} entries, not verified", entries.len());
3650 std::process::exit(0);
3651 }
3652
3653 let registry = keys.map(load_registry).unwrap_or_default();
3654 let revoked = revocations(api, auth);
3662 let report = choir_identity::sync::page(&entries, ®istry, &revoked);
3663 for note in &report.notes {
3664 eprintln!("choir log: {note}");
3665 }
3666 for failure in &report.failures {
3667 eprintln!("choir log: {failure}");
3668 }
3669 let passkeys = if report.integrity_only == 0 {
3676 String::new()
3677 } else {
3678 format!(
3679 ", {} passkey signatures intact but unanchored",
3680 report.integrity_only
3681 )
3682 };
3683 eprintln!(
3684 "choir log: {} entries, chain {}, {} signatures verified, {} unverified{passkeys}",
3685 entries.len(),
3686 if report.failures.is_empty() {
3687 "holds"
3688 } else {
3689 "BROKEN"
3690 },
3691 report.checked,
3692 report.unverified
3693 );
3694 std::process::exit(i32::from(!report.failures.is_empty()));
3695}
3696
3697fn batch(api: &str, key_file: &str, channel: &str, source: &str, auth: AuthOptions<'_>) -> ! {
3719 let text = if source == "-" {
3720 let mut buffer = String::new();
3721 if let Err(error) = std::io::Read::read_to_string(&mut std::io::stdin(), &mut buffer) {
3722 eprintln!("choir batch: cannot read stdin: {error}");
3723 std::process::exit(2);
3724 }
3725 buffer
3726 } else {
3727 match std::fs::read_to_string(source) {
3728 Ok(text) => text,
3729 Err(error) => {
3730 eprintln!("choir batch: {source}: {error}");
3731 std::process::exit(2);
3732 }
3733 }
3734 };
3735
3736 let mut ops: Vec<ViewOp> = Vec::new();
3740 for (number, line) in text.lines().enumerate() {
3741 if line.trim().is_empty() {
3742 continue;
3743 }
3744 match serde_json::from_str::<ViewOp>(line) {
3745 Ok(op) => ops.push(op),
3746 Err(error) => {
3747 eprintln!("choir batch: {source}:{}: {error}", number + 1);
3751 std::process::exit(2);
3752 }
3753 }
3754 }
3755 if ops.is_empty() {
3756 eprintln!("choir batch: {source} contains no operations");
3757 std::process::exit(2);
3758 }
3759
3760 let (node, head) = log_scope(api, auth);
3761 let signed: Vec<serde_json::Value> = ops
3762 .into_iter()
3763 .map(|op| {
3764 let op = op.in_scope(node.clone(), head.clone());
3765 signed_payload_body(key_file, channel, &op.to_payload())
3766 })
3767 .collect();
3768 let count = signed.len();
3769 let (status, resp) = http(
3770 api,
3771 auth,
3772 "choir_submit_batch",
3773 serde_json::json!({ "ops": signed }),
3774 );
3775
3776 let parsed: serde_json::Value = match serde_json::from_str(&resp) {
3777 Ok(value) => value,
3778 Err(_) => {
3779 println!("{resp}");
3784 std::process::exit(if (200..300).contains(&status) { 0 } else { 1 });
3785 }
3786 };
3787 let Some(results) = parsed["results"].as_array() else {
3788 println!("{resp}");
3789 std::process::exit(if (200..300).contains(&status) { 0 } else { 1 });
3790 };
3791 for result in results {
3792 println!("{result}");
3793 }
3794 let accepted = parsed["accepted"].as_u64().unwrap_or(0);
3795 let rejected = parsed["rejected"].as_u64().unwrap_or(0);
3796 eprintln!("choir batch: {accepted} accepted, {rejected} rejected, {count} submitted");
3797 std::process::exit(
3800 if (200..300).contains(&status) && rejected == 0 && results.len() == count {
3801 0
3802 } else {
3803 1
3804 },
3805 );
3806}
3807
3808fn runner_finish(result: &Result<serde_json::Value, choir_cli::runner::Failure>) -> ! {
3815 match result {
3816 Ok(value) => {
3817 println!("{value}");
3818 std::process::exit(0);
3819 }
3820 Err(failure) => {
3821 println!("{}", failure.to_json());
3822 eprintln!("choir runner: {}: {}", failure.code, failure.message);
3823 std::process::exit(1);
3824 }
3825 }
3826}
3827
3828fn runner_json(
3830 source: &str,
3831 code: &str,
3832 what: &str,
3833) -> Result<serde_json::Value, choir_cli::runner::Failure> {
3834 serde_json::from_str(source)
3835 .map_err(|error| choir_cli::runner::Failure::terminal(code, format!("{what}: {error}")))
3836}
3837
3838fn runner(config_file: &str, auth: AuthOptions<'_>) -> ! {
3845 use choir_cli::runner::{Config, Failure, Operation, Request};
3846
3847 let outcome = (|| -> Result<serde_json::Value, Failure> {
3848 let raw = std::fs::read_to_string(config_file).map_err(|error| {
3849 Failure::terminal("invalid_config", format!("cannot read config: {error}"))
3850 })?;
3851 let config = Config::parse(&runner_json(&raw, "invalid_config", "config is not JSON")?)?;
3852
3853 let mut stdin = String::new();
3854 std::io::Read::read_to_string(&mut std::io::stdin(), &mut stdin).map_err(|error| {
3855 Failure::terminal("invalid_request", format!("cannot read stdin: {error}"))
3856 })?;
3857 let request = Request::parse(
3858 &runner_json(&stdin, "invalid_request", "request is not JSON")?,
3859 &config,
3860 )?;
3861
3862 let auth = AuthOptions {
3866 file: config.auth_file.as_deref().or(auth.file_for(&config.api)),
3867 user: config.auth_user.as_deref().or(auth.user),
3868 explicit: true,
3869 };
3870 let id = &request.identity;
3871
3872 match request.operation {
3873 Operation::Ensure => {
3874 let base = match &request.base {
3875 Some(base) => base.clone(),
3876 None => {
3877 let base_ref = config.base_ref.as_ref().ok_or_else(|| {
3878 Failure::terminal(
3879 "invalid_config",
3880 "ensure needs either a request base or a config base_ref",
3881 )
3882 })?;
3883 let (status, body) =
3884 http(&config.api, auth, "choir_view", serde_json::json!({}));
3885 if !(200..300).contains(&status) {
3886 return Err(choir_cli::runner::failure_from_response(
3887 &body,
3888 "base revision lookup",
3889 ));
3890 }
3891 choir_cli::runner::base_from_view(
3892 &runner_json(&body, "invalid_response", "view is not JSON")?,
3893 base_ref,
3894 )?
3895 }
3896 };
3897 let flags: Vec<&str> = vec![
3898 "--base",
3899 &base,
3900 "--owner",
3901 &config.owner,
3902 "--key-file",
3903 &config.key_file,
3904 "--change",
3905 &id.change_id,
3906 "--idempotency-key",
3907 &id.idempotency_key,
3908 ];
3909 let body = workspace_body(&config.repo, &id.workspace_name, &flags);
3910 let (status, resp) = http(&config.api, auth, "choir_workspace", body);
3911 if !(200..300).contains(&status) {
3912 return Err(choir_cli::runner::failure_from_response(
3913 &resp,
3914 "workspace creation",
3915 ));
3916 }
3917 let response = runner_json(&resp, "invalid_response", "creation is not JSON")?;
3918 choir_cli::runner::verify_binding(&response, id)?;
3919 Ok(serde_json::json!({
3920 "protocol_version": choir_cli::runner::PROTOCOL_VERSION,
3921 "operation": "ensure",
3922 "workspace": {
3923 "path": response.get("path").cloned().unwrap_or(serde_json::Value::Null),
3924 "created_now": response.get("created") == Some(&serde_json::json!(true)),
3925 },
3926 "binding": binding_json(
3927 id,
3928 &config,
3929 Some(&choir_cli::runner::bound_base(&response, &base)),
3930 ),
3931 "receipt": response.get("operation").cloned()
3932 .unwrap_or_else(|| serde_json::json!({})),
3933 }))
3934 }
3935 Operation::Checkpoint => {
3936 let revision = request.base.clone().ok_or_else(|| {
3937 Failure::terminal(
3938 "invalid_request",
3939 "checkpoint needs the exact committed and pushed Git object id as base",
3940 )
3941 })?;
3942 let Some(revision_hash) = choir_hash::ContentHash::from_git_oid(&revision) else {
3943 return Err(Failure::terminal(
3944 "invalid_request",
3945 "checkpoint base must be a 40- or 64-char hex Git object id",
3946 ));
3947 };
3948 let prev_revision = current_change_revision(&config.api, auth, &id.change_id);
3949 let op = ViewOp::new(OpKind::CheckpointChange {
3950 id: id.change_id.clone(),
3951 workspace: id.workspace_id.clone(),
3952 revision: revision_hash,
3953 prev_revision,
3954 });
3955 let body = signed_body(&config.api, &config.key_file, &config.owner, &op, auth);
3956 let (status, resp) = http(&config.api, auth, "choir_submit", body);
3957 if !(200..300).contains(&status) {
3958 return Err(choir_cli::runner::failure_from_response(
3959 &resp,
3960 "revision checkpoint",
3961 ));
3962 }
3963 Ok(serde_json::json!({
3964 "protocol_version": choir_cli::runner::PROTOCOL_VERSION,
3965 "operation": "checkpoint",
3966 "checkpoint": {
3967 "change_id": id.change_id,
3968 "workspace_id": id.workspace_id,
3969 "revision_id": revision,
3970 },
3971 "receipt": runner_json(&resp, "invalid_response", "checkpoint is not JSON")
3972 .unwrap_or_else(|_| serde_json::json!({})),
3973 }))
3974 }
3975 Operation::Archive => {
3976 let prev_revision = current_change_revision(&config.api, auth, &id.change_id);
3977 let authorization = ArchiveAuthorization::new(
3978 id.change_id.clone(),
3979 id.workspace_id.clone(),
3980 prev_revision,
3981 );
3982 let mut body = signed_payload_body(
3983 &config.key_file,
3984 &config.owner,
3985 &authorization.to_payload(),
3986 );
3987 body["repo"] = serde_json::json!(config.repo);
3988 body["name"] = serde_json::json!(id.workspace_name);
3989 body["change"] = serde_json::json!(id.change_id);
3990 body["idempotency_key"] = serde_json::json!(id.idempotency_key);
3991 let (status, resp) = http(&config.api, auth, "choir_workspace_archive", body);
3992 if !(200..300).contains(&status) {
3993 return Err(choir_cli::runner::failure_from_response(
3994 &resp,
3995 "workspace archive",
3996 ));
3997 }
3998 let response = runner_json(&resp, "invalid_response", "archive is not JSON")?;
3999 choir_cli::runner::verify_binding(&response, id)?;
4000 Ok(serde_json::json!({
4001 "protocol_version": choir_cli::runner::PROTOCOL_VERSION,
4002 "operation": "archive",
4003 "archive": {
4004 "workspace_id": id.workspace_id,
4005 "change_id": id.change_id,
4006 "archived_path": response.get("archived_path").cloned()
4007 .unwrap_or(serde_json::Value::Null),
4008 "already_archived":
4009 response.get("already_archived") == Some(&serde_json::json!(true)),
4010 },
4011 "receipt": response.get("operation").cloned()
4012 .unwrap_or_else(|| serde_json::json!({})),
4013 }))
4014 }
4015 }
4016 })();
4017 runner_finish(&outcome);
4018}
4019
4020fn binding_json(
4022 id: &choir_cli::runner::Identity,
4023 config: &choir_cli::runner::Config,
4024 base: Option<&str>,
4025) -> serde_json::Value {
4026 serde_json::json!({
4027 "repo": config.repo,
4028 "owner": config.owner,
4029 "scheme": id.scheme.as_str(),
4030 "workspace_id": id.workspace_id,
4031 "workspace_name": id.workspace_name,
4032 "change_id": id.change_id,
4033 "idempotency_key": id.idempotency_key,
4034 "base": base,
4035 })
4036}
4037
4038fn parse_auth(args: &[String]) -> (AuthOptions<'_>, &[String]) {
4039 let mut file = None;
4040 let mut user = None;
4041 let mut index = 0;
4042 while let Some(flag) = args.get(index) {
4043 let slot = match flag.as_str() {
4044 "--auth-file" if file.is_none() => &mut file,
4045 "--auth-user" if user.is_none() => &mut user,
4046 "--auth-file" | "--auth-user" => usage(),
4047 _ => break,
4048 };
4049 let Some(value) = args.get(index + 1) else {
4050 usage();
4051 };
4052 *slot = Some(value.as_str());
4053 index += 2;
4054 }
4055 if user.is_some() && file.is_none() {
4056 eprintln!("choir: --auth-user needs --auth-file");
4057 std::process::exit(2);
4058 }
4059 (
4060 AuthOptions {
4061 file,
4062 user,
4063 explicit: index > 0,
4064 },
4065 &args[index..],
4066 )
4067}
4068
4069fn workspace_body(repo: &str, name: &str, rest: &[&str]) -> serde_json::Value {
4070 if rest.is_empty() {
4071 return serde_json::json!({ "repo": repo, "name": name });
4072 }
4073 let (mut base, mut owner, mut key_file, mut change, mut idempotency_key) =
4074 (None, None, None, None, None);
4075 let mut cone: Vec<String> = Vec::new();
4079 let mut index = 0;
4080 while index < rest.len() {
4081 let Some(value) = rest.get(index + 1).copied() else {
4082 usage();
4083 };
4084 if rest[index] == "--path" {
4085 cone.push(value.to_string());
4086 index += 2;
4087 continue;
4088 }
4089 let slot = match rest[index] {
4090 "--base" if base.is_none() => &mut base,
4091 "--owner" if owner.is_none() => &mut owner,
4092 "--key-file" if key_file.is_none() => &mut key_file,
4093 "--change" if change.is_none() => &mut change,
4094 "--idempotency-key" if idempotency_key.is_none() => &mut idempotency_key,
4095 _ => usage(),
4096 };
4097 *slot = Some(value);
4098 index += 2;
4099 }
4100 cone.sort();
4105 cone.dedup();
4106 let (Some(base), Some(owner), Some(key_file), Some(change), Some(idempotency_key)) =
4107 (base, owner, key_file, change, idempotency_key)
4108 else {
4109 usage();
4110 };
4111 let Some(base_revision) = choir_hash::ContentHash::from_git_oid(base) else {
4112 eprintln!("<git-oid> must be a 40- or 64-char hex object id");
4113 std::process::exit(2);
4114 };
4115 let authorization = CreateAuthorization::new(
4116 change.into(),
4117 owner.into(),
4118 format!("{repo}/{name}"),
4119 base_revision,
4120 idempotency_key.into(),
4121 )
4122 .with_cone(cone);
4123 let mut body = signed_payload_body(key_file, owner, &authorization.to_payload());
4124 body["repo"] = serde_json::json!(repo);
4125 body["name"] = serde_json::json!(name);
4126 body["base"] = serde_json::json!(base);
4127 body["owner"] = serde_json::json!(owner);
4128 body["change"] = serde_json::json!(change);
4129 body["idempotency_key"] = serde_json::json!(idempotency_key);
4130 body
4131}
4132
4133fn parse_content_hash_hex(value: &str) -> Option<choir_hash::ContentHash> {
4134 let (codec, digest) = value.split_once('-')?;
4135 let codec = u8::from_str_radix(codec, 16).ok()?;
4136 if digest.is_empty() || digest.len() % 2 != 0 {
4137 return None;
4138 }
4139 let digest = (0..digest.len())
4140 .step_by(2)
4141 .map(|index| u8::from_str_radix(digest.get(index..index + 2)?, 16).ok())
4142 .collect::<Option<Vec<_>>>()?;
4143 Some(choir_hash::ContentHash { codec, digest })
4144}
4145
4146fn current_change_revision(
4147 api: &str,
4148 auth: AuthOptions<'_>,
4149 change_id: &str,
4150) -> choir_hash::ContentHash {
4151 let (status, body) = http(api, auth, "choir_view", serde_json::json!({}));
4152 if !(200..300).contains(&status) {
4153 finish(status, &body);
4154 }
4155 let view: serde_json::Value = match serde_json::from_str(&body) {
4156 Ok(view) => view,
4157 Err(error) => {
4158 eprintln!("choir: view returned invalid JSON: {error}");
4159 std::process::exit(1);
4160 }
4161 };
4162 let Some(revision) = view["changes"][change_id]["revision_id"].as_str() else {
4163 eprintln!("choir: no such change or revision in GET /api/view");
4164 std::process::exit(1);
4165 };
4166 match parse_content_hash_hex(revision) {
4167 Some(revision) => revision,
4168 None => {
4169 eprintln!("choir: change revision has an invalid content-hash envelope");
4170 std::process::exit(1);
4171 }
4172 }
4173}
4174
4175struct NodeOptions {
4193 state: std::path::PathBuf,
4194 port: u16,
4195 create: Vec<String>,
4196 extra: Vec<String>,
4197}
4198
4199fn node_options(command: &str, rest: &[&str]) -> NodeOptions {
4206 let (mine, extra) = match rest.iter().position(|a| *a == "--") {
4207 Some(at) => (&rest[..at], &rest[at + 1..]),
4208 None => (rest, &rest[rest.len()..]),
4209 };
4210 let mut state: Option<String> = None;
4211 let mut port: Option<u16> = None;
4212 let mut create: Vec<String> = Vec::new();
4213 let mut i = 0;
4214 while i < mine.len() {
4215 let name = mine[i];
4216 let Some(value) = mine.get(i + 1) else {
4217 eprintln!("{command}: {name} needs a value");
4218 std::process::exit(2);
4219 };
4220 match name {
4221 "--state" => state = Some((*value).to_string()),
4222 "--create" => create.push((*value).to_string()),
4223 "--port" => match value.parse::<u16>() {
4224 Ok(n) => port = Some(n),
4225 Err(_) => {
4226 eprintln!("{command}: --port needs a port number, not {value:?}");
4227 std::process::exit(2);
4228 }
4229 },
4230 other => {
4231 eprintln!(
4232 "{command}: unknown option {other:?}\n\n \
4233 daemon flags go after `--`: {command} -- {other} ..."
4234 );
4235 std::process::exit(2);
4236 }
4237 }
4238 i += 2;
4239 }
4240 NodeOptions {
4241 state: state
4242 .map(std::path::PathBuf::from)
4243 .unwrap_or_else(state_dir),
4244 port: port
4248 .or_else(|| {
4249 configured_node()
4250 .as_deref()
4251 .and_then(choir_cli::serve::port_of)
4252 })
4253 .unwrap_or(8417),
4254 create,
4255 extra: extra.iter().map(|a| (*a).to_string()).collect(),
4256 }
4257}
4258
4259fn state_dir() -> std::path::PathBuf {
4264 std::env::var_os("HOME")
4265 .map(std::path::PathBuf::from)
4266 .unwrap_or_default()
4267 .join(".choir")
4268}
4269
4270fn home_dir() -> std::path::PathBuf {
4272 std::env::var_os("HOME")
4273 .map(std::path::PathBuf::from)
4274 .unwrap_or_default()
4275}
4276
4277fn supervisor(command: &str) -> choir_cli::supervise::Supervisor {
4279 match choir_cli::supervise::Supervisor::detect() {
4280 Some(supervisor) => supervisor,
4281 None => {
4282 eprintln!(
4283 "{command}: no service manager known for {}\n\n \
4284 run it in the foreground instead: choir node serve",
4285 std::env::consts::OS
4286 );
4287 std::process::exit(1);
4288 }
4289 }
4290}
4291
4292fn step(what: &str, detail: &str) {
4300 let style = choir_cli::style::Style::for_stderr();
4301 eprintln!(" {} {:14} {detail}", style.green("ok"), style.dim(what));
4302}
4303
4304fn handover(done: &[String], why: &str, paste: &[String], retry: &str) -> ! {
4311 let style = choir_cli::style::Style::for_stderr();
4312 eprintln!("\n {} {why}\n", style.cyan("next:"));
4313 for line in paste {
4314 eprintln!(" {line}");
4315 }
4316 eprintln!("\n {} {retry}", style.dim("then:"));
4317 if !done.is_empty() {
4318 eprintln!("\n {} {}", style.dim("already done:"), done.join(", "));
4319 }
4320 eprintln!();
4321 std::process::exit(3)
4322}
4323
4324fn host_failed(done: &[String], what: &str, why: &str, retry: &str) -> ! {
4326 let style = choir_cli::style::Style::for_stderr();
4327 eprintln!("\n {} {what}: {why}\n", style.red("failed"));
4328 if !done.is_empty() {
4329 eprintln!(" {} {}\n", style.dim("already done:"), done.join(", "));
4330 }
4331 eprintln!(" {} {retry}\n", style.dim("retry:"));
4332 std::process::exit(1)
4333}
4334
4335fn install_unit(
4350 state: &std::path::Path,
4351 port: u16,
4352 extra: &[String],
4353) -> Result<(std::path::PathBuf, std::path::PathBuf), String> {
4354 let Some(supervisor) = choir_cli::supervise::Supervisor::detect() else {
4355 return Err(format!(
4356 "no service manager known for {}\n\n \
4357 run it in the foreground instead: choir node serve",
4358 std::env::consts::OS
4359 ));
4360 };
4361 let exe = std::env::current_exe().map_err(|_| "cannot find my own path".to_string())?;
4365 if choir_cli::supervise::in_build_directory(&exe) {
4366 return Err(format!(
4367 "this `choir` lives in a build directory:\n {}\n\n \
4368 a unit pointing there stops working at the next `cargo clean`,\n \
4369 and it stops working at reboot. install it first:\n\n \
4370 cargo build --release -p choir-cli -p choir-node\n \
4371 cp target/release/choir target/release/choir-node ~/.local/bin/",
4372 exe.display()
4373 ));
4374 }
4375 let layout = choir_cli::serve::Layout::new(state, port);
4379 if !layout.missing().is_empty() {
4380 return Err(format!(
4381 "no node in {} yet\n\n create one: choir init",
4382 state.display()
4383 ));
4384 }
4385 let home = home_dir();
4386 let unit = supervisor.unit_path(&home);
4387 if let Some(parent) = unit.parent() {
4388 std::fs::create_dir_all(parent)
4389 .map_err(|error| format!("create {}: {error}", parent.display()))?;
4390 }
4391 let body = supervisor.render(&exe, state, port, extra);
4392 choir_fs::write_atomic(&unit, body)
4393 .map_err(|error| format!("write {}: {error}", unit.display()))?;
4394 let steps = supervisor.commands(choir_cli::supervise::Action::Install, &home);
4395 let last = steps.len().saturating_sub(1);
4398 for (at, step) in steps.iter().enumerate() {
4399 if !run_step(step, at != last) {
4400 return Err("the service manager refused".to_string());
4401 }
4402 }
4403 Ok((unit, exe))
4404}
4405
4406fn run_step(argv: &[String], allow_failure: bool) -> bool {
4412 let Some((program, args)) = argv.split_first() else {
4413 return true;
4414 };
4415 match std::process::Command::new(program).args(args).output() {
4416 Ok(out) if out.status.success() => true,
4417 Ok(out) => {
4418 if !allow_failure {
4419 let text = String::from_utf8_lossy(&out.stderr);
4420 eprintln!(" {} {}", argv.join(" "), text.trim());
4421 }
4422 allow_failure
4423 }
4424 Err(error) => {
4425 if !allow_failure {
4426 eprintln!(" {}: {error}", argv.join(" "));
4427 }
4428 allow_failure
4429 }
4430 }
4431}
4432
4433fn configured_node() -> Option<String> {
4434 configured("node")
4435}
4436
4437fn configured(want: &str) -> Option<String> {
4463 let mut dir = std::env::current_dir().ok();
4464 while let Some(here) = dir {
4465 if let Some(found) = configured_in(&here.join(".choir/config"), want) {
4466 return Some(found);
4467 }
4468 let mut up = here;
4469 if !up.pop() {
4470 break;
4471 }
4472 dir = Some(up);
4473 }
4474 configured_in(&state_dir().join("config"), want)
4475}
4476
4477fn configured_in(path: &std::path::Path, want: &str) -> Option<String> {
4479 let text = std::fs::read_to_string(path).ok()?;
4480 for line in text.lines() {
4481 let line = line.trim();
4482 if line.starts_with('#') {
4483 continue;
4484 }
4485 if let Some((key, value)) = line.split_once('=') {
4486 if key.trim() == want {
4487 let value = value.trim();
4488 if !value.is_empty() {
4489 return Some(value.to_string());
4490 }
4491 }
4492 }
4493 }
4494 None
4495}
4496
4497fn with_configured_node(args: &[String]) -> Vec<String> {
4508 if args.is_empty() {
4509 return args.to_vec();
4510 }
4511 let two = (args.len() >= 2).then(|| format!("{} {}", args[0], args[1]));
4518 let (name, words) = match two {
4519 Some(two) if choir_cli::surface::COMMANDS.iter().any(|c| c.name == two) => (two, 2),
4520 _ => (args[0].clone(), 1),
4521 };
4522 let takes_api = choir_cli::surface::COMMANDS
4523 .iter()
4524 .any(|c| c.name == name && c.args.starts_with("<api>"));
4525 if !takes_api {
4526 return args.to_vec();
4527 }
4528 let given = args.get(words).map(String::as_str).unwrap_or("");
4529 if given.starts_with("http://") || given.starts_with("https://") {
4530 return args.to_vec();
4531 }
4532 let Some(node) = configured_node() else {
4533 return args.to_vec();
4534 };
4535 let mut filled = Vec::with_capacity(args.len() + 1);
4536 filled.extend(args[..words].iter().cloned());
4537 filled.push(node);
4538 filled.extend(args[words..].iter().cloned());
4539 filled
4540}
4541
4542fn main() {
4543 let args: Vec<String> = std::env::args().skip(1).collect();
4544 let style = choir_cli::style::Style::for_stdout();
4548 if args.first().is_some_and(|a| a == "--version" || a == "-V") {
4557 println!("choir {}", choir_node::build_line());
4558 std::process::exit(0);
4559 }
4560 let asked = match args.iter().position(|a| is_help(a)) {
4569 Some(1) => Some(args[0].clone()),
4570 Some(2)
4574 if choir_cli::surface::COMMANDS
4575 .iter()
4576 .any(|c| c.name == format!("{} {}", args[0], args[1])) =>
4577 {
4578 Some(format!("{} {}", args[0], args[1]))
4579 }
4580 _ => None,
4581 };
4582 if let Some(name) = asked {
4583 if let Some(help) = choir_cli::surface::command_help_in(&name, style) {
4584 print!("{help}");
4585 std::process::exit(0);
4586 }
4587 }
4588 if args.first().is_some_and(|a| is_help(a)) {
4589 print!("{}", choir_cli::surface::usage_in(style));
4590 std::process::exit(0);
4591 }
4592 let (auth, args) = parse_auth(&args);
4593 let args = with_configured_node(args);
4594 let args: Vec<&str> = args.iter().map(String::as_str).collect();
4595 match args.as_slice() {
4596 ["key", key_file, rest @ ..] if rest.len() <= 1 && auth.is_empty() => {
4600 let key = load_key(key_file);
4601 let hex = hex_encode(&key.public_key_bytes());
4602 match rest.first() {
4603 Some(name) => println!("{name} {hex}"),
4604 None => println!("{hex}"),
4605 }
4606 }
4607 ["init", rest @ ..] if auth.is_empty() => {
4610 let (mut dir, mut port, mut force) = (None, 8417u16, false);
4611 let mut it = rest.iter();
4612 while let Some(arg) = it.next() {
4613 match *arg {
4614 "--force" => force = true,
4615 "--port" => {
4616 let Some(value) = it.next().and_then(|v| v.parse().ok()) else {
4617 usage()
4618 };
4619 port = value;
4620 }
4621 other if !other.starts_with('-') && dir.is_none() => dir = Some(other),
4622 _ => usage(),
4623 }
4624 }
4625 let style = choir_cli::style::Style::for_stdout();
4626 let state = match dir {
4631 Some(dir) => std::path::PathBuf::from(dir),
4632 None => match std::env::var_os("HOME") {
4633 Some(home) => std::path::PathBuf::from(home).join(".choir"),
4634 None => {
4635 eprintln!(
4636 "{} no HOME, so there is no default state directory\n\
4637 \n choir init <state-dir>",
4638 style.red("choir init:")
4639 );
4640 std::process::exit(2);
4641 }
4642 },
4643 };
4644 let plan = choir_cli::init::Plan::new(&state, port);
4645 match choir_cli::init::run(&plan, force) {
4646 Ok(made) => {
4647 let rows: Vec<(&str, String)> = vec![
4648 ("repos", plan.repos.display().to_string()),
4649 ("auth", format!("{} (0600)", plan.auth.display())),
4650 ("key", format!("{} (0600)", plan.key.display())),
4651 ("trusted", plan.trusted.display().to_string()),
4652 (
4653 "config",
4654 format!("{} -> {}", plan.config.display(), plan.node_url()),
4655 ),
4656 ];
4657 note("ready", &rows);
4658 if !made.replaced.is_empty() {
4659 eprintln!(
4660 " {} replaced {} existing file(s); the previous credential is gone\n",
4661 style.red("--force:"),
4662 made.replaced.len()
4663 );
4664 }
4665 let serve = match dir {
4676 Some(_) => format!("choir node serve --state {}", state.display()),
4677 None => "choir node serve".to_string(),
4678 };
4679 println!("{serve}");
4680 eprintln!(
4681 " {} run the line above, then:\n choir repo create {} me/thing.git\n",
4682 style.dim("next"),
4683 plan.node_url()
4684 );
4685 let _ = made.user;
4686 }
4687 Err(error) => {
4688 eprintln!("{} {error}", style.red("choir init:"));
4689 std::process::exit(1);
4690 }
4691 }
4692 }
4693 ["host", rest @ ..] if auth.is_empty() => host(rest),
4697 ["seed", rest @ ..] if auth.is_empty() => seed(rest),
4698 ["node", "tls", rest @ ..] => node_tls(rest),
4702 ["node", "upgrade", rest @ ..] => node_upgrade(rest),
4703 ["repo", "create", api, name] => {
4708 let client = match choir_cli::mcp::HttpClient::new(
4709 api,
4710 auth.file_for(api).map(std::path::Path::new),
4711 auth.user,
4712 ) {
4713 Ok(client) => client,
4714 Err(error) => {
4715 eprintln!("choir: {error}");
4716 std::process::exit(2);
4717 }
4718 };
4719 let endpoint = choir_cli::surface::endpoint("POST", "/api/repo")
4720 .expect("the repo endpoint is in the table");
4721 let body = serde_json::json!({ "name": name });
4722 match client.request(endpoint, &body) {
4723 Ok((status, response)) => {
4724 if (200..300).contains(&status) {
4734 note(
4735 "repository created",
4736 &[("clone", format!("{}/{name}", api.trim_end_matches('/')))],
4737 );
4738 }
4739 finish(status, &response);
4740 }
4741 Err(error) => {
4742 eprintln!("choir: {error}");
4743 std::process::exit(1);
4744 }
4745 }
4746 }
4747 ["node", "serve", rest @ ..] => {
4755 let style = choir_cli::style::Style::for_stdout();
4756 let options = node_options("choir node serve", rest);
4757 let layout = choir_cli::serve::Layout::new(&options.state, options.port);
4758 let port = options.port;
4759 let program = match choir_cli::serve::find_daemon() {
4760 Ok(program) => program,
4761 Err(error) => {
4762 eprintln!("{} {error}", style.red("choir node serve:"));
4763 std::process::exit(1);
4764 }
4765 };
4766 let invocation =
4767 match choir_cli::serve::plan(program, &layout, &options.create, &options.extra) {
4768 Ok(invocation) => invocation,
4769 Err(error) => {
4770 eprintln!("{} {error}", style.red("choir node serve:"));
4771 std::process::exit(1);
4772 }
4773 };
4774 if choir_cli::serve::port_taken(port) {
4775 eprintln!(
4776 "{} something is already listening on 127.0.0.1:{port}\n\n \
4777 is it yours? choir node status\n \
4778 use another: choir node serve --port <n>",
4779 style.red("choir node serve:")
4780 );
4781 std::process::exit(1);
4782 }
4783 eprintln!("{}", style.dim(&invocation.display()));
4786 eprintln!("{} {}", style.dim("serving"), layout.port);
4787 eprintln!("{}", style.red(&choir_cli::serve::exec(&invocation)));
4788 std::process::exit(1);
4789 }
4790 ["repo", "list", api] => {
4791 let style = choir_cli::style::Style::for_stdout();
4792 let client = match choir_cli::mcp::HttpClient::new(
4793 api,
4794 auth.file_for(api).map(std::path::Path::new),
4795 auth.user,
4796 ) {
4797 Ok(client) => client,
4798 Err(error) => {
4799 eprintln!("{} {error}", style.red("choir repo list:"));
4800 std::process::exit(2);
4801 }
4802 };
4803 let (status, body) = match client.get("/api/repos") {
4804 Ok(answer) => answer,
4805 Err(error) => {
4806 eprintln!("{} {error}", style.red("choir repo list:"));
4807 std::process::exit(1);
4808 }
4809 };
4810 if status != 200 {
4811 eprintln!("{} {status}: {body}", style.red("choir repo list:"));
4812 std::process::exit(1);
4813 }
4814 let answer: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();
4815 let names: Vec<&str> = answer["repos"]
4816 .as_array()
4817 .map(|a| a.iter().filter_map(serde_json::Value::as_str).collect())
4818 .unwrap_or_default();
4819 for name in &names {
4820 println!("{name}");
4821 }
4822 if names.is_empty() {
4825 if answer["narrowed"].as_bool().unwrap_or(false) {
4826 eprintln!(
4827 " {}\n",
4828 style.dim("no repositories this credential can read")
4829 );
4830 } else {
4831 eprintln!(
4832 " {}\n choir repo create {api} me/thing.git\n",
4833 style.dim("no repositories on this node yet — make one:")
4834 );
4835 }
4836 }
4837 }
4838 ["repo", "url", api, name] => {
4843 let style = choir_cli::style::Style::for_stdout();
4844 let name = if name.ends_with(".git") {
4845 (*name).to_string()
4846 } else {
4847 format!("{name}.git")
4848 };
4849 let url = format!("{}/{name}", api.trim_end_matches('/'));
4850 println!("{url}");
4851 let credential = auth
4852 .file_for(api)
4853 .map(str::to_string)
4854 .unwrap_or_else(|| format!("{}/auth", state_dir().display()));
4855 eprintln!(
4856 "\n {}\n git clone {url}\n git -C {} config credential.helper \\\n \
4857 '!choir git-credential {credential}'\n",
4858 style.dim("clone it, then teach git the credential:"),
4859 name.trim_end_matches(".git")
4860 .rsplit('/')
4861 .next()
4862 .unwrap_or("repo")
4863 );
4864 }
4865 ["node", "install", rest @ ..] => {
4871 let style = choir_cli::style::Style::for_stdout();
4872 let command = "choir node install";
4873 let options = node_options(command, rest);
4874 let layout = choir_cli::serve::Layout::new(&options.state, options.port);
4875 let unit = match install_unit(&options.state, options.port, &options.extra) {
4876 Ok((unit, _)) => unit,
4877 Err(error) => {
4878 eprintln!("{} {error}", style.red(&format!("{command}:")));
4879 std::process::exit(1);
4880 }
4881 };
4882 note(
4883 "installed",
4884 &[
4885 ("unit", unit.display().to_string()),
4886 ("state", options.state.display().to_string()),
4887 ("log", layout.log.display().to_string()),
4888 ],
4889 );
4890 eprintln!(" {} choir node status\n", style.dim("check it"));
4891 }
4892 ["node", "stop"] => {
4893 let style = choir_cli::style::Style::for_stdout();
4894 let supervisor = supervisor("choir node stop");
4895 let home = home_dir();
4896 for step in supervisor.commands(choir_cli::supervise::Action::Stop, &home) {
4897 if !run_step(&step, false) {
4898 eprintln!("{} nothing was running", style.red("choir node stop:"));
4899 std::process::exit(1);
4900 }
4901 }
4902 eprintln!(
4903 "stopped — the unit is still installed, so it returns at next login\n \
4904 to end it: choir node uninstall"
4905 );
4906 }
4907 ["node", "restart"] => {
4912 let style = choir_cli::style::Style::for_stdout();
4913 let command = "choir node restart";
4914 let supervisor = supervisor(command);
4915 let home = home_dir();
4916 let unit = supervisor.unit_path(&home);
4917 if !unit.exists() {
4918 eprintln!(
4919 "{} nothing is installed here\n\n install it: choir node install",
4920 style.red(&format!("{command}:"))
4921 );
4922 std::process::exit(1);
4923 }
4924 let steps = supervisor.commands(choir_cli::supervise::Action::Install, &home);
4925 let last = steps.len().saturating_sub(1);
4926 for (at, step) in steps.iter().enumerate() {
4927 if !run_step(step, at != last) {
4928 eprintln!(
4929 "{} the service manager refused",
4930 style.red(&format!("{command}:"))
4931 );
4932 std::process::exit(1);
4933 }
4934 }
4935 eprintln!("restarted {}", unit.display());
4936 }
4937 ["node", "uninstall"] => {
4942 let style = choir_cli::style::Style::for_stdout();
4943 let supervisor = supervisor("choir node uninstall");
4944 let home = home_dir();
4945 let unit = supervisor.unit_path(&home);
4946 for step in supervisor.commands(choir_cli::supervise::Action::Uninstall, &home) {
4947 run_step(&step, true);
4948 }
4949 match std::fs::remove_file(&unit) {
4950 Ok(()) => eprintln!("uninstalled {}", unit.display()),
4951 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
4952 eprintln!("nothing was installed at {}", unit.display());
4953 }
4954 Err(error) => {
4955 eprintln!(
4956 "{} remove {}: {error}",
4957 style.red("choir node uninstall:"),
4958 unit.display()
4959 );
4960 std::process::exit(1);
4961 }
4962 }
4963 eprintln!(
4964 " {} {} — keys, repositories and the op log\n",
4965 style.dim("kept"),
4966 state_dir().display()
4967 );
4968 let hook =
4975 std::path::Path::new(choir_cli::tls::HOOK_DIR).join(choir_cli::tls::HOOK_NAME);
4976 if hook.exists() {
4977 eprintln!(
4978 " {} {}\n it does nothing once {} is gone; to remove it and the\n \
4979 certificate as well:\n\n sudo rm {}\n sudo certbot delete\n",
4980 style.cyan("renewal hook still installed:"),
4981 hook.display(),
4982 state_dir().join("tls.enabled").display(),
4983 hook.display(),
4984 );
4985 }
4986 }
4987 ["node", "logs", rest @ ..] => {
4988 let style = choir_cli::style::Style::for_stdout();
4989 let (lines, rest) = match rest.split_first() {
4990 Some((first, tail)) if !first.starts_with('-') => match first.parse::<usize>() {
4991 Ok(n) => (n, tail),
4992 Err(_) => {
4993 eprintln!("choir node logs: <lines> must be a number, not {first:?}");
4994 std::process::exit(2);
4995 }
4996 },
4997 _ => (30, rest),
4998 };
4999 let options = node_options("choir node logs", rest);
5000 let log = choir_cli::serve::Layout::new(&options.state, options.port).log;
5001 match std::fs::read_to_string(&log) {
5002 Ok(text) => {
5003 let all: Vec<&str> = text.lines().collect();
5004 for line in all.iter().skip(all.len().saturating_sub(lines)) {
5005 println!("{line}");
5006 }
5007 }
5008 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
5009 eprintln!(
5010 "{} no log at {}\n\n \
5011 a node started by hand writes to your terminal, not here;\n \
5012 the log is written by a supervised node: choir node install",
5013 style.red("choir node logs:"),
5014 log.display()
5015 );
5016 std::process::exit(1);
5017 }
5018 Err(error) => {
5019 eprintln!(
5020 "{} {}: {error}",
5021 style.red("choir node logs:"),
5022 log.display()
5023 );
5024 std::process::exit(1);
5025 }
5026 }
5027 }
5028 ["node", "status", rest @ ..] if rest.len() <= 1 => {
5029 let api = rest
5030 .first()
5031 .copied()
5032 .map(str::to_string)
5033 .or_else(configured_node);
5034 let Some(api) = api else {
5035 eprintln!(
5036 "choir node status: no node given and none configured\n\
5037 \n\
5038 write `node = <url>` to .choir/config, or pass the URL"
5039 );
5040 std::process::exit(2);
5041 };
5042 let style = choir_cli::style::Style::for_stdout();
5043 match choir_cli::node::status(&api, auth.file_for(&api).map(std::path::Path::new)) {
5044 Ok((health, view)) => {
5045 print!(
5046 "{}",
5047 choir_cli::node::status_report(&api, health, &view, style)
5048 );
5049 std::process::exit(health.exit_code());
5050 }
5051 Err(error) => {
5052 eprintln!("{} {error}", style.red("choir node status:"));
5053 std::process::exit(1);
5054 }
5055 }
5056 }
5057 ["doctor", rest @ ..] if rest.len() <= 3 => {
5062 let (state, rest) = match rest {
5066 [head @ .., "--state", dir] | ["--state", dir, head @ ..] => {
5067 (std::path::PathBuf::from(*dir), head.to_vec())
5068 }
5069 other if other.len() <= 1 => (state_dir(), other.to_vec()),
5070 _ => usage(),
5071 };
5072 let configured = configured_node();
5073 let api = rest.first().copied().map(str::to_string).or(configured);
5074 let effective = api.as_deref().and_then(|api| auth.file_for(api));
5078 let credential = effective.or(auth.file);
5079 let mut checks = choir_cli::doctor::run(api.as_deref(), credential);
5080 let seeds = configured_seeds();
5083 if !seeds.is_empty() {
5084 checks.extend(choir_cli::doctor::seeds(
5085 api.as_deref(),
5086 &seeds,
5087 &|url: &str| auth.file_for(url).map(str::to_string),
5088 auth.user,
5089 ));
5090 }
5091 if choir_cli::serve::Layout::new(&state, 8417)
5096 .missing()
5097 .is_empty()
5098 {
5099 checks.extend(choir_cli::doctor::host(&state, credential));
5100 }
5101 let style = choir_cli::style::Style::for_stdout();
5102 let role = choir_cli::join::Role::of(&state_dir(), credential);
5107 print!("{}", choir_cli::doctor::heading(role, style));
5108 print!("{}", choir_cli::doctor::report(&checks, style));
5109 std::process::exit(choir_cli::doctor::exit_code(&checks));
5110 }
5111 ["backup", "restore", src, root] => {
5117 let style = choir_cli::style::Style::for_stdout();
5118 let (src, root) = (std::path::Path::new(src), std::path::Path::new(root));
5119 if !src.is_dir() {
5120 eprintln!(
5121 "{} no backup directory at {}",
5122 style.red("choir backup restore:"),
5123 src.display()
5124 );
5125 std::process::exit(2);
5126 }
5127 let daemon = match choir_cli::serve::find_daemon() {
5128 Ok(daemon) => daemon,
5129 Err(error) => {
5130 eprintln!("{} {error}", style.red("choir backup restore:"));
5131 std::process::exit(1);
5132 }
5133 };
5134 let auth = auth
5139 .file
5140 .map(std::path::PathBuf::from)
5141 .unwrap_or_else(|| root.join(".choir/auth"));
5142 let mut say = |line: &str| eprintln!(" {} {line}", style.dim("restore:"));
5143 match choir_cli::restore::run(src, root, &daemon, &auth, &mut say) {
5144 Ok(done) => {
5145 println!(
5150 "restore: {} ops replayed, {} repos unbundled, canary landed at seq {}",
5151 done.ops, done.repos, done.ops
5152 );
5153 println!(
5154 "restore: the canary ref is {} in {} — it is evidence, delete it when you no longer want it",
5155 done.canary, done.landed_in
5156 );
5157 println!(
5158 "restore: root is {} — start it under your supervisor:",
5159 root.display()
5160 );
5161 println!(" choir node install --state {}", root.display());
5162 }
5163 Err(refusal) => {
5164 let tag = if refusal.code == 3 {
5165 style.red("choir backup restore: DECIDE")
5166 } else {
5167 style.red("choir backup restore:")
5168 };
5169 eprintln!("{tag} {}", refusal.message);
5170 std::process::exit(refusal.code);
5171 }
5172 }
5173 }
5174 ["repo", "follower", rest @ ..] => repo_follower(rest),
5177 ["backup", "take", rest @ ..] => backup_take(rest),
5178 ["backup", "schedule", rest @ ..] => backup_schedule(rest),
5179 ["backup", "unschedule"] => {
5180 let style = choir_cli::style::Style::for_stdout();
5181 let supervisor = supervisor("choir backup unschedule");
5182 let home = home_dir();
5183 for step in supervisor.backup_commands(choir_cli::supervise::Action::Uninstall, &home) {
5184 run_step(&step, true);
5185 }
5186 let mut removed = 0;
5187 for unit in supervisor.backup_units(&home) {
5188 match std::fs::remove_file(&unit) {
5189 Ok(()) => removed += 1,
5190 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
5191 Err(error) => {
5192 eprintln!(
5193 "{} remove {}: {error}",
5194 style.red("choir backup unschedule:"),
5195 unit.display()
5196 );
5197 std::process::exit(1);
5198 }
5199 }
5200 }
5201 if removed == 0 {
5202 eprintln!("no backup timer was scheduled");
5203 } else {
5204 eprintln!("unscheduled; the backups already taken are kept");
5205 }
5206 }
5207 ["backup", "verify", dir] => {
5208 let style = choir_cli::style::Style::for_stdout();
5209 let dir = std::path::Path::new(dir);
5210 if !dir.is_dir() {
5211 eprintln!(
5212 "{} {} is not a directory",
5213 style.red("choir backup verify:"),
5214 dir.display()
5215 );
5216 std::process::exit(2);
5217 }
5218 let daemon = choir_cli::serve::find_daemon().ok();
5219 let checks = choir_cli::backup::verify(dir, daemon.as_deref());
5220 print!("{}", choir_cli::doctor::report(&checks, style));
5221 std::process::exit(i32::from(!choir_cli::backup::restorable(&checks)));
5222 }
5223 ["repair", log_file, rest @ ..] => repair(log_file, rest),
5229 ["workspace", api, repo, name, rest @ ..] => {
5230 let body = workspace_body(repo, name, rest);
5231 let (status, resp) = http(api, auth, "choir_workspace", body);
5232 finish(status, &resp);
5233 }
5234 ["checkpoint", api, key_file, channel, change_id, workspace, oid] => {
5235 let Some(revision) = choir_hash::ContentHash::from_git_oid(oid) else {
5236 eprintln!("<git-oid> must be a 40- or 64-char hex object id");
5237 std::process::exit(2);
5238 };
5239 let prev_revision = current_change_revision(api, auth, change_id);
5240 let op = ViewOp::new(OpKind::CheckpointChange {
5241 id: (*change_id).into(),
5242 workspace: (*workspace).into(),
5243 revision,
5244 prev_revision,
5245 });
5246 submit(api, key_file, channel, &op, auth);
5247 }
5248 ["workspace-archive", api, key_file, channel, repo, name, change_id, idempotency_key] => {
5249 let prev_revision = current_change_revision(api, auth, change_id);
5250 let authorization = ArchiveAuthorization::new(
5251 (*change_id).into(),
5252 format!("{repo}/{name}"),
5253 prev_revision,
5254 );
5255 let mut body = signed_payload_body(key_file, channel, &authorization.to_payload());
5256 body["repo"] = serde_json::json!(repo);
5257 body["name"] = serde_json::json!(name);
5258 body["change"] = serde_json::json!(change_id);
5259 body["idempotency_key"] = serde_json::json!(idempotency_key);
5260 let (status, resp) = http(api, auth, "choir_workspace_archive", body);
5261 finish(status, &resp);
5262 }
5263 ["propose", rest @ ..] => propose(rest, auth),
5267 ["join", link, rest @ ..] if auth.is_empty() && choir_cli::join::Link::looks_like(link) => {
5272 match choir_cli::join::Link::parse(link) {
5273 Ok(choir_cli::join::Link { api, id, secret }) => {
5274 join(&api, Invite::Pair(id, secret), None, rest)
5275 }
5276 Err(why) => {
5277 eprintln!("choir join: {why}");
5278 std::process::exit(2);
5279 }
5280 }
5281 }
5282 ["join", api, invite_file, key_file, rest @ ..] if auth.is_empty() => {
5283 join(api, Invite::File(invite_file), Some(key_file), rest)
5284 }
5285 ["git-credential", auth_file, operation] if auth.is_empty() => {
5288 git_credential(auth_file, None, operation)
5289 }
5290 ["git-credential", auth_file, "--auth-user", user, operation] if auth.is_empty() => {
5291 git_credential(auth_file, Some(user), operation)
5292 }
5293 ["runner", config_file] => runner(config_file, auth),
5294 ["schema", api] => {
5299 let (status, body) = http(api, auth, "choir_schema", serde_json::json!({}));
5300 finish(status, &body);
5301 }
5302 ["log", api, rest @ ..] => {
5303 let (mut from, mut verify, mut keys) = (0u64, false, None);
5304 let mut it = rest.iter();
5305 while let Some(arg) = it.next() {
5306 match *arg {
5307 "--from" => {
5308 let Some(value) = it.next().and_then(|v| v.parse().ok()) else {
5309 usage()
5310 };
5311 from = value;
5312 }
5313 "--verify" => verify = true,
5314 "--keys" => {
5315 let Some(path) = it.next() else { usage() };
5316 keys = Some(*path);
5317 }
5318 _ => usage(),
5319 }
5320 }
5321 log(api, from, verify, keys, auth);
5322 }
5323 ["batch", api, key_file, channel, ops_file] => {
5324 batch(api, key_file, channel, ops_file, auth);
5325 }
5326 ["submit", api, key_file, channel, op_json] => {
5327 let op: ViewOp = match serde_json::from_str(op_json) {
5330 Ok(op) => op,
5331 Err(e) => {
5332 eprintln!("bad op json: {e}");
5333 std::process::exit(2);
5334 }
5335 };
5336 submit(api, key_file, channel, &op, auth);
5337 }
5338 ["review", api, key_file, channel, id, oid, rest @ ..] => {
5343 let Some(target) = choir_hash::ContentHash::from_git_oid(oid) else {
5344 eprintln!("<git-oid> must be a 40- or 64-char hex object id");
5345 std::process::exit(2);
5346 };
5347 let mut target_ref = None;
5348 let mut reviewers = Vec::new();
5349 let mut it = rest.iter();
5350 while let Some(arg) = it.next() {
5351 if *arg == "--ref" {
5352 let Some(name) = it.next() else { usage() };
5353 target_ref = Some((*name).to_string());
5354 } else {
5355 reviewers.push((*arg).to_string());
5356 }
5357 }
5358 let op = ViewOp::new(OpKind::RequestReview {
5359 id: (*id).into(),
5360 target,
5361 reviewers,
5362 target_ref,
5363 });
5364 submit(api, key_file, channel, &op, auth);
5365 }
5366 ["verdict", api, key_file, reviewer, id, verdict, rest @ ..] if rest.len() <= 1 => {
5367 let verdict = match *verdict {
5368 "approve" => Verdict::Approve,
5369 "request-changes" => Verdict::RequestChanges,
5370 _ => usage(),
5371 };
5372 let op = ViewOp::new(OpKind::PostVerdict {
5373 id: (*id).into(),
5374 reviewer: (*reviewer).into(),
5375 verdict,
5376 note: rest.first().copied().unwrap_or("").into(),
5377 });
5378 submit(api, key_file, reviewer, &op, auth);
5381 }
5382 ["comment", api, key_file, channel, id, comment, body] => {
5387 let op = ViewOp::new(OpKind::PostComment {
5388 id: (*id).into(),
5389 comment: (*comment).into(),
5390 author: (*channel).into(),
5391 body: (*body).into(),
5392 });
5393 submit(api, key_file, channel, &op, auth);
5396 }
5397 ["viewed", api, key_file, viewer, id] => {
5402 let op = ViewOp::new(OpKind::ViewedReview {
5403 id: (*id).into(),
5404 viewer: (*viewer).into(),
5405 });
5406 submit(api, key_file, viewer, &op, auth);
5409 }
5410 ["witness", api, key_file, channel] => {
5416 let op = ViewOp::new(OpKind::CountersignSnapshot {
5417 witness: reviewer_operator(channel).into(),
5418 snapshot: latest_snapshot(api, auth),
5419 });
5420 submit(api, key_file, channel, &op, auth);
5421 }
5422 ["vouch", api, key_file, channel, subject, rest @ ..] if rest.len() <= 1 => {
5423 let op = ViewOp::new(OpKind::Vouch {
5424 voucher: reviewer_operator(channel).into(),
5425 subject: (*subject).into(),
5426 note: rest.first().copied().unwrap_or("").into(),
5427 });
5428 submit(api, key_file, channel, &op, auth);
5429 }
5430 ["unvouch", api, key_file, channel, subject, reason] => {
5431 let op = ViewOp::new(OpKind::WithdrawVouch {
5432 voucher: reviewer_operator(channel).into(),
5433 subject: (*subject).into(),
5434 reason: (*reason).into(),
5435 });
5436 submit(api, key_file, channel, &op, auth);
5437 }
5438 ["slash", api, node_key_file, id, reviewer, reason] => {
5439 require_node_key_file(node_key_file);
5440 let op = ViewOp::new(OpKind::SlashApproval {
5441 id: (*id).into(),
5442 reviewer: (*reviewer).into(),
5443 reason: (*reason).into(),
5444 });
5445 submit(api, node_key_file, "node/slash", &op, auth);
5449 }
5450 ["abandon", api, node_key_file, id] => {
5458 require_node_key_file(node_key_file);
5459 let op = ViewOp::new(OpKind::ArchiveReview {
5460 id: (*id).into(),
5461 lapsed: true,
5462 });
5463 submit(api, node_key_file, "node/abandon", &op, auth);
5464 }
5465 ["bind", api, node_key_file, operator, key_hex, rest @ ..] if rest.len() <= 1 => {
5475 require_node_key_file(node_key_file);
5476 let key = actor_id_from_hex(key_hex);
5477 let channel = rest.first().map(|c| (*c).to_string());
5478 if let Some(existing) = current_binding(api, auth, &key.to_hex()) {
5490 if existing["operator"] == serde_json::json!(operator)
5491 && existing["channel"] == serde_json::json!(channel)
5492 && existing["revoked"].is_null()
5493 {
5494 finish(
5495 200,
5496 &serde_json::json!({
5497 "already_bound": true,
5498 "operator": operator,
5499 "channel": channel,
5500 "bound_at": existing["bound_at"],
5501 })
5502 .to_string(),
5503 );
5504 }
5505 }
5506 let op = ViewOp::new(OpKind::BindKey {
5507 operator: (*operator).into(),
5508 key,
5509 channel,
5510 });
5511 submit(api, node_key_file, "node/bind", &op, auth);
5512 }
5513 ["revoke", api, node_key_file, key_hex, reason] => {
5514 require_node_key_file(node_key_file);
5515 let op = ViewOp::new(OpKind::RevokeKey {
5516 key: actor_id_from_hex(key_hex),
5517 reason: (*reason).into(),
5518 });
5519 submit(api, node_key_file, "node/revoke", &op, auth);
5520 }
5521 ["appeal", api, attempt_id] => {
5522 let attempt_id = attempt_id.parse::<u64>().unwrap_or_else(|_| usage());
5523 let (status, resp) = http(
5524 api,
5525 auth,
5526 "choir_appeal",
5527 serde_json::json!({ "attempt_id": attempt_id }),
5528 );
5529 finish(status, &resp);
5530 }
5531 ["intent", api, key_file, channel, subject, kind, body] => {
5532 let op = ViewOp::new(OpKind::RecordProvenance {
5535 subject: (*subject).into(),
5536 kind: (*kind).into(),
5537 body: (*body).into(),
5538 });
5539 submit(api, key_file, channel, &op, auth);
5540 }
5541 ["check", api, key_file, channel, oid, name, status, rest @ ..] if rest.len() <= 3 => {
5545 let Some(subject) = choir_hash::ContentHash::from_git_oid(oid) else {
5546 eprintln!("<git-oid> must be a 40- or 64-char hex object id");
5547 std::process::exit(2);
5548 };
5549 let Some(status) = CheckStatus::parse(status) else {
5550 eprintln!("<status> must be passed, failed or running");
5551 std::process::exit(2);
5552 };
5553 let mut evidence = String::new();
5554 let mut target_ref = None;
5555 let mut it = rest.iter();
5556 while let Some(arg) = it.next() {
5557 if *arg == "--ref" {
5558 let Some(name) = it.next() else { usage() };
5559 target_ref = Some((*name).to_string());
5560 } else {
5561 evidence = (*arg).to_string();
5562 }
5563 }
5564 let op = ViewOp::new(OpKind::RecordCheck {
5565 subject,
5566 name: (*name).into(),
5567 status,
5568 evidence,
5569 reporter: (*channel).into(),
5570 target_ref,
5571 });
5572 submit(api, key_file, channel, &op, auth);
5573 }
5574 ["checks", api, oid] => {
5577 let Some(subject) = choir_hash::ContentHash::from_git_oid(oid) else {
5578 eprintln!("<git-oid> must be a 40- or 64-char hex object id");
5579 std::process::exit(2);
5580 };
5581 let (status, resp) = http(api, auth, "choir_view", serde_json::json!({}));
5582 if !(200..300).contains(&status) {
5583 finish(status, &resp);
5584 }
5585 check_exit(&subject, &resp);
5586 }
5587 ["profile", api, channel] => {
5588 let (status, resp) = http(
5589 api,
5590 auth,
5591 "choir_profile",
5592 serde_json::json!({ "channel": channel }),
5593 );
5594 finish(status, &resp);
5595 }
5596 ["search", api, term, rest @ ..] => {
5597 let mut arguments = serde_json::Map::new();
5602 arguments.insert("q".into(), serde_json::json!(term));
5603 let mut it = rest.iter();
5604 while let Some(arg) = it.next() {
5605 let field = match *arg {
5606 "--in" => "in",
5607 "--repo" => "repo",
5608 "--rev" => "rev",
5609 "--limit" => "limit",
5610 _ => usage(),
5611 };
5612 let Some(value) = it.next() else { usage() };
5613 let value = match field {
5619 "limit" => match value.parse::<u64>() {
5620 Ok(n) => serde_json::json!(n),
5621 Err(_) => usage(),
5622 },
5623 _ => serde_json::json!(value),
5624 };
5625 arguments.insert(field.to_string(), value);
5626 }
5627 let (status, resp) = http(api, auth, "choir_search", arguments.into());
5628 finish(status, &resp);
5629 }
5630 ["reviews", api, reviewer] => {
5631 let (status, resp) = http(
5632 api,
5633 auth,
5634 "choir_reviews",
5635 serde_json::json!({ "reviewer": reviewer }),
5636 );
5637 finish(status, &resp);
5638 }
5639 ["view", api, rest @ ..] => {
5640 let mut arguments = serde_json::Map::new();
5645 let mut it = rest.iter();
5646 while let Some(arg) = it.next() {
5647 let field = match *arg {
5648 "--limit" => "limit",
5649 "--offset" => "offset",
5650 _ => usage(),
5651 };
5652 let Some(value) = it.next().and_then(|v| v.parse::<u64>().ok()) else {
5653 usage()
5654 };
5655 arguments.insert(field.to_string(), serde_json::json!(value));
5656 }
5657 let (status, resp) = http(api, auth, "choir_view", arguments.into());
5658 finish(status, &resp);
5659 }
5660 ["docs", rest @ ..] => {
5661 let open = match rest {
5662 [] => false,
5663 ["--open"] => true,
5664 _ => usage(),
5665 };
5666 let cwd = std::env::current_dir().unwrap_or_else(|e| {
5667 eprintln!("choir: cannot read the working directory: {e}");
5668 std::process::exit(1);
5669 });
5670 let Some(root) = choir_cli::docs::find_root(&cwd) else {
5671 eprintln!("choir: {}", choir_cli::docs::Failure::NotACheckout);
5672 std::process::exit(1);
5673 };
5674 let built = match choir_cli::docs::build(&root) {
5675 Ok(built) => built,
5676 Err(failure) => {
5677 eprintln!("choir: {failure}");
5678 std::process::exit(1);
5679 }
5680 };
5681 let opened = open && choir_cli::docs::open(&built.book.join("index.html"));
5682 note(
5683 "documentation built",
5684 &[
5685 ("book", built.book.join("index.html").display().to_string()),
5686 ("api", built.api.join("index.html").display().to_string()),
5687 ("crates", built.crates.len().to_string()),
5688 ],
5689 );
5690 let doc = serde_json::json!({
5691 "root": built.root.display().to_string(),
5692 "book": built.book.display().to_string(),
5693 "api": built.api.display().to_string(),
5694 "crates": built.crates,
5695 "opened": opened,
5696 });
5697 finish(200, &doc.to_string());
5698 }
5699 ["skill", "install", rest @ ..] => {
5700 let into = match rest {
5701 [] => ".claude/skills",
5702 ["--into", dir] => dir,
5703 _ => usage(),
5704 };
5705 let dir = std::path::Path::new(into).join(choir_cli::surface::SKILL_DIR);
5706 let path = dir.join("SKILL.md");
5707 let rendered = choir_cli::surface::skill_md();
5708 let wrote = std::fs::read_to_string(&path).ok().as_deref() != Some(rendered.as_str());
5712 if wrote {
5713 if let Err(error) = choir_fs::write_atomic(&path, &rendered) {
5714 eprintln!("choir: cannot write {}: {error}", path.display());
5715 std::process::exit(1);
5716 }
5717 }
5718 let doc = serde_json::json!({ "path": path.display().to_string(), "wrote": wrote });
5719 note(
5720 if wrote {
5721 "skill installed"
5722 } else {
5723 "skill already current"
5724 },
5725 &[("path", path.display().to_string())],
5726 );
5727 finish(200, &doc.to_string());
5728 }
5729 ["invite", api, name, repo] => invite(api, auth, name, repo, "write"),
5730 ["invite", api, name, repo, level] => invite(api, auth, name, repo, level),
5731 ["asks", api] => asks(api, auth),
5732 ["grant", api, id, repo] => grant(api, auth, id, repo, "write"),
5733 ["grant", api, id, repo, level] => grant(api, auth, id, repo, level),
5734 ["decline", api, id] => decline(api, auth, id),
5735 ["acl", "render", api, acl_file] => acl_render(api, auth, acl_file),
5736 ["funnel", api] => {
5737 println!("{}", derived_view(api, auth, choir_cli::triage::funnel));
5738 }
5739 ["triage", api] => {
5740 let doc = derived_view(api, auth, choir_cli::triage::triage);
5741 finish(200, &doc);
5742 }
5743 ["state", api, channel] => {
5744 let doc = derived_view(api, auth, |view| {
5745 choir_cli::triage::next_actions(view, api, channel)
5746 });
5747 finish(200, &doc);
5748 }
5749 _ => usage(),
5750 }
5751}