// atlibs: the CLI. `atlibs help` (usage below) lists every command. /// println! for every command's output: a reader that closed early (`atlibs help | head`) ends the /// process quietly with exit 0, not println!'s broken-pipe panic. (SIGPIPE stays ignored, Rust's default: /// an exec handler's or a socket's closed pipe is an error to handle, not a reason to die.) macro_rules! out { ($($arg:tt)*) => { $crate::out(format_args!($($arg)*)) }; } pub fn out(line: std::fmt::Arguments) { use std::io::Write; if let Err(e) = writeln!(std::io::stdout().lock(), "{line}") { if e.kind() != std::io::ErrorKind::BrokenPipe { eprintln!("stdout: {e}"); std::process::exit(1); } std::process::exit(0); } } mod bridge; mod handlers; mod lexicon; mod login; mod new; mod publish; mod registry; mod run; mod serve; #[cfg(test)] mod fake; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; use atlibs_host::host::{Fetch, Session}; use atlibs_host::store::Store; use atlibs_host::Signal; use atlibs_host::transport::Transports; /// Every command's usage: `atlibs help` prints them all, a bad invocation its own (`usage`). /// (command, synopsis, what it does) const COMMANDS: &[(&str, &str, &str)] = &[ ("new", "new ", "a module repo in ./: a cargo workspace of modules/*, one module (modules/) with a passing cargo test"), ("add", "add /[@pin|@cid] | ... [--dir .]", "fetch published modules and compositions: atlibs.lock.json + modules/ in --dir"), ("stage", "stage [--target spa|server] [--out dist] [--origin https://site/] [--oauth-path oauth/] [--name [--record ]] [--dir .] [ ...]", "a site's or a server's files: stage.json, modules/, oauth//client-metadata.json (spa: no --origin\n writes a template); no ref: everything in --dir's atlibs.lock.json"), ("oauth", "oauth --origin https://site/ [--oauth-path oauth/] [--name ] [--dir .] [ ...]", "print the client metadata documents an origin serves, by session"), ("run", "run [] ['' | - | @file] [--handler = ...] [--allow-exec] [--params '' | @file]", "one run; prints its result (input from stdin with -, a file with @file). No entry: lists them.\n A composition runs ; its handlers: --handler =wasm:# | https://...\n | exec: (with --allow-exec); its parameters: --params {inputs, secrets, host}"), ("serve", "serve [--port 8787] [--host 127.0.0.1] [--origin URL | ATLIBS_ORIGIN] [--oauth-path oauth/] [--handler = ...] [--allow-exec]", "GET / (its entries), POST /run/[/], GET /oauth//client-metadata.json"), ("publish", "publish [ ...] [--dir modules] [--dry-run] [--check-live] [--no-build] [--allow-dirty] [--out records/wasm]", "every module (modules/* or examples/rust/*, or those in --dir) and records/*.json, or the named ones: only what changed since\n the last publish (the cache: $XDG_CACHE_HOME/atlibs//, default ~/.cache; no entry or --check-live: the live\n records, reporting drift); a table of published / unchanged / would-publish / skipped:dirty / drift / failed"), ("login", "login [--scope \"atproto transition:generic\"] [--no-open]", "sign in with OAuth (a loopback redirect in your browser; ATLIBS_BROWSER opens it): the session is saved per DID\n in the state dir; act as it with --as (every command)"), ("logout", "logout ", "revoke and forget that account's OAuth session"), ("publish module", "publish module ... [--no-build] [--out records/wasm] [--allow-dirty]", "build (the repo's cargo workspace, in the rust: container verify uses), then publish its source records, manifest, blob and module record;\n refuses uncommitted sources unless --allow-dirty; writes records/wasm/.json (--out)"), ("publish records", "publish records [ ...]", "each record (default records/*.json) by its $type, rkey = the file name"), ("publish tile", "publish tile [--out records/tiles]", "a static site as a web tile (ing.dasl.masl)"), ("publish composition", "publish composition [--rkey ]", "a com.fedfork.exec.composition; its module keys become this account's module records"), ("publish versions", "publish versions ... [--dry-run]", "copy each named live module and composition to its version record when it has none (published before 0.4):\n no build, the same pin; what a pin or a binding's CID loads"), ("publish delete", "publish delete ...", "remove this account's records (an old module, its manifest)"), ("verify", "verify / | ", "rebuild a published module from its source records in a container; compare sha256"), ("compose bindings", "compose bindings [--dir .] [ ...]", "atlibs.modules.js and .d.ts in --dir for compose/build.js: a binding per module its atlibs.lock.json vendors (pinned),\n per modules/ module directory and per one named (unpinned); `atlibs add` writes them too"), ("compose check", "compose check ", "the hosts' load checks, no wasm needed: modules from atlibs.lock.json or stage.json beside it (pins too), module\n directories (module.json) or published refs; prints the composition as it would run, or the reason it would not load (exit 1)"), ]; /// `command`'s usage lines (`publish`: each of its subcommands'). pub fn usage(command: &str) -> String { let lines: Vec = COMMANDS.iter().filter(|(c, ..)| *c == command || c.strip_prefix(command).is_some_and(|r| r.starts_with(' '))).map(|(_, synopsis, _)| format!("atlibs {synopsis}")).collect(); format!("usage: {}\n(atlibs help)", lines.join("\n ")) } fn help() -> String { let commands: String = COMMANDS.iter().map(|(_, synopsis, what)| format!(" {synopsis}\n {what}\n")).collect(); format!( "usage: atlibs ...\n{commands} (and `bridge`: internal, the conformance suite's stdio engine, rust/host/conformance.mjs) A is {}. A published ref may give its pin (32+ hex digits of the module pin) or record CID after @. Who a command acts as, never read from the environment (no flag: signed out): --as the OAuth session `atlibs login` saved (the normal path) --app-password (--password-file | --password-env ) [--pds https://bsky.social] an app password (simple / testing); its session is saved and refreshed env: ATLIBS_SERVER_STATE (default ~/.atlibs-server, shared with javascript/src/server), ATLIBS_BUDGET, ATLIBS_IROH_KEY, ATLIBS_ORIGIN (serve), ATLIBS_ALLOW_PRIVATE=1 (http and private addresses for DID and PDS lookups: local dev only), ATLIBS_PLC_URL (default https://plc.directory), ATLIBS_HANDLE_RESOLVER (default https://public.api.bsky.app), ATLIBS_WASM_CONCURRENCY (wasm runs executing at once in a process, default the cores; the rest queue), CONTAINER_ENGINE (publish, verify).", registry::REF_FORMS ) } /// The transports this build has (cargo features, rust/cli/Cargo.toml), their keys under `state_dir`, for /// every module's call (run::Host). One left out is refused: ": not available on this host (built without )". #[cfg_attr(not(feature = "iroh"), allow(unused_variables))] pub fn transports(state_dir: &Path) -> Transports { #[allow(unused_mut)] let mut transports: Transports = vec![]; #[cfg(feature = "iroh")] transports.push(atlibs_iroh::transport(env("ATLIBS_IROH_KEY").map(PathBuf::from).unwrap_or_else(|| state_dir.join("iroh_ed25519")))); transports } /// ATLIBS_SERVER_STATE, else ~/.atlibs-server. pub fn state_dir() -> PathBuf { env("ATLIBS_SERVER_STATE").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(std::env::var("HOME").unwrap_or_default()).join(".atlibs-server")) } pub fn env(key: &str) -> Option { std::env::var(key).ok().filter(|v| !v.trim().is_empty()) } /// `--key value` / `--key=value` flags (those in `switches` take no value; a flag may repeat) and positionals. pub struct Args { pub pos: Vec, flags: HashMap>, } impl Args { pub fn parse(argv: &[String], switches: &[&str]) -> Result { let (mut pos, mut flags) = (vec![], HashMap::new()); let mut it = argv.iter(); while let Some(arg) = it.next() { match arg.strip_prefix("--") { Some(flag) if !flag.is_empty() => { let (k, v) = match flag.split_once('=') { Some((k, v)) => (k.to_string(), v.to_string()), None if switches.contains(&flag) => (flag.to_string(), "true".into()), None => (flag.to_string(), it.next().ok_or(format!("--{flag} needs a value"))?.clone()), }; flags.entry(k).or_insert_with(Vec::new).push(v); } _ => pos.push(arg.clone()), } } Ok(Self { pos, flags }) } /// The last value of `--key`. pub fn flag(&self, key: &str) -> Option<&str> { self.flags.get(key).and_then(|v| v.last()).map(String::as_str) } /// Every value of a repeated `--key`. pub fn all(&self, key: &str) -> Vec<&str> { self.flags.get(key).into_iter().flatten().map(String::as_str).collect() } pub fn has(&self, key: &str) -> bool { self.flags.contains_key(key) } /// Refuses flags `command` does not know (a typo should not silently do the default). pub fn only(&self, known: &[&str], command: &str) -> Result<&Self, String> { match self.flags.keys().find(|k| !known.contains(&k.as_str())) { Some(k) => Err(format!("unknown flag --{k}\n{}", usage(command))), None => Ok(self), } } } /// What every command shares: HTTP, the state dir, the sealed store. pub struct Cli { pub fetch: Arc, pub net: registry::Net, pub state_dir: PathBuf, /// opened on first use (`store()`): add, stage, oauth and verify never create the state dir or its key pub store: OnceLock, } impl Cli { pub fn from_env() -> Result { let state_dir = state_dir(); let fetch: Arc = Arc::new(reqwest::Client::builder().user_agent("atlibs").build().map_err(|e| e.to_string())?); Ok(Self { net: registry::net_from_env(fetch.clone()), fetch, store: OnceLock::new(), state_dir, }) } pub fn store(&self) -> Result<&Store, String> { if let Some(store) = self.store.get() { return Ok(store); } let store = Store::open(&self.state_dir, "atlibs-server")?; Ok(self.store.get_or_init(|| store)) } /// The session --as / --app-password named (login.rs; None without one), refreshed when it expires. pub async fn session(&self) -> Result>, String> { login::session(self).await } pub async fn require_session(&self) -> Result, String> { self.session().await?.ok_or_else(|| "sign in: --as (after atlibs login), or --app-password --password-file ".into()) } } async fn dispatch(argv: &[String], signal: Signal) -> Result<(), String> { atlibs_host::wasm::concurrency()?; // a bad ATLIBS_WASM_CONCURRENCY stops here, before any run let argv = login::take_flags(argv)?; let (command, rest) = argv.split_first().ok_or_else(help)?; match command.as_str() { "bridge" => bridge::main().await, "new" => new::main(rest), "add" => registry::add(&Cli::from_env()?, rest).await, "stage" => registry::stage(&Cli::from_env()?, rest).await, "oauth" => registry::oauth(&Cli::from_env()?, rest).await, "run" => run::main(&Cli::from_env()?, rest, signal).await, "serve" => serve::main(Cli::from_env()?, rest, signal).await, "login" => login::login(&Cli::from_env()?, rest, signal).await, "logout" => login::logout(&Cli::from_env()?, rest).await, "publish" => publish::main(&Cli::from_env()?, rest).await, "verify" => registry::verify(&Cli::from_env()?, rest).await, "compose" => match rest.split_first() { Some((sub, rest)) if sub == "check" => registry::compose_check(&Cli::from_env()?, rest).await, Some((sub, rest)) if sub == "bindings" => registry::compose_bindings(rest), _ => Err(usage("compose")), }, "help" | "--help" | "-h" => { out!("{}", help()); Ok(()) } other => Err(format!("unknown command {other}\n\n{}", help())), } } // One thread, like the JS event loop: calls start in the order the module made them. // Returns (not process::exit) so the runtime drops its tasks first: iroh temp dirs go, children die. #[tokio::main(flavor = "current_thread")] async fn main() -> std::process::ExitCode { atlibs_host::tls(); let argv: Vec = std::env::args().skip(1).collect(); let signal = Signal::new(); let stop = signal.clone(); tokio::spawn(async move { if tokio::signal::ctrl_c().await.is_ok() { stop.abort("aborted"); } }); match dispatch(&argv, signal).await { Ok(()) => std::process::ExitCode::SUCCESS, Err(e) => { eprintln!("{e}"); std::process::ExitCode::FAILURE } } }