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