@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/README.md +1 -1
- package/bin/index.js +697 -181
- package/bin/serve.js +111 -28
- package/dist/config.d.ts +8 -1
- package/dist/db/remote-storage.d.ts +22 -0
- package/dist/index.js +506 -113
- package/dist/lib/hook-env.d.ts +98 -0
- package/dist/lib/manifest.d.ts +35 -2
- package/dist/lib/redact.d.ts +40 -0
- package/dist/lib/semver.d.ts +46 -0
- package/dist/lib/store.d.ts +16 -0
- package/dist/lib/sync.d.ts +55 -4
- package/dist/serve.d.ts +2 -1
- package/dist/storage.js +104 -7
- package/package.json +2 -2
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(
|
|
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(
|
|
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
|
|
4588
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
5252
|
+
sha256: sha,
|
|
5203
5253
|
events: custom2.manifest.events,
|
|
5204
5254
|
description: custom2.manifest.description ?? "Custom hook",
|
|
5205
|
-
source:
|
|
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
|
|
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-]+)\/(
|
|
5316
|
+
const artifactMatch = /^\/api\/v1\/hooks\/([\w-]+)\/([0-9A-Za-z.%+_-]+)$/.exec(url.pathname);
|
|
5255
5317
|
if (artifactMatch && req.method === "GET") {
|
|
5256
|
-
const [,
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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);
|