1mod confirm;
2mod executor;
3mod persistence;
4pub mod plan;
5mod provider;
6mod registry;
7
8use std::collections::BTreeMap;
9use std::fs;
10use std::io::{self, IsTerminal, Write};
11use std::path::{Path, PathBuf};
12use std::process::{Command, Stdio};
13
14use anyhow::{Context, Result, bail};
15use serde::{Deserialize, Serialize};
16
17use crate::cli::{WizardApplyArgs, WizardLaunchArgs, WizardValidateArgs};
18use crate::i18n;
19use crate::passthrough::resolve_binary;
20use crate::wizard::executor::ExecuteOptions;
21use crate::wizard::plan::{WizardAnswers, WizardFrontend, WizardPlan};
22use crate::wizard::provider::{ProviderRequest, ShellWizardProvider, WizardProvider};
23
24const DEFAULT_LOCALE: &str = "en-US";
25const DEFAULT_SCHEMA_VERSION: &str = "1.0.0";
26const WIZARD_ID: &str = "greentic-dev.wizard.launcher.main";
27const SCHEMA_ID: &str = "greentic-dev.launcher.main";
28const EMBEDDED_WIZARD_ROOT_ZERO_ACTION_ENV: &str = "GREENTIC_WIZARD_ROOT_ZERO_ACTION";
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31enum ExecutionMode {
32 DryRun,
33 Execute,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37enum LauncherMenuChoice {
38 Pack,
39 Bundle,
40 MainMenu,
41 Exit,
42}
43
44#[derive(Debug, Clone)]
45struct LoadedAnswers {
46 answers: serde_json::Value,
47 inferred_locale: Option<String>,
48 schema_version: Option<String>,
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
52struct AnswerDocument {
53 wizard_id: String,
54 schema_id: String,
55 schema_version: String,
56 locale: String,
57 answers: serde_json::Value,
58 #[serde(default)]
59 locks: BTreeMap<String, String>,
60}
61
62pub fn launch(args: WizardLaunchArgs) -> Result<()> {
63 let mode = if args.dry_run {
64 ExecutionMode::DryRun
65 } else {
66 ExecutionMode::Execute
67 };
68
69 if let Some(answers_path) = args.answers.as_deref() {
70 let loaded =
71 load_answer_document(answers_path, args.schema_version.as_deref(), args.migrate)?;
72
73 return run_from_inputs(
74 args.frontend,
75 args.locale,
76 loaded,
77 args.out,
78 mode,
79 args.yes,
80 args.non_interactive,
81 args.unsafe_commands,
82 args.allow_destructive,
83 args.emit_answers,
84 args.schema_version,
85 );
86 }
87
88 let locale = i18n::select_locale(args.locale.as_deref());
89 if mode == ExecutionMode::DryRun {
90 let Some(answers) = prompt_launcher_answers(mode, &locale)? else {
91 return Ok(());
92 };
93 let loaded = LoadedAnswers {
94 answers,
95 inferred_locale: None,
96 schema_version: args.schema_version.clone(),
97 };
98
99 return run_from_inputs(
100 args.frontend,
101 Some(locale),
102 loaded,
103 args.out,
104 mode,
105 args.yes,
106 args.non_interactive,
107 args.unsafe_commands,
108 args.allow_destructive,
109 args.emit_answers,
110 args.schema_version,
111 );
112 }
113
114 loop {
115 let Some(answers) = prompt_launcher_answers(mode, &locale)? else {
116 return Ok(());
117 };
118
119 run_interactive_delegate(&answers, &locale)?;
120 }
121}
122
123fn run_interactive_delegate(answers: &serde_json::Value, locale: &str) -> Result<()> {
124 let selected_action = answers
125 .get("selected_action")
126 .and_then(|value| value.as_str())
127 .ok_or_else(|| anyhow::anyhow!("missing required answers.selected_action"))?;
128
129 let program = match selected_action {
130 "pack" => "greentic-pack",
131 "bundle" => "greentic-bundle",
132 other => bail!("unsupported selected_action `{other}`; expected `pack` or `bundle`"),
133 };
134
135 let bin = resolve_binary(program)?;
136 let mut command = Command::new(&bin);
137 command
138 .args(interactive_delegate_args(program, locale))
139 .env("LANG", locale)
140 .env("LC_ALL", locale)
141 .env("LC_MESSAGES", locale)
142 .stdin(Stdio::inherit())
143 .stdout(Stdio::inherit())
144 .stderr(Stdio::inherit());
145 if program == "greentic-bundle" {
146 command.env(EMBEDDED_WIZARD_ROOT_ZERO_ACTION_ENV, "back");
147 }
148 let status = command
149 .status()
150 .map_err(|e| anyhow::anyhow!("failed to execute {}: {e}", bin.display()))?;
151 if status.success() {
152 Ok(())
153 } else {
154 bail!(
155 "wizard step command failed: {} {:?} (exit code {:?})",
156 program,
157 ["wizard"],
158 status.code()
159 );
160 }
161}
162
163fn interactive_delegate_args(program: &str, locale: &str) -> Vec<String> {
164 if program == "greentic-bundle" {
165 vec![
166 "--locale".to_string(),
167 locale.to_string(),
168 "wizard".to_string(),
169 ]
170 } else {
171 vec!["wizard".to_string()]
172 }
173}
174
175pub fn validate(args: WizardValidateArgs) -> Result<()> {
176 let loaded = load_answer_document(
177 args.answers.as_path(),
178 args.schema_version.as_deref(),
179 args.migrate,
180 )?;
181
182 run_from_inputs(
183 args.frontend,
184 args.locale,
185 loaded,
186 args.out,
187 ExecutionMode::DryRun,
188 true,
189 true,
190 false,
191 false,
192 args.emit_answers,
193 args.schema_version,
194 )
195}
196
197pub fn apply(args: WizardApplyArgs) -> Result<()> {
198 let loaded = load_answer_document(
199 args.answers.as_path(),
200 args.schema_version.as_deref(),
201 args.migrate,
202 )?;
203
204 run_from_inputs(
205 args.frontend,
206 args.locale,
207 loaded,
208 args.out,
209 ExecutionMode::Execute,
210 args.yes,
211 args.non_interactive,
212 args.unsafe_commands,
213 args.allow_destructive,
214 args.emit_answers,
215 args.schema_version,
216 )
217}
218
219#[allow(clippy::too_many_arguments)]
220fn run_from_inputs(
221 frontend_raw: String,
222 cli_locale: Option<String>,
223 loaded: LoadedAnswers,
224 out: Option<PathBuf>,
225 mode: ExecutionMode,
226 yes: bool,
227 non_interactive: bool,
228 unsafe_commands: bool,
229 allow_destructive: bool,
230 emit_answers: Option<PathBuf>,
231 requested_schema_version: Option<String>,
232) -> Result<()> {
233 let locale = i18n::select_locale(
234 cli_locale
235 .as_deref()
236 .or(loaded.inferred_locale.as_deref())
237 .or(Some(DEFAULT_LOCALE)),
238 );
239 let frontend = WizardFrontend::parse(&frontend_raw).ok_or_else(|| {
240 anyhow::anyhow!(
241 "unsupported frontend `{}`; expected text|json|adaptive-card",
242 frontend_raw
243 )
244 })?;
245
246 if registry::resolve("launcher", "main").is_none() {
247 bail!("launcher mapping missing for `launcher.main`");
248 }
249
250 let merged_answers = merge_answers(None, None, Some(loaded.answers.clone()), None);
251 let delegated_answers_path = persist_delegated_answers_if_present(
252 &paths_for_provider(out.as_deref())?,
253 &merged_answers,
254 )?;
255 let provider = ShellWizardProvider;
256 let req = ProviderRequest {
257 frontend: frontend.clone(),
258 locale: locale.clone(),
259 dry_run: mode == ExecutionMode::DryRun,
260 answers: merged_answers.clone(),
261 delegated_answers_path,
262 };
263 let mut plan = provider.build_plan(&req)?;
264
265 let out_dir = persistence::resolve_out_dir(out.as_deref());
266 let paths = persistence::prepare_dir(&out_dir)?;
267 persistence::persist_plan_and_answers(&paths, &merged_answers, &plan)?;
268
269 render_plan(&plan)?;
270
271 if mode == ExecutionMode::Execute {
272 confirm::ensure_execute_allowed(
273 &crate::i18n::tf(
274 &locale,
275 "runtime.wizard.confirm.summary",
276 &[
277 ("target", plan.metadata.target.clone()),
278 ("mode", plan.metadata.mode.clone()),
279 ("step_count", plan.steps.len().to_string()),
280 ],
281 ),
282 yes,
283 non_interactive,
284 &locale,
285 )?;
286 let report = executor::execute(
287 &plan,
288 &paths.exec_log_path,
289 &ExecuteOptions {
290 unsafe_commands,
291 allow_destructive,
292 locale: locale.clone(),
293 },
294 )?;
295 annotate_execution_metadata(&mut plan, &report);
296 persistence::persist_plan_and_answers(&paths, &merged_answers, &plan)?;
297 }
298
299 if let Some(path) = emit_answers {
300 let schema_version = requested_schema_version
301 .or(loaded.schema_version)
302 .unwrap_or_else(|| DEFAULT_SCHEMA_VERSION.to_string());
303 let doc = build_answer_document(&locale, &schema_version, &merged_answers, &plan);
304 write_answer_document(&path, &doc)?;
305 }
306
307 Ok(())
308}
309
310fn paths_for_provider(out: Option<&Path>) -> Result<persistence::PersistedPaths> {
311 let out_dir = persistence::resolve_out_dir(out);
312 persistence::prepare_dir(&out_dir)
313}
314
315fn persist_delegated_answers_if_present(
316 paths: &persistence::PersistedPaths,
317 answers: &WizardAnswers,
318) -> Result<Option<PathBuf>> {
319 let Some(delegated_answers) = answers.data.get("delegate_answer_document") else {
320 return Ok(None);
321 };
322 if !delegated_answers.is_object() {
323 bail!("answers.delegate_answer_document must be a JSON object");
324 }
325 persistence::persist_delegated_answers(&paths.delegated_answers_path, delegated_answers)?;
326 Ok(Some(paths.delegated_answers_path.clone()))
327}
328
329fn render_plan(plan: &WizardPlan) -> Result<()> {
330 let rendered = match plan.metadata.frontend {
331 WizardFrontend::Json => {
332 serde_json::to_string_pretty(plan).context("failed to encode wizard plan")?
333 }
334 WizardFrontend::Text => render_text_plan(plan),
335 WizardFrontend::AdaptiveCard => {
336 let card = serde_json::json!({
337 "type": "AdaptiveCard",
338 "version": "1.5",
339 "body": [
340 {"type":"TextBlock","weight":"Bolder","text":"greentic-dev launcher wizard plan"},
341 {"type":"TextBlock","text": "target: launcher mode: main"},
342 ],
343 "data": { "plan": plan }
344 });
345 serde_json::to_string_pretty(&card).context("failed to encode adaptive card")?
346 }
347 };
348 println!("{rendered}");
349 Ok(())
350}
351
352fn render_text_plan(plan: &WizardPlan) -> String {
353 let mut out = String::new();
354 out.push_str(&format!(
355 "wizard plan v{}: {}.{}\n",
356 plan.plan_version, plan.metadata.target, plan.metadata.mode
357 ));
358 out.push_str(&format!("locale: {}\n", plan.metadata.locale));
359 out.push_str(&format!("steps: {}\n", plan.steps.len()));
360 for (idx, step) in plan.steps.iter().enumerate() {
361 match step {
362 crate::wizard::plan::WizardStep::RunCommand(cmd) => {
363 out.push_str(&format!(
364 "{}. RunCommand {} {}\n",
365 idx + 1,
366 cmd.program,
367 cmd.args.join(" ")
368 ));
369 }
370 other => out.push_str(&format!("{}. {:?}\n", idx + 1, other)),
371 }
372 }
373 out
374}
375
376fn prompt_launcher_answers(mode: ExecutionMode, locale: &str) -> Result<Option<serde_json::Value>> {
377 let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
378 if !interactive {
379 bail!(
380 "{}",
381 i18n::t(locale, "cli.wizard.error.interactive_required")
382 );
383 }
384
385 loop {
386 eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.title"));
387 eprintln!();
388 eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.option_pack"));
389 eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.option_bundle"));
390 eprintln!("{}", i18n::t(locale, "cli.wizard.launcher.option_exit"));
391 eprintln!();
392 eprint!("{}", i18n::t(locale, "cli.wizard.launcher.select_option"));
393 io::stderr().flush()?;
394
395 let mut input = String::new();
396 io::stdin().read_line(&mut input)?;
397 match parse_launcher_menu_choice(input.trim(), true, locale)? {
398 LauncherMenuChoice::Pack => return Ok(Some(build_launcher_answers(mode, "pack"))),
399 LauncherMenuChoice::Bundle => return Ok(Some(build_launcher_answers(mode, "bundle"))),
400 LauncherMenuChoice::MainMenu => {
401 eprintln!();
402 continue;
403 }
404 LauncherMenuChoice::Exit => return Ok(None),
405 }
406 }
407}
408
409fn parse_launcher_menu_choice(
410 input: &str,
411 in_main_menu: bool,
412 locale: &str,
413) -> Result<LauncherMenuChoice> {
414 match input.trim() {
415 "1" if in_main_menu => Ok(LauncherMenuChoice::Pack),
416 "2" if in_main_menu => Ok(LauncherMenuChoice::Bundle),
417 "0" if in_main_menu => Ok(LauncherMenuChoice::Exit),
418 "0" => Ok(LauncherMenuChoice::MainMenu),
419 "m" | "M" => Ok(LauncherMenuChoice::MainMenu),
420 _ => bail!("{}", i18n::t(locale, "cli.wizard.error.invalid_selection")),
421 }
422}
423
424fn build_launcher_answers(mode: ExecutionMode, selected_action: &str) -> serde_json::Value {
425 let mut answers = serde_json::Map::new();
426 answers.insert(
427 "selected_action".to_string(),
428 serde_json::Value::String(selected_action.to_string()),
429 );
430 if mode == ExecutionMode::DryRun {
431 answers.insert(
432 "delegate_answer_document".to_string(),
433 serde_json::Value::Object(Default::default()),
434 );
435 }
436 serde_json::Value::Object(answers)
437}
438
439fn load_answer_document(
440 path: &Path,
441 requested_schema_version: Option<&str>,
442 migrate: bool,
443) -> Result<LoadedAnswers> {
444 let raw =
445 fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
446 let value: serde_json::Value = serde_json::from_str(&raw)
447 .with_context(|| format!("failed to parse {}", path.display()))?;
448
449 let mut doc: AnswerDocument = serde_json::from_value(value)
450 .with_context(|| format!("failed to parse AnswerDocument from {}", path.display()))?;
451 validate_answer_document_identity(&doc, path)?;
452
453 if let Some(schema_version) = requested_schema_version
454 && doc.schema_version != schema_version
455 {
456 if migrate {
457 doc = migrate_answer_document(doc, schema_version);
458 } else {
459 bail!(
460 "answers schema_version `{}` does not match requested `{}`; re-run with --migrate",
461 doc.schema_version,
462 schema_version
463 );
464 }
465 }
466
467 if !doc.answers.is_object() {
468 bail!(
469 "AnswerDocument `answers` must be a JSON object in {}",
470 path.display()
471 );
472 }
473
474 Ok(LoadedAnswers {
475 answers: doc.answers.clone(),
476 inferred_locale: Some(doc.locale),
477 schema_version: Some(doc.schema_version),
478 })
479}
480
481fn validate_answer_document_identity(doc: &AnswerDocument, path: &Path) -> Result<()> {
482 if doc.wizard_id != WIZARD_ID {
483 bail!(
484 "unsupported wizard_id `{}` in {}; expected `{}`",
485 doc.wizard_id,
486 path.display(),
487 WIZARD_ID
488 );
489 }
490 if doc.schema_id != SCHEMA_ID {
491 bail!(
492 "unsupported schema_id `{}` in {}; expected `{}`",
493 doc.schema_id,
494 path.display(),
495 SCHEMA_ID
496 );
497 }
498 Ok(())
499}
500
501fn merge_answers(
502 cli_overrides: Option<serde_json::Value>,
503 parent_prefill: Option<serde_json::Value>,
504 answers_file: Option<serde_json::Value>,
505 provider_defaults: Option<serde_json::Value>,
506) -> WizardAnswers {
507 let mut out = BTreeMap::<String, serde_json::Value>::new();
508 merge_obj(&mut out, provider_defaults);
509 merge_obj(&mut out, answers_file);
510 merge_obj(&mut out, parent_prefill);
511 merge_obj(&mut out, cli_overrides);
512 WizardAnswers {
513 data: serde_json::Value::Object(out.into_iter().collect()),
514 }
515}
516
517fn merge_obj(dst: &mut BTreeMap<String, serde_json::Value>, src: Option<serde_json::Value>) {
518 if let Some(serde_json::Value::Object(map)) = src {
519 for (k, v) in map {
520 dst.insert(k, v);
521 }
522 }
523}
524
525fn migrate_answer_document(mut doc: AnswerDocument, target_schema_version: &str) -> AnswerDocument {
526 doc.schema_version = target_schema_version.to_string();
527 doc
528}
529
530fn build_answer_document(
531 locale: &str,
532 schema_version: &str,
533 answers: &WizardAnswers,
534 plan: &WizardPlan,
535) -> AnswerDocument {
536 AnswerDocument {
537 wizard_id: WIZARD_ID.to_string(),
538 schema_id: SCHEMA_ID.to_string(),
539 schema_version: schema_version.to_string(),
540 locale: locale.to_string(),
541 answers: answers.data.clone(),
542 locks: plan.inputs.clone(),
543 }
544}
545
546fn write_answer_document(path: &Path, doc: &AnswerDocument) -> Result<()> {
547 let rendered = serde_json::to_string_pretty(doc).context("render answers envelope JSON")?;
548 fs::write(path, rendered).with_context(|| format!("failed to write {}", path.display()))
549}
550
551fn annotate_execution_metadata(
552 plan: &mut WizardPlan,
553 report: &crate::wizard::executor::ExecutionReport,
554) {
555 for (program, version) in &report.resolved_versions {
556 plan.inputs
557 .insert(format!("resolved_versions.{program}"), version.clone());
558 }
559 plan.inputs.insert(
560 "executed_commands".to_string(),
561 report.commands_executed.to_string(),
562 );
563}
564
565#[cfg(test)]
566mod tests {
567 use std::collections::BTreeMap;
568 use std::path::Path;
569
570 use serde_json::json;
571
572 use super::{
573 AnswerDocument, LauncherMenuChoice, SCHEMA_ID, WIZARD_ID, build_answer_document,
574 build_launcher_answers, interactive_delegate_args, merge_answers,
575 parse_launcher_menu_choice, validate_answer_document_identity,
576 };
577 use crate::wizard::plan::{WizardFrontend, WizardPlan, WizardPlanMetadata};
578
579 #[test]
580 fn answer_precedence_cli_over_file() {
581 let merged = merge_answers(
582 Some(json!({"foo":"cli"})),
583 None,
584 Some(json!({"foo":"file","bar":"file"})),
585 None,
586 );
587 assert_eq!(merged.data["foo"], "cli");
588 assert_eq!(merged.data["bar"], "file");
589 }
590
591 #[test]
592 fn build_answer_document_sets_launcher_identity_fields() {
593 let answers = merge_answers(None, None, Some(json!({"selected_action":"pack"})), None);
594 let plan = WizardPlan {
595 plan_version: 1,
596 created_at: None,
597 metadata: WizardPlanMetadata {
598 target: "launcher".to_string(),
599 mode: "main".to_string(),
600 locale: "en-US".to_string(),
601 frontend: WizardFrontend::Json,
602 },
603 inputs: BTreeMap::from([(
604 "resolved_versions.greentic-pack".to_string(),
605 "greentic-pack 0.1".to_string(),
606 )]),
607 steps: vec![],
608 };
609
610 let doc = build_answer_document("en-US", "1.0.0", &answers, &plan);
611
612 assert_eq!(doc.wizard_id, WIZARD_ID);
613 assert_eq!(doc.schema_id, SCHEMA_ID);
614 assert_eq!(doc.schema_version, "1.0.0");
615 assert_eq!(doc.locale, "en-US");
616 assert_eq!(doc.answers["selected_action"], "pack");
617 assert_eq!(
618 doc.locks.get("resolved_versions.greentic-pack"),
619 Some(&"greentic-pack 0.1".to_string())
620 );
621 }
622
623 #[test]
624 fn reject_non_launcher_answer_document_id() {
625 let doc = AnswerDocument {
626 wizard_id: "greentic-dev.wizard.pack.build".to_string(),
627 schema_id: SCHEMA_ID.to_string(),
628 schema_version: "1.0.0".to_string(),
629 locale: "en-US".to_string(),
630 answers: json!({}),
631 locks: BTreeMap::new(),
632 };
633 let err = validate_answer_document_identity(&doc, Path::new("answers.json")).unwrap_err();
634 assert!(err.to_string().contains("unsupported wizard_id"));
635 }
636
637 #[test]
638 fn parse_main_menu_navigation_keys() {
639 assert_eq!(
640 parse_launcher_menu_choice("1", true, "en-US").expect("parse"),
641 LauncherMenuChoice::Pack
642 );
643 assert_eq!(
644 parse_launcher_menu_choice("2", true, "en-US").expect("parse"),
645 LauncherMenuChoice::Bundle
646 );
647 assert_eq!(
648 parse_launcher_menu_choice("0", true, "en-US").expect("parse"),
649 LauncherMenuChoice::Exit
650 );
651 assert_eq!(
652 parse_launcher_menu_choice("M", true, "en-US").expect("parse"),
653 LauncherMenuChoice::MainMenu
654 );
655 }
656
657 #[test]
658 fn parse_nested_menu_zero_returns_to_main_menu() {
659 assert_eq!(
660 parse_launcher_menu_choice("0", false, "en-US").expect("parse"),
661 LauncherMenuChoice::MainMenu
662 );
663 }
664
665 #[test]
666 fn build_launcher_answers_includes_selected_action() {
667 let answers = build_launcher_answers(super::ExecutionMode::DryRun, "bundle");
668 assert_eq!(answers["selected_action"], "bundle");
669 assert!(answers.get("delegate_answer_document").is_some());
670 }
671
672 #[test]
673 fn bundle_delegate_receives_locale_flag() {
674 assert_eq!(
675 interactive_delegate_args("greentic-bundle", "en-GB"),
676 vec!["--locale", "en-GB", "wizard"]
677 );
678 }
679
680 #[test]
681 fn pack_delegate_keeps_plain_wizard_args() {
682 assert_eq!(
683 interactive_delegate_args("greentic-pack", "en-GB"),
684 vec!["wizard"]
685 );
686 }
687}