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>
269 lines
7.3 KiB
Rust
269 lines
7.3 KiB
Rust
//! PiCloud command-line client.
|
|
//!
|
|
//! Thin client over the existing admin + execute HTTP surface — the
|
|
//! server gains nothing for the CLI; the CLI is just a developer
|
|
//! ergonomics layer over endpoints the dashboard already uses.
|
|
|
|
use std::path::PathBuf;
|
|
use std::process::ExitCode;
|
|
|
|
use clap::{Args, Parser, Subcommand};
|
|
|
|
mod client;
|
|
mod cmds;
|
|
mod config;
|
|
mod output;
|
|
|
|
use crate::output::OutputMode;
|
|
|
|
#[derive(Parser)]
|
|
#[command(name = "pic", version, about = "PiCloud command-line client")]
|
|
struct Cli {
|
|
/// Output format for `ls` / `show` / `whoami` / `logs` commands.
|
|
/// TSV stays pipe-friendly; JSON is `jq`-ready.
|
|
#[arg(long, value_enum, global = true, default_value_t = OutputMode::Tsv)]
|
|
output: OutputMode,
|
|
|
|
#[command(subcommand)]
|
|
cmd: Cmd,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Cmd {
|
|
/// Authenticate with the server. Default flow prompts for username
|
|
/// + password and saves the returned session token; `--token` skips
|
|
/// the password exchange and persists a bearer string directly (use
|
|
/// this for long-lived API keys minted via `pic api-keys mint`).
|
|
Login(LoginArgs),
|
|
|
|
/// Revoke the saved session server-side and delete the local
|
|
/// credentials file. Idempotent.
|
|
Logout,
|
|
|
|
/// Print the principal the saved token resolves to.
|
|
Whoami,
|
|
|
|
/// App management.
|
|
Apps {
|
|
#[command(subcommand)]
|
|
cmd: AppsCmd,
|
|
},
|
|
|
|
/// Script management.
|
|
Scripts {
|
|
#[command(subcommand)]
|
|
cmd: ScriptsCmd,
|
|
},
|
|
|
|
/// Long-lived bearer API key management.
|
|
#[command(name = "api-keys")]
|
|
ApiKeys {
|
|
#[command(subcommand)]
|
|
cmd: ApiKeysCmd,
|
|
},
|
|
|
|
/// Tail recent execution logs for a script.
|
|
Logs(LogsArgs),
|
|
|
|
/// Top-level alias for `pic scripts invoke <id>`.
|
|
Invoke(InvokeArgs),
|
|
|
|
/// Top-level alias for `pic scripts deploy <file> --app <slug>`.
|
|
Deploy(DeployArgs),
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct LoginArgs {
|
|
/// Override the URL prompt non-interactively. Also reads
|
|
/// `PICLOUD_URL`.
|
|
#[arg(long)]
|
|
url: Option<String>,
|
|
|
|
/// Skip the username + password exchange and persist this bearer
|
|
/// directly (validated against `/auth/me` first). Also reads
|
|
/// `PICLOUD_TOKEN`.
|
|
#[arg(long)]
|
|
token: Option<String>,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum AppsCmd {
|
|
/// List apps the caller can see.
|
|
Ls,
|
|
|
|
/// Create a new app.
|
|
Create {
|
|
slug: String,
|
|
#[arg(long)]
|
|
name: Option<String>,
|
|
#[arg(long)]
|
|
description: Option<String>,
|
|
},
|
|
|
|
/// Show a single app, including the caller's role in it.
|
|
Show { ident: String },
|
|
|
|
/// Delete an app. Without `--force`, the server rejects if the app
|
|
/// still owns scripts.
|
|
Delete {
|
|
ident: String,
|
|
#[arg(long)]
|
|
force: bool,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum ScriptsCmd {
|
|
/// List scripts. With `--app`, scoped to one app; without, one
|
|
/// `GET /admin/scripts` for everything the caller can see.
|
|
Ls {
|
|
#[arg(long)]
|
|
app: Option<String>,
|
|
},
|
|
|
|
/// Upload a `.rhai` file. Patches the existing script with the
|
|
/// matching name in `--app` if one exists, otherwise creates it.
|
|
Deploy(DeployArgs),
|
|
|
|
/// POST to `/api/v1/execute/{id}`. Body via `--body @path`,
|
|
/// `--body @-` for stdin, or inline JSON.
|
|
Invoke(InvokeArgs),
|
|
|
|
/// Delete a script. Requires AppAdmin on the owning app.
|
|
Delete { id: String },
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct DeployArgs {
|
|
file: PathBuf,
|
|
#[arg(long)]
|
|
app: String,
|
|
#[arg(long)]
|
|
name: Option<String>,
|
|
#[arg(long)]
|
|
description: Option<String>,
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct InvokeArgs {
|
|
id: String,
|
|
#[arg(long)]
|
|
body: Option<String>,
|
|
#[arg(short = 'H', long = "header", value_parser = client::parse_kv_header)]
|
|
headers: Vec<(String, String)>,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum ApiKeysCmd {
|
|
/// Mint a new long-lived bearer key. Token printed exactly once.
|
|
Mint {
|
|
name: String,
|
|
/// Repeat for multiple scopes: `--scope script:read --scope log:read`.
|
|
#[arg(long = "scope", required = true)]
|
|
scopes: Vec<String>,
|
|
/// Bind the key to a single app (slug or id). Rejects
|
|
/// `instance:*` scopes when set.
|
|
#[arg(long)]
|
|
app: Option<String>,
|
|
/// Absolute RFC 3339 (`2026-12-31T23:59:59Z`) or shorthand
|
|
/// `<N>d`/`<N>h`/`<N>m`.
|
|
#[arg(long)]
|
|
expires: Option<String>,
|
|
},
|
|
|
|
/// List the caller's keys (no `raw_token` after mint).
|
|
Ls,
|
|
|
|
/// Revoke a key by id.
|
|
Rm { id: String },
|
|
}
|
|
|
|
#[derive(Args)]
|
|
struct LogsArgs {
|
|
script_id: String,
|
|
#[arg(long, default_value_t = 50)]
|
|
limit: u32,
|
|
}
|
|
|
|
#[tokio::main(flavor = "current_thread")]
|
|
async fn main() -> ExitCode {
|
|
let cli = Cli::parse();
|
|
let mode = cli.output;
|
|
let result = match cli.cmd {
|
|
Cmd::Login(args) => cmds::login::run(args.url.as_deref(), args.token.as_deref()).await,
|
|
Cmd::Logout => cmds::logout::run().await,
|
|
Cmd::Whoami => cmds::whoami::run(mode).await,
|
|
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls(mode).await,
|
|
Cmd::Apps {
|
|
cmd:
|
|
AppsCmd::Create {
|
|
slug,
|
|
name,
|
|
description,
|
|
},
|
|
} => cmds::apps::create(&slug, name.as_deref(), description.as_deref()).await,
|
|
Cmd::Apps {
|
|
cmd: AppsCmd::Show { ident },
|
|
} => cmds::apps::show(&ident, mode).await,
|
|
Cmd::Apps {
|
|
cmd: AppsCmd::Delete { ident, force },
|
|
} => cmds::apps::delete(&ident, force).await,
|
|
Cmd::Scripts {
|
|
cmd: ScriptsCmd::Ls { app },
|
|
} => cmds::scripts::ls(app.as_deref(), mode).await,
|
|
Cmd::Scripts {
|
|
cmd: ScriptsCmd::Deploy(args),
|
|
} => {
|
|
cmds::scripts::deploy(
|
|
&args.file,
|
|
&args.app,
|
|
args.name.as_deref(),
|
|
args.description.as_deref(),
|
|
)
|
|
.await
|
|
}
|
|
Cmd::Scripts {
|
|
cmd: ScriptsCmd::Invoke(args),
|
|
} => cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await,
|
|
Cmd::Scripts {
|
|
cmd: ScriptsCmd::Delete { id },
|
|
} => cmds::scripts::delete(&id).await,
|
|
Cmd::ApiKeys {
|
|
cmd:
|
|
ApiKeysCmd::Mint {
|
|
name,
|
|
scopes,
|
|
app,
|
|
expires,
|
|
},
|
|
} => cmds::api_keys::mint(&name, &scopes, app.as_deref(), expires.as_deref(), mode).await,
|
|
Cmd::ApiKeys {
|
|
cmd: ApiKeysCmd::Ls,
|
|
} => cmds::api_keys::ls(mode).await,
|
|
Cmd::ApiKeys {
|
|
cmd: ApiKeysCmd::Rm { id },
|
|
} => cmds::api_keys::rm(&id).await,
|
|
Cmd::Logs(LogsArgs { script_id, limit }) => cmds::logs::run(&script_id, limit, mode).await,
|
|
Cmd::Invoke(args) => {
|
|
cmds::scripts::invoke(&args.id, args.body.as_deref(), &args.headers).await
|
|
}
|
|
Cmd::Deploy(args) => {
|
|
cmds::scripts::deploy(
|
|
&args.file,
|
|
&args.app,
|
|
args.name.as_deref(),
|
|
args.description.as_deref(),
|
|
)
|
|
.await
|
|
}
|
|
};
|
|
|
|
match result {
|
|
Ok(()) => ExitCode::SUCCESS,
|
|
Err(err) => {
|
|
output::print_error(&err);
|
|
ExitCode::FAILURE
|
|
}
|
|
}
|
|
}
|