//! `pic apply [--file picloud.toml]` — reconcile the live app to the //! manifest's desired state in one server-side transaction. Creates and //! updates are applied; `--prune` additionally deletes live scripts/routes/ //! triggers absent from the manifest (secrets are never pruned). use std::io::{IsTerminal, Write}; use std::path::Path; use anyhow::{Context, Result}; use crate::client::{Client, NodeKind}; use crate::cmds::plan::build_bundle; use crate::config; use crate::manifest::Manifest; use crate::output::{KvBlock, OutputMode}; pub async fn run( manifest_path: &Path, env: Option<&str>, prune: bool, yes: bool, force: bool, mode: OutputMode, ) -> Result<()> { let creds = config::resolve()?; let client = Client::from_creds(&creds)?; let manifest = Manifest::load_with_env(manifest_path, env)?; let base_dir = manifest_path.parent().unwrap_or_else(|| Path::new(".")); let bundle = build_bundle(&manifest, base_dir)?; let kind = if manifest.is_group() { NodeKind::Group } else { NodeKind::App }; let slug = manifest.slug().to_string(); if prune && !yes { confirm_prune(&slug)?; } // Bound-plan check: replay the token from the last `pic plan` (for this // node) so the server refuses if it changed since it was reviewed. // `--force` skips it; no recorded plan means no check (apply still works // standalone). The token is single-use — cleared after a successful apply. let expected_token = if force { None } else { crate::linkstate::read_plan(base_dir) .filter(|l| l.app == slug) .map(|l| l.state_token) }; let report = client .apply_node(kind, &slug, &bundle, prune, expected_token.as_deref()) .await?; crate::linkstate::clear_plan(base_dir, &slug); let label = if manifest.is_group() { "group" } else { "app" }; let mut block = KvBlock::new(); block .field(label, slug.clone()) .field( "scripts", format!( "+{} ~{} -{}", report.scripts_created, report.scripts_updated, report.scripts_deleted ), ) .field( "routes", format!( "+{} ~{} -{}", report.routes_created, report.routes_updated, report.routes_deleted ), ) .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()); } block.print(mode); Ok(()) } /// `--prune` deletes live scripts/routes/triggers absent from the manifest — /// irreversible. Require an explicit go-ahead: an interactive `y`, or `--yes` /// for non-interactive/CI use. Refuse a non-interactive prune without `--yes` /// rather than silently deleting (review the deletions first with `pic plan`). fn confirm_prune(slug: &str) -> Result<()> { if !std::io::stdin().is_terminal() { anyhow::bail!( "refusing to `apply --prune` non-interactively without `--yes`: prune \ deletes resources absent from the manifest and cannot be undone. \ Review with `pic plan`, then re-run with `--yes`." ); } eprint!( "apply --prune will DELETE live scripts/routes/triggers on `{slug}` that are \ absent from the manifest. This cannot be undone. Continue? [y/N] " ); std::io::stderr().flush().ok(); let mut answer = String::new(); std::io::stdin() .read_line(&mut answer) .context("read confirmation")?; if !matches!(answer.trim(), "y" | "Y" | "yes" | "Yes") { anyhow::bail!("aborted"); } Ok(()) }