@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,384 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { extractLocalLinks, sortByDependencies } from '../lib/dependency-sorter.js';
6
+ import type { PushCandidate } from '../lib/file-scanner.js';
7
+
8
+ describe('dependency-sorter', () => {
9
+ let testDir: string;
10
+
11
+ beforeEach(() => {
12
+ testDir = mkdtempSync(join(tmpdir(), 'cn-test-'));
13
+ });
14
+
15
+ afterEach(() => {
16
+ rmSync(testDir, { recursive: true, force: true });
17
+ });
18
+
19
+ describe('extractLocalLinks', () => {
20
+ test('extracts simple markdown links to .md files', () => {
21
+ const content = 'Check out [my page](other-page.md) for more info.';
22
+
23
+ const links = extractLocalLinks(content);
24
+
25
+ expect(links).toEqual(['other-page.md']);
26
+ });
27
+
28
+ test('extracts multiple links', () => {
29
+ const content = `
30
+ # Documentation
31
+
32
+ See [intro](intro.md) for getting started.
33
+ Then read [guide](guide.md) for details.
34
+ `;
35
+
36
+ const links = extractLocalLinks(content);
37
+
38
+ expect(links).toEqual(['intro.md', 'guide.md']);
39
+ });
40
+
41
+ test('extracts links with relative paths', () => {
42
+ const content = 'See [parent](../parent.md) and [child](subdir/child.md).';
43
+
44
+ const links = extractLocalLinks(content);
45
+
46
+ expect(links).toEqual(['../parent.md', 'subdir/child.md']);
47
+ });
48
+
49
+ test('ignores http links', () => {
50
+ const content = 'See [external](http://example.com/page.md) for more.';
51
+
52
+ const links = extractLocalLinks(content);
53
+
54
+ expect(links).toEqual([]);
55
+ });
56
+
57
+ test('ignores https links', () => {
58
+ const content = 'See [secure](https://example.com/page.md) for more.';
59
+
60
+ const links = extractLocalLinks(content);
61
+
62
+ expect(links).toEqual([]);
63
+ });
64
+
65
+ test('removes anchor fragments from links', () => {
66
+ const content = 'See [section](guide.md#installation) for setup.';
67
+
68
+ const links = extractLocalLinks(content);
69
+
70
+ expect(links).toEqual(['guide.md']);
71
+ });
72
+
73
+ test('handles nested brackets in link text', () => {
74
+ const content = 'See [text [nested]](page.md) for info.';
75
+
76
+ const links = extractLocalLinks(content);
77
+
78
+ expect(links).toEqual(['page.md']);
79
+ });
80
+
81
+ test('returns empty array for content with no links', () => {
82
+ const content = '# Just a heading\n\nSome plain text.';
83
+
84
+ const links = extractLocalLinks(content);
85
+
86
+ expect(links).toEqual([]);
87
+ });
88
+
89
+ test('returns empty array for links to non-md files', () => {
90
+ const content = 'See [image](photo.png) and [doc](file.pdf).';
91
+
92
+ const links = extractLocalLinks(content);
93
+
94
+ expect(links).toEqual([]);
95
+ });
96
+ });
97
+
98
+ describe('sortByDependencies', () => {
99
+ function createCandidate(path: string, type: 'new' | 'modified' = 'new'): PushCandidate {
100
+ return {
101
+ path,
102
+ type,
103
+ title: path.replace('.md', ''),
104
+ };
105
+ }
106
+
107
+ test('returns original order when no files have links', () => {
108
+ writeFileSync(join(testDir, 'a.md'), '# File A');
109
+ writeFileSync(join(testDir, 'b.md'), '# File B');
110
+ writeFileSync(join(testDir, 'c.md'), '# File C');
111
+
112
+ const candidates: PushCandidate[] = [createCandidate('a.md'), createCandidate('b.md'), createCandidate('c.md')];
113
+
114
+ const { sorted, cycles } = sortByDependencies(candidates, testDir);
115
+
116
+ expect(cycles).toEqual([]);
117
+ expect(sorted.map((c) => c.path)).toEqual(['a.md', 'b.md', 'c.md']);
118
+ });
119
+
120
+ test('sorts linear chain so dependencies come first', () => {
121
+ // A links to B, B links to C
122
+ // Expected order: C, B, A
123
+ writeFileSync(join(testDir, 'a.md'), '# A\nSee [B](b.md)');
124
+ writeFileSync(join(testDir, 'b.md'), '# B\nSee [C](c.md)');
125
+ writeFileSync(join(testDir, 'c.md'), '# C');
126
+
127
+ const candidates: PushCandidate[] = [createCandidate('a.md'), createCandidate('b.md'), createCandidate('c.md')];
128
+
129
+ const { sorted, cycles } = sortByDependencies(candidates, testDir);
130
+
131
+ expect(cycles).toEqual([]);
132
+ expect(sorted.map((c) => c.path)).toEqual(['c.md', 'b.md', 'a.md']);
133
+ });
134
+
135
+ test('handles diamond dependency pattern', () => {
136
+ // A -> B, A -> C, B -> D, C -> D
137
+ // Expected: D first, then B and C (in any order), then A
138
+ writeFileSync(join(testDir, 'a.md'), '# A\nSee [B](b.md) and [C](c.md)');
139
+ writeFileSync(join(testDir, 'b.md'), '# B\nSee [D](d.md)');
140
+ writeFileSync(join(testDir, 'c.md'), '# C\nSee [D](d.md)');
141
+ writeFileSync(join(testDir, 'd.md'), '# D');
142
+
143
+ const candidates: PushCandidate[] = [
144
+ createCandidate('a.md'),
145
+ createCandidate('b.md'),
146
+ createCandidate('c.md'),
147
+ createCandidate('d.md'),
148
+ ];
149
+
150
+ const { sorted, cycles } = sortByDependencies(candidates, testDir);
151
+
152
+ expect(cycles).toEqual([]);
153
+
154
+ const paths = sorted.map((c) => c.path);
155
+ // D must come before B and C
156
+ expect(paths.indexOf('d.md')).toBeLessThan(paths.indexOf('b.md'));
157
+ expect(paths.indexOf('d.md')).toBeLessThan(paths.indexOf('c.md'));
158
+ // B and C must come before A
159
+ expect(paths.indexOf('b.md')).toBeLessThan(paths.indexOf('a.md'));
160
+ expect(paths.indexOf('c.md')).toBeLessThan(paths.indexOf('a.md'));
161
+ });
162
+
163
+ test('detects and reports simple circular dependency', () => {
164
+ // A -> B, B -> A
165
+ writeFileSync(join(testDir, 'a.md'), '# A\nSee [B](b.md)');
166
+ writeFileSync(join(testDir, 'b.md'), '# B\nSee [A](a.md)');
167
+
168
+ const candidates: PushCandidate[] = [createCandidate('a.md'), createCandidate('b.md')];
169
+
170
+ const { sorted, cycles } = sortByDependencies(candidates, testDir);
171
+
172
+ // Should detect cycle
173
+ expect(cycles.length).toBeGreaterThan(0);
174
+ // Both files should still be in output
175
+ expect(sorted).toHaveLength(2);
176
+ });
177
+
178
+ test('detects longer cycles', () => {
179
+ // A -> B -> C -> A
180
+ writeFileSync(join(testDir, 'a.md'), '# A\nSee [B](b.md)');
181
+ writeFileSync(join(testDir, 'b.md'), '# B\nSee [C](c.md)');
182
+ writeFileSync(join(testDir, 'c.md'), '# C\nSee [A](a.md)');
183
+
184
+ const candidates: PushCandidate[] = [createCandidate('a.md'), createCandidate('b.md'), createCandidate('c.md')];
185
+
186
+ const { sorted, cycles } = sortByDependencies(candidates, testDir);
187
+
188
+ expect(cycles.length).toBeGreaterThan(0);
189
+ // All files should still be in output
190
+ expect(sorted).toHaveLength(3);
191
+ });
192
+
193
+ test('ignores links to files not in candidates', () => {
194
+ // A links to B (candidate) and X (not a candidate)
195
+ writeFileSync(join(testDir, 'a.md'), '# A\nSee [B](b.md) and [X](x.md)');
196
+ writeFileSync(join(testDir, 'b.md'), '# B');
197
+ writeFileSync(join(testDir, 'x.md'), '# X (already synced)');
198
+
199
+ const candidates: PushCandidate[] = [createCandidate('a.md'), createCandidate('b.md')];
200
+
201
+ const { sorted, cycles } = sortByDependencies(candidates, testDir);
202
+
203
+ expect(cycles).toEqual([]);
204
+ // B should come before A because A depends on B
205
+ expect(sorted.map((c) => c.path)).toEqual(['b.md', 'a.md']);
206
+ });
207
+
208
+ test('handles links in subdirectories', () => {
209
+ mkdirSync(join(testDir, 'docs'));
210
+
211
+ // docs/a.md links to docs/b.md
212
+ writeFileSync(join(testDir, 'docs', 'a.md'), '# A\nSee [B](b.md)');
213
+ writeFileSync(join(testDir, 'docs', 'b.md'), '# B');
214
+
215
+ const candidates: PushCandidate[] = [createCandidate('docs/a.md'), createCandidate('docs/b.md')];
216
+
217
+ const { sorted, cycles } = sortByDependencies(candidates, testDir);
218
+
219
+ expect(cycles).toEqual([]);
220
+ expect(sorted.map((c) => c.path)).toEqual(['docs/b.md', 'docs/a.md']);
221
+ });
222
+
223
+ test('handles cross-directory links', () => {
224
+ mkdirSync(join(testDir, 'guides'));
225
+ mkdirSync(join(testDir, 'reference'));
226
+
227
+ // guides/a.md links to reference/b.md
228
+ writeFileSync(join(testDir, 'guides', 'a.md'), '# A\nSee [B](../reference/b.md)');
229
+ writeFileSync(join(testDir, 'reference', 'b.md'), '# B');
230
+
231
+ const candidates: PushCandidate[] = [createCandidate('guides/a.md'), createCandidate('reference/b.md')];
232
+
233
+ const { sorted, cycles } = sortByDependencies(candidates, testDir);
234
+
235
+ expect(cycles).toEqual([]);
236
+ expect(sorted.map((c) => c.path)).toEqual(['reference/b.md', 'guides/a.md']);
237
+ });
238
+
239
+ test('handles empty candidate list', () => {
240
+ const { sorted, cycles } = sortByDependencies([], testDir);
241
+
242
+ expect(sorted).toEqual([]);
243
+ expect(cycles).toEqual([]);
244
+ });
245
+
246
+ test('handles single candidate with no links', () => {
247
+ writeFileSync(join(testDir, 'only.md'), '# Only file');
248
+
249
+ const candidates: PushCandidate[] = [createCandidate('only.md')];
250
+
251
+ const { sorted, cycles } = sortByDependencies(candidates, testDir);
252
+
253
+ expect(cycles).toEqual([]);
254
+ expect(sorted).toEqual(candidates);
255
+ });
256
+
257
+ test('handles single candidate with self-link', () => {
258
+ writeFileSync(join(testDir, 'self.md'), '# Self\nSee [Self](self.md)');
259
+
260
+ const candidates: PushCandidate[] = [createCandidate('self.md')];
261
+
262
+ const { sorted, cycles } = sortByDependencies(candidates, testDir);
263
+
264
+ // Self-link creates a cycle
265
+ expect(cycles.length).toBeGreaterThan(0);
266
+ expect(sorted).toHaveLength(1);
267
+ });
268
+
269
+ test('preserves candidate type and title', () => {
270
+ writeFileSync(join(testDir, 'new.md'), '# New\nSee [modified](modified.md)');
271
+ writeFileSync(join(testDir, 'modified.md'), '# Modified');
272
+
273
+ const candidates: PushCandidate[] = [
274
+ { path: 'new.md', type: 'new', title: 'New Page' },
275
+ { path: 'modified.md', type: 'modified', title: 'Modified Page', pageId: '123' },
276
+ ];
277
+
278
+ const { sorted } = sortByDependencies(candidates, testDir);
279
+
280
+ // modified.md should come first (dependency)
281
+ expect(sorted[0]).toEqual({
282
+ path: 'modified.md',
283
+ type: 'modified',
284
+ title: 'Modified Page',
285
+ pageId: '123',
286
+ });
287
+ expect(sorted[1]).toEqual({
288
+ path: 'new.md',
289
+ type: 'new',
290
+ title: 'New Page',
291
+ });
292
+ });
293
+
294
+ test('handles mixed candidates with and without dependencies', () => {
295
+ // A -> B, C has no links, D -> E
296
+ writeFileSync(join(testDir, 'a.md'), '# A\nSee [B](b.md)');
297
+ writeFileSync(join(testDir, 'b.md'), '# B');
298
+ writeFileSync(join(testDir, 'c.md'), '# C (independent)');
299
+ writeFileSync(join(testDir, 'd.md'), '# D\nSee [E](e.md)');
300
+ writeFileSync(join(testDir, 'e.md'), '# E');
301
+
302
+ const candidates: PushCandidate[] = [
303
+ createCandidate('a.md'),
304
+ createCandidate('b.md'),
305
+ createCandidate('c.md'),
306
+ createCandidate('d.md'),
307
+ createCandidate('e.md'),
308
+ ];
309
+
310
+ const { sorted, cycles } = sortByDependencies(candidates, testDir);
311
+
312
+ expect(cycles).toEqual([]);
313
+
314
+ const paths = sorted.map((c) => c.path);
315
+ // B before A
316
+ expect(paths.indexOf('b.md')).toBeLessThan(paths.indexOf('a.md'));
317
+ // E before D
318
+ expect(paths.indexOf('e.md')).toBeLessThan(paths.indexOf('d.md'));
319
+ // All files included
320
+ expect(paths).toHaveLength(5);
321
+ });
322
+
323
+ test('gracefully handles unreadable files', () => {
324
+ writeFileSync(join(testDir, 'readable.md'), '# Readable\nSee [unreadable](unreadable.md)');
325
+ // unreadable.md doesn't exist as a file, but is in candidates
326
+
327
+ const candidates: PushCandidate[] = [createCandidate('readable.md'), createCandidate('unreadable.md')];
328
+
329
+ // Should not throw, even though unreadable.md can't be read
330
+ const { sorted, cycles } = sortByDependencies(candidates, testDir);
331
+
332
+ expect(cycles).toEqual([]);
333
+ expect(sorted).toHaveLength(2);
334
+ });
335
+
336
+ test('prioritizes new pages before modified pages in cycles', () => {
337
+ // Create circular dependency: modified -> new -> modified
338
+ // The new page should be pushed first so it gets a page_id,
339
+ // allowing the modified page to resolve links to it
340
+ mkdirSync(join(testDir, 'getting-started'));
341
+ mkdirSync(join(testDir, 'tools'));
342
+
343
+ writeFileSync(join(testDir, 'getting-started', 'onboarding.md'), '# Onboarding\nSee [CLI](../tools/cli.md)');
344
+ writeFileSync(join(testDir, 'tools', 'cli.md'), '# CLI\nSee [Onboarding](../getting-started/onboarding.md)');
345
+
346
+ const candidates: PushCandidate[] = [
347
+ { path: 'getting-started/onboarding.md', type: 'modified', title: 'Onboarding', pageId: '123' },
348
+ { path: 'tools/cli.md', type: 'new', title: 'CLI' },
349
+ ];
350
+
351
+ const { sorted, cycles } = sortByDependencies(candidates, testDir);
352
+
353
+ // Should detect the cycle
354
+ expect(cycles.length).toBeGreaterThan(0);
355
+ // Both files should be in output
356
+ expect(sorted).toHaveLength(2);
357
+ // New page should come FIRST (before modified) so it gets created first
358
+ expect(sorted[0].type).toBe('new');
359
+ expect(sorted[0].path).toBe('tools/cli.md');
360
+ expect(sorted[1].type).toBe('modified');
361
+ expect(sorted[1].path).toBe('getting-started/onboarding.md');
362
+ });
363
+
364
+ test('maintains alphabetical order within same type in cycles', () => {
365
+ // All new pages in a cycle should maintain alphabetical order
366
+ writeFileSync(join(testDir, 'c.md'), '# C\nSee [A](a.md)');
367
+ writeFileSync(join(testDir, 'a.md'), '# A\nSee [B](b.md)');
368
+ writeFileSync(join(testDir, 'b.md'), '# B\nSee [C](c.md)');
369
+
370
+ const candidates: PushCandidate[] = [
371
+ { path: 'c.md', type: 'new', title: 'C' },
372
+ { path: 'a.md', type: 'new', title: 'A' },
373
+ { path: 'b.md', type: 'new', title: 'B' },
374
+ ];
375
+
376
+ const { sorted, cycles } = sortByDependencies(candidates, testDir);
377
+
378
+ expect(cycles.length).toBeGreaterThan(0);
379
+ // All same type (new), should preserve original order from candidates array
380
+ // Original order is c, a, b
381
+ expect(sorted.map((c) => c.path)).toEqual(['c.md', 'a.md', 'b.md']);
382
+ });
383
+ });
384
+ });
@@ -0,0 +1,199 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ ConfigError,
4
+ FileSystemError,
5
+ ParseError,
6
+ ValidationError,
7
+ ApiError,
8
+ RateLimitError,
9
+ AuthError,
10
+ SyncError,
11
+ NetworkError,
12
+ SpaceNotFoundError,
13
+ PageNotFoundError,
14
+ VersionConflictError,
15
+ FolderNotFoundError,
16
+ EXIT_CODES,
17
+ getExitCodeForError,
18
+ } from '../lib/errors.js';
19
+
20
+ describe('Error types', () => {
21
+ describe('ConfigError', () => {
22
+ test('has correct _tag', () => {
23
+ const error = new ConfigError('Test error');
24
+ expect(error._tag).toBe('ConfigError');
25
+ expect(error.message).toBe('Test error');
26
+ expect(error.name).toBe('ConfigError');
27
+ });
28
+ });
29
+
30
+ describe('FileSystemError', () => {
31
+ test('has correct _tag', () => {
32
+ const error = new FileSystemError('File not found');
33
+ expect(error._tag).toBe('FileSystemError');
34
+ expect(error.message).toBe('File not found');
35
+ });
36
+ });
37
+
38
+ describe('ParseError', () => {
39
+ test('has correct _tag', () => {
40
+ const error = new ParseError('Invalid JSON');
41
+ expect(error._tag).toBe('ParseError');
42
+ });
43
+ });
44
+
45
+ describe('ValidationError', () => {
46
+ test('has correct _tag', () => {
47
+ const error = new ValidationError('Invalid config');
48
+ expect(error._tag).toBe('ValidationError');
49
+ });
50
+ });
51
+
52
+ describe('ApiError', () => {
53
+ test('has correct _tag and statusCode', () => {
54
+ const error = new ApiError('Not found', 404);
55
+ expect(error._tag).toBe('ApiError');
56
+ expect(error.statusCode).toBe(404);
57
+ });
58
+ });
59
+
60
+ describe('RateLimitError', () => {
61
+ test('has correct _tag and retryAfter', () => {
62
+ const error = new RateLimitError('Too many requests', 60);
63
+ expect(error._tag).toBe('RateLimitError');
64
+ expect(error.retryAfter).toBe(60);
65
+ });
66
+
67
+ test('handles undefined retryAfter', () => {
68
+ const error = new RateLimitError('Too many requests');
69
+ expect(error.retryAfter).toBeUndefined();
70
+ });
71
+ });
72
+
73
+ describe('AuthError', () => {
74
+ test('has correct _tag and statusCode', () => {
75
+ const error = new AuthError('Unauthorized', 401);
76
+ expect(error._tag).toBe('AuthError');
77
+ expect(error.statusCode).toBe(401);
78
+ });
79
+ });
80
+
81
+ describe('SyncError', () => {
82
+ test('has correct _tag', () => {
83
+ const error = new SyncError('Sync failed');
84
+ expect(error._tag).toBe('SyncError');
85
+ });
86
+ });
87
+
88
+ describe('NetworkError', () => {
89
+ test('has correct _tag', () => {
90
+ const error = new NetworkError('Connection failed');
91
+ expect(error._tag).toBe('NetworkError');
92
+ });
93
+ });
94
+
95
+ describe('SpaceNotFoundError', () => {
96
+ test('has correct _tag and spaceKey', () => {
97
+ const error = new SpaceNotFoundError('TEST');
98
+ expect(error._tag).toBe('SpaceNotFoundError');
99
+ expect(error.spaceKey).toBe('TEST');
100
+ expect(error.message).toBe('Space not found: TEST');
101
+ });
102
+ });
103
+
104
+ describe('PageNotFoundError', () => {
105
+ test('has correct _tag and pageId', () => {
106
+ const error = new PageNotFoundError('123456');
107
+ expect(error._tag).toBe('PageNotFoundError');
108
+ expect(error.pageId).toBe('123456');
109
+ expect(error.message).toBe('Page not found: 123456');
110
+ });
111
+ });
112
+
113
+ describe('VersionConflictError', () => {
114
+ test('has correct _tag and versions', () => {
115
+ const error = new VersionConflictError(3, 5);
116
+ expect(error._tag).toBe('VersionConflictError');
117
+ expect(error.localVersion).toBe(3);
118
+ expect(error.remoteVersion).toBe(5);
119
+ expect(error.message).toContain('local version 3');
120
+ expect(error.message).toContain('remote version 5');
121
+ });
122
+ });
123
+
124
+ describe('FolderNotFoundError', () => {
125
+ test('has correct _tag and folderId', () => {
126
+ const error = new FolderNotFoundError('folder-123');
127
+ expect(error._tag).toBe('FolderNotFoundError');
128
+ expect(error.folderId).toBe('folder-123');
129
+ expect(error.message).toBe('Folder not found: folder-123');
130
+ });
131
+ });
132
+ });
133
+
134
+ describe('EXIT_CODES', () => {
135
+ test('has all expected codes', () => {
136
+ expect(EXIT_CODES.SUCCESS).toBe(0);
137
+ expect(EXIT_CODES.GENERAL_ERROR).toBe(1);
138
+ expect(EXIT_CODES.CONFIG_ERROR).toBe(2);
139
+ expect(EXIT_CODES.AUTH_ERROR).toBe(3);
140
+ expect(EXIT_CODES.NETWORK_ERROR).toBe(4);
141
+ expect(EXIT_CODES.SPACE_NOT_FOUND).toBe(5);
142
+ expect(EXIT_CODES.INVALID_ARGUMENTS).toBe(6);
143
+ expect(EXIT_CODES.PAGE_NOT_FOUND).toBe(7);
144
+ expect(EXIT_CODES.VERSION_CONFLICT).toBe(8);
145
+ expect(EXIT_CODES.FOLDER_NOT_FOUND).toBe(9);
146
+ });
147
+ });
148
+
149
+ describe('getExitCodeForError', () => {
150
+ test('returns CONFIG_ERROR for ConfigError', () => {
151
+ const error = new ConfigError('Test');
152
+ expect(getExitCodeForError(error)).toBe(EXIT_CODES.CONFIG_ERROR);
153
+ });
154
+
155
+ test('returns CONFIG_ERROR for ValidationError', () => {
156
+ const error = new ValidationError('Test');
157
+ expect(getExitCodeForError(error)).toBe(EXIT_CODES.CONFIG_ERROR);
158
+ });
159
+
160
+ test('returns AUTH_ERROR for AuthError', () => {
161
+ const error = new AuthError('Test', 401);
162
+ expect(getExitCodeForError(error)).toBe(EXIT_CODES.AUTH_ERROR);
163
+ });
164
+
165
+ test('returns NETWORK_ERROR for NetworkError', () => {
166
+ const error = new NetworkError('Test');
167
+ expect(getExitCodeForError(error)).toBe(EXIT_CODES.NETWORK_ERROR);
168
+ });
169
+
170
+ test('returns NETWORK_ERROR for RateLimitError', () => {
171
+ const error = new RateLimitError('Test');
172
+ expect(getExitCodeForError(error)).toBe(EXIT_CODES.NETWORK_ERROR);
173
+ });
174
+
175
+ test('returns SPACE_NOT_FOUND for SpaceNotFoundError', () => {
176
+ const error = new SpaceNotFoundError('TEST');
177
+ expect(getExitCodeForError(error)).toBe(EXIT_CODES.SPACE_NOT_FOUND);
178
+ });
179
+
180
+ test('returns PAGE_NOT_FOUND for PageNotFoundError', () => {
181
+ const error = new PageNotFoundError('123456');
182
+ expect(getExitCodeForError(error)).toBe(EXIT_CODES.PAGE_NOT_FOUND);
183
+ });
184
+
185
+ test('returns VERSION_CONFLICT for VersionConflictError', () => {
186
+ const error = new VersionConflictError(3, 5);
187
+ expect(getExitCodeForError(error)).toBe(EXIT_CODES.VERSION_CONFLICT);
188
+ });
189
+
190
+ test('returns FOLDER_NOT_FOUND for FolderNotFoundError', () => {
191
+ const error = new FolderNotFoundError('folder-123');
192
+ expect(getExitCodeForError(error)).toBe(EXIT_CODES.FOLDER_NOT_FOUND);
193
+ });
194
+
195
+ test('returns GENERAL_ERROR for other errors', () => {
196
+ const error = new ApiError('Test', 500);
197
+ expect(getExitCodeForError(error)).toBe(EXIT_CODES.GENERAL_ERROR);
198
+ });
199
+ });