@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 CHANGED
@@ -1,39 +1,39 @@
1
- # cn
1
+ # confluence-cli
2
2
 
3
- CLI for syncing Confluence spaces to local markdown.
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
- # 1. Configure your Confluence credentials
16
+ # Configure your Confluence credentials
15
17
  cn setup
16
18
 
17
- # 2. Clone a Confluence space
18
- cn clone <SPACE_KEY>
19
+ # Search pages
20
+ cn search "authentication"
19
21
 
20
- # 3. Pull pages as markdown
21
- cd <SPACE_KEY>
22
- cn pull
23
- ```
22
+ # Open a page in the browser
23
+ cn open "Getting Started"
24
24
 
25
- The space key is the identifier in your Confluence URL:
26
- `https://yoursite.atlassian.net/wiki/spaces/<SPACE_KEY>/...`
25
+ # Create a page
26
+ cn create "My Page" --space ENG
27
27
 
28
- Credentials are stored in `~/.cn/config.json`. Space configuration is saved to `.confluence.json` in the synced directory.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronshaf/confluence-cli",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "Confluence CLI for syncing spaces and local markdown files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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: 'storage',
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 spaces = await client.getAllSpaces();
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 clone DOCS Clone DOCS space to ./DOCS
412
- cn pull Pull changes
413
- cn tree Show page hierarchy
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
- await spacesCommand({ xml: args.includes('--xml') });
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
- const createFlagValues = new Set([spaceKey, parentId].filter(Boolean));
275
- const title = subArgs.find((arg) => !arg.startsWith('--') && !createFlagValues.has(arg));
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.fetchWithRetryEffect(`/spaces?limit=${limit}`, SpacesResponseSchema);
182
+ return getSpacesEffectFn(this.baseUrl, this.authHeader, limit, (path, schema) =>
183
+ this.fetchWithRetryEffect(path, schema),
184
+ );
174
185
  }
175
186
 
176
- /** Get all spaces (async version) */
177
- async getSpaces(limit = 25): Promise<SpacesResponse> {
178
- return this.fetchWithRetry(`/spaces?limit=${limit}`, SpacesResponseSchema);
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 pagination (async version) */
192
+ /** Get all spaces with full cursor pagination */
182
193
  async getAllSpaces(): Promise<Space[]> {
183
- const allSpaces: Space[] = [];
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 pipe(
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
- const response = await this.fetchWithRetry(`/spaces?keys=${key}&limit=1`, SpacesResponseSchema);
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: string,
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) */
@@ -19,6 +19,7 @@ export type {
19
19
  SearchResultItem,
20
20
  Space,
21
21
  SpacesResponse,
22
+ SpacesV1Response,
22
23
  UpdatePageRequest,
23
24
  User,
24
25
  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: Schema.Literal('storage'),
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: Schema.Literal('storage'),
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/v2/spaces', () => {
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/v2/spaces', () => {
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/v2/spaces', () => {
66
+ http.get('*/wiki/rest/api/space', () => {
67
67
  return HttpResponse.json({
68
68
  results: [
69
69
  {
70
- id: 'space-null-desc',
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 response = await client.getSpaces();
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);
@@ -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/v2/spaces', () => {
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
+ });