@hasna/hooks 0.7.9 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +32 -6
  2. package/bin/index.js +1573 -695
  3. package/bin/serve.js +713 -335
  4. package/dist/config.d.ts +25 -19
  5. package/dist/db/index.d.ts +3 -1
  6. package/dist/index.d.ts +3 -2
  7. package/dist/index.js +1162 -473
  8. package/dist/lib/app-home.d.ts +74 -0
  9. package/dist/lib/installer.d.ts +0 -9
  10. package/dist/lib/local-opt-in.d.ts +80 -0
  11. package/dist/lib/profiles.d.ts +14 -1
  12. package/dist/lib/resolver-types.d.ts +45 -0
  13. package/dist/lib/sync.d.ts +13 -8
  14. package/dist/lib/transport.d.ts +87 -0
  15. package/dist/openapi.d.ts +1 -1
  16. package/dist/serve.d.ts +7 -0
  17. package/dist/storage.js +141 -27
  18. package/hooks/hook-affected-tests/package.json +1 -1
  19. package/hooks/hook-agent-rules-version-check/package.json +1 -1
  20. package/hooks/hook-agentmessages/package.json +1 -1
  21. package/hooks/hook-announce-start/package.json +1 -1
  22. package/hooks/hook-announce-stop/package.json +1 -1
  23. package/hooks/hook-autoformat/package.json +1 -1
  24. package/hooks/hook-branchprotect/package.json +1 -1
  25. package/hooks/hook-checkbugs/package.json +1 -1
  26. package/hooks/hook-checkdocs/package.json +1 -1
  27. package/hooks/hook-checkfiles/package.json +1 -1
  28. package/hooks/hook-checklint/package.json +1 -1
  29. package/hooks/hook-checkpoint/package.json +1 -1
  30. package/hooks/hook-checksecurity/package.json +1 -1
  31. package/hooks/hook-checktasks/package.json +1 -1
  32. package/hooks/hook-checktests/package.json +1 -1
  33. package/hooks/hook-conflict-detect/package.json +1 -1
  34. package/hooks/hook-contextrefresh/package.json +1 -1
  35. package/hooks/hook-desktopnotify/package.json +1 -1
  36. package/hooks/hook-dm-inject/package.json +1 -1
  37. package/hooks/hook-envsetup/package.json +1 -1
  38. package/hooks/hook-failure-to-task/package.json +1 -1
  39. package/hooks/hook-filelock/package.json +1 -1
  40. package/hooks/hook-fleet-blockers-gate/package.json +1 -1
  41. package/hooks/hook-fleet-catchup/package.json +1 -1
  42. package/hooks/hook-gitguard/package.json +1 -1
  43. package/hooks/hook-packageage/package.json +1 -1
  44. package/hooks/hook-permissionguard/package.json +1 -1
  45. package/hooks/hook-phonenotify/package.json +1 -1
  46. package/hooks/hook-precompact/package.json +1 -1
  47. package/hooks/hook-protectfiles/package.json +1 -1
  48. package/hooks/hook-stylescheck/package.json +1 -1
  49. package/hooks/hook-typecheck-gate/package.json +1 -1
  50. package/package.json +6 -9
  51. package/scripts/artifact-scan.ts +49 -0
  52. package/scripts/ensure-profiles-dir.mjs +99 -0
  53. package/scripts/validate-package.ts +280 -0
package/bin/serve.js CHANGED
@@ -15,6 +15,121 @@ var __export = (target, all) => {
15
15
  });
16
16
  };
17
17
 
18
+ // src/lib/app-home.ts
19
+ import { existsSync } from "fs";
20
+ import { homedir } from "os";
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 = {
25
+ config: "HASNA_CONFIG_HOME",
26
+ data: "HASNA_DATA_HOME",
27
+ state: "HASNA_STATE_HOME",
28
+ cache: "HASNA_CACHE_HOME"
29
+ };
30
+ var PATHS_RESOLVER_APP_SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
31
+ function pathsResolverAssertApp(app) {
32
+ if (typeof app !== "string" || app.length === 0) {
33
+ throw new TypeError("paths: app must be a non-empty string");
34
+ }
35
+ if (!PATHS_RESOLVER_APP_SLUG_RE.test(app)) {
36
+ throw new TypeError(`paths: invalid app slug "${app}" \u2014 expected lowercase kebab-case ([a-z0-9]+(-[a-z0-9]+)*)`);
37
+ }
38
+ }
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
+ }
43
+ }
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)
49
+ return override;
50
+ const home = options.home ?? pathsResolverHomedir();
51
+ const platform = options.platform ?? process.platform;
52
+ if (platform === "darwin") {
53
+ switch (kind) {
54
+ case "config":
55
+ case "data":
56
+ return pathsResolverJoin(home, "Library", "Application Support", "Hasna");
57
+ case "cache":
58
+ return pathsResolverJoin(home, "Library", "Caches", "Hasna");
59
+ case "state":
60
+ return pathsResolverJoin(home, "Library", "Logs", "Hasna");
61
+ }
62
+ }
63
+ switch (kind) {
64
+ case "config":
65
+ return pathsResolverJoin(home, ".config", "hasna");
66
+ case "data":
67
+ return pathsResolverJoin(home, ".local", "share", "hasna");
68
+ case "state":
69
+ return pathsResolverJoin(home, ".local", "state", "hasna");
70
+ case "cache":
71
+ return pathsResolverJoin(home, ".cache", "hasna");
72
+ }
73
+ }
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);
78
+ }
79
+ function dataDir(options) {
80
+ return pathsResolverResolve("data", options);
81
+ }
82
+ var STORE_MARKER = "hooks.db";
83
+ function getHomeDir(env = process.env) {
84
+ return env.HOME || env.USERPROFILE || homedir();
85
+ }
86
+ function getResolverDataRoot(env = process.env) {
87
+ return dataDir({ app: "hooks", home: getHomeDir(env), env });
88
+ }
89
+ function getLegacyDataRoot(env = process.env) {
90
+ return join(getHomeDir(env), ".hasna", "hooks");
91
+ }
92
+ function adoptResolverDataRoot(resolved, env = process.env) {
93
+ const dataOverride = env.HASNA_DATA_HOME;
94
+ if (typeof dataOverride === "string" && dataOverride.trim().length > 0)
95
+ return true;
96
+ return existsSync(join(resolved, STORE_MARKER));
97
+ }
98
+ function getExplicitDataDir(env = process.env) {
99
+ const dir = env.HASNA_HOOKS_DATA_DIR ?? env.HOOKS_DATA_DIR;
100
+ if (typeof dir === "string" && dir.trim().length > 0)
101
+ return dir.trim();
102
+ return;
103
+ }
104
+ function getExactDataRoot(env = process.env) {
105
+ const dir = env.HASNA_HOOKS_HOME?.trim() || env.HOOKS_HOME?.trim();
106
+ if (dir)
107
+ return resolve(dir);
108
+ return;
109
+ }
110
+ function getEffectiveDataRoot(env = process.env) {
111
+ const explicit = getExplicitDataDir(env);
112
+ if (explicit)
113
+ return explicit;
114
+ const exact = getExactDataRoot(env);
115
+ if (exact)
116
+ return exact;
117
+ const resolved = getResolverDataRoot(env);
118
+ return adoptResolverDataRoot(resolved, env) ? resolve(resolved) : getLegacyDataRoot(env);
119
+ }
120
+ function getReportedDbPath(env = process.env) {
121
+ const explicit = getExplicitDbPath(env);
122
+ if (explicit)
123
+ return explicit;
124
+ return join(getEffectiveDataRoot(env), "hooks.db");
125
+ }
126
+ function getExplicitDbPath(env = process.env) {
127
+ const db = env.HASNA_HOOKS_DB_PATH ?? env.HOOKS_DB_PATH;
128
+ if (typeof db === "string" && db.trim().length > 0)
129
+ return db.trim();
130
+ return;
131
+ }
132
+
18
133
  // src/lib/registry.ts
