@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/bin/serve.js CHANGED
@@ -4518,22 +4518,27 @@ function getLockPath() {
4518
4518
  return explicit;
4519
4519
  return join(getHooksDataDir(), "hooks.lock");
4520
4520
  }
4521
- function resolveApiKey(flagKey) {
4522
- if (flagKey && flagKey.length > 0)
4523
- return flagKey;
4521
+ function resolveApiKey() {
4524
4522
  return process.env.HASNA_HOOKS_API_KEY ?? process.env.HOOKS_API_KEY;
4525
4523
  }
4526
4524
 
4525
+ // src/lib/semver.ts
4526
+ var NUMERIC_ID = "(0|[1-9]\\d{0,15})";
4527
+ var ALNUM_ID = "\\d*[A-Za-z-][0-9A-Za-z-]*";
4528
+ var SEMVER_PATTERN = new RegExp(`^${NUMERIC_ID}\\.${NUMERIC_ID}\\.${NUMERIC_ID}` + `(?:-(?:${NUMERIC_ID}|${ALNUM_ID})(?:\\.(?:${NUMERIC_ID}|${ALNUM_ID}))*)?` + `(?:\\+[0-9A-Za-z.-]+)?$`);
4529
+
4527
4530
  // src/lib/manifest.ts
4528
4531
  var HOOK_NAME_RE = /^[\w-]+$/;
4529
4532
  var manifestSchema = exports_external.object({
4530
4533
  name: exports_external.string().regex(HOOK_NAME_RE, "hook name must be /^[\\w-]+$/"),
4531
- version: exports_external.string().regex(/^\d+\.\d+\.\d+/, "version must be semver (e.g. 1.2.3)"),
4534
+ version: exports_external.string().regex(SEMVER_PATTERN, "version must be full semver (e.g. 1.2.3 or 1.2.3-beta.1)"),
4532
4535
  description: exports_external.string().optional(),
4533
4536
  events: exports_external.array(exports_external.string()).min(1, "at least one event is required"),
4534
4537
  script: exports_external.string().min(1, "script must name a relative path or inline content"),
4538
+ script_kind: exports_external.enum(["inline", "file"]).optional(),
4535
4539
  args: exports_external.array(exports_external.string()).optional(),
4536
- timeout_ms: exports_external.number().int().positive().optional()
4540
+ timeout_ms: exports_external.number().int().positive().optional(),
4541
+ env: exports_external.record(exports_external.string(), exports_external.string()).optional()
4537
4542
  });
4538
4543
  function parseManifest(json) {
4539
4544
  const parsed = JSON.parse(json);
@@ -4584,16 +4589,25 @@ function assertContained(target, root, raw) {
4584
4589
  assertContainedReal(root, lexical, raw);
4585
4590
  return lexical;
4586
4591
  }
4587
- function resolveScript(manifest, manifestDir) {
4588
- if (manifest.script.includes(`
4589
- `)) {
4592
+ function isInlineScript(manifest) {
4593
+ return manifest.script_kind === "inline" || manifest.script_kind === undefined && manifest.script.includes(`
4594
+ `);
4595
+ }
4596
+ function scriptRelFor(manifest) {
4597
+ if (isInlineScript(manifest)) {
4590
4598
  const ext = manifest.script.trim().startsWith("#!") ? ".sh" : ".ts";
4591
- return { path: `script${ext}`, content: manifest.script };
4599
+ return `script${ext}`;
4592
4600
  }
4593
4601
  const rel = normalize(manifest.script);
4594
4602
  if (isAbsolute(rel)) {
4595
4603
  throw new Error(`manifest script must be relative or inline, got absolute path '${manifest.script}'`);
4596
4604
  }
4605
+ return rel;
4606
+ }
4607
+ function resolveScript(manifest, manifestDir) {
4608
+ if (isInlineScript(manifest))
4609
+ return { path: scriptRelFor(manifest), content: manifest.script };
4610
+ const rel = scriptRelFor(manifest);
4597
4611
  const resolved = assertContained(join2(manifestDir, rel), manifestDir, manifest.script);
4598
4612
  if (!existsSync(resolved)) {
4599
4613
  throw new Error(`manifest script file not found: ${resolved}`);
@@ -4611,8 +4625,8 @@ function readCustomManifest(name) {
4611
4625
  try {
4612
4626
  const manifest = parseManifest(readFileSync(manifestPath, "utf-8"));
4613
4627
  const script = resolveScript(manifest, dir);
4614
- return { manifest, scriptPath: join2(dir, script.path), scriptContent: script.content, scriptIsInline: manifest.script.includes(`
4615
- `) };
4628
+ const scriptIsInline = isInlineScript(manifest);
4629
+ return { manifest, scriptPath: join2(dir, script.path), scriptContent: script.content, scriptIsInline };
4616
4630
  } catch (err) {
4617
4631
  if (err instanceof ScriptContainmentError)
4618
4632
  throw err;
@@ -4641,7 +4655,7 @@ function readdirSafe(dir) {
4641
4655
 
4642
4656
  // src/lib/store.ts
4643
4657
  import { createHash } from "crypto";
4644
- import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "fs";
4658
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync2, renameSync } from "fs";
4645
4659
  import { readFile } from "fs/promises";
4646
4660
 
4647
4661
  // src/db/index.ts
@@ -5002,6 +5016,7 @@ function getDb() {
5002
5016
  }
5003
5017
 
5004
5018
  // src/lib/store.ts
5019
+ import { randomBytes } from "crypto";
5005
5020
  function sha256Of(input) {
5006
5021
  return createHash("sha256").update(input).digest("hex");
5007
5022
  }
@@ -5037,16 +5052,45 @@ function upsertHookRecord(db, record) {
5037
5052
  record.last_verified_at ?? null
5038
5053
  ]);
5039
5054
  }
5055
+ class LockFileError extends Error {
5056
+ constructor(message) {
5057
+ super(message);
5058
+ this.name = "LockFileError";
5059
+ }
5060
+ }
5061
+ function assertLockShape(parsed, path) {
5062
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
5063
+ 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.`);
5064
+ }
5065
+ const hooks = parsed.hooks;
5066
+ if (hooks === null || typeof hooks !== "object" || Array.isArray(hooks)) {
5067
+ 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.`);
5068
+ }
5069
+ for (const [name, entry] of Object.entries(hooks)) {
5070
+ const e = entry;
5071
+ if (e === null || typeof e !== "object" || typeof e.version !== "string" || typeof e.sha256 !== "string") {
5072
+ 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.`);
5073
+ }
5074
+ }
5075
+ }
5040
5076
  function readLock() {
5077
+ const path = getLockPath();
5078
+ if (!existsSync4(path))
5079
+ return { hooks: {} };
5080
+ let raw;
5041
5081
  try {
5042
- const path = getLockPath();
5043
- if (!existsSync4(path))
5044
- return { hooks: {} };
5045
- const parsed = JSON.parse(readFileSync3(path, "utf-8"));
5046
- return { hooks: parsed.hooks ?? {} };
5082
+ raw = readFileSync3(path, "utf-8");
5047
5083
  } catch {
5048
- return { hooks: {} };
5084
+ throw new LockFileError(`hooks.lock (${path}) could not be read. Repair: fix permissions or move the file aside and run 'hooks sync'.`);
5085
+ }
5086
+ let parsed;
5087
+ try {
5088
+ parsed = JSON.parse(raw);
5089
+ } catch {
5090
+ 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.`);
5049
5091
  }
5092
+ assertLockShape(parsed, path);
5093
+ return parsed;
5050
5094
  }
5051
5095
  function writeLock(lock) {
5052
5096
  const path = getLockPath();
@@ -5055,8 +5099,10 @@ function writeLock(lock) {
5055
5099
  for (const name of Object.keys(lock.hooks).sort()) {
5056
5100
  sorted[name] = lock.hooks[name];
5057
5101
  }
5058
- writeFileSync2(path, JSON.stringify({ hooks: sorted }, null, 2) + `
5102
+ const tmp = `${path}.tmp-${randomBytes(8).toString("hex")}`;
5103
+ writeFileSync2(tmp, JSON.stringify({ hooks: sorted }, null, 2) + `
5059
5104
  `, "utf-8");
5105
+ renameSync(tmp, path);
5060
5106
  return path;
5061
5107
  }
5062
5108
  function setPinnedHook(name, entry) {
@@ -5185,24 +5231,29 @@ async function buildCatalog() {
5185
5231
  const scriptPath = resolveHook(meta.name)?.scriptPath;
5186
5232
  if (!scriptPath)
5187
5233
  continue;
5234
+ const sha = await sha256File(scriptPath);
5188
5235
  byName.set(meta.name, {
5189
5236
  name: meta.name,
5190
5237
  version: meta.version,
5191
- sha256: await sha256File(scriptPath),
5238
+ sha256: sha,
5192
5239
  events: meta.events && meta.events.length > 0 ? meta.events : [meta.event],
5193
5240
  description: meta.description,
5194
- source: "bundled"
5241
+ source: "bundled",
5242
+ versions: [meta.version]
5195
5243
  });
5196
5244
  }
5197
5245
  for (const custom2 of listCustomHooks()) {
5198
5246
  const name = shortManifestName(custom2.manifest.name);
5247
+ const sha = await sha256File(custom2.scriptPath);
5248
+ const existing = byName.get(name);
5199
5249
  byName.set(name, {
5200
5250
  name,
5201
5251
  version: custom2.manifest.version,
5202
- sha256: await sha256File(custom2.scriptPath),
5252
+ sha256: sha,
5203
5253
  events: custom2.manifest.events,
5204
5254
  description: custom2.manifest.description ?? "Custom hook",
5205
- source: byName.has(name) ? "custom-overrides-bundled" : "custom"
5255
+ source: existing ? "custom-overrides-bundled" : "custom",
5256
+ versions: existing ? [...new Set([...existing.versions, custom2.manifest.version])] : [custom2.manifest.version]
5206
5257
  });
5207
5258
  }
5208
5259
  return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
@@ -5220,6 +5271,7 @@ async function artifactFor(name, version) {
5220
5271
  description: resolved.description,
5221
5272
  events: resolved.events,
5222
5273
  script: custom2 ? custom2.manifest.script : `src/hook.ts`,
5274
+ script_kind: custom2?.manifest.script_kind,
5223
5275
  args: custom2?.manifest.args,
5224
5276
  timeout_ms: custom2?.manifest.timeout_ms
5225
5277
  };
@@ -5249,11 +5301,32 @@ function handleServeRequest(req, apiKey) {
5249
5301
  return buildCatalog().then((catalog) => json({ hooks: catalog }));
5250
5302
  }
5251
5303
  if (url.pathname === "/api/v1/lock" && req.method === "GET") {
5252
- return Promise.resolve(json(readLock()));
5304
+ return buildCatalog().then((catalog) => {
5305
+ const lock = readLock();
5306
+ const byVersion = {};
5307
+ for (const entry of catalog)
5308
+ byVersion[entry.name] = entry.versions;
5309
+ const hooks = {};
5310
+ for (const [name, pin] of Object.entries(lock.hooks)) {
5311
+ hooks[name] = { ...pin, versions: byVersion[name] ?? [pin.version] };
5312
+ }
5313
+ return json({ hooks });
5314
+ });
5253
5315
  }
5254
- const artifactMatch = /^\/api\/v1\/hooks\/([\w-]+)\/(\d+\.\d+\.\d+)$/.exec(url.pathname);
5316
+ const artifactMatch = /^\/api\/v1\/hooks\/([\w-]+)\/([0-9A-Za-z.%+_-]+)$/.exec(url.pathname);
5255
5317
  if (artifactMatch && req.method === "GET") {
5256
- const [, name, version] = artifactMatch;
5318
+ const [, rawName, rawVersion] = artifactMatch;
5319
+ let name;
5320
+ let version;
5321
+ try {
5322
+ name = decodeURIComponent(rawName);
5323
+ version = decodeURIComponent(rawVersion);
5324
+ } catch {
5325
+ return Promise.resolve(json({ error: "invalid URL encoding" }, 400));
5326
+ }
5327
+ if (!SEMVER_PATTERN.test(version)) {
5328
+ return Promise.resolve(json({ error: `invalid semver version '${version}'` }, 400));
5329
+ }
5257
5330
  return artifactFor(name, version).then(async (artifact) => {
5258
5331
  if (!artifact)
5259
5332
  return json({ error: `Hook '${name}@${version}' not found locally` }, 404);
@@ -5284,7 +5357,7 @@ function handleServeRequest(req, apiKey) {
5284
5357
  function startServeServer(options) {
5285
5358
  const port = options.port ?? DEFAULT_SERVE_PORT;
5286
5359
  const host = options.host ?? SERVE_HOST;
5287
- const apiKey = resolveApiKey(options.apiKey);
5360
+ const apiKey = resolveApiKey();
5288
5361
  const server = Bun.serve({
5289
5362
  hostname: host,
5290
5363
  port,
@@ -5296,7 +5369,17 @@ function startServeServer(options) {
5296
5369
  return server;
5297
5370
  }
5298
5371
  if (import.meta.main) {
5299
- startServeServer({});
5372
+ const argv = process.argv.slice(2);
5373
+ const flagValue = (name) => {
5374
+ const idx = argv.indexOf(name);
5375
+ return idx >= 0 && argv[idx + 1] ? argv[idx + 1] : undefined;
5376
+ };
5377
+ const portArg = flagValue("--port");
5378
+ const hostArg = flagValue("--host");
5379
+ startServeServer({
5380
+ port: portArg ? parseInt(portArg, 10) : undefined,
5381
+ host: hostArg
5382
+ });
5300
5383
  }
5301
5384
  export {
5302
5385
  startServeServer,
package/dist/config.d.ts CHANGED
@@ -15,4 +15,11 @@ export interface HooksConfig {
15
15
  export declare function readConfig(): HooksConfig;
16
16
  export declare function writeConfig(config: HooksConfig): string;
17
17
  export declare function resolveApiUrl(): string | undefined;
18
- export declare function resolveApiKey(flagKey?: string): string | undefined;
18
+ /**
19
+ * P1-8: the registry API key resolves from the environment ONLY —
20
+ * HASNA_HOOKS_API_KEY / HOOKS_API_KEY. A secret-valued CLI flag is removed
21
+ * (a flag value is visible in process listings and shell history); the
22
+ * vault-key-NAME reference (config api_key_ref) is a name, not a value, and
23
+ * is untouched.
24
+ */
25
+ export declare function resolveApiKey(): string | undefined;
@@ -1,3 +1,25 @@
1
+ /**
2
+ * P1-5: verified TLS by default. `sslmode=require` / `ssl=true` previously
3
+ * produced `rejectUnauthorized: false` — TLS that verified nothing, so the
4
+ * "encrypted" channel was open to a MITM. The default is now
5
+ * `rejectUnauthorized: true`.
6
+ *
7
+ * Insecure verification exists ONLY through the explicit dev-only config key
8
+ * HASNA_HOOKS_PG_INSECURE_TLS=1 (or HOOKS_PG_INSECURE_TLS) and is refused
9
+ * under a production-shaped environment (NODE_ENV=production) — a dev key
10
+ * silently disabling verification in prod would be the exact failure this
11
+ * finding corrects.
12
+ *
13
+ * P3-12: sslmode/ssl are parsed from the connection-string QUERY, not by
14
+ * substring scan — `sslmode = require` (spaces around the separator), other
15
+ * casings and non-URL DSN forms are all resolved from the parameter value.
16
+ *
17
+ * Exported for tests; the connection-string forms are the documented
18
+ * sslmode values the pg client understands.
19
+ */
20
+ export declare function sslConfigFor(connectionString: string): {
21
+ rejectUnauthorized: boolean;
22
+ } | undefined;
1
23
  export declare class PgAdapterAsync {
2
24
  private readonly pool;
3
25
  constructor(connectionString: string);