@hasna/hooks 0.7.10 → 0.9.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 (96) hide show
  1. package/README.md +32 -6
  2. package/bin/index.js +2640 -1820
  3. package/bin/serve.js +742 -551
  4. package/dist/config.d.ts +25 -19
  5. package/dist/index.d.ts +3 -2
  6. package/dist/index.js +1251 -717
  7. package/dist/lib/app-home.d.ts +9 -0
  8. package/dist/lib/installer.d.ts +40 -10
  9. package/dist/lib/local-opt-in.d.ts +80 -0
  10. package/dist/lib/profiles.d.ts +10 -0
  11. package/dist/lib/registration.d.ts +75 -0
  12. package/dist/lib/registry.d.ts +14 -0
  13. package/dist/lib/resolver-types.d.ts +45 -0
  14. package/dist/lib/sync.d.ts +13 -8
  15. package/dist/lib/transport.d.ts +87 -0
  16. package/dist/openapi.d.ts +1 -1
  17. package/dist/storage.js +54 -63
  18. package/hooks/hook-affected-tests/package.json +1 -1
  19. package/hooks/hook-agent-rules-version-check/README.md +1 -1
  20. package/hooks/hook-agent-rules-version-check/package.json +1 -1
  21. package/hooks/hook-agentmessages/package.json +1 -1
  22. package/hooks/hook-announce-start/package.json +1 -1
  23. package/hooks/hook-announce-stop/package.json +1 -1
  24. package/hooks/hook-autoformat/package.json +1 -1
  25. package/hooks/hook-branchprotect/package.json +1 -1
  26. package/hooks/hook-checkbugs/package.json +1 -1
  27. package/hooks/hook-checkdocs/package.json +1 -1
  28. package/hooks/hook-checkfiles/package.json +1 -1
  29. package/hooks/hook-checklint/package.json +1 -1
  30. package/hooks/hook-checkpoint/package.json +1 -1
  31. package/hooks/hook-checksecurity/package.json +1 -1
  32. package/hooks/hook-checktasks/package.json +1 -1
  33. package/hooks/hook-checktests/package.json +1 -1
  34. package/hooks/hook-conflict-detect/package.json +1 -1
  35. package/hooks/hook-contextrefresh/package.json +1 -1
  36. package/hooks/hook-desktopnotify/package.json +1 -1
  37. package/hooks/hook-dm-inject/package.json +1 -1
  38. package/hooks/hook-envsetup/package.json +1 -1
  39. package/hooks/hook-failure-to-task/package.json +1 -1
  40. package/hooks/hook-filelock/package.json +1 -1
  41. package/hooks/hook-fleet-blockers-gate/package.json +1 -1
  42. package/hooks/hook-fleet-catchup/package.json +1 -1
  43. package/hooks/hook-gitguard/package.json +1 -1
  44. package/hooks/hook-packageage/package.json +1 -1
  45. package/hooks/hook-permissionguard/package.json +1 -1
  46. package/hooks/hook-phonenotify/package.json +1 -1
  47. package/hooks/hook-precompact/package.json +1 -1
  48. package/hooks/hook-protectfiles/package.json +1 -1
  49. package/hooks/hook-stylescheck/package.json +1 -1
  50. package/hooks/hook-trash-guard/README.md +132 -0
  51. package/hooks/hook-trash-guard/package.json +12 -0
  52. package/hooks/hook-trash-guard/src/hook.ts +1114 -0
  53. package/hooks/hook-typecheck-gate/package.json +1 -1
  54. package/package.json +7 -10
  55. package/scripts/ensure-profiles-dir.mjs +52 -1
  56. package/hooks/codewith-native-common.test.ts +0 -1935
  57. package/hooks/hook-affected-tests/tsconfig.json +0 -25
  58. package/hooks/hook-agent-rules-version-check/src/hook.test.ts +0 -104
  59. package/hooks/hook-agent-rules-version-check/tsconfig.json +0 -25
  60. package/hooks/hook-announce-start/tsconfig.json +0 -25
  61. package/hooks/hook-announce-stop/tsconfig.json +0 -25
  62. package/hooks/hook-autoformat/tsconfig.json +0 -25
  63. package/hooks/hook-branchprotect/tsconfig.json +0 -25
  64. package/hooks/hook-checkbugs/tsconfig.json +0 -15
  65. package/hooks/hook-checkdocs/tsconfig.json +0 -15
  66. package/hooks/hook-checkfiles/tsconfig.json +0 -15
  67. package/hooks/hook-checklint/tsconfig.json +0 -15
  68. package/hooks/hook-checkpoint/tsconfig.json +0 -25
  69. package/hooks/hook-checksecurity/tsconfig.json +0 -15
  70. package/hooks/hook-checktasks/tsconfig.json +0 -20
  71. package/hooks/hook-checktests/tsconfig.json +0 -15
  72. package/hooks/hook-conflict-detect/tsconfig.json +0 -25
  73. package/hooks/hook-contextrefresh/tsconfig.json +0 -25
  74. package/hooks/hook-desktopnotify/tsconfig.json +0 -25
  75. package/hooks/hook-dm-inject/tsconfig.json +0 -25
  76. package/hooks/hook-envsetup/tsconfig.json +0 -25
  77. package/hooks/hook-failure-to-task/tsconfig.json +0 -25
  78. package/hooks/hook-filelock/tsconfig.json +0 -25
  79. package/hooks/hook-fleet-blockers-gate/src/hook.test.ts +0 -302
  80. package/hooks/hook-fleet-blockers-gate/tsconfig.json +0 -25
  81. package/hooks/hook-fleet-catchup/src/hook.test.ts +0 -156
  82. package/hooks/hook-fleet-catchup/tsconfig.json +0 -25
  83. package/hooks/hook-gitguard/tsconfig.json +0 -25
  84. package/hooks/hook-knowledge-context/src/hook.test.ts +0 -379
  85. package/hooks/hook-packageage/tsconfig.json +0 -25
  86. package/hooks/hook-permissionguard/tsconfig.json +0 -25
  87. package/hooks/hook-phonenotify/tsconfig.json +0 -25
  88. package/hooks/hook-precompact/tsconfig.json +0 -25
  89. package/hooks/hook-protectfiles/tsconfig.json +0 -25
  90. package/hooks/hook-scanoutput/src/hook.test.ts +0 -217
  91. package/hooks/hook-spiral-detector/src/hook.test.ts +0 -72
  92. package/hooks/hook-stylescheck/tsconfig.json +0 -25
  93. package/hooks/hook-typecheck-gate/tsconfig.json +0 -25
  94. package/hooks/hook-workspace-repos-guard/src/hook.test.ts +0 -466
  95. package/hooks/hook-workspace-repos-guard/tsconfig.json +0 -21
  96. package/hooks/mention-context/src/hook.test.ts +0 -68
