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