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>
154 lines
5.2 KiB
Rust
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");
|
|
}
|
|
}
|