@aaronshaf/confluence-cli 0.1.15 → 1.0.0

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
@@ -34,7 +34,6 @@ Credentials are stored in `~/.cn/config.json`. Space configuration is saved to `
34
34
  | `cn setup` | Configure Confluence credentials |
35
35
  | `cn clone <SPACE_KEY>` | Clone a space to a new folder |
36
36
  | `cn pull` | Pull changes from Confluence as markdown |
37
- | `cn push [file]` | Push local markdown file(s) to Confluence |
38
37
  | `cn status` | Check connection and sync status |
39
38
  | `cn tree` | Display page hierarchy |
40
39
  | `cn open [page]` | Open page in browser |
@@ -48,6 +47,7 @@ Credentials are stored in `~/.cn/config.json`. Space configuration is saved to `
48
47
  | `cn labels <id\|file>` | Manage page labels |
49
48
  | `cn move <id\|file> <parentId>` | Move a page to a new parent |
50
49
  | `cn attachments <id\|file>` | Manage page attachments |
50
+ | `cn folder <subcommand>` | Manage folders (create, list, delete, move) |
51
51
 
52
52
  Run `cn <command> --help` for details on each command.
53
53
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aaronshaf/confluence-cli",
3
- "version": "0.1.15",
3
+ "version": "1.0.0",
4
4
  "description": "Confluence CLI for syncing spaces and local markdown files",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,189 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import chalk from 'chalk';
