feat(cli): add pic command-line client (login, apps, scripts, logs)

Adds a new workspace crate `picloud-cli` shipping a `pic` binary that
drives the edit-deploy-invoke-tail-logs loop against PiCloud's admin
and execute HTTP surface. Eight subcommands cover the minimum a
developer needs to never open the dashboard:

  pic login                    (paste URL + bearer token, validates via /auth/me)
  pic whoami                   (re-validates and prints principal)
  pic apps ls | create
  pic scripts ls | deploy | invoke
  pic logs <id>

Credentials persist as TOML under the platform config dir (resolved
via `directories`); on POSIX the file is forced to mode 0600.
PICLOUD_URL + PICLOUD_TOKEN env vars short-circuit interactive prompts
for CI and integration tests.

The CLI redeclares minimal request/response structs in `client.rs`
rather than depending on `manager-core` — keeps the blast radius
contained without touching the existing crate boundaries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-05-28 20:53:49 +02:00
parent b42e273479
commit 7b50047730
13 changed files with 1448 additions and 0 deletions

View File

@@ -0,0 +1,142 @@
//! 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;
#[derive(Parser)]
#[command(name = "pic", version, about = "PiCloud command-line client")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
/// Save URL + bearer token to `~/.picloud/credentials`.
Login,
/// Print the principal the saved token resolves to.
Whoami,
/// App management.
Apps {
#[command(subcommand)]
cmd: AppsCmd,
},
/// Script management.
Scripts {
#[command(subcommand)]
cmd: ScriptsCmd,
},
/// Tail recent execution logs for a script.
Logs(LogsArgs),
}
#[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>,
},
}
#[derive(Subcommand)]
enum ScriptsCmd {
/// List scripts. With `--app`, scoped to one app; without,
/// iterates over every app 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 {
file: PathBuf,
#[arg(long)]
app: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
},
/// POST to `/api/v1/execute/{id}`. Body via `--body @path`,
/// `--body @-` for stdin, or inline JSON.
Invoke {
id: String,
#[arg(long)]
body: Option<String>,
#[arg(short = 'H', long = "header", value_parser = client::parse_kv_header)]
headers: Vec<(String, 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 result = match cli.cmd {
Cmd::Login => cmds::login::run().await,
Cmd::Whoami => cmds::whoami::run().await,
Cmd::Apps { cmd: AppsCmd::Ls } => cmds::apps::ls().await,
Cmd::Apps {
cmd:
AppsCmd::Create {
slug,
name,
description,
},
} => cmds::apps::create(&slug, name.as_deref(), description.as_deref()).await,
Cmd::Scripts {
cmd: ScriptsCmd::Ls { app },
} => cmds::scripts::ls(app.as_deref()).await,
Cmd::Scripts {
cmd:
ScriptsCmd::Deploy {
file,
app,
name,
description,
},
} => cmds::scripts::deploy(&file, &app, name.as_deref(), description.as_deref()).await,
Cmd::Scripts {
cmd: ScriptsCmd::Invoke { id, body, headers },
} => cmds::scripts::invoke(&id, body.as_deref(), &headers).await,
Cmd::Logs(LogsArgs { script_id, limit }) => cmds::logs::run(&script_id, limit).await,
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
output::print_error(&err);
ExitCode::FAILURE
}
}
}