cloudburn 0.6.2 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +33 -0
  2. package/dist/cli.js +658 -84
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -8,6 +8,36 @@ Command-line interface for Cloudburn cloud cost optimization.
8
8
  npm install -g cloudburn
9
9
  ```
10
10
 
11
+ ## Shell completion
12
+
13
+ Inspect the available completion subcommands:
14
+
15
+ ```sh
16
+ cloudburn completion
17
+ cloudburn completion zsh --help
18
+ ```
19
+
20
+ Generate a completion script for your shell and source it directly:
21
+
22
+ ```sh
23
+ source <(cloudburn completion zsh)
24
+ source <(cloudburn completion bash)
25
+ cloudburn completion fish | source
26
+ ```
27
+
28
+ To enable completion persistently, add one of the following lines to your shell config:
29
+
30
+ ```sh
31
+ # ~/.zshrc
32
+ source <(cloudburn completion zsh)
33
+
34
+ # ~/.bashrc
35
+ source <(cloudburn completion bash)
36
+
37
+ # ~/.config/fish/config.fish
38
+ cloudburn completion fish | source
39
+ ```
40
+
11
41
  ## Usage
12
42
 
13
43
  ```sh
@@ -23,6 +53,9 @@ cloudburn scan ./template.yaml
23
53
  cloudburn scan ./iac
24
54
  cloudburn discover
25
55
  cloudburn discover --region all
