@kyubiware/commit-mint 0.7.0 → 0.7.2

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/dist/cli.mjs CHANGED
@@ -29,7 +29,7 @@ var __exportAll = (all, no_symbols) => {
29
29
  //#region package.json
30
30
  var package_default = {
31
31
  name: "@kyubiware/commit-mint",
32
- version: "0.7.0",
32
+ version: "0.7.2",
33
33
  description: "🌿 AI-powered git commit tool — auto-group changed files, generate messages, run pre-commit checks",
34
34
  type: "module",
35
35
  bin: { "cmint": "./dist/cli.mjs" },
@@ -1497,9 +1497,11 @@ function tryCopy(cmd, args, content) {
1497
1497
  //#region src/ui/check-failure-menu.ts
1498
1498
  const MAX_TSC_DIAGNOSTICS = 3;
1499
1499
  const MAX_ESLINT_DIAGNOSTICS = 3;
1500
+ const MAX_TEST_FAILURES = 3;
1500
1501
  const MAX_SUMMARY_LINE_LENGTH = 120;
1501
1502
  const TSC_DIAGNOSTIC = /^(.+?\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs))\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.+)$/;
1502
1503
  const ESLINT_ERROR_LINE = /^\s*(\d+):(\d+)\s+(error|warning)\s+(.+)\s{2,}(\S+)\s*$/;
1504
+ const TEST_FILE_FAIL = /^\s*FAIL\s+(.+?\.(?:test|spec)\.[^\s>]+)\s*>\s*(.+)$/;
1503
1505
  function formatCheckFailureSummary(errors) {
1504
1506
  if (errors.length === 0) return "No check error details were parsed. View full output for details.";
1505
1507
  return errors.map((error) => formatCheckErrorSummary(error)).join("\n");
@@ -1513,6 +1515,10 @@ function formatCheckErrorSummary(error) {
1513
1515
  const diagnostics = extractEslintDiagnostics(error.raw || error.message);
1514
1516
  if (diagnostics.length > 0) return formatEslintSummary(diagnostics);
1515
1517
  }
1518
+ if (error.tool === "vitest" || error.tool === "jest") {
1519
+ const failures = extractTestFailures(error.raw || error.message);
1520
+ if (failures.length > 0) return formatTestFailureSummary(failures, error.tool);
1521
+ }
1516
1522
  const message = firstMeaningfulLine(error.message || error.raw);
1517
1523
  return ` ${red("•")} [${error.tool}] ${truncate(message, MAX_SUMMARY_LINE_LENGTH)}`;
1518
1524
  }
@@ -1557,6 +1563,46 @@ function extractEslintDiagnostics(raw) {
1557
1563
  }
1558
1564
  return diagnostics;
1559
1565
  }
1566
+ function extractTestFailures(raw) {
1567
+ const failures = [];
1568
+ const seen = /* @__PURE__ */ new Set();
1569
+ for (const line of raw.split("\n")) {
1570
+ const match = TEST_FILE_FAIL.exec(line);
1571
+ if (!match) continue;
1572
+ const file = (match[1] ?? "").trim();
1573
+ const name = (match[2] ?? "").trim();
1574
+ if (!file || !name) continue;
1575
+ const key = `${file}\u0000${name}`;
1576
+ if (seen.has(key)) continue;
1577
+ seen.add(key);
1578
+ failures.push({
1579
+ file,
1580
+ name
1581
+ });
1582
+ }
1583
+ return failures;
1584
+ }
1585
+ function formatTestFailureSummary(failures, tool) {
1586
+ const total = failures.length;
1587
+ const visible = failures.slice(0, MAX_TEST_FAILURES);
1588
+ const hidden = total - visible.length;
1589
+ const fileCount = new Set(failures.map((f) => f.file)).size;
1590
+ const testNoun = total === 1 ? "test" : "tests";
1591
+ const fileNoun = fileCount === 1 ? "file" : "files";
1592
+ const lines = [` ${red("•")} [${tool}] ${total} failed ${testNoun} in ${fileCount} ${fileNoun}`];
1593
+ const byFile = /* @__PURE__ */ new Map();
1594
+ for (const failure of visible) {
1595
+ const names = byFile.get(failure.file) ?? [];
1596
+ names.push(failure.name);
1597
+ byFile.set(failure.file, names);
1598
+ }
1599
+ for (const [file, names] of byFile) {
1600
+ lines.push(` ${truncate(file, MAX_SUMMARY_LINE_LENGTH)}`);
1601
+ for (const name of names) lines.push(` ${red("×")} ${truncate(name, MAX_SUMMARY_LINE_LENGTH)}`);
1602
+ }
1603
+ if (hidden > 0) lines.push(dim(` +${hidden} more failed ${hidden === 1 ? "test" : "tests"}. View full output for details.`));
1604
+ return lines.join("\n");
1605
+ }
1560
1606
  function formatEslintSummary(diagnostics) {
1561
1607
  const visible = diagnostics.slice(0, MAX_ESLINT_DIAGNOSTICS);
1562
1608
  const hidden = diagnostics.length - visible.length;
@@ -2263,6 +2309,272 @@ async function agentCommand(flags) {
2263
2309
  });
