@aaronshaf/confluence-cli 0.1.15 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,305 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
- import { join } from 'node:path';
4
- import { handleFileRename } from '../cli/commands/file-rename.js';
5
-
6
- describe('handleFileRename', () => {
7
- const testDir = join(import.meta.dir, '.test-file-rename');
8
-
9
- beforeEach(() => {
10
- // Clean up and create test directory
11
- if (existsSync(testDir)) {
12
- rmSync(testDir, { recursive: true });
13
- }
14
- mkdirSync(testDir, { recursive: true });
15
- });
16
-
17
- afterEach(() => {
18
- // Clean up test directory
19
- if (existsSync(testDir)) {
20
- rmSync(testDir, { recursive: true });
21
- }
22
- });
23
-
24
- test('does not rename README.md even with different title', () => {
25
- const filePath = join(testDir, 'README.md');
26
- const content = `---
27
- title: "Engineering Wiki"
28
- page_id: page-123
29
- ---
30
-
31
- # Engineering Wiki
32
-
33
- Welcome to the wiki.
34
- `;
35
- writeFileSync(filePath, content);
36
-
37
- const result = handleFileRename(filePath, 'README.md', 'Engineering Wiki', content);
38
-
39
- expect(result.wasRenamed).toBe(false);
40
- expect(result.finalPath).toBe('README.md');
41
- expect(existsSync(filePath)).toBe(true);
42
- expect(readFileSync(filePath, 'utf-8')).toBe(content);
43
- });
44
-
45
- test('does not rename readme.md (lowercase) even with different title', () => {
46
- const filePath = join(testDir, 'readme.md');
47
- const content = `---
48
- title: "My Custom Title"
49
- page_id: page-456
50
- ---
51
-
52
- # My Custom Title
53
-
54
- Content here.
55
- `;
56
- writeFileSync(filePath, content);
57
-
58
- const result = handleFileRename(filePath, 'readme.md', 'My Custom Title', content);
59
-
60
- expect(result.wasRenamed).toBe(false);
61
- expect(result.finalPath).toBe('readme.md');
62
- expect(existsSync(filePath)).toBe(true);
63
- });
64
-
65
- test('does not rename index.md even with different title', () => {
66
- const filePath = join(testDir, 'index.md');
67
- const content = `---
68
- title: "Home Page"
69
- page_id: page-789
70
- ---
71
-
72
- # Home Page
73
-
74
- Welcome.
75
- `;
76
- writeFileSync(filePath, content);
77
-
78
- const result = handleFileRename(filePath, 'index.md', 'Home Page', content);
79
-
80
- expect(result.wasRenamed).toBe(false);
81
- expect(result.finalPath).toBe('index.md');
82
- expect(existsSync(filePath)).toBe(true);
83
- });
84
-
85
- test('renames regular file when title changes', () => {
86
- const filePath = join(testDir, 'old-name.md');
87
- const content = `---
88
- title: "New Name"
89
- page_id: page-101
90
- ---
91
-
92
- # New Name
93
-
94
- Content.
95
- `;
96
- writeFileSync(filePath, content);
97
-
98
- const result = handleFileRename(filePath, 'old-name.md', 'New Name', content);
99
-
100
- expect(result.wasRenamed).toBe(true);
101
- expect(result.finalPath).toBe('new-name.md');
102
- expect(existsSync(join(testDir, 'new-name.md'))).toBe(true);
103
- expect(existsSync(filePath)).toBe(false);
104
- });
105
-
106
- test('does not rename if target file already exists', () => {
107
- const filePath = join(testDir, 'old-name.md');
108
- const existingPath = join(testDir, 'new-name.md');
109
- const content = `---
110
- title: "New Name"
111
- ---
112
-
113
- # New Name
114
- `;
115
- writeFileSync(filePath, content);
116
- writeFileSync(existingPath, 'existing content');
117
-
118
- const result = handleFileRename(filePath, 'old-name.md', 'New Name', content);
119
-
120
- expect(result.wasRenamed).toBe(false);
121
- expect(result.finalPath).toBe('old-name.md');
122
- expect(existsSync(filePath)).toBe(true);
123
- });
124
-
125
- test('preserves README.md in subdirectory', () => {
126
- const subDir = join(testDir, 'docs');
127
- mkdirSync(subDir);
128
- const filePath = join(subDir, 'README.md');
129
- const content = `---
130
- title: "Documentation Overview"
131
- page_id: page-docs
132
- ---
133
-
134
- # Documentation Overview
135
- `;
136
- writeFileSync(filePath, content);
137
-
138
- const result = handleFileRename(filePath, 'docs/README.md', 'Documentation Overview', content);
139
-
140
- expect(result.wasRenamed).toBe(false);
141
- expect(result.finalPath).toBe('docs/README.md');
142
- expect(existsSync(filePath)).toBe(true);
143
- });
144
-
145
- test('handles file in subdirectory with rename', () => {
146
- const subDir = join(testDir, 'guides');
147
- mkdirSync(subDir);
148
- const filePath = join(subDir, 'old-guide.md');
149
- const content = `---
150
- title: "New Guide Name"
151
- ---
152
-
153
- # New Guide Name
154
- `;
155
- writeFileSync(filePath, content);
156
-
157
- const result = handleFileRename(filePath, 'guides/old-guide.md', 'New Guide Name', content);
158
-
159
- expect(result.wasRenamed).toBe(true);
160
- expect(result.finalPath).toBe('guides/new-guide-name.md');
161
- expect(existsSync(join(subDir, 'new-guide-name.md'))).toBe(true);
162
- });
163
-
164
- test('updates references in other files when renamed with spaceRoot', () => {
165
- // Create a file that links to another file
166
- const linkingFile = join(testDir, 'index.md');
167
- writeFileSync(
168
- linkingFile,
169
- `---
170
- title: Index
171
- ---
172
-
173
- See [Old Guide](./old-guide.md) for details.
174
- `,
175
- );
176
-
177
- // Create the file that will be renamed
178
- const filePath = join(testDir, 'old-guide.md');
179
- const content = `---
180
- title: "New Guide Name"
181
- ---
182
-
183
- # New Guide Name
184
- `;
185
- writeFileSync(filePath, content);
186
-
187
- // Rename with spaceRoot to trigger reference updates
188
- const result = handleFileRename(filePath, 'old-guide.md', 'New Guide Name', content, testDir);
189
-
190
- expect(result.wasRenamed).toBe(true);
191
- expect(result.finalPath).toBe('new-guide-name.md');
192
-
193
- // Verify the link in the other file was updated
194
- const linkingContent = readFileSync(linkingFile, 'utf-8');
195
- expect(linkingContent).toContain('./new-guide-name.md');
196
- expect(linkingContent).not.toContain('./old-guide.md');
197
- });
198
-
199
- test('updates references without ./ prefix when renamed with spaceRoot', () => {
200
- // Create a file that links without ./ prefix
201
- const linkingFile = join(testDir, 'README.md');
202
- writeFileSync(
203
- linkingFile,
204
- `---
205
- title: README
206
- ---
207
-
208
- See [Guide](old-guide.md) for details.
209
- `,
210
- );
211
-
212
- // Create the file that will be renamed
213
- const filePath = join(testDir, 'old-guide.md');
214
- const content = `---
215
- title: "New Guide Name"
216
- ---
217
-
218
- # New Guide Name
219
- `;
220
- writeFileSync(filePath, content);
221
-
222
- // Rename with spaceRoot to trigger reference updates
223
- const result = handleFileRename(filePath, 'old-guide.md', 'New Guide Name', content, testDir);
224
-
225
- expect(result.wasRenamed).toBe(true);
226
-
227
- // Verify the link was updated (preserving no-prefix style)
228
- const linkingContent = readFileSync(linkingFile, 'utf-8');
229
- expect(linkingContent).toContain('new-guide-name.md');
230
- expect(linkingContent).not.toContain('old-guide.md');
231
- });
232
-
233
- test('updates references in subdirectory files when renamed with spaceRoot', () => {
234
- // Create subdirectory
235
- const docsDir = join(testDir, 'docs');
236
- mkdirSync(docsDir);
237
-
238
- // Create a file in subdirectory that links to root file
239
- const linkingFile = join(docsDir, 'guide.md');
240
- writeFileSync(
241
- linkingFile,
242
- `---
243
- title: Guide
244
- ---
245
-
246
- See [Overview](../overview.md) for details.
247
- `,
248
- );
249
-
250
- // Create the file that will be renamed (at root)
251
- const filePath = join(testDir, 'overview.md');
252
- const content = `---
253
- title: "Project Overview"
254
- ---
255
-
256
- # Project Overview
257
- `;
258
- writeFileSync(filePath, content);
259
-
260
- // Rename with spaceRoot to trigger reference updates
261
- const result = handleFileRename(filePath, 'overview.md', 'Project Overview', content, testDir);
262
-
263
- expect(result.wasRenamed).toBe(true);
264
- expect(result.finalPath).toBe('project-overview.md');
265
-
266
- // Verify the relative link in subdirectory file was updated
267
- const linkingContent = readFileSync(linkingFile, 'utf-8');
268
- expect(linkingContent).toContain('../project-overview.md');
269
- expect(linkingContent).not.toContain('../overview.md');
270
- });
271
-
272
- test('does not update references when no spaceRoot provided', () => {
273
- // Create a file that links to another file
274
- const linkingFile = join(testDir, 'index.md');
275
- writeFileSync(
276
- linkingFile,
277
- `---
278
- title: Index
279
- ---
280
-
281
- See [Old Guide](./old-guide.md) for details.
282
- `,
283
- );
284
-
285
- // Create the file that will be renamed
286
- const filePath = join(testDir, 'old-guide.md');
287
- const content = `---
288
- title: "New Guide Name"
289
- ---
290
-
291
- # New Guide Name
292
- `;
293
- writeFileSync(filePath, content);
294
-
295
- // Rename WITHOUT spaceRoot - should not update references
296
- const result = handleFileRename(filePath, 'old-guide.md', 'New Guide Name', content);
297
-
298
- expect(result.wasRenamed).toBe(true);
299
-
300
- // Verify the link in the other file was NOT updated (no spaceRoot)
301
- const linkingContent = readFileSync(linkingFile, 'utf-8');
302
- expect(linkingContent).toContain('./old-guide.md');
303
- expect(linkingContent).not.toContain('./new-guide-name.md');
304
- });
305
- });
@@ -1,337 +0,0 @@
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 { HttpResponse, http } from 'msw';
6
- import { ConfluenceClient } from '../lib/confluence-client/client.js';
7
- import {
8
- determineExpectedParent,
9
- ensureFolderHierarchy,
10
- FolderHierarchyError,
11
- sanitizeFolderTitle,
12
- } from '../cli/commands/folder-hierarchy.js';
13
- import type { SpaceConfigWithState } from '../lib/space-config.js';
14
- import { server } from './setup-msw.js';
15
- import { createValidFolder } from './msw-schema-validation.js';
16
-
17
- const testConfig = {
18
- confluenceUrl: 'https://test.atlassian.net',
19
- email: 'test@example.com',
20
- apiToken: 'test-token',
21
- };
22
-
23
- describe('folder-hierarchy', () => {
24
- describe('sanitizeFolderTitle', () => {
25
- test('returns unchanged title for valid names', () => {
26
- const result = sanitizeFolderTitle('My Folder');
27
- expect(result.sanitized).toBe('My Folder');
28
- expect(result.wasModified).toBe(false);
29
- });
30
-
31
- test('replaces pipe character', () => {
32
- const result = sanitizeFolderTitle('Folder | Name');
33
- expect(result.sanitized).toBe('Folder - Name');
34
- expect(result.wasModified).toBe(true);
35
- });
36
-
37
- test('replaces backslash', () => {
38
- const result = sanitizeFolderTitle('Folder\\Name');
39
- expect(result.sanitized).toBe('Folder-Name');
40
- expect(result.wasModified).toBe(true);
41
- });
42
-
43
- test('replaces forward slash', () => {
44
- const result = sanitizeFolderTitle('Folder/Name');
45
- expect(result.sanitized).toBe('Folder-Name');
46
- expect(result.wasModified).toBe(true);
47
- });
48
-
49
- test('replaces colon', () => {
50
- const result = sanitizeFolderTitle('Folder: Name');
51
- expect(result.sanitized).toBe('Folder- Name');
52
- expect(result.wasModified).toBe(true);
53
- });
54
-
55
- test('replaces asterisk', () => {
56
- const result = sanitizeFolderTitle('Folder*Name');
57
- expect(result.sanitized).toBe('Folder-Name');
58
- expect(result.wasModified).toBe(true);
59
- });
60
-
61
- test('replaces question mark', () => {
62
- const result = sanitizeFolderTitle('Folder?Name');
63
- expect(result.sanitized).toBe('Folder-Name');
64
- expect(result.wasModified).toBe(true);
65
- });
66
-
67
- test('replaces double quotes', () => {
68
- const result = sanitizeFolderTitle('Folder"Name');
69
- expect(result.sanitized).toBe('Folder-Name');
70
- expect(result.wasModified).toBe(true);
71
- });
72
-
73
- test('replaces angle brackets', () => {
74
- const result = sanitizeFolderTitle('Folder<Name>');
75
- expect(result.sanitized).toBe('Folder-Name-');
76
- expect(result.wasModified).toBe(true);
77
- });
78
-
79
- test('replaces multiple invalid characters', () => {
80
- const result = sanitizeFolderTitle('A|B/C:D*E?F"G<H>I');
81
- expect(result.sanitized).toBe('A-B-C-D-E-F-G-H-I');
82
- expect(result.wasModified).toBe(true);
83
- });
84
-
85
- test('trims whitespace', () => {
86
- const result = sanitizeFolderTitle(' Folder Name ');
87
- expect(result.sanitized).toBe('Folder Name');
88
- expect(result.wasModified).toBe(true);
89
- });
90
- });
91
-
92
- describe('ensureFolderHierarchy', () => {
93
- let testDir: string;
94
-
95
- beforeEach(() => {
96
- testDir = mkdtempSync(join(tmpdir(), 'cn-folder-hierarchy-test-'));
97
- });
98
-
99
- afterEach(() => {
100
- if (existsSync(testDir)) {
101
- rmSync(testDir, { recursive: true });
102
- }
103
- });
104
-
105
- const createSpaceConfig = (overrides?: Partial<SpaceConfigWithState>): SpaceConfigWithState => ({
106
- spaceKey: 'TEST',
107
- spaceId: 'space-123',
108
- spaceName: 'Test Space',
109
- pages: {},
110
- folders: {},
111
- ...overrides,
112
- });
113
-
114
- test('returns undefined parentId for root-level files', async () => {
115
- const client = new ConfluenceClient(testConfig);
116
- const spaceConfig = createSpaceConfig();
117
-
118
- const result = await ensureFolderHierarchy(client, spaceConfig, testDir, 'readme.md', true);
119
-
120
- expect(result.parentId).toBeUndefined();
121
- expect(result.updatedConfig).toEqual(spaceConfig);
122
- });
123
-
124
- test('returns undefined parentId for files with ./ prefix at root', async () => {
125
- const client = new ConfluenceClient(testConfig);
126
- const spaceConfig = createSpaceConfig();
127
-
128
- const result = await ensureFolderHierarchy(client, spaceConfig, testDir, './readme.md', true);
129
-
130
- expect(result.parentId).toBeUndefined();
131
- });
132
-
133
- test('returns existing folder ID from local config', async () => {
134
- const client = new ConfluenceClient(testConfig);
135
- const spaceConfig = createSpaceConfig({
136
- folders: {
137
- 'folder-abc': {
138
- folderId: 'folder-abc',
139
- title: 'docs',
140
- localPath: 'docs',
141
- },
142
- },
143
- });
144
-
145
- const result = await ensureFolderHierarchy(client, spaceConfig, testDir, 'docs/guide.md', true);
146
-
147
- expect(result.parentId).toBe('folder-abc');
148
- });
149
-
150
- test('returns nested folder ID from local config', async () => {
151
- const client = new ConfluenceClient(testConfig);
152
- const spaceConfig = createSpaceConfig({
153
- folders: {
154
- 'folder-docs': {
155
- folderId: 'folder-docs',
156
- title: 'docs',
157
- localPath: 'docs',
158
- },
159
- 'folder-api': {
160
- folderId: 'folder-api',
161
- title: 'api',
162
- parentId: 'folder-docs',
163
- localPath: 'docs/api',
164
- },
165
- },
166
- });
167
-
168
- const result = await ensureFolderHierarchy(client, spaceConfig, testDir, 'docs/api/endpoints.md', true);
169
-
170
- expect(result.parentId).toBe('folder-api');
171
- });
172
-
173
- test('throws error for path traversal attempts', async () => {
174
- const client = new ConfluenceClient(testConfig);
175
- const spaceConfig = createSpaceConfig();
176
-
177
- try {
178
- await ensureFolderHierarchy(client, spaceConfig, testDir, '../etc/passwd', true);
179
- expect.unreachable('Should have thrown');
180
- } catch (error) {
181
- expect(error).toBeInstanceOf(FolderHierarchyError);
182
- expect((error as FolderHierarchyError).message).toContain('path traversal');
183
- }
184
- });
185
-
186
- test('throws error for deeply nested paths exceeding max depth', async () => {
187
- const client = new ConfluenceClient(testConfig);
188
- const spaceConfig = createSpaceConfig();
189
-
190
- // Create a path with 11 levels (exceeds MAX_FOLDER_DEPTH of 10)
191
- const deepPath = 'a/b/c/d/e/f/g/h/i/j/k/file.md';
192
-
193
- try {
194
- await ensureFolderHierarchy(client, spaceConfig, testDir, deepPath, true);
195
- expect.unreachable('Should have thrown');
196
- } catch (error) {
197
- expect(error).toBeInstanceOf(FolderHierarchyError);
198
- expect((error as FolderHierarchyError).message).toContain('too deep');
199
- }
200
- });
201
-
202
- test('dry run returns undefined when folder needs creation', async () => {
203
- const client = new ConfluenceClient(testConfig);
204
- const spaceConfig = createSpaceConfig();
205
-
206
- // Mock: folder does not exist on Confluence
207
- server.use(
208
- http.get('*/wiki/api/v2/spaces/space-123/folders', () => {
209
- return HttpResponse.json({ results: [] });
210
- }),
211
- );
212
-
213
- const result = await ensureFolderHierarchy(client, spaceConfig, testDir, 'newdir/file.md', true);
214
-
215
- // Dry run can't continue without a real folder ID
216
- expect(result.parentId).toBeUndefined();
217
- });
218
- });
219
-
220
- describe('determineExpectedParent', () => {
221
- let testDir: string;
222
-
223
- beforeEach(() => {
224
- testDir = mkdtempSync(join(tmpdir(), 'cn-determine-parent-test-'));
225
- });
226
-
227
- afterEach(() => {
228
- if (existsSync(testDir)) {
229
- rmSync(testDir, { recursive: true });
230
- }
231
- });
232
-
233
- const createSpaceConfig = (overrides?: Partial<SpaceConfigWithState>): SpaceConfigWithState => ({
234
- spaceKey: 'TEST',
235
- spaceId: 'space-123',
236
- spaceName: 'Test Space',
237
- pages: {},
238
- folders: {},
239
- ...overrides,
240
- });
241
-
242
- test('returns undefined parentId for root-level files', async () => {
243
- const client = new ConfluenceClient(testConfig);
244
- const spaceConfig = createSpaceConfig();
245
-
246
- const result = await determineExpectedParent(client, spaceConfig, testDir, 'readme.md', true);
247
-
248
- expect(result.parentId).toBeUndefined();
249
- expect(result.updatedConfig).toEqual(spaceConfig);
250
- });
251
-
252
- test('returns undefined for files with . directory', async () => {
253
- const client = new ConfluenceClient(testConfig);
254
- const spaceConfig = createSpaceConfig();
255
-
256
- // This tests the early return for dirPath === '.'
257
- const result = await determineExpectedParent(client, spaceConfig, testDir, './file.md', true);
258
-
259
- expect(result.parentId).toBeUndefined();
260
- });
261
-
262
- test('returns existing folder ID for nested file', async () => {
263
- const client = new ConfluenceClient(testConfig);
264
- const spaceConfig = createSpaceConfig({
265
- folders: {
266
- 'folder-xyz': {
267
- folderId: 'folder-xyz',
268
- title: 'docs',
269
- localPath: 'docs',
270
- },
271
- },
272
- });
273
-
274
- const result = await determineExpectedParent(client, spaceConfig, testDir, 'docs/page.md', true);
275
-
276
- expect(result.parentId).toBe('folder-xyz');
277
- });
278
-
279
- test('returns deeply nested folder ID', async () => {
280
- const client = new ConfluenceClient(testConfig);
281
- const spaceConfig = createSpaceConfig({
282
- folders: {
283
- 'folder-docs': {
284
- folderId: 'folder-docs',
285
- title: 'docs',
286
- localPath: 'docs',
287
- },
288
- 'folder-api': {
289
- folderId: 'folder-api',
290
- title: 'api',
291
- parentId: 'folder-docs',
292
- localPath: 'docs/api',
293
- },
294
- 'folder-v2': {
295
- folderId: 'folder-v2',
296
- title: 'v2',
297
- parentId: 'folder-api',
298
- localPath: 'docs/api/v2',
299
- },
300
- },
301
- });
302
-
303
- const result = await determineExpectedParent(client, spaceConfig, testDir, 'docs/api/v2/endpoints.md', true);
304
-
305
- expect(result.parentId).toBe('folder-v2');
306
- });
307
-
308
- test('handles path normalization with ./ prefix', async () => {
309
- const client = new ConfluenceClient(testConfig);
310
- const spaceConfig = createSpaceConfig({
311
- folders: {
312
- 'folder-docs': {
313
- folderId: 'folder-docs',
314
- title: 'docs',
315
- localPath: 'docs',
316
- },
317
- },
318
- });
319
-
320
- const result = await determineExpectedParent(client, spaceConfig, testDir, './docs/guide.md', true);
321
-
322
- expect(result.parentId).toBe('folder-docs');
323
- });
324
-
325
- test('propagates FolderHierarchyError from ensureFolderHierarchy', async () => {
326
- const client = new ConfluenceClient(testConfig);
327
- const spaceConfig = createSpaceConfig();
328
-
329
- try {
330
- await determineExpectedParent(client, spaceConfig, testDir, '../outside/file.md', true);
331
- expect.unreachable('Should have thrown');
332
- } catch (error) {
333
- expect(error).toBeInstanceOf(FolderHierarchyError);
334
- }
335
- });
336
- });
337
- });