@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.
- package/LICENSE +21 -0
- package/README.md +69 -0
- package/package.json +73 -0
- package/src/cli/commands/attachments.ts +113 -0
- package/src/cli/commands/clone.ts +188 -0
- package/src/cli/commands/comments.ts +56 -0
- package/src/cli/commands/create.ts +58 -0
- package/src/cli/commands/delete.ts +46 -0
- package/src/cli/commands/doctor.ts +161 -0
- package/src/cli/commands/duplicate-check.ts +89 -0
- package/src/cli/commands/file-rename.ts +113 -0
- package/src/cli/commands/folder-hierarchy.ts +241 -0
- package/src/cli/commands/info.ts +56 -0
- package/src/cli/commands/labels.ts +53 -0
- package/src/cli/commands/move.ts +23 -0
- package/src/cli/commands/open.ts +145 -0
- package/src/cli/commands/pull.ts +241 -0
- package/src/cli/commands/push-errors.ts +40 -0
- package/src/cli/commands/push.ts +699 -0
- package/src/cli/commands/search.ts +62 -0
- package/src/cli/commands/setup.ts +124 -0
- package/src/cli/commands/spaces.ts +42 -0
- package/src/cli/commands/status.ts +88 -0
- package/src/cli/commands/tree.ts +190 -0
- package/src/cli/help.ts +425 -0
- package/src/cli/index.ts +413 -0
- package/src/cli/utils/browser.ts +34 -0
- package/src/cli/utils/progress-reporter.ts +49 -0
- package/src/cli.ts +6 -0
- package/src/lib/config.ts +156 -0
- package/src/lib/confluence-client/attachment-operations.ts +221 -0
- package/src/lib/confluence-client/client.ts +653 -0
- package/src/lib/confluence-client/comment-operations.ts +60 -0
- package/src/lib/confluence-client/folder-operations.ts +203 -0
- package/src/lib/confluence-client/index.ts +47 -0
- package/src/lib/confluence-client/label-operations.ts +102 -0
- package/src/lib/confluence-client/page-operations.ts +270 -0
- package/src/lib/confluence-client/search-operations.ts +60 -0
- package/src/lib/confluence-client/types.ts +329 -0
- package/src/lib/confluence-client/user-operations.ts +58 -0
- package/src/lib/dependency-sorter.ts +233 -0
- package/src/lib/errors.ts +237 -0
- package/src/lib/file-scanner.ts +195 -0
- package/src/lib/formatters.ts +314 -0
- package/src/lib/health-check.ts +204 -0
- package/src/lib/markdown/converter.ts +427 -0
- package/src/lib/markdown/frontmatter.ts +116 -0
- package/src/lib/markdown/html-converter.ts +398 -0
- package/src/lib/markdown/index.ts +21 -0
- package/src/lib/markdown/link-converter.ts +189 -0
- package/src/lib/markdown/reference-updater.ts +251 -0
- package/src/lib/markdown/slugify.ts +32 -0
- package/src/lib/page-state.ts +195 -0
- package/src/lib/resolve-page-target.ts +33 -0
- package/src/lib/space-config.ts +264 -0
- package/src/lib/sync/cleanup.ts +50 -0
- package/src/lib/sync/folder-path.ts +61 -0
- package/src/lib/sync/index.ts +2 -0
- package/src/lib/sync/link-resolution-pass.ts +139 -0
- package/src/lib/sync/sync-engine.ts +681 -0
- package/src/lib/sync/sync-specific.ts +221 -0
- package/src/lib/sync/types.ts +42 -0
- package/src/test/attachments.test.ts +68 -0
- package/src/test/clone.test.ts +373 -0
- package/src/test/comments.test.ts +53 -0
- package/src/test/config.test.ts +209 -0
- package/src/test/confluence-client.test.ts +535 -0
- package/src/test/delete.test.ts +39 -0
- package/src/test/dependency-sorter.test.ts +384 -0
- package/src/test/errors.test.ts +199 -0
- package/src/test/file-rename.test.ts +305 -0
- package/src/test/file-scanner.test.ts +331 -0
- package/src/test/folder-hierarchy.test.ts +337 -0
- package/src/test/formatters.test.ts +213 -0
- package/src/test/html-converter.test.ts +399 -0
- package/src/test/info.test.ts +56 -0
- package/src/test/labels.test.ts +70 -0
- package/src/test/link-conversion-integration.test.ts +189 -0
- package/src/test/link-converter.test.ts +413 -0
- package/src/test/link-resolution-pass.test.ts +368 -0
- package/src/test/markdown.test.ts +443 -0
- package/src/test/mocks/handlers.ts +228 -0
- package/src/test/move.test.ts +53 -0
- package/src/test/msw-schema-validation.ts +151 -0
- package/src/test/page-state.test.ts +542 -0
- package/src/test/push.test.ts +551 -0
- package/src/test/reference-updater.test.ts +293 -0
- package/src/test/resolve-page-target.test.ts +55 -0
- package/src/test/search.test.ts +64 -0
- package/src/test/setup-msw.ts +75 -0
- package/src/test/space-config.test.ts +516 -0
- package/src/test/spaces.test.ts +53 -0
- package/src/test/sync-engine.test.ts +486 -0
- 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
|
+
});
|