@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,221 @@
1
+ import { mkdirSync, writeFileSync } from 'node:fs';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+ import type { ConfluenceClient, User } from '../confluence-client/index.js';
4
+ import { SyncError } from '../errors.js';
5
+ import { RESERVED_FILENAMES } from '../file-scanner.js';
6
+ import { buildPageLookupMapFromCache, type MarkdownConverter } from '../markdown/index.js';
7
+ import { buildPageStateFromFiles } from '../page-state.js';
8
+ import {
9
+ readSpaceConfig,
10
+ updateLastSync,
11
+ updatePageSyncInfo,
12
+ writeSpaceConfig,
13
+ type SpaceConfigWithState,
14
+ } from '../space-config.js';
15
+ import type { SyncOptions, SyncResult } from './sync-engine.js';
16
+
17
+ /**
18
+ * Create a cached user fetcher to avoid redundant API calls
19
+ */
20
+ function createUserFetcher(client: ConfluenceClient): (accountId: string | undefined) => Promise<User | undefined> {
21
+ const cache = new Map<string, User | undefined>();
22
+ return async (accountId: string | undefined): Promise<User | undefined> => {
23
+ if (!accountId) return undefined;
24
+ if (cache.has(accountId)) {
25
+ return cache.get(accountId);
26
+ }
27
+ try {
28
+ const user = await client.getUser(accountId);
29
+ cache.set(accountId, user);
30
+ return user;
31
+ } catch {
32
+ cache.set(accountId, undefined);
33
+ return undefined;
34
+ }
35
+ };
36
+ }
37
+
38
+ /**
39
+ * Validate that a path stays within a base directory (prevents path traversal)
40
+ */
41
+ function assertPathWithinDirectory(baseDir: string, targetPath: string): void {
42
+ const resolvedBase = resolve(baseDir);
43
+ const resolvedTarget = resolve(baseDir, targetPath);
44
+ if (!resolvedTarget.startsWith(`${resolvedBase}/`) && resolvedTarget !== resolvedBase) {
45
+ throw new SyncError(`Path traversal detected: "${targetPath}" escapes base directory`);
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Check if a path uses a reserved filename (used by coding agents)
51
+ */
52
+ function isReservedPath(path: string): boolean {
53
+ return RESERVED_FILENAMES.has(basename(path).toLowerCase());
54
+ }
55
+
56
+ /**
57
+ * Fast path: resync specific pages without fetching the entire space
58
+ */
59
+ export async function syncSpecificPages(
60
+ client: ConfluenceClient,
61
+ converter: MarkdownConverter,
62
+ baseUrl: string,
63
+ directory: string,
64
+ pageRefs: string[],
65
+ options: SyncOptions = {},
66
+ ): Promise<SyncResult> {
67
+ const result: SyncResult = {
68
+ success: true,
69
+ changes: { added: [], modified: [], deleted: [] },
70
+ warnings: [],
71
+ errors: [],
72
+ cancelled: false,
73
+ };
74
+ const progress = options.progress;
75
+
76
+ try {
77
+ let config = readSpaceConfig(directory);
78
+ if (!config) {
79
+ throw new SyncError('No space configuration found.');
80
+ }
81
+
82
+ // Build reverse lookup from localPath to pageId
83
+ // Per ADR-0024: config.pages is now Record<string, string> (pageId -> localPath)
84
+ const pathToPageId = new Map<string, string>();
85
+ for (const [pageId, localPath] of Object.entries(config.pages)) {
86
+ pathToPageId.set(localPath, pageId);
87
+ }
88
+
89
+ // Resolve page references to IDs
90
+ const pageIds: string[] = [];
91
+ for (const pageRef of pageRefs) {
92
+ const normalizedPath = pageRef.replace(/^\.\//, '');
93
+ const normalizedId = pathToPageId.get(normalizedPath);
94
+ const directId = pathToPageId.get(pageRef);
95
+
96
+ if (normalizedId) {
97
+ pageIds.push(normalizedId);
98
+ } else if (directId) {
99
+ pageIds.push(directId);
100
+ } else if (config.pages[pageRef]) {
101
+ pageIds.push(pageRef);
102
+ } else {
103
+ result.warnings.push(`Could not find page for: ${pageRef}`);
104
+ }
105
+ }
106
+
107
+ if (pageIds.length === 0) {
108
+ return result;
109
+ }
110
+
111
+ progress?.onFetchStart?.();
112
+ progress?.onFetchComplete?.(pageIds.length, 0);
113
+
114
+ // Build diff - all specified pages are "modified"
115
+ // Per ADR-0024: config.pages[pageId] is now just the localPath string
116
+ for (const pageId of pageIds) {
117
+ const localPath = config.pages[pageId];
118
+ if (localPath) {
119
+ const title =
120
+ localPath
121
+ .split('/')
122
+ .pop()
123
+ ?.replace('.md', '')
124
+ .replace(/readme$/i, '') || pageId;
125
+ result.changes.modified.push({
126
+ type: 'modified',
127
+ pageId,
128
+ title,
129
+ localPath,
130
+ });
131
+ }
132
+ }
133
+
134
+ progress?.onDiffComplete?.(0, result.changes.modified.length, 0);
135
+
136
+ if (options.dryRun) {
137
+ return result;
138
+ }
139
+
140
+ // Create cached user fetcher
141
+ const fetchUser = createUserFetcher(client);
142
+
143
+ // Build page state and lookup map for link conversion (ADR-0022, ADR-0024)
144
+ // Enable duplicate title warnings during sync
145
+ const pageStateBuildResult = buildPageStateFromFiles(directory, config.pages);
146
+ result.warnings.push(...pageStateBuildResult.warnings);
147
+ const pageLookupMap = buildPageLookupMapFromCache(pageStateBuildResult, true);
148
+
149
+ // Process each page
150
+ let currentChange = 0;
151
+ const totalChanges = result.changes.modified.length;
152
+
153
+ for (const change of result.changes.modified) {
154
+ currentChange++;
155
+ progress?.onPageStart?.(currentChange, totalChanges, change.title, 'modified');
156
+
157
+ // Skip reserved filenames (used by coding agents)
158
+ if (change.localPath && isReservedPath(change.localPath)) {
159
+ result.warnings.push(`Skipping page "${change.title}": reserved filename ${basename(change.localPath)}`);
160
+ continue;
161
+ }
162
+
163
+ try {
164
+ const fullPage = await client.getPage(change.pageId, true);
165
+ const labels = await client.getAllLabels(change.pageId);
166
+
167
+ // Per ADR-0024: config.pages[id] is just localPath, get parent title from path
168
+ let parentTitle: string | undefined;
169
+ const parentLocalPath = fullPage.parentId ? config.pages[fullPage.parentId] : undefined;
170
+ if (parentLocalPath) {
171
+ parentTitle = parentLocalPath.split('/').pop()?.replace('.md', '');
172
+ }
173
+
174
+ // Get author and last modifier user information (cached)
175
+ const author = await fetchUser(fullPage.authorId);
176
+ const lastModifier = await fetchUser(fullPage.version?.authorId);
177
+
178
+ const localPath = change.localPath ?? '';
179
+
180
+ // Convert to markdown with link conversion (ADR-0022)
181
+ // Note: childCount is undefined for specific-page syncs (requires full sync to compute)
182
+ const { markdown, warnings } = converter.convertPage(
183
+ fullPage,
184
+ labels,
185
+ parentTitle,
186
+ baseUrl,
187
+ author,
188
+ lastModifier,
189
+ localPath,
190
+ pageLookupMap,
191
+ undefined, // childCount not available in specific-page sync
192
+ );
193
+ result.warnings.push(...warnings.map((w) => `${fullPage.title}: ${w}`));
194
+
195
+ assertPathWithinDirectory(directory, localPath);
196
+ const fullPath = join(directory, localPath);
197
+ mkdirSync(dirname(fullPath), { recursive: true });
198
+ writeFileSync(fullPath, markdown, 'utf-8');
199
+
200
+ // Per ADR-0024: Only store pageId -> localPath mapping
201
+ // Version, title, timestamps are in frontmatter
202
+ config = updatePageSyncInfo(config, { pageId: change.pageId, localPath });
203
+ writeSpaceConfig(directory, config);
204
+
205
+ progress?.onPageComplete?.(currentChange, totalChanges, change.title, localPath);
206
+ } catch (error) {
207
+ result.errors.push(`Failed to sync page "${change.title}": ${error}`);
208
+ result.success = false;
209
+ progress?.onPageError?.(change.title, String(error));
210
+ }
211
+ }
212
+
213
+ config = updateLastSync(config);
214
+ writeSpaceConfig(directory, config);
215
+ } catch (error) {
216
+ result.errors.push(`Sync failed: ${error}`);
217
+ result.success = false;
218
+ }
219
+
220
+ return result;
221
+ }
@@ -0,0 +1,42 @@
1
+ /** Sync diff types */
2
+ export interface SyncChange {
3
+ type: 'added' | 'modified' | 'deleted';
4
+ pageId: string;
5
+ title: string;
6
+ localPath?: string;
7
+ }
8
+
9
+ export interface SyncDiff {
10
+ added: SyncChange[];
11
+ modified: SyncChange[];
12
+ deleted: SyncChange[];
13
+ }
14
+
15
+ /**
16
+ * Progress reporter for sync operations
17
+ */
18
+ export interface SyncProgressReporter {
19
+ onFetchStart?: () => void;
20
+ onFetchComplete?: (pageCount: number, folderCount: number) => void;
21
+ onDiffComplete?: (added: number, modified: number, deleted: number) => void;
22
+ onPageStart?: (index: number, total: number, title: string, type: 'added' | 'modified' | 'deleted') => void;
23
+ onPageComplete?: (index: number, total: number, title: string, localPath: string) => void;
24
+ onPageError?: (title: string, error: string) => void;
25
+ }
26
+
27
+ export interface SyncOptions {
28
+ dryRun?: boolean;
29
+ force?: boolean;
30
+ forcePages?: string[]; // Page IDs or local paths to force resync
31
+ depth?: number;
32
+ progress?: SyncProgressReporter;
33
+ signal?: { cancelled: boolean };
34
+ }
35
+
36
+ export interface SyncResult {
37
+ success: boolean;
38
+ changes: SyncDiff;
39
+ warnings: string[];
40
+ errors: string[];
41
+ cancelled?: boolean;
42
+ }
@@ -0,0 +1,68 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import { HttpResponse, http } from 'msw';
3
+ import { ConfluenceClient } from '../lib/confluence-client/client.js';
4
+ import { createValidAttachment } from './msw-schema-validation.js';
5
+ import { server } from './setup-msw.js';
6
+
7
+ const testConfig = {
8
+ confluenceUrl: 'https://test.atlassian.net',
9
+ email: 'test@example.com',
10
+ apiToken: 'test-token',
11
+ };
12
+
13
+ describe('ConfluenceClient - attachment operations', () => {
14
+ test('lists attachments (empty by default)', async () => {
15
+ const client = new ConfluenceClient(testConfig);
16
+ const response = await client.getAttachments('page-123');
17
+ expect(response.results).toBeArray();
18
+ expect(response.results.length).toBe(0);
19
+ });
20
+
21
+ test('lists attachments when present', async () => {
22
+ server.use(
23
+ http.get('*/wiki/api/v2/pages/:pageId/attachments', () => {
24
+ return HttpResponse.json({
25
+ results: [createValidAttachment({ id: 'att-1', title: 'image.png', mediaType: 'image/png', fileSize: 2048 })],
26
+ });
27
+ }),
28
+ );
29
+
30
+ const client = new ConfluenceClient(testConfig);
31
+ const response = await client.getAttachments('page-123');
32
+ expect(response.results.length).toBe(1);
33
+ expect(response.results[0].id).toBe('att-1');
34
+ expect(response.results[0].title).toBe('image.png');
35
+ });
36
+
37
+ test('uploads attachment without throwing', async () => {
38
+ const client = new ConfluenceClient(testConfig);
39
+ await client.uploadAttachment('page-123', 'test.png', Buffer.from('fake-image-data'), 'image/png');
40
+ });
41
+
42
+ test('deletes attachment without throwing', async () => {
43
+ const client = new ConfluenceClient(testConfig);
44
+ await client.deleteAttachment('att-123');
45
+ });
46
+
47
+ test('throws on 401 when getting attachments', async () => {
48
+ server.use(
49
+ http.get('*/wiki/api/v2/pages/:pageId/attachments', () => {
50
+ return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 });
51
+ }),
52
+ );
53
+
54
+ const client = new ConfluenceClient(testConfig);
55
+ await expect(client.getAttachments('page-123')).rejects.toThrow();
56
+ });
57
+
58
+ test('throws on 404 when deleting attachment', async () => {
59
+ server.use(
60
+ http.delete('*/wiki/api/v2/attachments/:attachmentId', () => {
61
+ return HttpResponse.json({ error: 'Not found' }, { status: 404 });
62
+ }),
63
+ );
64
+
65
+ const client = new ConfluenceClient(testConfig);
66
+ await expect(client.deleteAttachment('nonexistent')).rejects.toThrow();
67
+ });
68
+ });
@@ -0,0 +1,373 @@
1
+ import { afterEach, beforeEach, describe, expect, test, spyOn } from 'bun:test';
2
+ import { existsSync, mkdirSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { tmpdir } from 'node:os';
5
+ import { http, HttpResponse } from 'msw';
6
+ import { cloneCommand } from '../cli/commands/clone.js';
7
+ import { server } from './setup-msw.js';
8
+ import { createValidSpace, createValidPage } from './msw-schema-validation.js';
9
+
10
+ describe('cloneCommand', () => {
11
+ let testDir: string;
12
+ let originalCwd: string;
13
+
14
+ beforeEach(() => {
15
+ // Create a unique test directory
16
+ testDir = join(tmpdir(), `cn-test-${Date.now()}`);
17
+ mkdirSync(testDir, { recursive: true });
18
+
19
+ // Change to test directory
20
+ originalCwd = process.cwd();
21
+ process.chdir(testDir);
22
+
23
+ // Mock config
24
+ process.env.HOME = testDir;
25
+ const configDir = join(testDir, '.cn');
26
+ mkdirSync(configDir, { recursive: true });
27
+ Bun.write(
28
+ join(configDir, 'config.json'),
29
+ JSON.stringify({
30
+ baseUrl: 'https://test.atlassian.net',
31
+ email: 'test@example.com',
32
+ apiToken: 'test-token',
33
+ }),
34
+ );
35
+ });
36
+
37
+ afterEach(() => {
38
+ // Restore original directory
39
+ process.chdir(originalCwd);
40
+
41
+ // Clean up test directory
42
+ if (existsSync(testDir)) {
43
+ rmSync(testDir, { recursive: true });
44
+ }
45
+ });
46
+
47
+ describe('single space', () => {
48
+ beforeEach(() => {
49
+ // Mock API responses for single space
50
+ server.use(
51
+ http.get('*/wiki/api/v2/spaces', ({ request }) => {
52
+ const url = new URL(request.url);
53
+ const keys = url.searchParams.get('keys');
54
+ if (keys === 'TEST1') {
55
+ return HttpResponse.json({
56
+ results: [createValidSpace({ id: 'space-123', key: 'TEST1', name: 'Test Space 1' })],
57
+ });
58
+ }
59
+ return HttpResponse.json({ results: [] });
60
+ }),
61
+ http.get('*/wiki/api/v2/spaces/:spaceId/pages', () => {
62
+ return HttpResponse.json({
63
+ results: [
64
+ createValidPage({
65
+ id: 'page-1',
66
+ spaceId: 'space-123',
67
+ title: 'Home',
68
+ parentId: null,
69
+ }),
70
+ ],
71
+ });
72
+ }),
73
+ http.get('*/wiki/api/v2/pages/:pageId', () => {
74
+ return HttpResponse.json(
75
+ createValidPage({
76
+ id: 'page-1',
77
+ spaceId: 'space-123',
78
+ title: 'Home',
79
+ parentId: null,
80
+ body: '<p>Test content</p>',
81
+ }),
82
+ );
83
+ }),
84
+ );
85
+ });
86
+
87
+ test('clones a single space successfully', async () => {
88
+ await cloneCommand({ spaceKeys: ['TEST1'] });
89
+
90
+ // Verify directory was created
91
+ expect(existsSync(join(testDir, 'TEST1'))).toBe(true);
92
+
93
+ // Verify .confluence.json was created
94
+ expect(existsSync(join(testDir, 'TEST1', '.confluence.json'))).toBe(true);
95
+ });
96
+
97
+ test('throws error if directory already exists', async () => {
98
+ // Create directory first
99
+ mkdirSync(join(testDir, 'TEST1'));
100
+
101
+ const exitSpy = spyOn(process, 'exit').mockImplementation(() => {
102
+ throw new Error('process.exit called');
103
+ });
104
+
105
+ await expect(cloneCommand({ spaceKeys: ['TEST1'] })).rejects.toThrow('process.exit called');
106
+
107
+ exitSpy.mockRestore();
108
+ });
109
+ });
110
+
111
+ describe('multiple spaces', () => {
112
+ beforeEach(() => {
113
+ // Mock API responses for multiple spaces
114
+ server.use(
115
+ http.get('*/wiki/api/v2/spaces', ({ request }) => {
116
+ const url = new URL(request.url);
117
+ const keys = url.searchParams.get('keys');
118
+
119
+ if (keys === 'TEST1') {
120
+ return HttpResponse.json({
121
+ results: [createValidSpace({ id: 'space-123', key: 'TEST1', name: 'Test Space 1' })],
122
+ });
123
+ }
124
+ if (keys === 'TEST2') {
125
+ return HttpResponse.json({
126
+ results: [createValidSpace({ id: 'space-456', key: 'TEST2', name: 'Test Space 2' })],
127
+ });
128
+ }
129
+ if (keys === 'TEST3') {
130
+ return HttpResponse.json({
131
+ results: [createValidSpace({ id: 'space-789', key: 'TEST3', name: 'Test Space 3' })],
132
+ });
133
+ }
134
+ return HttpResponse.json({ results: [] });
135
+ }),
136
+ http.get('*/wiki/api/v2/spaces/:spaceId/pages', () => {
137
+ return HttpResponse.json({
138
+ results: [
139
+ createValidPage({
140
+ id: 'page-1',
141
+ spaceId: 'space-123',
142
+ title: 'Home',
143
+ parentId: null,
144
+ }),
145
+ ],
146
+ });
147
+ }),
148
+ http.get('*/wiki/api/v2/pages/:pageId', () => {
149
+ return HttpResponse.json(
150
+ createValidPage({
151
+ id: 'page-1',
152
+ spaceId: 'space-123',
153
+ title: 'Home',
154
+ parentId: null,
155
+ body: '<p>Test content</p>',
156
+ }),
157
+ );
158
+ }),
159
+ );
160
+ });
161
+
162
+ test('clones multiple spaces successfully', async () => {
163
+ await cloneCommand({ spaceKeys: ['TEST1', 'TEST2', 'TEST3'] });
164
+
165
+ // Verify all directories were created
166
+ expect(existsSync(join(testDir, 'TEST1'))).toBe(true);
167
+ expect(existsSync(join(testDir, 'TEST2'))).toBe(true);
168
+ expect(existsSync(join(testDir, 'TEST3'))).toBe(true);
169
+
170
+ // Verify .confluence.json was created for each
171
+ expect(existsSync(join(testDir, 'TEST1', '.confluence.json'))).toBe(true);
172
+ expect(existsSync(join(testDir, 'TEST2', '.confluence.json'))).toBe(true);
173
+ expect(existsSync(join(testDir, 'TEST3', '.confluence.json'))).toBe(true);
174
+ });
175
+
176
+ test('continues cloning when one space fails', async () => {
177
+ // Pre-create TEST2 directory to cause failure
178
+ mkdirSync(join(testDir, 'TEST2'));
179
+
180
+ const exitSpy = spyOn(process, 'exit').mockImplementation(() => {
181
+ throw new Error('process.exit called');
182
+ });
183
+
184
+ await expect(cloneCommand({ spaceKeys: ['TEST1', 'TEST2', 'TEST3'] })).rejects.toThrow('process.exit called');
185
+
186
+ // Verify TEST1 and TEST3 were still created
187
+ expect(existsSync(join(testDir, 'TEST1'))).toBe(true);
188
+ expect(existsSync(join(testDir, 'TEST3'))).toBe(true);
189
+
190
+ // Verify TEST2 already existed
191
+ expect(existsSync(join(testDir, 'TEST2', '.confluence.json'))).toBe(false);
192
+
193
+ // Verify process.exit was called with error code
194
+ expect(exitSpy).toHaveBeenCalled();
195
+
196
+ exitSpy.mockRestore();
197
+ });
198
+
199
+ test('exits with error code when all spaces fail', async () => {
200
+ // Pre-create all directories to cause all failures
201
+ mkdirSync(join(testDir, 'TEST1'));
202
+ mkdirSync(join(testDir, 'TEST2'));
203
+ mkdirSync(join(testDir, 'TEST3'));
204
+
205
+ const exitSpy = spyOn(process, 'exit').mockImplementation(() => {
206
+ throw new Error('process.exit called');
207
+ });
208
+
209
+ await expect(cloneCommand({ spaceKeys: ['TEST1', 'TEST2', 'TEST3'] })).rejects.toThrow('process.exit called');
210
+
211
+ expect(exitSpy).toHaveBeenCalled();
212
+
213
+ exitSpy.mockRestore();
214
+ });
215
+ });
216
+
217
+ describe('error handling', () => {
218
+ test('exits if not configured', async () => {
219
+ // Create a new test directory without config
220
+ const noConfigDir = join(tmpdir(), `cn-test-noconfig-${Date.now()}`);
221
+ mkdirSync(noConfigDir, { recursive: true });
222
+
223
+ // Save and override CN_CONFIG_PATH to point to directory without config
224
+ const savedConfigPath = process.env.CN_CONFIG_PATH;
225
+ process.env.CN_CONFIG_PATH = noConfigDir;
226
+
227
+ const exitSpy = spyOn(process, 'exit').mockImplementation(() => {
228
+ throw new Error('process.exit called');
229
+ });
230
+
231
+ try {
232
+ await expect(cloneCommand({ spaceKeys: ['TEST1'] })).rejects.toThrow('process.exit called');
233
+ } finally {
234
+ exitSpy.mockRestore();
235
+
236
+ // Restore
237
+ if (savedConfigPath) {
238
+ process.env.CN_CONFIG_PATH = savedConfigPath;
239
+ } else {
240
+ delete process.env.CN_CONFIG_PATH;
241
+ }
242
+
243
+ // Clean up
244
+ if (existsSync(noConfigDir)) {
245
+ rmSync(noConfigDir, { recursive: true });
246
+ }
247
+ }
248
+ });
249
+
250
+ test('handles space not found error', async () => {
251
+ // Mock API to return no spaces
252
+ server.use(
253
+ http.get('*/wiki/api/v2/spaces', () => {
254
+ return HttpResponse.json({ results: [] });
255
+ }),
256
+ );
257
+
258
+ const exitSpy = spyOn(process, 'exit').mockImplementation(() => {
259
+ throw new Error('process.exit called');
260
+ });
261
+
262
+ await expect(cloneCommand({ spaceKeys: ['NOTFOUND'] })).rejects.toThrow('process.exit called');
263
+
264
+ exitSpy.mockRestore();
265
+ });
266
+
267
+ test('handles duplicate space keys', async () => {
268
+ const exitSpy = spyOn(process, 'exit').mockImplementation(() => {
269
+ throw new Error('process.exit called');
270
+ });
271
+
272
+ await expect(cloneCommand({ spaceKeys: ['TEST1', 'TEST2', 'TEST1'] })).rejects.toThrow('process.exit called');
273
+
274
+ exitSpy.mockRestore();
275
+ });
276
+ });
277
+
278
+ describe('console output', () => {
279
+ beforeEach(() => {
280
+ // Mock API responses for console output tests
281
+ server.use(
282
+ http.get('*/wiki/api/v2/spaces', ({ request }) => {
283
+ const url = new URL(request.url);
284
+ const keys = url.searchParams.get('keys');
285
+ if (keys === 'TEST1' || keys === 'TEST2' || keys === 'TEST3') {
286
+ return HttpResponse.json({
287
+ results: [
288
+ createValidSpace({
289
+ id: `space-${keys}`,
290
+ key: keys || 'TEST1',
291
+ name: `Test Space ${keys}`,
292
+ }),
293
+ ],
294
+ });
295
+ }
296
+ return HttpResponse.json({ results: [] });
297
+ }),
298
+ http.get('*/wiki/api/v2/spaces/:spaceId/pages', () => {
299
+ return HttpResponse.json({
300
+ results: [
301
+ createValidPage({
302
+ id: 'page-1',
303
+ spaceId: 'space-123',
304
+ title: 'Home',
305
+ parentId: null,
306
+ }),
307
+ ],
308
+ });
309
+ }),
310
+ http.get('*/wiki/api/v2/pages/:pageId', () => {
311
+ return HttpResponse.json(
312
+ createValidPage({
313
+ id: 'page-1',
314
+ spaceId: 'space-123',
315
+ title: 'Home',
316
+ parentId: null,
317
+ body: '<p>Test content</p>',
318
+ }),
319
+ );
320
+ }),
321
+ );
322
+ });
323
+
324
+ test('displays separator and progress for multiple spaces', async () => {
325
+ const consoleSpy = spyOn(console, 'log');
326
+
327
+ await cloneCommand({ spaceKeys: ['TEST1', 'TEST2', 'TEST3'] });
328
+
329
+ // Check for progress indicators
330
+ const output = consoleSpy.mock.calls.map((call) => call[0]).join('\n');
331
+ expect(output).toContain('Cloning 1/3:');
332
+ expect(output).toContain('Cloning 2/3:');
333
+ expect(output).toContain('Cloning 3/3:');
334
+ expect(output).toContain('Clone Summary');
335
+ expect(output).toContain('Successfully cloned:');
336
+
337
+ consoleSpy.mockRestore();
338
+ });
339
+
340
+ test('does not display separator for single space', async () => {
341
+ const consoleSpy = spyOn(console, 'log');
342
+
343
+ await cloneCommand({ spaceKeys: ['TEST1'] });
344
+
345
+ // Check that separator is NOT displayed
346
+ const output = consoleSpy.mock.calls.map((call) => call[0]).join('\n');
347
+ expect(output).not.toContain('Clone Summary');
348
+ expect(output).not.toContain('Cloning 1/1:');
349
+
350
+ consoleSpy.mockRestore();
351
+ });
352
+
353
+ test('displays failure details in summary', async () => {
354
+ // Pre-create TEST2 directory to cause failure
355
+ mkdirSync(join(testDir, 'TEST2'));
356
+
357
+ const consoleSpy = spyOn(console, 'log');
358
+ const exitSpy = spyOn(process, 'exit').mockImplementation(() => {
359
+ throw new Error('process.exit called');
360
+ });
361
+
362
+ await expect(cloneCommand({ spaceKeys: ['TEST1', 'TEST2', 'TEST3'] })).rejects.toThrow('process.exit called');
363
+
364
+ const output = consoleSpy.mock.calls.map((call) => call[0]).join('\n');
365
+ expect(output).toContain('Failed to clone:');
366
+ expect(output).toContain('TEST2');
367
+ expect(output).toContain('already exists');
368
+
369
+ consoleSpy.mockRestore();
370
+ exitSpy.mockRestore();
371
+ });
372
+ });
373
+ });