~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs

0
0
main
246 lines · 13.5 KB Raw History
// 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 <name>", "a module repo in ./<name>: a cargo workspace of modules/*, one module (modules/<name>) with a passing cargo test"),
    ("add", "add <handle>/<lib>[@pin|@cid] | <at://...> ... [--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 <client> [--record <file>]] [--dir .] [<ref> ...]", "a site's or a server's files: stage.json, modules/, oauth/<session>/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 <client>] [--dir .] [<ref> ...]", "print the client metadata documents an origin serves, by session"),
    ("run", "run <ref> [<instance>] <entry> ['<json>' | - | @file] [--handler <name>=<spec> ...] [--allow-exec] [--params '<json>' | @file]", "one run; prints its result (input from stdin with -, a file with @file). No entry: lists them.\n      A composition runs <instance> <entry>; its handlers: --handler <name>=wasm:<ref>#<entry> | https://...\n      | exec:<command> (with --allow-exec); its parameters: --params {inputs, secrets, host}"),
    ("serve", "serve <ref> [--port 8787] [--host 127.0.0.1] [--origin URL | ATLIBS_ORIGIN] [--oauth-path oauth/] [--handler <name>=<spec> ...] [--allow-exec]", "GET / (its entries), POST /run/[<instance>/]<entry>, GET /oauth/<session>/client-metadata.json"),
    ("publish", "publish [<name> ...] [--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/<did>/, 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 <handle|did> [--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 <handle|did> (every command)"),
    ("logout", "logout <handle|did>", "revoke and forget that account's OAuth session"),
    ("publish module", "publish module <dir|name> ... [--no-build] [--out records/wasm] [--allow-dirty]", "build (the repo's cargo workspace, in the rust:<rustc version> container verify uses), then publish its source records, manifest, blob and module record;\n      refuses uncommitted sources unless --allow-dirty; writes records/wasm/<name>.json (--out)"),
    ("publish records", "publish records [<file> ...]", "each record (default records/*.json) by its $type, rkey = the file name"),
    ("publish tile", "publish tile <dir> <name> [--out records/tiles]", "a static site as a web tile (ing.dasl.masl)"),
    ("publish composition", "publish composition <file> [--rkey <name>]", "a com.fedfork.exec.composition; its module keys become this account's module records"),
    ("publish versions", "publish versions <name> ... [--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 <at://did/collection/rkey | collection/rkey> ...", "remove this account's records (an old module, its manifest)"),
    ("verify", "verify <handle>/<lib> | <at://.../com.fedfork.exec.wasm.module/...>", "rebuild a published module from its source records in a container; compare sha256"),
    ("compose bindings", "compose bindings [--dir .] [<module 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/<name> module directory and per one named (unpinned); `atlibs add` writes them too"),
    ("compose check", "compose check <composition.json>", "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<String> = 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 <command> ...\n{commands}  (and `bridge`: internal, the conformance suite's stdio engine, rust/host/conformance.mjs)

A <ref> 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 <handle|did>                  the OAuth session `atlibs login` saved (the normal path)
  --app-password <handle> (--password-file <path|-> | --password-env <NAME>) [--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: "<nsid>: not available on this host (built without <name>)".
#[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<String> {
    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<String>,
    flags: HashMap<String, Vec<String>>,
}

impl Args {
    pub fn parse(argv: &[String], switches: &[&str]) -> Result<Self, String> {
        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<dyn Fetch>,
    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<Store>,
}

impl Cli {
    pub fn from_env() -> Result<Self, String> {
        let state_dir = state_dir();
        let fetch: Arc<dyn Fetch> = 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<Option<Arc<Session>>, String> {
        login::session(self).await
    }

    pub async fn require_session(&self) -> Result<Arc<Session>, String> {
        self.session().await?.ok_or_else(|| "sign in: --as <handle> (after atlibs login), or --app-password <handle> --password-file <path|->".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<String> = 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
        }
    }
}