Skip to main content

packc/cli/
wizard.rs

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