use std::time::Duration;
use anyhow::Context;
use serde_json::{json, Value};
use sylphx_sdk_core::normalize_management_base_url;
use sylphx_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 github_token() -> Option<String> {
for key in ["GH_TOKEN", "GITHUB_TOKEN", "SYLPHX_GITHUB_TOKEN"] {
if let Ok(v) = std::env::var(key) {
let t = v.trim();
if !t.is_empty() {
return Some(t.to_string());
}
}
}
None
}
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_github_release_asset(
client: &reqwest::Client,
tag: &str,
asset: &str,
) -> anyhow::Result<(Vec<u8>, String)> {
let browser_url = if tag == "latest" {
format!("https://github.com/SylphxAI/platform/releases/latest/download/{asset}")
} else {
format!("https://github.com/SylphxAI/platform/releases/download/{tag}/{asset}")
};
let browser = client.get(&browser_url).send().await.context("download release")?;
if browser.status().is_success() {
let bytes = browser.bytes().await.context("read body")?.to_vec();
return Ok((bytes, browser_url));
}
let token = github_token().ok_or_else(|| {
anyhow::anyhow!(
"GitHub release download failed (private monorepo). \
Public channel is npm — this fallback is optional for agents."
)
})?;
let release_api = if tag == "latest" {
"https://api.github.com/repos/SylphxAI/platform/releases/latest".to_string()
} else {
format!("https://api.github.com/repos/SylphxAI/platform/releases/tags/{tag}")
};
let rel = client
.get(&release_api)
.header("Authorization", format!("Bearer {token}"))
.header("Accept", "application/vnd.github+json")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
.context("github release metadata")?;
if !rel.status().is_success() {
anyhow::bail!("GitHub release lookup failed HTTP {}", rel.status());
}
let rel_body: Value = rel.json().await.context("decode release metadata")?;
let assets = rel_body
.get("assets")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let asset_meta = assets.iter().find(|a| {
a.get("name")
.and_then(|v| v.as_str())
.map(|n| n == asset)
.unwrap_or(false)
});
let Some(asset_meta) = asset_meta else {
anyhow::bail!("release {tag} has no asset named {asset}");
};
let asset_api = asset_meta
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("release asset missing api url"))?
.to_string();
let resp = client
.get(&asset_api)
.header("Authorization", format!("Bearer {token}"))
.header("Accept", "application/octet-stream")
.header("X-GitHub-Api-Version", "2022-11-28")
.send()
.await
.context("download release asset via api")?;
if !resp.status().is_success() {
anyhow::bail!(
"authenticated asset download failed HTTP {} for {asset_api}",
resp.status()
);
}
let bytes = resp.bytes().await.context("read asset body")?.to_vec();
Ok((bytes, asset_api))
}
async fn download_cli_binary(
client: &reqwest::Client,
pin: Option<&str>,
) -> anyhow::Result<(Vec<u8>, String, NpmCliRelease)> {
match fetch_npm_cli_release(client, pin).await {
Ok(rel) => match download_npm_binary(client, &rel).await {
Ok((bytes, url)) => return Ok((bytes, url, rel)),
Err(npm_err) => {
if github_token().is_none() {
return Err(npm_err);
}
let tag = format!("cli-v{}", rel.crate_version);
let (os, arch) = host_platform()?;
let asset = match os {
"linux" => format!("sylphx-linux-{arch}"),
"darwin" => format!("sylphx-darwin-{arch}"),
_ => unreachable!(),
};
let (bytes, url) = download_github_release_asset(client, &tag, &asset).await?;
return Ok((bytes, url, rel));
}
},
Err(_e) if github_token().is_some() => {
let ver = pin.unwrap_or("latest");
let tag = if ver == "latest" {
"latest".into()
} else if ver.starts_with("cli-v") {
ver.to_string()
} else {
format!("cli-v{ver}")
};
let (os, arch) = host_platform()?;
let asset = match os {
"linux" => format!("sylphx-linux-{arch}"),
"darwin" => format!("sylphx-darwin-{arch}"),
_ => unreachable!(),
};
let (bytes, url) = download_github_release_asset(client, &tag, &asset).await?;
let rel = NpmCliRelease {
npm_version: String::new(),
crate_version: ver.trim_start_matches("cli-v").to_string(),
platform_pkg: "github-release".into(),
tarball_url: url.clone(),
};
return Ok((bytes, url, rel));
}
Err(e) => return Err(e),
}
}
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 = npm_latest
.as_ref()
.map(|r| r.crate_version.clone())
.or(crates_latest);
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 (bytes, source_url, rel) = download_cli_binary(&client, pin).await?;
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_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;
}
}
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 = matches!(
state.to_ascii_lowercase().as_str(),
"ready" | "live" | "healthy" | "succeeded" | "success" | "failed" | "error" | "cancelled"
| "canceled"
);
if terminal {
ux::print_out(json, &val, || {
ux::ok_line(&format!("terminal state={}", ux::status_badge(&state)))
});
if matches!(
state.to_ascii_lowercase().as_str(),
"failed" | "error" | "cancelled" | "canceled"
) {
anyhow::bail!("deployment 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
.list_deployments(project, None, org)
.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", "first-class", "/projects/:id/deployments"),
("deploy|rollback|status|wait", "first-class", "deploy loop"),
("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"),
("tasks *", "first-class", "/tasks"),
("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", "first-class", "/projects/:id/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 *", "first-class", "delivery control plane via /delivery/*"),
("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/promote", "api-escape", "use: sylphx api …"),
("realtime/referrals/saml/search/session-replay", "api-escape", "use: sylphx api …"),
("workspaces/device-sessions/mobile-device-tests", "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 releases_list(json: bool, client: &ManagementClient, project: &str, org: Option<&str>) -> anyhow::Result<()> {
rest_get(json, client, &format!("/projects/{}/releases", 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 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
}
pub async fn delivery_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("/delivery") { p } else { format!("/delivery{p}") };
rest_get(json, client, &full, org).await
}