@bestend/confluence-cli 1.15.1

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.
@@ -0,0 +1,1225 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { program } = require('commander');
4
+ const chalk = require('chalk');
5
+ const inquirer = require('inquirer');
6
+ const ConfluenceClient = require('../lib/confluence-client');
7
+ const { getConfig, initConfig } = require('../lib/config');
8
+ const Analytics = require('../lib/analytics');
9
+ const pkg = require('../package.json');
10
+
11
+ program
12
+ .name('confluence')
13
+ .description('CLI tool for Atlassian Confluence')
14
+ .version(pkg.version);
15
+
16
+ // Init command
17
+ program
18
+ .command('init')
19
+ .description('Initialize Confluence CLI configuration')
20
+ .option('-d, --domain <domain>', 'Confluence domain')
21
+ .option('-p, --api-path <path>', 'REST API path')
22
+ .option('-a, --auth-type <type>', 'Authentication type (basic or bearer)')
23
+ .option('-e, --email <email>', 'Email for basic auth')
24
+ .option('-t, --token <token>', 'API token')
25
+ .action(async (options) => {
26
+ await initConfig(options);
27
+ });
28
+
29
+ // Read command
30
+ program
31
+ .command('read <pageId>')
32
+ .description('Read a Confluence page by ID or URL')
33
+ .option('-f, --format <format>', 'Output format (html, text, markdown)', 'text')
34
+ .action(async (pageId, options) => {
35
+ const analytics = new Analytics();
36
+ try {
37
+ const client = new ConfluenceClient(getConfig());
38
+ const content = await client.readPage(pageId, options.format);
39
+ console.log(content);
40
+ analytics.track('read', true);
41
+ } catch (error) {
42
+ analytics.track('read', false);
43
+ console.error(chalk.red('Error:'), error.message);
44
+ process.exit(1);
45
+ }
46
+ });
47
+
48
+ // Info command
49
+ program
50
+ .command('info <pageId>')
51
+ .description('Get information about a Confluence page')
52
+ .action(async (pageId) => {
53
+ const analytics = new Analytics();
54
+ try {
55
+ const client = new ConfluenceClient(getConfig());
56
+ const info = await client.getPageInfo(pageId);
57
+ console.log(chalk.blue('Page Information:'));
58
+ console.log(`Title: ${chalk.green(info.title)}`);
59
+ console.log(`ID: ${chalk.green(info.id)}`);
60
+ console.log(`Type: ${chalk.green(info.type)}`);
61
+ console.log(`Status: ${chalk.green(info.status)}`);
62
+ if (info.space) {
63
+ console.log(`Space: ${chalk.green(info.space.name)} (${info.space.key})`);
64
+ }
65
+ analytics.track('info', true);
66
+ } catch (error) {
67
+ analytics.track('info', false);
68
+ console.error(chalk.red('Error:'), error.message);
69
+ process.exit(1);
70
+ }
71
+ });
72
+
73
+ // Search command
74
+ program
75
+ .command('search <query>')
76
+ .description('Search for Confluence pages')
77
+ .option('-l, --limit <limit>', 'Limit number of results', '10')
78
+ .action(async (query, options) => {
79
+ const analytics = new Analytics();
80
+ try {
81
+ const client = new ConfluenceClient(getConfig());
82
+ const results = await client.search(query, parseInt(options.limit));
83
+
84
+ if (results.length === 0) {
85
+ console.log(chalk.yellow('No results found.'));
86
+ analytics.track('search', true);
87
+ return;
88
+ }
89
+
90
+ console.log(chalk.blue(`Found ${results.length} results:`));
91
+ results.forEach((result, index) => {
92
+ console.log(`${index + 1}. ${chalk.green(result.title)} (ID: ${result.id})`);
93
+ if (result.excerpt) {
94
+ console.log(` ${chalk.gray(result.excerpt)}`);
95
+ }
96
+ });
97
+ analytics.track('search', true);
98
+ } catch (error) {
99
+ analytics.track('search', false);
100
+ console.error(chalk.red('Error:'), error.message);
101
+ process.exit(1);
102
+ }
103
+ });
104
+
105
+ // List spaces command
106
+ program
107
+ .command('spaces')
108
+ .description('List all Confluence spaces')
109
+ .action(async () => {
110
+ const analytics = new Analytics();
111
+ try {
112
+ const config = getConfig();
113
+ const client = new ConfluenceClient(config);
114
+ const spaces = await client.getSpaces();
115
+
116
+ console.log(chalk.blue('Available spaces:'));
117
+ spaces.forEach(space => {
118
+ console.log(`${chalk.green(space.key)} - ${space.name}`);
119
+ });
120
+ analytics.track('spaces', true);
121
+ } catch (error) {
122
+ analytics.track('spaces', false);
123
+ console.error(chalk.red('Error:'), error.message);
124
+ process.exit(1);
125
+ }
126
+ });
127
+
128
+ // Stats command
129
+ program
130
+ .command('stats')
131
+ .description('Show usage statistics')
132
+ .action(async () => {
133
+ try {
134
+ const analytics = new Analytics();
135
+ analytics.showStats();
136
+ } catch (error) {
137
+ console.error(chalk.red('Error:'), error.message);
138
+ process.exit(1);
139
+ }
140
+ });
141
+
142
+ // Create command
143
+ program
144
+ .command('create <title> <spaceKey>')
145
+ .description('Create a new Confluence page')
146
+ .option('-f, --file <file>', 'Read content from file')
147
+ .option('-c, --content <content>', 'Page content as string')
148
+ .option('--format <format>', 'Content format (storage, html, markdown)', 'storage')
149
+ .action(async (title, spaceKey, options) => {
150
+ const analytics = new Analytics();
151
+ try {
152
+ const config = getConfig();
153
+ const client = new ConfluenceClient(config);
154
+
155
+ let content = '';
156
+
157
+ if (options.file) {
158
+ const fs = require('fs');
159
+ if (!fs.existsSync(options.file)) {
160
+ throw new Error(`File not found: ${options.file}`);
161
+ }
162
+ content = fs.readFileSync(options.file, 'utf8');
163
+ } else if (options.content) {
164
+ content = options.content;
165
+ } else {
166
+ throw new Error('Either --file or --content option is required');
167
+ }
168
+
169
+ const result = await client.createPage(title, spaceKey, content, options.format);
170
+
171
+ console.log(chalk.green('✅ Page created successfully!'));
172
+ console.log(`Title: ${chalk.blue(result.title)}`);
173
+ console.log(`ID: ${chalk.blue(result.id)}`);
174
+ console.log(`Space: ${chalk.blue(result.space.name)} (${result.space.key})`);
175
+ console.log(`URL: ${chalk.gray(`https://${config.domain}/wiki${result._links.webui}`)}`);
176
+
177
+ analytics.track('create', true);
178
+ } catch (error) {
179
+ analytics.track('create', false);
180
+ console.error(chalk.red('Error:'), error.message);
181
+ process.exit(1);
182
+ }
183
+ });
184
+
185
+ // Create child page command
186
+ program
187
+ .command('create-child <title> <parentId>')
188
+ .description('Create a new Confluence page as a child of another page')
189
+ .option('-f, --file <file>', 'Read content from file')
190
+ .option('-c, --content <content>', 'Page content as string')
191
+ .option('--format <format>', 'Content format (storage, html, markdown)', 'storage')
192
+ .action(async (title, parentId, options) => {
193
+ const analytics = new Analytics();
194
+ try {
195
+ const config = getConfig();
196
+ const client = new ConfluenceClient(config);
197
+
198
+ // Get parent page info to get space key
199
+ const parentInfo = await client.getPageInfo(parentId);
200
+ const spaceKey = parentInfo.space.key;
201
+
202
+ let content = '';
203
+
204
+ if (options.file) {
205
+ const fs = require('fs');
206
+ if (!fs.existsSync(options.file)) {
207
+ throw new Error(`File not found: ${options.file}`);
208
+ }
209
+ content = fs.readFileSync(options.file, 'utf8');
210
+ } else if (options.content) {
211
+ content = options.content;
212
+ } else {
213
+ throw new Error('Either --file or --content option is required');
214
+ }
215
+
216
+ const result = await client.createChildPage(title, spaceKey, parentId, content, options.format);
217
+
218
+ console.log(chalk.green('✅ Child page created successfully!'));
219
+ console.log(`Title: ${chalk.blue(result.title)}`);
220
+ console.log(`ID: ${chalk.blue(result.id)}`);
221
+ console.log(`Parent: ${chalk.blue(parentInfo.title)} (${parentId})`);
222
+ console.log(`Space: ${chalk.blue(result.space.name)} (${result.space.key})`);
223
+ console.log(`URL: ${chalk.gray(`https://${config.domain}/wiki${result._links.webui}`)}`);
224
+
225
+ analytics.track('create_child', true);
226
+ } catch (error) {
227
+ analytics.track('create_child', false);
228
+ console.error(chalk.red('Error:'), error.message);
229
+ process.exit(1);
230
+ }
231
+ });
232
+
233
+ // Update command
234
+ program
235
+ .command('update <pageId>')
236
+ .description('Update an existing Confluence page')
237
+ .option('-t, --title <title>', 'New page title (optional)')
238
+ .option('-f, --file <file>', 'Read content from file')
239
+ .option('-c, --content <content>', 'Page content as string')
240
+ .option('--format <format>', 'Content format (storage, html, markdown)', 'storage')
241
+ .action(async (pageId, options) => {
242
+ const analytics = new Analytics();
243
+ try {
244
+ // Check if at least one option is provided
245
+ if (!options.title && !options.file && !options.content) {
246
+ throw new Error('At least one of --title, --file, or --content must be provided.');
247
+ }
248
+
249
+ const config = getConfig();
250
+ const client = new ConfluenceClient(config);
251
+
252
+ let content = null; // Use null to indicate no content change
253
+
254
+ if (options.file) {
255
+ const fs = require('fs');
256
+ if (!fs.existsSync(options.file)) {
257
+ throw new Error(`File not found: ${options.file}`);
258
+ }
259
+ content = fs.readFileSync(options.file, 'utf8');
260
+ } else if (options.content) {
261
+ content = options.content;
262
+ }
263
+
264
+ const result = await client.updatePage(pageId, options.title, content, options.format);
265
+
266
+ console.log(chalk.green('✅ Page updated successfully!'));
267
+ console.log(`Title: ${chalk.blue(result.title)}`);
268
+ console.log(`ID: ${chalk.blue(result.id)}`);
269
+ console.log(`Version: ${chalk.blue(result.version.number)}`);
270
+ console.log(`URL: ${chalk.gray(`https://${config.domain}/wiki${result._links.webui}`)}`);
271
+
272
+ analytics.track('update', true);
273
+ } catch (error) {
274
+ analytics.track('update', false);
275
+ console.error(chalk.red('Error:'), error.message);
276
+ process.exit(1);
277
+ }
278
+ });
279
+
280
+ // Delete command
281
+ program
282
+ .command('delete <pageIdOrUrl>')
283
+ .description('Delete a Confluence page by ID or URL')
284
+ .option('-y, --yes', 'Skip confirmation prompt')
285
+ .action(async (pageIdOrUrl, options) => {
286
+ const analytics = new Analytics();
287
+ try {
288
+ const config = getConfig();
289
+ const client = new ConfluenceClient(config);
290
+ const pageInfo = await client.getPageInfo(pageIdOrUrl);
291
+
292
+ if (!options.yes) {
293
+ const spaceLabel = pageInfo.space?.key ? ` (${pageInfo.space.key})` : '';
294
+ const { confirmed } = await inquirer.prompt([
295
+ {
296
+ type: 'confirm',
297
+ name: 'confirmed',
298
+ default: false,
299
+ message: `Delete "${pageInfo.title}" (ID: ${pageInfo.id})${spaceLabel}?`
300
+ }
301
+ ]);
302
+
303
+ if (!confirmed) {
304
+ console.log(chalk.yellow('Cancelled.'));
305
+ analytics.track('delete_cancel', true);
306
+ return;
307
+ }
308
+ }
309
+
310
+ const result = await client.deletePage(pageInfo.id);
311
+
312
+ console.log(chalk.green('✅ Page deleted successfully!'));
313
+ console.log(`Title: ${chalk.blue(pageInfo.title)}`);
314
+ console.log(`ID: ${chalk.blue(result.id)}`);
315
+ analytics.track('delete', true);
316
+ } catch (error) {
317
+ analytics.track('delete', false);
318
+ console.error(chalk.red('Error:'), error.message);
319
+ process.exit(1);
320
+ }
321
+ });
322
+
323
+ // Edit command - opens page content for editing
324
+ program
325
+ .command('edit <pageId>')
326
+ .description('Get page content for editing')
327
+ .option('-o, --output <file>', 'Save content to file')
328
+ .action(async (pageId, options) => {
329
+ const analytics = new Analytics();
330
+ try {
331
+ const config = getConfig();
332
+ const client = new ConfluenceClient(config);
333
+ const pageData = await client.getPageForEdit(pageId);
334
+
335
+ console.log(chalk.blue('Page Information:'));
336
+ console.log(`Title: ${chalk.green(pageData.title)}`);
337
+ console.log(`ID: ${chalk.green(pageData.id)}`);
338
+ console.log(`Version: ${chalk.green(pageData.version)}`);
339
+ console.log(`Space: ${chalk.green(pageData.space.name)} (${pageData.space.key})`);
340
+ console.log('');
341
+
342
+ if (options.output) {
343
+ const fs = require('fs');
344
+ fs.writeFileSync(options.output, pageData.content);
345
+ console.log(chalk.green(`✅ Content saved to: ${options.output}`));
346
+ console.log(chalk.yellow('💡 Edit the file and use "confluence update" to save changes'));
347
+ } else {
348
+ console.log(chalk.blue('Page Content:'));
349
+ console.log(pageData.content);
350
+ }
351
+
352
+ analytics.track('edit', true);
353
+ } catch (error) {
354
+ analytics.track('edit', false);
355
+ console.error(chalk.red('Error:'), error.message);
356
+ process.exit(1);
357
+ }
358
+ });
359
+
360
+ // Find page by title command
361
+ program
362
+ .command('find <title>')
363
+ .description('Find a page by title')
364
+ .option('-s, --space <spaceKey>', 'Limit search to specific space')
365
+ .action(async (title, options) => {
366
+ const analytics = new Analytics();
367
+ try {
368
+ const config = getConfig();
369
+ const client = new ConfluenceClient(config);
370
+ const pageInfo = await client.findPageByTitle(title, options.space);
371
+
372
+ console.log(chalk.blue('Page found:'));
373
+ console.log(`Title: ${chalk.green(pageInfo.title)}`);
374
+ console.log(`ID: ${chalk.green(pageInfo.id)}`);
375
+ console.log(`Space: ${chalk.green(pageInfo.space.name)} (${pageInfo.space.key})`);
376
+ console.log(`URL: ${chalk.gray(`https://${config.domain}/wiki${pageInfo.url}`)}`);
377
+
378
+ analytics.track('find', true);
379
+ } catch (error) {
380
+ analytics.track('find', false);
381
+ console.error(chalk.red('Error:'), error.message);
382
+ process.exit(1);
383
+ }
384
+ });
385
+
386
+ // Attachments command
387
+ program
388
+ .command('attachments <pageId>')
389
+ .description('List or download attachments for a page')
390
+ .option('-l, --limit <limit>', 'Maximum number of attachments to fetch (default: all)')
391
+ .option('-p, --pattern <glob>', 'Filter attachments by filename (e.g., "*.png")')
392
+ .option('-d, --download', 'Download matching attachments')
393
+ .option('--dest <directory>', 'Directory to save downloads (default: current directory)', '.')
394
+ .action(async (pageId, options) => {
395
+ const analytics = new Analytics();
396
+ try {
397
+ const config = getConfig();
398
+ const client = new ConfluenceClient(config);
399
+ const maxResults = options.limit ? parseInt(options.limit, 10) : null;
400
+ const pattern = options.pattern ? options.pattern.trim() : null;
401
+
402
+ if (options.limit && (Number.isNaN(maxResults) || maxResults <= 0)) {
403
+ throw new Error('Limit must be a positive number.');
404
+ }
405
+
406
+ const attachments = await client.getAllAttachments(pageId, { maxResults });
407
+ const filtered = pattern ? attachments.filter(att => client.matchesPattern(att.title, pattern)) : attachments;
408
+
409
+ if (filtered.length === 0) {
410
+ console.log(chalk.yellow('No attachments found.'));
411
+ analytics.track('attachments', true);
412
+ return;
413
+ }
414
+
415
+ console.log(chalk.blue(`Found ${filtered.length} attachment${filtered.length === 1 ? '' : 's'}:`));
416
+ filtered.forEach((att, index) => {
417
+ const sizeKb = att.fileSize ? `${Math.max(1, Math.round(att.fileSize / 1024))} KB` : 'unknown size';
418
+ const typeLabel = att.mediaType || 'unknown';
419
+ console.log(`${index + 1}. ${chalk.green(att.title)} (ID: ${att.id})`);
420
+ console.log(` Type: ${chalk.gray(typeLabel)} • Size: ${chalk.gray(sizeKb)} • Version: ${chalk.gray(att.version)}`);
421
+ });
422
+
423
+ if (options.download) {
424
+ const fs = require('fs');
425
+ const path = require('path');
426
+ const destDir = path.resolve(options.dest || '.');
427
+ fs.mkdirSync(destDir, { recursive: true });
428
+
429
+ const uniquePathFor = (dir, filename) => {
430
+ const parsed = path.parse(filename);
431
+ let attempt = path.join(dir, filename);
432
+ let counter = 1;
433
+ while (fs.existsSync(attempt)) {
434
+ const suffix = ` (${counter})`;
435
+ const nextName = `${parsed.name}${suffix}${parsed.ext}`;
436
+ attempt = path.join(dir, nextName);
437
+ counter += 1;
438
+ }
439
+ return attempt;
440
+ };
441
+
442
+ const writeStream = (stream, targetPath) => new Promise((resolve, reject) => {
443
+ const writer = fs.createWriteStream(targetPath);
444
+ stream.pipe(writer);
445
+ stream.on('error', reject);
446
+ writer.on('error', reject);
447
+ writer.on('finish', resolve);
448
+ });
449
+
450
+ let downloaded = 0;
451
+ for (const attachment of filtered) {
452
+ const targetPath = uniquePathFor(destDir, attachment.title);
453
+ // Pass the full attachment object so downloadAttachment can use downloadLink directly
454
+ const dataStream = await client.downloadAttachment(pageId, attachment);
455
+ await writeStream(dataStream, targetPath);
456
+ downloaded += 1;
457
+ console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`);
458
+ }
459
+
460
+ console.log(chalk.green(`Downloaded ${downloaded} attachment${downloaded === 1 ? '' : 's'} to ${destDir}`));
461
+ }
462
+
463
+ analytics.track('attachments', true);
464
+ } catch (error) {
465
+ analytics.track('attachments', false);
466
+ console.error(chalk.red('Error:'), error.message);
467
+ process.exit(1);
468
+ }
469
+ });
470
+
471
+ // Comments command
472
+ program
473
+ .command('comments <pageId>')
474
+ .description('List comments for a page by ID or URL')
475
+ .option('-f, --format <format>', 'Output format (text, markdown, json)', 'text')
476
+ .option('-l, --limit <limit>', 'Maximum number of comments to fetch (default: 25)')
477
+ .option('--start <start>', 'Start index for results (default: 0)', '0')
478
+ .option('--location <location>', 'Filter by location (inline, footer, resolved). Comma-separated')
479
+ .option('--depth <depth>', 'Comment depth ("" for root only, "all")')
480
+ .option('--all', 'Fetch all comments (ignores pagination)')
481
+ .action(async (pageId, options) => {
482
+ const analytics = new Analytics();
483
+ try {
484
+ const config = getConfig();
485
+ const client = new ConfluenceClient(config);
486
+
487
+ const format = (options.format || 'text').toLowerCase();
488
+ if (!['text', 'markdown', 'json'].includes(format)) {
489
+ throw new Error('Format must be one of: text, markdown, json');
490
+ }
491
+
492
+ const limit = options.limit ? parseInt(options.limit, 10) : null;
493
+ if (options.limit && (Number.isNaN(limit) || limit <= 0)) {
494
+ throw new Error('Limit must be a positive number.');
495
+ }
496
+
497
+ const start = options.start ? parseInt(options.start, 10) : 0;
498
+ if (options.start && (Number.isNaN(start) || start < 0)) {
499
+ throw new Error('Start must be a non-negative number.');
500
+ }
501
+
502
+ const locationValues = parseLocationOptions(options.location);
503
+ const invalidLocations = locationValues.filter(value => !['inline', 'footer', 'resolved'].includes(value));
504
+ if (invalidLocations.length > 0) {
505
+ throw new Error(`Invalid location value(s): ${invalidLocations.join(', ')}`);
506
+ }
507
+ const locationParam = locationValues.length === 0
508
+ ? null
509
+ : (locationValues.length === 1 ? locationValues[0] : locationValues);
510
+
511
+ let comments = [];
512
+ let nextStart = null;
513
+
514
+ if (options.all) {
515
+ comments = await client.getAllComments(pageId, {
516
+ maxResults: limit || null,
517
+ start,
518
+ location: locationParam,
519
+ depth: options.depth
520
+ });
521
+ } else {
522
+ const response = await client.listComments(pageId, {
523
+ limit: limit || undefined,
524
+ start,
525
+ location: locationParam,
526
+ depth: options.depth
527
+ });
528
+ comments = response.results;
529
+ nextStart = response.nextStart;
530
+ }
531
+
532
+ if (comments.length === 0) {
533
+ console.log(chalk.yellow('No comments found.'));
534
+ analytics.track('comments', true);
535
+ return;
536
+ }
537
+
538
+ if (format === 'json') {
539
+ const resolvedPageId = await client.extractPageId(pageId);
540
+ const output = {
541
+ pageId: resolvedPageId,
542
+ commentCount: comments.length,
543
+ comments: comments.map(comment => ({
544
+ ...comment,
545
+ bodyStorage: comment.body,
546
+ bodyText: client.formatCommentBody(comment.body, 'text')
547
+ }))
548
+ };
549
+ if (!options.all) {
550
+ output.nextStart = nextStart;
551
+ }
552
+ console.log(JSON.stringify(output, null, 2));
553
+ analytics.track('comments', true);
554
+ return;
555
+ }
556
+
557
+ const commentTree = buildCommentTree(comments);
558
+ console.log(chalk.blue(`Found ${comments.length} comment${comments.length === 1 ? '' : 's'}:`));
559
+
560
+ const renderComments = (nodes, path = []) => {
561
+ nodes.forEach((comment, index) => {
562
+ const currentPath = [...path, index + 1];
563
+ const level = currentPath.length - 1;
564
+ const indent = ' '.repeat(level);
565
+ const branchGlyph = level > 0 ? (index === nodes.length - 1 ? '└─ ' : '├─ ') : '';
566
+ const headerPrefix = `${indent}${chalk.dim(branchGlyph)}`;
567
+ const bodyIndent = level === 0
568
+ ? ' '
569
+ : `${indent}${' '.repeat(branchGlyph.length)}`;
570
+
571
+ const isReply = Boolean(comment.parentId);
572
+ const location = comment.location || 'unknown';
573
+ const author = comment.author?.displayName || 'Unknown';
574
+ const createdAt = comment.createdAt || 'unknown date';
575
+ const metaParts = [`Created: ${createdAt}`];
576
+ if (comment.status) metaParts.push(`Status: ${comment.status}`);
577
+ if (comment.version) metaParts.push(`Version: ${comment.version}`);
578
+ if (!isReply && comment.resolution) metaParts.push(`Resolution: ${comment.resolution}`);
579
+
580
+ const label = isReply ? chalk.gray('[reply]') : chalk.cyan(`[${location}]`);
581
+ console.log(`${headerPrefix}${currentPath.join('.')}. ${chalk.green(author)} ${chalk.gray(`(ID: ${comment.id})`)} ${label}`);
582
+ console.log(chalk.dim(`${bodyIndent}${metaParts.join(' • ')}`));
583
+
584
+ if (!isReply) {
585
+ const inlineProps = comment.inlineProperties || {};
586
+ const selectionText = inlineProps.selection || inlineProps.originalSelection;
587
+ if (selectionText) {
588
+ const selectionLabel = inlineProps.selection ? 'Highlight' : 'Highlight (original)';
589
+ console.log(chalk.dim(`${bodyIndent}${selectionLabel}: ${selectionText}`));
590
+ }
591
+ if (inlineProps.markerRef) {
592
+ console.log(chalk.dim(`${bodyIndent}Marker ref: ${inlineProps.markerRef}`));
593
+ }
594
+ }
595
+
596
+ const body = client.formatCommentBody(comment.body, format);
597
+ if (body) {
598
+ console.log(`${bodyIndent}${chalk.yellowBright('Body:')}`);
599
+ console.log(formatBodyBlock(body, `${bodyIndent} `));
600
+ }
601
+
602
+ if (comment.children && comment.children.length > 0) {
603
+ renderComments(comment.children, currentPath);
604
+ }
605
+ });
606
+ };
607
+
608
+ renderComments(commentTree);
609
+
610
+ if (!options.all && nextStart !== null && nextStart !== undefined) {
611
+ console.log(chalk.gray(`Next start: ${nextStart}`));
612
+ }
613
+
614
+ analytics.track('comments', true);
615
+ } catch (error) {
616
+ analytics.track('comments', false);
617
+ console.error(chalk.red('Error:'), error.message);
618
+ process.exit(1);
619
+ }
620
+ });
621
+
622
+ // Comment creation command
623
+ program
624
+ .command('comment <pageId>')
625
+ .description('Create a comment on a page by ID or URL (footer or inline)')
626
+ .option('-f, --file <file>', 'Read content from file')
627
+ .option('-c, --content <content>', 'Comment content as string')
628
+ .option('--format <format>', 'Content format (storage, html, markdown)', 'storage')
629
+ .option('--parent <commentId>', 'Reply to a comment by ID')
630
+ .option('--location <location>', 'Comment location (inline or footer)', 'footer')
631
+ .option('--inline-selection <text>', 'Inline selection text')
632
+ .option('--inline-original-selection <text>', 'Original inline selection text')
633
+ .option('--inline-marker-ref <ref>', 'Inline marker reference (optional)')
634
+ .option('--inline-properties <json>', 'Inline properties JSON (advanced)')
635
+ .action(async (pageId, options) => {
636
+ const analytics = new Analytics();
637
+ let location = null;
638
+ try {
639
+ const config = getConfig();
640
+ const client = new ConfluenceClient(config);
641
+
642
+ let content = '';
643
+
644
+ if (options.file) {
645
+ const fs = require('fs');
646
+ if (!fs.existsSync(options.file)) {
647
+ throw new Error(`File not found: ${options.file}`);
648
+ }
649
+ content = fs.readFileSync(options.file, 'utf8');
650
+ } else if (options.content) {
651
+ content = options.content;
652
+ } else {
653
+ throw new Error('Either --file or --content option is required');
654
+ }
655
+
656
+ location = (options.location || 'footer').toLowerCase();
657
+ if (!['inline', 'footer'].includes(location)) {
658
+ throw new Error('Location must be either "inline" or "footer".');
659
+ }
660
+
661
+ let inlineProperties = {};
662
+ if (options.inlineProperties) {
663
+ try {
664
+ const parsed = JSON.parse(options.inlineProperties);
665
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
666
+ throw new Error('Inline properties must be a JSON object.');
667
+ }
668
+ inlineProperties = { ...parsed };
669
+ } catch (error) {
670
+ throw new Error(`Invalid --inline-properties JSON: ${error.message}`);
671
+ }
672
+ }
673
+
674
+ if (options.inlineSelection) {
675
+ inlineProperties.selection = options.inlineSelection;
676
+ }
677
+ if (options.inlineOriginalSelection) {
678
+ inlineProperties.originalSelection = options.inlineOriginalSelection;
679
+ }
680
+ if (options.inlineMarkerRef) {
681
+ inlineProperties.markerRef = options.inlineMarkerRef;
682
+ }
683
+
684
+ if (Object.keys(inlineProperties).length > 0 && location !== 'inline') {
685
+ throw new Error('Inline properties can only be used with --location inline.');
686
+ }
687
+
688
+ const parentId = options.parent;
689
+
690
+ if (location === 'inline') {
691
+ const hasSelection = inlineProperties.selection || inlineProperties.originalSelection;
692
+ if (!hasSelection && !parentId) {
693
+ throw new Error('Inline comments require --inline-selection or --inline-original-selection when starting a new inline thread.');
694
+ }
695
+ if (hasSelection) {
696
+ if (!inlineProperties.originalSelection && inlineProperties.selection) {
697
+ inlineProperties.originalSelection = inlineProperties.selection;
698
+ }
699
+ if (!inlineProperties.markerRef) {
700
+ inlineProperties.markerRef = `comment-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
701
+ }
702
+ }
703
+ }
704
+
705
+ const result = await client.createComment(pageId, content, options.format, {
706
+ parentId,
707
+ location,
708
+ inlineProperties: location === 'inline' ? inlineProperties : null
709
+ });
710
+
711
+ console.log(chalk.green('✅ Comment created successfully!'));
712
+ console.log(`ID: ${chalk.blue(result.id)}`);
713
+ if (result.container?.id) {
714
+ console.log(`Page ID: ${chalk.blue(result.container.id)}`);
715
+ }
716
+ if (result._links?.webui) {
717
+ const url = client.toAbsoluteUrl(result._links.webui);
718
+ console.log(`URL: ${chalk.gray(url)}`);
719
+ }
720
+
721
+ analytics.track('comment_create', true);
722
+ } catch (error) {
723
+ analytics.track('comment_create', false);
724
+ console.error(chalk.red('Error:'), error.message);
725
+ if (error.response?.data) {
726
+ const detail = typeof error.response.data === 'string'
727
+ ? error.response.data
728
+ : JSON.stringify(error.response.data, null, 2);
729
+ console.error(chalk.red('API response:'), detail);
730
+ }
731
+ const apiErrors = error.response?.data?.data?.errors || error.response?.data?.errors || [];
732
+ const errorKeys = apiErrors
733
+ .map((entry) => entry?.message?.key || entry?.message || entry?.key)
734
+ .filter(Boolean);
735
+ const needsInlineMeta = ['matchIndex', 'lastFetchTime', 'serializedHighlights']
736
+ .every((key) => errorKeys.includes(key));
737
+ if (location === 'inline' && needsInlineMeta) {
738
+ console.error(chalk.yellow('Inline comment creation requires editor highlight metadata (matchIndex, lastFetchTime, serializedHighlights).'));
739
+ console.error(chalk.yellow('Try replying to an existing inline comment or use footer comments instead.'));
740
+ }
741
+ process.exit(1);
742
+ }
743
+ });
744
+
745
+ // Comment delete command
746
+ program
747
+ .command('comment-delete <commentId>')
748
+ .description('Delete a comment by ID')
749
+ .option('-y, --yes', 'Skip confirmation prompt')
750
+ .action(async (commentId, options) => {
751
+ const analytics = new Analytics();
752
+ try {
753
+ const config = getConfig();
754
+ const client = new ConfluenceClient(config);
755
+
756
+ if (!options.yes) {
757
+ const { confirmed } = await inquirer.prompt([
758
+ {
759
+ type: 'confirm',
760
+ name: 'confirmed',
761
+ default: false,
762
+ message: `Delete comment ${commentId}?`
763
+ }
764
+ ]);
765
+
766
+ if (!confirmed) {
767
+ console.log(chalk.yellow('Cancelled.'));
768
+ analytics.track('comment_delete_cancel', true);
769
+ return;
770
+ }
771
+ }
772
+
773
+ const result = await client.deleteComment(commentId);
774
+
775
+ console.log(chalk.green('✅ Comment deleted successfully!'));
776
+ console.log(`ID: ${chalk.blue(result.id)}`);
777
+ analytics.track('comment_delete', true);
778
+ } catch (error) {
779
+ analytics.track('comment_delete', false);
780
+ console.error(chalk.red('Error:'), error.message);
781
+ process.exit(1);
782
+ }
783
+ });
784
+
785
+ // Export page content with attachments
786
+ program
787
+ .command('export <pageId>')
788
+ .description('Export a page to a directory with its attachments')
789
+ .option('--format <format>', 'Content format (html, text, markdown)', 'markdown')
790
+ .option('--dest <directory>', 'Base directory to export into', '.')
791
+ .option('--file <filename>', 'Content filename (default: page.<ext>)')
792
+ .option('--attachments-dir <name>', 'Subdirectory for attachments', 'attachments')
793
+ .option('--pattern <glob>', 'Filter attachments by filename (e.g., "*.png")')
794
+ .option('--referenced-only', 'Download only attachments referenced in the page content')
795
+ .option('--skip-attachments', 'Do not download attachments')
796
+ .action(async (pageId, options) => {
797
+ const analytics = new Analytics();
798
+ try {
799
+ const config = getConfig();
800
+ const client = new ConfluenceClient(config);
801
+ const fs = require('fs');
802
+ const path = require('path');
803
+
804
+ const format = (options.format || 'markdown').toLowerCase();
805
+ const formatExt = { markdown: 'md', html: 'html', text: 'txt' };
806
+ const contentExt = formatExt[format] || 'txt';
807
+
808
+ const pageInfo = await client.getPageInfo(pageId);
809
+ const content = await client.readPage(
810
+ pageId,
811
+ format,
812
+ options.referencedOnly ? { extractReferencedAttachments: true } : {}
813
+ );
814
+ const referencedAttachments = options.referencedOnly
815
+ ? (client._referencedAttachments || new Set())
816
+ : null;
817
+
818
+ const baseDir = path.resolve(options.dest || '.');
819
+ const folderName = sanitizeTitle(pageInfo.title || 'page');
820
+ const exportDir = path.join(baseDir, folderName);
821
+ fs.mkdirSync(exportDir, { recursive: true });
822
+
823
+ const contentFile = options.file || `page.${contentExt}`;
824
+ const contentPath = path.join(exportDir, contentFile);
825
+ fs.writeFileSync(contentPath, content);
826
+
827
+ console.log(chalk.green('✅ Page exported'));
828
+ console.log(`Title: ${chalk.blue(pageInfo.title)}`);
829
+ console.log(`Content: ${chalk.gray(contentPath)}`);
830
+
831
+ if (!options.skipAttachments) {
832
+ const pattern = options.pattern ? options.pattern.trim() : null;
833
+ const allAttachments = await client.getAllAttachments(pageId);
834
+
835
+ let filtered;
836
+ if (pattern) {
837
+ filtered = allAttachments.filter(att => client.matchesPattern(att.title, pattern));
838
+ } else if (options.referencedOnly) {
839
+ filtered = allAttachments.filter(att => referencedAttachments?.has(att.title));
840
+ } else {
841
+ filtered = allAttachments;
842
+ }
843
+
844
+ if (filtered.length === 0) {
845
+ console.log(chalk.yellow('No attachments to download.'));
846
+ } else {
847
+ const attachmentsDirName = options.attachmentsDir || 'attachments';
848
+ const attachmentsDir = path.join(exportDir, attachmentsDirName);
849
+ fs.mkdirSync(attachmentsDir, { recursive: true });
850
+
851
+ const uniquePathFor = (dir, filename) => {
852
+ const parsed = path.parse(filename);
853
+ let attempt = path.join(dir, filename);
854
+ let counter = 1;
855
+ while (fs.existsSync(attempt)) {
856
+ const suffix = ` (${counter})`;
857
+ const nextName = `${parsed.name}${suffix}${parsed.ext}`;
858
+ attempt = path.join(dir, nextName);
859
+ counter += 1;
860
+ }
861
+ return attempt;
862
+ };
863
+
864
+ const writeStream = (stream, targetPath) => new Promise((resolve, reject) => {
865
+ const writer = fs.createWriteStream(targetPath);
866
+ stream.pipe(writer);
867
+ stream.on('error', reject);
868
+ writer.on('error', reject);
869
+ writer.on('finish', resolve);
870
+ });
871
+
872
+ let downloaded = 0;
873
+ for (const attachment of filtered) {
874
+ const targetPath = uniquePathFor(attachmentsDir, attachment.title);
875
+ // Pass the full attachment object so downloadAttachment can use downloadLink directly
876
+ const dataStream = await client.downloadAttachment(pageId, attachment);
877
+ await writeStream(dataStream, targetPath);
878
+ downloaded += 1;
879
+ console.log(`⬇️ ${chalk.green(attachment.title)} -> ${chalk.gray(targetPath)}`);
880
+ }
881
+
882
+ console.log(chalk.green(`Downloaded ${downloaded} attachment${downloaded === 1 ? '' : 's'} to ${attachmentsDir}`));
883
+ }
884
+ }
885
+
886
+ analytics.track('export', true);
887
+ } catch (error) {
888
+ analytics.track('export', false);
889
+ console.error(chalk.red('Error:'), error.message);
890
+ process.exit(1);
891
+ }
892
+ });
893
+
894
+ function sanitizeTitle(value) {
895
+ const fallback = 'page';
896
+ if (!value || typeof value !== 'string') {
897
+ return fallback;
898
+ }
899
+ const cleaned = value.replace(/[\\/:*?"<>|]/g, ' ').trim();
900
+ return cleaned || fallback;
901
+ }
902
+
903
+ function parseLocationOptions(raw) {
904
+ if (!raw) {
905
+ return [];
906
+ }
907
+ if (Array.isArray(raw)) {
908
+ return raw.flatMap(item => String(item).split(','))
909
+ .map(value => value.trim().toLowerCase())
910
+ .filter(Boolean);
911
+ }
912
+ return String(raw).split(',').map(value => value.trim().toLowerCase()).filter(Boolean);
913
+ }
914
+
915
+ function formatBodyBlock(text, indent = '') {
916
+ return text.split('\n').map(line => `${indent}${chalk.white(line)}`).join('\n');
917
+ }
918
+
919
+ function buildCommentTree(comments) {
920
+ const nodes = comments.map((comment, index) => ({
921
+ ...comment,
922
+ _order: index,
923
+ children: []
924
+ }));
925
+ const byId = new Map(nodes.map(node => [String(node.id), node]));
926
+ const roots = [];
927
+
928
+ nodes.forEach((node) => {
929
+ const parentId = node.parentId ? String(node.parentId) : null;
930
+ if (parentId && byId.has(parentId)) {
931
+ byId.get(parentId).children.push(node);
932
+ } else {
933
+ roots.push(node);
934
+ }
935
+ });
936
+
937
+ const sortNodes = (list) => {
938
+ list.sort((a, b) => a._order - b._order);
939
+ list.forEach((child) => sortNodes(child.children));
940
+ };
941
+
942
+ sortNodes(roots);
943
+ return roots;
944
+ }
945
+
946
+ // Copy page tree command
947
+ program
948
+ .command('copy-tree <sourcePageId> <targetParentId> [newTitle]')
949
+ .description('Copy a page and all its children to a new location')
950
+ .option('--max-depth <depth>', 'Maximum depth to copy (default: 10)', '10')
951
+ .option('--exclude <patterns>', 'Comma-separated patterns to exclude (supports wildcards)')
952
+ .option('--delay-ms <ms>', 'Delay between sibling creations in ms (default: 100)', '100')
953
+ .option('--copy-suffix <suffix>', 'Suffix for new root title (default: " (Copy)")', ' (Copy)')
954
+ .option('-n, --dry-run', 'Preview operations without creating pages')
955
+ .option('--fail-on-error', 'Exit with non-zero code if any page fails')
956
+ .option('-q, --quiet', 'Suppress progress output')
957
+ .action(async (sourcePageId, targetParentId, newTitle, options) => {
958
+ const analytics = new Analytics();
959
+ try {
960
+ const config = getConfig();
961
+ const client = new ConfluenceClient(config);
962
+
963
+ // Parse numeric flags with safe fallbacks
964
+ const parsedDepth = parseInt(options.maxDepth, 10);
965
+ const maxDepth = Number.isNaN(parsedDepth) ? 10 : parsedDepth;
966
+ const parsedDelay = parseInt(options.delayMs, 10);
967
+ const delayMs = Number.isNaN(parsedDelay) ? 100 : parsedDelay;
968
+ const copySuffix = options.copySuffix ?? ' (Copy)';
969
+
970
+ console.log(chalk.blue('🚀 Starting page tree copy...'));
971
+ console.log(`Source: ${sourcePageId}`);
972
+ console.log(`Target parent: ${targetParentId}`);
973
+ if (newTitle) console.log(`New root title: ${newTitle}`);
974
+ console.log(`Max depth: ${maxDepth}`);
975
+ console.log(`Delay: ${delayMs} ms`);
976
+ if (copySuffix) console.log(`Root suffix: ${copySuffix}`);
977
+ console.log('');
978
+
979
+ // Parse exclude patterns
980
+ let excludePatterns = [];
981
+ if (options.exclude) {
982
+ excludePatterns = options.exclude.split(',').map(p => p.trim()).filter(Boolean);
983
+ if (excludePatterns.length > 0) {
984
+ console.log(chalk.yellow(`Exclude patterns: ${excludePatterns.join(', ')}`));
985
+ }
986
+ }
987
+
988
+ // Progress callback
989
+ const onProgress = (message) => {
990
+ console.log(message);
991
+ };
992
+
993
+ // Dry-run: compute plan without creating anything
994
+ if (options.dryRun) {
995
+ const info = await client.getPageInfo(sourcePageId);
996
+ const rootTitle = newTitle || `${info.title}${copySuffix}`;
997
+ const descendants = await client.getAllDescendantPages(sourcePageId, maxDepth);
998
+ const filtered = descendants.filter(p => !client.shouldExcludePage(p.title, excludePatterns));
999
+ console.log(chalk.yellow('Dry run: no changes will be made.'));
1000
+ console.log(`Would create root: ${chalk.blue(rootTitle)} (under parent ${targetParentId})`);
1001
+ console.log(`Would create ${filtered.length} child page(s)`);
1002
+ // Show a preview list (first 50)
1003
+ const tree = client.buildPageTree(filtered, sourcePageId);
1004
+ const lines = [];
1005
+ const walk = (nodes, depth = 0) => {
1006
+ for (const n of nodes) {
1007
+ if (lines.length >= 50) return; // limit output
1008
+ lines.push(`${' '.repeat(depth)}- ${n.title}`);
1009
+ if (n.children && n.children.length) walk(n.children, depth + 1);
1010
+ }
1011
+ };
1012
+ walk(tree);
1013
+ if (lines.length) {
1014
+ console.log('Planned children:');
1015
+ lines.forEach(l => console.log(l));
1016
+ if (filtered.length > lines.length) {
1017
+ console.log(`...and ${filtered.length - lines.length} more`);
1018
+ }
1019
+ }
1020
+ analytics.track('copy_tree_dry_run', true);
1021
+ return;
1022
+ }
1023
+
1024
+ // Copy the page tree
1025
+ const result = await client.copyPageTree(
1026
+ sourcePageId,
1027
+ targetParentId,
1028
+ newTitle,
1029
+ {
1030
+ maxDepth,
1031
+ excludePatterns,
1032
+ onProgress: options.quiet ? null : onProgress,
1033
+ quiet: options.quiet,
1034
+ delayMs,
1035
+ copySuffix
1036
+ }
1037
+ );
1038
+
1039
+ console.log('');
1040
+ console.log(chalk.green('✅ Page tree copy completed'));
1041
+ console.log(`Root page: ${chalk.blue(result.rootPage.title)} (ID: ${result.rootPage.id})`);
1042
+ console.log(`Total copied pages: ${chalk.blue(result.totalCopied)}`);
1043
+ if (result.failures?.length) {
1044
+ console.log(chalk.yellow(`Failures: ${result.failures.length}`));
1045
+ result.failures.slice(0, 10).forEach(f => {
1046
+ const reason = f.status ? `${f.status}` : '';
1047
+ console.log(` - ${f.title} (ID: ${f.id})${reason ? `: ${reason}` : ''}`);
1048
+ });
1049
+ if (result.failures.length > 10) {
1050
+ console.log(` - ...and ${result.failures.length - 10} more`);
1051
+ }
1052
+ }
1053
+ console.log(`URL: ${chalk.gray(`https://${config.domain}/wiki${result.rootPage._links.webui}`)}`);
1054
+ if (options.failOnError && result.failures?.length) {
1055
+ analytics.track('copy_tree', false);
1056
+ console.error(chalk.red('Completed with failures and --fail-on-error is set.'));
1057
+ process.exit(1);
1058
+ }
1059
+
1060
+ analytics.track('copy_tree', true);
1061
+ } catch (error) {
1062
+ analytics.track('copy_tree', false);
1063
+ console.error(chalk.red('Error:'), error.message);
1064
+ process.exit(1);
1065
+ }
1066
+ });
1067
+
1068
+ // List children command
1069
+ program
1070
+ .command('children <pageId>')
1071
+ .description('List child pages of a Confluence page')
1072
+ .option('-r, --recursive', 'List all descendants recursively', false)
1073
+ .option('--max-depth <number>', 'Maximum depth for recursive listing', '10')
1074
+ .option('--format <format>', 'Output format (list, tree, json)', 'list')
1075
+ .option('--show-url', 'Show page URLs', false)
1076
+ .option('--show-id', 'Show page IDs', false)
1077
+ .action(async (pageId, options) => {
1078
+ const analytics = new Analytics();
1079
+ try {
1080
+ const config = getConfig();
1081
+ const client = new ConfluenceClient(config);
1082
+
1083
+ // Extract page ID from URL if needed
1084
+ const resolvedPageId = await client.extractPageId(pageId);
1085
+
1086
+ // Get children
1087
+ let children;
1088
+ if (options.recursive) {
1089
+ const maxDepth = parseInt(options.maxDepth) || 10;
1090
+ children = await client.getAllDescendantPages(resolvedPageId, maxDepth);
1091
+ } else {
1092
+ children = await client.getChildPages(resolvedPageId);
1093
+ }
1094
+
1095
+ if (children.length === 0) {
1096
+ console.log(chalk.yellow('No child pages found.'));
1097
+ analytics.track('children', true);
1098
+ return;
1099
+ }
1100
+
1101
+ // Format output
1102
+ const format = options.format.toLowerCase();
1103
+
1104
+ if (format === 'json') {
1105
+ // JSON output
1106
+ const output = {
1107
+ pageId: resolvedPageId,
1108
+ childCount: children.length,
1109
+ children: children.map(page => ({
1110
+ id: page.id,
1111
+ title: page.title,
1112
+ type: page.type,
1113
+ status: page.status,
1114
+ spaceKey: page.space?.key,
1115
+ url: `https://${config.domain}/wiki/spaces/${page.space?.key}/pages/${page.id}`,
1116
+ parentId: page.parentId || resolvedPageId
1117
+ }))
1118
+ };
1119
+ console.log(JSON.stringify(output, null, 2));
1120
+ } else if (format === 'tree' && options.recursive) {
1121
+ // Tree format (only for recursive mode)
1122
+ const pageInfo = await client.getPageInfo(resolvedPageId);
1123
+ console.log(chalk.blue(`📁 ${pageInfo.title}`));
1124
+
1125
+ // Build tree structure
1126
+ const tree = buildTree(children, resolvedPageId);
1127
+ printTree(tree, config, options, 1);
1128
+
1129
+ console.log('');
1130
+ console.log(chalk.gray(`Total: ${children.length} child page${children.length === 1 ? '' : 's'}`));
1131
+ } else {
1132
+ // List format (default)
1133
+ console.log(chalk.blue('Child pages:'));
1134
+ console.log('');
1135
+
1136
+ children.forEach((page, index) => {
1137
+ let output = `${index + 1}. ${chalk.green(page.title)}`;
1138
+
1139
+ if (options.showId) {
1140
+ output += ` ${chalk.gray(`(ID: ${page.id})`)}`;
1141
+ }
1142
+
1143
+ if (options.showUrl) {
1144
+ const url = `https://${config.domain}/wiki/spaces/${page.space?.key}/pages/${page.id}`;
1145
+ output += `\n ${chalk.gray(url)}`;
1146
+ }
1147
+
1148
+ if (options.recursive && page.parentId && page.parentId !== resolvedPageId) {
1149
+ output += ` ${chalk.dim('(nested)')}`;
1150
+ }
1151
+
1152
+ console.log(output);
1153
+ });
1154
+
1155
+ console.log('');
1156
+ console.log(chalk.gray(`Total: ${children.length} child page${children.length === 1 ? '' : 's'}`));
1157
+ }
1158
+
1159
+ analytics.track('children', true);
1160
+ } catch (error) {
1161
+ analytics.track('children', false);
1162
+ console.error(chalk.red('Error:'), error.message);
1163
+ process.exit(1);
1164
+ }
1165
+ });
1166
+
1167
+ // Helper function to build tree structure
1168
+ function buildTree(pages, rootId) {
1169
+ const tree = [];
1170
+ const pageMap = new Map();
1171
+
1172
+ // Create a map of all pages
1173
+ pages.forEach(page => {
1174
+ pageMap.set(page.id, { ...page, children: [] });
1175
+ });
1176
+
1177
+ // Build tree structure
1178
+ pages.forEach(page => {
1179
+ const node = pageMap.get(page.id);
1180
+ const parentId = page.parentId || rootId;
1181
+
1182
+ if (parentId === rootId) {
1183
+ tree.push(node);
1184
+ } else {
1185
+ const parent = pageMap.get(parentId);
1186
+ if (parent) {
1187
+ parent.children.push(node);
1188
+ }
1189
+ }
1190
+ });
1191
+
1192
+ return tree;
1193
+ }
1194
+
1195
+ // Helper function to print tree
1196
+ function printTree(nodes, config, options, depth = 1) {
1197
+ nodes.forEach((node, index) => {
1198
+ const isLast = index === nodes.length - 1;
1199
+ const indent = ' '.repeat(depth - 1);
1200
+ const prefix = isLast ? '└── ' : '├── ';
1201
+
1202
+ let output = `${indent}${prefix}📄 ${chalk.green(node.title)}`;
1203
+
1204
+ if (options.showId) {
1205
+ output += ` ${chalk.gray(`(ID: ${node.id})`)}`;
1206
+ }
1207
+
1208
+ if (options.showUrl) {
1209
+ const url = `https://${config.domain}/wiki/spaces/${node.space?.key}/pages/${node.id}`;
1210
+ output += `\n${indent}${isLast ? ' ' : '│ '}${chalk.gray(url)}`;
1211
+ }
1212
+
1213
+ console.log(output);
1214
+
1215
+ if (node.children && node.children.length > 0) {
1216
+ printTree(node.children, config, options, depth + 1);
1217
+ }
1218
+ });
1219
+ }
1220
+
1221
+ if (process.argv.length <= 2) {
1222
+ program.help({ error: false });
1223
+ }
1224
+
1225
+ program.parse(process.argv);