@hasna/hooks 0.6.8 → 0.7.2
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 +579 -113
- package/bin/serve.js +503 -38
- package/dist/config.d.ts +7 -5
- package/dist/db/pg-migrations.d.ts +1 -1
- package/dist/index.js +533 -71
- package/dist/openapi.d.ts +216 -0
- package/hooks/codewith-native-common.test.ts +25 -25
- package/hooks/codewith-native-common.ts +1 -1
- package/hooks/hook-autoformat/package.json +1 -1
- package/hooks/hook-desktopnotify/package.json +1 -1
- package/hooks/hook-envsetup/package.json +1 -1
- package/hooks/hook-knowledge-context/README.md +1 -1
- package/hooks/hook-knowledge-context/src/hook.test.ts +1 -1
- package/hooks/hook-permissionguard/package.json +1 -1
- package/hooks/hook-protectfiles/package.json +1 -1
- package/hooks/hook-spiral-detector/LICENSE +191 -0
- package/hooks/hook-spiral-detector/README.md +23 -0
- package/hooks/hook-spiral-detector/package.json +12 -0
- package/hooks/hook-spiral-detector/src/hook.test.ts +72 -0
- package/hooks/hook-spiral-detector/src/hook.ts +93 -0
- package/hooks/hook-workspace-repos-guard/README.md +4 -3
- package/hooks/hook-workspace-repos-guard/src/hook.test.ts +7 -7
- package/hooks/hook-workspace-repos-guard/src/hook.ts +5 -3
- package/hooks/mention-context/src/hasna-mention-context.py +2 -2
- package/hooks/mention-context/src/hasna-mention-warm.py +1 -1
- package/hooks/pre-bash/README.md +1 -1
- package/hooks/prompt-guard/README.md +1 -1
- package/hooks/session-start/README.md +1 -1
- package/hooks/stop-sync/README.md +1 -1
- package/hooks/worktree-guard/README.md +1 -1
- package/package.json +10 -6
package/bin/serve.js
CHANGED
|
@@ -368,6 +368,16 @@ var HOOKS = [
|
|
|
368
368
|
matcher: "",
|
|
369
369
|
tags: ["errors", "failures", "logging", "debugging"]
|
|
370
370
|
},
|
|
371
|
+
{
|
|
372
|
+
name: "spiral-detector",
|
|
373
|
+
displayName: "Spiral Detector",
|
|
374
|
+
description: "Interrupts sessions after five consecutive identical command failures",
|
|
375
|
+
version: "0.1.0",
|
|
376
|
+
category: "Observability",
|
|
377
|
+
event: "PostToolUse",
|
|
378
|
+
matcher: "",
|
|
379
|
+
tags: ["spiral", "failures", "retries", "safety", "interrupt"]
|
|
380
|
+
},
|
|
371
381
|
{
|
|
372
382
|
name: "stylescheck",
|
|
373
383
|
displayName: "Styles Check",
|
|
@@ -524,8 +534,8 @@ function getHook(name) {
|
|
|
524
534
|
}
|
|
525
535
|
|
|
526
536
|
// src/lib/manifest.ts
|
|
527
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from "fs";
|
|
528
|
-
import { basename, dirname, isAbsolute, join as
|
|
537
|
+
import { existsSync, mkdirSync, readFileSync as readFileSync2, readdirSync, realpathSync, writeFileSync } from "fs";
|
|
538
|
+
import { basename, dirname, isAbsolute, join as join3, normalize, resolve, sep } from "path";
|
|
529
539
|
|
|
530
540
|
// ../../node_modules/.bun/zod@3.25.76/node_modules/zod/v3/external.js
|
|
531
541
|
var exports_external = {};
|
|
@@ -4500,26 +4510,303 @@ var coerce = {
|
|
|
4500
4510
|
date: (arg) => ZodDate.create({ ...arg, coerce: true })
|
|
4501
4511
|
};
|
|
4502
4512
|
var NEVER = INVALID;
|
|
4503
|
-
//
|
|
4513
|
+
// ../contracts/dist/client/transport.js
|
|
4514
|
+
import { readFileSync, statSync } from "fs";
|
|
4504
4515
|
import { join } from "path";
|
|
4516
|
+
function envToken(name) {
|
|
4517
|
+
return name.toUpperCase().replace(/-/g, "_");
|
|
4518
|
+
}
|
|
4519
|
+
function clientTransportEnvKeys(name) {
|
|
4520
|
+
const envSegment = envToken(name);
|
|
4521
|
+
return {
|
|
4522
|
+
apiUrlKeys: [`HASNA_${envSegment}_API_URL`, `${envSegment}_API_URL`],
|
|
4523
|
+
apiKeyKeys: [`HASNA_${envSegment}_API_KEY`, `${envSegment}_API_KEY`]
|
|
4524
|
+
};
|
|
4525
|
+
}
|
|
4526
|
+
function credentialOverrideEnvKey(name) {
|
|
4527
|
+
return `HASNA_${envToken(name)}_API_KEY_OVERRIDE`;
|
|
4528
|
+
}
|
|
4529
|
+
var CREDENTIAL_PROFILE_ENV_KEY = "HASNA_PROFILE";
|
|
4530
|
+
|
|
4531
|
+
class CredentialResolutionError extends Error {
|
|
4532
|
+
appName;
|
|
4533
|
+
attempted;
|
|
4534
|
+
constructor(appName, message, attempted) {
|
|
4535
|
+
super(message);
|
|
4536
|
+
this.name = "CredentialResolutionError";
|
|
4537
|
+
this.appName = appName;
|
|
4538
|
+
this.attempted = attempted;
|
|
4539
|
+
}
|
|
4540
|
+
}
|
|
4541
|
+
var HASNA_STATE_DIR = ".hasna";
|
|
4542
|
+
var FLEET_CREDENTIAL_DIR = "cloud";
|
|
4543
|
+
var CONFIG_DIR = ".config";
|
|
4544
|
+
var CONFIG_NAMESPACE = "hasna";
|
|
4545
|
+
var MAX_CREDENTIAL_FILE_BYTES = 64 * 1024;
|
|
4546
|
+
var SAFE_APP_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
4547
|
+
var SAFE_PROFILE = /^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$/;
|
|
4548
|
+
var ILLEGAL_IN_HEADER_VALUE = /[^\t\x20-\x7e]/;
|
|
4549
|
+
function homeDir(env) {
|
|
4550
|
+
const home = env.HOME?.trim();
|
|
4551
|
+
return home ? home : null;
|
|
4552
|
+
}
|
|
4553
|
+
function credentialDiskSources(name, env) {
|
|
4554
|
+
return profileDiskSources(name, env, null);
|
|
4555
|
+
}
|
|
4556
|
+
function profileDiskSources(name, env, profile) {
|
|
4557
|
+
const home = homeDir(env);
|
|
4558
|
+
if (!home || !SAFE_APP_SLUG.test(name))
|
|
4559
|
+
return [];
|
|
4560
|
+
const stem = profile ? `${name}.${profile}` : name;
|
|
4561
|
+
const configStem = profile ? `${name}-${profile}` : name;
|
|
4562
|
+
return [
|
|
4563
|
+
join(home, HASNA_STATE_DIR, FLEET_CREDENTIAL_DIR, `${stem}.env`),
|
|
4564
|
+
join(home, CONFIG_DIR, CONFIG_NAMESPACE, `${configStem}-cloud.env`)
|
|
4565
|
+
];
|
|
4566
|
+
}
|
|
4567
|
+
function parseEnvFile(text) {
|
|
4568
|
+
const values = new Map;
|
|
4569
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
4570
|
+
const line = rawLine.trim();
|
|
4571
|
+
if (line.length === 0 || line.startsWith("#"))
|
|
4572
|
+
continue;
|
|
4573
|
+
const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
|
|
4574
|
+
const equals = withoutExport.indexOf("=");
|
|
4575
|
+
if (equals <= 0)
|
|
4576
|
+
continue;
|
|
4577
|
+
const key = withoutExport.slice(0, equals).trim();
|
|
4578
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
4579
|
+
continue;
|
|
4580
|
+
let value = withoutExport.slice(equals + 1).trim();
|
|
4581
|
+
const quote = value[0];
|
|
4582
|
+
if (quote === '"' || quote === "'") {
|
|
4583
|
+
if (value.length < 2 || !value.endsWith(quote))
|
|
4584
|
+
continue;
|
|
4585
|
+
value = value.slice(1, -1);
|
|
4586
|
+
}
|
|
4587
|
+
if (value.length === 0)
|
|
4588
|
+
continue;
|
|
4589
|
+
values.set(key, value);
|
|
4590
|
+
}
|
|
4591
|
+
return values;
|
|
4592
|
+
}
|
|
4593
|
+
function readAppConfigFile(path) {
|
|
4594
|
+
let text;
|
|
4595
|
+
try {
|
|
4596
|
+
const stats = statSync(path);
|
|
4597
|
+
if (!stats.isFile() || stats.size > MAX_CREDENTIAL_FILE_BYTES)
|
|
4598
|
+
return null;
|
|
4599
|
+
text = readFileSync(path, "utf8");
|
|
4600
|
+
} catch {
|
|
4601
|
+
return null;
|
|
4602
|
+
}
|
|
4603
|
+
return parseEnvFile(text);
|
|
4604
|
+
}
|
|
4605
|
+
function readCredentialFile(path, apiKeyKeys) {
|
|
4606
|
+
const values = readAppConfigFile(path);
|
|
4607
|
+
if (!values)
|
|
4608
|
+
return null;
|
|
4609
|
+
for (const key of apiKeyKeys) {
|
|
4610
|
+
const value = values.get(key)?.trim();
|
|
4611
|
+
if (value)
|
|
4612
|
+
return value;
|
|
4613
|
+
}
|
|
4614
|
+
return null;
|
|
4615
|
+
}
|
|
4616
|
+
function assertUsableCredential(appName, source, value) {
|
|
4617
|
+
if (!ILLEGAL_IN_HEADER_VALUE.test(value))
|
|
4618
|
+
return;
|
|
4619
|
+
throw new CredentialResolutionError(appName, `The credential from ${source} contains characters that cannot be sent in an HTTP header ` + `(a control character or non-ASCII byte). A file written with CR-only line endings is the usual ` + `cause. Rewrite that credential file with one LF-terminated KEY=value line. ` + `The value is not shown here, and is deliberately never logged.`, [source]);
|
|
4620
|
+
}
|
|
4621
|
+
var INSPECT_CUSTOM = Symbol.for("nodejs.util.inspect.custom");
|
|
4622
|
+
var CREDENTIAL_SEAL = Symbol.for("hasna:contracts:sealedCredential");
|
|
4623
|
+
function sealCredential(fields) {
|
|
4624
|
+
const { apiKey } = fields;
|
|
4625
|
+
const visible = {
|
|
4626
|
+
tier: fields.tier,
|
|
4627
|
+
source: fields.source,
|
|
4628
|
+
deliberate: fields.deliberate,
|
|
4629
|
+
deprecated: fields.deprecated,
|
|
4630
|
+
diskCandidates: Object.freeze([...fields.diskCandidates]),
|
|
4631
|
+
warning: fields.warning
|
|
4632
|
+
};
|
|
4633
|
+
const sealed = { ...visible };
|
|
4634
|
+
Object.defineProperty(sealed, "apiKey", {
|
|
4635
|
+
value: apiKey,
|
|
4636
|
+
enumerable: false,
|
|
4637
|
+
writable: false,
|
|
4638
|
+
configurable: false
|
|
4639
|
+
});
|
|
4640
|
+
Object.defineProperty(sealed, INSPECT_CUSTOM, {
|
|
4641
|
+
value: () => ({ ...visible, apiKey: "[redacted]" }),
|
|
4642
|
+
enumerable: false,
|
|
4643
|
+
writable: false,
|
|
4644
|
+
configurable: false
|
|
4645
|
+
});
|
|
4646
|
+
Object.defineProperty(sealed, CREDENTIAL_SEAL, {
|
|
4647
|
+
value: true,
|
|
4648
|
+
enumerable: false,
|
|
4649
|
+
writable: false,
|
|
4650
|
+
configurable: false
|
|
4651
|
+
});
|
|
4652
|
+
return Object.freeze(sealed);
|
|
4653
|
+
}
|
|
4654
|
+
function firstEnvValue(env, keys) {
|
|
4655
|
+
for (const key of keys) {
|
|
4656
|
+
const value = env[key]?.trim();
|
|
4657
|
+
if (value)
|
|
4658
|
+
return { key, value };
|
|
4659
|
+
}
|
|
4660
|
+
return null;
|
|
4661
|
+
}
|
|
4662
|
+
var DEPRECATION_REGISTRY = Symbol.for("hasna:contracts:credentialDeprecationNotices");
|
|
4663
|
+
function deprecationNotified() {
|
|
4664
|
+
const host = globalThis;
|
|
4665
|
+
const existing = host[DEPRECATION_REGISTRY];
|
|
4666
|
+
if (existing instanceof Set)
|
|
4667
|
+
return existing;
|
|
4668
|
+
const created = new Set;
|
|
4669
|
+
host[DEPRECATION_REGISTRY] = created;
|
|
4670
|
+
return created;
|
|
4671
|
+
}
|
|
4672
|
+
function defaultDeprecationSink(message) {
|
|
4673
|
+
if (typeof process !== "undefined" && process.stderr) {
|
|
4674
|
+
process.stderr.write(`${message}
|
|
4675
|
+
`);
|
|
4676
|
+
}
|
|
4677
|
+
}
|
|
4678
|
+
function resolveCredential(name, env, options = {}) {
|
|
4679
|
+
const { apiKeyKeys } = clientTransportEnvKeys(name);
|
|
4680
|
+
const diskPaths = credentialDiskSources(name, env);
|
|
4681
|
+
const explicitKey = options.apiKey?.trim();
|
|
4682
|
+
if (explicitKey) {
|
|
4683
|
+
assertUsableCredential(name, "the explicit apiKey argument", explicitKey);
|
|
4684
|
+
return sealCredential({
|
|
4685
|
+
apiKey: explicitKey,
|
|
4686
|
+
tier: "argument",
|
|
4687
|
+
source: "explicit apiKey argument",
|
|
4688
|
+
deliberate: true,
|
|
4689
|
+
deprecated: false,
|
|
4690
|
+
diskCandidates: diskPaths,
|
|
4691
|
+
warning: null
|
|
4692
|
+
});
|
|
4693
|
+
}
|
|
4694
|
+
const overrideKeyName = credentialOverrideEnvKey(name);
|
|
4695
|
+
const overrideRaw = env[overrideKeyName];
|
|
4696
|
+
if (overrideRaw !== undefined) {
|
|
4697
|
+
const override = overrideRaw.trim();
|
|
4698
|
+
if (!override) {
|
|
4699
|
+
throw new CredentialResolutionError(name, `${overrideKeyName} is set but empty. It is a deliberate override, so it is not resolved around: ` + `either give it a real key or unset it to fall back to the credential on disk.`, [overrideKeyName]);
|
|
4700
|
+
}
|
|
4701
|
+
assertUsableCredential(name, overrideKeyName, override);
|
|
4702
|
+
return sealCredential({
|
|
4703
|
+
apiKey: override,
|
|
4704
|
+
tier: "override",
|
|
4705
|
+
source: overrideKeyName,
|
|
4706
|
+
deliberate: true,
|
|
4707
|
+
deprecated: false,
|
|
4708
|
+
diskCandidates: diskPaths,
|
|
4709
|
+
warning: null
|
|
4710
|
+
});
|
|
4711
|
+
}
|
|
4712
|
+
const profile = options.profile?.trim() || env[CREDENTIAL_PROFILE_ENV_KEY]?.trim();
|
|
4713
|
+
if (profile) {
|
|
4714
|
+
const profileSource = options.profile?.trim() ? "explicit profile argument" : CREDENTIAL_PROFILE_ENV_KEY;
|
|
4715
|
+
if (!SAFE_PROFILE.test(profile)) {
|
|
4716
|
+
throw new CredentialResolutionError(name, `Profile name from ${profileSource} is not usable in a path. ` + `Use letters, digits, dot, dash, or underscore.`, [profileSource]);
|
|
4717
|
+
}
|
|
4718
|
+
const paths = profileDiskSources(name, env, profile);
|
|
4719
|
+
for (const path of paths) {
|
|
4720
|
+
const value = readCredentialFile(path, apiKeyKeys);
|
|
4721
|
+
if (value) {
|
|
4722
|
+
assertUsableCredential(name, path, value);
|
|
4723
|
+
return sealCredential({
|
|
4724
|
+
apiKey: value,
|
|
4725
|
+
tier: "profile",
|
|
4726
|
+
source: path,
|
|
4727
|
+
deliberate: true,
|
|
4728
|
+
deprecated: false,
|
|
4729
|
+
diskCandidates: paths,
|
|
4730
|
+
warning: null
|
|
4731
|
+
});
|
|
4732
|
+
}
|
|
4733
|
+
}
|
|
4734
|
+
throw new CredentialResolutionError(name, `Profile '${profile}' (from ${profileSource}) has no ${apiKeyKeys[0]} for '${name}'. ` + `Looked in: ${paths.join(", ") || "<no HOME in this environment>"}. ` + `A profile names WHICH identity to use, so it is never resolved around \u2014 ` + `create the profile's credential file or unset ${CREDENTIAL_PROFILE_ENV_KEY}.`, paths);
|
|
4735
|
+
}
|
|
4736
|
+
const diskHits = diskPaths.map((path) => ({ path, value: readCredentialFile(path, apiKeyKeys) })).filter((hit) => hit.value !== null);
|
|
4737
|
+
if (diskHits.length > 0) {
|
|
4738
|
+
const winner = diskHits[0];
|
|
4739
|
+
assertUsableCredential(name, winner.path, winner.value);
|
|
4740
|
+
const divergentSources = [
|
|
4741
|
+
...diskHits.slice(1).filter((hit) => hit.value !== winner.value).map((hit) => hit.path),
|
|
4742
|
+
...(() => {
|
|
4743
|
+
const legacyHit = firstEnvValue(env, apiKeyKeys);
|
|
4744
|
+
return legacyHit && legacyHit.value !== winner.value ? [legacyHit.key] : [];
|
|
4745
|
+
})()
|
|
4746
|
+
];
|
|
4747
|
+
const warning = divergentSources.length > 0 ? `Credential sources disagree for '${name}': ${winner.path} and ` + `${divergentSources.join(", ")} hold different keys. ${winner.path} wins, because a file on ` + `disk is re-read on every call while an environment variable is a snapshot. Reconcile them \u2014 ` + `a rotation that updated only one leaves the other to fail 401 wherever it is loaded first.` : null;
|
|
4748
|
+
return sealCredential({
|
|
4749
|
+
apiKey: winner.value,
|
|
4750
|
+
tier: "disk",
|
|
4751
|
+
source: winner.path,
|
|
4752
|
+
deliberate: false,
|
|
4753
|
+
deprecated: false,
|
|
4754
|
+
diskCandidates: diskPaths,
|
|
4755
|
+
warning
|
|
4756
|
+
});
|
|
4757
|
+
}
|
|
4758
|
+
const legacy = firstEnvValue(env, apiKeyKeys);
|
|
4759
|
+
if (legacy) {
|
|
4760
|
+
assertUsableCredential(name, legacy.key, legacy.value);
|
|
4761
|
+
const where = diskPaths.length > 0 ? `Put the current key in ${diskPaths[0]} \u2014 it is re-read on every call, so rotations take effect immediately.` : `This environment has no HOME, so no credential file could be consulted at all; the disk tier is ` + `unavailable here and this process will keep using the environment snapshot.`;
|
|
4762
|
+
const message = `[${name}] DEPRECATED: the API key came from ${legacy.key} in this process's environment. ` + `Environment variables are a snapshot taken when this process started, so a shell that started ` + `before a key rotation keeps using the old key until it exits. ${where}`;
|
|
4763
|
+
const sink = options.onDeprecation ?? defaultDeprecationSink;
|
|
4764
|
+
const notified = deprecationNotified();
|
|
4765
|
+
if (!notified.has(name)) {
|
|
4766
|
+
notified.add(name);
|
|
4767
|
+
sink(message);
|
|
4768
|
+
}
|
|
4769
|
+
return sealCredential({
|
|
4770
|
+
apiKey: legacy.value,
|
|
4771
|
+
tier: "legacy-env",
|
|
4772
|
+
source: legacy.key,
|
|
4773
|
+
deliberate: false,
|
|
4774
|
+
deprecated: true,
|
|
4775
|
+
diskCandidates: diskPaths,
|
|
4776
|
+
warning: message
|
|
4777
|
+
});
|
|
4778
|
+
}
|
|
4779
|
+
return null;
|
|
4780
|
+
}
|
|
4781
|
+
var IDEMPOTENT_METHODS = new Set(["GET", "HEAD", "PUT", "DELETE", "OPTIONS"]);
|
|
4782
|
+
var AUTHORITY_OVERRIDE_HEADERS = new Set([
|
|
4783
|
+
"host",
|
|
4784
|
+
":authority",
|
|
4785
|
+
"forwarded",
|
|
4786
|
+
"x-forwarded-host",
|
|
4787
|
+
"x-original-host"
|
|
4788
|
+
]);
|
|
4789
|
+
|
|
4790
|
+
// src/config.ts
|
|
4791
|
+
import { join as join2 } from "path";
|
|
4505
4792
|
import { homedir } from "os";
|
|
4506
4793
|
function getHooksDataDir() {
|
|
4507
4794
|
const explicit = process.env.HASNA_HOOKS_DATA_DIR ?? process.env.HOOKS_DATA_DIR;
|
|
4508
4795
|
if (explicit)
|
|
4509
4796
|
return explicit;
|
|
4510
|
-
return
|
|
4797
|
+
return join2(homedir(), ".hasna", "hooks");
|
|
4511
4798
|
}
|
|
4512
4799
|
function getCustomHooksDir() {
|
|
4513
|
-
return
|
|
4800
|
+
return join2(getHooksDataDir(), "hooks");
|
|
4514
4801
|
}
|
|
4515
4802
|
function getLockPath() {
|
|
4516
4803
|
const explicit = process.env.HASNA_HOOKS_LOCK_PATH ?? process.env.HOOKS_LOCK_PATH;
|
|
4517
4804
|
if (explicit)
|
|
4518
4805
|
return explicit;
|
|
4519
|
-
return
|
|
4806
|
+
return join2(getHooksDataDir(), "hooks.lock");
|
|
4520
4807
|
}
|
|
4521
4808
|
function resolveApiKey() {
|
|
4522
|
-
return
|
|
4809
|
+
return resolveCredential("hooks", process.env)?.apiKey;
|
|
4523
4810
|
}
|
|
4524
4811
|
|
|
4525
4812
|
// src/lib/semver.ts
|
|
@@ -4575,7 +4862,7 @@ function realpathNearest(path) {
|
|
|
4575
4862
|
tail.unshift(basename(cur));
|
|
4576
4863
|
cur = parent;
|
|
4577
4864
|
}
|
|
4578
|
-
return
|
|
4865
|
+
return join3(realpathSync(cur), ...tail);
|
|
4579
4866
|
}
|
|
4580
4867
|
function assertContainedReal(root, target, raw) {
|
|
4581
4868
|
const rootReal = realpathSync(root);
|
|
@@ -4608,25 +4895,25 @@ function resolveScript(manifest, manifestDir) {
|
|
|
4608
4895
|
if (isInlineScript(manifest))
|
|
4609
4896
|
return { path: scriptRelFor(manifest), content: manifest.script };
|
|
4610
4897
|
const rel = scriptRelFor(manifest);
|
|
4611
|
-
const resolved = assertContained(
|
|
4898
|
+
const resolved = assertContained(join3(manifestDir, rel), manifestDir, manifest.script);
|
|
4612
4899
|
if (!existsSync(resolved)) {
|
|
4613
4900
|
throw new Error(`manifest script file not found: ${resolved}`);
|
|
4614
4901
|
}
|
|
4615
|
-
return { path: rel, content:
|
|
4902
|
+
return { path: rel, content: readFileSync2(resolved, "utf-8") };
|
|
4616
4903
|
}
|
|
4617
4904
|
function customHookDir(name) {
|
|
4618
|
-
return
|
|
4905
|
+
return join3(getCustomHooksDir(), shortManifestName(name));
|
|
4619
4906
|
}
|
|
4620
4907
|
function readCustomManifest(name) {
|
|
4621
4908
|
const dir = customHookDir(name);
|
|
4622
|
-
const manifestPath =
|
|
4909
|
+
const manifestPath = join3(dir, "manifest.json");
|
|
4623
4910
|
if (!existsSync(manifestPath))
|
|
4624
4911
|
return;
|
|
4625
4912
|
try {
|
|
4626
|
-
const manifest = parseManifest(
|
|
4913
|
+
const manifest = parseManifest(readFileSync2(manifestPath, "utf-8"));
|
|
4627
4914
|
const script = resolveScript(manifest, dir);
|
|
4628
4915
|
const scriptIsInline = isInlineScript(manifest);
|
|
4629
|
-
return { manifest, scriptPath:
|
|
4916
|
+
return { manifest, scriptPath: join3(dir, script.path), scriptContent: script.content, scriptIsInline };
|
|
4630
4917
|
} catch (err) {
|
|
4631
4918
|
if (err instanceof ScriptContainmentError)
|
|
4632
4919
|
throw err;
|
|
@@ -4655,13 +4942,13 @@ function readdirSafe(dir) {
|
|
|
4655
4942
|
|
|
4656
4943
|
// src/lib/store.ts
|
|
4657
4944
|
import { createHash } from "crypto";
|
|
4658
|
-
import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as
|
|
4945
|
+
import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync2, renameSync } from "fs";
|
|
4659
4946
|
import { readFile } from "fs/promises";
|
|
4660
4947
|
|
|
4661
4948
|
// src/db/index.ts
|
|
4662
4949
|
import { Database } from "bun:sqlite";
|
|
4663
4950
|
import { existsSync as existsSync3, mkdirSync as mkdirSync2, cpSync } from "fs";
|
|
4664
|
-
import { join as
|
|
4951
|
+
import { join as join5 } from "path";
|
|
4665
4952
|
import { homedir as homedir3 } from "os";
|
|
4666
4953
|
|
|
4667
4954
|
// src/db/schema.ts
|
|
@@ -4834,8 +5121,8 @@ function runMigrations(db) {
|
|
|
4834
5121
|
}
|
|
4835
5122
|
|
|
4836
5123
|
// src/db/legacy-import.ts
|
|
4837
|
-
import { existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as
|
|
4838
|
-
import { join as
|
|
5124
|
+
import { existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync3 } from "fs";
|
|
5125
|
+
import { join as join4 } from "path";
|
|
4839
5126
|
import { homedir as homedir2 } from "os";
|
|
4840
5127
|
var META_KEY = "legacy_import_done";
|
|
4841
5128
|
function ensureMetaTable(db) {
|
|
@@ -4860,7 +5147,7 @@ function nanoid() {
|
|
|
4860
5147
|
function importJsonlFile(db, filePath) {
|
|
4861
5148
|
let count = 0;
|
|
4862
5149
|
try {
|
|
4863
|
-
const lines =
|
|
5150
|
+
const lines = readFileSync3(filePath, "utf-8").split(`
|
|
4864
5151
|
`).filter(Boolean);
|
|
4865
5152
|
for (const line of lines) {
|
|
4866
5153
|
try {
|
|
@@ -4886,7 +5173,7 @@ function importJsonlFile(db, filePath) {
|
|
|
4886
5173
|
function importErrorsLog(db, filePath) {
|
|
4887
5174
|
let count = 0;
|
|
4888
5175
|
try {
|
|
4889
|
-
const lines =
|
|
5176
|
+
const lines = readFileSync3(filePath, "utf-8").split(`
|
|
4890
5177
|
`).filter(Boolean);
|
|
4891
5178
|
const linePattern = /^\[(.+?)\]\s+(?:\[session:(\S+)\]\s+)?(.+?)\s+\u2014\s+(.+)$/;
|
|
4892
5179
|
for (const line of lines) {
|
|
@@ -4911,25 +5198,25 @@ function importErrorsLog(db, filePath) {
|
|
|
4911
5198
|
} catch {}
|
|
4912
5199
|
return count;
|
|
4913
5200
|
}
|
|
4914
|
-
function runLegacyImport(db,
|
|
5201
|
+
function runLegacyImport(db, homeDir2 = homedir2()) {
|
|
4915
5202
|
try {
|
|
4916
5203
|
if (isAlreadyDone(db))
|
|
4917
5204
|
return;
|
|
4918
5205
|
let total = 0;
|
|
4919
|
-
const claudeProjectsDir =
|
|
5206
|
+
const claudeProjectsDir = join4(homeDir2, ".claude", "projects");
|
|
4920
5207
|
if (existsSync2(claudeProjectsDir)) {
|
|
4921
5208
|
try {
|
|
4922
5209
|
const projectDirs = readdirSync2(claudeProjectsDir);
|
|
4923
5210
|
for (const dir of projectDirs) {
|
|
4924
|
-
const projectDir =
|
|
5211
|
+
const projectDir = join4(claudeProjectsDir, dir);
|
|
4925
5212
|
try {
|
|
4926
5213
|
const files = readdirSync2(projectDir);
|
|
4927
5214
|
for (const file of files) {
|
|
4928
5215
|
if (file.match(/^session-log-\d{4}-\d{2}-\d{2}\.jsonl$/)) {
|
|
4929
|
-
total += importJsonlFile(db,
|
|
5216
|
+
total += importJsonlFile(db, join4(projectDir, file));
|
|
4930
5217
|
}
|
|
4931
5218
|
if (file === "errors.log") {
|
|
4932
|
-
total += importErrorsLog(db,
|
|
5219
|
+
total += importErrorsLog(db, join4(projectDir, file));
|
|
4933
5220
|
}
|
|
4934
5221
|
}
|
|
4935
5222
|
} catch {}
|
|
@@ -4967,10 +5254,10 @@ function resolveDataDir() {
|
|
|
4967
5254
|
const explicit = process.env.HASNA_HOOKS_DATA_DIR ?? process.env.HOOKS_DATA_DIR;
|
|
4968
5255
|
if (explicit)
|
|
4969
5256
|
return explicit;
|
|
4970
|
-
const newDir =
|
|
4971
|
-
const oldDir =
|
|
5257
|
+
const newDir = join5(homedir3(), ".hasna", "hooks");
|
|
5258
|
+
const oldDir = join5(homedir3(), ".hooks");
|
|
4972
5259
|
if (!existsSync3(newDir) && existsSync3(oldDir)) {
|
|
4973
|
-
mkdirSync2(
|
|
5260
|
+
mkdirSync2(join5(homedir3(), ".hasna"), { recursive: true });
|
|
4974
5261
|
cpSync(oldDir, newDir, { recursive: true });
|
|
4975
5262
|
}
|
|
4976
5263
|
return newDir;
|
|
@@ -4980,7 +5267,7 @@ function getDbPath() {
|
|
|
4980
5267
|
if (explicitDb)
|
|
4981
5268
|
return explicitDb;
|
|
4982
5269
|
const dataDir = resolveDataDir();
|
|
4983
|
-
return
|
|
5270
|
+
return join5(dataDir, "hooks.db");
|
|
4984
5271
|
}
|
|
4985
5272
|
function ensureDir(dbPath) {
|
|
4986
5273
|
const dir = dbPath.substring(0, dbPath.lastIndexOf("/"));
|
|
@@ -5079,7 +5366,7 @@ function readLock() {
|
|
|
5079
5366
|
return { hooks: {} };
|
|
5080
5367
|
let raw;
|
|
5081
5368
|
try {
|
|
5082
|
-
raw =
|
|
5369
|
+
raw = readFileSync4(path, "utf-8");
|
|
5083
5370
|
} catch {
|
|
5084
5371
|
throw new LockFileError(`hooks.lock (${path}) could not be read. Repair: fix permissions or move the file aside and run 'hooks sync'.`);
|
|
5085
5372
|
}
|
|
@@ -5111,7 +5398,7 @@ function setPinnedHook(name, entry) {
|
|
|
5111
5398
|
return writeLock(lock);
|
|
5112
5399
|
}
|
|
5113
5400
|
function retrustHook(name, scriptPath, version, source) {
|
|
5114
|
-
const actual = sha256Of(
|
|
5401
|
+
const actual = sha256Of(readFileSync4(scriptPath));
|
|
5115
5402
|
const db = getDb();
|
|
5116
5403
|
upsertHookRecord(db, {
|
|
5117
5404
|
name,
|
|
@@ -5125,14 +5412,14 @@ function retrustHook(name, scriptPath, version, source) {
|
|
|
5125
5412
|
}
|
|
5126
5413
|
|
|
5127
5414
|
// src/lib/resolve.ts
|
|
5128
|
-
import { dirname as dirname2, join as
|
|
5415
|
+
import { dirname as dirname2, join as join6 } from "path";
|
|
5129
5416
|
import { existsSync as existsSync5 } from "fs";
|
|
5130
5417
|
import { fileURLToPath } from "url";
|
|
5131
5418
|
function resolveBundledHooksDir(moduleDir = dirname2(fileURLToPath(import.meta.url))) {
|
|
5132
|
-
const sourceLayout =
|
|
5133
|
-
if (existsSync5(
|
|
5419
|
+
const sourceLayout = join6(moduleDir, "..", "..", "hooks");
|
|
5420
|
+
if (existsSync5(join6(sourceLayout, "hook-gitguard")))
|
|
5134
5421
|
return sourceLayout;
|
|
5135
|
-
return
|
|
5422
|
+
return join6(moduleDir, "..", "hooks");
|
|
5136
5423
|
}
|
|
5137
5424
|
function resolveHookDir(name) {
|
|
5138
5425
|
const custom2 = readCustomManifest(name);
|
|
@@ -5143,10 +5430,10 @@ function resolveHookDir(name) {
|
|
|
5143
5430
|
return;
|
|
5144
5431
|
const short = shortManifestName(name);
|
|
5145
5432
|
const bundledBase = resolveBundledHooksDir();
|
|
5146
|
-
const direct =
|
|
5433
|
+
const direct = join6(bundledBase, short);
|
|
5147
5434
|
if (existsSync5(direct))
|
|
5148
5435
|
return direct;
|
|
5149
|
-
const prefixed =
|
|
5436
|
+
const prefixed = join6(bundledBase, `hook-${short}`);
|
|
5150
5437
|
return existsSync5(prefixed) ? prefixed : undefined;
|
|
5151
5438
|
}
|
|
5152
5439
|
function resolveScriptPath(name) {
|
|
@@ -5156,7 +5443,7 @@ function resolveScriptPath(name) {
|
|
|
5156
5443
|
const dir = resolveHookDir(name);
|
|
5157
5444
|
if (!dir)
|
|
5158
5445
|
return;
|
|
5159
|
-
const script =
|
|
5446
|
+
const script = join6(dir, "src", "hook.ts");
|
|
5160
5447
|
return existsSync5(script) ? script : undefined;
|
|
5161
5448
|
}
|
|
5162
5449
|
function resolveHookMeta(name) {
|
|
@@ -5208,6 +5495,11 @@ function resolveHook(name) {
|
|
|
5208
5495
|
};
|
|
5209
5496
|
}
|
|
5210
5497
|
|
|
5498
|
+
// src/serve.ts
|
|
5499
|
+
import { readFileSync as readFileSync5 } from "fs";
|
|
5500
|
+
import { dirname as dirname3, join as join7 } from "path";
|
|
5501
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
5502
|
+
|
|
5211
5503
|
// src/lib/secure-compare.ts
|
|
5212
5504
|
function secureEqual(a, b) {
|
|
5213
5505
|
const enc = new TextEncoder;
|
|
@@ -5221,10 +5513,169 @@ function secureEqual(a, b) {
|
|
|
5221
5513
|
return diff === 0;
|
|
5222
5514
|
}
|
|
5223
5515
|
|
|
5516
|
+
// src/openapi.ts
|
|
5517
|
+
var openApiDocument = {
|
|
5518
|
+
openapi: "3.1.0",
|
|
5519
|
+
info: {
|
|
5520
|
+
title: "Hooks Registry API",
|
|
5521
|
+
version: "0.1.0",
|
|
5522
|
+
description: "Local registry HTTP API for @hasna/hooks: catalog, lock, and artifact reads are open; publishing (PUT /api/v1/hooks) requires the API key (authorization: Bearer or x-api-key)."
|
|
5523
|
+
},
|
|
5524
|
+
servers: [{ url: "/" }],
|
|
5525
|
+
paths: {
|
|
5526
|
+
"/health": {
|
|
5527
|
+
get: {
|
|
5528
|
+
summary: "Liveness probe",
|
|
5529
|
+
operationId: "getHealth",
|
|
5530
|
+
responses: {
|
|
5531
|
+
"200": {
|
|
5532
|
+
description: "Service is alive",
|
|
5533
|
+
content: {
|
|
5534
|
+
"application/json": {
|
|
5535
|
+
schema: { type: "object", additionalProperties: true }
|
|
5536
|
+
}
|
|
5537
|
+
}
|
|
5538
|
+
}
|
|
5539
|
+
}
|
|
5540
|
+
}
|
|
5541
|
+
},
|
|
5542
|
+
"/ready": {
|
|
5543
|
+
get: {
|
|
5544
|
+
summary: "Readiness probe \u2014 the hooks.lock store is readable",
|
|
5545
|
+
operationId: "getReady",
|
|
5546
|
+
responses: {
|
|
5547
|
+
"200": { description: "Lock store is readable" },
|
|
5548
|
+
"503": { description: "Lock store is not readable" }
|
|
5549
|
+
}
|
|
5550
|
+
}
|
|
5551
|
+
},
|
|
5552
|
+
"/version": {
|
|
5553
|
+
get: {
|
|
5554
|
+
summary: "Package version",
|
|
5555
|
+
operationId: "getVersion",
|
|
5556
|
+
responses: {
|
|
5557
|
+
"200": {
|
|
5558
|
+
description: "The installed @hasna/hooks version",
|
|
5559
|
+
content: {
|
|
5560
|
+
"application/json": {
|
|
5561
|
+
schema: { type: "object", properties: { version: { type: "string" } } }
|
|
5562
|
+
}
|
|
5563
|
+
}
|
|
5564
|
+
}
|
|
5565
|
+
}
|
|
5566
|
+
}
|
|
5567
|
+
},
|
|
5568
|
+
"/openapi.json": {
|
|
5569
|
+
get: {
|
|
5570
|
+
summary: "This OpenAPI document",
|
|
5571
|
+
operationId: "getOpenApi",
|
|
5572
|
+
responses: { "200": { description: "OpenAPI 3.1 document" } }
|
|
5573
|
+
}
|
|
5574
|
+
},
|
|
5575
|
+
"/api/v1/catalog": {
|
|
5576
|
+
get: {
|
|
5577
|
+
summary: "List the full hook catalog with versions and sha256",
|
|
5578
|
+
operationId: "getCatalog",
|
|
5579
|
+
responses: {
|
|
5580
|
+
"200": {
|
|
5581
|
+
description: "Catalog entries",
|
|
5582
|
+
content: {
|
|
5583
|
+
"application/json": {
|
|
5584
|
+
schema: {
|
|
5585
|
+
type: "object",
|
|
5586
|
+
properties: {
|
|
5587
|
+
hooks: {
|
|
5588
|
+
type: "array",
|
|
5589
|
+
items: {
|
|
5590
|
+
type: "object",
|
|
5591
|
+
properties: {
|
|
5592
|
+
name: { type: "string" },
|
|
5593
|
+
version: { type: "string" },
|
|
5594
|
+
sha256: { type: "string" },
|
|
5595
|
+
events: { type: "array", items: { type: "string" } }
|
|
5596
|
+
}
|
|
5597
|
+
}
|
|
5598
|
+
}
|
|
5599
|
+
}
|
|
5600
|
+
}
|
|
5601
|
+
}
|
|
5602
|
+
}
|
|
5603
|
+
}
|
|
5604
|
+
}
|
|
5605
|
+
}
|
|
5606
|
+
},
|
|
5607
|
+
"/api/v1/lock": {
|
|
5608
|
+
get: {
|
|
5609
|
+
summary: "Read the published hooks.lock with per-hook versions",
|
|
5610
|
+
operationId: "getLock",
|
|
5611
|
+
responses: { "200": { description: "The lock file" } }
|
|
5612
|
+
}
|
|
5613
|
+
},
|
|
5614
|
+
"/api/v1/hooks/{name}/{version}": {
|
|
5615
|
+
get: {
|
|
5616
|
+
summary: "Fetch one hook's manifest and script",
|
|
5617
|
+
operationId: "getHookArtifact",
|
|
5618
|
+
parameters: [
|
|
5619
|
+
{ name: "name", in: "path", required: true, schema: { type: "string" } },
|
|
5620
|
+
{ name: "version", in: "path", required: true, schema: { type: "string" } }
|
|
5621
|
+
],
|
|
5622
|
+
responses: {
|
|
5623
|
+
"200": {
|
|
5624
|
+
description: "Hook artifact; x-hook-sha256 header carries the script hash"
|
|
5625
|
+
},
|
|
5626
|
+
"400": { description: "Invalid URL encoding or semver version" },
|
|
5627
|
+
"404": { description: "Hook not found locally" }
|
|
5628
|
+
}
|
|
5629
|
+
}
|
|
5630
|
+
},
|
|
5631
|
+
"/api/v1/hooks": {
|
|
5632
|
+
put: {
|
|
5633
|
+
summary: "Publish/retrust a hook (requires the API key)",
|
|
5634
|
+
operationId: "putHook",
|
|
5635
|
+
requestBody: {
|
|
5636
|
+
required: true,
|
|
5637
|
+
content: {
|
|
5638
|
+
"application/json": {
|
|
5639
|
+
schema: {
|
|
5640
|
+
type: "object",
|
|
5641
|
+
properties: { name: { type: "string" }, version: { type: "string" } }
|
|
5642
|
+
}
|
|
5643
|
+
}
|
|
5644
|
+
}
|
|
5645
|
+
},
|
|
5646
|
+
responses: {
|
|
5647
|
+
"200": { description: "Hook retrusted and pin updated" },
|
|
5648
|
+
"401": { description: "Missing or invalid API key" },
|
|
5649
|
+
"404": { description: "Hook not found in local store" },
|
|
5650
|
+
"409": { description: "Version mismatch with the local store" }
|
|
5651
|
+
},
|
|
5652
|
+
security: [{ apiKey: [] }]
|
|
5653
|
+
}
|
|
5654
|
+
}
|
|
5655
|
+
},
|
|
5656
|
+
components: {
|
|
5657
|
+
securitySchemes: {
|
|
5658
|
+
apiKey: {
|
|
5659
|
+
type: "http",
|
|
5660
|
+
scheme: "bearer",
|
|
5661
|
+
description: "The registry API key (HASNA_HOOKS_API_KEY / HOOKS_API_KEY)."
|
|
5662
|
+
}
|
|
5663
|
+
}
|
|
5664
|
+
}
|
|
5665
|
+
};
|
|
5666
|
+
|
|
5224
5667
|
// src/serve.ts
|
|
5225
5668
|
var DEFAULT_SERVE_PORT = 39428;
|
|
5226
5669
|
var SERVE_HOST = "127.0.0.1";
|
|
5227
5670
|
var SERVE_SERVICE_NAME = "hooks-registry";
|
|
5671
|
+
function packageVersion() {
|
|
5672
|
+
try {
|
|
5673
|
+
const pkgPath = join7(dirname3(fileURLToPath2(import.meta.url)), "..", "package.json");
|
|
5674
|
+
return JSON.parse(readFileSync5(pkgPath, "utf-8")).version || "0.0.0";
|
|
5675
|
+
} catch {
|
|
5676
|
+
return "0.0.0";
|
|
5677
|
+
}
|
|
5678
|
+
}
|
|
5228
5679
|
async function buildCatalog() {
|
|
5229
5680
|
const byName = new Map;
|
|
5230
5681
|
for (const meta of HOOKS) {
|
|
@@ -5297,6 +5748,20 @@ function handleServeRequest(req, apiKey) {
|
|
|
5297
5748
|
if (url.pathname === "/health" && req.method === "GET") {
|
|
5298
5749
|
return Promise.resolve(json({ status: "ok", name: SERVE_SERVICE_NAME }));
|
|
5299
5750
|
}
|
|
5751
|
+
if (url.pathname === "/ready" && req.method === "GET") {
|
|
5752
|
+
try {
|
|
5753
|
+
readLock();
|
|
5754
|
+
return Promise.resolve(json({ status: "ready", name: SERVE_SERVICE_NAME }));
|
|
5755
|
+
} catch {
|
|
5756
|
+
return Promise.resolve(json({ status: "not-ready", name: SERVE_SERVICE_NAME }, 503));
|
|
5757
|
+
}
|
|
5758
|
+
}
|
|
5759
|
+
if (url.pathname === "/version" && req.method === "GET") {
|
|
5760
|
+
return Promise.resolve(json({ version: packageVersion() }));
|
|
5761
|
+
}
|
|
5762
|
+
if (url.pathname === "/openapi.json" && req.method === "GET") {
|
|
5763
|
+
return Promise.resolve(json(openApiDocument));
|
|
5764
|
+
}
|
|
5300
5765
|
if (url.pathname === "/api/v1/catalog" && req.method === "GET") {
|
|
5301
5766
|
return buildCatalog().then((catalog) => json({ hooks: catalog }));
|
|
5302
5767
|
}
|