Files
cli/sunbeam-sdk/src/lasuite/cli.rs
Sienna Meridian Satterwhite ca0748b109 feat: encrypted vault keystore, JWT auth, Drive upload
Vault keystore (vault_keystore.rs):
- AES-256-GCM encrypted local storage for root tokens + unseal keys
- Argon2id KDF with machine-specific salt, 0600 permissions
- save/load/verify/export API with 26 unit tests
- Integrated into seed flow: save after init, load as fallback,
  backfill from cluster, restore K8s Secret if wiped

Vault CLI:
- vault reinit: wipe and re-initialize vault with confirmation
- vault keys: show local keystore status
- vault export-keys: plaintext export for machine migration
- vault status: now shows keystore status + uses JWT auth
- Fixed seal_status() bypassing request() (missing auth headers)

Vault OIDC auth:
- JWT auth method enabled on OpenBao via seed script
- cli-admin role: full access for users with admin:true JWT claim
- cli-reader role: read-only for non-admin SSO users
- BaoClient.with_proxy_auth(): sends both Bearer (proxy) and
  X-Vault-Token (vault) headers
- SunbeamClient.bao() authenticates via JWT login, falls back
  to local keystore root token

Drive:
- SDK client uses /items/ endpoint (was /files/ and /folders/)
- Added create_child, upload_ended, upload_to_s3 methods
- Added recursive drive upload command (--path, --folder-id)
- Switched all La Suite clients to /external_api/v1.0/

Infrastructure:
- Removed openbao-keys-placeholder.yaml from kustomization
- Added sunbeam.dev/managed-by label to programmatic secrets
- kv_patch→kv_put fallback for fresh vault initialization
- Hydra/Kratos secrets combined (new,old) for key rotation
2026-03-24 12:09:01 +00:00

1242 lines
39 KiB
Rust

