@comment-io/cli 0.1.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/LICENSE +21 -0
- package/README.md +76 -0
- package/bin/comment.js +24 -0
- package/docs/COMMENTFS-SYNC-USAGE.md +88 -0
- package/package.json +40 -0
- package/scripts/commentd.ts +398 -0
- package/scripts/commentfs-sync.ts +586 -0
- package/shared/comment-notifications.ts +388 -0
- package/shared/commentfs-sync.ts +1558 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { homedir, platform } from 'node:os';
|
|
5
|
+
import { resolve } from 'node:path';
|
|
6
|
+
import { dirname } from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import {
|
|
9
|
+
DEFAULT_COMMENTFS_BASE_URL,
|
|
10
|
+
DEFAULT_SYNC_ROOT,
|
|
11
|
+
readCommentFsConfig,
|
|
12
|
+
explainCommentFsPath,
|
|
13
|
+
getCommentFsStatus,
|
|
14
|
+
recoverCommentFsPath,
|
|
15
|
+
repairCommentFsPermissions,
|
|
16
|
+
saveCommentFsUserApiKey,
|
|
17
|
+
syncConfiguredCommentDocs,
|
|
18
|
+
syncConfiguredCommentDocsSettled,
|
|
19
|
+
syncOneCommentDoc,
|
|
20
|
+
syncRemoteSelectedCommentDocsSettled,
|
|
21
|
+
} from '../shared/commentfs-sync.js';
|
|
22
|
+
import {
|
|
23
|
+
daemonPidPath,
|
|
24
|
+
daemonRequest,
|
|
25
|
+
isProcessAlive,
|
|
26
|
+
loadAgentProfiles,
|
|
27
|
+
readPid,
|
|
28
|
+
waitForLocalNotification,
|
|
29
|
+
} from '../shared/comment-notifications.js';
|
|
30
|
+
import { runCommentDaemon, startDetachedDaemon } from './commentd.js';
|
|
31
|
+
|
|
32
|
+
interface ParsedArgs {
|
|
33
|
+
command: string[];
|
|
34
|
+
options: Record<string, string | true>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parseArgs(argv: string[]): ParsedArgs {
|
|
38
|
+
const command: string[] = [];
|
|
39
|
+
const options: Record<string, string | true> = {};
|
|
40
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
41
|
+
const arg = argv[i];
|
|
42
|
+
if (arg.startsWith('--')) {
|
|
43
|
+
const key = arg.slice(2);
|
|
44
|
+
const next = argv[i + 1];
|
|
45
|
+
if (next && !next.startsWith('--')) {
|
|
46
|
+
options[key] = next;
|
|
47
|
+
i += 1;
|
|
48
|
+
} else {
|
|
49
|
+
options[key] = true;
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
command.push(arg);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { command, options };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function optionString(options: Record<string, string | true>, key: string): string | undefined {
|
|
59
|
+
const value = options[key];
|
|
60
|
+
return typeof value === 'string' ? value : undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function usage(): string {
|
|
64
|
+
return [
|
|
65
|
+
'Usage:',
|
|
66
|
+
' comment sync add <doc-url-or-slug> [--root <folder>] [--token <token>] [--base-url <url>] [--agent <handle>] [--filename <name.md>]',
|
|
67
|
+
' comment sync login --api-key <user-api-key> [--base-url <url>]',
|
|
68
|
+
' comment sync [--root <folder>] [--api-key <user-api-key>]',
|
|
69
|
+
' comment sync status [--root <folder>]',
|
|
70
|
+
' comment sync repair [--root <folder>]',
|
|
71
|
+
' comment sync recover <path>',
|
|
72
|
+
' comment sync watch [--root <folder>] [--interval <seconds>] [--full-interval <seconds>] [--max-backoff <seconds>] [--once] [--api-key <user-api-key>] [--base-url <url>]',
|
|
73
|
+
' comment sync explain <path>',
|
|
74
|
+
' comment daemon run|start|stop|status|health|install|uninstall [--root <folder>]',
|
|
75
|
+
' comment notifications wait --profile <handle> [--timeout 30m] [--poll 250ms]',
|
|
76
|
+
' comment notifications ack <claim-id>',
|
|
77
|
+
' comment notifications release <claim-id>',
|
|
78
|
+
'',
|
|
79
|
+
'Local markdown files are read-only projections. Edit through Comment.io UI or API.',
|
|
80
|
+
`Default root: ${DEFAULT_SYNC_ROOT}`,
|
|
81
|
+
].join('\n');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function main(): Promise<void> {
|
|
85
|
+
const { command, options } = parseArgs(process.argv.slice(2));
|
|
86
|
+
if (options.help || command[0] === 'help') {
|
|
87
|
+
console.log(usage());
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (command[0] === 'daemon') {
|
|
92
|
+
await runDaemonCommand(command.slice(1), options);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (command[0] === 'notifications') {
|
|
97
|
+
await runNotificationsCommand(command.slice(1), options);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (command[0] !== 'sync') {
|
|
102
|
+
throw new Error(`Unknown command.\n\n${usage()}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const rootDir = optionString(options, 'root');
|
|
106
|
+
|
|
107
|
+
if (command[1] === 'login') {
|
|
108
|
+
const apiKey = optionString(options, 'api-key');
|
|
109
|
+
if (!apiKey) throw new Error(`Missing --api-key.\n\n${usage()}`);
|
|
110
|
+
const saved = await saveCommentFsUserApiKey({
|
|
111
|
+
userApiKey: apiKey,
|
|
112
|
+
baseUrl: optionString(options, 'base-url'),
|
|
113
|
+
});
|
|
114
|
+
console.log(`saved: ${saved.path}`);
|
|
115
|
+
console.log(`base_url: ${saved.config.baseUrl}`);
|
|
116
|
+
if (options['no-daemon'] !== true) {
|
|
117
|
+
await maybeOfferDaemonInstall();
|
|
118
|
+
}
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (command[1] === 'add') {
|
|
123
|
+
const input = command[2];
|
|
124
|
+
if (!input) throw new Error(`Missing doc URL or slug.\n\n${usage()}`);
|
|
125
|
+
const result = await syncOneCommentDoc({
|
|
126
|
+
rootDir,
|
|
127
|
+
input,
|
|
128
|
+
token: optionString(options, 'token'),
|
|
129
|
+
baseUrl: optionString(options, 'base-url'),
|
|
130
|
+
agentHandle: optionString(options, 'agent'),
|
|
131
|
+
filename: optionString(options, 'filename'),
|
|
132
|
+
});
|
|
133
|
+
printResult(result);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (command[1] === 'explain') {
|
|
138
|
+
const inputPath = command[2];
|
|
139
|
+
if (!inputPath) throw new Error(`Missing path.\n\n${usage()}`);
|
|
140
|
+
const result = await explainCommentFsPath(inputPath);
|
|
141
|
+
console.log(result.explanation);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (command[1] === 'recover') {
|
|
146
|
+
const inputPath = command[2];
|
|
147
|
+
if (!inputPath) throw new Error(`Missing path.\n\n${usage()}`);
|
|
148
|
+
const result = await recoverCommentFsPath(inputPath);
|
|
149
|
+
console.log(result.instructions);
|
|
150
|
+
console.log(`markdown: ${result.markdownPath}`);
|
|
151
|
+
console.log(`status: ${result.statusPath}`);
|
|
152
|
+
console.log(`edit: ${result.editPath}`);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (command[1] === 'watch') {
|
|
157
|
+
await runWatch({
|
|
158
|
+
rootDir,
|
|
159
|
+
baseUrl: optionString(options, 'base-url'),
|
|
160
|
+
apiKey: optionString(options, 'api-key'),
|
|
161
|
+
intervalMs: parseIntervalMs(optionString(options, 'interval') ?? '10s'),
|
|
162
|
+
fullIntervalMs: parseIntervalMs(optionString(options, 'full-interval') ?? '5m'),
|
|
163
|
+
maxBackoffMs: parseIntervalMs(optionString(options, 'max-backoff') ?? '1m'),
|
|
164
|
+
once: options.once === true,
|
|
165
|
+
});
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (command[1] === 'status') {
|
|
170
|
+
const status = await getCommentFsStatus(rootDir);
|
|
171
|
+
console.log(`root: ${status.rootDir}`);
|
|
172
|
+
console.log(`manifest: ${status.manifestPath}`);
|
|
173
|
+
if (status.docs.length === 0) {
|
|
174
|
+
console.log('No Comment.io docs configured.');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
for (const doc of status.docs) {
|
|
178
|
+
const health = doc.syncHealth?.status ?? 'unknown';
|
|
179
|
+
const revision = doc.revision === null ? 'unknown' : String(doc.revision);
|
|
180
|
+
const mode = doc.readOnly ? 'read-only' : 'writable-or-missing';
|
|
181
|
+
console.log(`${health}: ${doc.title} (${doc.slug}) revision ${revision} ${mode}`);
|
|
182
|
+
console.log(` markdown: ${doc.markdownPath}`);
|
|
183
|
+
console.log(` status: ${doc.statusPath}`);
|
|
184
|
+
if (doc.localChange) console.log(` recovery: ${doc.localChange.recoveryFile}`);
|
|
185
|
+
if (doc.syncHealth?.lastError) console.log(` error: ${doc.syncHealth.lastError}`);
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (command[1] === 'repair') {
|
|
191
|
+
const results = await repairCommentFsPermissions(rootDir);
|
|
192
|
+
if (results.length === 0) {
|
|
193
|
+
console.log('No Comment.io docs configured.');
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
for (const result of results) {
|
|
197
|
+
if (!result.existed) console.log(`missing: ${result.title} (${result.markdownPath})`);
|
|
198
|
+
else if (result.repaired) console.log(`repaired: ${result.title} (${result.markdownPath})`);
|
|
199
|
+
else console.log(`ok: ${result.title} (${result.markdownPath})`);
|
|
200
|
+
}
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (command.length === 1) {
|
|
205
|
+
const apiKey = optionString(options, 'api-key');
|
|
206
|
+
const results = await syncConfiguredCommentDocs({ rootDir, userApiKey: apiKey });
|
|
207
|
+
if (results.length === 0) {
|
|
208
|
+
console.log('No Comment.io docs configured. Run `comment sync add <doc-url-or-slug>` first.');
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
for (const result of results) printResult(result);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
throw new Error(`Unknown sync subcommand.\n\n${usage()}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function runDaemonCommand(command: string[], options: Record<string, string | true>): Promise<void> {
|
|
219
|
+
const sub = command[0] ?? 'status';
|
|
220
|
+
if (sub === 'run') {
|
|
221
|
+
await runCommentDaemon({
|
|
222
|
+
rootDir: optionString(options, 'root'),
|
|
223
|
+
intervalMs: parseIntervalMs(optionString(options, 'interval') ?? '10s'),
|
|
224
|
+
fullIntervalMs: parseIntervalMs(optionString(options, 'full-interval') ?? '5m'),
|
|
225
|
+
maxBackoffMs: parseIntervalMs(optionString(options, 'max-backoff') ?? '1m'),
|
|
226
|
+
});
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (sub === 'start') {
|
|
231
|
+
const health = await daemonRequest({ op: 'health' });
|
|
232
|
+
if (health.ok) {
|
|
233
|
+
console.log('daemon: already running');
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const started = await startDetachedDaemon([]);
|
|
237
|
+
console.log(`daemon: starting${started.pid ? ` pid ${started.pid}` : ''}`);
|
|
238
|
+
for (let i = 0; i < 20; i++) {
|
|
239
|
+
await sleep(250);
|
|
240
|
+
const resp = await daemonRequest({ op: 'health' }, homedir(), 500);
|
|
241
|
+
if (resp.ok) {
|
|
242
|
+
console.log('daemon: running');
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
throw new Error('Daemon did not become healthy within 5s.');
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (sub === 'stop') {
|
|
250
|
+
const resp = await daemonRequest({ op: 'stop' });
|
|
251
|
+
if (resp.ok) {
|
|
252
|
+
console.log('daemon: stopping');
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const pid = await readPid(daemonPidPath());
|
|
256
|
+
if (pid && await isProcessAlive(pid)) {
|
|
257
|
+
process.kill(pid, 'SIGTERM');
|
|
258
|
+
console.log(`daemon: sent SIGTERM to ${pid}`);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
console.log('daemon: not running');
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (sub === 'health') {
|
|
266
|
+
const resp = await daemonRequest({ op: 'health' });
|
|
267
|
+
console.log(JSON.stringify(resp, null, 2));
|
|
268
|
+
if (!resp.ok) process.exitCode = 1;
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (sub === 'status') {
|
|
273
|
+
const pid = await readPid(daemonPidPath());
|
|
274
|
+
const alive = pid ? await isProcessAlive(pid) : false;
|
|
275
|
+
const resp = await daemonRequest({ op: 'health' }, homedir(), 500);
|
|
276
|
+
console.log(`pid: ${pid ?? 'none'}`);
|
|
277
|
+
console.log(`process: ${alive ? 'running' : 'not-running'}`);
|
|
278
|
+
console.log(`socket: ${resp.ok ? 'healthy' : 'unavailable'}`);
|
|
279
|
+
if (resp.ok) console.log(JSON.stringify((resp as { health?: unknown }).health, null, 2));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (sub === 'install') {
|
|
284
|
+
await installDaemonService();
|
|
285
|
+
console.log('daemon: installed');
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (sub === 'uninstall') {
|
|
290
|
+
await uninstallDaemonService();
|
|
291
|
+
console.log('daemon: uninstalled');
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
throw new Error(`Unknown daemon subcommand.\n\n${usage()}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
async function runNotificationsCommand(command: string[], options: Record<string, string | true>): Promise<void> {
|
|
299
|
+
const sub = command[0];
|
|
300
|
+
if (sub === 'wait') {
|
|
301
|
+
let profile = optionString(options, 'profile');
|
|
302
|
+
if (!profile) {
|
|
303
|
+
const profiles = await loadAgentProfiles();
|
|
304
|
+
profile = profiles[0]?.handle;
|
|
305
|
+
}
|
|
306
|
+
if (!profile) throw new Error('Missing --profile and no configured Comment.io agent profile found.');
|
|
307
|
+
|
|
308
|
+
const health = await daemonRequest({ op: 'health' }, homedir(), 500);
|
|
309
|
+
if (!health.ok) {
|
|
310
|
+
console.log(JSON.stringify({ timeout: true, error: 'comment daemon is not running', code: 'DAEMON_UNAVAILABLE' }));
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const timeoutMs = parseIntervalMs(optionString(options, 'timeout') ?? '30m');
|
|
315
|
+
const pollMs = parseIntervalMs(optionString(options, 'poll') ?? '250ms');
|
|
316
|
+
const envelope = await waitForLocalNotification({ profile, timeoutMs, pollMs });
|
|
317
|
+
if (!envelope) {
|
|
318
|
+
console.log(JSON.stringify({ timeout: true, profile }));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
console.log(JSON.stringify(envelope, null, 2));
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (sub === 'ack' || sub === 'release') {
|
|
326
|
+
const claimId = command[1];
|
|
327
|
+
if (!claimId) throw new Error(`Missing claim id.\n\n${usage()}`);
|
|
328
|
+
const resp = await daemonRequest({ op: sub, claim_id: claimId });
|
|
329
|
+
console.log(JSON.stringify(resp, null, 2));
|
|
330
|
+
if (!resp.ok) process.exitCode = 1;
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
throw new Error(`Unknown notifications subcommand.\n\n${usage()}`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function maybeOfferDaemonInstall(): Promise<void> {
|
|
338
|
+
const health = await daemonRequest({ op: 'health' }, homedir(), 500);
|
|
339
|
+
if (health.ok) return;
|
|
340
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
341
|
+
console.log('daemon: not running; run `comment daemon install` or `comment daemon start` to enable background sync and notifications.');
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
process.stdout.write('Install and start the Comment.io daemon for background sync and notifications? [y/N] ');
|
|
345
|
+
const answer = await new Promise<string>((resolvePromise) => {
|
|
346
|
+
process.stdin.once('data', (chunk) => resolvePromise(chunk.toString('utf-8').trim().toLowerCase()));
|
|
347
|
+
});
|
|
348
|
+
if (answer === 'y' || answer === 'yes') {
|
|
349
|
+
await installDaemonService();
|
|
350
|
+
} else {
|
|
351
|
+
console.log('daemon: skipped');
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function commandPaths(): { tsxCli: string; cliScript: string } {
|
|
356
|
+
const script = fileURLToPath(import.meta.url);
|
|
357
|
+
const packageRoot = resolve(dirname(script), '..');
|
|
358
|
+
return {
|
|
359
|
+
tsxCli: resolve(packageRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs'),
|
|
360
|
+
cliScript: script,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function installDaemonService(): Promise<void> {
|
|
365
|
+
const { tsxCli, cliScript } = commandPaths();
|
|
366
|
+
if (platform() === 'darwin') {
|
|
367
|
+
const plistPath = resolve(homedir(), 'Library', 'LaunchAgents', 'io.comment.daemon.plist');
|
|
368
|
+
await mkdir(dirname(plistPath), { recursive: true, mode: 0o755 });
|
|
369
|
+
const plist = [
|
|
370
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
371
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
|
|
372
|
+
'<plist version="1.0">',
|
|
373
|
+
'<dict>',
|
|
374
|
+
' <key>Label</key><string>io.comment.daemon</string>',
|
|
375
|
+
' <key>ProgramArguments</key>',
|
|
376
|
+
' <array>',
|
|
377
|
+
` <string>${escapeXml(process.execPath)}</string>`,
|
|
378
|
+
` <string>${escapeXml(tsxCli)}</string>`,
|
|
379
|
+
` <string>${escapeXml(cliScript)}</string>`,
|
|
380
|
+
' <string>daemon</string>',
|
|
381
|
+
' <string>run</string>',
|
|
382
|
+
' </array>',
|
|
383
|
+
' <key>RunAtLoad</key><true/>',
|
|
384
|
+
' <key>KeepAlive</key><true/>',
|
|
385
|
+
'</dict>',
|
|
386
|
+
'</plist>',
|
|
387
|
+
'',
|
|
388
|
+
].join('\n');
|
|
389
|
+
await writeFile(plistPath, plist, { mode: 0o644 });
|
|
390
|
+
spawnSync('launchctl', ['unload', plistPath], { stdio: 'ignore' });
|
|
391
|
+
spawnSync('launchctl', ['load', '-w', plistPath], { stdio: 'inherit' });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (platform() === 'linux') {
|
|
396
|
+
const unitDir = resolve(homedir(), '.config', 'systemd', 'user');
|
|
397
|
+
const unitPath = resolve(unitDir, 'comment-io.service');
|
|
398
|
+
await mkdir(unitDir, { recursive: true, mode: 0o755 });
|
|
399
|
+
const unit = [
|
|
400
|
+
'[Unit]',
|
|
401
|
+
'Description=Comment.io local daemon',
|
|
402
|
+
'',
|
|
403
|
+
'[Service]',
|
|
404
|
+
`ExecStart=${process.execPath} ${tsxCli} ${cliScript} daemon run`,
|
|
405
|
+
'Restart=always',
|
|
406
|
+
'RestartSec=5',
|
|
407
|
+
'',
|
|
408
|
+
'[Install]',
|
|
409
|
+
'WantedBy=default.target',
|
|
410
|
+
'',
|
|
411
|
+
].join('\n');
|
|
412
|
+
await writeFile(unitPath, unit, { mode: 0o644 });
|
|
413
|
+
spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'inherit' });
|
|
414
|
+
spawnSync('systemctl', ['--user', 'enable', '--now', 'comment-io.service'], { stdio: 'inherit' });
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
await runDaemonCommand(['start'], {});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
async function uninstallDaemonService(): Promise<void> {
|
|
422
|
+
if (platform() === 'darwin') {
|
|
423
|
+
const plistPath = resolve(homedir(), 'Library', 'LaunchAgents', 'io.comment.daemon.plist');
|
|
424
|
+
spawnSync('launchctl', ['unload', plistPath], { stdio: 'ignore' });
|
|
425
|
+
await rm(plistPath, { force: true });
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (platform() === 'linux') {
|
|
429
|
+
spawnSync('systemctl', ['--user', 'disable', '--now', 'comment-io.service'], { stdio: 'ignore' });
|
|
430
|
+
await rm(resolve(homedir(), '.config', 'systemd', 'user', 'comment-io.service'), { force: true });
|
|
431
|
+
spawnSync('systemctl', ['--user', 'daemon-reload'], { stdio: 'ignore' });
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function escapeXml(value: string): string {
|
|
436
|
+
return value
|
|
437
|
+
.replace(/&/g, '&')
|
|
438
|
+
.replace(/</g, '<')
|
|
439
|
+
.replace(/>/g, '>')
|
|
440
|
+
.replace(/"/g, '"');
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function printResult(result: {
|
|
444
|
+
ok: boolean;
|
|
445
|
+
title: string;
|
|
446
|
+
revision?: number;
|
|
447
|
+
changed?: boolean;
|
|
448
|
+
localChangeDetected?: boolean;
|
|
449
|
+
recoveryPath?: string;
|
|
450
|
+
disabled?: boolean;
|
|
451
|
+
markdownPath: string;
|
|
452
|
+
statusPath: string;
|
|
453
|
+
error?: string;
|
|
454
|
+
}): void {
|
|
455
|
+
if (!result.ok) {
|
|
456
|
+
console.log(`error: ${result.title}`);
|
|
457
|
+
console.log(`markdown: ${result.markdownPath}`);
|
|
458
|
+
console.log(`status: ${result.statusPath}`);
|
|
459
|
+
console.log(`message: ${result.error ?? 'unknown error'}`);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (result.disabled) {
|
|
463
|
+
console.log(`unselected: ${result.title}`);
|
|
464
|
+
console.log(`removed: ${result.markdownPath}`);
|
|
465
|
+
console.log(`status: ${result.statusPath}`);
|
|
466
|
+
if (result.recoveryPath) console.log(`recovery: ${result.recoveryPath}`);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
const state = result.localChangeDetected ? 'recovered-local-change' : result.changed ? 'updated' : 'unchanged';
|
|
470
|
+
console.log(`${state}: ${result.title} (revision ${result.revision})`);
|
|
471
|
+
console.log(`markdown: ${result.markdownPath}`);
|
|
472
|
+
console.log(`status: ${result.statusPath}`);
|
|
473
|
+
if (result.recoveryPath) console.log(`recovery: ${result.recoveryPath}`);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function runWatch(options: {
|
|
477
|
+
rootDir?: string;
|
|
478
|
+
baseUrl?: string;
|
|
479
|
+
apiKey?: string;
|
|
480
|
+
intervalMs: number;
|
|
481
|
+
fullIntervalMs: number;
|
|
482
|
+
maxBackoffMs: number;
|
|
483
|
+
once: boolean;
|
|
484
|
+
}): Promise<void> {
|
|
485
|
+
const configured = await readCommentFsConfig().catch(() => null);
|
|
486
|
+
const apiKey = options.apiKey ?? process.env.COMMENT_IO_USER_API_KEY ?? configured?.userApiKey;
|
|
487
|
+
const baseUrl = (options.baseUrl ?? configured?.baseUrl ?? DEFAULT_COMMENTFS_BASE_URL).replace(/\/$/, '');
|
|
488
|
+
const rootDir = resolve(options.rootDir ?? DEFAULT_SYNC_ROOT);
|
|
489
|
+
const authMode = apiKey ? 'user_api_key' : 'configured_doc_credentials';
|
|
490
|
+
const maxBackoffMs = Math.max(options.maxBackoffMs, options.intervalMs);
|
|
491
|
+
let lastFullSyncAt = 0;
|
|
492
|
+
let consecutiveFailures = 0;
|
|
493
|
+
let emptyPolls = 0;
|
|
494
|
+
|
|
495
|
+
console.log('watch: starting CommentFS read-only projection sync');
|
|
496
|
+
console.log(`root: ${rootDir}`);
|
|
497
|
+
console.log(`base_url: ${apiKey ? baseUrl : 'per-doc manifest URLs'}`);
|
|
498
|
+
console.log(`auth_mode: ${authMode}`);
|
|
499
|
+
console.log(`interval: ${formatDuration(options.intervalMs)}`);
|
|
500
|
+
console.log(`full_reconcile_interval: ${apiKey ? formatDuration(options.fullIntervalMs) : 'not used without a user API key'}`);
|
|
501
|
+
console.log(`max_backoff: ${formatDuration(maxBackoffMs)}`);
|
|
502
|
+
|
|
503
|
+
do {
|
|
504
|
+
try {
|
|
505
|
+
const full = Boolean(apiKey && (lastFullSyncAt === 0 || Date.now() - lastFullSyncAt >= options.fullIntervalMs));
|
|
506
|
+
const results = apiKey
|
|
507
|
+
? await syncRemoteSelectedCommentDocsSettled({
|
|
508
|
+
rootDir,
|
|
509
|
+
baseUrl,
|
|
510
|
+
userApiKey: apiKey,
|
|
511
|
+
full,
|
|
512
|
+
})
|
|
513
|
+
: await syncConfiguredCommentDocsSettled({ rootDir });
|
|
514
|
+
if (full) lastFullSyncAt = Date.now();
|
|
515
|
+
|
|
516
|
+
const failures = results.filter((result) => !result.ok).length;
|
|
517
|
+
if (failures === 0) consecutiveFailures = 0;
|
|
518
|
+
else consecutiveFailures += 1;
|
|
519
|
+
|
|
520
|
+
if (results.length === 0) {
|
|
521
|
+
emptyPolls += 1;
|
|
522
|
+
if (emptyPolls === 1 || emptyPolls % 6 === 0 || options.once) {
|
|
523
|
+
console.log(`${watchStamp()} ${apiKey
|
|
524
|
+
? 'No remote Comment.io sync changes.'
|
|
525
|
+
: 'No Comment.io docs configured. Run `comment sync add <doc-url-or-slug>` first.'}`);
|
|
526
|
+
}
|
|
527
|
+
} else {
|
|
528
|
+
emptyPolls = 0;
|
|
529
|
+
console.log(`${watchStamp()} ${full ? 'full reconcile' : 'poll'} returned ${results.length} result${results.length === 1 ? '' : 's'}.`);
|
|
530
|
+
for (const result of results) printResult(result);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (options.once) {
|
|
534
|
+
if (failures > 0) process.exitCode = 1;
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
await sleep(nextWatchDelay(options.intervalMs, maxBackoffMs, consecutiveFailures));
|
|
538
|
+
} catch (error) {
|
|
539
|
+
consecutiveFailures += 1;
|
|
540
|
+
const delayMs = nextWatchDelay(options.intervalMs, maxBackoffMs, consecutiveFailures);
|
|
541
|
+
console.error(`${watchStamp()} sync poll failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
542
|
+
if (options.once) {
|
|
543
|
+
process.exitCode = 1;
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
console.error(`${watchStamp()} retrying in ${formatDuration(delayMs)}.`);
|
|
547
|
+
await sleep(delayMs);
|
|
548
|
+
}
|
|
549
|
+
} while (true);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function parseIntervalMs(value: string): number {
|
|
553
|
+
const trimmed = value.trim().toLowerCase();
|
|
554
|
+
const match = /^(\d+(?:\.\d+)?)(ms|s|m)?$/.exec(trimmed);
|
|
555
|
+
if (!match) throw new Error(`Invalid interval: ${value}`);
|
|
556
|
+
const amount = Number(match[1]);
|
|
557
|
+
const unit = match[2] ?? 's';
|
|
558
|
+
const ms = unit === 'm' ? amount * 60_000 : unit === 's' ? amount * 1000 : amount;
|
|
559
|
+
if (!Number.isFinite(ms) || ms < 100) throw new Error('Interval must be at least 100ms.');
|
|
560
|
+
return Math.round(ms);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function nextWatchDelay(intervalMs: number, maxBackoffMs: number, consecutiveFailures: number): number {
|
|
564
|
+
if (consecutiveFailures <= 0) return intervalMs;
|
|
565
|
+
const multiplier = 2 ** Math.min(consecutiveFailures - 1, 6);
|
|
566
|
+
return Math.min(maxBackoffMs, Math.max(intervalMs, intervalMs * multiplier));
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function formatDuration(ms: number): string {
|
|
570
|
+
if (ms % 60_000 === 0) return `${ms / 60_000}m`;
|
|
571
|
+
if (ms % 1000 === 0) return `${ms / 1000}s`;
|
|
572
|
+
return `${ms}ms`;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function watchStamp(): string {
|
|
576
|
+
return `[${new Date().toISOString()}]`;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function sleep(ms: number): Promise<void> {
|
|
580
|
+
return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
main().catch((error) => {
|
|
584
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
585
|
+
process.exit(1);
|
|
586
|
+
});
|