diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index fbaedbe..eeb8a72 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -933,6 +933,19 @@ impl Client { .json(&body) .send() .await?; + // The apply endpoint returns 409 only for a stale bound plan + // (`StateMoved`): the app changed since `pic plan` recorded its + // token. Surface an actionable next step instead of a bare + // `HTTP 409`. + if resp.status() == reqwest::StatusCode::CONFLICT { + let body = resp.text().await.unwrap_or_default(); + let msg = parse_error_body(&body).unwrap_or(body); + return Err(anyhow!( + "{msg}\nThe app changed since your last `pic plan`. Re-run \ + `pic plan` to review the new diff, then `pic apply` — or \ + `pic apply --force` to apply without re-reviewing." + )); + } decode(resp).await } } diff --git a/crates/picloud-cli/src/cmds/config.rs b/crates/picloud-cli/src/cmds/config.rs index 51679b8..b8da11f 100644 --- a/crates/picloud-cli/src/cmds/config.rs +++ b/crates/picloud-cli/src/cmds/config.rs @@ -18,13 +18,21 @@ use crate::config; use crate::manifest::Manifest; use crate::output::{OutputMode, Table}; -pub async fn run(manifest_path: &Path, effective: bool, mode: OutputMode) -> Result<()> { +pub async fn run( + manifest_path: &Path, + effective: bool, + env: Option<&str>, + 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)?; + // Resolve against the same env overlay as `pic plan`/`apply` so the + // masked report targets the app those commands would act on — not the + // base slug — when an overlay re-points slug/secrets. + let manifest = Manifest::load_with_env(manifest_path, env)?; let declared: BTreeSet = manifest.secrets.names.iter().cloned().collect(); let on_server: BTreeSet = client diff --git a/crates/picloud-cli/src/cmds/pull.rs b/crates/picloud-cli/src/cmds/pull.rs index 11f3b2d..7dd1b11 100644 --- a/crates/picloud-cli/src/cmds/pull.rs +++ b/crates/picloud-cli/src/cmds/pull.rs @@ -23,10 +23,24 @@ use crate::manifest::{ }; use crate::output::{KvBlock, OutputMode}; -pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> { +pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; + // Refuse to clobber an existing project (mirrors `pic init`). `pull` + // overwrites `picloud.toml` and every colliding `scripts/*.rhai`, so a + // stray `pic pull ` in a populated dir would destroy local + // edits. Fail fast — before any network call or file write — unless the + // operator opted in with `--force`. + let manifest_path = dir.join(MANIFEST_FILE); + if !force && manifest_path.exists() { + anyhow::bail!( + "{} already exists; refusing to overwrite. Re-run with --force to \ + replace it (and any colliding scripts/*.rhai).", + manifest_path.display() + ); + } + // One GET per resource kind (routes are per-script, below). let app = client.apps_get(app_ident).await?; let scripts = client.scripts_list_by_app(app_ident).await?; @@ -189,7 +203,6 @@ pub async fn run(app_ident: &str, dir: &Path, mode: OutputMode) -> Result<()> { }, }; - let manifest_path = dir.join(MANIFEST_FILE); std::fs::write(&manifest_path, manifest.to_toml()?) .with_context(|| format!("writing {}", manifest_path.display()))?; diff --git a/crates/picloud-cli/src/main.rs b/crates/picloud-cli/src/main.rs index 1fa6699..12bc481 100644 --- a/crates/picloud-cli/src/main.rs +++ b/crates/picloud-cli/src/main.rs @@ -224,6 +224,9 @@ struct PullArgs { /// Directory to write `picloud.toml` + `scripts/` into. #[arg(long, default_value = ".")] dir: PathBuf, + /// Overwrite an existing `picloud.toml` (and colliding `scripts/*.rhai`). + #[arg(long)] + force: bool, } #[derive(Args)] @@ -250,6 +253,10 @@ struct ConfigArgs { /// Show the effective (resolved) config with secrets masked. #[arg(long)] effective: bool, + /// Resolve against the `picloud..toml` overlay (per-env slug + + /// secrets), matching `pic plan --env` / `pic apply --env`. + #[arg(long)] + env: Option, } #[derive(Subcommand)] @@ -1151,8 +1158,10 @@ async fn main() -> ExitCode { .await } Cmd::Plan(args) => cmds::plan::run(&args.file, args.env.as_deref(), 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::Pull(args) => cmds::pull::run(&args.app, &args.dir, args.force, mode).await, + Cmd::Config(args) => { + cmds::config::run(&args.file, args.effective, args.env.as_deref(), mode).await + } Cmd::Init(args) => cmds::init::run( &args.dir, args.slug.as_deref(), diff --git a/crates/picloud-cli/src/manifest.rs b/crates/picloud-cli/src/manifest.rs index bdfe7f5..c40d2f0 100644 --- a/crates/picloud-cli/src/manifest.rs +++ b/crates/picloud-cli/src/manifest.rs @@ -112,7 +112,13 @@ fn overlay_path(base_path: &Path, env: &str) -> std::path::PathBuf { /// A sparse per-environment overlay (`picloud..toml`). Only the fields /// that vary per environment today — slug/name and secret names. +/// +/// `deny_unknown_fields`: an overlay can carry *only* `[app]` and `[secrets]`. +/// Scripts/routes/triggers belong in the shared base manifest, so a +/// `[[scripts]]`/`[[routes]]`/`[[triggers]]` table (or a typo'd key) in an +/// overlay is a mistake — error loudly rather than silently dropping it. #[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct ManifestOverlay { #[serde(default)] pub app: OverlayApp, @@ -121,6 +127,7 @@ pub struct ManifestOverlay { } #[derive(Debug, Clone, Default, Deserialize)] +#[serde(deny_unknown_fields)] pub struct OverlayApp { #[serde(default)] pub slug: Option, @@ -461,6 +468,26 @@ mod tests { assert_eq!(m.scripts.len(), 2); } + #[test] + fn overlay_rejects_non_overlay_tables() { + // An overlay carries only [app]/[secrets]. Scripts/routes/triggers + // belong in the shared base, so a `[[scripts]]` table (or a typo'd + // key) in an overlay must error loudly, not be silently dropped. + let err = toml::from_str::( + "[app]\nslug = \"blog-staging\"\n\n\ + [[scripts]]\nname = \"hello\"\nfile = \"scripts/hello.rhai\"\n", + ) + .expect_err("overlay with [[scripts]] must be rejected"); + assert!( + err.to_string().contains("scripts") || err.to_string().contains("unknown"), + "error should point at the offending table: {err}" + ); + + // Typo'd key inside [app] is likewise rejected. + toml::from_str::("[app]\nslag = \"oops\"\n") + .expect_err("overlay with a typo'd [app] key must be rejected"); + } + #[test] fn overlay_path_derivation() { assert_eq!(