@aaronshaf/confluence-cli 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +69 -0
- package/package.json +73 -0
- package/src/cli/commands/attachments.ts +113 -0
- package/src/cli/commands/clone.ts +188 -0
- package/src/cli/commands/comments.ts +56 -0
- package/src/cli/commands/create.ts +58 -0
- package/src/cli/commands/delete.ts +46 -0
- package/src/cli/commands/doctor.ts +161 -0
- package/src/cli/commands/duplicate-check.ts +89 -0
- package/src/cli/commands/file-rename.ts +113 -0
- package/src/cli/commands/folder-hierarchy.ts +241 -0
- package/src/cli/commands/info.ts +56 -0
- package/src/cli/commands/labels.ts +53 -0
- package/src/cli/commands/move.ts +23 -0
- package/src/cli/commands/open.ts +145 -0
- package/src/cli/commands/pull.ts +241 -0
- package/src/cli/commands/push-errors.ts +40 -0
- package/src/cli/commands/push.ts +699 -0
- package/src/cli/commands/search.ts +62 -0
- package/src/cli/commands/setup.ts +124 -0
- package/src/cli/commands/spaces.ts +42 -0
- package/src/cli/commands/status.ts +88 -0
- package/src/cli/commands/tree.ts +190 -0
- package/src/cli/help.ts +425 -0
- package/src/cli/index.ts +413 -0
- package/src/cli/utils/browser.ts +34 -0
- package/src/cli/utils/progress-reporter.ts +49 -0
- package/src/cli.ts +6 -0
- package/src/lib/config.ts +156 -0
- package/src/lib/confluence-client/attachment-operations.ts +221 -0
- package/src/lib/confluence-client/client.ts +653 -0
- package/src/lib/confluence-client/comment-operations.ts +60 -0
- package/src/lib/confluence-client/folder-operations.ts +203 -0
- package/src/lib/confluence-client/index.ts +47 -0
- package/src/lib/confluence-client/label-operations.ts +102 -0
- package/src/lib/confluence-client/page-operations.ts +270 -0
- package/src/lib/confluence-client/search-operations.ts +60 -0
- package/src/lib/confluence-client/types.ts +329 -0
- package/src/lib/confluence-client/user-operations.ts +58 -0
- package/src/lib/dependency-sorter.ts +233 -0
- package/src/lib/errors.ts +237 -0
- package/src/lib/file-scanner.ts +195 -0
- package/src/lib/formatters.ts +314 -0
- package/src/lib/health-check.ts +204 -0
- package/src/lib/markdown/converter.ts +427 -0
- package/src/lib/markdown/frontmatter.ts +116 -0
- package/src/lib/markdown/html-converter.ts +398 -0
- package/src/lib/markdown/index.ts +21 -0
- package/src/lib/markdown/link-converter.ts +189 -0
- package/src/lib/markdown/reference-updater.ts +251 -0
- package/src/lib/markdown/slugify.ts +32 -0
- package/src/lib/page-state.ts +195 -0
- package/src/lib/resolve-page-target.ts +33 -0
- package/src/lib/space-config.ts +264 -0
- package/src/lib/sync/cleanup.ts +50 -0
- package/src/lib/sync/folder-path.ts +61 -0
- package/src/lib/sync/index.ts +2 -0
- package/src/lib/sync/link-resolution-pass.ts +139 -0
- package/src/lib/sync/sync-engine.ts +681 -0
- package/src/lib/sync/sync-specific.ts +221 -0
- package/src/lib/sync/types.ts +42 -0
- package/src/test/attachments.test.ts +68 -0
- package/src/test/clone.test.ts +373 -0
- package/src/test/comments.test.ts +53 -0
- package/src/test/config.test.ts +209 -0
- package/src/test/confluence-client.test.ts +535 -0
- package/src/test/delete.test.ts +39 -0
- package/src/test/dependency-sorter.test.ts +384 -0
- package/src/test/errors.test.ts +199 -0
- package/src/test/file-rename.test.ts +305 -0
- package/src/test/file-scanner.test.ts +331 -0
- package/src/test/folder-hierarchy.test.ts +337 -0
- package/src/test/formatters.test.ts +213 -0
- package/src/test/html-converter.test.ts +399 -0
- package/src/test/info.test.ts +56 -0
- package/src/test/labels.test.ts +70 -0
- package/src/test/link-conversion-integration.test.ts +189 -0
- package/src/test/link-converter.test.ts +413 -0
- package/src/test/link-resolution-pass.test.ts +368 -0
- package/src/test/markdown.test.ts +443 -0
- package/src/test/mocks/handlers.ts +228 -0
- package/src/test/move.test.ts +53 -0
- package/src/test/msw-schema-validation.ts +151 -0
- package/src/test/page-state.test.ts +542 -0
- package/src/test/push.test.ts +551 -0
- package/src/test/reference-updater.test.ts +293 -0
- package/src/test/resolve-page-target.test.ts +55 -0
- package/src/test/search.test.ts +64 -0
- package/src/test/setup-msw.ts +75 -0
- package/src/test/space-config.test.ts +516 -0
- package/src/test/spaces.test.ts +53 -0
- package/src/test/sync-engine.test.ts +486 -0
- package/src/types/turndown-plugin-gfm.d.ts +9 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aaron Shafovaloff
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# cn
|
|
2
|
+
|
|
3
|
+
CLI for syncing Confluence spaces to local markdown.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun install -g @aaronshaf/confluence-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Getting Started
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# 1. Configure your Confluence credentials
|
|
15
|
+
cn setup
|
|
16
|
+
|
|
17
|
+
# 2. Clone a Confluence space
|
|
18
|
+
cn clone <SPACE_KEY>
|
|
19
|
+
|
|
20
|
+
# 3. Pull pages as markdown
|
|
21
|
+
cd <SPACE_KEY>
|
|
22
|
+
cn pull
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The space key is the identifier in your Confluence URL:
|
|
26
|
+
`https://yoursite.atlassian.net/wiki/spaces/<SPACE_KEY>/...`
|
|
27
|
+
|
|
28
|
+
Credentials are stored in `~/.cn/config.json`. Space configuration is saved to `.confluence.json` in the synced directory.
|
|
29
|
+
|
|
30
|
+
## Commands
|
|
31
|
+
|
|
32
|
+
| Command | Description |
|
|
33
|
+
|---------|-------------|
|
|
34
|
+
| `cn setup` | Configure Confluence credentials |
|
|
35
|
+
| `cn clone <SPACE_KEY>` | Clone a space to a new folder |
|
|
36
|
+
| `cn pull` | Pull changes from Confluence as markdown |
|
|
37
|
+
| `cn push [file]` | Push local markdown file(s) to Confluence |
|
|
38
|
+
| `cn status` | Check connection and sync status |
|
|
39
|
+
| `cn tree` | Display page hierarchy |
|
|
40
|
+
| `cn open [page]` | Open page in browser |
|
|
41
|
+
| `cn doctor` | Health check for sync issues |
|
|
42
|
+
| `cn search <query>` | Search pages using CQL |
|
|
43
|
+
| `cn spaces` | List available spaces |
|
|
44
|
+
| `cn info <id\|file>` | Show page info and labels |
|
|
45
|
+
| `cn create <title>` | Create a new page |
|
|
46
|
+
| `cn delete <id>` | Delete a page |
|
|
47
|
+
| `cn comments <id\|file>` | Show page comments |
|
|
48
|
+
| `cn labels <id\|file>` | Manage page labels |
|
|
49
|
+
| `cn move <id\|file> <parentId>` | Move a page to a new parent |
|
|
50
|
+
| `cn attachments <id\|file>` | Manage page attachments |
|
|
51
|
+
|
|
52
|
+
Run `cn <command> --help` for details on each command.
|
|
53
|
+
|
|
54
|
+
## Requirements
|
|
55
|
+
|
|
56
|
+
- Bun 1.2.0+
|
|
57
|
+
- Confluence Cloud account
|
|
58
|
+
|
|
59
|
+
## Development
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
bun install
|
|
63
|
+
bun run cn --help
|
|
64
|
+
bun test
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aaronshaf/confluence-cli",
|
|
3
|
+
"version": "0.1.15",
|
|
4
|
+
"description": "Confluence CLI for syncing spaces and local markdown files",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cn": "./src/cli.ts"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md",
|
|
12
|
+
"LICENSE",
|
|
13
|
+
"package.json"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"bun": ">=1.2.0"
|
|
17
|
+
},
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "https://github.com/aaronshaf/confluence-cli.git"
|
|
24
|
+
},
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/aaronshaf/confluence-cli/issues"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/aaronshaf/confluence-cli#readme",
|
|
29
|
+
"scripts": {
|
|
30
|
+
"dev": "bun run --watch src/cli.ts",
|
|
31
|
+
"test": "BUN_TEST_JOBS=1 NODE_ENV=test bun test",
|
|
32
|
+
"test:coverage": "BUN_TEST_JOBS=1 NODE_ENV=test bun test --coverage",
|
|
33
|
+
"test:coverage:report": "BUN_TEST_JOBS=1 NODE_ENV=test bun test --coverage --coverage-reporter=text",
|
|
34
|
+
"test:coverage:check": "BUN_TEST_JOBS=1 NODE_ENV=test bun test --coverage --coverage-threshold=70",
|
|
35
|
+
"check-file-sizes": "bun run scripts/check-file-sizes.ts",
|
|
36
|
+
"lint": "biome check",
|
|
37
|
+
"lint:fix": "biome check --write",
|
|
38
|
+
"format": "biome format --write",
|
|
39
|
+
"typecheck": "tsc --noEmit",
|
|
40
|
+
"cn": "bun run src/cli.ts",
|
|
41
|
+
"prepublishOnly": "bun run lint && bun run typecheck",
|
|
42
|
+
"pre-commit": "bun run typecheck && biome check --write && bun run check-file-sizes && bun run test:coverage:check",
|
|
43
|
+
"prepare": "node scripts/install-hooks.cjs"
|
|
44
|
+
},
|
|
45
|
+
"keywords": [
|
|
46
|
+
"confluence",
|
|
47
|
+
"cli",
|
|
48
|
+
"markdown",
|
|
49
|
+
"sync"
|
|
50
|
+
],
|
|
51
|
+
"author": "Aaron Shafovaloff <aaronshaf@gmail.com>",
|
|
52
|
+
"license": "MIT",
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@effect/schema": "^0.75.5",
|
|
55
|
+
"@inquirer/prompts": "^7.8.4",
|
|
56
|
+
"chalk": "^5.4.1",
|
|
57
|
+
"effect": "^3.16.10",
|
|
58
|
+
"gray-matter": "^4.0.3",
|
|
59
|
+
"marked": "17.0.1",
|
|
60
|
+
"ora": "^8.2.0",
|
|
61
|
+
"turndown": "^7.2.0",
|
|
62
|
+
"turndown-plugin-gfm": "^1.0.2",
|
|
63
|
+
"zod": "4.3.5"
|
|
64
|
+
},
|
|
65
|
+
"devDependencies": {
|
|
66
|
+
"@biomejs/biome": "^2.0.6",
|
|
67
|
+
"@types/marked": "6.0.0",
|
|
68
|
+
"@types/turndown": "^5.0.5",
|
|
69
|
+
"bun-types": "^1.0.20",
|
|
70
|
+
"msw": "^2.10.4",
|
|
71
|
+
"typescript": "^5.3.3"
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { basename } from 'node:path';
|
|
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 { escapeXml } from '../../lib/formatters.js';
|
|
8
|
+
import { resolvePageTarget } from '../../lib/resolve-page-target.js';
|
|
9
|
+
|
|
10
|
+
export interface AttachmentsCommandOptions {
|
|
11
|
+
upload?: string;
|
|
12
|
+
download?: string;
|
|
13
|
+
delete?: string;
|
|
14
|
+
xml?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function attachmentsCommand(target: string, options: AttachmentsCommandOptions = {}): Promise<void> {
|
|
18
|
+
const configManager = new ConfigManager();
|
|
19
|
+
const config = await configManager.getConfig();
|
|
20
|
+
|
|
21
|
+
if (!config) {
|
|
22
|
+
console.error(chalk.red('Not configured. Run: cn setup'));
|
|
23
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const pageId = resolvePageTarget(target);
|
|
27
|
+
const client = new ConfluenceClient(config);
|
|
28
|
+
|
|
29
|
+
if (options.upload) {
|
|
30
|
+
const filePath = options.upload;
|
|
31
|
+
const filename = basename(filePath);
|
|
32
|
+
const data = readFileSync(filePath);
|
|
33
|
+
const mimeType = guessMimeType(filename);
|
|
34
|
+
await client.uploadAttachment(pageId, filename, data, mimeType);
|
|
35
|
+
console.log(`${chalk.green('Uploaded:')} ${filename}`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (options.delete) {
|
|
40
|
+
await client.deleteAttachment(options.delete);
|
|
41
|
+
console.log(`${chalk.green('Deleted attachment:')} ${options.delete}`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const attachments = await client.getAllAttachments(pageId);
|
|
46
|
+
|
|
47
|
+
if (options.download) {
|
|
48
|
+
const attachment = attachments.find((a) => a.id === options.download);
|
|
49
|
+
if (!attachment) {
|
|
50
|
+
console.error(chalk.red(`Attachment not found: ${options.download}`));
|
|
51
|
+
process.exit(EXIT_CODES.GENERAL_ERROR);
|
|
52
|
+
}
|
|
53
|
+
if (!attachment.downloadLink) {
|
|
54
|
+
console.error(chalk.red('No download link available for this attachment.'));
|
|
55
|
+
process.exit(EXIT_CODES.GENERAL_ERROR);
|
|
56
|
+
}
|
|
57
|
+
const buf = await client.downloadAttachment(attachment.downloadLink);
|
|
58
|
+
const safeFilename = basename(attachment.title);
|
|
59
|
+
writeFileSync(safeFilename, buf);
|
|
60
|
+
console.log(`${chalk.green('Downloaded:')} ${safeFilename}`);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (options.xml) {
|
|
65
|
+
console.log('<attachments>');
|
|
66
|
+
for (const att of attachments) {
|
|
67
|
+
console.log(` <attachment id="${escapeXml(att.id)}">`);
|
|
68
|
+
console.log(` <title>${escapeXml(att.title)}</title>`);
|
|
69
|
+
if (att.mediaType) console.log(` <mediaType>${escapeXml(att.mediaType)}</mediaType>`);
|
|
70
|
+
if (att.fileSize != null) console.log(` <fileSize>${att.fileSize}</fileSize>`);
|
|
71
|
+
console.log(' </attachment>');
|
|
72
|
+
}
|
|
73
|
+
console.log('</attachments>');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (attachments.length === 0) {
|
|
78
|
+
console.log('No attachments.');
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const att of attachments) {
|
|
83
|
+
const size = att.fileSize != null ? ` (${formatBytes(att.fileSize)})` : '';
|
|
84
|
+
const mime = att.mediaType ? ` [${att.mediaType}]` : '';
|
|
85
|
+
console.log(`${chalk.bold(att.title)} ${chalk.gray(att.id)}${mime}${size}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function formatBytes(bytes: number): string {
|
|
90
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
91
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
|
92
|
+
return `${(bytes / 1024 / 1024).toFixed(1)}MB`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function guessMimeType(filename: string): string {
|
|
96
|
+
const ext = filename.split('.').pop()?.toLowerCase();
|
|
97
|
+
const mimeTypes: Record<string, string> = {
|
|
98
|
+
png: 'image/png',
|
|
99
|
+
jpg: 'image/jpeg',
|
|
100
|
+
jpeg: 'image/jpeg',
|
|
101
|
+
gif: 'image/gif',
|
|
102
|
+
pdf: 'application/pdf',
|
|
103
|
+
txt: 'text/plain',
|
|
104
|
+
md: 'text/markdown',
|
|
105
|
+
json: 'application/json',
|
|
106
|
+
docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
107
|
+
xlsx: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
108
|
+
pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
109
|
+
zip: 'application/zip',
|
|
110
|
+
csv: 'text/csv',
|
|
111
|
+
};
|
|
112
|
+
return mimeTypes[ext ?? ''] ?? 'application/octet-stream';
|
|
113
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { existsSync, mkdirSync, rmSync } from 'node:fs';
|
|
4
|
+
import { resolve } from 'node:path';
|
|
5
|
+
import { ConfigManager, type Config } from '../../lib/config.js';
|
|
6
|
+
import { EXIT_CODES } from '../../lib/errors.js';
|
|
7
|
+
import { SyncEngine } from '../../lib/sync/index.js';
|
|
8
|
+
import { createProgressReporter } from '../utils/progress-reporter.js';
|
|
9
|
+
|
|
10
|
+
const SEPARATOR = '='.repeat(60);
|
|
11
|
+
|
|
12
|
+
export interface CloneCommandOptions {
|
|
13
|
+
spaceKeys: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Clone command - clones one or more Confluence spaces to new local directories
|
|
18
|
+
*/
|
|
19
|
+
export async function cloneCommand(options: CloneCommandOptions): 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
|
+
// Check for duplicate space keys
|
|
29
|
+
const uniqueKeys = new Set(options.spaceKeys);
|
|
30
|
+
if (uniqueKeys.size !== options.spaceKeys.length) {
|
|
31
|
+
const duplicates = options.spaceKeys.filter((key, index) => options.spaceKeys.indexOf(key) !== index);
|
|
32
|
+
console.error(chalk.red('Duplicate space keys detected.'));
|
|
33
|
+
console.log(chalk.gray(`Duplicates: ${[...new Set(duplicates)].join(', ')}`));
|
|
34
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const results: Array<{ spaceKey: string; status: 'success' | 'error'; error?: string }> = [];
|
|
38
|
+
|
|
39
|
+
// Clone each space sequentially
|
|
40
|
+
for (let i = 0; i < options.spaceKeys.length; i++) {
|
|
41
|
+
const spaceKey = options.spaceKeys[i];
|
|
42
|
+
const isMultiSpace = options.spaceKeys.length > 1;
|
|
43
|
+
|
|
44
|
+
if (isMultiSpace) {
|
|
45
|
+
console.log(chalk.blue(`\n${SEPARATOR}`));
|
|
46
|
+
console.log(chalk.blue(`Cloning ${i + 1}/${options.spaceKeys.length}: ${chalk.bold(spaceKey)}`));
|
|
47
|
+
console.log(chalk.blue(SEPARATOR));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
await cloneSingleSpace({ spaceKey, directory: spaceKey }, config);
|
|
52
|
+
results.push({ spaceKey, status: 'success' });
|
|
53
|
+
} catch (error) {
|
|
54
|
+
results.push({
|
|
55
|
+
spaceKey,
|
|
56
|
+
status: 'error',
|
|
57
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Check for failures
|
|
63
|
+
const successes = results.filter((r) => r.status === 'success');
|
|
64
|
+
const failures = results.filter((r) => r.status === 'error');
|
|
65
|
+
|
|
66
|
+
// Display summary if multiple spaces were cloned
|
|
67
|
+
if (options.spaceKeys.length > 1) {
|
|
68
|
+
console.log(chalk.blue(`\n${SEPARATOR}`));
|
|
69
|
+
console.log(chalk.bold('Clone Summary'));
|
|
70
|
+
console.log(chalk.blue(SEPARATOR));
|
|
71
|
+
|
|
72
|
+
if (successes.length > 0) {
|
|
73
|
+
console.log(chalk.green(`✓ Successfully cloned: ${successes.map((r) => r.spaceKey).join(', ')}`));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (failures.length > 0) {
|
|
77
|
+
console.log(chalk.red(`✗ Failed to clone: ${failures.map((r) => r.spaceKey).join(', ')}`));
|
|
78
|
+
for (const failure of failures) {
|
|
79
|
+
console.log(chalk.red(` ${failure.spaceKey}: ${failure.error}`));
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Exit with error if any failures occurred
|
|
85
|
+
if (failures.length > 0) {
|
|
86
|
+
process.exit(EXIT_CODES.GENERAL_ERROR);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Clone a single space - extracted from original cloneCommand
|
|
92
|
+
*/
|
|
93
|
+
async function cloneSingleSpace(options: { spaceKey: string; directory?: string }, config: Config): Promise<void> {
|
|
94
|
+
const syncEngine = new SyncEngine(config);
|
|
95
|
+
|
|
96
|
+
// Determine target directory
|
|
97
|
+
const targetDir = options.directory || options.spaceKey;
|
|
98
|
+
const fullPath = resolve(process.cwd(), targetDir);
|
|
99
|
+
|
|
100
|
+
// Check if directory already exists
|
|
101
|
+
if (existsSync(fullPath)) {
|
|
102
|
+
throw new Error(`Directory "${targetDir}" already exists.`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const spinner = ora({
|
|
106
|
+
text: `Cloning space ${options.spaceKey} into ${targetDir}...`,
|
|
107
|
+
hideCursor: false,
|
|
108
|
+
discardStdin: false,
|
|
109
|
+
}).start();
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
// Create directory
|
|
113
|
+
mkdirSync(fullPath, { recursive: true });
|
|
114
|
+
|
|
115
|
+
// Initialize space config
|
|
116
|
+
const spaceConfig = await syncEngine.initSync(fullPath, options.spaceKey);
|
|
117
|
+
spinner.succeed(`Cloned space "${spaceConfig.spaceName}" (${spaceConfig.spaceKey}) into ${targetDir}`);
|
|
118
|
+
|
|
119
|
+
// Perform initial pull - wrapped separately so init failures clean up but sync failures don't
|
|
120
|
+
let syncFailed = false;
|
|
121
|
+
try {
|
|
122
|
+
console.log('');
|
|
123
|
+
const progressReporter = createProgressReporter();
|
|
124
|
+
const result = await syncEngine.sync(fullPath, {
|
|
125
|
+
progress: progressReporter,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Show warnings
|
|
129
|
+
if (result.warnings.length > 0) {
|
|
130
|
+
console.log('');
|
|
131
|
+
console.log(chalk.yellow('Warnings:'));
|
|
132
|
+
for (const warning of result.warnings) {
|
|
133
|
+
console.log(chalk.yellow(` ! ${warning}`));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Show errors
|
|
138
|
+
if (result.errors.length > 0) {
|
|
139
|
+
console.log('');
|
|
140
|
+
console.log(chalk.red('Errors:'));
|
|
141
|
+
for (const error of result.errors) {
|
|
142
|
+
console.log(chalk.red(` x ${error}`));
|
|
143
|
+
}
|
|
144
|
+
syncFailed = true;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Final summary
|
|
148
|
+
const { added, modified, deleted } = result.changes;
|
|
149
|
+
const total = added.length + modified.length + deleted.length;
|
|
150
|
+
if (total > 0) {
|
|
151
|
+
console.log('');
|
|
152
|
+
const parts = [];
|
|
153
|
+
if (added.length > 0) parts.push(`${added.length} added`);
|
|
154
|
+
if (modified.length > 0) parts.push(`${modified.length} modified`);
|
|
155
|
+
if (deleted.length > 0) parts.push(`${deleted.length} deleted`);
|
|
156
|
+
console.log(chalk.green(`✓ Clone complete: ${parts.join(', ')}`));
|
|
157
|
+
}
|
|
158
|
+
} catch (_syncError) {
|
|
159
|
+
// Sync failed but clone succeeded - don't clean up, provide recovery guidance
|
|
160
|
+
console.log('');
|
|
161
|
+
console.log(chalk.yellow('Initial pull failed. You can retry with:'));
|
|
162
|
+
syncFailed = true;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
console.log('');
|
|
166
|
+
console.log(chalk.gray(` cd ${targetDir}`));
|
|
167
|
+
if (syncFailed) {
|
|
168
|
+
console.log(chalk.gray(' cn pull'));
|
|
169
|
+
}
|
|
170
|
+
} catch (error) {
|
|
171
|
+
spinner.fail('Failed to clone space');
|
|
172
|
+
|
|
173
|
+
// Clean up directory on failure (only for init failures, not sync failures)
|
|
174
|
+
if (existsSync(fullPath)) {
|
|
175
|
+
try {
|
|
176
|
+
rmSync(fullPath, { recursive: true });
|
|
177
|
+
} catch {
|
|
178
|
+
// Ignore cleanup errors - directory may be partially created
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (error instanceof Error && error.message.includes('not found')) {
|
|
183
|
+
throw new Error(`Space "${options.spaceKey}" not found. Check the space key and try again.`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
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 CommentsCommandOptions {
|
|
9
|
+
xml?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function stripHtml(html: string): string {
|
|
13
|
+
return html.replace(/<[^>]+>/g, '').trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function commentsCommand(target: string, options: CommentsCommandOptions = {}): Promise<void> {
|
|
17
|
+
const configManager = new ConfigManager();
|
|
18
|
+
const config = await configManager.getConfig();
|
|
19
|
+
|
|
20
|
+
if (!config) {
|
|
21
|
+
console.error(chalk.red('Not configured. Run: cn setup'));
|
|
22
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const pageId = resolvePageTarget(target);
|
|
26
|
+
const client = new ConfluenceClient(config);
|
|
27
|
+
const comments = await client.getAllFooterComments(pageId);
|
|
28
|
+
|
|
29
|
+
if (options.xml) {
|
|
30
|
+
console.log('<comments>');
|
|
31
|
+
for (const comment of comments) {
|
|
32
|
+
const body = comment.body?.storage?.value ? stripHtml(comment.body.storage.value) : '';
|
|
33
|
+
console.log(` <comment id="${escapeXml(comment.id)}">`);
|
|
34
|
+
if (body) console.log(` <body>${escapeXml(body)}</body>`);
|
|
35
|
+
if (comment.authorId) console.log(` <authorId>${escapeXml(comment.authorId)}</authorId>`);
|
|
36
|
+
if (comment.createdAt) console.log(` <createdAt>${escapeXml(comment.createdAt)}</createdAt>`);
|
|
37
|
+
console.log(' </comment>');
|
|
38
|
+
}
|
|
39
|
+
console.log('</comments>');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (comments.length === 0) {
|
|
44
|
+
console.log('No comments found.');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const comment of comments) {
|
|
49
|
+
const body = comment.body?.storage?.value ? stripHtml(comment.body.storage.value) : '';
|
|
50
|
+
console.log(chalk.gray(`--- ${comment.id} ---`));
|
|
51
|
+
if (body) console.log(body);
|
|
52
|
+
if (comment.authorId) console.log(chalk.gray(`Author: ${comment.authorId}`));
|
|
53
|
+
if (comment.createdAt) console.log(chalk.gray(`Date: ${comment.createdAt}`));
|
|
54
|
+
console.log();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
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 { readSpaceConfig } from '../../lib/space-config.js';
|
|
6
|
+
import { openUrl } from '../utils/browser.js';
|
|
7
|
+
|
|
8
|
+
export interface CreateCommandOptions {
|
|
9
|
+
space?: string;
|
|
10
|
+
parent?: string;
|
|
11
|
+
open?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function createCommand(title: string, options: CreateCommandOptions = {}): 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 client = new ConfluenceClient(config);
|
|
24
|
+
let spaceId: string | undefined;
|
|
25
|
+
|
|
26
|
+
if (options.space) {
|
|
27
|
+
const space = await client.getSpaceByKey(options.space);
|
|
28
|
+
spaceId = space.id;
|
|
29
|
+
} else {
|
|
30
|
+
const spaceConfig = readSpaceConfig(process.cwd());
|
|
31
|
+
if (!spaceConfig) {
|
|
32
|
+
console.error(chalk.red('Not in a cloned space directory. Use --space to specify a space key.'));
|
|
33
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
34
|
+
}
|
|
35
|
+
spaceId = spaceConfig.spaceId;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const page = await client.createPage({
|
|
39
|
+
spaceId,
|
|
40
|
+
status: 'current',
|
|
41
|
+
title,
|
|
42
|
+
parentId: options.parent,
|
|
43
|
+
body: {
|
|
44
|
+
representation: 'storage',
|
|
45
|
+
value: '',
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
console.log(`${chalk.green('Created:')} ${chalk.bold(page.title)} ${chalk.gray(page.id)}`);
|
|
50
|
+
if (page._links?.webui) {
|
|
51
|
+
const url = `${config.confluenceUrl}/wiki${page._links.webui}`;
|
|
52
|
+
console.log(`URL: ${chalk.blue(url)}`);
|
|
53
|
+
|
|
54
|
+
if (options.open) {
|
|
55
|
+
openUrl(url);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { confirm } from '@inquirer/prompts';
|
|
3
|
+
import { ConfluenceClient } from '../../lib/confluence-client/index.js';
|
|
4
|
+
import { ConfigManager } from '../../lib/config.js';
|
|
5
|
+
import { EXIT_CODES } from '../../lib/errors.js';
|
|
6
|
+
import { resolvePageTarget } from '../../lib/resolve-page-target.js';
|
|
7
|
+
|
|
8
|
+
export interface DeleteCommandOptions {
|
|
9
|
+
force?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function deleteCommand(target: string, options: DeleteCommandOptions = {}): Promise<void> {
|
|
13
|
+
const configManager = new ConfigManager();
|
|
14
|
+
const config = await configManager.getConfig();
|
|
15
|
+
|
|
16
|
+
if (!config) {
|
|
17
|
+
console.error(chalk.red('Not configured. Run: cn setup'));
|
|
18
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const pageId = resolvePageTarget(target);
|
|
22
|
+
const client = new ConfluenceClient(config);
|
|
23
|
+
|
|
24
|
+
if (!options.force) {
|
|
25
|
+
let page: Awaited<ReturnType<typeof client.getPage>>;
|
|
26
|
+
try {
|
|
27
|
+
page = await client.getPage(pageId, false);
|
|
28
|
+
} catch {
|
|
29
|
+
console.error(chalk.red(`Page not found: ${pageId}`));
|
|
30
|
+
process.exit(EXIT_CODES.PAGE_NOT_FOUND);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const confirmed = await confirm({
|
|
34
|
+
message: `Delete "${page.title}" (${pageId})?`,
|
|
35
|
+
default: false,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
if (!confirmed) {
|
|
39
|
+
console.log('Cancelled.');
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await client.deletePage(pageId);
|
|
45
|
+
console.log(`${chalk.green('Deleted:')} ${pageId}`);
|
|
46
|
+
}
|