Files
PiCloud/crates/picloud-cli/src/config.rs
MechaCat02 f147665157 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>
2026-05-29 23:33:44 +02:00

154 lines
5.2 KiB
Rust

//! On-disk credentials store.
//!
//! Path is resolved via `directories::ProjectDirs` so the file lives in
//! the platform-appropriate config dir (XDG on Linux, Library on macOS,
//! AppData on Windows). On POSIX the file is forced to mode 0600 so the
//! pasted bearer token isn't world-readable.
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result};
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct Credentials {
pub url: String,
pub token: String,
pub username: String,
}
/// Resolve the credentials file path. Honors `PICLOUD_CONFIG_DIR` as an
/// override (used by tests to redirect to a tempdir) before falling
/// back to the platform default.
pub fn credentials_path() -> Result<PathBuf> {
if let Ok(dir) = std::env::var("PICLOUD_CONFIG_DIR") {
return Ok(PathBuf::from(dir).join("credentials"));
}
let dirs = ProjectDirs::from("dev", "picloud", "picloud")
.ok_or_else(|| anyhow!("could not determine config directory"))?;
Ok(dirs.config_dir().join("credentials"))
}
pub fn load() -> Result<Credentials> {
let path = credentials_path()?;
let body = fs::read_to_string(&path).with_context(|| {
format!(
"no credentials at {}. run `pic login` first",
path.display()
)
})?;
toml::from_str(&body).with_context(|| format!("failed to parse {}", path.display()))
}
/// Resolution order used by every non-login command:
/// 1. If both `PICLOUD_URL` and `PICLOUD_TOKEN` are set (and non-empty),
/// use them directly. Matches gcloud/aws/kubectl semantics — env
/// wins so CI never accidentally reads a developer's stale file.
/// 2. Otherwise fall back to the on-disk credentials file.
///
/// Username is best-effort: env mode has no way to know the real one
/// (no round-trip to `/auth/me`), so it shows as `"-"` in `whoami`
/// output. Callers that need the canonical username re-fetch via
/// `Client::auth_me`.
pub fn resolve() -> Result<Credentials> {
if let (Ok(url), Ok(token)) = (std::env::var("PICLOUD_URL"), std::env::var("PICLOUD_TOKEN")) {
if !url.is_empty() && !token.is_empty() {
return Ok(Credentials {
url,
token,
username: "-".to_string(),
});
}
}
load()
}
/// Delete the on-disk credentials file. Idempotent — silently succeeds
/// if the file is already gone (the user already logged out, or never
/// logged in to begin with).
pub fn delete() -> Result<()> {
let path = credentials_path()?;
match fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err).with_context(|| format!("removing {}", path.display())),
}
}
pub fn save(creds: &Credentials) -> Result<()> {
let path = credentials_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?;
}
let body = toml::to_string(creds).context("serializing credentials")?;
write_private(&path, body.as_bytes())?;
Ok(())
}
#[cfg(unix)]
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
use std::os::unix::fs::OpenOptionsExt;
let mut f = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(path)
.with_context(|| format!("opening {}", path.display()))?;
f.write_all(bytes)
.with_context(|| format!("writing {}", path.display()))?;
// Belt-and-suspenders: re-set perms in case the file already existed
// with a wider mode (mode() on create doesn't downgrade existing).
let mut perms = fs::metadata(path)?.permissions();
use std::os::unix::fs::PermissionsExt;
perms.set_mode(0o600);
fs::set_permissions(path, perms)?;
Ok(())
}
#[cfg(not(unix))]
fn write_private(path: &Path, bytes: &[u8]) -> Result<()> {
fs::write(path, bytes).with_context(|| format!("writing {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn roundtrip_toml() {
let creds = Credentials {
url: "http://localhost:8000".to_string(),
token: "pic_abc".to_string(),
username: "admin".to_string(),
};
let serialized = toml::to_string(&creds).unwrap();
let parsed: Credentials = toml::from_str(&serialized).unwrap();
assert_eq!(creds, parsed);
}
#[cfg(unix)]
#[test]
fn posix_mode_is_0600() {
use std::os::unix::fs::PermissionsExt;
let dir = tempdir().unwrap();
std::env::set_var("PICLOUD_CONFIG_DIR", dir.path());
let creds = Credentials {
url: "http://localhost:8000".to_string(),
token: "pic_secret".to_string(),
username: "admin".to_string(),
};
save(&creds).unwrap();
let path = credentials_path().unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "credentials must be readable only by owner");
std::env::remove_var("PICLOUD_CONFIG_DIR");
}
}