@hasna/hooks 0.6.5 → 0.6.8

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.
package/dist/index.js CHANGED
@@ -399,6 +399,60 @@ var init_db = __esm(() => {
399
399
  init_legacy_import();
400
400
  });
401
401
 
402
+ // src/lib/redact.ts
403
+ function redactText(value) {
404
+ let out = value;
405
+ for (const pattern of SECRET_SHAPES) {
406
+ out = out.replace(pattern, REDACTED);
407
+ }
408
+ return out;
409
+ }
410
+ function redactValue(value) {
411
+ if (typeof value === "string")
412
+ return redactText(value);
413
+ if (Array.isArray(value))
414
+ return value.map(redactValue);
415
+ if (value !== null && typeof value === "object") {
416
+ const out = {};
417
+ for (const [key, child] of Object.entries(value)) {
418
+ out[key] = SECRET_KEY_NAMES.test(key) ? REDACTED : redactValue(child);
419
+ }
420
+ return out;
421
+ }
422
+ return value;
423
+ }
424
+ function redactEventPayload(text) {
425
+ if (text === null || text === undefined)
426
+ return null;
427
+ try {
428
+ const parsed = JSON.parse(text);
429
+ return redactText(JSON.stringify(redactValue(parsed)));
430
+ } catch {
431
+ return redactText(text);
432
+ }
433
+ }
434
+ var REDACTED = "[REDACTED]", SECRET_KEY_NAMES, SECRET_SHAPES;
435
+ var init_redact = __esm(() => {
436
+ SECRET_KEY_NAMES = /^(key|keys|token|tokens|secret|secrets|password|passwd|api[_-]?key|apikey|access[_-]?key|private[_-]?key|client[_-]?secret|refresh[_-]?token|authorization|auth|cookie|credential|credentials|session[_-]?id|otp|mfa[_-]?code|two[_-]?fa[_-]?code|verification[_-]?code|database_url|db_url|connection_string|dsn)$/i;
437
+ SECRET_SHAPES = [
438
+ /\bsk-(?:proj|svc|ant)-[A-Za-z0-9_-]{10,}/g,
439
+ /\bsk-[A-Za-z0-9]{16,}/g,
440
+ /\btpe_[A-Za-z0-9]{8,}/g,
441
+ /\b(?:rk|sk)_(?:live|test)_[A-Za-z0-9]{8,}/g,
442
+ /\bgithub_pat_[A-Za-z0-9_]{20,}/g,
443
+ /gh[pousr]_[A-Za-z0-9]{20,}/g,
444
+ /xox[baprs]-[A-Za-z0-9-]{20,}/g,
445
+ /AKIA[0-9A-Z]{16}/g,
446
+ /ASIA[0-9A-Z]{16}/g,
447
+ /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/g,
448
+ /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/g,
449
+ /-----BEGIN OPENSSH PRIVATE KEY-----/g,
450
+ /\bBearer\s+[A-Za-z0-9._~+/=-]{6,}/g,
451
+ /\b[a-z][a-z0-9+.-]*:\/\/[^/\s:@]+:[^/\s@]+@/gi,
452
+ /\b(?:password|passwd|pwd|token|api[_-]?key|secret|access[_-]?key|client[_-]?secret|authorization|auth|credential|database_url|db_url|connection_string|dsn)\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;"']{6,})/gi
453
+ ];
454
+ });
455
+
402
456
  // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/helpers/util.js
