Skip to main content

packc/cli/
mod.rs

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    /// Logging filter (overrides PACKC_LOG)
41    #[arg(long = "log", default_value = "info", global = true)]
42    pub verbosity: String,
43
44    /// Force offline mode (disables any network activity)
45    #[arg(long, global = true)]
46    pub offline: bool,
47
48    /// Override cache directory (defaults to pack_dir/.packc or GREENTIC_PACK_CACHE_DIR)
49    #[arg(long = "cache-dir", global = true)]
50    pub cache_dir: Option<PathBuf>,
51
52    /// Optional config overrides in TOML/JSON (greentic-config layer)
53    #[arg(long = "config-override", value_name = "FILE", global = true)]
54    pub config_override: Option<PathBuf>,
55
56    /// Emit machine-readable JSON output where applicable
57    #[arg(long, global = true)]
58    pub json: bool,
59
60    /// Locale used for CLI messages (fallback: LC_ALL/LC_MESSAGES/LANG/system/en)
61    #[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 a pack component and supporting artifacts
72    Build(BuildArgs),
73    /// Lint a pack manifest, flows, and templates
74    Lint(self::lint::LintArgs),
75    /// Sync pack.yaml components with files under components/
76    Components(self::components::ComponentsArgs),
77    /// Sync pack.yaml components and flows with files under the pack root
78    Update(self::update::UpdateArgs),
79    /// Scaffold a new pack directory
80    New(new::NewArgs),
81    /// Sign a pack manifest using an Ed25519 private key
82    Sign(self::sign::SignArgs),
83    /// Verify a pack's manifest signature
84    Verify(self::verify::VerifyArgs),
85    /// GUI-related tooling
86    #[command(subcommand)]
87    Gui(self::gui::GuiCommand),
88    /// Diagnose a pack archive (.gtpack) or source directory (runs validation)
89    Doctor(self::inspect::InspectArgs),
90    /// Describe a .gtpack: show name, version, components, and metadata.
91    Info {
92        /// Path to a .gtpack file.
93        #[arg(value_name = "PATH")]
94        path: std::path::PathBuf,
95        /// Output format.
96        #[arg(long, value_enum, default_value_t = self::inspect::InspectFormat::Human)]
97        format: self::inspect::InspectFormat,
98        /// Fail if unsigned or signature invalid.
99        #[arg(long, default_value_t = false)]
100        strict: bool,
101    },
102    /// Deprecated alias for `doctor`
103    Inspect(self::inspect::InspectArgs),
104    /// Inspect pack.lock.cbor (stable JSON to stdout)
105    InspectLock(self::inspect_lock::InspectLockArgs),
106    /// Run component QA and store answers.
107    Qa(self::qa::QaArgs),
108    /// Inspect resolved configuration (provenance and warnings)
109    Config(self::config::ConfigArgs),
110    /// Generate a DeploymentPlan from a pack archive or source directory.
111    Plan(self::plan::PlanArgs),
112    /// Legacy provider-extension helpers (schema-core path).
113    #[command(subcommand)]
114    Providers(self::providers::ProvidersCommand),
115    /// Add data to pack extensions (provider extension path is legacy/schema-core).
116    #[command(subcommand)]
117    AddExtension(self::add_extension::AddExtensionCommand),
118    /// Resolve extension dependency refs into pack.extensions.lock.json
119    ExtensionsLock(self::extensions_lock::ExtensionsLockArgs),
120    /// Interactive pack wizard.
121    Wizard(self::wizard::WizardArgs),
122    /// Resolve component references and write pack.lock.cbor
123    Resolve(self::resolve::ResolveArgs),
124}
125
126#[derive(Debug, Clone, Parser)]
127pub struct BuildArgs {
128    /// Root directory of the pack (must contain pack.yaml)
129    #[arg(long = "in", value_name = "DIR")]
130    pub input: PathBuf,
131
132    /// Skip running `packc update` before building (default: update first)
133    #[arg(long = "no-update", default_value_t = false)]
134    pub no_update: bool,
135
136    /// Output path for the built Wasm component (legacy; writes a stub)
137    #[arg(long = "out", value_name = "FILE")]
138    pub component_out: Option<PathBuf>,
139
140    /// Output path for the generated manifest (CBOR); defaults to dist/manifest.cbor
141    #[arg(long, value_name = "FILE")]
142    pub manifest: Option<PathBuf>,
143
144    /// Output path for the generated SBOM (legacy; writes a stub JSON)
145    #[arg(long, value_name = "FILE")]
146    pub sbom: Option<PathBuf>,
147
148    /// Output path for the generated & canonical .gtpack archive (default: dist/<pack_dir>.gtpack)
149    #[arg(long = "gtpack-out", value_name = "FILE")]
150    pub gtpack_out: Option<PathBuf>,
151
152    /// Optional path to pack.lock.cbor (default: <pack_dir>/pack.lock.cbor)
153    #[arg(long = "lock", value_name = "FILE")]
154    pub lock: Option<PathBuf>,
155
156    /// Bundle strategy for component artifacts (cache=embed wasm, none=refs only)
157    #[arg(long = "bundle", value_enum, default_value = "cache")]
158    pub bundle: crate::build::BundleMode,
159
160    /// When set, the command validates input without writing artifacts
161    #[arg(long)]
162    pub dry_run: bool,
163
164    /// Optional JSON file with additional secret requirements (migration bridge)
165    #[arg(long = "secrets-req", value_name = "FILE")]
166    pub secrets_req: Option<PathBuf>,
167
168    /// Default secret scope to apply when missing (dev-only), format: env/tenant[/team]
169    #[arg(long = "default-secret-scope", value_name = "ENV/TENANT[/TEAM]")]
170    pub default_secret_scope: Option<String>,
171
172    /// Allow OCI component refs in extensions to be tag-based (default requires sha256 digest)
173    #[arg(long = "allow-oci-tags", default_value_t = false)]
174    pub allow_oci_tags: bool,
175
176    /// Require manifest metadata for flow-referenced components
177    #[arg(long, default_value_t = false)]
178    pub require_component_manifests: bool,
179
180    /// Skip auto-including extra directories (e.g. schemas/, templates/)
181    #[arg(long = "no-extra-dirs", default_value_t = false)]
182    pub no_extra_dirs: bool,
183
184    /// Include source files (pack.yaml, flows) inside the generated .gtpack for debugging
185    #[arg(long = "dev", default_value_t = false)]
186    pub dev: bool,
187
188    /// Migration-only escape hatch: allow deriving component manifest/schema from pack.yaml.
189    #[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
315/// Resolve the logging filter to use for telemetry initialisation.
316pub fn resolve_env_filter(cli: &Cli) -> String {
317    std::env::var("PACKC_LOG").unwrap_or_else(|_| cli.verbosity.clone())
318}
319
320/// Execute the CLI using a pre-parsed argument set.
321pub 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    // Install telemetry according to resolved config.
333    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            // Honour the global `--json` flag as a shortcut for `--format json`.
363            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}