@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
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { EXIT_CODES } from '../lib/errors.js';
|
|
7
|
+
import {
|
|
8
|
+
showAttachmentsHelp,
|
|
9
|
+
showCloneHelp,
|
|
10
|
+
showCommentsHelp,
|
|
11
|
+
showCreateHelp,
|
|
12
|
+
showDeleteHelp,
|
|
13
|
+
showDoctorHelp,
|
|
14
|
+
showHelp,
|
|
15
|
+
showInfoHelp,
|
|
16
|
+
showLabelsHelp,
|
|
17
|
+
showMoveHelp,
|
|
18
|
+
showOpenHelp,
|
|
19
|
+
showPullHelp,
|
|
20
|
+
showPushHelp,
|
|
21
|
+
showSearchHelp,
|
|
22
|
+
showSetupHelp,
|
|
23
|
+
showSpacesHelp,
|
|
24
|
+
showStatusHelp,
|
|
25
|
+
showTreeHelp,
|
|
26
|
+
} from './help.js';
|
|
27
|
+
import { attachmentsCommand } from './commands/attachments.js';
|
|
28
|
+
import { cloneCommand } from './commands/clone.js';
|
|
29
|
+
import { commentsCommand } from './commands/comments.js';
|
|
30
|
+
import { createCommand } from './commands/create.js';
|
|
31
|
+
import { deleteCommand } from './commands/delete.js';
|
|
32
|
+
import { doctorCommand } from './commands/doctor.js';
|
|
33
|
+
import { infoCommand } from './commands/info.js';
|
|
34
|
+
import { labelsCommand } from './commands/labels.js';
|
|
35
|
+
import { moveCommand } from './commands/move.js';
|
|
36
|
+
import { openCommand } from './commands/open.js';
|
|
37
|
+
import { pullCommand } from './commands/pull.js';
|
|
38
|
+
import { pushCommand } from './commands/push.js';
|
|
39
|
+
import { searchCommand } from './commands/search.js';
|
|
40
|
+
import { setup } from './commands/setup.js';
|
|
41
|
+
import { spacesCommand } from './commands/spaces.js';
|
|
42
|
+
import { statusCommand } from './commands/status.js';
|
|
43
|
+
import { treeCommand } from './commands/tree.js';
|
|
44
|
+
|
|
45
|
+
// Get version from package.json
|
|
46
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
47
|
+
const __dirname = dirname(__filename);
|
|
48
|
+
const packageJsonPath = join(__dirname, '..', '..', 'package.json');
|
|
49
|
+
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
50
|
+
const VERSION = packageJson.version;
|
|
51
|
+
|
|
52
|
+
async function main(): Promise<void> {
|
|
53
|
+
const args = process.argv.slice(2);
|
|
54
|
+
|
|
55
|
+
// Handle no arguments or help
|
|
56
|
+
if (args.length === 0 || args[0] === 'help' || args[0] === '--help' || args[0] === '-h') {
|
|
57
|
+
showHelp();
|
|
58
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Handle version
|
|
62
|
+
if (args[0] === '--version' || args[0] === '-v') {
|
|
63
|
+
console.log(`cn version ${VERSION}`);
|
|
64
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const command = args[0];
|
|
68
|
+
const subArgs = args.slice(1);
|
|
69
|
+
|
|
70
|
+
// Check for verbose mode
|
|
71
|
+
const verbose = args.includes('--verbose');
|
|
72
|
+
if (verbose && process.env.CN_DEBUG !== '1') {
|
|
73
|
+
process.env.CN_DEBUG = '1';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
switch (command) {
|
|
78
|
+
case 'setup':
|
|
79
|
+
if (args.includes('--help')) {
|
|
80
|
+
showSetupHelp();
|
|
81
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
82
|
+
}
|
|
83
|
+
await setup();
|
|
84
|
+
break;
|
|
85
|
+
|
|
86
|
+
case 'clone': {
|
|
87
|
+
if (args.includes('--help')) {
|
|
88
|
+
showCloneHelp();
|
|
89
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Get space keys (all non-flag arguments after 'clone')
|
|
93
|
+
const spaceKeys = subArgs.filter((arg) => !arg.startsWith('--'));
|
|
94
|
+
if (spaceKeys.length === 0) {
|
|
95
|
+
console.error(chalk.red('At least one space key is required.'));
|
|
96
|
+
console.log(chalk.gray('Usage: cn clone <SPACE_KEY> [SPACE_KEY...]'));
|
|
97
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await cloneCommand({ spaceKeys });
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
case 'pull': {
|
|
105
|
+
if (args.includes('--help')) {
|
|
106
|
+
showPullHelp();
|
|
107
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const dryRun = args.includes('--dry-run');
|
|
111
|
+
const force = args.includes('--force');
|
|
112
|
+
|
|
113
|
+
let depth: number | undefined;
|
|
114
|
+
const depthIndex = args.indexOf('--depth');
|
|
115
|
+
if (depthIndex !== -1 && depthIndex + 1 < args.length) {
|
|
116
|
+
depth = Number.parseInt(args[depthIndex + 1], 10);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Collect all --page arguments (can appear multiple times)
|
|
120
|
+
const pages: string[] = [];
|
|
121
|
+
for (let i = 0; i < args.length; i++) {
|
|
122
|
+
if (args[i] === '--page' && i + 1 < args.length) {
|
|
123
|
+
pages.push(args[i + 1]);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
await pullCommand({ dryRun, force, depth, pages: pages.length > 0 ? pages : undefined });
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
case 'push': {
|
|
132
|
+
if (args.includes('--help')) {
|
|
133
|
+
showPushHelp();
|
|
134
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
135
|
+
}
|
|
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
|
+
});
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
case 'status':
|
|
149
|
+
if (args.includes('--help')) {
|
|
150
|
+
showStatusHelp();
|
|
151
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
152
|
+
}
|
|
153
|
+
await statusCommand({ xml: args.includes('--xml') });
|
|
154
|
+
break;
|
|
155
|
+
|
|
156
|
+
case 'tree': {
|
|
157
|
+
if (args.includes('--help')) {
|
|
158
|
+
showTreeHelp();
|
|
159
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Find space key (first non-flag argument)
|
|
163
|
+
const spaceKey = subArgs.find((arg) => !arg.startsWith('--'));
|
|
164
|
+
|
|
165
|
+
let depth: number | undefined;
|
|
166
|
+
const depthIndex = args.indexOf('--depth');
|
|
167
|
+
if (depthIndex !== -1 && depthIndex + 1 < args.length) {
|
|
168
|
+
depth = Number.parseInt(args[depthIndex + 1], 10);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
await treeCommand({
|
|
172
|
+
spaceKey,
|
|
173
|
+
remote: args.includes('--remote') || !args.includes('--local'),
|
|
174
|
+
depth,
|
|
175
|
+
xml: args.includes('--xml'),
|
|
176
|
+
});
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
case 'open': {
|
|
181
|
+
if (args.includes('--help')) {
|
|
182
|
+
showOpenHelp();
|
|
183
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Find page argument (first non-flag argument)
|
|
187
|
+
const page = subArgs.find((arg) => !arg.startsWith('--'));
|
|
188
|
+
|
|
189
|
+
let spaceKey: string | undefined;
|
|
190
|
+
const spaceIndex = args.indexOf('--space');
|
|
191
|
+
if (spaceIndex !== -1 && spaceIndex + 1 < args.length) {
|
|
192
|
+
spaceKey = args[spaceIndex + 1];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
await openCommand({ page, spaceKey });
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
case 'doctor':
|
|
200
|
+
if (args.includes('--help')) {
|
|
201
|
+
showDoctorHelp();
|
|
202
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
203
|
+
}
|
|
204
|
+
await doctorCommand({
|
|
205
|
+
fix: args.includes('--fix'),
|
|
206
|
+
xml: args.includes('--xml'),
|
|
207
|
+
});
|
|
208
|
+
break;
|
|
209
|
+
|
|
210
|
+
case 'spaces':
|
|
211
|
+
if (args.includes('--help')) {
|
|
212
|
+
showSpacesHelp();
|
|
213
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
214
|
+
}
|
|
215
|
+
await spacesCommand({ xml: args.includes('--xml') });
|
|
216
|
+
break;
|
|
217
|
+
|
|
218
|
+
case 'search': {
|
|
219
|
+
if (args.includes('--help')) {
|
|
220
|
+
showSearchHelp();
|
|
221
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
222
|
+
}
|
|
223
|
+
let spaceKey: string | undefined;
|
|
224
|
+
const spaceIdx = args.indexOf('--space');
|
|
225
|
+
if (spaceIdx !== -1 && spaceIdx + 1 < args.length) {
|
|
226
|
+
spaceKey = args[spaceIdx + 1];
|
|
227
|
+
}
|
|
228
|
+
let limit: number | undefined;
|
|
229
|
+
const limitIdx = args.indexOf('--limit');
|
|
230
|
+
if (limitIdx !== -1 && limitIdx + 1 < args.length) {
|
|
231
|
+
limit = Number.parseInt(args[limitIdx + 1], 10);
|
|
232
|
+
}
|
|
233
|
+
const searchFlagValues = new Set(
|
|
234
|
+
[spaceKey, limit !== undefined ? args[limitIdx + 1] : undefined].filter(Boolean),
|
|
235
|
+
);
|
|
236
|
+
const query = subArgs.find((arg) => !arg.startsWith('--') && !searchFlagValues.has(arg));
|
|
237
|
+
if (!query) {
|
|
238
|
+
console.error(chalk.red('Search query is required.'));
|
|
239
|
+
console.log(chalk.gray('Usage: cn search <query>'));
|
|
240
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
241
|
+
}
|
|
242
|
+
await searchCommand(query, { space: spaceKey, limit, xml: args.includes('--xml') });
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
case 'info': {
|
|
247
|
+
if (args.includes('--help')) {
|
|
248
|
+
showInfoHelp();
|
|
249
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
250
|
+
}
|
|
251
|
+
const target = subArgs.find((arg) => !arg.startsWith('--'));
|
|
252
|
+
if (!target) {
|
|
253
|
+
console.error(chalk.red('Page ID or file path is required.'));
|
|
254
|
+
console.log(chalk.gray('Usage: cn info <id|file>'));
|
|
255
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
256
|
+
}
|
|
257
|
+
await infoCommand(target, { xml: args.includes('--xml') });
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
case 'create': {
|
|
262
|
+
if (args.includes('--help')) {
|
|
263
|
+
showCreateHelp();
|
|
264
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
265
|
+
}
|
|
266
|
+
let spaceKey: string | undefined;
|
|
267
|
+
const spaceIdx = args.indexOf('--space');
|
|
268
|
+
if (spaceIdx !== -1 && spaceIdx + 1 < args.length) {
|
|
269
|
+
spaceKey = args[spaceIdx + 1];
|
|
270
|
+
}
|
|
271
|
+
let parentId: string | undefined;
|
|
272
|
+
const parentIdx = args.indexOf('--parent');
|
|
273
|
+
if (parentIdx !== -1 && parentIdx + 1 < args.length) {
|
|
274
|
+
parentId = args[parentIdx + 1];
|
|
275
|
+
}
|
|
276
|
+
const createFlagValues = new Set([spaceKey, parentId].filter(Boolean));
|
|
277
|
+
const title = subArgs.find((arg) => !arg.startsWith('--') && !createFlagValues.has(arg));
|
|
278
|
+
if (!title) {
|
|
279
|
+
console.error(chalk.red('Page title is required.'));
|
|
280
|
+
console.log(chalk.gray('Usage: cn create <title>'));
|
|
281
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
282
|
+
}
|
|
283
|
+
await createCommand(title, {
|
|
284
|
+
space: spaceKey,
|
|
285
|
+
parent: parentId,
|
|
286
|
+
open: args.includes('--open'),
|
|
287
|
+
});
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
case 'delete': {
|
|
292
|
+
if (args.includes('--help')) {
|
|
293
|
+
showDeleteHelp();
|
|
294
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
295
|
+
}
|
|
296
|
+
const pageId = subArgs.find((arg) => !arg.startsWith('--'));
|
|
297
|
+
if (!pageId) {
|
|
298
|
+
console.error(chalk.red('Page ID is required.'));
|
|
299
|
+
console.log(chalk.gray('Usage: cn delete <id>'));
|
|
300
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
301
|
+
}
|
|
302
|
+
await deleteCommand(pageId, { force: args.includes('--force') });
|
|
303
|
+
break;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
case 'comments': {
|
|
307
|
+
if (args.includes('--help')) {
|
|
308
|
+
showCommentsHelp();
|
|
309
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
310
|
+
}
|
|
311
|
+
const target = subArgs.find((arg) => !arg.startsWith('--'));
|
|
312
|
+
if (!target) {
|
|
313
|
+
console.error(chalk.red('Page ID or file path is required.'));
|
|
314
|
+
console.log(chalk.gray('Usage: cn comments <id|file>'));
|
|
315
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
316
|
+
}
|
|
317
|
+
await commentsCommand(target, { xml: args.includes('--xml') });
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
case 'labels': {
|
|
322
|
+
if (args.includes('--help')) {
|
|
323
|
+
showLabelsHelp();
|
|
324
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
325
|
+
}
|
|
326
|
+
let addLabel: string | undefined;
|
|
327
|
+
const addIdx = args.indexOf('--add');
|
|
328
|
+
if (addIdx !== -1 && addIdx + 1 < args.length) {
|
|
329
|
+
addLabel = args[addIdx + 1];
|
|
330
|
+
}
|
|
331
|
+
let removeLabel: string | undefined;
|
|
332
|
+
const removeIdx = args.indexOf('--remove');
|
|
333
|
+
if (removeIdx !== -1 && removeIdx + 1 < args.length) {
|
|
334
|
+
removeLabel = args[removeIdx + 1];
|
|
335
|
+
}
|
|
336
|
+
const labelFlagValues = new Set([addLabel, removeLabel].filter(Boolean));
|
|
337
|
+
const labelsTarget = subArgs.find((arg) => !arg.startsWith('--') && !labelFlagValues.has(arg));
|
|
338
|
+
if (!labelsTarget) {
|
|
339
|
+
console.error(chalk.red('Page ID or file path is required.'));
|
|
340
|
+
console.log(chalk.gray('Usage: cn labels <id|file>'));
|
|
341
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
342
|
+
}
|
|
343
|
+
await labelsCommand(labelsTarget, { add: addLabel, remove: removeLabel, xml: args.includes('--xml') });
|
|
344
|
+
break;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
case 'move': {
|
|
348
|
+
if (args.includes('--help')) {
|
|
349
|
+
showMoveHelp();
|
|
350
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
351
|
+
}
|
|
352
|
+
const nonFlags = subArgs.filter((arg) => !arg.startsWith('--'));
|
|
353
|
+
if (nonFlags.length < 2) {
|
|
354
|
+
console.error(chalk.red('Page target and parent ID are required.'));
|
|
355
|
+
console.log(chalk.gray('Usage: cn move <id|file> <parentId>'));
|
|
356
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
357
|
+
}
|
|
358
|
+
await moveCommand(nonFlags[0], nonFlags[1]);
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
case 'attachments': {
|
|
363
|
+
if (args.includes('--help')) {
|
|
364
|
+
showAttachmentsHelp();
|
|
365
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
366
|
+
}
|
|
367
|
+
let uploadFile: string | undefined;
|
|
368
|
+
const uploadIdx = args.indexOf('--upload');
|
|
369
|
+
if (uploadIdx !== -1 && uploadIdx + 1 < args.length) {
|
|
370
|
+
uploadFile = args[uploadIdx + 1];
|
|
371
|
+
}
|
|
372
|
+
let downloadId: string | undefined;
|
|
373
|
+
const downloadIdx = args.indexOf('--download');
|
|
374
|
+
if (downloadIdx !== -1 && downloadIdx + 1 < args.length) {
|
|
375
|
+
downloadId = args[downloadIdx + 1];
|
|
376
|
+
}
|
|
377
|
+
let deleteId: string | undefined;
|
|
378
|
+
const deleteIdx = args.indexOf('--delete');
|
|
379
|
+
if (deleteIdx !== -1 && deleteIdx + 1 < args.length) {
|
|
380
|
+
deleteId = args[deleteIdx + 1];
|
|
381
|
+
}
|
|
382
|
+
const attachFlagValues = new Set([uploadFile, downloadId, deleteId].filter(Boolean));
|
|
383
|
+
const attachTarget = subArgs.find((arg) => !arg.startsWith('--') && !attachFlagValues.has(arg));
|
|
384
|
+
if (!attachTarget) {
|
|
385
|
+
console.error(chalk.red('Page ID or file path is required.'));
|
|
386
|
+
console.log(chalk.gray('Usage: cn attachments <id|file>'));
|
|
387
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
388
|
+
}
|
|
389
|
+
await attachmentsCommand(attachTarget, {
|
|
390
|
+
upload: uploadFile,
|
|
391
|
+
download: downloadId,
|
|
392
|
+
delete: deleteId,
|
|
393
|
+
xml: args.includes('--xml'),
|
|
394
|
+
});
|
|
395
|
+
break;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
default:
|
|
399
|
+
console.error(`Unknown command: ${command}`);
|
|
400
|
+
console.log('Run "cn help" for usage information');
|
|
401
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
402
|
+
}
|
|
403
|
+
} catch (error) {
|
|
404
|
+
console.error('Error:', error instanceof Error ? error.message : 'Unknown error');
|
|
405
|
+
process.exit(EXIT_CODES.GENERAL_ERROR);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Run the CLI
|
|
410
|
+
main().catch((error) => {
|
|
411
|
+
console.error('Fatal error:', error);
|
|
412
|
+
process.exit(EXIT_CODES.GENERAL_ERROR);
|
|
413
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { platform } from 'node:os';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Open a URL in the default browser.
|
|
7
|
+
* Uses spawn with arguments array to prevent command injection.
|
|
8
|
+
*/
|
|
9
|
+
export function openUrl(url: string): void {
|
|
10
|
+
const os = platform();
|
|
11
|
+
let command: string;
|
|
12
|
+
let args: string[];
|
|
13
|
+
|
|
14
|
+
switch (os) {
|
|
15
|
+
case 'darwin':
|
|
16
|
+
command = 'open';
|
|
17
|
+
args = [url];
|
|
18
|
+
break;
|
|
19
|
+
case 'win32':
|
|
20
|
+
command = 'cmd';
|
|
21
|
+
args = ['/c', 'start', '', url];
|
|
22
|
+
break;
|
|
23
|
+
default:
|
|
24
|
+
command = 'xdg-open';
|
|
25
|
+
args = [url];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const child = spawn(command, args, { stdio: 'ignore', detached: true });
|
|
29
|
+
child.on('error', (error) => {
|
|
30
|
+
console.error(chalk.red(`Failed to open browser: ${error.message}`));
|
|
31
|
+
console.log(chalk.gray(`URL: ${url}`));
|
|
32
|
+
});
|
|
33
|
+
child.unref();
|
|
34
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora, { type Ora } from 'ora';
|
|
3
|
+
import type { SyncProgressReporter } from '../../lib/sync/index.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create a progress reporter for sync operations (pull/clone)
|
|
7
|
+
*/
|
|
8
|
+
export function createProgressReporter(): SyncProgressReporter {
|
|
9
|
+
let spinner: Ora | undefined;
|
|
10
|
+
|
|
11
|
+
return {
|
|
12
|
+
onFetchStart: () => {
|
|
13
|
+
spinner = ora({
|
|
14
|
+
text: 'Fetching pages from Confluence...',
|
|
15
|
+
hideCursor: false,
|
|
16
|
+
discardStdin: false,
|
|
17
|
+
}).start();
|
|
18
|
+
},
|
|
19
|
+
onFetchComplete: (pageCount, folderCount) => {
|
|
20
|
+
const folderText = folderCount > 0 ? ` and ${folderCount} folders` : '';
|
|
21
|
+
spinner?.succeed(`Found ${pageCount} pages${folderText}`);
|
|
22
|
+
spinner = undefined;
|
|
23
|
+
},
|
|
24
|
+
onDiffComplete: (added, modified, deleted) => {
|
|
25
|
+
const total = added + modified + deleted;
|
|
26
|
+
if (total === 0) {
|
|
27
|
+
console.log(chalk.green(' Already up to date'));
|
|
28
|
+
} else {
|
|
29
|
+
const parts = [];
|
|
30
|
+
if (added > 0) parts.push(chalk.green(`${added} new`));
|
|
31
|
+
if (modified > 0) parts.push(chalk.yellow(`${modified} modified`));
|
|
32
|
+
if (deleted > 0) parts.push(chalk.red(`${deleted} deleted`));
|
|
33
|
+
console.log(` ${parts.join(', ')}`);
|
|
34
|
+
console.log('');
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
onPageStart: (_index, _total, _title, _type) => {
|
|
38
|
+
// No-op - we show progress on complete only
|
|
39
|
+
},
|
|
40
|
+
onPageComplete: (index, total, _title, localPath) => {
|
|
41
|
+
const icon = localPath ? chalk.green('✓') : chalk.red('×');
|
|
42
|
+
const progress = chalk.gray(`(${index}/${total})`);
|
|
43
|
+
console.log(` ${icon} ${progress} ${localPath || 'deleted'}`);
|
|
44
|
+
},
|
|
45
|
+
onPageError: (title, error) => {
|
|
46
|
+
console.log(` ${chalk.red('✗')} ${title}: ${error}`);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { Effect, pipe, Schema } from 'effect';
|
|
5
|
+
import { ConfigError, FileSystemError, ParseError, ValidationError } from './errors.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Schema for Confluence Cloud URL validation
|
|
9
|
+
* Only accepts https://*.atlassian.net URLs per ADR-0012
|
|
10
|
+
*/
|
|
11
|
+
const ConfluenceUrlSchema = Schema.String.pipe(
|
|
12
|
+
Schema.pattern(/^https:\/\/.+\.atlassian\.net$/),
|
|
13
|
+
Schema.annotations({
|
|
14
|
+
message: () => 'URL must be a Confluence Cloud URL (https://*.atlassian.net)',
|
|
15
|
+
}),
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Schema for email validation
|
|
20
|
+
*/
|
|
21
|
+
const EmailSchema = Schema.String.pipe(
|
|
22
|
+
Schema.pattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/),
|
|
23
|
+
Schema.annotations({
|
|
24
|
+
message: () => 'Invalid email format',
|
|
25
|
+
}),
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Configuration schema for cn CLI
|
|
30
|
+
*/
|
|
31
|
+
const ConfigSchema = Schema.Struct({
|
|
32
|
+
confluenceUrl: ConfluenceUrlSchema,
|
|
33
|
+
email: EmailSchema,
|
|
34
|
+
apiToken: Schema.String.pipe(Schema.minLength(1)),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
export type Config = Schema.Schema.Type<typeof ConfigSchema>;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* ConfigManager handles reading and writing the cn CLI configuration
|
|
41
|
+
* Configuration is stored in ~/.cn/config.json with 600 permissions
|
|
42
|
+
*/
|
|
43
|
+
export class ConfigManager {
|
|
44
|
+
private configDir: string;
|
|
45
|
+
private configFile: string;
|
|
46
|
+
|
|
47
|
+
constructor() {
|
|
48
|
+
this.configDir = process.env.CN_CONFIG_PATH ? process.env.CN_CONFIG_PATH : join(homedir(), '.cn');
|
|
49
|
+
this.configFile = join(this.configDir, 'config.json');
|
|
50
|
+
|
|
51
|
+
if (!existsSync(this.configDir)) {
|
|
52
|
+
mkdirSync(this.configDir, { recursive: true });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Get the path to the config file
|
|
58
|
+
*/
|
|
59
|
+
getConfigPath(): string {
|
|
60
|
+
return this.configFile;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Check if configuration exists
|
|
65
|
+
*/
|
|
66
|
+
hasConfig(): boolean {
|
|
67
|
+
return existsSync(this.configFile);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Effect-based configuration retrieval with detailed error handling
|
|
72
|
+
*/
|
|
73
|
+
getConfigEffect(): Effect.Effect<Config, ConfigError | FileSystemError | ParseError | ValidationError> {
|
|
74
|
+
return pipe(
|
|
75
|
+
Effect.sync(() => existsSync(this.configFile)),
|
|
76
|
+
Effect.flatMap(
|
|
77
|
+
(fileExists): Effect.Effect<Config, ConfigError | FileSystemError | ParseError | ValidationError> => {
|
|
78
|
+
if (fileExists) {
|
|
79
|
+
return pipe(
|
|
80
|
+
Effect.try(() => readFileSync(this.configFile, 'utf-8')),
|
|
81
|
+
Effect.mapError((error) => new FileSystemError(`Failed to read config file: ${error}`)),
|
|
82
|
+
Effect.flatMap((configData) =>
|
|
83
|
+
Effect.try(() => JSON.parse(configData)).pipe(
|
|
84
|
+
Effect.mapError((error) => new ParseError(`Invalid JSON in config file: ${error}`)),
|
|
85
|
+
),
|
|
86
|
+
),
|
|
87
|
+
Effect.flatMap((config) =>
|
|
88
|
+
Schema.decodeUnknown(ConfigSchema)(config).pipe(
|
|
89
|
+
Effect.mapError((error) => new ValidationError(`Invalid config schema: ${error}`)),
|
|
90
|
+
),
|
|
91
|
+
),
|
|
92
|
+
) as Effect.Effect<Config, ConfigError | FileSystemError | ParseError | ValidationError>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return Effect.fail(new ConfigError('No configuration found. Please run "cn setup" first.'));
|
|
96
|
+
},
|
|
97
|
+
),
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Async wrapper for getConfigEffect
|
|
103
|
+
*/
|
|
104
|
+
async getConfig(): Promise<Config | null> {
|
|
105
|
+
if (existsSync(this.configFile)) {
|
|
106
|
+
try {
|
|
107
|
+
const configData = readFileSync(this.configFile, 'utf-8');
|
|
108
|
+
const config = JSON.parse(configData);
|
|
109
|
+
return Schema.decodeUnknownSync(ConfigSchema)(config);
|
|
110
|
+
} catch {
|
|
111
|
+
// Invalid config file - return null to indicate no valid config
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Effect-based configuration update
|
|
120
|
+
*/
|
|
121
|
+
setConfigEffect(config: Config): Effect.Effect<void, ValidationError | FileSystemError> {
|
|
122
|
+
return pipe(
|
|
123
|
+
Schema.decodeUnknown(ConfigSchema)(config),
|
|
124
|
+
Effect.mapError((error) => new ValidationError(`Invalid config: ${error}`)),
|
|
125
|
+
Effect.flatMap((validated) =>
|
|
126
|
+
Effect.try(() => {
|
|
127
|
+
writeFileSync(this.configFile, JSON.stringify(validated, null, 2), 'utf-8');
|
|
128
|
+
chmodSync(this.configFile, 0o600);
|
|
129
|
+
}).pipe(Effect.mapError((error) => new FileSystemError(`Failed to save config: ${error}`))),
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Async wrapper for setConfigEffect
|
|
136
|
+
*/
|
|
137
|
+
async setConfig(config: Config): Promise<void> {
|
|
138
|
+
const validated = Schema.decodeUnknownSync(ConfigSchema)(config);
|
|
139
|
+
writeFileSync(this.configFile, JSON.stringify(validated, null, 2), 'utf-8');
|
|
140
|
+
chmodSync(this.configFile, 0o600);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Validate a Confluence URL
|
|
145
|
+
*/
|
|
146
|
+
static validateUrl(url: string): boolean {
|
|
147
|
+
return /^https:\/\/.+\.atlassian\.net$/.test(url);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Validate an email address
|
|
152
|
+
*/
|
|
153
|
+
static validateEmail(email: string): boolean {
|
|
154
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
155
|
+
}
|
|
156
|
+
}
|