403
457
  var util, objectUtil, ZodParsedType, getParsedType = (data) => {
404
458
  const t = typeof data;
@@ -4414,24 +4468,30 @@ function resolveApiUrl() {
4414
4468
  const cfg = readConfig();
4415
4469
  return cfg.api_url ? cfg.api_url.replace(/\/+$/, "") : undefined;
4416
4470
  }
4417
- function resolveApiKey(flagKey) {
4418
- if (flagKey && flagKey.length > 0)
4419
- return flagKey;
4471
+ function resolveApiKey() {
4420
4472
  return process.env.HASNA_HOOKS_API_KEY ?? process.env.HOOKS_API_KEY;
4421
4473
  }
4422
4474
  var init_config = () => {};
4423
4475
 
4476
+ // src/lib/semver.ts
4477
+ var NUMERIC_ID = "(0|[1-9]\\d{0,15})", ALNUM_ID = "\\d*[A-Za-z-][0-9A-Za-z-]*", SEMVER_PATTERN;
4478
+ var init_semver = __esm(() => {
4479
+ SEMVER_PATTERN = new RegExp(`^${NUMERIC_ID}\\.${NUMERIC_ID}\\.${NUMERIC_ID}` + `(?:-(?:${NUMERIC_ID}|${ALNUM_ID})(?:\\.(?:${NUMERIC_ID}|${ALNUM_ID}))*)?` + `(?:\\+[0-9A-Za-z.-]+)?$`);
4480
+ });
4481
+
4424
4482
  // src/lib/manifest.ts
4425
4483
  var exports_manifest = {};
4426
4484
  __export(exports_manifest, {
4427
4485
  writeCustomHook: () => writeCustomHook,
4428
4486
  shortManifestName: () => shortManifestName,
4487
+ scriptRelFor: () => scriptRelFor,
4429
4488
  resolveScript: () => resolveScript,
4430
4489
  readCustomManifest: () => readCustomManifest,
4431
4490
  parseManifest: () => parseManifest,
4432
4491
  normalizeManifestName: () => normalizeManifestName,
4433
4492
  manifestSchema: () => manifestSchema,
4434
4493
  listCustomHooks: () => listCustomHooks,
4494
+ isInlineScript: () => isInlineScript,
4435
4495
  customHookDir: () => customHookDir,
4436
4496
  assertContained: () => assertContained,
4437
4497
  ScriptContainmentError: () => ScriptContainmentError,
@@ -4484,16 +4544,25 @@ function assertContained(target, root, raw) {
4484
4544
  assertContainedReal(root, lexical, raw);
4485
4545
  return lexical;
4486
4546
  }
4487
- function resolveScript(manifest, manifestDir) {
4488
- if (manifest.script.includes(`
4489
- `)) {
4547
+ function isInlineScript(manifest) {
4548
+ return manifest.script_kind === "inline" || manifest.script_kind === undefined && manifest.script.includes(`
4549
+ `);
4550
+ }
4551
+ function scriptRelFor(manifest) {
4552
+ if (isInlineScript(manifest)) {
4490
4553
  const ext = manifest.script.trim().startsWith("#!") ? ".sh" : ".ts";
4491
- return { path: `script${ext}`, content: manifest.script };
4554
+ return `script${ext}`;
4492
4555
  }
4493
4556
  const rel = normalize(manifest.script);
4494
4557
  if (isAbsolute(rel)) {
4495
4558
  throw new Error(`manifest script must be relative or inline, got absolute path '${manifest.script}'`);
4496
4559
  }
4560
+ return rel;
4561
+ }
4562
+ function resolveScript(manifest, manifestDir) {
4563
+ if (isInlineScript(manifest))
4564
+ return { path: scriptRelFor(manifest), content: manifest.script };
4565
+ const rel = scriptRelFor(manifest);
4497
4566
  const resolved = assertContained(join4(manifestDir, rel), manifestDir, manifest.script);
4498
4567
  if (!existsSync4(resolved)) {
4499
4568
  throw new Error(`manifest script file not found: ${resolved}`);
@@ -4511,8 +4580,8 @@ function readCustomManifest(name) {
4511
4580
  try {
4512
4581
  const manifest = parseManifest(readFileSync3(manifestPath, "utf-8"));
4513
4582
  const script = resolveScript(manifest, dir);
4514
- return { manifest, scriptPath: join4(dir, script.path), scriptContent: script.content, scriptIsInline: manifest.script.includes(`
4515
- `) };
4583
+ const scriptIsInline = isInlineScript(manifest);
4584
+ return { manifest, scriptPath: join4(dir, script.path), scriptContent: script.content, scriptIsInline };
4516
4585
  } catch (err) {
4517
4586
  if (err instanceof ScriptContainmentError)
4518
4587
  throw err;
@@ -4559,15 +4628,18 @@ var HOOK_NAME_RE, manifestSchema, ScriptContainmentError;
4559
4628
  var init_manifest = __esm(() => {
4560
4629
  init_zod();
4561
4630
  init_config();
4631
+ init_semver();
4562
4632
  HOOK_NAME_RE = /^[\w-]+$/;
4563
4633
  manifestSchema = exports_external.object({
4564
4634
  name: exports_external.string().regex(HOOK_NAME_RE, "hook name must be /^[\\w-]+$/"),
4565
- version: exports_external.string().regex(/^\d+\.\d+\.\d+/, "version must be semver (e.g. 1.2.3)"),
4635
+ version: exports_external.string().regex(SEMVER_PATTERN, "version must be full semver (e.g. 1.2.3 or 1.2.3-beta.1)"),
4566
4636
  description: exports_external.string().optional(),
4567
4637
  events: exports_external.array(exports_external.string()).min(1, "at least one event is required"),
4568
4638
  script: exports_external.string().min(1, "script must name a relative path or inline content"),
4639
+ script_kind: exports_external.enum(["inline", "file"]).optional(),
4569
4640
  args: exports_external.array(exports_external.string()).optional(),
4570
- timeout_ms: exports_external.number().int().positive().optional()
4641
+ timeout_ms: exports_external.number().int().positive().optional(),
4642
+ env: exports_external.record(exports_external.string(), exports_external.string()).optional()
4571
4643
  });
4572
4644
  ScriptContainmentError = class ScriptContainmentError extends Error {
4573
4645
  constructor(path, hookDir, raw) {
@@ -4595,11 +4667,13 @@ __export(exports_store, {
4595
4667
  listHookRecords: () => listHookRecords,
4596
4668
  getPinnedHook: () => getPinnedHook,
4597
4669
  getHookRecord: () => getHookRecord,
4598
- checkScriptHash: () => checkScriptHash
4670
+ checkScriptHash: () => checkScriptHash,
4671
+ LockFileError: () => LockFileError
4599
4672
  });
4600
4673
  import { createHash } from "crypto";
4601
- import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
4674
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3, renameSync } from "fs";
4602
4675
  import { readFile } from "fs/promises";
4676
+ import { randomBytes } from "crypto";
4603
4677
  function sha256Of(input) {
4604
4678
  return createHash("sha256").update(input).digest("hex");
4605
4679
  }
@@ -4642,16 +4716,39 @@ function removeHookRecord(db, name) {
4642
4716
  const res = db.run("DELETE FROM hooks WHERE name = ?", [name]);
4643
4717
  return res.changes > 0;
4644
4718
  }
4719
+ function assertLockShape(parsed, path) {
4720
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
4721
+ throw new LockFileError(`hooks.lock (${path}) is malformed: expected a JSON object with a "hooks" map. ` + `Repair: fix the file by hand, or if it is unrecoverable move it aside and run 'hooks sync' to rebuild it. ` + `Nothing was trusted or rewritten \u2014 hooks are left in the refuse-to-run state until the lock is repaired.`);
4722
+ }
4723
+ const hooks = parsed.hooks;
4724
+ if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) {
4725
+ throw new LockFileError(`hooks.lock (${path}) is malformed: missing or invalid "hooks" map. ` + `Repair: fix the file by hand, or if it is unrecoverable move it aside and run 'hooks sync' to rebuild it. ` + `Nothing was trusted or rewritten \u2014 hooks are left in the refuse-to-run state until the lock is repaired.`);
4726
+ }
4727
+ for (const [name, entry] of Object.entries(hooks)) {
4728
+ const e = entry;
4729
+ if (e === null || typeof e !== "object" || typeof e.version !== "string" || typeof e.sha256 !== "string") {
4730
+ throw new LockFileError(`hooks.lock (${path}) is malformed: entry for '${name}' is not a valid pin (version and sha256 strings required). ` + `Repair: fix the file by hand, or if it is unrecoverable move it aside and run 'hooks sync' to rebuild it.`);
4731
+ }
4732
+ }
4733
+ }
4645
4734
  function readLock() {
4735
+ const path = getLockPath();
4736
+ if (!existsSync6(path))
4737
+ return { hooks: {} };
4738
+ let raw;
4646
4739
  try {
4647
- const path = getLockPath();
4648
- if (!existsSync6(path))
4649
- return { hooks: {} };
4650
- const parsed = JSON.parse(readFileSync4(path, "utf-8"));
4651
- return { hooks: parsed.hooks ?? {} };
4740
+ raw = readFileSync4(path, "utf-8");
4652
4741
  } catch {
4653
- return { hooks: {} };
4742
+ throw new LockFileError(`hooks.lock (${path}) could not be read. Repair: fix permissions or move the file aside and run 'hooks sync'.`);
4743
+ }
4744
+ let parsed;
4745
+ try {
4746
+ parsed = JSON.parse(raw);
4747
+ } catch {
4748
+ throw new LockFileError(`hooks.lock (${path}) is malformed: not valid JSON (${String((raw ?? "").slice(0, 80))}). ` + `Repair: fix the file by hand, or if it is unrecoverable move it aside and run 'hooks sync' to rebuild it. ` + `Nothing was trusted or rewritten \u2014 hooks are left in the refuse-to-run state until the lock is repaired.`);
4654
4749
  }
4750
+ assertLockShape(parsed, path);
4751
+ return parsed;
4655
4752
  }
4656
4753
  function writeLock(lock) {
4657
4754
  const path = getLockPath();
@@ -4660,8 +4757,10 @@ function writeLock(lock) {
4660
4757
  for (const name of Object.keys(lock.hooks).sort()) {
4661
4758
  sorted[name] = lock.hooks[name];
4662
4759
  }
4663
- writeFileSync3(path, JSON.stringify({ hooks: sorted }, null, 2) + `
4760
+ const tmp = `${path}.tmp-${randomBytes(8).toString("hex")}`;
4761
+ writeFileSync3(tmp, JSON.stringify({ hooks: sorted }, null, 2) + `
4664
4762
  `, "utf-8");
4763
+ renameSync(tmp, path);
4665
4764
  return path;
4666
4765
  }
4667
4766
  function setPinnedHook(name, entry) {
@@ -4747,9 +4846,16 @@ function retrustHook(name, scriptPath, version, source) {
4747
4846
  setPinnedHook(name, { version, sha256: actual, source });
4748
4847
  return { ok: true, pinned: true, expected: actual, actual, name };
4749
4848
  }
4849
+ var LockFileError;
4750
4850
  var init_store = __esm(() => {
4751
4851
  init_db();
4752
4852
  init_config();
4853
+ LockFileError = class LockFileError extends Error {
4854
+ constructor(message) {
4855
+ super(message);
4856
+ this.name = "LockFileError";
4857
+ }
4858
+ };
4753
4859
  });
4754
4860
 
4755
4861
  // src/lib/profiles.ts
@@ -4895,15 +5001,169 @@ var init_profiles = __esm(() => {
4895
5001
  PROFILES_DIR = resolveProfilesDir();
4896
5002
  });
4897
5003
 
5004
+ // src/lib/hook-env.ts
5005
+ import { dirname as dirname4, isAbsolute as isAbsolute2 } from "path";
5006
+ import { existsSync as existsSync10, statSync } from "fs";
5007
+ function isDeniedEnvName(name) {
5008
+ const upper = name.toUpperCase();
5009
+ if (DENY_PREFIXES.some((prefix) => upper.startsWith(prefix)))
5010
+ return true;
5011
+ if (DENY_SUFFIXES.some((suffix) => upper.endsWith(suffix)))
5012
+ return true;
5013
+ return DENY_CONTAINS.some((token) => upper.includes(token));
5014
+ }
5015
+ function isInterpreterInjectionEnvName(name) {
5016
+ if (INTERPRETER_INJECTION.has(name))
5017
+ return true;
5018
+ return INTERPRETER_INJECTION_PREFIXES.some((prefix) => name.startsWith(prefix));
5019
+ }
5020
+ function isBashFunctionEnvName(name) {
5021
+ return name.startsWith("BASH_FUNC_");
5022
+ }
5023
+ function isUnsafePathEntry(entry, home) {
5024
+ if (entry === "" || !isAbsolute2(entry))
5025
+ return true;
5026
+ if (home && (entry === home || entry.startsWith(home + "/")))
5027
+ return true;
5028
+ if (entry === "/tmp" || entry.startsWith("/tmp/"))
5029
+ return true;
5030
+ if (entry === "/var/tmp" || entry.startsWith("/var/tmp/"))
5031
+ return true;
5032
+ try {
5033
+ if ((statSync(entry).mode & 2) !== 0)
5034
+ return true;
5035
+ } catch {}
5036
+ return false;
5037
+ }
5038
+ function sanitizeHookPath(pathValue, home) {
5039
+ const baseline = PATH_BASELINE.slice();
5040
+ if (process.platform === "darwin" && existsSync10("/opt/homebrew/bin"))
5041
+ baseline.push("/opt/homebrew/bin");
5042
+ const bunDir = dirname4(process.execPath);
5043
+ const seen = new Set;
5044
+ const out = [];
5045
+ for (const dir of baseline) {
5046
+ if (seen.has(dir))
5047
+ continue;
5048
+ seen.add(dir);
5049
+ out.push(dir);
5050
+ }
5051
+ if (!seen.has(bunDir)) {
5052
+ seen.add(bunDir);
5053
+ out.push(bunDir);
5054
+ }
5055
+ for (const entry of pathValue.split(":")) {
5056
+ if (seen.has(entry))
5057
+ continue;
5058
+ if (isUnsafePathEntry(entry, home))
5059
+ continue;
5060
+ seen.add(entry);
5061
+ out.push(entry);
5062
+ }
5063
+ return out.join(":");
5064
+ }
5065
+ function buildHookEnv(source, extra) {
5066
+ const out = {};
5067
+ for (const name of ALLOWLIST) {
5068
+ if (name === "PATH")
5069
+ continue;
5070
+ const value = source[name] ?? extra?.[name];
5071
+ if (value !== undefined && value !== null)
5072
+ out[name] = value;
5073
+ }
5074
+ const pathOverride = extra?.PATH;
5075
+ if (pathOverride !== undefined && pathOverride !== null) {
5076
+ out.PATH = pathOverride;
5077
+ } else {
5078
+ const parentPath = source.PATH;
5079
+ if (parentPath !== undefined && parentPath !== null) {
5080
+ out.PATH = sanitizeHookPath(parentPath, source.HOME ?? extra?.HOME);
5081
+ }
5082
+ }
5083
+ for (const [from, to] of CONFIG_PROJECTIONS) {
5084
+ const value = source[from] ?? extra?.[from];
5085
+ if (value !== undefined && value !== null)
5086
+ out[to] = value;
5087
+ }
5088
+ const merged = { ...source, ...extra ?? {} };
5089
+ for (const [name, value] of Object.entries(merged)) {
5090
+ if (out[name] !== undefined)
5091
+ continue;
5092
+ if (isDeniedEnvName(name))
5093
+ continue;
5094
+ if (isInterpreterInjectionEnvName(name))
5095
+ continue;
5096
+ if (isBashFunctionEnvName(name))
5097
+ continue;
5098
+ if (value !== undefined && value !== null)
5099
+ out[name] = value;
5100
+ }
5101
+ return out;
5102
+ }
5103
+ var ALLOWLIST, INTERPRETER_INJECTION, INTERPRETER_INJECTION_PREFIXES, DENY_SUFFIXES, DENY_PREFIXES, DENY_CONTAINS, PATH_BASELINE, CONFIG_PROJECTIONS;
5104
+ var init_hook_env = __esm(() => {
5105
+ ALLOWLIST = new Set(["PATH", "HOME", "LANG", "TZ", "SHELL", "TERM", "USER", "PWD"]);
5106
+ INTERPRETER_INJECTION = new Set([
5107
+ "BASH_ENV",
5108
+ "ENV",
5109
+ "BASHOPTS",
5110
+ "SHELLOPTS",
5111
+ "NODE_OPTIONS",
5112
+ "NODE_PATH",
5113
+ "PYTHONSTARTUP",
5114
+ "PYTHONINSPECT",
5115
+ "PYTHONPATH",
5116
+ "PYTHONHOME",
5117
+ "LD_PRELOAD",
5118
+ "LD_LIBRARY_PATH",
5119
+ "GCONV_PATH",
5120
+ "LOCPATH",
5121
+ "PERL5OPT",
5122
+ "RUBYOPT",
5123
+ "NODE_EXTRA_CA_CERTS",
5124
+ "NODE_TLS_REJECT_UNAUTHORIZED",
5125
+ "SSL_CERT_FILE",
5126
+ "SSL_CERT_DIR",
5127
+ "CURL_CA_BUNDLE",
5128
+ "GIT_SSL_CAINFO",
5129
+ "AWS_CA_BUNDLE"
5130
+ ]);
5131
+ INTERPRETER_INJECTION_PREFIXES = ["GIT_CONFIG_"];
5132
+ DENY_SUFFIXES = ["KEY", "TOKEN", "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL", "CREDENTIALS", "URL", "URI"];
5133
+ DENY_PREFIXES = [
5134
+ "HASNA_",
5135
+ "AWS_",
5136
+ "AZURE_",
5137
+ "GCP_",
5138
+ "VAULT_",
5139
+ "GOOGLE_",
5140
+ "OPENAI_",
5141
+ "ANTHROPIC_",
5142
+ "POSTGRES_",
5143
+ "MYSQL_",
5144
+ "REDIS_",
5145
+ "MONGO_",
5146
+ "MEMENTOS_"
5147
+ ];
5148
+ DENY_CONTAINS = ["DATABASE_URL"];
5149
+ PATH_BASELINE = ["/usr/bin", "/bin", "/usr/local/bin", "/sbin", "/usr/sbin"];
5150
+ CONFIG_PROJECTIONS = [
5151
+ ["HASNA_HOOKS_DATA_DIR", "HOOKS_DATA_DIR"],
5152
+ ["HASNA_HOOKS_DB_PATH", "HOOKS_DB_PATH"],
5153
+ ["HASNA_HOOKS_LOCK_PATH", "HOOKS_LOCK_PATH"],
5154
+ ["HASNA_HOOKS_CONFIG_PATH", "HOOKS_CONFIG_PATH"]
5155
+ ];
5156
+ });
5157
+
4898
5158
  // src/lib/run.ts
4899
5159
  var exports_run = {};
4900
5160
  __export(exports_run, {
4901
5161
  executeVerifiedScript: () => executeVerifiedScript,
4902
5162
  HookTimeoutError: () => HookTimeoutError
4903
5163
  });
4904
- import { randomBytes } from "crypto";
5164
+ import { randomBytes as randomBytes2 } from "crypto";
4905
5165
  import { rmSync as rmSync4, writeFileSync as writeFileSync6 } from "fs";
4906
- import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute2, join as join9 } from "path";
5166
+ import { basename as basename2, dirname as dirname5, isAbsolute as isAbsolute3, join as join9 } from "path";
4907
5167
  function extensionOf(scriptPath) {
4908
5168
  const base = basename2(scriptPath);
4909
5169
  const dot = base.lastIndexOf(".");
@@ -4930,13 +5190,13 @@ function interpreterFor(name, scriptPath, content) {
4930
5190
  if (interp === "bash" || interp === "sh")
4931
5191
  return { command: ["/bin/bash"], tempExt: "sh" };
4932
5192
  if (interp === "node" || interp === "bun")
4933
- return { command: ["bun", "run"], tempExt: BUN_EXTENSIONS.has(ext) ? ext : "ts" };
5193
+ return { command: [process.execPath, "run"], tempExt: BUN_EXTENSIONS.has(ext) ? ext : "ts" };
4934
5194
  throw new Error(`Refusing to run hook '${name}': shebang '${firstLine}' is not a recognized interpreter (supported: bash/sh, node/bun)`);
4935
5195
  }
4936
5196
  if (BASH_EXTENSIONS.has(ext))
4937
5197
  return { command: ["/bin/bash"], tempExt: "sh" };
4938
5198
  if (BUN_EXTENSIONS.has(ext))
4939
- return { command: ["bun", "run"], tempExt: ext };
5199
+ return { command: [process.execPath, "run"], tempExt: ext };
4940
5200
  throw new Error(`Refusing to run hook '${name}': unsupported script extension '.${ext}' (supported: .sh .bash .ts .tsx .js .jsx .mjs .cjs .mts .cts, or a recognized shebang)`);
4941
5201
  }
4942
5202
  async function readPipeWithDeadline(stream, deadlineMs) {
@@ -4972,9 +5232,9 @@ async function readPipeWithDeadline(stream, deadlineMs) {
4972
5232
  return chunks.join("");
4973
5233
  }
4974
5234
  async function executeVerifiedScript(options) {
4975
- const scriptDir = dirname4(options.scriptPath);
5235
+ const scriptDir = dirname5(options.scriptPath);
4976
5236
  const interpreter = interpreterFor(options.name, options.scriptPath, options.content);
4977
- const tempPath = join9(scriptDir, `.hooks-verified-${randomBytes(12).toString("hex")}.${interpreter.tempExt}`);
5237
+ const tempPath = join9(scriptDir, `.hooks-verified-${randomBytes2(12).toString("hex")}.${interpreter.tempExt}`);
4978
5238
  let proc = null;
4979
5239
  let timedOut = false;
4980
5240
  const timeoutTimer = options.timeout ? setTimeout(() => {
@@ -4983,11 +5243,11 @@ async function executeVerifiedScript(options) {
4983
5243
  }, options.timeout) : null;
4984
5244
  try {
4985
5245
  writeFileSync6(tempPath, options.content, { flag: "wx", mode: 384 });
4986
- proc = Bun.spawn([...interpreter.command, isAbsolute2(tempPath) ? tempPath : `./${tempPath}`, ...options.args ?? []], {
5246
+ proc = Bun.spawn([...interpreter.command, isAbsolute3(tempPath) ? tempPath : `./${tempPath}`, ...options.args ?? []], {
4987
5247
  stdin: new Response(options.stdin),
4988
5248
  stdout: "pipe",
4989
5249
  stderr: "pipe",
4990
- env: options.env ?? process.env,
5250
+ env: buildHookEnv(process.env, options.env),
4991
5251
  detached: true
4992
5252
  });
4993
5253
  const exitCode = await proc.exited;
@@ -5045,6 +5305,7 @@ function killGroup(proc, options) {
5045
5305
  }
5046
5306
  var HookTimeoutError, BASH_EXTENSIONS, BUN_EXTENSIONS;
5047
5307
  var init_run = __esm(() => {
5308
+ init_hook_env();
5048
5309
  HookTimeoutError = class HookTimeoutError extends Error {
5049
5310
  timeoutMs;
5050
5311
  constructor(timeoutMs) {
@@ -5073,6 +5334,9 @@ function writeHookEvent(event) {
5073
5334
  const db = getDb();
5074
5335
  const id = nanoid2();
5075
5336
  const timestamp = event.timestamp ?? new Date().toISOString();
5337
+ const toolInput = redactEventPayload(event.tool_input);
5338
+ const error = redactEventPayload(event.error);
5339
+ const metadata = redactEventPayload(event.metadata);
5076
5340
  db.run(`INSERT INTO hook_events
5077
5341
  (id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
5078
5342
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
@@ -5082,12 +5346,12 @@ function writeHookEvent(event) {
5082
5346
  event.hook_name,
5083
5347
  event.event_type,
5084
5348
  event.tool_name ?? null,
5085
- event.tool_input ? event.tool_input.slice(0, 500) : null,
5349
+ toolInput ? toolInput.slice(0, 500) : null,
5086
5350
  event.result ?? null,
5087
- event.error ?? null,
5351
+ error ?? null,
5088
5352
  event.duration_ms ?? null,
5089
5353
  event.project_dir ?? null,
5090
- event.metadata ?? null
5354
+ metadata ?? null
5091
5355
  ]);
5092
5356
  } catch (err) {
5093
5357
  process.stderr.write(`[hooks db-writer] failed to write event: ${err}
@@ -5137,6 +5401,7 @@ function recordHookRun(record) {
5137
5401
  var ALLOWED_EVENT_TYPES;
5138
5402
  var init_db_writer = __esm(() => {
5139
5403
  init_db();
5404
+ init_redact();
5140
5405
  ALLOWED_EVENT_TYPES = new Set([
5141
5406
  "PreToolUse",
5142
5407
  "PostToolUse",
@@ -5159,7 +5424,7 @@ var PG_MIGRATIONS = [
5159
5424
  timestamp TEXT NOT NULL,
5160
5425
  session_id TEXT NOT NULL,
5161
5426
  hook_name TEXT NOT NULL,
5162
- event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit')),
5427
+ event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'SubagentStart')),
5163
5428
  tool_name TEXT,
5164
5429
  tool_input TEXT,
5165
5430
  result TEXT CHECK (result IN ('continue', 'block', NULL)),
@@ -5192,7 +5457,10 @@ var PG_MIGRATIONS = [
5192
5457
  )`,
5193
5458
  `ALTER TABLE hook_events DROP CONSTRAINT IF EXISTS hook_events_event_type_check`,
5194
5459
  `ALTER TABLE hook_events ADD CONSTRAINT hook_events_event_type_check
5195
- CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit'))`
5460
+ CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit'))`,
5461
+ `ALTER TABLE hook_events DROP CONSTRAINT IF EXISTS hook_events_event_type_check`,
5462
+ `ALTER TABLE hook_events ADD CONSTRAINT hook_events_event_type_check
5463
+ CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'SubagentStart'))`
5196
5464
  ];
5197
5465
 
5198
5466
  // src/db/remote-storage.ts
@@ -5206,7 +5474,34 @@ function normalizeParams(params) {
5206
5474
  return flat.map((value) => value === undefined ? null : value);
5207
5475
  }
5208
5476
  function sslConfigFor(connectionString) {
5209
- return connectionString.includes("sslmode=require") || connectionString.includes("ssl=true") ? { rejectUnauthorized: false } : undefined;
5477
+ const wantsSsl = wantsSslFor(connectionString);
5478
+ if (!wantsSsl)
5479
+ return;
5480
+ const insecure = process.env.HASNA_HOOKS_PG_INSECURE_TLS === "1" || process.env.HOOKS_PG_INSECURE_TLS === "1";
5481
+ if (insecure && false) {}
5482
+ return insecure ? { rejectUnauthorized: false } : { rejectUnauthorized: true };
5483
+ }
5484
+ function wantsSslFor(connectionString) {
5485
+ let raw;
5486
+ try {
5487
+ raw = new URL(connectionString).search.replace(/^\?/, "");
5488
+ } catch {
5489
+ raw = connectionString;
5490
+ }
5491
+ try {
5492
+ raw = decodeURIComponent(raw);
5493
+ } catch {}
5494
+ const paramValue = (name) => {
5495
+ const match = raw.match(new RegExp(`(?:^|[&\\s])${name}\\s*=\\s*([^&\\s]+)`, "i"));
5496
+ return match ? match[1].toLowerCase() : undefined;
5497
+ };
5498
+ const sslmode = paramValue("sslmode");
5499
+ if (sslmode !== undefined)
5500
+ return sslmode !== "disable" && sslmode !== "allow";
5501
+ const ssl = paramValue("ssl");
5502
+ if (ssl !== undefined)
5503
+ return ssl !== "false" && ssl !== "0" && ssl !== "no";
5504
+ return false;
5210
5505
  }
5211
5506
 
5212
5507
  class PgAdapterAsync {
@@ -5228,6 +5523,7 @@ class PgAdapterAsync {
5228
5523
  }
5229
5524
 
5230
5525
  // src/db/storage-sync.ts
5526
+ init_redact();
5231
5527
  var STORAGE_TABLES = [
5232
5528
  "hook_events",
5233
5529
  "schema_migrations",
@@ -5404,9 +5700,15 @@ async function pushTable(db, remote, table) {
5404
5700
  result.rowsRead = rows.length;
5405
5701
  if (rows.length === 0)
5406
5702
  return result;
5703
+ const projected = table === "hook_events" ? rows.map((row) => ({
5704
+ ...row,
5705
+ tool_input: typeof row.tool_input === "string" ? redactEventPayload(row.tool_input) : row.tool_input,
5706
+ error: typeof row.error === "string" ? redactEventPayload(row.error) : row.error,
5707
+ metadata: typeof row.metadata === "string" ? redactEventPayload(row.metadata) : row.metadata
5708
+ })) : rows;
5407
5709
  const remoteColumns = await getRemoteColumns(remote, table);
5408
- const columns = filterRemoteColumns(remoteColumns, Object.keys(rows[0]));
5409
- result.rowsWritten = await upsertPg(remote, table, columns, rows, remoteColumns);
5710
+ const columns = filterRemoteColumns(remoteColumns, Object.keys(projected[0]));
5711
+ result.rowsWritten = await upsertPg(remote, table, columns, projected, remoteColumns);
5410
5712
  } catch (error) {
5411
5713
  result.errors.push(error instanceof Error ? error.message : String(error));
5412
5714
  }
@@ -5421,8 +5723,14 @@ async function pullTable(remote, db, table) {
5421
5723
  result.rowsRead = rows.length;
5422
5724
  if (rows.length === 0)
5423
5725
  return result;
5424
- const columns = filterLocalColumns(db, table, Object.keys(rows[0]));
5425
- result.rowsWritten = upsertSqlite(db, table, columns, rows);
5726
+ const projected = table === "hook_events" ? rows.map((row) => ({
5727
+ ...row,
5728
+ tool_input: typeof row.tool_input === "string" ? redactEventPayload(row.tool_input) : row.tool_input,
5729
+ error: typeof row.error === "string" ? redactEventPayload(row.error) : row.error,
5730
+ metadata: typeof row.metadata === "string" ? redactEventPayload(row.metadata) : row.metadata
5731
+ })) : rows;
5732
+ const columns = filterLocalColumns(db, table, Object.keys(projected[0]));
5733
+ result.rowsWritten = upsertSqlite(db, table, columns, projected);
5426
5734
  } catch (error) {
5427
5735
  result.errors.push(error instanceof Error ? error.message : String(error));
5428
5736
  }
@@ -6236,7 +6544,7 @@ function getGlobalSettingsPathOverride(target) {
6236
6544
  function getSettingsPath(scope = "global", target = "claude", codewithConfigPath) {
6237
6545
  if (target === "codewith" && codewithConfigPath)
6238
6546
  return codewithConfigPath;
6239
- if (target === "codewith" && process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH) {
6547
+ if (target === "codewith" && scope === "global" && process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH) {
6240
6548
  return process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH;
6241
6549
  }
6242
6550
  const globalOverride = scope === "global" ? getGlobalSettingsPathOverride(target) : undefined;
@@ -6670,8 +6978,8 @@ function escapeRegExp(value) {
6670
6978
  function codewithHasHookEntryInText(configText, name) {
6671
6979
  return new RegExp(`hooks run ${escapeRegExp(name)}(?:\\s+--profile\\s+[\\w-]+)?(?:$|\\s|")`).test(configText);
6672
6980
  }
6673
- function codewithHasHookEntry(name) {
6674
- const path = getSettingsPath("global", "codewith");
6981
+ function codewithHasHookEntry(name, scope = "global") {
6982
+ const path = getSettingsPath(scope, "codewith");
6675
6983
  if (!existsSync7(path))
6676
6984
  return false;
6677
6985
  try {
@@ -6688,7 +6996,7 @@ function uninstallHook(name, scope = "global", target = "claude") {
6688
6996
  const registeredClaudeOrGemini = target === "all" ? existsInTarget("claude") || existsInTarget("gemini") : false;
6689
6997
  const registeredGlobal = registeredClaudeOrGemini || getRegisteredHooksForTarget("global", target === "all" ? "claude" : target).includes(shortName);
6690
6998
  const registeredProject = registeredClaudeOrGemini || getRegisteredHooksForTarget("project", target === "all" ? "claude" : target).includes(shortName);
6691
- const codewithOnly = (target === "codewith" || target === "all") && codewithHasHookEntry(shortName);
6999
+ const codewithOnly = (target === "codewith" || target === "all") && codewithHasHookEntry(shortName, scope);
6692
7000
  if (!custom2 && !bundledMeta && !registeredGlobal && !registeredProject && !codewithOnly) {
6693
7001
  return { name: shortName, removed: false, source: null, settingsScopes: [], storeDirRemoved: false, pinRemoved: false, dbRecordRemoved: false, registrationsRemaining: [], error: `Hook '${shortName}' not found` };
6694
7002
  }
@@ -6705,7 +7013,7 @@ function uninstallHook(name, scope = "global", target = "claude") {
6705
7013
  }
6706
7014
  const registrationsRemaining = [];
6707
7015
  if (target === "codewith" || target === "all") {
6708
- const codewithPath = getSettingsPath("global", "codewith");
7016
+ const codewithPath = getSettingsPath(scope, "codewith");
6709
7017
  if (existsSync7(codewithPath)) {
6710
7018
  const before = readFileSync5(codewithPath, "utf-8");
6711
7019
  const after = removeCodewithHookEntry(before, shortName);
@@ -6751,7 +7059,7 @@ init_profiles();
6751
7059
  init_config();
6752
7060
  init_store();
6753
7061
  init_manifest();
6754
- import { existsSync as existsSync10, readFileSync as readFileSync9 } from "fs";
7062
+ import { existsSync as existsSync11, readFileSync as readFileSync9 } from "fs";
6755
7063
 
6756
7064
  // src/lib/custom-install.ts
6757
7065
  init_config();
@@ -6831,19 +7139,20 @@ async function installCustomSource(source) {
6831
7139
  return copyManifestDir(source, source, "local");
6832
7140
  }
6833
7141
  async function installManifestUrl(url) {
6834
- const res = await fetch(url, { redirect: "follow" });
7142
+ const res = await fetch(url, { redirect: "error" });
6835
7143
  if (!res.ok) {
6836
7144
  throw new Error(`Failed to fetch manifest (${res.status}): ${url}`);
6837
7145
  }
6838
7146
  const manifest = parseManifest(await res.text());
6839
7147
  let script;
6840
- if (manifest.script.includes(`
6841
- `)) {
7148
+ const inline = manifest.script_kind === "inline" || manifest.script_kind === undefined && manifest.script.includes(`
7149
+ `);
7150
+ if (inline) {
6842
7151
  script = { path: "script.ts", content: manifest.script };
6843
7152
  } else {
6844
7153
  const base = url.substring(0, url.lastIndexOf("/"));
6845
7154
  const scriptUrl = `${base}/${manifest.script}`;
6846
- const scriptRes = await fetch(scriptUrl, { redirect: "follow" });
7155
+ const scriptRes = await fetch(scriptUrl, { redirect: "error" });
6847
7156
  if (!scriptRes.ok) {
6848
7157
  throw new Error(`Failed to fetch script (${scriptRes.status}): ${scriptUrl}`);
6849
7158
  }
@@ -6880,7 +7189,7 @@ function collectBundledCatalog() {
6880
7189
  }
6881
7190
  return out;
6882
7191
  }
6883
- async function fetchJson(base, path) {
7192
+ async function fetchJson(base, path, opts) {
6884
7193
  const headers = { accept: "application/json" };
6885
7194
  const apiKey = resolveApiKey();
6886
7195
  if (apiKey)
@@ -6896,7 +7205,11 @@ async function fetchJson(base, path) {
6896
7205
  if (!res.ok) {
6897
7206
  throw new Error(`GET ${path} failed with status ${res.status}`);
6898
7207
  }
6899
- return res.json();
7208
+ const body = await res.json();
7209
+ if (opts?.withHeader) {
7210
+ body[opts.withHeader] = res.headers.get("x-hook-sha256");
7211
+ }
7212
+ return body;
6900
7213
  }
6901
7214
  async function fetchRemoteState(apiUrl) {
6902
7215
  const catalog = await fetchJson(apiUrl, "/api/v1/catalog");
@@ -6909,6 +7222,16 @@ async function fetchRemoteState(apiUrl) {
6909
7222
  }
6910
7223
  return { catalog: catalog.hooks, remoteLock };
6911
7224
  }
7225
+ function classifyRemotePin(pin, remoteVersion, remoteSha) {
7226
+ const pinnedLocally = pin !== undefined && pin.version === remoteVersion && pin.sha256 === remoteSha;
7227
+ if (pinnedLocally)
7228
+ return "unchanged";
7229
+ if (pin !== undefined && pin.pinned === true && pin.version !== remoteVersion)
7230
+ return "unchanged";
7231
+ if (pin !== undefined)
7232
+ return "updated";
7233
+ return "added";
7234
+ }
6912
7235
  function computeDiff(catalog, remoteLock) {
6913
7236
  const local = readLock();
6914
7237
  const diff = { added: [], updated: [], unchanged: [], skipped: [] };
@@ -6916,30 +7239,30 @@ function computeDiff(catalog, remoteLock) {
6916
7239
  if (!entry.name || !entry.version || !entry.sha256)
6917
7240
  continue;
6918
7241
  const pin = local.hooks[entry.name];
6919
- const remotePin = remoteLock ? remoteLock.hooks[entry.name] : entry;
6920
- if (remoteLock && !remotePin) {
6921
- diff.skipped.push(entry.name);
6922
- continue;
6923
- }
6924
- const pinnedLocally = pin && pin.version === entry.version && pin.sha256 === entry.sha256;
6925
- if (pinnedLocally) {
6926
- diff.unchanged.push(entry.name);
6927
- } else if (pin) {
6928
- diff.updated.push(entry.name);
7242
+ if (remoteLock) {
7243
+ const remotePin = remoteLock.hooks[entry.name];
7244
+ if (!remotePin) {
7245
+ diff.skipped.push(entry.name);
7246
+ continue;
7247
+ }
7248
+ if (remotePin.version !== entry.version) {
7249
+ throw new Error(`remote registry is ambiguous for '${entry.name}': lock pins ${remotePin.version} but catalog latest is ${entry.version}. ` + `Fix the registry (or its lock) before syncing \u2014 nothing was changed.`);
7250
+ }
7251
+ diff[classifyRemotePin(pin, remotePin.version, remotePin.sha256)].push(entry.name);
6929
7252
  } else {
6930
- diff.added.push(entry.name);
7253
+ diff[classifyRemotePin(pin, entry.version, entry.sha256)].push(entry.name);
6931
7254
  }
6932
7255
  }
6933
7256
  return diff;
6934
7257
  }
6935
- async function planSync() {
7258
+ async function planSync(options = {}) {
6936
7259
  const apiUrl = resolveApiUrl();
6937
7260
  if (apiUrl) {
6938
7261
  const { catalog, remoteLock } = await fetchRemoteState(apiUrl);
6939
- return { apiUrl, dryRun: false, diff: computeDiff(catalog, remoteLock) };
7262
+ return { apiUrl, dryRun: options.dryRun ?? false, diff: computeDiff(catalog, remoteLock) };
6940
7263
  }
6941
7264
  const diff = computeDiff(collectBundledCatalog(), null);
6942
- return { apiUrl: null, dryRun: false, diff };
7265
+ return { apiUrl: null, dryRun: options.dryRun ?? false, diff };
6943
7266
  }
6944
7267
  async function syncHooks(options = {}) {
6945
7268
  const apiUrl = resolveApiUrl();
@@ -6947,10 +7270,10 @@ async function syncHooks(options = {}) {
6947
7270
  if (options.dryRun)
6948
7271
  return { ...plan, dryRun: true };
6949
7272
  if (!apiUrl) {
6950
- const db2 = getDb();
7273
+ const db = getDb();
6951
7274
  for (const entry of collectBundledCatalog()) {
6952
7275
  setPinnedHook(entry.name, { version: entry.version, sha256: entry.sha256, source: "bundled" });
6953
- upsertHookRecord(db2, {
7276
+ upsertHookRecord(db, {
6954
7277
  name: entry.name,
6955
7278
  version: entry.version,
6956
7279
  sha256: entry.sha256,
@@ -6963,13 +7286,17 @@ async function syncHooks(options = {}) {
6963
7286
  }
6964
7287
  const { catalog, remoteLock } = await fetchRemoteState(apiUrl);
6965
7288
  const diff = computeDiff(catalog, remoteLock);
6966
- const toFetch = [...diff.added, ...diff.updated];
6967
- const artifacts = new Map;
6968
- for (const name of toFetch) {
7289
+ const staged = await stageSyncArtifacts(apiUrl, [...diff.added, ...diff.updated], remoteLock);
7290
+ await commitSyncArtifacts(staged, apiUrl, remoteLock);
7291
+ return { apiUrl, dryRun: false, diff };
7292
+ }
7293
+ async function stageSyncArtifacts(apiUrl, names, remoteLock) {
7294
+ const staged = [];
7295
+ for (const name of names) {
6969
7296
  const entry = remoteLock.hooks[name];
6970
7297
  if (!entry)
6971
7298
  continue;
6972
- const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${name}/${entry.version}`);
7299
+ const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${encodeURIComponent(name)}/${encodeURIComponent(entry.version)}`);
6973
7300
  if (!artifact.manifest || typeof artifact.script !== "string") {
6974
7301
  throw new Error(`artifact for '${name}@${entry.version}' is malformed`);
6975
7302
  }
@@ -6977,34 +7304,64 @@ async function syncHooks(options = {}) {
6977
7304
  if (actualSha !== entry.sha256) {
6978
7305
  throw new Error(`sha256 mismatch for '${name}@${entry.version}': lock says ${entry.sha256}, artifact has ${actualSha}`);
6979
7306
  }
6980
- artifacts.set(name, artifact);
6981
- }
6982
- const db = getDb();
6983
- for (const name of toFetch) {
6984
- const artifact = artifacts.get(name);
6985
- const entry = remoteLock.hooks[name];
6986
- if (!artifact || !entry)
6987
- continue;
6988
7307
  const manifest = parseManifest(JSON.stringify({ ...artifact.manifest, name: shortManifestName(artifact.manifest.name || name) }));
6989
- const scriptRel = artifact.manifest.script.includes(`
6990
- `) ? "script.ts" : artifact.manifest.script;
6991
- writeCustomHook(name, manifest, artifact.script, scriptRel);
6992
- const scriptPath = resolveScriptPath(name);
6993
- const actual = await sha256File(scriptPath);
6994
- if (actual !== entry.sha256) {
6995
- throw new Error(`sha256 mismatch after write for '${name}@${entry.version}': lock says ${entry.sha256}, on disk ${actual}`);
7308
+ if (manifest.version !== entry.version) {
7309
+ throw new Error(`artifact for '${name}@${entry.version}' declares a different version ('${manifest.version}')`);
6996
7310
  }
6997
- setPinnedHook(name, { version: entry.version, sha256: entry.sha256, source: entry.source ?? "remote" });
6998
- upsertHookRecord(db, {
7311
+ const scriptRel = scriptRelFor(manifest);
7312
+ staged.push({
6999
7313
  name,
7000
7314
  version: entry.version,
7001
7315
  sha256: entry.sha256,
7002
- source_type: entry.source ?? "remote",
7003
- source_ref: apiUrl,
7004
- last_verified_at: new Date().toISOString()
7316
+ source: entry.source ?? "remote",
7317
+ manifest,
7318
+ scriptContent: artifact.script,
7319
+ scriptRel
7005
7320
  });
7006
7321
  }
7007
- return { apiUrl, dryRun: false, diff };
7322
+ return staged;
7323
+ }
7324
+ async function commitSyncArtifacts(staged, apiUrl, remoteLock) {
7325
+ const db = getDb();
7326
+ for (const item of staged) {
7327
+ const entry = remoteLock.hooks[item.name];
7328
+ if (!entry)
7329
+ continue;
7330
+ writeCustomHook(item.name, item.manifest, item.scriptContent, item.scriptRel);
7331
+ const scriptPath = resolveScriptPath(item.name);
7332
+ const actual = await sha256File(scriptPath);
7333
+ if (actual !== entry.sha256) {
7334
+ throw new Error(`sha256 mismatch after write for '${item.name}@${entry.version}': lock says ${entry.sha256}, on disk ${actual}`);
7335
+ }
7336
+ }
7337
+ const lock = readLock();
7338
+ for (const item of staged) {
7339
+ const entry = remoteLock.hooks[item.name];
7340
+ if (!entry)
7341
+ continue;
7342
+ lock.hooks[item.name] = { version: entry.version, sha256: entry.sha256, source: entry.source ?? "remote" };
7343
+ }
7344
+ writeLock(lock);
7345
+ db.exec("BEGIN");
7346
+ try {
7347
+ for (const item of staged) {
7348
+ const entry = remoteLock.hooks[item.name];
7349
+ if (!entry)
7350
+ continue;
7351
+ upsertHookRecord(db, {
7352
+ name: item.name,
7353
+ version: entry.version,
7354
+ sha256: entry.sha256,
7355
+ source_type: entry.source ?? "remote",
7356
+ source_ref: apiUrl,
7357
+ last_verified_at: new Date().toISOString()
7358
+ });
7359
+ }
7360
+ db.exec("COMMIT");
7361
+ } catch (err) {
7362
+ db.exec("ROLLBACK");
7363
+ throw err;
7364
+ }
7008
7365
  }
7009
7366
  async function fetchPinnedHook(name, version, apiUrl) {
7010
7367
  const remoteLock = await fetchJson(apiUrl, "/api/v1/lock");
@@ -7012,10 +7369,11 @@ async function fetchPinnedHook(name, version, apiUrl) {
7012
7369
  if (!entry) {
7013
7370
  throw new Error(`Hook '${name}' is not in the remote registry lock`);
7014
7371
  }
7015
- if (entry.version !== version) {
7016
- throw new Error(`Hook '${name}' version ${version} is not in the remote registry (available: ${entry.version})`);
7372
+ const isLatest = entry.version === version;
7373
+ if (!isLatest && !(entry.versions ?? []).includes(version)) {
7374
+ throw new Error(`Hook '${name}' version ${version} is not in the remote registry (available: ${[entry.version, ...entry.versions ?? []].join(", ")})`);
7017
7375
  }
7018
- const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${name}/${version}`);
7376
+ const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, { withHeader: "_headerSha" });
7019
7377
  if (!artifact.manifest || typeof artifact.script !== "string") {
7020
7378
  throw new Error(`artifact for '${name}@${version}' is malformed`);
7021
7379
  }
@@ -7027,24 +7385,31 @@ async function fetchPinnedHook(name, version, apiUrl) {
7027
7385
  throw new Error(`artifact for '${name}@${version}' declares a different version ('${artifact.manifest.version}')`);
7028
7386
  }
7029
7387
  const actualSha = sha256OfText(artifact.script);
7030
- if (actualSha !== entry.sha256) {
7031
- throw new Error(`sha256 mismatch for '${name}@${version}': lock says ${entry.sha256}, artifact has ${actualSha}`);
7388
+ let expectedSha = entry.sha256;
7389
+ if (!isLatest) {
7390
+ const headerSha = artifact._headerSha;
7391
+ if (!headerSha) {
7392
+ throw new Error(`registry did not return a sha256 header for '${name}@${version}' (older pin); refusing to trust an unverified artifact`);
7393
+ }
7394
+ expectedSha = headerSha;
7395
+ }
7396
+ if (actualSha !== expectedSha) {
7397
+ throw new Error(`sha256 mismatch for '${name}@${version}': expected ${expectedSha}, artifact has ${actualSha}`);
7032
7398
  }
7033
7399
  const manifest = parseManifest(JSON.stringify({ ...artifact.manifest, name: shortManifestName(artifact.manifest.name || name) }));
7034
- const scriptRel = artifact.manifest.script.includes(`
7035
- `) ? "script.ts" : artifact.manifest.script;
7400
+ const scriptRel = scriptRelFor(manifest);
7036
7401
  writeCustomHook(name, manifest, artifact.script, scriptRel);
7037
7402
  const scriptPath = resolveScriptPath(name);
7038
7403
  const actual = await sha256File(scriptPath);
7039
- if (actual !== entry.sha256) {
7040
- throw new Error(`sha256 mismatch after write for '${name}@${version}': lock says ${entry.sha256}, on disk ${actual}`);
7404
+ if (actual !== expectedSha) {
7405
+ throw new Error(`sha256 mismatch after write for '${name}@${version}': expected ${expectedSha}, on disk ${actual}`);
7041
7406
  }
7042
7407
  const db = getDb();
7043
- setPinnedHook(name, { version, sha256: entry.sha256, source: entry.source ?? "remote" });
7408
+ setPinnedHook(name, { version, sha256: expectedSha, source: entry.source ?? "remote", pinned: true });
7044
7409
  upsertHookRecord(db, {
7045
7410
  name,
7046
7411
  version,
7047
- sha256: entry.sha256,
7412
+ sha256: expectedSha,
7048
7413
  source_type: entry.source ?? "remote",
7049
7414
  source_ref: apiUrl,
7050
7415
  last_verified_at: new Date().toISOString()
@@ -7052,7 +7417,7 @@ async function fetchPinnedHook(name, version, apiUrl) {
7052
7417
  return {
7053
7418
  name,
7054
7419
  version,
7055
- sha256: entry.sha256,
7420
+ sha256: expectedSha,
7056
7421
  source: entry.source ?? "remote",
7057
7422
  source_ref: apiUrl,
7058
7423
  artifact,
@@ -7078,6 +7443,7 @@ function secureEqual(a, b) {
7078
7443
  }
7079
7444
 
7080
7445
  // src/serve.ts
7446
+ init_semver();
7081
7447
  var DEFAULT_SERVE_PORT = 39428;
7082
7448
  var SERVE_HOST = "127.0.0.1";
7083
7449
  var SERVE_SERVICE_NAME = "hooks-registry";
@@ -7087,24 +7453,29 @@ async function buildCatalog() {
7087
7453
  const scriptPath = resolveHook(meta.name)?.scriptPath;
7088
7454
  if (!scriptPath)
7089
7455
  continue;
7456
+ const sha = await sha256File(scriptPath);
7090
7457
  byName.set(meta.name, {
7091
7458
  name: meta.name,
7092
7459
  version: meta.version,
7093
- sha256: await sha256File(scriptPath),
7460
+ sha256: sha,
7094
7461
  events: meta.events && meta.events.length > 0 ? meta.events : [meta.event],
7095
7462
  description: meta.description,
7096
- source: "bundled"
7463
+ source: "bundled",
7464
+ versions: [meta.version]
7097
7465
  });
7098
7466
  }
7099
7467
  for (const custom2 of listCustomHooks()) {
7100
7468
  const name = shortManifestName(custom2.manifest.name);
7469
+ const sha = await sha256File(custom2.scriptPath);
7470
+ const existing = byName.get(name);
7101
7471
  byName.set(name, {
7102
7472
  name,
7103
7473
  version: custom2.manifest.version,
7104
- sha256: await sha256File(custom2.scriptPath),
7474
+ sha256: sha,
7105
7475
  events: custom2.manifest.events,
7106
7476
  description: custom2.manifest.description ?? "Custom hook",
7107
- source: byName.has(name) ? "custom-overrides-bundled" : "custom"
7477
+ source: existing ? "custom-overrides-bundled" : "custom",
7478
+ versions: existing ? [...new Set([...existing.versions, custom2.manifest.version])] : [custom2.manifest.version]
7108
7479
  });
7109
7480
  }
7110
7481
  return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
@@ -7122,6 +7493,7 @@ async function artifactFor(name, version) {
7122
7493
  description: resolved.description,
7123
7494
  events: resolved.events,
7124
7495
  script: custom2 ? custom2.manifest.script : `src/hook.ts`,
7496
+ script_kind: custom2?.manifest.script_kind,
7125
7497
  args: custom2?.manifest.args,
7126
7498
  timeout_ms: custom2?.manifest.timeout_ms
7127
7499
  };
@@ -7151,11 +7523,32 @@ function handleServeRequest(req, apiKey) {
7151
7523
  return buildCatalog().then((catalog) => json({ hooks: catalog }));
7152
7524
  }
7153
7525
  if (url.pathname === "/api/v1/lock" && req.method === "GET") {
7154
- return Promise.resolve(json(readLock()));
7526
+ return buildCatalog().then((catalog) => {
7527
+ const lock = readLock();
7528
+ const byVersion = {};
7529
+ for (const entry of catalog)
7530
+ byVersion[entry.name] = entry.versions;
7531
+ const hooks = {};
7532
+ for (const [name, pin] of Object.entries(lock.hooks)) {
7533
+ hooks[name] = { ...pin, versions: byVersion[name] ?? [pin.version] };
7534
+ }
7535
+ return json({ hooks });
7536
+ });
7155
7537
  }
7156
- const artifactMatch = /^\/api\/v1\/hooks\/([\w-]+)\/(\d+\.\d+\.\d+)$/.exec(url.pathname);
7538
+ const artifactMatch = /^\/api\/v1\/hooks\/([\w-]+)\/([0-9A-Za-z.%+_-]+)$/.exec(url.pathname);
7157
7539
  if (artifactMatch && req.method === "GET") {
7158
- const [, name, version] = artifactMatch;
7540
+ const [, rawName, rawVersion] = artifactMatch;
7541
+ let name;
7542
+ let version;
7543
+ try {
7544
+ name = decodeURIComponent(rawName);
7545
+ version = decodeURIComponent(rawVersion);
7546
+ } catch {
7547
+ return Promise.resolve(json({ error: "invalid URL encoding" }, 400));
7548
+ }
7549
+ if (!SEMVER_PATTERN.test(version)) {
7550
+ return Promise.resolve(json({ error: `invalid semver version '${version}'` }, 400));
7551
+ }
7159
7552
  return artifactFor(name, version).then(async (artifact) => {
7160
7553
  if (!artifact)
7161
7554
  return json({ error: `Hook '${name}@${version}' not found locally` }, 404);
@@ -7186,7 +7579,7 @@ function handleServeRequest(req, apiKey) {
7186
7579
  function startServeServer(options) {
7187
7580
  const port = options.port ?? DEFAULT_SERVE_PORT;
7188
7581
  const host = options.host ?? SERVE_HOST;
7189
- const apiKey = resolveApiKey(options.apiKey);
7582
+ const apiKey = resolveApiKey();
7190
7583
  const server = Bun.serve({
7191
7584
  hostname: host,
7192
7585
  port,
@@ -7296,7 +7689,7 @@ async function runHook(name, input, options = {}) {
7296
7689
  throw new Error(`Hook '${name}' not found`);
7297
7690
  const hookScript = getHookPath(name) + "/src/hook.ts";
7298
7691
  const resolvedScript = custom2?.scriptPath;
7299
- const script = resolvedScript ?? (existsSync10(hookScript) ? hookScript : undefined);
7692
+ const script = resolvedScript ?? (existsSync11(hookScript) ? hookScript : undefined);
7300
7693
  if (!script)
7301
7694
  throw new Error(`Hook script not found: ${name}`);
7302
7695
  const { sha256Of: sha256Of2, checkScriptHash: checkScriptHash2 } = await Promise.resolve().then(() => (init_store(), exports_store));
@@ -7331,8 +7724,8 @@ async function runHook(name, input, options = {}) {
7331
7724
  scriptPath: script,
7332
7725
  content,
7333
7726
  args: custom2?.manifest.args ?? [],
7727
+ env: custom2?.manifest.env,
7334
7728
  stdin: JSON.stringify(hookInput),
7335
- env: process.env,
7336
7729
  timeout: effectiveTimeout ?? undefined
7337
7730
  }));
7338
7731
  } catch (err) {