Skip to main content

packc/cli/
wizard.rs

1#![forbid(unsafe_code)]
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::env;
5use std::fs;
6use std::io::{self, BufRead, Write};
7use std::path::{Component, Path, PathBuf};
8use std::process::{Command, Output, Stdio};
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::time::{SystemTime, UNIX_EPOCH};
11
12use anyhow::{Context, Result, anyhow};
13use base64::Engine;
14use clap::{Args, Subcommand};
15use greentic_qa_lib::{WizardDriver, WizardFrontend, WizardRunConfig};
16use greentic_types::pack::extensions::capabilities::CapabilitiesExtensionV1;
17use serde::{Deserialize, Serialize};
18use serde_json::{Value, json};
19use serde_yaml_bw::{Mapping, Value as YamlValue};
20use walkdir::WalkDir;
21
22use crate::cli::add_extension::{
23    CapabilityOfferSpec, ensure_capabilities_extension, inject_capability_offer_spec,
24    inject_provider_entry_for_wizard,
25};
26use crate::cli::wizard_catalog::{
27    CatalogQuestion, CatalogQuestionKind, DEFAULT_EXTENSION_CATALOG_DOWNLOAD_URL, ExtensionCatalog,
28    ExtensionTemplate, ExtensionType, TemplatePlanStep, load_extension_catalog,
29};
30use crate::cli::wizard_i18n::{WizardI18n, detect_requested_locale};
31use crate::cli::wizard_ui;
32use crate::extensions::{CAPABILITIES_EXTENSION_KEY, DEPLOYER_EXTENSION_KEY};
33use crate::runtime::RuntimeContext;
34
35const PACK_WIZARD_ID: &str = "greentic-pack.wizard.run";
36const PACK_WIZARD_SCHEMA_ID: &str = "greentic-pack.wizard.answers";
37const PACK_WIZARD_SCHEMA_VERSION: &str = "1.0.0";
38const DEFAULT_EXTENSION_CATALOG_REF: &str =
39    "file://docs/extensions_capability_packs.catalog.v1.json";
40const LEGACY_MESSAGING_WEBCHAT_GUI_EXTENSION_ID: &str = "messaging-webchat-gui";
41static FORCED_WIZARD_SCHEMA: AtomicBool = AtomicBool::new(false);
42
43#[derive(Debug, Args, Default)]
44pub struct WizardArgs {
45    /// Load AnswerDocument JSON and run in non-interactive mode (implicit `run`)
46    #[arg(long, value_name = "FILE")]
47    pub answers: Option<PathBuf>,
48    /// Write AnswerDocument JSON after run (implicit `run`)
49    #[arg(long = "emit-answers", value_name = "FILE")]
50    pub emit_answers: Option<PathBuf>,
51    /// Pin schema version (default: 1.0.0) (implicit `run`)
52    #[arg(long = "schema-version", value_name = "VER")]
53    pub schema_version: Option<String>,
54    /// Allow migrating older AnswerDocument versions (implicit `run`)
55    #[arg(long, default_value_t = false)]
56    pub migrate: bool,
57    /// Record choices without running side effects (implicit `run`)
58    #[arg(long, default_value_t = false)]
59    pub dry_run: bool,
60    #[command(subcommand)]
61    pub command: Option<WizardCommand>,
62}
63
64#[derive(Debug, Subcommand)]
65pub enum WizardCommand {
66    /// Run wizard interactively (default when no subcommand is passed)
67    Run(WizardRunArgs),
68    /// Validate AnswerDocument input without running side effects
69    Validate(WizardValidateArgs),
70    /// Apply AnswerDocument input (doctor/build/sign side effects)
71    Apply(WizardApplyArgs),
72}
73
74#[derive(Debug, Args, Default)]
75pub struct WizardRunArgs {
76    /// Load AnswerDocument JSON and run in non-interactive mode
77    #[arg(long, value_name = "FILE")]
78    pub answers: Option<PathBuf>,
79    /// Write AnswerDocument JSON after run
80    #[arg(long = "emit-answers", value_name = "FILE")]
81    pub emit_answers: Option<PathBuf>,
82    /// Pin schema version (default: 1.0.0)
83    #[arg(long = "schema-version", value_name = "VER")]
84    pub schema_version: Option<String>,
85    /// Allow migrating older AnswerDocument versions to current target version
86    #[arg(long, default_value_t = false)]
87    pub migrate: bool,
88    /// Record choices without running side effects (for later `wizard apply --answers`)
89    #[arg(long, default_value_t = false)]
90    pub dry_run: bool,
91}
92
93#[derive(Debug, Args)]
94pub struct WizardValidateArgs {
95    /// Input AnswerDocument JSON
96    #[arg(long, value_name = "FILE")]
97    pub answers: PathBuf,
98    /// Write migrated/normalized AnswerDocument JSON
99    #[arg(long = "emit-answers", value_name = "FILE")]
100    pub emit_answers: Option<PathBuf>,
101    /// Pin schema version (default: 1.0.0)
102    #[arg(long = "schema-version", value_name = "VER")]
103    pub schema_version: Option<String>,
104    /// Allow migrating older AnswerDocument versions to current target version
105    #[arg(long, default_value_t = false)]
106    pub migrate: bool,
107}
108
109#[derive(Debug, Args)]
110pub struct WizardApplyArgs {
111    /// Input AnswerDocument JSON
112    #[arg(long, value_name = "FILE")]
113    pub answers: PathBuf,
114    /// Write migrated/normalized AnswerDocument JSON
115    #[arg(long = "emit-answers", value_name = "FILE")]
116    pub emit_answers: Option<PathBuf>,
117    /// Pin schema version (default: 1.0.0)
118    #[arg(long = "schema-version", value_name = "VER")]
119    pub schema_version: Option<String>,
120    /// Allow migrating older AnswerDocument versions to current target version
121    #[arg(long, default_value_t = false)]
122    pub migrate: bool,
123}
124
125#[derive(Clone, Copy)]
126enum MainChoice {
127    CreateApplicationPack,
128    UpdateApplicationPack,
129    CreateExtensionPack,
130    UpdateExtensionPack,
131    AddExtension,
132    Exit,
133}
134
135#[derive(Clone, Copy)]
136enum SubmenuAction {
137    Back,
138    MainMenu,
139}
140
141#[derive(Clone, Copy)]
142enum RunMode {
143    Harness,
144    Cli,
145}
146
147#[derive(Default)]
148struct WizardSession {
149    sign_key_path: Option<String>,
150    last_pack_dir: Option<PathBuf>,
151    dry_run_delegate_pack_dir: Option<PathBuf>,
152    create_pack_id: Option<String>,
153    create_pack_scaffold: bool,
154    dry_run: bool,
155    run_delegate_flow: bool,
156    run_delegate_component: bool,
157    run_doctor: bool,
158    run_build: bool,
159    flow_wizard_answers: Option<Value>,
160    component_wizard_answers: Option<Value>,
161    selected_actions: Vec<String>,
162    extension_operation: Option<ExtensionOperationRecord>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166struct ExtensionOperationRecord {
167    operation: String,
168    catalog_ref: String,
169    extension_type_id: String,
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    template_id: Option<String>,
172    #[serde(default)]
173    template_qa_answers: BTreeMap<String, String>,
174    #[serde(default)]
175    edit_answers: BTreeMap<String, String>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179struct WizardAnswerDocument {
180    wizard_id: String,
181    schema_id: String,
182    schema_version: String,
183    locale: String,
184    #[serde(default)]
185    answers: BTreeMap<String, Value>,
186    #[serde(default)]
187    locks: BTreeMap<String, Value>,
188    #[serde(skip)]
189    base_dir: PathBuf,
190}
191
192#[derive(Debug)]
193struct WizardExecutionPlan {
194    pack_dir: PathBuf,
195    pack_root: PathBuf,
196    create_pack_id: Option<String>,
197    create_pack_scaffold: bool,
198    run_delegate_flow: bool,
199    run_delegate_component: bool,
200    run_doctor: bool,
201    run_build: bool,
202    flow_wizard_answers: Option<Value>,
203    component_wizard_answers: Option<Value>,
204    sign_key_path: Option<String>,
205    extension_operation: Option<ExtensionOperationRecord>,
206    asset_staging: Vec<ResolvedAssetStagingEntry>,
207    i18n_langs: Vec<String>,
208}
209
210struct FlowSchemaContext {
211    pack_dir: Option<PathBuf>,
212    flow_wizard_answers: Option<Value>,
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
216#[serde(rename_all = "snake_case")]
217enum AssetStagingKind {
218    File,
219    Directory,
220}
221
222#[derive(Debug, Clone, Serialize, Deserialize)]
223struct AssetStagingEntry {
224    source: String,
225    destination: String,
226    kind: AssetStagingKind,
227    #[serde(default)]
228    recursive: bool,
229    #[serde(default = "default_asset_staging_overwrite")]
230    overwrite: bool,
231}
232
233#[derive(Debug)]
234struct ResolvedAssetStagingEntry {
235    source: PathBuf,
236    destination: PathBuf,
237    kind: AssetStagingKind,
238    recursive: bool,
239    overwrite: bool,
240}
241
242fn default_asset_staging_overwrite() -> bool {
243    true
244}
245
246pub(crate) fn set_forced_schema_flag(requested: bool) {
247    FORCED_WIZARD_SCHEMA.store(requested, Ordering::Relaxed);
248}
249
250fn consume_forced_schema_flag() -> bool {
251    FORCED_WIZARD_SCHEMA.swap(false, Ordering::Relaxed)
252}
253pub fn handle(
254    args: WizardArgs,
255    runtime: &RuntimeContext,
256    requested_locale: Option<&str>,
257) -> Result<()> {
258    let implicit_run_args = WizardRunArgs {
259        answers: args.answers,
260        emit_answers: args.emit_answers,
261        schema_version: args.schema_version,
262        migrate: args.migrate,
263        dry_run: args.dry_run,
264    };
265    let schema_requested = consume_forced_schema_flag();
266    match args.command {
267        None => run_interactive_command(
268            implicit_run_args,
269            runtime,
270            requested_locale,
271            schema_requested,
272        ),
273        Some(WizardCommand::Run(cmd)) => {
274            run_interactive_command(cmd, runtime, requested_locale, schema_requested)
275        }
276        Some(WizardCommand::Validate(cmd)) => run_validate_command(cmd, requested_locale),
277        Some(WizardCommand::Apply(cmd)) => run_apply_command(cmd, requested_locale),
278    }
279}
280
281pub fn run_with_io<R: BufRead, W: Write>(input: &mut R, output: &mut W) -> Result<()> {
282    run_with_mode(
283        input,
284        output,
285        detect_requested_locale().as_deref(),
286        RunMode::Harness,
287        None,
288        false,
289    )?;
290    Ok(())
291}
292
293pub fn run_with_io_and_locale<R: BufRead, W: Write>(
294    input: &mut R,
295    output: &mut W,
296    requested_locale: Option<&str>,
297) -> Result<()> {
298    run_with_mode(
299        input,
300        output,
301        requested_locale,
302        RunMode::Harness,
303        None,
304        false,
305    )?;
306    Ok(())
307}
308
309pub fn run_cli_with_io_and_locale<R: BufRead, W: Write>(
310    input: &mut R,
311    output: &mut W,
312    requested_locale: Option<&str>,
313) -> Result<()> {
314    run_with_mode(input, output, requested_locale, RunMode::Cli, None, false)?;
315    Ok(())
316}
317
318fn run_with_mode<R: BufRead, W: Write>(
319    input: &mut R,
320    output: &mut W,
321    requested_locale: Option<&str>,
322    mode: RunMode,
323    runtime: Option<&RuntimeContext>,
324    dry_run: bool,
325) -> Result<WizardSession> {
326    let i18n = WizardI18n::new(requested_locale);
327    let mut session = WizardSession {
328        dry_run,
329        ..WizardSession::default()
330    };
331
332    loop {
333        let choice = ask_main_menu(input, output, &i18n)?;
334        match choice {
335            MainChoice::CreateApplicationPack => {
336                session
337                    .selected_actions
338                    .push("main.create_application_pack".to_string());
339                match mode {
340                    RunMode::Harness => {
341                        let _ = ask_placeholder_submenu(
342                            input,
343                            output,
344                            &i18n,
345                            "wizard.create_application_pack.title",
346                        )?;
347                    }
348                    RunMode::Cli => {
349                        run_create_application_pack(input, output, &i18n, &mut session)?;
350                    }
351                }
352            }
353            MainChoice::UpdateApplicationPack => {
354                session
355                    .selected_actions
356                    .push("main.update_application_pack".to_string());
357                match mode {
358                    RunMode::Harness => {
359                        let _ = ask_placeholder_submenu(
360                            input,
361                            output,
362                            &i18n,
363                            "wizard.update_application_pack.title",
364                        )?;
365                    }
366                    RunMode::Cli => {
367                        run_update_application_pack(input, output, &i18n, &mut session)?;
368                    }
369                }
370            }
371            MainChoice::CreateExtensionPack => {
372                session
373                    .selected_actions
374                    .push("main.create_extension_pack".to_string());
375                match mode {
376                    RunMode::Harness => {
377                        let _ = ask_placeholder_submenu(
378                            input,
379                            output,
380                            &i18n,
381                            "wizard.create_extension_pack.title",
382                        )?;
383                    }
384                    RunMode::Cli => {
385                        run_create_extension_pack(input, output, &i18n, runtime, &mut session)?;
386                    }
387                }
388            }
389            MainChoice::UpdateExtensionPack => {
390                session
391                    .selected_actions
392                    .push("main.update_extension_pack".to_string());
393                match mode {
394                    RunMode::Harness => {
395                        let _ = ask_placeholder_submenu(
396                            input,
397                            output,
398                            &i18n,
399                            "wizard.update_extension_pack.title",
400                        )?;
401                    }
402                    RunMode::Cli => {
403                        run_update_extension_pack(input, output, &i18n, &mut session, runtime)?;
404                    }
405                }
406            }
407            MainChoice::AddExtension => {
408                session
409                    .selected_actions
410                    .push("main.add_extension".to_string());
411                match mode {
412                    RunMode::Harness => {
413                        let _ = ask_placeholder_submenu(
414                            input,
415                            output,
416                            &i18n,
417                            "wizard.main.option.add_extension",
418                        )?;
419                    }
420                    RunMode::Cli => {
421                        run_add_extension(input, output, &i18n, &mut session, runtime)?;
422                    }
423                }
424            }
425            MainChoice::Exit => {
426                session.selected_actions.push("main.exit".to_string());
427                return Ok(session);
428            }
429        }
430    }
431}
432
433fn run_interactive_command(
434    cmd: WizardRunArgs,
435    runtime: &RuntimeContext,
436    requested_locale: Option<&str>,
437    schema_requested: bool,
438) -> Result<()> {
439    if maybe_print_answer_schema(&cmd, schema_requested)? {
440        return Ok(());
441    }
442    let target_schema_version = target_schema_version(cmd.schema_version.as_deref())?;
443    let locale = resolved_locale(requested_locale);
444    if let Some(path) = cmd.answers.as_deref() {
445        let initial_result = (|| -> Result<()> {
446            let doc =
447                load_answer_document(path, &target_schema_version, cmd.migrate, requested_locale)?;
448            validate_answer_document(&doc)?;
449            if !cmd.dry_run {
450                apply_answer_document(&doc)?;
451            }
452            if let Some(out) = cmd.emit_answers.as_deref() {
453                write_answer_document(out, &doc)?;
454            }
455            Ok(())
456        })();
457        if initial_result.is_ok() {
458            return Ok(());
459        }
460
461        let stdin = io::stdin();
462        let stdout = io::stdout();
463        let mut input = stdin.lock();
464        let mut output = stdout.lock();
465        let i18n = WizardI18n::new(requested_locale);
466        wizard_ui::render_line(
467            &mut output,
468            &format!(
469                "{}: {}",
470                i18n.t("wizard.error.answer_document_failed"),
471                initial_result.expect_err("initial wizard answers error")
472            ),
473        )?;
474        let session = run_with_mode(
475            &mut input,
476            &mut output,
477            requested_locale,
478            RunMode::Cli,
479            Some(runtime),
480            cmd.dry_run,
481        )?;
482        if let Some(path) = cmd.emit_answers.as_deref() {
483            let doc = answer_document_from_session(&session, &locale, &target_schema_version)?;
484            write_answer_document(path, &doc)?;
485        }
486        return Ok(());
487    }
488
489    let stdin = io::stdin();
490    let stdout = io::stdout();
491    let mut input = stdin.lock();
492    let mut output = stdout.lock();
493    let session = run_with_mode(
494        &mut input,
495        &mut output,
496        requested_locale,
497        RunMode::Cli,
498        Some(runtime),
499        cmd.dry_run,
500    )?;
501    if let Some(path) = cmd.emit_answers.as_deref() {
502        let doc = answer_document_from_session(&session, &locale, &target_schema_version)?;
503        write_answer_document(path, &doc)?;
504    }
505    Ok(())
506}
507
508fn maybe_print_answer_schema(cmd: &WizardRunArgs, schema_requested: bool) -> Result<bool> {
509    if !schema_requested {
510        return Ok(false);
511    }
512    let target_schema_version = target_schema_version(cmd.schema_version.as_deref())?;
513    let flow_context = cmd.answers.as_deref().and_then(|path| {
514        load_answer_document(path, &target_schema_version, cmd.migrate, None)
515            .ok()
516            .and_then(|doc| execution_plan_from_answers(&doc.answers, &doc.base_dir).ok())
517            .map(|plan| FlowSchemaContext {
518                pack_dir: Some(plan.pack_dir),
519                flow_wizard_answers: plan.flow_wizard_answers,
520            })
521    });
522    let schema = wizard_answer_schema(&target_schema_version, flow_context.as_ref())?;
523    let stdout = io::stdout();
524    let mut output = stdout.lock();
525    serde_json::to_writer_pretty(&mut output, &schema).context("write wizard schema")?;
526    wizard_ui::render_text(&mut output, "\n").context("write wizard schema newline")?;
527    Ok(true)
528}
529fn run_validate_command(cmd: WizardValidateArgs, requested_locale: Option<&str>) -> Result<()> {
530    let target_schema_version = target_schema_version(cmd.schema_version.as_deref())?;
531    let doc = load_answer_document(
532        &cmd.answers,
533        &target_schema_version,
534        cmd.migrate,
535        requested_locale,
536    )?;
537    validate_answer_document(&doc)?;
538    if let Some(path) = cmd.emit_answers.as_deref() {
539        write_answer_document(path, &doc)?;
540    }
541    Ok(())
542}
543
544fn run_apply_command(cmd: WizardApplyArgs, requested_locale: Option<&str>) -> Result<()> {
545    let target_schema_version = target_schema_version(cmd.schema_version.as_deref())?;
546    let doc = load_answer_document(
547        &cmd.answers,
548        &target_schema_version,
549        cmd.migrate,
550        requested_locale,
551    )?;
552    validate_answer_document(&doc)?;
553    apply_answer_document(&doc)?;
554    if let Some(path) = cmd.emit_answers.as_deref() {
555        write_answer_document(path, &doc)?;
556    }
557    Ok(())
558}
559
560fn target_schema_version(schema_version: Option<&str>) -> Result<String> {
561    let version = schema_version.unwrap_or(PACK_WIZARD_SCHEMA_VERSION).trim();
562    if version.is_empty() {
563        return Err(anyhow!("schema version must not be empty"));
564    }
565    Ok(version.to_string())
566}
567
568fn resolved_locale(requested_locale: Option<&str>) -> String {
569    let i18n = WizardI18n::new(requested_locale);
570    i18n.qa_i18n_config()
571        .locale
572        .unwrap_or_else(|| "en-GB".to_string())
573}
574
575fn load_answer_document(
576    path: &Path,
577    target_schema_version: &str,
578    migrate: bool,
579    requested_locale: Option<&str>,
580) -> Result<WizardAnswerDocument> {
581    let raw = fs::read(path).with_context(|| format!("read answers file {}", path.display()))?;
582    let parsed: Value = serde_json::from_slice(&raw)
583        .with_context(|| format!("decode answers json {}", path.display()))?;
584    let base_dir = path
585        .parent()
586        .filter(|parent| !parent.as_os_str().is_empty())
587        .map(Path::to_path_buf)
588        .unwrap_or_else(|| PathBuf::from("."));
589    normalize_answer_document(
590        parsed,
591        target_schema_version,
592        migrate,
593        requested_locale,
594        base_dir,
595    )
596}
597
598fn normalize_answer_document(
599    parsed: Value,
600    target_schema_version: &str,
601    migrate: bool,
602    requested_locale: Option<&str>,
603    base_dir: PathBuf,
604) -> Result<WizardAnswerDocument> {
605    let mut obj = parsed
606        .as_object()
607        .cloned()
608        .ok_or_else(|| anyhow!("answers document root must be a JSON object"))?;
609
610    let mut wizard_id = obj
611        .remove("wizard_id")
612        .and_then(|v| v.as_str().map(ToString::to_string));
613    let mut schema_id = obj
614        .remove("schema_id")
615        .and_then(|v| v.as_str().map(ToString::to_string));
616    let mut schema_version = obj
617        .remove("schema_version")
618        .and_then(|v| v.as_str().map(ToString::to_string));
619    let locale = obj
620        .remove("locale")
621        .and_then(|v| v.as_str().map(ToString::to_string))
622        .unwrap_or_else(|| resolved_locale(requested_locale));
623
624    if wizard_id.is_none() || schema_id.is_none() || schema_version.is_none() {
625        if !migrate {
626            return Err(anyhow!(
627                "answers document missing wizard/schema identity; rerun with --migrate"
628            ));
629        }
630        wizard_id.get_or_insert_with(|| PACK_WIZARD_ID.to_string());
631        schema_id.get_or_insert_with(|| PACK_WIZARD_SCHEMA_ID.to_string());
632        schema_version.get_or_insert_with(|| PACK_WIZARD_SCHEMA_VERSION.to_string());
633    }
634
635    if schema_version.as_deref() != Some(target_schema_version) {
636        if !migrate {
637            return Err(anyhow!(
638                "answers schema_version '{}' does not match target '{}'; rerun with --migrate",
639                schema_version.as_deref().unwrap_or_default(),
640                target_schema_version
641            ));
642        }
643        schema_version = Some(target_schema_version.to_string());
644    }
645
646    let answers_value = obj.remove("answers").unwrap_or_else(|| json!({}));
647    let locks_value = obj.remove("locks").unwrap_or_else(|| json!({}));
648    let answers = json_object_to_btreemap(answers_value, "answers")?;
649    let locks = json_object_to_btreemap(locks_value, "locks")?;
650
651    Ok(WizardAnswerDocument {
652        wizard_id: wizard_id.unwrap_or_else(|| PACK_WIZARD_ID.to_string()),
653        schema_id: schema_id.unwrap_or_else(|| PACK_WIZARD_SCHEMA_ID.to_string()),
654        schema_version: schema_version.unwrap_or_else(|| target_schema_version.to_string()),
655        locale,
656        answers,
657        locks,
658        base_dir,
659    })
660}
661
662fn json_object_to_btreemap(value: Value, field: &str) -> Result<BTreeMap<String, Value>> {
663    let obj = value
664        .as_object()
665        .ok_or_else(|| anyhow!("{field} must be a JSON object"))?;
666    Ok(obj.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
667}
668
669fn write_answer_document(path: &Path, doc: &WizardAnswerDocument) -> Result<()> {
670    if let Some(parent) = path.parent()
671        && !parent.as_os_str().is_empty()
672    {
673        fs::create_dir_all(parent)
674            .with_context(|| format!("create answers output directory {}", parent.display()))?;
675    }
676    let bytes = serde_json::to_vec_pretty(doc).context("serialize answers document")?;
677    fs::write(path, bytes).with_context(|| format!("write answers file {}", path.display()))?;
678    Ok(())
679}
680
681fn answer_document_from_session(
682    session: &WizardSession,
683    locale: &str,
684    schema_version: &str,
685) -> Result<WizardAnswerDocument> {
686    let pack_dir = match session.last_pack_dir.as_deref() {
687        Some(path) => path.to_path_buf(),
688        None => std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
689    };
690    let mut answers = BTreeMap::new();
691    answers.insert(
692        "pack_dir".to_string(),
693        Value::String(pack_dir.display().to_string()),
694    );
695    if session.create_pack_scaffold {
696        answers.insert("create_pack_scaffold".to_string(), Value::Bool(true));
697    }
698    if let Some(pack_id) = session.create_pack_id.as_deref() {
699        answers.insert(
700            "create_pack_id".to_string(),
701            Value::String(pack_id.to_string()),
702        );
703    }
704    answers.insert(
705        "run_delegate_flow".to_string(),
706        Value::Bool(session.run_delegate_flow),
707    );
708    answers.insert(
709        "run_delegate_component".to_string(),
710        Value::Bool(session.run_delegate_component),
711    );
712    answers.insert("run_doctor".to_string(), Value::Bool(session.run_doctor));
713    answers.insert("run_build".to_string(), Value::Bool(session.run_build));
714    answers.insert(
715        "mode".to_string(),
716        Value::String(if session.dry_run {
717            "interactive-dry-run".to_string()
718        } else {
719            "interactive".to_string()
720        }),
721    );
722    answers.insert("dry_run".to_string(), Value::Bool(session.dry_run));
723    answers.insert(
724        "selected_actions".to_string(),
725        Value::Array(
726            session
727                .selected_actions
728                .iter()
729                .map(|item| Value::String(item.clone()))
730                .collect(),
731        ),
732    );
733    if let Some(flow_answers) = session.flow_wizard_answers.as_ref() {
734        answers.insert("flow_wizard_answers".to_string(), flow_answers.clone());
735    }
736    if let Some(component_answers) = session.component_wizard_answers.as_ref() {
737        answers.insert(
738            "component_wizard_answers".to_string(),
739            component_answers.clone(),
740        );
741    }
742    if let Some(extension) = session.extension_operation.as_ref() {
743        answers.insert(
744            "extension_operation".to_string(),
745            Value::String(extension.operation.clone()),
746        );
747        answers.insert(
748            "extension_catalog_ref".to_string(),
749            Value::String(extension.catalog_ref.clone()),
750        );
751        answers.insert(
752            "extension_type_id".to_string(),
753            Value::String(extension.extension_type_id.clone()),
754        );
755        if let Some(template_id) = extension.template_id.as_ref() {
756            answers.insert(
757                "extension_template_id".to_string(),
758                Value::String(template_id.clone()),
759            );
760        }
761        answers.insert(
762            "extension_template_qa_answers".to_string(),
763            string_map_to_json_value(&extension.template_qa_answers),
764        );
765        answers.insert(
766            "extension_edit_answers".to_string(),
767            string_map_to_json_value(&extension.edit_answers),
768        );
769    }
770    if let Some(key) = session.sign_key_path.as_deref() {
771        answers.insert("sign".to_string(), Value::Bool(true));
772        answers.insert("sign_key_path".to_string(), Value::String(key.to_string()));
773    } else {
774        answers.insert("sign".to_string(), Value::Bool(false));
775    }
776    Ok(WizardAnswerDocument {
777        wizard_id: PACK_WIZARD_ID.to_string(),
778        schema_id: PACK_WIZARD_SCHEMA_ID.to_string(),
779        schema_version: schema_version.to_string(),
780        locale: locale.to_string(),
781        answers,
782        locks: BTreeMap::new(),
783        base_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
784    })
785}
786
787fn wizard_answer_schema(
788    schema_version: &str,
789    flow_context: Option<&FlowSchemaContext>,
790) -> Result<Value> {
791    let flow_runtime_schema = load_flow_wizard_runtime_schema(flow_context)?;
792    let component_modes = [
793        "create",
794        "add_operation",
795        "update_operation",
796        "build_test",
797        "doctor",
798    ];
799    let component_mode_refs = component_modes
800        .iter()
801        .map(|mode| Value::String(format!("#/$defs/greentic_component_wizard_{mode}")))
802        .collect::<Vec<_>>();
803
804    let mut defs = serde_json::Map::new();
805    defs.insert(
806        "greentic_flow_wizard_runtime_schema".to_string(),
807        flow_runtime_schema,
808    );
809    defs.insert(
810        "greentic_flow_wizard_generic_schema".to_string(),
811        generic_flow_wizard_schema(),
812    );
813    defs.insert(
814        "greentic_flow_step_answers".to_string(),
815        flow_step_answers_schema(),
816    );
817    defs.insert(
818        "greentic_flow_wizard_action".to_string(),
819        flow_wizard_action_schema(),
820    );
821    defs.insert(
822        "greentic_component_wizard_simple_fields".to_string(),
823        component_wizard_simple_fields_schema(),
824    );
825    defs.insert(
826        "greentic_component_wizard_qa_envelope".to_string(),
827        component_wizard_qa_envelope_schema(),
828    );
829    for mode in component_modes {
830        defs.insert(
831            format!("greentic_component_wizard_{mode}"),
832            load_component_wizard_schema(mode)?,
833        );
834    }
835    defs.insert(
836        "greentic_component_wizard_any_mode".to_string(),
837        json!({
838            "description": "Any greentic-component wizard answer document supported by greentic-pack replay.",
839            "oneOf": component_mode_refs
840                .iter()
841                .map(|reference| json!({ "$ref": reference }))
842                .collect::<Vec<_>>(),
843        }),
844    );
845
846    Ok(json!({
847        "$schema": "https://json-schema.org/draft/2020-12/schema",
848        "$id": "https://greenticai.github.io/greentic-pack/schemas/wizard.answers.schema.json",
849        "title": "greentic-pack wizard answers",
850        "type": "object",
851        "additionalProperties": false,
852        "$comment": "Nested flow step answers are component-specific. Resolve those contracts by calling `greentic-flow component-schema <file/oci/repo/store>.wasm [--mode default|setup|update|remove]` and pass the resulting schema through to greentic-flow when composing flow wizard answers.",
853        "properties": {
854            "wizard_id": {
855                "type": "string",
856                "const": PACK_WIZARD_ID
857            },
858            "schema_id": {
859                "type": "string",
860                "const": PACK_WIZARD_SCHEMA_ID
861            },
862            "schema_version": {
863                "type": "string",
864                "const": schema_version
865            },
866            "locale": {
867                "type": "string"
868            },
869            "answers": pack_wizard_answers_schema(),
870            "locks": {
871                "type": "object",
872                "additionalProperties": true
873            }
874        },
875        "required": ["wizard_id", "schema_id", "schema_version", "answers"],
876        "$defs": Value::Object(defs),
877    }))
878}
879
880fn pack_wizard_answers_schema() -> Value {
881    json!({
882        "type": "object",
883        "additionalProperties": false,
884        "properties": {
885            "pack_dir": { "type": "string" },
886            "create_pack_scaffold": { "type": "boolean" },
887            "create_pack_id": { "type": "string" },
888            "run_delegate_flow": { "type": "boolean" },
889            "run_delegate_component": { "type": "boolean" },
890            "run_doctor": { "type": "boolean" },
891            "run_build": { "type": "boolean" },
892            "dry_run": { "type": "boolean" },
893            "mode": { "type": "string" },
894            "sign": { "type": "boolean" },
895            "sign_key_path": { "type": "string" },
896            "selected_actions": {
897                "type": "array",
898                "items": { "type": "string" }
899            },
900            "flow_wizard_answers": {
901                "description": "Nested greentic-flow wizard answers. The generic plan contract is provided here, and the current greentic-flow runtime schema is embedded under #/$defs/greentic_flow_wizard_runtime_schema.",
902                "anyOf": [
903                    { "$ref": "#/$defs/greentic_flow_wizard_generic_schema" },
904                    { "$ref": "#/$defs/greentic_flow_wizard_runtime_schema" }
905                ]
906            },
907            "component_wizard_answers": {
908                "description": "Nested greentic-component wizard answers for component-level replay inside greentic-pack. Accepts either the greentic-component QA replay envelope or the simple component fields object; simple fields are wrapped as {\"schema\":\"component-wizard-run/v1\",\"mode\":\"create\",\"fields\":...} before replay.",
909                "anyOf": [
910                    { "$ref": "#/$defs/greentic_component_wizard_any_mode" },
911                    { "$ref": "#/$defs/greentic_component_wizard_simple_fields" },
912                    { "$ref": "#/$defs/greentic_component_wizard_qa_envelope" }
913                ]
914            },
915            "langs": {
916                "type": "array",
917                "items": { "type": "string" },
918                "description": "Target locale codes to translate the pack's Adaptive Card strings into during build (e.g. [\"id\",\"ja\"]). Requires greentic-i18n-translator on PATH; missing/failed languages are skipped with a warning."
919            },
920            "asset_staging": {
921                "type": "array",
922                "description": "External files or directories to copy into the generated pack root before delegate/build steps run. Relative sources resolve from the AnswerDocument location; destinations must stay inside pack_dir.",
923                "items": {
924                    "type": "object",
925                    "additionalProperties": false,
926                    "properties": {
927                        "source": { "type": "string" },
928                        "destination": { "type": "string" },
929                        "kind": {
930                            "type": "string",
931                            "enum": ["file", "directory"]
932                        },
933                        "recursive": { "type": "boolean" },
934                        "overwrite": {
935                            "type": "boolean",
936                            "default": true
937                        }
938                    },
939                    "required": ["source", "destination", "kind"]
940                }
941            },
942            "extension_operation": { "type": "string" },
943            "extension_catalog_ref": { "type": "string" },
944            "extension_type_id": { "type": "string" },
945            "extension_template_id": { "type": "string" },
946            "extension_template_qa_answers": {
947                "type": "object",
948                "additionalProperties": { "type": "string" }
949            },
950            "extension_edit_answers": {
951                "type": "object",
952                "additionalProperties": { "type": "string" }
953            }
954        },
955        "required": ["pack_dir"]
956    })
957}
958
959fn generic_flow_wizard_schema() -> Value {
960    json!({
961        "type": "object",
962        "additionalProperties": false,
963        "description": "Generic greentic-flow wizard plan schema embedded by greentic-pack. For a concrete flow plan, also fetch greentic-flow's current runtime schema directly with `greentic-flow wizard <pack> --answers <plan.json> --schema <schema.json>`.",
964        "properties": {
965            "schema_id": {
966                "type": "string",
967                "const": "greentic-flow.wizard.plan"
968            },
969            "schema_version": {
970                "type": "string"
971            },
972            "actions": {
973                "type": "array",
974                "items": {
975                    "$ref": "#/$defs/greentic_flow_wizard_action"
976                }
977            }
978        },
979        "required": ["schema_id", "schema_version", "actions"]
980    })
981}
982
983fn component_wizard_simple_fields_schema() -> Value {
984    json!({
985        "type": "object",
986        "description": "Convenience shape for answers.component_wizard_answers. greentic-pack wraps this object in the greentic-component QA replay envelope before invoking `greentic-component wizard --qa-answers`.",
987        "additionalProperties": true,
988        "properties": {
989            "component_name": { "type": "string" },
990            "output_dir": { "type": "string" },
991            "abi_version": { "type": "string" },
992            "filesystem_mode": { "type": "string" },
993            "telemetry_scope": { "type": "string" },
994            "http_client": { "type": "boolean" },
995            "messaging_inbound": { "type": "boolean" },
996            "messaging_outbound": { "type": "boolean" },
997            "secrets_enabled": { "type": "boolean" },
998            "secret_keys": {
999                "type": "array",
1000                "items": { "type": "string" }
1001            }
1002        },
1003        "required": ["component_name"]
1004    })
1005}
1006
1007fn component_wizard_qa_envelope_schema() -> Value {
1008    json!({
1009        "type": "object",
1010        "description": "greentic-component QA replay envelope accepted by `greentic-component wizard --qa-answers`.",
1011        "additionalProperties": true,
1012        "properties": {
1013            "schema": {
1014                "type": "string",
1015                "const": "component-wizard-run/v1"
1016            },
1017            "mode": {
1018                "type": "string",
1019                "default": "create"
1020            },
1021            "fields": {
1022                "type": "object",
1023                "additionalProperties": true
1024            }
1025        },
1026        "required": ["schema", "mode", "fields"]
1027    })
1028}
1029
1030fn flow_wizard_routing_schema() -> Value {
1031    json!({
1032        "description": "Optional routing intent. Use \"out\", \"reply\", or an explicit route array such as [{\"to\":\"next\"}].",
1033        "anyOf": [
1034            { "enum": ["out", "reply"] },
1035            { "type": "array" }
1036        ]
1037    })
1038}
1039
1040fn flow_step_mapping_schema(description: &str) -> Value {
1041    json!({
1042        "description": description
1043    })
1044}
1045
1046fn flow_step_answers_schema() -> Value {
1047    json!({
1048        "type": "object",
1049        "description": "Exact step-answer contract resolution is component-specific. Call `greentic-flow component-schema <file/oci/repo/store>.wasm [--mode default|setup|update|remove]` and pass that schema on to greentic-flow when composing nested add-step/update-step/delete-step answers.",
1050        "$comment": "Resolve per-component step answer schemas via `greentic-flow component-schema <file/oci/repo/store>.wasm [--mode default|setup|update|remove]`.",
1051        "additionalProperties": true
1052    })
1053}
1054
1055fn flow_step_action_schema(action: &str) -> Value {
1056    let mut required = vec![json!("action"), json!("flow")];
1057    if matches!(action, "add-step" | "update-step") {
1058        required.push(json!("component"));
1059        required.push(json!("mode"));
1060    }
1061    if action == "update-step" {
1062        required.push(json!("step_id"));
1063    }
1064    json!({
1065        "type": "object",
1066        "additionalProperties": false,
1067        "properties": {
1068            "action": { "type": "string", "const": action },
1069            "flow": { "type": "string" },
1070            "step_id": { "type": "string" },
1071            "after": { "type": "string" },
1072            "component": { "type": "string" },
1073            "mode": {
1074                "type": "string",
1075                "enum": ["default", "setup", "update", "remove"]
1076            },
1077            "operation": { "type": "string" },
1078            "answers": { "$ref": "#/$defs/greentic_flow_step_answers" },
1079            "routing": flow_wizard_routing_schema(),
1080            "in_map": flow_step_mapping_schema("Optional flow authoring input mapping. This is separate from component `answers` and may reference flow payload/state/config such as `config.<key>`."),
1081            "out_map": flow_step_mapping_schema("Optional flow authoring success-output mapping. This is separate from component `answers`."),
1082            "err_map": flow_step_mapping_schema("Optional flow authoring error-output mapping. This is separate from component `answers`.")
1083        },
1084        "required": required
1085    })
1086}
1087
1088fn flow_wizard_action_schema() -> Value {
1089    json!({
1090        "oneOf": [
1091            {
1092                "type": "object",
1093                "additionalProperties": false,
1094                "properties": {
1095                    "action": { "type": "string", "const": "add-flow" },
1096                    "flow": { "type": "string" },
1097                    "flow_id": { "type": "string" },
1098                    "flow_type": { "type": "string" }
1099                },
1100                "required": ["action", "flow", "flow_id", "flow_type"]
1101            },
1102            {
1103                "type": "object",
1104                "additionalProperties": false,
1105                "properties": {
1106                    "action": { "type": "string", "const": "edit-flow-summary" },
1107                    "flow": { "type": "string" },
1108                    "name": { "type": "string" },
1109                    "description": { "type": "string" }
1110                },
1111                "required": ["action", "flow"]
1112            },
1113            {
1114                "type": "object",
1115                "additionalProperties": false,
1116                "properties": {
1117                    "action": { "type": "string", "const": "generate-translations" },
1118                    "locales": {
1119                        "type": "array",
1120                        "items": { "type": "string" }
1121                    }
1122                },
1123                "required": ["action", "locales"]
1124            },
1125            {
1126                "type": "object",
1127                "additionalProperties": false,
1128                "properties": {
1129                    "action": { "type": "string", "const": "delete-flow" },
1130                    "flow": { "type": "string" }
1131                },
1132                "required": ["action", "flow"]
1133            },
1134            flow_step_action_schema("add-step"),
1135            flow_step_action_schema("update-step"),
1136            flow_step_action_schema("delete-step")
1137        ]
1138    })
1139}
1140
1141fn load_flow_wizard_runtime_schema(flow_context: Option<&FlowSchemaContext>) -> Result<Value> {
1142    let temp = tempfile::tempdir().context("create temp dir for flow wizard schema")?;
1143    let cwd = flow_context
1144        .and_then(|ctx| ctx.pack_dir.as_deref())
1145        .unwrap_or_else(|| temp.path());
1146    let mut args = vec!["wizard".to_string(), "--schema".to_string()];
1147    let mut temp_answers_path = None;
1148
1149    if let Some(ctx) = flow_context
1150        && let Some(pack_dir) = ctx.pack_dir.as_ref()
1151    {
1152        args.push(pack_dir.display().to_string());
1153        if let Some(flow_answers) = ctx.flow_wizard_answers.as_ref() {
1154            let answers_path = temp.path().join("flow.answers.json");
1155            if !write_json_value(&answers_path, flow_answers) {
1156                return Err(anyhow!(
1157                    "failed to write temp greentic-flow answers plan {}",
1158                    answers_path.display()
1159                ));
1160            }
1161            args.push("--answers".to_string());
1162            args.push(answers_path.display().to_string());
1163            temp_answers_path = Some(answers_path);
1164        }
1165    }
1166
1167    let result = capture_delegate_json("greentic-flow", &args, cwd)
1168        .context("failed to fetch nested greentic-flow wizard schema");
1169    if let Some(path) = temp_answers_path.as_deref() {
1170        let _ = fs::remove_file(path);
1171    }
1172    result
1173}
1174
1175fn load_component_wizard_schema(mode: &str) -> Result<Value> {
1176    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1177    let args = vec![
1178        "wizard".to_string(),
1179        "--schema".to_string(),
1180        "--mode".to_string(),
1181        mode.to_string(),
1182    ];
1183    capture_delegate_json("greentic-component", &args, &cwd)
1184        .with_context(|| format!("fetch nested greentic-component wizard schema for mode '{mode}'"))
1185}
1186
1187fn validate_answer_document(doc: &WizardAnswerDocument) -> Result<()> {
1188    if doc.wizard_id != PACK_WIZARD_ID {
1189        return Err(anyhow!(
1190            "unsupported wizard_id '{}', expected '{}'",
1191            doc.wizard_id,
1192            PACK_WIZARD_ID
1193        ));
1194    }
1195    if doc.schema_id != PACK_WIZARD_SCHEMA_ID {
1196        return Err(anyhow!(
1197            "unsupported schema_id '{}', expected '{}'",
1198            doc.schema_id,
1199            PACK_WIZARD_SCHEMA_ID
1200        ));
1201    }
1202    let plan = execution_plan_from_answers(&doc.answers, &doc.base_dir)?;
1203    let pack_dir_must_exist = !plan.create_pack_scaffold
1204        && !matches!(
1205            plan.extension_operation
1206                .as_ref()
1207                .map(|item| item.operation.as_str()),
1208            Some("create_extension_pack")
1209        );
1210    if pack_dir_must_exist && !plan.pack_dir.is_dir() {
1211        return Err(anyhow!(
1212            "pack_dir is not an existing directory: {}",
1213            plan.pack_dir.display()
1214        ));
1215    }
1216    if plan.create_pack_scaffold && plan.create_pack_id.is_none() {
1217        return Err(anyhow!(
1218            "create_pack_scaffold=true requires answers.create_pack_id string"
1219        ));
1220    }
1221    if let Some(key) = plan.sign_key_path.as_deref()
1222        && key.trim().is_empty()
1223    {
1224        return Err(anyhow!("sign_key_path must not be empty"));
1225    }
1226    if let Some(extension) = plan.extension_operation.as_ref() {
1227        validate_extension_operation_record(extension)?;
1228    }
1229    Ok(())
1230}
1231
1232fn apply_answer_document(doc: &WizardAnswerDocument) -> Result<()> {
1233    let plan = execution_plan_from_answers(&doc.answers, &doc.base_dir)?;
1234    let self_exe = wizard_self_exe()?;
1235    if plan.create_pack_scaffold {
1236        let pack_id = plan
1237            .create_pack_id
1238            .as_deref()
1239            .ok_or_else(|| anyhow!("missing create_pack_id for scaffold apply"))?;
1240        let scaffold_ok = run_process(
1241            &self_exe,
1242            &[
1243                "new",
1244                "--dir",
1245                &plan.pack_dir.display().to_string(),
1246                pack_id,
1247            ],
1248            None,
1249        )?;
1250        if !scaffold_ok {
1251            return Err(anyhow!(
1252                "wizard apply failed while creating application pack {}",
1253                plan.pack_dir.display()
1254            ));
1255        }
1256    }
1257    if let Some(extension) = plan.extension_operation.as_ref() {
1258        apply_extension_operation(&plan.pack_dir, extension)?;
1259    }
1260    if !plan.asset_staging.is_empty() {
1261        stage_assets_into_pack(&plan.pack_root, &plan.asset_staging)?;
1262    }
1263    if plan.run_delegate_flow {
1264        let ok = run_flow_delegate_replay(&plan.pack_dir, plan.flow_wizard_answers.as_ref());
1265        if !ok {
1266            return Err(anyhow!(
1267                "wizard apply failed while running flow delegate for {}",
1268                plan.pack_dir.display()
1269            ));
1270        }
1271    }
1272    if plan.run_delegate_component {
1273        run_component_delegate_replay(&plan.pack_dir, plan.component_wizard_answers.as_ref())
1274            .with_context(|| {
1275                format!(
1276                    "wizard apply failed while running component delegate for {}",
1277                    plan.pack_dir.display()
1278                )
1279            })?;
1280    }
1281    if !plan.i18n_langs.is_empty() {
1282        // Non-fatal: writes pack_root/assets/i18n/*, reports skips to stderr.
1283        crate::i18n_build::materialize_i18n(&plan.pack_root, &plan.i18n_langs);
1284    }
1285    if plan.run_doctor || plan.run_build {
1286        let update_ok = run_process(
1287            &self_exe,
1288            &["update", "--in", &plan.pack_dir.display().to_string()],
1289            None,
1290        )?;
1291        if !update_ok {
1292            return Err(anyhow!(
1293                "wizard apply failed while syncing pack manifest for {}",
1294                plan.pack_dir.display()
1295            ));
1296        }
1297    }
1298    if plan.run_doctor {
1299        let doctor_ok = run_process(
1300            &self_exe,
1301            &["doctor", "--in", &plan.pack_dir.display().to_string()],
1302            None,
1303        )?;
1304        if !doctor_ok {
1305            return Err(anyhow!(
1306                "wizard apply failed while running doctor for {}",
1307                plan.pack_dir.display()
1308            ));
1309        }
1310    }
1311    if plan.run_build {
1312        let resolve_ok = run_process(
1313            &self_exe,
1314            &["resolve", "--in", &plan.pack_dir.display().to_string()],
1315            None,
1316        )?;
1317        if !resolve_ok {
1318            return Err(anyhow!(
1319                "wizard apply failed while running resolve for {}",
1320                plan.pack_dir.display()
1321            ));
1322        }
1323        let build_ok = run_process(
1324            &self_exe,
1325            &["build", "--in", &plan.pack_dir.display().to_string()],
1326            None,
1327        )?;
1328        if !build_ok {
1329            return Err(anyhow!(
1330                "wizard apply failed while running build for {}",
1331                plan.pack_dir.display()
1332            ));
1333        }
1334    }
1335    if let Some(key_path) = plan.sign_key_path.as_deref() {
1336        let sign_ok = run_process(
1337            &self_exe,
1338            &[
1339                "sign",
1340                "--pack",
1341                &plan.pack_dir.display().to_string(),
1342                "--key",
1343                key_path,
1344            ],
1345            None,
1346        )?;
1347        if !sign_ok {
1348            return Err(anyhow!(
1349                "wizard apply failed while signing {}",
1350                plan.pack_dir.display()
1351            ));
1352        }
1353    }
1354    Ok(())
1355}
1356
1357fn execution_plan_from_answers(
1358    answers: &BTreeMap<String, Value>,
1359    answers_base_dir: &Path,
1360) -> Result<WizardExecutionPlan> {
1361    let pack_dir_raw = answers
1362        .get("pack_dir")
1363        .and_then(Value::as_str)
1364        .ok_or_else(|| anyhow!("answers.pack_dir must be a string"))?;
1365    let pack_dir = PathBuf::from(pack_dir_raw);
1366    let pack_root = absolutize_path(&pack_dir);
1367    let create_pack_scaffold = answer_bool(answers, "create_pack_scaffold", false)?;
1368    let create_pack_id = answers
1369        .get("create_pack_id")
1370        .and_then(Value::as_str)
1371        .map(ToString::to_string);
1372    let run_delegate_flow = answer_bool(answers, "run_delegate_flow", false)?;
1373    let run_delegate_component = answer_bool(answers, "run_delegate_component", false)?;
1374    let run_doctor = answer_bool(answers, "run_doctor", true)?;
1375    let run_build = answer_bool(answers, "run_build", true)?;
1376    let flow_wizard_answers = answers.get("flow_wizard_answers").cloned();
1377    let component_wizard_answers = answers.get("component_wizard_answers").cloned();
1378    let sign = answer_bool(answers, "sign", false)?;
1379    let sign_key_path = answers
1380        .get("sign_key_path")
1381        .and_then(Value::as_str)
1382        .map(ToString::to_string);
1383    if sign && sign_key_path.is_none() {
1384        return Err(anyhow!(
1385            "answers.sign=true requires answers.sign_key_path string"
1386        ));
1387    }
1388    let sign_key_path = if sign { sign_key_path } else { None };
1389    let extension_operation = parse_extension_operation_record(answers)?;
1390    let asset_staging = parse_asset_staging_entries(answers, answers_base_dir, &pack_root)?;
1391    validate_scaffold_asset_staging_conflicts(create_pack_scaffold, &pack_root, &asset_staging)?;
1392    let i18n_langs: Vec<String> = answers
1393        .get("langs")
1394        .and_then(Value::as_array)
1395        .map(|arr| {
1396            arr.iter()
1397                .filter_map(|v| v.as_str().map(str::to_string))
1398                .collect()
1399        })
1400        .unwrap_or_default();
1401    Ok(WizardExecutionPlan {
1402        pack_dir,
1403        pack_root,
1404        create_pack_id,
1405        create_pack_scaffold,
1406        run_delegate_flow,
1407        run_delegate_component,
1408        run_doctor,
1409        run_build,
1410        flow_wizard_answers,
1411        component_wizard_answers,
1412        sign_key_path,
1413        extension_operation,
1414        asset_staging,
1415        i18n_langs,
1416    })
1417}
1418
1419fn answer_bool(answers: &BTreeMap<String, Value>, key: &str, default: bool) -> Result<bool> {
1420    match answers.get(key) {
1421        None => Ok(default),
1422        Some(value) => value
1423            .as_bool()
1424            .ok_or_else(|| anyhow!("answers.{key} must be a boolean")),
1425    }
1426}
1427
1428fn absolutize_path(path: &Path) -> PathBuf {
1429    if path.is_absolute() {
1430        path.to_path_buf()
1431    } else {
1432        std::env::current_dir()
1433            .unwrap_or_else(|_| PathBuf::from("."))
1434            .join(path)
1435    }
1436}
1437
1438fn normalize_pack_destination(pack_root: &Path, candidate: &Path) -> Result<PathBuf> {
1439    if candidate.is_absolute() {
1440        anyhow::bail!(
1441            "asset staging destination must be relative to pack_dir: {}",
1442            candidate.display()
1443        );
1444    }
1445
1446    let mut normalized = pack_root.to_path_buf();
1447    for component in candidate.components() {
1448        match component {
1449            Component::CurDir => {}
1450            Component::Normal(part) => normalized.push(part),
1451            Component::ParentDir => {
1452                anyhow::bail!(
1453                    "asset staging destination must not contain '..' segments: {}",
1454                    candidate.display()
1455                );
1456            }
1457            Component::Prefix(_) | Component::RootDir => {
1458                anyhow::bail!(
1459                    "asset staging destination must be relative to pack_dir: {}",
1460                    candidate.display()
1461                );
1462            }
1463        }
1464    }
1465    Ok(normalized)
1466}
1467
1468fn parse_asset_staging_entries(
1469    answers: &BTreeMap<String, Value>,
1470    answers_base_dir: &Path,
1471    pack_root: &Path,
1472) -> Result<Vec<ResolvedAssetStagingEntry>> {
1473    let Some(value) = answers.get("asset_staging") else {
1474        return Ok(Vec::new());
1475    };
1476    let items = value
1477        .as_array()
1478        .ok_or_else(|| anyhow!("answers.asset_staging must be an array"))?;
1479    let mut resolved = Vec::with_capacity(items.len());
1480    let mut seen_destinations = BTreeSet::new();
1481    for (index, item) in items.iter().enumerate() {
1482        let field = format!("answers.asset_staging[{index}]");
1483        let entry: AssetStagingEntry = serde_json::from_value(item.clone())
1484            .with_context(|| format!("{field} is not a valid asset staging entry"))?;
1485        let source_rel = PathBuf::from(&entry.source);
1486        let source = if source_rel.is_absolute() {
1487            source_rel
1488        } else {
1489            answers_base_dir.join(&source_rel)
1490        };
1491        let destination = normalize_pack_destination(pack_root, Path::new(&entry.destination))?;
1492        validate_asset_staging_entry(&field, &entry, &source, &destination)?;
1493        let dest_key = destination.display().to_string();
1494        if !seen_destinations.insert(dest_key.clone()) {
1495            anyhow::bail!(
1496                "{field}.destination conflicts with another asset staging entry: {dest_key}"
1497            );
1498        }
1499        resolved.push(ResolvedAssetStagingEntry {
1500            source,
1501            destination,
1502            kind: entry.kind,
1503            recursive: entry.recursive,
1504            overwrite: entry.overwrite,
1505        });
1506    }
1507    Ok(resolved)
1508}
1509
1510fn validate_scaffold_asset_staging_conflicts(
1511    create_pack_scaffold: bool,
1512    pack_root: &Path,
1513    entries: &[ResolvedAssetStagingEntry],
1514) -> Result<()> {
1515    if !create_pack_scaffold {
1516        return Ok(());
1517    }
1518
1519    let reserved_paths = [
1520        pack_root.join("pack.yaml"),
1521        pack_root.join("flows/main.ygtc"),
1522    ];
1523
1524    for entry in entries {
1525        if entry.overwrite || entry.kind != AssetStagingKind::File {
1526            continue;
1527        }
1528        if reserved_paths
1529            .iter()
1530            .any(|reserved| reserved == &entry.destination)
1531        {
1532            anyhow::bail!(
1533                "asset staging destination already exists in scaffold output and overwrite=false: {}",
1534                entry.destination.display()
1535            );
1536        }
1537    }
1538
1539    Ok(())
1540}
1541
1542fn validate_asset_staging_entry(
1543    field: &str,
1544    entry: &AssetStagingEntry,
1545    source: &Path,
1546    _destination: &Path,
1547) -> Result<()> {
1548    if entry.source.trim().is_empty() {
1549        anyhow::bail!("{field}.source must not be empty");
1550    }
1551    if entry.destination.trim().is_empty() {
1552        anyhow::bail!("{field}.destination must not be empty");
1553    }
1554    if !source.exists() {
1555        anyhow::bail!("{field}.source does not exist: {}", source.display());
1556    }
1557
1558    match entry.kind {
1559        AssetStagingKind::File => {
1560            if !source.is_file() {
1561                anyhow::bail!(
1562                    "{field}.kind=file requires a file source, got {}",
1563                    source.display()
1564                );
1565            }
1566        }
1567        AssetStagingKind::Directory => {
1568            if !source.is_dir() {
1569                anyhow::bail!(
1570                    "{field}.kind=directory requires a directory source, got {}",
1571                    source.display()
1572                );
1573            }
1574            if !entry.recursive {
1575                anyhow::bail!("{field}.recursive must be true when kind=directory");
1576            }
1577        }
1578    }
1579
1580    Ok(())
1581}
1582
1583fn stage_assets_into_pack(pack_root: &Path, entries: &[ResolvedAssetStagingEntry]) -> Result<()> {
1584    fs::create_dir_all(pack_root)
1585        .with_context(|| format!("create pack root {}", pack_root.display()))?;
1586    for entry in entries {
1587        stage_single_asset(pack_root, entry)?;
1588    }
1589    Ok(())
1590}
1591
1592fn stage_single_asset(_pack_root: &Path, entry: &ResolvedAssetStagingEntry) -> Result<()> {
1593    match entry.kind {
1594        AssetStagingKind::File => {
1595            copy_staged_file(&entry.source, &entry.destination, entry.overwrite)
1596        }
1597        AssetStagingKind::Directory => copy_staged_directory(
1598            &entry.source,
1599            &entry.destination,
1600            entry.recursive,
1601            entry.overwrite,
1602        ),
1603    }
1604}
1605
1606fn copy_staged_file(source: &Path, destination: &Path, overwrite: bool) -> Result<()> {
1607    if destination.is_dir() {
1608        anyhow::bail!(
1609            "asset staging destination is a directory but source is a file: {}",
1610            destination.display()
1611        );
1612    }
1613    if destination.exists() && !overwrite {
1614        anyhow::bail!(
1615            "asset staging destination already exists and overwrite=false: {}",
1616            destination.display()
1617        );
1618    }
1619    if let Some(parent) = destination.parent() {
1620        fs::create_dir_all(parent)
1621            .with_context(|| format!("create staged asset parent {}", parent.display()))?;
1622    }
1623    fs::copy(source, destination).with_context(|| {
1624        format!(
1625            "copy staged asset file {} -> {}",
1626            source.display(),
1627            destination.display()
1628        )
1629    })?;
1630    Ok(())
1631}
1632
1633fn copy_staged_directory(
1634    source: &Path,
1635    destination: &Path,
1636    recursive: bool,
1637    overwrite: bool,
1638) -> Result<()> {
1639    if !recursive {
1640        anyhow::bail!(
1641            "directory staging requires recursive=true for source {}",
1642            source.display()
1643        );
1644    }
1645    if destination.exists() && destination.is_file() {
1646        anyhow::bail!(
1647            "asset staging destination is a file but source is a directory: {}",
1648            destination.display()
1649        );
1650    }
1651    fs::create_dir_all(destination)
1652        .with_context(|| format!("create staged asset directory {}", destination.display()))?;
1653    for item in WalkDir::new(source).into_iter().filter_map(Result::ok) {
1654        let path = item.path();
1655        let rel = path
1656            .strip_prefix(source)
1657            .expect("walkdir entry should remain under source");
1658        if rel.as_os_str().is_empty() {
1659            continue;
1660        }
1661        let target = destination.join(rel);
1662        if item.file_type().is_dir() {
1663            fs::create_dir_all(&target)
1664                .with_context(|| format!("create staged asset directory {}", target.display()))?;
1665            continue;
1666        }
1667        if target.exists() && !overwrite {
1668            anyhow::bail!(
1669                "asset staging destination already exists and overwrite=false: {}",
1670                target.display()
1671            );
1672        }
1673        if let Some(parent) = target.parent() {
1674            fs::create_dir_all(parent)
1675                .with_context(|| format!("create staged asset parent {}", parent.display()))?;
1676        }
1677        fs::copy(path, &target).with_context(|| {
1678            format!(
1679                "copy staged asset file {} -> {}",
1680                path.display(),
1681                target.display()
1682            )
1683        })?;
1684    }
1685    Ok(())
1686}
1687
1688fn string_map_to_json_value(map: &BTreeMap<String, String>) -> Value {
1689    Value::Object(
1690        map.iter()
1691            .map(|(key, value)| (key.clone(), Value::String(value.clone())))
1692            .collect(),
1693    )
1694}
1695
1696fn json_value_to_string_map(
1697    value: Option<&Value>,
1698    field: &str,
1699) -> Result<BTreeMap<String, String>> {
1700    let Some(value) = value else {
1701        return Ok(BTreeMap::new());
1702    };
1703    let obj = value
1704        .as_object()
1705        .ok_or_else(|| anyhow!("answers.{field} must be an object"))?;
1706    let mut map = BTreeMap::new();
1707    for (key, value) in obj {
1708        let value = value
1709            .as_str()
1710            .ok_or_else(|| anyhow!("answers.{field}.{key} must be a string"))?;
1711        map.insert(key.clone(), value.to_string());
1712    }
1713    Ok(map)
1714}
1715
1716fn parse_extension_operation_record(
1717    answers: &BTreeMap<String, Value>,
1718) -> Result<Option<ExtensionOperationRecord>> {
1719    let operation = answers
1720        .get("extension_operation")
1721        .and_then(Value::as_str)
1722        .map(ToString::to_string)
1723        .or_else(|| infer_extension_operation_from_selected_actions(answers));
1724    let Some(operation) = operation.as_deref() else {
1725        return Ok(None);
1726    };
1727    let catalog_ref = answers
1728        .get("extension_catalog_ref")
1729        .and_then(Value::as_str)
1730        .ok_or_else(|| anyhow!("answers.extension_catalog_ref must be a string"))?;
1731    let extension_type_id = answers
1732        .get("extension_type_id")
1733        .and_then(Value::as_str)
1734        .ok_or_else(|| anyhow!("answers.extension_type_id must be a string"))?;
1735    let template_id = answers
1736        .get("extension_template_id")
1737        .and_then(Value::as_str)
1738        .map(ToString::to_string);
1739    let template_qa_answers = json_value_to_string_map(
1740        answers.get("extension_template_qa_answers"),
1741        "extension_template_qa_answers",
1742    )?;
1743    let edit_answers = json_value_to_string_map(
1744        answers.get("extension_edit_answers"),
1745        "extension_edit_answers",
1746    )?;
1747    Ok(Some(ExtensionOperationRecord {
1748        operation: operation.to_string(),
1749        catalog_ref: catalog_ref.to_string(),
1750        extension_type_id: extension_type_id.to_string(),
1751        template_id,
1752        template_qa_answers,
1753        edit_answers,
1754    }))
1755}
1756
1757fn infer_extension_operation_from_selected_actions(
1758    answers: &BTreeMap<String, Value>,
1759) -> Option<String> {
1760    let selected = answers.get("selected_actions")?.as_array()?;
1761    let contains = |needle: &str| {
1762        selected
1763            .iter()
1764            .any(|value| matches!(value.as_str(), Some(item) if item == needle))
1765    };
1766    if contains("main.update_extension_pack") || contains("update_extension_pack.edit_entries") {
1767        return Some("update_extension_pack".to_string());
1768    }
1769    if contains("main.create_extension_pack") || contains("create_extension_pack.start") {
1770        return Some("create_extension_pack".to_string());
1771    }
1772    if contains("main.add_extension") {
1773        return Some("add_extension".to_string());
1774    }
1775    None
1776}
1777
1778fn run_create_extension_pack<R: BufRead, W: Write>(
1779    input: &mut R,
1780    output: &mut W,
1781    i18n: &WizardI18n,
1782    runtime: Option<&RuntimeContext>,
1783    session: &mut WizardSession,
1784) -> Result<()> {
1785    session
1786        .selected_actions
1787        .push("create_extension_pack.start".to_string());
1788    let catalog_ref = prompt_for_extension_catalog_ref(input, output, i18n)?;
1789
1790    let catalog = match load_extension_catalog(catalog_ref.trim(), runtime) {
1791        Ok(value) => value,
1792        Err(err) => {
1793            wizard_ui::render_line(
1794                output,
1795                &format!("{}: {}", i18n.t("wizard.error.catalog_load_failed"), err),
1796            )?;
1797            let nav = ask_failure_nav(input, output, i18n)?;
1798            if matches!(nav, SubmenuAction::MainMenu) {
1799                return Ok(());
1800            }
1801            return Ok(());
1802        }
1803    };
1804
1805    let type_choice = ask_extension_type(input, output, i18n, &catalog)?;
1806    if type_choice == "0" || type_choice.eq_ignore_ascii_case("m") {
1807        return Ok(());
1808    }
1809
1810    let selected = catalog
1811        .extension_types
1812        .iter()
1813        .find(|item| item.id == type_choice)
1814        .ok_or_else(|| anyhow!("selected extension type not found"))?;
1815
1816    let template = match ask_extension_template(input, output, i18n, selected)? {
1817        Some(template) => template,
1818        None => return Ok(()),
1819    };
1820
1821    wizard_ui::render_line(
1822        output,
1823        &format!(
1824            "{} {} / {}",
1825            i18n.t("wizard.create_extension_pack.selected_type"),
1826            selected.id,
1827            template.id
1828        ),
1829    )?;
1830
1831    let default_dir = format!("./{}-extension", selected.id.replace('/', "-"));
1832    let pack_dir = ask_text(
1833        input,
1834        output,
1835        i18n,
1836        "pack.wizard.create_ext.pack_dir",
1837        "wizard.create_extension_pack.ask_pack_dir",
1838        Some("wizard.create_extension_pack.ask_pack_dir_help"),
1839        Some(&default_dir),
1840    )?;
1841    let pack_dir_path = PathBuf::from(pack_dir.trim());
1842    session.last_pack_dir = Some(pack_dir_path.clone());
1843    let qa_answers = ask_template_qa_answers(input, output, i18n, &template)?;
1844    let edit_answers = ask_extension_edit_answers(input, output, i18n, selected)?;
1845    session.extension_operation = Some(ExtensionOperationRecord {
1846        operation: "create_extension_pack".to_string(),
1847        catalog_ref: catalog_ref.trim().to_string(),
1848        extension_type_id: selected.id.clone(),
1849        template_id: Some(template.id.clone()),
1850        template_qa_answers: qa_answers.clone(),
1851        edit_answers: edit_answers.clone(),
1852    });
1853    if session.dry_run {
1854        wizard_ui::render_line(output, &i18n.t("wizard.dry_run.skipping_template_apply"))?;
1855    } else {
1856        if let Err(err) = apply_template_plan(
1857            &template,
1858            &pack_dir_path,
1859            selected,
1860            i18n,
1861            &qa_answers,
1862            &edit_answers,
1863        ) {
1864            wizard_ui::render_line(
1865                output,
1866                &format!("{}: {err}", i18n.t("wizard.error.template_apply_failed")),
1867            )?;
1868            let nav = ask_failure_nav(input, output, i18n)?;
1869            if matches!(nav, SubmenuAction::MainMenu) {
1870                return Ok(());
1871            }
1872            return Ok(());
1873        }
1874        persist_extension_state(
1875            &pack_dir_path,
1876            selected,
1877            &session
1878                .extension_operation
1879                .clone()
1880                .expect("extension operation recorded"),
1881        )?;
1882    }
1883
1884    let self_exe = wizard_self_exe()?;
1885    let finalized = run_update_validate_sequence(
1886        input,
1887        output,
1888        i18n,
1889        session,
1890        &self_exe,
1891        &pack_dir_path,
1892        true,
1893        "wizard.progress.running_finalize",
1894    )?;
1895    if !finalized {
1896        let _ = ask_failure_nav(input, output, i18n)?;
1897    }
1898    Ok(())
1899}
1900
1901fn ask_extension_type<R: BufRead, W: Write>(
1902    input: &mut R,
1903    output: &mut W,
1904    i18n: &WizardI18n,
1905    catalog: &ExtensionCatalog,
1906) -> Result<String> {
1907    let mut choices = catalog
1908        .extension_types
1909        .iter()
1910        .enumerate()
1911        .map(|(idx, ext)| {
1912            (
1913                (idx + 1).to_string(),
1914                format!(
1915                    "{} - {}",
1916                    ext.display_name(i18n),
1917                    ext.display_description(i18n)
1918                ),
1919                ext.id.clone(),
1920            )
1921        })
1922        .collect::<Vec<_>>();
1923
1924    let mut menu_choices = choices
1925        .iter()
1926        .map(|(menu_id, label, _)| (menu_id.clone(), label.clone()))
1927        .collect::<Vec<_>>();
1928    menu_choices.push(("0".to_string(), i18n.t("wizard.nav.back")));
1929    menu_choices.push(("M".to_string(), i18n.t("wizard.nav.main_menu")));
1930
1931    let choice = ask_enum_custom_labels_owned(
1932        input,
1933        output,
1934        i18n,
1935        "pack.wizard.create_ext.type",
1936        "wizard.create_extension_pack.type_menu.title",
1937        Some("wizard.create_extension_pack.type_menu.description"),
1938        &menu_choices,
1939        "M",
1940    )?;
1941
1942    if choice == "0" || choice.eq_ignore_ascii_case("m") {
1943        return Ok(choice);
1944    }
1945
1946    let selected = choices
1947        .iter_mut()
1948        .find(|(menu_id, _, _)| menu_id == &choice)
1949        .map(|(_, _, id)| id.clone())
1950        .ok_or_else(|| anyhow!("invalid extension type selection"))?;
1951    Ok(selected)
1952}
1953
1954fn ask_extension_template<R: BufRead, W: Write>(
1955    input: &mut R,
1956    output: &mut W,
1957    i18n: &WizardI18n,
1958    extension_type: &ExtensionType,
1959) -> Result<Option<ExtensionTemplate>> {
1960    if extension_type.templates.is_empty() {
1961        return Err(anyhow!("extension type has no templates"));
1962    }
1963
1964    let choices = extension_type
1965        .templates
1966        .iter()
1967        .enumerate()
1968        .map(|(idx, item)| {
1969            (
1970                (idx + 1).to_string(),
1971                format!(
1972                    "{} - {}",
1973                    item.display_name(i18n),
1974                    item.display_description(i18n)
1975                ),
1976                item,
1977            )
1978        })
1979        .collect::<Vec<_>>();
1980
1981    let mut menu_choices = choices
1982        .iter()
1983        .map(|(menu_id, label, _)| (menu_id.clone(), label.clone()))
1984        .collect::<Vec<_>>();
1985    menu_choices.push(("0".to_string(), i18n.t("wizard.nav.back")));
1986    menu_choices.push(("M".to_string(), i18n.t("wizard.nav.main_menu")));
1987
1988    let choice = ask_enum_custom_labels_owned(
1989        input,
1990        output,
1991        i18n,
1992        "pack.wizard.create_ext.template",
1993        "wizard.create_extension_pack.template_menu.title",
1994        Some("wizard.create_extension_pack.template_menu.description"),
1995        &menu_choices,
1996        "M",
1997    )?;
1998
1999    if choice == "0" || choice.eq_ignore_ascii_case("m") {
2000        return Ok(None);
2001    }
2002
2003    let selected = choices
2004        .iter()
2005        .find(|(menu_id, _, _)| menu_id == &choice)
2006        .map(|(_, _, template)| (*template).clone())
2007        .ok_or_else(|| anyhow!("invalid extension template selection"))?;
2008    Ok(Some(selected))
2009}
2010
2011fn apply_template_plan(
2012    template: &ExtensionTemplate,
2013    pack_dir: &Path,
2014    extension_type: &ExtensionType,
2015    i18n: &WizardI18n,
2016    qa_answers: &BTreeMap<String, String>,
2017    edit_answers: &BTreeMap<String, String>,
2018) -> Result<()> {
2019    ensure_extension_pack_base_scaffold(pack_dir)?;
2020    for step in &template.plan {
2021        match step {
2022            TemplatePlanStep::EnsureDir { paths } => {
2023                for rel in paths {
2024                    let target = pack_dir.join(render_template_string(
2025                        rel,
2026                        extension_type,
2027                        template,
2028                        i18n,
2029                        qa_answers,
2030                        edit_answers,
2031                    ));
2032                    fs::create_dir_all(&target)
2033                        .with_context(|| format!("create directory {}", target.display()))?;
2034                }
2035            }
2036            TemplatePlanStep::WriteFiles { files } => {
2037                for (rel, content) in files {
2038                    let target = pack_dir.join(render_template_string(
2039                        rel,
2040                        extension_type,
2041                        template,
2042                        i18n,
2043                        qa_answers,
2044                        edit_answers,
2045                    ));
2046                    if let Some(parent) = target.parent() {
2047                        fs::create_dir_all(parent).with_context(|| {
2048                            format!("create parent directory {}", parent.display())
2049                        })?;
2050                    }
2051                    let rendered = render_template_content(
2052                        content,
2053                        extension_type,
2054                        template,
2055                        i18n,
2056                        qa_answers,
2057                        edit_answers,
2058                    );
2059                    fs::write(&target, rendered)
2060                        .with_context(|| format!("write file {}", target.display()))?;
2061                }
2062            }
2063            TemplatePlanStep::WriteBinaryFiles { files } => {
2064                for (rel, encoded) in files {
2065                    let target = pack_dir.join(render_template_string(
2066                        rel,
2067                        extension_type,
2068                        template,
2069                        i18n,
2070                        qa_answers,
2071                        edit_answers,
2072                    ));
2073                    if let Some(parent) = target.parent() {
2074                        fs::create_dir_all(parent).with_context(|| {
2075                            format!("create parent directory {}", parent.display())
2076                        })?;
2077                    }
2078                    let bytes = base64::engine::general_purpose::STANDARD
2079                        .decode(encoded)
2080                        .with_context(|| {
2081                            format!("decode base64 binary scaffold for {}", target.display())
2082                        })?;
2083                    fs::write(&target, bytes)
2084                        .with_context(|| format!("write file {}", target.display()))?;
2085                }
2086            }
2087            TemplatePlanStep::RunCli { command, args } => {
2088                let (rendered_command, rendered_args) = render_run_cli_invocation(
2089                    command,
2090                    args,
2091                    extension_type,
2092                    template,
2093                    i18n,
2094                    qa_answers,
2095                    edit_answers,
2096                )?;
2097                let argv = rendered_args.iter().map(String::as_str).collect::<Vec<_>>();
2098                let ok = run_process(Path::new(&rendered_command), &argv, Some(pack_dir))
2099                    .unwrap_or(false);
2100                if !ok {
2101                    return Err(anyhow!(
2102                        "template run_cli step failed: {} {:?}",
2103                        rendered_command,
2104                        rendered_args
2105                    ));
2106                }
2107            }
2108            TemplatePlanStep::Delegate { target, .. } => {
2109                let ok = match target {
2110                    greentic_types::WizardTarget::Flow => {
2111                        let args = flow_delegate_args(pack_dir);
2112                        run_delegate_owned("greentic-flow", &args, pack_dir)
2113                    }
2114                    greentic_types::WizardTarget::Component => {
2115                        run_delegate("greentic-component", &["wizard"], pack_dir)
2116                    }
2117                    _ => false,
2118                };
2119                if !ok {
2120                    return Err(anyhow!(
2121                        "template delegate step failed for target {:?}",
2122                        target
2123                    ));
2124                }
2125            }
2126        }
2127    }
2128    Ok(())
2129}
2130
2131fn ensure_extension_pack_base_scaffold(pack_dir: &Path) -> Result<()> {
2132    fs::create_dir_all(pack_dir)
2133        .with_context(|| format!("create extension pack dir {}", pack_dir.display()))?;
2134
2135    for rel in ["flows", "components", "i18n", "assets", "qa", "extensions"] {
2136        let target = pack_dir.join(rel);
2137        fs::create_dir_all(&target)
2138            .with_context(|| format!("create directory {}", target.display()))?;
2139    }
2140
2141    for (rel, contents) in [
2142        ("assets/README.md", "Add extension assets here.\n"),
2143        ("qa/README.md", "Add extension QA/setup documents here.\n"),
2144    ] {
2145        let target = pack_dir.join(rel);
2146        if !target.exists() {
2147            fs::write(&target, contents)
2148                .with_context(|| format!("write file {}", target.display()))?;
2149        }
2150    }
2151
2152    Ok(())
2153}
2154
2155fn render_template_content(
2156    content: &str,
2157    extension_type: &ExtensionType,
2158    template: &ExtensionTemplate,
2159    i18n: &WizardI18n,
2160    qa_answers: &BTreeMap<String, String>,
2161    edit_answers: &BTreeMap<String, String>,
2162) -> String {
2163    render_template_string(
2164        content,
2165        extension_type,
2166        template,
2167        i18n,
2168        qa_answers,
2169        edit_answers,
2170    )
2171}
2172
2173fn render_template_string(
2174    raw: &str,
2175    extension_type: &ExtensionType,
2176    template: &ExtensionTemplate,
2177    i18n: &WizardI18n,
2178    qa_answers: &BTreeMap<String, String>,
2179    edit_answers: &BTreeMap<String, String>,
2180) -> String {
2181    let mut rendered = raw
2182        .replace("{{extension_type_id}}", &extension_type.id)
2183        .replace(
2184            "{{extension_type_name}}",
2185            &extension_type.display_name(i18n),
2186        )
2187        .replace("{{template_id}}", &template.id)
2188        .replace("{{template_name}}", &template.display_name(i18n))
2189        .replace(
2190            "{{canonical_extension_key}}",
2191            extension_type.canonical_extension_key(),
2192        )
2193        .replace(
2194            "{{not_implemented}}",
2195            &i18n.t("wizard.shared.not_implemented"),
2196        );
2197    for (key, value) in qa_answers {
2198        rendered = rendered.replace(&format!("{{{{qa.{key}}}}}"), value);
2199    }
2200    for (key, value) in edit_answers {
2201        rendered = rendered.replace(&format!("{{{{edit.{key}}}}}"), value);
2202    }
2203    rendered
2204}
2205
2206fn render_run_cli_invocation(
2207    command: &str,
2208    args: &[String],
2209    extension_type: &ExtensionType,
2210    template: &ExtensionTemplate,
2211    i18n: &WizardI18n,
2212    qa_answers: &BTreeMap<String, String>,
2213    edit_answers: &BTreeMap<String, String>,
2214) -> Result<(String, Vec<String>)> {
2215    let rendered_command = render_template_string(
2216        command,
2217        extension_type,
2218        template,
2219        i18n,
2220        qa_answers,
2221        edit_answers,
2222    );
2223    validate_run_cli_token(&rendered_command, "command", true)?;
2224
2225    let mut rendered_args = Vec::with_capacity(args.len());
2226    for (idx, arg) in args.iter().enumerate() {
2227        let rendered = render_template_string(
2228            arg,
2229            extension_type,
2230            template,
2231            i18n,
2232            qa_answers,
2233            edit_answers,
2234        );
2235        validate_run_cli_token(&rendered, &format!("arg[{idx}]"), false)?;
2236        rendered_args.push(rendered);
2237    }
2238    Ok((rendered_command, rendered_args))
2239}
2240
2241fn validate_run_cli_token(value: &str, field: &str, require_single_word: bool) -> Result<()> {
2242    if value.trim().is_empty() {
2243        return Err(anyhow!(
2244            "template run_cli {field} resolved to an empty value"
2245        ));
2246    }
2247    if value.contains("{{") || value.contains("}}") {
2248        return Err(anyhow!(
2249            "template run_cli {field} contains unresolved placeholders: {value}"
2250        ));
2251    }
2252    if value
2253        .chars()
2254        .any(|ch| ch == '\0' || ch == '\n' || ch == '\r' || ch.is_control())
2255    {
2256        return Err(anyhow!(
2257            "template run_cli {field} contains control characters"
2258        ));
2259    }
2260    if require_single_word && value.chars().any(char::is_whitespace) {
2261        return Err(anyhow!(
2262            "template run_cli {field} must not contain whitespace"
2263        ));
2264    }
2265    Ok(())
2266}
2267
2268fn ask_template_qa_answers<R: BufRead, W: Write>(
2269    input: &mut R,
2270    output: &mut W,
2271    i18n: &WizardI18n,
2272    template: &ExtensionTemplate,
2273) -> Result<BTreeMap<String, String>> {
2274    let mut answers = BTreeMap::new();
2275    for question in &template.qa_questions {
2276        let value = ask_catalog_question(
2277            input,
2278            output,
2279            i18n,
2280            &format!("pack.wizard.create_ext.qa.{}", question.id),
2281            question,
2282        )?;
2283        answers.insert(question.id.clone(), value);
2284    }
2285    Ok(answers)
2286}
2287
2288fn ask_extension_edit_answers<R: BufRead, W: Write>(
2289    input: &mut R,
2290    output: &mut W,
2291    i18n: &WizardI18n,
2292    extension_type: &ExtensionType,
2293) -> Result<BTreeMap<String, String>> {
2294    let mut answers = BTreeMap::new();
2295    let mut create_offer = None;
2296    let mut requires_setup = None;
2297    for question in &extension_type.edit_questions {
2298        let is_offer_field = matches!(
2299            question.id.as_str(),
2300            "offer_id"
2301                | "cap_id"
2302                | "component_ref"
2303                | "op"
2304                | "version"
2305                | "priority"
2306                | "requires_setup"
2307                | "qa_ref"
2308                | "hook_op_names"
2309        );
2310        if is_offer_field && create_offer == Some(false) {
2311            continue;
2312        }
2313        if question.id == "qa_ref" && requires_setup == Some(false) {
2314            continue;
2315        }
2316        let value = ask_catalog_question(
2317            input,
2318            output,
2319            i18n,
2320            &format!(
2321                "pack.wizard.update_ext.edit.{}.{}",
2322                extension_type.id, question.id
2323            ),
2324            question,
2325        )?;
2326        if question.id == "create_offer" {
2327            create_offer = Some(value.trim() == "true");
2328        }
2329        if question.id == "requires_setup" {
2330            requires_setup = Some(value.trim() == "true");
2331        }
2332        answers.insert(question.id.clone(), value);
2333    }
2334    Ok(answers)
2335}
2336
2337fn ask_catalog_question<R: BufRead, W: Write>(
2338    input: &mut R,
2339    output: &mut W,
2340    i18n: &WizardI18n,
2341    form_id: &str,
2342    question: &CatalogQuestion,
2343) -> Result<String> {
2344    match question.kind {
2345        CatalogQuestionKind::Enum => {
2346            let choices = question
2347                .choices
2348                .iter()
2349                .enumerate()
2350                .map(|(idx, choice)| ((idx + 1).to_string(), choice.clone()))
2351                .collect::<Vec<_>>();
2352            let mut menu = choices
2353                .iter()
2354                .map(|(id, label)| (id.clone(), label.clone()))
2355                .collect::<Vec<_>>();
2356            menu.push(("0".to_string(), i18n.t("wizard.nav.back")));
2357            let default_idx = question
2358                .default
2359                .as_deref()
2360                .and_then(|value| {
2361                    choices
2362                        .iter()
2363                        .find(|(_, label)| label == value)
2364                        .map(|(idx, _)| idx.as_str())
2365                })
2366                .unwrap_or("1");
2367            let selected = ask_enum_custom_labels_owned(
2368                input,
2369                output,
2370                i18n,
2371                form_id,
2372                &question.title_key,
2373                question.description_key.as_deref(),
2374                &menu,
2375                default_idx,
2376            )?;
2377            if selected == "0" {
2378                return Ok(question.default.clone().unwrap_or_default());
2379            }
2380            choices
2381                .iter()
2382                .find(|(idx, _)| idx == &selected)
2383                .map(|(_, label)| label.clone())
2384                .ok_or_else(|| anyhow!("invalid enum selection for {}", question.id))
2385        }
2386        CatalogQuestionKind::Boolean => {
2387            let selected = ask_enum(
2388                input,
2389                output,
2390                i18n,
2391                form_id,
2392                &question.title_key,
2393                question.description_key.as_deref(),
2394                &[
2395                    ("1", "wizard.bool.true"),
2396                    ("2", "wizard.bool.false"),
2397                    ("0", "wizard.nav.back"),
2398                ],
2399                if question.default.as_deref() == Some("false") {
2400                    "2"
2401                } else {
2402                    "1"
2403                },
2404            )?;
2405            match selected.as_str() {
2406                "1" => Ok("true".to_string()),
2407                "2" => Ok("false".to_string()),
2408                "0" => Ok(question
2409                    .default
2410                    .clone()
2411                    .unwrap_or_else(|| "false".to_string())),
2412                _ => Err(anyhow!("invalid boolean selection")),
2413            }
2414        }
2415        CatalogQuestionKind::Integer => loop {
2416            let value = ask_text(
2417                input,
2418                output,
2419                i18n,
2420                form_id,
2421                &question.title_key,
2422                question.description_key.as_deref(),
2423                question.default.as_deref(),
2424            )?;
2425            if value.trim().parse::<i64>().is_ok() {
2426                break Ok(value);
2427            }
2428            wizard_ui::render_line(output, &i18n.t("wizard.error.invalid_selection"))?;
2429        },
2430        CatalogQuestionKind::String => ask_text(
2431            input,
2432            output,
2433            i18n,
2434            form_id,
2435            &question.title_key,
2436            question.description_key.as_deref(),
2437            question.default.as_deref(),
2438        ),
2439    }
2440}
2441
2442fn persist_extension_edit_answers(
2443    pack_dir: &Path,
2444    extension_type: &ExtensionType,
2445    operation: &ExtensionOperationRecord,
2446) -> Result<()> {
2447    validate_capability_offer_component_ref(
2448        pack_dir,
2449        extension_type,
2450        &operation.template_qa_answers,
2451        &operation.edit_answers,
2452    )?;
2453    let dir = pack_dir.join("extensions");
2454    fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
2455    let path = dir.join(format!("{}.json", extension_type.id));
2456    let mut payload = json!({
2457        "extension_type": extension_type.id,
2458        "canonical_extension_key": extension_type.canonical_extension_key(),
2459        "operation": operation.operation,
2460        "catalog_ref": operation.catalog_ref,
2461        "template_id": operation.template_id,
2462        "template_qa_answers": operation.template_qa_answers,
2463        "edit_answers": operation.edit_answers,
2464    });
2465    if uses_capabilities_extension(extension_type) {
2466        payload["capabilities_extension"] = serde_json::to_value(build_capabilities_payload(
2467            extension_type,
2468            &operation.template_qa_answers,
2469            &operation.edit_answers,
2470        )?)
2471        .context("serialize capabilities extension payload")?;
2472    } else if uses_deployer_extension(extension_type) {
2473        payload["deployer_extension"] = build_deployer_payload(
2474            extension_type,
2475            &operation.template_qa_answers,
2476            &operation.edit_answers,
2477        )?;
2478    }
2479    let bytes =
2480        serde_json::to_vec_pretty(&payload).context("serialize extension edit answers payload")?;
2481    fs::write(&path, bytes).with_context(|| format!("write {}", path.display()))?;
2482    merge_extension_answers_into_pack_yaml(
2483        pack_dir,
2484        extension_type,
2485        &operation.template_qa_answers,
2486        &operation.edit_answers,
2487    )?;
2488    Ok(())
2489}
2490
2491fn merge_extension_answers_into_pack_yaml(
2492    pack_dir: &Path,
2493    extension_type: &ExtensionType,
2494    template_qa_answers: &BTreeMap<String, String>,
2495    edit_answers: &BTreeMap<String, String>,
2496) -> Result<()> {
2497    if !uses_capabilities_extension(extension_type) {
2498        if uses_deployer_extension(extension_type) {
2499            let pack_yaml = pack_dir.join("pack.yaml");
2500            if !pack_yaml.exists() {
2501                return Ok(());
2502            }
2503            let contents = fs::read_to_string(&pack_yaml)
2504                .with_context(|| format!("read {}", pack_yaml.display()))?;
2505            let serialized = inject_deployer_extension_payload(
2506                &contents,
2507                &build_deployer_payload(extension_type, template_qa_answers, edit_answers)?,
2508            )?;
2509            fs::write(&pack_yaml, serialized)
2510                .with_context(|| format!("write {}", pack_yaml.display()))?;
2511        }
2512        return Ok(());
2513    }
2514    let pack_yaml = pack_dir.join("pack.yaml");
2515    if !pack_yaml.exists() {
2516        return Ok(());
2517    }
2518    let contents =
2519        fs::read_to_string(&pack_yaml).with_context(|| format!("read {}", pack_yaml.display()))?;
2520    let capabilities =
2521        build_capabilities_payload(extension_type, template_qa_answers, edit_answers)?;
2522    let serialized = if let Some(spec) =
2523        capability_offer_spec_from_answers(extension_type, template_qa_answers, edit_answers)?
2524    {
2525        inject_capability_offer_spec(&contents, &spec)?
2526    } else {
2527        ensure_capabilities_extension(&contents)?
2528    };
2529    let _ = capabilities;
2530    fs::write(&pack_yaml, serialized).with_context(|| format!("write {}", pack_yaml.display()))?;
2531    Ok(())
2532}
2533
2534fn validate_capability_offer_component_ref(
2535    pack_dir: &Path,
2536    extension_type: &ExtensionType,
2537    template_qa_answers: &BTreeMap<String, String>,
2538    edit_answers: &BTreeMap<String, String>,
2539) -> Result<()> {
2540    if !uses_capabilities_extension(extension_type) {
2541        return Ok(());
2542    }
2543    let Some(spec) =
2544        capability_offer_spec_from_answers(extension_type, template_qa_answers, edit_answers)?
2545    else {
2546        return Ok(());
2547    };
2548    let pack_yaml = pack_dir.join("pack.yaml");
2549    if !pack_yaml.exists() {
2550        return Ok(());
2551    }
2552    let config = crate::config::load_pack_config(pack_dir)?;
2553    if config
2554        .components
2555        .iter()
2556        .any(|item| item.id == spec.component_ref)
2557    {
2558        return Ok(());
2559    }
2560    Err(anyhow!(
2561        "capability offer component_ref `{}` does not match any components[].id in pack.yaml; scaffold a component with that id or set create_offer=false",
2562        spec.component_ref
2563    ))
2564}
2565
2566fn persist_extension_state(
2567    pack_dir: &Path,
2568    extension_type: &ExtensionType,
2569    operation: &ExtensionOperationRecord,
2570) -> Result<()> {
2571    persist_extension_edit_answers(pack_dir, extension_type, operation)
2572}
2573
2574fn build_capabilities_payload(
2575    extension_type: &ExtensionType,
2576    template_qa_answers: &BTreeMap<String, String>,
2577    edit_answers: &BTreeMap<String, String>,
2578) -> Result<CapabilitiesExtensionV1> {
2579    let offer =
2580        capability_offer_spec_from_answers(extension_type, template_qa_answers, edit_answers)?.map(
2581            |spec| greentic_types::pack::extensions::capabilities::CapabilityOfferV1 {
2582                offer_id: spec.offer_id,
2583                cap_id: spec.cap_id,
2584                version: spec.version,
2585                provider: greentic_types::pack::extensions::capabilities::CapabilityProviderRefV1 {
2586                    component_ref: spec.component_ref,
2587                    op: spec.op,
2588                },
2589                scope: None,
2590                priority: spec.priority,
2591                requires_setup: spec.requires_setup,
2592                setup: spec.qa_ref.map(|qa_ref| {
2593                    greentic_types::pack::extensions::capabilities::CapabilitySetupV1 { qa_ref }
2594                }),
2595                applies_to: (!spec.hook_op_names.is_empty()).then_some(
2596                    greentic_types::pack::extensions::capabilities::CapabilityHookAppliesToV1 {
2597                        op_names: spec.hook_op_names,
2598                    },
2599                ),
2600            },
2601        );
2602    Ok(CapabilitiesExtensionV1::new(offer.into_iter().collect()))
2603}
2604
2605fn build_deployer_payload(
2606    _extension_type: &ExtensionType,
2607    _template_qa_answers: &BTreeMap<String, String>,
2608    edit_answers: &BTreeMap<String, String>,
2609) -> Result<Value> {
2610    let contract_id = required_answer(edit_answers, "contract_id")?;
2611    let ops = optional_answer(edit_answers, "supported_ops")
2612        .unwrap_or_else(|| "generate,plan,apply,destroy,status,rollback".to_string())
2613        .split(',')
2614        .map(str::trim)
2615        .filter(|item| !item.is_empty())
2616        .map(ToString::to_string)
2617        .collect::<Vec<_>>();
2618    if ops.is_empty() {
2619        return Err(anyhow!("missing required answer `supported_ops`"));
2620    }
2621    let flow_refs = ops
2622        .iter()
2623        .map(|op| (op.clone(), Value::String(format!("flows/{op}.ygtc"))))
2624        .collect::<serde_json::Map<_, _>>();
2625
2626    Ok(json!({
2627        "version": 1,
2628        "provides": [{
2629            "capability": DEPLOYER_EXTENSION_KEY,
2630            "contract": contract_id,
2631            "ops": ops,
2632        }],
2633        "flow_refs": flow_refs,
2634    }))
2635}
2636
2637fn capability_offer_spec_from_answers(
2638    extension_type: &ExtensionType,
2639    template_qa_answers: &BTreeMap<String, String>,
2640    edit_answers: &BTreeMap<String, String>,
2641) -> Result<Option<CapabilityOfferSpec>> {
2642    let create_offer = match edit_answers.get("create_offer").map(|value| value.trim()) {
2643        None | Some("") => false,
2644        Some("true") => true,
2645        Some("false") => false,
2646        Some(other) => return Err(anyhow!("invalid create_offer value `{other}`")),
2647    };
2648    if !create_offer {
2649        return Ok(None);
2650    }
2651
2652    let offer_id = required_answer(edit_answers, "offer_id")?;
2653    let cap_id = required_answer(edit_answers, "cap_id")?;
2654    let component_ref = required_answer(edit_answers, "component_ref")?;
2655    let op = required_answer(edit_answers, "op")?;
2656    let version = optional_answer(edit_answers, "version")
2657        .unwrap_or_else(|| default_capability_version(extension_type));
2658    let priority = optional_answer(edit_answers, "priority")
2659        .unwrap_or_else(|| "0".to_string())
2660        .parse::<i32>()
2661        .with_context(|| format!("invalid priority for extension type {}", extension_type.id))?;
2662    let requires_setup = matches!(
2663        edit_answers.get("requires_setup").map(|value| value.trim()),
2664        Some("true")
2665    );
2666    let qa_ref = if requires_setup {
2667        optional_answer(edit_answers, "qa_ref")
2668            .or_else(|| optional_answer(template_qa_answers, "qa_ref"))
2669    } else {
2670        None
2671    };
2672    if requires_setup && qa_ref.is_none() {
2673        return Err(anyhow!(
2674            "extension type {} requires qa_ref when requires_setup=true",
2675            extension_type.id
2676        ));
2677    }
2678    let hook_op_names = optional_answer(edit_answers, "hook_op_names")
2679        .map(|value| {
2680            value
2681                .split(',')
2682                .map(str::trim)
2683                .filter(|item| !item.is_empty())
2684                .map(ToString::to_string)
2685                .collect::<Vec<_>>()
2686        })
2687        .unwrap_or_default();
2688
2689    Ok(Some(CapabilityOfferSpec {
2690        offer_id,
2691        cap_id,
2692        version,
2693        component_ref,
2694        op,
2695        priority,
2696        requires_setup,
2697        qa_ref,
2698        hook_op_names,
2699    }))
2700}
2701
2702fn required_answer(answers: &BTreeMap<String, String>, key: &str) -> Result<String> {
2703    answers
2704        .get(key)
2705        .map(|value| value.trim())
2706        .filter(|value| !value.is_empty())
2707        .map(ToString::to_string)
2708        .ok_or_else(|| anyhow!("missing required answer `{key}`"))
2709}
2710
2711fn optional_answer(answers: &BTreeMap<String, String>, key: &str) -> Option<String> {
2712    answers
2713        .get(key)
2714        .map(|value| value.trim())
2715        .filter(|value| !value.is_empty())
2716        .map(ToString::to_string)
2717}
2718
2719fn default_capability_version(_extension_type: &ExtensionType) -> String {
2720    "v1".to_string()
2721}
2722
2723fn inject_deployer_extension_payload(contents: &str, payload: &Value) -> Result<String> {
2724    let mut document: YamlValue = serde_yaml_bw::from_str(contents)
2725        .context("parse pack.yaml for deployer extension merge")?;
2726    let mapping = document
2727        .as_mapping_mut()
2728        .ok_or_else(|| anyhow!("pack.yaml root must be a mapping"))?;
2729    let extensions = mapping
2730        .entry(yaml_key("extensions"))
2731        .or_insert_with(|| YamlValue::Mapping(Mapping::new()));
2732    let extensions_map = extensions
2733        .as_mapping_mut()
2734        .ok_or_else(|| anyhow!("extensions must be a mapping"))?;
2735    let extension_slot = extensions_map
2736        .entry(yaml_key(DEPLOYER_EXTENSION_KEY))
2737        .or_insert_with(|| YamlValue::Mapping(Mapping::new()));
2738    let extension_map = extension_slot
2739        .as_mapping_mut()
2740        .ok_or_else(|| anyhow!("deployer extension slot must be a mapping"))?;
2741    extension_map
2742        .entry(yaml_key("kind"))
2743        .or_insert_with(|| YamlValue::String(DEPLOYER_EXTENSION_KEY.to_string(), None));
2744    extension_map
2745        .entry(yaml_key("version"))
2746        .or_insert_with(|| YamlValue::String("1.0.0".to_string(), None));
2747    extension_map.insert(
2748        yaml_key("inline"),
2749        serde_yaml_bw::to_value(payload).context("serialize deployer extension payload")?,
2750    );
2751
2752    serde_yaml_bw::to_string(&document).context("serialize updated pack.yaml")
2753}
2754
2755fn yaml_key(key: &str) -> YamlValue {
2756    YamlValue::String(key.to_string(), None)
2757}
2758
2759fn uses_capabilities_extension(extension_type: &ExtensionType) -> bool {
2760    extension_type.canonical_extension_key() == CAPABILITIES_EXTENSION_KEY
2761}
2762
2763fn uses_deployer_extension(extension_type: &ExtensionType) -> bool {
2764    extension_type.canonical_extension_key() == DEPLOYER_EXTENSION_KEY
2765}
2766
2767fn validate_extension_operation_record(operation: &ExtensionOperationRecord) -> Result<()> {
2768    match operation.operation.as_str() {
2769        "create_extension_pack" | "update_extension_pack" | "add_extension" => {}
2770        other => {
2771            return Err(anyhow!(
2772                "unsupported extension operation `{other}` in answers document"
2773            ));
2774        }
2775    }
2776    if operation.catalog_ref.trim().is_empty() {
2777        return Err(anyhow!("extension catalog ref must not be empty"));
2778    }
2779    if operation.extension_type_id.trim().is_empty() {
2780        return Err(anyhow!("extension type id must not be empty"));
2781    }
2782    if operation.operation == "create_extension_pack" && operation.template_id.is_none() {
2783        return Err(anyhow!(
2784            "create_extension_pack requires answers.extension_template_id"
2785        ));
2786    }
2787    Ok(())
2788}
2789
2790fn apply_extension_operation(pack_dir: &Path, operation: &ExtensionOperationRecord) -> Result<()> {
2791    if operation.extension_type_id == LEGACY_MESSAGING_WEBCHAT_GUI_EXTENSION_ID {
2792        return apply_legacy_messaging_webchat_gui_extension(pack_dir, operation);
2793    }
2794    let catalog = load_extension_catalog(&operation.catalog_ref, None)?;
2795    let extension_type = catalog
2796        .extension_types
2797        .iter()
2798        .find(|item| item.id == operation.extension_type_id)
2799        .ok_or_else(|| {
2800            anyhow!(
2801                "extension type `{}` not found in catalog",
2802                operation.extension_type_id
2803            )
2804        })?;
2805
2806    if operation.operation == "create_extension_pack" {
2807        let template_id = operation
2808            .template_id
2809            .as_deref()
2810            .ok_or_else(|| anyhow!("missing template_id for create_extension_pack"))?;
2811        let template = extension_type
2812            .templates
2813            .iter()
2814            .find(|item| item.id == template_id)
2815            .ok_or_else(|| anyhow!("template `{template_id}` not found in catalog"))?;
2816        let i18n = WizardI18n::new(Some("en-GB"));
2817        apply_template_plan(
2818            template,
2819            pack_dir,
2820            extension_type,
2821            &i18n,
2822            &operation.template_qa_answers,
2823            &operation.edit_answers,
2824        )?;
2825    }
2826
2827    persist_extension_state(pack_dir, extension_type, operation)
2828}
2829
2830fn apply_legacy_messaging_webchat_gui_extension(
2831    pack_dir: &Path,
2832    operation: &ExtensionOperationRecord,
2833) -> Result<()> {
2834    let pack_yaml = pack_dir.join("pack.yaml");
2835    let contents =
2836        fs::read_to_string(&pack_yaml).with_context(|| format!("read {}", pack_yaml.display()))?;
2837    let provider_id = optional_answer(&operation.edit_answers, "entry_label")
2838        .unwrap_or_else(|| LEGACY_MESSAGING_WEBCHAT_GUI_EXTENSION_ID.to_string());
2839    let version = crate::config::load_pack_config(pack_dir)
2840        .map(|cfg| cfg.version.to_string())
2841        .unwrap_or_else(|_| "0.1.0".to_string());
2842    let updated = inject_provider_entry_for_wizard(&contents, &provider_id, "messaging", &version)?;
2843    fs::write(&pack_yaml, updated).with_context(|| format!("write {}", pack_yaml.display()))?;
2844    Ok(())
2845}
2846
2847fn ask_main_menu<R: BufRead, W: Write>(
2848    input: &mut R,
2849    output: &mut W,
2850    i18n: &WizardI18n,
2851) -> Result<MainChoice> {
2852    let choice = ask_enum(
2853        input,
2854        output,
2855        i18n,
2856        "pack.wizard.main",
2857        "wizard.main.title",
2858        Some("wizard.main.description"),
2859        &[
2860            ("1", "wizard.main.option.create_application_pack"),
2861            ("2", "wizard.main.option.update_application_pack"),
2862            ("3", "wizard.main.option.create_extension_pack"),
2863            ("4", "wizard.main.option.update_extension_pack"),
2864            ("5", "wizard.main.option.add_extension"),
2865            ("0", "wizard.main.option.exit"),
2866        ],
2867        "0",
2868    )?;
2869    MainChoice::from_choice(&choice)
2870}
2871
2872fn ask_placeholder_submenu<R: BufRead, W: Write>(
2873    input: &mut R,
2874    output: &mut W,
2875    i18n: &WizardI18n,
2876    title_key: &str,
2877) -> Result<SubmenuAction> {
2878    let choice = ask_enum(
2879        input,
2880        output,
2881        i18n,
2882        "pack.wizard.placeholder",
2883        title_key,
2884        Some("wizard.shared.not_implemented"),
2885        &[("0", "wizard.nav.back"), ("M", "wizard.nav.main_menu")],
2886        "M",
2887    )?;
2888    SubmenuAction::from_choice(&choice)
2889}
2890
2891fn run_create_application_pack<R: BufRead, W: Write>(
2892    input: &mut R,
2893    output: &mut W,
2894    i18n: &WizardI18n,
2895    session: &mut WizardSession,
2896) -> Result<()> {
2897    session
2898        .selected_actions
2899        .push("create_application_pack.start".to_string());
2900    let pack_id = ask_text(
2901        input,
2902        output,
2903        i18n,
2904        "pack.wizard.create_app.pack_id",
2905        "wizard.create_application_pack.ask_pack_id",
2906        None,
2907        None,
2908    )?;
2909
2910    let pack_dir_default = format!("./{pack_id}");
2911    let pack_dir = ask_text(
2912        input,
2913        output,
2914        i18n,
2915        "pack.wizard.create_app.pack_dir",
2916        "wizard.create_application_pack.ask_pack_dir",
2917        Some("wizard.create_application_pack.ask_pack_dir_help"),
2918        Some(&pack_dir_default),
2919    )?;
2920
2921    let pack_dir_path = PathBuf::from(pack_dir.trim());
2922    session.last_pack_dir = Some(pack_dir_path.clone());
2923    session.create_pack_scaffold = true;
2924    session.create_pack_id = Some(pack_id.clone());
2925    let self_exe = wizard_self_exe()?;
2926
2927    let scaffold_ok = if session.dry_run {
2928        wizard_ui::render_line(output, &i18n.t("wizard.dry_run.skipping_scaffold"))?;
2929        let temp_pack_dir = temp_answers_path("greentic-pack-dry-run-pack");
2930        let ok = run_process(
2931            &self_exe,
2932            &[
2933                "new",
2934                "--dir",
2935                &temp_pack_dir.display().to_string(),
2936                &pack_id,
2937            ],
2938            None,
2939        )?;
2940        if ok {
2941            session.dry_run_delegate_pack_dir = Some(temp_pack_dir);
2942        }
2943        ok
2944    } else {
2945        run_process(
2946            &self_exe,
2947            &[
2948                "new",
2949                "--dir",
2950                &pack_dir_path.display().to_string(),
2951                &pack_id,
2952            ],
2953            None,
2954        )?
2955    };
2956    if !scaffold_ok {
2957        wizard_ui::render_line(output, &i18n.t("wizard.error.create_app_failed"))?;
2958        let nav = ask_failure_nav(input, output, i18n)?;
2959        if matches!(nav, SubmenuAction::MainMenu) {
2960            return Ok(());
2961        }
2962        return Ok(());
2963    }
2964
2965    loop {
2966        let delegate_pack_dir = session
2967            .dry_run_delegate_pack_dir
2968            .as_deref()
2969            .unwrap_or(&pack_dir_path)
2970            .to_path_buf();
2971        let setup_choice = ask_enum(
2972            input,
2973            output,
2974            i18n,
2975            "pack.wizard.create_app.setup",
2976            "wizard.create_application_pack.setup.title",
2977            Some("wizard.create_application_pack.setup.description"),
2978            &[
2979                (
2980                    "1",
2981                    "wizard.create_application_pack.setup.option.edit_flows",
2982                ),
2983                (
2984                    "2",
2985                    "wizard.create_application_pack.setup.option.add_edit_components",
2986                ),
2987                ("3", "wizard.create_application_pack.setup.option.finalize"),
2988                ("0", "wizard.nav.back"),
2989                ("M", "wizard.nav.main_menu"),
2990            ],
2991            "M",
2992        )?;
2993
2994        match setup_choice.as_str() {
2995            "1" => {
2996                session.run_delegate_flow = true;
2997                let delegate_ok = run_flow_delegate_for_session(session, &delegate_pack_dir);
2998                if !delegate_ok
2999                    && handle_delegate_failure(
3000                        input,
3001                        output,
3002                        i18n,
3003                        session,
3004                        "wizard.error.delegate_flow_failed",
3005                    )?
3006                {
3007                    return Ok(());
3008                }
3009            }
3010            "2" => {
3011                session.run_delegate_component = true;
3012                let delegate_ok = run_component_delegate_for_session(session, &delegate_pack_dir);
3013                if !delegate_ok
3014                    && handle_delegate_failure(
3015                        input,
3016                        output,
3017                        i18n,
3018                        session,
3019                        "wizard.error.delegate_component_failed",
3020                    )?
3021                {
3022                    return Ok(());
3023                }
3024            }
3025            "3" => {
3026                if finalize_create_app(input, output, i18n, session, &self_exe, &pack_dir_path)? {
3027                    return Ok(());
3028                }
3029            }
3030            "0" | "M" | "m" => return Ok(()),
3031            _ => {
3032                wizard_ui::render_line(output, &i18n.t("wizard.error.invalid_selection"))?;
3033            }
3034        }
3035    }
3036}
3037
3038fn finalize_create_app<R: BufRead, W: Write>(
3039    input: &mut R,
3040    output: &mut W,
3041    i18n: &WizardI18n,
3042    session: &mut WizardSession,
3043    self_exe: &Path,
3044    pack_dir_path: &Path,
3045) -> Result<bool> {
3046    run_update_validate_sequence(
3047        input,
3048        output,
3049        i18n,
3050        session,
3051        self_exe,
3052        pack_dir_path,
3053        true,
3054        "wizard.progress.running_finalize",
3055    )
3056}
3057
3058fn run_update_application_pack<R: BufRead, W: Write>(
3059    input: &mut R,
3060    output: &mut W,
3061    i18n: &WizardI18n,
3062    session: &mut WizardSession,
3063) -> Result<()> {
3064    let pack_dir_path = ask_existing_pack_dir(
3065        input,
3066        output,
3067        i18n,
3068        "pack.wizard.update_app.pack_dir",
3069        "wizard.update_application_pack.ask_pack_dir",
3070        Some("wizard.update_application_pack.ask_pack_dir_help"),
3071        Some("."),
3072    )?;
3073    session.last_pack_dir = Some(pack_dir_path.clone());
3074    let self_exe = wizard_self_exe()?;
3075
3076    loop {
3077        let choice = ask_enum(
3078            input,
3079            output,
3080            i18n,
3081            "pack.wizard.update_app.menu",
3082            "wizard.update_application_pack.menu.title",
3083            Some("wizard.update_application_pack.menu.description"),
3084            &[
3085                ("1", "wizard.update_application_pack.menu.option.edit_flows"),
3086                (
3087                    "2",
3088                    "wizard.update_application_pack.menu.option.add_edit_components",
3089                ),
3090                (
3091                    "3",
3092                    "wizard.update_application_pack.menu.option.run_update_validate",
3093                ),
3094                ("4", "wizard.update_application_pack.menu.option.sign"),
3095                ("0", "wizard.nav.back"),
3096                ("M", "wizard.nav.main_menu"),
3097            ],
3098            "M",
3099        )?;
3100
3101        match choice.as_str() {
3102            "1" => {
3103                session
3104                    .selected_actions
3105                    .push("update_application_pack.edit_flows".to_string());
3106                session.run_delegate_flow = true;
3107                let delegate_ok = run_flow_delegate_for_session(session, &pack_dir_path);
3108                if delegate_ok {
3109                    let _ = run_update_validate_sequence(
3110                        input,
3111                        output,
3112                        i18n,
3113                        session,
3114                        &self_exe,
3115                        &pack_dir_path,
3116                        true,
3117                        "wizard.progress.auto_run_update_validate",
3118                    )?;
3119                } else if handle_delegate_failure(
3120                    input,
3121                    output,
3122                    i18n,
3123                    session,
3124                    "wizard.error.delegate_flow_failed",
3125                )? {
3126                    return Ok(());
3127                }
3128            }
3129            "2" => {
3130                session
3131                    .selected_actions
3132                    .push("update_application_pack.add_edit_components".to_string());
3133                session.run_delegate_component = true;
3134                let delegate_ok = run_component_delegate_for_session(session, &pack_dir_path);
3135                if delegate_ok {
3136                    let _ = run_update_validate_sequence(
3137                        input,
3138                        output,
3139                        i18n,
3140                        session,
3141                        &self_exe,
3142                        &pack_dir_path,
3143                        true,
3144                        "wizard.progress.auto_run_update_validate",
3145                    )?;
3146                } else if handle_delegate_failure(
3147                    input,
3148                    output,
3149                    i18n,
3150                    session,
3151                    "wizard.error.delegate_component_failed",
3152                )? {
3153                    return Ok(());
3154                }
3155            }
3156            "3" => {
3157                session
3158                    .selected_actions
3159                    .push("update_application_pack.run_update_validate".to_string());
3160                let _ = run_update_validate_sequence(
3161                    input,
3162                    output,
3163                    i18n,
3164                    session,
3165                    &self_exe,
3166                    &pack_dir_path,
3167                    true,
3168                    "wizard.progress.running_update_validate",
3169                )?;
3170            }
3171            "4" => {
3172                session
3173                    .selected_actions
3174                    .push("update_application_pack.sign".to_string());
3175                let _ = run_sign_for_pack(input, output, i18n, session, &self_exe, &pack_dir_path)?;
3176            }
3177            "0" | "M" | "m" => return Ok(()),
3178            _ => {
3179                wizard_ui::render_line(output, &i18n.t("wizard.error.invalid_selection"))?;
3180            }
3181        }
3182    }
3183}
3184
3185fn run_update_extension_pack<R: BufRead, W: Write>(
3186    input: &mut R,
3187    output: &mut W,
3188    i18n: &WizardI18n,
3189    session: &mut WizardSession,
3190    runtime: Option<&RuntimeContext>,
3191) -> Result<()> {
3192    session
3193        .selected_actions
3194        .push("update_extension_pack.start".to_string());
3195    let pack_dir_path = ask_existing_pack_dir(
3196        input,
3197        output,
3198        i18n,
3199        "pack.wizard.update_ext.pack_dir",
3200        "wizard.update_extension_pack.ask_pack_dir",
3201        Some("wizard.update_extension_pack.ask_pack_dir_help"),
3202        Some("."),
3203    )?;
3204    session.last_pack_dir = Some(pack_dir_path.clone());
3205    let catalog_ref = prompt_for_extension_catalog_ref(input, output, i18n)?;
3206
3207    let catalog = match load_extension_catalog(catalog_ref.trim(), runtime) {
3208        Ok(value) => value,
3209        Err(err) => {
3210            wizard_ui::render_line(
3211                output,
3212                &format!("{}: {}", i18n.t("wizard.error.catalog_load_failed"), err),
3213            )?;
3214            let nav = ask_failure_nav(input, output, i18n)?;
3215            if matches!(nav, SubmenuAction::MainMenu) {
3216                return Ok(());
3217            }
3218            return Ok(());
3219        }
3220    };
3221
3222    let self_exe = wizard_self_exe()?;
3223
3224    loop {
3225        let choice = ask_enum(
3226            input,
3227            output,
3228            i18n,
3229            "pack.wizard.update_ext.menu",
3230            "wizard.update_extension_pack.menu.title",
3231            Some("wizard.update_extension_pack.menu.description"),
3232            &[
3233                ("1", "wizard.update_extension_pack.menu.option.edit_entries"),
3234                ("2", "wizard.update_extension_pack.menu.option.edit_flows"),
3235                (
3236                    "3",
3237                    "wizard.update_extension_pack.menu.option.add_edit_components",
3238                ),
3239                (
3240                    "4",
3241                    "wizard.update_extension_pack.menu.option.run_update_validate",
3242                ),
3243                ("5", "wizard.update_extension_pack.menu.option.sign"),
3244                ("0", "wizard.nav.back"),
3245                ("M", "wizard.nav.main_menu"),
3246            ],
3247            "M",
3248        )?;
3249
3250        match choice.as_str() {
3251            "1" => {
3252                let type_choice = ask_extension_type(input, output, i18n, &catalog)?;
3253                if type_choice == "0" || type_choice.eq_ignore_ascii_case("m") {
3254                    continue;
3255                }
3256                let selected = catalog
3257                    .extension_types
3258                    .iter()
3259                    .find(|item| item.id == type_choice)
3260                    .ok_or_else(|| anyhow!("selected extension type not found"))?;
3261                let answers = ask_extension_edit_answers(input, output, i18n, selected)?;
3262                let operation = ExtensionOperationRecord {
3263                    operation: "update_extension_pack".to_string(),
3264                    catalog_ref: catalog_ref.trim().to_string(),
3265                    extension_type_id: selected.id.clone(),
3266                    template_id: None,
3267                    template_qa_answers: BTreeMap::new(),
3268                    edit_answers: answers.clone(),
3269                };
3270                session.extension_operation = Some(operation.clone());
3271                if !session.dry_run {
3272                    persist_extension_edit_answers(&pack_dir_path, selected, &operation)?;
3273                } else {
3274                    wizard_ui::render_line(
3275                        output,
3276                        &i18n.t("wizard.dry_run.skipping_edit_entry_persist"),
3277                    )?;
3278                }
3279                wizard_ui::render_line(
3280                    output,
3281                    &format!(
3282                        "{} {}",
3283                        i18n.t("wizard.update_extension_pack.edited_entry"),
3284                        type_choice
3285                    ),
3286                )?;
3287            }
3288            "2" => {
3289                session.run_delegate_flow = true;
3290                let delegate_ok = run_flow_delegate_for_session(session, &pack_dir_path);
3291                if !delegate_ok
3292                    && handle_delegate_failure(
3293                        input,
3294                        output,
3295                        i18n,
3296                        session,
3297                        "wizard.error.delegate_flow_failed",
3298                    )?
3299                {
3300                    return Ok(());
3301                }
3302            }
3303            "3" => {
3304                session.run_delegate_component = true;
3305                let delegate_ok = run_component_delegate_for_session(session, &pack_dir_path);
3306                if !delegate_ok
3307                    && handle_delegate_failure(
3308                        input,
3309                        output,
3310                        i18n,
3311                        session,
3312                        "wizard.error.delegate_component_failed",
3313                    )?
3314                {
3315                    return Ok(());
3316                }
3317            }
3318            "4" => {
3319                let _ = run_update_validate_sequence(
3320                    input,
3321                    output,
3322                    i18n,
3323                    session,
3324                    &self_exe,
3325                    &pack_dir_path,
3326                    true,
3327                    "wizard.progress.running_update_validate",
3328                )?;
3329            }
3330            "5" => {
3331                let _ = run_sign_for_pack(input, output, i18n, session, &self_exe, &pack_dir_path)?;
3332            }
3333            "0" | "M" | "m" => return Ok(()),
3334            _ => {
3335                wizard_ui::render_line(output, &i18n.t("wizard.error.invalid_selection"))?;
3336            }
3337        }
3338    }
3339}
3340
3341fn run_add_extension<R: BufRead, W: Write>(
3342    input: &mut R,
3343    output: &mut W,
3344    i18n: &WizardI18n,
3345    session: &mut WizardSession,
3346    runtime: Option<&RuntimeContext>,
3347) -> Result<()> {
3348    session
3349        .selected_actions
3350        .push("add_extension.start".to_string());
3351    let pack_dir_path = ask_existing_pack_dir(
3352        input,
3353        output,
3354        i18n,
3355        "pack.wizard.add_ext.pack_dir",
3356        "wizard.update_extension_pack.ask_pack_dir",
3357        Some("wizard.update_extension_pack.ask_pack_dir_help"),
3358        Some("."),
3359    )?;
3360    session.last_pack_dir = Some(pack_dir_path.clone());
3361    let catalog_ref = prompt_for_extension_catalog_ref(input, output, i18n)?;
3362
3363    let catalog = match load_extension_catalog(catalog_ref.trim(), runtime) {
3364        Ok(value) => value,
3365        Err(err) => {
3366            wizard_ui::render_line(
3367                output,
3368                &format!("{}: {}", i18n.t("wizard.error.catalog_load_failed"), err),
3369            )?;
3370            let nav = ask_failure_nav(input, output, i18n)?;
3371            if matches!(nav, SubmenuAction::MainMenu) {
3372                return Ok(());
3373            }
3374            return Ok(());
3375        }
3376    };
3377
3378    let type_choice = ask_extension_type(input, output, i18n, &catalog)?;
3379    if type_choice == "0" || type_choice.eq_ignore_ascii_case("m") {
3380        return Ok(());
3381    }
3382    let selected = catalog
3383        .extension_types
3384        .iter()
3385        .find(|item| item.id == type_choice)
3386        .ok_or_else(|| anyhow!("selected extension type not found"))?;
3387    let answers = ask_extension_edit_answers(input, output, i18n, selected)?;
3388    let operation = ExtensionOperationRecord {
3389        operation: "add_extension".to_string(),
3390        catalog_ref: catalog_ref.trim().to_string(),
3391        extension_type_id: selected.id.clone(),
3392        template_id: None,
3393        template_qa_answers: BTreeMap::new(),
3394        edit_answers: answers.clone(),
3395    };
3396    session.extension_operation = Some(operation.clone());
3397    if !session.dry_run {
3398        persist_extension_edit_answers(&pack_dir_path, selected, &operation)?;
3399        wizard_ui::render_line(output, &i18n.t("cli.wizard.updated_pack_yaml"))?;
3400    } else {
3401        wizard_ui::render_line(output, &i18n.t("cli.wizard.dry_run.update_pack_yaml"))?;
3402        let extension_path = pack_dir_path
3403            .join("extensions")
3404            .join(format!("{}.json", selected.id));
3405        let would_write = i18n.t("cli.wizard.dry_run.would_write").replacen(
3406            "{}",
3407            &extension_path.display().to_string(),
3408            1,
3409        );
3410        wizard_ui::render_line(output, &would_write)?;
3411    }
3412    session
3413        .selected_actions
3414        .push("add_extension.edit_entries".to_string());
3415    Ok(())
3416}
3417
3418#[allow(clippy::too_many_arguments)]
3419fn run_update_validate_sequence<R: BufRead, W: Write>(
3420    input: &mut R,
3421    output: &mut W,
3422    i18n: &WizardI18n,
3423    session: &mut WizardSession,
3424    self_exe: &Path,
3425    pack_dir_path: &Path,
3426    prompt_sign_after: bool,
3427    progress_key: &str,
3428) -> Result<bool> {
3429    session.run_doctor = true;
3430    session.run_build = true;
3431    session
3432        .selected_actions
3433        .push("pipeline.update_validate".to_string());
3434    if session.dry_run {
3435        wizard_ui::render_line(output, &i18n.t(progress_key))?;
3436        wizard_ui::render_line(output, &i18n.t("wizard.progress.running_doctor"))?;
3437        wizard_ui::render_line(output, &i18n.t("wizard.progress.running_build"))?;
3438        return if prompt_sign_after {
3439            run_sign_prompt_after_finalize(input, output, i18n, session, self_exe, pack_dir_path)
3440        } else {
3441            Ok(true)
3442        };
3443    }
3444
3445    wizard_ui::render_line(output, &i18n.t(progress_key))?;
3446    let update_ok = run_process(
3447        self_exe,
3448        &["update", "--in", &pack_dir_path.display().to_string()],
3449        None,
3450    )?;
3451    if !update_ok {
3452        wizard_ui::render_line(output, &i18n.t("wizard.error.finalize_build_failed"))?;
3453        return Ok(false);
3454    }
3455    wizard_ui::render_line(output, &i18n.t("wizard.progress.running_doctor"))?;
3456    let doctor_ok = run_process(
3457        self_exe,
3458        &["doctor", "--in", &pack_dir_path.display().to_string()],
3459        None,
3460    )?;
3461    if !doctor_ok {
3462        wizard_ui::render_line(output, &i18n.t("wizard.error.finalize_doctor_failed"))?;
3463        return Ok(false);
3464    }
3465
3466    let resolve_ok = run_process(
3467        self_exe,
3468        &["resolve", "--in", &pack_dir_path.display().to_string()],
3469        None,
3470    )?;
3471    if !resolve_ok {
3472        wizard_ui::render_line(output, &i18n.t("wizard.error.finalize_build_failed"))?;
3473        return Ok(false);
3474    }
3475
3476    wizard_ui::render_line(output, &i18n.t("wizard.progress.running_build"))?;
3477    let build_ok = run_process(
3478        self_exe,
3479        &["build", "--in", &pack_dir_path.display().to_string()],
3480        None,
3481    )?;
3482    if !build_ok {
3483        wizard_ui::render_line(output, &i18n.t("wizard.error.finalize_build_failed"))?;
3484        return Ok(false);
3485    }
3486
3487    if prompt_sign_after {
3488        run_sign_prompt_after_finalize(input, output, i18n, session, self_exe, pack_dir_path)
3489    } else {
3490        Ok(true)
3491    }
3492}
3493
3494fn run_sign_prompt_after_finalize<R: BufRead, W: Write>(
3495    input: &mut R,
3496    output: &mut W,
3497    i18n: &WizardI18n,
3498    session: &mut WizardSession,
3499    self_exe: &Path,
3500    pack_dir_path: &Path,
3501) -> Result<bool> {
3502    let sign_choice = ask_enum(
3503        input,
3504        output,
3505        i18n,
3506        "pack.wizard.sign_prompt",
3507        "wizard.sign.after_finalize.title",
3508        Some("wizard.sign.after_finalize.description"),
3509        &[
3510            ("1", "wizard.sign.after_finalize.option.sign_now"),
3511            ("2", "wizard.sign.after_finalize.option.skip"),
3512            ("0", "wizard.nav.back"),
3513            ("M", "wizard.nav.main_menu"),
3514        ],
3515        "2",
3516    )?;
3517
3518    match sign_choice.as_str() {
3519        "2" => {
3520            session
3521                .selected_actions
3522                .push("pipeline.sign_prompt.skip".to_string());
3523            Ok(true)
3524        }
3525        "M" | "m" => {
3526            session
3527                .selected_actions
3528                .push("pipeline.sign_prompt.main_menu".to_string());
3529            Ok(true)
3530        }
3531        "0" => {
3532            session
3533                .selected_actions
3534                .push("pipeline.sign_prompt.back".to_string());
3535            Ok(false)
3536        }
3537        "1" => run_sign_for_pack(input, output, i18n, session, self_exe, pack_dir_path),
3538        _ => {
3539            wizard_ui::render_line(output, &i18n.t("wizard.error.invalid_selection"))?;
3540            Ok(false)
3541        }
3542    }
3543}
3544
3545fn run_sign_for_pack<R: BufRead, W: Write>(
3546    input: &mut R,
3547    output: &mut W,
3548    i18n: &WizardI18n,
3549    session: &mut WizardSession,
3550    self_exe: &Path,
3551    pack_dir_path: &Path,
3552) -> Result<bool> {
3553    session.selected_actions.push("pipeline.sign".to_string());
3554    let key_path = ask_text(
3555        input,
3556        output,
3557        i18n,
3558        "pack.wizard.sign_key_path",
3559        "wizard.sign.ask_key_path",
3560        None,
3561        session.sign_key_path.as_deref(),
3562    )?;
3563    let sign_ok = if session.dry_run {
3564        wizard_ui::render_line(output, &i18n.t("wizard.dry_run.skipping_sign"))?;
3565        true
3566    } else {
3567        run_process(
3568            self_exe,
3569            &[
3570                "sign",
3571                "--pack",
3572                &pack_dir_path.display().to_string(),
3573                "--key",
3574                &key_path,
3575            ],
3576            None,
3577        )?
3578    };
3579    if !sign_ok {
3580        wizard_ui::render_line(output, &i18n.t("wizard.error.sign_failed"))?;
3581        return Ok(false);
3582    }
3583    session.sign_key_path = Some(key_path);
3584    Ok(true)
3585}
3586
3587fn ask_failure_nav<R: BufRead, W: Write>(
3588    input: &mut R,
3589    output: &mut W,
3590    i18n: &WizardI18n,
3591) -> Result<SubmenuAction> {
3592    let choice = ask_enum(
3593        input,
3594        output,
3595        i18n,
3596        "pack.wizard.failure_nav",
3597        "wizard.failure_nav.title",
3598        Some("wizard.failure_nav.description"),
3599        &[("0", "wizard.nav.back"), ("M", "wizard.nav.main_menu")],
3600        "0",
3601    )?;
3602    SubmenuAction::from_choice(&choice)
3603}
3604
3605#[allow(clippy::too_many_arguments)]
3606fn ask_enum<R: BufRead, W: Write>(
3607    input: &mut R,
3608    output: &mut W,
3609    i18n: &WizardI18n,
3610    form_id: &str,
3611    title_key: &str,
3612    description_key: Option<&str>,
3613    choices: &[(&str, &str)],
3614    default_on_eof: &str,
3615) -> Result<String> {
3616    let mut question = json!({
3617        "id": "choice",
3618        "type": "enum",
3619        "title": i18n.t(title_key),
3620        "title_i18n": {"key": title_key},
3621        "required": true,
3622        "choices": choices.iter().map(|(v, _)| *v).collect::<Vec<_>>(),
3623    });
3624    if let Some(description_key) = description_key {
3625        question["description"] = Value::String(i18n.t(description_key));
3626        question["description_i18n"] = json!({"key": description_key});
3627    }
3628
3629    let spec = json!({
3630        "id": form_id,
3631        "title": i18n.t(title_key),
3632        "version": "1.0.0",
3633        "description": description_key.map(|key| i18n.t(key)).unwrap_or_default(),
3634        "progress_policy": {
3635            "skip_answered": true,
3636            "autofill_defaults": false,
3637            "treat_default_as_answered": false,
3638        },
3639        "questions": [question],
3640    });
3641    let config = WizardRunConfig {
3642        spec_json: serde_json::to_string(&spec).context("serialize enum QA spec")?,
3643        initial_answers_json: None,
3644        frontend: WizardFrontend::Text,
3645        env_id: "default".to_string(),
3646        i18n: i18n.qa_i18n_config(),
3647        verbose: false,
3648    };
3649
3650    let mut driver = WizardDriver::new(config).context("initialize QA enum driver")?;
3651    loop {
3652        let payload_raw = driver
3653            .next_payload_json()
3654            .context("render QA enum payload")?;
3655        let payload: Value = serde_json::from_str(&payload_raw).context("parse QA enum payload")?;
3656        if let Some(text) = payload.get("text").and_then(Value::as_str) {
3657            render_driver_text(output, text)?;
3658        }
3659
3660        if driver.is_complete() {
3661            break;
3662        }
3663
3664        for (value, key) in choices {
3665            wizard_ui::render_line(output, &format!("{value}) {}", i18n.t(key)))?;
3666        }
3667
3668        wizard_ui::render_prompt(output, &i18n.t("wizard.prompt"))?;
3669        let Some(line) = read_trimmed_line(input)? else {
3670            return Ok(default_on_eof.to_string());
3671        };
3672        let candidate = if line.eq_ignore_ascii_case("m") {
3673            "M".to_string()
3674        } else {
3675            line
3676        };
3677        if !choices
3678            .iter()
3679            .map(|(value, _)| *value)
3680            .any(|value| value.eq_ignore_ascii_case(&candidate))
3681        {
3682            wizard_ui::render_line(output, &i18n.t("wizard.error.invalid_selection"))?;
3683            continue;
3684        }
3685
3686        let submit = driver
3687            .submit_patch_json(&json!({"choice": candidate}).to_string())
3688            .context("submit QA enum answer")?;
3689        if submit.status == "error" {
3690            wizard_ui::render_line(output, &i18n.t("wizard.error.invalid_selection"))?;
3691        }
3692    }
3693
3694    let result = driver.finish().context("finish QA enum")?;
3695    result
3696        .answer_set
3697        .answers
3698        .get("choice")
3699        .and_then(Value::as_str)
3700        .map(ToString::to_string)
3701        .ok_or_else(|| anyhow!("missing enum answer"))
3702}
3703
3704#[allow(clippy::too_many_arguments)]
3705fn ask_enum_custom_labels_owned<R: BufRead, W: Write>(
3706    input: &mut R,
3707    output: &mut W,
3708    i18n: &WizardI18n,
3709    form_id: &str,
3710    title_key: &str,
3711    description_key: Option<&str>,
3712    choices: &[(String, String)],
3713    default_on_eof: &str,
3714) -> Result<String> {
3715    let mut question = json!({
3716        "id": "choice",
3717        "type": "enum",
3718        "title": i18n.t(title_key),
3719        "title_i18n": {"key": title_key},
3720        "required": true,
3721        "choices": choices.iter().map(|(v, _)| v).collect::<Vec<_>>(),
3722    });
3723    if let Some(description_key) = description_key {
3724        question["description"] = Value::String(i18n.t(description_key));
3725        question["description_i18n"] = json!({"key": description_key});
3726    }
3727
3728    let spec = json!({
3729        "id": form_id,
3730        "title": i18n.t(title_key),
3731        "version": "1.0.0",
3732        "description": description_key.map(|key| i18n.t(key)).unwrap_or_default(),
3733        "progress_policy": {
3734            "skip_answered": true,
3735            "autofill_defaults": false,
3736            "treat_default_as_answered": false,
3737        },
3738        "questions": [question],
3739    });
3740    let config = WizardRunConfig {
3741        spec_json: serde_json::to_string(&spec).context("serialize custom enum QA spec")?,
3742        initial_answers_json: None,
3743        frontend: WizardFrontend::Text,
3744        env_id: "default".to_string(),
3745        i18n: i18n.qa_i18n_config(),
3746        verbose: false,
3747    };
3748
3749    let mut driver = WizardDriver::new(config).context("initialize QA custom enum driver")?;
3750    loop {
3751        let payload_raw = driver
3752            .next_payload_json()
3753            .context("render QA custom enum payload")?;
3754        let payload: Value =
3755            serde_json::from_str(&payload_raw).context("parse QA custom enum payload")?;
3756        if let Some(text) = payload.get("text").and_then(Value::as_str) {
3757            render_driver_text(output, text)?;
3758        }
3759
3760        if driver.is_complete() {
3761            break;
3762        }
3763
3764        for (value, label) in choices {
3765            wizard_ui::render_line(output, &format!("{value}) {label}"))?;
3766        }
3767
3768        wizard_ui::render_prompt(output, &i18n.t("wizard.prompt"))?;
3769        let Some(line) = read_trimmed_line(input)? else {
3770            return Ok(default_on_eof.to_string());
3771        };
3772        let candidate = if line.eq_ignore_ascii_case("m") {
3773            "M".to_string()
3774        } else {
3775            line
3776        };
3777        if !choices
3778            .iter()
3779            .map(|(value, _)| value.as_str())
3780            .any(|value| value.eq_ignore_ascii_case(&candidate))
3781        {
3782            wizard_ui::render_line(output, &i18n.t("wizard.error.invalid_selection"))?;
3783            continue;
3784        }
3785
3786        let submit = driver
3787            .submit_patch_json(&json!({"choice": candidate}).to_string())
3788            .context("submit QA custom enum answer")?;
3789        if submit.status == "error" {
3790            wizard_ui::render_line(output, &i18n.t("wizard.error.invalid_selection"))?;
3791        }
3792    }
3793
3794    let result = driver.finish().context("finish QA custom enum")?;
3795    result
3796        .answer_set
3797        .answers
3798        .get("choice")
3799        .and_then(Value::as_str)
3800        .map(ToString::to_string)
3801        .ok_or_else(|| anyhow!("missing custom enum answer"))
3802}
3803
3804fn ask_text<R: BufRead, W: Write>(
3805    input: &mut R,
3806    output: &mut W,
3807    i18n: &WizardI18n,
3808    form_id: &str,
3809    title_key: &str,
3810    description_key: Option<&str>,
3811    default_value: Option<&str>,
3812) -> Result<String> {
3813    let mut question = json!({
3814        "id": "value",
3815        "type": "string",
3816        "title": i18n.t(title_key),
3817        "title_i18n": {"key": title_key},
3818        "required": true,
3819    });
3820    if let Some(description_key) = description_key {
3821        question["description"] = Value::String(i18n.t(description_key));
3822        question["description_i18n"] = json!({"key": description_key});
3823    }
3824    if let Some(default_value) = default_value {
3825        question["default_value"] = Value::String(default_value.to_string());
3826    }
3827
3828    let spec = json!({
3829        "id": form_id,
3830        "title": i18n.t(title_key),
3831        "version": "1.0.0",
3832        "description": description_key.map(|key| i18n.t(key)).unwrap_or_default(),
3833        "progress_policy": {
3834            "skip_answered": true,
3835            "autofill_defaults": false,
3836            "treat_default_as_answered": false,
3837        },
3838        "questions": [question],
3839    });
3840    let config = WizardRunConfig {
3841        spec_json: serde_json::to_string(&spec).context("serialize text QA spec")?,
3842        initial_answers_json: None,
3843        frontend: WizardFrontend::Text,
3844        env_id: "default".to_string(),
3845        i18n: i18n.qa_i18n_config(),
3846        verbose: false,
3847    };
3848
3849    let mut driver = WizardDriver::new(config).context("initialize QA text driver")?;
3850    loop {
3851        let payload_raw = driver
3852            .next_payload_json()
3853            .context("render QA text payload")?;
3854        let payload: Value = serde_json::from_str(&payload_raw).context("parse QA text payload")?;
3855        if let Some(text) = payload.get("text").and_then(Value::as_str) {
3856            render_driver_text(output, text)?;
3857        }
3858
3859        if driver.is_complete() {
3860            break;
3861        }
3862
3863        wizard_ui::render_prompt(output, &i18n.t("wizard.prompt"))?;
3864        let Some(line) = read_trimmed_line(input)? else {
3865            if let Some(default) = default_value {
3866                return Ok(default.to_string());
3867            }
3868            return Err(anyhow!("missing text input"));
3869        };
3870
3871        let answer = if line.trim().is_empty() {
3872            default_value.unwrap_or_default().to_string()
3873        } else {
3874            line
3875        };
3876        let submit = driver
3877            .submit_patch_json(&json!({"value": answer}).to_string())
3878            .context("submit QA text answer")?;
3879        if submit.status == "error" {
3880            wizard_ui::render_line(output, &i18n.t("wizard.error.invalid_selection"))?;
3881        }
3882    }
3883
3884    let result = driver.finish().context("finish QA text")?;
3885    result
3886        .answer_set
3887        .answers
3888        .get("value")
3889        .and_then(Value::as_str)
3890        .map(ToString::to_string)
3891        .ok_or_else(|| anyhow!("missing text answer"))
3892}
3893
3894fn prompt_for_extension_catalog_ref<R: BufRead, W: Write>(
3895    input: &mut R,
3896    output: &mut W,
3897    i18n: &WizardI18n,
3898) -> Result<String> {
3899    loop {
3900        wizard_ui::render_line(output, &i18n.t("wizard.extension_catalog.check_newer"))?;
3901        wizard_ui::render_line(output, &i18n.t("wizard.extension_catalog.check_newer_help"))?;
3902        wizard_ui::render_prompt(output, &i18n.t("wizard.prompt"))?;
3903
3904        let Some(line) = read_trimmed_line(input)? else {
3905            return Ok(DEFAULT_EXTENSION_CATALOG_DOWNLOAD_URL.to_string());
3906        };
3907        let trimmed = line.trim();
3908
3909        if trimmed.is_empty()
3910            || trimmed.eq_ignore_ascii_case("y")
3911            || trimmed.eq_ignore_ascii_case("yes")
3912        {
3913            return ask_text(
3914                input,
3915                output,
3916                i18n,
3917                "pack.wizard.extension_catalog.url",
3918                "wizard.extension_catalog.url",
3919                Some("wizard.extension_catalog.url_help"),
3920                Some(DEFAULT_EXTENSION_CATALOG_DOWNLOAD_URL),
3921            );
3922        }
3923        if trimmed.eq_ignore_ascii_case("n") || trimmed.eq_ignore_ascii_case("no") {
3924            return Ok(DEFAULT_EXTENSION_CATALOG_REF.to_string());
3925        }
3926        if looks_like_catalog_ref(trimmed) {
3927            return Ok(trimmed.to_string());
3928        }
3929
3930        wizard_ui::render_line(output, &i18n.t("wizard.error.invalid_selection"))?;
3931    }
3932}
3933
3934fn looks_like_catalog_ref(value: &str) -> bool {
3935    value.contains("://")
3936}
3937
3938fn ask_existing_pack_dir<R: BufRead, W: Write>(
3939    input: &mut R,
3940    output: &mut W,
3941    i18n: &WizardI18n,
3942    form_id: &str,
3943    title_key: &str,
3944    description_key: Option<&str>,
3945    default_value: Option<&str>,
3946) -> Result<PathBuf> {
3947    loop {
3948        let pack_dir = ask_text(
3949            input,
3950            output,
3951            i18n,
3952            form_id,
3953            title_key,
3954            description_key,
3955            default_value,
3956        )?;
3957        let candidate = PathBuf::from(pack_dir.trim());
3958        if candidate.is_dir() {
3959            return Ok(candidate);
3960        }
3961        wizard_ui::render_line(
3962            output,
3963            &format!(
3964                "{}: {}",
3965                i18n.t("wizard.error.invalid_pack_dir"),
3966                candidate.display()
3967            ),
3968        )?;
3969    }
3970}
3971
3972fn run_process(binary: &Path, args: &[&str], cwd: Option<&Path>) -> Result<bool> {
3973    let mut cmd = Command::new(binary);
3974    cmd.args(args)
3975        .stdin(Stdio::inherit())
3976        .stdout(Stdio::inherit())
3977        .stderr(Stdio::inherit());
3978    if let Some(cwd) = cwd {
3979        cmd.current_dir(cwd);
3980    }
3981    let status = cmd
3982        .status()
3983        .with_context(|| format!("spawn {}", binary.display()))?;
3984    Ok(status.success())
3985}
3986
3987fn run_process_capture(binary: &Path, args: &[String], cwd: &Path) -> Result<Output> {
3988    Command::new(binary)
3989        .args(args)
3990        .current_dir(cwd)
3991        .stdin(Stdio::inherit())
3992        .stdout(Stdio::piped())
3993        .stderr(Stdio::piped())
3994        .output()
3995        .with_context(|| format!("spawn {}", binary.display()))
3996}
3997
3998fn run_delegate(binary: &str, args: &[&str], cwd: &Path) -> bool {
3999    let resolved = crate::external_tools::resolve(binary).unwrap_or_else(|| PathBuf::from(binary));
4000    run_process(&resolved, args, Some(cwd)).unwrap_or(false)
4001}
4002
4003fn run_delegate_owned(binary: &str, args: &[String], cwd: &Path) -> bool {
4004    let argv = args.iter().map(String::as_str).collect::<Vec<_>>();
4005    run_delegate(binary, &argv, cwd)
4006}
4007
4008fn capture_delegate_json(binary: &str, args: &[String], cwd: &Path) -> Result<Value> {
4009    let resolved = crate::external_tools::resolve(binary).unwrap_or_else(|| PathBuf::from(binary));
4010    let output = Command::new(&resolved)
4011        .args(args)
4012        .current_dir(cwd)
4013        .stdin(Stdio::null())
4014        .stdout(Stdio::piped())
4015        .stderr(Stdio::piped())
4016        .output()
4017        .with_context(|| format!("spawn {}", resolved.display()))?;
4018    if !output.status.success() {
4019        let stderr = String::from_utf8_lossy(&output.stderr);
4020        return Err(anyhow!("{} failed: {}", resolved.display(), stderr.trim()));
4021    }
4022    serde_json::from_slice(&output.stdout)
4023        .with_context(|| format!("parse json emitted by {}", resolved.display()))
4024}
4025
4026fn temp_answers_path(prefix: &str) -> PathBuf {
4027    let stamp = SystemTime::now()
4028        .duration_since(UNIX_EPOCH)
4029        .map(|d| d.as_nanos())
4030        .unwrap_or(0);
4031    std::env::temp_dir().join(format!("{prefix}-{}-{stamp}.json", std::process::id()))
4032}
4033
4034fn read_json_value(path: &Path) -> Option<Value> {
4035    let bytes = fs::read(path).ok()?;
4036    serde_json::from_slice::<Value>(&bytes).ok()
4037}
4038
4039fn write_json_value(path: &Path, value: &Value) -> bool {
4040    serde_json::to_vec_pretty(value)
4041        .ok()
4042        .and_then(|bytes| fs::write(path, bytes).ok())
4043        .is_some()
4044}
4045
4046fn flow_delegate_args(_pack_dir: &Path) -> Vec<String> {
4047    vec!["wizard".to_string(), ".".to_string()]
4048}
4049
4050fn run_flow_delegate_for_session(session: &mut WizardSession, pack_dir: &Path) -> bool {
4051    if !session.dry_run {
4052        let args = flow_delegate_args(pack_dir);
4053        return run_delegate_owned("greentic-flow", &args, pack_dir);
4054    }
4055    let answers_path = temp_answers_path("greentic-flow-wizard-answers");
4056    let mut args = flow_delegate_args(pack_dir);
4057    args.push("--emit-answers".to_string());
4058    args.push(answers_path.display().to_string());
4059    let ok = run_delegate_owned("greentic-flow", &args, pack_dir);
4060    if ok {
4061        session.flow_wizard_answers = read_json_value(&answers_path);
4062    }
4063    let _ = fs::remove_file(&answers_path);
4064    ok
4065}
4066
4067fn run_component_delegate_for_session(session: &mut WizardSession, pack_dir: &Path) -> bool {
4068    if !session.dry_run {
4069        return run_delegate("greentic-component", &["wizard"], pack_dir);
4070    }
4071    let answers_path = temp_answers_path("greentic-component-wizard-answers");
4072    let args = vec![
4073        "wizard".to_string(),
4074        "--project-root".to_string(),
4075        ".".to_string(),
4076        "--execution".to_string(),
4077        "dry-run".to_string(),
4078        "--qa-answers-out".to_string(),
4079        answers_path.display().to_string(),
4080    ];
4081    let ok = run_delegate_owned("greentic-component", &args, pack_dir);
4082    if ok {
4083        session.component_wizard_answers = read_json_value(&answers_path);
4084    }
4085    let _ = fs::remove_file(&answers_path);
4086    ok
4087}
4088
4089fn run_flow_delegate_replay(pack_dir: &Path, answers: Option<&Value>) -> bool {
4090    if let Some(answers) = answers {
4091        let answers_path = temp_answers_path("greentic-flow-wizard-replay");
4092        if !write_json_value(&answers_path, answers) {
4093            return false;
4094        }
4095        let mut args = flow_delegate_args(pack_dir);
4096        args.push("--answers".to_string());
4097        args.push(answers_path.display().to_string());
4098        let ok = run_delegate_owned("greentic-flow", &args, pack_dir);
4099        let _ = fs::remove_file(&answers_path);
4100        return ok;
4101    }
4102    let args = flow_delegate_args(pack_dir);
4103    run_delegate_owned("greentic-flow", &args, pack_dir)
4104}
4105
4106fn run_component_delegate_replay(pack_dir: &Path, answers: Option<&Value>) -> Result<()> {
4107    if let Some(answers) = answers {
4108        let answers_path = temp_answers_path("greentic-component-wizard-replay");
4109        let replay_answers = normalize_component_wizard_answers_for_replay(answers)?;
4110        let replay_json = serde_json::to_string_pretty(&replay_answers)
4111            .context("serialize component_wizard_answers for replay")?;
4112        fs::write(&answers_path, replay_json.as_bytes()).with_context(|| {
4113            format!(
4114                "write temp greentic-component replay answers {}",
4115                answers_path.display()
4116            )
4117        })?;
4118        let args = vec![
4119            "wizard".to_string(),
4120            "--project-root".to_string(),
4121            ".".to_string(),
4122            "--execution".to_string(),
4123            "execute".to_string(),
4124            "--qa-answers".to_string(),
4125            answers_path.display().to_string(),
4126        ];
4127        let resolved = crate::external_tools::resolve("greentic-component")
4128            .unwrap_or_else(|| PathBuf::from("greentic-component"));
4129        let output = run_process_capture(&resolved, &args, pack_dir);
4130        let _ = fs::remove_file(&answers_path);
4131        let output = output?;
4132        if !output.status.success() {
4133            let stdout = String::from_utf8_lossy(&output.stdout);
4134            let stderr = String::from_utf8_lossy(&output.stderr);
4135            return Err(anyhow!(
4136                "greentic-component wizard replay failed with status {}\nstdout:\n{}\nstderr:\n{}\ncomponent_wizard_answers JSON passed to greentic-component:\n{}",
4137                output.status,
4138                stdout.trim(),
4139                stderr.trim(),
4140                replay_json
4141            ));
4142        }
4143        if !output.stdout.is_empty() {
4144            let _ = io::stdout().write_all(&output.stdout);
4145        }
4146        if !output.stderr.is_empty() {
4147            let _ = io::stderr().write_all(&output.stderr);
4148        }
4149        return Ok(());
4150    }
4151    if run_delegate("greentic-component", &["wizard"], pack_dir) {
4152        Ok(())
4153    } else {
4154        Err(anyhow!("greentic-component wizard failed"))
4155    }
4156}
4157
4158fn normalize_component_wizard_answers_for_replay(answers: &Value) -> Result<Value> {
4159    reject_custom_component_operation_names(answers)?;
4160    let Some(object) = answers.as_object() else {
4161        return Ok(answers.clone());
4162    };
4163    if object.contains_key("schema")
4164        || object.contains_key("wizard_id")
4165        || object.contains_key("answers")
4166    {
4167        return Ok(answers.clone());
4168    }
4169    if !object.contains_key("component_name") {
4170        return Ok(answers.clone());
4171    }
4172    Ok(json!({
4173        "schema": "component-wizard-run/v1",
4174        "mode": "create",
4175        "fields": answers
4176    }))
4177}
4178
4179fn reject_custom_component_operation_names(answers: &Value) -> Result<()> {
4180    let Some((path, operation_names)) = find_component_operation_names(answers) else {
4181        return Ok(());
4182    };
4183    if operation_names.as_array().is_some_and(Vec::is_empty) {
4184        return Ok(());
4185    }
4186    Err(anyhow!(
4187        "answers.component_wizard_answers{path} is not supported by greentic-pack component replay because greentic-component currently ignores custom operation names during scaffold. Scaffold the component first, then run `greentic-component wizard add-operation` for each custom operation."
4188    ))
4189}
4190
4191fn find_component_operation_names(answers: &Value) -> Option<(&'static str, &Value)> {
4192    let object = answers.as_object()?;
4193    if let Some(value) = object.get("operation_names") {
4194        return Some((".operation_names", value));
4195    }
4196    if let Some(value) = object
4197        .get("fields")
4198        .and_then(Value::as_object)
4199        .and_then(|fields| fields.get("operation_names"))
4200    {
4201        return Some((".fields.operation_names", value));
4202    }
4203    if let Some(value) = object
4204        .get("answers")
4205        .and_then(Value::as_object)
4206        .and_then(|answers| answers.get("fields"))
4207        .and_then(Value::as_object)
4208        .and_then(|fields| fields.get("operation_names"))
4209    {
4210        return Some((".answers.fields.operation_names", value));
4211    }
4212    None
4213}
4214
4215fn handle_delegate_failure<R: BufRead, W: Write>(
4216    input: &mut R,
4217    output: &mut W,
4218    i18n: &WizardI18n,
4219    session: &WizardSession,
4220    error_key: &str,
4221) -> Result<bool> {
4222    if session.dry_run {
4223        wizard_ui::render_line(output, &i18n.t("wizard.dry_run.child_wizard_returned"))?;
4224        return Ok(false);
4225    }
4226    wizard_ui::render_line(output, &i18n.t(error_key))?;
4227    if matches!(
4228        ask_failure_nav(input, output, i18n)?,
4229        SubmenuAction::MainMenu
4230    ) {
4231        return Ok(true);
4232    }
4233    Ok(false)
4234}
4235
4236fn wizard_self_exe() -> Result<PathBuf> {
4237    if let Ok(path) = env::var("GREENTIC_PACK_WIZARD_SELF_EXE") {
4238        let candidate = PathBuf::from(path);
4239        if candidate.exists() {
4240            return Ok(candidate);
4241        }
4242        return Err(anyhow!(
4243            "GREENTIC_PACK_WIZARD_SELF_EXE does not exist: {}",
4244            candidate.display()
4245        ));
4246    }
4247    std::env::current_exe().context("resolve current executable")
4248}
4249
4250fn read_trimmed_line<R: BufRead>(input: &mut R) -> Result<Option<String>> {
4251    let mut line = String::new();
4252    let read = input.read_line(&mut line)?;
4253    if read == 0 {
4254        return Ok(None);
4255    }
4256    Ok(Some(line.trim().to_string()))
4257}
4258
4259fn render_driver_text<W: Write>(output: &mut W, text: &str) -> Result<()> {
4260    let filtered = filter_driver_boilerplate(text);
4261    if filtered.trim().is_empty() {
4262        return Ok(());
4263    }
4264    wizard_ui::render_text(output, &filtered)?;
4265    if !filtered.ends_with('\n') {
4266        wizard_ui::render_text(output, "\n")?;
4267    }
4268    Ok(())
4269}
4270
4271fn filter_driver_boilerplate(text: &str) -> String {
4272    let mut kept = Vec::new();
4273    let mut skipping_visible_block = false;
4274    for line in text.lines() {
4275        let trimmed = line.trim_start();
4276        if let Some(title) = trimmed.strip_prefix("Title:") {
4277            let title = title.trim();
4278            if !title.is_empty() {
4279                kept.push(title);
4280            }
4281            continue;
4282        }
4283        if trimmed.starts_with("Description:") || trimmed.starts_with("Required:") {
4284            continue;
4285        }
4286        if trimmed == "All visible questions are answered." {
4287            continue;
4288        }
4289        if trimmed.starts_with("Form:")
4290            || trimmed.starts_with("Status:")
4291            || trimmed.starts_with("Help:")
4292            || trimmed.starts_with("Next question:")
4293        {
4294            skipping_visible_block = false;
4295            continue;
4296        }
4297        if trimmed.starts_with("Visible questions:") {
4298            skipping_visible_block = true;
4299            continue;
4300        }
4301        if skipping_visible_block {
4302            if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
4303                continue;
4304            }
4305            if trimmed.is_empty() {
4306                continue;
4307            }
4308            skipping_visible_block = false;
4309        }
4310        kept.push(line);
4311    }
4312    let joined = kept.join("\n");
4313    joined.trim_matches('\n').to_string()
4314}
4315
4316impl SubmenuAction {
4317    fn from_choice(choice: &str) -> Result<Self> {
4318        if choice == "0" {
4319            return Ok(Self::Back);
4320        }
4321        if choice.eq_ignore_ascii_case("m") {
4322            return Ok(Self::MainMenu);
4323        }
4324        Err(anyhow!("invalid submenu selection `{choice}`"))
4325    }
4326}
4327
4328impl MainChoice {
4329    fn from_choice(choice: &str) -> Result<Self> {
4330        match choice {
4331            "1" => Ok(Self::CreateApplicationPack),
4332            "2" => Ok(Self::UpdateApplicationPack),
4333            "3" => Ok(Self::CreateExtensionPack),
4334            "4" => Ok(Self::UpdateExtensionPack),
4335            "5" => Ok(Self::AddExtension),
4336            "0" => Ok(Self::Exit),
4337            _ => Err(anyhow!("invalid main selection `{choice}`")),
4338        }
4339    }
4340}