Skip to main content

packc/cli/
wizard.rs

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