@pyyupsk/nit 0.1.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/LICENSE +21 -0
- package/README.md +90 -0
- package/dist/cli.cjs +257 -0
- package/dist/cli.js +233 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pyyupsk
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# @pyyupsk/nit
|
|
2
|
+
|
|
3
|
+
Lightweight, zero-dependency Git hooks manager for JavaScript/Node.js projects.
|
|
4
|
+
|
|
5
|
+
Configure hooks in `package.json` — no extra config files, no dependencies.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Zero runtime dependencies
|
|
10
|
+
- Config lives in `package.json` under a `"nit"` key
|
|
11
|
+
- Auto-installs hooks via the `prepare` lifecycle script
|
|
12
|
+
- CI-safe `check` command — exits 1 when hooks are out of sync
|
|
13
|
+
- Works with any package manager (bun, npm, pnpm, yarn)
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
bun add -D @pyyupsk/nit
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Then add a `prepare` script so hooks are installed automatically on `bun install`:
|
|
22
|
+
|
|
23
|
+
```json
|
|
24
|
+
{
|
|
25
|
+
"scripts": {
|
|
26
|
+
"prepare": "nit install"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Configuration
|
|
32
|
+
|
|
33
|
+
Add a `"nit"` key to your `package.json`:
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"nit": {
|
|
38
|
+
"hooks": {
|
|
39
|
+
"pre-commit": "bun run lint && bun run typecheck",
|
|
40
|
+
"commit-msg": "bun run test"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Any valid [Git hook name](https://git-scm.com/docs/githooks) is supported as a key. The value is the shell command to run.
|
|
47
|
+
|
|
48
|
+
## CLI
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
nit install # write hooks to .git/hooks/ (default when run bare)
|
|
52
|
+
nit sync # alias for install
|
|
53
|
+
nit check # exit 1 if installed hooks differ from config (CI-safe)
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### `nit install` / `nit sync`
|
|
57
|
+
|
|
58
|
+
Reads `nit.hooks` from `package.json` and writes a shell script for each entry into `.git/hooks/`. Each hook file is made executable automatically.
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
$ nit install
|
|
62
|
+
nit: installed pre-commit, commit-msg
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### `nit check`
|
|
66
|
+
|
|
67
|
+
Compares the installed hook files against the current config without writing anything. Exits with code `1` if any hook is missing or has a different command — useful in CI to enforce that hooks are committed.
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
$ nit check
|
|
71
|
+
nit: hooks are up to date
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Self-hosting Example
|
|
75
|
+
|
|
76
|
+
`nit` manages its own hooks:
|
|
77
|
+
|
|
78
|
+
```json
|
|
79
|
+
{
|
|
80
|
+
"nit": {
|
|
81
|
+
"hooks": {
|
|
82
|
+
"pre-commit": "bun format && bun check && bun typecheck && bun test && bun run build"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/cli.ts
|
|
22
|
+
var cli_exports = {};
|
|
23
|
+
__export(cli_exports, {
|
|
24
|
+
run: () => run
|
|
25
|
+
});
|
|
26
|
+
module.exports = __toCommonJS(cli_exports);
|
|
27
|
+
var import_node_path6 = require("path");
|
|
28
|
+
|
|
29
|
+
// src/core/check.ts
|
|
30
|
+
var import_node_fs = require("fs");
|
|
31
|
+
var import_promises = require("fs/promises");
|
|
32
|
+
var import_node_path = require("path");
|
|
33
|
+
async function checkHooks(hooks, hooksDir) {
|
|
34
|
+
try {
|
|
35
|
+
await (0, import_promises.stat)(hooksDir);
|
|
36
|
+
} catch {
|
|
37
|
+
return [new Error(`Hooks directory does not exist: ${hooksDir}`), null];
|
|
38
|
+
}
|
|
39
|
+
for (const [name, cmd] of Object.entries(hooks)) {
|
|
40
|
+
const hookPath = (0, import_node_path.join)(hooksDir, name);
|
|
41
|
+
if (!(0, import_node_fs.existsSync)(hookPath)) {
|
|
42
|
+
return [null, false];
|
|
43
|
+
}
|
|
44
|
+
let content;
|
|
45
|
+
try {
|
|
46
|
+
content = await (0, import_promises.readFile)(hookPath, "utf8");
|
|
47
|
+
} catch (e) {
|
|
48
|
+
return [
|
|
49
|
+
e instanceof Error ? e : new Error(`Failed to read hook "${name}"`),
|
|
50
|
+
null
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
const lines = content.split("\n");
|
|
54
|
+
const hasCmd = lines.some((line) => line.trim() === cmd.trim());
|
|
55
|
+
if (!hasCmd) {
|
|
56
|
+
return [null, false];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return [null, true];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/core/install.ts
|
|
63
|
+
var import_promises2 = require("fs/promises");
|
|
64
|
+
var import_node_path2 = require("path");
|
|
65
|
+
function hookScript(cmd) {
|
|
66
|
+
return `#!/bin/sh
|
|
67
|
+
${cmd}
|
|
68
|
+
`;
|
|
69
|
+
}
|
|
70
|
+
async function installHooks(hooks, hooksDir) {
|
|
71
|
+
try {
|
|
72
|
+
await (0, import_promises2.stat)(hooksDir);
|
|
73
|
+
} catch {
|
|
74
|
+
return [new Error(`Hooks directory does not exist: ${hooksDir}`), null];
|
|
75
|
+
}
|
|
76
|
+
const written = [];
|
|
77
|
+
for (const [name, cmd] of Object.entries(hooks)) {
|
|
78
|
+
const hookPath = (0, import_node_path2.join)(hooksDir, name);
|
|
79
|
+
try {
|
|
80
|
+
await (0, import_promises2.writeFile)(hookPath, hookScript(cmd), "utf8");
|
|
81
|
+
written.push(hookPath);
|
|
82
|
+
await (0, import_promises2.chmod)(hookPath, 493);
|
|
83
|
+
} catch (e) {
|
|
84
|
+
await Promise.all(written.map((p) => (0, import_promises2.unlink)(p).catch(() => {
|
|
85
|
+
})));
|
|
86
|
+
return [
|
|
87
|
+
e instanceof Error ? e : new Error(`Failed to write hook "${name}"`),
|
|
88
|
+
null
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return [null, true];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/core/validate.ts
|
|
96
|
+
var import_node_fs2 = require("fs");
|
|
97
|
+
var import_node_path3 = require("path");
|
|
98
|
+
function commandExists(bin) {
|
|
99
|
+
const sep = process.platform === "win32" ? ";" : ":";
|
|
100
|
+
const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
|
|
101
|
+
const dirs = (process.env.PATH ?? "").split(sep);
|
|
102
|
+
return dirs.some(
|
|
103
|
+
(dir) => exts.some((ext) => (0, import_node_fs2.existsSync)((0, import_node_path3.join)(dir, bin + ext)))
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
function validateHooks(hooks) {
|
|
107
|
+
for (const [hookName, cmd] of Object.entries(hooks)) {
|
|
108
|
+
const bin = cmd.trim().split(/\s+/).at(0);
|
|
109
|
+
if (bin === void 0 || bin === "") {
|
|
110
|
+
return new Error(`Hook "${hookName}" has an empty command`);
|
|
111
|
+
}
|
|
112
|
+
if (!commandExists(bin)) {
|
|
113
|
+
return new Error(`Hook "${hookName}": command "${bin}" not found in PATH`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/utils/config.ts
|
|
120
|
+
var import_promises3 = require("fs/promises");
|
|
121
|
+
var import_node_path4 = require("path");
|
|
122
|
+
async function readConfig(cwd) {
|
|
123
|
+
const pkgPath = (0, import_node_path4.join)(cwd, "package.json");
|
|
124
|
+
let raw;
|
|
125
|
+
try {
|
|
126
|
+
raw = await (0, import_promises3.readFile)(pkgPath, "utf8");
|
|
127
|
+
} catch {
|
|
128
|
+
return [new Error(`Cannot find package.json at ${pkgPath}`), null];
|
|
129
|
+
}
|
|
130
|
+
let pkg;
|
|
131
|
+
try {
|
|
132
|
+
pkg = JSON.parse(raw);
|
|
133
|
+
} catch {
|
|
134
|
+
return [new Error(`package.json contains invalid JSON at ${pkgPath}`), null];
|
|
135
|
+
}
|
|
136
|
+
if (typeof pkg !== "object" || pkg === null) {
|
|
137
|
+
return [new Error("package.json must be a JSON object"), null];
|
|
138
|
+
}
|
|
139
|
+
const nit = pkg.nit;
|
|
140
|
+
if (nit === void 0) {
|
|
141
|
+
return [null, { hooks: {} }];
|
|
142
|
+
}
|
|
143
|
+
if (typeof nit !== "object" || nit === null) {
|
|
144
|
+
return [new Error('"nit" in package.json must be an object'), null];
|
|
145
|
+
}
|
|
146
|
+
const nitObj = nit;
|
|
147
|
+
const hooks = nitObj.hooks;
|
|
148
|
+
if (hooks === void 0) {
|
|
149
|
+
return [null, { hooks: {} }];
|
|
150
|
+
}
|
|
151
|
+
if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks)) {
|
|
152
|
+
return [new Error("hooks must be an object in nit config"), null];
|
|
153
|
+
}
|
|
154
|
+
const hooksObj = hooks;
|
|
155
|
+
for (const [name, cmd] of Object.entries(hooksObj)) {
|
|
156
|
+
if (typeof cmd !== "string") {
|
|
157
|
+
return [
|
|
158
|
+
new Error(
|
|
159
|
+
`Hook "${name}" must have a string command, got ${typeof cmd}`
|
|
160
|
+
),
|
|
161
|
+
null
|
|
162
|
+
];
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return [null, { hooks: hooksObj }];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// src/utils/git.ts
|
|
169
|
+
var import_node_fs3 = require("fs");
|
|
170
|
+
var import_promises4 = require("fs/promises");
|
|
171
|
+
var import_node_path5 = require("path");
|
|
172
|
+
async function findGitDir(startPath, stopAt) {
|
|
173
|
+
try {
|
|
174
|
+
await (0, import_promises4.stat)(startPath);
|
|
175
|
+
} catch {
|
|
176
|
+
return [new Error(`Path does not exist: ${startPath}`), null];
|
|
177
|
+
}
|
|
178
|
+
let current = startPath;
|
|
179
|
+
const { root } = (0, import_node_path5.parse)(current);
|
|
180
|
+
while (current !== root) {
|
|
181
|
+
const candidate = (0, import_node_path5.join)(current, ".git");
|
|
182
|
+
if ((0, import_node_fs3.existsSync)(candidate)) {
|
|
183
|
+
return [null, candidate];
|
|
184
|
+
}
|
|
185
|
+
if (stopAt !== void 0 && current === stopAt) break;
|
|
186
|
+
const parent = (0, import_node_path5.dirname)(current);
|
|
187
|
+
if (parent === current) break;
|
|
188
|
+
current = parent;
|
|
189
|
+
}
|
|
190
|
+
return [new Error(`Not a git repository (searched from ${startPath})`), null];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/cli.ts
|
|
194
|
+
var silent = { log: () => {
|
|
195
|
+
}, error: () => {
|
|
196
|
+
} };
|
|
197
|
+
async function run(args, cwd, logger = silent) {
|
|
198
|
+
const command = args.at(0) ?? "install";
|
|
199
|
+
if (!["install", "sync", "check"].includes(command)) {
|
|
200
|
+
logger.error(`nit: unknown command "${command}"`);
|
|
201
|
+
logger.error("Usage: nit [install|sync|check]");
|
|
202
|
+
return 1;
|
|
203
|
+
}
|
|
204
|
+
const [cfgErr, config] = await readConfig(cwd);
|
|
205
|
+
if (cfgErr !== null) {
|
|
206
|
+
logger.error(`nit: ${cfgErr.message}`);
|
|
207
|
+
return 1;
|
|
208
|
+
}
|
|
209
|
+
const [gitErr, gitDir] = await findGitDir(cwd);
|
|
210
|
+
if (gitErr !== null) {
|
|
211
|
+
if (command !== "check") return 0;
|
|
212
|
+
logger.error(`nit: ${gitErr.message}`);
|
|
213
|
+
return 1;
|
|
214
|
+
}
|
|
215
|
+
const hooksDir = (0, import_node_path6.join)(gitDir, "hooks");
|
|
216
|
+
if (command === "check") {
|
|
217
|
+
const [err, inSync] = await checkHooks(config.hooks, hooksDir);
|
|
218
|
+
if (err !== null) {
|
|
219
|
+
logger.error(`nit: ${err.message}`);
|
|
220
|
+
return 1;
|
|
221
|
+
}
|
|
222
|
+
if (!inSync) {
|
|
223
|
+
logger.error("nit: hooks are out of sync \u2014 run `nit install` to fix");
|
|
224
|
+
return 1;
|
|
225
|
+
}
|
|
226
|
+
logger.log("nit: hooks are up to date");
|
|
227
|
+
return 0;
|
|
228
|
+
}
|
|
229
|
+
const validErr = validateHooks(config.hooks);
|
|
230
|
+
if (validErr !== null) {
|
|
231
|
+
logger.error(`nit: ${validErr.message}`);
|
|
232
|
+
return 1;
|
|
233
|
+
}
|
|
234
|
+
const [installErr] = await installHooks(config.hooks, hooksDir);
|
|
235
|
+
if (installErr !== null) {
|
|
236
|
+
logger.error(`nit: ${installErr.message}`);
|
|
237
|
+
return 1;
|
|
238
|
+
}
|
|
239
|
+
const hookNames = Object.keys(config.hooks);
|
|
240
|
+
if (hookNames.length === 0) {
|
|
241
|
+
logger.log("nit: no hooks configured");
|
|
242
|
+
} else {
|
|
243
|
+
logger.log(`nit: installed ${hookNames.join(", ")}`);
|
|
244
|
+
}
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
var isMain = typeof process !== "undefined" && /[/\\](?:cli\.[jt]s|nit)$/.test(process.argv[1] ?? "");
|
|
248
|
+
if (isMain) {
|
|
249
|
+
const args = process.argv.slice(2);
|
|
250
|
+
run(args, process.cwd(), console).then((code) => {
|
|
251
|
+
process.exit(code);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
255
|
+
0 && (module.exports = {
|
|
256
|
+
run
|
|
257
|
+
});
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { join as join6 } from "path";
|
|
5
|
+
|
|
6
|
+
// src/core/check.ts
|
|
7
|
+
import { existsSync } from "fs";
|
|
8
|
+
import { readFile, stat } from "fs/promises";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
async function checkHooks(hooks, hooksDir) {
|
|
11
|
+
try {
|
|
12
|
+
await stat(hooksDir);
|
|
13
|
+
} catch {
|
|
14
|
+
return [new Error(`Hooks directory does not exist: ${hooksDir}`), null];
|
|
15
|
+
}
|
|
16
|
+
for (const [name, cmd] of Object.entries(hooks)) {
|
|
17
|
+
const hookPath = join(hooksDir, name);
|
|
18
|
+
if (!existsSync(hookPath)) {
|
|
19
|
+
return [null, false];
|
|
20
|
+
}
|
|
21
|
+
let content;
|
|
22
|
+
try {
|
|
23
|
+
content = await readFile(hookPath, "utf8");
|
|
24
|
+
} catch (e) {
|
|
25
|
+
return [
|
|
26
|
+
e instanceof Error ? e : new Error(`Failed to read hook "${name}"`),
|
|
27
|
+
null
|
|
28
|
+
];
|
|
29
|
+
}
|
|
30
|
+
const lines = content.split("\n");
|
|
31
|
+
const hasCmd = lines.some((line) => line.trim() === cmd.trim());
|
|
32
|
+
if (!hasCmd) {
|
|
33
|
+
return [null, false];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return [null, true];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// src/core/install.ts
|
|
40
|
+
import { chmod, stat as stat2, unlink, writeFile } from "fs/promises";
|
|
41
|
+
import { join as join2 } from "path";
|
|
42
|
+
function hookScript(cmd) {
|
|
43
|
+
return `#!/bin/sh
|
|
44
|
+
${cmd}
|
|
45
|
+
`;
|
|
46
|
+
}
|
|
47
|
+
async function installHooks(hooks, hooksDir) {
|
|
48
|
+
try {
|
|
49
|
+
await stat2(hooksDir);
|
|
50
|
+
} catch {
|
|
51
|
+
return [new Error(`Hooks directory does not exist: ${hooksDir}`), null];
|
|
52
|
+
}
|
|
53
|
+
const written = [];
|
|
54
|
+
for (const [name, cmd] of Object.entries(hooks)) {
|
|
55
|
+
const hookPath = join2(hooksDir, name);
|
|
56
|
+
try {
|
|
57
|
+
await writeFile(hookPath, hookScript(cmd), "utf8");
|
|
58
|
+
written.push(hookPath);
|
|
59
|
+
await chmod(hookPath, 493);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
await Promise.all(written.map((p) => unlink(p).catch(() => {
|
|
62
|
+
})));
|
|
63
|
+
return [
|
|
64
|
+
e instanceof Error ? e : new Error(`Failed to write hook "${name}"`),
|
|
65
|
+
null
|
|
66
|
+
];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return [null, true];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/core/validate.ts
|
|
73
|
+
import { existsSync as existsSync2 } from "fs";
|
|
74
|
+
import { join as join3 } from "path";
|
|
75
|
+
function commandExists(bin) {
|
|
76
|
+
const sep = process.platform === "win32" ? ";" : ":";
|
|
77
|
+
const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ""] : [""];
|
|
78
|
+
const dirs = (process.env.PATH ?? "").split(sep);
|
|
79
|
+
return dirs.some(
|
|
80
|
+
(dir) => exts.some((ext) => existsSync2(join3(dir, bin + ext)))
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
function validateHooks(hooks) {
|
|
84
|
+
for (const [hookName, cmd] of Object.entries(hooks)) {
|
|
85
|
+
const bin = cmd.trim().split(/\s+/).at(0);
|
|
86
|
+
if (bin === void 0 || bin === "") {
|
|
87
|
+
return new Error(`Hook "${hookName}" has an empty command`);
|
|
88
|
+
}
|
|
89
|
+
if (!commandExists(bin)) {
|
|
90
|
+
return new Error(`Hook "${hookName}": command "${bin}" not found in PATH`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// src/utils/config.ts
|
|
97
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
98
|
+
import { join as join4 } from "path";
|
|
99
|
+
async function readConfig(cwd) {
|
|
100
|
+
const pkgPath = join4(cwd, "package.json");
|
|
101
|
+
let raw;
|
|
102
|
+
try {
|
|
103
|
+
raw = await readFile2(pkgPath, "utf8");
|
|
104
|
+
} catch {
|
|
105
|
+
return [new Error(`Cannot find package.json at ${pkgPath}`), null];
|
|
106
|
+
}
|
|
107
|
+
let pkg;
|
|
108
|
+
try {
|
|
109
|
+
pkg = JSON.parse(raw);
|
|
110
|
+
} catch {
|
|
111
|
+
return [new Error(`package.json contains invalid JSON at ${pkgPath}`), null];
|
|
112
|
+
}
|
|
113
|
+
if (typeof pkg !== "object" || pkg === null) {
|
|
114
|
+
return [new Error("package.json must be a JSON object"), null];
|
|
115
|
+
}
|
|
116
|
+
const nit = pkg.nit;
|
|
117
|
+
if (nit === void 0) {
|
|
118
|
+
return [null, { hooks: {} }];
|
|
119
|
+
}
|
|
120
|
+
if (typeof nit !== "object" || nit === null) {
|
|
121
|
+
return [new Error('"nit" in package.json must be an object'), null];
|
|
122
|
+
}
|
|
123
|
+
const nitObj = nit;
|
|
124
|
+
const hooks = nitObj.hooks;
|
|
125
|
+
if (hooks === void 0) {
|
|
126
|
+
return [null, { hooks: {} }];
|
|
127
|
+
}
|
|
128
|
+
if (typeof hooks !== "object" || hooks === null || Array.isArray(hooks)) {
|
|
129
|
+
return [new Error("hooks must be an object in nit config"), null];
|
|
130
|
+
}
|
|
131
|
+
const hooksObj = hooks;
|
|
132
|
+
for (const [name, cmd] of Object.entries(hooksObj)) {
|
|
133
|
+
if (typeof cmd !== "string") {
|
|
134
|
+
return [
|
|
135
|
+
new Error(
|
|
136
|
+
`Hook "${name}" must have a string command, got ${typeof cmd}`
|
|
137
|
+
),
|
|
138
|
+
null
|
|
139
|
+
];
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return [null, { hooks: hooksObj }];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// src/utils/git.ts
|
|
146
|
+
import { existsSync as existsSync3 } from "fs";
|
|
147
|
+
import { stat as stat3 } from "fs/promises";
|
|
148
|
+
import { dirname, join as join5, parse } from "path";
|
|
149
|
+
async function findGitDir(startPath, stopAt) {
|
|
150
|
+
try {
|
|
151
|
+
await stat3(startPath);
|
|
152
|
+
} catch {
|
|
153
|
+
return [new Error(`Path does not exist: ${startPath}`), null];
|
|
154
|
+
}
|
|
155
|
+
let current = startPath;
|
|
156
|
+
const { root } = parse(current);
|
|
157
|
+
while (current !== root) {
|
|
158
|
+
const candidate = join5(current, ".git");
|
|
159
|
+
if (existsSync3(candidate)) {
|
|
160
|
+
return [null, candidate];
|
|
161
|
+
}
|
|
162
|
+
if (stopAt !== void 0 && current === stopAt) break;
|
|
163
|
+
const parent = dirname(current);
|
|
164
|
+
if (parent === current) break;
|
|
165
|
+
current = parent;
|
|
166
|
+
}
|
|
167
|
+
return [new Error(`Not a git repository (searched from ${startPath})`), null];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// src/cli.ts
|
|
171
|
+
var silent = { log: () => {
|
|
172
|
+
}, error: () => {
|
|
173
|
+
} };
|
|
174
|
+
async function run(args, cwd, logger = silent) {
|
|
175
|
+
const command = args.at(0) ?? "install";
|
|
176
|
+
if (!["install", "sync", "check"].includes(command)) {
|
|
177
|
+
logger.error(`nit: unknown command "${command}"`);
|
|
178
|
+
logger.error("Usage: nit [install|sync|check]");
|
|
179
|
+
return 1;
|
|
180
|
+
}
|
|
181
|
+
const [cfgErr, config] = await readConfig(cwd);
|
|
182
|
+
if (cfgErr !== null) {
|
|
183
|
+
logger.error(`nit: ${cfgErr.message}`);
|
|
184
|
+
return 1;
|
|
185
|
+
}
|
|
186
|
+
const [gitErr, gitDir] = await findGitDir(cwd);
|
|
187
|
+
if (gitErr !== null) {
|
|
188
|
+
if (command !== "check") return 0;
|
|
189
|
+
logger.error(`nit: ${gitErr.message}`);
|
|
190
|
+
return 1;
|
|
191
|
+
}
|
|
192
|
+
const hooksDir = join6(gitDir, "hooks");
|
|
193
|
+
if (command === "check") {
|
|
194
|
+
const [err, inSync] = await checkHooks(config.hooks, hooksDir);
|
|
195
|
+
if (err !== null) {
|
|
196
|
+
logger.error(`nit: ${err.message}`);
|
|
197
|
+
return 1;
|
|
198
|
+
}
|
|
199
|
+
if (!inSync) {
|
|
200
|
+
logger.error("nit: hooks are out of sync \u2014 run `nit install` to fix");
|
|
201
|
+
return 1;
|
|
202
|
+
}
|
|
203
|
+
logger.log("nit: hooks are up to date");
|
|
204
|
+
return 0;
|
|
205
|
+
}
|
|
206
|
+
const validErr = validateHooks(config.hooks);
|
|
207
|
+
if (validErr !== null) {
|
|
208
|
+
logger.error(`nit: ${validErr.message}`);
|
|
209
|
+
return 1;
|
|
210
|
+
}
|
|
211
|
+
const [installErr] = await installHooks(config.hooks, hooksDir);
|
|
212
|
+
if (installErr !== null) {
|
|
213
|
+
logger.error(`nit: ${installErr.message}`);
|
|
214
|
+
return 1;
|
|
215
|
+
}
|
|
216
|
+
const hookNames = Object.keys(config.hooks);
|
|
217
|
+
if (hookNames.length === 0) {
|
|
218
|
+
logger.log("nit: no hooks configured");
|
|
219
|
+
} else {
|
|
220
|
+
logger.log(`nit: installed ${hookNames.join(", ")}`);
|
|
221
|
+
}
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
var isMain = typeof process !== "undefined" && /[/\\](?:cli\.[jt]s|nit)$/.test(process.argv[1] ?? "");
|
|
225
|
+
if (isMain) {
|
|
226
|
+
const args = process.argv.slice(2);
|
|
227
|
+
run(args, process.cwd(), console).then((code) => {
|
|
228
|
+
process.exit(code);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
export {
|
|
232
|
+
run
|
|
233
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pyyupsk/nit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Lightweight zero-dependency Git hooks manager for JavaScript/Node.js projects",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"packageManager": "bun@1.3.11",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "dist/cli.js",
|
|
9
|
+
"module": "dist/cli.js",
|
|
10
|
+
"bin": {
|
|
11
|
+
"nit": "dist/cli.js"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"files": [
|
|
17
|
+
"dist"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup",
|
|
21
|
+
"check": "biome check .",
|
|
22
|
+
"dev": "tsup --watch",
|
|
23
|
+
"format": "biome format --write .",
|
|
24
|
+
"prepare": "bun run ./src/cli.ts install",
|
|
25
|
+
"test": "vitest run",
|
|
26
|
+
"test:watch": "vitest",
|
|
27
|
+
"typecheck": "tsc --noEmit"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@biomejs/biome": "^2.4.11",
|
|
31
|
+
"@types/bun": "^1.3.12",
|
|
32
|
+
"@vitest/coverage-v8": "^4.1.4",
|
|
33
|
+
"tsup": "^8.5.1",
|
|
34
|
+
"typescript": "^6.0.2",
|
|
35
|
+
"vitest": "^4.1.4"
|
|
36
|
+
},
|
|
37
|
+
"nit": {
|
|
38
|
+
"hooks": {
|
|
39
|
+
"pre-commit": "bun format && bun check && bun typecheck && bun test && bun run build"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|