@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,62 @@
1
+ import chalk from 'chalk';
2
+ import { ConfluenceClient } from '../../lib/confluence-client/index.js';
3
+ import { ConfigManager } from '../../lib/config.js';
4
+ import { EXIT_CODES } from '../../lib/errors.js';
5
+ import { escapeXml } from '../../lib/formatters.js';
6
+
7
+ export interface SearchCommandOptions {
8
+ space?: string;
9
+ limit?: number;
10
+ xml?: boolean;
11
+ }
12
+
13
+ export async function searchCommand(query: string, options: SearchCommandOptions = {}): Promise<void> {
14
+ const configManager = new ConfigManager();
15
+ const config = await configManager.getConfig();
16
+
17
+ if (!config) {
18
+ console.error(chalk.red('Not configured. Run: cn setup'));
19
+ process.exit(EXIT_CODES.CONFIG_ERROR);
20
+ }
21
+
22
+ let cql = `type=page AND text~"${query.replace(/"/g, '\\"')}"`;
23
+ if (options.space) {
24
+ cql += ` AND space="${options.space.replace(/"/g, '\\"')}"`;
25
+ }
26
+
27
+ const client = new ConfluenceClient(config);
28
+ const response = await client.search(cql, options.limit ?? 10);
29
+
30
+ if (options.xml) {
31
+ console.log('<results>');
32
+ for (const item of response.results) {
33
+ const content = item.content;
34
+ const webui = content?._links?.webui;
35
+ const id = content?.id ?? item.id ?? '';
36
+ const title = content?.title ?? item.title ?? '';
37
+ console.log(` <result id="${escapeXml(id)}">`);
38
+ console.log(` <title>${escapeXml(title)}</title>`);
39
+ if (item.excerpt) console.log(` <excerpt>${escapeXml(item.excerpt)}</excerpt>`);
40
+ if (webui) console.log(` <url>${escapeXml(`${config.confluenceUrl}/wiki${webui}`)}</url>`);
41
+ console.log(' </result>');
42
+ }
43
+ console.log('</results>');
44
+ return;
45
+ }
46
+
47
+ if (response.results.length === 0) {
48
+ console.log('No results found.');
49
+ return;
50
+ }
51
+
52
+ for (const item of response.results) {
53
+ const content = item.content;
54
+ const title = content?.title ?? item.title ?? '(untitled)';
55
+ const id = content?.id ?? item.id ?? '';
56
+ const webui = content?._links?.webui;
57
+ const url = webui ? `${config.confluenceUrl}/wiki${webui}` : '';
58
+ console.log(`${chalk.bold(title)} ${chalk.gray(id)}`);
59
+ if (item.excerpt) console.log(` ${chalk.gray(item.excerpt)}`);
60
+ if (url) console.log(` ${chalk.blue(url)}`);
61
+ }
62
+ }
@@ -0,0 +1,124 @@
1
+ import { input, password } from '@inquirer/prompts';
2
+ import chalk from 'chalk';
3
+ import ora from 'ora';
4
+ import { ConfluenceClient } from '../../lib/confluence-client/index.js';
5
+ import { ConfigManager, type Config } from '../../lib/config.js';
6
+ import { EXIT_CODES } from '../../lib/errors.js';
7
+
8
+ /**
9
+ * Interactive setup command for configuring Confluence credentials
10
+ */
11
+ export async function setup(): Promise<void> {
12
+ console.log(chalk.bold('\nConfluence CLI Setup\n'));
13
+ console.log(chalk.gray('This wizard will help you configure your Confluence credentials.'));
14
+ console.log(chalk.gray('You can create an API token at: https://id.atlassian.com/manage/api-tokens\n'));
15
+
16
+ const configManager = new ConfigManager();
17
+
18
+ // Check for existing config
19
+ const existingConfig = await configManager.getConfig();
20
+ if (existingConfig) {
21
+ console.log(chalk.yellow('Existing configuration found:'));
22
+ console.log(` URL: ${existingConfig.confluenceUrl}`);
23
+ console.log(` Email: ${existingConfig.email}`);
24
+ console.log('');
25
+ }
26
+
27
+ // Get Confluence URL
28
+ let confluenceUrl: string;
29
+ while (true) {
30
+ confluenceUrl = await input({
31
+ message: 'Confluence URL:',
32
+ default: existingConfig?.confluenceUrl,
33
+ validate: (value) => {
34
+ if (!value) return 'URL is required';
35
+ if (!ConfigManager.validateUrl(value)) {
36
+ return 'URL must be a Confluence Cloud URL (https://*.atlassian.net)';
37
+ }
38
+ return true;
39
+ },
40
+ });
41
+
42
+ // Normalize URL (remove trailing slashes)
43
+ confluenceUrl = confluenceUrl.replace(/\/+$/, '');
44
+ break;
45
+ }
46
+
47
+ // Get email
48
+ let email: string;
49
+ while (true) {
50
+ email = await input({
51
+ message: 'Email:',
52
+ default: existingConfig?.email,
53
+ validate: (value) => {
54
+ if (!value) return 'Email is required';
55
+ if (!ConfigManager.validateEmail(value)) {
56
+ return 'Invalid email format';
57
+ }
58
+ return true;
59
+ },
60
+ });
61
+ break;
62
+ }
63
+
64
+ // Get API token
65
+ const apiToken = await password({
66
+ message: 'API Token:',
67
+ mask: '*',
68
+ validate: (value) => {
69
+ if (!value) return 'API token is required';
70
+ return true;
71
+ },
72
+ });
73
+
74
+ // Create config object
75
+ const config: Config = {
76
+ confluenceUrl,
77
+ email,
78
+ apiToken,
79
+ };
80
+
81
+ // Verify connection
82
+ const spinner = ora('Verifying connection...').start();
83
+
84
+ try {
85
+ const client = new ConfluenceClient(config);
86
+ await client.verifyConnection();
87
+ spinner.succeed('Connected to Confluence successfully!');
88
+ } catch (error) {
89
+ spinner.fail('Connection failed');
90
+
91
+ if (error instanceof Error) {
92
+ if (error.message.includes('401') || error.message.includes('Invalid credentials')) {
93
+ console.error(chalk.red('\nAuthentication failed. Please check your email and API token.'));
94
+ console.log(chalk.gray('Make sure you are using an API token, not your account password.'));
95
+ console.log(chalk.gray('Create a token at: https://id.atlassian.com/manage/api-tokens'));
96
+ } else if (error.message.includes('403') || error.message.includes('Access denied')) {
97
+ console.error(chalk.red('\nPermission denied. Your account may not have access to Confluence.'));
98
+ } else if (error.message.includes('Network error') || error.message.includes('ENOTFOUND')) {
99
+ console.error(chalk.red('\nCould not connect to the Confluence URL.'));
100
+ console.log(chalk.gray('Please verify the URL is correct and accessible.'));
101
+ } else {
102
+ console.error(chalk.red(`\nError: ${error.message}`));
103
+ }
104
+ }
105
+
106
+ process.exit(EXIT_CODES.AUTH_ERROR);
107
+ }
108
+
109
+ // Save configuration
110
+ const saveSpinner = ora('Saving configuration...').start();
111
+
112
+ try {
113
+ await configManager.setConfig(config);
114
+ saveSpinner.succeed(`Configuration saved to ${configManager.getConfigPath()}`);
115
+ } catch (error) {
116
+ saveSpinner.fail('Failed to save configuration');
117
+ console.error(chalk.red(error instanceof Error ? error.message : 'Unknown error'));
118
+ process.exit(EXIT_CODES.CONFIG_ERROR);
119
+ }
120
+
121
+ console.log('');
122
+ console.log(chalk.green('Setup complete!'));
123
+ console.log(chalk.gray('You can now use "cn sync --init <SPACE_KEY>" to sync a Confluence space.'));
124
+ }
@@ -0,0 +1,42 @@
1
+ import chalk from 'chalk';
2
+ import { ConfluenceClient } from '../../lib/confluence-client/index.js';
3
+ import { ConfigManager } from '../../lib/config.js';
4
+ import { EXIT_CODES } from '../../lib/errors.js';
5
+ import { escapeXml } from '../../lib/formatters.js';
6
+
7
+ export interface SpacesCommandOptions {
8
+ xml?: boolean;
9
+ }
10
+
11
+ export async function spacesCommand(options: SpacesCommandOptions = {}): Promise<void> {
12
+ const configManager = new ConfigManager();
13
+ const config = await configManager.getConfig();
14
+
15
+ if (!config) {
16
+ console.error(chalk.red('Not configured. Run: cn setup'));
17
+ process.exit(EXIT_CODES.CONFIG_ERROR);
18
+ }
19
+
20
+ const client = new ConfluenceClient(config);
21
+ const spaces = await client.getAllSpaces();
22
+
23
+ if (options.xml) {
24
+ console.log('<spaces>');
25
+ for (const space of spaces) {
26
+ console.log(
27
+ ` <space id="${escapeXml(space.id)}" key="${escapeXml(space.key)}">${escapeXml(space.name)}</space>`,
28
+ );
29
+ }
30
+ console.log('</spaces>');
31
+ return;
32
+ }
33
+
34
+ if (spaces.length === 0) {
35
+ console.log('No spaces found.');
36
+ return;
37
+ }
38
+
39
+ for (const space of spaces) {
40
+ console.log(`${chalk.bold(space.key)} ${space.name} ${chalk.gray(space.id)}`);
41
+ }
42
+ }
@@ -0,0 +1,88 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { ConfluenceClient } from '../../lib/confluence-client/index.js';
4
+ import { ConfigManager } from '../../lib/config.js';
5
+ import { EXIT_CODES } from '../../lib/errors.js';
6
+ import { getFormatter, type StatusInfo } from '../../lib/formatters.js';
7
+ import { buildPageStateFromFiles } from '../../lib/page-state.js';
8
+ import { readSpaceConfig } from '../../lib/space-config.js';
9
+ import { SyncEngine } from '../../lib/sync/index.js';
10
+
11
+ export interface StatusCommandOptions {
12
+ xml?: boolean;
13
+ }
14
+
15
+ /**
16
+ * Status command - shows connection and sync status
17
+ */
18
+ export async function statusCommand(options: StatusCommandOptions = {}): Promise<void> {
19
+ const configManager = new ConfigManager();
20
+ const config = await configManager.getConfig();
21
+ const formatter = getFormatter(options.xml || false);
22
+
23
+ const status: StatusInfo = {
24
+ configured: !!config,
25
+ connected: false,
26
+ initialized: false,
27
+ };
28
+
29
+ // If not configured, show message and exit
30
+ if (!config) {
31
+ console.log(formatter.formatStatus(status));
32
+ process.exit(EXIT_CODES.CONFIG_ERROR);
33
+ }
34
+
35
+ status.confluenceUrl = config.confluenceUrl;
36
+ status.email = config.email;
37
+
38
+ // Check connection
39
+ const spinner = options.xml ? null : ora('Checking connection...').start();
40
+
41
+ try {
42
+ const client = new ConfluenceClient(config);
43
+ await client.verifyConnection();
44
+ status.connected = true;
45
+ spinner?.succeed('Connected to Confluence');
46
+ } catch (_error) {
47
+ status.connected = false;
48
+ spinner?.fail('Not connected');
49
+ }
50
+
51
+ // Check space configuration
52
+ const directory = process.cwd();
53
+ const spaceConfig = readSpaceConfig(directory);
54
+
55
+ if (spaceConfig) {
56
+ status.initialized = true;
57
+ status.spaceKey = spaceConfig.spaceKey;
58
+ status.spaceName = spaceConfig.spaceName;
59
+ status.lastSync = spaceConfig.lastSync;
60
+ status.pageCount = Object.keys(spaceConfig.pages).length;
61
+
62
+ // Check for pending changes if connected
63
+ if (status.connected) {
64
+ try {
65
+ const syncEngine = new SyncEngine(config);
66
+ const remotePages = await syncEngine.fetchPageTree(spaceConfig.spaceId);
67
+ // Per ADR-0024: Build PageStateCache for version comparison from frontmatter
68
+ const pageState = buildPageStateFromFiles(directory, spaceConfig.pages);
69
+ const diff = syncEngine.computeDiff(remotePages, spaceConfig, pageState);
70
+
71
+ status.pendingChanges = {
72
+ added: diff.added.length,
73
+ modified: diff.modified.length,
74
+ deleted: diff.deleted.length,
75
+ };
76
+ } catch (_error) {
77
+ // Ignore errors when checking pending changes
78
+ }
79
+ }
80
+ }
81
+
82
+ // Clear spinner if it's still running
83
+ spinner?.stop();
84
+
85
+ // Output status
86
+ console.log('');
87
+ console.log(formatter.formatStatus(status));
88
+ }
@@ -0,0 +1,190 @@
1
+ import chalk from 'chalk';
2
+ import ora from 'ora';
3
+ import { ConfluenceClient, type Page, type PageTreeNode } from '../../lib/confluence-client/index.js';
4
+ import { ConfigManager } from '../../lib/config.js';
5
+ import { EXIT_CODES } from '../../lib/errors.js';
6
+ import { getFormatter, type TreeNode } from '../../lib/formatters.js';
7
+ import { readSpaceConfig } from '../../lib/space-config.js';
8
+ import { SyncEngine } from '../../lib/sync/index.js';
9
+
10
+ export interface TreeCommandOptions {
11
+ spaceKey?: string;
12
+ remote?: boolean;
13
+ depth?: number;
14
+ xml?: boolean;
15
+ }
16
+
17
+ /**
18
+ * Convert PageTreeNode to TreeNode format for formatter
19
+ */
20
+ function convertToTreeNode(node: PageTreeNode, maxDepth?: number, currentDepth = 0): TreeNode {
21
+ const shouldIncludeChildren = maxDepth === undefined || currentDepth < maxDepth;
22
+
23
+ return {
24
+ id: node.page.id,
25
+ title: node.page.title,
26
+ children: shouldIncludeChildren
27
+ ? node.children.map((child) => convertToTreeNode(child, maxDepth, currentDepth + 1))
28
+ : [],
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Build tree from flat page list
34
+ */
35
+ function buildTree(pages: Page[]): PageTreeNode[] {
36
+ const pageMap = new Map<string, PageTreeNode>();
37
+ const roots: PageTreeNode[] = [];
38
+
39
+ // Create nodes for all pages
40
+ for (const page of pages) {
41
+ pageMap.set(page.id, { page, children: [] });
42
+ }
43
+
44
+ // Build tree structure
45
+ for (const page of pages) {
46
+ const node = pageMap.get(page.id);
47
+ if (!node) continue;
48
+ if (page.parentId && pageMap.has(page.parentId)) {
49
+ pageMap.get(page.parentId)?.children.push(node);
50
+ } else {
51
+ roots.push(node);
52
+ }
53
+ }
54
+
55
+ // Sort children alphabetically
56
+ const sortChildren = (nodes: PageTreeNode[]): void => {
57
+ nodes.sort((a, b) => a.page.title.localeCompare(b.page.title));
58
+ for (const node of nodes) {
59
+ sortChildren(node.children);
60
+ }
61
+ };
62
+ sortChildren(roots);
63
+
64
+ return roots;
65
+ }
66
+
67
+ /**
68
+ * Tree command - displays page hierarchy
69
+ */
70
+ export async function treeCommand(options: TreeCommandOptions = {}): Promise<void> {
71
+ const configManager = new ConfigManager();
72
+ const config = await configManager.getConfig();
73
+ const formatter = getFormatter(options.xml || false);
74
+
75
+ if (!config) {
76
+ console.error(chalk.red('Not configured. Please run "cn setup" first.'));
77
+ process.exit(EXIT_CODES.CONFIG_ERROR);
78
+ }
79
+
80
+ const directory = process.cwd();
81
+ let spaceId: string | undefined;
82
+ let spaceKey: string | undefined;
83
+
84
+ // Determine space to use
85
+ if (options.spaceKey) {
86
+ // Get space by key
87
+ const spinner = options.xml ? null : ora(`Fetching space ${options.spaceKey}...`).start();
88
+
89
+ try {
90
+ const client = new ConfluenceClient(config);
91
+ const space = await client.getSpaceByKey(options.spaceKey);
92
+ spaceId = space.id;
93
+ spaceKey = space.key;
94
+ spinner?.succeed(`Found space: ${space.name}`);
95
+ } catch (_error) {
96
+ spinner?.fail(`Space "${options.spaceKey}" not found`);
97
+ process.exit(EXIT_CODES.SPACE_NOT_FOUND);
98
+ }
99
+ } else {
100
+ // Use space from current directory
101
+ const spaceConfig = readSpaceConfig(directory);
102
+ if (!spaceConfig) {
103
+ console.error(chalk.red('No space configured in this directory.'));
104
+ console.log(chalk.gray('Specify a space key or run "cn sync --init <SPACE_KEY>" first.'));
105
+ process.exit(EXIT_CODES.CONFIG_ERROR);
106
+ }
107
+ spaceId = spaceConfig.spaceId;
108
+ spaceKey = spaceConfig.spaceKey;
109
+ }
110
+
111
+ // Fetch pages
112
+ const spinner = options.xml ? null : ora('Fetching page tree...').start();
113
+
114
+ try {
115
+ let pages: Page[];
116
+
117
+ if (options.remote !== false) {
118
+ // Fetch from API
119
+ if (!spaceId) {
120
+ spinner?.fail('Space ID not available');
121
+ process.exit(EXIT_CODES.CONFIG_ERROR);
122
+ }
123
+ const syncEngine = new SyncEngine(config);
124
+ pages = await syncEngine.fetchPageTree(spaceId);
125
+ } else {
126
+ // Use local cache
127
+ const spaceConfig = readSpaceConfig(directory);
128
+ if (!spaceConfig) {
129
+ spinner?.fail('No local sync state found. Use --remote to fetch from API.');
130
+ process.exit(EXIT_CODES.CONFIG_ERROR);
131
+ }
132
+
133
+ // Build pages from sync state, inferring hierarchy from file paths
134
+ // Per ADR-0024: pages is now Record<string, string> (pageId -> localPath)
135
+ // Create a map of directory paths to page IDs for parent lookup
136
+ // Note: sync-engine uses README.md for directory index files (per ADR-0005)
137
+ const pathToPageId = new Map<string, string>();
138
+ for (const [pageId, localPath] of Object.entries(spaceConfig.pages)) {
139
+ // For README.md files (directory index), map the parent directory
140
+ if (localPath.endsWith('/README.md') || localPath === 'README.md') {
141
+ const dir = localPath.replace('/README.md', '').replace('README.md', '');
142
+ pathToPageId.set(dir, pageId);
143
+ }
144
+ }
145
+
146
+ pages = Object.entries(spaceConfig.pages).map(([pageId, localPath]) => {
147
+ // Extract title from filename
148
+ const filename = localPath.split('/').pop() || '';
149
+ const title = filename.replace('.md', '').replace(/^README$/i, '') || pageId;
150
+
151
+ // Infer parent from path structure
152
+ const pathParts = localPath.split('/');
153
+ let parentId: string | null = null;
154
+
155
+ if (pathParts.length > 1) {
156
+ // For regular files, parent is the directory's README.md
157
+ // For README.md, parent is the grandparent directory's README.md
158
+ const isReadme = localPath.endsWith('/README.md');
159
+ const parentDir = isReadme ? pathParts.slice(0, -2).join('/') : pathParts.slice(0, -1).join('/');
160
+
161
+ if (parentDir) {
162
+ parentId = pathToPageId.get(parentDir) || null;
163
+ }
164
+ }
165
+
166
+ return {
167
+ id: pageId,
168
+ title: title || localPath,
169
+ spaceId: spaceConfig.spaceId,
170
+ parentId,
171
+ };
172
+ });
173
+ }
174
+
175
+ spinner?.succeed(`Found ${pages.length} pages`);
176
+
177
+ // Build tree
178
+ const tree = buildTree(pages);
179
+ const treeNodes = tree.map((node) => convertToTreeNode(node, options.depth));
180
+
181
+ // Output tree
182
+ console.log('');
183
+ console.log(options.xml ? '' : chalk.bold(`${spaceKey}:`));
184
+ console.log(formatter.formatTree(treeNodes));
185
+ } catch (error) {
186
+ spinner?.fail('Failed to fetch page tree');
187
+ console.error(chalk.red(error instanceof Error ? error.message : 'Unknown error'));
188
+ process.exit(EXIT_CODES.GENERAL_ERROR);
189
+ }
190
+ }