Skip to main content

greentic_setup/
cli_args.rs

1//! CLI argument definitions for greentic-setup.
2
3use std::path::PathBuf;
4
5use clap::{Args, Parser, Subcommand};
6
7/// Tenant a command binds, deploys, or writes secrets under when the operator
8/// names none.
9///
10/// Must equal `greentic_types::DEFAULT_TENANT`. It is duplicated rather than
11/// imported because raising the greentic-types floor to 1.1.4 drags ten crates
12/// back to 1.1.0 — `greentic-session 1.1.1` requires greentic-types `=1.1.2`
13/// exactly. `every_tenant_arg_in_the_command_tree_uses_the_shared_constant`
14/// and the literal assertion below guard the copy; delete it in favour of the
15/// import once that pin is relaxed.
16///
17/// Was `demo`, which disagreed with greentic-deployer and produced
18/// environments serving deployments under two tenants — tolerated by a dev
19/// store, refused by the Vault activation gate.
20const DEFAULT_TENANT: &str = "default";
21/// Team half of [`DEFAULT_TENANT`]. This one never diverged.
22pub(crate) const DEFAULT_TEAM: &str = "default";
23
24#[derive(Parser, Debug)]
25#[command(name = "greentic-setup")]
26#[command(version)]
27#[command(about = "Greentic bundle setup CLI")]
28#[command(after_help = r#"EXAMPLES:
29  Interactive wizard:
30    greentic-setup ./my-bundle
31
32  Preview without executing:
33    greentic-setup --dry-run ./my-bundle
34
35  Generate answers template:
36    greentic-setup --dry-run --emit-answers answers.json ./my-bundle
37
38  Apply answers file:
39    greentic-setup --answers answers.json ./my-bundle.gtbundle
40
41  Deploy a bundle into an environment:
42    greentic-setup env-deploy ./my-bundle.gtbundle
43    greentic-setup env-deploy ./my-bundle.gtbundle --env staging
44    greentic-setup env-deploy --dry-run ./my-bundle.gtbundle
45
46  Add a messaging provider to an environment:
47    greentic-setup provider add telegram
48    greentic-setup provider add slack --env staging
49    greentic-setup provider add telegram --answers answers.json --non-interactive
50    greentic-setup provider list
51    greentic-setup provider remove <endpoint-id>
52
53  Advanced (bundle subcommands):
54    greentic-setup bundle init ./my-bundle
55    greentic-setup bundle add pack.gtpack --bundle ./my-bundle
56    greentic-setup bundle status --bundle ./my-bundle
57"#)]
58pub struct Cli {
59    /// Bundle path (.gtbundle file or directory)
60    #[arg(value_name = "BUNDLE")]
61    pub bundle: Option<PathBuf>,
62
63    /// Dry run - show wizard but don't execute
64    #[arg(long = "dry-run", global = true)]
65    pub dry_run: bool,
66
67    /// Emit answers template to file (combine with --dry-run to only generate)
68    #[arg(long = "emit-answers", value_name = "FILE", global = true)]
69    pub emit_answers: Option<PathBuf>,
70
71    /// Apply answers from file
72    #[arg(long = "answers", short = 'a', value_name = "FILE", global = true)]
73    pub answers: Option<PathBuf>,
74
75    /// Encryption/decryption key for answer documents that include secrets
76    #[arg(long = "key", value_name = "KEY", global = true)]
77    pub key: Option<String>,
78
79    /// Tenant identifier
80    #[arg(long = "tenant", short = 't', default_value = DEFAULT_TENANT, global = true)]
81    pub tenant: String,
82
83    /// Team identifier
84    #[arg(long = "team", global = true)]
85    pub team: Option<String>,
86
87    /// Environment (defaults to `local`; legacy `dev` remapped via the A4b
88    /// compat alias with a once-per-process warning until removal).
89    #[arg(long = "env", short = 'e', default_value = "local", global = true)]
90    pub env: String,
91
92    /// UI locale (BCP-47 tag, e.g., en, ja, id)
93    #[arg(long = "locale", global = true)]
94    pub locale: Option<String>,
95
96    /// Advanced mode — show all questions including optional ones
97    #[arg(long = "advanced", global = true)]
98    pub advanced: bool,
99
100    /// Launch web-based setup UI in browser (enabled by default).
101    /// Use --no-ui to disable the UI; stdin prompts may still be used.
102    #[arg(long = "ui", global = true, default_value_t = true)]
103    pub ui: bool,
104
105    /// Disable web UI; stdin prompts may still be used.
106    #[arg(long = "no-ui", global = true)]
107    pub no_ui: bool,
108
109    /// Strict non-interactive mode: no prompts, fail if answers incomplete
110    #[arg(long = "non-interactive", global = true)]
111    pub non_interactive: bool,
112
113    /// Ignore any cached state: clear the persisted env secrets store and the
114    /// bundle's cached setup-state / extraction dirs before running, so setup
115    /// rebuilds from scratch instead of reusing prior values.
116    #[arg(long = "new-cache", global = true)]
117    pub new_cache: bool,
118
119    #[command(subcommand)]
120    pub command: Option<Command>,
121}
122
123#[derive(Subcommand, Debug)]
124pub enum Command {
125    /// Diagnose bundle setup inputs and generated setup outputs
126    Doctor(DoctorArgs),
127    /// Deploy a bundle into an environment via the env-apply engine
128    EnvDeploy(EnvDeployArgs),
129    /// Manage messaging providers in an environment
130    #[command(subcommand)]
131    Provider(ProviderCommand),
132    /// Bundle lifecycle management (advanced)
133    #[command(subcommand)]
134    Bundle(Box<BundleCommand>),
135}
136
137#[derive(Args, Debug, Clone)]
138pub struct EnvDeployArgs {
139    /// Bundle path (.gtbundle file or bundle directory)
140    #[arg(value_name = "BUNDLE")]
141    pub bundle: PathBuf,
142}
143
144#[derive(Args, Debug, Clone)]
145pub struct DoctorArgs {
146    /// Bundle path (.gtbundle file or directory)
147    #[arg(value_name = "BUNDLE")]
148    pub bundle: Option<PathBuf>,
149    #[command(subcommand)]
150    pub command: Option<DoctorCommand>,
151    /// Emit stable machine-readable JSON
152    #[arg(long = "json")]
153    pub json: bool,
154    /// Treat warnings as command failures
155    #[arg(long = "strict")]
156    pub strict: bool,
157    /// Include fix hints in human-readable output
158    #[arg(long = "fix-hints")]
159    pub fix_hints: bool,
160    /// Show informational diagnostics in human-readable output
161    #[arg(long = "show-info")]
162    pub show_info: bool,
163    /// Limit checks to one stage
164    #[arg(long = "stage", value_enum)]
165    pub stage: Option<DoctorStageArg>,
166}
167
168#[derive(Subcommand, Debug, Clone)]
169pub enum DoctorCommand {
170    /// Validate a provider pack's setup contract
171    Provider(DoctorProviderArgs),
172}
173
174#[derive(Args, Debug, Clone)]
175pub struct DoctorProviderArgs {
176    /// Provider pack path (.gtpack)
177    #[arg(value_name = "PACK")]
178    pub pack: PathBuf,
179}
180
181#[derive(clap::ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
182pub enum DoctorStageArg {
183    Setup,
184    Cache,
185    Locks,
186    Answers,
187    Runtime,
188    Routes,
189}
190
191#[derive(Subcommand, Debug, Clone)]
192pub enum BundleCommand {
193    /// Initialize a new bundle directory
194    Init(BundleInitArgs),
195    /// Add a pack to a bundle
196    Add(BundleAddArgs),
197    /// Run setup flow for provider(s) in a bundle
198    Setup(BundleSetupArgs),
199    /// Update a provider's configuration in a bundle
200    Update(BundleSetupArgs),
201    /// Show persisted generic provider setup status
202    SetupStatus(BundleSetupStatusArgs),
203    /// Inspect and record the next generic provider setup step
204    SetupNext(BundleSetupNextArgs),
205    /// Clear retry-blocking state for a generic provider setup step
206    SetupRetry(BundleSetupRetryArgs),
207    /// Reset persisted generic provider setup state
208    SetupReset(BundleSetupResetArgs),
209    /// Migrate legacy provider setup state into the generic setup state layout
210    SetupMigrate(BundleSetupMigrateArgs),
211    /// Remove a provider from a bundle
212    Remove(BundleRemoveArgs),
213    /// Build a portable bundle (copy + resolve)
214    Build(BundleBuildArgs),
215    /// List packs or flows in a bundle
216    List(BundleListArgs),
217    /// Show bundle status
218    Status(BundleStatusArgs),
219}
220
221#[derive(Args, Debug, Clone)]
222pub struct BundleInitArgs {
223    /// Bundle directory (default: current directory)
224    #[arg(value_name = "PATH")]
225    pub path: Option<PathBuf>,
226    /// Bundle name
227    #[arg(long = "name", short = 'n')]
228    pub name: Option<String>,
229}
230
231#[derive(Args, Debug, Clone)]
232pub struct BundleAddArgs {
233    /// Pack reference (local path or OCI reference)
234    #[arg(value_name = "PACK_REF")]
235    pub pack_ref: String,
236    /// Bundle directory (default: current directory)
237    #[arg(long = "bundle", short = 'b')]
238    pub bundle: Option<PathBuf>,
239    /// Tenant identifier
240    #[arg(long = "tenant", short = 't', default_value = DEFAULT_TENANT)]
241    pub tenant: String,
242    /// Team identifier
243    #[arg(long = "team")]
244    pub team: Option<String>,
245    /// Environment (defaults to `local`; legacy `dev` remapped via the A4b
246    /// compat alias with a once-per-process warning until removal).
247    #[arg(long = "env", short = 'e', default_value = "local")]
248    pub env: String,
249    /// Dry run (don't actually add)
250    #[arg(long = "dry-run")]
251    pub dry_run: bool,
252}
253
254#[derive(Args, Debug, Clone)]
255pub struct BundleSetupArgs {
256    /// Provider ID to setup/update (optional, setup all if not specified)
257    #[arg(value_name = "PROVIDER_ID")]
258    pub provider_id: Option<String>,
259    /// Bundle directory (default: current directory)
260    #[arg(long = "bundle", short = 'b')]
261    pub bundle: Option<PathBuf>,
262    /// Answers file (JSON/YAML)
263    #[arg(long = "answers", short = 'a')]
264    pub answers: Option<PathBuf>,
265    /// Encryption/decryption key for answer documents that include secrets
266    #[arg(long = "key", value_name = "KEY")]
267    pub key: Option<String>,
268    /// Tenant identifier
269    #[arg(long = "tenant", short = 't', default_value = DEFAULT_TENANT)]
270    pub tenant: String,
271    /// Team identifier
272    #[arg(long = "team")]
273    pub team: Option<String>,
274    /// Environment (defaults to `local`; legacy `dev` remapped via the A4b
275    /// compat alias with a once-per-process warning until removal).
276    #[arg(long = "env", short = 'e', default_value = "local")]
277    pub env: String,
278    /// Filter by domain (messaging/events/secrets/oauth/all)
279    #[arg(long = "domain", short = 'd', default_value = "all")]
280    pub domain: String,
281    /// Number of parallel setup operations
282    #[arg(long = "parallel", default_value = "1")]
283    pub parallel: usize,
284    /// Backup existing config before setup
285    #[arg(long = "backup")]
286    pub backup: bool,
287    /// Skip secrets initialization
288    #[arg(long = "skip-secrets-init")]
289    pub skip_secrets_init: bool,
290    /// Continue on error (best effort)
291    #[arg(long = "best-effort")]
292    pub best_effort: bool,
293    /// Populated from the global --non-interactive flag before dispatch.
294    #[arg(skip)]
295    pub non_interactive: bool,
296    /// Dry run (plan only, don't execute)
297    #[arg(long = "dry-run")]
298    pub dry_run: bool,
299    /// Emit answers template JSON (use with --dry-run)
300    #[arg(long = "emit-answers")]
301    pub emit_answers: Option<PathBuf>,
302    /// Advanced mode — show all questions including optional ones
303    #[arg(long = "advanced")]
304    pub advanced: bool,
305}
306
307#[derive(Args, Debug, Clone)]
308pub struct BundleSetupStatusArgs {
309    /// Provider ID to inspect
310    #[arg(value_name = "PROVIDER_ID")]
311    pub provider_id: String,
312    /// Bundle directory (default: current directory)
313    #[arg(long = "bundle", short = 'b')]
314    pub bundle: Option<PathBuf>,
315    /// Tenant identifier
316    #[arg(long = "tenant", short = 't', default_value = DEFAULT_TENANT)]
317    pub tenant: String,
318    /// Team identifier
319    #[arg(long = "team")]
320    pub team: Option<String>,
321    /// Environment (dev/staging/prod)
322    #[arg(long = "env", short = 'e', default_value = "dev")]
323    pub env: String,
324    /// Output format: text or json
325    #[arg(long = "format", default_value = "text")]
326    pub format: String,
327}
328
329#[derive(Args, Debug, Clone)]
330pub struct BundleSetupNextArgs {
331    /// Provider ID to advance
332    #[arg(value_name = "PROVIDER_ID")]
333    pub provider_id: String,
334    /// Bundle directory (default: current directory)
335    #[arg(long = "bundle", short = 'b')]
336    pub bundle: Option<PathBuf>,
337    /// Tenant identifier
338    #[arg(long = "tenant", short = 't', default_value = DEFAULT_TENANT)]
339    pub tenant: String,
340    /// Team identifier
341    #[arg(long = "team")]
342    pub team: Option<String>,
343    /// Environment (dev/staging/prod)
344    #[arg(long = "env", short = 'e', default_value = "dev")]
345    pub env: String,
346    /// Output format: text or json
347    #[arg(long = "format", default_value = "text")]
348    pub format: String,
349    /// Only report the next action; do not write state or events
350    #[arg(long = "dry-run")]
351    pub dry_run: bool,
352}
353
354#[derive(Args, Debug, Clone)]
355pub struct BundleSetupRetryArgs {
356    /// Provider ID to retry
357    #[arg(value_name = "PROVIDER_ID")]
358    pub provider_id: String,
359    /// Bundle directory (default: current directory)
360    #[arg(long = "bundle", short = 'b')]
361    pub bundle: Option<PathBuf>,
362    /// Tenant identifier
363    #[arg(long = "tenant", short = 't', default_value = DEFAULT_TENANT)]
364    pub tenant: String,
365    /// Team identifier
366    #[arg(long = "team")]
367    pub team: Option<String>,
368    /// Environment (dev/staging/prod)
369    #[arg(long = "env", short = 'e', default_value = "dev")]
370    pub env: String,
371    /// Optional step to retry; defaults to the last recorded setup step
372    #[arg(long = "step")]
373    pub step: Option<String>,
374    /// Emit stable machine-readable JSON
375    #[arg(long = "json")]
376    pub json: bool,
377}
378
379#[derive(Args, Debug, Clone)]
380pub struct BundleSetupResetArgs {
381    /// Provider ID to reset
382    #[arg(value_name = "PROVIDER_ID")]
383    pub provider_id: String,
384    /// Bundle directory (default: current directory)
385    #[arg(long = "bundle", short = 'b')]
386    pub bundle: Option<PathBuf>,
387    /// Tenant identifier
388    #[arg(long = "tenant", short = 't', default_value = DEFAULT_TENANT)]
389    pub tenant: String,
390    /// Team identifier
391    #[arg(long = "team")]
392    pub team: Option<String>,
393    /// Confirm destructive reset of setup progress
394    #[arg(long = "yes")]
395    pub yes: bool,
396    /// Emit stable machine-readable JSON
397    #[arg(long = "json")]
398    pub json: bool,
399}
400
401#[derive(Args, Debug, Clone)]
402pub struct BundleSetupMigrateArgs {
403    /// Provider ID to migrate
404    #[arg(value_name = "PROVIDER_ID")]
405    pub provider_id: String,
406    /// Bundle directory (default: current directory)
407    #[arg(long = "bundle", short = 'b')]
408    pub bundle: Option<PathBuf>,
409    /// Tenant identifier
410    #[arg(long = "tenant", short = 't', default_value = DEFAULT_TENANT)]
411    pub tenant: String,
412    /// Team identifier
413    #[arg(long = "team")]
414    pub team: Option<String>,
415    /// Environment (dev/staging/prod)
416    #[arg(long = "env", short = 'e', default_value = "dev")]
417    pub env: String,
418    /// Emit stable machine-readable JSON
419    #[arg(long = "json")]
420    pub json: bool,
421}
422
423#[derive(Args, Debug, Clone)]
424pub struct BundleRemoveArgs {
425    /// Provider ID to remove
426    #[arg(value_name = "PROVIDER_ID")]
427    pub provider_id: String,
428    /// Bundle directory (default: current directory)
429    #[arg(long = "bundle", short = 'b')]
430    pub bundle: Option<PathBuf>,
431    /// Tenant identifier
432    #[arg(long = "tenant", short = 't', default_value = DEFAULT_TENANT)]
433    pub tenant: String,
434    /// Team identifier
435    #[arg(long = "team")]
436    pub team: Option<String>,
437    /// Force removal without confirmation
438    #[arg(long = "force", short = 'f')]
439    pub force: bool,
440}
441
442#[derive(Args, Debug, Clone)]
443pub struct BundleBuildArgs {
444    /// Bundle directory (default: current directory)
445    #[arg(long = "bundle", short = 'b')]
446    pub bundle: Option<PathBuf>,
447    /// Output directory for portable bundle
448    #[arg(long = "out", short = 'o')]
449    pub out: PathBuf,
450    /// Tenant identifier
451    #[arg(long = "tenant", short = 't')]
452    pub tenant: Option<String>,
453    /// Team identifier
454    #[arg(long = "team")]
455    pub team: Option<String>,
456    /// Only include used providers
457    #[arg(long = "only-used-providers")]
458    pub only_used_providers: bool,
459    /// Run doctor validation after build
460    #[arg(long = "doctor")]
461    pub doctor: bool,
462    /// Skip doctor validation
463    #[arg(long = "skip-doctor")]
464    pub skip_doctor: bool,
465}
466
467#[derive(Args, Debug, Clone)]
468pub struct BundleListArgs {
469    /// Bundle directory (default: current directory)
470    #[arg(long = "bundle", short = 'b')]
471    pub bundle: Option<PathBuf>,
472    /// Filter by domain (messaging/events/secrets/oauth)
473    #[arg(long = "domain", short = 'd', default_value = "messaging")]
474    pub domain: String,
475    /// Show flows for a specific pack
476    #[arg(long = "pack", short = 'p')]
477    pub pack: Option<String>,
478    /// Output format (text/json)
479    #[arg(long = "format", default_value = "text")]
480    pub format: String,
481}
482
483#[derive(Args, Debug, Clone)]
484pub struct BundleStatusArgs {
485    /// Bundle directory (default: current directory)
486    #[arg(long = "bundle", short = 'b')]
487    pub bundle: Option<PathBuf>,
488    /// Output format (text/json)
489    #[arg(long = "format", default_value = "text")]
490    pub format: String,
491}
492
493// --- Provider subcommands ---------------------------------------------------
494
495#[derive(Subcommand, Debug, Clone)]
496pub enum ProviderCommand {
497    /// Add a messaging provider to an environment
498    Add(ProviderAddArgs),
499    /// List messaging providers in an environment
500    List(ProviderListArgs),
501    /// Remove a messaging provider from an environment
502    Remove(ProviderRemoveArgs),
503}
504
505#[derive(Args, Debug, Clone)]
506pub struct ProviderAddArgs {
507    /// Provider kind (telegram, slack, webex, teams)
508    #[arg(value_name = "KIND")]
509    pub kind: String,
510    /// Bundle id to link (auto-detected when the env has exactly one bundle)
511    #[arg(long = "bundle-id")]
512    pub bundle_id: Option<String>,
513    /// Local .gtpack file override (skips OCI fetch)
514    #[arg(long = "pack")]
515    pub pack: Option<PathBuf>,
516    /// OCI tag override (e.g. a specific version like "0.5.6"). Only affects
517    /// the OCI reference; ignored when --pack is given.
518    #[arg(long = "pack-version")]
519    pub pack_version: Option<String>,
520    /// Provider instance id (defaults to the kind name)
521    #[arg(long = "provider-id")]
522    pub provider_id: Option<String>,
523    /// Human-readable display name for the endpoint
524    #[arg(long = "display-name")]
525    pub display_name: Option<String>,
526}
527
528#[derive(Args, Debug, Clone)]
529pub struct ProviderListArgs {}
530
531#[derive(Args, Debug, Clone)]
532pub struct ProviderRemoveArgs {
533    /// Endpoint id to remove (from `provider list`)
534    #[arg(value_name = "ENDPOINT_ID")]
535    pub endpoint_id: String,
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use clap::{CommandFactory, Parser};
542
543    /// The tenant a bundle binds under when the operator names none.
544    ///
545    /// This used to be `demo` while greentic-deployer used `default`, so the
546    /// same environment ended up serving deployments in two namespaces —
547    /// tolerated by a dev store and refused outright by the Vault activation
548    /// gate. Both ends now read the same constant; this pins that they do.
549    #[test]
550    fn tenant_defaults_to_the_shared_constant_not_demo() {
551        let cli = Cli::parse_from(["greentic-setup", "env-deploy", "b.gtbundle"]);
552        assert_eq!(cli.tenant, DEFAULT_TENANT);
553        assert_ne!(cli.tenant, "demo");
554        // Pin the literal too: this is a copy of greentic_types::DEFAULT_TENANT
555        // that exists only because the greentic-types floor cannot move.
556        assert_eq!(DEFAULT_TENANT, "default");
557        assert_eq!(DEFAULT_TEAM, "default");
558    }
559
560    /// Every `--tenant` in the whole command tree, not just the ones that
561    /// exist today. One straggler puts a single command's bundles in a
562    /// different namespace from every other command's, which is exactly the
563    /// shape of the original defect.
564    #[test]
565    fn every_tenant_arg_in_the_command_tree_uses_the_shared_constant() {
566        fn walk(cmd: &clap::Command, path: &str, seen: &mut usize) {
567            for arg in cmd.get_arguments() {
568                if arg.get_id() != "tenant" {
569                    continue;
570                }
571                let defaults: Vec<String> = arg
572                    .get_default_values()
573                    .iter()
574                    .map(|value| value.to_string_lossy().into_owned())
575                    .collect();
576                // No default at all is fine — that arg makes no guess, and the
577                // global `--tenant` covers it. A *wrong* default is the defect.
578                if defaults.is_empty() {
579                    continue;
580                }
581                *seen += 1;
582                assert_eq!(
583                    defaults,
584                    vec![DEFAULT_TENANT.to_string()],
585                    "`{path} --tenant` must default to DEFAULT_TENANT"
586                );
587            }
588            for sub in cmd.get_subcommands() {
589                walk(sub, &format!("{path} {}", sub.get_name()), seen);
590            }
591        }
592
593        let mut seen = 0;
594        walk(&Cli::command(), "greentic-setup", &mut seen);
595        // 10 today: the 9 declared defaults plus one more the walk reaches
596        // through clap's `global = true` propagation of the root `--tenant`.
597        // A change here means a new `--tenant` appeared and needs the same
598        // treatment; update the number once you have checked it.
599        assert_eq!(seen, 10, "the defaulted tenant-arg count changed");
600    }
601}