From 34dcae98a299650f66e6d7526bf8162585c86cdd Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Thu, 25 Jun 2026 07:40:36 +0200 Subject: [PATCH] feat(cli): declare app vars in the manifest + plan/apply them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `[vars]` block to `picloud.toml` (key → TOML value), reconciled by `pic plan`/`pic apply` alongside scripts/routes/triggers/secrets. Unlike `[secrets]` (name-only), var values live inline since they aren't secret. The overlay (`picloud..toml`) may carry `[vars]`; overlay keys win per key (the env-specific value of an env-agnostic default). `build_bundle` serializes the vars to JSON for the wire (TOML round-trips via serde); `pic plan` shows a `var` row group and `pic apply` reports `vars +c ~u -d`. The `PlanDto`/`ApplyReportDto` gain the matching fields. `pic pull` carries an empty `[vars]` for now (export lands next). Adds a round-trip + overlay-precedence unit test. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/picloud-cli/src/client.rs | 8 +++++ crates/picloud-cli/src/cmds/apply.rs | 7 ++++ crates/picloud-cli/src/cmds/init.rs | 1 + crates/picloud-cli/src/cmds/plan.rs | 14 +++++++- crates/picloud-cli/src/cmds/pull.rs | 1 + crates/picloud-cli/src/manifest.rs | 49 ++++++++++++++++++++++++---- 6 files changed, 72 insertions(+), 8 deletions(-) diff --git a/crates/picloud-cli/src/client.rs b/crates/picloud-cli/src/client.rs index 3652765..18fe273 100644 --- a/crates/picloud-cli/src/client.rs +++ b/crates/picloud-cli/src/client.rs @@ -1221,6 +1221,8 @@ pub struct PlanDto { pub triggers: Vec, #[serde(default)] pub secrets: Vec, + #[serde(default)] + pub vars: Vec, /// Fingerprint of the live state this plan was computed against; carried /// in `.picloud/` and replayed to `apply` for the bound-plan check. #[serde(default)] @@ -1255,6 +1257,12 @@ pub struct ApplyReportDto { #[serde(default)] pub triggers_deleted: u32, #[serde(default)] + pub vars_created: u32, + #[serde(default)] + pub vars_updated: u32, + #[serde(default)] + pub vars_deleted: u32, + #[serde(default)] pub warnings: Vec, } diff --git a/crates/picloud-cli/src/cmds/apply.rs b/crates/picloud-cli/src/cmds/apply.rs index 51febd3..5974aec 100644 --- a/crates/picloud-cli/src/cmds/apply.rs +++ b/crates/picloud-cli/src/cmds/apply.rs @@ -75,6 +75,13 @@ pub async fn run( .field( "triggers", format!("+{} -{}", report.triggers_created, report.triggers_deleted), + ) + .field( + "vars", + format!( + "+{} ~{} -{}", + report.vars_created, report.vars_updated, report.vars_deleted + ), ); for w in &report.warnings { block.field("warning", w.clone()); diff --git a/crates/picloud-cli/src/cmds/init.rs b/crates/picloud-cli/src/cmds/init.rs index 11f702a..d2e85c0 100644 --- a/crates/picloud-cli/src/cmds/init.rs +++ b/crates/picloud-cli/src/cmds/init.rs @@ -137,6 +137,7 @@ fn scaffold_manifest(slug: &str, name: &str) -> Manifest { }], triggers: crate::manifest::ManifestTriggers::default(), secrets: crate::manifest::ManifestSecrets::default(), + vars: std::collections::BTreeMap::new(), } } diff --git a/crates/picloud-cli/src/cmds/plan.rs b/crates/picloud-cli/src/cmds/plan.rs index ff3b3b7..648445a 100644 --- a/crates/picloud-cli/src/cmds/plan.rs +++ b/crates/picloud-cli/src/cmds/plan.rs @@ -95,11 +95,22 @@ pub fn build_bundle(manifest: &Manifest, base_dir: &Path) -> Result { triggers.push(tagged("queue", s)?); } + // Vars: key → JSON value. TOML values round-trip to JSON via serde so the + // wire shape matches the server's `serde_json::Value`. + let mut vars = Map::new(); + for (k, v) in &manifest.vars { + vars.insert( + k.clone(), + serde_json::to_value(v).with_context(|| format!("encoding var `{k}`"))?, + ); + } + Ok(json!({ "scripts": scripts, "routes": routes, "triggers": triggers, "secrets": manifest.secrets.names, + "vars": vars, })) } @@ -114,11 +125,12 @@ fn tagged(kind: &str, spec: impl Serialize) -> Result { fn render(plan: &PlanDto, mode: OutputMode) { let mut table = Table::new(["kind", "op", "resource", "detail"]); - let groups: [(&str, &Vec); 4] = [ + let groups: [(&str, &Vec); 5] = [ ("script", &plan.scripts), ("route", &plan.routes), ("trigger", &plan.triggers), ("secret", &plan.secrets), + ("var", &plan.vars), ]; for (kind, changes) in groups { for c in changes { diff --git a/crates/picloud-cli/src/cmds/pull.rs b/crates/picloud-cli/src/cmds/pull.rs index ec67535..628617c 100644 --- a/crates/picloud-cli/src/cmds/pull.rs +++ b/crates/picloud-cli/src/cmds/pull.rs @@ -204,6 +204,7 @@ pub async fn run(app_ident: &str, dir: &Path, force: bool, mode: OutputMode) -> secrets: ManifestSecrets { names: secrets.iter().map(|s| s.name.clone()).collect(), }, + vars: std::collections::BTreeMap::new(), }; std::fs::write(&manifest_path, manifest.to_toml()?) diff --git a/crates/picloud-cli/src/manifest.rs b/crates/picloud-cli/src/manifest.rs index c40d2f0..f80d7f1 100644 --- a/crates/picloud-cli/src/manifest.rs +++ b/crates/picloud-cli/src/manifest.rs @@ -14,6 +14,7 @@ //! exposed declaratively). `email` triggers carry an `inbound_secret_ref` //! (a secret name) resolved server-side at apply. +use std::collections::BTreeMap; use std::fs; use std::path::Path; @@ -38,6 +39,11 @@ pub struct Manifest { pub triggers: ManifestTriggers, #[serde(default, skip_serializing_if = "ManifestSecrets::is_empty")] pub secrets: ManifestSecrets, + /// `[vars]` — app config key → value, reconciled at app scope `*`. Values + /// are non-secret and live inline (unlike `[secrets]`, which is name-only). + /// The overlay merges per-env, last-write-wins by key. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub vars: BTreeMap, } impl Manifest { @@ -61,9 +67,9 @@ impl Manifest { /// Load the base manifest, then (if `env` is set) merge the sparse /// `picloud..toml` overlay on top — the §4.1 base+overlay model - /// where "an environment is an app". The overlay carries per-env slug + - /// secrets; scripts/routes/triggers stay in the shared base. (Rich - /// per-key `vars` resolution is Phase 3.) + /// where "an environment is an app". The overlay carries per-env slug, + /// secrets, and vars (overlay vars win per key); scripts/routes/triggers + /// stay in the shared base. pub fn load_with_env(base_path: &Path, env: Option<&str>) -> Result { let mut base = Self::load(base_path)?; if let Some(env) = env { @@ -95,6 +101,11 @@ impl Manifest { self.secrets.names.push(n); } } + // Overlay vars override base vars per key (the env-specific value of + // an env-agnostic default); keys only in the base are kept. + for (k, v) in overlay.vars { + self.vars.insert(k, v); + } } } @@ -113,10 +124,10 @@ 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. +/// `deny_unknown_fields`: an overlay can carry *only* `[app]`, `[secrets]`, +/// and `[vars]`. 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 { @@ -124,6 +135,8 @@ pub struct ManifestOverlay { pub app: OverlayApp, #[serde(default)] pub secrets: ManifestSecrets, + #[serde(default)] + pub vars: BTreeMap, } #[derive(Debug, Clone, Default, Deserialize)] @@ -409,6 +422,10 @@ mod tests { secrets: ManifestSecrets { names: vec!["STRIPE_KEY".into()], }, + vars: BTreeMap::from([ + ("region".to_string(), toml::Value::String("eu".into())), + ("max-retries".to_string(), toml::Value::Integer(3)), + ]), } } @@ -512,12 +529,30 @@ mod tests { routes: vec![], triggers: ManifestTriggers::default(), secrets: ManifestSecrets::default(), + vars: BTreeMap::new(), }; let text = m.to_toml().unwrap(); assert!(!text.contains("[[scripts]]"), "got:\n{text}"); assert!(!text.contains("triggers"), "got:\n{text}"); assert!(!text.contains("secrets"), "got:\n{text}"); + assert!(!text.contains("vars"), "got:\n{text}"); // Still round-trips. assert_eq!(m, Manifest::parse(&text).unwrap()); } + + #[test] + fn overlay_vars_override_base_per_key() { + let mut base = sample(); + base.vars + .insert("region".into(), toml::Value::String("eu".into())); + base.vars + .insert("tier".into(), toml::Value::String("base".into())); + let overlay: ManifestOverlay = + toml::from_str("[vars]\nregion = \"us\"\nextra = true\n").unwrap(); + base.apply_overlay(overlay); + // overlay wins for `region`, base-only `tier` survives, overlay adds `extra`. + assert_eq!(base.vars["region"], toml::Value::String("us".into())); + assert_eq!(base.vars["tier"], toml::Value::String("base".into())); + assert_eq!(base.vars["extra"], toml::Value::Boolean(true)); + } }