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.
@@ -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
- * Groups detected services by risk category, presents plan tiers,
8
- * and collects user choices via Node readline.
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
- * Shows detected services grouped by risk, lets user pick plans.
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
 
@@ -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: "\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"
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
- "\n\u{1F4CB} Let's configure each detected service. Services are grouped by cost risk.\n"
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("\u2500".repeat(50));
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(" \u2192 Auto-configured (no plan tiers available)");
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 ? " (recommended)" : "";
144
- const costStr = plan.type === "exclude" ? "" : plan.monthlyBase !== void 0 ? ` \u2014 $${plan.monthlyBase}/mo` : " \u2014 variable";
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
- ` Choose [${defaultChoice}]: `
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(` \u2192 ${service.name}: excluded from tracking`);
181
+ console.log(` -> ${service.name}: excluded`);
164
182
  continue;
165
183
  }
166
- const tracked = {
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
- tracked.planCost = chosen.monthlyBase;
175
- if (chosen.monthlyBase > 0) {
176
- tracked.budget = chosen.monthlyBase;
177
- }
192
+ tracked2.planCost = chosen.monthlyBase;
178
193
  }
179
- if (service.apiTier === "live" || chosen.requiresKey) {
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(` \u{1F510} Using existing API key from global config`);
183
- tracked.hasApiKey = true;
184
- if (service.autoDetectPlan && service.id === "scrapfly") {
185
- console.log(" \u{1F50D} Auto-detecting plan from API...");
186
- const planName = await autoDetectScrapflyPlan(existingKey);
187
- if (planName) {
188
- console.log(` \u2192 Detected plan: ${planName}`);
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
- } else if (chosen.requiresKey) {
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
- ` Enter API key (or press Enter to skip): `
212
+ ` API key for real-time tracking (Enter to skip): `
196
213
  );
197
214
  if (keyAnswer) {
198
- tracked.hasApiKey = true;
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
- if (service.autoDetectPlan && service.id === "scrapfly") {
204
- console.log(" \u{1F50D} Auto-detecting plan from API...");
205
- const planName = await autoDetectScrapflyPlan(keyAnswer);
206
- if (planName) {
207
- console.log(` \u2192 Detected plan: ${planName}`);
208
- tracked.planName = planName;
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
- if (tracked.budget === void 0 || tracked.budget === 0) {
215
- const suggestion = chosen.monthlyBase && chosen.monthlyBase > 0 ? ` [${chosen.monthlyBase}]` : "";
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 in USD${suggestion} (or press Enter to skip): $`
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 budget = parseFloat(budgetAnswer);
222
- if (!isNaN(budget)) {
223
- tracked.budget = budget;
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] = tracked;
228
- const tierLabel = tracked.hasApiKey ? "\u2705 LIVE" : tracked.planCost !== void 0 ? "\u{1F7E1} CALC" : "\u{1F534} BLIND";
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
- ` \u2192 ${service.name}: ${chosen.name} (${tierLabel}${budgetStr})`
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":"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","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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "burnwatch",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Passive cost memory for vibe coding — detects paid services, tracks spend, injects budget context into your AI coding sessions.",
5
5
  "type": "module",
6
6
  "bin": {