burnwatch 0.4.2 ā 0.5.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 +20 -3
- package/dist/cli.js +144 -81
- package/dist/cli.js.map +1 -1
- package/dist/interactive-init.d.ts +9 -3
- package/dist/interactive-init.js +86 -49
- package/dist/interactive-init.js.map +1 -1
- package/package.json +1 -1
|
@@ -4,8 +4,10 @@ import { D as DetectionResult } from './detector-C4LnLT-O.js';
|
|
|
4
4
|
/**
|
|
5
5
|
* Interactive init flow for burnwatch.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
7
|
+
* Conducts a per-service interview: detects what it can automatically
|
|
8
|
+
* (existing API keys, env vars), asks for plan selection, collects
|
|
9
|
+
* API keys for LIVE tracking, and ensures every service exits with
|
|
10
|
+
* a budget. No skipping.
|
|
9
11
|
*/
|
|
10
12
|
|
|
11
13
|
interface InteractiveInitResult {
|
|
@@ -13,7 +15,11 @@ interface InteractiveInitResult {
|
|
|
13
15
|
}
|
|
14
16
|
/**
|
|
15
17
|
* Run the interactive init flow.
|
|
16
|
-
*
|
|
18
|
+
*
|
|
19
|
+
* For each detected service:
|
|
20
|
+
* 1. Ask which plan they're on
|
|
21
|
+
* 2. If LIVE-capable, check for existing key or ask for one
|
|
22
|
+
* 3. Set budget (defaults to plan cost, $0 for free - never skipped)
|
|
17
23
|
*/
|
|
18
24
|
declare function runInteractiveInit(detected: DetectionResult[]): Promise<InteractiveInitResult>;
|
|
19
25
|
|
package/dist/interactive-init.js
CHANGED
|
@@ -63,10 +63,18 @@ async function fetchJson(url, options = {}) {
|
|
|
63
63
|
// src/interactive-init.ts
|
|
64
64
|
var RISK_ORDER = ["llm", "usage", "infra", "flat"];
|
|
65
65
|
var RISK_LABELS = {
|
|
66
|
-
llm: "
|
|
67
|
-
usage: "
|
|
68
|
-
infra: "
|
|
69
|
-
flat: "
|
|
66
|
+
llm: "LLM / AI Services (highest variable cost)",
|
|
67
|
+
usage: "Usage-Based Services",
|
|
68
|
+
infra: "Infrastructure & Compute",
|
|
69
|
+
flat: "Flat-Rate / Free Tier Services"
|
|
70
|
+
};
|
|
71
|
+
var API_KEY_HINTS = {
|
|
72
|
+
anthropic: "Admin key: console.anthropic.com -> Settings -> Admin API Keys",
|
|
73
|
+
openai: "Org key: platform.openai.com -> Settings -> API Keys",
|
|
74
|
+
vercel: "Token: vercel.com/account/tokens",
|
|
75
|
+
supabase: "Service role key: supabase.com/dashboard -> Settings -> API",
|
|
76
|
+
stripe: "Secret key: dashboard.stripe.com -> Developers -> API Keys",
|
|
77
|
+
scrapfly: "API key: scrapfly.io/dashboard"
|
|
70
78
|
};
|
|
71
79
|
function classifyRisk(service) {
|
|
72
80
|
if (service.billingModel === "token_usage") return "llm";
|
|
@@ -103,6 +111,13 @@ async function autoDetectScrapflyPlan(apiKey) {
|
|
|
103
111
|
}
|
|
104
112
|
return null;
|
|
105
113
|
}
|
|
114
|
+
function findEnvKey(service) {
|
|
115
|
+
for (const pattern of service.envPatterns) {
|
|
116
|
+
const val = process.env[pattern];
|
|
117
|
+
if (val && val.length > 0) return val;
|
|
118
|
+
}
|
|
119
|
+
return void 0;
|
|
120
|
+
}
|
|
106
121
|
async function runInteractiveInit(detected) {
|
|
107
122
|
const rl = readline.createInterface({
|
|
108
123
|
input: process.stdin,
|
|
@@ -112,14 +127,16 @@ async function runInteractiveInit(detected) {
|
|
|
112
127
|
const groups = groupByRisk(detected);
|
|
113
128
|
const globalConfig = readGlobalConfig();
|
|
114
129
|
console.log(
|
|
115
|
-
|
|
130
|
+
`
|
|
131
|
+
Found ${detected.length} paid service${detected.length !== 1 ? "s" : ""}. Let's configure each one.
|
|
132
|
+
`
|
|
116
133
|
);
|
|
117
134
|
for (const category of RISK_ORDER) {
|
|
118
135
|
const group = groups.get(category);
|
|
119
136
|
if (group.length === 0) continue;
|
|
120
137
|
console.log(`
|
|
121
|
-
${RISK_LABELS[category]}`);
|
|
122
|
-
console.log("
|
|
138
|
+
${RISK_LABELS[category]}`);
|
|
139
|
+
console.log(" " + "-".repeat(48));
|
|
123
140
|
for (const det of group) {
|
|
124
141
|
const service = det.service;
|
|
125
142
|
const plans = service.plans;
|
|
@@ -131,23 +148,24 @@ ${RISK_LABELS[category]}`);
|
|
|
131
148
|
serviceId: service.id,
|
|
132
149
|
detectedVia: det.sources,
|
|
133
150
|
hasApiKey: false,
|
|
134
|
-
firstDetected: (/* @__PURE__ */ new Date()).toISOString()
|
|
151
|
+
firstDetected: (/* @__PURE__ */ new Date()).toISOString(),
|
|
152
|
+
budget: 0
|
|
135
153
|
};
|
|
136
|
-
console.log("
|
|
154
|
+
console.log(" -> Configured (no plan tiers in registry, budget: $0)");
|
|
137
155
|
continue;
|
|
138
156
|
}
|
|
139
157
|
const defaultIndex = plans.findIndex((p) => p.default);
|
|
140
158
|
console.log("");
|
|
141
159
|
for (let i = 0; i < plans.length; i++) {
|
|
142
160
|
const plan = plans[i];
|
|
143
|
-
const marker = i === defaultIndex ? "
|
|
144
|
-
const costStr = plan.type === "exclude" ? "" : plan.monthlyBase !== void 0 ? `
|
|
161
|
+
const marker = i === defaultIndex ? " *" : "";
|
|
162
|
+
const costStr = plan.type === "exclude" ? "" : plan.monthlyBase !== void 0 ? ` - $${plan.monthlyBase}/mo` : " - variable";
|
|
145
163
|
console.log(` ${i + 1}) ${plan.name}${costStr}${marker}`);
|
|
146
164
|
}
|
|
147
165
|
const defaultChoice = defaultIndex >= 0 ? String(defaultIndex + 1) : "1";
|
|
148
166
|
const answer = await ask(
|
|
149
167
|
rl,
|
|
150
|
-
`
|
|
168
|
+
` Which plan? [${defaultChoice}]: `
|
|
151
169
|
);
|
|
152
170
|
const choiceIndex = (answer === "" ? parseInt(defaultChoice) : parseInt(answer)) - 1;
|
|
153
171
|
const chosen = plans[choiceIndex] ?? plans[defaultIndex >= 0 ? defaultIndex : 0];
|
|
@@ -160,10 +178,10 @@ ${RISK_LABELS[category]}`);
|
|
|
160
178
|
excluded: true,
|
|
161
179
|
planName: chosen.name
|
|
162
180
|
};
|
|
163
|
-
console.log(`
|
|
181
|
+
console.log(` -> ${service.name}: excluded`);
|
|
164
182
|
continue;
|
|
165
183
|
}
|
|
166
|
-
const
|
|
184
|
+
const tracked2 = {
|
|
167
185
|
serviceId: service.id,
|
|
168
186
|
detectedVia: det.sources,
|
|
169
187
|
hasApiKey: false,
|
|
@@ -171,67 +189,86 @@ ${RISK_LABELS[category]}`);
|
|
|
171
189
|
planName: chosen.name
|
|
172
190
|
};
|
|
173
191
|
if (chosen.type === "flat" && chosen.monthlyBase !== void 0) {
|
|
174
|
-
|
|
175
|
-
if (chosen.monthlyBase > 0) {
|
|
176
|
-
tracked.budget = chosen.monthlyBase;
|
|
177
|
-
}
|
|
192
|
+
tracked2.planCost = chosen.monthlyBase;
|
|
178
193
|
}
|
|
179
|
-
if (service.apiTier === "live"
|
|
194
|
+
if (service.apiTier === "live") {
|
|
180
195
|
const existingKey = globalConfig.services[service.id]?.apiKey;
|
|
196
|
+
const envKey = findEnvKey(service);
|
|
181
197
|
if (existingKey) {
|
|
182
|
-
console.log(`
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
tracked.planName = planName;
|
|
190
|
-
}
|
|
198
|
+
console.log(` API key: found in global config`);
|
|
199
|
+
tracked2.hasApiKey = true;
|
|
200
|
+
} else if (envKey) {
|
|
201
|
+
console.log(` API key: found in environment (${service.envPatterns[0]})`);
|
|
202
|
+
tracked2.hasApiKey = true;
|
|
203
|
+
if (!globalConfig.services[service.id]) {
|
|
204
|
+
globalConfig.services[service.id] = {};
|
|
191
205
|
}
|
|
192
|
-
|
|
206
|
+
globalConfig.services[service.id].apiKey = envKey;
|
|
207
|
+
} else {
|
|
208
|
+
const hint = API_KEY_HINTS[service.id];
|
|
209
|
+
if (hint) console.log(` ${hint}`);
|
|
193
210
|
const keyAnswer = await ask(
|
|
194
211
|
rl,
|
|
195
|
-
`
|
|
212
|
+
` API key for real-time tracking (Enter to skip): `
|
|
196
213
|
);
|
|
197
214
|
if (keyAnswer) {
|
|
198
|
-
|
|
215
|
+
tracked2.hasApiKey = true;
|
|
199
216
|
if (!globalConfig.services[service.id]) {
|
|
200
217
|
globalConfig.services[service.id] = {};
|
|
201
218
|
}
|
|
202
219
|
globalConfig.services[service.id].apiKey = keyAnswer;
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (service.autoDetectPlan && service.id === "scrapfly" && tracked2.hasApiKey) {
|
|
223
|
+
const key = globalConfig.services[service.id]?.apiKey;
|
|
224
|
+
if (key) {
|
|
225
|
+
console.log(" Detecting plan from API...");
|
|
226
|
+
const planName = await autoDetectScrapflyPlan(key);
|
|
227
|
+
if (planName) {
|
|
228
|
+
console.log(` -> Detected plan: ${planName}`);
|
|
229
|
+
tracked2.planName = planName;
|
|
210
230
|
}
|
|
211
231
|
}
|
|
212
232
|
}
|
|
213
233
|
}
|
|
214
|
-
|
|
215
|
-
|
|
234
|
+
const planCost = chosen.monthlyBase ?? 0;
|
|
235
|
+
if (chosen.type === "usage" && planCost === 0) {
|
|
216
236
|
const budgetAnswer = await ask(
|
|
217
237
|
rl,
|
|
218
|
-
` Monthly budget
|
|
238
|
+
` Monthly budget: $`
|
|
239
|
+
);
|
|
240
|
+
const parsed = parseFloat(budgetAnswer);
|
|
241
|
+
tracked2.budget = !isNaN(parsed) ? parsed : 0;
|
|
242
|
+
} else {
|
|
243
|
+
const budgetAnswer = await ask(
|
|
244
|
+
rl,
|
|
245
|
+
` Monthly budget [$${planCost}]: $`
|
|
219
246
|
);
|
|
220
247
|
if (budgetAnswer) {
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
248
|
+
const parsed = parseFloat(budgetAnswer);
|
|
249
|
+
tracked2.budget = !isNaN(parsed) ? parsed : planCost;
|
|
250
|
+
} else {
|
|
251
|
+
tracked2.budget = planCost;
|
|
225
252
|
}
|
|
226
253
|
}
|
|
227
|
-
services[service.id] =
|
|
228
|
-
const tierLabel =
|
|
229
|
-
const budgetStr = tracked.budget !== void 0 ? ` | Budget: $${tracked.budget}/mo` : "";
|
|
254
|
+
services[service.id] = tracked2;
|
|
255
|
+
const tierLabel = tracked2.hasApiKey ? "LIVE" : tracked2.planCost !== void 0 ? "CALC" : "BLIND";
|
|
230
256
|
console.log(
|
|
231
|
-
`
|
|
257
|
+
` -> ${service.name}: ${tracked2.planName} | ${tierLabel} | $${tracked2.budget}/mo`
|
|
232
258
|
);
|
|
233
259
|
}
|
|
234
260
|
}
|
|
261
|
+
const tracked = Object.values(services).filter((s) => !s.excluded);
|
|
262
|
+
const excluded = Object.values(services).filter((s) => s.excluded);
|
|
263
|
+
const liveCount = tracked.filter((s) => s.hasApiKey).length;
|
|
264
|
+
const totalBudget = tracked.reduce((sum, s) => sum + (s.budget ?? 0), 0);
|
|
265
|
+
console.log("\n " + "=".repeat(48));
|
|
266
|
+
console.log(` ${tracked.length} services configured`);
|
|
267
|
+
if (liveCount > 0) console.log(` ${liveCount} with real-time billing (LIVE)`);
|
|
268
|
+
if (tracked.length - liveCount > 0) console.log(` ${tracked.length - liveCount} estimated/calculated`);
|
|
269
|
+
if (excluded.length > 0) console.log(` ${excluded.length} excluded`);
|
|
270
|
+
console.log(` Total monthly budget: $${totalBudget}`);
|
|
271
|
+
console.log(" " + "=".repeat(48));
|
|
235
272
|
writeGlobalConfig(globalConfig);
|
|
236
273
|
rl.close();
|
|
237
274
|
return { services };
|
|
@@ -1 +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 tracked.planCost = chosen.monthlyBase;\n // Auto-set budget to plan cost for paid flat plans\n if (chosen.monthlyBase > 0) {\n tracked.budget = chosen.monthlyBase;\n }\n }\n\n // If the service has a billing API, offer to provide a key\n if (service.apiTier === \"live\" || 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 if (chosen.requiresKey) {\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 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\n // Always ask for budget if not already set to a meaningful value\n if (tracked.budget === undefined || tracked.budget === 0) {\n const suggestion = chosen.monthlyBase && chosen.monthlyBase > 0\n ? ` [${chosen.monthlyBase}]`\n : \"\";\n const budgetAnswer = await ask(\n rl,\n ` Monthly budget in USD${suggestion} (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 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":";AAOA,YAAY,cAAc;;;ACP1B,YAAY,QAAQ;AACpB,YAAY,UAAU;AACtB,YAAY,QAAQ;AAab,SAAS,kBAA0B;AACxC,QAAM,YAAY,QAAQ,IAAI,iBAAiB;AAC/C,MAAI,UAAW,QAAY,UAAK,WAAW,WAAW;AACtD,SAAY,UAAQ,WAAQ,GAAG,WAAW,WAAW;AACvD;AA0BO,SAAS,mBAAiC;AAC/C,QAAM,aAAkB,UAAK,gBAAgB,GAAG,aAAa;AAC7D,MAAI;AACF,UAAM,MAAS,gBAAa,YAAY,OAAO;AAC/C,WAAO,KAAK,MAAM,GAAG;AAAA,EACvB,QAAQ;AACN,WAAO,EAAE,UAAU,CAAC,EAAE;AAAA,EACxB;AACF;AAEO,SAAS,kBAAkB,QAA4B;AAC5D,QAAM,MAAM,gBAAgB;AAC5B,EAAG,aAAU,KAAK,EAAE,WAAW,KAAK,CAAC;AACrC,QAAM,aAAkB,UAAK,KAAK,aAAa;AAC/C,EAAG,iBAAc,YAAY,KAAK,UAAU,QAAQ,MAAM,CAAC,IAAI,MAAM,OAAO;AAE5E,EAAG,aAAU,YAAY,GAAK;AAChC;;;ACpCA,eAAsB,UACpB,KACA,UAKI,CAAC,GAC+D;AACpE,MAAI;AACF,UAAM,aAAa,IAAI,gBAAgB;AACvC,UAAM,YAAY;AAAA,MAChB,MAAM,WAAW,MAAM;AAAA,MACvB,QAAQ,WAAW;AAAA,IACrB;AAEA,UAAM,WAAW,MAAM,MAAM,KAAK;AAAA,MAChC,QAAQ,QAAQ,UAAU;AAAA,MAC1B,SAAS,QAAQ;AAAA,MACjB,MAAM,QAAQ;AAAA,MACd,QAAQ,WAAW;AAAA,IACrB,CAAC;AAED,iBAAa,SAAS;AAEtB,QAAI,CAAC,SAAS,IAAI;AAChB,aAAO;AAAA,QACL,IAAI;AAAA,QACJ,QAAQ,SAAS;AAAA,QACjB,OAAO,QAAQ,SAAS,MAAM,KAAK,SAAS,UAAU;AAAA,MACxD;AAAA,IACF;AAEA,UAAM,OAAQ,MAAM,SAAS,KAAK;AAClC,WAAO,EAAE,IAAI,MAAM,QAAQ,SAAS,QAAQ,KAAK;AAAA,EACnD,SAAS,KAAK;AACZ,WAAO;AAAA,MACL,IAAI;AAAA,MACJ,QAAQ;AAAA,MACR,OAAO,eAAe,QAAQ,IAAI,UAAU;AAAA,IAC9C;AAAA,EACF;AACF;;;AFjDA,IAAM,aAAoC,CAAC,OAAO,SAAS,SAAS,MAAM;AAE1E,IAAM,cAAmD;AAAA,EACvD,KAAK;AAAA,EACL,OAAO;AAAA,EACP,OAAO;AAAA,EACP,MAAM;AACR;AAGA,SAAS,aAAa,SAAiD;AACrE,MAAI,QAAQ,iBAAiB,cAAe,QAAO;AACnD,MACE,QAAQ,iBAAiB,iBACzB,QAAQ,iBAAiB,gBACzB,QAAQ,iBAAiB;AAEzB,WAAO;AACT,MAAI,QAAQ,iBAAiB,UAAW,QAAO;AAC/C,SAAO;AACT;AAGA,SAAS,YACP,UAC6C;AAC7C,QAAM,SAAS,oBAAI,IAA4C;AAC/D,aAAW,OAAO,YAAY;AAC5B,WAAO,IAAI,KAAK,CAAC,CAAC;AAAA,EACpB;AAEA,aAAW,OAAO,UAAU;AAC1B,UAAM,MAAM,aAAa,IAAI,OAAO;AACpC,WAAO,IAAI,GAAG,EAAG,KAAK,GAAG;AAAA,EAC3B;AAEA,SAAO;AACT;AAGA,SAAS,IAAI,IAAwB,UAAmC;AACtE,SAAO,IAAI,QAAQ,CAAC,YAAY;AAC9B,OAAG,SAAS,UAAU,CAAC,WAAW;AAChC,cAAQ,OAAO,KAAK,CAAC;AAAA,IACvB,CAAC;AAAA,EACH,CAAC;AACH;AAGA,eAAe,uBACb,QACwB;AACxB,MAAI;AACF,UAAM,SAAS,MAAM,UAElB,uCAAuC,MAAM,EAAE;AAElD,QAAI,OAAO,MAAM,OAAO,MAAM,cAAc,MAAM,MAAM;AACtD,aAAO,OAAO,KAAK,aAAa,KAAK;AAAA,IACvC;AAAA,EACF,QAAQ;AAAA,EAER;AACA,SAAO;AACT;AAUA,eAAsB,mBACpB,UACgC;AAChC,QAAM,KAAc,yBAAgB;AAAA,IAClC,OAAO,QAAQ;AAAA,IACf,QAAQ,QAAQ;AAAA,EAClB,CAAC;AAED,QAAM,WAA2C,CAAC;AAClD,QAAM,SAAS,YAAY,QAAQ;AACnC,QAAM,eAAe,iBAAiB;AAEtC,UAAQ;AAAA,IACN;AAAA,EACF;AAEA,aAAW,YAAY,YAAY;AACjC,UAAM,QAAQ,OAAO,IAAI,QAAQ;AACjC,QAAI,MAAM,WAAW,EAAG;AAExB,YAAQ,IAAI;AAAA,EAAK,YAAY,QAAQ,CAAC,EAAE;AACxC,YAAQ,IAAI,SAAI,OAAO,EAAE,CAAC;AAE1B,eAAW,OAAO,OAAO;AACvB,YAAM,UAAU,IAAI;AACpB,YAAM,QAAQ,QAAQ;AAEtB,cAAQ,IAAI;AAAA,IAAO,QAAQ,IAAI,EAAE;AACjC,cAAQ,IAAI,mBAAmB,IAAI,QAAQ,KAAK,IAAI,CAAC,EAAE;AAEvD,UAAI,CAAC,SAAS,MAAM,WAAW,GAAG;AAEhC,iBAAS,QAAQ,EAAE,IAAI;AAAA,UACrB,WAAW,QAAQ;AAAA,UACnB,aAAa,IAAI;AAAA,UACjB,WAAW;AAAA,UACX,gBAAe,oBAAI,KAAK,GAAE,YAAY;AAAA,QACxC;AACA,gBAAQ,IAAI,oDAA+C;AAC3D;AAAA,MACF;AAGA,YAAM,eAAe,MAAM,UAAU,CAAC,MAAM,EAAE,OAAO;AACrD,cAAQ,IAAI,EAAE;AACd,eAAS,IAAI,GAAG,IAAI,MAAM,QAAQ,KAAK;AACrC,cAAM,OAAO,MAAM,CAAC;AACpB,cAAM,SAAS,MAAM,eAAe,mBAAmB;AACvD,cAAM,UACJ,KAAK,SAAS,YACV,KACA,KAAK,gBAAgB,SACnB,YAAO,KAAK,WAAW,QACvB;AACR,gBAAQ,IAAI,OAAO,IAAI,CAAC,KAAK,KAAK,IAAI,GAAG,OAAO,GAAG,MAAM,EAAE;AAAA,MAC7D;AAEA,YAAM,gBACJ,gBAAgB,IAAI,OAAO,eAAe,CAAC,IAAI;AACjD,YAAM,SAAS,MAAM;AAAA,QACnB;AAAA,QACA,aAAa,aAAa;AAAA,MAC5B;AAEA,YAAM,eAAe,WAAW,KAAK,SAAS,aAAa,IAAI,SAAS,MAAM,KAAK;AACnF,YAAM,SACJ,MAAM,WAAW,KAAK,MAAM,gBAAgB,IAAI,eAAe,CAAC;AAElE,UAAI,OAAO,SAAS,WAAW;AAE7B,iBAAS,QAAQ,EAAE,IAAI;AAAA,UACrB,WAAW,QAAQ;AAAA,UACnB,aAAa,IAAI;AAAA,UACjB,WAAW;AAAA,UACX,gBAAe,oBAAI,KAAK,GAAE,YAAY;AAAA,UACtC,UAAU;AAAA,UACV,UAAU,OAAO;AAAA,QACnB;AACA,gBAAQ,IAAI,YAAO,QAAQ,IAAI,0BAA0B;AACzD;AAAA,MACF;AAEA,YAAM,UAA0B;AAAA,QAC9B,WAAW,QAAQ;AAAA,QACnB,aAAa,IAAI;AAAA,QACjB,WAAW;AAAA,QACX,gBAAe,oBAAI,KAAK,GAAE,YAAY;AAAA,QACtC,UAAU,OAAO;AAAA,MACnB;AAEA,UAAI,OAAO,SAAS,UAAU,OAAO,gBAAgB,QAAW;AAC9D,gBAAQ,WAAW,OAAO;AAE1B,YAAI,OAAO,cAAc,GAAG;AAC1B,kBAAQ,SAAS,OAAO;AAAA,QAC1B;AAAA,MACF;AAGA,UAAI,QAAQ,YAAY,UAAU,OAAO,aAAa;AAEpD,cAAM,cAAc,aAAa,SAAS,QAAQ,EAAE,GAAG;AACvD,YAAI,aAAa;AACf,kBAAQ,IAAI,uDAAgD;AAC5D,kBAAQ,YAAY;AAGpB,cAAI,QAAQ,kBAAkB,QAAQ,OAAO,YAAY;AACvD,oBAAQ,IAAI,6CAAsC;AAClD,kBAAM,WAAW,MAAM,uBAAuB,WAAW;AACzD,gBAAI,UAAU;AACZ,sBAAQ,IAAI,2BAAsB,QAAQ,EAAE;AAC5C,sBAAQ,WAAW;AAAA,YACrB;AAAA,UACF;AAAA,QACF,WAAW,OAAO,aAAa;AAC7B,gBAAM,YAAY,MAAM;AAAA,YACtB;AAAA,YACA;AAAA,UACF;AACA,cAAI,WAAW;AACb,oBAAQ,YAAY;AACpB,gBAAI,CAAC,aAAa,SAAS,QAAQ,EAAE,GAAG;AACtC,2BAAa,SAAS,QAAQ,EAAE,IAAI,CAAC;AAAA,YACvC;AACA,yBAAa,SAAS,QAAQ,EAAE,EAAG,SAAS;AAG5C,gBAAI,QAAQ,kBAAkB,QAAQ,OAAO,YAAY;AACvD,sBAAQ,IAAI,6CAAsC;AAClD,oBAAM,WAAW,MAAM,uBAAuB,SAAS;AACvD,kBAAI,UAAU;AACZ,wBAAQ,IAAI,2BAAsB,QAAQ,EAAE;AAC5C,wBAAQ,WAAW;AAAA,cACrB;AAAA,YACF;AAAA,UACF;AAAA,QACF;AAAA,MACF;AAGA,UAAI,QAAQ,WAAW,UAAa,QAAQ,WAAW,GAAG;AACxD,cAAM,aAAa,OAAO,eAAe,OAAO,cAAc,IAC1D,KAAK,OAAO,WAAW,MACvB;AACJ,cAAM,eAAe,MAAM;AAAA,UACzB;AAAA,UACA,0BAA0B,UAAU;AAAA,QACtC;AACA,YAAI,cAAc;AAChB,gBAAM,SAAS,WAAW,YAAY;AACtC,cAAI,CAAC,MAAM,MAAM,GAAG;AAClB,oBAAQ,SAAS;AAAA,UACnB;AAAA,QACF;AAAA,MACF;AAEA,eAAS,QAAQ,EAAE,IAAI;AAEvB,YAAM,YAAY,QAAQ,YACtB,gBACA,QAAQ,aAAa,SACnB,mBACA;AACN,YAAM,YAAY,QAAQ,WAAW,SAAY,eAAe,QAAQ,MAAM,QAAQ;AACtF,cAAQ;AAAA,QACN,YAAO,QAAQ,IAAI,KAAK,OAAO,IAAI,KAAK,SAAS,GAAG,SAAS;AAAA,MAC/D;AAAA,IACF;AAAA,EACF;AAGA,oBAAkB,YAAY;AAE9B,KAAG,MAAM;AAET,SAAO,EAAE,SAAS;AACpB;","names":[]}
|
|
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 * Conducts a per-service interview: detects what it can automatically\n * (existing API keys, env vars), asks for plan selection, collects\n * API keys for LIVE tracking, and ensures every service exits with\n * a budget. No skipping.\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/** Where to find API keys for LIVE-capable services */\nconst API_KEY_HINTS: Record<string, string> = {\n anthropic: \"Admin key: console.anthropic.com -> Settings -> Admin API Keys\",\n openai: \"Org key: platform.openai.com -> Settings -> API Keys\",\n vercel: \"Token: vercel.com/account/tokens\",\n supabase: \"Service role key: supabase.com/dashboard -> Settings -> API\",\n stripe: \"Secret key: dashboard.stripe.com -> Developers -> API Keys\",\n scrapfly: \"API key: scrapfly.io/dashboard\",\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\n/** Scan environment for API keys matching service env patterns */\nfunction findEnvKey(service: ServiceDefinition): string | undefined {\n for (const pattern of service.envPatterns) {\n const val = process.env[pattern];\n if (val && val.length > 0) return val;\n }\n return undefined;\n}\n\nexport interface InteractiveInitResult {\n services: Record<string, TrackedService>;\n}\n\n/**\n * Run the interactive init flow.\n *\n * For each detected service:\n * 1. Ask which plan they're on\n * 2. If LIVE-capable, check for existing key or ask for one\n * 3. Set budget (defaults to plan cost, $0 for free - never skipped)\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 Found ${detected.length} paid service${detected.length !== 1 ? \"s\" : \"\"}. Let's configure each one.\\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(48));\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 - basic tracking with $0 budget\n services[service.id] = {\n serviceId: service.id,\n detectedVia: det.sources,\n hasApiKey: false,\n firstDetected: new Date().toISOString(),\n budget: 0,\n };\n console.log(\" -> Configured (no plan tiers in registry, budget: $0)\");\n continue;\n }\n\n // --- Plan selection ---\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 ? \" *\" : \"\";\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 ` Which plan? [${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 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`);\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 tracked.planCost = chosen.monthlyBase;\n }\n\n // --- API key (LIVE-capable services) ---\n if (service.apiTier === \"live\") {\n const existingKey = globalConfig.services[service.id]?.apiKey;\n const envKey = findEnvKey(service);\n\n if (existingKey) {\n console.log(` API key: found in global config`);\n tracked.hasApiKey = true;\n } else if (envKey) {\n console.log(` API key: found in environment (${service.envPatterns[0]})`);\n tracked.hasApiKey = true;\n if (!globalConfig.services[service.id]) {\n globalConfig.services[service.id] = {};\n }\n globalConfig.services[service.id]!.apiKey = envKey;\n } else {\n const hint = API_KEY_HINTS[service.id];\n if (hint) console.log(` ${hint}`);\n const keyAnswer = await ask(\n rl,\n ` API key for real-time tracking (Enter to skip): `,\n );\n if (keyAnswer) {\n tracked.hasApiKey = true;\n if (!globalConfig.services[service.id]) {\n globalConfig.services[service.id] = {};\n }\n globalConfig.services[service.id]!.apiKey = keyAnswer;\n }\n }\n\n // Auto-detect plan for Scrapfly\n if (service.autoDetectPlan && service.id === \"scrapfly\" && tracked.hasApiKey) {\n const key = globalConfig.services[service.id]?.apiKey;\n if (key) {\n console.log(\" Detecting plan from API...\");\n const planName = await autoDetectScrapflyPlan(key);\n if (planName) {\n console.log(` -> Detected plan: ${planName}`);\n tracked.planName = planName;\n }\n }\n }\n }\n\n // --- Budget (always set, never skip) ---\n const planCost = chosen.monthlyBase ?? 0;\n\n if (chosen.type === \"usage\" && planCost === 0) {\n // Usage-based with no fixed cost - need a real number\n const budgetAnswer = await ask(\n rl,\n ` Monthly budget: $`,\n );\n const parsed = parseFloat(budgetAnswer);\n tracked.budget = !isNaN(parsed) ? parsed : 0;\n } else {\n // Flat plan or usage with known base - default to plan cost\n const budgetAnswer = await ask(\n rl,\n ` Monthly budget [$${planCost}]: $`,\n );\n if (budgetAnswer) {\n const parsed = parseFloat(budgetAnswer);\n tracked.budget = !isNaN(parsed) ? parsed : planCost;\n } else {\n tracked.budget = planCost;\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 console.log(\n ` -> ${service.name}: ${tracked.planName} | ${tierLabel} | $${tracked.budget}/mo`,\n );\n }\n }\n\n // --- Summary ---\n const tracked = Object.values(services).filter((s) => !s.excluded);\n const excluded = Object.values(services).filter((s) => s.excluded);\n const liveCount = tracked.filter((s) => s.hasApiKey).length;\n const totalBudget = tracked.reduce((sum, s) => sum + (s.budget ?? 0), 0);\n\n console.log(\"\\n \" + \"=\".repeat(48));\n console.log(` ${tracked.length} services configured`);\n if (liveCount > 0) console.log(` ${liveCount} with real-time billing (LIVE)`);\n if (tracked.length - liveCount > 0) console.log(` ${tracked.length - liveCount} estimated/calculated`);\n if (excluded.length > 0) console.log(` ${excluded.length} excluded`);\n console.log(` Total monthly budget: $${totalBudget}`);\n console.log(\" \" + \"=\".repeat(48));\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":["tracked"]}
|
package/package.json
CHANGED