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