2264
2310
  }
2265
2311
  //#endregion
2312
+ //#region src/services/update-check.ts
2313
+ const REGISTRY_URL = "https://registry.npmjs.org/-/package/@kyubiware/commit-mint/dist-tags";
2314
+ const PACKAGE_NAME = "@kyubiware/commit-mint";
2315
+ const TTL_MS = 1440 * 60 * 1e3;
2316
+ const FETCH_TIMEOUT_MS = 5e3;
2317
+ let cachePath = join(os.homedir(), ".cache", "commit-mint", "update-check.json");
2318
+ let fetchImpl = globalThis.fetch;
2319
+ /**
2320
+ * Returns true when the notifier must not run: env opt-out, CI, test env,
2321
+ * non-TTY stderr, or an invalid/missing current version.
2322
+ */
2323
+ function shouldSkip(currentVersion) {
2324
+ const noUpdate = process.env.NO_UPDATE_NOTIFIER;
2325
+ if (noUpdate !== void 0 && noUpdate !== "") return true;
2326
+ const ci = process.env.CI;
2327
+ if (ci !== void 0 && ci !== "" && ci !== "0") return true;
2328
+ if (process.env.NODE_ENV === "test") return true;
2329
+ if (process.stderr.isTTY !== true) return true;
2330
+ if (currentVersion === void 0) return true;
2331
+ if (currentVersion === "") return true;
2332
+ if (semver.valid(currentVersion) === null) return true;
2333
+ return false;
2334
+ }
2335
+ function loadCache() {
2336
+ try {
2337
+ const raw = readFileSync(cachePath, "utf8");
2338
+ const parsed = JSON.parse(raw);
2339
+ if (parsed !== null && typeof parsed === "object" && typeof parsed.latest === "string" && typeof parsed.checkedAt === "number") {
2340
+ const entry = parsed;
2341
+ const ageH = ((Date.now() - entry.checkedAt) / 36e5).toFixed(2);
2342
+ debug("loadCache: hit — latest=%s, age=%sh", entry.latest, ageH);
2343
+ return Promise.resolve(entry);
2344
+ }
2345
+ debug("loadCache: miss — malformed entry");
2346
+ return Promise.resolve(null);
2347
+ } catch {
2348
+ debug("loadCache: miss — %s does not exist", cachePath);
2349
+ return Promise.resolve(null);
2350
+ }
2351
+ }
2352
+ function saveCache(entry) {
2353
+ try {
2354
+ mkdirSync(dirname(cachePath), { recursive: true });
2355
+ writeFileSync(cachePath, JSON.stringify(entry), "utf8");
2356
+ debug("saveCache: wrote latest=%s to %s", entry.latest, cachePath);
2357
+ } catch (err) {
2358
+ debug("saveCache: failed — %s", err instanceof Error ? err.message : String(err));
2359
+ }
2360
+ return Promise.resolve();
2361
+ }
2362
+ /**
2363
+ * Fetch latest version from the registry. Aborts on FETCH_TIMEOUT_MS or when
2364
+ * `parentSignal` aborts (user keypress). Returns null on any failure so the
2365
+ * caller can degrade silently.
2366
+ */
2367
+ async function fetchLatest(parentSignal) {
2368
+ debug("fetchLatest: GET %s (timeout=%dms)", REGISTRY_URL, FETCH_TIMEOUT_MS);
2369
+ try {
2370
+ const controller = new AbortController();
2371
+ const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
2372
+ const onParentAbort = () => controller.abort();
2373
+ if (parentSignal) if (parentSignal.aborted) controller.abort();
2374
+ else parentSignal.addEventListener("abort", onParentAbort, { once: true });
2375
+ try {
2376
+ const response = await fetchImpl(REGISTRY_URL, { signal: controller.signal });
2377
+ if (!response.ok) {
2378
+ debug("fetchLatest: HTTP %d — returning null", response.status);
2379
+ return null;
2380
+ }
2381
+ const data = await response.json();
2382
+ if (typeof data.latest !== "string") {
2383
+ debug("fetchLatest: response missing 'latest' field — returning null");
2384
+ return null;
2385
+ }
2386
+ debug("fetchLatest: ok — latest=%s", data.latest);
2387
+ return data.latest;
2388
+ } finally {
2389
+ clearTimeout(timer);
2390
+ if (parentSignal) parentSignal.removeEventListener("abort", onParentAbort);
2391
+ }
2392
+ } catch (err) {
2393
+ debug("fetchLatest: error — %s", err instanceof Error ? err.message : String(err));
2394
+ return null;
2395
+ }
2396
+ }
2397
+ function displayNag(current, latest) {
2398
+ debug("displayNag: %s → %s", current, latest);
2399
+ const message = `Update available: ${yellow(current)} → ${green(latest)}\nRun ${cyan(`npm update -g ${PACKAGE_NAME}`)} to update`;
2400
+ log.warn(message);
2401
+ }
2402
+ /**
2403
+ * Run the full update check. Exported for tests; the public surface is
2404
+ * {@link checkForUpdatesUpfront}. Accepts an optional AbortSignal that
2405
+ * propagates to the underlying fetch — used by the cancellable spinner.
2406
+ *
2407
+ * Returns an {@link UpdateCheckStatus} so the caller can distinguish a
2408
+ * real fetch that found the user current (eligible for "You are on the
2409
+ * latest version" feedback) from a silent cache hit.
2410
+ */
2411
+ async function runUpdateCheck(currentVersion, parentSignal) {
2412
+ debug("runUpdateCheck: currentVersion=%s", currentVersion);
2413
+ if (shouldSkip(currentVersion)) {
2414
+ debug("runUpdateCheck: skipped (NO_UPDATE_NOTIFIER / CI / NODE_ENV=test / non-TTY / invalid version)");
2415
+ return "skipped";
2416
+ }
2417
+ try {
2418
+ const cached = await loadCache();
2419
+ if (cached && Date.now() - cached.checkedAt < TTL_MS) {
2420
+ debug("runUpdateCheck: cache fresh (<%dh), skipping fetch", TTL_MS / 36e5);
2421
+ if (semver.gt(cached.latest, currentVersion)) {
2422
+ displayNag(currentVersion, cached.latest);
2423
+ return "cache-update";
2424
+ }
2425
+ debug("runUpdateCheck: current >= latest, no nag");
2426
+ return "cache-current";
2427
+ }
2428
+ if (cached) debug("runUpdateCheck: cache stale, refetching");
2429
+ else debug("runUpdateCheck: no cache, fetching");
2430
+ const latest = await fetchLatest(parentSignal);
2431
+ if (latest === null) {
2432
+ debug("runUpdateCheck: fetch returned null, not saving cache");
2433
+ return "fetch-failed-or-aborted";
2434
+ }
2435
+ await saveCache({
2436
+ latest,
2437
+ checkedAt: Date.now()
2438
+ });
2439
+ if (semver.gt(latest, currentVersion)) {
2440
+ displayNag(currentVersion, latest);
2441
+ return "fetch-update";
2442
+ }
2443
+ debug("runUpdateCheck: current >= latest, no nag");
2444
+ return "fetch-current";
2445
+ } catch (err) {
2446
+ debug("runUpdateCheck: unexpected error — %s", err instanceof Error ? err.message : String(err));
2447
+ return "error";
2448
+ }
2449
+ }
2450
+ /**
2451
+ * Run the update check at startup. Silent when:
2452
+ * - skip conditions match (CI, NO_UPDATE_NOTIFIER, NODE_ENV=test, non-TTY
2453
+ * stderr, invalid version)
2454
+ * - cache is fresh (< 24h old) — only displays nag if update available.
2455
+ * A cache-hit-current result produces NO output (no "latest version"
2456
+ * message), because no actual check was performed.
2457
+ * - stdin is non-interactive (piped) — runs silently without spinner, but
2458
+ * still shows "You are on the latest version" after a successful fetch
2459
+ *
2460
+ * On a stale/missing cache with interactive stdin, shows a spinner that the
2461
+ * user can dismiss by pressing any key. Ctrl+C restores the terminal and
2462
+ * exits with conventional code 130.
2463
+ */
2464
+ async function checkForUpdatesUpfront(currentVersion) {
2465
+ debug("checkForUpdatesUpfront: currentVersion=%s", currentVersion);
2466
+ if (shouldSkip(currentVersion)) {
2467
+ debug("checkForUpdatesUpfront: skipped (NO_UPDATE_NOTIFIER / CI / NODE_ENV=test / non-TTY / invalid version)");
2468
+ return;
2469
+ }
2470
+ const cached = await loadCache();
2471
+ if (cached && Date.now() - cached.checkedAt < TTL_MS) {
2472
+ debug("checkForUpdatesUpfront: cache fresh (<%dh), skipping fetch", TTL_MS / 36e5);
2473
+ if (semver.gt(cached.latest, currentVersion)) displayNag(currentVersion, cached.latest);
2474
+ else debug("checkForUpdatesUpfront: current >= latest, no nag");
2475
+ return;
2476
+ }
2477
+ if (cached) debug("checkForUpdatesUpfront: cache stale, refetching");
2478
+ else debug("checkForUpdatesUpfront: no cache, fetching");
2479
+ const stdin = process.stdin;
2480
+ if (stdin.isTTY !== true || typeof stdin.setRawMode !== "function") {
2481
+ debug("checkForUpdatesUpfront: stdin not interactive, running silent check");
2482
+ reportFetchCurrent(await runUpdateCheck(currentVersion));
2483
+ return;
2484
+ }
2485
+ await runCheckWithSpinner(currentVersion);
2486
+ }
2487
+ /**
2488
+ * Surface "You are on the latest version" feedback only when the check
2489
+ * actually performed a fetch and the user is current. Silent on cache hits,
2490
+ * skips, failures, and aborts.
2491
+ */
2492
+ function reportFetchCurrent(status) {
2493
+ if (status === "fetch-current") log.info(green("You are on the latest version"));
2494
+ }
2495
+ /**
2496
+ * Attach a "press any key to skip" listener to stdin. Returns a cleanup
2497
+ * function that restores raw mode + pauses stdin; safe to call multiple times.
2498
+ * Returns `failed: true` if raw mode could not be enabled (caller should fall
2499
+ * back to a silent check).
2500
+ *
2501
+ * On any keypress: aborts `controller`. On Ctrl+C (byte 0x03): runs cleanup,
2502
+ * stops the spinner with `spinnerMsg`, and exits with conventional code 130.
2503
+ */
2504
+ function attachStdinSkip(controller, spinner, spinnerMsg) {
2505
+ const stdin = process.stdin;
2506
+ let cleanedUp = false;
2507
+ const cleanup = () => {
2508
+ if (cleanedUp) return;
2509
+ cleanedUp = true;
2510
+ stdin.off("data", onData);
2511
+ try {
2512
+ stdin.setRawMode(false);
2513
+ } catch (err) {
2514
+ debug("attachStdinSkip: setRawMode(false) failed — %s", err instanceof Error ? err.message : String(err));
2515
+ }
2516
+ try {
2517
+ stdin.pause();
2518
+ } catch {}
2519
+ };
2520
+ function onData(buffer) {
2521
+ const byte = buffer[0];
2522
+ debug("attachStdinSkip: stdin byte=0x%s", byte.toString(16).padStart(2, "0"));
2523
+ if (byte === 3) {
2524
+ debug("attachStdinSkip: Ctrl+C (0x03), exiting");
2525
+ cleanup();
2526
+ spinner.stop(spinnerMsg);
2527
+ process.exit(130);
2528
+ }
2529
+ debug("attachStdinSkip: user pressed key, aborting check");
2530
+ controller.abort();
2531
+ }
2532
+ try {
2533
+ stdin.setRawMode(true);
2534
+ stdin.resume();
2535
+ stdin.on("data", onData);
2536
+ return {
2537
+ cleanup,
2538
+ failed: false
2539
+ };
2540
+ } catch (err) {
2541
+ debug("attachStdinSkip: setRawMode failed — %s", err instanceof Error ? err.message : String(err));
2542
+ return {
2543
+ cleanup: () => {},
2544
+ failed: true
2545
+ };
2546
+ }
2547
+ }
2548
+ /**
2549
+ * Show a cancellable spinner while the check runs. Puts stdin in raw mode to
2550
+ * capture individual keypresses via {@link attachStdinSkip}.
2551
+ */
2552
+ async function runCheckWithSpinner(currentVersion) {
2553
+ debug("runCheckWithSpinner: showing cancellable spinner");
2554
+ const s = spinner();
2555
+ s.start("Checking for updates (press any key to skip)");
2556
+ const controller = new AbortController();
2557
+ const handler = attachStdinSkip(controller, s, "Cancelled");
2558
+ if (handler.failed) {
2559
+ s.stop("");
2560
+ reportFetchCurrent(await runUpdateCheck(currentVersion));
2561
+ return;
2562
+ }
2563
+ let status;
2564
+ try {
2565
+ status = await runUpdateCheck(currentVersion, controller.signal);
2566
+ } finally {
2567
+ handler.cleanup();
2568
+ }
2569
+ if (controller.signal.aborted) {
2570
+ debug("runCheckWithSpinner: spinner dismissed by user");
2571
+ s.stop("Skipped");
2572
+ } else if (status === "fetch-current") s.stop(green("You are on the latest version"));
2573
+ else if (status === "fetch-update") s.stop("");
2574
+ else if (status === "fetch-failed-or-aborted") s.stop("Update check failed");
2575
+ else s.stop("");
2576
+ }
2577
+ //#endregion
2266
2578
  //#region src/commands/commit-utils.ts
