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_step_answers_schema() -> Value {
966    json!({
967        "type": "object",
968        "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.",
969        "$comment": "Resolve per-component step answer schemas via `greentic-flow component-schema <file/oci/repo/store>.wasm [--mode default|setup|update|remove]`.",
970        "additionalProperties": true
971    })
972}
973
974fn flow_step_action_schema(action: &str) -> Value {
975    let mut required = vec![json!("action"), json!("flow")];
976    if matches!(action, "add-step" | "update-step") {
977        required.push(json!("component"));
978        required.push(json!("mode"));
979    }
980    if action == "update-step" {
981        required.push(json!("step_id"));
982    }
983    json!({
984        "type": "object",
985        "additionalProperties": false,
986        "properties": {
987            "action": { "type": "string", "const": action },
988            "flow": { "type": "string" },
989            "step_id": { "type": "string" },
990            "after": { "type": "string" },
991            "component": { "type": "string" },
992            "mode": {
993                "type": "string",
994                "enum": ["default", "setup", "update", "remove"]
995            },
996            "answers": { "$ref": "#/$defs/greentic_flow_step_answers" }
997        },
998        "required": required
999    })
1000}
1001
1002fn flow_wizard_action_schema() -> Value {
1003    json!({
1004        "oneOf": [
1005            {
1006                "type": "object",
1007                "additionalProperties": false,
1008                "properties": {
1009                    "action": { "type": "string", "const": "add-flow" },
1010                    "flow": { "type": "string" },
1011                    "flow_id": { "type": "string" },
1012                    "flow_type": { "type": "string" }
1013                },
1014                "required": ["action", "flow", "flow_id", "flow_type"]
1015            },
1016            {
1017                "type": "object",
1018                "additionalProperties": false,
1019                "properties": {
1020                    "action": { "type": "string", "const": "edit-flow-summary" },
1021                    "flow": { "type": "string" },
1022                    "name": { "type": "string" },
1023                    "description": { "type": "string" }
1024                },
1025                "required": ["action", "flow"]
1026            },
1027            {
1028                "type": "object",
1029                "additionalProperties": false,
1030                "properties": {
1031                    "action": { "type": "string", "const": "generate-translations" },
1032                    "locales": {
1033                        "type": "array",
1034                        "items": { "type": "string" }
1035                    }
1036                },
1037                "required": ["action", "locales"]
1038            },
1039            {
1040                "type": "object",
1041                "additionalProperties": false,
1042                "properties": {
1043                    "action": { "type": "string", "const": "delete-flow" },
1044                    "flow": { "type": "string" }
1045                },
1046                "required": ["action", "flow"]
1047            },
1048            flow_step_action_schema("add-step"),
1049            flow_step_action_schema("update-step"),
1050            flow_step_action_schema("delete-step")
1051        ]
1052    })
1053}
1054
1055fn load_flow_wizard_runtime_schema(flow_context: Option<&FlowSchemaContext>) -> Result<Value> {
1056    let temp = tempfile::tempdir().context("create temp dir for flow wizard schema")?;
1057    let cwd = flow_context
1058        .and_then(|ctx| ctx.pack_dir.as_deref())
1059        .unwrap_or_else(|| temp.path());
1060    let mut args = vec!["wizard".to_string(), "--schema".to_string()];
1061    let mut temp_answers_path = None;
1062
1063    if let Some(ctx) = flow_context
1064        && let Some(pack_dir) = ctx.pack_dir.as_ref()
1065    {
1066        args.push(pack_dir.display().to_string());
1067        if let Some(flow_answers) = ctx.flow_wizard_answers.as_ref() {
1068            let answers_path = temp.path().join("flow.answers.json");
1069            if !write_json_value(&answers_path, flow_answers) {
1070                return Err(anyhow!(
1071                    "failed to write temp greentic-flow answers plan {}",
1072                    answers_path.display()
1073                ));
1074            }
1075            args.push("--answers".to_string());
1076            args.push(answers_path.display().to_string());
1077            temp_answers_path = Some(answers_path);
1078        }
1079    }
1080
1081    let result = capture_delegate_json("greentic-flow", &args, cwd)
1082        .context("failed to fetch nested greentic-flow wizard schema");
1083    if let Some(path) = temp_answers_path.as_deref() {
1084        let _ = fs::remove_file(path);
1085    }
1086    result
1087}
1088
1089fn load_component_wizard_schema(mode: &str) -> Result<Value> {
1090    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
1091    let args = vec![
1092        "wizard".to_string(),
1093        "--schema".to_string(),
1094        "--mode".to_string(),
1095        mode.to_string(),
1096    ];
1097    capture_delegate_json("greentic-component", &args, &cwd)
1098        .with_context(|| format!("fetch nested greentic-component wizard schema for mode '{mode}'"))
1099}
1100
1101fn validate_answer_document(doc: &WizardAnswerDocument) -> Result<()> {
1102    if doc.wizard_id != PACK_WIZARD_ID {
1103        return Err(anyhow!(
1104            "unsupported wizard_id '{}', expected '{}'",
1105            doc.wizard_id,
1106            PACK_WIZARD_ID
1107        ));
1108    }
1109    if doc.schema_id != PACK_WIZARD_SCHEMA_ID {
1110        return Err(anyhow!(
1111            "unsupported schema_id '{}', expected '{}'",
1112            doc.schema_id,
1113            PACK_WIZARD_SCHEMA_ID
1114        ));
1115    }
1116    let plan = execution_plan_from_answers(&doc.answers, &doc.base_dir)?;
1117    let pack_dir_must_exist = !plan.create_pack_scaffold
1118        && !matches!(
1119            plan.extension_operation
1120                .as_ref()
1121                .map(|item| item.operation.as_str()),
1122            Some("create_extension_pack")
1123        );
1124    if pack_dir_must_exist && !plan.pack_dir.is_dir() {
1125        return Err(anyhow!(
1126            "pack_dir is not an existing directory: {}",
1127            plan.pack_dir.display()
1128        ));
1129    }
1130    if plan.create_pack_scaffold && plan.create_pack_id.is_none() {
1131        return Err(anyhow!(
1132            "create_pack_scaffold=true requires answers.create_pack_id string"
1133        ));
1134    }
1135    if let Some(key) = plan.sign_key_path.as_deref()
1136        && key.trim().is_empty()
1137    {
1138        return Err(anyhow!("sign_key_path must not be empty"));
1139    }
1140    if let Some(extension) = plan.extension_operation.as_ref() {
1141        validate_extension_operation_record(extension)?;
1142    }
1143    Ok(())
1144}
1145
1146fn apply_answer_document(doc: &WizardAnswerDocument) -> Result<()> {
1147    let plan = execution_plan_from_answers(&doc.answers, &doc.base_dir)?;
1148    let self_exe = wizard_self_exe()?;
1149    if plan.create_pack_scaffold {
1150        let pack_id = plan
1151            .create_pack_id
1152            .as_deref()
1153            .ok_or_else(|| anyhow!("missing create_pack_id for scaffold apply"))?;
1154        let scaffold_ok = run_process(
1155            &self_exe,
1156            &[
1157                "new",
1158                "--dir",
1159                &plan.pack_dir.display().to_string(),
1160                pack_id,
1161            ],
1162            None,
1163        )?;
1164        if !scaffold_ok {
1165            return Err(anyhow!(
1166                "wizard apply failed while creating application pack {}",
1167                plan.pack_dir.display()
1168            ));
1169        }
1170    }
1171    if let Some(extension) = plan.extension_operation.as_ref() {
1172        apply_extension_operation(&plan.pack_dir, extension)?;
1173    }
1174    if !plan.asset_staging.is_empty() {
1175        stage_assets_into_pack(&plan.pack_root, &plan.asset_staging)?;
1176    }
1177    if plan.run_delegate_flow {
1178        let ok = run_flow_delegate_replay(&plan.pack_dir, plan.flow_wizard_answers.as_ref());
1179        if !ok {
1180            return Err(anyhow!(
1181                "wizard apply failed while running flow delegate for {}",
1182                plan.pack_dir.display()
1183            ));
1184        }
1185    }
1186    if plan.run_delegate_component {
1187        let ok =
1188            run_component_delegate_replay(&plan.pack_dir, plan.component_wizard_answers.as_ref());
1189        if !ok {
1190            return Err(anyhow!(
1191                "wizard apply failed while running component delegate for {}",
1192                plan.pack_dir.display()
1193            ));
1194        }
1195    }
1196    if plan.run_doctor || plan.run_build {
1197        let update_ok = run_process(
1198            &self_exe,
1199            &["update", "--in", &plan.pack_dir.display().to_string()],
1200            None,
1201        )?;
1202        if !update_ok {
1203            return Err(anyhow!(
1204                "wizard apply failed while syncing pack manifest for {}",
1205                plan.pack_dir.display()
1206            ));
1207        }
1208    }
1209    if plan.run_doctor {
1210        let doctor_ok = run_process(
1211            &self_exe,
1212            &["doctor", "--in", &plan.pack_dir.display().to_string()],
1213            None,
1214        )?;
1215        if !doctor_ok {
1216            return Err(anyhow!(
1217                "wizard apply failed while running doctor for {}",
1218                plan.pack_dir.display()
1219            ));
1220        }
1221    }
1222    if plan.run_build {
1223        let resolve_ok = run_process(
1224            &self_exe,
1225            &["resolve", "--in", &plan.pack_dir.display().to_string()],
1226            None,
1227        )?;
1228        if !resolve_ok {
1229            return Err(anyhow!(
1230                "wizard apply failed while running resolve for {}",
1231                plan.pack_dir.display()
1232            ));
1233        }
1234        let build_ok = run_process(
1235            &self_exe,
1236            &["build", "--in", &plan.pack_dir.display().to_string()],
1237            None,
1238        )?;
1239        if !build_ok {
1240            return Err(anyhow!(
1241                "wizard apply failed while running build for {}",
1242                plan.pack_dir.display()
1243            ));
1244        }
1245    }
1246    if let Some(key_path) = plan.sign_key_path.as_deref() {
1247        let sign_ok = run_process(
1248            &self_exe,
1249            &[
1250                "sign",
1251                "--pack",
1252                &plan.pack_dir.display().to_string(),
1253                "--key",
1254                key_path,
1255            ],
1256            None,
1257        )?;
1258        if !sign_ok {
1259            return Err(anyhow!(
1260                "wizard apply failed while signing {}",
1261                plan.pack_dir.display()
1262            ));
1263        }
1264    }
1265    Ok(())
1266}
1267
1268fn execution_plan_from_answers(
1269    answers: &BTreeMap<String, Value>,
1270    answers_base_dir: &Path,
1271) -> Result<WizardExecutionPlan> {
1272    let pack_dir_raw = answers
1273        .get("pack_dir")
1274        .and_then(Value::as_str)
1275        .ok_or_else(|| anyhow!("answers.pack_dir must be a string"))?;
1276    let pack_dir = PathBuf::from(pack_dir_raw);
1277    let pack_root = absolutize_path(&pack_dir);
1278    let create_pack_scaffold = answer_bool(answers, "create_pack_scaffold", false)?;
1279    let create_pack_id = answers
1280        .get("create_pack_id")
1281        .and_then(Value::as_str)
1282        .map(ToString::to_string);
1283    let run_delegate_flow = answer_bool(answers, "run_delegate_flow", false)?;
1284    let run_delegate_component = answer_bool(answers, "run_delegate_component", false)?;
1285    let run_doctor = answer_bool(answers, "run_doctor", true)?;
1286    let run_build = answer_bool(answers, "run_build", true)?;
1287    let flow_wizard_answers = answers.get("flow_wizard_answers").cloned();
1288    let component_wizard_answers = answers.get("component_wizard_answers").cloned();
1289    let sign = answer_bool(answers, "sign", false)?;
1290    let sign_key_path = answers
1291        .get("sign_key_path")
1292        .and_then(Value::as_str)
1293        .map(ToString::to_string);
1294    if sign && sign_key_path.is_none() {
1295        return Err(anyhow!(
1296            "answers.sign=true requires answers.sign_key_path string"
1297        ));
1298    }
1299    let sign_key_path = if sign { sign_key_path } else { None };
1300    let extension_operation = parse_extension_operation_record(answers)?;
1301    let asset_staging = parse_asset_staging_entries(answers, answers_base_dir, &pack_root)?;
1302    validate_scaffold_asset_staging_conflicts(create_pack_scaffold, &pack_root, &asset_staging)?;
1303    Ok(WizardExecutionPlan {
1304        pack_dir,
1305        pack_root,
1306        create_pack_id,
1307        create_pack_scaffold,
1308        run_delegate_flow,
1309        run_delegate_component,
1310        run_doctor,
1311        run_build,
1312        flow_wizard_answers,
1313        component_wizard_answers,
1314        sign_key_path,
1315        extension_operation,
1316        asset_staging,
1317    })
1318}
1319
1320fn answer_bool(answers: &BTreeMap<String, Value>, key: &str, default: bool) -> Result<bool> {
1321    match answers.get(key) {
1322        None => Ok(default),
1323        Some(value) => value
1324            .as_bool()
1325            .ok_or_else(|| anyhow!("answers.{key} must be a boolean")),
1326    }
1327}
1328
1329fn absolutize_path(path: &Path) -> PathBuf {
1330    if path.is_absolute() {
1331        path.to_path_buf()
1332    } else {
1333        std::env::current_dir()
1334            .unwrap_or_else(|_| PathBuf::from("."))
1335            .join(path)
1336    }
1337}
1338
1339fn normalize_pack_destination(pack_root: &Path, candidate: &Path) -> Result<PathBuf> {
1340    if candidate.is_absolute() {
1341        anyhow::bail!(
1342            "asset staging destination must be relative to pack_dir: {}",
1343            candidate.display()
1344        );
1345    }
1346
1347    let mut normalized = pack_root.to_path_buf();
1348    for component in candidate.components() {
1349        match component {
1350            Component::CurDir => {}
1351            Component::Normal(part) => normalized.push(part),
1352            Component::ParentDir => {
1353                anyhow::bail!(
1354                    "asset staging destination must not contain '..' segments: {}",
1355                    candidate.display()
1356                );
1357            }
1358            Component::Prefix(_) | Component::RootDir => {
1359                anyhow::bail!(
1360                    "asset staging destination must be relative to pack_dir: {}",
1361                    candidate.display()
1362                );
1363            }
1364        }
1365    }
1366    Ok(normalized)
1367}
1368
1369fn parse_asset_staging_entries(
1370    answers: &BTreeMap<String, Value>,
1371    answers_base_dir: &Path,
1372    pack_root: &Path,
1373) -> Result<Vec<ResolvedAssetStagingEntry>> {
1374    let Some(value) = answers.get("asset_staging") else {
1375        return Ok(Vec::new());
1376    };
1377    let items = value
1378        .as_array()
1379        .ok_or_else(|| anyhow!("answers.asset_staging must be an array"))?;
1380    let mut resolved = Vec::with_capacity(items.len());
1381    let mut seen_destinations = BTreeSet::new();
1382    for (index, item) in items.iter().enumerate() {
1383        let field = format!("answers.asset_staging[{index}]");
1384        let entry: AssetStagingEntry = serde_json::from_value(item.clone())
1385            .with_context(|| format!("{field} is not a valid asset staging entry"))?;
1386        let source_rel = PathBuf::from(&entry.source);
1387        let source = if source_rel.is_absolute() {
1388            source_rel
1389        } else {
1390            answers_base_dir.join(&source_rel)
1391        };
1392        let destination = normalize_pack_destination(pack_root, Path::new(&entry.destination))?;
1393        validate_asset_staging_entry(&field, &entry, &source, &destination)?;
1394        let dest_key = destination.display().to_string();
1395        if !seen_destinations.insert(dest_key.clone()) {
1396            anyhow::bail!(
1397                "{field}.destination conflicts with another asset staging entry: {dest_key}"
1398            );
1399        }
1400        resolved.push(ResolvedAssetStagingEntry {
1401            source,
1402            destination,
1403            kind: entry.kind,
1404            recursive: entry.recursive,
1405            overwrite: entry.overwrite,
1406        });
1407    }
1408    Ok(resolved)
1409}
1410
1411fn validate_scaffold_asset_staging_conflicts(
1412    create_pack_scaffold: bool,
1413    pack_root: &Path,
1414    entries: &[ResolvedAssetStagingEntry],
1415) -> Result<()> {
1416    if !create_pack_scaffold {
1417        return Ok(());
1418    }
1419
1420    let reserved_paths = [
1421        pack_root.join("pack.yaml"),
1422        pack_root.join("flows/main.ygtc"),
1423    ];
1424
1425    for entry in entries {
1426        if entry.overwrite || entry.kind != AssetStagingKind::File {
1427            continue;
1428        }
1429        if reserved_paths
1430            .iter()
1431            .any(|reserved| reserved == &entry.destination)
1432        {
1433            anyhow::bail!(
1434                "asset staging destination already exists in scaffold output and overwrite=false: {}",
1435                entry.destination.display()
1436            );
1437        }
1438    }
1439
1440    Ok(())
1441}
1442
1443fn validate_asset_staging_entry(
1444    field: &str,
1445    entry: &AssetStagingEntry,
1446    source: &Path,
1447    _destination: &Path,
1448) -> Result<()> {
1449    if entry.source.trim().is_empty() {
1450        anyhow::bail!("{field}.source must not be empty");
1451    }
1452    if entry.destination.trim().is_empty() {
1453        anyhow::bail!("{field}.destination must not be empty");
1454    }
1455    if !source.exists() {
1456        anyhow::bail!("{field}.source does not exist: {}", source.display());
1457    }
1458
1459    match entry.kind {
1460        AssetStagingKind::File => {
1461            if !source.is_file() {
1462                anyhow::bail!(
1463                    "{field}.kind=file requires a file source, got {}",
1464                    source.display()
1465                );
1466            }
1467        }
1468        AssetStagingKind::Directory => {
1469            if !source.is_dir() {
1470                anyhow::bail!(
1471                    "{field}.kind=directory requires a directory source, got {}",
1472                    source.display()
1473                );
1474            }
1475            if !entry.recursive {
1476                anyhow::bail!("{field}.recursive must be true when kind=directory");
1477            }
1478        }
1479    }
1480
1481    Ok(())
1482}
1483
1484fn stage_assets_into_pack(pack_root: &Path, entries: &[ResolvedAssetStagingEntry]) -> Result<()> {
1485    fs::create_dir_all(pack_root)
1486        .with_context(|| format!("create pack root {}", pack_root.display()))?;
1487    for entry in entries {
1488        stage_single_asset(pack_root, entry)?;
1489    }
1490    Ok(())
1491}
1492
1493fn stage_single_asset(_pack_root: &Path, entry: &ResolvedAssetStagingEntry) -> Result<()> {
1494    match entry.kind {
1495        AssetStagingKind::File => {
1496            copy_staged_file(&entry.source, &entry.destination, entry.overwrite)
1497        }
1498        AssetStagingKind::Directory => copy_staged_directory(
1499            &entry.source,
1500            &entry.destination,
1501            entry.recursive,
1502            entry.overwrite,
1503        ),
1504    }
1505}
1506
1507fn copy_staged_file(source: &Path, destination: &Path, overwrite: bool) -> Result<()> {
1508    if destination.is_dir() {
1509        anyhow::bail!(
1510            "asset staging destination is a directory but source is a file: {}",
1511            destination.display()
1512        );
1513    }
1514    if destination.exists() && !overwrite {
1515        anyhow::bail!(
1516            "asset staging destination already exists and overwrite=false: {}",
1517            destination.display()
1518        );
1519    }
1520    if let Some(parent) = destination.parent() {
1521        fs::create_dir_all(parent)
1522            .with_context(|| format!("create staged asset parent {}", parent.display()))?;
1523    }
1524    fs::copy(source, destination).with_context(|| {
1525        format!(
1526            "copy staged asset file {} -> {}",
1527            source.display(),
1528            destination.display()
1529        )
1530    })?;
1531    Ok(())
1532}
1533
1534fn copy_staged_directory(
1535    source: &Path,
1536    destination: &Path,
1537    recursive: bool,
1538    overwrite: bool,
1539) -> Result<()> {
1540    if !recursive {
1541        anyhow::bail!(
1542            "directory staging requires recursive=true for source {}",
1543            source.display()
1544        );
1545    }
1546    if destination.exists() && destination.is_file() {
1547        anyhow::bail!(
1548            "asset staging destination is a file but source is a directory: {}",
1549            destination.display()
1550        );
1551    }
1552    fs::create_dir_all(destination)
1553        .with_context(|| format!("create staged asset directory {}", destination.display()))?;
1554    for item in WalkDir::new(source).into_iter().filter_map(Result::ok) {
1555        let path = item.path();
1556        let rel = path
1557            .strip_prefix(source)
1558            .expect("walkdir entry should remain under source");
1559        if rel.as_os_str().is_empty() {
1560            continue;
1561        }
1562        let target = destination.join(rel);
1563        if item.file_type().is_dir() {
1564            fs::create_dir_all(&target)
1565                .with_context(|| format!("create staged asset directory {}", target.display()))?;
1566            continue;
1567        }
1568        if target.exists() && !overwrite {
1569            anyhow::bail!(
1570                "asset staging destination already exists and overwrite=false: {}",
1571                target.display()
1572            );
1573        }
1574        if let Some(parent) = target.parent() {
1575            fs::create_dir_all(parent)
1576                .with_context(|| format!("create staged asset parent {}", parent.display()))?;
1577        }
1578        fs::copy(path, &target).with_context(|| {
1579            format!(
1580                "copy staged asset file {} -> {}",
1581                path.display(),
1582                target.display()
1583            )
1584        })?;
1585    }
1586    Ok(())
1587}
1588
1589fn string_map_to_json_value(map: &BTreeMap<String, String>) -> Value {
1590    Value::Object(
1591        map.iter()
1592            .map(|(key, value)| (key.clone(), Value::String(value.clone())))
1593            .collect(),
1594    )
1595}
1596
1597fn json_value_to_string_map(
1598    value: Option<&Value>,
1599    field: &str,
1600) -> Result<BTreeMap<String, String>> {
1601    let Some(value) = value else {
1602        return Ok(BTreeMap::new());
1603    };
1604    let obj = value
1605        .as_object()
1606        .ok_or_else(|| anyhow!("answers.{field} must be an object"))?;
1607    let mut map = BTreeMap::new();
1608    for (key, value) in obj {
1609        let value = value
1610            .as_str()
1611            .ok_or_else(|| anyhow!("answers.{field}.{key} must be a string"))?;
1612        map.insert(key.clone(), value.to_string());
1613    }
1614    Ok(map)
1615}
1616
1617fn parse_extension_operation_record(
1618    answers: &BTreeMap<String, Value>,
1619) -> Result<Option<ExtensionOperationRecord>> {
1620    let operation = answers
1621        .get("extension_operation")
1622        .and_then(Value::as_str)
1623        .map(ToString::to_string)
1624        .or_else(|| infer_extension_operation_from_selected_actions(answers));
1625    let Some(operation) = operation.as_deref() else {
1626        return Ok(None);
1627    };
1628    let catalog_ref = answers
1629        .get("extension_catalog_ref")
1630        .and_then(Value::as_str)
1631        .ok_or_else(|| anyhow!("answers.extension_catalog_ref must be a string"))?;
1632    let extension_type_id = answers
1633        .get("extension_type_id")
1634        .and_then(Value::as_str)
1635        .ok_or_else(|| anyhow!("answers.extension_type_id must be a string"))?;
1636    let template_id = answers
1637        .get("extension_template_id")
1638        .and_then(Value::as_str)
1639        .map(ToString::to_string);
1640    let template_qa_answers = json_value_to_string_map(
1641        answers.get("extension_template_qa_answers"),
1642        "extension_template_qa_answers",
1643    )?;
1644    let edit_answers = json_value_to_string_map(
1645        answers.get("extension_edit_answers"),
1646        "extension_edit_answers",
1647    )?;
1648    Ok(Some(ExtensionOperationRecord {
1649        operation: operation.to_string(),
1650        catalog_ref: catalog_ref.to_string(),
1651        extension_type_id: extension_type_id.to_string(),
1652        template_id,
1653        template_qa_answers,
1654        edit_answers,
1655    }))
1656}
1657
1658fn infer_extension_operation_from_selected_actions(
1659    answers: &BTreeMap<String, Value>,
1660) -> Option<String> {
1661    let selected = answers.get("selected_actions")?.as_array()?;
1662    let contains = |needle: &str| {
1663        selected
1664            .iter()
1665            .any(|value| matches!(value.as_str(), Some(item) if item == needle))
1666    };
1667    if contains("main.update_extension_pack") || contains("update_extension_pack.edit_entries") {
1668        return Some("update_extension_pack".to_string());
1669    }
1670    if contains("main.create_extension_pack") || contains("create_extension_pack.start") {
1671        return Some("create_extension_pack".to_string());
1672    }
1673    if contains("main.add_extension") {
1674        return Some("add_extension".to_string());
1675    }
1676    None
1677}
1678
1679fn run_create_extension_pack<R: BufRead, W: Write>(
1680    input: &mut R,
1681    output: &mut W,
1682    i18n: &WizardI18n,
1683    runtime: Option<&RuntimeContext>,
1684    session: &mut WizardSession,
1685) -> Result<()> {
1686    session
1687        .selected_actions
1688        .push("create_extension_pack.start".to_string());
1689    let catalog_ref = prompt_for_extension_catalog_ref(input, output, i18n)?;
1690
1691    let catalog = match load_extension_catalog(catalog_ref.trim(), runtime) {
1692        Ok(value) => value,
1693        Err(err) => {
1694            wizard_ui::render_line(
1695                output,
1696                &format!("{}: {}", i18n.t("wizard.error.catalog_load_failed"), err),
1697            )?;
1698            let nav = ask_failure_nav(input, output, i18n)?;
1699            if matches!(nav, SubmenuAction::MainMenu) {
1700                return Ok(());
1701            }
1702            return Ok(());
1703        }
1704    };
1705
1706    let type_choice = ask_extension_type(input, output, i18n, &catalog)?;
1707    if type_choice == "0" || type_choice.eq_ignore_ascii_case("m") {
1708        return Ok(());
1709    }
1710
1711    let selected = catalog
1712        .extension_types
1713        .iter()
1714        .find(|item| item.id == type_choice)
1715        .ok_or_else(|| anyhow!("selected extension type not found"))?;
1716
1717    let template = match ask_extension_template(input, output, i18n, selected)? {
1718        Some(template) => template,
1719        None => return Ok(()),
1720    };
1721
1722    wizard_ui::render_line(
1723        output,
1724        &format!(
1725            "{} {} / {}",
1726            i18n.t("wizard.create_extension_pack.selected_type"),
1727            selected.id,
1728            template.id
1729        ),
1730    )?;
1731
1732    let default_dir = format!("./{}-extension", selected.id.replace('/', "-"));
1733    let pack_dir = ask_text(
1734        input,
1735        output,
1736        i18n,
1737        "pack.wizard.create_ext.pack_dir",
1738        "wizard.create_extension_pack.ask_pack_dir",
1739        Some("wizard.create_extension_pack.ask_pack_dir_help"),
1740        Some(&default_dir),
1741    )?;
1742    let pack_dir_path = PathBuf::from(pack_dir.trim());
1743    session.last_pack_dir = Some(pack_dir_path.clone());
1744    let qa_answers = ask_template_qa_answers(input, output, i18n, &template)?;
1745    let edit_answers = ask_extension_edit_answers(input, output, i18n, selected)?;
1746    session.extension_operation = Some(ExtensionOperationRecord {
1747        operation: "create_extension_pack".to_string(),
1748        catalog_ref: catalog_ref.trim().to_string(),
1749        extension_type_id: selected.id.clone(),
1750        template_id: Some(template.id.clone()),
1751        template_qa_answers: qa_answers.clone(),
1752        edit_answers: edit_answers.clone(),
1753    });
1754    if session.dry_run {
1755        wizard_ui::render_line(output, &i18n.t("wizard.dry_run.skipping_template_apply"))?;
1756    } else {
1757        if let Err(err) = apply_template_plan(
1758            &template,
1759            &pack_dir_path,
1760            selected,
1761            i18n,
1762            &qa_answers,
1763            &edit_answers,
1764        ) {
1765            wizard_ui::render_line(
1766                output,
1767                &format!("{}: {err}", i18n.t("wizard.error.template_apply_failed")),
1768            )?;
1769            let nav = ask_failure_nav(input, output, i18n)?;
1770            if matches!(nav, SubmenuAction::MainMenu) {
1771                return Ok(());
1772            }
1773            return Ok(());
1774        }
1775        persist_extension_state(
1776            &pack_dir_path,
1777            selected,
1778            &session
1779                .extension_operation
1780                .clone()
1781                .expect("extension operation recorded"),
1782        )?;
1783    }
1784
1785    let self_exe = wizard_self_exe()?;
1786    let finalized = run_update_validate_sequence(
1787        input,
1788        output,
1789        i18n,
1790        session,
1791        &self_exe,
1792        &pack_dir_path,
1793        true,
1794        "wizard.progress.running_finalize",
1795    )?;
1796    if !finalized {
1797        let _ = ask_failure_nav(input, output, i18n)?;
1798    }
1799    Ok(())
1800}
1801
1802fn ask_extension_type<R: BufRead, W: Write>(
1803    input: &mut R,
1804    output: &mut W,
1805    i18n: &WizardI18n,
1806    catalog: &ExtensionCatalog,
1807) -> Result<String> {
1808    let mut choices = catalog
1809        .extension_types
1810        .iter()
1811        .enumerate()
1812        .map(|(idx, ext)| {
1813            (
1814                (idx + 1).to_string(),
1815                format!(
1816                    "{} - {}",
1817                    ext.display_name(i18n),
1818                    ext.display_description(i18n)
1819                ),
1820                ext.id.clone(),
1821            )
1822        })
1823        .collect::<Vec<_>>();
1824
1825    let mut menu_choices = choices
1826        .iter()
1827        .map(|(menu_id, label, _)| (menu_id.clone(), label.clone()))
1828        .collect::<Vec<_>>();
1829    menu_choices.push(("0".to_string(), i18n.t("wizard.nav.back")));
1830    menu_choices.push(("M".to_string(), i18n.t("wizard.nav.main_menu")));
1831
1832    let choice = ask_enum_custom_labels_owned(
1833        input,
1834        output,
1835        i18n,
1836        "pack.wizard.create_ext.type",
1837        "wizard.create_extension_pack.type_menu.title",
1838        Some("wizard.create_extension_pack.type_menu.description"),
1839        &menu_choices,
1840        "M",
1841    )?;
1842
1843    if choice == "0" || choice.eq_ignore_ascii_case("m") {
1844        return Ok(choice);
1845    }
1846
1847    let selected = choices
1848        .iter_mut()
1849        .find(|(menu_id, _, _)| menu_id == &choice)
1850        .map(|(_, _, id)| id.clone())
1851        .ok_or_else(|| anyhow!("invalid extension type selection"))?;
1852    Ok(selected)
1853}
1854
1855fn ask_extension_template<R: BufRead, W: Write>(
1856    input: &mut R,
1857    output: &mut W,
1858    i18n: &WizardI18n,
1859    extension_type: &ExtensionType,
1860) -> Result<Option<ExtensionTemplate>> {
1861    if extension_type.templates.is_empty() {
1862        return Err(anyhow!("extension type has no templates"));
1863    }
1864
1865    let choices = extension_type
1866        .templates
1867        .iter()
1868        .enumerate()
1869        .map(|(idx, item)| {
1870            (
1871                (idx + 1).to_string(),
1872                format!(
1873                    "{} - {}",
1874                    item.display_name(i18n),
1875                    item.display_description(i18n)
1876                ),
1877                item,
1878            )
1879        })
1880        .collect::<Vec<_>>();
1881
1882    let mut menu_choices = choices
1883        .iter()
1884        .map(|(menu_id, label, _)| (menu_id.clone(), label.clone()))
1885        .collect::<Vec<_>>();
1886    menu_choices.push(("0".to_string(), i18n.t("wizard.nav.back")));
1887    menu_choices.push(("M".to_string(), i18n.t("wizard.nav.main_menu")));
1888
1889    let choice = ask_enum_custom_labels_owned(
1890        input,
1891        output,
1892        i18n,
1893        "pack.wizard.create_ext.template",
1894        "wizard.create_extension_pack.template_menu.title",
1895        Some("wizard.create_extension_pack.template_menu.description"),
1896        &menu_choices,
1897        "M",
1898    )?;
1899
1900    if choice == "0" || choice.eq_ignore_ascii_case("m") {
1901        return Ok(None);
1902    }
1903
1904    let selected = choices
1905        .iter()
1906        .find(|(menu_id, _, _)| menu_id == &choice)
1907        .map(|(_, _, template)| (*template).clone())
1908        .ok_or_else(|| anyhow!("invalid extension template selection"))?;
1909    Ok(Some(selected))
1910}
1911
1912fn apply_template_plan(
1913    template: &ExtensionTemplate,
1914    pack_dir: &Path,
1915    extension_type: &ExtensionType,
1916    i18n: &WizardI18n,
1917    qa_answers: &BTreeMap<String, String>,
1918    edit_answers: &BTreeMap<String, String>,
1919) -> Result<()> {
1920    ensure_extension_pack_base_scaffold(pack_dir)?;
1921    for step in &template.plan {
1922        match step {
1923            TemplatePlanStep::EnsureDir { paths } => {
1924                for rel in paths {
1925                    let target = pack_dir.join(render_template_string(
1926                        rel,
1927                        extension_type,
1928                        template,
1929                        i18n,
1930                        qa_answers,
1931                        edit_answers,
1932                    ));
1933                    fs::create_dir_all(&target)
1934                        .with_context(|| format!("create directory {}", target.display()))?;
1935                }
1936            }
1937            TemplatePlanStep::WriteFiles { files } => {
1938                for (rel, content) in files {
1939                    let target = pack_dir.join(render_template_string(
1940                        rel,
1941                        extension_type,
1942                        template,
1943                        i18n,
1944                        qa_answers,
1945                        edit_answers,
1946                    ));
1947                    if let Some(parent) = target.parent() {
1948                        fs::create_dir_all(parent).with_context(|| {
1949                            format!("create parent directory {}", parent.display())
1950                        })?;
1951                    }
1952                    let rendered = render_template_content(
1953                        content,
1954                        extension_type,
1955                        template,
1956                        i18n,
1957                        qa_answers,
1958                        edit_answers,
1959                    );
1960                    fs::write(&target, rendered)
1961                        .with_context(|| format!("write file {}", target.display()))?;
1962                }
1963            }
1964            TemplatePlanStep::WriteBinaryFiles { files } => {
1965                for (rel, encoded) in files {
1966                    let target = pack_dir.join(render_template_string(
1967                        rel,
1968                        extension_type,
1969                        template,
1970                        i18n,
1971                        qa_answers,
1972                        edit_answers,
1973                    ));
1974                    if let Some(parent) = target.parent() {
1975                        fs::create_dir_all(parent).with_context(|| {
1976                            format!("create parent directory {}", parent.display())
1977                        })?;
1978                    }
1979                    let bytes = base64::engine::general_purpose::STANDARD
1980                        .decode(encoded)
1981                        .with_context(|| {
1982                            format!("decode base64 binary scaffold for {}", target.display())
1983                        })?;
1984                    fs::write(&target, bytes)
1985                        .with_context(|| format!("write file {}", target.display()))?;
1986                }
1987            }
1988            TemplatePlanStep::RunCli { command, args } => {
1989                let (rendered_command, rendered_args) = render_run_cli_invocation(
1990                    command,
1991                    args,
1992                    extension_type,
1993                    template,
1994                    i18n,
1995                    qa_answers,
1996                    edit_answers,
1997                )?;
1998                let argv = rendered_args.iter().map(String::as_str).collect::<Vec<_>>();
1999                let ok = run_process(Path::new(&rendered_command), &argv, Some(pack_dir))
2000                    .unwrap_or(false);
2001                if !ok {
2002                    return Err(anyhow!(
2003                        "template run_cli step failed: {} {:?}",
2004                        rendered_command,
2005                        rendered_args
2006                    ));
2007                }
2008            }
2009            TemplatePlanStep::Delegate { target, .. } => {
2010                let ok = match target {
2011                    greentic_types::WizardTarget::Flow => {
2012                        let args = flow_delegate_args(pack_dir);
2013                        run_delegate_owned("greentic-flow", &args, pack_dir)
2014                    }
2015                    greentic_types::WizardTarget::Component => {
2016                        run_delegate("greentic-component", &["wizard"], pack_dir)
2017                    }
2018                    _ => false,
2019                };
2020                if !ok {
2021                    return Err(anyhow!(
2022                        "template delegate step failed for target {:?}",
2023                        target
2024                    ));
2025                }
2026            }
2027        }
2028    }
2029    Ok(())
2030}
2031
2032fn ensure_extension_pack_base_scaffold(pack_dir: &Path) -> Result<()> {
2033    fs::create_dir_all(pack_dir)
2034        .with_context(|| format!("create extension pack dir {}", pack_dir.display()))?;
2035
2036    for rel in ["flows", "components", "i18n", "assets", "qa", "extensions"] {
2037        let target = pack_dir.join(rel);
2038        fs::create_dir_all(&target)
2039            .with_context(|| format!("create directory {}", target.display()))?;
2040    }
2041
2042    for (rel, contents) in [
2043        ("assets/README.md", "Add extension assets here.\n"),
2044        ("qa/README.md", "Add extension QA/setup documents here.\n"),
2045    ] {
2046        let target = pack_dir.join(rel);
2047        if !target.exists() {
2048            fs::write(&target, contents)
2049                .with_context(|| format!("write file {}", target.display()))?;
2050        }
2051    }
2052
2053    Ok(())
2054}
2055
2056fn render_template_content(
2057    content: &str,
2058    extension_type: &ExtensionType,
2059    template: &ExtensionTemplate,
2060    i18n: &WizardI18n,
2061    qa_answers: &BTreeMap<String, String>,
2062    edit_answers: &BTreeMap<String, String>,
2063) -> String {
2064    render_template_string(
2065        content,
2066        extension_type,
2067        template,
2068        i18n,
2069        qa_answers,
2070        edit_answers,
2071    )
2072}
2073
2074fn render_template_string(
2075    raw: &str,
2076    extension_type: &ExtensionType,
2077    template: &ExtensionTemplate,
2078    i18n: &WizardI18n,
2079    qa_answers: &BTreeMap<String, String>,
2080    edit_answers: &BTreeMap<String, String>,
2081) -> String {
2082    let mut rendered = raw
2083        .replace("{{extension_type_id}}", &extension_type.id)
2084        .replace(
2085            "{{extension_type_name}}",
2086            &extension_type.display_name(i18n),
2087        )
2088        .replace("{{template_id}}", &template.id)
2089        .replace("{{template_name}}", &template.display_name(i18n))
2090        .replace(
2091            "{{canonical_extension_key}}",
2092            extension_type.canonical_extension_key(),
2093        )
2094        .replace(
2095            "{{not_implemented}}",
2096            &i18n.t("wizard.shared.not_implemented"),
2097        );
2098    for (key, value) in qa_answers {
2099        rendered = rendered.replace(&format!("{{{{qa.{key}}}}}"), value);
2100    }
2101    for (key, value) in edit_answers {
2102        rendered = rendered.replace(&format!("{{{{edit.{key}}}}}"), value);
2103    }
2104    rendered
2105}
2106
2107fn render_run_cli_invocation(
2108    command: &str,
2109    args: &[String],
2110    extension_type: &ExtensionType,
2111    template: &ExtensionTemplate,
2112    i18n: &WizardI18n,
2113    qa_answers: &BTreeMap<String, String>,
2114    edit_answers: &BTreeMap<String, String>,
2115) -> Result<(String, Vec<String>)> {
2116    let rendered_command = render_template_string(
2117        command,
2118        extension_type,
2119        template,
2120        i18n,
2121        qa_answers,
2122        edit_answers,
2123    );
2124    validate_run_cli_token(&rendered_command, "command", true)?;
2125
2126    let mut rendered_args = Vec::with_capacity(args.len());
2127    for (idx, arg) in args.iter().enumerate() {
2128        let rendered = render_template_string(
2129            arg,
2130            extension_type,
2131            template,
2132            i18n,
2133            qa_answers,
2134            edit_answers,
2135        );
2136        validate_run_cli_token(&rendered, &format!("arg[{idx}]"), false)?;
2137        rendered_args.push(rendered);
2138    }
2139    Ok((rendered_command, rendered_args))
2140}
2141
2142fn validate_run_cli_token(value: &str, field: &str, require_single_word: bool) -> Result<()> {
2143    if value.trim().is_empty() {
2144        return Err(anyhow!(
2145            "template run_cli {field} resolved to an empty value"
2146        ));
2147    }
2148    if value.contains("{{") || value.contains("}}") {
2149        return Err(anyhow!(
2150            "template run_cli {field} contains unresolved placeholders: {value}"
2151        ));
2152    }
2153    if value
2154        .chars()
2155        .any(|ch| ch == '\0' || ch == '\n' || ch == '\r' || ch.is_control())
2156    {
2157        return Err(anyhow!(
2158            "template run_cli {field} contains control characters"
2159        ));
2160    }
2161    if require_single_word && value.chars().any(char::is_whitespace) {
2162        return Err(anyhow!(
2163            "template run_cli {field} must not contain whitespace"
2164        ));
2165    }
2166    Ok(())
2167}
2168
2169fn ask_template_qa_answers<R: BufRead, W: Write>(
2170    input: &mut R,
2171    output: &mut W,
2172    i18n: &WizardI18n,
2173    template: &ExtensionTemplate,
2174) -> Result<BTreeMap<String, String>> {
2175    let mut answers = BTreeMap::new();
2176    for question in &template.qa_questions {
2177        let value = ask_catalog_question(
2178            input,
2179            output,
2180            i18n,
2181            &format!("pack.wizard.create_ext.qa.{}", question.id),
2182            question,
2183        )?;
2184        answers.insert(question.id.clone(), value);
2185    }
2186    Ok(answers)
2187}
2188
2189fn ask_extension_edit_answers<R: BufRead, W: Write>(
2190    input: &mut R,
2191    output: &mut W,
2192    i18n: &WizardI18n,
2193    extension_type: &ExtensionType,
2194) -> Result<BTreeMap<String, String>> {
2195    let mut answers = BTreeMap::new();
2196    let mut create_offer = None;
2197    let mut requires_setup = None;
2198    for question in &extension_type.edit_questions {
2199        let is_offer_field = matches!(
2200            question.id.as_str(),
2201            "offer_id"
2202                | "cap_id"
2203                | "component_ref"
2204                | "op"
2205                | "version"
2206                | "priority"
2207                | "requires_setup"
2208                | "qa_ref"
2209                | "hook_op_names"
2210        );
2211        if is_offer_field && create_offer == Some(false) {
2212            continue;
2213        }
2214        if question.id == "qa_ref" && requires_setup == Some(false) {
2215            continue;
2216        }
2217        let value = ask_catalog_question(
2218            input,
2219            output,
2220            i18n,
2221            &format!(
2222                "pack.wizard.update_ext.edit.{}.{}",
2223                extension_type.id, question.id
2224            ),
2225            question,
2226        )?;
2227        if question.id == "create_offer" {
2228            create_offer = Some(value.trim() == "true");
2229        }
2230        if question.id == "requires_setup" {
2231            requires_setup = Some(value.trim() == "true");
2232        }
2233        answers.insert(question.id.clone(), value);
2234    }
2235    Ok(answers)
2236}
2237
2238fn ask_catalog_question<R: BufRead, W: Write>(
2239    input: &mut R,
2240    output: &mut W,
2241    i18n: &WizardI18n,
2242    form_id: &str,
2243    question: &CatalogQuestion,
2244) -> Result<String> {
2245    match question.kind {
2246        CatalogQuestionKind::Enum => {
2247            let choices = question
2248                .choices
2249                .iter()
2250                .enumerate()
2251                .map(|(idx, choice)| ((idx + 1).to_string(), choice.clone()))
2252                .collect::<Vec<_>>();
2253            let mut menu = choices
2254                .iter()
2255                .map(|(id, label)| (id.clone(), label.clone()))
2256                .collect::<Vec<_>>();
2257            menu.push(("0".to_string(), i18n.t("wizard.nav.back")));
2258            let default_idx = question
2259                .default
2260                .as_deref()
2261                .and_then(|value| {
2262                    choices
2263                        .iter()
2264                        .find(|(_, label)| label == value)
2265                        .map(|(idx, _)| idx.as_str())
2266                })
2267                .unwrap_or("1");
2268            let selected = ask_enum_custom_labels_owned(
2269                input,
2270                output,
2271                i18n,
2272                form_id,
2273                &question.title_key,
2274                question.description_key.as_deref(),
2275                &menu,
2276                default_idx,
2277            )?;
2278            if selected == "0" {
2279                return Ok(question.default.clone().unwrap_or_default());
2280            }
2281            choices
2282                .iter()
2283                .find(|(idx, _)| idx == &selected)
2284                .map(|(_, label)| label.clone())
2285                .ok_or_else(|| anyhow!("invalid enum selection for {}", question.id))
2286        }
2287        CatalogQuestionKind::Boolean => {
2288            let selected = ask_enum(
2289                input,
2290                output,
2291                i18n,
2292                form_id,
2293                &question.title_key,
2294                question.description_key.as_deref(),
2295                &[
2296                    ("1", "wizard.bool.true"),
2297                    ("2", "wizard.bool.false"),
2298                    ("0", "wizard.nav.back"),
2299                ],
2300                if question.default.as_deref() == Some("false") {
2301                    "2"
2302                } else {
2303                    "1"
2304                },
2305            )?;
2306            match selected.as_str() {
2307                "1" => Ok("true".to_string()),
2308                "2" => Ok("false".to_string()),
2309                "0" => Ok(question
2310                    .default
2311                    .clone()
2312                    .unwrap_or_else(|| "false".to_string())),
2313                _ => Err(anyhow!("invalid boolean selection")),
2314            }
2315        }
2316        CatalogQuestionKind::Integer => loop {
2317            let value = ask_text(
2318                input,
2319                output,
2320                i18n,
2321                form_id,
2322                &question.title_key,
2323                question.description_key.as_deref(),
2324                question.default.as_deref(),
2325            )?;
2326            if value.trim().parse::<i64>().is_ok() {
2327                break Ok(value);
2328            }
2329            wizard_ui::render_line(output, &i18n.t("wizard.error.invalid_selection"))?;
2330        },
2331        CatalogQuestionKind::String => ask_text(
2332            input,
2333            output,
2334            i18n,
2335            form_id,
2336            &question.title_key,
2337            question.description_key.as_deref(),
2338            question.default.as_deref(),
2339        ),
2340    }
2341}
2342
2343fn persist_extension_edit_answers(
2344    pack_dir: &Path,
2345    extension_type: &ExtensionType,
2346    operation: &ExtensionOperationRecord,
2347) -> Result<()> {
2348    validate_capability_offer_component_ref(
2349        pack_dir,
2350        extension_type,
2351        &operation.template_qa_answers,
2352        &operation.edit_answers,
2353    )?;
2354    let dir = pack_dir.join("extensions");
2355    fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
2356    let path = dir.join(format!("{}.json", extension_type.id));
2357    let mut payload = json!({
2358        "extension_type": extension_type.id,
2359        "canonical_extension_key": extension_type.canonical_extension_key(),
2360        "operation": operation.operation,
2361        "catalog_ref": operation.catalog_ref,
2362        "template_id": operation.template_id,
2363        "template_qa_answers": operation.template_qa_answers,
2364        "edit_answers": operation.edit_answers,
2365    });
2366    if uses_capabilities_extension(extension_type) {
2367        payload["capabilities_extension"] = serde_json::to_value(build_capabilities_payload(
2368            extension_type,
2369            &operation.template_qa_answers,
2370            &operation.edit_answers,
2371        )?)
2372        .context("serialize capabilities extension payload")?;
2373    } else if uses_deployer_extension(extension_type) {
2374        payload["deployer_extension"] = build_deployer_payload(
2375            extension_type,
2376            &operation.template_qa_answers,
2377            &operation.edit_answers,
2378        )?;
2379    }
2380    let bytes =
2381        serde_json::to_vec_pretty(&payload).context("serialize extension edit answers payload")?;
2382    fs::write(&path, bytes).with_context(|| format!("write {}", path.display()))?;
2383    merge_extension_answers_into_pack_yaml(
2384        pack_dir,
2385        extension_type,
2386        &operation.template_qa_answers,
2387        &operation.edit_answers,
2388    )?;
2389    Ok(())
2390}
2391
2392fn merge_extension_answers_into_pack_yaml(
2393    pack_dir: &Path,
2394    extension_type: &ExtensionType,
2395    template_qa_answers: &BTreeMap<String, String>,
2396    edit_answers: &BTreeMap<String, String>,
2397) -> Result<()> {
2398    if !uses_capabilities_extension(extension_type) {
2399        if uses_deployer_extension(extension_type) {
2400            let pack_yaml = pack_dir.join("pack.yaml");
2401            if !pack_yaml.exists() {
2402                return Ok(());
2403            }
2404            let contents = fs::read_to_string(&pack_yaml)
2405                .with_context(|| format!("read {}", pack_yaml.display()))?;
2406            let serialized = inject_deployer_extension_payload(
2407                &contents,
2408                &build_deployer_payload(extension_type, template_qa_answers, edit_answers)?,
2409            )?;
2410            fs::write(&pack_yaml, serialized)
2411                .with_context(|| format!("write {}", pack_yaml.display()))?;
2412        }
2413        return Ok(());
2414    }
2415    let pack_yaml = pack_dir.join("pack.yaml");
2416    if !pack_yaml.exists() {
2417        return Ok(());
2418    }
2419    let contents =
2420        fs::read_to_string(&pack_yaml).with_context(|| format!("read {}", pack_yaml.display()))?;
2421    let capabilities =
2422        build_capabilities_payload(extension_type, template_qa_answers, edit_answers)?;
2423    let serialized = if let Some(spec) =
2424        capability_offer_spec_from_answers(extension_type, template_qa_answers, edit_answers)?
2425    {
2426        inject_capability_offer_spec(&contents, &spec)?
2427    } else {
2428        ensure_capabilities_extension(&contents)?
2429    };
2430    let _ = capabilities;
2431    fs::write(&pack_yaml, serialized).with_context(|| format!("write {}", pack_yaml.display()))?;
2432    Ok(())
2433}
2434
2435fn validate_capability_offer_component_ref(
2436    pack_dir: &Path,
2437    extension_type: &ExtensionType,
2438    template_qa_answers: &BTreeMap<String, String>,
2439    edit_answers: &BTreeMap<String, String>,
2440) -> Result<()> {
2441    if !uses_capabilities_extension(extension_type) {
2442        return Ok(());
2443    }
2444    let Some(spec) =
2445        capability_offer_spec_from_answers(extension_type, template_qa_answers, edit_answers)?
2446    else {
2447        return Ok(());
2448    };
2449    let pack_yaml = pack_dir.join("pack.yaml");
2450    if !pack_yaml.exists() {
2451        return Ok(());
2452    }
2453    let config = crate::config::load_pack_config(pack_dir)?;
2454    if config
2455        .components
2456        .iter()
2457        .any(|item| item.id == spec.component_ref)
2458    {
2459        return Ok(());
2460    }
2461    Err(anyhow!(
2462        "capability offer component_ref `{}` does not match any components[].id in pack.yaml; scaffold a component with that id or set create_offer=false",
2463        spec.component_ref
2464    ))
2465}
2466
2467fn persist_extension_state(
2468    pack_dir: &Path,
2469    extension_type: &ExtensionType,
2470    operation: &ExtensionOperationRecord,
2471) -> Result<()> {
2472    persist_extension_edit_answers(pack_dir, extension_type, operation)
2473}
2474
2475fn build_capabilities_payload(
2476    extension_type: &ExtensionType,
2477    template_qa_answers: &BTreeMap<String, String>,
2478    edit_answers: &BTreeMap<String, String>,
2479) -> Result<CapabilitiesExtensionV1> {
2480    let offer =
2481        capability_offer_spec_from_answers(extension_type, template_qa_answers, edit_answers)?.map(
2482            |spec| greentic_types::pack::extensions::capabilities::CapabilityOfferV1 {
2483                offer_id: spec.offer_id,
2484                cap_id: spec.cap_id,
2485                version: spec.version,
2486                provider: greentic_types::pack::extensions::capabilities::CapabilityProviderRefV1 {
2487                    component_ref: spec.component_ref,
2488                    op: spec.op,
2489                },
2490                scope: None,
2491                priority: spec.priority,
2492                requires_setup: spec.requires_setup,
2493                setup: spec.qa_ref.map(|qa_ref| {
2494                    greentic_types::pack::extensions::capabilities::CapabilitySetupV1 { qa_ref }
2495                }),
2496                applies_to: (!spec.hook_op_names.is_empty()).then_some(
2497                    greentic_types::pack::extensions::capabilities::CapabilityHookAppliesToV1 {
2498                        op_names: spec.hook_op_names,
2499                    },
2500                ),
2501            },
2502        );
2503    Ok(CapabilitiesExtensionV1::new(offer.into_iter().collect()))
2504}
2505
2506fn build_deployer_payload(
2507    _extension_type: &ExtensionType,
2508    _template_qa_answers: &BTreeMap<String, String>,
2509    edit_answers: &BTreeMap<String, String>,
2510) -> Result<Value> {
2511    let contract_id = required_answer(edit_answers, "contract_id")?;
2512    let ops = optional_answer(edit_answers, "supported_ops")
2513        .unwrap_or_else(|| "generate,plan,apply,destroy,status,rollback".to_string())
2514        .split(',')
2515        .map(str::trim)
2516        .filter(|item| !item.is_empty())
2517        .map(ToString::to_string)
2518        .collect::<Vec<_>>();
2519    if ops.is_empty() {
2520        return Err(anyhow!("missing required answer `supported_ops`"));
2521    }
2522    let flow_refs = ops
2523        .iter()
2524        .map(|op| (op.clone(), Value::String(format!("flows/{op}.ygtc"))))
2525        .collect::<serde_json::Map<_, _>>();
2526
2527    Ok(json!({
2528        "version": 1,
2529        "provides": [{
2530            "capability": DEPLOYER_EXTENSION_KEY,
2531            "contract": contract_id,
2532            "ops": ops,
2533        }],
2534        "flow_refs": flow_refs,
2535    }))
2536}
2537
2538fn capability_offer_spec_from_answers(
2539    extension_type: &ExtensionType,
2540    template_qa_answers: &BTreeMap<String, String>,
2541    edit_answers: &BTreeMap<String, String>,
2542) -> Result<Option<CapabilityOfferSpec>> {
2543    let create_offer = match edit_answers.get("create_offer").map(|value| value.trim()) {
2544        None | Some("") => false,
2545        Some("true") => true,
2546        Some("false") => false,
2547        Some(other) => return Err(anyhow!("invalid create_offer value `{other}`")),
2548    };
2549    if !create_offer {
2550        return Ok(None);
2551    }
2552
2553    let offer_id = required_answer(edit_answers, "offer_id")?;
2554    let cap_id = required_answer(edit_answers, "cap_id")?;
2555    let component_ref = required_answer(edit_answers, "component_ref")?;
2556    let op = required_answer(edit_answers, "op")?;
2557    let version = optional_answer(edit_answers, "version")
2558        .unwrap_or_else(|| default_capability_version(extension_type));
2559    let priority = optional_answer(edit_answers, "priority")
2560        .unwrap_or_else(|| "0".to_string())
2561        .parse::<i32>()
2562        .with_context(|| format!("invalid priority for extension type {}", extension_type.id))?;
2563    let requires_setup = matches!(
2564        edit_answers.get("requires_setup").map(|value| value.trim()),
2565        Some("true")
2566    );
2567    let qa_ref = if requires_setup {
2568        optional_answer(edit_answers, "qa_ref")
2569            .or_else(|| optional_answer(template_qa_answers, "qa_ref"))
2570    } else {
2571        None
2572    };
2573    if requires_setup && qa_ref.is_none() {
2574        return Err(anyhow!(
2575            "extension type {} requires qa_ref when requires_setup=true",
2576            extension_type.id
2577        ));
2578    }
2579    let hook_op_names = optional_answer(edit_answers, "hook_op_names")
2580        .map(|value| {
2581            value
2582                .split(',')
2583                .map(str::trim)
2584                .filter(|item| !item.is_empty())
2585                .map(ToString::to_string)
2586                .collect::<Vec<_>>()
2587        })
2588        .unwrap_or_default();
2589
2590    Ok(Some(CapabilityOfferSpec {
2591        offer_id,
2592        cap_id,
2593        version,
2594        component_ref,
2595        op,
2596        priority,
2597        requires_setup,
2598        qa_ref,
2599        hook_op_names,
2600    }))
2601}
2602
2603fn required_answer(answers: &BTreeMap<String, String>, key: &str) -> Result<String> {
2604    answers
2605        .get(key)
2606        .map(|value| value.trim())
2607        .filter(|value| !value.is_empty())
2608        .map(ToString::to_string)
2609        .ok_or_else(|| anyhow!("missing required answer `{key}`"))
2610}
2611
2612fn optional_answer(answers: &BTreeMap<String, String>, key: &str) -> Option<String> {
2613    answers
2614        .get(key)
2615        .map(|value| value.trim())
2616        .filter(|value| !value.is_empty())
2617        .map(ToString::to_string)
2618}
2619
2620fn default_capability_version(_extension_type: &ExtensionType) -> String {
2621    "v1".to_string()
2622}
2623
2624fn inject_deployer_extension_payload(contents: &str, payload: &Value) -> Result<String> {
2625    let mut document: YamlValue = serde_yaml_bw::from_str(contents)
2626        .context("parse pack.yaml for deployer extension merge")?;
2627    let mapping = document
2628        .as_mapping_mut()
2629        .ok_or_else(|| anyhow!("pack.yaml root must be a mapping"))?;
2630    let extensions = mapping
2631        .entry(yaml_key("extensions"))
2632        .or_insert_with(|| YamlValue::Mapping(Mapping::new()));
2633    let extensions_map = extensions
2634        .as_mapping_mut()
2635        .ok_or_else(|| anyhow!("extensions must be a mapping"))?;
2636    let extension_slot = extensions_map
2637        .entry(yaml_key(DEPLOYER_EXTENSION_KEY))
2638        .or_insert_with(|| YamlValue::Mapping(Mapping::new()));
2639    let extension_map = extension_slot
2640        .as_mapping_mut()
2641        .ok_or_else(|| anyhow!("deployer extension slot must be a mapping"))?;
2642    extension_map
2643        .entry(yaml_key("kind"))
2644        .or_insert_with(|| YamlValue::String(DEPLOYER_EXTENSION_KEY.to_string(), None));
2645    extension_map
2646        .entry(yaml_key("version"))
2647        .or_insert_with(|| YamlValue::String("1.0.0".to_string(), None));
2648    extension_map.insert(
2649        yaml_key("inline"),
2650        serde_yaml_bw::to_value(payload).context("serialize deployer extension payload")?,
2651    );
2652
2653    serde_yaml_bw::to_string(&document).context("serialize updated pack.yaml")
2654}
2655
2656fn yaml_key(key: &str) -> YamlValue {
2657    YamlValue::String(key.to_string(), None)
2658}
2659
2660fn uses_capabilities_extension(extension_type: &ExtensionType) -> bool {
2661    extension_type.canonical_extension_key() == CAPABILITIES_EXTENSION_KEY
2662}
2663
2664fn uses_deployer_extension(extension_type: &ExtensionType) -> bool {
2665    extension_type.canonical_extension_key() == DEPLOYER_EXTENSION_KEY
2666}
2667
2668fn validate_extension_operation_record(operation: &ExtensionOperationRecord) -> Result<()> {
2669    match operation.operation.as_str() {
2670        "create_extension_pack" | "update_extension_pack" | "add_extension" => {}
2671        other => {
2672            return Err(anyhow!(
2673                "unsupported extension operation `{other}` in answers document"
2674            ));
2675        }
2676    }
2677    if operation.catalog_ref.trim().is_empty() {
2678        return Err(anyhow!("extension catalog ref must not be empty"));
2679    }
2680    if operation.extension_type_id.trim().is_empty() {
2681        return Err(anyhow!("extension type id must not be empty"));
2682    }
2683    if operation.operation == "create_extension_pack" && operation.template_id.is_none() {
2684        return Err(anyhow!(
2685            "create_extension_pack requires answers.extension_template_id"
2686        ));
2687    }
2688    Ok(())
2689}
2690
2691fn apply_extension_operation(pack_dir: &Path, operation: &ExtensionOperationRecord) -> Result<()> {
2692    if operation.extension_type_id == LEGACY_MESSAGING_WEBCHAT_GUI_EXTENSION_ID {
2693        return apply_legacy_messaging_webchat_gui_extension(pack_dir, operation);
2694    }
2695    let catalog = load_extension_catalog(&operation.catalog_ref, None)?;
2696    let extension_type = catalog
2697        .extension_types
2698        .iter()
2699        .find(|item| item.id == operation.extension_type_id)
2700        .ok_or_else(|| {
2701            anyhow!(
2702                "extension type `{}` not found in catalog",
2703                operation.extension_type_id
2704            )
2705        })?;
2706
2707    if operation.operation == "create_extension_pack" {
2708        let template_id = operation
2709            .template_id
2710            .as_deref()
2711            .ok_or_else(|| anyhow!("missing template_id for create_extension_pack"))?;
2712        let template = extension_type
2713            .templates
2714            .iter()
2715            .find(|item| item.id == template_id)
2716            .ok_or_else(|| anyhow!("template `{template_id}` not found in catalog"))?;
2717        let i18n = WizardI18n::new(Some("en-GB"));
2718        apply_template_plan(
2719            template,
2720            pack_dir,
2721            extension_type,
2722            &i18n,
2723            &operation.template_qa_answers,
2724            &operation.edit_answers,
2725        )?;
2726    }
2727
2728    persist_extension_state(pack_dir, extension_type, operation)
2729}
2730
2731fn apply_legacy_messaging_webchat_gui_extension(
2732    pack_dir: &Path,
2733    operation: &ExtensionOperationRecord,
2734) -> Result<()> {
2735    let pack_yaml = pack_dir.join("pack.yaml");
2736    let contents =
2737        fs::read_to_string(&pack_yaml).with_context(|| format!("read {}", pack_yaml.display()))?;
2738    let provider_id = optional_answer(&operation.edit_answers, "entry_label")
2739        .unwrap_or_else(|| LEGACY_MESSAGING_WEBCHAT_GUI_EXTENSION_ID.to_string());
2740    let version = crate::config::load_pack_config(pack_dir)
2741        .map(|cfg| cfg.version.to_string())
2742        .unwrap_or_else(|_| "0.1.0".to_string());
2743    let updated = inject_provider_entry_for_wizard(&contents, &provider_id, "messaging", &version)?;
2744    fs::write(&pack_yaml, updated).with_context(|| format!("write {}", pack_yaml.display()))?;
2745    Ok(())
2746}
2747
2748fn ask_main_menu<R: BufRead, W: Write>(
2749    input: &mut R,
2750    output: &mut W,
2751    i18n: &WizardI18n,
2752) -> Result<MainChoice> {
2753    let choice = ask_enum(
2754        input,
2755        output,
2756        i18n,
2757        "pack.wizard.main",
2758        "wizard.main.title",
2759        Some("wizard.main.description"),
2760        &[
2761            ("1", "wizard.main.option.create_application_pack"),
2762            ("2", "wizard.main.option.update_application_pack"),
2763            ("3", "wizard.main.option.create_extension_pack"),
2764            ("4", "wizard.main.option.update_extension_pack"),
2765            ("5", "wizard.main.option.add_extension"),
2766            ("0", "wizard.main.option.exit"),
2767        ],
2768        "0",
2769    )?;
2770    MainChoice::from_choice(&choice)
2771}
2772
2773fn ask_placeholder_submenu<R: BufRead, W: Write>(
2774    input: &mut R,
2775    output: &mut W,
2776    i18n: &WizardI18n,
2777    title_key: &str,
2778) -> Result<SubmenuAction> {
2779    let choice = ask_enum(
2780        input,
2781        output,
2782        i18n,
2783        "pack.wizard.placeholder",
2784        title_key,
2785        Some("wizard.shared.not_implemented"),
2786        &[("0", "wizard.nav.back"), ("M", "wizard.nav.main_menu")],
2787        "M",
2788    )?;
2789    SubmenuAction::from_choice(&choice)
2790}
2791
2792fn run_create_application_pack<R: BufRead, W: Write>(
2793    input: &mut R,
2794    output: &mut W,
2795    i18n: &WizardI18n,
2796    session: &mut WizardSession,
2797) -> Result<()> {
2798    session
2799        .selected_actions
2800        .push("create_application_pack.start".to_string());
2801    let pack_id = ask_text(
2802        input,
2803        output,
2804        i18n,
2805        "pack.wizard.create_app.pack_id",
2806        "wizard.create_application_pack.ask_pack_id",
2807        None,
2808        None,
2809    )?;
2810
2811    let pack_dir_default = format!("./{pack_id}");
2812    let pack_dir = ask_text(
2813        input,
2814        output,
2815        i18n,
2816        "pack.wizard.create_app.pack_dir",
2817        "wizard.create_application_pack.ask_pack_dir",
2818        Some("wizard.create_application_pack.ask_pack_dir_help"),
2819        Some(&pack_dir_default),
2820    )?;
2821
2822    let pack_dir_path = PathBuf::from(pack_dir.trim());
2823    session.last_pack_dir = Some(pack_dir_path.clone());
2824    session.create_pack_scaffold = true;
2825    session.create_pack_id = Some(pack_id.clone());
2826    let self_exe = wizard_self_exe()?;
2827
2828    let scaffold_ok = if session.dry_run {
2829        wizard_ui::render_line(output, &i18n.t("wizard.dry_run.skipping_scaffold"))?;
2830        let temp_pack_dir = temp_answers_path("greentic-pack-dry-run-pack");
2831        let ok = run_process(
2832            &self_exe,
2833            &[
2834                "new",
2835                "--dir",
2836                &temp_pack_dir.display().to_string(),
2837                &pack_id,
2838            ],
2839            None,
2840        )?;
2841        if ok {
2842            session.dry_run_delegate_pack_dir = Some(temp_pack_dir);
2843        }
2844        ok
2845    } else {
2846        run_process(
2847            &self_exe,
2848            &[
2849                "new",
2850                "--dir",
2851                &pack_dir_path.display().to_string(),
2852                &pack_id,
2853            ],
2854            None,
2855        )?
2856    };
2857    if !scaffold_ok {
2858        wizard_ui::render_line(output, &i18n.t("wizard.error.create_app_failed"))?;
2859        let nav = ask_failure_nav(input, output, i18n)?;
2860        if matches!(nav, SubmenuAction::MainMenu) {
2861            return Ok(());
2862        }
2863        return Ok(());
2864    }
2865
2866    loop {
2867        let delegate_pack_dir = session
2868            .dry_run_delegate_pack_dir
2869            .as_deref()
2870            .unwrap_or(&pack_dir_path)
2871            .to_path_buf();
2872        let setup_choice = ask_enum(
2873            input,
2874            output,
2875            i18n,
2876            "pack.wizard.create_app.setup",
2877            "wizard.create_application_pack.setup.title",
2878            Some("wizard.create_application_pack.setup.description"),
2879            &[
2880                (
2881                    "1",
2882                    "wizard.create_application_pack.setup.option.edit_flows",
2883                ),
2884                (
2885                    "2",
2886                    "wizard.create_application_pack.setup.option.add_edit_components",
2887                ),
2888                ("3", "wizard.create_application_pack.setup.option.finalize"),
2889                ("0", "wizard.nav.back"),
2890                ("M", "wizard.nav.main_menu"),
2891            ],
2892            "M",
2893        )?;
2894
2895        match setup_choice.as_str() {
2896            "1" => {
2897                session.run_delegate_flow = true;
2898                let delegate_ok = run_flow_delegate_for_session(session, &delegate_pack_dir);
2899                if !delegate_ok
2900                    && handle_delegate_failure(
2901                        input,
2902                        output,
2903                        i18n,
2904                        session,
2905                        "wizard.error.delegate_flow_failed",
2906                    )?
2907                {
2908                    return Ok(());
2909                }
2910            }
2911            "2" => {
2912                session.run_delegate_component = true;
2913                let delegate_ok = run_component_delegate_for_session(session, &delegate_pack_dir);
2914                if !delegate_ok
2915                    && handle_delegate_failure(
2916                        input,
2917                        output,
2918                        i18n,
2919                        session,
2920                        "wizard.error.delegate_component_failed",
2921                    )?
2922                {
2923                    return Ok(());
2924                }
2925            }
2926            "3" => {
2927                if finalize_create_app(input, output, i18n, session, &self_exe, &pack_dir_path)? {
2928                    return Ok(());
2929                }
2930            }
2931            "0" | "M" | "m" => return Ok(()),
2932            _ => {
2933                wizard_ui::render_line(output, &i18n.t("wizard.error.invalid_selection"))?;
2934            }
2935        }
2936    }
2937}
2938
2939fn finalize_create_app<R: BufRead, W: Write>(
2940    input: &mut R,
2941    output: &mut W,
2942    i18n: &WizardI18n,
2943    session: &mut WizardSession,
2944    self_exe: &Path,
2945    pack_dir_path: &Path,
2946) -> Result<bool> {
2947    run_update_validate_sequence(
2948        input,
2949        output,
2950        i18n,
2951        session,
2952        self_exe,
2953        pack_dir_path,
2954        true,
2955        "wizard.progress.running_finalize",
2956    )
2957}
2958
2959fn run_update_application_pack<R: BufRead, W: Write>(
2960    input: &mut R,
2961    output: &mut W,
2962    i18n: &WizardI18n,
2963    session: &mut WizardSession,
2964) -> Result<()> {
2965    let pack_dir_path = ask_existing_pack_dir(
2966        input,
2967        output,
2968        i18n,
2969        "pack.wizard.update_app.pack_dir",
2970        "wizard.update_application_pack.ask_pack_dir",
2971        Some("wizard.update_application_pack.ask_pack_dir_help"),
2972        Some("."),
2973    )?;
2974    session.last_pack_dir = Some(pack_dir_path.clone());
2975    let self_exe = wizard_self_exe()?;
2976
2977    loop {
2978        let choice = ask_enum(
2979            input,
2980            output,
2981            i18n,
2982            "pack.wizard.update_app.menu",
2983            "wizard.update_application_pack.menu.title",
2984            Some("wizard.update_application_pack.menu.description"),
2985            &[
2986                ("1", "wizard.update_application_pack.menu.option.edit_flows"),
2987                (
2988                    "2",
2989                    "wizard.update_application_pack.menu.option.add_edit_components",
2990                ),
2991                (
2992                    "3",
2993                    "wizard.update_application_pack.menu.option.run_update_validate",
2994                ),
2995                ("4", "wizard.update_application_pack.menu.option.sign"),
2996                ("0", "wizard.nav.back"),
2997                ("M", "wizard.nav.main_menu"),
2998            ],
2999            "M",
3000        )?;
3001
3002        match choice.as_str() {
3003            "1" => {
3004                session
3005                    .selected_actions
3006                    .push("update_application_pack.edit_flows".to_string());
3007                session.run_delegate_flow = true;
3008                let delegate_ok = run_flow_delegate_for_session(session, &pack_dir_path);
3009                if delegate_ok {
3010                    let _ = run_update_validate_sequence(
3011                        input,
3012                        output,
3013                        i18n,
3014                        session,
3015                        &self_exe,
3016                        &pack_dir_path,
3017                        true,
3018                        "wizard.progress.auto_run_update_validate",
3019                    )?;
3020                } else if handle_delegate_failure(
3021                    input,
3022                    output,
3023                    i18n,
3024                    session,
3025                    "wizard.error.delegate_flow_failed",
3026                )? {
3027                    return Ok(());
3028                }
3029            }
3030            "2" => {
3031                session
3032                    .selected_actions
3033                    .push("update_application_pack.add_edit_components".to_string());
3034                session.run_delegate_component = true;
3035                let delegate_ok = run_component_delegate_for_session(session, &pack_dir_path);
3036                if delegate_ok {
3037                    let _ = run_update_validate_sequence(
3038                        input,
3039                        output,
3040                        i18n,
3041                        session,
3042                        &self_exe,
3043                        &pack_dir_path,
3044                        true,
3045                        "wizard.progress.auto_run_update_validate",
3046                    )?;
3047                } else if handle_delegate_failure(
3048                    input,
3049                    output,
3050                    i18n,
3051                    session,
3052                    "wizard.error.delegate_component_failed",
3053                )? {
3054                    return Ok(());
3055                }
3056            }
3057            "3" => {
3058                session
3059                    .selected_actions
3060                    .push("update_application_pack.run_update_validate".to_string());
3061                let _ = run_update_validate_sequence(
3062                    input,
3063                    output,
3064                    i18n,
3065                    session,
3066                    &self_exe,
3067                    &pack_dir_path,
3068                    true,
3069                    "wizard.progress.running_update_validate",
3070                )?;
3071            }
3072            "4" => {
3073                session
3074                    .selected_actions
3075                    .push("update_application_pack.sign".to_string());
3076                let _ = run_sign_for_pack(input, output, i18n, session, &self_exe, &pack_dir_path)?;
3077            }
3078            "0" | "M" | "m" => return Ok(()),
3079            _ => {
3080                wizard_ui::render_line(output, &i18n.t("wizard.error.invalid_selection"))?;
3081            }
3082        }
3083    }
3084}
3085
3086fn run_update_extension_pack<R: BufRead, W: Write>(
3087    input: &mut R,
3088    output: &mut W,
3089    i18n: &WizardI18n,
3090    session: &mut WizardSession,
3091    runtime: Option<&RuntimeContext>,
3092) -> Result<()> {
3093    session
3094        .selected_actions
3095        .push("update_extension_pack.start".to_string());
3096    let pack_dir_path = ask_existing_pack_dir(
3097        input,
3098        output,
3099        i18n,
3100        "pack.wizard.update_ext.pack_dir",
3101        "wizard.update_extension_pack.ask_pack_dir",
3102        Some("wizard.update_extension_pack.ask_pack_dir_help"),
3103        Some("."),
3104    )?;
3105    session.last_pack_dir = Some(pack_dir_path.clone());
3106    let catalog_ref = prompt_for_extension_catalog_ref(input, output, i18n)?;
3107
3108    let catalog = match load_extension_catalog(catalog_ref.trim(), runtime) {
3109        Ok(value) => value,
3110        Err(err) => {
3111            wizard_ui::render_line(
3112                output,
3113                &format!("{}: {}", i18n.t("wizard.error.catalog_load_failed"), err),
3114            )?;
3115            let nav = ask_failure_nav(input, output, i18n)?;
3116            if matches!(nav, SubmenuAction::MainMenu) {
3117                return Ok(());
3118            }
3119            return Ok(());
3120        }
3121    };
3122
3123    let self_exe = wizard_self_exe()?;
3124
3125    loop {
3126        let choice = ask_enum(
3127            input,
3128            output,
3129            i18n,
3130            "pack.wizard.update_ext.menu",
3131            "wizard.update_extension_pack.menu.title",
3132            Some("wizard.update_extension_pack.menu.description"),
3133            &[
3134                ("1", "wizard.update_extension_pack.menu.option.edit_entries"),
3135                ("2", "wizard.update_extension_pack.menu.option.edit_flows"),
3136                (
3137                    "3",
3138                    "wizard.update_extension_pack.menu.option.add_edit_components",
3139                ),
3140                (
3141                    "4",
3142                    "wizard.update_extension_pack.menu.option.run_update_validate",
3143                ),
3144                ("5", "wizard.update_extension_pack.menu.option.sign"),
3145                ("0", "wizard.nav.back"),
3146                ("M", "wizard.nav.main_menu"),
3147            ],
3148            "M",
3149        )?;
3150
3151        match choice.as_str() {
3152            "1" => {
3153                let type_choice = ask_extension_type(input, output, i18n, &catalog)?;
3154                if type_choice == "0" || type_choice.eq_ignore_ascii_case("m") {
3155                    continue;
3156                }
3157                let selected = catalog
3158                    .extension_types
3159                    .iter()
3160                    .find(|item| item.id == type_choice)
3161                    .ok_or_else(|| anyhow!("selected extension type not found"))?;
3162                let answers = ask_extension_edit_answers(input, output, i18n, selected)?;
3163                let operation = ExtensionOperationRecord {
3164                    operation: "update_extension_pack".to_string(),
3165                    catalog_ref: catalog_ref.trim().to_string(),
3166                    extension_type_id: selected.id.clone(),
3167                    template_id: None,
3168                    template_qa_answers: BTreeMap::new(),
3169                    edit_answers: answers.clone(),
3170                };
3171                session.extension_operation = Some(operation.clone());
3172                if !session.dry_run {
3173                    persist_extension_edit_answers(&pack_dir_path, selected, &operation)?;
3174                } else {
3175                    wizard_ui::render_line(
3176                        output,
3177                        &i18n.t("wizard.dry_run.skipping_edit_entry_persist"),
3178                    )?;
3179                }
3180                wizard_ui::render_line(
3181                    output,
3182                    &format!(
3183                        "{} {}",
3184                        i18n.t("wizard.update_extension_pack.edited_entry"),
3185                        type_choice
3186                    ),
3187                )?;
3188            }
3189            "2" => {
3190                session.run_delegate_flow = true;
3191                let delegate_ok = run_flow_delegate_for_session(session, &pack_dir_path);
3192                if !delegate_ok
3193                    && handle_delegate_failure(
3194                        input,
3195                        output,
3196                        i18n,
3197                        session,
3198                        "wizard.error.delegate_flow_failed",
3199                    )?
3200                {
3201                    return Ok(());
3202                }
3203            }
3204            "3" => {
3205                session.run_delegate_component = true;
3206                let delegate_ok = run_component_delegate_for_session(session, &pack_dir_path);
3207                if !delegate_ok
3208                    && handle_delegate_failure(
3209                        input,
3210                        output,
3211                        i18n,
3212                        session,
3213                        "wizard.error.delegate_component_failed",
3214                    )?
3215                {
3216                    return Ok(());
3217                }
3218            }
3219            "4" => {
3220                let _ = run_update_validate_sequence(
3221                    input,
3222                    output,
3223                    i18n,
3224                    session,
3225                    &self_exe,
3226                    &pack_dir_path,
3227                    true,
3228                    "wizard.progress.running_update_validate",
3229                )?;
3230            }
3231            "5" => {
3232                let _ = run_sign_for_pack(input, output, i18n, session, &self_exe, &pack_dir_path)?;
3233            }
3234            "0" | "M" | "m" => return Ok(()),
3235            _ => {
3236                wizard_ui::render_line(output, &i18n.t("wizard.error.invalid_selection"))?;
3237            }
3238        }
3239    }
3240}
3241
3242fn run_add_extension<R: BufRead, W: Write>(
3243    input: &mut R,
3244    output: &mut W,
3245    i18n: &WizardI18n,
3246    session: &mut WizardSession,
3247    runtime: Option<&RuntimeContext>,
3248) -> Result<()> {
3249    session
3250        .selected_actions
3251        .push("add_extension.start".to_string());
3252    let pack_dir_path = ask_existing_pack_dir(
3253        input,
3254        output,
3255        i18n,
3256        "pack.wizard.add_ext.pack_dir",
3257        "wizard.update_extension_pack.ask_pack_dir",
3258        Some("wizard.update_extension_pack.ask_pack_dir_help"),
3259        Some("."),
3260    )?;
3261    session.last_pack_dir = Some(pack_dir_path.clone());
3262    let catalog_ref = prompt_for_extension_catalog_ref(input, output, i18n)?;
3263
3264    let catalog = match load_extension_catalog(catalog_ref.trim(), runtime) {
3265        Ok(value) => value,
3266        Err(err) => {
3267            wizard_ui::render_line(
3268                output,
3269                &format!("{}: {}", i18n.t("wizard.error.catalog_load_failed"), err),
3270            )?;
3271            let nav = ask_failure_nav(input, output, i18n)?;
3272            if matches!(nav, SubmenuAction::MainMenu) {
3273                return Ok(());
3274            }
3275            return Ok(());
3276        }
3277    };
3278
3279    let type_choice = ask_extension_type(input, output, i18n, &catalog)?;
3280    if type_choice == "0" || type_choice.eq_ignore_ascii_case("m") {
3281        return Ok(());
3282    }
3283    let selected = catalog
3284        .extension_types
3285        .iter()
3286        .find(|item| item.id == type_choice)
3287        .ok_or_else(|| anyhow!("selected extension type not found"))?;
3288    let answers = ask_extension_edit_answers(input, output, i18n, selected)?;
3289    let operation = ExtensionOperationRecord {
3290        operation: "add_extension".to_string(),
3291        catalog_ref: catalog_ref.trim().to_string(),
3292        extension_type_id: selected.id.clone(),
3293        template_id: None,
3294        template_qa_answers: BTreeMap::new(),
3295        edit_answers: answers.clone(),
3296    };
3297    session.extension_operation = Some(operation.clone());
3298    if !session.dry_run {
3299        persist_extension_edit_answers(&pack_dir_path, selected, &operation)?;
3300        wizard_ui::render_line(output, &i18n.t("cli.wizard.updated_pack_yaml"))?;
3301    } else {
3302        wizard_ui::render_line(output, &i18n.t("cli.wizard.dry_run.update_pack_yaml"))?;
3303        let extension_path = pack_dir_path
3304            .join("extensions")
3305            .join(format!("{}.json", selected.id));
3306        let would_write = i18n.t("cli.wizard.dry_run.would_write").replacen(
3307            "{}",
3308            &extension_path.display().to_string(),
3309            1,
3310        );
3311        wizard_ui::render_line(output, &would_write)?;
3312    }
3313    session
3314        .selected_actions
3315        .push("add_extension.edit_entries".to_string());
3316    Ok(())
3317}
3318
3319#[allow(clippy::too_many_arguments)]
3320fn run_update_validate_sequence<R: BufRead, W: Write>(
3321    input: &mut R,
3322    output: &mut W,
3323    i18n: &WizardI18n,
3324    session: &mut WizardSession,
3325    self_exe: &Path,
3326    pack_dir_path: &Path,
3327    prompt_sign_after: bool,
3328    progress_key: &str,
3329) -> Result<bool> {
3330    session.run_doctor = true;
3331    session.run_build = true;
3332    session
3333        .selected_actions
3334        .push("pipeline.update_validate".to_string());
3335    if session.dry_run {
3336        wizard_ui::render_line(output, &i18n.t(progress_key))?;
3337        wizard_ui::render_line(output, &i18n.t("wizard.progress.running_doctor"))?;
3338        wizard_ui::render_line(output, &i18n.t("wizard.progress.running_build"))?;
3339        return if prompt_sign_after {
3340            run_sign_prompt_after_finalize(input, output, i18n, session, self_exe, pack_dir_path)
3341        } else {
3342            Ok(true)
3343        };
3344    }
3345
3346    wizard_ui::render_line(output, &i18n.t(progress_key))?;
3347    let update_ok = run_process(
3348        self_exe,
3349        &["update", "--in", &pack_dir_path.display().to_string()],
3350        None,
3351    )?;
3352    if !update_ok {
3353        wizard_ui::render_line(output, &i18n.t("wizard.error.finalize_build_failed"))?;
3354        return Ok(false);
3355    }
3356    wizard_ui::render_line(output, &i18n.t("wizard.progress.running_doctor"))?;
3357    let doctor_ok = run_process(
3358        self_exe,
3359        &["doctor", "--in", &pack_dir_path.display().to_string()],
3360        None,
3361    )?;
3362    if !doctor_ok {
3363        wizard_ui::render_line(output, &i18n.t("wizard.error.finalize_doctor_failed"))?;
3364        return Ok(false);
3365    }
3366
3367    let resolve_ok = run_process(
3368        self_exe,
3369        &["resolve", "--in", &pack_dir_path.display().to_string()],
3370        None,
3371    )?;
3372    if !resolve_ok {
3373        wizard_ui::render_line(output, &i18n.t("wizard.error.finalize_build_failed"))?;
3374        return Ok(false);
3375    }
3376
3377    wizard_ui::render_line(output, &i18n.t("wizard.progress.running_build"))?;
3378    let build_ok = run_process(
3379        self_exe,
3380        &["build", "--in", &pack_dir_path.display().to_string()],
3381        None,
3382    )?;
3383    if !build_ok {
3384        wizard_ui::render_line(output, &i18n.t("wizard.error.finalize_build_failed"))?;
3385        return Ok(false);
3386    }
3387
3388    if prompt_sign_after {
3389        run_sign_prompt_after_finalize(input, output, i18n, session, self_exe, pack_dir_path)
3390    } else {
3391        Ok(true)
3392    }
3393}
3394
3395fn run_sign_prompt_after_finalize<R: BufRead, W: Write>(
3396    input: &mut R,
3397    output: &mut W,
3398    i18n: &WizardI18n,
3399    session: &mut WizardSession,
3400    self_exe: &Path,
3401    pack_dir_path: &Path,
3402) -> Result<bool> {
3403    let sign_choice = ask_enum(
3404        input,
3405        output,
3406        i18n,
3407        "pack.wizard.sign_prompt",
3408        "wizard.sign.after_finalize.title",
3409        Some("wizard.sign.after_finalize.description"),
3410        &[
3411            ("1", "wizard.sign.after_finalize.option.sign_now"),
3412            ("2", "wizard.sign.after_finalize.option.skip"),
3413            ("0", "wizard.nav.back"),
3414            ("M", "wizard.nav.main_menu"),
3415        ],
3416        "2",
3417    )?;
3418
3419    match sign_choice.as_str() {
3420        "2" => {
3421            session
3422                .selected_actions
3423                .push("pipeline.sign_prompt.skip".to_string());
3424            Ok(true)
3425        }
3426        "M" | "m" => {
3427            session
3428                .selected_actions
3429                .push("pipeline.sign_prompt.main_menu".to_string());
3430            Ok(true)
3431        }
3432        "0" => {
3433            session
3434                .selected_actions
3435                .push("pipeline.sign_prompt.back".to_string());
3436            Ok(false)
3437        }
3438        "1" => run_sign_for_pack(input, output, i18n, session, self_exe, pack_dir_path),
3439        _ => {
3440            wizard_ui::render_line(output, &i18n.t("wizard.error.invalid_selection"))?;
3441            Ok(false)
3442        }
3443    }
3444}
3445
3446fn run_sign_for_pack<R: BufRead, W: Write>(
3447    input: &mut R,
3448    output: &mut W,
3449    i18n: &WizardI18n,
3450    session: &mut WizardSession,
3451    self_exe: &Path,
3452    pack_dir_path: &Path,
3453) -> Result<bool> {
3454    session.selected_actions.push("pipeline.sign".to_string());
3455    let key_path = ask_text(
3456        input,
3457        output,
3458        i18n,
3459        "pack.wizard.sign_key_path",
3460        "wizard.sign.ask_key_path",
3461        None,
3462        session.sign_key_path.as_deref(),
3463    )?;
3464    let sign_ok = if session.dry_run {
3465        wizard_ui::render_line(output, &i18n.t("wizard.dry_run.skipping_sign"))?;
3466        true
3467    } else {
3468        run_process(
3469            self_exe,
3470            &[
3471                "sign",
3472                "--pack",
3473                &pack_dir_path.display().to_string(),
3474                "--key",
3475                &key_path,
3476            ],
3477            None,
3478        )?
3479    };
3480    if !sign_ok {
3481        wizard_ui::render_line(output, &i18n.t("wizard.error.sign_failed"))?;
3482        return Ok(false);
3483    }
3484    session.sign_key_path = Some(key_path);
3485    Ok(true)
3486}
3487
3488fn ask_failure_nav<R: BufRead, W: Write>(
3489    input: &mut R,
3490    output: &mut W,
3491    i18n: &WizardI18n,
3492) -> Result<SubmenuAction> {
3493    let choice = ask_enum(
3494        input,
3495        output,
3496        i18n,
3497        "pack.wizard.failure_nav",
3498        "wizard.failure_nav.title",
3499        Some("wizard.failure_nav.description"),
3500        &[("0", "wizard.nav.back"), ("M", "wizard.nav.main_menu")],
3501        "0",
3502    )?;
3503    SubmenuAction::from_choice(&choice)
3504}
3505
3506#[allow(clippy::too_many_arguments)]
3507fn ask_enum<R: BufRead, W: Write>(
3508    input: &mut R,
3509    output: &mut W,
3510    i18n: &WizardI18n,
3511    form_id: &str,
3512    title_key: &str,
3513    description_key: Option<&str>,
3514    choices: &[(&str, &str)],
3515    default_on_eof: &str,
3516) -> Result<String> {
3517    let mut question = json!({
3518        "id": "choice",
3519        "type": "enum",
3520        "title": i18n.t(title_key),
3521        "title_i18n": {"key": title_key},
3522        "required": true,
3523        "choices": choices.iter().map(|(v, _)| *v).collect::<Vec<_>>(),
3524    });
3525    if let Some(description_key) = description_key {
3526        question["description"] = Value::String(i18n.t(description_key));
3527        question["description_i18n"] = json!({"key": description_key});
3528    }
3529
3530    let spec = json!({
3531        "id": form_id,
3532        "title": i18n.t(title_key),
3533        "version": "1.0.0",
3534        "description": description_key.map(|key| i18n.t(key)).unwrap_or_default(),
3535        "progress_policy": {
3536            "skip_answered": true,
3537            "autofill_defaults": false,
3538            "treat_default_as_answered": false,
3539        },
3540        "questions": [question],
3541    });
3542    let config = WizardRunConfig {
3543        spec_json: serde_json::to_string(&spec).context("serialize enum QA spec")?,
3544        initial_answers_json: None,
3545        frontend: WizardFrontend::Text,
3546        i18n: i18n.qa_i18n_config(),
3547        verbose: false,
3548    };
3549
3550    let mut driver = WizardDriver::new(config).context("initialize QA enum driver")?;
3551    loop {
3552        let payload_raw = driver
3553            .next_payload_json()
3554            .context("render QA enum payload")?;
3555        let payload: Value = serde_json::from_str(&payload_raw).context("parse QA enum payload")?;
3556        if let Some(text) = payload.get("text").and_then(Value::as_str) {
3557            render_driver_text(output, text)?;
3558        }
3559
3560        if driver.is_complete() {
3561            break;
3562        }
3563
3564        for (value, key) in choices {
3565            wizard_ui::render_line(output, &format!("{value}) {}", i18n.t(key)))?;
3566        }
3567
3568        wizard_ui::render_prompt(output, &i18n.t("wizard.prompt"))?;
3569        let Some(line) = read_trimmed_line(input)? else {
3570            return Ok(default_on_eof.to_string());
3571        };
3572        let candidate = if line.eq_ignore_ascii_case("m") {
3573            "M".to_string()
3574        } else {
3575            line
3576        };
3577        if !choices
3578            .iter()
3579            .map(|(value, _)| *value)
3580            .any(|value| value.eq_ignore_ascii_case(&candidate))
3581        {
3582            wizard_ui::render_line(output, &i18n.t("wizard.error.invalid_selection"))?;
3583            continue;
3584        }
3585
3586        let submit = driver
3587            .submit_patch_json(&json!({"choice": candidate}).to_string())
3588            .context("submit QA enum answer")?;
3589        if submit.status == "error" {
3590            wizard_ui::render_line(output, &i18n.t("wizard.error.invalid_selection"))?;
3591        }
3592    }
3593
3594    let result = driver.finish().context("finish QA enum")?;
3595    result
3596        .answer_set
3597        .answers
3598        .get("choice")
3599        .and_then(Value::as_str)
3600        .map(ToString::to_string)
3601        .ok_or_else(|| anyhow!("missing enum answer"))
3602}
3603
3604#[allow(clippy::too_many_arguments)]
3605fn ask_enum_custom_labels_owned<R: BufRead, W: Write>(
3606    input: &mut R,
3607    output: &mut W,
3608    i18n: &WizardI18n,
3609    form_id: &str,
3610    title_key: &str,
3611    description_key: Option<&str>,
3612    choices: &[(String, String)],
3613    default_on_eof: &str,
3614) -> Result<String> {
3615    let mut question = json!({
3616        "id": "choice",
3617        "type": "enum",
3618        "title": i18n.t(title_key),
3619        "title_i18n": {"key": title_key},
3620        "required": true,
3621        "choices": choices.iter().map(|(v, _)| v).collect::<Vec<_>>(),
3622    });
3623    if let Some(description_key) = description_key {
3624        question["description"] = Value::String(i18n.t(description_key));
3625        question["description_i18n"] = json!({"key": description_key});
3626    }
3627
3628    let spec = json!({
3629        "id": form_id,
3630        "title": i18n.t(title_key),
3631        "version": "1.0.0",
3632        "description": description_key.map(|key| i18n.t(key)).unwrap_or_default(),
3633        "progress_policy": {
3634            "skip_answered": true,
3635            "autofill_defaults": false,
3636            "treat_default_as_answered": false,
3637        },
3638        "questions": [question],
3639    });
3640    let config = WizardRunConfig {
3641        spec_json: serde_json::to_string(&spec).context("serialize custom enum QA spec")?,
3642        initial_answers_json: None,
3643        frontend: WizardFrontend::Text,
3644        i18n: i18n.qa_i18n_config(),
3645        verbose: false,
3646    };
3647
3648    let mut driver = WizardDriver::new(config).context("initialize QA custom enum driver")?;
3649    loop {
3650        let payload_raw = driver
3651            .next_payload_json()
3652            .context("render QA custom enum payload")?;
3653        let payload: Value =
3654            serde_json::from_str(&payload_raw).context("parse QA custom enum payload")?;
3655        if let Some(text) = payload.get("text").and_then(Value::as_str) {
3656            render_driver_text(output, text)?;
3657        }
3658
3659        if driver.is_complete() {
3660            break;
3661        }
3662
3663        for (value, label) in choices {
3664            wizard_ui::render_line(output, &format!("{value}) {label}"))?;
3665        }
3666
3667        wizard_ui::render_prompt(output, &i18n.t("wizard.prompt"))?;
3668        let Some(line) = read_trimmed_line(input)? else {
3669            return Ok(default_on_eof.to_string());
3670        };
3671        let candidate = if line.eq_ignore_ascii_case("m") {
3672            "M".to_string()
3673        } else {
3674            line
3675        };
3676        if !choices
3677            .iter()
3678            .map(|(value, _)| value.as_str())
3679            .any(|value| value.eq_ignore_ascii_case(&candidate))
3680        {
3681            wizard_ui::render_line(output, &i18n.t("wizard.error.invalid_selection"))?;
3682            continue;
3683        }
3684
3685        let submit = driver
3686            .submit_patch_json(&json!({"choice": candidate}).to_string())
3687            .context("submit QA custom enum answer")?;
3688        if submit.status == "error" {
3689            wizard_ui::render_line(output, &i18n.t("wizard.error.invalid_selection"))?;
3690        }
3691    }
3692
3693    let result = driver.finish().context("finish QA custom enum")?;
3694    result
3695        .answer_set
3696        .answers
3697        .get("choice")
3698        .and_then(Value::as_str)
3699        .map(ToString::to_string)
3700        .ok_or_else(|| anyhow!("missing custom enum answer"))
3701}
3702
3703fn ask_text<R: BufRead, W: Write>(
3704    input: &mut R,
3705    output: &mut W,
3706    i18n: &WizardI18n,
3707    form_id: &str,
3708    title_key: &str,
3709    description_key: Option<&str>,
3710    default_value: Option<&str>,
3711) -> Result<String> {
3712    let mut question = json!({
3713        "id": "value",
3714        "type": "string",
3715        "title": i18n.t(title_key),
3716        "title_i18n": {"key": title_key},
3717        "required": true,
3718    });
3719    if let Some(description_key) = description_key {
3720        question["description"] = Value::String(i18n.t(description_key));
3721        question["description_i18n"] = json!({"key": description_key});
3722    }
3723    if let Some(default_value) = default_value {
3724        question["default_value"] = Value::String(default_value.to_string());
3725    }
3726
3727    let spec = json!({
3728        "id": form_id,
3729        "title": i18n.t(title_key),
3730        "version": "1.0.0",
3731        "description": description_key.map(|key| i18n.t(key)).unwrap_or_default(),
3732        "progress_policy": {
3733            "skip_answered": true,
3734            "autofill_defaults": false,
3735            "treat_default_as_answered": false,
3736        },
3737        "questions": [question],
3738    });
3739    let config = WizardRunConfig {
3740        spec_json: serde_json::to_string(&spec).context("serialize text QA spec")?,
3741        initial_answers_json: None,
3742        frontend: WizardFrontend::Text,
3743        i18n: i18n.qa_i18n_config(),
3744        verbose: false,
3745    };
3746
3747    let mut driver = WizardDriver::new(config).context("initialize QA text driver")?;
3748    loop {
3749        let payload_raw = driver
3750            .next_payload_json()
3751            .context("render QA text payload")?;
3752        let payload: Value = serde_json::from_str(&payload_raw).context("parse QA text payload")?;
3753        if let Some(text) = payload.get("text").and_then(Value::as_str) {
3754            render_driver_text(output, text)?;
3755        }
3756
3757        if driver.is_complete() {
3758            break;
3759        }
3760
3761        wizard_ui::render_prompt(output, &i18n.t("wizard.prompt"))?;
3762        let Some(line) = read_trimmed_line(input)? else {
3763            if let Some(default) = default_value {
3764                return Ok(default.to_string());
3765            }
3766            return Err(anyhow!("missing text input"));
3767        };
3768
3769        let answer = if line.trim().is_empty() {
3770            default_value.unwrap_or_default().to_string()
3771        } else {
3772            line
3773        };
3774        let submit = driver
3775            .submit_patch_json(&json!({"value": answer}).to_string())
3776            .context("submit QA text answer")?;
3777        if submit.status == "error" {
3778            wizard_ui::render_line(output, &i18n.t("wizard.error.invalid_selection"))?;
3779        }
3780    }
3781
3782    let result = driver.finish().context("finish QA text")?;
3783    result
3784        .answer_set
3785        .answers
3786        .get("value")
3787        .and_then(Value::as_str)
3788        .map(ToString::to_string)
3789        .ok_or_else(|| anyhow!("missing text answer"))
3790}
3791
3792fn prompt_for_extension_catalog_ref<R: BufRead, W: Write>(
3793    input: &mut R,
3794    output: &mut W,
3795    i18n: &WizardI18n,
3796) -> Result<String> {
3797    loop {
3798        wizard_ui::render_line(output, &i18n.t("wizard.extension_catalog.check_newer"))?;
3799        wizard_ui::render_line(output, &i18n.t("wizard.extension_catalog.check_newer_help"))?;
3800        wizard_ui::render_prompt(output, &i18n.t("wizard.prompt"))?;
3801
3802        let Some(line) = read_trimmed_line(input)? else {
3803            return Ok(DEFAULT_EXTENSION_CATALOG_DOWNLOAD_URL.to_string());
3804        };
3805        let trimmed = line.trim();
3806
3807        if trimmed.is_empty()
3808            || trimmed.eq_ignore_ascii_case("y")
3809            || trimmed.eq_ignore_ascii_case("yes")
3810        {
3811            return ask_text(
3812                input,
3813                output,
3814                i18n,
3815                "pack.wizard.extension_catalog.url",
3816                "wizard.extension_catalog.url",
3817                Some("wizard.extension_catalog.url_help"),
3818                Some(DEFAULT_EXTENSION_CATALOG_DOWNLOAD_URL),
3819            );
3820        }
3821        if trimmed.eq_ignore_ascii_case("n") || trimmed.eq_ignore_ascii_case("no") {
3822            return Ok(DEFAULT_EXTENSION_CATALOG_REF.to_string());
3823        }
3824        if looks_like_catalog_ref(trimmed) {
3825            return Ok(trimmed.to_string());
3826        }
3827
3828        wizard_ui::render_line(output, &i18n.t("wizard.error.invalid_selection"))?;
3829    }
3830}
3831
3832fn looks_like_catalog_ref(value: &str) -> bool {
3833    value.contains("://")
3834}
3835
3836fn ask_existing_pack_dir<R: BufRead, W: Write>(
3837    input: &mut R,
3838    output: &mut W,
3839    i18n: &WizardI18n,
3840    form_id: &str,
3841    title_key: &str,
3842    description_key: Option<&str>,
3843    default_value: Option<&str>,
3844) -> Result<PathBuf> {
3845    loop {
3846        let pack_dir = ask_text(
3847            input,
3848            output,
3849            i18n,
3850            form_id,
3851            title_key,
3852            description_key,
3853            default_value,
3854        )?;
3855        let candidate = PathBuf::from(pack_dir.trim());
3856        if candidate.is_dir() {
3857            return Ok(candidate);
3858        }
3859        wizard_ui::render_line(
3860            output,
3861            &format!(
3862                "{}: {}",
3863                i18n.t("wizard.error.invalid_pack_dir"),
3864                candidate.display()
3865            ),
3866        )?;
3867    }
3868}
3869
3870fn run_process(binary: &Path, args: &[&str], cwd: Option<&Path>) -> Result<bool> {
3871    let mut cmd = Command::new(binary);
3872    cmd.args(args)
3873        .stdin(Stdio::inherit())
3874        .stdout(Stdio::inherit())
3875        .stderr(Stdio::inherit());
3876    if let Some(cwd) = cwd {
3877        cmd.current_dir(cwd);
3878    }
3879    let status = cmd
3880        .status()
3881        .with_context(|| format!("spawn {}", binary.display()))?;
3882    Ok(status.success())
3883}
3884
3885fn run_delegate(binary: &str, args: &[&str], cwd: &Path) -> bool {
3886    let resolved = crate::external_tools::resolve(binary).unwrap_or_else(|| PathBuf::from(binary));
3887    run_process(&resolved, args, Some(cwd)).unwrap_or(false)
3888}
3889
3890fn run_delegate_owned(binary: &str, args: &[String], cwd: &Path) -> bool {
3891    let argv = args.iter().map(String::as_str).collect::<Vec<_>>();
3892    run_delegate(binary, &argv, cwd)
3893}
3894
3895fn capture_delegate_json(binary: &str, args: &[String], cwd: &Path) -> Result<Value> {
3896    let resolved = crate::external_tools::resolve(binary).unwrap_or_else(|| PathBuf::from(binary));
3897    let output = Command::new(&resolved)
3898        .args(args)
3899        .current_dir(cwd)
3900        .stdin(Stdio::null())
3901        .stdout(Stdio::piped())
3902        .stderr(Stdio::piped())
3903        .output()
3904        .with_context(|| format!("spawn {}", resolved.display()))?;
3905    if !output.status.success() {
3906        let stderr = String::from_utf8_lossy(&output.stderr);
3907        return Err(anyhow!("{} failed: {}", resolved.display(), stderr.trim()));
3908    }
3909    serde_json::from_slice(&output.stdout)
3910        .with_context(|| format!("parse json emitted by {}", resolved.display()))
3911}
3912
3913fn temp_answers_path(prefix: &str) -> PathBuf {
3914    let stamp = SystemTime::now()
3915        .duration_since(UNIX_EPOCH)
3916        .map(|d| d.as_nanos())
3917        .unwrap_or(0);
3918    std::env::temp_dir().join(format!("{prefix}-{}-{stamp}.json", std::process::id()))
3919}
3920
3921fn read_json_value(path: &Path) -> Option<Value> {
3922    let bytes = fs::read(path).ok()?;
3923    serde_json::from_slice::<Value>(&bytes).ok()
3924}
3925
3926fn write_json_value(path: &Path, value: &Value) -> bool {
3927    serde_json::to_vec_pretty(value)
3928        .ok()
3929        .and_then(|bytes| fs::write(path, bytes).ok())
3930        .is_some()
3931}
3932
3933fn flow_delegate_args(_pack_dir: &Path) -> Vec<String> {
3934    vec!["wizard".to_string(), ".".to_string()]
3935}
3936
3937fn run_flow_delegate_for_session(session: &mut WizardSession, pack_dir: &Path) -> bool {
3938    if !session.dry_run {
3939        let args = flow_delegate_args(pack_dir);
3940        return run_delegate_owned("greentic-flow", &args, pack_dir);
3941    }
3942    let answers_path = temp_answers_path("greentic-flow-wizard-answers");
3943    let mut args = flow_delegate_args(pack_dir);
3944    args.push("--emit-answers".to_string());
3945    args.push(answers_path.display().to_string());
3946    let ok = run_delegate_owned("greentic-flow", &args, pack_dir);
3947    if ok {
3948        session.flow_wizard_answers = read_json_value(&answers_path);
3949    }
3950    let _ = fs::remove_file(&answers_path);
3951    ok
3952}
3953
3954fn run_component_delegate_for_session(session: &mut WizardSession, pack_dir: &Path) -> bool {
3955    if !session.dry_run {
3956        return run_delegate("greentic-component", &["wizard"], pack_dir);
3957    }
3958    let answers_path = temp_answers_path("greentic-component-wizard-answers");
3959    let args = vec![
3960        "wizard".to_string(),
3961        "--project-root".to_string(),
3962        ".".to_string(),
3963        "--execution".to_string(),
3964        "dry-run".to_string(),
3965        "--qa-answers-out".to_string(),
3966        answers_path.display().to_string(),
3967    ];
3968    let ok = run_delegate_owned("greentic-component", &args, pack_dir);
3969    if ok {
3970        session.component_wizard_answers = read_json_value(&answers_path);
3971    }
3972    let _ = fs::remove_file(&answers_path);
3973    ok
3974}
3975
3976fn run_flow_delegate_replay(pack_dir: &Path, answers: Option<&Value>) -> bool {
3977    if let Some(answers) = answers {
3978        let answers_path = temp_answers_path("greentic-flow-wizard-replay");
3979        if !write_json_value(&answers_path, answers) {
3980            return false;
3981        }
3982        let mut args = flow_delegate_args(pack_dir);
3983        args.push("--answers".to_string());
3984        args.push(answers_path.display().to_string());
3985        let ok = run_delegate_owned("greentic-flow", &args, pack_dir);
3986        let _ = fs::remove_file(&answers_path);
3987        return ok;
3988    }
3989    let args = flow_delegate_args(pack_dir);
3990    run_delegate_owned("greentic-flow", &args, pack_dir)
3991}
3992
3993fn run_component_delegate_replay(pack_dir: &Path, answers: Option<&Value>) -> bool {
3994    if let Some(answers) = answers {
3995        let answers_path = temp_answers_path("greentic-component-wizard-replay");
3996        if !write_json_value(&answers_path, answers) {
3997            return false;
3998        }
3999        let args = vec![
4000            "wizard".to_string(),
4001            "--project-root".to_string(),
4002            ".".to_string(),
4003            "--execution".to_string(),
4004            "execute".to_string(),
4005            "--qa-answers".to_string(),
4006            answers_path.display().to_string(),
4007        ];
4008        let ok = run_delegate_owned("greentic-component", &args, pack_dir);
4009        let _ = fs::remove_file(&answers_path);
4010        return ok;
4011    }
4012    run_delegate("greentic-component", &["wizard"], pack_dir)
4013}
4014
4015fn handle_delegate_failure<R: BufRead, W: Write>(
4016    input: &mut R,
4017    output: &mut W,
4018    i18n: &WizardI18n,
4019    session: &WizardSession,
4020    error_key: &str,
4021) -> Result<bool> {
4022    if session.dry_run {
4023        wizard_ui::render_line(output, &i18n.t("wizard.dry_run.child_wizard_returned"))?;
4024        return Ok(false);
4025    }
4026    wizard_ui::render_line(output, &i18n.t(error_key))?;
4027    if matches!(
4028        ask_failure_nav(input, output, i18n)?,
4029        SubmenuAction::MainMenu
4030    ) {
4031        return Ok(true);
4032    }
4033    Ok(false)
4034}
4035
4036fn wizard_self_exe() -> Result<PathBuf> {
4037    if let Ok(path) = env::var("GREENTIC_PACK_WIZARD_SELF_EXE") {
4038        let candidate = PathBuf::from(path);
4039        if candidate.exists() {
4040            return Ok(candidate);
4041        }
4042        return Err(anyhow!(
4043            "GREENTIC_PACK_WIZARD_SELF_EXE does not exist: {}",
4044            candidate.display()
4045        ));
4046    }
4047    std::env::current_exe().context("resolve current executable")
4048}
4049
4050fn read_trimmed_line<R: BufRead>(input: &mut R) -> Result<Option<String>> {
4051    let mut line = String::new();
4052    let read = input.read_line(&mut line)?;
4053    if read == 0 {
4054        return Ok(None);
4055    }
4056    Ok(Some(line.trim().to_string()))
4057}
4058
4059fn render_driver_text<W: Write>(output: &mut W, text: &str) -> Result<()> {
4060    let filtered = filter_driver_boilerplate(text);
4061    if filtered.trim().is_empty() {
4062        return Ok(());
4063    }
4064    wizard_ui::render_text(output, &filtered)?;
4065    if !filtered.ends_with('\n') {
4066        wizard_ui::render_text(output, "\n")?;
4067    }
4068    Ok(())
4069}
4070
4071fn filter_driver_boilerplate(text: &str) -> String {
4072    let mut kept = Vec::new();
4073    let mut skipping_visible_block = false;
4074    for line in text.lines() {
4075        let trimmed = line.trim_start();
4076        if let Some(title) = trimmed.strip_prefix("Title:") {
4077            let title = title.trim();
4078            if !title.is_empty() {
4079                kept.push(title);
4080            }
4081            continue;
4082        }
4083        if trimmed.starts_with("Description:") || trimmed.starts_with("Required:") {
4084            continue;
4085        }
4086        if trimmed == "All visible questions are answered." {
4087            continue;
4088        }
4089        if trimmed.starts_with("Form:")
4090            || trimmed.starts_with("Status:")
4091            || trimmed.starts_with("Help:")
4092            || trimmed.starts_with("Next question:")
4093        {
4094            skipping_visible_block = false;
4095            continue;
4096        }
4097        if trimmed.starts_with("Visible questions:") {
4098            skipping_visible_block = true;
4099            continue;
4100        }
4101        if skipping_visible_block {
4102            if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
4103                continue;
4104            }
4105            if trimmed.is_empty() {
4106                continue;
4107            }
4108            skipping_visible_block = false;
4109        }
4110        kept.push(line);
4111    }
4112    let joined = kept.join("\n");
4113    joined.trim_matches('\n').to_string()
4114}
4115
4116impl SubmenuAction {
4117    fn from_choice(choice: &str) -> Result<Self> {
4118        if choice == "0" {
4119            return Ok(Self::Back);
4120        }
4121        if choice.eq_ignore_ascii_case("m") {
4122            return Ok(Self::MainMenu);
4123        }
4124        Err(anyhow!("invalid submenu selection `{choice}`"))
4125    }
4126}
4127
4128impl MainChoice {
4129    fn from_choice(choice: &str) -> Result<Self> {
4130        match choice {
4131            "1" => Ok(Self::CreateApplicationPack),
4132            "2" => Ok(Self::UpdateApplicationPack),
4133            "3" => Ok(Self::CreateExtensionPack),
4134            "4" => Ok(Self::UpdateExtensionPack),
4135            "5" => Ok(Self::AddExtension),
4136            "0" => Ok(Self::Exit),
4137            _ => Err(anyhow!("invalid main selection `{choice}`")),
4138        }
4139    }
4140}