@aaronshaf/confluence-cli 1.0.0 → 1.0.1
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/spaces.ts +10 -1
- package/src/cli/commands/update.ts +72 -0
- package/src/cli/help.ts +40 -4
- package/src/cli/index.ts +62 -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
|
|
|
@@ -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:')}
|
|
@@ -219,6 +220,7 @@ ${chalk.bold('cn create - Create a new Confluence page')}
|
|
|
219
220
|
|
|
220
221
|
${chalk.yellow('Usage:')}
|
|
221
222
|
cn create <title> [options]
|
|
223
|
+
echo "<p>Content</p>" | cn create <title> [options]
|
|
222
224
|
|
|
223
225
|
${chalk.yellow('Arguments:')}
|
|
224
226
|
title Page title (required)
|
|
@@ -226,8 +228,37 @@ ${chalk.yellow('Arguments:')}
|
|
|
226
228
|
${chalk.yellow('Options:')}
|
|
227
229
|
--space <key> Space key (required if not in cloned dir)
|
|
228
230
|
--parent <id> Parent page ID
|
|
231
|
+
--format <format> Body format: storage (default), wiki, atlas_doc_format
|
|
229
232
|
--open Open page in browser after creation
|
|
230
233
|
--help Show this help message
|
|
234
|
+
|
|
235
|
+
${chalk.yellow('Examples:')}
|
|
236
|
+
cn create "My Page" --space ENG
|
|
237
|
+
echo "<p>Hello</p>" | cn create "My Page" --space ENG
|
|
238
|
+
echo "h1. Hello" | cn create "Wiki Page" --space ENG --format wiki
|
|
239
|
+
`);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export function showUpdateHelp(): void {
|
|
243
|
+
console.log(`
|
|
244
|
+
${chalk.bold('cn update - Update an existing Confluence page')}
|
|
245
|
+
|
|
246
|
+
${chalk.yellow('Usage:')}
|
|
247
|
+
echo "<p>Content</p>" | cn update <id> [options]
|
|
248
|
+
|
|
249
|
+
${chalk.yellow('Arguments:')}
|
|
250
|
+
id Page ID (required)
|
|
251
|
+
|
|
252
|
+
${chalk.yellow('Options:')}
|
|
253
|
+
--format <format> Body format: storage (default), wiki, atlas_doc_format
|
|
254
|
+
--title <title> New page title (default: keep existing title)
|
|
255
|
+
--message <msg> Version message
|
|
256
|
+
--help Show this help message
|
|
257
|
+
|
|
258
|
+
${chalk.yellow('Examples:')}
|
|
259
|
+
echo "<p>Updated content</p>" | cn update 123456
|
|
260
|
+
echo "<p>New content</p>" | cn update 123456 --title "New Title"
|
|
261
|
+
echo "h1. Hello" | cn update 123456 --format wiki --message "Updated via automation"
|
|
231
262
|
`);
|
|
232
263
|
}
|
|
233
264
|
|
|
@@ -389,6 +420,7 @@ ${chalk.yellow('Commands:')}
|
|
|
389
420
|
cn spaces List available spaces
|
|
390
421
|
cn info Show page info and labels
|
|
391
422
|
cn create Create a new page
|
|
423
|
+
cn update Update an existing page
|
|
392
424
|
cn delete Delete a page
|
|
393
425
|
cn comments Show page comments
|
|
394
426
|
cn labels Manage page labels
|
|
@@ -408,10 +440,14 @@ ${chalk.yellow('Environment Variables:')}
|
|
|
408
440
|
|
|
409
441
|
${chalk.yellow('Examples:')}
|
|
410
442
|
cn setup Configure credentials
|
|
411
|
-
cn
|
|
412
|
-
cn
|
|
413
|
-
cn
|
|
443
|
+
cn spaces List available spaces
|
|
444
|
+
cn search "my topic" Search across all spaces
|
|
445
|
+
cn search "api" --space ENG Search within a space
|
|
446
|
+
cn info 123456 Show page info
|
|
414
447
|
cn open "My Page" Open page in browser
|
|
448
|
+
cn create "New Page" --space ENG Create a page
|
|
449
|
+
cn clone DOCS Clone DOCS space locally
|
|
450
|
+
cn pull Pull changes
|
|
415
451
|
|
|
416
452
|
${chalk.gray('For more information on a command, run: cn <command> --help')}
|
|
417
453
|
${chalk.gray('Confluence REST API reference: https://docs.atlassian.com/atlassian-confluence/REST/6.6.0/')}
|
package/src/cli/index.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
showSpacesHelp,
|
|
24
24
|
showStatusHelp,
|
|
25
25
|
showTreeHelp,
|
|
26
|
+
showUpdateHelp,
|
|
26
27
|
} from './help.js';
|
|
27
28
|
import { attachmentsCommand } from './commands/attachments.js';
|
|
28
29
|
import { cloneCommand } from './commands/clone.js';
|
|
@@ -41,6 +42,9 @@ import { setup } from './commands/setup.js';
|
|
|
41
42
|
import { spacesCommand } from './commands/spaces.js';
|
|
42
43
|
import { statusCommand } from './commands/status.js';
|
|
43
44
|
import { treeCommand } from './commands/tree.js';
|
|
45
|
+
import { updateCommand } from './commands/update.js';
|
|
46
|
+
|
|
47
|
+
import { findPositional } from './utils/args.js';
|
|
44
48
|
|
|
45
49
|
// Get version from package.json
|
|
46
50
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -210,7 +214,23 @@ async function main(): Promise<void> {
|
|
|
210
214
|
showSpacesHelp();
|
|
211
215
|
process.exit(EXIT_CODES.SUCCESS);
|
|
212
216
|
}
|
|
213
|
-
|
|
217
|
+
{
|
|
218
|
+
let limit: number | undefined;
|
|
219
|
+
const limitArg = args.find((a) => a.startsWith('--limit=') || a === '--limit');
|
|
220
|
+
if (limitArg) {
|
|
221
|
+
limit = limitArg.includes('=')
|
|
222
|
+
? Number.parseInt(limitArg.split('=')[1], 10)
|
|
223
|
+
: Number.parseInt(args[args.indexOf('--limit') + 1], 10);
|
|
224
|
+
}
|
|
225
|
+
let page: number | undefined;
|
|
226
|
+
const pageArg = args.find((a) => a.startsWith('--page=') || a === '--page');
|
|
227
|
+
if (pageArg) {
|
|
228
|
+
page = pageArg.includes('=')
|
|
229
|
+
? Number.parseInt(pageArg.split('=')[1], 10)
|
|
230
|
+
: Number.parseInt(args[args.indexOf('--page') + 1], 10);
|
|
231
|
+
}
|
|
232
|
+
await spacesCommand({ xml: args.includes('--xml'), limit, page });
|
|
233
|
+
}
|
|
214
234
|
break;
|
|
215
235
|
|
|
216
236
|
case 'search': {
|
|
@@ -271,8 +291,12 @@ async function main(): Promise<void> {
|
|
|
271
291
|
if (parentIdx !== -1 && parentIdx + 1 < args.length) {
|
|
272
292
|
parentId = args[parentIdx + 1];
|
|
273
293
|
}
|
|
274
|
-
|
|
275
|
-
const
|
|
294
|
+
let createFormat: string | undefined;
|
|
295
|
+
const createFormatIdx = args.indexOf('--format');
|
|
296
|
+
if (createFormatIdx !== -1 && createFormatIdx + 1 < args.length) {
|
|
297
|
+
createFormat = args[createFormatIdx + 1];
|
|
298
|
+
}
|
|
299
|
+
const title = findPositional(subArgs, ['--space', '--parent', '--format']);
|
|
276
300
|
if (!title) {
|
|
277
301
|
console.error(chalk.red('Page title is required.'));
|
|
278
302
|
console.log(chalk.gray('Usage: cn create <title>'));
|
|
@@ -282,6 +306,7 @@ async function main(): Promise<void> {
|
|
|
282
306
|
space: spaceKey,
|
|
283
307
|
parent: parentId,
|
|
284
308
|
open: args.includes('--open'),
|
|
309
|
+
format: createFormat,
|
|
285
310
|
});
|
|
286
311
|
break;
|
|
287
312
|
}
|
|
@@ -393,6 +418,40 @@ async function main(): Promise<void> {
|
|
|
393
418
|
break;
|
|
394
419
|
}
|
|
395
420
|
|
|
421
|
+
case 'update': {
|
|
422
|
+
if (args.includes('--help')) {
|
|
423
|
+
showUpdateHelp();
|
|
424
|
+
process.exit(EXIT_CODES.SUCCESS);
|
|
425
|
+
}
|
|
426
|
+
let updateFormat: string | undefined;
|
|
427
|
+
const updateFormatIdx = args.indexOf('--format');
|
|
428
|
+
if (updateFormatIdx !== -1 && updateFormatIdx + 1 < args.length) {
|
|
429
|
+
updateFormat = args[updateFormatIdx + 1];
|
|
430
|
+
}
|
|
431
|
+
let updateTitle: string | undefined;
|
|
432
|
+
const updateTitleIdx = args.indexOf('--title');
|
|
433
|
+
if (updateTitleIdx !== -1 && updateTitleIdx + 1 < args.length) {
|
|
434
|
+
updateTitle = args[updateTitleIdx + 1];
|
|
435
|
+
}
|
|
436
|
+
let updateMessage: string | undefined;
|
|
437
|
+
const updateMessageIdx = args.indexOf('--message');
|
|
438
|
+
if (updateMessageIdx !== -1 && updateMessageIdx + 1 < args.length) {
|
|
439
|
+
updateMessage = args[updateMessageIdx + 1];
|
|
440
|
+
}
|
|
441
|
+
const updateId = findPositional(subArgs, ['--format', '--title', '--message']);
|
|
442
|
+
if (!updateId) {
|
|
443
|
+
console.error(chalk.red('Page ID is required.'));
|
|
444
|
+
console.log(chalk.gray('Usage: cn update <id>'));
|
|
445
|
+
process.exit(EXIT_CODES.INVALID_ARGUMENTS);
|
|
446
|
+
}
|
|
447
|
+
await updateCommand(updateId, {
|
|
448
|
+
format: updateFormat,
|
|
449
|
+
title: updateTitle,
|
|
450
|
+
message: updateMessage,
|
|
451
|
+
});
|
|
452
|
+
break;
|
|
453
|
+
}
|
|
454
|
+
|
|
396
455
|
default:
|
|
397
456
|
console.error(`Unknown command: ${command}`);
|
|
398
457
|
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
|
+
});
|