1use std::collections::BTreeMap;
2use std::fs;
3use std::io::IsTerminal;
4use std::io::{self, BufRead, Write};
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result, bail};
8use greentic_qa_lib::{I18nConfig, WizardDriver, WizardFrontend, WizardRunConfig};
9use semver::Version;
10use serde::Serialize;
11use serde_json::{Map, Value, json};
12
13use crate::answers::{AnswerDocument, migrate::migrate_document};
14use crate::cli::wizard::{WizardApplyArgs, WizardMode, WizardRunArgs, WizardValidateArgs};
15
16pub mod i18n;
17
18pub const WIZARD_ID: &str = "greentic-bundle.wizard.run";
19pub const ANSWER_SCHEMA_ID: &str = "greentic-bundle.wizard.answers";
20pub const DEFAULT_PROVIDER_REGISTRY: &str =
21 "oci://ghcr.io/greenticai/greentic-bundle/providers:stable";
22const DEPLOYER_AWS_REF: &str = "oci://ghcr.io/greenticai/packs/deployer/greentic.deploy.aws:stable";
23const DEPLOYER_AZURE_REF: &str =
24 "oci://ghcr.io/greenticai/packs/deployer/greentic.deploy.azure:stable";
25const DEPLOYER_GCP_REF: &str = "oci://ghcr.io/greenticai/packs/deployer/greentic.deploy.gcp:stable";
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
28#[serde(rename_all = "snake_case")]
29pub enum ExecutionMode {
30 DryRun,
31 Execute,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct NormalizedRequest {
36 pub mode: WizardMode,
37 pub locale: String,
38 pub bundle_name: String,
39 pub bundle_id: String,
40 pub output_dir: PathBuf,
41 pub local_reference_base_dir: Option<PathBuf>,
42 pub app_pack_entries: Vec<AppPackEntry>,
43 pub access_rules: Vec<AccessRuleInput>,
44 pub extension_provider_entries: Vec<ExtensionProviderEntry>,
45 pub advanced_setup: bool,
46 pub app_packs: Vec<String>,
47 pub extension_providers: Vec<String>,
48 pub remote_catalogs: Vec<String>,
49 pub setup_specs: BTreeMap<String, Value>,
50 pub setup_answers: BTreeMap<String, Value>,
51 pub setup_execution_intent: bool,
52 pub export_intent: bool,
53 pub capabilities: Vec<String>,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
57pub struct AppPackEntry {
58 pub reference: String,
59 pub detected_kind: String,
60 pub pack_id: String,
61 pub display_name: String,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub version: Option<String>,
64 pub mapping: AppPackMappingInput,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
68pub struct AppPackMappingInput {
69 pub scope: String,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub tenant: Option<String>,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub team: Option<String>,
74}
75
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
77pub struct AccessRuleInput {
78 pub rule_path: String,
79 pub policy: String,
80 pub tenant: String,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub team: Option<String>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize, serde::Deserialize)]
86pub struct ExtensionProviderEntry {
87 pub reference: String,
88 pub detected_kind: String,
89 pub provider_id: String,
90 pub display_name: String,
91 #[serde(skip_serializing_if = "Option::is_none")]
92 pub version: Option<String>,
93 #[serde(skip_serializing_if = "Option::is_none")]
94 pub source_catalog: Option<String>,
95 #[serde(skip_serializing_if = "Option::is_none")]
96 pub group: Option<String>,
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100enum ReviewAction {
101 BuildNow,
102 DryRunOnly,
103 SaveAnswersOnly,
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107enum InteractiveChoice {
108 Create,
109 Update,
110 Validate,
111 Doctor,
112 Inspect,
113 Unbundle,
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub enum RootMenuZeroAction {
118 Exit,
119 Back,
120}
121
122#[derive(Debug)]
123struct InteractiveRequest {
124 request: NormalizedRequest,
125 review_action: ReviewAction,
126}
127
128enum InteractiveSelection {
129 Request(Box<InteractiveRequest>),
130 Handled,
131}
132
133enum BundleTarget {
134 Workspace(PathBuf),
135 Artifact(PathBuf),
136}
137
138#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
139pub struct WizardPlanEnvelope {
140 pub metadata: PlanMetadata,
141 pub target_root: String,
142 pub requested_action: String,
143 pub normalized_input_summary: BTreeMap<String, Value>,
144 pub ordered_step_list: Vec<WizardPlanStep>,
145 pub expected_file_writes: Vec<String>,
146 pub warnings: Vec<String>,
147}
148
149#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
150pub struct PlanMetadata {
151 pub wizard_id: String,
152 pub schema_id: String,
153 pub schema_version: String,
154 pub locale: String,
155 pub execution: ExecutionMode,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
159pub struct WizardPlanStep {
160 pub kind: StepKind,
161 pub description: String,
162}
163
164#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
165#[serde(rename_all = "snake_case")]
166pub enum StepKind {
167 EnsureWorkspace,
168 WriteBundleFile,
169 UpdateAccessRules,
170 ResolveRefs,
171 WriteLock,
172 BuildBundle,
173 ExportBundle,
174}
175
176#[derive(Debug)]
177pub struct WizardRunResult {
178 pub plan: WizardPlanEnvelope,
179 pub document: AnswerDocument,
180 pub applied_files: Vec<PathBuf>,
181}
182
183struct LoadedRequest {
184 request: NormalizedRequest,
185 locks: BTreeMap<String, Value>,
186 build_bundle_now: bool,
187}
188
189pub fn run_command(args: WizardRunArgs) -> Result<()> {
190 let locale = crate::i18n::current_locale();
191 let result = if let Some(path) = args.answers.as_ref() {
192 let loaded = load_and_normalize_answers(
193 path,
194 args.mode,
195 args.schema_version.as_deref(),
196 args.migrate,
197 &locale,
198 )?;
199 execute_request(
200 loaded.request,
201 execution_for_run(args.dry_run),
202 loaded.build_bundle_now && !args.dry_run,
203 args.schema_version.as_deref(),
204 args.emit_answers.as_ref(),
205 Some(loaded.locks),
206 )?
207 } else {
208 run_interactive(
209 args.mode,
210 args.emit_answers.as_ref(),
211 args.schema_version.as_deref(),
212 execution_for_run(args.dry_run),
213 )?
214 };
215 print_plan(&result.plan)?;
216 Ok(())
217}
218
219pub fn answer_document_schema(
220 mode: Option<WizardMode>,
221 schema_version: Option<&str>,
222) -> Result<Value> {
223 let schema_version = requested_schema_version(schema_version)?;
224 let selected_mode = mode.map(mode_name);
225 let mode_schema = match selected_mode {
226 Some(mode) => json!({
227 "type": "string",
228 "const": mode,
229 "description": "Wizard mode. When omitted, the CLI can also supply --mode."
230 }),
231 None => json!({
232 "type": "string",
233 "enum": ["create", "update", "doctor"],
234 "description": "Wizard mode. Defaults to create when omitted unless the CLI supplies --mode."
235 }),
236 };
237
238 Ok(json!({
239 "$schema": "https://json-schema.org/draft/2020-12/schema",
240 "$id": "https://greenticai.github.io/greentic-bundle/schemas/wizard.answers.schema.json",
241 "title": "greentic-bundle wizard answers",
242 "type": "object",
243 "additionalProperties": false,
244 "properties": {
245 "wizard_id": {
246 "type": "string",
247 "const": WIZARD_ID
248 },
249 "schema_id": {
250 "type": "string",
251 "const": ANSWER_SCHEMA_ID
252 },
253 "schema_version": {
254 "type": "string",
255 "const": schema_version.to_string()
256 },
257 "locale": {
258 "type": "string",
259 "minLength": 1
260 },
261 "answers": {
262 "type": "object",
263 "additionalProperties": false,
264 "properties": {
265 "mode": mode_schema,
266 "bundle_name": non_empty_string_schema("Human-friendly bundle name."),
267 "bundle_id": non_empty_string_schema("Stable bundle id."),
268 "output_dir": non_empty_string_schema("Workspace output directory."),
269 "advanced_setup": {
270 "type": "boolean"
271 },
272 "app_pack_entries": {
273 "type": "array",
274 "items": app_pack_entry_schema()
275 },
276 "access_rules": {
277 "type": "array",
278 "items": access_rule_schema()
279 },
280 "extension_provider_entries": {
281 "type": "array",
282 "items": extension_provider_entry_schema()
283 },
284 "app_packs": string_array_schema("App-pack references or local paths."),
285 "extension_providers": string_array_schema("Extension provider references or local paths."),
286 "cloud_deployer_target": {
287 "type": "string",
288 "enum": ["aws", "azure", "gcp"]
289 },
290 "remote_catalogs": string_array_schema("Additional remote catalog references."),
291 "setup_specs": {
292 "type": "object",
293 "additionalProperties": true
294 },
295 "setup_answers": {
296 "type": "object",
297 "additionalProperties": true
298 },
299 "setup_execution_intent": {
300 "type": "boolean"
301 },
302 "export_intent": {
303 "type": "boolean"
304 },
305 "capabilities": string_array_schema("Requested bundle capabilities.")
306 },
307 "required": ["bundle_name", "bundle_id"]
308 },
309 "locks": {
310 "type": "object",
311 "additionalProperties": true,
312 "properties": {
313 "execution": {
314 "type": "string",
315 "enum": ["dry_run", "execute"]
316 }
317 }
318 }
319 },
320 "required": ["wizard_id", "schema_id", "schema_version", "locale", "answers"]
321 }))
322}
323
324fn non_empty_string_schema(description: &str) -> Value {
325 json!({
326 "type": "string",
327 "minLength": 1,
328 "description": description
329 })
330}
331
332fn string_array_schema(description: &str) -> Value {
333 json!({
334 "type": "array",
335 "description": description,
336 "items": {
337 "type": "string",
338 "minLength": 1
339 }
340 })
341}
342
343fn app_pack_entry_schema() -> Value {
344 json!({
345 "type": "object",
346 "additionalProperties": false,
347 "properties": {
348 "reference": non_empty_string_schema("Resolved reference or source path."),
349 "detected_kind": non_empty_string_schema("Detected source kind."),
350 "pack_id": non_empty_string_schema("Pack id."),
351 "display_name": non_empty_string_schema("Pack display name."),
352 "version": {
353 "type": ["string", "null"]
354 },
355 "mapping": app_pack_mapping_schema()
356 },
357 "required": ["reference", "detected_kind", "pack_id", "display_name", "mapping"]
358 })
359}
360
361fn app_pack_mapping_schema() -> Value {
362 json!({
363 "type": "object",
364 "additionalProperties": false,
365 "properties": {
366 "scope": {
367 "type": "string",
368 "enum": ["global", "tenant", "tenant_team"]
369 },
370 "tenant": {
371 "type": ["string", "null"]
372 },
373 "team": {
374 "type": ["string", "null"]
375 }
376 },
377 "required": ["scope"]
378 })
379}
380
381fn access_rule_schema() -> Value {
382 json!({
383 "type": "object",
384 "additionalProperties": false,
385 "properties": {
386 "rule_path": non_empty_string_schema("Resolved GMAP rule path."),
387 "policy": {
388 "type": "string",
389 "enum": ["allow", "forbid"]
390 },
391 "tenant": non_empty_string_schema("Tenant id."),
392 "team": {
393 "type": ["string", "null"]
394 }
395 },
396 "required": ["rule_path", "policy", "tenant"]
397 })
398}
399
400fn extension_provider_entry_schema() -> Value {
401 json!({
402 "type": "object",
403 "additionalProperties": false,
404 "properties": {
405 "reference": non_empty_string_schema("Resolved reference or source path."),
406 "detected_kind": non_empty_string_schema("Detected source kind."),
407 "provider_id": non_empty_string_schema("Provider id."),
408 "display_name": non_empty_string_schema("Provider display name."),
409 "version": {
410 "type": ["string", "null"]
411 },
412 "source_catalog": {
413 "type": ["string", "null"]
414 },
415 "group": {
416 "type": ["string", "null"]
417 }
418 },
419 "required": ["reference", "detected_kind", "provider_id", "display_name"]
420 })
421}
422
423pub fn validate_command(args: WizardValidateArgs) -> Result<()> {
424 let locale = crate::i18n::current_locale();
425 let loaded = load_and_normalize_answers(
426 &args.answers,
427 args.mode,
428 args.schema_version.as_deref(),
429 args.migrate,
430 &locale,
431 )?;
432 let result = execute_request(
433 loaded.request,
434 ExecutionMode::DryRun,
435 false,
436 args.schema_version.as_deref(),
437 args.emit_answers.as_ref(),
438 Some(loaded.locks),
439 )?;
440 print_plan(&result.plan)?;
441 Ok(())
442}
443
444pub fn apply_command(args: WizardApplyArgs) -> Result<()> {
445 let locale = crate::i18n::current_locale();
446 let loaded = load_and_normalize_answers(
447 &args.answers,
448 args.mode,
449 args.schema_version.as_deref(),
450 args.migrate,
451 &locale,
452 )?;
453 let execution = if args.dry_run {
454 ExecutionMode::DryRun
455 } else {
456 ExecutionMode::Execute
457 };
458 let result = execute_request(
459 loaded.request,
460 execution,
461 loaded.build_bundle_now && execution == ExecutionMode::Execute,
462 args.schema_version.as_deref(),
463 args.emit_answers.as_ref(),
464 Some(loaded.locks),
465 )?;
466 print_plan(&result.plan)?;
467 Ok(())
468}
469
470pub fn run_interactive(
471 initial_mode: Option<WizardMode>,
472 emit_answers: Option<&PathBuf>,
473 schema_version: Option<&str>,
474 execution: ExecutionMode,
475) -> Result<WizardRunResult> {
476 match run_interactive_with_zero_action(
477 initial_mode,
478 emit_answers,
479 schema_version,
480 execution,
481 RootMenuZeroAction::Exit,
482 )? {
483 Some(result) => Ok(result),
484 None => bail!("{}", crate::i18n::tr("wizard.exit.message")),
485 }
486}
487
488pub fn run_interactive_with_zero_action(
489 initial_mode: Option<WizardMode>,
490 emit_answers: Option<&PathBuf>,
491 schema_version: Option<&str>,
492 execution: ExecutionMode,
493 zero_action: RootMenuZeroAction,
494) -> Result<Option<WizardRunResult>> {
495 let stdin = io::stdin();
496 let stdout = io::stdout();
497 let mut input = stdin.lock();
498 let mut output = stdout.lock();
499 loop {
500 let Some(selection) =
501 collect_guided_interactive_request(&mut input, &mut output, initial_mode, zero_action)?
502 else {
503 return Ok(None);
504 };
505 let InteractiveSelection::Request(interactive) = selection else {
506 if initial_mode.is_none() {
507 continue;
508 }
509 return Ok(None);
510 };
511 let resolved_execution = match execution {
512 ExecutionMode::DryRun => ExecutionMode::DryRun,
513 ExecutionMode::Execute => match interactive.review_action {
514 ReviewAction::BuildNow => ExecutionMode::Execute,
515 ReviewAction::DryRunOnly | ReviewAction::SaveAnswersOnly => ExecutionMode::DryRun,
516 },
517 };
518 return Ok(Some(execute_request(
519 interactive.request,
520 resolved_execution,
521 matches!(interactive.review_action, ReviewAction::BuildNow)
522 && resolved_execution == ExecutionMode::Execute,
523 schema_version,
524 emit_answers,
525 None,
526 )?));
527 }
528}
529
530fn collect_guided_interactive_request<R: BufRead, W: Write>(
531 input: &mut R,
532 output: &mut W,
533 initial_mode: Option<WizardMode>,
534 zero_action: RootMenuZeroAction,
535) -> Result<Option<InteractiveSelection>> {
536 if let Some(mode) = initial_mode {
537 let interactive = match mode {
538 WizardMode::Create => collect_create_flow(input, output)?,
539 WizardMode::Update => collect_update_flow(input, output, false)?,
540 WizardMode::Doctor => collect_doctor_flow(input, output)?,
541 };
542 return Ok(Some(InteractiveSelection::Request(Box::new(interactive))));
543 }
544
545 let Some(choice) = choose_interactive_menu(input, output, zero_action)? else {
546 return Ok(None);
547 };
548
549 match choice {
550 InteractiveChoice::Create => Ok(Some(InteractiveSelection::Request(Box::new(
551 collect_create_flow(input, output)?,
552 )))),
553 InteractiveChoice::Update => Ok(Some(InteractiveSelection::Request(Box::new(
554 collect_update_flow(input, output, false)?,
555 )))),
556 InteractiveChoice::Validate => Ok(Some(InteractiveSelection::Request(Box::new(
557 collect_update_flow(input, output, true)?,
558 )))),
559 InteractiveChoice::Doctor => {
560 perform_doctor_action(input, output)?;
561 Ok(Some(InteractiveSelection::Handled))
562 }
563 InteractiveChoice::Inspect => {
564 perform_inspect_action(input, output)?;
565 Ok(Some(InteractiveSelection::Handled))
566 }
567 InteractiveChoice::Unbundle => {
568 perform_unbundle_action(input, output)?;
569 Ok(Some(InteractiveSelection::Handled))
570 }
571 }
572}
573
574fn choose_interactive_menu<R: BufRead, W: Write>(
575 input: &mut R,
576 output: &mut W,
577 zero_action: RootMenuZeroAction,
578) -> Result<Option<InteractiveChoice>> {
579 writeln!(output, "{}", crate::i18n::tr("wizard.menu.title"))?;
580 write_root_menu_option(
581 output,
582 "1",
583 &crate::i18n::tr("wizard.mode.create"),
584 &crate::i18n::tr("wizard.menu_desc.create"),
585 )?;
586 write_root_menu_option(
587 output,
588 "2",
589 &crate::i18n::tr("wizard.mode.update"),
590 &crate::i18n::tr("wizard.menu_desc.update"),
591 )?;
592 write_root_menu_option(
593 output,
594 "3",
595 &crate::i18n::tr("wizard.mode.validate"),
596 &crate::i18n::tr("wizard.menu_desc.validate"),
597 )?;
598 write_root_menu_option(
599 output,
600 "4",
601 &crate::i18n::tr("wizard.mode.doctor"),
602 &crate::i18n::tr("wizard.menu_desc.doctor"),
603 )?;
604 write_root_menu_option(
605 output,
606 "5",
607 &crate::i18n::tr("wizard.mode.inspect"),
608 &crate::i18n::tr("wizard.menu_desc.inspect"),
609 )?;
610 write_root_menu_option(
611 output,
612 "6",
613 &crate::i18n::tr("wizard.mode.unbundle"),
614 &crate::i18n::tr("wizard.menu_desc.unbundle"),
615 )?;
616 let zero_label = match zero_action {
617 RootMenuZeroAction::Exit => crate::i18n::tr("wizard.menu.exit"),
618 RootMenuZeroAction::Back => crate::i18n::tr("wizard.action.back"),
619 };
620 writeln!(output, "0. {zero_label}")?;
621 loop {
622 write!(output, "{} ", crate::i18n::tr("wizard.setup.enum_prompt"))?;
623 output.flush()?;
624 let mut line = String::new();
625 if input.read_line(&mut line)? == 0 {
626 bail!("{}", crate::i18n::tr("wizard.error.input_ended"));
627 }
628 match line.trim() {
629 "0" => match zero_action {
630 RootMenuZeroAction::Exit => bail!("{}", crate::i18n::tr("wizard.exit.message")),
631 RootMenuZeroAction::Back => return Ok(None),
632 },
633 "1" | "create" => return Ok(Some(InteractiveChoice::Create)),
634 "2" | "update" | "open" => return Ok(Some(InteractiveChoice::Update)),
635 "3" | "validate" => return Ok(Some(InteractiveChoice::Validate)),
636 "4" | "doctor" => return Ok(Some(InteractiveChoice::Doctor)),
637 "5" | "inspect" => return Ok(Some(InteractiveChoice::Inspect)),
638 "6" | "unbundle" => return Ok(Some(InteractiveChoice::Unbundle)),
639 _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
640 }
641 }
642}
643
644fn write_root_menu_option<W: Write>(
645 output: &mut W,
646 number: &str,
647 title: &str,
648 description: &str,
649) -> Result<()> {
650 writeln!(output, "{number}. {title}")?;
651 writeln!(output, " {description}")?;
652 Ok(())
653}
654
655fn collect_create_flow<R: BufRead, W: Write>(
656 input: &mut R,
657 output: &mut W,
658) -> Result<InteractiveRequest> {
659 let locale = crate::i18n::current_locale();
660 let bundle_name = prompt_required_string(
661 input,
662 output,
663 &crate::i18n::tr("wizard.prompt.bundle_name"),
664 None,
665 )?;
666 let bundle_id = normalize_bundle_id(&prompt_required_string(
667 input,
668 output,
669 &crate::i18n::tr("wizard.prompt.bundle_id"),
670 None,
671 )?);
672 let mut state = normalize_request(SeedRequest {
673 mode: WizardMode::Create,
674 locale,
675 bundle_name,
676 bundle_id: bundle_id.clone(),
677 output_dir: PathBuf::from(prompt_required_string(
678 input,
679 output,
680 &crate::i18n::tr("wizard.prompt.output_dir"),
681 Some(&default_bundle_output_dir(&bundle_id).display().to_string()),
682 )?),
683 app_pack_entries: Vec::new(),
684 access_rules: Vec::new(),
685 extension_provider_entries: Vec::new(),
686 cloud_deployer_target: None,
687 advanced_setup: false,
688 app_packs: Vec::new(),
689 extension_providers: Vec::new(),
690 remote_catalogs: Vec::new(),
691 setup_specs: BTreeMap::new(),
692 setup_answers: BTreeMap::new(),
693 setup_execution_intent: false,
694 export_intent: false,
695 capabilities: Vec::new(),
696 });
697 state = edit_app_packs(input, output, state, false)?;
698 state = edit_extension_providers(input, output, state, false)?;
699 state = edit_cloud_deployer_target(input, output, state)?;
700 state = edit_bundle_capabilities(input, output, state)?;
701 let review_action = review_summary(input, output, &state, false)?;
702 Ok(InteractiveRequest {
703 request: state,
704 review_action,
705 })
706}
707
708fn collect_update_flow<R: BufRead, W: Write>(
709 input: &mut R,
710 output: &mut W,
711 validate_only: bool,
712) -> Result<InteractiveRequest> {
713 let (target, mut state) = prompt_request_from_bundle_target(
714 input,
715 output,
716 &crate::i18n::tr("wizard.prompt.current_bundle_root"),
717 WizardMode::Update,
718 )?;
719 if matches!(target, BundleTarget::Artifact(_)) && !validate_only {
720 state.output_dir = PathBuf::from(prompt_required_string(
721 input,
722 output,
723 &crate::i18n::tr("wizard.prompt.output_dir"),
724 Some(&state.output_dir.display().to_string()),
725 )?);
726 }
727 state.bundle_name = prompt_required_string(
728 input,
729 output,
730 &crate::i18n::tr("wizard.prompt.bundle_name"),
731 Some(&state.bundle_name),
732 )?;
733 state.bundle_id = normalize_bundle_id(&prompt_required_string(
734 input,
735 output,
736 &crate::i18n::tr("wizard.prompt.bundle_id"),
737 Some(&state.bundle_id),
738 )?);
739 if !validate_only {
740 state = edit_app_packs(input, output, state, true)?;
741 state = edit_extension_providers(input, output, state, true)?;
742 state = edit_cloud_deployer_target(input, output, state)?;
743 state = edit_bundle_capabilities(input, output, state)?;
744 let review_action = review_summary(input, output, &state, true)?;
745 Ok(InteractiveRequest {
746 request: state,
747 review_action,
748 })
749 } else {
750 Ok(InteractiveRequest {
751 request: state,
752 review_action: ReviewAction::DryRunOnly,
753 })
754 }
755}
756
757fn collect_doctor_flow<R: BufRead, W: Write>(
758 input: &mut R,
759 output: &mut W,
760) -> Result<InteractiveRequest> {
761 Ok(InteractiveRequest {
762 request: prompt_request_from_bundle_target(
763 input,
764 output,
765 &crate::i18n::tr("wizard.prompt.current_bundle_root"),
766 WizardMode::Doctor,
767 )?
768 .1,
769 review_action: ReviewAction::DryRunOnly,
770 })
771}
772
773fn perform_doctor_action<R: BufRead, W: Write>(input: &mut R, output: &mut W) -> Result<()> {
774 run_prompted_bundle_target_action(
775 input,
776 output,
777 &crate::i18n::tr("wizard.prompt.bundle_target"),
778 |target| match target {
779 BundleTarget::Workspace(root) => crate::build::doctor_target(Some(root), None),
780 BundleTarget::Artifact(artifact) => crate::build::doctor_target(None, Some(artifact)),
781 },
782 )
783}
784
785fn perform_inspect_action<R: BufRead, W: Write>(input: &mut R, output: &mut W) -> Result<()> {
786 loop {
787 let target = prompt_bundle_target(
788 input,
789 output,
790 &crate::i18n::tr("wizard.prompt.bundle_target"),
791 )?;
792 match inspect_bundle_target(output, &target) {
793 Ok(()) => return Ok(()),
794 Err(error) => writeln!(output, "{error}")?,
795 }
796 }
797}
798
799fn perform_unbundle_action<R: BufRead, W: Write>(input: &mut R, output: &mut W) -> Result<()> {
800 loop {
801 let artifact = prompt_bundle_artifact_path(input, output)?;
802 let out = prompt_optional_string(
803 input,
804 output,
805 &crate::i18n::tr("wizard.prompt.unbundle_output_dir"),
806 Some("."),
807 )?;
808 match crate::build::unbundle_artifact(&artifact, Path::new(&out)) {
809 Ok(result) => {
810 writeln!(output, "{}", serde_json::to_string_pretty(&result)?)?;
811 return Ok(());
812 }
813 Err(error) => writeln!(output, "{error}")?,
814 }
815 }
816}
817
818fn prompt_bundle_target<R: BufRead, W: Write>(
819 input: &mut R,
820 output: &mut W,
821 title: &str,
822) -> Result<BundleTarget> {
823 loop {
824 let raw = prompt_required_string(input, output, title, None)?;
825 match parse_bundle_target(PathBuf::from(raw)) {
826 Ok(target) => return Ok(target),
827 Err(error) => writeln!(output, "{error}")?,
828 }
829 }
830}
831
832fn prompt_bundle_artifact_path<R: BufRead, W: Write>(
833 input: &mut R,
834 output: &mut W,
835) -> Result<PathBuf> {
836 loop {
837 let raw = prompt_required_string(
838 input,
839 output,
840 &crate::i18n::tr("wizard.prompt.bundle_artifact"),
841 None,
842 )?;
843 let path = PathBuf::from(raw);
844 if !is_bundle_artifact_path(&path) {
845 writeln!(
846 output,
847 "{}",
848 crate::i18n::tr("wizard.error.bundle_artifact_required")
849 )?;
850 continue;
851 }
852 if !path.exists() {
853 writeln!(
854 output,
855 "{}: {}",
856 crate::i18n::tr("wizard.error.bundle_target_missing"),
857 path.display()
858 )?;
859 continue;
860 }
861 return Ok(path);
862 }
863}
864
865fn prompt_request_from_bundle_target<R: BufRead, W: Write>(
866 input: &mut R,
867 output: &mut W,
868 title: &str,
869 mode: WizardMode,
870) -> Result<(BundleTarget, NormalizedRequest)> {
871 loop {
872 let target = prompt_bundle_target(input, output, title)?;
873 match request_from_bundle_target(&target, mode) {
874 Ok(request) => return Ok((target, request)),
875 Err(error) => writeln!(output, "{error}")?,
876 }
877 }
878}
879
880fn run_prompted_bundle_target_action<R: BufRead, W: Write, T, F>(
881 input: &mut R,
882 output: &mut W,
883 title: &str,
884 action: F,
885) -> Result<()>
886where
887 T: Serialize,
888 F: Fn(&BundleTarget) -> Result<T>,
889{
890 loop {
891 let target = prompt_bundle_target(input, output, title)?;
892 match action(&target) {
893 Ok(report) => {
894 writeln!(output, "{}", serde_json::to_string_pretty(&report)?)?;
895 return Ok(());
896 }
897 Err(error) => writeln!(output, "{error}")?,
898 }
899 }
900}
901
902fn inspect_bundle_target<W: Write>(output: &mut W, target: &BundleTarget) -> Result<()> {
903 let report = match target {
904 BundleTarget::Workspace(root) => crate::build::inspect_target(Some(root), None)?,
905 BundleTarget::Artifact(artifact) => crate::build::inspect_target(None, Some(artifact))?,
906 };
907 if report.kind == "artifact" {
908 for entry in report.contents.as_deref().unwrap_or(&[]) {
909 writeln!(output, "{entry}")?;
910 }
911 } else {
912 writeln!(output, "{}", serde_json::to_string_pretty(&report)?)?;
913 }
914 Ok(())
915}
916
917fn parse_bundle_target(path: PathBuf) -> Result<BundleTarget> {
918 if !path.exists() {
919 bail!(
920 "{}: {}",
921 crate::i18n::tr("wizard.error.bundle_target_missing"),
922 path.display()
923 );
924 }
925 if is_bundle_artifact_path(&path) {
926 Ok(BundleTarget::Artifact(path))
927 } else {
928 Ok(BundleTarget::Workspace(path))
929 }
930}
931
932fn request_from_bundle_target(
933 target: &BundleTarget,
934 mode: WizardMode,
935) -> Result<NormalizedRequest> {
936 match target {
937 BundleTarget::Workspace(root) => {
938 let workspace = crate::project::read_bundle_workspace(root)
939 .with_context(|| format!("read current bundle workspace {}", root.display()))?;
940 Ok(request_from_workspace(&workspace, root, mode))
941 }
942 BundleTarget::Artifact(artifact) => {
943 let staging = tempfile::tempdir().with_context(|| {
944 format!("create temporary workspace for {}", artifact.display())
945 })?;
946 crate::build::unbundle_artifact(artifact, staging.path())?;
947 let workspace =
948 crate::project::read_bundle_workspace(staging.path()).with_context(|| {
949 format!("read unbundled bundle workspace {}", artifact.display())
950 })?;
951 let mut request = request_from_workspace(&workspace, staging.path(), mode);
952 request.output_dir = default_workspace_dir_for_artifact(artifact);
953 Ok(request)
954 }
955 }
956}
957
958fn default_workspace_dir_for_artifact(artifact: &Path) -> PathBuf {
959 let stem = artifact
960 .file_stem()
961 .map(|value| value.to_os_string())
962 .unwrap_or_else(|| "bundle".into());
963 artifact
964 .parent()
965 .unwrap_or_else(|| Path::new("."))
966 .join(stem)
967}
968
969fn is_bundle_artifact_path(path: &Path) -> bool {
970 path.extension()
971 .and_then(|value| value.to_str())
972 .is_some_and(|value| value.eq_ignore_ascii_case("gtbundle"))
973}
974
975fn execution_for_run(dry_run: bool) -> ExecutionMode {
976 if dry_run {
977 ExecutionMode::DryRun
978 } else {
979 ExecutionMode::Execute
980 }
981}
982
983fn execute_request(
984 request: NormalizedRequest,
985 execution: ExecutionMode,
986 build_bundle_now: bool,
987 schema_version: Option<&str>,
988 emit_answers: Option<&PathBuf>,
989 source_locks: Option<BTreeMap<String, Value>>,
990) -> Result<WizardRunResult> {
991 let target_version = requested_schema_version(schema_version)?;
992 if !request.remote_catalogs.is_empty() {
993 eprintln!(
994 "[resolve] Resolving {} remote catalog(s)...",
995 request.remote_catalogs.len()
996 );
997 }
998 let catalog_resolution = crate::catalog::resolve::resolve_catalogs(
999 &request.output_dir,
1000 &request.remote_catalogs,
1001 &crate::catalog::resolve::CatalogResolveOptions {
1002 offline: crate::runtime::offline(),
1003 write_cache: execution == ExecutionMode::Execute,
1004 },
1005 )?;
1006 if !request.remote_catalogs.is_empty() {
1007 eprintln!(
1008 "[resolve] Catalog resolution complete ({} entries)",
1009 catalog_resolution.entries.len()
1010 );
1011 }
1012 let request = discover_setup_specs(request, &catalog_resolution);
1013 let setup_writes = preview_setup_writes(&request, execution)?;
1014 let bundle_lock = build_bundle_lock(&request, execution, &catalog_resolution, &setup_writes);
1015 let plan = build_plan(
1016 &request,
1017 execution,
1018 build_bundle_now,
1019 &target_version,
1020 &catalog_resolution.cache_writes,
1021 &setup_writes,
1022 );
1023 let mut document = answer_document_from_request(&request, Some(&target_version.to_string()))?;
1024 let mut locks = source_locks.unwrap_or_default();
1025 locks.extend(bundle_lock_to_answer_locks(&bundle_lock));
1026 document.locks = locks;
1027 let applied_files = if execution == ExecutionMode::Execute {
1028 let mut applied_files = apply_plan(&request, &bundle_lock)?;
1029 if build_bundle_now {
1030 let build_result = crate::build::build_workspace(&request.output_dir, None, false)?;
1031 applied_files.push(PathBuf::from(build_result.artifact_path));
1032 }
1033 applied_files.sort();
1034 applied_files.dedup();
1035 applied_files
1036 } else {
1037 Vec::new()
1038 };
1039 if let Some(path) = emit_answers {
1040 write_answer_document(path, &document)?;
1041 }
1042 Ok(WizardRunResult {
1043 plan,
1044 document,
1045 applied_files,
1046 })
1047}
1048
1049#[allow(dead_code)]
1050fn collect_interactive_request<R: BufRead, W: Write>(
1051 input: &mut R,
1052 output: &mut W,
1053 initial_mode: Option<WizardMode>,
1054 last_compact_title: &mut Option<String>,
1055) -> Result<NormalizedRequest> {
1056 let mode = match initial_mode {
1057 Some(mode) => mode,
1058 None => choose_mode_via_qa(input, output, last_compact_title)?,
1059 };
1060 let request = match mode {
1061 WizardMode::Update => collect_update_request(input, output, last_compact_title)?,
1062 WizardMode::Create | WizardMode::Doctor => {
1063 let answers = run_qa_form(
1064 input,
1065 output,
1066 &wizard_request_form_spec_json(mode, None)?,
1067 None,
1068 "root wizard",
1069 last_compact_title,
1070 )?;
1071 normalized_request_from_qa_answers(answers, crate::i18n::current_locale(), mode)?
1072 }
1073 };
1074 collect_interactive_setup_answers(input, output, request, last_compact_title)
1075}
1076
1077#[allow(dead_code)]
1078fn parse_csv_answers(raw: &str) -> Vec<String> {
1079 raw.split(',')
1080 .map(str::trim)
1081 .filter(|entry| !entry.is_empty())
1082 .map(ToOwned::to_owned)
1083 .collect()
1084}
1085
1086#[allow(dead_code)]
1087fn choose_mode_via_qa<R: BufRead, W: Write>(
1088 input: &mut R,
1089 output: &mut W,
1090 last_compact_title: &mut Option<String>,
1091) -> Result<WizardMode> {
1092 let config = WizardRunConfig {
1093 spec_json: json!({
1094 "id": "greentic-bundle-wizard-mode",
1095 "title": crate::i18n::tr("wizard.menu.title"),
1096 "version": "1.0.0",
1097 "presentation": {
1098 "default_locale": crate::i18n::current_locale()
1099 },
1100 "progress_policy": {
1101 "skip_answered": true,
1102 "autofill_defaults": false,
1103 "treat_default_as_answered": false
1104 },
1105 "questions": [{
1106 "id": "mode",
1107 "type": "enum",
1108 "title": crate::i18n::tr("wizard.prompt.main_choice"),
1109 "required": true,
1110 "choices": ["create", "update", "doctor"]
1111 }]
1112 })
1113 .to_string(),
1114 initial_answers_json: None,
1115 frontend: WizardFrontend::JsonUi,
1116 i18n: I18nConfig {
1117 locale: Some(crate::i18n::current_locale()),
1118 resolved: None,
1119 debug: false,
1120 },
1121 verbose: false,
1122 };
1123 let mut driver =
1124 WizardDriver::new(config).context("initialize greentic-qa-lib wizard mode form")?;
1125
1126 loop {
1127 driver
1128 .next_payload_json()
1129 .context("render greentic-qa-lib wizard mode payload")?;
1130 if driver.is_complete() {
1131 break;
1132 }
1133
1134 let ui_raw = driver
1135 .last_ui_json()
1136 .ok_or_else(|| anyhow::anyhow!("greentic-qa-lib wizard mode missing UI state"))?;
1137 let ui: Value =
1138 serde_json::from_str(ui_raw).context("parse greentic-qa-lib wizard mode UI payload")?;
1139 let question = ui
1140 .get("questions")
1141 .and_then(Value::as_array)
1142 .and_then(|questions| questions.first())
1143 .ok_or_else(|| anyhow::anyhow!("greentic-qa-lib wizard mode missing question"))?;
1144
1145 let answer = prompt_wizard_mode_question(input, output, question)?;
1146 driver
1147 .submit_patch_json(&json!({ "mode": answer }).to_string())
1148 .context("submit greentic-qa-lib wizard mode answer")?;
1149 }
1150 *last_compact_title = Some(crate::i18n::tr("wizard.menu.title"));
1151
1152 let answers = driver
1153 .finish()
1154 .context("finish greentic-qa-lib wizard mode")?
1155 .answer_set
1156 .answers;
1157
1158 Ok(
1159 match answers
1160 .get("mode")
1161 .and_then(Value::as_str)
1162 .unwrap_or("create")
1163 {
1164 "update" => WizardMode::Update,
1165 "doctor" => WizardMode::Doctor,
1166 _ => WizardMode::Create,
1167 },
1168 )
1169}
1170
1171#[allow(dead_code)]
1172fn prompt_wizard_mode_question<R: BufRead, W: Write>(
1173 input: &mut R,
1174 output: &mut W,
1175 question: &Value,
1176) -> Result<Value> {
1177 writeln!(output, "{}", crate::i18n::tr("wizard.menu.title"))?;
1178 let choices = question
1179 .get("choices")
1180 .and_then(Value::as_array)
1181 .ok_or_else(|| anyhow::anyhow!("wizard mode question missing choices"))?;
1182 for (index, choice) in choices.iter().enumerate() {
1183 let choice = choice
1184 .as_str()
1185 .ok_or_else(|| anyhow::anyhow!("wizard mode choice must be a string"))?;
1186 writeln!(
1187 output,
1188 "{}. {}",
1189 index + 1,
1190 crate::i18n::tr(&format!("wizard.mode.{choice}"))
1191 )?;
1192 }
1193 prompt_compact_enum(
1194 input,
1195 output,
1196 question,
1197 true,
1198 question_default_value(question, "enum"),
1199 )
1200}
1201
1202#[allow(dead_code)]
1203fn prompt_compact_enum<R: BufRead, W: Write>(
1204 input: &mut R,
1205 output: &mut W,
1206 question: &Value,
1207 required: bool,
1208 default_value: Option<Value>,
1209) -> Result<Value> {
1210 let choices = question
1211 .get("choices")
1212 .and_then(Value::as_array)
1213 .ok_or_else(|| anyhow::anyhow!("qa enum question missing choices"))?
1214 .iter()
1215 .filter_map(Value::as_str)
1216 .map(ToOwned::to_owned)
1217 .collect::<Vec<_>>();
1218
1219 loop {
1220 write!(output, "{} ", crate::i18n::tr("wizard.setup.enum_prompt"))?;
1221 output.flush()?;
1222
1223 let mut line = String::new();
1224 if input.read_line(&mut line)? == 0 {
1225 bail!("{}", crate::i18n::tr("wizard.error.input_ended"));
1226 }
1227 let trimmed = line.trim();
1228 if trimmed.is_empty() {
1229 if let Some(default) = &default_value {
1230 return Ok(default.clone());
1231 }
1232 if required {
1233 writeln!(output, "{}", crate::i18n::tr("wizard.error.empty_answer"))?;
1234 continue;
1235 }
1236 return Ok(Value::Null);
1237 }
1238 if let Ok(number) = trimmed.parse::<usize>()
1239 && number > 0
1240 && number <= choices.len()
1241 {
1242 return Ok(Value::String(choices[number - 1].clone()));
1243 }
1244 if choices.iter().any(|choice| choice == trimmed) {
1245 return Ok(Value::String(trimmed.to_string()));
1246 }
1247 writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?;
1248 }
1249}
1250
1251#[allow(dead_code)]
1252fn collect_update_request<R: BufRead, W: Write>(
1253 input: &mut R,
1254 output: &mut W,
1255 last_compact_title: &mut Option<String>,
1256) -> Result<NormalizedRequest> {
1257 let root_answers = run_qa_form(
1258 input,
1259 output,
1260 &json!({
1261 "id": "greentic-bundle-update-root",
1262 "title": crate::i18n::tr("wizard.menu.update"),
1263 "version": "1.0.0",
1264 "presentation": {
1265 "default_locale": crate::i18n::current_locale()
1266 },
1267 "progress_policy": {
1268 "skip_answered": true,
1269 "autofill_defaults": false,
1270 "treat_default_as_answered": false
1271 },
1272 "questions": [{
1273 "id": "output_dir",
1274 "type": "string",
1275 "title": crate::i18n::tr("wizard.prompt.current_bundle_root"),
1276 "required": true
1277 }]
1278 })
1279 .to_string(),
1280 None,
1281 "update bundle root",
1282 last_compact_title,
1283 )?;
1284 let root = PathBuf::from(
1285 root_answers
1286 .get("output_dir")
1287 .and_then(Value::as_str)
1288 .ok_or_else(|| anyhow::anyhow!("update wizard missing current bundle root"))?,
1289 );
1290 let workspace = crate::project::read_bundle_workspace(&root)
1291 .with_context(|| format!("read current bundle workspace {}", root.display()))?;
1292 let defaults = request_defaults_from_workspace(&workspace, &root);
1293 let answers = run_qa_form(
1294 input,
1295 output,
1296 &wizard_request_form_spec_json(WizardMode::Update, Some(&defaults))?,
1297 None,
1298 "update wizard",
1299 last_compact_title,
1300 )?;
1301 normalized_request_from_qa_answers(answers, crate::i18n::current_locale(), WizardMode::Update)
1302}
1303
1304#[allow(dead_code)]
1305fn request_defaults_from_workspace(
1306 workspace: &crate::project::BundleWorkspaceDefinition,
1307 root: &Path,
1308) -> RequestDefaults {
1309 RequestDefaults {
1310 bundle_name: Some(workspace.bundle_name.clone()),
1311 bundle_id: Some(workspace.bundle_id.clone()),
1312 output_dir: Some(root.display().to_string()),
1313 advanced_setup: Some(workspace.advanced_setup.to_string()),
1314 app_packs: Some(workspace.app_packs.join(", ")),
1315 extension_providers: Some(workspace.extension_providers.join(", ")),
1316 cloud_deployer_target: detect_cloud_deployer_target(&workspace.extension_providers),
1317 remote_catalogs: Some(workspace.remote_catalogs.join(", ")),
1318 setup_execution_intent: Some(workspace.setup_execution_intent.to_string()),
1319 export_intent: Some(workspace.export_intent.to_string()),
1320 }
1321}
1322
1323#[allow(dead_code)]
1324fn run_qa_form<R: BufRead, W: Write>(
1325 input: &mut R,
1326 output: &mut W,
1327 spec_json: &str,
1328 initial_answers_json: Option<String>,
1329 context_label: &str,
1330 last_compact_title: &mut Option<String>,
1331) -> Result<Value> {
1332 let config = WizardRunConfig {
1333 spec_json: spec_json.to_string(),
1334 initial_answers_json,
1335 frontend: WizardFrontend::Text,
1336 i18n: I18nConfig {
1337 locale: Some(crate::i18n::current_locale()),
1338 resolved: None,
1339 debug: false,
1340 },
1341 verbose: false,
1342 };
1343 let mut driver = WizardDriver::new(config)
1344 .with_context(|| format!("initialize greentic-qa-lib {context_label}"))?;
1345 loop {
1346 let payload_raw = driver
1347 .next_payload_json()
1348 .with_context(|| format!("render greentic-qa-lib {context_label} payload"))?;
1349 let payload: Value = serde_json::from_str(&payload_raw)
1350 .with_context(|| format!("parse greentic-qa-lib {context_label} payload"))?;
1351 if let Some(text) = payload.get("text").and_then(Value::as_str) {
1352 render_qa_driver_text(output, text, last_compact_title)?;
1353 }
1354 if driver.is_complete() {
1355 break;
1356 }
1357
1358 let ui_raw = driver.last_ui_json().ok_or_else(|| {
1359 anyhow::anyhow!("greentic-qa-lib {context_label} payload missing UI state")
1360 })?;
1361 let ui: Value = serde_json::from_str(ui_raw)
1362 .with_context(|| format!("parse greentic-qa-lib {context_label} UI payload"))?;
1363 let question_id = ui
1364 .get("next_question_id")
1365 .and_then(Value::as_str)
1366 .ok_or_else(|| {
1367 anyhow::anyhow!("greentic-qa-lib {context_label} missing next_question_id")
1368 })?
1369 .to_string();
1370 let question = ui
1371 .get("questions")
1372 .and_then(Value::as_array)
1373 .and_then(|questions| {
1374 questions.iter().find(|question| {
1375 question.get("id").and_then(Value::as_str) == Some(question_id.as_str())
1376 })
1377 })
1378 .ok_or_else(|| {
1379 anyhow::anyhow!("greentic-qa-lib {context_label} missing question {question_id}")
1380 })?;
1381
1382 let answer = prompt_qa_question_answer(input, output, &question_id, question)?;
1383 driver
1384 .submit_patch_json(&json!({ question_id: answer }).to_string())
1385 .with_context(|| format!("submit greentic-qa-lib {context_label} answer"))?;
1386 }
1387
1388 let result = driver
1389 .finish()
1390 .with_context(|| format!("finish greentic-qa-lib {context_label}"))?;
1391 Ok(result.answer_set.answers)
1392}
1393
1394#[allow(dead_code)]
1395#[derive(Debug, Clone, Default)]
1396struct RequestDefaults {
1397 bundle_name: Option<String>,
1398 bundle_id: Option<String>,
1399 output_dir: Option<String>,
1400 advanced_setup: Option<String>,
1401 app_packs: Option<String>,
1402 extension_providers: Option<String>,
1403 cloud_deployer_target: Option<String>,
1404 remote_catalogs: Option<String>,
1405 setup_execution_intent: Option<String>,
1406 export_intent: Option<String>,
1407}
1408
1409#[allow(dead_code)]
1410fn wizard_request_form_spec_json(
1411 mode: WizardMode,
1412 defaults: Option<&RequestDefaults>,
1413) -> Result<String> {
1414 let defaults = defaults.cloned().unwrap_or_default();
1415 let output_dir_default = defaults.output_dir.clone().or_else(|| {
1416 if matches!(mode, WizardMode::Create) {
1417 defaults
1418 .bundle_id
1419 .as_deref()
1420 .map(default_bundle_output_dir)
1421 .map(|path| path.display().to_string())
1422 } else {
1423 None
1424 }
1425 });
1426 Ok(json!({
1427 "id": format!("greentic-bundle-root-wizard-{}", mode_name(mode)),
1428 "title": crate::i18n::tr("wizard.menu.title"),
1429 "version": "1.0.0",
1430 "presentation": {
1431 "default_locale": crate::i18n::current_locale()
1432 },
1433 "progress_policy": {
1434 "skip_answered": true,
1435 "autofill_defaults": false,
1436 "treat_default_as_answered": false
1437 },
1438 "questions": [
1439 {
1440 "id": "bundle_name",
1441 "type": "string",
1442 "title": crate::i18n::tr("wizard.prompt.bundle_name"),
1443 "required": true,
1444 "default_value": defaults.bundle_name
1445 },
1446 {
1447 "id": "bundle_id",
1448 "type": "string",
1449 "title": crate::i18n::tr("wizard.prompt.bundle_id"),
1450 "required": true,
1451 "default_value": defaults.bundle_id
1452 },
1453 {
1454 "id": "output_dir",
1455 "type": "string",
1456 "title": crate::i18n::tr("wizard.prompt.output_dir"),
1457 "required": !matches!(mode, WizardMode::Create),
1458 "default_value": output_dir_default
1459 },
1460 {
1461 "id": "advanced_setup",
1462 "type": "boolean",
1463 "title": crate::i18n::tr("wizard.prompt.advanced_setup"),
1464 "required": true,
1465 "default_value": defaults.advanced_setup.unwrap_or_else(|| "false".to_string())
1466 },
1467 {
1468 "id": "app_packs",
1469 "type": "string",
1470 "title": crate::i18n::tr("wizard.prompt.app_packs"),
1471 "required": false,
1472 "default_value": defaults.app_packs,
1473 "visible_if": { "op": "var", "path": "/advanced_setup" }
1474 },
1475 {
1476 "id": "extension_providers",
1477 "type": "string",
1478 "title": crate::i18n::tr("wizard.prompt.extension_providers"),
1479 "required": false,
1480 "default_value": defaults.extension_providers,
1481 "visible_if": { "op": "var", "path": "/advanced_setup" }
1482 },
1483 {
1484 "id": "cloud_deployer_target",
1485 "type": "string",
1486 "title": "Cloud deployer target",
1487 "required": false,
1488 "default_value": defaults.cloud_deployer_target,
1489 "enum": ["", "aws", "azure", "gcp"],
1490 "enum_labels": ["None", "AWS", "Azure", "GCP"],
1491 "visible_if": { "op": "var", "path": "/advanced_setup" }
1492 },
1493 {
1494 "id": "remote_catalogs",
1495 "type": "string",
1496 "title": crate::i18n::tr("wizard.prompt.remote_catalogs"),
1497 "required": false,
1498 "default_value": defaults.remote_catalogs,
1499 "visible_if": { "op": "var", "path": "/advanced_setup" }
1500 },
1501 {
1502 "id": "setup_execution_intent",
1503 "type": "boolean",
1504 "title": crate::i18n::tr("wizard.prompt.setup_execution"),
1505 "required": true,
1506 "default_value": defaults
1507 .setup_execution_intent
1508 .unwrap_or_else(|| "false".to_string()),
1509 "visible_if": { "op": "var", "path": "/advanced_setup" }
1510 },
1511 {
1512 "id": "export_intent",
1513 "type": "boolean",
1514 "title": crate::i18n::tr("wizard.prompt.export_intent"),
1515 "required": true,
1516 "default_value": defaults.export_intent.unwrap_or_else(|| "false".to_string()),
1517 "visible_if": { "op": "var", "path": "/advanced_setup" }
1518 }
1519 ]
1520 })
1521 .to_string())
1522}
1523
1524#[derive(Debug)]
1525struct SeedRequest {
1526 mode: WizardMode,
1527 locale: String,
1528 bundle_name: String,
1529 bundle_id: String,
1530 output_dir: PathBuf,
1531 app_pack_entries: Vec<AppPackEntry>,
1532 access_rules: Vec<AccessRuleInput>,
1533 extension_provider_entries: Vec<ExtensionProviderEntry>,
1534 cloud_deployer_target: Option<String>,
1535 advanced_setup: bool,
1536 app_packs: Vec<String>,
1537 extension_providers: Vec<String>,
1538 remote_catalogs: Vec<String>,
1539 setup_specs: BTreeMap<String, Value>,
1540 setup_answers: BTreeMap<String, Value>,
1541 setup_execution_intent: bool,
1542 export_intent: bool,
1543 capabilities: Vec<String>,
1544}
1545
1546fn normalize_request(seed: SeedRequest) -> NormalizedRequest {
1547 let bundle_id = normalize_bundle_id(&seed.bundle_id);
1548 let mut app_pack_entries = seed.app_pack_entries;
1549 if app_pack_entries.is_empty() {
1550 app_pack_entries = seed
1551 .app_packs
1552 .iter()
1553 .map(|reference| AppPackEntry {
1554 reference: reference.clone(),
1555 detected_kind: "legacy".to_string(),
1556 pack_id: inferred_reference_id(reference),
1557 display_name: inferred_display_name(reference),
1558 version: inferred_reference_version(reference),
1559 mapping: AppPackMappingInput {
1560 scope: "global".to_string(),
1561 tenant: None,
1562 team: None,
1563 },
1564 })
1565 .collect();
1566 }
1567 app_pack_entries.sort_by(|left, right| left.reference.cmp(&right.reference));
1568 app_pack_entries.dedup_by(|left, right| {
1569 left.reference == right.reference
1570 && left.mapping.scope == right.mapping.scope
1571 && left.mapping.tenant == right.mapping.tenant
1572 && left.mapping.team == right.mapping.team
1573 });
1574 let mut app_packs = seed.app_packs;
1575 app_packs.extend(app_pack_entries.iter().map(|entry| entry.reference.clone()));
1576
1577 let mut extension_provider_entries = seed.extension_provider_entries;
1578 if let Some(target) = seed.cloud_deployer_target.as_deref() {
1579 apply_cloud_deployer_target_to_entries(&mut extension_provider_entries, target);
1580 }
1581 if extension_provider_entries.is_empty() {
1582 extension_provider_entries = seed
1583 .extension_providers
1584 .iter()
1585 .map(|reference| ExtensionProviderEntry {
1586 reference: reference.clone(),
1587 detected_kind: "legacy".to_string(),
1588 provider_id: inferred_reference_id(reference),
1589 display_name: inferred_display_name(reference),
1590 version: inferred_reference_version(reference),
1591 source_catalog: None,
1592 group: None,
1593 })
1594 .collect();
1595 }
1596 extension_provider_entries.sort_by(|left, right| left.reference.cmp(&right.reference));
1597 extension_provider_entries.dedup_by(|left, right| left.reference == right.reference);
1598 let mut extension_providers = seed.extension_providers;
1599 extension_providers.extend(
1600 extension_provider_entries
1601 .iter()
1602 .map(|entry| entry.reference.clone()),
1603 );
1604
1605 let mut remote_catalogs = seed.remote_catalogs;
1606 remote_catalogs.extend(
1607 extension_provider_entries
1608 .iter()
1609 .filter_map(|entry| entry.source_catalog.clone()),
1610 );
1611
1612 let access_rules = if seed.access_rules.is_empty() {
1613 derive_access_rules_from_entries(&app_pack_entries)
1614 } else {
1615 normalize_access_rules(seed.access_rules)
1616 };
1617
1618 NormalizedRequest {
1619 mode: seed.mode,
1620 locale: crate::i18n::normalize_locale(&seed.locale).unwrap_or_else(|| "en".to_string()),
1621 bundle_name: seed.bundle_name.trim().to_string(),
1622 bundle_id,
1623 output_dir: normalize_output_dir(seed.output_dir),
1624 local_reference_base_dir: None,
1625 app_pack_entries,
1626 access_rules,
1627 extension_provider_entries,
1628 advanced_setup: seed.advanced_setup,
1629 app_packs: sorted_unique(app_packs),
1630 extension_providers: sorted_unique(extension_providers),
1631 remote_catalogs: sorted_unique(remote_catalogs),
1632 setup_specs: seed.setup_specs,
1633 setup_answers: seed.setup_answers,
1634 setup_execution_intent: seed.setup_execution_intent,
1635 export_intent: seed.export_intent,
1636 capabilities: sorted_unique(seed.capabilities),
1637 }
1638}
1639
1640fn normalize_access_rules(mut rules: Vec<AccessRuleInput>) -> Vec<AccessRuleInput> {
1641 rules.retain(|rule| !rule.rule_path.trim().is_empty() && !rule.tenant.trim().is_empty());
1642 rules.sort_by(|left, right| {
1643 left.tenant
1644 .cmp(&right.tenant)
1645 .then(left.team.cmp(&right.team))
1646 .then(left.rule_path.cmp(&right.rule_path))
1647 .then(left.policy.cmp(&right.policy))
1648 });
1649 rules.dedup_by(|left, right| {
1650 left.tenant == right.tenant
1651 && left.team == right.team
1652 && left.rule_path == right.rule_path
1653 && left.policy == right.policy
1654 });
1655 rules
1656}
1657
1658fn request_from_workspace(
1659 workspace: &crate::project::BundleWorkspaceDefinition,
1660 root: &Path,
1661 mode: WizardMode,
1662) -> NormalizedRequest {
1663 let app_pack_entries = if workspace.app_pack_mappings.is_empty() {
1664 workspace
1665 .app_packs
1666 .iter()
1667 .map(|reference| AppPackEntry {
1668 pack_id: inferred_reference_id(reference),
1669 display_name: inferred_display_name(reference),
1670 version: inferred_reference_version(reference),
1671 detected_kind: detected_reference_kind(root, reference).to_string(),
1672 reference: reference.clone(),
1673 mapping: AppPackMappingInput {
1674 scope: "global".to_string(),
1675 tenant: None,
1676 team: None,
1677 },
1678 })
1679 .collect::<Vec<_>>()
1680 } else {
1681 workspace
1682 .app_pack_mappings
1683 .iter()
1684 .map(|mapping| AppPackEntry {
1685 pack_id: inferred_reference_id(&mapping.reference),
1686 display_name: inferred_display_name(&mapping.reference),
1687 version: inferred_reference_version(&mapping.reference),
1688 detected_kind: detected_reference_kind(root, &mapping.reference).to_string(),
1689 reference: mapping.reference.clone(),
1690 mapping: AppPackMappingInput {
1691 scope: match mapping.scope {
1692 crate::project::MappingScope::Global => "global".to_string(),
1693 crate::project::MappingScope::Tenant => "tenant".to_string(),
1694 crate::project::MappingScope::Team => "tenant_team".to_string(),
1695 },
1696 tenant: mapping.tenant.clone(),
1697 team: mapping.team.clone(),
1698 },
1699 })
1700 .collect::<Vec<_>>()
1701 };
1702
1703 let access_rules = derive_access_rules_from_entries(&app_pack_entries);
1704 let extension_provider_entries = workspace
1705 .extension_providers
1706 .iter()
1707 .map(|reference| ExtensionProviderEntry {
1708 provider_id: inferred_reference_id(reference),
1709 display_name: inferred_display_name(reference),
1710 version: inferred_reference_version(reference),
1711 detected_kind: detected_reference_kind(root, reference).to_string(),
1712 reference: reference.clone(),
1713 source_catalog: workspace.remote_catalogs.first().cloned(),
1714 group: None,
1715 })
1716 .collect();
1717
1718 normalize_request(SeedRequest {
1719 mode,
1720 locale: workspace.locale.clone(),
1721 bundle_name: workspace.bundle_name.clone(),
1722 bundle_id: workspace.bundle_id.clone(),
1723 output_dir: root.to_path_buf(),
1724 app_pack_entries,
1725 access_rules,
1726 extension_provider_entries,
1727 cloud_deployer_target: detect_cloud_deployer_target(&workspace.extension_providers),
1728 advanced_setup: false,
1729 app_packs: workspace.app_packs.clone(),
1730 extension_providers: workspace.extension_providers.clone(),
1731 remote_catalogs: workspace.remote_catalogs.clone(),
1732 setup_specs: BTreeMap::new(),
1733 setup_answers: BTreeMap::new(),
1734 setup_execution_intent: false,
1735 export_intent: false,
1736 capabilities: workspace.capabilities.clone(),
1737 })
1738}
1739
1740fn prompt_required_string<R: BufRead, W: Write>(
1741 input: &mut R,
1742 output: &mut W,
1743 title: &str,
1744 default: Option<&str>,
1745) -> Result<String> {
1746 loop {
1747 let value = prompt_optional_string(input, output, title, default)?;
1748 if !value.trim().is_empty() {
1749 return Ok(value);
1750 }
1751 writeln!(output, "{}", crate::i18n::tr("wizard.error.empty_answer"))?;
1752 }
1753}
1754
1755fn prompt_optional_string<R: BufRead, W: Write>(
1756 input: &mut R,
1757 output: &mut W,
1758 title: &str,
1759 default: Option<&str>,
1760) -> Result<String> {
1761 let default_value = default.map(|value| Value::String(value.to_string()));
1762 let value = prompt_qa_string_like(input, output, title, false, false, default_value)?;
1763 Ok(value.as_str().unwrap_or_default().to_string())
1764}
1765
1766fn edit_app_packs<R: BufRead, W: Write>(
1767 input: &mut R,
1768 output: &mut W,
1769 mut state: NormalizedRequest,
1770 allow_back: bool,
1771) -> Result<NormalizedRequest> {
1772 loop {
1773 writeln!(output, "{}", crate::i18n::tr("wizard.stage.app_packs"))?;
1774 render_pack_entries(output, &state.app_pack_entries)?;
1775 writeln!(
1776 output,
1777 "1. {}",
1778 crate::i18n::tr("wizard.action.add_app_pack")
1779 )?;
1780 writeln!(
1781 output,
1782 "2. {}",
1783 crate::i18n::tr("wizard.action.edit_app_pack_mapping")
1784 )?;
1785 writeln!(
1786 output,
1787 "3. {}",
1788 crate::i18n::tr("wizard.action.remove_app_pack")
1789 )?;
1790 writeln!(output, "4. {}", crate::i18n::tr("wizard.action.continue"))?;
1791 if allow_back {
1792 writeln!(output, "0. {}", crate::i18n::tr("wizard.action.back"))?;
1793 }
1794
1795 let answer = prompt_menu_value(input, output)?;
1796 match answer.as_str() {
1797 "1" => {
1798 if let Some(entry) = add_app_pack(input, output, &state)? {
1799 state.app_pack_entries.push(entry);
1800 state.access_rules = derive_access_rules_from_entries(&state.app_pack_entries);
1801 state = rebuild_request(state);
1802 }
1803 }
1804 "2" => {
1805 if !state.app_pack_entries.is_empty() {
1806 state = edit_pack_access(input, output, state, true)?;
1807 }
1808 }
1809 "3" => {
1810 remove_app_pack(input, output, &mut state)?;
1811 state.access_rules = derive_access_rules_from_entries(&state.app_pack_entries);
1812 state = rebuild_request(state);
1813 }
1814 "4" => {
1815 if state.app_pack_entries.is_empty() {
1816 writeln!(
1817 output,
1818 "{}",
1819 crate::i18n::tr("wizard.error.app_pack_required")
1820 )?;
1821 continue;
1822 }
1823 return Ok(state);
1824 }
1825 "0" if allow_back => return Ok(state),
1826 _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
1827 }
1828 }
1829}
1830
1831fn edit_pack_access<R: BufRead, W: Write>(
1832 input: &mut R,
1833 output: &mut W,
1834 mut state: NormalizedRequest,
1835 allow_back: bool,
1836) -> Result<NormalizedRequest> {
1837 loop {
1838 writeln!(output, "{}", crate::i18n::tr("wizard.stage.pack_access"))?;
1839 render_pack_entries(output, &state.app_pack_entries)?;
1840 writeln!(
1841 output,
1842 "1. {}",
1843 crate::i18n::tr("wizard.action.change_scope")
1844 )?;
1845 writeln!(
1846 output,
1847 "2. {}",
1848 crate::i18n::tr("wizard.action.add_tenant_access")
1849 )?;
1850 writeln!(
1851 output,
1852 "3. {}",
1853 crate::i18n::tr("wizard.action.add_tenant_team_access")
1854 )?;
1855 writeln!(
1856 output,
1857 "4. {}",
1858 crate::i18n::tr("wizard.action.remove_scope")
1859 )?;
1860 writeln!(output, "5. {}", crate::i18n::tr("wizard.action.continue"))?;
1861 writeln!(
1862 output,
1863 "6. {}",
1864 crate::i18n::tr("wizard.action.advanced_access_rules")
1865 )?;
1866 if allow_back {
1867 writeln!(output, "0. {}", crate::i18n::tr("wizard.action.back"))?;
1868 }
1869 let answer = prompt_menu_value(input, output)?;
1870 match answer.as_str() {
1871 "1" => change_pack_scope(input, output, &mut state)?,
1872 "2" => add_pack_scope(input, output, &mut state, false)?,
1873 "3" => add_pack_scope(input, output, &mut state, true)?,
1874 "4" => remove_pack_scope(input, output, &mut state)?,
1875 "5" => return Ok(rebuild_request(state)),
1876 "6" => edit_advanced_access_rules(input, output, &mut state)?,
1877 "0" if allow_back => return Ok(rebuild_request(state)),
1878 _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
1879 }
1880 state.access_rules = derive_access_rules_from_entries(&state.app_pack_entries);
1881 }
1882}
1883
1884fn edit_extension_providers<R: BufRead, W: Write>(
1885 input: &mut R,
1886 output: &mut W,
1887 mut state: NormalizedRequest,
1888 allow_back: bool,
1889) -> Result<NormalizedRequest> {
1890 loop {
1891 writeln!(
1892 output,
1893 "{}",
1894 crate::i18n::tr("wizard.stage.extension_providers")
1895 )?;
1896 render_named_entries(
1897 output,
1898 &crate::i18n::tr("wizard.stage.current_extension_providers"),
1899 &state
1900 .extension_provider_entries
1901 .iter()
1902 .map(|entry| format!("{} [{}]", entry.display_name, entry.reference))
1903 .collect::<Vec<_>>(),
1904 )?;
1905 writeln!(
1906 output,
1907 "1. {}",
1908 crate::i18n::tr("wizard.action.add_common_extension_provider")
1909 )?;
1910 writeln!(
1911 output,
1912 "2. {}",
1913 crate::i18n::tr("wizard.action.add_custom_extension_provider")
1914 )?;
1915 writeln!(
1916 output,
1917 "3. {}",
1918 crate::i18n::tr("wizard.action.remove_extension_provider")
1919 )?;
1920 writeln!(output, "4. {}", crate::i18n::tr("wizard.action.continue"))?;
1921 if allow_back {
1922 writeln!(output, "0. {}", crate::i18n::tr("wizard.action.back"))?;
1923 }
1924 let answer = prompt_menu_value(input, output)?;
1925 match answer.as_str() {
1926 "1" => {
1927 if let Some(entry) = add_common_extension_provider(input, output, &state)? {
1928 state.extension_provider_entries.push(entry);
1929 state = rebuild_request(state);
1930 }
1931 }
1932 "2" => {
1933 if let Some(entry) = add_custom_extension_provider(input, output, &state)? {
1934 state.extension_provider_entries.push(entry);
1935 state = rebuild_request(state);
1936 }
1937 }
1938 "3" => {
1939 remove_extension_provider(input, output, &mut state)?;
1940 state = rebuild_request(state);
1941 }
1942 "4" => return Ok(state),
1943 "0" if allow_back => return Ok(state),
1944 _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
1945 }
1946 }
1947}
1948
1949fn review_summary<R: BufRead, W: Write>(
1950 input: &mut R,
1951 output: &mut W,
1952 state: &NormalizedRequest,
1953 include_edit_paths: bool,
1954) -> Result<ReviewAction> {
1955 loop {
1956 writeln!(output, "{}", crate::i18n::tr("wizard.stage.review"))?;
1957 writeln!(
1958 output,
1959 "{}: {}",
1960 crate::i18n::tr("wizard.prompt.bundle_name"),
1961 state.bundle_name
1962 )?;
1963 writeln!(
1964 output,
1965 "{}: {}",
1966 crate::i18n::tr("wizard.prompt.bundle_id"),
1967 state.bundle_id
1968 )?;
1969 writeln!(
1970 output,
1971 "{}: {}",
1972 crate::i18n::tr("wizard.prompt.output_dir"),
1973 state.output_dir.display()
1974 )?;
1975 render_named_entries(
1976 output,
1977 &crate::i18n::tr("wizard.stage.current_app_packs"),
1978 &state
1979 .app_pack_entries
1980 .iter()
1981 .map(|entry| {
1982 format!(
1983 "{} [{} -> {}]",
1984 entry.display_name,
1985 entry.reference,
1986 format_mapping(&entry.mapping)
1987 )
1988 })
1989 .collect::<Vec<_>>(),
1990 )?;
1991 render_named_entries(
1992 output,
1993 &crate::i18n::tr("wizard.stage.current_access_rules"),
1994 &state
1995 .access_rules
1996 .iter()
1997 .map(format_access_rule)
1998 .collect::<Vec<_>>(),
1999 )?;
2000 render_named_entries(
2001 output,
2002 &crate::i18n::tr("wizard.stage.current_extension_providers"),
2003 &state
2004 .extension_provider_entries
2005 .iter()
2006 .map(|entry| format!("{} [{}]", entry.display_name, entry.reference))
2007 .collect::<Vec<_>>(),
2008 )?;
2009 if !state.capabilities.is_empty() {
2010 render_named_entries(
2011 output,
2012 &crate::i18n::tr("wizard.stage.capabilities"),
2013 &state.capabilities,
2014 )?;
2015 }
2016 writeln!(
2017 output,
2018 "1. {}",
2019 crate::i18n::tr("wizard.action.build_bundle")
2020 )?;
2021 writeln!(
2022 output,
2023 "2. {}",
2024 crate::i18n::tr("wizard.action.dry_run_only")
2025 )?;
2026 writeln!(
2027 output,
2028 "3. {}",
2029 crate::i18n::tr("wizard.action.save_answers_only")
2030 )?;
2031 if include_edit_paths {
2032 writeln!(output, "4. {}", crate::i18n::tr("wizard.action.finish"))?;
2033 }
2034 writeln!(output, "0. {}", crate::i18n::tr("wizard.action.back"))?;
2035 let answer = prompt_menu_value(input, output)?;
2036 match answer.as_str() {
2037 "1" => return Ok(ReviewAction::BuildNow),
2038 "2" => return Ok(ReviewAction::DryRunOnly),
2039 "3" => return Ok(ReviewAction::SaveAnswersOnly),
2040 "4" if include_edit_paths => return Ok(ReviewAction::BuildNow),
2041 "0" => return Ok(ReviewAction::DryRunOnly),
2042 _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
2043 }
2044 }
2045}
2046
2047fn prompt_menu_value<R: BufRead, W: Write>(input: &mut R, output: &mut W) -> Result<String> {
2048 write!(output, "{} ", crate::i18n::tr("wizard.setup.enum_prompt"))?;
2049 output.flush()?;
2050 let mut line = String::new();
2051 if input.read_line(&mut line)? == 0 {
2052 bail!("{}", crate::i18n::tr("wizard.error.input_ended"));
2053 }
2054 Ok(line.trim().to_string())
2055}
2056
2057fn render_named_entries<W: Write>(output: &mut W, title: &str, entries: &[String]) -> Result<()> {
2058 writeln!(output, "{title}:")?;
2059 if entries.is_empty() {
2060 writeln!(output, "- {}", crate::i18n::tr("wizard.list.none"))?;
2061 } else {
2062 for entry in entries {
2063 writeln!(output, "- {entry}")?;
2064 }
2065 }
2066 Ok(())
2067}
2068
2069#[derive(Debug, Clone)]
2070struct PackGroup {
2071 reference: String,
2072 display_name: String,
2073 scopes: Vec<AppPackMappingInput>,
2074}
2075
2076fn render_pack_entries<W: Write>(output: &mut W, entries: &[AppPackEntry]) -> Result<()> {
2077 writeln!(
2078 output,
2079 "{}",
2080 crate::i18n::tr("wizard.stage.current_app_packs")
2081 )?;
2082 let groups = group_pack_entries(entries);
2083 if groups.is_empty() {
2084 writeln!(output, "- {}", crate::i18n::tr("wizard.list.none"))?;
2085 return Ok(());
2086 }
2087 for (index, group) in groups.iter().enumerate() {
2088 writeln!(output, "{}) {}", index + 1, group.display_name)?;
2089 writeln!(
2090 output,
2091 " {}: {}",
2092 crate::i18n::tr("wizard.label.source"),
2093 group.reference
2094 )?;
2095 writeln!(
2096 output,
2097 " {}: {}",
2098 crate::i18n::tr("wizard.label.scope"),
2099 group
2100 .scopes
2101 .iter()
2102 .map(format_mapping)
2103 .collect::<Vec<_>>()
2104 .join(", ")
2105 )?;
2106 }
2107 Ok(())
2108}
2109
2110fn group_pack_entries(entries: &[AppPackEntry]) -> Vec<PackGroup> {
2111 let mut groups = Vec::<PackGroup>::new();
2112 for entry in entries {
2113 if let Some(group) = groups
2114 .iter_mut()
2115 .find(|group| group.reference == entry.reference)
2116 {
2117 group.scopes.push(entry.mapping.clone());
2118 } else {
2119 groups.push(PackGroup {
2120 reference: entry.reference.clone(),
2121 display_name: entry.display_name.clone(),
2122 scopes: vec![entry.mapping.clone()],
2123 });
2124 }
2125 }
2126 groups
2127}
2128
2129fn edit_bundle_capabilities<R: BufRead, W: Write>(
2130 input: &mut R,
2131 output: &mut W,
2132 mut state: NormalizedRequest,
2133) -> Result<NormalizedRequest> {
2134 let cap = crate::project::CAP_BUNDLE_ASSETS_READ_V1.to_string();
2135 let already_enabled = state.capabilities.contains(&cap);
2136 let default_value = if already_enabled {
2137 Some(Value::Bool(true))
2138 } else {
2139 Some(Value::Bool(false))
2140 };
2141 let answer = prompt_qa_boolean(
2142 input,
2143 output,
2144 &crate::i18n::tr("wizard.prompt.enable_bundle_assets"),
2145 false,
2146 default_value,
2147 )?;
2148 let enable = answer.as_bool().unwrap_or(false);
2149 if enable && !state.capabilities.contains(&cap) {
2150 state.capabilities.push(cap);
2151 } else if !enable {
2152 state.capabilities.retain(|c| c != &cap);
2153 }
2154 Ok(state)
2155}
2156
2157fn rebuild_request(request: NormalizedRequest) -> NormalizedRequest {
2158 normalize_request(SeedRequest {
2159 mode: request.mode,
2160 locale: request.locale,
2161 bundle_name: request.bundle_name,
2162 bundle_id: request.bundle_id,
2163 output_dir: request.output_dir,
2164 app_pack_entries: request.app_pack_entries,
2165 access_rules: request.access_rules,
2166 extension_provider_entries: request.extension_provider_entries,
2167 cloud_deployer_target: detect_cloud_deployer_target(&request.extension_providers),
2168 advanced_setup: false,
2169 app_packs: Vec::new(),
2170 extension_providers: Vec::new(),
2171 remote_catalogs: request.remote_catalogs,
2172 setup_specs: BTreeMap::new(),
2173 setup_answers: BTreeMap::new(),
2174 setup_execution_intent: false,
2175 export_intent: false,
2176 capabilities: request.capabilities,
2177 })
2178}
2179
2180fn edit_cloud_deployer_target<R: BufRead, W: Write>(
2181 input: &mut R,
2182 output: &mut W,
2183 mut state: NormalizedRequest,
2184) -> Result<NormalizedRequest> {
2185 let current = detect_cloud_deployer_target(&state.extension_providers);
2186 let labels = vec![
2187 "None".to_string(),
2188 "AWS".to_string(),
2189 "Azure".to_string(),
2190 "GCP".to_string(),
2191 ];
2192 let prompt = format!(
2193 "Choose cloud deployer target (current: {})",
2194 current.as_deref().unwrap_or("none")
2195 );
2196 let Some(index) = choose_named_index(input, output, &prompt, &labels)? else {
2197 return Ok(state);
2198 };
2199 let target = match index {
2200 1 => "aws",
2201 2 => "azure",
2202 3 => "gcp",
2203 _ => "",
2204 };
2205 apply_cloud_deployer_target_to_entries(&mut state.extension_provider_entries, target);
2206 Ok(rebuild_request(state))
2207}
2208
2209fn apply_cloud_deployer_target_to_entries(entries: &mut Vec<ExtensionProviderEntry>, target: &str) {
2210 entries.retain(|entry| !is_cloud_deployer_entry(entry));
2211 if let Some(entry) = cloud_deployer_entry_for_target(target) {
2212 entries.push(entry);
2213 }
2214}
2215
2216fn cloud_deployer_entry_for_target(target: &str) -> Option<ExtensionProviderEntry> {
2217 let (provider_id, display_name, reference) = match target.trim() {
2218 "aws" => ("deployer-aws", "AWS Deployer", DEPLOYER_AWS_REF),
2219 "azure" => ("deployer-azure", "Azure Deployer", DEPLOYER_AZURE_REF),
2220 "gcp" => ("deployer-gcp", "GCP Deployer", DEPLOYER_GCP_REF),
2221 _ => return None,
2222 };
2223 Some(ExtensionProviderEntry {
2224 reference: reference.to_string(),
2225 detected_kind: "catalog".to_string(),
2226 provider_id: provider_id.to_string(),
2227 display_name: display_name.to_string(),
2228 version: inferred_reference_version(reference),
2229 source_catalog: Some(DEFAULT_PROVIDER_REGISTRY.to_string()),
2230 group: Some("deployer".to_string()),
2231 })
2232}
2233
2234fn is_cloud_deployer_entry(entry: &ExtensionProviderEntry) -> bool {
2235 matches!(
2236 entry.provider_id.as_str(),
2237 "deployer-aws" | "deployer-azure" | "deployer-gcp"
2238 ) || detect_cloud_deployer_target(std::slice::from_ref(&entry.reference)).is_some()
2239}
2240
2241fn detect_cloud_deployer_target(references: &[String]) -> Option<String> {
2242 for reference in references {
2243 let normalized = reference.trim().to_ascii_lowercase();
2244 if normalized.contains("greentic.deploy.aws") || normalized.ends_with("/aws.gtpack") {
2245 return Some("aws".to_string());
2246 }
2247 if normalized.contains("greentic.deploy.azure") || normalized.ends_with("/azure.gtpack") {
2248 return Some("azure".to_string());
2249 }
2250 if normalized.contains("greentic.deploy.gcp") || normalized.ends_with("/gcp.gtpack") {
2251 return Some("gcp".to_string());
2252 }
2253 }
2254 None
2255}
2256
2257fn format_mapping(mapping: &AppPackMappingInput) -> String {
2258 match mapping.scope.as_str() {
2259 "tenant" => format!("tenant:{}", mapping.tenant.clone().unwrap_or_default()),
2260 "tenant_team" => format!(
2261 "tenant/team:{}/{}",
2262 mapping.tenant.clone().unwrap_or_default(),
2263 mapping.team.clone().unwrap_or_default()
2264 ),
2265 _ => "global".to_string(),
2266 }
2267}
2268
2269fn format_access_rule(rule: &AccessRuleInput) -> String {
2270 match &rule.team {
2271 Some(team) => format!(
2272 "{}/{team}: {} = {}",
2273 rule.tenant, rule.rule_path, rule.policy
2274 ),
2275 None => format!("{}: {} = {}", rule.tenant, rule.rule_path, rule.policy),
2276 }
2277}
2278
2279fn derive_access_rules_from_entries(entries: &[AppPackEntry]) -> Vec<AccessRuleInput> {
2280 normalize_access_rules(
2281 entries
2282 .iter()
2283 .map(|entry| match entry.mapping.scope.as_str() {
2284 "tenant" => AccessRuleInput {
2285 rule_path: entry.pack_id.clone(),
2286 policy: "public".to_string(),
2287 tenant: entry
2288 .mapping
2289 .tenant
2290 .clone()
2291 .unwrap_or_else(|| "default".to_string()),
2292 team: None,
2293 },
2294 "tenant_team" => AccessRuleInput {
2295 rule_path: entry.pack_id.clone(),
2296 policy: "public".to_string(),
2297 tenant: entry
2298 .mapping
2299 .tenant
2300 .clone()
2301 .unwrap_or_else(|| "default".to_string()),
2302 team: entry.mapping.team.clone(),
2303 },
2304 _ => AccessRuleInput {
2305 rule_path: entry.pack_id.clone(),
2306 policy: "public".to_string(),
2307 tenant: "default".to_string(),
2308 team: None,
2309 },
2310 })
2311 .collect(),
2312 )
2313}
2314
2315fn choose_pack_group_index<R: BufRead, W: Write>(
2316 input: &mut R,
2317 output: &mut W,
2318 entries: &[AppPackEntry],
2319) -> Result<Option<usize>> {
2320 let groups = group_pack_entries(entries);
2321 choose_named_index(
2322 input,
2323 output,
2324 &crate::i18n::tr("wizard.prompt.choose_app_pack"),
2325 &groups
2326 .iter()
2327 .map(|group| format!("{} [{}]", group.display_name, group.reference))
2328 .collect::<Vec<_>>(),
2329 )
2330}
2331
2332fn change_pack_scope<R: BufRead, W: Write>(
2333 input: &mut R,
2334 output: &mut W,
2335 state: &mut NormalizedRequest,
2336) -> Result<()> {
2337 let Some(group_index) = choose_pack_group_index(input, output, &state.app_pack_entries)? else {
2338 return Ok(());
2339 };
2340 let groups = group_pack_entries(&state.app_pack_entries);
2341 let group = &groups[group_index];
2342 let template = state
2343 .app_pack_entries
2344 .iter()
2345 .find(|entry| entry.reference == group.reference)
2346 .cloned()
2347 .ok_or_else(|| anyhow::anyhow!("missing pack entry template"))?;
2348 let mapping = prompt_app_pack_mapping(input, output, &template.pack_id)?;
2349 state
2350 .app_pack_entries
2351 .retain(|entry| entry.reference != group.reference);
2352 let mut replacement = template;
2353 replacement.mapping = mapping;
2354 state.app_pack_entries.push(replacement);
2355 Ok(())
2356}
2357
2358fn add_pack_scope<R: BufRead, W: Write>(
2359 input: &mut R,
2360 output: &mut W,
2361 state: &mut NormalizedRequest,
2362 include_team: bool,
2363) -> Result<()> {
2364 let Some(group_index) = choose_pack_group_index(input, output, &state.app_pack_entries)? else {
2365 return Ok(());
2366 };
2367 let groups = group_pack_entries(&state.app_pack_entries);
2368 let group = &groups[group_index];
2369 let template = state
2370 .app_pack_entries
2371 .iter()
2372 .find(|entry| entry.reference == group.reference)
2373 .cloned()
2374 .ok_or_else(|| anyhow::anyhow!("missing pack entry template"))?;
2375 let mapping = if include_team {
2376 let tenant = prompt_required_string(
2377 input,
2378 output,
2379 &crate::i18n::tr("wizard.prompt.tenant_id"),
2380 Some("default"),
2381 )?;
2382 let team = prompt_required_string(
2383 input,
2384 output,
2385 &crate::i18n::tr("wizard.prompt.team_id"),
2386 None,
2387 )?;
2388 AppPackMappingInput {
2389 scope: "tenant_team".to_string(),
2390 tenant: Some(tenant),
2391 team: Some(team),
2392 }
2393 } else {
2394 let tenant = prompt_required_string(
2395 input,
2396 output,
2397 &crate::i18n::tr("wizard.prompt.tenant_id"),
2398 Some("default"),
2399 )?;
2400 AppPackMappingInput {
2401 scope: "tenant".to_string(),
2402 tenant: Some(tenant),
2403 team: None,
2404 }
2405 };
2406 if state
2407 .app_pack_entries
2408 .iter()
2409 .any(|entry| entry.reference == group.reference && entry.mapping == mapping)
2410 {
2411 return Ok(());
2412 }
2413 let mut addition = template;
2414 addition.mapping = mapping;
2415 state.app_pack_entries.push(addition);
2416 Ok(())
2417}
2418
2419fn remove_pack_scope<R: BufRead, W: Write>(
2420 input: &mut R,
2421 output: &mut W,
2422 state: &mut NormalizedRequest,
2423) -> Result<()> {
2424 let groups = group_pack_entries(&state.app_pack_entries);
2425 let Some(group_index) = choose_pack_group_index(input, output, &state.app_pack_entries)? else {
2426 return Ok(());
2427 };
2428 let group = &groups[group_index];
2429 let Some(scope_index) = choose_named_index(
2430 input,
2431 output,
2432 &crate::i18n::tr("wizard.prompt.choose_scope"),
2433 &group.scopes.iter().map(format_mapping).collect::<Vec<_>>(),
2434 )?
2435 else {
2436 return Ok(());
2437 };
2438 let target_scope = &group.scopes[scope_index];
2439 state
2440 .app_pack_entries
2441 .retain(|entry| !(entry.reference == group.reference && &entry.mapping == target_scope));
2442 Ok(())
2443}
2444
2445fn edit_advanced_access_rules<R: BufRead, W: Write>(
2446 input: &mut R,
2447 output: &mut W,
2448 state: &mut NormalizedRequest,
2449) -> Result<()> {
2450 writeln!(
2451 output,
2452 "{}",
2453 crate::i18n::tr("wizard.stage.advanced_access_rules")
2454 )?;
2455 render_named_entries(
2456 output,
2457 &crate::i18n::tr("wizard.stage.current_access_rules"),
2458 &state
2459 .access_rules
2460 .iter()
2461 .map(format_access_rule)
2462 .collect::<Vec<_>>(),
2463 )?;
2464 writeln!(
2465 output,
2466 "1. {}",
2467 crate::i18n::tr("wizard.action.add_allow_rule")
2468 )?;
2469 writeln!(
2470 output,
2471 "2. {}",
2472 crate::i18n::tr("wizard.action.remove_rule")
2473 )?;
2474 writeln!(
2475 output,
2476 "3. {}",
2477 crate::i18n::tr("wizard.action.return_simple_mode")
2478 )?;
2479 loop {
2480 match prompt_menu_value(input, output)?.as_str() {
2481 "1" => add_manual_access_rule(input, output, state, "public")?,
2482 "2" => remove_access_rule(input, output, state)?,
2483 "3" => return Ok(()),
2484 _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
2485 }
2486 state.access_rules = normalize_access_rules(state.access_rules.clone());
2487 }
2488}
2489
2490fn add_app_pack<R: BufRead, W: Write>(
2491 input: &mut R,
2492 output: &mut W,
2493 state: &NormalizedRequest,
2494) -> Result<Option<AppPackEntry>> {
2495 loop {
2496 let raw = prompt_required_string(
2497 input,
2498 output,
2499 &crate::i18n::tr("wizard.prompt.app_pack_reference"),
2500 None,
2501 )?;
2502 let resolved = match resolve_reference_metadata(&state.output_dir, &raw) {
2503 Ok(resolved) => resolved,
2504 Err(error) => {
2505 writeln!(output, "{error}")?;
2506 continue;
2507 }
2508 };
2509 writeln!(output, "{}", crate::i18n::tr("wizard.confirm.app_pack"))?;
2510 writeln!(
2511 output,
2512 "{}: {}",
2513 crate::i18n::tr("wizard.label.pack_id"),
2514 resolved.id
2515 )?;
2516 writeln!(
2517 output,
2518 "{}: {}",
2519 crate::i18n::tr("wizard.label.name"),
2520 resolved.display_name
2521 )?;
2522 if let Some(version) = &resolved.version {
2523 writeln!(
2524 output,
2525 "{}: {}",
2526 crate::i18n::tr("wizard.label.version"),
2527 version
2528 )?;
2529 }
2530 writeln!(
2531 output,
2532 "{}: {}",
2533 crate::i18n::tr("wizard.label.source"),
2534 resolved.reference
2535 )?;
2536 writeln!(
2537 output,
2538 "1. {}",
2539 crate::i18n::tr("wizard.action.add_this_app_pack")
2540 )?;
2541 writeln!(
2542 output,
2543 "2. {}",
2544 crate::i18n::tr("wizard.action.reenter_reference")
2545 )?;
2546 writeln!(output, "0. {}", crate::i18n::tr("wizard.action.back"))?;
2547 match prompt_menu_value(input, output)?.as_str() {
2548 "1" => {
2549 let mapping = prompt_app_pack_mapping(input, output, &resolved.id)?;
2550 return Ok(Some(AppPackEntry {
2551 reference: resolved.reference,
2552 detected_kind: resolved.detected_kind,
2553 pack_id: resolved.id,
2554 display_name: resolved.display_name,
2555 version: resolved.version,
2556 mapping,
2557 }));
2558 }
2559 "2" => continue,
2560 "0" => return Ok(None),
2561 _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
2562 }
2563 }
2564}
2565
2566fn remove_app_pack<R: BufRead, W: Write>(
2567 input: &mut R,
2568 output: &mut W,
2569 state: &mut NormalizedRequest,
2570) -> Result<()> {
2571 let Some(index) = choose_named_index(
2572 input,
2573 output,
2574 &crate::i18n::tr("wizard.prompt.choose_app_pack"),
2575 &state
2576 .app_pack_entries
2577 .iter()
2578 .map(|entry| format!("{} [{}]", entry.display_name, entry.reference))
2579 .collect::<Vec<_>>(),
2580 )?
2581 else {
2582 return Ok(());
2583 };
2584 state.app_pack_entries.remove(index);
2585 Ok(())
2586}
2587
2588fn prompt_app_pack_mapping<R: BufRead, W: Write>(
2589 input: &mut R,
2590 output: &mut W,
2591 pack_id: &str,
2592) -> Result<AppPackMappingInput> {
2593 writeln!(output, "{}", crate::i18n::tr("wizard.stage.map_app_pack"))?;
2594 writeln!(output, "{}", pack_id)?;
2595 writeln!(output, "1. {}", crate::i18n::tr("wizard.mapping.global"))?;
2596 writeln!(output, "2. {}", crate::i18n::tr("wizard.mapping.tenant"))?;
2597 writeln!(
2598 output,
2599 "3. {}",
2600 crate::i18n::tr("wizard.mapping.tenant_team")
2601 )?;
2602 writeln!(output, "0. {}", crate::i18n::tr("wizard.action.back"))?;
2603 loop {
2604 match prompt_menu_value(input, output)?.as_str() {
2605 "1" => {
2606 return Ok(AppPackMappingInput {
2607 scope: "global".to_string(),
2608 tenant: None,
2609 team: None,
2610 });
2611 }
2612 "2" => {
2613 let tenant = prompt_required_string(
2614 input,
2615 output,
2616 &crate::i18n::tr("wizard.prompt.tenant_id"),
2617 Some("default"),
2618 )?;
2619 return Ok(AppPackMappingInput {
2620 scope: "tenant".to_string(),
2621 tenant: Some(tenant),
2622 team: None,
2623 });
2624 }
2625 "3" => {
2626 let tenant = prompt_required_string(
2627 input,
2628 output,
2629 &crate::i18n::tr("wizard.prompt.tenant_id"),
2630 Some("default"),
2631 )?;
2632 let team = prompt_required_string(
2633 input,
2634 output,
2635 &crate::i18n::tr("wizard.prompt.team_id"),
2636 None,
2637 )?;
2638 return Ok(AppPackMappingInput {
2639 scope: "tenant_team".to_string(),
2640 tenant: Some(tenant),
2641 team: Some(team),
2642 });
2643 }
2644 "0" => {
2645 return Ok(AppPackMappingInput {
2646 scope: "global".to_string(),
2647 tenant: None,
2648 team: None,
2649 });
2650 }
2651 _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
2652 }
2653 }
2654}
2655
2656fn add_manual_access_rule<R: BufRead, W: Write>(
2657 input: &mut R,
2658 output: &mut W,
2659 state: &mut NormalizedRequest,
2660 policy: &str,
2661) -> Result<()> {
2662 let target = prompt_access_target(input, output)?;
2663 let rule_path = prompt_required_string(
2664 input,
2665 output,
2666 &crate::i18n::tr("wizard.prompt.rule_path"),
2667 None,
2668 )?;
2669 state.access_rules.push(AccessRuleInput {
2670 rule_path,
2671 policy: policy.to_string(),
2672 tenant: target.0,
2673 team: target.1,
2674 });
2675 Ok(())
2676}
2677
2678fn remove_access_rule<R: BufRead, W: Write>(
2679 input: &mut R,
2680 output: &mut W,
2681 state: &mut NormalizedRequest,
2682) -> Result<()> {
2683 let Some(index) = choose_named_index(
2684 input,
2685 output,
2686 &crate::i18n::tr("wizard.prompt.choose_access_rule"),
2687 &state
2688 .access_rules
2689 .iter()
2690 .map(format_access_rule)
2691 .collect::<Vec<_>>(),
2692 )?
2693 else {
2694 return Ok(());
2695 };
2696 state.access_rules.remove(index);
2697 Ok(())
2698}
2699
2700fn prompt_access_target<R: BufRead, W: Write>(
2701 input: &mut R,
2702 output: &mut W,
2703) -> Result<(String, Option<String>)> {
2704 writeln!(output, "1. {}", crate::i18n::tr("wizard.mapping.global"))?;
2705 writeln!(output, "2. {}", crate::i18n::tr("wizard.mapping.tenant"))?;
2706 writeln!(
2707 output,
2708 "3. {}",
2709 crate::i18n::tr("wizard.mapping.tenant_team")
2710 )?;
2711 loop {
2712 match prompt_menu_value(input, output)?.as_str() {
2713 "1" => return Ok(("default".to_string(), None)),
2714 "2" => {
2715 let tenant = prompt_required_string(
2716 input,
2717 output,
2718 &crate::i18n::tr("wizard.prompt.tenant_id"),
2719 Some("default"),
2720 )?;
2721 return Ok((tenant, None));
2722 }
2723 "3" => {
2724 let tenant = prompt_required_string(
2725 input,
2726 output,
2727 &crate::i18n::tr("wizard.prompt.tenant_id"),
2728 Some("default"),
2729 )?;
2730 let team = prompt_required_string(
2731 input,
2732 output,
2733 &crate::i18n::tr("wizard.prompt.team_id"),
2734 None,
2735 )?;
2736 return Ok((tenant, Some(team)));
2737 }
2738 _ => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
2739 }
2740 }
2741}
2742
2743fn resolve_extension_provider_catalog(
2751 output_dir: &Path,
2752 remote_catalogs: &[String],
2753) -> Result<(
2754 bool,
2755 Option<String>,
2756 Vec<crate::catalog::registry::CatalogEntry>,
2757)> {
2758 if let Some(catalog_ref) = remote_catalogs.first() {
2760 let resolution = crate::catalog::resolve::resolve_catalogs(
2761 output_dir,
2762 std::slice::from_ref(catalog_ref),
2763 &crate::catalog::resolve::CatalogResolveOptions {
2764 offline: crate::runtime::offline(),
2765 write_cache: false,
2766 },
2767 )?;
2768 return Ok((true, Some(catalog_ref.clone()), resolution.discovered_items));
2769 }
2770
2771 let use_bundled_only = std::env::var("GREENTIC_BUNDLE_USE_BUNDLED_CATALOG")
2773 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
2774 .unwrap_or(false);
2775
2776 if !crate::runtime::offline() && !use_bundled_only {
2778 let catalog_ref = DEFAULT_PROVIDER_REGISTRY.to_string();
2779 match crate::catalog::resolve::resolve_catalogs(
2780 output_dir,
2781 std::slice::from_ref(&catalog_ref),
2782 &crate::catalog::resolve::CatalogResolveOptions {
2783 offline: false,
2784 write_cache: false,
2785 },
2786 ) {
2787 Ok(resolution) if !resolution.discovered_items.is_empty() => {
2788 return Ok((true, Some(catalog_ref), resolution.discovered_items));
2789 }
2790 _ => {
2791 }
2793 }
2794 }
2795
2796 let entries = crate::catalog::registry::bundled_provider_registry_entries()?;
2798 Ok((false, None, entries))
2799}
2800
2801fn add_common_extension_provider<R: BufRead, W: Write>(
2802 input: &mut R,
2803 output: &mut W,
2804 state: &NormalizedRequest,
2805) -> Result<Option<ExtensionProviderEntry>> {
2806 let (persist_catalog_ref, catalog_ref, entries) =
2807 resolve_extension_provider_catalog(&state.output_dir, &state.remote_catalogs)?;
2808 if entries.is_empty() {
2809 writeln!(output, "{}", crate::i18n::tr("wizard.error.empty_catalog"))?;
2810 return Ok(None);
2811 }
2812 let grouped_entries = group_catalog_entries_by_category(&entries);
2813 let category_key = if grouped_entries.len() > 1 {
2814 let labels = grouped_entries
2815 .iter()
2816 .map(|(category_id, category_label, description, _)| {
2817 let display_name = category_label.as_deref().unwrap_or(category_id);
2819 format_extension_category_label(display_name, description.as_deref())
2820 })
2821 .collect::<Vec<_>>();
2822 let Some(index) = choose_named_index(input, output, "Choose extension category", &labels)?
2823 else {
2824 return Ok(None);
2825 };
2826 Some(grouped_entries[index].0.clone())
2827 } else {
2828 None
2829 };
2830 let selected_entries = category_key
2831 .as_deref()
2832 .map(|category| {
2833 entries
2834 .iter()
2835 .filter(|entry| entry.category.as_deref().unwrap_or("other") == category)
2836 .collect::<Vec<_>>()
2837 })
2838 .unwrap_or_else(|| entries.iter().collect::<Vec<_>>());
2839 let options = build_extension_provider_options(&selected_entries);
2840 let labels = options
2841 .iter()
2842 .map(|option| option.display_name.clone())
2843 .collect::<Vec<_>>();
2844 let Some(index) = choose_named_index(
2845 input,
2846 output,
2847 &crate::i18n::tr("wizard.prompt.choose_extension_provider"),
2848 &labels,
2849 )?
2850 else {
2851 return Ok(None);
2852 };
2853 let selected = &options[index];
2854 let entry = selected.entry;
2855 let reference = resolve_catalog_entry_reference(input, output, &entry.reference)?;
2856 Ok(Some(ExtensionProviderEntry {
2857 detected_kind: detected_reference_kind(&state.output_dir, &reference).to_string(),
2858 reference,
2859 provider_id: entry.id.clone(),
2860 display_name: selected.display_name.clone(),
2861 version: inferred_reference_version(&entry.reference),
2862 source_catalog: if persist_catalog_ref {
2863 catalog_ref
2864 } else {
2865 None
2866 },
2867 group: None,
2868 }))
2869}
2870
2871fn build_extension_provider_options<'a>(
2872 entries: &'a [&'a crate::catalog::registry::CatalogEntry],
2873) -> Vec<ResolvedExtensionProviderOption<'a>> {
2874 let mut options = Vec::<ResolvedExtensionProviderOption<'a>>::new();
2875 for entry in entries {
2876 let display_name = clean_extension_provider_label(entry);
2877 if options
2878 .iter()
2879 .any(|existing| existing.display_name == display_name)
2880 {
2881 continue;
2882 }
2883 options.push(ResolvedExtensionProviderOption {
2884 entry,
2885 display_name,
2886 });
2887 }
2888 options
2889}
2890
2891#[derive(Clone)]
2892struct ResolvedExtensionProviderOption<'a> {
2893 entry: &'a crate::catalog::registry::CatalogEntry,
2894 display_name: String,
2895}
2896
2897fn clean_extension_provider_label(entry: &crate::catalog::registry::CatalogEntry) -> String {
2898 let raw = entry
2899 .label
2900 .clone()
2901 .unwrap_or_else(|| inferred_display_name(&entry.reference));
2902 let trimmed = raw.trim();
2903 for suffix in [" (latest)", " (Latest)", " (LATEST)"] {
2904 if let Some(base) = trimmed.strip_suffix(suffix) {
2905 return base.trim().to_string();
2906 }
2907 }
2908 trimmed.to_string()
2909}
2910
2911type CategoryGroup = (String, Option<String>, Option<String>, Vec<usize>);
2913
2914fn group_catalog_entries_by_category(
2915 entries: &[crate::catalog::registry::CatalogEntry],
2916) -> Vec<CategoryGroup> {
2917 let mut grouped = Vec::<CategoryGroup>::new();
2918 for (index, entry) in entries.iter().enumerate() {
2919 let category = entry
2920 .category
2921 .clone()
2922 .unwrap_or_else(|| "other".to_string());
2923 let label = entry.category_label.clone();
2924 let description = entry.category_description.clone();
2925 if let Some((_, existing_label, existing_description, indices)) =
2926 grouped.iter_mut().find(|(name, _, _, _)| name == &category)
2927 {
2928 if existing_label.is_none() {
2929 *existing_label = label.clone();
2930 }
2931 if existing_description.is_none() {
2932 *existing_description = description.clone();
2933 }
2934 indices.push(index);
2935 } else {
2936 grouped.push((category, label, description, vec![index]));
2937 }
2938 }
2939 grouped
2940}
2941
2942fn format_extension_category_label(category: &str, description: Option<&str>) -> String {
2943 match description
2944 .map(str::trim)
2945 .filter(|description| !description.is_empty())
2946 {
2947 Some(description) => format!("{category} -> {description}"),
2948 None => category.to_string(),
2949 }
2950}
2951
2952fn add_custom_extension_provider<R: BufRead, W: Write>(
2953 input: &mut R,
2954 output: &mut W,
2955 state: &NormalizedRequest,
2956) -> Result<Option<ExtensionProviderEntry>> {
2957 loop {
2958 let raw = prompt_required_string(
2959 input,
2960 output,
2961 &crate::i18n::tr("wizard.prompt.extension_provider_reference"),
2962 None,
2963 )?;
2964 let resolved = match resolve_reference_metadata(&state.output_dir, &raw) {
2965 Ok(resolved) => resolved,
2966 Err(error) => {
2967 writeln!(output, "{error}")?;
2968 continue;
2969 }
2970 };
2971 return Ok(Some(ExtensionProviderEntry {
2972 reference: resolved.reference,
2973 detected_kind: resolved.detected_kind,
2974 provider_id: resolved.id.clone(),
2975 display_name: resolved.display_name,
2976 version: resolved.version,
2977 source_catalog: None,
2978 group: None,
2979 }));
2980 }
2981}
2982
2983fn remove_extension_provider<R: BufRead, W: Write>(
2984 input: &mut R,
2985 output: &mut W,
2986 state: &mut NormalizedRequest,
2987) -> Result<()> {
2988 let Some(index) = choose_named_index(
2989 input,
2990 output,
2991 &crate::i18n::tr("wizard.prompt.choose_extension_provider"),
2992 &state
2993 .extension_provider_entries
2994 .iter()
2995 .map(|entry| format!("{} [{}]", entry.display_name, entry.reference))
2996 .collect::<Vec<_>>(),
2997 )?
2998 else {
2999 return Ok(());
3000 };
3001 state.extension_provider_entries.remove(index);
3002 Ok(())
3003}
3004
3005fn choose_named_index<R: BufRead, W: Write>(
3006 input: &mut R,
3007 output: &mut W,
3008 title: &str,
3009 entries: &[String],
3010) -> Result<Option<usize>> {
3011 if entries.is_empty() {
3012 return Ok(None);
3013 }
3014 writeln!(output, "{title}:")?;
3015 for (index, entry) in entries.iter().enumerate() {
3016 writeln!(output, "{}. {}", index + 1, entry)?;
3017 }
3018 writeln!(output, "0. {}", crate::i18n::tr("wizard.action.back"))?;
3019 loop {
3020 let answer = prompt_menu_value(input, output)?;
3021 if answer == "0" {
3022 return Ok(None);
3023 }
3024 if let Ok(index) = answer.parse::<usize>()
3025 && index > 0
3026 && index <= entries.len()
3027 {
3028 return Ok(Some(index - 1));
3029 }
3030 writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?;
3031 }
3032}
3033
3034struct ResolvedReferenceMetadata {
3035 reference: String,
3036 detected_kind: String,
3037 id: String,
3038 display_name: String,
3039 version: Option<String>,
3040}
3041
3042fn resolve_reference_metadata(root: &Path, raw: &str) -> Result<ResolvedReferenceMetadata> {
3043 let raw = raw.trim();
3044 if raw.is_empty() {
3045 bail!("{}", crate::i18n::tr("wizard.error.empty_answer"));
3046 }
3047 validate_reference_input(root, raw)?;
3048 let detected_kind = detected_reference_kind(root, raw).to_string();
3049 Ok(ResolvedReferenceMetadata {
3050 id: inferred_reference_id(raw),
3051 display_name: inferred_display_name(raw),
3052 version: inferred_reference_version(raw),
3053 reference: raw.to_string(),
3054 detected_kind,
3055 })
3056}
3057
3058fn resolve_catalog_entry_reference<R: BufRead, W: Write>(
3059 input: &mut R,
3060 output: &mut W,
3061 raw: &str,
3062) -> Result<String> {
3063 if !raw.contains("<pr-version>") {
3064 return Ok(raw.to_string());
3065 }
3066 let version = prompt_required_string(input, output, "PR version or tag", None)?;
3067 Ok(raw.replace("<pr-version>", version.trim()))
3068}
3069
3070fn validate_reference_input(root: &Path, raw: &str) -> Result<()> {
3071 if raw.contains("<pr-version>") {
3072 bail!("Reference contains an unresolved <pr-version> placeholder.");
3073 }
3074 if let Some(path) = parse_local_gtpack_reference(root, raw) {
3075 let metadata = fs::metadata(&path)
3076 .with_context(|| format!("read local .gtpack {}", path.display()))?;
3077 if !metadata.is_file() {
3078 bail!(
3079 "Local .gtpack reference must point to a file: {}",
3080 path.display()
3081 );
3082 }
3083 }
3084 Ok(())
3085}
3086
3087fn parse_local_gtpack_reference(root: &Path, raw: &str) -> Option<PathBuf> {
3088 if let Some(path) = raw.strip_prefix("file://") {
3089 let path = PathBuf::from(path.trim());
3090 return Some(path);
3091 }
3092 if raw.contains("://") || !raw.ends_with(".gtpack") {
3093 return None;
3094 }
3095 let candidate = PathBuf::from(raw);
3096 Some(if candidate.is_absolute() {
3097 candidate
3098 } else {
3099 root.join(candidate)
3100 })
3101}
3102
3103fn detected_reference_kind(root: &Path, raw: &str) -> &'static str {
3104 if raw.starts_with("file://") {
3105 return "file_uri";
3106 }
3107 if raw.starts_with("https://") {
3108 return "https";
3109 }
3110 if raw.starts_with("oci://") {
3111 return "oci";
3112 }
3113 if raw.starts_with("repo://") {
3114 return "repo";
3115 }
3116 if raw.starts_with("store://") {
3117 return "store";
3118 }
3119 if raw.contains("://") {
3120 return "unknown";
3121 }
3122 let path = PathBuf::from(raw);
3123 let resolved = if path.is_absolute() {
3124 path
3125 } else {
3126 root.join(&path)
3127 };
3128 if resolved.is_dir() {
3129 "local_dir"
3130 } else {
3131 "local_file"
3132 }
3133}
3134
3135fn inferred_reference_id(raw: &str) -> String {
3136 let cleaned = raw
3137 .trim_end_matches('/')
3138 .rsplit('/')
3139 .next()
3140 .unwrap_or(raw)
3141 .split('@')
3142 .next()
3143 .unwrap_or(raw)
3144 .split(':')
3145 .next()
3146 .unwrap_or(raw)
3147 .trim_end_matches(".json")
3148 .trim_end_matches(".gtpack")
3149 .trim_end_matches(".yaml")
3150 .trim_end_matches(".yml");
3151 normalize_bundle_id(cleaned)
3152}
3153
3154fn inferred_display_name(raw: &str) -> String {
3155 inferred_reference_id(raw)
3156 .split('-')
3157 .filter(|part| !part.is_empty())
3158 .map(|part| {
3159 let mut chars = part.chars();
3160 match chars.next() {
3161 Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
3162 None => String::new(),
3163 }
3164 })
3165 .collect::<Vec<_>>()
3166 .join(" ")
3167}
3168
3169fn inferred_reference_version(raw: &str) -> Option<String> {
3170 raw.split('@').nth(1).map(ToOwned::to_owned).or_else(|| {
3171 raw.rsplit_once(':')
3172 .and_then(|(_, version)| (!version.contains('/')).then(|| version.to_string()))
3173 })
3174}
3175
3176fn load_and_normalize_answers(
3177 path: &Path,
3178 mode_override: Option<WizardMode>,
3179 schema_version: Option<&str>,
3180 migrate: bool,
3181 locale: &str,
3182) -> Result<LoadedRequest> {
3183 let raw = fs::read_to_string(path)
3184 .with_context(|| format!("failed to read answers file {}", path.display()))?;
3185 let value: Value = serde_json::from_str(&raw).map_err(|_| {
3186 anyhow::anyhow!(crate::i18n::trf(
3187 "errors.answer_document.invalid_json",
3188 &[("path", &path.display().to_string())],
3189 ))
3190 })?;
3191 let document = parse_answer_document(value, schema_version, migrate, locale)?;
3192 let locks = document.locks.clone();
3193 let build_bundle_now = answer_document_requests_bundle_build(&document);
3194 let request = normalized_request_from_document(document, mode_override)?;
3195 let request = NormalizedRequest {
3196 local_reference_base_dir: Some(answer_reference_base_dir(path)?),
3197 ..request
3198 };
3199 Ok(LoadedRequest {
3200 request,
3201 locks,
3202 build_bundle_now,
3203 })
3204}
3205
3206fn answer_reference_base_dir(path: &Path) -> Result<PathBuf> {
3207 let base = path
3208 .parent()
3209 .filter(|parent| !parent.as_os_str().is_empty());
3210 match base {
3211 Some(parent) => parent
3212 .canonicalize()
3213 .with_context(|| format!("canonicalize answers parent {}", parent.display())),
3214 None => std::env::current_dir().context("resolve current working directory for answers"),
3215 }
3216}
3217
3218fn reference_roots_for_apply(request: &NormalizedRequest) -> Result<Vec<PathBuf>> {
3219 let mut roots = Vec::new();
3220 if let Some(base_dir) = &request.local_reference_base_dir {
3221 roots.push(base_dir.clone());
3222 }
3223 let current_dir = std::env::current_dir().context("resolve current working directory")?;
3224 if !roots.iter().any(|root| root == ¤t_dir) {
3225 roots.push(current_dir);
3226 }
3227 Ok(roots)
3228}
3229
3230fn answer_document_requests_bundle_build(document: &AnswerDocument) -> bool {
3231 matches!(
3232 document.locks.get("execution").and_then(Value::as_str),
3233 Some("execute")
3234 )
3235}
3236
3237fn parse_answer_document(
3238 value: Value,
3239 schema_version: Option<&str>,
3240 migrate: bool,
3241 locale: &str,
3242) -> Result<AnswerDocument> {
3243 let object = value
3244 .as_object()
3245 .cloned()
3246 .ok_or_else(|| anyhow::anyhow!(crate::i18n::tr("errors.answer_document.invalid_root")))?;
3247
3248 let has_metadata = object.contains_key("wizard_id")
3249 || object.contains_key("schema_id")
3250 || object.contains_key("schema_version")
3251 || object.contains_key("locale");
3252
3253 let document = if has_metadata {
3254 let document: AnswerDocument =
3255 serde_json::from_value(Value::Object(object)).map_err(|_| {
3256 anyhow::anyhow!(crate::i18n::tr("errors.answer_document.invalid_document"))
3257 })?;
3258 document.validate()?;
3259 document
3260 } else if migrate {
3261 let mut document = AnswerDocument::new(locale);
3262 if let Some(Value::Object(answers)) = object.get("answers") {
3263 document.answers = answers
3264 .iter()
3265 .map(|(key, value)| (key.clone(), value.clone()))
3266 .collect();
3267 } else {
3268 document.answers = object
3269 .iter()
3270 .filter(|(key, _)| key.as_str() != "locks")
3271 .map(|(key, value)| (key.clone(), value.clone()))
3272 .collect();
3273 }
3274 if let Some(Value::Object(locks)) = object.get("locks") {
3275 document.locks = locks
3276 .iter()
3277 .map(|(key, value)| (key.clone(), value.clone()))
3278 .collect();
3279 }
3280 document
3281 } else {
3282 bail!(
3283 "{}",
3284 crate::i18n::tr("errors.answer_document.metadata_missing")
3285 );
3286 };
3287
3288 if document.schema_id != ANSWER_SCHEMA_ID {
3289 bail!(
3290 "{}",
3291 crate::i18n::tr("errors.answer_document.schema_id_mismatch")
3292 );
3293 }
3294
3295 let target_version = requested_schema_version(schema_version)?;
3296 let migrated = migrate_document(document, &target_version)?;
3297 if migrated.migrated && !migrate {
3298 bail!(
3299 "{}",
3300 crate::i18n::tr("errors.answer_document.migrate_required")
3301 );
3302 }
3303 Ok(migrated.document)
3304}
3305
3306fn normalized_request_from_document(
3307 document: AnswerDocument,
3308 mode_override: Option<WizardMode>,
3309) -> Result<NormalizedRequest> {
3310 let answers = normalized_answers_from_document(&document.answers)?;
3311 let mode = match mode_override {
3312 Some(mode) => mode,
3313 None => mode_from_answers(&answers)?,
3314 };
3315 let bundle_name = required_string(&answers, "bundle_name")?;
3316 let bundle_id = required_string(&answers, "bundle_id")?;
3317 let normalized_bundle_id = normalize_bundle_id(&bundle_id);
3318 let output_dir = answers
3319 .get("output_dir")
3320 .and_then(Value::as_str)
3321 .map(str::trim)
3322 .filter(|value| !value.is_empty())
3323 .map(PathBuf::from)
3324 .unwrap_or_else(|| default_bundle_output_dir(&normalized_bundle_id));
3325 let request = normalize_request(SeedRequest {
3326 mode,
3327 locale: document.locale,
3328 bundle_name,
3329 bundle_id,
3330 output_dir,
3331 app_pack_entries: optional_app_pack_entries(&answers, "app_pack_entries")?,
3332 access_rules: optional_access_rules(&answers, "access_rules")?,
3333 extension_provider_entries: optional_extension_provider_entries(
3334 &answers,
3335 "extension_provider_entries",
3336 )?,
3337 cloud_deployer_target: optional_string(&answers, "cloud_deployer_target")?,
3338 advanced_setup: optional_bool(&answers, "advanced_setup")?,
3339 app_packs: optional_string_list(&answers, "app_packs")?,
3340 extension_providers: optional_string_list(&answers, "extension_providers")?,
3341 remote_catalogs: optional_string_list(&answers, "remote_catalogs")?,
3342 setup_specs: optional_object_map(&answers, "setup_specs")?,
3343 setup_answers: optional_object_map(&answers, "setup_answers")?,
3344 setup_execution_intent: optional_bool(&answers, "setup_execution_intent")?,
3345 export_intent: optional_bool(&answers, "export_intent")?,
3346 capabilities: optional_string_list(&answers, "capabilities")?,
3347 });
3348 validate_normalized_answer_request(&request)?;
3349 Ok(request)
3350}
3351
3352fn normalized_answers_from_document(
3353 answers: &BTreeMap<String, Value>,
3354) -> Result<BTreeMap<String, Value>> {
3355 let mut normalized = answers.clone();
3356
3357 if let Some(Value::Object(bundle)) = answers.get("bundle") {
3358 copy_nested_string(bundle, "name", &mut normalized, "bundle_name")?;
3359 copy_nested_string(bundle, "id", &mut normalized, "bundle_id")?;
3360 copy_nested_string(bundle, "output_dir", &mut normalized, "output_dir")?;
3361 }
3362
3363 if !normalized.contains_key("mode")
3364 && let Some(Value::String(action)) = answers.get("selected_action")
3365 {
3366 if action == "bundle" {
3367 normalized.insert("mode".to_string(), Value::String("create".to_string()));
3368 } else {
3369 return Err(invalid_answer_field("selected_action"));
3370 }
3371 }
3372
3373 if !normalized.contains_key("app_packs")
3374 && !normalized.contains_key("app_pack_entries")
3375 && let Some(Value::Array(apps)) = answers.get("apps")
3376 {
3377 normalized.insert(
3378 "app_packs".to_string(),
3379 Value::Array(
3380 apps.iter()
3381 .map(app_reference_from_launcher_entry)
3382 .collect::<Result<Vec<_>>>()?
3383 .into_iter()
3384 .map(Value::String)
3385 .collect(),
3386 ),
3387 );
3388 }
3389
3390 if !normalized.contains_key("extension_providers")
3391 && !normalized.contains_key("extension_provider_entries")
3392 && let Some(Value::Object(providers)) = answers.get("providers")
3393 {
3394 let mut refs = Vec::new();
3395 for key in ["messaging", "events"] {
3396 if let Some(value) = providers.get(key) {
3397 refs.extend(string_list_from_launcher_value(value, "providers")?);
3398 }
3399 }
3400 normalized.insert(
3401 "extension_providers".to_string(),
3402 Value::Array(refs.into_iter().map(Value::String).collect()),
3403 );
3404 }
3405
3406 Ok(normalized)
3407}
3408
3409fn copy_nested_string(
3410 object: &Map<String, Value>,
3411 source_key: &str,
3412 normalized: &mut BTreeMap<String, Value>,
3413 target_key: &str,
3414) -> Result<()> {
3415 if normalized.contains_key(target_key) {
3416 return Ok(());
3417 }
3418 match object.get(source_key) {
3419 None => Ok(()),
3420 Some(Value::String(value)) => {
3421 normalized.insert(target_key.to_string(), Value::String(value.clone()));
3422 Ok(())
3423 }
3424 Some(_) => Err(invalid_answer_field(target_key)),
3425 }
3426}
3427
3428fn app_reference_from_launcher_entry(value: &Value) -> Result<String> {
3429 let object = value
3430 .as_object()
3431 .ok_or_else(|| invalid_answer_field("apps"))?;
3432 object
3433 .get("source")
3434 .and_then(Value::as_str)
3435 .filter(|value| !value.trim().is_empty())
3436 .map(ToOwned::to_owned)
3437 .ok_or_else(|| invalid_answer_field("apps"))
3438}
3439
3440fn string_list_from_launcher_value(value: &Value, key: &str) -> Result<Vec<String>> {
3441 match value {
3442 Value::Array(entries) => entries
3443 .iter()
3444 .map(|entry| {
3445 entry
3446 .as_str()
3447 .filter(|value| !value.trim().is_empty())
3448 .map(ToOwned::to_owned)
3449 .ok_or_else(|| invalid_answer_field(key))
3450 })
3451 .collect(),
3452 _ => Err(invalid_answer_field(key)),
3453 }
3454}
3455
3456#[allow(dead_code)]
3457fn normalized_request_from_qa_answers(
3458 answers: Value,
3459 locale: String,
3460 mode: WizardMode,
3461) -> Result<NormalizedRequest> {
3462 let object = answers
3463 .as_object()
3464 .ok_or_else(|| anyhow::anyhow!("wizard answers must be a JSON object"))?;
3465 let bundle_name = object
3466 .get("bundle_name")
3467 .and_then(Value::as_str)
3468 .ok_or_else(|| anyhow::anyhow!("wizard answer missing bundle_name"))?
3469 .to_string();
3470 let bundle_id = normalize_bundle_id(
3471 object
3472 .get("bundle_id")
3473 .and_then(Value::as_str)
3474 .ok_or_else(|| anyhow::anyhow!("wizard answer missing bundle_id"))?,
3475 );
3476 let output_dir = object
3477 .get("output_dir")
3478 .and_then(Value::as_str)
3479 .map(str::trim)
3480 .filter(|value| !value.is_empty())
3481 .map(PathBuf::from)
3482 .unwrap_or_else(|| default_bundle_output_dir(&bundle_id));
3483
3484 Ok(normalize_request(SeedRequest {
3485 mode,
3486 locale,
3487 bundle_name,
3488 bundle_id,
3489 output_dir,
3490 app_pack_entries: Vec::new(),
3491 access_rules: Vec::new(),
3492 extension_provider_entries: Vec::new(),
3493 cloud_deployer_target: object
3494 .get("cloud_deployer_target")
3495 .and_then(Value::as_str)
3496 .map(str::trim)
3497 .filter(|value| !value.is_empty())
3498 .map(ToOwned::to_owned),
3499 advanced_setup: object
3500 .get("advanced_setup")
3501 .and_then(Value::as_bool)
3502 .unwrap_or(false),
3503 app_packs: parse_csv_answers(
3504 object
3505 .get("app_packs")
3506 .and_then(Value::as_str)
3507 .unwrap_or_default(),
3508 ),
3509 extension_providers: parse_csv_answers(
3510 object
3511 .get("extension_providers")
3512 .and_then(Value::as_str)
3513 .unwrap_or_default(),
3514 ),
3515 remote_catalogs: parse_csv_answers(
3516 object
3517 .get("remote_catalogs")
3518 .and_then(Value::as_str)
3519 .unwrap_or_default(),
3520 ),
3521 setup_specs: BTreeMap::new(),
3522 setup_answers: BTreeMap::new(),
3523 setup_execution_intent: object
3524 .get("setup_execution_intent")
3525 .and_then(Value::as_bool)
3526 .unwrap_or(false),
3527 export_intent: object
3528 .get("export_intent")
3529 .and_then(Value::as_bool)
3530 .unwrap_or(false),
3531 capabilities: object
3532 .get("capabilities")
3533 .and_then(Value::as_array)
3534 .map(|arr| {
3535 arr.iter()
3536 .filter_map(Value::as_str)
3537 .map(ToOwned::to_owned)
3538 .collect()
3539 })
3540 .unwrap_or_default(),
3541 }))
3542}
3543
3544fn mode_from_answers(answers: &BTreeMap<String, Value>) -> Result<WizardMode> {
3545 match answers.get("mode") {
3546 None => Ok(WizardMode::Create),
3547 Some(Value::String(value)) => match value.to_ascii_lowercase().as_str() {
3548 "create" => Ok(WizardMode::Create),
3549 "update" => Ok(WizardMode::Update),
3550 "doctor" => Ok(WizardMode::Doctor),
3551 _ => Err(invalid_answer_field("mode")),
3552 },
3553 Some(_) => Err(invalid_answer_field("mode")),
3554 }
3555}
3556
3557fn required_string(answers: &BTreeMap<String, Value>, key: &str) -> Result<String> {
3558 let value = answers.get(key).ok_or_else(|| missing_answer_field(key))?;
3559 let text = value.as_str().ok_or_else(|| invalid_answer_field(key))?;
3560 if text.trim().is_empty() {
3561 return Err(invalid_answer_field(key));
3562 }
3563 Ok(text.to_string())
3564}
3565
3566fn optional_bool(answers: &BTreeMap<String, Value>, key: &str) -> Result<bool> {
3567 match answers.get(key) {
3568 None => Ok(false),
3569 Some(Value::Bool(value)) => Ok(*value),
3570 Some(_) => Err(invalid_answer_field(key)),
3571 }
3572}
3573
3574fn optional_string(answers: &BTreeMap<String, Value>, key: &str) -> Result<Option<String>> {
3575 match answers.get(key) {
3576 None => Ok(None),
3577 Some(Value::String(value)) => {
3578 let trimmed = value.trim();
3579 if trimmed.is_empty() {
3580 Ok(None)
3581 } else {
3582 Ok(Some(trimmed.to_string()))
3583 }
3584 }
3585 Some(_) => Err(invalid_answer_field(key)),
3586 }
3587}
3588
3589fn optional_string_list(answers: &BTreeMap<String, Value>, key: &str) -> Result<Vec<String>> {
3590 match answers.get(key) {
3591 None => Ok(Vec::new()),
3592 Some(Value::Array(entries)) => entries
3593 .iter()
3594 .map(|entry| {
3595 entry
3596 .as_str()
3597 .map(ToOwned::to_owned)
3598 .ok_or_else(|| invalid_answer_field(key))
3599 })
3600 .collect(),
3601 Some(_) => Err(invalid_answer_field(key)),
3602 }
3603}
3604
3605fn optional_object_map(
3606 answers: &BTreeMap<String, Value>,
3607 key: &str,
3608) -> Result<BTreeMap<String, Value>> {
3609 match answers.get(key) {
3610 None => Ok(BTreeMap::new()),
3611 Some(Value::Object(entries)) => Ok(entries
3612 .iter()
3613 .map(|(entry_key, entry_value)| (entry_key.clone(), entry_value.clone()))
3614 .collect()),
3615 Some(_) => Err(invalid_answer_field(key)),
3616 }
3617}
3618
3619fn optional_app_pack_entries(
3620 answers: &BTreeMap<String, Value>,
3621 key: &str,
3622) -> Result<Vec<AppPackEntry>> {
3623 answers
3624 .get(key)
3625 .cloned()
3626 .map(|value| serde_json::from_value(value).map_err(|_| invalid_answer_field(key)))
3627 .transpose()
3628 .map(|value| value.unwrap_or_default())
3629}
3630
3631fn optional_access_rules(
3632 answers: &BTreeMap<String, Value>,
3633 key: &str,
3634) -> Result<Vec<AccessRuleInput>> {
3635 answers
3636 .get(key)
3637 .cloned()
3638 .map(|value| serde_json::from_value(value).map_err(|_| invalid_answer_field(key)))
3639 .transpose()
3640 .map(|value| value.unwrap_or_default())
3641}
3642
3643fn optional_extension_provider_entries(
3644 answers: &BTreeMap<String, Value>,
3645 key: &str,
3646) -> Result<Vec<ExtensionProviderEntry>> {
3647 answers
3648 .get(key)
3649 .cloned()
3650 .map(|value| serde_json::from_value(value).map_err(|_| invalid_answer_field(key)))
3651 .transpose()
3652 .map(|value| value.unwrap_or_default())
3653}
3654
3655fn missing_answer_field(key: &str) -> anyhow::Error {
3656 anyhow::anyhow!(crate::i18n::trf(
3657 "errors.answer_document.answer_missing",
3658 &[("field", key)],
3659 ))
3660}
3661
3662fn invalid_answer_field(key: &str) -> anyhow::Error {
3663 anyhow::anyhow!(crate::i18n::trf(
3664 "errors.answer_document.answer_invalid",
3665 &[("field", key)],
3666 ))
3667}
3668
3669fn validate_normalized_answer_request(request: &NormalizedRequest) -> Result<()> {
3670 if request.bundle_name.trim().is_empty() {
3671 bail!(
3672 "{}",
3673 crate::i18n::trf(
3674 "errors.answer_document.answer_invalid",
3675 &[("field", "bundle_name")]
3676 )
3677 );
3678 }
3679 if request.bundle_id.trim().is_empty() {
3680 bail!(
3681 "{}",
3682 crate::i18n::trf(
3683 "errors.answer_document.answer_invalid",
3684 &[("field", "bundle_id")]
3685 )
3686 );
3687 }
3688 Ok(())
3689}
3690
3691fn requested_schema_version(schema_version: Option<&str>) -> Result<Version> {
3692 let raw = schema_version.unwrap_or("1.0.0");
3693 Version::parse(raw).with_context(|| format!("invalid schema version {raw}"))
3694}
3695
3696fn answer_document_from_request(
3697 request: &NormalizedRequest,
3698 schema_version: Option<&str>,
3699) -> Result<AnswerDocument> {
3700 let mut document = AnswerDocument::new(&request.locale);
3701 document.schema_version = requested_schema_version(schema_version)?;
3702 document.answers = BTreeMap::from([
3703 (
3704 "mode".to_string(),
3705 Value::String(mode_name(request.mode).to_string()),
3706 ),
3707 (
3708 "bundle_name".to_string(),
3709 Value::String(request.bundle_name.clone()),
3710 ),
3711 (
3712 "bundle_id".to_string(),
3713 Value::String(request.bundle_id.clone()),
3714 ),
3715 (
3716 "output_dir".to_string(),
3717 Value::String(request.output_dir.display().to_string()),
3718 ),
3719 (
3720 "advanced_setup".to_string(),
3721 Value::Bool(request.advanced_setup),
3722 ),
3723 (
3724 "app_pack_entries".to_string(),
3725 serde_json::to_value(&request.app_pack_entries)?,
3726 ),
3727 (
3728 "app_packs".to_string(),
3729 Value::Array(
3730 request
3731 .app_packs
3732 .iter()
3733 .cloned()
3734 .map(Value::String)
3735 .collect(),
3736 ),
3737 ),
3738 (
3739 "extension_providers".to_string(),
3740 Value::Array(
3741 request
3742 .extension_providers
3743 .iter()
3744 .cloned()
3745 .map(Value::String)
3746 .collect(),
3747 ),
3748 ),
3749 (
3750 "extension_provider_entries".to_string(),
3751 serde_json::to_value(&request.extension_provider_entries)?,
3752 ),
3753 (
3754 "remote_catalogs".to_string(),
3755 Value::Array(
3756 request
3757 .remote_catalogs
3758 .iter()
3759 .cloned()
3760 .map(Value::String)
3761 .collect(),
3762 ),
3763 ),
3764 (
3765 "setup_execution_intent".to_string(),
3766 Value::Bool(request.setup_execution_intent),
3767 ),
3768 (
3769 "setup_specs".to_string(),
3770 Value::Object(request.setup_specs.clone().into_iter().collect()),
3771 ),
3772 (
3773 "access_rules".to_string(),
3774 serde_json::to_value(&request.access_rules)?,
3775 ),
3776 (
3777 "setup_answers".to_string(),
3778 Value::Object(request.setup_answers.clone().into_iter().collect()),
3779 ),
3780 (
3781 "export_intent".to_string(),
3782 Value::Bool(request.export_intent),
3783 ),
3784 (
3785 "capabilities".to_string(),
3786 Value::Array(
3787 request
3788 .capabilities
3789 .iter()
3790 .cloned()
3791 .map(Value::String)
3792 .collect(),
3793 ),
3794 ),
3795 ]);
3796 Ok(document)
3797}
3798
3799pub fn build_plan(
3800 request: &NormalizedRequest,
3801 execution: ExecutionMode,
3802 build_bundle_now: bool,
3803 schema_version: &Version,
3804 cache_writes: &[String],
3805 setup_writes: &[String],
3806) -> WizardPlanEnvelope {
3807 let mut expected_file_writes = vec![
3808 request
3809 .output_dir
3810 .join(crate::project::WORKSPACE_ROOT_FILE)
3811 .display()
3812 .to_string(),
3813 request
3814 .output_dir
3815 .join("tenants/default/tenant.gmap")
3816 .display()
3817 .to_string(),
3818 request
3819 .output_dir
3820 .join(crate::project::LOCK_FILE)
3821 .display()
3822 .to_string(),
3823 ];
3824 expected_file_writes.extend(
3825 cache_writes
3826 .iter()
3827 .map(|path| request.output_dir.join(path).display().to_string()),
3828 );
3829 expected_file_writes.extend(
3830 setup_writes
3831 .iter()
3832 .map(|path| request.output_dir.join(path).display().to_string()),
3833 );
3834 if build_bundle_now && execution == ExecutionMode::Execute {
3835 expected_file_writes.push(
3836 crate::build::default_artifact_path(&request.output_dir, &request.bundle_id)
3837 .display()
3838 .to_string(),
3839 );
3840 }
3841 expected_file_writes.sort();
3842 expected_file_writes.dedup();
3843 let mut warnings = Vec::new();
3844 if request.advanced_setup
3845 && request.app_packs.is_empty()
3846 && request.extension_providers.is_empty()
3847 {
3848 warnings.push(crate::i18n::tr("wizard.warning.advanced_without_refs"));
3849 }
3850
3851 WizardPlanEnvelope {
3852 metadata: PlanMetadata {
3853 wizard_id: WIZARD_ID.to_string(),
3854 schema_id: ANSWER_SCHEMA_ID.to_string(),
3855 schema_version: schema_version.to_string(),
3856 locale: request.locale.clone(),
3857 execution,
3858 },
3859 target_root: request.output_dir.display().to_string(),
3860 requested_action: mode_name(request.mode).to_string(),
3861 normalized_input_summary: normalized_summary(request),
3862 ordered_step_list: plan_steps(request, build_bundle_now),
3863 expected_file_writes,
3864 warnings,
3865 }
3866}
3867
3868fn normalized_summary(request: &NormalizedRequest) -> BTreeMap<String, Value> {
3869 BTreeMap::from([
3870 (
3871 "mode".to_string(),
3872 Value::String(mode_name(request.mode).to_string()),
3873 ),
3874 (
3875 "bundle_name".to_string(),
3876 Value::String(request.bundle_name.clone()),
3877 ),
3878 (
3879 "bundle_id".to_string(),
3880 Value::String(request.bundle_id.clone()),
3881 ),
3882 (
3883 "output_dir".to_string(),
3884 Value::String(request.output_dir.display().to_string()),
3885 ),
3886 (
3887 "advanced_setup".to_string(),
3888 Value::Bool(request.advanced_setup),
3889 ),
3890 (
3891 "app_pack_entries".to_string(),
3892 serde_json::to_value(&request.app_pack_entries).unwrap_or(Value::Null),
3893 ),
3894 (
3895 "app_packs".to_string(),
3896 Value::Array(
3897 request
3898 .app_packs
3899 .iter()
3900 .cloned()
3901 .map(Value::String)
3902 .collect(),
3903 ),
3904 ),
3905 (
3906 "extension_providers".to_string(),
3907 Value::Array(
3908 request
3909 .extension_providers
3910 .iter()
3911 .cloned()
3912 .map(Value::String)
3913 .collect(),
3914 ),
3915 ),
3916 (
3917 "extension_provider_entries".to_string(),
3918 serde_json::to_value(&request.extension_provider_entries).unwrap_or(Value::Null),
3919 ),
3920 (
3921 "remote_catalogs".to_string(),
3922 Value::Array(
3923 request
3924 .remote_catalogs
3925 .iter()
3926 .cloned()
3927 .map(Value::String)
3928 .collect(),
3929 ),
3930 ),
3931 (
3932 "setup_execution_intent".to_string(),
3933 Value::Bool(request.setup_execution_intent),
3934 ),
3935 (
3936 "access_rules".to_string(),
3937 serde_json::to_value(&request.access_rules).unwrap_or(Value::Null),
3938 ),
3939 (
3940 "setup_spec_providers".to_string(),
3941 Value::Array(
3942 request
3943 .setup_specs
3944 .keys()
3945 .cloned()
3946 .map(Value::String)
3947 .collect(),
3948 ),
3949 ),
3950 (
3951 "export_intent".to_string(),
3952 Value::Bool(request.export_intent),
3953 ),
3954 ])
3955}
3956
3957fn plan_steps(request: &NormalizedRequest, build_bundle_now: bool) -> Vec<WizardPlanStep> {
3958 let mut steps = vec![
3959 WizardPlanStep {
3960 kind: StepKind::EnsureWorkspace,
3961 description: crate::i18n::tr("wizard.plan.ensure_workspace"),
3962 },
3963 WizardPlanStep {
3964 kind: StepKind::WriteBundleFile,
3965 description: crate::i18n::tr("wizard.plan.write_bundle_file"),
3966 },
3967 WizardPlanStep {
3968 kind: StepKind::UpdateAccessRules,
3969 description: crate::i18n::tr("wizard.plan.update_access_rules"),
3970 },
3971 WizardPlanStep {
3972 kind: StepKind::ResolveRefs,
3973 description: crate::i18n::tr("wizard.plan.resolve_refs"),
3974 },
3975 WizardPlanStep {
3976 kind: StepKind::WriteLock,
3977 description: crate::i18n::tr("wizard.plan.write_lock"),
3978 },
3979 ];
3980 if build_bundle_now || matches!(request.mode, WizardMode::Doctor) {
3981 steps.push(WizardPlanStep {
3982 kind: StepKind::BuildBundle,
3983 description: crate::i18n::tr("wizard.plan.build_bundle"),
3984 });
3985 }
3986 if request.export_intent {
3987 steps.push(WizardPlanStep {
3988 kind: StepKind::ExportBundle,
3989 description: crate::i18n::tr("wizard.plan.export_bundle"),
3990 });
3991 }
3992 steps
3993}
3994
3995fn apply_plan(
3996 request: &NormalizedRequest,
3997 bundle_lock: &crate::project::BundleLock,
3998) -> Result<Vec<PathBuf>> {
3999 fs::create_dir_all(&request.output_dir)
4000 .with_context(|| format!("create output dir {}", request.output_dir.display()))?;
4001 let bundle_yaml = request.output_dir.join(crate::project::WORKSPACE_ROOT_FILE);
4002 let tenant_gmap = request.output_dir.join("tenants/default/tenant.gmap");
4003 let lock_file = request.output_dir.join(crate::project::LOCK_FILE);
4004
4005 let workspace = workspace_definition_from_request(request);
4006 let mut writes = crate::project::init_bundle_workspace(&request.output_dir, &workspace)?;
4007
4008 for entry in &request.app_pack_entries {
4009 if let Some(tenant) = &entry.mapping.tenant {
4010 if let Some(team) = &entry.mapping.team {
4011 crate::project::ensure_team(&request.output_dir, tenant, team)?;
4012 } else {
4013 crate::project::ensure_tenant(&request.output_dir, tenant)?;
4014 }
4015 }
4016 }
4017
4018 for rule in &request.access_rules {
4019 let preview = crate::access::mutate_access(
4020 &request.output_dir,
4021 &crate::access::GmapTarget {
4022 tenant: rule.tenant.clone(),
4023 team: rule.team.clone(),
4024 },
4025 &crate::access::GmapMutation {
4026 rule_path: rule.rule_path.clone(),
4027 policy: match rule.policy.as_str() {
4028 "forbidden" => crate::access::Policy::Forbidden,
4029 _ => crate::access::Policy::Public,
4030 },
4031 },
4032 false,
4033 )?;
4034 writes.extend(
4035 preview
4036 .writes
4037 .into_iter()
4038 .map(|path| request.output_dir.join(path)),
4039 );
4040 }
4041
4042 let setup_result = persist_setup_state(request, ExecutionMode::Execute)?;
4043 crate::project::write_bundle_lock(&request.output_dir, bundle_lock)
4044 .with_context(|| format!("write {}", lock_file.display()))?;
4045 crate::project::sync_project_with_reference_roots(
4046 &request.output_dir,
4047 &reference_roots_for_apply(request)?,
4048 )?;
4049
4050 if request
4051 .capabilities
4052 .iter()
4053 .any(|c| c == crate::project::CAP_BUNDLE_ASSETS_READ_V1)
4054 {
4055 let scaffolded = crate::project::scaffold_assets_from_packs(&request.output_dir)?;
4056 writes.extend(scaffolded);
4057 }
4058
4059 writes.push(bundle_yaml);
4060 writes.push(tenant_gmap);
4061 writes.push(lock_file);
4062 writes.extend(
4063 setup_result
4064 .writes
4065 .into_iter()
4066 .map(|path| request.output_dir.join(path)),
4067 );
4068 writes.sort();
4069 writes.dedup();
4070 Ok(writes)
4071}
4072
4073fn workspace_definition_from_request(
4074 request: &NormalizedRequest,
4075) -> crate::project::BundleWorkspaceDefinition {
4076 let mut workspace = crate::project::BundleWorkspaceDefinition::new(
4077 request.bundle_name.clone(),
4078 request.bundle_id.clone(),
4079 request.locale.clone(),
4080 mode_name(request.mode).to_string(),
4081 );
4082 workspace.advanced_setup = request.advanced_setup;
4083 workspace.app_pack_mappings = request
4084 .app_pack_entries
4085 .iter()
4086 .map(|entry| crate::project::AppPackMapping {
4087 reference: entry.reference.clone(),
4088 scope: match entry.mapping.scope.as_str() {
4089 "tenant" => crate::project::MappingScope::Tenant,
4090 "tenant_team" => crate::project::MappingScope::Team,
4091 _ => crate::project::MappingScope::Global,
4092 },
4093 tenant: entry.mapping.tenant.clone(),
4094 team: entry.mapping.team.clone(),
4095 })
4096 .collect();
4097 workspace.app_packs = request.app_packs.clone();
4098 workspace.extension_providers = request.extension_providers.clone();
4099 workspace.remote_catalogs = request.remote_catalogs.clone();
4100 workspace.capabilities = request.capabilities.clone();
4101 workspace.setup_execution_intent = false;
4102 workspace.export_intent = false;
4103 workspace.canonicalize();
4104 workspace
4105}
4106
4107fn write_answer_document(path: &Path, document: &AnswerDocument) -> Result<()> {
4108 if let Some(parent) = path.parent()
4109 && !parent.as_os_str().is_empty()
4110 {
4111 fs::create_dir_all(parent)
4112 .with_context(|| format!("create answers parent {}", parent.display()))?;
4113 }
4114 fs::write(path, document.to_pretty_json_string()?)
4115 .with_context(|| format!("write answers file {}", path.display()))
4116}
4117
4118fn normalize_bundle_id(raw: &str) -> String {
4119 let normalized = raw
4120 .trim()
4121 .to_ascii_lowercase()
4122 .chars()
4123 .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
4124 .collect::<String>();
4125 normalized.trim_matches('-').to_string()
4126}
4127
4128fn normalize_output_dir(path: PathBuf) -> PathBuf {
4129 if path.as_os_str().is_empty() {
4130 PathBuf::from(".")
4131 } else {
4132 path
4133 }
4134}
4135
4136fn default_bundle_output_dir(bundle_id: &str) -> PathBuf {
4137 let normalized = normalize_bundle_id(bundle_id);
4138 if normalized.is_empty() {
4139 PathBuf::from("./bundle")
4140 } else {
4141 PathBuf::from(format!("./{normalized}-bundle"))
4142 }
4143}
4144
4145fn sorted_unique(entries: Vec<String>) -> Vec<String> {
4146 let mut entries = entries
4147 .into_iter()
4148 .filter(|entry| !entry.trim().is_empty())
4149 .collect::<Vec<_>>();
4150 entries.sort();
4151 entries.dedup();
4152 entries
4153}
4154
4155fn mode_name(mode: WizardMode) -> &'static str {
4156 match mode {
4157 WizardMode::Create => "create",
4158 WizardMode::Update => "update",
4159 WizardMode::Doctor => "doctor",
4160 }
4161}
4162
4163pub fn print_plan(plan: &WizardPlanEnvelope) -> Result<()> {
4164 println!("{}", serde_json::to_string_pretty(plan)?);
4165 Ok(())
4166}
4167
4168fn build_bundle_lock(
4169 request: &NormalizedRequest,
4170 execution: ExecutionMode,
4171 catalog_resolution: &crate::catalog::resolve::CatalogResolution,
4172 setup_writes: &[String],
4173) -> crate::project::BundleLock {
4174 crate::project::BundleLock {
4175 schema_version: crate::project::LOCK_SCHEMA_VERSION,
4176 bundle_id: request.bundle_id.clone(),
4177 requested_mode: mode_name(request.mode).to_string(),
4178 execution: match execution {
4179 ExecutionMode::DryRun => "dry_run",
4180 ExecutionMode::Execute => "execute",
4181 }
4182 .to_string(),
4183 cache_policy: crate::catalog::DEFAULT_CACHE_POLICY.to_string(),
4184 tool_version: env!("CARGO_PKG_VERSION").to_string(),
4185 build_format_version: "bundle-lock-v1".to_string(),
4186 workspace_root: crate::project::WORKSPACE_ROOT_FILE.to_string(),
4187 lock_file: crate::project::LOCK_FILE.to_string(),
4188 catalogs: catalog_resolution.entries.clone(),
4189 app_packs: request
4190 .app_packs
4191 .iter()
4192 .cloned()
4193 .map(|reference| crate::project::DependencyLock {
4194 reference,
4195 digest: None,
4196 })
4197 .collect(),
4198 extension_providers: request
4199 .extension_providers
4200 .iter()
4201 .cloned()
4202 .map(|reference| crate::project::DependencyLock {
4203 reference,
4204 digest: None,
4205 })
4206 .collect(),
4207 setup_state_files: setup_writes.to_vec(),
4208 }
4209}
4210
4211fn bundle_lock_to_answer_locks(lock: &crate::project::BundleLock) -> BTreeMap<String, Value> {
4212 let catalogs = lock
4213 .catalogs
4214 .iter()
4215 .map(|entry| {
4216 serde_json::json!({
4217 "requested_ref": entry.requested_ref,
4218 "resolved_ref": entry.resolved_ref,
4219 "digest": entry.digest,
4220 "source": entry.source,
4221 "item_count": entry.item_count,
4222 "item_ids": entry.item_ids,
4223 "cache_path": entry.cache_path,
4224 })
4225 })
4226 .collect::<Vec<_>>();
4227
4228 BTreeMap::from([
4229 (
4230 "cache_policy".to_string(),
4231 Value::String(lock.cache_policy.clone()),
4232 ),
4233 (
4234 "workspace_root".to_string(),
4235 Value::String(lock.workspace_root.clone()),
4236 ),
4237 (
4238 "lock_file".to_string(),
4239 Value::String(lock.lock_file.clone()),
4240 ),
4241 (
4242 "requested_mode".to_string(),
4243 Value::String(lock.requested_mode.clone()),
4244 ),
4245 (
4246 "execution".to_string(),
4247 Value::String(lock.execution.clone()),
4248 ),
4249 ("catalogs".to_string(), Value::Array(catalogs)),
4250 (
4251 "setup_state_files".to_string(),
4252 Value::Array(
4253 lock.setup_state_files
4254 .iter()
4255 .cloned()
4256 .map(Value::String)
4257 .collect(),
4258 ),
4259 ),
4260 ])
4261}
4262
4263fn preview_setup_writes(
4264 request: &NormalizedRequest,
4265 execution: ExecutionMode,
4266) -> Result<Vec<String>> {
4267 let _ = execution;
4268 let instructions = collect_setup_instructions(request)?;
4269 if instructions.is_empty() {
4270 return Ok(Vec::new());
4271 }
4272 Ok(crate::setup::persist::persist_setup(
4273 &request.output_dir,
4274 &instructions,
4275 &crate::setup::backend::NoopSetupBackend,
4276 )?
4277 .writes)
4278}
4279
4280fn persist_setup_state(
4281 request: &NormalizedRequest,
4282 execution: ExecutionMode,
4283) -> Result<crate::setup::persist::SetupPersistenceResult> {
4284 let instructions = collect_setup_instructions(request)?;
4285 if instructions.is_empty() {
4286 return Ok(crate::setup::persist::SetupPersistenceResult {
4287 states: Vec::new(),
4288 writes: Vec::new(),
4289 });
4290 }
4291
4292 let backend: Box<dyn crate::setup::backend::SetupBackend> = match execution {
4293 ExecutionMode::Execute => Box::new(crate::setup::backend::FileSetupBackend::new(
4294 &request.output_dir,
4295 )),
4296 ExecutionMode::DryRun => Box::new(crate::setup::backend::NoopSetupBackend),
4297 };
4298 crate::setup::persist::persist_setup(&request.output_dir, &instructions, backend.as_ref())
4299}
4300
4301fn collect_setup_instructions(
4302 request: &NormalizedRequest,
4303) -> Result<Vec<crate::setup::persist::SetupInstruction>> {
4304 if !request.setup_execution_intent {
4305 return Ok(Vec::new());
4306 }
4307 crate::setup::persist::collect_setup_instructions(&request.setup_specs, &request.setup_answers)
4308}
4309
4310#[allow(dead_code)]
4311fn collect_interactive_setup_answers<R: BufRead, W: Write>(
4312 input: &mut R,
4313 output: &mut W,
4314 request: NormalizedRequest,
4315 last_compact_title: &mut Option<String>,
4316) -> Result<NormalizedRequest> {
4317 if !request.setup_execution_intent {
4318 return Ok(request);
4319 }
4320
4321 let catalog_resolution = crate::catalog::resolve::resolve_catalogs(
4322 &request.output_dir,
4323 &request.remote_catalogs,
4324 &crate::catalog::resolve::CatalogResolveOptions {
4325 offline: crate::runtime::offline(),
4326 write_cache: false,
4327 },
4328 )?;
4329 let mut request = discover_setup_specs(request, &catalog_resolution);
4330 let provider_ids = request.setup_specs.keys().cloned().collect::<Vec<_>>();
4331 for provider_id in provider_ids {
4332 let needs_answers = request
4333 .setup_answers
4334 .get(&provider_id)
4335 .and_then(Value::as_object)
4336 .map(|answers| answers.is_empty())
4337 .unwrap_or(true);
4338 if !needs_answers {
4339 continue;
4340 }
4341
4342 let spec_input = request
4343 .setup_specs
4344 .get(&provider_id)
4345 .cloned()
4346 .ok_or_else(|| anyhow::anyhow!("missing setup spec for {provider_id}"))?;
4347 let parsed = serde_json::from_value::<crate::setup::SetupSpecInput>(spec_input)?;
4348 let (_, form) = crate::setup::form_spec_from_input(&parsed, &provider_id)?;
4349 let answers =
4350 prompt_setup_form_answers(input, output, &provider_id, &form, last_compact_title)?;
4351 request
4352 .setup_answers
4353 .insert(provider_id, Value::Object(answers.into_iter().collect()));
4354 }
4355
4356 Ok(request)
4357}
4358
4359#[allow(dead_code)]
4360fn prompt_setup_form_answers<R: BufRead, W: Write>(
4361 input: &mut R,
4362 output: &mut W,
4363 provider_id: &str,
4364 form: &crate::setup::FormSpec,
4365 last_compact_title: &mut Option<String>,
4366) -> Result<BTreeMap<String, Value>> {
4367 writeln!(
4368 output,
4369 "{} {} ({provider_id})",
4370 crate::i18n::tr("wizard.setup.form_prefix"),
4371 form.title
4372 )?;
4373 let spec_json = serde_json::to_string(&qa_form_spec_from_setup_form(form)?)?;
4374 let config = WizardRunConfig {
4375 spec_json,
4376 initial_answers_json: None,
4377 frontend: WizardFrontend::Text,
4378 i18n: I18nConfig {
4379 locale: Some(crate::i18n::current_locale()),
4380 resolved: None,
4381 debug: false,
4382 },
4383 verbose: false,
4384 };
4385 let mut driver =
4386 WizardDriver::new(config).context("initialize greentic-qa-lib setup wizard")?;
4387 loop {
4388 let payload_raw = driver
4389 .next_payload_json()
4390 .context("render greentic-qa-lib setup payload")?;
4391 let payload: Value =
4392 serde_json::from_str(&payload_raw).context("parse greentic-qa-lib setup payload")?;
4393
4394 if let Some(text) = payload.get("text").and_then(Value::as_str) {
4395 render_qa_driver_text(output, text, last_compact_title)?;
4396 }
4397
4398 if driver.is_complete() {
4399 break;
4400 }
4401
4402 let ui_raw = driver
4403 .last_ui_json()
4404 .ok_or_else(|| anyhow::anyhow!("greentic-qa-lib setup payload missing UI state"))?;
4405 let ui: Value = serde_json::from_str(ui_raw).context("parse greentic-qa-lib UI payload")?;
4406 let question_id = ui
4407 .get("next_question_id")
4408 .and_then(Value::as_str)
4409 .ok_or_else(|| anyhow::anyhow!("greentic-qa-lib UI payload missing next_question_id"))?
4410 .to_string();
4411 let question = ui
4412 .get("questions")
4413 .and_then(Value::as_array)
4414 .and_then(|questions| {
4415 questions.iter().find(|question| {
4416 question.get("id").and_then(Value::as_str) == Some(question_id.as_str())
4417 })
4418 })
4419 .ok_or_else(|| {
4420 anyhow::anyhow!("greentic-qa-lib UI payload missing question {question_id}")
4421 })?;
4422
4423 let answer = prompt_qa_question_answer(input, output, &question_id, question)?;
4424 driver
4425 .submit_patch_json(&json!({ question_id: answer }).to_string())
4426 .context("submit greentic-qa-lib setup answer")?;
4427 }
4428
4429 let result = driver
4430 .finish()
4431 .context("finish greentic-qa-lib setup wizard")?;
4432 let answers = result
4433 .answer_set
4434 .answers
4435 .as_object()
4436 .cloned()
4437 .unwrap_or_else(Map::new);
4438 Ok(answers.into_iter().collect())
4439}
4440
4441#[allow(dead_code)]
4442fn qa_form_spec_from_setup_form(form: &crate::setup::FormSpec) -> Result<Value> {
4443 let questions = form
4444 .questions
4445 .iter()
4446 .map(|question| {
4447 let mut value = json!({
4448 "id": question.id,
4449 "type": qa_question_type_name(question.kind),
4450 "title": question.title,
4451 "required": question.required,
4452 "secret": question.secret,
4453 });
4454 if let Some(description) = &question.description {
4455 value["description"] = Value::String(description.clone());
4456 }
4457 if !question.choices.is_empty() {
4458 value["choices"] = Value::Array(
4459 question
4460 .choices
4461 .iter()
4462 .cloned()
4463 .map(Value::String)
4464 .collect(),
4465 );
4466 }
4467 if let Some(default) = &question.default_value
4468 && let Some(default_value) = qa_default_value(default)
4469 {
4470 value["default_value"] = Value::String(default_value);
4471 }
4472 value
4473 })
4474 .collect::<Vec<_>>();
4475
4476 Ok(json!({
4477 "id": form.id,
4478 "title": form.title,
4479 "version": form.version,
4480 "description": form.description,
4481 "presentation": {
4482 "default_locale": crate::i18n::current_locale()
4483 },
4484 "progress_policy": {
4485 "skip_answered": true,
4486 "autofill_defaults": false,
4487 "treat_default_as_answered": false
4488 },
4489 "questions": questions
4490 }))
4491}
4492
4493#[allow(dead_code)]
4494fn qa_question_type_name(kind: crate::setup::QuestionKind) -> &'static str {
4495 match kind {
4496 crate::setup::QuestionKind::String => "string",
4497 crate::setup::QuestionKind::Number => "number",
4498 crate::setup::QuestionKind::Boolean => "boolean",
4499 crate::setup::QuestionKind::Enum => "enum",
4500 }
4501}
4502
4503#[allow(dead_code)]
4504fn qa_default_value(value: &Value) -> Option<String> {
4505 match value {
4506 Value::String(text) => Some(text.clone()),
4507 Value::Bool(flag) => Some(flag.to_string()),
4508 Value::Number(number) => Some(number.to_string()),
4509 _ => None,
4510 }
4511}
4512
4513#[allow(dead_code)]
4514fn render_qa_driver_text<W: Write>(
4515 output: &mut W,
4516 text: &str,
4517 last_compact_title: &mut Option<String>,
4518) -> Result<()> {
4519 if text.is_empty() {
4520 return Ok(());
4521 }
4522 if let Some(title) = compact_form_title(text) {
4523 if last_compact_title.as_deref() != Some(title) {
4524 writeln!(output, "{title}")?;
4525 output.flush()?;
4526 *last_compact_title = Some(title.to_string());
4527 }
4528 return Ok(());
4529 }
4530 *last_compact_title = None;
4531 for line in text.lines() {
4532 writeln!(output, "{line}")?;
4533 }
4534 if !text.ends_with('\n') {
4535 output.flush()?;
4536 }
4537 Ok(())
4538}
4539
4540#[allow(dead_code)]
4541fn compact_form_title(text: &str) -> Option<&str> {
4542 let first_line = text.lines().next()?;
4543 let form = first_line.strip_prefix("Form: ")?;
4544 let (title, form_id) = form.rsplit_once(" (")?;
4545 if form_id
4546 .strip_suffix(')')
4547 .is_some_and(|id| id.starts_with("greentic-bundle-root-wizard-"))
4548 {
4549 return Some(title);
4550 }
4551 None
4552}
4553
4554#[allow(dead_code)]
4555fn prompt_qa_question_answer<R: BufRead, W: Write>(
4556 input: &mut R,
4557 output: &mut W,
4558 question_id: &str,
4559 question: &Value,
4560) -> Result<Value> {
4561 let title = question
4562 .get("title")
4563 .and_then(Value::as_str)
4564 .unwrap_or(question_id);
4565 let required = question
4566 .get("required")
4567 .and_then(Value::as_bool)
4568 .unwrap_or(false);
4569 let kind = question
4570 .get("type")
4571 .and_then(Value::as_str)
4572 .unwrap_or("string");
4573 let secret = question
4574 .get("secret")
4575 .and_then(Value::as_bool)
4576 .unwrap_or(false);
4577 let default_value = question_default_value(question, kind);
4578
4579 match kind {
4580 "boolean" => prompt_qa_boolean(input, output, title, required, default_value),
4581 "enum" => prompt_qa_enum(input, output, title, required, question, default_value),
4582 _ => prompt_qa_string_like(input, output, title, required, secret, default_value),
4583 }
4584}
4585
4586fn prompt_qa_string_like<R: BufRead, W: Write>(
4587 input: &mut R,
4588 output: &mut W,
4589 title: &str,
4590 required: bool,
4591 secret: bool,
4592 default_value: Option<Value>,
4593) -> Result<Value> {
4594 loop {
4595 if secret && io::stdin().is_terminal() && io::stdout().is_terminal() {
4596 let prompt = format!("{title}{}: ", default_suffix(default_value.as_ref()));
4597 let secret_value =
4598 rpassword::prompt_password(prompt).context("read secret wizard input")?;
4599 if secret_value.trim().is_empty() {
4600 if let Some(default) = &default_value {
4601 return Ok(default.clone());
4602 }
4603 if required {
4604 writeln!(output, "{}", crate::i18n::tr("wizard.error.empty_answer"))?;
4605 continue;
4606 }
4607 return Ok(Value::Null);
4608 }
4609 return Ok(Value::String(secret_value));
4610 }
4611
4612 write!(
4613 output,
4614 "{title}{}: ",
4615 default_suffix(default_value.as_ref())
4616 )?;
4617 output.flush()?;
4618 let mut line = String::new();
4619 if input.read_line(&mut line)? == 0 {
4620 bail!("{}", crate::i18n::tr("wizard.error.input_ended"));
4621 }
4622 let trimmed = line.trim();
4623 if trimmed.is_empty() {
4624 if let Some(default) = &default_value {
4625 return Ok(default.clone());
4626 }
4627 if required {
4628 writeln!(output, "{}", crate::i18n::tr("wizard.error.empty_answer"))?;
4629 continue;
4630 }
4631 return Ok(Value::Null);
4632 }
4633 return Ok(Value::String(trimmed.to_string()));
4634 }
4635}
4636
4637#[allow(dead_code)]
4638fn prompt_qa_boolean<R: BufRead, W: Write>(
4639 input: &mut R,
4640 output: &mut W,
4641 title: &str,
4642 required: bool,
4643 default_value: Option<Value>,
4644) -> Result<Value> {
4645 loop {
4646 write!(
4647 output,
4648 "{title}{}: ",
4649 default_suffix(default_value.as_ref())
4650 )?;
4651 output.flush()?;
4652 let mut line = String::new();
4653 if input.read_line(&mut line)? == 0 {
4654 bail!("{}", crate::i18n::tr("wizard.error.input_ended"));
4655 }
4656 let trimmed = line.trim().to_ascii_lowercase();
4657 if trimmed.is_empty() {
4658 if let Some(default) = &default_value {
4659 return Ok(default.clone());
4660 }
4661 if required {
4662 writeln!(output, "{}", crate::i18n::tr("wizard.error.empty_answer"))?;
4663 continue;
4664 }
4665 return Ok(Value::Null);
4666 }
4667 match parse_localized_boolean(&trimmed) {
4668 Some(value) => return Ok(Value::Bool(value)),
4669 None => writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?,
4670 }
4671 }
4672}
4673
4674#[allow(dead_code)]
4675fn parse_localized_boolean(input: &str) -> Option<bool> {
4676 let trimmed = input.trim().to_ascii_lowercase();
4677 if trimmed.is_empty() {
4678 return None;
4679 }
4680
4681 let locale = crate::i18n::current_locale();
4682 let mut truthy = vec!["true", "t", "yes", "y", "1"];
4683 let mut falsy = vec!["false", "f", "no", "n", "0"];
4684
4685 match crate::i18n::base_language(&locale).as_deref() {
4686 Some("nl") => {
4687 truthy.extend(["ja", "j"]);
4688 falsy.extend(["nee"]);
4689 }
4690 Some("de") => {
4691 truthy.extend(["ja", "j"]);
4692 falsy.extend(["nein"]);
4693 }
4694 Some("fr") => {
4695 truthy.extend(["oui", "o"]);
4696 falsy.extend(["non"]);
4697 }
4698 Some("es") | Some("pt") | Some("it") => {
4699 truthy.extend(["si", "s"]);
4700 falsy.extend(["no"]);
4701 }
4702 _ => {}
4703 }
4704
4705 if truthy.iter().any(|value| *value == trimmed) {
4706 return Some(true);
4707 }
4708 if falsy.iter().any(|value| *value == trimmed) {
4709 return Some(false);
4710 }
4711 None
4712}
4713
4714#[allow(dead_code)]
4715fn prompt_qa_enum<R: BufRead, W: Write>(
4716 input: &mut R,
4717 output: &mut W,
4718 title: &str,
4719 required: bool,
4720 question: &Value,
4721 default_value: Option<Value>,
4722) -> Result<Value> {
4723 let choices = question
4724 .get("choices")
4725 .and_then(Value::as_array)
4726 .ok_or_else(|| anyhow::anyhow!("qa enum question missing choices"))?
4727 .iter()
4728 .filter_map(Value::as_str)
4729 .map(ToOwned::to_owned)
4730 .collect::<Vec<_>>();
4731
4732 loop {
4733 if !title.is_empty() {
4734 writeln!(output, "{title}:")?;
4735 }
4736 for (index, choice) in choices.iter().enumerate() {
4737 if title.is_empty() {
4738 writeln!(output, "{}. {}", index + 1, choice)?;
4739 } else {
4740 writeln!(output, " {}. {}", index + 1, choice)?;
4741 }
4742 }
4743 write!(output, "{} ", crate::i18n::tr("wizard.setup.enum_prompt"))?;
4744 output.flush()?;
4745
4746 let mut line = String::new();
4747 if input.read_line(&mut line)? == 0 {
4748 bail!("{}", crate::i18n::tr("wizard.error.input_ended"));
4749 }
4750 let trimmed = line.trim();
4751 if trimmed.is_empty() {
4752 if let Some(default) = &default_value {
4753 return Ok(default.clone());
4754 }
4755 if required {
4756 writeln!(output, "{}", crate::i18n::tr("wizard.error.empty_answer"))?;
4757 continue;
4758 }
4759 return Ok(Value::Null);
4760 }
4761 if let Ok(number) = trimmed.parse::<usize>()
4762 && number > 0
4763 && number <= choices.len()
4764 {
4765 return Ok(Value::String(choices[number - 1].clone()));
4766 }
4767 if choices.iter().any(|choice| choice == trimmed) {
4768 return Ok(Value::String(trimmed.to_string()));
4769 }
4770 writeln!(output, "{}", crate::i18n::tr("wizard.error.invalid_choice"))?;
4771 }
4772}
4773
4774#[allow(dead_code)]
4775fn question_default_value(question: &Value, kind: &str) -> Option<Value> {
4776 let raw = question
4777 .get("current_value")
4778 .cloned()
4779 .or_else(|| question.get("default").cloned())?;
4780 match raw {
4781 Value::String(text) => match kind {
4782 "boolean" => match text.as_str() {
4783 "true" => Some(Value::Bool(true)),
4784 "false" => Some(Value::Bool(false)),
4785 _ => None,
4786 },
4787 "number" => serde_json::from_str::<serde_json::Number>(&text)
4788 .ok()
4789 .map(Value::Number),
4790 _ => Some(Value::String(text)),
4791 },
4792 Value::Bool(flag) if kind == "boolean" => Some(Value::Bool(flag)),
4793 Value::Number(number) if kind == "number" => Some(Value::Number(number)),
4794 Value::Null => None,
4795 other => Some(other),
4796 }
4797}
4798
4799fn default_suffix(value: Option<&Value>) -> String {
4800 match value {
4801 Some(Value::String(text)) if !text.is_empty() => format!(" [{}]", text),
4802 Some(Value::Bool(flag)) => format!(" [{}]", flag),
4803 Some(Value::Number(number)) => format!(" [{}]", number),
4804 _ => String::new(),
4805 }
4806}
4807
4808fn discover_setup_specs(
4809 mut request: NormalizedRequest,
4810 catalog_resolution: &crate::catalog::resolve::CatalogResolution,
4811) -> NormalizedRequest {
4812 if !request.setup_execution_intent {
4813 return request;
4814 }
4815
4816 for reference in request
4817 .extension_providers
4818 .iter()
4819 .chain(request.app_packs.iter())
4820 {
4821 if request.setup_specs.contains_key(reference) {
4822 continue;
4823 }
4824 if let Some(entry) = catalog_resolution
4825 .discovered_items
4826 .iter()
4827 .find(|entry| entry.id == *reference || entry.reference == *reference)
4828 && let Some(setup) = &entry.setup
4829 {
4830 request
4831 .setup_specs
4832 .entry(entry.id.clone())
4833 .or_insert_with(|| serde_json::to_value(setup).expect("serialize setup metadata"));
4834
4835 if let Some(answer_value) = request.setup_answers.remove(reference) {
4836 request
4837 .setup_answers
4838 .entry(entry.id.clone())
4839 .or_insert(answer_value);
4840 }
4841 }
4842 }
4843
4844 request
4845}
4846
4847#[cfg(test)]
4848mod tests {
4849 use std::io::Cursor;
4850
4851 use crate::catalog::registry::CatalogEntry;
4852
4853 use super::{
4854 RootMenuZeroAction, apply_cloud_deployer_target_to_entries,
4855 build_extension_provider_options, choose_interactive_menu, clean_extension_provider_label,
4856 detect_cloud_deployer_target, detected_reference_kind,
4857 };
4858
4859 #[test]
4860 fn root_menu_shows_back_and_returns_none_for_embedded_wizards() {
4861 crate::i18n::init(Some("en".to_string()));
4862 let mut input = Cursor::new(b"0\n");
4863 let mut output = Vec::new();
4864
4865 let choice = choose_interactive_menu(&mut input, &mut output, RootMenuZeroAction::Back)
4866 .expect("menu should render");
4867
4868 assert_eq!(choice, None);
4869 let rendered = String::from_utf8(output).expect("utf8");
4870 assert!(rendered.contains("0. Back"));
4871 assert!(!rendered.contains("0. Exit"));
4872 }
4873
4874 #[test]
4875 fn extension_provider_options_dedupe_by_display_name() {
4876 let pinned = CatalogEntry {
4877 id: "greentic.secrets.aws-sm.v0-4-25".to_string(),
4878 category: Some("secrets".to_string()),
4879 category_label: None,
4880 category_description: None,
4881 label: Some("Greentic Secrets AWS SM".to_string()),
4882 reference:
4883 "oci://ghcr.io/greenticai/packs/secret/greentic.secrets.aws-sm.gtpack:0.4.25"
4884 .to_string(),
4885 setup: None,
4886 };
4887 let latest = CatalogEntry {
4888 id: "greentic.secrets.aws-sm.latest".to_string(),
4889 category: Some("secrets".to_string()),
4890 category_label: None,
4891 category_description: None,
4892 label: Some("Greentic Secrets AWS SM".to_string()),
4893 reference:
4894 "oci://ghcr.io/greenticai/packs/secret/greentic.secrets.aws-sm.gtpack:stable"
4895 .to_string(),
4896 setup: None,
4897 };
4898 let entries = vec![&pinned, &latest];
4899 let options = build_extension_provider_options(&entries);
4900
4901 assert_eq!(options.len(), 1);
4902 assert_eq!(options[0].display_name, "Greentic Secrets AWS SM");
4903 assert_eq!(options[0].entry.id, "greentic.secrets.aws-sm.v0-4-25");
4904 assert_eq!(
4905 options[0].entry.reference,
4906 "oci://ghcr.io/greenticai/packs/secret/greentic.secrets.aws-sm.gtpack:0.4.25"
4907 );
4908 }
4909
4910 #[test]
4911 fn clean_extension_provider_label_removes_latest_suffix_only() {
4912 let latest = CatalogEntry {
4913 id: "x.latest".to_string(),
4914 category: None,
4915 category_label: None,
4916 category_description: None,
4917 label: Some("Greentic Secrets AWS SM (latest)".to_string()),
4918 reference: "oci://ghcr.io/example/secrets:stable".to_string(),
4919 setup: None,
4920 };
4921 let semver = CatalogEntry {
4922 id: "x.0.4.25".to_string(),
4923 category: None,
4924 category_label: None,
4925 category_description: None,
4926 label: Some("Greentic Secrets AWS SM (0.4.25)".to_string()),
4927 reference: "oci://ghcr.io/example/secrets:0.4.25".to_string(),
4928 setup: None,
4929 };
4930 let pr = CatalogEntry {
4931 id: "x.pr".to_string(),
4932 category: None,
4933 category_label: None,
4934 category_description: None,
4935 label: Some("Greentic Messaging Dummy (PR version)".to_string()),
4936 reference: "oci://ghcr.io/example/messaging:<pr-version>".to_string(),
4937 setup: None,
4938 };
4939
4940 assert_eq!(
4941 clean_extension_provider_label(&latest),
4942 "Greentic Secrets AWS SM"
4943 );
4944 assert_eq!(
4945 clean_extension_provider_label(&semver),
4946 "Greentic Secrets AWS SM (0.4.25)"
4947 );
4948 assert_eq!(
4949 clean_extension_provider_label(&pr),
4950 "Greentic Messaging Dummy (PR version)"
4951 );
4952 }
4953
4954 #[test]
4955 fn detected_reference_kind_classifies_https_refs() {
4956 let root = std::path::Path::new(".");
4957 assert_eq!(
4958 detected_reference_kind(root, "https://example.com/packs/cards-demo.gtpack"),
4959 "https"
4960 );
4961 }
4962
4963 #[test]
4964 fn cloud_deployer_target_adds_provider_entry() {
4965 let mut entries = Vec::new();
4966 apply_cloud_deployer_target_to_entries(&mut entries, "aws");
4967
4968 assert_eq!(entries.len(), 1);
4969 assert_eq!(entries[0].provider_id, "deployer-aws");
4970 assert_eq!(
4971 entries[0].reference,
4972 "oci://ghcr.io/greenticai/packs/deployer/greentic.deploy.aws:stable"
4973 );
4974 }
4975
4976 #[test]
4977 fn cloud_deployer_target_replaces_existing_cloud_deployer_entry() {
4978 let mut entries = Vec::new();
4979 apply_cloud_deployer_target_to_entries(&mut entries, "aws");
4980 apply_cloud_deployer_target_to_entries(&mut entries, "gcp");
4981
4982 assert_eq!(entries.len(), 1);
4983 assert_eq!(entries[0].provider_id, "deployer-gcp");
4984 assert_eq!(
4985 detect_cloud_deployer_target(&[entries[0].reference.clone()]),
4986 Some("gcp".to_string())
4987 );
4988 }
4989}