@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,237 @@
1
+ /**
2
+ * Error types for cn CLI with discriminated unions using _tag property
3
+ * These error types follow the Effect pattern for type-safe error handling
4
+ */
5
+
6
+ /**
7
+ * Configuration-related errors (missing config, invalid config path)
8
+ */
9
+ export class ConfigError extends Error {
10
+ readonly _tag = 'ConfigError' as const;
11
+
12
+ constructor(message: string) {
13
+ super(message);
14
+ this.name = 'ConfigError';
15
+ }
16
+ }
17
+
18
+ /**
19
+ * File system operation errors
20
+ */
21
+ export class FileSystemError extends Error {
22
+ readonly _tag = 'FileSystemError' as const;
23
+
24
+ constructor(message: string) {
25
+ super(message);
26
+ this.name = 'FileSystemError';
27
+ }
28
+ }
29
+
30
+ /**
31
+ * JSON parsing errors
32
+ */
33
+ export class ParseError extends Error {
34
+ readonly _tag = 'ParseError' as const;
35
+
36
+ constructor(message: string) {
37
+ super(message);
38
+ this.name = 'ParseError';
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Schema validation errors
44
+ */
45
+ export class ValidationError extends Error {
46
+ readonly _tag = 'ValidationError' as const;
47
+
48
+ constructor(message: string) {
49
+ super(message);
50
+ this.name = 'ValidationError';
51
+ }
52
+ }
53
+
54
+ /**
55
+ * API request errors with status code
56
+ */
57
+ export class ApiError extends Error {
58
+ readonly _tag = 'ApiError' as const;
59
+ readonly statusCode: number;
60
+
61
+ constructor(message: string, statusCode: number) {
62
+ super(message);
63
+ this.name = 'ApiError';
64
+ this.statusCode = statusCode;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Rate limit errors (429 responses)
70
+ */
71
+ export class RateLimitError extends Error {
72
+ readonly _tag = 'RateLimitError' as const;
73
+ readonly retryAfter?: number;
74
+
75
+ constructor(message: string, retryAfter?: number) {
76
+ super(message);
77
+ this.name = 'RateLimitError';
78
+ this.retryAfter = retryAfter;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Authentication errors (401, 403)
84
+ */
85
+ export class AuthError extends Error {
86
+ readonly _tag = 'AuthError' as const;
87
+ readonly statusCode: number;
88
+
89
+ constructor(message: string, statusCode: number) {
90
+ super(message);
91
+ this.name = 'AuthError';
92
+ this.statusCode = statusCode;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Sync operation errors
98
+ */
99
+ export class SyncError extends Error {
100
+ readonly _tag = 'SyncError' as const;
101
+
102
+ constructor(message: string) {
103
+ super(message);
104
+ this.name = 'SyncError';
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Network/connectivity errors
110
+ */
111
+ export class NetworkError extends Error {
112
+ readonly _tag = 'NetworkError' as const;
113
+
114
+ constructor(message: string) {
115
+ super(message);
116
+ this.name = 'NetworkError';
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Space not found errors
122
+ */
123
+ export class SpaceNotFoundError extends Error {
124
+ readonly _tag = 'SpaceNotFoundError' as const;
125
+ readonly spaceKey: string;
126
+
127
+ constructor(spaceKey: string) {
128
+ super(`Space not found: ${spaceKey}`);
129
+ this.name = 'SpaceNotFoundError';
130
+ this.spaceKey = spaceKey;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Page not found errors (404 when updating)
136
+ */
137
+ export class PageNotFoundError extends Error {
138
+ readonly _tag = 'PageNotFoundError' as const;
139
+ readonly pageId: string;
140
+
141
+ constructor(pageId: string) {
142
+ super(`Page not found: ${pageId}`);
143
+ this.name = 'PageNotFoundError';
144
+ this.pageId = pageId;
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Version conflict errors (409 when updating with stale version)
150
+ */
151
+ export class VersionConflictError extends Error {
152
+ readonly _tag = 'VersionConflictError' as const;
153
+ readonly localVersion: number;
154
+ readonly remoteVersion: number;
155
+
156
+ constructor(localVersion: number, remoteVersion: number) {
157
+ super(`Version conflict: local version ${localVersion} does not match remote version ${remoteVersion}`);
158
+ this.name = 'VersionConflictError';
159
+ this.localVersion = localVersion;
160
+ this.remoteVersion = remoteVersion;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Folder not found errors (404 when folder is deleted on Confluence)
166
+ * Per ADR-0023: Folder push workflow support
167
+ */
168
+ export class FolderNotFoundError extends Error {
169
+ readonly _tag = 'FolderNotFoundError' as const;
170
+ readonly folderId: string;
171
+
172
+ constructor(folderId: string) {
173
+ super(`Folder not found: ${folderId}`);
174
+ this.name = 'FolderNotFoundError';
175
+ this.folderId = folderId;
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Union type of all error types for comprehensive error handling
181
+ */
182
+ export type CnError =
183
+ | ConfigError
184
+ | FileSystemError
185
+ | ParseError
186
+ | ValidationError
187
+ | ApiError
188
+ | RateLimitError
189
+ | AuthError
190
+ | SyncError
191
+ | NetworkError
192
+ | SpaceNotFoundError
193
+ | PageNotFoundError
194
+ | VersionConflictError
195
+ | FolderNotFoundError;
196
+
197
+ /**
198
+ * Exit codes for CLI
199
+ */
200
+ export const EXIT_CODES = {
201
+ SUCCESS: 0,
202
+ GENERAL_ERROR: 1,
203
+ CONFIG_ERROR: 2,
204
+ AUTH_ERROR: 3,
205
+ NETWORK_ERROR: 4,
206
+ SPACE_NOT_FOUND: 5,
207
+ INVALID_ARGUMENTS: 6,
208
+ PAGE_NOT_FOUND: 7,
209
+ VERSION_CONFLICT: 8,
210
+ FOLDER_NOT_FOUND: 9,
211
+ } as const;
212
+
213
+ /**
214
+ * Get exit code for a given error
215
+ */
216
+ export function getExitCodeForError(error: CnError): number {
217
+ switch (error._tag) {
218
+ case 'ConfigError':
219
+ case 'ValidationError':
220
+ return EXIT_CODES.CONFIG_ERROR;
221
+ case 'AuthError':
222
+ return EXIT_CODES.AUTH_ERROR;
223
+ case 'NetworkError':
224
+ case 'RateLimitError':
225
+ return EXIT_CODES.NETWORK_ERROR;
226
+ case 'SpaceNotFoundError':
227
+ return EXIT_CODES.SPACE_NOT_FOUND;
228
+ case 'PageNotFoundError':
229
+ return EXIT_CODES.PAGE_NOT_FOUND;
230
+ case 'VersionConflictError':
231
+ return EXIT_CODES.VERSION_CONFLICT;
232
+ case 'FolderNotFoundError':
233
+ return EXIT_CODES.FOLDER_NOT_FOUND;
234
+ default:
235
+ return EXIT_CODES.GENERAL_ERROR;
236
+ }
237
+ }
@@ -0,0 +1,195 @@
1
+ import { readFileSync, readdirSync, statSync, type Stats } from 'node:fs';
2
+ import { join, relative } from 'node:path';
3
+ import { parseMarkdown } from './markdown/index.js';
4
+
5
+ /**
6
+ * Directories to exclude from scanning
7
+ */
8
+ export const EXCLUDED_DIRS = new Set([
9
+ 'node_modules',
10
+ '.git',
11
+ 'dist',
12
+ 'build',
13
+ 'coverage',
14
+ '.next',
15
+ '.nuxt',
16
+ '.cache',
17
+ '.turbo',
18
+ 'out',
19
+ 'vendor',
20
+ '__pycache__',
21
+ '.venv',
22
+ 'venv',
23
+ ]);
24
+
25
+ /**
26
+ * Reserved filenames that should not be synced (used by coding agents)
27
+ * Checked case-insensitively
28
+ */
29
+ export const RESERVED_FILENAMES = new Set(['claude.md', 'agents.md']);
30
+
31
+ /**
32
+ * Scans a directory recursively for markdown files.
33
+ * Excludes common build/dependency directories and hidden files.
34
+ *
35
+ * @param directory - Root directory to scan for markdown files
36
+ * @returns Array of relative paths to markdown files, sorted alphabetically
37
+ *
38
+ * @example
39
+ * ```typescript
40
+ * const files = scanMarkdownFiles('/path/to/project');
41
+ * // Returns: ['README.md', 'docs/guide.md', 'docs/api/endpoints.md']
42
+ * ```
43
+ */
44
+ export function scanMarkdownFiles(directory: string): string[] {
45
+ const files: string[] = [];
46
+
47
+ function scan(dir: string): void {
48
+ let entries: string[];
49
+ try {
50
+ entries = readdirSync(dir);
51
+ } catch {
52
+ return;
53
+ }
54
+
55
+ for (const entry of entries) {
56
+ // Skip hidden files/directories (starting with .)
57
+ if (entry.startsWith('.')) {
58
+ continue;
59
+ }
60
+
61
+ // Skip excluded directories
62
+ if (EXCLUDED_DIRS.has(entry)) {
63
+ continue;
64
+ }
65
+
66
+ const fullPath = join(dir, entry);
67
+ let stat: Stats;
68
+ try {
69
+ stat = statSync(fullPath);
70
+ } catch {
71
+ continue;
72
+ }
73
+
74
+ if (stat.isDirectory()) {
75
+ scan(fullPath);
76
+ } else if (stat.isFile() && entry.endsWith('.md')) {
77
+ // Skip reserved filenames (used by coding agents)
78
+ if (RESERVED_FILENAMES.has(entry.toLowerCase())) {
79
+ continue;
80
+ }
81
+ // Return path relative to the root directory
82
+ files.push(relative(directory, fullPath));
83
+ }
84
+ }
85
+ }
86
+
87
+ scan(directory);
88
+ return files.sort();
89
+ }
90
+
91
+ /**
92
+ * Represents a file that may need to be pushed
93
+ */
94
+ export interface PushCandidate {
95
+ /** Relative path from directory root */
96
+ path: string;
97
+ /** Whether this is a new file (no page_id) or modified existing file */
98
+ type: 'new' | 'modified';
99
+ /** Title from frontmatter or filename */
100
+ title: string;
101
+ /** Page ID if it exists */
102
+ pageId?: string;
103
+ }
104
+
105
+ /**
106
+ * Detects which markdown files need to be pushed to Confluence.
107
+ *
108
+ * @param directory - Root directory to scan for changed files
109
+ * @returns Array of files that are new or modified since last sync
110
+ *
111
+ * Detection logic:
112
+ * - **New files**: have no `page_id` in frontmatter
113
+ * - **Modified files**: have `page_id` and file mtime > `synced_at` + 1 second
114
+ *
115
+ * The 1-second tolerance accounts for filesystem write timing during pull operations.
116
+ * Files without `synced_at` but with `page_id` are treated as modified.
117
+ *
118
+ * @example
119
+ * ```typescript
120
+ * const candidates = detectPushCandidates('/path/to/project');
121
+ * for (const candidate of candidates) {
122
+ * console.log(`${candidate.type}: ${candidate.path}`);
123
+ * }
124
+ * ```
125
+ */
126
+ export function detectPushCandidates(directory: string): PushCandidate[] {
127
+ const files = scanMarkdownFiles(directory);
128
+ const candidates: PushCandidate[] = [];
129
+
130
+ for (const relativePath of files) {
131
+ const fullPath = join(directory, relativePath);
132
+
133
+ let content: string;
134
+ try {
135
+ content = readFileSync(fullPath, 'utf-8');
136
+ } catch {
137
+ continue;
138
+ }
139
+
140
+ const { frontmatter } = parseMarkdown(content);
141
+
142
+ // Get file title from frontmatter or filename
143
+ const filename = relativePath.split('/').pop()?.replace(/\.md$/, '') ?? relativePath.replace(/\.md$/, '');
144
+ const title = (frontmatter.title as string) || filename;
145
+
146
+ // New file - no page_id
147
+ if (!frontmatter.page_id) {
148
+ candidates.push({
149
+ path: relativePath,
150
+ type: 'new',
151
+ title,
152
+ });
153
+ continue;
154
+ }
155
+
156
+ // Existing file - check if modified since last sync
157
+ const syncedAt = frontmatter.synced_at as string | undefined;
158
+ if (!syncedAt) {
159
+ // No synced_at means it was never synced, treat as modified
160
+ candidates.push({
161
+ path: relativePath,
162
+ type: 'modified',
163
+ title,
164
+ pageId: frontmatter.page_id as string,
165
+ });
166
+ continue;
167
+ }
168
+
169
+ // Compare file mtime to synced_at
170
+ let stat: Stats;
171
+ try {
172
+ stat = statSync(fullPath);
173
+ } catch {
174
+ continue;
175
+ }
176
+
177
+ const syncedAtTime = new Date(syncedAt).getTime();
178
+ const mtimeMs = stat.mtimeMs;
179
+
180
+ // Add 1 second tolerance to account for filesystem write timing
181
+ // during pull operations (file mtime is set slightly after synced_at)
182
+ const TOLERANCE_MS = 1000;
183
+
184
+ if (mtimeMs > syncedAtTime + TOLERANCE_MS) {
185
+ candidates.push({
186
+ path: relativePath,
187
+ type: 'modified',
188
+ title,
189
+ pageId: frontmatter.page_id as string,
190
+ });
191
+ }
192
+ }
193
+
194
+ return candidates;
195
+ }