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