@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,161 @@
1
+ /**
2
+ * cn doctor - Health check command for detecting issues in synced spaces
3
+ */
4
+
5
+ import { confirm } from '@inquirer/prompts';
6
+ import chalk from 'chalk';
7
+ import { unlinkSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import {
10
+ findBestDuplicate,
11
+ findStaleDuplicates,
12
+ runHealthCheck,
13
+ type DuplicatePageId,
14
+ } from '../../lib/health-check.js';
15
+ import { hasSpaceConfig, readSpaceConfig } from '../../lib/space-config.js';
16
+
17
+ export interface DoctorOptions {
18
+ fix?: boolean;
19
+ xml?: boolean;
20
+ }
21
+
22
+ function formatDate(dateStr?: string): string {
23
+ if (!dateStr) return 'never';
24
+ const date = new Date(dateStr);
25
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
26
+ }
27
+
28
+ /**
29
+ * Escape special XML characters for attribute values
30
+ */
31
+ function escapeXmlAttr(str: string): string {
32
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
33
+ }
34
+
35
+ function printDuplicateInfo(duplicate: DuplicatePageId): void {
36
+ const best = findBestDuplicate(duplicate.files);
37
+ const stale = findStaleDuplicates(duplicate);
38
+
39
+ console.log(chalk.yellow(`\n Duplicate page_id: ${duplicate.pageId}`));
40
+
41
+ for (const file of duplicate.files) {
42
+ const isBest = file.path === best.path;
43
+ const marker = isBest ? chalk.green(' (keep)') : chalk.red(' (stale)');
44
+ const version = file.version ? `v${file.version}` : 'v?';
45
+ const synced = formatDate(file.syncedAt);
46
+
47
+ console.log(` ${isBest ? chalk.green('*') : chalk.red('x')} ${file.path}${marker}`);
48
+ console.log(chalk.gray(` ${version}, synced ${synced}`));
49
+ }
50
+
51
+ if (stale.length > 0) {
52
+ console.log(chalk.gray(` Recommendation: Delete ${stale.map((f) => f.path).join(', ')}`));
53
+ }
54
+ }
55
+
56
+ function printXmlOutput(result: ReturnType<typeof runHealthCheck>): void {
57
+ console.log('<health-check>');
58
+ console.log(
59
+ ` <summary files="${result.files.length}" tracked="${result.trackedFiles.length}" new="${result.newFiles.length}" duplicates="${result.duplicates.length}" />`,
60
+ );
61
+
62
+ if (result.duplicates.length > 0) {
63
+ console.log(' <duplicates>');
64
+ for (const dup of result.duplicates) {
65
+ const best = findBestDuplicate(dup.files);
66
+ console.log(` <duplicate page-id="${escapeXmlAttr(dup.pageId)}">`);
67
+ for (const file of dup.files) {
68
+ const isBest = file.path === best.path;
69
+ console.log(
70
+ ` <file path="${escapeXmlAttr(file.path)}" version="${file.version || '?'}" synced="${file.syncedAt || 'never'}" status="${isBest ? 'keep' : 'stale'}" />`,
71
+ );
72
+ }
73
+ console.log(' </duplicate>');
74
+ }
75
+ console.log(' </duplicates>');
76
+ }
77
+
78
+ console.log('</health-check>');
79
+ }
80
+
81
+ export async function doctorCommand(options: DoctorOptions = {}): Promise<void> {
82
+ const directory = process.cwd();
83
+
84
+ // Check for space config
85
+ if (!hasSpaceConfig(directory)) {
86
+ console.log(chalk.yellow('Not in a Confluence-synced directory.'));
87
+ console.log(chalk.gray('Run "cn clone <SPACE_KEY>" to initialize a space.'));
88
+ return;
89
+ }
90
+
91
+ const config = readSpaceConfig(directory);
92
+ if (!config) {
93
+ console.log(chalk.red('Failed to read space configuration.'));
94
+ return;
95
+ }
96
+
97
+ console.log(chalk.bold(`Running health check for ${config.spaceName || config.spaceKey}...\n`));
98
+
99
+ const result = runHealthCheck(directory);
100
+
101
+ // XML output mode
102
+ if (options.xml) {
103
+ printXmlOutput(result);
104
+ return;
105
+ }
106
+
107
+ // Summary
108
+ console.log(chalk.bold('Summary:'));
109
+ console.log(` Total files: ${result.files.length}`);
110
+ console.log(` Tracked (with page_id): ${result.trackedFiles.length}`);
111
+ console.log(` New (no page_id): ${result.newFiles.length}`);
112
+
113
+ // Check for issues
114
+ let hasIssues = false;
115
+
116
+ // Duplicate page_ids
117
+ if (result.duplicates.length > 0) {
118
+ hasIssues = true;
119
+ console.log(chalk.red(`\n Duplicate page_ids found: ${result.duplicates.length}`));
120
+
121
+ for (const duplicate of result.duplicates) {
122
+ printDuplicateInfo(duplicate);
123
+ }
124
+ }
125
+
126
+ if (!hasIssues) {
127
+ console.log(chalk.green('\n No issues found.'));
128
+ return;
129
+ }
130
+
131
+ // Offer to fix issues
132
+ if (options.fix || result.duplicates.length > 0) {
133
+ console.log('');
134
+
135
+ for (const duplicate of result.duplicates) {
136
+ const stale = findStaleDuplicates(duplicate);
137
+ if (stale.length === 0) continue;
138
+
139
+ for (const file of stale) {
140
+ const shouldDelete =
141
+ options.fix ||
142
+ (await confirm({
143
+ message: `Delete stale file ${file.path}?`,
144
+ default: true,
145
+ }));
146
+
147
+ if (shouldDelete) {
148
+ const fullPath = join(directory, file.path);
149
+ try {
150
+ unlinkSync(fullPath);
151
+ console.log(chalk.green(` Deleted: ${file.path}`));
152
+ } catch (error) {
153
+ console.log(
154
+ chalk.red(` Failed to delete ${file.path}: ${error instanceof Error ? error.message : 'Unknown error'}`),
155
+ );
156
+ }
157
+ }
158
+ }
159
+ }
160
+ }
161
+ }
@@ -0,0 +1,89 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import chalk from 'chalk';
3
+ import {
4
+ checkFileForDuplicates,
5
+ findBestDuplicate,
6
+ findDuplicatePageIds,
7
+ scanFilesForHealthCheck,
8
+ type DuplicatePageId,
9
+ } from '../../lib/health-check.js';
10
+
11
+ /**
12
+ * Display duplicate page_id information
13
+ */
14
+ export function displayDuplicates(duplicates: DuplicatePageId[]): void {
15
+ console.log(chalk.red('Duplicate page_ids detected:'));
16
+ for (const dup of duplicates) {
17
+ const best = findBestDuplicate(dup.files);
18
+ console.log(chalk.yellow(`\n page_id: ${dup.pageId}`));
19
+ for (const file of dup.files) {
20
+ const isBest = file.path === best.path;
21
+ const marker = isBest ? chalk.green(' (keep)') : chalk.red(' (stale)');
22
+ const version = file.version ? `v${file.version}` : 'v?';
23
+ console.log(` ${isBest ? chalk.green('*') : chalk.red('x')} ${file.path}${marker} - ${version}`);
24
+ }
25
+ }
26
+ console.log('');
27
+ }
28
+
29
+ /**
30
+ * Check for duplicate page_ids before push and prompt for confirmation
31
+ * Returns true if push should continue, false if user cancelled
32
+ */
33
+ export async function checkDuplicatesBeforePush(directory: string): Promise<boolean> {
34
+ const allFiles = scanFilesForHealthCheck(directory);
35
+ const duplicates = findDuplicatePageIds(allFiles);
36
+
37
+ if (duplicates.length === 0) {
38
+ return true;
39
+ }
40
+
41
+ displayDuplicates(duplicates);
42
+
43
+ const shouldContinue = await confirm({
44
+ message: 'Continue with push? (Stale files may cause version conflicts)',
45
+ default: false,
46
+ });
47
+
48
+ if (!shouldContinue) {
49
+ console.log(chalk.gray('Run "cn doctor" to fix duplicate page_ids.'));
50
+ return false;
51
+ }
52
+ console.log('');
53
+ return true;
54
+ }
55
+
56
+ /**
57
+ * Display version conflict guidance, checking for duplicates that may explain the conflict
58
+ * This is called when a version mismatch is detected during push
59
+ */
60
+ export function displayVersionConflictGuidance(directory: string, relativePath: string, remoteVersion: number): void {
61
+ const { hasDuplicates, duplicates } = checkFileForDuplicates(directory, relativePath);
62
+
63
+ if (hasDuplicates) {
64
+ const newerDuplicate = duplicates.find((d) => (d.version || 0) >= remoteVersion);
65
+ if (newerDuplicate) {
66
+ console.log(chalk.yellow('Found another file with the same page_id:'));
67
+ console.log(chalk.yellow(` ${newerDuplicate.path} (v${newerDuplicate.version || '?'})`));
68
+ console.log('');
69
+ console.log(chalk.gray('This usually happens when a page was moved on Confluence.'));
70
+ console.log(chalk.gray(`The file at ${relativePath} appears to be stale.`));
71
+ console.log('');
72
+ console.log(chalk.gray('Recommended actions:'));
73
+ console.log(chalk.gray(` 1. Delete the stale file: rm "${relativePath}"`));
74
+ console.log(chalk.gray(` 2. Or run "cn doctor" to fix all duplicates`));
75
+ } else {
76
+ console.log(chalk.yellow('Found duplicate files with the same page_id:'));
77
+ for (const dup of duplicates) {
78
+ console.log(chalk.yellow(` ${dup.path} (v${dup.version || '?'})`));
79
+ }
80
+ console.log('');
81
+ console.log(chalk.gray('Run "cn doctor" to identify and fix duplicates.'));
82
+ }
83
+ } else {
84
+ console.log(chalk.yellow('The page has been modified on Confluence since your last pull.'));
85
+ console.log(chalk.gray('Options:'));
86
+ console.log(chalk.gray(` - Run "cn pull --page ${relativePath}" to get the latest version`));
87
+ console.log(chalk.gray(` - Run "cn push ${relativePath} --force" to overwrite remote changes`));
88
+ }
89
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * File rename utilities for push operations
3
+ */
4
+
5
+ import { existsSync, mkdtempSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
6
+ import { tmpdir } from 'node:os';
7
+ import { basename, dirname, join } from 'node:path';
8
+ import chalk from 'chalk';
9
+ import { slugify, updateReferencesAfterRename } from '../../lib/markdown/index.js';
10
+
11
+ // Index files that should not be renamed based on title (case-insensitive check)
12
+ const INDEX_FILES = new Set(['index.md', 'readme.md']);
13
+
14
+ /**
15
+ * Result of file rename operation
16
+ */
17
+ export interface RenameResult {
18
+ finalPath: string;
19
+ wasRenamed: boolean;
20
+ }
21
+
22
+ /**
23
+ * Handle file renaming when title changes
24
+ * Returns the final local path for updating sync state
25
+ * Uses atomic operations: writes to temp file first, then renames
26
+ * Also updates references in other markdown files when a rename occurs
27
+ */
28
+ export function handleFileRename(
29
+ filePath: string,
30
+ originalRelativePath: string,
31
+ expectedTitle: string,
32
+ updatedMarkdown: string,
33
+ spaceRoot?: string,
34
+ ): RenameResult {
35
+ const currentFilename = basename(filePath);
36
+ const currentDir = dirname(filePath);
37
+ const expectedSlug = slugify(expectedTitle);
38
+ const expectedFilename = `${expectedSlug}.md`;
39
+ // Track the current relative path (will be updated if file is renamed)
40
+ const currentRelativePath = originalRelativePath.replace(/^\.\//, '');
41
+ let finalLocalPath = currentRelativePath;
42
+
43
+ const isIndexFile = INDEX_FILES.has(currentFilename.toLowerCase());
44
+
45
+ // Write to temp file first for atomicity
46
+ const tempDir = mkdtempSync(join(tmpdir(), 'cn-push-'));
47
+ const tempFile = join(tempDir, 'temp.md');
48
+ writeFileSync(tempFile, updatedMarkdown, 'utf-8');
49
+
50
+ try {
51
+ if (!isIndexFile && expectedFilename !== currentFilename && expectedSlug) {
52
+ const newFilePath = join(currentDir, expectedFilename);
53
+
54
+ if (existsSync(newFilePath)) {
55
+ console.log(chalk.yellow(` Note: Keeping filename "${currentFilename}" (${expectedFilename} already exists)`));
56
+ // Atomic rename: temp file -> original file
57
+ renameSync(tempFile, filePath);
58
+ return { finalPath: finalLocalPath, wasRenamed: false };
59
+ }
60
+
61
+ // Warn user about automatic rename
62
+ console.log(chalk.cyan(` Note: File will be renamed to match page title`));
63
+
64
+ // Atomic operations: remove old file, move temp to new location
65
+ const backupPath = `${filePath}.bak`;
66
+ renameSync(filePath, backupPath);
67
+ try {
68
+ renameSync(tempFile, newFilePath);
69
+ // Clean up backup only after successful rename
70
+ try {
71
+ unlinkSync(backupPath);
72
+ } catch {
73
+ // Ignore cleanup errors
74
+ }
75
+ } catch (error) {
76
+ // Restore from backup if rename fails
77
+ try {
78
+ renameSync(backupPath, filePath);
79
+ } catch {
80
+ // If restore fails, log the backup location
81
+ console.error(chalk.red(` Error: Failed to rename file. Backup available at: ${backupPath}`));
82
+ }
83
+ throw error;
84
+ }
85
+
86
+ const relativeDir = dirname(currentRelativePath);
87
+ const newRelativePath = relativeDir === '.' ? expectedFilename : join(relativeDir, expectedFilename);
88
+ finalLocalPath = newRelativePath;
89
+ console.log(chalk.cyan(` Renamed: ${currentFilename} → ${expectedFilename}`));
90
+
91
+ // Update references in other markdown files that link to the old filename
92
+ if (spaceRoot) {
93
+ const updatedFiles = updateReferencesAfterRename(spaceRoot, currentRelativePath, newRelativePath);
94
+ if (updatedFiles.length > 0) {
95
+ console.log(chalk.cyan(` Updated ${updatedFiles.length} file(s) with new link path`));
96
+ }
97
+ }
98
+
99
+ return { finalPath: finalLocalPath, wasRenamed: true };
100
+ }
101
+
102
+ // Atomic rename: temp file -> original file
103
+ renameSync(tempFile, filePath);
104
+ return { finalPath: finalLocalPath, wasRenamed: false };
105
+ } finally {
106
+ // Always clean up temp directory
107
+ try {
108
+ rmSync(tempDir, { recursive: true, force: true });
109
+ } catch {
110
+ // Ignore cleanup errors
111
+ }
112
+ }
113
+ }
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Folder hierarchy management for push operations
3
+ * Per ADR-0023: Folder push workflow support
4
+ */
5
+
6
+ import { confirm } from '@inquirer/prompts';
7
+ import chalk from 'chalk';
8
+ import { dirname } from 'node:path';
9
+ import type { ConfluenceClient, CreateFolderRequest } from '../../lib/confluence-client/index.js';
10
+ import { ApiError, EXIT_CODES } from '../../lib/errors.js';
11
+ import {
12
+ getFolderByPath,
13
+ updateFolderSyncInfo,
14
+ writeSpaceConfig,
15
+ type FolderSyncInfo,
16
+ type SpaceConfigWithState,
17
+ } from '../../lib/space-config.js';
18
+
19
+ /**
20
+ * Result of ensureFolderHierarchy operation
21
+ */
22
+ export interface FolderHierarchyResult {
23
+ parentId: string | undefined;
24
+ updatedConfig: SpaceConfigWithState;
25
+ }
26
+
27
+ // Maximum folder hierarchy depth to prevent hitting Confluence limits
28
+ const MAX_FOLDER_DEPTH = 10;
29
+
30
+ // Characters not allowed in Confluence page/folder titles
31
+ const INVALID_TITLE_CHARS = /[|\\/:*?"<>]/g;
32
+
33
+ /**
34
+ * Error thrown during folder hierarchy operations
35
+ */
36
+ export class FolderHierarchyError extends Error {
37
+ constructor(
38
+ message: string,
39
+ public readonly exitCode: number,
40
+ ) {
41
+ super(message);
42
+ this.name = 'FolderHierarchyError';
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Sanitize folder title for Confluence
48
+ * Removes invalid characters and returns sanitized title
49
+ */
50
+ export function sanitizeFolderTitle(title: string): { sanitized: string; wasModified: boolean } {
51
+ const sanitized = title.replace(INVALID_TITLE_CHARS, '-').trim();
52
+ return { sanitized, wasModified: sanitized !== title };
53
+ }
54
+
55
+ /**
56
+ * Ensure folder hierarchy exists for a file path
57
+ * Per ADR-0023: Creates Confluence folders matching local directory structure
58
+ *
59
+ * @param client - Confluence client
60
+ * @param spaceConfig - Current space configuration
61
+ * @param directory - Base directory of the space
62
+ * @param filePath - Relative path to the file (e.g., "docs/api/endpoints.md")
63
+ * @param dryRun - If true, don't actually create folders
64
+ * @returns The leaf folder ID as parentId and updated config
65
+ */
66
+ export async function ensureFolderHierarchy(
67
+ client: ConfluenceClient,
68
+ spaceConfig: SpaceConfigWithState,
69
+ directory: string,
70
+ filePath: string,
71
+ dryRun = false,
72
+ ): Promise<FolderHierarchyResult> {
73
+ // Extract directory path from file path
74
+ const normalizedPath = filePath.replace(/^\.\//, '');
75
+ const dirPath = dirname(normalizedPath);
76
+
77
+ // Root level file - no folder needed
78
+ if (dirPath === '.') {
79
+ return { parentId: undefined, updatedConfig: spaceConfig };
80
+ }
81
+
82
+ // Split directory path into segments
83
+ const segments = dirPath.split('/').filter((s) => s.length > 0);
84
+ if (segments.length === 0) {
85
+ return { parentId: undefined, updatedConfig: spaceConfig };
86
+ }
87
+
88
+ // Path traversal validation - reject paths with ..
89
+ if (segments.some((s) => s === '..')) {
90
+ throw new FolderHierarchyError(
91
+ `Invalid path: "${dirPath}" contains path traversal sequences`,
92
+ EXIT_CODES.INVALID_ARGUMENTS,
93
+ );
94
+ }
95
+
96
+ // Folder depth guard - prevent hitting Confluence hierarchy limits
97
+ if (segments.length > MAX_FOLDER_DEPTH) {
98
+ throw new FolderHierarchyError(
99
+ `Folder hierarchy too deep: ${segments.length} levels (max: ${MAX_FOLDER_DEPTH})`,
100
+ EXIT_CODES.INVALID_ARGUMENTS,
101
+ );
102
+ }
103
+
104
+ let config = spaceConfig;
105
+ let currentParentId: string | undefined;
106
+ let currentPath = '';
107
+
108
+ // Iterate through each directory segment
109
+ for (const segment of segments) {
110
+ currentPath = currentPath ? `${currentPath}/${segment}` : segment;
111
+
112
+ // Check if folder already exists in local config
113
+ const existingLocalFolder = getFolderByPath(config, currentPath);
114
+ if (existingLocalFolder) {
115
+ currentParentId = existingLocalFolder.folderId;
116
+ continue;
117
+ }
118
+
119
+ // Sanitize folder title for Confluence
120
+ const { sanitized: folderTitle, wasModified } = sanitizeFolderTitle(segment);
121
+ if (wasModified) {
122
+ console.log(chalk.yellow(` Note: Folder title sanitized: "${segment}" → "${folderTitle}"`));
123
+ }
124
+
125
+ // Check if folder already exists on Confluence (not in local config)
126
+ const existingRemoteFolder = await client.findFolderByTitle(config.spaceKey, folderTitle, currentParentId);
127
+ if (existingRemoteFolder) {
128
+ console.log(
129
+ chalk.gray(
130
+ ` Found existing folder on Confluence: ${existingRemoteFolder.title} (id: ${existingRemoteFolder.id})`,
131
+ ),
132
+ );
133
+
134
+ // Track folder in config
135
+ const folderInfo: FolderSyncInfo = {
136
+ folderId: existingRemoteFolder.id,
137
+ title: existingRemoteFolder.title,
138
+ parentId: currentParentId,
139
+ localPath: currentPath,
140
+ };
141
+ config = updateFolderSyncInfo(config, folderInfo);
142
+ writeSpaceConfig(directory, config);
143
+
144
+ currentParentId = existingRemoteFolder.id;
145
+ continue;
146
+ }
147
+
148
+ if (dryRun) {
149
+ console.log(chalk.gray(` Would create folder: ${currentPath}`));
150
+ // In dry run, we can't continue because we don't have a real folder ID
151
+ return { parentId: undefined, updatedConfig: config };
152
+ }
153
+
154
+ // Prompt user for confirmation before creating folder
155
+ const shouldCreate = await confirm({
156
+ message: `Create folder "${folderTitle}" on Confluence?`,
157
+ default: true,
158
+ });
159
+
160
+ if (!shouldCreate) {
161
+ console.log(chalk.yellow(` Skipping folder creation for "${folderTitle}"`));
162
+ throw new FolderHierarchyError(
163
+ `Cannot push to subdirectory without creating folder hierarchy`,
164
+ EXIT_CODES.GENERAL_ERROR,
165
+ );
166
+ }
167
+
168
+ console.log(chalk.gray(` Creating folder: ${folderTitle}...`));
169
+
170
+ // Create folder on Confluence
171
+ const createRequest: CreateFolderRequest = {
172
+ spaceId: config.spaceId,
173
+ title: folderTitle,
174
+ parentId: currentParentId,
175
+ };
176
+
177
+ try {
178
+ const folder = await client.createFolder(createRequest);
179
+ console.log(chalk.green(` Created folder: ${folder.title} (id: ${folder.id})`));
180
+
181
+ // Track folder in config
182
+ const folderInfo: FolderSyncInfo = {
183
+ folderId: folder.id,
184
+ title: folder.title,
185
+ parentId: currentParentId,
186
+ localPath: currentPath,
187
+ };
188
+ config = updateFolderSyncInfo(config, folderInfo);
189
+
190
+ // Save config immediately so we don't lose folder tracking on failure
191
+ writeSpaceConfig(directory, config);
192
+
193
+ currentParentId = folder.id;
194
+ } catch (error) {
195
+ const message = error instanceof Error ? error.message : 'Unknown error';
196
+ console.error(chalk.red(` Failed to create folder "${folderTitle}": ${message}`));
197
+ throw new FolderHierarchyError(`Failed to create folder: ${folderTitle}`, EXIT_CODES.GENERAL_ERROR);
198
+ }
199
+ }
200
+
201
+ // Return the leaf folder ID - page will be created with this as parentId
202
+ // The v2 API supports creating pages directly under folders
203
+ return { parentId: currentParentId, updatedConfig: config };
204
+ }
205
+
206
+ /**
207
+ * Determine expected parent ID based on file's folder location.
208
+ *
209
+ * This is a semantic wrapper around ensureFolderHierarchy for use when updating
210
+ * existing pages. It answers: "What parent should this page have based on its
211
+ * current local file path?" - creating any missing folders in the process.
212
+ *
213
+ * For new pages, use ensureFolderHierarchy directly.
214
+ * For existing pages being moved, use this function to determine target parent.
215
+ *
216
+ * @param client - Confluence client
217
+ * @param spaceConfig - Current space configuration
218
+ * @param directory - Base directory of the space
219
+ * @param filePath - Relative path to the file
220
+ * @param dryRun - If true, don't create folders
221
+ * @returns The expected parent ID and updated config
222
+ */
223
+ export async function determineExpectedParent(
224
+ client: ConfluenceClient,
225
+ spaceConfig: SpaceConfigWithState,
226
+ directory: string,
227
+ filePath: string,
228
+ dryRun = false,
229
+ ): Promise<FolderHierarchyResult> {
230
+ // Get directory path
231
+ const normalizedPath = filePath.replace(/^\.\//, '');
232
+ const dirPath = dirname(normalizedPath);
233
+
234
+ // Root level file - no parent
235
+ if (dirPath === '.') {
236
+ return { parentId: undefined, updatedConfig: spaceConfig };
237
+ }
238
+
239
+ // Use ensureFolderHierarchy to determine (and create if needed) the parent
240
+ return ensureFolderHierarchy(client, spaceConfig, directory, filePath, dryRun);
241
+ }
@@ -0,0 +1,56 @@
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
+ import { resolvePageTarget } from '../../lib/resolve-page-target.js';
7
+
8
+ export interface InfoCommandOptions {
9
+ xml?: boolean;
10
+ }
11
+
12
+ export async function infoCommand(target: string, options: InfoCommandOptions = {}): Promise<void> {
13
+ const configManager = new ConfigManager();
14
+ const config = await configManager.getConfig();
15
+
16
+ if (!config) {
17
+ console.error(chalk.red('Not configured. Run: cn setup'));
18
+ process.exit(EXIT_CODES.CONFIG_ERROR);
19
+ }
20
+
21
+ const pageId = resolvePageTarget(target);
22
+ const client = new ConfluenceClient(config);
23
+
24
+ const [page, labels] = await Promise.all([client.getPage(pageId, false), client.getAllLabels(pageId)]);
25
+
26
+ if (options.xml) {
27
+ console.log('<page>');
28
+ console.log(` <id>${escapeXml(page.id)}</id>`);
29
+ console.log(` <title>${escapeXml(page.title)}</title>`);
30
+ console.log(` <spaceId>${escapeXml(page.spaceId)}</spaceId>`);
31
+ if (page.status) console.log(` <status>${escapeXml(page.status)}</status>`);
32
+ if (page.parentId) console.log(` <parentId>${escapeXml(page.parentId)}</parentId>`);
33
+ if (page.version) console.log(` <version>${page.version.number}</version>`);
34
+ if (page._links?.webui)
35
+ console.log(` <url>${escapeXml(`${config.confluenceUrl}/wiki${page._links.webui}`)}</url>`);
36
+ if (labels.length > 0) {
37
+ console.log(' <labels>');
38
+ for (const label of labels) {
39
+ console.log(` <label>${escapeXml(label.name)}</label>`);
40
+ }
41
+ console.log(' </labels>');
42
+ }
43
+ console.log('</page>');
44
+ return;
45
+ }
46
+
47
+ console.log(`${chalk.bold(page.title)} ${chalk.gray(page.id)}`);
48
+ console.log(`Space: ${page.spaceId}`);
49
+ if (page.status) console.log(`Status: ${page.status}`);
50
+ if (page.parentId) console.log(`Parent: ${page.parentId}`);
51
+ if (page.version) console.log(`Version: ${page.version.number}`);
52
+ if (page._links?.webui) console.log(`URL: ${config.confluenceUrl}/wiki${page._links.webui}`);
53
+ if (labels.length > 0) {
54
+ console.log(`Labels: ${labels.map((l) => chalk.cyan(l.name)).join(', ')}`);
55
+ }
56
+ }