@kyubiware/commit-mint 0.5.6 → 0.6.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/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.6",
31
+ version: "0.6.0",
32
32
  description: "🌿 A commit tool that actually handles hook failures",
33
33
  type: "module",
34
34
  bin: { "cmint": "./dist/cli.mjs" },
@@ -47,7 +47,8 @@ var package_default = {
47
47
  "release:patch": "bash scripts/release.sh patch",
48
48
  "release:minor": "bash scripts/release.sh minor",
49
49
  "release:major": "bash scripts/release.sh major",
50
- "prepublishOnly": "npm run build"
50
+ "prepublishOnly": "npm run build",
51
+ "publish:cmint": "cd packages/cmint && npm publish"
51
52
  },
52
53
  keywords: [
53
54
  "git",
@@ -1374,8 +1375,10 @@ function tryCopy(cmd, args, content) {
1374
1375
  //#endregion
1375
1376
  //#region src/ui/check-failure-menu.ts
1376
1377
  const MAX_TSC_DIAGNOSTICS = 3;
1378
+ const MAX_ESLINT_DIAGNOSTICS = 3;
1377
1379
  const MAX_SUMMARY_LINE_LENGTH = 120;
1378
1380
  const TSC_DIAGNOSTIC = /^(.+?\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs))\((\d+),(\d+)\):\s+error\s+(TS\d+):\s+(.+)$/;
1381
+ const ESLINT_ERROR_LINE = /^\s*(\d+):(\d+)\s+(error|warning)\s+(.+)\s{2,}(\S+)\s*$/;
1379
1382
  function formatCheckFailureSummary(errors) {
1380
1383
  if (errors.length === 0) return "No check error details were parsed. View full output for details.";
1381
1384
  return errors.map((error) => formatCheckErrorSummary(error)).join("\n");
@@ -1385,6 +1388,10 @@ function formatCheckErrorSummary(error) {
1385
1388
  const diagnostics = extractTscDiagnostics(error.raw || error.message);
1386
1389
  if (diagnostics.length > 0) return formatTscSummary(diagnostics);
1387
1390
  }
1391
+ if (error.tool === "eslint") {
1392
+ const diagnostics = extractEslintDiagnostics(error.raw || error.message);
1393
+ if (diagnostics.length > 0) return formatEslintSummary(diagnostics);
1394
+ }
1388
1395
  const message = firstMeaningfulLine(error.message || error.raw);
1389
1396
  return ` ${red("•")} [${error.tool}] ${truncate(message, MAX_SUMMARY_LINE_LENGTH)}`;
1390
1397
  }
@@ -1408,6 +1415,36 @@ function formatTscSummary(diagnostics) {
1408
1415
  if (hidden > 0) lines.push(dim(` +${hidden} more TypeScript error${hidden !== 1 ? "s" : ""}. View full output for details.`));
1409
1416
  return lines.join("\n");
1410
1417
  }
1418
+ function extractEslintDiagnostics(raw) {
1419
+ const diagnostics = [];
1420
+ const lines = raw.split("\n");
1421
+ let currentFile = "";
1422
+ for (const line of lines) {
1423
+ if (!/^\s/.test(line) && line.includes("/") && !ESLINT_ERROR_LINE.test(line)) {
1424
+ currentFile = line.trim();
1425
+ continue;
1426
+ }
1427
+ const match = ESLINT_ERROR_LINE.exec(line);
1428
+ if (match) diagnostics.push({
1429
+ file: currentFile || "unknown",
1430
+ line: match[1] ?? "",
1431
+ column: match[2] ?? "",
1432
+ severity: match[3] ?? "",
1433
+ message: (match[4] ?? "").trim(),
1434
+ rule: match[5] ?? ""
1435
+ });
1436
+ }
1437
+ return diagnostics;
1438
+ }
1439
+ function formatEslintSummary(diagnostics) {
1440
+ const visible = diagnostics.slice(0, MAX_ESLINT_DIAGNOSTICS);
1441
+ const hidden = diagnostics.length - visible.length;
1442
+ const count = diagnostics.length;
1443
+ const noun = count === 1 ? "problem" : "problems";
1444
+ const lines = [` ${red("•")} [eslint] ${count} ESLint ${noun}`, ...visible.map((diagnostic) => `${diagnostic.file}:${diagnostic.line}:${diagnostic.column} ${diagnostic.severity} ${diagnostic.rule} — ${truncate(diagnostic.message, MAX_SUMMARY_LINE_LENGTH)}`)];
1445
+ if (hidden > 0) lines.push(dim(` +${hidden} more ESLint ${hidden === 1 ? "problem" : "problems"}. View full output for details.`));
1446
+ return lines.join("\n");
1447
+ }
1411
1448
  function firstMeaningfulLine(message) {
1412
1449
  return message.split("\n").map((l) => l.trim()).find((l) => l.length > 0 && !l.startsWith(">") && !l.startsWith("ELIFECYCLE")) ?? message;
1413
1450
  }
@@ -1873,6 +1910,199 @@ async function handleRetry() {
1873
1910
  else process.exit(1);
1874
1911
  }
1875
1912
  //#endregion
1913
+ //#region src/commands/setup.ts
1914
+ /** Marker files for each tool. First match wins per tool. */
1915
+ const TOOL_MARKERS = {
1916
+ biome: ["biome.json", "biome.jsonc"],
1917
+ eslint: [
1918
+ "eslint.config.js",
1919
+ "eslint.config.mjs",
1920
+ "eslint.config.ts",
1921
+ "eslint.config.cjs",
1922
+ ".eslintrc.js",
1923
+ ".eslintrc.cjs",
1924
+ ".eslintrc.json",
1925
+ ".eslintrc.yml",
1926
+ ".eslintrc.yaml",
1927
+ ".eslintrc"
1928
+ ],
1929
+ typescript: ["tsconfig.json"],
1930
+ vitest: [
1931
+ "vitest.config.js",
1932
+ "vitest.config.mts",
1933
+ "vitest.config.ts",
1934
+ "vitest.config.mjs"
1935
+ ]
1936
+ };
1937
+ /** Indent for generated config — matches biome.json `indentStyle: "tab"`. */
1938
+ const TAB = " ";
1939
+ async function exists(path) {
1940
+ try {
1941
+ await access(path, constants.R_OK);
1942
+ return true;
1943
+ } catch {
1944
+ return false;
1945
+ }
1946
+ }
1947
+ /**
1948
+ * Scan a directory for marker files that indicate which tools the project uses.
1949
+ * Returns a map of tool name to detected status. Order within each tool's list
1950
+ * is priority order (first match wins).
1951
+ */
1952
+ async function detectTools(cwd) {
1953
+ const result = {
1954
+ biome: false,
1955
+ eslint: false,
1956
+ typescript: false,
1957
+ vitest: false
1958
+ };
1959
+ for (const [tool, files] of Object.entries(TOOL_MARKERS)) for (const file of files) if (await exists(join(cwd, file))) {
1960
+ result[tool] = true;
1961
+ debug("setup: detected %s via %s", tool, file);
1962
+ break;
1963
+ }
1964
+ debug("setup: detection result %o", result);
1965
+ return result;
1966
+ }
1967
+ /**
1968
+ * Build the string content of a .cmintrc file from a detection result.
1969
+ * Returns tabs-indented TS/JS object literal with trailing commas. Biome is
1970
+ * preferred when both biome and eslint are present — overlapping globs would
1971
+ * cause both tools to run on the same files, which is wasteful and noisy.
1972
+ */
1973
+ function buildCmintrcContent(tools) {
1974
+ const entries = [];
1975
+ if (tools.biome || tools.eslint) {
1976
+ const cmd = tools.biome ? "biome check --write --no-errors-on-unmatched --error-on-warnings" : "eslint --fix";
1977
+ const ext = tools.biome ? "{js,ts,json}" : "{js,ts}";
1978
+ entries.push(`${TAB}"*.${ext}": "${cmd}",`);
1979
+ }
1980
+ const tsChecks = [];
1981
+ if (tools.typescript) tsChecks.push("tsc --noEmit");
1982
+ if (tools.vitest) tsChecks.push("vitest run --passWithNoTests");
1983
+ if (tsChecks.length > 0) {
1984
+ const body = tsChecks.map((c) => `"${c}"`).join(", ");
1985
+ const fn = tsChecks.length === 1 ? `() => ${body}` : `() => [${body}]`;
1986
+ entries.push(`${TAB}"*.ts": ${fn},`);
1987
+ }
1988
+ if (entries.length === 0) return `export default {\n};\n`;
1989
+ return `export default {\n${entries.join("\n")}\n};\n`;
1990
+ }
1991
+ /** Choose the file extension based on whether the project uses TypeScript. */
1992
+ function pickFileName(tools) {
1993
+ return tools.typescript ? ".cmintrc.ts" : ".cmintrc";
1994
+ }
1995
+ function formatDetection(tools) {
1996
+ return Object.entries(tools).map(([tool, found]) => ` ${found ? green("✓") : dim("✗")} ${tool}`).join("\n");
1997
+ }
1998
+ /**
1999
+ * Interactive setup for `.cmintrc`. Detects biome/eslint/typescript/vitest in
2000
+ * the given directory, previews the generated config, and writes the file
2001
+ * after confirmation. Refuses to overwrite without explicit consent. Defaults
2002
+ * to `process.cwd()` when called from the `cmint config` menu; the preflight
2003
+ * caller passes the repo root explicitly.
2004
+ */
2005
+ async function setupCmintrcCommand(cwd = process.cwd()) {
2006
+ debug("setupCmintrcCommand: starting in %s", cwd);
2007
+ const tools = await detectTools(cwd);
2008
+ p.log.info(`Detected tools in ${bold(cwd)}:`);
2009
+ p.log.message(formatDetection(tools));
2010
+ if (!Object.values(tools).some(Boolean)) p.log.warn("No recognized tools found. Writing an empty config to fill in manually.");
2011
+ else if (tools.biome && tools.eslint) p.log.warn(yellow("Both biome and eslint detected — using biome (remove this line to switch)."));
2012
+ const fileName = pickFileName(tools);
2013
+ const filePath = join(cwd, fileName);
2014
+ if (await exists(filePath)) {
2015
+ const overwrite = await p.confirm({ message: `${fileName} already exists. Overwrite?` });
2016
+ if (p.isCancel(overwrite) || !overwrite) {
2017
+ p.log.info(dim("Cancelled — existing file left untouched."));
2018
+ return;
2019
+ }
2020
+ }
2021
+ const content = buildCmintrcContent(tools);
2022
+ p.log.info(dim(`\nPreview of ${fileName}:`));
2023
+ p.log.message(dim(content));
2024
+ const confirm = await p.confirm({ message: `Write ${fileName}?` });
2025
+ if (p.isCancel(confirm) || !confirm) {
2026
+ p.log.info(dim("Cancelled."));
2027
+ return;
2028
+ }
2029
+ await writeFile(filePath, content, "utf-8");
2030
+ debug("setupCmintrcCommand: wrote %s", filePath);
2031
+ p.log.success(green(`Wrote ${fileName}`));
2032
+ }
2033
+ /** Project-local marker file that suppresses the preflight prompt forever. */
2034
+ const SKIP_SETUP_MARKER = ".cmint-skip-setup";
2035
+ /** True if at least one of biome/eslint/typescript/vitest is present. */
2036
+ function isAutoConfigurable(tools) {
2037
+ return Object.values(tools).some(Boolean);
2038
+ }
2039
+ /** True if the skip-setup marker exists in `cwd`. */
2040
+ async function hasSkipSetupMarker(cwd) {
2041
+ return exists(join(cwd, SKIP_SETUP_MARKER));
2042
+ }
2043
+ /** Write the skip-setup marker to `cwd`. The file is empty by design. */
2044
+ async function writeSkipSetupMarker(cwd) {
2045
+ const filePath = join(cwd, SKIP_SETUP_MARKER);
2046
+ await writeFile(filePath, "", "utf-8");
2047
+ debug("preflight: wrote skip-setup marker to %s", filePath);
2048
+ }
2049
+ /**
2050
+ * One-shot prompt run at the start of `cmint`. Skips silently if the user
2051
+ * already has a `.cmintrc` or has previously opted out (`.cmint-skip-setup`).
2052
+ * If the project is auto-configurable, asks the user whether to run setup
2053
+ * now. Choices: `yes` runs the standard setup flow; `no` proceeds without
2054
+ * setup and re-prompts next time; `never` writes a marker to suppress the
2055
+ * prompt for this project forever.
2056
+ */
2057
+ async function runPreflightSetupPrompt(cwd) {
2058
+ debug("preflight: checking %s", cwd);
2059
+ if (await hasSkipSetupMarker(cwd)) {
2060
+ debug("preflight: skip-setup marker present, skipping prompt");
2061
+ return;
2062
+ }
2063
+ const existingConfig = await detectConfig(cwd);
2064
+ if (existingConfig) {
2065
+ debug("preflight: .cmintrc present at %s, skipping prompt", existingConfig);
2066
+ return;
2067
+ }
2068
+ if (!isAutoConfigurable(await detectTools(cwd))) {
2069
+ debug("preflight: project not auto-configurable, skipping prompt");
2070
+ return;
2071
+ }
2072
+ const choice = await p.select({
2073
+ message: "No .cmintrc found. Run setup to create one from detected tools?",
2074
+ options: [
2075
+ {
2076
+ label: "Yes, set up .cmintrc",
2077
+ value: "yes"
2078
+ },
2079
+ {
2080
+ label: "No, skip for now",
2081
+ value: "no"
2082
+ },
2083
+ {
2084
+ label: "No, don't ask again",
2085
+ value: "never"
2086
+ }
2087
+ ]
2088
+ });
2089
+ if (p.isCancel(choice)) {
2090
+ debug("preflight: user cancelled prompt");
2091
+ return;
2092
+ }
2093
+ if (choice === "never") {
2094
+ await writeSkipSetupMarker(cwd);
2095
+ p.log.info(dim(`Won't ask again. Delete ${SKIP_SETUP_MARKER} to re-enable.`));
2096
+ return;
2097
+ }
2098
+ if (choice === "no") {
2099
+ p.log.info(dim("Skipping .cmintrc setup."));
2100
+ return;
2101
+ }
2102
+ debug("preflight: user chose yes, running setup");
2103
+ await setupCmintrcCommand(cwd);
2104
+ }
2105
+ //#endregion
1876
2106
  //#region src/ui/staging-menu.ts
1877
2107
  async function showStagingMenu(files, hasChecks) {
1878
2108
  debug("showStagingMenu: %d files", files.length);
@@ -2058,6 +2288,8 @@ async function commitCommand(flags) {
2058
2288
  debug("commitCommand called", { flags });
2059
2289
  await assertGitRepo();
2060
2290
  if (flags.retry) return handleRetry();
2291
+ const repoRoot = await getRepoRoot();
2292
+ await runPreflightSetupPrompt(repoRoot);
2061
2293
  intro("🌿 commit-mint");
2062
2294
  const status = await getStatusShort();
2063
2295
  debug("Git status:", status || "(empty)");
@@ -2104,7 +2336,7 @@ async function commitCommand(flags) {
2104
2336
  debug("All staged files are excluded:", diffResult.excludedFiles);
2105
2337
  const message = buildExcludedFilesMessage(diffResult.excludedFiles);
2106
2338
  log.info(diffResult.excludedFiles.map((f) => ` ${f}`).join("\n"));
2107
- await saveCachedCommit(await getRepoRoot(), message);
2339
+ await saveCachedCommit(repoRoot, message);
2108
2340
  s.start("Running pre-commit hooks...");
2109
2341
  const result = await commitWithRecovery(message, s, await getHead());
2110
2342
  if (result === "committed") {
@@ -2163,7 +2395,6 @@ async function commitCommand(flags) {
2163
2395
  return;
2164
2396
  }
2165
2397
  message = reviewed;
2166
- const repoRoot = await getRepoRoot();
2167
2398
  await saveCachedCommit(repoRoot, message);
2168
2399
  debug("Message cached for repo:", repoRoot);
2169
2400
  s.start("Running pre-commit hooks...");
@@ -2347,13 +2578,20 @@ async function configCommand() {
2347
2578
  p.note(buildConfigDisplay(config), "commit-mint config");
2348
2579
  const action = await p.select({
2349
2580
  message: "What would you like to do?",
2350
- options: [{
2351
- label: "Edit settings",
2352
- value: "edit"
2353
- }, {
2354
- label: "Done",
2355
- value: "done"
2356
- }]
2581
+ options: [
2582
+ {
2583
+ label: "Edit settings",
2584
+ value: "edit"
2585
+ },
2586
+ {
2587
+ label: "Setup .cmintrc",
2588
+ value: "setup"
2589
+ },
2590
+ {
2591
+ label: "Done",
2592
+ value: "done"
2593
+ }
2594
+ ]
2357
2595
  });
2358
2596
  if (p.isCancel(action)) {
2359
2597
  debug("configCommand: cancelled at main menu");
@@ -2365,6 +2603,11 @@ async function configCommand() {
2365
2603
  p.outro("Config saved.");
2366
2604
  return;
2367
2605
  }
2606
+ if (action === "setup") {
2607
+ debug("configCommand: starting .cmintrc setup");
2608
+ await setupCmintrcCommand();
2609
+ continue;
2610
+ }
2368
2611
  await editSettingsLoop(config);
2369
2612
  }
2370
2613
  }