@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,699 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import chalk from 'chalk';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { basename, resolve } from 'node:path';
5
+ import { ConfigManager } from '../../lib/config.js';
6
+ import { ConfluenceClient, type CreatePageRequest, type UpdatePageRequest } from '../../lib/confluence-client/index.js';
7
+ import { EXIT_CODES, PageNotFoundError } from '../../lib/errors.js';
8
+ import { sortByDependencies } from '../../lib/dependency-sorter.js';
9
+ import { detectPushCandidates, type PushCandidate } from '../../lib/file-scanner.js';
10
+ import { checkDuplicatesBeforePush, displayVersionConflictGuidance } from './duplicate-check.js';
11
+ import { handlePushError, PushError } from './push-errors.js';
12
+ import {
13
+ buildPageLookupMapFromCache,
14
+ extractH1Title,
15
+ HtmlConverter,
16
+ parseMarkdown,
17
+ serializeMarkdown,
18
+ stripH1Title,
19
+ type PageFrontmatter,
20
+ } from '../../lib/markdown/index.js';
21
+ import { buildPageStateFromFiles } from '../../lib/page-state.js';
22
+ import {
23
+ hasSpaceConfig,
24
+ readSpaceConfig,
25
+ updatePageSyncInfo,
26
+ writeSpaceConfig,
27
+ type SpaceConfigWithState,
28
+ } from '../../lib/space-config.js';
29
+ import { handleFileRename } from './file-rename.js';
30
+ import { determineExpectedParent, ensureFolderHierarchy, FolderHierarchyError } from './folder-hierarchy.js';
31
+
32
+ export interface PushCommandOptions {
33
+ file?: string;
34
+ force?: boolean;
35
+ dryRun?: boolean;
36
+ }
37
+
38
+ /**
39
+ * Result of pushing a single file to Confluence
40
+ */
41
+ interface PushResult {
42
+ success: boolean;
43
+ updatedConfig: SpaceConfigWithState;
44
+ pageInfo?: {
45
+ pageId: string;
46
+ title: string;
47
+ localPath: string;
48
+ };
49
+ }
50
+
51
+ // Confluence Cloud has a ~65k character limit for page content in Storage Format
52
+ // Reference: https://confluence.atlassian.com/doc/confluence-cloud-document-and-restriction-limits-938777919.html
53
+ const MAX_PAGE_SIZE = 65000;
54
+
55
+ /**
56
+ * Push command - pushes local markdown files to Confluence
57
+ * When file is specified: pushes single file
58
+ * When no file: scans for changed files and prompts for each
59
+ */
60
+ export async function pushCommand(options: PushCommandOptions): Promise<void> {
61
+ const configManager = new ConfigManager();
62
+ const config = await configManager.getConfig();
63
+
64
+ if (!config) {
65
+ console.error(chalk.red('Not configured. Please run "cn setup" first.'));
66
+ process.exit(EXIT_CODES.CONFIG_ERROR);
67
+ }
68
+
69
+ const directory = process.cwd();
70
+
71
+ // Check if space is configured
72
+ if (!hasSpaceConfig(directory)) {
73
+ console.error(chalk.red('No space configured in this directory.'));
74
+ console.log(chalk.gray('Run "cn clone <SPACE_KEY>" to clone a space first.'));
75
+ process.exit(EXIT_CODES.CONFIG_ERROR);
76
+ }
77
+
78
+ const spaceConfigResult = readSpaceConfig(directory);
79
+ if (!spaceConfigResult || !spaceConfigResult.spaceId || !spaceConfigResult.spaceKey) {
80
+ console.error(chalk.red('Invalid space configuration.'));
81
+ console.log(chalk.gray('The .confluence.json file may be corrupted.'));
82
+ process.exit(EXIT_CODES.CONFIG_ERROR);
83
+ }
84
+ const spaceConfig = spaceConfigResult;
85
+
86
+ const client = new ConfluenceClient(config);
87
+
88
+ // If no file specified, scan for changes and prompt
89
+ if (!options.file) {
90
+ await pushBatch(client, config, spaceConfig, directory, options);
91
+ return;
92
+ }
93
+
94
+ // Single file push - exit with error code on failure
95
+ try {
96
+ await pushSingleFile(client, config, spaceConfig, directory, options.file, options);
97
+ } catch (error) {
98
+ if (error instanceof PushError || error instanceof FolderHierarchyError) {
99
+ process.exit(error.exitCode);
100
+ }
101
+ // Unexpected error - log and exit with general error code
102
+ console.error(chalk.red('Unexpected error:'));
103
+ console.error(error instanceof Error ? error.message : String(error));
104
+ process.exit(EXIT_CODES.GENERAL_ERROR);
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Push a single file to Confluence
110
+ */
111
+ async function pushSingleFile(
112
+ client: ConfluenceClient,
113
+ config: { confluenceUrl: string },
114
+ spaceConfig: SpaceConfigWithState,
115
+ directory: string,
116
+ file: string,
117
+ options: PushCommandOptions,
118
+ ): Promise<PushResult> {
119
+ // Resolve and validate file path
120
+ const filePath = resolve(directory, file);
121
+ if (!existsSync(filePath)) {
122
+ console.error(chalk.red(`File not found: ${file}`));
123
+ throw new PushError(`File not found: ${file}`, EXIT_CODES.INVALID_ARGUMENTS);
124
+ }
125
+
126
+ // Validate file extension
127
+ if (!filePath.endsWith('.md')) {
128
+ console.error(chalk.red(`Invalid file type: ${file}`));
129
+ console.log(chalk.gray('Only markdown files (.md) are supported.'));
130
+ throw new PushError(`Invalid file type: ${file}`, EXIT_CODES.INVALID_ARGUMENTS);
131
+ }
132
+
133
+ // Read and parse the markdown file
134
+ const markdownContent = readFileSync(filePath, 'utf-8');
135
+ const { frontmatter, content } = parseMarkdown(markdownContent);
136
+
137
+ // Get title: frontmatter > H1 heading > filename
138
+ const currentFilename = basename(filePath, '.md');
139
+ const h1Title = extractH1Title(content);
140
+ const title = frontmatter.title || h1Title || currentFilename;
141
+
142
+ // Strip H1 from content - Confluence displays title separately
143
+ const bodyContent = stripH1Title(content);
144
+
145
+ // Warn if using filename fallback for new pages
146
+ if (!frontmatter.page_id && !frontmatter.title && !h1Title) {
147
+ console.log(chalk.yellow(` Note: No title found, using filename: "${title}"`));
148
+ }
149
+
150
+ // Check if this is a new page (no page_id) or existing page
151
+ if (!frontmatter.page_id) {
152
+ return createNewPage(
153
+ client,
154
+ config,
155
+ spaceConfig,
156
+ directory,
157
+ filePath,
158
+ file,
159
+ options,
160
+ frontmatter,
161
+ bodyContent,
162
+ title,
163
+ );
164
+ } else {
165
+ return updateExistingPage(
166
+ client,
167
+ config,
168
+ spaceConfig,
169
+ directory,
170
+ filePath,
171
+ file,
172
+ options,
173
+ frontmatter,
174
+ bodyContent,
175
+ title,
176
+ );
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Scan for changed files and push with y/n prompts
182
+ */
183
+ async function pushBatch(
184
+ client: ConfluenceClient,
185
+ config: { confluenceUrl: string },
186
+ spaceConfig: SpaceConfigWithState,
187
+ directory: string,
188
+ options: PushCommandOptions,
189
+ ): Promise<void> {
190
+ console.log(chalk.gray('Scanning for changes...'));
191
+ console.log('');
192
+
193
+ // Check for duplicate page_ids before proceeding (ADR-0024)
194
+ const shouldContinue = await checkDuplicatesBeforePush(directory);
195
+ if (!shouldContinue) {
196
+ return;
197
+ }
198
+
199
+ const candidates = detectPushCandidates(directory);
200
+
201
+ if (candidates.length === 0) {
202
+ console.log(chalk.green('No changes to push.'));
203
+ return;
204
+ }
205
+
206
+ // Sort by dependencies so linked-to pages are pushed first
207
+ const { sorted: sortedCandidates, cycles } = sortByDependencies(candidates, directory);
208
+
209
+ // Warn about circular dependencies
210
+ if (cycles.length > 0) {
211
+ console.log(chalk.yellow('Circular link dependencies detected:'));
212
+ for (const cycle of cycles) {
213
+ console.log(chalk.yellow(` ${cycle.join(' -> ')} -> ${cycle[0]}`));
214
+ }
215
+ console.log('');
216
+ }
217
+
218
+ // Show summary
219
+ const newCount = sortedCandidates.filter((c) => c.type === 'new').length;
220
+ const modifiedCount = sortedCandidates.filter((c) => c.type === 'modified').length;
221
+
222
+ console.log(`Found ${chalk.bold(sortedCandidates.length)} file(s) to push:`);
223
+ for (const candidate of sortedCandidates) {
224
+ const typeLabel = candidate.type === 'new' ? chalk.cyan('[N]') : chalk.yellow('[M]');
225
+ console.log(` ${typeLabel} ${candidate.path}`);
226
+ }
227
+ console.log('');
228
+
229
+ if (options.dryRun) {
230
+ console.log(chalk.blue('--- DRY RUN MODE ---'));
231
+ console.log(chalk.gray(`Would push ${newCount} new and ${modifiedCount} modified file(s)`));
232
+ console.log(chalk.blue('No changes were made (dry run mode)'));
233
+ return;
234
+ }
235
+
236
+ // Process each candidate with y/n prompt
237
+ // Maintain in-memory config state throughout batch so link resolution works
238
+ let currentConfig = spaceConfig;
239
+ let pushed = 0;
240
+ let skipped = 0;
241
+ let failed = 0;
242
+ const failedFiles: string[] = [];
243
+ const pushedPages: Array<{ pageId: string; title: string; localPath: string }> = [];
244
+
245
+ for (const candidate of sortedCandidates) {
246
+ const typeLabel = candidate.type === 'new' ? 'create' : 'update';
247
+ const shouldPush = await confirm({
248
+ message: `Push ${candidate.path}? (${typeLabel})`,
249
+ default: true,
250
+ });
251
+
252
+ if (!shouldPush) {
253
+ skipped++;
254
+ continue;
255
+ }
256
+
257
+ try {
258
+ // Pass currentConfig so link resolution has access to previously-pushed pages
259
+ const result = await pushSingleFile(client, config, currentConfig, directory, candidate.path, {
260
+ ...options,
261
+ file: candidate.path,
262
+ });
263
+
264
+ // Update in-memory config for next iteration
265
+ currentConfig = result.updatedConfig;
266
+ pushed++;
267
+
268
+ // Track pushed page info for summary
269
+ if (result.pageInfo) {
270
+ pushedPages.push(result.pageInfo);
271
+ }
272
+ } catch (error) {
273
+ // Don't exit on individual failures in batch mode - continue with remaining files.
274
+ // PushError and FolderHierarchyError print their own user-friendly messages before throwing,
275
+ // so we only need to print the error message for unexpected errors.
276
+ if (!(error instanceof PushError) && !(error instanceof FolderHierarchyError)) {
277
+ console.error(chalk.red(` Failed: ${error instanceof Error ? error.message : 'Unknown error'}`));
278
+ }
279
+ failed++;
280
+ failedFiles.push(candidate.path);
281
+ }
282
+
283
+ console.log('');
284
+ }
285
+
286
+ // Summary
287
+ console.log(chalk.bold('Push complete:'));
288
+ if (pushed > 0) console.log(chalk.green(` ${pushed} pushed`));
289
+ if (skipped > 0) console.log(chalk.gray(` ${skipped} skipped`));
290
+ if (failed > 0) {
291
+ console.log(chalk.red(` ${failed} failed`));
292
+ console.log('');
293
+ console.log(chalk.gray('Failed files:'));
294
+ for (const file of failedFiles) {
295
+ console.log(chalk.gray(` ${file}`));
296
+ }
297
+ }
298
+
299
+ // Show pushed pages summary
300
+ if (pushedPages.length > 0) {
301
+ console.log('');
302
+ console.log(chalk.bold('Pushed pages:'));
303
+ for (const page of pushedPages) {
304
+ console.log(chalk.gray(` ${page.title} (${page.pageId}) - ${page.localPath}`));
305
+ }
306
+ }
307
+ }
308
+
309
+ /**
310
+ * Create a new page on Confluence
311
+ */
312
+ async function createNewPage(
313
+ client: ConfluenceClient,
314
+ config: { confluenceUrl: string },
315
+ spaceConfig: SpaceConfigWithState,
316
+ directory: string,
317
+ filePath: string,
318
+ relativePath: string,
319
+ options: PushCommandOptions,
320
+ frontmatter: Partial<PageFrontmatter>,
321
+ content: string,
322
+ title: string,
323
+ ): Promise<PushResult> {
324
+ console.log(chalk.bold(`Creating: ${title}`));
325
+ console.log(chalk.cyan(' (New page - no page_id in frontmatter)'));
326
+
327
+ // Convert markdown to HTML with link conversion (ADR-0022, ADR-0024)
328
+ // Uses the passed-in spaceConfig which is maintained in-memory during batch pushes
329
+ console.log(chalk.gray(' Converting markdown to HTML...'));
330
+ const converter = new HtmlConverter();
331
+ const pageState = buildPageStateFromFiles(directory, spaceConfig.pages);
332
+ const pageLookupMap = buildPageLookupMapFromCache(pageState);
333
+ const { html, warnings } = converter.convert(
334
+ content,
335
+ directory,
336
+ relativePath.replace(/^\.\//, ''),
337
+ spaceConfig.spaceKey,
338
+ pageLookupMap,
339
+ );
340
+
341
+ // Validate content size
342
+ if (html.length > MAX_PAGE_SIZE) {
343
+ console.error('');
344
+ console.error(chalk.red(`Content too large: ${html.length} characters (max: ${MAX_PAGE_SIZE})`));
345
+ console.log(chalk.gray('Confluence has a page size limit. Consider splitting into multiple pages.'));
346
+ throw new PushError(`Content too large: ${html.length} characters`, EXIT_CODES.INVALID_ARGUMENTS);
347
+ }
348
+
349
+ // Show conversion warnings (includes page state build warnings)
350
+ const allWarnings = [...pageState.warnings, ...warnings];
351
+ if (allWarnings.length > 0) {
352
+ console.log('');
353
+ console.log(chalk.yellow('Conversion warnings:'));
354
+ for (const warning of allWarnings) {
355
+ console.log(chalk.yellow(` ! ${warning}`));
356
+ }
357
+ console.log('');
358
+ }
359
+
360
+ // Determine parent handling - either explicit parent_id or auto-create folder hierarchy
361
+ let parentId: string | undefined = frontmatter.parent_id ?? undefined;
362
+ let currentConfig = spaceConfig;
363
+
364
+ // Validate explicit parent_id if specified
365
+ if (frontmatter.parent_id) {
366
+ try {
367
+ console.log(chalk.gray(' Validating parent page...'));
368
+ await client.getPage(frontmatter.parent_id, false);
369
+ } catch (error) {
370
+ if (error instanceof PageNotFoundError) {
371
+ console.error('');
372
+ console.error(chalk.red(`Parent page not found (ID: ${frontmatter.parent_id}).`));
373
+ console.log(chalk.gray('Remove parent_id from frontmatter or use a valid page ID.'));
374
+ throw new PushError(`Parent page not found: ${frontmatter.parent_id}`, EXIT_CODES.PAGE_NOT_FOUND);
375
+ }
376
+ throw error;
377
+ }
378
+ } else {
379
+ // No explicit parent_id - check if file is in a subdirectory
380
+ // If so, ensure folder hierarchy exists (ADR-0023)
381
+ const result = await ensureFolderHierarchy(client, spaceConfig, directory, relativePath, options.dryRun);
382
+ parentId = result.parentId;
383
+ currentConfig = result.updatedConfig;
384
+ }
385
+
386
+ // Build create request
387
+ // parentId can be a page ID or folder ID - v2 API supports both
388
+ const createRequest: CreatePageRequest = {
389
+ spaceId: currentConfig.spaceId,
390
+ status: 'current',
391
+ title,
392
+ parentId,
393
+ body: {
394
+ representation: 'storage',
395
+ value: html,
396
+ },
397
+ };
398
+
399
+ // Dry run mode
400
+ if (options.dryRun) {
401
+ console.log(chalk.blue('\n--- DRY RUN MODE ---'));
402
+ console.log(chalk.gray(`Would create: ${title} in ${currentConfig.spaceKey}`));
403
+ if (createRequest.parentId) console.log(chalk.gray(` Parent ID: ${createRequest.parentId}`));
404
+ console.log(chalk.gray(` Content: ${html.length} chars`));
405
+ console.log(chalk.blue('No changes were made (dry run mode)\n'));
406
+ return {
407
+ success: true,
408
+ updatedConfig: currentConfig,
409
+ pageInfo: { pageId: '[dry-run]', title, localPath: relativePath },
410
+ };
411
+ }
412
+
413
+ try {
414
+ // Create page on Confluence
415
+ console.log(chalk.gray(' Creating page on Confluence...'));
416
+ const createdPage = await client.createPage(createRequest);
417
+
418
+ // Set editor property to v2 to enable the new editor
419
+ // This is needed because the V2 API with storage format defaults to legacy editor
420
+ // See: https://community.developer.atlassian.com/t/confluence-rest-api-v2-struggling-to-create-a-page-with-the-new-editor/75235
421
+ try {
422
+ await client.setEditorV2(createdPage.id);
423
+ } catch {
424
+ // Non-fatal: page was created but may use legacy editor
425
+ console.log(chalk.yellow(' Warning: Could not set editor to v2. Page may use legacy editor.'));
426
+ }
427
+
428
+ // Build complete frontmatter from response
429
+ // Note: space_key is not included (inferred from .confluence.json)
430
+ const webui = createdPage._links?.webui;
431
+ const newFrontmatter: PageFrontmatter = {
432
+ page_id: createdPage.id,
433
+ title: createdPage.title,
434
+ created_at: createdPage.createdAt,
435
+ updated_at: createdPage.version?.createdAt,
436
+ version: createdPage.version?.number || 1,
437
+ parent_id: createdPage.parentId ?? undefined,
438
+ author_id: createdPage.authorId,
439
+ last_modifier_id: createdPage.version?.authorId,
440
+ url: webui ? `${config.confluenceUrl}/wiki${webui}` : undefined,
441
+ synced_at: new Date().toISOString(),
442
+ };
443
+
444
+ // Preserve any extra frontmatter fields the user may have added
445
+ const updatedFrontmatter: PageFrontmatter = {
446
+ ...frontmatter,
447
+ ...newFrontmatter,
448
+ };
449
+
450
+ const updatedMarkdown = serializeMarkdown(updatedFrontmatter, content);
451
+
452
+ // Handle file rename if title changed (also updates references in other files)
453
+ const { finalPath: finalLocalPath } = handleFileRename(
454
+ filePath,
455
+ relativePath,
456
+ createdPage.title,
457
+ updatedMarkdown,
458
+ directory,
459
+ );
460
+
461
+ // Update .confluence.json sync state
462
+ // Start with currentConfig (which may have folder updates from ensureFolderHierarchy)
463
+ // and add the new page's sync info
464
+ // Per ADR-0024: Only store pageId -> localPath mapping
465
+ const updatedSpaceConfig = updatePageSyncInfo(currentConfig, {
466
+ pageId: createdPage.id,
467
+ localPath: finalLocalPath,
468
+ });
469
+ writeSpaceConfig(directory, updatedSpaceConfig);
470
+
471
+ // Success!
472
+ console.log('');
473
+ console.log(chalk.green(`✓ Created: ${createdPage.title} (page_id: ${createdPage.id})`));
474
+
475
+ if (webui) {
476
+ console.log(chalk.gray(` ${config.confluenceUrl}/wiki${webui}`));
477
+ }
478
+
479
+ return {
480
+ success: true,
481
+ updatedConfig: updatedSpaceConfig,
482
+ pageInfo: {
483
+ pageId: createdPage.id,
484
+ title: createdPage.title,
485
+ localPath: finalLocalPath,
486
+ },
487
+ };
488
+ } catch (error) {
489
+ handlePushError(error, relativePath);
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Update an existing page on Confluence
495
+ */
496
+ async function updateExistingPage(
497
+ client: ConfluenceClient,
498
+ config: { confluenceUrl: string },
499
+ spaceConfig: SpaceConfigWithState,
500
+ directory: string,
501
+ filePath: string,
502
+ relativePath: string,
503
+ options: PushCommandOptions,
504
+ frontmatter: Partial<PageFrontmatter>,
505
+ content: string,
506
+ title: string,
507
+ ): Promise<PushResult> {
508
+ // Verify page_id exists (should be guaranteed by caller)
509
+ if (!frontmatter.page_id) {
510
+ throw new Error('updateExistingPage called without page_id');
511
+ }
512
+
513
+ const pageId = frontmatter.page_id;
514
+ const localVersion = frontmatter.version || 1;
515
+
516
+ console.log(chalk.bold(`Pushing: ${title}`));
517
+
518
+ try {
519
+ // Fetch current page to check version
520
+ console.log(chalk.gray(' Checking remote version...'));
521
+ const remotePage = await client.getPage(pageId, false);
522
+ const remoteVersion = remotePage.version?.number || 1;
523
+
524
+ // Check version match (unless --force)
525
+ if (!options.force && localVersion !== remoteVersion) {
526
+ console.error('');
527
+ console.error(chalk.red(`Version conflict detected.`));
528
+ console.error(chalk.red(` Local version: ${localVersion}`));
529
+ console.error(chalk.red(` Remote version: ${remoteVersion}`));
530
+ console.error('');
531
+
532
+ // Check for duplicates - this often explains version conflicts after page moves
533
+ displayVersionConflictGuidance(directory, relativePath, remoteVersion);
534
+ throw new PushError('Version conflict', EXIT_CODES.VERSION_CONFLICT);
535
+ }
536
+
537
+ // Warn if title differs
538
+ if (remotePage.title !== title) {
539
+ console.log(chalk.yellow(` Warning: Title differs (local: "${title}", remote: "${remotePage.title}")`));
540
+ console.log(chalk.yellow(' The remote title will be updated to match local.'));
541
+ }
542
+
543
+ // Determine expected parent from current folder structure
544
+ const { parentId: expectedParentId, updatedConfig: configAfterFolders } = await determineExpectedParent(
545
+ client,
546
+ spaceConfig,
547
+ directory,
548
+ relativePath,
549
+ options.dryRun,
550
+ );
551
+ const currentConfig = configAfterFolders;
552
+
553
+ // Get current parent from remote page
554
+ const currentParentId = remotePage.parentId ?? undefined;
555
+
556
+ // Track actual parent ID (may differ from expected if move fails)
557
+ let actualParentId = currentParentId;
558
+ const needsMove = expectedParentId !== currentParentId;
559
+
560
+ if (needsMove) {
561
+ console.log(chalk.yellow(` Parent changed: page will be moved to new location`));
562
+ if (!options.dryRun) {
563
+ if (expectedParentId) {
564
+ try {
565
+ await client.movePage(pageId, expectedParentId, 'append');
566
+ actualParentId = expectedParentId;
567
+ console.log(chalk.green(` Moved page under parent ID: ${expectedParentId}`));
568
+ } catch (error) {
569
+ const message = error instanceof Error ? error.message : 'Unknown error';
570
+ console.log(chalk.yellow(` Warning: Could not move page: ${message}`));
571
+ }
572
+ } else {
573
+ console.log(chalk.yellow(` Note: Cannot move to root level via API - page stays in current location`));
574
+ }
575
+ }
576
+ }
577
+
578
+ // Convert markdown to HTML with link conversion (ADR-0022, ADR-0024)
579
+ // Uses currentConfig which is maintained in-memory during batch pushes
580
+ console.log(chalk.gray(' Converting markdown to HTML...'));
581
+ const converter = new HtmlConverter();
582
+ const pageState = buildPageStateFromFiles(directory, currentConfig.pages);
583
+ const pageLookupMap = buildPageLookupMapFromCache(pageState);
584
+ const { html, warnings } = converter.convert(
585
+ content,
586
+ directory,
587
+ relativePath.replace(/^\.\//, ''),
588
+ currentConfig.spaceKey,
589
+ pageLookupMap,
590
+ );
591
+
592
+ // Validate content size
593
+ if (html.length > MAX_PAGE_SIZE) {
594
+ console.error('');
595
+ console.error(chalk.red(`Content too large: ${html.length} characters (max: ${MAX_PAGE_SIZE})`));
596
+ console.log(chalk.gray('Confluence has a page size limit. Consider splitting into multiple pages.'));
597
+ throw new PushError(`Content too large: ${html.length} characters`, EXIT_CODES.INVALID_ARGUMENTS);
598
+ }
599
+
600
+ // Show conversion warnings (includes page state build warnings)
601
+ const allWarnings = [...pageState.warnings, ...warnings];
602
+ if (allWarnings.length > 0) {
603
+ console.log('');
604
+ console.log(chalk.yellow('Conversion warnings:'));
605
+ for (const warning of allWarnings) {
606
+ console.log(chalk.yellow(` ! ${warning}`));
607
+ }
608
+ console.log('');
609
+ }
610
+
611
+ // Build update request
612
+ const newVersion = (options.force ? remoteVersion : localVersion) + 1;
613
+ const updateRequest: UpdatePageRequest = {
614
+ id: pageId,
615
+ status: 'current',
616
+ title,
617
+ body: {
618
+ representation: 'storage',
619
+ value: html,
620
+ },
621
+ version: {
622
+ number: newVersion,
623
+ },
624
+ };
625
+
626
+ // Dry run mode
627
+ if (options.dryRun) {
628
+ console.log(chalk.blue('\n--- DRY RUN MODE ---'));
629
+ console.log(chalk.gray(`Would update: ${title} (${pageId}), v${localVersion} → v${newVersion}`));
630
+ if (options.force) console.log(chalk.yellow(' Force mode: Would overwrite remote changes'));
631
+ if (needsMove) console.log(chalk.blue(` Would move to parent: ${expectedParentId ?? 'root'}`));
632
+ console.log(chalk.gray(` Content: ${html.length} chars`));
633
+ console.log(chalk.blue('No changes were made (dry run mode)\n'));
634
+ return { success: true, updatedConfig: currentConfig, pageInfo: { pageId, title, localPath: relativePath } };
635
+ }
636
+
637
+ // Push to Confluence
638
+ console.log(chalk.gray(` Pushing to Confluence (version ${localVersion} → ${newVersion})...`));
639
+ const updatedPage = await client.updatePage(updateRequest);
640
+
641
+ // Update local frontmatter with new metadata from response
642
+ // Note: space_key is not included (inferred from .confluence.json)
643
+ const webui = updatedPage._links?.webui;
644
+ const updatedFrontmatter: PageFrontmatter = {
645
+ ...frontmatter,
646
+ page_id: pageId,
647
+ title: updatedPage.title,
648
+ version: updatedPage.version?.number || newVersion,
649
+ updated_at: updatedPage.version?.createdAt,
650
+ last_modifier_id: updatedPage.version?.authorId,
651
+ parent_id: actualParentId,
652
+ url: webui ? `${config.confluenceUrl}/wiki${webui}` : frontmatter.url,
653
+ synced_at: new Date().toISOString(),
654
+ };
655
+ const updatedMarkdown = serializeMarkdown(updatedFrontmatter, content);
656
+
657
+ // Handle file rename if title changed (also updates references in other files)
658
+ const { finalPath: finalLocalPath } = handleFileRename(
659
+ filePath,
660
+ relativePath,
661
+ updatedPage.title,
662
+ updatedMarkdown,
663
+ directory,
664
+ );
665
+
666
+ // Update .confluence.json sync state
667
+ // Start with currentConfig (includes folder updates from determineExpectedParent)
668
+ // Per ADR-0024: Only store pageId -> localPath mapping
669
+ const updatedSpaceConfig = updatePageSyncInfo(currentConfig, {
670
+ pageId,
671
+ localPath: finalLocalPath,
672
+ });
673
+ writeSpaceConfig(directory, updatedSpaceConfig);
674
+
675
+ // Success!
676
+ console.log('');
677
+ console.log(
678
+ chalk.green(
679
+ `✓ Pushed: ${updatedPage.title} (version ${localVersion} → ${updatedPage.version?.number || newVersion})`,
680
+ ),
681
+ );
682
+
683
+ if (webui) {
684
+ console.log(chalk.gray(` ${config.confluenceUrl}/wiki${webui}`));
685
+ }
686
+
687
+ return {
688
+ success: true,
689
+ updatedConfig: updatedSpaceConfig,
690
+ pageInfo: {
691
+ pageId,
692
+ title: updatedPage.title,
693
+ localPath: finalLocalPath,
694
+ },
695
+ };
696
+ } catch (error) {
697
+ handlePushError(error, relativePath);
698
+ }
699
+ }