@comment-io/cli 0.1.1-alpha.9 → 0.1.2

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,1565 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
- import { existsSync } from 'node:fs';
3
- import { chmod, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
4
- import { homedir } from 'node:os';
5
- import { basename, dirname, extname, join, relative, resolve } from 'node:path';
6
-
7
- export const COMMENTFS_VERSION = 1;
8
- export const DEFAULT_COMMENTFS_BASE_URL = 'https://comment.io';
9
- export const DEFAULT_SYNC_ROOT = join(homedir(), 'Comment Docs');
10
- export const COMMENT_DIR = '.comment';
11
- export const MANIFEST_FILE = 'manifest.json';
12
- export const CREDENTIALS_FILE = 'credentials.json';
13
- export const COMMENTFS_CONFIG_FILE = 'commentfs.json';
14
-
15
- export interface CommentFsManifestDoc {
16
- slug: string;
17
- title: string;
18
- file: string;
19
- sidecarDir?: string;
20
- statusFile: string;
21
- editFile: string;
22
- authorshipFile?: string;
23
- commentsFile?: string;
24
- participantsFile?: string;
25
- sourceUrl: string;
26
- apiUrl: string;
27
- apiDocsUrl: string;
28
- baseUrl: string;
29
- token?: string;
30
- addedAt: string;
31
- lastSyncedAt?: string;
32
- lastRevision?: number;
33
- lastMarkdownSha256?: string;
34
- lastCanonicalMarkdownSha256?: string;
35
- lastLocalChange?: CommentFsLocalChange;
36
- lastSyncHealth?: CommentFsSyncHealth;
37
- authMode?: 'user_api_key';
38
- }
39
-
40
- export interface CommentFsRecoveryRecord {
41
- slug: string;
42
- title: string;
43
- file: string;
44
- statusFile: string;
45
- editFile: string;
46
- sourceUrl: string;
47
- apiUrl: string;
48
- apiDocsUrl: string;
49
- localChange: CommentFsLocalChange;
50
- removedAt: string;
51
- }
52
-
53
- export interface CommentFsLocalChange {
54
- detectedAt: string;
55
- recoveryFile: string;
56
- restoredCanonicalRevision: number;
57
- }
58
-
59
- export interface CommentFsManifest {
60
- version: 1;
61
- docs: CommentFsManifestDoc[];
62
- recoveries?: CommentFsRecoveryRecord[];
63
- remoteSyncCursor?: number;
64
- }
65
-
66
- export interface CommentFsConfig {
67
- version: 1;
68
- baseUrl: string;
69
- userApiKey?: string;
70
- updatedAt: string;
71
- }
72
-
73
- interface CommentFsCredentials {
74
- version: 1;
75
- docs: Record<string, { token: string; updatedAt: string }>;
76
- }
77
-
78
- export interface CommentFsSyncHealth {
79
- status: 'ok' | 'error';
80
- lastAttemptAt: string;
81
- lastSuccessAt?: string;
82
- lastErrorAt?: string;
83
- lastError?: string;
84
- }
85
-
86
- export interface CommentFsDocResponse {
87
- id?: string;
88
- slug?: string;
89
- title?: string;
90
- markdown: string;
91
- revision: number;
92
- your_role?: string;
93
- read_only?: boolean;
94
- updated_at?: string;
95
- authorship?: unknown;
96
- blocks?: unknown;
97
- comment_meta?: unknown;
98
- participants?: unknown;
99
- actors?: unknown;
100
- your_token?: string;
101
- }
102
-
103
- export interface CommentFsDocRef {
104
- slug: string;
105
- token?: string;
106
- baseUrl: string;
107
- sourceUrl: string;
108
- }
109
-
110
- class CommentFsDeletedError extends Error {
111
- constructor(message: string) {
112
- super(message);
113
- this.name = 'CommentFsDeletedError';
114
- }
115
- }
116
-
117
- export interface SyncDocOptions {
118
- rootDir?: string;
119
- input: string;
120
- token?: string;
121
- userApiKey?: string;
122
- baseUrl?: string;
123
- agentHandle?: string;
124
- filename?: string;
125
- now?: () => Date;
126
- fetchImpl?: typeof fetch;
127
- homeDir?: string;
128
- }
129
-
130
- export interface SyncConfiguredOptions {
131
- rootDir?: string;
132
- baseUrl?: string;
133
- userApiKey?: string;
134
- full?: boolean;
135
- now?: () => Date;
136
- fetchImpl?: typeof fetch;
137
- homeDir?: string;
138
- }
139
-
140
- export interface SyncDocResult {
141
- ok: true;
142
- slug: string;
143
- title: string;
144
- markdownPath: string;
145
- statusPath: string;
146
- editPath: string;
147
- manifestPath: string;
148
- revision: number;
149
- changed: boolean;
150
- localChangeDetected: boolean;
151
- recoveryPath?: string;
152
- }
153
-
154
- export interface SyncDocFailure {
155
- ok: false;
156
- slug: string;
157
- title: string;
158
- markdownPath: string;
159
- statusPath: string;
160
- editPath: string;
161
- manifestPath: string;
162
- error: string;
163
- }
164
-
165
- export interface SyncDocUnselectedResult {
166
- ok: true;
167
- slug: string;
168
- title: string;
169
- markdownPath: string;
170
- statusPath: string;
171
- editPath: string;
172
- manifestPath: string;
173
- revision: number;
174
- changed: true;
175
- localChangeDetected: boolean;
176
- recoveryPath?: string;
177
- disabled: true;
178
- }
179
-
180
- export interface ExplainPathResult {
181
- rootDir: string;
182
- inputPath: string;
183
- slug: string;
184
- title: string;
185
- markdownPath: string;
186
- statusPath: string;
187
- editPath: string;
188
- sourceUrl: string;
189
- apiUrl: string;
190
- apiDocsUrl: string;
191
- explanation: string;
192
- }
193
-
194
- export interface RecoverPathResult {
195
- rootDir: string;
196
- inputPath: string;
197
- slug: string;
198
- title: string;
199
- markdownPath: string;
200
- statusPath: string;
201
- editPath: string;
202
- recoveryPath: string;
203
- apiUrl: string;
204
- apiDocsUrl: string;
205
- detectedAt: string;
206
- restoredCanonicalRevision: number;
207
- instructions: string;
208
- }
209
-
210
- export interface CommentFsStatusDoc {
211
- slug: string;
212
- title: string;
213
- markdownPath: string;
214
- statusPath: string;
215
- editPath: string;
216
- sourceUrl: string;
217
- apiUrl: string;
218
- apiDocsUrl: string;
219
- revision: number | null;
220
- lastSyncedAt: string | null;
221
- readOnly: boolean;
222
- syncHealth: CommentFsSyncHealth | null;
223
- localChange: CommentFsLocalChange | null;
224
- }
225
-
226
- export interface CommentFsStatus {
227
- rootDir: string;
228
- manifestPath: string;
229
- docs: CommentFsStatusDoc[];
230
- }
231
-
232
- export interface CommentFsRepairResult {
233
- slug: string;
234
- title: string;
235
- markdownPath: string;
236
- existed: boolean;
237
- repaired: boolean;
238
- }
239
-
240
- interface FetchDocOptions {
241
- ref: CommentFsDocRef;
242
- token?: string;
243
- userApiKey?: string;
244
- agentHandle?: string;
245
- fetchImpl: typeof fetch;
246
- homeDir: string;
247
- }
248
-
249
- interface RemoteSyncDoc {
250
- slug: string;
251
- enabled?: boolean;
252
- title?: string | null;
253
- last_known_revision?: number | null;
254
- }
255
-
256
- interface RemoteSyncChangesResponse {
257
- cursor: number;
258
- sync_docs: RemoteSyncDoc[];
259
- }
260
-
261
- export function parseCommentDocRef(input: string, defaultBaseUrl = DEFAULT_COMMENTFS_BASE_URL): CommentFsDocRef {
262
- const trimmed = input.trim();
263
- if (!trimmed) throw new Error('A Comment.io doc URL or slug is required.');
264
-
265
- try {
266
- const url = new URL(trimmed);
267
- const parts = url.pathname.split('/').filter(Boolean);
268
- let slug = '';
269
- if ((parts[0] === 'd' || parts[0] === 'docs') && parts[1]) slug = decodeURIComponent(parts[1]);
270
- else if (parts.length === 1) slug = decodeURIComponent(parts[0]);
271
- if (!slug) throw new Error(`Could not find a document slug in ${trimmed}`);
272
- if (!/^[A-Za-z0-9_-]+$/.test(slug)) {
273
- throw new Error(`Invalid Comment.io slug: ${slug}`);
274
- }
275
- const sourceUrl = new URL(url.toString());
276
- sourceUrl.username = '';
277
- sourceUrl.password = '';
278
- sourceUrl.searchParams.delete('token');
279
- sourceUrl.searchParams.delete('access_token');
280
- return {
281
- slug,
282
- token: url.searchParams.get('token') ?? undefined,
283
- baseUrl: url.origin.replace(/\/$/, ''),
284
- sourceUrl: sourceUrl.toString(),
285
- };
286
- } catch (error) {
287
- if (error instanceof TypeError) {
288
- const slug = trimmed.replace(/^@/, '');
289
- if (!/^[A-Za-z0-9_-]+$/.test(slug)) {
290
- throw new Error(`Invalid Comment.io slug: ${trimmed}`);
291
- }
292
- const baseUrl = defaultBaseUrl.replace(/\/$/, '');
293
- return {
294
- slug,
295
- baseUrl,
296
- sourceUrl: `${baseUrl}/d/${encodeURIComponent(slug)}`,
297
- };
298
- }
299
- throw error;
300
- }
301
- }
302
-
303
- export function safeMarkdownFilename(title: string | undefined, slug: string): string {
304
- const seed = (title?.trim() || slug).normalize('NFKD');
305
- const stem = seed
306
- .replace(/[\u0300-\u036f]/g, '')
307
- .toLowerCase()
308
- .replace(/[^a-z0-9._ -]+/g, '')
309
- .replace(/\s+/g, '-')
310
- .replace(/[. -]+$/g, '')
311
- .replace(/^[. -]+/g, '')
312
- .slice(0, 80) || slug;
313
- return stem.endsWith('.md') ? stem : `${stem}.md`;
314
- }
315
-
316
- function validateProjectionFilename(filename: string): string {
317
- const trimmed = filename.trim();
318
- if (
319
- !trimmed ||
320
- trimmed !== basename(trimmed) ||
321
- trimmed.includes('\\') ||
322
- trimmed === '.' ||
323
- trimmed === '..'
324
- ) {
325
- throw new Error(`Invalid CommentFS projection filename: ${filename}`);
326
- }
327
- return trimmed.endsWith('.md') ? trimmed : `${trimmed}.md`;
328
- }
329
-
330
- export function manifestPath(rootDir: string): string {
331
- return join(rootDir, COMMENT_DIR, MANIFEST_FILE);
332
- }
333
-
334
- function credentialsPath(rootDir: string): string {
335
- return join(rootDir, COMMENT_DIR, CREDENTIALS_FILE);
336
- }
337
-
338
- export async function readManifest(rootDir: string): Promise<CommentFsManifest> {
339
- const path = manifestPath(rootDir);
340
- if (!existsSync(path)) return { version: COMMENTFS_VERSION, docs: [] };
341
- const parsed = JSON.parse(await readFile(path, 'utf-8')) as CommentFsManifest;
342
- if (parsed.version !== COMMENTFS_VERSION || !Array.isArray(parsed.docs)) {
343
- throw new Error(`Unsupported CommentFS manifest at ${path}`);
344
- }
345
- return parsed;
346
- }
347
-
348
- export async function writeManifest(rootDir: string, manifest: CommentFsManifest): Promise<void> {
349
- const path = manifestPath(rootDir);
350
- await mkdir(dirname(path), { recursive: true, mode: 0o755 });
351
- const sanitized: CommentFsManifest = {
352
- ...manifest,
353
- docs: manifest.docs.map(({ token: _token, ...doc }) => doc),
354
- recoveries: manifest.recoveries?.slice(-100),
355
- };
356
- await writeJsonIfChanged(path, sanitized);
357
- }
358
-
359
- async function readCredentials(rootDir: string): Promise<CommentFsCredentials> {
360
- const path = credentialsPath(rootDir);
361
- if (!existsSync(path)) return { version: COMMENTFS_VERSION, docs: {} };
362
- const parsed = JSON.parse(await readFile(path, 'utf-8')) as CommentFsCredentials;
363
- if (parsed.version !== COMMENTFS_VERSION || !parsed.docs || typeof parsed.docs !== 'object') {
364
- throw new Error(`Unsupported CommentFS credentials at ${path}`);
365
- }
366
- return parsed;
367
- }
368
-
369
- async function readDocCredential(rootDir: string, slug: string): Promise<string | undefined> {
370
- const credentials = await readCredentials(rootDir);
371
- return credentials.docs[slug]?.token;
372
- }
373
-
374
- async function saveDocCredential(rootDir: string, slug: string, token: string, now: string): Promise<void> {
375
- const credentials = await readCredentials(rootDir);
376
- if (credentials.docs[slug]?.token === token) {
377
- const path = credentialsPath(rootDir);
378
- if (existsSync(path)) await chmod(path, 0o600);
379
- return;
380
- }
381
- credentials.docs[slug] = { token, updatedAt: now };
382
- await writePrivateJsonIfChanged(credentialsPath(rootDir), credentials);
383
- }
384
-
385
- async function deleteDocCredential(rootDir: string, slug: string): Promise<void> {
386
- const path = credentialsPath(rootDir);
387
- if (!existsSync(path)) return;
388
- const credentials = await readCredentials(rootDir);
389
- if (!credentials.docs[slug]) return;
390
- delete credentials.docs[slug];
391
- await writePrivateJsonIfChanged(path, credentials);
392
- }
393
-
394
- export function commentFsConfigPath(homeDir = homedir()): string {
395
- return join(homeDir, '.comment-io', COMMENTFS_CONFIG_FILE);
396
- }
397
-
398
- export async function readCommentFsConfig(homeDir = homedir()): Promise<CommentFsConfig | null> {
399
- const path = commentFsConfigPath(homeDir);
400
- if (!existsSync(path)) return null;
401
- const parsed = JSON.parse(await readFile(path, 'utf-8')) as CommentFsConfig;
402
- if (parsed.version !== COMMENTFS_VERSION) {
403
- throw new Error(`Unsupported CommentFS config at ${path}`);
404
- }
405
- return parsed;
406
- }
407
-
408
- export async function saveCommentFsUserApiKey(options: {
409
- userApiKey: string;
410
- baseUrl?: string;
411
- homeDir?: string;
412
- now?: () => Date;
413
- }): Promise<{ path: string; config: CommentFsConfig }> {
414
- const path = commentFsConfigPath(options.homeDir ?? homedir());
415
- const config: CommentFsConfig = {
416
- version: COMMENTFS_VERSION,
417
- baseUrl: (options.baseUrl ?? DEFAULT_COMMENTFS_BASE_URL).replace(/\/$/, ''),
418
- userApiKey: options.userApiKey.trim(),
419
- updatedAt: (options.now ?? (() => new Date()))().toISOString(),
420
- };
421
- await writePrivateJsonIfChanged(path, config);
422
- return { path, config };
423
- }
424
-
425
- export async function deleteCommentFsUserApiKey(homeDir = homedir()): Promise<{ path: string; removed: boolean }> {
426
- const path = commentFsConfigPath(homeDir);
427
- const removed = existsSync(path);
428
- await rm(path, { force: true });
429
- return { path, removed };
430
- }
431
-
432
- export async function syncOneCommentDoc(options: SyncDocOptions): Promise<SyncDocResult> {
433
- const rootDir = resolve(options.rootDir ?? DEFAULT_SYNC_ROOT);
434
- const now = options.now ?? (() => new Date());
435
- const fetchImpl = options.fetchImpl ?? fetch;
436
- const homeDir = options.homeDir ?? homedir();
437
- const ref = parseCommentDocRef(options.input, options.baseUrl ?? DEFAULT_COMMENTFS_BASE_URL);
438
- const explicitToken = options.token;
439
-
440
- await ensureRoot(rootDir);
441
- const manifest = await readManifest(rootDir);
442
- const existing = manifest.docs.find((doc) => doc.slug === ref.slug);
443
- const useUserApiKeyAuth = Boolean(options.userApiKey) || existing?.authMode === 'user_api_key';
444
- if (existing?.authMode === 'user_api_key' && !options.userApiKey) {
445
- throw new Error('This CommentFS document requires a configured user API key. Run `comment sync login --api-key <user-api-key>` or set COMMENT_IO_USER_API_KEY.');
446
- }
447
- const storedToken = await readDocCredential(rootDir, ref.slug).catch(() => undefined);
448
- const doc = await fetchCommentDoc({
449
- ref,
450
- token: useUserApiKeyAuth ? explicitToken : explicitToken ?? storedToken ?? existing?.token,
451
- userApiKey: options.userApiKey,
452
- agentHandle: options.agentHandle,
453
- fetchImpl,
454
- homeDir,
455
- });
456
- const title = doc.title?.trim() || ref.slug;
457
- const file = options.filename
458
- ? validateProjectionFilename(options.filename)
459
- : existing?.file
460
- ? validateProjectionFilename(existing.file)
461
- : await chooseMarkdownFile(rootDir, manifest, title, ref.slug);
462
- const markdownPath = join(rootDir, file);
463
- const syncedAt = now().toISOString();
464
- const sidecars = sidecarPaths(ref.slug);
465
- const canonicalMarkdownSha = sha256(doc.markdown);
466
- const projectedMarkdown = buildMarkdownProjection(doc.markdown, {
467
- title,
468
- sourceUrl: ref.sourceUrl,
469
- statusPath: join(COMMENT_DIR, sidecars.statusFile),
470
- editPath: join(COMMENT_DIR, sidecars.editFile),
471
- });
472
- const projectionMarkdownSha = sha256(projectedMarkdown);
473
- const projectionExists = await fileExists(markdownPath);
474
- const localMarkdown = projectionExists ? await readFile(markdownPath, 'utf-8') : undefined;
475
- const localChangeDetected = Boolean(
476
- existing?.lastMarkdownSha256 &&
477
- localMarkdown !== undefined &&
478
- sha256(localMarkdown) !== existing.lastMarkdownSha256,
479
- );
480
- const canonicalChanged =
481
- !existing ||
482
- existing.lastRevision !== doc.revision ||
483
- (existing.lastCanonicalMarkdownSha256 ?? existing.lastMarkdownSha256) !== canonicalMarkdownSha ||
484
- existing.lastMarkdownSha256 !== projectionMarkdownSha ||
485
- !projectionExists;
486
- const changed = canonicalChanged || localChangeDetected;
487
- const refreshHealth = changed || existing?.lastSyncHealth?.status === 'error' || !existing?.lastSyncHealth;
488
- const newLocalChange = localChangeDetected && localMarkdown !== undefined
489
- ? await preserveLocalChange(rootDir, ref.slug, localMarkdown, syncedAt, doc.revision)
490
- : undefined;
491
- const localChange = newLocalChange ?? existing?.lastLocalChange;
492
-
493
- const nextDoc: CommentFsManifestDoc = {
494
- slug: ref.slug,
495
- title,
496
- file,
497
- sidecarDir: sidecars.sidecarDir,
498
- statusFile: sidecars.statusFile,
499
- editFile: sidecars.editFile,
500
- authorshipFile: sidecars.authorshipFile,
501
- commentsFile: sidecars.commentsFile,
502
- participantsFile: sidecars.participantsFile,
503
- sourceUrl: ref.sourceUrl,
504
- apiUrl: `${ref.baseUrl}/docs/${encodeURIComponent(ref.slug)}`,
505
- apiDocsUrl: `${ref.baseUrl}/docs/${encodeURIComponent(ref.slug)}?docs`,
506
- baseUrl: ref.baseUrl,
507
- addedAt: existing?.addedAt ?? syncedAt,
508
- lastSyncedAt: changed ? syncedAt : existing?.lastSyncedAt,
509
- lastRevision: doc.revision,
510
- lastMarkdownSha256: projectionMarkdownSha,
511
- lastCanonicalMarkdownSha256: canonicalMarkdownSha,
512
- lastLocalChange: localChange,
513
- authMode: useUserApiKeyAuth && !explicitToken ? 'user_api_key' : existing?.authMode,
514
- lastSyncHealth: refreshHealth
515
- ? {
516
- status: 'ok',
517
- lastAttemptAt: syncedAt,
518
- lastSuccessAt: syncedAt,
519
- }
520
- : existing.lastSyncHealth,
521
- };
522
-
523
- if (changed) {
524
- await writeReadOnlyProjection(markdownPath, projectedMarkdown);
525
- } else {
526
- await ensureReadOnly(markdownPath);
527
- }
528
-
529
- if (existing) await removeSupersededSidecars(rootDir, existing, nextDoc);
530
- const credentialToken = useUserApiKeyAuth
531
- ? undefined
532
- : doc.your_token ?? explicitToken ?? storedToken ?? existing?.token ?? ref.token;
533
- if (useUserApiKeyAuth) await deleteDocCredential(rootDir, ref.slug);
534
- else if (credentialToken) await saveDocCredential(rootDir, ref.slug, credentialToken, syncedAt);
535
- if (newLocalChange) indexRecoveryRecord(manifest, nextDoc, newLocalChange, syncedAt);
536
- await writeRootReadme(rootDir);
537
- await writeDocSidecars(rootDir, nextDoc, doc, canonicalMarkdownSha, projectionMarkdownSha, changed ? syncedAt : nextDoc.lastSyncedAt ?? syncedAt);
538
-
539
- const idx = manifest.docs.findIndex((entry) => entry.slug === ref.slug);
540
- if (idx >= 0) manifest.docs[idx] = nextDoc;
541
- else manifest.docs.push(nextDoc);
542
- sortManifest(manifest);
543
- await writeManifest(rootDir, manifest);
544
-
545
- return {
546
- ok: true,
547
- slug: ref.slug,
548
- title,
549
- markdownPath,
550
- statusPath: join(rootDir, COMMENT_DIR, nextDoc.statusFile),
551
- editPath: join(rootDir, COMMENT_DIR, nextDoc.editFile),
552
- manifestPath: manifestPath(rootDir),
553
- revision: doc.revision,
554
- changed,
555
- localChangeDetected,
556
- recoveryPath: localChangeDetected && localChange ? join(rootDir, localChange.recoveryFile) : undefined,
557
- };
558
- }
559
-
560
- export async function syncConfiguredCommentDocs(options: SyncConfiguredOptions = {}): Promise<SyncDocResult[]> {
561
- const results = await syncConfiguredCommentDocsSettled(options);
562
- const firstFailure = results.find((result): result is SyncDocFailure => !result.ok);
563
- if (firstFailure) throw new Error(firstFailure.error);
564
- return results.filter((result): result is SyncDocResult => result.ok);
565
- }
566
-
567
- export async function syncConfiguredCommentDocsSettled(
568
- options: SyncConfiguredOptions = {},
569
- ): Promise<Array<SyncDocResult | SyncDocFailure>> {
570
- const rootDir = resolve(options.rootDir ?? DEFAULT_SYNC_ROOT);
571
- const manifest = await readManifest(rootDir);
572
- const results: Array<SyncDocResult | SyncDocFailure> = [];
573
- const credentials = await readCredentials(rootDir).catch(() => ({ version: COMMENTFS_VERSION, docs: {} }) as CommentFsCredentials);
574
- const config = await readCommentFsConfig(options.homeDir ?? homedir()).catch(() => null);
575
- const fallbackUserApiKey = options.userApiKey ?? process.env.COMMENT_IO_USER_API_KEY ?? config?.userApiKey;
576
- for (const manifestDoc of manifest.docs) {
577
- try {
578
- const userApiKey = manifestDoc.authMode === 'user_api_key' ? fallbackUserApiKey : options.userApiKey;
579
- if (manifestDoc.authMode === 'user_api_key' && !userApiKey) {
580
- throw new Error('This CommentFS document requires a configured user API key. Run `comment sync login --api-key <user-api-key>` or set COMMENT_IO_USER_API_KEY.');
581
- }
582
- results.push(await syncOneCommentDoc({
583
- rootDir,
584
- input: manifestDoc.sourceUrl || manifestDoc.slug,
585
- token: userApiKey ? undefined : credentials.docs[manifestDoc.slug]?.token ?? manifestDoc.token,
586
- userApiKey,
587
- baseUrl: manifestDoc.baseUrl,
588
- filename: manifestDoc.file,
589
- now: options.now,
590
- fetchImpl: options.fetchImpl,
591
- homeDir: options.homeDir,
592
- }));
593
- } catch (error) {
594
- const attemptedAt = (options.now ?? (() => new Date()))().toISOString();
595
- if (error instanceof CommentFsDeletedError) {
596
- results.push(await removeUnselectedProjection(rootDir, await readManifest(rootDir), manifestDoc, attemptedAt));
597
- continue;
598
- }
599
- const message = error instanceof Error ? error.message : String(error);
600
- results.push(await recordSyncFailure(rootDir, manifestDoc, attemptedAt, message));
601
- }
602
- }
603
- return results;
604
- }
605
-
606
- export async function syncRemoteSelectedCommentDocsSettled(
607
- options: SyncConfiguredOptions = {},
608
- ): Promise<Array<SyncDocResult | SyncDocFailure | SyncDocUnselectedResult>> {
609
- const rootDir = resolve(options.rootDir ?? DEFAULT_SYNC_ROOT);
610
- const config = await readCommentFsConfig(options.homeDir ?? homedir()).catch(() => null);
611
- const baseUrl = (options.baseUrl ?? config?.baseUrl ?? DEFAULT_COMMENTFS_BASE_URL).replace(/\/$/, '');
612
- const userApiKey = options.userApiKey ?? process.env.COMMENT_IO_USER_API_KEY ?? config?.userApiKey;
613
- if (!userApiKey) {
614
- throw new Error('Remote CommentFS sync polling requires --api-key or COMMENT_IO_USER_API_KEY.');
615
- }
616
-
617
- await ensureRoot(rootDir);
618
- let manifest = await readManifest(rootDir);
619
- const changes = options.full
620
- ? await fetchRemoteSyncDocs({
621
- baseUrl,
622
- userApiKey,
623
- fetchImpl: options.fetchImpl ?? fetch,
624
- })
625
- : await fetchRemoteSyncChanges({
626
- baseUrl,
627
- userApiKey,
628
- since: manifest.remoteSyncCursor ?? 0,
629
- fetchImpl: options.fetchImpl ?? fetch,
630
- });
631
-
632
- const results: Array<SyncDocResult | SyncDocFailure | SyncDocUnselectedResult> = [];
633
- let hadSyncFailure = false;
634
- for (const remoteDoc of changes.sync_docs) {
635
- if (!remoteDoc.slug) continue;
636
- manifest = await readManifest(rootDir);
637
- const existing = manifest.docs.find((doc) => doc.slug === remoteDoc.slug);
638
- if (remoteDoc.enabled === false) {
639
- if (existing) {
640
- const detectedAt = (options.now ?? (() => new Date()))().toISOString();
641
- results.push(await removeUnselectedProjection(rootDir, manifest, existing, detectedAt));
642
- }
643
- continue;
644
- }
645
- try {
646
- results.push(await syncOneCommentDoc({
647
- rootDir,
648
- input: remoteDoc.slug,
649
- baseUrl,
650
- filename: existing?.file,
651
- userApiKey,
652
- now: options.now,
653
- fetchImpl: options.fetchImpl,
654
- homeDir: options.homeDir,
655
- }));
656
- } catch (error) {
657
- const attemptedAt = (options.now ?? (() => new Date()))().toISOString();
658
- if (error instanceof CommentFsDeletedError && existing) {
659
- results.push(await removeUnselectedProjection(rootDir, await readManifest(rootDir), existing, attemptedAt));
660
- continue;
661
- }
662
- hadSyncFailure = true;
663
- const message = error instanceof Error ? error.message : String(error);
664
- if (existing) results.push(await recordSyncFailure(rootDir, existing, attemptedAt, message));
665
- else {
666
- results.push(await recordNewRemoteSyncFailure(rootDir, {
667
- slug: remoteDoc.slug,
668
- title: remoteDoc.title ?? remoteDoc.slug,
669
- baseUrl,
670
- attemptedAt,
671
- error: message,
672
- }));
673
- }
674
- }
675
- }
676
-
677
- manifest = await readManifest(rootDir);
678
- if (!hadSyncFailure) {
679
- manifest.remoteSyncCursor = changes.cursor;
680
- await writeManifest(rootDir, manifest);
681
- }
682
- return results;
683
- }
684
-
685
- export async function explainCommentFsPath(inputPath: string): Promise<ExplainPathResult> {
686
- const resolvedInput = resolve(inputPath);
687
- const rootDir = await findSyncRoot(resolvedInput);
688
- if (!rootDir) {
689
- throw new Error(`No CommentFS manifest found for ${inputPath}`);
690
- }
691
-
692
- const manifest = await readManifest(rootDir);
693
- const relativeInput = relative(rootDir, resolvedInput);
694
- const normalized = relativeInput.split('/').join('/');
695
- const doc = manifest.docs.find((entry) =>
696
- normalized === entry.file ||
697
- normalized === `${COMMENT_DIR}/${entry.statusFile}` ||
698
- normalized === `${COMMENT_DIR}/${entry.editFile}` ||
699
- normalized === `${COMMENT_DIR}/${entry.authorshipFile ?? sidecarPaths(entry.slug).authorshipFile}` ||
700
- normalized === `${COMMENT_DIR}/${entry.commentsFile ?? sidecarPaths(entry.slug).commentsFile}` ||
701
- normalized === `${COMMENT_DIR}/${entry.participantsFile ?? sidecarPaths(entry.slug).participantsFile}`
702
- );
703
- if (!doc) {
704
- throw new Error(`${inputPath} is inside a CommentFS root but is not a synced Comment.io document path.`);
705
- }
706
-
707
- const result = {
708
- rootDir,
709
- inputPath: resolvedInput,
710
- slug: doc.slug,
711
- title: doc.title,
712
- markdownPath: join(rootDir, doc.file),
713
- statusPath: join(rootDir, COMMENT_DIR, doc.statusFile),
714
- editPath: join(rootDir, COMMENT_DIR, doc.editFile),
715
- sourceUrl: doc.sourceUrl,
716
- apiUrl: doc.apiUrl,
717
- apiDocsUrl: doc.apiDocsUrl,
718
- };
719
-
720
- return {
721
- ...result,
722
- explanation: buildPathExplanation(result),
723
- };
724
- }
725
-
726
- export async function recoverCommentFsPath(inputPath: string): Promise<RecoverPathResult> {
727
- const resolvedInput = resolve(inputPath);
728
- const rootDir = await findSyncRoot(resolvedInput);
729
- if (!rootDir) {
730
- throw new Error(`No CommentFS manifest found for ${inputPath}`);
731
- }
732
-
733
- const manifest = await readManifest(rootDir);
734
- const relativeInput = relative(rootDir, resolvedInput);
735
- const normalized = relativeInput.split('/').join('/');
736
- const isRecoveryPath = normalized.startsWith(`${COMMENT_DIR}/recovery/`);
737
- if (isRecoveryPath) {
738
- const activeDoc = manifest.docs.find((entry) => entry.lastLocalChange?.recoveryFile === normalized);
739
- if (activeDoc?.lastLocalChange && await fileExists(join(rootDir, normalized))) {
740
- return buildRecoverPathResult({
741
- rootDir,
742
- inputPath: resolvedInput,
743
- slug: activeDoc.slug,
744
- title: activeDoc.title,
745
- markdownPath: join(rootDir, activeDoc.file),
746
- statusPath: join(rootDir, COMMENT_DIR, activeDoc.statusFile),
747
- editPath: join(rootDir, COMMENT_DIR, activeDoc.editFile),
748
- recoveryFile: activeDoc.lastLocalChange.recoveryFile,
749
- apiUrl: activeDoc.apiUrl,
750
- apiDocsUrl: activeDoc.apiDocsUrl,
751
- localChange: activeDoc.lastLocalChange,
752
- });
753
- }
754
-
755
- const recovery = manifest.recoveries?.find((entry) =>
756
- normalized === entry.localChange.recoveryFile
757
- );
758
- if (recovery && await fileExists(join(rootDir, normalized))) {
759
- return buildRecoverPathResult({
760
- rootDir,
761
- inputPath: resolvedInput,
762
- slug: recovery.slug,
763
- title: recovery.title,
764
- markdownPath: join(rootDir, recovery.file),
765
- statusPath: join(rootDir, COMMENT_DIR, recovery.statusFile),
766
- editPath: join(rootDir, COMMENT_DIR, recovery.editFile),
767
- recoveryFile: recovery.localChange.recoveryFile,
768
- apiUrl: recovery.apiUrl,
769
- apiDocsUrl: recovery.apiDocsUrl,
770
- localChange: recovery.localChange,
771
- });
772
- }
773
-
774
- const prefixMatchedDoc = manifest.docs.find((entry) =>
775
- basename(normalized).startsWith(`${safeRecoveryStem(entry.slug)}.`)
776
- );
777
- if (prefixMatchedDoc && await fileExists(join(rootDir, normalized))) {
778
- throw new Error(`${inputPath} is a CommentFS recovery artifact, but this manifest does not have metadata for that specific artifact.`);
779
- }
780
-
781
- throw new Error(`${inputPath} is inside a CommentFS root but is not associated with a synced Comment.io document.`);
782
- }
783
-
784
- const doc = manifest.docs.find((entry) =>
785
- normalized === entry.file ||
786
- normalized === `${COMMENT_DIR}/${entry.statusFile}` ||
787
- normalized === `${COMMENT_DIR}/${entry.editFile}` ||
788
- normalized === `${COMMENT_DIR}/${entry.authorshipFile ?? sidecarPaths(entry.slug).authorshipFile}` ||
789
- normalized === `${COMMENT_DIR}/${entry.commentsFile ?? sidecarPaths(entry.slug).commentsFile}` ||
790
- normalized === `${COMMENT_DIR}/${entry.participantsFile ?? sidecarPaths(entry.slug).participantsFile}`
791
- );
792
- if (!doc) {
793
- throw new Error(`${inputPath} is inside a CommentFS root but is not associated with a synced Comment.io document.`);
794
- }
795
- const localChange = doc.lastLocalChange;
796
- if (!localChange) {
797
- throw new Error(`${doc.title} has no recorded local recovery artifact.`);
798
- }
799
-
800
- return buildRecoverPathResult({
801
- rootDir,
802
- inputPath: resolvedInput,
803
- slug: doc.slug,
804
- title: doc.title,
805
- markdownPath: join(rootDir, doc.file),
806
- statusPath: join(rootDir, COMMENT_DIR, doc.statusFile),
807
- editPath: join(rootDir, COMMENT_DIR, doc.editFile),
808
- recoveryFile: localChange.recoveryFile,
809
- apiUrl: doc.apiUrl,
810
- apiDocsUrl: doc.apiDocsUrl,
811
- localChange,
812
- });
813
- }
814
-
815
- function buildRecoverPathResult(options: {
816
- rootDir: string;
817
- inputPath: string;
818
- slug: string;
819
- title: string;
820
- markdownPath: string;
821
- statusPath: string;
822
- editPath: string;
823
- recoveryFile: string;
824
- apiUrl: string;
825
- apiDocsUrl: string;
826
- localChange: CommentFsLocalChange;
827
- }): RecoverPathResult {
828
- const recoveryPath = join(options.rootDir, options.recoveryFile);
829
- return {
830
- rootDir: options.rootDir,
831
- inputPath: options.inputPath,
832
- slug: options.slug,
833
- title: options.title,
834
- markdownPath: options.markdownPath,
835
- statusPath: options.statusPath,
836
- editPath: options.editPath,
837
- recoveryPath,
838
- apiUrl: options.apiUrl,
839
- apiDocsUrl: options.apiDocsUrl,
840
- detectedAt: options.localChange.detectedAt,
841
- restoredCanonicalRevision: options.localChange.restoredCanonicalRevision,
842
- instructions: [
843
- `Recovery artifact for ${options.title} (${options.slug}): ${recoveryPath}`,
844
- `Canonical projection was restored at revision ${options.localChange.restoredCanonicalRevision}.`,
845
- 'This command does not upload local markdown edits.',
846
- `Use Comment.io UI or the API docs at ${options.apiDocsUrl} to apply any intended change explicitly.`,
847
- ].join('\n'),
848
- };
849
- }
850
-
851
- export async function getCommentFsStatus(rootDirOption?: string): Promise<CommentFsStatus> {
852
- const rootDir = resolve(rootDirOption ?? DEFAULT_SYNC_ROOT);
853
- const manifest = await readManifest(rootDir);
854
- const docs: CommentFsStatusDoc[] = [];
855
- for (const doc of manifest.docs) {
856
- const markdownPath = join(rootDir, doc.file);
857
- docs.push({
858
- slug: doc.slug,
859
- title: doc.title,
860
- markdownPath,
861
- statusPath: join(rootDir, COMMENT_DIR, doc.statusFile),
862
- editPath: join(rootDir, COMMENT_DIR, doc.editFile),
863
- sourceUrl: doc.sourceUrl,
864
- apiUrl: doc.apiUrl,
865
- apiDocsUrl: doc.apiDocsUrl,
866
- revision: doc.lastRevision ?? null,
867
- lastSyncedAt: doc.lastSyncedAt ?? null,
868
- readOnly: await isReadOnly(markdownPath),
869
- syncHealth: doc.lastSyncHealth ?? null,
870
- localChange: doc.lastLocalChange ?? null,
871
- });
872
- }
873
- return {
874
- rootDir,
875
- manifestPath: manifestPath(rootDir),
876
- docs,
877
- };
878
- }
879
-
880
- export async function repairCommentFsPermissions(rootDirOption?: string): Promise<CommentFsRepairResult[]> {
881
- const rootDir = resolve(rootDirOption ?? DEFAULT_SYNC_ROOT);
882
- const manifest = await readManifest(rootDir);
883
- const results: CommentFsRepairResult[] = [];
884
- for (const doc of manifest.docs) {
885
- const markdownPath = join(rootDir, doc.file);
886
- const existed = await fileExists(markdownPath);
887
- const wasReadOnly = existed ? await isReadOnly(markdownPath) : false;
888
- if (existed && !wasReadOnly) await ensureReadOnly(markdownPath);
889
- results.push({
890
- slug: doc.slug,
891
- title: doc.title,
892
- markdownPath,
893
- existed,
894
- repaired: existed && !wasReadOnly,
895
- });
896
- }
897
- return results;
898
- }
899
-
900
- async function fetchCommentDoc(options: FetchDocOptions): Promise<CommentFsDocResponse> {
901
- const explicitAuthToken = options.token ?? options.userApiKey;
902
- let shareTokenError: Error | undefined;
903
- if (options.ref.token && !explicitAuthToken) {
904
- const shareUrl = new URL(`${options.ref.baseUrl}/docs/${encodeURIComponent(options.ref.slug)}`);
905
- shareUrl.searchParams.set('token', options.ref.token);
906
- try {
907
- const shared = await requestDoc(shareUrl, undefined, options.fetchImpl);
908
- if (shared.your_token) return shared;
909
- } catch (error) {
910
- shareTokenError = error instanceof Error ? error : new Error(String(error));
911
- }
912
- }
913
-
914
- if (shareTokenError) throw shareTokenError;
915
-
916
- const authToken = explicitAuthToken ?? await loadAgentSecret({
917
- homeDir: options.homeDir,
918
- agentHandle: options.agentHandle,
919
- });
920
-
921
- if (!authToken) {
922
- if (shareTokenError) throw shareTokenError;
923
- throw new Error(
924
- 'No Comment.io token available. Pass --token, use a share URL with ?token=, set COMMENT_IO_AGENT_SECRET, pass --api-key, or configure ~/.comment-io/agents.',
925
- );
926
- }
927
-
928
- return requestDoc(
929
- new URL(`${options.ref.baseUrl}/docs/${encodeURIComponent(options.ref.slug)}`),
930
- authToken,
931
- options.fetchImpl,
932
- );
933
- }
934
-
935
- async function requestDoc(
936
- url: URL,
937
- bearerToken: string | undefined,
938
- fetchImpl: typeof fetch,
939
- ): Promise<CommentFsDocResponse> {
940
- const headers = new Headers({ Accept: 'application/json' });
941
- if (bearerToken) headers.set('Authorization', `Bearer ${bearerToken}`);
942
- const response = await fetchImpl(url, { headers });
943
- if (!response.ok) {
944
- const body = await response.text().catch(() => '');
945
- if (response.status === 410) {
946
- throw new CommentFsDeletedError(`Comment.io GET ${url.pathname} failed with 410: ${body}`);
947
- }
948
- throw new Error(`Comment.io GET ${url.pathname} failed with ${response.status}: ${body}`);
949
- }
950
- const doc = await response.json() as CommentFsDocResponse;
951
- if (typeof doc.markdown !== 'string' || typeof doc.revision !== 'number') {
952
- throw new Error(`Comment.io GET ${url.pathname} returned an invalid document payload.`);
953
- }
954
- return doc;
955
- }
956
-
957
- async function fetchRemoteSyncChanges(options: {
958
- baseUrl: string;
959
- userApiKey: string;
960
- since: number;
961
- fetchImpl: typeof fetch;
962
- }): Promise<RemoteSyncChangesResponse> {
963
- const url = new URL(`${options.baseUrl}/auth/sync-docs/changes`);
964
- url.searchParams.set('since', String(options.since));
965
- const response = await options.fetchImpl(url, {
966
- headers: {
967
- Accept: 'application/json',
968
- Authorization: `Bearer ${options.userApiKey}`,
969
- },
970
- });
971
- if (!response.ok) {
972
- const body = await response.text().catch(() => '');
973
- throw new Error(`Comment.io sync changes poll failed with ${response.status}: ${body}`);
974
- }
975
- const data = await response.json() as RemoteSyncChangesResponse;
976
- if (!Array.isArray(data.sync_docs) || typeof data.cursor !== 'number') {
977
- throw new Error('Comment.io sync changes poll returned an invalid payload.');
978
- }
979
- return data;
980
- }
981
-
982
- async function fetchRemoteSyncDocs(options: {
983
- baseUrl: string;
984
- userApiKey: string;
985
- fetchImpl: typeof fetch;
986
- }): Promise<RemoteSyncChangesResponse> {
987
- const url = new URL(`${options.baseUrl}/auth/sync-docs`);
988
- url.searchParams.set('include_disabled', '1');
989
- const response = await options.fetchImpl(url, {
990
- headers: {
991
- Accept: 'application/json',
992
- Authorization: `Bearer ${options.userApiKey}`,
993
- },
994
- });
995
- if (!response.ok) {
996
- const body = await response.text().catch(() => '');
997
- throw new Error(`Comment.io sync docs reconciliation failed with ${response.status}: ${body}`);
998
- }
999
- const data = await response.json() as RemoteSyncChangesResponse;
1000
- if (!Array.isArray(data.sync_docs) || typeof data.cursor !== 'number') {
1001
- throw new Error('Comment.io sync docs reconciliation returned an invalid payload.');
1002
- }
1003
- return data;
1004
- }
1005
-
1006
- async function removeUnselectedProjection(
1007
- rootDir: string,
1008
- manifest: CommentFsManifest,
1009
- doc: CommentFsManifestDoc,
1010
- detectedAt: string,
1011
- ): Promise<SyncDocUnselectedResult> {
1012
- const markdownPath = join(rootDir, doc.file);
1013
- const statusPath = join(rootDir, COMMENT_DIR, doc.statusFile);
1014
- const editPath = join(rootDir, COMMENT_DIR, doc.editFile);
1015
- let localChange: CommentFsLocalChange | undefined;
1016
- if (doc.lastMarkdownSha256 && await fileExists(markdownPath)) {
1017
- const localMarkdown = await readFile(markdownPath, 'utf-8');
1018
- if (sha256(localMarkdown) !== doc.lastMarkdownSha256) {
1019
- localChange = await preserveLocalChange(rootDir, doc.slug, localMarkdown, detectedAt, doc.lastRevision ?? 0);
1020
- }
1021
- }
1022
- await Promise.all([
1023
- rm(markdownPath, { force: true }),
1024
- rm(statusPath, { force: true }),
1025
- rm(editPath, { force: true }),
1026
- doc.authorshipFile ? rm(join(rootDir, COMMENT_DIR, doc.authorshipFile), { force: true }) : Promise.resolve(),
1027
- doc.commentsFile ? rm(join(rootDir, COMMENT_DIR, doc.commentsFile), { force: true }) : Promise.resolve(),
1028
- doc.participantsFile ? rm(join(rootDir, COMMENT_DIR, doc.participantsFile), { force: true }) : Promise.resolve(),
1029
- ]);
1030
- const recoveryToIndex = localChange ?? doc.lastLocalChange;
1031
- if (recoveryToIndex) {
1032
- manifest.recoveries = [
1033
- ...(manifest.recoveries ?? []).filter((entry) => entry.localChange.recoveryFile !== recoveryToIndex.recoveryFile),
1034
- {
1035
- slug: doc.slug,
1036
- title: doc.title,
1037
- file: doc.file,
1038
- statusFile: doc.statusFile,
1039
- editFile: doc.editFile,
1040
- sourceUrl: doc.sourceUrl,
1041
- apiUrl: doc.apiUrl,
1042
- apiDocsUrl: doc.apiDocsUrl,
1043
- localChange: recoveryToIndex,
1044
- removedAt: detectedAt,
1045
- },
1046
- ];
1047
- }
1048
- manifest.docs = manifest.docs.filter((entry) => entry.slug !== doc.slug);
1049
- await deleteDocCredential(rootDir, doc.slug);
1050
- await writeManifest(rootDir, manifest);
1051
- return {
1052
- ok: true,
1053
- slug: doc.slug,
1054
- title: doc.title,
1055
- markdownPath,
1056
- statusPath,
1057
- editPath,
1058
- manifestPath: manifestPath(rootDir),
1059
- revision: doc.lastRevision ?? 0,
1060
- changed: true,
1061
- localChangeDetected: Boolean(localChange),
1062
- recoveryPath: recoveryToIndex ? join(rootDir, recoveryToIndex.recoveryFile) : undefined,
1063
- disabled: true,
1064
- };
1065
- }
1066
-
1067
- function indexRecoveryRecord(
1068
- manifest: CommentFsManifest,
1069
- doc: CommentFsManifestDoc,
1070
- localChange: CommentFsLocalChange,
1071
- recordedAt: string,
1072
- ): void {
1073
- manifest.recoveries = [
1074
- ...(manifest.recoveries ?? []).filter((entry) => entry.localChange.recoveryFile !== localChange.recoveryFile),
1075
- {
1076
- slug: doc.slug,
1077
- title: doc.title,
1078
- file: doc.file,
1079
- statusFile: doc.statusFile,
1080
- editFile: doc.editFile,
1081
- sourceUrl: doc.sourceUrl,
1082
- apiUrl: doc.apiUrl,
1083
- apiDocsUrl: doc.apiDocsUrl,
1084
- localChange,
1085
- removedAt: recordedAt,
1086
- },
1087
- ];
1088
- }
1089
-
1090
- async function loadAgentSecret(options: { homeDir: string; agentHandle?: string }): Promise<string | undefined> {
1091
- const envSecret = process.env.COMMENT_IO_AGENT_SECRET?.trim();
1092
- if (envSecret) return envSecret;
1093
-
1094
- const agentsDir = join(options.homeDir, '.comment-io', 'agents');
1095
- const desired = options.agentHandle?.replace(/^@/, '').toLowerCase();
1096
- if (existsSync(agentsDir)) {
1097
- const files = (await readdir(agentsDir)).filter((file) => file.endsWith('.json')).sort();
1098
- for (const file of files) {
1099
- const handle = basename(file, '.json').toLowerCase();
1100
- if (desired && desired !== handle) continue;
1101
- try {
1102
- const cfg = JSON.parse(await readFile(join(agentsDir, file), 'utf-8')) as { agent_secret?: string };
1103
- if (cfg.agent_secret?.startsWith('as_')) return cfg.agent_secret;
1104
- } catch {
1105
- // Ignore malformed credential files, matching the MCP plugin behavior.
1106
- }
1107
- }
1108
- }
1109
-
1110
- const legacy = join(options.homeDir, '.comment-io', 'config.json');
1111
- if (existsSync(legacy)) {
1112
- try {
1113
- const cfg = JSON.parse(await readFile(legacy, 'utf-8')) as { agent_secret?: string; handle?: string };
1114
- if (!desired || cfg.handle?.toLowerCase() === desired) {
1115
- if (cfg.agent_secret?.startsWith('as_')) return cfg.agent_secret;
1116
- }
1117
- } catch {
1118
- // Ignore malformed legacy config.
1119
- }
1120
- }
1121
-
1122
- return undefined;
1123
- }
1124
-
1125
- async function chooseMarkdownFile(
1126
- rootDir: string,
1127
- manifest: CommentFsManifest,
1128
- title: string,
1129
- slug: string,
1130
- ): Promise<string> {
1131
- const preferred = safeMarkdownFilename(title, slug);
1132
- const used = new Set(manifest.docs.map((doc) => doc.file));
1133
- if (!used.has(preferred) && !(await fileExists(join(rootDir, preferred)))) return preferred;
1134
-
1135
- const stem = preferred.slice(0, -extname(preferred).length);
1136
- for (let i = 2; i < 1000; i += 1) {
1137
- const candidate = `${stem}-${i}.md`;
1138
- if (!used.has(candidate) && !(await fileExists(join(rootDir, candidate)))) return candidate;
1139
- }
1140
- throw new Error(`Could not choose a unique markdown filename for ${slug}`);
1141
- }
1142
-
1143
- async function ensureRoot(rootDir: string): Promise<void> {
1144
- await mkdir(rootDir, { recursive: true, mode: 0o755 });
1145
- await mkdir(join(rootDir, COMMENT_DIR), { recursive: true, mode: 0o755 });
1146
- }
1147
-
1148
- async function findSyncRoot(path: string): Promise<string | null> {
1149
- let current = existsSync(path) && (await stat(path)).isDirectory() ? path : dirname(path);
1150
- while (true) {
1151
- if (existsSync(manifestPath(current))) return current;
1152
- const parent = dirname(current);
1153
- if (parent === current) return null;
1154
- current = parent;
1155
- }
1156
- }
1157
-
1158
- async function writeReadOnlyProjection(path: string, markdown: string): Promise<void> {
1159
- await mkdir(dirname(path), { recursive: true, mode: 0o755 });
1160
- const temp = join(dirname(path), `.${basename(path)}.${process.pid}.${Date.now()}.tmp`);
1161
- await writeFile(temp, markdown, { mode: 0o644 });
1162
- await rename(temp, path);
1163
- await ensureReadOnly(path);
1164
- }
1165
-
1166
- async function preserveLocalChange(
1167
- rootDir: string,
1168
- sidecarStem: string,
1169
- localMarkdown: string,
1170
- detectedAt: string,
1171
- canonicalRevision: number,
1172
- ): Promise<CommentFsLocalChange> {
1173
- const recoveryDir = join(rootDir, COMMENT_DIR, 'recovery');
1174
- await mkdir(recoveryDir, { recursive: true, mode: 0o755 });
1175
- const stamp = detectedAt.replace(/[^0-9A-Za-z-]+/g, '-');
1176
- const stem = `${safeRecoveryStem(sidecarStem)}.${stamp}`;
1177
- let filename = `${stem}.local.md`;
1178
- for (let i = 2; await fileExists(join(recoveryDir, filename)); i += 1) {
1179
- filename = `${stem}-${i}.local.md`;
1180
- }
1181
- const recoveryFile = join(COMMENT_DIR, 'recovery', filename);
1182
- await writeFile(join(rootDir, recoveryFile), localMarkdown, { mode: 0o644 });
1183
- return {
1184
- detectedAt,
1185
- recoveryFile,
1186
- restoredCanonicalRevision: canonicalRevision,
1187
- };
1188
- }
1189
-
1190
- function safeRecoveryStem(slug: string): string {
1191
- return slug.replace(/[^A-Za-z0-9_-]/g, '_') || 'doc';
1192
- }
1193
-
1194
- async function recordSyncFailure(
1195
- rootDir: string,
1196
- manifestDoc: CommentFsManifestDoc,
1197
- attemptedAt: string,
1198
- error: string,
1199
- ): Promise<SyncDocFailure> {
1200
- const nextDoc: CommentFsManifestDoc = {
1201
- ...manifestDoc,
1202
- lastSyncHealth: {
1203
- status: 'error',
1204
- lastAttemptAt: attemptedAt,
1205
- lastSuccessAt: manifestDoc.lastSyncHealth?.lastSuccessAt ?? manifestDoc.lastSyncedAt,
1206
- lastErrorAt: attemptedAt,
1207
- lastError: error,
1208
- },
1209
- };
1210
- const manifest = await readManifest(rootDir);
1211
- const idx = manifest.docs.findIndex((entry) => entry.slug === manifestDoc.slug);
1212
- if (idx >= 0) manifest.docs[idx] = nextDoc;
1213
- sortManifest(manifest);
1214
- await writeManifest(rootDir, manifest);
1215
- await writeStatusSidecar(rootDir, nextDoc, {
1216
- revision: nextDoc.lastRevision ?? null,
1217
- role: null,
1218
- canonicalUpdatedAt: null,
1219
- lastSyncedAt: nextDoc.lastSyncedAt ?? null,
1220
- canonicalMarkdownSha256: nextDoc.lastCanonicalMarkdownSha256 ?? null,
1221
- projectionMarkdownSha256: nextDoc.lastMarkdownSha256 ?? null,
1222
- credentialPresent: Boolean(await readDocCredential(rootDir, nextDoc.slug).catch(() => undefined)),
1223
- });
1224
- return {
1225
- ok: false,
1226
- slug: nextDoc.slug,
1227
- title: nextDoc.title,
1228
- markdownPath: join(rootDir, nextDoc.file),
1229
- statusPath: join(rootDir, COMMENT_DIR, nextDoc.statusFile),
1230
- editPath: join(rootDir, COMMENT_DIR, nextDoc.editFile),
1231
- manifestPath: manifestPath(rootDir),
1232
- error,
1233
- };
1234
- }
1235
-
1236
- async function recordNewRemoteSyncFailure(
1237
- rootDir: string,
1238
- options: {
1239
- slug: string;
1240
- title: string;
1241
- baseUrl: string;
1242
- attemptedAt: string;
1243
- error: string;
1244
- },
1245
- ): Promise<SyncDocFailure> {
1246
- const manifest = await readManifest(rootDir);
1247
- const sidecars = sidecarPaths(options.slug);
1248
- const file = await chooseMarkdownFile(rootDir, manifest, options.title, options.slug);
1249
- const manifestDoc: CommentFsManifestDoc = {
1250
- slug: options.slug,
1251
- title: options.title,
1252
- file,
1253
- sidecarDir: sidecars.sidecarDir,
1254
- statusFile: sidecars.statusFile,
1255
- editFile: sidecars.editFile,
1256
- authorshipFile: sidecars.authorshipFile,
1257
- commentsFile: sidecars.commentsFile,
1258
- participantsFile: sidecars.participantsFile,
1259
- sourceUrl: `${options.baseUrl}/d/${encodeURIComponent(options.slug)}`,
1260
- apiUrl: `${options.baseUrl}/docs/${encodeURIComponent(options.slug)}`,
1261
- apiDocsUrl: `${options.baseUrl}/docs/${encodeURIComponent(options.slug)}?docs`,
1262
- baseUrl: options.baseUrl,
1263
- addedAt: options.attemptedAt,
1264
- authMode: 'user_api_key',
1265
- lastSyncHealth: {
1266
- status: 'error',
1267
- lastAttemptAt: options.attemptedAt,
1268
- lastErrorAt: options.attemptedAt,
1269
- lastError: options.error,
1270
- },
1271
- };
1272
-
1273
- await writeRootReadme(rootDir);
1274
- await writeTextIfChanged(join(rootDir, COMMENT_DIR, manifestDoc.editFile), buildEditInstructions(manifestDoc));
1275
- await writeStatusSidecar(rootDir, manifestDoc, {
1276
- revision: null,
1277
- role: null,
1278
- canonicalUpdatedAt: null,
1279
- lastSyncedAt: null,
1280
- canonicalMarkdownSha256: null,
1281
- projectionMarkdownSha256: null,
1282
- credentialPresent: false,
1283
- });
1284
- const idx = manifest.docs.findIndex((entry) => entry.slug === manifestDoc.slug);
1285
- if (idx >= 0) manifest.docs[idx] = manifestDoc;
1286
- else manifest.docs.push(manifestDoc);
1287
- sortManifest(manifest);
1288
- await writeManifest(rootDir, manifest);
1289
-
1290
- return {
1291
- ok: false,
1292
- slug: options.slug,
1293
- title: options.title,
1294
- markdownPath: join(rootDir, file),
1295
- statusPath: join(rootDir, COMMENT_DIR, sidecars.statusFile),
1296
- editPath: join(rootDir, COMMENT_DIR, sidecars.editFile),
1297
- manifestPath: manifestPath(rootDir),
1298
- error: options.error,
1299
- };
1300
- }
1301
-
1302
- async function ensureReadOnly(path: string): Promise<void> {
1303
- await chmod(path, 0o444);
1304
- }
1305
-
1306
- async function writeDocSidecars(
1307
- rootDir: string,
1308
- manifestDoc: CommentFsManifestDoc,
1309
- doc: CommentFsDocResponse,
1310
- canonicalMarkdownSha256: string,
1311
- projectionMarkdownSha256: string,
1312
- syncedAt: string,
1313
- ): Promise<void> {
1314
- await writeStatusSidecar(rootDir, manifestDoc, {
1315
- revision: doc.revision,
1316
- role: doc.your_role ?? null,
1317
- canonicalUpdatedAt: doc.updated_at ?? null,
1318
- lastSyncedAt: syncedAt,
1319
- canonicalMarkdownSha256,
1320
- projectionMarkdownSha256,
1321
- credentialPresent: Boolean(await readDocCredential(rootDir, manifestDoc.slug).catch(() => undefined)),
1322
- });
1323
- await writeTextIfChanged(join(rootDir, COMMENT_DIR, manifestDoc.editFile), buildEditInstructions(manifestDoc));
1324
- await writeJsonIfChanged(join(rootDir, COMMENT_DIR, manifestDoc.authorshipFile ?? sidecarPaths(manifestDoc.slug).authorshipFile), {
1325
- version: COMMENTFS_VERSION,
1326
- slug: manifestDoc.slug,
1327
- authorship: doc.authorship ?? null,
1328
- actors: doc.actors ?? null,
1329
- });
1330
- await writeJsonIfChanged(join(rootDir, COMMENT_DIR, manifestDoc.commentsFile ?? sidecarPaths(manifestDoc.slug).commentsFile), {
1331
- version: COMMENTFS_VERSION,
1332
- slug: manifestDoc.slug,
1333
- blocks: doc.blocks ?? [],
1334
- comment_meta: doc.comment_meta ?? null,
1335
- });
1336
- await writeJsonIfChanged(join(rootDir, COMMENT_DIR, manifestDoc.participantsFile ?? sidecarPaths(manifestDoc.slug).participantsFile), {
1337
- version: COMMENTFS_VERSION,
1338
- slug: manifestDoc.slug,
1339
- participants: doc.participants ?? [],
1340
- actors: doc.actors ?? null,
1341
- });
1342
- }
1343
-
1344
- async function writeStatusSidecar(
1345
- rootDir: string,
1346
- manifestDoc: CommentFsManifestDoc,
1347
- details: {
1348
- revision: number | null;
1349
- role: string | null;
1350
- canonicalUpdatedAt: string | null;
1351
- lastSyncedAt: string | null;
1352
- canonicalMarkdownSha256: string | null;
1353
- projectionMarkdownSha256: string | null;
1354
- credentialPresent: boolean;
1355
- },
1356
- ): Promise<void> {
1357
- const commentDir = join(rootDir, COMMENT_DIR);
1358
- const status = {
1359
- version: COMMENTFS_VERSION,
1360
- slug: manifestDoc.slug,
1361
- title: manifestDoc.title,
1362
- source_url: manifestDoc.sourceUrl,
1363
- api_url: manifestDoc.apiUrl,
1364
- api_docs_url: manifestDoc.apiDocsUrl,
1365
- markdown_file: manifestDoc.file,
1366
- revision: details.revision,
1367
- role: details.role,
1368
- canonical_updated_at: details.canonicalUpdatedAt,
1369
- last_synced_at: details.lastSyncedAt,
1370
- canonical_markdown_sha256: details.canonicalMarkdownSha256,
1371
- projection_markdown_sha256: details.projectionMarkdownSha256,
1372
- sync_health: manifestDoc.lastSyncHealth ?? null,
1373
- read_only_projection: true,
1374
- read_only_reason: 'Comment.io online state is canonical; local markdown is a read-only projection.',
1375
- sidecars: {
1376
- edit_instructions: manifestDoc.editFile,
1377
- authorship: manifestDoc.authorshipFile ?? sidecarPaths(manifestDoc.slug).authorshipFile,
1378
- comments: manifestDoc.commentsFile ?? sidecarPaths(manifestDoc.slug).commentsFile,
1379
- participants: manifestDoc.participantsFile ?? sidecarPaths(manifestDoc.slug).participantsFile,
1380
- },
1381
- auth: {
1382
- token_present: details.credentialPresent,
1383
- token_location: details.credentialPresent ? '.comment/credentials.json' : null,
1384
- agent_secret_supported: true,
1385
- },
1386
- local_change: manifestDoc.lastLocalChange ?? null,
1387
- };
1388
- await writeJsonIfChanged(join(commentDir, manifestDoc.statusFile), status);
1389
- }
1390
-
1391
- async function writeRootReadme(rootDir: string): Promise<void> {
1392
- await writeTextIfChanged(join(rootDir, COMMENT_DIR, 'README.md'), [
1393
- '# Comment.io Synced Files',
1394
- '',
1395
- 'Markdown files in this folder are read-only projections of online Comment.io documents.',
1396
- '',
1397
- 'Do not edit synced markdown files directly. Use the Comment.io UI or the Comment.io API so edits go through canonical reconciliation, provenance, comments, and permissions.',
1398
- '',
1399
- 'Useful commands:',
1400
- '- `comment sync status` shows projection health.',
1401
- '- `comment sync explain <path>` explains a synced markdown or sidecar path.',
1402
- '- `comment sync recover <path>` explains the latest preserved local edit artifact.',
1403
- '',
1404
- 'For a specific file, read its `.comment/*.status.json` and `.comment/*.edit.md` sidecars.',
1405
- 'The full agent API reference is available at https://comment.io/llms.txt and per-doc via the `api_docs_url` in status JSON.',
1406
- '',
1407
- ].join('\n'));
1408
- }
1409
-
1410
- function buildEditInstructions(doc: CommentFsManifestDoc): string {
1411
- return [
1412
- `# Edit ${doc.title}`,
1413
- '',
1414
- 'This file is synced from Comment.io and is not the edit surface.',
1415
- 'To edit it, call the Comment.io API for this document or open the source URL in the browser.',
1416
- '',
1417
- `- Markdown projection: ../${doc.file}`,
1418
- `- Status JSON: ${doc.statusFile}`,
1419
- `- Authorship JSON: ${doc.authorshipFile ?? sidecarPaths(doc.slug).authorshipFile}`,
1420
- `- Comments JSON: ${doc.commentsFile ?? sidecarPaths(doc.slug).commentsFile}`,
1421
- `- Participants JSON: ${doc.participantsFile ?? sidecarPaths(doc.slug).participantsFile}`,
1422
- `- Source URL: ${doc.sourceUrl}`,
1423
- `- API URL: ${doc.apiUrl}`,
1424
- `- Per-doc API docs: ${doc.apiDocsUrl}`,
1425
- `- Full API reference: ${doc.baseUrl}/llms.txt`,
1426
- `- Explain this path: comment sync explain "${doc.file}"`,
1427
- '- Recover preserved local edits: comment sync recover <path>',
1428
- '',
1429
- '`COMMENT_IO_USER_API_KEY` is only for CommentFS read-only projection sync.',
1430
- 'For API edits, use `COMMENT_IO_AGENT_SECRET`, a registered agent credential, or an edit-capable per-document token as the Bearer token.',
1431
- '',
1432
- ].join('\n');
1433
- }
1434
-
1435
- function sidecarPaths(slug: string): {
1436
- sidecarDir: string;
1437
- statusFile: string;
1438
- editFile: string;
1439
- authorshipFile: string;
1440
- commentsFile: string;
1441
- participantsFile: string;
1442
- } {
1443
- const safeSlug = slug.replace(/[^a-zA-Z0-9_-]/g, '_');
1444
- const sidecarDir = join('docs', safeSlug);
1445
- return {
1446
- sidecarDir,
1447
- statusFile: join(sidecarDir, 'status.json'),
1448
- editFile: join(sidecarDir, 'edit.md'),
1449
- authorshipFile: join(sidecarDir, 'authorship.json'),
1450
- commentsFile: join(sidecarDir, 'comments.json'),
1451
- participantsFile: join(sidecarDir, 'participants.json'),
1452
- };
1453
- }
1454
-
1455
- function buildMarkdownProjection(
1456
- canonicalMarkdown: string,
1457
- details: { title: string; sourceUrl: string; statusPath: string; editPath: string },
1458
- ): string {
1459
- const header = [
1460
- '<!--',
1461
- 'Synced from Comment.io. Local markdown is a read-only projection.',
1462
- 'Edit through the Comment.io UI or API; do not edit this file directly.',
1463
- `Title: ${details.title}`,
1464
- `Source: ${details.sourceUrl}`,
1465
- `Metadata: ${details.statusPath}`,
1466
- `Edit instructions: ${details.editPath}`,
1467
- '-->',
1468
- '',
1469
- ].join('\n');
1470
- return `${header}${canonicalMarkdown}`;
1471
- }
1472
-
1473
- async function removeSupersededSidecars(
1474
- rootDir: string,
1475
- oldDoc: CommentFsManifestDoc,
1476
- newDoc: CommentFsManifestDoc,
1477
- ): Promise<void> {
1478
- const oldFiles = [
1479
- oldDoc.statusFile,
1480
- oldDoc.editFile,
1481
- oldDoc.authorshipFile,
1482
- oldDoc.commentsFile,
1483
- oldDoc.participantsFile,
1484
- ].filter((file): file is string => typeof file === 'string');
1485
- const newFiles = new Set([
1486
- newDoc.statusFile,
1487
- newDoc.editFile,
1488
- newDoc.authorshipFile,
1489
- newDoc.commentsFile,
1490
- newDoc.participantsFile,
1491
- ].filter((file): file is string => typeof file === 'string'));
1492
- await Promise.all(oldFiles
1493
- .filter((file) => !newFiles.has(file))
1494
- .map((file) => rm(join(rootDir, COMMENT_DIR, file), { force: true })));
1495
- }
1496
-
1497
- function buildPathExplanation(doc: Omit<ExplainPathResult, 'explanation'>): string {
1498
- return [
1499
- 'This is a Comment.io synced file. Do not edit it directly.',
1500
- 'Use the Comment.io UI or API so edits go through canonical reconciliation, provenance, comments, and permissions.',
1501
- '',
1502
- `Title: ${doc.title}`,
1503
- `Slug: ${doc.slug}`,
1504
- `Markdown projection: ${doc.markdownPath}`,
1505
- `Status JSON: ${doc.statusPath}`,
1506
- `Edit instructions: ${doc.editPath}`,
1507
- `Source URL: ${doc.sourceUrl}`,
1508
- `API URL: ${doc.apiUrl}`,
1509
- `Per-doc API docs: ${doc.apiDocsUrl}`,
1510
- '',
1511
- 'For the full agent API reference, fetch /llms.txt from the same Comment.io host.',
1512
- ].join('\n');
1513
- }
1514
-
1515
- async function writeJsonIfChanged(path: string, value: unknown): Promise<void> {
1516
- await writeTextIfChanged(path, `${JSON.stringify(value, null, 2)}\n`);
1517
- }
1518
-
1519
- async function writePrivateJsonIfChanged(path: string, value: unknown): Promise<void> {
1520
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
1521
- const text = `${JSON.stringify(value, null, 2)}\n`;
1522
- if (existsSync(path)) {
1523
- const current = await readFile(path, 'utf-8');
1524
- if (current === text) {
1525
- await chmod(path, 0o600);
1526
- return;
1527
- }
1528
- }
1529
- await writeFile(path, text, { mode: 0o600 });
1530
- await chmod(path, 0o600);
1531
- }
1532
-
1533
- async function writeTextIfChanged(path: string, value: string): Promise<void> {
1534
- await mkdir(dirname(path), { recursive: true, mode: 0o755 });
1535
- if (existsSync(path)) {
1536
- const current = await readFile(path, 'utf-8');
1537
- if (current === value) return;
1538
- }
1539
- await writeFile(path, value, { mode: 0o644 });
1540
- }
1541
-
1542
- async function fileExists(path: string): Promise<boolean> {
1543
- try {
1544
- await stat(path);
1545
- return true;
1546
- } catch {
1547
- return false;
1548
- }
1549
- }
1550
-
1551
- async function isReadOnly(path: string): Promise<boolean> {
1552
- try {
1553
- return ((await stat(path)).mode & 0o222) === 0;
1554
- } catch {
1555
- return false;
1556
- }
1557
- }
1558
-
1559
- function sortManifest(manifest: CommentFsManifest): void {
1560
- manifest.docs.sort((a, b) => a.file.localeCompare(b.file));
1561
- }
1562
-
1563
- function sha256(value: string): string {
1564
- return createHash('sha256').update(value).digest('hex');
1565
- }