@aaronshaf/confluence-cli 1.0.0 → 1.0.2
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/README.md +24 -22
- package/package.json +1 -1
- package/src/cli/commands/create.ts +21 -2
- package/src/cli/commands/read.ts +53 -0
- package/src/cli/commands/spaces.ts +10 -1
- package/src/cli/commands/update.ts +72 -0
- package/src/cli/help.ts +64 -4
- package/src/cli/index.ts +79 -3
- package/src/cli/utils/args.ts +15 -0
- package/src/cli/utils/stdin.ts +14 -0
- package/src/lib/confluence-client/client.ts +23 -52
- package/src/lib/confluence-client/index.ts +1 -0
- package/src/lib/confluence-client/space-operations.ts +133 -0
- package/src/lib/confluence-client/types.ts +27 -3
- package/src/test/confluence-client.test.ts +9 -7
- package/src/test/mocks/handlers.ts +12 -0
- package/src/test/spaces.test.ts +1 -1
- package/src/test/update.test.ts +115 -0
package/README.md
CHANGED
|
@@ -1,39 +1,39 @@
|
|
|
1
|
-
#
|
|
1
|
+
# confluence-cli
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
## Install
|
|
3
|
+
## Installation
|
|
6
4
|
|
|
7
5
|
```bash
|
|
6
|
+
# Install Bun runtime
|
|
7
|
+
curl -fsSL https://bun.sh/install | bash
|
|
8
|
+
|
|
9
|
+
# Install confluence-cli
|
|
8
10
|
bun install -g @aaronshaf/confluence-cli
|
|
9
11
|
```
|
|
10
12
|
|
|
11
13
|
## Getting Started
|
|
12
14
|
|
|
13
15
|
```bash
|
|
14
|
-
#
|
|
16
|
+
# Configure your Confluence credentials
|
|
15
17
|
cn setup
|
|
16
18
|
|
|
17
|
-
#
|
|
18
|
-
cn
|
|
19
|
+
# Search pages
|
|
20
|
+
cn search "authentication"
|
|
19
21
|
|
|
20
|
-
#
|
|
21
|
-
|
|
22
|
-
cn pull
|
|
23
|
-
```
|
|
22
|
+
# Open a page in the browser
|
|
23
|
+
cn open "Getting Started"
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
# Create a page
|
|
26
|
+
cn create "My Page" --space ENG
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
# List spaces
|
|
29
|
+
cn spaces
|
|
30
|
+
```
|
|
29
31
|
|
|
30
32
|
## Commands
|
|
31
33
|
|
|
32
34
|
| Command | Description |
|
|
33
35
|
|---------|-------------|
|
|
34
36
|
| `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
37
|
| `cn status` | Check connection and sync status |
|
|
38
38
|
| `cn tree` | Display page hierarchy |
|
|
39
39
|
| `cn open [page]` | Open page in browser |
|
|
@@ -41,21 +41,19 @@ Credentials are stored in `~/.cn/config.json`. Space configuration is saved to `
|
|
|
41
41
|
| `cn search <query>` | Search pages using CQL |
|
|
42
42
|
| `cn spaces` | List available spaces |
|
|
43
43
|
| `cn info <id\|file>` | Show page info and labels |
|
|
44
|
-
| `cn create <title>` | Create a new page |
|
|
44
|
+
| `cn create <title>` | Create a new page (pipe content via stdin) |
|
|
45
|
+
| `cn update <id>` | Update an existing page (pipe content via stdin) |
|
|
45
46
|
| `cn delete <id>` | Delete a page |
|
|
46
47
|
| `cn comments <id\|file>` | Show page comments |
|
|
47
48
|
| `cn labels <id\|file>` | Manage page labels |
|
|
48
49
|
| `cn move <id\|file> <parentId>` | Move a page to a new parent |
|
|
49
50
|
| `cn attachments <id\|file>` | Manage page attachments |
|
|
50
51
|
| `cn folder <subcommand>` | Manage folders (create, list, delete, move) |
|
|
52
|
+
| `cn clone <SPACE_KEY>` | Clone a space to a new folder |
|
|
53
|
+
| `cn pull` | Pull changes from Confluence as markdown |
|
|
51
54
|
|
|
52
55
|
Run `cn <command> --help` for details on each command.
|
|
53
56
|
|
|
54
|
-
## Requirements
|
|
55
|
-
|
|
56
|
-
- Bun 1.2.0+
|
|
57
|
-
- Confluence Cloud account
|
|
58
|
-
|
|
59
57
|
## Development
|
|
60
58
|
|
|
61
59
|
```bash
|
|
@@ -64,6 +62,10 @@ bun run cn --help
|
|
|
64
62
|
bun test
|
|
65
63
|
```
|
|
66
64
|
|
|
65
|
+
## See also
|
|
66
|
+
|
|
67
|
+
- [pchuri/confluence-cli](https://github.com/pchuri/confluence-cli)
|
|
68
|
+
|
|
67
69
|
## License
|
|
68
70
|
|
|
69
71
|
MIT
|
package/package.json
CHANGED
|
@@ -4,11 +4,13 @@ import { ConfigManager } from '../../lib/config.js';
|
|
|
4
4
|
import { EXIT_CODES } from '../../lib/errors.js';
|
|
5
5
|
import { readSpaceConfig } from '../../lib/space-config.js';
|
|
6
6
|
import { openUrl } from '../utils/browser.js';
|
|
7
|
+
import { VALID_FORMATS, isValidFormat, readStdin } from '../utils/stdin.js';
|
|
7
8
|
|
|
8
9
|
export interface CreateCommandOptions {
|
|
9
10
|
space?: string;
|
|
10
11
|
parent?: string;
|
|
11
12
|
open?: boolean;
|
|
13
|
+
format?: string;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
export async function createCommand(title: string, options: CreateCommandOptions = {}): Promise<void> {
|
|
@@ -20,6 +22,23 @@ export async function createCommand(title: string, options: CreateCommandOptions
|
|
|
20
22
|
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
21
23
|
}
|
|
22
24
|
|
|
25
|
+
const rawFormat = options.format ?? 'storage';
|
|
26
|
+
if (!isValidFormat(rawFormat)) {
|
|
27
|
+
console.error(chalk.red(`Invalid format: ${rawFormat}`));
|
|
28
|
+
console.log(chalk.gray(`Valid formats: ${VALID_FORMATS.join(', ')}`));
|
|
29
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
30
|
+
}
|
|
31
|
+
const representation = rawFormat;
|
|
32
|
+
|
|
33
|
+
let bodyValue = '';
|
|
34
|
+
if (!process.stdin.isTTY) {
|
|
35
|
+
bodyValue = await readStdin();
|
|
36
|
+
if (bodyValue.trim().length === 0) {
|
|
37
|
+
console.error(chalk.red('Stdin is empty. Provide content to create a page with body.'));
|
|
38
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
23
42
|
const client = new ConfluenceClient(config);
|
|
24
43
|
let spaceId: string | undefined;
|
|
25
44
|
|
|
@@ -41,8 +60,8 @@ export async function createCommand(title: string, options: CreateCommandOptions
|
|
|
41
60
|
title,
|
|
42
61
|
parentId: options.parent,
|
|
43
62
|
body: {
|
|
44
|
-
representation
|
|
45
|
-
value:
|
|
63
|
+
representation,
|
|
64
|
+
value: bodyValue,
|
|
46
65
|
},
|
|
47
66
|
});
|
|
48
67
|
|
|
@@ -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 { MarkdownConverter } from '../../lib/markdown/index.js';
|
|
7
|
+
import { resolvePageTarget } from '../../lib/resolve-page-target.js';
|
|
8
|
+
|
|
9
|
+
export interface ReadCommandOptions {
|
|
10
|
+
xml?: boolean;
|
|
11
|
+
html?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function readCommand(target: string, options: ReadCommandOptions = {}): 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
|
+
if (options.xml && options.html) {
|
|
24
|
+
console.error(chalk.red('Cannot use --xml and --html together.'));
|
|
25
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const pageId = resolvePageTarget(target);
|
|
29
|
+
const client = new ConfluenceClient(config);
|
|
30
|
+
const page = await client.getPage(pageId, true);
|
|
31
|
+
|
|
32
|
+
const storageHtml = page.body?.storage?.value || '';
|
|
33
|
+
|
|
34
|
+
if (options.xml) {
|
|
35
|
+
const converter = new MarkdownConverter();
|
|
36
|
+
const markdown = converter.convert(storageHtml);
|
|
37
|
+
console.log('<page>');
|
|
38
|
+
console.log(` <id>${escapeXml(page.id)}</id>`);
|
|
39
|
+
console.log(` <title>${escapeXml(page.title)}</title>`);
|
|
40
|
+
console.log(` <content>${escapeXml(markdown)}</content>`);
|
|
41
|
+
console.log('</page>');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (options.html) {
|
|
46
|
+
console.log(storageHtml);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const converter = new MarkdownConverter();
|
|
51
|
+
const markdown = converter.convert(storageHtml);
|
|
52
|
+
console.log(markdown);
|
|
53
|
+
}
|
|
@@ -6,6 +6,8 @@ import { escapeXml } from '../../lib/formatters.js';
|
|
|
6
6
|
|
|
7
7
|
export interface SpacesCommandOptions {
|
|
8
8
|
xml?: boolean;
|
|
9
|
+
limit?: number;
|
|
10
|
+
page?: number;
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
export async function spacesCommand(options: SpacesCommandOptions = {}): Promise<void> {
|
|
@@ -17,8 +19,11 @@ export async function spacesCommand(options: SpacesCommandOptions = {}): Promise
|
|
|
17
19
|
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
const limit = options.limit ?? 25;
|
|
23
|
+
const page = options.page ?? 1;
|
|
20
24
|
const client = new ConfluenceClient(config);
|
|
21
|
-
const
|
|
25
|
+
const response = await client.getSpaces(limit, page);
|
|
26
|
+
const spaces = response.results;
|
|
22
27
|
|
|
23
28
|
if (options.xml) {
|
|
24
29
|
console.log('<spaces>');
|
|
@@ -39,4 +44,8 @@ export async function spacesCommand(options: SpacesCommandOptions = {}): Promise
|
|
|
39
44
|
for (const space of spaces) {
|
|
40
45
|
console.log(`${chalk.bold(space.key)} ${space.name} ${chalk.gray(space.id)}`);
|
|
41
46
|
}
|
|
47
|
+
|
|
48
|
+
if (response.size === limit) {
|
|
49
|
+
console.log(chalk.gray(`\nPage ${page}. Use --page ${page + 1} for next page, --limit to change page size.`));
|
|
50
|
+
}
|
|
42
51
|
}
|
|
@@ -0,0 +1,72 @@
|
|
|
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 { VALID_FORMATS, isValidFormat, readStdin } from '../utils/stdin.js';
|
|
6
|
+
|
|
7
|
+
export interface UpdateCommandOptions {
|
|
8
|
+
format?: string;
|
|
9
|
+
title?: string;
|
|
10
|
+
message?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function updateCommand(pageId: string, options: UpdateCommandOptions = {}): Promise<void> {
|
|
14
|
+
const configManager = new ConfigManager();
|
|
15
|
+
const config = await configManager.getConfig();
|
|
16
|
+
|
|
17
|
+
if (!config) {
|
|
18
|
+
console.error(chalk.red('Not configured. Run: cn setup'));
|
|
19
|
+
process.exit(EXIT_CODES.CONFIG_ERROR);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (process.stdin.isTTY) {
|
|
23
|
+
console.error(chalk.red('No content provided. Pipe content via stdin.'));
|
|
24
|
+
console.log(chalk.gray('Usage: echo "<p>Content</p>" | cn update <id>'));
|
|
25
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const rawFormat = options.format ?? 'storage';
|
|
29
|
+
if (!isValidFormat(rawFormat)) {
|
|
30
|
+
console.error(chalk.red(`Invalid format: ${rawFormat}`));
|
|
31
|
+
console.log(chalk.gray(`Valid formats: ${VALID_FORMATS.join(', ')}`));
|
|
32
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
33
|
+
}
|
|
34
|
+
const representation = rawFormat;
|
|
35
|
+
|
|
36
|
+
const bodyValue = await readStdin();
|
|
37
|
+
if (bodyValue.trim().length === 0) {
|
|
38
|
+
console.error(chalk.red('Stdin is empty. Provide content to update the page.'));
|
|
39
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const client = new ConfluenceClient(config);
|
|
43
|
+
const current = await client.getPage(pageId, false);
|
|
44
|
+
|
|
45
|
+
if (!current) {
|
|
46
|
+
console.error(chalk.red(`Page not found: ${pageId}`));
|
|
47
|
+
process.exit(EXIT_CODES.GENERAL_ERROR);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const currentVersion = current.version?.number ?? 1;
|
|
51
|
+
const title = options.title ?? current.title;
|
|
52
|
+
|
|
53
|
+
const updated = await client.updatePage({
|
|
54
|
+
id: pageId,
|
|
55
|
+
status: 'current',
|
|
56
|
+
title,
|
|
57
|
+
body: {
|
|
58
|
+
representation,
|
|
59
|
+
value: bodyValue,
|
|
60
|
+
},
|
|
61
|
+
version: {
|
|
62
|
+
number: currentVersion + 1,
|
|
63
|
+
message: options.message,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
console.log(`${chalk.green('Updated:')} ${chalk.bold(updated.title)} ${chalk.gray(updated.id)}`);
|
|
68
|
+
if (updated._links?.webui) {
|
|
69
|
+
const url = `${config.confluenceUrl}/wiki${updated._links.webui}`;
|
|
70
|
+
console.log(`URL: ${chalk.blue(url)}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/cli/help.ts
CHANGED
|
@@ -144,7 +144,8 @@ ${chalk.yellow('Usage:')}
|
|
|
144
144
|
cn open [options]
|
|
145
145
|
|
|
146
146
|
${chalk.yellow('Description:')}
|
|
147
|
-
Opens a Confluence page in your default browser.
|
|
147
|
+
Opens a Confluence page in your default web browser (launches a browser window).
|
|
148
|
+
Not suitable for non-interactive environments (CI, bots, scripts).
|
|
148
149
|
Without arguments, opens the space home page.
|
|
149
150
|
|
|
150
151
|
${chalk.yellow('Arguments:')}
|
|
@@ -213,12 +214,36 @@ ${chalk.yellow('Options:')}
|
|
|
213
214
|
`);
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
export function showReadHelp(): void {
|
|
218
|
+
console.log(`
|
|
219
|
+
${chalk.bold('cn read - Read and display page content')}
|
|
220
|
+
|
|
221
|
+
${chalk.yellow('Usage:')}
|
|
222
|
+
cn read <id|file> [options]
|
|
223
|
+
|
|
224
|
+
${chalk.yellow('Arguments:')}
|
|
225
|
+
id|file Page ID or path to local .md file
|
|
226
|
+
|
|
227
|
+
${chalk.yellow('Options:')}
|
|
228
|
+
--xml Output in XML format
|
|
229
|
+
--html Output raw Confluence storage format HTML
|
|
230
|
+
--help Show this help message
|
|
231
|
+
|
|
232
|
+
${chalk.yellow('Examples:')}
|
|
233
|
+
cn read 123456 Read page by ID
|
|
234
|
+
cn read ./docs/page.md Read page from local file
|
|
235
|
+
cn read 123456 --xml Read page in XML format
|
|
236
|
+
cn read 123456 --html Read raw HTML storage format
|
|
237
|
+
`);
|
|
238
|
+
}
|
|
239
|
+
|
|
216
240
|
export function showCreateHelp(): void {
|
|
217
241
|
console.log(`
|
|
218
242
|
${chalk.bold('cn create - Create a new Confluence page')}
|
|
219
243
|
|
|
220
244
|
${chalk.yellow('Usage:')}
|
|
221
245
|
cn create <title> [options]
|
|
246
|
+
echo "<p>Content</p>" | cn create <title> [options]
|
|
222
247
|
|
|
223
248
|
${chalk.yellow('Arguments:')}
|
|
224
249
|
title Page title (required)
|
|
@@ -226,8 +251,37 @@ ${chalk.yellow('Arguments:')}
|
|
|
226
251
|
${chalk.yellow('Options:')}
|
|
227
252
|
--space <key> Space key (required if not in cloned dir)
|
|
228
253
|
--parent <id> Parent page ID
|
|
254
|
+
--format <format> Body format: storage (default), wiki, atlas_doc_format
|
|
229
255
|
--open Open page in browser after creation
|
|
230
256
|
--help Show this help message
|
|
257
|
+
|
|
258
|
+
${chalk.yellow('Examples:')}
|
|
259
|
+
cn create "My Page" --space ENG
|
|
260
|
+
echo "<p>Hello</p>" | cn create "My Page" --space ENG
|
|
261
|
+
echo "h1. Hello" | cn create "Wiki Page" --space ENG --format wiki
|
|
262
|
+
`);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function showUpdateHelp(): void {
|
|
266
|
+
console.log(`
|
|
267
|
+
${chalk.bold('cn update - Update an existing Confluence page')}
|
|
268
|
+
|
|
269
|
+
${chalk.yellow('Usage:')}
|
|
270
|
+
echo "<p>Content</p>" | cn update <id> [options]
|
|
271
|
+
|
|
272
|
+
${chalk.yellow('Arguments:')}
|
|
273
|
+
id Page ID (required)
|
|
274
|
+
|
|
275
|
+
${chalk.yellow('Options:')}
|
|
276
|
+
--format <format> Body format: storage (default), wiki, atlas_doc_format
|
|
277
|
+
--title <title> New page title (default: keep existing title)
|
|
278
|
+
--message <msg> Version message
|
|
279
|
+
--help Show this help message
|
|
280
|
+
|
|
281
|
+
${chalk.yellow('Examples:')}
|
|
282
|
+
echo "<p>Updated content</p>" | cn update 123456
|
|
283
|
+
echo "<p>New content</p>" | cn update 123456 --title "New Title"
|
|
284
|
+
echo "h1. Hello" | cn update 123456 --format wiki --message "Updated via automation"
|
|
231
285
|
`);
|
|
232
286
|
}
|
|
233
287
|
|
|
@@ -388,7 +442,9 @@ ${chalk.yellow('Commands:')}
|
|
|
388
442
|
cn search Search pages using CQL
|
|
389
443
|
cn spaces List available spaces
|
|
390
444
|
cn info Show page info and labels
|
|
445
|
+
cn read Read and display page content
|
|
391
446
|
cn create Create a new page
|
|
447
|
+
cn update Update an existing page
|
|
392
448
|
cn delete Delete a page
|
|
393
449
|
cn comments Show page comments
|
|
394
450
|
cn labels Manage page labels
|
|
@@ -408,10 +464,14 @@ ${chalk.yellow('Environment Variables:')}
|
|
|
408
464
|
|
|
409
465
|
${chalk.yellow('Examples:')}
|
|
410
466
|
cn setup Configure credentials
|
|
411
|
-
cn
|
|
412
|
-
cn
|
|
413
|
-
cn
|
|
467
|
+
cn spaces List available spaces
|
|
468
|
+
cn search "my topic" Search across all spaces
|
|
469
|
+
cn search "api" --space ENG Search within a space
|
|
470
|
+
cn info 123456 Show page info
|
|
414
471
|
cn open "My Page" Open page in browser
|
|
472
|
+
cn create "New Page" --space ENG Create a page
|
|
473
|
+
cn clone DOCS Clone DOCS space locally
|
|
474
|
+
cn pull Pull changes
|
|
415
475
|
|
|
416
476
|
${chalk.gray('For more information on a command, run: cn <command> --help')}
|
|
417
477
|
${chalk.gray('Confluence REST API reference: https://docs.atlassian.com/atlassian-confluence/REST/6.6.0/')}
|
package/src/cli/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
showDoctorHelp,
|
|
14
14
|
showHelp,
|
|
15
15
|
showInfoHelp,
|
|
16
|
+
showReadHelp,
|
|
16
17
|
showLabelsHelp,
|
|
17
18
|
showMoveHelp,
|
|
18
19
|
showOpenHelp,
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
showSpacesHelp,
|
|
24
25
|
showStatusHelp,
|
|
25
26
|
showTreeHelp,
|
|
27
|
+
showUpdateHelp,
|
|
26
28
|
} from './help.js';
|
|
27
29
|
import { attachmentsCommand } from './commands/attachments.js';
|
|
28
30
|
import { cloneCommand } from './commands/clone.js';
|
|
@@ -36,11 +38,15 @@ import { moveCommand } from './commands/move.js';
|
|
|
36
38
|
import { openCommand } from './commands/open.js';
|
|
37
39
|
import { folderCommand } from './commands/folder.js';
|
|
38
40
|
import { pullCommand } from './commands/pull.js';
|
|
41
|
+
import { readCommand } from './commands/read.js';
|
|
39
42
|
import { searchCommand } from './commands/search.js';
|
|
40
43
|
import { setup } from './commands/setup.js';
|
|
41
44
|
import { spacesCommand } from './commands/spaces.js';
|
|
42
45
|
import { statusCommand } from './commands/status.js';
|
|
43
46
|
import { treeCommand } from './commands/tree.js';
|
|
47
|
+
import { updateCommand } from './commands/update.js';
|
|
48
|
+
|
|
49
|
+
import { findPositional } from './utils/args.js';
|
|
44
50
|
|
|
45
51
|
// Get version from package.json
|
|
46
52
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -210,7 +216,23 @@ async function main(): Promise<void> {
|
|
|
210
216
|
showSpacesHelp();
|
|
211
217
|
process.exit(EXIT_CODES.SUCCESS);
|
|
212
218
|
}
|
|
213
|
-
|
|
219
|
+
{
|
|
220
|
+
let limit: number | undefined;
|
|
221
|
+
const limitArg = args.find((a) => a.startsWith('--limit=') || a === '--limit');
|
|
222
|
+
if (limitArg) {
|
|
223
|
+
limit = limitArg.includes('=')
|
|
224
|
+
? Number.parseInt(limitArg.split('=')[1], 10)
|
|
225
|
+
: Number.parseInt(args[args.indexOf('--limit') + 1], 10);
|
|
226
|
+
}
|
|
227
|
+
let page: number | undefined;
|
|
228
|
+
const pageArg = args.find((a) => a.startsWith('--page=') || a === '--page');
|
|
229
|
+
if (pageArg) {
|
|
230
|
+
page = pageArg.includes('=')
|
|
231
|
+
? Number.parseInt(pageArg.split('=')[1], 10)
|
|
232
|
+
: Number.parseInt(args[args.indexOf('--page') + 1], 10);
|
|
233
|
+
}
|
|
234
|
+
await spacesCommand({ xml: args.includes('--xml'), limit, page });
|
|
235
|
+
}
|
|
214
236
|
break;
|
|
215
237
|
|
|
216
238
|
case 'search': {
|
|
@@ -256,6 +278,21 @@ async function main(): Promise<void> {
|
|
|
256
278
|
break;
|
|
257
279
|
}
|
|
258
280
|
|
|
281
|
+
case 'read': {
|
|
282
|
+
if (args.includes('--help')) {
|
|
283
|
+
showReadHelp();
|
|
284
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
285
|
+
}
|
|
286
|
+
const target = subArgs.find((arg) => !arg.startsWith('--'));
|
|
287
|
+
if (!target) {
|
|
288
|
+
console.error(chalk.red('Page ID or file path is required.'));
|
|
289
|
+
console.log(chalk.gray('Usage: cn read <id|file>'));
|
|
290
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
291
|
+
}
|
|
292
|
+
await readCommand(target, { xml: args.includes('--xml'), html: args.includes('--html') });
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
|
|
259
296
|
case 'create': {
|
|
260
297
|
if (args.includes('--help')) {
|
|
261
298
|
showCreateHelp();
|
|
@@ -271,8 +308,12 @@ async function main(): Promise<void> {
|
|
|
271
308
|
if (parentIdx !== -1 && parentIdx + 1 < args.length) {
|
|
272
309
|
parentId = args[parentIdx + 1];
|
|
273
310
|
}
|
|
274
|
-
|
|
275
|
-
const
|
|
311
|
+
let createFormat: string | undefined;
|
|
312
|
+
const createFormatIdx = args.indexOf('--format');
|
|
313
|
+
if (createFormatIdx !== -1 && createFormatIdx + 1 < args.length) {
|
|
314
|
+
createFormat = args[createFormatIdx + 1];
|
|
315
|
+
}
|
|
316
|
+
const title = findPositional(subArgs, ['--space', '--parent', '--format']);
|
|
276
317
|
if (!title) {
|
|
277
318
|
console.error(chalk.red('Page title is required.'));
|
|
278
319
|
console.log(chalk.gray('Usage: cn create <title>'));
|
|
@@ -282,6 +323,7 @@ async function main(): Promise<void> {
|
|
|
282
323
|
space: spaceKey,
|
|
283
324
|
parent: parentId,
|
|
284
325
|
open: args.includes('--open'),
|
|
326
|
+
format: createFormat,
|
|
285
327
|
});
|
|
286
328
|
break;
|
|
287
329
|
}
|
|
@@ -393,6 +435,40 @@ async function main(): Promise<void> {
|
|
|
393
435
|
break;
|
|
394
436
|
}
|
|
395
437
|
|
|
438
|
+
case 'update': {
|
|
439
|
+
if (args.includes('--help')) {
|
|
440
|
+
showUpdateHelp();
|
|
441
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
442
|
+
}
|
|
443
|
+
let updateFormat: string | undefined;
|
|
444
|
+
const updateFormatIdx = args.indexOf('--format');
|
|
445
|
+
if (updateFormatIdx !== -1 && updateFormatIdx + 1 < args.length) {
|
|
446
|
+
updateFormat = args[updateFormatIdx + 1];
|
|
447
|
+
}
|
|
448
|
+
let updateTitle: string | undefined;
|
|
449
|
+
const updateTitleIdx = args.indexOf('--title');
|
|
450
|
+
if (updateTitleIdx !== -1 && updateTitleIdx + 1 < args.length) {
|
|
451
|
+
updateTitle = args[updateTitleIdx + 1];
|
|
452
|
+
}
|
|
453
|
+
let updateMessage: string | undefined;
|
|
454
|
+
const updateMessageIdx = args.indexOf('--message');
|
|
455
|
+
if (updateMessageIdx !== -1 && updateMessageIdx + 1 < args.length) {
|
|
456
|
+
updateMessage = args[updateMessageIdx + 1];
|
|
457
|
+
}
|
|
458
|
+
const updateId = findPositional(subArgs, ['--format', '--title', '--message']);
|
|
459
|
+
if (!updateId) {
|
|
460
|
+
console.error(chalk.red('Page ID is required.'));
|
|
461
|
+
console.log(chalk.gray('Usage: cn update <id>'));
|
|
462
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
463
|
+
}
|
|
464
|
+
await updateCommand(updateId, {
|
|
465
|
+
format: updateFormat,
|
|
466
|
+
title: updateTitle,
|
|
467
|
+
message: updateMessage,
|
|
468
|
+
});
|
|
469
|
+
break;
|
|
470
|
+
}
|
|
471
|
+
|
|
396
472
|
default:
|
|
397
473
|
console.error(`Unknown command: ${command}`);
|
|
398
474
|
console.log('Run "cn help" for usage information');
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Find a positional argument in args, skipping flags and their values.
|
|
3
|
+
* --flag <value> pairs are identified by their indices so we never mistake
|
|
4
|
+
* a flag's value for a positional argument even if they are equal strings.
|
|
5
|
+
*/
|
|
6
|
+
export function findPositional(args: string[], flagsWithValues: string[]): string | undefined {
|
|
7
|
+
const flagValueIndices = new Set<number>();
|
|
8
|
+
for (const flag of flagsWithValues) {
|
|
9
|
+
const idx = args.indexOf(flag);
|
|
10
|
+
if (idx !== -1 && idx + 1 < args.length) {
|
|
11
|
+
flagValueIndices.add(idx + 1);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return args.find((arg, i) => !arg.startsWith('--') && !flagValueIndices.has(i));
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export async function readStdin(): Promise<string> {
|
|
2
|
+
const chunks: Buffer[] = [];
|
|
3
|
+
for await (const chunk of process.stdin) {
|
|
4
|
+
chunks.push(chunk);
|
|
5
|
+
}
|
|
6
|
+
return Buffer.concat(chunks).toString('utf-8');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const VALID_FORMATS = ['storage', 'wiki', 'atlas_doc_format'] as const;
|
|
10
|
+
export type ValidFormat = (typeof VALID_FORMATS)[number];
|
|
11
|
+
|
|
12
|
+
export function isValidFormat(s: string): s is ValidFormat {
|
|
13
|
+
return (VALID_FORMATS as readonly string[]).includes(s);
|
|
14
|
+
}
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
NetworkError,
|
|
8
8
|
type PageNotFoundError,
|
|
9
9
|
RateLimitError,
|
|
10
|
-
SpaceNotFoundError,
|
|
10
|
+
type SpaceNotFoundError,
|
|
11
11
|
type VersionConflictError,
|
|
12
12
|
} from '../errors.js';
|
|
13
13
|
import {
|
|
@@ -34,13 +34,20 @@ import {
|
|
|
34
34
|
import { searchEffect as searchEffectFn } from './search-operations.js';
|
|
35
35
|
import { getAllFooterComments as getAllFooterCommentsFn } from './comment-operations.js';
|
|
36
36
|
import { getUserEffect as getUserEffectFn } from './user-operations.js';
|
|
37
|
+
import {
|
|
38
|
+
getAllSpaces as getAllSpacesFn,
|
|
39
|
+
getSpaces as getSpacesFn,
|
|
40
|
+
getSpacesEffect as getSpacesEffectFn,
|
|
41
|
+
getSpaceByKey as getSpaceByKeyFn,
|
|
42
|
+
getSpaceByKeyEffect as getSpaceByKeyEffectFn,
|
|
43
|
+
getSpaceByIdEffect as getSpaceByIdEffectFn,
|
|
44
|
+
} from './space-operations.js';
|
|
37
45
|
import {
|
|
38
46
|
CommentsResponseSchema,
|
|
39
47
|
FolderSchema,
|
|
40
48
|
LabelsResponseSchema,
|
|
41
49
|
PageSchema,
|
|
42
50
|
PagesResponseSchema,
|
|
43
|
-
SpaceSchema,
|
|
44
51
|
SpacesResponseSchema,
|
|
45
52
|
type Attachment,
|
|
46
53
|
type AttachmentsResponse,
|
|
@@ -109,8 +116,10 @@ export class ConfluenceClient {
|
|
|
109
116
|
): Effect.Effect<T, ApiError | AuthError | NetworkError | RateLimitError> {
|
|
110
117
|
const url = `${this.baseUrl}/wiki/api/v2${path}`;
|
|
111
118
|
|
|
119
|
+
const verbose = process.env.CN_DEBUG === '1';
|
|
112
120
|
const makeRequest = Effect.tryPromise({
|
|
113
121
|
try: async () => {
|
|
122
|
+
if (verbose) process.stderr.write(`[debug] fetch: ${url}\n`);
|
|
114
123
|
const response = await fetch(url, {
|
|
115
124
|
...options,
|
|
116
125
|
headers: {
|
|
@@ -170,74 +179,36 @@ export class ConfluenceClient {
|
|
|
170
179
|
|
|
171
180
|
/** Get all spaces (Effect version) */
|
|
172
181
|
getSpacesEffect(limit = 25): Effect.Effect<SpacesResponse, ApiError | AuthError | NetworkError | RateLimitError> {
|
|
173
|
-
return this.
|
|
182
|
+
return getSpacesEffectFn(this.baseUrl, this.authHeader, limit, (path, schema) =>
|
|
183
|
+
this.fetchWithRetryEffect(path, schema),
|
|
184
|
+
);
|
|
174
185
|
}
|
|
175
186
|
|
|
176
|
-
/** Get
|
|
177
|
-
async getSpaces(limit = 25): Promise<
|
|
178
|
-
return this.
|
|
187
|
+
/** Get spaces with offset-based pagination via v1 API */
|
|
188
|
+
async getSpaces(limit = 25, page = 1): Promise<{ results: Space[]; start: number; limit: number; size: number }> {
|
|
189
|
+
return getSpacesFn(this.baseUrl, this.authHeader, limit, page);
|
|
179
190
|
}
|
|
180
191
|
|
|
181
|
-
/** Get all spaces with
|
|
192
|
+
/** Get all spaces with full cursor pagination */
|
|
182
193
|
async getAllSpaces(): Promise<Space[]> {
|
|
183
|
-
|
|
184
|
-
let cursor: string | undefined;
|
|
185
|
-
do {
|
|
186
|
-
let path = '/spaces?limit=100';
|
|
187
|
-
if (cursor) path += `&cursor=${encodeURIComponent(cursor)}`;
|
|
188
|
-
const response = await this.fetchWithRetry(path, SpacesResponseSchema);
|
|
189
|
-
allSpaces.push(...response.results);
|
|
190
|
-
cursor = extractCursor(response._links?.next);
|
|
191
|
-
} while (cursor);
|
|
192
|
-
return allSpaces;
|
|
194
|
+
return getAllSpacesFn(this.baseUrl, this.authHeader, (path, schema) => this.fetchWithRetry(path, schema));
|
|
193
195
|
}
|
|
194
196
|
|
|
195
197
|
/** Get a space by key (Effect version) */
|
|
196
198
|
getSpaceByKeyEffect(
|
|
197
199
|
key: string,
|
|
198
200
|
): Effect.Effect<Space, ApiError | AuthError | NetworkError | RateLimitError | SpaceNotFoundError> {
|
|
199
|
-
return
|
|
200
|
-
this.fetchWithRetryEffect(`/spaces?keys=${key}&limit=1`, SpacesResponseSchema),
|
|
201
|
-
Effect.flatMap((response) => {
|
|
202
|
-
if (response.results.length === 0) {
|
|
203
|
-
return Effect.fail(new SpaceNotFoundError(key));
|
|
204
|
-
}
|
|
205
|
-
return Effect.succeed(response.results[0]);
|
|
206
|
-
}),
|
|
207
|
-
);
|
|
201
|
+
return getSpaceByKeyEffectFn(key, (path, schema) => this.fetchWithRetryEffect(path, schema));
|
|
208
202
|
}
|
|
209
203
|
|
|
210
204
|
/** Get a space by key (async version) */
|
|
211
205
|
async getSpaceByKey(key: string): Promise<Space> {
|
|
212
|
-
|
|
213
|
-
if (response.results.length === 0) {
|
|
214
|
-
throw new SpaceNotFoundError(key);
|
|
215
|
-
}
|
|
216
|
-
return response.results[0];
|
|
206
|
+
return getSpaceByKeyFn(key, (path, schema) => this.fetchWithRetry(path, schema));
|
|
217
207
|
}
|
|
218
208
|
|
|
219
209
|
/** Get a space by ID (Effect version) */
|
|
220
|
-
getSpaceByIdEffect(
|
|
221
|
-
id
|
|
222
|
-
): Effect.Effect<Space, ApiError | AuthError | NetworkError | RateLimitError | SpaceNotFoundError> {
|
|
223
|
-
const baseUrl = this.baseUrl;
|
|
224
|
-
const authHeader = this.authHeader;
|
|
225
|
-
return Effect.tryPromise({
|
|
226
|
-
try: async () => {
|
|
227
|
-
const url = `${baseUrl}/wiki/api/v2/spaces/${id}`;
|
|
228
|
-
const response = await fetch(url, { headers: { Authorization: authHeader, Accept: 'application/json' } });
|
|
229
|
-
if (response.status === 404) throw new SpaceNotFoundError(id);
|
|
230
|
-
if (response.status === 401) throw new AuthError('Invalid credentials', 401);
|
|
231
|
-
if (response.status === 403) throw new AuthError('Access denied', 403);
|
|
232
|
-
if (!response.ok) throw new ApiError(`API error: ${response.status}`, response.status);
|
|
233
|
-
return Schema.decodeUnknownSync(SpaceSchema)(await response.json());
|
|
234
|
-
},
|
|
235
|
-
catch: (error) => {
|
|
236
|
-
if (error instanceof SpaceNotFoundError || error instanceof AuthError || error instanceof ApiError)
|
|
237
|
-
return error;
|
|
238
|
-
return new NetworkError(`Network error: ${error}`);
|
|
239
|
-
},
|
|
240
|
-
});
|
|
210
|
+
getSpaceByIdEffect(id: string): Effect.Effect<Space, ApiError | AuthError | NetworkError | SpaceNotFoundError> {
|
|
211
|
+
return getSpaceByIdEffectFn(id, this.baseUrl, this.authHeader);
|
|
241
212
|
}
|
|
242
213
|
|
|
243
214
|
/** Get a space by ID (async version) */
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { Effect, pipe, Schema } from 'effect';
|
|
2
|
+
import { ApiError, AuthError, NetworkError, type RateLimitError, SpaceNotFoundError } from '../errors.js';
|
|
3
|
+
import { SpaceSchema, SpacesResponseSchema, SpacesV1ResponseSchema, type Space, type SpacesResponse } from './types.js';
|
|
4
|
+
|
|
5
|
+
function extractCursor(nextLink: string | undefined): string | undefined {
|
|
6
|
+
if (!nextLink) return undefined;
|
|
7
|
+
try {
|
|
8
|
+
const url = new URL(nextLink, 'https://placeholder.invalid');
|
|
9
|
+
return url.searchParams.get('cursor') ?? undefined;
|
|
10
|
+
} catch {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function fetchV1<T>(baseUrl: string, authHeader: string, path: string, schema: Schema.Schema<T>): Promise<T> {
|
|
16
|
+
const url = `${baseUrl}/wiki/rest/api${path}`;
|
|
17
|
+
const verbose = process.env.CN_DEBUG === '1';
|
|
18
|
+
if (verbose) process.stderr.write(`[debug] fetchV1: ${url}\n`);
|
|
19
|
+
const response = await fetch(url, {
|
|
20
|
+
headers: { Authorization: authHeader, Accept: 'application/json' },
|
|
21
|
+
});
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
const errorText = await response.text();
|
|
24
|
+
throw new ApiError(`API request failed: ${response.status} ${errorText}`, response.status);
|
|
25
|
+
}
|
|
26
|
+
const data = await response.json();
|
|
27
|
+
return Effect.runPromise(
|
|
28
|
+
Schema.decodeUnknown(schema)(data).pipe(Effect.mapError((e) => new ApiError(`Invalid response: ${e}`, 500))),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function getSpacesEffect(
|
|
33
|
+
_baseUrl: string,
|
|
34
|
+
_authHeader: string,
|
|
35
|
+
limit: number,
|
|
36
|
+
fetchWithRetryEffect: <T>(
|
|
37
|
+
path: string,
|
|
38
|
+
schema: Schema.Schema<T>,
|
|
39
|
+
) => Effect.Effect<T, ApiError | AuthError | NetworkError | RateLimitError>,
|
|
40
|
+
): Effect.Effect<SpacesResponse, ApiError | AuthError | NetworkError | RateLimitError> {
|
|
41
|
+
return fetchWithRetryEffect(`/spaces?limit=${limit}`, SpacesResponseSchema);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function getSpaces(
|
|
45
|
+
baseUrl: string,
|
|
46
|
+
authHeader: string,
|
|
47
|
+
limit = 25,
|
|
48
|
+
page = 1,
|
|
49
|
+
): Promise<{ results: Space[]; start: number; limit: number; size: number }> {
|
|
50
|
+
const start = (page - 1) * limit;
|
|
51
|
+
const response = await fetchV1(baseUrl, authHeader, `/space?limit=${limit}&start=${start}`, SpacesV1ResponseSchema);
|
|
52
|
+
return {
|
|
53
|
+
...response,
|
|
54
|
+
results: response.results.map((s) => ({ ...s, id: String(s.id) })),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function getAllSpaces(
|
|
59
|
+
baseUrl: string,
|
|
60
|
+
_authHeader: string,
|
|
61
|
+
fetchWithRetry: <T>(path: string, schema: Schema.Schema<T>) => Promise<T>,
|
|
62
|
+
): Promise<Space[]> {
|
|
63
|
+
const verbose = process.env.CN_DEBUG === '1';
|
|
64
|
+
const allSpaces: Space[] = [];
|
|
65
|
+
let cursor: string | undefined;
|
|
66
|
+
let page = 1;
|
|
67
|
+
do {
|
|
68
|
+
let path = '/spaces?limit=20';
|
|
69
|
+
if (cursor) path += `&cursor=${encodeURIComponent(cursor)}`;
|
|
70
|
+
if (verbose) process.stderr.write(`[debug] getAllSpaces: fetching page ${page} (${baseUrl}/wiki/api/v2${path})\n`);
|
|
71
|
+
const response = await fetchWithRetry(path, SpacesResponseSchema);
|
|
72
|
+
if (verbose)
|
|
73
|
+
process.stderr.write(
|
|
74
|
+
`[debug] getAllSpaces: got ${response.results.length} spaces, next=${response._links?.next ?? 'none'}\n`,
|
|
75
|
+
);
|
|
76
|
+
allSpaces.push(...response.results);
|
|
77
|
+
cursor = extractCursor(response._links?.next);
|
|
78
|
+
page++;
|
|
79
|
+
} while (cursor);
|
|
80
|
+
if (verbose) process.stderr.write(`[debug] getAllSpaces: done, total=${allSpaces.length}\n`);
|
|
81
|
+
return allSpaces;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function getSpaceByKeyEffect(
|
|
85
|
+
key: string,
|
|
86
|
+
fetchWithRetryEffect: <T>(
|
|
87
|
+
path: string,
|
|
88
|
+
schema: Schema.Schema<T>,
|
|
89
|
+
) => Effect.Effect<T, ApiError | AuthError | NetworkError | RateLimitError>,
|
|
90
|
+
): Effect.Effect<Space, ApiError | AuthError | NetworkError | RateLimitError | SpaceNotFoundError> {
|
|
91
|
+
return pipe(
|
|
92
|
+
fetchWithRetryEffect(`/spaces?keys=${key}&limit=1`, SpacesResponseSchema),
|
|
93
|
+
Effect.flatMap((response) => {
|
|
94
|
+
if (response.results.length === 0) {
|
|
95
|
+
return Effect.fail(new SpaceNotFoundError(key));
|
|
96
|
+
}
|
|
97
|
+
return Effect.succeed(response.results[0]);
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function getSpaceByKey(
|
|
103
|
+
key: string,
|
|
104
|
+
fetchWithRetry: <T>(path: string, schema: Schema.Schema<T>) => Promise<T>,
|
|
105
|
+
): Promise<Space> {
|
|
106
|
+
const response = await fetchWithRetry(`/spaces?keys=${key}&limit=1`, SpacesResponseSchema);
|
|
107
|
+
if (response.results.length === 0) {
|
|
108
|
+
throw new SpaceNotFoundError(key);
|
|
109
|
+
}
|
|
110
|
+
return response.results[0];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function getSpaceByIdEffect(
|
|
114
|
+
id: string,
|
|
115
|
+
baseUrl: string,
|
|
116
|
+
authHeader: string,
|
|
117
|
+
): Effect.Effect<Space, ApiError | AuthError | NetworkError | SpaceNotFoundError> {
|
|
118
|
+
return Effect.tryPromise({
|
|
119
|
+
try: async () => {
|
|
120
|
+
const url = `${baseUrl}/wiki/api/v2/spaces/${id}`;
|
|
121
|
+
const response = await fetch(url, { headers: { Authorization: authHeader, Accept: 'application/json' } });
|
|
122
|
+
if (response.status === 404) throw new SpaceNotFoundError(id);
|
|
123
|
+
if (response.status === 401) throw new AuthError('Invalid credentials', 401);
|
|
124
|
+
if (response.status === 403) throw new AuthError('Access denied', 403);
|
|
125
|
+
if (!response.ok) throw new ApiError(`API error: ${response.status}`, response.status);
|
|
126
|
+
return Schema.decodeUnknownSync(SpaceSchema)(await response.json());
|
|
127
|
+
},
|
|
128
|
+
catch: (error) => {
|
|
129
|
+
if (error instanceof SpaceNotFoundError || error instanceof AuthError || error instanceof ApiError) return error;
|
|
130
|
+
return new NetworkError(`Network error: ${error}`);
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
}
|
|
@@ -24,7 +24,7 @@ export const SpaceSchema = Schema.Struct({
|
|
|
24
24
|
name: Schema.String,
|
|
25
25
|
type: Schema.optional(Schema.String),
|
|
26
26
|
status: Schema.optional(Schema.String),
|
|
27
|
-
homepageId: Schema.optional(Schema.String),
|
|
27
|
+
homepageId: Schema.optional(Schema.NullOr(Schema.String)),
|
|
28
28
|
description: Schema.optional(
|
|
29
29
|
Schema.NullOr(
|
|
30
30
|
Schema.Struct({
|
|
@@ -57,6 +57,23 @@ export const SpacesResponseSchema = Schema.Struct({
|
|
|
57
57
|
});
|
|
58
58
|
export type SpacesResponse = Schema.Schema.Type<typeof SpacesResponseSchema>;
|
|
59
59
|
|
|
60
|
+
const SpaceV1Schema = Schema.Struct({
|
|
61
|
+
id: Schema.Number,
|
|
62
|
+
key: Schema.String,
|
|
63
|
+
name: Schema.String,
|
|
64
|
+
type: Schema.optional(Schema.String),
|
|
65
|
+
status: Schema.optional(Schema.String),
|
|
66
|
+
_links: Schema.optional(Schema.Struct({ webui: Schema.optional(Schema.String) })),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
export const SpacesV1ResponseSchema = Schema.Struct({
|
|
70
|
+
results: Schema.Array(SpaceV1Schema),
|
|
71
|
+
start: Schema.Number,
|
|
72
|
+
limit: Schema.Number,
|
|
73
|
+
size: Schema.Number,
|
|
74
|
+
});
|
|
75
|
+
export type SpacesV1Response = Schema.Schema.Type<typeof SpacesV1ResponseSchema>;
|
|
76
|
+
|
|
60
77
|
/**
|
|
61
78
|
* Page version information
|
|
62
79
|
*/
|
|
@@ -182,12 +199,19 @@ export interface VersionConflictResponse {
|
|
|
182
199
|
/**
|
|
183
200
|
* Request body for updating a page
|
|
184
201
|
*/
|
|
202
|
+
export const RepresentationSchema = Schema.Union(
|
|
203
|
+
Schema.Literal('storage'),
|
|
204
|
+
Schema.Literal('wiki'),
|
|
205
|
+
Schema.Literal('atlas_doc_format'),
|
|
206
|
+
);
|
|
207
|
+
export type Representation = Schema.Schema.Type<typeof RepresentationSchema>;
|
|
208
|
+
|
|
185
209
|
export const UpdatePageRequestSchema = Schema.Struct({
|
|
186
210
|
id: Schema.String,
|
|
187
211
|
status: Schema.String,
|
|
188
212
|
title: Schema.String,
|
|
189
213
|
body: Schema.Struct({
|
|
190
|
-
representation:
|
|
214
|
+
representation: RepresentationSchema,
|
|
191
215
|
value: Schema.String,
|
|
192
216
|
}),
|
|
193
217
|
version: Schema.Struct({
|
|
@@ -206,7 +230,7 @@ export const CreatePageRequestSchema = Schema.Struct({
|
|
|
206
230
|
title: Schema.String,
|
|
207
231
|
parentId: Schema.optional(Schema.String),
|
|
208
232
|
body: Schema.Struct({
|
|
209
|
-
representation:
|
|
233
|
+
representation: RepresentationSchema,
|
|
210
234
|
value: Schema.String,
|
|
211
235
|
}),
|
|
212
236
|
});
|
|
@@ -23,7 +23,7 @@ describe('ConfluenceClient', () => {
|
|
|
23
23
|
|
|
24
24
|
test('throws AuthError on 401', async () => {
|
|
25
25
|
server.use(
|
|
26
|
-
http.get('*/wiki/api/
|
|
26
|
+
http.get('*/wiki/rest/api/space', () => {
|
|
27
27
|
return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
28
28
|
}),
|
|
29
29
|
);
|
|
@@ -37,7 +37,7 @@ describe('ConfluenceClient', () => {
|
|
|
37
37
|
|
|
38
38
|
test('throws AuthError on 403', async () => {
|
|
39
39
|
server.use(
|
|
40
|
-
http.get('*/wiki/api/
|
|
40
|
+
http.get('*/wiki/rest/api/space', () => {
|
|
41
41
|
return HttpResponse.json({ error: 'Forbidden' }, { status: 403 });
|
|
42
42
|
}),
|
|
43
43
|
);
|
|
@@ -63,16 +63,18 @@ describe('ConfluenceClient', () => {
|
|
|
63
63
|
|
|
64
64
|
test('handles spaces with null description', async () => {
|
|
65
65
|
server.use(
|
|
66
|
-
http.get('*/wiki/api/
|
|
66
|
+
http.get('*/wiki/rest/api/space', () => {
|
|
67
67
|
return HttpResponse.json({
|
|
68
68
|
results: [
|
|
69
69
|
{
|
|
70
|
-
id:
|
|
70
|
+
id: 99,
|
|
71
71
|
key: 'NULL',
|
|
72
72
|
name: 'Space with null description',
|
|
73
|
-
description: null,
|
|
74
73
|
},
|
|
75
74
|
],
|
|
75
|
+
start: 0,
|
|
76
|
+
limit: 25,
|
|
77
|
+
size: 1,
|
|
76
78
|
});
|
|
77
79
|
}),
|
|
78
80
|
);
|
|
@@ -82,7 +84,6 @@ describe('ConfluenceClient', () => {
|
|
|
82
84
|
|
|
83
85
|
expect(response.results).toBeArray();
|
|
84
86
|
expect(response.results[0].key).toBe('NULL');
|
|
85
|
-
expect(response.results[0].description).toBeNull();
|
|
86
87
|
});
|
|
87
88
|
});
|
|
88
89
|
|
|
@@ -425,7 +426,8 @@ describe('ConfluenceClient', () => {
|
|
|
425
426
|
);
|
|
426
427
|
|
|
427
428
|
const client = new ConfluenceClient(testConfig);
|
|
428
|
-
const
|
|
429
|
+
const { Effect } = await import('effect');
|
|
430
|
+
const response = await Effect.runPromise(client.getSpacesEffect(1));
|
|
429
431
|
|
|
430
432
|
expect(response.results).toBeArray();
|
|
431
433
|
expect(requestCount).toBe(2);
|
|
@@ -26,6 +26,18 @@ import {
|
|
|
26
26
|
* Individual tests should override these with server.use() for specific test scenarios.
|
|
27
27
|
*/
|
|
28
28
|
export const handlers = [
|
|
29
|
+
// Confluence v1 spaces mock (offset-based pagination)
|
|
30
|
+
http.get('*/wiki/rest/api/space', ({ request }) => {
|
|
31
|
+
const url = new URL(request.url);
|
|
32
|
+
const limit = Number(url.searchParams.get('limit') ?? 25);
|
|
33
|
+
const start = Number(url.searchParams.get('start') ?? 0);
|
|
34
|
+
const spaces = [
|
|
35
|
+
{ id: 1, key: 'TEST', name: 'Test Space', type: 'global' },
|
|
36
|
+
{ id: 2, key: 'DOCS', name: 'Documentation', type: 'global' },
|
|
37
|
+
].slice(start, start + limit);
|
|
38
|
+
return HttpResponse.json({ results: spaces, start, limit, size: spaces.length });
|
|
39
|
+
}),
|
|
40
|
+
|
|
29
41
|
// Confluence spaces mock
|
|
30
42
|
http.get('*/wiki/api/v2/spaces', ({ request }) => {
|
|
31
43
|
const url = new URL(request.url);
|
package/src/test/spaces.test.ts
CHANGED
|
@@ -42,7 +42,7 @@ describe('ConfluenceClient - spaces', () => {
|
|
|
42
42
|
|
|
43
43
|
test('throws on 401', async () => {
|
|
44
44
|
server.use(
|
|
45
|
-
http.get('*/wiki/api/
|
|
45
|
+
http.get('*/wiki/rest/api/space', () => {
|
|
46
46
|
return HttpResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
47
47
|
}),
|
|
48
48
|
);
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { HttpResponse, http } from 'msw';
|
|
3
|
+
import { ConfluenceClient } from '../lib/confluence-client/client.js';
|
|
4
|
+
import { server } from './setup-msw.js';
|
|
5
|
+
import { createValidPage } from './msw-schema-validation.js';
|
|
6
|
+
import { findPositional } from '../cli/utils/args.js';
|
|
7
|
+
import { isValidFormat, VALID_FORMATS } from '../cli/utils/stdin.js';
|
|
8
|
+
|
|
9
|
+
const testConfig = {
|
|
10
|
+
confluenceUrl: 'https://test.atlassian.net',
|
|
11
|
+
email: 'test@example.com',
|
|
12
|
+
apiToken: 'test-token',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe('ConfluenceClient - updatePage', () => {
|
|
16
|
+
test('updates page successfully', async () => {
|
|
17
|
+
server.use(
|
|
18
|
+
http.put('*/wiki/api/v2/pages/:pageId', async ({ request, params }) => {
|
|
19
|
+
const body = (await request.json()) as { title: string; body: { value: string } };
|
|
20
|
+
const page = createValidPage({
|
|
21
|
+
id: params.pageId as string,
|
|
22
|
+
title: body.title,
|
|
23
|
+
version: 2,
|
|
24
|
+
});
|
|
25
|
+
return HttpResponse.json(page);
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const client = new ConfluenceClient(testConfig);
|
|
30
|
+
const result = await client.updatePage({
|
|
31
|
+
id: 'page-123',
|
|
32
|
+
status: 'current',
|
|
33
|
+
title: 'Updated Title',
|
|
34
|
+
body: { representation: 'storage', value: '<p>New content</p>' },
|
|
35
|
+
version: { number: 2 },
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(result.id).toBe('page-123');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('fetches page without body when includeBody=false', async () => {
|
|
42
|
+
let requestUrl = '';
|
|
43
|
+
server.use(
|
|
44
|
+
http.get('*/wiki/api/v2/pages/:pageId', ({ request }) => {
|
|
45
|
+
requestUrl = request.url;
|
|
46
|
+
const page = createValidPage({ id: 'page-123', version: 3 });
|
|
47
|
+
return HttpResponse.json(page);
|
|
48
|
+
}),
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
const client = new ConfluenceClient(testConfig);
|
|
52
|
+
await client.getPage('page-123', false);
|
|
53
|
+
expect(requestUrl).not.toContain('body-format');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('throws on 404', async () => {
|
|
57
|
+
server.use(
|
|
58
|
+
http.put('*/wiki/api/v2/pages/:pageId', () => {
|
|
59
|
+
return HttpResponse.json({ message: 'Not found' }, { status: 404 });
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const client = new ConfluenceClient(testConfig);
|
|
64
|
+
await expect(
|
|
65
|
+
client.updatePage({
|
|
66
|
+
id: 'nonexistent',
|
|
67
|
+
status: 'current',
|
|
68
|
+
title: 'Title',
|
|
69
|
+
body: { representation: 'storage', value: '<p>x</p>' },
|
|
70
|
+
version: { number: 2 },
|
|
71
|
+
}),
|
|
72
|
+
).rejects.toThrow();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('format validation', () => {
|
|
77
|
+
test('accepts valid formats', () => {
|
|
78
|
+
for (const fmt of VALID_FORMATS) {
|
|
79
|
+
expect(isValidFormat(fmt)).toBe(true);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('rejects markdown', () => {
|
|
84
|
+
expect(isValidFormat('markdown')).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('rejects unknown format', () => {
|
|
88
|
+
expect(isValidFormat('html')).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('findPositional', () => {
|
|
93
|
+
test('finds simple positional arg', () => {
|
|
94
|
+
expect(findPositional(['123456'], ['--format', '--title'])).toBe('123456');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('skips flag and its value, not confusing them with positional', () => {
|
|
98
|
+
// cn update --title 123 123 => subArgs: ['--title', '123', '123'] => positional is second 123
|
|
99
|
+
expect(findPositional(['--title', '123', '123'], ['--title'])).toBe('123');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('id equals title value — skips by index not value', () => {
|
|
103
|
+
// cn update 123 --title 123 => subArgs: ['123', '--title', '123'] => positional is first 123
|
|
104
|
+
expect(findPositional(['123', '--title', '123'], ['--title'])).toBe('123');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('returns undefined when no positional', () => {
|
|
108
|
+
expect(findPositional(['--title', 'Some Title'], ['--title'])).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('create: title equals space value', () => {
|
|
112
|
+
// cn create ENG --space ENG => subArgs: ['ENG', '--space', 'ENG'] => positional is ENG
|
|
113
|
+
expect(findPositional(['ENG', '--space', 'ENG'], ['--space', '--parent', '--format'])).toBe('ENG');
|
|
114
|
+
});
|
|
115
|
+
});
|