Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Enabled TypeScript's `noUncheckedIndexedAccess` check and handled potentially missing array elements and lookup results explicitly.
- **BREAKING: deployment modes are gone; hooks storage is a two-value data-backend switch.** `StorageMode = "local" | "hybrid" | "remote"` described *where* something ran, which was never a property of the data layer, and nothing in the codebase ever branched on it — it was reported by `hooks storage status` and the `storage_status` MCP tool and otherwise decorative. It is replaced by `StorageBackend = "sqlite" | "postgresql"`.
- `HASNA_HOOKS_STORAGE_MODE` and `HOOKS_STORAGE_MODE` are retired and are **no longer read**. Setting either now raises an error naming the replacement variable and the backend to use, instead of being quietly ignored: `local` became `sqlite`, and `hybrid` / `remote` / `self_hosted` / `self-hosted` / `cloud` all became `postgresql`.
- New `HASNA_HOOKS_STORAGE_BACKEND` (fallback `HOOKS_STORAGE_BACKEND`) accepts `sqlite` or `postgresql` (`sqlite3`, `postgres` and `pg` are accepted aliases). **An unrecognised value now throws.** Previously any unknown value — including a typo — fell through `normalizeStorageMode` to `undefined` and then silently to `local`, so a misconfigured mode looked like a working local one. That silent normalisation was the actual defect; the vocabulary was its symptom.
Expand Down
4 changes: 2 additions & 2 deletions src/cli/components/InstallProgress.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ export function InstallProgress({
const install = async () => {
const newResults: InstallResult[] = [];

for (let i = 0; i < hooks.length; i++) {
for (const [i, hook] of hooks.entries()) {
setCurrent(i);
await new Promise((r) => setTimeout(r, 100));

const result = installHook(hooks[i], { overwrite });
const result = installHook(hook, { overwrite });
newResults.push(result);
setResults([...newResults]);
}
Expand Down
30 changes: 22 additions & 8 deletions src/cli/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,16 +124,28 @@ function printDisclosureHint(hidden: number, detailCommand: string, options: { i
/** Levenshtein distance for did-you-mean suggestions */
function editDistance(a: string, b: string): number {
const m = a.length, n = b.length;
const dp: number[][] = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]);
for (let j = 0; j <= n; j++) dp[0][j] = j;
const dp: number[][] = [Array.from({ length: n + 1 }, (_, j) => j)];
for (let i = 1; i <= m; i++) {
const previousRow = dp[i - 1];
if (!previousRow) throw new Error("Unable to calculate edit distance");

const row = [i];
for (let j = 1; j <= n; j++) {
dp[i][j] = a[i - 1] === b[j - 1]
? dp[i - 1][j - 1]
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
const deletion = previousRow[j];
const insertion = row[j - 1];
const substitution = previousRow[j - 1];
if (deletion === undefined || insertion === undefined || substitution === undefined) {
throw new Error("Unable to calculate edit distance");
}
row.push(a.charAt(i - 1) === b.charAt(j - 1)
? substitution
: 1 + Math.min(deletion, insertion, substitution));
}
dp.push(row);
}
return dp[m][n];
const distance = dp[m]?.[n];
if (distance === undefined) throw new Error("Unable to calculate edit distance");
return distance;
}

function suggestHooks(name: string, max = 3): string[] {
Expand Down Expand Up @@ -1152,8 +1164,10 @@ logCmd
function parseDuration(s: string): number {
const m = s.match(/^(\d+)(s|m|h|d)$/);
if (!m) return 24 * 60 * 60 * 1000;
const n = parseInt(m[1]);
switch (m[2]) {
const [, amount, unit] = m;
if (!amount || !unit) return 24 * 60 * 60 * 1000;
const n = parseInt(amount);
switch (unit) {
case "s": return n * 1000;
case "m": return n * 60 * 1000;
case "h": return n * 60 * 60 * 1000;
Expand Down
1 change: 1 addition & 0 deletions src/db/legacy-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ function importErrorsLog(db: Database, filePath: string): number {
const m = line.match(linePattern);
if (!m) continue;
const [, timestamp, sessionPrefix, , errorMsg] = m;
if (!timestamp || !errorMsg) continue;
db.run(
`INSERT OR IGNORE INTO hook_events
(id, timestamp, session_id, hook_name, event_type, error)
Expand Down
8 changes: 4 additions & 4 deletions src/lib/installer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,15 +199,15 @@ describe("installer", () => {
test("installs multiple hooks", () => {
const results = installHooks(["gitguard", "checkpoint"]);
expect(results).toHaveLength(2);
expect(results[0].success).toBe(true);
expect(results[1].success).toBe(true);
expect(results[0]?.success).toBe(true);
expect(results[1]?.success).toBe(true);
});

test("returns mixed results for valid and invalid hooks", () => {
const results = installHooks(["gitguard", "nonexistent"]);
expect(results).toHaveLength(2);
expect(results[0].success).toBe(true);
expect(results[1].success).toBe(false);
expect(results[0]?.success).toBe(true);
expect(results[1]?.success).toBe(false);
});
});

Expand Down
3 changes: 2 additions & 1 deletion src/lib/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,8 @@ export function getRegisteredHooksForTarget(scope: Scope = "global", target: Sin
const re = /command\s*=\s*["']hooks run ([\w-]+)(?:\s+--profile\s+[\w-]+)?["']/g;
let match: RegExpExecArray | null;
while ((match = re.exec(config))) {
registered.push(match[1]);
const hookName = match[1];
if (hookName) registered.push(hookName);
}
return [...new Set(registered)];
}
Expand Down
7 changes: 5 additions & 2 deletions src/lib/profiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,11 @@ describe("profiles", () => {

const all = listProfiles();
for (let i = 1; i < all.length; i++) {
expect(new Date(all[i].created_at).getTime()).toBeGreaterThanOrEqual(
new Date(all[i - 1].created_at).getTime()
const current = all[i];
const previous = all[i - 1];
if (!current || !previous) throw new Error("Expected adjacent profiles");
expect(new Date(current.created_at).getTime()).toBeGreaterThanOrEqual(
new Date(previous.created_at).getTime()
);
}
});
Expand Down
8 changes: 4 additions & 4 deletions src/lib/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,25 +161,25 @@ describe("registry", () => {
test("finds hooks by name", () => {
const results = searchHooks("gitguard");
expect(results).toHaveLength(1);
expect(results[0].name).toBe("gitguard");
expect(results[0]?.name).toBe("gitguard");
});

test("finds hooks by display name", () => {
const results = searchHooks("Git Guard");
expect(results).toHaveLength(1);
expect(results[0].name).toBe("gitguard");
expect(results[0]?.name).toBe("gitguard");
});

test("finds hooks by description keyword", () => {
const results = searchHooks("destructive");
expect(results.length).toBeGreaterThanOrEqual(1);
expect(results[0].name).toBe("gitguard");
expect(results[0]?.name).toBe("gitguard");
});

test("finds hooks by tag", () => {
const results = searchHooks("typosquatting");
expect(results).toHaveLength(1);
expect(results[0].name).toBe("packageage");
expect(results[0]?.name).toBe("packageage");
});

test("search is case-insensitive", () => {
Expand Down
39 changes: 33 additions & 6 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -803,8 +803,17 @@ export function createHooksServer(): McpServer {
function parseDuration(s: string): string | null {
const m = s.match(/^(\d+)(s|m|h|d)$/);
if (!m) return null;
const n = parseInt(m[1]);
const ms = { s: 1000, m: 60000, h: 3600000, d: 86400000 }[m[2] as "s"|"m"|"h"|"d"]!;
const [, amount, unit] = m;
if (!amount || !unit) return null;
const n = parseInt(amount);
let ms: number;
switch (unit) {
case "s": ms = 1000; break;
case "m": ms = 60000; break;
case "h": ms = 3600000; break;
case "d": ms = 86400000; break;
default: return null;
}
return new Date(Date.now() - n * ms).toISOString();
}

Expand Down Expand Up @@ -877,8 +886,17 @@ export function createHooksServer(): McpServer {
function parseDuration(s: string): string {
const m = s.match(/^(\d+)(s|m|h|d)$/);
if (!m) return s;
const n = parseInt(m[1]);
const ms = { s: 1000, m: 60000, h: 3600000, d: 86400000 }[m[2] as "s"|"m"|"h"|"d"]!;
const [, amount, unit] = m;
if (!amount || !unit) return s;
const n = parseInt(amount);
let ms: number;
switch (unit) {
case "s": ms = 1000; break;
case "m": ms = 60000; break;
case "h": ms = 3600000; break;
case "d": ms = 86400000; break;
default: return s;
}
return new Date(Date.now() - n * ms).toISOString();
}

Expand Down Expand Up @@ -913,8 +931,17 @@ export function createHooksServer(): McpServer {
function parseDuration(s: string): string {
const m = s.match(/^(\d+)(s|m|h|d)$/);
if (!m) return s;
const n = parseInt(m[1]);
const ms = { s: 1000, m: 60000, h: 3600000, d: 86400000 }[m[2] as "s"|"m"|"h"|"d"]!;
const [, amount, unit] = m;
if (!amount || !unit) return s;
const n = parseInt(amount);
let ms: number;
switch (unit) {
case "s": ms = 1000; break;
case "m": ms = 60000; break;
case "h": ms = 3600000; break;
case "d": ms = 86400000; break;
default: return s;
}
return new Date(Date.now() - n * ms).toISOString();
}

Expand Down
1 change: 1 addition & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "./dist",
Expand Down
Loading