@hasna/hooks 0.7.10 → 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 (49) hide show
  1. package/README.md +32 -6
  2. package/bin/index.js +1872 -1300
  3. package/bin/serve.js +730 -551
  4. package/dist/config.d.ts +25 -19
  5. package/dist/index.d.ts +3 -2
  6. package/dist/index.js +1198 -712
  7. package/dist/lib/app-home.d.ts +9 -0
  8. package/dist/lib/installer.d.ts +0 -9
  9. package/dist/lib/local-opt-in.d.ts +80 -0
  10. package/dist/lib/profiles.d.ts +10 -0
  11. package/dist/lib/resolver-types.d.ts +45 -0
  12. package/dist/lib/sync.d.ts +13 -8
  13. package/dist/lib/transport.d.ts +87 -0
  14. package/dist/openapi.d.ts +1 -1
  15. package/dist/storage.js +54 -63
  16. package/hooks/hook-affected-tests/package.json +1 -1
  17. package/hooks/hook-agent-rules-version-check/package.json +1 -1
  18. package/hooks/hook-agentmessages/package.json +1 -1
  19. package/hooks/hook-announce-start/package.json +1 -1
  20. package/hooks/hook-announce-stop/package.json +1 -1
  21. package/hooks/hook-autoformat/package.json +1 -1
  22. package/hooks/hook-branchprotect/package.json +1 -1
  23. package/hooks/hook-checkbugs/package.json +1 -1
  24. package/hooks/hook-checkdocs/package.json +1 -1
  25. package/hooks/hook-checkfiles/package.json +1 -1
  26. package/hooks/hook-checklint/package.json +1 -1
  27. package/hooks/hook-checkpoint/package.json +1 -1
  28. package/hooks/hook-checksecurity/package.json +1 -1
  29. package/hooks/hook-checktasks/package.json +1 -1
  30. package/hooks/hook-checktests/package.json +1 -1
  31. package/hooks/hook-conflict-detect/package.json +1 -1
  32. package/hooks/hook-contextrefresh/package.json +1 -1
  33. package/hooks/hook-desktopnotify/package.json +1 -1
  34. package/hooks/hook-dm-inject/package.json +1 -1
  35. package/hooks/hook-envsetup/package.json +1 -1
  36. package/hooks/hook-failure-to-task/package.json +1 -1
  37. package/hooks/hook-filelock/package.json +1 -1
  38. package/hooks/hook-fleet-blockers-gate/package.json +1 -1
  39. package/hooks/hook-fleet-catchup/package.json +1 -1
  40. package/hooks/hook-gitguard/package.json +1 -1
  41. package/hooks/hook-packageage/package.json +1 -1
  42. package/hooks/hook-permissionguard/package.json +1 -1
  43. package/hooks/hook-phonenotify/package.json +1 -1
  44. package/hooks/hook-precompact/package.json +1 -1
  45. package/hooks/hook-protectfiles/package.json +1 -1
  46. package/hooks/hook-stylescheck/package.json +1 -1
  47. package/hooks/hook-typecheck-gate/package.json +1 -1
  48. package/package.json +4 -9
  49. package/scripts/ensure-profiles-dir.mjs +52 -1
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;
@@ -656,8 +649,8 @@ function getHook(name) {
656
649
  }
657
650
 
658
651
  // 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";
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";
661
654
 
662
655
  // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/external.js
663
656
  var exports_external = {};
@@ -4632,519 +4625,161 @@ var coerce = {
4632
4625
  date: (arg) => ZodDate.create({ ...arg, coerce: true })
4633
4626
  };
4634
4627
  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
- };
4628
+ // src/config.ts
4629
+ import { join as join2 } from "path";
4630
+ function getHooksDataDir() {
4631
+ return getEffectiveDataRoot();
4648
4632
  }
