burnwatch 0.13.1 → 0.14.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 +9 -0
- package/README.md +276 -214
- package/billing/anthropic.json +49 -0
- package/billing/billing.schema.json +213 -0
- package/billing/browserbase.json +68 -0
- package/billing/google-gemini.json +66 -0
- package/billing/inngest.json +45 -0
- package/billing/openai.json +70 -0
- package/billing/posthog.json +61 -0
- package/billing/resend.json +49 -0
- package/billing/scrapfly.json +38 -0
- package/billing/supabase.json +32 -0
- package/billing/upstash.json +65 -0
- package/billing/vercel.json +85 -0
- package/billing/voyage-ai.json +42 -0
- package/dist/cli.js +477 -286
- package/dist/cli.js.map +1 -1
- package/dist/cost-impact.d.ts +8 -4
- package/dist/cost-impact.js +261 -72
- package/dist/cost-impact.js.map +1 -1
- package/dist/{detector-myYS2eVC.d.ts → detector-DiBj3WjE.d.ts} +1 -1
- package/dist/hooks/on-file-change.js +271 -88
- package/dist/hooks/on-file-change.js.map +1 -1
- package/dist/hooks/on-prompt.js.map +1 -1
- package/dist/hooks/on-session-start.js +104 -79
- package/dist/hooks/on-session-start.js.map +1 -1
- package/dist/hooks/on-stop.js +65 -46
- package/dist/hooks/on-stop.js.map +1 -1
- package/dist/index.d.ts +6 -4
- package/dist/index.js +286 -113
- package/dist/index.js.map +1 -1
- package/dist/interactive-init.d.ts +2 -2
- package/dist/interactive-init.js +21 -19
- package/dist/interactive-init.js.map +1 -1
- package/dist/mcp-server.js +719 -88
- package/dist/mcp-server.js.map +1 -1
- package/dist/{types-BwIeWOYc.d.ts → types-CUAiYzmE.d.ts} +2 -0
- package/llms.txt +1 -1
- package/package.json +2 -1
- package/registry.json +12 -68
- package/skills/burnwatch-interview/SKILL.md +2 -4
- package/skills/setup-burnwatch/SKILL.md +7 -9
package/dist/cli.js
CHANGED
|
@@ -10,14 +10,14 @@ var __export = (target, all) => {
|
|
|
10
10
|
};
|
|
11
11
|
|
|
12
12
|
// src/services/base.ts
|
|
13
|
-
async function fetchJson(
|
|
13
|
+
async function fetchJson(url3, options = {}) {
|
|
14
14
|
try {
|
|
15
15
|
const controller = new AbortController();
|
|
16
16
|
const timeoutId = setTimeout(
|
|
17
17
|
() => controller.abort(),
|
|
18
18
|
options.timeout ?? 1e4
|
|
19
19
|
);
|
|
20
|
-
const response = await fetch(
|
|
20
|
+
const response = await fetch(url3, {
|
|
21
21
|
method: options.method ?? "GET",
|
|
22
22
|
headers: options.headers,
|
|
23
23
|
body: options.body,
|
|
@@ -55,11 +55,19 @@ __export(probes_exports, {
|
|
|
55
55
|
});
|
|
56
56
|
function matchPlanByPrefix(detected, plans) {
|
|
57
57
|
const lower = detected.toLowerCase();
|
|
58
|
-
|
|
58
|
+
const candidates = plans.filter((p) => {
|
|
59
59
|
if (p.type === "exclude") return false;
|
|
60
60
|
const firstWord = p.name.split(/[\s(]/)[0].toLowerCase();
|
|
61
61
|
return lower.includes(firstWord) || firstWord.includes(lower);
|
|
62
62
|
});
|
|
63
|
+
if (candidates.length <= 1) return candidates[0];
|
|
64
|
+
const dollarMatch = detected.match(/\$(\d+)/);
|
|
65
|
+
if (dollarMatch) {
|
|
66
|
+
const amount = parseInt(dollarMatch[1], 10);
|
|
67
|
+
const byAmount = candidates.find((p) => p.monthlyBase === amount);
|
|
68
|
+
if (byAmount) return byAmount;
|
|
69
|
+
}
|
|
70
|
+
return candidates[0];
|
|
63
71
|
}
|
|
64
72
|
async function probeService(serviceId, apiKey, plans) {
|
|
65
73
|
const probe = PROBES.get(serviceId);
|
|
@@ -78,7 +86,7 @@ function formatK(n) {
|
|
|
78
86
|
if (n >= 1e3) return `${(n / 1e3).toFixed(n % 1e3 === 0 ? 0 : 1)}K`;
|
|
79
87
|
return String(n);
|
|
80
88
|
}
|
|
81
|
-
var probeScrapfly, probeAnthropic, probeOpenAI, probeVercel, probeSupabase,
|
|
89
|
+
var probeScrapfly, probeAnthropic, probeOpenAI, probeVercel, probeSupabase, probeBrowserbase, probeUpstash, probePostHog, PROBES;
|
|
82
90
|
var init_probes = __esm({
|
|
83
91
|
"src/probes.ts"() {
|
|
84
92
|
"use strict";
|
|
@@ -190,10 +198,21 @@ var init_probes = __esm({
|
|
|
190
198
|
return null;
|
|
191
199
|
};
|
|
192
200
|
probeSupabase = async (apiKey, plans) => {
|
|
201
|
+
if (apiKey.startsWith("eyJ")) {
|
|
202
|
+
return {
|
|
203
|
+
summary: "Key is a service_role JWT \u2014 Supabase Management API requires a Personal Access Token (sbp_*) from supabase.com/dashboard \u2192 Account \u2192 Access Tokens",
|
|
204
|
+
confidence: "low"
|
|
205
|
+
};
|
|
206
|
+
}
|
|
193
207
|
const orgsResult = await fetchJson("https://api.supabase.com/v1/organizations", {
|
|
194
208
|
headers: { Authorization: `Bearer ${apiKey}` }
|
|
195
209
|
});
|
|
196
|
-
if (!orgsResult.ok || !orgsResult.data || !Array.isArray(orgsResult.data))
|
|
210
|
+
if (!orgsResult.ok || !orgsResult.data || !Array.isArray(orgsResult.data)) {
|
|
211
|
+
return {
|
|
212
|
+
summary: "Supabase API call failed \u2014 ensure key is a Personal Access Token (sbp_*), not a service_role key",
|
|
213
|
+
confidence: "low"
|
|
214
|
+
};
|
|
215
|
+
}
|
|
197
216
|
const org = orgsResult.data[0];
|
|
198
217
|
if (!org) return null;
|
|
199
218
|
const planName = org.billing?.plan;
|
|
@@ -211,21 +230,6 @@ var init_probes = __esm({
|
|
|
211
230
|
confidence: "low"
|
|
212
231
|
};
|
|
213
232
|
};
|
|
214
|
-
probeStripe = async (apiKey, _plans) => {
|
|
215
|
-
const result = await fetchJson("https://api.stripe.com/v1/balance", {
|
|
216
|
-
headers: { Authorization: `Bearer ${apiKey}` }
|
|
217
|
-
});
|
|
218
|
-
if (!result.ok || !result.data) return null;
|
|
219
|
-
const available = result.data.available?.[0];
|
|
220
|
-
const pending = result.data.pending?.[0];
|
|
221
|
-
const totalCents = (available?.amount ?? 0) + (pending?.amount ?? 0);
|
|
222
|
-
const currency = (available?.currency ?? "usd").toUpperCase();
|
|
223
|
-
return {
|
|
224
|
-
usage: { spend: totalCents / 100, currency },
|
|
225
|
-
summary: `Balance: ${currency} ${(totalCents / 100).toFixed(2)} (${((available?.amount ?? 0) / 100).toFixed(2)} available)`,
|
|
226
|
-
confidence: "medium"
|
|
227
|
-
};
|
|
228
|
-
};
|
|
229
233
|
probeBrowserbase = async (apiKey, _plans) => {
|
|
230
234
|
const projResult = await fetchJson("https://api.browserbase.com/v1/projects", {
|
|
231
235
|
headers: { "X-BB-API-Key": apiKey }
|
|
@@ -278,7 +282,6 @@ var init_probes = __esm({
|
|
|
278
282
|
["openai", probeOpenAI],
|
|
279
283
|
["vercel", probeVercel],
|
|
280
284
|
["supabase", probeSupabase],
|
|
281
|
-
["stripe", probeStripe],
|
|
282
285
|
["browserbase", probeBrowserbase],
|
|
283
286
|
["upstash", probeUpstash],
|
|
284
287
|
["posthog", probePostHog]
|
|
@@ -287,8 +290,8 @@ var init_probes = __esm({
|
|
|
287
290
|
});
|
|
288
291
|
|
|
289
292
|
// src/cli.ts
|
|
290
|
-
import * as
|
|
291
|
-
import * as
|
|
293
|
+
import * as fs8 from "fs";
|
|
294
|
+
import * as path8 from "path";
|
|
292
295
|
|
|
293
296
|
// src/core/config.ts
|
|
294
297
|
import * as fs from "fs";
|
|
@@ -638,8 +641,8 @@ var anthropicConnector = {
|
|
|
638
641
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
639
642
|
const startDate = startOfMonth.toISOString().split("T")[0];
|
|
640
643
|
const endDate = now.toISOString().split("T")[0];
|
|
641
|
-
const
|
|
642
|
-
const result = await fetchJson(
|
|
644
|
+
const url3 = `https://api.anthropic.com/v1/organizations/usage?start_date=${startDate}&end_date=${endDate}`;
|
|
645
|
+
const result = await fetchJson(url3, {
|
|
643
646
|
headers: {
|
|
644
647
|
"x-api-key": apiKey,
|
|
645
648
|
"anthropic-version": "2023-06-01"
|
|
@@ -681,8 +684,8 @@ var openaiConnector = {
|
|
|
681
684
|
const now = /* @__PURE__ */ new Date();
|
|
682
685
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
683
686
|
const startTime = Math.floor(startOfMonth.getTime() / 1e3);
|
|
684
|
-
const
|
|
685
|
-
const result = await fetchJson(
|
|
687
|
+
const url3 = `https://api.openai.com/v1/organization/costs?start_time=${startTime}`;
|
|
688
|
+
const result = await fetchJson(url3, {
|
|
686
689
|
headers: {
|
|
687
690
|
Authorization: `Bearer ${apiKey}`
|
|
688
691
|
}
|
|
@@ -728,8 +731,8 @@ var vercelConnector = {
|
|
|
728
731
|
async fetchSpend(token, options) {
|
|
729
732
|
const teamId = options?.["teamId"] ?? "";
|
|
730
733
|
const teamParam = teamId ? `?teamId=${teamId}` : "";
|
|
731
|
-
const
|
|
732
|
-
const result = await fetchJson(
|
|
734
|
+
const url3 = `https://api.vercel.com/v2/usage${teamParam}`;
|
|
735
|
+
const result = await fetchJson(url3, {
|
|
733
736
|
headers: {
|
|
734
737
|
Authorization: `Bearer ${token}`
|
|
735
738
|
}
|
|
@@ -767,8 +770,8 @@ init_base();
|
|
|
767
770
|
var scrapflyConnector = {
|
|
768
771
|
serviceId: "scrapfly",
|
|
769
772
|
async fetchSpend(apiKey) {
|
|
770
|
-
const
|
|
771
|
-
const result = await fetchJson(
|
|
773
|
+
const url3 = `https://api.scrapfly.io/account?key=${apiKey}`;
|
|
774
|
+
const result = await fetchJson(url3);
|
|
772
775
|
if (!result.ok || !result.data) {
|
|
773
776
|
return {
|
|
774
777
|
serviceId: "scrapfly",
|
|
@@ -812,6 +815,15 @@ init_base();
|
|
|
812
815
|
var supabaseConnector = {
|
|
813
816
|
serviceId: "supabase",
|
|
814
817
|
async fetchSpend(token) {
|
|
818
|
+
if (token.startsWith("eyJ")) {
|
|
819
|
+
return {
|
|
820
|
+
serviceId: "supabase",
|
|
821
|
+
spend: 0,
|
|
822
|
+
isEstimate: true,
|
|
823
|
+
tier: "est",
|
|
824
|
+
error: "Key is a service_role JWT \u2014 needs a Personal Access Token (sbp_*) from supabase.com/dashboard \u2192 Account \u2192 Access Tokens"
|
|
825
|
+
};
|
|
826
|
+
}
|
|
815
827
|
const orgsResult = await fetchJson("https://api.supabase.com/v1/organizations", {
|
|
816
828
|
headers: {
|
|
817
829
|
Authorization: `Bearer ${token}`
|
|
@@ -928,9 +940,8 @@ var browserbaseConnector = {
|
|
|
928
940
|
return {
|
|
929
941
|
serviceId: "browserbase",
|
|
930
942
|
spend,
|
|
931
|
-
isEstimate:
|
|
932
|
-
|
|
933
|
-
tier: "est",
|
|
943
|
+
isEstimate: false,
|
|
944
|
+
tier: "live",
|
|
934
945
|
unitsUsed: sessionCount,
|
|
935
946
|
unitName: "sessions",
|
|
936
947
|
raw: {
|
|
@@ -959,76 +970,79 @@ async function pollService(tracked) {
|
|
|
959
970
|
const connector = connectors.get(tracked.serviceId);
|
|
960
971
|
const definition = getService(tracked.serviceId);
|
|
961
972
|
let effectivePlanCost = tracked.planCost;
|
|
962
|
-
|
|
973
|
+
let isFlatPlan = false;
|
|
974
|
+
if (tracked.planName && definition?.plans) {
|
|
963
975
|
const matchedPlan = definition.plans.find(
|
|
964
976
|
(p) => p.name === tracked.planName || p.name.toLowerCase().includes((tracked.planName ?? "").toLowerCase())
|
|
965
977
|
);
|
|
966
|
-
if (matchedPlan
|
|
967
|
-
|
|
978
|
+
if (matchedPlan) {
|
|
979
|
+
isFlatPlan = matchedPlan.type === "flat";
|
|
980
|
+
if (effectivePlanCost === void 0 && matchedPlan.monthlyBase !== void 0) {
|
|
981
|
+
effectivePlanCost = matchedPlan.monthlyBase;
|
|
982
|
+
}
|
|
968
983
|
}
|
|
969
984
|
}
|
|
985
|
+
const calcSpend = (cost) => {
|
|
986
|
+
if (isFlatPlan) return cost;
|
|
987
|
+
const now = /* @__PURE__ */ new Date();
|
|
988
|
+
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
989
|
+
return cost / daysInMonth * now.getDate();
|
|
990
|
+
};
|
|
970
991
|
if (connector && serviceConfig?.apiKey) {
|
|
971
992
|
try {
|
|
972
993
|
const result = await connector.fetchSpend(
|
|
973
994
|
serviceConfig.apiKey,
|
|
974
995
|
serviceConfig
|
|
975
996
|
);
|
|
976
|
-
if (!result.error)
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
997
|
+
if (!result.error) {
|
|
998
|
+
if (isFlatPlan && effectivePlanCost !== void 0) {
|
|
999
|
+
if (result.spend < effectivePlanCost) {
|
|
1000
|
+
result.spend = effectivePlanCost;
|
|
1001
|
+
}
|
|
1002
|
+
result.isEstimate = false;
|
|
1003
|
+
}
|
|
1004
|
+
result.isFlatPlan = isFlatPlan;
|
|
1005
|
+
return result;
|
|
1006
|
+
}
|
|
1007
|
+
const fallbackSpend = effectivePlanCost !== void 0 ? calcSpend(effectivePlanCost) : 0;
|
|
982
1008
|
return {
|
|
983
1009
|
serviceId: tracked.serviceId,
|
|
984
1010
|
spend: fallbackSpend,
|
|
985
|
-
isEstimate: true,
|
|
1011
|
+
isEstimate: isFlatPlan ? false : true,
|
|
986
1012
|
tier: effectivePlanCost !== void 0 ? "calc" : "blind",
|
|
1013
|
+
isFlatPlan,
|
|
987
1014
|
error: `LIVE failed (${result.error}) \u2014 showing ${effectivePlanCost !== void 0 ? "CALC" : "BLIND"} fallback`
|
|
988
1015
|
};
|
|
989
1016
|
} catch (err) {
|
|
990
|
-
const fallbackSpend = effectivePlanCost !== void 0 ? (
|
|
991
|
-
const now = /* @__PURE__ */ new Date();
|
|
992
|
-
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
993
|
-
return effectivePlanCost / daysInMonth * now.getDate();
|
|
994
|
-
})() : 0;
|
|
1017
|
+
const fallbackSpend = effectivePlanCost !== void 0 ? calcSpend(effectivePlanCost) : 0;
|
|
995
1018
|
return {
|
|
996
1019
|
serviceId: tracked.serviceId,
|
|
997
1020
|
spend: fallbackSpend,
|
|
998
|
-
isEstimate: true,
|
|
1021
|
+
isEstimate: isFlatPlan ? false : true,
|
|
999
1022
|
tier: effectivePlanCost !== void 0 ? "calc" : "blind",
|
|
1023
|
+
isFlatPlan,
|
|
1000
1024
|
error: `LIVE failed (${err instanceof Error ? err.message : "unknown"}) \u2014 showing ${effectivePlanCost !== void 0 ? "CALC" : "BLIND"} fallback`
|
|
1001
1025
|
};
|
|
1002
1026
|
}
|
|
1003
1027
|
}
|
|
1004
1028
|
if (connector && tracked.hasApiKey && !serviceConfig?.apiKey) {
|
|
1005
|
-
const projectedSpend = effectivePlanCost !== void 0 ? (
|
|
1006
|
-
const now = /* @__PURE__ */ new Date();
|
|
1007
|
-
const daysInMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0).getDate();
|
|
1008
|
-
return effectivePlanCost / daysInMonth * now.getDate();
|
|
1009
|
-
})() : 0;
|
|
1029
|
+
const projectedSpend = effectivePlanCost !== void 0 ? calcSpend(effectivePlanCost) : 0;
|
|
1010
1030
|
return {
|
|
1011
1031
|
serviceId: tracked.serviceId,
|
|
1012
1032
|
spend: projectedSpend,
|
|
1013
|
-
isEstimate: true,
|
|
1033
|
+
isEstimate: isFlatPlan ? false : true,
|
|
1014
1034
|
tier: effectivePlanCost !== void 0 ? "calc" : "blind",
|
|
1035
|
+
isFlatPlan,
|
|
1015
1036
|
error: "API key marked as configured but not found in ~/.config/burnwatch/ \u2014 re-run configure with --key"
|
|
1016
1037
|
};
|
|
1017
1038
|
}
|
|
1018
1039
|
if (effectivePlanCost !== void 0) {
|
|
1019
|
-
const now = /* @__PURE__ */ new Date();
|
|
1020
|
-
const daysInMonth = new Date(
|
|
1021
|
-
now.getFullYear(),
|
|
1022
|
-
now.getMonth() + 1,
|
|
1023
|
-
0
|
|
1024
|
-
).getDate();
|
|
1025
|
-
const dayOfMonth = now.getDate();
|
|
1026
|
-
const projectedSpend = effectivePlanCost / daysInMonth * dayOfMonth;
|
|
1027
1040
|
return {
|
|
1028
1041
|
serviceId: tracked.serviceId,
|
|
1029
|
-
spend:
|
|
1030
|
-
isEstimate:
|
|
1031
|
-
tier: "calc"
|
|
1042
|
+
spend: calcSpend(effectivePlanCost),
|
|
1043
|
+
isEstimate: !isFlatPlan,
|
|
1044
|
+
tier: "calc",
|
|
1045
|
+
isFlatPlan
|
|
1032
1046
|
};
|
|
1033
1047
|
}
|
|
1034
1048
|
if (definition) {
|
|
@@ -1072,52 +1086,37 @@ var CONFIDENCE_BADGES = {
|
|
|
1072
1086
|
// src/core/brief.ts
|
|
1073
1087
|
function formatBrief(brief) {
|
|
1074
1088
|
const lines = [];
|
|
1075
|
-
const
|
|
1076
|
-
const
|
|
1077
|
-
|
|
1078
|
-
lines.push(`\
|
|
1079
|
-
lines.push(
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
) + "\u2551"
|
|
1083
|
-
);
|
|
1084
|
-
lines.push(`\u2560${hrDouble}\u2563`);
|
|
1085
|
-
lines.push(
|
|
1086
|
-
formatRow("Service", "Spend", "Conf", "Budget", "Left", width)
|
|
1087
|
-
);
|
|
1088
|
-
lines.push(`\u2551 ${hrSingle} \u2551`);
|
|
1089
|
+
const hr = "\u2550".repeat(62);
|
|
1090
|
+
const hrThin = "\u2500".repeat(58);
|
|
1091
|
+
lines.push(`\u2554${hr}`);
|
|
1092
|
+
lines.push(`\u2551 BURNWATCH \u2014 ${brief.projectName} \u2014 ${brief.period}`);
|
|
1093
|
+
lines.push(`\u2560${hr}`);
|
|
1094
|
+
lines.push(formatRow("Service", "Spend", "Conf", "Budget", "Left"));
|
|
1095
|
+
lines.push(`\u2551 ${hrThin}`);
|
|
1089
1096
|
for (const svc of brief.services.filter((s) => s.tier !== "excluded")) {
|
|
1090
1097
|
const spendStr = svc.tier === "blind" && svc.spend === 0 ? "\u2014" : svc.isEstimate ? `~$${svc.spend.toFixed(2)}` : `$${svc.spend.toFixed(2)}`;
|
|
1091
1098
|
const badge = CONFIDENCE_BADGES[svc.tier];
|
|
1092
1099
|
const budgetStr = svc.budget ? `$${svc.budget}` : "\u2014";
|
|
1093
1100
|
const leftStr = formatLeft(svc);
|
|
1094
|
-
lines.push(formatRow(svc.serviceId, spendStr, badge, budgetStr, leftStr
|
|
1101
|
+
lines.push(formatRow(svc.serviceId, spendStr, badge, budgetStr, leftStr));
|
|
1095
1102
|
if (svc.allowance) {
|
|
1096
1103
|
const usedStr = formatCompact(svc.allowance.used);
|
|
1097
1104
|
const totalStr2 = formatCompact(svc.allowance.included);
|
|
1098
1105
|
const pctStr = svc.allowance.percent.toFixed(0);
|
|
1099
1106
|
const warn = svc.allowance.percent >= 75 ? " \u26A0\uFE0F" : "";
|
|
1100
|
-
lines.push(
|
|
1101
|
-
`\u2551 \u21B3 ${usedStr}/${totalStr2} ${svc.allowance.unitName} (${pctStr}%)${warn}`.padEnd(width + 1) + "\u2551"
|
|
1102
|
-
);
|
|
1107
|
+
lines.push(`\u2551 \u21B3 ${usedStr}/${totalStr2} ${svc.allowance.unitName} (${pctStr}%)${warn}`);
|
|
1103
1108
|
}
|
|
1104
1109
|
}
|
|
1105
|
-
lines.push(`\u2560${
|
|
1110
|
+
lines.push(`\u2560${hr}`);
|
|
1106
1111
|
const totalStr = brief.totalIsEstimate ? `~$${brief.totalSpend.toFixed(2)}` : `$${brief.totalSpend.toFixed(2)}`;
|
|
1107
1112
|
const marginStr = brief.estimateMargin > 0 ? ` Est margin: \xB1$${brief.estimateMargin.toFixed(0)}` : "";
|
|
1108
1113
|
const untrackedStr = brief.untrackedCount > 0 ? `No billing data: ${brief.untrackedCount} \u26A0\uFE0F` : `All tracked \u2705`;
|
|
1109
|
-
lines.push(
|
|
1110
|
-
`\u2551 TOTAL: ${totalStr} ${untrackedStr}${marginStr}`.padEnd(
|
|
1111
|
-
width + 1
|
|
1112
|
-
) + "\u2551"
|
|
1113
|
-
);
|
|
1114
|
+
lines.push(`\u2551 TOTAL: ${totalStr} ${untrackedStr}${marginStr}`);
|
|
1114
1115
|
for (const alert of brief.alerts) {
|
|
1115
1116
|
const icon = alert.severity === "critical" ? "\u{1F6A8}" : "\u26A0\uFE0F";
|
|
1116
|
-
lines.push(
|
|
1117
|
-
`\u2551 ${icon} ${alert.message}`.padEnd(width + 1) + "\u2551"
|
|
1118
|
-
);
|
|
1117
|
+
lines.push(`\u2551 ${icon} ${alert.message}`);
|
|
1119
1118
|
}
|
|
1120
|
-
lines.push(`\u255A${
|
|
1119
|
+
lines.push(`\u255A${hr}`);
|
|
1121
1120
|
return lines.join("\n");
|
|
1122
1121
|
}
|
|
1123
1122
|
function buildBrief(projectName, snapshots, blindCount) {
|
|
@@ -1144,12 +1143,14 @@ function buildBrief(projectName, snapshots, blindCount) {
|
|
|
1144
1143
|
severity: "critical"
|
|
1145
1144
|
});
|
|
1146
1145
|
} else if (snap.status === "caution" && snap.budgetPercent && snap.budgetPercent >= 80) {
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1146
|
+
if (!(snap.isFlatPlan && snap.budgetPercent >= 99.5 && snap.budgetPercent <= 100.5)) {
|
|
1147
|
+
alerts.push({
|
|
1148
|
+
serviceId: snap.serviceId,
|
|
1149
|
+
type: "near_budget",
|
|
1150
|
+
message: `${snap.serviceId} at ${snap.budgetPercent.toFixed(0)}% of budget`,
|
|
1151
|
+
severity: "warning"
|
|
1152
|
+
});
|
|
1153
|
+
}
|
|
1153
1154
|
}
|
|
1154
1155
|
}
|
|
1155
1156
|
if (blindCount > 0) {
|
|
@@ -1172,30 +1173,32 @@ function buildBrief(projectName, snapshots, blindCount) {
|
|
|
1172
1173
|
alerts
|
|
1173
1174
|
};
|
|
1174
1175
|
}
|
|
1175
|
-
function formatRow(service, spend, conf, budget, left
|
|
1176
|
-
|
|
1177
|
-
return `\u2551${row}`.padEnd(width + 1) + "\u2551";
|
|
1176
|
+
function formatRow(service, spend, conf, budget, left) {
|
|
1177
|
+
return `\u2551 ${service.padEnd(14)} ${spend.padEnd(11)} ${conf.padEnd(9)} ${budget.padEnd(7)} ${left}`;
|
|
1178
1178
|
}
|
|
1179
1179
|
function formatLeft(snap) {
|
|
1180
1180
|
if (!snap.budget) return "\u2014";
|
|
1181
|
-
if (snap.status === "over") return "\u26A0\uFE0F
|
|
1181
|
+
if (snap.status === "over") return "\u26A0\uFE0F OVER";
|
|
1182
1182
|
if (snap.budgetPercent !== void 0) {
|
|
1183
1183
|
const remaining = 100 - snap.budgetPercent;
|
|
1184
1184
|
return `${remaining.toFixed(0)}%`;
|
|
1185
1185
|
}
|
|
1186
1186
|
return "\u2014";
|
|
1187
1187
|
}
|
|
1188
|
-
function buildSnapshot(serviceId, tier, spend, budget, allowanceData) {
|
|
1188
|
+
function buildSnapshot(serviceId, tier, spend, budget, allowanceData, isEstimateOverride, isFlatPlan) {
|
|
1189
1189
|
if (isNaN(spend) || !isFinite(spend)) spend = 0;
|
|
1190
1190
|
if (budget !== void 0 && (isNaN(budget) || !isFinite(budget))) budget = void 0;
|
|
1191
|
-
const isEstimate = tier === "est" || tier === "calc";
|
|
1191
|
+
const isEstimate = isEstimateOverride ?? (tier === "est" || tier === "calc");
|
|
1192
1192
|
const budgetPercent = budget && budget > 0 ? spend / budget * 100 : void 0;
|
|
1193
1193
|
let status = "unknown";
|
|
1194
1194
|
let statusLabel = tier === "blind" ? "needs API key" : "no budget";
|
|
1195
1195
|
if (budget) {
|
|
1196
1196
|
if (budgetPercent > 100) {
|
|
1197
1197
|
status = "over";
|
|
1198
|
-
statusLabel =
|
|
1198
|
+
statusLabel = `${budgetPercent.toFixed(0)}% over`;
|
|
1199
|
+
} else if (isFlatPlan && budgetPercent >= 99.5) {
|
|
1200
|
+
status = "healthy";
|
|
1201
|
+
statusLabel = "flat \u2014 on plan";
|
|
1199
1202
|
} else if (budgetPercent >= 75) {
|
|
1200
1203
|
status = "caution";
|
|
1201
1204
|
statusLabel = `${(100 - budgetPercent).toFixed(0)}% \u2014 caution`;
|
|
@@ -1231,6 +1234,7 @@ function buildSnapshot(serviceId, tier, spend, budget, allowanceData) {
|
|
|
1231
1234
|
budgetPercent,
|
|
1232
1235
|
status,
|
|
1233
1236
|
statusLabel,
|
|
1237
|
+
isFlatPlan,
|
|
1234
1238
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1235
1239
|
allowance
|
|
1236
1240
|
};
|
|
@@ -1324,7 +1328,6 @@ var API_KEY_HINTS = {
|
|
|
1324
1328
|
openai: "Org key: platform.openai.com -> Settings -> API Keys",
|
|
1325
1329
|
vercel: "Token: vercel.com/account/tokens",
|
|
1326
1330
|
supabase: "Service role key: supabase.com/dashboard -> Settings -> API",
|
|
1327
|
-
stripe: "Secret key: dashboard.stripe.com -> Developers -> API Keys",
|
|
1328
1331
|
scrapfly: "API key: scrapfly.io/dashboard"
|
|
1329
1332
|
};
|
|
1330
1333
|
function classifyRisk(service) {
|
|
@@ -1346,9 +1349,9 @@ function groupByRisk(detected) {
|
|
|
1346
1349
|
return groups;
|
|
1347
1350
|
}
|
|
1348
1351
|
function ask(rl, question) {
|
|
1349
|
-
return new Promise((
|
|
1352
|
+
return new Promise((resolve4) => {
|
|
1350
1353
|
rl.question(question, (answer) => {
|
|
1351
|
-
|
|
1354
|
+
resolve4(answer.trim());
|
|
1352
1355
|
});
|
|
1353
1356
|
});
|
|
1354
1357
|
}
|
|
@@ -1648,10 +1651,41 @@ async function runInteractiveInit(detected) {
|
|
|
1648
1651
|
}
|
|
1649
1652
|
|
|
1650
1653
|
// src/utilization.ts
|
|
1651
|
-
import * as
|
|
1652
|
-
import * as
|
|
1654
|
+
import * as fs7 from "fs";
|
|
1655
|
+
import * as path7 from "path";
|
|
1653
1656
|
|
|
1654
1657
|
// src/cost-impact.ts
|
|
1658
|
+
import * as fs6 from "fs";
|
|
1659
|
+
import * as path6 from "path";
|
|
1660
|
+
import * as url2 from "url";
|
|
1661
|
+
var __dirname2 = path6.dirname(url2.fileURLToPath(import.meta.url));
|
|
1662
|
+
var manifestCache = null;
|
|
1663
|
+
function loadBillingManifests() {
|
|
1664
|
+
if (manifestCache) return manifestCache;
|
|
1665
|
+
manifestCache = /* @__PURE__ */ new Map();
|
|
1666
|
+
const candidates = [
|
|
1667
|
+
path6.resolve(__dirname2, "../billing"),
|
|
1668
|
+
// from src/ during dev
|
|
1669
|
+
path6.resolve(__dirname2, "../../billing")
|
|
1670
|
+
// from dist/
|
|
1671
|
+
];
|
|
1672
|
+
for (const dir of candidates) {
|
|
1673
|
+
if (!fs6.existsSync(dir)) continue;
|
|
1674
|
+
const files = fs6.readdirSync(dir).filter((f) => f.endsWith(".json") && f !== "billing.schema.json");
|
|
1675
|
+
for (const file of files) {
|
|
1676
|
+
try {
|
|
1677
|
+
const raw = fs6.readFileSync(path6.join(dir, file), "utf-8");
|
|
1678
|
+
const manifest = JSON.parse(raw);
|
|
1679
|
+
if (manifest.serviceId) {
|
|
1680
|
+
manifestCache.set(manifest.serviceId, manifest);
|
|
1681
|
+
}
|
|
1682
|
+
} catch {
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
break;
|
|
1686
|
+
}
|
|
1687
|
+
return manifestCache;
|
|
1688
|
+
}
|
|
1655
1689
|
var SERVICE_CALL_PATTERNS = {
|
|
1656
1690
|
anthropic: [
|
|
1657
1691
|
/\.messages\.create\s*\(/g,
|
|
@@ -1697,11 +1731,6 @@ var SERVICE_CALL_PATTERNS = {
|
|
|
1697
1731
|
/resend\.emails\.send\s*\(/g,
|
|
1698
1732
|
/\.emails\.send\s*\(/g
|
|
1699
1733
|
],
|
|
1700
|
-
stripe: [
|
|
1701
|
-
/stripe\.charges\.create\s*\(/g,
|
|
1702
|
-
/stripe\.paymentIntents\.create\s*\(/g,
|
|
1703
|
-
/stripe\.checkout\.sessions\.create\s*\(/g
|
|
1704
|
-
],
|
|
1705
1734
|
supabase: [
|
|
1706
1735
|
/supabase\.from\s*\(/g,
|
|
1707
1736
|
/\.rpc\s*\(/g,
|
|
@@ -1715,11 +1744,6 @@ var SERVICE_CALL_PATTERNS = {
|
|
|
1715
1744
|
/posthog\.capture\s*\(/g,
|
|
1716
1745
|
/\.capture\s*\(/g
|
|
1717
1746
|
],
|
|
1718
|
-
aws: [
|
|
1719
|
-
/\.send\s*\(new\s+\w+Command/g,
|
|
1720
|
-
/s3Client\.send\s*\(/g,
|
|
1721
|
-
/lambdaClient\.send\s*\(/g
|
|
1722
|
-
],
|
|
1723
1747
|
firebase: [
|
|
1724
1748
|
/firestore\.\w+\(\s*["']/g,
|
|
1725
1749
|
/\.collection\s*\(/g,
|
|
@@ -1749,40 +1773,134 @@ var SERVICE_CALL_PATTERNS = {
|
|
|
1749
1773
|
replicate: [
|
|
1750
1774
|
/replicate\.run\s*\(/g,
|
|
1751
1775
|
/replicate\.predictions\.create\s*\(/g
|
|
1776
|
+
],
|
|
1777
|
+
vercel: [
|
|
1778
|
+
/\.functions\./g,
|
|
1779
|
+
/edge\s+function/gi
|
|
1752
1780
|
]
|
|
1753
1781
|
};
|
|
1782
|
+
function extractNumericContext(content) {
|
|
1783
|
+
const ctx = /* @__PURE__ */ new Map();
|
|
1784
|
+
const assignRegex = /(?:const|let|var)\s+(\w+)\s*=\s*(\d+)\s*[;,\n]/g;
|
|
1785
|
+
let m;
|
|
1786
|
+
while ((m = assignRegex.exec(content)) !== null) {
|
|
1787
|
+
ctx.set(m[1], parseInt(m[2], 10));
|
|
1788
|
+
}
|
|
1789
|
+
const arrayCtorRegex = /(?:const|let|var)\s+(\w+)\s*=\s*(?:new\s+)?Array\s*\(\s*(\d+)\s*\)/g;
|
|
1790
|
+
while ((m = arrayCtorRegex.exec(content)) !== null) {
|
|
1791
|
+
ctx.set(m[1], parseInt(m[2], 10));
|
|
1792
|
+
}
|
|
1793
|
+
const arrayLitRegex = /(?:const|let|var)\s+(\w+)\s*=\s*\[([^\]]*)\]/g;
|
|
1794
|
+
while ((m = arrayLitRegex.exec(content)) !== null) {
|
|
1795
|
+
const elements = m[2].split(",").filter((e) => e.trim().length > 0);
|
|
1796
|
+
if (elements.length > 1) {
|
|
1797
|
+
ctx.set(m[1], elements.length);
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
return ctx;
|
|
1801
|
+
}
|
|
1802
|
+
function resolveLoopBound(varName, content, ctx) {
|
|
1803
|
+
const num = parseInt(varName, 10);
|
|
1804
|
+
if (!isNaN(num) && num > 0) return num;
|
|
1805
|
+
if (ctx.has(varName)) return ctx.get(varName);
|
|
1806
|
+
const lengthMatch = varName.match(/^(\w+)\.length$/);
|
|
1807
|
+
if (lengthMatch && ctx.has(lengthMatch[1])) {
|
|
1808
|
+
return ctx.get(lengthMatch[1]);
|
|
1809
|
+
}
|
|
1810
|
+
return null;
|
|
1811
|
+
}
|
|
1812
|
+
function resolveIterableSize(content, ctx) {
|
|
1813
|
+
const forOfMatch = content.match(/for\s*\(\s*(?:const|let|var)\s+\w+\s+of\s+(\w+)/);
|
|
1814
|
+
if (forOfMatch) {
|
|
1815
|
+
const resolved = ctx.get(forOfMatch[1]);
|
|
1816
|
+
if (resolved) return resolved;
|
|
1817
|
+
}
|
|
1818
|
+
const iterMatch = content.match(/(\w+)\s*\.\s*(?:map|forEach)\s*\(/);
|
|
1819
|
+
if (iterMatch) {
|
|
1820
|
+
const resolved = ctx.get(iterMatch[1]);
|
|
1821
|
+
if (resolved) return resolved;
|
|
1822
|
+
}
|
|
1823
|
+
return null;
|
|
1824
|
+
}
|
|
1754
1825
|
function detectMultipliers(content) {
|
|
1755
1826
|
const multipliers = [];
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1827
|
+
const ctx = extractNumericContext(content);
|
|
1828
|
+
const forLoopRegex = /for\s*\(.*;\s*\w+\s*(?:<|<=)\s*([\w.]+)/g;
|
|
1829
|
+
const forLoopMatch = forLoopRegex.exec(content);
|
|
1830
|
+
if (forLoopMatch) {
|
|
1831
|
+
const boundExpr = forLoopMatch[1];
|
|
1832
|
+
const resolved = resolveLoopBound(boundExpr, content, ctx);
|
|
1833
|
+
if (resolved && resolved > 1) {
|
|
1834
|
+
multipliers.push({ label: `for loop (${resolved} iterations)`, factor: resolved });
|
|
1763
1835
|
} else {
|
|
1764
|
-
|
|
1836
|
+
const hintMatch = content.match(new RegExp(`(?:const|let|var)\\s+${boundExpr}\\s*=\\s*(\\w+)\\.length`));
|
|
1837
|
+
if (hintMatch) {
|
|
1838
|
+
const arrayName = hintMatch[1];
|
|
1839
|
+
const arraySize = ctx.get(arrayName);
|
|
1840
|
+
if (arraySize && arraySize > 1) {
|
|
1841
|
+
multipliers.push({ label: `for loop (${arraySize} iterations via ${arrayName}.length)`, factor: arraySize });
|
|
1842
|
+
} else {
|
|
1843
|
+
multipliers.push({ label: `for loop (${arrayName}.length \u2014 variable bound)`, factor: 10 });
|
|
1844
|
+
}
|
|
1845
|
+
} else {
|
|
1846
|
+
multipliers.push({ label: "for loop (variable bound)", factor: 10 });
|
|
1847
|
+
}
|
|
1765
1848
|
}
|
|
1766
1849
|
}
|
|
1767
1850
|
if (/\.\s*map\s*\(\s*(async\s*)?\(/g.test(content)) {
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1851
|
+
const size = resolveIterableSize(content, ctx);
|
|
1852
|
+
if (size && size > 1) {
|
|
1853
|
+
multipliers.push({ label: `.map() over ${size} items`, factor: size });
|
|
1854
|
+
} else {
|
|
1855
|
+
multipliers.push({ label: ".map() iteration", factor: 10 });
|
|
1856
|
+
}
|
|
1857
|
+
} else if (/\.\s*forEach\s*\(\s*(async\s*)?\(/g.test(content)) {
|
|
1858
|
+
const size = resolveIterableSize(content, ctx);
|
|
1859
|
+
if (size && size > 1) {
|
|
1860
|
+
multipliers.push({ label: `.forEach() over ${size} items`, factor: size });
|
|
1861
|
+
} else {
|
|
1862
|
+
multipliers.push({ label: ".forEach() iteration", factor: 10 });
|
|
1863
|
+
}
|
|
1772
1864
|
}
|
|
1773
1865
|
if (/for\s*\(\s*(const|let|var)\s+\w+\s+(of|in)\s+/g.test(content)) {
|
|
1774
|
-
|
|
1866
|
+
const size = resolveIterableSize(content, ctx);
|
|
1867
|
+
if (size && size > 1) {
|
|
1868
|
+
multipliers.push({ label: `for...of over ${size} items`, factor: size });
|
|
1869
|
+
} else {
|
|
1870
|
+
multipliers.push({ label: "for...of/in loop", factor: 10 });
|
|
1871
|
+
}
|
|
1775
1872
|
}
|
|
1776
1873
|
if (/Promise\.all\s*\(/g.test(content)) {
|
|
1777
|
-
multipliers.
|
|
1874
|
+
const hasMap = multipliers.some((m) => m.label.includes(".map()"));
|
|
1875
|
+
if (!hasMap) {
|
|
1876
|
+
multipliers.push({ label: "Promise.all (parallel batch)", factor: 10 });
|
|
1877
|
+
}
|
|
1778
1878
|
}
|
|
1779
|
-
if (/cron|schedule|interval|setInterval|every\s
|
|
1879
|
+
if (/cron|schedule|interval|setInterval|every\s+(?:\d+|other)\s*(min|hour|day|sec|week)/gi.test(content)) {
|
|
1780
1880
|
if (/every\s+5\s*min/gi.test(content) || /\*\/5\s+\*\s+\*/g.test(content)) {
|
|
1781
1881
|
multipliers.push({ label: "cron: every 5 minutes", factor: 8640 });
|
|
1882
|
+
} else if (/every\s+15\s*min/gi.test(content) || /\*\/15\s+\*\s+\*/g.test(content)) {
|
|
1883
|
+
multipliers.push({ label: "cron: every 15 minutes", factor: 2880 });
|
|
1884
|
+
} else if (/every\s+30\s*min/gi.test(content) || /\*\/30\s+\*\s+\*/g.test(content)) {
|
|
1885
|
+
multipliers.push({ label: "cron: every 30 minutes", factor: 1440 });
|
|
1782
1886
|
} else if (/every\s+1?\s*hour/gi.test(content) || /0\s+\*\s+\*\s+\*/g.test(content)) {
|
|
1783
1887
|
multipliers.push({ label: "cron: hourly", factor: 720 });
|
|
1888
|
+
} else if (/every\s+(\d+)\s*hours?/gi.test(content)) {
|
|
1889
|
+
const hoursMatch = content.match(/every\s+(\d+)\s*hours?/i);
|
|
1890
|
+
const hours = parseInt(hoursMatch[1], 10);
|
|
1891
|
+
const factor = Math.round(720 / hours);
|
|
1892
|
+
multipliers.push({ label: `cron: every ${hours} hours`, factor });
|
|
1893
|
+
} else if (/every\s+other\s+day|every\s+2\s*days?/gi.test(content) || /\*\/2\s+/g.test(content)) {
|
|
1894
|
+
multipliers.push({ label: "cron: every other day", factor: 15 });
|
|
1895
|
+
} else if (/every\s+(\d+)\s*days?/gi.test(content)) {
|
|
1896
|
+
const daysMatch = content.match(/every\s+(\d+)\s*days?/i);
|
|
1897
|
+
const days = parseInt(daysMatch[1], 10);
|
|
1898
|
+
const factor = Math.round(30 / days);
|
|
1899
|
+
multipliers.push({ label: `cron: every ${days} days`, factor });
|
|
1784
1900
|
} else if (/every\s+1?\s*day/gi.test(content) || /0\s+0\s+\*\s+\*/g.test(content)) {
|
|
1785
1901
|
multipliers.push({ label: "cron: daily", factor: 30 });
|
|
1902
|
+
} else if (/every\s+1?\s*week/gi.test(content) || /0\s+0\s+\*\s+\*\s+0/g.test(content)) {
|
|
1903
|
+
multipliers.push({ label: "cron: weekly", factor: 4 });
|
|
1786
1904
|
} else {
|
|
1787
1905
|
multipliers.push({ label: "scheduled execution", factor: 30 });
|
|
1788
1906
|
}
|
|
@@ -1794,40 +1912,92 @@ function detectMultipliers(content) {
|
|
|
1794
1912
|
multipliers.push({ label: `batch size: ${batchSize}`, factor: batchSize });
|
|
1795
1913
|
}
|
|
1796
1914
|
}
|
|
1915
|
+
for (const [name, value] of ctx) {
|
|
1916
|
+
if (value >= 100 && /^(total|count|num|max|limit|size|pages|items|urls|batch)/i.test(name)) {
|
|
1917
|
+
const alreadyCovered = multipliers.some((m) => m.factor === value);
|
|
1918
|
+
if (!alreadyCovered) {
|
|
1919
|
+
multipliers.push({ label: `${name} = ${value}`, factor: value });
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1797
1923
|
return multipliers;
|
|
1798
1924
|
}
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1925
|
+
function detectVariant(dimension, content) {
|
|
1926
|
+
if (!dimension.variants || dimension.variants.length === 0) return void 0;
|
|
1927
|
+
for (const variant of dimension.variants) {
|
|
1928
|
+
if (!variant.codePatterns) continue;
|
|
1929
|
+
for (const pattern of variant.codePatterns) {
|
|
1930
|
+
try {
|
|
1931
|
+
if (new RegExp(pattern, "i").test(content)) {
|
|
1932
|
+
return variant;
|
|
1933
|
+
}
|
|
1934
|
+
} catch {
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
return dimension.variants.find((v) => v.isDefault);
|
|
1939
|
+
}
|
|
1940
|
+
function detectManifestMultipliers(manifest, content) {
|
|
1941
|
+
if (!manifest.costMultipliers || manifest.costMultipliers.length === 0) {
|
|
1942
|
+
return void 0;
|
|
1943
|
+
}
|
|
1944
|
+
let maxFactor = 1;
|
|
1945
|
+
const activeNames = [];
|
|
1946
|
+
for (const cm of manifest.costMultipliers) {
|
|
1947
|
+
if (!cm.codePatterns) continue;
|
|
1948
|
+
for (const pattern of cm.codePatterns) {
|
|
1949
|
+
try {
|
|
1950
|
+
if (new RegExp(pattern, "i").test(content)) {
|
|
1951
|
+
maxFactor = Math.max(maxFactor, cm.factor);
|
|
1952
|
+
activeNames.push(cm.name);
|
|
1953
|
+
break;
|
|
1954
|
+
}
|
|
1955
|
+
} catch {
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
}
|
|
1959
|
+
if (activeNames.length === 0) return void 0;
|
|
1960
|
+
return {
|
|
1821
1961
|
low: 1,
|
|
1822
|
-
high:
|
|
1823
|
-
explanation: "
|
|
1962
|
+
high: maxFactor,
|
|
1963
|
+
explanation: activeNames.join(", ")
|
|
1964
|
+
};
|
|
1965
|
+
}
|
|
1966
|
+
function computeManifestPerCallCost(manifest, content) {
|
|
1967
|
+
let totalPerCall = 0;
|
|
1968
|
+
const parts = [];
|
|
1969
|
+
const unitsPerCall = manifest.typicalDevUsage?.unitsPerCall ?? 1;
|
|
1970
|
+
for (const dim of manifest.billingDimensions) {
|
|
1971
|
+
const variant = detectVariant(dim, content);
|
|
1972
|
+
const ratePerUnit = variant?.ratePerUnit ?? dim.ratePerUnit;
|
|
1973
|
+
const ratePer = variant?.ratePer ?? dim.ratePer ?? 1;
|
|
1974
|
+
const dimCostPerCall = unitsPerCall / ratePer * ratePerUnit;
|
|
1975
|
+
if (dimCostPerCall > 0) {
|
|
1976
|
+
totalPerCall += dimCostPerCall;
|
|
1977
|
+
const variantLabel = variant?.name ?? dim.name;
|
|
1978
|
+
parts.push(variantLabel);
|
|
1979
|
+
}
|
|
1824
1980
|
}
|
|
1981
|
+
return {
|
|
1982
|
+
perCall: totalPerCall,
|
|
1983
|
+
explanation: parts.length > 0 ? parts.join(" + ") : manifest.name
|
|
1984
|
+
};
|
|
1985
|
+
}
|
|
1986
|
+
var LEGACY_GOTCHA_MULTIPLIERS = {};
|
|
1987
|
+
var LEGACY_CALL_COSTS = {
|
|
1988
|
+
firebase: 1e-4,
|
|
1989
|
+
twilio: 0.01,
|
|
1990
|
+
sendgrid: 1e-3,
|
|
1991
|
+
"mongodb-atlas": 1e-4,
|
|
1992
|
+
clerk: 0,
|
|
1993
|
+
replicate: 0.01
|
|
1825
1994
|
};
|
|
1826
1995
|
function analyzeCostImpact(filePath, content, projectRoot) {
|
|
1827
|
-
if (!/\.(ts|tsx|js|jsx|mjs|cjs)$/.test(filePath)) {
|
|
1996
|
+
if (!/\.(ts|tsx|js|jsx|mjs|cjs|py)$/.test(filePath)) {
|
|
1828
1997
|
return [];
|
|
1829
1998
|
}
|
|
1830
1999
|
const registry = loadRegistry(projectRoot);
|
|
2000
|
+
const manifests = loadBillingManifests();
|
|
1831
2001
|
const impacts = [];
|
|
1832
2002
|
const multipliers = detectMultipliers(content);
|
|
1833
2003
|
for (const [serviceId, patterns] of Object.entries(SERVICE_CALL_PATTERNS)) {
|
|
@@ -1843,32 +2013,48 @@ function analyzeCostImpact(filePath, content, projectRoot) {
|
|
|
1843
2013
|
const service = registry.get(serviceId);
|
|
1844
2014
|
if (!service) continue;
|
|
1845
2015
|
const multiplierFactor = multipliers.length > 0 ? multipliers.reduce((max, m) => Math.max(max, m.factor), 1) : 1;
|
|
1846
|
-
const
|
|
2016
|
+
const manifest = manifests.get(serviceId);
|
|
2017
|
+
let baseMonthlyRuns;
|
|
2018
|
+
const isCron = multipliers.some((m) => m.label.startsWith("cron"));
|
|
2019
|
+
if (isCron) {
|
|
2020
|
+
baseMonthlyRuns = 1;
|
|
2021
|
+
} else if (manifest?.typicalDevUsage?.callsPerDevHour && manifest.typicalDevUsage.callsPerDevHour > 0) {
|
|
2022
|
+
baseMonthlyRuns = 132;
|
|
2023
|
+
} else {
|
|
2024
|
+
baseMonthlyRuns = 50;
|
|
2025
|
+
}
|
|
1847
2026
|
const monthlyInvocations = totalCalls * multiplierFactor * baseMonthlyRuns;
|
|
1848
|
-
const gotcha = GOTCHA_MULTIPLIERS[serviceId];
|
|
1849
|
-
const unitRate = service.pricing?.unitRate ?? 0;
|
|
1850
2027
|
let costLow;
|
|
1851
2028
|
let costHigh;
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
costLow = 0;
|
|
1857
|
-
costHigh = 0;
|
|
1858
|
-
} else {
|
|
1859
|
-
const typicalCallCosts = {
|
|
1860
|
-
anthropic: 3e-3,
|
|
1861
|
-
// ~$3/MTok * ~1K tokens average
|
|
1862
|
-
openai: 2e-3,
|
|
1863
|
-
"google-gemini": 1e-3,
|
|
1864
|
-
scrapfly: 15e-5,
|
|
1865
|
-
browserbase: 0.01,
|
|
1866
|
-
resend: 1e-3,
|
|
1867
|
-
stripe: 0.3
|
|
1868
|
-
};
|
|
1869
|
-
const perCall = typicalCallCosts[serviceId] ?? 1e-3;
|
|
2029
|
+
let rangeExplanation;
|
|
2030
|
+
if (manifest) {
|
|
2031
|
+
const { perCall } = computeManifestPerCallCost(manifest, content);
|
|
2032
|
+
const gotcha = detectManifestMultipliers(manifest, content);
|
|
1870
2033
|
costLow = monthlyInvocations * perCall * (gotcha?.low ?? 1);
|
|
1871
2034
|
costHigh = monthlyInvocations * perCall * (gotcha?.high ?? 1);
|
|
2035
|
+
rangeExplanation = gotcha?.explanation;
|
|
2036
|
+
if (!gotcha && manifest.costMultipliers && manifest.costMultipliers.length > 0) {
|
|
2037
|
+
const maxFactor = Math.max(...manifest.costMultipliers.map((m) => m.factor));
|
|
2038
|
+
if (maxFactor > 1) {
|
|
2039
|
+
costHigh = monthlyInvocations * perCall * maxFactor;
|
|
2040
|
+
rangeExplanation = manifest.costMultipliers.map((m) => m.description).join("; ");
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
} else {
|
|
2044
|
+
const gotcha = LEGACY_GOTCHA_MULTIPLIERS[serviceId];
|
|
2045
|
+
const unitRate = service.pricing?.unitRate ?? 0;
|
|
2046
|
+
if (unitRate > 0) {
|
|
2047
|
+
costLow = monthlyInvocations * unitRate * (gotcha?.low ?? 1);
|
|
2048
|
+
costHigh = monthlyInvocations * unitRate * (gotcha?.high ?? 1);
|
|
2049
|
+
} else if (service.pricing?.monthlyBase !== void 0 && service.pricing.monthlyBase > 0) {
|
|
2050
|
+
costLow = 0;
|
|
2051
|
+
costHigh = 0;
|
|
2052
|
+
} else {
|
|
2053
|
+
const perCall = LEGACY_CALL_COSTS[serviceId] ?? 1e-3;
|
|
2054
|
+
costLow = monthlyInvocations * perCall * (gotcha?.low ?? 1);
|
|
2055
|
+
costHigh = monthlyInvocations * perCall * (gotcha?.high ?? 1);
|
|
2056
|
+
}
|
|
2057
|
+
rangeExplanation = gotcha?.explanation;
|
|
1872
2058
|
}
|
|
1873
2059
|
if (costLow === 0 && costHigh === 0) continue;
|
|
1874
2060
|
impacts.push({
|
|
@@ -1881,7 +2067,7 @@ function analyzeCostImpact(filePath, content, projectRoot) {
|
|
|
1881
2067
|
monthlyInvocations,
|
|
1882
2068
|
costLow,
|
|
1883
2069
|
costHigh,
|
|
1884
|
-
rangeExplanation
|
|
2070
|
+
rangeExplanation
|
|
1885
2071
|
});
|
|
1886
2072
|
}
|
|
1887
2073
|
return impacts;
|
|
@@ -1889,11 +2075,11 @@ function analyzeCostImpact(filePath, content, projectRoot) {
|
|
|
1889
2075
|
|
|
1890
2076
|
// src/utilization.ts
|
|
1891
2077
|
function utilizationModelPath(projectRoot) {
|
|
1892
|
-
return
|
|
2078
|
+
return path7.join(projectDataDir(projectRoot), "utilization.json");
|
|
1893
2079
|
}
|
|
1894
2080
|
function readUtilizationModel(projectRoot) {
|
|
1895
2081
|
try {
|
|
1896
|
-
const raw =
|
|
2082
|
+
const raw = fs7.readFileSync(utilizationModelPath(projectRoot), "utf-8");
|
|
1897
2083
|
return JSON.parse(raw);
|
|
1898
2084
|
} catch {
|
|
1899
2085
|
return {
|
|
@@ -1906,9 +2092,9 @@ function readUtilizationModel(projectRoot) {
|
|
|
1906
2092
|
}
|
|
1907
2093
|
function writeUtilizationModel(model, projectRoot) {
|
|
1908
2094
|
const dir = projectDataDir(projectRoot);
|
|
1909
|
-
|
|
2095
|
+
fs7.mkdirSync(dir, { recursive: true });
|
|
1910
2096
|
model.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1911
|
-
|
|
2097
|
+
fs7.writeFileSync(
|
|
1912
2098
|
utilizationModelPath(projectRoot),
|
|
1913
2099
|
JSON.stringify(model, null, 2) + "\n",
|
|
1914
2100
|
"utf-8"
|
|
@@ -1989,10 +2175,10 @@ function buildUtilizationModel(projectRoot) {
|
|
|
1989
2175
|
const files = findSourceFiles(projectRoot);
|
|
1990
2176
|
for (const file of files) {
|
|
1991
2177
|
try {
|
|
1992
|
-
const content =
|
|
2178
|
+
const content = fs7.readFileSync(file, "utf-8");
|
|
1993
2179
|
const callSites = analyzeFileUtilization(file, content, projectRoot);
|
|
1994
2180
|
if (callSites.length > 0) {
|
|
1995
|
-
const relPath =
|
|
2181
|
+
const relPath = path7.relative(projectRoot, file);
|
|
1996
2182
|
const relativeSites = callSites.map((cs) => ({
|
|
1997
2183
|
...cs,
|
|
1998
2184
|
filePath: relPath
|
|
@@ -2008,21 +2194,21 @@ function findSourceFiles(projectRoot) {
|
|
|
2008
2194
|
const files = [];
|
|
2009
2195
|
const dirsToScan = [];
|
|
2010
2196
|
for (const dir of CODE_DIRS) {
|
|
2011
|
-
const fullPath =
|
|
2012
|
-
if (
|
|
2197
|
+
const fullPath = path7.join(projectRoot, dir);
|
|
2198
|
+
if (fs7.existsSync(fullPath)) {
|
|
2013
2199
|
dirsToScan.push(fullPath);
|
|
2014
2200
|
}
|
|
2015
2201
|
}
|
|
2016
2202
|
try {
|
|
2017
|
-
const entries =
|
|
2203
|
+
const entries = fs7.readdirSync(projectRoot, { withFileTypes: true });
|
|
2018
2204
|
for (const entry of entries) {
|
|
2019
2205
|
if (!entry.isDirectory()) continue;
|
|
2020
2206
|
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist" || entry.name.startsWith(".")) continue;
|
|
2021
|
-
const subPkgPath =
|
|
2022
|
-
if (
|
|
2207
|
+
const subPkgPath = path7.join(projectRoot, entry.name, "package.json");
|
|
2208
|
+
if (fs7.existsSync(subPkgPath)) {
|
|
2023
2209
|
for (const dir of CODE_DIRS) {
|
|
2024
|
-
const fullPath =
|
|
2025
|
-
if (
|
|
2210
|
+
const fullPath = path7.join(projectRoot, entry.name, dir);
|
|
2211
|
+
if (fs7.existsSync(fullPath)) {
|
|
2026
2212
|
dirsToScan.push(fullPath);
|
|
2027
2213
|
}
|
|
2028
2214
|
}
|
|
@@ -2038,10 +2224,10 @@ function findSourceFiles(projectRoot) {
|
|
|
2038
2224
|
function walkDir2(dir, pattern, results, maxDepth = 5) {
|
|
2039
2225
|
if (maxDepth <= 0) return;
|
|
2040
2226
|
try {
|
|
2041
|
-
const entries =
|
|
2227
|
+
const entries = fs7.readdirSync(dir, { withFileTypes: true });
|
|
2042
2228
|
for (const entry of entries) {
|
|
2043
2229
|
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
|
|
2044
|
-
const fullPath =
|
|
2230
|
+
const fullPath = path7.join(dir, entry.name);
|
|
2045
2231
|
if (entry.isDirectory()) {
|
|
2046
2232
|
walkDir2(fullPath, pattern, results, maxDepth - 1);
|
|
2047
2233
|
} else if (pattern.test(entry.name)) {
|
|
@@ -2137,10 +2323,10 @@ async function cmdInit() {
|
|
|
2137
2323
|
const projectRoot = process.cwd();
|
|
2138
2324
|
const nonInteractive = flags.has("--non-interactive") || flags.has("--ni");
|
|
2139
2325
|
const alreadyInitialized = isInitialized(projectRoot);
|
|
2140
|
-
let projectName =
|
|
2326
|
+
let projectName = path8.basename(projectRoot);
|
|
2141
2327
|
try {
|
|
2142
|
-
const pkgPath =
|
|
2143
|
-
const pkg = JSON.parse(
|
|
2328
|
+
const pkgPath = path8.join(projectRoot, "package.json");
|
|
2329
|
+
const pkg = JSON.parse(fs8.readFileSync(pkgPath, "utf-8"));
|
|
2144
2330
|
if (pkg.name) projectName = pkg.name;
|
|
2145
2331
|
} catch {
|
|
2146
2332
|
}
|
|
@@ -2179,8 +2365,8 @@ async function cmdInit() {
|
|
|
2179
2365
|
config.services = result.services;
|
|
2180
2366
|
}
|
|
2181
2367
|
writeProjectConfig(config, projectRoot);
|
|
2182
|
-
const gitignorePath =
|
|
2183
|
-
|
|
2368
|
+
const gitignorePath = path8.join(projectConfigDir(projectRoot), ".gitignore");
|
|
2369
|
+
fs8.writeFileSync(
|
|
2184
2370
|
gitignorePath,
|
|
2185
2371
|
[
|
|
2186
2372
|
"# Burnwatch \u2014 ignore cache and snapshots, keep ledger and config",
|
|
@@ -2224,10 +2410,10 @@ async function cmdInterview() {
|
|
|
2224
2410
|
if (!isInitialized(projectRoot)) {
|
|
2225
2411
|
ensureProjectDirs(projectRoot);
|
|
2226
2412
|
const detected = detectServices(projectRoot);
|
|
2227
|
-
let projectName =
|
|
2413
|
+
let projectName = path8.basename(projectRoot);
|
|
2228
2414
|
try {
|
|
2229
2415
|
const pkg = JSON.parse(
|
|
2230
|
-
|
|
2416
|
+
fs8.readFileSync(path8.join(projectRoot, "package.json"), "utf-8")
|
|
2231
2417
|
);
|
|
2232
2418
|
if (pkg.name) projectName = pkg.name;
|
|
2233
2419
|
} catch {
|
|
@@ -2262,9 +2448,9 @@ async function cmdInterview() {
|
|
|
2262
2448
|
const envFiles = findEnvFiles(projectRoot, 3);
|
|
2263
2449
|
for (const envFilePath of envFiles) {
|
|
2264
2450
|
try {
|
|
2265
|
-
const envContent =
|
|
2451
|
+
const envContent = fs8.readFileSync(envFilePath, "utf-8");
|
|
2266
2452
|
const envKeys = parseEnvKeys(envContent);
|
|
2267
|
-
const envFileName =
|
|
2453
|
+
const envFileName = path8.relative(projectRoot, envFilePath);
|
|
2268
2454
|
for (const pattern of definition.envPatterns) {
|
|
2269
2455
|
if (envKeys.has(pattern)) {
|
|
2270
2456
|
const label = `${pattern} (in ${envFileName})`;
|
|
@@ -2287,7 +2473,7 @@ async function cmdInterview() {
|
|
|
2287
2473
|
const envFile = parts[1];
|
|
2288
2474
|
const envVar = parts[2];
|
|
2289
2475
|
try {
|
|
2290
|
-
const envContent =
|
|
2476
|
+
const envContent = fs8.readFileSync(path8.join(projectRoot, envFile), "utf-8");
|
|
2291
2477
|
const regex = new RegExp(`^(?:export\\s+)?${envVar}\\s*=\\s*(.+)$`, "m");
|
|
2292
2478
|
const match = envContent.match(regex);
|
|
2293
2479
|
if (match?.[1]) apiKey = match[1].trim().replace(/^["']|["']$/g, "");
|
|
@@ -2312,7 +2498,6 @@ async function cmdInterview() {
|
|
|
2312
2498
|
openai: "Admin key from platform.openai.com \u2192 Settings \u2192 API Keys (sk-admin-*)",
|
|
2313
2499
|
vercel: "Token from vercel.com/account/tokens",
|
|
2314
2500
|
supabase: "PAT from supabase.com/dashboard \u2192 Account \u2192 Access Tokens (not service_role key)",
|
|
2315
|
-
stripe: "Secret key from dashboard.stripe.com \u2192 Developers \u2192 API Keys (sk_live_*)",
|
|
2316
2501
|
scrapfly: "API key from scrapfly.io/dashboard",
|
|
2317
2502
|
browserbase: "API key from browserbase.com \u2192 Settings \u2192 API Keys",
|
|
2318
2503
|
upstash: "email:api_key from console.upstash.com \u2192 Account \u2192 Management API",
|
|
@@ -2465,9 +2650,45 @@ async function cmdConfigure() {
|
|
|
2465
2650
|
if (options["plan"]) {
|
|
2466
2651
|
const planSearch = options["plan"].toLowerCase();
|
|
2467
2652
|
const plans = definition?.plans ?? [];
|
|
2468
|
-
const
|
|
2653
|
+
const budgetHint = options["budget"] !== void 0 ? parseFloat(options["budget"]) : void 0;
|
|
2654
|
+
let candidates = plans.filter(
|
|
2469
2655
|
(p) => p.name.toLowerCase().includes(planSearch) || p.name.toLowerCase().split(/[\s(]/)[0] === planSearch
|
|
2470
2656
|
);
|
|
2657
|
+
if (candidates.length === 0) {
|
|
2658
|
+
const stripped = planSearch.replace(/\(\s*\//, "(").replace(/\$\d+/g, "");
|
|
2659
|
+
candidates = plans.filter((p) => {
|
|
2660
|
+
const pStripped = p.name.toLowerCase().replace(/\$\d+/g, "").replace(/\(\s*\//, "(");
|
|
2661
|
+
return pStripped.includes(stripped) || stripped.includes(pStripped.split(/[\s(]/)[0]);
|
|
2662
|
+
});
|
|
2663
|
+
}
|
|
2664
|
+
let matched = candidates[0];
|
|
2665
|
+
if (candidates.length > 1) {
|
|
2666
|
+
let disambiguated = false;
|
|
2667
|
+
const dollarMatch = options["plan"].match(/\$(\d+)/);
|
|
2668
|
+
if (dollarMatch) {
|
|
2669
|
+
const amount = parseInt(dollarMatch[1], 10);
|
|
2670
|
+
const byAmount = candidates.find((p) => p.monthlyBase === amount);
|
|
2671
|
+
if (byAmount) {
|
|
2672
|
+
matched = byAmount;
|
|
2673
|
+
disambiguated = true;
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
if (budgetHint !== void 0 && !isNaN(budgetHint)) {
|
|
2677
|
+
const byBudget = candidates.find((p) => p.monthlyBase === budgetHint);
|
|
2678
|
+
if (byBudget) {
|
|
2679
|
+
matched = byBudget;
|
|
2680
|
+
disambiguated = true;
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
if (!disambiguated) {
|
|
2684
|
+
const numMatch = options["plan"].match(/(\d{2,})/);
|
|
2685
|
+
if (numMatch) {
|
|
2686
|
+
const amount = parseInt(numMatch[1], 10);
|
|
2687
|
+
const byAmount = candidates.find((p) => p.monthlyBase === amount);
|
|
2688
|
+
if (byAmount) matched = byAmount;
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2471
2692
|
if (matched) {
|
|
2472
2693
|
tracked.planName = matched.name;
|
|
2473
2694
|
tracked.excluded = false;
|
|
@@ -2482,42 +2703,12 @@ async function cmdConfigure() {
|
|
|
2482
2703
|
}
|
|
2483
2704
|
}
|
|
2484
2705
|
if (matched.includedUnits !== void 0 && matched.unitName) {
|
|
2485
|
-
tracked.allowance = {
|
|
2486
|
-
included: matched.includedUnits,
|
|
2487
|
-
unitName: matched.unitName
|
|
2488
|
-
};
|
|
2706
|
+
tracked.allowance = { included: matched.includedUnits, unitName: matched.unitName };
|
|
2489
2707
|
} else {
|
|
2490
2708
|
delete tracked.allowance;
|
|
2491
2709
|
}
|
|
2492
2710
|
} else {
|
|
2493
|
-
|
|
2494
|
-
const secondTry = plans.find(
|
|
2495
|
-
(p) => {
|
|
2496
|
-
const pStripped = p.name.toLowerCase().replace(/\$\d+/g, "").replace(/\(\s*\//, "(");
|
|
2497
|
-
return pStripped.includes(stripped) || stripped.includes(pStripped.split(/[\s(]/)[0]);
|
|
2498
|
-
}
|
|
2499
|
-
);
|
|
2500
|
-
if (secondTry) {
|
|
2501
|
-
tracked.planName = secondTry.name;
|
|
2502
|
-
tracked.excluded = false;
|
|
2503
|
-
if (secondTry.type === "flat" && secondTry.monthlyBase !== void 0) {
|
|
2504
|
-
tracked.planCost = secondTry.monthlyBase;
|
|
2505
|
-
if (options["budget"] === void 0 && (tracked.budget === void 0 || tracked.budget === 0)) {
|
|
2506
|
-
tracked.budget = secondTry.monthlyBase;
|
|
2507
|
-
}
|
|
2508
|
-
} else if (secondTry.suggestedBudget !== void 0 && options["budget"] === void 0) {
|
|
2509
|
-
if (tracked.budget === void 0 || tracked.budget === 0) {
|
|
2510
|
-
tracked.budget = secondTry.suggestedBudget;
|
|
2511
|
-
}
|
|
2512
|
-
}
|
|
2513
|
-
if (secondTry.includedUnits !== void 0 && secondTry.unitName) {
|
|
2514
|
-
tracked.allowance = { included: secondTry.includedUnits, unitName: secondTry.unitName };
|
|
2515
|
-
} else {
|
|
2516
|
-
delete tracked.allowance;
|
|
2517
|
-
}
|
|
2518
|
-
} else {
|
|
2519
|
-
tracked.planName = options["plan"];
|
|
2520
|
-
}
|
|
2711
|
+
tracked.planName = options["plan"];
|
|
2521
2712
|
}
|
|
2522
2713
|
}
|
|
2523
2714
|
if (options["budget"] !== void 0) {
|
|
@@ -2669,7 +2860,7 @@ async function cmdStatus() {
|
|
|
2669
2860
|
const snapshots = results.map((r) => {
|
|
2670
2861
|
const tracked = config.services[r.serviceId];
|
|
2671
2862
|
const allowanceData = r.unitsUsed !== void 0 && r.unitsTotal !== void 0 && r.unitName ? { used: r.unitsUsed, included: r.unitsTotal, unitName: r.unitName } : tracked?.allowance ? { used: 0, included: tracked.allowance.included, unitName: tracked.allowance.unitName } : void 0;
|
|
2672
|
-
return buildSnapshot(r.serviceId, r.tier, r.spend, tracked?.budget, allowanceData);
|
|
2863
|
+
return buildSnapshot(r.serviceId, r.tier, r.spend, tracked?.budget, allowanceData, r.isEstimate, r.isFlatPlan);
|
|
2673
2864
|
});
|
|
2674
2865
|
const blindCount = snapshots.filter((s) => s.tier === "blind").length;
|
|
2675
2866
|
const brief = buildBrief(config.projectName, snapshots, blindCount);
|
|
@@ -2779,26 +2970,26 @@ function cmdScan() {
|
|
|
2779
2970
|
}
|
|
2780
2971
|
function cmdReset() {
|
|
2781
2972
|
const projectRoot = process.cwd();
|
|
2782
|
-
const burnwatchDir =
|
|
2783
|
-
const claudeSkillsDir =
|
|
2784
|
-
if (!
|
|
2973
|
+
const burnwatchDir = path8.join(projectRoot, ".burnwatch");
|
|
2974
|
+
const claudeSkillsDir = path8.join(projectRoot, ".claude", "skills");
|
|
2975
|
+
if (!fs8.existsSync(burnwatchDir)) {
|
|
2785
2976
|
console.log("burnwatch is not initialized in this project.");
|
|
2786
2977
|
return;
|
|
2787
2978
|
}
|
|
2788
|
-
|
|
2979
|
+
fs8.rmSync(burnwatchDir, { recursive: true, force: true });
|
|
2789
2980
|
console.log(`\u{1F5D1}\uFE0F Removed ${burnwatchDir}`);
|
|
2790
2981
|
const skillNames = ["setup-burnwatch", "burnwatch-interview", "spend"];
|
|
2791
2982
|
for (const skill of skillNames) {
|
|
2792
|
-
const skillDir =
|
|
2793
|
-
if (
|
|
2794
|
-
|
|
2983
|
+
const skillDir = path8.join(claudeSkillsDir, skill);
|
|
2984
|
+
if (fs8.existsSync(skillDir)) {
|
|
2985
|
+
fs8.rmSync(skillDir, { recursive: true, force: true });
|
|
2795
2986
|
}
|
|
2796
2987
|
}
|
|
2797
2988
|
console.log("\u{1F5D1}\uFE0F Removed burnwatch skills from .claude/skills/");
|
|
2798
|
-
const settingsPath =
|
|
2799
|
-
if (
|
|
2989
|
+
const settingsPath = path8.join(projectRoot, ".claude", "settings.json");
|
|
2990
|
+
if (fs8.existsSync(settingsPath)) {
|
|
2800
2991
|
try {
|
|
2801
|
-
const settings = JSON.parse(
|
|
2992
|
+
const settings = JSON.parse(fs8.readFileSync(settingsPath, "utf-8"));
|
|
2802
2993
|
const hooks = settings["hooks"];
|
|
2803
2994
|
if (hooks) {
|
|
2804
2995
|
for (const [event, hookList] of Object.entries(hooks)) {
|
|
@@ -2808,7 +2999,7 @@ function cmdReset() {
|
|
|
2808
2999
|
if (hooks[event].length === 0) delete hooks[event];
|
|
2809
3000
|
}
|
|
2810
3001
|
if (Object.keys(hooks).length === 0) delete settings["hooks"];
|
|
2811
|
-
|
|
3002
|
+
fs8.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
2812
3003
|
console.log("\u{1F5D1}\uFE0F Removed burnwatch hooks from .claude/settings.json");
|
|
2813
3004
|
}
|
|
2814
3005
|
} catch {
|
|
@@ -2859,23 +3050,23 @@ Examples:
|
|
|
2859
3050
|
}
|
|
2860
3051
|
function cmdVersion() {
|
|
2861
3052
|
try {
|
|
2862
|
-
const pkgPath =
|
|
2863
|
-
|
|
3053
|
+
const pkgPath = path8.resolve(
|
|
3054
|
+
path8.dirname(new URL(import.meta.url).pathname),
|
|
2864
3055
|
"../package.json"
|
|
2865
3056
|
);
|
|
2866
|
-
const pkg = JSON.parse(
|
|
3057
|
+
const pkg = JSON.parse(fs8.readFileSync(pkgPath, "utf-8"));
|
|
2867
3058
|
console.log(`burnwatch v${pkg.version}`);
|
|
2868
3059
|
} catch {
|
|
2869
3060
|
console.log("burnwatch v0.1.0");
|
|
2870
3061
|
}
|
|
2871
3062
|
}
|
|
2872
3063
|
function registerHooks(projectRoot) {
|
|
2873
|
-
const sourceHooksDir =
|
|
2874
|
-
|
|
3064
|
+
const sourceHooksDir = path8.resolve(
|
|
3065
|
+
path8.dirname(new URL(import.meta.url).pathname),
|
|
2875
3066
|
"hooks"
|
|
2876
3067
|
);
|
|
2877
|
-
const localHooksDir =
|
|
2878
|
-
|
|
3068
|
+
const localHooksDir = path8.join(projectRoot, ".burnwatch", "hooks");
|
|
3069
|
+
fs8.mkdirSync(localHooksDir, { recursive: true });
|
|
2879
3070
|
const hookFiles = [
|
|
2880
3071
|
"on-session-start.js",
|
|
2881
3072
|
"on-prompt.js",
|
|
@@ -2883,45 +3074,45 @@ function registerHooks(projectRoot) {
|
|
|
2883
3074
|
"on-stop.js"
|
|
2884
3075
|
];
|
|
2885
3076
|
for (const file of hookFiles) {
|
|
2886
|
-
const src =
|
|
2887
|
-
const dest =
|
|
3077
|
+
const src = path8.join(sourceHooksDir, file);
|
|
3078
|
+
const dest = path8.join(localHooksDir, file);
|
|
2888
3079
|
try {
|
|
2889
|
-
|
|
3080
|
+
fs8.copyFileSync(src, dest);
|
|
2890
3081
|
const mapSrc = src + ".map";
|
|
2891
|
-
if (
|
|
2892
|
-
|
|
3082
|
+
if (fs8.existsSync(mapSrc)) {
|
|
3083
|
+
fs8.copyFileSync(mapSrc, dest + ".map");
|
|
2893
3084
|
}
|
|
2894
3085
|
} catch (err) {
|
|
2895
3086
|
console.error(` Warning: Could not copy hook ${file}: ${err instanceof Error ? err.message : err}`);
|
|
2896
3087
|
}
|
|
2897
3088
|
}
|
|
2898
3089
|
console.log(` Hook scripts copied to ${localHooksDir}`);
|
|
2899
|
-
const sourceSkillsDir =
|
|
2900
|
-
|
|
3090
|
+
const sourceSkillsDir = path8.resolve(
|
|
3091
|
+
path8.dirname(new URL(import.meta.url).pathname),
|
|
2901
3092
|
"../skills"
|
|
2902
3093
|
);
|
|
2903
3094
|
const skillNames = ["setup-burnwatch", "burnwatch-interview", "spend"];
|
|
2904
|
-
const claudeSkillsDir =
|
|
3095
|
+
const claudeSkillsDir = path8.join(projectRoot, ".claude", "skills");
|
|
2905
3096
|
for (const skillName of skillNames) {
|
|
2906
|
-
const srcSkill =
|
|
2907
|
-
const destDir =
|
|
2908
|
-
const destSkill =
|
|
3097
|
+
const srcSkill = path8.join(sourceSkillsDir, skillName, "SKILL.md");
|
|
3098
|
+
const destDir = path8.join(claudeSkillsDir, skillName);
|
|
3099
|
+
const destSkill = path8.join(destDir, "SKILL.md");
|
|
2909
3100
|
try {
|
|
2910
|
-
if (
|
|
2911
|
-
|
|
2912
|
-
|
|
3101
|
+
if (fs8.existsSync(srcSkill)) {
|
|
3102
|
+
fs8.mkdirSync(destDir, { recursive: true });
|
|
3103
|
+
fs8.copyFileSync(srcSkill, destSkill);
|
|
2913
3104
|
}
|
|
2914
3105
|
} catch (err) {
|
|
2915
3106
|
console.error(` Warning: Could not copy skill ${skillName}: ${err instanceof Error ? err.message : err}`);
|
|
2916
3107
|
}
|
|
2917
3108
|
}
|
|
2918
3109
|
console.log(` Skills installed to ${claudeSkillsDir}`);
|
|
2919
|
-
const claudeDir =
|
|
2920
|
-
const settingsPath =
|
|
2921
|
-
|
|
3110
|
+
const claudeDir = path8.join(projectRoot, ".claude");
|
|
3111
|
+
const settingsPath = path8.join(claudeDir, "settings.json");
|
|
3112
|
+
fs8.mkdirSync(claudeDir, { recursive: true });
|
|
2922
3113
|
let settings = {};
|
|
2923
3114
|
try {
|
|
2924
|
-
const existing =
|
|
3115
|
+
const existing = fs8.readFileSync(settingsPath, "utf-8");
|
|
2925
3116
|
settings = JSON.parse(existing);
|
|
2926
3117
|
console.log(` Merging into existing ${settingsPath}`);
|
|
2927
3118
|
} catch {
|
|
@@ -2937,7 +3128,7 @@ function registerHooks(projectRoot) {
|
|
|
2937
3128
|
hooks: [
|
|
2938
3129
|
{
|
|
2939
3130
|
type: "command",
|
|
2940
|
-
command: `node "${
|
|
3131
|
+
command: `node "${path8.join(hooksDir, "on-session-start.js")}"`,
|
|
2941
3132
|
timeout: 15
|
|
2942
3133
|
}
|
|
2943
3134
|
]
|
|
@@ -2950,7 +3141,7 @@ function registerHooks(projectRoot) {
|
|
|
2950
3141
|
hooks: [
|
|
2951
3142
|
{
|
|
2952
3143
|
type: "command",
|
|
2953
|
-
command: `node "${
|
|
3144
|
+
command: `node "${path8.join(hooksDir, "on-prompt.js")}"`,
|
|
2954
3145
|
timeout: 5
|
|
2955
3146
|
}
|
|
2956
3147
|
]
|
|
@@ -2962,7 +3153,7 @@ function registerHooks(projectRoot) {
|
|
|
2962
3153
|
hooks: [
|
|
2963
3154
|
{
|
|
2964
3155
|
type: "command",
|
|
2965
|
-
command: `node "${
|
|
3156
|
+
command: `node "${path8.join(hooksDir, "on-file-change.js")}"`,
|
|
2966
3157
|
timeout: 5
|
|
2967
3158
|
}
|
|
2968
3159
|
]
|
|
@@ -2972,14 +3163,14 @@ function registerHooks(projectRoot) {
|
|
|
2972
3163
|
hooks: [
|
|
2973
3164
|
{
|
|
2974
3165
|
type: "command",
|
|
2975
|
-
command: `node "${
|
|
3166
|
+
command: `node "${path8.join(hooksDir, "on-stop.js")}"`,
|
|
2976
3167
|
timeout: 15,
|
|
2977
3168
|
async: true
|
|
2978
3169
|
}
|
|
2979
3170
|
]
|
|
2980
3171
|
});
|
|
2981
3172
|
settings["hooks"] = hooks;
|
|
2982
|
-
|
|
3173
|
+
fs8.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
|
|
2983
3174
|
console.log(` Hooks registered in ${settingsPath}`);
|
|
2984
3175
|
}
|
|
2985
3176
|
function addHookIfMissing(hookArray, _eventName, hookConfig) {
|