3
+ import { ConfluenceClient } from '../../lib/confluence-client/index.js';
4
+ import { ConfigManager } from '../../lib/config.js';
5
+ import { EXIT_CODES } from '../../lib/errors.js';
6
+ import { escapeXml } from '../../lib/formatters.js';
7
+ import { readSpaceConfig } from '../../lib/space-config.js';
8
+
9
+ /**
10
+ * Extract a flag value from an args array, e.g. --space DOCS -> "DOCS"
11
+ */
12
+ function getFlagValue(args: string[], flag: string): string | undefined {
13
+ const idx = args.indexOf(flag);
14
+ if (idx !== -1 && idx + 1 < args.length && !args[idx + 1].startsWith('--')) {
15
+ return args[idx + 1];
16
+ }
17
+ return undefined;
18
+ }
19
+
20
+ /**
21
+ * Get positional args by stripping flags and their values
22
+ */
23
+ function getPositionals(args: string[], flagsWithValues: string[]): string[] {
24
+ const result: string[] = [];
25
+ for (let i = 0; i < args.length; i++) {
26
+ if (args[i].startsWith('--')) {
27
+ if (flagsWithValues.includes(args[i])) {
28
+ i++; // skip the value too
29
+ }
30
+ } else {
31
+ result.push(args[i]);
32
+ }
33
+ }
34
+ return result;
35
+ }
36
+
37
+ export async function folderCommand(subcommand: string, subArgs: string[], allArgs: string[]): Promise<void> {
38
+ const configManager = new ConfigManager();
39
+ const config = await configManager.getConfig();
40
+
41
+ if (!config) {
42
+ console.error(chalk.red('Not configured. Run: cn setup'));
43
+ process.exit(EXIT_CODES.CONFIG_ERROR);
44
+ }
45
+
46
+ const client = new ConfluenceClient(config);
47
+
48
+ switch (subcommand) {
49
+ case 'create': {
50
+ const positionals = getPositionals(subArgs, ['--space', '--parent']);
51
+ const title = positionals[0];
52
+ if (!title) {
53
+ console.error(chalk.red('Folder title is required.'));
54
+ console.log(chalk.gray('Usage: cn folder create <title> --space <key>'));
55
+ process.exit(EXIT_CODES.INVALID_ARGUMENTS);
56
+ }
57
+
58
+ const spaceKeyArg = getFlagValue(allArgs, '--space');
59
+ const parentId = getFlagValue(allArgs, '--parent');
60
+
61
+ let spaceId: string;
62
+
63
+ if (spaceKeyArg) {
64
+ const space = await client.getSpaceByKey(spaceKeyArg);
65
+ spaceId = space.id;
66
+ } else {
67
+ const spaceConfig = readSpaceConfig(process.cwd());
68
+ if (!spaceConfig) {
69
+ console.error(chalk.red('Not in a cloned space directory. Use --space to specify a space key.'));
70
+ process.exit(EXIT_CODES.INVALID_ARGUMENTS);
71
+ }
72
+ spaceId = spaceConfig.spaceId;
73
+ }
74
+
75
+ const folder = await client.createFolder({ spaceId, title, parentId });
76
+ console.log(`${chalk.green('Created:')} "${folder.title}" (${folder.id})`);
77
+ break;
78
+ }
79
+
80
+ case 'list': {
81
+ const spaceKeyArg = getFlagValue(allArgs, '--space');
82
+
83
+ let spaceKey: string;
84
+
85
+ if (spaceKeyArg) {
86
+ spaceKey = spaceKeyArg;
87
+ } else {
88
+ const spaceConfig = readSpaceConfig(process.cwd());
89
+ if (!spaceConfig) {
90
+ console.error(chalk.red('Not in a cloned space directory. Use --space to specify a space key.'));
91
+ process.exit(EXIT_CODES.INVALID_ARGUMENTS);
92
+ }
93
+ spaceKey = spaceConfig.spaceKey;
94
+ }
95
+
96
+ const cql = `type=folder AND space="${spaceKey.replace(/"/g, '\\"')}"`;
97
+ const PAGE_SIZE = 100;
98
+ const allResults: (typeof firstPage.results)[number][] = [];
99
+ let start = 0;
100
+
101
+ const firstPage = await client.search(cql, PAGE_SIZE, start);
102
+ allResults.push(...firstPage.results);
103
+ const total = firstPage.totalSize ?? firstPage.results.length;
104
+
105
+ while (allResults.length < total) {
106
+ start += PAGE_SIZE;
107
+ const page = await client.search(cql, PAGE_SIZE, start);
108
+ if (page.results.length === 0) break;
109
+ allResults.push(...page.results);
110
+ }
111
+
112
+ const xml = allArgs.includes('--xml');
113
+
114
+ if (xml) {
115
+ console.log('<folders>');
116
+ for (const result of allResults) {
117
+ const c = result.content;
118
+ if (c) {
119
+ console.log(` <folder>`);
120
+ console.log(` <id>${escapeXml(c.id ?? '')}</id>`);
121
+ console.log(` <title>${escapeXml(c.title ?? '')}</title>`);
122
+ console.log(` </folder>`);
123
+ }
124
+ }
125
+ console.log('</folders>');
126
+ } else {
127
+ if (allResults.length === 0) {
128
+ console.log(chalk.gray('No folders found.'));
129
+ } else {
130
+ for (const result of allResults) {
131
+ const c = result.content;
132
+ if (c) {
133
+ console.log(`${chalk.cyan(c.id)} ${c.title}`);
134
+ }
135
+ }
136
+ }
137
+ }
138
+ break;
139
+ }
140
+
141
+ case 'delete': {
142
+ const positionals = getPositionals(subArgs, []);
143
+ const folderId = positionals[0];
144
+ if (!folderId) {
145
+ console.error(chalk.red('Folder ID is required.'));
146
+ console.log(chalk.gray('Usage: cn folder delete <id>'));
147
+ process.exit(EXIT_CODES.INVALID_ARGUMENTS);
148
+ }
149
+
150
+ if (!allArgs.includes('--force')) {
151
+ const folder = await client.getFolder(folderId);
152
+ const confirmed = await confirm({
153
+ message: `Delete folder "${folder.title}" (${folderId})?`,
154
+ default: false,
155
+ });
156
+ if (!confirmed) {
157
+ console.log('Cancelled.');
158
+ return;
159
+ }
160
+ }
161
+
162
+ await client.deleteFolder(folderId);
163
+ console.log(`${chalk.green('Deleted:')} ${folderId}`);
164
+ break;
165
+ }
166
+
167
+ case 'move': {
168
+ const positionals = getPositionals(subArgs, []);
169
+ if (positionals.length < 2) {
170
+ console.error(chalk.red('Folder ID and parent ID are required.'));
171
+ console.log(chalk.gray('Usage: cn folder move <id> <parentId>'));
172
+ process.exit(EXIT_CODES.INVALID_ARGUMENTS);
173
+ }
174
+
175
+ const folderId = positionals[0] as string;
176
+ const parentId = positionals[1] as string;
177
+ const [folder, parent] = await Promise.all([client.getFolder(folderId), client.getFolder(parentId)]);
178
+
179
+ await client.movePage(folderId, parentId);
180
+ console.log(`${chalk.green('Moved:')} "${folder.title}" under "${parent.title}"`);
181
+ break;
182
+ }
183
+
184
+ default:
185
+ console.error(chalk.red(`Unknown folder subcommand: ${subcommand}`));
186
+ console.log(chalk.gray('Run "cn folder --help" for usage information.'));
187
+ process.exit(EXIT_CODES.INVALID_ARGUMENTS);
188
+ }
189
+ }
package/src/cli/help.ts CHANGED
@@ -89,41 +89,6 @@ ${chalk.yellow('Examples:')}
89
89
  `);
90
90
  }
91
91
 
92
- export function showPushHelp(): void {
93
- console.log(`
94
- ${chalk.bold('cn push - Push local markdown files to Confluence')}
95
-
96
- ${chalk.yellow('Usage:')}
97
- cn push [file] [options]
98
-
99
- ${chalk.yellow('Description:')}
100
- Push local markdown files to Confluence.
101
-
102
- With a file argument: pushes that single file.
103
- Without arguments: scans for changed files and prompts y/n for each.
104
-
105
- ${chalk.yellow('Arguments:')}
106
- file Path to markdown file (optional)
107
-
108
- ${chalk.yellow('Options:')}
109
- --force Ignore version conflicts and overwrite
110
- --dry-run Preview changes without pushing
111
- --help Show this help message
112
-
113
- ${chalk.yellow('Examples:')}
114
- cn push Scan and prompt for all changed files
115
- cn push --dry-run Preview what would be pushed
116
- cn push ./docs/page.md Push single page
117
- cn push ./docs/page.md --force Force push (ignore version conflict)
118
-
119
- ${chalk.yellow('Notes:')}
120
- - New files (no page_id) will be created on Confluence
121
- - Modified files are detected by comparing file mtime vs synced_at
122
- - Only basic markdown elements are fully supported
123
- - Files are automatically renamed to match page titles (except index.md/README.md)
124
- `);
125
- }
126
-
127
92
  export function showStatusHelp(): void {
128
93
  console.log(`
