@hasna/hooks 0.7.9 → 0.8.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.
Files changed (53) hide show
  1. package/README.md +32 -6
  2. package/bin/index.js +1573 -695
  3. package/bin/serve.js +713 -335
  4. package/dist/config.d.ts +25 -19
  5. package/dist/db/index.d.ts +3 -1
  6. package/dist/index.d.ts +3 -2
  7. package/dist/index.js +1162 -473
  8. package/dist/lib/app-home.d.ts +74 -0
  9. package/dist/lib/installer.d.ts +0 -9
  10. package/dist/lib/local-opt-in.d.ts +80 -0
  11. package/dist/lib/profiles.d.ts +14 -1
  12. package/dist/lib/resolver-types.d.ts +45 -0
  13. package/dist/lib/sync.d.ts +13 -8
  14. package/dist/lib/transport.d.ts +87 -0
  15. package/dist/openapi.d.ts +1 -1
  16. package/dist/serve.d.ts +7 -0
  17. package/dist/storage.js +141 -27
  18. package/hooks/hook-affected-tests/package.json +1 -1
  19. package/hooks/hook-agent-rules-version-check/package.json +1 -1
  20. package/hooks/hook-agentmessages/package.json +1 -1
  21. package/hooks/hook-announce-start/package.json +1 -1
  22. package/hooks/hook-announce-stop/package.json +1 -1
  23. package/hooks/hook-autoformat/package.json +1 -1
  24. package/hooks/hook-branchprotect/package.json +1 -1
  25. package/hooks/hook-checkbugs/package.json +1 -1
  26. package/hooks/hook-checkdocs/package.json +1 -1
  27. package/hooks/hook-checkfiles/package.json +1 -1
  28. package/hooks/hook-checklint/package.json +1 -1
  29. package/hooks/hook-checkpoint/package.json +1 -1
  30. package/hooks/hook-checksecurity/package.json +1 -1
  31. package/hooks/hook-checktasks/package.json +1 -1
  32. package/hooks/hook-checktests/package.json +1 -1
  33. package/hooks/hook-conflict-detect/package.json +1 -1
  34. package/hooks/hook-contextrefresh/package.json +1 -1
  35. package/hooks/hook-desktopnotify/package.json +1 -1
  36. package/hooks/hook-dm-inject/package.json +1 -1
  37. package/hooks/hook-envsetup/package.json +1 -1
  38. package/hooks/hook-failure-to-task/package.json +1 -1
  39. package/hooks/hook-filelock/package.json +1 -1
  40. package/hooks/hook-fleet-blockers-gate/package.json +1 -1
  41. package/hooks/hook-fleet-catchup/package.json +1 -1
  42. package/hooks/hook-gitguard/package.json +1 -1
  43. package/hooks/hook-packageage/package.json +1 -1
  44. package/hooks/hook-permissionguard/package.json +1 -1
  45. package/hooks/hook-phonenotify/package.json +1 -1
  46. package/hooks/hook-precompact/package.json +1 -1
  47. package/hooks/hook-protectfiles/package.json +1 -1
  48. package/hooks/hook-stylescheck/package.json +1 -1
  49. package/hooks/hook-typecheck-gate/package.json +1 -1
  50. package/package.json +6 -9
  51. package/scripts/artifact-scan.ts +49 -0
  52. package/scripts/ensure-profiles-dir.mjs +99 -0
  53. package/scripts/validate-package.ts +280 -0
