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::{RetryConfig, DEFAULT_MANAGEMENT_BASE_URL};
use sylphx_sdk_management::{ListOptions, ManagementClient};
mod credentials;
#[derive(Parser, Debug)]
#[command(
name = "sylphx",
about = "Sylphx Platform CLI (Rust) — agent operator surface via sylphx-sdk-management",
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)]
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,
},
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>,
},
}
#[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",
"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",
"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)
.base_url(base)
.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 = client.whoami().await?;
print_out(&cli, &w, || {
let user = w.user.as_ref();
println!(
"user_id={} email={} name={} orgs={}",
user.map(|u| u.id.as_str()).unwrap_or("-"),
user.map(|u| u.email.as_str()).unwrap_or("-"),
user.and_then(|u| u.name.as_deref()).unwrap_or("-"),
w.orgs.len()
);
});
}
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 list = client.list_projects(org_id.as_deref(), None).await?;
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, || {
for d in &list.data {
println!(
"{}\t{}\t{}",
d.id,
d.status,
d.git_sha.as_deref().unwrap_or("-")
);
}
})?;
}
Commands::Deploy {
project_id,
env,
env_id,
r#ref,
service,
org_id,
} => {
let r = client
.create_deploy(
&project_id,
env.as_deref(),
env_id.as_deref(),
r#ref.as_deref(),
service.as_deref(),
org_id.as_deref(),
)
.await?;
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,
} => {
let list = 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?;
emit_json_or(&cli, &list, || {
for line in &list.data {
println!(
"{}\t{}\t{}",
line.timestamp,
line.level.as_deref().unwrap_or("-"),
line.message
);
}
})?;
}
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 },
} => {
let list = client
.list_project_storage(&project_id, org_id.as_deref())
.await?;
emit_json_or(&cli, &list, || {
for s in &list.data {
println!(
"{}\t{}\t{}",
s.id,
s.name,
s.public_url.as_deref().unwrap_or("-")
);
}
})?;
}
Commands::Tasks {
command: TaskCommands::List { project, org_id },
} => {
let list = client
.list_tasks(project.as_deref(), None, org_id.as_deref())
.await?;
emit_json_or(&cli, &list, || {
for t in &list.data {
println!("{}\t{}\t{}", t.id, t.name, t.status);
}
})?;
}
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 },
} => {
let list = client
.list_backups(resource.as_deref(), org_id.as_deref())
.await?;
emit_json_or(&cli, &list, || {
for b in &list.data {
println!(
"{}\t{}\t{}",
b.id,
b.status,
b.tag.as_deref().unwrap_or("-")
);
}
})?;
}
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 },
} => {
let list = client
.list_flags(project.as_deref(), org_id.as_deref())
.await?;
emit_json_or(&cli, &list, || {
for f in &list.data {
println!("{}\tenabled={}", f.key, f.enabled);
}
})?;
}
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:
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
);
})?;
}
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::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 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),
}
}
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 = auth_url
.as_deref()
.map(str::trim)
.filter(|u| !u.is_empty())
.map(|u| u.to_string())
.or_else(|| std::env::var("SYLPHX_AUTH_URL").ok().filter(|u| !u.trim().is_empty()))
.unwrap_or_else(|| api_url.clone());
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 path = credentials::save(&credentials::CredentialsFile {
token: token.clone(),
api_url: Some(api_url.clone()),
source: Some(source.clone()),
})?;
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()?;
let w = client.whoami().await?;
whoami_body = serde_json::to_value(&w)?;
}
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-url: {api_url}");
if !whoami_body.is_null() {
if let Some(user) = whoami_body.get("user") {
println!(
"whoami: user_id={} email={}",
user.get("id").and_then(|v| v.as_str()).unwrap_or("-"),
user.get("email").and_then(|v| v.as_str()).unwrap_or("-"),
);
}
}
});
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)..])
}
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}"))?;
if !init_resp.status().is_success() {
let status = init_resp.status();
let text = init_resp.text().await.unwrap_or_default();
anyhow::bail!("device init failed HTTP {status}: {text}");
}
let grant: Value = init_resp.json().await.context("decode device init")?;
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"))?
.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!("Open this URL to approve the CLI (device flow):");
if !verification.is_empty() {
eprintln!(" {verification}");
}
eprintln!("User code: {user_code}");
eprintln!("Waiting for approval (poll every {interval}s, timeout {timeout_secs}s)…");
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 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();
}
}