129
94
  ${chalk.bold('cn status - Check connection and sync status')}
@@ -316,6 +281,35 @@ ${chalk.yellow('Options:')}
316
281
  `);
317
282
  }
318
283
 
284
+ export function showFolderHelp(): void {
285
+ console.log(`
286
+ ${chalk.bold('cn folder - Manage Confluence folders')}
287
+
288
+ ${chalk.yellow('Usage:')}
289
+ cn folder <subcommand> [options]
290
+
291
+ ${chalk.yellow('Subcommands:')}
292
+ create <title> Create a new folder
293
+ list List folders in a space
294
+ delete <id> Delete a folder
295
+ move <id> <parentId> Move a folder to a new parent
296
+
297
+ ${chalk.yellow('Options:')}
298
+ --space <key> Space key (required for create/list if not in cloned dir)
299
+ --parent <id> Parent folder ID (for create)
300
+ --force Skip confirmation prompt (for delete)
301
+ --xml Output in XML format (for list)
302
+ --help Show this help message
303
+
304
+ ${chalk.yellow('Examples:')}
305
+ cn folder create "My Folder" --space DOCS
306
+ cn folder create "Nested" --space DOCS --parent 123456
307
+ cn folder list --space DOCS
308
+ cn folder delete 123456
309
+ cn folder move 123456 789012
310
+ `);
311
+ }
312
+
319
313
  export function showMoveHelp(): void {
320
314
  console.log(`
