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