4649
- function credentialOverrideEnvKey(name) {
4650
- return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
4633
+ function getCustomHooksDir() {
4634
+ return join2(getHooksDataDir(), "hooks");
4651
4635
  }
4652
- var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
4653
- function credentialPointerEnvKey(name) {
4654
- return `HASNA_${envToken(name)}_API_KEY_REF`;
4636
+ function getLockPath() {
4637
+ const explicit = process.env.HASNA_HOOKS_LOCK_PATH ?? process.env.HOOKS_LOCK_PATH;
4638
+ if (explicit)
4639
+ return explicit;
4640
+ return join2(getHooksDataDir(), "hooks.lock");
4655
4641
  }
4656
4642
 
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
- }
4643
+ // src/lib/semver.ts
4644
+ var NUMERIC_ID = "(0|[1-9]\\d{0,15})";
4645
+ var ALNUM_ID = "\\d*[A-Za-z-][0-9A-Za-z-]*";
4646
+ var SEMVER_PATTERN = new RegExp(`^${NUMERIC_ID}\\.${NUMERIC_ID}\\.${NUMERIC_ID}` + `(?:-(?:${NUMERIC_ID}|${ALNUM_ID})(?:\\.(?:${NUMERIC_ID}|${ALNUM_ID}))*)?` + `(?:\\+[0-9A-Za-z.-]+)?$`);
4647
+
4648
+ // src/lib/manifest.ts
4649
+ var HOOK_NAME_RE = /^[\w-]+$/;
4650
+ var manifestSchema = exports_external.object({
4651
+ name: exports_external.string().regex(HOOK_NAME_RE, "hook name must be /^[\\w-]+$/"),
4652
+ version: exports_external.string().regex(SEMVER_PATTERN, "version must be full semver (e.g. 1.2.3 or 1.2.3-beta.1)"),
4653
+ description: exports_external.string().optional(),
4654
+ events: exports_external.array(exports_external.string()).min(1, "at least one event is required"),
4655
+ script: exports_external.string().min(1, "script must name a relative path or inline content"),
4656
+ script_kind: exports_external.enum(["inline", "file"]).optional(),
4657
+ args: exports_external.array(exports_external.string()).optional(),
4658
+ timeout_ms: exports_external.number().int().positive().optional(),
4659
+ env: exports_external.record(exports_external.string(), exports_external.string()).optional()
4660
+ });
4661
+ function parseManifest(json) {
4662
+ const parsed = JSON.parse(json);
4663
+ return manifestSchema.parse(parsed);
4666
4664
  }
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;
4665
+ function shortManifestName(name) {
4666
+ return name.startsWith("hook-") ? name.slice("hook-".length) : name;
4681
4667
  }
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
- ];
4668
+
4669
+ class ScriptContainmentError extends Error {
4670
+ constructor(path, hookDir, raw) {
4671
+ 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}'`);
4672
+ this.name = "ScriptContainmentError";
4673
+ }
4710
4674
  }
4711
- function credentialDiskSources(name, env) {
4712
- return credentialDiskSourceList(name, env, null).map((s) => s.path);
4675
+ function isWithin(child, parent) {
4676
+ return child === parent || child.startsWith(parent + sep);
4713
4677
  }
4714
- function profileDiskSources(name, env, profile) {
4715
- return credentialDiskSourceList(name, env, profile).map((s) => s.path);
4678
+ function assertContainedLexical(target, root, raw) {
4679
+ const lexical = resolve2(target);
4680
+ const rootLexical = resolve2(root);
4681
+ if (!isWithin(lexical, rootLexical)) {
4682
+ throw new ScriptContainmentError(target, root, raw);
4683
+ }
4684
+ return lexical;
4716
4685
  }
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);
4686
+ function realpathNearest(path) {
4687
+ const tail = [];
4688
+ let cur = path;
4689
+ while (!existsSync2(cur)) {
4690
+ const parent = dirname(cur);
4691
+ if (parent === cur)
4692
+ break;
4693
+ tail.unshift(basename(cur));
4694
+ cur = parent;
4740
4695
  }
4741
- return values;
4696
+ return join3(realpathSync(cur), ...tail);
4742
4697
  }
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;
4698
+ function assertContainedReal(root, target, raw) {
4699
+ const rootReal = realpathSync(root);
4700
+ const targetReal = realpathNearest(target);
4701
+ if (!isWithin(targetReal, rootReal)) {
4702
+ throw new ScriptContainmentError(target, root, raw);
4752
4703
  }
4753
- return parseEnvFile(text);
4754
4704
  }
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;
4705
+ function assertContained(target, root, raw) {
4706
+ const lexical = assertContainedLexical(target, root, raw);
4707
+ assertContainedReal(root, lexical, raw);
4708
+ return lexical;
4709
+ }
4710
+ function isInlineScript(manifest) {
4711
+ return manifest.script_kind === "inline" || manifest.script_kind === undefined && manifest.script.includes(`
4712
+ `);
4713
+ }
4714
+ function scriptRelFor(manifest) {
4715
+ if (isInlineScript(manifest)) {
4716
+ const ext = manifest.script.trim().startsWith("#!") ? ".sh" : ".ts";
4717
+ return `script${ext}`;
4763
4718
  }
4764
- return null;
4719
+ const rel = normalize(manifest.script);
4720
+ if (isAbsolute(rel)) {
4721
+ throw new Error(`manifest script must be relative or inline, got absolute path '${manifest.script}'`);
4722
+ }
4723
+ return rel;
4765
4724
  }
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]);
4725
+ function resolveScript(manifest, manifestDir) {
4726
+ if (isInlineScript(manifest))
4727
+ return { path: scriptRelFor(manifest), content: manifest.script };
4728
+ const rel = scriptRelFor(manifest);
4729
+ const resolved = assertContained(join3(manifestDir, rel), manifestDir, manifest.script);
4730
+ if (!existsSync2(resolved)) {
4731
+ throw new Error(`manifest script file not found: ${resolved}`);
4769
4732
  }
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]);
4733
+ return { path: rel, content: readFileSync(resolved, "utf-8") };
4773
4734
  }
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
- });
4735
+ function customHookDir(name) {
4736
+ return join3(getCustomHooksDir(), shortManifestName(name));
4737
+ }
4738
+ function readCustomManifest(name) {
4739
+ const dir = customHookDir(name);
4740
+ const manifestPath = join3(dir, "manifest.json");
4741
+ if (!existsSync2(manifestPath))
4742
+ return;
4743
+ try {
4744
+ const manifest = parseManifest(readFileSync(manifestPath, "utf-8"));
4745
+ const script = resolveScript(manifest, dir);
4746
+ const scriptIsInline = isInlineScript(manifest);
4747
+ return { manifest, scriptPath: join3(dir, script.path), scriptContent: script.content, scriptIsInline };
4748
+ } catch (err) {
4749
+ if (err instanceof ScriptContainmentError)
4750
+ throw err;
4751
+ return;
4800
4752
  }
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
4753
  }