package/bin/serve.js CHANGED
@@ -17,90 +17,83 @@ var __export = (target, all) => {
17
17
 
18
18
  // src/lib/app-home.ts
19
19
  import { existsSync } from "fs";
20
- import { homedir as homedir2 } from "os";
21
- import { join as join2, resolve } from "path";
22
-
23
- // ../../node_modules/.bun/@hasna+paths@0.1.0/node_modules/@hasna/paths/dist/index.js
24
20
  import { homedir } from "os";
25
- import { join } from "path";
26
- var KIND_ENV = {
21
+ import { join, resolve } from "path";
22
+ import { homedir as pathsResolverHomedir } from "os";
23
+ import { join as pathsResolverJoin } from "path";
24
+ var PATHS_RESOLVER_KIND_ENV = {
27
25
  config: "HASNA_CONFIG_HOME",
28
26
  data: "HASNA_DATA_HOME",
29
27
  state: "HASNA_STATE_HOME",
30
28
  cache: "HASNA_CACHE_HOME"
31
29
  };
32
- var APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
33
- function assertApp(app) {
30
+ var PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
31
+ function pathsResolverAssertApp(app) {
34
32
  if (typeof app !== "string" || app.length === 0) {
35
33
  throw new TypeError("paths: app must be a non-empty string");
36
34
  }
37
- if (!APP_SLUG_RE.test(app)) {
35
+ if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
38
36
  throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
39
37
  }
40
38
  }
41
- function envOf(options) {
42
- return options.env ?? process.env;
43
- }
44
- function envValue(options, kind) {
45
- const value = envOf(options)[KIND_ENV[kind]];
46
- return typeof value === "string" && value.length > 0 ? value : undefined;
47
- }
48
- function isMacOS(platform) {
49
- return platform === "darwin";
39
+ function pathsResolverAssertKind(kind) {
40
+ if (!Object.keys(PATHS_RESOLVER_KIND_ENV).includes(kind)) {
41
+ throw new TypeError(`paths: invalid path kind "${kind}" \u2014 expected one of ${Object.keys(PATHS_RESOLVER_KIND_ENV).join(", ")}`);
42
+ }
50
43
  }
51
- function baseDir(kind, options) {
52
- const override = envValue(options, kind);
53
- if (override)
44
+ function pathsResolverBaseDir(kind, options) {
45
+ pathsResolverAssertKind(kind);
46
+ const env = options.env ?? process.env;
47
+ const override = env[PATHS_RESOLVER_KIND_ENV[kind]];
48
+ if (typeof override === "string" && override.length > 0)
54
49
  return override;
55
- const home = options.home ?? homedir();
50
+ const home = options.home ?? pathsResolverHomedir();
56
51
  const platform = options.platform ?? process.platform;
57
- if (isMacOS(platform)) {
52
+ if (platform === "darwin") {
58
53
  switch (kind) {
59
54
  case "config":
60
55
  case "data":
61
- return join(home, "Library", "Application Support", "Hasna");
56
+ return pathsResolverJoin(home, "Library", "Application Support", "Hasna");
62
57
  case "cache":
63
- return join(home, "Library", "Caches", "Hasna");
58
+ return pathsResolverJoin(home, "Library", "Caches", "Hasna");
64
59
  case "state":
65
- return join(home, "Library", "Logs", "Hasna");
60
+ return pathsResolverJoin(home, "Library", "Logs", "Hasna");
66
61
  }
67
62
  }
68
63
  switch (kind) {
69
64
  case "config":
70
- return join(home, ".config", "hasna");
65
+ return pathsResolverJoin(home, ".config", "hasna");
71
66
  case "data":
72
- return join(home, ".local", "share", "hasna");
67
+ return pathsResolverJoin(home, ".local", "share", "hasna");
73
68
  case "state":
74
- return join(home, ".local", "state", "hasna");
69
+ return pathsResolverJoin(home, ".local", "state", "hasna");
75
70
  case "cache":
76
- return join(home, ".cache", "hasna");
71
+ return pathsResolverJoin(home, ".cache", "hasna");
77
72
  }
78
73
  }
79
- function resolvePath(kind, options) {
80
- assertApp(options.app);
81
- const appSegment = options.internal === true ? join("internal", options.app) : options.app;
82
- return join(baseDir(kind, options), appSegment);
74
+ function pathsResolverResolve(kind, options) {
75
+ pathsResolverAssertApp(options.app);
76
+ const appSegment = options.internal === true ? pathsResolverJoin("internal", options.app) : options.app;
77
+ return pathsResolverJoin(pathsResolverBaseDir(kind, options), appSegment);
83
78
  }
84
79
  function dataDir(options) {
85
- return resolvePath("data", options);
80
+ return pathsResolverResolve("data", options);
86
81
  }
87
-
88
- // src/lib/app-home.ts
89
82
  var STORE_MARKER = "hooks.db";
90
83
  function getHomeDir(env = process.env) {
91
- return env.HOME || env.USERPROFILE || homedir2();
84
+ return env.HOME || env.USERPROFILE || homedir();
92
85
  }
93
86
  function getResolverDataRoot(env = process.env) {
94
87
  return dataDir({ app: "hooks", home: getHomeDir(env), env });
95
88
  }
96
89
  function getLegacyDataRoot(env = process.env) {
97
- return join2(getHomeDir(env), ".hasna", "hooks");
90
+ return join(getHomeDir(env), ".hasna", "hooks");
98
91
  }
99
92
  function adoptResolverDataRoot(resolved, env = process.env) {
100
93
  const dataOverride = env.HASNA_DATA_HOME;
101
94
  if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
102
95
  return true;
103
- return existsSync(join2(resolved, STORE_MARKER));
96
+ return existsSync(join(resolved, STORE_MARKER));
104
97
  }
105
98
  function getExplicitDataDir(env = process.env) {
106
99
  const dir = env.HASNA_HOOKS_DATA_DIR ?? env.HOOKS_DATA_DIR;
@@ -128,7 +121,7 @@ function getReportedDbPath(env = process.env) {
128
121
  const explicit = getExplicitDbPath(env);
129
122
  if (explicit)
130
123
  return explicit;
131
- return join2(getEffectiveDataRoot(env), "hooks.db");
124
+ return join(getEffectiveDataRoot(env), "hooks.db");
132
125
  }
133
126
  function getExplicitDbPath(env = process.env) {
134
127
  const db = env.HASNA_HOOKS_DB_PATH ?? env.HOOKS_DB_PATH;
@@ -189,6 +182,18 @@ var HOOKS = [
189
182
  matcher: "^(Bash|Write|Edit|MultiEdit|NotebookEdit|apply_patch|ApplyPatch|functions\\.apply_patch)$",
190
183
  tags: ["workspace", "repos", "structure", "guard", "safety", "orgs", "multi-agent"]
191
184
  },
185
+ {
186
+ name: "trash-guard",
187
+ displayName: "Trash Guard",
188
+ description: "Rewrites rm issued through Bash into `trash guard`, so the delete lands in a recoverable trash store; refuses the delete when there is nothing to redirect to (trash absent, a delete verb it cannot rewrite, or the protected class: /, ~, ~/.hasna, ~/.ssh, ~/.aws)",
189
+ version: "0.1.0",
190
+ category: "Git Safety",
191
+ event: "PreToolUse",
192
+ matcher: "Bash",
193
+ tags: ["rm", "delete", "trash", "recoverable", "guard", "safety", "bash"],
194
+ rewritesInput: true,
195
+ timeoutSeconds: 5
196
+ },
192
197
  {
193
198
  name: "checktests",
194
199
  displayName: "Check Tests",
@@ -656,8 +661,8 @@ function getHook(name) {
656
661
  }
657
662
 
658
663
  // src/lib/manifest.ts
659
- import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, readdirSync, realpathSync, writeFileSync } from "fs";
660
- import { basename, dirname, isAbsolute, join as join5, normalize, resolve as resolve2, sep } from "path";
664
+ import { existsSync as existsSync2, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "fs";
665
+ import { basename, dirname, isAbsolute, join as join3, normalize, resolve as resolve2, sep } from "path";
661
666
 
662
667
  // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/external.js
663
668
  var exports_external = {};
@@ -4632,519 +4637,161 @@ var coerce = {
4632
4637
  date: (arg) => ZodDate.create({ ...arg, coerce: true })
4633
4638
  };
4634
4639
  var NEVER = INVALID;
4635
- // ../contracts/dist/client/transport.js
4636
- import { readFileSync, statSync } from "fs";
4637
- import { createRequire } from "module";
4638
- import { join as join3 } from "path";
4639
- function envToken(name) {
4640
- return name.toUpperCase().replace(/-/g, "_");
4641
- }
4642
- function clientTransportEnvKeys(name) {
4643
- const envSegment = envToken(name);
4644
- return {
4645
- apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
4646
- apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
4647
- };
4640
+ // src/config.ts
4641
+ import { join as join2 } from "path";
4642
+ function getHooksDataDir() {
4643
+ return getEffectiveDataRoot();
4648
4644
  }
4649
- function credentialOverrideEnvKey(name) {
4650
- return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
4645
+ function getCustomHooksDir() {
4646
+ return join2(getHooksDataDir(), "hooks");
4651
4647
  }
4652
- var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
4653
- function credentialPointerEnvKey(name) {
4654
- return `HASNA_${envToken(name)}_API_KEY_REF`;
4648
+ function getLockPath() {
4649
+ const explicit = process.env.HASNA_HOOKS_LOCK_PATH ?? process.env.HOOKS_LOCK_PATH;
4650
+ if (explicit)
4651
+ return explicit;
4652
+ return join2(getHooksDataDir(), "hooks.lock");
4655
4653
  }
4656
4654
 
4657
- class CredentialResolutionError extends Error {
4658
- appName;
4659
- attempted;
4660
- constructor(appName, message, attempted) {
4661
- super(message);
4662
- this.name = "CredentialResolutionError";
4663
- this.appName = appName;
4664
- this.attempted = attempted;
4665
- }
4655
+ // src/lib/semver.ts
4656
+ var NUMERIC_ID = "(0|[1-9]\\d{0,15})";
4657
+ var ALNUM_ID = "\\d*[A-Za-z-][0-9A-Za-z-]*";
4658
+ var SEMVER_PATTERN = new RegExp(`^${NUMERIC_ID}\\.${NUMERIC_ID}\\.${NUMERIC_ID}` + `(?:-(?:${NUMERIC_ID}|${ALNUM_ID})(?:\\.(?:${NUMERIC_ID}|${ALNUM_ID}))*)?` + `(?:\\+[0-9A-Za-z.-]+)?$`);
4659
+
4660
+ // src/lib/manifest.ts
4661
+ var HOOK_NAME_RE = /^[\w-]+$/;
4662
+ var manifestSchema = exports_external.object({
4663
+ name: exports_external.string().regex(HOOK_NAME_RE, "hook name must be /^[\\w-]+$/"),
4664
+ version: exports_external.string().regex(SEMVER_PATTERN, "version must be full semver (e.g. 1.2.3 or 1.2.3-beta.1)"),
4665
+ description: exports_external.string().optional(),
4666
+ events: exports_external.array(exports_external.string()).min(1, "at least one event is required"),
4667
+ script: exports_external.string().min(1, "script must name a relative path or inline content"),
4668
+ script_kind: exports_external.enum(["inline", "file"]).optional(),
4669
+ args: exports_external.array(exports_external.string()).optional(),
4670
+ timeout_ms: exports_external.number().int().positive().optional(),
4671
+ env: exports_external.record(exports_external.string(), exports_external.string()).optional()
4672
+ });
4673
+ function parseManifest(json) {
4674
+ const parsed = JSON.parse(json);
4675
+ return manifestSchema.parse(parsed);
4666
4676
  }
4667
- var HASNA_STATE_DIR = ".hasna";
4668
- var FLEET_CREDENTIAL_DIR = "fleet-env";
4669
- var LEGACY_CLOUD_DIR = "cloud";
4670
- var CONFIG_DIR = ".config";
4671
- var CONFIG_NAMESPACE = "hasna";
4672
- var LEGACY_CLOUD_REMOVAL_DEADLINE = "2026-10-01";
4673
- var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
4674
- var SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
4675
- var SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
4676
- var ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
4677
- var VAULT_POINTER_SHAPE = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
4678
- function homeDir(env) {
4679
- const home = env.HOME?.trim();
4680
- return home ? home : null;
4677
+ function shortManifestName(name) {
4678
+ return name.startsWith("hook-") ? name.slice("hook-".length) : name;
4681
4679
  }
4682
- function credentialDiskSourceList(name, env, profile = null) {
4683
- const home = homeDir(env);
4684
- if (!home || !SAFE_APP_SLUG.test(name))
4685
- return [];
4686
- const stem = profile ? `${name}.${profile}` : name;
4687
- const configStem = profile ? `${name}-${profile}` : name;
4688
- return [
4689
- {
4690
- path: join3(home, HASNA_STATE_DIR, FLEET_CREDENTIAL_DIR, `${stem}.env`),
4691
- tier: "fleet-env",
4692
- deprecated: false
4693
- },
4694
- {
4695
- path: join3(home, HASNA_STATE_DIR, LEGACY_CLOUD_DIR, `${stem}.env`),
4696
- tier: "legacy-cloud",
4697
- deprecated: true
4698
- },
4699
- {
4700
- path: join3(home, CONFIG_DIR, CONFIG_NAMESPACE, `${configStem}.env`),
4701
- tier: "config",
4702
- deprecated: false
4703
- },
4704
- {
4705
- path: join3(home, CONFIG_DIR, CONFIG_NAMESPACE, `${configStem}-cloud.env`),
4706
- tier: "config-legacy",
4707
- deprecated: true
4708
- }
4709
- ];
4680
+
4681
+ class ScriptContainmentError extends Error {
4682
+ constructor(path, hookDir, raw) {
4683
+ super(raw && raw !== path ? `manifest script escapes the hook directory: '${path}' (from '${raw}') resolves outside '${hookDir}'` : `manifest script escapes the hook directory: '${path}' resolves outside '${hookDir}'`);
4684
+ this.name = "ScriptContainmentError";
4685
+ }
4710
4686
  }
4711
- function credentialDiskSources(name, env) {
4712
- return credentialDiskSourceList(name, env, null).map((s) => s.path);
4687
+ function isWithin(child, parent) {
4688
+ return child === parent || child.startsWith(parent + sep);
4713
4689
  }
4714
- function profileDiskSources(name, env, profile) {
4715
- return credentialDiskSourceList(name, env, profile).map((s) => s.path);
4690
+ function assertContainedLexical(target, root, raw) {
4691
+ const lexical = resolve2(target);
4692
+ const rootLexical = resolve2(root);
4693
+ if (!isWithin(lexical, rootLexical)) {
4694
+ throw new ScriptContainmentError(target, root, raw);
4695
+ }
4696
+ return lexical;
4716
4697
  }
4717
- function parseEnvFile(text) {
4718
- const values = new Map;
4719
- for (const rawLine of text.split(/\r?\n/)) {
4720
- const line = rawLine.trim();
4721
- if (line.length === 0 || line.startsWith("#"))
4722
- continue;
4723
- const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
4724
- const equals = withoutExport.indexOf("=");
4725
- if (equals <= 0)
4726
- continue;
4727
- const key = withoutExport.slice(0, equals).trim();
4728
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
4729
- continue;
4730
- let value = withoutExport.slice(equals + 1).trim();
4731
- const quote = value[0];
4732
- if (quote === '"' || quote === "'") {
4733
- if (value.length < 2 || !value.endsWith(quote))
4734
- continue;
4735
- value = value.slice(1, -1);
4736
- }
4737
- if (value.length === 0)
4738
- continue;
4739
- values.set(key, value);
4698
+ function realpathNearest(path) {
4699
+ const tail = [];
4700
+ let cur = path;
4701
+ while (!existsSync2(cur)) {
4702
+ const parent = dirname(cur);
4703
+ if (parent === cur)
4704
+ break;
4705
+ tail.unshift(basename(cur));
4706
+ cur = parent;
4740
4707
  }
4741
- return values;
4708
+ return join3(realpathSync(cur), ...tail);
4742
4709
  }
4743
- function readAppConfigFile(path) {
4744
- let text;
4745
- try {
4746
- const stats = statSync(path);
4747
- if (!stats.isFile() || stats.size > MAX_CREDENTIAL_FILE_BYTES)
4748
- return null;
4749
- text = readFileSync(path, "utf8");
4750
- } catch {
4751
- return null;
4710
+ function assertContainedReal(root, target, raw) {
4711
+ const rootReal = realpathSync(root);
4712
+ const targetReal = realpathNearest(target);
4713
+ if (!isWithin(targetReal, rootReal)) {
4714
+ throw new ScriptContainmentError(target, root, raw);
4752
4715
  }
4753
- return parseEnvFile(text);
4754
4716
  }
4755
- function readCredentialFile(path, apiKeyKeys) {
4756
- const values = readAppConfigFile(path);
4757
- if (!values)
4758
- return null;
4759
- for (const key of apiKeyKeys) {
4760
- const value = values.get(key)?.trim();
4761
- if (value)
4762
- return value;
4717
+ function assertContained(target, root, raw) {
4718
+ const lexical = assertContainedLexical(target, root, raw);
4719
+ assertContainedReal(root, lexical, raw);
4720
+ return lexical;
4721
+ }
4722
+ function isInlineScript(manifest) {
4723
+ return manifest.script_kind === "inline" || manifest.script_kind === undefined && manifest.script.includes(`
4724
+ `);
4725
+ }
4726
+ function scriptRelFor(manifest) {
4727
+ if (isInlineScript(manifest)) {
4728
+ const ext = manifest.script.trim().startsWith("#!") ? ".sh" : ".ts";
4729
+ return `script${ext}`;
4763
4730
  }
4764
- return null;
4731
+ const rel = normalize(manifest.script);
4732
+ if (isAbsolute(rel)) {
4733
+ throw new Error(`manifest script must be relative or inline, got absolute path '${manifest.script}'`);
4734
+ }
4735
+ return rel;
4765
4736
  }
4766
- function assertUsableCredential(appName, source, value) {
4767
- if (VAULT_POINTER_SHAPE.test(value)) {
4768
- throw new CredentialResolutionError(appName, `The credential from ${source} looks like a secrets-vault pointer (a path-shaped reference like ` + `'namespace/app/live/api_key'). A vault path is NEVER accepted as a literal API key. ` + `Use ${credentialPointerEnvKey(appName)} to resolve the key through the vault, or provide the actual key value.`, [source]);
4737
+ function resolveScript(manifest, manifestDir) {
4738
+ if (isInlineScript(manifest))
4739
+ return { path: scriptRelFor(manifest), content: manifest.script };
4740
+ const rel = scriptRelFor(manifest);
4741
+ const resolved = assertContained(join3(manifestDir, rel), manifestDir, manifest.script);
4742
+ if (!existsSync2(resolved)) {
4743
+ throw new Error(`manifest script file not found: ${resolved}`);
4769
4744
  }
4770
- if (!ILLEGAL_IN_HEADER_VALUE.test(value))
4771
- return;
4772
- throw new CredentialResolutionError(appName, `The credential from ${source} contains characters that cannot be sent in an HTTP header ` + `(a control character or non-ASCII byte). A file written with CR-only line endings is the usual ` + `cause. Rewrite that credential file with one LF-terminated KEY=value line. ` + `The value is not shown here, and is deliberately never logged.`, [source]);
4745
+ return { path: rel, content: readFileSync(resolved, "utf-8") };
4773
4746
  }
4774
- var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
4775
- var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
4776
- function sealCredential(fields) {
4777
- const { apiKey } = fields;
4778
- const visible = {
4779
- tier: fields.tier,
4780
- source: fields.source,
4781
- deliberate: fields.deliberate,
4782
- deprecated: fields.deprecated,
4783
- diskCandidates: Object.freeze([...fields.diskCandidates]),
4784
- warning: fields.warning
4785
- };
4786
- const sealed = { ...visible };
4787
- Object.defineProperty(sealed, "apiKey", {
4788
- value: apiKey,
4789
- enumerable: false,
4790
- writable: false,
4791
- configurable: false
4792
- });
4793
- if (fields.pointerVaultKey !== undefined) {
4794
- Object.defineProperty(sealed, "pointerVaultKey", {
4795
- value: fields.pointerVaultKey,
4796
- enumerable: false,
4797
- writable: false,
4798
- configurable: false
4799
- });
4747
+ function customHookDir(name) {
4748
+ return join3(getCustomHooksDir(), shortManifestName(name));
4749
+ }
4750
+ function readCustomManifest(name) {
4751
+ const dir = customHookDir(name);
4752
+ const manifestPath = join3(dir, "manifest.json");
4753
+ if (!existsSync2(manifestPath))
4754
+ return;
4755
+ try {
4756
+ const manifest = parseManifest(readFileSync(manifestPath, "utf-8"));
4757
+ const script = resolveScript(manifest, dir);
4758
+ const scriptIsInline = isInlineScript(manifest);
4759
+ return { manifest, scriptPath: join3(dir, script.path), scriptContent: script.content, scriptIsInline };
4760
+ } catch (err) {
4761
+ if (err instanceof ScriptContainmentError)
4762
+ throw err;
4763
+ return;
4800
4764
  }
4801
- Object.defineProperty(sealed, INSPECT_CUSTOM, {
4802
- value: () => ({ ...visible, apiKey: "[redacted]" }),
4803
- enumerable: false,
4804
- writable: false,
4805
- configurable: false
4806
- });
4807
- Object.defineProperty(sealed, CREDENTIAL_SEAL, {
4808
- value: true,
4809
- enumerable: false,
4810
- writable: false,
4811
- configurable: false
4812
- });
4813
- return Object.freeze(sealed);
4814
4765
  }
4815
- function firstEnvValue(env, keys) {
4816
- for (const key of keys) {
4817
- const value = env[key]?.trim();
4818
- if (value)
4819
- return { key, value };
4766
+ function listCustomHooks() {
4767
+ const dir = getCustomHooksDir();
4768
+ if (!existsSync2(dir))
4769
+ return [];
4770
+ const results = [];
4771
+ for (const entry of readdirSafe(dir)) {
4772
+ const parsed = readCustomManifest(entry);
4773
+ if (parsed)
4774
+ results.push(parsed);
4820
4775
  }
4821
- return null;
4776
+ return results.sort((a, b) => a.manifest.name.localeCompare(b.manifest.name));
4822
4777
  }
4823
- var DEPRECATION_REGISTRY = Symbol.for("hasna:contracts:credentialDeprecationNotices");
4824
- function deprecationNotified() {
4825
- const host = globalThis;
4826
- const existing = host[DEPRECATION_REGISTRY];
4827
- if (existing instanceof Set)
4828
- return existing;
4829
- const created = new Set;
4830
- host[DEPRECATION_REGISTRY] = created;
4831
- return created;
4832
- }
4833
- function defaultDeprecationSink(message) {
4834
- if (typeof process !== "undefined" && process.stderr) {
4835
- process.stderr.write(`${message}
4836
- `);
4837
- }
4838
- }
4839
- function resolveCredential(name, env, options = {}) {
4840
- const { apiKeyKeys } = clientTransportEnvKeys(name);
4841
- const diskPaths = credentialDiskSources(name, env);
4842
- const explicitKey = options.apiKey?.trim();
4843
- if (explicitKey) {
4844
- assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
4845
- return sealCredential({
4846
- apiKey: explicitKey,
4847
- tier: "argument",
4848
- source: "explicit apiKey argument",
4849
- deliberate: true,
4850
- deprecated: false,
4851
- diskCandidates: diskPaths,
4852
- warning: null
4853
- });
4854
- }
4855
- const overrideKeyName = credentialOverrideEnvKey(name);
4856
- const overrideRaw = env[overrideKeyName];
4857
- if (overrideRaw !== undefined) {
4858
- const override = overrideRaw.trim();
4859
- if (!override) {
4860
- throw new CredentialResolutionError(name, `${overrideKeyName} is set but empty. It is a deliberate override, so it is not resolved around: ` + `either give it a real key or unset it to fall back to the credential on disk.`, [overrideKeyName]);
4861
- }
4862
- assertUsableCredential(name, overrideKeyName, override);
4863
- return sealCredential({
4864
- apiKey: override,
4865
- tier: "override",
4866
- source: overrideKeyName,
4867
- deliberate: true,
4868
- deprecated: false,
4869
- diskCandidates: diskPaths,
4870
- warning: null
4871
- });
4872
- }
4873
- const pointerKeyName = credentialPointerEnvKey(name);
4874
- const pointerRaw = env[pointerKeyName];
4875
- if (pointerRaw !== undefined) {
4876
- const pointer = pointerRaw.trim();
4877
- if (!pointer) {
4878
- throw new CredentialResolutionError(name, `${pointerKeyName} is set but empty. It is a deliberate vault pointer, so it is not resolved around: ` + `either give it a vault item key or unset it to fall back to the credential on disk.`, [pointerKeyName]);
4879
- }
4880
- if (!VAULT_POINTER_SHAPE.test(pointer)) {
4881
- throw new CredentialResolutionError(name, `${pointerKeyName} must name a vault ITEM KEY (a path-shaped reference like ` + `'namespace/app/live/api_key'), not a credential value. A pointer that carries a literal is refused.`, [pointerKeyName]);
4882
- }
4883
- return sealCredential({
4884
- apiKey: "",
4885
- pointerVaultKey: pointer,
4886
- tier: "pointer",
4887
- source: pointerKeyName,
4888
- deliberate: true,
4889
- deprecated: false,
4890
- diskCandidates: diskPaths,
4891
- warning: null
4892
- });
4893
- }
4894
- const profile = options.profile?.trim() || env[CREDENTIAL_PROFILE_ENV_KEY]?.trim();
4895
- if (profile) {
4896
- const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
4897
- if (!SAFE_PROFILE.test(profile)) {
4898
- throw new CredentialResolutionError(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
4899
- }
4900
- const paths = profileDiskSources(name, env, profile);
4901
- for (const path of paths) {
4902
- const value = readCredentialFile(path, apiKeyKeys);
4903
- if (value) {
4904
- assertUsableCredential(name, path, value);
4905
- return sealCredential({
4906
- apiKey: value,
4907
- tier: "profile",
4908
- source: path,
4909
- deliberate: true,
4910
- deprecated: false,
4911
- diskCandidates: paths,
4912
- warning: null
4913
- });
4914
- }
4915
- }
4916
- throw new CredentialResolutionError(name, `Profile '${profile}' (from ${profileSource}) has no ${apiKeyKeys[0]} for '${name}'. ` + `Looked in: ${paths.join(", ") || "<no HOME in this environment>"}. ` + `A profile names WHICH identity to use, so it is never resolved around \u2014 ` + `create the profile's credential file or unset ${CREDENTIAL_PROFILE_ENV_KEY}.`, paths);
4917
- }
4918
- const diskSourceList = credentialDiskSourceList(name, env, null);
4919
- const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile(src.path, apiKeyKeys) })).filter((hit) => hit.value !== null);
4920
- if (diskHits.length > 0) {
4921
- const winner = diskHits[0];
4922
- assertUsableCredential(name, winner.src.path, winner.value);
4923
- const divergentSources = [
4924
- ...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.src.path),
4925
- ...(() => {
4926
- const legacyHit = firstEnvValue(env, apiKeyKeys);
4927
- return legacyHit && legacyHit.value !== winner.value ? [legacyHit.key] : [];
4928
- })()
4929
- ];
4930
- const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.src.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.src.path} wins, because a file on ` + `disk is re-read on every call while an environment variable is a snapshot. Reconcile them \u2014 ` + `a rotation that updated only one leaves the other to fail 401 wherever it is loaded first.` : null;
4931
- let deprecated = winner.src.deprecated;
4932
- let finalWarning = warning;
4933
- if (winner.src.deprecated) {
4934
- deprecated = true;
4935
- const sink = options.onDeprecation ?? defaultDeprecationSink;
4936
- const notified = deprecationNotified();
4937
- const noticeKey = `${name}:${winner.src.path}`;
4938
- if (!notified.has(noticeKey)) {
4939
- notified.add(noticeKey);
4940
- const target = diskSourceList[0]?.path ?? "<none>";
4941
- const message = `[${name}] DEPRECATED: the API key came from ${winner.src.path} \u2014 a legacy credential location. ` + `The primary location is ${target} (~/.hasna/fleet-env/<app>.env). The legacy 'cloud' tiers are ` + `removed after ${LEGACY_CLOUD_REMOVAL_DEADLINE}. Migrate the key to the primary location.`;
4942
- sink(message);
4943
- }
4944
- finalWarning = [warning, `Legacy credential source: ${winner.src.path}. Removed after ${LEGACY_CLOUD_REMOVAL_DEADLINE}.`].filter(Boolean).join(" ") || null;
4945
- }
4946
- return sealCredential({
4947
- apiKey: winner.value,
4948
- tier: winner.src.tier,
4949
- source: winner.src.path,
4950
- deliberate: false,
4951
- deprecated,
4952
- diskCandidates: diskPaths,
4953
- warning: finalWarning
4954
- });
4955
- }
4956
- const legacy = firstEnvValue(env, apiKeyKeys);
4957
- if (legacy) {
4958
- assertUsableCredential(name, legacy.key, legacy.value);
4959
- const where = diskPaths.length > 0 ? `Put the current key in ${diskPaths[0]} \u2014 it is re-read on every call, so rotations take effect immediately.` : `This environment has no HOME, so no credential file could be consulted at all; the disk tier is ` + `unavailable here and this process will keep using the environment snapshot.`;
4960
- const message = `[${name}] DEPRECATED: the API key came from ${legacy.key} in this process's environment. ` + `Environment variables are a snapshot taken when this process started, so a shell that started ` + `before a key rotation keeps using the old key until it exits. ${where}`;
4961
- const sink = options.onDeprecation ?? defaultDeprecationSink;
4962
- const notified = deprecationNotified();
4963
- if (!notified.has(name)) {
4964
- notified.add(name);
4965
- sink(message);
4966
- }
4967
- return sealCredential({
4968
- apiKey: legacy.value,
4969
- tier: "legacy-env",
4970
- source: legacy.key,
4971
- deliberate: false,
4972
- deprecated: true,
4973
- diskCandidates: diskPaths,
4974
- warning: message
4975
- });
4976
- }
4977
- return null;
4978
- }
4979
- var SECRETS_PACKAGE_SPECIFIER = "@hasna/" + "secrets";
4980
- var requireSecretsSdk = createRequire(import.meta.url);
4981
- var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
4982
- var AUTHORITY_OVERRIDE_HEADERS = new Set([
4983
- "host",
4984
- ":authority",
4985
- "forwarded",
4986
- "x-forwarded-host",
4987
- "x-original-host"
4988
- ]);
4989
-
4990
- // src/config.ts
4991
- import { join as join4 } from "path";
4992
- function getHooksDataDir() {
4993
- return getEffectiveDataRoot();
4994
- }
4995
- function getCustomHooksDir() {
4996
- return join4(getHooksDataDir(), "hooks");
4997
- }
4998
- function getLockPath() {
4999
- const explicit = process.env.HASNA_HOOKS_LOCK_PATH ?? process.env.HOOKS_LOCK_PATH;
5000
- if (explicit)
5001
- return explicit;
5002
- return join4(getHooksDataDir(), "hooks.lock");
5003
- }
5004
- function resolveApiKey() {
5005
- return resolveCredential("hooks", process.env)?.apiKey;
5006
- }
5007
-
5008
- // src/lib/semver.ts
5009
- var NUMERIC_ID = "(0|[1-9]\\d{0,15})";
5010
- var ALNUM_ID = "\\d*[A-Za-z-][0-9A-Za-z-]*";
5011
- var SEMVER_PATTERN = new RegExp(`^${NUMERIC_ID}\\.${NUMERIC_ID}\\.${NUMERIC_ID}` + `(?:-(?:${NUMERIC_ID}|${ALNUM_ID})(?:\\.(?:${NUMERIC_ID}|${ALNUM_ID}))*)?` + `(?:\\+[0-9A-Za-z.-]+)?$`);
5012
-
5013
- // src/lib/manifest.ts
5014
- var HOOK_NAME_RE = /^[\w-]+$/;
5015
- var manifestSchema = exports_external.object({
5016
- name: exports_external.string().regex(HOOK_NAME_RE, "hook name must be /^[\\w-]+$/"),
5017
- version: exports_external.string().regex(SEMVER_PATTERN, "version must be full semver (e.g. 1.2.3 or 1.2.3-beta.1)"),
5018
- description: exports_external.string().optional(),
5019
- events: exports_external.array(exports_external.string()).min(1, "at least one event is required"),
5020
- script: exports_external.string().min(1, "script must name a relative path or inline content"),
5021
- script_kind: exports_external.enum(["inline", "file"]).optional(),
5022
- args: exports_external.array(exports_external.string()).optional(),
5023
- timeout_ms: exports_external.number().int().positive().optional(),
5024
- env: exports_external.record(exports_external.string(), exports_external.string()).optional()
5025
- });
5026
- function parseManifest(json) {
5027
- const parsed = JSON.parse(json);
5028
- return manifestSchema.parse(parsed);
5029
- }
5030
- function shortManifestName(name) {
5031
- return name.startsWith("hook-") ? name.slice("hook-".length) : name;
5032
- }
5033
-
5034
- class ScriptContainmentError extends Error {
5035
- constructor(path, hookDir, raw) {
5036
- super(raw && raw !== path ? `manifest script escapes the hook directory: '${path}' (from '${raw}') resolves outside '${hookDir}'` : `manifest script escapes the hook directory: '${path}' resolves outside '${hookDir}'`);
5037
- this.name = "ScriptContainmentError";
5038
- }
5039
- }
5040
- function isWithin(child, parent) {
5041
- return child === parent || child.startsWith(parent + sep);
5042
- }
5043
- function assertContainedLexical(target, root, raw) {
5044
- const lexical = resolve2(target);
5045
- const rootLexical = resolve2(root);
5046
- if (!isWithin(lexical, rootLexical)) {
5047
- throw new ScriptContainmentError(target, root, raw);
5048
- }
5049
- return lexical;
5050
- }
5051
- function realpathNearest(path) {
5052
- const tail = [];
5053
- let cur = path;
5054
- while (!existsSync2(cur)) {
5055
- const parent = dirname(cur);
5056
- if (parent === cur)
5057
- break;
5058
- tail.unshift(basename(cur));
5059
- cur = parent;
5060
- }
5061
- return join5(realpathSync(cur), ...tail);
5062
- }
5063
- function assertContainedReal(root, target, raw) {
5064
- const rootReal = realpathSync(root);
5065
- const targetReal = realpathNearest(target);
5066
- if (!isWithin(targetReal, rootReal)) {
5067
- throw new ScriptContainmentError(target, root, raw);
5068
- }
5069
- }
5070
- function assertContained(target, root, raw) {
5071
- const lexical = assertContainedLexical(target, root, raw);
5072
- assertContainedReal(root, lexical, raw);
5073
- return lexical;
5074
- }
5075
- function isInlineScript(manifest) {
5076
- return manifest.script_kind === "inline" || manifest.script_kind === undefined && manifest.script.includes(`
5077
- `);
5078
- }
5079
- function scriptRelFor(manifest) {
5080
- if (isInlineScript(manifest)) {
5081
- const ext = manifest.script.trim().startsWith("#!") ? ".sh" : ".ts";
5082
- return `script${ext}`;
5083
- }
5084
- const rel = normalize(manifest.script);
5085
- if (isAbsolute(rel)) {
5086
- throw new Error(`manifest script must be relative or inline, got absolute path '${manifest.script}'`);
5087
- }
5088
- return rel;
5089
- }
5090
- function resolveScript(manifest, manifestDir) {
5091
- if (isInlineScript(manifest))
5092
- return { path: scriptRelFor(manifest), content: manifest.script };
5093
- const rel = scriptRelFor(manifest);
5094
- const resolved = assertContained(join5(manifestDir, rel), manifestDir, manifest.script);
5095
- if (!existsSync2(resolved)) {
5096
- throw new Error(`manifest script file not found: ${resolved}`);
5097
- }
5098
- return { path: rel, content: readFileSync2(resolved, "utf-8") };
5099
- }
5100
- function customHookDir(name) {
5101
- return join5(getCustomHooksDir(), shortManifestName(name));
5102
- }
5103
- function readCustomManifest(name) {
5104
- const dir = customHookDir(name);
5105
- const manifestPath = join5(dir, "manifest.json");
5106
- if (!existsSync2(manifestPath))
5107
- return;
5108
- try {
5109
- const manifest = parseManifest(readFileSync2(manifestPath, "utf-8"));
5110
- const script = resolveScript(manifest, dir);
5111
- const scriptIsInline = isInlineScript(manifest);
5112
- return { manifest, scriptPath: join5(dir, script.path), scriptContent: script.content, scriptIsInline };
5113
- } catch (err) {
5114
- if (err instanceof ScriptContainmentError)
5115
- throw err;
5116
- return;
5117
- }
5118
- }
5119
- function listCustomHooks() {
5120
- const dir = getCustomHooksDir();
5121
- if (!existsSync2(dir))
5122
- return [];
5123
- const results = [];
5124
- for (const entry of readdirSafe(dir)) {
5125
- const parsed = readCustomManifest(entry);
5126
- if (parsed)
5127
- results.push(parsed);
5128
- }
5129
- return results.sort((a, b) => a.manifest.name.localeCompare(b.manifest.name));
5130
- }
5131
- function readdirSafe(dir) {
5132
- try {
5133
- return readdirSync(dir);
5134
- } catch {
5135
- return [];
4778
+ function readdirSafe(dir) {
4779
+ try {
4780
+ return readdirSync(dir);
4781
+ } catch {
4782
+ return [];
5136
4783
  }
5137
4784
  }
5138
4785
 
5139
4786
  // src/lib/store.ts
5140
4787
  import { createHash } from "crypto";
5141
- import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync2, renameSync } from "fs";
4788
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2, renameSync } from "fs";
5142
4789
  import { readFile } from "fs/promises";
5143
4790
 
5144
4791
  // src/db/index.ts
5145
4792
  import { Database } from "bun:sqlite";
5146
4793
  import { existsSync as existsSync4, mkdirSync as mkdirSync2, cpSync } from "fs";
5147
- import { join as join7 } from "path";
4794
+ import { join as join5 } from "path";
5148
4795
 
5149
4796
  // src/db/schema.ts
5150
4797
  var CREATE_HOOK_EVENTS_TABLE = `
@@ -5316,9 +4963,9 @@ function runMigrations(db) {
5316
4963
  }
5317
4964
 
5318
4965
  // src/db/legacy-import.ts
5319
- import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
5320
- import { join as join6 } from "path";
5321
- import { homedir as homedir3 } from "os";
4966
+ import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync2 } from "fs";
4967
+ import { join as join4 } from "path";
4968
+ import { homedir as homedir2 } from "os";
5322
4969
  var META_KEY = "legacy_import_done";
5323
4970
  function ensureMetaTable(db) {
5324
4971
  db.exec(`
@@ -5342,7 +4989,7 @@ function nanoid() {
5342
4989
  function importJsonlFile(db, filePath) {
5343
4990
  let count = 0;
5344
4991
  try {
5345
- const lines = readFileSync3(filePath, "utf-8").split(`
4992
+ const lines = readFileSync2(filePath, "utf-8").split(`
5346
4993
  `).filter(Boolean);
5347
4994
  for (const line of lines) {
5348
4995
  try {
@@ -5368,7 +5015,7 @@ function importJsonlFile(db, filePath) {
5368
5015
  function importErrorsLog(db, filePath) {
5369
5016
  let count = 0;
5370
5017
  try {
5371
- const lines = readFileSync3(filePath, "utf-8").split(`
5018
+ const lines = readFileSync2(filePath, "utf-8").split(`
5372
5019
  `).filter(Boolean);
5373
5020
  const linePattern = /^\[(.+?)\]\s+(?:\[session:(\S+)\]\s+)?(.+?)\s+\u2014\s+(.+)$/;
5374
5021
  for (const line of lines) {
@@ -5393,25 +5040,25 @@ function importErrorsLog(db, filePath) {
5393
5040
  } catch {}
5394
5041
  return count;
5395
5042
  }
5396
- function runLegacyImport(db, homeDir2 = homedir3()) {
5043
+ function runLegacyImport(db, homeDir = homedir2()) {
5397
5044
  try {
5398
5045
  if (isAlreadyDone(db))
5399
5046
  return;
5400
5047
  let total = 0;
5401
- const claudeProjectsDir = join6(homeDir2, ".claude", "projects");
5048
+ const claudeProjectsDir = join4(homeDir, ".claude", "projects");
5402
5049
  if (existsSync3(claudeProjectsDir)) {
5403
5050
  try {
5404
5051
  const projectDirs = readdirSync2(claudeProjectsDir);
5405
5052
  for (const dir of projectDirs) {
5406
- const projectDir = join6(claudeProjectsDir, dir);
5053
+ const projectDir = join4(claudeProjectsDir, dir);
5407
5054
  try {
5408
5055
  const files = readdirSync2(projectDir);
5409
5056
  for (const file of files) {
5410
5057
  if (file.match(/^session-log-\d{4}-\d{2}-\d{2}\.jsonl$/)) {
5411
- total += importJsonlFile(db, join6(projectDir, file));
5058
+ total += importJsonlFile(db, join4(projectDir, file));
5412
5059
  }
5413
5060
  if (file === "errors.log") {
5414
- total += importErrorsLog(db, join6(projectDir, file));
5061
+ total += importErrorsLog(db, join4(projectDir, file));
5415
5062
  }
5416
5063
  }
5417
5064
  } catch {}
@@ -5447,7 +5094,7 @@ function runRetention(db, days) {
5447
5094
  var instance = null;
5448
5095
  function resolveDataDir() {
5449
5096
  const effective = getEffectiveDataRoot();
5450
- const oldDir = join7(getHomeDir(), ".hooks");
5097
+ const oldDir = join5(getHomeDir(), ".hooks");
5451
5098
  if (!existsSync4(effective) && existsSync4(oldDir)) {
5452
5099
  mkdirSync2(effective, { recursive: true });
5453
5100
  cpSync(oldDir, effective, { recursive: true });
@@ -5459,7 +5106,7 @@ function getDbPath() {
5459
5106
  if (explicitDb)
5460
5107
  return explicitDb;
5461
5108
  const dataDir2 = resolveDataDir();
5462
- return join7(dataDir2, "hooks.db");
5109
+ return join5(dataDir2, "hooks.db");
5463
5110
  }
5464
5111
  function ensureDir(dbPath) {
5465
5112
  const dir = dbPath.substring(0, dbPath.lastIndexOf("/"));
@@ -5558,7 +5205,7 @@ function readLock() {
5558
5205
  return { hooks: {} };
5559
5206
  let raw;
5560
5207
  try {
5561
- raw = readFileSync4(path, "utf-8");
5208
+ raw = readFileSync3(path, "utf-8");
5562
5209
  } catch {
5563
5210
  throw new LockFileError(`hooks.lock (${path}) could not be read. Repair: fix permissions or move the file aside and run 'hooks sync'.`);
5564
5211
  }
@@ -5590,7 +5237,7 @@ function setPinnedHook(name, entry) {
5590
5237
  return writeLock(lock);
5591
5238
  }
5592
5239
  function retrustHook(name, scriptPath, version, source) {
5593
- const actual = sha256Of(readFileSync4(scriptPath));
5240
+ const actual = sha256Of(readFileSync3(scriptPath));
5594
5241
  const db = getDb();
5595
5242
  upsertHookRecord(db, {
5596
5243
  name,
@@ -5604,14 +5251,14 @@ function retrustHook(name, scriptPath, version, source) {
5604
5251
  }
5605
5252
 
5606
5253
  // src/lib/resolve.ts
5607
- import { dirname as dirname2, join as join8 } from "path";
5254
+ import { dirname as dirname2, join as join6 } from "path";
5608
5255
  import { existsSync as existsSync6 } from "fs";
5609
5256
  import { fileURLToPath } from "url";
5610
5257
  function resolveBundledHooksDir(moduleDir = dirname2(fileURLToPath(import.meta.url))) {
5611
- const sourceLayout = join8(moduleDir, "..", "..", "hooks");
5612
- if (existsSync6(join8(sourceLayout, "hook-gitguard")))
5258
+ const sourceLayout = join6(moduleDir, "..", "..", "hooks");
5259
+ if (existsSync6(join6(sourceLayout, "hook-gitguard")))
5613
5260
  return sourceLayout;
5614
- return join8(moduleDir, "..", "hooks");
5261
+ return join6(moduleDir, "..", "hooks");
5615
5262
  }
5616
5263
  function resolveHookDir(name) {
5617
5264
  const custom2 = readCustomManifest(name);
@@ -5622,10 +5269,10 @@ function resolveHookDir(name) {
5622
5269
  return;
5623
5270
  const short = shortManifestName(name);
5624
5271
  const bundledBase = resolveBundledHooksDir();
5625
- const direct = join8(bundledBase, short);
5272
+ const direct = join6(bundledBase, short);
5626
5273
  if (existsSync6(direct))
5627
5274
  return direct;
5628
- const prefixed = join8(bundledBase, `hook-${short}`);
5275
+ const prefixed = join6(bundledBase, `hook-${short}`);
5629
5276
  return existsSync6(prefixed) ? prefixed : undefined;
5630
5277
  }
5631
5278
  function resolveScriptPath(name) {
@@ -5635,7 +5282,7 @@ function resolveScriptPath(name) {
5635
5282
  const dir = resolveHookDir(name);
5636
5283
  if (!dir)
5637
5284
  return;
5638
- const script = join8(dir, "src", "hook.ts");
5285
+ const script = join6(dir, "src", "hook.ts");
5639
5286
  return existsSync6(script) ? script : undefined;
5640
5287
  }
5641
5288
  function resolveHookMeta(name) {
@@ -5689,9 +5336,554 @@ function resolveHook(name) {
5689
5336
 
5690
5337
  // src/serve.ts
5691
5338
  import { readFileSync as readFileSync5 } from "fs";
5692
- import { dirname as dirname3, join as join9 } from "path";
5339
+ import { dirname as dirname3, join as join8 } from "path";
5693
5340
  import { fileURLToPath as fileURLToPath2 } from "url";
5694
5341
 
5342
+ // ../contracts/dist/client/transport.js
5343
+ import { spawnSync } from "child_process";
5344
+ import { closeSync, fstatSync, openSync, readFileSync as readFileSync4 } from "fs";
5345
+ import { O_NOFOLLOW, O_NONBLOCK, O_RDONLY } from "constants";
5346
+ import { createRequire } from "module";
5347
+ import { hostname as osHostname } from "os";
5348
+ import { isAbsolute as isAbsolute2, join as join7 } from "path";
5349
+ function envToken(name) {
5350
+ return name.toUpperCase().replace(/-/g, "_");
5351
+ }
5352
+ function clientTransportEnvKeys(name) {
5353
+ const envSegment = envToken(name);
5354
+ return {
5355
+ apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
5356
+ apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
5357
+ };
5358
+ }
5359
+ function credentialOverrideEnvKey(name) {
5360
+ return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
5361
+ }
5362
+ var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
5363
+ function credentialPointerEnvKey(name) {
5364
+ return `HASNA_${envToken(name)}_API_KEY_REF`;
5365
+ }
5366
+
5367
+ class CredentialResolutionError extends Error {
5368
+ appName;
5369
+ attempted;
5370
+ constructor(appName, message, attempted) {
5371
+ super(message);
5372
+ this.name = "CredentialResolutionError";
5373
+ this.appName = appName;
5374
+ this.attempted = attempted;
5375
+ }
5376
+ }
5377
+
5378
+ class CredentialFileUnsafeError extends Error {
5379
+ path;
5380
+ constructor(path, reason) {
5381
+ super(`Refusing unsafe credential/config file ${path}: ${reason}.`);
5382
+ this.name = "CredentialFileUnsafeError";
5383
+ this.path = path;
5384
+ }
5385
+ }
5386
+ var HASNA_HOME_ENV_KEY = "HASNA_HOME";
5387
+ var HASNA_CONFIG_HOME_ENV_KEY = "HASNA_CONFIG_HOME";
5388
+ var KEYCHAIN_STATION_ENV_KEY = "HASNA_STATION";
5389
+ var HASNA_HOME_DIR = ".hasna";
5390
+ var CONFIG_SUBDIR = "config";
5391
+ var CREDENTIALS_FILE = "credentials";
5392
+ var KEYCHAIN_SECURITY_BIN = "/usr/bin/security";
5393
+ var KEYCHAIN_SERVICE_PREFIX = "hasna.credentials";
5394
+ var KEYCHAIN_ITEM_NOT_FOUND_STATUS = 44;
5395
+ var KEYCHAIN_SPAWN_TIMEOUT_MS = 1e4;
5396
+ var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
5397
+ var SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
5398
+ var SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
5399
+ var ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
5400
+ var VAULT_POINTER_SHAPE = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
5401
+ function homeDir(env) {
5402
+ const home = env.HOME?.trim();
5403
+ return home ? home : null;
5404
+ }
5405
+ function absoluteOverride(env, key) {
5406
+ const value = env[key]?.trim();
5407
+ return value && isAbsolute2(value) ? value : null;
5408
+ }
5409
+ function hasnaHomeDir(env) {
5410
+ const override = absoluteOverride(env, HASNA_HOME_ENV_KEY);
5411
+ if (override)
5412
+ return override;
5413
+ const home = homeDir(env);
5414
+ return home ? join7(home, HASNA_HOME_DIR) : null;
5415
+ }
5416
+ function appConfigDir(name, env) {
5417
+ const configRoot = absoluteOverride(env, HASNA_CONFIG_HOME_ENV_KEY);
5418
+ if (configRoot)
5419
+ return join7(configRoot, name);
5420
+ const root = hasnaHomeDir(env);
5421
+ return root ? join7(root, name, CONFIG_SUBDIR) : null;
5422
+ }
5423
+ function credentialDiskSourceList(name, env, profile = null) {
5424
+ if (!SAFE_APP_SLUG.test(name))
5425
+ return [];
5426
+ const directory = appConfigDir(name, env);
5427
+ if (!directory)
5428
+ return [];
5429
+ const file = profile ? `${CREDENTIALS_FILE}-${profile}` : CREDENTIALS_FILE;
5430
+ return [{ path: join7(directory, file), tier: "disk" }];
5431
+ }
5432
+ function credentialDiskSources(name, env) {
5433
+ return credentialDiskSourceList(name, env, null).map((s) => s.path);
5434
+ }
5435
+ function profileDiskSources(name, env, profile) {
5436
+ return credentialDiskSourceList(name, env, profile).map((s) => s.path);
5437
+ }
5438
+ function parseEnvFile(text) {
5439
+ const values = new Map;
5440
+ const unusable = new Set;
5441
+ for (const rawLine of text.split(/\r?\n/)) {
5442
+ const line = rawLine.trim();
5443
+ if (line.length === 0 || line.startsWith("#"))
5444
+ continue;
5445
+ const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
5446
+ const equals = withoutExport.indexOf("=");
5447
+ if (equals <= 0)
5448
+ continue;
5449
+ const key = withoutExport.slice(0, equals).trim();
5450
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
5451
+ continue;
5452
+ let value = withoutExport.slice(equals + 1).trim();
5453
+ const quote = value[0];
5454
+ if (quote === '"' || quote === "'") {
5455
+ if (value.length < 2 || !value.endsWith(quote)) {
5456
+ unusable.add(key);
5457
+ continue;
5458
+ }
5459
+ value = value.slice(1, -1);
5460
+ }
5461
+ if (value.trim().length === 0) {
5462
+ unusable.add(key);
5463
+ continue;
5464
+ }
5465
+ if (values.has(key) && values.get(key) !== value)
5466
+ unusable.add(key);
5467
+ values.set(key, value);
5468
+ }
5469
+ return { values, unusable };
5470
+ }
5471
+ function configFileModeAllowed(mode) {
5472
+ const permissions = mode & 4095;
5473
+ return permissions === 256 || permissions === 384;
5474
+ }
5475
+ function configFileReadsCoherent(before, after) {
5476
+ return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
5477
+ }
5478
+ function readAppConfigFile(path) {
5479
+ const unsafe = (reason) => {
5480
+ throw new CredentialFileUnsafeError(path, reason);
5481
+ };
5482
+ let fd = -1;
5483
+ try {
5484
+ fd = openSync(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
5485
+ } catch (error) {
5486
+ const code = error.code;
5487
+ if (code === "ENOENT" || code === "ENOTDIR")
5488
+ return null;
5489
+ if (code === "ELOOP")
5490
+ unsafe("the path is a symlink");
5491
+ unsafe(`the path could not be opened (${code ?? "unknown error"})`);
5492
+ }
5493
+ try {
5494
+ const before = fstatSync(fd);
5495
+ if (!before.isFile())
5496
+ unsafe("the path is not a regular file");
5497
+ if (!configFileModeAllowed(before.mode)) {
5498
+ unsafe(`permission mode ${(before.mode & 4095).toString(8).padStart(4, "0")} is not owner-only 0400 or 0600`);
5499
+ }
5500
+ const uid = process.getuid?.() ?? process.geteuid?.();
5501
+ if (uid !== undefined && before.uid !== uid)
5502
+ unsafe("the file is not owned by the current user");
5503
+ if (before.size > MAX_CREDENTIAL_FILE_BYTES)
5504
+ unsafe("the file exceeds the size limit");
5505
+ const bytes = readFileSync4(fd);
5506
+ const after = fstatSync(fd);
5507
+ if (!configFileReadsCoherent(before, after)) {
5508
+ unsafe("the file changed while being read");
5509
+ }
5510
+ return parseEnvFile(bytes.toString("utf8"));
5511
+ } finally {
5512
+ if (fd !== -1)
5513
+ closeSync(fd);
5514
+ }
5515
+ }
5516
+ function readCredentialFile(path, apiKeyKeys) {
5517
+ const parsed = readAppConfigFile(path);
5518
+ if (!parsed)
5519
+ return null;
5520
+ for (const key of apiKeyKeys) {
5521
+ if (parsed.unusable.has(key)) {
5522
+ throw new CredentialFileUnsafeError(path, `${key} is declared but blank or malformed`);
5523
+ }
5524
+ }
5525
+ const values = apiKeyKeys.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
5526
+ if (new Set(values).size > 1) {
5527
+ throw new CredentialFileUnsafeError(path, "credential aliases disagree");
5528
+ }
5529
+ return values[0] ?? null;
5530
+ }
5531
+ function assertUsableCredential(appName, source, value) {
5532
+ if (VAULT_POINTER_SHAPE.test(value)) {
5533
+ throw new CredentialResolutionError(appName, `The credential from ${source} looks like a secrets-vault pointer (a path-shaped reference like ` + `'namespace/app/live/api_key'). A vault path is NEVER accepted as a literal API key. ` + `Use ${credentialPointerEnvKey(appName)} to resolve the key through the vault, or provide the actual key value.`, [source]);
5534
+ }
5535
+ if (!ILLEGAL_IN_HEADER_VALUE.test(value))
5536
+ return;
5537
+ throw new CredentialResolutionError(appName, `The credential from ${source} contains characters that cannot be sent in an HTTP header ` + `(a control character or non-ASCII byte). A file written with CR-only line endings is the usual ` + `cause. Rewrite that credential file with one LF-terminated KEY=value line. ` + `The value is not shown here, and is deliberately never logged.`, [source]);
5538
+ }
5539
+ var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
5540
+ var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
5541
+ function sealCredential(fields) {
5542
+ const { apiKey } = fields;
5543
+ const visible = {
5544
+ tier: fields.tier,
5545
+ source: fields.source,
5546
+ deliberate: fields.deliberate,
5547
+ diskCandidates: Object.freeze([...fields.diskCandidates]),
5548
+ warning: fields.warning
5549
+ };
5550
+ const sealed = { ...visible };
5551
+ Object.defineProperty(sealed, "apiKey", {
5552
+ value: apiKey,
5553
+ enumerable: false,
5554
+ writable: false,
5555
+ configurable: false
5556
+ });
5557
+ if (fields.pointerVaultKey !== undefined) {
5558
+ Object.defineProperty(sealed, "pointerVaultKey", {
5559
+ value: fields.pointerVaultKey,
5560
+ enumerable: false,
5561
+ writable: false,
5562
+ configurable: false
5563
+ });
5564
+ }
5565
+ Object.defineProperty(sealed, INSPECT_CUSTOM, {
5566
+ value: () => ({ ...visible, apiKey: "[redacted]" }),
5567
+ enumerable: false,
5568
+ writable: false,
5569
+ configurable: false
5570
+ });
5571
+ Object.defineProperty(sealed, CREDENTIAL_SEAL, {
5572
+ value: true,
5573
+ enumerable: false,
5574
+ writable: false,
5575
+ configurable: false
5576
+ });
5577
+ return Object.freeze(sealed);
5578
+ }
5579
+ function firstEnvValue(env, keys) {
5580
+ for (const key of keys) {
5581
+ if (!Object.prototype.hasOwnProperty.call(env, key))
5582
+ continue;
5583
+ const value = env[key]?.trim();
5584
+ if (value)
5585
+ return { key, value };
5586
+ }
5587
+ return null;
5588
+ }
5589
+ var AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
5590
+ function isAmbientEnvironment(env) {
5591
+ return env === process.env || env[AMBIENT_ENVIRONMENT] === true;
5592
+ }
5593
+ function defaultKeychainRunner(argv) {
5594
+ const result = spawnSync(KEYCHAIN_SECURITY_BIN, [...argv], {
5595
+ encoding: "utf8",
5596
+ stdio: ["ignore", "pipe", "pipe"],
5597
+ timeout: KEYCHAIN_SPAWN_TIMEOUT_MS
5598
+ });
5599
+ return {
5600
+ status: result.status,
5601
+ stdout: result.stdout ?? "",
5602
+ stderr: result.error ? result.error.message : result.stderr ?? ""
5603
+ };
5604
+ }
5605
+ function keychainTierEnabled(env, options) {
5606
+ if ((options.platform ?? process.platform) !== "darwin")
5607
+ return false;
5608
+ if (options.enabled !== undefined)
5609
+ return options.enabled;
5610
+ return options.run !== undefined || isAmbientEnvironment(env);
5611
+ }
5612
+ function keychainAccount(env, options) {
5613
+ const station = env[KEYCHAIN_STATION_ENV_KEY]?.trim();
5614
+ if (station)
5615
+ return station;
5616
+ const host = (options.hostname ?? osHostname)().split(".")[0]?.trim() ?? "";
5617
+ if (host)
5618
+ return host;
5619
+ const user = env.USER?.trim();
5620
+ return user || null;
5621
+ }
5622
+ function keychainFailureHint(text) {
5623
+ const line = text.split(/\r?\n/).find((entry) => entry.trim().length > 0)?.trim() ?? "";
5624
+ const clean = line.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 200);
5625
+ return clean ? `: ${clean}` : "";
5626
+ }
5627
+ function readKeychainItem(name, env, kind, options) {
5628
+ if (!SAFE_APP_SLUG.test(name) || !keychainTierEnabled(env, options))
5629
+ return null;
5630
+ const account = keychainAccount(env, options);
5631
+ if (!account)
5632
+ return null;
5633
+ const service = `${KEYCHAIN_SERVICE_PREFIX}.${name}.${kind}`;
5634
+ const source = `keychain:${service}@${account}`;
5635
+ const run = options.run ?? defaultKeychainRunner;
5636
+ let result;
5637
+ try {
5638
+ result = run(["find-generic-password", "-a", account, "-s", service, "-w"]);
5639
+ } catch (error) {
5640
+ const reason = keychainFailureHint(error instanceof Error ? error.message : String(error));
5641
+ throw new CredentialResolutionError(name, `The Keychain lookup for ${source} could not run${reason}. A Keychain failure is never resolved ` + `around: fix the keychain, or delete the item to fall through to the credential on disk.`, [source]);
5642
+ }
5643
+ if (result.status === KEYCHAIN_ITEM_NOT_FOUND_STATUS)
5644
+ return null;
5645
+ if (result.status !== 0) {
5646
+ throw new CredentialResolutionError(name, `The Keychain lookup for ${source} failed (security exited ` + `${result.status ?? "without a status"}${keychainFailureHint(result.stderr)}). A Keychain item that ` + `exists but cannot be read is never resolved around: unlock the keychain, run from a session that ` + `may use it, or delete the item to fall through to the credential on disk.`, [source]);
5647
+ }
5648
+ const value = result.stdout.trim();
5649
+ if (!value) {
5650
+ throw new CredentialResolutionError(name, `${source} exists but holds an empty value; a declared item never falls through to another ` + `identity. Store a value in it or delete the item.`, [source]);
5651
+ }
5652
+ return { value, source };
5653
+ }
5654
+ function snapshotClientEnvironment(name, env) {
5655
+ const keys = clientTransportEnvKeys(name);
5656
+ const ambient = isAmbientEnvironment(env);
5657
+ const snapshot = Object.create(null);
5658
+ for (const key of [
5659
+ ...keys.apiUrlKeys,
5660
+ ...keys.apiKeyKeys,
5661
+ credentialOverrideEnvKey(name),
5662
+ credentialPointerEnvKey(name),
5663
+ CREDENTIAL_PROFILE_ENV_KEY,
5664
+ "HOME",
5665
+ HASNA_HOME_ENV_KEY,
5666
+ HASNA_CONFIG_HOME_ENV_KEY,
5667
+ KEYCHAIN_STATION_ENV_KEY,
5668
+ "USER"
5669
+ ]) {
5670
+ const descriptor = Object.getOwnPropertyDescriptor(env, key);
5671
+ if (!descriptor)
5672
+ continue;
5673
+ if (!("value" in descriptor)) {
5674
+ throw new CredentialResolutionError(name, `${key} is accessor-backed; client configuration requires own data properties.`, [key]);
5675
+ }
5676
+ if (descriptor.value !== undefined && typeof descriptor.value !== "string") {
5677
+ throw new CredentialResolutionError(name, `${key} must be a string data property.`, [key]);
5678
+ }
5679
+ snapshot[key] = descriptor.value;
5680
+ }
5681
+ if (ambient) {
5682
+ Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT, {
5683
+ value: true,
5684
+ enumerable: false,
5685
+ writable: false,
5686
+ configurable: false
5687
+ });
5688
+ }
5689
+ return Object.freeze(snapshot);
5690
+ }
5691
+ function resolveCredential(name, env, options = {}) {
5692
+ env = snapshotClientEnvironment(name, env);
5693
+ const { apiKeyKeys } = clientTransportEnvKeys(name);
5694
+ const diskPaths = credentialDiskSources(name, env);
5695
+ if (options.apiKey !== undefined) {
5696
+ const explicitKey = options.apiKey.trim();
5697
+ if (!explicitKey) {
5698
+ throw new CredentialResolutionError(name, "The explicit apiKey argument is blank; an explicit credential never falls through to another identity.", ["explicit apiKey argument"]);
5699
+ }
5700
+ assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
5701
+ return sealCredential({
5702
+ apiKey: explicitKey,
5703
+ tier: "argument",
5704
+ source: "explicit apiKey argument",
5705
+ deliberate: true,
5706
+ diskCandidates: diskPaths,
5707
+ warning: null
5708
+ });
5709
+ }
5710
+ const overrideKeyName = credentialOverrideEnvKey(name);
5711
+ const overrideRaw = Object.prototype.hasOwnProperty.call(env, overrideKeyName) ? env[overrideKeyName] : undefined;
5712
+ if (overrideRaw !== undefined) {
5713
+ const override = overrideRaw.trim();
5714
+ if (!override) {
5715
+ throw new CredentialResolutionError(name, `${overrideKeyName} is set but empty. It is a deliberate override, so it is not resolved around: ` + `either give it a real key or unset it to fall back to the credential on disk.`, [overrideKeyName]);
5716
+ }
5717
+ assertUsableCredential(name, overrideKeyName, override);
5718
+ return sealCredential({
5719
+ apiKey: override,
5720
+ tier: "override",
5721
+ source: overrideKeyName,
5722
+ deliberate: true,
5723
+ diskCandidates: diskPaths,
5724
+ warning: null
5725
+ });
5726
+ }
5727
+ const pointerKeyName = credentialPointerEnvKey(name);
5728
+ const pointerRaw = Object.prototype.hasOwnProperty.call(env, pointerKeyName) ? env[pointerKeyName] : undefined;
5729
+ if (pointerRaw !== undefined) {
5730
+ const pointer = pointerRaw.trim();
5731
+ if (!pointer) {
5732
+ throw new CredentialResolutionError(name, `${pointerKeyName} is set but empty. It is a deliberate vault pointer, so it is not resolved around: ` + `either give it a vault item key or unset it to fall back to the credential on disk.`, [pointerKeyName]);
5733
+ }
5734
+ if (!VAULT_POINTER_SHAPE.test(pointer)) {
5735
+ throw new CredentialResolutionError(name, `${pointerKeyName} must name a vault ITEM KEY (a path-shaped reference like ` + `'namespace/app/live/api_key'), not a credential value. A pointer that carries a literal is refused.`, [pointerKeyName]);
5736
+ }
5737
+ return sealCredential({
5738
+ apiKey: "",
5739
+ pointerVaultKey: pointer,
5740
+ tier: "pointer",
5741
+ source: pointerKeyName,
5742
+ deliberate: true,
5743
+ diskCandidates: diskPaths,
5744
+ warning: null
5745
+ });
5746
+ }
5747
+ if (options.profile !== undefined && !options.profile.trim()) {
5748
+ throw new CredentialResolutionError(name, "The explicit profile argument is blank; an explicit identity selection never falls through.", ["explicit profile argument"]);
5749
+ }
5750
+ const profileRaw = Object.prototype.hasOwnProperty.call(env, CREDENTIAL_PROFILE_ENV_KEY) ? env[CREDENTIAL_PROFILE_ENV_KEY] : undefined;
5751
+ if (profileRaw !== undefined && !profileRaw.trim()) {
5752
+ throw new CredentialResolutionError(name, `${CREDENTIAL_PROFILE_ENV_KEY} is set but blank.`, [CREDENTIAL_PROFILE_ENV_KEY]);
5753
+ }
5754
+ const profile = options.profile?.trim() || profileRaw?.trim();
5755
+ if (profile) {
5756
+ const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
5757
+ if (!SAFE_PROFILE.test(profile)) {
5758
+ throw new CredentialResolutionError(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
5759
+ }
5760
+ const paths = profileDiskSources(name, env, profile);
5761
+ for (const path of paths) {
5762
+ const value = readCredentialFile(path, apiKeyKeys);
5763
+ if (value) {
5764
+ assertUsableCredential(name, path, value);
5765
+ return sealCredential({
5766
+ apiKey: value,
5767
+ tier: "profile",
5768
+ source: path,
5769
+ deliberate: true,
5770
+ diskCandidates: paths,
5771
+ warning: null
5772
+ });
5773
+ }
5774
+ }
5775
+ throw new CredentialResolutionError(name, `Profile '${profile}' (from ${profileSource}) has no ${apiKeyKeys[0]} for '${name}'. ` + `Looked in: ${paths.join(", ") || "<no HOME in this environment>"}. ` + `A profile names WHICH identity to use, so it is never resolved around \u2014 ` + `create the profile's credential file or unset ${CREDENTIAL_PROFILE_ENV_KEY}.`, paths);
5776
+ }
5777
+ const definedEnvEntries = apiKeyKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, value: String(env[key]).trim() }));
5778
+ const blankEnv = definedEnvEntries.find((entry) => entry.value.length === 0);
5779
+ if (blankEnv) {
5780
+ throw new CredentialResolutionError(name, `${blankEnv.key} is set but blank; a declared credential never falls through to another alias or identity.`, [blankEnv.key]);
5781
+ }
5782
+ if (definedEnvEntries.length > 1 && new Set(definedEnvEntries.map((entry) => entry.value)).size > 1) {
5783
+ throw new CredentialResolutionError(name, `${definedEnvEntries.map((entry) => entry.key).join(" and ")} disagree; credential aliases must be identical or only one may be set.`, definedEnvEntries.map((entry) => entry.key));
5784
+ }
5785
+ const envHit = firstEnvValue(env, apiKeyKeys);
5786
+ const keychainHit = readKeychainItem(name, env, "api-key", options.keychain ?? {});
5787
+ if (keychainHit) {
5788
+ assertUsableCredential(name, keychainHit.source, keychainHit.value);
5789
+ const warning = envHit && envHit.value !== keychainHit.value ? `Credential sources disagree for '${name}': ${keychainHit.source} and ${envHit.key} hold ` + `different keys. ${keychainHit.source} wins, because the Keychain is re-read on every call while ` + `an environment variable is a snapshot. Reconcile them \u2014 a rotation that updated only one leaves ` + `the other to fail 401 wherever it is loaded first.` : null;
5790
+ return sealCredential({
5791
+ apiKey: keychainHit.value,
5792
+ tier: "keychain",
5793
+ source: keychainHit.source,
5794
+ deliberate: false,
5795
+ diskCandidates: diskPaths,
5796
+ warning
5797
+ });
5798
+ }
5799
+ const diskSourceList = credentialDiskSourceList(name, env, null);
5800
+ const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile(src.path, apiKeyKeys) })).filter((hit) => hit.value !== null);
5801
+ if (diskHits.length > 0) {
5802
+ const winner = diskHits[0];
5803
+ assertUsableCredential(name, winner.src.path, winner.value);
5804
+ const divergentSources = [
5805
+ ...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.src.path),
5806
+ ...envHit && envHit.value !== winner.value ? [envHit.key] : []
5807
+ ];
5808
+ const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.src.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.src.path} wins, because a file on ` + `disk is re-read on every call while an environment variable is a snapshot. Reconcile them \u2014 ` + `a rotation that updated only one leaves the other to fail 401 wherever it is loaded first.` : null;
5809
+ return sealCredential({
5810
+ apiKey: winner.value,
5811
+ tier: winner.src.tier,
5812
+ source: winner.src.path,
5813
+ deliberate: false,
5814
+ diskCandidates: diskPaths,
5815
+ warning
5816
+ });
5817
+ }
5818
+ if (envHit) {
5819
+ assertUsableCredential(name, envHit.key, envHit.value);
5820
+ return sealCredential({
5821
+ apiKey: envHit.value,
5822
+ tier: "env",
5823
+ source: envHit.key,
5824
+ deliberate: false,
5825
+ diskCandidates: diskPaths,
5826
+ warning: null
5827
+ });
5828
+ }
5829
+ return null;
5830
+ }
5831
+ var SECRETS_PACKAGE_SPECIFIER = "@hasna/" + "secrets";
5832
+ var requireSecretsSdk = createRequire(import.meta.url);
5833
+ var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
5834
+ var AUTHORITY_OVERRIDE_HEADERS = new Set([
5835
+ "host",
5836
+ ":authority",
5837
+ "forwarded",
5838
+ "x-forwarded-host",
5839
+ "x-original-host"
5840
+ ]);
5841
+
5842
+ // src/lib/local-opt-in.ts
5843
+ function hooksAuthorityEnvKeys() {
5844
+ const keys = clientTransportEnvKeys("hooks");
5845
+ return [
5846
+ ...keys.apiUrlKeys,
5847
+ ...keys.apiKeyKeys,
5848
+ credentialOverrideEnvKey("hooks"),
5849
+ credentialPointerEnvKey("hooks"),
5850
+ CREDENTIAL_PROFILE_ENV_KEY
5851
+ ];
5852
+ }
5853
+ function hooksResolverEnv(env) {
5854
+ const blanks = hooksAuthorityEnvKeys().filter((key) => (key in env) && (env[key] ?? "").trim() === "");
5855
+ if (blanks.length === 0)
5856
+ return env;
5857
+ const next = { ...env };
5858
+ for (const key of blanks)
5859
+ delete next[key];
5860
+ return next;
5861
+ }
5862
+ var CONTRACTS_AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
5863
+ function isAmbientHooksEnv(env) {
5864
+ if (typeof process !== "undefined" && env === process.env)
5865
+ return true;
5866
+ return env[CONTRACTS_AMBIENT_ENVIRONMENT] === true;
5867
+ }
5868
+ function hooksResolverInputs(env, credentials = {}) {
5869
+ const normalised = hooksResolverEnv(env);
5870
+ if (normalised === env)
5871
+ return { env: normalised, credentials };
5872
+ const keychain = { ...credentials.keychain };
5873
+ if (keychain.enabled === undefined && keychain.run === undefined) {
5874
+ keychain.enabled = isAmbientHooksEnv(env);
5875
+ }
5876
+ return { env: normalised, credentials: { ...credentials, keychain } };
5877
+ }
5878
+
5879
+ // src/lib/transport.ts
5880
+ function resolveHooksServePublishKey(env = process.env, options = {}) {
5881
+ const { env: resolverEnv, credentials } = hooksResolverInputs(env, options.credentials);
5882
+ const tier1 = options.credentials?.apiKey;
5883
+ const credentialOptions = tier1 !== undefined ? { ...credentials, apiKey: tier1 } : credentials;
5884
+ return resolveCredential("hooks", resolverEnv, credentialOptions)?.apiKey;
5885
+ }
5886
+
5695
5887
  // src/lib/secure-compare.ts
5696
5888
  function secureEqual(a, b) {
5697
5889
  const enc = new TextEncoder;
@@ -5850,7 +6042,7 @@ var openApiDocument = {
5850
6042
  apiKey: {
5851
6043
  type: "http",
5852
6044
  scheme: "bearer",
5853
- description: "The registry API key (HASNA_HOOKS_API_KEY / HOOKS_API_KEY)."
6045
+ description: "The registry API key \u2014 resolved by the client through the @hasna/contracts chain (HASNA_HOOKS_API_KEY, the Keychain item hasna.credentials.hooks.api-key, or ~/.hasna/hooks/config/credentials); the server compares the inbound bearer/x-api-key value."
5854
6046
  }
5855
6047
  }
5856
6048
  }
@@ -5862,7 +6054,7 @@ var SERVE_HOST = "127.0.0.1";
5862
6054
  var SERVE_SERVICE_NAME = "hooks-registry";
5863
6055
  function packageVersion() {
5864
6056
  try {
5865
- const pkgPath = join9(dirname3(fileURLToPath2(import.meta.url)), "..", "package.json");
6057
+ const pkgPath = join8(dirname3(fileURLToPath2(import.meta.url)), "..", "package.json");
5866
6058
  return JSON.parse(readFileSync5(pkgPath, "utf-8")).version || "0.0.0";
5867
6059
  } catch {
5868
6060
  return "0.0.0";
@@ -6020,12 +6212,11 @@ function resolveServeOptions(options) {
6020
6212
  }
6021
6213
  function startServeServer(options) {
6022
6214
  const { port, host } = resolveServeOptions(options);
6023
- const apiKey = resolveApiKey();
6024
6215
  const server = Bun.serve({
6025
6216
  hostname: host,
6026
6217
  port,
6027
6218
  async fetch(req) {
6028
- return handleServeRequest(req, apiKey);
6219
+ return handleServeRequest(req, resolveHooksServePublishKey());
6029
6220
  }
6030
6221
  });
6031
6222
  console.error(`hooks registry listening on http://${host}:${port} (publish requires an API key)`);