1#![forbid(unsafe_code)]
2
3use std::{convert::TryFrom, ffi::OsString, path::PathBuf};
4
5use anyhow::Result;
6use clap::{Parser, Subcommand};
7use greentic_types::{EnvId, TenantCtx, TenantId};
8use tokio::runtime::Runtime;
9
10pub mod add_extension;
11pub mod components;
12pub mod config;
13pub mod extensions_lock;
14pub mod gui;
15pub mod info;
16pub mod info_cmd;
17pub mod input;
18pub mod inspect;
19pub mod inspect_lock;
20pub mod lint;
21pub mod plan;
22pub mod providers;
23pub mod qa;
24pub mod resolve;
25pub mod sign;
26pub mod update;
27pub mod verify;
28pub mod wizard;
29mod wizard_catalog;
30mod wizard_i18n;
31mod wizard_ui;
32
33use crate::telemetry::set_current_tenant_ctx;
34use crate::{build, new, runtime};
35
36#[derive(Debug, Parser)]
37#[command(name = "greentic-pack", about = "Greentic pack CLI", version)]
38pub struct Cli {
39 #[arg(long = "log", default_value = "info", global = true)]
41 pub verbosity: String,
42
43 #[arg(long, global = true)]
45 pub offline: bool,
46
47 #[arg(long = "cache-dir", global = true)]
49 pub cache_dir: Option<PathBuf>,
50
51 #[arg(long = "config-override", value_name = "FILE", global = true)]
53 pub config_override: Option<PathBuf>,
54
55 #[arg(long, global = true)]
57 pub json: bool,
58
59 #[arg(long, global = true)]
61 pub locale: Option<String>,
62
63 #[command(subcommand)]
64 pub command: Command,
65}
66
67#[allow(clippy::large_enum_variant)]
68#[derive(Debug, Subcommand)]
69pub enum Command {
70 Build(BuildArgs),
72 Lint(self::lint::LintArgs),
74 Components(self::components::ComponentsArgs),
76 Update(self::update::UpdateArgs),
78 New(new::NewArgs),
80 Sign(self::sign::SignArgs),
82 Verify(self::verify::VerifyArgs),
84 #[command(subcommand)]
86 Gui(self::gui::GuiCommand),
87 Doctor(self::inspect::InspectArgs),
89 Info {
91 #[arg(value_name = "PATH")]
93 path: std::path::PathBuf,
94 #[arg(long, value_enum, default_value_t = self::inspect::InspectFormat::Human)]
96 format: self::inspect::InspectFormat,
97 #[arg(long, default_value_t = false)]
99 strict: bool,
100 },
101 Inspect(self::inspect::InspectArgs),
103 InspectLock(self::inspect_lock::InspectLockArgs),
105 Qa(self::qa::QaArgs),
107 Config(self::config::ConfigArgs),
109 Plan(self::plan::PlanArgs),
111 #[command(subcommand)]
113 Providers(self::providers::ProvidersCommand),
114 #[command(subcommand)]
116 AddExtension(self::add_extension::AddExtensionCommand),
117 ExtensionsLock(self::extensions_lock::ExtensionsLockArgs),
119 Wizard(self::wizard::WizardArgs),
121 Resolve(self::resolve::ResolveArgs),
123}
124
125#[derive(Debug, Clone, Parser)]
126pub struct BuildArgs {
127 #[arg(long = "in", value_name = "DIR")]
129 pub input: PathBuf,
130
131 #[arg(long = "no-update", default_value_t = false)]
133 pub no_update: bool,
134
135 #[arg(long = "out", value_name = "FILE")]
137 pub component_out: Option<PathBuf>,
138
139 #[arg(long, value_name = "FILE")]
141 pub manifest: Option<PathBuf>,
142
143 #[arg(long, value_name = "FILE")]
145 pub sbom: Option<PathBuf>,
146
147 #[arg(long = "gtpack-out", value_name = "FILE")]
149 pub gtpack_out: Option<PathBuf>,
150
151 #[arg(long = "lock", value_name = "FILE")]
153 pub lock: Option<PathBuf>,
154
155 #[arg(long = "bundle", value_enum, default_value = "cache")]
157 pub bundle: crate::build::BundleMode,
158
159 #[arg(long)]
161 pub dry_run: bool,
162
163 #[arg(long = "secrets-req", value_name = "FILE")]
165 pub secrets_req: Option<PathBuf>,
166
167 #[arg(long = "default-secret-scope", value_name = "ENV/TENANT[/TEAM]")]
169 pub default_secret_scope: Option<String>,
170
171 #[arg(long = "allow-oci-tags", default_value_t = false)]
173 pub allow_oci_tags: bool,
174
175 #[arg(long, default_value_t = false)]
177 pub require_component_manifests: bool,
178
179 #[arg(long = "no-extra-dirs", default_value_t = false)]
181 pub no_extra_dirs: bool,
182
183 #[arg(long = "dev", default_value_t = false)]
185 pub dev: bool,
186
187 #[arg(long = "allow-pack-schema", default_value_t = false)]
189 pub allow_pack_schema: bool,
190}
191
192pub fn run() -> Result<()> {
193 let cli = parse_cli_from_env();
194 Runtime::new()?.block_on(run_with_cli(cli, false))
195}
196
197pub fn parse_cli_from_env() -> Cli {
198 let args: Vec<OsString> = std::env::args_os().collect();
199 parse_cli_from_args(args)
200}
201
202pub fn parse_cli_from_args(args: Vec<OsString>) -> Cli {
203 let (rewritten, wizard_schema_requested) = rewrite_wizard_schema_flags(args);
204 self::wizard::set_forced_schema_flag(wizard_schema_requested);
205 Cli::parse_from(rewritten)
206}
207
208fn rewrite_wizard_schema_flags(args: Vec<OsString>) -> (Vec<OsString>, bool) {
209 let mut saw_wizard = false;
210 let mut schema_requested = false;
211 let mut rewritten = Vec::with_capacity(args.len());
212
213 for arg in args {
214 if arg == "wizard" {
215 saw_wizard = true;
216 rewritten.push(arg);
217 continue;
218 }
219 if saw_wizard && arg == "--schema" {
220 schema_requested = true;
221 continue;
222 }
223 rewritten.push(arg);
224 }
225
226 (rewritten, schema_requested)
227}
228
229pub fn print_top_level_help() {
230 println!("{}", crate::cli_i18n::t("cli.help.title"));
231 println!();
232 println!("{}", crate::cli_i18n::t("cli.help.usage"));
233 println!();
234 println!("{}", crate::cli_i18n::t("cli.help.commands_header"));
235 println!("{}", crate::cli_i18n::t("cli.help.command.build"));
236 println!("{}", crate::cli_i18n::t("cli.help.command.lint"));
237 println!("{}", crate::cli_i18n::t("cli.help.command.components"));
238 println!("{}", crate::cli_i18n::t("cli.help.command.update"));
239 println!("{}", crate::cli_i18n::t("cli.help.command.new"));
240 println!("{}", crate::cli_i18n::t("cli.help.command.sign"));
241 println!("{}", crate::cli_i18n::t("cli.help.command.verify"));
242 println!("{}", crate::cli_i18n::t("cli.help.command.gui"));
243 println!("{}", crate::cli_i18n::t("cli.help.command.doctor"));
244 println!("{}", crate::cli_i18n::t("cli.help.command.inspect"));
245 println!("{}", crate::cli_i18n::t("cli.help.command.inspect_lock"));
246 println!("{}", crate::cli_i18n::t("cli.help.command.qa"));
247 println!("{}", crate::cli_i18n::t("cli.help.command.config"));
248 println!("{}", crate::cli_i18n::t("cli.help.command.plan"));
249 println!("{}", crate::cli_i18n::t("cli.help.command.providers"));
250 println!("{}", crate::cli_i18n::t("cli.help.command.add_extension"));
251 println!("{}", crate::cli_i18n::t("cli.help.command.extensions_lock"));
252 println!("{}", crate::cli_i18n::t("cli.help.command.wizard"));
253 println!("{}", crate::cli_i18n::t("cli.help.command.resolve"));
254 println!("{}", crate::cli_i18n::t("cli.help.command.help"));
255 println!();
256 println!("{}", crate::cli_i18n::t("cli.help.options_header"));
257 println!("{}", crate::cli_i18n::t("cli.help.option.log"));
258 println!("{}", crate::cli_i18n::t("cli.help.option.offline"));
259 println!("{}", crate::cli_i18n::t("cli.help.option.cache_dir"));
260 println!("{}", crate::cli_i18n::t("cli.help.option.config_override"));
261 println!("{}", crate::cli_i18n::t("cli.help.option.json"));
262 println!("{}", crate::cli_i18n::t("cli.help.option.locale"));
263 println!("{}", crate::cli_i18n::t("cli.help.option.help"));
264 println!("{}", crate::cli_i18n::t("cli.help.option.version"));
265}
266
267pub fn print_help_for_path(path: &[String]) -> bool {
268 let key = match path {
269 [] => "cli.help.page.root",
270 [a] if a == "build" => "cli.help.page.build",
271 [a] if a == "lint" => "cli.help.page.lint",
272 [a] if a == "components" => "cli.help.page.components",
273 [a] if a == "update" => "cli.help.page.update",
274 [a] if a == "new" => "cli.help.page.new",
275 [a] if a == "sign" => "cli.help.page.sign",
276 [a] if a == "verify" => "cli.help.page.verify",
277 [a] if a == "gui" => "cli.help.page.gui",
278 [a] if a == "doctor" => "cli.help.page.doctor",
279 [a] if a == "inspect" => "cli.help.page.inspect",
280 [a] if a == "inspect-lock" => "cli.help.page.inspect_lock",
281 [a] if a == "qa" => "cli.help.page.qa",
282 [a] if a == "config" => "cli.help.page.config",
283 [a] if a == "plan" => "cli.help.page.plan",
284 [a] if a == "providers" => "cli.help.page.providers",
285 [a] if a == "add-extension" => "cli.help.page.add_extension",
286 [a] if a == "extensions-lock" => "cli.help.page.extensions_lock",
287 [a] if a == "wizard" => "cli.help.page.wizard",
288 [a, b] if a == "wizard" && b == "run" => "cli.help.page.wizard_run",
289 [a, b] if a == "wizard" && b == "validate" => "cli.help.page.wizard_validate",
290 [a, b] if a == "wizard" && b == "apply" => "cli.help.page.wizard_apply",
291 [a] if a == "resolve" => "cli.help.page.resolve",
292 [a, b] if a == "gui" && b == "loveable-convert" => "cli.help.page.gui_loveable_convert",
293 [a, b] if a == "providers" && b == "list" => "cli.help.page.providers_list",
294 [a, b] if a == "providers" && b == "info" => "cli.help.page.providers_info",
295 [a, b] if a == "providers" && b == "validate" => "cli.help.page.providers_validate",
296 [a, b] if a == "add-extension" && b == "provider" => "cli.help.page.add_extension_provider",
297 [a, b] if a == "add-extension" && b == "capability" => {
298 "cli.help.page.add_extension_capability"
299 }
300 [a, b] if a == "add-extension" && b == "deployer" => "cli.help.page.add_extension_deployer",
301 [a, b] if a == "add-extension" && b == "dependency" => {
302 "cli.help.page.add_extension_dependency"
303 }
304 _ => return false,
305 };
306
307 if !crate::cli_i18n::has(key) {
308 return false;
309 }
310 println!("{}", crate::cli_i18n::t(key));
311 true
312}
313
314pub fn resolve_env_filter(cli: &Cli) -> String {
316 std::env::var("PACKC_LOG").unwrap_or_else(|_| cli.verbosity.clone())
317}
318
319pub async fn run_with_cli(cli: Cli, warn_inspect_alias: bool) -> Result<()> {
321 let wizard_locale = cli.locale.clone();
322 crate::cli_i18n::init_locale(cli.locale.as_deref());
323
324 let runtime = runtime::resolve_runtime(
325 Some(std::env::current_dir()?.as_path()),
326 cli.cache_dir.as_deref(),
327 cli.offline,
328 cli.config_override.as_deref(),
329 )?;
330
331 crate::telemetry::install_with_config("packc", &runtime.resolved.config.telemetry)?;
333
334 set_current_tenant_ctx(&TenantCtx::new(
335 EnvId::try_from("local").expect("static env id"),
336 TenantId::try_from("packc").expect("static tenant id"),
337 ));
338
339 match cli.command {
340 Command::Build(args) => {
341 build::run(&build::BuildOptions::from_args(args, &runtime)?).await?
342 }
343 Command::Lint(args) => self::lint::handle(args, cli.json)?,
344 Command::Components(args) => self::components::handle(args, cli.json)?,
345 Command::Update(args) => self::update::handle(args, cli.json)?,
346 Command::New(args) => new::handle(args, cli.json, &runtime).await?,
347 Command::Sign(args) => self::sign::handle(args, cli.json)?,
348 Command::Verify(args) => self::verify::handle(args, cli.json)?,
349 Command::Gui(cmd) => self::gui::handle(cmd, cli.json, &runtime).await?,
350 Command::Inspect(args) | Command::Doctor(args) => {
351 if warn_inspect_alias {
352 eprintln!("{}", crate::cli_i18n::t("cli.warn.inspect_deprecated"));
353 }
354 self::inspect::handle(args, cli.json, &runtime).await?
355 }
356 Command::Info {
357 path,
358 format,
359 strict,
360 } => {
361 let effective_format = if cli.json {
363 self::inspect::InspectFormat::Json
364 } else {
365 format
366 };
367 match self::info_cmd::handle(&path, effective_format, strict) {
368 Ok(()) => {}
369 Err(err) => {
370 let msg = err.to_string();
371 let code = if msg.starts_with(self::info_cmd::ERR_NOT_A_PACK) {
372 2
373 } else if msg.starts_with(self::info_cmd::ERR_STRICT_UNSIGNED) {
374 3
375 } else {
376 1
377 };
378 eprintln!("{msg}");
379 std::process::exit(code);
380 }
381 }
382 }
383 Command::InspectLock(args) => self::inspect_lock::handle(args)?,
384 Command::Qa(args) => self::qa::handle(args, &runtime)?,
385 Command::Config(args) => self::config::handle(args, cli.json, &runtime)?,
386 Command::Plan(args) => self::plan::handle(&args)?,
387 Command::Providers(cmd) => self::providers::run(cmd)?,
388 Command::AddExtension(cmd) => self::add_extension::handle(cmd)?,
389 Command::ExtensionsLock(args) => {
390 self::extensions_lock::handle(args, &runtime, true).await?
391 }
392 Command::Wizard(args) => self::wizard::handle(args, &runtime, wizard_locale.as_deref())?,
393 Command::Resolve(args) => self::resolve::handle(args, &runtime, true).await?,
394 }
395
396 Ok(())
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 #[test]
404 fn cli_parse_build_populates_defaults() {
405 let cli = Cli::parse_from(["greentic-pack", "build", "--in", "demo-pack"]);
406 assert_eq!(cli.verbosity, "info");
407 assert!(!cli.offline);
408 assert!(!cli.json);
409 assert!(matches!(
410 cli.command,
411 Command::Build(BuildArgs {
412 input,
413 no_update: false,
414 dry_run: false,
415 allow_oci_tags: false,
416 require_component_manifests: false,
417 no_extra_dirs: false,
418 dev: false,
419 allow_pack_schema: false,
420 ..
421 }) if input.as_path() == std::path::Path::new("demo-pack")
422 ));
423 }
424
425 #[test]
426 fn cli_parse_nested_subcommands_and_globals() {
427 let cli = Cli::parse_from([
428 "greentic-pack",
429 "--json",
430 "--offline",
431 "--locale",
432 "nl",
433 "providers",
434 "validate",
435 ]);
436 assert!(cli.json);
437 assert!(cli.offline);
438 assert_eq!(cli.locale.as_deref(), Some("nl"));
439 assert!(matches!(
440 cli.command,
441 Command::Providers(self::providers::ProvidersCommand::Validate(_))
442 ));
443 }
444
445 #[test]
446 fn print_help_for_known_paths_returns_true() {
447 crate::cli_i18n::init_locale(Some("en"));
448
449 assert!(print_help_for_path(&[]));
450 assert!(print_help_for_path(&["build".to_string()]));
451 assert!(print_help_for_path(&[
452 "wizard".to_string(),
453 "run".to_string()
454 ]));
455 assert!(print_help_for_path(&[
456 "providers".to_string(),
457 "validate".to_string()
458 ]));
459 assert!(print_help_for_path(&[
460 "add-extension".to_string(),
461 "dependency".to_string()
462 ]));
463 }
464
465 #[test]
466 fn print_help_for_unknown_paths_returns_false() {
467 crate::cli_i18n::init_locale(Some("en"));
468 assert!(!print_help_for_path(&["does-not-exist".to_string()]));
469 assert!(!print_help_for_path(&[
470 "wizard".to_string(),
471 "missing".to_string()
472 ]));
473 }
474
475 #[test]
476 fn localized_wizard_help_mentions_schema_option() {
477 let en_catalog: serde_json::Value =
478 serde_json::from_str(include_str!("../../i18n/en.json")).expect("valid English i18n");
479 let en_wizard = en_catalog["cli.help.page.wizard"]
480 .as_str()
481 .expect("English wizard help string");
482 let en_run = en_catalog["cli.help.page.wizard_run"]
483 .as_str()
484 .expect("English wizard run help string");
485 assert!(en_wizard.contains("--schema"));
486 assert!(en_run.contains("--schema"));
487
488 let nl_catalog: serde_json::Value =
489 serde_json::from_str(include_str!("../../i18n/nl.json")).expect("valid Dutch i18n");
490 let nl_wizard = nl_catalog["cli.help.page.wizard"]
491 .as_str()
492 .expect("Dutch wizard help string");
493 let nl_run = nl_catalog["cli.help.page.wizard_run"]
494 .as_str()
495 .expect("Dutch wizard run help string");
496 assert!(nl_wizard.contains("--schema"));
497 assert!(nl_run.contains("--schema"));
498 assert!(nl_wizard.contains("AnswerDocument-schema"));
499 assert!(nl_run.contains("AnswerDocument-schema"));
500 }
501
502 #[test]
503 fn print_top_level_help_does_not_panic() {
504 crate::cli_i18n::init_locale(Some("en"));
505 print_top_level_help();
506 }
507
508 #[test]
509 fn resolve_env_filter_uses_cli_verbosity_when_env_missing() {
510 let cli = Cli::parse_from(["greentic-pack", "--log", "debug", "build", "--in", "demo"]);
511 assert_eq!(resolve_env_filter(&cli), "debug");
512 }
513
514 #[test]
515 fn rewrite_wizard_schema_flags_strips_schema_after_wizard() {
516 let (rewritten, schema_requested) = rewrite_wizard_schema_flags(vec![
517 "greentic-pack".into(),
518 "--locale".into(),
519 "nl".into(),
520 "wizard".into(),
521 "run".into(),
522 "--schema".into(),
523 "--answers".into(),
524 "answers.json".into(),
525 ]);
526
527 assert!(schema_requested);
528 assert_eq!(
529 rewritten,
530 vec![
531 OsString::from("greentic-pack"),
532 OsString::from("--locale"),
533 OsString::from("nl"),
534 OsString::from("wizard"),
535 OsString::from("run"),
536 OsString::from("--answers"),
537 OsString::from("answers.json"),
538 ]
539 );
540 }
541
542 #[test]
543 fn rewrite_wizard_schema_flags_leaves_other_schema_flags_alone() {
544 let (rewritten, schema_requested) = rewrite_wizard_schema_flags(vec![
545 "greentic-pack".into(),
546 "build".into(),
547 "--schema".into(),
548 ]);
549
550 assert!(!schema_requested);
551 assert_eq!(
552 rewritten,
553 vec![
554 OsString::from("greentic-pack"),
555 OsString::from("build"),
556 OsString::from("--schema"),
557 ]
558 );
559 }
560}