cloudburn 0.8.2 → 0.8.4

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 (2) hide show
  1. package/dist/cli.js +228 -39
  2. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { pathToFileURL } from "url";
4
+ import { realpathSync } from "fs";
5
+ import { resolve as resolve2 } from "path";
6
+ import { fileURLToPath } from "url";
5
7
 
6
8
  // src/completion/engine.ts
7
9
  var buildCompletionTree = (command) => {
@@ -153,6 +155,13 @@ var buildCommandPath = (command) => {
153
155
  }
154
156
  return names.join(" ");
155
157
  };
158
+ var getRootCommand = (command) => {
159
+ let current = command;
160
+ while (current.parent !== null) {
161
+ current = current.parent;
162
+ }
163
+ return current;
164
+ };
156
165
  var visibleCommands = (command) => command.commands.filter((subcommand) => !subcommand._hidden);
157
166
  var getCommandExamples = (command) => {
158
167
  const { _cloudburnExamples } = command;
@@ -221,8 +230,9 @@ var formatCloudBurnHelp = (command, helper) => {
221
230
  const cloudBurnHelp = helper;
222
231
  const helpWidth = helper.helpWidth ?? 80;
223
232
  const scenario = getHelpScenario(command);
224
- const localOptions = helper.visibleOptions(command);
225
- const globalOptions = helper.visibleGlobalOptions(command);
233
+ const rootCommand = getRootCommand(command);
234
+ const localOptions = helper.visibleOptions(command).filter((option) => command.options.includes(option));
235
+ const globalOptions = helper.visibleGlobalOptions(command).filter((option) => rootCommand.options.includes(option));
226
236
  const commands = helper.visibleCommands(command);
227
237
  const examples = getCommandExamples(command);
228
238
  const usageGuidance = getCommandUsageGuidance(command);
@@ -386,7 +396,7 @@ var setCommandUsageGuidance = (command, guidance) => {
386
396
  };
387
397
 
388
398
  // src/commands/completion.ts
389
- var getRootCommand = (command) => {
399
+ var getRootCommand2 = (command) => {
390
400
  let currentCommand = command;
391
401
  while (currentCommand.parent !== null) {
392
402
  currentCommand = currentCommand.parent;
@@ -441,14 +451,14 @@ var registerCompletionCommand = (program) => {
441
451
  for (const shell of ["bash", "fish", "zsh"]) {
442
452
  setCommandUsageGuidance(
443
453
  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));
454
+ const output = generateCompletionScript(shell, getRootCommand2(this));
445
455
  process.stdout.write(output);
446
456
  }),
447
457
  getCompletionHelpText(shell)
448
458
  );
449
459
  }
450
460
  program.command("__complete", { hidden: true }).argument("[words...]").allowUnknownOption().action(function(words) {
451
- const suggestions = resolveCompletionSuggestions(createCompletionTree(getRootCommand(this)), words ?? []);
461
+ const suggestions = resolveCompletionSuggestions(createCompletionTree(getRootCommand2(this)), words ?? []);
452
462
  if (suggestions.length === 0) {
453
463
  return;
454
464
  }
@@ -480,25 +490,26 @@ var categorize = (err) => {
480
490
  if (!(err instanceof Error)) {
481
491
  return { code: "RUNTIME_ERROR", message: "An unexpected error occurred." };
482
492
  }
483
- if (err.name === "CredentialsProviderError" || err.name === "ExpiredTokenException") {
493
+ const code = "code" in err && typeof err.code === "string" ? err.code : void 0;
494
+ if (err.name === "CredentialsProviderError" || err.name === "ExpiredTokenException" || code === "CredentialsProviderError" || code === "ExpiredTokenException") {
484
495
  return {
485
496
  code: "CREDENTIALS_ERROR",
486
497
  message: "AWS credentials not found or expired. Run 'aws sts get-caller-identity' to verify your session."
487
498
  };
488
499
  }
489
- if (err.name.includes("AccessDenied")) {
500
+ if (err.name.includes("AccessDenied") || code?.includes("AccessDenied") === true) {
490
501
  return {
491
502
  code: "ACCESS_DENIED",
492
- message: "Insufficient AWS permissions. Check your IAM role or policy."
503
+ message: sanitizeRuntimeErrorMessage(err.message).trim() || "Insufficient AWS permissions. Check your IAM role or policy."
493
504
  };
494
505
  }
495
506
  if (err.code === "ENOENT") {
496
507
  const path = err.path ?? "unknown";
497
508
  return { code: "PATH_NOT_FOUND", message: `Path not found: ${path}` };
498
509
  }
499
- if ("code" in err && typeof err.code === "string" && isAwsDiscoveryErrorCode(err.code)) {
510
+ if (code && isAwsDiscoveryErrorCode(code)) {
500
511
  return {
501
- code: err.code,
512
+ code,
502
513
  message: sanitizeRuntimeErrorMessage(err.message).trim() || "AWS Resource Explorer discovery failed."
503
514
  };
504
515
  }
@@ -526,6 +537,7 @@ var flattenScanResult = (result) => result.providers.flatMap(
526
537
  )
527
538
  );
528
539
  var countScanResultFindings = (result) => flattenScanResult(result).length;
540
+ var getScanDiagnostics = (result) => result.diagnostics ?? [];
529
541
 
530
542
  // src/formatters/output.ts
531
543
  var DEFAULT_TABLE_WIDTH = 200;
@@ -574,6 +586,8 @@ var renderJson = (response) => {
574
586
  switch (response.kind) {
575
587
  case "document":
576
588
  return JSON.stringify({ content: response.content, contentType: response.contentType }, null, 2);
589
+ case "discovery-status":
590
+ return JSON.stringify({ summary: response.summary, regions: response.rows }, null, 2);
577
591
  case "record-list":
578
592
  return JSON.stringify(response.rows, null, 2);
579
593
  case "rule-list":
@@ -590,6 +604,9 @@ var renderText = (response) => {
590
604
  switch (response.kind) {
591
605
  case "document":
592
606
  return response.content;
607
+ case "discovery-status":
608
+ return `${response.summaryText}
609
+ ${renderTextRows(response.rows, response.columns, "No discovery status available.")}`;
593
610
  case "record-list":
594
611
  return renderTextRows(response.rows, response.columns, response.emptyMessage);
595
612
  case "rule-list":
@@ -615,6 +632,19 @@ var renderTable = (response) => {
615
632
  { key: "Value", header: "Value" }
616
633
  ]
617
634
  );
635
+ case "discovery-status": {
636
+ const summaryTable = renderAsciiTable(
637
+ Object.entries(response.summary).map(([field, value]) => ({ Field: field, Value: value })),
638
+ [
639
+ { key: "Field", header: "Field" },
640
+ { key: "Value", header: "Value" }
641
+ ]
642
+ );
643
+ const regionsTable = response.rows.length === 0 ? "No discovery status available." : renderAsciiTable(response.rows, response.columns ?? inferColumns(response.rows));
644
+ return `${summaryTable}
645
+
646
+ ${regionsTable}`;
647
+ }
618
648
  case "record-list":
619
649
  return response.rows.length === 0 ? response.emptyMessage : renderAsciiTable(response.rows, response.columns ?? inferColumns(response.rows));
620
650
  case "rule-list":
@@ -638,19 +668,34 @@ var renderTable = (response) => {
638
668
  );
639
669
  }
640
670
  };
641
- var projectScanRows = (result) => flattenScanResult(result).map(({ finding, message, provider, ruleId, service, source }) => ({
642
- accountId: finding.accountId ?? "",
643
- message,
644
- path: finding.location?.path ?? "",
645
- provider,
646
- region: finding.region ?? "",
647
- resourceId: finding.resourceId,
648
- ruleId,
649
- service,
650
- source,
651
- column: finding.location?.column ?? "",
652
- line: finding.location?.line ?? ""
653
- }));
671
+ var projectScanRows = (result) => [
672
+ ...flattenScanResult(result).map(({ finding, message, provider, ruleId, service, source }) => ({
673
+ accountId: finding.accountId ?? "",
674
+ message,
675
+ path: finding.location?.path ?? "",
676
+ provider,
677
+ region: finding.region ?? "",
678
+ resourceId: finding.resourceId,
679
+ ruleId,
680
+ service,
681
+ source,
682
+ column: finding.location?.column ?? "",
683
+ line: finding.location?.line ?? ""
684
+ })),
685
+ ...getScanDiagnostics(result).map((diagnostic) => ({
686
+ accountId: "",
687
+ column: "",
688
+ line: "",
689
+ message: diagnostic.message,
690
+ path: "",
691
+ provider: diagnostic.provider,
692
+ region: diagnostic.region ?? "",
693
+ resourceId: "",
694
+ ruleId: "",
695
+ service: diagnostic.service,
696
+ source: diagnostic.source
697
+ }))
698
+ ];
654
699
  var renderTextRows = (rows, columns, emptyMessage) => {
655
700
  if (rows.length === 0) {
656
701
  return emptyMessage;
@@ -822,6 +867,69 @@ var parseRuleIdList = (value) => {
822
867
  };
823
868
 
824
869
  // src/commands/discover.ts
870
+ var describeDiscoverySummary = (status) => {
871
+ const aggregatorSummary = status.aggregatorRegion ? ` Aggregator region: ${status.aggregatorRegion}.` : "";
872
+ const warningSummary = status.warning ? ` ${status.warning}` : "";
873
+ return `Coverage: ${status.coverage}. Indexed ${status.indexedRegionCount} of ${status.totalRegionCount} enabled regions.${aggregatorSummary}${warningSummary}`.trim();
874
+ };
875
+ var describeInitializationMessage = (result) => {
876
+ const baseMessage = result.indexType === "aggregator" ? result.aggregatorAction === "promoted" ? `Promoted the existing local Resource Explorer index in ${result.aggregatorRegion} to the aggregator.` : result.aggregatorAction === "created" ? `Configured ${result.aggregatorRegion} as the Resource Explorer aggregator.` : result.coverage === "full" ? `Resource Explorer aggregator already exists in ${result.aggregatorRegion}.` : `Resource Explorer aggregator already exists in ${result.aggregatorRegion}, but only ${result.observedStatus.indexedRegionCount} of ${result.observedStatus.totalRegionCount} regions are indexed.` : result.status === "EXISTING" ? `Local Resource Explorer setup already exists in ${result.aggregatorRegion}.` : `Local Resource Explorer setup created in ${result.aggregatorRegion}.`;
877
+ const indexSummaryParts = [];
878
+ if (result.createdIndexCount > 0) {
879
+ indexSummaryParts.push(
880
+ `Created ${result.createdIndexCount} ${result.createdIndexCount === 1 ? "index" : "indexes"}.`
881
+ );
882
+ }
883
+ if (result.reusedIndexCount > 0) {
884
+ indexSummaryParts.push(
885
+ `Reused ${result.reusedIndexCount} existing ${result.reusedIndexCount === 1 ? "index" : "indexes"}.`
886
+ );
887
+ }
888
+ const indexSummary = indexSummaryParts.length === 0 ? "" : ` ${indexSummaryParts.join(" ")}`;
889
+ const warnings = Array.from(new Set([result.warning, result.observedStatus.warning].filter(Boolean)));
890
+ const warning = warnings.length === 0 ? "" : ` ${warnings.join(" ")}`;
891
+ const convergenceNotice = result.verificationStatus === "timed_out" ? " Setup is still converging in AWS, so the observed coverage may still change." : "";
892
+ return `${baseMessage}${indexSummary}${warning}${convergenceNotice}`.trim();
893
+ };
894
+ var buildInitializationStatusData = (result, message, format) => {
895
+ const restrictedRegionCount = Math.max(
896
+ 0,
897
+ result.observedStatus.totalRegionCount - result.observedStatus.accessibleRegionCount
898
+ );
899
+ if (format === "json") {
900
+ return {
901
+ aggregatorAction: result.aggregatorAction,
902
+ aggregatorRegion: result.aggregatorRegion,
903
+ coverage: result.coverage,
904
+ createdIndexCount: result.createdIndexCount,
905
+ indexType: result.indexType,
906
+ message,
907
+ observedStatus: result.observedStatus,
908
+ regions: result.regions,
909
+ reusedIndexCount: result.reusedIndexCount,
910
+ status: result.status,
911
+ taskId: result.taskId ?? "",
912
+ verificationStatus: result.verificationStatus,
913
+ ...result.warning ? { warning: result.warning } : {}
914
+ };
915
+ }
916
+ return {
917
+ aggregatorAction: result.aggregatorAction,
918
+ aggregatorRegion: result.aggregatorRegion,
919
+ coverage: result.coverage,
920
+ createdIndexes: String(result.createdIndexCount),
921
+ details: "Run `cloudburn discover status` for per-region details.",
922
+ indexedRegions: result.regions.length === 0 ? "none" : result.regions.join(", "),
923
+ indexedSummary: `${result.observedStatus.indexedRegionCount} of ${result.observedStatus.totalRegionCount}`,
924
+ indexType: result.indexType,
925
+ message,
926
+ reusedIndexes: String(result.reusedIndexCount),
927
+ ...restrictedRegionCount > 0 ? { restrictedRegions: String(restrictedRegionCount) } : {},
928
+ status: result.status,
929
+ taskId: result.taskId ?? "",
930
+ verificationStatus: result.verificationStatus
931
+ };
932
+ };
825
933
  var parseAwsRegion = (value) => {
826
934
  try {
827
935
  return assertValidAwsRegion(value);
@@ -860,9 +968,17 @@ var registerDiscoverCommand = (program) => {
860
968
  const discoverCommand = setCommandExamples(
861
969
  program.command("discover").description("Run a live AWS discovery").enablePositionalOptions().option(
862
970
  "--region <region>",
863
- 'Discovery region to use. Pass "all" to require an aggregator index.',
971
+ 'Discovery region to use. Defaults to the current AWS region from AWS_REGION; use this flag to override it. Pass "all" to check resources in all regions that are indexed in AWS Resource Explorer.',
864
972
  parseDiscoverRegion
865
- ).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) => {
973
+ ).option("--config <path>", "Explicit CloudBurn config file to load").option(
974
+ "--enabled-rules <ruleIds>",
975
+ "Comma-separated rule IDs to enable. When set, CloudBurn checks only these rules. By default, all rules are enabled.",
976
+ parseRuleIdList
977
+ ).option(
978
+ "--disabled-rules <ruleIds>",
979
+ "Comma-separated rule IDs to disable. By default, all rules are enabled; use this to exclude specific rules.",
980
+ parseRuleIdList
981
+ ).option("--exit-code", "Exit with code 1 when findings exist").action(async (options, command) => {
866
982
  await runCommand(async () => {
867
983
  const scanner = new CloudBurnClient();
868
984
  const loadedConfig = await scanner.loadConfig(options.config);
@@ -891,10 +1007,64 @@ var registerDiscoverCommand = (program) => {
891
1007
  "cloudburn discover",
892
1008
  "cloudburn discover --region eu-central-1",
893
1009
  "cloudburn discover --region all",
1010
+ "cloudburn discover status",
894
1011
  "cloudburn discover list-enabled-regions",
895
1012
  "cloudburn discover init"
896
1013
  ]
897
1014
  );
1015
+ discoverCommand.command("status").description("Show Resource Explorer status across all enabled AWS regions").action(async (_options, command) => {
1016
+ await runCommand(async () => {
1017
+ const scanner = new CloudBurnClient();
1018
+ const parentRegion = discoverCommand.opts().region;
1019
+ const region = parentRegion === "all" ? void 0 : parentRegion;
1020
+ const status = await scanner.getDiscoveryStatus({ region });
1021
+ const format = resolveOutputFormat(command);
1022
+ const rows = format === "json" ? status.regions.map((regionStatus) => ({
1023
+ ...regionStatus,
1024
+ notes: regionStatus.notes ?? ""
1025
+ })) : status.regions.map((regionStatus) => ({
1026
+ region: regionStatus.region,
1027
+ indexType: regionStatus.indexType === void 0 ? "" : regionStatus.isAggregator ? `${regionStatus.indexType} (active)` : regionStatus.indexType,
1028
+ notes: regionStatus.notes ?? "",
1029
+ status: regionStatus.status,
1030
+ viewStatus: regionStatus.viewStatus ?? ""
1031
+ }));
1032
+ const summary = format === "json" ? {
1033
+ accessibleRegionCount: status.accessibleRegionCount,
1034
+ coverage: status.coverage,
1035
+ indexedRegionCount: status.indexedRegionCount,
1036
+ totalRegionCount: status.totalRegionCount,
1037
+ ...status.aggregatorRegion ? { aggregatorRegion: status.aggregatorRegion } : {},
1038
+ ...status.warning ? { warning: status.warning } : {}
1039
+ } : {
1040
+ accessibleRegionCount: status.accessibleRegionCount,
1041
+ aggregatorRegion: status.aggregatorRegion ?? "",
1042
+ coverage: status.coverage,
1043
+ indexedRegionCount: status.indexedRegionCount,
1044
+ totalRegionCount: status.totalRegionCount,
1045
+ ...status.warning ? { warning: status.warning } : {}
1046
+ };
1047
+ const output = renderResponse(
1048
+ {
1049
+ kind: "discovery-status",
1050
+ columns: [
1051
+ { key: "region", header: "Region" },
1052
+ { key: "indexType", header: "IndexType" },
1053
+ { key: "status", header: "Status" },
1054
+ { key: "viewStatus", header: "ViewStatus" },
1055
+ { key: "notes", header: "Notes" }
1056
+ ],
1057
+ rows,
1058
+ summary,
1059
+ summaryText: describeDiscoverySummary(status)
1060
+ },
1061
+ format
1062
+ );
1063
+ process.stdout.write(`${output}
1064
+ `);
1065
+ return EXIT_CODE_OK;
1066
+ });
1067
+ });
898
1068
  discoverCommand.command("list-enabled-regions").description("List AWS regions with a local or aggregator Resource Explorer index").action(async (_options, command) => {
899
1069
  await runCommand(async () => {
900
1070
  const scanner = new CloudBurnClient();
@@ -917,24 +1087,22 @@ var registerDiscoverCommand = (program) => {
917
1087
  return EXIT_CODE_OK;
918
1088
  });
919
1089
  });
920
- 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) => {
1090
+ discoverCommand.command("init").description("Set up AWS Resource Explorer for CloudBurn").option(
1091
+ "--region <region>",
1092
+ "Requested aggregator region to create or reuse during setup. This is the main Resource Explorer region that aggregates indexes from other regions when cross-region setup succeeds. Defaults to the current AWS region from AWS_REGION; use this flag to override it.",
1093
+ parseAwsRegion
1094
+ ).action(async (options, command) => {
921
1095
  await runCommand(async () => {
922
1096
  const scanner = new CloudBurnClient();
923
1097
  const parentRegion = discoverCommand.opts().region;
924
1098
  const region = options.region ?? (parentRegion === "all" ? void 0 : parentRegion);
925
1099
  const result = await scanner.initializeDiscovery({ region });
926
- const message = result.status === "EXISTING" ? `Resource Explorer setup already exists in ${result.aggregatorRegion}.` : `Resource Explorer setup created in ${result.aggregatorRegion}.`;
1100
+ const message = describeInitializationMessage(result);
927
1101
  const format = resolveOutputFormat(command);
928
1102
  const output = renderResponse(
929
1103
  {
930
1104
  kind: "status",
931
- data: {
932
- aggregatorRegion: result.aggregatorRegion,
933
- message,
934
- regions: result.regions,
935
- status: result.status,
936
- taskId: result.taskId ?? ""
937
- },
1105
+ data: buildInitializationStatusData(result, message, format),
938
1106
  text: message
939
1107
  },
940
1108
  format
@@ -1145,7 +1313,15 @@ var toScanConfigOverride = (options) => {
1145
1313
  };
1146
1314
  var registerScanCommand = (program) => {
1147
1315
  setCommandExamples(
1148
- 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) => {
1316
+ 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(
1317
+ "--enabled-rules <ruleIds>",
1318
+ "Comma-separated rule IDs to enable. When set, CloudBurn checks only these rules. By default, all rules are enabled.",
1319
+ parseRuleIdList
1320
+ ).option(
1321
+ "--disabled-rules <ruleIds>",
1322
+ "Comma-separated rule IDs to disable. By default, all rules are enabled; use this to exclude specific rules.",
1323
+ parseRuleIdList
1324
+ ).option("--exit-code", "Exit with code 1 when findings exist").action(async (path, options, command) => {
1149
1325
  try {
1150
1326
  const scanner = new CloudBurnClient2();
1151
1327
  const loadedConfig = await scanner.loadConfig(options.config);
@@ -1172,9 +1348,22 @@ var registerScanCommand = (program) => {
1172
1348
  };
1173
1349
 
1174
1350
  // src/cli.ts
1351
+ var resolveEntrypointPath = (entrypointPath) => {
1352
+ try {
1353
+ return realpathSync.native(entrypointPath);
1354
+ } catch {
1355
+ return resolve2(entrypointPath);
1356
+ }
1357
+ };
1358
+ var isCliEntrypoint = (moduleUrl, argvEntry = process.argv[1]) => {
1359
+ if (argvEntry === void 0) {
1360
+ return false;
1361
+ }
1362
+ return resolveEntrypointPath(fileURLToPath(moduleUrl)) === resolveEntrypointPath(argvEntry);
1363
+ };
1175
1364
  var createProgram = () => {
1176
1365
  const program = createCliCommand();
1177
- program.name("cloudburn").usage("[command]").description("Know what you spend. Fix what you waste.").version("0.8.2").option("--format <format>", OUTPUT_FORMAT_OPTION_DESCRIPTION, parseOutputFormat);
1366
+ program.name("cloudburn").usage("[command]").description("Know what you spend. Fix what you waste.").version("0.8.4").option("--format <format>", OUTPUT_FORMAT_OPTION_DESCRIPTION, parseOutputFormat);
1178
1367
  configureCliHelp(program);
1179
1368
  registerCompletionCommand(program);
1180
1369
  registerDiscoverCommand(program);
@@ -1187,11 +1376,11 @@ var createProgram = () => {
1187
1376
  var runCli = async () => {
1188
1377
  await createProgram().parseAsync(process.argv);
1189
1378
  };
1190
- var isMain = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false;
1191
- if (isMain) {
1379
+ if (isCliEntrypoint(import.meta.url)) {
1192
1380
  await runCli();
1193
1381
  }
1194
1382
  export {
1195
1383
  createProgram,
1384
+ isCliEntrypoint,
1196
1385
  runCli
1197
1386
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cloudburn",
3
- "version": "0.8.2",
3
+ "version": "0.8.4",
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.13.1"
14
+ "@cloudburn/sdk": "0.13.2"
15
15
  },
16
16
  "devDependencies": {
17
17
  "@biomejs/biome": "^2.4.6",