@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 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.2",
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
 
@@ -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 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:')}
@@ -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 clone DOCS Clone DOCS space to ./DOCS
412
- cn pull Pull changes
413
- cn tree Show page hierarchy
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
- await spacesCommand({ xml: args.includes('--xml') });
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
- const createFlagValues = new Set([spaceKey, parentId].filter(Boolean));
275
- const title = subArgs.find((arg) => !arg.startsWith('--') && !createFlagValues.has(arg));
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.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
+ });