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