@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,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,6 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Main CLI entry point - imports from the modular command structure
4
+ * All commands have been extracted to src/cli/commands/
5
+ */
6
+ import './cli/index.js';
@@ -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
+ }