@hasna/hooks 0.6.4 → 0.6.6

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,7 +399,61 @@ var init_db = __esm(() => {
399
399
  init_legacy_import();
400
400
  });
401
401
 
402
- // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/helpers/util.js
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
+
456
+ // node_modules/zod/v3/helpers/util.js
403
457
  var util, objectUtil, ZodParsedType, getParsedType = (data) => {
404
458
  const t = typeof data;
405
459
  switch (t) {
@@ -530,7 +584,7 @@ var init_util = __esm(() => {
530
584
  ]);
531
585
  });
532
586
 
533
- // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/ZodError.js
587
+ // node_modules/zod/v3/ZodError.js
534
588
  var ZodIssueCode, quotelessJson = (obj) => {
535
589
  const json = JSON.stringify(obj, null, 2);
536
590
  return json.replace(/"([^"]+)":/g, "$1:");
@@ -651,7 +705,7 @@ var init_ZodError = __esm(() => {
651
705
  };
652
706
  });
653
707
 
654
- // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/locales/en.js
708
+ // node_modules/zod/v3/locales/en.js
655
709
  var errorMap = (issue, _ctx) => {
656
710
  let message;
657
711
  switch (issue.code) {
@@ -758,7 +812,7 @@ var init_en = __esm(() => {
758
812
  en_default = errorMap;
759
813
  });
760
814
 
761
- // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/errors.js
815
+ // node_modules/zod/v3/errors.js
762
816
  function setErrorMap(map) {
763
817
  overrideErrorMap = map;
764
818
  }
@@ -771,7 +825,7 @@ var init_errors = __esm(() => {
771
825
  overrideErrorMap = en_default;
772
826
  });
773
827
 
774
- // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/helpers/parseUtil.js
828
+ // node_modules/zod/v3/helpers/parseUtil.js
775
829
  function addIssueToContext(ctx, issueData) {
776
830
  const overrideMap = getErrorMap();
777
831
  const issue = makeIssue({
@@ -876,10 +930,10 @@ var init_parseUtil = __esm(() => {
876
930
  });
877
931
  });
878
932
 
879
- // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/helpers/typeAliases.js
933
+ // node_modules/zod/v3/helpers/typeAliases.js
880
934
  var init_typeAliases = () => {};
881
935
 
882
- // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/helpers/errorUtil.js
936
+ // node_modules/zod/v3/helpers/errorUtil.js
883
937
  var errorUtil;
884
938
  var init_errorUtil = __esm(() => {
885
939
  (function(errorUtil2) {
@@ -888,7 +942,7 @@ var init_errorUtil = __esm(() => {
888
942
  })(errorUtil || (errorUtil = {}));
889
943
  });
890
944
 
891
- // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/types.js
945
+ // node_modules/zod/v3/types.js
892
946
  class ParseInputLazyPath {
893
947
  constructor(parent, value, path, key) {
894
948
  this._cachedPath = [];
@@ -4239,7 +4293,7 @@ var init_types = __esm(() => {
4239
4293
  NEVER = INVALID;
4240
4294
  });
4241
4295
 
4242
- // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/external.js
4296
+ // node_modules/zod/v3/external.js
4243
4297
  var exports_external = {};
4244
4298
  __export(exports_external, {
4245
4299
  void: () => voidType,
@@ -4359,7 +4413,7 @@ var init_external = __esm(() => {
4359
4413
  init_ZodError();
4360
4414
  });
4361
4415
 
4362
- // ../../node_modules/.bun/zod@3.25.76/node_modules/zod/index.js
4416
+ // node_modules/zod/index.js
4363
4417
  var init_zod = __esm(() => {
4364
4418
  init_external();
4365
4419
  init_external();
@@ -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 SEMVER_PATTERN;
4478
+ var init_semver = __esm(() => {
4479
+ SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[0-9A-Za-z.-]+)?(\+[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,13 +4628,15 @@ 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
4641
  timeout_ms: exports_external.number().int().positive().optional()
4571
4642
  });
@@ -4595,11 +4666,13 @@ __export(exports_store, {
4595
4666
  listHookRecords: () => listHookRecords,
4596
4667
  getPinnedHook: () => getPinnedHook,
4597
4668
  getHookRecord: () => getHookRecord,
4598
- checkScriptHash: () => checkScriptHash
4669
+ checkScriptHash: () => checkScriptHash,
4670
+ LockFileError: () => LockFileError
4599
4671
  });
4600
4672
  import { createHash } from "crypto";
4601
- import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
4673
+ import { existsSync as existsSync6, mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3, renameSync } from "fs";
4602
4674
  import { readFile } from "fs/promises";
4675
+ import { randomBytes } from "crypto";
4603
4676
  function sha256Of(input) {
4604
4677
  return createHash("sha256").update(input).digest("hex");
4605
4678
  }
@@ -4642,16 +4715,39 @@ function removeHookRecord(db, name) {
4642
4715
  const res = db.run("DELETE FROM hooks WHERE name = ?", [name]);
4643
4716
  return res.changes > 0;
4644
4717
  }
4718
+ function assertLockShape(parsed, path) {
4719
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
4720
+ 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.`);
4721
+ }
4722
+ const hooks = parsed.hooks;
4723
+ if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) {
4724
+ 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.`);
4725
+ }
4726
+ for (const [name, entry] of Object.entries(hooks)) {
4727
+ const e = entry;
4728
+ if (e === null || typeof e !== "object" || typeof e.version !== "string" || typeof e.sha256 !== "string") {
4729
+ 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.`);
4730
+ }
4731
+ }
4732
+ }
4645
4733
  function readLock() {
4734
+ const path = getLockPath();
4735
+ if (!existsSync6(path))
4736
+ return { hooks: {} };
4737
+ let raw;
4646
4738
  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 ?? {} };
4739
+ raw = readFileSync4(path, "utf-8");
4652
4740
  } catch {
4653
- return { hooks: {} };
4741
+ throw new LockFileError(`hooks.lock (${path}) could not be read. Repair: fix permissions or move the file aside and run 'hooks sync'.`);
4742
+ }
4743
+ let parsed;
4744
+ try {
4745
+ parsed = JSON.parse(raw);
4746
+ } catch {
4747
+ 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
4748
  }
4749
+ assertLockShape(parsed, path);
4750
+ return parsed;
4655
4751
  }
4656
4752
  function writeLock(lock) {
4657
4753
  const path = getLockPath();
@@ -4660,8 +4756,10 @@ function writeLock(lock) {
4660
4756
  for (const name of Object.keys(lock.hooks).sort()) {
4661
4757
  sorted[name] = lock.hooks[name];
4662
4758
  }
4663
- writeFileSync3(path, JSON.stringify({ hooks: sorted }, null, 2) + `
4759
+ const tmp = `${path}.tmp-${randomBytes(8).toString("hex")}`;
4760
+ writeFileSync3(tmp, JSON.stringify({ hooks: sorted }, null, 2) + `
4664
4761
  `, "utf-8");
4762
+ renameSync(tmp, path);
4665
4763
  return path;
4666
4764
  }
4667
4765
  function setPinnedHook(name, entry) {
@@ -4747,9 +4845,16 @@ function retrustHook(name, scriptPath, version, source) {
4747
4845
  setPinnedHook(name, { version, sha256: actual, source });
4748
4846
  return { ok: true, pinned: true, expected: actual, actual, name };
4749
4847
  }
4848
+ var LockFileError;
4750
4849
  var init_store = __esm(() => {
4751
4850
  init_db();
4752
4851
  init_config();
4852
+ LockFileError = class LockFileError extends Error {
4853
+ constructor(message) {
4854
+ super(message);
4855
+ this.name = "LockFileError";
4856
+ }
4857
+ };
4753
4858
  });
4754
4859
 
4755
4860
  // src/lib/profiles.ts
@@ -4895,13 +5000,73 @@ var init_profiles = __esm(() => {
4895
5000
  PROFILES_DIR = resolveProfilesDir();
4896
5001
  });
4897
5002
 
5003
+ // src/lib/hook-env.ts
5004
+ function isDeniedEnvName(name) {
5005
+ const upper = name.toUpperCase();
5006
+ if (DENY_PREFIXES.some((prefix) => upper.startsWith(prefix)))
5007
+ return true;
5008
+ if (DENY_SUFFIXES.some((suffix) => upper.endsWith(suffix)))
5009
+ return true;
5010
+ return DENY_CONTAINS.some((token) => upper.includes(token));
5011
+ }
5012
+ function buildHookEnv(source, extra) {
5013
+ const out = {};
5014
+ for (const name of ALLOWLIST) {
5015
+ const value = source[name] ?? extra?.[name];
5016
+ if (value !== undefined && value !== null)
5017
+ out[name] = value;
5018
+ }
5019
+ for (const [from, to] of CONFIG_PROJECTIONS) {
5020
+ const value = source[from] ?? extra?.[from];
5021
+ if (value !== undefined && value !== null)
5022
+ out[to] = value;
5023
+ }
5024
+ const merged = { ...source, ...extra ?? {} };
5025
+ for (const [name, value] of Object.entries(merged)) {
5026
+ if (out[name] !== undefined)
5027
+ continue;
5028
+ if (isDeniedEnvName(name))
5029
+ continue;
5030
+ if (value !== undefined && value !== null)
5031
+ out[name] = value;
5032
+ }
5033
+ return out;
5034
+ }
5035
+ var ALLOWLIST, DENY_SUFFIXES, DENY_PREFIXES, DENY_CONTAINS, CONFIG_PROJECTIONS;
5036
+ var init_hook_env = __esm(() => {
5037
+ ALLOWLIST = new Set(["PATH", "HOME", "LANG", "TZ", "SHELL", "TERM", "USER", "PWD"]);
5038
+ DENY_SUFFIXES = ["KEY", "TOKEN", "SECRET", "PASSWORD", "PASSWD", "CREDENTIAL", "CREDENTIALS", "URL", "URI"];
5039
+ DENY_PREFIXES = [
5040
+ "HASNA_",
5041
+ "AWS_",
5042
+ "AZURE_",
5043
+ "GCP_",
5044
+ "VAULT_",
5045
+ "GOOGLE_",
5046
+ "OPENAI_",
5047
+ "ANTHROPIC_",
5048
+ "POSTGRES_",
5049
+ "MYSQL_",
5050
+ "REDIS_",
5051
+ "MONGO_",
5052
+ "MEMENTOS_"
5053
+ ];
5054
+ DENY_CONTAINS = ["DATABASE_URL"];
5055
+ CONFIG_PROJECTIONS = [
5056
+ ["HASNA_HOOKS_DATA_DIR", "HOOKS_DATA_DIR"],
5057
+ ["HASNA_HOOKS_DB_PATH", "HOOKS_DB_PATH"],
5058
+ ["HASNA_HOOKS_LOCK_PATH", "HOOKS_LOCK_PATH"],
5059
+ ["HASNA_HOOKS_CONFIG_PATH", "HOOKS_CONFIG_PATH"]
5060
+ ];
5061
+ });
5062
+
4898
5063
  // src/lib/run.ts
4899
5064
  var exports_run = {};
4900
5065
  __export(exports_run, {
4901
5066
  executeVerifiedScript: () => executeVerifiedScript,
4902
5067
  HookTimeoutError: () => HookTimeoutError
4903
5068
  });
4904
- import { randomBytes } from "crypto";
5069
+ import { randomBytes as randomBytes2 } from "crypto";
4905
5070
  import { rmSync as rmSync4, writeFileSync as writeFileSync6 } from "fs";
4906
5071
  import { basename as basename2, dirname as dirname4, isAbsolute as isAbsolute2, join as join9 } from "path";
4907
5072
  function extensionOf(scriptPath) {
@@ -4974,12 +5139,12 @@ async function readPipeWithDeadline(stream, deadlineMs) {
4974
5139
  async function executeVerifiedScript(options) {
4975
5140
  const scriptDir = dirname4(options.scriptPath);
4976
5141
  const interpreter = interpreterFor(options.name, options.scriptPath, options.content);
4977
- const tempPath = join9(scriptDir, `.hooks-verified-${randomBytes(12).toString("hex")}.${interpreter.tempExt}`);
5142
+ const tempPath = join9(scriptDir, `.hooks-verified-${randomBytes2(12).toString("hex")}.${interpreter.tempExt}`);
4978
5143
  let proc = null;
4979
5144
  let timedOut = false;
4980
5145
  const timeoutTimer = options.timeout ? setTimeout(() => {
4981
5146
  timedOut = true;
4982
- killGroup(proc);
5147
+ killGroup(proc, { leaderAlive: proc !== null && proc.exitCode === null });
4983
5148
  }, options.timeout) : null;
4984
5149
  try {
4985
5150
  writeFileSync6(tempPath, options.content, { flag: "wx", mode: 384 });
@@ -4987,7 +5152,7 @@ async function executeVerifiedScript(options) {
4987
5152
  stdin: new Response(options.stdin),
4988
5153
  stdout: "pipe",
4989
5154
  stderr: "pipe",
4990
- env: options.env ?? process.env,
5155
+ env: buildHookEnv(process.env, options.env),
4991
5156
  detached: true
4992
5157
  });
4993
5158
  const exitCode = await proc.exited;
@@ -4998,7 +5163,7 @@ async function executeVerifiedScript(options) {
4998
5163
  readPipeWithDeadline(proc.stdout, 750),
4999
5164
  readPipeWithDeadline(proc.stderr, 750)
5000
5165
  ]);
5001
- killGroup(proc);
5166
+ killGroup(proc, { leaderAlive: false });
5002
5167
  return { stdout: stdoutText, stderr: stderrText, exitCode };
5003
5168
  } finally {
5004
5169
  if (timeoutTimer)
@@ -5008,12 +5173,36 @@ async function executeVerifiedScript(options) {
5008
5173
  } catch {}
5009
5174
  }
5010
5175
  }
5011
- function killGroup(proc) {
5176
+ function killGroup(proc, options) {
5012
5177
  if (!proc)
5013
5178
  return;
5014
- try {
5015
- process.kill(-proc.pid, "SIGKILL");
5016
- } catch {
5179
+ const pgid = proc.pid;
5180
+ for (let pass = 0;pass < 5; pass++) {
5181
+ let members = [];
5182
+ try {
5183
+ const res = Bun.spawnSync(["ps", "-eo", "pid=,pgid="], { stdout: "pipe", stderr: "pipe" });
5184
+ if (res.exitCode === 0) {
5185
+ members = res.stdout.toString().split(`
5186
+ `).map((line) => line.trim().split(/\s+/)).filter((cols) => cols.length === 2 && cols[1] === String(pgid)).map((cols) => Number(cols[0])).filter((pid) => Number.isInteger(pid) && pid > 1).filter((pid) => options.leaderAlive || pid !== proc.pid);
5187
+ }
5188
+ } catch {}
5189
+ if (members.length === 0)
5190
+ break;
5191
+ let killed = 0;
5192
+ for (const pid of members) {
5193
+ try {
5194
+ const probe = Bun.spawnSync(["ps", "-o", "pid=,pgid=", "-p", String(pid)], { stdout: "pipe", stderr: "pipe" });
5195
+ const cols = probe.stdout.toString().trim().split(/\s+/);
5196
+ if (cols.length === 2 && cols[1] === String(pgid)) {
5197
+ process.kill(pid, "SIGKILL");
5198
+ killed++;
5199
+ }
5200
+ } catch {}
5201
+ }
5202
+ if (killed === 0)
5203
+ break;
5204
+ }
5205
+ if (options.leaderAlive) {
5017
5206
  try {
5018
5207
  process.kill(proc.pid, "SIGKILL");
5019
5208
  } catch {}
@@ -5021,6 +5210,7 @@ function killGroup(proc) {
5021
5210
  }
5022
5211
  var HookTimeoutError, BASH_EXTENSIONS, BUN_EXTENSIONS;
5023
5212
  var init_run = __esm(() => {
5213
+ init_hook_env();
5024
5214
  HookTimeoutError = class HookTimeoutError extends Error {
5025
5215
  timeoutMs;
5026
5216
  constructor(timeoutMs) {
@@ -5049,6 +5239,9 @@ function writeHookEvent(event) {
5049
5239
  const db = getDb();
5050
5240
  const id = nanoid2();
5051
5241
  const timestamp = event.timestamp ?? new Date().toISOString();
5242
+ const toolInput = redactEventPayload(event.tool_input);
5243
+ const error = redactEventPayload(event.error);
5244
+ const metadata = redactEventPayload(event.metadata);
5052
5245
  db.run(`INSERT INTO hook_events
5053
5246
  (id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
5054
5247
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
@@ -5058,12 +5251,12 @@ function writeHookEvent(event) {
5058
5251
  event.hook_name,
5059
5252
  event.event_type,
5060
5253
  event.tool_name ?? null,
5061
- event.tool_input ? event.tool_input.slice(0, 500) : null,
5254
+ toolInput ? toolInput.slice(0, 500) : null,
5062
5255
  event.result ?? null,
5063
- event.error ?? null,
5256
+ error ?? null,
5064
5257
  event.duration_ms ?? null,
5065
5258
  event.project_dir ?? null,
5066
- event.metadata ?? null
5259
+ metadata ?? null
5067
5260
  ]);
5068
5261
  } catch (err) {
5069
5262
  process.stderr.write(`[hooks db-writer] failed to write event: ${err}
@@ -5113,6 +5306,7 @@ function recordHookRun(record) {
5113
5306
  var ALLOWED_EVENT_TYPES;
5114
5307
  var init_db_writer = __esm(() => {
5115
5308
  init_db();
5309
+ init_redact();
5116
5310
  ALLOWED_EVENT_TYPES = new Set([
5117
5311
  "PreToolUse",
5118
5312
  "PostToolUse",
@@ -5135,7 +5329,7 @@ var PG_MIGRATIONS = [
5135
5329
  timestamp TEXT NOT NULL,
5136
5330
  session_id TEXT NOT NULL,
5137
5331
  hook_name TEXT NOT NULL,
5138
- event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit')),
5332
+ event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'SubagentStart')),
5139
5333
  tool_name TEXT,
5140
5334
  tool_input TEXT,
5141
5335
  result TEXT CHECK (result IN ('continue', 'block', NULL)),
@@ -5168,7 +5362,10 @@ var PG_MIGRATIONS = [
5168
5362
  )`,
5169
5363
  `ALTER TABLE hook_events DROP CONSTRAINT IF EXISTS hook_events_event_type_check`,
5170
5364
  `ALTER TABLE hook_events ADD CONSTRAINT hook_events_event_type_check
5171
- CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit'))`
5365
+ CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit'))`,
5366
+ `ALTER TABLE hook_events DROP CONSTRAINT IF EXISTS hook_events_event_type_check`,
5367
+ `ALTER TABLE hook_events ADD CONSTRAINT hook_events_event_type_check
5368
+ CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit', 'SubagentStart'))`
5172
5369
  ];
5173
5370
 
5174
5371
  // src/db/remote-storage.ts
@@ -5182,7 +5379,34 @@ function normalizeParams(params) {
5182
5379
  return flat.map((value) => value === undefined ? null : value);
5183
5380
  }
5184
5381
  function sslConfigFor(connectionString) {
5185
- return connectionString.includes("sslmode=require") || connectionString.includes("ssl=true") ? { rejectUnauthorized: false } : undefined;
5382
+ const wantsSsl = wantsSslFor(connectionString);
5383
+ if (!wantsSsl)
5384
+ return;
5385
+ const insecure = process.env.HASNA_HOOKS_PG_INSECURE_TLS === "1" || process.env.HOOKS_PG_INSECURE_TLS === "1";
5386
+ if (insecure && false) {}
5387
+ return insecure ? { rejectUnauthorized: false } : { rejectUnauthorized: true };
5388
+ }
5389
+ function wantsSslFor(connectionString) {
5390
+ let raw;
5391
+ try {
5392
+ raw = new URL(connectionString).search.replace(/^\?/, "");
5393
+ } catch {
5394
+ raw = connectionString;
5395
+ }
5396
+ try {
5397
+ raw = decodeURIComponent(raw);
5398
+ } catch {}
5399
+ const paramValue = (name) => {
5400
+ const match = raw.match(new RegExp(`(?:^|[&\\s])${name}\\s*=\\s*([^&\\s]+)`, "i"));
5401
+ return match ? match[1].toLowerCase() : undefined;
5402
+ };
5403
+ const sslmode = paramValue("sslmode");
5404
+ if (sslmode !== undefined)
5405
+ return sslmode !== "disable" && sslmode !== "allow";
5406
+ const ssl = paramValue("ssl");
5407
+ if (ssl !== undefined)
5408
+ return ssl !== "false" && ssl !== "0" && ssl !== "no";
5409
+ return false;
5186
5410
  }
5187
5411
 
5188
5412
  class PgAdapterAsync {
@@ -5204,6 +5428,7 @@ class PgAdapterAsync {
5204
5428
  }
5205
5429
 
5206
5430
  // src/db/storage-sync.ts
5431
+ init_redact();
5207
5432
  var STORAGE_TABLES = [
5208
5433
  "hook_events",
5209
5434
  "schema_migrations",
@@ -5380,9 +5605,15 @@ async function pushTable(db, remote, table) {
5380
5605
  result.rowsRead = rows.length;
5381
5606
  if (rows.length === 0)
5382
5607
  return result;
5608
+ const projected = table === "hook_events" ? rows.map((row) => ({
5609
+ ...row,
5610
+ tool_input: typeof row.tool_input === "string" ? redactEventPayload(row.tool_input) : row.tool_input,
5611
+ error: typeof row.error === "string" ? redactEventPayload(row.error) : row.error,
5612
+ metadata: typeof row.metadata === "string" ? redactEventPayload(row.metadata) : row.metadata
5613
+ })) : rows;
5383
5614
  const remoteColumns = await getRemoteColumns(remote, table);
5384
- const columns = filterRemoteColumns(remoteColumns, Object.keys(rows[0]));
5385
- result.rowsWritten = await upsertPg(remote, table, columns, rows, remoteColumns);
5615
+ const columns = filterRemoteColumns(remoteColumns, Object.keys(projected[0]));
5616
+ result.rowsWritten = await upsertPg(remote, table, columns, projected, remoteColumns);
5386
5617
  } catch (error) {
5387
5618
  result.errors.push(error instanceof Error ? error.message : String(error));
5388
5619
  }
@@ -5397,8 +5628,14 @@ async function pullTable(remote, db, table) {
5397
5628
  result.rowsRead = rows.length;
5398
5629
  if (rows.length === 0)
5399
5630
  return result;
5400
- const columns = filterLocalColumns(db, table, Object.keys(rows[0]));
5401
- result.rowsWritten = upsertSqlite(db, table, columns, rows);
5631
+ const projected = table === "hook_events" ? rows.map((row) => ({
5632
+ ...row,
5633
+ tool_input: typeof row.tool_input === "string" ? redactEventPayload(row.tool_input) : row.tool_input,
5634
+ error: typeof row.error === "string" ? redactEventPayload(row.error) : row.error,
5635
+ metadata: typeof row.metadata === "string" ? redactEventPayload(row.metadata) : row.metadata
5636
+ })) : rows;
5637
+ const columns = filterLocalColumns(db, table, Object.keys(projected[0]));
5638
+ result.rowsWritten = upsertSqlite(db, table, columns, projected);
5402
5639
  } catch (error) {
5403
5640
  result.errors.push(error instanceof Error ? error.message : String(error));
5404
5641
  }
@@ -6212,7 +6449,7 @@ function getGlobalSettingsPathOverride(target) {
6212
6449
  function getSettingsPath(scope = "global", target = "claude", codewithConfigPath) {
6213
6450
  if (target === "codewith" && codewithConfigPath)
6214
6451
  return codewithConfigPath;
6215
- if (target === "codewith" && process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH) {
6452
+ if (target === "codewith" && scope === "global" && process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH) {
6216
6453
  return process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH;
6217
6454
  }
6218
6455
  const globalOverride = scope === "global" ? getGlobalSettingsPathOverride(target) : undefined;
@@ -6646,8 +6883,8 @@ function escapeRegExp(value) {
6646
6883
  function codewithHasHookEntryInText(configText, name) {
6647
6884
  return new RegExp(`hooks run ${escapeRegExp(name)}(?:\\s+--profile\\s+[\\w-]+)?(?:$|\\s|")`).test(configText);
6648
6885
  }
6649
- function codewithHasHookEntry(name) {
6650
- const path = getSettingsPath("global", "codewith");
6886
+ function codewithHasHookEntry(name, scope = "global") {
6887
+ const path = getSettingsPath(scope, "codewith");
6651
6888
  if (!existsSync7(path))
6652
6889
  return false;
6653
6890
  try {
@@ -6664,7 +6901,7 @@ function uninstallHook(name, scope = "global", target = "claude") {
6664
6901
  const registeredClaudeOrGemini = target === "all" ? existsInTarget("claude") || existsInTarget("gemini") : false;
6665
6902
  const registeredGlobal = registeredClaudeOrGemini || getRegisteredHooksForTarget("global", target === "all" ? "claude" : target).includes(shortName);
6666
6903
  const registeredProject = registeredClaudeOrGemini || getRegisteredHooksForTarget("project", target === "all" ? "claude" : target).includes(shortName);
6667
- const codewithOnly = (target === "codewith" || target === "all") && codewithHasHookEntry(shortName);
6904
+ const codewithOnly = (target === "codewith" || target === "all") && codewithHasHookEntry(shortName, scope);
6668
6905
  if (!custom2 && !bundledMeta && !registeredGlobal && !registeredProject && !codewithOnly) {
6669
6906
  return { name: shortName, removed: false, source: null, settingsScopes: [], storeDirRemoved: false, pinRemoved: false, dbRecordRemoved: false, registrationsRemaining: [], error: `Hook '${shortName}' not found` };
6670
6907
  }
@@ -6681,7 +6918,7 @@ function uninstallHook(name, scope = "global", target = "claude") {
6681
6918
  }
6682
6919
  const registrationsRemaining = [];
6683
6920
  if (target === "codewith" || target === "all") {
6684
- const codewithPath = getSettingsPath("global", "codewith");
6921
+ const codewithPath = getSettingsPath(scope, "codewith");
6685
6922
  if (existsSync7(codewithPath)) {
6686
6923
  const before = readFileSync5(codewithPath, "utf-8");
6687
6924
  const after = removeCodewithHookEntry(before, shortName);
@@ -6807,19 +7044,20 @@ async function installCustomSource(source) {
6807
7044
  return copyManifestDir(source, source, "local");
6808
7045
  }
6809
7046
  async function installManifestUrl(url) {
6810
- const res = await fetch(url, { redirect: "follow" });
7047
+ const res = await fetch(url, { redirect: "error" });
6811
7048
  if (!res.ok) {
6812
7049
  throw new Error(`Failed to fetch manifest (${res.status}): ${url}`);
6813
7050
  }
6814
7051
  const manifest = parseManifest(await res.text());
6815
7052
  let script;
6816
- if (manifest.script.includes(`
6817
- `)) {
7053
+ const inline = manifest.script_kind === "inline" || manifest.script_kind === undefined && manifest.script.includes(`
7054
+ `);
7055
+ if (inline) {
6818
7056
  script = { path: "script.ts", content: manifest.script };
6819
7057
  } else {
6820
7058
  const base = url.substring(0, url.lastIndexOf("/"));
6821
7059
  const scriptUrl = `${base}/${manifest.script}`;
6822
- const scriptRes = await fetch(scriptUrl, { redirect: "follow" });
7060
+ const scriptRes = await fetch(scriptUrl, { redirect: "error" });
6823
7061
  if (!scriptRes.ok) {
6824
7062
  throw new Error(`Failed to fetch script (${scriptRes.status}): ${scriptUrl}`);
6825
7063
  }
@@ -6856,7 +7094,7 @@ function collectBundledCatalog() {
6856
7094
  }
6857
7095
  return out;
6858
7096
  }
6859
- async function fetchJson(base, path) {
7097
+ async function fetchJson(base, path, opts) {
6860
7098
  const headers = { accept: "application/json" };
6861
7099
  const apiKey = resolveApiKey();
6862
7100
  if (apiKey)
@@ -6872,7 +7110,11 @@ async function fetchJson(base, path) {
6872
7110
  if (!res.ok) {
6873
7111
  throw new Error(`GET ${path} failed with status ${res.status}`);
6874
7112
  }
6875
- return res.json();
7113
+ const body = await res.json();
7114
+ if (opts?.withHeader) {
7115
+ body[opts.withHeader] = res.headers.get("x-hook-sha256");
7116
+ }
7117
+ return body;
6876
7118
  }
6877
7119
  async function fetchRemoteState(apiUrl) {
6878
7120
  const catalog = await fetchJson(apiUrl, "/api/v1/catalog");
@@ -6885,6 +7127,16 @@ async function fetchRemoteState(apiUrl) {
6885
7127
  }
6886
7128
  return { catalog: catalog.hooks, remoteLock };
6887
7129
  }
7130
+ function classifyRemotePin(pin, remoteVersion, remoteSha) {
7131
+ const pinnedLocally = pin !== undefined && pin.version === remoteVersion && pin.sha256 === remoteSha;
7132
+ if (pinnedLocally)
7133
+ return "unchanged";
7134
+ if (pin !== undefined && pin.pinned === true && pin.version !== remoteVersion)
7135
+ return "unchanged";
7136
+ if (pin !== undefined)
7137
+ return "updated";
7138
+ return "added";
7139
+ }
6888
7140
  function computeDiff(catalog, remoteLock) {
6889
7141
  const local = readLock();
6890
7142
  const diff = { added: [], updated: [], unchanged: [], skipped: [] };
@@ -6892,30 +7144,30 @@ function computeDiff(catalog, remoteLock) {
6892
7144
  if (!entry.name || !entry.version || !entry.sha256)
6893
7145
  continue;
6894
7146
  const pin = local.hooks[entry.name];
6895
- const remotePin = remoteLock ? remoteLock.hooks[entry.name] : entry;
6896
- if (remoteLock && !remotePin) {
6897
- diff.skipped.push(entry.name);
6898
- continue;
6899
- }
6900
- const pinnedLocally = pin && pin.version === entry.version && pin.sha256 === entry.sha256;
6901
- if (pinnedLocally) {
6902
- diff.unchanged.push(entry.name);
6903
- } else if (pin) {
6904
- diff.updated.push(entry.name);
7147
+ if (remoteLock) {
7148
+ const remotePin = remoteLock.hooks[entry.name];
7149
+ if (!remotePin) {
7150
+ diff.skipped.push(entry.name);
7151
+ continue;
7152
+ }
7153
+ if (remotePin.version !== entry.version) {
7154
+ 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.`);
7155
+ }
7156
+ diff[classifyRemotePin(pin, remotePin.version, remotePin.sha256)].push(entry.name);
6905
7157
  } else {
6906
- diff.added.push(entry.name);
7158
+ diff[classifyRemotePin(pin, entry.version, entry.sha256)].push(entry.name);
6907
7159
  }
6908
7160
  }
6909
7161
  return diff;
6910
7162
  }
6911
- async function planSync() {
7163
+ async function planSync(options = {}) {
6912
7164
  const apiUrl = resolveApiUrl();
6913
7165
  if (apiUrl) {
6914
7166
  const { catalog, remoteLock } = await fetchRemoteState(apiUrl);
6915
- return { apiUrl, dryRun: false, diff: computeDiff(catalog, remoteLock) };
7167
+ return { apiUrl, dryRun: options.dryRun ?? false, diff: computeDiff(catalog, remoteLock) };
6916
7168
  }
6917
7169
  const diff = computeDiff(collectBundledCatalog(), null);
6918
- return { apiUrl: null, dryRun: false, diff };
7170
+ return { apiUrl: null, dryRun: options.dryRun ?? false, diff };
6919
7171
  }
6920
7172
  async function syncHooks(options = {}) {
6921
7173
  const apiUrl = resolveApiUrl();
@@ -6923,10 +7175,10 @@ async function syncHooks(options = {}) {
6923
7175
  if (options.dryRun)
6924
7176
  return { ...plan, dryRun: true };
6925
7177
  if (!apiUrl) {
6926
- const db2 = getDb();
7178
+ const db = getDb();
6927
7179
  for (const entry of collectBundledCatalog()) {
6928
7180
  setPinnedHook(entry.name, { version: entry.version, sha256: entry.sha256, source: "bundled" });
6929
- upsertHookRecord(db2, {
7181
+ upsertHookRecord(db, {
6930
7182
  name: entry.name,
6931
7183
  version: entry.version,
6932
7184
  sha256: entry.sha256,
@@ -6939,13 +7191,17 @@ async function syncHooks(options = {}) {
6939
7191
  }
6940
7192
  const { catalog, remoteLock } = await fetchRemoteState(apiUrl);
6941
7193
  const diff = computeDiff(catalog, remoteLock);
6942
- const toFetch = [...diff.added, ...diff.updated];
6943
- const artifacts = new Map;
6944
- for (const name of toFetch) {
7194
+ const staged = await stageSyncArtifacts(apiUrl, [...diff.added, ...diff.updated], remoteLock);
7195
+ await commitSyncArtifacts(staged, apiUrl, remoteLock);
7196
+ return { apiUrl, dryRun: false, diff };
7197
+ }
7198
+ async function stageSyncArtifacts(apiUrl, names, remoteLock) {
7199
+ const staged = [];
7200
+ for (const name of names) {
6945
7201
  const entry = remoteLock.hooks[name];
6946
7202
  if (!entry)
6947
7203
  continue;
6948
- const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${name}/${entry.version}`);
7204
+ const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${encodeURIComponent(name)}/${encodeURIComponent(entry.version)}`);
6949
7205
  if (!artifact.manifest || typeof artifact.script !== "string") {
6950
7206
  throw new Error(`artifact for '${name}@${entry.version}' is malformed`);
6951
7207
  }
@@ -6953,34 +7209,64 @@ async function syncHooks(options = {}) {
6953
7209
  if (actualSha !== entry.sha256) {
6954
7210
  throw new Error(`sha256 mismatch for '${name}@${entry.version}': lock says ${entry.sha256}, artifact has ${actualSha}`);
6955
7211
  }
6956
- artifacts.set(name, artifact);
6957
- }
6958
- const db = getDb();
6959
- for (const name of toFetch) {
6960
- const artifact = artifacts.get(name);
6961
- const entry = remoteLock.hooks[name];
6962
- if (!artifact || !entry)
6963
- continue;
6964
7212
  const manifest = parseManifest(JSON.stringify({ ...artifact.manifest, name: shortManifestName(artifact.manifest.name || name) }));
6965
- const scriptRel = artifact.manifest.script.includes(`
6966
- `) ? "script.ts" : artifact.manifest.script;
6967
- writeCustomHook(name, manifest, artifact.script, scriptRel);
6968
- const scriptPath = resolveScriptPath(name);
6969
- const actual = await sha256File(scriptPath);
6970
- if (actual !== entry.sha256) {
6971
- throw new Error(`sha256 mismatch after write for '${name}@${entry.version}': lock says ${entry.sha256}, on disk ${actual}`);
7213
+ if (manifest.version !== entry.version) {
7214
+ throw new Error(`artifact for '${name}@${entry.version}' declares a different version ('${manifest.version}')`);
6972
7215
  }
6973
- setPinnedHook(name, { version: entry.version, sha256: entry.sha256, source: entry.source ?? "remote" });
6974
- upsertHookRecord(db, {
7216
+ const scriptRel = scriptRelFor(manifest);
7217
+ staged.push({
6975
7218
  name,
6976
7219
  version: entry.version,
6977
7220
  sha256: entry.sha256,
6978
- source_type: entry.source ?? "remote",
6979
- source_ref: apiUrl,
6980
- last_verified_at: new Date().toISOString()
7221
+ source: entry.source ?? "remote",
7222
+ manifest,
7223
+ scriptContent: artifact.script,
7224
+ scriptRel
6981
7225
  });
6982
7226
  }
6983
- return { apiUrl, dryRun: false, diff };
7227
+ return staged;
7228
+ }
7229
+ async function commitSyncArtifacts(staged, apiUrl, remoteLock) {
7230
+ const db = getDb();
7231
+ for (const item of staged) {
7232
+ const entry = remoteLock.hooks[item.name];
7233
+ if (!entry)
7234
+ continue;
7235
+ writeCustomHook(item.name, item.manifest, item.scriptContent, item.scriptRel);
7236
+ const scriptPath = resolveScriptPath(item.name);
7237
+ const actual = await sha256File(scriptPath);
7238
+ if (actual !== entry.sha256) {
7239
+ throw new Error(`sha256 mismatch after write for '${item.name}@${entry.version}': lock says ${entry.sha256}, on disk ${actual}`);
7240
+ }
7241
+ }
7242
+ const lock = readLock();
7243
+ for (const item of staged) {
7244
+ const entry = remoteLock.hooks[item.name];
7245
+ if (!entry)
7246
+ continue;
7247
+ lock.hooks[item.name] = { version: entry.version, sha256: entry.sha256, source: entry.source ?? "remote" };
7248
+ }
7249
+ writeLock(lock);
7250
+ db.exec("BEGIN");
7251
+ try {
7252
+ for (const item of staged) {
7253
+ const entry = remoteLock.hooks[item.name];
7254
+ if (!entry)
7255
+ continue;
7256
+ upsertHookRecord(db, {
7257
+ name: item.name,
7258
+ version: entry.version,
7259
+ sha256: entry.sha256,
7260
+ source_type: entry.source ?? "remote",
7261
+ source_ref: apiUrl,
7262
+ last_verified_at: new Date().toISOString()
7263
+ });
7264
+ }
7265
+ db.exec("COMMIT");
7266
+ } catch (err) {
7267
+ db.exec("ROLLBACK");
7268
+ throw err;
7269
+ }
6984
7270
  }
6985
7271
  async function fetchPinnedHook(name, version, apiUrl) {
6986
7272
  const remoteLock = await fetchJson(apiUrl, "/api/v1/lock");
@@ -6988,10 +7274,11 @@ async function fetchPinnedHook(name, version, apiUrl) {
6988
7274
  if (!entry) {
6989
7275
  throw new Error(`Hook '${name}' is not in the remote registry lock`);
6990
7276
  }
6991
- if (entry.version !== version) {
6992
- throw new Error(`Hook '${name}' version ${version} is not in the remote registry (available: ${entry.version})`);
7277
+ const isLatest = entry.version === version;
7278
+ if (!isLatest && !(entry.versions ?? []).includes(version)) {
7279
+ throw new Error(`Hook '${name}' version ${version} is not in the remote registry (available: ${[entry.version, ...entry.versions ?? []].join(", ")})`);
6993
7280
  }
6994
- const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${name}/${version}`);
7281
+ const artifact = await fetchJson(apiUrl, `/api/v1/hooks/${encodeURIComponent(name)}/${encodeURIComponent(version)}`, { withHeader: "_headerSha" });
6995
7282
  if (!artifact.manifest || typeof artifact.script !== "string") {
6996
7283
  throw new Error(`artifact for '${name}@${version}' is malformed`);
6997
7284
  }
@@ -7003,24 +7290,31 @@ async function fetchPinnedHook(name, version, apiUrl) {
7003
7290
  throw new Error(`artifact for '${name}@${version}' declares a different version ('${artifact.manifest.version}')`);
7004
7291
  }
7005
7292
  const actualSha = sha256OfText(artifact.script);
7006
- if (actualSha !== entry.sha256) {
7007
- throw new Error(`sha256 mismatch for '${name}@${version}': lock says ${entry.sha256}, artifact has ${actualSha}`);
7293
+ let expectedSha = entry.sha256;
7294
+ if (!isLatest) {
7295
+ const headerSha = artifact._headerSha;
7296
+ if (!headerSha) {
7297
+ throw new Error(`registry did not return a sha256 header for '${name}@${version}' (older pin); refusing to trust an unverified artifact`);
7298
+ }
7299
+ expectedSha = headerSha;
7300
+ }
7301
+ if (actualSha !== expectedSha) {
7302
+ throw new Error(`sha256 mismatch for '${name}@${version}': expected ${expectedSha}, artifact has ${actualSha}`);
7008
7303
  }
7009
7304
  const manifest = parseManifest(JSON.stringify({ ...artifact.manifest, name: shortManifestName(artifact.manifest.name || name) }));
7010
- const scriptRel = artifact.manifest.script.includes(`
7011
- `) ? "script.ts" : artifact.manifest.script;
7305
+ const scriptRel = scriptRelFor(manifest);
7012
7306
  writeCustomHook(name, manifest, artifact.script, scriptRel);
7013
7307
  const scriptPath = resolveScriptPath(name);
7014
7308
  const actual = await sha256File(scriptPath);
7015
- if (actual !== entry.sha256) {
7016
- throw new Error(`sha256 mismatch after write for '${name}@${version}': lock says ${entry.sha256}, on disk ${actual}`);
7309
+ if (actual !== expectedSha) {
7310
+ throw new Error(`sha256 mismatch after write for '${name}@${version}': expected ${expectedSha}, on disk ${actual}`);
7017
7311
  }
7018
7312
  const db = getDb();
7019
- setPinnedHook(name, { version, sha256: entry.sha256, source: entry.source ?? "remote" });
7313
+ setPinnedHook(name, { version, sha256: expectedSha, source: entry.source ?? "remote", pinned: true });
7020
7314
  upsertHookRecord(db, {
7021
7315
  name,
7022
7316
  version,
7023
- sha256: entry.sha256,
7317
+ sha256: expectedSha,
7024
7318
  source_type: entry.source ?? "remote",
7025
7319
  source_ref: apiUrl,
7026
7320
  last_verified_at: new Date().toISOString()
@@ -7028,7 +7322,7 @@ async function fetchPinnedHook(name, version, apiUrl) {
7028
7322
  return {
7029
7323
  name,
7030
7324
  version,
7031
- sha256: entry.sha256,
7325
+ sha256: expectedSha,
7032
7326
  source: entry.source ?? "remote",
7033
7327
  source_ref: apiUrl,
7034
7328
  artifact,
@@ -7054,6 +7348,7 @@ function secureEqual(a, b) {
7054
7348
  }
7055
7349
 
7056
7350
  // src/serve.ts
7351
+ init_semver();
7057
7352
  var DEFAULT_SERVE_PORT = 39428;
7058
7353
  var SERVE_HOST = "127.0.0.1";
7059
7354
  var SERVE_SERVICE_NAME = "hooks-registry";
@@ -7063,24 +7358,29 @@ async function buildCatalog() {
7063
7358
  const scriptPath = resolveHook(meta.name)?.scriptPath;
7064
7359
  if (!scriptPath)
7065
7360
  continue;
7361
+ const sha = await sha256File(scriptPath);
7066
7362
  byName.set(meta.name, {
7067
7363
  name: meta.name,
7068
7364
  version: meta.version,
7069
- sha256: await sha256File(scriptPath),
7365
+ sha256: sha,
7070
7366
  events: meta.events && meta.events.length > 0 ? meta.events : [meta.event],
7071
7367
  description: meta.description,
7072
- source: "bundled"
7368
+ source: "bundled",
7369
+ versions: [meta.version]
7073
7370
  });
7074
7371
  }
7075
7372
  for (const custom2 of listCustomHooks()) {
7076
7373
  const name = shortManifestName(custom2.manifest.name);
7374
+ const sha = await sha256File(custom2.scriptPath);
7375
+ const existing = byName.get(name);
7077
7376
  byName.set(name, {
7078
7377
  name,
7079
7378
  version: custom2.manifest.version,
7080
- sha256: await sha256File(custom2.scriptPath),
7379
+ sha256: sha,
7081
7380
  events: custom2.manifest.events,
7082
7381
  description: custom2.manifest.description ?? "Custom hook",
7083
- source: byName.has(name) ? "custom-overrides-bundled" : "custom"
7382
+ source: existing ? "custom-overrides-bundled" : "custom",
7383
+ versions: existing ? [...new Set([...existing.versions, custom2.manifest.version])] : [custom2.manifest.version]
7084
7384
  });
7085
7385
  }
7086
7386
  return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
@@ -7098,6 +7398,7 @@ async function artifactFor(name, version) {
7098
7398
  description: resolved.description,
7099
7399
  events: resolved.events,
7100
7400
  script: custom2 ? custom2.manifest.script : `src/hook.ts`,
7401
+ script_kind: custom2?.manifest.script_kind,
7101
7402
  args: custom2?.manifest.args,
7102
7403
  timeout_ms: custom2?.manifest.timeout_ms
7103
7404
  };
@@ -7127,11 +7428,32 @@ function handleServeRequest(req, apiKey) {
7127
7428
  return buildCatalog().then((catalog) => json({ hooks: catalog }));
7128
7429
  }
7129
7430
  if (url.pathname === "/api/v1/lock" && req.method === "GET") {
7130
- return Promise.resolve(json(readLock()));
7431
+ return buildCatalog().then((catalog) => {
7432
+ const lock = readLock();
7433
+ const byVersion = {};
7434
+ for (const entry of catalog)
7435
+ byVersion[entry.name] = entry.versions;
7436
+ const hooks = {};
7437
+ for (const [name, pin] of Object.entries(lock.hooks)) {
7438
+ hooks[name] = { ...pin, versions: byVersion[name] ?? [pin.version] };
7439
+ }
7440
+ return json({ hooks });
7441
+ });
7131
7442
  }
7132
- const artifactMatch = /^\/api\/v1\/hooks\/([\w-]+)\/(\d+\.\d+\.\d+)$/.exec(url.pathname);
7443
+ const artifactMatch = /^\/api\/v1\/hooks\/([\w-]+)\/([0-9A-Za-z.%+_-]+)$/.exec(url.pathname);
7133
7444
  if (artifactMatch && req.method === "GET") {
7134
- const [, name, version] = artifactMatch;
7445
+ const [, rawName, rawVersion] = artifactMatch;
7446
+ let name;
7447
+ let version;
7448
+ try {
7449
+ name = decodeURIComponent(rawName);
7450
+ version = decodeURIComponent(rawVersion);
7451
+ } catch {
7452
+ return Promise.resolve(json({ error: "invalid URL encoding" }, 400));
7453
+ }
7454
+ if (!SEMVER_PATTERN.test(version)) {
7455
+ return Promise.resolve(json({ error: `invalid semver version '${version}'` }, 400));
7456
+ }
7135
7457
  return artifactFor(name, version).then(async (artifact) => {
7136
7458
  if (!artifact)
7137
7459
  return json({ error: `Hook '${name}@${version}' not found locally` }, 404);
@@ -7162,7 +7484,7 @@ function handleServeRequest(req, apiKey) {
7162
7484
  function startServeServer(options) {
7163
7485
  const port = options.port ?? DEFAULT_SERVE_PORT;
7164
7486
  const host = options.host ?? SERVE_HOST;
7165
- const apiKey = resolveApiKey(options.apiKey);
7487
+ const apiKey = resolveApiKey();
7166
7488
  const server = Bun.serve({
7167
7489
  hostname: host,
7168
7490
  port,
@@ -7308,7 +7630,6 @@ async function runHook(name, input, options = {}) {
7308
7630
  content,
7309
7631
  args: custom2?.manifest.args ?? [],
7310
7632
  stdin: JSON.stringify(hookInput),
7311
- env: process.env,
7312
7633
  timeout: effectiveTimeout ?? undefined
7313
7634
  }));
7314
7635
  } catch (err) {