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