@commitguard/cli 0.0.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.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "dotenv/config";
package/dist/index.mjs ADDED
@@ -0,0 +1,1055 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ import process from "node:process";
4
+ import { consola } from "consola";
5
+ import updateNotifier from "update-notifier";
6
+ import { execFileSync, execSync } from "node:child_process";
7
+ import { createHash } from "node:crypto";
8
+ import { cancel, confirm, intro, isCancel, log, multiselect, note, outro, text } from "@clack/prompts";
9
+ import { Entry } from "@napi-rs/keyring";
10
+ import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { dirname, join } from "node:path";
13
+ import { readFile } from "node:fs/promises";
14
+ import { pathToFileURL } from "node:url";
15
+ import { findUp } from "find-up";
16
+ import { FlatCache } from "flat-cache";
17
+ import stringWidth from "string-width";
18
+ import "dotenv/config";
19
+
20
+ //#region rolldown:runtime
21
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
22
+
23
+ //#endregion
24
+ //#region package.json
25
+ var version = "0.0.1";
26
+ var package_default = {
27
+ name: "@commitguard/cli",
28
+ type: "module",
29
+ version,
30
+ description: "AI-powered git commit checker that blocks bad code before it ships",
31
+ license: "MIT",
32
+ repository: {
33
+ "type": "git",
34
+ "url": "git+https://github.com/moshetanzer/commitguard.git"
35
+ },
36
+ exports: {
37
+ ".": "./dist/index.mjs",
38
+ "./package.json": "./package.json"
39
+ },
40
+ main: "./dist/index.mjs",
41
+ module: "./dist/index.mjs",
42
+ types: "./dist/index.d.mts",
43
+ bin: { "commitguard": "./dist/index.mjs" },
44
+ files: ["dist"],
45
+ scripts: {
46
+ "build": "tsdown",
47
+ "dev": "tsdown --watch",
48
+ "test": "vitest",
49
+ "typecheck": "tsc --noEmit",
50
+ "prepublishOnly": "pnpm run build",
51
+ "lint": "eslint .",
52
+ "lint:fix": "eslint . --fix"
53
+ },
54
+ dependencies: {
55
+ "@clack/prompts": "^0.11.0",
56
+ "@napi-rs/keyring": "^1.2.0",
57
+ "consola": "^3.4.2",
58
+ "dotenv": "^17.2.3",
59
+ "find-up": "^8.0.0",
60
+ "flat-cache": "^6.1.19",
61
+ "micromatch": "^4.0.8",
62
+ "string-width": "^8.1.0",
63
+ "update-notifier": "^7.3.1"
64
+ },
65
+ devDependencies: {
66
+ "@antfu/eslint-config": "^6.7.3",
67
+ "@types/micromatch": "^4.0.10",
68
+ "@types/node": "^24.10.4",
69
+ "@types/update-notifier": "^6.0.8",
70
+ "bumpp": "^10.3.2",
71
+ "eslint": "^9.39.2",
72
+ "tsdown": "^0.18.3",
73
+ "typescript": "^5.9.3",
74
+ "vitest": "^4.0.16"
75
+ }
76
+ };
77
+
78
+ //#endregion
79
+ //#region src/data/ignore.json
80
+ var ignore = [
81
+ ".git/**",
82
+ ".svn/**",
83
+ ".hg/**",
84
+ "node_modules/**",
85
+ "vendor/**",
86
+ ".pnp/**",
87
+ "Pods/**",
88
+ "Packages/**",
89
+ "package.json",
90
+ "package-lock.json",
91
+ "pnpm-lock.yaml",
92
+ "yarn.lock",
93
+ "bun.lockb",
94
+ "composer.lock",
95
+ "poetry.lock",
96
+ "Pipfile.lock",
97
+ "Cargo.lock",
98
+ "go.sum",
99
+ "mix.lock",
100
+ "dist/**",
101
+ "build/**",
102
+ "out/**",
103
+ ".output/**",
104
+ ".nuxt/**",
105
+ ".next/**",
106
+ ".svelte-kit/**",
107
+ "public/build/**",
108
+ "target/**",
109
+ "bin/**",
110
+ "obj/**",
111
+ "Generated/**",
112
+ ".angular/**",
113
+ ".expo/**",
114
+ ".vite/**",
115
+ ".vercel/**",
116
+ ".netlify/**",
117
+ "DerivedData/**",
118
+ "*.xcworkspace/xcuserdata/**",
119
+ "android/app/build/**",
120
+ "ios/build/**",
121
+ "*.class",
122
+ "*.jar",
123
+ "*.war",
124
+ "*.ear",
125
+ "*.kotlin_module",
126
+ "__pycache__/**",
127
+ "*.pyc",
128
+ "*.pyo",
129
+ "*.pyd",
130
+ "*.egg-info/**",
131
+ ".pytest_cache/**",
132
+ ".mypy_cache/**",
133
+ "target/**",
134
+ "*.o",
135
+ "*.obj",
136
+ "*.so",
137
+ "*.a",
138
+ "*.dll",
139
+ "*.exe",
140
+ "*.out",
141
+ "*.pdb",
142
+ "*.zip",
143
+ "*.tar",
144
+ "*.gz",
145
+ "*.bz2",
146
+ "*.7z",
147
+ "*.rar",
148
+ "*.iso",
149
+ "*.img",
150
+ "*.dmg",
151
+ "*.map",
152
+ "*.min.js",
153
+ "*.min.css",
154
+ "*.bundle.js",
155
+ "*.wasm",
156
+ "*.log",
157
+ "logs/**",
158
+ ".cache/**",
159
+ ".parcel-cache/**",
160
+ ".eslintcache",
161
+ ".tsbuildinfo",
162
+ "*.png",
163
+ "*.jpg",
164
+ "*.jpeg",
165
+ "*.gif",
166
+ "*.bmp",
167
+ "*.svg",
168
+ "*.webp",
169
+ "*.avif",
170
+ "*.ico",
171
+ "*.mp4",
172
+ "*.webm",
173
+ "*.mov",
174
+ "*.mp3",
175
+ "*.wav",
176
+ "*.ogg",
177
+ "*.flac",
178
+ "*.snap",
179
+ "__snapshots__/**",
180
+ "playwright-report/**",
181
+ "test-results/**",
182
+ ".nyc_output/**",
183
+ "docs/.vitepress/dist/**",
184
+ "storybook-static/**",
185
+ ".DS_Store",
186
+ "Thumbs.db",
187
+ ".idea/**",
188
+ ".vscode/**",
189
+ "*.swp",
190
+ "*.dump",
191
+ "*.dmp",
192
+ "coverage/**",
193
+ "tmp/**",
194
+ "temp/**",
195
+ "*.lock",
196
+ "*.sqlite",
197
+ "*.db",
198
+ "*.rdb"
199
+ ];
200
+
201
+ //#endregion
202
+ //#region src/utils/global.ts
203
+ function createDiffHash(diff) {
204
+ return createHash("md5").update(diff).digest("base64url");
205
+ }
206
+ function addGitLineNumbers(diff) {
207
+ if (!diff.trim()) return diff;
208
+ const lines = diff.split("\n");
209
+ const result = [];
210
+ let oldLine = 0;
211
+ let newLine = 0;
212
+ for (const line of lines) if (line.startsWith("@@")) {
213
+ const match = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
214
+ if (match) {
215
+ oldLine = Number.parseInt(match[1], 10);
216
+ newLine = Number.parseInt(match[2], 10);
217
+ }
218
+ result.push(line);
219
+ } else if (line.startsWith("---") || line.startsWith("+++") || line.startsWith("diff ") || line.startsWith("index ")) result.push(line);
220
+ else if (line.startsWith("-")) {
221
+ result.push(`${oldLine}:${line}`);
222
+ oldLine++;
223
+ } else if (line.startsWith("+")) {
224
+ result.push(`${newLine}:${line}`);
225
+ newLine++;
226
+ } else {
227
+ result.push(`${newLine}:${line}`);
228
+ oldLine++;
229
+ newLine++;
230
+ }
231
+ return result.join("\n");
232
+ }
233
+
234
+ //#endregion
235
+ //#region src/utils/git.ts
236
+ function getStagedDiff() {
237
+ try {
238
+ return addGitLineNumbers(execFileSync("git", [
239
+ "diff",
240
+ "--cached",
241
+ "--no-color",
242
+ "--function-context",
243
+ "--diff-algorithm=histogram",
244
+ "--diff-filter=AMC",
245
+ "--",
246
+ ".",
247
+ ...ignore.map((p) => `:(exclude)${p}`)
248
+ ], {
249
+ encoding: "utf8",
250
+ maxBuffer: 10 * 1024 * 1024,
251
+ stdio: [
252
+ "pipe",
253
+ "pipe",
254
+ "ignore"
255
+ ]
256
+ }));
257
+ } catch {
258
+ return "";
259
+ }
260
+ }
261
+ function getLastDiff() {
262
+ try {
263
+ return execFileSync("git", [
264
+ "diff",
265
+ "HEAD~1",
266
+ "HEAD",
267
+ "--no-color",
268
+ "--function-context",
269
+ "--diff-algorithm=histogram",
270
+ "--diff-filter=AMC",
271
+ "--",
272
+ ".",
273
+ ...ignore.map((p) => `:(exclude)${p}`)
274
+ ], {
275
+ encoding: "utf8",
276
+ maxBuffer: 10 * 1024 * 1024,
277
+ stdio: [
278
+ "pipe",
279
+ "pipe",
280
+ "ignore"
281
+ ]
282
+ });
283
+ } catch {
284
+ return "";
285
+ }
286
+ }
287
+
288
+ //#endregion
289
+ //#region src/utils/key.ts
290
+ const store = new Entry("commit_guard", "global_key");
291
+ function setGlobalKey(apiKey) {
292
+ store.setPassword(apiKey);
293
+ }
294
+ function getGlobalKey() {
295
+ return store.getPassword();
296
+ }
297
+ function deleteGlobalKey() {
298
+ store.deletePassword();
299
+ }
300
+ function validateApiKey(value) {
301
+ if (!value || value.trim() === "") return "API key cannot be empty.";
302
+ if (value.startsWith("sk_") === false) return "Invalid API key format. It should start with \"sk_\".";
303
+ }
304
+ async function manageGlobalKey() {
305
+ try {
306
+ if (getGlobalKey()) {
307
+ intro("An existing API key was found.");
308
+ if (await confirm({
309
+ message: "Do you want to delete the existing global API key?",
310
+ initialValue: false
311
+ })) {
312
+ deleteGlobalKey();
313
+ log.success("Global API key deleted.");
314
+ if (await confirm({
315
+ message: "Do you want to add a new global API key?",
316
+ initialValue: true
317
+ })) {
318
+ const apiKey = await text({
319
+ message: "Enter your CommitGuard API key:",
320
+ placeholder: "sk_XXXXXXXXXXXXXXXXXXXXXX",
321
+ validate: validateApiKey
322
+ });
323
+ if (typeof apiKey === "string") {
324
+ setGlobalKey(apiKey);
325
+ outro("New global API key set successfully.");
326
+ }
327
+ } else outro("API key removed. You can set a new one later using `commitguard init` or `commitguard keys`.");
328
+ }
329
+ } else {
330
+ note("To get your free API key, visit https://commitguard.dev", "Get your free API key");
331
+ const apiKey = await text({
332
+ message: "Enter your CommitGuard API key:",
333
+ placeholder: "sk_XXXXXXXXXXXXXXXXXXXXXX",
334
+ validate: validateApiKey
335
+ });
336
+ if (typeof apiKey === "string") {
337
+ setGlobalKey(apiKey);
338
+ log.success("Global API key set successfully.");
339
+ }
340
+ }
341
+ } catch (error) {
342
+ const err = error;
343
+ if (err.name === "ExitPromptError") {
344
+ log.message("\nšŸ‘‹ Until next time!");
345
+ return;
346
+ }
347
+ log.error(`Error managing global API key: ${err.message}`);
348
+ }
349
+ }
350
+
351
+ //#endregion
352
+ //#region src/utils/api.ts
353
+ async function sendToCommitGuard(diff, eslint, config) {
354
+ const apiKey = process.env.COMMITGUARD_API_KEY || getGlobalKey() || null;
355
+ if (!apiKey) throw new Error("No API key found. Set one globally with \"commitguard keys\" or add COMMITGUARD_API_KEY to your .env file. Get your free API key at https://commitguard.dev");
356
+ const apiUrl = process.env.COMMITGUARD_API_URL || "https://api.commitguard.ai/v1/analyze";
357
+ const response = await fetch(apiUrl, {
358
+ method: "POST",
359
+ headers: {
360
+ "Content-Type": "application/json",
361
+ "Authorization": `Bearer ${apiKey}`,
362
+ "User-Agent": "commitguard-cli"
363
+ },
364
+ body: JSON.stringify({
365
+ diff,
366
+ eslint,
367
+ config
368
+ })
369
+ });
370
+ if (!response.ok) {
371
+ const errorText = await response.text();
372
+ throw new Error(`API request failed (${response.status}): ${errorText}`);
373
+ }
374
+ return await response.json();
375
+ }
376
+ async function bypassCommitGuard() {
377
+ const apiKey = process.env.COMMITGUARD_API_KEY || getGlobalKey() || null;
378
+ if (!apiKey) throw new Error("No API key found. Set one globally with \"commitguard keys\" or add COMMITGUARD_API_KEY to your .env file. Get your free API key at https://commitguard.dev");
379
+ const apiUrl = process.env.COMMITGUARD_API_BYPASS_URL || "https://api.commitguard.ai/v1/bypass";
380
+ const diff = getLastDiff();
381
+ const response = await fetch(apiUrl, {
382
+ method: "POST",
383
+ headers: {
384
+ "Content-Type": "application/json",
385
+ "Authorization": `Bearer ${apiKey}`,
386
+ "User-Agent": "commitguard-cli"
387
+ },
388
+ body: JSON.stringify({ diff })
389
+ });
390
+ if (!response.ok) {
391
+ const errorText = await response.text();
392
+ throw new Error(`API request failed (${response.status}): ${errorText}`);
393
+ }
394
+ return await response.json();
395
+ }
396
+
397
+ //#endregion
398
+ //#region src/utils/config.ts
399
+ const MAX_CUSTOM_PROMPT_LENGTH = 500;
400
+ const CONFIG_DIR = join(homedir(), ".commitguard");
401
+ const PROJECTS_CONFIG_PATH = join(CONFIG_DIR, "projects.json");
402
+ let projectsConfigCache = null;
403
+ function ensureConfigDir() {
404
+ if (!existsSync(CONFIG_DIR)) try {
405
+ mkdirSync(CONFIG_DIR, { recursive: true });
406
+ } catch (e) {
407
+ consola.error(`Failed to create config directory at ${CONFIG_DIR}: ${e.message}`);
408
+ }
409
+ }
410
+ function getDefaultConfig() {
411
+ return {
412
+ checks: {
413
+ security: true,
414
+ performance: true,
415
+ codeQuality: true,
416
+ architecture: true
417
+ },
418
+ severityLevels: {
419
+ critical: true,
420
+ warning: true,
421
+ suggestion: true
422
+ },
423
+ customRule: ""
424
+ };
425
+ }
426
+ let projectIdCache = null;
427
+ function getProjectId() {
428
+ if (projectIdCache) return projectIdCache;
429
+ try {
430
+ projectIdCache = execFileSync("git", [
431
+ "rev-list",
432
+ "--max-parents=0",
433
+ "HEAD"
434
+ ], {
435
+ encoding: "utf8",
436
+ stdio: [
437
+ "pipe",
438
+ "pipe",
439
+ "ignore"
440
+ ]
441
+ }).trim().split("\n")[0];
442
+ return projectIdCache;
443
+ } catch {
444
+ consola.error("Warning: Unable to determine project ID. Using current working directory as fallback project ID.");
445
+ projectIdCache = process.cwd();
446
+ return projectIdCache;
447
+ }
448
+ }
449
+ function loadProjectsConfig() {
450
+ if (projectsConfigCache) return projectsConfigCache;
451
+ if (existsSync(PROJECTS_CONFIG_PATH)) try {
452
+ const content = readFileSync(PROJECTS_CONFIG_PATH, "utf8");
453
+ projectsConfigCache = JSON.parse(content);
454
+ return projectsConfigCache;
455
+ } catch {
456
+ consola.warn("Failed to parse projects config");
457
+ }
458
+ projectsConfigCache = {};
459
+ return projectsConfigCache;
460
+ }
461
+ function saveProjectsConfig(projects) {
462
+ try {
463
+ ensureConfigDir();
464
+ writeFileSync(PROJECTS_CONFIG_PATH, JSON.stringify(projects, null, 2));
465
+ projectsConfigCache = projects;
466
+ } catch (e) {
467
+ consola.error(`Failed to save projects config: ${e.message}`);
468
+ }
469
+ }
470
+ function loadConfig() {
471
+ const projectId = getProjectId();
472
+ return loadProjectsConfig()[projectId] || getDefaultConfig();
473
+ }
474
+ async function manageConfig() {
475
+ const projectId = getProjectId();
476
+ const currentConfig = loadConfig();
477
+ intro(`CommitGuard Configuration`);
478
+ const enabledChecks = await multiselect({
479
+ message: "Select enabled checks for this project:",
480
+ options: [
481
+ {
482
+ value: "security",
483
+ label: "Security"
484
+ },
485
+ {
486
+ value: "performance",
487
+ label: "Performance"
488
+ },
489
+ {
490
+ value: "codeQuality",
491
+ label: "Code Quality"
492
+ },
493
+ {
494
+ value: "architecture",
495
+ label: "Architecture"
496
+ }
497
+ ],
498
+ initialValues: Object.entries(currentConfig.checks).filter(([_, enabled]) => enabled).map(([key]) => key)
499
+ });
500
+ if (isCancel(enabledChecks)) {
501
+ cancel("Configuration cancelled");
502
+ return;
503
+ }
504
+ const enabledSeverity = await multiselect({
505
+ message: "Select severity levels for enabled checks:",
506
+ options: [
507
+ {
508
+ value: "suggestion",
509
+ label: "Suggestion"
510
+ },
511
+ {
512
+ value: "warning",
513
+ label: "Warning"
514
+ },
515
+ {
516
+ value: "critical",
517
+ label: "Critical"
518
+ }
519
+ ],
520
+ initialValues: Object.entries(currentConfig.severityLevels).filter(([_, enabled]) => enabled).map(([key]) => key)
521
+ });
522
+ if (isCancel(enabledSeverity)) {
523
+ cancel("Configuration cancelled");
524
+ return;
525
+ }
526
+ let customRule = currentConfig.customRule;
527
+ if (currentConfig.customRule) {
528
+ consola.info(`Current custom rule: ${currentConfig.customRule}`);
529
+ const editCustomRule = await confirm({
530
+ message: "Would you like to edit the custom rule? (Currently only available to pro users)",
531
+ initialValue: false
532
+ });
533
+ if (isCancel(editCustomRule)) {
534
+ cancel("Configuration cancelled");
535
+ return;
536
+ }
537
+ if (editCustomRule) {
538
+ const newCustomRule = await text({
539
+ message: "Enter new custom rule (leave empty to remove):",
540
+ initialValue: currentConfig.customRule,
541
+ validate: (value) => {
542
+ const val = String(value).trim();
543
+ if (!val) return void 0;
544
+ if (val.length > MAX_CUSTOM_PROMPT_LENGTH) return `Custom rule must be ${MAX_CUSTOM_PROMPT_LENGTH} characters or less (current: ${val.length})`;
545
+ }
546
+ });
547
+ if (isCancel(newCustomRule)) {
548
+ cancel("Configuration cancelled");
549
+ return;
550
+ }
551
+ customRule = String(newCustomRule).trim();
552
+ }
553
+ } else {
554
+ const addCustomRule = await confirm({
555
+ message: "Would you like to add a custom rule for this project? (Currently only available to pro users)",
556
+ initialValue: false
557
+ });
558
+ if (isCancel(addCustomRule)) {
559
+ cancel("Configuration cancelled");
560
+ return;
561
+ }
562
+ if (addCustomRule) {
563
+ const newCustomRule = await text({
564
+ message: "Enter custom rule (leave empty to skip):",
565
+ placeholder: "e.g., Check for proper error handling in async functions",
566
+ validate: (value) => {
567
+ const val = String(value).trim();
568
+ if (!val) return void 0;
569
+ if (val.length > MAX_CUSTOM_PROMPT_LENGTH) return `Custom rule must be ${MAX_CUSTOM_PROMPT_LENGTH} characters or less (current: ${val.length})`;
570
+ }
571
+ });
572
+ if (isCancel(newCustomRule)) {
573
+ cancel("Configuration cancelled");
574
+ return;
575
+ }
576
+ customRule = String(newCustomRule).trim();
577
+ }
578
+ }
579
+ const newConfig = {
580
+ checks: {
581
+ security: enabledChecks.includes("security"),
582
+ performance: enabledChecks.includes("performance"),
583
+ codeQuality: enabledChecks.includes("codeQuality"),
584
+ architecture: enabledChecks.includes("architecture")
585
+ },
586
+ severityLevels: {
587
+ suggestion: enabledSeverity.includes("suggestion"),
588
+ warning: enabledSeverity.includes("warning"),
589
+ critical: enabledSeverity.includes("critical")
590
+ },
591
+ customRule
592
+ };
593
+ if (JSON.stringify(newConfig) === JSON.stringify(currentConfig)) {
594
+ outro("No changes made to the configuration.");
595
+ return;
596
+ }
597
+ const confirmUpdate = await confirm({ message: "Save this configuration?" });
598
+ if (isCancel(confirmUpdate)) {
599
+ cancel("Configuration cancelled");
600
+ return;
601
+ }
602
+ if (!confirmUpdate) {
603
+ outro("Configuration not saved.");
604
+ return;
605
+ }
606
+ const projects = loadProjectsConfig();
607
+ projects[projectId] = newConfig;
608
+ saveProjectsConfig(projects);
609
+ outro("āœ“ Configuration updated for this project!");
610
+ }
611
+
612
+ //#endregion
613
+ //#region src/utils/eslint.ts
614
+ const cacheDir = join(homedir(), ".cache", "commitguard");
615
+ const cache = new FlatCache({
616
+ cacheDir,
617
+ cacheId: "eslint-config"
618
+ });
619
+ cache.load("eslint-config", cacheDir);
620
+ async function findProjectRoot(startDir) {
621
+ const packageJsonPath = await findUp("package.json");
622
+ return packageJsonPath ? dirname(packageJsonPath) : startDir;
623
+ }
624
+ async function getEslintRules({ startDir = process.cwd(), overrideCache = false } = {}) {
625
+ const cacheKey = `eslint-${startDir}`;
626
+ const cached = cache.getKey(cacheKey);
627
+ if (cached && !overrideCache) return cached;
628
+ const projectRoot = await findProjectRoot(startDir);
629
+ const loaders = [
630
+ ".eslintrc",
631
+ ".eslintrc.json",
632
+ ".eslintrc.js",
633
+ ".eslintrc.mjs",
634
+ "eslint.config.js",
635
+ "eslint.config.mjs",
636
+ "package.json"
637
+ ].map((file) => join(projectRoot, file)).map(async (full) => {
638
+ if (!existsSync(full)) return null;
639
+ if (full.endsWith(".json") || full.endsWith(".eslintrc")) try {
640
+ const raw = JSON.parse(await readFile(full, "utf8"));
641
+ if (raw.rules) return {
642
+ rules: raw.rules,
643
+ source: full
644
+ };
645
+ } catch {}
646
+ if (full.endsWith(".js") || full.endsWith(".mjs")) try {
647
+ const mod = await import(pathToFileURL(full).href);
648
+ let cfg = mod.default || mod;
649
+ if (cfg instanceof Promise) cfg = await cfg;
650
+ if (Array.isArray(cfg)) {
651
+ const lastConfigWithRules = [...cfg].reverse().find((c) => c.rules && Object.keys(c.rules).length > 0);
652
+ if (lastConfigWithRules?.rules) return {
653
+ rules: lastConfigWithRules.rules,
654
+ source: full
655
+ };
656
+ }
657
+ if (cfg.rules) return {
658
+ rules: cfg.rules,
659
+ source: full
660
+ };
661
+ } catch {}
662
+ if (full.endsWith("package.json")) try {
663
+ const pkg = JSON.parse(await readFile(full, "utf8"));
664
+ if (pkg.eslintConfig && pkg.eslintConfig.rules) return {
665
+ rules: pkg.eslintConfig.rules,
666
+ source: full
667
+ };
668
+ } catch {}
669
+ return null;
670
+ });
671
+ const config = (await Promise.all(loaders)).find((r) => r !== null) ?? {
672
+ rules: {},
673
+ source: null
674
+ };
675
+ cache.setKey(cacheKey, config);
676
+ cache.save(true);
677
+ return config;
678
+ }
679
+
680
+ //#endregion
681
+ //#region src/utils/install.ts
682
+ const COMMITGUARD_MARKER = "# CommitGuard commit-msg hook";
683
+ const POST_INDEX_MARKER = "# CommitGuard post-index-change hook";
684
+ const GIT_DIR = ".git";
685
+ const HOOKS_DIR = join(GIT_DIR, "hooks");
686
+ const COMMIT_MSG_HOOK_PATH = join(HOOKS_DIR, "commit-msg");
687
+ const POST_INDEX_HOOK_PATH = join(HOOKS_DIR, "post-index-change");
688
+ const MESSAGES = { noGit: "No .git folder found. Run this inside a git repository." };
689
+ async function installHooks() {
690
+ if (!existsSync(GIT_DIR)) {
691
+ cancel(MESSAGES.noGit);
692
+ process.exit(1);
693
+ }
694
+ if (!existsSync(HOOKS_DIR)) mkdirSync(HOOKS_DIR, { recursive: true });
695
+ try {
696
+ const versionMatch = execSync("git --version", { encoding: "utf8" }).match(/(\d+\.\d+\.\d+)/);
697
+ if ((versionMatch ? versionMatch[1] : "0.0.0") < "2.34.0") {
698
+ log.warn("Your Git version is below 2.34.0. CommitGuard requires Git 2.34.0 or higher to function properly.");
699
+ note("You can download the latest version of Git from https://git-scm.com/downloads", "How to update Git");
700
+ return;
701
+ }
702
+ } catch {
703
+ log.warn("Unable to determine Git version. Please ensure you have Git 2.34.0 or higher installed for CommitGuard to function properly.");
704
+ note("You can download the latest version of Git from https://git-scm.com/downloads", "How to update Git");
705
+ return;
706
+ }
707
+ if (existsSync(COMMIT_MSG_HOOK_PATH)) {
708
+ if (readFileSync(COMMIT_MSG_HOOK_PATH, "utf8").includes(COMMITGUARD_MARKER)) {
709
+ outro("CommitGuard is already installed.");
710
+ return;
711
+ }
712
+ if (!await confirm({
713
+ message: "CommitGuard uses the git `commit-msg` hook to function properly. A commit-msg hook already exists. Do you want to overwrite it?",
714
+ initialValue: true
715
+ })) {
716
+ outro("Installation cancelled. CommitGuard was not installed.");
717
+ return;
718
+ }
719
+ }
720
+ if (existsSync(POST_INDEX_HOOK_PATH)) {
721
+ if (!readFileSync(POST_INDEX_HOOK_PATH, "utf8").includes(POST_INDEX_MARKER)) {
722
+ if (!await confirm({
723
+ message: "A post-index-change hook already exists. Do you want to overwrite it?",
724
+ initialValue: true
725
+ })) {
726
+ log.error("Installation cancelled. CommitGuard was not installed.");
727
+ return;
728
+ }
729
+ }
730
+ }
731
+ log.info("Installing CommitGuard...");
732
+ const node = process.execPath.replace(/\\/g, "/");
733
+ const cliPath = __require.resolve("commitguard").replace(/\\/g, "/");
734
+ writeFileSync(COMMIT_MSG_HOOK_PATH, `#!/bin/sh
735
+ ${COMMITGUARD_MARKER}
736
+ # Auto-generated - do not edit manually
737
+
738
+ commit_msg_file="$1"
739
+
740
+ if grep -q -- "--skip" "$commit_msg_file"; then
741
+ echo "āš ļø CommitGuard bypassed with --skip"
742
+
743
+ sed 's/--skip//g' "$commit_msg_file" > "$commit_msg_file.tmp"
744
+ mv "$commit_msg_file.tmp" "$commit_msg_file"
745
+
746
+ trap '(sleep 1 && "${node}" "${cliPath}" bypass > /dev/null 2>&1 &)' EXIT
747
+
748
+ exit 0
749
+ fi
750
+
751
+ if [ ! -s "$commit_msg_file" ] || ! grep -qv '^#' "$commit_msg_file"; then
752
+ exit 0
753
+ fi
754
+
755
+ if [ -t 1 ]; then
756
+ "${node}" "${cliPath}" pre-commit < /dev/tty
757
+ RESULT=$?
758
+ else
759
+ "${node}" "${cliPath}" pre-commit
760
+ RESULT=$?
761
+ fi
762
+
763
+ if [ $RESULT -ne 0 ]; then
764
+ exit 1
765
+ fi
766
+
767
+ exit 0
768
+ `, { mode: 493 });
769
+ writeFileSync(POST_INDEX_HOOK_PATH, `#!/bin/sh
770
+ ${POST_INDEX_MARKER}
771
+ # Auto-generated - do not edit manually
772
+
773
+ # Skip if only flags changed (not actual content)
774
+ if [ "$1" = "1" ]; then
775
+ exit 0
776
+ fi
777
+
778
+ "${node}" "${cliPath}" staged > /dev/null 2>&1 &
779
+
780
+ exit 0
781
+ `, { mode: 493 });
782
+ log.info("Analyzing ESLint configuration for better checks...");
783
+ await getEslintRules({ overrideCache: true });
784
+ log.success("ESLint configuration loaded.");
785
+ if (!getGlobalKey() && process.env.COMMITGUARD_API_KEY === void 0) {
786
+ if (await confirm({
787
+ message: "No global API key found. Do you want to set it now?",
788
+ initialValue: true
789
+ })) await manageGlobalKey();
790
+ }
791
+ outro("You are all set! CommitGuard has been installed successfully.");
792
+ }
793
+ async function listHooks() {
794
+ if (!existsSync(GIT_DIR) || !existsSync(HOOKS_DIR)) {
795
+ outro(MESSAGES.noGit);
796
+ return;
797
+ }
798
+ const hooks = readdirSync(HOOKS_DIR).filter((file) => {
799
+ const filePath = join(HOOKS_DIR, file);
800
+ return statSync(filePath).isFile() && !file.endsWith(".sample") && !file.startsWith(".") && (readFileSync(filePath, "utf8").includes(COMMITGUARD_MARKER) || readFileSync(filePath, "utf8").includes(POST_INDEX_MARKER));
801
+ });
802
+ if (hooks.length === 0) {
803
+ outro();
804
+ return;
805
+ }
806
+ for (const hook of hooks) log.success(hook);
807
+ outro("Run \"commitguard remove\" to uninstall CommitGuard.");
808
+ }
809
+ async function removeHooks() {
810
+ if (!existsSync(GIT_DIR)) {
811
+ cancel(MESSAGES.noGit);
812
+ process.exit(1);
813
+ }
814
+ const commitMsgExists = existsSync(COMMIT_MSG_HOOK_PATH);
815
+ const postIndexExists = existsSync(POST_INDEX_HOOK_PATH);
816
+ if (!commitMsgExists && !postIndexExists) {
817
+ log.info("CommitGuard is not installed in this repository.");
818
+ return;
819
+ }
820
+ if (!await confirm({
821
+ message: "Are you sure you want to remove CommitGuard from this repository?",
822
+ initialValue: false
823
+ })) {
824
+ outro("CommitGuard uninstallation cancelled.");
825
+ return;
826
+ }
827
+ if (commitMsgExists) unlinkSync(COMMIT_MSG_HOOK_PATH);
828
+ if (postIndexExists) unlinkSync(POST_INDEX_HOOK_PATH);
829
+ log.success("CommitGuard uninstalled successfully!");
830
+ outro("Your commits are no longer be secured by CommitGuard.");
831
+ }
832
+
833
+ //#endregion
834
+ //#region src/utils/staged.ts
835
+ const CACHE_PATH = join(".git", "commitguard-cache.json");
836
+ const CATEGORY_LABELS = {
837
+ security: "🚨 [SECURITY]",
838
+ performance: "šŸš€ [PERFORMANCE]",
839
+ code_quality: "✨ [CODE QUALITY]",
840
+ architecture: "šŸ—ļø [ARCHITECTURE]"
841
+ };
842
+ const SEVERITY = {
843
+ critical: "CRITICAL",
844
+ warning: "WARNING",
845
+ suggestion: "SUGGESTION"
846
+ };
847
+ const LABEL_WIDTH = Math.max(...Object.values(CATEGORY_LABELS).map((label) => stringWidth(label)));
848
+ function padLabel(label) {
849
+ const pad = LABEL_WIDTH - stringWidth(label);
850
+ return label + " ".repeat(pad);
851
+ }
852
+ const SEVERITY_WIDTH = Math.max(...Object.values(SEVERITY).map((sev) => stringWidth(sev) + 2));
853
+ function padSeverity(severity) {
854
+ const pad = SEVERITY_WIDTH - stringWidth(severity);
855
+ return severity + " ".repeat(pad);
856
+ }
857
+ let memoryCache = null;
858
+ function readCache() {
859
+ if (memoryCache) return memoryCache;
860
+ if (!existsSync(CACHE_PATH)) return null;
861
+ try {
862
+ const content = readFileSync(CACHE_PATH, "utf8");
863
+ memoryCache = JSON.parse(content);
864
+ return memoryCache;
865
+ } catch {
866
+ return null;
867
+ }
868
+ }
869
+ function writeCache(data) {
870
+ memoryCache = data;
871
+ writeFileSync(CACHE_PATH, JSON.stringify(data));
872
+ }
873
+ function clearCache() {
874
+ memoryCache = null;
875
+ if (existsSync(CACHE_PATH)) unlinkSync(CACHE_PATH);
876
+ }
877
+ function groupIssuesByFile(issues = []) {
878
+ const grouped = {};
879
+ const noFile = [];
880
+ for (const issue of issues) {
881
+ if (!issue.file) {
882
+ noFile.push(issue);
883
+ continue;
884
+ }
885
+ if (!grouped[issue.file]) grouped[issue.file] = [];
886
+ grouped[issue.file].push(issue);
887
+ }
888
+ return {
889
+ grouped,
890
+ noFile
891
+ };
892
+ }
893
+ async function onStaged() {
894
+ const diff = getStagedDiff();
895
+ if (!diff.trim()) {
896
+ clearCache();
897
+ return;
898
+ }
899
+ const diffHash = createDiffHash(diff);
900
+ const existingCache = readCache();
901
+ if (existingCache && existingCache.hash === diffHash) return;
902
+ try {
903
+ const config = loadConfig();
904
+ const response = await sendToCommitGuard(diff, (await getEslintRules()).rules, config);
905
+ writeCache({
906
+ hash: diffHash,
907
+ timestamp: Date.now(),
908
+ diff,
909
+ analysis: response
910
+ });
911
+ } catch (error) {
912
+ consola.error("Analysis failed:", error);
913
+ }
914
+ }
915
+ function getCachedAnalysis(diff, diffHash) {
916
+ const effectiveDiff = diff ?? getStagedDiff();
917
+ if (!effectiveDiff.trim()) return {
918
+ analysis: {
919
+ status: "pass",
920
+ issues: []
921
+ },
922
+ age: 0
923
+ };
924
+ const effectiveDiffHash = diffHash ?? createDiffHash(effectiveDiff);
925
+ const cache$1 = readCache();
926
+ if (!cache$1) return null;
927
+ if (cache$1.hash !== effectiveDiffHash) return null;
928
+ const age = Math.round((Date.now() - cache$1.timestamp) / 1e3);
929
+ return {
930
+ analysis: cache$1.analysis,
931
+ age
932
+ };
933
+ }
934
+ async function validateCommit() {
935
+ const diff = getStagedDiff();
936
+ const diffHash = diff.trim() ? createDiffHash(diff) : "";
937
+ const cached = getCachedAnalysis(diff, diffHash);
938
+ if (!cached) {
939
+ await onStaged();
940
+ const newCached = getCachedAnalysis(diff, diffHash);
941
+ if (!newCached) {
942
+ consola.error("Analysis failed");
943
+ process.exit(1);
944
+ }
945
+ await displayResults(newCached.analysis);
946
+ return;
947
+ }
948
+ await displayResults(cached.analysis);
949
+ }
950
+ async function displayResults(analysis) {
951
+ const isTTY = process.stdout.isTTY;
952
+ if (analysis.status === "pass" || analysis.approved || !analysis.issues || analysis.issues.length === 0) {
953
+ consola.success("All checks passed");
954
+ return;
955
+ }
956
+ const count = analysis.issues?.length ?? 0;
957
+ if (!isTTY) consola.log(`CommitGuard Detected ${count} issue${count === 1 ? "" : "s"}.`);
958
+ const { grouped, noFile } = groupIssuesByFile(analysis.issues);
959
+ const fileCount = Object.keys(grouped).length;
960
+ if (isTTY) consola.log(`\nCommitGuard Detected ${count} issue${count === 1 ? "" : "s"} in ${fileCount} file${fileCount === 1 ? "" : "s"}:`);
961
+ else consola.log(`\nIssues in ${fileCount} file${fileCount === 1 ? "" : "s"}:`);
962
+ for (const [file, issues] of Object.entries(grouped)) {
963
+ let output = `\nšŸ“„ ${file}\n`;
964
+ const lastIdx = issues.length - 1;
965
+ for (let i = 0; i < issues.length; i++) {
966
+ const issue = issues[i];
967
+ const prefix = i === lastIdx ? " └─" : " ā”œā”€";
968
+ const label = padLabel(CATEGORY_LABELS[issue.category] ?? "•");
969
+ const severity = padSeverity(issue.severity ? `[${issue.severity.toUpperCase()}]` : "[INFO]");
970
+ const location = issue.file ? ` (${issue.file}${issue.line ? `:${issue.line}` : ""})` : "";
971
+ output += `${prefix} ${label} ${severity} ${issue.message}${location}\n`;
972
+ }
973
+ process.stdout.write(output);
974
+ }
975
+ if (noFile.length > 0) {
976
+ consola.log("\nšŸ“‹ General Issues");
977
+ noFile.forEach((issue, idx) => {
978
+ const prefix = idx === noFile.length - 1 ? " └─" : " ā”œā”€";
979
+ const emoji = CATEGORY_LABELS[issue.category] || "•";
980
+ consola.log(`${prefix} ${emoji} ${issue.message}`);
981
+ });
982
+ }
983
+ consola.log("\nFix these issues and try again.\n");
984
+ if (!isTTY) {
985
+ consola.log("--------------------");
986
+ consola.log("Unfortunately, CommitGuard cannot prompt for easy confirmation in non-interactive mode. To see how this works, please use git in a supported terminal.");
987
+ consola.log("To bypass this check, add --skip anywhere in your commit message");
988
+ process.exit(1);
989
+ }
990
+ if (await consola.prompt("Do you want to ignore these issues and commit anyway?", {
991
+ type: "confirm",
992
+ initial: false
993
+ })) {
994
+ consola.log("āš ļø Commit forced by user despite detected issues.\n");
995
+ return;
996
+ }
997
+ consola.log("\nšŸ’” To bypass this check, add --skip anywhere in your commit message\n");
998
+ process.exit(1);
999
+ }
1000
+
1001
+ //#endregion
1002
+ //#region src/index.ts
1003
+ updateNotifier({ pkg: package_default }).notify();
1004
+ const command = process.argv[2];
1005
+ (async () => {
1006
+ try {
1007
+ switch (command) {
1008
+ case "init":
1009
+ await installHooks();
1010
+ break;
1011
+ case "config":
1012
+ await manageConfig();
1013
+ break;
1014
+ case "pre-commit":
1015
+ await validateCommit();
1016
+ break;
1017
+ case "list":
1018
+ case "ls":
1019
+ await listHooks();
1020
+ break;
1021
+ case "remove":
1022
+ case "uninstall":
1023
+ await removeHooks();
1024
+ break;
1025
+ case "keys":
1026
+ await manageGlobalKey();
1027
+ break;
1028
+ case "bypass":
1029
+ await bypassCommitGuard();
1030
+ break;
1031
+ case "staged":
1032
+ await onStaged();
1033
+ break;
1034
+ default: consola.box(`
1035
+ CommitGuard - AI-powered git commit checker v${version}
1036
+
1037
+ Usage:
1038
+ commitguard init Initialize CommitGuard in the current git repository
1039
+ commitguard remove Remove CommitGuard from the current git repository
1040
+ commitguard config Configure CommitGuard settings for the current repository
1041
+ commitguard keys Manage your CommitGuard API key
1042
+
1043
+ Links:
1044
+ Documentation: https://commitguard.ai/docs
1045
+ Dashboard: https://commitguard.ai/dashboard
1046
+ Support: https://commitguard.ai/support`);
1047
+ }
1048
+ } catch (error) {
1049
+ consola.error("CommitGuard error:", error);
1050
+ process.exit(1);
1051
+ }
1052
+ })();
1053
+
1054
+ //#endregion
1055
+ export { };
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@commitguard/cli",
3
+ "type": "module",
4
+ "version": "0.0.1",
5
+ "description": "AI-powered git commit checker that blocks bad code before it ships",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/moshetanzer/commitguard.git"
10
+ },
11
+ "exports": {
12
+ ".": "./dist/index.mjs",
13
+ "./package.json": "./package.json"
14
+ },
15
+ "main": "./dist/index.mjs",
16
+ "module": "./dist/index.mjs",
17
+ "types": "./dist/index.d.mts",
18
+ "bin": {
19
+ "commitguard": "./dist/index.mjs"
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "dependencies": {
25
+ "@clack/prompts": "^0.11.0",
26
+ "@napi-rs/keyring": "^1.2.0",
27
+ "consola": "^3.4.2",
28
+ "dotenv": "^17.2.3",
29
+ "find-up": "^8.0.0",
30
+ "flat-cache": "^6.1.19",
31
+ "micromatch": "^4.0.8",
32
+ "string-width": "^8.1.0",
33
+ "update-notifier": "^7.3.1"
34
+ },
35
+ "devDependencies": {
36
+ "@antfu/eslint-config": "^6.7.3",
37
+ "@types/micromatch": "^4.0.10",
38
+ "@types/node": "^24.10.4",
39
+ "@types/update-notifier": "^6.0.8",
40
+ "bumpp": "^10.3.2",
41
+ "eslint": "^9.39.2",
42
+ "tsdown": "^0.18.3",
43
+ "typescript": "^5.9.3",
44
+ "vitest": "^4.0.16"
45
+ },
46
+ "scripts": {
47
+ "build": "tsdown",
48
+ "dev": "tsdown --watch",
49
+ "test": "vitest",
50
+ "typecheck": "tsc --noEmit",
51
+ "lint": "eslint .",
52
+ "lint:fix": "eslint . --fix"
53
+ }
54
+ }