Skip to main content

greentic_setup/cli_commands/
setup.rs

1//! Setup and update commands for bundle configuration.
2
3use 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
23/// Run the setup command.
24pub fn setup(args: BundleSetupArgs, i18n: &CliI18n) -> Result<()> {
25    setup_or_update(args, SetupMode::Create, i18n)
26}
27
28/// Run the update command.
29pub fn update(args: BundleSetupArgs, i18n: &CliI18n) -> Result<()> {
30    setup_or_update(args, SetupMode::Update, i18n)
31}
32
33/// Show persisted setup status for a provider backend contract.
34pub 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
154/// Inspect and record the next provider backend-contract setup step.
155pub 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
320/// Clear retry-blocking state for a provider backend-contract setup step.
321pub 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
446/// Reset persisted setup state for a provider backend contract.
447pub 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
573/// Migrate legacy setup backend state to the generic setup state location.
574pub 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
705/// Shared implementation for setup and update commands.
706fn 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    // A10: thread the env_id through the wizard surface as the canonical
728    // env id. resolve_env applies the A4b `dev` → `local` compat alias so
729    // a user passing `--env dev` doesn't slip past as a raw legacy string.
730    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        // Prefer the runtime's actual gateway port (persisted by a prior
818        // `gtc start`) so this setup pass keys into the SAME shared tunnel
819        // record (shared_tunnel.rs, keyed by local port) that the runtime
820        // already uses or will use — otherwise a bogus placeholder port
821        // spawns its own untracked tunnel, and the URL registered with
822        // providers like Slack never matches what the runtime actually
823        // serves once started.
824        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
973/// Idempotently auto-create the `local` Environment on first `gtc setup`.
974///
975/// Per A4 of `plans/next-gen-deployment.md`: every `gtc setup` (and update)
976/// invocation guarantees a `local` Environment exists with the five default
977/// capability-slot bindings (deployer/secrets/telemetry/sessions/state).
978/// Subsequent calls find the env on disk and stay silent.
979pub(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    // greentic-setup never seeds a `public_base_url` at bootstrap time; the
984    // operator sets it later via `gtc op env init --public-url <URL>` or
985    // `gtc op env set-public-url`. Passing `None` preserves prior behavior on
986    // both first-run and idempotent re-runs.
987    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    // `HOME` is process-global; serialize tests that mutate it.
1098    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        // SAFETY: serialized by HOME_LOCK; tests are single-threaded inside the
1104        // critical section. unsafe is required because set_var/remove_var are
1105        // marked unsafe in Rust 2024 edition.
1106        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}