burnwatch 0.12.0 → 0.12.1

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 CHANGED
@@ -5,6 +5,18 @@ All notable changes to burnwatch will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.12.1] - 2026-03-25
9
+
10
+ ### Fixed
11
+
12
+ - **`keysFoundInEnv` always 0 in interview JSON**: The env scanner in `burnwatch interview --json` used a fragile regex (`^KEY=(.+)$`) and hardcoded 3 file names. It failed on `export KEY=value` format, spaces around `=`, and missed `.env` files in subdirectories. Now uses the same recursive `findEnvFiles()` + `parseEnvKeys()` as the detector — proven to work since v0.1.0.
13
+ - **`autoConfigureServices` missed keys in `.env` files**: The `findEnvKey()` helper only checked `process.env`, which is empty in agent contexts. Now also scans `.env*` files on disk, finding keys the same way the detector does.
14
+
15
+ ### Added
16
+
17
+ - **`parseEnvKeys()` utility**: Shared env file parser handles `export` prefix, quoted values, `\r\n` line endings, inline comments, and spaces around `=`. Used by both the detector and interview scanner.
18
+ - **10 new tests** (60 → 70): `parseEnvKeys` edge cases (7 tests), `findEnvFiles` behavior (2 tests), `detectInFileChange` with `export` prefix (1 test).
19
+
8
20
  ## [0.12.0] - 2026-03-25
9
21
 
10
22
  ### Added
@@ -192,6 +204,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
192
204
  - Snapshot system for delta computation across sessions
193
205
  - Claude Code skills: `/spend` (on-demand brief), `/setup-burnwatch` (guided onboarding)
194
206
 
207
+ [0.12.1]: https://github.com/RaleighSF/burnwatch/compare/v0.12.0...v0.12.1
195
208
  [0.12.0]: https://github.com/RaleighSF/burnwatch/compare/v0.11.0...v0.12.0
196
209
  [0.11.0]: https://github.com/RaleighSF/burnwatch/compare/v0.10.0...v0.11.0
197
210
  [0.10.0]: https://github.com/RaleighSF/burnwatch/compare/v0.9.0...v0.10.0
package/dist/cli.js CHANGED
@@ -287,8 +287,8 @@ var init_probes = __esm({
287
287
  });
288
288
 
289
289
  // src/cli.ts
290
- import * as fs5 from "fs";
291
- import * as path5 from "path";
290
+ import * as fs6 from "fs";
291
+ import * as path6 from "path";
292
292
 
293
293
  // src/core/config.ts
294
294
  import * as fs from "fs";
