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