@homeflare/config 0.9.0 → 0.11.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/README.md +5 -40
- package/bin/hooks.ts +8 -4
- package/dist/hooks/activate.d.ts +7 -0
- package/dist/hooks/activate.d.ts.map +1 -0
- package/dist/hooks/gates.d.ts +7 -14
- package/dist/hooks/gates.d.ts.map +1 -1
- package/dist/hooks/install.d.ts +7 -1
- package/dist/hooks/install.d.ts.map +1 -1
- package/dist/hooks/push-plan.d.ts +59 -0
- package/dist/hooks/push-plan.d.ts.map +1 -0
- package/dist/hooks/push-range.d.ts +51 -0
- package/dist/hooks/push-range.d.ts.map +1 -0
- package/dist/hooks/report.d.ts +29 -2
- package/dist/hooks/report.d.ts.map +1 -1
- package/dist/hooks/secrets.d.ts +2 -0
- package/dist/hooks/secrets.d.ts.map +1 -0
- package/dist/hooks.d.ts +30 -6
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +339 -31
- package/dist/hooks.js.map +11 -7
- package/dist/repo-shape/automerge.d.ts +17 -0
- package/dist/repo-shape/automerge.d.ts.map +1 -0
- package/dist/repo-shape/ci.d.ts.map +1 -1
- package/dist/repo-shape/companions.d.ts +2 -6
- package/dist/repo-shape/companions.d.ts.map +1 -1
- package/dist/repo-shape/dependabot.d.ts +35 -0
- package/dist/repo-shape/dependabot.d.ts.map +1 -0
- package/dist/repo-shape/guards.d.ts +25 -0
- package/dist/repo-shape/guards.d.ts.map +1 -0
- package/dist/repo-shape/render.d.ts.map +1 -1
- package/dist/repo-shape/shape.d.ts +40 -2
- package/dist/repo-shape/shape.d.ts.map +1 -1
- package/dist/repo-shape.d.ts +5 -2
- package/dist/repo-shape.d.ts.map +1 -1
- package/dist/repo-shape.js +269 -105
- package/dist/repo-shape.js.map +10 -7
- package/dist/versions.js +68 -31
- package/dist/versions.js.map +6 -5
- package/docs/hooks.md +99 -0
- package/docs/repo-shape-dependabot.md +126 -0
- package/docs/repo-shape-inputs.md +83 -0
- package/docs/repo-shape.md +5 -23
- package/package.json +1 -1
- package/src/hooks/activate.ts +71 -0
- package/src/hooks/gates.ts +87 -28
- package/src/hooks/install.ts +39 -20
- package/src/hooks/push-plan.ts +167 -0
- package/src/hooks/push-range.ts +177 -0
- package/src/hooks/report.ts +31 -6
- package/src/hooks/secrets.ts +42 -0
- package/src/hooks.ts +30 -14
- package/src/repo-shape/automerge.ts +144 -0
- package/src/repo-shape/ci.ts +35 -16
- package/src/repo-shape/companions.ts +2 -77
- package/src/repo-shape/dependabot.ts +136 -0
- package/src/repo-shape/guards.ts +68 -0
- package/src/repo-shape/render.ts +5 -1
- package/src/repo-shape/shape.ts +51 -35
- package/src/repo-shape.ts +8 -4
package/dist/hooks.js
CHANGED
|
@@ -16,10 +16,23 @@ async function run(cmd, isolated = false) {
|
|
|
16
16
|
return await proc.exited;
|
|
17
17
|
}
|
|
18
18
|
async function capture(cmd) {
|
|
19
|
+
return (await probe(cmd)).stdout;
|
|
20
|
+
}
|
|
21
|
+
async function probe(cmd) {
|
|
19
22
|
const proc = Bun.spawn([...cmd], { stdout: "pipe", stderr: "ignore" });
|
|
20
|
-
const
|
|
21
|
-
await proc.exited;
|
|
22
|
-
|
|
23
|
+
const stdout = await new Response(proc.stdout).text();
|
|
24
|
+
return { code: await proc.exited, stdout };
|
|
25
|
+
}
|
|
26
|
+
async function runLane(command, root) {
|
|
27
|
+
const env = withoutGitEnv();
|
|
28
|
+
env["PATH"] = `${root}/node_modules/.bin:${env["PATH"] ?? ""}`;
|
|
29
|
+
const proc = Bun.spawn(["sh", "-c", command], {
|
|
30
|
+
cwd: root,
|
|
31
|
+
env,
|
|
32
|
+
stdout: "inherit",
|
|
33
|
+
stderr: "inherit"
|
|
34
|
+
});
|
|
35
|
+
return await proc.exited;
|
|
23
36
|
}
|
|
24
37
|
function tool(root, name) {
|
|
25
38
|
const local = `${root}/node_modules/.bin/${name}`;
|
|
@@ -44,12 +57,243 @@ function fail(hook, what, fix) {
|
|
|
44
57
|
process.exit(1);
|
|
45
58
|
}
|
|
46
59
|
|
|
60
|
+
// src/hooks/activate.ts
|
|
61
|
+
function inCi(env) {
|
|
62
|
+
const ci = env["CI"];
|
|
63
|
+
return ci !== undefined && ci !== "" && ci !== "0" && ci.toLowerCase() !== "false";
|
|
64
|
+
}
|
|
65
|
+
async function activateHooks(root, env = process.env) {
|
|
66
|
+
if (inCi(env))
|
|
67
|
+
return { active: false, message: "CI is set \u2014 hooks stay off; CI runs the gate" };
|
|
68
|
+
const where = await probe(["git", "-C", root, "rev-parse", "--show-prefix"]);
|
|
69
|
+
if (where.code !== 0)
|
|
70
|
+
return { active: false, message: "not a git work tree \u2014 nothing to do" };
|
|
71
|
+
const tracked = await Promise.all(["pre-commit", "pre-push"].map((name) => Bun.file(`${root}/.husky/${name}`).exists()));
|
|
72
|
+
if (!tracked.some(Boolean)) {
|
|
73
|
+
return { active: false, message: "no .husky/pre-commit or .husky/pre-push to point git at" };
|
|
74
|
+
}
|
|
75
|
+
const want = `${where.stdout.trim()}.husky`;
|
|
76
|
+
const current = (await probe(["git", "-C", root, "config", "--get", "core.hooksPath"])).stdout.trim();
|
|
77
|
+
if (current === want)
|
|
78
|
+
return { active: true, message: `core.hooksPath is already ${want}` };
|
|
79
|
+
const set = await probe(["git", "-C", root, "config", "core.hooksPath", want]);
|
|
80
|
+
if (set.code !== 0)
|
|
81
|
+
return { active: false, message: "git config core.hooksPath failed" };
|
|
82
|
+
const was = current === "" ? "unset" : current;
|
|
83
|
+
return {
|
|
84
|
+
active: true,
|
|
85
|
+
message: `core.hooksPath ${was} \u2192 ${want}, for every worktree of this clone`
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// src/hooks/gates.ts
|
|
90
|
+
import { existsSync } from "fs";
|
|
91
|
+
|
|
92
|
+
// src/hooks/push-plan.ts
|
|
93
|
+
var LEFT_TO_CI = /^(build|smoke)(:|$)/;
|
|
94
|
+
var MAX_DEPTH = 8;
|
|
95
|
+
function andChain(script) {
|
|
96
|
+
const parts = [];
|
|
97
|
+
let quote;
|
|
98
|
+
let current = "";
|
|
99
|
+
for (let i = 0;i < script.length; i++) {
|
|
100
|
+
const char = script[i] ?? "";
|
|
101
|
+
if (quote !== undefined) {
|
|
102
|
+
if (char === quote)
|
|
103
|
+
quote = undefined;
|
|
104
|
+
current += char;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (char === '"' || char === "'") {
|
|
108
|
+
quote = char;
|
|
109
|
+
current += char;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (char === "&" && script[i + 1] === "&") {
|
|
113
|
+
parts.push(current.trim());
|
|
114
|
+
current = "";
|
|
115
|
+
i += 1;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if ("|;&<>`".includes(char) || char === "$" && script[i + 1] === "(")
|
|
119
|
+
return;
|
|
120
|
+
current += char;
|
|
121
|
+
}
|
|
122
|
+
if (quote !== undefined)
|
|
123
|
+
return;
|
|
124
|
+
parts.push(current.trim());
|
|
125
|
+
return parts.some((part) => part === "") ? undefined : parts;
|
|
126
|
+
}
|
|
127
|
+
function scriptRef(segment, scripts) {
|
|
128
|
+
const words = segment.split(/\s+/);
|
|
129
|
+
if (words.length === 2 && words[0] === "npm" && words[1] === "test")
|
|
130
|
+
return "test";
|
|
131
|
+
const [runner, verb, name] = words;
|
|
132
|
+
if (words.length !== 3 || verb !== "run" || name === undefined)
|
|
133
|
+
return;
|
|
134
|
+
if (runner !== "bun" && runner !== "npm")
|
|
135
|
+
return;
|
|
136
|
+
return Object.hasOwn(scripts, name) ? name : undefined;
|
|
137
|
+
}
|
|
138
|
+
var isBunTest = (segment) => /^bun\s+test(\s|$)/.test(segment);
|
|
139
|
+
function testLane(segment, base) {
|
|
140
|
+
return base === undefined ? { kind: "test", label: segment, command: segment, scoped: false } : { kind: "test", label: segment, command: `${segment} --changed=${base}`, scoped: true };
|
|
141
|
+
}
|
|
142
|
+
function expand(script, scripts, base, depth) {
|
|
143
|
+
const segments = andChain(script);
|
|
144
|
+
if (segments === undefined || depth > MAX_DEPTH)
|
|
145
|
+
return;
|
|
146
|
+
const lanes = [];
|
|
147
|
+
for (const segment of segments) {
|
|
148
|
+
if (isBunTest(segment)) {
|
|
149
|
+
lanes.push(testLane(segment, base));
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const name = scriptRef(segment, scripts);
|
|
153
|
+
if (name === undefined) {
|
|
154
|
+
lanes.push({ kind: "run", label: segment, command: segment });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (LEFT_TO_CI.test(name)) {
|
|
158
|
+
lanes.push({ kind: "skip", label: segment, why: "CI runs it on every pull request" });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
const inner = expand(scripts[name] ?? "", scripts, base, depth + 1);
|
|
162
|
+
if (inner !== undefined && inner.some((lane) => lane.kind !== "run")) {
|
|
163
|
+
lanes.push(...inner);
|
|
164
|
+
} else if (/^test(:|$)/.test(name)) {
|
|
165
|
+
lanes.push({ kind: "test", label: segment, command: segment, scoped: false });
|
|
166
|
+
} else {
|
|
167
|
+
lanes.push({ kind: "run", label: segment, command: segment });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return lanes;
|
|
171
|
+
}
|
|
172
|
+
function planLanes(scripts, base) {
|
|
173
|
+
const check = scripts["check"];
|
|
174
|
+
if (check === undefined)
|
|
175
|
+
return [];
|
|
176
|
+
const whole = {
|
|
177
|
+
kind: "test",
|
|
178
|
+
label: "bun run check",
|
|
179
|
+
command: "bun run check",
|
|
180
|
+
scoped: false
|
|
181
|
+
};
|
|
182
|
+
return expand(check, scripts, base, 0) ?? [whole];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/hooks/push-range.ts
|
|
186
|
+
var ZERO = /^0+$/;
|
|
187
|
+
function parsePushRefs(stdin) {
|
|
188
|
+
const refs = [];
|
|
189
|
+
for (const line2 of stdin.split(`
|
|
190
|
+
`)) {
|
|
191
|
+
const [localRef, localSha, remoteRef, remoteSha] = line2.trim().split(/\s+/);
|
|
192
|
+
if (localRef && localSha && remoteRef && remoteSha) {
|
|
193
|
+
refs.push({ localRef, localSha, remoteRef, remoteSha });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return refs;
|
|
197
|
+
}
|
|
198
|
+
var short = (sha) => sha.slice(0, 9);
|
|
199
|
+
async function ok2(root, args) {
|
|
200
|
+
return (await probe(["git", "-C", root, ...args])).code === 0;
|
|
201
|
+
}
|
|
202
|
+
async function out(root, args) {
|
|
203
|
+
const result = await probe(["git", "-C", root, ...args]);
|
|
204
|
+
return result.code === 0 ? result.stdout.trim() : undefined;
|
|
205
|
+
}
|
|
206
|
+
async function defaultBranch(root, remote) {
|
|
207
|
+
const head = await out(root, ["symbolic-ref", "--quiet", `refs/remotes/${remote}/HEAD`]);
|
|
208
|
+
if (head !== undefined && head !== "")
|
|
209
|
+
return head;
|
|
210
|
+
for (const name of ["main", "master"]) {
|
|
211
|
+
const ref = `refs/remotes/${remote}/${name}`;
|
|
212
|
+
if (await ok2(root, ["rev-parse", "--verify", "--quiet", ref]))
|
|
213
|
+
return ref;
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
async function baseFor(root, remote, ref) {
|
|
218
|
+
const { localSha, remoteSha } = ref;
|
|
219
|
+
if (!ZERO.test(remoteSha) && await ok2(root, ["cat-file", "-e", `${remoteSha}^{commit}`]) && await ok2(root, ["merge-base", "--is-ancestor", remoteSha, localSha])) {
|
|
220
|
+
return { base: remoteSha, why: `since ${short(remoteSha)}, what ${remote} has now` };
|
|
221
|
+
}
|
|
222
|
+
const branch = await defaultBranch(root, remote);
|
|
223
|
+
if (branch === undefined)
|
|
224
|
+
return { why: `no ${remote}/main or ${remote}/master to measure from` };
|
|
225
|
+
const mergeBase = await out(root, ["merge-base", localSha, branch]);
|
|
226
|
+
if (mergeBase === undefined || mergeBase === "") {
|
|
227
|
+
return { why: `no merge base with ${branch} (a shallow clone, or unrelated history)` };
|
|
228
|
+
}
|
|
229
|
+
const name = branch.replace(/^refs\/remotes\//, "");
|
|
230
|
+
return { base: mergeBase, why: `since ${short(mergeBase)}, where this branch left ${name}` };
|
|
231
|
+
}
|
|
232
|
+
async function pushScope(root, remote, refs) {
|
|
233
|
+
const pushed = refs.filter((ref2) => !ZERO.test(ref2.localSha));
|
|
234
|
+
if (refs.length > 0 && pushed.length === 0) {
|
|
235
|
+
return { kind: "empty", why: "this push only deletes refs" };
|
|
236
|
+
}
|
|
237
|
+
const head = await out(root, ["rev-parse", "HEAD"]);
|
|
238
|
+
const atHead = pushed.find((ref2) => ref2.localSha === head);
|
|
239
|
+
const others = pushed.filter((ref2) => ref2 !== atHead).map((ref2) => ref2.localRef);
|
|
240
|
+
if (pushed.length > 0 && atHead === undefined) {
|
|
241
|
+
return {
|
|
242
|
+
kind: "elsewhere",
|
|
243
|
+
why: `pushing ${others.join(", ")}, but the checkout is at ${short(head ?? "?")}`
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const ref = atHead ?? {
|
|
247
|
+
localRef: "HEAD",
|
|
248
|
+
localSha: head ?? "HEAD",
|
|
249
|
+
remoteRef: "",
|
|
250
|
+
remoteSha: "0".repeat(40)
|
|
251
|
+
};
|
|
252
|
+
const found = await baseFor(root, remote, ref);
|
|
253
|
+
const also = others.length > 0 ? `; ${others.join(", ")} not checked here` : "";
|
|
254
|
+
if (!("base" in found))
|
|
255
|
+
return { kind: "unscoped", why: `${found.why}${also}` };
|
|
256
|
+
const diff = await probe([
|
|
257
|
+
"git",
|
|
258
|
+
"-C",
|
|
259
|
+
root,
|
|
260
|
+
"diff",
|
|
261
|
+
"--name-only",
|
|
262
|
+
"-z",
|
|
263
|
+
found.base,
|
|
264
|
+
ref.localSha
|
|
265
|
+
]);
|
|
266
|
+
if (diff.code !== 0)
|
|
267
|
+
return { kind: "unscoped", why: `git diff ${short(found.base)} failed` };
|
|
268
|
+
const changed = diff.stdout.split("\x00").filter((path) => path !== "");
|
|
269
|
+
if (changed.length === 0)
|
|
270
|
+
return { kind: "empty", why: `no file differs ${found.why}${also}` };
|
|
271
|
+
return { kind: "scoped", base: found.base, changed, why: `${found.why}${also}` };
|
|
272
|
+
}
|
|
273
|
+
function changesEverything(path) {
|
|
274
|
+
const name = path.split("/").pop() ?? "";
|
|
275
|
+
return /^(package\.json|bun\.lockb?|bunfig\.toml|tsconfig.*\.json)$/.test(name);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// src/hooks/secrets.ts
|
|
279
|
+
var INSTALL = "brew install gitleaks \u2014 Linux: https://github.com/gitleaks/gitleaks/releases";
|
|
280
|
+
async function scanStagedSecrets() {
|
|
281
|
+
if (Bun.which("gitleaks") === null) {
|
|
282
|
+
fail("pre-commit", "gitleaks is not installed, so the staged changes were NOT scanned", INSTALL);
|
|
283
|
+
}
|
|
284
|
+
const code = await run(["gitleaks", "git", "--staged", "--redact", "--no-banner", "."]);
|
|
285
|
+
if (code !== 0) {
|
|
286
|
+
fail("pre-commit", "gitleaks found a secret in the staged changes", "remove it, then ROTATE it \u2014 assume anything committed is already compromised");
|
|
287
|
+
}
|
|
288
|
+
ok("pre-commit: gitleaks found no secret in the staged changes");
|
|
289
|
+
}
|
|
290
|
+
|
|
47
291
|
// src/hooks/staged.ts
|
|
48
292
|
var FORMATTABLE = /\.(ts|tsx|js|jsx|mjs|cjs|json|jsonc|md)$/;
|
|
49
293
|
var CODE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
|
|
50
294
|
async function names(args) {
|
|
51
|
-
const
|
|
52
|
-
return
|
|
295
|
+
const out2 = await capture(["git", ...args, "-z"]);
|
|
296
|
+
return out2.split("\x00").filter((line2) => line2.length > 0);
|
|
53
297
|
}
|
|
54
298
|
async function staged() {
|
|
55
299
|
const indexed = await names(["diff", "--cached", "--name-only", "--diff-filter=ACMR"]);
|
|
@@ -62,18 +306,27 @@ async function staged() {
|
|
|
62
306
|
};
|
|
63
307
|
}
|
|
64
308
|
async function fingerprints(files) {
|
|
65
|
-
const
|
|
309
|
+
const out2 = new Map;
|
|
66
310
|
for (const file of files) {
|
|
67
311
|
const handle = Bun.file(file);
|
|
68
312
|
if (!await handle.exists())
|
|
69
313
|
continue;
|
|
70
|
-
|
|
314
|
+
out2.set(file, String(Bun.hash(await handle.arrayBuffer())));
|
|
71
315
|
}
|
|
72
|
-
return
|
|
316
|
+
return out2;
|
|
73
317
|
}
|
|
74
318
|
|
|
75
319
|
// src/hooks/gates.ts
|
|
320
|
+
function installed(root, hook) {
|
|
321
|
+
if (existsSync(`${root}/node_modules`))
|
|
322
|
+
return true;
|
|
323
|
+
note(`${hook}: no node_modules in this worktree \u2014 run 'bun install'; skipping the rest`);
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
76
326
|
async function preCommit(root) {
|
|
327
|
+
await scanStagedSecrets();
|
|
328
|
+
if (!installed(root, "pre-commit"))
|
|
329
|
+
return;
|
|
77
330
|
const oxfmt = tool(root, "oxfmt");
|
|
78
331
|
const { formattable, code, partial } = await staged();
|
|
79
332
|
if (partial.length > 0) {
|
|
@@ -103,40 +356,82 @@ async function preCommit(root) {
|
|
|
103
356
|
}
|
|
104
357
|
ok(`pre-commit: ${formattable.length} staged file(s) formatted and linted`);
|
|
105
358
|
}
|
|
106
|
-
|
|
359
|
+
function describe(lane) {
|
|
360
|
+
if (lane.kind === "skip")
|
|
361
|
+
return `skip ${lane.label} \u2014 ${lane.why}`;
|
|
362
|
+
if (lane.kind === "test" && lane.scoped)
|
|
363
|
+
return `run ${lane.command} (only the tests the push can reach)`;
|
|
364
|
+
if (lane.kind === "test")
|
|
365
|
+
return `run ${lane.command} (IN FULL)`;
|
|
366
|
+
return `run ${lane.command}`;
|
|
367
|
+
}
|
|
368
|
+
async function prePush(root, args, stdin) {
|
|
107
369
|
const manifest = Bun.file(`${root}/package.json`);
|
|
108
370
|
const pkg = await manifest.exists() ? await manifest.json() : {};
|
|
109
|
-
|
|
371
|
+
const scripts = pkg.scripts ?? {};
|
|
372
|
+
if (scripts["check"] === undefined) {
|
|
110
373
|
note("pre-push: no `check` script declared in package.json; nothing to run");
|
|
111
374
|
return;
|
|
112
375
|
}
|
|
113
|
-
|
|
376
|
+
if (!installed(root, "pre-push"))
|
|
377
|
+
return;
|
|
378
|
+
const scope = await pushScope(root, args[0] ?? "origin", parsePushRefs(stdin));
|
|
379
|
+
if (scope.kind === "empty") {
|
|
380
|
+
ok(`pre-push: ${scope.why} \u2014 nothing to check`);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (scope.kind === "elsewhere") {
|
|
384
|
+
note(`pre-push: ${scope.why} \u2014 the working tree is not what is being pushed`);
|
|
385
|
+
note(" NOT CHECKED here; CI checks it. To check it locally, check it out and push from there.");
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
let base;
|
|
389
|
+
if (scope.kind === "unscoped") {
|
|
390
|
+
note(`pre-push: ${scope.why} \u2014 every lane runs, tests in full`);
|
|
391
|
+
} else {
|
|
392
|
+
const global = scope.changed.filter(changesEverything);
|
|
393
|
+
note(`pre-push: ${String(scope.changed.length)} file(s) changed ${scope.why}`);
|
|
394
|
+
if (global.length > 0)
|
|
395
|
+
note(` ${global.join(", ")} changes what every test runs on \u2014 tests in full`);
|
|
396
|
+
else
|
|
397
|
+
base = scope.base;
|
|
398
|
+
}
|
|
399
|
+
const lanes = planLanes(scripts, base);
|
|
114
400
|
const started = Bun.nanoseconds();
|
|
115
|
-
|
|
116
|
-
|
|
401
|
+
let ran = 0;
|
|
402
|
+
for (const lane of lanes) {
|
|
403
|
+
note(describe(lane));
|
|
404
|
+
if (lane.kind === "skip")
|
|
405
|
+
continue;
|
|
406
|
+
if (await runLane(lane.command, root) !== 0) {
|
|
407
|
+
fail("pre-push", `\`${lane.label}\` failed`, `${lane.command} \u2014 until it is green`);
|
|
408
|
+
}
|
|
409
|
+
ran += 1;
|
|
117
410
|
}
|
|
118
|
-
|
|
411
|
+
const seconds = ((Bun.nanoseconds() - started) / 1e9).toFixed(1);
|
|
412
|
+
ok(`pre-push: ${String(ran)} lane(s) of \`check\` passed in ${seconds}s \u2014 CI runs the full gate`);
|
|
119
413
|
}
|
|
120
414
|
|
|
121
415
|
// src/hooks/install.ts
|
|
122
|
-
import { chmod, mkdir } from "fs/promises";
|
|
416
|
+
import { chmod, mkdir, stat } from "fs/promises";
|
|
123
417
|
var HOOK_NAMES = ["pre-commit", "pre-push"];
|
|
124
418
|
var RUNNER = "node_modules/@homeflare/config/bin/hooks.ts";
|
|
419
|
+
var PREPARE = `bun ${RUNNER} activate`;
|
|
125
420
|
var HUSKY_HOOK = `# HomeFlare shared git hook. The behaviour lives in @homeflare/config, not in this file,
|
|
126
421
|
# and the same bytes are installed as .husky/pre-commit and .husky/pre-push \u2014 the hook
|
|
127
|
-
# name comes from $0.
|
|
422
|
+
# name comes from $0, and git's arguments and stdin pass straight through.
|
|
128
423
|
#
|
|
129
|
-
# \u26A0\uFE0F A hook is a local convenience, not a gate: it is skippable with --no-verify and
|
|
130
|
-
#
|
|
131
|
-
#
|
|
424
|
+
# \u26A0\uFE0F A hook is a local convenience, not a gate: it is skippable with --no-verify, and a
|
|
425
|
+
# worktree runs it only once \`bun install\` has run there. The required checks on main
|
|
426
|
+
# stay the gate.
|
|
132
427
|
#
|
|
133
428
|
# Regenerate this file with: bun ${RUNNER} install
|
|
134
429
|
hook="${RUNNER}"
|
|
135
430
|
if [ ! -f "$hook" ]; then
|
|
136
|
-
echo "
|
|
431
|
+
echo "homeflare hooks: $hook is missing \u2014 run 'bun install' in this worktree; skipping" >&2
|
|
137
432
|
exit 0
|
|
138
433
|
fi
|
|
139
|
-
exec bun "$hook" "$(basename "$0")"
|
|
434
|
+
exec bun "$hook" "$(basename "$0")" "$@"
|
|
140
435
|
`;
|
|
141
436
|
async function installHooks(projectDir) {
|
|
142
437
|
await mkdir(`${projectDir}/.husky`, { recursive: true });
|
|
@@ -155,14 +450,16 @@ async function problemsInHooks(projectDir) {
|
|
|
155
450
|
if (!await manifest.exists())
|
|
156
451
|
return ["package.json: missing"];
|
|
157
452
|
const pkg = await manifest.json();
|
|
158
|
-
|
|
159
|
-
|
|
453
|
+
const prepare = pkg.scripts?.["prepare"] ?? "";
|
|
454
|
+
if (!/bin\/hooks\.ts activate/.test(prepare)) {
|
|
455
|
+
problems.push(`package.json: "prepare" does not run \`${PREPARE}\` \u2014 no clone or worktree gets hooks`);
|
|
160
456
|
}
|
|
161
|
-
if (
|
|
162
|
-
problems.push(
|
|
457
|
+
if (/\bhusky\b/.test(prepare)) {
|
|
458
|
+
problems.push('package.json: "prepare" still runs husky, which re-points core.hooksPath at .husky/_');
|
|
163
459
|
}
|
|
164
460
|
for (const name of HOOK_NAMES) {
|
|
165
|
-
const
|
|
461
|
+
const path = `${projectDir}/.husky/${name}`;
|
|
462
|
+
const file = Bun.file(path);
|
|
166
463
|
if (!await file.exists()) {
|
|
167
464
|
problems.push(`.husky/${name}: missing \u2014 run \`bun ${RUNNER} install\``);
|
|
168
465
|
continue;
|
|
@@ -170,33 +467,44 @@ async function problemsInHooks(projectDir) {
|
|
|
170
467
|
if (await file.text() !== HUSKY_HOOK) {
|
|
171
468
|
problems.push(`.husky/${name}: differs from the @homeflare/config wrapper \u2014 run \`bun ${RUNNER} install\`, or change it in the package`);
|
|
172
469
|
}
|
|
470
|
+
if (((await stat(path)).mode & 73) === 0) {
|
|
471
|
+
problems.push(`.husky/${name}: not executable, so git ignores it \u2014 chmod +x it and commit`);
|
|
472
|
+
}
|
|
173
473
|
}
|
|
174
474
|
return problems;
|
|
175
475
|
}
|
|
176
476
|
|
|
177
477
|
// src/hooks.ts
|
|
178
478
|
function isCommand(value) {
|
|
179
|
-
return
|
|
479
|
+
return ["pre-commit", "pre-push", "install", "activate"].includes(value);
|
|
180
480
|
}
|
|
181
|
-
async function runCommand(command, root) {
|
|
481
|
+
async function runCommand(command, root, args = [], stdin = "") {
|
|
182
482
|
if (command === "install") {
|
|
183
483
|
const written = await installHooks(root);
|
|
184
484
|
ok(`wrote ${written.join(", ")} \u2014 commit them`);
|
|
185
|
-
note("
|
|
485
|
+
note(`then set "prepare": "${PREPARE}" so every install activates them`);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
if (command === "activate") {
|
|
489
|
+
const result = await activateHooks(root);
|
|
490
|
+
(result.active ? ok : note)(`homeflare hooks: ${result.message}`);
|
|
186
491
|
return;
|
|
187
492
|
}
|
|
188
493
|
if (command === "pre-commit")
|
|
189
494
|
return await preCommit(root);
|
|
190
|
-
return await prePush(root);
|
|
495
|
+
return await prePush(root, args, stdin);
|
|
191
496
|
}
|
|
192
497
|
export {
|
|
193
498
|
HOOK_NAMES,
|
|
194
499
|
HUSKY_HOOK,
|
|
500
|
+
PREPARE,
|
|
501
|
+
activateHooks,
|
|
195
502
|
installHooks,
|
|
196
503
|
isCommand,
|
|
504
|
+
planLanes,
|
|
197
505
|
problemsInHooks,
|
|
198
506
|
runCommand
|
|
199
507
|
};
|
|
200
508
|
|
|
201
|
-
//# debugId=
|
|
509
|
+
//# debugId=F70ED1E9F00E0B1964756E2164756E21
|
|
202
510
|
//# sourceMappingURL=hooks.js.map
|