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