@hasna/hooks 0.7.8 → 0.7.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ /**
2
+ * App-home resolution for @hasna/hooks — routes the local data root through
3
+ * the @hasna/paths resolver (XDG / macOS home layout) with gated legacy
4
+ * adoption.
5
+ *
6
+ * The XDG home migration (hotfixes plan 0f49f56a, task P3.3) moves the store
7
+ * from `~/.hasna/hooks` toward `~/.local/share/hasna/hooks` on Linux and
8
+ * `~/Library/Application Support/Hasna/hooks` on macOS. Nothing moves on disk
9
+ * in this phase — the resolver root is adopted only when the operator has
10
+ * deliberately opted in (`HASNA_DATA_HOME`) or the store has already been
11
+ * physically migrated there, so a live store at the legacy home never becomes
12
+ * invisible on upgrade.
13
+ */
14
+ /** Resolve the user's home directory: $HOME, then $USERPROFILE, then the OS user database. */
15
+ export declare function getHomeDir(env?: NodeJS.ProcessEnv): string;
16
+ /**
17
+ * The @hasna/paths-resolved (XDG / macOS layout) data root for hooks: the
18
+ * forward-looking home the XDG migration moves the store toward. The home
19
+ * override mirrors the pre-existing $HOME-first resolution so the resolver
20
+ * follows the same home the legacy path does.
21
+ */
22
+ export declare function getResolverDataRoot(env?: NodeJS.ProcessEnv): string;
23
+ /** The legacy (pre-XDG) data root: ~/.hasna/hooks */
24
+ export declare function getLegacyDataRoot(env?: NodeJS.ProcessEnv): string;
25
+ /**
26
+ * Whether the resolver (XDG) data root should be adopted as the effective data
27
+ * root. The resolver root is adopted only when the operator has set
28
+ * `HASNA_DATA_HOME` (the data-kind override — a deliberate opt-in to the XDG
29
+ * layout) or the store has already been physically migrated there (`hooks.db`
30
+ * exists). A machine that only redirects another kind (e.g. cache to tmpfs)
31
+ * must NOT have its data home moved, and a live store at the legacy home must
32
+ * never become invisible on upgrade.
33
+ */
34
+ export declare function adoptResolverDataRoot(resolved: string, env?: NodeJS.ProcessEnv): boolean;
35
+ /**
36
+ * The granular data-dir override root, when set: `HASNA_HOOKS_DATA_DIR`, then
37
+ * `HOOKS_DATA_DIR`. This preserves the pre-existing exact-data-dir override
38
+ * behavior verbatim (first-nonblank; a set-but-empty value falls through).
39
+ */
40
+ export declare function getExplicitDataDir(env?: NodeJS.ProcessEnv): string | undefined;
41
+ /**
42
+ * The exact-app override root, when set: `HASNA_HOOKS_HOME`, then `HOOKS_HOME`.
43
+ * First-nonblank selection: a set-but-whitespace override must not suppress a
44
+ * valid fallback (the same `?.trim() ||` semantics the emails lane settled as
45
+ * release-review P1).
46
+ */
47
+ export declare function getExactDataRoot(env?: NodeJS.ProcessEnv): string | undefined;
48
+ /**
49
+ * The effective data root for hooks. Precedence: the granular data-dir
50
+ * override (`HASNA_HOOKS_DATA_DIR`/`HOOKS_DATA_DIR`) wins; then the exact-app
51
+ * override (`HASNA_HOOKS_HOME`/`HOOKS_HOME`); then the resolver (XDG) data
52
+ * root once adopted; then the legacy `~/.hasna/hooks` default — a live store
53
+ * never becomes invisible on upgrade. Store/lock/config paths are layered on
54
+ * top of this by their own overrides, so an explicit path always wins
55
+ * regardless.
56
+ */
57
+ export declare function getEffectiveDataRoot(env?: NodeJS.ProcessEnv): string;
58
+ /**
59
+ * The SQLite store path surfaced by help/status surfaces (e.g. `hooks log`,
60
+ * registry hook descriptions). The explicit `HASNA_HOOKS_DB_PATH` /
61
+ * `HOOKS_DB_PATH` override wins; otherwise `hooks.db` under the effective data
62
+ * root. A status surface must never hardcode the legacy literal — the store can
63
+ * live at the resolver home once adopted.
64
+ */
65
+ export declare function getReportedDbPath(env?: NodeJS.ProcessEnv): string;
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * Agent profile management — identity system for hooks
3
3
  *
