@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,699 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { basename, resolve } from 'node:path';
|
|
5
|
+
import { ConfigManager } from '../../lib/config.js';
|
|
6
|
+
import { ConfluenceClient, type CreatePageRequest, type UpdatePageRequest } from '../../lib/confluence-client/index.js';
|
|
7
|
+
import { EXIT_CODES, PageNotFoundError } from '../../lib/errors.js';
|
|
8
|
+
import { sortByDependencies } from '../../lib/dependency-sorter.js';
|
|
9
|
+
import { detectPushCandidates, type PushCandidate } from '../../lib/file-scanner.js';
|
|
10
|
+
import { checkDuplicatesBeforePush, displayVersionConflictGuidance } from './duplicate-check.js';
|
|
11
|
+
import { handlePushError, PushError } from './push-errors.js';
|
|
12
|
+
import {
|
|
13
|
+
buildPageLookupMapFromCache,
|
|
14
|
+
extractH1Title,
|
|
15
|
+
HtmlConverter,
|
|
16
|
+
parseMarkdown,
|
|
17
|
+
serializeMarkdown,
|
|
18
|
+
stripH1Title,
|
|
19
|
+
type PageFrontmatter,
|
|
20
|
+
} from '../../lib/markdown/index.js';
|
|
21
|
+
import { buildPageStateFromFiles } from '../../lib/page-state.js';
|
|
22
|
+
import {
|
|
23
|
+
hasSpaceConfig,
|
|
24
|
+
readSpaceConfig,
|
|
25
|
+
updatePageSyncInfo,
|
|
26
|
+
writeSpaceConfig,
|
|
27
|
+
type SpaceConfigWithState,
|
|
28
|
+
} from '../../lib/space-config.js';
|
|
29
|
+
import { handleFileRename } from './file-rename.js';
|
|
30
|
+
import { determineExpectedParent, ensureFolderHierarchy, FolderHierarchyError } from './folder-hierarchy.js';
|
|
31
|
+
|
|
32
|
+
export interface PushCommandOptions {
|
|
33
|
+
file?: string;
|
|
34
|
+
force?: boolean;
|
|
35
|
+
dryRun?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Result of pushing a single file to Confluence
|
|
40
|
+
*/
|
|
41
|
+
interface PushResult {
|
|
42
|
+
success: boolean;
|
|
43
|
+
updatedConfig: SpaceConfigWithState;
|
|
44
|
+
pageInfo?: {
|
|
45
|
+
pageId: string;
|
|
46
|
+
title: string;
|
|
47
|
+
localPath: string;
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Confluence Cloud has a ~65k character limit for page content in Storage Format
|
|
52
|
+
// Reference: https://confluence.atlassian.com/doc/confluence-cloud-document-and-restriction-limits-938777919.html
|
|
53
|
+
const MAX_PAGE_SIZE = 65000;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Push command - pushes local markdown files to Confluence
|
|
57
|
+
* When file is specified: pushes single file
|
|
58
|
+
* When no file: scans for changed files and prompts for each
|
|
59
|
+
*/
|
|
60
|
+
export async function pushCommand(options: PushCommandOptions): Promise<void> {
|
|
61
|
+
const configManager = new ConfigManager();
|
|
62
|
+
const config = await configManager.getConfig();
|
|
63
|
+
|
|
64
|
+
if (!config) {
|
|
65
|
+
console.error(chalk.red('Not configured. Please run "cn setup" first.'));
|
|
66
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const directory = process.cwd();
|
|
70
|
+
|
|
71
|
+
// Check if space is configured
|
|
72
|
+
if (!hasSpaceConfig(directory)) {
|
|
73
|
+
console.error(chalk.red('No space configured in this directory.'));
|
|
74
|
+
console.log(chalk.gray('Run "cn clone <SPACE_KEY>" to clone a space first.'));
|
|
75
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const spaceConfigResult = readSpaceConfig(directory);
|
|
79
|
+
if (!spaceConfigResult || !spaceConfigResult.spaceId || !spaceConfigResult.spaceKey) {
|
|
80
|
+
console.error(chalk.red('Invalid space configuration.'));
|
|
81
|
+
console.log(chalk.gray('The .confluence.json file may be corrupted.'));
|
|
82
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
83
|
+
}
|
|
84
|
+
const spaceConfig = spaceConfigResult;
|
|
85
|
+
|
|
86
|
+
const client = new ConfluenceClient(config);
|
|
87
|
+
|
|
88
|
+
// If no file specified, scan for changes and prompt
|
|
89
|
+
if (!options.file) {
|
|
90
|
+
await pushBatch(client, config, spaceConfig, directory, options);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Single file push - exit with error code on failure
|
|
95
|
+
try {
|
|
96
|
+
await pushSingleFile(client, config, spaceConfig, directory, options.file, options);
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error instanceof PushError || error instanceof FolderHierarchyError) {
|
|
99
|
+
process.exit(error.exitCode);
|
|
100
|
+
}
|
|
101
|
+
// Unexpected error - log and exit with general error code
|
|
102
|
+
console.error(chalk.red('Unexpected error:'));
|
|
103
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
104
|
+
process.exit(EXIT_CODES.GENERAL_ERROR);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Push a single file to Confluence
|
|
110
|
+
*/
|
|
111
|
+
async function pushSingleFile(
|
|
112
|
+
client: ConfluenceClient,
|
|
113
|
+
config: { confluenceUrl: string },
|
|
114
|
+
spaceConfig: SpaceConfigWithState,
|
|
115
|
+
directory: string,
|
|
116
|
+
file: string,
|
|
117
|
+
options: PushCommandOptions,
|
|
118
|
+
): Promise<PushResult> {
|
|
119
|
+
// Resolve and validate file path
|
|
120
|
+
const filePath = resolve(directory, file);
|
|
121
|
+
if (!existsSync(filePath)) {
|
|
122
|
+
console.error(chalk.red(`File not found: ${file}`));
|
|
123
|
+
throw new PushError(`File not found: ${file}`, EXIT_CODES.INVALID_ARGUMENTS);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Validate file extension
|
|
127
|
+
if (!filePath.endsWith('.md')) {
|
|
128
|
+
console.error(chalk.red(`Invalid file type: ${file}`));
|
|
129
|
+
console.log(chalk.gray('Only markdown files (.md) are supported.'));
|
|
130
|
+
throw new PushError(`Invalid file type: ${file}`, EXIT_CODES.INVALID_ARGUMENTS);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Read and parse the markdown file
|
|
134
|
+
const markdownContent = readFileSync(filePath, 'utf-8');
|
|
135
|
+
const { frontmatter, content } = parseMarkdown(markdownContent);
|
|
136
|
+
|
|
137
|
+
// Get title: frontmatter > H1 heading > filename
|
|
138
|
+
const currentFilename = basename(filePath, '.md');
|
|
139
|
+
const h1Title = extractH1Title(content);
|
|
140
|
+
const title = frontmatter.title || h1Title || currentFilename;
|
|
141
|
+
|
|
142
|
+
// Strip H1 from content - Confluence displays title separately
|
|
143
|
+
const bodyContent = stripH1Title(content);
|
|
144
|
+
|
|
145
|
+
// Warn if using filename fallback for new pages
|
|
146
|
+
if (!frontmatter.page_id && !frontmatter.title && !h1Title) {
|
|
147
|
+
console.log(chalk.yellow(` Note: No title found, using filename: "${title}"`));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Check if this is a new page (no page_id) or existing page
|
|
151
|
+
if (!frontmatter.page_id) {
|
|
152
|
+
return createNewPage(
|
|
153
|
+
client,
|
|
154
|
+
config,
|
|
155
|
+
spaceConfig,
|
|
156
|
+
directory,
|
|
157
|
+
filePath,
|
|
158
|
+
file,
|
|
159
|
+
options,
|
|
160
|
+
frontmatter,
|
|
161
|
+
bodyContent,
|
|
162
|
+
title,
|
|
163
|
+
);
|
|
164
|
+
} else {
|
|
165
|
+
return updateExistingPage(
|
|
166
|
+
client,
|
|
167
|
+
config,
|
|
168
|
+
spaceConfig,
|
|
169
|
+
directory,
|
|
170
|
+
filePath,
|
|
171
|
+
file,
|
|
172
|
+
options,
|
|
173
|
+
frontmatter,
|
|
174
|
+
bodyContent,
|
|
175
|
+
title,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Scan for changed files and push with y/n prompts
|
|
182
|
+
*/
|
|
183
|
+
async function pushBatch(
|
|
184
|
+
client: ConfluenceClient,
|
|
185
|
+
config: { confluenceUrl: string },
|
|
186
|
+
spaceConfig: SpaceConfigWithState,
|
|
187
|
+
directory: string,
|
|
188
|
+
options: PushCommandOptions,
|
|
189
|
+
): Promise<void> {
|
|
190
|
+
console.log(chalk.gray('Scanning for changes...'));
|
|
191
|
+
console.log('');
|
|
192
|
+
|
|
193
|
+
// Check for duplicate page_ids before proceeding (ADR-0024)
|
|
194
|
+
const shouldContinue = await checkDuplicatesBeforePush(directory);
|
|
195
|
+
if (!shouldContinue) {
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const candidates = detectPushCandidates(directory);
|
|
200
|
+
|
|
201
|
+
if (candidates.length === 0) {
|
|
202
|
+
console.log(chalk.green('No changes to push.'));
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Sort by dependencies so linked-to pages are pushed first
|
|
207
|
+
const { sorted: sortedCandidates, cycles } = sortByDependencies(candidates, directory);
|
|
208
|
+
|
|
209
|
+
// Warn about circular dependencies
|
|
210
|
+
if (cycles.length > 0) {
|
|
211
|
+
console.log(chalk.yellow('Circular link dependencies detected:'));
|
|
212
|
+
for (const cycle of cycles) {
|
|
213
|
+
console.log(chalk.yellow(` ${cycle.join(' -> ')} -> ${cycle[0]}`));
|
|
214
|
+
}
|
|
215
|
+
console.log('');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Show summary
|
|
219
|
+
const newCount = sortedCandidates.filter((c) => c.type === 'new').length;
|
|
220
|
+
const modifiedCount = sortedCandidates.filter((c) => c.type === 'modified').length;
|
|
221
|
+
|
|
222
|
+
console.log(`Found ${chalk.bold(sortedCandidates.length)} file(s) to push:`);
|
|
223
|
+
for (const candidate of sortedCandidates) {
|
|
224
|
+
const typeLabel = candidate.type === 'new' ? chalk.cyan('[N]') : chalk.yellow('[M]');
|
|
225
|
+
console.log(` ${typeLabel} ${candidate.path}`);
|
|
226
|
+
}
|
|
227
|
+
console.log('');
|
|
228
|
+
|
|
229
|
+
if (options.dryRun) {
|
|
230
|
+
console.log(chalk.blue('--- DRY RUN MODE ---'));
|
|
231
|
+
console.log(chalk.gray(`Would push ${newCount} new and ${modifiedCount} modified file(s)`));
|
|
232
|
+
console.log(chalk.blue('No changes were made (dry run mode)'));
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Process each candidate with y/n prompt
|
|
237
|
+
// Maintain in-memory config state throughout batch so link resolution works
|
|
238
|
+
let currentConfig = spaceConfig;
|
|
239
|
+
let pushed = 0;
|
|
240
|
+
let skipped = 0;
|
|
241
|
+
let failed = 0;
|
|
242
|
+
const failedFiles: string[] = [];
|
|
243
|
+
const pushedPages: Array<{ pageId: string; title: string; localPath: string }> = [];
|
|
244
|
+
|
|
245
|
+
for (const candidate of sortedCandidates) {
|
|
246
|
+
const typeLabel = candidate.type === 'new' ? 'create' : 'update';
|
|
247
|
+
const shouldPush = await confirm({
|
|
248
|
+
message: `Push ${candidate.path}? (${typeLabel})`,
|
|
249
|
+
default: true,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
if (!shouldPush) {
|
|
253
|
+
skipped++;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
// Pass currentConfig so link resolution has access to previously-pushed pages
|
|
259
|
+
const result = await pushSingleFile(client, config, currentConfig, directory, candidate.path, {
|
|
260
|
+
...options,
|
|
261
|
+
file: candidate.path,
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// Update in-memory config for next iteration
|
|
265
|
+
currentConfig = result.updatedConfig;
|
|
266
|
+
pushed++;
|
|
267
|
+
|
|
268
|
+
// Track pushed page info for summary
|
|
269
|
+
if (result.pageInfo) {
|
|
270
|
+
pushedPages.push(result.pageInfo);
|
|
271
|
+
}
|
|
272
|
+
} catch (error) {
|
|
273
|
+
// Don't exit on individual failures in batch mode - continue with remaining files.
|
|
274
|
+
// PushError and FolderHierarchyError print their own user-friendly messages before throwing,
|
|
275
|
+
// so we only need to print the error message for unexpected errors.
|
|
276
|
+
if (!(error instanceof PushError) && !(error instanceof FolderHierarchyError)) {
|
|
277
|
+
console.error(chalk.red(` Failed: ${error instanceof Error ? error.message : 'Unknown error'}`));
|
|
278
|
+
}
|
|
279
|
+
failed++;
|
|
280
|
+
failedFiles.push(candidate.path);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
console.log('');
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Summary
|
|
287
|
+
console.log(chalk.bold('Push complete:'));
|
|
288
|
+
if (pushed > 0) console.log(chalk.green(` ${pushed} pushed`));
|
|
289
|
+
if (skipped > 0) console.log(chalk.gray(` ${skipped} skipped`));
|
|
290
|
+
if (failed > 0) {
|
|
291
|
+
console.log(chalk.red(` ${failed} failed`));
|
|
292
|
+
console.log('');
|
|
293
|
+
console.log(chalk.gray('Failed files:'));
|
|
294
|
+
for (const file of failedFiles) {
|
|
295
|
+
console.log(chalk.gray(` ${file}`));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Show pushed pages summary
|
|
300
|
+
if (pushedPages.length > 0) {
|
|
301
|
+
console.log('');
|
|
302
|
+
console.log(chalk.bold('Pushed pages:'));
|
|
303
|
+
for (const page of pushedPages) {
|
|
304
|
+
console.log(chalk.gray(` ${page.title} (${page.pageId}) - ${page.localPath}`));
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Create a new page on Confluence
|
|
311
|
+
*/
|
|
312
|
+
async function createNewPage(
|
|
313
|
+
client: ConfluenceClient,
|
|
314
|
+
config: { confluenceUrl: string },
|
|
315
|
+
spaceConfig: SpaceConfigWithState,
|
|
316
|
+
directory: string,
|
|
317
|
+
filePath: string,
|
|
318
|
+
relativePath: string,
|
|
319
|
+
options: PushCommandOptions,
|
|
320
|
+
frontmatter: Partial<PageFrontmatter>,
|
|
321
|
+
content: string,
|
|
322
|
+
title: string,
|
|
323
|
+
): Promise<PushResult> {
|
|
324
|
+
console.log(chalk.bold(`Creating: ${title}`));
|
|
325
|
+
console.log(chalk.cyan(' (New page - no page_id in frontmatter)'));
|
|
326
|
+
|
|
327
|
+
// Convert markdown to HTML with link conversion (ADR-0022, ADR-0024)
|
|
328
|
+
// Uses the passed-in spaceConfig which is maintained in-memory during batch pushes
|
|
329
|
+
console.log(chalk.gray(' Converting markdown to HTML...'));
|
|
330
|
+
const converter = new HtmlConverter();
|
|
331
|
+
const pageState = buildPageStateFromFiles(directory, spaceConfig.pages);
|
|
332
|
+
const pageLookupMap = buildPageLookupMapFromCache(pageState);
|
|
333
|
+
const { html, warnings } = converter.convert(
|
|
334
|
+
content,
|
|
335
|
+
directory,
|
|
336
|
+
relativePath.replace(/^\.\//, ''),
|
|
337
|
+
spaceConfig.spaceKey,
|
|
338
|
+
pageLookupMap,
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
// Validate content size
|
|
342
|
+
if (html.length > MAX_PAGE_SIZE) {
|
|
343
|
+
console.error('');
|
|
344
|
+
console.error(chalk.red(`Content too large: ${html.length} characters (max: ${MAX_PAGE_SIZE})`));
|
|
345
|
+
console.log(chalk.gray('Confluence has a page size limit. Consider splitting into multiple pages.'));
|
|
346
|
+
throw new PushError(`Content too large: ${html.length} characters`, EXIT_CODES.INVALID_ARGUMENTS);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Show conversion warnings (includes page state build warnings)
|
|
350
|
+
const allWarnings = [...pageState.warnings, ...warnings];
|
|
351
|
+
if (allWarnings.length > 0) {
|
|
352
|
+
console.log('');
|
|
353
|
+
console.log(chalk.yellow('Conversion warnings:'));
|
|
354
|
+
for (const warning of allWarnings) {
|
|
355
|
+
console.log(chalk.yellow(` ! ${warning}`));
|
|
356
|
+
}
|
|
357
|
+
console.log('');
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Determine parent handling - either explicit parent_id or auto-create folder hierarchy
|
|
361
|
+
let parentId: string | undefined = frontmatter.parent_id ?? undefined;
|
|
362
|
+
let currentConfig = spaceConfig;
|
|
363
|
+
|
|
364
|
+
// Validate explicit parent_id if specified
|
|
365
|
+
if (frontmatter.parent_id) {
|
|
366
|
+
try {
|
|
367
|
+
console.log(chalk.gray(' Validating parent page...'));
|
|
368
|
+
await client.getPage(frontmatter.parent_id, false);
|
|
369
|
+
} catch (error) {
|
|
370
|
+
if (error instanceof PageNotFoundError) {
|
|
371
|
+
console.error('');
|
|
372
|
+
console.error(chalk.red(`Parent page not found (ID: ${frontmatter.parent_id}).`));
|
|
373
|
+
console.log(chalk.gray('Remove parent_id from frontmatter or use a valid page ID.'));
|
|
374
|
+
throw new PushError(`Parent page not found: ${frontmatter.parent_id}`, EXIT_CODES.PAGE_NOT_FOUND);
|
|
375
|
+
}
|
|
376
|
+
throw error;
|
|
377
|
+
}
|
|
378
|
+
} else {
|
|
379
|
+
// No explicit parent_id - check if file is in a subdirectory
|
|
380
|
+
// If so, ensure folder hierarchy exists (ADR-0023)
|
|
381
|
+
const result = await ensureFolderHierarchy(client, spaceConfig, directory, relativePath, options.dryRun);
|
|
382
|
+
parentId = result.parentId;
|
|
383
|
+
currentConfig = result.updatedConfig;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// Build create request
|
|
387
|
+
// parentId can be a page ID or folder ID - v2 API supports both
|
|
388
|
+
const createRequest: CreatePageRequest = {
|
|
389
|
+
spaceId: currentConfig.spaceId,
|
|
390
|
+
status: 'current',
|
|
391
|
+
title,
|
|
392
|
+
parentId,
|
|
393
|
+
body: {
|
|
394
|
+
representation: 'storage',
|
|
395
|
+
value: html,
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
// Dry run mode
|
|
400
|
+
if (options.dryRun) {
|
|
401
|
+
console.log(chalk.blue('\n--- DRY RUN MODE ---'));
|
|
402
|
+
console.log(chalk.gray(`Would create: ${title} in ${currentConfig.spaceKey}`));
|
|
403
|
+
if (createRequest.parentId) console.log(chalk.gray(` Parent ID: ${createRequest.parentId}`));
|
|
404
|
+
console.log(chalk.gray(` Content: ${html.length} chars`));
|
|
405
|
+
console.log(chalk.blue('No changes were made (dry run mode)\n'));
|
|
406
|
+
return {
|
|
407
|
+
success: true,
|
|
408
|
+
updatedConfig: currentConfig,
|
|
409
|
+
pageInfo: { pageId: '[dry-run]', title, localPath: relativePath },
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
// Create page on Confluence
|
|
415
|
+
console.log(chalk.gray(' Creating page on Confluence...'));
|
|
416
|
+
const createdPage = await client.createPage(createRequest);
|
|
417
|
+
|
|
418
|
+
// Set editor property to v2 to enable the new editor
|
|
419
|
+
// This is needed because the V2 API with storage format defaults to legacy editor
|
|
420
|
+
// See: https://community.developer.atlassian.com/t/confluence-rest-api-v2-struggling-to-create-a-page-with-the-new-editor/75235
|
|
421
|
+
try {
|
|
422
|
+
await client.setEditorV2(createdPage.id);
|
|
423
|
+
} catch {
|
|
424
|
+
// Non-fatal: page was created but may use legacy editor
|
|
425
|
+
console.log(chalk.yellow(' Warning: Could not set editor to v2. Page may use legacy editor.'));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// Build complete frontmatter from response
|
|
429
|
+
// Note: space_key is not included (inferred from .confluence.json)
|
|
430
|
+
const webui = createdPage._links?.webui;
|
|
431
|
+
const newFrontmatter: PageFrontmatter = {
|
|
432
|
+
page_id: createdPage.id,
|
|
433
|
+
title: createdPage.title,
|
|
434
|
+
created_at: createdPage.createdAt,
|
|
435
|
+
updated_at: createdPage.version?.createdAt,
|
|
436
|
+
version: createdPage.version?.number || 1,
|
|
437
|
+
parent_id: createdPage.parentId ?? undefined,
|
|
438
|
+
author_id: createdPage.authorId,
|
|
439
|
+
last_modifier_id: createdPage.version?.authorId,
|
|
440
|
+
url: webui ? `${config.confluenceUrl}/wiki${webui}` : undefined,
|
|
441
|
+
synced_at: new Date().toISOString(),
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
// Preserve any extra frontmatter fields the user may have added
|
|
445
|
+
const updatedFrontmatter: PageFrontmatter = {
|
|
446
|
+
...frontmatter,
|
|
447
|
+
...newFrontmatter,
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
const updatedMarkdown = serializeMarkdown(updatedFrontmatter, content);
|
|
451
|
+
|
|
452
|
+
// Handle file rename if title changed (also updates references in other files)
|
|
453
|
+
const { finalPath: finalLocalPath } = handleFileRename(
|
|
454
|
+
filePath,
|
|
455
|
+
relativePath,
|
|
456
|
+
createdPage.title,
|
|
457
|
+
updatedMarkdown,
|
|
458
|
+
directory,
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
// Update .confluence.json sync state
|
|
462
|
+
// Start with currentConfig (which may have folder updates from ensureFolderHierarchy)
|
|
463
|
+
// and add the new page's sync info
|
|
464
|
+
// Per ADR-0024: Only store pageId -> localPath mapping
|
|
465
|
+
const updatedSpaceConfig = updatePageSyncInfo(currentConfig, {
|
|
466
|
+
pageId: createdPage.id,
|
|
467
|
+
localPath: finalLocalPath,
|
|
468
|
+
});
|
|
469
|
+
writeSpaceConfig(directory, updatedSpaceConfig);
|
|
470
|
+
|
|
471
|
+
// Success!
|
|
472
|
+
console.log('');
|
|
473
|
+
console.log(chalk.green(`✓ Created: ${createdPage.title} (page_id: ${createdPage.id})`));
|
|
474
|
+
|
|
475
|
+
if (webui) {
|
|
476
|
+
console.log(chalk.gray(` ${config.confluenceUrl}/wiki${webui}`));
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
success: true,
|
|
481
|
+
updatedConfig: updatedSpaceConfig,
|
|
482
|
+
pageInfo: {
|
|
483
|
+
pageId: createdPage.id,
|
|
484
|
+
title: createdPage.title,
|
|
485
|
+
localPath: finalLocalPath,
|
|
486
|
+
},
|
|
487
|
+
};
|
|
488
|
+
} catch (error) {
|
|
489
|
+
handlePushError(error, relativePath);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Update an existing page on Confluence
|
|
495
|
+
*/
|
|
496
|
+
async function updateExistingPage(
|
|
497
|
+
client: ConfluenceClient,
|
|
498
|
+
config: { confluenceUrl: string },
|
|
499
|
+
spaceConfig: SpaceConfigWithState,
|
|
500
|
+
directory: string,
|
|
501
|
+
filePath: string,
|
|
502
|
+
relativePath: string,
|
|
503
|
+
options: PushCommandOptions,
|
|
504
|
+
frontmatter: Partial<PageFrontmatter>,
|
|
505
|
+
content: string,
|
|
506
|
+
title: string,
|
|
507
|
+
): Promise<PushResult> {
|
|
508
|
+
// Verify page_id exists (should be guaranteed by caller)
|
|
509
|
+
if (!frontmatter.page_id) {
|
|
510
|
+
throw new Error('updateExistingPage called without page_id');
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const pageId = frontmatter.page_id;
|
|
514
|
+
const localVersion = frontmatter.version || 1;
|
|
515
|
+
|
|
516
|
+
console.log(chalk.bold(`Pushing: ${title}`));
|
|
517
|
+
|
|
518
|
+
try {
|
|
519
|
+
// Fetch current page to check version
|
|
520
|
+
console.log(chalk.gray(' Checking remote version...'));
|
|
521
|
+
const remotePage = await client.getPage(pageId, false);
|
|
522
|
+
const remoteVersion = remotePage.version?.number || 1;
|
|
523
|
+
|
|
524
|
+
// Check version match (unless --force)
|
|
525
|
+
if (!options.force && localVersion !== remoteVersion) {
|
|
526
|
+
console.error('');
|
|
527
|
+
console.error(chalk.red(`Version conflict detected.`));
|
|
528
|
+
console.error(chalk.red(` Local version: ${localVersion}`));
|
|
529
|
+
console.error(chalk.red(` Remote version: ${remoteVersion}`));
|
|
530
|
+
console.error('');
|
|
531
|
+
|
|
532
|
+
// Check for duplicates - this often explains version conflicts after page moves
|
|
533
|
+
displayVersionConflictGuidance(directory, relativePath, remoteVersion);
|
|
534
|
+
throw new PushError('Version conflict', EXIT_CODES.VERSION_CONFLICT);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Warn if title differs
|
|
538
|
+
if (remotePage.title !== title) {
|
|
539
|
+
console.log(chalk.yellow(` Warning: Title differs (local: "${title}", remote: "${remotePage.title}")`));
|
|
540
|
+
console.log(chalk.yellow(' The remote title will be updated to match local.'));
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Determine expected parent from current folder structure
|
|
544
|
+
const { parentId: expectedParentId, updatedConfig: configAfterFolders } = await determineExpectedParent(
|
|
545
|
+
client,
|
|
546
|
+
spaceConfig,
|
|
547
|
+
directory,
|
|
548
|
+
relativePath,
|
|
549
|
+
options.dryRun,
|
|
550
|
+
);
|
|
551
|
+
const currentConfig = configAfterFolders;
|
|
552
|
+
|
|
553
|
+
// Get current parent from remote page
|
|
554
|
+
const currentParentId = remotePage.parentId ?? undefined;
|
|
555
|
+
|
|
556
|
+
// Track actual parent ID (may differ from expected if move fails)
|
|
557
|
+
let actualParentId = currentParentId;
|
|
558
|
+
const needsMove = expectedParentId !== currentParentId;
|
|
559
|
+
|
|
560
|
+
if (needsMove) {
|
|
561
|
+
console.log(chalk.yellow(` Parent changed: page will be moved to new location`));
|
|
562
|
+
if (!options.dryRun) {
|
|
563
|
+
if (expectedParentId) {
|
|
564
|
+
try {
|
|
565
|
+
await client.movePage(pageId, expectedParentId, 'append');
|
|
566
|
+
actualParentId = expectedParentId;
|
|
567
|
+
console.log(chalk.green(` Moved page under parent ID: ${expectedParentId}`));
|
|
568
|
+
} catch (error) {
|
|
569
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
570
|
+
console.log(chalk.yellow(` Warning: Could not move page: ${message}`));
|
|
571
|
+
}
|
|
572
|
+
} else {
|
|
573
|
+
console.log(chalk.yellow(` Note: Cannot move to root level via API - page stays in current location`));
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Convert markdown to HTML with link conversion (ADR-0022, ADR-0024)
|
|
579
|
+
// Uses currentConfig which is maintained in-memory during batch pushes
|
|
580
|
+
console.log(chalk.gray(' Converting markdown to HTML...'));
|
|
581
|
+
const converter = new HtmlConverter();
|
|
582
|
+
const pageState = buildPageStateFromFiles(directory, currentConfig.pages);
|
|
583
|
+
const pageLookupMap = buildPageLookupMapFromCache(pageState);
|
|
584
|
+
const { html, warnings } = converter.convert(
|
|
585
|
+
content,
|
|
586
|
+
directory,
|
|
587
|
+
relativePath.replace(/^\.\//, ''),
|
|
588
|
+
currentConfig.spaceKey,
|
|
589
|
+
pageLookupMap,
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
// Validate content size
|
|
593
|
+
if (html.length > MAX_PAGE_SIZE) {
|
|
594
|
+
console.error('');
|
|
595
|
+
console.error(chalk.red(`Content too large: ${html.length} characters (max: ${MAX_PAGE_SIZE})`));
|
|
596
|
+
console.log(chalk.gray('Confluence has a page size limit. Consider splitting into multiple pages.'));
|
|
597
|
+
throw new PushError(`Content too large: ${html.length} characters`, EXIT_CODES.INVALID_ARGUMENTS);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Show conversion warnings (includes page state build warnings)
|
|
601
|
+
const allWarnings = [...pageState.warnings, ...warnings];
|
|
602
|
+
if (allWarnings.length > 0) {
|
|
603
|
+
console.log('');
|
|
604
|
+
console.log(chalk.yellow('Conversion warnings:'));
|
|
605
|
+
for (const warning of allWarnings) {
|
|
606
|
+
console.log(chalk.yellow(` ! ${warning}`));
|
|
607
|
+
}
|
|
608
|
+
console.log('');
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Build update request
|
|
612
|
+
const newVersion = (options.force ? remoteVersion : localVersion) + 1;
|
|
613
|
+
const updateRequest: UpdatePageRequest = {
|
|
614
|
+
id: pageId,
|
|
615
|
+
status: 'current',
|
|
616
|
+
title,
|
|
617
|
+
body: {
|
|
618
|
+
representation: 'storage',
|
|
619
|
+
value: html,
|
|
620
|
+
},
|
|
621
|
+
version: {
|
|
622
|
+
number: newVersion,
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
// Dry run mode
|
|
627
|
+
if (options.dryRun) {
|
|
628
|
+
console.log(chalk.blue('\n--- DRY RUN MODE ---'));
|
|
629
|
+
console.log(chalk.gray(`Would update: ${title} (${pageId}), v${localVersion} → v${newVersion}`));
|
|
630
|
+
if (options.force) console.log(chalk.yellow(' Force mode: Would overwrite remote changes'));
|
|
631
|
+
if (needsMove) console.log(chalk.blue(` Would move to parent: ${expectedParentId ?? 'root'}`));
|
|
632
|
+
console.log(chalk.gray(` Content: ${html.length} chars`));
|
|
633
|
+
console.log(chalk.blue('No changes were made (dry run mode)\n'));
|
|
634
|
+
return { success: true, updatedConfig: currentConfig, pageInfo: { pageId, title, localPath: relativePath } };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Push to Confluence
|
|
638
|
+
console.log(chalk.gray(` Pushing to Confluence (version ${localVersion} → ${newVersion})...`));
|
|
639
|
+
const updatedPage = await client.updatePage(updateRequest);
|
|
640
|
+
|
|
641
|
+
// Update local frontmatter with new metadata from response
|
|
642
|
+
// Note: space_key is not included (inferred from .confluence.json)
|
|
643
|
+
const webui = updatedPage._links?.webui;
|
|
644
|
+
const updatedFrontmatter: PageFrontmatter = {
|
|
645
|
+
...frontmatter,
|
|
646
|
+
page_id: pageId,
|
|
647
|
+
title: updatedPage.title,
|
|
648
|
+
version: updatedPage.version?.number || newVersion,
|
|
649
|
+
updated_at: updatedPage.version?.createdAt,
|
|
650
|
+
last_modifier_id: updatedPage.version?.authorId,
|
|
651
|
+
parent_id: actualParentId,
|
|
652
|
+
url: webui ? `${config.confluenceUrl}/wiki${webui}` : frontmatter.url,
|
|
653
|
+
synced_at: new Date().toISOString(),
|
|
654
|
+
};
|
|
655
|
+
const updatedMarkdown = serializeMarkdown(updatedFrontmatter, content);
|
|
656
|
+
|
|
657
|
+
// Handle file rename if title changed (also updates references in other files)
|
|
658
|
+
const { finalPath: finalLocalPath } = handleFileRename(
|
|
659
|
+
filePath,
|
|
660
|
+
relativePath,
|
|
661
|
+
updatedPage.title,
|
|
662
|
+
updatedMarkdown,
|
|
663
|
+
directory,
|
|
664
|
+
);
|
|
665
|
+
|
|
666
|
+
// Update .confluence.json sync state
|
|
667
|
+
// Start with currentConfig (includes folder updates from determineExpectedParent)
|
|
668
|
+
// Per ADR-0024: Only store pageId -> localPath mapping
|
|
669
|
+
const updatedSpaceConfig = updatePageSyncInfo(currentConfig, {
|
|
670
|
+
pageId,
|
|
671
|
+
localPath: finalLocalPath,
|
|
672
|
+
});
|
|
673
|
+
writeSpaceConfig(directory, updatedSpaceConfig);
|
|
674
|
+
|
|
675
|
+
// Success!
|
|
676
|
+
console.log('');
|
|
677
|
+
console.log(
|
|
678
|
+
chalk.green(
|
|
679
|
+
`✓ Pushed: ${updatedPage.title} (version ${localVersion} → ${updatedPage.version?.number || newVersion})`,
|
|
680
|
+
),
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
if (webui) {
|
|
684
|
+
console.log(chalk.gray(` ${config.confluenceUrl}/wiki${webui}`));
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
return {
|
|
688
|
+
success: true,
|
|
689
|
+
updatedConfig: updatedSpaceConfig,
|
|
690
|
+
pageInfo: {
|
|
691
|
+
pageId,
|
|
692
|
+
title: updatedPage.title,
|
|
693
|
+
localPath: finalLocalPath,
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
} catch (error) {
|
|
697
|
+
handlePushError(error, relativePath);
|
|
698
|
+
}
|
|
699
|
+
}
|