use std::time::Duration;
use anyhow::Context;
use serde_json::{json, Value};
use sylphx_apps_sdk_core::normalize_management_base_url;
use sylphx_apps_sdk_management::ManagementClient;
use crate::channel;
use crate::context;
use crate::credentials;
use crate::ux;
pub async fn doctor(
json: bool,
client: &ManagementClient,
token: &str,
base: &str,
) -> anyhow::Result<()> {
#[derive(serde::Serialize)]
struct Row {
status: &'static str,
title: String,
detail: String,
#[serde(skip_serializing_if = "Option::is_none")]
fix: Option<String>,
}
let mut rows: Vec<Row> = Vec::new();
let mut worst = "pass";
let channel = channel::detect_install_channel();
let current = env!("CARGO_PKG_VERSION");
rows.push(Row {
status: "pass",
title: "CLI version".into(),
detail: format!("{current} (Rust)"),
fix: None,
});
rows.push(Row {
status: "pass",
title: "Install method".into(),
detail: format!(
"{} · upgrade: {}",
channel.display_name(),
channel.upgrade_command()
),
fix: None,
});
match channel::fetch_latest_release_version().await {
Ok(latest) => {
if channel::is_outdated(current, &latest) {
if worst == "pass" {
worst = "warn";
}
rows.push(Row {
status: "warn",
title: "Update available".into(),
detail: format!("{current} → {latest}"),
fix: Some(channel.upgrade_command().to_string()),
});
} else if channel::is_outdated(&latest, current) {
rows.push(Row {
status: "pass",
title: "Update available".into(),
detail: format!("local {current} is ahead of published {latest}"),
fix: None,
});
} else {
rows.push(Row {
status: "pass",
title: "Update available".into(),
detail: format!("up to date with {latest}"),
fix: None,
});
}
}
Err(e) => {
rows.push(Row {
status: "pass",
title: "Update available".into(),
detail: format!("skipped ({e})"),
fix: None,
});
}
}
let kind = credentials::token_kind(token);
rows.push(Row {
status: "pass",
title: "Credential".into(),
detail: format!("kind={kind}"),
fix: None,
});
rows.push(Row {
status: "pass",
title: "API base URL".into(),
detail: normalize_management_base_url(base),
fix: None,
});
match client.health().await {
Ok(h) => rows.push(Row {
status: "pass",
title: "API health".into(),
detail: format!("status={}", h.status),
fix: None,
}),
Err(e) => match client.health_status_probe().await {
Ok(status) if status == "ok" || status == "healthy" || status == "up" => {
if worst == "pass" {
worst = "warn";
}
rows.push(Row {
status: "warn",
title: "API health".into(),
detail: format!(
"status={status} (typed HealthResponse decode lag: {e})"
),
fix: Some(
"upgrade CLI/SDK wire contract when Management health fields advance"
.into(),
),
});
}
Ok(status) => {
worst = "fail";
rows.push(Row {
status: "fail",
title: "API health".into(),
detail: format!("status={status}"),
fix: Some("check network / SYLPHX_API_URL".into()),
});
}
Err(probe_err) => {
worst = "fail";
rows.push(Row {
status: "fail",
title: "API health".into(),
detail: format!("{e}; probe={probe_err}"),
fix: Some("check network / SYLPHX_API_URL".into()),
});
}
},
}
match client.whoami().await {
Ok(w) => {
let email = w.user.as_option().map(|u| u.email.as_str()).unwrap_or("-");
rows.push(Row {
status: "pass",
title: "Whoami".into(),
detail: format!("{email} · {} orgs", w.orgs.len()),
fix: None,
});
}
Err(e) => {
let msg = e.to_string();
if msg.contains("user_context_required") && kind == "service_token" {
rows.push(Row {
status: "pass",
title: "Whoami".into(),
detail: "service token (no user profile — expected)".into(),
fix: None,
});
} else {
worst = "fail";
rows.push(Row {
status: "fail",
title: "Whoami".into(),
detail: msg,
fix: Some("sylphx login".into()),
});
}
}
}
match context::resolve_org_id(None)? {
Some(o) => {
let looks_valid = o.starts_with("org_") || o.chars().all(|c| c.is_ascii_hexdigit() || c == '-');
let in_whoami = match client.whoami().await {
Ok(w) => w.orgs.iter().any(|org| org.id == o || org.slug == o),
Err(_) => true, };
if o.starts_with("org_") && in_whoami {
rows.push(Row {
status: "pass",
title: "Preferred org".into(),
detail: o,
fix: None,
});
} else if looks_valid && in_whoami {
rows.push(Row {
status: "pass",
title: "Preferred org".into(),
detail: format!("{o} (resolved via whoami)"),
fix: None,
});
} else {
if worst == "pass" {
worst = "warn";
}
rows.push(Row {
status: "warn",
title: "Preferred org".into(),
detail: format!(
"{o} — not found in whoami orgs (projects/status may 404)"
),
fix: Some("sylphx context use --org-id org_… (from: sylphx orgs list)".into()),
});
}
}
None => {
if worst == "pass" {
worst = "warn";
}
rows.push(Row {
status: "warn",
title: "Preferred org".into(),
detail: "not set — projects list may be empty".into(),
fix: Some("sylphx context use --org-id <org_…>".into()),
});
}
}
match context::load_linked_project()? {
Some(l) => rows.push(Row {
status: "pass",
title: "Linked project".into(),
detail: format!(
"{} ({})",
l.project_id,
l.project_slug.as_deref().unwrap_or("-")
),
fix: None,
}),
None => rows.push(Row {
status: "pass",
title: "Linked project".into(),
detail: "none (optional)".into(),
fix: None,
}),
}
let body = json!({ "worst": worst, "checks": rows, "installChannel": channel.as_str() });
ux::print_out(json, &body, || {
let view: Vec<(&str, &str, &str, Option<&str>)> = rows
.iter()
.map(|r| {
(
r.status,
r.title.as_str(),
r.detail.as_str(),
r.fix.as_deref(),
)
})
.collect();
ux::print_doctor_rows(&view);
println!();
let summary = match worst {
"pass" => format!("{} {}", ux::mark_pass(), ux::green("summary: pass")),
"warn" => format!("{} {}", ux::mark_warn(), ux::yellow("summary: warn")),
_ => format!("{} {}", ux::mark_fail(), ux::red("summary: fail")),
};
println!("{summary}");
});
if worst == "fail" {
anyhow::bail!("doctor found failures — see above");
}
Ok(())
}
fn host_platform() -> anyhow::Result<(&'static str, &'static str)> {
let os = match std::env::consts::OS {
"linux" => "linux",
"macos" => "darwin",
other => {
anyhow::bail!(
"unsupported os={other}; use cargo install sylphx-cli or npm i -g @sylphx/cli"
)
}
};
let arch = match std::env::consts::ARCH {
"x86_64" => "x64",
"aarch64" => "arm64",
other => anyhow::bail!("unsupported arch={other}"),
};
Ok((os, arch))
}
#[derive(Debug, Clone)]
struct NpmCliRelease {
npm_version: String,
crate_version: String,
platform_pkg: String,
tarball_url: String,
}
async fn fetch_npm_cli_release(
client: &reqwest::Client,
pin: Option<&str>,
) -> anyhow::Result<NpmCliRelease> {
let (os, arch) = host_platform()?;
let platform_pkg = format!("@sylphx/cli-{os}-{arch}");
let encoded_meta = "@sylphx%2Fcli";
let meta_url = format!("https://registry.npmjs.org/{encoded_meta}");
let meta = client
.get(&meta_url)
.header("Accept", "application/json")
.send()
.await
.context("npm registry metadata")?
.error_for_status()
.context("npm registry metadata status")?
.json::<Value>()
.await
.context("decode npm metadata")?;
let npm_version = if let Some(pin) = pin {
let pin = pin
.strip_prefix("cli-v")
.or_else(|| pin.strip_prefix('v'))
.unwrap_or(pin);
if meta
.pointer(&format!("/versions/{pin}"))
.is_some()
{
pin.to_string()
} else {
let versions = meta
.get("versions")
.and_then(|v| v.as_object())
.ok_or_else(|| anyhow::anyhow!("npm metadata missing versions"))?;
let mut found: Option<String> = None;
for (ver, body) in versions {
let crate_v = body
.pointer("/sylphx/crateVersion")
.and_then(|v| v.as_str())
.unwrap_or("");
if crate_v == pin {
found = Some(ver.clone());
}
}
found.ok_or_else(|| {
anyhow::anyhow!(
"no public npm @sylphx/cli version maps to crate/product version {pin}"
)
})?
}
} else {
meta.pointer("/dist-tags/latest")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("npm @sylphx/cli has no latest tag"))?
.to_string()
};
let version_body = meta
.pointer(&format!("/versions/{npm_version}"))
.cloned()
.ok_or_else(|| anyhow::anyhow!("npm version {npm_version} missing from registry"))?;
let crate_version = version_body
.pointer("/sylphx/crateVersion")
.and_then(|v| v.as_str())
.unwrap_or(&npm_version)
.to_string();
let platform_encoded = platform_pkg.replace('@', "%40").replace('/', "%2F");
let platform_meta_url = format!("https://registry.npmjs.org/{platform_encoded}");
let platform_meta = client
.get(&platform_meta_url)
.header("Accept", "application/json")
.send()
.await
.context("npm platform package metadata")?;
let tarball_url = if platform_meta.status().is_success() {
let body: Value = platform_meta.json().await?;
body.pointer(&format!("/versions/{npm_version}/dist/tarball"))
.and_then(|v| v.as_str())
.map(str::to_string)
.ok_or_else(|| {
anyhow::anyhow!("{platform_pkg}@{npm_version} has no dist.tarball on npm")
})?
} else {
version_body
.pointer("/dist/tarball")
.and_then(|v| v.as_str())
.map(str::to_string)
.ok_or_else(|| anyhow::anyhow!("@sylphx/cli@{npm_version} has no dist.tarball"))?
};
Ok(NpmCliRelease {
npm_version,
crate_version,
platform_pkg,
tarball_url,
})
}
async fn download_npm_binary(
client: &reqwest::Client,
release: &NpmCliRelease,
) -> anyhow::Result<(Vec<u8>, String)> {
let (os, arch) = host_platform()?;
let resp = client
.get(&release.tarball_url)
.send()
.await
.context("download npm tarball")?
.error_for_status()
.with_context(|| format!("npm tarball HTTP error for {}", release.tarball_url))?;
let bytes = resp.bytes().await.context("read npm tarball")?.to_vec();
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let tmp = std::env::temp_dir().join(format!("sylphx-update-{stamp}"));
std::fs::create_dir_all(&tmp).context("create extract dir")?;
let tgz = tmp.join("cli.tgz");
std::fs::write(&tgz, &bytes).context("write tarball")?;
let status = std::process::Command::new("tar")
.args(["-xzf"])
.arg(&tgz)
.current_dir(&tmp)
.status()
.context("run tar")?;
if !status.success() {
let _ = std::fs::remove_dir_all(&tmp);
anyhow::bail!("tar extract failed for {}", release.tarball_url);
}
let candidates = [
tmp.join("package/bin/sylphx"),
tmp.join(format!("package/binaries/{os}-{arch}/sylphx")),
tmp.join("package/bin/sylphx.exe"),
];
for path in candidates {
if path.is_file() {
let bin = std::fs::read(&path)
.with_context(|| format!("read extracted binary {}", path.display()))?;
let _ = std::fs::remove_dir_all(&tmp);
return Ok((bin, release.tarball_url.clone()));
}
}
let _ = std::fs::remove_dir_all(&tmp);
anyhow::bail!(
"npm package {}@{} did not contain a sylphx binary for {os}-{arch}",
release.platform_pkg,
release.npm_version
);
}
async fn download_cli_binary(
client: &reqwest::Client,
pin: Option<&str>,
) -> anyhow::Result<(Vec<u8>, String, NpmCliRelease)> {
let rel = fetch_npm_cli_release(client, pin).await?;
let (bytes, url) = download_npm_binary(client, &rel).await?;
Ok((bytes, url, rel))
}
pub async fn update(
json: bool,
check_only: bool,
version: Option<String>,
force_release_binary: bool,
) -> anyhow::Result<()> {
let channel = if force_release_binary {
channel::InstallChannel::Standalone
} else {
channel::detect_install_channel()
};
let current = env!("CARGO_PKG_VERSION");
let client = reqwest::Client::builder()
.user_agent(format!("sylphx-cli/{current} (+https://sylphx.com)"))
.redirect(reqwest::redirect::Policy::limited(10))
.build()
.context("http client")?;
let npm_latest = fetch_npm_cli_release(&client, None).await.ok();
let crates_latest = channel::fetch_latest_release_version().await.ok();
let latest = match (&crates_latest, &npm_latest) {
(Some(c), Some(n)) => {
if channel::is_outdated(c, &n.crate_version) {
Some(n.crate_version.clone())
} else {
Some(c.clone())
}
}
(Some(c), None) => Some(c.clone()),
(None, Some(n)) => Some(n.crate_version.clone()),
(None, None) => None,
};
let pin = version.as_deref().filter(|s| *s != "latest");
let target_ver = if let Some(p) = pin {
p.strip_prefix("cli-v")
.or_else(|| p.strip_prefix('v'))
.unwrap_or(p)
.to_string()
} else {
latest.clone().unwrap_or_else(|| current.to_string())
};
let outdated = latest
.as_deref()
.map(|l| channel::is_outdated(current, l))
.unwrap_or(false);
let pin_differs = pin.is_some() && target_ver != current;
let needs_upgrade = outdated || pin_differs;
if check_only {
let body = json!({
"current": current,
"latest": latest,
"target": target_ver,
"outdated": outdated,
"needsUpgrade": needs_upgrade,
"installMethod": channel.as_str(),
"installMethodLabel": channel.display_name(),
"upgradeCommand": channel.upgrade_command(),
"standaloneSelfUpdate": channel.allows_standalone_self_update() || force_release_binary,
"npmVersion": npm_latest.as_ref().map(|r| &r.npm_version),
"checkOnly": true,
});
ux::print_out(json, &body, || {
println!("current {current}");
if let Some(l) = &latest {
println!("latest {l}");
println!("outdated {outdated}");
}
println!("install {}", channel.display_name());
println!("upgrade {}", channel.upgrade_command());
});
return Ok(());
}
if !needs_upgrade {
let body = json!({
"ok": true,
"current": current,
"latest": latest,
"outdated": false,
"installMethod": channel.as_str(),
"message": "already up to date",
});
ux::print_out(json, &body, || {
ux::ok_line(&format!("sylphx {current} is current"));
});
return Ok(());
}
if matches!(channel, channel::InstallChannel::Unknown) && !force_release_binary {
let body = json!({
"ok": false,
"error": "install_method_unknown",
"upgradeCommand": channel.upgrade_command(),
"docs": "https://sylphx.com/docs/cli/update",
});
ux::print_out(json, &body, || {
println!("Could not detect how sylphx was installed.");
println!(" See: https://sylphx.com/docs/cli/update");
println!(" or: sylphx update --force-release-binary");
});
anyhow::bail!("could not detect install method — see https://sylphx.com/docs/cli/update");
}
if let Some((cmd, args)) = channel.upgrade_argv() {
let cmd_str = channel.upgrade_command();
if !json {
println!("Updating via `{cmd_str}`...");
}
channel::run_package_manager_upgrade(channel)?;
let body = json!({
"ok": true,
"previous": current,
"installMethod": channel.as_str(),
"upgradeCommand": cmd_str,
"message": "package manager upgrade completed",
});
let _ = (cmd, args);
ux::print_out(json, &body, || {
ux::ok_line("update ran successfully — restart any long-running sessions");
});
return Ok(());
}
let dest = std::env::current_exe().context("resolve current executable")?;
let tmp = dest.with_extension("download");
if !json {
println!("Updating…");
}
let download_pin = pin.or(Some(target_ver.as_str()));
let (bytes, source_url, rel) = download_cli_binary(&client, download_pin)
.await
.map_err(|e| {
anyhow::anyhow!(
"{e}
Fallbacks while npm adapters catch up:
cargo install sylphx-cli --locked --force
npm install -g @sylphx/cli@latest"
)
})?;
std::fs::write(&tmp, &bytes).with_context(|| format!("write {}", tmp.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))?;
}
let probe = std::process::Command::new(&tmp)
.arg("--version")
.output()
.context("probe downloaded binary")?;
if !probe.status.success() {
let stderr = String::from_utf8_lossy(&probe.stderr);
let stdout = String::from_utf8_lossy(&probe.stdout);
let _ = std::fs::remove_file(&tmp);
anyhow::bail!(
"downloaded asset is not runnable on this host ({})\n\
stdout: {}\n\
stderr: {}\n\
Hint: prebuilt binaries may need a newer glibc than this image.\n\
Fallbacks: cargo install sylphx-cli --locked --force\n\
npm install -g @sylphx/cli@latest",
probe.status,
stdout.trim(),
stderr.trim()
);
}
let new_ver = String::from_utf8_lossy(&probe.stdout).trim().to_string();
std::fs::rename(&tmp, &dest).with_context(|| {
format!(
"replace {} — permission denied? try: {}",
dest.display(),
channel.upgrade_command()
)
})?;
let _ = channel::write_install_method_marker(channel::InstallChannel::Standalone);
let body = json!({
"ok": true,
"previous": current,
"installed": new_ver,
"path": dest.display().to_string(),
"url": source_url,
"npmVersion": rel.npm_version,
"crateVersion": rel.crate_version,
"installMethod": channel.as_str(),
"forcedStandalone": force_release_binary,
});
ux::print_out(json, &body, || {
ux::ok_line(&format!("updated → {new_ver}"));
println!(" {}", dest.display());
});
Ok(())
}
pub fn print_log_line(line: &sylphx_apps_api_types::LogLine) {
let ts = if line.timestamp.len() > 19 {
&line.timestamp[..19]
} else {
line.timestamp.as_str()
};
let level = line.level.as_deref().unwrap_or("info");
let lvl = match level.to_ascii_lowercase().as_str() {
"error" | "err" | "fatal" | "crit" | "critical" => ux::red(level),
"warn" | "warning" => ux::yellow(level),
"debug" | "trace" => ux::muted(level),
_ => ux::cyan(level),
};
let svc = line
.service
.as_deref()
.map(|s| format!(" {}", ux::dim(&format!("[{s}]"))))
.unwrap_or_default();
println!("{} {}{} {}", ux::muted(ts), lvl, svc, line.message);
}
pub async fn logs_follow(
json: bool,
client: &ManagementClient,
project: Option<&str>,
environment: Option<&str>,
log_type: &str,
env_type: Option<&str>,
tail: u32,
level: Option<&str>,
service: Option<&str>,
grep: Option<&str>,
interval_secs: u64,
org: Option<&str>,
) -> anyhow::Result<()> {
use std::collections::HashSet;
if json {
anyhow::bail!("logs --follow does not support --json (stream is human/line oriented)");
}
let interval = Duration::from_secs(interval_secs.max(1));
let mut seen: HashSet<String> = HashSet::new();
let mut first = true;
eprintln!(
"{} following logs (poll {}s) — Ctrl-C to stop",
ux::mark_info(),
interval.as_secs()
);
loop {
let list = client
.list_logs(
project,
environment,
Some(log_type),
env_type,
Some(if first { tail.max(1) } else { tail.max(20) }),
level,
service,
grep,
org,
)
.await
.map_err(ux::map_sdk_err)?;
for line in &list.data {
let key = format!("{}|{}|{}", line.timestamp, line.level.as_deref().unwrap_or(""), line.message);
if seen.insert(key) {
print_log_line(line);
}
}
if seen.len() > 4096 {
seen.clear();
for line in list.data.iter().rev().take(200) {
seen.insert(format!(
"{}|{}|{}",
line.timestamp,
line.level.as_deref().unwrap_or(""),
line.message
));
}
}
first = false;
tokio::time::sleep(interval).await;
}
}
fn is_terminal_project_status(state: &str) -> bool {
matches!(
state.to_ascii_lowercase().as_str(),
"running"
| "ready"
| "live"
| "healthy"
| "succeeded"
| "success"
| "failed"
| "error"
| "cancelled"
| "canceled"
)
}
fn is_failed_project_status(state: &str) -> bool {
matches!(
state.to_ascii_lowercase().as_str(),
"failed" | "error" | "cancelled" | "canceled"
)
}
pub async fn wait_status(
json: bool,
client: &ManagementClient,
project_id: &str,
timeout_secs: u64,
interval_secs: u64,
org_id: Option<&str>,
) -> anyhow::Result<()> {
let start = std::time::Instant::now();
let timeout = Duration::from_secs(timeout_secs);
let interval = Duration::from_secs(interval_secs.max(1));
loop {
let status = client
.get_project_status(project_id, org_id)
.await
.map_err(ux::map_sdk_err)?;
let val = serde_json::to_value(&status)?;
let state = val
.pointer("/status")
.or_else(|| val.pointer("/deployment/status"))
.or_else(|| val.pointer("/current/status"))
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
if !json {
println!(
" {} project={} state={} elapsed={}s",
ux::mark_info(),
ux::muted(project_id),
ux::status_badge(&state),
start.elapsed().as_secs()
);
}
let terminal = is_terminal_project_status(&state);
if terminal {
ux::print_out(json, &val, || {
ux::ok_line(&format!("terminal state={}", ux::status_badge(&state)))
});
if is_failed_project_status(&state) {
anyhow::bail!("project reached failure state: {state}");
}
return Ok(());
}
if start.elapsed() > timeout {
ux::print_out(json, &val, || {
ux::warn_line(&format!(
"timeout waiting for project {project_id} (last state={})",
ux::status_badge(&state)
));
});
anyhow::bail!("wait timed out after {timeout_secs}s");
}
tokio::time::sleep(interval).await;
}
}
pub async fn api_json(
client: &ManagementClient,
method: reqwest::Method,
path: &str,
body: Option<&Value>,
org_id: Option<&str>,
) -> anyhow::Result<(u16, Value)> {
client
.api(method, path, body, org_id)
.await
.map_err(ux::map_sdk_err)
}
pub fn enc(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
async fn emit_api(
json: bool,
client: &ManagementClient,
method: reqwest::Method,
path: &str,
body: Option<&Value>,
org: Option<&str>,
) -> anyhow::Result<()> {
let (status, body) = api_json(client, method, path, body, org).await?;
ux::print_out(json, &json!({"status": status, "body": body}), || {
let badge = if (200..300).contains(&status) {
ux::green(&format!("HTTP {status}"))
} else if (400..500).contains(&status) {
ux::yellow(&format!("HTTP {status}"))
} else if status >= 500 {
ux::red(&format!("HTTP {status}"))
} else {
format!("HTTP {status}")
};
println!("{} {}", badge, ux::dim(path));
ux::print_json_human(&body);
});
Ok(())
}
pub async fn rest_get(
json: bool,
client: &ManagementClient,
path: &str,
org: Option<&str>,
) -> anyhow::Result<()> {
emit_api(json, client, reqwest::Method::GET, path, None, org).await
}
pub async fn rest_mut(
json: bool,
client: &ManagementClient,
method: reqwest::Method,
path: &str,
body: Option<&Value>,
org: Option<&str>,
) -> anyhow::Result<()> {
emit_api(json, client, method, path, body, org).await
}
pub async fn inspect_project(
json: bool,
client: &ManagementClient,
project: &str,
org: Option<&str>,
) -> anyhow::Result<()> {
let status = client
.get_project_status(project, org)
.await
.map_err(ux::map_sdk_err)?;
let project_obj = client
.get_project(project, org)
.await
.map_err(ux::map_sdk_err)?;
let envs = client
.list_environments(project, None, org)
.await
.map_err(ux::map_sdk_err)?;
let deploys = client
.delivery()
.with_organization(org)
.list_deployments(sylphx_apps_api_types::ListDeploymentsRequest {
project_id: project.into(),
..Default::default()
})
.await
.map_err(ux::map_sdk_err)?;
let (svc_status, services) = api_json(
client,
reqwest::Method::GET,
&format!("/projects/{}/services", enc(project)),
None,
org,
)
.await
.unwrap_or((0, Value::Null));
let body = json!({
"project": project_obj,
"status": status,
"environments": envs,
"deployments": deploys,
"services": { "status": svc_status, "body": services },
});
ux::print_out(json, &body, || {
println!("inspect {project}");
println!("{}", serde_json::to_string_pretty(&body).unwrap_or_default());
});
Ok(())
}
pub fn catalog_entries() -> Vec<Value> {
let rows = [
("health", "first-class", "GET /health"),
("login/logout/whoami", "first-class", "auth bootstrap"),
("doctor/config/context/link/open/update", "first-class", "operator UX"),
("orgs list", "first-class", "from whoami"),
("projects list|get|create", "first-class", "/projects"),
("environments list|get", "first-class", "/projects/:id/environments"),
("deployments list|get|create", "first-class", "/projects/:id/deployments"),
("status|wait", "first-class", "project runtime observation"),
("env list|set|rm|pull", "first-class", "/projects/:id/env-vars"),
("logs|tail", "first-class", "/projects/:id/logs"),
("resources *", "first-class", "/resources"),
("db *", "first-class", "resources kind=database"),
("storage *", "first-class", "/projects/:id/storage"),
("work *", "first-class", "/work/container"),
("backup *", "first-class", "/backups"),
("flags *", "first-class", "/flags"),
("previews cost|list", "first-class", "/projects/:id/previews"),
("domains *", "first-class", "/projects/:id/domains"),
("secrets *", "first-class", "/secrets|/projects/:id/secrets"),
("tokens *", "first-class", "/orgs/:id/service-tokens"),
("billing usage|plan", "first-class", "/billing/*"),
("services *", "first-class", "/projects/:id/services"),
("webhooks *", "first-class", "/webhooks|/projects/:id/webhooks"),
("certs list", "first-class", "/projects/:id/certs"),
("releases list|get|create|promote|receipts|decisions", "first-class", "/projects/:id/environments/:environment/releases"),
("runners list", "first-class", "/runners"),
("sandbox list|get|create", "first-class", "/sandboxes"),
(
"computer profiles list|get",
"first-class",
"/computer-profiles",
),
(
"computer lease create|get|capabilities|renew|reclaim",
"first-class",
"/projects/:id/computer-leases",
),
("volumes list", "first-class", "/volumes"),
("users me", "first-class", "/users/me|/whoami"),
("inspect", "first-class", "project summary bundle"),
("init", "first-class", "scaffold + optional link"),
("catalog", "first-class", "this surface map"),
("admin *", "first-class", "/admin/* via passthrough"),
("delivery configuration get", "first-class", "/projects/:id/environments/:environment/services/:service/delivery-configuration"),
("delivery policy get|patch", "first-class", "/projects/:id/environments/:environment/delivery-policy"),
("api METHOD PATH", "escape-hatch", "any Management REST route"),
("account/ai/analytics/consent/email/engagement", "api-escape", "use: sylphx api get /…"),
("experiments/insights/monitoring/newsletter", "api-escape", "use: sylphx api get /…"),
("notifications/oidc/plan/privacy", "api-escape", "use: sylphx api …"),
("realtime/referrals/saml/search/session-replay", "api-escape", "use: sylphx api …"),
("device-sessions *", "first-class", "/projects/:id/device-sessions|/device-sessions/:id"),
("device-tests *", "first-class", "/projects/:id/mobile/device-tests|/mobile/device-tests/:id"),
("workspaces", "api-escape", "use: sylphx api …"),
("migrate/bisect/dev/run", "out-of-band", "local/dev tooling — not Management SSOT"),
];
rows
.into_iter()
.map(|(command, kind, notes)| {
json!({
"command": command,
"kind": kind,
"notes": notes,
})
})
.collect()
}
pub fn print_catalog(json_mode: bool) {
let entries = catalog_entries();
ux::print_out(json_mode, &json!({ "surface": entries, "plane": "management" }), || {
println!("Sylphx Management operator surface catalog");
println!("plane: management (api.sylphx.com/v1) — not BaaS app runtime");
println!();
for e in &entries {
println!(
" {:40} {:14} {}",
e["command"].as_str().unwrap_or("-"),
e["kind"].as_str().unwrap_or("-"),
e["notes"].as_str().unwrap_or("-"),
);
}
println!();
println!("Any residual Management route: sylphx api get|post|… /path --org-id …");
});
}
pub async fn init_project(
json: bool,
name: &str,
org_id: Option<&str>,
link: bool,
) -> anyhow::Result<()> {
let dir = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
let sylphx_dir = dir.join(".sylphx");
std::fs::create_dir_all(&sylphx_dir)?;
let marker = sylphx_dir.join("init.json");
let body = json!({
"name": name,
"orgId": org_id,
"createdAt": chrono_like_now(),
"cli": env!("CARGO_PKG_VERSION"),
});
std::fs::write(&marker, serde_json::to_string_pretty(&body)?)?;
let gi = dir.join(".gitignore");
if gi.exists() {
let raw = std::fs::read_to_string(&gi).unwrap_or_default();
if !raw.lines().any(|l| l.trim() == ".sylphx/") {
let mut n = raw;
if !n.ends_with('\n') && !n.is_empty() {
n.push('\n');
}
n.push_str("# Sylphx local link/context\n.sylphx/\n");
let _ = std::fs::write(&gi, n);
}
}
let out = json!({
"ok": true,
"path": marker.display().to_string(),
"name": name,
"orgId": org_id,
"linkHint": if link { "pass --project after create to link" } else { "sylphx link --project <id>" },
});
ux::print_out(json, &out, || {
ux::ok_line(&format!("initialized .sylphx for {name}"));
println!(" {}", marker.display());
println!("Next: sylphx projects create {name} --org-id <org> && sylphx link --project <id>");
});
Ok(())
}
fn chrono_like_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
format!("{secs}")
}
pub async fn domains_list(json: bool, client: &ManagementClient, project: &str, org: Option<&str>) -> anyhow::Result<()> {
match client.list_project_domains(project, org).await {
Ok(list) => {
ux::print_out(json, &list, || {
let rows: Vec<Vec<String>> = list
.data
.iter()
.map(|d| {
let domain = d
.apex_domain
.clone()
.or_else(|| d.domain.clone())
.unwrap_or_else(|| "-".into());
let host_count = d.hostnames.len();
let active = d
.hostnames
.iter()
.filter(|h| {
h.dns_status
.as_deref()
.map(|s| s.eq_ignore_ascii_case("active"))
.unwrap_or(false)
})
.count();
let status = if host_count == 0 {
d.status
.as_deref()
.map(ux::status_badge)
.unwrap_or_else(|| "-".into())
} else if active == host_count {
ux::green(&format!("active ({host_count})"))
} else {
ux::yellow(&format!("{active}/{host_count} active"))
};
let hosts = if host_count == 0 {
"-".into()
} else {
d.hostnames
.iter()
.filter_map(|h| h.hostname.clone())
.take(3)
.collect::<Vec<_>>()
.join(", ")
};
vec![d.id.clone(), domain, hosts, status]
})
.collect();
ux::print_table(&["ID", "APEX", "HOSTNAMES", "DNS"], &rows);
println!("{}", ux::dim(&format!("{} domain(s)", list.data.len())));
});
Ok(())
}
Err(_) => rest_get(json, client, &format!("/projects/{}/domains", enc(project)), org).await,
}
}
pub async fn domains_get(json: bool, client: &ManagementClient, project: &str, domain_id: &str, org: Option<&str>) -> anyhow::Result<()> {
rest_get(json, client, &format!("/projects/{}/domains/{}", enc(project), enc(domain_id)), org).await
}
pub async fn domains_add(json: bool, client: &ManagementClient, project: &str, domain: &str, org: Option<&str>) -> anyhow::Result<()> {
rest_mut(json, client, reqwest::Method::POST, &format!("/projects/{}/domains", enc(project)), Some(&json!({"domain": domain})), org).await
}
pub async fn domains_rm(json: bool, client: &ManagementClient, project: &str, domain_id: &str, org: Option<&str>) -> anyhow::Result<()> {
rest_mut(json, client, reqwest::Method::DELETE, &format!("/projects/{}/domains/{}", enc(project), enc(domain_id)), None, org).await
}
fn secrets_list_path(project: Option<&str>) -> anyhow::Result<String> {
let p = project
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
anyhow::anyhow!(
"project required for secrets list (pass --project or sylphx link --project …)"
)
})?;
Ok(format!("/secrets?projectId={}", enc(p)))
}
pub async fn secrets_list(json: bool, client: &ManagementClient, project: Option<&str>, org: Option<&str>) -> anyhow::Result<()> {
let path = secrets_list_path(project)?;
match client
.api(reqwest::Method::GET, &path, None, org)
.await
{
Ok((status, body)) if (200..300).contains(&status) => {
ux::print_out(json, &body, || {
let secrets = body
.get("secrets")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let rows: Vec<Vec<String>> = secrets
.iter()
.map(|s| {
let key = s
.get("key")
.and_then(|v| v.as_str())
.unwrap_or("-")
.to_string();
let id = s
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("-")
.to_string();
let env = s
.pointer("/environment/name")
.or_else(|| s.pointer("/environment/id"))
.and_then(|v| v.as_str())
.unwrap_or("-")
.to_string();
let version = s
.get("version")
.map(|v| match v {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Number(n) => n.to_string(),
_ => "-".into(),
})
.unwrap_or_else(|| "-".into());
let active = match s.get("isActive").and_then(|v| v.as_bool()) {
Some(true) => ux::green("active"),
Some(false) => ux::muted("inactive"),
None => "-".into(),
};
vec![id, key, env, version, active]
})
.collect();
ux::print_table(&["ID", "KEY", "ENV", "VER", "STATE"], &rows);
println!("{}", ux::dim(&format!("{} secret(s)", rows.len())));
});
Ok(())
}
Ok((status, body)) => {
anyhow::bail!("api {status}: {}", body);
}
Err(e) => Err(ux::map_sdk_err(e)),
}
}
pub async fn secrets_set(json: bool, client: &ManagementClient, name: &str, value: &str, project: Option<&str>, org: Option<&str>) -> anyhow::Result<()> {
let project = project
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("project required for secrets set"))?;
let body = json!({
"projectId": project,
"key": name,
"name": name,
"value": value,
});
rest_mut(json, client, reqwest::Method::POST, "/secrets", Some(&body), org).await
}
pub async fn secrets_rm(json: bool, client: &ManagementClient, name: &str, project: Option<&str>, org: Option<&str>) -> anyhow::Result<()> {
let project = project
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("project required for secrets rm"))?;
let id = if name.starts_with("sec_") {
name.to_string()
} else {
let path = format!("/secrets?projectId={}", enc(project));
let (_status, body) = client
.api(reqwest::Method::GET, &path, None, org)
.await
.map_err(ux::map_sdk_err)?;
let secrets = body
.get("secrets")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
secrets
.iter()
.find_map(|s| {
let key = s.get("key")?.as_str()?;
if key == name {
s.get("id")?.as_str().map(str::to_string)
} else {
None
}
})
.ok_or_else(|| anyhow::anyhow!("secret key not found: {name}"))?
};
let body = json!({ "id": id });
rest_mut(json, client, reqwest::Method::DELETE, "/secrets", Some(&body), org).await
}
pub async fn tokens_list(json: bool, client: &ManagementClient, org: &str) -> anyhow::Result<()> {
rest_get(json, client, &format!("/orgs/{}/service-tokens", enc(org)), Some(org)).await
}
pub async fn tokens_create(json: bool, client: &ManagementClient, org: &str, name: &str, scopes: &str, project: Option<&str>) -> anyhow::Result<()> {
let scope_list: Vec<&str> = scopes.split(',').map(str::trim).filter(|s| !s.is_empty()).collect();
let mut body = json!({"name": name, "scopes": scope_list});
if let Some(p) = project { body["projectId"] = json!(p); }
rest_mut(json, client, reqwest::Method::POST, &format!("/orgs/{}/service-tokens", enc(org)), Some(&body), Some(org)).await
}
pub async fn tokens_rm(json: bool, client: &ManagementClient, org: &str, token_id: &str) -> anyhow::Result<()> {
rest_mut(json, client, reqwest::Method::DELETE, &format!("/orgs/{}/service-tokens/{}", enc(org), enc(token_id)), None, Some(org)).await
}
pub async fn tokens_rotate(json: bool, client: &ManagementClient, org: &str, token_id: &str) -> anyhow::Result<()> {
rest_mut(json, client, reqwest::Method::POST, &format!("/orgs/{}/service-tokens/{}/rotate", enc(org), enc(token_id)), Some(&json!({})), Some(org)).await
}
pub async fn billing_usage(json: bool, client: &ManagementClient, org: Option<&str>) -> anyhow::Result<()> {
rest_get(json, client, "/billing/usage", org).await
}
pub async fn billing_plan(json: bool, client: &ManagementClient, org: Option<&str>) -> anyhow::Result<()> {
let path = if let Some(o) = org {
format!("/orgs/{}/billing/plan", enc(o))
} else {
"/billing/plan".into()
};
rest_get(json, client, &path, org).await
}
pub async fn services_list(json: bool, client: &ManagementClient, project: &str, org: Option<&str>) -> anyhow::Result<()> {
match client.list_project_services(project, org).await {
Ok(list) => {
ux::print_out(json, &list, || {
let rows: Vec<Vec<String>> = list
.data
.iter()
.map(|s| {
vec![
s.id.clone(),
s.name.clone(),
s.slug.clone(),
s.source_kind.clone().unwrap_or_else(|| "-".into()),
if s.is_active.unwrap_or(false) {
ux::green("active")
} else {
ux::muted("inactive")
},
]
})
.collect();
ux::print_table(&["ID", "NAME", "SLUG", "SOURCE", "STATE"], &rows);
println!("{}", ux::dim(&format!("{} service(s)", list.data.len())));
});
Ok(())
}
Err(_) => rest_get(json, client, &format!("/projects/{}/services", enc(project)), org).await,
}
}
pub async fn webhooks_list(json: bool, client: &ManagementClient, project: Option<&str>, org: Option<&str>) -> anyhow::Result<()> {
let path = if let Some(p) = project.filter(|s| !s.is_empty()) {
format!("/projects/{}/webhooks", enc(p))
} else {
"/webhooks".into()
};
rest_get(json, client, &path, org).await
}
pub async fn certs_list(json: bool, client: &ManagementClient, project: &str, org: Option<&str>) -> anyhow::Result<()> {
rest_get(json, client, &format!("/projects/{}/certs", enc(project)), org).await
}
pub async fn runners_list(json: bool, client: &ManagementClient, org: Option<&str>) -> anyhow::Result<()> {
rest_get(json, client, "/runners", org).await
}
pub async fn sandbox_list(json: bool, client: &ManagementClient, org: Option<&str>) -> anyhow::Result<()> {
rest_get(json, client, "/sandboxes", org).await
}
pub async fn sandbox_get(json: bool, client: &ManagementClient, id: &str, org: Option<&str>) -> anyhow::Result<()> {
rest_get(json, client, &format!("/sandboxes/{}", enc(id)), org).await
}
pub async fn sandbox_create(json: bool, client: &ManagementClient, name: &str, org: Option<&str>) -> anyhow::Result<()> {
rest_mut(json, client, reqwest::Method::POST, "/sandboxes", Some(&json!({"name": name})), org).await
}
pub fn typeid_or_uuid_to_uuid(raw: &str) -> anyhow::Result<String> {
let raw = raw.trim();
if raw.len() == 36 && raw.chars().filter(|c| *c == '-').count() == 4 {
return Ok(raw.to_string());
}
let suf = if let Some((prefix, rest)) = raw.split_once('_') {
if !matches!(prefix, "proj" | "project" | "org" | "user" | "env") {
anyhow::bail!("unsupported typeid prefix: {prefix}");
}
rest
} else {
raw
};
if suf.len() != 26 {
anyhow::bail!("expected TypeID suffix length 26, got {}", suf.len());
}
const ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz";
let mut n: u128 = 0;
for c in suf.bytes() {
let c = match c {
b'A'..=b'Z' => c + 32,
_ => c,
};
let idx = ALPHABET
.iter()
.position(|a| *a == c)
.with_context(|| format!("invalid crockford char in {suf}"))?;
n = n
.checked_mul(32)
.and_then(|v| v.checked_add(idx as u128))
.context("typeid overflow")?;
}
let b = n.to_be_bytes();
Ok(format!(
"{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}",
b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7], b[8], b[9], b[10], b[11], b[12], b[13], b[14], b[15]
))
}
fn new_idempotency_key() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("idem_{nanos:x}")
}
async fn computer_emit(
json: bool,
client: &ManagementClient,
method: reqwest::Method,
path: &str,
body: Option<&Value>,
org: Option<&str>,
extra: &[(&str, &str)],
) -> anyhow::Result<()> {
let (status, body) = client
.api_with_headers(method, path, body, org, extra)
.await
.map_err(ux::map_sdk_err)?;
ux::print_out(json, &json!({"status": status, "body": body}), || {
if !json {
println!("HTTP {status}");
}
println!("{}", serde_json::to_string_pretty(&body).unwrap_or_default());
});
if status >= 400 {
anyhow::bail!("computer api HTTP {status}");
}
Ok(())
}
pub async fn device_sessions_list(
json: bool,
client: &ManagementClient,
project: &str,
org: Option<&str>,
status: Option<&str>,
environment_id: Option<&str>,
) -> anyhow::Result<()> {
let mut path = format!("/projects/{}/device-sessions", enc(project));
let mut q: Vec<String> = Vec::new();
if let Some(s) = status.filter(|s| !s.is_empty()) {
q.push(format!("status={}", enc(s)));
}
if let Some(e) = environment_id.filter(|s| !s.is_empty()) {
q.push(format!("environmentId={}", enc(e)));
}
if !q.is_empty() {
path.push('?');
path.push_str(&q.join("&"));
}
rest_get(json, client, &path, org).await
}
pub async fn device_sessions_create(
json: bool,
client: &ManagementClient,
project: &str,
org: Option<&str>,
name: Option<&str>,
environment_id: Option<&str>,
platform: &str,
runtime: Option<&str>,
os_version: Option<&str>,
model: Option<&str>,
locale: Option<&str>,
idle_ttl_secs: Option<u64>,
stream: bool,
wait: bool,
wait_timeout_secs: u64,
poll_interval_secs: u64,
) -> anyhow::Result<()> {
let mut profile = json!({ "platform": platform });
if let Some(r) = runtime {
profile["runtime"] = json!(r);
}
if let Some(v) = os_version {
profile["osVersion"] = json!(v);
}
if let Some(m) = model {
profile["model"] = json!(m);
}
if let Some(l) = locale {
profile["locale"] = json!(l);
}
let mut body = json!({ "deviceProfile": profile, "stream": stream });
if let Some(n) = name {
body["name"] = json!(n);
}
if let Some(e) = environment_id {
body["environmentId"] = json!(e);
}
if let Some(ttl) = idle_ttl_secs {
body["idleTtlSeconds"] = json!(ttl);
}
let path = format!("/projects/{}/device-sessions", enc(project));
let (status, created) = api_json(
client,
reqwest::Method::POST,
&path,
Some(&body),
org,
)
.await?;
if status >= 400 {
ux::print_out(json, &created, || {
println!("{}", serde_json::to_string_pretty(&created).unwrap_or_default());
});
anyhow::bail!("device-sessions create HTTP {status}");
}
if !wait {
ux::print_out(json, &created, || {
println!("{}", serde_json::to_string_pretty(&created).unwrap_or_default());
});
return Ok(());
}
let session_id = created
.pointer("/session/id")
.or_else(|| created.get("id"))
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("device-sessions create response missing session id"))?
.to_string();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(wait_timeout_secs);
loop {
let (_st, cur) = api_json(
client,
reqwest::Method::GET,
&format!("/device-sessions/{}", enc(&session_id)),
None,
org,
)
.await?;
let status_s = cur
.pointer("/session/status")
.or_else(|| cur.get("status"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if status_s == "ready" || status_s == "terminated" || status_s == "errored" {
ux::print_out(json, &cur, || {
if status_s == "ready" {
println!("{} Device session ready", ux::mark_pass());
} else {
println!("{} Device session status={status_s}", ux::mark_warn());
}
println!(" id {session_id}");
println!(" status {status_s}");
if let Some(p) = cur.pointer("/session/deviceProfile/platform").or_else(|| cur.pointer("/deviceProfile/platform")).and_then(|v| v.as_str()) {
println!(" device {p}");
}
});
if status_s != "ready" {
anyhow::bail!("device session ended status={status_s}");
}
return Ok(());
}
if std::time::Instant::now() >= deadline {
ux::print_out(json, &cur, || {
println!("{}", serde_json::to_string_pretty(&cur).unwrap_or_default());
});
anyhow::bail!("device-sessions wait timed out status={status_s}");
}
tokio::time::sleep(std::time::Duration::from_secs(poll_interval_secs.max(1))).await;
}
}
pub async fn device_sessions_get(
json: bool,
client: &ManagementClient,
session_id: &str,
org: Option<&str>,
) -> anyhow::Result<()> {
rest_get(json, client, &format!("/device-sessions/{}", enc(session_id)), org).await
}
pub async fn device_sessions_install(
json: bool,
client: &ManagementClient,
session_id: &str,
apk_uri: &str,
org: Option<&str>,
) -> anyhow::Result<()> {
let body = json!({ "apkUri": apk_uri });
rest_mut(
json,
client,
reqwest::Method::POST,
&format!("/device-sessions/{}/install", enc(session_id)),
Some(&body),
org,
)
.await
}
pub async fn device_sessions_launch(
json: bool,
client: &ManagementClient,
session_id: &str,
package: Option<&str>,
org: Option<&str>,
) -> anyhow::Result<()> {
let mut body = json!({});
if let Some(p) = package {
body["package"] = json!(p);
}
rest_mut(
json,
client,
reqwest::Method::POST,
&format!("/device-sessions/{}/launch", enc(session_id)),
Some(&body),
org,
)
.await
}
pub async fn device_sessions_input(
json: bool,
client: &ManagementClient,
session_id: &str,
body: &Value,
org: Option<&str>,
) -> anyhow::Result<()> {
rest_mut(
json,
client,
reqwest::Method::POST,
&format!("/device-sessions/{}/input", enc(session_id)),
Some(body),
org,
)
.await
}
pub async fn device_sessions_screenshot(
json: bool,
client: &ManagementClient,
session_id: &str,
output: Option<&std::path::Path>,
org: Option<&str>,
) -> anyhow::Result<()> {
let path = format!("/device-sessions/{}/screenshot", enc(session_id));
let (status, body) = api_json(client, reqwest::Method::GET, &path, None, org).await?;
if status >= 400 {
ux::print_out(json, &body, || {
println!("{}", serde_json::to_string_pretty(&body).unwrap_or_default());
});
anyhow::bail!("device-sessions screenshot HTTP {status}");
}
if let Some(out) = output {
let b64 = body
.get("pngBase64")
.or_else(|| body.get("base64"))
.or_else(|| body.pointer("/screenshot/base64"))
.or_else(|| body.get("data"))
.and_then(|v| v.as_str());
if let Some(b64) = b64 {
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64.trim())
.map_err(|e| anyhow::anyhow!("screenshot base64 decode: {e}"))?;
if let Some(parent) = out.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
std::fs::write(out, bytes)?;
if !json {
println!("{} wrote {}", ux::mark_pass(), out.display());
} else {
println!(
"{}",
serde_json::to_string_pretty(&json!({
"ok": true,
"path": out.display().to_string(),
"sessionId": session_id,
}))?
);
}
return Ok(());
}
}
ux::print_out(json, &body, || {
println!("{}", serde_json::to_string_pretty(&body).unwrap_or_default());
});
Ok(())
}
pub async fn device_sessions_logcat(
json: bool,
client: &ManagementClient,
session_id: &str,
lines: Option<u32>,
tag: Option<&str>,
org: Option<&str>,
) -> anyhow::Result<()> {
let mut path = format!("/device-sessions/{}/logcat", enc(session_id));
let mut q: Vec<String> = Vec::new();
if let Some(n) = lines {
q.push(format!("lines={n}"));
}
if let Some(t) = tag {
if !t.is_empty() {
q.push(format!("tag={}", enc(t)));
}
}
if !q.is_empty() {
path.push('?');
path.push_str(&q.join("&"));
}
rest_get(json, client, &path, org).await
}
pub async fn device_sessions_rm(
json: bool,
client: &ManagementClient,
session_id: &str,
org: Option<&str>,
) -> anyhow::Result<()> {
rest_mut(
json,
client,
reqwest::Method::DELETE,
&format!("/device-sessions/{}", enc(session_id)),
None,
org,
)
.await
}
pub async fn device_tests_list(
json: bool,
client: &ManagementClient,
project: &str,
org: Option<&str>,
status: Option<&str>,
environment_id: Option<&str>,
) -> anyhow::Result<()> {
let mut path = format!("/projects/{}/mobile/device-tests", enc(project));
let mut q: Vec<String> = Vec::new();
if let Some(s) = status.filter(|s| !s.is_empty()) {
q.push(format!("status={}", enc(s)));
}
if let Some(e) = environment_id.filter(|s| !s.is_empty()) {
q.push(format!("environmentId={}", enc(e)));
}
if !q.is_empty() {
path.push('?');
path.push_str(&q.join("&"));
}
rest_get(json, client, &path, org).await
}
pub async fn device_tests_create(
json: bool,
client: &ManagementClient,
project: &str,
org: Option<&str>,
body: &Value,
) -> anyhow::Result<()> {
rest_mut(
json,
client,
reqwest::Method::POST,
&format!("/projects/{}/mobile/device-tests", enc(project)),
Some(body),
org,
)
.await
}
pub async fn device_tests_get(
json: bool,
client: &ManagementClient,
test_id: &str,
org: Option<&str>,
) -> anyhow::Result<()> {
rest_get(json, client, &format!("/mobile/device-tests/{}", enc(test_id)), org).await
}
pub async fn device_tests_cancel(
json: bool,
client: &ManagementClient,
test_id: &str,
org: Option<&str>,
) -> anyhow::Result<()> {
rest_mut(
json,
client,
reqwest::Method::POST,
&format!("/mobile/device-tests/{}/cancel", enc(test_id)),
Some(&json!({})),
org,
)
.await
}
pub async fn device_tests_wait(
json: bool,
client: &ManagementClient,
test_id: &str,
org: Option<&str>,
timeout_secs: u64,
poll_interval_secs: u64,
) -> anyhow::Result<()> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
loop {
let (_st, body) = api_json(
client,
reqwest::Method::GET,
&format!("/mobile/device-tests/{}", enc(test_id)),
None,
org,
)
.await?;
let status_s = body
.pointer("/deviceTest/status")
.or_else(|| body.get("status"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
if matches!(
status_s.as_str(),
"passed" | "failed" | "errored" | "cancelled" | "expired"
) {
ux::print_out(json, &body, || {
println!("device-test id={test_id} status={status_s}");
println!("{}", serde_json::to_string_pretty(&body).unwrap_or_default());
});
if status_s != "passed" {
anyhow::bail!("device-test terminal status={status_s}");
}
return Ok(());
}
if std::time::Instant::now() >= deadline {
ux::print_out(json, &body, || {
println!("{}", serde_json::to_string_pretty(&body).unwrap_or_default());
});
anyhow::bail!("device-tests wait timed out status={status_s}");
}
tokio::time::sleep(std::time::Duration::from_secs(poll_interval_secs.max(1))).await;
}
}
pub async fn computer_profiles_list(
json: bool,
client: &ManagementClient,
org: Option<&str>,
) -> anyhow::Result<()> {
rest_get(json, client, "/computer-profiles", org).await
}
pub async fn computer_profiles_get(
json: bool,
client: &ManagementClient,
profile_id: &str,
org: Option<&str>,
) -> anyhow::Result<()> {
rest_get(
json,
client,
&format!("/computer-profiles/{}", enc(profile_id)),
org,
)
.await
}
pub async fn computer_lease_create(
json: bool,
client: &ManagementClient,
project: &str,
org: Option<&str>,
profile_id: &str,
ttl_secs: u64,
idle_timeout_secs: u64,
) -> anyhow::Result<()> {
let project_uuid = typeid_or_uuid_to_uuid(project)?;
let mut scope = json!({ "projectId": project_uuid });
if let Some(org_raw) = org {
if let Ok(org_uuid) = typeid_or_uuid_to_uuid(org_raw) {
scope["orgId"] = json!(org_uuid);
}
}
let body = json!({
"scope": scope,
"profileId": profile_id,
"profileRevision": "1",
"requestedTtl": format!("{ttl_secs}s"),
"requestedPacks": [],
"spec": {
"resourceClassOverride": "COMPUTER_LEASE_SIZE_CLASS_STANDARD",
"configuration": {
"idleTimeout": format!("{idle_timeout_secs}s"),
"ephemeralStorage": { "enabled": false },
"attachments": [],
"environmentBindingRefs": []
}
}
});
let key = new_idempotency_key();
computer_emit(
json,
client,
reqwest::Method::POST,
&format!("/projects/{}/computer-leases", enc(project)),
Some(&body),
org,
&[("Idempotency-Key", key.as_str())],
)
.await
}
pub async fn computer_lease_get(
json: bool,
client: &ManagementClient,
project: &str,
lease_id: &str,
org: Option<&str>,
) -> anyhow::Result<()> {
rest_get(
json,
client,
&format!(
"/projects/{}/computer-leases/{}",
enc(project),
enc(lease_id)
),
org,
)
.await
}
pub async fn computer_lease_capabilities(
json: bool,
client: &ManagementClient,
project: &str,
lease_id: &str,
lease_epoch: &str,
org: Option<&str>,
) -> anyhow::Result<()> {
rest_get(
json,
client,
&format!(
"/projects/{}/computer-leases/{}/capabilities?leaseEpoch={}",
enc(project),
enc(lease_id),
enc(lease_epoch)
),
org,
)
.await
}
pub async fn computer_lease_renew(
json: bool,
client: &ManagementClient,
project: &str,
lease_id: &str,
lease_epoch: &str,
fencing_token: &str,
extension_secs: u64,
org: Option<&str>,
) -> anyhow::Result<()> {
let body = json!({
"leaseEpoch": lease_epoch,
"extension": format!("{extension_secs}s")
});
let key = new_idempotency_key();
computer_emit(
json,
client,
reqwest::Method::POST,
&format!(
"/projects/{}/computer-leases/{}:renew",
enc(project),
enc(lease_id)
),
Some(&body),
org,
&[
("Idempotency-Key", key.as_str()),
("x-sylphx-fencing-token", fencing_token),
],
)
.await
}
pub async fn computer_lease_reclaim(
json: bool,
client: &ManagementClient,
project: &str,
lease_id: &str,
lease_epoch: &str,
fencing_token: &str,
reason_code: &str,
org: Option<&str>,
) -> anyhow::Result<()> {
let body = json!({
"leaseEpoch": lease_epoch,
"reasonCode": reason_code
});
let key = new_idempotency_key();
computer_emit(
json,
client,
reqwest::Method::POST,
&format!(
"/projects/{}/computer-leases/{}:reclaim",
enc(project),
enc(lease_id)
),
Some(&body),
org,
&[
("Idempotency-Key", key.as_str()),
("x-sylphx-fencing-token", fencing_token),
],
)
.await
}
pub async fn volumes_list(json: bool, client: &ManagementClient, org: Option<&str>) -> anyhow::Result<()> {
rest_get(json, client, "/volumes", org).await
}
pub async fn users_me(json: bool, client: &ManagementClient, token: &str) -> anyhow::Result<()> {
match client.whoami().await {
Ok(w) => {
ux::print_out(json, &w, || {
println!("{}", serde_json::to_string_pretty(&w).unwrap_or_default());
});
Ok(())
}
Err(e) => {
let msg = e.to_string();
if msg.contains("user_context_required")
|| credentials::token_kind(token) == "service_token"
{
let body = serde_json::json!({
"principal": "service_token",
"tokenKind": credentials::token_kind(token),
"note": "Service tokens have no user profile. Use `sylphx login` for a user JWT."
});
ux::print_out(json, &body, || {
println!("Authenticated with service token (no user profile)");
println!(" token {}", credentials::token_kind(token));
println!("Tip: sylphx login");
});
return Ok(());
}
match api_json(client, reqwest::Method::GET, "/users/me", None, None).await {
Ok((status, body)) => {
ux::print_out(json, &serde_json::json!({"status": status, "body": body}), || {
println!("{}", serde_json::to_string_pretty(&body).unwrap_or_default());
});
Ok(())
}
Err(_) => Err(ux::map_sdk_err(e)),
}
}
}
}
pub async fn previews_list(json: bool, client: &ManagementClient, project: &str, org: Option<&str>) -> anyhow::Result<()> {
rest_get(json, client, &format!("/projects/{}/previews", enc(project)), org).await
}
pub async fn admin_get(json: bool, client: &ManagementClient, path: &str, org: Option<&str>) -> anyhow::Result<()> {
let p = if path.starts_with('/') { path.to_string() } else { format!("/{path}") };
let full = if p.starts_with("/admin") { p } else { format!("/admin{p}") };
rest_get(json, client, &full, org).await
}
#[cfg(test)]
mod tests {
use super::is_terminal_project_status;
#[test]
fn running_is_a_successful_wait_terminal() {
assert!(is_terminal_project_status("running"));
}
#[test]
fn deploying_and_queued_are_not_wait_terminals() {
assert!(!is_terminal_project_status("deploying"));
assert!(!is_terminal_project_status("queued"));
}
#[test]
fn failed_is_a_wait_terminal() {
assert!(is_terminal_project_status("failed"));
}
}