@kyubiware/commit-mint 0.5.4 → 0.5.6

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
@@ -28,7 +28,7 @@ var __exportAll = (all, no_symbols) => {
28
28
  //#region package.json
29
29
  var package_default = {
30
30
  name: "@kyubiware/commit-mint",
31
- version: "0.5.4",
31
+ version: "0.5.6",
32
32
  description: "🌿 A commit tool that actually handles hook failures",
33
33
  type: "module",
34
34
  bin: { "cmint": "./dist/cli.mjs" },
@@ -1031,23 +1031,44 @@ function buildCommand(command, files) {
1031
1031
  return `${command} ${files.map((f) => f.includes(" ") ? `"${f}"` : f).join(" ")}`;
1032
1032
  }
1033
1033
  /**
1034
- * Resolve config commands for a glob entry into an array of command strings.
1035
- * Function commands receive matched filenames; string commands are used as-is.
1034
+ * Call a function command with matched files and normalize the result to ResolvedCommand[].
1035
+ */
1036
+ function resolveFunction(fn, matchedFiles) {
1037
+ const resolved = fn(matchedFiles);
1038
+ return (Array.isArray(resolved) ? resolved : [resolved]).map((command) => ({
1039
+ command,
1040
+ fromFunction: true
1041
+ }));
1042
+ }
1043
+ /**
1044
+ * Resolve config commands for a glob entry into an array of resolved commands.
1045
+ * Function commands are called with matched filenames; string commands are kept as-is.
1046
+ * Each resolved entry tracks whether it came from a function (for file-append behavior).
1036
1047
  */
1037
1048
  function resolveCommands(commands, matchedFiles) {
1038
- if (typeof commands === "function") {
1039
- const resolved = commands(matchedFiles);
1040
- return Array.isArray(resolved) ? resolved : [resolved];
1049
+ if (typeof commands === "function") return resolveFunction(commands, matchedFiles);
1050
+ if (Array.isArray(commands)) {
1051
+ const result = [];
1052
+ for (const cmd of commands) if (typeof cmd === "function") result.push(...resolveFunction(cmd, matchedFiles));
1053
+ else result.push({
1054
+ command: cmd,
1055
+ fromFunction: false
1056
+ });
1057
+ return result;
1041
1058
  }
1042
- return Array.isArray(commands) ? commands : [commands];
1059
+ return [{
1060
+ command: commands,
1061
+ fromFunction: false
1062
+ }];
1043
1063
  }
1044
1064
  /**
1045
1065
  * Run resolved commands for a single glob entry, appending results.
1066
+ * Function-originated commands run as-is; string commands get matched files appended.
1046
1067
  * Returns false if any command fails (for fail-fast signaling).
1047
1068
  */
1048
- async function runCommandsForGlob(cmds, isFunction, matchedFiles, timeout, results, repoRoot) {
1049
- for (const cmd of cmds) {
1050
- const fullCommand = isFunction ? cmd : buildCommand(cmd, matchedFiles);
1069
+ async function runCommandsForGlob(cmds, matchedFiles, timeout, results, repoRoot) {
1070
+ for (const { command, fromFunction } of cmds) {
1071
+ const fullCommand = fromFunction ? command : buildCommand(command, matchedFiles);
1051
1072
  debug("runCommandsForGlob: running '%s'", fullCommand);
1052
1073
  const result = await runCommand(fullCommand, timeout, repoRoot);
1053
1074
  results.push({
@@ -1080,13 +1101,12 @@ async function runAllChecks(repoRoot, stagedFiles, timeout) {
1080
1101
  const results = [];
1081
1102
  for (const [glob, commands] of Object.entries(config)) {
1082
1103
  const matchedFiles = matchFiles(glob, stagedFiles);
1083
- const isFunction = typeof commands === "function";
1084
1104
  if (matchedFiles.length === 0) {
1085
1105
  debug("runAllChecks: no files matched pattern '%s'", glob);
1086
1106
  continue;
1087
1107
  }
1088
1108
  debug("runAllChecks: pattern '%s' matched %d files", glob, matchedFiles.length);
1089
- if (!await runCommandsForGlob(resolveCommands(commands, matchedFiles), isFunction, matchedFiles, timeout, results, repoRoot)) return {
1109
+ if (!await runCommandsForGlob(resolveCommands(commands, matchedFiles), matchedFiles, timeout, results, repoRoot)) return {
1090
1110
  ok: false,
1091
1111
  results
1092
1112
  };
@@ -1274,6 +1294,188 @@ function validateGroups(groups, allFiles) {
1274
1294
  return validated;
1275
1295
  }
1276
1296
  //#endregion
1297
+ //#region src/services/clipboard.ts
1298
+ /** Milliseconds to wait after stdin closes for quick exit failures. */
1299
+ const GRACE_PERIOD_MS = 150;
1300
+ async function copyToClipboard(content) {
1301
+ for (const [cmd, args] of [
1302
+ ["wl-copy", []],
1303
+ ["xclip", ["-selection", "clipboard"]],
1304
+ ["xsel", ["--clipboard", "--input"]],
1305
+ ["pbcopy", []]
1306
+ ]) try {
1307
+ if (await tryCopy(cmd, args, content)) return true;
1308
+ } catch {}
1309
+ return false;
1310
+ }
1311
+ /**
1312
+ * Try to copy content using a single clipboard tool.
1313
+ *
1314
+ * Waits a short grace period after stdin closes to detect quick failures
1315
+ * (e.g. wl-copy on non-Wayland, missing display). If the tool survives
1316
+ * the grace period, assumes success — clipboard tools like xclip and
1317
+ * wl-copy hold the selection open indefinitely, so we can't wait for exit.
1318
+ */
1319
+ /**
1320
+ * Evaluate clipboard tool status after the grace period.
1321
+ * If the child already exited, report based on exit code.
1322
+ * If still alive, assume success (clipboard tools hold selection open).
1323
+ */
1324
+ function handleGracePeriod(settled, exitCode, stderrChunks, child, done) {
1325
+ if (settled) return;
1326
+ if (exitCode !== null) {
1327
+ if (exitCode === 0) done(true, "exited 0");
1328
+ else {
1329
+ const stderr = Buffer.concat(stderrChunks).toString().trim();
1330
+ done(false, `exit ${exitCode}${stderr ? `: ${stderr}` : ""}`);
1331
+ }
1332
+ return;
1333
+ }
1334
+ child.unref();
1335
+ done(true);
1336
+ }
1337
+ function tryCopy(cmd, args, content) {
1338
+ return new Promise((resolve) => {
1339
+ debug("clipboard: trying %s", cmd);
1340
+ const child = spawn(cmd, args, { stdio: [
1341
+ "pipe",
1342
+ "ignore",
1343
+ "pipe"
1344
+ ] });
1345
+ let settled = false;
1346
+ const stderrChunks = [];
1347
+ child.stderr?.on("data", (chunk) => {
1348
+ stderrChunks.push(chunk);
1349
+ });
1350
+ const done = (result, reason) => {
1351
+ if (settled) return;
1352
+ settled = true;
1353
+ debug("clipboard: %s %s%s", cmd, result ? "ok" : "failed", reason ? ` (${reason})` : "");
1354
+ resolve(result);
1355
+ };
1356
+ child.on("error", (err) => {
1357
+ done(false, err.message);
1358
+ });
1359
+ let exitCode = null;
1360
+ child.on("exit", (code) => {
1361
+ exitCode = code;
1362
+ });
1363
+ child.stdin.write(content, (err) => {
1364
+ if (err) {
1365
+ done(false, "stdin write error");
1366
+ return;
1367
+ }
1368
+ child.stdin.end(() => {
1369
+ setTimeout(() => handleGracePeriod(settled, exitCode, stderrChunks, child, done), GRACE_PERIOD_MS);
1370
+ });
1371
+ });
1372
+ });
1373
+ }
1374
+ //#endregion
1375
+ //#region src/ui/check-failure-menu.ts
1376
+ const MAX_TSC_DIAGNOSTICS = 3;
1377
+ const MAX_SUMMARY_LINE_LENGTH = 120;
1378
+ const TSC_DIAGNOSTIC = /^(.+?\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs))\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.+)$/;
1379
+ function formatCheckFailureSummary(errors) {
1380
+ if (errors.length === 0) return "No check error details were parsed. View full output for details.";
1381
+ return errors.map((error) => formatCheckErrorSummary(error)).join("\n");
1382
+ }
1383
+ function formatCheckErrorSummary(error) {
1384
+ if (error.tool === "tsc") {
1385
+ const diagnostics = extractTscDiagnostics(error.raw || error.message);
1386
+ if (diagnostics.length > 0) return formatTscSummary(diagnostics);
1387
+ }
1388
+ const message = firstMeaningfulLine(error.message || error.raw);
1389
+ return ` ${red("•")} [${error.tool}] ${truncate(message, MAX_SUMMARY_LINE_LENGTH)}`;
1390
+ }
1391
+ function extractTscDiagnostics(raw) {
1392
+ return raw.split("\n").map((line) => line.trim()).map((line) => {
1393
+ const match = TSC_DIAGNOSTIC.exec(line);
1394
+ if (!match) return null;
1395
+ return {
1396
+ file: match[1] ?? "",
1397
+ line: match[2] ?? "",
1398
+ column: match[3] ?? "",
1399
+ code: match[4] ?? "",
1400
+ message: match[5] ?? ""
1401
+ };
1402
+ }).filter((diagnostic) => diagnostic !== null);
1403
+ }
1404
+ function formatTscSummary(diagnostics) {
1405
+ const visible = diagnostics.slice(0, MAX_TSC_DIAGNOSTICS);
1406
+ const hidden = diagnostics.length - visible.length;
1407
+ const lines = [` ${red("•")} [tsc] ${diagnostics.length} TypeScript error${diagnostics.length !== 1 ? "s" : ""}`, ...visible.map((diagnostic) => `${diagnostic.file}:${diagnostic.line}:${diagnostic.column} — error ${diagnostic.code}: ${truncate(diagnostic.message, MAX_SUMMARY_LINE_LENGTH)}`)];
1408
+ if (hidden > 0) lines.push(dim(` +${hidden} more TypeScript error${hidden !== 1 ? "s" : ""}. View full output for details.`));
1409
+ return lines.join("\n");
1410
+ }
1411
+ function firstMeaningfulLine(message) {
1412
+ return message.split("\n").map((l) => l.trim()).find((l) => l.length > 0 && !l.startsWith(">") && !l.startsWith("ELIFECYCLE")) ?? message;
1413
+ }
1414
+ function truncate(message, maxLength) {
1415
+ const collapsed = message.replace(/\s+/g, " ").trim();
1416
+ if (collapsed.length <= maxLength) return collapsed;
1417
+ return `${collapsed.slice(0, Math.max(0, maxLength - 1))}…`;
1418
+ }
1419
+ async function showCheckFailureMenu(errors, rawStderr, onRetry) {
1420
+ debug("showCheckFailureMenu: %d errors", errors.length);
1421
+ let clipboardCopied = false;
1422
+ p.note(formatCheckFailureSummary(errors), red("Pre-commit check failed"));
1423
+ while (true) {
1424
+ const choice = await p.select({
1425
+ message: "What do you want to do?",
1426
+ options: [
1427
+ {
1428
+ label: clipboardCopied ? `${green("✓")} Copy error report to clipboard` : "Copy error report to clipboard",
1429
+ value: "copy"
1430
+ },
1431
+ {
1432
+ label: "View full error output",
1433
+ value: "view",
1434
+ hint: "Show the raw stderr from checks"
1435
+ },
1436
+ {
1437
+ label: "Retry checks",
1438
+ value: "retry",
1439
+ hint: "Re-run checks after fixing errors"
1440
+ },
1441
+ {
1442
+ label: "Skip checks and commit",
1443
+ value: "skip"
1444
+ },
1445
+ {
1446
+ label: "Cancel",
1447
+ value: "cancel"
1448
+ }
1449
+ ]
1450
+ });
1451
+ if (p.isCancel(choice)) {
1452
+ debug("showCheckFailureMenu: user cancelled");
1453
+ return "cancelled";
1454
+ }
1455
+ debug("showCheckFailureMenu: user chose %s", choice);
1456
+ switch (choice) {
1457
+ case "copy":
1458
+ if (await copyToClipboard(rawStderr)) {
1459
+ clipboardCopied = true;
1460
+ p.log.step(green("Copied to clipboard."));
1461
+ } else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
1462
+ continue;
1463
+ case "view":
1464
+ p.note(rawStderr.trim() || "(no raw output)", "Full error output");
1465
+ continue;
1466
+ case "retry":
1467
+ if (onRetry) return "retried";
1468
+ return "retried";
1469
+ case "skip":
1470
+ p.log.info("Skipping checks and proceeding with commit...");
1471
+ return "skipped";
1472
+ case "cancel":
1473
+ p.outro(dim("Cancelled."));
1474
+ return "cancelled";
1475
+ }
1476
+ }
1477
+ }
1478
+ //#endregion
1277
1479
  //#region src/ui/grouping.ts
1278
1480
  async function showGroupingConfirmation(groups, excluded) {
1279
1481
  debug("showGroupingConfirmation: %d groups, %d excluded", groups.length, excluded.length);
@@ -1336,126 +1538,7 @@ function showGroupedFiles(groups, changedFiles) {
1336
1538
  p.note(lines.join("\n"), "Commit groups");
1337
1539
  }
1338
1540
  //#endregion
1339
- //#region src/services/clipboard.ts
1340
- async function copyToClipboard(content) {
1341
- for (const [cmd, args] of [
1342
- ["wl-copy", []],
1343
- ["xclip", ["-selection", "clipboard"]],
1344
- ["xsel", ["--clipboard", "--input"]],
1345
- ["pbcopy", []]
1346
- ]) try {
1347
- if (await new Promise((resolve) => {
1348
- const child = spawn(cmd, args, { stdio: [
1349
- "pipe",
1350
- "ignore",
1351
- "ignore"
1352
- ] });
1353
- let settled = false;
1354
- const done = (result) => {
1355
- if (settled) return;
1356
- settled = true;
1357
- resolve(result);
1358
- };
1359
- child.on("error", () => done(false));
1360
- child.on("exit", (code) => {
1361
- if (code !== 0) done(false);
1362
- });
1363
- child.stdin.write(content, (err) => {
1364
- if (err) {
1365
- done(false);
1366
- return;
1367
- }
1368
- child.stdin.end(() => {
1369
- child.unref();
1370
- done(true);
1371
- });
1372
- });
1373
- })) return true;
1374
- } catch {}
1375
- return false;
1376
- }
1377
- //#endregion
1378
- //#region src/ui/menu.ts
1379
- async function showStagingMenu(files, hasChecks) {
1380
- debug("showStagingMenu: %d files", files.length);
1381
- const statusLabel = (status) => {
1382
- switch (status) {
1383
- case "M": return yellow("M");
1384
- case "A": return green("A");
1385
- case "D": return red("D");
1386
- case "?":
1387
- case "??": return cyan("?");
1388
- default: return dim(status);
1389
- }
1390
- };
1391
- const sorted = [...files].sort((a, b) => {
1392
- if (a.staged !== b.staged) return a.staged ? -1 : 1;
1393
- return a.path.localeCompare(b.path);
1394
- });
1395
- const stagedFiles = sorted.filter((f) => f.staged);
1396
- const unstagedFiles = sorted.filter((f) => !f.staged);
1397
- const lines = [];
1398
- if (stagedFiles.length > 0) lines.push(green(bold("Staged:")), ...stagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
1399
- if (unstagedFiles.length > 0) {
1400
- if (lines.length > 0) lines.push("");
1401
- lines.push(yellow(bold("Changed:")), ...unstagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
1402
- }
1403
- p.note(lines.join("\n"), `${files.length} file${files.length !== 1 ? "s" : ""}`);
1404
- const choice = await p.select({
1405
- message: "Stage files for commit:",
1406
- options: [
1407
- {
1408
- label: "Auto-group into commits",
1409
- value: "autogroup",
1410
- hint: "LLM groups files into logical commits"
1411
- },
1412
- ...stagedFiles.length > 0 ? [{
1413
- label: "Commit staged files only",
1414
- value: "staged",
1415
- hint: `${stagedFiles.length} file${stagedFiles.length !== 1 ? "s" : ""} already staged`
1416
- }] : [],
1417
- {
1418
- label: "Stage all files",
1419
- value: "all",
1420
- hint: `${files.length} file${files.length !== 1 ? "s" : ""}`
1421
- },
1422
- ...hasChecks ? [{
1423
- label: "Run checks",
1424
- value: "checks",
1425
- hint: "Pre-flight checks from cmint config"
1426
- }] : [],
1427
- {
1428
- label: "Select files...",
1429
- value: "select"
1430
- },
1431
- {
1432
- label: "Cancel",
1433
- value: "cancel"
1434
- }
1435
- ]
1436
- });
1437
- if (p.isCancel(choice) || choice === "cancel") return null;
1438
- if (choice === "autogroup") return "autogroup";
1439
- if (choice === "checks") return "checks";
1440
- if (choice === "staged") return "staged";
1441
- if (choice === "all") return {
1442
- files: files.map((f) => f.path),
1443
- all: true
1444
- };
1445
- const selected = await p.multiselect({
1446
- message: "Select files to stage:",
1447
- options: sorted.map((f) => ({
1448
- label: `${statusLabel(f.status)} ${f.path}`,
1449
- value: f.path
1450
- })),
1451
- required: true
1452
- });
1453
- if (p.isCancel(selected)) return null;
1454
- return {
1455
- files: selected,
1456
- all: false
1457
- };
1458
- }
1541
+ //#region src/ui/recovery-menu.ts
1459
1542
  async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message, rawStderr) {
1460
1543
  debug("showRecoveryMenu: %d errors", errors.length);
1461
1544
  let clipboardCopied = false;
@@ -1557,65 +1640,6 @@ async function showRecoveryMenu(errors, onRetry, onSkipHooks, onRestage, message
1557
1640
  }
1558
1641
  }
1559
1642
  }
1560
- async function showCheckFailureMenu(errors, rawStderr, onRetry) {
1561
- debug("showCheckFailureMenu: %d errors", errors.length);
1562
- let clipboardCopied = false;
1563
- p.note(errors.map((e) => ` ${red("•")} [${e.tool}] ${e.message}`).join("\n"), red("Pre-commit check failed"));
1564
- while (true) {
1565
- const choice = await p.select({
1566
- message: "What do you want to do?",
1567
- options: [
1568
- {
1569
- label: clipboardCopied ? `${green("✓")} Copy error report to clipboard` : "Copy error report to clipboard",
1570
- value: "copy"
1571
- },
1572
- {
1573
- label: "View full error output",
1574
- value: "view",
1575
- hint: "Show the raw stderr from checks"
1576
- },
1577
- {
1578
- label: "Retry checks",
1579
- value: "retry",
1580
- hint: "Re-run checks after fixing errors"
1581
- },
1582
- {
1583
- label: "Skip checks and commit",
1584
- value: "skip"
1585
- },
1586
- {
1587
- label: "Cancel",
1588
- value: "cancel"
1589
- }
1590
- ]
1591
- });
1592
- if (p.isCancel(choice)) {
1593
- debug("showCheckFailureMenu: user cancelled");
1594
- return "cancelled";
1595
- }
1596
- debug("showCheckFailureMenu: user chose %s", choice);
1597
- switch (choice) {
1598
- case "copy":
1599
- if (await copyToClipboard(rawStderr)) {
1600
- clipboardCopied = true;
1601
- p.log.step(green("Copied to clipboard."));
1602
- } else p.log.warn(red("No clipboard tool found. Install xclip, wl-copy, or xsel."));
1603
- continue;
1604
- case "view":
1605
- p.note(rawStderr.trim() || "(no raw output)", "Full error output");
1606
- continue;
1607
- case "retry":
1608
- if (onRetry) return "retried";
1609
- return "retried";
1610
- case "skip":
1611
- p.log.info("Skipping checks and proceeding with commit...");
1612
- return "skipped";
1613
- case "cancel":
1614
- p.outro(dim("Cancelled."));
1615
- return "cancelled";
1616
- }
1617
- }
1618
- }
1619
1643
  //#endregion
1620
1644
  //#region src/commands/auto-group.ts
1621
1645
  async function runAutoGroupFlow(changedFiles, flags) {
@@ -1849,6 +1873,88 @@ async function handleRetry() {
1849
1873
  else process.exit(1);
1850
1874
  }
1851
1875
  //#endregion
1876
+ //#region src/ui/staging-menu.ts
1877
+ async function showStagingMenu(files, hasChecks) {
1878
+ debug("showStagingMenu: %d files", files.length);
1879
+ const statusLabel = (status) => {
1880
+ switch (status) {
1881
+ case "M": return yellow("M");
1882
+ case "A": return green("A");
1883
+ case "D": return red("D");
1884
+ case "?":
1885
+ case "??": return cyan("?");
1886
+ default: return dim(status);
1887
+ }
1888
+ };
1889
+ const sorted = [...files].sort((a, b) => {
1890
+ if (a.staged !== b.staged) return a.staged ? -1 : 1;
1891
+ return a.path.localeCompare(b.path);
1892
+ });
1893
+ const stagedFiles = sorted.filter((f) => f.staged);
1894
+ const unstagedFiles = sorted.filter((f) => !f.staged);
1895
+ const lines = [];
1896
+ if (stagedFiles.length > 0) lines.push(green(bold("Staged:")), ...stagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
1897
+ if (unstagedFiles.length > 0) {
1898
+ if (lines.length > 0) lines.push("");
1899
+ lines.push(yellow(bold("Changed:")), ...unstagedFiles.map((f) => ` ${statusLabel(f.status)} ${f.path}`));
1900
+ }
1901
+ p.note(lines.join("\n"), `${files.length} file${files.length !== 1 ? "s" : ""}`);
1902
+ const choice = await p.select({
1903
+ message: "Stage files for commit:",
1904
+ options: [
1905
+ {
1906
+ label: "Auto-group into commits",
1907
+ value: "autogroup",
1908
+ hint: "LLM groups files into logical commits"
1909
+ },
1910
+ ...stagedFiles.length > 0 ? [{
1911
+ label: "Commit staged files only",
1912
+ value: "staged",
1913
+ hint: `${stagedFiles.length} file${stagedFiles.length !== 1 ? "s" : ""} already staged`
1914
+ }] : [],
1915
+ {
1916
+ label: "Stage all files",
1917
+ value: "all",
1918
+ hint: `${files.length} file${files.length !== 1 ? "s" : ""}`
1919
+ },
1920
+ ...hasChecks ? [{
1921
+ label: "Run checks",
1922
+ value: "checks",
1923
+ hint: "Pre-flight checks from cmint config"
1924
+ }] : [],
1925
+ {
1926
+ label: "Select files...",
1927
+ value: "select"
1928
+ },
1929
+ {
1930
+ label: "Cancel",
1931
+ value: "cancel"
1932
+ }
1933
+ ]
1934
+ });
1935
+ if (p.isCancel(choice) || choice === "cancel") return null;
1936
+ if (choice === "autogroup") return "autogroup";
1937
+ if (choice === "checks") return "checks";
1938
+ if (choice === "staged") return "staged";
1939
+ if (choice === "all") return {
1940
+ files: files.map((f) => f.path),
1941
+ all: true
1942
+ };
1943
+ const selected = await p.multiselect({
1944
+ message: "Select files to stage:",
1945
+ options: sorted.map((f) => ({
1946
+ label: `${statusLabel(f.status)} ${f.path}`,
1947
+ value: f.path
1948
+ })),
1949
+ required: true
1950
+ });
1951
+ if (p.isCancel(selected)) return null;
1952
+ return {
1953
+ files: selected,
1954
+ all: false
1955
+ };
1956
+ }
1957
+ //#endregion
1852
1958
  //#region src/commands/staging.ts
1853
1959
  /** Interactive staging loop for multiple changed files */
1854
1960
  async function handleStaging(changedFiles, flags) {