19
134
  var HOOKS = [
20
135
  {
@@ -331,7 +446,7 @@ var HOOKS = [
331
446
  {
332
447
  name: "sessionlog",
333
448
  displayName: "Session Log",
334
- description: "Logs every tool call to SQLite (~/.hasna/hooks/hooks.db)",
449
+ description: `Logs every tool call to SQLite (${getReportedDbPath()})`,
335
450
  version: "0.1.0",
336
451
  category: "Observability",
337
452
  event: "PostToolUse",
@@ -341,7 +456,7 @@ var HOOKS = [
341
456
  {
342
457
  name: "commandlog",
343
458
  displayName: "Command Log",
344
- description: "Logs every Bash command to SQLite (~/.hasna/hooks/hooks.db)",
459
+ description: `Logs every Bash command to SQLite (${getReportedDbPath()})`,
345
460
  version: "0.1.0",
346
461
  category: "Observability",
347
462
  event: "PostToolUse",
@@ -361,7 +476,7 @@ var HOOKS = [
361
476
  {
362
477
  name: "errornotify",
363
478
  displayName: "Error Notify",
364
- description: "Detects tool failures and logs errors to SQLite (~/.hasna/hooks/hooks.db)",
479
+ description: `Detects tool failures and logs errors to SQLite (${getReportedDbPath()})`,
365
480
  version: "0.1.0",
366
481
  category: "Observability",
367
482
  event: "PostToolUse",
@@ -534,8 +649,8 @@ function getHook(name) {
534
649
  }
535
650
 
536
651
  // src/lib/manifest.ts
537
- import { existsSync, mkdirSync, readFileSync as readFileSync2, readdirSync, realpathSync, writeFileSync } from "fs";
538
- import { basename, dirname, isAbsolute, join as join3, normalize, resolve, sep } from "path";
652
+ import { existsSync as existsSync2, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "fs";
653
+ import { basename, dirname, isAbsolute, join as join3, normalize, resolve as resolve2, sep } from "path";
539
654
 
540
655
  // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/external.js
541
656
  var exports_external = {};
@@ -4510,291 +4625,10 @@ var coerce = {
4510
4625
  date: (arg) => ZodDate.create({ ...arg, coerce: true })
4511
4626
  };
4512
4627
  var NEVER = INVALID;
4513
- // ../contracts/dist/client/transport.js
4514
- import { readFileSync, statSync } from "fs";
4515
- import { join } from "path";
4516
- function envToken(name) {
4517
- return name.toUpperCase().replace(/-/g, "_");
4518
- }
4519
- function clientTransportEnvKeys(name) {
4520
- const envSegment = envToken(name);
4521
- return {
4522
- apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
4523
- apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
4524
- };
4525
- }
4526
- function credentialOverrideEnvKey(name) {
4527
- return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
4528
- }
4529
- var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
4530
-
4531
- class CredentialResolutionError extends Error {
4532
- appName;
4533
- attempted;
4534
- constructor(appName, message, attempted) {
4535
- super(message);
4536
- this.name = "CredentialResolutionError";
4537
- this.appName = appName;
4538
- this.attempted = attempted;
4539
- }
4540
- }
4541
- var HASNA_STATE_DIR = ".hasna";
4542
- var FLEET_CREDENTIAL_DIR = "cloud";
4543
- var CONFIG_DIR = ".config";
4544
- var CONFIG_NAMESPACE = "hasna";
4545
- var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
4546
- var SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
4547
- var SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
4548
- var ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
4549
- function homeDir(env) {
4550
- const home = env.HOME?.trim();
4551
- return home ? home : null;
4552
- }
4553
- function credentialDiskSources(name, env) {
4554
- return profileDiskSources(name, env, null);
4555
- }
4556
- function profileDiskSources(name, env, profile) {
4557
- const home = homeDir(env);
4558
- if (!home || !SAFE_APP_SLUG.test(name))
4559
- return [];
4560
- const stem = profile ? `${name}.${profile}` : name;
4561
- const configStem = profile ? `${name}-${profile}` : name;
4562
- return [
4563
- join(home, HASNA_STATE_DIR, FLEET_CREDENTIAL_DIR, `${stem}.env`),
4564
- join(home, CONFIG_DIR, CONFIG_NAMESPACE, `${configStem}-cloud.env`)
4565
- ];
4566
- }
4567
- function parseEnvFile(text) {
4568
- const values = new Map;
4569
- for (const rawLine of text.split(/\r?\n/)) {
4570
- const line = rawLine.trim();
4571
- if (line.length === 0 || line.startsWith("#"))
4572
- continue;
4573
- const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
4574
- const equals = withoutExport.indexOf("=");
4575
- if (equals <= 0)
4576
- continue;
4577
- const key = withoutExport.slice(0, equals).trim();
4578
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
4579
- continue;
4580
- let value = withoutExport.slice(equals + 1).trim();
4581
- const quote = value[0];
4582
- if (quote === '"' || quote === "'") {
4583
- if (value.length < 2 || !value.endsWith(quote))
4584
- continue;
4585
- value = value.slice(1, -1);
4586
- }
4587
- if (value.length === 0)
4588
- continue;
4589
- values.set(key, value);
4590
- }
4591
- return values;
4592
- }
4593
- function readAppConfigFile(path) {
4594
- let text;
4595
- try {
4596
- const stats = statSync(path);
4597
- if (!stats.isFile() || stats.size > MAX_CREDENTIAL_FILE_BYTES)
4598
- return null;
4599
- text = readFileSync(path, "utf8");
4600
- } catch {
4601
- return null;
4602
- }
4603
- return parseEnvFile(text);
4604
- }
4605
- function readCredentialFile(path, apiKeyKeys) {
4606
- const values = readAppConfigFile(path);
4607
- if (!values)
4608
- return null;
4609
- for (const key of apiKeyKeys) {
4610
- const value = values.get(key)?.trim();
4611
- if (value)
4612
- return value;
4613
- }
4614
- return null;
4615
- }
4616
- function assertUsableCredential(appName, source, value) {
4617
- if (!ILLEGAL_IN_HEADER_VALUE.test(value))
4618
- return;
4619
- 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]);
4620
- }
4621
- var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
4622
- var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
4623
- function sealCredential(fields) {
4624
- const { apiKey } = fields;
4625
- const visible = {
4626
- tier: fields.tier,
4627
- source: fields.source,
4628
- deliberate: fields.deliberate,
4629
- deprecated: fields.deprecated,
4630
- diskCandidates: Object.freeze([...fields.diskCandidates]),
4631
- warning: fields.warning
4632
- };
4633
- const sealed = { ...visible };
4634
- Object.defineProperty(sealed, "apiKey", {
4635
- value: apiKey,
4636
- enumerable: false,
4637
- writable: false,
4638
- configurable: false
4639
- });
4640
- Object.defineProperty(sealed, INSPECT_CUSTOM, {
4641
- value: () => ({ ...visible, apiKey: "[redacted]" }),
4642
- enumerable: false,
4643
- writable: false,
4644
- configurable: false
4645
- });
4646
- Object.defineProperty(sealed, CREDENTIAL_SEAL, {
4647
- value: true,
4648
- enumerable: false,
4649
- writable: false,
4650
- configurable: false
4651
- });
4652
- return Object.freeze(sealed);
4653
- }
4654
- function firstEnvValue(env, keys) {
4655
- for (const key of keys) {
4656
- const value = env[key]?.trim();
4657
- if (value)
4658
- return { key, value };
4659
- }
4660
- return null;
4661
- }
4662
- var DEPRECATION_REGISTRY = Symbol.for("hasna:contracts:credentialDeprecationNotices");
4663
- function deprecationNotified() {
4664
- const host = globalThis;
4665
- const existing = host[DEPRECATION_REGISTRY];
4666
- if (existing instanceof Set)
4667
- return existing;
4668
- const created = new Set;
4669
- host[DEPRECATION_REGISTRY] = created;
4670
- return created;
4671
- }
4672
- function defaultDeprecationSink(message) {
4673
- if (typeof process !== "undefined" && process.stderr) {
4674
- process.stderr.write(`${message}
4675
- `);
4676
- }
4677
- }
4678
- function resolveCredential(name, env, options = {}) {
4679
- const { apiKeyKeys } = clientTransportEnvKeys(name);
4680
- const diskPaths = credentialDiskSources(name, env);
4681
- const explicitKey = options.apiKey?.trim();
4682
- if (explicitKey) {
4683
- assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
4684
- return sealCredential({
4685
- apiKey: explicitKey,
4686
- tier: "argument",
4687
- source: "explicit apiKey argument",
4688
- deliberate: true,
4689
- deprecated: false,
4690
- diskCandidates: diskPaths,
4691
- warning: null
4692
- });
4693
- }
4694
- const overrideKeyName = credentialOverrideEnvKey(name);
4695
- const overrideRaw = env[overrideKeyName];
4696
- if (overrideRaw !== undefined) {
4697
- const override = overrideRaw.trim();
4698
- if (!override) {
4699
- 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]);
4700
- }
4701
- assertUsableCredential(name, overrideKeyName, override);
4702
- return sealCredential({
4703
- apiKey: override,
4704
- tier: "override",
4705
- source: overrideKeyName,
4706
- deliberate: true,
4707
- deprecated: false,
4708
- diskCandidates: diskPaths,
4709
- warning: null
4710
- });
4711
- }
4712
- const profile = options.profile?.trim() || env[CREDENTIAL_PROFILE_ENV_KEY]?.trim();
4713
- if (profile) {
4714
- const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
4715
- if (!SAFE_PROFILE.test(profile)) {
4716
- throw new CredentialResolutionError(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
4717
- }
4718
- const paths = profileDiskSources(name, env, profile);
4719
- for (const path of paths) {
4720
- const value = readCredentialFile(path, apiKeyKeys);
4721
- if (value) {
4722
- assertUsableCredential(name, path, value);
4723
- return sealCredential({
4724
- apiKey: value,
4725
- tier: "profile",
4726
- source: path,
4727
- deliberate: true,
4728
- deprecated: false,
4729
- diskCandidates: paths,
4730
- warning: null
4731
- });
4732
- }
4733
- }
4734
- 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);
4735
- }
4736
- const diskHits = diskPaths.map((path) => ({ path, value: readCredentialFile(path, apiKeyKeys) })).filter((hit) => hit.value !== null);
4737
- if (diskHits.length > 0) {
4738
- const winner = diskHits[0];
4739
- assertUsableCredential(name, winner.path, winner.value);
4740
- const divergentSources = [
4741
- ...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.path),
4742
- ...(() => {
4743
- const legacyHit = firstEnvValue(env, apiKeyKeys);
4744
- return legacyHit && legacyHit.value !== winner.value ? [legacyHit.key] : [];
4745
- })()
4746
- ];
4747
- const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.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;
4748
- return sealCredential({
4749
- apiKey: winner.value,
4750
- tier: "disk",
4751
- source: winner.path,
4752
- deliberate: false,
4753
- deprecated: false,
4754
- diskCandidates: diskPaths,
4755
- warning
4756
- });
4757
- }
4758
- const legacy = firstEnvValue(env, apiKeyKeys);
4759
- if (legacy) {
4760
- assertUsableCredential(name, legacy.key, legacy.value);
4761
- 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.`;
4762
- 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}`;
4763
- const sink = options.onDeprecation ?? defaultDeprecationSink;
4764
- const notified = deprecationNotified();
4765
- if (!notified.has(name)) {
4766
- notified.add(name);
4767
- sink(message);
4768
- }
4769
- return sealCredential({
4770
- apiKey: legacy.value,
4771
- tier: "legacy-env",
4772
- source: legacy.key,
4773
- deliberate: false,
4774
- deprecated: true,
4775
- diskCandidates: diskPaths,
4776
- warning: message
4777
- });
4778
- }
4779
- return null;
4780
- }
4781
- var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
4782
- var AUTHORITY_OVERRIDE_HEADERS = new Set([
4783
- "host",
4784
- ":authority",
4785
- "forwarded",
4786
- "x-forwarded-host",
4787
- "x-original-host"
4788
- ]);
4789
-
4790
4628
  // src/config.ts
4791
4629
  import { join as join2 } from "path";
4792
- import { homedir } from "os";
4793
4630
  function getHooksDataDir() {
4794
- const explicit = process.env.HASNA_HOOKS_DATA_DIR ?? process.env.HOOKS_DATA_DIR;
4795
- if (explicit)
4796
- return explicit;
4797
- return join2(homedir(), ".hasna", "hooks");
4631
+ return getEffectiveDataRoot();
4798
4632
  }
4799
4633
  function getCustomHooksDir() {
4800
4634
  return join2(getHooksDataDir(), "hooks");
@@ -4805,9 +4639,6 @@ function getLockPath() {
4805
4639
  return explicit;
4806
4640
  return join2(getHooksDataDir(), "hooks.lock");
4807
4641
  }
4808
- function resolveApiKey() {
4809
- return resolveCredential("hooks", process.env)?.apiKey;
4810
- }
4811
4642
 
4812
4643
  // src/lib/semver.ts
4813
4644
  var NUMERIC_ID = "(0|[1-9]\\d{0,15})";
@@ -4845,8 +4676,8 @@ function isWithin(child, parent) {
4845
4676
  return child === parent || child.startsWith(parent + sep);
4846
4677
  }
4847
4678
  function assertContainedLexical(target, root, raw) {
4848
- const lexical = resolve(target);
4849
- const rootLexical = resolve(root);
4679
+ const lexical = resolve2(target);
4680
+ const rootLexical = resolve2(root);
4850
4681
  if (!isWithin(lexical, rootLexical)) {
4851
4682
  throw new ScriptContainmentError(target, root, raw);
4852
4683
  }
@@ -4855,7 +4686,7 @@ function assertContainedLexical(target, root, raw) {
4855
4686
  function realpathNearest(path) {
4856
4687
  const tail = [];
4857
4688
  let cur = path;
4858
- while (!existsSync(cur)) {
4689
+ while (!existsSync2(cur)) {
4859
4690
  const parent = dirname(cur);
4860
4691
  if (parent === cur)
4861
4692
  break;
@@ -4896,10 +4727,10 @@ function resolveScript(manifest, manifestDir) {
4896
4727
  return { path: scriptRelFor(manifest), content: manifest.script };
4897
4728
  const rel = scriptRelFor(manifest);
4898
4729
  const resolved = assertContained(join3(manifestDir, rel), manifestDir, manifest.script);
4899
- if (!existsSync(resolved)) {
4730
+ if (!existsSync2(resolved)) {
4900
4731
  throw new Error(`manifest script file not found: ${resolved}`);
4901
4732
  }
4902
- return { path: rel, content: readFileSync2(resolved, "utf-8") };
4733
+ return { path: rel, content: readFileSync(resolved, "utf-8") };
4903
4734
  }
4904
4735
  function customHookDir(name) {
4905
4736
  return join3(getCustomHooksDir(), shortManifestName(name));
@@ -4907,10 +4738,10 @@ function customHookDir(name) {
4907
4738
  function readCustomManifest(name) {
4908
4739
  const dir = customHookDir(name);
4909
4740
  const manifestPath = join3(dir, "manifest.json");
4910
- if (!existsSync(manifestPath))
4741
+ if (!existsSync2(manifestPath))
4911
4742
  return;
4912
4743
  try {
4913
- const manifest = parseManifest(readFileSync2(manifestPath, "utf-8"));
4744
+ const manifest = parseManifest(readFileSync(manifestPath, "utf-8"));
4914
4745
  const script = resolveScript(manifest, dir);
4915
4746
  const scriptIsInline = isInlineScript(manifest);
4916
4747
  return { manifest, scriptPath: join3(dir, script.path), scriptContent: script.content, scriptIsInline };
@@ -4922,7 +4753,7 @@ function readCustomManifest(name) {
4922
4753
  }
4923
4754
  function listCustomHooks() {
4924
4755
  const dir = getCustomHooksDir();
4925
- if (!existsSync(dir))
4756
+ if (!existsSync2(dir))
4926
4757
  return [];
4927
4758
  const results = [];
4928
4759
  for (const entry of readdirSafe(dir)) {
@@ -4942,14 +4773,13 @@ function readdirSafe(dir) {
4942
4773
 
4943
4774
  // src/lib/store.ts
4944
4775
  import { createHash } from "crypto";
4945
- import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync2, renameSync } from "fs";
4776
+ import { existsSync as existsSync5, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2, renameSync } from "fs";
4946
4777
  import { readFile } from "fs/promises";
4947
4778
 
4948
4779
  // src/db/index.ts
4949
4780
  import { Database } from "bun:sqlite";
4950
- import { existsSync as existsSync3, mkdirSync as mkdirSync2, cpSync } from "fs";
4781
+ import { existsSync as existsSync4, mkdirSync as mkdirSync2, cpSync } from "fs";
4951
4782
  import { join as join5 } from "path";
4952
- import { homedir as homedir3 } from "os";
4953
4783
 
4954
4784
  // src/db/schema.ts
4955
4785
  var CREATE_HOOK_EVENTS_TABLE = `
@@ -5121,7 +4951,7 @@ function runMigrations(db) {
5121
4951
  }
5122
4952
 
5123
4953
  // src/db/legacy-import.ts
5124
- import { existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
4954
+ import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync2 } from "fs";
5125
4955
  import { join as join4 } from "path";
5126
4956
  import { homedir as homedir2 } from "os";
5127
4957
  var META_KEY = "legacy_import_done";
@@ -5147,7 +4977,7 @@ function nanoid() {
5147
4977
  function importJsonlFile(db, filePath) {
5148
4978
  let count = 0;
5149
4979
  try {
5150
- const lines = readFileSync3(filePath, "utf-8").split(`
4980
+ const lines = readFileSync2(filePath, "utf-8").split(`
5151
4981
  `).filter(Boolean);
5152
4982
  for (const line of lines) {
5153
4983
  try {
@@ -5173,7 +5003,7 @@ function importJsonlFile(db, filePath) {
5173
5003
  function importErrorsLog(db, filePath) {
5174
5004
  let count = 0;
5175
5005
  try {
5176
- const lines = readFileSync3(filePath, "utf-8").split(`
5006
+ const lines = readFileSync2(filePath, "utf-8").split(`
5177
5007
  `).filter(Boolean);
5178
5008
  const linePattern = /^\[(.+?)\]\s+(?:\[session:(\S+)\]\s+)?(.+?)\s+\u2014\s+(.+)$/;
5179
5009
  for (const line of lines) {
@@ -5198,13 +5028,13 @@ function importErrorsLog(db, filePath) {
5198
5028
  } catch {}
5199
5029
  return count;
5200
5030
  }
5201
- function runLegacyImport(db, homeDir2 = homedir2()) {
5031
+ function runLegacyImport(db, homeDir = homedir2()) {
5202
5032
  try {
5203
5033
  if (isAlreadyDone(db))
5204
5034
  return;
5205
5035
  let total = 0;
5206
- const claudeProjectsDir = join4(homeDir2, ".claude", "projects");
5207
- if (existsSync2(claudeProjectsDir)) {
5036
+ const claudeProjectsDir = join4(homeDir, ".claude", "projects");
5037
+ if (existsSync3(claudeProjectsDir)) {
5208
5038
  try {
5209
5039
  const projectDirs = readdirSync2(claudeProjectsDir);
5210
5040
  for (const dir of projectDirs) {
@@ -5251,27 +5081,24 @@ function runRetention(db, days) {
5251
5081
  // src/db/index.ts
5252
5082
  var instance = null;
5253
5083
  function resolveDataDir() {
5254
- const explicit = process.env.HASNA_HOOKS_DATA_DIR ?? process.env.HOOKS_DATA_DIR;
5255
- if (explicit)
5256
- return explicit;
5257
- const newDir = join5(homedir3(), ".hasna", "hooks");
5258
- const oldDir = join5(homedir3(), ".hooks");
5259
- if (!existsSync3(newDir) && existsSync3(oldDir)) {
5260
- mkdirSync2(join5(homedir3(), ".hasna"), { recursive: true });
5261
- cpSync(oldDir, newDir, { recursive: true });
5084
+ const effective = getEffectiveDataRoot();
5085
+ const oldDir = join5(getHomeDir(), ".hooks");
5086
+ if (!existsSync4(effective) && existsSync4(oldDir)) {
5087
+ mkdirSync2(effective, { recursive: true });
5088
+ cpSync(oldDir, effective, { recursive: true });
5262
5089
  }
5263
- return newDir;
5090
+ return effective;
5264
5091
  }
5265
5092
  function getDbPath() {
5266
5093
  const explicitDb = process.env.HASNA_HOOKS_DB_PATH ?? process.env.HOOKS_DB_PATH;
5267
5094
  if (explicitDb)
5268
5095
  return explicitDb;
5269
- const dataDir = resolveDataDir();
5270
- return join5(dataDir, "hooks.db");
5096
+ const dataDir2 = resolveDataDir();
5097
+ return join5(dataDir2, "hooks.db");
5271
5098
  }
5272
5099
  function ensureDir(dbPath) {
5273
5100
  const dir = dbPath.substring(0, dbPath.lastIndexOf("/"));
5274
- if (dir && !existsSync3(dir)) {
5101
+ if (dir && !existsSync4(dir)) {
5275
5102
  mkdirSync2(dir, { recursive: true });
5276
5103
  }
5277
5104
  }
@@ -5279,7 +5106,7 @@ function getDb() {
5279
5106
  if (instance)
5280
5107
  return instance;
5281
5108
  const dbPath = getDbPath();
5282
- const isNew = dbPath === ":memory:" || !existsSync3(dbPath);
5109
+ const isNew = dbPath === ":memory:" || !existsSync4(dbPath);
5283
5110
  ensureDir(dbPath);
5284
5111
  instance = new Database(dbPath);
5285
5112
  instance.exec("PRAGMA busy_timeout=5000");
@@ -5362,11 +5189,11 @@ function assertLockShape(parsed, path) {
5362
5189
  }
5363
5190
  function readLock() {
5364
5191
  const path = getLockPath();
5365
- if (!existsSync4(path))
5192
+ if (!existsSync5(path))
5366
5193
  return { hooks: {} };
5367
5194
  let raw;
5368
5195
  try {
5369
- raw = readFileSync4(path, "utf-8");
5196
+ raw = readFileSync3(path, "utf-8");
5370
5197
  } catch {
5371
5198
  throw new LockFileError(`hooks.lock (${path}) could not be read. Repair: fix permissions or move the file aside and run 'hooks sync'.`);
5372
5199
  }
@@ -5398,7 +5225,7 @@ function setPinnedHook(name, entry) {
5398
5225
  return writeLock(lock);
5399
5226
  }
5400
5227
  function retrustHook(name, scriptPath, version, source) {
5401
- const actual = sha256Of(readFileSync4(scriptPath));
5228
+ const actual = sha256Of(readFileSync3(scriptPath));
5402
5229
  const db = getDb();
5403
5230
  upsertHookRecord(db, {
5404
5231
  name,
@@ -5413,11 +5240,11 @@ function retrustHook(name, scriptPath, version, source) {
5413
5240
 
5414
5241
  // src/lib/resolve.ts
5415
5242
  import { dirname as dirname2, join as join6 } from "path";
5416
- import { existsSync as existsSync5 } from "fs";
5243
+ import { existsSync as existsSync6 } from "fs";
5417
5244
  import { fileURLToPath } from "url";
5418
5245
  function resolveBundledHooksDir(moduleDir = dirname2(fileURLToPath(import.meta.url))) {
5419
5246
  const sourceLayout = join6(moduleDir, "..", "..", "hooks");
5420
- if (existsSync5(join6(sourceLayout, "hook-gitguard")))
5247
+ if (existsSync6(join6(sourceLayout, "hook-gitguard")))
5421
5248
  return sourceLayout;
5422
5249
  return join6(moduleDir, "..", "hooks");
5423
5250
  }
@@ -5431,10 +5258,10 @@ function resolveHookDir(name) {
5431
5258
  const short = shortManifestName(name);
5432
5259
  const bundledBase = resolveBundledHooksDir();
5433
5260
  const direct = join6(bundledBase, short);
5434
- if (existsSync5(direct))
5261
+ if (existsSync6(direct))
5435
5262
  return direct;
5436
5263
  const prefixed = join6(bundledBase, `hook-${short}`);
5437
- return existsSync5(prefixed) ? prefixed : undefined;
5264
+ return existsSync6(prefixed) ? prefixed : undefined;
5438
5265
  }
5439
5266
  function resolveScriptPath(name) {
5440
5267
  const custom2 = readCustomManifest(name);
@@ -5444,7 +5271,7 @@ function resolveScriptPath(name) {
5444
5271
  if (!dir)
5445
5272
  return;
5446
5273
  const script = join6(dir, "src", "hook.ts");
5447
- return existsSync5(script) ? script : undefined;
5274
+ return existsSync6(script) ? script : undefined;
5448
5275
  }
5449
5276
  function resolveHookMeta(name) {
5450
5277
  const custom2 = readCustomManifest(name);
@@ -5497,9 +5324,554 @@ function resolveHook(name) {
5497
5324
 
5498
5325
  // src/serve.ts
5499
5326
  import { readFileSync as readFileSync5 } from "fs";
5500
- import { dirname as dirname3, join as join7 } from "path";
5327
+ import { dirname as dirname3, join as join8 } from "path";
5501
5328
  import { fileURLToPath as fileURLToPath2 } from "url";
5502
5329
 
5330
+ // ../contracts/dist/client/transport.js
5331
+ import { spawnSync } from "child_process";
5332
+ import { closeSync, fstatSync, openSync, readFileSync as readFileSync4 } from "fs";
5333
+ import { O_NOFOLLOW, O_NONBLOCK, O_RDONLY } from "constants";
5334
+ import { createRequire } from "module";
5335
+ import { hostname as osHostname } from "os";
5336
+ import { isAbsolute as isAbsolute2, join as join7 } from "path";
5337
+ function envToken(name) {
5338
+ return name.toUpperCase().replace(/-/g, "_");
5339
+ }
5340
+ function clientTransportEnvKeys(name) {
5341
+ const envSegment = envToken(name);
5342
+ return {
5343
+ apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
5344
+ apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
5345
+ };
5346
+ }
5347
+ function credentialOverrideEnvKey(name) {
5348
+ return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
5349
+ }
5350
+ var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
5351
+ function credentialPointerEnvKey(name) {
5352
+ return `HASNA_${envToken(name)}_API_KEY_REF`;
5353
+ }
5354
+
5355
+ class CredentialResolutionError extends Error {
5356
+ appName;
5357
+ attempted;
5358
+ constructor(appName, message, attempted) {
5359
+ super(message);
5360
+ this.name = "CredentialResolutionError";
5361
+ this.appName = appName;
5362
+ this.attempted = attempted;
5363
+ }
5364
+ }
5365
+
5366
+ class CredentialFileUnsafeError extends Error {
5367
+ path;
5368
+ constructor(path, reason) {
5369
+ super(`Refusing unsafe credential/config file ${path}: ${reason}.`);
5370
+ this.name = "CredentialFileUnsafeError";
5371
+ this.path = path;
5372
+ }
5373
+ }
5374
+ var HASNA_HOME_ENV_KEY = "HASNA_HOME";
5375
+ var HASNA_CONFIG_HOME_ENV_KEY = "HASNA_CONFIG_HOME";
5376
+ var KEYCHAIN_STATION_ENV_KEY = "HASNA_STATION";
5377
+ var HASNA_HOME_DIR = ".hasna";
5378
+ var CONFIG_SUBDIR = "config";
5379
+ var CREDENTIALS_FILE = "credentials";
5380
+ var KEYCHAIN_SECURITY_BIN = "/usr/bin/security";
5381
+ var KEYCHAIN_SERVICE_PREFIX = "hasna.credentials";
5382
+ var KEYCHAIN_ITEM_NOT_FOUND_STATUS = 44;
5383
+ var KEYCHAIN_SPAWN_TIMEOUT_MS = 1e4;
5384
+ var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
5385
+ var SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
5386
+ var SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
5387
+ var ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
5388
+ var VAULT_POINTER_SHAPE = /^[a-z0-9][a-z0-9-]*(?:\/[a-z0-9][a-z0-9-_.]*){2,}$/;
5389
+ function homeDir(env) {
5390
+ const home = env.HOME?.trim();
5391
+ return home ? home : null;
5392
+ }
5393
+ function absoluteOverride(env, key) {
5394
+ const value = env[key]?.trim();
5395
+ return value && isAbsolute2(value) ? value : null;
5396
+ }
5397
+ function hasnaHomeDir(env) {
5398
+ const override = absoluteOverride(env, HASNA_HOME_ENV_KEY);
5399
+ if (override)
5400
+ return override;
5401
+ const home = homeDir(env);
5402
+ return home ? join7(home, HASNA_HOME_DIR) : null;
5403
+ }
5404
+ function appConfigDir(name, env) {
5405
+ const configRoot = absoluteOverride(env, HASNA_CONFIG_HOME_ENV_KEY);
5406
+ if (configRoot)
5407
+ return join7(configRoot, name);
5408
+ const root = hasnaHomeDir(env);
5409
+ return root ? join7(root, name, CONFIG_SUBDIR) : null;
5410
+ }
5411
+ function credentialDiskSourceList(name, env, profile = null) {
5412
+ if (!SAFE_APP_SLUG.test(name))
5413
+ return [];
5414
+ const directory = appConfigDir(name, env);
5415
+ if (!directory)
5416
+ return [];
5417
+ const file = profile ? `${CREDENTIALS_FILE}-${profile}` : CREDENTIALS_FILE;
5418
+ return [{ path: join7(directory, file), tier: "disk" }];
5419
+ }
5420
+ function credentialDiskSources(name, env) {
5421
+ return credentialDiskSourceList(name, env, null).map((s) => s.path);
5422
+ }
5423
+ function profileDiskSources(name, env, profile) {
5424
+ return credentialDiskSourceList(name, env, profile).map((s) => s.path);
5425
+ }
5426
+ function parseEnvFile(text) {
5427
+ const values = new Map;
5428
+ const unusable = new Set;
5429
+ for (const rawLine of text.split(/\r?\n/)) {
5430
+ const line = rawLine.trim();
5431
+ if (line.length === 0 || line.startsWith("#"))
5432
+ continue;
5433
+ const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
5434
+ const equals = withoutExport.indexOf("=");
5435
+ if (equals <= 0)
5436
+ continue;
5437
+ const key = withoutExport.slice(0, equals).trim();
5438
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
5439
+ continue;
5440
+ let value = withoutExport.slice(equals + 1).trim();
5441
+ const quote = value[0];
5442
+ if (quote === '"' || quote === "'") {
5443
+ if (value.length < 2 || !value.endsWith(quote)) {
5444
+ unusable.add(key);
5445
+ continue;
5446
+ }
5447
+ value = value.slice(1, -1);
5448
+ }
5449
+ if (value.trim().length === 0) {
5450
+ unusable.add(key);
5451
+ continue;
5452
+ }
5453
+ if (values.has(key) && values.get(key) !== value)
5454
+ unusable.add(key);
5455
+ values.set(key, value);
5456
+ }
5457
+ return { values, unusable };
5458
+ }
5459
+ function configFileModeAllowed(mode) {
5460
+ const permissions = mode & 4095;
5461
+ return permissions === 256 || permissions === 384;
5462
+ }
5463
+ function configFileReadsCoherent(before, after) {
5464
+ return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs && before.ctimeMs === after.ctimeMs;
5465
+ }
5466
+ function readAppConfigFile(path) {
5467
+ const unsafe = (reason) => {
5468
+ throw new CredentialFileUnsafeError(path, reason);
5469
+ };
5470
+ let fd = -1;
5471
+ try {
5472
+ fd = openSync(path, O_RDONLY | O_NOFOLLOW | O_NONBLOCK);
5473
+ } catch (error) {
5474
+ const code = error.code;
5475
+ if (code === "ENOENT" || code === "ENOTDIR")
5476
+ return null;
5477
+ if (code === "ELOOP")
5478
+ unsafe("the path is a symlink");
5479
+ unsafe(`the path could not be opened (${code ?? "unknown error"})`);
5480
+ }
5481
+ try {
5482
+ const before = fstatSync(fd);
5483
+ if (!before.isFile())
5484
+ unsafe("the path is not a regular file");
5485
+ if (!configFileModeAllowed(before.mode)) {
5486
+ unsafe(`permission mode ${(before.mode & 4095).toString(8).padStart(4, "0")} is not owner-only 0400 or 0600`);
5487
+ }
5488
+ const uid = process.getuid?.() ?? process.geteuid?.();
5489
+ if (uid !== undefined && before.uid !== uid)
5490
+ unsafe("the file is not owned by the current user");
5491
+ if (before.size > MAX_CREDENTIAL_FILE_BYTES)
5492
+ unsafe("the file exceeds the size limit");
5493
+ const bytes = readFileSync4(fd);
5494
+ const after = fstatSync(fd);
5495
+ if (!configFileReadsCoherent(before, after)) {
5496
+ unsafe("the file changed while being read");
5497
+ }
5498
+ return parseEnvFile(bytes.toString("utf8"));
5499
+ } finally {
5500
+ if (fd !== -1)
5501
+ closeSync(fd);
5502
+ }
5503
+ }
5504
+ function readCredentialFile(path, apiKeyKeys) {
5505
+ const parsed = readAppConfigFile(path);
5506
+ if (!parsed)
5507
+ return null;
5508
+ for (const key of apiKeyKeys) {
5509
+ if (parsed.unusable.has(key)) {
5510
+ throw new CredentialFileUnsafeError(path, `${key} is declared but blank or malformed`);
5511
+ }
5512
+ }
5513
+ const values = apiKeyKeys.map((key) => parsed.values.get(key)?.trim()).filter((value) => Boolean(value));
5514
+ if (new Set(values).size > 1) {
5515
+ throw new CredentialFileUnsafeError(path, "credential aliases disagree");
5516
+ }
5517
+ return values[0] ?? null;
5518
+ }
5519
+ function assertUsableCredential(appName, source, value) {
5520
+ if (VAULT_POINTER_SHAPE.test(value)) {
5521
+ 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]);
5522
+ }
5523
+ if (!ILLEGAL_IN_HEADER_VALUE.test(value))
5524
+ return;
5525
+ 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]);
5526
+ }
5527
+ var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
5528
+ var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
5529
+ function sealCredential(fields) {
5530
+ const { apiKey } = fields;
5531
+ const visible = {
5532
+ tier: fields.tier,
5533
+ source: fields.source,
5534
+ deliberate: fields.deliberate,
5535
+ diskCandidates: Object.freeze([...fields.diskCandidates]),
5536
+ warning: fields.warning
5537
+ };
5538
+ const sealed = { ...visible };
5539
+ Object.defineProperty(sealed, "apiKey", {
5540
+ value: apiKey,
5541
+ enumerable: false,
5542
+ writable: false,
5543
+ configurable: false
5544
+ });
5545
+ if (fields.pointerVaultKey !== undefined) {
5546
+ Object.defineProperty(sealed, "pointerVaultKey", {
5547
+ value: fields.pointerVaultKey,
5548
+ enumerable: false,
5549
+ writable: false,
5550
+ configurable: false
5551
+ });
5552
+ }
5553
+ Object.defineProperty(sealed, INSPECT_CUSTOM, {
5554
+ value: () => ({ ...visible, apiKey: "[redacted]" }),
5555
+ enumerable: false,
5556
+ writable: false,
5557
+ configurable: false
5558
+ });
5559
+ Object.defineProperty(sealed, CREDENTIAL_SEAL, {
5560
+ value: true,
5561
+ enumerable: false,
5562
+ writable: false,
5563
+ configurable: false
5564
+ });
5565
+ return Object.freeze(sealed);
5566
+ }
5567
+ function firstEnvValue(env, keys) {
5568
+ for (const key of keys) {
5569
+ if (!Object.prototype.hasOwnProperty.call(env, key))
5570
+ continue;
5571
+ const value = env[key]?.trim();
5572
+ if (value)
5573
+ return { key, value };
5574
+ }
5575
+ return null;
5576
+ }
5577
+ var AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
5578
+ function isAmbientEnvironment(env) {
5579
+ return env === process.env || env[AMBIENT_ENVIRONMENT] === true;
5580
+ }
5581
+ function defaultKeychainRunner(argv) {
5582
+ const result = spawnSync(KEYCHAIN_SECURITY_BIN, [...argv], {
5583
+ encoding: "utf8",
5584
+ stdio: ["ignore", "pipe", "pipe"],
5585
+ timeout: KEYCHAIN_SPAWN_TIMEOUT_MS
5586
+ });
5587
+ return {
5588
+ status: result.status,
5589
+ stdout: result.stdout ?? "",
5590
+ stderr: result.error ? result.error.message : result.stderr ?? ""
5591
+ };
5592
+ }
5593
+ function keychainTierEnabled(env, options) {
5594
+ if ((options.platform ?? process.platform) !== "darwin")
5595
+ return false;
5596
+ if (options.enabled !== undefined)
5597
+ return options.enabled;
5598
+ return options.run !== undefined || isAmbientEnvironment(env);
5599
+ }
5600
+ function keychainAccount(env, options) {
5601
+ const station = env[KEYCHAIN_STATION_ENV_KEY]?.trim();
5602
+ if (station)
5603
+ return station;
5604
+ const host = (options.hostname ?? osHostname)().split(".")[0]?.trim() ?? "";
5605
+ if (host)
5606
+ return host;
5607
+ const user = env.USER?.trim();
5608
+ return user || null;
5609
+ }
5610
+ function keychainFailureHint(text) {
5611
+ const line = text.split(/\r?\n/).find((entry) => entry.trim().length > 0)?.trim() ?? "";
5612
+ const clean = line.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 200);
5613
+ return clean ? `: ${clean}` : "";
5614
+ }
5615
+ function readKeychainItem(name, env, kind, options) {
5616
+ if (!SAFE_APP_SLUG.test(name) || !keychainTierEnabled(env, options))
5617
+ return null;
5618
+ const account = keychainAccount(env, options);
5619
+ if (!account)
5620
+ return null;
5621
+ const service = `${KEYCHAIN_SERVICE_PREFIX}.${name}.${kind}`;
5622
+ const source = `keychain:${service}@${account}`;
5623
+ const run = options.run ?? defaultKeychainRunner;
5624
+ let result;
5625
+ try {
5626
+ result = run(["find-generic-password", "-a", account, "-s", service, "-w"]);
5627
+ } catch (error) {
5628
+ const reason = keychainFailureHint(error instanceof Error ? error.message : String(error));
5629
+ 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]);
5630
+ }
5631
+ if (result.status === KEYCHAIN_ITEM_NOT_FOUND_STATUS)
5632
+ return null;
5633
+ if (result.status !== 0) {
5634
+ 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]);
5635
+ }
5636
+ const value = result.stdout.trim();
5637
+ if (!value) {
5638
+ 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]);
5639
+ }
5640
+ return { value, source };
5641
+ }
5642
+ function snapshotClientEnvironment(name, env) {
5643
+ const keys = clientTransportEnvKeys(name);
5644
+ const ambient = isAmbientEnvironment(env);
5645
+ const snapshot = Object.create(null);
5646
+ for (const key of [
5647
+ ...keys.apiUrlKeys,
5648
+ ...keys.apiKeyKeys,
5649
+ credentialOverrideEnvKey(name),
5650
+ credentialPointerEnvKey(name),
5651
+ CREDENTIAL_PROFILE_ENV_KEY,
5652
+ "HOME",
5653
+ HASNA_HOME_ENV_KEY,
5654
+ HASNA_CONFIG_HOME_ENV_KEY,
5655
+ KEYCHAIN_STATION_ENV_KEY,
5656
+ "USER"
5657
+ ]) {
5658
+ const descriptor = Object.getOwnPropertyDescriptor(env, key);
5659
+ if (!descriptor)
5660
+ continue;
5661
+ if (!("value" in descriptor)) {
5662
+ throw new CredentialResolutionError(name, `${key} is accessor-backed; client configuration requires own data properties.`, [key]);
5663
+ }
5664
+ if (descriptor.value !== undefined && typeof descriptor.value !== "string") {
5665
+ throw new CredentialResolutionError(name, `${key} must be a string data property.`, [key]);
5666
+ }
5667
+ snapshot[key] = descriptor.value;
5668
+ }
5669
+ if (ambient) {
5670
+ Object.defineProperty(snapshot, AMBIENT_ENVIRONMENT, {
5671
+ value: true,
5672
+ enumerable: false,
5673
+ writable: false,
5674
+ configurable: false
5675
+ });
5676
+ }
5677
+ return Object.freeze(snapshot);
5678
+ }
5679
+ function resolveCredential(name, env, options = {}) {
5680
+ env = snapshotClientEnvironment(name, env);
5681
+ const { apiKeyKeys } = clientTransportEnvKeys(name);
5682
+ const diskPaths = credentialDiskSources(name, env);
5683
+ if (options.apiKey !== undefined) {
5684
+ const explicitKey = options.apiKey.trim();
5685
+ if (!explicitKey) {
5686
+ throw new CredentialResolutionError(name, "The explicit apiKey argument is blank; an explicit credential never falls through to another identity.", ["explicit apiKey argument"]);
5687
+ }
5688
+ assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
5689
+ return sealCredential({
5690
+ apiKey: explicitKey,
5691
+ tier: "argument",
5692
+ source: "explicit apiKey argument",
5693
+ deliberate: true,
5694
+ diskCandidates: diskPaths,
5695
+ warning: null
5696
+ });
5697
+ }
5698
+ const overrideKeyName = credentialOverrideEnvKey(name);
5699
+ const overrideRaw = Object.prototype.hasOwnProperty.call(env, overrideKeyName) ? env[overrideKeyName] : undefined;
5700
+ if (overrideRaw !== undefined) {
5701
+ const override = overrideRaw.trim();
5702
+ if (!override) {
5703
+ 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]);
5704
+ }
5705
+ assertUsableCredential(name, overrideKeyName, override);
5706
+ return sealCredential({
5707
+ apiKey: override,
5708
+ tier: "override",
5709
+ source: overrideKeyName,
5710
+ deliberate: true,
5711
+ diskCandidates: diskPaths,
5712
+ warning: null
5713
+ });
5714
+ }
5715
+ const pointerKeyName = credentialPointerEnvKey(name);
5716
+ const pointerRaw = Object.prototype.hasOwnProperty.call(env, pointerKeyName) ? env[pointerKeyName] : undefined;
5717
+ if (pointerRaw !== undefined) {
5718
+ const pointer = pointerRaw.trim();
5719
+ if (!pointer) {
5720
+ 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]);
5721
+ }
5722
+ if (!VAULT_POINTER_SHAPE.test(pointer)) {
5723
+ 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]);
5724
+ }
5725
+ return sealCredential({
5726
+ apiKey: "",
5727
+ pointerVaultKey: pointer,
5728
+ tier: "pointer",
5729
+ source: pointerKeyName,
5730
+ deliberate: true,
5731
+ diskCandidates: diskPaths,
5732
+ warning: null
5733
+ });
5734
+ }
5735
+ if (options.profile !== undefined && !options.profile.trim()) {
5736
+ throw new CredentialResolutionError(name, "The explicit profile argument is blank; an explicit identity selection never falls through.", ["explicit profile argument"]);
5737
+ }
5738
+ const profileRaw = Object.prototype.hasOwnProperty.call(env, CREDENTIAL_PROFILE_ENV_KEY) ? env[CREDENTIAL_PROFILE_ENV_KEY] : undefined;
5739
+ if (profileRaw !== undefined && !profileRaw.trim()) {
5740
+ throw new CredentialResolutionError(name, `${CREDENTIAL_PROFILE_ENV_KEY} is set but blank.`, [CREDENTIAL_PROFILE_ENV_KEY]);
5741
+ }
5742
+ const profile = options.profile?.trim() || profileRaw?.trim();
5743
+ if (profile) {
5744
+ const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
5745
+ if (!SAFE_PROFILE.test(profile)) {
5746
+ throw new CredentialResolutionError(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
5747
+ }
5748
+ const paths = profileDiskSources(name, env, profile);
5749
+ for (const path of paths) {
5750
+ const value = readCredentialFile(path, apiKeyKeys);
5751
+ if (value) {
5752
+ assertUsableCredential(name, path, value);
5753
+ return sealCredential({
5754
+ apiKey: value,
5755
+ tier: "profile",
5756
+ source: path,
5757
+ deliberate: true,
5758
+ diskCandidates: paths,
5759
+ warning: null
5760
+ });
5761
+ }
5762
+ }
5763
+ 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);
5764
+ }
5765
+ const definedEnvEntries = apiKeyKeys.filter((key) => Object.prototype.hasOwnProperty.call(env, key) && env[key] !== undefined).map((key) => ({ key, value: String(env[key]).trim() }));
5766
+ const blankEnv = definedEnvEntries.find((entry) => entry.value.length === 0);
5767
+ if (blankEnv) {
5768
+ throw new CredentialResolutionError(name, `${blankEnv.key} is set but blank; a declared credential never falls through to another alias or identity.`, [blankEnv.key]);
5769
+ }
5770
+ if (definedEnvEntries.length > 1 && new Set(definedEnvEntries.map((entry) => entry.value)).size > 1) {
5771
+ 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));
5772
+ }
5773
+ const envHit = firstEnvValue(env, apiKeyKeys);
5774
+ const keychainHit = readKeychainItem(name, env, "api-key", options.keychain ?? {});
5775
+ if (keychainHit) {
5776
+ assertUsableCredential(name, keychainHit.source, keychainHit.value);
5777
+ 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;
5778
+ return sealCredential({
5779
+ apiKey: keychainHit.value,
5780
+ tier: "keychain",
5781
+ source: keychainHit.source,
5782
+ deliberate: false,
5783
+ diskCandidates: diskPaths,
5784
+ warning
5785
+ });
5786
+ }
5787
+ const diskSourceList = credentialDiskSourceList(name, env, null);
5788
+ const diskHits = diskSourceList.map((src) => ({ src, value: readCredentialFile(src.path, apiKeyKeys) })).filter((hit) => hit.value !== null);
5789
+ if (diskHits.length > 0) {
5790
+ const winner = diskHits[0];
5791
+ assertUsableCredential(name, winner.src.path, winner.value);
5792
+ const divergentSources = [
5793
+ ...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.src.path),
5794
+ ...envHit && envHit.value !== winner.value ? [envHit.key] : []
5795
+ ];
5796
+ 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;
5797
+ return sealCredential({
5798
+ apiKey: winner.value,
5799
+ tier: winner.src.tier,
5800
+ source: winner.src.path,
5801
+ deliberate: false,
5802
+ diskCandidates: diskPaths,
5803
+ warning
5804
+ });
5805
+ }
5806
+ if (envHit) {
5807
+ assertUsableCredential(name, envHit.key, envHit.value);
5808
+ return sealCredential({
5809
+ apiKey: envHit.value,
5810
+ tier: "env",
5811
+ source: envHit.key,
5812
+ deliberate: false,
5813
+ diskCandidates: diskPaths,
5814
+ warning: null
5815
+ });
5816
+ }
5817
+ return null;
5818
+ }
5819
+ var SECRETS_PACKAGE_SPECIFIER = "@hasna/" + "secrets";
5820
+ var requireSecretsSdk = createRequire(import.meta.url);
5821
+ var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
5822
+ var AUTHORITY_OVERRIDE_HEADERS = new Set([
5823
+ "host",
5824
+ ":authority",
5825
+ "forwarded",
5826
+ "x-forwarded-host",
5827
+ "x-original-host"
5828
+ ]);
5829
+
5830
+ // src/lib/local-opt-in.ts
5831
+ function hooksAuthorityEnvKeys() {
5832
+ const keys = clientTransportEnvKeys("hooks");
5833
+ return [
5834
+ ...keys.apiUrlKeys,
5835
+ ...keys.apiKeyKeys,
5836
+ credentialOverrideEnvKey("hooks"),
5837
+ credentialPointerEnvKey("hooks"),
5838
+ CREDENTIAL_PROFILE_ENV_KEY
5839
+ ];
5840
+ }
5841
+ function hooksResolverEnv(env) {
5842
+ const blanks = hooksAuthorityEnvKeys().filter((key) => (key in env) && (env[key] ?? "").trim() === "");
5843
+ if (blanks.length === 0)
5844
+ return env;
5845
+ const next = { ...env };
5846
+ for (const key of blanks)
5847
+ delete next[key];
5848
+ return next;
5849
+ }
5850
+ var CONTRACTS_AMBIENT_ENVIRONMENT = Symbol.for("hasna:contracts:ambientClientEnvironment");
5851
+ function isAmbientHooksEnv(env) {
5852
+ if (typeof process !== "undefined" && env === process.env)
5853
+ return true;
5854
+ return env[CONTRACTS_AMBIENT_ENVIRONMENT] === true;
5855
+ }
5856
+ function hooksResolverInputs(env, credentials = {}) {
5857
+ const normalised = hooksResolverEnv(env);
5858
+ if (normalised === env)
5859
+ return { env: normalised, credentials };
5860
+ const keychain = { ...credentials.keychain };
5861
+ if (keychain.enabled === undefined && keychain.run === undefined) {
5862
+ keychain.enabled = isAmbientHooksEnv(env);
5863
+ }
5864
+ return { env: normalised, credentials: { ...credentials, keychain } };
5865
+ }
5866
+
5867
+ // src/lib/transport.ts
5868
+ function resolveHooksServePublishKey(env = process.env, options = {}) {
5869
+ const { env: resolverEnv, credentials } = hooksResolverInputs(env, options.credentials);
5870
+ const tier1 = options.credentials?.apiKey;
5871
+ const credentialOptions = tier1 !== undefined ? { ...credentials, apiKey: tier1 } : credentials;
5872
+ return resolveCredential("hooks", resolverEnv, credentialOptions)?.apiKey;
5873
+ }
5874
+
5503
5875
  // src/lib/secure-compare.ts
5504
5876
  function secureEqual(a, b) {
5505
5877
  const enc = new TextEncoder;
@@ -5658,7 +6030,7 @@ var openApiDocument = {
5658
6030
  apiKey: {
5659
6031
  type: "http",
5660
6032
  scheme: "bearer",
5661
- description: "The registry API key (HASNA_HOOKS_API_KEY / HOOKS_API_KEY)."
6033
+ 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."
5662
6034
  }
5663
6035
  }
5664
6036
  }
@@ -5670,7 +6042,7 @@ var SERVE_HOST = "127.0.0.1";
5670
6042
  var SERVE_SERVICE_NAME = "hooks-registry";
5671
6043
  function packageVersion() {
5672
6044
  try {
5673
- const pkgPath = join7(dirname3(fileURLToPath2(import.meta.url)), "..", "package.json");
6045
+ const pkgPath = join8(dirname3(fileURLToPath2(import.meta.url)), "..", "package.json");
5674
6046
  return JSON.parse(readFileSync5(pkgPath, "utf-8")).version || "0.0.0";
5675
6047
  } catch {
5676
6048
  return "0.0.0";
@@ -5819,15 +6191,20 @@ function handleServeRequest(req, apiKey) {
5819
6191
  }
5820
6192
  return Promise.resolve(json({ error: "Not Found" }, 404));
5821
6193
  }
6194
+ function resolveServeOptions(options) {
6195
+ const envPort = process.env.PORT?.trim();
6196
+ const envHost = process.env.HOST?.trim();
6197
+ const port = options.port ?? (envPort && /^[0-9]+$/.test(envPort) ? parseInt(envPort, 10) : DEFAULT_SERVE_PORT);
6198
+ const host = options.host ?? (envHost && envHost.length > 0 ? envHost : SERVE_HOST);
6199
+ return { port, host };
6200
+ }
5822
6201
  function startServeServer(options) {
5823
- const port = options.port ?? DEFAULT_SERVE_PORT;
5824
- const host = options.host ?? SERVE_HOST;
5825
- const apiKey = resolveApiKey();
6202
+ const { port, host } = resolveServeOptions(options);
5826
6203
  const server = Bun.serve({
5827
6204
  hostname: host,
5828
6205
  port,
5829
6206
  async fetch(req) {
5830
- return handleServeRequest(req, apiKey);
6207
+ return handleServeRequest(req, resolveHooksServePublishKey());
5831
6208
  }
5832
6209
  });
5833
6210
  console.error(`hooks registry listening on http://${host}:${port} (publish requires an API key)`);
@@ -5862,6 +6239,7 @@ options:
5862
6239
  }
5863
6240
  export {
5864
6241
  startServeServer,
6242
+ resolveServeOptions,
5865
6243
  handleServeRequest,
5866
6244
  SERVE_SERVICE_NAME,
5867
6245
  SERVE_HOST,