@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,60 @@
1
+ import { Effect, Schema } from 'effect';
2
+ import { ApiError, AuthError, NetworkError, type RateLimitError } from '../errors.js';
3
+ import { CommentsResponseSchema, type Comment, type CommentsResponse } from './types.js';
4
+
5
+ /**
6
+ * Extract cursor from a Confluence API next-page link.
7
+ */
8
+ function extractCursor(nextLink: string | undefined): string | undefined {
9
+ if (!nextLink) return undefined;
10
+ try {
11
+ const url = new URL(nextLink, 'https://placeholder.invalid');
12
+ return url.searchParams.get('cursor') ?? undefined;
13
+ } catch {
14
+ return undefined;
15
+ }
16
+ }
17
+
18
+ /**
19
+ * Fetch one page of footer comments (Effect version).
20
+ */
21
+ export function getFooterCommentsEffect(
22
+ baseUrl: string,
23
+ authHeader: string,
24
+ path: string,
25
+ ): Effect.Effect<CommentsResponse, ApiError | AuthError | NetworkError | RateLimitError> {
26
+ const url = `${baseUrl}/wiki/api/v2${path}`;
27
+ return Effect.tryPromise({
28
+ try: async () => {
29
+ const response = await fetch(url, {
30
+ headers: { Authorization: authHeader, Accept: 'application/json' },
31
+ });
32
+ if (response.status === 401) throw new AuthError('Invalid credentials', 401);
33
+ if (!response.ok) throw new ApiError(`Request failed: ${response.status}`, response.status);
34
+ const data = await response.json();
35
+ return Schema.decodeUnknownSync(CommentsResponseSchema)(data);
36
+ },
37
+ catch: (e) => (e instanceof AuthError || e instanceof ApiError ? e : new NetworkError(String(e))),
38
+ });
39
+ }
40
+
41
+ /**
42
+ * Get all footer comments for a page with cursor pagination (async version).
43
+ */
44
+ export async function getAllFooterComments(baseUrl: string, authHeader: string, pageId: string): Promise<Comment[]> {
45
+ const allComments: Comment[] = [];
46
+ let cursor: string | undefined;
47
+ do {
48
+ let path = `/pages/${pageId}/footer-comments?body-format=storage&limit=100`;
49
+ if (cursor) path += `&cursor=${encodeURIComponent(cursor)}`;
50
+ const url = `${baseUrl}/wiki/api/v2${path}`;
51
+ const response = await fetch(url, {
52
+ headers: { Authorization: authHeader, Accept: 'application/json' },
53
+ });
54
+ if (!response.ok) throw new ApiError(`Request failed: ${response.status}`, response.status);
55
+ const data = Schema.decodeUnknownSync(CommentsResponseSchema)(await response.json());
56
+ allComments.push(...data.results);
57
+ cursor = extractCursor(data._links?.next);
58
+ } while (cursor);
59
+ return allComments;
60
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Folder API operations for Confluence
3
+ * Per ADR-0018 and ADR-0023
4
+ */
5
+
6
+ import { Effect, pipe, Schedule, Schema } from 'effect';
7
+ import {
8
+ ApiError,
9
+ AuthError,
10
+ FolderNotFoundError,
11
+ NetworkError,
12
+ PageNotFoundError,
13
+ RateLimitError,
14
+ } from '../errors.js';
15
+ import { FolderSchema, type CreateFolderRequest, type Folder } from './types.js';
16
+
17
+ /**
18
+ * Retry configuration for rate limiting
19
+ */
20
+ const MAX_RETRIES = 5;
21
+ const BASE_DELAY_MS = 1000;
22
+ const rateLimitRetrySchedule = Schedule.exponential(BASE_DELAY_MS).pipe(
23
+ Schedule.jittered,
24
+ Schedule.whileInput((error: unknown) => error instanceof RateLimitError),
25
+ Schedule.upTo(MAX_RETRIES * BASE_DELAY_MS * 32),
26
+ );
27
+
28
+ /**
29
+ * Get a folder by ID (Effect version)
30
+ */
31
+ export function getFolderEffect(
32
+ baseUrl: string,
33
+ authHeader: string,
34
+ folderId: string,
35
+ ): Effect.Effect<Folder, ApiError | AuthError | NetworkError | RateLimitError | FolderNotFoundError> {
36
+ const makeRequest = Effect.tryPromise({
37
+ try: async () => {
38
+ const url = `${baseUrl}/wiki/api/v2/folders/${folderId}`;
39
+ const response = await fetch(url, {
40
+ headers: { Authorization: authHeader, Accept: 'application/json' },
41
+ });
42
+
43
+ if (response.status === 404) throw new FolderNotFoundError(folderId);
44
+ if (response.status === 401) throw new AuthError('Invalid credentials', 401);
45
+ if (response.status === 403) throw new AuthError('Access denied', 403);
46
+ if (response.status === 429) {
47
+ const retryAfter = response.headers.get('Retry-After');
48
+ throw new RateLimitError('Rate limited', retryAfter ? Number.parseInt(retryAfter, 10) : undefined);
49
+ }
50
+ if (!response.ok) throw new ApiError(`API error: ${response.status}`, response.status);
51
+
52
+ const data = await response.json();
53
+ return Schema.decodeUnknownSync(FolderSchema)(data);
54
+ },
55
+ catch: (error) => {
56
+ if (
57
+ error instanceof FolderNotFoundError ||
58
+ error instanceof AuthError ||
59
+ error instanceof ApiError ||
60
+ error instanceof RateLimitError
61
+ ) {
62
+ return error;
63
+ }
64
+ return new NetworkError(`Network error: ${error}`);
65
+ },
66
+ });
67
+
68
+ return pipe(makeRequest, Effect.retry(rateLimitRetrySchedule));
69
+ }
70
+
71
+ /**
72
+ * Create a new folder (Effect version)
73
+ */
74
+ export function createFolderEffect(
75
+ baseUrl: string,
76
+ authHeader: string,
77
+ request: CreateFolderRequest,
78
+ ): Effect.Effect<Folder, ApiError | AuthError | NetworkError | RateLimitError> {
79
+ const url = `${baseUrl}/wiki/api/v2/folders`;
80
+
81
+ const makeRequest = Effect.tryPromise({
82
+ try: async () => {
83
+ const response = await fetch(url, {
84
+ method: 'POST',
85
+ headers: { Authorization: authHeader, Accept: 'application/json', 'Content-Type': 'application/json' },
86
+ body: JSON.stringify(request),
87
+ });
88
+
89
+ if (response.status === 429) {
90
+ const retryAfter = response.headers.get('Retry-After');
91
+ throw new RateLimitError('Rate limited', retryAfter ? Number.parseInt(retryAfter, 10) : undefined);
92
+ }
93
+ if (response.status === 401) throw new AuthError('Invalid credentials', 401);
94
+ if (response.status === 403) throw new AuthError('Access denied', 403);
95
+ if (!response.ok) {
96
+ const errorText = await response.text();
97
+ throw new ApiError(`API request failed: ${response.status} ${errorText}`, response.status);
98
+ }
99
+
100
+ return response.json();
101
+ },
102
+ catch: (error) => {
103
+ if (error instanceof RateLimitError || error instanceof AuthError || error instanceof ApiError) {
104
+ return error;
105
+ }
106
+ return new NetworkError(`Network error: ${error}`);
107
+ },
108
+ });
109
+
110
+ return pipe(
111
+ makeRequest,
112
+ Effect.flatMap((data) =>
113
+ Schema.decodeUnknown(FolderSchema)(data).pipe(
114
+ Effect.mapError((e) => new ApiError(`Invalid response: ${e}`, 500)),
115
+ ),
116
+ ),
117
+ Effect.retry(rateLimitRetrySchedule),
118
+ );
119
+ }
120
+
121
+ /**
122
+ * Move a page to a new parent (Effect version)
123
+ * Uses v1 API: PUT /wiki/rest/api/content/{id}/move/{position}/{targetId}
124
+ * Returns void since response structure varies and is not needed by callers
125
+ */
126
+ export function movePageEffect(
127
+ baseUrl: string,
128
+ authHeader: string,
129
+ pageId: string,
130
+ targetId: string,
131
+ position: 'append' | 'prepend' = 'append',
132
+ ): Effect.Effect<void, ApiError | AuthError | NetworkError | RateLimitError | PageNotFoundError> {
133
+ const url = `${baseUrl}/wiki/rest/api/content/${pageId}/move/${position}/${targetId}`;
134
+
135
+ const makeRequest = Effect.tryPromise({
136
+ try: async () => {
137
+ const response = await fetch(url, {
138
+ method: 'PUT',
139
+ headers: { Authorization: authHeader, Accept: 'application/json', 'Content-Type': 'application/json' },
140
+ });
141
+
142
+ if (response.status === 429) {
143
+ const retryAfter = response.headers.get('Retry-After');
144
+ throw new RateLimitError('Rate limited', retryAfter ? Number.parseInt(retryAfter, 10) : undefined);
145
+ }
146
+ if (response.status === 401) throw new AuthError('Invalid credentials', 401);
147
+ if (response.status === 403) throw new AuthError('Access denied', 403);
148
+ if (response.status === 404) throw new PageNotFoundError(pageId);
149
+ if (!response.ok) {
150
+ const errorText = await response.text();
151
+ throw new ApiError(`API request failed: ${response.status} ${errorText}`, response.status);
152
+ }
153
+ // Response body not validated - structure varies when moving to folders vs pages
154
+ },
155
+ catch: (error) => {
156
+ if (
157
+ error instanceof RateLimitError ||
158
+ error instanceof AuthError ||
159
+ error instanceof ApiError ||
160
+ error instanceof PageNotFoundError
161
+ ) {
162
+ return error;
163
+ }
164
+ return new NetworkError(`Network error: ${error}`);
165
+ },
166
+ });
167
+
168
+ return pipe(makeRequest, Effect.retry(rateLimitRetrySchedule));
169
+ }
170
+
171
+ /**
172
+ * Find a folder by title in a space using CQL search
173
+ */
174
+ export async function findFolderByTitle(
175
+ baseUrl: string,
176
+ authHeader: string,
177
+ spaceKey: string,
178
+ title: string,
179
+ parentId?: string,
180
+ ): Promise<Folder | null> {
181
+ const escapedTitle = title.replace(/"/g, '\\"');
182
+ let cql = `type=folder AND space="${spaceKey}" AND title="${escapedTitle}"`;
183
+ if (parentId) cql += ` AND parent=${parentId}`;
184
+
185
+ const url = `${baseUrl}/wiki/rest/api/search?cql=${encodeURIComponent(cql)}&limit=10`;
186
+ const response = await fetch(url, { headers: { Authorization: authHeader, Accept: 'application/json' } });
187
+
188
+ if (!response.ok) return null;
189
+
190
+ const data = await response.json();
191
+ for (const result of data?.results || []) {
192
+ if (result.content?.type === 'folder' && result.content?.title === title) {
193
+ const content = result.content;
194
+ return {
195
+ id: content.id,
196
+ type: 'folder' as const,
197
+ title: content.title,
198
+ parentId: content.ancestors?.length > 0 ? content.ancestors[content.ancestors.length - 1]?.id : undefined,
199
+ };
200
+ }
201
+ }
202
+ return null;
203
+ }
@@ -0,0 +1,47 @@
1
+ export { ConfluenceClient } from './client.js';
2
+ export type {
3
+ Attachment,
4
+ AttachmentsResponse,
5
+ Body,
6
+ Comment,
7
+ CommentsResponse,
8
+ ContentItem,
9
+ ContentTreeNode,
10
+ CreateFolderRequest,
11
+ CreatePageRequest,
12
+ Folder,
13
+ Label,
14
+ LabelsResponse,
15
+ Page,
16
+ PagesResponse,
17
+ PageTreeNode,
18
+ SearchResponse,
19
+ SearchResultItem,
20
+ Space,
21
+ SpacesResponse,
22
+ UpdatePageRequest,
23
+ User,
24
+ Version,
25
+ } from './types.js';
26
+ export {
27
+ AttachmentSchema,
28
+ AttachmentsResponseSchema,
29
+ BodySchema,
30
+ CommentSchema,
31
+ CommentsResponseSchema,
32
+ CreateFolderRequestSchema,
33
+ CreatePageRequestSchema,
34
+ FolderSchema,
35
+ isFolder,
36
+ LabelSchema,
37
+ LabelsResponseSchema,
38
+ PageSchema,
39
+ PagesResponseSchema,
40
+ SearchResponseSchema,
41
+ SearchResultItemSchema,
42
+ SpaceSchema,
43
+ SpacesResponseSchema,
44
+ UpdatePageRequestSchema,
45
+ UserSchema,
46
+ VersionSchema,
47
+ } from './types.js';
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Label operations for Confluence pages
3
+ */
4
+
5
+ import { Effect, pipe, Schedule } from 'effect';
6
+ import { ApiError, AuthError, NetworkError, RateLimitError } from '../errors.js';
7
+
8
+ const MAX_RETRIES = 5;
9
+ const BASE_DELAY_MS = 1000;
10
+ const rateLimitRetrySchedule = Schedule.exponential(BASE_DELAY_MS).pipe(
11
+ Schedule.jittered,
12
+ Schedule.whileInput((error: unknown) => error instanceof RateLimitError),
13
+ Schedule.upTo(MAX_RETRIES * BASE_DELAY_MS * 32),
14
+ );
15
+
16
+ /**
17
+ * Add a label to a page (Effect version)
18
+ * Uses POST /wiki/rest/api/content/{pageId}/label
19
+ */
20
+ export function addLabelEffect(
21
+ baseUrl: string,
22
+ authHeader: string,
23
+ pageId: string,
24
+ labelName: string,
25
+ ): Effect.Effect<void, ApiError | AuthError | NetworkError | RateLimitError> {
26
+ const url = `${baseUrl}/wiki/rest/api/content/${pageId}/label`;
27
+
28
+ const makeRequest = Effect.tryPromise({
29
+ try: async () => {
30
+ const response = await fetch(url, {
31
+ method: 'POST',
32
+ headers: {
33
+ Authorization: authHeader,
34
+ Accept: 'application/json',
35
+ 'Content-Type': 'application/json',
36
+ },
37
+ body: JSON.stringify([{ prefix: 'global', name: labelName }]),
38
+ });
39
+
40
+ if (response.status === 429) {
41
+ const retryAfter = response.headers.get('Retry-After');
42
+ throw new RateLimitError('Rate limited', retryAfter ? Number.parseInt(retryAfter, 10) : undefined);
43
+ }
44
+
45
+ if (response.status === 401) throw new AuthError('Authentication failed', 401);
46
+ if (response.status === 403) throw new AuthError('Permission denied', 403);
47
+ if (!response.ok) throw new ApiError(`Failed to add label: ${await response.text()}`, response.status);
48
+ },
49
+ catch: (error) => {
50
+ if (error instanceof RateLimitError || error instanceof AuthError || error instanceof ApiError) {
51
+ return error;
52
+ }
53
+ return new NetworkError(`Network error: ${error}`);
54
+ },
55
+ });
56
+
57
+ return pipe(makeRequest, Effect.retry(rateLimitRetrySchedule));
58
+ }
59
+
60
+ /**
61
+ * Remove a label from a page (Effect version)
62
+ * Uses DELETE /wiki/rest/api/content/{pageId}/label/{labelName}
63
+ */
64
+ export function removeLabelEffect(
65
+ baseUrl: string,
66
+ authHeader: string,
67
+ pageId: string,
68
+ labelName: string,
69
+ ): Effect.Effect<void, ApiError | AuthError | NetworkError | RateLimitError> {
70
+ const url = `${baseUrl}/wiki/rest/api/content/${pageId}/label/${encodeURIComponent(labelName)}`;
71
+
72
+ const makeRequest = Effect.tryPromise({
73
+ try: async () => {
74
+ const response = await fetch(url, {
75
+ method: 'DELETE',
76
+ headers: {
77
+ Authorization: authHeader,
78
+ Accept: 'application/json',
79
+ },
80
+ });
81
+
82
+ if (response.status === 429) {
83
+ const retryAfter = response.headers.get('Retry-After');
84
+ throw new RateLimitError('Rate limited', retryAfter ? Number.parseInt(retryAfter, 10) : undefined);
85
+ }
86
+
87
+ if (response.status === 401) throw new AuthError('Authentication failed', 401);
88
+ if (response.status === 403) throw new AuthError('Permission denied', 403);
89
+ if (response.status !== 204 && !response.ok) {
90
+ throw new ApiError(`Failed to remove label: ${await response.text()}`, response.status);
91
+ }
92
+ },
93
+ catch: (error) => {
94
+ if (error instanceof RateLimitError || error instanceof AuthError || error instanceof ApiError) {
95
+ return error;
96
+ }
97
+ return new NetworkError(`Network error: ${error}`);
98
+ },
99
+ });
100
+
101
+ return pipe(makeRequest, Effect.retry(rateLimitRetrySchedule));
102
+ }
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Page mutation operations for Confluence
3
+ * Extracted from client.ts for file size management
4
+ */
5
+
6
+ import { Effect, pipe, Schedule, Schema } from 'effect';
7
+ import {
8
+ ApiError,
9
+ AuthError,
10
+ NetworkError,
11
+ PageNotFoundError,
12
+ RateLimitError,
13
+ VersionConflictError,
14
+ } from '../errors.js';
15
+ import {
16
+ PageSchema,
17
+ type CreatePageRequest,
18
+ type Page,
19
+ type UpdatePageRequest,
20
+ type VersionConflictResponse,
21
+ } from './types.js';
22
+
23
+ /**
24
+ * Retry configuration for rate limiting
25
+ */
26
+ const MAX_RETRIES = 5;
27
+ const BASE_DELAY_MS = 1000;
28
+ const rateLimitRetrySchedule = Schedule.exponential(BASE_DELAY_MS).pipe(
29
+ Schedule.jittered,
30
+ Schedule.whileInput((error: unknown) => error instanceof RateLimitError),
31
+ Schedule.upTo(MAX_RETRIES * BASE_DELAY_MS * 32),
32
+ );
33
+
34
+ /**
35
+ * Create a new page (Effect version)
36
+ */
37
+ export function createPageEffect(
38
+ baseUrl: string,
39
+ authHeader: string,
40
+ request: CreatePageRequest,
41
+ ): Effect.Effect<Page, ApiError | AuthError | NetworkError | RateLimitError> {
42
+ const url = `${baseUrl}/wiki/api/v2/pages`;
43
+
44
+ const makeRequest = Effect.tryPromise({
45
+ try: async () => {
46
+ const response = await fetch(url, {
47
+ method: 'POST',
48
+ headers: {
49
+ Authorization: authHeader,
50
+ Accept: 'application/json',
51
+ 'Content-Type': 'application/json',
52
+ },
53
+ body: JSON.stringify(request),
54
+ });
55
+
56
+ if (response.status === 429) {
57
+ const retryAfter = response.headers.get('Retry-After');
58
+ throw new RateLimitError(
59
+ 'Rate limited by Confluence API',
60
+ retryAfter ? Number.parseInt(retryAfter, 10) : undefined,
61
+ );
62
+ }
63
+
64
+ if (response.status === 401) {
65
+ throw new AuthError('Invalid credentials. Please check your email and API token.', 401);
66
+ }
67
+
68
+ if (response.status === 403) {
69
+ throw new AuthError('Access denied. Please check your permissions.', 403);
70
+ }
71
+
72
+ if (!response.ok) {
73
+ const errorText = await response.text();
74
+ throw new ApiError(`API request failed: ${response.status} ${errorText}`, response.status);
75
+ }
76
+
77
+ return response.json();
78
+ },
79
+ catch: (error) => {
80
+ if (error instanceof RateLimitError || error instanceof AuthError || error instanceof ApiError) {
81
+ return error;
82
+ }
83
+ return new NetworkError(`Network error: ${error}`);
84
+ },
85
+ });
86
+
87
+ return pipe(
88
+ makeRequest,
89
+ Effect.flatMap((data) =>
90
+ Schema.decodeUnknown(PageSchema)(data).pipe(Effect.mapError((e) => new ApiError(`Invalid response: ${e}`, 500))),
91
+ ),
92
+ Effect.retry(rateLimitRetrySchedule),
93
+ );
94
+ }
95
+
96
+ /**
97
+ * Update a page (Effect version)
98
+ * Uses PUT /wiki/api/v2/pages/{id} endpoint
99
+ */
100
+ export function updatePageEffect(
101
+ baseUrl: string,
102
+ authHeader: string,
103
+ request: UpdatePageRequest,
104
+ ): Effect.Effect<
105
+ Page,
106
+ ApiError | AuthError | NetworkError | RateLimitError | PageNotFoundError | VersionConflictError
107
+ > {
108
+ const url = `${baseUrl}/wiki/api/v2/pages/${request.id}`;
109
+
110
+ const makeRequest = Effect.tryPromise({
111
+ try: async () => {
112
+ const response = await fetch(url, {
113
+ method: 'PUT',
114
+ headers: {
115
+ Authorization: authHeader,
116
+ Accept: 'application/json',
117
+ 'Content-Type': 'application/json',
118
+ },
119
+ body: JSON.stringify(request),
120
+ });
121
+
122
+ if (response.status === 429) {
123
+ const retryAfter = response.headers.get('Retry-After');
124
+ throw new RateLimitError(
125
+ 'Rate limited by Confluence API',
126
+ retryAfter ? Number.parseInt(retryAfter, 10) : undefined,
127
+ );
128
+ }
129
+
130
+ if (response.status === 401) {
131
+ throw new AuthError('Invalid credentials. Please check your email and API token.', 401);
132
+ }
133
+
134
+ if (response.status === 403) {
135
+ throw new AuthError('Access denied. Please check your permissions.', 403);
136
+ }
137
+
138
+ if (response.status === 404) {
139
+ throw new PageNotFoundError(request.id);
140
+ }
141
+
142
+ if (response.status === 409) {
143
+ // Version conflict - the remote version has changed
144
+ const errorData: VersionConflictResponse = await response.json().catch(() => ({}));
145
+ const remoteVersion = errorData?.version?.number ?? 0;
146
+ throw new VersionConflictError(request.version.number, remoteVersion);
147
+ }
148
+
149
+ if (!response.ok) {
150
+ const errorText = await response.text();
151
+ throw new ApiError(`API request failed: ${response.status} ${errorText}`, response.status);
152
+ }
153
+
154
+ return response.json();
155
+ },
156
+ catch: (error) => {
157
+ if (
158
+ error instanceof RateLimitError ||
159
+ error instanceof AuthError ||
160
+ error instanceof ApiError ||
161
+ error instanceof PageNotFoundError ||
162
+ error instanceof VersionConflictError
163
+ ) {
164
+ return error;
165
+ }
166
+ return new NetworkError(`Network error: ${error}`);
167
+ },
168
+ });
169
+
170
+ return pipe(
171
+ makeRequest,
172
+ Effect.flatMap((data) =>
173
+ Schema.decodeUnknown(PageSchema)(data).pipe(Effect.mapError((e) => new ApiError(`Invalid response: ${e}`, 500))),
174
+ ),
175
+ Effect.retry(rateLimitRetrySchedule),
176
+ );
177
+ }
178
+
179
+ /**
180
+ * Set a content property on a page (Effect version)
181
+ */
182
+ export function setContentPropertyEffect(
183
+ baseUrl: string,
184
+ authHeader: string,
185
+ pageId: string,
186
+ key: string,
187
+ value: unknown,
188
+ ): Effect.Effect<void, ApiError | AuthError | NetworkError | RateLimitError> {
189
+ const url = `${baseUrl}/wiki/api/v2/pages/${pageId}/properties`;
190
+
191
+ const makeRequest = Effect.tryPromise({
192
+ try: async () => {
193
+ const response = await fetch(url, {
194
+ method: 'POST',
195
+ headers: {
196
+ Authorization: authHeader,
197
+ Accept: 'application/json',
198
+ 'Content-Type': 'application/json',
199
+ },
200
+ body: JSON.stringify({ key, value }),
201
+ });
202
+
203
+ if (response.status === 429) {
204
+ const retryAfter = response.headers.get('Retry-After');
205
+ throw new RateLimitError('Rate limited', retryAfter ? Number.parseInt(retryAfter, 10) : undefined);
206
+ }
207
+
208
+ if (response.status === 401) throw new AuthError('Authentication failed', 401);
209
+ if (response.status === 403) throw new AuthError('Permission denied', 403);
210
+ if (!response.ok) throw new ApiError(`Failed to set content property: ${await response.text()}`, response.status);
211
+ },
212
+ catch: (error) => {
213
+ if (error instanceof RateLimitError || error instanceof AuthError || error instanceof ApiError) {
214
+ return error;
215
+ }
216
+ return new NetworkError(`Network error: ${error}`);
217
+ },
218
+ });
219
+
220
+ return pipe(makeRequest, Effect.retry(rateLimitRetrySchedule));
221
+ }
222
+
223
+ /**
224
+ * Delete a page (Effect version)
225
+ * Uses DELETE /wiki/api/v2/pages/{id} endpoint
226
+ */
227
+ export function deletePageEffect(
228
+ baseUrl: string,
229
+ authHeader: string,
230
+ pageId: string,
231
+ ): Effect.Effect<void, ApiError | AuthError | NetworkError | RateLimitError | PageNotFoundError> {
232
+ const url = `${baseUrl}/wiki/api/v2/pages/${pageId}`;
233
+
234
+ const makeRequest = Effect.tryPromise({
235
+ try: async () => {
236
+ const response = await fetch(url, {
237
+ method: 'DELETE',
238
+ headers: {
239
+ Authorization: authHeader,
240
+ Accept: 'application/json',
241
+ },
242
+ });
243
+
244
+ if (response.status === 429) {
245
+ const retryAfter = response.headers.get('Retry-After');
246
+ throw new RateLimitError('Rate limited', retryAfter ? Number.parseInt(retryAfter, 10) : undefined);
247
+ }
248
+
249
+ if (response.status === 401) throw new AuthError('Authentication failed', 401);
250
+ if (response.status === 403) throw new AuthError('Permission denied', 403);
251
+ if (response.status === 404) throw new PageNotFoundError(pageId);
252
+ if (response.status !== 204 && !response.ok) {
253
+ throw new ApiError(`Failed to delete page: ${await response.text()}`, response.status);
254
+ }
255
+ },
256
+ catch: (error) => {
257
+ if (
258
+ error instanceof RateLimitError ||
259
+ error instanceof AuthError ||
260
+ error instanceof ApiError ||
261
+ error instanceof PageNotFoundError
262
+ ) {
263
+ return error;
264
+ }
265
+ return new NetworkError(`Network error: ${error}`);
266
+ },
267
+ });
268
+
269
+ return pipe(makeRequest, Effect.retry(rateLimitRetrySchedule));
270
+ }