@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.
- package/LICENSE +21 -0
- package/README.md +69 -0
- package/package.json +73 -0
- package/src/cli/commands/attachments.ts +113 -0
- package/src/cli/commands/clone.ts +188 -0
- package/src/cli/commands/comments.ts +56 -0
- package/src/cli/commands/create.ts +58 -0
- package/src/cli/commands/delete.ts +46 -0
- package/src/cli/commands/doctor.ts +161 -0
- package/src/cli/commands/duplicate-check.ts +89 -0
- package/src/cli/commands/file-rename.ts +113 -0
- package/src/cli/commands/folder-hierarchy.ts +241 -0
- package/src/cli/commands/info.ts +56 -0
- package/src/cli/commands/labels.ts +53 -0
- package/src/cli/commands/move.ts +23 -0
- package/src/cli/commands/open.ts +145 -0
- package/src/cli/commands/pull.ts +241 -0
- package/src/cli/commands/push-errors.ts +40 -0
- package/src/cli/commands/push.ts +699 -0
- package/src/cli/commands/search.ts +62 -0
- package/src/cli/commands/setup.ts +124 -0
- package/src/cli/commands/spaces.ts +42 -0
- package/src/cli/commands/status.ts +88 -0
- package/src/cli/commands/tree.ts +190 -0
- package/src/cli/help.ts +425 -0
- package/src/cli/index.ts +413 -0
- package/src/cli/utils/browser.ts +34 -0
- package/src/cli/utils/progress-reporter.ts +49 -0
- package/src/cli.ts +6 -0
- package/src/lib/config.ts +156 -0
- package/src/lib/confluence-client/attachment-operations.ts +221 -0
- package/src/lib/confluence-client/client.ts +653 -0
- package/src/lib/confluence-client/comment-operations.ts +60 -0
- package/src/lib/confluence-client/folder-operations.ts +203 -0
- package/src/lib/confluence-client/index.ts +47 -0
- package/src/lib/confluence-client/label-operations.ts +102 -0
- package/src/lib/confluence-client/page-operations.ts +270 -0
- package/src/lib/confluence-client/search-operations.ts +60 -0
- package/src/lib/confluence-client/types.ts +329 -0
- package/src/lib/confluence-client/user-operations.ts +58 -0
- package/src/lib/dependency-sorter.ts +233 -0
- package/src/lib/errors.ts +237 -0
- package/src/lib/file-scanner.ts +195 -0
- package/src/lib/formatters.ts +314 -0
- package/src/lib/health-check.ts +204 -0
- package/src/lib/markdown/converter.ts +427 -0
- package/src/lib/markdown/frontmatter.ts +116 -0
- package/src/lib/markdown/html-converter.ts +398 -0
- package/src/lib/markdown/index.ts +21 -0
- package/src/lib/markdown/link-converter.ts +189 -0
- package/src/lib/markdown/reference-updater.ts +251 -0
- package/src/lib/markdown/slugify.ts +32 -0
- package/src/lib/page-state.ts +195 -0
- package/src/lib/resolve-page-target.ts +33 -0
- package/src/lib/space-config.ts +264 -0
- package/src/lib/sync/cleanup.ts +50 -0
- package/src/lib/sync/folder-path.ts +61 -0
- package/src/lib/sync/index.ts +2 -0
- package/src/lib/sync/link-resolution-pass.ts +139 -0
- package/src/lib/sync/sync-engine.ts +681 -0
- package/src/lib/sync/sync-specific.ts +221 -0
- package/src/lib/sync/types.ts +42 -0
- package/src/test/attachments.test.ts +68 -0
- package/src/test/clone.test.ts +373 -0
- package/src/test/comments.test.ts +53 -0
- package/src/test/config.test.ts +209 -0
- package/src/test/confluence-client.test.ts +535 -0
- package/src/test/delete.test.ts +39 -0
- package/src/test/dependency-sorter.test.ts +384 -0
- package/src/test/errors.test.ts +199 -0
- package/src/test/file-rename.test.ts +305 -0
- package/src/test/file-scanner.test.ts +331 -0
- package/src/test/folder-hierarchy.test.ts +337 -0
- package/src/test/formatters.test.ts +213 -0
- package/src/test/html-converter.test.ts +399 -0
- package/src/test/info.test.ts +56 -0
- package/src/test/labels.test.ts +70 -0
- package/src/test/link-conversion-integration.test.ts +189 -0
- package/src/test/link-converter.test.ts +413 -0
- package/src/test/link-resolution-pass.test.ts +368 -0
- package/src/test/markdown.test.ts +443 -0
- package/src/test/mocks/handlers.ts +228 -0
- package/src/test/move.test.ts +53 -0
- package/src/test/msw-schema-validation.ts +151 -0
- package/src/test/page-state.test.ts +542 -0
- package/src/test/push.test.ts +551 -0
- package/src/test/reference-updater.test.ts +293 -0
- package/src/test/resolve-page-target.test.ts +55 -0
- package/src/test/search.test.ts +64 -0
- package/src/test/setup-msw.ts +75 -0
- package/src/test/space-config.test.ts +516 -0
- package/src/test/spaces.test.ts +53 -0
- package/src/test/sync-engine.test.ts +486 -0
- package/src/types/turndown-plugin-gfm.d.ts +9 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attachment operations for Confluence pages
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { Effect, pipe, Schedule, Schema } from 'effect';
|
|
6
|
+
import { ApiError, AuthError, NetworkError, RateLimitError } from '../errors.js';
|
|
7
|
+
import { AttachmentsResponseSchema, type Attachment, type AttachmentsResponse } from './types.js';
|
|
8
|
+
|
|
9
|
+
const MAX_RETRIES = 5;
|
|
10
|
+
const BASE_DELAY_MS = 1000;
|
|
11
|
+
const rateLimitRetrySchedule = Schedule.exponential(BASE_DELAY_MS).pipe(
|
|
12
|
+
Schedule.jittered,
|
|
13
|
+
Schedule.whileInput((error: unknown) => error instanceof RateLimitError),
|
|
14
|
+
Schedule.upTo(MAX_RETRIES * BASE_DELAY_MS * 32),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get attachments for a page (Effect version)
|
|
19
|
+
* Uses GET /wiki/api/v2/pages/{pageId}/attachments
|
|
20
|
+
*/
|
|
21
|
+
export function getAttachmentsEffect(
|
|
22
|
+
baseUrl: string,
|
|
23
|
+
authHeader: string,
|
|
24
|
+
pageId: string,
|
|
25
|
+
): Effect.Effect<AttachmentsResponse, ApiError | AuthError | NetworkError | RateLimitError> {
|
|
26
|
+
const url = `${baseUrl}/wiki/api/v2/pages/${pageId}/attachments`;
|
|
27
|
+
|
|
28
|
+
const makeRequest = Effect.tryPromise({
|
|
29
|
+
try: async () => {
|
|
30
|
+
const response = await fetch(url, {
|
|
31
|
+
headers: {
|
|
32
|
+
Authorization: authHeader,
|
|
33
|
+
Accept: 'application/json',
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (response.status === 429) {
|
|
38
|
+
const retryAfter = response.headers.get('Retry-After');
|
|
39
|
+
throw new RateLimitError('Rate limited', retryAfter ? Number.parseInt(retryAfter, 10) : undefined);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (response.status === 401) throw new AuthError('Authentication failed', 401);
|
|
43
|
+
if (response.status === 403) throw new AuthError('Permission denied', 403);
|
|
44
|
+
if (!response.ok) throw new ApiError(`Failed to get attachments: ${await response.text()}`, response.status);
|
|
45
|
+
|
|
46
|
+
return response.json();
|
|
47
|
+
},
|
|
48
|
+
catch: (error) => {
|
|
49
|
+
if (error instanceof RateLimitError || error instanceof AuthError || error instanceof ApiError) {
|
|
50
|
+
return error;
|
|
51
|
+
}
|
|
52
|
+
return new NetworkError(`Network error: ${error}`);
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return pipe(
|
|
57
|
+
makeRequest,
|
|
58
|
+
Effect.flatMap((data) =>
|
|
59
|
+
Schema.decodeUnknown(AttachmentsResponseSchema)(data).pipe(
|
|
60
|
+
Effect.mapError((e) => new ApiError(`Invalid response: ${e}`, 500)),
|
|
61
|
+
),
|
|
62
|
+
),
|
|
63
|
+
Effect.retry(rateLimitRetrySchedule),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Upload an attachment to a page (Effect version)
|
|
69
|
+
* Uses POST /wiki/rest/api/content/{pageId}/child/attachment
|
|
70
|
+
* Requires X-Atlassian-Token: no-check header
|
|
71
|
+
*/
|
|
72
|
+
export function uploadAttachmentEffect(
|
|
73
|
+
baseUrl: string,
|
|
74
|
+
authHeader: string,
|
|
75
|
+
pageId: string,
|
|
76
|
+
filename: string,
|
|
77
|
+
data: Buffer,
|
|
78
|
+
mimeType: string,
|
|
79
|
+
): Effect.Effect<void, ApiError | AuthError | NetworkError | RateLimitError> {
|
|
80
|
+
const url = `${baseUrl}/wiki/rest/api/content/${pageId}/child/attachment`;
|
|
81
|
+
|
|
82
|
+
const makeRequest = Effect.tryPromise({
|
|
83
|
+
try: async () => {
|
|
84
|
+
const formData = new FormData();
|
|
85
|
+
const arrayBuffer = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer;
|
|
86
|
+
const blob = new Blob([arrayBuffer], { type: mimeType });
|
|
87
|
+
formData.append('file', blob, filename);
|
|
88
|
+
|
|
89
|
+
const response = await fetch(url, {
|
|
90
|
+
method: 'POST',
|
|
91
|
+
headers: {
|
|
92
|
+
Authorization: authHeader,
|
|
93
|
+
Accept: 'application/json',
|
|
94
|
+
'X-Atlassian-Token': 'no-check',
|
|
95
|
+
},
|
|
96
|
+
body: formData,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
if (response.status === 429) {
|
|
100
|
+
const retryAfter = response.headers.get('Retry-After');
|
|
101
|
+
throw new RateLimitError('Rate limited', retryAfter ? Number.parseInt(retryAfter, 10) : undefined);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (response.status === 401) throw new AuthError('Authentication failed', 401);
|
|
105
|
+
if (response.status === 403) throw new AuthError('Permission denied', 403);
|
|
106
|
+
if (!response.ok) throw new ApiError(`Failed to upload attachment: ${await response.text()}`, response.status);
|
|
107
|
+
},
|
|
108
|
+
catch: (error) => {
|
|
109
|
+
if (error instanceof RateLimitError || error instanceof AuthError || error instanceof ApiError) {
|
|
110
|
+
return error;
|
|
111
|
+
}
|
|
112
|
+
return new NetworkError(`Network error: ${error}`);
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return pipe(makeRequest, Effect.retry(rateLimitRetrySchedule));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Extract cursor from a Confluence API next-page link.
|
|
121
|
+
*/
|
|
122
|
+
function extractCursor(nextLink: string | undefined): string | undefined {
|
|
123
|
+
if (!nextLink) return undefined;
|
|
124
|
+
try {
|
|
125
|
+
const url = new URL(nextLink, 'https://placeholder.invalid');
|
|
126
|
+
return url.searchParams.get('cursor') ?? undefined;
|
|
127
|
+
} catch {
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Get all attachments for a page with cursor pagination (async version).
|
|
134
|
+
*/
|
|
135
|
+
export async function getAllAttachments(baseUrl: string, authHeader: string, pageId: string): Promise<Attachment[]> {
|
|
136
|
+
const allAttachments: Attachment[] = [];
|
|
137
|
+
let cursor: string | undefined;
|
|
138
|
+
do {
|
|
139
|
+
let path = `/pages/${pageId}/attachments?limit=100`;
|
|
140
|
+
if (cursor) path += `&cursor=${encodeURIComponent(cursor)}`;
|
|
141
|
+
const url = `${baseUrl}/wiki/api/v2${path}`;
|
|
142
|
+
const response = await fetch(url, {
|
|
143
|
+
headers: { Authorization: authHeader, Accept: 'application/json' },
|
|
144
|
+
});
|
|
145
|
+
if (!response.ok) throw new ApiError(`Request failed: ${response.status}`, response.status);
|
|
146
|
+
const data = Schema.decodeUnknownSync(AttachmentsResponseSchema)(await response.json());
|
|
147
|
+
allAttachments.push(...data.results);
|
|
148
|
+
cursor = extractCursor(data._links?.next);
|
|
149
|
+
} while (cursor);
|
|
150
|
+
return allAttachments;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Download an attachment by its download link (async version).
|
|
155
|
+
*/
|
|
156
|
+
export async function downloadAttachment(baseUrl: string, authHeader: string, downloadLink: string): Promise<Buffer> {
|
|
157
|
+
return Effect.runPromise(
|
|
158
|
+
Effect.retry(
|
|
159
|
+
Effect.tryPromise({
|
|
160
|
+
try: async () => {
|
|
161
|
+
const url = `${baseUrl}${downloadLink}`;
|
|
162
|
+
const response = await fetch(url, { headers: { Authorization: authHeader } });
|
|
163
|
+
if (response.status === 429) {
|
|
164
|
+
const retryAfter = response.headers.get('Retry-After');
|
|
165
|
+
throw new RateLimitError('Rate limited', retryAfter ? Number.parseInt(retryAfter, 10) : undefined);
|
|
166
|
+
}
|
|
167
|
+
if (response.status === 401) throw new AuthError('Invalid credentials.', 401);
|
|
168
|
+
if (!response.ok) throw new ApiError(`Download failed: ${response.status}`, response.status);
|
|
169
|
+
return Buffer.from(await response.arrayBuffer());
|
|
170
|
+
},
|
|
171
|
+
catch: (e) =>
|
|
172
|
+
e instanceof RateLimitError || e instanceof AuthError || e instanceof ApiError
|
|
173
|
+
? e
|
|
174
|
+
: new NetworkError(String(e)),
|
|
175
|
+
}),
|
|
176
|
+
rateLimitRetrySchedule,
|
|
177
|
+
),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Delete an attachment (Effect version)
|
|
182
|
+
* Uses DELETE /wiki/api/v2/attachments/{attachmentId}
|
|
183
|
+
*/
|
|
184
|
+
export function deleteAttachmentEffect(
|
|
185
|
+
baseUrl: string,
|
|
186
|
+
authHeader: string,
|
|
187
|
+
attachmentId: string,
|
|
188
|
+
): Effect.Effect<void, ApiError | AuthError | NetworkError | RateLimitError> {
|
|
189
|
+
const url = `${baseUrl}/wiki/api/v2/attachments/${attachmentId}`;
|
|
190
|
+
|
|
191
|
+
const makeRequest = Effect.tryPromise({
|
|
192
|
+
try: async () => {
|
|
193
|
+
const response = await fetch(url, {
|
|
194
|
+
method: 'DELETE',
|
|
195
|
+
headers: {
|
|
196
|
+
Authorization: authHeader,
|
|
197
|
+
Accept: 'application/json',
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
if (response.status === 429) {
|
|
202
|
+
const retryAfter = response.headers.get('Retry-After');
|
|
203
|
+
throw new RateLimitError('Rate limited', retryAfter ? Number.parseInt(retryAfter, 10) : undefined);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (response.status === 401) throw new AuthError('Authentication failed', 401);
|
|
207
|
+
if (response.status === 403) throw new AuthError('Permission denied', 403);
|
|
208
|
+
if (response.status !== 204 && !response.ok) {
|
|
209
|
+
throw new ApiError(`Failed to delete attachment: ${await response.text()}`, response.status);
|
|
210
|
+
}
|
|
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
|
+
}
|