@@ -0,0 +1,280 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { dirname, join } from "node:path";
5
+
6
+ type PackageJson = {
7
+ name: string;
8
+ types?: string;
9
+ exports?: Record<string, unknown>;
10
+ };
11
+
12
+ type PackEntry = {
13
+ filename: string;
14
+ files: Array<{ path: string }>;
15
+ };
16
+
17
+ const root = process.cwd();
18
+
19
+ function normalizePackagePath(path: string): string {
20
+ return path.replace(/^\.\//, "");
21
+ }
22
+
23
+ function run(command: string, args: string[], options: { cwd?: string } = {}): string {
24
+ const result = spawnSync(command, args, {
25
+ cwd: options.cwd ?? root,
26
+ encoding: "utf8",
27
+ stdio: ["ignore", "pipe", "pipe"],
28
+ });
29
+
30
+ if (result.status !== 0) {
31
+ const rendered = [result.stdout, result.stderr].filter(Boolean).join("\n");
32
+ throw new Error(`Command failed: ${command} ${args.join(" ")}\n${rendered}`);
33
+ }
34
+
35
+ return result.stdout;
36
+ }
37
+
38
+ async function readPackageJson(path: string): Promise<PackageJson> {
39
+ return JSON.parse(await readFile(path, "utf8")) as PackageJson;
40
+ }
41
+
42
+ function collectExportTypes(value: unknown, paths: Set<string>): void {
43
+ if (!value || typeof value !== "object" || Array.isArray(value)) return;
44
+ const record = value as Record<string, unknown>;
45
+ if (typeof record.types === "string") paths.add(normalizePackagePath(record.types));
46
+ for (const nested of Object.values(record)) collectExportTypes(nested, paths);
47
+ }
48
+
49
+ function collectRequiredTypePaths(pkg: PackageJson): string[] {
50
+ const paths = new Set<string>();
51
+ if (pkg.types) paths.add(normalizePackagePath(pkg.types));
52
+ if (pkg.exports) collectExportTypes(pkg.exports, paths);
53
+ return [...paths].sort();
54
+ }
55
+
56
+ function assertPackedTypes(pkg: PackageJson, pack: PackEntry): void {
57
+ const packedPaths = new Set(pack.files.map((file) => file.path));
58
+ const missing = collectRequiredTypePaths(pkg).filter((path) => !packedPaths.has(path));
59
+
60
+ if (missing.length > 0) {
61
+ throw new Error(`Packed package is missing declaration files referenced by package.json: ${missing.join(", ")}`);
62
+ }
63
+ }
64
+
65
+ function runTsc(args: string[], cwd: string): void {
66
+ const tsc = join(root, "node_modules", ".bin", process.platform === "win32" ? "tsc.cmd" : "tsc");
67
+ run(tsc, args, { cwd });
68
+ }
69
+
70
+ async function validateConsumerImports(pkg: PackageJson): Promise<void> {
71
+ const tempRoot = join(root, "temp");
72
+ await mkdir(tempRoot, { recursive: true });
73
+ const workspace = await mkdtemp(join(tempRoot, "hasna-hooks-pack-"));
74
+ try {
75
+ const tarballJson = run("npm", ["pack", "--json", "--ignore-scripts", "--dry-run=false", "--pack-destination", workspace]);
76
+ const [tarball] = JSON.parse(tarballJson) as PackEntry[];
77
+ if (!tarball?.filename) throw new Error("npm pack did not return a tarball filename");
78
+
79
+ const packageDir = join(workspace, "consumer", "node_modules", "@hasna", "hooks");
80
+ await mkdir(packageDir, { recursive: true });
81
+ run("tar", ["-xzf", join(workspace, tarball.filename), "-C", packageDir, "--strip-components=1"]);
82
+
83
+ const consumerDir = join(workspace, "consumer");
84
+ await writeFile(join(consumerDir, "package.json"), `${JSON.stringify({ type: "module" }, null, 2)}\n`);
85
+ const consumerFile = join(consumerDir, "consumer.ts");
86
+ await writeFile(
87
+ consumerFile,
88
+ [
89
+ `import { HOOKS, type HookInput } from "${pkg.name}";`,
90
+ `import { getStorageStatus, type StorageStatus } from "${pkg.name}/storage";`,
91
+ "",
92
+ "const input: HookInput = { cwd: process.cwd() };",
93
+ "const hookCount: number = HOOKS.length;",
94
+ "const status: StorageStatus = getStorageStatus();",
95
+ "void input;",
96
+ "void hookCount;",
97
+ "void status;",
98
+ "",
99
+ ].join("\n"),
100
+ );
101
+
102
+ runTsc([
103
+ "--noEmit",
104
+ "--target",
105
+ "ES2022",
106
+ "--module",
107
+ "ESNext",
108
+ "--moduleResolution",
109
+ "Bundler",
110
+ "--skipLibCheck",
111
+ "--strict",
112
+ "--types",
113
+ "bun-types",
114
+ consumerFile,
115
+ ], dirname(consumerFile));
116
+
117
+ runTsc([
118
+ "--noEmit",
119
+ "--target",
120
+ "ES2022",
121
+ "--module",
122
+ "NodeNext",
123
+ "--moduleResolution",
124
+ "NodeNext",
125
+ "--strict",
126
+ "--types",
127
+ "bun-types",
128
+ consumerFile,
129
+ ], dirname(consumerFile));
130
+ } finally {
131
+ await rm(workspace, { force: true, recursive: true });
132
+ }
133
+ }
134
+
135
+ async function main(): Promise<void> {
136
+ const pkg = await readPackageJson(join(root, "package.json"));
137
+ const packJson = run("npm", ["pack", "--dry-run", "--json", "--ignore-scripts"]);
138
+ const [pack] = JSON.parse(packJson) as PackEntry[];
139
+ if (!pack) throw new Error("npm pack --dry-run did not return package metadata");
140
+
141
+ assertPackedTypes(pkg, pack);
142
+ await validateConsumerImports(pkg);
143
+ await validateExtractedSmoke(pkg);
144
+
145
+ console.log("Package validation passed: packed declarations are present, Bundler/NodeNext TypeScript consumer imports resolve, and the extracted tarball smoke-tests CLI help, serve /health, MCP startup, SDK import and one bundled-hook run (P3-17).");
146
+ }
147
+
148
+ /**
149
+ * P3-17: smoke-test the EXTRACTED tarball, not the source tree — a package
150
+ * that builds and type-checks in-repo can still ship broken bins (wrong
151
+ * output dir, missing files, unresolvable imports once the tarball layout
152
+ * applies).
153
+ *
154
+ * Lanes: CLI help, serve /health, MCP stdio startup (initialize handshake),
155
+ * a runtime SDK import, and one bundled-hook run with an isolated data dir.
156
+ */
157
+ async function validateExtractedSmoke(pkg: PackageJson): Promise<void> {
158
+ const tempRoot = join(root, "temp");
159
+ await mkdir(tempRoot, { recursive: true });
160
+ const workspace = await mkdtemp(join(tempRoot, "hasna-hooks-smoke-"));
161
+ try {
162
+ const tarballJson = run("npm", ["pack", "--json", "--ignore-scripts", "--pack-destination", workspace]);
163
+ const [tarball] = JSON.parse(tarballJson) as PackEntry[];
164
+ if (!tarball?.filename) throw new Error("npm pack did not return a tarball filename");
165
+
166
+ const packageDir = join(workspace, "smoke", "node_modules", "@hasna", "hooks");
167
+ await mkdir(packageDir, { recursive: true });
168
+ run("tar", ["-xzf", join(workspace, tarball.filename), "-C", packageDir, "--strip-components=1"]);
169
+
170
+ const smokeDir = join(workspace, "smoke");
171
+ await writeFile(join(smokeDir, "package.json"), `${JSON.stringify({ type: "module" }, null, 2)}\n`);
172
+
173
+ const dataDir = join(workspace, "data");
174
+ const binIndex = join(packageDir, "bin", "index.js");
175
+ const binServe = join(packageDir, "bin", "serve.js");
176
+ if (!existsSync(binIndex)) throw new Error(`packed tarball is missing bin/index.js (CLI bin)`);
177
+ if (!existsSync(binServe)) throw new Error(`packed tarball is missing bin/serve.js (serve bin)`);
178
+
179
+ const env = {
180
+ ...process.env,
181
+ HASNA_HOOKS_DATA_DIR: dataDir,
182
+ HASNA_HOOKS_DB_PATH: join(dataDir, "hooks.db"),
183
+ NO_COLOR: "1",
184
+ };
185
+
186
+ // 1. CLI help.
187
+ const help = spawnSync("bun", ["run", binIndex, "--help"], { cwd: smokeDir, env, encoding: "utf8" });
188
+ if (help.status !== 0) throw new Error(`packed CLI --help failed: ${help.stderr}`);
189
+ if (!/Usage:/.test(help.stdout)) throw new Error("packed CLI --help produced no usage output");
190
+
191
+ // 2. serve /health on an ephemeral port.
192
+ const port = 39000 + Math.floor(Math.random() * 1000);
193
+ const serveProc = spawn("bun", ["run", binServe, "--port", String(port)], {
194
+ cwd: smokeDir,
195
+ env,
196
+ stdio: ["ignore", "ignore", "pipe"],
197
+ });
198
+ try {
199
+ let health: string | null = null;
200
+ for (let attempt = 0; attempt < 40; attempt++) {
201
+ const res = spawnSync("curl", ["-s", `http://127.0.0.1:${port}/health`], { encoding: "utf8", timeout: 3000 });
202
+ if (res.status === 0 && res.stdout.includes("ok")) { health = res.stdout; break; }
203
+ await new Promise((r) => setTimeout(r, 150));
204
+ }
205
+ if (!health) throw new Error(`packed serve /health did not respond on port ${port}`);
206
+ const parsed = JSON.parse(health) as { name?: string };
207
+ if (parsed.name !== "hooks-registry") throw new Error(`packed serve /health returned unexpected payload: ${health}`);
208
+ } finally {
209
+ serveProc.kill("SIGTERM");
210
+ await new Promise((r) => setTimeout(r, 100));
211
+ }
212
+
213
+ // 3. MCP stdio startup: an initialize handshake must get a response.
214
+ const mcpProc = spawn("bun", ["run", binIndex, "mcp", "--stdio"], {
215
+ cwd: smokeDir,
216
+ env,
217
+ stdio: ["pipe", "pipe", "pipe"],
218
+ });
219
+ let mcpOk = false;
220
+ try {
221
+ mcpProc.stdin?.write(JSON.stringify({
222
+ jsonrpc: "2.0",
223
+ id: 1,
224
+ method: "initialize",
225
+ params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "pack-smoke", version: "0.0.0" } },
226
+ }) + "\n");
227
+ const readTimeout = new Promise<string>((_, reject) => setTimeout(() => reject(new Error("MCP initialize timed out")), 8000));
228
+ const out = await Promise.race([new Promise<string>((resolve) => {
229
+ let acc = "";
230
+ const onData = (chunk: Uint8Array) => {
231
+ acc += new TextDecoder().decode(chunk);
232
+ if (acc.includes('"result"')) resolve(acc);
233
+ };
234
+ mcpProc.stdout!.on("data", onData);
235
+ }), readTimeout]);
236
+ if (!out.includes('"result"')) throw new Error("MCP initialize handshake got no result");
237
+ mcpOk = true;
238
+ } finally {
239
+ mcpProc.kill("SIGTERM");
240
+ await new Promise((r) => setTimeout(r, 100));
241
+ }
242
+ if (!mcpOk) throw new Error("MCP startup smoke failed");
243
+
244
+ // 4. Runtime SDK import from the extracted package. The index re-exports
245
+ // storage, which loads the pg adapter, so pg must resolve: link it from
246
+ // the workspace root (bun resolves the symlink target's realpath).
247
+ const smokeNodeModules = join(smokeDir, "node_modules");
248
+ await mkdir(smokeNodeModules, { recursive: true });
249
+ try {
250
+ await Bun.$`ln -sf ${join(root, "node_modules", "pg")} ${join(smokeNodeModules, "pg")}`.quiet();
251
+ } catch {
252
+ // pg link is best-effort; the import below will fail loudly if needed.
253
+ }
254
+ const sdkSmoke = join(smokeDir, "sdk-smoke.ts");
255
+ await writeFile(sdkSmoke, `import { HOOKS, getStorageStatus } from "@hasna/hooks";\nimport { getStorageStatus as ss } from "@hasna/hooks/storage";\nconsole.log(JSON.stringify({ count: HOOKS.length, backend: getStorageStatus().backend, ss: ss().backend }));\n`);
256
+ const sdk = spawnSync("bun", ["run", sdkSmoke], { cwd: smokeDir, env, encoding: "utf8", timeout: 20000 });
257
+ if (sdk.status !== 0) throw new Error(`packed SDK import failed: ${sdk.stderr}`);
258
+ const sdkOut = JSON.parse(sdk.stdout.trim()) as { count: number; backend: string };
259
+ if (typeof sdkOut.count !== "number" || sdkOut.count <= 0) throw new Error(`packed SDK import returned count ${sdkOut.count}`);
260
+
261
+ // 5. One bundled-hook run from the packed artifact (isolated data dir;
262
+ // first run self-trusts, then executes).
263
+ const runRes = spawnSync("bun", ["run", binIndex, "run", "gitguard"], {
264
+ cwd: smokeDir,
265
+ env,
266
+ encoding: "utf8",
267
+ input: JSON.stringify({ hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: { command: "echo smoke" } }),
268
+ timeout: 20000,
269
+ });
270
+ if (runRes.status !== 0) throw new Error(`packed bundled-hook run failed (exit ${runRes.status}): ${runRes.stderr}`);
271
+ if (!/decision/.test(runRes.stdout)) throw new Error(`packed bundled-hook run produced no hook decision: ${runRes.stdout.slice(0, 200)}`);
272
+ } finally {
273
+ await rm(workspace, { force: true, recursive: true });
274
+ }
275
+ }
276
+
277
+ main().catch((error) => {
278
+ console.error(error instanceof Error ? error.message : error);
279
+ process.exit(1);
280
+ });