4
- * Each agent instance gets a unique 8-char UUID stored at ~/.hasna/hooks/profiles/<id>.json.
4
+ * Each agent instance gets a unique 8-char UUID stored at
5
+ * <effective-data-root>/profiles/<id>.json, where the effective data root is
6
+ * resolved through @hasna/paths (legacy ~/.hasna/hooks until adopted, then
7
+ * the XDG data home).
5
8
  * Profiles are injected into HookInput when hooks are run with --profile <id>,
6
9
  * allowing hooks to identify which agent is calling them.
7
10
  */
package/dist/serve.d.ts CHANGED
@@ -31,6 +31,13 @@ export interface ArtifactPayload {
31
31
  script: string;
32
32
  }
33
33
  export declare function handleServeRequest(req: Request, apiKey: string | undefined): Promise<Response>;
34
+ export declare function resolveServeOptions(options: {
35
+ port?: number;
36
+ host?: string;
37
+ }): {
38
+ port: number;
39
+ host: string;
40
+ };
34
41
  export declare function startServeServer(options: {
35
42
  port?: number;
36
43
  host?: string;
package/dist/storage.js CHANGED
@@ -15,6 +15,132 @@ var __export = (target, all) => {
15
15
  };
16
16
  var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
17
17
 
18
+ // ../../node_modules/.bun/@hasna+paths@0.1.0/node_modules/@hasna/paths/dist/index.js
19
+ import { homedir } from "os";
20
+ import { join } from "path";
21
+ function assertApp(app) {
22
+ if (typeof app !== "string" || app.length === 0) {
23
+ throw new TypeError("paths: app must be a non-empty string");
24
+ }
25
+ if (!APP_SLUG_RE.test(app)) {
26
+ throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
27
+ }
28
+ }
29
+ function envOf(options) {
30
+ return options.env ?? process.env;
31
+ }
32
+ function envValue(options, kind) {
33
+ const value = envOf(options)[KIND_ENV[kind]];
34
+ return typeof value === "string" && value.length > 0 ? value : undefined;
35
+ }
36
+ function isMacOS(platform) {
37
+ return platform === "darwin";
38
+ }
39
+ function baseDir(kind, options) {
40
+ const override = envValue(options, kind);
41
+ if (override)
42
+ return override;
43
+ const home = options.home ?? homedir();
44
+ const platform = options.platform ?? process.platform;
45
+ if (isMacOS(platform)) {
46
+ switch (kind) {
47
+ case "config":
48
+ case "data":
49
+ return join(home, "Library", "Application Support", "Hasna");
50
+ case "cache":
51
+ return join(home, "Library", "Caches", "Hasna");
52
+ case "state":
53
+ return join(home, "Library", "Logs", "Hasna");
54
+ }
55
+ }
56
+ switch (kind) {
57
+ case "config":
58
+ return join(home, ".config", "hasna");
59
+ case "data":
60
+ return join(home, ".local", "share", "hasna");
61
+ case "state":
62
+ return join(home, ".local", "state", "hasna");
63
+ case "cache":
64
+ return join(home, ".cache", "hasna");
65
+ }
66
+ }
67
+ function resolvePath(kind, options) {
68
+ assertApp(options.app);
69
+ const appSegment = options.internal === true ? join("internal", options.app) : options.app;
70
+ return join(baseDir(kind, options), appSegment);
71
+ }
72
+ function dataDir(options) {
73
+ return resolvePath("data", options);
74
+ }
75
+ var KIND_ENV, APP_SLUG_RE;
76
+ var init_dist = __esm(() => {
77
+ KIND_ENV = {
78
+ config: "HASNA_CONFIG_HOME",
79
+ data: "HASNA_DATA_HOME",
80
+ state: "HASNA_STATE_HOME",
81
+ cache: "HASNA_CACHE_HOME"
82
+ };
83
+ APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
84
+ });
85
+
86
+ // src/lib/app-home.ts
87
+ import { existsSync } from "fs";
88
+ import { homedir as homedir2 } from "os";
89
+ import { join as join2, resolve } from "path";
90
+ function getHomeDir(env = process.env) {
91
+ return env.HOME || env.USERPROFILE || homedir2();
92
+ }
93
+ function getResolverDataRoot(env = process.env) {
94
+ return dataDir({ app: "hooks", home: getHomeDir(env), env });
95
+ }
96
+ function getLegacyDataRoot(env = process.env) {
97
+ return join2(getHomeDir(env), ".hasna", "hooks");
98
+ }
99
+ function adoptResolverDataRoot(resolved, env = process.env) {
100
+ const dataOverride = env.HASNA_DATA_HOME;
101
+ if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
102
+ return true;
103
+ return existsSync(join2(resolved, STORE_MARKER));
104
+ }
105
+ function getExplicitDataDir(env = process.env) {
106
+ const dir = env.HASNA_HOOKS_DATA_DIR ?? env.HOOKS_DATA_DIR;
107
+ if (typeof dir === "string" && dir.trim().length > 0)
108
+ return dir.trim();
109
+ return;
110
+ }
111
+ function getExactDataRoot(env = process.env) {
112
+ const dir = env.HASNA_HOOKS_HOME?.trim() || env.HOOKS_HOME?.trim();
113
+ if (dir)
114
+ return resolve(dir);
115
+ return;
116
+ }
117
+ function getEffectiveDataRoot(env = process.env) {
118
+ const explicit = getExplicitDataDir(env);
119
+ if (explicit)
120
+ return explicit;
121
+ const exact = getExactDataRoot(env);
122
+ if (exact)
123
+ return exact;
124
+ const resolved = getResolverDataRoot(env);
125
+ return adoptResolverDataRoot(resolved, env) ? resolve(resolved) : getLegacyDataRoot(env);
126
+ }
127
+ function getReportedDbPath(env = process.env) {
128
+ const explicit = getExplicitDbPath(env);
129
+ if (explicit)
130
+ return explicit;
131
+ return join2(getEffectiveDataRoot(env), "hooks.db");
132
+ }
133
+ function getExplicitDbPath(env = process.env) {
134
+ const db = env.HASNA_HOOKS_DB_PATH ?? env.HOOKS_DB_PATH;
135
+ if (typeof db === "string" && db.trim().length > 0)
136
+ return db.trim();
137
+ return;
138
+ }
139
+ var STORE_MARKER = "hooks.db";
140
+ var init_app_home = __esm(() => {
141
+ init_dist();
142
+ });
143
+
18
144
  // src/db/schema.ts
19
145
  var CREATE_HOOK_EVENTS_TABLE = `
20
146
  CREATE TABLE IF NOT EXISTS hook_events (
@@ -209,9 +335,9 @@ var init_migrations = __esm(() => {
209
335
  });
210
336
 
211
337
  // src/db/legacy-import.ts
212
- import { existsSync, readdirSync, readFileSync } from "fs";
213
- import { join } from "path";
214
- import { homedir } from "os";
338
+ import { existsSync as existsSync2, readdirSync, readFileSync } from "fs";
339
+ import { join as join3 } from "path";
340
+ import { homedir as homedir3 } from "os";
215
341
  function ensureMetaTable(db) {
216
342
  db.exec(`
217
343
  CREATE TABLE IF NOT EXISTS _meta (
@@ -285,25 +411,25 @@ function importErrorsLog(db, filePath) {
285
411
  } catch {}
286
412
  return count;
287
413
  }
288
- function runLegacyImport(db, homeDir = homedir()) {
414
+ function runLegacyImport(db, homeDir = homedir3()) {
289
415
  try {
290
416
  if (isAlreadyDone(db))
291
417
  return;
292
418
  let total = 0;
293
- const claudeProjectsDir = join(homeDir, ".claude", "projects");
294
- if (existsSync(claudeProjectsDir)) {
419
+ const claudeProjectsDir = join3(homeDir, ".claude", "projects");
420
+ if (existsSync2(claudeProjectsDir)) {
295
421
  try {
296
422
  const projectDirs = readdirSync(claudeProjectsDir);
297
423
  for (const dir of projectDirs) {
298
- const projectDir = join(claudeProjectsDir, dir);
424
+ const projectDir = join3(claudeProjectsDir, dir);
299
425
  try {
300
426
  const files = readdirSync(projectDir);
301
427
  for (const file of files) {
302
428
  if (file.match(/^session-log-\d{4}-\d{2}-\d{2}\.jsonl$/)) {
303
- total += importJsonlFile(db, join(projectDir, file));
429
+ total += importJsonlFile(db, join3(projectDir, file));
304
430
  }
305
431
  if (file === "errors.log") {
306
- total += importErrorsLog(db, join(projectDir, file));
432
+ total += importErrorsLog(db, join3(projectDir, file));
307
433
  }
308
434
  }
309
435
  } catch {}
@@ -339,31 +465,27 @@ function runRetention(db, days) {
339
465
 
340
466
  // src/db/index.ts
341
467
  import { Database } from "bun:sqlite";
342
- import { existsSync as existsSync2, mkdirSync, cpSync } from "fs";
343
- import { join as join2 } from "path";
344
- import { homedir as homedir2 } from "os";
468
+ import { existsSync as existsSync3, mkdirSync, cpSync } from "fs";
469
+ import { join as join4 } from "path";
345
470
  function resolveDataDir() {
346
- const explicit = process.env.HASNA_HOOKS_DATA_DIR ?? process.env.HOOKS_DATA_DIR;
347
- if (explicit)
348
- return explicit;
349
- const newDir = join2(homedir2(), ".hasna", "hooks");
350
- const oldDir = join2(homedir2(), ".hooks");
351
- if (!existsSync2(newDir) && existsSync2(oldDir)) {
352
- mkdirSync(join2(homedir2(), ".hasna"), { recursive: true });
353
- cpSync(oldDir, newDir, { recursive: true });
471
+ const effective = getEffectiveDataRoot();
472
+ const oldDir = join4(getHomeDir(), ".hooks");
473
+ if (!existsSync3(effective) && existsSync3(oldDir)) {
474
+ mkdirSync(effective, { recursive: true });
475
+ cpSync(oldDir, effective, { recursive: true });
354
476
  }
355
- return newDir;
477
+ return effective;
356
478
  }
357
479
  function getDbPath() {
358
480
  const explicitDb = process.env.HASNA_HOOKS_DB_PATH ?? process.env.HOOKS_DB_PATH;
359
481
  if (explicitDb)
360
482
  return explicitDb;
361
- const dataDir = resolveDataDir();
362
- return join2(dataDir, "hooks.db");
483
+ const dataDir2 = resolveDataDir();
484
+ return join4(dataDir2, "hooks.db");
363
485
  }
364
486
  function ensureDir(dbPath) {
365
487
  const dir = dbPath.substring(0, dbPath.lastIndexOf("/"));
366
- if (dir && !existsSync2(dir)) {
488
+ if (dir && !existsSync3(dir)) {
367
489
  mkdirSync(dir, { recursive: true });
368
490
  }
369
491
  }
@@ -371,7 +493,7 @@ function getDb() {
371
493
  if (instance)
372
494
  return instance;
373
495
  const dbPath = getDbPath();
374
- const isNew = dbPath === ":memory:" || !existsSync2(dbPath);
496
+ const isNew = dbPath === ":memory:" || !existsSync3(dbPath);
375
497
  ensureDir(dbPath);
376
498
  instance = new Database(dbPath);
377
499
  instance.exec("PRAGMA busy_timeout=5000");
@@ -395,6 +517,7 @@ function getDb() {
395
517
  }
396
518
  var instance = null;
397
519
  var init_db = __esm(() => {
520
+ init_app_home();
398
521
  init_migrations();
399
522
  init_legacy_import();
400
523
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hasna/hooks",
3
- "version": "0.7.8",
3
+ "version": "0.7.10",
4
4
  "description": "Open source hooks library for AI coding agents - Install safety, quality, and automation hooks with a single command",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,7 +30,7 @@
30
30
  "dashboard:dev": "cd dashboard && bun run dev",
31
31
  "dashboard:build": "cd dashboard && bun run build",
32
32
  "dashboard:preview": "cd dashboard && bun run preview",
33
- "postinstall": "mkdir -p $HOME/.hasna/hooks/profiles 2>/dev/null || true",
33
+ "postinstall": "node scripts/ensure-profiles-dir.mjs",
34
34
  "scan:artifact": "bun scripts/artifact-scan.ts",
35
35
  "prepack": "bun run build && bun run scan:artifact"
36
36
  },
@@ -52,10 +52,11 @@
52
52
  "@types/react": "^18.2.0",
53
53
  "typescript": "^5",
54
54
  "bun-types": "1.3.14",
55
- "@hasna/contracts": "0.14.0"
55
+ "@hasna/contracts": "0.14.1"
56
56
  },
57
57
  "dependencies": {
58
58
  "@hasna/events": "^0.1.16",
59
+ "@hasna/paths": "0.1.0",
59
60
  "@hasna/secrets": "0.3.10",
60
61
  "@modelcontextprotocol/sdk": "^1.26.0",
61
62
  "chalk": "^5.3.0",
@@ -68,7 +69,7 @@
68
69
  "pg": "^8.16.3",
69
70
  "react": "^18.2.0",
70
71
  "zod": "3.25.76",
71
- "@hasna/contracts": "0.14.0"
72
+ "@hasna/contracts": "0.14.1"
72
73
  },
73
74
  "engines": {
74
75
  "bun": ">=1.0.0"
@@ -82,6 +83,7 @@
82
83
  "dist/",
83
84
  "hooks/",
84
85
  "!hooks/*/dist/",
86
+ "scripts/",
85
87
  "README.md",
86
88
  "LICENSE"
87
89
  ],
@@ -0,0 +1,49 @@
1
+ // Pack this repo the way `npm publish` would, then scan what actually shipped.
2
+ //
3
+ // `contracts artifact-scan` takes a PACKED .tgz — running it against `src/` is
4
+ // explicitly wrong, and running it with no target at all just exits 1 and takes
5
+ // `npm pack` / `bun publish` down with it. So this script produces the tarball
6
+ // first and hands the real artifact to the scanner.
7
+ //
8
+ // Packing uses `--ignore-scripts`: without it, packing from inside `prepack`
9
+ // would re-enter `prepack` forever.
10
+ //
11
+ // The scanner comes from the pinned `@hasna/contracts` devDependency
12
+ // (node_modules/.bin), never `bunx` — an unpinned package runner makes the
13
+ // release gate depend on whatever the registry serves that day.
14
+
15
+ import { mkdtempSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { isAbsolute, join } from "node:path";
18
+
19
+ function run(command: string[], cwd: string): string {
20
+ const result = Bun.spawnSync(command, { cwd, stdout: "pipe", stderr: "pipe" });
21
+ const stdout = new TextDecoder().decode(result.stdout).trim();
22
+ if (result.exitCode !== 0) {
23
+ const stderr = new TextDecoder().decode(result.stderr).trim();
24
+ throw new Error(`${command.join(" ")} exited ${result.exitCode}\n${stdout}\n${stderr}`);
25
+ }
26
+ return stdout;
27
+ }
28
+
29
+ const repoRoot = join(import.meta.dir, "..");
30
+ const scanner = join(repoRoot, "node_modules", "@hasna", "contracts", "dist", "cli", "index.js");
31
+ const workspace = mkdtempSync(join(tmpdir(), "hooks-artifact-scan-"));
32
+
33
+ try {
34
+ const packed = run(["bun", "pm", "pack", "--destination", workspace, "--ignore-scripts", "--quiet"], repoRoot);
35
+ const archive = isAbsolute(packed) ? packed : join(workspace, packed);
36
+ // The manifest is where a reviewed waiver would be declared, so the scanner
37
+ // has to read it; a waiver the enforcement never loads is a dead end.
38
+ const scan = Bun.spawnSync([scanner, "artifact-scan", archive, "--manifest", join(repoRoot, "hasna.contract.json")], {
39
+ cwd: repoRoot,
40
+ stdout: "inherit",
41
+ stderr: "inherit",
42
+ });
43
+ if (scan.exitCode !== 0) {
44
+ console.error("\nA published artifact must not carry a bulk asset inventory.");
45
+ process.exit(scan.exitCode ?? 1);
46
+ }
47
+ } finally {
48
+ rmSync(workspace, { recursive: true, force: true });
49
+ }
@@ -0,0 +1,48 @@
1
+ // Ensure the hooks agent-profile directory exists at the effective data root.
2
+ // Runs at install time (postinstall). Mirrors src/lib/app-home.ts resolution:
3
+ // granular data-dir override (HASNA_HOOKS_DATA_DIR/HOOKS_DATA_DIR), then
4
+ // exact-app override (HASNA_HOOKS_HOME/HOOKS_HOME), then the @hasna/paths
5
+ // resolver data root once adopted (HASNA_DATA_HOME set or hooks.db already
6
+ // present there), then the legacy ~/.hasna/hooks default. Best-effort — the
7
+ // package must never fail to install because this script cannot run.
8
+ import { existsSync, mkdirSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { join, resolve } from "node:path";
11
+
12
+ const HOME = process.env.HOME || process.env.USERPROFILE || homedir();
13
+
14
+ function legacyDataRoot() {
15
+ return join(HOME, ".hasna", "hooks");
16
+ }
17
+
18
+ function adoptResolverRoot(resolved) {
19
+ const override = process.env.HASNA_DATA_HOME;
20
+ if (typeof override === "string" && override.trim().length > 0) return true;
21
+ return existsSync(join(resolved, "hooks.db"));
22
+ }
23
+
24
+ async function effectiveDataRoot() {
25
+ const explicit = process.env.HASNA_HOOKS_DATA_DIR ?? process.env.HOOKS_DATA_DIR;
26
+ if (typeof explicit === "string" && explicit.trim().length > 0) return explicit.trim();
27
+
28
+ const exact = process.env.HASNA_HOOKS_HOME?.trim() || process.env.HOOKS_HOME?.trim();
29
+ if (exact) return resolve(exact);
30
+
31
+ let resolved = null;
32
+ try {
33
+ const { dataDir } = await import("@hasna/paths");
34
+ resolved = dataDir({ app: "hooks", home: HOME });
35
+ } catch {
36
+ // @hasna/paths unavailable — fall back to the legacy root.
37
+ }
38
+ if (resolved && adoptResolverRoot(resolved)) return resolve(resolved);
39
+ return legacyDataRoot();
40
+ }
41
+
42
+ // Best-effort: a profile-directory creation failure must never block install.
43
+ try {
44
+ const root = await effectiveDataRoot();
45
+ mkdirSync(join(root, "profiles"), { recursive: true });
46
+ } catch {
47
+ // ignore
48
+ }