//! CLI command definitions and dispatch for all 7 La Suite services.
use clap::Subcommand;
use crate::client::SunbeamClient;
use crate::error::Result;
use crate::output::{self, OutputFormat};
// ═══════════════════════════════════════════════════════════════════════════
// People
// ═══════════════════════════════════════════════════════════════════════════
#[derive(Subcommand, Debug)]
pub enum PeopleCommand {
/// Contact management.
Contact {
#[command(subcommand)]
action: ContactAction,
},
/// Team management.
Team {
#[command(subcommand)]
action: TeamAction,
},
/// Service provider listing.
ServiceProvider {
#[command(subcommand)]
action: ServiceProviderAction,
},
/// Mail domain listing.
MailDomain {
#[command(subcommand)]
action: MailDomainAction,
},
}
#[derive(Subcommand, Debug)]
pub enum ContactAction {
/// List contacts.
List {
#[arg(long)]
page: Option<u32>,
},
/// Get a contact by ID.
Get {
#[arg(short, long)]
id: String,
},
/// Create a contact from JSON.
Create {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Update a contact from JSON.
Update {
#[arg(short, long)]
id: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Delete a contact.
Delete {
#[arg(short, long)]
id: String,
},
}
#[derive(Subcommand, Debug)]
pub enum TeamAction {
/// List teams.
List {
#[arg(long)]
page: Option<u32>,
},
/// Get a team by ID.
Get {
#[arg(short, long)]
id: String,
},
/// Create a team from JSON.
Create {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum ServiceProviderAction {
/// List service providers.
List,
}
#[derive(Subcommand, Debug)]
pub enum MailDomainAction {
/// List mail domains.
List,
}
pub async fn dispatch_people(
cmd: PeopleCommand,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let people = client.people().await?;
match cmd {
PeopleCommand::Contact { action } => match action {
ContactAction::List { page } => {
let page_data = people.list_contacts(page).await?;
output::render_list(
&page_data.results,
&["ID", "NAME", "EMAIL", "ORGANIZATION"],
|c| {
vec![
c.id.clone(),
format!(
"{} {}",
c.first_name.as_deref().unwrap_or(""),
c.last_name.as_deref().unwrap_or("")
)
.trim()
.to_string(),
c.email.clone().unwrap_or_default(),
c.organization.clone().unwrap_or_default(),
]
},
fmt,
)
}
ContactAction::Get { id } => {
let item = people.get_contact(&id).await?;
output::render(&item, fmt)
}
ContactAction::Create { data } => {
let json = output::read_json_input(data.as_deref())?;
let item = people.create_contact(&json).await?;
output::render(&item, fmt)
}
ContactAction::Update { id, data } => {
let json = output::read_json_input(data.as_deref())?;
let item = people.update_contact(&id, &json).await?;
output::render(&item, fmt)
}
ContactAction::Delete { id } => {
people.delete_contact(&id).await?;
output::ok(&format!("Deleted contact {id}"));
Ok(())
}
},
PeopleCommand::Team { action } => match action {
TeamAction::List { page } => {
let page_data = people.list_teams(page).await?;
output::render_list(
&page_data.results,
&["ID", "NAME", "DESCRIPTION"],
|t| {
vec![
t.id.clone(),
t.name.clone().unwrap_or_default(),
t.description.clone().unwrap_or_default(),
]
},
fmt,
)
}
TeamAction::Get { id } => {
let item = people.get_team(&id).await?;
output::render(&item, fmt)
}
TeamAction::Create { data } => {
let json = output::read_json_input(data.as_deref())?;
let item = people.create_team(&json).await?;
output::render(&item, fmt)
}
},
PeopleCommand::ServiceProvider { action } => match action {
ServiceProviderAction::List => {
let page_data = people.list_service_providers().await?;
output::render_list(
&page_data.results,
&["ID", "NAME", "BASE_URL"],
|sp| {
vec![
sp.id.clone(),
sp.name.clone().unwrap_or_default(),
sp.base_url.clone().unwrap_or_default(),
]
},
fmt,
)
}
},
PeopleCommand::MailDomain { action } => match action {
MailDomainAction::List => {
let page_data = people.list_mail_domains().await?;
output::render_list(
&page_data.results,
&["ID", "NAME", "STATUS"],
|md| {
vec![
md.id.clone(),
md.name.clone().unwrap_or_default(),
md.status.clone().unwrap_or_default(),
]
},
fmt,
)
}
},
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Docs
// ═══════════════════════════════════════════════════════════════════════════
#[derive(Subcommand, Debug)]
pub enum DocsCommand {
/// Document management.
Document {
#[command(subcommand)]
action: DocumentAction,
},
/// Template management.
Template {
#[command(subcommand)]
action: TemplateAction,
},
/// Version history.
Version {
#[command(subcommand)]
action: VersionAction,
},
/// Invite a user to a document.
Invite {
/// Document ID.
#[arg(short, long)]
id: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum DocumentAction {
/// List documents.
List {
#[arg(long)]
page: Option<u32>,
},
/// Get a document by ID.
Get {
#[arg(short, long)]
id: String,
},
/// Create a document from JSON.
Create {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Update a document from JSON.
Update {
#[arg(short, long)]
id: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Delete a document.
Delete {
#[arg(short, long)]
id: String,
},
}
#[derive(Subcommand, Debug)]
pub enum TemplateAction {
/// List templates.
List {
#[arg(long)]
page: Option<u32>,
},
/// Create a template from JSON.
Create {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum VersionAction {
/// List versions of a document.
List {
/// Document ID.
#[arg(short, long)]
id: String,
},
}
pub async fn dispatch_docs(
cmd: DocsCommand,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let docs = client.docs().await?;
match cmd {
DocsCommand::Document { action } => match action {
DocumentAction::List { page } => {
let page_data = docs.list_documents(page).await?;
output::render_list(
&page_data.results,
&["ID", "TITLE", "PUBLIC", "UPDATED"],
|d| {
vec![
d.id.clone(),
d.title.clone().unwrap_or_default(),
d.is_public.map_or("-".into(), |p| p.to_string()),
d.updated_at.clone().unwrap_or_default(),
]
},
fmt,
)
}
DocumentAction::Get { id } => {
let item = docs.get_document(&id).await?;
output::render(&item, fmt)
}
DocumentAction::Create { data } => {
let json = output::read_json_input(data.as_deref())?;
let item = docs.create_document(&json).await?;
output::render(&item, fmt)
}
DocumentAction::Update { id, data } => {
let json = output::read_json_input(data.as_deref())?;
let item = docs.update_document(&id, &json).await?;
output::render(&item, fmt)
}
DocumentAction::Delete { id } => {
docs.delete_document(&id).await?;
output::ok(&format!("Deleted document {id}"));
Ok(())
}
},
DocsCommand::Template { action } => match action {
TemplateAction::List { page } => {
let page_data = docs.list_templates(page).await?;
output::render_list(
&page_data.results,
&["ID", "TITLE", "PUBLIC"],
|t| {
vec![
t.id.clone(),
t.title.clone().unwrap_or_default(),
t.is_public.map_or("-".into(), |p| p.to_string()),
]
},
fmt,
)
}
TemplateAction::Create { data } => {
let json = output::read_json_input(data.as_deref())?;
let item = docs.create_template(&json).await?;
output::render(&item, fmt)
}
},
DocsCommand::Version { action } => match action {
VersionAction::List { id } => {
let page_data = docs.list_versions(&id).await?;
output::render_list(
&page_data.results,
&["ID", "VERSION", "CREATED"],
|v| {
vec![
v.id.clone(),
v.version_number.map_or("-".into(), |n| n.to_string()),
v.created_at.clone().unwrap_or_default(),
]
},
fmt,
)
}
},
DocsCommand::Invite { id, data } => {
let json = output::read_json_input(data.as_deref())?;
let item = docs.invite_user(&id, &json).await?;
output::render(&item, fmt)
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Meet
// ═══════════════════════════════════════════════════════════════════════════
#[derive(Subcommand, Debug)]
pub enum MeetCommand {
/// Room management.
Room {
#[command(subcommand)]
action: RoomAction,
},
/// Recording management.
Recording {
#[command(subcommand)]
action: RecordingAction,
},
}
#[derive(Subcommand, Debug)]
pub enum RoomAction {
/// List rooms.
List {
#[arg(long)]
page: Option<u32>,
},
/// Get a room by ID.
Get {
#[arg(short, long)]
id: String,
},
/// Create a room from JSON.
Create {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Update a room from JSON.
Update {
#[arg(short, long)]
id: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Delete a room.
Delete {
#[arg(short, long)]
id: String,
},
}
#[derive(Subcommand, Debug)]
pub enum RecordingAction {
/// List recordings for a room.
List {
/// Room ID.
#[arg(short, long)]
id: String,
},
}
pub async fn dispatch_meet(
cmd: MeetCommand,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let meet = client.meet().await?;
match cmd {
MeetCommand::Room { action } => match action {
RoomAction::List { page } => {
let page_data = meet.list_rooms(page).await?;
output::render_list(
&page_data.results,
&["ID", "NAME", "SLUG", "PUBLIC"],
|r| {
vec![
r.id.clone(),
r.name.clone().unwrap_or_default(),
r.slug.clone().unwrap_or_default(),
r.is_public.map_or("-".into(), |p| p.to_string()),
]
},
fmt,
)
}
RoomAction::Get { id } => {
let item = meet.get_room(&id).await?;
output::render(&item, fmt)
}
RoomAction::Create { data } => {
let json = output::read_json_input(data.as_deref())?;
let item = meet.create_room(&json).await?;
output::render(&item, fmt)
}
RoomAction::Update { id, data } => {
let json = output::read_json_input(data.as_deref())?;
let item = meet.update_room(&id, &json).await?;
output::render(&item, fmt)
}
RoomAction::Delete { id } => {
meet.delete_room(&id).await?;
output::ok(&format!("Deleted room {id}"));
Ok(())
}
},
MeetCommand::Recording { action } => match action {
RecordingAction::List { id } => {
let page_data = meet.list_recordings(&id).await?;
output::render_list(
&page_data.results,
&["ID", "FILENAME", "DURATION", "CREATED"],
|r| {
vec![
r.id.clone(),
r.filename.clone().unwrap_or_default(),
r.duration.map_or("-".into(), |d| format!("{d:.1}s")),
r.created_at.clone().unwrap_or_default(),
]
},
fmt,
)
}
},
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Drive
// ═══════════════════════════════════════════════════════════════════════════
#[derive(Subcommand, Debug)]
pub enum DriveCommand {
/// File management.
File {
#[command(subcommand)]
action: FileAction,
},
/// Folder management.
Folder {
#[command(subcommand)]
action: FolderAction,
},
/// Share a file with a user.
Share {
/// File ID.
#[arg(short, long)]
id: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Permission management.
Permission {
#[command(subcommand)]
action: PermissionAction,
},
/// Upload a local file or directory to a Drive folder.
Upload {
/// Local path to upload (file or directory).
#[arg(short, long)]
path: String,
/// Target Drive folder ID.
#[arg(short = 't', long)]
folder_id: String,
},
}
#[derive(Subcommand, Debug)]
pub enum FileAction {
/// List files.
List {
#[arg(long)]
page: Option<u32>,
},
/// Get a file by ID.
Get {
#[arg(short, long)]
id: String,
},
/// Upload a file (JSON metadata).
Upload {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Delete a file.
Delete {
#[arg(short, long)]
id: String,
},
}
#[derive(Subcommand, Debug)]
pub enum FolderAction {
/// List folders.
List {
#[arg(long)]
page: Option<u32>,
},
/// Create a folder from JSON.
Create {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum PermissionAction {
/// List permissions for a file.
List {
/// File ID.
#[arg(short, long)]
id: String,
},
}
pub async fn dispatch_drive(
cmd: DriveCommand,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let drive = client.drive().await?;
match cmd {
DriveCommand::File { action } => match action {
FileAction::List { page } => {
let page_data = drive.list_files(page).await?;
output::render_list(
&page_data.results,
&["ID", "NAME", "SIZE", "MIME_TYPE"],
|f| {
vec![
f.id.clone(),
f.name.clone().unwrap_or_default(),
f.size.map_or("-".into(), |s| s.to_string()),
f.mime_type.clone().unwrap_or_default(),
]
},
fmt,
)
}
FileAction::Get { id } => {
let item = drive.get_file(&id).await?;
output::render(&item, fmt)
}
FileAction::Upload { data } => {
let json = output::read_json_input(data.as_deref())?;
let item = drive.upload_file(&json).await?;
output::render(&item, fmt)
}
FileAction::Delete { id } => {
drive.delete_file(&id).await?;
output::ok(&format!("Deleted file {id}"));
Ok(())
}
},
DriveCommand::Folder { action } => match action {
FolderAction::List { page } => {
let page_data = drive.list_folders(page).await?;
output::render_list(
&page_data.results,
&["ID", "NAME", "PARENT_ID"],
|f| {
vec![
f.id.clone(),
f.name.clone().unwrap_or_default(),
f.parent_id.clone().unwrap_or_default(),
]
},
fmt,
)
}
FolderAction::Create { data } => {
let json = output::read_json_input(data.as_deref())?;
let item = drive.create_folder(&json).await?;
output::render(&item, fmt)
}
},
DriveCommand::Share { id, data } => {
let json = output::read_json_input(data.as_deref())?;
let item = drive.share_file(&id, &json).await?;
output::render(&item, fmt)
}
DriveCommand::Permission { action } => match action {
PermissionAction::List { id } => {
let page_data = drive.get_permissions(&id).await?;
output::render_list(
&page_data.results,
&["ID", "USER_ID", "ROLE", "READ", "WRITE"],
|p| {
vec![
p.id.clone(),
p.user_id.clone().unwrap_or_default(),
p.role.clone().unwrap_or_default(),
p.can_read.map_or("-".into(), |v| v.to_string()),
p.can_write.map_or("-".into(), |v| v.to_string()),
]
},
fmt,
)
}
},
DriveCommand::Upload { path, folder_id } => {
upload_recursive(drive, &path, &folder_id).await
}
}
}
/// Recursively upload a local file or directory to a Drive folder.
async fn upload_recursive(
drive: &super::DriveClient,
local_path: &str,
parent_id: &str,
) -> Result<()> {
let path = std::path::Path::new(local_path);
if !path.exists() {
return Err(crate::error::SunbeamError::Other(format!(
"Path does not exist: {local_path}"
)));
}
if path.is_file() {
upload_single_file(drive, path, parent_id).await
} else if path.is_dir() {
upload_directory(drive, path, parent_id).await
} else {
Err(crate::error::SunbeamError::Other(format!(
"Not a file or directory: {local_path}"
)))
}
}
async fn upload_directory(
drive: &super::DriveClient,
dir: &std::path::Path,
parent_id: &str,
) -> Result<()> {
let dir_name = dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unnamed");
output::step(&format!("Creating folder: {dir_name}"));
// Create the folder in Drive
let folder = drive
.create_child(
parent_id,
&serde_json::json!({
"title": dir_name,
"type": "folder",
}),
)
.await?;
let folder_id = folder["id"]
.as_str()
.ok_or_else(|| crate::error::SunbeamError::Other("No folder ID in response".into()))?;
// Process entries
let mut entries: Vec<_> = std::fs::read_dir(dir)
.map_err(|e| crate::error::SunbeamError::Other(format!("reading dir: {e}")))?
.filter_map(|e| e.ok())
.collect();
entries.sort_by_key(|e| e.file_name());
for entry in entries {
let entry_path = entry.path();
if entry_path.is_dir() {
Box::pin(upload_directory(drive, &entry_path, folder_id)).await?;
} else if entry_path.is_file() {
upload_single_file(drive, &entry_path, folder_id).await?;
}
}
Ok(())
}
async fn upload_single_file(
drive: &super::DriveClient,
file_path: &std::path::Path,
parent_id: &str,
) -> Result<()> {
let filename = file_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unnamed");
// Skip hidden files
if filename.starts_with('.') {
return Ok(());
}
output::ok(&format!("Uploading: {filename}"));
// Create the file item in Drive
let item = drive
.create_child(
parent_id,
&serde_json::json!({
"title": filename,
"type": "file",
}),
)
.await?;
let item_id = item["id"]
.as_str()
.ok_or_else(|| crate::error::SunbeamError::Other("No item ID in response".into()))?;
// Get the presigned upload URL (Drive returns it as "policy" on create)
let upload_url = item["policy"]
.as_str()
.ok_or_else(|| crate::error::SunbeamError::Other("No upload policy URL in response — is the item a file?".into()))?;
// Read the file and upload to S3
let data = std::fs::read(file_path)
.map_err(|e| crate::error::SunbeamError::Other(format!("reading file: {e}")))?;
drive
.upload_to_s3(upload_url, bytes::Bytes::from(data))
.await?;
// Notify Drive the upload is complete
drive.upload_ended(item_id).await?;
Ok(())
}
// ═══════════════════════════════════════════════════════════════════════════
// Mail (Messages)
// ═══════════════════════════════════════════════════════════════════════════
#[derive(Subcommand, Debug)]
pub enum MailCommand {
/// Mailbox management.
Mailbox {
#[command(subcommand)]
action: MailboxAction,
},
/// Message management.
Message {
#[command(subcommand)]
action: MessageAction,
},
/// Folder listing.
Folder {
#[command(subcommand)]
action: MailFolderAction,
},
/// Contact listing.
Contact {
#[command(subcommand)]
action: MailContactAction,
},
}
#[derive(Subcommand, Debug)]
pub enum MailboxAction {
/// List mailboxes.
List,
/// Get a mailbox by ID.
Get {
#[arg(short, long)]
id: String,
},
}
#[derive(Subcommand, Debug)]
pub enum MessageAction {
/// List messages in a mailbox folder.
List {
/// Mailbox ID.
#[arg(short, long)]
id: String,
/// Folder name (e.g. "inbox").
#[arg(short, long, default_value = "inbox")]
folder: String,
},
/// Get a message.
Get {
/// Mailbox ID.
#[arg(short, long)]
id: String,
/// Message ID.
#[arg(short, long)]
message_id: String,
},
/// Send a message from a mailbox.
Send {
/// Mailbox ID.
#[arg(short, long)]
id: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum MailFolderAction {
/// List folders in a mailbox.
List {
/// Mailbox ID.
#[arg(short, long)]
id: String,
},
}
#[derive(Subcommand, Debug)]
pub enum MailContactAction {
/// List contacts in a mailbox.
List {
/// Mailbox ID.
#[arg(short, long)]
id: String,
},
}
pub async fn dispatch_mail(
cmd: MailCommand,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let mail = client.messages().await?;
match cmd {
MailCommand::Mailbox { action } => match action {
MailboxAction::List => {
let page_data = mail.list_mailboxes().await?;
output::render_list(
&page_data.results,
&["ID", "EMAIL", "DISPLAY_NAME"],
|m| {
vec![
m.id.clone(),
m.email.clone().unwrap_or_default(),
m.display_name.clone().unwrap_or_default(),
]
},
fmt,
)
}
MailboxAction::Get { id } => {
let item = mail.get_mailbox(&id).await?;
output::render(&item, fmt)
}
},
MailCommand::Message { action } => match action {
MessageAction::List { id, folder } => {
let page_data = mail.list_messages(&id, &folder).await?;
output::render_list(
&page_data.results,
&["ID", "SUBJECT", "FROM", "READ", "CREATED"],
|m| {
vec![
m.id.clone(),
m.subject.clone().unwrap_or_default(),
m.from_address.clone().unwrap_or_default(),
m.is_read.map_or("-".into(), |r| r.to_string()),
m.created_at.clone().unwrap_or_default(),
]
},
fmt,
)
}
MessageAction::Get { id, message_id } => {
let item = mail.get_message(&id, &message_id).await?;
output::render(&item, fmt)
}
MessageAction::Send { id, data } => {
let json = output::read_json_input(data.as_deref())?;
let item = mail.send_message(&id, &json).await?;
output::render(&item, fmt)
}
},
MailCommand::Folder { action } => match action {
MailFolderAction::List { id } => {
let page_data = mail.list_folders(&id).await?;
output::render_list(
&page_data.results,
&["ID", "NAME", "MESSAGES", "UNREAD"],
|f| {
vec![
f.id.clone(),
f.name.clone().unwrap_or_default(),
f.message_count.map_or("-".into(), |c| c.to_string()),
f.unread_count.map_or("-".into(), |c| c.to_string()),
]
},
fmt,
)
}
},
MailCommand::Contact { action } => match action {
MailContactAction::List { id } => {
let page_data = mail.list_contacts(&id).await?;
output::render_list(
&page_data.results,
&["ID", "EMAIL", "DISPLAY_NAME"],
|c| {
vec![
c.id.clone(),
c.email.clone().unwrap_or_default(),
c.display_name.clone().unwrap_or_default(),
]
},
fmt,
)
}
},
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Calendars
// ═══════════════════════════════════════════════════════════════════════════
#[derive(Subcommand, Debug)]
pub enum CalCommand {
/// Calendar management.
Calendar {
#[command(subcommand)]
action: CalendarAction,
},
/// Event management.
Event {
#[command(subcommand)]
action: EventAction,
},
/// RSVP to an event.
Rsvp {
/// Calendar ID.
#[arg(short = 'c', long)]
calendar_id: String,
/// Event ID.
#[arg(short = 'e', long)]
event_id: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum CalendarAction {
/// List calendars.
List,
/// Get a calendar by ID.
Get {
#[arg(short, long)]
id: String,
},
/// Create a calendar from JSON.
Create {
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
}
#[derive(Subcommand, Debug)]
pub enum EventAction {
/// List events in a calendar.
List {
/// Calendar ID.
#[arg(short = 'c', long)]
calendar_id: String,
},
/// Get an event.
Get {
/// Calendar ID.
#[arg(short = 'c', long)]
calendar_id: String,
/// Event ID.
#[arg(short = 'e', long)]
event_id: String,
},
/// Create an event from JSON.
Create {
/// Calendar ID.
#[arg(short = 'c', long)]
calendar_id: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Update an event from JSON.
Update {
/// Calendar ID.
#[arg(short = 'c', long)]
calendar_id: String,
/// Event ID.
#[arg(short = 'e', long)]
event_id: String,
/// JSON body (or "-" to read from stdin).
#[arg(short, long)]
data: Option<String>,
},
/// Delete an event.
Delete {
/// Calendar ID.
#[arg(short = 'c', long)]
calendar_id: String,
/// Event ID.
#[arg(short = 'e', long)]
event_id: String,
},
}
pub async fn dispatch_cal(
cmd: CalCommand,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let cal = client.calendars().await?;
match cmd {
CalCommand::Calendar { action } => match action {
CalendarAction::List => {
let page_data = cal.list_calendars().await?;
output::render_list(
&page_data.results,
&["ID", "NAME", "COLOR", "DEFAULT"],
|c| {
vec![
c.id.clone(),
c.name.clone().unwrap_or_default(),
c.color.clone().unwrap_or_default(),
c.is_default.map_or("-".into(), |d| d.to_string()),
]
},
fmt,
)
}
CalendarAction::Get { id } => {
let item = cal.get_calendar(&id).await?;
output::render(&item, fmt)
}
CalendarAction::Create { data } => {
let json = output::read_json_input(data.as_deref())?;
let item = cal.create_calendar(&json).await?;
output::render(&item, fmt)
}
},
CalCommand::Event { action } => match action {
EventAction::List { calendar_id } => {
let page_data = cal.list_events(&calendar_id).await?;
output::render_list(
&page_data.results,
&["ID", "TITLE", "START", "END", "ALL_DAY"],
|e| {
vec![
e.id.clone(),
e.title.clone().unwrap_or_default(),
e.start.clone().unwrap_or_default(),
e.end.clone().unwrap_or_default(),
e.all_day.map_or("-".into(), |a| a.to_string()),
]
},
fmt,
)
}
EventAction::Get {
calendar_id,
event_id,
} => {
let item = cal.get_event(&calendar_id, &event_id).await?;
output::render(&item, fmt)
}
EventAction::Create { calendar_id, data } => {
let json = output::read_json_input(data.as_deref())?;
let item = cal.create_event(&calendar_id, &json).await?;
output::render(&item, fmt)
}
EventAction::Update {
calendar_id,
event_id,
data,
} => {
let json = output::read_json_input(data.as_deref())?;
let item = cal.update_event(&calendar_id, &event_id, &json).await?;
output::render(&item, fmt)
}
EventAction::Delete {
calendar_id,
event_id,
} => {
cal.delete_event(&calendar_id, &event_id).await?;
output::ok(&format!("Deleted event {event_id}"));
Ok(())
}
},
CalCommand::Rsvp {
calendar_id,
event_id,
data,
} => {
let json = output::read_json_input(data.as_deref())?;
cal.rsvp(&calendar_id, &event_id, &json).await?;
output::ok(&format!("RSVP sent for event {event_id}"));
Ok(())
}
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Find
// ═══════════════════════════════════════════════════════════════════════════
#[derive(Subcommand, Debug)]
pub enum FindCommand {
/// Search across La Suite services.
Search {
/// Search query.
#[arg(short, long)]
query: String,
#[arg(long)]
page: Option<u32>,
},
}
pub async fn dispatch_find(
cmd: FindCommand,
client: &SunbeamClient,
fmt: OutputFormat,
) -> Result<()> {
let find = client.find().await?;
match cmd {
FindCommand::Search { query, page } => {
let page_data = find.search(&query, page).await?;
output::render_list(
&page_data.results,
&["ID", "TITLE", "SOURCE", "SCORE", "URL"],
|r| {
vec![
r.id.clone(),
r.title.clone().unwrap_or_default(),
r.source.clone().unwrap_or_default(),
r.score.map_or("-".into(), |s| format!("{s:.2}")),
r.url.clone().unwrap_or_default(),
]
},
fmt,
)
}
}
}