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