archer-wizard 0.1.1 → 0.2.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/dist/daemon/client.d.ts +4 -0
- package/dist/daemon/client.d.ts.map +1 -0
- package/dist/daemon/client.js +44 -0
- package/dist/daemon/client.js.map +1 -0
- package/dist/daemon/lifecycle.d.ts +9 -0
- package/dist/daemon/lifecycle.d.ts.map +1 -0
- package/dist/daemon/lifecycle.js +118 -0
- package/dist/daemon/lifecycle.js.map +1 -0
- package/dist/daemon/process.d.ts +2 -0
- package/dist/daemon/process.d.ts.map +1 -0
- package/dist/daemon/process.js +171 -0
- package/dist/daemon/process.js.map +1 -0
- package/dist/daemon/run.d.ts +2 -0
- package/dist/daemon/run.d.ts.map +1 -0
- package/dist/daemon/run.js +5 -0
- package/dist/daemon/run.js.map +1 -0
- package/dist/daemon/store.d.ts +9 -0
- package/dist/daemon/store.d.ts.map +1 -0
- package/dist/daemon/store.js +53 -0
- package/dist/daemon/store.js.map +1 -0
- package/dist/daemon/types.d.ts +47 -0
- package/dist/daemon/types.d.ts.map +1 -0
- package/dist/daemon/types.js +10 -0
- package/dist/daemon/types.js.map +1 -0
- package/dist/index.js +105 -21
- package/dist/index.js.map +1 -1
- package/dist/lib/ascii.d.ts.map +1 -1
- package/dist/lib/ascii.js +9 -41
- package/dist/lib/ascii.js.map +1 -1
- package/dist/tools/unwatch.d.ts +7 -0
- package/dist/tools/unwatch.d.ts.map +1 -0
- package/dist/tools/unwatch.js +20 -0
- package/dist/tools/unwatch.js.map +1 -0
- package/dist/tools/watch.d.ts +14 -34
- package/dist/tools/watch.d.ts.map +1 -1
- package/dist/tools/watch.js +36 -170
- package/dist/tools/watch.js.map +1 -1
- package/dist/tools/watches.d.ts +2 -0
- package/dist/tools/watches.d.ts.map +1 -0
- package/dist/tools/watches.js +33 -0
- package/dist/tools/watches.js.map +1 -0
- package/package.json +2 -1
- package/src/daemon/client.ts +50 -0
- package/src/daemon/lifecycle.ts +126 -0
- package/src/daemon/process.ts +207 -0
- package/src/daemon/run.ts +6 -0
- package/src/daemon/store.ts +60 -0
- package/src/daemon/types.ts +41 -0
- package/src/index.ts +111 -22
- package/src/lib/ascii.ts +9 -44
- package/src/tools/unwatch.ts +26 -0
- package/src/tools/watch.ts +46 -212
- package/src/tools/watches.ts +37 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { sendCommand } from '../daemon/client.js';
|
|
2
|
+
// ─── Execute List Watches (via daemon IPC) ──────────────────
|
|
3
|
+
export async function executeListWatches() {
|
|
4
|
+
try {
|
|
5
|
+
const res = await sendCommand({ type: 'list_watches' });
|
|
6
|
+
if (!res.ok) {
|
|
7
|
+
return `❌ Failed to list watches: ${res.error}`;
|
|
8
|
+
}
|
|
9
|
+
if (res.type !== 'watch_list') {
|
|
10
|
+
return `❌ Unexpected response type: ${res.type}`;
|
|
11
|
+
}
|
|
12
|
+
if (res.watches.length === 0) {
|
|
13
|
+
return '📭 No active watches.';
|
|
14
|
+
}
|
|
15
|
+
const lines = [`📋 Active watches (${res.watches.length}):\n`];
|
|
16
|
+
for (const w of res.watches) {
|
|
17
|
+
lines.push(` 🔹 ${w.id}`);
|
|
18
|
+
lines.push(` table: ${w.table}`);
|
|
19
|
+
lines.push(` event: ${w.event}`);
|
|
20
|
+
if (w.filter)
|
|
21
|
+
lines.push(` filter: ${w.filter}`);
|
|
22
|
+
if (w.webhookUrl)
|
|
23
|
+
lines.push(` webhook: ${w.webhookUrl}`);
|
|
24
|
+
lines.push(` created: ${w.createdAt}`);
|
|
25
|
+
lines.push('');
|
|
26
|
+
}
|
|
27
|
+
return lines.join('\n');
|
|
28
|
+
}
|
|
29
|
+
catch (err) {
|
|
30
|
+
return `❌ Daemon error: ${err instanceof Error ? err.message : String(err)}\n\nIs the archer daemon running?`;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
//# sourceMappingURL=watches.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"watches.js","sourceRoot":"","sources":["../../src/tools/watches.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAElD,+DAA+D;AAE/D,MAAM,CAAC,KAAK,UAAU,kBAAkB;IACtC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,WAAW,CAAC,EAAE,IAAI,EAAE,cAAc,EAAE,CAAC,CAAC;QAExD,IAAI,CAAC,GAAG,CAAC,EAAE,EAAE,CAAC;YACZ,OAAO,6BAA6B,GAAG,CAAC,KAAK,EAAE,CAAC;QAClD,CAAC;QAED,IAAI,GAAG,CAAC,IAAI,KAAK,YAAY,EAAE,CAAC;YAC9B,OAAO,+BAA+B,GAAG,CAAC,IAAI,EAAE,CAAC;QACnD,CAAC;QAED,IAAI,GAAG,CAAC,OAAO,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;YAC7B,OAAO,uBAAuB,CAAC;QACjC,CAAC;QAED,MAAM,KAAK,GAAG,CAAC,sBAAsB,GAAG,CAAC,OAAO,CAAC,MAAM,MAAM,CAAC,CAAC;QAE/D,KAAK,MAAM,CAAC,IAAI,GAAG,CAAC,OAAO,EAAE,CAAC;YAC5B,KAAK,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;YAC3B,KAAK,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC;YACrC,KAAK,CAAC,IAAI,CAAC,eAAe,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC;YACrC,IAAI,CAAC,CAAC,MAAM;gBAAE,KAAK,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC;YACrD,IAAI,CAAC,CAAC,UAAU;gBAAE,KAAK,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC,UAAU,EAAE,CAAC,CAAC;YAC9D,KAAK,CAAC,IAAI,CAAC,iBAAiB,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC;YAC3C,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;QACjB,CAAC;QAED,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC1B,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,OAAO,mBAAmB,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,mCAAmC,CAAC;IAChH,CAAC;AACH,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "archer-wizard",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "event intelligence layer for AI agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"build": "tsc",
|
|
12
12
|
"dev": "tsx src/index.ts",
|
|
13
13
|
"dev:mcp": "tsx src/index.ts --mcp",
|
|
14
|
+
"dev:daemon": "tsx src/index.ts --daemon",
|
|
14
15
|
"prepublishOnly": "npm run build"
|
|
15
16
|
},
|
|
16
17
|
"dependencies": {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import { DAEMON_PORT, DAEMON_HOST } from './types.js';
|
|
3
|
+
import type { IpcRequest, IpcResponse } from './types.js';
|
|
4
|
+
|
|
5
|
+
// ─── Send Command to Daemon ─────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export function sendCommand(req: IpcRequest): Promise<IpcResponse> {
|
|
8
|
+
return new Promise((resolve, reject) => {
|
|
9
|
+
const socket = net.createConnection({ host: DAEMON_HOST, port: DAEMON_PORT }, () => {
|
|
10
|
+
socket.write(JSON.stringify(req) + '\n');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
let buffer = '';
|
|
14
|
+
|
|
15
|
+
socket.on('data', (data) => {
|
|
16
|
+
buffer += data.toString();
|
|
17
|
+
const newlineIdx = buffer.indexOf('\n');
|
|
18
|
+
if (newlineIdx !== -1) {
|
|
19
|
+
const line = buffer.slice(0, newlineIdx).trim();
|
|
20
|
+
socket.end();
|
|
21
|
+
try {
|
|
22
|
+
resolve(JSON.parse(line) as IpcResponse);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
reject(new Error(`invalid daemon response: ${line}`));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
socket.on('error', (err) => {
|
|
30
|
+
reject(new Error(`daemon connection failed: ${err.message}`));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Timeout after 5 seconds
|
|
34
|
+
socket.setTimeout(5_000, () => {
|
|
35
|
+
socket.destroy();
|
|
36
|
+
reject(new Error('daemon response timeout'));
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Check if Daemon is Running ─────────────────────────────
|
|
42
|
+
|
|
43
|
+
export async function isDaemonRunning(): Promise<boolean> {
|
|
44
|
+
try {
|
|
45
|
+
const res = await sendCommand({ type: 'ping' });
|
|
46
|
+
return res.ok && res.type === 'pong';
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { PID_FILE, ARCHER_DIR, LOG_FILE } from './types.js';
|
|
6
|
+
import { isDaemonRunning } from './client.js';
|
|
7
|
+
|
|
8
|
+
// ─── Start Daemon ───────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export async function startDaemon(): Promise<{ pid: number; alreadyRunning: boolean }> {
|
|
11
|
+
// Check if already running
|
|
12
|
+
if (await isDaemonRunning()) {
|
|
13
|
+
const pid = readPid();
|
|
14
|
+
return { pid: pid ?? 0, alreadyRunning: true };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Ensure directory exists
|
|
18
|
+
if (!fs.existsSync(ARCHER_DIR)) {
|
|
19
|
+
fs.mkdirSync(ARCHER_DIR, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Resolve the daemon entry script
|
|
23
|
+
// In compiled mode this is dist/daemon/process.js
|
|
24
|
+
// In dev mode with tsx this is src/daemon/process.ts
|
|
25
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
26
|
+
const thisDir = path.dirname(thisFile);
|
|
27
|
+
|
|
28
|
+
// Look for the daemon runner script
|
|
29
|
+
const daemonRunner = path.join(thisDir, 'run.js');
|
|
30
|
+
const daemonRunnerTs = path.join(thisDir, 'run.ts');
|
|
31
|
+
|
|
32
|
+
let cmd: string;
|
|
33
|
+
let args: string[];
|
|
34
|
+
|
|
35
|
+
if (fs.existsSync(daemonRunner)) {
|
|
36
|
+
// Compiled mode
|
|
37
|
+
cmd = process.execPath; // node
|
|
38
|
+
args = [daemonRunner];
|
|
39
|
+
} else if (fs.existsSync(daemonRunnerTs)) {
|
|
40
|
+
// Dev mode — use tsx
|
|
41
|
+
cmd = 'npx';
|
|
42
|
+
args = ['tsx', daemonRunnerTs];
|
|
43
|
+
} else {
|
|
44
|
+
// Fallback: assume we're in dist/ and run process.js directly
|
|
45
|
+
const processJs = path.join(thisDir, 'process.js');
|
|
46
|
+
cmd = process.execPath;
|
|
47
|
+
args = [processJs, '--run-daemon'];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Open log file for daemon output
|
|
51
|
+
const logFd = fs.openSync(LOG_FILE, 'a');
|
|
52
|
+
|
|
53
|
+
const child = spawn(cmd, args, {
|
|
54
|
+
detached: true,
|
|
55
|
+
stdio: ['ignore', logFd, logFd],
|
|
56
|
+
env: { ...process.env },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
child.unref();
|
|
60
|
+
fs.closeSync(logFd);
|
|
61
|
+
|
|
62
|
+
const pid = child.pid ?? 0;
|
|
63
|
+
|
|
64
|
+
// Wait briefly for daemon to start, then verify
|
|
65
|
+
await sleep(500);
|
|
66
|
+
|
|
67
|
+
const running = await isDaemonRunning();
|
|
68
|
+
if (!running) {
|
|
69
|
+
// Give it another moment
|
|
70
|
+
await sleep(1000);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { pid, alreadyRunning: false };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ─── Stop Daemon ────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
export async function stopDaemon(): Promise<boolean> {
|
|
79
|
+
const pid = readPid();
|
|
80
|
+
if (pid === null) return false;
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
process.kill(pid, 'SIGTERM');
|
|
84
|
+
// Wait for it to die
|
|
85
|
+
await sleep(500);
|
|
86
|
+
return true;
|
|
87
|
+
} catch {
|
|
88
|
+
// Process already dead — clean up PID file
|
|
89
|
+
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── Ensure Daemon is Running ───────────────────────────────
|
|
95
|
+
|
|
96
|
+
export async function ensureDaemon(): Promise<{ pid: number }> {
|
|
97
|
+
const result = await startDaemon();
|
|
98
|
+
return { pid: result.pid };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ─── Helpers ────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
function readPid(): number | null {
|
|
104
|
+
try {
|
|
105
|
+
if (!fs.existsSync(PID_FILE)) return null;
|
|
106
|
+
const raw = fs.readFileSync(PID_FILE, 'utf-8').trim();
|
|
107
|
+
const pid = parseInt(raw, 10);
|
|
108
|
+
if (isNaN(pid)) return null;
|
|
109
|
+
|
|
110
|
+
// Check if process is alive
|
|
111
|
+
try {
|
|
112
|
+
process.kill(pid, 0); // Signal 0 = existence check
|
|
113
|
+
return pid;
|
|
114
|
+
} catch {
|
|
115
|
+
// Process is dead — clean up stale PID
|
|
116
|
+
try { fs.unlinkSync(PID_FILE); } catch {}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function sleep(ms: number): Promise<void> {
|
|
125
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
126
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import { createClient, type RealtimeChannel, type SupabaseClient } from '@supabase/supabase-js';
|
|
3
|
+
import { DAEMON_PORT, DAEMON_HOST, PID_FILE, ARCHER_DIR, LOG_FILE } from './types.js';
|
|
4
|
+
import type { IpcRequest, IpcResponse, WatchConfig } from './types.js';
|
|
5
|
+
import { loadWatches, addWatch, removeWatch } from './store.js';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
|
|
8
|
+
// ─── Simple webhook delivery for daemon context ─────────────
|
|
9
|
+
|
|
10
|
+
async function deliverWebhook(url: string, payload: unknown): Promise<void> {
|
|
11
|
+
const res = await fetch(url, {
|
|
12
|
+
method: 'POST',
|
|
13
|
+
headers: {
|
|
14
|
+
'Content-Type': 'application/json',
|
|
15
|
+
'User-Agent': 'Archer/0.2.0',
|
|
16
|
+
},
|
|
17
|
+
body: JSON.stringify(payload),
|
|
18
|
+
});
|
|
19
|
+
if (!res.ok) {
|
|
20
|
+
throw new Error(`webhook ${res.status} ${res.statusText}`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ─── Active Channel Registry ────────────────────────────────
|
|
25
|
+
|
|
26
|
+
const channels = new Map<string, { client: SupabaseClient; channel: RealtimeChannel }>();
|
|
27
|
+
|
|
28
|
+
// ─── Subscribe to a Watch ───────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function subscribe(watch: WatchConfig): void {
|
|
31
|
+
// Skip if already subscribed
|
|
32
|
+
if (channels.has(watch.id)) return;
|
|
33
|
+
|
|
34
|
+
const client = createClient(watch.supabaseUrl, watch.supabaseKey);
|
|
35
|
+
|
|
36
|
+
const channelName = `daemon-${watch.table}-${watch.id}`;
|
|
37
|
+
const channel = client
|
|
38
|
+
.channel(channelName)
|
|
39
|
+
.on(
|
|
40
|
+
'postgres_changes',
|
|
41
|
+
{
|
|
42
|
+
event: watch.event,
|
|
43
|
+
schema: 'public',
|
|
44
|
+
table: watch.table,
|
|
45
|
+
...(watch.filter ? { filter: watch.filter } : {}),
|
|
46
|
+
},
|
|
47
|
+
async (payload) => {
|
|
48
|
+
log(`[event] ${watch.table} ${payload.eventType} → watch ${watch.id}`);
|
|
49
|
+
|
|
50
|
+
if (watch.webhookUrl) {
|
|
51
|
+
try {
|
|
52
|
+
await deliverWebhook(watch.webhookUrl, payload);
|
|
53
|
+
log(`[webhook] delivered to ${watch.webhookUrl}`);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
log(`[webhook] failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
.subscribe((status) => {
|
|
61
|
+
log(`[channel] ${channelName} → ${status}`);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
channels.set(watch.id, { client, channel });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── Unsubscribe from a Watch ───────────────────────────────
|
|
68
|
+
|
|
69
|
+
async function unsubscribe(watchId: string): Promise<void> {
|
|
70
|
+
const entry = channels.get(watchId);
|
|
71
|
+
if (!entry) return;
|
|
72
|
+
|
|
73
|
+
await entry.client.removeChannel(entry.channel);
|
|
74
|
+
channels.delete(watchId);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ─── Handle IPC Command ─────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
async function handleCommand(req: IpcRequest): Promise<IpcResponse> {
|
|
80
|
+
switch (req.type) {
|
|
81
|
+
case 'add_watch': {
|
|
82
|
+
const watches = addWatch(req.watch);
|
|
83
|
+
subscribe(req.watch);
|
|
84
|
+
return { ok: true, type: 'watch_added', watchId: req.watch.id };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
case 'remove_watch': {
|
|
88
|
+
const { watches, removed } = removeWatch(req.watchId);
|
|
89
|
+
if (!removed) {
|
|
90
|
+
return { ok: false, error: `watch ${req.watchId} not found` };
|
|
91
|
+
}
|
|
92
|
+
await unsubscribe(req.watchId);
|
|
93
|
+
return { ok: true, type: 'watch_removed', watchId: req.watchId };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
case 'list_watches': {
|
|
97
|
+
const watches = loadWatches();
|
|
98
|
+
return { ok: true, type: 'watch_list', watches };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
case 'ping': {
|
|
102
|
+
return { ok: true, type: 'pong' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
default:
|
|
106
|
+
return { ok: false, error: 'unknown command' };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── Logging ────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
function log(message: string): void {
|
|
113
|
+
const ts = new Date().toISOString();
|
|
114
|
+
const line = `[${ts}] ${message}\n`;
|
|
115
|
+
try {
|
|
116
|
+
fs.appendFileSync(LOG_FILE, line);
|
|
117
|
+
} catch {
|
|
118
|
+
// If we can't write to log, just continue
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Main: Start TCP Server ─────────────────────────────────
|
|
123
|
+
|
|
124
|
+
export function startDaemonProcess(): void {
|
|
125
|
+
// Ensure directory exists
|
|
126
|
+
if (!fs.existsSync(ARCHER_DIR)) {
|
|
127
|
+
fs.mkdirSync(ARCHER_DIR, { recursive: true });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Write PID file
|
|
131
|
+
fs.writeFileSync(PID_FILE, String(process.pid), 'utf-8');
|
|
132
|
+
|
|
133
|
+
log('daemon starting');
|
|
134
|
+
|
|
135
|
+
// Re-subscribe all persisted watches
|
|
136
|
+
const watches = loadWatches();
|
|
137
|
+
for (const watch of watches) {
|
|
138
|
+
subscribe(watch);
|
|
139
|
+
log(`[restore] re-subscribed watch ${watch.id} on ${watch.table}`);
|
|
140
|
+
}
|
|
141
|
+
log(`restored ${watches.length} watch(es)`);
|
|
142
|
+
|
|
143
|
+
// Create TCP server
|
|
144
|
+
const server = net.createServer((socket) => {
|
|
145
|
+
let buffer = '';
|
|
146
|
+
|
|
147
|
+
socket.on('data', async (data) => {
|
|
148
|
+
buffer += data.toString();
|
|
149
|
+
|
|
150
|
+
// Process complete JSON lines
|
|
151
|
+
let newlineIdx: number;
|
|
152
|
+
while ((newlineIdx = buffer.indexOf('\n')) !== -1) {
|
|
153
|
+
const line = buffer.slice(0, newlineIdx).trim();
|
|
154
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
155
|
+
|
|
156
|
+
if (!line) continue;
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const req = JSON.parse(line) as IpcRequest;
|
|
160
|
+
const res = await handleCommand(req);
|
|
161
|
+
socket.write(JSON.stringify(res) + '\n');
|
|
162
|
+
} catch (err) {
|
|
163
|
+
const errRes: IpcResponse = {
|
|
164
|
+
ok: false,
|
|
165
|
+
error: `parse error: ${err instanceof Error ? err.message : String(err)}`,
|
|
166
|
+
};
|
|
167
|
+
socket.write(JSON.stringify(errRes) + '\n');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
socket.on('error', (err) => {
|
|
173
|
+
log(`[socket] error: ${err.message}`);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
server.listen(DAEMON_PORT, DAEMON_HOST, () => {
|
|
178
|
+
log(`daemon listening on ${DAEMON_HOST}:${DAEMON_PORT} (pid ${process.pid})`);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
server.on('error', (err) => {
|
|
182
|
+
log(`[server] error: ${err.message}`);
|
|
183
|
+
process.exit(1);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Clean shutdown
|
|
187
|
+
const shutdown = () => {
|
|
188
|
+
log('daemon shutting down');
|
|
189
|
+
server.close();
|
|
190
|
+
|
|
191
|
+
// Unsubscribe all channels
|
|
192
|
+
for (const [id, entry] of channels) {
|
|
193
|
+
entry.client.removeChannel(entry.channel).catch(() => {});
|
|
194
|
+
}
|
|
195
|
+
channels.clear();
|
|
196
|
+
|
|
197
|
+
// Remove PID file
|
|
198
|
+
try {
|
|
199
|
+
fs.unlinkSync(PID_FILE);
|
|
200
|
+
} catch {}
|
|
201
|
+
|
|
202
|
+
process.exit(0);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
process.on('SIGTERM', shutdown);
|
|
206
|
+
process.on('SIGINT', shutdown);
|
|
207
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { ARCHER_DIR, STATE_FILE } from './types.js';
|
|
4
|
+
import type { WatchConfig } from './types.js';
|
|
5
|
+
|
|
6
|
+
// ─── Ensure Directory ───────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
function ensureDir(): void {
|
|
9
|
+
if (!fs.existsSync(ARCHER_DIR)) {
|
|
10
|
+
fs.mkdirSync(ARCHER_DIR, { recursive: true });
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// ─── Load Watches ───────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export function loadWatches(): WatchConfig[] {
|
|
17
|
+
try {
|
|
18
|
+
if (!fs.existsSync(STATE_FILE)) return [];
|
|
19
|
+
const raw = fs.readFileSync(STATE_FILE, 'utf-8');
|
|
20
|
+
const parsed = JSON.parse(raw);
|
|
21
|
+
if (!Array.isArray(parsed)) return [];
|
|
22
|
+
return parsed as WatchConfig[];
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ─── Save Watches (atomic write) ────────────────────────────
|
|
29
|
+
|
|
30
|
+
export function saveWatches(watches: WatchConfig[]): void {
|
|
31
|
+
ensureDir();
|
|
32
|
+
const tmp = STATE_FILE + '.tmp';
|
|
33
|
+
fs.writeFileSync(tmp, JSON.stringify(watches, null, 2) + '\n', 'utf-8');
|
|
34
|
+
fs.renameSync(tmp, STATE_FILE);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ─── Add Watch ──────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
export function addWatch(watch: WatchConfig): WatchConfig[] {
|
|
40
|
+
const watches = loadWatches();
|
|
41
|
+
// Replace if same ID exists
|
|
42
|
+
const idx = watches.findIndex((w) => w.id === watch.id);
|
|
43
|
+
if (idx >= 0) {
|
|
44
|
+
watches[idx] = watch;
|
|
45
|
+
} else {
|
|
46
|
+
watches.push(watch);
|
|
47
|
+
}
|
|
48
|
+
saveWatches(watches);
|
|
49
|
+
return watches;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─── Remove Watch ───────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
export function removeWatch(watchId: string): { watches: WatchConfig[]; removed: boolean } {
|
|
55
|
+
const watches = loadWatches();
|
|
56
|
+
const before = watches.length;
|
|
57
|
+
const filtered = watches.filter((w) => w.id !== watchId);
|
|
58
|
+
saveWatches(filtered);
|
|
59
|
+
return { watches: filtered, removed: filtered.length < before };
|
|
60
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// ─── Daemon Configuration ───────────────────────────────────
|
|
2
|
+
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
|
|
6
|
+
export const DAEMON_PORT = 7481;
|
|
7
|
+
export const DAEMON_HOST = '127.0.0.1';
|
|
8
|
+
export const ARCHER_DIR = path.join(os.homedir(), '.archer');
|
|
9
|
+
export const STATE_FILE = path.join(ARCHER_DIR, 'state.json');
|
|
10
|
+
export const PID_FILE = path.join(ARCHER_DIR, 'daemon.pid');
|
|
11
|
+
export const LOG_FILE = path.join(ARCHER_DIR, 'daemon.log');
|
|
12
|
+
|
|
13
|
+
// ─── Watch State ────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface WatchConfig {
|
|
16
|
+
id: string;
|
|
17
|
+
table: string;
|
|
18
|
+
event: 'INSERT' | 'UPDATE' | 'DELETE' | '*';
|
|
19
|
+
filter?: string;
|
|
20
|
+
webhookUrl?: string;
|
|
21
|
+
createdAt: string; // ISO timestamp
|
|
22
|
+
supabaseUrl: string; // needed to re-subscribe
|
|
23
|
+
supabaseKey: string; // service role key
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ─── IPC Protocol ───────────────────────────────────────────
|
|
27
|
+
// JSON lines over TCP. Each message is JSON + '\n'.
|
|
28
|
+
// Client sends a Request, daemon responds with a Response.
|
|
29
|
+
|
|
30
|
+
export type IpcRequest =
|
|
31
|
+
| { type: 'add_watch'; watch: WatchConfig }
|
|
32
|
+
| { type: 'remove_watch'; watchId: string }
|
|
33
|
+
| { type: 'list_watches' }
|
|
34
|
+
| { type: 'ping' };
|
|
35
|
+
|
|
36
|
+
export type IpcResponse =
|
|
37
|
+
| { ok: true; type: 'watch_added'; watchId: string }
|
|
38
|
+
| { ok: true; type: 'watch_removed'; watchId: string }
|
|
39
|
+
| { ok: true; type: 'watch_list'; watches: WatchConfig[] }
|
|
40
|
+
| { ok: true; type: 'pong' }
|
|
41
|
+
| { ok: false; error: string };
|