56
+ cloudburn rules
57
+ cloudburn completion
58
+ cloudburn completion zsh
26
59
  ```
27
60
 
28
61
  `cloudburn scan --format json` emits the lean canonical grouped result:
package/dist/cli.js CHANGED
@@ -2,11 +2,464 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { pathToFileURL } from "url";
5
+
6
+ // src/completion/engine.ts
7
+ var buildCompletionTree = (command) => {
8
+ const help = command.createHelp();
9
+ return {
10
+ commands: help.visibleCommands(command).map((childCommand) => buildCompletionTree(childCommand)),
11
+ name: command.name(),
12
+ options: collectVisibleOptions([...help.visibleOptions(command), ...help.visibleGlobalOptions(command)])
13
+ };
14
+ };
15
+ var collectVisibleOptions = (options) => options.map((option) => ({
16
+ expectsValue: option.required || option.optional,
17
+ tokens: [option.short, option.long].filter((token) => token !== void 0)
18
+ }));
19
+ var flattenOptionTokens = (options) => {
20
+ const seen = /* @__PURE__ */ new Set();
21
+ const tokens = [];
22
+ for (const option of options) {
23
+ for (const token of option.tokens) {
24
+ if (seen.has(token)) {
25
+ continue;
26
+ }
27
+ seen.add(token);
28
+ tokens.push(token);
29
+ }
30
+ }
31
+ return tokens;
32
+ };
33
+ var findOption = (options, token) => options.find((option) => option.tokens.includes(token));
34
+ var parseOptionToken = (token) => {
35
+ const separatorIndex = token.indexOf("=");
36
+ if (separatorIndex === -1) {
37
+ return { hasInlineValue: false, name: token };
38
+ }
39
+ return {
40
+ hasInlineValue: true,
41
+ name: token.slice(0, separatorIndex)
42
+ };
43
+ };
44
+ var createCompletionTree = (command) => buildCompletionTree(command);
45
+ var resolveCompletionSuggestions = (root, words) => {
46
+ const committedWords = words.length === 0 ? [] : words.slice(0, -1);
47
+ const partialWord = words.at(-1) ?? "";
48
+ let currentNode = root;
49
+ let expectsOptionValue = false;
50
+ for (const word of committedWords) {
51
+ if (expectsOptionValue) {
52
+ expectsOptionValue = false;
53
+ continue;
54
+ }
55
+ if (word.startsWith("-")) {
56
+ const parsedOption = parseOptionToken(word);
57
+ const option = findOption(currentNode.options, parsedOption.name);
58
+ if (option?.expectsValue && !parsedOption.hasInlineValue) {
59
+ expectsOptionValue = true;
60
+ }
61
+ continue;
62
+ }
63
+ const nextNode = currentNode.commands.find((command) => command.name === word);
64
+ if (nextNode !== void 0) {
65
+ currentNode = nextNode;
66
+ }
67
+ }
68
+ if (expectsOptionValue) {
69
+ return [];
70
+ }
71
+ if (partialWord.startsWith("-")) {
72
+ const parsedOption = parseOptionToken(partialWord);
73
+ if (parsedOption.hasInlineValue && findOption(currentNode.options, parsedOption.name) !== void 0) {
74
+ return [];
75
+ }
76
+ return flattenOptionTokens(currentNode.options).filter((token) => token.startsWith(partialWord));
77
+ }
78
+ const commandSuggestions = currentNode.commands.map((command) => command.name).filter((commandName) => commandName.startsWith(partialWord));
79
+ if (partialWord.length > 0) {
80
+ return commandSuggestions;
81
+ }
82
+ return [...commandSuggestions, ...flattenOptionTokens(currentNode.options)];
83
+ };
84
+
85
+ // src/completion/shell.ts
86
+ var renderZshCompletion = (commandName) => `#compdef ${commandName}
87
+
88
+ _${commandName}() {
89
+ local -a suggestions
90
+ suggestions=("\${(@f)$("\${words[1]}" __complete -- "\${words[@]:1:$((CURRENT - 1))}")}")
91
+ _describe 'values' suggestions
92
+ }
93
+
94
+ compdef _${commandName} ${commandName}
95
+ `;
96
+ var renderBashCompletion = (commandName) => `_${commandName}() {
97
+ local -a args=()
98
+ local line
99
+ local -a suggestions=()
100
+
101
+ args=("\${COMP_WORDS[@]:1:$COMP_CWORD}")
102
+ if [ "$COMP_CWORD" -ge "\${#COMP_WORDS[@]}" ]; then
103
+ args+=("")
104
+ fi
105
+
106
+ while IFS= read -r line; do
107
+ suggestions+=("$line")
108
+ done < <("\${COMP_WORDS[0]}" __complete -- "\${args[@]}")
109
+
110
+ COMPREPLY=("\${suggestions[@]}")
111
+ }
112
+
113
+ complete -F _${commandName} ${commandName}
114
+ `;
115
+ var renderFishCompletion = (commandName) => `function __fish_${commandName}_complete
116
+ set -l words (commandline -opc)
117
+ if test (count $words) -gt 0
118
+ set -e words[1]
119
+ end
120
+ set -a words (commandline -ct)
121
+ ${commandName} __complete -- $words
122
+ end
123
+
124
+ complete -c ${commandName} -f -a "(__fish_${commandName}_complete)"
125
+ `;
126
+ var generateCompletionScript = (shell, program) => {
127
+ const commandName = program.name();
128
+ switch (shell) {
129
+ case "zsh":
130
+ return renderZshCompletion(commandName);
131
+ case "bash":
132
+ return renderBashCompletion(commandName);
133
+ case "fish":
134
+ return renderFishCompletion(commandName);
135
+ }
136
+ };
137
+
138
+ // src/help.ts
5
139
  import { Command } from "commander";
140
+ var ITEM_INDENT = 2;
141
+ var SPACER_WIDTH = 2;
142
+ var INCOMPLETE_HELP_ERROR_CODES = /* @__PURE__ */ new Set([
143
+ "commander.missingArgument",
144
+ "commander.missingMandatoryOptionValue",
145
+ "commander.optionMissingArgument"
146
+ ]);
147
+ var buildCommandPath = (command) => {
148
+ const names = [];
149
+ let current = command;
150
+ while (current !== null) {
151
+ names.unshift(current.name());
152
+ current = current.parent;
153
+ }
154
+ return names.join(" ");
155
+ };
156
+ var visibleCommands = (command) => command.commands.filter((subcommand) => !subcommand._hidden);
157
+ var getCommandExamples = (command) => {
158
+ const { _cloudburnExamples } = command;
159
+ return _cloudburnExamples ?? [];
160
+ };
161
+ var getCommandUsageGuidance = (command) => {
162
+ const { _cloudburnUsageGuidance } = command;
163
+ return _cloudburnUsageGuidance ?? null;
164
+ };
165
+ var getHelpScenario = (command) => command._cloudburnRenderingHelpScenario ?? "explicit";
166
+ var inferHelpScenario = (errorCode) => errorCode !== void 0 && INCOMPLETE_HELP_ERROR_CODES.has(errorCode) ? "incomplete" : "error";
167
+ var buildSubcommandGuidance = (command) => {
168
+ const firstSubcommand = visibleCommands(command).at(0);
169
+ if (firstSubcommand === void 0) {
170
+ return null;
171
+ }
172
+ return `Specify one of the available subcommands to continue.
173
+ Try: ${buildCommandPath(command)} ${firstSubcommand.name()}`;
174
+ };
175
+ var buildExampleGuidance = (command) => {
176
+ const [firstExample] = getCommandExamples(command);
177
+ return firstExample === void 0 ? null : `Try: ${firstExample}`;
178
+ };
179
+ var buildRecoveryGuidance = (command, scenario) => {
180
+ if (scenario !== "incomplete") {
181
+ return null;
182
+ }
183
+ return buildSubcommandGuidance(command) ?? buildExampleGuidance(command);
184
+ };
185
+ var formatDescriptionItem = (term, description, termWidth, helper) => {
186
+ const itemIndent = " ".repeat(ITEM_INDENT);
187
+ if (!description.includes("\n")) {
188
+ return helper.formatItem(term, termWidth, description, helper);
189
+ }
190
+ const paddedTerm = term.padEnd(termWidth + term.length - helper.displayWidth(term));
191
+ const continuationIndent = `${itemIndent}${" ".repeat(termWidth)}${" ".repeat(SPACER_WIDTH)}`;
192
+ const [firstLine, ...remainingLines] = description.split("\n");
193
+ return [
194
+ `${itemIndent}${paddedTerm}${" ".repeat(SPACER_WIDTH)}${firstLine}`,
195
+ ...remainingLines.map((line) => `${continuationIndent}${line}`)
196
+ ].join("\n");
197
+ };
198
+ var formatSection = (title, items, termWidth, helper) => {
199
+ if (items.length === 0) {
200
+ return [];
201
+ }
202
+ return [
203
+ helper.styleTitle(title),
204
+ ...items.map(({ description, term }) => formatDescriptionItem(term, description, termWidth, helper)),
205
+ ""
206
+ ];
207
+ };
208
+ var formatExamplesSection = (examples) => {
209
+ if (examples.length === 0) {
210
+ return [];
211
+ }
212
+ return ["Examples:", ...examples.map((example) => ` ${example}`), ""];
213
+ };
214
+ var formatGuidanceBlock = (guidance) => {
215
+ if (guidance === null) {
216
+ return [];
217
+ }
218
+ return [...guidance.trim().split("\n"), ""];
219
+ };
220
+ var formatCloudBurnHelp = (command, helper) => {
221
+ const cloudBurnHelp = helper;
222
+ const helpWidth = helper.helpWidth ?? 80;
223
+ const scenario = getHelpScenario(command);
224
+ const localOptions = helper.visibleOptions(command);
225
+ const globalOptions = helper.visibleGlobalOptions(command);
226
+ const commands = helper.visibleCommands(command);
227
+ const examples = getCommandExamples(command);
228
+ const usageGuidance = getCommandUsageGuidance(command);
229
+ const recoveryGuidance = buildRecoveryGuidance(command, scenario);
230
+ const termWidth = Math.max(
231
+ helper.longestArgumentTermLength(command, helper),
232
+ helper.longestOptionTermLength(command, helper),
233
+ helper.longestGlobalOptionTermLength(command, helper),
234
+ helper.longestSubcommandTermLength(command, helper)
235
+ );
236
+ const output = [];
237
+ const description = helper.commandDescription(command);
238
+ if (!cloudBurnHelp._cloudburnIsErrorOutput && description.length > 0) {
239
+ output.push(helper.boxWrap(helper.styleCommandDescription(description), helpWidth), "");
240
+ }
241
+ output.push(`${helper.styleTitle("Usage:")} ${helper.styleUsage(helper.commandUsage(command))}`, "");
242
+ output.push(...formatExamplesSection(examples));
243
+ output.push(...formatGuidanceBlock(usageGuidance));
244
+ output.push(
245
+ ...formatSection(
246
+ "Available Commands:",
247
+ commands.map((subcommand) => ({
248
+ description: helper.styleSubcommandDescription(helper.subcommandDescription(subcommand)),
249
+ term: helper.styleSubcommandTerm(helper.subcommandTerm(subcommand))
250
+ })),
251
+ termWidth,
252
+ helper
253
+ )
254
+ );
255
+ output.push(
256
+ ...formatSection(
257
+ "Arguments:",
258
+ helper.visibleArguments(command).map((argument) => ({
259
+ description: helper.styleArgumentDescription(helper.argumentDescription(argument)),
260
+ term: helper.styleArgumentTerm(helper.argumentTerm(argument))
261
+ })),
262
+ termWidth,
263
+ helper
264
+ )
265
+ );
266
+ output.push(
267
+ ...formatSection(
268
+ command.parent === null ? "Global Flags:" : "Flags:",
269
+ localOptions.map((option) => ({
270
+ description: helper.styleOptionDescription(helper.optionDescription(option)),
271
+ term: helper.styleOptionTerm(helper.optionTerm(option))
272
+ })),
273
+ termWidth,
274
+ helper
275
+ )
276
+ );
277
+ if (command.parent !== null) {
278
+ output.push(
279
+ ...formatSection(
280
+ "Global Flags:",
281
+ globalOptions.map((option) => ({
282
+ description: helper.styleOptionDescription(helper.optionDescription(option)),
283
+ term: helper.styleOptionTerm(helper.optionTerm(option))
284
+ })),
285
+ termWidth,
286
+ helper
287
+ )
288
+ );
289
+ }
290
+ output.push(...formatGuidanceBlock(recoveryGuidance));
291
+ if (scenario === "error" && commands.length > 0) {
292
+ output.push(`Use "${buildCommandPath(command)} [command] --help" for more information about a command.`, "");
293
+ }
294
+ while (output.at(-1) === "") {
295
+ output.pop();
296
+ }
297
+ return `${output.join("\n")}
298
+ `;
299
+ };
300
+ var prepareCloudBurnHelpContext = function(contextOptions) {
301
+ this._cloudburnIsErrorOutput = !!contextOptions.error;
302
+ this.helpWidth = this.helpWidth ?? contextOptions.helpWidth ?? 80;
303
+ };
304
+ var CloudBurnCommand = class _CloudBurnCommand extends Command {
305
+ _cloudburnExamples;
306
+ _cloudburnPendingHelpScenario;
307
+ _cloudburnRenderingHelpScenario;
308
+ _cloudburnUsageGuidance;
309
+ /**
310
+ * Creates CloudBurn subcommands that inherit the scenario-aware help behavior.
311
+ *
312
+ * @param name - Optional subcommand name supplied by Commander.
313
+ * @returns A new CloudBurn-aware command instance.
314
+ */
315
+ createCommand(name) {
316
+ return new _CloudBurnCommand(name);
317
+ }
318
+ /**
319
+ * Captures the error scenario so Commander help-after-error renders the correct layout.
320
+ *
321
+ * @param message - Error text emitted by Commander.
322
+ * @param errorOptions - Commander exit metadata including the error code.
323
+ * @returns Never returns because Commander exits or throws via exitOverride.
324
+ */
325
+ error(message, errorOptions) {
326
+ this._cloudburnPendingHelpScenario = inferHelpScenario(errorOptions?.code);
327
+ return super.error(message, errorOptions);
328
+ }
329
+ /**
330
+ * Returns help text using the shared CloudBurn scenario rules.
331
+ *
332
+ * @param context - Commander help context.
333
+ * @returns The formatted help text.
334
+ */
335
+ helpInformation(context) {
336
+ return this.renderWithHelpScenario(context, () => super.helpInformation(context));
337
+ }
338
+ renderWithHelpScenario(context, render) {
339
+ const previousScenario = this._cloudburnRenderingHelpScenario;
340
+ const pendingScenario = this._cloudburnPendingHelpScenario;
341
+ this._cloudburnRenderingHelpScenario = context?.error ? pendingScenario ?? "error" : pendingScenario ?? "explicit";
342
+ this._cloudburnPendingHelpScenario = void 0;
343
+ try {
344
+ return render();
345
+ } finally {
346
+ this._cloudburnRenderingHelpScenario = previousScenario;
347
+ }
348
+ }
349
+ };
350
+ var outputScenarioHelp = (command, scenario) => {
351
+ const cloudBurnCommand = command;
352
+ const previousScenario = cloudBurnCommand._cloudburnPendingHelpScenario;
353
+ cloudBurnCommand._cloudburnPendingHelpScenario = scenario;
354
+ try {
355
+ command.outputHelp();
356
+ } finally {
357
+ cloudBurnCommand._cloudburnPendingHelpScenario = previousScenario;
358
+ }
359
+ };
360
+ var createCliCommand = () => new CloudBurnCommand();
361
+ var createHelpConfiguration = () => ({
362
+ formatHelp: formatCloudBurnHelp,
363
+ prepareContext: prepareCloudBurnHelpContext,
364
+ showGlobalOptions: true,
365
+ visibleCommands
366
+ });
367
+ var configureCliHelp = (program) => {
368
+ program.configureHelp(createHelpConfiguration());
369
+ program.showHelpAfterError();
370
+ program.showSuggestionAfterError();
371
+ };
372
+ var registerParentCommand = (parent, name, description) => parent.command(name).description(description).usage("[command]").allowExcessArguments(true).action(function() {
373
+ if (this.args.length > 0) {
374
+ this.unknownCommand();
375
+ return;
376
+ }
377
+ outputScenarioHelp(this, "incomplete");
378
+ });
379
+ var setCommandExamples = (command, examples) => {
380
+ command._cloudburnExamples = examples;
381
+ return command;
382
+ };
383
+ var setCommandUsageGuidance = (command, guidance) => {
384
+ command._cloudburnUsageGuidance = guidance.trim();
385
+ return command;
386
+ };
387
+
388
+ // src/commands/completion.ts
389
+ var getRootCommand = (command) => {
390
+ let currentCommand = command;
391
+ while (currentCommand.parent !== null) {
392
+ currentCommand = currentCommand.parent;
393
+ }
394
+ return currentCommand;
395
+ };
396
+ var getCompletionHelpText = (shell) => {
397
+ switch (shell) {
398
+ case "bash":
399
+ return `
400
+ To load completions in your current shell session:
401
+
402
+ source <(cloudburn completion bash)
403
+
404
+ To load completions for every new session, add this to your shell config:
405
+
406
+ source <(cloudburn completion bash)
407
+ `;
408
+ case "fish":
409
+ return `
410
+ To load completions in your current shell session:
411
+
412
+ cloudburn completion fish | source
413
+
414
+ To load completions for every new session, add this to your shell config:
415
+
416
+ cloudburn completion fish | source
417
+ `;
418
+ case "zsh":
419
+ return `
420
+ If shell completion is not already enabled in your environment you will need
421
+ to enable it. You can execute the following once:
422
+
423
+ echo "autoload -U compinit; compinit" >> ~/.zshrc
424
+
425
+ To load completions in your current shell session:
426
+
427
+ source <(cloudburn completion zsh)
428
+
429
+ To load completions for every new session, add this to your shell config:
430
+
431
+ source <(cloudburn completion zsh)
432
+ `;
433
+ }
434
+ };
435
+ var registerCompletionCommand = (program) => {
436
+ const completionCommand = registerParentCommand(
437
+ program,
438
+ "completion",
439
+ "Generate shell completion scripts for CloudBurn."
440
+ );
441
+ for (const shell of ["bash", "fish", "zsh"]) {
442
+ setCommandUsageGuidance(
443
+ completionCommand.command(shell).description(`Generate the autocompletion script for the ${shell} shell.`).option("--no-descriptions", "disable completion descriptions").action(function() {
444
+ const output = generateCompletionScript(shell, getRootCommand(this));
445
+ process.stdout.write(output);
446
+ }),
447
+ getCompletionHelpText(shell)
448
+ );
449
+ }
450
+ program.command("__complete", { hidden: true }).argument("[words...]").allowUnknownOption().action(function(words) {
451
+ const suggestions = resolveCompletionSuggestions(createCompletionTree(getRootCommand(this)), words ?? []);
452
+ if (suggestions.length === 0) {
453
+ return;
454
+ }
455
+ process.stdout.write(`${suggestions.join("\n")}
456
+ `);
457
+ });
458
+ };
6
459
 
7
460
  // src/commands/discover.ts
8
461
  import { assertValidAwsRegion, CloudBurnClient } from "@cloudburn/sdk";
9
- import { InvalidArgumentError as InvalidArgumentError2 } from "commander";
462
+ import { InvalidArgumentError as InvalidArgumentError3 } from "commander";
10
463
 
11
464
  // src/exit-codes.ts
12
465
  var EXIT_CODE_OK = 0;
@@ -89,7 +542,7 @@ var scanColumns = [
89
542
  { key: "startColumn", header: "StartColumn" },
90
543
  { key: "message", header: "Message" }
91
544
  ];
92
- var formatOptionDescription = "Output format. table: human-readable terminal output. text: tab-delimited output for grep, sed, and awk. json: machine-readable output for automation and downstream systems.";
545
+ var formatOptionDescription = "Options: table: human-readable terminal output.\ntext: tab-delimited output for grep, sed, and awk.\njson: machine-readable output for automation and downstream systems.";
93
546
  var OUTPUT_FORMAT_OPTION_DESCRIPTION = formatOptionDescription;
94
547
  var parseOutputFormat = (value) => {
95
548
  if (supportedOutputFormats.includes(value)) {
@@ -254,12 +707,22 @@ var renderAsciiTable = (rows, columns) => {
254
707
  return [border, header, border, ...body, border].join("\n");
255
708
  };
256
709
 
710
+ // src/commands/config-options.ts
711
+ import { InvalidArgumentError as InvalidArgumentError2 } from "commander";
712
+ var parseRuleIdList = (value) => {
713
+ const ruleIds = value.split(",").map((ruleId) => ruleId.trim()).filter((ruleId) => ruleId.length > 0);
714
+ if (ruleIds.length === 0) {
715
+ throw new InvalidArgumentError2("Provide at least one rule ID.");
716
+ }
717
+ return ruleIds;
718
+ };
719
+
257
720
  // src/commands/discover.ts
258
721
  var parseAwsRegion = (value) => {
259
722
  try {
260
723
  return assertValidAwsRegion(value);
261
724
  } catch (err) {
262
- throw new InvalidArgumentError2(err instanceof Error ? err.message : "Invalid AWS region.");
725
+ throw new InvalidArgumentError3(err instanceof Error ? err.message : "Invalid AWS region.");
263
726
  }
264
727
  };
265
728
  var parseDiscoverRegion = (value) => {
@@ -269,6 +732,17 @@ var parseDiscoverRegion = (value) => {
269
732
  return parseAwsRegion(value);
270
733
  };
271
734
  var resolveDiscoveryTarget = (region) => region === void 0 ? { mode: "current" } : region === "all" ? { mode: "all" } : { mode: "region", region };
735
+ var toDiscoveryConfigOverride = (options) => {
736
+ if (options.enabledRules === void 0 && options.disabledRules === void 0) {
737
+ return void 0;
738
+ }
739
+ return {
740
+ discovery: {
741
+ disabledRules: options.disabledRules,
742
+ enabledRules: options.enabledRules
743
+ }
744
+ };
745
+ };
272
746
  var runCommand = async (action) => {
273
747
  try {
274
748
  process.exitCode = await action() ?? EXIT_CODE_OK;
@@ -279,39 +753,49 @@ var runCommand = async (action) => {
279
753
  }
280
754
  };
281
755
  var registerDiscoverCommand = (program) => {
282
- const discoverCommand = program.command("discover").description("Run a live AWS discovery").enablePositionalOptions().option(
283
- "--region <region>",
284
- 'Discovery region to use. Pass "all" to require an aggregator index.',
285
- parseDiscoverRegion
286
- ).option("--format <format>", OUTPUT_FORMAT_OPTION_DESCRIPTION, parseOutputFormat).option("--exit-code", "Exit with code 1 when findings exist").addHelpText(
287
- "after",
288
- `
289
- Examples:
290
- cloudburn discover
291
- cloudburn discover --region eu-central-1
292
- cloudburn discover --region all
293
- cloudburn discover list-enabled-regions
294
- cloudburn discover init
295
- `
296
- ).action(async (options, command) => {
297
- await runCommand(async () => {
298
- const scanner = new CloudBurnClient();
299
- const result = await scanner.discover({ target: resolveDiscoveryTarget(options.region) });
300
- const format = resolveOutputFormat(command, options.format);
301
- const output = renderResponse({ kind: "scan-result", result }, format);
302
- process.stdout.write(`${output}
756
+ const discoverCommand = setCommandExamples(
757
+ program.command("discover").description("Run a live AWS discovery").enablePositionalOptions().option(
758
+ "--region <region>",
759
+ 'Discovery region to use. Pass "all" to require an aggregator index.',
760
+ parseDiscoverRegion
761
+ ).option("--config <path>", "Explicit CloudBurn config file to load").option("--enabled-rules <ruleIds>", "Comma-separated rule IDs to enable", parseRuleIdList).option("--disabled-rules <ruleIds>", "Comma-separated rule IDs to disable", parseRuleIdList).option("--exit-code", "Exit with code 1 when findings exist").action(async (options, command) => {
762
+ await runCommand(async () => {
763
+ const scanner = new CloudBurnClient();
764
+ const loadedConfig = await scanner.loadConfig(options.config);
765
+ const discoveryOptions = {
766
+ target: resolveDiscoveryTarget(options.region)
767
+ };
768
+ const configOverride = toDiscoveryConfigOverride(options);
769
+ if (configOverride !== void 0) {
770
+ discoveryOptions.config = configOverride;
771
+ }
772
+ if (options.config !== void 0) {
773
+ discoveryOptions.configPath = options.config;
774
+ }
775
+ const result = await scanner.discover(discoveryOptions);
776
+ const format = resolveOutputFormat(command, void 0, loadedConfig.discovery.format ?? "table");
777
+ const output = renderResponse({ kind: "scan-result", result }, format);
778
+ process.stdout.write(`${output}
303
779
  `);
304
- if (options.exitCode && countScanResultFindings(result) > 0) {
305
- return EXIT_CODE_POLICY_VIOLATION;
306
- }
307
- return EXIT_CODE_OK;
308
- });
309
- });
310
- discoverCommand.command("list-enabled-regions").description("List AWS regions with a local or aggregator Resource Explorer index").option("--format <format>", OUTPUT_FORMAT_OPTION_DESCRIPTION, parseOutputFormat).action(async (options, command) => {
780
+ if (options.exitCode && countScanResultFindings(result) > 0) {
781
+ return EXIT_CODE_POLICY_VIOLATION;
782
+ }
783
+ return EXIT_CODE_OK;
784
+ });
785
+ }),
786
+ [
787
+ "cloudburn discover",
788
+ "cloudburn discover --region eu-central-1",
789
+ "cloudburn discover --region all",
790
+ "cloudburn discover list-enabled-regions",
791
+ "cloudburn discover init"
792
+ ]
793
+ );
794
+ discoverCommand.command("list-enabled-regions").description("List AWS regions with a local or aggregator Resource Explorer index").action(async (_options, command) => {
311
795
  await runCommand(async () => {
312
796
  const scanner = new CloudBurnClient();
313
797
  const regions = await scanner.listEnabledDiscoveryRegions();
314
- const format = resolveOutputFormat(command, options.format);
798
+ const format = resolveOutputFormat(command);
315
799
  const output = renderResponse(
316
800
  {
317
801
  kind: "record-list",
@@ -329,14 +813,14 @@ Examples:
329
813
  return EXIT_CODE_OK;
330
814
  });
331
815
  });
332
- discoverCommand.command("init").description("Set up AWS Resource Explorer for CloudBurn").option("--format <format>", OUTPUT_FORMAT_OPTION_DESCRIPTION, parseOutputFormat).option("--region <region>", "Aggregator region to create or reuse during setup", parseAwsRegion).action(async (options, command) => {
816
+ discoverCommand.command("init").description("Set up AWS Resource Explorer for CloudBurn").option("--region <region>", "Aggregator region to create or reuse during setup", parseAwsRegion).action(async (options, command) => {
333
817
  await runCommand(async () => {
334
818
  const scanner = new CloudBurnClient();
335
819
  const parentRegion = discoverCommand.opts().region;
336
820
  const region = options.region ?? (parentRegion === "all" ? void 0 : parentRegion);
337
821
  const result = await scanner.initializeDiscovery({ region });
338
822
  const message = result.status === "EXISTING" ? `Resource Explorer setup already exists in ${result.aggregatorRegion}.` : `Resource Explorer setup created in ${result.aggregatorRegion}.`;
339
- const format = resolveOutputFormat(command, options.format);
823
+ const format = resolveOutputFormat(command);
340
824
  const output = renderResponse(
341
825
  {
342
826
  kind: "status",
@@ -356,11 +840,11 @@ Examples:
356
840
  return EXIT_CODE_OK;
357
841
  });
358
842
  });
359
- discoverCommand.command("supported-resource-types").description("List Resource Explorer supported AWS resource types").option("--format <format>", OUTPUT_FORMAT_OPTION_DESCRIPTION, parseOutputFormat).action(async (options, command) => {
843
+ discoverCommand.command("supported-resource-types").description("List Resource Explorer supported AWS resource types").action(async (_options, command) => {
360
844
  await runCommand(async () => {
361
845
  const scanner = new CloudBurnClient();
362
846
  const resourceTypes = await scanner.listSupportedDiscoveryResourceTypes();
363
- const format = resolveOutputFormat(command, options.format);
847
+ const format = resolveOutputFormat(command);
364
848
  const output = renderResponse(
365
849
  {
366
850
  kind: "record-list",
@@ -385,8 +869,8 @@ Examples:
385
869
 
386
870
  // src/commands/estimate.ts
387
871
  var registerEstimateCommand = (program) => {
388
- program.command("estimate").description("Request optional pricing estimates from a self-hosted dashboard").option("--format <format>", OUTPUT_FORMAT_OPTION_DESCRIPTION, parseOutputFormat).option("--server <url>", "Dashboard API base URL").action((options, command) => {
389
- const format = resolveOutputFormat(command, options.format);
872
+ program.command("estimate").description("Request optional pricing estimates from a self-hosted dashboard").option("--server <url>", "Dashboard API base URL").action((options, command) => {
873
+ const format = resolveOutputFormat(command);
390
874
  if (!options.server) {
391
875
  const output2 = renderResponse(
392
876
  {
@@ -422,41 +906,120 @@ var registerEstimateCommand = (program) => {
422
906
  };
423
907
 
424
908
  // src/commands/init.ts
425
- var starterConfig = `version: 1
426
- profile: dev
909
+ import { access, writeFile } from "fs/promises";
910
+ import { dirname, join, resolve } from "path";
911
+ var CONFIG_FILENAMES = [".cloudburn.yml", ".cloudburn.yaml"];
912
+ var starterConfig = `# Static IaC scan configuration.
913
+ # enabled-rules restricts scans to only the listed rule IDs.
914
+ # disabled-rules removes specific rule IDs from the active set.
915
+ # format sets the default output format when --format is not passed.
916
+ iac:
917
+ enabled-rules:
918
+ - CLDBRN-AWS-EBS-1
919
+ disabled-rules:
920
+ - CLDBRN-AWS-EC2-2
921
+ format: table
427
922
 
428
- # Profiles are parsed but not applied yet, so configure the active rules block directly for now.
429
- rules:
430
- ec2-instance-type-preferred:
431
- severity: error
923
+ # Live AWS discovery configuration.
924
+ # Use the same rule controls here to tune discover runs separately from IaC scans.
925
+ discovery:
926
+ enabled-rules:
927
+ - CLDBRN-AWS-EBS-1
928
+ disabled-rules:
929
+ - CLDBRN-AWS-S3-1
930
+ format: json
432
931
  `;
932
+ var renderStarterConfig = (command) => renderResponse(
933
+ {
934
+ kind: "document",
935
+ content: starterConfig,
936
+ contentType: "application/yaml"
937
+ },
938
+ resolveOutputFormat(command, void 0, "text")
939
+ );
940
+ var fileExists = async (path) => {
941
+ try {
942
+ await access(path);
943
+ return true;
944
+ } catch {
945
+ return false;
946
+ }
947
+ };
948
+ var findProjectRoot = async (startDirectory) => {
949
+ let currentDirectory = resolve(startDirectory);
950
+ while (true) {
951
+ if (await fileExists(join(currentDirectory, ".git"))) {
952
+ return currentDirectory;
953
+ }
954
+ const parentDirectory = dirname(currentDirectory);
955
+ if (parentDirectory === currentDirectory) {
956
+ return resolve(startDirectory);
957
+ }
958
+ currentDirectory = parentDirectory;
959
+ }
960
+ };
433
961
  var registerInitCommand = (program) => {
434
- program.command("init").description("Print a starter .cloudburn.yml configuration").option("--format <format>", OUTPUT_FORMAT_OPTION_DESCRIPTION, parseOutputFormat).action((options, command) => {
435
- const output = renderResponse(
436
- {
437
- kind: "document",
438
- content: starterConfig,
439
- contentType: "application/yaml"
440
- },
441
- resolveOutputFormat(command, options.format, "text")
442
- );
443
- process.stdout.write(`${output}
962
+ const initCommand = program.command("init").description("Initialize CloudBurn scaffolding").usage("[command]").action(function() {
963
+ process.stdout.write(`${renderStarterConfig(this)}
964
+ `);
965
+ process.exitCode = EXIT_CODE_OK;
966
+ });
967
+ initCommand.command("config").description("Create a starter .cloudburn.yml configuration").option("--print", "Print the starter config instead of writing the file").action(async function(options) {
968
+ try {
969
+ if (options.print) {
970
+ process.stdout.write(`${renderStarterConfig(this)}
971
+ `);
972
+ process.exitCode = EXIT_CODE_OK;
973
+ return;
974
+ }
975
+ const rootDirectory = await findProjectRoot(process.cwd());
976
+ const existingConfigPath = (await Promise.all(
977
+ CONFIG_FILENAMES.map(async (filename) => {
978
+ const path = join(rootDirectory, filename);
979
+ return await fileExists(path) ? path : void 0;
980
+ })
981
+ )).find((path) => path !== void 0);
982
+ if (existingConfigPath) {
983
+ throw new Error(
984
+ `CloudBurn config already exists at ${existingConfigPath}. Use --print to inspect the template.`
985
+ );
986
+ }
987
+ const configPath = join(rootDirectory, ".cloudburn.yml");
988
+ await writeFile(configPath, starterConfig, { encoding: "utf8", flag: "wx" });
989
+ const output = renderResponse(
990
+ {
991
+ kind: "status",
992
+ data: {
993
+ message: "Created CloudBurn config.",
994
+ path: configPath
995
+ },
996
+ text: `Created ${configPath}.`
997
+ },
998
+ resolveOutputFormat(this)
999
+ );
1000
+ process.stdout.write(`${output}
1001
+ `);
1002
+ process.exitCode = EXIT_CODE_OK;
1003
+ } catch (err) {
1004
+ process.stderr.write(`${formatError(err)}
444
1005
  `);
1006
+ process.exitCode = EXIT_CODE_RUNTIME_ERROR;
1007
+ }
445
1008
  });
446
1009
  };
447
1010
 
448
1011
  // src/commands/rules-list.ts
449
1012
  import { builtInRuleMetadata } from "@cloudburn/sdk";
450
1013
  var registerRulesListCommand = (program) => {
451
- const rulesCommand = program.command("rules").description("Inspect built-in CloudBurn rules");
452
- rulesCommand.command("list").description("List built-in CloudBurn rules").option("--format <format>", OUTPUT_FORMAT_OPTION_DESCRIPTION, parseOutputFormat).action((options, command) => {
1014
+ const rulesCommand = registerParentCommand(program, "rules", "Inspect built-in CloudBurn rules");
1015
+ rulesCommand.command("list").description("List built-in CloudBurn rules").action(function() {
453
1016
  const output = renderResponse(
454
1017
  {
455
1018
  kind: "rule-list",
456
1019
  emptyMessage: "No built-in rules are available.",
457
1020
  rules: builtInRuleMetadata
458
1021
  },
459
- resolveOutputFormat(command, options.format, "text")
1022
+ resolveOutputFormat(this, void 0, "text")
460
1023
  );
461
1024
  process.stdout.write(`${output}
462
1025
  `);
@@ -465,40 +1028,51 @@ var registerRulesListCommand = (program) => {
465
1028
 
466
1029
  // src/commands/scan.ts
467
1030
  import { CloudBurnClient as CloudBurnClient2 } from "@cloudburn/sdk";
1031
+ var toScanConfigOverride = (options) => {
1032
+ if (options.enabledRules === void 0 && options.disabledRules === void 0) {
1033
+ return void 0;
1034
+ }
1035
+ return {
1036
+ iac: {
1037
+ disabledRules: options.disabledRules,
1038
+ enabledRules: options.enabledRules
1039
+ }
1040
+ };
1041
+ };
468
1042
  var registerScanCommand = (program) => {
469
- program.command("scan").description("Run an autodetected static IaC scan").argument("[path]", "Terraform file, CloudFormation template, or directory to scan").option("--format <format>", OUTPUT_FORMAT_OPTION_DESCRIPTION, parseOutputFormat).option("--exit-code", "Exit with code 1 when findings exist").addHelpText(
470
- "after",
471
- `
472
- Examples:
473
- cloudburn scan ./main.tf
474
- cloudburn scan ./template.yaml
475
- cloudburn scan ./iac
476
- `
477
- ).action(async (path, options, command) => {
478
- try {
479
- const scanner = new CloudBurnClient2();
480
- const result = await scanner.scanStatic(path ?? process.cwd());
481
- const format = resolveOutputFormat(command, options.format);
482
- const output = renderResponse({ kind: "scan-result", result }, format);
483
- process.stdout.write(`${output}
1043
+ setCommandExamples(
1044
+ program.command("scan").description("Run an autodetected static IaC scan").argument("[path]", "Terraform file, CloudFormation template, or directory to scan").option("--config <path>", "Explicit CloudBurn config file to load").option("--enabled-rules <ruleIds>", "Comma-separated rule IDs to enable", parseRuleIdList).option("--disabled-rules <ruleIds>", "Comma-separated rule IDs to disable", parseRuleIdList).option("--exit-code", "Exit with code 1 when findings exist").action(async (path, options, command) => {
1045
+ try {
1046
+ const scanner = new CloudBurnClient2();
1047
+ const loadedConfig = await scanner.loadConfig(options.config);
1048
+ const configOverride = toScanConfigOverride(options);
1049
+ const scanPath = path ?? process.cwd();
1050
+ const result = configOverride === void 0 && options.config === void 0 ? await scanner.scanStatic(scanPath) : options.config === void 0 ? await scanner.scanStatic(scanPath, configOverride) : await scanner.scanStatic(scanPath, configOverride, { configPath: options.config });
1051
+ const format = resolveOutputFormat(command, void 0, loadedConfig.iac.format ?? "table");
1052
+ const output = renderResponse({ kind: "scan-result", result }, format);
1053
+ process.stdout.write(`${output}
484
1054
  `);
485
- if (options.exitCode && countScanResultFindings(result) > 0) {
486
- process.exitCode = EXIT_CODE_POLICY_VIOLATION;
487
- return;
488
- }
489
- process.exitCode = EXIT_CODE_OK;
490
- } catch (err) {
491
- process.stderr.write(`${formatError(err)}
1055
+ if (options.exitCode && countScanResultFindings(result) > 0) {
1056
+ process.exitCode = EXIT_CODE_POLICY_VIOLATION;
1057
+ return;
1058
+ }
1059
+ process.exitCode = EXIT_CODE_OK;
1060
+ } catch (err) {
1061
+ process.stderr.write(`${formatError(err)}
492
1062
  `);
493
- process.exitCode = EXIT_CODE_RUNTIME_ERROR;
494
- }
495
- });
1063
+ process.exitCode = EXIT_CODE_RUNTIME_ERROR;
1064
+ }
1065
+ }),
1066
+ ["cloudburn scan ./main.tf", "cloudburn scan ./template.yaml", "cloudburn scan ./iac"]
1067
+ );
496
1068
  };
497
1069
 
498
1070
  // src/cli.ts
499
1071
  var createProgram = () => {
500
- const program = new Command();
501
- program.name("cloudburn").description("Know what you spend. Fix what you waste.").version("0.6.2").option("--format <format>", OUTPUT_FORMAT_OPTION_DESCRIPTION, parseOutputFormat);
1072
+ const program = createCliCommand();
1073
+ program.name("cloudburn").usage("[command]").description("Know what you spend. Fix what you waste.").version("0.8.0").option("--format <format>", OUTPUT_FORMAT_OPTION_DESCRIPTION, parseOutputFormat);
1074
+ configureCliHelp(program);
1075
+ registerCompletionCommand(program);
502
1076
  registerDiscoverCommand(program);
503
1077
  registerScanCommand(program);
504
1078
  registerInitCommand(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudburn",
3
- "version": "0.6.2",
3
+ "version": "0.8.0",
4
4
  "description": "Cloudburn CLI for cloud cost optimization",
5
5
  "type": "module",
6
6
  "bin": {
@@ -11,7 +11,7 @@
11
11
  ],
12
12
  "dependencies": {
13
13
  "commander": "^13.1.0",
14
- "@cloudburn/sdk": "0.10.0"
14
+ "@cloudburn/sdk": "0.12.0"
15
15
  },
16
16
  "devDependencies": {
17
17
  "@biomejs/biome": "^2.4.6",