charon-hooks 0.1.7 → 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/bin/charon.js +471 -24
- package/config/config.yaml.dist +9 -0
- package/config/triggers.yaml.dist +50 -0
- package/dist/server/index.js +141 -14
- package/package.json +2 -1
package/bin/charon.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
import { resolve, dirname } from 'path';
|
|
4
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'fs';
|
|
5
|
-
import { homedir } from 'os';
|
|
4
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, copyFileSync, unlinkSync } from 'fs';
|
|
5
|
+
import { homedir, platform } from 'os';
|
|
6
6
|
import { fileURLToPath } from 'url';
|
|
7
|
+
import { spawn } from 'child_process';
|
|
8
|
+
import YAML from 'yaml';
|
|
7
9
|
|
|
8
10
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
9
11
|
|
|
@@ -18,16 +20,29 @@ Usage:
|
|
|
18
20
|
charon-hooks [options]
|
|
19
21
|
|
|
20
22
|
Options:
|
|
21
|
-
--
|
|
22
|
-
--
|
|
23
|
-
--
|
|
23
|
+
--help, -h Show this help
|
|
24
|
+
--version, -v Show version
|
|
25
|
+
--service <command> Service management (status|start|stop|install)
|
|
26
|
+
|
|
27
|
+
Service Commands:
|
|
28
|
+
--service status Check if Charon is running
|
|
29
|
+
--service start Start Charon in background
|
|
30
|
+
--service stop Stop background Charon process
|
|
31
|
+
--service install Generate system service files
|
|
32
|
+
|
|
33
|
+
Promise Commands (for Claude Code integration):
|
|
34
|
+
--wait <uuid> --trigger <id> Wait for a promise to be resolved
|
|
35
|
+
--resolve <uuid> --description <text> Resolve a pending promise
|
|
36
|
+
|
|
37
|
+
Configuration:
|
|
38
|
+
Server port and other settings are configured in config/config.yaml.
|
|
39
|
+
Copy config/config.yaml.dist to config/config.yaml to customize.
|
|
24
40
|
|
|
25
41
|
Environment:
|
|
26
|
-
PORT Server port
|
|
27
42
|
CHARON_DATA_DIR Override data directory (default: ~/.charon)
|
|
28
43
|
|
|
29
44
|
Data Location:
|
|
30
|
-
If ./config/
|
|
45
|
+
If ./config/config.yaml exists, uses current directory (dev mode).
|
|
31
46
|
Otherwise, uses ~/.charon/ for configuration and data.
|
|
32
47
|
|
|
33
48
|
Documentation:
|
|
@@ -43,38 +58,446 @@ if (args.includes('--version') || args.includes('-v')) {
|
|
|
43
58
|
process.exit(0);
|
|
44
59
|
}
|
|
45
60
|
|
|
46
|
-
// Determine data directory
|
|
47
|
-
const localConfig = resolve(process.cwd(), 'config/
|
|
61
|
+
// Determine data directory based on config file presence
|
|
62
|
+
const localConfig = resolve(process.cwd(), 'config/config.yaml');
|
|
48
63
|
const userDataDir = process.env.CHARON_DATA_DIR || resolve(homedir(), '.charon');
|
|
49
64
|
|
|
50
65
|
let dataDir;
|
|
51
66
|
if (existsSync(localConfig)) {
|
|
52
67
|
dataDir = process.cwd();
|
|
53
|
-
console.log('[charon] Using local config:', localConfig);
|
|
54
68
|
} else {
|
|
55
69
|
dataDir = userDataDir;
|
|
56
70
|
ensureDataDir(dataDir);
|
|
57
|
-
console.log('[charon] Using user config:', resolve(dataDir, 'config/triggers.yaml'));
|
|
58
71
|
}
|
|
59
72
|
|
|
60
73
|
// Set environment for the server
|
|
61
74
|
process.env.CHARON_DATA_DIR = dataDir;
|
|
62
75
|
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
76
|
+
// Load configuration
|
|
77
|
+
const config = loadConfig(dataDir);
|
|
78
|
+
const port = config.server?.port || 3000;
|
|
79
|
+
process.env.PORT = String(port);
|
|
80
|
+
|
|
81
|
+
// PID file location
|
|
82
|
+
const pidFile = resolve(dataDir, 'charon.pid');
|
|
83
|
+
|
|
84
|
+
// Handle service commands
|
|
85
|
+
const serviceIdx = args.indexOf('--service');
|
|
86
|
+
if (serviceIdx !== -1) {
|
|
87
|
+
const command = args[serviceIdx + 1];
|
|
88
|
+
await handleServiceCommand(command, port, pidFile);
|
|
89
|
+
process.exit(0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Handle promise --wait command
|
|
93
|
+
const waitIdx = args.indexOf('--wait');
|
|
94
|
+
if (waitIdx !== -1) {
|
|
95
|
+
const uuid = args[waitIdx + 1];
|
|
96
|
+
const triggerIdx = args.indexOf('--trigger');
|
|
97
|
+
const triggerId = triggerIdx !== -1 ? args[triggerIdx + 1] : 'unknown';
|
|
98
|
+
|
|
99
|
+
if (!uuid) {
|
|
100
|
+
console.error('[charon] --wait requires a UUID');
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
await handleWaitCommand(uuid, triggerId, port);
|
|
105
|
+
process.exit(0);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Handle promise --resolve command
|
|
109
|
+
const resolveIdx = args.indexOf('--resolve');
|
|
110
|
+
if (resolveIdx !== -1) {
|
|
111
|
+
const uuid = args[resolveIdx + 1];
|
|
112
|
+
const descIdx = args.indexOf('--description');
|
|
113
|
+
const description = descIdx !== -1 ? args[descIdx + 1] : '';
|
|
114
|
+
|
|
115
|
+
if (!uuid) {
|
|
116
|
+
console.error('[charon] --resolve requires a UUID');
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await handleResolveCommand(uuid, description, port);
|
|
121
|
+
process.exit(0);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Check if already running (singleton)
|
|
125
|
+
const isRunning = await isCharonRunning(port);
|
|
126
|
+
if (isRunning) {
|
|
127
|
+
console.log(`[charon] Already running on port ${port}`);
|
|
128
|
+
console.log('[charon] Use --service status for details, or --service stop to stop');
|
|
129
|
+
process.exit(0);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Show startup info
|
|
133
|
+
if (existsSync(localConfig)) {
|
|
134
|
+
console.log('[charon] Dev mode: using local config');
|
|
135
|
+
} else {
|
|
136
|
+
console.log('[charon] Using user config:', resolve(dataDir, 'config'));
|
|
68
137
|
}
|
|
69
|
-
|
|
138
|
+
console.log('[charon] Server port:', port);
|
|
139
|
+
|
|
140
|
+
// Write PID file
|
|
141
|
+
writePidFile(pidFile, process.pid);
|
|
142
|
+
|
|
143
|
+
// Cleanup PID file on exit
|
|
144
|
+
process.on('exit', () => cleanupPidFile(pidFile));
|
|
145
|
+
process.on('SIGINT', () => { cleanupPidFile(pidFile); process.exit(0); });
|
|
146
|
+
process.on('SIGTERM', () => { cleanupPidFile(pidFile); process.exit(0); });
|
|
70
147
|
|
|
71
148
|
// Start server
|
|
72
149
|
const serverPath = resolve(__dirname, '..', 'dist', 'server', 'index.js');
|
|
73
150
|
await import(serverPath);
|
|
74
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Handle --wait command: blocks until the promise is resolved
|
|
154
|
+
*/
|
|
155
|
+
async function handleWaitCommand(uuid, triggerId, port) {
|
|
156
|
+
const running = await isCharonRunning(port);
|
|
157
|
+
if (!running) {
|
|
158
|
+
console.error('[charon] Charon is not running. Start it with: npx charon-hooks --service start');
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const controller = new AbortController();
|
|
164
|
+
|
|
165
|
+
// Cleanup on process exit
|
|
166
|
+
const cleanup = () => {
|
|
167
|
+
controller.abort();
|
|
168
|
+
process.exit(0);
|
|
169
|
+
};
|
|
170
|
+
process.on('SIGINT', cleanup);
|
|
171
|
+
process.on('SIGTERM', cleanup);
|
|
172
|
+
|
|
173
|
+
const response = await fetch(
|
|
174
|
+
`http://localhost:${port}/api/promise/${uuid}/wait?trigger=${encodeURIComponent(triggerId)}`,
|
|
175
|
+
{ signal: controller.signal }
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
if (!response.ok) {
|
|
179
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
|
180
|
+
console.error(`[charon] Wait failed: ${error.error || response.statusText}`);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const result = await response.json();
|
|
185
|
+
|
|
186
|
+
if (result.status === 'resolved') {
|
|
187
|
+
// Output the description to stdout (for Claude to consume)
|
|
188
|
+
console.log(result.description);
|
|
189
|
+
} else {
|
|
190
|
+
console.error(`[charon] Promise ${result.status}: ${result.error || 'Unknown'}`);
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
} catch (err) {
|
|
194
|
+
if (err.name === 'AbortError') {
|
|
195
|
+
// Process was killed, exit gracefully
|
|
196
|
+
process.exit(0);
|
|
197
|
+
}
|
|
198
|
+
console.error(`[charon] Wait error: ${err.message}`);
|
|
199
|
+
process.exit(1);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Handle --resolve command: resolves a pending promise
|
|
205
|
+
*/
|
|
206
|
+
async function handleResolveCommand(uuid, description, port) {
|
|
207
|
+
const running = await isCharonRunning(port);
|
|
208
|
+
if (!running) {
|
|
209
|
+
console.error('[charon] Charon is not running. Start it with: npx charon-hooks --service start');
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const response = await fetch(`http://localhost:${port}/api/promise/${uuid}/resolve`, {
|
|
215
|
+
method: 'POST',
|
|
216
|
+
headers: { 'Content-Type': 'application/json' },
|
|
217
|
+
body: JSON.stringify({ description }),
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (!response.ok) {
|
|
221
|
+
const error = await response.json().catch(() => ({ error: 'Unknown error' }));
|
|
222
|
+
console.error(`[charon] Resolve failed: ${error.error || response.statusText}`);
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const result = await response.json();
|
|
227
|
+
|
|
228
|
+
if (result.status === 'resolved') {
|
|
229
|
+
console.log(`[charon] Resolved promise ${uuid}`);
|
|
230
|
+
} else if (result.status === 'no_waiter') {
|
|
231
|
+
// Silently succeed - no one was waiting
|
|
232
|
+
console.log(`[charon] No waiter for ${uuid} (already resolved or never registered)`);
|
|
233
|
+
}
|
|
234
|
+
} catch (err) {
|
|
235
|
+
console.error(`[charon] Resolve error: ${err.message}`);
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Check if Charon is running on the given port
|
|
242
|
+
*/
|
|
243
|
+
async function isCharonRunning(port) {
|
|
244
|
+
try {
|
|
245
|
+
const controller = new AbortController();
|
|
246
|
+
const timeout = setTimeout(() => controller.abort(), 2000);
|
|
247
|
+
|
|
248
|
+
const response = await fetch(`http://localhost:${port}/api/triggers`, {
|
|
249
|
+
signal: controller.signal
|
|
250
|
+
});
|
|
251
|
+
clearTimeout(timeout);
|
|
252
|
+
|
|
253
|
+
return response.ok;
|
|
254
|
+
} catch {
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Handle service management commands
|
|
261
|
+
*/
|
|
262
|
+
async function handleServiceCommand(command, port, pidFile) {
|
|
263
|
+
switch (command) {
|
|
264
|
+
case 'status': {
|
|
265
|
+
const running = await isCharonRunning(port);
|
|
266
|
+
if (running) {
|
|
267
|
+
const pid = readPidFile(pidFile);
|
|
268
|
+
console.log(`[charon] Running on port ${port}${pid ? ` (PID: ${pid})` : ''}`);
|
|
269
|
+
} else {
|
|
270
|
+
console.log(`[charon] Not running`);
|
|
271
|
+
}
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
case 'start': {
|
|
276
|
+
const running = await isCharonRunning(port);
|
|
277
|
+
if (running) {
|
|
278
|
+
console.log(`[charon] Already running on port ${port}`);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
console.log('[charon] Starting in background...');
|
|
283
|
+
|
|
284
|
+
// Spawn detached process
|
|
285
|
+
const child = spawn(process.execPath, [resolve(__dirname, 'charon.js')], {
|
|
286
|
+
detached: true,
|
|
287
|
+
stdio: 'ignore',
|
|
288
|
+
cwd: process.cwd(),
|
|
289
|
+
env: { ...process.env, CHARON_DATA_DIR: dataDir }
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
child.unref();
|
|
293
|
+
|
|
294
|
+
// Wait for startup
|
|
295
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
296
|
+
|
|
297
|
+
const nowRunning = await isCharonRunning(port);
|
|
298
|
+
if (nowRunning) {
|
|
299
|
+
console.log(`[charon] Started on port ${port} (PID: ${child.pid})`);
|
|
300
|
+
} else {
|
|
301
|
+
console.log('[charon] Failed to start. Check logs for errors.');
|
|
302
|
+
process.exit(1);
|
|
303
|
+
}
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
case 'stop': {
|
|
308
|
+
const running = await isCharonRunning(port);
|
|
309
|
+
if (!running) {
|
|
310
|
+
console.log('[charon] Not running');
|
|
311
|
+
cleanupPidFile(pidFile);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const pid = readPidFile(pidFile);
|
|
316
|
+
if (pid) {
|
|
317
|
+
try {
|
|
318
|
+
process.kill(pid, 'SIGTERM');
|
|
319
|
+
console.log(`[charon] Stopped (PID: ${pid})`);
|
|
320
|
+
cleanupPidFile(pidFile);
|
|
321
|
+
} catch (err) {
|
|
322
|
+
console.log(`[charon] Could not stop process: ${err.message}`);
|
|
323
|
+
console.log('[charon] Process may have been started by another user or system service');
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
console.log('[charon] Running but no PID file found');
|
|
327
|
+
console.log('[charon] Process may have been started by system service or another method');
|
|
328
|
+
}
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
case 'install': {
|
|
333
|
+
await generateServiceFiles(dataDir, port);
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
default:
|
|
338
|
+
console.log(`[charon] Unknown service command: ${command}`);
|
|
339
|
+
console.log('[charon] Valid commands: status, start, stop, install');
|
|
340
|
+
process.exit(1);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Generate system service files
|
|
346
|
+
*/
|
|
347
|
+
async function generateServiceFiles(dataDir, _port) {
|
|
348
|
+
const os = platform();
|
|
349
|
+
const serviceDir = resolve(dataDir, 'service');
|
|
350
|
+
|
|
351
|
+
if (!existsSync(serviceDir)) {
|
|
352
|
+
mkdirSync(serviceDir, { recursive: true });
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (os === 'darwin') {
|
|
356
|
+
// macOS launchd plist
|
|
357
|
+
const plistPath = resolve(serviceDir, 'com.charon.hooks.plist');
|
|
358
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
359
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
360
|
+
<plist version="1.0">
|
|
361
|
+
<dict>
|
|
362
|
+
<key>Label</key>
|
|
363
|
+
<string>com.charon.hooks</string>
|
|
364
|
+
<key>ProgramArguments</key>
|
|
365
|
+
<array>
|
|
366
|
+
<string>${process.execPath}</string>
|
|
367
|
+
<string>${resolve(__dirname, 'charon.js')}</string>
|
|
368
|
+
</array>
|
|
369
|
+
<key>EnvironmentVariables</key>
|
|
370
|
+
<dict>
|
|
371
|
+
<key>CHARON_DATA_DIR</key>
|
|
372
|
+
<string>${dataDir}</string>
|
|
373
|
+
</dict>
|
|
374
|
+
<key>RunAtLoad</key>
|
|
375
|
+
<true/>
|
|
376
|
+
<key>KeepAlive</key>
|
|
377
|
+
<true/>
|
|
378
|
+
<key>StandardOutPath</key>
|
|
379
|
+
<string>${resolve(dataDir, 'charon.log')}</string>
|
|
380
|
+
<key>StandardErrorPath</key>
|
|
381
|
+
<string>${resolve(dataDir, 'charon.error.log')}</string>
|
|
382
|
+
</dict>
|
|
383
|
+
</plist>`;
|
|
384
|
+
|
|
385
|
+
writeFileSync(plistPath, plist);
|
|
386
|
+
console.log(`[charon] Generated launchd plist: ${plistPath}`);
|
|
387
|
+
console.log('');
|
|
388
|
+
console.log('To install as a user service:');
|
|
389
|
+
console.log(` cp ${plistPath} ~/Library/LaunchAgents/`);
|
|
390
|
+
console.log(' launchctl load ~/Library/LaunchAgents/com.charon.hooks.plist');
|
|
391
|
+
console.log('');
|
|
392
|
+
console.log('To uninstall:');
|
|
393
|
+
console.log(' launchctl unload ~/Library/LaunchAgents/com.charon.hooks.plist');
|
|
394
|
+
console.log(' rm ~/Library/LaunchAgents/com.charon.hooks.plist');
|
|
395
|
+
|
|
396
|
+
} else if (os === 'linux') {
|
|
397
|
+
// Linux systemd unit file
|
|
398
|
+
const unitPath = resolve(serviceDir, 'charon-hooks.service');
|
|
399
|
+
const unit = `[Unit]
|
|
400
|
+
Description=Charon Hooks - Task triggering service
|
|
401
|
+
After=network.target
|
|
402
|
+
|
|
403
|
+
[Service]
|
|
404
|
+
Type=simple
|
|
405
|
+
ExecStart=${process.execPath} ${resolve(__dirname, 'charon.js')}
|
|
406
|
+
Environment=CHARON_DATA_DIR=${dataDir}
|
|
407
|
+
Restart=on-failure
|
|
408
|
+
RestartSec=5
|
|
409
|
+
|
|
410
|
+
[Install]
|
|
411
|
+
WantedBy=default.target
|
|
412
|
+
`;
|
|
413
|
+
|
|
414
|
+
writeFileSync(unitPath, unit);
|
|
415
|
+
console.log(`[charon] Generated systemd unit file: ${unitPath}`);
|
|
416
|
+
console.log('');
|
|
417
|
+
console.log('To install as a user service:');
|
|
418
|
+
console.log(' mkdir -p ~/.config/systemd/user');
|
|
419
|
+
console.log(` cp ${unitPath} ~/.config/systemd/user/`);
|
|
420
|
+
console.log(' systemctl --user daemon-reload');
|
|
421
|
+
console.log(' systemctl --user enable charon-hooks');
|
|
422
|
+
console.log(' systemctl --user start charon-hooks');
|
|
423
|
+
console.log('');
|
|
424
|
+
console.log('To check status:');
|
|
425
|
+
console.log(' systemctl --user status charon-hooks');
|
|
426
|
+
console.log('');
|
|
427
|
+
console.log('To uninstall:');
|
|
428
|
+
console.log(' systemctl --user stop charon-hooks');
|
|
429
|
+
console.log(' systemctl --user disable charon-hooks');
|
|
430
|
+
console.log(' rm ~/.config/systemd/user/charon-hooks.service');
|
|
431
|
+
|
|
432
|
+
} else {
|
|
433
|
+
console.log(`[charon] Service installation not supported on ${os}`);
|
|
434
|
+
console.log('[charon] You can start Charon manually with: npx charon-hooks --service start');
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Write PID to file
|
|
440
|
+
*/
|
|
441
|
+
function writePidFile(pidFile, pid) {
|
|
442
|
+
try {
|
|
443
|
+
writeFileSync(pidFile, String(pid));
|
|
444
|
+
} catch {
|
|
445
|
+
// Ignore write errors (e.g., permission issues)
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Read PID from file
|
|
451
|
+
*/
|
|
452
|
+
function readPidFile(pidFile) {
|
|
453
|
+
try {
|
|
454
|
+
if (existsSync(pidFile)) {
|
|
455
|
+
return parseInt(readFileSync(pidFile, 'utf-8').trim(), 10);
|
|
456
|
+
}
|
|
457
|
+
} catch {
|
|
458
|
+
// Ignore read errors
|
|
459
|
+
}
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Cleanup PID file
|
|
465
|
+
*/
|
|
466
|
+
function cleanupPidFile(pidFile) {
|
|
467
|
+
try {
|
|
468
|
+
if (existsSync(pidFile)) {
|
|
469
|
+
unlinkSync(pidFile);
|
|
470
|
+
}
|
|
471
|
+
} catch {
|
|
472
|
+
// Ignore cleanup errors
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Load configuration from config/config.yaml
|
|
478
|
+
*/
|
|
479
|
+
function loadConfig(dir) {
|
|
480
|
+
const configPath = resolve(dir, 'config/config.yaml');
|
|
481
|
+
|
|
482
|
+
if (!existsSync(configPath)) {
|
|
483
|
+
return { server: { port: 3000 } };
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
try {
|
|
487
|
+
const content = readFileSync(configPath, 'utf-8');
|
|
488
|
+
const parsed = YAML.parse(content);
|
|
489
|
+
return parsed || { server: { port: 3000 } };
|
|
490
|
+
} catch (error) {
|
|
491
|
+
console.error('[charon] Error parsing config.yaml:', error.message);
|
|
492
|
+
return { server: { port: 3000 } };
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* Ensure data directory structure exists with default files
|
|
498
|
+
*/
|
|
75
499
|
function ensureDataDir(dir) {
|
|
76
500
|
if (!existsSync(dir)) {
|
|
77
|
-
console.log('[charon] Creating data directory:', dir);
|
|
78
501
|
mkdirSync(dir, { recursive: true });
|
|
79
502
|
}
|
|
80
503
|
|
|
@@ -88,16 +511,40 @@ function ensureDataDir(dir) {
|
|
|
88
511
|
mkdirSync(sanitizersDir, { recursive: true });
|
|
89
512
|
}
|
|
90
513
|
|
|
91
|
-
|
|
514
|
+
// Initialize config.yaml from .dist template
|
|
515
|
+
const configFile = resolve(configDir, 'config.yaml');
|
|
92
516
|
if (!existsSync(configFile)) {
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
517
|
+
const distFile = resolve(__dirname, '..', 'config', 'config.yaml.dist');
|
|
518
|
+
if (existsSync(distFile)) {
|
|
519
|
+
copyFileSync(distFile, configFile);
|
|
520
|
+
} else {
|
|
521
|
+
writeFileSync(
|
|
522
|
+
configFile,
|
|
523
|
+
`# Charon configuration
|
|
524
|
+
# See https://github.com/NaxYo/charon for documentation
|
|
525
|
+
|
|
526
|
+
server:
|
|
527
|
+
port: 3000
|
|
528
|
+
`
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Initialize triggers.yaml
|
|
534
|
+
const triggersFile = resolve(configDir, 'triggers.yaml');
|
|
535
|
+
if (!existsSync(triggersFile)) {
|
|
536
|
+
const distTriggersFile = resolve(__dirname, '..', 'config', 'triggers.yaml.dist');
|
|
537
|
+
if (existsSync(distTriggersFile)) {
|
|
538
|
+
copyFileSync(distTriggersFile, triggersFile);
|
|
539
|
+
} else {
|
|
540
|
+
writeFileSync(
|
|
541
|
+
triggersFile,
|
|
542
|
+
`# Charon trigger configuration
|
|
96
543
|
# See https://github.com/NaxYo/charon for documentation
|
|
97
544
|
|
|
98
545
|
triggers: []
|
|
99
546
|
`
|
|
100
|
-
|
|
101
|
-
|
|
547
|
+
);
|
|
548
|
+
}
|
|
102
549
|
}
|
|
103
550
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Charon configuration
|
|
2
|
+
# See https://github.com/NaxYo/charon for documentation
|
|
3
|
+
#
|
|
4
|
+
# Copy this file to config.yaml to customize settings.
|
|
5
|
+
# For production: ~/.charon/config/config.yaml
|
|
6
|
+
# For development: ./config/config.yaml (takes precedence)
|
|
7
|
+
|
|
8
|
+
server:
|
|
9
|
+
port: 3000
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Tunnel configuration (optional)
|
|
2
|
+
# Exposes webhooks to the internet via ngrok
|
|
3
|
+
# Get your authtoken at: https://dashboard.ngrok.com/get-started/your-authtoken
|
|
4
|
+
tunnel:
|
|
5
|
+
enabled: false
|
|
6
|
+
provider: ngrok
|
|
7
|
+
authtoken: your_ngrok_authtoken_here
|
|
8
|
+
# Optional: use a static domain (free tier includes 1)
|
|
9
|
+
# Get one at: https://dashboard.ngrok.com/cloud-edge/domains
|
|
10
|
+
# domain: your-name.ngrok-free.app
|
|
11
|
+
# Optional: expose the full UI via tunnel (default: false, only webhooks exposed)
|
|
12
|
+
# expose_ui: false
|
|
13
|
+
|
|
14
|
+
triggers:
|
|
15
|
+
- id: example-webhook
|
|
16
|
+
name: Example Webhook
|
|
17
|
+
type: webhook
|
|
18
|
+
enabled: true
|
|
19
|
+
template: |
|
|
20
|
+
Received webhook payload:
|
|
21
|
+
|
|
22
|
+
{payload}
|
|
23
|
+
sanitizer: passthrough
|
|
24
|
+
egress: console
|
|
25
|
+
|
|
26
|
+
- id: example-cron
|
|
27
|
+
name: Example Cron
|
|
28
|
+
type: cron
|
|
29
|
+
schedule: "0 9 * * *"
|
|
30
|
+
enabled: false
|
|
31
|
+
template: |
|
|
32
|
+
Daily scheduled task at {trigger_time}
|
|
33
|
+
egress: console
|
|
34
|
+
context:
|
|
35
|
+
source: scheduler
|
|
36
|
+
|
|
37
|
+
# Example: CLI egress with Claude
|
|
38
|
+
# - id: github-issues
|
|
39
|
+
# name: GitHub Issue Handler
|
|
40
|
+
# type: webhook
|
|
41
|
+
# enabled: true
|
|
42
|
+
# template: |
|
|
43
|
+
# Fix issue #{issue_number}: {title}
|
|
44
|
+
#
|
|
45
|
+
# {body}
|
|
46
|
+
# sanitizer: github-issue
|
|
47
|
+
# egress: cli
|
|
48
|
+
# context:
|
|
49
|
+
# cli_template: "claude \"{description}\""
|
|
50
|
+
# working_dir: "/home/dev/{repo}"
|
package/dist/server/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// src/server/app.ts
|
|
2
|
-
import { Hono as
|
|
2
|
+
import { Hono as Hono8 } from "hono";
|
|
3
3
|
import { dirname as dirname2, resolve as resolve3 } from "path";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
|
+
import { existsSync as existsSync4 } from "fs";
|
|
5
6
|
|
|
6
7
|
// src/server/middleware/logger.ts
|
|
7
8
|
import { createMiddleware } from "hono/factory";
|
|
@@ -484,7 +485,7 @@ function compose(template, context) {
|
|
|
484
485
|
function generateTaskId() {
|
|
485
486
|
return "task_" + Math.random().toString(36).slice(2, 10);
|
|
486
487
|
}
|
|
487
|
-
async function processWebhook(db2, trigger, payload, headers) {
|
|
488
|
+
async function processWebhook(db2, trigger, payload, headers, promiseUuid) {
|
|
488
489
|
const run = createRun(db2, {
|
|
489
490
|
trigger_id: trigger.id,
|
|
490
491
|
trigger_type: "webhook",
|
|
@@ -498,7 +499,7 @@ async function processWebhook(db2, trigger, payload, headers) {
|
|
|
498
499
|
data: { payload_size: JSON.stringify(payload).length }
|
|
499
500
|
});
|
|
500
501
|
updateRun(db2, run.id, { status: "processing" });
|
|
501
|
-
return processPipeline(db2, run.id, trigger, payload, headers);
|
|
502
|
+
return processPipeline(db2, run.id, trigger, payload, headers, promiseUuid);
|
|
502
503
|
}
|
|
503
504
|
async function processCron(db2, trigger) {
|
|
504
505
|
const run = createRun(db2, {
|
|
@@ -519,7 +520,7 @@ async function processCron(db2, trigger) {
|
|
|
519
520
|
};
|
|
520
521
|
return processPipeline(db2, run.id, trigger, cronContext, {});
|
|
521
522
|
}
|
|
522
|
-
async function processPipeline(db2, runId, trigger, payload, headers) {
|
|
523
|
+
async function processPipeline(db2, runId, trigger, payload, headers, promiseUuid) {
|
|
523
524
|
const startTime = Date.now();
|
|
524
525
|
try {
|
|
525
526
|
let context;
|
|
@@ -547,6 +548,9 @@ async function processPipeline(db2, runId, trigger, payload, headers) {
|
|
|
547
548
|
return { run: getRun(db2, runId), task: null };
|
|
548
549
|
}
|
|
549
550
|
context = { ...trigger.context, ...result };
|
|
551
|
+
if (promiseUuid) {
|
|
552
|
+
context.promise_uuid = promiseUuid;
|
|
553
|
+
}
|
|
550
554
|
updateRun(db2, runId, {
|
|
551
555
|
sanitizer_result: {
|
|
552
556
|
success: true,
|
|
@@ -563,6 +567,9 @@ async function processPipeline(db2, runId, trigger, payload, headers) {
|
|
|
563
567
|
});
|
|
564
568
|
} else {
|
|
565
569
|
context = typeof payload === "object" && payload !== null ? { ...trigger.context, ...payload } : { ...trigger.context, payload };
|
|
570
|
+
if (promiseUuid) {
|
|
571
|
+
context.promise_uuid = promiseUuid;
|
|
572
|
+
}
|
|
566
573
|
}
|
|
567
574
|
const composerStart = Date.now();
|
|
568
575
|
const description = compose(trigger.template, context);
|
|
@@ -631,6 +638,15 @@ function initScheduler(db2, triggers) {
|
|
|
631
638
|
const task = cron.schedule(trigger.schedule, async () => {
|
|
632
639
|
console.log(`[scheduler] Firing trigger: ${trigger.id}`);
|
|
633
640
|
await processCron(db2, trigger);
|
|
641
|
+
if (trigger.one_off) {
|
|
642
|
+
console.log(`[scheduler] One-off trigger '${trigger.id}' completed, deleting...`);
|
|
643
|
+
const job = scheduledJobs.get(trigger.id);
|
|
644
|
+
if (job) {
|
|
645
|
+
job.stop();
|
|
646
|
+
scheduledJobs.delete(trigger.id);
|
|
647
|
+
}
|
|
648
|
+
await deleteTrigger(trigger.id);
|
|
649
|
+
}
|
|
634
650
|
});
|
|
635
651
|
scheduledJobs.set(trigger.id, task);
|
|
636
652
|
}
|
|
@@ -860,6 +876,30 @@ function getTrigger(id) {
|
|
|
860
876
|
const config = getConfig();
|
|
861
877
|
return config.triggers.find((t) => t.id === id);
|
|
862
878
|
}
|
|
879
|
+
async function deleteTrigger(id, configPath = "config/triggers.yaml") {
|
|
880
|
+
if (!existsSync2(configPath)) {
|
|
881
|
+
return false;
|
|
882
|
+
}
|
|
883
|
+
const content = readFileSync2(configPath, "utf-8");
|
|
884
|
+
const result = parseConfig(content);
|
|
885
|
+
if (!result.success) {
|
|
886
|
+
console.error(`[config] Cannot delete trigger: invalid config`);
|
|
887
|
+
return false;
|
|
888
|
+
}
|
|
889
|
+
const config = result.data;
|
|
890
|
+
const triggerIndex = config.triggers.findIndex((t) => t.id === id);
|
|
891
|
+
if (triggerIndex === -1) {
|
|
892
|
+
console.warn(`[config] Trigger '${id}' not found for deletion`);
|
|
893
|
+
return false;
|
|
894
|
+
}
|
|
895
|
+
config.triggers.splice(triggerIndex, 1);
|
|
896
|
+
const yaml = await import("yaml");
|
|
897
|
+
const newContent = yaml.stringify(config);
|
|
898
|
+
writeFileSync(configPath, newContent, "utf-8");
|
|
899
|
+
console.log(`[config] Deleted trigger '${id}'`);
|
|
900
|
+
cachedConfig = config;
|
|
901
|
+
return true;
|
|
902
|
+
}
|
|
863
903
|
function startConfigWatcher(configPath = "config/triggers.yaml") {
|
|
864
904
|
if (configWatcher && watchedConfigPath === configPath) {
|
|
865
905
|
return;
|
|
@@ -962,7 +1002,7 @@ async function writeTrigger(trigger, configPath = DEFAULT_CONFIG_PATH) {
|
|
|
962
1002
|
return { success: false, error: `Write failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
963
1003
|
}
|
|
964
1004
|
}
|
|
965
|
-
async function
|
|
1005
|
+
async function deleteTrigger2(id, configPath = DEFAULT_CONFIG_PATH) {
|
|
966
1006
|
try {
|
|
967
1007
|
const content = readFileSync3(configPath, "utf-8");
|
|
968
1008
|
const config = parseYaml2(content) || {};
|
|
@@ -1104,7 +1144,7 @@ async function updateTriggerInternal(id, trigger, configPath) {
|
|
|
1104
1144
|
};
|
|
1105
1145
|
}
|
|
1106
1146
|
if (idChanged) {
|
|
1107
|
-
const deleteResult = await
|
|
1147
|
+
const deleteResult = await deleteTrigger2(id, configPath);
|
|
1108
1148
|
if (!deleteResult.success) {
|
|
1109
1149
|
return {
|
|
1110
1150
|
success: false,
|
|
@@ -1138,7 +1178,7 @@ async function deleteTriggerInternal(id, configPath) {
|
|
|
1138
1178
|
status: 404
|
|
1139
1179
|
};
|
|
1140
1180
|
}
|
|
1141
|
-
const result = await
|
|
1181
|
+
const result = await deleteTrigger2(id, configPath);
|
|
1142
1182
|
if (!result.success) {
|
|
1143
1183
|
return {
|
|
1144
1184
|
success: false,
|
|
@@ -1481,9 +1521,8 @@ async function ensureConfig2() {
|
|
|
1481
1521
|
configLoaded2 = true;
|
|
1482
1522
|
}
|
|
1483
1523
|
}
|
|
1484
|
-
|
|
1524
|
+
async function handleWebhook(c, id, promiseUuid) {
|
|
1485
1525
|
await ensureConfig2();
|
|
1486
|
-
const id = c.req.param("id");
|
|
1487
1526
|
const trigger = getTrigger(id);
|
|
1488
1527
|
if (!trigger) {
|
|
1489
1528
|
return c.json(
|
|
@@ -1509,11 +1548,20 @@ webhookRoutes.post("/:id", async (c) => {
|
|
|
1509
1548
|
headers[key] = value;
|
|
1510
1549
|
});
|
|
1511
1550
|
const db2 = getDb();
|
|
1512
|
-
const result = await processWebhook(db2, trigger, payload, headers);
|
|
1551
|
+
const result = await processWebhook(db2, trigger, payload, headers, promiseUuid);
|
|
1513
1552
|
return c.json(
|
|
1514
|
-
{ run_id: result.run.id, status: result.run.status },
|
|
1553
|
+
{ run_id: result.run.id, status: result.run.status, promise_uuid: promiseUuid || null },
|
|
1515
1554
|
202
|
|
1516
1555
|
);
|
|
1556
|
+
}
|
|
1557
|
+
webhookRoutes.post("/:id", async (c) => {
|
|
1558
|
+
const id = c.req.param("id");
|
|
1559
|
+
return handleWebhook(c, id);
|
|
1560
|
+
});
|
|
1561
|
+
webhookRoutes.post("/:id/:uuid", async (c) => {
|
|
1562
|
+
const id = c.req.param("id");
|
|
1563
|
+
const uuid = c.req.param("uuid");
|
|
1564
|
+
return handleWebhook(c, id, uuid);
|
|
1517
1565
|
});
|
|
1518
1566
|
|
|
1519
1567
|
// src/server/routes/task.ts
|
|
@@ -1562,6 +1610,80 @@ taskRoutes.post("/complete", async (c) => {
|
|
|
1562
1610
|
}
|
|
1563
1611
|
});
|
|
1564
1612
|
|
|
1613
|
+
// src/server/routes/promise.ts
|
|
1614
|
+
import { Hono as Hono7 } from "hono";
|
|
1615
|
+
var promiseRoutes = new Hono7();
|
|
1616
|
+
var pendingPromises = /* @__PURE__ */ new Map();
|
|
1617
|
+
promiseRoutes.get("/:uuid/wait", async (c) => {
|
|
1618
|
+
const uuid = c.req.param("uuid");
|
|
1619
|
+
const triggerId = c.req.query("trigger") || "unknown";
|
|
1620
|
+
if (pendingPromises.has(uuid)) {
|
|
1621
|
+
return c.json({ error: "Promise already registered" }, 409);
|
|
1622
|
+
}
|
|
1623
|
+
console.log(`[promise] Waiting for ${uuid} (trigger: ${triggerId})`);
|
|
1624
|
+
return new Promise((promiseResolve) => {
|
|
1625
|
+
const cleanup = () => {
|
|
1626
|
+
pendingPromises.delete(uuid);
|
|
1627
|
+
};
|
|
1628
|
+
pendingPromises.set(uuid, {
|
|
1629
|
+
resolve: (description) => {
|
|
1630
|
+
cleanup();
|
|
1631
|
+
console.log(`[promise] Resolved ${uuid}`);
|
|
1632
|
+
promiseResolve(c.json({ status: "resolved", description }));
|
|
1633
|
+
},
|
|
1634
|
+
reject: (error) => {
|
|
1635
|
+
cleanup();
|
|
1636
|
+
console.log(`[promise] Rejected ${uuid}: ${error.message}`);
|
|
1637
|
+
promiseResolve(c.json({ status: "rejected", error: error.message }, 500));
|
|
1638
|
+
},
|
|
1639
|
+
triggerId,
|
|
1640
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
1641
|
+
});
|
|
1642
|
+
});
|
|
1643
|
+
});
|
|
1644
|
+
promiseRoutes.post("/:uuid/resolve", async (c) => {
|
|
1645
|
+
const uuid = c.req.param("uuid");
|
|
1646
|
+
const body = await c.req.json().catch(() => ({}));
|
|
1647
|
+
const description = body.description || "";
|
|
1648
|
+
const pending = pendingPromises.get(uuid);
|
|
1649
|
+
if (pending) {
|
|
1650
|
+
pending.resolve(description);
|
|
1651
|
+
return c.json({ status: "resolved", uuid });
|
|
1652
|
+
}
|
|
1653
|
+
console.log(`[promise] No waiter for ${uuid}, ignoring resolve`);
|
|
1654
|
+
return c.json({ status: "no_waiter", uuid });
|
|
1655
|
+
});
|
|
1656
|
+
promiseRoutes.get("/:uuid", (c) => {
|
|
1657
|
+
const uuid = c.req.param("uuid");
|
|
1658
|
+
const pending = pendingPromises.get(uuid);
|
|
1659
|
+
if (pending) {
|
|
1660
|
+
return c.json({
|
|
1661
|
+
uuid,
|
|
1662
|
+
status: "pending",
|
|
1663
|
+
triggerId: pending.triggerId,
|
|
1664
|
+
createdAt: pending.createdAt.toISOString()
|
|
1665
|
+
});
|
|
1666
|
+
}
|
|
1667
|
+
return c.json({ uuid, status: "not_found" }, 404);
|
|
1668
|
+
});
|
|
1669
|
+
promiseRoutes.get("/", (c) => {
|
|
1670
|
+
const promises = Array.from(pendingPromises.entries()).map(([uuid, p]) => ({
|
|
1671
|
+
uuid,
|
|
1672
|
+
triggerId: p.triggerId,
|
|
1673
|
+
createdAt: p.createdAt.toISOString()
|
|
1674
|
+
}));
|
|
1675
|
+
return c.json({ promises });
|
|
1676
|
+
});
|
|
1677
|
+
promiseRoutes.delete("/:uuid", (c) => {
|
|
1678
|
+
const uuid = c.req.param("uuid");
|
|
1679
|
+
const pending = pendingPromises.get(uuid);
|
|
1680
|
+
if (pending) {
|
|
1681
|
+
pending.reject(new Error("Cancelled"));
|
|
1682
|
+
return c.json({ status: "cancelled", uuid });
|
|
1683
|
+
}
|
|
1684
|
+
return c.json({ status: "not_found", uuid }, 404);
|
|
1685
|
+
});
|
|
1686
|
+
|
|
1565
1687
|
// src/server/middleware/tunnel-proxy.ts
|
|
1566
1688
|
var TUNNEL_PATTERNS = [
|
|
1567
1689
|
".ngrok-free.app",
|
|
@@ -1590,8 +1712,10 @@ async function tunnelProxyMiddleware(c, next) {
|
|
|
1590
1712
|
|
|
1591
1713
|
// src/server/app.ts
|
|
1592
1714
|
var __dirname = dirname2(fileURLToPath(import.meta.url));
|
|
1593
|
-
var
|
|
1594
|
-
var
|
|
1715
|
+
var prodClientDir = resolve3(__dirname, "../client");
|
|
1716
|
+
var devClientDir = resolve3(process.cwd(), "dist/client");
|
|
1717
|
+
var clientDir = existsSync4(devClientDir) ? devClientDir : prodClientDir;
|
|
1718
|
+
var app = new Hono8();
|
|
1595
1719
|
app.use("*", quietLogger);
|
|
1596
1720
|
app.use("*", tunnelProxyMiddleware);
|
|
1597
1721
|
app.route("/api/triggers", triggersRoutes);
|
|
@@ -1600,6 +1724,7 @@ app.route("/api/sanitizers", sanitizersRoutes);
|
|
|
1600
1724
|
app.route("/api/tunnel", tunnelRoutes);
|
|
1601
1725
|
app.route("/api/webhook", webhookRoutes);
|
|
1602
1726
|
app.route("/api/task", taskRoutes);
|
|
1727
|
+
app.route("/api/promise", promiseRoutes);
|
|
1603
1728
|
app.use("/*", serveStatic({ root: clientDir }));
|
|
1604
1729
|
app.get("*", serveStatic({ path: resolve3(clientDir, "index.html") }));
|
|
1605
1730
|
|
|
@@ -1623,7 +1748,9 @@ await initializeServices();
|
|
|
1623
1748
|
if (typeof Bun !== "undefined") {
|
|
1624
1749
|
const server = Bun.serve({
|
|
1625
1750
|
fetch: app.fetch,
|
|
1626
|
-
port: PORT
|
|
1751
|
+
port: PORT,
|
|
1752
|
+
// Disable idle timeout to support long-running promise waits
|
|
1753
|
+
idleTimeout: 0
|
|
1627
1754
|
});
|
|
1628
1755
|
console.log(`[server] Charon running at http://localhost:${server.port}`);
|
|
1629
1756
|
} else {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "charon-hooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Autonomous task triggering service - webhooks and cron to AI agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"bin/",
|
|
11
|
+
"config/*.dist",
|
|
11
12
|
"dist/"
|
|
12
13
|
],
|
|
13
14
|
"scripts": {
|