@aaronshaf/confluence-cli 0.1.15

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.
Files changed (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +69 -0
  3. package/package.json +73 -0
  4. package/src/cli/commands/attachments.ts +113 -0
  5. package/src/cli/commands/clone.ts +188 -0
  6. package/src/cli/commands/comments.ts +56 -0
  7. package/src/cli/commands/create.ts +58 -0
  8. package/src/cli/commands/delete.ts +46 -0
  9. package/src/cli/commands/doctor.ts +161 -0
  10. package/src/cli/commands/duplicate-check.ts +89 -0
  11. package/src/cli/commands/file-rename.ts +113 -0
  12. package/src/cli/commands/folder-hierarchy.ts +241 -0
  13. package/src/cli/commands/info.ts +56 -0
  14. package/src/cli/commands/labels.ts +53 -0
  15. package/src/cli/commands/move.ts +23 -0
  16. package/src/cli/commands/open.ts +145 -0
  17. package/src/cli/commands/pull.ts +241 -0
  18. package/src/cli/commands/push-errors.ts +40 -0
  19. package/src/cli/commands/push.ts +699 -0
  20. package/src/cli/commands/search.ts +62 -0
  21. package/src/cli/commands/setup.ts +124 -0
  22. package/src/cli/commands/spaces.ts +42 -0
  23. package/src/cli/commands/status.ts +88 -0
  24. package/src/cli/commands/tree.ts +190 -0
  25. package/src/cli/help.ts +425 -0
  26. package/src/cli/index.ts +413 -0
  27. package/src/cli/utils/browser.ts +34 -0
  28. package/src/cli/utils/progress-reporter.ts +49 -0
  29. package/src/cli.ts +6 -0
  30. package/src/lib/config.ts +156 -0
  31. package/src/lib/confluence-client/attachment-operations.ts +221 -0
  32. package/src/lib/confluence-client/client.ts +653 -0
  33. package/src/lib/confluence-client/comment-operations.ts +60 -0
  34. package/src/lib/confluence-client/folder-operations.ts +203 -0
  35. package/src/lib/confluence-client/index.ts +47 -0
  36. package/src/lib/confluence-client/label-operations.ts +102 -0
  37. package/src/lib/confluence-client/page-operations.ts +270 -0
  38. package/src/lib/confluence-client/search-operations.ts +60 -0
  39. package/src/lib/confluence-client/types.ts +329 -0
  40. package/src/lib/confluence-client/user-operations.ts +58 -0
  41. package/src/lib/dependency-sorter.ts +233 -0
  42. package/src/lib/errors.ts +237 -0
  43. package/src/lib/file-scanner.ts +195 -0
  44. package/src/lib/formatters.ts +314 -0
  45. package/src/lib/health-check.ts +204 -0
  46. package/src/lib/markdown/converter.ts +427 -0
  47. package/src/lib/markdown/frontmatter.ts +116 -0
  48. package/src/lib/markdown/html-converter.ts +398 -0
  49. package/src/lib/markdown/index.ts +21 -0
  50. package/src/lib/markdown/link-converter.ts +189 -0
  51. package/src/lib/markdown/reference-updater.ts +251 -0
  52. package/src/lib/markdown/slugify.ts +32 -0
  53. package/src/lib/page-state.ts +195 -0
  54. package/src/lib/resolve-page-target.ts +33 -0
  55. package/src/lib/space-config.ts +264 -0
  56. package/src/lib/sync/cleanup.ts +50 -0
  57. package/src/lib/sync/folder-path.ts +61 -0
  58. package/src/lib/sync/index.ts +2 -0
  59. package/src/lib/sync/link-resolution-pass.ts +139 -0
  60. package/src/lib/sync/sync-engine.ts +681 -0
  61. package/src/lib/sync/sync-specific.ts +221 -0
  62. package/src/lib/sync/types.ts +42 -0
  63. package/src/test/attachments.test.ts +68 -0
  64. package/src/test/clone.test.ts +373 -0
  65. package/src/test/comments.test.ts +53 -0
  66. package/src/test/config.test.ts +209 -0
  67. package/src/test/confluence-client.test.ts +535 -0
  68. package/src/test/delete.test.ts +39 -0
  69. package/src/test/dependency-sorter.test.ts +384 -0
  70. package/src/test/errors.test.ts +199 -0
  71. package/src/test/file-rename.test.ts +305 -0
  72. package/src/test/file-scanner.test.ts +331 -0
  73. package/src/test/folder-hierarchy.test.ts +337 -0
  74. package/src/test/formatters.test.ts +213 -0
  75. package/src/test/html-converter.test.ts +399 -0
  76. package/src/test/info.test.ts +56 -0
  77. package/src/test/labels.test.ts +70 -0
  78. package/src/test/link-conversion-integration.test.ts +189 -0
  79. package/src/test/link-converter.test.ts +413 -0
  80. package/src/test/link-resolution-pass.test.ts +368 -0
  81. package/src/test/markdown.test.ts +443 -0
  82. package/src/test/mocks/handlers.ts +228 -0
  83. package/src/test/move.test.ts +53 -0
  84. package/src/test/msw-schema-validation.ts +151 -0
  85. package/src/test/page-state.test.ts +542 -0
  86. package/src/test/push.test.ts +551 -0
  87. package/src/test/reference-updater.test.ts +293 -0
  88. package/src/test/resolve-page-target.test.ts +55 -0
  89. package/src/test/search.test.ts +64 -0
  90. package/src/test/setup-msw.ts +75 -0
  91. package/src/test/space-config.test.ts +516 -0
  92. package/src/test/spaces.test.ts +53 -0
  93. package/src/test/sync-engine.test.ts +486 -0
  94. package/src/types/turndown-plugin-gfm.d.ts +9 -0
@@ -0,0 +1,542 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { buildPageStateFromFiles, getPageInfoByPath, scanDirectoryForPages } from '../lib/page-state.js';
6
+
7
+ describe('page-state', () => {
8
+ let testDir: string;
9
+
10
+ beforeEach(() => {
11
+ testDir = mkdtempSync(join(tmpdir(), 'cn-page-state-test-'));
12
+ });
13
+
14
+ afterEach(() => {
15
+ if (existsSync(testDir)) {
16
+ rmSync(testDir, { recursive: true });
17
+ }
18
+ });
19
+
20
+ describe('buildPageStateFromFiles', () => {
21
+ test('builds page state from valid markdown files', () => {
22
+ // Create test files with frontmatter
23
+ const docsDir = join(testDir, 'docs');
24
+ mkdirSync(docsDir, { recursive: true });
25
+
26
+ writeFileSync(
27
+ join(testDir, 'home.md'),
28
+ `---
29
+ page_id: "page-1"
30
+ title: "Home Page"
31
+ version: 3
32
+ updated_at: "2024-01-15T10:00:00Z"
33
+ synced_at: "2024-01-16T08:00:00Z"
34
+ ---
35
+
36
+ # Home Page
37
+
38
+ Welcome!
39
+ `,
40
+ );
41
+
42
+ writeFileSync(
43
+ join(docsDir, 'guide.md'),
44
+ `---
45
+ page_id: "page-2"
46
+ title: "User Guide"
47
+ version: 5
48
+ updated_at: "2024-01-20T14:30:00Z"
49
+ ---
50
+
51
+ # User Guide
52
+
53
+ Content here.
54
+ `,
55
+ );
56
+
57
+ const pageMappings = {
58
+ 'page-1': 'home.md',
59
+ 'page-2': 'docs/guide.md',
60
+ };
61
+
62
+ const result = buildPageStateFromFiles(testDir, pageMappings);
63
+
64
+ // Check pages map
65
+ expect(result.pages.size).toBe(2);
66
+
67
+ const page1 = result.pages.get('page-1');
68
+ expect(page1).toBeDefined();
69
+ expect(page1?.pageId).toBe('page-1');
70
+ expect(page1?.localPath).toBe('home.md');
71
+ expect(page1?.title).toBe('Home Page');
72
+ expect(page1?.version).toBe(3);
73
+ expect(page1?.updatedAt).toBe('2024-01-15T10:00:00Z');
74
+ expect(page1?.syncedAt).toBe('2024-01-16T08:00:00Z');
75
+
76
+ const page2 = result.pages.get('page-2');
77
+ expect(page2).toBeDefined();
78
+ expect(page2?.title).toBe('User Guide');
79
+ expect(page2?.version).toBe(5);
80
+
81
+ // Check pathToPageId map
82
+ expect(result.pathToPageId.size).toBe(2);
83
+ expect(result.pathToPageId.get('home.md')).toBe('page-1');
84
+ expect(result.pathToPageId.get('docs/guide.md')).toBe('page-2');
85
+ });
86
+
87
+ test('skips files that do not exist and reports warnings', () => {
88
+ writeFileSync(
89
+ join(testDir, 'exists.md'),
90
+ `---
91
+ page_id: "page-1"
92
+ title: "Existing Page"
93
+ version: 1
94
+ ---
95
+
96
+ Content
97
+ `,
98
+ );
99
+
100
+ const pageMappings = {
101
+ 'page-1': 'exists.md',
102
+ 'page-2': 'does-not-exist.md',
103
+ };
104
+
105
+ const result = buildPageStateFromFiles(testDir, pageMappings);
106
+
107
+ // Only the existing file should be in pages map
108
+ expect(result.pages.size).toBe(1);
109
+ expect(result.pages.has('page-1')).toBe(true);
110
+ expect(result.pages.has('page-2')).toBe(false);
111
+
112
+ // pathToPageId should only contain successfully parsed pages
113
+ expect(result.pathToPageId.size).toBe(1);
114
+ expect(result.pathToPageId.get('exists.md')).toBe('page-1');
115
+
116
+ // Should have a warning about the missing file
117
+ expect(result.warnings.length).toBe(1);
118
+ expect(result.warnings[0]).toContain('File not found');
119
+ expect(result.warnings[0]).toContain('page-2');
120
+ });
121
+
122
+ test('handles files with malformed frontmatter and reports warnings', () => {
123
+ writeFileSync(
124
+ join(testDir, 'valid.md'),
125
+ `---
126
+ page_id: "page-1"
127
+ title: "Valid Page"
128
+ version: 1
129
+ ---
130
+
131
+ Content
132
+ `,
133
+ );
134
+
135
+ writeFileSync(
136
+ join(testDir, 'malformed.md'),
137
+ `---
138
+ this is not valid yaml: [
139
+ ---
140
+
141
+ Content
142
+ `,
143
+ );
144
+
145
+ const pageMappings = {
146
+ 'page-1': 'valid.md',
147
+ 'page-2': 'malformed.md',
148
+ };
149
+
150
+ const result = buildPageStateFromFiles(testDir, pageMappings);
151
+
152
+ // Only the valid file should be in pages map
153
+ expect(result.pages.size).toBe(1);
154
+ expect(result.pages.has('page-1')).toBe(true);
155
+ expect(result.pages.has('page-2')).toBe(false);
156
+
157
+ // Should have a warning about the malformed file
158
+ expect(result.warnings.length).toBe(1);
159
+ expect(result.warnings[0]).toContain('Failed to parse frontmatter');
160
+ expect(result.warnings[0]).toContain('malformed.md');
161
+ });
162
+
163
+ test('handles empty page mappings', () => {
164
+ const result = buildPageStateFromFiles(testDir, {});
165
+
166
+ expect(result.pages.size).toBe(0);
167
+ expect(result.pathToPageId.size).toBe(0);
168
+ expect(result.warnings.length).toBe(0);
169
+ });
170
+
171
+ test('warns when frontmatter page_id does not match mapping key', () => {
172
+ writeFileSync(
173
+ join(testDir, 'mismatched.md'),
174
+ `---
175
+ page_id: "actual-page-id"
176
+ title: "Mismatched Page"
177
+ version: 1
178
+ ---
179
+
180
+ Content
181
+ `,
182
+ );
183
+
184
+ const pageMappings = {
185
+ 'mapping-page-id': 'mismatched.md',
186
+ };
187
+
188
+ const result = buildPageStateFromFiles(testDir, pageMappings);
189
+
190
+ // Page should still be added (using mapping key)
191
+ expect(result.pages.size).toBe(1);
192
+ expect(result.pages.has('mapping-page-id')).toBe(true);
193
+
194
+ // Should have a warning about the mismatch
195
+ expect(result.warnings.length).toBe(1);
196
+ expect(result.warnings[0]).toContain('Page ID mismatch');
197
+ expect(result.warnings[0]).toContain('mapping-page-id');
198
+ expect(result.warnings[0]).toContain('actual-page-id');
199
+ });
200
+
201
+ test('skips paths that attempt directory traversal', () => {
202
+ writeFileSync(
203
+ join(testDir, 'safe.md'),
204
+ `---
205
+ page_id: "safe-page"
206
+ title: "Safe Page"
207
+ version: 1
208
+ ---
209
+
210
+ Content
211
+ `,
212
+ );
213
+
214
+ const pageMappings = {
215
+ 'safe-page': 'safe.md',
216
+ 'malicious-page': '../../../etc/passwd',
217
+ };
218
+
219
+ const result = buildPageStateFromFiles(testDir, pageMappings);
220
+
221
+ // Only the safe page should be added
222
+ expect(result.pages.size).toBe(1);
223
+ expect(result.pages.has('safe-page')).toBe(true);
224
+ expect(result.pages.has('malicious-page')).toBe(false);
225
+
226
+ // Should have a warning about the traversal attempt
227
+ expect(result.warnings.length).toBe(1);
228
+ expect(result.warnings[0]).toContain('Skipping path outside directory');
229
+ expect(result.warnings[0]).toContain('malicious-page');
230
+ });
231
+
232
+ test('uses default values for missing frontmatter fields', () => {
233
+ writeFileSync(
234
+ join(testDir, 'minimal.md'),
235
+ `---
236
+ page_id: "page-1"
237
+ ---
238
+
239
+ Minimal content
240
+ `,
241
+ );
242
+
243
+ const pageMappings = {
244
+ 'page-1': 'minimal.md',
245
+ };
246
+
247
+ const result = buildPageStateFromFiles(testDir, pageMappings);
248
+
249
+ const page = result.pages.get('page-1');
250
+ expect(page).toBeDefined();
251
+ expect(page?.title).toBe(''); // Default empty string
252
+ expect(page?.version).toBe(1); // Default version 1
253
+ expect(page?.updatedAt).toBeUndefined();
254
+ expect(page?.syncedAt).toBeUndefined();
255
+ });
256
+ });
257
+
258
+ describe('getPageInfoByPath', () => {
259
+ test('returns page info for valid file', () => {
260
+ writeFileSync(
261
+ join(testDir, 'page.md'),
262
+ `---
263
+ page_id: "page-123"
264
+ title: "Test Page"
265
+ version: 7
266
+ updated_at: "2024-02-01T12:00:00Z"
267
+ ---
268
+
269
+ Content
270
+ `,
271
+ );
272
+
273
+ const result = getPageInfoByPath(testDir, 'page.md');
274
+
275
+ expect(result).not.toBeNull();
276
+ expect(result?.pageId).toBe('page-123');
277
+ expect(result?.localPath).toBe('page.md');
278
+ expect(result?.title).toBe('Test Page');
279
+ expect(result?.version).toBe(7);
280
+ expect(result?.updatedAt).toBe('2024-02-01T12:00:00Z');
281
+ });
282
+
283
+ test('returns null for non-existent file', () => {
284
+ const result = getPageInfoByPath(testDir, 'does-not-exist.md');
285
+
286
+ expect(result).toBeNull();
287
+ });
288
+
289
+ test('returns null for file without page_id', () => {
290
+ writeFileSync(
291
+ join(testDir, 'no-id.md'),
292
+ `---
293
+ title: "Page Without ID"
294
+ ---
295
+
296
+ Content
297
+ `,
298
+ );
299
+
300
+ const result = getPageInfoByPath(testDir, 'no-id.md');
301
+
302
+ expect(result).toBeNull();
303
+ });
304
+
305
+ test('returns null for file with malformed frontmatter', () => {
306
+ writeFileSync(
307
+ join(testDir, 'bad.md'),
308
+ `---
309
+ invalid: yaml: content: [
310
+ ---
311
+
312
+ Content
313
+ `,
314
+ );
315
+
316
+ const result = getPageInfoByPath(testDir, 'bad.md');
317
+
318
+ expect(result).toBeNull();
319
+ });
320
+
321
+ test('handles nested paths', () => {
322
+ const nestedDir = join(testDir, 'deeply', 'nested', 'path');
323
+ mkdirSync(nestedDir, { recursive: true });
324
+
325
+ writeFileSync(
326
+ join(nestedDir, 'page.md'),
327
+ `---
328
+ page_id: "nested-page"
329
+ title: "Nested Page"
330
+ version: 2
331
+ ---
332
+
333
+ Nested content
334
+ `,
335
+ );
336
+
337
+ const result = getPageInfoByPath(testDir, 'deeply/nested/path/page.md');
338
+
339
+ expect(result).not.toBeNull();
340
+ expect(result?.pageId).toBe('nested-page');
341
+ expect(result?.localPath).toBe('deeply/nested/path/page.md');
342
+ });
343
+ });
344
+
345
+ describe('scanDirectoryForPages', () => {
346
+ test('discovers all markdown files with page_id', () => {
347
+ // Create directory structure
348
+ const docsDir = join(testDir, 'docs');
349
+ const apiDir = join(testDir, 'docs', 'api');
350
+ mkdirSync(apiDir, { recursive: true });
351
+
352
+ writeFileSync(
353
+ join(testDir, 'README.md'),
354
+ `---
355
+ page_id: "home"
356
+ title: "Home"
357
+ version: 1
358
+ ---
359
+
360
+ Home content
361
+ `,
362
+ );
363
+
364
+ writeFileSync(
365
+ join(docsDir, 'guide.md'),
366
+ `---
367
+ page_id: "guide"
368
+ title: "Guide"
369
+ version: 2
370
+ ---
371
+
372
+ Guide content
373
+ `,
374
+ );
375
+
376
+ writeFileSync(
377
+ join(apiDir, 'endpoints.md'),
378
+ `---
379
+ page_id: "api-endpoints"
380
+ title: "API Endpoints"
381
+ version: 3
382
+ ---
383
+
384
+ API content
385
+ `,
386
+ );
387
+
388
+ // File without page_id should be skipped
389
+ writeFileSync(
390
+ join(testDir, 'untracked.md'),
391
+ `---
392
+ title: "Untracked File"
393
+ ---
394
+
395
+ Not a Confluence page
396
+ `,
397
+ );
398
+
399
+ const result = scanDirectoryForPages(testDir);
400
+
401
+ expect(result.pages.size).toBe(3);
402
+ expect(result.pages.has('home')).toBe(true);
403
+ expect(result.pages.has('guide')).toBe(true);
404
+ expect(result.pages.has('api-endpoints')).toBe(true);
405
+
406
+ expect(result.pathToPageId.get('README.md')).toBe('home');
407
+ expect(result.pathToPageId.get('docs/guide.md')).toBe('guide');
408
+ expect(result.pathToPageId.get('docs/api/endpoints.md')).toBe('api-endpoints');
409
+ });
410
+
411
+ test('skips hidden directories', () => {
412
+ const hiddenDir = join(testDir, '.hidden');
413
+ mkdirSync(hiddenDir, { recursive: true });
414
+
415
+ writeFileSync(
416
+ join(hiddenDir, 'secret.md'),
417
+ `---
418
+ page_id: "secret"
419
+ title: "Secret Page"
420
+ version: 1
421
+ ---
422
+
423
+ Should be skipped
424
+ `,
425
+ );
426
+
427
+ writeFileSync(
428
+ join(testDir, 'visible.md'),
429
+ `---
430
+ page_id: "visible"
431
+ title: "Visible Page"
432
+ version: 1
433
+ ---
434
+
435
+ Should be included
436
+ `,
437
+ );
438
+
439
+ const result = scanDirectoryForPages(testDir);
440
+
441
+ expect(result.pages.size).toBe(1);
442
+ expect(result.pages.has('visible')).toBe(true);
443
+ expect(result.pages.has('secret')).toBe(false);
444
+ });
445
+
446
+ test('skips node_modules directory', () => {
447
+ const nodeModulesDir = join(testDir, 'node_modules', 'some-package');
448
+ mkdirSync(nodeModulesDir, { recursive: true });
449
+
450
+ writeFileSync(
451
+ join(nodeModulesDir, 'readme.md'),
452
+ `---
453
+ page_id: "npm-page"
454
+ title: "NPM Package"
455
+ version: 1
456
+ ---
457
+
458
+ Should be skipped
459
+ `,
460
+ );
461
+
462
+ writeFileSync(
463
+ join(testDir, 'app.md'),
464
+ `---
465
+ page_id: "app"
466
+ title: "App"
467
+ version: 1
468
+ ---
469
+
470
+ Should be included
471
+ `,
472
+ );
473
+
474
+ const result = scanDirectoryForPages(testDir);
475
+
476
+ expect(result.pages.size).toBe(1);
477
+ expect(result.pages.has('app')).toBe(true);
478
+ expect(result.pages.has('npm-page')).toBe(false);
479
+ });
480
+
481
+ test('skips reserved filenames (claude.md, agents.md)', () => {
482
+ writeFileSync(
483
+ join(testDir, 'CLAUDE.md'),
484
+ `---
485
+ page_id: "claude-page"
486
+ title: "Claude Instructions"
487
+ version: 1
488
+ ---
489
+
490
+ Should be skipped
491
+ `,
492
+ );
493
+
494
+ writeFileSync(
495
+ join(testDir, 'agents.md'),
496
+ `---
497
+ page_id: "agents-page"
498
+ title: "Agents Config"
499
+ version: 1
500
+ ---
501
+
502
+ Should be skipped
503
+ `,
504
+ );
505
+
506
+ writeFileSync(
507
+ join(testDir, 'readme.md'),
508
+ `---
509
+ page_id: "readme"
510
+ title: "Readme"
511
+ version: 1
512
+ ---
513
+
514
+ Should be included
515
+ `,
516
+ );
517
+
518
+ const result = scanDirectoryForPages(testDir);
519
+
520
+ expect(result.pages.size).toBe(1);
521
+ expect(result.pages.has('readme')).toBe(true);
522
+ expect(result.pages.has('claude-page')).toBe(false);
523
+ expect(result.pages.has('agents-page')).toBe(false);
524
+ });
525
+
526
+ test('handles empty directory', () => {
527
+ const result = scanDirectoryForPages(testDir);
528
+
529
+ expect(result.pages.size).toBe(0);
530
+ expect(result.pathToPageId.size).toBe(0);
531
+ });
532
+
533
+ test('handles directory with only non-markdown files', () => {
534
+ writeFileSync(join(testDir, 'config.json'), '{}');
535
+ writeFileSync(join(testDir, 'script.ts'), 'console.log("hello")');
536
+
537
+ const result = scanDirectoryForPages(testDir);
538
+
539
+ expect(result.pages.size).toBe(0);
540
+ });
541
+ });
542
+ });