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 pub(crate) bundle: String,
32 #[arg(long)]
34 pub(crate) json: bool,
35 #[arg(long)]
37 pub(crate) strict: bool,
38 #[arg(long)]
40 pub(crate) fix_hints: bool,
41 #[arg(long)]
43 pub(crate) show_info: bool,
44 #[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 #[arg(long)]
66 pub(crate) bundle: PathBuf,
67 #[arg(long, value_name = "DIR")]
69 pub(crate) cache_dir: Option<PathBuf>,
70 #[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 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}