feat(cli): real auth, delete commands, api-keys, JSON output, env override
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>
This commit is contained in:
201
crates/picloud-cli/src/cmds/api_keys.rs
Normal file
201
crates/picloud-cli/src/cmds/api_keys.rs
Normal file
@@ -0,0 +1,201 @@
|
||||
//! `pic api-keys` — long-lived bearer-key management.
|
||||
//!
|
||||
//! Server semantics (mirrored from `manager-core/src/api_keys_api.rs`):
|
||||
//! * `raw_token` is returned **once** on mint and never again.
|
||||
//! * `app_id` (optional `--app`) binds the key to one app; instance
|
||||
//! scopes (`instance:*`) are rejected when `--app` is also set.
|
||||
//! * `scopes` is a `text[]` in the wire form (`script:read`, …).
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use picloud_shared::Scope;
|
||||
|
||||
use crate::client::{Client, MintApiKeyBody};
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
pub async fn mint(
|
||||
name: &str,
|
||||
scope_strs: &[String],
|
||||
app_ident: Option<&str>,
|
||||
expires: Option<&str>,
|
||||
mode: OutputMode,
|
||||
) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let scopes = parse_scopes(scope_strs)?;
|
||||
let expires_at = expires.map(parse_expires).transpose()?;
|
||||
let app_id = match app_ident {
|
||||
Some(ident) => Some(client.apps_get(ident).await?.app.id),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let body = MintApiKeyBody {
|
||||
name,
|
||||
scopes: &scopes,
|
||||
app_id,
|
||||
expires_at,
|
||||
};
|
||||
let resp = client.apikeys_mint(&body).await?;
|
||||
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("id", resp.key.id.to_string())
|
||||
.field("name", resp.key.name.clone())
|
||||
.field("prefix", resp.key.prefix.clone())
|
||||
.field(
|
||||
"scopes",
|
||||
resp.key
|
||||
.scopes
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
)
|
||||
.field(
|
||||
"app_id",
|
||||
resp.key
|
||||
.app_id
|
||||
.map(|a| a.to_string())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
)
|
||||
.field(
|
||||
"expires_at",
|
||||
resp.key
|
||||
.expires_at
|
||||
.map(|t| t.to_rfc3339())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
)
|
||||
.field("token", resp.raw_token.clone());
|
||||
block.print(mode);
|
||||
if matches!(mode, OutputMode::Tsv) {
|
||||
// The token row is human-easy-to-miss in a wall of metadata;
|
||||
// call it out exactly once on the human path. Skip on JSON
|
||||
// since machine consumers don't need the nudge.
|
||||
eprintln!("Save this token — it will not be shown again.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn ls(mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let keys = client.apikeys_list().await?;
|
||||
let mut table = Table::new([
|
||||
"id",
|
||||
"name",
|
||||
"prefix",
|
||||
"scopes",
|
||||
"app_id",
|
||||
"expires_at",
|
||||
"last_used_at",
|
||||
"created_at",
|
||||
]);
|
||||
for k in keys {
|
||||
table.row([
|
||||
k.id.to_string(),
|
||||
k.name,
|
||||
k.prefix,
|
||||
k.scopes
|
||||
.iter()
|
||||
.map(|s| s.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(","),
|
||||
k.app_id
|
||||
.map(|a| a.to_string())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
k.expires_at
|
||||
.map(|t| t.to_rfc3339())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
k.last_used_at
|
||||
.map(|t| t.to_rfc3339())
|
||||
.unwrap_or_else(|| "-".into()),
|
||||
k.created_at.to_rfc3339(),
|
||||
]);
|
||||
}
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn rm(id: &str) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
client.apikeys_delete(id).await?;
|
||||
println!("Revoked api-key {id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_scopes(raw: &[String]) -> Result<Vec<Scope>> {
|
||||
if raw.is_empty() {
|
||||
return Err(anyhow!(
|
||||
"at least one `--scope` is required (e.g. --scope script:read)"
|
||||
));
|
||||
}
|
||||
raw.iter()
|
||||
.map(|s| Scope::from_wire(s).ok_or_else(|| anyhow!("unknown scope: {s}")))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `--expires` accepts either RFC 3339 (`2026-12-31T23:59:59Z`) or a
|
||||
/// shorthand `<N>d` / `<N>h` / `<N>m` (days / hours / minutes from now).
|
||||
/// Shorthand wins for the common "key good for 30 days" case; full
|
||||
/// RFC 3339 keeps the door open for precise cutoffs.
|
||||
fn parse_expires(raw: &str) -> Result<DateTime<Utc>> {
|
||||
if let Some(spec) = raw.strip_suffix('d') {
|
||||
let days: i64 = spec.parse().map_err(|_| anyhow!("bad days: {raw}"))?;
|
||||
return Ok(Utc::now() + chrono::Duration::days(days));
|
||||
}
|
||||
if let Some(spec) = raw.strip_suffix('h') {
|
||||
let hours: i64 = spec.parse().map_err(|_| anyhow!("bad hours: {raw}"))?;
|
||||
return Ok(Utc::now() + chrono::Duration::hours(hours));
|
||||
}
|
||||
if let Some(spec) = raw.strip_suffix('m') {
|
||||
let mins: i64 = spec.parse().map_err(|_| anyhow!("bad minutes: {raw}"))?;
|
||||
return Ok(Utc::now() + chrono::Duration::minutes(mins));
|
||||
}
|
||||
DateTime::parse_from_rfc3339(raw)
|
||||
.map(|d| d.with_timezone(&Utc))
|
||||
.map_err(|e| anyhow!("expected RFC 3339 or `<N>d/h/m`, got {raw:?}: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_scopes_accepts_wire_form() {
|
||||
let scopes = parse_scopes(&["script:read".into(), "log:read".into()]).unwrap();
|
||||
assert_eq!(scopes, vec![Scope::ScriptRead, Scope::LogRead]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_scopes_rejects_empty() {
|
||||
let err = parse_scopes(&[]).unwrap_err();
|
||||
assert!(format!("{err}").contains("at least one"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_scopes_rejects_unknown() {
|
||||
let err = parse_scopes(&["script:nope".into()]).unwrap_err();
|
||||
assert!(format!("{err}").contains("unknown scope"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_expires_days_shorthand() {
|
||||
let d = parse_expires("7d").unwrap();
|
||||
let diff = (d - Utc::now()).num_days();
|
||||
assert!((6..=7).contains(&diff), "got {diff}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_expires_rfc3339_passes_through() {
|
||||
let d = parse_expires("2030-01-01T00:00:00Z").unwrap();
|
||||
assert_eq!(d.timestamp(), 1893456000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_expires_garbage_errors() {
|
||||
assert!(parse_expires("tomorrow").is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
//! `pic apps ls` and `pic apps create`.
|
||||
//! `pic apps` subcommands: `ls`, `create`, `show`, `delete`.
|
||||
|
||||
use anyhow::Result;
|
||||
use picloud_shared::AppRole;
|
||||
|
||||
use crate::client::{Client, CreateAppBody};
|
||||
use crate::config::load;
|
||||
use crate::output::Table;
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
pub async fn ls() -> Result<()> {
|
||||
let creds = load()?;
|
||||
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"]);
|
||||
@@ -22,12 +23,12 @@ pub async fn ls() -> Result<()> {
|
||||
app.created_at.to_rfc3339(),
|
||||
]);
|
||||
}
|
||||
table.print();
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create(slug: &str, name: Option<&str>, description: Option<&str>) -> Result<()> {
|
||||
let creds = load()?;
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let body = CreateAppBody {
|
||||
slug,
|
||||
@@ -38,3 +39,46 @@ pub async fn create(slug: &str, name: Option<&str>, description: Option<&str>) -
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,118 @@
|
||||
//! `pic login` — interactively (or via PICLOUD_URL/PICLOUD_TOKEN env
|
||||
//! shortcut for non-interactive contexts like CI and integration tests)
|
||||
//! capture the URL + bearer token, validate against `/auth/me`, save.
|
||||
//! `pic login` — primary auth entry point.
|
||||
//!
|
||||
//! Two flows:
|
||||
//! * **username + password** (default, interactive): POST
|
||||
//! `/api/v1/admin/auth/login` with the credentials and persist the
|
||||
//! returned session token. Mirrors the dashboard's login form.
|
||||
//! * **paste-a-token** (`--token <T>`, or `PICLOUD_TOKEN` env): skip
|
||||
//! the credential exchange and persist a bearer string directly.
|
||||
//! Used by CI and by anyone using a long-lived API key minted via
|
||||
//! `pic api-keys mint`. Validated against `/auth/me` before save.
|
||||
//!
|
||||
//! `--url <U>` (or `PICLOUD_URL`) overrides the URL prompt non-interactively.
|
||||
|
||||
use std::io::{self, BufRead, Write};
|
||||
|
||||
use anyhow::Result;
|
||||
use anyhow::{Context, Result};
|
||||
use picloud_shared::InstanceRole;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::client::{self, Client};
|
||||
use crate::config::{save, Credentials};
|
||||
|
||||
const DEFAULT_URL: &str = "http://localhost:8000";
|
||||
|
||||
pub async fn run() -> Result<()> {
|
||||
let (url, token) = collect_credentials()?;
|
||||
let client = Client::new(&url, &token)?;
|
||||
let me = client.auth_me().await?;
|
||||
pub async fn run(url_arg: Option<&str>, token_arg: Option<&str>) -> Result<()> {
|
||||
let url = resolve_url(url_arg)?;
|
||||
let token_from_env = std::env::var("PICLOUD_TOKEN")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty());
|
||||
let bearer_token = token_arg.map(str::to_string).or(token_from_env);
|
||||
|
||||
let (token, username, role) = match bearer_token {
|
||||
Some(t) => login_with_bearer(&url, &t).await?,
|
||||
None => login_with_password(&url).await?,
|
||||
};
|
||||
|
||||
let creds = Credentials {
|
||||
url: client.url().to_string(),
|
||||
url: url.clone(),
|
||||
token,
|
||||
username: me.username.clone(),
|
||||
username: username.clone(),
|
||||
};
|
||||
save(&creds)?;
|
||||
println!(
|
||||
"Logged in as {} ({}) at {}",
|
||||
me.username,
|
||||
instance_role_label(&me.instance_role),
|
||||
creds.url
|
||||
"Logged in as {username} ({}) at {url}",
|
||||
instance_role_label(&role)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn collect_credentials() -> Result<(String, String)> {
|
||||
// Non-interactive shortcut: both vars set → use as-is. Used by the
|
||||
// integration test and any CI flow that wants to skip the prompts.
|
||||
if let (Ok(url), Ok(tok)) = (std::env::var("PICLOUD_URL"), std::env::var("PICLOUD_TOKEN")) {
|
||||
if !url.is_empty() && !tok.is_empty() {
|
||||
return Ok((url, tok));
|
||||
async fn login_with_password(url: &str) -> Result<(String, String, InstanceRole)> {
|
||||
let username = prompt_line("Username: ")?;
|
||||
if username.is_empty() {
|
||||
anyhow::bail!("username is required");
|
||||
}
|
||||
let password = read_password()?;
|
||||
let resp = client::auth_login(url, &username, &password).await?;
|
||||
Ok((resp.token, resp.user.username, resp.user.instance_role))
|
||||
}
|
||||
|
||||
/// Read a password without echoing it where possible. Falls back to a
|
||||
/// plain stdin read when no controlling terminal is attached — CI
|
||||
/// systems and `cargo test`'s piped stdin both land here, and dying
|
||||
/// outright would block scripted use entirely. The fallback is louder
|
||||
/// (visible characters), but it's that or no functioning login.
|
||||
fn read_password() -> Result<String> {
|
||||
match rpassword::prompt_password("Password: ") {
|
||||
Ok(p) => Ok(p),
|
||||
Err(_) => {
|
||||
eprint!("Password: ");
|
||||
io::stderr().flush()?;
|
||||
let mut buf = String::new();
|
||||
io::stdin()
|
||||
.lock()
|
||||
.read_line(&mut buf)
|
||||
.context("reading password from stdin")?;
|
||||
Ok(buf.trim_end_matches(['\r', '\n']).to_string())
|
||||
}
|
||||
}
|
||||
let url = prompt_with_default("PiCloud URL", DEFAULT_URL)?;
|
||||
let token = rpassword::prompt_password("API token: ")?;
|
||||
Ok((url, token))
|
||||
}
|
||||
|
||||
/// Bearer-token path: validate against `/auth/me` so a typo doesn't get
|
||||
/// persisted, then trust the username the server reports rather than
|
||||
/// whatever the user typed (which they didn't type at all in this mode).
|
||||
async fn login_with_bearer(url: &str, token: &str) -> Result<(String, String, InstanceRole)> {
|
||||
let client = Client::new(url, token)?;
|
||||
let me = client.auth_me().await?;
|
||||
Ok((token.to_string(), me.username, me.instance_role))
|
||||
}
|
||||
|
||||
fn instance_role_label(role: &InstanceRole) -> &'static str {
|
||||
match role {
|
||||
InstanceRole::Owner => "owner",
|
||||
InstanceRole::Admin => "admin",
|
||||
InstanceRole::Member => "member",
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_url(url_arg: Option<&str>) -> Result<String> {
|
||||
if let Some(u) = url_arg {
|
||||
return Ok(u.trim_end_matches('/').to_string());
|
||||
}
|
||||
if let Ok(env_url) = std::env::var("PICLOUD_URL") {
|
||||
if !env_url.is_empty() {
|
||||
return Ok(env_url.trim_end_matches('/').to_string());
|
||||
}
|
||||
}
|
||||
let typed = prompt_with_default("PiCloud URL", DEFAULT_URL)?;
|
||||
Ok(typed.trim_end_matches('/').to_string())
|
||||
}
|
||||
|
||||
fn prompt_line(label: &str) -> Result<String> {
|
||||
print!("{label}");
|
||||
io::stdout().flush()?;
|
||||
let mut buf = String::new();
|
||||
io::stdin().lock().read_line(&mut buf)?;
|
||||
Ok(buf.trim().to_string())
|
||||
}
|
||||
|
||||
fn prompt_with_default(label: &str, default: &str) -> Result<String> {
|
||||
@@ -55,12 +127,3 @@ fn prompt_with_default(label: &str, default: &str) -> Result<String> {
|
||||
trimmed.to_string()
|
||||
})
|
||||
}
|
||||
|
||||
fn instance_role_label(role: &picloud_shared::InstanceRole) -> &'static str {
|
||||
use picloud_shared::InstanceRole as R;
|
||||
match role {
|
||||
R::Owner => "owner",
|
||||
R::Admin => "admin",
|
||||
R::Member => "member",
|
||||
}
|
||||
}
|
||||
|
||||
29
crates/picloud-cli/src/cmds/logout.rs
Normal file
29
crates/picloud-cli/src/cmds/logout.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
//! `pic logout` — revoke the saved session server-side, then wipe the
|
||||
//! local credentials file.
|
||||
//!
|
||||
//! Idempotent: if the file doesn't exist or the server already forgot
|
||||
//! the session, we still succeed. The point is leaving the user in a
|
||||
//! clean "no token" state, not enforcing that a session existed.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config;
|
||||
|
||||
pub async fn run() -> Result<()> {
|
||||
// Load before delete so we have a token to POST /logout with; if
|
||||
// there's no creds file there's also nothing to revoke server-side.
|
||||
let creds = config::load().ok();
|
||||
|
||||
if let Some(creds) = creds {
|
||||
let client = Client::from_creds(&creds)?;
|
||||
// Best-effort: a 4xx (token already invalid) or network error
|
||||
// shouldn't block the local wipe. The whole point of logout is
|
||||
// leaving no credentials on disk.
|
||||
let _ = client.auth_logout().await;
|
||||
}
|
||||
|
||||
config::delete()?;
|
||||
println!("Logged out");
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,27 +1,48 @@
|
||||
//! `pic logs <script-id>` — print recent execution log rows.
|
||||
//!
|
||||
//! In TSV mode emits a header + truncated-summary rows (`pic logs` was
|
||||
//! previously headerless — inconsistent with `apps ls` / `scripts ls`).
|
||||
//! In JSON mode emits the raw `ExecutionLog` array (no truncation),
|
||||
//! letting `jq` consumers see request/response bodies in full.
|
||||
|
||||
use anyhow::Result;
|
||||
use picloud_shared::ExecutionStatus;
|
||||
use picloud_shared::{ExecutionLog, ExecutionStatus};
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config::load;
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn run(script_id: &str, limit: u32) -> Result<()> {
|
||||
let creds = load()?;
|
||||
pub async fn run(script_id: &str, limit: u32, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let entries = client.logs_list(script_id, limit).await?;
|
||||
for e in entries {
|
||||
let summary = summarize(&e.response_body, &e.script_logs);
|
||||
println!(
|
||||
"{}\t{}\t{}",
|
||||
e.created_at.to_rfc3339(),
|
||||
status_label(&e.status),
|
||||
truncate(&summary, 120),
|
||||
);
|
||||
match mode {
|
||||
OutputMode::Tsv => render_tsv(&entries),
|
||||
OutputMode::Json => render_json(&entries),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn render_tsv(entries: &[ExecutionLog]) {
|
||||
let mut table = Table::new(["created_at", "status", "summary"]);
|
||||
for e in entries {
|
||||
let summary = summarize(&e.response_body, &e.script_logs);
|
||||
table.row([
|
||||
e.created_at.to_rfc3339(),
|
||||
status_label(&e.status).to_string(),
|
||||
truncate(&summary, 120),
|
||||
]);
|
||||
}
|
||||
table.print(OutputMode::Tsv);
|
||||
}
|
||||
|
||||
fn render_json(entries: &[ExecutionLog]) {
|
||||
// Pretty for human jq-piping; consumers that want compact can pipe
|
||||
// through `jq -c`.
|
||||
let s = serde_json::to_string_pretty(entries).unwrap_or_else(|_| "[]".to_string());
|
||||
println!("{s}");
|
||||
}
|
||||
|
||||
fn status_label(s: &ExecutionStatus) -> &'static str {
|
||||
match s {
|
||||
ExecutionStatus::Success => "success",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod api_keys;
|
||||
pub mod apps;
|
||||
pub mod login;
|
||||
pub mod logout;
|
||||
pub mod logs;
|
||||
pub mod scripts;
|
||||
pub mod whoami;
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
//! `pic scripts ls | deploy | invoke`.
|
||||
//! `pic scripts ls | deploy | invoke | delete`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use picloud_shared::AppId;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::client::{Client, CreateScriptBody};
|
||||
use crate::config::load;
|
||||
use crate::output::Table;
|
||||
use crate::config;
|
||||
use crate::output::{OutputMode, Table};
|
||||
|
||||
pub async fn ls(app: Option<&str>) -> Result<()> {
|
||||
let creds = load()?;
|
||||
pub async fn ls(app: Option<&str>, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let mut table = Table::new(["id", "app_slug", "name", "version", "updated_at"]);
|
||||
@@ -29,24 +31,29 @@ pub async fn ls(app: Option<&str>) -> Result<()> {
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
// No filter → walk every accessible app. One request per app is
|
||||
// fine at MVP scale (handful of apps); a bulk endpoint can come
|
||||
// later if the count grows.
|
||||
let apps = client.apps_list().await?;
|
||||
for a in apps {
|
||||
let scripts = client.scripts_list_by_app(&a.slug).await?;
|
||||
for s in scripts {
|
||||
table.row([
|
||||
s.id.to_string(),
|
||||
a.slug.clone(),
|
||||
s.name,
|
||||
s.version.to_string(),
|
||||
s.updated_at.to_rfc3339(),
|
||||
]);
|
||||
}
|
||||
// No filter → use the single `GET /admin/scripts` call. Server
|
||||
// filters by membership for `Member`; for `Admin`/`Owner` it
|
||||
// returns every script. Two requests total (apps + scripts) run
|
||||
// in parallel; the per-app walk we used to do here aborted on
|
||||
// the first 404 when another caller deleted an app mid-listing,
|
||||
// and was the entire reason a 5× retry existed in the tests.
|
||||
let (apps, scripts) = tokio::try_join!(client.apps_list(), client.scripts_list_all())?;
|
||||
let slug_by_id: HashMap<AppId, String> = apps.into_iter().map(|a| (a.id, a.slug)).collect();
|
||||
for s in scripts {
|
||||
let app_slug = slug_by_id
|
||||
.get(&s.app_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
table.row([
|
||||
s.id.to_string(),
|
||||
app_slug,
|
||||
s.name,
|
||||
s.version.to_string(),
|
||||
s.updated_at.to_rfc3339(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
table.print();
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -56,7 +63,7 @@ pub async fn deploy(
|
||||
name_override: Option<&str>,
|
||||
description: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let creds = load()?;
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let source =
|
||||
@@ -99,7 +106,7 @@ pub async fn deploy(
|
||||
}
|
||||
|
||||
pub async fn invoke(id: &str, body_arg: Option<&str>, headers: &[(String, String)]) -> Result<()> {
|
||||
let creds = load()?;
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
let body = parse_body_arg(body_arg)?;
|
||||
@@ -115,6 +122,18 @@ pub async fn invoke(id: &str, body_arg: Option<&str>, headers: &[(String, String
|
||||
}
|
||||
}
|
||||
|
||||
/// `pic scripts delete <id>`. Requires `AppAdmin` on the owning app
|
||||
/// server-side, which is stricter than the edit endpoints — Editor
|
||||
/// can deploy/update but not destroy. Surfaces that as a 403 with the
|
||||
/// usual role hint.
|
||||
pub async fn delete(id: &str) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
client.scripts_delete(id).await?;
|
||||
println!("Deleted script {id}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_body_arg(arg: Option<&str>) -> Result<Value> {
|
||||
match arg {
|
||||
None => Ok(Value::Object(serde_json::Map::new())),
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
//! `pic whoami` — re-validates the saved token by hitting `/auth/me`
|
||||
//! every time. Cached username in the credentials file is for
|
||||
//! display-only contexts; this command is the source of truth.
|
||||
//!
|
||||
//! TSV output uses `KvBlock` (aligned `key: value` rows), JSON output
|
||||
//! is a flat object — both downstream-friendly without the user having
|
||||
//! to parse a headerless tab-line.
|
||||
|
||||
use anyhow::Result;
|
||||
use picloud_shared::InstanceRole;
|
||||
|
||||
use crate::client::Client;
|
||||
use crate::config::load;
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode};
|
||||
|
||||
pub async fn run() -> Result<()> {
|
||||
let creds = load()?;
|
||||
pub async fn run(mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let me = client.auth_me().await?;
|
||||
let role = match me.instance_role {
|
||||
picloud_shared::InstanceRole::Owner => "owner",
|
||||
picloud_shared::InstanceRole::Admin => "admin",
|
||||
picloud_shared::InstanceRole::Member => "member",
|
||||
InstanceRole::Owner => "owner",
|
||||
InstanceRole::Admin => "admin",
|
||||
InstanceRole::Member => "member",
|
||||
};
|
||||
let email = me.email.as_deref().unwrap_or("-");
|
||||
println!("{}\t{role}\t{email}\t{}", me.username, creds.url);
|
||||
let mut block = KvBlock::new();
|
||||
block
|
||||
.field("username", me.username)
|
||||
.field("role", role)
|
||||
.field("email", email)
|
||||
.field("url", creds.url.clone());
|
||||
block.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user