@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,53 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { ConfluenceClient } from '../../lib/confluence-client/index.js';
|
|
3
|
+
import { ConfigManager } from '../../lib/config.js';
|
|
4
|
+
import { EXIT_CODES } from '../../lib/errors.js';
|
|
5
|
+
import { escapeXml } from '../../lib/formatters.js';
|
|
6
|
+
import { resolvePageTarget } from '../../lib/resolve-page-target.js';
|
|
7
|
+
|
|
8
|
+
export interface LabelsCommandOptions {
|
|
9
|
+
add?: string;
|
|
10
|
+
remove?: string;
|
|
11
|
+
xml?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function labelsCommand(target: string, options: LabelsCommandOptions = {}): Promise<void> {
|
|
15
|
+
const configManager = new ConfigManager();
|
|
16
|
+
const config = await configManager.getConfig();
|
|
17
|
+
|
|
18
|
+
if (!config) {
|
|
19
|
+
console.error(chalk.red('Not configured. Run: cn setup'));
|
|
20
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const pageId = resolvePageTarget(target);
|
|
24
|
+
const client = new ConfluenceClient(config);
|
|
25
|
+
|
|
26
|
+
if (options.add) {
|
|
27
|
+
await client.addLabel(pageId, options.add);
|
|
28
|
+
console.log(`${chalk.green('Added label:')} ${options.add}`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (options.remove) {
|
|
32
|
+
await client.removeLabel(pageId, options.remove);
|
|
33
|
+
console.log(`${chalk.green('Removed label:')} ${options.remove}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const labels = await client.getAllLabels(pageId);
|
|
37
|
+
|
|
38
|
+
if (options.xml) {
|
|
39
|
+
console.log('<labels>');
|
|
40
|
+
for (const label of labels) {
|
|
41
|
+
console.log(` <label>${escapeXml(label.name)}</label>`);
|
|
42
|
+
}
|
|
43
|
+
console.log('</labels>');
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (labels.length === 0) {
|
|
48
|
+
console.log('No labels.');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
console.log(labels.map((l) => chalk.cyan(l.name)).join(', '));
|
|
53
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { ConfluenceClient } from '../../lib/confluence-client/index.js';
|
|
3
|
+
import { ConfigManager } from '../../lib/config.js';
|
|
4
|
+
import { EXIT_CODES } from '../../lib/errors.js';
|
|
5
|
+
import { resolvePageTarget } from '../../lib/resolve-page-target.js';
|
|
6
|
+
|
|
7
|
+
export async function moveCommand(target: string, parentId: string): Promise<void> {
|
|
8
|
+
const configManager = new ConfigManager();
|
|
9
|
+
const config = await configManager.getConfig();
|
|
10
|
+
|
|
11
|
+
if (!config) {
|
|
12
|
+
console.error(chalk.red('Not configured. Run: cn setup'));
|
|
13
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const pageId = resolvePageTarget(target);
|
|
17
|
+
const client = new ConfluenceClient(config);
|
|
18
|
+
|
|
19
|
+
const [page, parent] = await Promise.all([client.getPage(pageId, false), client.getPage(parentId, false)]);
|
|
20
|
+
|
|
21
|
+
await client.movePage(pageId, parentId);
|
|
22
|
+
console.log(`${chalk.green('Moved:')} "${page.title}" under "${parent.title}"`);
|
|
23
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { ConfluenceClient } from '../../lib/confluence-client/index.js';
|
|
5
|
+
import { ConfigManager } from '../../lib/config.js';
|
|
6
|
+
import { EXIT_CODES } from '../../lib/errors.js';
|
|
7
|
+
import { extractPageId } from '../../lib/markdown/index.js';
|
|
8
|
+
import { readSpaceConfig } from '../../lib/space-config.js';
|
|
9
|
+
import { openUrl } from '../utils/browser.js';
|
|
10
|
+
|
|
11
|
+
export interface OpenCommandOptions {
|
|
12
|
+
page?: string;
|
|
13
|
+
spaceKey?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Open command - opens a page in the browser
|
|
18
|
+
*/
|
|
19
|
+
export async function openCommand(options: OpenCommandOptions = {}): Promise<void> {
|
|
20
|
+
const configManager = new ConfigManager();
|
|
21
|
+
const config = await configManager.getConfig();
|
|
22
|
+
|
|
23
|
+
if (!config) {
|
|
24
|
+
console.error(chalk.red('Not configured. Please run "cn setup" first.'));
|
|
25
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const client = new ConfluenceClient(config);
|
|
29
|
+
const directory = process.cwd();
|
|
30
|
+
|
|
31
|
+
// Get space info
|
|
32
|
+
let spaceKey: string | undefined = options.spaceKey;
|
|
33
|
+
let spaceId: string | undefined;
|
|
34
|
+
|
|
35
|
+
if (!spaceKey) {
|
|
36
|
+
const spaceConfig = readSpaceConfig(directory);
|
|
37
|
+
if (spaceConfig) {
|
|
38
|
+
spaceKey = spaceConfig.spaceKey;
|
|
39
|
+
spaceId = spaceConfig.spaceId;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// If no page specified, open space home
|
|
44
|
+
if (!options.page) {
|
|
45
|
+
if (!spaceKey) {
|
|
46
|
+
console.error(chalk.red('No space specified and no space configured in this directory.'));
|
|
47
|
+
console.log(chalk.gray('Specify a page to open or run "cn sync --init <SPACE_KEY>" first.'));
|
|
48
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const url = `${config.confluenceUrl}/wiki/spaces/${spaceKey}`;
|
|
52
|
+
console.log(chalk.gray(`Opening space: ${url}`));
|
|
53
|
+
openUrl(url);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const pageArg = options.page;
|
|
58
|
+
|
|
59
|
+
// Check if it's a file path
|
|
60
|
+
if (pageArg.endsWith('.md') || pageArg.includes('/')) {
|
|
61
|
+
const filePath = pageArg.startsWith('/') ? pageArg : join(directory, pageArg);
|
|
62
|
+
|
|
63
|
+
if (existsSync(filePath)) {
|
|
64
|
+
try {
|
|
65
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
66
|
+
const pageId = extractPageId(content);
|
|
67
|
+
|
|
68
|
+
if (pageId) {
|
|
69
|
+
const page = await client.getPage(pageId, false);
|
|
70
|
+
const webui = page._links?.webui;
|
|
71
|
+
if (webui) {
|
|
72
|
+
const url = `${config.confluenceUrl}/wiki${webui}`;
|
|
73
|
+
console.log(chalk.gray(`Opening page: ${url}`));
|
|
74
|
+
openUrl(url);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
} catch (_error) {
|
|
79
|
+
// Fall through to other methods
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Check if it's a page ID (numeric string)
|
|
85
|
+
if (/^\d+$/.test(pageArg)) {
|
|
86
|
+
try {
|
|
87
|
+
const page = await client.getPage(pageArg, false);
|
|
88
|
+
const webui = page._links?.webui;
|
|
89
|
+
if (webui) {
|
|
90
|
+
const url = `${config.confluenceUrl}/wiki${webui}`;
|
|
91
|
+
console.log(chalk.gray(`Opening page: ${url}`));
|
|
92
|
+
openUrl(url);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
} catch (_error) {
|
|
96
|
+
// Fall through to title search
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Search by title
|
|
101
|
+
if (!spaceId && spaceKey) {
|
|
102
|
+
try {
|
|
103
|
+
const space = await client.getSpaceByKey(spaceKey);
|
|
104
|
+
spaceId = space.id;
|
|
105
|
+
} catch (_error) {
|
|
106
|
+
console.error(chalk.red(`Space "${spaceKey}" not found.`));
|
|
107
|
+
process.exit(EXIT_CODES.SPACE_NOT_FOUND);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!spaceId) {
|
|
112
|
+
console.error(chalk.red('No space specified. Use --space or configure a space in this directory.'));
|
|
113
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Search for page by title
|
|
117
|
+
try {
|
|
118
|
+
const pages = await client.getAllPagesInSpace(spaceId);
|
|
119
|
+
const matchingPage = pages.find(
|
|
120
|
+
(p) => p.title.toLowerCase() === pageArg.toLowerCase() || p.title.toLowerCase().includes(pageArg.toLowerCase()),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
if (matchingPage) {
|
|
124
|
+
const webui = matchingPage._links?.webui;
|
|
125
|
+
if (webui) {
|
|
126
|
+
const url = `${config.confluenceUrl}/wiki${webui}`;
|
|
127
|
+
console.log(chalk.gray(`Opening page: ${url}`));
|
|
128
|
+
openUrl(url);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Fallback to constructing URL
|
|
133
|
+
const url = `${config.confluenceUrl}/wiki/spaces/${spaceKey}/pages/${matchingPage.id}`;
|
|
134
|
+
console.log(chalk.gray(`Opening page: ${url}`));
|
|
135
|
+
openUrl(url);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
console.error(chalk.red(`Page "${pageArg}" not found in space ${spaceKey}.`));
|
|
140
|
+
process.exit(EXIT_CODES.GENERAL_ERROR);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
console.error(chalk.red(error instanceof Error ? error.message : 'Unknown error'));
|
|
143
|
+
process.exit(EXIT_CODES.GENERAL_ERROR);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { confirm } from '@inquirer/prompts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { unlinkSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { ConfigManager } from '../../lib/config.js';
|
|
6
|
+
import { EXIT_CODES } from '../../lib/errors.js';
|
|
7
|
+
import { getFormatter } from '../../lib/formatters.js';
|
|
8
|
+
import {
|
|
9
|
+
findBestDuplicate,
|
|
10
|
+
findDuplicatePageIds,
|
|
11
|
+
findStaleDuplicates,
|
|
12
|
+
scanFilesForHealthCheck,
|
|
13
|
+
} from '../../lib/health-check.js';
|
|
14
|
+
import { readSpaceConfig, hasSpaceConfig } from '../../lib/space-config.js';
|
|
15
|
+
import { SyncEngine } from '../../lib/sync/index.js';
|
|
16
|
+
import { createProgressReporter } from '../utils/progress-reporter.js';
|
|
17
|
+
|
|
18
|
+
export interface PullCommandOptions {
|
|
19
|
+
dryRun?: boolean;
|
|
20
|
+
force?: boolean;
|
|
21
|
+
depth?: number;
|
|
22
|
+
pages?: string[]; // Page IDs or local paths to force resync
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface CleanupContext {
|
|
26
|
+
sigintHandler: () => void;
|
|
27
|
+
stdinHandler: (data: Buffer | string) => void;
|
|
28
|
+
restoreRawMode: boolean | undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Clean up signal handlers and restore stdin state
|
|
33
|
+
*/
|
|
34
|
+
function cleanupHandlers(ctx: CleanupContext): void {
|
|
35
|
+
process.off('SIGINT', ctx.sigintHandler);
|
|
36
|
+
process.off('SIGTERM', ctx.sigintHandler);
|
|
37
|
+
if (process.stdin.isTTY) {
|
|
38
|
+
process.stdin.off('data', ctx.stdinHandler);
|
|
39
|
+
process.stdin.pause();
|
|
40
|
+
}
|
|
41
|
+
if (ctx.restoreRawMode !== undefined && process.stdin.setRawMode) {
|
|
42
|
+
try {
|
|
43
|
+
process.stdin.setRawMode(ctx.restoreRawMode);
|
|
44
|
+
} catch {
|
|
45
|
+
// Ignore errors restoring raw mode
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Pull command - pulls a Confluence space to the local directory
|
|
52
|
+
*/
|
|
53
|
+
export async function pullCommand(options: PullCommandOptions): Promise<void> {
|
|
54
|
+
const configManager = new ConfigManager();
|
|
55
|
+
const config = await configManager.getConfig();
|
|
56
|
+
|
|
57
|
+
if (!config) {
|
|
58
|
+
console.error(chalk.red('Not configured. Please run "cn setup" first.'));
|
|
59
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const syncEngine = new SyncEngine(config);
|
|
63
|
+
const directory = process.cwd();
|
|
64
|
+
const formatter = getFormatter(false);
|
|
65
|
+
|
|
66
|
+
// Check if space is configured
|
|
67
|
+
if (!hasSpaceConfig(directory)) {
|
|
68
|
+
console.error(chalk.red('No space configured in this directory.'));
|
|
69
|
+
console.log(chalk.gray('Run "cn clone <SPACE_KEY>" to clone a space.'));
|
|
70
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Get space info for display
|
|
74
|
+
const spaceConfig = readSpaceConfig(directory);
|
|
75
|
+
if (spaceConfig) {
|
|
76
|
+
console.log(chalk.bold(`Pulling space: ${spaceConfig.spaceName} (${spaceConfig.spaceKey})`));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Check for duplicate page_ids before proceeding
|
|
80
|
+
const allFiles = scanFilesForHealthCheck(directory);
|
|
81
|
+
const duplicates = findDuplicatePageIds(allFiles);
|
|
82
|
+
|
|
83
|
+
if (duplicates.length > 0) {
|
|
84
|
+
console.log('');
|
|
85
|
+
console.log(chalk.red('Duplicate page_ids detected:'));
|
|
86
|
+
for (const dup of duplicates) {
|
|
87
|
+
const best = findBestDuplicate(dup.files);
|
|
88
|
+
console.log(chalk.yellow(`\n page_id: ${dup.pageId}`));
|
|
89
|
+
for (const file of dup.files) {
|
|
90
|
+
const isBest = file.path === best.path;
|
|
91
|
+
const marker = isBest ? chalk.green(' (keep)') : chalk.red(' (stale)');
|
|
92
|
+
const version = file.version ? `v${file.version}` : 'v?';
|
|
93
|
+
console.log(` ${isBest ? chalk.green('*') : chalk.red('x')} ${file.path}${marker} - ${version}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
console.log('');
|
|
97
|
+
|
|
98
|
+
// Offer to auto-fix
|
|
99
|
+
const shouldFix = await confirm({
|
|
100
|
+
message: 'Delete stale files before pulling?',
|
|
101
|
+
default: true,
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (shouldFix) {
|
|
105
|
+
for (const dup of duplicates) {
|
|
106
|
+
const stale = findStaleDuplicates(dup);
|
|
107
|
+
for (const file of stale) {
|
|
108
|
+
try {
|
|
109
|
+
unlinkSync(join(directory, file.path));
|
|
110
|
+
console.log(chalk.green(` Deleted: ${file.path}`));
|
|
111
|
+
} catch (error) {
|
|
112
|
+
console.log(
|
|
113
|
+
chalk.red(` Failed to delete ${file.path}: ${error instanceof Error ? error.message : 'Unknown error'}`),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
console.log('');
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Cancellation signal - shared between handler and sync engine
|
|
123
|
+
const signal = { cancelled: false };
|
|
124
|
+
|
|
125
|
+
// Force raw mode to capture Ctrl+C as data when Bun doesn't deliver SIGINT.
|
|
126
|
+
let restoreRawMode: boolean | undefined;
|
|
127
|
+
if (process.stdin.isTTY && process.stdin.setRawMode) {
|
|
128
|
+
try {
|
|
129
|
+
restoreRawMode = process.stdin.isRaw ?? false;
|
|
130
|
+
process.stdin.setRawMode(true);
|
|
131
|
+
} catch {
|
|
132
|
+
restoreRawMode = undefined;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Handle Ctrl+C via SIGINT
|
|
137
|
+
const sigintHandler = (): void => {
|
|
138
|
+
if (signal.cancelled) return;
|
|
139
|
+
signal.cancelled = true;
|
|
140
|
+
console.log('');
|
|
141
|
+
console.log(chalk.yellow('Cancelling pull...'));
|
|
142
|
+
};
|
|
143
|
+
process.on('SIGINT', sigintHandler);
|
|
144
|
+
process.on('SIGTERM', sigintHandler);
|
|
145
|
+
|
|
146
|
+
// Fallback: Handle Ctrl+C as raw byte (0x03/ETX) when terminal is in raw mode
|
|
147
|
+
// This catches Ctrl+C even when SIGINT isn't generated
|
|
148
|
+
const stdinHandler = (data: Buffer | string): void => {
|
|
149
|
+
const isCtrlC = typeof data === 'string' ? data.includes('\u0003') : data.includes(0x03);
|
|
150
|
+
if (isCtrlC) {
|
|
151
|
+
// ETX byte = Ctrl+C
|
|
152
|
+
if (signal.cancelled) return;
|
|
153
|
+
signal.cancelled = true;
|
|
154
|
+
console.log('');
|
|
155
|
+
console.log(chalk.yellow('Cancelling pull...'));
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
if (process.stdin.isTTY) {
|
|
159
|
+
process.stdin.on('data', stdinHandler);
|
|
160
|
+
process.stdin.resume();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Perform sync
|
|
164
|
+
const progressReporter = options.dryRun ? undefined : createProgressReporter();
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const result = await syncEngine.sync(directory, {
|
|
168
|
+
dryRun: options.dryRun,
|
|
169
|
+
force: options.force,
|
|
170
|
+
forcePages: options.pages,
|
|
171
|
+
depth: options.depth,
|
|
172
|
+
progress: progressReporter,
|
|
173
|
+
signal,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// Clean up handlers
|
|
177
|
+
cleanupHandlers({ sigintHandler, stdinHandler, restoreRawMode });
|
|
178
|
+
|
|
179
|
+
// For dry run, show diff
|
|
180
|
+
if (options.dryRun) {
|
|
181
|
+
console.log('');
|
|
182
|
+
console.log(formatter.formatSyncDiff(result.changes));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Show warnings
|
|
186
|
+
if (result.warnings.length > 0) {
|
|
187
|
+
console.log('');
|
|
188
|
+
console.log(chalk.yellow('Warnings:'));
|
|
189
|
+
for (const warning of result.warnings) {
|
|
190
|
+
console.log(chalk.yellow(` ! ${warning}`));
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Show errors
|
|
195
|
+
if (result.errors.length > 0) {
|
|
196
|
+
console.log('');
|
|
197
|
+
console.log(chalk.red('Errors:'));
|
|
198
|
+
for (const error of result.errors) {
|
|
199
|
+
console.log(chalk.red(` x ${error}`));
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Handle cancellation
|
|
204
|
+
if (result.cancelled) {
|
|
205
|
+
console.log('');
|
|
206
|
+
console.log(chalk.yellow('Pull cancelled. Run "cn pull" again to resume.'));
|
|
207
|
+
process.exit(130);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// If dry run, offer to sync
|
|
211
|
+
if (options.dryRun) {
|
|
212
|
+
const totalChanges = result.changes.added.length + result.changes.modified.length + result.changes.deleted.length;
|
|
213
|
+
|
|
214
|
+
if (totalChanges > 0) {
|
|
215
|
+
console.log('');
|
|
216
|
+
console.log(chalk.gray('This was a dry run. No changes were made.'));
|
|
217
|
+
}
|
|
218
|
+
} else if (!result.success) {
|
|
219
|
+
console.log('');
|
|
220
|
+
console.error(chalk.red('Pull completed with errors.'));
|
|
221
|
+
process.exit(EXIT_CODES.GENERAL_ERROR);
|
|
222
|
+
} else {
|
|
223
|
+
const { added, modified, deleted } = result.changes;
|
|
224
|
+
const total = added.length + modified.length + deleted.length;
|
|
225
|
+
if (total > 0) {
|
|
226
|
+
console.log('');
|
|
227
|
+
const parts = [];
|
|
228
|
+
if (added.length > 0) parts.push(`${added.length} added`);
|
|
229
|
+
if (modified.length > 0) parts.push(`${modified.length} modified`);
|
|
230
|
+
if (deleted.length > 0) parts.push(`${deleted.length} deleted`);
|
|
231
|
+
console.log(chalk.green(`✓ Pull complete: ${parts.join(', ')}`));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
} catch (error) {
|
|
235
|
+
// Clean up handlers
|
|
236
|
+
cleanupHandlers({ sigintHandler, stdinHandler, restoreRawMode });
|
|
237
|
+
console.error(chalk.red('Pull failed'));
|
|
238
|
+
console.error(chalk.red(error instanceof Error ? error.message : 'Unknown error'));
|
|
239
|
+
process.exit(EXIT_CODES.GENERAL_ERROR);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { EXIT_CODES, PageNotFoundError, VersionConflictError } from '../../lib/errors.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Error thrown during push operations
|
|
6
|
+
*/
|
|
7
|
+
export class PushError extends Error {
|
|
8
|
+
constructor(
|
|
9
|
+
message: string,
|
|
10
|
+
public readonly exitCode: number,
|
|
11
|
+
) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'PushError';
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Handle push errors - formats error and throws PushError
|
|
19
|
+
*/
|
|
20
|
+
export function handlePushError(error: unknown, filePath: string): never {
|
|
21
|
+
if (error instanceof PageNotFoundError) {
|
|
22
|
+
console.error('');
|
|
23
|
+
console.error(chalk.red(`Page not found on Confluence (ID: ${error.pageId}).`));
|
|
24
|
+
console.log(chalk.gray('The page may have been deleted.'));
|
|
25
|
+
throw new PushError(`Page not found: ${error.pageId}`, EXIT_CODES.PAGE_NOT_FOUND);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (error instanceof VersionConflictError) {
|
|
29
|
+
console.error('');
|
|
30
|
+
console.error(chalk.red('Version conflict: remote version has changed.'));
|
|
31
|
+
console.log(chalk.gray(`Run "cn pull --page ${filePath}" to get the latest version.`));
|
|
32
|
+
throw new PushError('Version conflict', EXIT_CODES.VERSION_CONFLICT);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.error('');
|
|
36
|
+
console.error(chalk.red('Push failed'));
|
|
37
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
38
|
+
console.error(chalk.red(message));
|
|
39
|
+
throw new PushError(message, EXIT_CODES.GENERAL_ERROR);
|
|
40
|
+
}
|