diff --git a/crates/picloud-cli/src/cmds/config.rs b/crates/picloud-cli/src/cmds/config.rs new file mode 100644 index 0000000..51679b8 --- /dev/null +++ b/crates/picloud-cli/src/cmds/config.rs @@ -0,0 +1,50 @@ +//! `pic config --effective` — read-only view of an app's resolved +//! configuration, with secret values masked (§4.6). +//! +//! Single-app today: "config" means the app's secrets, cross-referenced +//! against the manifest so an operator can see at a glance which declared +//! secrets are still unset and which live secrets aren't declared. Values are +//! never fetched or shown — only `` / ``. The command is shaped to +//! grow an `--explain` mode and inherited `vars` once groups/vars land (the +//! Phase-3 multi-level resolution). + +use std::collections::BTreeSet; +use std::path::Path; + +use anyhow::{bail, Result}; + +use crate::client::Client; +use crate::config; +use crate::manifest::Manifest; +use crate::output::{OutputMode, Table}; + +pub async fn run(manifest_path: &Path, effective: bool, mode: OutputMode) -> Result<()> { + if !effective { + bail!("`pic config` currently supports only `--effective`"); + } + let creds = config::resolve()?; + let client = Client::from_creds(&creds)?; + let manifest = Manifest::load(manifest_path)?; + + let declared: BTreeSet = manifest.secrets.names.iter().cloned().collect(); + let on_server: BTreeSet = client + .secrets_list(&manifest.app.slug) + .await? + .secrets + .into_iter() + .map(|s| s.name) + .collect(); + + let mut table = Table::new(["secret", "value", "status"]); + for name in declared.union(&on_server) { + let (value, status) = match (declared.contains(name), on_server.contains(name)) { + (true, true) => ("", "managed"), + (true, false) => ("", "declared, not pushed — `pic secret set`"), + (false, true) => ("", "on server, not in manifest"), + (false, false) => unreachable!("name came from one of the two sets"), + }; + table.row([name.clone(), value.to_string(), status.to_string()]); + } + table.print(mode); + Ok(()) +} diff --git a/crates/picloud-cli/src/cmds/mod.rs b/crates/picloud-cli/src/cmds/mod.rs index 821ec24..a0070ed 100644 --- a/crates/picloud-cli/src/cmds/mod.rs +++ b/crates/picloud-cli/src/cmds/mod.rs @@ -3,6 +3,7 @@ pub mod api_keys; pub mod apply; pub mod apps; pub mod apps_domains; +pub mod config; pub mod dead_letters; pub mod files; pub mod init; diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index 79ff7ab..dda3854 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -177,6 +177,10 @@ enum Cmd { /// app), `scripts/hello.rhai`, and a `.gitignore`. Offline; deploy with /// `pic plan` then `pic apply`. Init(InitArgs), + + /// Show an app's resolved configuration. `--effective` lists secrets with + /// values masked (``/``), cross-referenced against the manifest. + Config(ConfigArgs), } #[derive(Args)] @@ -230,6 +234,16 @@ struct InitArgs { force: bool, } +#[derive(Args)] +struct ConfigArgs { + /// Path to the manifest. + #[arg(long, default_value = "picloud.toml")] + file: PathBuf, + /// Show the effective (resolved) config with secrets masked. + #[arg(long)] + effective: bool, +} + #[derive(Subcommand)] enum KvCmd { /// List keys in a collection. @@ -1122,6 +1136,7 @@ async fn main() -> ExitCode { } Cmd::Plan(args) => cmds::plan::run(&args.file, mode).await, Cmd::Pull(args) => cmds::pull::run(&args.app, &args.dir, mode).await, + Cmd::Config(args) => cmds::config::run(&args.file, args.effective, mode).await, Cmd::Init(args) => cmds::init::run( &args.dir, args.slug.as_deref(), diff --git a/crates/picloud-cli/tests/cli.rs b/crates/picloud-cli/tests/cli.rs index d7e1216..417fbf8 100644 --- a/crates/picloud-cli/tests/cli.rs +++ b/crates/picloud-cli/tests/cli.rs @@ -18,6 +18,7 @@ mod api_keys; mod apply; mod apps; mod auth; +mod config; mod dead_letters; mod email_queue; mod enabled; diff --git a/crates/picloud-cli/tests/config.rs b/crates/picloud-cli/tests/config.rs new file mode 100644 index 0000000..5521f1d --- /dev/null +++ b/crates/picloud-cli/tests/config.rs @@ -0,0 +1,57 @@ +//! `pic config --effective` — masked secret resolution against the manifest. + +use std::fs; + +use predicates::prelude::*; +use tempfile::TempDir; + +use crate::common; +use crate::common::cleanup::AppGuard; + +#[ignore = "needs DATABASE_URL pointing at a running Postgres"] +#[test] +fn config_effective_masks_and_classifies_secrets() { + let Some(fx) = common::fixture_or_skip() else { + return; + }; + let env = common::admin_env(fx); + let slug = common::unique_slug("config"); + common::pic_as(&env) + .args(["apps", "create", &slug]) + .assert() + .success(); + let _guard = AppGuard::new(&env.url, &env.token, &slug); + + let dir = TempDir::new().unwrap(); + let manifest_path = dir.path().join("picloud.toml"); + fs::write( + &manifest_path, + format!("[app]\nslug = \"{slug}\"\nname = \"Cfg\"\n\n[secrets]\nnames = [\"API_KEY\"]\n"), + ) + .unwrap(); + + // Declared but not pushed → masked + flagged unset; value never shown. + common::pic_as(&env) + .args(["config", "--effective", "--file"]) + .arg(&manifest_path) + .assert() + .success() + .stdout(predicate::str::contains("API_KEY")) + .stdout(predicate::str::contains("")) + .stdout(predicate::str::contains("not pushed")); + + // Push it → now masked as / managed, still never the value. + common::pic_as(&env) + .args(["secrets", "set", "--app", &slug, "API_KEY"]) + .write_stdin("super-secret-value") + .assert() + .success(); + common::pic_as(&env) + .args(["config", "--effective", "--file"]) + .arg(&manifest_path) + .assert() + .success() + .stdout(predicate::str::contains("")) + .stdout(predicate::str::contains("managed")) + .stdout(predicate::str::contains("super-secret-value").not()); +}