@comment-io/cli 0.1.0 → 0.1.1-alpha.11

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.
@@ -1,398 +0,0 @@
1
- #!/usr/bin/env node
2
- import { spawn } from 'node:child_process';
3
- import { existsSync } from 'node:fs';
4
- import { rm, readFile } from 'node:fs/promises';
5
- import { hostname, homedir, platform } from 'node:os';
6
- import { dirname, resolve } from 'node:path';
7
- import { fileURLToPath } from 'node:url';
8
- import net from 'node:net';
9
- import {
10
- DEFAULT_COMMENTFS_BASE_URL,
11
- DEFAULT_SYNC_ROOT,
12
- readCommentFsConfig,
13
- syncConfiguredCommentDocsSettled,
14
- syncRemoteSelectedCommentDocsSettled,
15
- } from '../shared/commentfs-sync.js';
16
- import {
17
- appendLog,
18
- buildQueueEnvelope,
19
- daemonLogPath,
20
- daemonPidPath,
21
- daemonRequest,
22
- daemonSocketPath,
23
- ensurePrivateDir,
24
- findClaimFile,
25
- isProcessAlive,
26
- loadAgentProfiles,
27
- readPid,
28
- removeClaimFile,
29
- republishClaimFile,
30
- sweepExpiredLocalClaims,
31
- writePid,
32
- writeQueueEnvelope,
33
- type CommentAgentProfile,
34
- type DaemonRequest,
35
- type DaemonResponse,
36
- type NotificationLeaseEnvelope,
37
- type NotificationPayload,
38
- } from '../shared/comment-notifications.js';
39
-
40
- interface DaemonOptions {
41
- rootDir?: string;
42
- homeDir?: string;
43
- intervalMs?: number;
44
- fullIntervalMs?: number;
45
- maxBackoffMs?: number;
46
- }
47
-
48
- interface NotificationProfileHealth {
49
- handle: string;
50
- connected: boolean;
51
- lastConnectedAt: string | null;
52
- lastMessageAt: string | null;
53
- lastLeaseAt: string | null;
54
- lastError: string | null;
55
- reconnects: number;
56
- }
57
-
58
- interface DaemonHealth {
59
- pid: number;
60
- started_at: string;
61
- socket_path: string;
62
- sync: {
63
- enabled: boolean;
64
- last_attempt_at: string | null;
65
- last_success_at: string | null;
66
- last_error_at: string | null;
67
- last_error: string | null;
68
- consecutive_failures: number;
69
- };
70
- notifications: Record<string, NotificationProfileHealth>;
71
- }
72
-
73
- const defaultOptions: Required<DaemonOptions> = {
74
- rootDir: DEFAULT_SYNC_ROOT,
75
- homeDir: homedir(),
76
- intervalMs: 10_000,
77
- fullIntervalMs: 5 * 60_000,
78
- maxBackoffMs: 60_000,
79
- };
80
-
81
- export async function runCommentDaemon(options: DaemonOptions = {}): Promise<void> {
82
- const opts = { ...defaultOptions, ...options };
83
- const socketPath = daemonSocketPath(opts.homeDir);
84
- const pidPath = daemonPidPath(opts.homeDir);
85
- const logPath = daemonLogPath(new Date(), opts.homeDir);
86
- const abort = new AbortController();
87
- const startedAt = new Date().toISOString();
88
- const profileHealth = new Map<string, NotificationProfileHealth>();
89
- const health: DaemonHealth = {
90
- pid: process.pid,
91
- started_at: startedAt,
92
- socket_path: socketPath,
93
- sync: {
94
- enabled: false,
95
- last_attempt_at: null,
96
- last_success_at: null,
97
- last_error_at: null,
98
- last_error: null,
99
- consecutive_failures: 0,
100
- },
101
- notifications: {},
102
- };
103
-
104
- async function log(level: 'info' | 'warn' | 'error', msg: string, data: Record<string, unknown> = {}): Promise<void> {
105
- await appendLog(logPath, JSON.stringify({ ts: new Date().toISOString(), level, component: 'commentd', msg, data }));
106
- }
107
-
108
- await ensurePrivateDir(dirname(pidPath));
109
- const existingPid = await readPid(pidPath);
110
- if (existingPid && await isProcessAlive(existingPid)) {
111
- throw new Error(`comment daemon already running with pid ${existingPid}`);
112
- }
113
- await writePid(pidPath);
114
-
115
- if (platform() !== 'win32' && existsSync(socketPath)) {
116
- const resp = await daemonRequest({ op: 'health' }, opts.homeDir, 500);
117
- if (resp.ok) throw new Error(`comment daemon already listening at ${socketPath}`);
118
- await rm(socketPath, { force: true });
119
- }
120
-
121
- const server = net.createServer((socket) => {
122
- let buffer = '';
123
- socket.on('data', (chunk) => {
124
- buffer += chunk.toString('utf-8');
125
- const idx = buffer.indexOf('\n');
126
- if (idx < 0) return;
127
- const raw = buffer.slice(0, idx);
128
- buffer = buffer.slice(idx + 1);
129
- handleSocketRequest(raw)
130
- .then((response) => socket.end(`${JSON.stringify(response)}\n`))
131
- .catch((err) => socket.end(`${JSON.stringify({ ok: false, error: String(err), code: 'INTERNAL' })}\n`));
132
- });
133
- });
134
-
135
- async function handleSocketRequest(raw: string): Promise<DaemonResponse> {
136
- let request: DaemonRequest;
137
- try {
138
- request = JSON.parse(raw) as DaemonRequest;
139
- } catch {
140
- return { ok: false, error: 'Invalid JSON', code: 'BAD_REQUEST' };
141
- }
142
-
143
- if (request.op === 'health') {
144
- health.notifications = Object.fromEntries(profileHealth);
145
- return { ok: true, op: 'health', health };
146
- }
147
- if (request.op === 'stop') {
148
- abort.abort();
149
- return { ok: true, op: 'stop' };
150
- }
151
- if (request.op === 'reload-profiles') {
152
- await log('info', 'daemon.reload_profiles_requested');
153
- return { ok: true, op: 'reload-profiles' };
154
- }
155
- if (request.op === 'ack' || request.op === 'release') {
156
- if (!request.claim_id) return { ok: false, error: 'Missing claim_id', code: 'VALIDATION_ERROR' };
157
- return proxyClaim(request.op, request.claim_id);
158
- }
159
- return { ok: false, error: `Unknown op: ${(request as { op?: string }).op}`, code: 'UNKNOWN_OP' };
160
- }
161
-
162
- async function proxyClaim(op: 'ack' | 'release', claimId: string): Promise<DaemonResponse> {
163
- const found = await findClaimFile(claimId, opts.homeDir);
164
- if (!found) return { ok: false, error: `Unknown local claim ${claimId}`, code: 'CLAIM_NOT_FOUND' };
165
- const envelope = JSON.parse(await readFile(found.path, 'utf-8')) as NotificationLeaseEnvelope;
166
- const profiles = await loadAgentProfiles({ homeDir: opts.homeDir, defaultBaseUrl: envelope.base_url });
167
- const profile = profiles.find((item) => item.handle === envelope.profile);
168
- if (!profile) return { ok: false, error: `Profile not configured: ${envelope.profile}`, code: 'PROFILE_NOT_FOUND' };
169
-
170
- const url = `${profile.baseUrl}/agents/me/notifications/claim/${encodeURIComponent(claimId)}/${op}`;
171
- const resp = await fetch(url, {
172
- method: 'POST',
173
- headers: { Authorization: `Bearer ${profile.agentSecret}` },
174
- });
175
- const body = await resp.text();
176
- if (!resp.ok) {
177
- await log('warn', `notification.${op}_failed`, { claim_id: claimId, status: resp.status, body });
178
- return { ok: false, error: body || `${op} failed`, code: `SERVER_${resp.status}` };
179
- }
180
-
181
- if (op === 'ack') await removeClaimFile(claimId, opts.homeDir);
182
- else await republishClaimFile(claimId, opts.homeDir);
183
- await log('info', `notification.${op}`, { claim_id: claimId, profile: envelope.profile });
184
- return { ok: true, op, claim_id: claimId };
185
- }
186
-
187
- await new Promise<void>((resolvePromise, reject) => {
188
- server.once('error', reject);
189
- server.listen(socketPath, () => resolvePromise());
190
- });
191
- if (platform() !== 'win32') {
192
- await import('node:fs/promises').then(({ chmod }) => chmod(socketPath, 0o600)).catch(() => {});
193
- }
194
-
195
- await log('info', 'daemon.started', { pid: process.pid, socket_path: socketPath });
196
-
197
- const loops = [
198
- runSyncLoop(opts, health, abort.signal, log),
199
- runNotificationLoops(opts, profileHealth, abort.signal, log),
200
- runLocalSweepLoop(opts.homeDir, abort.signal, log),
201
- ];
202
-
203
- const stop = async () => {
204
- abort.abort();
205
- server.close();
206
- if (platform() !== 'win32') await rm(socketPath, { force: true });
207
- await rm(pidPath, { force: true });
208
- await log('info', 'daemon.stopped');
209
- };
210
-
211
- process.once('SIGTERM', () => { void stop(); });
212
- process.once('SIGINT', () => { void stop(); });
213
-
214
- await Promise.race([
215
- Promise.allSettled(loops),
216
- new Promise<void>((resolvePromise) => abort.signal.addEventListener('abort', () => resolvePromise(), { once: true })),
217
- ]);
218
- await stop();
219
- }
220
-
221
- async function runSyncLoop(
222
- opts: Required<DaemonOptions>,
223
- health: DaemonHealth,
224
- signal: AbortSignal,
225
- log: (level: 'info' | 'warn' | 'error', msg: string, data?: Record<string, unknown>) => Promise<void>,
226
- ): Promise<void> {
227
- let lastFullSyncAt = 0;
228
- while (!signal.aborted) {
229
- const attemptedAt = new Date().toISOString();
230
- health.sync.last_attempt_at = attemptedAt;
231
- try {
232
- const configured = await readCommentFsConfig(opts.homeDir).catch(() => null);
233
- const userApiKey = process.env.COMMENT_IO_USER_API_KEY ?? configured?.userApiKey;
234
- const baseUrl = (configured?.baseUrl ?? DEFAULT_COMMENTFS_BASE_URL).replace(/\/$/, '');
235
- health.sync.enabled = Boolean(userApiKey);
236
- const full = Boolean(userApiKey && (lastFullSyncAt === 0 || Date.now() - lastFullSyncAt >= opts.fullIntervalMs));
237
- const results = userApiKey
238
- ? await syncRemoteSelectedCommentDocsSettled({ rootDir: resolve(opts.rootDir), baseUrl, userApiKey, full, homeDir: opts.homeDir })
239
- : await syncConfiguredCommentDocsSettled({ rootDir: resolve(opts.rootDir), homeDir: opts.homeDir });
240
- if (full) lastFullSyncAt = Date.now();
241
- const failures = results.filter((result) => !result.ok).length;
242
- if (failures > 0) throw new Error(`${failures} CommentFS sync item(s) failed`);
243
- health.sync.last_success_at = new Date().toISOString();
244
- health.sync.last_error = null;
245
- health.sync.last_error_at = null;
246
- health.sync.consecutive_failures = 0;
247
- await log('info', 'sync.poll_ok', { count: results.length, full });
248
- await delay(opts.intervalMs, signal);
249
- } catch (err) {
250
- health.sync.consecutive_failures += 1;
251
- health.sync.last_error_at = new Date().toISOString();
252
- health.sync.last_error = err instanceof Error ? err.message : String(err);
253
- const backoff = Math.min(opts.maxBackoffMs, Math.max(opts.intervalMs, opts.intervalMs * 2 ** Math.min(health.sync.consecutive_failures - 1, 6)));
254
- await log('warn', 'sync.poll_failed', { error: health.sync.last_error, retry_ms: backoff });
255
- await delay(backoff, signal);
256
- }
257
- }
258
- }
259
-
260
- async function runNotificationLoops(
261
- opts: Required<DaemonOptions>,
262
- profileHealth: Map<string, NotificationProfileHealth>,
263
- signal: AbortSignal,
264
- log: (level: 'info' | 'warn' | 'error', msg: string, data?: Record<string, unknown>) => Promise<void>,
265
- ): Promise<void> {
266
- const profiles = await loadAgentProfiles({ homeDir: opts.homeDir });
267
- if (profiles.length === 0) {
268
- await log('info', 'notifications.no_profiles');
269
- return;
270
- }
271
- await Promise.allSettled(profiles.map((profile) => runNotificationProfile(profile, opts.homeDir, profileHealth, signal, log)));
272
- }
273
-
274
- async function runNotificationProfile(
275
- profile: CommentAgentProfile,
276
- homeDir: string,
277
- profileHealth: Map<string, NotificationProfileHealth>,
278
- signal: AbortSignal,
279
- log: (level: 'info' | 'warn' | 'error', msg: string, data?: Record<string, unknown>) => Promise<void>,
280
- ): Promise<void> {
281
- let reconnects = 0;
282
- const health: NotificationProfileHealth = {
283
- handle: profile.handle,
284
- connected: false,
285
- lastConnectedAt: null,
286
- lastMessageAt: null,
287
- lastLeaseAt: null,
288
- lastError: null,
289
- reconnects,
290
- };
291
- profileHealth.set(profile.handle, health);
292
-
293
- while (!signal.aborted) {
294
- try {
295
- health.connected = true;
296
- health.lastConnectedAt = new Date().toISOString();
297
- health.lastError = null;
298
- const lease = await waitForServerNotification(profile, signal);
299
- health.lastMessageAt = new Date().toISOString();
300
- if (lease) {
301
- const envelope = buildQueueEnvelope({
302
- profile: profile.handle,
303
- baseUrl: profile.baseUrl,
304
- claimId: lease.claim_id,
305
- claimedAt: lease.claimed_at,
306
- leaseExpiresAt: lease.lease_expires_at,
307
- notification: lease.notification,
308
- });
309
- await writeQueueEnvelope(envelope, homeDir);
310
- health.lastLeaseAt = new Date().toISOString();
311
- await log('info', 'notifications.queued', { profile: profile.handle, claim_id: lease.claim_id, notification_id: lease.notification.id });
312
- }
313
- reconnects = 0;
314
- health.reconnects = reconnects;
315
- } catch (err) {
316
- health.lastError = err instanceof Error ? err.message : String(err);
317
- await log('warn', 'notifications.wait_failed', { profile: profile.handle, error: health.lastError });
318
- reconnects += 1;
319
- health.reconnects = reconnects;
320
- await delay(Math.min(60_000, 1000 * 2 ** Math.min(reconnects, 6)), signal);
321
- }
322
- }
323
- health.connected = false;
324
- }
325
-
326
- async function waitForServerNotification(
327
- profile: CommentAgentProfile,
328
- signal: AbortSignal,
329
- ): Promise<{
330
- claim_id: string;
331
- claimed_at: string;
332
- lease_expires_at: string;
333
- notification: NotificationPayload;
334
- } | null> {
335
- const resp = await fetch(`${profile.baseUrl}/agents/me/notifications/wait?timeout=55&lease=600`, {
336
- method: 'POST',
337
- signal,
338
- headers: {
339
- Authorization: `Bearer ${profile.agentSecret}`,
340
- 'Content-Type': 'application/json',
341
- },
342
- });
343
- if (resp.status === 204) return null;
344
- if (!resp.ok) throw new Error(`wait failed with ${resp.status}`);
345
- return resp.json() as Promise<{
346
- claim_id: string;
347
- claimed_at: string;
348
- lease_expires_at: string;
349
- notification: NotificationPayload;
350
- }>;
351
- }
352
-
353
- async function runLocalSweepLoop(
354
- homeDir: string,
355
- signal: AbortSignal,
356
- log: (level: 'info' | 'warn' | 'error', msg: string, data?: Record<string, unknown>) => Promise<void>,
357
- ): Promise<void> {
358
- while (!signal.aborted) {
359
- const swept = await sweepExpiredLocalClaims(homeDir).catch(() => 0);
360
- if (swept > 0) await log('info', 'notifications.local_claims_swept', { swept });
361
- await delay(30_000, signal);
362
- }
363
- }
364
-
365
- function delay(ms: number, signal: AbortSignal): Promise<void> {
366
- return new Promise((resolvePromise) => {
367
- if (signal.aborted) {
368
- resolvePromise();
369
- return;
370
- }
371
- const timer = setTimeout(resolvePromise, ms);
372
- signal.addEventListener('abort', () => {
373
- clearTimeout(timer);
374
- resolvePromise();
375
- }, { once: true });
376
- });
377
- }
378
-
379
- export async function startDetachedDaemon(args: string[] = []): Promise<{ pid: number | undefined }> {
380
- const script = fileURLToPath(import.meta.url);
381
- const packageRoot = resolve(dirname(script), '..');
382
- const tsxCli = resolve(packageRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs');
383
- const child = spawn(process.execPath, [tsxCli, script, 'run', ...args], {
384
- detached: true,
385
- stdio: 'ignore',
386
- });
387
- child.unref();
388
- return { pid: child.pid };
389
- }
390
-
391
- if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
392
- if (process.argv[2] === 'run') {
393
- runCommentDaemon().catch((err) => {
394
- console.error(err instanceof Error ? err.message : String(err));
395
- process.exit(1);
396
- });
397
- }
398
- }