mod apply;
mod auth;
mod build_cache;
mod build_run;
mod build_source;
mod context;
mod devices;
mod docs_cmd;
mod enable;
mod events_listen;
mod names;
mod output;
mod porcelain;
mod release_cmd;
mod token;
mod tree;
mod work_runner;
use std::io::{IsTerminal, Read, Write};
use std::process::ExitCode;
use clap::{Arg, ArgAction, ArgMatches, Command};
use serde_json::{json, Map, Value};
use sylphx::{Client, Error, HttpRequest};
use context::Link;
use output::Format;
use tree::{MethodCmd, Tree};
const HAND_WRITTEN: &[(&str, &str)] = &[
("auth", "enable"),
("auth", "status"),
("build", "run"),
("build", "logs"),
("build", "image"),
("build", "cache"),
("build", "source"),
("events", "listen"),
("data", "docs"),
];
fn cli(tree: &Tree) -> Command {
let global = |a: Arg| a.global(true);
let mut cmd = Command::new("sylphx")
.version(env!("CARGO_PKG_VERSION"))
.about("Sylphx: one API, one key, every service. https://sylphx.com/docs")
.subcommand_required(true)
.arg_required_else_help(true)
.arg(global(
Arg::new("output")
.long("output")
.short('o')
.value_name("FORMAT")
.default_value("table")
.value_parser(["table", "json", "yaml", "name"])
.help("Output format; tables show name, Ready, and age"),
))
.arg(global(
Arg::new("api-key")
.long("api-key")
.visible_alias("key")
.value_name("KEY")
.help("A Sylphx Access key; defaults to SYLPHX_API_KEY, then `sylphx login`"),
))
.arg(global(
Arg::new("org")
.long("org")
.value_name("ORG")
.help("Use this organization's credential; with login, lock browser approval to this org"),
))
.arg(global(
Arg::new("base-url")
.long("base-url")
.value_name("URL")
.hide(true),
))
.arg(global(
Arg::new("from-file")
.long("from-file")
.short('f')
.value_name("PATH")
.help("Read the whole request (wire JSON or YAML) from a file; conflicting flags are refused"),
))
.arg(global(
Arg::new("no-wait")
.long("no-wait")
.action(ArgAction::SetTrue)
.help("Return a long-running operation at once instead of waiting for it"),
))
.arg(global(
Arg::new("yes")
.long("yes")
.short('y')
.action(ArgAction::SetTrue)
.help("Do not ask before destructive calls"),
))
.arg(global(
Arg::new("all")
.long("all")
.action(ArgAction::SetTrue)
.help("For list: follow every page"),
))
.subcommand(
Command::new("login")
.about("Sign in: a browser approval (device flow), or an Access key with --api-key KEY|- (agents, CI)")
.arg(
Arg::new("with-token")
.long("with-token")
.action(ArgAction::SetTrue)
.help("Read an Access key from standard input (same as --api-key -)"),
)
.arg(
Arg::new("no-verify")
.long("no-verify")
.action(ArgAction::SetTrue)
.help("Store the credential without calling whoami"),
)
.arg(
Arg::new("store-file")
.long("store-file")
.action(ArgAction::SetTrue)
.help("With no OS keychain, save the key in the default config directory's credentials.json (0600), shared by every process of this user; SYLPHX_CONFIG_DIR picks another directory"),
),
)
.subcommand(
Command::new("token")
.about("Print a short-lived token for one scope, from your login (for cargo and other tools)")
.long_about("Prints one token on stdout (nothing else) that carries only --scope and expires in 15 minutes. It is minted from your login (or SYLPHX_API_KEY) and cached for reuse until 2 minutes before it expires; the login key itself is never printed. Errors go to stderr with a non-zero exit.\n\nExample, in .cargo/config.toml:\n credential-provider = [\"cargo:token-from-stdout\", \"sylphx\", \"token\", \"--scope\", \"hosting:deploy\"]")
.arg(
Arg::new("scope")
.long("scope")
.value_name("SCOPE")
.required(true)
.help("A registered scope, e.g. hosting:deploy or ai:inference"),
),
)
.subcommand(apply::command())
.subcommand(Command::new("logout").about("Revoke and forget the selected org credential (--all: every stored credential)"))
.subcommand(
Command::new("org")
.about("Choose the active organization for commands without an explicit or linked org")
.subcommand_required(true)
.subcommand(
Command::new("use")
.about("Use an organization already approved by sylphx login")
.arg(Arg::new("slug").required(true).value_name("SLUG")),
),
)
.subcommand(
Command::new("whoami").about("Show the caller: principal, org, project, env, scopes"),
)
.subcommand(
Command::new("link")
.visible_alias("init")
.about("Link this directory to an org, project, and env (.sylphx/project.json)")
.arg(
Arg::new("project")
.long("project")
.value_name("NAME")
.help("orgs/{org}/projects/{project}"),
)
.arg(
Arg::new("env")
.long("env")
.value_name("NAME")
.help("orgs/{org}/projects/{project}/envs/{env}"),
),
)
.subcommand(
Command::new("api")
.about("Call any path of the API directly (raw escape hatch)")
.arg(
Arg::new("method")
.required(true)
.value_parser(["GET", "POST", "PATCH", "DELETE"]),
)
.arg(
Arg::new("path")
.required(true)
.help("Path below the host, e.g. /v1/whoami"),
)
.arg(
Arg::new("data")
.long("data")
.short('d')
.value_name("JSON|@FILE")
.help("Request body"),
),
)
.subcommand(
Command::new("mcp")
.about("Run the Sylphx MCP server over stdio")
.subcommand(
Command::new("setup")
.about("Register the Sylphx MCP server with the MCP clients on this machine")
.arg(
Arg::new("client")
.long("client")
.value_name("ID")
.action(ArgAction::Append)
.help("Only this client: claude-code, codex, cursor, vscode, vscode-insiders, claude-desktop, windsurf or gemini (repeatable)"),
)
.arg(
Arg::new("remove")
.long("remove")
.action(ArgAction::SetTrue)
.help("Remove the entry instead of adding it"),
)
.arg(
Arg::new("dry-run")
.long("dry-run")
.action(ArgAction::SetTrue)
.help("Print the changes without writing them"),
),
),
)
.subcommand(devices::command())
.subcommands(release_cmd::commands())
.subcommand(porcelain::command())
.subcommand(work_runner::command())
.subcommand(
Command::new("completion")
.about("Print a shell completion script")
.arg(
Arg::new("shell")
.required(true)
.value_parser(clap::value_parser!(clap_complete::Shell)),
),
);
for s in tree::service_commands(tree, HAND_WRITTEN) {
let s = if s.get_name() == "auth" {
s.subcommand(
Command::new("enable")
.about("Enable Sylphx Auth on an environment (idempotent); prints its instance")
.arg(
Arg::new("env")
.long("env")
.value_name("NAME|ID")
.help("orgs/{org}/projects/{project}/envs/{env}, or an environment id in the linked project; default: the linked or key's environment"),
),
)
.subcommand(
Command::new("status")
.about("Show whether Sylphx Auth is enabled, and its instance"),
)
} else if s.get_name() == "build" {
s.subcommand(build_run::command())
.subcommand(build_run::logs_command())
.subcommand(build_run::image_command())
.subcommand(build_cache::command())
.subcommand(build_source::command())
} else if s.get_name() == "data" {
s.subcommand(docs_cmd::command())
} else if s.get_name() == "events" {
s.subcommand(events_listen::command())
} else {
s
};
cmd = cmd.subcommand(s);
}
cmd
}
#[tokio::main]
async fn main() -> ExitCode {
let tree = tree::load();
let matches = cli(&tree).get_matches();
match run(&tree, &matches).await {
Ok(()) => ExitCode::SUCCESS,
Err(Failure::Usage(e)) => {
eprintln!("error: {e}");
ExitCode::from(2)
}
Err(Failure::Refused(e)) => {
eprintln!("error: {e}");
ExitCode::from(1)
}
Err(Failure::Api(e)) => {
eprintln!("{}", describe(&e));
ExitCode::from(1)
}
Err(Failure::Exit(code)) => ExitCode::from(code),
}
}
#[derive(Debug)]
enum Failure {
Usage(String),
Refused(String),
Api(Error),
Exit(u8),
}
impl From<String> for Failure {
fn from(s: String) -> Self {
Failure::Usage(s)
}
}
impl From<Error> for Failure {
fn from(e: Error) -> Self {
Failure::Api(e)
}
}
fn describe(e: &Error) -> String {
match e {
Error::Api {
code,
status,
detail,
request_id,
retryable,
..
} => {
let mut s = format!("error: {} ({status}): {detail}", code.as_str());
if *retryable {
s.push_str(" [retryable]");
}
if let Some(id) = request_id {
s.push_str(&format!("\nrequest id: {id}"));
}
s
}
other => format!("error: {other}"),
}
}
async fn run(tree: &Tree, m: &ArgMatches) -> Result<(), Failure> {
let format = Format::parse(
m.get_one::<String>("output")
.map(String::as_str)
.unwrap_or("table"),
)?;
let api_key = m.get_one::<String>("api-key").cloned();
let base_url = m.get_one::<String>("base-url").cloned();
let org = m
.get_one::<String>("org")
.map(String::as_str)
.or_else(|| command_org(m));
let client = || async {
context::client(api_key.clone(), base_url.clone(), org)
.await?
.ok_or_else(|| {
Failure::Usage(
"not signed in: run `sylphx login` (browser), or give an Access key with \
SYLPHX_API_KEY=sylphx_sk_… or `sylphx login --api-key -` (stdin)"
.into(),
)
})
};
let (name, sub) = m.subcommand().expect("subcommand_required");
match name {
"apply" => apply::run(sub, api_key, base_url, org, format).await,
"login" => {
login(
api_key.clone(),
base_url.clone(),
sub.get_flag("with-token"),
sub.get_one::<String>("org").cloned(),
sub.get_flag("no-verify"),
sub.get_flag("store-file"),
)
.await
}
"token" => {
let scope = sub.get_one::<String>("scope").expect("required");
let t = token::token(scope, api_key.clone(), base_url.clone(), org)
.await
.map_err(Failure::Refused)?;
println!("{t}");
Ok(())
}
"org" => {
let (_, use_) = sub.subcommand().expect("subcommand_required");
let slug = use_.get_one::<String>("slug").expect("required");
context::use_org(slug).map_err(Failure::Usage)?;
println!("Active organization: {slug}.");
Ok(())
}
"logout" => {
let creds = context::load_credentials();
let all = m.get_flag("all");
let legacy = org.is_none() && creds.active_org.is_none() && creds.store.is_some();
let selected = if all || legacy {
None
} else {
let linked = std::env::current_dir()
.ok()
.and_then(|cwd| context::default_link(&cwd).ok().flatten());
context::selected_org(
&creds,
org.or_else(|| linked.as_ref().and_then(|l| l.at(1))),
)
.map(|o| o.id.clone())
};
let mut keys = Vec::new();
for entry in &creds.orgs {
if all || selected.as_deref() == Some(&entry.id) {
if let Some(key) = context::org_key(&creds, entry) {
keys.push(key);
}
}
}
if all || legacy || creds.orgs.is_empty() {
if let Some(key) = context::stored_key(&creds) {
keys.push(key);
}
}
let url = base_url
.clone()
.or_else(|| std::env::var("SYLPHX_BASE_URL").ok())
.or(creds.base_url.clone());
for key in &keys {
if key.starts_with("sylphx_sk_") {
if let Err(e) = auth::revoke_self(&auth::api_root(url.as_deref()), key).await {
eprintln!("warning: could not revoke a key server-side ({e}); revoke it in Settings → API keys");
}
}
}
if all || creds.orgs.is_empty() {
context::forget(&creds)?;
} else if legacy {
context::forget_legacy(&creds)?;
} else if let Some(id) = selected {
context::forget_org(&creds, &id)?;
} else {
return Err(Failure::Usage(
"no stored credential for the selected organization".into(),
));
}
println!("Logged out: stored credentials removed; server revocation attempted.");
Ok(())
}
"whoami" => {
let me = client().await?.invoke("access.whoami", json!({})).await?;
println!(
"{}",
output::render(
&me,
if format == Format::Table {
Format::Yaml
} else {
format
}
)
);
Ok(())
}
"link" => link(&client().await?, sub).await,
"devices" => devices::run(&client().await?, sub).await,
"deploy" | "status" | "rollback" => {
release_cmd::run(
&client().await?,
name,
sub,
org,
format,
sub.get_flag("yes"),
sub.get_flag("no-wait"),
)
.await
}
"work" => work_runner::run(api_key.clone(), org, sub, format == Format::Json).await,
"api" => {
let method = sub.get_one::<String>("method").expect("required").clone();
let path = sub.get_one::<String>("path").expect("required").clone();
let body = sub
.get_one::<String>("data")
.map(|d| read_json_arg(d))
.transpose()?;
let path = if path.starts_with('/') {
path
} else {
format!("/{path}")
};
let (path, query) = match path.split_once('?') {
Some((p, q)) => (
p.to_string(),
q.split('&')
.filter_map(|kv| {
kv.split_once('=')
.map(|(k, v)| (k.to_string(), v.to_string()))
})
.collect(),
),
None => (path, vec![]),
};
let method: &'static str = match method.as_str() {
"GET" => "GET",
"POST" => "POST",
"PATCH" => "PATCH",
_ => "DELETE",
};
let v: Value = client()
.await?
.call(HttpRequest {
method,
path,
query,
body,
mutation: method != "GET",
origin: None,
effect_ids: false,
})
.await?;
println!(
"{}",
output::render(
&v,
if format == Format::Table {
Format::Json
} else {
format
}
)
);
Ok(())
}
"mcp" if sub.subcommand_matches("setup").is_some() => {
let setup = sub.subcommand_matches("setup").expect("matched above");
let options = sylphx_mcp::SetupOptions {
dry_run: setup.get_flag("dry-run"),
remove: setup.get_flag("remove"),
clients: setup
.get_many::<String>("client")
.map(|c| c.cloned().collect()),
};
sylphx_mcp::setup(&options)
.map(|_| ())
.map_err(|e| Failure::Usage(e.to_string()))
}
"mcp" => {
let c = context::client(api_key.clone(), base_url.clone(), org).await?;
sylphx_mcp::serve_stdio(
sylphx_mcp::Server::new(c).with_docs(sylphx_mcp::docs::DocsIndex::from_env()),
)
.await
.map_err(|e| Failure::Usage(e.to_string()))
}
porcelain::COMMAND => {
let manifest = porcelain::manifest(&cli(tree), tree);
println!(
"{}",
serde_json::to_string_pretty(&manifest).expect("a JSON value serializes")
);
Ok(())
}
"completion" => {
let shell = *sub
.get_one::<clap_complete::Shell>("shell")
.expect("required");
let mut cmd = cli(tree);
clap_complete::generate(shell, &mut cmd, "sylphx", &mut std::io::stdout());
Ok(())
}
"build" if sub.subcommand_name() == Some("run") => {
let (_, rm) = sub.subcommand().expect("checked");
let key = context::resolve(api_key.clone(), base_url.clone(), org).map(|r| r.key);
build_run::run(client().await, key, rm).await
}
"build" if sub.subcommand_name() == Some("logs") => {
let (_, lm) = sub.subcommand().expect("checked");
build_run::logs(client().await, lm).await
}
"build" if sub.subcommand_name() == Some("image") => {
let (_, im) = sub.subcommand().expect("checked");
let key = context::resolve(api_key.clone(), base_url.clone(), org).map(|r| r.key);
build_run::run_image(client().await, key, im).await
}
"build" if sub.subcommand_name() == Some("cache") => {
let (_, cm) = sub.subcommand().expect("checked");
let creds = context::resolve(api_key.clone(), base_url.clone(), org);
build_cache::run(creds, cm, format).await
}
"build" if sub.subcommand_name() == Some("source") => {
let (_, sm) = sub.subcommand().expect("checked");
let creds = context::resolve(api_key.clone(), base_url.clone(), org);
build_source::run(creds, sm, format).await
}
"data" if sub.subcommand_name() == Some("docs") => {
let (_, dm) = sub.subcommand().expect("checked");
docs_cmd::run(&client().await?, dm, format).await
}
"events" if sub.subcommand_name() == Some("listen") => {
let (_, lm) = sub.subcommand().expect("checked");
events_listen::run(&client().await?, lm, format == Format::Json).await
}
"auth" if matches!(sub.subcommand_name(), Some("enable" | "status")) => {
let (verb, vm) = sub.subcommand().expect("checked");
auth_enable(&client().await?, verb, vm, format).await
}
service => {
let (method, mm) = tree::find(tree, service, sub)
.ok_or_else(|| Failure::Usage(format!("unknown command under `{service}`")))?;
if matches!(
method.method.as_str(),
"workflows.schedules.list" | "workflows.schedules.get"
) {
return compute_schedules(api_key, base_url, org, method, &mm, format).await;
}
generated(&client().await?, method, &mm, m, format).await
}
}
}
fn command_org(matches: &ArgMatches) -> Option<&str> {
if let Some((_, sub)) = matches.subcommand() {
if let Some(org) = command_org(sub) {
return Some(org);
}
}
for id in matches.ids() {
if let Some(values) = matches.get_raw(id.as_str()) {
for value in values {
if let Some(name) = value.to_str().filter(|v| v.starts_with("orgs/")) {
return Some(name);
}
}
}
}
None
}
async fn compute_schedules(
api_key: Option<String>,
base_url: Option<String>,
org: Option<&str>,
method: &MethodCmd,
mm: &ArgMatches,
format: Format,
) -> Result<(), Failure> {
let url = std::env::var("SYLPHX_COMPUTE_URL")
.ok()
.filter(|u| !u.is_empty())
.or(base_url)
.or_else(|| {
std::env::var("SYLPHX_BASE_URL")
.ok()
.filter(|u| !u.is_empty())
})
.unwrap_or_else(|| "https://api.compute.sylphx.com".to_string());
let client = context::client(api_key, Some(url), org)
.await?
.ok_or_else(|| {
Failure::Usage(
"not signed in: run `sylphx login`, or give an Access key with SYLPHX_API_KEY"
.into(),
)
})?;
let (path, query) = if method.verb == "get" {
let id = mm.get_one::<String>("__positional").ok_or_else(|| {
Failure::Usage("name the schedule: sylphx workflows schedules get <id>".into())
})?;
(format!("/v1/schedules/{}", enable::bare_id(id)), vec![])
} else {
let (values, _) = tree::flag_values(method, mm)?;
let mut query = vec![];
for (field, value) in values {
let key = match field.as_str() {
"page_size" => "limit",
"page_token" => "cursor",
_ => continue,
};
let text = match &value {
Value::String(s) => s.clone(),
other => other.to_string(),
};
query.push((key.to_string(), text));
}
("/v1/schedules".to_string(), query)
};
let v: Value = client
.call(HttpRequest {
method: "GET",
path,
query,
body: None,
mutation: false,
origin: None,
effect_ids: false,
})
.await?;
let text = output::render(&v, format);
if !text.is_empty() {
println!("{text}");
}
Ok(())
}
async fn auth_enable(
client: &Client,
verb: &str,
sub: &ArgMatches,
format: Format,
) -> Result<(), Failure> {
let org = sub
.try_get_one::<String>("org")
.ok()
.flatten()
.map(String::as_str)
.or_else(|| command_org(sub));
let given = sub.try_get_one::<String>("env").ok().flatten();
let mut defaults = Defaults {
client,
link: None,
org: org.map(str::to_string),
};
let env = match given {
Some(e) if e.starts_with("orgs/") => names::resolve(client, e).await?,
Some(e) => {
let project = defaults.at("orgs/{org}/projects/{project}").await?;
names::resolve(client, &format!("{project}/envs/{e}")).await?
}
None => {
defaults
.at("orgs/{org}/projects/{project}/envs/{env}")
.await?
}
};
let link = Link::from_env_name(&env)
.ok_or_else(|| Failure::Usage("--env must name an environment".into()))?;
let project = link.project;
let path = enable::bindings_path(&project);
let render = |v: &Value| {
output::render(
v,
if format == Format::Table {
Format::Yaml
} else {
format
},
)
};
if verb == "status" {
let list: Value = client
.call(HttpRequest {
method: "GET",
path,
query: vec![],
body: None,
mutation: false,
origin: None,
effect_ids: false,
})
.await?;
println!("{}", render(&enable::status(&list)));
return Ok(());
}
let answer: Value = client
.call(HttpRequest {
method: "PUT",
path,
query: vec![],
body: Some(enable::enable_body(&env)),
mutation: true,
origin: None,
effect_ids: false,
})
.await?;
let instance = enable::organization_id(&answer).ok_or_else(|| {
Failure::Refused("Auth was bound but the answer names no instance".into())
})?;
if format == Format::Table {
println!("Sylphx Auth is enabled on {}.", enable::bare_id(&env));
println!("Instance (SYLPHX_AUTH_ORGANIZATION_ID): {instance}");
} else {
println!(
"{}",
render(&json!({"organizationId": instance, "binding": answer}))
);
}
Ok(())
}
async fn login(
api_key: Option<String>,
base_url: Option<String>,
with_token: bool,
org: Option<String>,
no_verify: bool,
store_file: bool,
) -> Result<(), Failure> {
let base_url = base_url
.or_else(|| {
std::env::var("SYLPHX_BASE_URL")
.ok()
.filter(|v| !v.trim().is_empty())
})
.or_else(|| context::load_credentials().base_url);
context::check_key_store(base_url.as_deref(), store_file).map_err(Failure::Usage)?;
let asked = api_key.as_deref() == Some("-") || (api_key.is_none() && with_token);
let piped = api_key.is_none() && !std::io::stdin().is_terminal();
let key = if asked || piped {
let mut s = String::new();
std::io::stdin()
.read_to_string(&mut s)
.map_err(|e| e.to_string())?;
let s = s.trim().to_string();
(asked || !s.is_empty()).then_some(s)
} else {
api_key
};
let issued_here = key.is_none();
let (keys, active_after) = match key {
Some(k) if k.is_empty() => return Err(Failure::Usage("no key on stdin".into())),
Some(k) => {
if !k.starts_with("sylphx_sk_") && !k.starts_with("sylphx_pk_") {
eprintln!("warning: an Access key starts with sylphx_sk_ (secret) or sylphx_pk_ (publishable)");
}
(
vec![auth::IssuedKey {
api_key: k,
org_id: String::new(),
org_slug: String::new(),
key_name: String::new(),
}],
std::time::Duration::ZERO,
)
}
None => {
let issued = auth::device_login(&auth::api_root(base_url.as_deref()), org).await?;
for key in &issued.keys {
eprintln!("Approved in {}.", key.org_slug);
}
(issued.keys, issued.active_after)
}
};
let stored = if issued_here {
Some(context::store_keys(base_url.clone(), &keys, store_file)?)
} else {
None
};
if !no_verify {
tokio::time::sleep(active_after).await;
}
let mut principal = None;
let mut unconfirmed = false;
if !no_verify {
for key in &keys {
match verify_login_key(&key.api_key, base_url.clone(), issued_here).await {
Ok(me) => {
principal = me["principal"]
.as_str()
.or_else(|| me["email"].as_str())
.or_else(|| me["user"]["email"].as_str())
.map(str::to_string)
.or(principal);
}
Err((e, transient)) if issued_here || transient => {
unconfirmed = true;
eprintln!("A key could not be confirmed yet ({e}). Run `sylphx whoami` in a minute; there is no need to sign in again.");
}
Err((e, _)) => return Err(e.into()),
}
}
}
let (path, in_keychain) = match stored {
Some(stored) => stored,
None => context::store_key(base_url, &keys[0].api_key, None, store_file)?,
};
let where_ = if in_keychain {
"the OS keychain".to_string()
} else {
eprintln!(
"note: no OS keychain here; credentials are in {} (mode 0600)",
path.display()
);
path.display().to_string()
};
if no_verify || unconfirmed {
println!("Credentials stored in {where_}.");
} else {
println!(
"Signed in as {} (key stored in {where_}).",
principal.as_deref().unwrap_or("?")
);
}
Ok(())
}
async fn verify_login_key(
key: &str,
base_url: Option<String>,
issued_here: bool,
) -> Result<Value, (Error, bool)> {
let client = context::client_for(
&context::resolve(Some(key.to_string()), base_url, None)
.expect("an explicit credential resolves"),
)
.map_err(|e| (Error::Transport(e), false))?;
let mut waits = [500u64, 1000, 2000, 4000, 8000].into_iter();
loop {
match client.invoke("access.whoami", json!({})).await {
Err(e) if transient_whoami(&e) || (issued_here && unknown_key(&e)) => {
match waits.next() {
Some(ms) => tokio::time::sleep(std::time::Duration::from_millis(ms)).await,
None => return Err((e, true)),
}
}
Err(e) => return Err((e, false)),
Ok(me) => return Ok(me),
}
}
}
fn unknown_key(e: &Error) -> bool {
matches!(e, Error::Api { detail, .. } if detail.contains("unknown_key"))
}
fn transient_whoami(e: &Error) -> bool {
match e {
Error::Api {
code,
status,
detail,
..
} => {
*code == sylphx::common::ErrorCode::NotYetPropagated
|| detail.contains("not_yet_propagated")
|| *status == 429
|| *status >= 500
}
Error::Transport(_) => true,
_ => false,
}
}
async fn link(client: &Client, sub: &ArgMatches) -> Result<(), Failure> {
let me = client.invoke("access.whoami", json!({})).await?;
let mine = Link::from_whoami(&me);
let org = mine.org.clone();
if let Some(selected) = sub.get_one::<String>("org") {
let credentials = context::load_credentials();
let selected = context::selected_org(&credentials, Some(selected))
.map(|o| names::org_name(&o.id))
.unwrap_or_else(|| names::org_name(selected));
if selected != org {
return Err(Failure::Usage(
"--org does not match the signed-in credential".into(),
));
}
}
if org.is_empty() {
return Err(Failure::Usage("the key names no org; pass --org".into()));
}
let dir = std::env::current_dir().map_err(|e| e.to_string())?;
let project = sub.get_one::<String>("project");
let env = sub.get_one::<String>("env");
let selected = if project.is_some() || env.is_some_and(|e| e.starts_with("orgs/")) {
mine
} else {
context::default_link_for(&dir, Some(&org))?.unwrap_or(mine)
};
let mut l = if project.is_none() && env.is_none() {
selected
} else {
let project = project.map(String::as_str).or_else(|| {
(!selected.project.is_empty() && env.is_none_or(|e| !e.starts_with("orgs/")))
.then_some(selected.project.as_str())
});
let (org, project, env) =
names::link_names(client, &org, project, env.map(String::as_str)).await?;
Link {
org,
project: project.unwrap_or_default(),
env: env.unwrap_or_default(),
}
};
names::complete(client, &mut l, 3).await?;
if l.at(3).is_none() {
return Err(Failure::Usage(
"link requires a selected environment".into(),
));
}
let p = context::write_link(&dir, &l)?;
println!(
"Linked {} to {}.",
dir.display(),
if !l.env.is_empty() {
&l.env
} else if !l.project.is_empty() {
&l.project
} else {
&l.org
}
);
println!("Wrote {}.", p.display());
Ok(())
}
fn read_json_arg(s: &str) -> Result<Value, String> {
let text = match s.strip_prefix('@') {
Some(path) => std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?,
None => s.to_string(),
};
serde_json::from_str(&text).map_err(|e| format!("not JSON: {e}"))
}
struct Defaults<'c> {
client: &'c Client,
link: Option<Link>,
org: Option<String>,
}
impl Defaults<'_> {
async fn at(&mut self, pattern: &str) -> Result<String, Failure> {
let level = tree::context_level(pattern).ok_or_else(|| {
Failure::Usage(format!("give the parent explicitly (it matches {pattern})"))
})?;
if self.link.is_none() {
let cwd = std::env::current_dir().map_err(|e| e.to_string())?;
self.link = Some(
match context::default_link_for(&cwd, self.org.as_deref())
.map_err(Failure::Usage)?
{
Some(l) => l,
None => {
let mine = Link::from_whoami(
&self.client.invoke("access.whoami", json!({})).await?,
);
if let Some(selected) = self.org.as_deref() {
let credentials = context::load_credentials();
let selected = context::selected_org(&credentials, Some(selected))
.map(|o| names::org_name(&o.id))
.unwrap_or_else(|| names::org_name(selected));
if selected != mine.org {
return Err(Failure::Usage(
"--org does not match the signed-in credential".into(),
));
}
}
mine
}
},
);
}
if self.link.as_ref().and_then(|l| l.at(level)).is_none() {
let link = self.link.as_mut().expect("initialized above");
names::complete(self.client, link, level).await?;
let cwd = std::env::current_dir().map_err(|e| e.to_string())?;
if std::env::var(context::ENV_VAR)
.unwrap_or_default()
.trim()
.is_empty()
&& context::find_link(&cwd).is_none_or(|(_, existing)| existing.org == link.org)
{
if let Err(error) = context::write_link(&cwd, link) {
eprintln!("warning: could not persist default context: {error}; continuing with the resolved context");
}
}
}
let name = self
.link
.as_ref()
.and_then(|l| l.at(level))
.ok_or_else(|| {
Failure::Usage(format!(
"no default for {pattern}: pass it explicitly or run `sylphx link --env …`"
))
})?;
names::resolve(self.client, name).await
}
async fn name(&mut self, value: &str, pattern: &str) -> Result<String, Failure> {
if value.starts_with("orgs/") {
return names::resolve(self.client, value).await;
}
if value.contains('/') || !pattern.contains('/') {
return Ok(value.to_string());
}
let (parent, collection) = tree::split_pattern(pattern)
.ok_or_else(|| Failure::Usage(format!("`{value}` must be a full name ({pattern})")))?;
Ok(format!("{}/{collection}/{value}", self.at(&parent).await?))
}
}
fn singleton_name(parent: String, suffix: Option<&str>) -> String {
match suffix {
Some(s) => format!("{parent}/{s}"),
None => parent,
}
}
fn same_field_value(existing: &Value, value: &Value, ty: &str) -> bool {
if existing == value {
return true;
}
match ty {
"number" => existing
.as_f64()
.zip(value.as_f64())
.is_some_and(|(a, b)| a == b),
"int" => existing.as_str().is_some_and(|s| {
s.parse::<i64>()
.ok()
.zip(value.as_i64())
.is_some_and(|(a, b)| a == b)
|| s.parse::<u64>()
.ok()
.zip(value.as_u64())
.is_some_and(|(a, b)| a == b)
}),
_ => false,
}
}
async fn generated(
client: &Client,
method: &MethodCmd,
mm: &ArgMatches,
root: &ArgMatches,
format: Format,
) -> Result<(), Failure> {
let mut args = match root.get_one::<String>("from-file") {
Some(path) => {
let text = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?;
let v: Value = if path.ends_with(".json") {
serde_json::from_str(&text).map_err(|e| format!("{path}: {e}"))?
} else {
serde_yaml_ng::from_str(&text).map_err(|e| format!("{path}: {e}"))?
};
match v {
Value::Object(o) => o,
_ => {
return Err(Failure::Usage(format!(
"{path}: the request must be an object"
)))
}
}
}
None => Map::new(),
};
let file_args = Value::Object(args.clone());
let (values, mask) = tree::flag_values(method, mm)?;
for (field, value) in &values {
if let Some(existing) = tree::get_path(&file_args, field) {
let flag = method
.flags
.iter()
.find(|f| &f.field == field)
.expect("flag field");
if !same_field_value(existing, value, &flag.r#type) {
let detail = if flag.sensitive {
": values redacted".to_string()
} else {
format!(": file value {existing}, flag value {value}")
};
return Err(Failure::Usage(format!(
"{} conflicts with --from-file at {field}{detail}",
flag.flag
)));
}
}
}
let mut defaults = Defaults {
client,
link: None,
org: root.get_one::<String>("org").cloned(),
};
if let Some(p) = &method.positional {
let given = mm.get_one::<String>("__positional").cloned();
let value = match (p.arg.as_str(), given) {
("ID", v) => v,
("PARENT", Some(v)) => Some(singleton_name(
names::resolve(client, &v).await?,
p.suffix.as_deref(),
)),
("PARENT", None)
if tree::get_path(&Value::Object(args.clone()), &p.field).is_none() =>
{
Some(singleton_name(
defaults
.at(p.pattern.as_deref().unwrap_or_default())
.await?,
p.suffix.as_deref(),
))
}
(_, Some(v)) => Some(
defaults
.name(&v, p.pattern.as_deref().unwrap_or_default())
.await?,
),
_ => None,
};
if let Some(v) = value {
let value = Value::String(v);
if let Some(existing) = tree::get_path(&file_args, &p.field) {
if existing != &value {
return Err(Failure::Usage(format!(
"{} conflicts with --from-file at {}: file value {existing}, positional value {value}",
p.arg, p.field
)));
}
}
tree::set_path(&mut args, &p.field, value);
}
}
for (field, value) in values {
tree::set_path(&mut args, &field, value);
}
for f in method.flags.iter().filter(|f| f.default_from_context) {
if tree::get_path(&Value::Object(args.clone()), &f.field).is_none() {
let v = defaults
.at(f.pattern.as_deref().unwrap_or_default())
.await?;
tree::set_path(&mut args, &f.field, Value::String(v));
}
}
if method.is_update()
&& root.get_one::<String>("from-file").is_none()
&& !mask.is_empty()
&& !args.contains_key("update_mask")
{
args.insert("update_mask".into(), Value::String(mask.join(",")));
}
let missing = tree::missing_required(method, &Value::Object(args.clone()));
if !missing.is_empty() {
return Err(Failure::Usage(format!(
"missing required flags: {}",
missing.join(", ")
)));
}
if method.sends_etag || method.flags.iter().any(|f| f.field == "etag") {
etag(client, method, &mut args).await?;
}
if method.effect == "destructive" && !root.get_flag("yes") {
let snapshot = Value::Object(args.clone());
let target = method
.positional
.as_ref()
.and_then(|p| tree::get_path(&snapshot, &p.field))
.and_then(Value::as_str)
.unwrap_or(&method.method)
.to_string();
confirm(&format!("{} {target}?", method.verb))?;
}
if sylphx::methods::method(&method.method).is_some_and(|spec| spec.server_streaming) {
use futures_util::StreamExt;
let mut stream = client
.invoke_stream(&method.method, Value::Object(args))
.await?;
while let Some(event) = stream.next().await {
println!("{}", output::render(&event?, format));
}
return Ok(());
}
let mut result = client
.invoke(&method.method, Value::Object(args.clone()))
.await?;
if method.paginates && root.get_flag("all") {
result = follow_pages(client, method, args, result).await?;
}
if method.long_running() && !root.get_flag("no-wait") && result.get("validate_only").is_none() {
result = wait(client, result).await?;
}
let text = output::render(&result, format);
if !text.is_empty() {
println!("{text}");
}
Ok(())
}
async fn etag(
client: &Client,
method: &MethodCmd,
args: &mut Map<String, Value>,
) -> Result<(), Failure> {
let Some(get) = method
.method
.rsplit_once('.')
.map(|(c, _)| format!("{c}.get"))
else {
return Ok(());
};
let (name_field, etag_path) = match method.resource_field() {
Some(r) if method.is_update() => (format!("{r}.name"), format!("{r}.meta.etag")),
_ => ("name".to_string(), "etag".to_string()),
};
let current = Value::Object(args.clone());
if tree::get_path(¤t, &etag_path).is_some() || sylphx::methods::method(&get).is_none() {
return Ok(());
}
let Some(name) = tree::get_path(¤t, &name_field)
.and_then(Value::as_str)
.map(str::to_string)
else {
return Ok(());
};
match client.invoke(&get, json!({ "name": name })).await {
Ok(r) => {
if let Some(e) = r
.get("meta")
.and_then(|m| m.get("etag"))
.and_then(Value::as_str)
{
tree::set_path(args, &etag_path, Value::String(e.to_string()));
}
Ok(())
}
Err(Error::Api { status: 404, .. }) => Ok(()),
Err(e) => Err(e.into()),
}
}
fn confirm(prompt: &str) -> Result<(), Failure> {
if !std::io::stdin().is_terminal() {
return Err(Failure::Usage(format!(
"{prompt} This is destructive: pass --yes to confirm"
)));
}
eprint!("{prompt} [y/N] ");
std::io::stderr().flush().ok();
let mut line = String::new();
std::io::stdin()
.read_line(&mut line)
.map_err(|e| e.to_string())?;
if matches!(line.trim(), "y" | "Y" | "yes") {
Ok(())
} else {
Err(Failure::Usage("aborted".into()))
}
}
async fn follow_pages(
client: &Client,
method: &MethodCmd,
mut args: Map<String, Value>,
first: Value,
) -> Result<Value, Failure> {
let key = first
.as_object()
.and_then(|o| {
o.iter()
.find(|(k, v)| k.as_str() != "next_page_token" && v.is_array())
})
.map(|(k, _)| k.clone());
let Some(key) = key else {
return Ok(first);
};
let mut items = first[&key].as_array().cloned().unwrap_or_default();
let mut token = first["next_page_token"]
.as_str()
.unwrap_or_default()
.to_string();
while !token.is_empty() {
args.insert("page_token".into(), Value::String(token));
let page = client
.invoke(&method.method, Value::Object(args.clone()))
.await?;
items.extend(page[&key].as_array().cloned().unwrap_or_default());
token = page["next_page_token"]
.as_str()
.unwrap_or_default()
.to_string();
}
Ok(json!({ key: items }))
}
async fn wait(client: &Client, mut op: Value) -> Result<Value, Failure> {
let Some(name) = op.get("name").and_then(Value::as_str).map(str::to_string) else {
return Ok(op);
};
if !name.contains("/operations/") {
return Ok(op);
}
let tty = std::io::stderr().is_terminal();
while op.get("done") != Some(&Value::Bool(true)) {
if tty {
let msg = op
.get("progress")
.and_then(|p| p.get("message"))
.and_then(Value::as_str)
.unwrap_or("waiting");
eprint!("\r{msg}…");
}
op = client
.call(HttpRequest {
method: "POST",
path: format!("/v1/{name}:wait"),
query: vec![("timeout".into(), "60s".into())],
body: Some(json!({})),
mutation: false,
origin: None,
effect_ids: false,
})
.await?;
}
if tty {
eprint!("\r");
}
if let Some(err) = op.get("error").filter(|e| !e.is_null()) {
let problem: sylphx::common::ProblemDetails =
serde_json::from_value(err.clone()).map_err(|e| Failure::Usage(e.to_string()))?;
return Err(Failure::Api(Error::Api {
code: sylphx::common::ErrorCode::from_wire(&problem.code),
status: problem.status.max(0) as u16,
retryable: problem.retryable,
effect: problem.effect.clone(),
detail: problem.detail.clone(),
request_id: None,
problem: Box::new(problem),
}));
}
Ok(match op.get("response") {
Some(Value::Object(r)) => {
let mut r = r.clone();
r.remove("@type");
Value::Object(r)
}
_ => op,
})
}