use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
use anyhow::Context;
use clap::{Parser, Subcommand, ValueEnum};
use reqwest::Method;
use serde_json::{json, Value};
use sylphx_sdk_core::{
normalize_management_base_url, platform_console_baas_base_url, RetryConfig,
DEFAULT_MANAGEMENT_BASE_URL,
};
use sylphx_sdk_management::{ListOptions, ManagementClient};
mod credentials;
mod channel;
mod context;
mod ux;
mod extra;
#[derive(Parser, Debug)]
#[command(
name = "sylphx",
about = "Sylphx Platform CLI — deploy, manage, and operate projects from the terminal",
long_about = "Sylphx Platform CLI — Management-plane operator (Rust sole authority).
\
Day-1:
sylphx login / doctor / whoami / context use / projects list / deploy
\
Agent/CI: export SYLPHX_TOKEN=svc_…
Self-update: sylphx update",
version
)]
struct Cli {
#[arg(long, env = "SYLPHX_API_URL", global = true)]
api_url: Option<String>,
#[arg(long, env = "SYLPHX_TOKEN", global = true)]
token: Option<String>,
#[arg(long, global = true, default_value_t = false)]
json: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand, Debug)]
enum Commands {
Health,
Login {
#[arg(long)]
token: Option<String>,
#[arg(long, default_value_t = false)]
device: bool,
#[arg(long, env = "SYLPHX_AUTH_URL")]
auth_url: Option<String>,
#[arg(long, default_value = "sylphx-cli")]
client_id: String,
#[arg(long, default_value_t = 300)]
timeout_secs: u64,
#[arg(long, default_value_t = false)]
no_verify: bool,
},
Logout,
Whoami,
Orgs {
#[command(subcommand)]
command: OrgCommands,
},
Projects {
#[command(subcommand)]
command: ProjectCommands,
},
Environments {
#[command(subcommand)]
command: EnvironmentCommands,
},
Deployments {
#[command(subcommand)]
command: DeploymentCommands,
},
Deploy {
project_id: String,
#[arg(long)]
env: Option<String>,
#[arg(long)]
env_id: Option<String>,
#[arg(long)]
r#ref: Option<String>,
#[arg(long)]
service: Option<String>,
#[arg(long, default_value_t = false)]
force: bool,
#[arg(long)]
org_id: Option<String>,
},
Rollback {
project_id: String,
#[arg(long)]
env_id: Option<String>,
#[arg(long)]
deployment: Option<String>,
#[arg(long)]
service: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Status {
project_id: String,
#[arg(long)]
org_id: Option<String>,
},
Env {
#[command(subcommand)]
command: EnvCommands,
},
Logs {
#[arg(long)]
project: Option<String>,
#[arg(long)]
environment: Option<String>,
#[arg(long = "type", default_value = "runtime")]
log_type: String,
#[arg(long)]
env: Option<String>,
#[arg(long, default_value_t = 100)]
tail: u32,
#[arg(long)]
level: Option<String>,
#[arg(long)]
service: Option<String>,
#[arg(long)]
grep: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Resources {
#[command(subcommand)]
command: ResourceCommands,
},
Db {
#[command(subcommand)]
command: DbCommands,
},
Storage {
#[command(subcommand)]
command: StorageCommands,
},
Tasks {
#[command(subcommand)]
command: TaskCommands,
},
Backup {
#[command(subcommand)]
command: BackupCommands,
},
Flags {
#[command(subcommand)]
command: FlagCommands,
},
Previews {
#[command(subcommand)]
command: PreviewCommands,
},
Doctor,
Config {
#[command(subcommand)]
command: ConfigCommands,
},
Context {
#[command(subcommand)]
command: ContextCommands,
},
Link {
#[arg(long)]
project: String,
#[arg(long)]
org_id: Option<String>,
#[arg(long)]
slug: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
env: Option<String>,
},
Unlink,
Open {
#[arg(long)]
project: Option<String>,
#[arg(long)]
org_id: Option<String>,
#[arg(long, default_value = "console")]
target: String,
},
Update {
#[arg(long, default_value_t = false)]
check: bool,
#[arg(long)]
version: Option<String>,
#[arg(long, default_value_t = false)]
force_release_binary: bool,
},
Wait {
project_id: String,
#[arg(long, default_value_t = 300)]
timeout_secs: u64,
#[arg(long, default_value_t = 3)]
interval_secs: u64,
#[arg(long)]
org_id: Option<String>,
},
Domains {
#[command(subcommand)]
command: DomainsCommands,
},
Secrets {
#[command(subcommand)]
command: SecretsCommands,
},
Tokens {
#[command(subcommand)]
command: TokensCommands,
},
Billing {
#[command(subcommand)]
command: BillingCommands,
},
Services {
#[command(subcommand)]
command: ServicesCommands,
},
Webhooks {
#[command(subcommand)]
command: WebhooksCommands,
},
Certs {
#[command(subcommand)]
command: CertsCommands,
},
Releases {
#[command(subcommand)]
command: ReleasesCommands,
},
Runners {
#[command(subcommand)]
command: RunnersCommands,
},
Sandbox {
#[command(subcommand)]
command: SandboxCommands,
},
Volumes {
#[command(subcommand)]
command: VolumesCommands,
},
Users {
#[command(subcommand)]
command: UsersCommands,
},
Inspect {
project_id: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Init {
#[arg(long, default_value = "app")]
name: String,
#[arg(long)]
org_id: Option<String>,
},
Tail {
#[arg(long)]
project: Option<String>,
#[arg(long)]
environment: Option<String>,
#[arg(long, default_value_t = 100)]
tail: u32,
#[arg(long)]
org_id: Option<String>,
},
Catalog,
Admin {
#[command(subcommand)]
command: AdminCommands,
},
Delivery {
#[command(subcommand)]
command: DeliveryCommands,
},
Api {
method: ApiMethod,
path: String,
#[arg(long)]
body: Option<String>,
#[arg(long)]
input: Option<PathBuf>,
#[arg(long)]
org_id: Option<String>,
},
VersionInfo,
}
#[derive(Subcommand, Debug)]
enum OrgCommands {
List,
}
#[derive(Subcommand, Debug)]
enum ProjectCommands {
List {
#[arg(long)]
org_id: Option<String>,
},
Get {
id: String,
#[arg(long)]
org_id: Option<String>,
},
Create {
name: String,
#[arg(long)]
slug: Option<String>,
#[arg(long)]
org_id: Option<String>,
#[arg(long)]
description: Option<String>,
#[arg(long)]
r#ref: Option<String>,
},
}
#[derive(Subcommand, Debug)]
enum EnvironmentCommands {
List {
project_id: String,
#[arg(long)]
org_id: Option<String>,
#[arg(long)]
page_token: Option<String>,
#[arg(long)]
page_size: Option<u32>,
},
Get {
project_id: String,
environment_id: String,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug)]
enum DeploymentCommands {
List {
project_id: String,
#[arg(long)]
org_id: Option<String>,
#[arg(long)]
page_token: Option<String>,
#[arg(long)]
page_size: Option<u32>,
},
}
#[derive(Subcommand, Debug)]
enum EnvCommands {
List {
project_id: String,
#[arg(long)]
env: Option<String>,
#[arg(long)]
env_id: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Set {
project_id: String,
assignment: String,
#[arg(long, default_value_t = false)]
secret: bool,
#[arg(long)]
env: Option<String>,
#[arg(long)]
env_id: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Rm {
project_id: String,
key: String,
#[arg(long)]
env: Option<String>,
#[arg(long)]
env_id: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug)]
enum ResourceCommands {
List {
#[arg(long)]
kind: Option<String>,
#[arg(long)]
project: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Get {
id: String,
#[arg(long)]
org_id: Option<String>,
},
Create {
kind: String,
name: String,
#[arg(long)]
project: Option<String>,
#[arg(long)]
tier: Option<String>,
#[arg(long)]
storage: Option<u32>,
#[arg(long)]
org_id: Option<String>,
},
Delete {
id: String,
#[arg(long)]
org_id: Option<String>,
},
Bind {
resource_id: String,
environment_id: String,
#[arg(long)]
project: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug)]
enum DbCommands {
Create {
name: String,
#[arg(long)]
project: Option<String>,
#[arg(long, default_value = "standard")]
tier: String,
#[arg(long, default_value_t = 10)]
storage: u32,
#[arg(long)]
org_id: Option<String>,
},
List {
#[arg(long)]
project: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Get {
id: String,
#[arg(long)]
org_id: Option<String>,
},
Delete {
id: String,
#[arg(long)]
org_id: Option<String>,
},
Branch {
resource_id: String,
#[arg(long)]
name: String,
#[arg(long)]
env: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug)]
enum StorageCommands {
Create {
project_id: String,
name: String,
#[arg(long)]
env: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
List {
project_id: String,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug)]
enum TaskCommands {
List {
#[arg(long)]
project: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Create {
name: String,
#[arg(long)]
project: Option<String>,
#[arg(long)]
mode: Option<String>,
#[arg(long)]
handler: Option<String>,
#[arg(long)]
cron: Option<String>,
#[arg(long)]
callback_url: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Get {
id: String,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug)]
enum BackupCommands {
List {
#[arg(long)]
resource: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Create {
#[arg(long)]
resource: Option<String>,
#[arg(long)]
tag: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Restore {
backup_id: String,
#[arg(long)]
resource: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug)]
enum FlagCommands {
List {
#[arg(long)]
project: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Create {
key: String,
#[arg(long)]
description: Option<String>,
#[arg(long)]
project: Option<String>,
#[arg(long)]
enabled: Option<bool>,
#[arg(long)]
org_id: Option<String>,
},
Toggle {
key: String,
#[arg(long)]
enabled: Option<bool>,
#[arg(long)]
project: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug)]
enum PreviewCommands {
Cost {
project_id: String,
pr_number: String,
#[arg(long)]
org_id: Option<String>,
},
List {
project_id: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug, Clone)]
enum ConfigCommands {
Show,
Path,
}
#[derive(Subcommand, Debug, Clone)]
enum ContextCommands {
Show,
Use {
#[arg(long)]
org_id: String,
#[arg(long)]
slug: Option<String>,
#[arg(long)]
name: Option<String>,
},
Clear,
}
#[derive(Subcommand, Debug, Clone)]
enum DomainsCommands {
List {
project_id: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Get {
domain_id: String,
project_id: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Add {
domain: String,
project_id: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Rm {
domain_id: String,
project_id: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug, Clone)]
enum SecretsCommands {
List {
#[arg(long)]
project: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Set {
name: String,
value: String,
#[arg(long)]
project: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Rm {
name: String,
#[arg(long)]
project: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug, Clone)]
enum TokensCommands {
List {
#[arg(long)]
org_id: Option<String>,
},
Create {
name: String,
#[arg(long)]
org_id: Option<String>,
#[arg(long)]
project: Option<String>,
#[arg(long, default_value = "project:read,project:deploy")]
scopes: String,
},
Rm {
token_id: String,
#[arg(long)]
org_id: Option<String>,
},
Rotate {
token_id: String,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug, Clone)]
enum BillingCommands {
Usage {
#[arg(long)]
org_id: Option<String>,
},
Plan {
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug, Clone)]
enum ServicesCommands {
List {
project_id: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug, Clone)]
enum WebhooksCommands {
List {
project_id: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug, Clone)]
enum CertsCommands {
List {
project_id: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug, Clone)]
enum ReleasesCommands {
List {
project_id: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug, Clone)]
enum RunnersCommands {
List {
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug, Clone)]
enum SandboxCommands {
List {
#[arg(long)]
org_id: Option<String>,
},
Get {
id: String,
#[arg(long)]
org_id: Option<String>,
},
Create {
name: String,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug, Clone)]
enum VolumesCommands {
List {
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug, Clone)]
enum UsersCommands {
Me,
}
#[derive(Subcommand, Debug, Clone)]
enum AdminCommands {
Get {
path: String,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug, Clone)]
enum DeliveryCommands {
Get {
path: String,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Clone, Debug, ValueEnum)]
enum ApiMethod {
Get,
Head,
Post,
Put,
Patch,
Delete,
}
impl ApiMethod {
fn to_reqwest(self) -> Method {
match self {
Self::Get => Method::GET,
Self::Head => Method::HEAD,
Self::Post => Method::POST,
Self::Put => Method::PUT,
Self::Patch => Method::PATCH,
Self::Delete => Method::DELETE,
}
}
}
fn page_opts(page_token: Option<String>, page_size: Option<u32>) -> Option<ListOptions> {
if page_token.is_none() && page_size.is_none() {
return None;
}
Some(ListOptions {
page_token,
page_size,
})
}
const SHIPPED_COMMANDS: &[&str] = &[
"health", "login", "logout", "whoami", "doctor",
"config show", "config path",
"context show", "context use", "context clear",
"link", "unlink", "open", "update", "wait", "catalog", "init", "inspect", "tail",
"orgs list", "projects list", "projects get", "projects create",
"environments list", "environments get", "deployments list",
"deploy", "rollback", "status",
"env list", "env set", "env rm", "logs",
"resources list", "resources get", "resources create", "resources delete", "resources bind",
"db create", "db list", "db get", "db delete", "db branch",
"storage create", "storage list",
"tasks list", "tasks create", "tasks get",
"backup list", "backup create", "backup restore",
"flags list", "flags create", "flags toggle",
"previews cost", "previews list",
"domains list", "domains get", "domains add", "domains rm",
"secrets list", "secrets set", "secrets rm",
"tokens list", "tokens create", "tokens rm", "tokens rotate",
"billing usage", "billing plan",
"services list", "webhooks list",
"certs list", "releases list", "runners list",
"sandbox list", "sandbox get", "sandbox create",
"volumes list", "users me",
"admin get", "delivery get",
"api", "version-info",
];
#[tokio::main]
async fn main() -> ExitCode {
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("error: {err}");
ExitCode::from(1)
}
}
}
async fn run() -> anyhow::Result<()> {
let cli = Cli::parse();
if matches!(cli.command, Commands::VersionInfo) {
let body = json!({
"cli": env!("CARGO_PKG_VERSION"),
"contractPackage": sylphx_wire::CONTRACT_PACKAGE,
"contractRevision": sylphx_wire::CONTRACT_REVISION,
"client": "sylphx-sdk-management",
"product": "rust",
"shippedCommands": SHIPPED_COMMANDS,
});
print_out(&cli, &body, || {
println!(
"sylphx {} (Rust) contract {}@{} via sylphx-sdk-management",
env!("CARGO_PKG_VERSION"),
sylphx_wire::CONTRACT_PACKAGE,
sylphx_wire::CONTRACT_REVISION
);
});
return Ok(());
}
if let Commands::Login {
token: login_token,
device,
auth_url,
client_id,
timeout_secs,
no_verify,
} = &cli.command
{
return run_login(
&cli,
login_token.clone(),
*device,
auth_url.clone(),
client_id.clone(),
*timeout_secs,
*no_verify,
)
.await;
}
if matches!(cli.command, Commands::Logout) {
let removed = credentials::clear()?;
let path = credentials::credentials_path()?;
let body = json!({
"removed": removed,
"path": path.display().to_string(),
});
print_out(&cli, &body, || {
if removed {
println!("logged out ({})", path.display());
} else {
println!("no stored credentials ({})", path.display());
}
});
return Ok(());
}
let token = credentials::resolve_token(cli.token.as_deref())?;
let base = credentials::resolve_api_url(cli.api_url.as_deref(), DEFAULT_MANAGEMENT_BASE_URL);
let client = ManagementClient::builder(token.clone())
.base_url(base.clone())
.retry(RetryConfig {
max_attempts: 3,
base_delay: Duration::from_millis(100),
})
.build()?;
match &cli.command {
Commands::Login { .. } | Commands::Logout => unreachable!("handled above"),
Commands::Health => {
let h = client.health().await?;
print_out(&cli, &h, || {
println!(
"status={} version={} git_sha={}",
h.status,
h.version.as_deref().unwrap_or("-"),
h.git_sha.as_deref().unwrap_or("-")
);
});
}
Commands::Whoami => {
let w = match client.whoami().await {
Ok(w) => w,
Err(e) => {
let msg = e.to_string();
if msg.contains("user_context_required") {
let body = json!({
"principal": "service_token",
"tokenKind": credentials::token_kind(&token),
"apiUrl": base,
"note": "Service tokens have no user profile. Use device login for whoami."
});
print_out(&cli, &body, || {
println!("Authenticated with service token (no user profile)");
println!(" token {}", credentials::token_kind(&token));
println!(" api {base}");
println!();
println!("Tip: sylphx login");
println!(" sylphx projects list --org-id <org>");
});
return Ok(());
}
return Err(ux::map_sdk_err(e));
}
};
print_out(&cli, &w, || {
let user = w.user.as_ref();
let name = user.and_then(|u| u.name.as_deref()).unwrap_or("-");
let email = user.map(|u| u.email.as_str()).unwrap_or("-");
let id = user.map(|u| u.id.as_str()).unwrap_or("-");
println!("Signed in as {name}");
println!(" email {email}");
println!(" user {id}");
println!(" orgs {}", w.orgs.len());
if !w.orgs.is_empty() {
println!();
println!("Organizations");
for org in w.orgs.iter().take(20) {
let slug = org.slug.as_str();
let oname = if org.name.is_empty() {
slug
} else {
org.name.as_str()
};
println!(" • {oname} ({slug})");
}
if w.orgs.len() > 20 {
println!(" … and {} more", w.orgs.len() - 20);
}
}
});
}
Commands::Orgs {
command: OrgCommands::List,
} => {
let orgs = client.list_orgs().await?;
emit_json_or(&cli, &orgs, || {
for o in &orgs {
println!("{}\t{}\t{}", o.id, o.slug, o.name);
}
})?;
}
Commands::Projects {
command: ProjectCommands::List { org_id },
} => {
let org = resolve_org(org_id)?;
let list = client
.list_projects(org.as_deref(), None)
.await
.map_err(ux::map_sdk_err)?;
emit_json_or(&cli, &list, || {
for p in &list.data {
println!("{}\t{}\t{}", p.id, p.slug, p.name);
}
})?;
}
Commands::Projects {
command: ProjectCommands::Get { id, org_id },
} => {
let p = client.get_project(&id, org_id.as_deref()).await?;
emit_json_or(&cli, &p, || {
println!(
"id={} slug={} name={} org_id={} active={}",
p.id, p.slug, p.name, p.org_id, p.is_active
);
})?;
}
Commands::Projects {
command:
ProjectCommands::Create {
name,
slug,
org_id,
description,
r#ref,
},
} => {
let p = client
.create_project(
&name,
slug.as_deref(),
org_id.as_deref(),
description.as_deref(),
r#ref.as_deref(),
)
.await?;
emit_json_or(&cli, &p, || {
println!("created id={} slug={} name={}", p.id, p.slug, p.name);
})?;
}
Commands::Environments {
command:
EnvironmentCommands::List {
project_id,
org_id,
page_token,
page_size,
},
} => {
let page = page_opts(page_token.clone(), page_size.as_ref().copied());
let list = client
.list_environments(&project_id, page.as_ref(), org_id.as_deref())
.await?;
emit_json_or(&cli, &list, || {
for e in &list.data {
println!(
"{}\t{}\t{}\t{}",
e.id,
e.slug,
e.name,
e.status.as_deref().unwrap_or("-")
);
}
})?;
}
Commands::Environments {
command:
EnvironmentCommands::Get {
project_id,
environment_id,
org_id,
},
} => {
let e = client
.get_environment(&project_id, &environment_id, org_id.as_deref())
.await?;
emit_json_or(&cli, &e, || {
println!(
"id={} slug={} status={}",
e.id,
e.slug,
e.status.as_deref().unwrap_or("-")
);
})?;
}
Commands::Deployments {
command:
DeploymentCommands::List {
project_id,
org_id,
page_token,
page_size,
},
} => {
let page = page_opts(page_token.clone(), page_size.as_ref().copied());
let list = client
.list_deployments(&project_id, page.as_ref(), org_id.as_deref())
.await?;
emit_json_or(&cli, &list, || {
println!(
"{:<14} {:<12} {:<10} {:<22} {:<22} {}",
"STATUS", "GIT_SHA", "STARTED", "FINISHED", "DIGEST", "ID"
);
for d in &list.data {
let started = d
.started_at
.as_deref()
.map(|s| s.get(..19).unwrap_or(s))
.unwrap_or("-");
let finished = d
.finished_at
.as_deref()
.map(|s| s.get(..19).unwrap_or(s))
.unwrap_or("-");
let dig = d
.image_digest
.as_deref()
.map(|s| {
s.strip_prefix("sha256:")
.unwrap_or(s)
.get(..12)
.unwrap_or(s)
})
.unwrap_or("-");
println!(
"{:<14} {:<12} {:<10} {:<22} {:<22} {}",
d.status,
d.git_sha.as_deref().map(|s| s.get(..12).unwrap_or(s)).unwrap_or("-"),
started,
finished,
dig,
d.id,
);
}
})?;
}
Commands::Deploy {
project_id,
env,
env_id,
r#ref,
service,
force,
org_id,
} => {
let r = client
.create_deploy_with_options(
project_id,
env.as_deref(),
env_id.as_deref(),
r#ref.as_deref(),
service.as_deref(),
*force,
org_id.as_deref(),
)
.await
.context("create deploy")?;
emit_json_or(&cli, &r, || {
println!("deploy id={} status={}", r.id, r.status);
})?;
}
Commands::Rollback {
project_id,
env_id,
deployment,
service,
org_id,
} => {
let r = client
.rollback(
&project_id,
env_id.as_deref(),
deployment.as_deref(),
service.as_deref(),
org_id.as_deref(),
)
.await?;
emit_json_or(&cli, &r, || {
println!("rollback id={} status={}", r.id, r.status);
})?;
}
Commands::Status {
project_id,
org_id,
} => {
let s = client
.get_project_status(&project_id, org_id.as_deref())
.await?;
emit_json_or(&cli, &s, || {
println!(
"project={} status={} deployment={}",
s.project_id,
s.status,
s.deployment_id.as_deref().unwrap_or("-")
);
})?;
}
Commands::Env {
command:
EnvCommands::List {
project_id,
env,
env_id,
org_id,
},
} => {
let list = client
.list_env_vars(
&project_id,
env.as_deref(),
env_id.as_deref(),
org_id.as_deref(),
)
.await?;
emit_json_or(&cli, &list, || {
for e in &list.data {
println!(
"{}\tsecret={}\t{}",
e.key,
e.secret,
e.value.as_deref().unwrap_or("***")
);
}
})?;
}
Commands::Env {
command:
EnvCommands::Set {
project_id,
assignment,
secret,
env,
env_id,
org_id,
},
} => {
let (key, value) = assignment
.split_once('=')
.ok_or_else(|| anyhow::anyhow!("env set requires KEY=value"))?;
let r = client
.set_env_var(
&project_id,
key,
value,
*secret,
env.as_deref(),
env_id.as_deref(),
org_id.as_deref(),
)
.await?;
emit_json_or(&cli, &r, || {
println!("set key={} created={}", key, r.created);
})?;
}
Commands::Env {
command:
EnvCommands::Rm {
project_id,
key,
env,
env_id,
org_id,
},
} => {
let r = client
.delete_env_var(
&project_id,
&key,
env.as_deref(),
env_id.as_deref(),
org_id.as_deref(),
)
.await?;
emit_json_or(&cli, &r, || {
println!("deleted={} key={}", r.deleted, r.key);
})?;
}
Commands::Logs {
project,
environment,
log_type,
env,
tail,
level,
service,
grep,
org_id,
} => {
match client
.list_logs(
project.as_deref(),
environment.as_deref(),
Some(log_type.as_str()),
env.as_deref(),
Some(*tail),
level.as_deref(),
service.as_deref(),
grep.as_deref(),
org_id.as_deref(),
)
.await
{
Ok(list) => {
emit_json_or(&cli, &list, || {
for line in &list.data {
println!(
"{}\t{}\t{}",
line.timestamp,
line.level.as_deref().unwrap_or("-"),
line.message
);
}
})?;
}
Err(e) => map_residual_or_err(e)?,
}
}
Commands::Resources {
command:
ResourceCommands::List {
kind,
project,
org_id,
},
} => {
let list = client
.list_resources(kind.as_deref(), project.as_deref(), None, org_id.as_deref())
.await?;
emit_json_or(&cli, &list, || {
for r in &list.data {
println!("{}\t{}\t{}\t{}", r.id, r.kind, r.name, r.status);
}
})?;
}
Commands::Resources {
command: ResourceCommands::Get { id, org_id },
} => {
let r = client.get_resource(&id, org_id.as_deref()).await?;
emit_json_or(&cli, &r, || {
println!("{}\t{}\t{}\t{}", r.id, r.kind, r.name, r.status);
})?;
}
Commands::Resources {
command:
ResourceCommands::Create {
kind,
name,
project,
tier,
storage,
org_id,
},
} => {
let r = client
.create_resource(
&kind,
&name,
project.as_deref(),
tier.as_deref(),
storage.as_ref().copied(),
org_id.as_deref(),
)
.await?;
emit_json_or(&cli, &r, || {
if let Some(res) = r.resource.as_ref() {
println!("created id={} status={}", res.id, res.status);
}
})?;
}
Commands::Resources {
command: ResourceCommands::Delete { id, org_id },
} => {
let r = client.delete_resource(&id, org_id.as_deref()).await?;
emit_json_or(&cli, &r, || {
println!("deleted={} id={}", r.deleted, r.id);
})?;
}
Commands::Resources {
command:
ResourceCommands::Bind {
resource_id,
environment_id,
project,
org_id,
},
} => {
let r = client
.bind_resource(
&resource_id,
&environment_id,
project.as_deref(),
org_id.as_deref(),
)
.await?;
emit_json_or(&cli, &r, || {
println!(
"bound={} resource={} env={}",
r.bound, r.resource_id, r.environment_id
);
})?;
}
Commands::Db {
command:
DbCommands::Create {
name,
project,
tier,
storage,
org_id,
},
} => {
let r = client
.create_resource(
"database",
&name,
project.as_deref(),
Some(&tier),
Some(*storage),
org_id.as_deref(),
)
.await?;
emit_json_or(&cli, &r, || {
if let Some(res) = r.resource.as_ref() {
println!("db id={} status={}", res.id, res.status);
}
})?;
}
Commands::Db {
command: DbCommands::List { project, org_id },
} => {
let list = client
.list_resources(
Some("database"),
project.as_deref(),
None,
org_id.as_deref(),
)
.await?;
emit_json_or(&cli, &list, || {
for r in &list.data {
println!("{}\t{}\t{}", r.id, r.name, r.status);
}
})?;
}
Commands::Db {
command: DbCommands::Get { id, org_id },
} => {
let r = client.get_resource(&id, org_id.as_deref()).await?;
emit_json_or(&cli, &r, || {
println!("{}\t{}\t{}", r.id, r.name, r.status);
})?;
}
Commands::Db {
command: DbCommands::Delete { id, org_id },
} => {
let r = client.delete_resource(&id, org_id.as_deref()).await?;
emit_json_or(&cli, &r, || {
println!("deleted={} id={}", r.deleted, r.id);
})?;
}
Commands::Db {
command:
DbCommands::Branch {
resource_id,
name,
env,
org_id,
},
} => {
let r = client
.branch_resource(&resource_id, &name, env.as_deref(), org_id.as_deref())
.await?;
emit_json_or(&cli, &r, || {
if let Some(res) = r.resource.as_ref() {
println!("branch id={} status={}", res.id, res.status);
}
})?;
}
Commands::Storage {
command:
StorageCommands::Create {
project_id,
name,
env,
org_id,
},
} => {
let r = client
.create_project_storage(&project_id, &name, env.as_deref(), org_id.as_deref())
.await?;
emit_json_or(&cli, &r, || {
if let Some(s) = r.storage.as_ref() {
println!(
"storage id={} url={}",
s.id,
s.public_url.as_deref().unwrap_or("-")
);
}
})?;
}
Commands::Storage {
command: StorageCommands::List { project_id, org_id },
} => {
match client
.list_project_storage(project_id, org_id.as_deref())
.await
{
Ok(list) => {
emit_json_or(&cli, &list, || {
for s in &list.data {
println!(
"{}\t{}\t{}",
s.id,
s.name,
s.public_url.as_deref().unwrap_or("-")
);
}
})?;
}
Err(e) => map_residual_or_err(e)?,
}
}
Commands::Tasks {
command: TaskCommands::List { project, org_id },
} => {
match client
.list_tasks(project.as_deref(), None, org_id.as_deref())
.await
{
Ok(list) => {
emit_json_or(&cli, &list, || {
for t in &list.data {
println!("{}\t{}\t{}", t.id, t.name, t.status);
}
})?;
}
Err(e) => map_residual_or_err(e)?,
}
}
Commands::Tasks {
command:
TaskCommands::Create {
name,
project,
mode,
handler,
cron,
callback_url,
org_id,
},
} => {
let r = client
.create_task(
&name,
project.as_deref(),
mode.as_deref(),
handler.as_deref(),
cron.as_deref(),
callback_url.as_deref(),
org_id.as_deref(),
)
.await?;
emit_json_or(&cli, &r, || {
if let Some(t) = r.task.as_ref() {
println!("task id={} status={}", t.id, t.status);
}
})?;
}
Commands::Tasks {
command: TaskCommands::Get { id, org_id },
} => {
let t = client.get_task(&id, org_id.as_deref()).await?;
emit_json_or(&cli, &t, || {
println!("{}\t{}\t{}", t.id, t.name, t.status);
})?;
}
Commands::Backup {
command: BackupCommands::List { resource, org_id },
} => {
match client
.list_backups(resource.as_deref(), org_id.as_deref())
.await
{
Ok(list) => {
emit_json_or(&cli, &list, || {
for b in &list.data {
println!(
"{}\t{}\t{}",
b.id,
b.status,
b.tag.as_deref().unwrap_or("-")
);
}
})?;
}
Err(e) => map_residual_or_err(e)?,
}
}
Commands::Backup {
command:
BackupCommands::Create {
resource,
tag,
org_id,
},
} => {
let r = client
.create_backup(resource.as_deref(), tag.as_deref(), org_id.as_deref())
.await?;
emit_json_or(&cli, &r, || {
if let Some(b) = r.backup.as_ref() {
println!("backup id={} status={}", b.id, b.status);
}
})?;
}
Commands::Backup {
command:
BackupCommands::Restore {
backup_id,
resource,
org_id,
},
} => {
let r = client
.restore_backup(&backup_id, resource.as_deref(), org_id.as_deref())
.await?;
emit_json_or(&cli, &r, || {
println!("restore id={} status={}", r.id, r.status);
})?;
}
Commands::Flags {
command: FlagCommands::List { project, org_id },
} => {
match client
.list_flags(project.as_deref(), org_id.as_deref())
.await
{
Ok(list) => {
emit_json_or(&cli, &list, || {
for f in &list.data {
println!("{}\tenabled={}", f.key, f.enabled);
}
})?;
}
Err(e) => map_residual_or_err(e)?,
}
}
Commands::Flags {
command:
FlagCommands::Create {
key,
description,
project,
enabled,
org_id,
},
} => {
let r = client
.create_flag(
&key,
description.as_deref(),
project.as_deref(),
enabled.as_ref().copied(),
org_id.as_deref(),
)
.await?;
emit_json_or(&cli, &r, || {
if let Some(f) = r.flag.as_ref() {
println!("flag key={} enabled={}", f.key, f.enabled);
}
})?;
}
Commands::Flags {
command:
FlagCommands::Toggle {
key,
enabled,
project,
org_id,
},
} => {
let r = client
.toggle_flag(&key, enabled.as_ref().copied(), project.as_deref(), org_id.as_deref())
.await?;
emit_json_or(&cli, &r, || {
if let Some(f) = r.flag.as_ref() {
println!("flag key={} enabled={}", f.key, f.enabled);
}
})?;
}
Commands::Previews { command } => match command {
PreviewCommands::Cost {
project_id,
pr_number,
org_id,
} => {
let r = client
.get_preview_cost(&project_id, &pr_number, org_id.as_deref())
.await?;
emit_json_or(&cli, &r, || {
println!(
"pr={} total={} {}",
r.pr_number, r.total, r.currency
);
})?;
}
PreviewCommands::List { project_id, org_id } => {
let project = context::resolve_project_id(project_id.as_deref())?;
let org = resolve_org(org_id)?;
extra::previews_list(cli.json, &client, &project, org.as_deref()).await?;
}
},
Commands::Api {
method,
path,
body,
input,
org_id,
} => {
let payload = load_body(body.as_deref(), input.as_ref())?;
let (status, value) = client
.api(
method.clone().to_reqwest(),
path,
payload.as_ref(),
org_id.as_deref(),
)
.await?;
if cli.json {
let envelope = json!({"status": status, "body": value});
println!("{}", serde_json::to_string_pretty(&envelope)?);
} else if !value.is_null() {
println!("{}", serde_json::to_string_pretty(&value)?);
}
}
Commands::Doctor => {
extra::doctor(cli.json, &client, &token, &base).await?;
}
Commands::Config { command: ConfigCommands::Show } => {
let creds = credentials::load()?;
let global = context::load_global()?;
let linked = context::load_linked_project()?;
let body = json!({
"configDir": credentials::config_dir()?.display().to_string(),
"credentialsPath": credentials::credentials_path()?.display().to_string(),
"apiUrl": base,
"tokenKind": credentials::token_kind(&token),
"authSource": creds.as_ref().and_then(|c| c.source.clone()).unwrap_or_else(|| "unknown".into()),
"preferredOrg": global,
"linkedProject": linked,
});
print_out(&cli, &body, || {
println!("CLI configuration");
println!(" config dir {}", credentials::config_dir().map(|p| p.display().to_string()).unwrap_or_else(|_| "?".into()));
println!(" credentials {}", credentials::credentials_path().map(|p| p.display().to_string()).unwrap_or_else(|_| "?".into()));
println!(" apiUrl {base}");
println!(" tokenKind {}", credentials::token_kind(&token));
println!(" preferredOrg {}", global.preferred_org_id.as_deref().unwrap_or("(none)"));
println!(" linked {}", linked.as_ref().map(|l| l.project_id.as_str()).unwrap_or("(none)"));
});
}
Commands::Config { command: ConfigCommands::Path } => {
let path = credentials::config_dir()?;
print_out(&cli, &json!({"path": path.display().to_string()}), || println!("{}", path.display()));
}
Commands::Context { command: ContextCommands::Show } => {
let global = context::load_global()?;
let linked = context::load_linked_project()?;
print_out(&cli, &json!({"preferredOrg": global, "linkedProject": linked}), || {
println!("Context");
println!(" org {}", global.preferred_org_id.as_deref().unwrap_or("(none)"));
println!(" project {}", linked.as_ref().map(|l| l.project_id.as_str()).unwrap_or("(none)"));
});
}
Commands::Context { command: ContextCommands::Use { org_id, slug, name } } => {
let mut g = context::load_global()?;
g.preferred_org_id = Some(org_id.clone());
g.preferred_org_slug = slug.clone();
g.preferred_org_name = name.clone();
let path = context::save_global(&g)?;
print_out(&cli, &json!({"ok": true, "path": path.display().to_string(), "preferredOrg": g}), || {
ux::ok_line(&format!("preferred org set to {org_id}"));
println!(" saved {}", path.display());
});
}
Commands::Context { command: ContextCommands::Clear } => {
let removed = context::clear_global()?;
print_out(&cli, &json!({"removed": removed}), || {
if removed { ux::ok_line("preferred org cleared"); } else { println!("no preferred org set"); }
});
}
Commands::Link { project, org_id, slug, name, env } => {
let org = resolve_org(org_id)?;
let link = context::LinkedProject {
project_id: project.clone(),
project_slug: slug.clone(),
project_name: name.clone(),
org_id: org.clone(),
org_slug: None,
environment: env.clone(),
};
let path = context::save_linked_project(&link)?;
if let Some(oid) = org.clone() {
let mut g = context::load_global()?;
g.preferred_org_id = Some(oid);
let _ = context::save_global(&g);
}
print_out(&cli, &json!({"ok": true, "path": path.display().to_string(), "link": link}), || {
ux::ok_line(&format!("linked project {project}"));
println!(" {}", path.display());
});
}
Commands::Unlink => {
let removed = context::clear_linked_project()?;
print_out(&cli, &json!({"removed": removed}), || {
if removed { ux::ok_line("project link removed"); } else { println!("no local project link"); }
});
}
Commands::Open { project, org_id, target } => {
let org = resolve_org(org_id)?;
let proj = project.clone().or_else(|| context::load_linked_project().ok().flatten().map(|l| l.project_id));
let url = match target.as_str() {
"docs" => "https://docs.sylphx.com".to_string(),
"project" => {
let p = proj.as_deref().unwrap_or("");
if p.is_empty() { "https://console.sylphx.com".into() }
else if let Some(o) = org.as_deref() { format!("https://console.sylphx.com/{o}/projects/{p}") }
else { format!("https://console.sylphx.com/projects/{p}") }
}
_ => "https://console.sylphx.com".into(),
};
let opened = open_browser_url(&url);
print_out(&cli, &json!({"url": url, "opened": opened}), || {
println!("{url}");
if opened { ux::ok_line("opened in browser"); }
});
}
Commands::Update { check, version, force_release_binary } => {
extra::update(cli.json, *check, version.clone(), *force_release_binary).await?;
}
Commands::Wait { project_id, timeout_secs, interval_secs, org_id } => {
let org = resolve_org(org_id)?;
extra::wait_status(cli.json, &client, project_id, *timeout_secs, *interval_secs, org.as_deref()).await?;
}
Commands::Domains { command } => match command {
DomainsCommands::List { project_id, org_id } => {
let project = context::resolve_project_id(project_id.as_deref())?;
let org = resolve_org(org_id)?;
extra::domains_list(cli.json, &client, &project, org.as_deref()).await?;
}
DomainsCommands::Get { domain_id, project_id, org_id } => {
let project = context::resolve_project_id(project_id.as_deref())?;
let org = resolve_org(org_id)?;
extra::domains_get(cli.json, &client, &project, domain_id, org.as_deref()).await?;
}
DomainsCommands::Add { domain, project_id, org_id } => {
let project = context::resolve_project_id(project_id.as_deref())?;
let org = resolve_org(org_id)?;
extra::domains_add(cli.json, &client, &project, domain, org.as_deref()).await?;
}
DomainsCommands::Rm { domain_id, project_id, org_id } => {
let project = context::resolve_project_id(project_id.as_deref())?;
let org = resolve_org(org_id)?;
extra::domains_rm(cli.json, &client, &project, domain_id, org.as_deref()).await?;
}
},
Commands::Secrets { command } => match command {
SecretsCommands::List { project, org_id } => {
let org = resolve_org(org_id)?;
let proj = project.clone().or_else(|| context::load_linked_project().ok().flatten().map(|l| l.project_id));
extra::secrets_list(cli.json, &client, proj.as_deref(), org.as_deref()).await?;
}
SecretsCommands::Set { name, value, project, org_id } => {
let org = resolve_org(org_id)?;
let proj = project.clone().or_else(|| context::load_linked_project().ok().flatten().map(|l| l.project_id));
extra::secrets_set(cli.json, &client, name, value, proj.as_deref(), org.as_deref()).await?;
}
SecretsCommands::Rm { name, project, org_id } => {
let org = resolve_org(org_id)?;
let proj = project.clone().or_else(|| context::load_linked_project().ok().flatten().map(|l| l.project_id));
extra::secrets_rm(cli.json, &client, name, proj.as_deref(), org.as_deref()).await?;
}
},
Commands::Tokens { command } => match command {
TokensCommands::List { org_id } => {
let org = resolve_org(org_id)?.ok_or_else(|| anyhow::anyhow!("org required: --org-id or context use"))?;
extra::tokens_list(cli.json, &client, &org).await?;
}
TokensCommands::Create { name, org_id, project, scopes } => {
let org = resolve_org(org_id)?.ok_or_else(|| anyhow::anyhow!("org required: --org-id or context use"))?;
extra::tokens_create(cli.json, &client, &org, name, scopes, project.as_deref()).await?;
}
TokensCommands::Rm { token_id, org_id } => {
let org = resolve_org(org_id)?.ok_or_else(|| anyhow::anyhow!("org required: --org-id or context use"))?;
extra::tokens_rm(cli.json, &client, &org, token_id).await?;
}
TokensCommands::Rotate { token_id, org_id } => {
let org = resolve_org(org_id)?.ok_or_else(|| anyhow::anyhow!("org required: --org-id or context use"))?;
extra::tokens_rotate(cli.json, &client, &org, token_id).await?;
}
},
Commands::Billing { command } => match command {
BillingCommands::Usage { org_id } => {
let org = resolve_org(org_id)?;
extra::billing_usage(cli.json, &client, org.as_deref()).await?;
}
BillingCommands::Plan { org_id } => {
let org = resolve_org(org_id)?;
extra::billing_plan(cli.json, &client, org.as_deref()).await?;
}
},
Commands::Services { command: ServicesCommands::List { project_id, org_id } } => {
let project = context::resolve_project_id(project_id.as_deref())?;
let org = resolve_org(org_id)?;
extra::services_list(cli.json, &client, &project, org.as_deref()).await?;
}
Commands::Webhooks { command: WebhooksCommands::List { project_id, org_id } } => {
let org = resolve_org(org_id)?;
let proj = project_id.clone().or_else(|| context::load_linked_project().ok().flatten().map(|l| l.project_id));
extra::webhooks_list(cli.json, &client, proj.as_deref(), org.as_deref()).await?;
}
Commands::Certs { command: CertsCommands::List { project_id, org_id } } => {
let project = context::resolve_project_id(project_id.as_deref())?;
let org = resolve_org(org_id)?;
extra::certs_list(cli.json, &client, &project, org.as_deref()).await?;
}
Commands::Releases { command: ReleasesCommands::List { project_id, org_id } } => {
let project = context::resolve_project_id(project_id.as_deref())?;
let org = resolve_org(org_id)?;
extra::releases_list(cli.json, &client, &project, org.as_deref()).await?;
}
Commands::Runners { command: RunnersCommands::List { org_id } } => {
let org = resolve_org(org_id)?;
extra::runners_list(cli.json, &client, org.as_deref()).await?;
}
Commands::Sandbox { command } => match command {
SandboxCommands::List { org_id } => {
let org = resolve_org(org_id)?;
extra::sandbox_list(cli.json, &client, org.as_deref()).await?;
}
SandboxCommands::Get { id, org_id } => {
let org = resolve_org(org_id)?;
extra::sandbox_get(cli.json, &client, id, org.as_deref()).await?;
}
SandboxCommands::Create { name, org_id } => {
let org = resolve_org(org_id)?;
extra::sandbox_create(cli.json, &client, name, org.as_deref()).await?;
}
},
Commands::Volumes { command: VolumesCommands::List { org_id } } => {
let org = resolve_org(org_id)?;
extra::volumes_list(cli.json, &client, org.as_deref()).await?;
}
Commands::Users { command: UsersCommands::Me } => {
extra::users_me(cli.json, &client, &token).await?;
}
Commands::Inspect { project_id, org_id } => {
let project = context::resolve_project_id(project_id.as_deref())?;
let org = resolve_org(org_id)?;
extra::inspect_project(cli.json, &client, &project, org.as_deref()).await?;
}
Commands::Init { name, org_id } => {
extra::init_project(cli.json, name, org_id.as_deref(), false).await?;
}
Commands::Tail { project, environment, tail, org_id } => {
let org = resolve_org(org_id)?;
let project = project.clone().or_else(|| context::load_linked_project().ok().flatten().map(|l| l.project_id));
match client
.list_logs(
project.as_deref(),
environment.as_deref(),
Some("runtime"),
None,
Some(*tail),
None,
None,
None,
org.as_deref(),
)
.await
{
Ok(list) => {
emit_json_or(&cli, &list, || {
for line in &list.data {
println!(
"{} {} {}",
line.timestamp,
line.level.as_deref().unwrap_or("-"),
line.message
);
}
})?;
}
Err(e) => map_residual_or_err(e)?,
}
}
Commands::Catalog => {
extra::print_catalog(cli.json);
}
Commands::Admin { command: AdminCommands::Get { path, org_id } } => {
let org = resolve_org(org_id)?;
extra::admin_get(cli.json, &client, path, org.as_deref()).await?;
}
Commands::Delivery { command: DeliveryCommands::Get { path, org_id } } => {
let org = resolve_org(org_id)?;
extra::delivery_get(cli.json, &client, path, org.as_deref()).await?;
}
Commands::VersionInfo => unreachable!(),
}
Ok(())
}
fn emit_json_or<T: serde::Serialize>(
cli: &Cli,
value: &T,
human: impl FnOnce(),
) -> anyhow::Result<()> {
if cli.json {
println!("{}", serde_json::to_string_pretty(value)?);
} else {
human();
}
Ok(())
}
fn map_residual_or_err(err: sylphx_sdk_core::SdkError) -> anyhow::Result<()> {
let msg = err.to_string();
let residual = msg.contains("product_io_residual")
|| msg.contains("\"product_io_residual\"")
|| (msg.contains("residual")
&& (msg.contains("\"ok\":false") || msg.contains("\"ok\": false")));
if residual {
if let Some(body) = msg.split("body=").nth(1) {
if let Ok(v) = serde_json::from_str::<Value>(body.trim()) {
println!(
"product residual residual (not product-live)\n semantic={}\n note={}\n path={}",
v.get("semantic")
.and_then(|x| x.as_str())
.unwrap_or("-"),
v.get("note").and_then(|x| x.as_str()).unwrap_or("-"),
v.get("path").and_then(|x| x.as_str()).unwrap_or("-"),
);
return Ok(());
}
}
println!("product residual residual (not product-live): {msg}");
return Ok(());
}
Err(err.into())
}
fn load_body(
inline: Option<&str>,
input: Option<&PathBuf>,
) -> anyhow::Result<Option<Value>> {
match (inline, input) {
(Some(_), Some(_)) => Err(anyhow::anyhow!(
"use only one of --body or --input for request payload"
)),
(Some(s), None) => Ok(Some(serde_json::from_str(s)?)),
(None, Some(path)) => {
let text = if path.as_os_str() == "-" {
use std::io::Read;
let mut buf = String::new();
std::io::stdin().read_to_string(&mut buf)?;
buf
} else {
std::fs::read_to_string(path)?
};
if text.trim().is_empty() {
Ok(None)
} else {
Ok(Some(serde_json::from_str(&text)?))
}
}
(None, None) => Ok(None),
}
}
fn resolve_device_auth_base(explicit: Option<&str>, _management_api_url: &str) -> String {
if let Some(u) = explicit.map(str::trim).filter(|u| !u.is_empty()) {
return u.trim_end_matches('/').to_string();
}
for key in ["SYLPHX_AUTH_URL", "SYLPHX_BAAS_URL", "SYLPHX_RUNTIME_URL"] {
if let Ok(u) = std::env::var(key) {
let u = u.trim().trim_end_matches('/');
if !u.is_empty() {
if u.contains("://api.sylphx.com") && !u.contains(".api.sylphx.com") {
continue;
}
if u.contains("platform.api.sylphx.com") || u.contains("runtime.sylphx.com") {
continue;
}
return u.to_string();
}
}
}
platform_console_baas_base_url()
}
async fn run_login(
cli: &Cli,
login_token: Option<String>,
device: bool,
auth_url: Option<String>,
client_id: String,
timeout_secs: u64,
no_verify: bool,
) -> anyhow::Result<()> {
let api_url = credentials::resolve_api_url(cli.api_url.as_deref(), DEFAULT_MANAGEMENT_BASE_URL);
let auth_base = resolve_device_auth_base(auth_url.as_deref(), &api_url);
let token_arg = login_token
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty())
.map(|t| t.to_string());
let (token, source) = if let Some(t) = token_arg {
(t, "token".to_string())
} else {
let _ = device; let tok = device_login(&auth_base, &client_id, timeout_secs).await?;
(tok, "device".to_string())
};
let api_url = normalize_management_base_url(&api_url);
let mut whoami_body = Value::Null;
if !no_verify {
let client = ManagementClient::builder(token.clone())
.base_url(api_url.clone())
.retry(RetryConfig {
max_attempts: 2,
base_delay: Duration::from_millis(50),
})
.build()
.map_err(ux::map_sdk_err)?;
match client.whoami().await {
Ok(w) => whoami_body = serde_json::to_value(&w)?,
Err(e) => {
let msg = e.to_string();
if msg.contains("user_context_required") && credentials::token_kind(&token) == "service_token" {
whoami_body = json!({
"ok": true,
"principal": "service_token",
"note": "service tokens have no user profile; whoami skipped"
});
} else {
return Err(ux::map_sdk_err(e).context("login verification failed — credentials were NOT saved"));
}
}
}
}
let path = credentials::save(&credentials::CredentialsFile {
token: token.clone(),
api_url: Some(api_url.clone()),
source: Some(source.clone()),
})?;
let body = json!({
"ok": true,
"source": source,
"path": path.display().to_string(),
"apiUrl": api_url,
"tokenPreview": preview_token(&token),
"whoami": whoami_body,
});
print_out(cli, &body, || {
println!("✓ Logged in via {source}");
println!(" credentials {}", path.display());
println!(" api {api_url}");
if !whoami_body.is_null() {
if let Some(user) = whoami_body.get("user") {
let email = user.get("email").and_then(|v| v.as_str()).unwrap_or("-");
let name = user
.get("name")
.and_then(|v| v.as_str())
.unwrap_or(email);
println!(" user {name} <{email}>");
}
if let Some(orgs) = whoami_body.get("orgs").and_then(|v| v.as_array()) {
println!(" orgs {}", orgs.len());
}
}
println!();
println!("Next: sylphx whoami · sylphx orgs list · sylphx projects list");
});
Ok(())
}
fn preview_token(token: &str) -> String {
let t = token.trim();
if t.len() <= 12 {
return "***".into();
}
format!("{}…{}", &t[..6], &t[t.len().saturating_sub(4)..])
}
fn open_browser_url(url: &str) -> bool {
let candidates: &[(&str, &[&str])] = &[
("xdg-open", &[url]),
("open", &[url]),
("gio", &["open", url]),
("wslview", &[url]),
];
for (bin, args) in candidates {
if std::process::Command::new(bin)
.args(*args)
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
{
return true;
}
}
false
}
async fn device_login(auth_base: &str, client_id: &str, timeout_secs: u64) -> anyhow::Result<String> {
let base = auth_base.trim_end_matches('/');
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(30))
.build()?;
let init_url = format!("{base}/auth/device");
let init_resp = http
.post(&init_url)
.json(&json!({
"client_id": client_id,
"scope": ["openid", "profile", "email", "offline_access"],
}))
.send()
.await
.with_context(|| format!("device init POST {init_url}"))?;
let status = init_resp.status();
let grant: Value = init_resp
.json()
.await
.with_context(|| format!("decode device init body from {init_url}"))?;
if grant.get("product_io_residual").and_then(|v| v.as_bool()) == Some(true)
|| grant.get("semantic").and_then(|v| v.as_str()) == Some("commercial_root_index")
{
anyhow::bail!(
"device init hit Management residual plane ({init_url}). \
Device grants live on BaaS runtime (default {}). \
Set --auth-url or SYLPHX_BAAS_URL / SYLPHX_AUTH_URL. body={grant}",
platform_console_baas_base_url()
);
}
if !status.is_success() {
anyhow::bail!("device init failed HTTP {status}: {grant}");
}
let device_code = grant
.get("device_code")
.or_else(|| grant.get("deviceCode"))
.and_then(|v| v.as_str())
.ok_or_else(|| {
anyhow::anyhow!(
"device init missing device_code from {init_url} (HTTP {status}). body={grant}"
)
})?
.to_string();
let user_code = grant
.get("user_code")
.or_else(|| grant.get("userCode"))
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string();
let verification = grant
.get("verification_uri_complete")
.or_else(|| grant.get("verificationUriComplete"))
.or_else(|| grant.get("verification_uri"))
.or_else(|| grant.get("verificationUri"))
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let interval = grant
.get("interval")
.and_then(|v| v.as_u64())
.unwrap_or(5)
.max(1);
let expires_in = grant
.get("expires_in")
.or_else(|| grant.get("expiresIn"))
.and_then(|v| v.as_u64())
.unwrap_or(timeout_secs);
eprintln!();
eprintln!("┌─ Sylphx CLI login ─────────────────────────────────────────");
eprintln!("│ Open this URL in your browser to approve this device:");
if !verification.is_empty() {
eprintln!("│");
eprintln!("│ {verification}");
eprintln!("│");
let _ = open_browser_url(&verification);
}
eprintln!("│ User code: {user_code}");
eprintln!(
"│ Waiting for approval… (poll every {interval}s, timeout {timeout_secs}s)"
);
eprintln!("└────────────────────────────────────────────────────────────");
eprintln!();
let deadline = std::time::Instant::now() + Duration::from_secs(timeout_secs.min(expires_in));
loop {
if std::time::Instant::now() > deadline {
anyhow::bail!("device login timed out waiting for approval");
}
tokio::time::sleep(Duration::from_secs(interval)).await;
let poll_url = format!("{base}/auth/device/poll?device_code={}", urlencoding_encode(&device_code));
let poll_resp = http
.get(&poll_url)
.send()
.await
.with_context(|| format!("device poll GET {poll_url}"))?;
if !poll_resp.status().is_success() {
let status = poll_resp.status();
let text = poll_resp.text().await.unwrap_or_default();
if status.as_u16() == 428 || status.as_u16() == 400 {
continue;
}
anyhow::bail!("device poll failed HTTP {status}: {text}");
}
let body: Value = poll_resp.json().await.context("decode device poll")?;
let status = body
.get("status")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_ascii_lowercase();
if status == "pending" || status == "authorization_pending" {
continue;
}
if status == "denied" || status == "access_denied" {
anyhow::bail!("device login denied by user");
}
if status == "expired" {
anyhow::bail!("device login expired");
}
let token = body
.get("access_token")
.or_else(|| body.get("accessToken"))
.or_else(|| body.pointer("/tokens/access_token"))
.or_else(|| body.pointer("/token/access_token"))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
if let Some(tok) = token {
return Ok(tok);
}
if status == "approved" || status == "complete" || status.is_empty() {
anyhow::bail!("device poll approved but missing access_token: {body}");
}
anyhow::bail!("unexpected device poll status={status} body={body}");
}
}
fn urlencoding_encode(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
}
fn resolve_org(cli_org: &Option<String>) -> anyhow::Result<Option<String>> {
context::resolve_org_id(cli_org.as_deref())
}
fn print_out<T: serde::Serialize>(cli: &Cli, value: &T, human: impl FnOnce()) {
if cli.json {
println!(
"{}",
serde_json::to_string_pretty(value).expect("serialize json")
);
} else {
human();
}
}
#[cfg(test)]
mod device_auth_base_tests {
use super::*;
#[test]
fn resolve_device_auth_base_defaults_to_customer_zero_baas() {
assert_eq!(
resolve_device_auth_base(None, DEFAULT_MANAGEMENT_BASE_URL),
platform_console_baas_base_url()
);
assert!(
platform_console_baas_base_url().contains("keen-wave-x3m9p2.api.sylphx.com"),
"must use PLATFORM_CONSOLE_SLUG host, not vanity platform.api"
);
assert!(
!platform_console_baas_base_url().contains("platform.api.sylphx.com"),
"ADR-3888 forbids vanity platform.api default"
);
assert_eq!(
resolve_device_auth_base(Some("https://custom.api.sylphx.com/v1"), DEFAULT_MANAGEMENT_BASE_URL),
"https://custom.api.sylphx.com/v1"
);
assert_ne!(
resolve_device_auth_base(None, "https://api.sylphx.com/v1"),
"https://api.sylphx.com/v1"
);
}
}