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::JSONSchema::compile(&schema_json)
245 .map_err(|e| anyhow::anyhow!("schema load error: {e}"))?;
246 match compiled.validate(&config_json) {
247 Ok(()) => {
248 println!("{}", crate::i18n::tr("cli.ext.validate.ok"));
249 }
250 Err(errs) => {
251 for e in errs {
252 eprintln!("{}: {e}", e.instance_path);
253 }
254 return Err(anyhow::anyhow!(crate::i18n::tr("cli.ext.validate.failed")));
255 }
256 }
257 }
258 ext::ExtCommand::Render {
259 extension_id,
260 recipe_id,
261 config,
262 session,
263 out,
264 json,
265 } => {
266 use ext_helpers::{extension_error_code, fail_json, read_input};
267
268 if config == "-" && session == "-" {
270 return Err(fail_json(
271 json,
272 "invalid-args",
273 "only one of --config and --session may read from stdin",
274 ));
275 }
276 if json && out.is_none() {
277 return Err(fail_json(json, "invalid-args", "--json requires --out"));
278 }
279
280 let config_json = read_input(&config)
281 .map_err(|e| fail_json(json, "invalid-config", &e.to_string()))?;
282 let session_json = read_input(&session)
283 .map_err(|e| fail_json(json, "invalid-session", &e.to_string()))?;
284
285 let result = invoke_recipe(
286 ®istry,
287 &extension_id,
288 &recipe_id,
289 &config_json,
290 &session_json,
291 );
292
293 match (result, out) {
294 (Ok(art), Some(path)) => {
295 fs::write(&path, &art.bytes)?;
296 if json {
297 let summary = serde_json::json!({
298 "status": "ok",
299 "filename": art.filename,
300 "sha256": art.sha256,
301 "bytesLen": art.bytes.len(),
302 });
303 println!("{summary}");
304 } else {
305 let path_str = path.display().to_string();
306 println!(
307 "{}",
308 crate::i18n::trf(
309 "cli.ext.render.wrote",
310 &[("file", path_str.as_str()), ("sha256", art.sha256.as_str()),],
311 )
312 );
313 }
314 }
315 (Ok(art), None) => {
316 std::io::stdout().write_all(&art.bytes)?;
317 }
318 (Err(e), _) => {
319 return Err(fail_json(json, extension_error_code(&e), &e.to_string()));
320 }
321 }
322 }
323 ext::ExtCommand::InstallDir => {
324 println!("{}", install_dir.display());
325 }
326 }
327 Ok(())
328}
329
330#[cfg(test)]
331mod tests {
332 use clap::Parser;
333
334 use super::{Cli, Commands};
335
336 #[test]
337 fn parses_global_locale_and_wizard_flags() {
338 let cli = Cli::try_parse_from([
339 "greentic-bundle",
340 "--locale",
341 "en-US",
342 "wizard",
343 "run",
344 "--schema",
345 "--answers",
346 "answers.json",
347 "--emit-answers",
348 "out.json",
349 "--schema-version",
350 "1.2.3",
351 "--migrate",
352 "--dry-run",
353 ])
354 .expect("cli parses");
355
356 assert_eq!(cli.locale.as_deref(), Some("en-US"));
357 match cli.command {
358 Commands::Wizard(args) => {
359 assert!(args.schema);
360 match args.command.expect("wizard subcommand") {
361 super::wizard::WizardCommand::Run(run) => {
362 assert_eq!(
363 run.answers.as_deref(),
364 Some(std::path::Path::new("answers.json"))
365 );
366 assert_eq!(
367 run.emit_answers.as_deref(),
368 Some(std::path::Path::new("out.json"))
369 );
370 assert_eq!(run.schema_version.as_deref(), Some("1.2.3"));
371 assert!(run.migrate);
372 assert!(run.dry_run);
373 }
374 _ => panic!("expected run"),
375 }
376 }
377 _ => panic!("expected wizard"),
378 }
379 }
380
381 #[test]
382 fn parses_access_allow_execute_flag() {
383 let cli = Cli::try_parse_from([
384 "greentic-bundle",
385 "access",
386 "allow",
387 "tenant-a",
388 "--execute",
389 ])
390 .expect("cli parses");
391
392 match cli.command {
393 Commands::Access(args) => match args.command {
394 super::access::AccessCommand::Allow(allow) => {
395 assert_eq!(allow.subject, "tenant-a");
396 assert!(allow.execute);
397 assert!(!allow.dry_run);
398 }
399 _ => panic!("expected access allow"),
400 },
401 _ => panic!("expected access"),
402 }
403 }
404
405 #[test]
406 fn parses_build_export_doctor_and_inspect_flags() {
407 let build = Cli::try_parse_from([
408 "greentic-bundle",
409 "build",
410 "--root",
411 "bundle",
412 "--output",
413 "out.gtbundle",
414 "--dry-run",
415 ])
416 .expect("build parses");
417 match build.command {
418 Commands::Build(args) => {
419 assert_eq!(args.root, std::path::PathBuf::from("bundle"));
420 assert_eq!(args.output, Some(std::path::PathBuf::from("out.gtbundle")));
421 assert!(args.dry_run);
422 }
423 _ => panic!("expected build"),
424 }
425
426 let doctor = Cli::try_parse_from([
427 "greentic-bundle",
428 "doctor",
429 "--artifact",
430 "demo.gtbundle",
431 "--json",
432 ])
433 .expect("doctor parses");
434 match doctor.command {
435 Commands::Doctor(args) => {
436 assert_eq!(
437 args.artifact,
438 Some(std::path::PathBuf::from("demo.gtbundle"))
439 );
440 assert!(args.json);
441 }
442 _ => panic!("expected doctor"),
443 }
444
445 let export = Cli::try_parse_from([
446 "greentic-bundle",
447 "export",
448 "--build-dir",
449 "state/build/demo/normalized",
450 "--output",
451 "demo.gtbundle",
452 "--dry-run",
453 ])
454 .expect("export parses");
455 match export.command {
456 Commands::Export(args) => {
457 assert_eq!(
458 args.build_dir,
459 std::path::PathBuf::from("state/build/demo/normalized")
460 );
461 assert_eq!(args.output, std::path::PathBuf::from("demo.gtbundle"));
462 assert!(args.dry_run);
463 }
464 _ => panic!("expected export"),
465 }
466
467 let inspect = Cli::try_parse_from(["greentic-bundle", "inspect", "bundle", "--json"])
468 .expect("inspect parses");
469 match inspect.command {
470 Commands::Inspect(args) => {
471 assert_eq!(args.target, Some(std::path::PathBuf::from("bundle")));
472 assert!(args.json);
473 }
474 _ => panic!("expected inspect"),
475 }
476 }
477
478 #[test]
479 fn command_defaults_use_current_directory() {
480 assert_eq!(
481 super::build::BuildArgs::default().root,
482 std::path::PathBuf::from(".")
483 );
484 assert_eq!(
485 super::doctor::DoctorArgs::default().root,
486 std::path::PathBuf::from(".")
487 );
488 assert_eq!(
489 super::inspect::InspectArgs::default().root,
490 std::path::PathBuf::from(".")
491 );
492 }
493}