@@ -502,8 +502,7 @@ function collectEnvVars(projectRoot) {
502
502
  for (const envFile of envFiles) {
503
503
  try {
504
504
  const content = fs3.readFileSync(envFile, "utf-8");
505
- const keys = content.split("\n").filter((line) => line.includes("=") && !line.startsWith("#")).map((line) => line.split("=")[0].trim()).filter(Boolean);
506
- for (const key of keys) {
505
+ for (const key of parseEnvKeys(content)) {
507
506
  envVars.add(key);
508
507
  }
509
508
  } catch {
@@ -511,6 +510,19 @@ function collectEnvVars(projectRoot) {
511
510
  }
512
511
  return envVars;
513
512
  }
513
+ function parseEnvKeys(content) {
514
+ const keys = /* @__PURE__ */ new Set();
515
+ for (const line of content.split("\n")) {
516
+ const trimmed = line.trim();
517
+ if (!trimmed || trimmed.startsWith("#")) continue;
518
+ const stripped = trimmed.startsWith("export ") ? trimmed.slice(7).trim() : trimmed;
519
+ const eqIdx = stripped.indexOf("=");
520
+ if (eqIdx > 0) {
521
+ keys.add(stripped.slice(0, eqIdx).trim());
522
+ }
523
+ }
524
+ return keys;
525
+ }
514
526
  function findEnvFiles(dir, maxDepth) {
515
527
  const results = [];
516
528
  if (maxDepth <= 0) return results;
@@ -1267,6 +1279,8 @@ function saveSnapshot(brief, projectRoot) {
1267
1279
 
1268
1280
  // src/interactive-init.ts
1269
1281
  import * as readline from "readline";
1282
+ import * as fs5 from "fs";
1283
+ import "path";
1270
1284
  init_probes();
1271
1285
  function formatUnits(n) {
1272
1286
  if (n >= 1e6) return `${(n / 1e6).toFixed(n % 1e6 === 0 ? 0 : 1)}M`;
@@ -1313,11 +1327,27 @@ function ask(rl, question) {
1313
1327
  });
1314
1328
  });
1315
1329
  }
1316
- function findEnvKey(service) {
1330
+ function findEnvKey(service, projectRoot = process.cwd()) {
1317
1331
  for (const pattern of service.envPatterns) {
1318
1332
  const val = process.env[pattern];
1319
1333
  if (val && val.length > 0) return val;
1320
1334
  }
1335
+ if (projectRoot) {
1336
+ const envFiles = findEnvFiles(projectRoot, 3);
1337
+ for (const filePath of envFiles) {
1338
+ try {
1339
+ const content = fs5.readFileSync(filePath, "utf-8");
1340
+ for (const pattern of service.envPatterns) {
1341
+ const regex = new RegExp(`^(?:export\\s+)?${pattern}\\s*=\\s*(.+)$`, "m");
1342
+ const match = content.match(regex);
1343
+ if (match?.[1]) {
1344
+ return match[1].trim().replace(/^["']|["']$/g, "");
1345
+ }
1346
+ }
1347
+ } catch {
1348
+ }
1349
+ }
1350
+ }
1321
1351
  return void 0;
1322
1352
  }
1323
1353
  async function autoConfigureServices(detected) {
@@ -1646,10 +1676,10 @@ async function cmdInit() {
1646
1676
  const projectRoot = process.cwd();
1647
1677
  const nonInteractive = flags.has("--non-interactive") || flags.has("--ni");
1648
1678
  const alreadyInitialized = isInitialized(projectRoot);
1649
- let projectName = path5.basename(projectRoot);
1679
+ let projectName = path6.basename(projectRoot);
1650
1680
  try {
1651
- const pkgPath = path5.join(projectRoot, "package.json");
1652
- const pkg = JSON.parse(fs5.readFileSync(pkgPath, "utf-8"));
1681
+ const pkgPath = path6.join(projectRoot, "package.json");
1682
+ const pkg = JSON.parse(fs6.readFileSync(pkgPath, "utf-8"));
1653
1683
  if (pkg.name) projectName = pkg.name;
1654
1684
  } catch {
1655
1685
  }
@@ -1688,8 +1718,8 @@ async function cmdInit() {
1688
1718
  config.services = result.services;
1689
1719
  }
1690
1720
  writeProjectConfig(config, projectRoot);
1691
- const gitignorePath = path5.join(projectConfigDir(projectRoot), ".gitignore");
1692
- fs5.writeFileSync(
1721
+ const gitignorePath = path6.join(projectConfigDir(projectRoot), ".gitignore");
1722
+ fs6.writeFileSync(
1693
1723
  gitignorePath,
1694
1724
  [
1695
1725
  "# Burnwatch \u2014 ignore cache and snapshots, keep ledger and config",
@@ -1719,10 +1749,10 @@ async function cmdInterview() {
1719
1749
  if (!isInitialized(projectRoot)) {
1720
1750
  ensureProjectDirs(projectRoot);
1721
1751
  const detected = detectServices(projectRoot);
1722
- let projectName = path5.basename(projectRoot);
1752
+ let projectName = path6.basename(projectRoot);
1723
1753
  try {
1724
1754
  const pkg = JSON.parse(
1725
- fs5.readFileSync(path5.join(projectRoot, "package.json"), "utf-8")
1755
+ fs6.readFileSync(path6.join(projectRoot, "package.json"), "utf-8")
1726
1756
  );
1727
1757
  if (pkg.name) projectName = pkg.name;
1728
1758
  } catch {
@@ -1754,20 +1784,19 @@ async function cmdInterview() {
1754
1784
  if (!keySource && !globalKey) keySource = `env:${pattern}`;
1755
1785
  }
1756
1786
  }
1757
- const envFiles = [".env", ".env.local", ".env.development"];
1758
- for (const envFile of envFiles) {
1787
+ const envFiles = findEnvFiles(projectRoot, 3);
1788
+ for (const envFilePath of envFiles) {
1759
1789
  try {
1760
- const envPath = path5.join(projectRoot, envFile);
1761
- const envContent = fs5.readFileSync(envPath, "utf-8");
1790
+ const envContent = fs6.readFileSync(envFilePath, "utf-8");
1791
+ const envKeys = parseEnvKeys(envContent);
1792
+ const envFileName = path6.relative(projectRoot, envFilePath);
1762
1793
  for (const pattern of definition.envPatterns) {
1763
- const regex = new RegExp(`^${pattern}=(.+)$`, "m");
1764
- const match = envContent.match(regex);
1765
- if (match?.[1]) {
1766
- const label = `${pattern} (in ${envFile})`;
1794
+ if (envKeys.has(pattern)) {
1795
+ const label = `${pattern} (in ${envFileName})`;
1767
1796
  if (!envKeysFound.some((k) => k.startsWith(pattern))) {
1768
1797
  envKeysFound.push(label);
1769
1798
  }
1770
- if (!keySource && !globalKey) keySource = `file:${envFile}:${pattern}`;
1799
+ if (!keySource && !globalKey) keySource = `file:${envFileName}:${pattern}`;
1771
1800
  }
1772
1801
  }
1773
1802
  } catch {
@@ -1783,8 +1812,9 @@ async function cmdInterview() {
1783
1812
  const envFile = parts[1];
1784
1813
  const envVar = parts[2];
1785
1814
  try {
1786
- const envContent = fs5.readFileSync(path5.join(projectRoot, envFile), "utf-8");
1787
- const match = envContent.match(new RegExp(`^${envVar}=(.+)$`, "m"));
1815
+ const envContent = fs6.readFileSync(path6.join(projectRoot, envFile), "utf-8");
1816
+ const regex = new RegExp(`^(?:export\\s+)?${envVar}\\s*=\\s*(.+)$`, "m");
1817
+ const match = envContent.match(regex);
1788
1818
  if (match?.[1]) apiKey = match[1].trim().replace(/^["']|["']$/g, "");
1789
1819
  } catch {
1790
1820
  }
@@ -2184,26 +2214,26 @@ async function cmdReconcile() {
2184
2214
  }
2185
2215
  function cmdReset() {
2186
2216
  const projectRoot = process.cwd();
2187
- const burnwatchDir = path5.join(projectRoot, ".burnwatch");
2188
- const claudeSkillsDir = path5.join(projectRoot, ".claude", "skills");
2189
- if (!fs5.existsSync(burnwatchDir)) {
2217
+ const burnwatchDir = path6.join(projectRoot, ".burnwatch");
2218
+ const claudeSkillsDir = path6.join(projectRoot, ".claude", "skills");
2219
+ if (!fs6.existsSync(burnwatchDir)) {
2190
2220
  console.log("burnwatch is not initialized in this project.");
2191
2221
  return;
2192
2222
  }
2193
- fs5.rmSync(burnwatchDir, { recursive: true, force: true });
2223
+ fs6.rmSync(burnwatchDir, { recursive: true, force: true });
2194
2224
  console.log(`\u{1F5D1}\uFE0F Removed ${burnwatchDir}`);
2195
2225
  const skillNames = ["setup-burnwatch", "burnwatch-interview", "spend"];
2196
2226
  for (const skill of skillNames) {
2197
- const skillDir = path5.join(claudeSkillsDir, skill);
2198
- if (fs5.existsSync(skillDir)) {
2199
- fs5.rmSync(skillDir, { recursive: true, force: true });
2227
+ const skillDir = path6.join(claudeSkillsDir, skill);
2228
+ if (fs6.existsSync(skillDir)) {
2229
+ fs6.rmSync(skillDir, { recursive: true, force: true });
2200
2230
  }
2201
2231
  }
2202
2232
  console.log("\u{1F5D1}\uFE0F Removed burnwatch skills from .claude/skills/");
2203
- const settingsPath = path5.join(projectRoot, ".claude", "settings.json");
2204
- if (fs5.existsSync(settingsPath)) {
2233
+ const settingsPath = path6.join(projectRoot, ".claude", "settings.json");
2234
+ if (fs6.existsSync(settingsPath)) {
2205
2235
  try {
2206
- const settings = JSON.parse(fs5.readFileSync(settingsPath, "utf-8"));
2236
+ const settings = JSON.parse(fs6.readFileSync(settingsPath, "utf-8"));
2207
2237
  const hooks = settings["hooks"];
2208
2238
  if (hooks) {
2209
2239
  for (const [event, hookList] of Object.entries(hooks)) {
@@ -2213,7 +2243,7 @@ function cmdReset() {
2213
2243
  if (hooks[event].length === 0) delete hooks[event];
2214
2244
  }
2215
2245
  if (Object.keys(hooks).length === 0) delete settings["hooks"];
2216
- fs5.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
2246
+ fs6.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
2217
2247
  console.log("\u{1F5D1}\uFE0F Removed burnwatch hooks from .claude/settings.json");
2218
2248
  }
2219
2249
  } catch {
@@ -2263,23 +2293,23 @@ Examples:
2263
2293
  }
2264
2294
  function cmdVersion() {
2265
2295
  try {
2266
- const pkgPath = path5.resolve(
2267
- path5.dirname(new URL(import.meta.url).pathname),
2296
+ const pkgPath = path6.resolve(
2297
+ path6.dirname(new URL(import.meta.url).pathname),
2268
2298
  "../package.json"
2269
2299
  );
2270
- const pkg = JSON.parse(fs5.readFileSync(pkgPath, "utf-8"));
2300
+ const pkg = JSON.parse(fs6.readFileSync(pkgPath, "utf-8"));
2271
2301
  console.log(`burnwatch v${pkg.version}`);
2272
2302
  } catch {
2273
2303
  console.log("burnwatch v0.1.0");
2274
2304
  }
2275
2305
  }
2276
2306
  function registerHooks(projectRoot) {
2277
- const sourceHooksDir = path5.resolve(
2278
- path5.dirname(new URL(import.meta.url).pathname),
2307
+ const sourceHooksDir = path6.resolve(
2308
+ path6.dirname(new URL(import.meta.url).pathname),
2279
2309
  "hooks"
2280
2310
  );
2281
- const localHooksDir = path5.join(projectRoot, ".burnwatch", "hooks");
2282
- fs5.mkdirSync(localHooksDir, { recursive: true });
2311
+ const localHooksDir = path6.join(projectRoot, ".burnwatch", "hooks");
2312
+ fs6.mkdirSync(localHooksDir, { recursive: true });
2283
2313
  const hookFiles = [
2284
2314
  "on-session-start.js",
2285
2315
  "on-prompt.js",
@@ -2287,45 +2317,45 @@ function registerHooks(projectRoot) {
2287
2317
  "on-stop.js"
2288
2318
  ];
2289
2319
  for (const file of hookFiles) {
2290
- const src = path5.join(sourceHooksDir, file);
2291
- const dest = path5.join(localHooksDir, file);
2320
+ const src = path6.join(sourceHooksDir, file);
2321
+ const dest = path6.join(localHooksDir, file);
2292
2322
  try {
2293
- fs5.copyFileSync(src, dest);
2323
+ fs6.copyFileSync(src, dest);
2294
2324
  const mapSrc = src + ".map";
2295
- if (fs5.existsSync(mapSrc)) {
2296
- fs5.copyFileSync(mapSrc, dest + ".map");
2325
+ if (fs6.existsSync(mapSrc)) {
2326
+ fs6.copyFileSync(mapSrc, dest + ".map");
2297
2327
  }
2298
2328
  } catch (err) {
2299
2329
  console.error(` Warning: Could not copy hook ${file}: ${err instanceof Error ? err.message : err}`);
2300
2330
  }
2301
2331
  }
2302
2332
  console.log(` Hook scripts copied to ${localHooksDir}`);
2303
- const sourceSkillsDir = path5.resolve(
2304
- path5.dirname(new URL(import.meta.url).pathname),
2333
+ const sourceSkillsDir = path6.resolve(
2334
+ path6.dirname(new URL(import.meta.url).pathname),
2305
2335
  "../skills"
2306
2336
  );
2307
2337
  const skillNames = ["setup-burnwatch", "burnwatch-interview", "spend"];
2308
- const claudeSkillsDir = path5.join(projectRoot, ".claude", "skills");
2338
+ const claudeSkillsDir = path6.join(projectRoot, ".claude", "skills");
2309
2339
  for (const skillName of skillNames) {
2310
- const srcSkill = path5.join(sourceSkillsDir, skillName, "SKILL.md");
2311
- const destDir = path5.join(claudeSkillsDir, skillName);
2312
- const destSkill = path5.join(destDir, "SKILL.md");
2340
+ const srcSkill = path6.join(sourceSkillsDir, skillName, "SKILL.md");
2341
+ const destDir = path6.join(claudeSkillsDir, skillName);
2342
+ const destSkill = path6.join(destDir, "SKILL.md");
2313
2343
  try {
2314
- if (fs5.existsSync(srcSkill)) {
2315
- fs5.mkdirSync(destDir, { recursive: true });
2316
- fs5.copyFileSync(srcSkill, destSkill);
2344
+ if (fs6.existsSync(srcSkill)) {
2345
+ fs6.mkdirSync(destDir, { recursive: true });
2346
+ fs6.copyFileSync(srcSkill, destSkill);
2317
2347
  }
2318
2348
  } catch (err) {
2319
2349
  console.error(` Warning: Could not copy skill ${skillName}: ${err instanceof Error ? err.message : err}`);
2320
2350
  }
2321
2351
  }
2322
2352
  console.log(` Skills installed to ${claudeSkillsDir}`);
2323
- const claudeDir = path5.join(projectRoot, ".claude");
2324
- const settingsPath = path5.join(claudeDir, "settings.json");
2325
- fs5.mkdirSync(claudeDir, { recursive: true });
2353
+ const claudeDir = path6.join(projectRoot, ".claude");
2354
+ const settingsPath = path6.join(claudeDir, "settings.json");
2355
+ fs6.mkdirSync(claudeDir, { recursive: true });
2326
2356
  let settings = {};
2327
2357
  try {
2328
- const existing = fs5.readFileSync(settingsPath, "utf-8");
2358
+ const existing = fs6.readFileSync(settingsPath, "utf-8");
2329
2359
  settings = JSON.parse(existing);
2330
2360
  console.log(` Merging into existing ${settingsPath}`);
2331
2361
  } catch {
@@ -2341,7 +2371,7 @@ function registerHooks(projectRoot) {
2341
2371
  hooks: [
2342
2372
  {
2343
2373
  type: "command",
2344
- command: `node "${path5.join(hooksDir, "on-session-start.js")}"`,
2374
+ command: `node "${path6.join(hooksDir, "on-session-start.js")}"`,
2345
2375
  timeout: 15
2346
2376
  }
2347
2377
  ]
@@ -2354,7 +2384,7 @@ function registerHooks(projectRoot) {
2354
2384
  hooks: [
2355
2385
  {
2356
2386
  type: "command",
2357
- command: `node "${path5.join(hooksDir, "on-prompt.js")}"`,
2387
+ command: `node "${path6.join(hooksDir, "on-prompt.js")}"`,
2358
2388
  timeout: 5
2359
2389
  }
2360
2390
  ]
@@ -2366,7 +2396,7 @@ function registerHooks(projectRoot) {
2366
2396
  hooks: [
2367
2397
  {
2368
2398
  type: "command",
2369
- command: `node "${path5.join(hooksDir, "on-file-change.js")}"`,
2399
+ command: `node "${path6.join(hooksDir, "on-file-change.js")}"`,
2370
2400
  timeout: 5
2371
2401
  }
2372
2402
  ]
@@ -2376,14 +2406,14 @@ function registerHooks(projectRoot) {
2376
2406
  hooks: [
2377
2407
  {
2378
2408
  type: "command",
2379
- command: `node "${path5.join(hooksDir, "on-stop.js")}"`,
2409
+ command: `node "${path6.join(hooksDir, "on-stop.js")}"`,
2380
2410
  timeout: 15,
2381
2411
  async: true
2382
2412
  }
2383
2413
  ]
2384
2414
  });
2385
2415
  settings["hooks"] = hooks;
2386
- fs5.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
2416
+ fs6.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
2387
2417
  console.log(` Hooks registered in ${settingsPath}`);
2388
2418
  }
2389
2419
  function addHookIfMissing(hookArray, _eventName, hookConfig) {