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