Address the review findings on the CLI surface: * `pic login` now prompts for username + password and POSTs to `/api/v1/admin/auth/login`. `--token` (and `PICLOUD_TOKEN`) still works for paste-a-bearer flows (CI, long-lived API keys). Falls back to a plain stdin read when no controlling tty is attached. * `pic logout` revokes the session server-side and deletes the local credentials file. Idempotent. * `PICLOUD_URL` / `PICLOUD_TOKEN` now override the on-disk credentials file for every command via `config::resolve`, not just for `pic login`. Matches gcloud/aws/kubectl semantics. * New commands: `pic apps delete [--force]`, `pic apps show`, `pic scripts delete`, `pic api-keys mint|ls|rm`, plus top-level `pic invoke` / `pic deploy` shortcuts. * `pic scripts ls` (no `--app`) now issues a single `GET /admin/scripts` + one `apps_list` in parallel and joins client-side, instead of walking N+1 per-app calls that aborted on the first 404 — the bug the test suite was retrying around. * Global `--output tsv|json` flag wired through every list/show and through `whoami` / `logs`. TSV stays pipe-friendly; JSON is a real array of objects (or a flat object for single-row views). * `whoami` and `logs` now emit labeled output instead of headerless tab lines, consistent with the existing `apps ls` / `scripts ls`. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
85 lines
2.8 KiB
Rust
85 lines
2.8 KiB
Rust
//! `pic apps` subcommands: `ls`, `create`, `show`, `delete`.
|
|
|
|
use anyhow::Result;
|
|
use picloud_shared::AppRole;
|
|
|
|
use crate::client::{Client, CreateAppBody};
|
|
use crate::config;
|
|
use crate::output::{KvBlock, OutputMode, Table};
|
|
|
|
pub async fn ls(mode: OutputMode) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let apps = client.apps_list().await?;
|
|
let mut table = Table::new(["slug", "name", "my_role", "created_at"]);
|
|
for app in apps {
|
|
// The list endpoint returns App without my_role. We do a per-app
|
|
// lookup only on demand; for `ls` we leave the column dashed so
|
|
// the call stays cheap (one HTTP request).
|
|
table.row([
|
|
app.slug.clone(),
|
|
app.name.clone(),
|
|
"-".to_string(),
|
|
app.created_at.to_rfc3339(),
|
|
]);
|
|
}
|
|
table.print(mode);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn create(slug: &str, name: Option<&str>, description: Option<&str>) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let body = CreateAppBody {
|
|
slug,
|
|
name: name.unwrap_or(slug),
|
|
description,
|
|
};
|
|
let app = client.apps_create(&body).await?;
|
|
println!("Created app {}", app.slug);
|
|
Ok(())
|
|
}
|
|
|
|
/// `pic apps show <slug>` — single-app inspect using the lookup
|
|
/// endpoint, which carries `my_role` for the caller (the `ls` endpoint
|
|
/// doesn't).
|
|
pub async fn show(ident: &str, mode: OutputMode) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
let lookup = client.apps_get(ident).await?;
|
|
let mut block = KvBlock::new();
|
|
block
|
|
.field("id", lookup.app.id.to_string())
|
|
.field("slug", lookup.app.slug.clone())
|
|
.field("name", lookup.app.name.clone())
|
|
.field(
|
|
"description",
|
|
lookup.app.description.clone().unwrap_or_else(|| "-".into()),
|
|
)
|
|
.field("my_role", role_label(lookup.my_role.as_ref()))
|
|
.field("created_at", lookup.app.created_at.to_rfc3339())
|
|
.field("updated_at", lookup.app.updated_at.to_rfc3339());
|
|
block.print(mode);
|
|
Ok(())
|
|
}
|
|
|
|
/// `pic apps delete <slug> [--force]`. Without `--force` the server
|
|
/// returns 409 if the app still owns scripts — surface that as a
|
|
/// useful error rather than swallowing.
|
|
pub async fn delete(ident: &str, force: bool) -> Result<()> {
|
|
let creds = config::resolve()?;
|
|
let client = Client::from_creds(&creds)?;
|
|
client.apps_delete(ident, force).await?;
|
|
println!("Deleted app {ident}");
|
|
Ok(())
|
|
}
|
|
|
|
fn role_label(role: Option<&AppRole>) -> String {
|
|
// Use the wire form so the CLI label matches what the dashboard
|
|
// shows and what the membership APIs accept.
|
|
match role {
|
|
Some(r) => r.as_str().to_string(),
|
|
None => "-".into(),
|
|
}
|
|
}
|