321
315
  ${chalk.bold('cn move - Move a page to a new parent')}
@@ -386,7 +380,7 @@ ${chalk.yellow('Commands:')}
386
380
  cn setup Configure Confluence credentials
387
381
  cn clone Clone a space to a new folder
388
382
  cn pull Pull space to local folder
389
- cn push Push local file to Confluence
383
+ cn folder Manage Confluence folders
390
384
  cn status Check connection and sync status
391
385
  cn tree Display page hierarchy
392
386
  cn open Open page in browser
package/src/cli/index.ts CHANGED
@@ -16,8 +16,8 @@ import {
16
16
  showLabelsHelp,
17
17
  showMoveHelp,
18
18
  showOpenHelp,
19
+ showFolderHelp,
19
20
  showPullHelp,
20
- showPushHelp,
21
21
  showSearchHelp,
22
22
  showSetupHelp,
23
23
  showSpacesHelp,
@@ -34,8 +34,8 @@ import { infoCommand } from './commands/info.js';
34
34
  import { labelsCommand } from './commands/labels.js';
35
35
  import { moveCommand } from './commands/move.js';
36
36
  import { openCommand } from './commands/open.js';
37
+ import { folderCommand } from './commands/folder.js';
37
38
  import { pullCommand } from './commands/pull.js';
38
- import { pushCommand } from './commands/push.js';
39
39
  import { searchCommand } from './commands/search.js';
40
40
  import { setup } from './commands/setup.js';
41
41
  import { spacesCommand } from './commands/spaces.js';
@@ -128,20 +128,18 @@ async function main(): Promise<void> {
128
128
  break;
129
129
  }
130
130
 
131
- case 'push': {
132
- if (args.includes('--help')) {
133
- showPushHelp();
131
+ case 'folder': {
132
+ if (args.includes('--help') && !subArgs.find((a) => !a.startsWith('--'))) {
133
+ showFolderHelp();
134
134
  process.exit(EXIT_CODES.SUCCESS);
135
135
  }
136
-
137
- // File is optional - if not provided, scan for changes
138
- const file = subArgs.find((arg) => !arg.startsWith('--'));
139
-
140
- await pushCommand({
141
- file,
142
- force: args.includes('--force'),
143
- dryRun: args.includes('--dry-run'),
144
- });
136
+ const folderSubcommand = subArgs[0];
137
+ if (!folderSubcommand || folderSubcommand.startsWith('--')) {
138
+ showFolderHelp();
139
+ process.exit(EXIT_CODES.INVALID_ARGUMENTS);
140
+ }
141
+ const folderSubArgs = subArgs.slice(1);
142
+ await folderCommand(folderSubcommand, folderSubArgs, args);
145
143
  break;
146
144
  }
147
145
 
@@ -12,6 +12,7 @@ import {
12
12
  } from '../errors.js';
13
13
  import {
14
14
  createFolderEffect as createFolderEffectFn,
15
+ deleteFolderEffect as deleteFolderEffectFn,
15
16
  findFolderByTitle as findFolderByTitleFn,
16
17
  getFolderEffect as getFolderEffectFn,
17
18
  movePageEffect as movePageEffectFn,
@@ -479,6 +480,20 @@ export class ConfluenceClient {
479
480
  return Effect.runPromise(this.createFolderEffect(request));
480
481
  }
481
482
 
483
+ /**
484
+ * Delete a folder by ID (Effect version)
485
+ */
486
+ deleteFolderEffect(
487
+ folderId: string,
488
+ ): Effect.Effect<void, ApiError | AuthError | NetworkError | RateLimitError | FolderNotFoundError> {
489
+ return deleteFolderEffectFn(this.baseUrl, this.authHeader, folderId);
490
+ }
491
+
492
+ /** Delete a folder by ID (async version) */
493
+ async deleteFolder(folderId: string): Promise<void> {
494
+ return Effect.runPromise(this.deleteFolderEffect(folderId));
495
+ }
496
+
482
497
  /**
483
498
  * Find a folder by title in a space
484
499
  * Uses v1 CQL search API to find folders by title
@@ -514,13 +529,14 @@ export class ConfluenceClient {
514
529
  searchEffect(
515
530
  cql: string,
516
531
  limit = 10,
532
+ start = 0,
517
533
  ): Effect.Effect<SearchResponse, ApiError | AuthError | NetworkError | RateLimitError> {
518
- return searchEffectFn(this.baseUrl, this.authHeader, cql, limit);
534
+ return searchEffectFn(this.baseUrl, this.authHeader, cql, limit, start);
519
535
  }
520
536
 
521
537
  /** Search pages using CQL (async version) */
522
- async search(cql: string, limit = 10): Promise<SearchResponse> {
523
- return Effect.runPromise(this.searchEffect(cql, limit));
538
+ async search(cql: string, limit = 10, start = 0): Promise<SearchResponse> {
539
+ return Effect.runPromise(this.searchEffect(cql, limit, start));
524
540
  }
525
541
 
526
542
  // ================== Comments API ==================
@@ -118,6 +118,47 @@ export function createFolderEffect(
118
118
  );
119
119
  }
120
120
 
121
+ /**
122
+ * Delete a folder by ID (Effect version)
123
+ */
124
+ export function deleteFolderEffect(
125
+ baseUrl: string,
126
+ authHeader: string,
127
+ folderId: string,
128
+ ): Effect.Effect<void, ApiError | AuthError | NetworkError | RateLimitError | FolderNotFoundError> {
129
+ const makeRequest = Effect.tryPromise({
130
+ try: async () => {
131
+ const url = `${baseUrl}/wiki/api/v2/folders/${folderId}`;
132
+ const response = await fetch(url, {
133
+ method: 'DELETE',
134
+ headers: { Authorization: authHeader, Accept: 'application/json' },
135
+ });
136
+
137
+ if (response.status === 404) throw new FolderNotFoundError(folderId);
138
+ if (response.status === 401) throw new AuthError('Invalid credentials', 401);
139
+ if (response.status === 403) throw new AuthError('Access denied', 403);
140
+ if (response.status === 429) {
141
+ const retryAfter = response.headers.get('Retry-After');
142
+ throw new RateLimitError('Rate limited', retryAfter ? Number.parseInt(retryAfter, 10) : undefined);
143
+ }
144
+ if (!response.ok) throw new ApiError(`API error: ${response.status}`, response.status);
145
+ },
146
+ catch: (error) => {
147
+ if (
148
+ error instanceof FolderNotFoundError ||
149
+ error instanceof AuthError ||
150
+ error instanceof ApiError ||
151
+ error instanceof RateLimitError
152
+ ) {
153
+ return error;
154
+ }
155
+ return new NetworkError(`Network error: ${error}`);
156
+ },
157
+ });
158
+
159
+ return pipe(makeRequest, Effect.retry(rateLimitRetrySchedule));
160
+ }
161
+
121
162
  /**
122
163
  * Move a page to a new parent (Effect version)
123
164
  * Uses v1 API: PUT /wiki/rest/api/content/{id}/move/{position}/{targetId}
@@ -21,8 +21,9 @@ export function searchEffect(
21
21
  authHeader: string,
22
22
  cql: string,
23
23
  limit = 10,
24
+ start = 0,
24
25
  ): Effect.Effect<SearchResponse, ApiError | AuthError | NetworkError | RateLimitError> {
25
- const url = `${baseUrl}/wiki/rest/api/search?cql=${encodeURIComponent(cql)}&limit=${limit}`;
26
+ const url = `${baseUrl}/wiki/rest/api/search?cql=${encodeURIComponent(cql)}&limit=${limit}&start=${start}`;
26
27
 
27
28
  const makeRequest = Effect.tryPromise({
28
29
  try: async () => {
@@ -0,0 +1,182 @@
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 { createValidFolder, createValidSpace } from './msw-schema-validation.js';
6
+
7
+ const testConfig = {
8
+ confluenceUrl: 'https://test.atlassian.net',
9
+ email: 'test@example.com',
10
+ apiToken: 'test-token',
11
+ };
12
+
13
+ describe('cn folder - API layer', () => {
14
+ describe('createFolder', () => {
15
+ test('creates a folder with spaceId resolved from getSpaceByKey', async () => {
16
+ let capturedBody: unknown;
17
+
18
+ server.use(
19
+ http.get('*/wiki/api/v2/spaces', ({ request }) => {
20
+ const url = new URL(request.url);
21
+ const keys = url.searchParams.get('keys');
22
+ return HttpResponse.json({ results: [createValidSpace({ id: 'space-456', key: keys ?? 'DOCS' })] });
23
+ }),
24
+ http.post('*/wiki/api/v2/folders', async ({ request }) => {
25
+ capturedBody = await request.json();
26
+ return HttpResponse.json(createValidFolder({ id: 'folder-new', title: 'My Folder' }));
27
+ }),
28
+ );
29
+
30
+ const client = new ConfluenceClient(testConfig);
31
+ const space = await client.getSpaceByKey('DOCS');
32
+ const folder = await client.createFolder({ spaceId: space.id, title: 'My Folder' });
33
+
34
+ expect(space.id).toBe('space-456');
35
+ expect(folder.id).toBe('folder-new');
36
+ expect(folder.title).toBe('My Folder');
37
+ expect((capturedBody as { spaceId: string }).spaceId).toBe('space-456');
38
+ });
39
+
40
+ test('creates a folder with a parentId', async () => {
41
+ let capturedBody: unknown;
42
+
43
+ server.use(
44
+ http.post('*/wiki/api/v2/folders', async ({ request }) => {
45
+ capturedBody = await request.json();
46
+ return HttpResponse.json(
47
+ createValidFolder({ id: 'folder-child', title: 'Child Folder', parentId: 'folder-parent' }),
48
+ );
49
+ }),
50
+ );
51
+
52
+ const client = new ConfluenceClient(testConfig);
53
+ await client.createFolder({ spaceId: 'space-123', title: 'Child Folder', parentId: 'folder-parent' });
54
+
55
+ expect((capturedBody as { parentId: string }).parentId).toBe('folder-parent');
56
+ });
57
+ });
58
+
59
+ describe('deleteFolder', () => {
60
+ test('deletes a folder by ID', async () => {
61
+ let deletedId = '';
62
+
63
+ server.use(
64
+ http.delete('*/wiki/api/v2/folders/:folderId', ({ params }) => {
65
+ deletedId = params.folderId as string;
66
+ return new HttpResponse(null, { status: 204 });
67
+ }),
68
+ );
69
+
70
+ const client = new ConfluenceClient(testConfig);
71
+ await client.deleteFolder('folder-123');
72
+
73
+ expect(deletedId).toBe('folder-123');
74
+ });
75
+
76
+ test('throws FolderNotFoundError for 404', async () => {
77
+ server.use(
78
+ http.delete('*/wiki/api/v2/folders/:folderId', () => {
79
+ return HttpResponse.json({ message: 'Not found' }, { status: 404 });
80
+ }),
81
+ );
82
+
83
+ const client = new ConfluenceClient(testConfig);
84
+ await expect(client.deleteFolder('nonexistent')).rejects.toThrow();
85
+ });
86
+ });
87
+
88
+ describe('folder list pagination', () => {
89
+ test('fetches a single page when total fits in one request', async () => {
90
+ server.use(
91
+ http.get('*/wiki/rest/api/search', ({ request }) => {
92
+ const url = new URL(request.url);
93
+ const start = Number(url.searchParams.get('start') ?? 0);
94
+ if (start === 0) {
95
+ return HttpResponse.json({
96
+ results: [
97
+ { content: { id: 'f1', type: 'folder', title: 'Folder 1' } },
98
+ { content: { id: 'f2', type: 'folder', title: 'Folder 2' } },
99
+ ],
100
+ totalSize: 2,
101
+ });
102
+ }
103
+ return HttpResponse.json({ results: [], totalSize: 2 });
104
+ }),
105
+ );
106
+
107
+ const client = new ConfluenceClient(testConfig);
108
+ const page1 = await client.search('type=folder AND space="TEST"', 100, 0);
109
+ expect(page1.results).toHaveLength(2);
110
+ expect(page1.totalSize).toBe(2);
111
+ });
112
+
113
+ test('paginates when totalSize exceeds page size', async () => {
114
+ const allFolders = Array.from({ length: 150 }, (_, i) => ({
115
+ content: { id: `f${i}`, type: 'folder', title: `Folder ${i}` },
116
+ }));
117
+
118
+ server.use(
119
+ http.get('*/wiki/rest/api/search', ({ request }) => {
120
+ const url = new URL(request.url);
121
+ const start = Number(url.searchParams.get('start') ?? 0);
122
+ const limit = Number(url.searchParams.get('limit') ?? 10);
123
+ const slice = allFolders.slice(start, start + limit);
124
+ return HttpResponse.json({ results: slice, totalSize: 150 });
125
+ }),
126
+ );
127
+
128
+ const client = new ConfluenceClient(testConfig);
129
+ const collected = [];
130
+ let start = 0;
131
+ const PAGE_SIZE = 100;
132
+
133
+ const first = await client.search('type=folder AND space="TEST"', PAGE_SIZE, start);
134
+ collected.push(...first.results);
135
+ const total = first.totalSize ?? first.results.length;
136
+
137
+ while (collected.length < total) {
138
+ start += PAGE_SIZE;
139
+ const page = await client.search('type=folder AND space="TEST"', PAGE_SIZE, start);
140
+ if (page.results.length === 0) break;
141
+ collected.push(...page.results);
142
+ }
143
+
144
+ expect(collected).toHaveLength(150);
145
+ });
146
+ });
147
+
148
+ describe('getPositionals (arg parsing)', () => {
149
+ // Test the arg parsing logic directly via the folderCommand behavior
150
+ test('search passes start param in URL', async () => {
151
+ const capturedUrls: string[] = [];
152
+
153
+ server.use(
154
+ http.get('*/wiki/rest/api/search', ({ request }) => {
155
+ capturedUrls.push(request.url);
156
+ return HttpResponse.json({ results: [], totalSize: 0 });
157
+ }),
158
+ );
159
+
160
+ const client = new ConfluenceClient(testConfig);
161
+ await client.search('type=folder', 100, 42);
162
+
163
+ expect(capturedUrls[0]).toContain('start=42');
164
+ });
165
+
166
+ test('search passes limit param in URL', async () => {
167
+ const capturedUrls: string[] = [];
168
+
169
+ server.use(
170
+ http.get('*/wiki/rest/api/search', ({ request }) => {
171
+ capturedUrls.push(request.url);
172
+ return HttpResponse.json({ results: [], totalSize: 0 });
173
+ }),
174
+ );
175
+
176
+ const client = new ConfluenceClient(testConfig);
177
+ await client.search('type=folder', 50, 0);
178
+
179
+ expect(capturedUrls[0]).toContain('limit=50');
180
+ });
181
+ });
182
+ });