@hasna/hooks 0.2.20 → 0.3.0
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 +45 -2
- package/bin/index.js +1157 -84
- package/dist/db/migrations/002_session_events.d.ts +10 -0
- package/dist/db/migrations/003_user_prompt_submit_event.d.ts +10 -0
- package/dist/db/pg-migrations.d.ts +1 -1
- package/dist/db/remote-storage.d.ts +9 -0
- package/dist/db/schema.d.ts +4 -3
- package/dist/db/storage-sync.d.ts +57 -0
- package/dist/index.d.ts +16 -3
- package/dist/index.js +1042 -58
- package/dist/lib/installer.d.ts +25 -5
- package/dist/lib/registry.d.ts +3 -1
- package/dist/storage.d.ts +4 -0
- package/dist/storage.js +696 -0
- package/hooks/codewith-native-common.test.ts +61 -0
- package/hooks/codewith-native-common.ts +478 -0
- package/hooks/hook-agent-rules-version-check/LICENSE +191 -0
- package/hooks/hook-agent-rules-version-check/README.md +50 -0
- package/hooks/hook-agent-rules-version-check/package.json +53 -0
- package/hooks/hook-agent-rules-version-check/src/hook.test.ts +104 -0
- package/hooks/hook-agent-rules-version-check/src/hook.ts +215 -0
- package/hooks/hook-agent-rules-version-check/tsconfig.json +25 -0
- package/hooks/hook-announce-start/README.md +7 -5
- package/hooks/hook-announce-start/package.json +2 -2
- package/hooks/hook-announce-start/src/hook.ts +33 -14
- package/hooks/hook-fleet-blockers-gate/LICENSE +191 -0
- package/hooks/hook-fleet-blockers-gate/README.md +39 -0
- package/hooks/hook-fleet-blockers-gate/package.json +52 -0
- package/hooks/hook-fleet-blockers-gate/src/hook.test.ts +106 -0
- package/hooks/hook-fleet-blockers-gate/src/hook.ts +242 -0
- package/hooks/hook-fleet-blockers-gate/tsconfig.json +25 -0
- package/hooks/hook-fleet-catchup/LICENSE +191 -0
- package/hooks/hook-fleet-catchup/README.md +40 -0
- package/hooks/hook-fleet-catchup/package.json +52 -0
- package/hooks/hook-fleet-catchup/src/hook.test.ts +156 -0
- package/hooks/hook-fleet-catchup/src/hook.ts +283 -0
- package/hooks/hook-fleet-catchup/tsconfig.json +25 -0
- package/hooks/pre-bash/README.md +17 -0
- package/hooks/pre-bash/package.json +12 -0
- package/hooks/pre-bash/src/hook.ts +103 -0
- package/hooks/prompt-guard/README.md +17 -0
- package/hooks/prompt-guard/package.json +12 -0
- package/hooks/prompt-guard/src/hook.ts +76 -0
- package/hooks/session-start/README.md +17 -0
- package/hooks/session-start/package.json +12 -0
- package/hooks/session-start/src/hook.ts +150 -0
- package/hooks/stop-sync/README.md +17 -0
- package/hooks/stop-sync/package.json +12 -0
- package/hooks/stop-sync/src/hook.ts +69 -0
- package/hooks/worktree-guard/README.md +17 -0
- package/hooks/worktree-guard/package.json +12 -0
- package/hooks/worktree-guard/src/hook.ts +88 -0
- package/package.json +14 -6
- package/dist/cli/components/App.d.ts +0 -7
- package/dist/cli/components/CategorySelect.d.ts +0 -7
- package/dist/cli/components/DataTable.d.ts +0 -11
- package/dist/cli/components/Header.d.ts +0 -7
- package/dist/cli/components/HookSelect.d.ts +0 -11
- package/dist/cli/components/InstallProgress.d.ts +0 -9
- package/dist/cli/components/SearchView.d.ts +0 -9
- package/dist/cli/index.d.ts +0 -2
- package/dist/lib/db-writer.d.ts +0 -9
- package/dist/mcp/http.d.ts +0 -16
- package/dist/mcp/server.d.ts +0 -17
package/dist/storage.js
ADDED
|
@@ -0,0 +1,696 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __returnValue = (v) => v;
|
|
4
|
+
function __exportSetter(name, newValue) {
|
|
5
|
+
this[name] = __returnValue.bind(null, newValue);
|
|
6
|
+
}
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, {
|
|
10
|
+
get: all[name],
|
|
11
|
+
enumerable: true,
|
|
12
|
+
configurable: true,
|
|
13
|
+
set: __exportSetter.bind(all, name)
|
|
14
|
+
});
|
|
15
|
+
};
|
|
16
|
+
var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
|
|
17
|
+
|
|
18
|
+
// src/db/index.ts
|
|
19
|
+
import { Database } from "bun:sqlite";
|
|
20
|
+
import { existsSync as existsSync2, mkdirSync, cpSync } from "fs";
|
|
21
|
+
import { join as join2 } from "path";
|
|
22
|
+
import { homedir as homedir2 } from "os";
|
|
23
|
+
|
|
24
|
+
// src/db/schema.ts
|
|
25
|
+
var CREATE_HOOK_EVENTS_TABLE = `
|
|
26
|
+
CREATE TABLE IF NOT EXISTS hook_events (
|
|
27
|
+
id TEXT PRIMARY KEY,
|
|
28
|
+
timestamp TEXT NOT NULL,
|
|
29
|
+
session_id TEXT NOT NULL,
|
|
30
|
+
hook_name TEXT NOT NULL,
|
|
31
|
+
event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit')),
|
|
32
|
+
tool_name TEXT,
|
|
33
|
+
tool_input TEXT,
|
|
34
|
+
result TEXT CHECK (result IN ('continue', 'block', NULL)),
|
|
35
|
+
error TEXT,
|
|
36
|
+
duration_ms INTEGER,
|
|
37
|
+
project_dir TEXT,
|
|
38
|
+
metadata TEXT
|
|
39
|
+
)
|
|
40
|
+
`;
|
|
41
|
+
var CREATE_INDEXES = [
|
|
42
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_timestamp ON hook_events (timestamp)`,
|
|
43
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_session_id ON hook_events (session_id)`,
|
|
44
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_hook_name ON hook_events (hook_name)`,
|
|
45
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_event_type ON hook_events (event_type)`,
|
|
46
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_errors ON hook_events (timestamp) WHERE error IS NOT NULL`
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
// src/db/migrations/001_initial.ts
|
|
50
|
+
function up(db) {
|
|
51
|
+
db.exec(CREATE_HOOK_EVENTS_TABLE);
|
|
52
|
+
for (const idx of CREATE_INDEXES) {
|
|
53
|
+
db.exec(idx);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// src/db/migrations/002_session_events.ts
|
|
58
|
+
function up2(db) {
|
|
59
|
+
const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("hook_events");
|
|
60
|
+
if (!row?.sql)
|
|
61
|
+
return;
|
|
62
|
+
if (row.sql.includes("SessionStart"))
|
|
63
|
+
return;
|
|
64
|
+
db.exec("BEGIN");
|
|
65
|
+
try {
|
|
66
|
+
db.exec("ALTER TABLE hook_events RENAME TO hook_events_old");
|
|
67
|
+
db.exec(CREATE_HOOK_EVENTS_TABLE);
|
|
68
|
+
db.exec(`INSERT INTO hook_events
|
|
69
|
+
(id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
|
|
70
|
+
SELECT id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata
|
|
71
|
+
FROM hook_events_old`);
|
|
72
|
+
db.exec("DROP TABLE hook_events_old");
|
|
73
|
+
for (const idx of CREATE_INDEXES) {
|
|
74
|
+
db.exec(idx);
|
|
75
|
+
}
|
|
76
|
+
db.exec("COMMIT");
|
|
77
|
+
} catch (err) {
|
|
78
|
+
db.exec("ROLLBACK");
|
|
79
|
+
throw err;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// src/db/migrations/003_user_prompt_submit_event.ts
|
|
84
|
+
function up3(db) {
|
|
85
|
+
const row = db.query("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?").get("hook_events");
|
|
86
|
+
if (!row?.sql)
|
|
87
|
+
return;
|
|
88
|
+
if (row.sql.includes("UserPromptSubmit"))
|
|
89
|
+
return;
|
|
90
|
+
db.exec("BEGIN");
|
|
91
|
+
try {
|
|
92
|
+
db.exec("ALTER TABLE hook_events RENAME TO hook_events_old");
|
|
93
|
+
db.exec(CREATE_HOOK_EVENTS_TABLE);
|
|
94
|
+
db.exec(`INSERT INTO hook_events
|
|
95
|
+
(id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata)
|
|
96
|
+
SELECT id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, result, error, duration_ms, project_dir, metadata
|
|
97
|
+
FROM hook_events_old`);
|
|
98
|
+
db.exec("DROP TABLE hook_events_old");
|
|
99
|
+
for (const idx of CREATE_INDEXES) {
|
|
100
|
+
db.exec(idx);
|
|
101
|
+
}
|
|
102
|
+
db.exec("COMMIT");
|
|
103
|
+
} catch (err) {
|
|
104
|
+
db.exec("ROLLBACK");
|
|
105
|
+
throw err;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// src/db/migrations/index.ts
|
|
110
|
+
var MIGRATIONS = [
|
|
111
|
+
{ version: "001_initial", up },
|
|
112
|
+
{ version: "002_session_events", up: up2 },
|
|
113
|
+
{ version: "003_user_prompt_submit_event", up: up3 }
|
|
114
|
+
];
|
|
115
|
+
function ensureMigrationsTable(db) {
|
|
116
|
+
db.exec(`
|
|
117
|
+
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
118
|
+
version TEXT PRIMARY KEY,
|
|
119
|
+
applied_at TEXT NOT NULL
|
|
120
|
+
)
|
|
121
|
+
`);
|
|
122
|
+
}
|
|
123
|
+
function getApplied(db) {
|
|
124
|
+
const rows = db.query("SELECT version FROM schema_migrations").all();
|
|
125
|
+
return new Set(rows.map((r) => r.version));
|
|
126
|
+
}
|
|
127
|
+
function runMigrations(db) {
|
|
128
|
+
ensureMigrationsTable(db);
|
|
129
|
+
const applied = getApplied(db);
|
|
130
|
+
for (const migration of MIGRATIONS) {
|
|
131
|
+
if (applied.has(migration.version))
|
|
132
|
+
continue;
|
|
133
|
+
migration.up(db);
|
|
134
|
+
db.run("INSERT INTO schema_migrations (version, applied_at) VALUES (?, ?)", [
|
|
135
|
+
migration.version,
|
|
136
|
+
new Date().toISOString()
|
|
137
|
+
]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/db/legacy-import.ts
|
|
142
|
+
import { existsSync, readdirSync, readFileSync } from "fs";
|
|
143
|
+
import { join } from "path";
|
|
144
|
+
import { homedir } from "os";
|
|
145
|
+
var META_KEY = "legacy_import_done";
|
|
146
|
+
function ensureMetaTable(db) {
|
|
147
|
+
db.exec(`
|
|
148
|
+
CREATE TABLE IF NOT EXISTS _meta (
|
|
149
|
+
key TEXT PRIMARY KEY,
|
|
150
|
+
value TEXT NOT NULL
|
|
151
|
+
)
|
|
152
|
+
`);
|
|
153
|
+
}
|
|
154
|
+
function isAlreadyDone(db) {
|
|
155
|
+
ensureMetaTable(db);
|
|
156
|
+
const row = db.query("SELECT value FROM _meta WHERE key = ?").get(META_KEY);
|
|
157
|
+
return row?.value === "1";
|
|
158
|
+
}
|
|
159
|
+
function markDone(db) {
|
|
160
|
+
db.run("INSERT OR REPLACE INTO _meta (key, value) VALUES (?, ?)", [META_KEY, "1"]);
|
|
161
|
+
}
|
|
162
|
+
function nanoid() {
|
|
163
|
+
return crypto.randomUUID().replace(/-/g, "").slice(0, 21);
|
|
164
|
+
}
|
|
165
|
+
function importJsonlFile(db, filePath) {
|
|
166
|
+
let count = 0;
|
|
167
|
+
try {
|
|
168
|
+
const lines = readFileSync(filePath, "utf-8").split(`
|
|
169
|
+
`).filter(Boolean);
|
|
170
|
+
for (const line of lines) {
|
|
171
|
+
try {
|
|
172
|
+
const entry = JSON.parse(line);
|
|
173
|
+
db.run(`INSERT OR IGNORE INTO hook_events
|
|
174
|
+
(id, timestamp, session_id, hook_name, event_type, tool_name, tool_input, project_dir)
|
|
175
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
|
|
176
|
+
nanoid(),
|
|
177
|
+
entry.timestamp ?? new Date().toISOString(),
|
|
178
|
+
entry.session_id ?? "legacy",
|
|
179
|
+
"sessionlog",
|
|
180
|
+
"PostToolUse",
|
|
181
|
+
entry.tool_name ?? null,
|
|
182
|
+
entry.tool_input ? String(entry.tool_input).slice(0, 500) : null,
|
|
183
|
+
null
|
|
184
|
+
]);
|
|
185
|
+
count++;
|
|
186
|
+
} catch {}
|
|
187
|
+
}
|
|
188
|
+
} catch {}
|
|
189
|
+
return count;
|
|
190
|
+
}
|
|
191
|
+
function importErrorsLog(db, filePath) {
|
|
192
|
+
let count = 0;
|
|
193
|
+
try {
|
|
194
|
+
const lines = readFileSync(filePath, "utf-8").split(`
|
|
195
|
+
`).filter(Boolean);
|
|
196
|
+
const linePattern = /^\[(.+?)\]\s+(?:\[session:(\S+)\]\s+)?(.+?)\s+\u2014\s+(.+)$/;
|
|
197
|
+
for (const line of lines) {
|
|
198
|
+
try {
|
|
199
|
+
const m = line.match(linePattern);
|
|
200
|
+
if (!m)
|
|
201
|
+
continue;
|
|
202
|
+
const [, timestamp, sessionPrefix, , errorMsg] = m;
|
|
203
|
+
db.run(`INSERT OR IGNORE INTO hook_events
|
|
204
|
+
(id, timestamp, session_id, hook_name, event_type, error)
|
|
205
|
+
VALUES (?, ?, ?, ?, ?, ?)`, [
|
|
206
|
+
nanoid(),
|
|
207
|
+
timestamp,
|
|
208
|
+
sessionPrefix ? `legacy-${sessionPrefix}` : "legacy",
|
|
209
|
+
"errornotify",
|
|
210
|
+
"PostToolUse",
|
|
211
|
+
errorMsg.slice(0, 500)
|
|
212
|
+
]);
|
|
213
|
+
count++;
|
|
214
|
+
} catch {}
|
|
215
|
+
}
|
|
216
|
+
} catch {}
|
|
217
|
+
return count;
|
|
218
|
+
}
|
|
219
|
+
function runLegacyImport(db) {
|
|
220
|
+
try {
|
|
221
|
+
if (isAlreadyDone(db))
|
|
222
|
+
return;
|
|
223
|
+
let total = 0;
|
|
224
|
+
const claudeProjectsDir = join(homedir(), ".claude", "projects");
|
|
225
|
+
if (existsSync(claudeProjectsDir)) {
|
|
226
|
+
try {
|
|
227
|
+
const projectDirs = readdirSync(claudeProjectsDir);
|
|
228
|
+
for (const dir of projectDirs) {
|
|
229
|
+
const projectDir = join(claudeProjectsDir, dir);
|
|
230
|
+
try {
|
|
231
|
+
const files = readdirSync(projectDir);
|
|
232
|
+
for (const file of files) {
|
|
233
|
+
if (file.match(/^session-log-\d{4}-\d{2}-\d{2}\.jsonl$/)) {
|
|
234
|
+
total += importJsonlFile(db, join(projectDir, file));
|
|
235
|
+
}
|
|
236
|
+
if (file === "errors.log") {
|
|
237
|
+
total += importErrorsLog(db, join(projectDir, file));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
} catch {}
|
|
241
|
+
}
|
|
242
|
+
} catch {}
|
|
243
|
+
}
|
|
244
|
+
markDone(db);
|
|
245
|
+
if (total > 0) {
|
|
246
|
+
process.stderr.write(`[hooks] Imported ${total} legacy log entries into SQLite.
|
|
247
|
+
`);
|
|
248
|
+
}
|
|
249
|
+
} catch (err) {
|
|
250
|
+
process.stderr.write(`[hooks] Legacy import failed (non-fatal): ${err}
|
|
251
|
+
`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// src/db/retention.ts
|
|
256
|
+
function runRetention(db, days) {
|
|
257
|
+
const envDays = parseInt(process.env.HOOKS_RETENTION_DAYS ?? "30");
|
|
258
|
+
const retentionDays = days ?? (isNaN(envDays) || envDays <= 0 ? 30 : envDays);
|
|
259
|
+
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000).toISOString();
|
|
260
|
+
try {
|
|
261
|
+
db.run("DELETE FROM hook_events WHERE timestamp < ?", [cutoff]);
|
|
262
|
+
const changes = db.query("SELECT changes() as changes").get()?.changes ?? 0;
|
|
263
|
+
return changes;
|
|
264
|
+
} catch {
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/db/index.ts
|
|
270
|
+
var instance = null;
|
|
271
|
+
function resolveDataDir() {
|
|
272
|
+
const explicit = process.env.HASNA_HOOKS_DATA_DIR ?? process.env.HOOKS_DATA_DIR;
|
|
273
|
+
if (explicit)
|
|
274
|
+
return explicit;
|
|
275
|
+
const newDir = join2(homedir2(), ".hasna", "hooks");
|
|
276
|
+
const oldDir = join2(homedir2(), ".hooks");
|
|
277
|
+
if (!existsSync2(newDir) && existsSync2(oldDir)) {
|
|
278
|
+
mkdirSync(join2(homedir2(), ".hasna"), { recursive: true });
|
|
279
|
+
cpSync(oldDir, newDir, { recursive: true });
|
|
280
|
+
}
|
|
281
|
+
return newDir;
|
|
282
|
+
}
|
|
283
|
+
function getDbPath() {
|
|
284
|
+
const explicitDb = process.env.HASNA_HOOKS_DB_PATH ?? process.env.HOOKS_DB_PATH;
|
|
285
|
+
if (explicitDb)
|
|
286
|
+
return explicitDb;
|
|
287
|
+
const dataDir = resolveDataDir();
|
|
288
|
+
return join2(dataDir, "hooks.db");
|
|
289
|
+
}
|
|
290
|
+
function ensureDir(dbPath) {
|
|
291
|
+
const dir = dbPath.substring(0, dbPath.lastIndexOf("/"));
|
|
292
|
+
if (dir && !existsSync2(dir)) {
|
|
293
|
+
mkdirSync(dir, { recursive: true });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
function getDb() {
|
|
297
|
+
if (instance)
|
|
298
|
+
return instance;
|
|
299
|
+
const dbPath = getDbPath();
|
|
300
|
+
const isNew = dbPath === ":memory:" || !existsSync2(dbPath);
|
|
301
|
+
ensureDir(dbPath);
|
|
302
|
+
instance = new Database(dbPath);
|
|
303
|
+
instance.exec("PRAGMA journal_mode=WAL");
|
|
304
|
+
instance.exec("PRAGMA foreign_keys=ON");
|
|
305
|
+
runMigrations(instance);
|
|
306
|
+
runRetention(instance);
|
|
307
|
+
instance.exec(`CREATE TABLE IF NOT EXISTS feedback (
|
|
308
|
+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
|
|
309
|
+
message TEXT NOT NULL,
|
|
310
|
+
email TEXT,
|
|
311
|
+
category TEXT DEFAULT 'general',
|
|
312
|
+
version TEXT,
|
|
313
|
+
machine_id TEXT,
|
|
314
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
315
|
+
)`);
|
|
316
|
+
if (isNew) {
|
|
317
|
+
runLegacyImport(instance);
|
|
318
|
+
}
|
|
319
|
+
return instance;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// src/db/pg-migrations.ts
|
|
323
|
+
var PG_MIGRATIONS = [
|
|
324
|
+
`CREATE TABLE IF NOT EXISTS hook_events (
|
|
325
|
+
id TEXT PRIMARY KEY,
|
|
326
|
+
timestamp TEXT NOT NULL,
|
|
327
|
+
session_id TEXT NOT NULL,
|
|
328
|
+
hook_name TEXT NOT NULL,
|
|
329
|
+
event_type TEXT NOT NULL CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit')),
|
|
330
|
+
tool_name TEXT,
|
|
331
|
+
tool_input TEXT,
|
|
332
|
+
result TEXT CHECK (result IN ('continue', 'block', NULL)),
|
|
333
|
+
error TEXT,
|
|
334
|
+
duration_ms INTEGER,
|
|
335
|
+
project_dir TEXT,
|
|
336
|
+
metadata TEXT
|
|
337
|
+
)`,
|
|
338
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_timestamp ON hook_events (timestamp)`,
|
|
339
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_session_id ON hook_events (session_id)`,
|
|
340
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_hook_name ON hook_events (hook_name)`,
|
|
341
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_event_type ON hook_events (event_type)`,
|
|
342
|
+
`CREATE INDEX IF NOT EXISTS idx_hook_events_errors ON hook_events (timestamp) WHERE error IS NOT NULL`,
|
|
343
|
+
`CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
344
|
+
version TEXT PRIMARY KEY,
|
|
345
|
+
applied_at TEXT NOT NULL
|
|
346
|
+
)`,
|
|
347
|
+
`CREATE TABLE IF NOT EXISTS _meta (
|
|
348
|
+
key TEXT PRIMARY KEY,
|
|
349
|
+
value TEXT NOT NULL
|
|
350
|
+
)`,
|
|
351
|
+
`CREATE TABLE IF NOT EXISTS feedback (
|
|
352
|
+
id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::text,
|
|
353
|
+
message TEXT NOT NULL,
|
|
354
|
+
email TEXT,
|
|
355
|
+
category TEXT DEFAULT 'general',
|
|
356
|
+
version TEXT,
|
|
357
|
+
machine_id TEXT,
|
|
358
|
+
created_at TEXT NOT NULL DEFAULT NOW()::text
|
|
359
|
+
)`,
|
|
360
|
+
`ALTER TABLE hook_events DROP CONSTRAINT IF EXISTS hook_events_event_type_check`,
|
|
361
|
+
`ALTER TABLE hook_events ADD CONSTRAINT hook_events_event_type_check
|
|
362
|
+
CHECK (event_type IN ('PreToolUse', 'PostToolUse', 'Stop', 'Notification', 'SessionStart', 'SessionEnd', 'UserPromptSubmit'))`
|
|
363
|
+
];
|
|
364
|
+
|
|
365
|
+
// src/db/remote-storage.ts
|
|
366
|
+
import pg from "pg";
|
|
367
|
+
function translatePlaceholders(sql) {
|
|
368
|
+
let index = 0;
|
|
369
|
+
return sql.replace(/\?/g, () => `$${++index}`);
|
|
370
|
+
}
|
|
371
|
+
function normalizeParams(params) {
|
|
372
|
+
const flat = params.length === 1 && Array.isArray(params[0]) ? params[0] : params;
|
|
373
|
+
return flat.map((value) => value === undefined ? null : value);
|
|
374
|
+
}
|
|
375
|
+
function sslConfigFor(connectionString) {
|
|
376
|
+
return connectionString.includes("sslmode=require") || connectionString.includes("ssl=true") ? { rejectUnauthorized: false } : undefined;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
class PgAdapterAsync {
|
|
380
|
+
pool;
|
|
381
|
+
constructor(connectionString) {
|
|
382
|
+
this.pool = new pg.Pool({ connectionString, ssl: sslConfigFor(connectionString) });
|
|
383
|
+
}
|
|
384
|
+
async run(sql, ...params) {
|
|
385
|
+
const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
|
|
386
|
+
return { changes: result.rowCount ?? 0 };
|
|
387
|
+
}
|
|
388
|
+
async all(sql, ...params) {
|
|
389
|
+
const result = await this.pool.query(translatePlaceholders(sql), normalizeParams(params));
|
|
390
|
+
return result.rows;
|
|
391
|
+
}
|
|
392
|
+
async close() {
|
|
393
|
+
await this.pool.end();
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// src/db/storage-sync.ts
|
|
398
|
+
var STORAGE_TABLES = [
|
|
399
|
+
"hook_events",
|
|
400
|
+
"schema_migrations",
|
|
401
|
+
"_meta",
|
|
402
|
+
"feedback"
|
|
403
|
+
];
|
|
404
|
+
var HOOKS_STORAGE_TABLES = STORAGE_TABLES;
|
|
405
|
+
var HOOKS_STORAGE_ENV = "HASNA_HOOKS_DATABASE_URL";
|
|
406
|
+
var HOOKS_STORAGE_FALLBACK_ENV = "HOOKS_DATABASE_URL";
|
|
407
|
+
var HOOKS_STORAGE_MODE_ENV = "HASNA_HOOKS_STORAGE_MODE";
|
|
408
|
+
var HOOKS_STORAGE_MODE_FALLBACK_ENV = "HOOKS_STORAGE_MODE";
|
|
409
|
+
var STORAGE_DATABASE_ENV = [HOOKS_STORAGE_ENV, HOOKS_STORAGE_FALLBACK_ENV];
|
|
410
|
+
var STORAGE_MODE_ENV = [HOOKS_STORAGE_MODE_ENV, HOOKS_STORAGE_MODE_FALLBACK_ENV];
|
|
411
|
+
var PRIMARY_KEYS = {
|
|
412
|
+
hook_events: ["id"],
|
|
413
|
+
schema_migrations: ["version"],
|
|
414
|
+
_meta: ["key"],
|
|
415
|
+
feedback: ["id"]
|
|
416
|
+
};
|
|
417
|
+
function readEnv(name) {
|
|
418
|
+
const value = process.env[name]?.trim();
|
|
419
|
+
return value || undefined;
|
|
420
|
+
}
|
|
421
|
+
function normalizeStorageMode(value) {
|
|
422
|
+
const normalized = value?.trim().toLowerCase();
|
|
423
|
+
if (normalized === "local" || normalized === "hybrid" || normalized === "remote")
|
|
424
|
+
return normalized;
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
function getStorageDatabaseEnvName() {
|
|
428
|
+
for (const name of STORAGE_DATABASE_ENV) {
|
|
429
|
+
if (readEnv(name))
|
|
430
|
+
return name;
|
|
431
|
+
}
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
function getStorageDatabaseEnv() {
|
|
435
|
+
const name = getStorageDatabaseEnvName();
|
|
436
|
+
return name ? { name } : null;
|
|
437
|
+
}
|
|
438
|
+
function getStorageDatabaseUrl() {
|
|
439
|
+
const env = getStorageDatabaseEnv();
|
|
440
|
+
return env ? readEnv(env.name) ?? null : null;
|
|
441
|
+
}
|
|
442
|
+
function getStorageMode() {
|
|
443
|
+
const mode = normalizeStorageMode(readEnv(HOOKS_STORAGE_MODE_ENV)) ?? normalizeStorageMode(readEnv(HOOKS_STORAGE_MODE_FALLBACK_ENV));
|
|
444
|
+
if (mode)
|
|
445
|
+
return mode;
|
|
446
|
+
return getStorageDatabaseUrl() ? "hybrid" : "local";
|
|
447
|
+
}
|
|
448
|
+
async function getStoragePg() {
|
|
449
|
+
const url = getStorageDatabaseUrl();
|
|
450
|
+
if (!url) {
|
|
451
|
+
throw new Error("Missing HASNA_HOOKS_DATABASE_URL or HOOKS_DATABASE_URL");
|
|
452
|
+
}
|
|
453
|
+
return new PgAdapterAsync(url);
|
|
454
|
+
}
|
|
455
|
+
async function runStorageMigrations(remote) {
|
|
456
|
+
await remote.run("CREATE EXTENSION IF NOT EXISTS pgcrypto");
|
|
457
|
+
for (const sql of PG_MIGRATIONS)
|
|
458
|
+
await remote.run(sql);
|
|
459
|
+
}
|
|
460
|
+
async function storagePush(options) {
|
|
461
|
+
const remote = await getStoragePg();
|
|
462
|
+
const db = getDb();
|
|
463
|
+
try {
|
|
464
|
+
await runStorageMigrations(remote);
|
|
465
|
+
const results = [];
|
|
466
|
+
for (const table of resolveTables(options?.tables)) {
|
|
467
|
+
results.push(await pushTable(db, remote, table));
|
|
468
|
+
}
|
|
469
|
+
recordSyncMeta(db, "push", results);
|
|
470
|
+
return results;
|
|
471
|
+
} finally {
|
|
472
|
+
await remote.close();
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
async function storagePull(options) {
|
|
476
|
+
const remote = await getStoragePg();
|
|
477
|
+
const db = getDb();
|
|
478
|
+
try {
|
|
479
|
+
await runStorageMigrations(remote);
|
|
480
|
+
const results = [];
|
|
481
|
+
for (const table of resolveTables(options?.tables)) {
|
|
482
|
+
results.push(await pullTable(remote, db, table));
|
|
483
|
+
}
|
|
484
|
+
recordSyncMeta(db, "pull", results);
|
|
485
|
+
return results;
|
|
486
|
+
} finally {
|
|
487
|
+
await remote.close();
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
async function storageSync(options) {
|
|
491
|
+
const pull = await storagePull(options);
|
|
492
|
+
const push = await storagePush(options);
|
|
493
|
+
return { pull, push };
|
|
494
|
+
}
|
|
495
|
+
function getSyncMetaAll() {
|
|
496
|
+
const db = getDb();
|
|
497
|
+
ensureSyncMetaTable(db);
|
|
498
|
+
return db.query("SELECT table_name, last_synced_at, direction FROM _hooks_sync_meta ORDER BY table_name, direction").all();
|
|
499
|
+
}
|
|
500
|
+
function getStorageStatus() {
|
|
501
|
+
const activeEnv = getStorageDatabaseEnv();
|
|
502
|
+
return {
|
|
503
|
+
configured: Boolean(activeEnv),
|
|
504
|
+
mode: getStorageMode(),
|
|
505
|
+
env: STORAGE_DATABASE_ENV,
|
|
506
|
+
activeEnv: activeEnv?.name ?? null,
|
|
507
|
+
service: "hooks",
|
|
508
|
+
tables: STORAGE_TABLES,
|
|
509
|
+
sync: getSyncMetaAll()
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
function resolveTables(tables) {
|
|
513
|
+
if (!tables || tables.length === 0)
|
|
514
|
+
return [...STORAGE_TABLES];
|
|
515
|
+
const allowed = new Set(STORAGE_TABLES);
|
|
516
|
+
const requested = tables.map((table) => table.trim()).filter(Boolean);
|
|
517
|
+
const invalid = requested.filter((table) => !allowed.has(table));
|
|
518
|
+
if (invalid.length > 0)
|
|
519
|
+
throw new Error(`Unknown hooks sync table(s): ${invalid.join(", ")}`);
|
|
520
|
+
return requested;
|
|
521
|
+
}
|
|
522
|
+
function parseStorageTables(value) {
|
|
523
|
+
if (!value)
|
|
524
|
+
return;
|
|
525
|
+
return resolveTables(Array.isArray(value) ? value : value.split(","));
|
|
526
|
+
}
|
|
527
|
+
async function pushTable(db, remote, table) {
|
|
528
|
+
const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
|
|
529
|
+
try {
|
|
530
|
+
if (!tableExists(db, table))
|
|
531
|
+
return result;
|
|
532
|
+
const rows = db.query(`SELECT * FROM ${quoteIdent(table)}`).all();
|
|
533
|
+
result.rowsRead = rows.length;
|
|
534
|
+
if (rows.length === 0)
|
|
535
|
+
return result;
|
|
536
|
+
const remoteColumns = await getRemoteColumns(remote, table);
|
|
537
|
+
const columns = filterRemoteColumns(remoteColumns, Object.keys(rows[0]));
|
|
538
|
+
result.rowsWritten = await upsertPg(remote, table, columns, rows, remoteColumns);
|
|
539
|
+
} catch (error) {
|
|
540
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
541
|
+
}
|
|
542
|
+
return result;
|
|
543
|
+
}
|
|
544
|
+
async function pullTable(remote, db, table) {
|
|
545
|
+
const result = { table, rowsRead: 0, rowsWritten: 0, errors: [] };
|
|
546
|
+
try {
|
|
547
|
+
if (!tableExists(db, table))
|
|
548
|
+
return result;
|
|
549
|
+
const rows = await remote.all(`SELECT * FROM ${quoteIdent(table)}`);
|
|
550
|
+
result.rowsRead = rows.length;
|
|
551
|
+
if (rows.length === 0)
|
|
552
|
+
return result;
|
|
553
|
+
const columns = filterLocalColumns(db, table, Object.keys(rows[0]));
|
|
554
|
+
result.rowsWritten = upsertSqlite(db, table, columns, rows);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
result.errors.push(error instanceof Error ? error.message : String(error));
|
|
557
|
+
}
|
|
558
|
+
return result;
|
|
559
|
+
}
|
|
560
|
+
async function getRemoteColumns(remote, table) {
|
|
561
|
+
const rows = await remote.all("SELECT column_name, data_type FROM information_schema.columns WHERE table_schema = 'public' AND table_name = ?", table);
|
|
562
|
+
return new Map(rows.map((row) => [row.column_name, row.data_type]));
|
|
563
|
+
}
|
|
564
|
+
function filterRemoteColumns(remoteColumns, columns) {
|
|
565
|
+
if (remoteColumns.size === 0)
|
|
566
|
+
return columns;
|
|
567
|
+
return columns.filter((column) => remoteColumns.has(column));
|
|
568
|
+
}
|
|
569
|
+
function filterLocalColumns(db, table, columns) {
|
|
570
|
+
const rows = db.query(`PRAGMA table_info(${quoteIdent(table)})`).all();
|
|
571
|
+
const allowed = new Set(rows.map((row) => row.name));
|
|
572
|
+
return columns.filter((column) => allowed.has(column));
|
|
573
|
+
}
|
|
574
|
+
async function upsertPg(remote, table, columns, rows, remoteColumns) {
|
|
575
|
+
if (columns.length === 0)
|
|
576
|
+
return 0;
|
|
577
|
+
const primaryKeys = PRIMARY_KEYS[table];
|
|
578
|
+
const columnList = columns.map(quoteIdent).join(", ");
|
|
579
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
580
|
+
const keyList = primaryKeys.map(quoteIdent).join(", ");
|
|
581
|
+
const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
|
|
582
|
+
const fallbackKey = primaryKeys[0];
|
|
583
|
+
const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = EXCLUDED.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = EXCLUDED.${quoteIdent(fallbackKey)}`;
|
|
584
|
+
for (const row of rows) {
|
|
585
|
+
await remote.run(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
|
|
586
|
+
ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`, ...columns.map((column) => coerceForPg(row[column], remoteColumns.get(column))));
|
|
587
|
+
}
|
|
588
|
+
return rows.length;
|
|
589
|
+
}
|
|
590
|
+
function upsertSqlite(db, table, columns, rows) {
|
|
591
|
+
if (columns.length === 0)
|
|
592
|
+
return 0;
|
|
593
|
+
const primaryKeys = PRIMARY_KEYS[table];
|
|
594
|
+
const columnList = columns.map(quoteIdent).join(", ");
|
|
595
|
+
const placeholders = columns.map(() => "?").join(", ");
|
|
596
|
+
const keyList = primaryKeys.map(quoteIdent).join(", ");
|
|
597
|
+
const updateColumns = columns.filter((column) => !primaryKeys.includes(column));
|
|
598
|
+
const fallbackKey = primaryKeys[0];
|
|
599
|
+
const setClause = updateColumns.length > 0 ? updateColumns.map((column) => `${quoteIdent(column)} = excluded.${quoteIdent(column)}`).join(", ") : `${quoteIdent(fallbackKey)} = excluded.${quoteIdent(fallbackKey)}`;
|
|
600
|
+
const statement = db.query(`INSERT INTO ${quoteIdent(table)} (${columnList}) VALUES (${placeholders})
|
|
601
|
+
ON CONFLICT (${keyList}) DO UPDATE SET ${setClause}`);
|
|
602
|
+
const insert = db.transaction((batch) => {
|
|
603
|
+
for (const row of batch)
|
|
604
|
+
statement.run(...columns.map((column) => coerceForSqlite(row[column])));
|
|
605
|
+
});
|
|
606
|
+
insert(rows);
|
|
607
|
+
return rows.length;
|
|
608
|
+
}
|
|
609
|
+
function recordSyncMeta(db, direction, results) {
|
|
610
|
+
ensureSyncMetaTable(db);
|
|
611
|
+
const now = new Date().toISOString();
|
|
612
|
+
const statement = db.query(`
|
|
613
|
+
INSERT INTO _hooks_sync_meta (table_name, last_synced_at, direction)
|
|
614
|
+
VALUES (?, ?, ?)
|
|
615
|
+
ON CONFLICT(table_name, direction) DO UPDATE SET last_synced_at = excluded.last_synced_at
|
|
616
|
+
`);
|
|
617
|
+
for (const result of results) {
|
|
618
|
+
if (result.errors.length > 0)
|
|
619
|
+
continue;
|
|
620
|
+
statement.run(result.table, now, direction);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
function ensureSyncMetaTable(db) {
|
|
624
|
+
db.exec(`
|
|
625
|
+
CREATE TABLE IF NOT EXISTS _hooks_sync_meta (
|
|
626
|
+
table_name TEXT NOT NULL,
|
|
627
|
+
last_synced_at TEXT,
|
|
628
|
+
direction TEXT NOT NULL CHECK(direction IN ('push', 'pull')),
|
|
629
|
+
PRIMARY KEY (table_name, direction)
|
|
630
|
+
)
|
|
631
|
+
`);
|
|
632
|
+
}
|
|
633
|
+
function tableExists(db, table) {
|
|
634
|
+
const row = db.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(table);
|
|
635
|
+
return Boolean(row);
|
|
636
|
+
}
|
|
637
|
+
function quoteIdent(identifier) {
|
|
638
|
+
return `"${identifier.replace(/"/g, '""')}"`;
|
|
639
|
+
}
|
|
640
|
+
function coerceForPg(value, dataType) {
|
|
641
|
+
if (value === undefined || value === null)
|
|
642
|
+
return null;
|
|
643
|
+
if (dataType === "boolean") {
|
|
644
|
+
if (typeof value === "boolean")
|
|
645
|
+
return value;
|
|
646
|
+
if (typeof value === "number")
|
|
647
|
+
return value !== 0;
|
|
648
|
+
if (typeof value === "string")
|
|
649
|
+
return value === "1" || value.toLowerCase() === "true";
|
|
650
|
+
}
|
|
651
|
+
if (value instanceof Date)
|
|
652
|
+
return value.toISOString();
|
|
653
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array)
|
|
654
|
+
return value;
|
|
655
|
+
if (typeof value === "object")
|
|
656
|
+
return JSON.stringify(value);
|
|
657
|
+
return value;
|
|
658
|
+
}
|
|
659
|
+
function coerceForSqlite(value) {
|
|
660
|
+
if (value === undefined || value === null)
|
|
661
|
+
return null;
|
|
662
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || typeof value === "boolean")
|
|
663
|
+
return value;
|
|
664
|
+
if (value instanceof Date)
|
|
665
|
+
return value.toISOString();
|
|
666
|
+
if (Buffer.isBuffer(value) || value instanceof Uint8Array)
|
|
667
|
+
return value;
|
|
668
|
+
if (typeof value === "object")
|
|
669
|
+
return JSON.stringify(value);
|
|
670
|
+
return String(value);
|
|
671
|
+
}
|
|
672
|
+
export {
|
|
673
|
+
storageSync,
|
|
674
|
+
storagePush,
|
|
675
|
+
storagePull,
|
|
676
|
+
runStorageMigrations,
|
|
677
|
+
resolveTables,
|
|
678
|
+
parseStorageTables,
|
|
679
|
+
getSyncMetaAll,
|
|
680
|
+
getStorageStatus,
|
|
681
|
+
getStoragePg,
|
|
682
|
+
getStorageMode,
|
|
683
|
+
getStorageDatabaseUrl,
|
|
684
|
+
getStorageDatabaseEnvName,
|
|
685
|
+
getStorageDatabaseEnv,
|
|
686
|
+
STORAGE_TABLES,
|
|
687
|
+
STORAGE_MODE_ENV,
|
|
688
|
+
STORAGE_DATABASE_ENV,
|
|
689
|
+
PgAdapterAsync,
|
|
690
|
+
PG_MIGRATIONS,
|
|
691
|
+
HOOKS_STORAGE_TABLES,
|
|
692
|
+
HOOKS_STORAGE_MODE_FALLBACK_ENV,
|
|
693
|
+
HOOKS_STORAGE_MODE_ENV,
|
|
694
|
+
HOOKS_STORAGE_FALLBACK_ENV,
|
|
695
|
+
HOOKS_STORAGE_ENV
|
|
696
|
+
};
|