@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/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 'push': {
132
- if (args.includes('--help')) {
133
- showPushHelp();
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
- // File is optional - if not provided, scan for changes
138
- const file = subArgs.find((arg) => !arg.startsWith('--'));
139
-
140
- await pushCommand({
141
- file,
142
- force: args.includes('--force'),
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
- await spacesCommand({ xml: args.includes('--xml') });
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
- const createFlagValues = new Set([spaceKey, parentId].filter(Boolean));
277
- const title = subArgs.find((arg) => !arg.startsWith('--') && !createFlagValues.has(arg));
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.fetchWithRetryEffect(`/spaces?limit=${limit}`, SpacesResponseSchema);
182
+ return getSpacesEffectFn(this.baseUrl, this.authHeader, limit, (path, schema) =>
183
+ this.fetchWithRetryEffect(path, schema),
184
+ );
173
185
  }
174
186
 
175
- /** Get all spaces (async version) */
176
- async getSpaces(limit = 25): Promise<SpacesResponse> {
177
- return this.fetchWithRetry(`/spaces?limit=${limit}`, SpacesResponseSchema);
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 pagination (async version) */
192
+ /** Get all spaces with full cursor pagination */
181
193
  async getAllSpaces(): Promise<Space[]> {
182
- const allSpaces: Space[] = [];
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 pipe(
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
- const response = await this.fetchWithRetry(`/spaces?keys=${key}&limit=1`, SpacesResponseSchema);
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: string,
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}
@@ -19,6 +19,7 @@ export type {
19
19
  SearchResultItem,
20
20
  Space,
21
21
  SpacesResponse,
22
+ SpacesV1Response,
22
23
  UpdatePageRequest,
23
24
  User,
24
25
  Version,
@@ -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: Schema.Literal('storage'),
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: Schema.Literal('storage'),
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/v2/spaces', () => {
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/v2/spaces', () => {
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/v2/spaces', () => {
66
+ http.get('*/wiki/rest/api/space', () => {
67
67
  return HttpResponse.json({
68
68
  results: [
69
69
  {
70
- id: 'space-null-desc',
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 response = await client.getSpaces();
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);