Skip to main content

packc/cli/
wizard.rs

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