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_apps_sdk_core::{
normalize_management_base_url, RetryConfig, DEFAULT_MANAGEMENT_BASE_URL,
};
use sylphx_apps_sdk_management::{ListOptions, ManagementClient};
mod candidate;
mod delivery;
use delivery::{DeliveryCommands, DeploymentCommands, ReleasesCommands};
mod credentials;
mod channel;
mod context;
mod oauth_session;
mod ux;
mod extra;
mod runtime;
#[derive(Parser, Debug)]
#[command(
name = "sylphx",
about = "Sylphx Cloud CLI — deploy, manage, and operate projects from the terminal",
long_about = "Sylphx Cloud CLI — Management-plane operator (Rust sole authority).
\
Day-1:
sylphx login / doctor / whoami / context use / projects list / deployments create
\
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)]
client_id: Option<String>,
#[arg(long, default_value_t = false)]
identity: bool,
#[arg(long, env = "SYLPHX_IDENTITY_URL")]
identity_auth_url: Option<String>,
#[arg(long)]
org: Option<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,
},
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,
},
Work {
#[command(subcommand)]
command: WorkCommands,
},
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 EnvCommands {
List {
project_id: 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)]
env_id: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Rm {
key: String,
#[arg(long = "project")]
project_id: Option<String>,
#[arg(long)]
env_id: Option<String>,
#[arg(long)]
org_id: Option<String>,
},
Pull {
project_id: 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>,
},
Update {
id: String,
#[arg(long)]
tier: String,
#[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>,
},
}
#[derive(Subcommand, Debug)]
enum StorageCommands {
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 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>,
},
}
#[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>,
},
}
#[derive(Subcommand, Debug)]
enum PreviewCommands {
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)]
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)]
tag: Option<String>,
#[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(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", "deployments get", "deployments create", "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 update", "db delete", "db branch",
"objects create", "objects list", "objects buckets", "objects put", "objects get",
"work index", "work create", "work get", "work wait",
"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", "releases get", "releases create",
"releases promote", "releases receipts", "releases decisions", "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 configuration get", "delivery policy get", "delivery policy patch",
"api", "completions", "env pull", "version-info",
];
#[tokio::main]
async fn main() -> ExitCode {
let mut registry = buffa::type_registry::TypeRegistry::new();
sylphx_apps_sdk_core::register_wire_types(&mut registry);
buffa::type_registry::set_type_registry(registry);
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_apps_api_types::CONTRACT_PACKAGE,
"contractRevision": sylphx_apps_api_types::CONTRACT_REVISION,
"client": "sylphx-apps-sdk-management",
"product": "rust",
"shippedCommands": SHIPPED_COMMANDS,
});
print_out(&cli, &body, || {
println!(
"sylphx {} (Rust) contract {}@{} via sylphx-sdk-management",
env!("CARGO_PKG_VERSION"),
sylphx_apps_api_types::CONTRACT_PACKAGE,
sylphx_apps_api_types::CONTRACT_REVISION
);
});
return Ok(());
}
if let Commands::Login {
token: login_token,
device,
localhost,
auth_url,
client_id,
identity,
identity_auth_url,
org: login_org,
timeout_secs,
no_verify,
} = &cli.command
{
return run_login(
&cli,
login_token.clone(),
*device,
*localhost,
auth_url.clone(),
client_id.clone(),
*identity,
identity_auth_url.clone(),
login_org.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()?;
if session.management_base_url.is_some() {
oauth_session::revoke_management(&http, session).await?;
} else {
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 } => {
delivery::deployments(cli.json, &client, command).await?;
}
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_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_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,
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, 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_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_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_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_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(op) = r.operation.as_option() {
println!(
"operation={} state={} phase={}",
op.name, op.state, op.phase
);
}
})?;
}
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, || {
if let Some(op) = r.operation.as_option() {
ux::ok_line(&format!("operation={} state={}", op.name, op.state));
}
})?;
}
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, || {
if let Some(op) = r.operation.as_option() {
println!("operation={} state={}", op.name, op.state);
}
})?;
}
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(op) = r.operation.as_option() {
println!("operation={} state={}", op.name, op.state);
}
})?;
}
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, || {
if let Some(op) = r.operation.as_option() {
ux::ok_line(&format!("operation={} state={}", op.name, op.state));
}
})?;
}
Commands::Db {
command: DbCommands::Update { id, tier, org_id },
} => {
let org = resolve_org(org_id)?;
let r = client
.update_database_tier(&id, &tier, org.as_deref())
.await?;
emit_json_or(&cli, &r, || {
println!(
"{}\t{}\ttier={}\t{}",
r.id,
r.name,
r.tier.as_deref().unwrap_or("-"),
r.status
);
})?;
}
Commands::Work {
command: WorkCommands::Index { secret_url },
} => {
let baas = runtime::RuntimeClient::from_secret_url(secret_url.as_ref())?;
let v = baas
.work_container_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 = runtime::RuntimeClient::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
.work_container_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 = runtime::RuntimeClient::from_secret_url(secret_url.as_ref())?;
let v = baas
.work_container_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 = runtime::RuntimeClient::from_secret_url(secret_url.as_ref())?;
let deadline = std::time::Instant::now() + Duration::from_secs(*timeout_secs);
loop {
let v = baas
.work_container_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::Objects {
command: StorageCommands::Buckets { secret_url },
} => {
let baas = runtime::RuntimeClient::from_secret_url(secret_url.as_ref())?;
let v = baas
.objects_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 = runtime::RuntimeClient::from_secret_url(secret_url.as_ref())?;
let v = baas
.objects_list(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 = runtime::RuntimeClient::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
.objects_put(&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 = runtime::RuntimeClient::from_secret_url(secret_url.as_ref())?;
let bytes = baas
.objects_get_bytes(&file_id, bucket.as_deref())
.await
.map_err(|e| anyhow::anyhow!(e.to_string()))?;
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)?;
}
}
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::Previews { command } => match command { 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 } => {
delivery::releases(cli.json, &client, command).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,
tag,
org_id,
} => {
let org = resolve_org(org_id)?;
extra::device_sessions_logcat(
cli.json,
&client,
&session_id,
lines.as_ref().copied(),
tag.as_deref(),
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 } => {
delivery::run(cli.json, &client, command).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 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_apps_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 login_uses_identity_exchange(
identity_flag: bool,
has_token: bool,
device: bool,
localhost: bool,
auth_url: Option<&str>,
) -> bool {
if identity_flag {
return true;
}
if has_token || device || localhost {
return false;
}
!auth_url.map(str::trim).is_some_and(|u| !u.is_empty())
}
fn resolve_device_auth_base(explicit: Option<&str>, management_api_url: &str) -> String {
resolve_device_auth_base_from_env(explicit, management_api_url, |k| std::env::var(k).ok())
}
fn resolve_device_auth_base_from_env(
explicit: Option<&str>,
_management_api_url: &str,
mut env: impl FnMut(&str) -> Option<String>,
) -> 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 Some(u) = env(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();
}
}
}
String::new()
}
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 = if session.management_base_url.is_some() {
oauth_session::refresh_management(&http, &session).await?
} else {
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),
management_base_url: session.management_base_url,
});
credentials::save(&stored)?;
Ok(stored.token)
}
async fn run_identity_login(
cli: &Cli,
login_token: Option<String>,
client_id: Option<String>,
identity_auth_url: Option<String>,
login_org: Option<String>,
timeout_secs: u64,
no_verify: bool,
api_url: &str,
) -> anyhow::Result<()> {
let token_arg = login_token
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty())
.map(|t| t.to_string());
let http = oauth_session::http_client()?;
let login_config = oauth_session::fetch_cli_login_config(&http, api_url).await;
let client_id =
oauth_session::resolve_cli_client_id(client_id.as_deref(), login_config.as_ref());
let identity_base = oauth_session::identity_base_with_config(
identity_auth_url.as_deref(),
|k| std::env::var(k).ok(),
login_config.as_ref().map(|config| config.issuer.as_str()),
);
let started =
oauth_session::identity_device_start(&http, &identity_base, client_id.trim()).await?;
eprintln!();
eprintln!("┌─ Sylphx Cloud CLI login (Identity) ─────────────────────────");
eprintln!("│ Open this URL in your browser to approve this device:");
eprintln!("│");
if started.verification_uri_complete.is_empty() {
eprintln!("│ (no verification URL returned; user code below)");
} else {
eprintln!("│ {}", started.verification_uri_complete);
}
eprintln!("│ User code: {}", started.user_code);
eprintln!(
"│ Waiting for approval… (poll every {}s, timeout {timeout_secs}s)",
started.interval_secs
);
eprintln!("└────────────────────────────────────────────────────────────");
eprintln!();
if !started.verification_uri_complete.is_empty() {
let _ = open_browser_url(&started.verification_uri_complete);
}
let deadline =
std::time::Instant::now() + Duration::from_secs(timeout_secs.min(started.expires_in_secs));
let identity_access = loop {
if std::time::Instant::now() > deadline {
anyhow::bail!("device login timed out waiting for approval");
}
match oauth_session::identity_device_poll(&http, &identity_base, client_id.trim(), &started.device_code).await? {
oauth_session::IdentityDevicePoll::Done { access_token, .. } => break access_token,
oauth_session::IdentityDevicePoll::Pending => {
tokio::time::sleep(Duration::from_secs(started.interval_secs)).await;
}
oauth_session::IdentityDevicePoll::SlowDown => {
tokio::time::sleep(Duration::from_secs(started.interval_secs + 5)).await;
}
}
};
let exchanged = oauth_session::management_exchange(
&http,
api_url,
&identity_access,
login_org.as_deref(),
)
.await?;
let (token, source, oauth) = if let Some(t) = token_arg {
(t, "token".to_string(), None)
} else {
let now = oauth_session::now()?;
let session = credentials::OAuthSession {
issuer: identity_base.clone(),
token_endpoint: format!("{}/v1/oauth/token", identity_base.trim_end_matches('/')),
revocation_endpoint: format!("{}/v1/oauth/revoke", identity_base.trim_end_matches('/')),
client_id: client_id.trim().to_string(),
refresh_token: exchanged.refresh_token.clone(),
access_expires_at: Some(now.saturating_add(exchanged.expires_in)),
refresh_expires_at: exchanged.refresh_expires_at.clone(),
scope: exchanged.scope.clone(),
management_base_url: Some(api_url.to_string()),
};
(exchanged.access_token, "identity".to_string(), Some(session))
};
let mut whoami_body = Value::Null;
if !no_verify {
let client = ManagementClient::builder(token.clone())
.base_url(api_url.to_string())
.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.to_string()),
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(())
}
async fn run_login(
cli: &Cli,
login_token: Option<String>,
device: bool,
localhost: bool,
auth_url: Option<String>,
client_id: Option<String>,
identity: bool,
identity_auth_url: Option<String>,
login_org: Option<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 api_url = normalize_management_base_url(&api_url);
let token_arg = login_token
.as_deref()
.map(str::trim)
.filter(|t| !t.is_empty())
.map(|t| t.to_string());
if login_uses_identity_exchange(
identity,
token_arg.is_some(),
device,
localhost,
auth_url.as_deref(),
) {
return run_identity_login(cli, login_token, client_id, identity_auth_url, login_org, timeout_secs, no_verify, &api_url).await;
}
let client_id = client_id
.map(|id| id.trim().to_string())
.filter(|id| !id.is_empty())
.unwrap_or_else(|| oauth_session::DEFAULT_CLI_CLIENT_ID.to_string());
let auth_base = resolve_device_auth_base(auth_url.as_deref(), &api_url);
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_base = metadata
.authorization_endpoint
.clone()
.unwrap_or_else(|| {
format!(
"{}/v1/oauth/authorize",
metadata.issuer.trim_end_matches('/')
)
});
let authorize_url = format!(
"{}?response_type=code&client_id={}&redirect_uri={}&code_challenge={}&code_challenge_method=S256&scope={}&state={}",
authorize_base.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. \
Set --auth-url or SYLPHX_BAAS_URL / SYLPHX_AUTH_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_has_no_compiled_slug_default() {
let default =
resolve_device_auth_base_from_env(None, DEFAULT_MANAGEMENT_BASE_URL, |_| None);
assert!(default.is_empty());
assert_eq!(
resolve_device_auth_base_from_env(
Some("https://custom.example/v1"),
DEFAULT_MANAGEMENT_BASE_URL,
|_| None
),
"https://custom.example/v1"
);
assert_ne!(
resolve_device_auth_base_from_env(None, "https://api.sylphx.com/v1", |_| None),
"https://api.sylphx.com/v1"
);
}
#[test]
fn urlencoding_encoder_is_shared_by_oauth_flows() {
assert_eq!(urlencoding_encode("device code+/"), "device%20code%2B%2F");
}
#[test]
fn default_human_login_selects_identity_exchange() {
assert!(login_uses_identity_exchange(false, false, false, false, None));
assert!(login_uses_identity_exchange(false, false, false, false, Some("")));
assert!(login_uses_identity_exchange(false, false, false, false, Some(" ")));
assert!(login_uses_identity_exchange(true, false, false, false, None));
assert!(login_uses_identity_exchange(
true, true, true, true, Some("https://auth.example"),
));
}
#[test]
fn residual_baas_and_token_do_not_select_identity_exchange() {
assert!(!login_uses_identity_exchange(false, true, false, false, None));
assert!(!login_uses_identity_exchange(false, false, true, false, None));
assert!(!login_uses_identity_exchange(false, false, false, true, None));
assert!(!login_uses_identity_exchange(
false, false, false, false, Some("https://auth.example"),
));
assert!(!login_uses_identity_exchange(
false, false, false, false, Some(" https://auth.example "),
));
}
#[test]
fn login_client_id_is_an_optional_explicit_override() {
let cli = Cli::parse_from(["sylphx", "login"]);
let Commands::Login { client_id, .. } = &cli.command else {
panic!("login command expected");
};
assert_eq!(client_id, &None);
let cli = Cli::parse_from([
"sylphx",
"login",
"--client-id",
"identity_oauth_client_flag",
]);
let Commands::Login { client_id, .. } = &cli.command else {
panic!("login command expected");
};
assert_eq!(client_id.as_deref(), Some("identity_oauth_client_flag"));
}
}