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