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 configured("node").is_some_and(|node| node.trim_end_matches('/') == api.trim_end_matches('/'))
111}
112
113impl<'a> AuthOptions<'a> {
114 fn is_empty(self) -> bool {
115 !self.explicit
116 }
117
118 fn file_for(self, api: &str) -> Option<&'a str> {
125 if self.file.is_some() {
126 return self.file;
127 }
128 if !may_hold_credential(api) {
129 return None;
130 }
131 DEFAULT_AUTH.get_or_init(discovered_auth_file).as_deref()
132 }
133}
134
135fn note(heading: &str, rows: &[(&str, String)]) {
147 let style = choir_cli::style::Style::for_stderr();
148 if !style.is_painted() {
149 return;
150 }
151 let width = rows.iter().map(|(k, _)| k.len()).max().unwrap_or(0);
152 eprintln!("\n {}", style.green(heading));
153 for (key, value) in rows {
154 let key = format!("{key:width$}");
155 eprintln!(" {} {value}", style.dim(&key));
156 }
157 eprintln!();
158}
159
160fn is_help(argument: &str) -> bool {
166 argument == "--help" || argument == "-h"
167}
168
169fn invoked_command() -> Option<String> {
177 let args: Vec<String> = std::env::args().skip(1).collect();
178 let mut index = 0;
181 while matches!(
182 args.get(index).map(String::as_str),
183 Some("--auth-file" | "--auth-user")
184 ) {
185 index += 2;
186 }
187 let first = args.get(index)?.clone();
188 if let Some(second) = args.get(index + 1) {
196 let two = format!("{first} {second}");
197 if choir_cli::surface::COMMANDS.iter().any(|c| c.name == two) {
198 return Some(two);
199 }
200 }
201 if choir_cli::surface::COMMANDS
205 .iter()
206 .any(|c| c.name.starts_with(&format!("{first} ")))
207 {
208 return Some(first);
209 }
210 Some(first)
211}
212
213fn usage() -> ! {
223 let style = choir_cli::style::Style::for_stderr();
224 match invoked_command() {
225 None if choir_cli::join::Role::of(&state_dir(), discovered_auth_file().as_deref())
230 == choir_cli::join::Role::Nothing =>
231 {
232 eprint!("{}", choir_cli::join::orientation(style));
233 }
234 None => eprint!("{}", choir_cli::surface::usage_in(style)),
235 Some(name) => match choir_cli::surface::command_help_in(&name, style) {
236 Some(help) => {
237 eprintln!(
238 "{} those arguments do not match `{}`. It takes:\n",
239 style.red("choir:"),
240 name
241 );
242 eprint!("{help}");
243 }
244 None => {
245 let under: Vec<&str> = choir_cli::surface::COMMANDS
252 .iter()
253 .map(|c| c.name)
254 .filter(|n| n.starts_with(&format!("{name} ")))
255 .collect();
256 if under.is_empty() {
257 eprintln!("{} `{}` is not a choir command.", style.red("choir:"), name);
258 let names = choir_cli::surface::COMMANDS.iter().map(|c| c.name);
259 if let Some(near) = choir_cli::style::nearest(&name, names) {
260 eprintln!(" did you mean {}?", style.cyan(near));
261 }
262 } else {
263 eprintln!(
264 "{} `{}` is not a command on its own. It has:",
265 style.red("choir:"),
266 name
267 );
268 for one in under {
269 eprintln!(" {}", style.cyan(one));
270 }
271 }
272 eprintln!(" {} lists every command.", style.cyan("choir --help"));
273 }
274 },
275 }
276 std::process::exit(2);
277}
278
279fn hex_encode(bytes: &[u8]) -> String {
280 bytes.iter().map(|b| format!("{b:02x}")).collect()
281}
282
283fn require_node_key_file(path: &str) {
291 if !std::path::Path::new(path).is_file() {
292 eprintln!("choir: <node-key-file> must name the node's existing key file");
293 std::process::exit(2);
294 }
295}
296
297fn actor_id_from_hex(key_hex: &str) -> choir_hash::ContentHash {
303 let bytes: Option<Vec<u8>> = (key_hex.len() == 64)
304 .then(|| {
305 (0..64)
306 .step_by(2)
307 .map(|i| u8::from_str_radix(&key_hex[i..i + 2], 16).ok())
308 .collect()
309 })
310 .flatten();
311 let Some(bytes) = bytes else {
312 eprintln!("choir: <key-hex> must be a 64-character ed25519 public key hex");
313 std::process::exit(2);
314 };
315 choir_hash::ContentHash::blake3(&bytes)
316}
317
318fn repair(log_file: &str, flags: &[&str]) {
327 let path = std::path::Path::new(log_file);
328 let verify = flags.contains(&"--verify");
329 let truncate = flags.contains(&"--truncate-tail");
330 if verify == truncate {
331 eprintln!(
335 "choir repair <log-file> --verify | --truncate-tail\n\
336 \n\
337 --verify walk the chain and report; changes nothing\n\
338 --truncate-tail quarantine a partly written final record and\n\
339 cut the log back to the last complete one\n\
340 \n\
341 Exactly one mode, and no default: which of these happens to your\n\
342 log is not a decision this tool should make for you."
343 );
344 std::process::exit(2);
345 }
346
347 let report = match choir_oplog::repair::verify(path) {
348 Ok(report) => report,
349 Err(error) => {
350 eprintln!("choir: cannot read {log_file}: {error:?}");
351 std::process::exit(1);
352 }
353 };
354
355 println!("{log_file}");
356 println!(" intact records: {}", report.intact_records);
357 match &report.head {
358 Some(head) => println!(" head: {}", head.to_hex()),
359 None => println!(" head: (empty log)"),
360 }
361 if report.torn_tail_bytes > 0 {
362 println!(
363 " torn tail: {} bytes, never acknowledged to any client",
364 report.torn_tail_bytes
365 );
366 }
367
368 if let Some(fault) = &report.fault {
369 println!(" FAULT: {fault}");
374 eprintln!(
375 "\nThis is damage to a record that was written whole, not an interrupted\n\
376 write, so cutting the end of the file cannot repair it: every record\n\
377 after position {} was acknowledged to a client.\n\
378 \n\
379 Restore from backup:\n\
380 1. stop the node (it will refuse to start on this log anyway)\n\
381 2. keep this file -- do not delete it; it is the only copy of\n\
382 whatever is still readable\n\
383 3. restore the log from the most recent backup\n\
384 4. verify the restored copy with `choir repair <log> --verify`\n\
385 before starting the node on it",
386 fault.position()
387 );
388 std::process::exit(1);
389 }
390
391 if verify {
392 if report.torn_tail_bytes > 0 {
393 println!(
394 "\nUsable. The torn tail is repairable: re-run with --truncate-tail,\n\
395 or simply start the node, which repairs it on open."
396 );
397 } else {
398 println!("\nIntact.");
399 }
400 return;
401 }
402
403 match choir_oplog::repair::truncate_tail(path) {
404 Ok(None) => println!("\nNothing to repair; the log already ends on a record boundary."),
405 Ok(Some(repaired)) => println!(
406 "\nRepaired.\n quarantined: {} ({} bytes)\n log length: {}\n\n\
407 The removed bytes are in that file, not deleted. Verify before\n\
408 starting the node: choir repair {log_file} --verify",
409 repaired.quarantine.display(),
410 repaired.bytes,
411 repaired.length
412 ),
413 Err(error) => {
414 eprintln!("choir: repair refused: {error:?}");
415 std::process::exit(1);
416 }
417 }
418}
419
420fn load_key(path: &str) -> ActorKey {
422 if std::path::Path::new(path).exists() {
423 let bytes = std::fs::read(path).expect("read key file");
424 ActorKey::from_secret_bytes(&bytes.as_slice().try_into().expect("32-byte key file"))
425 } else {
426 let key = ActorKey::generate();
427 choir_fs::write_atomic_private(std::path::Path::new(path), key.secret_bytes())
430 .expect("write key file");
431 key
432 }
433}
434
435fn http(
437 api: &str,
438 auth: AuthOptions<'_>,
439 tool: &str,
440 arguments: serde_json::Value,
441) -> (u16, String) {
442 let client = match choir_cli::mcp::HttpClient::new(
443 api,
444 auth.file_for(api).map(std::path::Path::new),
445 auth.user,
446 ) {
447 Ok(client) => client,
448 Err(error) => {
449 eprintln!("choir: {error}");
450 std::process::exit(2);
451 }
452 };
453 let endpoint = choir_cli::surface::mcp_endpoint(tool).expect("CLI endpoint is in the table");
454 match client.request(endpoint, &arguments) {
455 Ok(response) => response,
456 Err(error) => {
457 eprintln!("choir: {error}");
458 std::process::exit(1);
459 }
460 }
461}
462
463fn derived_view(
470 api: &str,
471 auth: AuthOptions<'_>,
472 derive: impl Fn(&serde_json::Value) -> serde_json::Value,
473) -> String {
474 let (status, body) = http(api, auth, "choir_view", serde_json::json!({}));
475 if !(200..300).contains(&status) {
476 eprintln!("choir: GET /api/view returned {status}: {body}");
477 std::process::exit(1);
478 }
479 let view: serde_json::Value = match serde_json::from_str(&body) {
480 Ok(view) => view,
481 Err(error) => {
482 eprintln!("choir: /api/view response is not JSON: {error}");
483 std::process::exit(1);
484 }
485 };
486 serde_json::to_string_pretty(&derive(&view)).expect("derived documents are serializable")
487}
488
489fn operator_call(
497 api: &str,
498 auth: AuthOptions<'_>,
499 method: &str,
500 path: &'static str,
501 body: &serde_json::Value,
502) -> serde_json::Value {
503 let endpoint = choir_cli::surface::endpoint(method, path)
504 .unwrap_or_else(|| panic!("{path} is in the endpoint table"));
505 let client = match choir_cli::mcp::HttpClient::new(
506 api,
507 auth.file_for(api).map(std::path::Path::new),
508 auth.user,
509 ) {
510 Ok(client) => client,
511 Err(error) => {
512 eprintln!("choir: {error}");
513 std::process::exit(2);
514 }
515 };
516 let (status, text) = match client.request(endpoint, body) {
517 Ok(response) => response,
518 Err(error) => {
519 eprintln!("choir: {error}");
520 std::process::exit(1);
521 }
522 };
523 let parsed = serde_json::from_str::<serde_json::Value>(&text).unwrap_or_default();
524 if !(200..300).contains(&status) {
525 eprintln!(
526 "choir: {method} {path} returned {status}: {}",
527 parsed["error"].as_str().unwrap_or(text.trim())
528 );
529 if status == 403 || status == 401 {
530 eprintln!("this needs a credential holding `@node write`.");
531 }
532 std::process::exit(1);
533 }
534 parsed
535}
536
537fn grant_line(repo: &str, level: &str) -> String {
543 let repo = repo.strip_suffix(".git").unwrap_or(repo);
544 format!("{repo}.git {level}")
545}
546
547fn checked_level(level: &str) -> &str {
549 match level {
550 "read" | "propose" | "write" => level,
551 other => {
552 eprintln!("choir: `{other}` is not a level; use read, propose or write");
553 std::process::exit(2);
554 }
555 }
556}
557
558fn host(rest: &[&str]) -> ! {
581 let style = choir_cli::style::Style::for_stderr();
582 let options = match choir_cli::host::parse(rest, state_dir()) {
583 Ok(options) => options,
584 Err(error) => {
585 eprintln!("{} {error}", style.red("choir host:"));
586 std::process::exit(2);
587 }
588 };
589 let layout = choir_cli::serve::Layout::new(&options.state, options.port);
590 let user = choir_cli::host::username();
591 let mut done: Vec<String> = Vec::new();
592 let retry = rerun_line(rest);
593 eprintln!();
594
595 if layout.missing().is_empty() {
600 step(
601 "state",
602 &format!("{} (already here, kept)", options.state.display()),
603 );
604 } else {
605 let plan = choir_cli::init::Plan::new(&options.state, options.port);
606 if let Err(error) = choir_cli::init::run(&plan, false) {
607 host_failed(&done, "state", &error, &retry);
608 }
609 step(
610 "state",
611 &format!(
612 "{} — credential, key, trusted keys",
613 options.state.display()
614 ),
615 );
616 }
617 done.push("state".to_string());
618
619 if !layout.acl.exists() {
631 let acl = "# Who may reach what (D29). `<user> <repo|*|@node> <level>`,\n\
632 # levels read < propose < write < own. Issued grants (D36) are\n\
633 # merged with this file; `own` and `@node` are granted here only.\n\
634 choir * own\n\
635 choir @node write\n";
636 if let Err(error) = choir_fs::write_atomic_private(&layout.acl, acl) {
637 host_failed(
638 &done,
639 "acl",
640 &format!("{}: {error}", layout.acl.display()),
641 &retry,
642 );
643 }
644 }
645 if !layout.accounts.exists() {
646 if let Err(error) = choir_fs::write_atomic_private(&layout.accounts, "") {
647 host_failed(
648 &done,
649 "accounts",
650 &format!("{}: {error}", layout.accounts.display()),
651 &retry,
652 );
653 }
654 }
655
656 let name = options.exposure.name();
658 match &name {
659 None => step("certificate", "not needed — this node binds loopback only"),
660 Some(name) => {
661 let sudo = tls_line(name, &user, options.port, options.dry_run);
662 match layout.tls() {
663 Some((cert, _)) if !options.dry_run && std::fs::File::open(&cert).is_ok() => {
664 match choir_cli::tls::expiry(&cert) {
665 Ok(when) => step(
666 "certificate",
667 &format!("already issued, valid until {when}"),
668 ),
669 Err(_) => step(
670 "certificate",
671 &format!("already issued — {}", cert.display()),
672 ),
673 }
674 }
675 _ => {
676 let firewall = choir_cli::host::firewall_hint(options.port, true);
677 let mut paste = vec![sudo];
678 if let Some(line) = firewall {
679 paste.push(line);
680 }
681 handover(
682 &done,
683 &format!(
684 "a certificate for {name} has to be issued as root.\n \
685 certbot writes /etc/letsencrypt, and the renewal hook that keeps\n \
686 this working for the next two years lives there too. Paste this:"
687 ),
688 &paste,
689 &retry,
690 );
691 }
692 }
693 done.push("certificate".to_string());
694 }
695 }
696
697 match choir_cli::host::linger(&user) {
701 None | Some(true) => {}
702 Some(false) if options.yes => {
703 eprintln!(
704 " {} {:14} off — this node will stop when {user} logs out",
705 style.cyan("!!"),
706 style.dim("linger")
707 );
708 }
709 Some(false) => handover(
710 &done,
711 &format!(
712 "linger is off for {user}, so systemd stops this node at logout.\n \
713 One line fixes it for the life of the machine:"
714 ),
715 &[format!("sudo loginctl enable-linger {user}")],
716 &format!("{retry} (or add --yes to accept a node that dies at logout)"),
717 ),
718 }
719 if choir_cli::host::linger(&user) == Some(true) {
720 step("linger", &format!("on for {user}"));
721 }
722
723 let url = options.exposure.url(options.port);
728 if let Err(error) = choir_fs::write_atomic(&layout.public_url, format!("{url}\n")) {
729 host_failed(
730 &done,
731 "address",
732 &format!("{}: {error}", layout.public_url.display()),
733 &retry,
734 );
735 }
736 if let Err(error) = choir_fs::write_atomic(
737 std::path::Path::new(".choir/config"),
738 format!(
739 "# Which node the `choir` commands talk to when they are not\n\
740 # given one. Written by `choir host`: this machine is the node.\n\
741 node = {url}\n"
742 ),
743 ) {
744 eprintln!(" {} .choir/config: {error}", style.cyan("!!"));
745 }
746 step("address", &url);
747
748 if options.foreground {
755 step(
756 "foreground",
757 "becoming the daemon; the runtime supervises it",
758 );
759 eprintln!();
760 let program = match choir_cli::serve::find_daemon() {
761 Ok(program) => program,
762 Err(error) => host_failed(&done, "foreground", &error, &retry),
763 };
764 match choir_cli::serve::plan(program, &layout, &[], &options.extra) {
765 Ok(invocation) => {
766 let error = choir_cli::serve::exec(&invocation);
767 host_failed(&done, "foreground", &error, &retry);
768 }
769 Err(error) => host_failed(&done, "foreground", &error, &retry),
770 }
771 }
772 match install_unit(&options.state, options.port, &options.extra) {
773 Ok((unit, _)) => step("supervised", &unit.display().to_string()),
774 Err(error) => host_failed(&done, "supervised", &error, &retry),
775 }
776 done.push("supervised".to_string());
777
778 let client = match choir_cli::mcp::HttpClient::new(&url, Some(&layout.auth), None) {
781 Ok(client) => client,
782 Err(error) => host_failed(&done, "healthy", &error, &retry),
783 };
784 match choir_cli::host::wait_healthy(&client, 30) {
785 Ok(()) => step("healthy", &format!("{url}/healthz")),
786 Err(error) => host_failed(
791 &done,
792 "healthy",
793 &format!(
794 "{error}\n it says why in {}",
795 layout.log.display()
796 ),
797 &format!("choir node logs --state {}", options.state.display()),
798 ),
799 }
800 done.push("healthy".to_string());
801
802 if let Some(repo) = &options.repo {
804 let endpoint = choir_cli::surface::endpoint("POST", "/api/repo")
805 .expect("the repo endpoint is in the table");
806 match client.request(endpoint, &serde_json::json!({ "name": repo })) {
807 Ok((status, _)) if (200..300).contains(&status) => {
808 step("repository", &format!("{url}/{repo}"));
809 }
810 Ok((409, _)) => step("repository", &format!("{repo} was already there")),
811 Ok((status, body)) => {
812 host_failed(&done, "repository", &format!("{status}: {body}"), &retry)
813 }
814 Err(error) => host_failed(&done, "repository", &error, &retry),
815 }
816 done.push("repository".to_string());
817 }
818 let mut link: Option<String> = None;
819 if let Some(who) = &options.invite {
820 let repo = options.repo.clone().unwrap_or_default();
821 let endpoint = choir_cli::surface::endpoint("POST", "/api/accounts/invite")
822 .expect("the invite endpoint is in the table");
823 let body = serde_json::json!({
824 "display_name": who,
825 "grants": [grant_line(&repo, "write")],
826 });
827 match client.request(endpoint, &body) {
828 Ok((status, text)) if (200..300).contains(&status) => {
829 let answer: serde_json::Value = serde_json::from_str(&text).unwrap_or_default();
830 let issued = answer["join_url"]
831 .as_str()
832 .or_else(|| answer["invite"].as_str())
833 .unwrap_or_default()
834 .to_string();
835 step("invited", who);
836 link = Some(issued);
837 }
838 Ok((status, body)) => {
839 host_failed(&done, "invited", &format!("{status}: {body}"), &retry)
840 }
841 Err(error) => host_failed(&done, "invited", &error, &retry),
842 }
843 }
844
845 eprintln!();
848 if name.is_some() {
849 if let Some(line) = choir_cli::host::firewall_hint(options.port, true) {
850 eprintln!(
851 " {} a firewall is running here and this command did not touch it.\n {line}\n",
852 style.cyan("note:")
853 );
854 }
855 } else {
856 eprintln!(
857 " {} nobody outside this machine can reach a loopback node. To share it:\n {}\n",
858 style.dim("share:"),
859 choir_cli::host::share_hint(options.port)
860 );
861 }
862 eprintln!(" {} choir doctor\n", style.dim("check it:"));
863
864 println!("{url}");
866 if let Some(link) = link {
867 println!("{link}");
868 }
869 std::process::exit(0)
870}
871
872fn tls_line(domain: &str, user: &str, port: u16, dry_run: bool) -> String {
874 let mut line = format!("sudo choir node tls {domain} --user {user} --port {port}");
875 if dry_run {
876 line.push_str(" --dry-run");
877 }
878 line
879}
880
881fn rerun_line(rest: &[&str]) -> String {
883 let mut line = "choir host".to_string();
884 for argument in rest {
885 line.push(' ');
886 line.push_str(argument);
887 }
888 line
889}
890
891fn node_tls(rest: &[&str]) -> ! {
900 let style = choir_cli::style::Style::for_stderr();
901 let command = "choir node tls";
902 let mut domain: Option<&str> = None;
903 let mut user: Option<String> = None;
904 let mut port = 8417u16;
905 let mut issuance = choir_cli::tls::Issuance::Live;
906 let mut i = 0;
907 while i < rest.len() {
908 match rest[i] {
909 "--dry-run" => {
910 issuance = choir_cli::tls::Issuance::DryRun;
911 i += 1;
912 }
913 "--staging" => {
914 issuance = choir_cli::tls::Issuance::Staging;
915 i += 1;
916 }
917 flag @ ("--user" | "--port") => {
918 let Some(value) = rest.get(i + 1) else {
919 eprintln!("{command}: {flag} needs a value");
920 std::process::exit(2);
921 };
922 match flag {
923 "--user" => user = Some((*value).to_string()),
924 _ => match value.parse() {
925 Ok(n) => port = n,
926 Err(_) => {
927 eprintln!("{command}: --port needs a port number, not {value:?}");
928 std::process::exit(2);
929 }
930 },
931 }
932 i += 2;
933 }
934 other if !other.starts_with('-') && domain.is_none() => {
935 domain = Some(other);
936 i += 1;
937 }
938 other => {
939 eprintln!("{command}: unknown option {other:?}");
940 std::process::exit(2);
941 }
942 }
943 }
944 let Some(domain) = domain else {
945 eprintln!(
946 "{command}: which name is the certificate for?\n\n \
947 sudo choir node tls <domain> --user <the account the node runs as>"
948 );
949 std::process::exit(2);
950 };
951 let Some(user) = user else {
957 eprintln!(
958 "{command}: --user is required — which unprivileged account does the node\n \
959 run as? Under sudo this process is root, and guessing would put the\n \
960 certificate in the wrong home while the node kept serving plaintext.\n\n \
961 sudo choir node tls {domain} --user $(id -un)"
962 );
963 std::process::exit(2);
964 };
965 let home = home_of(&user).unwrap_or_else(|| {
966 eprintln!("{command}: no such user: {user}");
967 std::process::exit(1);
968 });
969 let plan = choir_cli::tls::Plan::new(domain, port, &user, &home);
970 let challenge = choir_cli::tls::Challenge::for_state(&plan.state);
971 let uid = choir_cli::tls::uid();
972 if let Err(problems) = choir_cli::tls::preflight(&plan, challenge, &uid) {
973 eprintln!("\n {} {problems}\n", style.red(&format!("{command}:")));
974 std::process::exit(1);
975 }
976 eprintln!();
977 if challenge == choir_cli::tls::Challenge::Http01 {
978 eprintln!(
979 " {} HTTP-01: port 80 must be reachable from the internet now and at\n \
980 every renewal. There is no $HOME/.choir/cloudflare.ini here, which is\n \
981 what selects DNS-01 instead.\n",
982 style.dim("method:")
983 );
984 }
985 let (steps, failure) = match choir_cli::tls::apply(&plan, challenge, issuance, &uid) {
986 Ok(steps) => (steps, None),
987 Err((steps, error)) => (steps, Some(error)),
988 };
989 for one in &steps {
990 match &one.outcome {
991 Ok(detail) => step(&one.what, detail),
992 Err(error) => eprintln!(
993 " {} {:14} {error}",
994 style.red("--"),
995 style.dim(&one.what)
996 ),
997 }
998 }
999 if let Some(error) = failure {
1000 eprintln!(
1001 "\n {} {error}\n\n \
1002 nothing the node reads was changed, so it is still serving whatever it\n \
1003 was serving before.\n",
1004 style.red("stopped:")
1005 );
1006 std::process::exit(1);
1007 }
1008 if issuance == choir_cli::tls::Issuance::DryRun {
1009 eprintln!(
1010 "\n {} the path works and no certificate was issued. Run it again\n \
1011 without --dry-run.\n",
1012 style.green("dry run:")
1013 );
1014 std::process::exit(0);
1015 }
1016 eprintln!(
1017 "\n {} renewal is certbot's own timer; the hook above re-projects the pair\n \
1018 and restarts the node, because the daemon reads its certificate once at\n \
1019 bind and has no reload.\n\n \
1020 {} back as {user}: choir host --domain {domain} --port {port}\n",
1021 style.dim("renewal:"),
1022 style.dim("then:")
1023 );
1024 println!("{}", plan.url());
1025 std::process::exit(0)
1026}
1027
1028fn home_of(user: &str) -> Option<std::path::PathBuf> {
1034 if let Some(out) = std::process::Command::new("getent")
1035 .args(["passwd", user])
1036 .output()
1037 .ok()
1038 .filter(|out| out.status.success())
1039 {
1040 let text = String::from_utf8_lossy(&out.stdout);
1041 let field = text.trim().split(':').nth(5)?;
1042 if !field.is_empty() {
1043 return Some(std::path::PathBuf::from(field));
1044 }
1045 }
1046 let out = std::process::Command::new("dscl")
1047 .args([".", "-read", &format!("/Users/{user}"), "NFSHomeDirectory"])
1048 .output()
1049 .ok()
1050 .filter(|out| out.status.success())?;
1051 let text = String::from_utf8_lossy(&out.stdout);
1052 let field = text.trim().strip_prefix("NFSHomeDirectory:")?.trim();
1053 match field.is_empty() {
1054 true => None,
1055 false => Some(std::path::PathBuf::from(field)),
1056 }
1057}
1058
1059fn invite(api: &str, auth: AuthOptions<'_>, name: &str, repo: &str, level: &str) -> ! {
1060 let answer = operator_call(
1061 api,
1062 auth,
1063 "POST",
1064 "/api/accounts/invite",
1065 &serde_json::json!({
1066 "display_name": name,
1067 "grants": [grant_line(repo, checked_level(level))],
1068 }),
1069 );
1070 match answer["join_url"].as_str() {
1071 Some(url) => println!("{url}"),
1072 None => println!("{}", answer["invite"].as_str().unwrap_or_default()),
1076 }
1077 std::process::exit(0)
1078}
1079
1080fn asks(api: &str, auth: AuthOptions<'_>) -> ! {
1085 let answer = operator_call(api, auth, "GET", "/api/accounts", &serde_json::json!({}));
1086 let rows = answer["requests"]
1087 .as_array()
1088 .map(Vec::as_slice)
1089 .unwrap_or_default();
1090 if rows.is_empty() {
1091 println!("Nobody is waiting.");
1092 std::process::exit(0);
1093 }
1094 let now = std::time::SystemTime::now()
1095 .duration_since(std::time::UNIX_EPOCH)
1096 .map_or(0, |d| d.as_secs());
1097 for row in rows {
1098 let asked_at = row["asked_at"].as_u64().unwrap_or(now);
1099 let waited = now.saturating_sub(asked_at) / 3600;
1100 println!(
1101 "{} {} ({waited}h) {}",
1102 row["request_id"].as_str().unwrap_or("?"),
1103 row["display_name"].as_str().unwrap_or("?"),
1104 row["about"].as_str().unwrap_or("")
1105 );
1106 }
1107 std::process::exit(0)
1108}
1109
1110fn grant(api: &str, auth: AuthOptions<'_>, id: &str, repo: &str, level: &str) -> ! {
1116 let answer = operator_call(
1117 api,
1118 auth,
1119 "POST",
1120 "/api/accounts/request/grant",
1121 &serde_json::json!({
1122 "request_id": id,
1123 "grants": [grant_line(repo, checked_level(level))],
1124 }),
1125 );
1126 println!(
1127 "{} is in as {}. The link they already hold now works; send nothing.",
1128 answer["display_name"].as_str().unwrap_or("they"),
1129 answer["user"].as_str().unwrap_or("?")
1130 );
1131 std::process::exit(0)
1132}
1133
1134fn decline(api: &str, auth: AuthOptions<'_>, id: &str) -> ! {
1136 operator_call(
1137 api,
1138 auth,
1139 "POST",
1140 "/api/accounts/request/decline",
1141 &serde_json::json!({ "request_id": id }),
1142 );
1143 println!("Declined. Their link now reads as one that was never valid.");
1144 std::process::exit(0)
1145}
1146
1147fn acl_render(api: &str, auth: AuthOptions<'_>, acl_file: &str) -> ! {
1162 let endpoint = choir_cli::surface::endpoint("GET", "/api/accounts")
1163 .expect("the accounts roster is in the endpoint table");
1164 let client = match choir_cli::mcp::HttpClient::new(
1165 api,
1166 auth.file_for(api).map(std::path::Path::new),
1167 auth.user,
1168 ) {
1169 Ok(client) => client,
1170 Err(error) => {
1171 eprintln!("choir: {error}");
1172 std::process::exit(2);
1173 }
1174 };
1175 let (status, body) = match client.request(endpoint, &serde_json::json!({})) {
1176 Ok(response) => response,
1177 Err(error) => {
1178 eprintln!("choir: {error}");
1179 std::process::exit(1);
1180 }
1181 };
1182 if !(200..300).contains(&status) {
1183 eprintln!(
1184 "choir: GET /api/accounts returned {status}: {body}\n\
1185 reading the roster needs a credential with `@node auditor`."
1186 );
1187 std::process::exit(1);
1188 }
1189 let roster = match serde_json::from_str::<serde_json::Value>(&body) {
1190 Ok(doc) => doc["accounts"]
1191 .as_array()
1192 .map(Vec::as_slice)
1193 .unwrap_or_default()
1194 .iter()
1195 .filter_map(|account| {
1196 Some((
1197 account["user"].as_str()?.to_string(),
1198 account["display_name"].as_str()?.to_string(),
1199 ))
1200 })
1201 .collect::<choir_cli::acl::Roster>(),
1202 Err(error) => {
1203 eprintln!("choir: /api/accounts response is not JSON: {error}");
1204 std::process::exit(1);
1205 }
1206 };
1207
1208 let path = std::path::Path::new(acl_file);
1209 let before = match std::fs::read_to_string(path) {
1210 Ok(text) => text,
1211 Err(error) => {
1212 eprintln!("choir: cannot read {acl_file}: {error}");
1213 std::process::exit(1);
1214 }
1215 };
1216 let after = choir_cli::acl::render(&before, &roster);
1217 let (grants, named) = choir_cli::acl::counts(&before, &roster);
1218 let wrote = after != before;
1219 if wrote {
1220 if let Err(error) = choir_fs::write_atomic_private(path, &after) {
1226 eprintln!("choir: cannot write {acl_file}: {error}");
1227 std::process::exit(1);
1228 }
1229 }
1230 let doc = serde_json::json!({
1231 "path": path.display().to_string(),
1232 "wrote": wrote,
1233 "grants": grants,
1234 "named": named,
1235 "unresolved": grants - named,
1236 });
1237 finish(200, &doc.to_string());
1238}
1239
1240enum Invite<'a> {
1248 File(&'a str),
1250 Pair(String, String),
1252}
1253
1254fn ensure_state_dir() -> Result<(), String> {
1261 let dir = state_dir();
1262 if !dir.is_dir() {
1263 std::fs::create_dir_all(&dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
1264 }
1265 #[cfg(unix)]
1266 {
1267 use std::os::unix::fs::PermissionsExt;
1268 std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))
1269 .map_err(|e| format!("chmod 700 {}: {e}", dir.display()))?;
1270 }
1271 Ok(())
1272}
1273
1274fn refusal_next(body: &str) -> Option<String> {
1281 let doc: serde_json::Value = serde_json::from_str(body).ok()?;
1282 doc.get("next")?.as_str().map(str::to_string)
1283}
1284
1285fn redeem_next(body: &str) -> String {
1294 let message = serde_json::from_str::<serde_json::Value>(body)
1295 .ok()
1296 .and_then(|doc| doc.get("error")?.as_str().map(str::to_string))
1297 .unwrap_or_default();
1298 if message.contains("no such invite") {
1299 return "ask the operator for a new link; this one was never valid, or has been used"
1300 .to_string();
1301 }
1302 if message.contains("expired") {
1303 return "ask the operator for a new link; this one has expired".to_string();
1304 }
1305 if message.contains("--invite-binds-keys") {
1306 return "send the operator the line `choir key <key-file> <channel>` prints, \
1307 and ask them to register it"
1308 .to_string();
1309 }
1310 if message.contains("already taken") || message.contains("username") {
1311 return "choir join '<link>' --user <another-name>".to_string();
1312 }
1313 "send the operator that message; it describes their node, not your machine".to_string()
1314}
1315
1316fn join(api: &str, invite: Invite<'_>, key_file: Option<&str>, rest: &[&str]) -> ! {
1341 let (mut channel, mut ssh_key, mut token_file, mut chosen_user) = (None, None, None, None);
1342 let mut key_flag = None;
1343 let mut index = 0;
1344 while index < rest.len() {
1345 let Some(value) = rest.get(index + 1).copied() else {
1346 usage();
1347 };
1348 let slot = match rest[index] {
1349 "--channel" if channel.is_none() => &mut channel,
1350 "--ssh-key" if ssh_key.is_none() => &mut ssh_key,
1351 "--token-file" if token_file.is_none() => &mut token_file,
1352 "--key-file" if key_flag.is_none() => &mut key_flag,
1356 "--user" if chosen_user.is_none() => &mut chosen_user,
1360 _ => usage(),
1361 };
1362 *slot = Some(value);
1363 index += 2;
1364 }
1365 let from_link = key_file.is_none();
1366 if from_link {
1367 if let Err(error) = ensure_state_dir() {
1368 eprintln!("choir join: {error}\nnext: choir join '<link>' --key-file <path>");
1369 std::process::exit(1);
1370 }
1371 }
1372 let default_key = state_dir().join("agent.key").display().to_string();
1387 let key_file = match (key_flag, key_file) {
1388 (Some(named), _) | (None, Some(named)) => named.to_string(),
1389 (None, None) => {
1390 let joined =
1391 std::path::Path::new(&default_key).exists() && state_dir().join("auth").exists();
1392 if joined {
1393 eprintln!(
1394 "choir join: this machine has already joined a node: there is a key at \
1395 {default_key} and a token beside it.\n\
1396 A key is an identity the node has bound, so this will not replace one.\n\
1397 next: choir join '<link>' --key-file <another-path> --token-file <another-path>"
1398 );
1399 std::process::exit(1);
1400 }
1401 default_key
1402 }
1403 };
1404 let key_file = key_file.as_str();
1405 let invite_file = match invite {
1406 Invite::File(path) => {
1407 if !std::path::Path::new(path).is_file() {
1408 eprintln!(
1409 "choir join: {path} does not exist.\n\
1410 Write the invite the operator sent you into it, as one line: <id>:<secret>\n\
1411 next: choir join '<link>' (the link needs no file at all)"
1412 );
1413 std::process::exit(2);
1414 }
1415 Some(path)
1416 }
1417 Invite::Pair(..) => None,
1418 };
1419 let key = load_key(key_file);
1424 let mut body = serde_json::json!({ "actor_key": hex_encode(&key.public_key_bytes()) });
1425 if let Some(user) = chosen_user {
1426 body["user"] = serde_json::json!(user);
1427 }
1428 if let Some(channel) = channel {
1429 body["channel"] = serde_json::json!(channel);
1430 }
1431 if let Some(path) = ssh_key {
1432 match std::fs::read_to_string(path) {
1433 Ok(line) => body["ssh_key"] = serde_json::json!(line.trim()),
1434 Err(error) => {
1435 eprintln!("choir join: cannot read {path}: {error}");
1436 std::process::exit(2);
1437 }
1438 }
1439 }
1440
1441 let endpoint = choir_cli::surface::endpoint("POST", "/api/accounts/redeem")
1442 .expect("redemption is in the endpoint table");
1443 let client = match &invite {
1444 Invite::File(_) => {
1445 choir_cli::mcp::HttpClient::new(api, invite_file.map(std::path::Path::new), None)
1446 }
1447 Invite::Pair(id, secret) => choir_cli::mcp::HttpClient::with_credential(api, id, secret),
1448 };
1449 let client = match client {
1450 Ok(client) => client,
1451 Err(error) => {
1452 eprintln!("choir join: {error}\nnext: choir join '<link>'");
1453 std::process::exit(2);
1454 }
1455 };
1456 let send = |body: &serde_json::Value| match client.request(endpoint, body) {
1457 Ok(response) => response,
1458 Err(error) => {
1459 eprintln!(
1460 "choir join: {error}\n\
1461 next: choir doctor (it says which of curl, the network or the node is at fault)"
1462 );
1463 std::process::exit(1);
1464 }
1465 };
1466 let (mut status, mut response) = send(&body);
1467 if status == 400 && response.contains("this invite lets you pick your name") {
1473 match choir_cli::prompt::ask("Pick the name this account keeps (letters, digits, - and _):")
1474 {
1475 Some(name) => {
1476 body["user"] = serde_json::json!(name);
1477 (status, response) = send(&body);
1478 }
1479 None => {
1480 eprintln!(
1481 "choir join: this invite lets you pick your name, and nothing here can \
1482 ask for one.\n\
1483 next: choir join '<link>' --user <name>"
1484 );
1485 std::process::exit(2);
1486 }
1487 }
1488 }
1489 if !(200..300).contains(&status) {
1490 println!("{response}");
1491 eprintln!(
1498 "\nnext: {}",
1499 refusal_next(&response).unwrap_or_else(|| redeem_next(&response))
1500 );
1501 std::process::exit(1);
1502 }
1503 let account: serde_json::Value = match serde_json::from_str(&response) {
1504 Ok(account) => account,
1505 Err(error) => {
1506 eprintln!(
1510 "choir join: the node's answer did not parse: {error}\n\
1511 next: send the operator that line; their node answered something \
1512 this version cannot read"
1513 );
1514 std::process::exit(1);
1515 }
1516 };
1517 let (Some(user), Some(token)) = (account["user"].as_str(), account["token"].as_str()) else {
1518 eprintln!(
1519 "choir join: the node issued no token\n\
1520 next: send the operator this; the invite was accepted and nothing was handed back"
1521 );
1522 std::process::exit(1);
1523 };
1524
1525 let token_path = match (token_file, from_link) {
1535 (Some(named), _) => std::path::PathBuf::from(named),
1536 (None, true) => state_dir().join("auth"),
1537 (None, false) => std::path::Path::new(key_file)
1538 .parent()
1539 .unwrap_or(std::path::Path::new("."))
1540 .join("choir.auth"),
1541 };
1542 if let Err(error) = choir_fs::write_atomic_private(&token_path, format!("{user}:{token}\n")) {
1543 eprintln!(
1544 "choir join: the node issued a token but it could not be stored at {}: {error}\n\
1545 The invite is spent, so this token cannot be reissued.\n\
1546 next: ask the operator for another link, then run \
1547 `choir join '<link>' --token-file <a-writable-path>`",
1548 token_path.display()
1549 );
1550 std::process::exit(1);
1551 }
1552
1553 let bound = account["actor_key_bound"] == serde_json::Value::Bool(true);
1554 let channel = account["channel"].as_str().unwrap_or(user);
1555 let (git_config, home_config) = match from_link {
1561 false => (None, None),
1562 true => (
1563 configure_git_credential(api, &token_path.display().to_string()),
1564 write_home_config(api, channel, key_file),
1565 ),
1566 };
1567 let next = if bound {
1568 "choir propose".to_string()
1569 } else {
1570 format!("choir key {key_file} {channel} — send the operator that line")
1574 };
1575 let summary = serde_json::json!({
1576 "user": user,
1577 "channel": account["channel"],
1578 "grants": account["grants"],
1579 "auth_file": token_path.display().to_string(),
1580 "key_file": key_file,
1581 "actor_key_bound": bound,
1582 "git_config": git_config,
1583 "config": home_config,
1584 "next": next,
1585 });
1586 if !from_link {
1587 note(
1588 &format!("joined as {user}"),
1589 &[
1590 ("token", format!("{} (0600)", token_path.display())),
1591 ("key", key_file.to_string()),
1592 ("next", next),
1593 ],
1594 );
1595 finish(
1596 200,
1597 &serde_json::to_string_pretty(&summary).expect("summary is serializable"),
1598 );
1599 }
1600
1601 let style = choir_cli::style::Style::for_stdout();
1606 println!("\n {}\n", style.green(&format!("Joined {api} as {user}.")));
1607 println!(" {} {}", style.dim("channel "), channel);
1608 println!(
1609 " {} {} {}",
1610 style.dim("key "),
1611 key_file,
1612 style.dim("(0600)")
1613 );
1614 println!(
1615 " {} {} {}",
1616 style.dim("token "),
1617 token_path.display(),
1618 style.dim("(0600)")
1619 );
1620 if let Some(path) = &home_config {
1621 println!(
1622 " {} {} {}",
1623 style.dim("node "),
1624 path,
1625 style.dim("(so no command has to be told the node again)")
1626 );
1627 }
1628 match &git_config {
1629 Some(path) => println!(
1630 " {} {} {}",
1631 style.dim("git "),
1632 path,
1633 style.dim("(clone and push need no token in the URL)")
1634 ),
1635 None => println!(
1636 " {} {}",
1637 style.dim("git "),
1638 style.cyan(&format!(
1639 "not configured; run: git config --global credential.{api}.helper \
1640 '!choir git-credential {}'",
1641 token_path.display()
1642 )),
1643 ),
1644 }
1645 if bound {
1646 println!(
1647 "\n {}\n {}\n",
1648 style.dim("Clone anything you were granted, commit on a branch, then:"),
1649 style.cyan("choir propose")
1650 );
1651 } else {
1652 println!(
1653 "\n {}\n {}\n",
1654 style.dim(
1655 "This node does not register keys at redemption. Send the operator this line:"
1656 ),
1657 style.cyan(&format!("choir key {key_file} {channel}")),
1658 );
1659 }
1660 std::process::exit(0);
1661}
1662
1663fn configure_git_credential(api: &str, auth_file: &str) -> Option<String> {
1676 let exe = std::env::current_exe()
1677 .map(|p| p.display().to_string())
1678 .unwrap_or_else(|_| "choir".to_string());
1679 let origin = api.trim_end_matches('/');
1680 let out = std::process::Command::new("git")
1681 .args([
1682 "config",
1683 "--global",
1684 &format!("credential.{origin}.helper"),
1685 &format!("!{exe} git-credential {auth_file}"),
1686 ])
1687 .output()
1688 .ok()?;
1689 if !out.status.success() {
1690 return None;
1691 }
1692 let out = std::process::Command::new("git")
1693 .args([
1694 "config",
1695 "--global",
1696 "--list",
1697 "--show-origin",
1698 "--name-only",
1699 ])
1700 .output()
1701 .ok()?;
1702 String::from_utf8_lossy(&out.stdout)
1707 .lines()
1708 .next()
1709 .and_then(|line| line.split('\t').next())
1710 .and_then(|origin| origin.strip_prefix("file:"))
1711 .map(str::to_string)
1712}
1713
1714fn write_home_config(api: &str, channel: &str, key_file: &str) -> Option<String> {
1724 let path = state_dir().join("config");
1725 let existing = std::fs::read_to_string(&path).unwrap_or_default();
1726 let mut out = String::new();
1727 let written = [
1728 ("node", api.trim_end_matches('/')),
1729 ("channel", channel),
1730 ("key", key_file),
1731 ];
1732 for line in existing.lines() {
1733 let key = line.split_once('=').map(|(k, _)| k.trim()).unwrap_or("");
1734 if !written.iter().any(|(name, _)| *name == key) {
1735 out.push_str(line);
1736 out.push('\n');
1737 }
1738 }
1739 for (name, value) in written {
1740 out.push_str(&format!("{name} = {value}\n"));
1741 }
1742 choir_fs::write_atomic_private(&path, out).ok()?;
1743 Some(path.display().to_string())
1744}
1745
1746fn git_credential(auth_file: &str, user: Option<&str>, operation: &str) -> ! {
1768 match operation {
1769 "store" | "erase" => std::process::exit(0),
1773 "get" => {}
1774 _ => {
1775 eprintln!("choir git-credential: unknown operation `{operation}`");
1776 std::process::exit(2);
1777 }
1778 }
1779 let mut request = String::new();
1784 use std::io::Read;
1785 if std::io::stdin().read_to_string(&mut request).is_err() {
1786 std::process::exit(1);
1787 }
1788 let (user, token) = match choir_cli::mcp::credential_pair(std::path::Path::new(auth_file), user)
1789 {
1790 Ok(pair) => pair,
1791 Err(error) => {
1792 eprintln!("choir git-credential: {error}");
1797 std::process::exit(0);
1798 }
1799 };
1800 println!("username={user}");
1801 println!("password={token}");
1802 std::process::exit(0);
1803}
1804
1805fn git_capture(dir: &std::path::Path, args: &[&str]) -> Result<String, String> {
1812 let out = std::process::Command::new("git")
1813 .args(args)
1814 .current_dir(dir)
1815 .output()
1816 .map_err(|e| format!("cannot run git: {e}"))?;
1817 if !out.status.success() {
1818 return Err(String::from_utf8_lossy(&out.stderr).trim().to_string());
1819 }
1820 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
1821}
1822
1823fn git_run(dir: &std::path::Path, args: &[&str]) -> Result<(), String> {
1826 let status = std::process::Command::new("git")
1827 .args(args)
1828 .current_dir(dir)
1829 .status()
1830 .map_err(|e| format!("cannot run git: {e}"))?;
1831 if status.success() {
1832 Ok(())
1833 } else {
1834 Err(format!("git {} failed", args.join(" ")))
1835 }
1836}
1837
1838fn propose_abort(step: &str, detail: &str) -> ! {
1846 eprintln!("choir propose: {step}: {detail}");
1847 if !detail.contains("next:") {
1855 let next = match step {
1856 "checkout" => "run this from inside a git checkout",
1857 "identity" => "choir join '<link>'",
1858 "remote" | "base" => {
1859 "git fetch, then re-run; or name it: choir propose --api <url> --repo <owner/repo>"
1860 }
1861 "push" => "check the push error above; the credential comes from ~/.choir/auth",
1862 _ => "choir doctor",
1863 };
1864 eprintln!("next: {next}");
1865 }
1866 std::process::exit(1);
1867}
1868
1869struct ProposeOptions<'a> {
1871 api: Option<&'a str>,
1872 repo: Option<&'a str>,
1873 remote: &'a str,
1874 onto: Option<&'a str>,
1875 change: Option<&'a str>,
1876 key_file: Option<&'a str>,
1877 channel: Option<&'a str>,
1878 cone: Vec<String>,
1879 reviewers: Vec<String>,
1880}
1881
1882fn positional_propose<'a>(rest: &'a [&'a str]) -> Option<(&'a str, &'a str, &'a [&'a str])> {
1894 let [key_file, channel, tail @ ..] = rest else {
1895 return None;
1896 };
1897 let positional = !key_file.starts_with("--")
1898 && !channel.starts_with("--")
1899 && std::path::Path::new(key_file).is_file();
1900 positional.then_some((key_file, channel, tail))
1901}
1902
1903fn parse_propose<'a>(rest: &[&'a str]) -> ProposeOptions<'a> {
1904 let (mut api, mut repo, mut onto, mut change) = (None, None, None, None);
1905 let (mut key_file, mut channel) = (None, None);
1906 let mut remote = "origin";
1907 let (mut cone, mut reviewers) = (Vec::new(), Vec::new());
1908 let mut index = 0;
1909 while index < rest.len() {
1910 let flag = rest[index];
1911 if !flag.starts_with("--") {
1912 reviewers.push(flag.to_string());
1913 index += 1;
1914 continue;
1915 }
1916 let Some(value) = rest.get(index + 1).copied() else {
1917 usage();
1918 };
1919 match flag {
1920 "--api" if api.is_none() => api = Some(value),
1921 "--repo" if repo.is_none() => repo = Some(value),
1922 "--remote" => remote = value,
1923 "--onto" if onto.is_none() => onto = Some(value),
1924 "--change" if change.is_none() => change = Some(value),
1925 "--path" => cone.push(value.to_string()),
1926 "--key-file" if key_file.is_none() => key_file = Some(value),
1930 "--channel" if channel.is_none() => channel = Some(value),
1931 _ => usage(),
1932 }
1933 index += 2;
1934 }
1935 cone.sort();
1940 cone.dedup();
1941 ProposeOptions {
1942 api,
1943 repo,
1944 remote,
1945 onto,
1946 change,
1947 key_file,
1948 channel,
1949 cone,
1950 reviewers,
1951 }
1952}
1953
1954fn propose(rest: &[&str], auth: AuthOptions<'_>) -> ! {
1973 let positional = positional_propose(rest);
1974 let options = parse_propose(positional.map_or(rest, |(_, _, tail)| tail));
1975 let key_file = positional
1981 .map(|(key_file, _, _)| key_file.to_string())
1982 .or_else(|| options.key_file.map(str::to_string))
1983 .or_else(|| configured("key"))
1984 .unwrap_or_else(|| state_dir().join("agent.key").display().to_string());
1985 if !std::path::Path::new(&key_file).is_file() {
1986 propose_abort(
1987 "identity",
1988 &format!(
1989 "no key at {key_file}\nnext: choir join '<link>' \
1990 (or name one: choir propose --key-file <path>)"
1991 ),
1992 );
1993 }
1994 let channel = positional
1999 .map(|(_, channel, _)| channel.to_string())
2000 .or_else(|| options.channel.map(str::to_string))
2001 .or_else(|| configured("channel"))
2002 .or_else(|| {
2003 let path = discovered_auth_file()?;
2004 choir_cli::mcp::credential_pair(std::path::Path::new(&path), auth.user)
2005 .ok()
2006 .map(|(user, _)| user)
2007 })
2008 .unwrap_or_else(|| {
2009 propose_abort(
2010 "identity",
2011 "nothing here says which channel to sign as\nnext: choir propose --channel <name>",
2012 )
2013 });
2014 let (key_file, channel) = (key_file.as_str(), channel.as_str());
2015 let cwd = std::env::current_dir().unwrap_or_else(|e| propose_abort("checkout", &e.to_string()));
2016 let top = match git_capture(&cwd, &["rev-parse", "--show-toplevel"]) {
2017 Ok(top) => std::path::PathBuf::from(top),
2018 Err(error) => propose_abort("checkout", &error),
2019 };
2020
2021 let remote_url = git_capture(&top, &["remote", "get-url", options.remote]);
2024 let inferred = match (&options.api, &options.repo, &remote_url) {
2025 (Some(api), Some(repo), _) => choir_cli::propose::Remote {
2028 api: (*api).to_string(),
2029 repo: (*repo).to_string(),
2030 },
2031 (_, _, Ok(url)) => match choir_cli::propose::Remote::parse(url) {
2032 Ok(remote) => remote,
2033 Err(failure) => propose_abort("remote", &failure.message),
2034 },
2035 (_, _, Err(error)) => propose_abort("remote", error),
2036 };
2037 let api = options.api.map_or(inferred.api, str::to_string);
2038 let repo = options.repo.map_or(inferred.repo, str::to_string);
2039
2040 let branch =
2044 git_capture(&top, &["symbolic-ref", "--quiet", "--short", "HEAD"]).unwrap_or_default();
2045 let onto = options.onto.map_or_else(
2046 || {
2047 git_capture(
2052 &top,
2053 &[
2054 "symbolic-ref",
2055 "--short",
2056 &format!("refs/remotes/{}/HEAD", options.remote),
2057 ],
2058 )
2059 .ok()
2060 .and_then(|head| head.rsplit('/').next().map(str::to_string))
2061 .unwrap_or_else(|| "main".to_string())
2062 },
2063 str::to_string,
2064 );
2065 let proposal = match choir_cli::propose::Proposal::derive(&repo, &branch, &onto) {
2066 Ok(proposal) => proposal,
2067 Err(failure) => propose_abort("branch", &failure.message),
2068 };
2069 let change_id = options
2070 .change
2071 .map_or(proposal.identity.change_id.clone(), str::to_string);
2072 let head = match git_capture(&top, &["rev-parse", "HEAD"]) {
2073 Ok(head) => head,
2074 Err(error) => propose_abort("checkout", &error),
2075 };
2076
2077 let (status, body) = http(&api, auth, "choir_view", serde_json::json!({}));
2082 if !(200..300).contains(&status) {
2083 propose_abort("view", &format!("GET /api/view returned {status}: {body}"));
2084 }
2085 let view: serde_json::Value = serde_json::from_str(&body)
2086 .unwrap_or_else(|e| propose_abort("view", &format!("response is not JSON: {e}")));
2087 let review_open = view["reviews"]
2091 .get(&change_id)
2092 .is_some_and(|review| !review.is_null() && review["status"] != "archived");
2093 let existing = view["changes"]
2094 .get(&change_id)
2095 .filter(|state| !state.is_null());
2096 let workspace_id = match existing {
2097 Some(state) => {
2098 let workspace = state["active_workspace"]
2099 .as_str()
2100 .unwrap_or_else(|| {
2101 propose_abort(
2102 "change",
2103 "this change's workspace has been archived; propose from a new branch",
2104 )
2105 })
2106 .to_string();
2107 eprintln!(
2108 "choir propose: updating change {}",
2109 choir_cli::propose::short_change_id(&change_id)
2110 );
2111 workspace
2112 }
2113 None => {
2114 let base = git_capture(
2118 &top,
2119 &["merge-base", "HEAD", &format!("{}/{onto}", options.remote)],
2120 )
2121 .unwrap_or_else(|error| propose_abort(
2122 "base",
2123 &format!("{error}\ncannot find where this branch left {}/{onto}; fetch first, or pass --onto", options.remote),
2124 ));
2125 let workspace_name = proposal.identity.workspace_name.clone();
2126 let mut body = serde_json::json!({
2127 "repo": repo,
2128 "name": workspace_name,
2129 "base": base,
2130 "owner": channel,
2131 "change": change_id,
2132 "idempotency_key": proposal.identity.idempotency_key,
2133 });
2134 let Some(base_revision) = choir_hash::ContentHash::from_git_oid(&base) else {
2135 propose_abort("base", "merge-base did not return a git object id");
2136 };
2137 let authorization = CreateAuthorization::new(
2138 change_id.clone(),
2139 channel.into(),
2140 format!("{repo}/{workspace_name}"),
2141 base_revision,
2142 proposal.identity.idempotency_key.clone(),
2143 )
2144 .with_cone(options.cone.clone());
2145 let signed = signed_payload_body(key_file, channel, &authorization.to_payload());
2146 for field in ["channel", "payload_hex", "key_id", "signature_hex"] {
2147 body[field] = signed[field].clone();
2148 }
2149 let (status, response) = http(&api, auth, "choir_workspace", body);
2150 if !(200..300).contains(&status) {
2151 propose_abort("create change", &response);
2152 }
2153 eprintln!(
2154 "choir propose: created change {} on {base}",
2155 choir_cli::propose::short_change_id(&change_id)
2156 );
2157 format!("{repo}/{workspace_name}")
2158 }
2159 };
2160
2161 let revision_ref = proposal.revision_ref(&head);
2167 let refspec = format!("HEAD:{revision_ref}");
2168 if let Err(error) = git_run(&top, &["push", options.remote, &refspec]) {
2169 propose_abort("push", &error);
2170 }
2171
2172 let Some(revision) = choir_hash::ContentHash::from_git_oid(&head) else {
2177 propose_abort("checkpoint", "HEAD is not a git object id");
2178 };
2179 let prev_revision = current_change_revision(&api, auth, &change_id);
2180 if prev_revision != revision {
2181 let op = ViewOp::new(OpKind::CheckpointChange {
2182 id: change_id.clone(),
2183 workspace: workspace_id.clone(),
2184 revision: revision.clone(),
2185 prev_revision,
2186 });
2187 let signed = signed_body(&api, key_file, channel, &op, auth);
2188 let (status, response) = http(&api, auth, "choir_submit", signed);
2189 if !(200..300).contains(&status) {
2190 propose_abort("checkpoint", &response);
2191 }
2192 }
2193
2194 if !review_open {
2201 let op = ViewOp::new(OpKind::RequestReview {
2202 id: change_id.clone(),
2203 target: revision,
2204 reviewers: options.reviewers.clone(),
2205 target_ref: Some(proposal.review_target(&repo)),
2206 });
2207 let signed = signed_body(&api, key_file, channel, &op, auth);
2208 let (status, response) = http(&api, auth, "choir_submit", signed);
2209 if !(200..300).contains(&status) {
2210 propose_abort("request review", &response);
2211 }
2212 }
2213
2214 let summary = serde_json::json!({
2215 "change": change_id,
2216 "workspace": workspace_id,
2217 "commit": head,
2218 "pushed_ref": revision_ref,
2219 "fetch": format!("git fetch {} {revision_ref}", options.remote),
2220 "target_ref": proposal.review_target(&repo),
2221 "reviewers": if options.reviewers.is_empty() {
2222 serde_json::json!("drawn by the node")
2223 } else {
2224 serde_json::json!(options.reviewers)
2225 },
2226 "review": if review_open {
2227 "already open; it now needs re-review against this revision"
2232 } else {
2233 "opened"
2234 },
2235 "next": format!("choir state {api} {channel}"),
2236 });
2237 finish(
2238 200,
2239 &serde_json::to_string_pretty(&summary).expect("summary is serializable"),
2240 );
2241}
2242
2243fn check_exit(subject: &ContentHash, body: &str) -> ! {
2257 let view: serde_json::Value = match serde_json::from_str(body) {
2258 Ok(view) => view,
2259 Err(error) => {
2260 eprintln!("choir checks: the node's view did not parse: {error}");
2261 std::process::exit(1);
2262 }
2263 };
2264 let prefix = format!("{}:", subject.to_hex());
2265 let rows: serde_json::Map<String, serde_json::Value> = view
2266 .get("checks")
2267 .and_then(serde_json::Value::as_object)
2268 .map(|checks| {
2269 checks
2270 .iter()
2271 .filter(|(key, _)| key.starts_with(&prefix))
2272 .map(|(key, value)| (key[prefix.len()..].to_string(), value.clone()))
2273 .collect()
2274 })
2275 .unwrap_or_default();
2276 let status_of = |value: &serde_json::Value| {
2277 value
2278 .get("status")
2279 .and_then(serde_json::Value::as_str)
2280 .map(str::to_lowercase)
2281 };
2282 let (verdict, code) = if rows.is_empty() {
2288 ("unreported", 1)
2289 } else if rows
2290 .values()
2291 .any(|v| status_of(v).as_deref() == Some("failed"))
2292 {
2293 ("failed", 1)
2294 } else if rows
2295 .values()
2296 .any(|v| status_of(v).as_deref() == Some("errored"))
2297 {
2298 ("errored", 4)
2299 } else if rows
2300 .values()
2301 .any(|v| status_of(v).as_deref() == Some("running"))
2302 {
2303 ("running", 3)
2304 } else {
2305 ("passed", 0)
2306 };
2307 println!(
2308 "{}",
2309 serde_json::json!({
2310 "subject": subject.to_hex(),
2311 "verdict": verdict,
2312 "checks": rows,
2313 })
2314 );
2315 std::process::exit(code);
2316}
2317
2318fn finish(status: u16, body: &str) -> ! {
2320 println!("{body}");
2321 if !(200..300).contains(&status) {
2326 if let Some(next) = refusal_next(body) {
2327 eprintln!("\nnext: {next}");
2328 }
2329 }
2330 std::process::exit(if (200..300).contains(&status) { 0 } else { 1 });
2331}
2332
2333fn current_binding(api: &str, auth: AuthOptions<'_>, actor_id: &str) -> Option<serde_json::Value> {
2341 let (status, body) = http(api, auth, "choir_view", serde_json::json!({}));
2342 if !(200..300).contains(&status) {
2343 return None;
2344 }
2345 let view: serde_json::Value = serde_json::from_str(&body).ok()?;
2346 let binding = view.get("bindings")?.get(actor_id)?;
2347 (!binding.is_null()).then(|| binding.clone())
2348}
2349
2350fn latest_snapshot(api: &str, auth: AuthOptions<'_>) -> ContentHash {
2369 let (status, body) = http(api, auth, "choir_view", serde_json::json!({}));
2370 let fail = |why: &str| -> ! {
2371 eprintln!("choir: cannot read the ref-state attestation from {api}: {why}");
2372 eprintln!("choir: not signing a witness statement about a snapshot nobody read.");
2373 std::process::exit(1);
2374 };
2375 if !(200..300).contains(&status) {
2376 fail(&format!("GET /api/view returned {status}"));
2377 }
2378 let view: serde_json::Value = match serde_json::from_str(&body) {
2379 Ok(view) => view,
2380 Err(error) => fail(&format!("response is not JSON: {error}")),
2381 };
2382 match view["snapshot"]["id"].as_str().and_then(hash_from_hex) {
2383 Some(id) => id,
2384 None => fail(
2390 "no `snapshot.id` in the view: either the node has attested no ref-state yet, \
2391 or this credential may not read node-wide sections (`@node auditor`)",
2392 ),
2393 }
2394}
2395
2396fn log_scope(api: &str, auth: AuthOptions<'_>) -> (ContentHash, Option<ContentHash>) {
2397 let (status, body) = http(api, auth, "choir_view", serde_json::json!({}));
2398 let fail = |why: &str| -> ! {
2399 eprintln!("choir: cannot read the log scope from {api}: {why}");
2400 eprintln!("choir: not signing an op that names no log. Retry when the node answers.");
2401 std::process::exit(1);
2402 };
2403 if !(200..300).contains(&status) {
2404 fail(&format!("GET /api/view returned {status}"));
2405 }
2406 let view: serde_json::Value = match serde_json::from_str(&body) {
2407 Ok(view) => view,
2408 Err(error) => fail(&format!("response is not JSON: {error}")),
2409 };
2410 let Some(node) = view["log"]["node"].as_str().and_then(hash_from_hex) else {
2411 fail("response has no `log.node`; this node predates op scopes");
2412 };
2413 let head = match view["log"]["head"].as_str() {
2414 Some(hex) => match hash_from_hex(hex) {
2415 Some(head) => Some(head),
2416 None => fail("`log.head` is not a content hash"),
2417 },
2418 None => None,
2421 };
2422 (node, head)
2423}
2424
2425fn hash_from_hex(hex: &str) -> Option<ContentHash> {
2427 let (codec, digest) = hex.split_once('-')?;
2428 let codec = u8::from_str_radix(codec, 16).ok()?;
2429 let digest: Option<Vec<u8>> = (0..digest.len())
2430 .step_by(2)
2431 .map(|i| u8::from_str_radix(digest.get(i..i + 2)?, 16).ok())
2432 .collect();
2433 Some(ContentHash {
2434 codec,
2435 digest: digest?,
2436 })
2437}
2438
2439fn signed_body(
2444 api: &str,
2445 key_file: &str,
2446 channel: &str,
2447 op: &ViewOp,
2448 auth: AuthOptions<'_>,
2449) -> serde_json::Value {
2450 let (node, head) = log_scope(api, auth);
2451 let op = op.clone().in_scope(node, head);
2452 signed_payload_body(key_file, channel, &op.to_payload())
2453}
2454
2455fn signed_payload_body(key_file: &str, channel: &str, payload: &[u8]) -> serde_json::Value {
2459 let key = load_key(key_file);
2460 let sig = key.sign_submission(channel, payload);
2461 serde_json::json!({
2462 "channel": channel,
2463 "payload_hex": hex_encode(payload),
2464 "key_id": sig.key_id,
2465 "signature_hex": hex_encode(&sig.signature),
2466 })
2467}
2468
2469fn submit(api: &str, key_file: &str, channel: &str, op: &ViewOp, auth: AuthOptions<'_>) -> ! {
2470 let body = signed_body(api, key_file, channel, op, auth);
2471 let (status, resp) = http(api, auth, "choir_submit", body);
2472 finish(status, &resp);
2473}
2474
2475fn load_registry(path: &str) -> Registry {
2483 let text = match std::fs::read_to_string(path) {
2484 Ok(text) => text,
2485 Err(error) => {
2486 eprintln!("choir log: {path}: {error}");
2487 std::process::exit(2);
2488 }
2489 };
2490 let mut registry = Registry::new();
2491 for (number, line) in text.lines().enumerate() {
2492 let line = line.split('#').next().unwrap_or("").trim();
2493 if line.is_empty() {
2494 continue;
2495 }
2496 let hex = line.split_whitespace().last().unwrap_or_default();
2499 let Some(bytes) = hex_decode(hex).and_then(|b| <[u8; 32]>::try_from(b).ok()) else {
2500 eprintln!("choir log: {path}:{}: not 64 hex characters", number + 1);
2501 std::process::exit(2);
2502 };
2503 if registry.register(&bytes).is_err() {
2504 eprintln!("choir log: {path}:{}: not a valid ed25519 key", number + 1);
2505 std::process::exit(2);
2506 }
2507 }
2508 registry
2509}
2510
2511fn revocations(api: &str, auth: AuthOptions<'_>) -> choir_cli::verify::Revocations {
2550 let (status, body) = http(api, auth, "choir_view", serde_json::json!({}));
2551 if !(200..300).contains(&status) {
2552 eprintln!("choir log: cannot read bindings ({status}); revocations not checked");
2553 return choir_cli::verify::Revocations::new();
2554 }
2555 let view: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();
2556 view["bindings"]
2557 .as_object()
2558 .map(|bindings| {
2559 bindings
2560 .iter()
2561 .filter_map(|(key_id, binding)| {
2562 Some((key_id.clone(), binding["revoked"]["at"].as_u64()?))
2563 })
2564 .collect()
2565 })
2566 .unwrap_or_default()
2567}
2568
2569fn log(api: &str, from: u64, verify: bool, keys: Option<&str>, auth: AuthOptions<'_>) -> ! {
2570 let (status, body) = http(api, auth, "choir_log", serde_json::json!({ "from": from }));
2571 if !(200..300).contains(&status) {
2572 println!("{body}");
2576 std::process::exit(1);
2577 }
2578 let page: serde_json::Value = match serde_json::from_str(&body) {
2579 Ok(page) => page,
2580 Err(error) => {
2581 eprintln!("choir log: response is not JSON: {error}");
2582 std::process::exit(1);
2583 }
2584 };
2585 let entries = page["entries"].as_array().cloned().unwrap_or_default();
2586 for entry in &entries {
2587 println!("{entry}");
2588 }
2589 if !verify {
2590 eprintln!("choir log: {} entries, not verified", entries.len());
2591 std::process::exit(0);
2592 }
2593
2594 let registry = keys.map(load_registry).unwrap_or_default();
2595 let revoked = revocations(api, auth);
2603 let report = choir_cli::verify::page(&entries, ®istry, &revoked);
2604 for note in &report.notes {
2605 eprintln!("choir log: {note}");
2606 }
2607 for failure in &report.failures {
2608 eprintln!("choir log: {failure}");
2609 }
2610 let passkeys = if report.integrity_only == 0 {
2617 String::new()
2618 } else {
2619 format!(
2620 ", {} passkey signatures intact but unanchored",
2621 report.integrity_only
2622 )
2623 };
2624 eprintln!(
2625 "choir log: {} entries, chain {}, {} signatures verified, {} unverified{passkeys}",
2626 entries.len(),
2627 if report.failures.is_empty() {
2628 "holds"
2629 } else {
2630 "BROKEN"
2631 },
2632 report.checked,
2633 report.unverified
2634 );
2635 std::process::exit(i32::from(!report.failures.is_empty()));
2636}
2637
2638fn batch(api: &str, key_file: &str, channel: &str, source: &str, auth: AuthOptions<'_>) -> ! {
2660 let text = if source == "-" {
2661 let mut buffer = String::new();
2662 if let Err(error) = std::io::Read::read_to_string(&mut std::io::stdin(), &mut buffer) {
2663 eprintln!("choir batch: cannot read stdin: {error}");
2664 std::process::exit(2);
2665 }
2666 buffer
2667 } else {
2668 match std::fs::read_to_string(source) {
2669 Ok(text) => text,
2670 Err(error) => {
2671 eprintln!("choir batch: {source}: {error}");
2672 std::process::exit(2);
2673 }
2674 }
2675 };
2676
2677 let mut ops: Vec<ViewOp> = Vec::new();
2681 for (number, line) in text.lines().enumerate() {
2682 if line.trim().is_empty() {
2683 continue;
2684 }
2685 match serde_json::from_str::<ViewOp>(line) {
2686 Ok(op) => ops.push(op),
2687 Err(error) => {
2688 eprintln!("choir batch: {source}:{}: {error}", number + 1);
2692 std::process::exit(2);
2693 }
2694 }
2695 }
2696 if ops.is_empty() {
2697 eprintln!("choir batch: {source} contains no operations");
2698 std::process::exit(2);
2699 }
2700
2701 let (node, head) = log_scope(api, auth);
2702 let signed: Vec<serde_json::Value> = ops
2703 .into_iter()
2704 .map(|op| {
2705 let op = op.in_scope(node.clone(), head.clone());
2706 signed_payload_body(key_file, channel, &op.to_payload())
2707 })
2708 .collect();
2709 let count = signed.len();
2710 let (status, resp) = http(
2711 api,
2712 auth,
2713 "choir_submit_batch",
2714 serde_json::json!({ "ops": signed }),
2715 );
2716
2717 let parsed: serde_json::Value = match serde_json::from_str(&resp) {
2718 Ok(value) => value,
2719 Err(_) => {
2720 println!("{resp}");
2725 std::process::exit(if (200..300).contains(&status) { 0 } else { 1 });
2726 }
2727 };
2728 let Some(results) = parsed["results"].as_array() else {
2729 println!("{resp}");
2730 std::process::exit(if (200..300).contains(&status) { 0 } else { 1 });
2731 };
2732 for result in results {
2733 println!("{result}");
2734 }
2735 let accepted = parsed["accepted"].as_u64().unwrap_or(0);
2736 let rejected = parsed["rejected"].as_u64().unwrap_or(0);
2737 eprintln!("choir batch: {accepted} accepted, {rejected} rejected, {count} submitted");
2738 std::process::exit(
2741 if (200..300).contains(&status) && rejected == 0 && results.len() == count {
2742 0
2743 } else {
2744 1
2745 },
2746 );
2747}
2748
2749fn runner_finish(result: &Result<serde_json::Value, choir_cli::runner::Failure>) -> ! {
2756 match result {
2757 Ok(value) => {
2758 println!("{value}");
2759 std::process::exit(0);
2760 }
2761 Err(failure) => {
2762 println!("{}", failure.to_json());
2763 eprintln!("choir runner: {}: {}", failure.code, failure.message);
2764 std::process::exit(1);
2765 }
2766 }
2767}
2768
2769fn runner_json(
2771 source: &str,
2772 code: &str,
2773 what: &str,
2774) -> Result<serde_json::Value, choir_cli::runner::Failure> {
2775 serde_json::from_str(source)
2776 .map_err(|error| choir_cli::runner::Failure::terminal(code, format!("{what}: {error}")))
2777}
2778
2779fn runner(config_file: &str, auth: AuthOptions<'_>) -> ! {
2786 use choir_cli::runner::{Config, Failure, Operation, Request};
2787
2788 let outcome = (|| -> Result<serde_json::Value, Failure> {
2789 let raw = std::fs::read_to_string(config_file).map_err(|error| {
2790 Failure::terminal("invalid_config", format!("cannot read config: {error}"))
2791 })?;
2792 let config = Config::parse(&runner_json(&raw, "invalid_config", "config is not JSON")?)?;
2793
2794 let mut stdin = String::new();
2795 std::io::Read::read_to_string(&mut std::io::stdin(), &mut stdin).map_err(|error| {
2796 Failure::terminal("invalid_request", format!("cannot read stdin: {error}"))
2797 })?;
2798 let request = Request::parse(
2799 &runner_json(&stdin, "invalid_request", "request is not JSON")?,
2800 &config,
2801 )?;
2802
2803 let auth = AuthOptions {
2807 file: config.auth_file.as_deref().or(auth.file_for(&config.api)),
2808 user: config.auth_user.as_deref().or(auth.user),
2809 explicit: true,
2810 };
2811 let id = &request.identity;
2812
2813 match request.operation {
2814 Operation::Ensure => {
2815 let base = match &request.base {
2816 Some(base) => base.clone(),
2817 None => {
2818 let base_ref = config.base_ref.as_ref().ok_or_else(|| {
2819 Failure::terminal(
2820 "invalid_config",
2821 "ensure needs either a request base or a config base_ref",
2822 )
2823 })?;
2824 let (status, body) =
2825 http(&config.api, auth, "choir_view", serde_json::json!({}));
2826 if !(200..300).contains(&status) {
2827 return Err(choir_cli::runner::failure_from_response(
2828 &body,
2829 "base revision lookup",
2830 ));
2831 }
2832 choir_cli::runner::base_from_view(
2833 &runner_json(&body, "invalid_response", "view is not JSON")?,
2834 base_ref,
2835 )?
2836 }
2837 };
2838 let flags: Vec<&str> = vec![
2839 "--base",
2840 &base,
2841 "--owner",
2842 &config.owner,
2843 "--key-file",
2844 &config.key_file,
2845 "--change",
2846 &id.change_id,
2847 "--idempotency-key",
2848 &id.idempotency_key,
2849 ];
2850 let body = workspace_body(&config.repo, &id.workspace_name, &flags);
2851 let (status, resp) = http(&config.api, auth, "choir_workspace", body);
2852 if !(200..300).contains(&status) {
2853 return Err(choir_cli::runner::failure_from_response(
2854 &resp,
2855 "workspace creation",
2856 ));
2857 }
2858 let response = runner_json(&resp, "invalid_response", "creation is not JSON")?;
2859 choir_cli::runner::verify_binding(&response, id)?;
2860 Ok(serde_json::json!({
2861 "protocol_version": choir_cli::runner::PROTOCOL_VERSION,
2862 "operation": "ensure",
2863 "workspace": {
2864 "path": response.get("path").cloned().unwrap_or(serde_json::Value::Null),
2865 "created_now": response.get("created") == Some(&serde_json::json!(true)),
2866 },
2867 "binding": binding_json(
2868 id,
2869 &config,
2870 Some(&choir_cli::runner::bound_base(&response, &base)),
2871 ),
2872 "receipt": response.get("operation").cloned()
2873 .unwrap_or_else(|| serde_json::json!({})),
2874 }))
2875 }
2876 Operation::Checkpoint => {
2877 let revision = request.base.clone().ok_or_else(|| {
2878 Failure::terminal(
2879 "invalid_request",
2880 "checkpoint needs the exact committed and pushed Git object id as base",
2881 )
2882 })?;
2883 let Some(revision_hash) = choir_hash::ContentHash::from_git_oid(&revision) else {
2884 return Err(Failure::terminal(
2885 "invalid_request",
2886 "checkpoint base must be a 40- or 64-char hex Git object id",
2887 ));
2888 };
2889 let prev_revision = current_change_revision(&config.api, auth, &id.change_id);
2890 let op = ViewOp::new(OpKind::CheckpointChange {
2891 id: id.change_id.clone(),
2892 workspace: id.workspace_id.clone(),
2893 revision: revision_hash,
2894 prev_revision,
2895 });
2896 let body = signed_body(&config.api, &config.key_file, &config.owner, &op, auth);
2897 let (status, resp) = http(&config.api, auth, "choir_submit", body);
2898 if !(200..300).contains(&status) {
2899 return Err(choir_cli::runner::failure_from_response(
2900 &resp,
2901 "revision checkpoint",
2902 ));
2903 }
2904 Ok(serde_json::json!({
2905 "protocol_version": choir_cli::runner::PROTOCOL_VERSION,
2906 "operation": "checkpoint",
2907 "checkpoint": {
2908 "change_id": id.change_id,
2909 "workspace_id": id.workspace_id,
2910 "revision_id": revision,
2911 },
2912 "receipt": runner_json(&resp, "invalid_response", "checkpoint is not JSON")
2913 .unwrap_or_else(|_| serde_json::json!({})),
2914 }))
2915 }
2916 Operation::Archive => {
2917 let prev_revision = current_change_revision(&config.api, auth, &id.change_id);
2918 let authorization = ArchiveAuthorization::new(
2919 id.change_id.clone(),
2920 id.workspace_id.clone(),
2921 prev_revision,
2922 );
2923 let mut body = signed_payload_body(
2924 &config.key_file,
2925 &config.owner,
2926 &authorization.to_payload(),
2927 );
2928 body["repo"] = serde_json::json!(config.repo);
2929 body["name"] = serde_json::json!(id.workspace_name);
2930 body["change"] = serde_json::json!(id.change_id);
2931 body["idempotency_key"] = serde_json::json!(id.idempotency_key);
2932 let (status, resp) = http(&config.api, auth, "choir_workspace_archive", body);
2933 if !(200..300).contains(&status) {
2934 return Err(choir_cli::runner::failure_from_response(
2935 &resp,
2936 "workspace archive",
2937 ));
2938 }
2939 let response = runner_json(&resp, "invalid_response", "archive is not JSON")?;
2940 choir_cli::runner::verify_binding(&response, id)?;
2941 Ok(serde_json::json!({
2942 "protocol_version": choir_cli::runner::PROTOCOL_VERSION,
2943 "operation": "archive",
2944 "archive": {
2945 "workspace_id": id.workspace_id,
2946 "change_id": id.change_id,
2947 "archived_path": response.get("archived_path").cloned()
2948 .unwrap_or(serde_json::Value::Null),
2949 "already_archived":
2950 response.get("already_archived") == Some(&serde_json::json!(true)),
2951 },
2952 "receipt": response.get("operation").cloned()
2953 .unwrap_or_else(|| serde_json::json!({})),
2954 }))
2955 }
2956 }
2957 })();
2958 runner_finish(&outcome);
2959}
2960
2961fn binding_json(
2963 id: &choir_cli::runner::Identity,
2964 config: &choir_cli::runner::Config,
2965 base: Option<&str>,
2966) -> serde_json::Value {
2967 serde_json::json!({
2968 "repo": config.repo,
2969 "owner": config.owner,
2970 "scheme": id.scheme.as_str(),
2971 "workspace_id": id.workspace_id,
2972 "workspace_name": id.workspace_name,
2973 "change_id": id.change_id,
2974 "idempotency_key": id.idempotency_key,
2975 "base": base,
2976 })
2977}
2978
2979fn parse_auth(args: &[String]) -> (AuthOptions<'_>, &[String]) {
2980 let mut file = None;
2981 let mut user = None;
2982 let mut index = 0;
2983 while let Some(flag) = args.get(index) {
2984 let slot = match flag.as_str() {
2985 "--auth-file" if file.is_none() => &mut file,
2986 "--auth-user" if user.is_none() => &mut user,
2987 "--auth-file" | "--auth-user" => usage(),
2988 _ => break,
2989 };
2990 let Some(value) = args.get(index + 1) else {
2991 usage();
2992 };
2993 *slot = Some(value.as_str());
2994 index += 2;
2995 }
2996 if user.is_some() && file.is_none() {
2997 eprintln!("choir: --auth-user needs --auth-file");
2998 std::process::exit(2);
2999 }
3000 (
3001 AuthOptions {
3002 file,
3003 user,
3004 explicit: index > 0,
3005 },
3006 &args[index..],
3007 )
3008}
3009
3010fn workspace_body(repo: &str, name: &str, rest: &[&str]) -> serde_json::Value {
3011 if rest.is_empty() {
3012 return serde_json::json!({ "repo": repo, "name": name });
3013 }
3014 let (mut base, mut owner, mut key_file, mut change, mut idempotency_key) =
3015 (None, None, None, None, None);
3016 let mut cone: Vec<String> = Vec::new();
3020 let mut index = 0;
3021 while index < rest.len() {
3022 let Some(value) = rest.get(index + 1).copied() else {
3023 usage();
3024 };
3025 if rest[index] == "--path" {
3026 cone.push(value.to_string());
3027 index += 2;
3028 continue;
3029 }
3030 let slot = match rest[index] {
3031 "--base" if base.is_none() => &mut base,
3032 "--owner" if owner.is_none() => &mut owner,
3033 "--key-file" if key_file.is_none() => &mut key_file,
3034 "--change" if change.is_none() => &mut change,
3035 "--idempotency-key" if idempotency_key.is_none() => &mut idempotency_key,
3036 _ => usage(),
3037 };
3038 *slot = Some(value);
3039 index += 2;
3040 }
3041 cone.sort();
3046 cone.dedup();
3047 let (Some(base), Some(owner), Some(key_file), Some(change), Some(idempotency_key)) =
3048 (base, owner, key_file, change, idempotency_key)
3049 else {
3050 usage();
3051 };
3052 let Some(base_revision) = choir_hash::ContentHash::from_git_oid(base) else {
3053 eprintln!("<git-oid> must be a 40- or 64-char hex object id");
3054 std::process::exit(2);
3055 };
3056 let authorization = CreateAuthorization::new(
3057 change.into(),
3058 owner.into(),
3059 format!("{repo}/{name}"),
3060 base_revision,
3061 idempotency_key.into(),
3062 )
3063 .with_cone(cone);
3064 let mut body = signed_payload_body(key_file, owner, &authorization.to_payload());
3065 body["repo"] = serde_json::json!(repo);
3066 body["name"] = serde_json::json!(name);
3067 body["base"] = serde_json::json!(base);
3068 body["owner"] = serde_json::json!(owner);
3069 body["change"] = serde_json::json!(change);
3070 body["idempotency_key"] = serde_json::json!(idempotency_key);
3071 body
3072}
3073
3074fn parse_content_hash_hex(value: &str) -> Option<choir_hash::ContentHash> {
3075 let (codec, digest) = value.split_once('-')?;
3076 let codec = u8::from_str_radix(codec, 16).ok()?;
3077 if digest.is_empty() || digest.len() % 2 != 0 {
3078 return None;
3079 }
3080 let digest = (0..digest.len())
3081 .step_by(2)
3082 .map(|index| u8::from_str_radix(digest.get(index..index + 2)?, 16).ok())
3083 .collect::<Option<Vec<_>>>()?;
3084 Some(choir_hash::ContentHash { codec, digest })
3085}
3086
3087fn current_change_revision(
3088 api: &str,
3089 auth: AuthOptions<'_>,
3090 change_id: &str,
3091) -> choir_hash::ContentHash {
3092 let (status, body) = http(api, auth, "choir_view", serde_json::json!({}));
3093 if !(200..300).contains(&status) {
3094 finish(status, &body);
3095 }
3096 let view: serde_json::Value = match serde_json::from_str(&body) {
3097 Ok(view) => view,
3098 Err(error) => {
3099 eprintln!("choir: view returned invalid JSON: {error}");
3100 std::process::exit(1);
3101 }
3102 };
3103 let Some(revision) = view["changes"][change_id]["revision_id"].as_str() else {
3104 eprintln!("choir: no such change or revision in GET /api/view");
3105 std::process::exit(1);
3106 };
3107 match parse_content_hash_hex(revision) {
3108 Some(revision) => revision,
3109 None => {
3110 eprintln!("choir: change revision has an invalid content-hash envelope");
3111 std::process::exit(1);
3112 }
3113 }
3114}
3115
3116struct NodeOptions {
3134 state: std::path::PathBuf,
3135 port: u16,
3136 create: Vec<String>,
3137 extra: Vec<String>,
3138}
3139
3140fn node_options(command: &str, rest: &[&str]) -> NodeOptions {
3147 let (mine, extra) = match rest.iter().position(|a| *a == "--") {
3148 Some(at) => (&rest[..at], &rest[at + 1..]),
3149 None => (rest, &rest[rest.len()..]),
3150 };
3151 let mut state: Option<String> = None;
3152 let mut port: Option<u16> = None;
3153 let mut create: Vec<String> = Vec::new();
3154 let mut i = 0;
3155 while i < mine.len() {
3156 let name = mine[i];
3157 let Some(value) = mine.get(i + 1) else {
3158 eprintln!("{command}: {name} needs a value");
3159 std::process::exit(2);
3160 };
3161 match name {
3162 "--state" => state = Some((*value).to_string()),
3163 "--create" => create.push((*value).to_string()),
3164 "--port" => match value.parse::<u16>() {
3165 Ok(n) => port = Some(n),
3166 Err(_) => {
3167 eprintln!("{command}: --port needs a port number, not {value:?}");
3168 std::process::exit(2);
3169 }
3170 },
3171 other => {
3172 eprintln!(
3173 "{command}: unknown option {other:?}\n\n \
3174 daemon flags go after `--`: {command} -- {other} ..."
3175 );
3176 std::process::exit(2);
3177 }
3178 }
3179 i += 2;
3180 }
3181 NodeOptions {
3182 state: state
3183 .map(std::path::PathBuf::from)
3184 .unwrap_or_else(state_dir),
3185 port: port
3189 .or_else(|| {
3190 configured_node()
3191 .as_deref()
3192 .and_then(choir_cli::serve::port_of)
3193 })
3194 .unwrap_or(8417),
3195 create,
3196 extra: extra.iter().map(|a| (*a).to_string()).collect(),
3197 }
3198}
3199
3200fn state_dir() -> std::path::PathBuf {
3205 std::env::var_os("HOME")
3206 .map(std::path::PathBuf::from)
3207 .unwrap_or_default()
3208 .join(".choir")
3209}
3210
3211fn home_dir() -> std::path::PathBuf {
3213 std::env::var_os("HOME")
3214 .map(std::path::PathBuf::from)
3215 .unwrap_or_default()
3216}
3217
3218fn supervisor(command: &str) -> choir_cli::supervise::Supervisor {
3220 match choir_cli::supervise::Supervisor::detect() {
3221 Some(supervisor) => supervisor,
3222 None => {
3223 eprintln!(
3224 "{command}: no service manager known for {}\n\n \
3225 run it in the foreground instead: choir node serve",
3226 std::env::consts::OS
3227 );
3228 std::process::exit(1);
3229 }
3230 }
3231}
3232
3233fn step(what: &str, detail: &str) {
3241 let style = choir_cli::style::Style::for_stderr();
3242 eprintln!(" {} {:14} {detail}", style.green("ok"), style.dim(what));
3243}
3244
3245fn handover(done: &[String], why: &str, paste: &[String], retry: &str) -> ! {
3252 let style = choir_cli::style::Style::for_stderr();
3253 eprintln!("\n {} {why}\n", style.cyan("next:"));
3254 for line in paste {
3255 eprintln!(" {line}");
3256 }
3257 eprintln!("\n {} {retry}", style.dim("then:"));
3258 if !done.is_empty() {
3259 eprintln!("\n {} {}", style.dim("already done:"), done.join(", "));
3260 }
3261 eprintln!();
3262 std::process::exit(3)
3263}
3264
3265fn host_failed(done: &[String], what: &str, why: &str, retry: &str) -> ! {
3267 let style = choir_cli::style::Style::for_stderr();
3268 eprintln!("\n {} {what}: {why}\n", style.red("failed"));
3269 if !done.is_empty() {
3270 eprintln!(" {} {}\n", style.dim("already done:"), done.join(", "));
3271 }
3272 eprintln!(" {} {retry}\n", style.dim("retry:"));
3273 std::process::exit(1)
3274}
3275
3276fn install_unit(
3291 state: &std::path::Path,
3292 port: u16,
3293 extra: &[String],
3294) -> Result<(std::path::PathBuf, std::path::PathBuf), String> {
3295 let Some(supervisor) = choir_cli::supervise::Supervisor::detect() else {
3296 return Err(format!(
3297 "no service manager known for {}\n\n \
3298 run it in the foreground instead: choir node serve",
3299 std::env::consts::OS
3300 ));
3301 };
3302 let exe = std::env::current_exe().map_err(|_| "cannot find my own path".to_string())?;
3306 if choir_cli::supervise::in_build_directory(&exe) {
3307 return Err(format!(
3308 "this `choir` lives in a build directory:\n {}\n\n \
3309 a unit pointing there stops working at the next `cargo clean`,\n \
3310 and it stops working at reboot. install it first:\n\n \
3311 cargo build --release -p choir-cli -p choir-node\n \
3312 cp target/release/choir target/release/choir-node ~/.local/bin/",
3313 exe.display()
3314 ));
3315 }
3316 let layout = choir_cli::serve::Layout::new(state, port);
3320 if !layout.missing().is_empty() {
3321 return Err(format!(
3322 "no node in {} yet\n\n create one: choir init",
3323 state.display()
3324 ));
3325 }
3326 let home = home_dir();
3327 let unit = supervisor.unit_path(&home);
3328 if let Some(parent) = unit.parent() {
3329 std::fs::create_dir_all(parent)
3330 .map_err(|error| format!("create {}: {error}", parent.display()))?;
3331 }
3332 let body = supervisor.render(&exe, state, port, extra);
3333 choir_fs::write_atomic(&unit, body)
3334 .map_err(|error| format!("write {}: {error}", unit.display()))?;
3335 let steps = supervisor.commands(choir_cli::supervise::Action::Install, &home);
3336 let last = steps.len().saturating_sub(1);
3339 for (at, step) in steps.iter().enumerate() {
3340 if !run_step(step, at != last) {
3341 return Err("the service manager refused".to_string());
3342 }
3343 }
3344 Ok((unit, exe))
3345}
3346
3347fn run_step(argv: &[String], allow_failure: bool) -> bool {
3353 let Some((program, args)) = argv.split_first() else {
3354 return true;
3355 };
3356 match std::process::Command::new(program).args(args).output() {
3357 Ok(out) if out.status.success() => true,
3358 Ok(out) => {
3359 if !allow_failure {
3360 let text = String::from_utf8_lossy(&out.stderr);
3361 eprintln!(" {} {}", argv.join(" "), text.trim());
3362 }
3363 allow_failure
3364 }
3365 Err(error) => {
3366 if !allow_failure {
3367 eprintln!(" {}: {error}", argv.join(" "));
3368 }
3369 allow_failure
3370 }
3371 }
3372}
3373
3374fn configured_node() -> Option<String> {
3375 configured("node")
3376}
3377
3378fn configured(want: &str) -> Option<String> {
3404 let mut dir = std::env::current_dir().ok();
3405 while let Some(here) = dir {
3406 if let Some(found) = configured_in(&here.join(".choir/config"), want) {
3407 return Some(found);
3408 }
3409 let mut up = here;
3410 if !up.pop() {
3411 break;
3412 }
3413 dir = Some(up);
3414 }
3415 configured_in(&state_dir().join("config"), want)
3416}
3417
3418fn configured_in(path: &std::path::Path, want: &str) -> Option<String> {
3420 let text = std::fs::read_to_string(path).ok()?;
3421 for line in text.lines() {
3422 let line = line.trim();
3423 if line.starts_with('#') {
3424 continue;
3425 }
3426 if let Some((key, value)) = line.split_once('=') {
3427 if key.trim() == want {
3428 let value = value.trim();
3429 if !value.is_empty() {
3430 return Some(value.to_string());
3431 }
3432 }
3433 }
3434 }
3435 None
3436}
3437
3438fn with_configured_node(args: &[String]) -> Vec<String> {
3449 if args.is_empty() {
3450 return args.to_vec();
3451 }
3452 let two = (args.len() >= 2).then(|| format!("{} {}", args[0], args[1]));
3459 let (name, words) = match two {
3460 Some(two) if choir_cli::surface::COMMANDS.iter().any(|c| c.name == two) => (two, 2),
3461 _ => (args[0].clone(), 1),
3462 };
3463 let takes_api = choir_cli::surface::COMMANDS
3464 .iter()
3465 .any(|c| c.name == name && c.args.starts_with("<api>"));
3466 if !takes_api {
3467 return args.to_vec();
3468 }
3469 let given = args.get(words).map(String::as_str).unwrap_or("");
3470 if given.starts_with("http://") || given.starts_with("https://") {
3471 return args.to_vec();
3472 }
3473 let Some(node) = configured_node() else {
3474 return args.to_vec();
3475 };
3476 let mut filled = Vec::with_capacity(args.len() + 1);
3477 filled.extend(args[..words].iter().cloned());
3478 filled.push(node);
3479 filled.extend(args[words..].iter().cloned());
3480 filled
3481}
3482
3483fn main() {
3484 let args: Vec<String> = std::env::args().skip(1).collect();
3485 let style = choir_cli::style::Style::for_stdout();
3489 if args.first().is_some_and(|a| a == "--version" || a == "-V") {
3498 println!("choir {}", choir_node::build_line());
3499 std::process::exit(0);
3500 }
3501 let asked = match args.iter().position(|a| is_help(a)) {
3510 Some(1) => Some(args[0].clone()),
3511 Some(2)
3515 if choir_cli::surface::COMMANDS
3516 .iter()
3517 .any(|c| c.name == format!("{} {}", args[0], args[1])) =>
3518 {
3519 Some(format!("{} {}", args[0], args[1]))
3520 }
3521 _ => None,
3522 };
3523 if let Some(name) = asked {
3524 if let Some(help) = choir_cli::surface::command_help_in(&name, style) {
3525 print!("{help}");
3526 std::process::exit(0);
3527 }
3528 }
3529 if args.first().is_some_and(|a| is_help(a)) {
3530 print!("{}", choir_cli::surface::usage_in(style));
3531 std::process::exit(0);
3532 }
3533 let (auth, args) = parse_auth(&args);
3534 let args = with_configured_node(args);
3535 let args: Vec<&str> = args.iter().map(String::as_str).collect();
3536 match args.as_slice() {
3537 ["key", key_file, rest @ ..] if rest.len() <= 1 && auth.is_empty() => {
3541 let key = load_key(key_file);
3542 let hex = hex_encode(&key.public_key_bytes());
3543 match rest.first() {
3544 Some(name) => println!("{name} {hex}"),
3545 None => println!("{hex}"),
3546 }
3547 }
3548 ["init", rest @ ..] if auth.is_empty() => {
3551 let (mut dir, mut port, mut force) = (None, 8417u16, false);
3552 let mut it = rest.iter();
3553 while let Some(arg) = it.next() {
3554 match *arg {
3555 "--force" => force = true,
3556 "--port" => {
3557 let Some(value) = it.next().and_then(|v| v.parse().ok()) else {
3558 usage()
3559 };
3560 port = value;
3561 }
3562 other if !other.starts_with('-') && dir.is_none() => dir = Some(other),
3563 _ => usage(),
3564 }
3565 }
3566 let style = choir_cli::style::Style::for_stdout();
3567 let state = match dir {
3572 Some(dir) => std::path::PathBuf::from(dir),
3573 None => match std::env::var_os("HOME") {
3574 Some(home) => std::path::PathBuf::from(home).join(".choir"),
3575 None => {
3576 eprintln!(
3577 "{} no HOME, so there is no default state directory\n\
3578 \n choir init <state-dir>",
3579 style.red("choir init:")
3580 );
3581 std::process::exit(2);
3582 }
3583 },
3584 };
3585 let plan = choir_cli::init::Plan::new(&state, port);
3586 match choir_cli::init::run(&plan, force) {
3587 Ok(made) => {
3588 let rows: Vec<(&str, String)> = vec![
3589 ("repos", plan.repos.display().to_string()),
3590 ("auth", format!("{} (0600)", plan.auth.display())),
3591 ("key", format!("{} (0600)", plan.key.display())),
3592 ("trusted", plan.trusted.display().to_string()),
3593 (
3594 "config",
3595 format!("{} -> {}", plan.config.display(), plan.node_url()),
3596 ),
3597 ];
3598 note("ready", &rows);
3599 if !made.replaced.is_empty() {
3600 eprintln!(
3601 " {} replaced {} existing file(s); the previous credential is gone\n",
3602 style.red("--force:"),
3603 made.replaced.len()
3604 );
3605 }
3606 let serve = match dir {
3617 Some(_) => format!("choir node serve --state {}", state.display()),
3618 None => "choir node serve".to_string(),
3619 };
3620 println!("{serve}");
3621 eprintln!(
3622 " {} run the line above, then:\n choir repo create {} me/thing.git\n",
3623 style.dim("next"),
3624 plan.node_url()
3625 );
3626 let _ = made.user;
3627 }
3628 Err(error) => {
3629 eprintln!("{} {error}", style.red("choir init:"));
3630 std::process::exit(1);
3631 }
3632 }
3633 }
3634 ["host", rest @ ..] if auth.is_empty() => host(rest),
3638 ["node", "tls", rest @ ..] => node_tls(rest),
3642 ["repo", "create", api, name] => {
3647 let client = match choir_cli::mcp::HttpClient::new(
3648 api,
3649 auth.file_for(api).map(std::path::Path::new),
3650 auth.user,
3651 ) {
3652 Ok(client) => client,
3653 Err(error) => {
3654 eprintln!("choir: {error}");
3655 std::process::exit(2);
3656 }
3657 };
3658 let endpoint = choir_cli::surface::endpoint("POST", "/api/repo")
3659 .expect("the repo endpoint is in the table");
3660 let body = serde_json::json!({ "name": name });
3661 match client.request(endpoint, &body) {
3662 Ok((status, response)) => {
3663 if (200..300).contains(&status) {
3673 note(
3674 "repository created",
3675 &[("clone", format!("{}/{name}", api.trim_end_matches('/')))],
3676 );
3677 }
3678 finish(status, &response);
3679 }
3680 Err(error) => {
3681 eprintln!("choir: {error}");
3682 std::process::exit(1);
3683 }
3684 }
3685 }
3686 ["node", "serve", rest @ ..] => {
3694 let style = choir_cli::style::Style::for_stdout();
3695 let options = node_options("choir node serve", rest);
3696 let layout = choir_cli::serve::Layout::new(&options.state, options.port);
3697 let port = options.port;
3698 let program = match choir_cli::serve::find_daemon() {
3699 Ok(program) => program,
3700 Err(error) => {
3701 eprintln!("{} {error}", style.red("choir node serve:"));
3702 std::process::exit(1);
3703 }
3704 };
3705 let invocation =
3706 match choir_cli::serve::plan(program, &layout, &options.create, &options.extra) {
3707 Ok(invocation) => invocation,
3708 Err(error) => {
3709 eprintln!("{} {error}", style.red("choir node serve:"));
3710 std::process::exit(1);
3711 }
3712 };
3713 if choir_cli::serve::port_taken(port) {
3714 eprintln!(
3715 "{} something is already listening on 127.0.0.1:{port}\n\n \
3716 is it yours? choir node status\n \
3717 use another: choir node serve --port <n>",
3718 style.red("choir node serve:")
3719 );
3720 std::process::exit(1);
3721 }
3722 eprintln!("{}", style.dim(&invocation.display()));
3725 eprintln!("{} {}", style.dim("serving"), layout.port);
3726 eprintln!("{}", style.red(&choir_cli::serve::exec(&invocation)));
3727 std::process::exit(1);
3728 }
3729 ["repo", "list", api] => {
3730 let style = choir_cli::style::Style::for_stdout();
3731 let client = match choir_cli::mcp::HttpClient::new(
3732 api,
3733 auth.file_for(api).map(std::path::Path::new),
3734 auth.user,
3735 ) {
3736 Ok(client) => client,
3737 Err(error) => {
3738 eprintln!("{} {error}", style.red("choir repo list:"));
3739 std::process::exit(2);
3740 }
3741 };
3742 let (status, body) = match client.get("/api/repos") {
3743 Ok(answer) => answer,
3744 Err(error) => {
3745 eprintln!("{} {error}", style.red("choir repo list:"));
3746 std::process::exit(1);
3747 }
3748 };
3749 if status != 200 {
3750 eprintln!("{} {status}: {body}", style.red("choir repo list:"));
3751 std::process::exit(1);
3752 }
3753 let answer: serde_json::Value = serde_json::from_str(&body).unwrap_or_default();
3754 let names: Vec<&str> = answer["repos"]
3755 .as_array()
3756 .map(|a| a.iter().filter_map(serde_json::Value::as_str).collect())
3757 .unwrap_or_default();
3758 for name in &names {
3759 println!("{name}");
3760 }
3761 if names.is_empty() {
3764 if answer["narrowed"].as_bool().unwrap_or(false) {
3765 eprintln!(
3766 " {}\n",
3767 style.dim("no repositories this credential can read")
3768 );
3769 } else {
3770 eprintln!(
3771 " {}\n choir repo create {api} me/thing.git\n",
3772 style.dim("no repositories on this node yet — make one:")
3773 );
3774 }
3775 }
3776 }
3777 ["repo", "url", api, name] => {
3782 let style = choir_cli::style::Style::for_stdout();
3783 let name = if name.ends_with(".git") {
3784 (*name).to_string()
3785 } else {
3786 format!("{name}.git")
3787 };
3788 let url = format!("{}/{name}", api.trim_end_matches('/'));
3789 println!("{url}");
3790 let credential = auth
3791 .file_for(api)
3792 .map(str::to_string)
3793 .unwrap_or_else(|| format!("{}/auth", state_dir().display()));
3794 eprintln!(
3795 "\n {}\n git clone {url}\n git -C {} config credential.helper \\\n \
3796 '!choir git-credential {credential}'\n",
3797 style.dim("clone it, then teach git the credential:"),
3798 name.trim_end_matches(".git")
3799 .rsplit('/')
3800 .next()
3801 .unwrap_or("repo")
3802 );
3803 }
3804 ["node", "install", rest @ ..] => {
3810 let style = choir_cli::style::Style::for_stdout();
3811 let command = "choir node install";
3812 let options = node_options(command, rest);
3813 let layout = choir_cli::serve::Layout::new(&options.state, options.port);
3814 let unit = match install_unit(&options.state, options.port, &options.extra) {
3815 Ok((unit, _)) => unit,
3816 Err(error) => {
3817 eprintln!("{} {error}", style.red(&format!("{command}:")));
3818 std::process::exit(1);
3819 }
3820 };
3821 note(
3822 "installed",
3823 &[
3824 ("unit", unit.display().to_string()),
3825 ("state", options.state.display().to_string()),
3826 ("log", layout.log.display().to_string()),
3827 ],
3828 );
3829 eprintln!(" {} choir node status\n", style.dim("check it"));
3830 }
3831 ["node", "stop"] => {
3832 let style = choir_cli::style::Style::for_stdout();
3833 let supervisor = supervisor("choir node stop");
3834 let home = home_dir();
3835 for step in supervisor.commands(choir_cli::supervise::Action::Stop, &home) {
3836 if !run_step(&step, false) {
3837 eprintln!("{} nothing was running", style.red("choir node stop:"));
3838 std::process::exit(1);
3839 }
3840 }
3841 eprintln!(
3842 "stopped — the unit is still installed, so it returns at next login\n \
3843 to end it: choir node uninstall"
3844 );
3845 }
3846 ["node", "restart"] => {
3851 let style = choir_cli::style::Style::for_stdout();
3852 let command = "choir node restart";
3853 let supervisor = supervisor(command);
3854 let home = home_dir();
3855 let unit = supervisor.unit_path(&home);
3856 if !unit.exists() {
3857 eprintln!(
3858 "{} nothing is installed here\n\n install it: choir node install",
3859 style.red(&format!("{command}:"))
3860 );
3861 std::process::exit(1);
3862 }
3863 let steps = supervisor.commands(choir_cli::supervise::Action::Install, &home);
3864 let last = steps.len().saturating_sub(1);
3865 for (at, step) in steps.iter().enumerate() {
3866 if !run_step(step, at != last) {
3867 eprintln!(
3868 "{} the service manager refused",
3869 style.red(&format!("{command}:"))
3870 );
3871 std::process::exit(1);
3872 }
3873 }
3874 eprintln!("restarted {}", unit.display());
3875 }
3876 ["node", "uninstall"] => {
3881 let style = choir_cli::style::Style::for_stdout();
3882 let supervisor = supervisor("choir node uninstall");
3883 let home = home_dir();
3884 let unit = supervisor.unit_path(&home);
3885 for step in supervisor.commands(choir_cli::supervise::Action::Uninstall, &home) {
3886 run_step(&step, true);
3887 }
3888 match std::fs::remove_file(&unit) {
3889 Ok(()) => eprintln!("uninstalled {}", unit.display()),
3890 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3891 eprintln!("nothing was installed at {}", unit.display());
3892 }
3893 Err(error) => {
3894 eprintln!(
3895 "{} remove {}: {error}",
3896 style.red("choir node uninstall:"),
3897 unit.display()
3898 );
3899 std::process::exit(1);
3900 }
3901 }
3902 eprintln!(
3903 " {} {} — keys, repositories and the op log\n",
3904 style.dim("kept"),
3905 state_dir().display()
3906 );
3907 let hook =
3914 std::path::Path::new(choir_cli::tls::HOOK_DIR).join(choir_cli::tls::HOOK_NAME);
3915 if hook.exists() {
3916 eprintln!(
3917 " {} {}\n it does nothing once {} is gone; to remove it and the\n \
3918 certificate as well:\n\n sudo rm {}\n sudo certbot delete\n",
3919 style.cyan("renewal hook still installed:"),
3920 hook.display(),
3921 state_dir().join("tls.enabled").display(),
3922 hook.display(),
3923 );
3924 }
3925 }
3926 ["node", "logs", rest @ ..] => {
3927 let style = choir_cli::style::Style::for_stdout();
3928 let (lines, rest) = match rest.split_first() {
3929 Some((first, tail)) if !first.starts_with('-') => match first.parse::<usize>() {
3930 Ok(n) => (n, tail),
3931 Err(_) => {
3932 eprintln!("choir node logs: <lines> must be a number, not {first:?}");
3933 std::process::exit(2);
3934 }
3935 },
3936 _ => (30, rest),
3937 };
3938 let options = node_options("choir node logs", rest);
3939 let log = choir_cli::serve::Layout::new(&options.state, options.port).log;
3940 match std::fs::read_to_string(&log) {
3941 Ok(text) => {
3942 let all: Vec<&str> = text.lines().collect();
3943 for line in all.iter().skip(all.len().saturating_sub(lines)) {
3944 println!("{line}");
3945 }
3946 }
3947 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
3948 eprintln!(
3949 "{} no log at {}\n\n \
3950 a node started by hand writes to your terminal, not here;\n \
3951 the log is written by a supervised node: choir node install",
3952 style.red("choir node logs:"),
3953 log.display()
3954 );
3955 std::process::exit(1);
3956 }
3957 Err(error) => {
3958 eprintln!(
3959 "{} {}: {error}",
3960 style.red("choir node logs:"),
3961 log.display()
3962 );
3963 std::process::exit(1);
3964 }
3965 }
3966 }
3967 ["node", "status", rest @ ..] if rest.len() <= 1 => {
3968 let api = rest
3969 .first()
3970 .copied()
3971 .map(str::to_string)
3972 .or_else(configured_node);
3973 let Some(api) = api else {
3974 eprintln!(
3975 "choir node status: no node given and none configured\n\
3976 \n\
3977 write `node = <url>` to .choir/config, or pass the URL"
3978 );
3979 std::process::exit(2);
3980 };
3981 let style = choir_cli::style::Style::for_stdout();
3982 match choir_cli::node::status(&api, auth.file_for(&api).map(std::path::Path::new)) {
3983 Ok((health, view)) => {
3984 print!(
3985 "{}",
3986 choir_cli::node::status_report(&api, health, &view, style)
3987 );
3988 std::process::exit(health.exit_code());
3989 }
3990 Err(error) => {
3991 eprintln!("{} {error}", style.red("choir node status:"));
3992 std::process::exit(1);
3993 }
3994 }
3995 }
3996 ["doctor", rest @ ..] if rest.len() <= 3 => {
4001 let (state, rest) = match rest {
4005 [head @ .., "--state", dir] | ["--state", dir, head @ ..] => {
4006 (std::path::PathBuf::from(*dir), head.to_vec())
4007 }
4008 other if other.len() <= 1 => (state_dir(), other.to_vec()),
4009 _ => usage(),
4010 };
4011 let configured = configured_node();
4012 let api = rest.first().copied().map(str::to_string).or(configured);
4013 let effective = api.as_deref().and_then(|api| auth.file_for(api));
4017 let credential = effective.or(auth.file);
4018 let mut checks = choir_cli::doctor::run(api.as_deref(), credential);
4019 if choir_cli::serve::Layout::new(&state, 8417)
4024 .missing()
4025 .is_empty()
4026 {
4027 checks.extend(choir_cli::doctor::host(&state, credential));
4028 }
4029 let style = choir_cli::style::Style::for_stdout();
4030 let role = choir_cli::join::Role::of(&state_dir(), credential);
4035 print!("{}", choir_cli::doctor::heading(role, style));
4036 print!("{}", choir_cli::doctor::report(&checks, style));
4037 std::process::exit(choir_cli::doctor::exit_code(&checks));
4038 }
4039 ["backup", "restore", src, root] => {
4045 let style = choir_cli::style::Style::for_stdout();
4046 let (src, root) = (std::path::Path::new(src), std::path::Path::new(root));
4047 if !src.is_dir() {
4048 eprintln!(
4049 "{} no backup directory at {}",
4050 style.red("choir backup restore:"),
4051 src.display()
4052 );
4053 std::process::exit(2);
4054 }
4055 let daemon = match choir_cli::serve::find_daemon() {
4056 Ok(daemon) => daemon,
4057 Err(error) => {
4058 eprintln!("{} {error}", style.red("choir backup restore:"));
4059 std::process::exit(1);
4060 }
4061 };
4062 let auth = auth
4067 .file
4068 .map(std::path::PathBuf::from)
4069 .unwrap_or_else(|| root.join(".choir/auth"));
4070 let mut say = |line: &str| eprintln!(" {} {line}", style.dim("restore:"));
4071 match choir_cli::restore::run(src, root, &daemon, &auth, &mut say) {
4072 Ok(done) => {
4073 println!(
4078 "restore: {} ops replayed, {} repos unbundled, canary landed at seq {}",
4079 done.ops, done.repos, done.ops
4080 );
4081 println!(
4082 "restore: the canary ref is {} in {} — it is evidence, delete it when you no longer want it",
4083 done.canary, done.landed_in
4084 );
4085 println!(
4086 "restore: root is {} — start it under your supervisor:",
4087 root.display()
4088 );
4089 println!(" choir node install --state {}", root.display());
4090 }
4091 Err(refusal) => {
4092 let tag = if refusal.code == 3 {
4093 style.red("choir backup restore: DECIDE")
4094 } else {
4095 style.red("choir backup restore:")
4096 };
4097 eprintln!("{tag} {}", refusal.message);
4098 std::process::exit(refusal.code);
4099 }
4100 }
4101 }
4102 ["backup", "verify", dir] => {
4105 let style = choir_cli::style::Style::for_stdout();
4106 let dir = std::path::Path::new(dir);
4107 if !dir.is_dir() {
4108 eprintln!(
4109 "{} {} is not a directory",
4110 style.red("choir backup verify:"),
4111 dir.display()
4112 );
4113 std::process::exit(2);
4114 }
4115 let daemon = choir_cli::serve::find_daemon().ok();
4116 let checks = choir_cli::backup::verify(dir, daemon.as_deref());
4117 print!("{}", choir_cli::doctor::report(&checks, style));
4118 std::process::exit(i32::from(!choir_cli::backup::restorable(&checks)));
4119 }
4120 ["repair", log_file, rest @ ..] => repair(log_file, rest),
4126 ["workspace", api, repo, name, rest @ ..] => {
4127 let body = workspace_body(repo, name, rest);
4128 let (status, resp) = http(api, auth, "choir_workspace", body);
4129 finish(status, &resp);
4130 }
4131 ["checkpoint", api, key_file, channel, change_id, workspace, oid] => {
4132 let Some(revision) = choir_hash::ContentHash::from_git_oid(oid) else {
4133 eprintln!("<git-oid> must be a 40- or 64-char hex object id");
4134 std::process::exit(2);
4135 };
4136 let prev_revision = current_change_revision(api, auth, change_id);
4137 let op = ViewOp::new(OpKind::CheckpointChange {
4138 id: (*change_id).into(),
4139 workspace: (*workspace).into(),
4140 revision,
4141 prev_revision,
4142 });
4143 submit(api, key_file, channel, &op, auth);
4144 }
4145 ["workspace-archive", api, key_file, channel, repo, name, change_id, idempotency_key] => {
4146 let prev_revision = current_change_revision(api, auth, change_id);
4147 let authorization = ArchiveAuthorization::new(
4148 (*change_id).into(),
4149 format!("{repo}/{name}"),
4150 prev_revision,
4151 );
4152 let mut body = signed_payload_body(key_file, channel, &authorization.to_payload());
4153 body["repo"] = serde_json::json!(repo);
4154 body["name"] = serde_json::json!(name);
4155 body["change"] = serde_json::json!(change_id);
4156 body["idempotency_key"] = serde_json::json!(idempotency_key);
4157 let (status, resp) = http(api, auth, "choir_workspace_archive", body);
4158 finish(status, &resp);
4159 }
4160 ["propose", rest @ ..] => propose(rest, auth),
4164 ["join", link, rest @ ..] if auth.is_empty() && choir_cli::join::Link::looks_like(link) => {
4169 match choir_cli::join::Link::parse(link) {
4170 Ok(choir_cli::join::Link { api, id, secret }) => {
4171 join(&api, Invite::Pair(id, secret), None, rest)
4172 }
4173 Err(why) => {
4174 eprintln!("choir join: {why}");
4175 std::process::exit(2);
4176 }
4177 }
4178 }
4179 ["join", api, invite_file, key_file, rest @ ..] if auth.is_empty() => {
4180 join(api, Invite::File(invite_file), Some(key_file), rest)
4181 }
4182 ["git-credential", auth_file, operation] if auth.is_empty() => {
4185 git_credential(auth_file, None, operation)
4186 }
4187 ["git-credential", auth_file, "--auth-user", user, operation] if auth.is_empty() => {
4188 git_credential(auth_file, Some(user), operation)
4189 }
4190 ["runner", config_file] => runner(config_file, auth),
4191 ["schema", api] => {
4196 let (status, body) = http(api, auth, "choir_schema", serde_json::json!({}));
4197 finish(status, &body);
4198 }
4199 ["log", api, rest @ ..] => {
4200 let (mut from, mut verify, mut keys) = (0u64, false, None);
4201 let mut it = rest.iter();
4202 while let Some(arg) = it.next() {
4203 match *arg {
4204 "--from" => {
4205 let Some(value) = it.next().and_then(|v| v.parse().ok()) else {
4206 usage()
4207 };
4208 from = value;
4209 }
4210 "--verify" => verify = true,
4211 "--keys" => {
4212 let Some(path) = it.next() else { usage() };
4213 keys = Some(*path);
4214 }
4215 _ => usage(),
4216 }
4217 }
4218 log(api, from, verify, keys, auth);
4219 }
4220 ["batch", api, key_file, channel, ops_file] => {
4221 batch(api, key_file, channel, ops_file, auth);
4222 }
4223 ["submit", api, key_file, channel, op_json] => {
4224 let op: ViewOp = match serde_json::from_str(op_json) {
4227 Ok(op) => op,
4228 Err(e) => {
4229 eprintln!("bad op json: {e}");
4230 std::process::exit(2);
4231 }
4232 };
4233 submit(api, key_file, channel, &op, auth);
4234 }
4235 ["review", api, key_file, channel, id, oid, rest @ ..] => {
4240 let Some(target) = choir_hash::ContentHash::from_git_oid(oid) else {
4241 eprintln!("<git-oid> must be a 40- or 64-char hex object id");
4242 std::process::exit(2);
4243 };
4244 let mut target_ref = None;
4245 let mut reviewers = Vec::new();
4246 let mut it = rest.iter();
4247 while let Some(arg) = it.next() {
4248 if *arg == "--ref" {
4249 let Some(name) = it.next() else { usage() };
4250 target_ref = Some((*name).to_string());
4251 } else {
4252 reviewers.push((*arg).to_string());
4253 }
4254 }
4255 let op = ViewOp::new(OpKind::RequestReview {
4256 id: (*id).into(),
4257 target,
4258 reviewers,
4259 target_ref,
4260 });
4261 submit(api, key_file, channel, &op, auth);
4262 }
4263 ["verdict", api, key_file, reviewer, id, verdict, rest @ ..] if rest.len() <= 1 => {
4264 let verdict = match *verdict {
4265 "approve" => Verdict::Approve,
4266 "request-changes" => Verdict::RequestChanges,
4267 _ => usage(),
4268 };
4269 let op = ViewOp::new(OpKind::PostVerdict {
4270 id: (*id).into(),
4271 reviewer: (*reviewer).into(),
4272 verdict,
4273 note: rest.first().copied().unwrap_or("").into(),
4274 });
4275 submit(api, key_file, reviewer, &op, auth);
4278 }
4279 ["comment", api, key_file, channel, id, comment, body] => {
4284 let op = ViewOp::new(OpKind::PostComment {
4285 id: (*id).into(),
4286 comment: (*comment).into(),
4287 author: (*channel).into(),
4288 body: (*body).into(),
4289 });
4290 submit(api, key_file, channel, &op, auth);
4293 }
4294 ["viewed", api, key_file, viewer, id] => {
4299 let op = ViewOp::new(OpKind::ViewedReview {
4300 id: (*id).into(),
4301 viewer: (*viewer).into(),
4302 });
4303 submit(api, key_file, viewer, &op, auth);
4306 }
4307 ["witness", api, key_file, channel] => {
4313 let op = ViewOp::new(OpKind::CountersignSnapshot {
4314 witness: reviewer_operator(channel).into(),
4315 snapshot: latest_snapshot(api, auth),
4316 });
4317 submit(api, key_file, channel, &op, auth);
4318 }
4319 ["vouch", api, key_file, channel, subject, rest @ ..] if rest.len() <= 1 => {
4320 let op = ViewOp::new(OpKind::Vouch {
4321 voucher: reviewer_operator(channel).into(),
4322 subject: (*subject).into(),
4323 note: rest.first().copied().unwrap_or("").into(),
4324 });
4325 submit(api, key_file, channel, &op, auth);
4326 }
4327 ["unvouch", api, key_file, channel, subject, reason] => {
4328 let op = ViewOp::new(OpKind::WithdrawVouch {
4329 voucher: reviewer_operator(channel).into(),
4330 subject: (*subject).into(),
4331 reason: (*reason).into(),
4332 });
4333 submit(api, key_file, channel, &op, auth);
4334 }
4335 ["slash", api, node_key_file, id, reviewer, reason] => {
4336 require_node_key_file(node_key_file);
4337 let op = ViewOp::new(OpKind::SlashApproval {
4338 id: (*id).into(),
4339 reviewer: (*reviewer).into(),
4340 reason: (*reason).into(),
4341 });
4342 submit(api, node_key_file, "node/slash", &op, auth);
4346 }
4347 ["abandon", api, node_key_file, id] => {
4355 require_node_key_file(node_key_file);
4356 let op = ViewOp::new(OpKind::ArchiveReview {
4357 id: (*id).into(),
4358 lapsed: true,
4359 });
4360 submit(api, node_key_file, "node/abandon", &op, auth);
4361 }
4362 ["bind", api, node_key_file, operator, key_hex, rest @ ..] if rest.len() <= 1 => {
4372 require_node_key_file(node_key_file);
4373 let key = actor_id_from_hex(key_hex);
4374 let channel = rest.first().map(|c| (*c).to_string());
4375 if let Some(existing) = current_binding(api, auth, &key.to_hex()) {
4387 if existing["operator"] == serde_json::json!(operator)
4388 && existing["channel"] == serde_json::json!(channel)
4389 && existing["revoked"].is_null()
4390 {
4391 finish(
4392 200,
4393 &serde_json::json!({
4394 "already_bound": true,
4395 "operator": operator,
4396 "channel": channel,
4397 "bound_at": existing["bound_at"],
4398 })
4399 .to_string(),
4400 );
4401 }
4402 }
4403 let op = ViewOp::new(OpKind::BindKey {
4404 operator: (*operator).into(),
4405 key,
4406 channel,
4407 });
4408 submit(api, node_key_file, "node/bind", &op, auth);
4409 }
4410 ["revoke", api, node_key_file, key_hex, reason] => {
4411 require_node_key_file(node_key_file);
4412 let op = ViewOp::new(OpKind::RevokeKey {
4413 key: actor_id_from_hex(key_hex),
4414 reason: (*reason).into(),
4415 });
4416 submit(api, node_key_file, "node/revoke", &op, auth);
4417 }
4418 ["appeal", api, attempt_id] => {
4419 let attempt_id = attempt_id.parse::<u64>().unwrap_or_else(|_| usage());
4420 let (status, resp) = http(
4421 api,
4422 auth,
4423 "choir_appeal",
4424 serde_json::json!({ "attempt_id": attempt_id }),
4425 );
4426 finish(status, &resp);
4427 }
4428 ["intent", api, key_file, channel, subject, kind, body] => {
4429 let op = ViewOp::new(OpKind::RecordProvenance {
4432 subject: (*subject).into(),
4433 kind: (*kind).into(),
4434 body: (*body).into(),
4435 });
4436 submit(api, key_file, channel, &op, auth);
4437 }
4438 ["check", api, key_file, channel, oid, name, status, rest @ ..] if rest.len() <= 3 => {
4442 let Some(subject) = choir_hash::ContentHash::from_git_oid(oid) else {
4443 eprintln!("<git-oid> must be a 40- or 64-char hex object id");
4444 std::process::exit(2);
4445 };
4446 let Some(status) = CheckStatus::parse(status) else {
4447 eprintln!("<status> must be passed, failed or running");
4448 std::process::exit(2);
4449 };
4450 let mut evidence = String::new();
4451 let mut target_ref = None;
4452 let mut it = rest.iter();
4453 while let Some(arg) = it.next() {
4454 if *arg == "--ref" {
4455 let Some(name) = it.next() else { usage() };
4456 target_ref = Some((*name).to_string());
4457 } else {
4458 evidence = (*arg).to_string();
4459 }
4460 }
4461 let op = ViewOp::new(OpKind::RecordCheck {
4462 subject,
4463 name: (*name).into(),
4464 status,
4465 evidence,
4466 reporter: (*channel).into(),
4467 target_ref,
4468 });
4469 submit(api, key_file, channel, &op, auth);
4470 }
4471 ["checks", api, oid] => {
4474 let Some(subject) = choir_hash::ContentHash::from_git_oid(oid) else {
4475 eprintln!("<git-oid> must be a 40- or 64-char hex object id");
4476 std::process::exit(2);
4477 };
4478 let (status, resp) = http(api, auth, "choir_view", serde_json::json!({}));
4479 if !(200..300).contains(&status) {
4480 finish(status, &resp);
4481 }
4482 check_exit(&subject, &resp);
4483 }
4484 ["profile", api, channel] => {
4485 let (status, resp) = http(
4486 api,
4487 auth,
4488 "choir_profile",
4489 serde_json::json!({ "channel": channel }),
4490 );
4491 finish(status, &resp);
4492 }
4493 ["search", api, term, rest @ ..] => {
4494 let mut arguments = serde_json::Map::new();
4499 arguments.insert("q".into(), serde_json::json!(term));
4500 let mut it = rest.iter();
4501 while let Some(arg) = it.next() {
4502 let field = match *arg {
4503 "--in" => "in",
4504 "--repo" => "repo",
4505 "--rev" => "rev",
4506 "--limit" => "limit",
4507 _ => usage(),
4508 };
4509 let Some(value) = it.next() else { usage() };
4510 let value = match field {
4516 "limit" => match value.parse::<u64>() {
4517 Ok(n) => serde_json::json!(n),
4518 Err(_) => usage(),
4519 },
4520 _ => serde_json::json!(value),
4521 };
4522 arguments.insert(field.to_string(), value);
4523 }
4524 let (status, resp) = http(api, auth, "choir_search", arguments.into());
4525 finish(status, &resp);
4526 }
4527 ["reviews", api, reviewer] => {
4528 let (status, resp) = http(
4529 api,
4530 auth,
4531 "choir_reviews",
4532 serde_json::json!({ "reviewer": reviewer }),
4533 );
4534 finish(status, &resp);
4535 }
4536 ["view", api, rest @ ..] => {
4537 let mut arguments = serde_json::Map::new();
4542 let mut it = rest.iter();
4543 while let Some(arg) = it.next() {
4544 let field = match *arg {
4545 "--limit" => "limit",
4546 "--offset" => "offset",
4547 _ => usage(),
4548 };
4549 let Some(value) = it.next().and_then(|v| v.parse::<u64>().ok()) else {
4550 usage()
4551 };
4552 arguments.insert(field.to_string(), serde_json::json!(value));
4553 }
4554 let (status, resp) = http(api, auth, "choir_view", arguments.into());
4555 finish(status, &resp);
4556 }
4557 ["docs", rest @ ..] => {
4558 let open = match rest {
4559 [] => false,
4560 ["--open"] => true,
4561 _ => usage(),
4562 };
4563 let cwd = std::env::current_dir().unwrap_or_else(|e| {
4564 eprintln!("choir: cannot read the working directory: {e}");
4565 std::process::exit(1);
4566 });
4567 let Some(root) = choir_cli::docs::find_root(&cwd) else {
4568 eprintln!("choir: {}", choir_cli::docs::Failure::NotACheckout);
4569 std::process::exit(1);
4570 };
4571 let built = match choir_cli::docs::build(&root) {
4572 Ok(built) => built,
4573 Err(failure) => {
4574 eprintln!("choir: {failure}");
4575 std::process::exit(1);
4576 }
4577 };
4578 let opened = open && choir_cli::docs::open(&built.book.join("index.html"));
4579 note(
4580 "documentation built",
4581 &[
4582 ("book", built.book.join("index.html").display().to_string()),
4583 ("api", built.api.join("index.html").display().to_string()),
4584 ("crates", built.crates.len().to_string()),
4585 ],
4586 );
4587 let doc = serde_json::json!({
4588 "root": built.root.display().to_string(),
4589 "book": built.book.display().to_string(),
4590 "api": built.api.display().to_string(),
4591 "crates": built.crates,
4592 "opened": opened,
4593 });
4594 finish(200, &doc.to_string());
4595 }
4596 ["skill", "install", rest @ ..] => {
4597 let into = match rest {
4598 [] => ".claude/skills",
4599 ["--into", dir] => dir,
4600 _ => usage(),
4601 };
4602 let dir = std::path::Path::new(into).join(choir_cli::surface::SKILL_DIR);
4603 let path = dir.join("SKILL.md");
4604 let rendered = choir_cli::surface::skill_md();
4605 let wrote = std::fs::read_to_string(&path).ok().as_deref() != Some(rendered.as_str());
4609 if wrote {
4610 if let Err(error) = choir_fs::write_atomic(&path, &rendered) {
4611 eprintln!("choir: cannot write {}: {error}", path.display());
4612 std::process::exit(1);
4613 }
4614 }
4615 let doc = serde_json::json!({ "path": path.display().to_string(), "wrote": wrote });
4616 note(
4617 if wrote {
4618 "skill installed"
4619 } else {
4620 "skill already current"
4621 },
4622 &[("path", path.display().to_string())],
4623 );
4624 finish(200, &doc.to_string());
4625 }
4626 ["invite", api, name, repo] => invite(api, auth, name, repo, "write"),
4627 ["invite", api, name, repo, level] => invite(api, auth, name, repo, level),
4628 ["asks", api] => asks(api, auth),
4629 ["grant", api, id, repo] => grant(api, auth, id, repo, "write"),
4630 ["grant", api, id, repo, level] => grant(api, auth, id, repo, level),
4631 ["decline", api, id] => decline(api, auth, id),
4632 ["acl", "render", api, acl_file] => acl_render(api, auth, acl_file),
4633 ["funnel", api] => {
4634 println!("{}", derived_view(api, auth, choir_cli::triage::funnel));
4635 }
4636 ["triage", api] => {
4637 let doc = derived_view(api, auth, choir_cli::triage::triage);
4638 finish(200, &doc);
4639 }
4640 ["state", api, channel] => {
4641 let doc = derived_view(api, auth, |view| {
4642 choir_cli::triage::next_actions(view, api, channel)
4643 });
4644 finish(200, &doc);
4645 }
4646 _ => usage(),
4647 }
4648}