burnwatch 0.3.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/CHANGELOG.md +24 -0
- package/dist/cli.js +237 -31
- package/dist/cli.js.map +1 -1
- package/dist/cost-impact.d.ts +23 -0
- package/dist/cost-impact.js +281 -0
- package/dist/cost-impact.js.map +1 -0
- package/dist/detector-C4LnLT-O.d.ts +28 -0
- package/dist/hooks/on-file-change.js +324 -6
- package/dist/hooks/on-file-change.js.map +1 -1
- package/dist/hooks/on-prompt.js +2 -1
- package/dist/hooks/on-prompt.js.map +1 -1
- package/dist/hooks/on-session-start.js +10 -1
- package/dist/hooks/on-session-start.js.map +1 -1
- package/dist/hooks/on-stop.js +47 -3
- package/dist/hooks/on-stop.js.map +1 -1
- package/dist/index.d.ts +5 -159
- package/dist/index.js +248 -1
- package/dist/index.js.map +1 -1
- package/dist/interactive-init.d.ts +20 -0
- package/dist/interactive-init.js +239 -0
- package/dist/interactive-init.js.map +1 -0
- package/dist/mcp-server.js +2 -1
- package/dist/mcp-server.js.map +1 -1
- package/dist/types-fDMu4rOd.d.ts +178 -0
- package/package.json +1 -1
- package/registry.json +89 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// src/interactive-init.ts
|
|
2
|
+
import * as readline from "readline";
|
|
3
|
+
|
|
4
|
+
// src/core/config.ts
|
|
5
|
+
import * as fs from "fs";
|
|
6
|
+
import * as path from "path";
|
|
7
|
+
import * as os from "os";
|
|
8
|
+
function globalConfigDir() {
|
|
9
|
+
const xdgConfig = process.env["XDG_CONFIG_HOME"];
|
|
10
|
+
if (xdgConfig) return path.join(xdgConfig, "burnwatch");
|
|
11
|
+
return path.join(os.homedir(), ".config", "burnwatch");
|
|
12
|
+
}
|
|
13
|
+
function readGlobalConfig() {
|
|
14
|
+
const configPath = path.join(globalConfigDir(), "config.json");
|
|
15
|
+
try {
|
|
16
|
+
const raw = fs.readFileSync(configPath, "utf-8");
|
|
17
|
+
return JSON.parse(raw);
|
|
18
|
+
} catch {
|
|
19
|
+
return { services: {} };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function writeGlobalConfig(config) {
|
|
23
|
+
const dir = globalConfigDir();
|
|
24
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
25
|
+
const configPath = path.join(dir, "config.json");
|
|
26
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
27
|
+
fs.chmodSync(configPath, 384);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// src/services/base.ts
|
|
31
|
+
async function fetchJson(url, options = {}) {
|
|
32
|
+
try {
|
|
33
|
+
const controller = new AbortController();
|
|
34
|
+
const timeoutId = setTimeout(
|
|
35
|
+
() => controller.abort(),
|
|
36
|
+
options.timeout ?? 1e4
|
|
37
|
+
);
|
|
38
|
+
const response = await fetch(url, {
|
|
39
|
+
method: options.method ?? "GET",
|
|
40
|
+
headers: options.headers,
|
|
41
|
+
body: options.body,
|
|
42
|
+
signal: controller.signal
|
|
43
|
+
});
|
|
44
|
+
clearTimeout(timeoutId);
|
|
45
|
+
if (!response.ok) {
|
|
46
|
+
return {
|
|
47
|
+
ok: false,
|
|
48
|
+
status: response.status,
|
|
49
|
+
error: `HTTP ${response.status}: ${response.statusText}`
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const data = await response.json();
|
|
53
|
+
return { ok: true, status: response.status, data };
|
|
54
|
+
} catch (err) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
status: 0,
|
|
58
|
+
error: err instanceof Error ? err.message : "Unknown error"
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// src/interactive-init.ts
|
|
64
|
+
var RISK_ORDER = ["llm", "usage", "infra", "flat"];
|
|
65
|
+
var RISK_LABELS = {
|
|
66
|
+
llm: "\u{1F916} LLM / AI Services (highest variable cost)",
|
|
67
|
+
usage: "\u{1F4CA} Usage-Based Services",
|
|
68
|
+
infra: "\u{1F3D7}\uFE0F Infrastructure & Compute",
|
|
69
|
+
flat: "\u{1F4E6} Flat-Rate / Free Tier Services"
|
|
70
|
+
};
|
|
71
|
+
function classifyRisk(service) {
|
|
72
|
+
if (service.billingModel === "token_usage") return "llm";
|
|
73
|
+
if (service.billingModel === "credit_pool" || service.billingModel === "percentage" || service.billingModel === "per_unit")
|
|
74
|
+
return "usage";
|
|
75
|
+
if (service.billingModel === "compute") return "infra";
|
|
76
|
+
return "flat";
|
|
77
|
+
}
|
|
78
|
+
function groupByRisk(detected) {
|
|
79
|
+
const groups = /* @__PURE__ */ new Map();
|
|
80
|
+
for (const cat of RISK_ORDER) {
|
|
81
|
+
groups.set(cat, []);
|
|
82
|
+
}
|
|
83
|
+
for (const det of detected) {
|
|
84
|
+
const cat = classifyRisk(det.service);
|
|
85
|
+
groups.get(cat).push(det);
|
|
86
|
+
}
|
|
87
|
+
return groups;
|
|
88
|
+
}
|
|
89
|
+
function ask(rl, question) {
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
rl.question(question, (answer) => {
|
|
92
|
+
resolve(answer.trim());
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
async function autoDetectScrapflyPlan(apiKey) {
|
|
97
|
+
try {
|
|
98
|
+
const result = await fetchJson(`https://api.scrapfly.io/account?key=${apiKey}`);
|
|
99
|
+
if (result.ok && result.data?.subscription?.plan?.name) {
|
|
100
|
+
return result.data.subscription.plan.name;
|
|
101
|
+
}
|
|
102
|
+
} catch {
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
async function runInteractiveInit(detected) {
|
|
107
|
+
const rl = readline.createInterface({
|
|
108
|
+
input: process.stdin,
|
|
109
|
+
output: process.stdout
|
|
110
|
+
});
|
|
111
|
+
const services = {};
|
|
112
|
+
const groups = groupByRisk(detected);
|
|
113
|
+
const globalConfig = readGlobalConfig();
|
|
114
|
+
console.log(
|
|
115
|
+
"\n\u{1F4CB} Let's configure each detected service. Services are grouped by cost risk.\n"
|
|
116
|
+
);
|
|
117
|
+
for (const category of RISK_ORDER) {
|
|
118
|
+
const group = groups.get(category);
|
|
119
|
+
if (group.length === 0) continue;
|
|
120
|
+
console.log(`
|
|
121
|
+
${RISK_LABELS[category]}`);
|
|
122
|
+
console.log("\u2500".repeat(50));
|
|
123
|
+
for (const det of group) {
|
|
124
|
+
const service = det.service;
|
|
125
|
+
const plans = service.plans;
|
|
126
|
+
console.log(`
|
|
127
|
+
${service.name}`);
|
|
128
|
+
console.log(` Detected via: ${det.details.join(", ")}`);
|
|
129
|
+
if (!plans || plans.length === 0) {
|
|
130
|
+
services[service.id] = {
|
|
131
|
+
serviceId: service.id,
|
|
132
|
+
detectedVia: det.sources,
|
|
133
|
+
hasApiKey: false,
|
|
134
|
+
firstDetected: (/* @__PURE__ */ new Date()).toISOString()
|
|
135
|
+
};
|
|
136
|
+
console.log(" \u2192 Auto-configured (no plan tiers available)");
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
const defaultIndex = plans.findIndex((p) => p.default);
|
|
140
|
+
console.log("");
|
|
141
|
+
for (let i = 0; i < plans.length; i++) {
|
|
142
|
+
const plan = plans[i];
|
|
143
|
+
const marker = i === defaultIndex ? " (recommended)" : "";
|
|
144
|
+
const costStr = plan.type === "exclude" ? "" : plan.monthlyBase !== void 0 ? ` \u2014 $${plan.monthlyBase}/mo` : " \u2014 variable";
|
|
145
|
+
console.log(` ${i + 1}) ${plan.name}${costStr}${marker}`);
|
|
146
|
+
}
|
|
147
|
+
const defaultChoice = defaultIndex >= 0 ? String(defaultIndex + 1) : "1";
|
|
148
|
+
const answer = await ask(
|
|
149
|
+
rl,
|
|
150
|
+
` Choose [${defaultChoice}]: `
|
|
151
|
+
);
|
|
152
|
+
const choiceIndex = (answer === "" ? parseInt(defaultChoice) : parseInt(answer)) - 1;
|
|
153
|
+
const chosen = plans[choiceIndex] ?? plans[defaultIndex >= 0 ? defaultIndex : 0];
|
|
154
|
+
if (chosen.type === "exclude") {
|
|
155
|
+
services[service.id] = {
|
|
156
|
+
serviceId: service.id,
|
|
157
|
+
detectedVia: det.sources,
|
|
158
|
+
hasApiKey: false,
|
|
159
|
+
firstDetected: (/* @__PURE__ */ new Date()).toISOString(),
|
|
160
|
+
excluded: true,
|
|
161
|
+
planName: chosen.name
|
|
162
|
+
};
|
|
163
|
+
console.log(` \u2192 ${service.name}: excluded from tracking`);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const tracked = {
|
|
167
|
+
serviceId: service.id,
|
|
168
|
+
detectedVia: det.sources,
|
|
169
|
+
hasApiKey: false,
|
|
170
|
+
firstDetected: (/* @__PURE__ */ new Date()).toISOString(),
|
|
171
|
+
planName: chosen.name
|
|
172
|
+
};
|
|
173
|
+
if (chosen.type === "flat" && chosen.monthlyBase !== void 0) {
|
|
174
|
+
tracked.budget = chosen.monthlyBase;
|
|
175
|
+
tracked.planCost = chosen.monthlyBase;
|
|
176
|
+
}
|
|
177
|
+
if (chosen.requiresKey) {
|
|
178
|
+
const existingKey = globalConfig.services[service.id]?.apiKey;
|
|
179
|
+
if (existingKey) {
|
|
180
|
+
console.log(` \u{1F510} Using existing API key from global config`);
|
|
181
|
+
tracked.hasApiKey = true;
|
|
182
|
+
if (service.autoDetectPlan && service.id === "scrapfly") {
|
|
183
|
+
console.log(" \u{1F50D} Auto-detecting plan from API...");
|
|
184
|
+
const planName = await autoDetectScrapflyPlan(existingKey);
|
|
185
|
+
if (planName) {
|
|
186
|
+
console.log(` \u2192 Detected plan: ${planName}`);
|
|
187
|
+
tracked.planName = planName;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
const keyAnswer = await ask(
|
|
192
|
+
rl,
|
|
193
|
+
` Enter API key (or press Enter to skip): `
|
|
194
|
+
);
|
|
195
|
+
if (keyAnswer) {
|
|
196
|
+
tracked.hasApiKey = true;
|
|
197
|
+
if (!globalConfig.services[service.id]) {
|
|
198
|
+
globalConfig.services[service.id] = {};
|
|
199
|
+
}
|
|
200
|
+
globalConfig.services[service.id].apiKey = keyAnswer;
|
|
201
|
+
if (service.autoDetectPlan && service.id === "scrapfly") {
|
|
202
|
+
console.log(" \u{1F50D} Auto-detecting plan from API...");
|
|
203
|
+
const planName = await autoDetectScrapflyPlan(keyAnswer);
|
|
204
|
+
if (planName) {
|
|
205
|
+
console.log(` \u2192 Detected plan: ${planName}`);
|
|
206
|
+
tracked.planName = planName;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
if (tracked.budget === void 0) {
|
|
212
|
+
const budgetAnswer = await ask(
|
|
213
|
+
rl,
|
|
214
|
+
` Monthly budget in USD (or press Enter to skip): $`
|
|
215
|
+
);
|
|
216
|
+
if (budgetAnswer) {
|
|
217
|
+
const budget = parseFloat(budgetAnswer);
|
|
218
|
+
if (!isNaN(budget)) {
|
|
219
|
+
tracked.budget = budget;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
services[service.id] = tracked;
|
|
225
|
+
const tierLabel = tracked.hasApiKey ? "\u2705 LIVE" : tracked.planCost !== void 0 ? "\u{1F7E1} CALC" : "\u{1F534} BLIND";
|
|
226
|
+
const budgetStr = tracked.budget !== void 0 ? ` | Budget: $${tracked.budget}/mo` : "";
|
|
227
|
+
console.log(
|
|
228
|
+
` \u2192 ${service.name}: ${chosen.name} (${tierLabel}${budgetStr})`
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
writeGlobalConfig(globalConfig);
|
|
233
|
+
rl.close();
|
|
234
|
+
return { services };
|
|
235
|
+
}
|
|
236
|
+
export {
|
|
237
|
+
runInteractiveInit
|
|
238
|
+
};
|
|
239
|
+
//# sourceMappingURL=interactive-init.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/interactive-init.ts","../src/core/config.ts","../src/services/base.ts"],"sourcesContent":["/**\n * Interactive init flow for burnwatch.\n *\n * Groups detected services by risk category, presents plan tiers,\n * and collects user choices via Node readline.\n */\n\nimport * as readline from \"node:readline\";\nimport type {\n ServiceDefinition,\n PlanTier,\n TrackedService,\n ServiceRiskCategory,\n} from \"./core/types.js\";\nimport type { DetectionResult } from \"./detection/detector.js\";\nimport { readGlobalConfig, writeGlobalConfig } from \"./core/config.js\";\nimport { fetchJson } from \"./services/base.js\";\n\n/** Risk categories in display order: LLMs first, then usage-based, infra, flat-rate */\nconst RISK_ORDER: ServiceRiskCategory[] = [\"llm\", \"usage\", \"infra\", \"flat\"];\n\nconst RISK_LABELS: Record<ServiceRiskCategory, string> = {\n llm: \"š¤ LLM / AI Services (highest variable cost)\",\n usage: \"š Usage-Based Services\",\n infra: \"šļø Infrastructure & Compute\",\n flat: \"š¦ Flat-Rate / Free Tier Services\",\n};\n\n/** Map service IDs to risk categories */\nfunction classifyRisk(service: ServiceDefinition): ServiceRiskCategory {\n if (service.billingModel === \"token_usage\") return \"llm\";\n if (\n service.billingModel === \"credit_pool\" ||\n service.billingModel === \"percentage\" ||\n service.billingModel === \"per_unit\"\n )\n return \"usage\";\n if (service.billingModel === \"compute\") return \"infra\";\n return \"flat\";\n}\n\n/** Group detection results by risk category */\nfunction groupByRisk(\n detected: DetectionResult[],\n): Map<ServiceRiskCategory, DetectionResult[]> {\n const groups = new Map<ServiceRiskCategory, DetectionResult[]>();\n for (const cat of RISK_ORDER) {\n groups.set(cat, []);\n }\n\n for (const det of detected) {\n const cat = classifyRisk(det.service);\n groups.get(cat)!.push(det);\n }\n\n return groups;\n}\n\n/** Prompt the user with a question and return their answer */\nfunction ask(rl: readline.Interface, question: string): Promise<string> {\n return new Promise((resolve) => {\n rl.question(question, (answer) => {\n resolve(answer.trim());\n });\n });\n}\n\n/** Try to auto-detect plan from Scrapfly API */\nasync function autoDetectScrapflyPlan(\n apiKey: string,\n): Promise<string | null> {\n try {\n const result = await fetchJson<{\n subscription?: { plan?: { name?: string } };\n }>(`https://api.scrapfly.io/account?key=${apiKey}`);\n\n if (result.ok && result.data?.subscription?.plan?.name) {\n return result.data.subscription.plan.name;\n }\n } catch {\n // Ignore errors\n }\n return null;\n}\n\nexport interface InteractiveInitResult {\n services: Record<string, TrackedService>;\n}\n\n/**\n * Run the interactive init flow.\n * Shows detected services grouped by risk, lets user pick plans.\n */\nexport async function runInteractiveInit(\n detected: DetectionResult[],\n): Promise<InteractiveInitResult> {\n const rl = readline.createInterface({\n input: process.stdin,\n output: process.stdout,\n });\n\n const services: Record<string, TrackedService> = {};\n const groups = groupByRisk(detected);\n const globalConfig = readGlobalConfig();\n\n console.log(\n \"\\nš Let's configure each detected service. Services are grouped by cost risk.\\n\",\n );\n\n for (const category of RISK_ORDER) {\n const group = groups.get(category)!;\n if (group.length === 0) continue;\n\n console.log(`\\n${RISK_LABELS[category]}`);\n console.log(\"ā\".repeat(50));\n\n for (const det of group) {\n const service = det.service;\n const plans = service.plans;\n\n console.log(`\\n ${service.name}`);\n console.log(` Detected via: ${det.details.join(\", \")}`);\n\n if (!plans || plans.length === 0) {\n // No plans defined ā fall back to basic tracking\n services[service.id] = {\n serviceId: service.id,\n detectedVia: det.sources,\n hasApiKey: false,\n firstDetected: new Date().toISOString(),\n };\n console.log(\" ā Auto-configured (no plan tiers available)\");\n continue;\n }\n\n // Show plan options\n const defaultIndex = plans.findIndex((p) => p.default);\n console.log(\"\");\n for (let i = 0; i < plans.length; i++) {\n const plan = plans[i]!;\n const marker = i === defaultIndex ? \" (recommended)\" : \"\";\n const costStr =\n plan.type === \"exclude\"\n ? \"\"\n : plan.monthlyBase !== undefined\n ? ` ā $${plan.monthlyBase}/mo`\n : \" ā variable\";\n console.log(` ${i + 1}) ${plan.name}${costStr}${marker}`);\n }\n\n const defaultChoice =\n defaultIndex >= 0 ? String(defaultIndex + 1) : \"1\";\n const answer = await ask(\n rl,\n ` Choose [${defaultChoice}]: `,\n );\n\n const choiceIndex = (answer === \"\" ? parseInt(defaultChoice) : parseInt(answer)) - 1;\n const chosen =\n plans[choiceIndex] ?? plans[defaultIndex >= 0 ? defaultIndex : 0]!;\n\n if (chosen.type === \"exclude\") {\n // Explicitly excluded\n services[service.id] = {\n serviceId: service.id,\n detectedVia: det.sources,\n hasApiKey: false,\n firstDetected: new Date().toISOString(),\n excluded: true,\n planName: chosen.name,\n };\n console.log(` ā ${service.name}: excluded from tracking`);\n continue;\n }\n\n const tracked: TrackedService = {\n serviceId: service.id,\n detectedVia: det.sources,\n hasApiKey: false,\n firstDetected: new Date().toISOString(),\n planName: chosen.name,\n };\n\n if (chosen.type === \"flat\" && chosen.monthlyBase !== undefined) {\n // Auto-set budget to plan cost\n tracked.budget = chosen.monthlyBase;\n tracked.planCost = chosen.monthlyBase;\n }\n\n // If requires API key, ask for it\n if (chosen.requiresKey) {\n // Check if we already have a key in global config\n const existingKey = globalConfig.services[service.id]?.apiKey;\n if (existingKey) {\n console.log(` š Using existing API key from global config`);\n tracked.hasApiKey = true;\n\n // Auto-detect plan for Scrapfly\n if (service.autoDetectPlan && service.id === \"scrapfly\") {\n console.log(\" š Auto-detecting plan from API...\");\n const planName = await autoDetectScrapflyPlan(existingKey);\n if (planName) {\n console.log(` ā Detected plan: ${planName}`);\n tracked.planName = planName;\n }\n }\n } else {\n const keyAnswer = await ask(\n rl,\n ` Enter API key (or press Enter to skip): `,\n );\n if (keyAnswer) {\n tracked.hasApiKey = true;\n // Save to global config\n if (!globalConfig.services[service.id]) {\n globalConfig.services[service.id] = {};\n }\n globalConfig.services[service.id]!.apiKey = keyAnswer;\n\n // Auto-detect plan for Scrapfly\n if (service.autoDetectPlan && service.id === \"scrapfly\") {\n console.log(\" š Auto-detecting plan from API...\");\n const planName = await autoDetectScrapflyPlan(keyAnswer);\n if (planName) {\n console.log(` ā Detected plan: ${planName}`);\n tracked.planName = planName;\n }\n }\n }\n }\n\n // Ask for budget if not already set from plan\n if (tracked.budget === undefined) {\n const budgetAnswer = await ask(\n rl,\n ` Monthly budget in USD (or press Enter to skip): $`,\n );\n if (budgetAnswer) {\n const budget = parseFloat(budgetAnswer);\n if (!isNaN(budget)) {\n tracked.budget = budget;\n }\n }\n }\n }\n\n services[service.id] = tracked;\n\n const tierLabel = tracked.hasApiKey\n ? \"ā
LIVE\"\n : tracked.planCost !== undefined\n ? \"š” CALC\"\n : \"š“ BLIND\";\n const budgetStr = tracked.budget !== undefined ? ` | Budget: $${tracked.budget}/mo` : \"\";\n console.log(\n ` ā ${service.name}: ${chosen.name} (${tierLabel}${budgetStr})`,\n );\n }\n }\n\n // Save any collected API keys\n writeGlobalConfig(globalConfig);\n\n rl.close();\n\n return { services };\n}\n","import * as fs from \"node:fs\";\nimport * as path from \"node:path\";\nimport * as os from \"node:os\";\nimport type { TrackedService } from \"./types.js\";\n\n/**\n * Paths for burnwatch configuration and data.\n *\n * Hybrid model:\n * - Global config (API keys, service credentials): ~/.config/burnwatch/\n * - Project config (budgets, tracked services): .burnwatch/\n * - Project data (ledger, events, cache): .burnwatch/data/\n */\n\n/** Global config directory ā stores API keys, never in project dirs. */\nexport function globalConfigDir(): string {\n const xdgConfig = process.env[\"XDG_CONFIG_HOME\"];\n if (xdgConfig) return path.join(xdgConfig, \"burnwatch\");\n return path.join(os.homedir(), \".config\", \"burnwatch\");\n}\n\n/** Project config directory ā stores budgets, tracked services. */\nexport function projectConfigDir(projectRoot?: string): string {\n const root = projectRoot ?? process.cwd();\n return path.join(root, \".burnwatch\");\n}\n\n/** Project data directory ā stores ledger, events, cache. */\nexport function projectDataDir(projectRoot?: string): string {\n return path.join(projectConfigDir(projectRoot), \"data\");\n}\n\n// --- Global config (API keys) ---\n\nexport interface GlobalConfig {\n services: Record<\n string,\n {\n apiKey?: string;\n token?: string;\n orgId?: string;\n }\n >;\n}\n\nexport function readGlobalConfig(): GlobalConfig {\n const configPath = path.join(globalConfigDir(), \"config.json\");\n try {\n const raw = fs.readFileSync(configPath, \"utf-8\");\n return JSON.parse(raw) as GlobalConfig;\n } catch {\n return { services: {} };\n }\n}\n\nexport function writeGlobalConfig(config: GlobalConfig): void {\n const dir = globalConfigDir();\n fs.mkdirSync(dir, { recursive: true });\n const configPath = path.join(dir, \"config.json\");\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + \"\\n\", \"utf-8\");\n // Restrict permissions ā this file contains API keys\n fs.chmodSync(configPath, 0o600);\n}\n\n// --- Project config (budgets, tracked services) ---\n\nexport interface ProjectConfig {\n projectName: string;\n services: Record<string, TrackedService>;\n createdAt: string;\n updatedAt: string;\n}\n\nexport function readProjectConfig(projectRoot?: string): ProjectConfig | null {\n const configPath = path.join(projectConfigDir(projectRoot), \"config.json\");\n try {\n const raw = fs.readFileSync(configPath, \"utf-8\");\n return JSON.parse(raw) as ProjectConfig;\n } catch {\n return null;\n }\n}\n\nexport function writeProjectConfig(\n config: ProjectConfig,\n projectRoot?: string,\n): void {\n const dir = projectConfigDir(projectRoot);\n fs.mkdirSync(dir, { recursive: true });\n config.updatedAt = new Date().toISOString();\n const configPath = path.join(dir, \"config.json\");\n fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + \"\\n\", \"utf-8\");\n}\n\n/** Ensure all project directories exist. */\nexport function ensureProjectDirs(projectRoot?: string): void {\n const dirs = [\n projectConfigDir(projectRoot),\n projectDataDir(projectRoot),\n path.join(projectDataDir(projectRoot), \"cache\"),\n path.join(projectDataDir(projectRoot), \"snapshots\"),\n ];\n for (const dir of dirs) {\n fs.mkdirSync(dir, { recursive: true });\n }\n}\n\n/** Check if burnwatch is initialized in the given project. */\nexport function isInitialized(projectRoot?: string): boolean {\n return readProjectConfig(projectRoot) !== null;\n}\n","import type { ConfidenceTier } from \"../core/types.js\";\n\n/** Result from polling a billing API. */\nexport interface BillingResult {\n serviceId: string;\n spend: number;\n isEstimate: boolean;\n tier: ConfidenceTier;\n raw?: Record<string, unknown>;\n error?: string;\n}\n\n/**\n * Base interface for service billing connectors.\n * Each LIVE service implements this to fetch real spend data.\n */\nexport interface BillingConnector {\n serviceId: string;\n /** Fetch current period spend. */\n fetchSpend(apiKey: string, options?: Record<string, string>): Promise<BillingResult>;\n}\n\n/**\n * Make an HTTP request and return JSON.\n * Uses native fetch (Node 18+). No external dependencies.\n */\nexport async function fetchJson<T>(\n url: string,\n options: {\n headers?: Record<string, string>;\n method?: string;\n body?: string;\n timeout?: number;\n } = {},\n): Promise<{ ok: boolean; status: number; data?: T; error?: string }> {\n try {\n const controller = new AbortController();\n const timeoutId = setTimeout(\n () => controller.abort(),\n options.timeout ?? 10_000,\n );\n\n const response = await fetch(url, {\n method: options.method ?? \"GET\",\n headers: options.headers,\n body: options.body,\n signal: controller.signal,\n });\n\n clearTimeout(timeoutId);\n\n if (!response.ok) {\n return {\n ok: false,\n status: response.status,\n error: `HTTP ${response.status}: ${response.statusText}`,\n };\n }\n\n const data = (await response.json()) as T;\n return { ok: true, status: response.status, data };\n } catch (err) {\n return {\n ok: false,\n status: 0,\n error: err instanceof Error ? err.message : \"Unknown error\",\n };\n }\n}\n"],"mappings":"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","names":[]}
|