@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
package/src/cli/index.ts
CHANGED
|
@@ -16,13 +16,14 @@ import {
|
|
|
16
16
|
showLabelsHelp,
|
|
17
17
|
showMoveHelp,
|
|
18
18
|
showOpenHelp,
|
|
19
|
+
showFolderHelp,
|
|
19
20
|
showPullHelp,
|
|
20
|
-
showPushHelp,
|
|
21
21
|
showSearchHelp,
|
|
22
22
|
showSetupHelp,
|
|
23
23
|
showSpacesHelp,
|
|
24
24
|
showStatusHelp,
|
|
25
25
|
showTreeHelp,
|
|
26
|
+
showUpdateHelp,
|
|
26
27
|
} from './help.js';
|
|
27
28
|
import { attachmentsCommand } from './commands/attachments.js';
|
|
28
29
|
import { cloneCommand } from './commands/clone.js';
|
|
@@ -34,13 +35,16 @@ import { infoCommand } from './commands/info.js';
|
|
|
34
35
|
import { labelsCommand } from './commands/labels.js';
|
|
35
36
|
import { moveCommand } from './commands/move.js';
|
|
36
37
|
import { openCommand } from './commands/open.js';
|
|
38
|
+
import { folderCommand } from './commands/folder.js';
|
|
37
39
|
import { pullCommand } from './commands/pull.js';
|
|
38
|
-
import { pushCommand } from './commands/push.js';
|
|
39
40
|
import { searchCommand } from './commands/search.js';
|
|
40
41
|
import { setup } from './commands/setup.js';
|
|
41
42
|
import { spacesCommand } from './commands/spaces.js';
|
|
42
43
|
import { statusCommand } from './commands/status.js';
|
|
43
44
|
import { treeCommand } from './commands/tree.js';
|
|
45
|
+
import { updateCommand } from './commands/update.js';
|
|
46
|
+
|
|
47
|
+
import { findPositional } from './utils/args.js';
|
|
44
48
|
|
|
45
49
|
// Get version from package.json
|
|
46
50
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -128,20 +132,18 @@ async function main(): Promise<void> {
|
|
|
128
132
|
break;
|
|
129
133
|
}
|
|
130
134
|
|
|
131
|
-
case '
|
|
132
|
-
if (args.includes('--help')) {
|
|
133
|
-
|
|
135
|
+
case 'folder': {
|
|
136
|
+
if (args.includes('--help') && !subArgs.find((a) => !a.startsWith('--'))) {
|
|
137
|
+
showFolderHelp();
|
|
134
138
|
process.exit(EXIT_CODES.SUCCESS);
|
|
135
139
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
dryRun: args.includes('--dry-run'),
|
|
144
|
-
});
|
|
140
|
+
const folderSubcommand = subArgs[0];
|
|
141
|
+
if (!folderSubcommand || folderSubcommand.startsWith('--')) {
|
|
142
|
+
showFolderHelp();
|
|
143
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
144
|
+
}
|
|
145
|
+
const folderSubArgs = subArgs.slice(1);
|
|
146
|
+
await folderCommand(folderSubcommand, folderSubArgs, args);
|
|
145
147
|
break;
|
|
146
148
|
}
|
|
147
149
|
|
|
@@ -212,7 +214,23 @@ async function main(): Promise<void> {
|
|
|
212
214
|
showSpacesHelp();
|
|
213
215
|
process.exit(EXIT_CODES.SUCCESS);
|
|
214
216
|
}
|
|
215
|
-
|
|
217
|
+
{
|
|
218
|
+
let limit: number | undefined;
|
|
219
|
+
const limitArg = args.find((a) => a.startsWith('--limit=') || a === '--limit');
|
|
220
|
+
if (limitArg) {
|
|
221
|
+
limit = limitArg.includes('=')
|
|
222
|
+
? Number.parseInt(limitArg.split('=')[1], 10)
|
|
223
|
+
: Number.parseInt(args[args.indexOf('--limit') + 1], 10);
|
|
224
|
+
}
|
|
225
|
+
let page: number | undefined;
|
|
226
|
+
const pageArg = args.find((a) => a.startsWith('--page=') || a === '--page');
|
|
227
|
+
if (pageArg) {
|
|
228
|
+
page = pageArg.includes('=')
|
|
229
|
+
? Number.parseInt(pageArg.split('=')[1], 10)
|
|
230
|
+
: Number.parseInt(args[args.indexOf('--page') + 1], 10);
|
|
231
|
+
}
|
|
232
|
+
await spacesCommand({ xml: args.includes('--xml'), limit, page });
|
|
233
|
+
}
|
|
216
234
|
break;
|
|
217
235
|
|
|
218
236
|
case 'search': {
|
|
@@ -273,8 +291,12 @@ async function main(): Promise<void> {
|
|
|
273
291
|
if (parentIdx !== -1 && parentIdx + 1 < args.length) {
|
|
274
292
|
parentId = args[parentIdx + 1];
|
|
275
293
|
}
|
|
276
|
-
|
|
277
|
-
const
|
|
294
|
+
let createFormat: string | undefined;
|
|
295
|
+
const createFormatIdx = args.indexOf('--format');
|
|
296
|
+
if (createFormatIdx !== -1 && createFormatIdx + 1 < args.length) {
|
|
297
|
+
createFormat = args[createFormatIdx + 1];
|
|
298
|
+
}
|
|
299
|
+
const title = findPositional(subArgs, ['--space', '--parent', '--format']);
|
|
278
300
|
if (!title) {
|
|
279
301
|
console.error(chalk.red('Page title is required.'));
|
|
280
302
|
console.log(chalk.gray('Usage: cn create <title>'));
|
|
@@ -284,6 +306,7 @@ async function main(): Promise<void> {
|
|
|
284
306
|
space: spaceKey,
|
|
285
307
|
parent: parentId,
|
|
286
308
|
open: args.includes('--open'),
|
|
309
|
+
format: createFormat,
|
|
287
310
|
});
|
|
288
311
|
break;
|
|
289
312
|
}
|
|
@@ -395,6 +418,40 @@ async function main(): Promise<void> {
|
|
|
395
418
|
break;
|
|
396
419
|
}
|
|
397
420
|
|
|
421
|
+
case 'update': {
|
|
422
|
+
if (args.includes('--help')) {
|
|
423
|
+
showUpdateHelp();
|
|
424
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
425
|
+
}
|
|
426
|
+
let updateFormat: string | undefined;
|
|
427
|
+
const updateFormatIdx = args.indexOf('--format');
|
|
428
|
+
if (updateFormatIdx !== -1 && updateFormatIdx + 1 < args.length) {
|
|
429
|
+
updateFormat = args[updateFormatIdx + 1];
|
|
430
|
+
}
|
|
431
|
+
let updateTitle: string | undefined;
|
|
432
|
+
const updateTitleIdx = args.indexOf('--title');
|
|
433
|
+
if (updateTitleIdx !== -1 && updateTitleIdx + 1 < args.length) {
|
|
434
|
+
updateTitle = args[updateTitleIdx + 1];
|
|
435
|
+
}
|
|
436
|
+
let updateMessage: string | undefined;
|
|
437
|
+
const updateMessageIdx = args.indexOf('--message');
|
|
438
|
+
if (updateMessageIdx !== -1 && updateMessageIdx + 1 < args.length) {
|
|
439
|
+
updateMessage = args[updateMessageIdx + 1];
|
|
440
|
+
}
|
|
441
|
+
const updateId = findPositional(subArgs, ['--format', '--title', '--message']);
|
|
442
|
+
if (!updateId) {
|
|
443
|
+
console.error(chalk.red('Page ID is required.'));
|
|
444
|
+
console.log(chalk.gray('Usage: cn update <id>'));
|
|
445
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
446
|
+
}
|
|
447
|
+
await updateCommand(updateId, {
|
|
448
|
+
format: updateFormat,
|
|
449
|
+
title: updateTitle,
|
|
450
|
+
message: updateMessage,
|
|
451
|
+
});
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
|
|
398
455
|
default:
|
|
399
456
|
console.error(`Unknown command: ${command}`);
|
|
400
457
|
console.log('Run "cn help" for usage information');
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find a positional argument in args, skipping flags and their values.
|
|
3
|
+
* --flag <value> pairs are identified by their indices so we never mistake
|
|
4
|
+
* a flag's value for a positional argument even if they are equal strings.
|
|
5
|
+
*/
|
|
6
|
+
export function findPositional(args: string[], flagsWithValues: string[]): string | undefined {
|
|
7
|
+
const flagValueIndices = new Set<number>();
|
|
8
|
+
for (const flag of flagsWithValues) {
|
|
9
|
+
const idx = args.indexOf(flag);
|
|
10
|
+
if (idx !== -1 && idx + 1 < args.length) {
|
|
11
|
+
flagValueIndices.add(idx + 1);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return args.find((arg, i) => !arg.startsWith('--') && !flagValueIndices.has(i));
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export async function readStdin(): Promise<string> {
|
|
2
|
+
const chunks: Buffer[] = [];
|
|
3
|
+
for await (const chunk of process.stdin) {
|
|
4
|
+
chunks.push(chunk);
|
|
5
|
+
}
|
|
6
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const VALID_FORMATS = ['storage', 'wiki', 'atlas_doc_format'] as const;
|
|
10
|
+
export type ValidFormat = (typeof VALID_FORMATS)[number];
|
|
11
|
+
|
|
12
|
+
export function isValidFormat(s: string): s is ValidFormat {
|
|
13
|
+
return (VALID_FORMATS as readonly string[]).includes(s);
|
|
14
|
+
}
|
|
@@ -7,11 +7,12 @@ import {
|
|
|
7
7
|
NetworkError,
|
|
8
8
|
type PageNotFoundError,
|
|
9
9
|
RateLimitError,
|
|
10
|
-
SpaceNotFoundError,
|
|
10
|
+
type SpaceNotFoundError,
|
|
11
11
|
type VersionConflictError,
|
|
12
12
|
} from '../errors.js';
|
|
13
13
|
import {
|
|
14
14
|
createFolderEffect as createFolderEffectFn,
|
|
15
|
+
deleteFolderEffect as deleteFolderEffectFn,
|
|
15
16
|
findFolderByTitle as findFolderByTitleFn,
|
|
16
17
|
getFolderEffect as getFolderEffectFn,
|
|
17
18
|
movePageEffect as movePageEffectFn,
|
|
@@ -33,13 +34,20 @@ import {
|
|
|
33
34
|
import { searchEffect as searchEffectFn } from './search-operations.js';
|
|
34
35
|
import { getAllFooterComments as getAllFooterCommentsFn } from './comment-operations.js';
|
|
35
36
|
import { getUserEffect as getUserEffectFn } from './user-operations.js';
|
|
37
|
+
import {
|
|
38
|
+
getAllSpaces as getAllSpacesFn,
|
|
39
|
+
getSpaces as getSpacesFn,
|
|
40
|
+
getSpacesEffect as getSpacesEffectFn,
|
|
41
|
+
getSpaceByKey as getSpaceByKeyFn,
|
|
42
|
+
getSpaceByKeyEffect as getSpaceByKeyEffectFn,
|
|
43
|
+
getSpaceByIdEffect as getSpaceByIdEffectFn,
|
|
44
|
+
} from './space-operations.js';
|
|
36
45
|
import {
|
|
37
46
|
CommentsResponseSchema,
|
|
38
47
|
FolderSchema,
|
|
39
48
|
LabelsResponseSchema,
|
|
40
49
|
PageSchema,
|
|
41
50
|
PagesResponseSchema,
|
|
42
|
-
SpaceSchema,
|
|
43
51
|
SpacesResponseSchema,
|
|
44
52
|
type Attachment,
|
|
45
53
|
type AttachmentsResponse,
|
|
@@ -108,8 +116,10 @@ export class ConfluenceClient {
|
|
|
108
116
|
): Effect.Effect<T, ApiError | AuthError | NetworkError | RateLimitError> {
|
|
109
117
|
const url = `${this.baseUrl}/wiki/api/v2${path}`;
|
|
110
118
|
|
|
119
|
+
const verbose = process.env.CN_DEBUG === '1';
|
|
111
120
|
const makeRequest = Effect.tryPromise({
|
|
112
121
|
try: async () => {
|
|
122
|
+
if (verbose) process.stderr.write(`[debug] fetch: ${url}\n`);
|
|
113
123
|
const response = await fetch(url, {
|
|
114
124
|
...options,
|
|
115
125
|
headers: {
|
|
@@ -169,74 +179,36 @@ export class ConfluenceClient {
|
|
|
169
179
|
|
|
170
180
|
/** Get all spaces (Effect version) */
|
|
171
181
|
getSpacesEffect(limit = 25): Effect.Effect<SpacesResponse, ApiError | AuthError | NetworkError | RateLimitError> {
|
|
172
|
-
return this.
|
|
182
|
+
return getSpacesEffectFn(this.baseUrl, this.authHeader, limit, (path, schema) =>
|
|
183
|
+
this.fetchWithRetryEffect(path, schema),
|
|
184
|
+
);
|
|
173
185
|
}
|
|
174
186
|
|
|
175
|
-
/** Get
|
|
176
|
-
async getSpaces(limit = 25): Promise<
|
|
177
|
-
return this.
|
|
187
|
+
/** Get spaces with offset-based pagination via v1 API */
|
|
188
|
+
async getSpaces(limit = 25, page = 1): Promise<{ results: Space[]; start: number; limit: number; size: number }> {
|
|
189
|
+
return getSpacesFn(this.baseUrl, this.authHeader, limit, page);
|
|
178
190
|
}
|
|
179
191
|
|
|
180
|
-
/** Get all spaces with
|
|
192
|
+
/** Get all spaces with full cursor pagination */
|
|
181
193
|
async getAllSpaces(): Promise<Space[]> {
|
|
182
|
-
|
|
183
|
-
let cursor: string | undefined;
|
|
184
|
-
do {
|
|
185
|
-
let path = '/spaces?limit=100';
|
|
186
|
-
if (cursor) path += `&cursor=${encodeURIComponent(cursor)}`;
|
|
187
|
-
const response = await this.fetchWithRetry(path, SpacesResponseSchema);
|
|
188
|
-
allSpaces.push(...response.results);
|
|
189
|
-
cursor = extractCursor(response._links?.next);
|
|
190
|
-
} while (cursor);
|
|
191
|
-
return allSpaces;
|
|
194
|
+
return getAllSpacesFn(this.baseUrl, this.authHeader, (path, schema) => this.fetchWithRetry(path, schema));
|
|
192
195
|
}
|
|
193
196
|
|
|
194
197
|
/** Get a space by key (Effect version) */
|
|
195
198
|
getSpaceByKeyEffect(
|
|
196
199
|
key: string,
|
|
197
200
|
): Effect.Effect<Space, ApiError | AuthError | NetworkError | RateLimitError | SpaceNotFoundError> {
|
|
198
|
-
return
|
|
199
|
-
this.fetchWithRetryEffect(`/spaces?keys=${key}&limit=1`, SpacesResponseSchema),
|
|
200
|
-
Effect.flatMap((response) => {
|
|
201
|
-
if (response.results.length === 0) {
|
|
202
|
-
return Effect.fail(new SpaceNotFoundError(key));
|
|
203
|
-
}
|
|
204
|
-
return Effect.succeed(response.results[0]);
|
|
205
|
-
}),
|
|
206
|
-
);
|
|
201
|
+
return getSpaceByKeyEffectFn(key, (path, schema) => this.fetchWithRetryEffect(path, schema));
|
|
207
202
|
}
|
|
208
203
|
|
|
209
204
|
/** Get a space by key (async version) */
|
|
210
205
|
async getSpaceByKey(key: string): Promise<Space> {
|
|
211
|
-
|
|
212
|
-
if (response.results.length === 0) {
|
|
213
|
-
throw new SpaceNotFoundError(key);
|
|
214
|
-
}
|
|
215
|
-
return response.results[0];
|
|
206
|
+
return getSpaceByKeyFn(key, (path, schema) => this.fetchWithRetry(path, schema));
|
|
216
207
|
}
|
|
217
208
|
|
|
218
209
|
/** Get a space by ID (Effect version) */
|
|
219
|
-
getSpaceByIdEffect(
|
|
220
|
-
id
|
|
221
|
-
): Effect.Effect<Space, ApiError | AuthError | NetworkError | RateLimitError | SpaceNotFoundError> {
|
|
222
|
-
const baseUrl = this.baseUrl;
|
|
223
|
-
const authHeader = this.authHeader;
|
|
224
|
-
return Effect.tryPromise({
|
|
225
|
-
try: async () => {
|
|
226
|
-
const url = `${baseUrl}/wiki/api/v2/spaces/${id}`;
|
|
227
|
-
const response = await fetch(url, { headers: { Authorization: authHeader, Accept: 'application/json' } });
|
|
228
|
-
if (response.status === 404) throw new SpaceNotFoundError(id);
|
|
229
|
-
if (response.status === 401) throw new AuthError('Invalid credentials', 401);
|
|
230
|
-
if (response.status === 403) throw new AuthError('Access denied', 403);
|
|
231
|
-
if (!response.ok) throw new ApiError(`API error: ${response.status}`, response.status);
|
|
232
|
-
return Schema.decodeUnknownSync(SpaceSchema)(await response.json());
|
|
233
|
-
},
|
|
234
|
-
catch: (error) => {
|
|
235
|
-
if (error instanceof SpaceNotFoundError || error instanceof AuthError || error instanceof ApiError)
|
|
236
|
-
return error;
|
|
237
|
-
return new NetworkError(`Network error: ${error}`);
|
|
238
|
-
},
|
|
239
|
-
});
|
|
210
|
+
getSpaceByIdEffect(id: string): Effect.Effect<Space, ApiError | AuthError | NetworkError | SpaceNotFoundError> {
|
|
211
|
+
return getSpaceByIdEffectFn(id, this.baseUrl, this.authHeader);
|
|
240
212
|
}
|
|
241
213
|
|
|
242
214
|
/** Get a space by ID (async version) */
|
|
@@ -479,6 +451,20 @@ export class ConfluenceClient {
|
|
|
479
451
|
return Effect.runPromise(this.createFolderEffect(request));
|
|
480
452
|
}
|
|
481
453
|
|
|
454
|
+
/**
|
|
455
|
+
* Delete a folder by ID (Effect version)
|
|
456
|
+
*/
|
|
457
|
+
deleteFolderEffect(
|
|
458
|
+
folderId: string,
|
|
459
|
+
): Effect.Effect<void, ApiError | AuthError | NetworkError | RateLimitError | FolderNotFoundError> {
|
|
460
|
+
return deleteFolderEffectFn(this.baseUrl, this.authHeader, folderId);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Delete a folder by ID (async version) */
|
|
464
|
+
async deleteFolder(folderId: string): Promise<void> {
|
|
465
|
+
return Effect.runPromise(this.deleteFolderEffect(folderId));
|
|
466
|
+
}
|
|
467
|
+
|
|
482
468
|
/**
|
|
483
469
|
* Find a folder by title in a space
|
|
484
470
|
* Uses v1 CQL search API to find folders by title
|
|
@@ -514,13 +500,14 @@ export class ConfluenceClient {
|
|
|
514
500
|
searchEffect(
|
|
515
501
|
cql: string,
|
|
516
502
|
limit = 10,
|
|
503
|
+
start = 0,
|
|
517
504
|
): Effect.Effect<SearchResponse, ApiError | AuthError | NetworkError | RateLimitError> {
|
|
518
|
-
return searchEffectFn(this.baseUrl, this.authHeader, cql, limit);
|
|
505
|
+
return searchEffectFn(this.baseUrl, this.authHeader, cql, limit, start);
|
|
519
506
|
}
|
|
520
507
|
|
|
521
508
|
/** Search pages using CQL (async version) */
|
|
522
|
-
async search(cql: string, limit = 10): Promise<SearchResponse> {
|
|
523
|
-
return Effect.runPromise(this.searchEffect(cql, limit));
|
|
509
|
+
async search(cql: string, limit = 10, start = 0): Promise<SearchResponse> {
|
|
510
|
+
return Effect.runPromise(this.searchEffect(cql, limit, start));
|
|
524
511
|
}
|
|
525
512
|
|
|
526
513
|
// ================== Comments API ==================
|
|
@@ -118,6 +118,47 @@ export function createFolderEffect(
|
|
|
118
118
|
);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Delete a folder by ID (Effect version)
|
|
123
|
+
*/
|
|
124
|
+
export function deleteFolderEffect(
|
|
125
|
+
baseUrl: string,
|
|
126
|
+
authHeader: string,
|
|
127
|
+
folderId: string,
|
|
128
|
+
): Effect.Effect<void, ApiError | AuthError | NetworkError | RateLimitError | FolderNotFoundError> {
|
|
129
|
+
const makeRequest = Effect.tryPromise({
|
|
130
|
+
try: async () => {
|
|
131
|
+
const url = `${baseUrl}/wiki/api/v2/folders/${folderId}`;
|
|
132
|
+
const response = await fetch(url, {
|
|
133
|
+
method: 'DELETE',
|
|
134
|
+
headers: { Authorization: authHeader, Accept: 'application/json' },
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
if (response.status === 404) throw new FolderNotFoundError(folderId);
|
|
138
|
+
if (response.status === 401) throw new AuthError('Invalid credentials', 401);
|
|
139
|
+
if (response.status === 403) throw new AuthError('Access denied', 403);
|
|
140
|
+
if (response.status === 429) {
|
|
141
|
+
const retryAfter = response.headers.get('Retry-After');
|
|
142
|
+
throw new RateLimitError('Rate limited', retryAfter ? Number.parseInt(retryAfter, 10) : undefined);
|
|
143
|
+
}
|
|
144
|
+
if (!response.ok) throw new ApiError(`API error: ${response.status}`, response.status);
|
|
145
|
+
},
|
|
146
|
+
catch: (error) => {
|
|
147
|
+
if (
|
|
148
|
+
error instanceof FolderNotFoundError ||
|
|
149
|
+
error instanceof AuthError ||
|
|
150
|
+
error instanceof ApiError ||
|
|
151
|
+
error instanceof RateLimitError
|
|
152
|
+
) {
|
|
153
|
+
return error;
|
|
154
|
+
}
|
|
155
|
+
return new NetworkError(`Network error: ${error}`);
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
return pipe(makeRequest, Effect.retry(rateLimitRetrySchedule));
|
|
160
|
+
}
|
|
161
|
+
|
|
121
162
|
/**
|
|
122
163
|
* Move a page to a new parent (Effect version)
|
|
123
164
|
* Uses v1 API: PUT /wiki/rest/api/content/{id}/move/{position}/{targetId}
|
|
@@ -21,8 +21,9 @@ export function searchEffect(
|
|
|
21
21
|
authHeader: string,
|
|
22
22
|
cql: string,
|
|
23
23
|
limit = 10,
|
|
24
|
+
start = 0,
|
|
24
25
|
): Effect.Effect<SearchResponse, ApiError | AuthError | NetworkError | RateLimitError> {
|
|
25
|
-
const url = `${baseUrl}/wiki/rest/api/search?cql=${encodeURIComponent(cql)}&limit=${limit}`;
|
|
26
|
+
const url = `${baseUrl}/wiki/rest/api/search?cql=${encodeURIComponent(cql)}&limit=${limit}&start=${start}`;
|
|
26
27
|
|
|
27
28
|
const makeRequest = Effect.tryPromise({
|
|
28
29
|
try: async () => {
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Effect, pipe, Schema } from 'effect';
|
|
2
|
+
import { ApiError, AuthError, NetworkError, type RateLimitError, SpaceNotFoundError } from '../errors.js';
|
|
3
|
+
import { SpaceSchema, SpacesResponseSchema, SpacesV1ResponseSchema, type Space, type SpacesResponse } from './types.js';
|
|
4
|
+
|
|
5
|
+
function extractCursor(nextLink: string | undefined): string | undefined {
|
|
6
|
+
if (!nextLink) return undefined;
|
|
7
|
+
try {
|
|
8
|
+
const url = new URL(nextLink, 'https://placeholder.invalid');
|
|
9
|
+
return url.searchParams.get('cursor') ?? undefined;
|
|
10
|
+
} catch {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function fetchV1<T>(baseUrl: string, authHeader: string, path: string, schema: Schema.Schema<T>): Promise<T> {
|
|
16
|
+
const url = `${baseUrl}/wiki/rest/api${path}`;
|
|
17
|
+
const verbose = process.env.CN_DEBUG === '1';
|
|
18
|
+
if (verbose) process.stderr.write(`[debug] fetchV1: ${url}\n`);
|
|
19
|
+
const response = await fetch(url, {
|
|
20
|
+
headers: { Authorization: authHeader, Accept: 'application/json' },
|
|
21
|
+
});
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
const errorText = await response.text();
|
|
24
|
+
throw new ApiError(`API request failed: ${response.status} ${errorText}`, response.status);
|
|
25
|
+
}
|
|
26
|
+
const data = await response.json();
|
|
27
|
+
return Effect.runPromise(
|
|
28
|
+
Schema.decodeUnknown(schema)(data).pipe(Effect.mapError((e) => new ApiError(`Invalid response: ${e}`, 500))),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getSpacesEffect(
|
|
33
|
+
_baseUrl: string,
|
|
34
|
+
_authHeader: string,
|
|
35
|
+
limit: number,
|
|
36
|
+
fetchWithRetryEffect: <T>(
|
|
37
|
+
path: string,
|
|
38
|
+
schema: Schema.Schema<T>,
|
|
39
|
+
) => Effect.Effect<T, ApiError | AuthError | NetworkError | RateLimitError>,
|
|
40
|
+
): Effect.Effect<SpacesResponse, ApiError | AuthError | NetworkError | RateLimitError> {
|
|
41
|
+
return fetchWithRetryEffect(`/spaces?limit=${limit}`, SpacesResponseSchema);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function getSpaces(
|
|
45
|
+
baseUrl: string,
|
|
46
|
+
authHeader: string,
|
|
47
|
+
limit = 25,
|
|
48
|
+
page = 1,
|
|
49
|
+
): Promise<{ results: Space[]; start: number; limit: number; size: number }> {
|
|
50
|
+
const start = (page - 1) * limit;
|
|
51
|
+
const response = await fetchV1(baseUrl, authHeader, `/space?limit=${limit}&start=${start}`, SpacesV1ResponseSchema);
|
|
52
|
+
return {
|
|
53
|
+
...response,
|
|
54
|
+
results: response.results.map((s) => ({ ...s, id: String(s.id) })),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function getAllSpaces(
|
|
59
|
+
baseUrl: string,
|
|
60
|
+
_authHeader: string,
|
|
61
|
+
fetchWithRetry: <T>(path: string, schema: Schema.Schema<T>) => Promise<T>,
|
|
62
|
+
): Promise<Space[]> {
|
|
63
|
+
const verbose = process.env.CN_DEBUG === '1';
|
|
64
|
+
const allSpaces: Space[] = [];
|
|
65
|
+
let cursor: string | undefined;
|
|
66
|
+
let page = 1;
|
|
67
|
+
do {
|
|
68
|
+
let path = '/spaces?limit=20';
|
|
69
|
+
if (cursor) path += `&cursor=${encodeURIComponent(cursor)}`;
|
|
70
|
+
if (verbose) process.stderr.write(`[debug] getAllSpaces: fetching page ${page} (${baseUrl}/wiki/api/v2${path})\n`);
|
|
71
|
+
const response = await fetchWithRetry(path, SpacesResponseSchema);
|
|
72
|
+
if (verbose)
|
|
73
|
+
process.stderr.write(
|
|
74
|
+
`[debug] getAllSpaces: got ${response.results.length} spaces, next=${response._links?.next ?? 'none'}\n`,
|
|
75
|
+
);
|
|
76
|
+
allSpaces.push(...response.results);
|
|
77
|
+
cursor = extractCursor(response._links?.next);
|
|
78
|
+
page++;
|
|
79
|
+
} while (cursor);
|
|
80
|
+
if (verbose) process.stderr.write(`[debug] getAllSpaces: done, total=${allSpaces.length}\n`);
|
|
81
|
+
return allSpaces;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getSpaceByKeyEffect(
|
|
85
|
+
key: string,
|
|
86
|
+
fetchWithRetryEffect: <T>(
|
|
87
|
+
path: string,
|
|
88
|
+
schema: Schema.Schema<T>,
|
|
89
|
+
) => Effect.Effect<T, ApiError | AuthError | NetworkError | RateLimitError>,
|
|
90
|
+
): Effect.Effect<Space, ApiError | AuthError | NetworkError | RateLimitError | SpaceNotFoundError> {
|
|
91
|
+
return pipe(
|
|
92
|
+
fetchWithRetryEffect(`/spaces?keys=${key}&limit=1`, SpacesResponseSchema),
|
|
93
|
+
Effect.flatMap((response) => {
|
|
94
|
+
if (response.results.length === 0) {
|
|
95
|
+
return Effect.fail(new SpaceNotFoundError(key));
|
|
96
|
+
}
|
|
97
|
+
return Effect.succeed(response.results[0]);
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function getSpaceByKey(
|
|
103
|
+
key: string,
|
|
104
|
+
fetchWithRetry: <T>(path: string, schema: Schema.Schema<T>) => Promise<T>,
|
|
105
|
+
): Promise<Space> {
|
|
106
|
+
const response = await fetchWithRetry(`/spaces?keys=${key}&limit=1`, SpacesResponseSchema);
|
|
107
|
+
if (response.results.length === 0) {
|
|
108
|
+
throw new SpaceNotFoundError(key);
|
|
109
|
+
}
|
|
110
|
+
return response.results[0];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getSpaceByIdEffect(
|
|
114
|
+
id: string,
|
|
115
|
+
baseUrl: string,
|
|
116
|
+
authHeader: string,
|
|
117
|
+
): Effect.Effect<Space, ApiError | AuthError | NetworkError | SpaceNotFoundError> {
|
|
118
|
+
return Effect.tryPromise({
|
|
119
|
+
try: async () => {
|
|
120
|
+
const url = `${baseUrl}/wiki/api/v2/spaces/${id}`;
|
|
121
|
+
const response = await fetch(url, { headers: { Authorization: authHeader, Accept: 'application/json' } });
|
|
122
|
+
if (response.status === 404) throw new SpaceNotFoundError(id);
|
|
123
|
+
if (response.status === 401) throw new AuthError('Invalid credentials', 401);
|
|
124
|
+
if (response.status === 403) throw new AuthError('Access denied', 403);
|
|
125
|
+
if (!response.ok) throw new ApiError(`API error: ${response.status}`, response.status);
|
|
126
|
+
return Schema.decodeUnknownSync(SpaceSchema)(await response.json());
|
|
127
|
+
},
|
|
128
|
+
catch: (error) => {
|
|
129
|
+
if (error instanceof SpaceNotFoundError || error instanceof AuthError || error instanceof ApiError) return error;
|
|
130
|
+
return new NetworkError(`Network error: ${error}`);
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
@@ -24,7 +24,7 @@ export const SpaceSchema = Schema.Struct({
|
|
|
24
24
|
name: Schema.String,
|
|
25
25
|
type: Schema.optional(Schema.String),
|
|
26
26
|
status: Schema.optional(Schema.String),
|
|
27
|
-
homepageId: Schema.optional(Schema.String),
|
|
27
|
+
homepageId: Schema.optional(Schema.NullOr(Schema.String)),
|
|
28
28
|
description: Schema.optional(
|
|
29
29
|
Schema.NullOr(
|
|
30
30
|
Schema.Struct({
|
|
@@ -57,6 +57,23 @@ export const SpacesResponseSchema = Schema.Struct({
|
|
|
57
57
|
});
|
|
58
58
|
export type SpacesResponse = Schema.Schema.Type<typeof SpacesResponseSchema>;
|
|
59
59
|
|
|
60
|
+
const SpaceV1Schema = Schema.Struct({
|
|
61
|
+
id: Schema.Number,
|
|
62
|
+
key: Schema.String,
|
|
63
|
+
name: Schema.String,
|
|
64
|
+
type: Schema.optional(Schema.String),
|
|
65
|
+
status: Schema.optional(Schema.String),
|
|
66
|
+
_links: Schema.optional(Schema.Struct({ webui: Schema.optional(Schema.String) })),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export const SpacesV1ResponseSchema = Schema.Struct({
|
|
70
|
+
results: Schema.Array(SpaceV1Schema),
|
|
71
|
+
start: Schema.Number,
|
|
72
|
+
limit: Schema.Number,
|
|
73
|
+
size: Schema.Number,
|
|
74
|
+
});
|
|
75
|
+
export type SpacesV1Response = Schema.Schema.Type<typeof SpacesV1ResponseSchema>;
|
|
76
|
+
|
|
60
77
|
/**
|
|
61
78
|
* Page version information
|
|
62
79
|
*/
|
|
@@ -182,12 +199,19 @@ export interface VersionConflictResponse {
|
|
|
182
199
|
/**
|
|
183
200
|
* Request body for updating a page
|
|
184
201
|
*/
|
|
202
|
+
export const RepresentationSchema = Schema.Union(
|
|
203
|
+
Schema.Literal('storage'),
|
|
204
|
+
Schema.Literal('wiki'),
|
|
205
|
+
Schema.Literal('atlas_doc_format'),
|
|
206
|
+
);
|
|
207
|
+
export type Representation = Schema.Schema.Type<typeof RepresentationSchema>;
|
|
208
|
+
|
|
185
209
|
export const UpdatePageRequestSchema = Schema.Struct({
|
|
186
210
|
id: Schema.String,
|
|
187
211
|
status: Schema.String,
|
|
188
212
|
title: Schema.String,
|
|
189
213
|
body: Schema.Struct({
|
|
190
|
-
representation:
|
|
214
|
+
representation: RepresentationSchema,
|
|
191
215
|
value: Schema.String,
|
|
192
216
|
}),
|
|
193
217
|
version: Schema.Struct({
|
|
@@ -206,7 +230,7 @@ export const CreatePageRequestSchema = Schema.Struct({
|
|
|
206
230
|
title: Schema.String,
|
|
207
231
|
parentId: Schema.optional(Schema.String),
|
|
208
232
|
body: Schema.Struct({
|
|
209
|
-
representation:
|
|
233
|
+
representation: RepresentationSchema,
|
|
210
234
|
value: Schema.String,
|
|
211
235
|
}),
|
|
212
236
|
});
|
|
@@ -23,7 +23,7 @@ describe('ConfluenceClient', () => {
|
|
|
23
23
|
|
|
24
24
|
test('throws AuthError on 401', async () => {
|
|
25
25
|
server.use(
|
|
26
|
-
http.get('*/wiki/api/
|
|
26
|
+
http.get('*/wiki/rest/api/space', () => {
|
|
27
27
|
return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
28
28
|
}),
|
|
29
29
|
);
|
|
@@ -37,7 +37,7 @@ describe('ConfluenceClient', () => {
|
|
|
37
37
|
|
|
38
38
|
test('throws AuthError on 403', async () => {
|
|
39
39
|
server.use(
|
|
40
|
-
http.get('*/wiki/api/
|
|
40
|
+
http.get('*/wiki/rest/api/space', () => {
|
|
41
41
|
return HttpResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
42
42
|
}),
|
|
43
43
|
);
|
|
@@ -63,16 +63,18 @@ describe('ConfluenceClient', () => {
|
|
|
63
63
|
|
|
64
64
|
test('handles spaces with null description', async () => {
|
|
65
65
|
server.use(
|
|
66
|
-
http.get('*/wiki/api/
|
|
66
|
+
http.get('*/wiki/rest/api/space', () => {
|
|
67
67
|
return HttpResponse.json({
|
|
68
68
|
results: [
|
|
69
69
|
{
|
|
70
|
-
id:
|
|
70
|
+
id: 99,
|
|
71
71
|
key: 'NULL',
|
|
72
72
|
name: 'Space with null description',
|
|
73
|
-
description: null,
|
|
74
73
|
},
|
|
75
74
|
],
|
|
75
|
+
start: 0,
|
|
76
|
+
limit: 25,
|
|
77
|
+
size: 1,
|
|
76
78
|
});
|
|
77
79
|
}),
|
|
78
80
|
);
|
|
@@ -82,7 +84,6 @@ describe('ConfluenceClient', () => {
|
|
|
82
84
|
|
|
83
85
|
expect(response.results).toBeArray();
|
|
84
86
|
expect(response.results[0].key).toBe('NULL');
|
|
85
|
-
expect(response.results[0].description).toBeNull();
|
|
86
87
|
});
|
|
87
88
|
});
|
|
88
89
|
|
|
@@ -425,7 +426,8 @@ describe('ConfluenceClient', () => {
|
|
|
425
426
|
);
|
|
426
427
|
|
|
427
428
|
const client = new ConfluenceClient(testConfig);
|
|
428
|
-
const
|
|
429
|
+
const { Effect } = await import('effect');
|
|
430
|
+
const response = await Effect.runPromise(client.getSpacesEffect(1));
|
|
429
431
|
|
|
430
432
|
expect(response.results).toBeArray();
|
|
431
433
|
expect(requestCount).toBe(2);
|