use std::process::ExitCode;
use anyhow::{Context, Result};
use astrid_core::kernel_api::{AdminRequestKind, AdminResponseBody, PairScopeArg};
use astrid_core::profile::DeviceScope;
use clap::{Args, Subcommand};
use colored::Colorize;
use crate::admin_client::{connect_as_active_agent, into_result};
use crate::context;
use crate::theme::Theme;
#[derive(Subcommand, Debug, Clone)]
pub(crate) enum PairDeviceCommand {
Issue(IssueArgs),
List(ListArgs),
Revoke(RevokeArgs),
}
#[derive(Args, Debug, Clone)]
pub(crate) struct IssueArgs {
#[arg(long, conflicts_with_all = ["allow", "deny"])]
pub scope: Option<String>,
#[arg(long, conflicts_with = "scope")]
pub allow: Vec<String>,
#[arg(long, conflicts_with = "scope")]
pub deny: Vec<String>,
#[arg(long)]
pub label: Option<String>,
#[arg(long)]
pub expires_secs: Option<u64>,
#[arg(long)]
pub raw: bool,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct ListArgs {
#[arg(long = "agent")]
pub principal: Option<String>,
#[arg(long)]
pub json: bool,
}
#[derive(Args, Debug, Clone)]
pub(crate) struct RevokeArgs {
pub key_id: String,
#[arg(long = "agent")]
pub principal: Option<String>,
}
pub(crate) async fn run(command: PairDeviceCommand) -> Result<ExitCode> {
match command {
PairDeviceCommand::Issue(args) => run_issue(args).await,
PairDeviceCommand::List(args) => run_list(args).await,
PairDeviceCommand::Revoke(args) => run_revoke(args).await,
}
}
fn scope_arg(args: &IssueArgs) -> PairScopeArg {
if !args.allow.is_empty() || !args.deny.is_empty() {
return PairScopeArg::Explicit {
allow: args.allow.clone(),
deny: args.deny.clone(),
};
}
match args.scope.as_deref() {
None | Some("full") => PairScopeArg::Full,
Some(name) => PairScopeArg::Preset {
name: name.to_string(),
},
}
}
async fn run_issue(args: IssueArgs) -> Result<ExitCode> {
let scope = scope_arg(&args);
let scope_summary = describe_scope_arg(&scope);
let mut client = connect_as_active_agent().await?;
let resp = client
.request(AdminRequestKind::PairDeviceIssue {
expires_secs: args.expires_secs,
label: args.label,
scope,
})
.await
.context("auth.pair.issue request failed")?;
let body = into_result(resp)?;
match body {
AdminResponseBody::PairToken(issued) => {
if args.raw {
println!("{}", issued.token);
} else {
println!(
"{} {} (principal: {}, scope: {}, label: {})",
Theme::success("issued"),
issued.token.bold(),
issued.principal,
scope_summary,
issued.label.as_deref().unwrap_or("-"),
);
println!("expires at unix epoch {}", issued.expires_at_epoch);
}
Ok(ExitCode::SUCCESS)
},
other => anyhow::bail!("unexpected response shape: {other:?}"),
}
}
async fn run_list(args: ListArgs) -> Result<ExitCode> {
let principal = context::resolve_agent(args.principal.as_deref())?;
let mut client = connect_as_active_agent().await?;
let resp = client
.request(AdminRequestKind::PairDeviceList { principal })
.await
.context("auth.pair.list request failed")?;
let body = into_result(resp)?;
match body {
AdminResponseBody::PairDeviceListed(devices) => {
if args.json {
println!("{}", serde_json::to_string_pretty(&devices)?);
} else if devices.is_empty() {
println!("{}", Theme::dimmed("no paired devices"));
} else {
println!(
"{:<16} {:<20} {:<12} LABEL",
"KEY_ID", "SCOPE", "CREATED",
);
for d in devices {
println!(
"{:<16} {:<20} {:<12} {}",
d.key_id,
describe_scope(&d.scope),
d.created_at,
d.label.as_deref().unwrap_or("-"),
);
}
}
Ok(ExitCode::SUCCESS)
},
other => anyhow::bail!("unexpected response shape: {other:?}"),
}
}
async fn run_revoke(args: RevokeArgs) -> Result<ExitCode> {
let principal = context::resolve_agent(args.principal.as_deref())?;
let mut client = connect_as_active_agent().await?;
let resp = client
.request(AdminRequestKind::PairDeviceRevoke {
principal,
key_id: args.key_id,
})
.await
.context("auth.pair.revoke request failed")?;
let body = into_result(resp)?;
match body {
AdminResponseBody::PairDeviceRevoked { key_id } => {
println!("{} device {}", Theme::success("revoked"), key_id.bold());
Ok(ExitCode::SUCCESS)
},
other => anyhow::bail!("unexpected response shape: {other:?}"),
}
}
fn describe_scope_arg(arg: &PairScopeArg) -> String {
match arg {
PairScopeArg::Full => "full".to_string(),
PairScopeArg::Preset { name } => name.clone(),
PairScopeArg::Explicit { allow, deny } => {
format!("allow={} deny={}", join_or_dash(allow), join_or_dash(deny))
},
}
}
fn describe_scope(scope: &DeviceScope) -> String {
match scope {
DeviceScope::Full => "full".to_string(),
DeviceScope::Scoped { allow, deny } => {
format!("allow={} deny={}", join_or_dash(allow), join_or_dash(deny))
},
}
}
fn join_or_dash(patterns: &[String]) -> String {
if patterns.is_empty() {
"-".to_string()
} else {
patterns.join(",")
}
}