@aaronshaf/confluence-cli 0.1.15 → 1.0.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.
@@ -1,241 +0,0 @@
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
- }
@@ -1,40 +0,0 @@
1
- import chalk from 'chalk';
2
- import { EXIT_CODES, PageNotFoundError, VersionConflictError } from '../../lib/errors.js';
3
-
4
- /**
5
- * Error thrown during push operations
6
- */
7
- export class PushError extends Error {
8
- constructor(
9
- message: string,
10
- public readonly exitCode: number,
11
- ) {
12
- super(message);
13
- this.name = 'PushError';
14
- }
15
- }
16
-
17
- /**
18
- * Handle push errors - formats error and throws PushError
19
- */
20
- export function handlePushError(error: unknown, filePath: string): never {
21
- if (error instanceof PageNotFoundError) {
22
- console.error('');
23
- console.error(chalk.red(`Page not found on Confluence (ID: ${error.pageId}).`));
24
- console.log(chalk.gray('The page may have been deleted.'));
25
- throw new PushError(`Page not found: ${error.pageId}`, EXIT_CODES.PAGE_NOT_FOUND);
26
- }
27
-
28
- if (error instanceof VersionConflictError) {
29
- console.error('');
30
- console.error(chalk.red('Version conflict: remote version has changed.'));
31
- console.log(chalk.gray(`Run "cn pull --page ${filePath}" to get the latest version.`));
32
- throw new PushError('Version conflict', EXIT_CODES.VERSION_CONFLICT);
33
- }
34
-
35
- console.error('');
36
- console.error(chalk.red('Push failed'));
37
- const message = error instanceof Error ? error.message : 'Unknown error';
38
- console.error(chalk.red(message));
39
- throw new PushError(message, EXIT_CODES.GENERAL_ERROR);
40
- }