Skip to main content

greentic_start/
cli_args.rs

1use std::path::PathBuf;
2
3use clap::{Parser, Subcommand, ValueEnum};
4
5use crate::DEMO_DEFAULT_TEAM;
6use crate::DEMO_DEFAULT_TENANT;
7use crate::runtime::NatsMode;
8
9#[derive(Parser)]
10#[command(name = "greentic-start", version)]
11pub(crate) struct Cli {
12    #[arg(long, global = true)]
13    pub(crate) locale: Option<String>,
14    #[command(subcommand)]
15    pub(crate) command: Command,
16}
17
18#[derive(Subcommand)]
19pub(crate) enum Command {
20    Start(StartArgs),
21    Up(StartArgs),
22    Stop(StopArgs),
23    Restart(StartArgs),
24    Warmup(WarmupArgs),
25    Doctor(DoctorArgs),
26}
27
28#[derive(Parser, Clone)]
29pub(crate) struct DoctorArgs {
30    /// Bundle reference or extracted bundle directory to inspect.
31    pub(crate) bundle: String,
32    /// Emit stable machine-readable JSON.
33    #[arg(long)]
34    pub(crate) json: bool,
35    /// Promote drift/tag/cache warnings to errors.
36    #[arg(long)]
37    pub(crate) strict: bool,
38    /// Include longer remediation hints in human output.
39    #[arg(long)]
40    pub(crate) fix_hints: bool,
41    /// Include informational checks in output.
42    #[arg(long)]
43    pub(crate) show_info: bool,
44    /// Restrict checks to one diagnostic stage.
45    #[arg(long, value_enum, default_value_t = DoctorStageArg::All)]
46    pub(crate) stage: DoctorStageArg,
47}
48
49#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
50pub(crate) enum DoctorStageArg {
51    All,
52    Setup,
53    Cache,
54    Locks,
55    Answers,
56    Runtime,
57    Routes,
58    Provider,
59    Secrets,
60}
61
62#[derive(Parser, Clone)]
63pub(crate) struct WarmupArgs {
64    /// Path to a setup-resolved bundle directory whose components should be precompiled.
65    #[arg(long)]
66    pub(crate) bundle: PathBuf,
67    /// Cache root directory. Defaults to `${GREENTIC_CACHE_DIR}` or `.greentic/cache/components`.
68    #[arg(long, value_name = "DIR")]
69    pub(crate) cache_dir: Option<PathBuf>,
70    /// Fail on the first compile error instead of counting it as skipped.
71    #[arg(long)]
72    pub(crate) strict: bool,
73}
74
75#[derive(Parser, Clone)]
76pub(crate) struct StartArgs {
77    #[arg(long)]
78    bundle: Option<String>,
79    #[arg(long)]
80    tenant: Option<String>,
81    #[arg(long)]
82    team: Option<String>,
83    #[arg(long, hide = true, conflicts_with = "nats")]
84    no_nats: bool,
85    #[arg(long = "nats", value_enum, default_value_t = NatsModeArg::Off)]
86    nats: NatsModeArg,
87    #[arg(long)]
88    nats_url: Option<String>,
89    #[arg(long)]
90    config: Option<PathBuf>,
91    #[arg(long, value_enum, default_value_t = CloudflaredModeArg::Off)]
92    cloudflared: CloudflaredModeArg,
93    #[arg(long)]
94    cloudflared_binary: Option<PathBuf>,
95    #[arg(long, value_enum, default_value_t = NgrokModeArg::Off)]
96    ngrok: NgrokModeArg,
97    #[arg(long)]
98    ngrok_binary: Option<PathBuf>,
99    #[arg(long)]
100    runner_binary: Option<PathBuf>,
101    #[arg(long, value_enum, value_delimiter = ',')]
102    restart: Vec<RestartTarget>,
103    #[arg(long, value_name = "DIR")]
104    log_dir: Option<PathBuf>,
105    #[arg(long, conflicts_with = "quiet")]
106    verbose: bool,
107    #[arg(long, conflicts_with = "verbose")]
108    quiet: bool,
109    #[arg(long, help = "Do not open the first web UI URL in the default browser")]
110    no_browser: bool,
111    #[arg(long, help = "Enable mTLS admin API endpoint")]
112    admin: bool,
113    #[arg(long, default_value = "8443", help = "Port for the admin API endpoint")]
114    admin_port: u16,
115    #[arg(
116        long,
117        value_name = "DIR",
118        help = "Directory containing admin TLS certs (server.crt, server.key, ca.crt)"
119    )]
120    admin_certs_dir: Option<PathBuf>,
121    #[arg(
122        long,
123        value_delimiter = ',',
124        help = "Comma-separated list of allowed client CNs (empty = allow all valid certs)"
125    )]
126    admin_allowed_clients: Vec<String>,
127}
128
129#[derive(Parser, Clone)]
130pub(crate) struct StopArgs {
131    #[arg(long)]
132    bundle: Option<String>,
133    #[arg(long)]
134    state_dir: Option<PathBuf>,
135    #[arg(long, default_value = DEMO_DEFAULT_TENANT)]
136    tenant: String,
137    #[arg(long, default_value = DEMO_DEFAULT_TEAM)]
138    team: String,
139}
140
141#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
142pub enum NatsModeArg {
143    Off,
144    On,
145    External,
146}
147
148impl From<NatsModeArg> for NatsMode {
149    fn from(value: NatsModeArg) -> Self {
150        match value {
151            NatsModeArg::Off => NatsMode::Off,
152            NatsModeArg::On => NatsMode::On,
153            NatsModeArg::External => NatsMode::External,
154        }
155    }
156}
157
158#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
159pub enum CloudflaredModeArg {
160    On,
161    Off,
162}
163
164#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
165pub enum NgrokModeArg {
166    On,
167    Off,
168}
169
170#[derive(Clone, Copy, Debug, PartialEq, Eq, ValueEnum)]
171pub enum RestartTarget {
172    All,
173    Cloudflared,
174    Ngrok,
175    Nats,
176    Gateway,
177    Egress,
178    Subscriptions,
179}
180
181#[derive(Clone, Debug, PartialEq, Eq)]
182pub struct StartRequest {
183    pub bundle: Option<String>,
184    pub tenant: Option<String>,
185    pub team: Option<String>,
186    pub no_nats: bool,
187    pub nats: NatsModeArg,
188    pub nats_url: Option<String>,
189    pub config: Option<PathBuf>,
190    pub cloudflared: CloudflaredModeArg,
191    pub cloudflared_binary: Option<PathBuf>,
192    pub ngrok: NgrokModeArg,
193    pub ngrok_binary: Option<PathBuf>,
194    pub runner_binary: Option<PathBuf>,
195    pub restart: Vec<RestartTarget>,
196    pub log_dir: Option<PathBuf>,
197    pub verbose: bool,
198    pub quiet: bool,
199    pub no_browser: bool,
200    pub admin: bool,
201    pub admin_port: u16,
202    pub admin_certs_dir: Option<PathBuf>,
203    pub admin_allowed_clients: Vec<String>,
204    /// Whether the user explicitly set `--cloudflared` or `--ngrok` on the CLI.
205    /// When `false` and the terminal is interactive, we prompt for tunnel selection.
206    pub tunnel_explicit: bool,
207}
208
209#[derive(Clone, Debug, PartialEq, Eq)]
210pub struct StopRequest {
211    pub bundle: Option<String>,
212    pub state_dir: Option<PathBuf>,
213    pub tenant: String,
214    pub team: String,
215}
216
217pub(crate) fn start_request_from_args(args: StartArgs, tunnel_explicit: bool) -> StartRequest {
218    StartRequest {
219        bundle: args.bundle,
220        tenant: args.tenant,
221        team: args.team,
222        no_nats: args.no_nats,
223        nats: args.nats,
224        nats_url: args.nats_url,
225        config: args.config,
226        cloudflared: args.cloudflared,
227        cloudflared_binary: args.cloudflared_binary,
228        ngrok: args.ngrok,
229        ngrok_binary: args.ngrok_binary,
230        runner_binary: args.runner_binary,
231        restart: args.restart,
232        log_dir: args.log_dir,
233        verbose: args.verbose,
234        quiet: args.quiet,
235        no_browser: args.no_browser,
236        admin: args.admin,
237        admin_port: args.admin_port,
238        admin_certs_dir: args.admin_certs_dir,
239        admin_allowed_clients: args.admin_allowed_clients,
240        tunnel_explicit,
241    }
242}
243
244pub(crate) fn stop_request_from_args(args: StopArgs) -> StopRequest {
245    StopRequest {
246        bundle: args.bundle,
247        state_dir: args.state_dir,
248        tenant: args.tenant,
249        team: args.team,
250    }
251}
252
253pub(crate) fn normalize_args(raw_tail: Vec<String>) -> Vec<String> {
254    let mut out = vec!["greentic-start".to_string()];
255    let mut stripped_demo_prefix = false;
256    let mut skip_next_value = false;
257    for arg in raw_tail {
258        if skip_next_value {
259            skip_next_value = false;
260            out.push(arg);
261            continue;
262        }
263        if arg_takes_value(&arg) {
264            skip_next_value = true;
265            out.push(arg);
266            continue;
267        }
268        if !stripped_demo_prefix && !arg.starts_with('-') {
269            stripped_demo_prefix = true;
270            if arg == "demo" {
271                continue;
272            }
273        }
274        out.push(arg);
275    }
276
277    if only_global_flags(&out[1..]) {
278        return out;
279    }
280
281    let known = ["start", "up", "stop", "restart", "warmup", "doctor"];
282    let mut first_pos = None;
283    let mut skip_next_value = false;
284    for arg in out.iter().skip(1) {
285        if skip_next_value {
286            skip_next_value = false;
287            continue;
288        }
289        if arg_takes_value(arg) {
290            skip_next_value = true;
291            continue;
292        }
293        if !arg.starts_with('-') {
294            first_pos = Some(arg.clone());
295            break;
296        }
297    }
298    let should_insert_start = match first_pos {
299        Some(cmd) => !known.contains(&cmd.as_str()),
300        None => true,
301    };
302    if should_insert_start {
303        out.insert(1, "start".to_string());
304    }
305    out
306}
307
308fn only_global_flags(args: &[String]) -> bool {
309    if args.is_empty() {
310        return false;
311    }
312
313    let mut index = 0;
314    while index < args.len() {
315        match args[index].as_str() {
316            "--help" | "-h" | "--version" | "-V" => {
317                index += 1;
318            }
319            "--locale" => {
320                if index + 1 >= args.len() {
321                    return false;
322                }
323                index += 2;
324            }
325            value if value.starts_with("--locale=") => {
326                index += 1;
327            }
328            _ => return false,
329        }
330    }
331
332    true
333}
334
335fn arg_takes_value(arg: &str) -> bool {
336    matches!(
337        arg,
338        "--locale"
339            | "--bundle"
340            | "--tenant"
341            | "--team"
342            | "--nats"
343            | "--nats-url"
344            | "--config"
345            | "--cloudflared"
346            | "--cloudflared-binary"
347            | "--ngrok"
348            | "--ngrok-binary"
349            | "--runner-binary"
350            | "--restart"
351            | "--log-dir"
352            | "--stage"
353            | "--state-dir"
354            | "--admin-port"
355            | "--admin-certs-dir"
356            | "--admin-allowed-clients"
357    )
358}
359
360pub(crate) fn restart_name(target: &RestartTarget) -> String {
361    match target {
362        RestartTarget::All => "all",
363        RestartTarget::Cloudflared => "cloudflared",
364        RestartTarget::Ngrok => "ngrok",
365        RestartTarget::Nats => "nats",
366        RestartTarget::Gateway => "gateway",
367        RestartTarget::Egress => "egress",
368        RestartTarget::Subscriptions => "subscriptions",
369    }
370    .to_string()
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn normalize_args_inserts_start_for_short_form() {
379        let args = normalize_args(vec!["--tenant".into(), "demo".into()]);
380        assert_eq!(args[0], "greentic-start");
381        assert_eq!(args[1], "start");
382        assert_eq!(args[2], "--tenant");
383    }
384
385    #[test]
386    fn normalize_args_removes_demo_prefix() {
387        let args = normalize_args(vec!["demo".into(), "start".into(), "--tenant".into()]);
388        assert_eq!(args[0], "greentic-start");
389        assert_eq!(args[1], "start");
390        assert_eq!(args[2], "--tenant");
391    }
392
393    #[test]
394    fn normalize_args_keeps_explicit_stop() {
395        let args = normalize_args(vec!["stop".into(), "--tenant".into(), "demo".into()]);
396        assert_eq!(args[0], "greentic-start");
397        assert_eq!(args[1], "stop");
398        assert_eq!(args[2], "--tenant");
399        assert_eq!(args[3], "demo");
400    }
401
402    #[test]
403    fn normalize_args_keeps_explicit_doctor() {
404        let args = normalize_args(vec!["doctor".into(), ".".into(), "--json".into()]);
405        assert_eq!(args[0], "greentic-start");
406        assert_eq!(args[1], "doctor");
407        assert_eq!(args[2], ".");
408    }
409
410    #[test]
411    fn normalize_args_strips_only_leading_demo_prefix() {
412        let args = normalize_args(vec![
413            "--locale".into(),
414            "en".into(),
415            "demo".into(),
416            "start".into(),
417            "--tenant".into(),
418            "demo".into(),
419        ]);
420        assert_eq!(args[0], "greentic-start");
421        assert_eq!(args[1], "--locale");
422        assert_eq!(args[2], "en");
423        assert_eq!(args[3], "start");
424        assert_eq!(args[4], "--tenant");
425        assert_eq!(args[5], "demo");
426    }
427
428    #[test]
429    fn normalize_args_keeps_runner_binary_value_with_demo_prefix() {
430        let args = normalize_args(vec![
431            "demo".into(),
432            "start".into(),
433            "--runner-binary".into(),
434            "/tmp/runner".into(),
435        ]);
436        assert_eq!(args[0], "greentic-start");
437        assert_eq!(args[1], "start");
438        assert_eq!(args[2], "--runner-binary");
439        assert_eq!(args[3], "/tmp/runner");
440    }
441
442    #[test]
443    fn normalize_args_keeps_global_version_flag_without_start() {
444        let args = normalize_args(vec!["--version".into()]);
445        assert_eq!(
446            args,
447            vec!["greentic-start".to_string(), "--version".to_string()]
448        );
449    }
450
451    #[test]
452    fn normalize_args_keeps_global_help_flag_without_start() {
453        let args = normalize_args(vec!["--help".into()]);
454        assert_eq!(
455            args,
456            vec!["greentic-start".to_string(), "--help".to_string()]
457        );
458    }
459
460    #[test]
461    fn normalize_args_keeps_locale_and_version_without_start() {
462        let args = normalize_args(vec!["--locale".into(), "en".into(), "--version".into()]);
463        assert_eq!(
464            args,
465            vec![
466                "greentic-start".to_string(),
467                "--locale".to_string(),
468                "en".to_string(),
469                "--version".to_string(),
470            ]
471        );
472    }
473}