1use std::ffi::OsString;
2
3use anyhow::Result;
4use clap::{Arg, ArgAction, CommandFactory, FromArgMatches, Parser, Subcommand};
5
6pub mod access;
7pub mod add;
8pub mod build;
9pub mod doctor;
10pub mod export;
11#[cfg(feature = "extensions")]
12pub mod ext;
13#[cfg(feature = "extensions")]
14mod ext_helpers;
15pub mod info;
16pub mod init;
17pub mod inspect;
18pub mod remove;
19pub mod unbundle;
20pub mod wizard;
21
22#[derive(Debug, Parser)]
23#[command(
24 name = "greentic-bundle",
25 about = "cli.root.about",
26 long_about = "cli.root.long_about",
27 version,
28 arg_required_else_help = true
29)]
30pub struct Cli {
31 #[arg(
32 long = "locale",
33 value_name = "LOCALE",
34 global = true,
35 help = "cli.option.locale"
36 )]
37 locale: Option<String>,
38
39 #[arg(
40 long = "offline",
41 global = true,
42 default_value_t = false,
43 help = "cli.option.offline"
44 )]
45 offline: bool,
46
47 #[command(subcommand)]
48 command: Commands,
49}
50
51#[derive(Debug, Subcommand)]
52enum Commands {
53 #[command(about = "cli.wizard.about")]
54 Wizard(wizard::WizardArgs),
55 #[command(about = "cli.doctor.about")]
56 Doctor(doctor::DoctorArgs),
57 #[command(about = "cli.build.about", long_about = "cli.build.long_about")]
58 Build(build::BuildArgs),
59 #[command(about = "cli.export.about", long_about = "cli.export.long_about")]
60 Export(export::ExportArgs),
61 #[command(about = "cli.inspect.about")]
62 Inspect(inspect::InspectArgs),
63 #[command(about = "cli.info.about")]
64 Info(info::InfoArgs),
65 #[command(about = "cli.unbundle.about")]
66 Unbundle(unbundle::UnbundleArgs),
67 #[command(about = "cli.add.about")]
68 Add(add::AddArgs),
69 #[command(about = "cli.remove.about")]
70 Remove(remove::RemoveArgs),
71 #[command(about = "cli.access.about")]
72 Access(access::AccessArgs),
73 #[command(about = "cli.init.about")]
74 Init(init::InitArgs),
75 #[cfg(feature = "extensions")]
76 #[command(about = "cli.ext.about")]
77 Ext(ext::ExtArgs),
78}
79
80pub fn run() -> Result<()> {
81 let argv: Vec<OsString> = std::env::args_os().collect();
82 crate::i18n::init(crate::i18n::cli_locale_from_argv(&argv));
83
84 let mut command = localized_command(true);
85 let matches = match command.try_get_matches_from_mut(argv) {
86 Ok(matches) => matches,
87 Err(err) => err.exit(),
88 };
89 let cli = Cli::from_arg_matches(&matches)?;
90 crate::i18n::init(cli.locale.clone());
91 crate::runtime::set_offline(cli.offline);
92 cli.dispatch()
93}
94
95pub fn localized_command(is_root: bool) -> clap::Command {
96 localize_help(Cli::command(), is_root)
97}
98
99impl Cli {
100 fn dispatch(self) -> Result<()> {
101 match self.command {
102 Commands::Wizard(args) => wizard::run(args),
103 Commands::Doctor(args) => doctor::run(args),
104 Commands::Build(args) => build::run(args),
105 Commands::Export(args) => export::run(args),
106 Commands::Inspect(args) => inspect::run(args),
107 Commands::Info(args) => info::run(args),
108 Commands::Unbundle(args) => unbundle::run(args),
109 Commands::Add(args) => add::run(args),
110 Commands::Remove(args) => remove::run(args),
111 Commands::Access(args) => access::run(args),
112 Commands::Init(args) => init::run(args),
113 #[cfg(feature = "extensions")]
114 Commands::Ext(args) => run_ext(args),
115 }
116 }
117}
118
119fn localize_help(mut command: clap::Command, is_root: bool) -> clap::Command {
120 if let Some(about) = command.get_about().map(|s| s.to_string()) {
121 command = command.about(crate::i18n::tr(&about));
122 }
123 if let Some(long_about) = command.get_long_about().map(|s| s.to_string()) {
124 command = command.long_about(crate::i18n::tr(&long_about));
125 }
126 if let Some(before) = command.get_before_help().map(|s| s.to_string()) {
127 command = command.before_help(crate::i18n::tr(&before));
128 }
129 if let Some(after) = command.get_after_help().map(|s| s.to_string()) {
130 command = command.after_help(crate::i18n::tr(&after));
131 }
132
133 command = command
134 .disable_help_subcommand(true)
135 .disable_help_flag(true)
136 .arg(
137 Arg::new("help")
138 .short('h')
139 .long("help")
140 .action(ArgAction::Help)
141 .help(crate::i18n::tr("cli.help.flag")),
142 );
143 if is_root {
144 command = command.disable_version_flag(true).arg(
145 Arg::new("version")
146 .short('V')
147 .long("version")
148 .action(ArgAction::Version)
149 .help(crate::i18n::tr("cli.version.flag")),
150 );
151 }
152
153 let arg_ids = command
154 .get_arguments()
155 .map(|arg| arg.get_id().clone())
156 .collect::<Vec<_>>();
157 for arg_id in arg_ids {
158 command = command.mut_arg(arg_id, |arg| {
159 let mut arg = arg;
160 if let Some(help) = arg.get_help().map(ToString::to_string) {
161 arg = arg.help(crate::i18n::tr(&help));
162 }
163 if let Some(long_help) = arg.get_long_help().map(ToString::to_string) {
164 arg = arg.long_help(crate::i18n::tr(&long_help));
165 }
166 arg
167 });
168 }
169
170 let sub_names = command
171 .get_subcommands()
172 .map(|sub| sub.get_name().to_string())
173 .collect::<Vec<_>>();
174 for name in sub_names {
175 command = command.mut_subcommand(name, |sub| localize_help(sub, false));
176 }
177 command
178}
179
180#[cfg(feature = "extensions")]
181#[allow(clippy::too_many_lines)]
182fn run_ext(args: ext::ExtArgs) -> anyhow::Result<()> {
183 use std::fs;
184 use std::io::Write;
185 use std::path::PathBuf;
186
187 use crate::ext::dispatcher::invoke_recipe;
188 use crate::ext::loader::load_from_dir;
189 use crate::ext::registry::ExtensionRegistry;
190
191 let install_dir = args
192 .extension_dir
193 .clone()
194 .unwrap_or_else(|| PathBuf::from("state").join("ext"));
195
196 let mut registry = ExtensionRegistry::new();
197 let discovered = load_from_dir(&install_dir)?;
198 registry.register_discovered(discovered)?;
199
200 match args.command {
201 ext::ExtCommand::List => {
202 for e in registry.list() {
203 println!(
204 "{ext} {ver} recipe={recipe} kind={kind:?}",
205 ext = e.extension_id,
206 ver = e.extension_version,
207 recipe = e.recipe.id,
208 kind = e.execution,
209 );
210 }
211 }
212 ext::ExtCommand::Info { extension_id } => {
213 let mut any = false;
214 for e in registry.list().filter(|e| e.extension_id == extension_id) {
215 any = true;
216 println!(
217 "{ext} {ver}\n recipe: {rid} — {display}\n schema: {schema}\n capabilities: {caps}",
218 ext = e.extension_id,
219 ver = e.extension_version,
220 rid = e.recipe.id,
221 display = e.recipe.display_name,
222 schema = e.recipe.config_schema,
223 caps = e.recipe.supported_capabilities.join(", "),
224 );
225 }
226 if !any {
227 return Err(anyhow::anyhow!(crate::i18n::trf(
228 "cli.ext.info.not_found",
229 &[("id", extension_id.as_str())]
230 )));
231 }
232 }
233 ext::ExtCommand::Validate {
234 extension_id,
235 recipe_id,
236 config,
237 } => {
238 let entry = registry.resolve(&extension_id, &recipe_id)?;
239 let schema_path = entry.descriptor_root.join(&entry.recipe.config_schema);
240 let schema_raw = fs::read_to_string(&schema_path)?;
241 let schema_json: serde_json::Value = serde_json::from_str(&schema_raw)?;
242 let config_raw = fs::read_to_string(&config)?;
243 let config_json: serde_json::Value = serde_json::from_str(&config_raw)?;
244 let compiled = jsonschema::validator_for(&schema_json)
245 .map_err(|e| anyhow::anyhow!("schema load error: {e}"))?;
246 let mut errors = compiled.iter_errors(&config_json).peekable();
247 if errors.peek().is_none() {
248 println!("{}", crate::i18n::tr("cli.ext.validate.ok"));
249 } else {
250 for e in errors {
251 eprintln!("{}: {e}", e.instance_path());
252 }
253 return Err(anyhow::anyhow!(crate::i18n::tr("cli.ext.validate.failed")));
254 }
255 }
256 ext::ExtCommand::Render {
257 extension_id,
258 recipe_id,
259 config,
260 session,
261 out,
262 json,
263 } => {
264 use ext_helpers::{extension_error_code, fail_json, read_input};
265
266 if config == "-" && session == "-" {
268 return Err(fail_json(
269 json,
270 "invalid-args",
271 "only one of --config and --session may read from stdin",
272 ));
273 }
274 if json && out.is_none() {
275 return Err(fail_json(json, "invalid-args", "--json requires --out"));
276 }
277
278 let config_json = read_input(&config)
279 .map_err(|e| fail_json(json, "invalid-config", &e.to_string()))?;
280 let session_json = read_input(&session)
281 .map_err(|e| fail_json(json, "invalid-session", &e.to_string()))?;
282
283 let result = invoke_recipe(
284 ®istry,
285 &extension_id,
286 &recipe_id,
287 &config_json,
288 &session_json,
289 );
290
291 match (result, out) {
292 (Ok(art), Some(path)) => {
293 fs::write(&path, &art.bytes)?;
294 if json {
295 let summary = serde_json::json!({
296 "status": "ok",
297 "filename": art.filename,
298 "sha256": art.sha256,
299 "bytesLen": art.bytes.len(),
300 });
301 println!("{summary}");
302 } else {
303 let path_str = path.display().to_string();
304 println!(
305 "{}",
306 crate::i18n::trf(
307 "cli.ext.render.wrote",
308 &[("file", path_str.as_str()), ("sha256", art.sha256.as_str()),],
309 )
310 );
311 }
312 }
313 (Ok(art), None) => {
314 std::io::stdout().write_all(&art.bytes)?;
315 }
316 (Err(e), _) => {
317 return Err(fail_json(json, extension_error_code(&e), &e.to_string()));
318 }
319 }
320 }
321 ext::ExtCommand::InstallDir => {
322 println!("{}", install_dir.display());
323 }
324 }
325 Ok(())
326}
327
328#[cfg(test)]
329mod tests {
330 use clap::Parser;
331
332 use super::{Cli, Commands};
333
334 #[test]
335 fn parses_global_locale_and_wizard_flags() {
336 let cli = Cli::try_parse_from([
337 "greentic-bundle",
338 "--locale",
339 "en-US",
340 "wizard",
341 "run",
342 "--schema",
343 "--answers",
344 "answers.json",
345 "--emit-answers",
346 "out.json",
347 "--schema-version",
348 "1.2.3",
349 "--migrate",
350 "--dry-run",
351 ])
352 .expect("cli parses");
353
354 assert_eq!(cli.locale.as_deref(), Some("en-US"));
355 match cli.command {
356 Commands::Wizard(args) => {
357 assert!(args.schema);
358 match args.command.expect("wizard subcommand") {
359 super::wizard::WizardCommand::Run(run) => {
360 assert_eq!(
361 run.answers.as_deref(),
362 Some(std::path::Path::new("answers.json"))
363 );
364 assert_eq!(
365 run.emit_answers.as_deref(),
366 Some(std::path::Path::new("out.json"))
367 );
368 assert_eq!(run.schema_version.as_deref(), Some("1.2.3"));
369 assert!(run.migrate);
370 assert!(run.dry_run);
371 }
372 _ => panic!("expected run"),
373 }
374 }
375 _ => panic!("expected wizard"),
376 }
377 }
378
379 #[test]
380 fn parses_access_allow_execute_flag() {
381 let cli = Cli::try_parse_from([
382 "greentic-bundle",
383 "access",
384 "allow",
385 "tenant-a",
386 "--execute",
387 ])
388 .expect("cli parses");
389
390 match cli.command {
391 Commands::Access(args) => match args.command {
392 super::access::AccessCommand::Allow(allow) => {
393 assert_eq!(allow.subject, "tenant-a");
394 assert!(allow.execute);
395 assert!(!allow.dry_run);
396 }
397 _ => panic!("expected access allow"),
398 },
399 _ => panic!("expected access"),
400 }
401 }
402
403 #[test]
404 fn parses_build_export_doctor_and_inspect_flags() {
405 let build = Cli::try_parse_from([
406 "greentic-bundle",
407 "build",
408 "--root",
409 "bundle",
410 "--output",
411 "out.gtbundle",
412 "--dry-run",
413 ])
414 .expect("build parses");
415 match build.command {
416 Commands::Build(args) => {
417 assert_eq!(args.root, std::path::PathBuf::from("bundle"));
418 assert_eq!(args.output, Some(std::path::PathBuf::from("out.gtbundle")));
419 assert!(args.dry_run);
420 }
421 _ => panic!("expected build"),
422 }
423
424 let doctor = Cli::try_parse_from([
425 "greentic-bundle",
426 "doctor",
427 "--artifact",
428 "demo.gtbundle",
429 "--json",
430 ])
431 .expect("doctor parses");
432 match doctor.command {
433 Commands::Doctor(args) => {
434 assert_eq!(
435 args.artifact,
436 Some(std::path::PathBuf::from("demo.gtbundle"))
437 );
438 assert!(args.json);
439 }
440 _ => panic!("expected doctor"),
441 }
442
443 let export = Cli::try_parse_from([
444 "greentic-bundle",
445 "export",
446 "--build-dir",
447 "state/build/demo/normalized",
448 "--output",
449 "demo.gtbundle",
450 "--dry-run",
451 ])
452 .expect("export parses");
453 match export.command {
454 Commands::Export(args) => {
455 assert_eq!(
456 args.build_dir,
457 std::path::PathBuf::from("state/build/demo/normalized")
458 );
459 assert_eq!(args.output, std::path::PathBuf::from("demo.gtbundle"));
460 assert!(args.dry_run);
461 }
462 _ => panic!("expected export"),
463 }
464
465 let inspect = Cli::try_parse_from(["greentic-bundle", "inspect", "bundle", "--json"])
466 .expect("inspect parses");
467 match inspect.command {
468 Commands::Inspect(args) => {
469 assert_eq!(args.target, Some(std::path::PathBuf::from("bundle")));
470 assert!(args.json);
471 }
472 _ => panic!("expected inspect"),
473 }
474 }
475
476 #[test]
477 fn command_defaults_use_current_directory() {
478 assert_eq!(
479 super::build::BuildArgs::default().root,
480 std::path::PathBuf::from(".")
481 );
482 assert_eq!(
483 super::doctor::DoctorArgs::default().root,
484 std::path::PathBuf::from(".")
485 );
486 assert_eq!(
487 super::inspect::InspectArgs::default().root,
488 std::path::PathBuf::from(".")
489 );
490 }
491}