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