@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,305 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
2
|
+
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { detectPushCandidates, RESERVED_FILENAMES, scanMarkdownFiles } from '../lib/file-scanner.js';
|
|
6
|
+
|
|
7
|
+
describe('file-scanner', () => {
|
|
8
|
+
let testDir: string;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
testDir = mkdtempSync(join(tmpdir(), 'cn-test-'));
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
rmSync(testDir, { recursive: true, force: true });
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe('scanMarkdownFiles', () => {
|
|
19
|
+
test('finds markdown files in directory', () => {
|
|
20
|
+
writeFileSync(join(testDir, 'page1.md'), '# Page 1');
|
|
21
|
+
writeFileSync(join(testDir, 'page2.md'), '# Page 2');
|
|
22
|
+
writeFileSync(join(testDir, 'readme.txt'), 'not markdown');
|
|
23
|
+
|
|
24
|
+
const files = scanMarkdownFiles(testDir);
|
|
25
|
+
|
|
26
|
+
expect(files).toEqual(['page1.md', 'page2.md']);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('finds markdown files recursively', () => {
|
|
30
|
+
mkdirSync(join(testDir, 'subdir'));
|
|
31
|
+
writeFileSync(join(testDir, 'page1.md'), '# Page 1');
|
|
32
|
+
writeFileSync(join(testDir, 'subdir', 'page2.md'), '# Page 2');
|
|
33
|
+
|
|
34
|
+
const files = scanMarkdownFiles(testDir);
|
|
35
|
+
|
|
36
|
+
expect(files).toEqual(['page1.md', 'subdir/page2.md']);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('excludes hidden files and directories', () => {
|
|
40
|
+
mkdirSync(join(testDir, '.git'));
|
|
41
|
+
writeFileSync(join(testDir, '.hidden.md'), 'hidden');
|
|
42
|
+
writeFileSync(join(testDir, '.git', 'config.md'), 'git');
|
|
43
|
+
writeFileSync(join(testDir, 'visible.md'), '# Visible');
|
|
44
|
+
|
|
45
|
+
const files = scanMarkdownFiles(testDir);
|
|
46
|
+
|
|
47
|
+
expect(files).toEqual(['visible.md']);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('excludes common build directories', () => {
|
|
51
|
+
mkdirSync(join(testDir, 'node_modules'));
|
|
52
|
+
mkdirSync(join(testDir, 'dist'));
|
|
53
|
+
writeFileSync(join(testDir, 'node_modules', 'package.md'), 'dep');
|
|
54
|
+
writeFileSync(join(testDir, 'dist', 'output.md'), 'build');
|
|
55
|
+
writeFileSync(join(testDir, 'page.md'), '# Page');
|
|
56
|
+
|
|
57
|
+
const files = scanMarkdownFiles(testDir);
|
|
58
|
+
|
|
59
|
+
expect(files).toEqual(['page.md']);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('returns empty array for directory with no markdown files', () => {
|
|
63
|
+
writeFileSync(join(testDir, 'readme.txt'), 'not markdown');
|
|
64
|
+
|
|
65
|
+
const files = scanMarkdownFiles(testDir);
|
|
66
|
+
|
|
67
|
+
expect(files).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('returns files sorted alphabetically', () => {
|
|
71
|
+
writeFileSync(join(testDir, 'zebra.md'), 'Z');
|
|
72
|
+
writeFileSync(join(testDir, 'alpha.md'), 'A');
|
|
73
|
+
writeFileSync(join(testDir, 'beta.md'), 'B');
|
|
74
|
+
|
|
75
|
+
const files = scanMarkdownFiles(testDir);
|
|
76
|
+
|
|
77
|
+
expect(files).toEqual(['alpha.md', 'beta.md', 'zebra.md']);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('excludes reserved filenames (CLAUDE.md, AGENTS.md)', () => {
|
|
81
|
+
writeFileSync(join(testDir, 'CLAUDE.md'), '# Claude instructions');
|
|
82
|
+
writeFileSync(join(testDir, 'AGENTS.md'), '# Agent instructions');
|
|
83
|
+
writeFileSync(join(testDir, 'page.md'), '# Regular page');
|
|
84
|
+
|
|
85
|
+
const files = scanMarkdownFiles(testDir);
|
|
86
|
+
|
|
87
|
+
expect(files).toEqual(['page.md']);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('excludes reserved filenames case-insensitively', () => {
|
|
91
|
+
// Use subdirectories to test different case variants
|
|
92
|
+
// (on case-insensitive filesystems like macOS, claude.md and Claude.md are the same file)
|
|
93
|
+
mkdirSync(join(testDir, 'lower'));
|
|
94
|
+
mkdirSync(join(testDir, 'upper'));
|
|
95
|
+
mkdirSync(join(testDir, 'mixed'));
|
|
96
|
+
writeFileSync(join(testDir, 'lower', 'claude.md'), '# Claude instructions');
|
|
97
|
+
writeFileSync(join(testDir, 'upper', 'CLAUDE.MD'), '# Claude instructions');
|
|
98
|
+
writeFileSync(join(testDir, 'mixed', 'Agents.md'), '# Agent instructions');
|
|
99
|
+
writeFileSync(join(testDir, 'page.md'), '# Regular page');
|
|
100
|
+
|
|
101
|
+
const files = scanMarkdownFiles(testDir);
|
|
102
|
+
|
|
103
|
+
// Only page.md should be included (case-insensitive matching for reserved names)
|
|
104
|
+
expect(files).toEqual(['page.md']);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('excludes reserved filenames in subdirectories', () => {
|
|
108
|
+
mkdirSync(join(testDir, 'subdir'));
|
|
109
|
+
writeFileSync(join(testDir, 'subdir', 'CLAUDE.md'), '# Claude instructions');
|
|
110
|
+
writeFileSync(join(testDir, 'subdir', 'AGENTS.md'), '# Agent instructions');
|
|
111
|
+
writeFileSync(join(testDir, 'subdir', 'page.md'), '# Regular page');
|
|
112
|
+
|
|
113
|
+
const files = scanMarkdownFiles(testDir);
|
|
114
|
+
|
|
115
|
+
expect(files).toEqual(['subdir/page.md']);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe('RESERVED_FILENAMES', () => {
|
|
120
|
+
test('includes claude.md and agents.md', () => {
|
|
121
|
+
expect(RESERVED_FILENAMES.has('claude.md')).toBe(true);
|
|
122
|
+
expect(RESERVED_FILENAMES.has('agents.md')).toBe(true);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('detectPushCandidates', () => {
|
|
127
|
+
test('detects new files without page_id', () => {
|
|
128
|
+
const content = `---
|
|
129
|
+
title: New Page
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
Content here.`;
|
|
133
|
+
writeFileSync(join(testDir, 'new-page.md'), content);
|
|
134
|
+
|
|
135
|
+
const candidates = detectPushCandidates(testDir);
|
|
136
|
+
|
|
137
|
+
expect(candidates).toHaveLength(1);
|
|
138
|
+
expect(candidates[0]).toMatchObject({
|
|
139
|
+
path: 'new-page.md',
|
|
140
|
+
type: 'new',
|
|
141
|
+
title: 'New Page',
|
|
142
|
+
});
|
|
143
|
+
expect(candidates[0].pageId).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('detects modified files with mtime after synced_at', () => {
|
|
147
|
+
const syncedAt = new Date('2026-01-15T12:00:00.000Z');
|
|
148
|
+
const content = `---
|
|
149
|
+
page_id: '12345'
|
|
150
|
+
title: Existing Page
|
|
151
|
+
synced_at: '${syncedAt.toISOString()}'
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
Content here.`;
|
|
155
|
+
const filePath = join(testDir, 'modified.md');
|
|
156
|
+
writeFileSync(filePath, content);
|
|
157
|
+
|
|
158
|
+
// Set file mtime to 2 seconds after synced_at (beyond tolerance)
|
|
159
|
+
const modifiedTime = new Date(syncedAt.getTime() + 2000);
|
|
160
|
+
utimesSync(filePath, modifiedTime, modifiedTime);
|
|
161
|
+
|
|
162
|
+
const candidates = detectPushCandidates(testDir);
|
|
163
|
+
|
|
164
|
+
expect(candidates).toHaveLength(1);
|
|
165
|
+
expect(candidates[0]).toMatchObject({
|
|
166
|
+
path: 'modified.md',
|
|
167
|
+
type: 'modified',
|
|
168
|
+
title: 'Existing Page',
|
|
169
|
+
pageId: '12345',
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('ignores files with mtime within tolerance window', () => {
|
|
174
|
+
const syncedAt = new Date('2026-01-15T12:00:00.000Z');
|
|
175
|
+
const content = `---
|
|
176
|
+
page_id: '12345'
|
|
177
|
+
title: Just Synced Page
|
|
178
|
+
synced_at: '${syncedAt.toISOString()}'
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
Content here.`;
|
|
182
|
+
const filePath = join(testDir, 'just-synced.md');
|
|
183
|
+
writeFileSync(filePath, content);
|
|
184
|
+
|
|
185
|
+
// Set file mtime to 500ms after synced_at (within 1 second tolerance)
|
|
186
|
+
const justAfterSync = new Date(syncedAt.getTime() + 500);
|
|
187
|
+
utimesSync(filePath, justAfterSync, justAfterSync);
|
|
188
|
+
|
|
189
|
+
const candidates = detectPushCandidates(testDir);
|
|
190
|
+
|
|
191
|
+
expect(candidates).toHaveLength(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('detects files without synced_at as modified', () => {
|
|
195
|
+
const content = `---
|
|
196
|
+
page_id: '12345'
|
|
197
|
+
title: Legacy Page
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
Content here.`;
|
|
201
|
+
writeFileSync(join(testDir, 'legacy.md'), content);
|
|
202
|
+
|
|
203
|
+
const candidates = detectPushCandidates(testDir);
|
|
204
|
+
|
|
205
|
+
expect(candidates).toHaveLength(1);
|
|
206
|
+
expect(candidates[0]).toMatchObject({
|
|
207
|
+
path: 'legacy.md',
|
|
208
|
+
type: 'modified',
|
|
209
|
+
title: 'Legacy Page',
|
|
210
|
+
pageId: '12345',
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('uses filename as title when no title in frontmatter', () => {
|
|
215
|
+
const content = `---
|
|
216
|
+
page_id: '12345'
|
|
217
|
+
synced_at: '2026-01-15T12:00:00.000Z'
|
|
218
|
+
---
|
|
219
|
+
|
|
220
|
+
Content here.`;
|
|
221
|
+
const filePath = join(testDir, 'untitled-page.md');
|
|
222
|
+
writeFileSync(filePath, content);
|
|
223
|
+
|
|
224
|
+
// Modify to be detected
|
|
225
|
+
const modifiedTime = new Date('2026-01-15T12:00:02.000Z');
|
|
226
|
+
utimesSync(filePath, modifiedTime, modifiedTime);
|
|
227
|
+
|
|
228
|
+
const candidates = detectPushCandidates(testDir);
|
|
229
|
+
|
|
230
|
+
expect(candidates).toHaveLength(1);
|
|
231
|
+
expect(candidates[0].title).toBe('untitled-page');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('returns multiple candidates sorted by path', () => {
|
|
235
|
+
// New file
|
|
236
|
+
writeFileSync(
|
|
237
|
+
join(testDir, 'new.md'),
|
|
238
|
+
`---
|
|
239
|
+
title: New
|
|
240
|
+
---
|
|
241
|
+
Content`,
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
// Modified file
|
|
245
|
+
const syncedAt = new Date('2026-01-15T12:00:00.000Z');
|
|
246
|
+
const modifiedPath = join(testDir, 'modified.md');
|
|
247
|
+
writeFileSync(
|
|
248
|
+
modifiedPath,
|
|
249
|
+
`---
|
|
250
|
+
page_id: '12345'
|
|
251
|
+
title: Modified
|
|
252
|
+
synced_at: '${syncedAt.toISOString()}'
|
|
253
|
+
---
|
|
254
|
+
Content`,
|
|
255
|
+
);
|
|
256
|
+
const modifiedTime = new Date(syncedAt.getTime() + 2000);
|
|
257
|
+
utimesSync(modifiedPath, modifiedTime, modifiedTime);
|
|
258
|
+
|
|
259
|
+
const candidates = detectPushCandidates(testDir);
|
|
260
|
+
|
|
261
|
+
expect(candidates).toHaveLength(2);
|
|
262
|
+
expect(candidates[0].path).toBe('modified.md');
|
|
263
|
+
expect(candidates[1].path).toBe('new.md');
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test('ignores unmodified files', () => {
|
|
267
|
+
const syncedAt = new Date('2026-01-15T12:00:00.000Z');
|
|
268
|
+
const content = `---
|
|
269
|
+
page_id: '12345'
|
|
270
|
+
title: Unmodified Page
|
|
271
|
+
synced_at: '${syncedAt.toISOString()}'
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
Content here.`;
|
|
275
|
+
const filePath = join(testDir, 'unmodified.md');
|
|
276
|
+
writeFileSync(filePath, content);
|
|
277
|
+
|
|
278
|
+
// Set file mtime to BEFORE synced_at
|
|
279
|
+
const olderTime = new Date(syncedAt.getTime() - 1000);
|
|
280
|
+
utimesSync(filePath, olderTime, olderTime);
|
|
281
|
+
|
|
282
|
+
const candidates = detectPushCandidates(testDir);
|
|
283
|
+
|
|
284
|
+
expect(candidates).toHaveLength(0);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('handles files in subdirectories', () => {
|
|
288
|
+
mkdirSync(join(testDir, 'subfolder'));
|
|
289
|
+
const content = `---
|
|
290
|
+
title: Nested Page
|
|
291
|
+
---
|
|
292
|
+
|
|
293
|
+
Content here.`;
|
|
294
|
+
writeFileSync(join(testDir, 'subfolder', 'nested.md'), content);
|
|
295
|
+
|
|
296
|
+
const candidates = detectPushCandidates(testDir);
|
|
297
|
+
|
|
298
|
+
expect(candidates).toHaveLength(1);
|
|
299
|
+
expect(candidates[0].path).toBe('subfolder/nested.md');
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
test('excludes reserved filenames from push candidates', () => {
|
|
303
|
+
writeFileSync(
|
|
304
|
+
join(testDir, 'CLAUDE.md'),
|
|
305
|
+
`---
|
|
306
|
+
title: Claude
|
|
307
|
+
---
|
|
308
|
+
Instructions`,
|
|
309
|
+
);
|
|
310
|
+
writeFileSync(
|
|
311
|
+
join(testDir, 'AGENTS.md'),
|
|
312
|
+
`---
|
|
313
|
+
title: Agents
|
|
314
|
+
---
|
|
315
|
+
Instructions`,
|
|
316
|
+
);
|
|
317
|
+
writeFileSync(
|
|
318
|
+
join(testDir, 'page.md'),
|
|
319
|
+
`---
|
|
320
|
+
title: Regular Page
|
|
321
|
+
---
|
|
322
|
+
Content`,
|
|
323
|
+
);
|
|
324
|
+
|
|
325
|
+
const candidates = detectPushCandidates(testDir);
|
|
326
|
+
|
|
327
|
+
expect(candidates).toHaveLength(1);
|
|
328
|
+
expect(candidates[0].path).toBe('page.md');
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
});
|