1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
// 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
}
}
}