@jadenrazo/cloudcost-mcp 0.2.0 → 0.4.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.
- package/README.md +129 -39
- package/data/region-price-multipliers.json +49 -0
- package/data/resource-equivalents.json +154 -14
- package/dist/{chunk-KZJSZMWM.js → chunk-TRRAOOVF.js} +14 -13
- package/dist/chunk-VP34WG7Z.js +9341 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +345 -0
- package/dist/index.js +894 -4380
- package/dist/{loader-WIX54B7L.js → loader-VXYJYDIH.js} +4 -2
- package/package.json +33 -11
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
#!/usr/bin/env node
|
|
3
|
+
import {
|
|
4
|
+
PricingCache,
|
|
5
|
+
PricingEngine,
|
|
6
|
+
analyzeTerraform,
|
|
7
|
+
compareProviders,
|
|
8
|
+
estimateCost,
|
|
9
|
+
loadConfig,
|
|
10
|
+
optimizeCost,
|
|
11
|
+
whatIf
|
|
12
|
+
} from "./chunk-VP34WG7Z.js";
|
|
13
|
+
import "./chunk-TRRAOOVF.js";
|
|
14
|
+
|
|
15
|
+
// src/cli.ts
|
|
16
|
+
import { readFileSync, existsSync, statSync } from "fs";
|
|
17
|
+
import { resolve, extname, join } from "path";
|
|
18
|
+
import { readdirSync } from "fs";
|
|
19
|
+
function parseArgs(argv) {
|
|
20
|
+
const args = argv.slice(2);
|
|
21
|
+
const parsed = {
|
|
22
|
+
command: void 0,
|
|
23
|
+
path: void 0,
|
|
24
|
+
provider: "aws",
|
|
25
|
+
region: void 0,
|
|
26
|
+
format: "markdown",
|
|
27
|
+
providers: ["aws", "azure", "gcp"],
|
|
28
|
+
changes: void 0,
|
|
29
|
+
json: false,
|
|
30
|
+
help: false
|
|
31
|
+
};
|
|
32
|
+
let i = 0;
|
|
33
|
+
while (i < args.length) {
|
|
34
|
+
const arg = args[i];
|
|
35
|
+
if (arg === "--help" || arg === "-h") {
|
|
36
|
+
parsed.help = true;
|
|
37
|
+
} else if (arg === "--json") {
|
|
38
|
+
parsed.json = true;
|
|
39
|
+
} else if (arg === "--provider" && args[i + 1]) {
|
|
40
|
+
parsed.provider = args[++i];
|
|
41
|
+
} else if (arg === "--region" && args[i + 1]) {
|
|
42
|
+
parsed.region = args[++i];
|
|
43
|
+
} else if (arg === "--format" && args[i + 1]) {
|
|
44
|
+
parsed.format = args[++i];
|
|
45
|
+
} else if (arg === "--providers" && args[i + 1]) {
|
|
46
|
+
parsed.providers = args[++i].split(",").map((p) => p.trim());
|
|
47
|
+
} else if (arg === "--changes" && args[i + 1]) {
|
|
48
|
+
parsed.changes = args[++i];
|
|
49
|
+
} else if (!arg.startsWith("--")) {
|
|
50
|
+
if (!parsed.command) {
|
|
51
|
+
parsed.command = arg;
|
|
52
|
+
} else if (!parsed.path) {
|
|
53
|
+
parsed.path = arg;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
return parsed;
|
|
59
|
+
}
|
|
60
|
+
function loadTerraformFiles(inputPath) {
|
|
61
|
+
const resolved = resolve(inputPath);
|
|
62
|
+
if (!existsSync(resolved)) {
|
|
63
|
+
throw new Error(`Path not found: ${resolved}`);
|
|
64
|
+
}
|
|
65
|
+
const stat = statSync(resolved);
|
|
66
|
+
if (stat.isFile()) {
|
|
67
|
+
return [
|
|
68
|
+
{
|
|
69
|
+
path: resolved,
|
|
70
|
+
content: readFileSync(resolved, "utf-8")
|
|
71
|
+
}
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
if (stat.isDirectory()) {
|
|
75
|
+
const entries = readdirSync(resolved);
|
|
76
|
+
const tfFiles = entries.filter((e) => extname(e) === ".tf" || extname(e) === ".tofu").map((e) => join(resolved, e));
|
|
77
|
+
if (tfFiles.length === 0) {
|
|
78
|
+
throw new Error(`No .tf or .tofu files found in directory: ${resolved}`);
|
|
79
|
+
}
|
|
80
|
+
return tfFiles.map((f) => ({
|
|
81
|
+
path: f,
|
|
82
|
+
content: readFileSync(f, "utf-8")
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
throw new Error(`Path is neither a file nor directory: ${resolved}`);
|
|
86
|
+
}
|
|
87
|
+
function loadTfvars(inputPath) {
|
|
88
|
+
const dir = statSync(resolve(inputPath)).isDirectory() ? resolve(inputPath) : resolve(inputPath, "..");
|
|
89
|
+
const tfvarsPath = join(dir, "terraform.tfvars");
|
|
90
|
+
if (existsSync(tfvarsPath)) {
|
|
91
|
+
return readFileSync(tfvarsPath, "utf-8");
|
|
92
|
+
}
|
|
93
|
+
return void 0;
|
|
94
|
+
}
|
|
95
|
+
function printJson(data) {
|
|
96
|
+
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
97
|
+
}
|
|
98
|
+
function printText(data) {
|
|
99
|
+
if (typeof data === "string") {
|
|
100
|
+
process.stdout.write(data + "\n");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
process.stdout.write(JSON.stringify(data, null, 2) + "\n");
|
|
104
|
+
}
|
|
105
|
+
function printUsage() {
|
|
106
|
+
process.stdout.write(
|
|
107
|
+
`
|
|
108
|
+
CloudCost \u2014 multi-cloud Terraform cost estimator
|
|
109
|
+
|
|
110
|
+
Usage:
|
|
111
|
+
cloudcost analyze <path> Parse Terraform and show resource inventory
|
|
112
|
+
cloudcost estimate <path> [options] Estimate costs on a specific provider
|
|
113
|
+
cloudcost compare <path> [options] Compare costs across all providers
|
|
114
|
+
cloudcost optimize <path> [options] Show cost optimization recommendations
|
|
115
|
+
cloudcost what-if <path> --changes <file> Simulate attribute changes and show cost impact
|
|
116
|
+
|
|
117
|
+
Options:
|
|
118
|
+
--provider <aws|azure|gcp> Target provider (default: aws)
|
|
119
|
+
--region <region> Target region (default: auto-detect)
|
|
120
|
+
--format <markdown|json|csv> Report format for compare (default: markdown)
|
|
121
|
+
--providers <aws,azure,gcp> Comma-separated providers for compare/optimize
|
|
122
|
+
--changes <path> JSON file with changes array for what-if
|
|
123
|
+
--json Output raw JSON
|
|
124
|
+
--help, -h Show this help
|
|
125
|
+
|
|
126
|
+
Examples:
|
|
127
|
+
cloudcost analyze ./terraform
|
|
128
|
+
cloudcost estimate ./terraform --provider aws --region us-east-1
|
|
129
|
+
cloudcost compare ./terraform --format markdown
|
|
130
|
+
cloudcost optimize ./terraform --providers aws,gcp
|
|
131
|
+
cloudcost estimate main.tf --provider gcp --json
|
|
132
|
+
cloudcost what-if ./terraform --changes changes.json --provider aws
|
|
133
|
+
`.trimStart()
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
async function runAnalyze(args, files, tfvars) {
|
|
137
|
+
const result = await analyzeTerraform({ files, tfvars, include_dependencies: false });
|
|
138
|
+
if (args.json) {
|
|
139
|
+
printJson(result);
|
|
140
|
+
} else {
|
|
141
|
+
printText(result);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async function runEstimate(args, files, tfvars, pricingEngine, config) {
|
|
145
|
+
const validProviders = ["aws", "azure", "gcp"];
|
|
146
|
+
if (!validProviders.includes(args.provider)) {
|
|
147
|
+
throw new Error(`Invalid provider "${args.provider}". Must be one of: aws, azure, gcp`);
|
|
148
|
+
}
|
|
149
|
+
const result = await estimateCost(
|
|
150
|
+
{
|
|
151
|
+
files,
|
|
152
|
+
tfvars,
|
|
153
|
+
provider: args.provider,
|
|
154
|
+
region: args.region,
|
|
155
|
+
currency: "USD"
|
|
156
|
+
},
|
|
157
|
+
pricingEngine,
|
|
158
|
+
config
|
|
159
|
+
);
|
|
160
|
+
if (args.json) {
|
|
161
|
+
printJson(result);
|
|
162
|
+
} else {
|
|
163
|
+
printText(result);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function runCompare(args, files, tfvars, pricingEngine, config) {
|
|
167
|
+
const validFormats = ["markdown", "json", "csv"];
|
|
168
|
+
if (!validFormats.includes(args.format)) {
|
|
169
|
+
throw new Error(`Invalid format "${args.format}". Must be one of: markdown, json, csv`);
|
|
170
|
+
}
|
|
171
|
+
const validatedProviders = args.providers.filter(
|
|
172
|
+
(p) => p === "aws" || p === "azure" || p === "gcp"
|
|
173
|
+
);
|
|
174
|
+
const result = await compareProviders(
|
|
175
|
+
{
|
|
176
|
+
files,
|
|
177
|
+
tfvars,
|
|
178
|
+
format: args.format,
|
|
179
|
+
currency: "USD",
|
|
180
|
+
providers: validatedProviders.length > 0 ? validatedProviders : ["aws", "azure", "gcp"]
|
|
181
|
+
},
|
|
182
|
+
pricingEngine,
|
|
183
|
+
config
|
|
184
|
+
);
|
|
185
|
+
if (args.json) {
|
|
186
|
+
printJson(result);
|
|
187
|
+
} else {
|
|
188
|
+
const obj = result;
|
|
189
|
+
if (typeof obj["report"] === "string") {
|
|
190
|
+
printText(obj["report"]);
|
|
191
|
+
} else {
|
|
192
|
+
printText(result);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async function runOptimize(args, files, tfvars, pricingEngine, config) {
|
|
197
|
+
const validatedProviders = args.providers.filter(
|
|
198
|
+
(p) => p === "aws" || p === "azure" || p === "gcp"
|
|
199
|
+
);
|
|
200
|
+
const result = await optimizeCost(
|
|
201
|
+
{
|
|
202
|
+
files,
|
|
203
|
+
tfvars,
|
|
204
|
+
providers: validatedProviders.length > 0 ? validatedProviders : ["aws", "azure", "gcp"]
|
|
205
|
+
},
|
|
206
|
+
pricingEngine,
|
|
207
|
+
config
|
|
208
|
+
);
|
|
209
|
+
if (args.json) {
|
|
210
|
+
printJson(result);
|
|
211
|
+
} else {
|
|
212
|
+
printText(result);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async function runWhatIf(args, files, tfvars, pricingEngine, config) {
|
|
216
|
+
if (!args.changes) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
'The what-if command requires --changes <path> pointing to a JSON file with a "changes" array'
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
const changesPath = resolve(args.changes);
|
|
222
|
+
if (!existsSync(changesPath)) {
|
|
223
|
+
throw new Error(`Changes file not found: ${changesPath}`);
|
|
224
|
+
}
|
|
225
|
+
let parsedChanges;
|
|
226
|
+
try {
|
|
227
|
+
parsedChanges = JSON.parse(readFileSync(changesPath, "utf-8"));
|
|
228
|
+
} catch (err) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`Failed to parse changes file "${changesPath}": ${err instanceof Error ? err.message : String(err)}`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
let changesArray;
|
|
234
|
+
if (Array.isArray(parsedChanges)) {
|
|
235
|
+
changesArray = parsedChanges;
|
|
236
|
+
} else if (parsedChanges !== null && typeof parsedChanges === "object" && "changes" in parsedChanges && Array.isArray(parsedChanges["changes"])) {
|
|
237
|
+
changesArray = parsedChanges["changes"];
|
|
238
|
+
} else {
|
|
239
|
+
throw new Error(`Changes file must contain a JSON array or an object with a "changes" array`);
|
|
240
|
+
}
|
|
241
|
+
const changes = changesArray.map((item, index) => {
|
|
242
|
+
if (item === null || typeof item !== "object") {
|
|
243
|
+
throw new Error(`Change at index ${index} must be an object`);
|
|
244
|
+
}
|
|
245
|
+
const c = item;
|
|
246
|
+
if (typeof c["resource_id"] !== "string" || !c["resource_id"]) {
|
|
247
|
+
throw new Error(`Change at index ${index} is missing a valid "resource_id"`);
|
|
248
|
+
}
|
|
249
|
+
if (typeof c["attribute"] !== "string" || !c["attribute"]) {
|
|
250
|
+
throw new Error(`Change at index ${index} is missing a valid "attribute"`);
|
|
251
|
+
}
|
|
252
|
+
if (typeof c["new_value"] !== "string" && typeof c["new_value"] !== "number") {
|
|
253
|
+
throw new Error(`Change at index ${index} "new_value" must be a string or number`);
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
resource_id: c["resource_id"],
|
|
257
|
+
attribute: c["attribute"],
|
|
258
|
+
new_value: c["new_value"]
|
|
259
|
+
};
|
|
260
|
+
});
|
|
261
|
+
const validProviders = ["aws", "azure", "gcp"];
|
|
262
|
+
const provider = validProviders.includes(args.provider) ? args.provider : void 0;
|
|
263
|
+
const result = await whatIf(
|
|
264
|
+
{
|
|
265
|
+
files,
|
|
266
|
+
tfvars,
|
|
267
|
+
changes,
|
|
268
|
+
provider,
|
|
269
|
+
region: args.region
|
|
270
|
+
},
|
|
271
|
+
pricingEngine,
|
|
272
|
+
config
|
|
273
|
+
);
|
|
274
|
+
if (args.json) {
|
|
275
|
+
printJson(result);
|
|
276
|
+
} else {
|
|
277
|
+
printText(result);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
async function main() {
|
|
281
|
+
const args = parseArgs(process.argv);
|
|
282
|
+
if (args.help || !args.command) {
|
|
283
|
+
printUsage();
|
|
284
|
+
process.exit(args.help ? 0 : 1);
|
|
285
|
+
}
|
|
286
|
+
const validCommands = ["analyze", "estimate", "compare", "optimize", "what-if"];
|
|
287
|
+
if (!validCommands.includes(args.command)) {
|
|
288
|
+
process.stderr.write(`Unknown command: ${args.command}
|
|
289
|
+
`);
|
|
290
|
+
printUsage();
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
if (!args.path) {
|
|
294
|
+
process.stderr.write(`Error: path argument is required
|
|
295
|
+
`);
|
|
296
|
+
printUsage();
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
let files;
|
|
300
|
+
let tfvars;
|
|
301
|
+
try {
|
|
302
|
+
files = loadTerraformFiles(args.path);
|
|
303
|
+
tfvars = loadTfvars(args.path);
|
|
304
|
+
} catch (err) {
|
|
305
|
+
process.stderr.write(
|
|
306
|
+
`Error loading Terraform files: ${err instanceof Error ? err.message : String(err)}
|
|
307
|
+
`
|
|
308
|
+
);
|
|
309
|
+
process.exit(1);
|
|
310
|
+
}
|
|
311
|
+
const config = loadConfig();
|
|
312
|
+
const cache = new PricingCache(config.cache.db_path);
|
|
313
|
+
const pricingEngine = new PricingEngine(cache, config);
|
|
314
|
+
try {
|
|
315
|
+
switch (args.command) {
|
|
316
|
+
case "analyze":
|
|
317
|
+
await runAnalyze(args, files, tfvars);
|
|
318
|
+
break;
|
|
319
|
+
case "estimate":
|
|
320
|
+
await runEstimate(args, files, tfvars, pricingEngine, config);
|
|
321
|
+
break;
|
|
322
|
+
case "compare":
|
|
323
|
+
await runCompare(args, files, tfvars, pricingEngine, config);
|
|
324
|
+
break;
|
|
325
|
+
case "optimize":
|
|
326
|
+
await runOptimize(args, files, tfvars, pricingEngine, config);
|
|
327
|
+
break;
|
|
328
|
+
case "what-if":
|
|
329
|
+
await runWhatIf(args, files, tfvars, pricingEngine, config);
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
} catch (err) {
|
|
333
|
+
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}
|
|
334
|
+
`);
|
|
335
|
+
process.exit(1);
|
|
336
|
+
} finally {
|
|
337
|
+
cache.close();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
main().catch((err) => {
|
|
341
|
+
process.stderr.write(`Fatal error: ${err instanceof Error ? err.message : String(err)}
|
|
342
|
+
`);
|
|
343
|
+
process.exit(1);
|
|
344
|
+
});
|
|
345
|
+
//# sourceMappingURL=cli.js.map
|