@webywz/vibe-commit 0.1.0 → 0.1.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/README.md +101 -100
- package/dist/cli.js +69 -1
- package/dist/hook.d.ts +42 -0
- package/dist/hook.js +235 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/parse-args.js +61 -1
- package/dist/types.d.ts +8 -0
- package/package.json +6 -3
package/README.md
CHANGED
|
@@ -1,29 +1,77 @@
|
|
|
1
1
|
# vibe-commit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
<p align="center">
|
|
4
|
+
<a href="https://github.com/webywz/vibe-commit/actions/workflows/ci.yml"><img src="https://github.com/webywz/vibe-commit/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
|
|
5
|
+
<a href="https://www.npmjs.com/package/@webywz/vibe-commit"><img src="https://img.shields.io/npm/v/@webywz/vibe-commit.svg" alt="npm" /></a>
|
|
6
|
+
<a href="./LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT" /></a>
|
|
7
|
+
</p>
|
|
4
8
|
|
|
5
|
-
|
|
9
|
+
<p align="center">
|
|
10
|
+
<strong>Stylish conventional commits. Offline-first. AI-optional.</strong><br/>
|
|
11
|
+
Stage your files, run one command, get a commit message that looks like you meant it.
|
|
12
|
+
</p>
|
|
6
13
|
|
|
7
|
-
|
|
14
|
+
<p align="center">
|
|
15
|
+
有品味的 Conventional Commits。离线优先,AI 可选。<br/>
|
|
16
|
+
暂存改动,一条命令,写出像你认真写过的提交信息。
|
|
17
|
+
</p>
|
|
8
18
|
|
|
9
|
-
[
|
|
10
|
-
|
|
11
|
-
[
|
|
19
|
+
Companion spirit to [diff-vibe](https://github.com/webywz/diff-vibe): commits here, pull requests there.
|
|
20
|
+
|
|
21
|
+
与 [diff-vibe](https://github.com/webywz/diff-vibe) 成对:这边写 commit,那边写 PR。
|
|
12
22
|
|
|
13
23
|
---
|
|
14
24
|
|
|
15
|
-
##
|
|
25
|
+
## Install · 安装
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npm i -g @webywz/vibe-commit
|
|
29
|
+
npx @webywz/vibe-commit
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Requires **Node.js 18.18+** and `git`. Package: [`@webywz/vibe-commit`](https://www.npmjs.com/package/@webywz/vibe-commit). Binary: `vibe-commit`.
|
|
33
|
+
|
|
34
|
+
需要 **Node.js 18.18+** 与 `git`。包名 `@webywz/vibe-commit`,命令仍是 `vibe-commit`。
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Quick start · 快速开始
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
git add -p
|
|
42
|
+
vibe-commit # propose from staged diff
|
|
43
|
+
vibe-commit --vibe playful --commit # write the commit
|
|
44
|
+
vibe-commit --dry-run # print only (default; wins over --commit)
|
|
45
|
+
git commit -m "$(vibe-commit --dry-run)"
|
|
46
|
+
vibe-commit install # optional prepare-commit-msg hook
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
| Flag / command | What it does · 作用 |
|
|
50
|
+
| --- | --- |
|
|
51
|
+
| `--commit` | Run `git commit` with the proposed message · 用生成信息执行提交 |
|
|
52
|
+
| `--dry-run` | Print only; never commit (default) · 只打印,不提交(默认) |
|
|
53
|
+
| `--type <type>` | Force `feat` / `fix` / `docs` / `style` / `refactor` / `perf` / `test` / `build` / `ci` / `chore` |
|
|
54
|
+
| `--scope <scope>` | Override inferred scope · 覆盖推断出的 scope |
|
|
55
|
+
| `--vibe <style>` | `playful` \| `pro` \| `terse` (default: `pro`) |
|
|
56
|
+
| `--no-ai` | Skip the API even if `OPENAI_API_KEY` is set · 即使有 Key 也不走 AI |
|
|
57
|
+
| `--json` | Machine-readable proposal · 机器可读提案 |
|
|
58
|
+
| `install [--path]` | Install the prepare-commit-msg hook · 安装钩子 |
|
|
59
|
+
| `uninstall [--path]` | Remove the managed hook · 卸载钩子 |
|
|
60
|
+
| `-h` / `-v` | Help / version · 帮助 / 版本 |
|
|
61
|
+
|
|
62
|
+
**Vibes** · 语气
|
|
63
|
+
|
|
64
|
+
- **pro** — default. Imperative, review-ready. · 默认:祈使句,可进 review。
|
|
65
|
+
- **terse** — headline only, filler gone. · 只留标题,去掉废话。
|
|
66
|
+
- **playful** — a little personality, still safe for a real repo. · 带一点性格,但仍适合正经仓库。
|
|
16
67
|
|
|
17
68
|
```text
|
|
18
|
-
$ git add src/heuristic.ts
|
|
19
69
|
$ vibe-commit
|
|
20
70
|
|
|
21
71
|
vibe-commit · heuristic · feat
|
|
22
72
|
|
|
23
73
|
1 file · +124 −8
|
|
24
74
|
|
|
25
|
-
M src/heuristic.ts +124 −8
|
|
26
|
-
|
|
27
75
|
proposed
|
|
28
76
|
|
|
29
77
|
feat(heuristic): infer conventional commit type from staged diffs
|
|
@@ -31,127 +79,80 @@ $ vibe-commit
|
|
|
31
79
|
next vibe-commit --commit
|
|
32
80
|
```
|
|
33
81
|
|
|
34
|
-
|
|
35
|
-
$ vibe-commit --vibe playful --commit
|
|
36
|
-
feat(cli): ship offline heuristic commit messages — quietly useful
|
|
37
|
-
committed
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
Pipe-friendly when stdout is not a TTY:
|
|
82
|
+
### Git hook · 钩子
|
|
41
83
|
|
|
42
84
|
```bash
|
|
43
|
-
|
|
85
|
+
vibe-commit install
|
|
86
|
+
vibe-commit install --path ~/projects/app
|
|
87
|
+
vibe-commit uninstall
|
|
44
88
|
```
|
|
45
89
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
## Install · 安装
|
|
90
|
+
After install, plain `git commit` (no `-m`) fills an empty or comment-only message file with a conventional proposal.
|
|
49
91
|
|
|
50
|
-
|
|
51
|
-
npm install -g vibe-commit
|
|
52
|
-
# or, no install:
|
|
53
|
-
npx vibe-commit
|
|
54
|
-
```
|
|
92
|
+
安装后,裸 `git commit`(没有 `-m`)会在空 / 仅注释的提交说明里填入提案。
|
|
55
93
|
|
|
56
|
-
|
|
94
|
+
| Situation · 情况 | Behavior · 行为 |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| Source is `merge`, `squash`, or `commit` (amend) | Leave the message alone · 不改 |
|
|
97
|
+
| Message already has non-comment text (e.g. `git commit -m`) | Never overwrite · 绝不覆盖 |
|
|
98
|
+
| No staged changes / generator error | Exit 0 — never break the commit · 以 0 退出,不打断提交 |
|
|
57
99
|
|
|
58
|
-
|
|
59
|
-
npm i -g vibe-commit # 全局安装
|
|
60
|
-
npx vibe-commit # 一次性运行
|
|
61
|
-
```
|
|
100
|
+
Hook stays **offline by default** (`VIBE_COMMIT_HOOK_AI=1` to allow AI). Unique markers (`# BEGIN VIBE-COMMIT` … `# END VIBE-COMMIT`) keep uninstall clean. A foreign `prepare-commit-msg` is backed up to `prepare-commit-msg.vibe-backup` and chained after ours.
|
|
62
101
|
|
|
63
|
-
|
|
102
|
+
钩子默认离线;设 `VIBE_COMMIT_HOOK_AI=1` 才允许 AI。用标记块保证干净卸载。已有外来钩子会备份并链式调用。
|
|
64
103
|
|
|
65
|
-
|
|
104
|
+
### AI (optional) · AI(可选)
|
|
66
105
|
|
|
67
106
|
```bash
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
107
|
+
export OPENAI_API_KEY=sk-...
|
|
108
|
+
export OPENAI_MODEL=gpt-4o-mini # optional
|
|
109
|
+
export OPENAI_BASE_URL=https://api.openai.com/v1
|
|
110
|
+
export VIBE_COMMIT_VIBE=pro # or VIBE
|
|
71
111
|
```
|
|
72
112
|
|
|
73
|
-
|
|
74
|
-
| --- | --- |
|
|
75
|
-
| `--commit` | Run `git commit` with the proposed message · 用生成的信息执行提交 |
|
|
76
|
-
| `--dry-run` | Print only; never commit (default, and wins over `--commit`) |
|
|
77
|
-
| `--type <type>` | Force `feat` / `fix` / `docs` / `style` / `refactor` / `perf` / `test` / `build` / `ci` / `chore` |
|
|
78
|
-
| `--scope <scope>` | Override inferred scope · 覆盖推断出的 scope |
|
|
79
|
-
| `--vibe <style>` | `playful` \| `pro` \| `terse` (default: `pro`) |
|
|
80
|
-
| `--no-ai` | Skip the API even if `OPENAI_API_KEY` is set |
|
|
81
|
-
| `--json` | Machine-readable proposal |
|
|
82
|
-
| `-h`, `--help` | Help |
|
|
83
|
-
| `-v`, `--version` | Version |
|
|
113
|
+
OpenAI-compatible Chat Completions. Missing key, timeout, or 4xx/5xx → silent fallback to the offline heuristic. See [`.env.example`](.env.example). Unstaged files are never sent; huge diffs are truncated.
|
|
84
114
|
|
|
85
|
-
|
|
115
|
+
兼容 OpenAI Chat Completions。没 Key、超时或 4xx/5xx → 静默回退离线启发式。可参考 `.env.example`。不会上传未暂存文件;过大 diff 会截断。
|
|
86
116
|
|
|
87
|
-
|
|
88
|
-
- **terse** — headline only, filler words gone.
|
|
89
|
-
- **playful** — a little personality, still safe for a real repo.
|
|
117
|
+
### Library · 库
|
|
90
118
|
|
|
91
|
-
|
|
119
|
+
```ts
|
|
120
|
+
import { proposeHeuristic, formatCommit } from "@webywz/vibe-commit";
|
|
121
|
+
|
|
122
|
+
const message = proposeHeuristic({ diff, vibe: "pro" });
|
|
123
|
+
console.log(formatCommit(message));
|
|
124
|
+
// feat(auth): add login
|
|
125
|
+
```
|
|
92
126
|
|
|
93
127
|
---
|
|
94
128
|
|
|
95
|
-
##
|
|
129
|
+
## Features · 特性
|
|
96
130
|
|
|
97
|
-
1.
|
|
98
|
-
|
|
131
|
+
1. Reads **staged** changes only (`git diff --cached`). Unstaged noise is ignored.
|
|
132
|
+
只读 **已暂存** 改动;忽略未暂存噪音。
|
|
133
|
+
2. **Offline heuristic** always on: paths + hunk keywords + added identifiers → `type`, `scope`, `subject`.
|
|
134
|
+
**离线启发式**始终开启:路径 + hunk 关键词 + 新增标识 → `type` / `scope` / `subject`。
|
|
99
135
|
- `README` / `docs/` → `docs`
|
|
100
136
|
- `*.test.ts` / `test/` → `test`
|
|
101
137
|
- `.github/workflows` → `ci`
|
|
102
138
|
- lockfiles / `package.json` → `chore`
|
|
103
139
|
- `fix` / `bug` / `crash` in the diff → `fix`
|
|
104
140
|
- new files / `export function` → `feat`
|
|
105
|
-
3.
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
---
|
|
111
|
-
|
|
112
|
-
## AI (optional)
|
|
113
|
-
|
|
114
|
-
```bash
|
|
115
|
-
export OPENAI_API_KEY=sk-...
|
|
116
|
-
# optional:
|
|
117
|
-
export OPENAI_MODEL=gpt-4o-mini
|
|
118
|
-
export OPENAI_BASE_URL=https://api.openai.com/v1 # any compatible gateway
|
|
119
|
-
export VIBE_COMMIT_VIBE=pro
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
Copy `.env.example` if you keep secrets in a file. vibe-commit never sends unstaged files, and it truncates huge diffs.
|
|
123
|
-
|
|
124
|
-
兼容 OpenAI Chat Completions。没 Key、超时、4xx/5xx → 静默回退到离线启发式,CLI 仍然能给出一条能用的信息。
|
|
141
|
+
3. Optional AI draft with automatic heuristic fallback.
|
|
142
|
+
可选 AI 草稿,失败自动回退启发式。
|
|
143
|
+
4. Managed `prepare-commit-msg` hook via `install` / `uninstall`.
|
|
144
|
+
通过 `install` / `uninstall` 管理 `prepare-commit-msg` 钩子。
|
|
125
145
|
|
|
126
146
|
---
|
|
127
147
|
|
|
128
|
-
##
|
|
148
|
+
## Links · 链接
|
|
129
149
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
console.log(formatCommit(message));
|
|
135
|
-
// feat(auth): add login
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
---
|
|
139
|
-
|
|
140
|
-
## Develop · 开发
|
|
150
|
+
- npm · [`@webywz/vibe-commit`](https://www.npmjs.com/package/@webywz/vibe-commit)
|
|
151
|
+
- Source · [github.com/webywz/vibe-commit](https://github.com/webywz/vibe-commit)
|
|
152
|
+
- Related · [diff-vibe](https://github.com/webywz/diff-vibe) · [prompt-diff](https://github.com/webywz/prompt-diff)
|
|
153
|
+
- License · [MIT](./LICENSE)
|
|
141
154
|
|
|
142
155
|
```bash
|
|
143
|
-
|
|
144
|
-
cd vibe-commit
|
|
145
|
-
npm install
|
|
146
|
-
npm test
|
|
147
|
-
npm run build
|
|
156
|
+
npm install && npm test && npm run build
|
|
148
157
|
node dist/cli.js --help
|
|
149
158
|
```
|
|
150
|
-
|
|
151
|
-
| Script | |
|
|
152
|
-
| --- | --- |
|
|
153
|
-
| `npm test` | Unit + e2e tests (`node:test` + `tsx`) |
|
|
154
|
-
| `npm run build` | Emit `dist/` |
|
|
155
|
-
| `npm run typecheck` | `tsc --noEmit` |
|
|
156
|
-
|
|
157
|
-
MIT © webywz — see [LICENSE](./LICENSE).
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
-
import { dirname, join } from "node:path";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
5
|
import pc from "picocolors";
|
|
6
6
|
import { formatCommit } from "./format.js";
|
|
7
7
|
import { commitWithMessage, GitError, inspectStaged } from "./git.js";
|
|
8
|
+
import { installHook, uninstallHook, runPrepareCommitMsg } from "./hook.js";
|
|
8
9
|
import { helpText, parseArgs, UsageError } from "./parse-args.js";
|
|
9
10
|
import { proposeMessage } from "./propose.js";
|
|
10
11
|
import { renderError, renderProposal, renderVersion } from "./render.js";
|
|
@@ -28,6 +29,15 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
28
29
|
process.stdout.write(`${renderVersion(readVersion())}\n`);
|
|
29
30
|
return 0;
|
|
30
31
|
}
|
|
32
|
+
if (options.command === "install") {
|
|
33
|
+
return runInstall(options.path);
|
|
34
|
+
}
|
|
35
|
+
if (options.command === "uninstall") {
|
|
36
|
+
return runUninstall(options.path);
|
|
37
|
+
}
|
|
38
|
+
if (options.command === "prepare-commit-msg") {
|
|
39
|
+
return runHookPrepare(options.messageFile, options.commitSource);
|
|
40
|
+
}
|
|
31
41
|
const piped = !process.stdout.isTTY;
|
|
32
42
|
try {
|
|
33
43
|
const diff = inspectStaged();
|
|
@@ -64,6 +74,64 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
64
74
|
return 1;
|
|
65
75
|
}
|
|
66
76
|
}
|
|
77
|
+
function runInstall(repoPath) {
|
|
78
|
+
try {
|
|
79
|
+
const target = resolve(repoPath ?? process.cwd());
|
|
80
|
+
const result = installHook(target);
|
|
81
|
+
const action = result.updated
|
|
82
|
+
? "updated"
|
|
83
|
+
: result.created
|
|
84
|
+
? "installed"
|
|
85
|
+
: "installed";
|
|
86
|
+
process.stdout.write(`${pc.green("ok")} prepare-commit-msg hook ${action}\n` +
|
|
87
|
+
` ${result.hookPath}\n`);
|
|
88
|
+
if (result.backedUp) {
|
|
89
|
+
process.stdout.write(` previous hook backed up as prepare-commit-msg.vibe-backup\n`);
|
|
90
|
+
}
|
|
91
|
+
process.stdout.write(` tip: plain \`git commit\` (no -m) will fill an empty message\n`);
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
const text = error instanceof GitError || error instanceof Error
|
|
96
|
+
? error.message
|
|
97
|
+
: "Unexpected error";
|
|
98
|
+
process.stderr.write(`${renderError(text)}\n`);
|
|
99
|
+
return 1;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function runUninstall(repoPath) {
|
|
103
|
+
try {
|
|
104
|
+
const target = resolve(repoPath ?? process.cwd());
|
|
105
|
+
const result = uninstallHook(target);
|
|
106
|
+
if (!result.removed) {
|
|
107
|
+
process.stdout.write(`${pc.yellow("skip")} no managed vibe-commit hook found\n` +
|
|
108
|
+
` ${result.hookPath}\n`);
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
process.stdout.write(`${pc.green("ok")} prepare-commit-msg hook removed\n` +
|
|
112
|
+
` ${result.hookPath}\n`);
|
|
113
|
+
if (result.restored) {
|
|
114
|
+
process.stdout.write(` previous hook restored from backup\n`);
|
|
115
|
+
}
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
const text = error instanceof GitError || error instanceof Error
|
|
120
|
+
? error.message
|
|
121
|
+
: "Unexpected error";
|
|
122
|
+
process.stderr.write(`${renderError(text)}\n`);
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
async function runHookPrepare(messageFile, commitSource) {
|
|
127
|
+
try {
|
|
128
|
+
await runPrepareCommitMsg(messageFile, commitSource, process.cwd());
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// Never fail a commit because the hook misfired.
|
|
132
|
+
}
|
|
133
|
+
return 0;
|
|
134
|
+
}
|
|
67
135
|
function readVersion() {
|
|
68
136
|
try {
|
|
69
137
|
const here = dirname(fileURLToPath(import.meta.url));
|
package/dist/hook.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export declare const HOOK_BEGIN = "# BEGIN VIBE-COMMIT";
|
|
2
|
+
export declare const HOOK_END = "# END VIBE-COMMIT";
|
|
3
|
+
export declare const HOOK_NAME = "prepare-commit-msg";
|
|
4
|
+
export declare const HOOK_BACKUP_NAME = "prepare-commit-msg.vibe-backup";
|
|
5
|
+
export declare function hasNonCommentContent(content: string): boolean;
|
|
6
|
+
/** Skip merge/squash/amend, or when the message file already has user text. */
|
|
7
|
+
export declare function shouldSkipPrepareCommitMsg(messageFileContent: string, source?: string): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* prepare-commit-msg runner. Offline by default (set VIBE_COMMIT_HOOK_AI=1 to enable AI).
|
|
10
|
+
* Never throws into git — failures are swallowed as "noop".
|
|
11
|
+
*/
|
|
12
|
+
export declare function runPrepareCommitMsg(messageFile: string, source?: string, cwd?: string, env?: NodeJS.ProcessEnv): Promise<"wrote" | "skipped" | "noop">;
|
|
13
|
+
export interface HookPaths {
|
|
14
|
+
repoPath: string;
|
|
15
|
+
hooksDir: string;
|
|
16
|
+
hookPath: string;
|
|
17
|
+
backupPath: string;
|
|
18
|
+
}
|
|
19
|
+
export declare function resolveHookPaths(repoPath?: string): HookPaths;
|
|
20
|
+
export declare function resolveGitHooksDir(repoPath: string): string;
|
|
21
|
+
export declare function resolveInstallCommand(argv1?: string | undefined, execPath?: string): {
|
|
22
|
+
execPath: string;
|
|
23
|
+
cliPath: string;
|
|
24
|
+
};
|
|
25
|
+
export declare function buildManagedHookBlock(execPath: string, cliPath: string): string;
|
|
26
|
+
export declare function shellQuote(value: string): string;
|
|
27
|
+
export declare function replaceManagedBlock(source: string, block: string): string;
|
|
28
|
+
export declare function stripManagedBlock(source: string): string;
|
|
29
|
+
export declare function installHook(repoPath?: string, command?: {
|
|
30
|
+
execPath: string;
|
|
31
|
+
cliPath: string;
|
|
32
|
+
}): {
|
|
33
|
+
hookPath: string;
|
|
34
|
+
created: boolean;
|
|
35
|
+
updated: boolean;
|
|
36
|
+
backedUp: boolean;
|
|
37
|
+
};
|
|
38
|
+
export declare function uninstallHook(repoPath?: string): {
|
|
39
|
+
hookPath: string;
|
|
40
|
+
removed: boolean;
|
|
41
|
+
restored: boolean;
|
|
42
|
+
};
|
package/dist/hook.js
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { formatCommit } from "./format.js";
|
|
6
|
+
import { assertRepo, GitError, inspectStaged } from "./git.js";
|
|
7
|
+
import { defaultVibeFromEnv } from "./parse-args.js";
|
|
8
|
+
import { proposeMessage } from "./propose.js";
|
|
9
|
+
export const HOOK_BEGIN = "# BEGIN VIBE-COMMIT";
|
|
10
|
+
export const HOOK_END = "# END VIBE-COMMIT";
|
|
11
|
+
export const HOOK_NAME = "prepare-commit-msg";
|
|
12
|
+
export const HOOK_BACKUP_NAME = "prepare-commit-msg.vibe-backup";
|
|
13
|
+
const SKIP_SOURCES = new Set(["merge", "squash", "commit"]);
|
|
14
|
+
export function hasNonCommentContent(content) {
|
|
15
|
+
return content.split(/\r?\n/).some((line) => {
|
|
16
|
+
const trimmed = line.trim();
|
|
17
|
+
return trimmed.length > 0 && !trimmed.startsWith("#");
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
/** Skip merge/squash/amend, or when the message file already has user text. */
|
|
21
|
+
export function shouldSkipPrepareCommitMsg(messageFileContent, source) {
|
|
22
|
+
const src = (source ?? "").trim().toLowerCase();
|
|
23
|
+
if (SKIP_SOURCES.has(src))
|
|
24
|
+
return true;
|
|
25
|
+
return hasNonCommentContent(messageFileContent);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* prepare-commit-msg runner. Offline by default (set VIBE_COMMIT_HOOK_AI=1 to enable AI).
|
|
29
|
+
* Never throws into git — failures are swallowed as "noop".
|
|
30
|
+
*/
|
|
31
|
+
export async function runPrepareCommitMsg(messageFile, source, cwd = process.cwd(), env = process.env) {
|
|
32
|
+
let content = "";
|
|
33
|
+
try {
|
|
34
|
+
content = readFileSync(messageFile, "utf8");
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return "noop";
|
|
38
|
+
}
|
|
39
|
+
if (shouldSkipPrepareCommitMsg(content, source))
|
|
40
|
+
return "skipped";
|
|
41
|
+
try {
|
|
42
|
+
const diff = inspectStaged(cwd);
|
|
43
|
+
// Avoid hanging `git commit` on network — hooks stay offline unless opted in.
|
|
44
|
+
const useAi = env.VIBE_COMMIT_HOOK_AI === "1";
|
|
45
|
+
const message = await proposeMessage(diff, {
|
|
46
|
+
vibe: defaultVibeFromEnv(env),
|
|
47
|
+
preferAi: useAi,
|
|
48
|
+
}, env);
|
|
49
|
+
const formatted = formatCommit(message);
|
|
50
|
+
const comments = content
|
|
51
|
+
.split(/\r?\n/)
|
|
52
|
+
.filter((line) => line.trim().startsWith("#"))
|
|
53
|
+
.join("\n");
|
|
54
|
+
const next = comments
|
|
55
|
+
? `${formatted}\n\n${comments}\n`
|
|
56
|
+
: `${formatted}\n`;
|
|
57
|
+
writeFileSync(messageFile, next, "utf8");
|
|
58
|
+
return "wrote";
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return "noop";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function resolveHookPaths(repoPath = process.cwd()) {
|
|
65
|
+
assertRepo(repoPath);
|
|
66
|
+
const hooksDir = resolveGitHooksDir(repoPath);
|
|
67
|
+
return {
|
|
68
|
+
repoPath,
|
|
69
|
+
hooksDir,
|
|
70
|
+
hookPath: join(hooksDir, HOOK_NAME),
|
|
71
|
+
backupPath: join(hooksDir, HOOK_BACKUP_NAME),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
export function resolveGitHooksDir(repoPath) {
|
|
75
|
+
try {
|
|
76
|
+
const hooksPath = execFileSync("git", ["rev-parse", "--git-path", "hooks"], {
|
|
77
|
+
cwd: repoPath,
|
|
78
|
+
encoding: "utf8",
|
|
79
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
80
|
+
}).trim();
|
|
81
|
+
return isAbsolute(hooksPath) ? hooksPath : join(repoPath, hooksPath);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
throw new GitError("Could not resolve .git/hooks for this repository.");
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export function resolveInstallCommand(argv1 = process.argv[1], execPath = process.execPath) {
|
|
88
|
+
let cliPath;
|
|
89
|
+
if (argv1 && existsSync(argv1)) {
|
|
90
|
+
cliPath = argv1;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
94
|
+
for (const name of ["cli.js", "cli.ts"]) {
|
|
95
|
+
const candidate = join(here, name);
|
|
96
|
+
if (existsSync(candidate)) {
|
|
97
|
+
cliPath = candidate;
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (!cliPath) {
|
|
103
|
+
throw new GitError("Could not locate the vibe-commit CLI entry. Reinstall @webywz/vibe-commit and retry.");
|
|
104
|
+
}
|
|
105
|
+
// Source installs (tsx) need the tsx binary; published dist uses node + cli.js.
|
|
106
|
+
if (cliPath.endsWith(".ts")) {
|
|
107
|
+
const tsx = findTsxBinary(cliPath);
|
|
108
|
+
if (tsx)
|
|
109
|
+
return { execPath: tsx, cliPath };
|
|
110
|
+
}
|
|
111
|
+
return { execPath, cliPath };
|
|
112
|
+
}
|
|
113
|
+
function findTsxBinary(cliPath) {
|
|
114
|
+
let dir = dirname(cliPath);
|
|
115
|
+
for (let i = 0; i < 6; i += 1) {
|
|
116
|
+
const bin = join(dir, "node_modules", ".bin", "tsx");
|
|
117
|
+
if (existsSync(bin))
|
|
118
|
+
return bin;
|
|
119
|
+
const parent = dirname(dir);
|
|
120
|
+
if (parent === dir)
|
|
121
|
+
break;
|
|
122
|
+
dir = parent;
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
export function buildManagedHookBlock(execPath, cliPath) {
|
|
127
|
+
const node = shellQuote(execPath);
|
|
128
|
+
const cli = shellQuote(cliPath);
|
|
129
|
+
return [
|
|
130
|
+
HOOK_BEGIN,
|
|
131
|
+
"# Managed by @webywz/vibe-commit — do not edit this block",
|
|
132
|
+
`${node} ${cli} prepare-commit-msg "$1" "$2" || true`,
|
|
133
|
+
HOOK_END,
|
|
134
|
+
].join("\n");
|
|
135
|
+
}
|
|
136
|
+
export function shellQuote(value) {
|
|
137
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
138
|
+
}
|
|
139
|
+
export function replaceManagedBlock(source, block) {
|
|
140
|
+
const begin = source.indexOf(HOOK_BEGIN);
|
|
141
|
+
const end = source.indexOf(HOOK_END);
|
|
142
|
+
if (begin === -1 || end === -1 || end < begin) {
|
|
143
|
+
return `${source.trimEnd()}\n\n${block}\n`;
|
|
144
|
+
}
|
|
145
|
+
const afterEnd = end + HOOK_END.length;
|
|
146
|
+
return `${source.slice(0, begin)}${block}${source.slice(afterEnd)}`;
|
|
147
|
+
}
|
|
148
|
+
export function stripManagedBlock(source) {
|
|
149
|
+
const begin = source.indexOf(HOOK_BEGIN);
|
|
150
|
+
const end = source.indexOf(HOOK_END);
|
|
151
|
+
if (begin === -1 || end === -1 || end < begin)
|
|
152
|
+
return source;
|
|
153
|
+
const afterEnd = end + HOOK_END.length;
|
|
154
|
+
const before = source.slice(0, begin).replace(/\n+$/, "\n");
|
|
155
|
+
const after = source.slice(afterEnd).replace(/^\n+/, "\n");
|
|
156
|
+
return `${before}${after}`.replace(/\n{3,}/g, "\n\n").trimStart();
|
|
157
|
+
}
|
|
158
|
+
function ensureTrailingNewline(text) {
|
|
159
|
+
return text.endsWith("\n") ? text : `${text}\n`;
|
|
160
|
+
}
|
|
161
|
+
function isShebangOnly(text) {
|
|
162
|
+
const trimmed = text.trim();
|
|
163
|
+
return (trimmed === "" ||
|
|
164
|
+
trimmed === "#!/bin/sh" ||
|
|
165
|
+
trimmed === "#!/usr/bin/env bash" ||
|
|
166
|
+
trimmed === "#!/bin/bash");
|
|
167
|
+
}
|
|
168
|
+
export function installHook(repoPath = process.cwd(), command = resolveInstallCommand()) {
|
|
169
|
+
const paths = resolveHookPaths(repoPath);
|
|
170
|
+
mkdirSync(paths.hooksDir, { recursive: true });
|
|
171
|
+
const block = buildManagedHookBlock(command.execPath, command.cliPath);
|
|
172
|
+
const existing = existsSync(paths.hookPath)
|
|
173
|
+
? readFileSync(paths.hookPath, "utf8")
|
|
174
|
+
: null;
|
|
175
|
+
let backedUp = false;
|
|
176
|
+
let created = false;
|
|
177
|
+
let updated = false;
|
|
178
|
+
if (existing === null) {
|
|
179
|
+
writeFileSync(paths.hookPath, `#!/bin/sh\n${block}\n`, { mode: 0o755 });
|
|
180
|
+
created = true;
|
|
181
|
+
}
|
|
182
|
+
else if (existing.includes(HOOK_BEGIN) && existing.includes(HOOK_END)) {
|
|
183
|
+
const next = replaceManagedBlock(existing, block);
|
|
184
|
+
writeFileSync(paths.hookPath, ensureTrailingNewline(next), {
|
|
185
|
+
mode: 0o755,
|
|
186
|
+
});
|
|
187
|
+
updated = true;
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
// Foreign hook: back it up, install wrapper that runs us then chains.
|
|
191
|
+
writeFileSync(paths.backupPath, existing, { mode: 0o755 });
|
|
192
|
+
chmodSync(paths.backupPath, 0o755);
|
|
193
|
+
backedUp = true;
|
|
194
|
+
const wrapper = [
|
|
195
|
+
"#!/bin/sh",
|
|
196
|
+
block,
|
|
197
|
+
"",
|
|
198
|
+
"# Chain the previous prepare-commit-msg hook",
|
|
199
|
+
`if [ -x ${shellQuote(paths.backupPath)} ]; then`,
|
|
200
|
+
` ${shellQuote(paths.backupPath)} "$@" || exit $?`,
|
|
201
|
+
"fi",
|
|
202
|
+
"",
|
|
203
|
+
].join("\n");
|
|
204
|
+
writeFileSync(paths.hookPath, wrapper, { mode: 0o755 });
|
|
205
|
+
created = true;
|
|
206
|
+
}
|
|
207
|
+
chmodSync(paths.hookPath, 0o755);
|
|
208
|
+
return { hookPath: paths.hookPath, created, updated, backedUp };
|
|
209
|
+
}
|
|
210
|
+
export function uninstallHook(repoPath = process.cwd()) {
|
|
211
|
+
const paths = resolveHookPaths(repoPath);
|
|
212
|
+
if (!existsSync(paths.hookPath)) {
|
|
213
|
+
return { hookPath: paths.hookPath, removed: false, restored: false };
|
|
214
|
+
}
|
|
215
|
+
const existing = readFileSync(paths.hookPath, "utf8");
|
|
216
|
+
const hasManaged = existing.includes(HOOK_BEGIN) && existing.includes(HOOK_END);
|
|
217
|
+
if (!hasManaged) {
|
|
218
|
+
return { hookPath: paths.hookPath, removed: false, restored: false };
|
|
219
|
+
}
|
|
220
|
+
if (existsSync(paths.backupPath)) {
|
|
221
|
+
renameSync(paths.backupPath, paths.hookPath);
|
|
222
|
+
chmodSync(paths.hookPath, 0o755);
|
|
223
|
+
return { hookPath: paths.hookPath, removed: true, restored: true };
|
|
224
|
+
}
|
|
225
|
+
const stripped = stripManagedBlock(existing).trim();
|
|
226
|
+
if (isShebangOnly(stripped)) {
|
|
227
|
+
unlinkSync(paths.hookPath);
|
|
228
|
+
return { hookPath: paths.hookPath, removed: true, restored: false };
|
|
229
|
+
}
|
|
230
|
+
writeFileSync(paths.hookPath, ensureTrailingNewline(stripped), {
|
|
231
|
+
mode: 0o755,
|
|
232
|
+
});
|
|
233
|
+
return { hookPath: paths.hookPath, removed: true, restored: false };
|
|
234
|
+
}
|
|
235
|
+
//# sourceMappingURL=hook.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -4,7 +4,8 @@ export { inspectStaged, commitWithMessage, GitError } from "./git.ts";
|
|
|
4
4
|
export { proposeHeuristic, inferType, inferScope, inferSubject, classifyPath, } from "./heuristic.ts";
|
|
5
5
|
export { parseArgs, helpText, UsageError } from "./parse-args.ts";
|
|
6
6
|
export { assembleDiff, parseUnifiedDiff } from "./parse-diff.ts";
|
|
7
|
+
export { installHook, uninstallHook, runPrepareCommitMsg, shouldSkipPrepareCommitMsg, hasNonCommentContent, HOOK_BEGIN, HOOK_END, } from "./hook.ts";
|
|
7
8
|
export { proposeMessage } from "./propose.ts";
|
|
8
9
|
export { applyVibe } from "./vibe.ts";
|
|
9
|
-
export type { CliOptions, CommitType, ProposedMessage, ProposeOptions, StagedDiff, StagedFile, Vibe, } from "./types.ts";
|
|
10
|
+
export type { CliCommand, CliOptions, CommitType, ProposedMessage, ProposeOptions, StagedDiff, StagedFile, Vibe, } from "./types.ts";
|
|
10
11
|
export { COMMIT_TYPES, VIBES } from "./types.ts";
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ export { inspectStaged, commitWithMessage, GitError } from "./git.js";
|
|
|
4
4
|
export { proposeHeuristic, inferType, inferScope, inferSubject, classifyPath, } from "./heuristic.js";
|
|
5
5
|
export { parseArgs, helpText, UsageError } from "./parse-args.js";
|
|
6
6
|
export { assembleDiff, parseUnifiedDiff } from "./parse-diff.js";
|
|
7
|
+
export { installHook, uninstallHook, runPrepareCommitMsg, shouldSkipPrepareCommitMsg, hasNonCommentContent, HOOK_BEGIN, HOOK_END, } from "./hook.js";
|
|
7
8
|
export { proposeMessage } from "./propose.js";
|
|
8
9
|
export { applyVibe } from "./vibe.js";
|
|
9
10
|
export { COMMIT_TYPES, VIBES } from "./types.js";
|
package/dist/parse-args.js
CHANGED
|
@@ -3,6 +3,12 @@ const HELP = `vibe-commit — stylish conventional commits, offline-first
|
|
|
3
3
|
|
|
4
4
|
Usage
|
|
5
5
|
vibe-commit [options]
|
|
6
|
+
vibe-commit install [--path <repo>]
|
|
7
|
+
vibe-commit uninstall [--path <repo>]
|
|
8
|
+
|
|
9
|
+
Commands
|
|
10
|
+
install Install a prepare-commit-msg hook in the repo
|
|
11
|
+
uninstall Remove the managed prepare-commit-msg hook
|
|
6
12
|
|
|
7
13
|
Options
|
|
8
14
|
--commit Create the commit with the proposed message
|
|
@@ -12,6 +18,7 @@ Options
|
|
|
12
18
|
--vibe <style> playful | pro | terse (default: pro)
|
|
13
19
|
--no-ai Skip AI even if OPENAI_API_KEY is set
|
|
14
20
|
--json Print a machine-readable proposal
|
|
21
|
+
--path <repo> Repo root for install/uninstall (default: cwd)
|
|
15
22
|
-h, --help Show help
|
|
16
23
|
-v, --version Show version
|
|
17
24
|
|
|
@@ -20,12 +27,20 @@ Env
|
|
|
20
27
|
OPENAI_MODEL Default: gpt-4o-mini
|
|
21
28
|
OPENAI_BASE_URL Default: https://api.openai.com/v1
|
|
22
29
|
VIBE / VIBE_COMMIT_VIBE Default vibe when --vibe is omitted
|
|
30
|
+
VIBE_COMMIT_HOOK_AI Set to 1 to allow AI inside the git hook (off by default)
|
|
31
|
+
|
|
32
|
+
Git hook
|
|
33
|
+
After \`vibe-commit install\`, plain \`git commit\` (no -m) fills an empty /
|
|
34
|
+
comment-only message file with a vibe-commit proposal. Skips merge, squash,
|
|
35
|
+
amend (source=commit), and any message that already has non-comment text.
|
|
23
36
|
|
|
24
37
|
Examples
|
|
25
38
|
git add -p
|
|
26
39
|
vibe-commit
|
|
27
40
|
vibe-commit --vibe playful --commit
|
|
28
41
|
git commit -m "$(vibe-commit --dry-run)"
|
|
42
|
+
vibe-commit install
|
|
43
|
+
vibe-commit uninstall --path ~/projects/app
|
|
29
44
|
`;
|
|
30
45
|
export function defaultVibeFromEnv(env = process.env) {
|
|
31
46
|
const raw = (env.VIBE_COMMIT_VIBE ?? env.VIBE ?? "pro").trim().toLowerCase();
|
|
@@ -33,6 +48,7 @@ export function defaultVibeFromEnv(env = process.env) {
|
|
|
33
48
|
}
|
|
34
49
|
export function parseArgs(argv, env = process.env) {
|
|
35
50
|
const options = {
|
|
51
|
+
command: "propose",
|
|
36
52
|
commit: false,
|
|
37
53
|
dryRun: false,
|
|
38
54
|
vibe: defaultVibeFromEnv(env),
|
|
@@ -41,7 +57,15 @@ export function parseArgs(argv, env = process.env) {
|
|
|
41
57
|
help: false,
|
|
42
58
|
version: false,
|
|
43
59
|
};
|
|
44
|
-
|
|
60
|
+
let i = 0;
|
|
61
|
+
if (argv[0] && !argv[0].startsWith("-")) {
|
|
62
|
+
const cmd = parseCommand(argv[0]);
|
|
63
|
+
if (cmd) {
|
|
64
|
+
options.command = cmd;
|
|
65
|
+
i = 1;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (; i < argv.length; i += 1) {
|
|
45
69
|
const arg = argv[i];
|
|
46
70
|
if (arg === undefined)
|
|
47
71
|
break;
|
|
@@ -86,13 +110,49 @@ export function parseArgs(argv, env = process.env) {
|
|
|
86
110
|
options.vibe = parseVibe(value);
|
|
87
111
|
continue;
|
|
88
112
|
}
|
|
113
|
+
if (arg === "--path" || arg.startsWith("--path=")) {
|
|
114
|
+
const value = arg.includes("=") ? arg.slice("--path=".length) : argv[++i];
|
|
115
|
+
if (!value)
|
|
116
|
+
throw new UsageError("Missing value for --path");
|
|
117
|
+
options.path = value;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
89
120
|
if (arg.startsWith("-")) {
|
|
90
121
|
throw new UsageError(`Unknown option: ${arg}`);
|
|
91
122
|
}
|
|
123
|
+
if (options.command === "prepare-commit-msg") {
|
|
124
|
+
if (!options.messageFile) {
|
|
125
|
+
options.messageFile = arg;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (!options.commitSource) {
|
|
129
|
+
options.commitSource = arg;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
throw new UsageError(`Unexpected argument: ${arg}`);
|
|
133
|
+
}
|
|
92
134
|
throw new UsageError(`Unexpected argument: ${arg}`);
|
|
93
135
|
}
|
|
136
|
+
if (options.command === "prepare-commit-msg" && !options.messageFile) {
|
|
137
|
+
throw new UsageError("prepare-commit-msg requires a message file path (git passes this automatically)");
|
|
138
|
+
}
|
|
139
|
+
if (options.path &&
|
|
140
|
+
options.command !== "install" &&
|
|
141
|
+
options.command !== "uninstall") {
|
|
142
|
+
throw new UsageError("--path is only valid with install or uninstall");
|
|
143
|
+
}
|
|
94
144
|
return options;
|
|
95
145
|
}
|
|
146
|
+
function parseCommand(value) {
|
|
147
|
+
switch (value) {
|
|
148
|
+
case "install":
|
|
149
|
+
case "uninstall":
|
|
150
|
+
case "prepare-commit-msg":
|
|
151
|
+
return value;
|
|
152
|
+
default:
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
96
156
|
export function parseType(value) {
|
|
97
157
|
const raw = value?.trim().toLowerCase();
|
|
98
158
|
if (!raw)
|
package/dist/types.d.ts
CHANGED
|
@@ -35,7 +35,9 @@ export interface ProposeOptions {
|
|
|
35
35
|
vibe: Vibe;
|
|
36
36
|
preferAi?: boolean;
|
|
37
37
|
}
|
|
38
|
+
export type CliCommand = "propose" | "install" | "uninstall" | "prepare-commit-msg";
|
|
38
39
|
export interface CliOptions {
|
|
40
|
+
command: CliCommand;
|
|
39
41
|
commit: boolean;
|
|
40
42
|
dryRun: boolean;
|
|
41
43
|
type?: CommitType;
|
|
@@ -45,4 +47,10 @@ export interface CliOptions {
|
|
|
45
47
|
json: boolean;
|
|
46
48
|
help: boolean;
|
|
47
49
|
version: boolean;
|
|
50
|
+
/** Target repo for install/uninstall (default: cwd). */
|
|
51
|
+
path?: string;
|
|
52
|
+
/** Message file path for prepare-commit-msg. */
|
|
53
|
+
messageFile?: string;
|
|
54
|
+
/** Git prepare-commit-msg source (message|template|merge|squash|commit). */
|
|
55
|
+
commitSource?: string;
|
|
48
56
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webywz/vibe-commit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Stylish conventional commit messages with personality. Offline-first, AI-optional.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"git",
|
|
@@ -23,7 +23,10 @@
|
|
|
23
23
|
"author": "webywz",
|
|
24
24
|
"type": "module",
|
|
25
25
|
"bin": {
|
|
26
|
-
"vibe-commit": "
|
|
26
|
+
"vibe-commit": "dist/cli.js"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
27
30
|
},
|
|
28
31
|
"exports": {
|
|
29
32
|
".": {
|
|
@@ -43,7 +46,7 @@
|
|
|
43
46
|
"node": ">=18.18"
|
|
44
47
|
},
|
|
45
48
|
"scripts": {
|
|
46
|
-
"build": "tsc",
|
|
49
|
+
"build": "tsc && node -e \"require('fs').chmodSync('dist/cli.js', 0o755)\"",
|
|
47
50
|
"clean": "rm -rf dist",
|
|
48
51
|
"prepublishOnly": "npm run build",
|
|
49
52
|
"test": "tsx --test test/*.test.ts",
|