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::Client as BaasClient;
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, default_value_t = false)]
localhost: 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,
},
Objects {
#[command(subcommand)]
command: StorageCommands,
},
Tasks {
#[command(subcommand)]
command: TaskCommands,
},
Work {
#[command(subcommand)]
command: WorkCommands,
},
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,
},
#[command(name = "device-sessions")]
DeviceSessions {
#[command(subcommand)]
command: DeviceSessionCommands,
},
#[command(name = "device-tests")]
DeviceTests {
#[command(subcommand)]
command: DeviceTestCommands,
},
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>,
},
Buckets {
#[arg(long)]
secret_url: Option<String>,
},
Ls {
#[arg(long)]
bucket: Option<String>,
#[arg(long)]
prefix: Option<String>,
#[arg(long)]
limit: Option<u32>,
#[arg(long)]
secret_url: Option<String>,
},
Put {
target: String,
path: Option<PathBuf>,
#[arg(long)]
filename: Option<String>,
#[arg(long)]
bucket: Option<String>,
#[arg(long)]
secret_url: Option<String>,
},
Get {
file_id: String,
#[arg(long)]
bucket: Option<String>,
#[arg(long)]
secret_url: 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 WorkCommands {
Index {
#[arg(long)]
secret_url: Option<String>,
},
Create {
#[arg(long)]
body: Option<String>,
#[arg(long)]
image: Option<String>,
#[arg(long)]
image_from_service: Option<String>,
#[arg(long)]
command: Option<String>,
#[arg(long, default_value = "single")]
kind: String,
#[arg(long)]
task_count: Option<u32>,
#[arg(long, default_value = "standard")]
machine: String,
#[arg(long)]
capacity_class: Option<String>,
#[arg(long, default_value_t = 3600)]
timeout_secs: u64,
#[arg(long)]
secret_url: Option<String>,
},
Get {
id: String,
#[arg(long)]
secret_url: Option<String>,
},
Wait {
id: String,
#[arg(long, default_value_t = 3600)]
timeout_secs: u64,
#[arg(long, default_value_t = 2)]
interval_secs: u64,
#[arg(long)]
secret_url: Option<String>,
},
ScheduleList {
#[arg(long)]
secret_url: Option<String>,
},
ScheduleCreate {
name: String,
#[arg(long)]
cron: String,
#[arg(long)]
callback_url: String,
#[arg(long)]
secret_url: Option<String>,
#[arg(long)]
payload: Option<String>,
},
SchedulePause {
id: String,
#[arg(long)]
secret_url: Option<String>,
},
ScheduleResume {
id: String,
#[arg(long)]
secret_url: Option<String>,
},
ScheduleDelete {
id: String,
#[arg(long)]
secret_url: Option<String>,
},
ScheduleFire {
id: String,
#[arg(long)]
secret_url: Option<String>,
#[arg(long)]
payload: 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 DeviceSessionCommands {
List {
#[arg(long)]
project: Option<String>,
#[arg(long)]
org_id: Option<String>,
#[arg(long)]
status: Option<String>,
#[arg(long)]
env_id: Option<String>,
},
Create {
#[arg(long)]
project: Option<String>,
#[arg(long)]
org_id: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
env_id: Option<String>,
#[arg(long, default_value = "android")]
platform: String,
#[arg(long)]
runtime: Option<String>,
#[arg(long)]
os_version: Option<String>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
locale: Option<String>,
#[arg(long)]
ttl_secs: Option<u64>,
#[arg(long, default_value_t = false)]
stream: bool,
#[arg(long, default_value_t = false)]
wait: bool,
#[arg(long, default_value_t = 600)]
wait_timeout_secs: u64,
#[arg(long, default_value_t = 3)]
poll_interval_secs: u64,
},
Get {
session_id: String,
#[arg(long)]
org_id: Option<String>,
},
Install {
session_id: String,
apk_uri: String,
#[arg(long)]
org_id: Option<String>,
},
Launch {
session_id: String,
#[arg(long)]
package: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Input {
session_id: String,
kind: String,
args: Vec<String>,
#[arg(long)]
org_id: Option<String>,
},
Screenshot {
session_id: String,
#[arg(short = 'o', long)]
output: Option<PathBuf>,
#[arg(long)]
org_id: Option<String>,
},
Logcat {
session_id: String,
#[arg(long)]
lines: Option<u32>,
#[arg(long)]
org_id: Option<String>,
},
Rm {
session_id: String,
#[arg(long)]
org_id: Option<String>,
},
}
#[derive(Subcommand, Debug, Clone)]
enum DeviceTestCommands {
List {
#[arg(long)]
project: Option<String>,
#[arg(long)]
org_id: Option<String>,
#[arg(long)]
status: Option<String>,
#[arg(long)]
env_id: Option<String>,
},
Create {
#[arg(long)]
project: Option<String>,
#[arg(long)]
org_id: Option<String>,
#[arg(long)]
body: PathBuf,
},
Get {
test_id: String,
#[arg(long)]
org_id: Option<String>,
},
Cancel {
test_id: String,
#[arg(long)]
org_id: Option<String>,
},
Wait {
test_id: String,
#[arg(long)]
org_id: Option<String>,
#[arg(long, default_value_t = 3600)]
timeout_secs: u64,
#[arg(long, default_value_t = 5)]
interval_secs: u64,
},
}
#[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",
"objects create", "objects list", "objects buckets", "objects put", "objects get",
"work index", "work create", "work get", "work wait", "work schedule-list", "work schedule-create", "work schedule-fire",
"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,
localhost,
auth_url,
client_id,
timeout_secs,
no_verify,
} = &cli.command
{
return run_login(
&cli,
login_token.clone(),
*device,
*localhost,
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 org = resolve_org(org_id)?;
let p = client.get_project(&id, org.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 org = resolve_org(org_id)?;
let p = client
.create_project(
&name,
slug.as_deref(),
org.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 org = resolve_org(org_id)?;
let page = page_opts(page_token.clone(), page_size.as_ref().copied());
let list = client
.list_deployments(&project_id, page.as_ref(), org.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 org = resolve_org(org_id)?;
let r = client
.create_deploy_with_options(
&project_id,
env.as_deref(),
env_id.as_deref(),
r#ref.as_deref(),
service.as_deref(),
*force,
org.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())?;
let org = resolve_org(org_id)?;
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.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.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 org = resolve_org(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.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.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 org = resolve_org(org_id)?;
let list = client
.list_resources(kind.as_deref(), project.as_deref(), None, org.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 org = resolve_org(org_id)?;
let r = client.get_resource(&id, org.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 org = resolve_org(org_id)?;
let r = client
.create_resource(
&kind,
&name,
project.as_deref(),
tier.as_deref(),
storage.as_ref().copied(),
org.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 },
} => {
let org = resolve_org(org_id)?;
ux::confirm_or_yes(cli.yes, cli.json, &format!("delete resource {id}?"))?;
let r = client.delete_resource(&id, org.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 org = resolve_org(org_id)?;
let r = client
.bind_resource(
&resource_id,
&environment_id,
project.as_deref(),
org.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 org = resolve_org(org_id)?;
let r = client
.create_resource(
"database",
&name,
project.as_deref(),
Some(&tier),
Some(*storage),
org.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 org = resolve_org(org_id)?;
let list = client
.list_resources(
Some("database"),
project.as_deref(),
None,
org.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 org = resolve_org(org_id)?;
let r = client.get_resource(&id, org.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 org = resolve_org(org_id)?;
ux::confirm_or_yes(cli.yes, cli.json, &format!("delete database resource {id}?"))?;
let r = client.delete_resource(&id, org.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 org = resolve_org(org_id)?;
let r = client
.branch_resource(&resource_id, &name, env.as_deref(), org.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::Objects {
command:
StorageCommands::Create {
project_id,
name,
env,
org_id,
},
} => {
let org = resolve_org(org_id)?;
let r = client
.create_project_storage(&project_id, &name, env.as_deref(), org.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::Objects {
command: StorageCommands::List { project_id, org_id },
} => {
let org = resolve_org(org_id)?;
match client
.list_project_storage(project_id, org.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 },
} => {
let org = resolve_org(org_id)?;
match client
.list_tasks(project.as_deref(), None, org.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 org = resolve_org(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.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 org = resolve_org(org_id)?;
let t = client.get_task(&id, org.as_deref()).await?;
emit_json_or(&cli, &t, || {
println!("{}\t{}\t{}", t.id, t.name, t.status);
})?;
}
Commands::Work {
command: WorkCommands::Index { secret_url },
} => {
let baas = baas_client_from_secret_url(secret_url.as_ref())?;
let v = baas
.batch_jobs_index()
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
print_baas_value(&cli, &v)?;
}
Commands::Work {
command:
WorkCommands::Create {
body,
image,
image_from_service,
command,
kind,
task_count,
machine,
capacity_class,
timeout_secs,
secret_url,
},
} => {
let baas = baas_client_from_secret_url(secret_url.as_ref())?;
let req = if let Some(path) = body {
let raw = std::fs::read_to_string(&path)
.with_context(|| format!("read work body {}", path))?;
serde_json::from_str(&raw).context("work --body must be JSON")?
} else {
let mut req = json!({
"machine": machine,
"timeoutSeconds": *timeout_secs,
"execution": { "kind": kind },
});
if kind == "array" {
let n = task_count.unwrap_or(1);
req["execution"] = json!({ "kind": "array", "taskCount": n });
}
if let Some(img) = image {
req["image"] = json!(img);
} else if let Some(svc) = image_from_service {
req["image"] = json!({ "service": svc });
} else {
anyhow::bail!("work create requires --image, --image-from-service, or --body");
}
if let Some(cmd) = command {
req["command"] = serde_json::from_str(&cmd)
.context("--command must be a JSON array")?;
}
if let Some(cc) = capacity_class {
req["capacityClass"] = json!(cc);
}
req
};
let v = baas
.batch_jobs_create(&req)
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
print_baas_value(&cli, &v)?;
}
Commands::Work {
command: WorkCommands::Get { id, secret_url },
} => {
let baas = baas_client_from_secret_url(secret_url.as_ref())?;
let v = baas
.batch_jobs_get(&id)
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
print_baas_value(&cli, &v)?;
}
Commands::Work {
command:
WorkCommands::Wait {
id,
timeout_secs,
interval_secs,
secret_url,
},
} => {
let baas = baas_client_from_secret_url(secret_url.as_ref())?;
let deadline = std::time::Instant::now() + Duration::from_secs(*timeout_secs);
loop {
let v = baas
.batch_jobs_get(&id)
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
let status = v
.get("status")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
if matches!(
status.as_str(),
"succeeded" | "failed" | "cancelled" | "timeout"
) {
print_baas_value(&cli, &v)?;
break;
}
if std::time::Instant::now() >= deadline {
print_baas_value(&cli, &v)?;
anyhow::bail!("work wait timed out status={status}");
}
tokio::time::sleep(Duration::from_secs(*interval_secs)).await;
}
}
Commands::Work {
command: WorkCommands::ScheduleList { secret_url },
} => {
let baas = baas_client_from_secret_url(secret_url.as_ref())?;
let v = baas.cron_list().await.map_err(|e| anyhow::anyhow!(e.to_string()))?;
print_baas_value(&cli, &v)?;
}
Commands::Work {
command:
WorkCommands::ScheduleCreate {
name,
cron,
callback_url,
secret_url,
payload,
},
} => {
let baas = baas_client_from_secret_url(secret_url.as_ref())?;
let mut body = json!({
"taskName": name,
"name": name,
"cron": cron,
"cronExpression": cron,
"callbackUrl": callback_url,
});
if let Some(p) = payload {
let parsed: Value = serde_json::from_str(&p)
.context("--payload must be JSON")?;
body["payload"] = parsed;
}
let v = baas
.cron_create(&body)
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
print_baas_value(&cli, &v)?;
}
Commands::Work {
command: WorkCommands::SchedulePause { id, secret_url },
} => {
let baas = baas_client_from_secret_url(secret_url.as_ref())?;
let v = baas
.cron_pause(&id)
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
print_baas_value(&cli, &v)?;
}
Commands::Work {
command: WorkCommands::ScheduleResume { id, secret_url },
} => {
let baas = baas_client_from_secret_url(secret_url.as_ref())?;
let v = baas
.cron_resume(&id)
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
print_baas_value(&cli, &v)?;
}
Commands::Work {
command: WorkCommands::ScheduleDelete { id, secret_url },
} => {
let baas = baas_client_from_secret_url(secret_url.as_ref())?;
ux::confirm_or_yes(cli.yes, cli.json, &format!("delete work schedule {id}?"))?;
let v = baas
.cron_delete(&id)
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
print_baas_value(&cli, &v)?;
}
Commands::Work {
command: WorkCommands::ScheduleFire { id, secret_url, payload },
} => {
let baas = baas_client_from_secret_url(secret_url.as_ref())?;
let pl = match payload {
Some(p) => Some(serde_json::from_str::<Value>(&p).context("--payload must be JSON")?),
None => None,
};
let v = baas
.cron_fire(&id, pl.as_ref())
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
print_baas_value(&cli, &v)?;
}
Commands::Objects {
command: StorageCommands::Buckets { secret_url },
} => {
let baas = baas_client_from_secret_url(secret_url.as_ref())?;
let v = baas
.storage_list_buckets()
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
print_baas_value(&cli, &v)?;
}
Commands::Objects {
command:
StorageCommands::Ls {
bucket,
prefix,
limit,
secret_url,
},
} => {
let baas = baas_client_from_secret_url(secret_url.as_ref())?;
let v = baas
.storage_list_objects(bucket.as_deref(), prefix.as_deref(), limit.as_ref().copied())
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
print_baas_value(&cli, &v)?;
}
Commands::Objects {
command:
StorageCommands::Put {
target,
path,
filename,
bucket,
secret_url,
},
} => {
let baas = baas_client_from_secret_url(secret_url.as_ref())?;
let (key, file_path) = match (path.as_ref(), filename.as_ref()) {
(Some(p), Some(k)) => (k.clone(), p.clone()),
(Some(p), None) => (target.clone(), p.clone()),
(None, Some(k)) => (k.clone(), PathBuf::from(&target)),
(None, None) => {
let p = PathBuf::from(&target);
let k = p
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "upload.bin".into());
(k, p)
}
};
let bytes = std::fs::read(&file_path)
.with_context(|| format!("read local file {}", file_path.display()))?;
let ct = match file_path.extension().and_then(|e| e.to_str()) {
Some("json") => Some("application/json"),
Some("txt") | Some("md") => Some("text/plain"),
Some("png") => Some("image/png"),
Some("jpg") | Some("jpeg") => Some("image/jpeg"),
_ => Some("application/octet-stream"),
};
let v = baas
.storage_put_object(&key, &bytes, ct, bucket.as_deref())
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
print_baas_value(&cli, &v)?;
}
Commands::Objects {
command: StorageCommands::Get { file_id, bucket, secret_url },
} => {
let baas = baas_client_from_secret_url(secret_url.as_ref())?;
match baas.storage_get_object_bytes(&file_id, bucket.as_deref()).await {
Ok(bytes) => {
if cli.json {
use base64::Engine as _;
let body = json!({
"key": file_id,
"size": bytes.len(),
"base64": base64::engine::general_purpose::STANDARD.encode(&bytes),
});
println!("{}", serde_json::to_string_pretty(&body)?);
} else {
use std::io::Write;
std::io::stdout().write_all(&bytes)?;
}
}
Err(e) => {
let v = baas
.storage_get_file(&file_id)
.await
.map_err(|_| anyhow::anyhow!(e.to_string()))?;
print_baas_value(&cli, &v)?;
}
}
}
Commands::Backup {
command: BackupCommands::List { resource, org_id },
} => {
let org = resolve_org(org_id)?;
match client
.list_backups(resource.as_deref(), org.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.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 org = resolve_org(org_id)?;
let r = client
.create_backup(resource.as_deref(), tag.as_deref(), org.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 org = resolve_org(org_id)?;
let r = client
.restore_backup(&backup_id, resource.as_deref(), org.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 org = resolve_org(org_id)?;
match client
.list_flags(project.as_deref(), org.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 org = resolve_org(org_id)?;
let r = client
.create_flag(
&key,
description.as_deref(),
project.as_deref(),
enabled.as_ref().copied(),
org.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 org = resolve_org(org_id)?;
let r = client
.toggle_flag(&key, enabled.as_ref().copied(), project.as_deref(), org.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 org = resolve_org(org_id)?;
let r = client
.get_preview_cost(&project_id, &pr_number, org.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::DeviceSessions { command } => match command {
DeviceSessionCommands::List {
project,
org_id,
status,
env_id,
} => {
let project = context::resolve_project_id(project.as_deref())?;
let org = resolve_org(org_id)?;
extra::device_sessions_list(
cli.json,
&client,
&project,
org.as_deref(),
status.as_deref(),
env_id.as_deref(),
)
.await?;
}
DeviceSessionCommands::Create {
project,
org_id,
name,
env_id,
platform,
runtime,
os_version,
model,
locale,
ttl_secs,
stream,
wait,
wait_timeout_secs,
poll_interval_secs,
} => {
let project = context::resolve_project_id(project.as_deref())?;
let org = resolve_org(org_id)?;
extra::device_sessions_create(
cli.json,
&client,
&project,
org.as_deref(),
name.as_deref(),
env_id.as_deref(),
&platform,
runtime.as_deref(),
os_version.as_deref(),
model.as_deref(),
locale.as_deref(),
ttl_secs.as_ref().copied(),
*stream,
*wait,
*wait_timeout_secs,
*poll_interval_secs,
)
.await?;
}
DeviceSessionCommands::Get { session_id, org_id } => {
let org = resolve_org(org_id)?;
extra::device_sessions_get(cli.json, &client, &session_id, org.as_deref()).await?;
}
DeviceSessionCommands::Install {
session_id,
apk_uri,
org_id,
} => {
let org = resolve_org(org_id)?;
extra::device_sessions_install(
cli.json,
&client,
&session_id,
&apk_uri,
org.as_deref(),
)
.await?;
}
DeviceSessionCommands::Launch {
session_id,
package,
org_id,
} => {
let org = resolve_org(org_id)?;
extra::device_sessions_launch(
cli.json,
&client,
&session_id,
package.as_deref(),
org.as_deref(),
)
.await?;
}
DeviceSessionCommands::Input {
session_id,
kind,
args,
org_id,
} => {
let org = resolve_org(org_id)?;
let body = match kind.as_str() {
"tap" => {
if args.len() < 2 {
anyhow::bail!("input tap requires: <x> <y>");
}
json!({
"type": "tap",
"x": args[0].parse::<f64>().context("tap x")?,
"y": args[1].parse::<f64>().context("tap y")?,
})
}
"swipe" => {
if args.len() < 4 {
anyhow::bail!("input swipe requires: <x1> <y1> <x2> <y2> [duration_ms]");
}
let mut b = json!({
"type": "swipe",
"x1": args[0].parse::<f64>().context("swipe x1")?,
"y1": args[1].parse::<f64>().context("swipe y1")?,
"x2": args[2].parse::<f64>().context("swipe x2")?,
"y2": args[3].parse::<f64>().context("swipe y2")?,
});
if args.len() >= 5 {
b["durationMs"] = json!(args[4].parse::<u64>().context("duration_ms")?);
}
b
}
"key" => {
if args.is_empty() {
anyhow::bail!("input key requires: <keycode|KEYCODE_*>");
}
if let Ok(n) = args[0].parse::<i64>() {
json!({ "type": "key", "keycode": n })
} else {
json!({ "type": "key", "keycode": args[0] })
}
}
"text" => {
if args.is_empty() {
anyhow::bail!("input text requires: <string…>");
}
json!({ "type": "text", "text": args.join(" ") })
}
other => anyhow::bail!("unknown input kind {other:?} (tap|swipe|key|text)"),
};
extra::device_sessions_input(
cli.json,
&client,
&session_id,
&body,
org.as_deref(),
)
.await?;
}
DeviceSessionCommands::Screenshot {
session_id,
output,
org_id,
} => {
let org = resolve_org(org_id)?;
extra::device_sessions_screenshot(
cli.json,
&client,
&session_id,
output.as_deref(),
org.as_deref(),
)
.await?;
}
DeviceSessionCommands::Logcat {
session_id,
lines,
org_id,
} => {
let org = resolve_org(org_id)?;
extra::device_sessions_logcat(
cli.json,
&client,
&session_id,
lines.as_ref().copied(),
org.as_deref(),
)
.await?;
}
DeviceSessionCommands::Rm { session_id, org_id } => {
let org = resolve_org(org_id)?;
ux::confirm_or_yes(
cli.yes,
cli.json,
&format!("terminate device session {session_id}?"),
)?;
extra::device_sessions_rm(cli.json, &client, &session_id, org.as_deref()).await?;
}
},
Commands::DeviceTests { command } => match command {
DeviceTestCommands::List {
project,
org_id,
status,
env_id,
} => {
let project = context::resolve_project_id(project.as_deref())?;
let org = resolve_org(org_id)?;
extra::device_tests_list(
cli.json,
&client,
&project,
org.as_deref(),
status.as_deref(),
env_id.as_deref(),
)
.await?;
}
DeviceTestCommands::Create {
project,
org_id,
body,
} => {
let project = context::resolve_project_id(project.as_deref())?;
let org = resolve_org(org_id)?;
let raw = std::fs::read_to_string(&body)
.with_context(|| format!("read {}", body.display()))?;
let value: Value =
serde_json::from_str(&raw).context("device-tests --body must be JSON")?;
extra::device_tests_create(
cli.json,
&client,
&project,
org.as_deref(),
&value,
)
.await?;
}
DeviceTestCommands::Get { test_id, org_id } => {
let org = resolve_org(org_id)?;
extra::device_tests_get(cli.json, &client, &test_id, org.as_deref()).await?;
}
DeviceTestCommands::Cancel { test_id, org_id } => {
let org = resolve_org(org_id)?;
extra::device_tests_cancel(cli.json, &client, &test_id, org.as_deref()).await?;
}
DeviceTestCommands::Wait {
test_id,
org_id,
timeout_secs,
interval_secs,
} => {
let org = resolve_org(org_id)?;
extra::device_tests_wait(
cli.json,
&client,
&test_id,
org.as_deref(),
*timeout_secs,
*interval_secs,
)
.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 } => {
let org = resolve_org(org_id)?;
extra::init_project(cli.json, name, org.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 baas_client_from_secret_url(secret_url: Option<&String>) -> anyhow::Result<BaasClient> {
let raw = secret_url
.map(|s| s.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.or_else(|| {
std::env::var("SYLPHX_SECRET_URL")
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
});
let Some(url) = raw else {
anyhow::bail!(
"BaaS runtime credential required. Set SYLPHX_SECRET_URL (sylphx://sk_…@<slug>.api.sylphx.com) \
or pass --secret-url. Values are inject/vault-path only — do not paste secrets into chat."
);
};
if !url.starts_with("sylphx://") {
anyhow::bail!(
"SYLPHX_SECRET_URL must be a sylphx:// connection URL (not a bare sk_*). \
Runtime services receive this via platform inject; agents should read vault/env only."
);
}
BaasClient::from_connection_url(&url).map_err(|e| anyhow::anyhow!(e.to_string()))
}
fn print_baas_value(cli: &Cli, v: &serde_json::Value) -> anyhow::Result<()> {
println!("{}", serde_json::to_string_pretty(v)?);
let _ = cli;
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,
localhost: 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 {
let _ = (device, localhost);
(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 = if localhost {
match loopback_login(&http, &metadata, &client_id, timeout_secs).await {
Ok(tokens) => tokens,
Err(err) => {
eprintln!(
"loopback login failed ({err}); falling back to device authorization"
);
device_login(&http, &metadata, &client_id, timeout_secs).await?
}
}
} else {
device_login(&http, &metadata, &client_id, timeout_secs).await?
};
let source = if localhost { "loopback_or_device" } else { "device" };
let now = oauth_session::now()?;
let session = oauth_session::session_from_token_set(&metadata, &client_id, &tokens, now);
(tokens.access_token, source.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
}
const CLI_LOOPBACK_BIND: &str = "127.0.0.1:8759";
const CLI_LOOPBACK_REDIRECT_URI: &str = "http://127.0.0.1:8759/callback";
fn urlencoding_encode(raw: &str) -> String {
let mut out = String::with_capacity(raw.len() * 3);
for b in raw.as_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 random_url_safe_bytes(len: usize) -> anyhow::Result<Vec<u8>> {
use std::io::Read;
let mut buf = vec![0u8; len];
std::fs::File::open("/dev/urandom")
.context("open /dev/urandom")?
.read_exact(&mut buf)
.context("read /dev/urandom")?;
Ok(buf)
}
async fn loopback_login(
http: &reqwest::Client,
metadata: &oauth_session::OAuthMetadata,
client_id: &str,
timeout_secs: u64,
) -> anyhow::Result<oauth_session::OAuthTokenSet> {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use sha2::{Digest, Sha256};
use std::io::{Read, Write};
use std::net::TcpListener;
let listener = TcpListener::bind(CLI_LOOPBACK_BIND)
.with_context(|| format!("bind loopback {CLI_LOOPBACK_BIND}"))?;
listener
.set_nonblocking(true)
.context("loopback nonblocking")?;
let verifier_bytes = random_url_safe_bytes(32)?;
let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
let code_challenge = URL_SAFE_NO_PAD.encode(Sha256::digest(code_verifier.as_bytes()));
let state = URL_SAFE_NO_PAD.encode(random_url_safe_bytes(16)?);
let authorize_url = format!(
"{}/v1/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&code_challenge={}&code_challenge_method=S256&scope={}&state={}",
metadata.issuer.trim_end_matches('/'),
urlencoding_encode(client_id),
urlencoding_encode(CLI_LOOPBACK_REDIRECT_URI),
urlencoding_encode(&code_challenge),
urlencoding_encode("openid profile email offline_access"),
urlencoding_encode(&state),
);
eprintln!();
eprintln!("┌─ Sylphx CLI login (loopback PKCE) ─────────────────────────");
eprintln!("│ Open this URL in a browser on this same machine:");
eprintln!("│");
eprintln!("│ {authorize_url}");
eprintln!("│");
eprintln!("│ Waiting on {CLI_LOOPBACK_REDIRECT_URI}");
eprintln!("└────────────────────────────────────────────────────────────");
eprintln!();
let _ = open_browser_url(&authorize_url);
let deadline = std::time::Instant::now() + Duration::from_secs(timeout_secs.max(30));
let (code, returned_state) = loop {
if std::time::Instant::now() > deadline {
anyhow::bail!("loopback login timed out waiting for browser callback");
}
match listener.accept() {
Ok((mut stream, _)) => {
let _ = stream.set_nonblocking(false);
let mut buf = [0u8; 8192];
let n = stream.read(&mut buf).unwrap_or(0);
let req = String::from_utf8_lossy(&buf[..n]);
let first = req.lines().next().unwrap_or("");
let path = first.split_whitespace().nth(1).unwrap_or("");
let q = path.split('?').nth(1).unwrap_or("");
let mut code: Option<String> = None;
let mut st: Option<String> = None;
let mut err: Option<String> = None;
for pair in q.split('&') {
let mut it = pair.splitn(2, '=');
let k = it.next().unwrap_or("");
let v = it.next().unwrap_or("");
match k {
"code" => code = Some(urlencoding_decode(v)),
"state" => st = Some(urlencoding_decode(v)),
"error" => err = Some(urlencoding_decode(v)),
_ => {}
}
}
let body = b"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nConnection: close\r\n\r\n<!doctype html><html><body><h1>CLI login complete</h1><p>You can close this window and return to the terminal.</p></body></html>";
let _ = stream.write_all(body);
if let Some(e) = err {
anyhow::bail!("loopback authorize error: {e}");
}
let code = code.ok_or_else(|| anyhow::anyhow!("loopback callback missing code"))?;
let st = st.unwrap_or_default();
break (code, st);
}
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
tokio::time::sleep(Duration::from_millis(100)).await;
}
Err(err) => return Err(err).context("accept loopback"),
}
};
if returned_state != state {
anyhow::bail!("loopback state mismatch");
}
let token_resp = http
.post(&metadata.token_endpoint)
.json(&json!({
"grant_type": "authorization_code",
"code": code,
"redirect_uri": CLI_LOOPBACK_REDIRECT_URI,
"client_id": client_id,
"code_verifier": code_verifier,
}))
.send()
.await
.with_context(|| format!("loopback token POST {}", metadata.token_endpoint))?;
let status = token_resp.status();
let body: Value = token_resp
.json()
.await
.context("decode loopback token response")?;
if !status.is_success() {
anyhow::bail!("loopback token exchange failed HTTP {status}: {body}");
}
oauth_session::token_set_from_value(&body)
}
fn urlencoding_decode(raw: &str) -> String {
let bytes = raw.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
let h = |c: u8| -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
};
if let (Some(a), Some(b)) = (h(bytes[i + 1]), h(bytes[i + 2])) {
out.push((a << 4) | b);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
c => {
out.push(c);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
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 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"
);
}
#[test]
fn urlencoding_encoder_is_shared_by_oauth_flows() {
assert_eq!(urlencoding_encode("device code+/"), "device%20code%2B%2F");
}
}