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