@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.
- package/README.md +25 -23
- package/package.json +1 -1
- package/src/cli/commands/create.ts +21 -2
- package/src/cli/commands/folder.ts +189 -0
- package/src/cli/commands/spaces.ts +10 -1
- package/src/cli/commands/update.ts +72 -0
- package/src/cli/help.ts +70 -40
- package/src/cli/index.ts +74 -17
- package/src/cli/utils/args.ts +15 -0
- package/src/cli/utils/stdin.ts +14 -0
- package/src/lib/confluence-client/client.ts +42 -55
- package/src/lib/confluence-client/folder-operations.ts +41 -0
- package/src/lib/confluence-client/index.ts +1 -0
- package/src/lib/confluence-client/search-operations.ts +2 -1
- package/src/lib/confluence-client/space-operations.ts +133 -0
- package/src/lib/confluence-client/types.ts +27 -3
- package/src/test/confluence-client.test.ts +9 -7
- package/src/test/folder-command.test.ts +182 -0
- package/src/test/mocks/handlers.ts +12 -0
- package/src/test/spaces.test.ts +1 -1
- package/src/test/update.test.ts +115 -0
- package/src/cli/commands/duplicate-check.ts +0 -89
- package/src/cli/commands/file-rename.ts +0 -113
- package/src/cli/commands/folder-hierarchy.ts +0 -241
- package/src/cli/commands/push-errors.ts +0 -40
- package/src/cli/commands/push.ts +0 -699
- package/src/lib/dependency-sorter.ts +0 -233
- package/src/test/dependency-sorter.test.ts +0 -384
- package/src/test/file-rename.test.ts +0 -305
- package/src/test/folder-hierarchy.test.ts +0 -337
- package/src/test/push.test.ts +0 -551
|
@@ -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
|
-
}
|