4815
- function firstEnvValue(env, keys) {
4816
- for (const key of keys) {
4817
- const value = env[key]?.trim();
4818
- if (value)
4819
- return { key, value };
4754
+ function listCustomHooks() {
4755
+ const dir = getCustomHooksDir();
4756
+ if (!existsSync2(dir))
4757
+ return [];
4758
+ const results = [];
4759
+ for (const entry of readdirSafe(dir)) {
4760
+ const parsed = readCustomManifest(entry);
4761
+ if (parsed)
4762
+ results.push(parsed);
4820
4763
  }
4821
- return null;
4764
+ return results.sort((a, b) => a.manifest.name.localeCompare(b.manifest.name));
4822
4765
  }
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 [];
4766
+ function readdirSafe(dir) {
4767
+ try {
4768
+ return readdirSync(dir);
4769
+ } catch {
4770
+ return [];
5136
4771
  }
5137
4772
  }
5138
4773
 
5139
4774
  // src/lib/store.ts
5140
4775
  import { createHash } from "crypto";
5141
- import { existsSync as existsSync5, 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";
5142
4777
  import { readFile } from "fs/promises";
5143
4778
 
5144
4779
  // src/db/index.ts
5145
4780
  import { Database } from "bun:sqlite";
5146
4781
  import { existsSync as existsSync4, mkdirSync as mkdirSync2, cpSync } from "fs";
5147
- import { join as join7 } from "path";
4782
+ import { join as join5 } from "path";
5148
4783
 
5149
4784
  // src/db/schema.ts
5150
4785
  var CREATE_HOOK_EVENTS_TABLE = `
@@ -5316,9 +4951,9 @@ function runMigrations(db) {
5316
4951
  }
5317
4952
 
5318
4953
  // 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";
4954
+ import { existsSync as existsSync3, readdirSync as readdirSync2, readFileSync as readFileSync2 } from "fs";
4955
+ import { join as join4 } from "path";
4956
+ import { homedir as homedir2 } from "os";
5322
4957
  var META_KEY = "legacy_import_done";
5323
4958
  function ensureMetaTable(db) {
5324
4959
  db.exec(`
@@ -5342,7 +4977,7 @@ function nanoid() {
5342
4977
  function importJsonlFile(db, filePath) {
5343
4978
  let count = 0;
5344
4979
  try {
5345
- const lines = readFileSync3(filePath, "utf-8").split(`
4980
+ const lines = readFileSync2(filePath, "utf-8").split(`
5346
4981
  `).filter(Boolean);
5347
4982
  for (const line of lines) {
5348
4983
  try {
@@ -5368,7 +5003,7 @@ function importJsonlFile(db, filePath) {
5368
5003
  function importErrorsLog(db, filePath) {
5369
5004
  let count = 0;
5370
5005
  try {
5371
- const lines = readFileSync3(filePath, "utf-8").split(`
5006
+ const lines = readFileSync2(filePath, "utf-8").split(`
5372
5007
  `).filter(Boolean);
5373
5008
  const linePattern = /^\[(.+?)\]\s+(?:\[session:(\S+)\]\s+)?(.+?)\s+\u2014\s+(.+)$/;
5374
5009
  for (const line of lines) {
@@ -5393,25 +5028,25 @@ function importErrorsLog(db, filePath) {
5393
5028
  } catch {}
5394
5029
  return count;
5395
5030
  }
5396
- function runLegacyImport(db, homeDir2 = homedir3()) {
5031
+ function runLegacyImport(db, homeDir = homedir2()) {
5397
5032
  try {
5398
5033
  if (isAlreadyDone(db))
5399
5034
  return;
5400
5035
  let total = 0;
5401
- const claudeProjectsDir = join6(homeDir2, ".claude", "projects");
5036
+ const claudeProjectsDir = join4(homeDir, ".claude", "projects");
5402
5037
  if (existsSync3(claudeProjectsDir)) {
5403
5038
  try {
5404
5039
  const projectDirs = readdirSync2(claudeProjectsDir);
5405
5040
  for (const dir of projectDirs) {
5406
- const projectDir = join6(claudeProjectsDir, dir);
5041
+ const projectDir = join4(claudeProjectsDir, dir);
5407
5042
  try {
5408
5043
  const files = readdirSync2(projectDir);
5409
5044
  for (const file of files) {
5410
5045
  if (file.match(/^session-log-\d{4}-\d{2}-\d{2}\.jsonl$/)) {
5411
- total += importJsonlFile(db, join6(projectDir, file));
5046
+ total += importJsonlFile(db, join4(projectDir, file));
5412
5047
  }
5413
5048
  if (file === "errors.log") {
5414
- total += importErrorsLog(db, join6(projectDir, file));
5049
+ total += importErrorsLog(db, join4(projectDir, file));
5415
5050
  }
5416
5051
  }
5417
5052
  } catch {}
@@ -5447,7 +5082,7 @@ function runRetention(db, days) {
5447
5082
  var instance = null;
5448
5083
  function resolveDataDir() {
5449
5084
  const effective = getEffectiveDataRoot();
5450
- const oldDir = join7(getHomeDir(), ".hooks");
5085
+ const oldDir = join5(getHomeDir(), ".hooks");
5451
5086
  if (!existsSync4(effective) && existsSync4(oldDir)) {
5452
5087
  mkdirSync2(effective, { recursive: true });
5453
5088
  cpSync(oldDir, effective, { recursive: true });
@@ -5459,7 +5094,7 @@ function getDbPath() {
5459
5094
  if (explicitDb)
5460
5095
  return explicitDb;
5461
5096
  const dataDir2 = resolveDataDir();
5462
- return join7(dataDir2, "hooks.db");
5097
+ return join5(dataDir2, "hooks.db");
5463
5098
  }
5464
5099
  function ensureDir(dbPath) {
5465
5100
  const dir = dbPath.substring(0, dbPath.lastIndexOf("/"));
@@ -5558,7 +5193,7 @@ function readLock() {
5558
5193
  return { hooks: {} };
5559
5194
  let raw;
5560
5195
  try {
5561
- raw = readFileSync4(path, "utf-8");
5196
+ raw = readFileSync3(path, "utf-8");
5562
5197
  } catch {
5563
5198
  throw new LockFileError(`hooks.lock (${path}) could not be read. Repair: fix permissions or move the file aside and run 'hooks sync'.`);
5564
5199
  }
@@ -5590,7 +5225,7 @@ function setPinnedHook(name, entry) {
5590
5225
  return writeLock(lock);
5591
5226
  }
5592
5227
  function retrustHook(name, scriptPath, version, source) {
5593
- const actual = sha256Of(readFileSync4(scriptPath));
5228
+ const actual = sha256Of(readFileSync3(scriptPath));
5594
5229
  const db = getDb();
5595
5230
  upsertHookRecord(db, {
5596
5231
  name,
@@ -5604,14 +5239,14 @@ function retrustHook(name, scriptPath, version, source) {
5604
5239
  }
5605
5240
 
5606
5241
  // src/lib/resolve.ts
5607
- import { dirname as dirname2, join as join8 } from "path";
5242
+ import { dirname as dirname2, join as join6 } from "path";
5608
5243
  import { existsSync as existsSync6 } from "fs";
5609
5244
  import { fileURLToPath } from "url";
5610
5245
  function resolveBundledHooksDir(moduleDir = dirname2(fileURLToPath(import.meta.url))) {
5611
- const sourceLayout = join8(moduleDir, "..", "..", "hooks");
5612
- if (existsSync6(join8(sourceLayout, "hook-gitguard")))
5246
+ const sourceLayout = join6(moduleDir, "..", "..", "hooks");
5247
+ if (existsSync6(join6(sourceLayout, "hook-gitguard")))
5613
5248
  return sourceLayout;
5614
- return join8(moduleDir, "..", "hooks");
5249
+ return join6(moduleDir, "..", "hooks");
5615
5250
  }
5616
5251
  function resolveHookDir(name) {
5617
5252
  const custom2 = readCustomManifest(name);
@@ -5622,10 +5257,10 @@ function resolveHookDir(name) {
5622
5257
  return;
5623
5258
  const short = shortManifestName(name);
5624
5259
  const bundledBase = resolveBundledHooksDir();
5625
- const direct = join8(bundledBase, short);
5260
+ const direct = join6(bundledBase, short);
5626
5261
  if (existsSync6(direct))
5627
5262
  return direct;
5628
- const prefixed = join8(bundledBase, `hook-${short}`);
5263
+ const prefixed = join6(bundledBase, `hook-${short}`);
5629
5264
  return existsSync6(prefixed) ? prefixed : undefined;
5630
5265
  }
5631
5266
  function resolveScriptPath(name) {
@@ -5635,7 +5270,7 @@ function resolveScriptPath(name) {
5635
5270
  const dir = resolveHookDir(name);
5636
5271
  if (!dir)
5637
5272
  return;
5638
- const script = join8(dir, "src", "hook.ts");
5273
+ const script = join6(dir, "src", "hook.ts");
5639
5274
  return existsSync6(script) ? script : undefined;
5640
5275
  }
5641
5276
  function resolveHookMeta(name) {
@@ -5689,9 +5324,554 @@ function resolveHook(name) {
5689
5324
 
5690
5325
  // src/serve.ts
5691
5326
  import { readFileSync as readFileSync5 } from "fs";
5692
- import { dirname as dirname3, join as join9 } from "path";
5327
+ import { dirname as dirname3, join as join8 } from "path";
5693
5328
  import { fileURLToPath as fileURLToPath2 } from "url";
5694
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
+
5695
5875
  // src/lib/secure-compare.ts
5696
5876
  function secureEqual(a, b) {
5697
5877
  const enc = new TextEncoder;
@@ -5850,7 +6030,7 @@ var openApiDocument = {
5850
6030
  apiKey: {
5851
6031
  type: "http",
5852
6032
  scheme: "bearer",
5853
- 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."
5854
6034
  }
5855
6035
  }
5856
6036
  }
@@ -5862,7 +6042,7 @@ var SERVE_HOST = "127.0.0.1";
5862
6042
  var SERVE_SERVICE_NAME = "hooks-registry";
5863
6043
  function packageVersion() {
5864
6044
  try {
5865
- const pkgPath = join9(dirname3(fileURLToPath2(import.meta.url)), "..", "package.json");
6045
+ const pkgPath = join8(dirname3(fileURLToPath2(import.meta.url)), "..", "package.json");
5866
6046
  return JSON.parse(readFileSync5(pkgPath, "utf-8")).version || "0.0.0";
5867
6047
  } catch {
5868
6048
  return "0.0.0";
@@ -6020,12 +6200,11 @@ function resolveServeOptions(options) {
6020
6200
  }
6021
6201
  function startServeServer(options) {
6022
6202
  const { port, host } = resolveServeOptions(options);
6023
- const apiKey = resolveApiKey();
6024
6203
  const server = Bun.serve({
6025
6204
  hostname: host,
6026
6205
  port,
6027
6206
  async fetch(req) {
6028
- return handleServeRequest(req, apiKey);
6207
+ return handleServeRequest(req, resolveHooksServePublishKey());
6029
6208
  }
6030
6209
  });
6031
6210
  console.error(`hooks registry listening on http://${host}:${port} (publish requires an API key)`);