From e9445550959c118d2eff293e867d6eaac240a6e6 Mon Sep 17 00:00:00 2001 From: hasna-drain Date: Wed, 29 Jul 2026 13:41:32 +0000 Subject: [PATCH 1/3] chore: begin drain OPE31-00004 From 4cd61f38feb4a7380129ef3de70ae40a6db810bd Mon Sep 17 00:00:00 2001 From: hasna-drain Date: Wed, 29 Jul 2026 13:41:32 +0000 Subject: [PATCH 2/3] test(db): cover storage sync and legacy imports --- CHANGELOG.md | 4 + src/db/legacy-import.test.ts | 151 +++++++++++++++ src/db/legacy-import.ts | 4 +- src/db/storage-sync.test.ts | 350 +++++++++++++++++++++++++++++++++++ 4 files changed, 507 insertions(+), 2 deletions(-) create mode 100644 src/db/legacy-import.test.ts create mode 100644 src/db/storage-sync.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 04d6e92..8daba92 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Added unit coverage for SQLite/PostgreSQL storage synchronization and one-time legacy flat-file imports, including empty, malformed, and permission-refusal paths. + ### Changed - **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"`. diff --git a/src/db/legacy-import.test.ts b/src/db/legacy-import.test.ts new file mode 100644 index 0000000..c9f8a12 --- /dev/null +++ b/src/db/legacy-import.test.ts @@ -0,0 +1,151 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runLegacyImport } from "./legacy-import.js"; +import { applySchema } from "./schema.js"; + +interface ImportedEvent { + timestamp: string; + session_id: string; + hook_name: string; + event_type: string; + tool_name: string | null; + tool_input: string | null; + error: string | null; +} + +let db: Database; +let tempHome: string; + +function projectsDir(): string { + return join(tempHome, ".claude", "projects"); +} + +function importedEvents(): ImportedEvent[] { + return db.query( + `SELECT timestamp, session_id, hook_name, event_type, tool_name, tool_input, error + FROM hook_events ORDER BY timestamp, hook_name`, + ).all(); +} + +beforeEach(() => { + tempHome = mkdtempSync(join(tmpdir(), "hooks-legacy-import-test-")); + db = new Database(":memory:"); + applySchema(db); +}); + +afterEach(() => { + db.close(); + mock.restore(); + rmSync(tempHome, { recursive: true, force: true }); +}); + +describe("runLegacyImport", () => { + test("imports valid JSONL and error entries, skips malformed lines, and only runs once", () => { + const projectDir = join(projectsDir(), "project-a"); + mkdirSync(projectDir, { recursive: true }); + writeFileSync(join(projectDir, "session-log-2026-07-29.jsonl"), [ + JSON.stringify({ + timestamp: "2026-07-29T00:00:00.000Z", + session_id: "session-a", + tool_name: "Read", + tool_input: "src/index.ts", + }), + "not json", + JSON.stringify({ + timestamp: "2026-07-29T00:01:00.000Z", + tool_name: "Write", + tool_input: "x".repeat(600), + }), + "", + ].join("\n")); + writeFileSync(join(projectDir, "errors.log"), [ + "[2026-07-29T00:02:00.000Z] [session:abc] Build — failed to compile", + `[2026-07-29T00:03:00.000Z] Runtime — ${"e".repeat(600)}`, + "malformed error line", + ].join("\n")); + writeFileSync( + join(projectDir, "session-log-current.jsonl"), + JSON.stringify({ timestamp: "2026-07-29T00:04:00.000Z" }), + ); + + runLegacyImport(db, tempHome); + + const events = importedEvents(); + expect(events).toHaveLength(4); + expect(events[0]).toEqual({ + timestamp: "2026-07-29T00:00:00.000Z", + session_id: "session-a", + hook_name: "sessionlog", + event_type: "PostToolUse", + tool_name: "Read", + tool_input: "src/index.ts", + error: null, + }); + expect(events[1]).toMatchObject({ + session_id: "legacy", + hook_name: "sessionlog", + tool_name: "Write", + }); + expect(events[1]?.tool_input).toHaveLength(500); + expect(events[2]).toMatchObject({ + session_id: "legacy-abc", + hook_name: "errornotify", + event_type: "PostToolUse", + error: "failed to compile", + }); + expect(events[3]).toMatchObject({ session_id: "legacy", hook_name: "errornotify" }); + expect(events[3]?.error).toHaveLength(500); + expect(db.query<{ value: string }, []>( + "SELECT value FROM _meta WHERE key = 'legacy_import_done'", + ).get()).toEqual({ value: "1" }); + + writeFileSync( + join(projectDir, "session-log-2026-07-30.jsonl"), + JSON.stringify({ timestamp: "2026-07-30T00:00:00.000Z" }), + ); + runLegacyImport(db, tempHome); + expect(importedEvents()).toHaveLength(4); + }); + + test("marks an absent legacy directory complete without inserting events", () => { + runLegacyImport(db, tempHome); + + expect(importedEvents()).toEqual([]); + expect(db.query<{ value: string }, []>( + "SELECT value FROM _meta WHERE key = 'legacy_import_done'", + ).get()).toEqual({ value: "1" }); + }); + + test("skips unreadable project entries and files without aborting the import", () => { + mkdirSync(projectsDir(), { recursive: true }); + writeFileSync(join(projectsDir(), "not-a-directory"), "not a project directory"); + const projectDir = join(projectsDir(), "project-b"); + mkdirSync(join(projectDir, "errors.log"), { recursive: true }); + writeFileSync(join(projectDir, "session-log-2026-07-29.jsonl"), "{malformed"); + + expect(() => runLegacyImport(db, tempHome)).not.toThrow(); + expect(importedEvents()).toEqual([]); + expect(db.query<{ value: string }, []>( + "SELECT value FROM _meta WHERE key = 'legacy_import_done'", + ).get()).toEqual({ value: "1" }); + }); + + test("contains database write failures and still records completion", () => { + db.close(); + db = new Database(":memory:"); + const projectDir = join(projectsDir(), "project-c"); + mkdirSync(projectDir, { recursive: true }); + writeFileSync( + join(projectDir, "session-log-2026-07-29.jsonl"), + JSON.stringify({ timestamp: "2026-07-29T00:00:00.000Z" }), + ); + + expect(() => runLegacyImport(db, tempHome)).not.toThrow(); + expect(db.query<{ value: string }, []>( + "SELECT value FROM _meta WHERE key = 'legacy_import_done'", + ).get()).toEqual({ value: "1" }); + }); +}); diff --git a/src/db/legacy-import.ts b/src/db/legacy-import.ts index 7d2e394..21af44d 100644 --- a/src/db/legacy-import.ts +++ b/src/db/legacy-import.ts @@ -107,14 +107,14 @@ function importErrorsLog(db: Database, filePath: string): number { return count; } -export function runLegacyImport(db: Database): void { +export function runLegacyImport(db: Database, homeDir = homedir()): void { try { if (isAlreadyDone(db)) return; let total = 0; // Scan ~/.claude/projects// directories for session log files - const claudeProjectsDir = join(homedir(), ".claude", "projects"); + const claudeProjectsDir = join(homeDir, ".claude", "projects"); if (existsSync(claudeProjectsDir)) { try { const projectDirs = readdirSync(claudeProjectsDir); diff --git a/src/db/storage-sync.test.ts b/src/db/storage-sync.test.ts new file mode 100644 index 0000000..712ef1d --- /dev/null +++ b/src/db/storage-sync.test.ts @@ -0,0 +1,350 @@ +import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; +import { Database } from "bun:sqlite"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { closeDb, getDb } from "./index.js"; +import { PG_MIGRATIONS } from "./pg-migrations.js"; +import { PgAdapterAsync } from "./remote-storage.js"; +import { + HOOKS_STORAGE_BACKEND_ENV, + HOOKS_STORAGE_BACKEND_FALLBACK_ENV, + HOOKS_STORAGE_ENV, + HOOKS_STORAGE_FALLBACK_ENV, + RETIRED_STORAGE_MODE_ENV, + STORAGE_TABLES, + getStorageBackend, + getStorageDatabaseEnv, + getStorageDatabaseEnvName, + getStorageDatabaseUrl, + getStoragePg, + getStorageStatus, + getSyncMetaAll, + parseStorageTables, + resolveTables, + runStorageMigrations, + storagePull, + storagePush, + storageSync, +} from "./storage-sync.js"; + +const ENV_KEYS = [ + HOOKS_STORAGE_ENV, + HOOKS_STORAGE_FALLBACK_ENV, + HOOKS_STORAGE_BACKEND_ENV, + HOOKS_STORAGE_BACKEND_FALLBACK_ENV, + ...RETIRED_STORAGE_MODE_ENV, + "HASNA_HOOKS_DB_PATH", +] as const; + +let tempDir: string; + +function clearStorageEnv(): void { + for (const key of ENV_KEYS) delete process.env[key]; +} + +function useTestDatabase(): string { + const dbPath = join(tempDir, "hooks.db"); + new Database(dbPath).close(); + process.env.HASNA_HOOKS_DB_PATH = dbPath; + return dbPath; +} + +function mockPostgres(options: { + all?: (sql: string, ...params: unknown[]) => Promise; + run?: (sql: string, ...params: unknown[]) => Promise<{ changes: number }>; +} = {}) { + const run = spyOn(PgAdapterAsync.prototype, "run").mockImplementation( + options.run ?? (async () => ({ changes: 0 })), + ); + const all = spyOn(PgAdapterAsync.prototype, "all").mockImplementation( + options.all ?? (async () => []), + ); + const close = spyOn(PgAdapterAsync.prototype, "close").mockResolvedValue(); + return { run, all, close }; +} + +beforeEach(() => { + closeDb(); + clearStorageEnv(); + tempDir = mkdtempSync(join(tmpdir(), "hooks-storage-sync-test-")); +}); + +afterEach(() => { + closeDb(); + clearStorageEnv(); + mock.restore(); + rmSync(tempDir, { recursive: true, force: true }); +}); + +describe("storage configuration", () => { + test("database env helpers prefer a non-empty primary value and expose its trimmed URL", () => { + expect(getStorageDatabaseEnvName()).toBeNull(); + expect(getStorageDatabaseEnv()).toBeNull(); + expect(getStorageDatabaseUrl()).toBeNull(); + + process.env[HOOKS_STORAGE_ENV] = " "; + process.env[HOOKS_STORAGE_FALLBACK_ENV] = " postgres://fallback/hooks "; + expect(getStorageDatabaseEnvName()).toBe(HOOKS_STORAGE_FALLBACK_ENV); + expect(getStorageDatabaseEnv()).toEqual({ name: HOOKS_STORAGE_FALLBACK_ENV }); + expect(getStorageDatabaseUrl()).toBe("postgres://fallback/hooks"); + + process.env[HOOKS_STORAGE_ENV] = "postgres://primary/hooks"; + expect(getStorageDatabaseEnvName()).toBe(HOOKS_STORAGE_ENV); + expect(getStorageDatabaseUrl()).toBe("postgres://primary/hooks"); + }); + + test("backend selection supports aliases and rejects invalid or retired configuration", () => { + expect(getStorageBackend()).toBe("sqlite"); + + process.env[HOOKS_STORAGE_FALLBACK_ENV] = "postgres://fallback/hooks"; + expect(getStorageBackend()).toBe("postgresql"); + + process.env[HOOKS_STORAGE_BACKEND_ENV] = " PG "; + expect(getStorageBackend()).toBe("postgresql"); + + process.env[HOOKS_STORAGE_BACKEND_ENV] = "hybrid"; + expect(() => getStorageBackend()).toThrow(/retired deployment mode/i); + + process.env[HOOKS_STORAGE_BACKEND_ENV] = "unknown"; + expect(() => getStorageBackend()).toThrow(/not a known hooks storage backend/i); + + delete process.env[HOOKS_STORAGE_BACKEND_ENV]; + process.env.HOOKS_STORAGE_MODE = "remote"; + expect(() => getStorageBackend()).toThrow(/HOOKS_STORAGE_MODE is a retired/); + }); + + test("getStoragePg refuses missing configuration and creates an adapter for a URL", async () => { + await expect(getStoragePg()).rejects.toThrow(/Missing HASNA_HOOKS_DATABASE_URL/); + + process.env[HOOKS_STORAGE_ENV] = "postgres://example/hooks"; + const close = spyOn(PgAdapterAsync.prototype, "close").mockResolvedValue(); + const remote = await getStoragePg(); + expect(remote).toBeInstanceOf(PgAdapterAsync); + await remote.close(); + expect(close).toHaveBeenCalledTimes(1); + }); +}); + +describe("table selection", () => { + test("resolveTables handles defaults, empty values, trimming, and invalid names", () => { + expect(resolveTables()).toEqual([...STORAGE_TABLES]); + expect(resolveTables([])).toEqual([...STORAGE_TABLES]); + expect(resolveTables([" hook_events ", "", "feedback"])).toEqual(["hook_events", "feedback"]); + expect(resolveTables([" "])).toEqual([]); + expect(() => resolveTables(["hook_events", "missing", "other"])).toThrow( + "Unknown hooks sync table(s): missing, other", + ); + }); + + test("parseStorageTables handles absent, comma-delimited, array, and invalid input", () => { + expect(parseStorageTables()).toBeUndefined(); + expect(parseStorageTables(null)).toBeUndefined(); + expect(parseStorageTables("")).toBeUndefined(); + expect(parseStorageTables("hook_events, ,feedback")).toEqual(["hook_events", "feedback"]); + expect(parseStorageTables(["_meta"])).toEqual(["_meta"]); + expect(() => parseStorageTables("hook_events,nope")).toThrow("Unknown hooks sync table(s): nope"); + }); +}); + +describe("PostgreSQL migrations", () => { + test("runs the extension and every migration in order", async () => { + const run = mock(async (_sql: string) => ({ changes: 0 })); + const remote = { run } as unknown as PgAdapterAsync; + + await runStorageMigrations(remote); + + expect(run).toHaveBeenCalledTimes(PG_MIGRATIONS.length + 1); + expect(run.mock.calls.map(([sql]) => sql)).toEqual([ + "CREATE EXTENSION IF NOT EXISTS pgcrypto", + ...PG_MIGRATIONS, + ]); + }); + + test("propagates a migration permission refusal and stops", async () => { + const run = mock(async () => { + throw new Error("permission denied for extension pgcrypto"); + }); + const remote = { run } as unknown as PgAdapterAsync; + + await expect(runStorageMigrations(remote)).rejects.toThrow("permission denied"); + expect(run).toHaveBeenCalledTimes(1); + }); +}); + +describe("storage synchronization", () => { + test("pushes local rows, records sync metadata, and closes PostgreSQL", async () => { + useTestDatabase(); + process.env[HOOKS_STORAGE_ENV] = "postgres://example/hooks"; + const db = getDb(); + db.run( + `INSERT INTO hook_events (id, timestamp, session_id, hook_name, event_type, tool_name) + VALUES (?, ?, ?, ?, ?, ?)`, + ["local-1", "2026-07-29T00:00:00.000Z", "session-1", "sessionlog", "PostToolUse", "Read"], + ); + const remoteColumns = ["id", "timestamp", "session_id", "hook_name", "event_type", "tool_name"]; + const pg = mockPostgres({ + all: async (sql) => sql.includes("information_schema.columns") + ? remoteColumns.map((column_name) => ({ column_name, data_type: "text" })) + : [], + }); + + const results = await storagePush({ tables: ["hook_events"] }); + + expect(results).toEqual([{ table: "hook_events", rowsRead: 1, rowsWritten: 1, errors: [] }]); + const insert = pg.run.mock.calls.find(([sql]) => sql.includes('INSERT INTO "hook_events"')); + expect(insert?.[0]).toContain('ON CONFLICT ("id") DO UPDATE'); + expect(insert?.slice(1)).toEqual([ + "local-1", + "2026-07-29T00:00:00.000Z", + "session-1", + "sessionlog", + "PostToolUse", + "Read", + ]); + expect(getSyncMetaAll()).toEqual([ + { table_name: "hook_events", direction: "push", last_synced_at: expect.any(String) }, + ]); + expect(pg.close).toHaveBeenCalledTimes(1); + }); + + test("returns a per-table push error on remote permission refusal and does not mark it synced", async () => { + useTestDatabase(); + process.env[HOOKS_STORAGE_ENV] = "postgres://example/hooks"; + getDb().run( + `INSERT INTO hook_events (id, timestamp, session_id, hook_name, event_type) + VALUES (?, ?, ?, ?, ?)`, + ["local-1", "2026-07-29T00:00:00.000Z", "session-1", "sessionlog", "PostToolUse"], + ); + const pg = mockPostgres({ + all: async () => [], + run: async (sql) => { + if (sql.includes('INSERT INTO "hook_events"')) throw new Error("permission denied for hook_events"); + return { changes: 0 }; + }, + }); + + const results = await storagePush({ tables: ["hook_events"] }); + + expect(results).toEqual([{ + table: "hook_events", + rowsRead: 1, + rowsWritten: 0, + errors: ["permission denied for hook_events"], + }]); + expect(getSyncMetaAll()).toEqual([]); + expect(pg.close).toHaveBeenCalledTimes(1); + }); + + test("pulls remote rows through the local schema and records sync metadata", async () => { + useTestDatabase(); + process.env[HOOKS_STORAGE_ENV] = "postgres://example/hooks"; + const pg = mockPostgres({ + all: async (sql) => sql.includes('SELECT * FROM "hook_events"') + ? [{ + id: "remote-1", + timestamp: "2026-07-29T01:00:00.000Z", + session_id: "session-2", + hook_name: "sessionlog", + event_type: "PostToolUse", + tool_name: "Write", + remote_only: "ignored", + }] + : [], + }); + + const results = await storagePull({ tables: ["hook_events"] }); + + expect(results).toEqual([{ table: "hook_events", rowsRead: 1, rowsWritten: 1, errors: [] }]); + expect(getDb().query( + "SELECT id, tool_name FROM hook_events WHERE id = 'remote-1'", + ).get()).toEqual({ id: "remote-1", tool_name: "Write" }); + expect(getSyncMetaAll()).toEqual([ + { table_name: "hook_events", direction: "pull", last_synced_at: expect.any(String) }, + ]); + expect(pg.close).toHaveBeenCalledTimes(1); + }); + + test("returns a per-table pull error on remote read refusal and does not mark it synced", async () => { + useTestDatabase(); + process.env[HOOKS_STORAGE_ENV] = "postgres://example/hooks"; + const pg = mockPostgres({ + all: async (sql) => { + if (sql.includes('SELECT * FROM "hook_events"')) throw new Error("not authorized to read hook_events"); + return []; + }, + }); + + const results = await storagePull({ tables: ["hook_events"] }); + + expect(results).toEqual([{ + table: "hook_events", + rowsRead: 0, + rowsWritten: 0, + errors: ["not authorized to read hook_events"], + }]); + expect(getSyncMetaAll()).toEqual([]); + expect(pg.close).toHaveBeenCalledTimes(1); + }); + + test("sync performs pull then push and closes both adapters for an empty table", async () => { + useTestDatabase(); + process.env[HOOKS_STORAGE_ENV] = "postgres://example/hooks"; + const pg = mockPostgres(); + + const result = await storageSync({ tables: ["feedback"] }); + + expect(result).toEqual({ + pull: [{ table: "feedback", rowsRead: 0, rowsWritten: 0, errors: [] }], + push: [{ table: "feedback", rowsRead: 0, rowsWritten: 0, errors: [] }], + }); + expect(getSyncMetaAll().map(({ table_name, direction }) => ({ table_name, direction }))).toEqual([ + { table_name: "feedback", direction: "pull" }, + { table_name: "feedback", direction: "push" }, + ]); + expect(pg.close).toHaveBeenCalledTimes(2); + }); + + test("sync rejects missing database configuration before doing work", async () => { + useTestDatabase(); + await expect(storageSync({ tables: ["feedback"] })).rejects.toThrow(/Missing HASNA_HOOKS_DATABASE_URL/); + expect(getSyncMetaAll()).toEqual([]); + }); +}); + +describe("sync metadata and status", () => { + test("getSyncMetaAll creates an empty metadata table and returns rows in stable order", () => { + useTestDatabase(); + expect(getSyncMetaAll()).toEqual([]); + const db = getDb(); + db.run( + "INSERT INTO _hooks_sync_meta (table_name, last_synced_at, direction) VALUES (?, ?, ?)", + ["feedback", null, "push"], + ); + db.run( + "INSERT INTO _hooks_sync_meta (table_name, last_synced_at, direction) VALUES (?, ?, ?)", + ["feedback", "2026-07-29T00:00:00.000Z", "pull"], + ); + expect(getSyncMetaAll()).toEqual([ + { table_name: "feedback", last_synced_at: "2026-07-29T00:00:00.000Z", direction: "pull" }, + { table_name: "feedback", last_synced_at: null, direction: "push" }, + ]); + }); + + test("getStorageStatus reports configured state and rejects a bad backend", () => { + useTestDatabase(); + process.env[HOOKS_STORAGE_FALLBACK_ENV] = "postgres://fallback/hooks"; + expect(getStorageStatus()).toEqual({ + configured: true, + backend: "postgresql", + env: [HOOKS_STORAGE_ENV, HOOKS_STORAGE_FALLBACK_ENV], + activeEnv: HOOKS_STORAGE_FALLBACK_ENV, + service: "hooks", + tables: STORAGE_TABLES, + sync: [], + }); + + process.env[HOOKS_STORAGE_BACKEND_ENV] = "invalid"; + expect(() => getStorageStatus()).toThrow(/not a known hooks storage backend/); + }); +}); From eadc07c45947207ad9510156465c89d952b827b7 Mon Sep 17 00:00:00 2001 From: Andrei Date: Fri, 31 Jul 2026 19:45:27 +0300 Subject: [PATCH 3/3] test: isolate hook settings in installer gates Use explicit settings-path overrides in tests so local Claude settings do not affect install/update expectations. Agent: Augustus --- src/cli/cli.test.ts | 39 ++++++++++++++++-------- src/lib/installer.test.ts | 63 +++++++++++++++++++++++---------------- src/lib/installer.ts | 8 +++++ src/mcp/server.test.ts | 37 +++++++++++------------ 4 files changed, 90 insertions(+), 57 deletions(-) diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index acd8e8e..2373d3c 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -1,35 +1,46 @@ -import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test"; import { join } from "path"; -import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync } from "fs"; -import { homedir, tmpdir } from "os"; +import { existsSync, readFileSync, writeFileSync, mkdirSync, mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; const CLI = join(import.meta.dir, "index.tsx"); -const SETTINGS_PATH = join(homedir(), ".claude", "settings.json"); - -let settingsBackup: string | null = null; +const TEST_HOME = mkdtempSync(join(process.cwd(), ".tmp-cli-home-")); +const SETTINGS_PATH = join(TEST_HOME, ".claude", "settings.json"); + +const settingsBackups: Array = []; + +function cliEnv(): Record { + return { + ...process.env, + HOME: TEST_HOME, + HASNA_HOOKS_CLAUDE_SETTINGS_PATH: SETTINGS_PATH, + NO_COLOR: "1", + }; +} function backupSettings(): void { if (existsSync(SETTINGS_PATH)) { - settingsBackup = readFileSync(SETTINGS_PATH, "utf-8"); + settingsBackups.push(readFileSync(SETTINGS_PATH, "utf-8")); } else { - settingsBackup = null; + settingsBackups.push(null); } } function restoreSettings(): void { + const settingsBackup = settingsBackups.pop(); + if (settingsBackup === undefined) return; if (settingsBackup !== null) { writeFileSync(SETTINGS_PATH, settingsBackup); } else if (existsSync(SETTINGS_PATH)) { rmSync(SETTINGS_PATH); } - settingsBackup = null; } async function run(...args: string[]): Promise<{ stdout: string; stderr: string; exitCode: number }> { const proc = Bun.spawn(["bun", "run", CLI, ...args], { stdout: "pipe", stderr: "pipe", - env: { ...process.env, NO_COLOR: "1" }, + env: cliEnv(), }); const [stdout, stderr] = await Promise.all([ new Response(proc.stdout).text(), @@ -39,6 +50,10 @@ async function run(...args: string[]): Promise<{ stdout: string; stderr: string; return { stdout, stderr, exitCode }; } +afterAll(() => { + rmSync(TEST_HOME, { recursive: true, force: true }); +}); + async function runJson(...args: string[]): Promise { const { stdout } = await run(...args, "--json"); return JSON.parse(stdout.trim()); @@ -318,7 +333,7 @@ describe("CLI", () => { stdout: "pipe", stderr: "pipe", cwd: tmpdir(), - env: { ...process.env, NO_COLOR: "1" }, + env: cliEnv(), }); const stdout = await new Response(proc.stdout).text(); await proc.exited; @@ -330,7 +345,7 @@ describe("CLI", () => { stdout: "pipe", stderr: "pipe", cwd: tmpdir(), - env: { ...process.env, NO_COLOR: "1" }, + env: cliEnv(), }); const stdout = await new Response(proc.stdout).text(); await proc.exited; diff --git a/src/lib/installer.test.ts b/src/lib/installer.test.ts index 27e9ce7..7bc5473 100644 --- a/src/lib/installer.test.ts +++ b/src/lib/installer.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeEach, afterEach } from "bun:test"; +import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test"; import { existsSync, readFileSync, writeFileSync, mkdirSync, rmSync, mkdtempSync } from "fs"; import { dirname, join } from "path"; import { homedir, tmpdir } from "os"; @@ -17,39 +17,31 @@ import { } from "./installer.js"; import { HOOKS, getHookEvents } from "./registry.js"; -const GLOBAL_SETTINGS = join(homedir(), ".claude", "settings.json"); +const TEST_HOME = mkdtempSync(join(process.cwd(), ".tmp-installer-home-")); +const GLOBAL_SETTINGS = join(TEST_HOME, ".claude", "settings.json"); +const GEMINI_SETTINGS = join(TEST_HOME, ".gemini", "settings.json"); +const originalClaudeSettingsPath = process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH; +const originalGeminiSettingsPath = process.env.HASNA_HOOKS_GEMINI_SETTINGS_PATH; +const settingsBackups: Array = []; -let settingsBackup: string | null = null; +process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH = GLOBAL_SETTINGS; +process.env.HASNA_HOOKS_GEMINI_SETTINGS_PATH = GEMINI_SETTINGS; function backupSettings(): void { if (existsSync(GLOBAL_SETTINGS)) { - settingsBackup = readFileSync(GLOBAL_SETTINGS, "utf-8"); + settingsBackups.push(readFileSync(GLOBAL_SETTINGS, "utf-8")); } else { - settingsBackup = null; + settingsBackups.push(null); } } function restoreSettings(): void { + const settingsBackup = settingsBackups.pop(); + if (settingsBackup === undefined) return; if (settingsBackup !== null) { writeFileSync(GLOBAL_SETTINGS, settingsBackup); } else if (existsSync(GLOBAL_SETTINGS)) { - // Clean up only our test hooks - try { - const settings = JSON.parse(readFileSync(GLOBAL_SETTINGS, "utf-8")); - if (settings.hooks) { - for (const eventKey of Object.keys(settings.hooks)) { - settings.hooks[eventKey] = settings.hooks[eventKey].filter( - (entry: any) => - !entry.hooks?.some((h: any) => - /hooks run (gitguard|checkpoint|packageage|branchprotect)/.test(h.command || "") - ) - ); - if (settings.hooks[eventKey].length === 0) delete settings.hooks[eventKey]; - } - if (Object.keys(settings.hooks).length === 0) delete settings.hooks; - } - writeFileSync(GLOBAL_SETTINGS, JSON.stringify(settings, null, 2) + "\n"); - } catch {} + rmSync(GLOBAL_SETTINGS); } } @@ -61,10 +53,29 @@ afterEach(() => { restoreSettings(); }); +afterAll(() => { + if (originalClaudeSettingsPath === undefined) delete process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH; + else process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH = originalClaudeSettingsPath; + if (originalGeminiSettingsPath === undefined) delete process.env.HASNA_HOOKS_GEMINI_SETTINGS_PATH; + else process.env.HASNA_HOOKS_GEMINI_SETTINGS_PATH = originalGeminiSettingsPath; + rmSync(TEST_HOME, { recursive: true, force: true }); +}); + describe("installer", () => { describe("getSettingsPath", () => { - test("global returns ~/.claude/settings.json", () => { - expect(getSettingsPath("global")).toBe(join(homedir(), ".claude", "settings.json")); + test("global returns configured Claude settings path when overridden", () => { + expect(getSettingsPath("global")).toBe(GLOBAL_SETTINGS); + }); + + test("global returns ~/.claude/settings.json without override", () => { + const previous = process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH; + delete process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH; + try { + expect(getSettingsPath("global")).toBe(join(homedir(), ".claude", "settings.json")); + } finally { + if (previous === undefined) delete process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH; + else process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH = previous; + } }); test("project returns .claude/settings.json in cwd", () => { @@ -480,11 +491,11 @@ describe("installer", () => { describe("getSettingsPath default", () => { test("defaults to global when no argument", () => { - expect(getSettingsPath()).toBe(join(homedir(), ".claude", "settings.json")); + expect(getSettingsPath()).toBe(GLOBAL_SETTINGS); }); test("gemini global path", () => { - expect(getSettingsPath("global", "gemini")).toBe(join(homedir(), ".gemini", "settings.json")); + expect(getSettingsPath("global", "gemini")).toBe(GEMINI_SETTINGS); }); test("gemini project path", () => { diff --git a/src/lib/installer.ts b/src/lib/installer.ts index c81aa5f..95236bb 100644 --- a/src/lib/installer.ts +++ b/src/lib/installer.ts @@ -92,6 +92,12 @@ function getTargetSettingsDir(target: SingleTarget): string { return ".claude"; } +function getGlobalSettingsPathOverride(target: SingleTarget): string | undefined { + if (target === "claude") return process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH; + if (target === "gemini") return process.env.HASNA_HOOKS_GEMINI_SETTINGS_PATH; + return undefined; +} + export interface InstallResult { hook: string; success: boolean; @@ -125,6 +131,8 @@ export function getSettingsPath(scope: Scope = "global", target: SingleTarget = if (target === "codewith" && process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH) { return process.env.HASNA_HOOKS_CODEWITH_CONFIG_PATH; } + const globalOverride = scope === "global" ? getGlobalSettingsPathOverride(target) : undefined; + if (globalOverride) return globalOverride; const dir = getTargetSettingsDir(target); if (scope === "project") { return target === "codewith" ? join(process.cwd(), dir, "config.toml") : join(process.cwd(), dir, "settings.json"); diff --git a/src/mcp/server.test.ts b/src/mcp/server.test.ts index b6ae471..09a9853 100644 --- a/src/mcp/server.test.ts +++ b/src/mcp/server.test.ts @@ -1,46 +1,45 @@ import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from "bun:test"; import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { join } from "path"; -import { homedir, tmpdir } from "os"; +import { tmpdir } from "os"; import { Client } from "@modelcontextprotocol/sdk/client"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; import { createHooksServer, MCP_PORT } from "./server.js"; import { closeDb, getDb } from "../db/index.js"; -const SETTINGS_PATH = join(homedir(), ".claude", "settings.json"); +const TEST_HOME = join(process.cwd(), ".tmp-mcp-home"); +const SETTINGS_PATH = join(TEST_HOME, ".claude", "settings.json"); const TEST_PORT = 39428; -let settingsBackup: string | null = null; +const originalClaudeSettingsPath = process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH; +const settingsBackups: Array = []; + +process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH = SETTINGS_PATH; function backupSettings(): void { if (existsSync(SETTINGS_PATH)) { - settingsBackup = readFileSync(SETTINGS_PATH, "utf-8"); + settingsBackups.push(readFileSync(SETTINGS_PATH, "utf-8")); } else { - settingsBackup = null; + settingsBackups.push(null); } } function restoreSettings(): void { + const settingsBackup = settingsBackups.pop(); + if (settingsBackup === undefined) return; if (settingsBackup !== null) { writeFileSync(SETTINGS_PATH, settingsBackup); } else if (existsSync(SETTINGS_PATH)) { - try { - const settings = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8")); - if (settings.hooks) { - for (const eventKey of Object.keys(settings.hooks)) { - settings.hooks[eventKey] = settings.hooks[eventKey].filter( - (entry: any) => - !entry.hooks?.some((h: any) => /hooks run /.test(h.command || "")) - ); - if (settings.hooks[eventKey].length === 0) delete settings.hooks[eventKey]; - } - if (Object.keys(settings.hooks).length === 0) delete settings.hooks; - } - writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2) + "\n"); - } catch {} + rmSync(SETTINGS_PATH); } } +afterAll(() => { + if (originalClaudeSettingsPath === undefined) delete process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH; + else process.env.HASNA_HOOKS_CLAUDE_SETTINGS_PATH = originalClaudeSettingsPath; + rmSync(TEST_HOME, { recursive: true, force: true }); +}); + function parseResult(result: any): any { return JSON.parse((result.content as any)[0].text); }