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