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