use std::path::PathBuf;
use std::process::ExitCode;
use std::time::Duration;
use anyhow::Context;
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
use clap_complete::{generate, shells};
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 candidate;
mod credentials;
mod channel;
mod context;
mod oauth_session;
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,
#[arg(long, global = true, value_enum, default_value_t = ColorChoiceArg::Auto)]
color: ColorChoiceArg,
#[arg(long, global = true, default_value_t = false)]
yes: bool,
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Clone, Copy, ValueEnum, Default)]
enum ColorChoiceArg {
#[default]
Auto,
Always,
Never,
}
impl ColorChoiceArg {
fn to_mode(self) -> ux::ColorMode {
match self {
Self::Auto => ux::ColorMode::Auto,
Self::Always => ux::ColorMode::Always,
Self::Never => ux::ColorMode::Never,
}
}
}
#[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 {
#[arg(long, default_value_t = false)]
local_only: bool,
},
Whoami,
Orgs {
#[command(subcommand)]
command: OrgCommands,
},
Projects {
#[command(subcommand)]
command: ProjectCommands,
},
Environments {
#[command(subcommand)]
command: EnvironmentCommands,
},
Deployments {
#[command(subcommand)]
command: DeploymentCommands,
},
Deploy {
project_id: Option<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: Option<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: Option<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, default_value_t = false)]
follow: bool,
#[arg(long, default_value_t = 2)]
interval_secs: u64,
#[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: Option<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,
},
Candidates {
#[command(subcommand)]
command: CandidateCommands,
},
Runners {
#[command(subcommand)]
command: RunnersCommands,
},
Sandbox {
#[command(subcommand)]
command: SandboxCommands,
},
Computer {
#[command(subcommand)]
command: ComputerCommands,
},
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>,
},
Completions {
#[arg(value_enum)]
shell: CompletionShell,
},
VersionInfo,
}
#[derive(Debug, Clone, Copy, ValueEnum)]
enum CompletionShell {
Bash,
Zsh,
Fish,
Powershell,
Elvish,
}
#[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 {
#[arg(long = "project", value_name = "PROJECT")]
project_id: Option<String>,
#[arg(long)]
org_id: Option<String>,
#[arg(long)]
page_token: Option<String>,
#[arg(long)]
page_size: Option<u32>,
},
Get {
environment_id: String,
#[arg(long = "project", value_name = "PROJECT")]
project_id: Option<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: Option<String>,
#[arg(long)]
env: Option<String>,
#[arg(long)]
env_id: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Set {
assignment: String,
#[arg(long = "project")]
project_id: Option<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 {
key: String,
#[arg(long = "project")]
project_id: Option<String>,
#[arg(long)]
env: Option<String>,
#[arg(long)]
env_id: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Pull {
project_id: Option<String>,
#[arg(long)]
env: Option<String>,
#[arg(long)]
env_id: Option<String>,
#[arg(long, default_value = ".env.local")]
out: PathBuf,
#[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)]
enum CandidateCommands {
Publish {
#[arg(long)]
project_id: Option<String>,
#[arg(long)]
repository: Option<String>,
#[arg(long, default_value = "main")]
target_ref: String,
#[arg(long)]
base_revision: Option<String>,
#[arg(long)]
source_revision: Option<String>,
#[arg(long)]
tree_oid: Option<String>,
#[arg(long, env = "ENACT_WORK_ITEM_ID")]
work_item: String,
#[arg(long, env = "ENACT_ATTEMPT_ID")]
producer_attempt: String,
#[arg(long)]
service_id: Option<String>,
#[arg(long)]
idempotency_key: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Get {
#[arg(long)]
project_id: Option<String>,
candidate_id: 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 ComputerCommands {
Profiles {
#[command(subcommand)]
command: ComputerProfilesCommands,
},
Lease {
#[command(subcommand)]
command: ComputerLeaseCommands,
},
}
#[derive(Subcommand, Debug, Clone)]
enum ComputerProfilesCommands {
List {
#[arg(long)]
org_id: Option<String>,
},
Get {
profile_id: String,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug, Clone)]
enum ComputerLeaseCommands {
Create {
#[arg(long)]
project: Option<String>,
#[arg(long)]
org_id: Option<String>,
#[arg(long, default_value = "linux.microvm.standard")]
profile: String,
#[arg(long, default_value_t = 600)]
ttl_secs: u64,
#[arg(long, default_value_t = 300)]
idle_timeout_secs: u64,
},
Get {
lease_id: String,
#[arg(long)]
project: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Capabilities {
lease_id: String,
#[arg(long)]
lease_epoch: String,
#[arg(long)]
project: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Renew {
lease_id: String,
#[arg(long)]
lease_epoch: String,
#[arg(long)]
fencing_token: String,
#[arg(long, default_value_t = 300)]
extension_secs: u64,
#[arg(long)]
project: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Reclaim {
lease_id: String,
#[arg(long)]
lease_epoch: String,
#[arg(long)]
fencing_token: String,
#[arg(long, default_value = "cli_reclaim")]
reason_code: String,
#[arg(long)]
project: Option<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", "env pull", "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", "candidates publish", "candidates get", "runners list",
"sandbox list", "sandbox get", "sandbox create",
"computer profiles list", "computer profiles get",
"computer lease create", "computer lease get", "computer lease capabilities",
"computer lease renew", "computer lease reclaim",
"volumes list", "users me",
"admin get", "delivery get",
"api", "completions", "env pull", "version-info",
];
#[tokio::main]
async fn main() -> ExitCode {
match run().await {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
ux::print_error(&err);
ExitCode::from(1)
}
}
}
async fn run() -> anyhow::Result<()> {
let cli = Cli::parse();
if cli.json {
ux::init_color(ux::ColorMode::Never);
} else {
ux::init_color(cli.color.to_mode());
}
if let Commands::Completions { shell } = &cli.command {
return emit_completions(*shell);
}
if matches!(cli.command, Commands::VersionInfo) {
let body = json!({
"cli": env!("CARGO_PKG_VERSION"),
"contractPackage": sylphx_api_types::CONTRACT_PACKAGE,
"contractRevision": sylphx_api_types::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_api_types::CONTRACT_PACKAGE,
sylphx_api_types::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 let Commands::Logout { local_only } = &cli.command {
if !local_only {
if let Some(stored) = credentials::load()? {
if let Some(session) = stored.oauth.as_ref() {
let http = oauth_session::http_client()?;
oauth_session::revoke(&http, session, &stored.token, "access_token").await?;
oauth_session::revoke(
&http,
session,
&session.refresh_token,
"refresh_token",
)
.await?;
}
}
}
let removed = credentials::clear()?;
let path = credentials::credentials_path()?;
let body = json!({
"removed": removed,
"remoteRevocation": !local_only,
"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 = resolve_active_token(cli.token.as_deref()).await?;
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_option();
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!("{} {}", ux::mark_pass(), ux::bold(&format!("Signed in as {name}")));
ux::kv("email", email);
ux::kv("user", id);
ux::kv("orgs", w.orgs.len().to_string());
if !w.orgs.is_empty() {
println!();
ux::section("Organizations");
let rows: Vec<(String, String, String)> = w
.orgs
.iter()
.take(40)
.map(|org| {
let slug = org.slug.clone();
let oname = if org.name.is_empty() {
slug.clone()
} else {
org.name.clone()
};
(org.id.clone(), slug, oname)
})
.collect();
ux::print_id_slug_name(&rows);
if w.orgs.len() > 40 {
println!(
"{}",
ux::dim(&format!("… and {} more", w.orgs.len() - 40))
);
}
}
});
}
Commands::Orgs {
command: OrgCommands::List,
} => {
let orgs = client.list_orgs().await?;
emit_json_or(&cli, &orgs, || {
let rows: Vec<(String, String, String)> = orgs
.iter()
.map(|o| (o.id.clone(), o.slug.clone(), o.name.clone()))
.collect();
ux::print_id_slug_name(&rows);
})?;
}
Commands::Projects {
command: ProjectCommands::List { org_id },
} => {
let org = resolve_org(org_id)?;
match client.list_projects(org.as_deref(), None).await {
Ok(list) => {
emit_json_or(&cli, &list, || {
let rows: Vec<Vec<String>> = list
.data
.iter()
.map(|p| {
vec![
p.id.clone(),
p.slug.clone(),
p.name.clone(),
p.region.clone().unwrap_or_else(|| "-".into()),
if p.is_active {
ux::green("active")
} else {
ux::muted("off")
},
]
})
.collect();
ux::print_table(&["ID", "SLUG", "NAME", "REGION", "STATE"], &rows);
println!(
"{}",
ux::dim(&format!("{} project(s)", list.data.len()))
);
})?;
}
Err(_typed_err) => {
let raw = client
.list_projects_json(org.as_deref())
.await
.map_err(ux::map_sdk_err)?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&raw)?);
} else {
eprintln!(
"{} {}",
ux::mark_warn(),
ux::dim("wire Project decode lag — rendering live envelope")
);
ux::print_json_human(&raw);
}
}
}
}
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 project_id = context::resolve_project_id(project_id.as_deref())?;
let org = resolve_org(org_id)?;
let page = page_opts(page_token.clone(), page_size.as_ref().copied());
let list = client
.list_environments(&project_id, page.as_ref(), org.as_deref())
.await
.map_err(ux::map_sdk_err)?;
emit_json_or(&cli, &list, || {
let rows: Vec<Vec<String>> = list
.data
.iter()
.map(|e| {
vec![
e.id.clone(),
e.slug.clone(),
e.name.clone(),
e.env_type.clone().unwrap_or_else(|| "-".into()),
e.status
.as_deref()
.map(ux::status_badge)
.unwrap_or_else(|| "-".into()),
]
})
.collect();
ux::print_table(&["ID", "SLUG", "NAME", "TYPE", "STATUS"], &rows);
println!("{}", ux::dim(&format!("{} environment(s)", list.data.len())));
})?;
}
Commands::Environments {
command:
EnvironmentCommands::Get {
project_id,
environment_id,
org_id,
},
} => {
let project_id = context::resolve_project_id(project_id.as_deref())?;
let org = resolve_org(org_id)?;
let e = client
.get_environment(&project_id, &environment_id, org.as_deref())
.await
.map_err(ux::map_sdk_err)?;
emit_json_or(&cli, &e, || {
println!(
"id={} slug={} name={} status={}",
e.id,
e.slug,
e.name,
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, || {
let rows: Vec<Vec<String>> = list
.data
.iter()
.map(|d| {
let started = d
.started_at
.as_deref()
.map(|s| s.get(..19).unwrap_or(s).to_string())
.unwrap_or_else(|| "-".into());
let finished = d
.finished_at
.as_deref()
.map(|s| s.get(..19).unwrap_or(s).to_string())
.unwrap_or_else(|| "-".into());
let dig = d
.image_digest
.as_deref()
.map(|s| {
s.strip_prefix("sha256:")
.unwrap_or(s)
.get(..12)
.unwrap_or(s)
.to_string()
})
.unwrap_or_else(|| "-".into());
let sha = d
.git_sha
.as_deref()
.map(|s| s.get(..12).unwrap_or(s).to_string())
.unwrap_or_else(|| "-".into());
vec![
ux::status_badge(&d.status),
sha,
started,
finished,
dig,
d.id.clone(),
]
})
.collect();
ux::print_table(
&["STATUS", "GIT_SHA", "STARTED", "FINISHED", "DIGEST", "ID"],
&rows,
);
})?;
}
Commands::Deploy {
project_id,
env,
env_id,
r#ref,
service,
force,
org_id,
} => {
let project_id = context::resolve_project_id(project_id.as_deref())?;
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, || {
ux::ok_line(&format!(
"deploy {} → {}",
r.id,
ux::status_badge(&r.status)
));
})?;
}
Commands::Rollback {
project_id,
env_id,
deployment,
service,
org_id,
} => {
let project_id = context::resolve_project_id(project_id.as_deref())?;
ux::confirm_or_yes(
cli.yes,
cli.json,
&format!("roll back project {project_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, || {
ux::ok_line(&format!(
"rollback {} → {}",
r.id,
ux::status_badge(&r.status)
));
})?;
}
Commands::Status {
project_id,
org_id,
} => {
let project_id = context::resolve_project_id(project_id.as_deref())?;
let org = resolve_org(org_id)?;
match client
.get_project_status(&project_id, org.as_deref())
.await
{
Ok(s) => {
emit_json_or(&cli, &s, || {
ux::section("Project status");
ux::kv("project", &s.project_id);
println!(
" {} {}",
ux::muted(&format!("{:<12}", "status")),
ux::status_badge(&s.status)
);
ux::kv(
"deployment",
s.deployment_id.as_deref().unwrap_or("-"),
);
if let Some(env) = s.environment_id.as_deref() {
ux::kv("environment", env);
}
if let Some(n) = s.service_count {
ux::kv("services", n.to_string());
}
if let Some(n) = s.environment_count {
ux::kv("environments", n.to_string());
}
if !s.environment_service_statuses.is_empty() {
let buckets: Vec<String> = s
.environment_service_statuses
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
ux::kv("serviceStates", buckets.join(" "));
}
if let Some(msg) = s.message.as_deref() {
ux::kv("message", msg);
}
})?;
}
Err(typed_err) => {
let path = format!("/projects/{project_id}/status");
let (_status, raw) = client
.api(reqwest::Method::GET, &path, None, org_id.as_deref())
.await
.map_err(ux::map_sdk_err)?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&raw)?);
} else {
let cause = typed_err.to_string();
let short = cause
.split("; body=")
.next()
.unwrap_or("decode")
.chars()
.take(100)
.collect::<String>();
eprintln!(
"{} {}",
ux::mark_warn(),
ux::dim(&format!("wire status decode lag ({short}…)"))
);
ux::section("Project status");
ux::print_json_human(&raw);
}
}
}
}
Commands::Env {
command:
EnvCommands::List {
project_id,
env,
env_id,
org_id,
},
} => {
let project_id = context::resolve_project_id(project_id.as_deref())?;
let org = resolve_org(org_id)?;
let list = client
.list_env_vars(
&project_id,
env.as_deref(),
env_id.as_deref(),
org.as_deref(),
)
.await
.map_err(ux::map_sdk_err)?;
emit_json_or(&cli, &list, || {
let rows: Vec<Vec<String>> = list
.data
.iter()
.map(|e| {
vec![
e.key.clone(),
if e.secret {
ux::yellow("secret")
} else {
ux::green("plain")
},
e.value
.clone()
.unwrap_or_else(|| ux::muted("***")),
e.env_id.clone().unwrap_or_else(|| "-".into()),
]
})
.collect();
ux::print_table(&["KEY", "KIND", "VALUE", "ENV"], &rows);
println!("{}", ux::dim(&format!("{} env var(s)", list.data.len())));
})?;
}
Commands::Env {
command:
EnvCommands::Set {
project_id,
assignment,
secret,
env,
env_id,
org_id,
},
} => {
let project_id = context::resolve_project_id(project_id.as_deref())?;
let org = resolve_org(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.as_deref(),
)
.await
.map_err(ux::map_sdk_err)?;
emit_json_or(&cli, &r, || {
ux::ok_line(&format!("set key={key} created={}", r.created));
})?;
}
Commands::Env {
command:
EnvCommands::Rm {
project_id,
key,
env,
env_id,
org_id,
},
} => {
let project_id = context::resolve_project_id(project_id.as_deref())?;
let org = resolve_org(org_id)?;
ux::confirm_or_yes(
cli.yes,
cli.json,
&format!("delete env var {key} on {project_id}?"),
)?;
let r = client
.delete_env_var(
&project_id,
&key,
env.as_deref(),
env_id.as_deref(),
org.as_deref(),
)
.await
.map_err(ux::map_sdk_err)?;
emit_json_or(&cli, &r, || {
ux::ok_line(&format!("deleted={} key={}", r.deleted, r.key));
})?;
}
Commands::Env {
command:
EnvCommands::Pull {
project_id,
env,
env_id,
out,
org_id,
},
} => {
let project_id = context::resolve_project_id(project_id.as_deref())?;
let org = resolve_org(org_id)?;
let list = client
.list_env_vars(
&project_id,
env.as_deref(),
env_id.as_deref(),
org.as_deref(),
)
.await
.map_err(ux::map_sdk_err)?;
let mut lines = Vec::new();
lines.push(format!(
"# sylphx env pull — project={project_id} generated={}",
chrono_like_now()
));
let mut written = 0u32;
let mut redacted = 0u32;
for e in &list.data {
if e.secret || e.value.is_none() {
lines.push(format!("# {}= # secret/redacted — set manually", e.key));
redacted += 1;
continue;
}
let val = e.value.as_deref().unwrap_or("");
let escaped = val.replace('\\', "\\\\").replace('"', "\\\"");
lines.push(format!("{}=\"{}\"", e.key, escaped));
written += 1;
}
std::fs::write(&out, lines.join("\n") + "\n")
.with_context(|| format!("write {}", out.display()))?;
let body = json!({
"path": out.display().to_string(),
"written": written,
"redacted": redacted,
"projectId": project_id,
});
emit_json_or(&cli, &body, || {
ux::ok_line(&format!(
"wrote {} ({} values, {} secret/redacted)",
out.display(),
written,
redacted
));
})?;
}
Commands::Logs {
project,
environment,
log_type,
env,
tail,
level,
service,
grep,
follow,
interval_secs,
org_id,
} => {
let project = if environment.is_some() {
project.clone()
} else {
Some(context::resolve_project_id(project.as_deref())?)
};
if *follow {
extra::logs_follow(
cli.json,
&client,
project.as_deref(),
environment.as_deref(),
log_type.as_str(),
env.as_deref(),
*tail,
level.as_deref(),
service.as_deref(),
grep.as_deref(),
*interval_secs,
org_id.as_deref(),
)
.await?;
} else {
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 {
extra::print_log_line(line);
}
})?;
}
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_option() {
println!("created id={} status={}", res.id, res.status);
}
})?;
}
Commands::Resources {
command: ResourceCommands::Delete { id, org_id },
} => {
ux::confirm_or_yes(cli.yes, cli.json, &format!("delete resource {id}?"))?;
let r = client.delete_resource(&id, org_id.as_deref()).await?;
emit_json_or(&cli, &r, || {
ux::ok_line(&format!("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_option() {
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 },
} => {
ux::confirm_or_yes(cli.yes, cli.json, &format!("delete database resource {id}?"))?;
let r = client.delete_resource(&id, org_id.as_deref()).await?;
emit_json_or(&cli, &r, || {
ux::ok_line(&format!("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_option() {
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_option() {
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_option() {
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, || {
let rows: Vec<Vec<String>> = list
.data
.iter()
.map(|b| {
vec![
b.id.clone(),
ux::status_badge(&b.status),
b.tag.clone().unwrap_or_else(|| "-".into()),
]
})
.collect();
ux::print_table(&["ID", "STATUS", "TAG"], &rows);
})?;
}
Err(_typed_err) => {
let raw = client
.list_backups_json(resource.as_deref(), org_id.as_deref())
.await
.map_err(ux::map_sdk_err)?;
if cli.json {
println!("{}", serde_json::to_string_pretty(&raw)?);
} else {
eprintln!(
"{} {}",
ux::mark_warn(),
ux::dim("wire Backup decode lag — rendering live envelope")
);
ux::print_json_human(&raw);
}
}
}
}
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_option() {
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_option() {
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_option() {
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 project = context::resolve_project_id(project_id.as_deref())?;
let org = resolve_org(org_id)?;
extra::wait_status(cli.json, &client, &project, *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)?;
ux::confirm_or_yes(
cli.yes,
cli.json,
&format!("remove domain {domain_id} from {project}?"),
)?;
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));
ux::confirm_or_yes(cli.yes, cli.json, &format!("delete secret {name}?"))?;
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"))?;
ux::confirm_or_yes(cli.yes, cli.json, &format!("delete service token {token_id}?"))?;
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"))?;
ux::confirm_or_yes(cli.yes, cli.json, &format!("rotate service token {token_id}?"))?;
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::Candidates { command } => match command {
CandidateCommands::Publish {
project_id,
repository,
target_ref,
base_revision,
source_revision,
tree_oid,
work_item,
producer_attempt,
service_id,
idempotency_key,
org_id,
} => {
let project = context::resolve_project_id(project_id.as_deref())?;
let org = resolve_org(org_id)?;
let response = candidate::publish_candidate(
&client,
&candidate::PublishCandidateOptions {
project_id: &project,
repository: repository.as_deref(),
target_ref,
base_revision: base_revision.as_deref(),
source_revision: source_revision.as_deref(),
tree_oid: tree_oid.as_deref(),
work_item_id: work_item,
producer_attempt_id: producer_attempt,
service_id: service_id.as_deref(),
idempotency_key: idempotency_key.as_deref(),
org_id: org.as_deref(),
},
)
.await?;
emit_json_or(&cli, &response, || {
println!(
"candidate={} digest={} state={}",
response
.get("id")
.or_else(|| response.get("candidate_id"))
.and_then(Value::as_str)
.unwrap_or("-"),
response
.get("candidate_digest")
.and_then(Value::as_str)
.unwrap_or("-"),
response
.get("status")
.and_then(Value::as_str)
.unwrap_or("accepted")
);
})?;
}
CandidateCommands::Get {
project_id,
candidate_id,
org_id,
} => {
let project = context::resolve_project_id(project_id.as_deref())?;
let org = resolve_org(org_id)?;
let response =
candidate::get_candidate(&client, &project, candidate_id, org.as_deref())
.await?;
emit_json_or(&cli, &response, || {
println!(
"candidate={} source={} status={}",
response
.get("id")
.or_else(|| response.get("candidate_id"))
.and_then(Value::as_str)
.unwrap_or(candidate_id),
response
.get("source_revision")
.and_then(Value::as_str)
.unwrap_or("-"),
response
.get("status")
.and_then(Value::as_str)
.unwrap_or("-")
);
})?;
}
},
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::Computer { command } => match command {
ComputerCommands::Profiles { command } => match command {
ComputerProfilesCommands::List { org_id } => {
let org = resolve_org(org_id)?;
extra::computer_profiles_list(cli.json, &client, org.as_deref()).await?;
}
ComputerProfilesCommands::Get { profile_id, org_id } => {
let org = resolve_org(org_id)?;
extra::computer_profiles_get(
cli.json,
&client,
&profile_id,
org.as_deref(),
)
.await?;
}
},
ComputerCommands::Lease { command } => match command {
ComputerLeaseCommands::Create {
project,
org_id,
profile,
ttl_secs,
idle_timeout_secs,
} => {
let org = resolve_org(org_id)?;
let project = context::resolve_project_id(project.as_deref())?;
extra::computer_lease_create(
cli.json,
&client,
&project,
org.as_deref(),
&profile,
*ttl_secs,
*idle_timeout_secs,
)
.await?;
}
ComputerLeaseCommands::Get {
lease_id,
project,
org_id,
} => {
let org = resolve_org(org_id)?;
let project = context::resolve_project_id(project.as_deref())?;
extra::computer_lease_get(
cli.json,
&client,
&project,
&lease_id,
org.as_deref(),
)
.await?;
}
ComputerLeaseCommands::Capabilities {
lease_id,
lease_epoch,
project,
org_id,
} => {
let org = resolve_org(org_id)?;
let project = context::resolve_project_id(project.as_deref())?;
extra::computer_lease_capabilities(
cli.json,
&client,
&project,
&lease_id,
&lease_epoch,
org.as_deref(),
)
.await?;
}
ComputerLeaseCommands::Renew {
lease_id,
lease_epoch,
fencing_token,
extension_secs,
project,
org_id,
} => {
let org = resolve_org(org_id)?;
let project = context::resolve_project_id(project.as_deref())?;
extra::computer_lease_renew(
cli.json,
&client,
&project,
&lease_id,
&lease_epoch,
&fencing_token,
*extension_secs,
org.as_deref(),
)
.await?;
}
ComputerLeaseCommands::Reclaim {
lease_id,
lease_epoch,
fencing_token,
reason_code,
project,
org_id,
} => {
let org = resolve_org(org_id)?;
let project = context::resolve_project_id(project.as_deref())?;
extra::computer_lease_reclaim(
cli.json,
&client,
&project,
&lease_id,
&lease_epoch,
&fencing_token,
&reason_code,
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 = if environment.is_some() {
project.clone()
} else {
Some(context::resolve_project_id(project.as_deref())?)
};
extra::logs_follow(
cli.json,
&client,
project.as_deref(),
environment.as_deref(),
"runtime",
None,
*tail,
None,
None,
None,
2,
org.as_deref(),
)
.await?;
}
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!("handled before client construction"),
Commands::Completions { .. } => {
unreachable!("handled before client construction")
}
}
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 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!("unix:{secs}")
}
fn emit_completions(shell: CompletionShell) -> anyhow::Result<()> {
let mut cmd = Cli::command();
let name = cmd.get_name().to_string();
match shell {
CompletionShell::Bash => generate(shells::Bash, &mut cmd, name, &mut std::io::stdout()),
CompletionShell::Zsh => generate(shells::Zsh, &mut cmd, name, &mut std::io::stdout()),
CompletionShell::Fish => generate(shells::Fish, &mut cmd, name, &mut std::io::stdout()),
CompletionShell::Powershell => {
generate(shells::PowerShell, &mut cmd, name, &mut std::io::stdout())
}
CompletionShell::Elvish => generate(shells::Elvish, &mut cmd, name, &mut std::io::stdout()),
}
Ok(())
}
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 resolve_active_token(cli_token: Option<&str>) -> anyhow::Result<String> {
if cli_token.is_some_and(|token| !token.trim().is_empty())
|| std::env::var("SYLPHX_TOKEN")
.ok()
.is_some_and(|token| !token.trim().is_empty())
{
return credentials::resolve_token(cli_token);
}
let Some(mut stored) = credentials::load()? else {
return credentials::resolve_token(None);
};
let Some(session) = stored.oauth.clone() else {
return Ok(stored.token);
};
let now = oauth_session::now()?;
if !oauth_session::should_refresh(session.access_expires_at, now) {
return Ok(stored.token);
}
let http = oauth_session::http_client()?;
let refreshed = oauth_session::refresh(&http, &session).await?;
stored.token = refreshed.access_token.clone();
stored.oauth = Some(credentials::OAuthSession {
issuer: session.issuer,
token_endpoint: session.token_endpoint,
revocation_endpoint: session.revocation_endpoint,
client_id: session.client_id,
refresh_token: refreshed.refresh_token,
access_expires_at: Some(now.saturating_add(refreshed.expires_in)),
refresh_expires_at: refreshed.refresh_expires_at,
scope: refreshed.scope.or(session.scope),
});
credentials::save(&stored)?;
Ok(stored.token)
}
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, oauth) = if let Some(t) = token_arg {
(t, "token".to_string(), None)
} else {
let _ = device; let http = oauth_session::http_client()?;
let metadata = oauth_session::discover(&http, &auth_base).await?;
let tokens = device_login(&http, &metadata, &client_id, timeout_secs).await?;
let now = oauth_session::now()?;
let session = oauth_session::session_from_token_set(&metadata, &client_id, &tokens, now);
(tokens.access_token, "device".to_string(), Some(session))
};
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::login_verification_err(e));
}
}
}
}
let path = credentials::save(&credentials::CredentialsFile {
token: token.clone(),
api_url: Some(api_url.clone()),
source: Some(source.clone()),
oauth: oauth.clone(),
})?;
let body = json!({
"ok": true,
"source": source,
"path": path.display().to_string(),
"apiUrl": api_url,
"refreshCapable": oauth.is_some(),
"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 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(
http: &reqwest::Client,
metadata: &oauth_session::OAuthMetadata,
client_id: &str,
timeout_secs: u64,
) -> anyhow::Result<oauth_session::OAuthTokenSet> {
let init_url = metadata.device_authorization_endpoint.clone();
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.",
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})"
)
})?
.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!(
"{}/poll?device_code={}",
metadata.device_authorization_endpoint.trim_end_matches('/'),
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();
if status.as_u16() == 428 || status.as_u16() == 400 {
continue;
}
anyhow::bail!("device poll failed HTTP {status}");
}
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");
}
if body.get("access_token").is_some()
|| body.get("accessToken").is_some()
|| body.pointer("/tokens/access_token").is_some()
|| body.pointer("/token/access_token").is_some()
{
return oauth_session::token_set_from_value(&body);
}
if status == "approved" || status == "complete" || status.is_empty() {
anyhow::bail!("device poll approved but did not return a complete token pair");
}
anyhow::bail!("unexpected device poll status={status}");
}
}
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"
);
}
}