From 6fb5e935f5af1de64ff4eb0d8b9202e2f585b48a Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Mon, 19 Oct 2026 18:35:57 +0530 Subject: [PATCH 1/5] Ask before ad-template generation writes a changed config ts audit ad-templates generate now previews the managed-field diff on stderr and asks for an explicit yes before replacing an existing config. Non-interactive runs fail without writing unless --yes is passed, --replace warns before the prompt, and an unchanged candidate is no longer rewritten. Closes #1229 Signed-off-by: dhruv8sh --- .../src/commands/audit/generate/mod.rs | 758 +++++++++++++++++- .../src/commands/audit/mod.rs | 17 + crates/trusted-server-cli/src/run.rs | 42 + docs/guide/cli.md | 47 +- 4 files changed, 829 insertions(+), 35 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs index 010522f8fb..a4aa110495 100644 --- a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs +++ b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs @@ -11,7 +11,7 @@ mod validate; use std::collections::BTreeSet; use std::fs; -use std::io::Write; +use std::io::{BufRead, Write}; use std::path::{Path, PathBuf}; use rand::RngCore as _; @@ -827,6 +827,10 @@ pub(crate) struct UpdateSlotsRequest<'a> { pub(crate) cookies: &'a [(String, String)], /// Print the candidate instead of writing it. pub(crate) dry_run: bool, + /// Apply a changed candidate without prompting (`--yes`). + pub(crate) yes: bool, + /// Whether an operator can see the confirmation prompt and answer it. + pub(crate) interactive: bool, /// Whether the crawl used the deterministic scroll pass. pub(crate) scroll: bool, /// Crawl bounds. @@ -856,6 +860,7 @@ pub(crate) fn run_update_slots( collectors: &[(&str, &dyn AuditCollector)], out: &mut dyn Write, err: &mut dyn Write, + input: &mut dyn BufRead, ) -> CliResult<()> { let Some((first_label, first_collector)) = collectors.first() else { return cli_error("no device profile was selected to audit with"); @@ -1136,41 +1141,38 @@ pub(crate) fn run_update_slots( .map_err(|error| report_error(format!("failed to write command output: {error}")))?; } - if request.dry_run { - let old_managed = managed_creative_projection(&existing)?; - let new_managed = managed_creative_projection(&updated)?; - if old_managed == new_managed { - // Stdout is the diff surface, so an English sentence there would - // break a redirected `--dry-run`; an empty diff is the stdout answer. - writeln!(err, "No managed creative-opportunity changes.").map_err(|error| { - report_error(format!("failed to write preview output: {error}")) - })?; - return Ok(()); - } - let diff = similar::TextDiff::from_lines(&old_managed, &new_managed); - writeln!( - out, - "{}", - diff.unified_diff().context_radius(0).header( - "configured creative opportunities", - "generated creative opportunities" + let old_managed = managed_creative_projection(&existing)?; + let new_managed = managed_creative_projection(&updated)?; + if old_managed == new_managed { + // Stdout is the diff surface, so an English sentence there would + // break a redirected `--dry-run`; an empty diff is the stdout answer. + // Outside a dry run there is nothing to confirm, and rewriting the file + // would only churn its formatting. + let message = if request.dry_run { + "No managed creative-opportunity changes.".to_string() + } else { + format!( + "No managed creative-opportunity changes; {} left unchanged.", + request.config_path.display() ) - ) - .map_err(|error| report_error(format!("failed to write preview diff: {error}")))?; + }; + writeln!(err, "{message}") + .map_err(|error| report_error(format!("failed to write preview output: {error}")))?; return Ok(()); } - let current = fs::read_to_string(request.config_path).map_err(|error| { - report_error(format!( - "failed to re-read config {} before writing: {error}", - request.config_path.display() - )) - })?; - if current != existing { - return cli_error(format!( - "refusing to overwrite {} because it changed during the browser audit; re-run against the current file", - request.config_path.display() - )); + let diff = managed_diff(&old_managed, &new_managed); + if request.dry_run { + writeln!(out, "{diff}") + .map_err(|error| report_error(format!("failed to write preview diff: {error}")))?; + return Ok(()); + } + // Checked before asking too, so the operator is not asked to approve a + // write that is already certain to be refused. + ensure_config_unchanged(request.config_path, &existing)?; + if !confirm_config_write(request, &diff, err, input)? { + return Ok(()); } + ensure_config_unchanged(request.config_path, &existing)?; // A writer could still land between this check and the rename below. That // window is microseconds against a browser crawl's minutes, and the rename // is atomic, so the loser of the race loses a whole write rather than half @@ -1193,6 +1195,93 @@ pub(crate) fn run_update_slots( .map_err(|error| report_error(format!("failed to write command output: {error}"))) } +/// Renders the zero-context unified diff between two managed projections. +fn managed_diff(old_managed: &str, new_managed: &str) -> String { + similar::TextDiff::from_lines(old_managed, new_managed) + .unified_diff() + .context_radius(0) + .header( + "configured creative opportunities", + "generated creative opportunities", + ) + .to_string() +} + +/// Refuses the write when the config no longer matches the copy the candidate +/// was built from. +/// +/// # Errors +/// +/// Returns an error when the config cannot be re-read or has changed. +fn ensure_config_unchanged(config_path: &Path, existing: &str) -> CliResult<()> { + let current = fs::read_to_string(config_path).map_err(|error| { + report_error(format!( + "failed to re-read config {} before writing: {error}", + config_path.display() + )) + })?; + if current != existing { + return cli_error(format!( + "refusing to overwrite {} because it changed during the browser audit or while awaiting confirmation; re-run against the current file", + config_path.display() + )); + } + Ok(()) +} + +/// Previews a changed candidate on `err` and decides whether to write it. +/// +/// `--yes` approves without reading `input`. Otherwise an operator at a +/// terminal must answer `y` or `yes`; any other answer, including an empty +/// line or EOF, declines. Without a terminal the run fails instead of guessing. +/// +/// # Errors +/// +/// Returns an error when the run is not interactive and `--yes` was not given, +/// or when the preview or the answer cannot be written or read. +fn confirm_config_write( + request: &UpdateSlotsRequest<'_>, + diff: &str, + err: &mut dyn Write, + input: &mut dyn BufRead, +) -> CliResult { + let path = request.config_path.display(); + let write_error = + |error: std::io::Error| report_error(format!("failed to write confirmation: {error}")); + writeln!( + err, + "Changes to managed creative-opportunity fields in {path}:\n{diff}" + ) + .map_err(write_error)?; + if request.replace { + writeln!( + err, + "warning: --replace discards every configured slot this crawl did not observe, \ + and every hand-written field on the slots it did rediscover." + ) + .map_err(write_error)?; + } + if request.yes { + return Ok(true); + } + if !request.interactive { + return cli_error(format!( + "refusing to update {path} without confirmation because stdin or stderr is not a terminal; re-run with --yes to apply these changes, or with --dry-run to preview only" + )); + } + write!(err, "Apply these changes to {path}? [y/N] ").map_err(write_error)?; + err.flush().map_err(write_error)?; + let mut answer = String::new(); + input + .read_line(&mut answer) + .map_err(|error| report_error(format!("failed to read confirmation: {error}")))?; + let approved = matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes"); + if !approved { + writeln!(err, "Not applied; {path} left unchanged.").map_err(write_error)?; + } + Ok(approved) +} + /// Renders only fields managed by ad-template generation, excluding secrets and /// unrelated operator configuration from dry-run output. fn managed_creative_projection(document: &str) -> CliResult { @@ -1861,12 +1950,15 @@ mod tests { replace: false, cookies: &[], dry_run: false, + yes: true, + interactive: false, scroll: false, budget: CrawlBudget::default(), }, &[("desktop", &collector)], &mut out, &mut progress_writer, + &mut io::empty(), ) .expect("should generate slots"); @@ -2910,12 +3002,15 @@ mod tests { replace: false, cookies: &[], dry_run: false, + yes: true, + interactive: false, scroll: false, budget: CrawlBudget::default(), }, &[("desktop", &collector)], &mut out, &mut std::io::sink(), + &mut io::empty(), ) .expect("should update slots"); @@ -2978,12 +3073,15 @@ mod tests { replace: false, cookies: &[], dry_run: true, + yes: false, + interactive: false, scroll, budget: CrawlBudget::default(), }, &[("desktop", &collector)], &mut out, &mut notes, + &mut io::empty(), ) .expect("should preserve unobserved slot"); @@ -3070,12 +3168,15 @@ mod tests { replace: false, cookies: &[], dry_run: true, + yes: false, + interactive: false, scroll: false, budget: CrawlBudget::default(), }, &[("desktop", &desktop), ("mobile", &mobile)], &mut out, &mut notes, + &mut io::empty(), ) .expect("the accepted slot should let generation complete"); @@ -3126,12 +3227,15 @@ mod tests { replace: false, cookies: &[], dry_run: true, + yes: false, + interactive: false, scroll: true, budget: CrawlBudget::default(), }, &[("desktop", &collector)], &mut std::io::sink(), &mut notes, + &mut io::empty(), ) .expect("should preserve the configured ambiguous placement"); @@ -3207,12 +3311,15 @@ mod tests { replace: false, cookies: &[], dry_run: true, + yes: false, + interactive: false, scroll: true, budget: CrawlBudget::default(), }, &[("desktop", &collector)], &mut std::io::sink(), &mut notes, + &mut io::empty(), ) .expect("the stable slot should let generation complete"); @@ -3261,12 +3368,15 @@ mod tests { replace: false, cookies: &[], dry_run: false, + yes: true, + interactive: false, scroll: false, budget: CrawlBudget::default(), }, &[("desktop", &collector)], &mut std::io::sink(), &mut std::io::sink(), + &mut io::empty(), ) .expect("should write static locale-root slot"); @@ -3307,12 +3417,15 @@ mod tests { replace: false, cookies: &[("session".to_string(), "secret".to_string())], dry_run: false, + yes: true, + interactive: false, scroll: false, budget: CrawlBudget::default(), }, &[("desktop", &collector)], &mut std::io::sink(), &mut std::io::sink(), + &mut io::empty(), ) .expect_err("cross-origin redirect must leave the requested trust boundary"); @@ -3365,12 +3478,15 @@ mod tests { replace: false, cookies: &[], dry_run: false, + yes: true, + interactive: false, budget: CrawlBudget::default(), scroll: false, }, &[("desktop", &collector)], &mut std::io::sink(), &mut err, + &mut io::empty(), ) .expect("should generate from the same-origin evidence alone"); @@ -3449,12 +3565,15 @@ mod tests { replace: false, cookies: &[], dry_run: false, + yes: true, + interactive: false, budget: CrawlBudget::default(), scroll: false, }, &[("desktop", &desktop), ("mobile", &mobile)], &mut std::io::sink(), &mut err, + &mut io::empty(), ) .expect("the same-origin pages of both profiles agree"); @@ -3503,12 +3622,15 @@ mod tests { replace: false, cookies: &[("session".to_string(), "secret".to_string())], dry_run: false, + yes: true, + interactive: false, scroll: false, budget: CrawlBudget::default(), }, &[("desktop", &collector)], &mut std::io::sink(), &mut notes, + &mut io::empty(), ) .expect("a same-host HTTPS upgrade should not be treated as cross-origin"); @@ -3550,12 +3672,15 @@ mod tests { replace: false, cookies: &[("session".to_string(), "secret".to_string())], dry_run: false, + yes: true, + interactive: false, scroll: false, budget: CrawlBudget::default(), }, &[("desktop", &collector)], &mut std::io::sink(), &mut std::io::sink(), + &mut io::empty(), ) .expect_err("an HTTPS downgrade must leave the requested trust boundary"); @@ -3584,12 +3709,15 @@ mod tests { replace: false, cookies: &[], dry_run: false, + yes: true, + interactive: false, scroll: false, budget: CrawlBudget::default(), }, &[("desktop", &desktop), ("mobile", &FailingCollector)], &mut std::io::sink(), &mut std::io::sink(), + &mut io::empty(), ) .expect_err("a selected profile with no usable page must refuse generation"); @@ -3619,12 +3747,15 @@ mod tests { replace: false, cookies: &[], dry_run: false, + yes: true, + interactive: false, scroll: false, budget: CrawlBudget::default(), }, &[("desktop", &collector)], &mut out, &mut std::io::sink(), + &mut io::empty(), ) .expect_err("should reject an invalid glob"); @@ -3687,12 +3818,15 @@ mod tests { replace: false, cookies: &[], dry_run: false, + yes: true, + interactive: false, scroll: false, budget: CrawlBudget::default(), }, &[("desktop", &collector)], &mut std::io::sink(), &mut std::io::sink(), + &mut io::empty(), ) .expect_err("explicit patterns cannot preserve borrowed-root safety"); @@ -3729,12 +3863,15 @@ mod tests { replace: false, cookies: &[], dry_run: false, + yes: true, + interactive: false, scroll: false, budget: CrawlBudget::default(), }, &[("desktop", &collector)], &mut out, &mut std::io::sink(), + &mut io::empty(), ) .expect("should accept a runtime-normalisable pattern"); @@ -3767,12 +3904,15 @@ mod tests { replace: false, cookies: &[], dry_run: false, + yes: true, + interactive: false, scroll: false, budget: CrawlBudget::default(), }, &[("desktop", &collector)], &mut out, &mut std::io::sink(), + &mut io::empty(), ) .expect("should update slots"); @@ -3867,12 +4007,15 @@ mod tests { replace: false, cookies: &[], dry_run: false, + yes: true, + interactive: false, scroll: false, budget: CrawlBudget::default(), }, &[("desktop", &collector)], &mut out, &mut err, + &mut io::empty(), ) .expect("should crawl and update slots"); @@ -3974,12 +4117,15 @@ mod tests { replace: false, cookies: &[], dry_run: false, + yes: true, + interactive: false, scroll: false, budget: CrawlBudget::default(), }, &[("desktop", &desktop), ("mobile", &mobile)], &mut out, &mut err, + &mut io::empty(), ) .expect_err("an all-refused crawl must not write an empty slot array"); @@ -4050,12 +4196,15 @@ mod tests { replace: false, cookies: &[], dry_run: false, + yes: true, + interactive: false, scroll: false, budget: CrawlBudget::default(), }, &[("desktop", &desktop), ("mobile", &mobile)], &mut out, &mut std::io::sink(), + &mut io::empty(), ) .expect_err("an all-refused crawl must not write an empty slot array"); @@ -4110,12 +4259,15 @@ mod tests { replace: false, cookies: &[], dry_run: false, + yes: true, + interactive: false, scroll: false, budget: CrawlBudget::default(), }, &[("desktop", &collector)], &mut out, &mut std::io::sink(), + &mut io::empty(), ) .expect_err("a mostly-challenged crawl should refuse"); @@ -4156,6 +4308,8 @@ mod tests { replace: false, cookies: &[], dry_run: false, + yes: true, + interactive: false, scroll: false, budget: CrawlBudget { max_sections: 8, @@ -4165,6 +4319,7 @@ mod tests { &[("desktop", &collector)], &mut out, &mut std::io::sink(), + &mut io::empty(), ) .expect("should update from the single page"); @@ -4211,12 +4366,15 @@ mod tests { replace: false, cookies: &[], dry_run: false, + yes: true, + interactive: false, scroll: false, budget: CrawlBudget::default(), }, &[("desktop", &collector)], &mut out, &mut std::io::sink(), + &mut io::empty(), ) .expect("should update slots"); @@ -4309,12 +4467,15 @@ mod tests { replace: false, cookies: &[], dry_run: true, + yes: false, + interactive: false, scroll: false, budget: CrawlBudget::default(), }, &[("desktop", &collector)], &mut out, &mut std::io::sink(), + &mut io::empty(), ) .expect("should render dry-run update"); @@ -4360,12 +4521,15 @@ mod tests { replace: false, cookies: &[], dry_run: false, + yes: true, + interactive: false, scroll: false, budget: CrawlBudget::default(), }, &[("desktop", &collector)], &mut std::io::sink(), &mut std::io::sink(), + &mut io::empty(), ) .expect_err("a stale update should be refused"); @@ -4376,4 +4540,534 @@ mod tests { "the concurrent edit must not be overwritten" ); } + + /// The prompt line every interactive confirmation ends with. + const APPLY_PROMPT: &str = "? [y/N] "; + + /// Write-gate settings for one `run_update_slots` call. + struct WriteGate<'a> { + existing_creative: Option<&'a CreativeOpportunitiesConfig>, + replace: bool, + dry_run: bool, + yes: bool, + interactive: bool, + } + + /// What a write-gate run returned and printed. + struct WriteGateRun { + result: CliResult<()>, + stdout: String, + stderr: String, + } + + /// Runs a header-slot crawl against `config_path` through the write gate. + fn run_write_gate( + config_path: &Path, + gate: &WriteGate<'_>, + input: &mut dyn BufRead, + ) -> WriteGateRun { + let collector = FakeCollector::new(collected_page_with_header_slot()); + let mut out = Vec::new(); + let mut err = Vec::new(); + let result = run_update_slots( + &UpdateSlotsRequest { + url: "https://publisher.example/", + config_path, + existing_creative: gate.existing_creative, + page_patterns: &[], + replace: gate.replace, + cookies: &[], + dry_run: gate.dry_run, + yes: gate.yes, + interactive: gate.interactive, + scroll: false, + budget: CrawlBudget::default(), + }, + &[("desktop", &collector)], + &mut out, + &mut err, + input, + ); + WriteGateRun { + result, + stdout: String::from_utf8(out).expect("stdout should be UTF-8"), + stderr: String::from_utf8(err).expect("stderr should be UTF-8"), + } + } + + /// An interactive run with no prior slots, so the crawl changes the config. + fn interactive_gate() -> WriteGate<'static> { + WriteGate { + existing_creative: None, + replace: false, + dry_run: false, + yes: false, + interactive: true, + } + } + + /// A loadable config whose managed fields the header-slot crawl changes. + fn write_changing_config(temp: &TempDir) -> (std::path::PathBuf, String) { + let config_path = temp.path().join("trusted-server.toml"); + let original = loadable_config(); + fs::write(&config_path, &original).expect("should write config"); + (config_path, original) + } + + /// A loadable config already holding the header slot plus an unobserved + /// sidebar slot, under the crawl's network id. + fn write_config_with_header_and_sidebar(temp: &TempDir) -> (std::path::PathBuf, String) { + let config_path = temp.path().join("trusted-server.toml"); + let mut original = + loadable_config().replace("gam_network_id = \"123456789\"", "gam_network_id = \"222\""); + original.push_str( + "\n[[creative_opportunities.slot]]\n\ + id = \"header\"\n\ + div_id = \"div-gpt-ad-header\"\n\ + gam_unit_path = \"/222/homepage/header\"\n\ + page_patterns = [\"/\"]\n\ + formats = [{ width = 728, height = 90 }]\n\n\ + [[creative_opportunities.slot]]\n\ + id = \"sidebar\"\n\ + div_id = \"ad-sidebar\"\n\ + gam_unit_path = \"/222/sidebar\"\n\ + page_patterns = [\"/news/*\"]\n\ + formats = [{ width = 300, height = 250 }]\n", + ); + fs::write(&config_path, &original).expect("should write config"); + (config_path, original) + } + + fn read_config(config_path: &Path) -> String { + fs::read_to_string(config_path).expect("should read config") + } + + #[test] + fn update_slots_prompt_shows_target_and_diff_then_writes_after_yes() { + let temp = TempDir::new().expect("should create temp dir"); + let (config_path, original) = write_changing_config(&temp); + let mut input = io::Cursor::new(b"yes\n".to_vec()); + + let run = run_write_gate(&config_path, &interactive_gate(), &mut input); + + run.result.expect("a confirmed update should succeed"); + let written = read_config(&config_path); + assert_ne!( + written, original, + "a confirmed update should write the config" + ); + assert!( + written.contains("div-gpt-ad-header"), + "the written config should carry the discovered slot, got {written:?}" + ); + let target = config_path.display().to_string(); + assert!( + run.stderr.contains(&target), + "the preview should name the target path, got {:?}", + run.stderr + ); + assert!( + run.stderr.contains("+++ generated creative opportunities") + && run.stderr.contains("div-gpt-ad-header"), + "the preview should show the managed-field diff, got {:?}", + run.stderr + ); + let diff_at = run + .stderr + .find("+++ generated creative opportunities") + .expect("should print the diff"); + let prompt_at = run + .stderr + .find(APPLY_PROMPT) + .expect("should ask before writing"); + assert!(diff_at < prompt_at, "the diff should precede the prompt"); + assert!( + !run.stdout.contains("+++"), + "the prompt preview must stay off stdout, got {:?}", + run.stdout + ); + assert!( + run.stdout.contains("Wrote 1 slot(s)"), + "should report the write on stdout, got {:?}", + run.stdout + ); + } + + #[test] + fn update_slots_prompt_accepts_only_an_explicit_yes() { + for answer in ["y\n", "Y\n", "yes\n", "YES\n", " yes \n", "yes"] { + let temp = TempDir::new().expect("should create temp dir"); + let (config_path, original) = write_changing_config(&temp); + let mut input = io::Cursor::new(answer.as_bytes().to_vec()); + + let run = run_write_gate(&config_path, &interactive_gate(), &mut input); + + run.result.expect("an accepted update should succeed"); + assert_ne!( + read_config(&config_path), + original, + "answer {answer:?} should apply the update" + ); + } + for answer in [ + "\n", + "n\n", + "no\n", + "yess\n", + "yes please\n", + "ye\n", + "\u{feff}yes\n", + ] { + let temp = TempDir::new().expect("should create temp dir"); + let (config_path, original) = write_changing_config(&temp); + let mut input = io::Cursor::new(answer.as_bytes().to_vec()); + + let run = run_write_gate(&config_path, &interactive_gate(), &mut input); + + run.result.expect("a declined update is not an error"); + assert_eq!( + read_config(&config_path), + original, + "answer {answer:?} should leave the config untouched" + ); + } + } + + #[test] + fn update_slots_prompt_decline_leaves_config_untouched() { + let temp = TempDir::new().expect("should create temp dir"); + let (config_path, original) = write_changing_config(&temp); + let mut input = io::Cursor::new(b"no\n".to_vec()); + + let run = run_write_gate(&config_path, &interactive_gate(), &mut input); + + run.result.expect("a declined update is not an error"); + assert_eq!( + read_config(&config_path), + original, + "declining should leave the config byte-for-byte unchanged" + ); + assert!( + run.stderr.contains(APPLY_PROMPT), + "should have asked, got {:?}", + run.stderr + ); + assert!( + run.stderr.contains("left unchanged"), + "should say the config was left alone, got {:?}", + run.stderr + ); + assert!( + !run.stdout.contains("Wrote"), + "a declined update must not report a write, got {:?}", + run.stdout + ); + let entries = fs::read_dir(temp.path()) + .expect("should read temp dir") + .count(); + assert_eq!(entries, 1, "declining should leave no temp file behind"); + } + + #[test] + fn update_slots_prompt_eof_declines() { + let temp = TempDir::new().expect("should create temp dir"); + let (config_path, original) = write_changing_config(&temp); + + let run = run_write_gate(&config_path, &interactive_gate(), &mut io::empty()); + + run.result + .expect("EOF at the prompt is a decline, not an error"); + assert_eq!( + read_config(&config_path), + original, + "EOF should leave the config untouched" + ); + assert!( + run.stderr.contains("left unchanged"), + "should say the config was left alone, got {:?}", + run.stderr + ); + } + + #[test] + fn update_slots_without_a_terminal_refuses_unless_preapproved() { + let temp = TempDir::new().expect("should create temp dir"); + let (config_path, original) = write_changing_config(&temp); + let mut input = io::Cursor::new(b"yes\n".to_vec()); + let gate = WriteGate { + interactive: false, + ..interactive_gate() + }; + + let run = run_write_gate(&config_path, &gate, &mut input); + + let error = run + .result + .expect_err("a non-interactive run without --yes should fail"); + let message = format!("{error:?}"); + assert!( + message.contains("--yes") && message.contains("--dry-run"), + "the refusal should name both ways forward, got {message}" + ); + assert_eq!( + read_config(&config_path), + original, + "a refused run should leave the config untouched" + ); + assert_eq!( + input.position(), + 0, + "a non-interactive run must not read an answer from piped input" + ); + assert!( + !run.stderr.contains(APPLY_PROMPT), + "a non-interactive run must not prompt, got {:?}", + run.stderr + ); + assert!( + run.stderr.contains("+++ generated creative opportunities"), + "the refused changes should still be previewed on stderr, got {:?}", + run.stderr + ); + } + + #[test] + fn update_slots_yes_writes_without_prompting() { + for interactive in [false, true] { + let temp = TempDir::new().expect("should create temp dir"); + let (config_path, original) = write_changing_config(&temp); + let mut input = io::Cursor::new(b"no\n".to_vec()); + let gate = WriteGate { + yes: true, + interactive, + ..interactive_gate() + }; + + let run = run_write_gate(&config_path, &gate, &mut input); + + run.result.expect("--yes should apply the update"); + assert_ne!( + read_config(&config_path), + original, + "--yes should write the config (interactive: {interactive})" + ); + assert_eq!( + input.position(), + 0, + "--yes must not read an answer (interactive: {interactive})" + ); + assert!( + !run.stderr.contains(APPLY_PROMPT), + "--yes must not prompt (interactive: {interactive}), got {:?}", + run.stderr + ); + } + } + + #[test] + fn update_slots_replace_warns_before_the_prompt() { + let temp = TempDir::new().expect("should create temp dir"); + let (config_path, original) = write_config_with_header_and_sidebar(&temp); + let existing = crate::commands::audit::creative_config(&original, &config_path) + .expect("should parse config") + .expect("should have creative opportunities"); + let mut input = io::Cursor::new(b"n\n".to_vec()); + let gate = WriteGate { + existing_creative: Some(&existing), + replace: true, + ..interactive_gate() + }; + + let run = run_write_gate(&config_path, &gate, &mut input); + + run.result.expect("a declined replace is not an error"); + assert_eq!( + read_config(&config_path), + original, + "a declined replace must keep every configured slot" + ); + let warning_at = run + .stderr + .find("--replace discards") + .expect("should warn about what --replace discards"); + let prompt_at = run + .stderr + .find(APPLY_PROMPT) + .expect("should still ask before writing"); + assert!( + warning_at < prompt_at, + "the --replace warning should precede the prompt, got {:?}", + run.stderr + ); + assert!( + run.stderr.contains("hand-written"), + "the warning should say hand-written fields are lost, got {:?}", + run.stderr + ); + assert!( + run.stderr + .lines() + .any(|line| line.starts_with('-') && line.contains("sidebar")), + "the preview should show the pruned slot as removed, got {:?}", + run.stderr + ); + } + + #[test] + fn update_slots_without_replace_does_not_warn_about_replace() { + let temp = TempDir::new().expect("should create temp dir"); + let (config_path, _) = write_changing_config(&temp); + + let run = run_write_gate(&config_path, &interactive_gate(), &mut io::empty()); + + run.result.expect("a declined update is not an error"); + assert!( + !run.stderr.contains("--replace discards"), + "a merge must not carry the --replace warning, got {:?}", + run.stderr + ); + } + + #[test] + fn update_slots_unchanged_candidate_neither_prompts_nor_writes() { + for interactive in [false, true] { + let temp = TempDir::new().expect("should create temp dir"); + let (config_path, original) = write_config_with_header_and_sidebar(&temp); + let existing = crate::commands::audit::creative_config(&original, &config_path) + .expect("should parse config") + .expect("should have creative opportunities"); + let modified_before = fs::metadata(&config_path) + .and_then(|metadata| metadata.modified()) + .expect("should read config mtime"); + let mut input = io::Cursor::new(b"yes\n".to_vec()); + let gate = WriteGate { + existing_creative: Some(&existing), + interactive, + ..interactive_gate() + }; + + let run = run_write_gate(&config_path, &gate, &mut input); + + run.result + .expect("an unchanged candidate should succeed without --yes"); + assert_eq!( + read_config(&config_path), + original, + "an unchanged candidate must not rewrite the config" + ); + assert_eq!( + fs::metadata(&config_path) + .and_then(|metadata| metadata.modified()) + .expect("should read config mtime"), + modified_before, + "an unchanged candidate must not replace the file" + ); + assert_eq!(input.position(), 0, "nothing to apply means no prompt"); + assert!( + run.stderr + .contains("No managed creative-opportunity changes"), + "should report that there is nothing to apply, got {:?}", + run.stderr + ); + assert!( + !run.stdout.contains("Wrote"), + "should not report a write, got {:?}", + run.stdout + ); + } + } + + #[test] + fn update_slots_dry_run_never_prompts() { + let temp = TempDir::new().expect("should create temp dir"); + let (config_path, original) = write_changing_config(&temp); + let mut input = io::Cursor::new(b"yes\n".to_vec()); + let gate = WriteGate { + dry_run: true, + ..interactive_gate() + }; + + let run = run_write_gate(&config_path, &gate, &mut input); + + run.result.expect("dry run should succeed"); + assert_eq!( + read_config(&config_path), + original, + "dry run must stay read-only" + ); + assert_eq!(input.position(), 0, "dry run must not read an answer"); + assert!( + !run.stderr.contains(APPLY_PROMPT), + "dry run must not prompt, got {:?}", + run.stderr + ); + assert!( + run.stdout + .starts_with("--- configured creative opportunities\n"), + "dry run should keep its stdout diff, got {:?}", + run.stdout + ); + } + + /// Input that edits the config while the operator is "reading" the prompt, + /// then answers yes. + struct EditingAnswer { + config_path: std::path::PathBuf, + replacement: String, + answer: io::Cursor>, + edited: bool, + } + + impl io::Read for EditingAnswer { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + let available = self.fill_buf()?; + let count = available.len().min(buffer.len()); + buffer[..count].copy_from_slice(&available[..count]); + self.consume(count); + Ok(count) + } + } + + impl BufRead for EditingAnswer { + fn fill_buf(&mut self) -> io::Result<&[u8]> { + if !self.edited { + self.edited = true; + fs::write(&self.config_path, &self.replacement)?; + } + self.answer.fill_buf() + } + + fn consume(&mut self, amount: usize) { + self.answer.consume(amount); + } + } + + #[test] + fn update_slots_refuses_to_overwrite_a_config_changed_during_the_prompt() { + let temp = TempDir::new().expect("should create temp dir"); + let (config_path, original) = write_changing_config(&temp); + let replacement = format!("{original}\n# edited while the prompt was open\n"); + let mut input = EditingAnswer { + config_path: config_path.clone(), + replacement: replacement.clone(), + answer: io::Cursor::new(b"yes\n".to_vec()), + edited: false, + }; + + let run = run_write_gate(&config_path, &interactive_gate(), &mut input); + + assert!(input.edited, "the prompt should have read an answer"); + let error = run + .result + .expect_err("an update confirmed against a stale file should be refused"); + assert!( + format!("{error:?}").contains("changed"), + "the refusal should say the file changed, got {error:?}" + ); + assert_eq!( + read_config(&config_path), + replacement, + "the edit made during the prompt must not be overwritten" + ); + } } diff --git a/crates/trusted-server-cli/src/commands/audit/mod.rs b/crates/trusted-server-cli/src/commands/audit/mod.rs index 6da0171b96..812449bb27 100644 --- a/crates/trusted-server-cli/src/commands/audit/mod.rs +++ b/crates/trusted-server-cli/src/commands/audit/mod.rs @@ -12,6 +12,8 @@ pub mod collector; pub mod generate; pub mod page; +use std::io::IsTerminal as _; + use clap::{Args, Subcommand}; use crate::app_config::AppConfigArgs; @@ -208,6 +210,13 @@ pub(crate) struct AuditAdTemplatesGenerateArgs { /// Preview the updated config on stdout instead of writing it. #[arg(long)] pub dry_run: bool, + /// Apply changes to the config without asking for confirmation. + /// + /// Without it, a run that would change the config shows the target path + /// and the managed-field diff, then waits for an explicit `yes`. Required + /// when stdin or stderr is not a terminal, so automation must opt in. + #[arg(long, conflicts_with = "dry_run")] + pub yes: bool, /// Perform a deterministic scroll pass after each page initially settles. #[arg(long)] pub scroll: bool, @@ -356,6 +365,11 @@ pub(crate) fn run_audit(args: &AuditArgs) -> Result { let mut out = stdout.lock(); let stderr = std::io::stderr(); let mut err = stderr.lock(); + let stdin = std::io::stdin(); + // The prompt is written to stderr and answered on stdin, so both + // must reach a person for an answer to mean anything. + let interactive = stdin.is_terminal() && stderr.is_terminal(); + let mut input = stdin.lock(); generate::run_update_slots( &generate::UpdateSlotsRequest { url: gen_args.url.as_str(), @@ -365,12 +379,15 @@ pub(crate) fn run_audit(args: &AuditArgs) -> Result { replace: gen_args.replace, cookies: &gen_args.cookies, dry_run: gen_args.dry_run, + yes: gen_args.yes, + interactive, scroll: gen_args.scroll, budget: gen_args.budget(), }, &selected, &mut out, &mut err, + &mut input, ) .map(|()| RunOutcome::Success) } diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs index 1639fd3cbd..fbe1f45b72 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -984,6 +984,48 @@ mod tests { assert_eq!(generate.browser.settle_quiet_ms, 750); assert_eq!(generate.browser.settle_max_ms, 12_000); assert!(!generate.scroll, "generation should not scroll by default"); + assert!( + !generate.yes, + "generation should ask before writing unless --yes is passed" + ); + } + + #[test] + fn audit_ad_templates_generate_parses_yes() { + let args = parse(&[ + "ts", + "audit", + "ad-templates", + "generate", + "https://www.example.com/", + "--yes", + ]); + let Command::Audit(audit) = args.command else { + panic!("expected audit command"); + }; + let Some(crate::commands::audit::AuditSubcommand::AdTemplates( + crate::commands::audit::AuditAdTemplatesCommand::Generate(generate), + )) = audit.command + else { + panic!("expected audit ad-templates generate command"); + }; + + assert!(generate.yes, "--yes should pre-approve the config write"); + assert!(!generate.dry_run, "--yes should not imply --dry-run"); + } + + #[test] + fn audit_ad_templates_generate_rejects_dry_run_with_yes() { + Args::try_parse_from([ + "ts", + "audit", + "ad-templates", + "generate", + "https://www.example.com/", + "--dry-run", + "--yes", + ]) + .expect_err("should reject conflicting --dry-run and --yes"); } #[test] diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 9466e1f3df..5054838b88 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -369,7 +369,8 @@ generation. New automation should use `ts audit generate `. `ts audit ad-templates generate ` discovers the publisher's ad slots and rewrites the `[creative_opportunities]` slot array in `trusted-server.toml` in -place, preserving every other section and comment. +place, preserving every other section and comment. It shows the change and asks +before writing; see [Confirming the write](#confirming-the-write). ```bash ts audit ad-templates generate https://publisher.example/ @@ -386,7 +387,7 @@ not its catalogue: 3. Load those pages, recording each slot's div, sizes, and GAM ad-unit path. 4. Reconcile every slot across the pages it appeared on. 5. Infer a `{section}` ad-unit template if the evidence proves one. -6. Verify the result loads, then write it. +6. Verify the result loads, show the change, and write it once confirmed. ### What it writes @@ -443,6 +444,42 @@ origin, userinfo, query, or fragment, and there is no flag to suppress it. A `--dry-run` that changes nothing says so on stderr too, leaving stdout an empty diff. +### Confirming the write + +A run that would change the config never writes it silently. After the crawl +and the load check, the command prints the target path and the same +managed-field diff that `--dry-run` prints, then asks: + +```text +Apply these changes to trusted-server.toml? [y/N] +``` + +Only `y` or `yes` (any case) applies the change. Anything else — an empty line, +`n`, or end of input — leaves the file untouched and exits successfully. With +`--replace`, a warning that unobserved slots and hand-written slot fields will +be discarded appears before the question. The preview, warning, and prompt all +go to stderr, so stdout carries only the final `Wrote ...` line. + +| Situation | Result | +| ----------------------------------------------- | ------------------------------------------------------------ | +| Managed fields unchanged | Says so on stderr; no prompt, and the file is not rewritten. | +| `--dry-run` | Prints the diff on stdout; never prompts or writes. | +| stdin and stderr are terminals | Previews the diff and asks; the default answer is no. | +| stdin or stderr is not a terminal, no `--yes` | Previews the diff on stderr, then fails without writing. | +| `--yes` | Previews the diff on stderr and writes without asking. | +| The file changed during the crawl or the prompt | Refuses to overwrite it; re-run against the current file. | + +`--yes` is the opt-in for automation and cannot be combined with `--dry-run`. +A useful pattern is to review a `--dry-run` first, then apply it: + +```bash +ts audit ad-templates generate https://publisher.example/ --dry-run +ts audit ad-templates generate https://publisher.example/ --yes +``` + +The second run crawls again, so its result can differ from the preview when the +live site changes in between; its own stderr preview shows what was written. + ### Bounding and steering the crawl ```bash @@ -462,6 +499,9 @@ ts audit ad-templates generate https://publisher.example/ \ # Preview without writing. ts audit ad-templates generate https://publisher.example/ --dry-run + +# Apply without the confirmation prompt (CI, scripts). +ts audit ad-templates generate https://publisher.example/ --yes ``` Re-running merges into the existing slots: a slot seen again keeps its @@ -475,7 +515,8 @@ targeting, or provider settings, a stderr note names the split because the new slot does not inherit them. A prefix that does claim several discovered divs is named in a stderr note, because the runtime resolves a prefix to at most one element. `--replace` discards existing slots instead, -which also discards any template you wrote by hand. +which also discards any template you wrote by hand; the confirmation prompt +warns about this before asking. `--scroll` performs the same deterministic stepped scroll on every page and device profile after the initial settle, then waits for the page to settle again From a7f6517aecbc62c2e6ebcc3a18531fe889988850 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Mon, 19 Oct 2026 19:15:56 +0530 Subject: [PATCH 2/5] Harden the ad-template write confirmation A declined or unanswerable confirmation now exits non-zero so chained steps do not run against a config that was not regenerated. The preview escapes page-supplied control and bidi-override characters, a non-UTF-8 answer counts as a decline, the section-template deploy note is printed only once such a config is written or previewed, and stdin is locked per read instead of for the whole crawl. Signed-off-by: dhruv8sh --- .../src/commands/audit/generate/mod.rs | 273 ++++++++++++++---- .../src/commands/audit/mod.rs | 4 +- docs/guide/cli.md | 31 +- 3 files changed, 231 insertions(+), 77 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs index a4aa110495..7651c200d8 100644 --- a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs +++ b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs @@ -1130,16 +1130,11 @@ pub(crate) fn run_update_slots( notes.extend(validate::check_candidate(&updated, &existing)?); emit_notes(err, &mut notes)?; - if policy.is_some() { - writeln!( - err, - "note: this config now uses a {{section}} ad-unit template. Deploy a \ - template-aware binary BEFORE pushing it, and do not roll that binary \ - back while this config is live — an older binary rejects the whole \ - config and serves an error on every route." - ) - .map_err(|error| report_error(format!("failed to write command output: {error}")))?; - } + // The deploy-order note describes the config as written, so it is held + // back until the candidate is the config (or is only being previewed); + // a declined or refused write must not tell the operator to roll out a + // template-aware binary for a file that did not change. + let uses_template = policy.is_some(); let old_managed = managed_creative_projection(&existing)?; let new_managed = managed_creative_projection(&updated)?; @@ -1158,10 +1153,11 @@ pub(crate) fn run_update_slots( }; writeln!(err, "{message}") .map_err(|error| report_error(format!("failed to write preview output: {error}")))?; - return Ok(()); + return emit_template_note(err, uses_template); } let diff = managed_diff(&old_managed, &new_managed); if request.dry_run { + emit_template_note(err, uses_template)?; writeln!(out, "{diff}") .map_err(|error| report_error(format!("failed to write preview diff: {error}")))?; return Ok(()); @@ -1169,9 +1165,7 @@ pub(crate) fn run_update_slots( // Checked before asking too, so the operator is not asked to approve a // write that is already certain to be refused. ensure_config_unchanged(request.config_path, &existing)?; - if !confirm_config_write(request, &diff, err, input)? { - return Ok(()); - } + confirm_config_write(request, &diff, err, input)?; ensure_config_unchanged(request.config_path, &existing)?; // A writer could still land between this check and the rename below. That // window is microseconds against a browser crawl's minutes, and the rename @@ -1184,6 +1178,7 @@ pub(crate) fn run_update_slots( request.config_path.display() )) })?; + emit_template_note(err, uses_template)?; writeln!( out, "Wrote {} slot(s) to {} ({} slot(s) seen across {} page(s))", @@ -1195,6 +1190,26 @@ pub(crate) fn run_update_slots( .map_err(|error| report_error(format!("failed to write command output: {error}"))) } +/// Warns that a `{section}` template config needs a template-aware binary +/// deployed first, when `uses_template` is set. +/// +/// # Errors +/// +/// Returns an error when the note cannot be written. +fn emit_template_note(err: &mut dyn Write, uses_template: bool) -> CliResult<()> { + if !uses_template { + return Ok(()); + } + writeln!( + err, + "note: this config now uses a {{section}} ad-unit template. Deploy a \ + template-aware binary BEFORE pushing it, and do not roll that binary \ + back while this config is live — an older binary rejects the whole \ + config and serves an error on every route." + ) + .map_err(|error| report_error(format!("failed to write command output: {error}"))) +} + /// Renders the zero-context unified diff between two managed projections. fn managed_diff(old_managed: &str, new_managed: &str) -> String { similar::TextDiff::from_lines(old_managed, new_managed) @@ -1229,30 +1244,43 @@ fn ensure_config_unchanged(config_path: &Path, existing: &str) -> CliResult<()> Ok(()) } -/// Previews a changed candidate on `err` and decides whether to write it. +/// Previews a changed candidate on `err` and requires approval to write it. /// /// `--yes` approves without reading `input`. Otherwise an operator at a /// terminal must answer `y` or `yes`; any other answer, including an empty -/// line or EOF, declines. Without a terminal the run fails instead of guessing. +/// line, bytes that are not UTF-8, or EOF, declines. Without a terminal the run +/// fails instead of guessing. /// /// # Errors /// -/// Returns an error when the run is not interactive and `--yes` was not given, -/// or when the preview or the answer cannot be written or read. +/// Returns an error when the operator declines, when the run is not interactive +/// and `--yes` was not given, or when the preview or the answer cannot be +/// written or read. fn confirm_config_write( request: &UpdateSlotsRequest<'_>, diff: &str, err: &mut dyn Write, input: &mut dyn BufRead, -) -> CliResult { +) -> CliResult<()> { let path = request.config_path.display(); let write_error = |error: std::io::Error| report_error(format!("failed to write confirmation: {error}")); writeln!( err, - "Changes to managed creative-opportunity fields in {path}:\n{diff}" + "Changes to managed creative-opportunity fields in {path}:" ) .map_err(write_error)?; + // Slot ids, div ids and unit paths come from the audited page. Escaped + // per line (the helper would also escape the newlines), so bidi overrides + // and control sequences cannot reorder or hide what is being approved. + for line in diff.lines() { + writeln!( + err, + "{}", + crate::ad_templates::output::escape_terminal_text(line) + ) + .map_err(write_error)?; + } if request.replace { writeln!( err, @@ -1262,7 +1290,7 @@ fn confirm_config_write( .map_err(write_error)?; } if request.yes { - return Ok(true); + return Ok(()); } if !request.interactive { return cli_error(format!( @@ -1271,15 +1299,17 @@ fn confirm_config_write( } write!(err, "Apply these changes to {path}? [y/N] ").map_err(write_error)?; err.flush().map_err(write_error)?; - let mut answer = String::new(); + // Bytes rather than a `String`, so an answer that is not UTF-8 is a + // decline instead of a read failure. + let mut answer = Vec::new(); input - .read_line(&mut answer) + .read_until(b'\n', &mut answer) .map_err(|error| report_error(format!("failed to read confirmation: {error}")))?; - let approved = matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes"); - if !approved { - writeln!(err, "Not applied; {path} left unchanged.").map_err(write_error)?; + let answer = answer.trim_ascii().to_ascii_lowercase(); + if answer != b"y" && answer != b"yes" { + return cli_error(format!("not applied; {path} left unchanged")); } - Ok(approved) + Ok(()) } /// Renders only fields managed by ad-template generation, excluding secrets and @@ -3959,17 +3989,11 @@ mod tests { ) } - #[test] - fn a_crawl_writes_a_section_template_and_per_section_patterns() { - // The end-to-end payoff: crawl sections, reconcile the slot across them, - // infer `{section}`, and write a config the runtime loads. - let temp = TempDir::new().expect("should create temp dir"); - let config_path = temp.path().join("trusted-server.toml"); - let original = loadable_config(); - fs::write(&config_path, &original).expect("should write config"); - + /// A three-page site whose header ad unit tracks the section, so the crawl + /// infers a `{section}` template. + fn templated_site_collector() -> SiteCollector { let nav = ["/news", "/deals"]; - let collector = SiteCollector::new(vec![ + SiteCollector::new(vec![ ( "https://publisher.example/", site_page( @@ -3994,7 +4018,19 @@ mod tests { &nav, ), ), - ]); + ]) + } + + #[test] + fn a_crawl_writes_a_section_template_and_per_section_patterns() { + // The end-to-end payoff: crawl sections, reconcile the slot across them, + // infer `{section}`, and write a config the runtime loads. + let temp = TempDir::new().expect("should create temp dir"); + let config_path = temp.path().join("trusted-server.toml"); + let original = loadable_config(); + fs::write(&config_path, &original).expect("should write config"); + + let collector = templated_site_collector(); let mut out = Vec::new(); let mut err = Vec::new(); @@ -4590,8 +4626,8 @@ mod tests { ); WriteGateRun { result, - stdout: String::from_utf8(out).expect("stdout should be UTF-8"), - stderr: String::from_utf8(err).expect("stderr should be UTF-8"), + stdout: String::from_utf8(out).expect("should decode stdout as UTF-8"), + stderr: String::from_utf8(err).expect("should decode stderr as UTF-8"), } } @@ -4638,6 +4674,18 @@ mod tests { (config_path, original) } + /// Asserts the run declined: a non-zero outcome that says the file was kept. + fn assert_declined(run: &WriteGateRun, case: &str) { + let error = run + .result + .as_ref() + .expect_err(&format!("should fail a declined update ({case})")); + assert!( + format!("{error:?}").contains("left unchanged"), + "a decline ({case}) should say the config was left alone, got {error:?}" + ); + } + fn read_config(config_path: &Path) -> String { fs::read_to_string(config_path).expect("should read config") } @@ -4650,7 +4698,7 @@ mod tests { let run = run_write_gate(&config_path, &interactive_gate(), &mut input); - run.result.expect("a confirmed update should succeed"); + run.result.expect("should apply a confirmed update"); let written = read_config(&config_path); assert_ne!( written, original, @@ -4702,7 +4750,7 @@ mod tests { let run = run_write_gate(&config_path, &interactive_gate(), &mut input); - run.result.expect("an accepted update should succeed"); + run.result.expect("should apply an accepted update"); assert_ne!( read_config(&config_path), original, @@ -4724,7 +4772,7 @@ mod tests { let run = run_write_gate(&config_path, &interactive_gate(), &mut input); - run.result.expect("a declined update is not an error"); + assert_declined(&run, "a declined update"); assert_eq!( read_config(&config_path), original, @@ -4741,7 +4789,7 @@ mod tests { let run = run_write_gate(&config_path, &interactive_gate(), &mut input); - run.result.expect("a declined update is not an error"); + assert_declined(&run, "a declined update"); assert_eq!( read_config(&config_path), original, @@ -4752,11 +4800,6 @@ mod tests { "should have asked, got {:?}", run.stderr ); - assert!( - run.stderr.contains("left unchanged"), - "should say the config was left alone, got {:?}", - run.stderr - ); assert!( !run.stdout.contains("Wrote"), "a declined update must not report a write, got {:?}", @@ -4775,18 +4818,12 @@ mod tests { let run = run_write_gate(&config_path, &interactive_gate(), &mut io::empty()); - run.result - .expect("EOF at the prompt is a decline, not an error"); + assert_declined(&run, "EOF"); assert_eq!( read_config(&config_path), original, "EOF should leave the config untouched" ); - assert!( - run.stderr.contains("left unchanged"), - "should say the config was left alone, got {:?}", - run.stderr - ); } #[test] @@ -4803,7 +4840,7 @@ mod tests { let error = run .result - .expect_err("a non-interactive run without --yes should fail"); + .expect_err("should refuse a non-interactive run without --yes"); let message = format!("{error:?}"); assert!( message.contains("--yes") && message.contains("--dry-run"), @@ -4845,7 +4882,7 @@ mod tests { let run = run_write_gate(&config_path, &gate, &mut input); - run.result.expect("--yes should apply the update"); + run.result.expect("should apply the update with --yes"); assert_ne!( read_config(&config_path), original, @@ -4880,7 +4917,7 @@ mod tests { let run = run_write_gate(&config_path, &gate, &mut input); - run.result.expect("a declined replace is not an error"); + assert_declined(&run, "a declined --replace"); assert_eq!( read_config(&config_path), original, @@ -4920,7 +4957,7 @@ mod tests { let run = run_write_gate(&config_path, &interactive_gate(), &mut io::empty()); - run.result.expect("a declined update is not an error"); + assert_declined(&run, "a declined update"); assert!( !run.stderr.contains("--replace discards"), "a merge must not carry the --replace warning, got {:?}", @@ -4949,7 +4986,7 @@ mod tests { let run = run_write_gate(&config_path, &gate, &mut input); run.result - .expect("an unchanged candidate should succeed without --yes"); + .expect("should succeed for an unchanged candidate without --yes"); assert_eq!( read_config(&config_path), original, @@ -4989,7 +5026,7 @@ mod tests { let run = run_write_gate(&config_path, &gate, &mut input); - run.result.expect("dry run should succeed"); + run.result.expect("should render the dry run"); assert_eq!( read_config(&config_path), original, @@ -5059,7 +5096,7 @@ mod tests { assert!(input.edited, "the prompt should have read an answer"); let error = run .result - .expect_err("an update confirmed against a stale file should be refused"); + .expect_err("should refuse an update confirmed against a stale file"); assert!( format!("{error:?}").contains("changed"), "the refusal should say the file changed, got {error:?}" @@ -5070,4 +5107,114 @@ mod tests { "the edit made during the prompt must not be overwritten" ); } + + #[test] + fn update_slots_prompt_treats_a_non_utf8_answer_as_a_decline() { + let temp = TempDir::new().expect("should create temp dir"); + let (config_path, original) = write_changing_config(&temp); + let mut input = io::Cursor::new(b"\xffyes\n".to_vec()); + + let run = run_write_gate(&config_path, &interactive_gate(), &mut input); + + assert_declined(&run, "a non-UTF-8 answer"); + assert_eq!( + read_config(&config_path), + original, + "a non-UTF-8 answer should leave the config untouched" + ); + } + + #[test] + fn update_slots_prompt_escapes_page_controlled_text_in_the_preview() { + let temp = TempDir::new().expect("should create temp dir"); + let (config_path, original) = write_changing_config(&temp); + let mut collected = collected_page_with_header_slot(); + collected.gpt_slots[0].div_id = "div-gpt-ad-\u{202e}redaeh".to_string(); + let collector = FakeCollector::new(collected); + let mut err = Vec::new(); + + let result = run_update_slots( + &UpdateSlotsRequest { + url: "https://publisher.example/", + config_path: &config_path, + existing_creative: None, + page_patterns: &[], + replace: false, + cookies: &[], + dry_run: false, + yes: false, + interactive: true, + scroll: false, + budget: CrawlBudget::default(), + }, + &[("desktop", &collector)], + &mut io::sink(), + &mut err, + &mut io::empty(), + ); + + let stderr = String::from_utf8(err).expect("should decode stderr as UTF-8"); + assert!( + stderr.contains("+++ generated creative opportunities"), + "the run should reach the preview, got {result:?} with {stderr:?}" + ); + assert!( + !stderr.contains('\u{202e}'), + "a page-supplied bidi override must not reach the terminal, got {stderr:?}" + ); + assert!( + stderr.contains("\\u{202E}"), + "the override should be shown escaped, got {stderr:?}" + ); + assert_eq!( + read_config(&config_path), + original, + "EOF at the prompt should leave the config untouched" + ); + } + + #[test] + fn update_slots_declined_template_omits_the_deploy_order_note() { + let temp = TempDir::new().expect("should create temp dir"); + let (config_path, original) = write_changing_config(&temp); + let collector = templated_site_collector(); + let mut input = io::Cursor::new(b"n\n".to_vec()); + let mut err = Vec::new(); + + let result = run_update_slots( + &UpdateSlotsRequest { + url: "https://publisher.example/", + config_path: &config_path, + existing_creative: None, + page_patterns: &[], + replace: false, + cookies: &[], + dry_run: false, + yes: false, + interactive: true, + scroll: false, + budget: CrawlBudget::default(), + }, + &[("desktop", &collector)], + &mut io::sink(), + &mut err, + &mut input, + ); + + let stderr = String::from_utf8(err).expect("should decode stderr as UTF-8"); + result.expect_err("should fail a declined update"); + assert!( + stderr.contains("{section}"), + "the preview should show the templated candidate, got {stderr:?}" + ); + assert!( + !stderr.contains("template-aware binary"), + "a declined write must not claim the config now uses a template, got {stderr:?}" + ); + assert_eq!( + read_config(&config_path), + original, + "a declined template should leave the config untouched" + ); + } } diff --git a/crates/trusted-server-cli/src/commands/audit/mod.rs b/crates/trusted-server-cli/src/commands/audit/mod.rs index 812449bb27..465a31a673 100644 --- a/crates/trusted-server-cli/src/commands/audit/mod.rs +++ b/crates/trusted-server-cli/src/commands/audit/mod.rs @@ -369,7 +369,9 @@ pub(crate) fn run_audit(args: &AuditArgs) -> Result { // The prompt is written to stderr and answered on stdin, so both // must reach a person for an answer to mean anything. let interactive = stdin.is_terminal() && stderr.is_terminal(); - let mut input = stdin.lock(); + // Locks stdin per read rather than for the whole crawl; only the + // confirmation prompt reads it. + let mut input = std::io::BufReader::new(stdin); generate::run_update_slots( &generate::UpdateSlotsRequest { url: gen_args.url.as_str(), diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 5054838b88..42dae7f721 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -455,19 +455,24 @@ Apply these changes to trusted-server.toml? [y/N] ``` Only `y` or `yes` (any case) applies the change. Anything else — an empty line, -`n`, or end of input — leaves the file untouched and exits successfully. With -`--replace`, a warning that unobserved slots and hand-written slot fields will -be discarded appears before the question. The preview, warning, and prompt all -go to stderr, so stdout carries only the final `Wrote ...` line. - -| Situation | Result | -| ----------------------------------------------- | ------------------------------------------------------------ | -| Managed fields unchanged | Says so on stderr; no prompt, and the file is not rewritten. | -| `--dry-run` | Prints the diff on stdout; never prompts or writes. | -| stdin and stderr are terminals | Previews the diff and asks; the default answer is no. | -| stdin or stderr is not a terminal, no `--yes` | Previews the diff on stderr, then fails without writing. | -| `--yes` | Previews the diff on stderr and writes without asking. | -| The file changed during the crawl or the prompt | Refuses to overwrite it; re-run against the current file. | +`n`, or end of input — leaves the file untouched and exits with an error, so a +chained step such as `&& ts config push` does not run against a config that was +not regenerated. With `--replace`, a warning that unobserved slots and +hand-written slot fields will be discarded appears before the question. The +preview, warning, and prompt all go to stderr, so stdout carries only the final +`Wrote ...` line. Page-supplied text in the preview is shown with control and +bidirectional-override characters escaped, so they cannot reorder or hide lines +of the diff you are approving. The `{section}` deploy-order note is printed +only once such a config is written (or previewed with `--dry-run`). + +| Situation | Result | +| ----------------------------------------------- | ---------------------------------------------------------------------------------------- | +| Managed fields unchanged | Says so on stderr; no prompt, and the file is not rewritten. | +| `--dry-run` | Prints the diff on stdout; never prompts or writes. | +| stdin and stderr are terminals | Previews the diff and asks; the default answer is no, and a decline exits with an error. | +| stdin or stderr is not a terminal, no `--yes` | Previews the diff on stderr, then fails without writing. | +| `--yes` | Previews the diff on stderr and writes without asking. | +| The file changed during the crawl or the prompt | Refuses to overwrite it; re-run against the current file. | `--yes` is the opt-in for automation and cannot be combined with `--dry-run`. A useful pattern is to review a `--dry-run` first, then apply it: From 7a46b8e60405a22a49de0a69130a6d86a965da6a Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Mon, 19 Oct 2026 19:27:32 +0530 Subject: [PATCH 3/5] Opt the browser generation fixture into writes with --yes The browser-gated generation tests run ts without a terminal, so a non-dry-run write now needs the same explicit --yes that automation uses. Signed-off-by: dhruv8sh --- crates/trusted-server-cli/tests/audit_config_diagnostics.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-cli/tests/audit_config_diagnostics.rs b/crates/trusted-server-cli/tests/audit_config_diagnostics.rs index 87df5a447f..74e9e13167 100644 --- a/crates/trusted-server-cli/tests/audit_config_diagnostics.rs +++ b/crates/trusted-server-cli/tests/audit_config_diagnostics.rs @@ -200,9 +200,9 @@ fn generate_from_fixture( "--settle-max-ms", "1000", ]); - if dry_run { - command.arg("--dry-run"); - } + // A test run has no terminal to confirm on, so a write must opt in the way + // automation does. + command.arg(if dry_run { "--dry-run" } else { "--yes" }); command .output() .expect("should run generation against local GPT fixture") From 959dd4f40568d2f5e786e248e4e8fe527d82e567 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Mon, 26 Oct 2026 18:27:16 +0530 Subject: [PATCH 4/5] Tighten the ad-template write gate tests and warn before asking Make the stale-prompt and declined-template tests fail when the behavior they guard breaks, and add a test for the pre-prompt staleness check. Show the deploy-order warning for a {section} candidate in the preview, before the question, instead of only after the write. Add the breaking-change CHANGELOG entry, note that the --dry-run diff is printed unescaped, and update the doc comments for the confirmation flow. Signed-off-by: dhruv8sh --- CHANGELOG.md | 1 + .../src/commands/audit/generate/mod.rs | 100 ++++++++++++++++-- docs/guide/cli.md | 12 ++- 3 files changed, 102 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f691c6a005..1d00f0fdcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **Breaking:** `x-ts-version` now reports the deployed git version — the release tag, else the branch, else the first 6 characters of the commit — compiled in from the build-time `TRUSTED_SERVER__GIT_VERSION` (set by the deploy pipeline) or local git, and is sent by every adapter, including on the Fastly `GET /health` probe. The Fastly service version it previously carried is now `x-ts-fastly-version`; update dashboards, monitors, and scripts that read `x-ts-version` as the Fastly version number. Builds with neither the override nor git omit the header. +- **Breaking:** `ts audit ad-templates generate` now previews the managed-field diff on stderr and asks before writing a changed config. Runs whose stdin or stderr is not a terminal fail without writing unless `--yes` is passed, so add `--yes` to scripts and CI jobs that apply generated slots. A declined prompt exits non-zero, `--yes` cannot be combined with `--dry-run`, and a candidate whose managed fields are unchanged is no longer rewritten. - **Breaking:** `ts prebid bundle` is now `ts prebid client`, alongside the new `ts prebid server` namespace. Update scripts and runbooks to use `ts prebid client` with the same arguments. The old `bundle` spelling is no longer accepted and has no compatibility alias. - The S2S `/_ts/api/v1/batch-sync` endpoint now validates the full batch and calls the CAS-protected update path once per distinct normalized EC ID. The last valid UID wins within a group, and infrastructure failures reject the failing and each unprocessed group, so accepted and `kv_unavailable` input indexes may interleave. - **Breaking:** Auction providers and bidder routes now use the configuration-first `[auction.providers.]` and `[auction.bidders.]` maps. The removed `[auction].providers = [...]` list and removed server fields under `[integrations.prebid]` and `[integrations.aps]` are rejected even when those integrations are disabled, and `ts config push` rejects the old shape before publication. Move PBS `server_url` to provider `endpoint`, server timeout to provider `timeout_ms`, request controls and bidder-parameter overrides to the `prebid-server` `profile_config`, notification suppression to `notifications`, and each former server bidder to an `[auction.bidders.]` route. Move APS endpoint, timeout, account, inventory, debug, and creative controls to an `aps` provider and its `profile_config`. Browser Prebid settings remain under `[integrations.prebid]`; values such as timeout and debug that previously affected both browser and server behavior must now be configured for each owner. Provider endpoints must be absolute HTTPS URLs. Only bidder codes present in `[auction.bidders]` are folded into Trusted Server requests; unlisted publisher bids remain native browser demand. Provider response names now use the configured provider ID, such as `pbs-main`, instead of the legacy literal `prebid`; audit consumers that match `AuctionResponse.provider`. This schema has no mixed-version-safe deployment order: old binaries reject the maps and new binaries reject the retired fields, so activate the new binary and config blob together. Rollbacks must restore an old-schema blob together with the old binary. diff --git a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs index 7651c200d8..56bf68dc07 100644 --- a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs +++ b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs @@ -847,14 +847,18 @@ const MAX_EMPTY_PAGE_SHARE: f64 = 0.25; /// Runs `ts audit ad-templates generate`: crawl the site's sections, reconcile /// what each slot looked like across them, infer a `{section}` ad-unit template -/// where the evidence proves one, and rewrite the config's slot array in place. +/// where the evidence proves one, preview the managed-field diff, and rewrite +/// the config's slot array in place once the change is approved. /// /// # Errors /// /// Returns an error when the config cannot be read, the root page cannot be /// collected, no slots are discovered, too many pages came back empty, the /// pages disagree about the GAM network id, or the resulting config would not -/// load. +/// load. A changed candidate also fails without being written when the +/// operator declines it (including an empty line, EOF, or an answer that is +/// not UTF-8), when the run is not interactive and `--yes` was not given, or +/// when the config changed during the crawl or while the prompt was open. pub(crate) fn run_update_slots( request: &UpdateSlotsRequest<'_>, collectors: &[(&str, &dyn AuditCollector)], @@ -1165,7 +1169,7 @@ pub(crate) fn run_update_slots( // Checked before asking too, so the operator is not asked to approve a // write that is already certain to be refused. ensure_config_unchanged(request.config_path, &existing)?; - confirm_config_write(request, &diff, err, input)?; + confirm_config_write(request, &diff, uses_template, err, input)?; ensure_config_unchanged(request.config_path, &existing)?; // A writer could still land between this check and the rename below. That // window is microseconds against a browser crawl's minutes, and the rename @@ -1244,6 +1248,11 @@ fn ensure_config_unchanged(config_path: &Path, existing: &str) -> CliResult<()> Ok(()) } +/// Deploy-order warning shown in the preview of a `{section}` candidate, +/// worded for a write that has not happened yet. +const PREVIEW_TEMPLATE_NOTE: &str = "note: applying this introduces a {section} ad-unit \ + template; deploy a template-aware binary BEFORE pushing the config."; + /// Previews a changed candidate on `err` and requires approval to write it. /// /// `--yes` approves without reading `input`. Otherwise an operator at a @@ -1259,6 +1268,7 @@ fn ensure_config_unchanged(config_path: &Path, existing: &str) -> CliResult<()> fn confirm_config_write( request: &UpdateSlotsRequest<'_>, diff: &str, + uses_template: bool, err: &mut dyn Write, input: &mut dyn BufRead, ) -> CliResult<()> { @@ -1281,6 +1291,11 @@ fn confirm_config_write( ) .map_err(write_error)?; } + // Shown before the question rather than only after the write, because the + // deploy order is what the operator has to weigh before approving. + if uses_template { + writeln!(err, "{PREVIEW_TEMPLATE_NOTE}").map_err(write_error)?; + } if request.replace { writeln!( err, @@ -1313,10 +1328,12 @@ fn confirm_config_write( } /// Renders only fields managed by ad-template generation, excluding secrets and -/// unrelated operator configuration from dry-run output. +/// unrelated operator configuration from the previewed diff. fn managed_creative_projection(document: &str) -> CliResult { let value = toml::from_str::(document).map_err(|error| { - report_error(format!("failed to parse config for dry-run diff: {error}")) + report_error(format!( + "failed to parse config for the managed-field diff: {error}" + )) })?; let creative = value .get("creative_opportunities") @@ -4772,7 +4789,7 @@ mod tests { let run = run_write_gate(&config_path, &interactive_gate(), &mut input); - assert_declined(&run, "a declined update"); + assert_declined(&run, &format!("answer {answer:?}")); assert_eq!( read_config(&config_path), original, @@ -5098,7 +5115,7 @@ mod tests { .result .expect_err("should refuse an update confirmed against a stale file"); assert!( - format!("{error:?}").contains("changed"), + format!("{error:?}").contains("refusing to overwrite"), "the refusal should say the file changed, got {error:?}" ); assert_eq!( @@ -5108,6 +5125,61 @@ mod tests { ); } + #[test] + fn update_slots_does_not_ask_to_apply_a_config_changed_during_the_crawl() { + let temp = TempDir::new().expect("should create temp dir"); + let (config_path, original) = write_changing_config(&temp); + let replacement = format!("{original}\n# edited while the browser was running\n"); + let collector = MutatingCollector { + collected: collected_page_with_header_slot(), + config_path: config_path.clone(), + replacement: replacement.clone(), + }; + let mut input = io::Cursor::new(b"yes\n".to_vec()); + let mut err = Vec::new(); + + let result = run_update_slots( + &UpdateSlotsRequest { + url: "https://publisher.example/", + config_path: &config_path, + existing_creative: None, + page_patterns: &[], + replace: false, + cookies: &[], + dry_run: false, + yes: false, + interactive: true, + scroll: false, + budget: CrawlBudget::default(), + }, + &[("desktop", &collector)], + &mut io::sink(), + &mut err, + &mut input, + ); + + let stderr = String::from_utf8(err).expect("should decode stderr as UTF-8"); + let error = result.expect_err("should refuse an update built from a stale file"); + assert!( + format!("{error:?}").contains("refusing to overwrite"), + "the refusal should say the file changed, got {error:?}" + ); + assert!( + !stderr.contains(APPLY_PROMPT), + "a write already certain to be refused must not be offered, got {stderr:?}" + ); + assert_eq!( + input.position(), + 0, + "a stale file should be refused before an answer is read" + ); + assert_eq!( + read_config(&config_path), + replacement, + "the edit made during the crawl must not be overwritten" + ); + } + #[test] fn update_slots_prompt_treats_a_non_utf8_answer_as_a_decline() { let temp = TempDir::new().expect("should create temp dir"); @@ -5204,11 +5276,21 @@ mod tests { let stderr = String::from_utf8(err).expect("should decode stderr as UTF-8"); result.expect_err("should fail a declined update"); assert!( - stderr.contains("{section}"), + stderr.contains("+section_root = "), "the preview should show the templated candidate, got {stderr:?}" ); + let note = stderr + .find(PREVIEW_TEMPLATE_NOTE) + .expect("the preview should warn about deploy order before asking"); + let prompt = stderr + .find(APPLY_PROMPT) + .expect("the run should reach the prompt"); + assert!( + note < prompt, + "the deploy-order warning should precede the question, got {stderr:?}" + ); assert!( - !stderr.contains("template-aware binary"), + !stderr.contains("this config now uses"), "a declined write must not claim the config now uses a template, got {stderr:?}" ); assert_eq!( diff --git a/docs/guide/cli.md b/docs/guide/cli.md index 42dae7f721..f09d9a52ac 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -462,8 +462,11 @@ hand-written slot fields will be discarded appears before the question. The preview, warning, and prompt all go to stderr, so stdout carries only the final `Wrote ...` line. Page-supplied text in the preview is shown with control and bidirectional-override characters escaped, so they cannot reorder or hide lines -of the diff you are approving. The `{section}` deploy-order note is printed -only once such a config is written (or previewed with `--dry-run`). +of the diff you are approving. When the candidate introduces a `{section}` +template, the preview warns before the question that a template-aware binary +must be deployed before the config is pushed. The note saying the config now +uses a template is printed only once such a config is written, previewed with +`--dry-run`, or already in place. | Situation | Result | | ----------------------------------------------- | ---------------------------------------------------------------------------------------- | @@ -485,6 +488,11 @@ ts audit ad-templates generate https://publisher.example/ --yes The second run crawls again, so its result can differ from the preview when the live site changes in between; its own stderr preview shows what was written. +Unlike the prompt's preview, the `--dry-run` diff on stdout is printed verbatim +so it stays usable as a diff, and page-supplied control or bidirectional +characters are not escaped. Review it through a viewer that shows them, such as +`cat -v`, or rely on the escaped preview at the prompt. + ### Bounding and steering the crawl ```bash From 7e543b927cfe2025536f56b2cb1c75c7408a95f2 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Fri, 30 Oct 2026 11:18:17 +0530 Subject: [PATCH 5/5] Address ad-template review feedback --- .../src/commands/audit/generate/mod.rs | 15 ++++++++++----- docs/guide/cli.md | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs index f1ed5c73d2..85a3807168 100644 --- a/crates/trusted-server-cli/src/commands/audit/generate/mod.rs +++ b/crates/trusted-server-cli/src/commands/audit/generate/mod.rs @@ -1090,10 +1090,10 @@ pub(crate) fn run_update_slots( notes.extend(validate::check_candidate(&updated, &existing)?); emit_notes(err, &mut notes)?; - // The deploy-order note describes the config as written, so it is held - // back until the candidate is the config (or is only being previewed); - // a declined or refused write must not tell the operator to roll out a - // template-aware binary for a file that did not change. + // The "now uses" deploy-order note describes the config as written, so it + // is held back until the candidate is the config (or is only being + // previewed); a changed candidate's preview carries a conditionally worded + // warning instead (see `PREVIEW_TEMPLATE_NOTE`). let uses_template = policy.is_some(); let old_managed = managed_creative_projection(&existing)?; @@ -1206,7 +1206,7 @@ fn ensure_config_unchanged(config_path: &Path, existing: &str) -> CliResult<()> /// Deploy-order warning shown in the preview of a `{section}` candidate, /// worded for a write that has not happened yet. -const PREVIEW_TEMPLATE_NOTE: &str = "note: applying this introduces a {section} ad-unit \ +const PREVIEW_TEMPLATE_NOTE: &str = "note: this candidate uses a {section} ad-unit \ template; deploy a template-aware binary BEFORE pushing the config."; /// Previews a changed candidate on `err` and requires approval to write it. @@ -4754,6 +4754,11 @@ mod tests { "should report the write on stdout, got {:?}", run.stdout ); + assert!( + !run.stderr.contains("{section} ad-unit template"), + "a literal-path candidate must not carry a template deploy note, got {:?}", + run.stderr + ); } #[test] diff --git a/docs/guide/cli.md b/docs/guide/cli.md index f09d9a52ac..70227ca493 100644 --- a/docs/guide/cli.md +++ b/docs/guide/cli.md @@ -462,7 +462,7 @@ hand-written slot fields will be discarded appears before the question. The preview, warning, and prompt all go to stderr, so stdout carries only the final `Wrote ...` line. Page-supplied text in the preview is shown with control and bidirectional-override characters escaped, so they cannot reorder or hide lines -of the diff you are approving. When the candidate introduces a `{section}` +of the diff you are approving. When the candidate uses a `{section}` template, the preview warns before the question that a template-aware binary must be deployed before the config is pushed. The note saying the config now uses a template is printed only once such a config is written, previewed with