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