2267
2579
  /** Shared recovery menu factory — avoids repeating the same callback set */
2268
2580
  function makeRecoveryCallbacks(message) {
@@ -2697,13 +3009,14 @@ async function restageFormatterModifications(stagedFileList) {
2697
3009
  }
2698
3010
  //#endregion
2699
3011
  //#region src/commands/commit.ts
2700
- async function commitCommand(flags) {
3012
+ async function commitCommand(flags, version) {
2701
3013
  debug("commitCommand called", { flags });
2702
3014
  await assertGitRepo();
2703
3015
  if (flags.retry) return handleRetry();
2704
3016
  const repoRoot = await getRepoRoot();
2705
3017
  await runPreflightSetupPrompt(repoRoot);
2706
3018
  intro("🌿 commit-mint");
3019
+ await checkForUpdatesUpfront(version);
2707
3020
  const status = await getStatusShort();
2708
3021
  debug("Git status:", status || "(empty)");
2709
3022
  if (!status) {
@@ -3059,95 +3372,6 @@ async function logsCommand(flags) {
3059
3372
  for (const line of lines) console.log(line);
3060
3373
  }
3061
3374
  //#endregion
3062
- //#region src/services/update-check.ts
3063
- const REGISTRY_URL = "https://registry.npmjs.org/@kyubiware/commit-mint/latest";
3064
- const PACKAGE_NAME = "@kyubiware/commit-mint";
3065
- const TTL_MS = 1440 * 60 * 1e3;
3066
- const FETCH_TIMEOUT_MS = 5e3;
3067
- let cachePath = join(os.homedir(), ".cache", "commit-mint", "update-check.json");
3068
- let fetchImpl = globalThis.fetch;
3069
- /**
3070
- * Returns true when the notifier must not run: env opt-out, CI, test env,
3071
- * non-TTY stderr, or an invalid/missing current version.
3072
- */
3073
- function shouldSkip(currentVersion) {
3074
- const noUpdate = process.env.NO_UPDATE_NOTIFIER;
3075
- if (noUpdate !== void 0 && noUpdate !== "") return true;
3076
- const ci = process.env.CI;
3077
- if (ci !== void 0 && ci !== "" && ci !== "0") return true;
3078
- if (process.env.NODE_ENV === "test") return true;
3079
- if (process.stderr.isTTY !== true) return true;
3080
- if (currentVersion === void 0) return true;
3081
- if (currentVersion === "") return true;
3082
- if (semver.valid(currentVersion) === null) return true;
3083
- return false;
3084
- }
3085
- function loadCache() {
3086
- try {
3087
- const raw = readFileSync(cachePath, "utf8");
3088
- const parsed = JSON.parse(raw);
3089
- if (parsed !== null && typeof parsed === "object" && typeof parsed.latest === "string" && typeof parsed.checkedAt === "number") return Promise.resolve(parsed);
3090
- return Promise.resolve(null);
3091
- } catch {
3092
- return Promise.resolve(null);
3093
- }
3094
- }
3095
- function saveCache(entry) {
3096
- try {
3097
- mkdirSync(dirname(cachePath), { recursive: true });
3098
- writeFileSync(cachePath, JSON.stringify(entry), "utf8");
3099
- } catch {}
3100
- return Promise.resolve();
3101
- }
3102
- async function fetchLatest() {
3103
- try {
3104
- const controller = new AbortController();
3105
- const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
3106
- try {
3107
- const response = await fetchImpl(REGISTRY_URL, { signal: controller.signal });
3108
- if (!response.ok) return null;
3109
- const data = await response.json();
3110
- if (typeof data.latest !== "string") return null;
3111
- return data.latest;
3112
- } finally {
3113
- clearTimeout(timer);
3114
- }
3115
- } catch {
3116
- return null;
3117
- }
3118
- }
3119
- function displayNag(current, latest) {
3120
- const message = `Update available: ${yellow(current)} → ${green(latest)}\nRun ${cyan(`npm update -g ${PACKAGE_NAME}`)} to update`;
3121
- log.warn(message);
3122
- }
3123
- /**
3124
- * Run the full update check. Exported for tests; the public surface is
3125
- * {@link checkForUpdates}, which schedules this on `beforeExit`.
3126
- */
3127
- async function runUpdateCheck(currentVersion) {
3128
- if (shouldSkip(currentVersion)) return;
3129
- try {
3130
- const cached = await loadCache();
3131
- if (cached && Date.now() - cached.checkedAt < TTL_MS) {
3132
- if (semver.gt(cached.latest, currentVersion)) displayNag(currentVersion, cached.latest);
3133
- return;
3134
- }
3135
- const latest = await fetchLatest();
3136
- if (latest === null) return;
3137
- await saveCache({
3138
- latest,
3139
- checkedAt: Date.now()
3140
- });
3141
- if (semver.gt(latest, currentVersion)) displayNag(currentVersion, latest);
3142
- } catch {}
3143
- }
3144
- /** Register the update check to run on `process.beforeExit`. */
3145
- function checkForUpdates(currentVersion) {
3146
- process.on("beforeExit", () => {
3147
- runUpdateCheck(currentVersion);
3148
- });
3149
- }
3150
- //#endregion
3151
3375
  //#region src/cli.ts
3152
3376
  const { version } = package_default;
3153
3377
  cli({
@@ -3212,10 +3436,7 @@ cli({
3212
3436
  writeSessionHeader();
3213
3437
  setDebug(argv.flags.debug);
3214
3438
  if (argv.flags.agent) agentCommand(argv.flags);
3215
- else {
3216
- checkForUpdates(version);
3217
- commitCommand(argv.flags);
3218
- }
3439
+ else commitCommand(argv.flags, version);
3219
3440
  });
3220
3441
  //#endregion
3221
3442
  export {};