From 420a4ae8dd05639a9ef92d5298982eeee7fb0618 Mon Sep 17 00:00:00 2001 From: Duyet Le Date: Wed, 21 Oct 2026 15:08:36 +0700 Subject: [PATCH 1/4] feat(publish): generic anyr publish and apps, api --data @file/- and -H No app names in Rust: schema comes from GET /v1/apps//schema, validated locally, posted with a stable Idempotency-Key. Also fixes --data and --method not taking values in the flag parser. Co-Authored-By: Claude Sonnet 5.5 Co-authored-by: Duyet Le Co-authored-by: duyetbot --- README.md | 22 ++ src/api.rs | 128 ++++++++++-- src/cmd/dispatch.rs | 15 +- src/commands.rs | 2 + src/completion.rs | 1 + src/help.rs | 2 + src/http.rs | 30 +++ src/lib.rs | 2 + src/parse.rs | 4 + src/publish.rs | 475 ++++++++++++++++++++++++++++++++++++++++++++ src/schema.rs | 320 +++++++++++++++++++++++++++++ tests/publish.rs | 376 +++++++++++++++++++++++++++++++++++ 12 files changed, 1357 insertions(+), 20 deletions(-) create mode 100644 src/publish.rs create mode 100644 src/schema.rs create mode 100644 tests/publish.rs diff --git a/README.md b/README.md index f079842..4a21b18 100644 --- a/README.md +++ b/README.md @@ -120,12 +120,34 @@ anyr api keys list --json anyr api keys create ci-bot # secret on stdout only: KEY=$(anyr api keys create x) anyr api GET /credits # raw request, like `gh api` anyr api PATCH /keys/ disabled=true limit:=25 +anyr api POST /apps/explain/submissions --data @body.json # body from a file +cat body.json | anyr api POST /apps/explain/submissions --data - -H 'X-Trace: 1' ``` Resources: `me credits keys logs presets aliases models providers dashboard hubs connections`. Run `anyr api --help` for verbs. Piped output is TSV; pass `--json` for JSON. Errors exit 2 (usage), 3 (404) or 4 (401/403). +### Publish to an app: `anyr publish` + +Apps publish their own JSON Schema; the CLI knows no app by name. `publish` checks +your document against that schema locally (problems listed by field, exit 2, nothing +uploaded), then sends it and prints the URL. + +```bash +anyr apps schema explain > schema.json # what an agent must produce + | anyr publish explain - +anyr publish explain fig.json --dry-run # validate only +anyr publish explain fig.json --visibility public --yes +anyr apps ls explain # what you published +anyr apps visibility explain private +anyr apps rm explain --yes +``` + +Visibility is `unlisted` by default (link works, not listed), `private` (only you) or +`public` (listed after review). Re-running the same document is safe: it carries a +stable `Idempotency-Key`, so the server returns the original instead of a copy. + ### Bring your own provider keys: `anyr byok` ```bash diff --git a/src/api.rs b/src/api.rs index 33b57a6..a27c250 100644 --- a/src/api.rs +++ b/src/api.rs @@ -12,14 +12,26 @@ use std::io::IsTerminal; use serde_json::{Map, Value}; use crate::config::Profile; -use crate::http::{http_delete, http_get, http_patch, http_post, join_api}; +use crate::http::{http_delete, http_get, http_patch, http_post, http_send, join_api}; use crate::key::{load_config_if_present, no_key_error, resolve_api_key, resolve_base_url}; use crate::parse::{get_string_flag, ParsedArgs}; use crate::term; pub const FLAGS: &[&str] = &[ - "profile", "base-url", "config", "key", "json", "yes", "limit", "model", "status", "type", - "name", "data", "method", + "profile", + "base-url", + "config", + "key", + "json", + "yes", + "limit", + "model", + "status", + "type", + "name", + "data", + "method", + "idempotency-key", ]; /// (resource, description). Order is help order. @@ -141,13 +153,17 @@ fn run_inner(parsed: &ParsedArgs, env: &BTreeMap) -> Result Result< body.insert("slug".into(), Value::String(slug.into())); let name = get_string_flag(&parsed.flags, "name").unwrap_or_else(|| slug.into()); body.entry("name").or_insert(Value::String(name)); - if let Some(data) = get_string_flag(&parsed.flags, "data") { - body.insert("config".into(), parse_json(&data)?); + if let Some(data) = data_flag(parsed)? { + body.insert("config".into(), data); } if !body.contains_key("config") { return Err("presets create needs --data '', e.g. --data '{\"model\":\"anyrouter/auto\"}'".into()); @@ -415,8 +431,8 @@ fn presets(ctx: &Ctx, parsed: &ParsedArgs, verb: &str, rest: &[&str]) -> Result< "update" => { let slug = slug()?; let mut body = fields(&rest[1..])?; - if let Some(data) = get_string_flag(&parsed.flags, "data") { - body.insert("config".into(), parse_json(&data)?); + if let Some(data) = data_flag(parsed)? { + body.insert("config".into(), data); } if let Some(name) = get_string_flag(&parsed.flags, "name") { body.insert("name".into(), Value::String(name)); @@ -509,13 +525,19 @@ fn aliases( Ok(0) } -fn raw(ctx: &Ctx, method: &str, path: &str, body: Option<&str>) -> Result { +fn raw( + ctx: &Ctx, + method: &str, + path: &str, + body: Option<&str>, + headers: &[(String, String)], +) -> Result { let path = if path.starts_with("/v1/") || path == "/v1" { path.to_string() } else { format!("/v1{path}") }; - let (status, text) = request(ctx, method, &path, body, ctx.key.as_deref())?; + let (status, text) = request_with(ctx, method, &path, body, ctx.key.as_deref(), headers)?; let pretty = serde_json::from_str::(&text) .ok() .filter(|_| std::io::stdout().is_terminal()) @@ -532,14 +554,33 @@ fn raw(ctx: &Ctx, method: &str, path: &str, body: Option<&str>) -> Result, key: Option<&str>, +) -> Result<(u16, String), String> { + request_with(ctx, method, path, body, key, &[]) +} + +pub(crate) fn request_with( + ctx: &Ctx, + method: &str, + path: &str, + body: Option<&str>, + key: Option<&str>, + headers: &[(String, String)], ) -> Result<(u16, String), String> { let url = join_api(&ctx.base, path); + if !headers.is_empty() { + return match method { + "GET" | "POST" | "PUT" | "PATCH" | "DELETE" => { + http_send(method, &url, key, headers, body) + } + other => Err(format!("unsupported method {other}")), + }; + } match method { "GET" => http_get(&url, key), "POST" | "PUT" => http_post(&url, key, body), @@ -629,7 +670,7 @@ fn error_summary(status: u16, body: &str) -> String { } } -fn exit_for(status: u16) -> i32 { +pub(crate) fn exit_for(status: u16) -> i32 { match status { 401 | 403 => 4, 404 => 3, @@ -637,10 +678,11 @@ fn exit_for(status: u16) -> i32 { } } -/// `--data ''` wins; otherwise `k=v` words become a JSON object. +/// `--data ''`, `--data @file` or `--data -` (stdin) wins; otherwise +/// `k=v` words become a JSON object. fn body_from(parsed: &ParsedArgs, words: &[&str]) -> Result, String> { - if let Some(data) = get_string_flag(&parsed.flags, "data") { - return Ok(Some(parse_json(&data)?.to_string())); + if let Some(data) = data_flag(parsed)? { + return Ok(Some(data.to_string())); } let map = fields(words)?; Ok((!map.is_empty()).then(|| Value::Object(map).to_string())) @@ -672,6 +714,54 @@ fn fields(words: &[&str]) -> Result, String> { Ok(map) } +/// The `--data` value as JSON, reading `@file` or `-` (stdin) when asked. +fn data_flag(parsed: &ParsedArgs) -> Result, String> { + let Some(spec) = get_string_flag(&parsed.flags, "data") else { + return Ok(None); + }; + let text = if spec == "-" { + read_stdin()? + } else if let Some(path) = spec.strip_prefix('@') { + std::fs::read_to_string(path).map_err(|e| format!("cannot read {path}: {e}"))? + } else { + spec + }; + parse_json(&text).map(Some) +} + +pub(crate) fn read_stdin() -> Result { + use std::io::Read; + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .map_err(|e| format!("cannot read stdin: {e}"))?; + Ok(buf) +} + +/// Pull `-H key:value` pairs (repeatable) out of the positional words. +type Headers = Vec<(String, String)>; + +fn split_headers<'a>(words: &[&'a str]) -> Result<(Headers, Vec<&'a str>), String> { + let mut headers = Vec::new(); + let mut rest = Vec::new(); + let mut it = words.iter(); + while let Some(&w) = it.next() { + if w == "-H" { + let raw = it + .next() + .ok_or_else(|| format!("{USAGE}-H needs a header, e.g. -H 'X-Trace: 1'"))?; + let (k, v) = raw + .split_once(':') + .filter(|(k, _)| !k.trim().is_empty()) + .ok_or_else(|| format!("{USAGE}header must look like key:value, got \"{raw}\""))?; + headers.push((k.trim().to_string(), v.trim().to_string())); + } else { + rest.push(w); + } + } + Ok((headers, rest)) +} + fn parse_json(s: &str) -> Result { serde_json::from_str(s).map_err(|e| format!("invalid JSON ({e}): {s}")) } @@ -699,7 +789,7 @@ fn encode(s: &str) -> String { .collect() } -fn confirm(parsed: &ParsedArgs, question: &str) -> Result<(), String> { +pub(crate) fn confirm(parsed: &ParsedArgs, question: &str) -> Result<(), String> { if parsed.flag_true("yes") { return Ok(()); } @@ -844,14 +934,14 @@ fn show(ctx: &Ctx, value: Value, _hide: &[&str]) -> Result { pub fn help() -> String { let bin = crate::help::invoked_bin(); let mut out = format!( - "{bin} api — the AnyRouter dashboard from your terminal\n\nExamples\n $ {bin} api me\n $ {bin} api credits\n $ {bin} api keys create ci-bot\n $ {bin} api keys list --json\n $ {bin} api logs list --limit 20\n $ {bin} api presets create fast --data '{{\"model\":\"anyrouter/auto\"}}'\n $ {bin} api aliases set opus anthropic/claude-opus-4.5\n\nRaw requests (like `gh api`)\n $ {bin} api /credits\n $ {bin} api PATCH /keys/ disabled=true limit:=25\n\nResources\n" + "{bin} api — the AnyRouter dashboard from your terminal\n\nExamples\n $ {bin} api me\n $ {bin} api credits\n $ {bin} api keys create ci-bot\n $ {bin} api keys list --json\n $ {bin} api logs list --limit 20\n $ {bin} api presets create fast --data '{{\"model\":\"anyrouter/auto\"}}'\n $ {bin} api aliases set opus anthropic/claude-opus-4.5\n\nRaw requests (like `gh api`)\n $ {bin} api /credits\n $ {bin} api PATCH /keys/ disabled=true limit:=25\n $ {bin} api POST /apps/explain/submissions --data @body.json\n $ cat body.json | {bin} api POST /apps/explain/submissions --data - -H X-Trace:1\n\nResources\n" ); for (name, desc) in RESOURCES { let verbs: Vec<&str> = verbs(name).iter().map(|(v, _)| *v).collect(); out.push_str(&format!(" {name:<12}{desc} [{}]\n", verbs.join(" "))); } out.push_str( - "\nFlags\n --json Print server JSON (default when piped for get)\n --yes Skip confirmation on revoke/delete\n --limit Page size for list verbs\n --data JSON body for raw / presets\n --profile Use another account\n\nExit codes: 0 ok, 1 error, 2 usage, 3 not found, 4 auth/permission.\n", + "\nFlags\n --json Print server JSON (default when piped for get)\n --yes Skip confirmation on revoke/delete\n --limit Page size for list verbs\n --data JSON body: inline, @file, or - for stdin (raw / presets)\n -H key:value Extra request header on raw calls (repeatable)\n --idempotency-key Send an Idempotency-Key header on raw calls\n --profile Use another account\n\nExit codes: 0 ok, 1 error, 2 usage, 3 not found, 4 auth/permission.\n", ); out } diff --git a/src/cmd/dispatch.rs b/src/cmd/dispatch.rs index 5a2c417..6d05525 100644 --- a/src/cmd/dispatch.rs +++ b/src/cmd/dispatch.rs @@ -214,7 +214,8 @@ pub(crate) fn cmd_kind(command: &str) -> Option { "setup" | "login" | "auth" | "menu" | "models" | "config" | "keys" | "whoami" | "status" | "logout" | "account" | "usage" | "claude" | "codex" | "grok" | "opencode" | "pool" | "pi" | "upgrade" | "onboard" | "impl" | "plan" | "fix" | "deploy" | "cp" - | "relay" | "decision" | "commands" | "api" | "completion" | "byok" => CmdKind::Implemented, + | "relay" | "decision" | "commands" | "api" | "completion" | "byok" | "publish" + | "apps" => CmdKind::Implemented, "cursor" | "cline" | "windsurf" => CmdKind::HelpOnly, "chat" | "task" | "delegate" | "audit" | "logs" | "transactions" | "skills" | "prompt" => { CmdKind::Stub @@ -256,6 +257,14 @@ pub(crate) const COMMANDS: &[(&str, &str)] = &[ "api", "Dashboard from the terminal: keys, credits, presets…", ), + ( + "publish", + "Publish a JSON document to an app: [file|-]", + ), + ( + "apps", + "Manage published documents: schema, ls, rm, visibility", + ), ("decision", "Structured decisions (Decisions API)"), ("models", "List catalog and set the default"), ("model", "Alias of models"), @@ -447,6 +456,8 @@ pub(crate) fn allowed_flags(command: &str) -> Option<&'static [&'static str]> { "prompt" => &["base-url", "json"], "menu" => &["dump-tui", "config", "profile", "key", "base-url"], "api" => crate::api::FLAGS, + "publish" => crate::publish::PUBLISH_FLAGS, + "apps" => crate::publish::APPS_FLAGS, "completion" => &[], "onboard" | "impl" | "plan" | "fix" | "deploy" | "cp" => &["json", "copy"], "upgrade" => &[ @@ -756,6 +767,8 @@ mod tests { "pi", "pool", "api", + "publish", + "apps", "decision", "models", "usage", diff --git a/src/commands.rs b/src/commands.rs index f4ed83b..822bb32 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -226,6 +226,8 @@ fn dispatch( "cursor" | "cline" | "windsurf" => stub(command), "upgrade" | "update" => crate::upgrade::run(parsed, env), "api" => crate::api::run(parsed, env), + "publish" => crate::publish::run_publish(parsed, env), + "apps" => crate::publish::run_apps(parsed, env), "completion" => run_completion(parsed), "onboard" | "impl" | "plan" | "fix" | "deploy" | "cp" => { crate::onboard::run(command, parsed) diff --git a/src/completion.rs b/src/completion.rs index c36829d..18d3538 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -56,6 +56,7 @@ pub fn subcommands(command: &str) -> &'static [(&'static str, &'static str)] { ), ], "api" => crate::api::RESOURCES, + "apps" => crate::publish::APPS_VERBS, _ => &[], } } diff --git a/src/help.rs b/src/help.rs index 161912c..b9874cc 100644 --- a/src/help.rs +++ b/src/help.rs @@ -252,6 +252,8 @@ pub fn command_help(command: &str) -> Option { ), "commands" => commands_help(), "api" => crate::api::help(), + "publish" => crate::publish::publish_help(), + "apps" => crate::publish::apps_help(), "completion" => fill(&bin, COMPLETION), "prompt" => fill( &bin, diff --git a/src/http.rs b/src/http.rs index 6940e23..1cf6e18 100644 --- a/src/http.rs +++ b/src/http.rs @@ -104,6 +104,25 @@ pub fn http_post( into_status_body(req.send_string(json_body.unwrap_or("{}"))) } +/// Any method with extra request headers (`anyr api -H k:v`, Idempotency-Key). +#[cfg(feature = "native")] +pub fn http_send( + method: &str, + url: &str, + api_key: Option<&str>, + headers: &[(String, String)], + body: Option<&str>, +) -> Result<(u16, String), String> { + let mut req = with_auth(agent().request(method, url), api_key); + for (k, v) in headers { + req = req.set(k, v); + } + match body { + Some(b) => into_status_body(req.set("Content-Type", "application/json").send_string(b)), + None => into_status_body(req.call()), + } +} + #[cfg(feature = "native")] pub fn http_delete(url: &str, api_key: Option<&str>) -> Result<(u16, String), String> { into_status_body(with_auth(agent().delete(url), api_key).call()) @@ -166,6 +185,17 @@ pub fn http_post( no_network() } +#[cfg(not(feature = "native"))] +pub fn http_send( + _method: &str, + _url: &str, + _api_key: Option<&str>, + _headers: &[(String, String)], + _body: Option<&str>, +) -> Result<(u16, String), String> { + no_network() +} + #[cfg(not(feature = "native"))] pub fn http_delete(_url: &str, _api_key: Option<&str>) -> Result<(u16, String), String> { no_network() diff --git a/src/lib.rs b/src/lib.rs index bdf7f0c..9c4e831 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,8 +15,10 @@ pub mod install; pub mod key; pub mod onboard; pub mod parse; +pub mod publish; #[cfg(feature = "native")] pub mod relay; +pub mod schema; pub mod spawn; pub mod spinner; pub mod term; diff --git a/src/parse.rs b/src/parse.rs index 61c600a..15b40e5 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -38,6 +38,10 @@ pub static VALUE_FLAGS: LazyLock> = LazyLock::new(|| { "max-concurrency", "state", "questions", + "data", + "method", + "idempotency-key", + "visibility", ]) }); diff --git a/src/publish.rs b/src/publish.rs new file mode 100644 index 0000000..c3b0e76 --- /dev/null +++ b/src/publish.rs @@ -0,0 +1,475 @@ +//! `anyr publish` and `anyr apps` — send structured documents to any +//! AnyRouter app that accepts submissions. +//! +//! The CLI knows no app by name. Each app serves its own JSON Schema at +//! `GET /v1/apps//schema`; `publish` validates against it locally, then +//! posts to `/v1/apps//submissions`. A new app needs no change here. + +use std::collections::BTreeMap; +use std::io::IsTerminal; + +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +use crate::api::{confirm, context, exit_for, read_stdin, request, request_with, Ctx}; +use crate::cmd::dispatch::USAGE; +use crate::key::no_key_error; +use crate::parse::{get_string_flag, ParsedArgs}; +use crate::{schema, term}; + +pub const PUBLISH_FLAGS: &[&str] = &[ + "profile", + "base-url", + "config", + "key", + "json", + "yes", + "visibility", + "dry-run", +]; +pub const APPS_FLAGS: &[&str] = &["profile", "base-url", "config", "key", "json", "yes"]; + +pub const APPS_VERBS: &[(&str, &str)] = &[ + ( + "schema", + "Print an app's JSON Schema, instructions and example", + ), + ("ls", "List your submissions: "), + ("rm", "Delete a submission: "), + ( + "visibility", + "Change who can see it: private|unlisted|public", + ), +]; + +const VISIBILITIES: &[&str] = &["unlisted", "private", "public"]; + +pub fn publish_help() -> String { + let bin = crate::help::invoked_bin(); + format!( + "{bin} publish — send a JSON document to an AnyRouter app\n\n\ +Usage:\n {bin} publish [file|-] [--visibility unlisted|private|public] [--yes] [--dry-run] [--json]\n\n\ +Reads the document from , or from stdin with `-` (or when piped with no file).\n\ +It is checked locally against the app's published JSON Schema first; problems are\n\ +listed by field and nothing is uploaded (exit 2). On success the URL is printed.\n\n\ +Examples\n $ {bin} apps schema explain > schema.json # what an agent must produce\n \ +$ | {bin} publish explain -\n \ +$ {bin} publish explain fig.json --visibility public --yes\n \ +$ {bin} publish explain fig.json --dry-run\n\n\ +Visibility: unlisted (default; link works, not listed), private (only you),\n\ +public (listed after review). Re-running the same document is safe: it carries a\n\ +stable Idempotency-Key, so the server returns the original instead of a copy.\n\n\ +Exit codes: 0 ok, 1 error, 2 usage or invalid document, 3 not found, 4 auth.\n" + ) +} + +pub fn apps_help() -> String { + let bin = crate::help::invoked_bin(); + let mut out = format!( + "{bin} apps — inspect and manage what you published\n\nUsage:\n {bin} apps [id] [--json]\n\nVerbs\n" + ); + for (v, d) in APPS_VERBS { + out.push_str(&format!(" {v:<11} {d}\n")); + } + out.push_str(&format!( + "\nExamples\n $ {bin} apps schema explain\n $ {bin} apps ls explain\n $ {bin} apps visibility explain k3f9a2 private\n $ {bin} apps rm explain k3f9a2 --yes\n" + )); + out +} + +/// Stable per-document key: key-order of the input does not matter, a change +/// of app or content does. +pub fn idempotency_key(app: &str, payload: &Value) -> String { + let mut h = Sha256::new(); + h.update(app.as_bytes()); + h.update([0]); + h.update(payload.to_string().as_bytes()); + let hex: String = h.finalize().iter().map(|b| format!("{b:02x}")).collect(); + format!("anyr-{}", &hex[..32]) +} + +fn usage(msg: impl std::fmt::Display) -> String { + format!("{USAGE}{} {msg}", term::err("error:")) +} + +fn app_arg(raw: Option<&&str>, bin_usage: &str) -> Result { + let app = raw.ok_or_else(|| { + usage(format!( + "missing \n{} {bin_usage}", + term::dim("usage:") + )) + })?; + if app.is_empty() + || !app + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + { + return Err(usage(format!( + "app names are lowercase letters, digits and dashes, got \"{app}\"" + ))); + } + Ok(app.to_string()) +} + +fn visibility_arg(v: &str) -> Result<&str, String> { + VISIBILITIES + .iter() + .copied() + .find(|x| *x == v) + .ok_or_else(|| { + usage(format!( + "visibility must be one of {}, got \"{v}\"", + VISIBILITIES.join(", ") + )) + }) +} + +/// Server message for a failed call, plus the exit code it maps to. +fn failure(status: u16, text: &str, what: &str) -> (String, i32) { + let v: Value = serde_json::from_str(text).unwrap_or(Value::Null); + let msg = v + .pointer("/error/message") + .or_else(|| v.get("error")) + .or_else(|| v.get("message")) + .and_then(Value::as_str) + .map(str::to_string) + .unwrap_or_else(|| { + text.split_whitespace() + .take(24) + .collect::>() + .join(" ") + }); + let hint = match status { + 401 => format!( + "\n{} sign in again: {} login", + term::dim("hint:"), + crate::help::invoked_bin() + ), + 403 => format!("\n{} this key cannot use this endpoint", term::dim("hint:")), + _ => String::new(), + }; + ( + format!("{} HTTP {status} {what}: {msg}{hint}", term::err("error:")), + exit_for(status), + ) +} + +/// One call. `Ok(Ok(json))` on 2xx, `Ok(Err(exit))` after printing the failure. +fn call( + ctx: &Ctx, + method: &str, + path: &str, + body: Option<&str>, + headers: &[(String, String)], + authed: bool, +) -> Result, String> { + let key = if authed { + Some(ctx.key.as_deref().ok_or_else(no_key_error)?) + } else { + ctx.key.as_deref() + }; + let (status, text) = if headers.is_empty() { + request(ctx, method, path, body, key)? + } else { + request_with(ctx, method, path, body, key, headers)? + }; + if (200..300).contains(&status) { + let v = serde_json::from_str(&text).unwrap_or(Value::Null); + return Ok(Ok((status, v))); + } + let (msg, code) = failure(status, &text, &format!("{method} {path}")); + eprintln!("{msg}"); + Ok(Err(code)) +} + +fn read_document(source: Option<&&str>) -> Result { + let text = match source { + Some(&"-") => read_stdin()?, + Some(path) => { + std::fs::read_to_string(path).map_err(|e| usage(format!("cannot read {path}: {e}")))? + } + None if !std::io::stdin().is_terminal() => read_stdin()?, + None => { + return Err(usage( + "no document: pass a , or pipe JSON and use `-`", + )) + } + }; + serde_json::from_str(&text).map_err(|e| usage(format!("input is not valid JSON: {e}"))) +} + +fn site_origin(base: &str) -> String { + let b = base.trim_end_matches('/'); + b.strip_suffix("/api").unwrap_or(b).to_string() +} + +fn size(bytes: usize) -> String { + format!("{:.1} KiB", bytes as f64 / 1024.0) +} + +pub fn run_publish(parsed: &ParsedArgs, env: &BTreeMap) -> Result { + crate::cmd::dispatch::usage_exit(publish_inner(parsed, env)) +} + +fn publish_inner(parsed: &ParsedArgs, env: &BTreeMap) -> Result { + let args: Vec<&str> = parsed.passthrough.iter().map(String::as_str).collect(); + let bin = crate::help::invoked_bin(); + let app = app_arg(args.first(), &format!("{bin} publish [file|-]"))?; + let visibility = get_string_flag(&parsed.flags, "visibility") + .map(|v| visibility_arg(&v).map(str::to_string)) + .transpose()?; + let payload = read_document(args.get(1))?; + let ctx = context(parsed, env); + + let schema_path = format!("/v1/apps/{app}/schema"); + let spec = match call(&ctx, "GET", &schema_path, None, &[], false)? { + Ok((_, v)) => v, + Err(code) => return Ok(code), + }; + let json_schema = spec.get("json_schema").ok_or_else(|| { + format!( + "{} {schema_path} returned no json_schema", + term::err("error:") + ) + })?; + let problems = schema::validate(json_schema, &payload); + let bytes = payload.to_string().len(); + if !problems.is_empty() { + for p in &problems { + eprintln!(" {p}"); + } + eprintln!( + "{} document does not match the {app} schema ({} problem{}); nothing uploaded.\n{} {bin} apps schema {app}", + term::err("error:"), + problems.len(), + if problems.len() == 1 { "" } else { "s" }, + term::dim("see:"), + ); + return Ok(2); + } + if parsed.flag_true("dry-run") { + let version = spec + .get("version") + .map(|v| { + format!( + " v{}", + v.as_str() + .map(str::to_string) + .unwrap_or_else(|| v.to_string()) + ) + }) + .unwrap_or_default(); + println!( + "ok: valid against {app}{version} ({}); not uploaded", + size(bytes) + ); + return Ok(0); + } + if visibility.as_deref() == Some("public") { + confirm( + parsed, + "Publish publicly? It is listed after review and anyone can open it.", + )?; + } + + let mut body = json!({ "payload": payload }); + if let Some(v) = &visibility { + body["visibility"] = json!(v); + } + let headers = [( + "Idempotency-Key".to_string(), + idempotency_key(&app, &payload), + )]; + let path = format!("/v1/apps/{app}/submissions"); + let (status, out) = match call(&ctx, "POST", &path, Some(&body.to_string()), &headers, true)? { + Ok(r) => r, + Err(code) => return Ok(code), + }; + if parsed.flag_true("json") { + println!("{}", serde_json::to_string_pretty(&out).unwrap_or_default()); + return Ok(0); + } + let url = out + .get("url") + .and_then(Value::as_str) + .ok_or_else(|| format!("{} server reply had no url", term::err("error:")))?; + let url = if url.starts_with('/') { + format!("{}{url}", site_origin(&ctx.base)) + } else { + url.to_string() + }; + println!("{url}"); + if status == 200 { + eprintln!( + "{}", + term::dim("already published (same content); reusing the original") + ); + let got = out.get("visibility").and_then(Value::as_str); + if let (Some(want), Some(got)) = (visibility.as_deref(), got) { + if want != got { + eprintln!( + "{} it is {got}, not {want}: change it with `{bin} apps visibility {app} {want}`", + term::dim("note:") + ); + } + } + } + if out.get("status").and_then(Value::as_str) == Some("pending_review") { + eprintln!( + "{}", + term::dim("pending review before it is listed publicly") + ); + } + Ok(0) +} + +pub fn run_apps(parsed: &ParsedArgs, env: &BTreeMap) -> Result { + crate::cmd::dispatch::usage_exit(apps_inner(parsed, env)) +} + +fn apps_inner(parsed: &ParsedArgs, env: &BTreeMap) -> Result { + let args: Vec<&str> = parsed.passthrough.iter().map(String::as_str).collect(); + let bin = crate::help::invoked_bin(); + let Some(verb) = args.first().copied() else { + print!("{}", apps_help()); + return Ok(0); + }; + let verb = match verb { + "list" => "ls", + "delete" => "rm", + "show" => "schema", + other => other, + }; + if !APPS_VERBS.iter().any(|(v, _)| *v == verb) { + return Err(usage(format!( + "unknown verb \"{verb}\"\n{} one of: {}", + term::dim("hint:"), + APPS_VERBS + .iter() + .map(|(v, _)| *v) + .collect::>() + .join(", ") + ))); + } + let app = app_arg(args.get(1), &format!("{bin} apps {verb} "))?; + let ctx = context(parsed, env); + let id = |i: usize| -> Result<&str, String> { + args.get(i) + .copied() + .filter(|s| { + !s.is_empty() + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + }) + .ok_or_else(|| { + usage(format!( + "missing \n{} {bin} apps {verb} ", + term::dim("usage:") + )) + }) + }; + let base = format!("/v1/apps/{app}"); + match verb { + "schema" => { + let (_, v) = match call(&ctx, "GET", &format!("{base}/schema"), None, &[], false)? { + Ok(r) => r, + Err(code) => return Ok(code), + }; + println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); + Ok(0) + } + "ls" => { + let (_, v) = match call( + &ctx, + "GET", + &format!("{base}/submissions?mine=1"), + None, + &[], + true, + )? { + Ok(r) => r, + Err(code) => return Ok(code), + }; + crate::api::list(&ctx, v, &["id", "visibility", "status", "title", "url"]) + } + "rm" => { + let id = id(2)?; + confirm( + parsed, + &format!("Delete {app}/{id}? This cannot be undone."), + )?; + match call( + &ctx, + "DELETE", + &format!("{base}/submissions/{id}"), + None, + &[], + true, + )? { + Ok(_) => { + eprintln!("deleted {app}/{id}"); + Ok(0) + } + Err(code) => Ok(code), + } + } + _ => { + let id = id(2)?; + let want = args.get(3).ok_or_else(|| { + usage(format!("missing \n{} {bin} apps visibility private|unlisted|public", term::dim("usage:"))) + })?; + let want = visibility_arg(want)?; + let body = json!({ "visibility": want }).to_string(); + match call( + &ctx, + "PATCH", + &format!("{base}/submissions/{id}"), + Some(&body), + &[], + true, + )? { + Ok((_, v)) => { + if parsed.flag_true("json") { + println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default()); + } else { + eprintln!("{app}/{id} is now {want}"); + } + Ok(0) + } + Err(code) => Ok(code), + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn idempotency_key_ignores_key_order_but_not_content_or_app() { + let a = idempotency_key("x", &json!({"a": 1, "b": [1, 2]})); + let b = idempotency_key("x", &serde_json::from_str(r#"{"b":[1,2],"a":1}"#).unwrap()); + assert_eq!(a, b); + assert_ne!(a, idempotency_key("y", &json!({"a": 1, "b": [1, 2]}))); + assert_ne!(a, idempotency_key("x", &json!({"a": 2, "b": [1, 2]}))); + assert!(a.starts_with("anyr-") && a.len() == 37, "{a}"); + } + + #[test] + fn app_names_are_path_safe() { + assert!(app_arg(Some(&"my-app2"), "u").is_ok()); + for bad in ["../x", "A", "a/b", ""] { + assert!(app_arg(Some(&bad), "u").is_err(), "{bad}"); + } + assert!(app_arg(None, "u").is_err()); + } + + #[test] + fn site_origin_drops_the_api_prefix() { + assert_eq!( + site_origin("https://anyrouter.dev/api/"), + "https://anyrouter.dev" + ); + } +} diff --git a/src/schema.rs b/src/schema.rs new file mode 100644 index 0000000..a804111 --- /dev/null +++ b/src/schema.rs @@ -0,0 +1,320 @@ +//! A small JSON Schema checker for `anyr publish`. +//! +//! It covers what a typical zod-generated schema uses: `type`, `enum`, +//! `const`, `properties`, `required`, `additionalProperties`, `items`, +//! `prefixItems`, size bounds, `anyOf` / `oneOf` / `allOf` / `not` and local +//! `$ref`. `pattern` and `format` are ignored here; the server stays the +//! authority and re-checks every submission. Output is one human line per +//! problem, each naming the JSON path. + +use serde_json::Value; + +/// Every way `value` breaks `schema`, as `path: message` lines. Empty = valid. +pub fn validate(schema: &Value, value: &Value) -> Vec { + let mut errors = Vec::new(); + check(schema, schema, value, "$", 0, &mut errors); + errors +} + +const MAX_DEPTH: usize = 64; + +fn check( + root: &Value, + schema: &Value, + value: &Value, + path: &str, + depth: usize, + out: &mut Vec, +) { + if depth > MAX_DEPTH { + out.push(format!("{path}: nested too deeply")); + return; + } + let Some(obj) = schema.as_object() else { + if schema == &Value::Bool(false) { + out.push(format!("{path}: not allowed")); + } + return; + }; + if let Some(target) = obj + .get("$ref") + .and_then(Value::as_str) + .and_then(|r| r.strip_prefix('#')) + { + match root.pointer(target) { + Some(resolved) => check(root, resolved, value, path, depth + 1, out), + None => out.push(format!("{path}: schema reference #{target} not found")), + } + return; + } + + if let Some(t) = obj.get("type") { + let names: Vec<&str> = match t { + Value::String(s) => vec![s.as_str()], + Value::Array(a) => a.iter().filter_map(Value::as_str).collect(), + _ => vec![], + }; + if !names.is_empty() && !names.iter().any(|n| type_matches(n, value)) { + out.push(format!( + "{path}: expected {}, got {}", + names.join(" or "), + type_name(value) + )); + return; + } + } + if let Some(Value::Array(options)) = obj.get("enum") { + if !options.contains(value) { + out.push(format!("{path}: must be one of {}", list(options))); + } + } + if let Some(c) = obj.get("const") { + if c != value { + out.push(format!("{path}: must equal {c}")); + } + } + + match value { + Value::Object(map) => { + if let Some(Value::Array(req)) = obj.get("required") { + for k in req.iter().filter_map(Value::as_str) { + if !map.contains_key(k) { + out.push(format!("{path}.{k}: required")); + } + } + } + let props = obj.get("properties").and_then(Value::as_object); + for (k, v) in map { + let child = format!("{path}.{k}"); + if let Some(sub) = props.and_then(|p| p.get(k)) { + check(root, sub, v, &child, depth + 1, out); + } else { + match obj.get("additionalProperties") { + Some(Value::Bool(false)) => out.push(format!("{child}: unknown field")), + Some(sub @ Value::Object(_)) => check(root, sub, v, &child, depth + 1, out), + _ => {} + } + } + } + } + Value::Array(items) => { + bound( + obj, + "minItems", + items.len() as f64, + path, + "items", + true, + out, + ); + bound( + obj, + "maxItems", + items.len() as f64, + path, + "items", + false, + out, + ); + let prefix = obj.get("prefixItems").and_then(Value::as_array); + for (i, v) in items.iter().enumerate() { + let child = format!("{path}[{i}]"); + if let Some(sub) = prefix.and_then(|p| p.get(i)) { + check(root, sub, v, &child, depth + 1, out); + } else if let Some(sub) = obj.get("items") { + check(root, sub, v, &child, depth + 1, out); + } + } + } + Value::String(s) => { + let n = s.chars().count() as f64; + bound(obj, "minLength", n, path, "characters", true, out); + bound(obj, "maxLength", n, path, "characters", false, out); + } + Value::Number(n) => { + let x = n.as_f64().unwrap_or(f64::NAN); + num(obj, "minimum", x, path, |x, l| x < l, "at least", out); + num(obj, "maximum", x, path, |x, l| x > l, "at most", out); + num( + obj, + "exclusiveMinimum", + x, + path, + |x, l| x <= l, + "greater than", + out, + ); + num( + obj, + "exclusiveMaximum", + x, + path, + |x, l| x >= l, + "less than", + out, + ); + } + _ => {} + } + + if let Some(Value::Array(all)) = obj.get("allOf") { + for sub in all { + check(root, sub, value, path, depth + 1, out); + } + } + if let Some(Value::Array(any)) = obj.get("anyOf") { + if !any.iter().any(|s| passes(root, s, value, path, depth)) { + out.push(format!("{path}: matches none of the allowed shapes")); + } + } + if let Some(Value::Array(one)) = obj.get("oneOf") { + let hits = one + .iter() + .filter(|s| passes(root, s, value, path, depth)) + .count(); + if hits != 1 { + out.push(format!( + "{path}: must match exactly one allowed shape, matches {hits}" + )); + } + } + if let Some(sub) = obj.get("not") { + if passes(root, sub, value, path, depth) { + out.push(format!("{path}: matches a disallowed shape")); + } + } +} + +fn passes(root: &Value, schema: &Value, value: &Value, path: &str, depth: usize) -> bool { + let mut scratch = Vec::new(); + check(root, schema, value, path, depth + 1, &mut scratch); + scratch.is_empty() +} + +fn bound( + obj: &serde_json::Map, + key: &str, + got: f64, + path: &str, + unit: &str, + is_min: bool, + out: &mut Vec, +) { + if let Some(limit) = obj.get(key).and_then(Value::as_f64) { + if (is_min && got < limit) || (!is_min && got > limit) { + let word = if is_min { "at least" } else { "at most" }; + out.push(format!("{path}: needs {word} {limit} {unit}, has {got}")); + } + } +} + +fn num( + obj: &serde_json::Map, + key: &str, + x: f64, + path: &str, + bad: fn(f64, f64) -> bool, + word: &str, + out: &mut Vec, +) { + if let Some(limit) = obj.get(key).and_then(Value::as_f64) { + if bad(x, limit) { + out.push(format!("{path}: must be {word} {limit}, got {x}")); + } + } +} + +fn type_matches(name: &str, v: &Value) -> bool { + match name { + "object" => v.is_object(), + "array" => v.is_array(), + "string" => v.is_string(), + "boolean" => v.is_boolean(), + "null" => v.is_null(), + "number" => v.is_number(), + "integer" => v.is_i64() || v.is_u64() || v.as_f64().is_some_and(|f| f.fract() == 0.0), + _ => true, + } +} + +fn type_name(v: &Value) -> &'static str { + match v { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +fn list(options: &[Value]) -> String { + options + .iter() + .map(Value::to_string) + .collect::>() + .join(", ") +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn schema() -> Value { + json!({ + "type": "object", + "required": ["title", "parts"], + "additionalProperties": false, + "properties": { + "title": {"type": "string", "minLength": 2, "maxLength": 10}, + "kind": {"enum": ["a", "b"]}, + "parts": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/part"}} + }, + "$defs": {"part": {"type": "object", "required": ["n"], "properties": {"n": {"type": "integer", "minimum": 1}}}} + }) + } + + #[test] + fn accepts_a_valid_document() { + let v = json!({"title": "Lock", "kind": "a", "parts": [{"n": 2}]}); + assert!(validate(&schema(), &v).is_empty()); + } + + #[test] + fn reports_every_problem_with_its_path() { + let v = json!({"title": "x", "kind": "z", "extra": 1, "parts": [{"n": 0}, {}]}); + let errs = validate(&schema(), &v); + let all = errs.join("\n"); + assert!( + all.contains("$.title: needs at least 2 characters"), + "{all}" + ); + assert!(all.contains("$.kind: must be one of"), "{all}"); + assert!(all.contains("$.extra: unknown field"), "{all}"); + assert!(all.contains("$.parts[0].n: must be at least 1"), "{all}"); + assert!(all.contains("$.parts[1].n: required"), "{all}"); + } + + #[test] + fn type_mismatch_stops_descending() { + let errs = validate(&schema(), &json!("nope")); + assert_eq!(errs, vec!["$: expected object, got string".to_string()]); + } + + #[test] + fn any_of_and_nullable_types() { + let s = json!({"anyOf": [{"type": "string"}, {"type": "null"}]}); + assert!(validate(&s, &json!(null)).is_empty()); + assert_eq!(validate(&s, &json!(3)).len(), 1); + let t = json!({"type": ["integer", "null"]}); + assert!(validate(&t, &json!(4.0)).is_empty()); + assert!(!validate(&t, &json!(4.5)).is_empty()); + } + + #[test] + fn dangling_ref_is_reported_not_ignored() { + let errs = validate(&json!({"$ref": "#/$defs/missing"}), &json!(1)); + assert!(errs[0].contains("not found"), "{errs:?}"); + } +} diff --git a/tests/publish.rs b/tests/publish.rs new file mode 100644 index 0000000..d70b06b --- /dev/null +++ b/tests/publish.rs @@ -0,0 +1,376 @@ +//! `anyr publish`, `anyr apps` and `anyr api --data/-H` end to end against a +//! local HTTP server, so nothing touches anyrouter.dev. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::net::TcpListener; +use std::process::{Command, Stdio}; +use std::sync::mpsc; + +const SCHEMA: &str = r#"{"app":"demo","version":"1","json_schema":{"type":"object","required":["title"],"additionalProperties":false,"properties":{"title":{"type":"string","minLength":2},"steps":{"type":"array","items":{"type":"integer"}}}}}"#; + +fn cli(base: &str, args: &[&str]) -> Command { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_anyr")); + cmd.env("ANYR_NO_UPDATE", "1") + .env("ANYR_NO_CATALOG", "1") + .env("NO_COLOR", "1") + .env( + "ANYROUTER_HOME", + std::env::temp_dir().join("anyr-publish-test-empty"), + ) + .args(args) + .args(["--base-url", base, "--key", "sk-ar-test"]); + cmd +} + +/// Answers `replies` in order, one connection each. Each request is reported +/// as "METHOD PATH\nheaders\nbody". +fn serve(replies: Vec<(u16, &'static str)>) -> (String, mpsc::Receiver) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}/api", listener.local_addr().unwrap()); + let (tx, rx) = mpsc::channel(); + std::thread::spawn(move || { + for (status, body) in replies { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + let mut reader = BufReader::new(stream.try_clone().unwrap()); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let mut headers = String::new(); + let mut len = 0usize; + loop { + let mut h = String::new(); + reader.read_line(&mut h).unwrap(); + if h.trim().is_empty() { + break; + } + if let Some(v) = h.to_ascii_lowercase().strip_prefix("content-length:") { + len = v.trim().parse().unwrap_or(0); + } + headers.push_str(&h.to_ascii_lowercase()); + } + let mut b = vec![0u8; len]; + reader.read_exact(&mut b).unwrap(); + let mut p = line.split_whitespace(); + let summary = format!( + "{} {}\n{}\n{}", + p.next().unwrap_or(""), + p.next().unwrap_or(""), + headers, + String::from_utf8_lossy(&b) + ); + let resp = format!( + "HTTP/1.1 {status} X\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(resp.as_bytes()).unwrap(); + tx.send(summary).unwrap(); + } + }); + (base, rx) +} + +fn run_with_stdin(mut cmd: Command, input: &str) -> (i32, String, String) { + let mut child = cmd + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(input.as_bytes()) + .unwrap(); + let out = child.wait_with_output().unwrap(); + ( + out.status.code().unwrap_or(1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +fn run(mut cmd: Command) -> (i32, String, String) { + let out = cmd.stdin(Stdio::null()).output().unwrap(); + ( + out.status.code().unwrap_or(1), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + ) +} + +fn idem(seen: &str) -> String { + seen.lines() + .find_map(|l| l.strip_prefix("idempotency-key:")) + .unwrap_or("") + .trim() + .to_string() +} + +const CREATED: &str = r#"{"id":"k3f9a2","url":"https://anyrouter.dev/apps/demo/s/k3f9a2","status":"accepted","visibility":"unlisted"}"#; + +#[test] +fn publish_from_stdin_validates_then_posts_and_prints_the_url() { + let (base, rx) = serve(vec![(200, SCHEMA), (201, CREATED)]); + let cmd = cli(&base, &["publish", "demo", "-"]); + let (code, out, err) = run_with_stdin(cmd, r#"{"title":"Lock","steps":[1,2]}"#); + assert_eq!(code, 0, "{err}"); + assert_eq!(out.trim(), "https://anyrouter.dev/apps/demo/s/k3f9a2"); + assert!(rx + .recv() + .unwrap() + .starts_with("GET /api/v1/apps/demo/schema")); + let post = rx.recv().unwrap(); + assert!( + post.starts_with("POST /api/v1/apps/demo/submissions"), + "{post}" + ); + assert!(post.contains("authorization: bearer sk-ar-test"), "{post}"); + assert!( + post.ends_with(r#"{"payload":{"steps":[1,2],"title":"Lock"}}"#), + "{post}" + ); +} + +#[test] +fn idempotency_key_is_the_same_for_the_same_document_in_any_key_order() { + let mut keys = Vec::new(); + for doc in [ + r#"{"title":"Lock","steps":[1]}"#, + r#"{"steps":[1],"title":"Lock"}"#, + r#"{"title":"Other"}"#, + ] { + let (base, rx) = serve(vec![(200, SCHEMA), (201, CREATED)]); + let cmd = cli(&base, &["publish", "demo", "-"]); + let (code, _, err) = run_with_stdin(cmd, doc); + assert_eq!(code, 0, "{err}"); + rx.recv().unwrap(); + keys.push(idem(&rx.recv().unwrap())); + } + // WHY: a retry or rerun must be deduplicated by the server, not duplicated. + assert!(keys[0].starts_with("anyr-") && !keys[0].is_empty()); + assert_eq!(keys[0], keys[1]); + assert_ne!(keys[0], keys[2]); +} + +#[test] +fn invalid_document_exits_2_with_field_errors_and_never_uploads() { + let (base, rx) = serve(vec![(200, SCHEMA)]); + let cmd = cli(&base, &["publish", "demo", "-"]); + let (code, out, err) = run_with_stdin(cmd, r#"{"title":"x","steps":["a"],"extra":1}"#); + assert_eq!(code, 2, "{err}"); + assert!(out.is_empty()); + assert!( + err.contains("$.title: needs at least 2 characters"), + "{err}" + ); + assert!( + err.contains("$.steps[0]: expected integer, got string"), + "{err}" + ); + assert!(err.contains("$.extra: unknown field"), "{err}"); + rx.recv().unwrap(); + assert!(rx.try_recv().is_err(), "a POST was sent"); +} + +#[test] +fn non_json_input_is_a_usage_error() { + let (base, _rx) = serve(vec![]); + let cmd = cli(&base, &["publish", "demo", "-"]); + let (code, _, err) = run_with_stdin(cmd, "not json"); + assert_eq!(code, 2); + assert!(err.contains("not valid JSON"), "{err}"); +} + +#[test] +fn dry_run_reads_a_file_and_does_not_upload() { + let dir = std::env::temp_dir().join("anyr-publish-dry"); + std::fs::create_dir_all(&dir).unwrap(); + let file = dir.join("doc.json"); + std::fs::write(&file, r#"{"title":"Lock"}"#).unwrap(); + let (base, rx) = serve(vec![(200, SCHEMA)]); + let cmd = cli( + &base, + &["publish", "demo", file.to_str().unwrap(), "--dry-run"], + ); + let (code, out, err) = run(cmd); + assert_eq!(code, 0, "{err}"); + assert!(out.starts_with("ok: valid against demo v1"), "{out}"); + rx.recv().unwrap(); + assert!(rx.try_recv().is_err()); +} + +#[test] +fn unknown_app_exits_3() { + let (base, _rx) = serve(vec![(404, r#"{"error":{"message":"unknown app"}}"#)]); + let cmd = cli(&base, &["publish", "nope", "-"]); + let (code, _, err) = run_with_stdin(cmd, "{}"); + assert_eq!(code, 3, "{err}"); + assert!(err.contains("unknown app"), "{err}"); +} + +#[test] +fn public_needs_yes_when_not_interactive_and_sends_visibility_with_it() { + let (base, _rx) = serve(vec![(200, SCHEMA)]); + let cmd = cli(&base, &["publish", "demo", "-", "--visibility", "public"]); + let (code, _, err) = run_with_stdin(cmd, r#"{"title":"Lock"}"#); + assert_eq!(code, 2, "{err}"); + assert!(err.contains("--yes"), "{err}"); + + let (base, rx) = serve(vec![ + (200, SCHEMA), + ( + 201, + r#"{"id":"a","url":"/apps/demo/s/a","status":"pending_review","visibility":"public"}"#, + ), + ]); + let cmd = cli( + &base, + &["publish", "demo", "-", "--visibility", "public", "--yes"], + ); + let (code, out, err) = run_with_stdin(cmd, r#"{"title":"Lock"}"#); + assert_eq!(code, 0, "{err}"); + // relative URL from the server is made absolute against the site origin + assert!( + out.trim().ends_with("/apps/demo/s/a") && out.starts_with("http://"), + "{out}" + ); + assert!(err.contains("pending review"), "{err}"); + rx.recv().unwrap(); + assert!(rx.recv().unwrap().contains(r#""visibility":"public""#)); +} + +#[test] +fn bad_visibility_is_a_usage_error() { + let (base, _rx) = serve(vec![]); + let cmd = cli(&base, &["publish", "demo", "-", "--visibility", "everyone"]); + let (code, _, err) = run_with_stdin(cmd, "{}"); + assert_eq!(code, 2); + assert!(err.contains("visibility must be one of"), "{err}"); +} + +#[test] +fn apps_verbs_hit_the_generic_routes() { + let (base, rx) = serve(vec![(200, SCHEMA)]); + let cmd = cli(&base, &["apps", "schema", "demo"]); + let (code, out, err) = run(cmd); + assert_eq!(code, 0, "{err}"); + assert!(out.contains("\"json_schema\""), "{out}"); + assert!(rx + .recv() + .unwrap() + .starts_with("GET /api/v1/apps/demo/schema")); + + let (base, rx) = serve(vec![( + 200, + r#"{"data":[{"id":"k1","visibility":"unlisted","status":"accepted","title":"Lock","url":"u"}]}"#, + )]); + let cmd = cli(&base, &["apps", "ls", "demo"]); + let (code, out, err) = run(cmd); + assert_eq!(code, 0, "{err}"); + assert_eq!(out.trim(), "k1\tunlisted\taccepted\tLock\tu"); + assert!(rx + .recv() + .unwrap() + .starts_with("GET /api/v1/apps/demo/submissions?mine=1")); + + let (base, rx) = serve(vec![(200, r#"{"id":"k1","visibility":"private"}"#)]); + let cmd = cli(&base, &["apps", "visibility", "demo", "k1", "private"]); + let (code, _, err) = run(cmd); + assert_eq!(code, 0, "{err}"); + let seen = rx.recv().unwrap(); + assert!( + seen.starts_with("PATCH /api/v1/apps/demo/submissions/k1"), + "{seen}" + ); + assert!(seen.ends_with(r#"{"visibility":"private"}"#), "{seen}"); + + let (base, rx) = serve(vec![(204, "")]); + let cmd = cli(&base, &["apps", "rm", "demo", "k1", "--yes"]); + let (code, _, err) = run(cmd); + assert_eq!(code, 0, "{err}"); + assert!(rx + .recv() + .unwrap() + .starts_with("DELETE /api/v1/apps/demo/submissions/k1")); +} + +#[test] +fn apps_rm_without_yes_refuses_when_not_interactive() { + let (base, rx) = serve(vec![]); + let cmd = cli(&base, &["apps", "rm", "demo", "k1"]); + let (code, _, err) = run(cmd); + assert_eq!(code, 2); + assert!(err.contains("--yes"), "{err}"); + assert!(rx.try_recv().is_err()); +} + +#[test] +fn api_data_reads_stdin_and_at_file_and_sends_headers() { + let (base, rx) = serve(vec![(201, "{}")]); + let cmd = cli( + &base, + &[ + "api", + "POST", + "/apps/demo/submissions", + "--data", + "-", + "-H", + "X-Trace: 7", + "--idempotency-key", + "abc", + ], + ); + let (code, _, err) = run_with_stdin(cmd, r#"{"payload":{"title":"Lock"}}"#); + assert_eq!(code, 0, "{err}"); + let seen = rx.recv().unwrap(); + assert!(seen.contains("x-trace: 7"), "{seen}"); + assert!(seen.contains("idempotency-key: abc"), "{seen}"); + assert!(seen.ends_with(r#"{"payload":{"title":"Lock"}}"#), "{seen}"); + + let file = std::env::temp_dir().join("anyr-api-data.json"); + std::fs::write(&file, r#"{"a":1}"#).unwrap(); + let (base, rx) = serve(vec![(200, "{}")]); + let at = format!("@{}", file.display()); + let cmd = cli(&base, &["api", "POST", "/x", "--data", &at]); + let (code, _, err) = run(cmd); + assert_eq!(code, 0, "{err}"); + assert!(rx.recv().unwrap().ends_with(r#"{"a":1}"#)); + + let (base, _rx) = serve(vec![]); + let cmd = cli( + &base, + &["api", "POST", "/x", "--data", "@/nonexistent/file.json"], + ); + let (code, _, err) = run(cmd); + assert_ne!(code, 0); + assert!(err.contains("cannot read /nonexistent/file.json"), "{err}"); +} + +#[test] +fn no_app_is_named_in_the_rust_source_outside_help_examples() { + // WHY: apps are server-side data; a new app must need no CLI change. + fn walk(dir: &std::path::Path, hits: &mut Vec) { + for e in std::fs::read_dir(dir).unwrap() { + let p = e.unwrap().path(); + if p.is_dir() { + walk(&p, hits); + } else if p.extension().is_some_and(|x| x == "rs") { + for (i, l) in std::fs::read_to_string(&p).unwrap().lines().enumerate() { + let is_example = l.contains("{bin}") || l.contains("$ Date: Wed, 21 Oct 2026 15:10:28 +0700 Subject: [PATCH 2/4] fix(publish): cap file and stdin input at the 256 KiB server limit Co-Authored-By: Claude Sonnet 5.5 Co-authored-by: Duyet Le Co-authored-by: duyetbot --- src/api.rs | 29 +++++++++++++++++++++++------ src/publish.rs | 10 ++++------ 2 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/api.rs b/src/api.rs index a27c250..a0ed3f6 100644 --- a/src/api.rs +++ b/src/api.rs @@ -7,7 +7,7 @@ //! stdout on raw calls) prints the server JSON unchanged. use std::collections::BTreeMap; -use std::io::IsTerminal; +use std::io::{IsTerminal, Read}; use serde_json::{Map, Value}; @@ -722,22 +722,39 @@ fn data_flag(parsed: &ParsedArgs) -> Result, String> { let text = if spec == "-" { read_stdin()? } else if let Some(path) = spec.strip_prefix('@') { - std::fs::read_to_string(path).map_err(|e| format!("cannot read {path}: {e}"))? + read_file_capped(path)? } else { spec }; parse_json(&text).map(Some) } -pub(crate) fn read_stdin() -> Result { - use std::io::Read; +/// Largest document read from a file or stdin (the server caps bodies at 256 KiB). +pub(crate) const MAX_INPUT_BYTES: u64 = 256 * 1024; + +fn read_capped(r: impl std::io::Read, what: &str) -> Result { let mut buf = String::new(); - std::io::stdin() + r.take(MAX_INPUT_BYTES + 1) .read_to_string(&mut buf) - .map_err(|e| format!("cannot read stdin: {e}"))?; + .map_err(|e| format!("cannot read {what}: {e}"))?; + if buf.len() as u64 > MAX_INPUT_BYTES { + return Err(format!( + "{what} is larger than {} KiB, the server limit", + MAX_INPUT_BYTES / 1024 + )); + } Ok(buf) } +pub(crate) fn read_stdin() -> Result { + read_capped(std::io::stdin(), "stdin") +} + +pub(crate) fn read_file_capped(path: &str) -> Result { + let f = std::fs::File::open(path).map_err(|e| format!("cannot read {path}: {e}"))?; + read_capped(f, path) +} + /// Pull `-H key:value` pairs (repeatable) out of the positional words. type Headers = Vec<(String, String)>; diff --git a/src/publish.rs b/src/publish.rs index c3b0e76..809adce 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -11,7 +11,7 @@ use std::io::IsTerminal; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; -use crate::api::{confirm, context, exit_for, read_stdin, request, request_with, Ctx}; +use crate::api::{confirm, context, exit_for, read_file_capped, read_stdin, request, request_with, Ctx}; use crate::cmd::dispatch::USAGE; use crate::key::no_key_error; use crate::parse::{get_string_flag, ParsedArgs}; @@ -184,11 +184,9 @@ fn call( fn read_document(source: Option<&&str>) -> Result { let text = match source { - Some(&"-") => read_stdin()?, - Some(path) => { - std::fs::read_to_string(path).map_err(|e| usage(format!("cannot read {path}: {e}")))? - } - None if !std::io::stdin().is_terminal() => read_stdin()?, + Some(&"-") => read_stdin().map_err(usage)?, + Some(path) => read_file_capped(path).map_err(usage)?, + None if !std::io::stdin().is_terminal() => read_stdin().map_err(usage)?, None => { return Err(usage( "no document: pass a , or pipe JSON and use `-`", From 58d00af19481dba89f09a1ac59166b08920e747f Mon Sep 17 00:00:00 2001 From: Duyet Le Date: Wed, 21 Oct 2026 15:10:37 +0700 Subject: [PATCH 3/4] style(publish): cargo fmt Co-Authored-By: Claude Sonnet 5.5 Co-authored-by: Duyet Le Co-authored-by: duyetbot --- src/publish.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/publish.rs b/src/publish.rs index 809adce..4b9f205 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -11,7 +11,9 @@ use std::io::IsTerminal; use serde_json::{json, Value}; use sha2::{Digest, Sha256}; -use crate::api::{confirm, context, exit_for, read_file_capped, read_stdin, request, request_with, Ctx}; +use crate::api::{ + confirm, context, exit_for, read_file_capped, read_stdin, request, request_with, Ctx, +}; use crate::cmd::dispatch::USAGE; use crate::key::no_key_error; use crate::parse::{get_string_flag, ParsedArgs}; From 28b51d3c7693601152694d44838e57b356642f7b Mon Sep 17 00:00:00 2001 From: Duyet Le Date: Thu, 22 Oct 2026 17:26:07 +0700 Subject: [PATCH 4/4] feat(publish): --dry-run asks the server to validate, print located issues The JSON Schema covers types only. --dry-run now posts to /submissions?validate_only=1, so it runs the app's full checks without storing anything. A 422 prints each issue as path: message, fix: ... Co-Authored-By: Claude Opus 5.5 Co-authored-by: Duyet Le Co-authored-by: duyetbot --- README.md | 2 +- src/publish.rs | 38 ++++++++++++++++++++++++++++++++------ tests/publish.rs | 24 ++++++++++++++++++++++-- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4a21b18..8586110 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ uploaded), then sends it and prints the URL. ```bash anyr apps schema explain > schema.json # what an agent must produce | anyr publish explain - -anyr publish explain fig.json --dry-run # validate only +anyr publish explain fig.json --dry-run # server checks it; nothing uploaded anyr publish explain fig.json --visibility public --yes anyr apps ls explain # what you published anyr apps visibility explain private diff --git a/src/publish.rs b/src/publish.rs index 4b9f205..06bf722 100644 --- a/src/publish.rs +++ b/src/publish.rs @@ -150,8 +150,29 @@ fn failure(status: u16, text: &str, what: &str) -> (String, i32) { 403 => format!("\n{} this key cannot use this endpoint", term::dim("hint:")), _ => String::new(), }; + let issues = v + .pointer("/error/metadata/issues") + .and_then(Value::as_array) + .map(|list| { + list.iter() + .map(|i| { + let field = |k: &str| i.get(k).and_then(Value::as_str).unwrap_or(""); + let fix = field("fix"); + let fix = if fix.is_empty() { + String::new() + } else { + format!(" {} {fix}", term::dim("fix:")) + }; + format!("\n {}: {}{fix}", field("path"), field("message")) + }) + .collect::() + }) + .unwrap_or_default(); ( - format!("{} HTTP {status} {what}: {msg}{hint}", term::err("error:")), + format!( + "{} HTTP {status} {what}: {msg}{issues}{hint}", + term::err("error:") + ), exit_for(status), ) } @@ -247,7 +268,17 @@ fn publish_inner(parsed: &ParsedArgs, env: &BTreeMap) -> Result< ); return Ok(2); } + let mut body = json!({ "payload": payload }); + if let Some(v) = &visibility { + body["visibility"] = json!(v); + } + let path = format!("/v1/apps/{app}/submissions"); if parsed.flag_true("dry-run") { + // The server runs every check (the schema covers types only); nothing is stored. + let check = format!("{path}?validate_only=1"); + if let Err(code) = call(&ctx, "POST", &check, Some(&body.to_string()), &[], true)? { + return Ok(code); + } let version = spec .get("version") .map(|v| { @@ -272,15 +303,10 @@ fn publish_inner(parsed: &ParsedArgs, env: &BTreeMap) -> Result< )?; } - let mut body = json!({ "payload": payload }); - if let Some(v) = &visibility { - body["visibility"] = json!(v); - } let headers = [( "Idempotency-Key".to_string(), idempotency_key(&app, &payload), )]; - let path = format!("/v1/apps/{app}/submissions"); let (status, out) = match call(&ctx, "POST", &path, Some(&body.to_string()), &headers, true)? { Ok(r) => r, Err(code) => return Ok(code), diff --git a/tests/publish.rs b/tests/publish.rs index d70b06b..f743348 100644 --- a/tests/publish.rs +++ b/tests/publish.rs @@ -184,12 +184,12 @@ fn non_json_input_is_a_usage_error() { } #[test] -fn dry_run_reads_a_file_and_does_not_upload() { +fn dry_run_asks_the_server_to_validate_and_does_not_upload() { let dir = std::env::temp_dir().join("anyr-publish-dry"); std::fs::create_dir_all(&dir).unwrap(); let file = dir.join("doc.json"); std::fs::write(&file, r#"{"title":"Lock"}"#).unwrap(); - let (base, rx) = serve(vec![(200, SCHEMA)]); + let (base, rx) = serve(vec![(200, SCHEMA), (200, r#"{"valid":true}"#)]); let cmd = cli( &base, &["publish", "demo", file.to_str().unwrap(), "--dry-run"], @@ -198,9 +198,29 @@ fn dry_run_reads_a_file_and_does_not_upload() { assert_eq!(code, 0, "{err}"); assert!(out.starts_with("ok: valid against demo v1"), "{out}"); rx.recv().unwrap(); + let check = rx.recv().unwrap(); + assert!( + check.starts_with("POST /api/v1/apps/demo/submissions?validate_only=1"), + "{check}" + ); + assert!( + check.ends_with(r#"{"payload":{"title":"Lock"}}"#), + "{check}" + ); assert!(rx.try_recv().is_err()); } +#[test] +fn dry_run_prints_each_server_issue_with_its_path_and_fix() { + let refused = r#"{"error":{"code":"invalid_payload","message":"Payload failed the demo checks (1 issue).","metadata":{"issues":[{"path":"steps[1]","code":"too_small","message":"step too small","fix":"Use at least 2."}]}}}"#; + let (base, _rx) = serve(vec![(200, SCHEMA), (422, refused)]); + let cmd = cli(&base, &["publish", "demo", "-", "--dry-run"]); + let (code, out, err) = run_with_stdin(cmd, r#"{"title":"Lock","steps":[1,2]}"#); + assert_ne!(code, 0, "{out}"); + assert!(err.contains("steps[1]: step too small"), "{err}"); + assert!(err.contains("fix: Use at least 2."), "{err}"); +} + #[test] fn unknown_app_exits_3() { let (base, _rx) = serve(vec![(404, r#"{"error":{"message":"unknown app"}}"#)]);