1use std::io::{self, Write};
4use std::thread;
5use std::time::Duration;
6
7use anyhow::{Context, Result, bail};
8use greentic_deployer::cli::bootstrap::{LocalEnvOutcome, ensure_local_environment};
9use greentic_deployer::environment::LocalFsStore;
10
11use crate::cli_args::*;
12use crate::cli_helpers::{
13 complete_loaded_answers_with_prompts, ensure_deployment_targets_present,
14 ensure_required_setup_answers_present, maybe_start_cli_setup_tunnel, resolve_bundle_dir,
15 resolve_setup_scope, run_interactive_wizard,
16};
17use crate::cli_i18n::CliI18n;
18use crate::engine::{LoadedAnswers, SetupConfig, SetupRequest};
19use crate::plan::TenantSelection;
20use crate::platform_setup::StaticRoutesPolicy;
21use crate::{SetupEngine, SetupMode, bundle, resolve_env};
22
23pub fn setup(args: BundleSetupArgs, i18n: &CliI18n) -> Result<()> {
25 setup_or_update(args, SetupMode::Create, i18n)
26}
27
28pub fn update(args: BundleSetupArgs, i18n: &CliI18n) -> Result<()> {
30 setup_or_update(args, SetupMode::Update, i18n)
31}
32
33pub fn setup_status(args: BundleSetupStatusArgs, _i18n: &CliI18n) -> Result<()> {
35 let bundle_dir = resolve_bundle_dir(args.bundle)?;
36 bundle::validate_bundle_exists(&bundle_dir)?;
37 let discovered = crate::discovery::discover(&bundle_dir)?;
38 let provider = discovered
39 .find_setup_target(&args.provider_id)
40 .ok_or_else(|| anyhow::anyhow!("provider not found in bundle: {}", args.provider_id))?;
41 if let Some(machine) = crate::setup_machine::load_setup_machine_from_pack(&provider.pack_path)?
42 {
43 let team = args.team.as_deref().unwrap_or("default");
44 let state = crate::setup_machine::load_or_init_setup_machine_state(
45 &bundle_dir,
46 &provider.provider_id,
47 &args.tenant,
48 team,
49 &machine,
50 )?;
51 let status = crate::setup_machine::render_setup_machine_status(&machine, &state);
52 if args.format == "json" {
53 println!("{}", serde_json::to_string_pretty(&status)?);
54 } else {
55 println!("provider: {}", provider.provider_id);
56 println!("tenant: {}", args.tenant);
57 println!("team: {}", team);
58 println!(
59 "status: {}",
60 status
61 .get("status")
62 .and_then(serde_json::Value::as_str)
63 .unwrap_or("unknown")
64 );
65 println!(
66 "next: {}",
67 status
68 .get("current_step")
69 .and_then(serde_json::Value::as_str)
70 .unwrap_or("complete")
71 );
72 }
73 return Ok(());
74 }
75 let contract = crate::setup_machine::load_setup_backend_contract_from_pack(
76 &provider.pack_path,
77 Some(&provider.provider_id),
78 )?
79 .ok_or_else(|| {
80 anyhow::anyhow!(
81 "provider {} does not declare greentic.setup.backend-contract.v1",
82 provider.provider_id
83 )
84 })?;
85 let team = args.team.as_deref().unwrap_or("default");
86 let stored = crate::setup_backend_contract::load_backend_state(
87 &bundle_dir,
88 &args.env,
89 &args.tenant,
90 team,
91 &provider.provider_id,
92 )?;
93 let status = crate::setup_backend_contract::render_status(&contract, &stored);
94
95 if args.format == "json" {
96 println!("{}", serde_json::to_string_pretty(&status)?);
97 } else {
98 println!("provider: {}", provider.provider_id);
99 println!("tenant: {}", args.tenant);
100 println!("team: {}", team);
101 println!(
102 "status: {}",
103 if status.get("ok").and_then(serde_json::Value::as_bool) == Some(true) {
104 "complete"
105 } else {
106 "incomplete"
107 }
108 );
109 println!(
110 "next: {}",
111 status
112 .get("next")
113 .and_then(serde_json::Value::as_str)
114 .unwrap_or("unknown")
115 );
116 if let Some(items) = status.get("items").and_then(serde_json::Value::as_array) {
117 for item in items {
118 println!(
119 " - {} [{}]",
120 item.get("id")
121 .and_then(serde_json::Value::as_str)
122 .unwrap_or("unknown"),
123 item.get("state")
124 .and_then(serde_json::Value::as_str)
125 .unwrap_or("unknown")
126 );
127 }
128 }
129 if let Some(blocked) = status
130 .get("blocked")
131 .and_then(serde_json::Value::as_object)
132 .filter(|blocked| !blocked.is_empty())
133 {
134 println!(
135 "blocked: {}",
136 blocked
137 .get("summary")
138 .and_then(serde_json::Value::as_str)
139 .unwrap_or("Setup step blocked")
140 );
141 if blocked
142 .get("retryable")
143 .and_then(serde_json::Value::as_bool)
144 .unwrap_or(false)
145 {
146 println!("retryable: true");
147 }
148 }
149 }
150
151 Ok(())
152}
153
154pub fn setup_next(args: BundleSetupNextArgs, _i18n: &CliI18n) -> Result<()> {
156 let bundle_dir = resolve_bundle_dir(args.bundle)?;
157 bundle::validate_bundle_exists(&bundle_dir)?;
158 let discovered = crate::discovery::discover(&bundle_dir)?;
159 let provider = discovered
160 .find_setup_target(&args.provider_id)
161 .ok_or_else(|| anyhow::anyhow!("provider not found in bundle: {}", args.provider_id))?;
162 if let Some(machine) = crate::setup_machine::load_setup_machine_from_pack(&provider.pack_path)?
163 {
164 let team = args.team.as_deref().unwrap_or("default");
165 let output = crate::setup_machine::advance_setup_machine_with_pack(
166 &bundle_dir,
167 Some(&provider.pack_path),
168 &provider.provider_id,
169 &args.tenant,
170 team,
171 &machine,
172 args.dry_run,
173 )?;
174 print_setup_next_output(&output, &args.format)?;
175 return Ok(());
176 }
177 let contract = crate::setup_machine::load_setup_backend_contract_from_pack(
178 &provider.pack_path,
179 Some(&provider.provider_id),
180 )?
181 .ok_or_else(|| {
182 anyhow::anyhow!(
183 "provider {} does not declare greentic.setup.backend-contract.v1",
184 provider.provider_id
185 )
186 })?;
187 let team = args.team.as_deref().unwrap_or("default");
188 let mut stored = crate::setup_backend_contract::load_backend_state(
189 &bundle_dir,
190 &args.env,
191 &args.tenant,
192 team,
193 &provider.provider_id,
194 )?;
195 let status = crate::setup_backend_contract::render_status(&contract, &stored);
196 let Some(next_step) = crate::setup_backend_contract::next_action_id(&contract, &stored) else {
197 let output = serde_json::json!({
198 "provider_id": provider.provider_id,
199 "tenant": args.tenant,
200 "team": team,
201 "ok": true,
202 "step": "complete",
203 "next": "Setup complete.",
204 "status": status,
205 });
206 print_setup_next_output(&output, &args.format)?;
207 return Ok(());
208 };
209 let action = crate::setup_backend_contract::action_by_id(&contract, &next_step)
210 .ok_or_else(|| anyhow::anyhow!("setup step not found in contract: {next_step}"))?;
211 let executor_kind =
212 crate::setup_backend_contract::executor_kind(action).unwrap_or("unsupported");
213 let result = match executor_kind {
214 "oauth_device_code"
215 if crate::setup_backend_contract::oauth_device_login_started(&stored, action) =>
216 {
217 crate::setup_backend_contract::execute_oauth_device_code_complete(&mut stored, action)?
218 }
219 "oauth_device_code" => {
220 crate::setup_backend_contract::execute_oauth_device_code_start(&mut stored, action)?
221 }
222 "provider_http"
223 if action
224 .get("executor")
225 .and_then(|executor| {
226 executor
227 .get("path_template")
228 .or_else(|| executor.get("target_path_template"))
229 })
230 .is_some() =>
231 {
232 crate::setup_backend_contract::execute_provider_http_local_route(
233 &bundle_dir,
234 &mut stored,
235 &provider.provider_id,
236 &args.tenant,
237 team,
238 &args.env,
239 action,
240 )?
241 }
242 "provider_http" => crate::setup_backend_contract::execute_provider_http_external(
243 &mut stored,
244 &provider.provider_id,
245 &args.tenant,
246 team,
247 &args.env,
248 action,
249 )?,
250 "runtime_observation" => {
251 crate::setup_backend_contract::execute_runtime_observation(&mut stored, action)?
252 }
253 "microsoft_graph_application" => {
254 crate::setup_backend_contract::execute_microsoft_graph_application(
255 &mut stored,
256 &provider.provider_id,
257 action,
258 )?
259 }
260 "microsoft_graph_teams_app_catalog_publish" => {
261 crate::setup_backend_contract::execute_microsoft_graph_teams_app_catalog_publish(
262 &provider.pack_path,
263 &mut stored,
264 &args.tenant,
265 team,
266 &args.env,
267 action,
268 )?
269 }
270 "microsoft_graph_teams_app_user_install" => {
271 crate::setup_backend_contract::execute_microsoft_graph_teams_app_user_install(
272 &mut stored,
273 &args.tenant,
274 team,
275 &args.env,
276 action,
277 )?
278 }
279 _ => crate::setup_backend_contract::step_result(
280 action,
281 false,
282 "Use a greentic-setup build or provider-specific setup runner that supports this executor.",
283 serde_json::json!({
284 "ok": false,
285 "blocked": true,
286 "error": "executor_not_available_in_cli",
287 "executor_kind": executor_kind,
288 "detail": "generic CLI setup-next records resumable state and diagnostics; this executor is not supported by this CLI build",
289 }),
290 ),
291 };
292
293 let event_path = if args.dry_run {
294 None
295 } else {
296 Some(crate::setup_backend_contract::record_action_result(
297 &bundle_dir,
298 &args.tenant,
299 team,
300 &provider.provider_id,
301 &mut stored,
302 result.clone(),
303 )?)
304 };
305 let output = serde_json::json!({
306 "provider_id": provider.provider_id,
307 "tenant": args.tenant,
308 "team": team,
309 "dry_run": args.dry_run,
310 "step": next_step,
311 "executor_kind": executor_kind,
312 "result": result,
313 "event_path": event_path,
314 "status": status,
315 });
316 print_setup_next_output(&output, &args.format)?;
317 Ok(())
318}
319
320pub fn setup_retry(args: BundleSetupRetryArgs, _i18n: &CliI18n) -> Result<()> {
322 let bundle_dir = resolve_bundle_dir(args.bundle)?;
323 bundle::validate_bundle_exists(&bundle_dir)?;
324 let discovered = crate::discovery::discover(&bundle_dir)?;
325 let provider = discovered
326 .find_setup_target(&args.provider_id)
327 .ok_or_else(|| anyhow::anyhow!("provider not found in bundle: {}", args.provider_id))?;
328 if let Some(machine) = crate::setup_machine::load_setup_machine_from_pack(&provider.pack_path)?
329 {
330 let team = args.team.as_deref().unwrap_or("default");
331 let output = crate::setup_machine::retry_setup_machine_step(
332 &bundle_dir,
333 &provider.provider_id,
334 &args.tenant,
335 team,
336 &machine,
337 args.step.as_deref(),
338 )?;
339 if args.json {
340 println!("{}", serde_json::to_string_pretty(&output)?);
341 } else {
342 println!("provider: {}", output["provider_id"].as_str().unwrap_or(""));
343 println!("tenant: {}", output["tenant"].as_str().unwrap_or(""));
344 println!("team: {}", output["team"].as_str().unwrap_or(""));
345 println!("retry: {}", output["step"].as_str().unwrap_or(""));
346 println!("changed: {}", output["changed"].as_bool().unwrap_or(false));
347 if let Some(path) = output.get("event_path").and_then(serde_json::Value::as_str) {
348 println!("event: {path}");
349 }
350 }
351 return Ok(());
352 }
353 let contract = crate::setup_machine::load_setup_backend_contract_from_pack(
354 &provider.pack_path,
355 Some(&provider.provider_id),
356 )?
357 .ok_or_else(|| {
358 anyhow::anyhow!(
359 "provider {} does not declare greentic.setup.backend-contract.v1",
360 provider.provider_id
361 )
362 })?;
363 let team = args.team.as_deref().unwrap_or("default");
364 let mut stored = crate::setup_backend_contract::load_backend_state(
365 &bundle_dir,
366 &args.env,
367 &args.tenant,
368 team,
369 &provider.provider_id,
370 )?;
371 let selected_step = args
372 .step
373 .as_deref()
374 .or_else(|| {
375 stored
376 .get("last_setup_result")
377 .and_then(|result| result.get("step"))
378 .and_then(serde_json::Value::as_str)
379 })
380 .map(str::to_string)
381 .ok_or_else(|| anyhow::anyhow!("no setup step to retry; pass --step <step-id>"))?;
382 let action = crate::setup_backend_contract::action_by_id(&contract, &selected_step)
383 .ok_or_else(|| anyhow::anyhow!("setup step not found in contract: {selected_step}"))?;
384
385 let mut changed = false;
386 if stored
387 .get("last_setup_result")
388 .and_then(|result| result.get("step"))
389 .and_then(serde_json::Value::as_str)
390 == Some(selected_step.as_str())
391 {
392 stored.remove("last_setup_result");
393 changed = true;
394 }
395 if stored
396 .get("oauth_resume")
397 .and_then(|resume| resume.get("resume_step"))
398 .and_then(serde_json::Value::as_str)
399 == Some(selected_step.as_str())
400 {
401 stored.remove("oauth_resume");
402 changed = true;
403 }
404 crate::setup_backend_contract::save_backend_state(
405 &bundle_dir,
406 &args.tenant,
407 team,
408 &provider.provider_id,
409 &stored,
410 )?;
411 let event_path = crate::setup_backend_contract::append_backend_event(
412 &bundle_dir,
413 &args.tenant,
414 team,
415 &provider.provider_id,
416 serde_json::json!({
417 "type": "step_retry_requested",
418 "step": selected_step,
419 "executor_kind": crate::setup_backend_contract::executor_kind(action),
420 "changed": changed,
421 }),
422 )?;
423 let status = crate::setup_backend_contract::render_status(&contract, &stored);
424 let output = serde_json::json!({
425 "provider_id": provider.provider_id,
426 "tenant": args.tenant,
427 "team": team,
428 "step": selected_step,
429 "changed": changed,
430 "event_path": event_path,
431 "status": status,
432 });
433 if args.json {
434 println!("{}", serde_json::to_string_pretty(&output)?);
435 } else {
436 println!("provider: {}", output["provider_id"].as_str().unwrap_or(""));
437 println!("tenant: {}", output["tenant"].as_str().unwrap_or(""));
438 println!("team: {}", output["team"].as_str().unwrap_or(""));
439 println!("retry: {}", output["step"].as_str().unwrap_or(""));
440 println!("changed: {}", output["changed"].as_bool().unwrap_or(false));
441 println!("event: {}", event_path.display());
442 }
443 Ok(())
444}
445
446pub fn setup_reset(args: BundleSetupResetArgs, _i18n: &CliI18n) -> Result<()> {
448 if !args.yes {
449 bail!("refusing to reset setup state without --yes");
450 }
451 let bundle_dir = resolve_bundle_dir(args.bundle)?;
452 bundle::validate_bundle_exists(&bundle_dir)?;
453 let team = args.team.as_deref().unwrap_or("default");
454 if let Ok(discovered) = crate::discovery::discover(&bundle_dir)
455 && let Some(provider) = discovered.find_setup_target(&args.provider_id)
456 && let Some(_machine) =
457 crate::setup_machine::load_setup_machine_from_pack(&provider.pack_path)?
458 {
459 let archive_path = crate::setup_machine::reset_setup_machine_state(
460 &bundle_dir,
461 &args.tenant,
462 team,
463 &provider.provider_id,
464 "manual-reset",
465 )?;
466 let output = serde_json::json!({
467 "provider_id": provider.provider_id,
468 "tenant": args.tenant,
469 "team": team,
470 "reset": true,
471 "archive_path": archive_path,
472 "setup_model": "setup_machine",
473 });
474 if args.json {
475 println!("{}", serde_json::to_string_pretty(&output)?);
476 } else {
477 println!("provider: {}", output["provider_id"].as_str().unwrap_or(""));
478 println!("tenant: {}", output["tenant"].as_str().unwrap_or(""));
479 println!("team: {}", output["team"].as_str().unwrap_or(""));
480 match output
481 .get("archive_path")
482 .and_then(serde_json::Value::as_str)
483 {
484 Some(path) => println!("archive: {path}"),
485 None => println!("archive: none"),
486 }
487 }
488 return Ok(());
489 }
490 let archive_path = crate::setup_backend_contract::reset_backend_state(
491 &bundle_dir,
492 &args.tenant,
493 team,
494 &args.provider_id,
495 "manual-reset",
496 )?;
497 let output = serde_json::json!({
498 "provider_id": args.provider_id,
499 "tenant": args.tenant,
500 "team": team,
501 "reset": true,
502 "archive_path": archive_path,
503 });
504 if args.json {
505 println!("{}", serde_json::to_string_pretty(&output)?);
506 } else {
507 println!("provider: {}", output["provider_id"].as_str().unwrap_or(""));
508 println!("tenant: {}", output["tenant"].as_str().unwrap_or(""));
509 println!("team: {}", output["team"].as_str().unwrap_or(""));
510 match output
511 .get("archive_path")
512 .and_then(serde_json::Value::as_str)
513 {
514 Some(path) => println!("archive: {path}"),
515 None => println!("archive: none"),
516 }
517 }
518 Ok(())
519}
520
521fn print_setup_next_output(output: &serde_json::Value, format: &str) -> Result<()> {
522 if format == "json" {
523 println!("{}", serde_json::to_string_pretty(output)?);
524 return Ok(());
525 }
526 println!(
527 "provider: {}",
528 output
529 .get("provider_id")
530 .and_then(serde_json::Value::as_str)
531 .unwrap_or("")
532 );
533 println!(
534 "tenant: {}",
535 output
536 .get("tenant")
537 .and_then(serde_json::Value::as_str)
538 .unwrap_or("")
539 );
540 println!(
541 "team: {}",
542 output
543 .get("team")
544 .and_then(serde_json::Value::as_str)
545 .unwrap_or("")
546 );
547 println!(
548 "next: {}",
549 output
550 .get("step")
551 .and_then(serde_json::Value::as_str)
552 .unwrap_or("unknown")
553 );
554 if let Some(kind) = output
555 .get("executor_kind")
556 .and_then(serde_json::Value::as_str)
557 {
558 println!("executor: {kind}");
559 }
560 if let Some(next) = output
561 .get("result")
562 .and_then(|result| result.get("next"))
563 .and_then(serde_json::Value::as_str)
564 {
565 println!("action: {next}");
566 }
567 if let Some(path) = output.get("event_path").and_then(serde_json::Value::as_str) {
568 println!("event: {path}");
569 }
570 Ok(())
571}
572
573pub fn setup_migrate(args: BundleSetupMigrateArgs, _i18n: &CliI18n) -> Result<()> {
575 let bundle_dir = resolve_bundle_dir(args.bundle)?;
576 bundle::validate_bundle_exists(&bundle_dir)?;
577 let discovered = crate::discovery::discover(&bundle_dir)?;
578 let provider = discovered
579 .find_setup_target(&args.provider_id)
580 .ok_or_else(|| anyhow::anyhow!("provider not found in bundle: {}", args.provider_id))?;
581 let team = args.team.as_deref().unwrap_or("default");
582 if let Some(machine) = crate::setup_machine::load_setup_machine_from_pack(&provider.pack_path)?
583 {
584 let migration = crate::setup_machine::migrate_setup_machine_state(
585 &bundle_dir,
586 &args.env,
587 &args.tenant,
588 team,
589 &provider.provider_id,
590 &machine,
591 )?;
592 let result = serde_json::json!({
593 "provider_id": provider.provider_id,
594 "tenant": args.tenant,
595 "team": team,
596 "machine_id": machine.id,
597 "legacy_backend_path": migration.legacy_backend_path.display().to_string(),
598 "legacy_setup_actions_path": migration.legacy_setup_actions_path.display().to_string(),
599 "state_path": migration.state_path.display().to_string(),
600 "backend_archive_path": migration.backend_archive_path.as_ref().map(|path| path.display().to_string()),
601 "setup_actions_archive_path": migration.setup_actions_archive_path.as_ref().map(|path| path.display().to_string()),
602 "event_path": migration.event_path.display().to_string(),
603 "source": migration.source.clone(),
604 "legacy_backend_removed": migration.legacy_backend_removed,
605 "setup_actions_removed": migration.setup_actions_removed,
606 "initialized": migration.initialized,
607 "migrated": true,
608 "empty": migration.empty,
609 });
610
611 if args.json {
612 println!("{}", serde_json::to_string_pretty(&result)?);
613 } else {
614 println!("provider: {}", result["provider_id"].as_str().unwrap_or(""));
615 println!("tenant: {}", result["tenant"].as_str().unwrap_or(""));
616 println!("team: {}", result["team"].as_str().unwrap_or(""));
617 println!("machine: {}", result["machine_id"].as_str().unwrap_or(""));
618 println!("state: {}", migration.state_path.display());
619 println!("event: {}", migration.event_path.display());
620 println!("source: {}", migration.source);
621 if let Some(path) = result
622 .get("backend_archive_path")
623 .and_then(serde_json::Value::as_str)
624 {
625 println!("legacy backend archive: {path}");
626 }
627 if migration.legacy_backend_removed {
628 println!("legacy backend: removed");
629 }
630 if let Some(path) = result
631 .get("setup_actions_archive_path")
632 .and_then(serde_json::Value::as_str)
633 {
634 println!("setup-actions archive: {path}");
635 }
636 if migration.setup_actions_removed {
637 println!("setup-actions: removed");
638 }
639 if migration.empty {
640 println!("note: no existing setup state was found; initialized machine state");
641 }
642 }
643 return Ok(());
644 }
645 let migration = crate::setup_backend_contract::migrate_backend_state(
646 &bundle_dir,
647 &args.env,
648 &args.tenant,
649 team,
650 &args.provider_id,
651 )?;
652
653 let result = serde_json::json!({
654 "provider_id": args.provider_id,
655 "tenant": args.tenant,
656 "team": team,
657 "legacy_path": migration.legacy_path.display().to_string(),
658 "legacy_setup_actions_path": migration.legacy_setup_actions_path.display().to_string(),
659 "state_path": migration.state_path.display().to_string(),
660 "archive_path": migration.archive_path.as_ref().map(|path| path.display().to_string()),
661 "setup_actions_archive_path": migration.setup_actions_archive_path.as_ref().map(|path| path.display().to_string()),
662 "event_path": migration.event_path.display().to_string(),
663 "source": migration.source.clone(),
664 "legacy_removed": migration.legacy_removed,
665 "setup_actions_removed": migration.setup_actions_removed,
666 "migrated": true,
667 "empty": migration.empty,
668 });
669
670 if args.json {
671 println!("{}", serde_json::to_string_pretty(&result)?);
672 } else {
673 println!("provider: {}", result["provider_id"].as_str().unwrap_or(""));
674 println!("tenant: {}", result["tenant"].as_str().unwrap_or(""));
675 println!("team: {}", result["team"].as_str().unwrap_or(""));
676 println!("state: {}", migration.state_path.display());
677 println!("event: {}", migration.event_path.display());
678 println!("source: {}", migration.source);
679 if let Some(path) = result
680 .get("archive_path")
681 .and_then(serde_json::Value::as_str)
682 {
683 println!("archive: {path}");
684 }
685 if migration.legacy_removed {
686 println!("legacy: removed");
687 }
688 if let Some(path) = result
689 .get("setup_actions_archive_path")
690 .and_then(serde_json::Value::as_str)
691 {
692 println!("setup-actions archive: {path}");
693 }
694 if migration.setup_actions_removed {
695 println!("setup-actions: removed");
696 }
697 if migration.empty {
698 println!("note: no existing setup state was found; wrote an empty state file");
699 }
700 }
701
702 Ok(())
703}
704
705fn setup_or_update(args: BundleSetupArgs, mode: SetupMode, i18n: &CliI18n) -> Result<()> {
707 let bundle_dir = resolve_bundle_dir(args.bundle)?;
708 let BundleSetupArgs {
709 provider_id,
710 bundle: _,
711 tenant: cli_tenant,
712 team: cli_team,
713 env: cli_env,
714 domain,
715 dry_run,
716 emit_answers,
717 answers,
718 key,
719 non_interactive,
720 advanced,
721 parallel,
722 backup,
723 skip_secrets_init,
724 best_effort,
725 } = args;
726
727 let cli_env = resolve_env(Some(&cli_env));
731
732 bundle::validate_bundle_exists(&bundle_dir).context(i18n.t("cli.error.invalid_bundle"))?;
733
734 bootstrap_local_environment(i18n)?;
735
736 let provider_display = provider_id.clone().unwrap_or_else(|| "all".to_string());
737
738 let header_key = match mode {
739 SetupMode::Update => "cli.bundle.update.updating",
740 _ => "cli.bundle.setup.setting_up",
741 };
742 println!("{}", i18n.t(header_key));
743 println!(
744 "{}",
745 i18n.tf("cli.bundle.setup.provider", &[&provider_display])
746 );
747 println!(
748 "{}",
749 i18n.tf(
750 "cli.bundle.add.bundle",
751 &[&bundle_dir.display().to_string()]
752 )
753 );
754 let loader_engine = SetupEngine::new(SetupConfig {
755 tenant: cli_tenant.clone(),
756 team: cli_team.clone(),
757 env: cli_env.clone(),
758 offline: false,
759 verbose: true,
760 });
761
762 let loaded_answers = if let Some(answers_path) = &answers {
763 loader_engine
764 .load_answers(answers_path, key.as_deref(), !non_interactive)
765 .context(i18n.t("cli.error.failed_read_answers"))?
766 } else if emit_answers.is_some() {
767 LoadedAnswers::default()
768 } else if non_interactive {
769 bail!("{}", i18n.t("cli.error.answers_required"));
770 } else {
771 println!("\n{}", i18n.t("cli.simple.interactive_mode"));
772 println!();
773 run_interactive_wizard(
774 &bundle_dir,
775 &cli_tenant,
776 cli_team.as_deref(),
777 &cli_env,
778 advanced,
779 )?
780 };
781 let (tenant, team, env) = if answers.is_some() {
782 resolve_setup_scope(cli_tenant, cli_team, cli_env, &loaded_answers)
783 } else {
784 (cli_tenant, cli_team, cli_env)
785 };
786
787 println!("{}", i18n.tf("cli.bundle.add.tenant", &[&tenant]));
788 println!(
789 "{}",
790 i18n.tf(
791 "cli.bundle.add.team",
792 &[team.as_deref().unwrap_or("default")]
793 )
794 );
795 println!("{}", i18n.tf("cli.bundle.add.env", &[&env]));
796 println!("{}", i18n.tf("cli.bundle.setup.domain", &[&domain]));
797
798 let mut loaded_answers = if answers.is_some() {
799 complete_loaded_answers_with_prompts(
800 &bundle_dir,
801 &tenant,
802 team.as_deref(),
803 &env,
804 advanced,
805 non_interactive,
806 loaded_answers,
807 )?
808 } else {
809 loaded_answers
810 };
811 if non_interactive {
812 ensure_deployment_targets_present(&bundle_dir, &loaded_answers)?;
813 }
814
815 let is_dry_run = dry_run || emit_answers.is_some();
816 let _setup_tunnel = if !is_dry_run {
817 let local_base_url = crate::platform_setup::load_runtime_local_base_url(
825 &bundle_dir,
826 &tenant,
827 team.as_deref(),
828 )
829 .ok()
830 .flatten()
831 .unwrap_or_else(|| "http://127.0.0.1:1".to_string());
832 let tunnel =
833 maybe_start_cli_setup_tunnel(&bundle_dir, &mut loaded_answers, &local_base_url)
834 .context("failed to start setup tunnel")?;
835 if let Some(tunnel) = tunnel.as_ref() {
836 println!("Setup tunnel public_base_url: {}", tunnel.public_base_url);
837 }
838 tunnel
839 } else {
840 None
841 };
842 if non_interactive {
843 ensure_required_setup_answers_present(&bundle_dir, &loaded_answers)
844 .context("Missing required answers in --non-interactive mode")?;
845 }
846
847 let providers = provider_id.clone().map_or_else(Vec::new, |id| vec![id]);
848
849 let request = SetupRequest {
850 bundle: bundle_dir.clone(),
851 bundle_name: crate::bundle::read_bundle_name(&bundle_dir).ok().flatten(),
852 providers,
853 tenants: vec![TenantSelection {
854 tenant: tenant.clone(),
855 team: team.clone(),
856 allow_paths: Vec::new(),
857 }],
858 static_routes: StaticRoutesPolicy::normalize(
859 loaded_answers.platform_setup.static_routes.as_ref(),
860 &env,
861 )
862 .context(i18n.t("cli.error.failed_read_answers"))?,
863 deployment_targets: loaded_answers.platform_setup.deployment_targets,
864 tunnel: loaded_answers.platform_setup.tunnel,
865 telemetry: loaded_answers.platform_setup.telemetry,
866 setup_answers: loaded_answers.setup_answers,
867 domain_filter: if domain == "all" {
868 None
869 } else {
870 Some(domain.clone())
871 },
872 parallel,
873 backup,
874 skip_secrets_init,
875 best_effort,
876 ..Default::default()
877 };
878
879 let engine = SetupEngine::new(SetupConfig {
880 tenant: tenant.clone(),
881 team: team.clone(),
882 env: env.clone(),
883 offline: false,
884 verbose: true,
885 });
886
887 let plan = engine
888 .plan(mode, &request, is_dry_run)
889 .context(i18n.t("cli.error.failed_build_plan"))?;
890
891 engine.print_plan(&plan);
892
893 if let Some(emit_path) = &emit_answers {
894 let emit_path_str = emit_path.display().to_string();
895 engine
896 .emit_answers(&plan, emit_path, key.as_deref(), !non_interactive)
897 .context(i18n.t("cli.error.failed_emit_answers"))?;
898 println!(
899 "\n{}",
900 i18n.tf("cli.bundle.setup.emit_written", &[&emit_path_str])
901 );
902 let usage_key = match mode {
903 SetupMode::Update => "cli.bundle.update.emit_usage",
904 _ => "cli.bundle.setup.emit_usage",
905 };
906 println!("{}", i18n.tf(usage_key, &[&emit_path_str]));
907 return Ok(());
908 }
909
910 if dry_run {
911 let dry_key = match mode {
912 SetupMode::Update => "cli.bundle.update.dry_run",
913 _ => "cli.bundle.setup.dry_run",
914 };
915 println!("\n{}", i18n.tf(dry_key, &[&provider_display]));
916 return Ok(());
917 }
918
919 let report = engine
920 .execute(&plan)
921 .context(i18n.t("cli.error.failed_execute_plan"))?;
922 print_pending_setup_actions(&report.pending_setup_actions);
923 let _ = (non_interactive, env);
924
925 let done_key = match mode {
926 SetupMode::Update => "cli.bundle.update.complete",
927 _ => "cli.bundle.setup.complete",
928 };
929 println!("\n{}", i18n.tf(done_key, &[&provider_display]));
930
931 Ok(())
932}
933
934pub fn print_pending_setup_actions(actions: &[crate::setup_actions::SetupAction]) {
935 let visible_actions: Vec<_> = actions
936 .iter()
937 .filter(|action| {
938 matches!(
939 action.kind,
940 crate::setup_actions::SetupActionKind::OauthInstallButton
941 | crate::setup_actions::SetupActionKind::OpenUrl
942 ) && action.status == crate::setup_actions::SetupActionStatus::Pending
943 })
944 .collect();
945 if visible_actions.is_empty() {
946 return;
947 }
948
949 println!();
950 for action in visible_actions {
951 match action.kind {
952 crate::setup_actions::SetupActionKind::OauthInstallButton => {
953 if let Some(url) = action.authorize_url.as_deref() {
954 println!("{url}");
955 }
956 if action.callback_path.is_some() {
957 println!(
958 "After completing the OAuth flow, re-run setup if the callback was not handled automatically."
959 );
960 }
961 }
962 crate::setup_actions::SetupActionKind::OpenUrl => {
963 if let Some(url) = action.extra.get("url").and_then(serde_json::Value::as_str) {
964 println!("{url}");
965 }
966 }
967 _ => {}
968 }
969 println!();
970 }
971}
972
973pub(crate) fn bootstrap_local_environment(i18n: &CliI18n) -> Result<()> {
980 let root = LocalFsStore::default_root()
981 .context("Cannot determine default environment store root (no home directory).")?;
982 let store = LocalFsStore::new(root.clone());
983 let (_env, outcome) = ensure_local_environment(&store, None)
988 .with_context(|| format!("Bootstrapping `local` environment at {}", root.display()))?;
989 if outcome == LocalEnvOutcome::Created {
990 println!(
991 "{}",
992 i18n.tf(
993 "cli.bundle.setup.env_bootstrap_created",
994 &[&root.display().to_string()]
995 )
996 );
997 }
998 Ok(())
999}
1000
1001pub fn wait_for_pending_oauth_callbacks(
1002 server: Option<crate::no_ui_oauth::NoUiOAuthCallbackServer>,
1003 actions: &[crate::setup_actions::SetupAction],
1004) -> Result<()> {
1005 let pending = crate::no_ui_oauth::pending_oauth_install_actions(actions);
1006 if pending.is_empty() {
1007 return Ok(());
1008 }
1009 let Some(server) = server else {
1010 return Ok(());
1011 };
1012 println!("Waiting for OAuth callback...");
1013 let message = server.wait_for_callback()?;
1014 println!("{message}");
1015 Ok(())
1016}
1017
1018pub fn execute_pending_oauth_device_actions(
1019 bundle_dir: &std::path::Path,
1020 env: &str,
1021 actions: &[crate::setup_actions::SetupAction],
1022) -> Result<()> {
1023 for action in actions {
1024 if action.kind != crate::setup_actions::SetupActionKind::OauthDeviceCode
1025 || action.status != crate::setup_actions::SetupActionStatus::Pending
1026 {
1027 continue;
1028 }
1029 println!("Starting {}...", action.label);
1030 let start = crate::oauth_device::start_oauth_device_code(
1031 bundle_dir,
1032 &crate::oauth_device::OAuthDeviceStartInput {
1033 provider_id: action.provider_id.clone(),
1034 tenant: action.tenant.clone(),
1035 team: action.team.clone(),
1036 action_id: action.id.clone(),
1037 },
1038 crate::oauth_device::DEFAULT_EXTENSION_KEY,
1039 )?;
1040 println!("Open {}", start.verification_uri);
1041 println!("Enter code: {}", start.user_code);
1042 print!("Press Enter after approving, or wait while setup polls...");
1043 io::stdout().flush().ok();
1044 let mut line = String::new();
1045 let _ = io::stdin().read_line(&mut line);
1046
1047 let runtime =
1048 tokio::runtime::Runtime::new().context("failed to create OAuth polling runtime")?;
1049 let mut interval = start.interval.max(1);
1050 loop {
1051 let report = runtime.block_on(crate::oauth_device::poll_oauth_device_code(
1052 bundle_dir,
1053 env,
1054 &crate::oauth_device::OAuthDevicePollInput {
1055 session_id: start.session_id.clone(),
1056 },
1057 crate::oauth_device::DEFAULT_EXTENSION_KEY,
1058 ))?;
1059 match report.status {
1060 crate::oauth_device::OAuthDevicePollStatus::Complete => {
1061 println!("OAuth device-code setup complete for {}.", action.label);
1062 break;
1063 }
1064 crate::oauth_device::OAuthDevicePollStatus::Pending
1065 | crate::oauth_device::OAuthDevicePollStatus::SlowDown => {
1066 interval = report.interval.unwrap_or(interval).max(1);
1067 if crate::setup_actions::current_epoch_secs() >= start.expires_at {
1068 bail!("OAuth device code expired before authorization completed");
1069 }
1070 thread::sleep(Duration::from_secs(interval.min(30)));
1071 }
1072 crate::oauth_device::OAuthDevicePollStatus::Failed => {
1073 if !report.checklist.is_empty() {
1074 println!("Checklist:");
1075 for item in &report.checklist {
1076 println!("- {item}");
1077 }
1078 }
1079 bail!(
1080 "{}",
1081 report
1082 .message
1083 .unwrap_or_else(|| "OAuth device-code setup failed".to_string())
1084 );
1085 }
1086 }
1087 }
1088 }
1089 Ok(())
1090}
1091
1092#[cfg(test)]
1093mod tests {
1094 use super::*;
1095 use std::sync::Mutex;
1096
1097 static HOME_LOCK: Mutex<()> = Mutex::new(());
1099
1100 fn with_home<R>(tmp: &std::path::Path, body: impl FnOnce() -> R) -> R {
1101 let _guard = HOME_LOCK.lock().unwrap_or_else(|e| e.into_inner());
1102 let prev = std::env::var_os("HOME");
1103 unsafe {
1107 std::env::set_var("HOME", tmp);
1108 }
1109 let out = body();
1110 unsafe {
1111 match prev {
1112 Some(v) => std::env::set_var("HOME", v),
1113 None => std::env::remove_var("HOME"),
1114 }
1115 }
1116 out
1117 }
1118
1119 #[test]
1120 fn bootstrap_creates_local_env_under_default_root() {
1121 let tmp = tempfile::TempDir::new().expect("tempdir");
1122 let i18n = CliI18n::from_request(Some("en")).expect("i18n");
1123 with_home(tmp.path(), || {
1124 bootstrap_local_environment(&i18n).expect("first bootstrap");
1125 });
1126 let env_file = tmp
1127 .path()
1128 .join(".greentic")
1129 .join("environments")
1130 .join("local")
1131 .join("environment.json");
1132 assert!(env_file.exists(), "expected env file at {env_file:?}");
1133 }
1134
1135 #[test]
1136 fn bootstrap_is_idempotent_across_calls() {
1137 let tmp = tempfile::TempDir::new().expect("tempdir");
1138 let i18n = CliI18n::from_request(Some("en")).expect("i18n");
1139 with_home(tmp.path(), || {
1140 bootstrap_local_environment(&i18n).expect("first bootstrap");
1141 bootstrap_local_environment(&i18n).expect("second bootstrap");
1142 });
1143 let env_file = tmp
1144 .path()
1145 .join(".greentic")
1146 .join("environments")
1147 .join("local")
1148 .join("environment.json");
1149 assert!(env_file.exists());
1150 }
1151}