@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,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
+ });