confluence-cli 1.15.1 → 1.17.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/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [1.17.0](https://github.com/pchuri/confluence-cli/compare/v1.16.0...v1.17.0) (2026-02-13)
2
+
3
+
4
+ ### Features
5
+
6
+ * add attachment upload and delete commands ([#36](https://github.com/pchuri/confluence-cli/issues/36)) ([ed62bb4](https://github.com/pchuri/confluence-cli/commit/ed62bb45468566c128f066016615048e31ed1775))
7
+
8
+ # [1.16.0](https://github.com/pchuri/confluence-cli/compare/v1.15.1...v1.16.0) (2026-02-13)
9
+
10
+
11
+ ### Features
12
+
13
+ * Add move command to relocate pages ([#32](https://github.com/pchuri/confluence-cli/issues/32)) ([a37f9b8](https://github.com/pchuri/confluence-cli/commit/a37f9b83174ef08a6517fe279dcce1b39fc1fb1a))
14
+
1
15
  ## [1.15.1](https://github.com/pchuri/confluence-cli/compare/v1.15.0...v1.15.1) (2026-02-12)
2
16
 
3
17
 
package/README.md CHANGED
@@ -11,7 +11,7 @@ A powerful command-line interface for Atlassian Confluence that allows you to re
11
11
  - ✏️ **Create pages** - Create new pages with support for Markdown, HTML, or Storage format
12
12
  - 📝 **Update pages** - Update existing page content and titles
13
13
  - 🗑️ **Delete pages** - Delete (or move to trash) pages by ID or URL
14
- - 📎 **Attachments** - List or download page attachments
14
+ - 📎 **Attachments** - List, download, upload, or delete page attachments
15
15
  - 💬 **Comments** - List, create, and delete page comments (footer or inline)
16
16
  - 📦 **Export** - Save a page and its attachments to a local folder
17
17
  - 🛠️ **Edit workflow** - Export page content for editing and re-import
@@ -160,6 +160,27 @@ confluence attachments 123456789 --pattern "*.png" --limit 5
160
160
  confluence attachments 123456789 --pattern "*.png" --download --dest ./downloads
161
161
  ```
162
162
 
163
+ ### Upload Attachments
164
+ ```bash
165
+ # Upload a single attachment
166
+ confluence attachment-upload 123456789 --file ./report.pdf
167
+
168
+ # Upload multiple files with a comment
169
+ confluence attachment-upload 123456789 --file ./a.pdf --file ./b.png --comment "v2"
170
+
171
+ # Replace an existing attachment by filename
172
+ confluence attachment-upload 123456789 --file ./diagram.png --replace
173
+ ```
174
+
175
+ ### Delete Attachments
176
+ ```bash
177
+ # Delete an attachment by ID
178
+ confluence attachment-delete 123456789 998877
179
+
180
+ # Skip confirmation
181
+ confluence attachment-delete 123456789 998877 --yes
182
+ ```
183
+
163
184
  ### Comments
164
185
  ```bash
165
186
  # List all comments (footer + inline)
@@ -296,6 +317,22 @@ confluence update 123456789 --file ./updated-content.md --format markdown
296
317
  confluence update 123456789 --title "New Title" --content "And new content"
297
318
  ```
298
319
 
320
+ ### Move a Page to New Parent
321
+
322
+ ```bash
323
+ # Move page by ID
324
+ confluence move 123456789 987654321
325
+
326
+ # Move page and rename it
327
+ confluence move 123456789 987654321 --title "Relocated Page"
328
+
329
+ # Move using URLs (for convenience)
330
+ confluence move "https://domain.atlassian.net/wiki/viewpage.action?pageId=123456789" \
331
+ "https://domain.atlassian.net/wiki/viewpage.action?pageId=987654321"
332
+ ```
333
+
334
+ **Note:** Pages can only be moved within the same Confluence space. Cross-space moves are not supported.
335
+
299
336
  ### Delete a Page
300
337
  ```bash
301
338
  # Delete by page ID (prompts for confirmation)
@@ -341,9 +378,12 @@ confluence stats
341
378
  | `create-child <title> <parentId>` | Create a child page | `--content <string>`, `--file <path>`, `--format <storage\|html\|markdown>` |
342
379
  | `copy-tree <sourcePageId> <targetParentId> [newTitle]` | Copy page tree with all children | `--max-depth <number>`, `--exclude <patterns>`, `--delay-ms <ms>`, `--copy-suffix <text>`, `--dry-run`, `--fail-on-error`, `--quiet` |
343
380
  | `update <pageId>` | Update a page's title or content | `--title <string>`, `--content <string>`, `--file <path>`, `--format <storage\|html\|markdown>` |
381
+ | `move <pageId_or_url> <newParentId_or_url>` | Move a page to a new parent location | `--title <string>` |
344
382
  | `delete <pageId_or_url>` | Delete a page by ID or URL | `--yes` |
345
383
  | `edit <pageId>` | Export page content for editing | `--output <file>` |
346
384
  | `attachments <pageId_or_url>` | List or download attachments for a page | `--limit <number>`, `--pattern <glob>`, `--download`, `--dest <directory>` |
385
+ | `attachment-upload <pageId_or_url>` | Upload attachments to a page | `--file <path>`, `--comment <text>`, `--replace`, `--minor-edit` |
386
+ | `attachment-delete <pageId_or_url> <attachmentId>` | Delete an attachment from a page | `--yes` |
347
387
  | `comments <pageId_or_url>` | List comments for a page | `--format <text\|markdown\|json>`, `--limit <number>`, `--start <number>`, `--location <inline\|footer\|resolved>`, `--depth <root\|all>`, `--all` |
348
388
  | `comment <pageId_or_url>` | Create a comment on a page | `--content <string>`, `--file <path>`, `--format <storage\|html\|markdown>`, `--parent <commentId>`, `--location <inline\|footer>`, `--inline-selection <text>`, `--inline-original-selection <text>`, `--inline-marker-ref <ref>`, `--inline-properties <json>` |
349
389
  | `comment-delete <commentId>` | Delete a comment by ID | `--yes` |
@@ -371,6 +411,16 @@ confluence search "API documentation" --limit 3
371
411
  # List all spaces
372
412
  confluence spaces
373
413
 
414
+ # Move a page to a new parent
415
+ confluence move 123456789 987654321
416
+
417
+ # Move and rename
418
+ confluence move 123456789 987654321 --title "New Title"
419
+
420
+ # Upload and delete an attachment
421
+ confluence attachment-upload 123456789 --file ./report.pdf
422
+ confluence attachment-delete 123456789 998877 --yes
423
+
374
424
  # View usage statistics
375
425
  confluence stats
376
426
  ```
@@ -414,7 +464,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
414
464
  - [ ] Bulk operations
415
465
  - [ ] Export pages to different formats
416
466
  - [ ] Integration with other Atlassian tools (Jira)
417
- - [ ] Page attachments management
467
+ - [x] Page attachments management (list, download, upload, delete)
418
468
  - [x] Comments
419
469
  - [ ] Reviews
420
470
 
package/bin/confluence.js CHANGED
@@ -277,6 +277,33 @@ program
277
277
  }
278
278
  });
279
279
 
280
+ // Move command
281
+ program
282
+ .command('move <pageId_or_url> <newParentId_or_url>')
283
+ .description('Move a page to a new parent location (within same space)')
284
+ .option('-t, --title <title>', 'New page title (optional)')
285
+ .action(async (pageId, newParentId, options) => {
286
+ const analytics = new Analytics();
287
+ try {
288
+ const config = getConfig();
289
+ const client = new ConfluenceClient(config);
290
+ const result = await client.movePage(pageId, newParentId, options.title);
291
+
292
+ console.log(chalk.green('✅ Page moved successfully!'));
293
+ console.log(`Title: ${chalk.blue(result.title)}`);
294
+ console.log(`ID: ${chalk.blue(result.id)}`);
295
+ console.log(`New Parent: ${chalk.blue(newParentId)}`);
296
+ console.log(`Version: ${chalk.blue(result.version.number)}`);
297
+ console.log(`URL: ${chalk.gray(`https://${config.domain}/wiki${result._links.webui}`)}`);
298
+
299
+ analytics.track('move', true);
300
+ } catch (error) {
301
+ analytics.track('move', false);
302
+ console.error(chalk.red('Error:'), error.message);
303
+ process.exit(1);
304
+ }
305
+ });
306
+
280
307
  // Delete command
281
308
  program
282
309
  .command('delete <pageIdOrUrl>')
@@ -468,6 +495,108 @@ program
468
495
  }
469
496
  });
470
497
 
498
+ // Attachment upload command
499
+ program
500
+ .command('attachment-upload <pageId>')
501
+ .description('Upload one or more attachments to a page')
502
+ .option('-f, --file <file>', 'File to upload (repeatable)', (value, previous) => {
503
+ const files = Array.isArray(previous) ? previous : [];
504
+ files.push(value);
505
+ return files;
506
+ }, [])
507
+ .option('--comment <comment>', 'Comment for the attachment(s)')
508
+ .option('--replace', 'Replace an existing attachment with the same filename')
509
+ .option('--minor-edit', 'Mark the upload as a minor edit')
510
+ .action(async (pageId, options) => {
511
+ const analytics = new Analytics();
512
+ try {
513
+ const files = Array.isArray(options.file) ? options.file.filter(Boolean) : [];
514
+ if (files.length === 0) {
515
+ throw new Error('At least one --file option is required.');
516
+ }
517
+
518
+ const fs = require('fs');
519
+ const path = require('path');
520
+ const config = getConfig();
521
+ const client = new ConfluenceClient(config);
522
+
523
+ const resolvedFiles = files.map((filePath) => ({
524
+ original: filePath,
525
+ resolved: path.resolve(filePath)
526
+ }));
527
+
528
+ resolvedFiles.forEach((file) => {
529
+ if (!fs.existsSync(file.resolved)) {
530
+ throw new Error(`File not found: ${file.original}`);
531
+ }
532
+ });
533
+
534
+ let uploaded = 0;
535
+ for (const file of resolvedFiles) {
536
+ const result = await client.uploadAttachment(pageId, file.resolved, {
537
+ comment: options.comment,
538
+ replace: options.replace,
539
+ minorEdit: options.minorEdit === true ? true : undefined
540
+ });
541
+ const attachment = result.results[0];
542
+ if (attachment) {
543
+ console.log(`⬆️ ${chalk.green(attachment.title)} (ID: ${attachment.id}, Version: ${attachment.version})`);
544
+ } else {
545
+ console.log(`⬆️ ${chalk.green(path.basename(file.resolved))}`);
546
+ }
547
+ uploaded += 1;
548
+ }
549
+
550
+ console.log(chalk.green(`Uploaded ${uploaded} attachment${uploaded === 1 ? '' : 's'} to page ${pageId}`));
551
+ analytics.track('attachment_upload', true);
552
+ } catch (error) {
553
+ analytics.track('attachment_upload', false);
554
+ console.error(chalk.red('Error:'), error.message);
555
+ process.exit(1);
556
+ }
557
+ });
558
+
559
+ // Attachment delete command
560
+ program
561
+ .command('attachment-delete <pageId> <attachmentId>')
562
+ .description('Delete an attachment by ID from a page')
563
+ .option('-y, --yes', 'Skip confirmation prompt')
564
+ .action(async (pageId, attachmentId, options) => {
565
+ const analytics = new Analytics();
566
+ try {
567
+ const config = getConfig();
568
+ const client = new ConfluenceClient(config);
569
+
570
+ if (!options.yes) {
571
+ const { confirmed } = await inquirer.prompt([
572
+ {
573
+ type: 'confirm',
574
+ name: 'confirmed',
575
+ default: false,
576
+ message: `Delete attachment ${attachmentId} from page ${pageId}?`
577
+ }
578
+ ]);
579
+
580
+ if (!confirmed) {
581
+ console.log(chalk.yellow('Cancelled.'));
582
+ analytics.track('attachment_delete_cancel', true);
583
+ return;
584
+ }
585
+ }
586
+
587
+ const result = await client.deleteAttachment(pageId, attachmentId);
588
+
589
+ console.log(chalk.green('✅ Attachment deleted successfully!'));
590
+ console.log(`ID: ${chalk.blue(result.id)}`);
591
+ console.log(`Page ID: ${chalk.blue(result.pageId)}`);
592
+ analytics.track('attachment_delete', true);
593
+ } catch (error) {
594
+ analytics.track('attachment_delete', false);
595
+ console.error(chalk.red('Error:'), error.message);
596
+ process.exit(1);
597
+ }
598
+ });
599
+
471
600
  // Comments command
472
601
  program
473
602
  .command('comments <pageId>')
@@ -1,4 +1,7 @@
1
1
  const axios = require('axios');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const FormData = require('form-data');
2
5
  const { convert } = require('html-to-text');
3
6
  const MarkdownIt = require('markdown-it');
4
7
 
@@ -808,6 +811,67 @@ class ConfluenceClient {
808
811
  return downloadResponse.data;
809
812
  }
810
813
 
814
+ /**
815
+ * Upload an attachment to a page
816
+ */
817
+ async uploadAttachment(pageIdOrUrl, filePath, options = {}) {
818
+ if (!filePath || typeof filePath !== 'string') {
819
+ throw new Error('File path is required for attachment upload.');
820
+ }
821
+
822
+ const resolvedPath = path.resolve(filePath);
823
+ if (!fs.existsSync(resolvedPath)) {
824
+ throw new Error(`File not found: ${filePath}`);
825
+ }
826
+
827
+ const pageId = await this.extractPageId(pageIdOrUrl);
828
+ const form = new FormData();
829
+ form.append('file', fs.createReadStream(resolvedPath), { filename: path.basename(resolvedPath) });
830
+
831
+ if (options.comment !== undefined && options.comment !== null) {
832
+ form.append('comment', options.comment, { contentType: 'text/plain; charset=utf-8' });
833
+ }
834
+
835
+ if (typeof options.minorEdit === 'boolean') {
836
+ form.append('minorEdit', options.minorEdit ? 'true' : 'false');
837
+ }
838
+
839
+ const method = options.replace ? 'put' : 'post';
840
+ const response = await this.client.request({
841
+ url: `/content/${pageId}/child/attachment`,
842
+ method,
843
+ headers: {
844
+ ...form.getHeaders(),
845
+ 'X-Atlassian-Token': 'nocheck'
846
+ },
847
+ data: form,
848
+ maxBodyLength: Infinity,
849
+ maxContentLength: Infinity
850
+ });
851
+
852
+ const results = Array.isArray(response.data?.results)
853
+ ? response.data.results.map((item) => this.normalizeAttachment(item))
854
+ : [];
855
+
856
+ return {
857
+ results,
858
+ raw: response.data
859
+ };
860
+ }
861
+
862
+ /**
863
+ * Delete an attachment by ID
864
+ */
865
+ async deleteAttachment(pageIdOrUrl, attachmentId) {
866
+ if (!attachmentId) {
867
+ throw new Error('Attachment ID is required.');
868
+ }
869
+
870
+ const pageId = await this.extractPageId(pageIdOrUrl);
871
+ await this.client.delete(`/content/${pageId}/child/attachment/${attachmentId}`);
872
+ return { id: String(attachmentId), pageId: String(pageId) };
873
+ }
874
+
811
875
  /**
812
876
  * Convert markdown to Confluence storage format
813
877
  */
@@ -1449,6 +1513,55 @@ class ConfluenceClient {
1449
1513
  return response.data;
1450
1514
  }
1451
1515
 
1516
+ /**
1517
+ * Move a page to a new parent location
1518
+ */
1519
+ async movePage(pageIdOrUrl, newParentIdOrUrl, newTitle = null) {
1520
+ // Resolve both IDs from URLs if needed
1521
+ const pageId = await this.extractPageId(pageIdOrUrl);
1522
+ const newParentId = await this.extractPageId(newParentIdOrUrl);
1523
+
1524
+ // Fetch current page
1525
+ const response = await this.client.get(`/content/${pageId}`, {
1526
+ params: { expand: 'body.storage,version,space' }
1527
+ });
1528
+ const { version, title, body, space } = response.data;
1529
+
1530
+ // Fetch new parent to get its space (for validation)
1531
+ const parentResponse = await this.client.get(`/content/${newParentId}`, {
1532
+ params: { expand: 'space' }
1533
+ });
1534
+ const parentSpace = parentResponse.data.space;
1535
+
1536
+ // Validate same space
1537
+ if (parentSpace.key !== space.key) {
1538
+ throw new Error(
1539
+ `Cannot move page across spaces. Page is in space "${space.key}" ` +
1540
+ `but new parent is in space "${parentSpace.key}". ` +
1541
+ 'Pages can only be moved within the same space.'
1542
+ );
1543
+ }
1544
+
1545
+ // Proceed with move
1546
+ const pageData = {
1547
+ id: pageId,
1548
+ type: 'page',
1549
+ title: newTitle || title,
1550
+ space: { key: space.key },
1551
+ body: {
1552
+ storage: {
1553
+ value: body.storage.value,
1554
+ representation: 'storage'
1555
+ }
1556
+ },
1557
+ version: { number: version.number + 1 },
1558
+ ancestors: [{ id: newParentId }]
1559
+ };
1560
+
1561
+ const updateResponse = await this.client.put(`/content/${pageId}`, pageData);
1562
+ return updateResponse.data;
1563
+ }
1564
+
1452
1565
  /**
1453
1566
  * Get page content for editing
1454
1567
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-cli",
3
- "version": "1.15.1",
3
+ "version": "1.17.0",
4
4
  "description": "A command-line interface for Atlassian Confluence with page creation and editing capabilities",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -26,6 +26,7 @@
26
26
  "axios": "^1.12.0",
27
27
  "chalk": "^4.1.2",
28
28
  "commander": "^11.1.0",
29
+ "form-data": "^4.0.5",
29
30
  "html-to-text": "^9.0.5",
30
31
  "inquirer": "^8.2.6",
31
32
  "markdown-it": "^14.1.0",
@@ -1,6 +1,44 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const FormData = require('form-data');
1
5
  const ConfluenceClient = require('../lib/confluence-client');
2
6
  const MockAdapter = require('axios-mock-adapter');
3
7
 
8
+ const removeDirRecursive = (dir) => {
9
+ if (!dir) return;
10
+ try {
11
+ if (fs.rmSync) {
12
+ fs.rmSync(dir, { recursive: true, force: true });
13
+ return;
14
+ }
15
+ } catch (error) {
16
+ void error;
17
+ }
18
+
19
+ if (!fs.existsSync(dir)) return;
20
+
21
+ fs.readdirSync(dir).forEach((entry) => {
22
+ const entryPath = path.join(dir, entry);
23
+ const stats = fs.lstatSync(entryPath);
24
+ if (stats.isDirectory()) {
25
+ removeDirRecursive(entryPath);
26
+ } else {
27
+ try {
28
+ fs.unlinkSync(entryPath);
29
+ } catch (error) {
30
+ void error;
31
+ }
32
+ }
33
+ });
34
+
35
+ try {
36
+ fs.rmdirSync(dir);
37
+ } catch (error) {
38
+ void error;
39
+ }
40
+ };
41
+
4
42
  describe('ConfluenceClient', () => {
5
43
  let client;
6
44
 
@@ -298,6 +336,160 @@ describe('ConfluenceClient', () => {
298
336
  });
299
337
  });
300
338
 
339
+ describe('movePage', () => {
340
+ test('should move a page by ID', async () => {
341
+ const mock = new MockAdapter(client.client);
342
+
343
+ mock.onGet('/content/123456789').reply(200, {
344
+ id: '123456789',
345
+ title: 'Original Title',
346
+ version: { number: 5 },
347
+ body: { storage: { value: '<p>Original content</p>' } },
348
+ space: { key: 'TEST' }
349
+ });
350
+
351
+ mock.onGet('/content/987654321').reply(200, {
352
+ id: '987654321',
353
+ space: { key: 'TEST' }
354
+ });
355
+
356
+ mock.onPut('/content/123456789').reply(200, {
357
+ id: '123456789',
358
+ title: 'Original Title',
359
+ version: { number: 6 },
360
+ ancestors: [{ id: '987654321' }]
361
+ });
362
+
363
+ const result = await client.movePage('123456789', '987654321');
364
+
365
+ expect(result.id).toBe('123456789');
366
+ expect(result.version.number).toBe(6);
367
+ expect(result.ancestors).toEqual([{ id: '987654321' }]);
368
+
369
+ mock.restore();
370
+ });
371
+
372
+ test('should move a page with new title', async () => {
373
+ const mock = new MockAdapter(client.client);
374
+
375
+ mock.onGet('/content/555666777').reply(200, {
376
+ id: '555666777',
377
+ title: 'Old Title',
378
+ version: { number: 2 },
379
+ body: { storage: { value: '<p>Page content</p>' } },
380
+ space: { key: 'DOCS' }
381
+ });
382
+
383
+ mock.onGet('/content/888999000').reply(200, {
384
+ id: '888999000',
385
+ space: { key: 'DOCS' }
386
+ });
387
+
388
+ mock.onPut('/content/555666777').reply(200, {
389
+ id: '555666777',
390
+ title: 'New Title',
391
+ version: { number: 3 },
392
+ ancestors: [{ id: '888999000' }]
393
+ });
394
+
395
+ const result = await client.movePage('555666777', '888999000', 'New Title');
396
+
397
+ expect(result.title).toBe('New Title');
398
+ expect(result.version.number).toBe(3);
399
+ expect(result.ancestors).toEqual([{ id: '888999000' }]);
400
+
401
+ mock.restore();
402
+ });
403
+
404
+ test('should move a page using URL for pageId', async () => {
405
+ const mock = new MockAdapter(client.client);
406
+ const pageUrl = 'https://test.atlassian.net/wiki/viewpage.action?pageId=111222333';
407
+
408
+ mock.onGet('/content/111222333').reply(200, {
409
+ id: '111222333',
410
+ title: 'Test Page',
411
+ version: { number: 1 },
412
+ body: { storage: { value: '<p>Content</p>' } },
413
+ space: { key: 'TEST' }
414
+ });
415
+
416
+ mock.onGet('/content/444555666').reply(200, {
417
+ id: '444555666',
418
+ space: { key: 'TEST' }
419
+ });
420
+
421
+ mock.onPut('/content/111222333').reply(200, {
422
+ id: '111222333',
423
+ title: 'Test Page',
424
+ version: { number: 2 },
425
+ ancestors: [{ id: '444555666' }]
426
+ });
427
+
428
+ const result = await client.movePage(pageUrl, '444555666');
429
+
430
+ expect(result.id).toBe('111222333');
431
+ expect(result.version.number).toBe(2);
432
+
433
+ mock.restore();
434
+ });
435
+
436
+ test('should move a page using URLs for both parameters', async () => {
437
+ const mock = new MockAdapter(client.client);
438
+ const pageUrl = 'https://test.atlassian.net/wiki/viewpage.action?pageId=777888999';
439
+ const parentUrl = 'https://test.atlassian.net/wiki/viewpage.action?pageId=111000111';
440
+
441
+ mock.onGet('/content/777888999').reply(200, {
442
+ id: '777888999',
443
+ title: 'Source Page',
444
+ version: { number: 3 },
445
+ body: { storage: { value: '<p>Page content</p>' } },
446
+ space: { key: 'DOCS' }
447
+ });
448
+
449
+ mock.onGet('/content/111000111').reply(200, {
450
+ id: '111000111',
451
+ space: { key: 'DOCS' }
452
+ });
453
+
454
+ mock.onPut('/content/777888999').reply(200, {
455
+ id: '777888999',
456
+ title: 'Source Page',
457
+ version: { number: 4 },
458
+ ancestors: [{ id: '111000111' }]
459
+ });
460
+
461
+ const result = await client.movePage(pageUrl, parentUrl);
462
+
463
+ expect(result.id).toBe('777888999');
464
+ expect(result.version.number).toBe(4);
465
+
466
+ mock.restore();
467
+ });
468
+
469
+ test('should throw error when moving page across spaces', async () => {
470
+ const mock = new MockAdapter(client.client);
471
+
472
+ mock.onGet('/content/123456789').reply(200, {
473
+ id: '123456789',
474
+ title: 'Page in Space A',
475
+ version: { number: 1 },
476
+ body: { storage: { value: '<p>Content</p>' } },
477
+ space: { key: 'SPACEA' }
478
+ });
479
+
480
+ mock.onGet('/content/987654321').reply(200, {
481
+ id: '987654321',
482
+ space: { key: 'SPACEB' }
483
+ });
484
+
485
+ await expect(
486
+ client.movePage('123456789', '987654321')
487
+ ).rejects.toThrow('Cannot move page across spaces');
488
+
489
+ mock.restore();
490
+ });
491
+ });
492
+
301
493
  describe('page tree operations', () => {
302
494
  test('should have required methods for tree operations', () => {
303
495
  expect(typeof client.getChildPages).toBe('function');
@@ -445,6 +637,8 @@ describe('ConfluenceClient', () => {
445
637
  expect(typeof client.listAttachments).toBe('function');
446
638
  expect(typeof client.getAllAttachments).toBe('function');
447
639
  expect(typeof client.downloadAttachment).toBe('function');
640
+ expect(typeof client.uploadAttachment).toBe('function');
641
+ expect(typeof client.deleteAttachment).toBe('function');
448
642
  });
449
643
 
450
644
  test('matchesPattern should respect glob patterns', () => {
@@ -460,5 +654,68 @@ describe('ConfluenceClient', () => {
460
654
  expect(client.parseNextStart('/rest/api/content/1/child/attachment?limit=50')).toBeNull();
461
655
  expect(client.parseNextStart(null)).toBeNull();
462
656
  });
657
+
658
+ test('uploadAttachment should send multipart request with Atlassian token header', async () => {
659
+ const mock = new MockAdapter(client.client);
660
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'confluence-cli-'));
661
+ const tempFile = path.join(tempDir, 'upload.txt');
662
+ fs.writeFileSync(tempFile, 'hello');
663
+
664
+ try {
665
+ mock.onPost('/content/123/child/attachment').reply((config) => {
666
+ expect(config.headers['X-Atlassian-Token']).toBe('nocheck');
667
+ const contentType = config.headers['content-type'] || config.headers['Content-Type'];
668
+ expect(contentType).toContain('multipart/form-data');
669
+ expect(config.data).toBeInstanceOf(FormData);
670
+ return [200, {
671
+ results: [{
672
+ id: '1',
673
+ title: 'upload.txt',
674
+ version: { number: 2 },
675
+ _links: { download: '/download' }
676
+ }]
677
+ }];
678
+ });
679
+
680
+ const response = await client.uploadAttachment('123', tempFile, { comment: 'note', minorEdit: true });
681
+ expect(response.results[0].title).toBe('upload.txt');
682
+ } finally {
683
+ mock.restore();
684
+ removeDirRecursive(tempDir);
685
+ }
686
+ });
687
+
688
+ test('uploadAttachment should use PUT when replace is true', async () => {
689
+ const mock = new MockAdapter(client.client);
690
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'confluence-cli-'));
691
+ const tempFile = path.join(tempDir, 'replace.txt');
692
+ fs.writeFileSync(tempFile, 'replace');
693
+
694
+ try {
695
+ mock.onPut('/content/456/child/attachment').reply(200, {
696
+ results: [{
697
+ id: '2',
698
+ title: 'replace.txt',
699
+ version: { number: 3 },
700
+ _links: { download: '/download' }
701
+ }]
702
+ });
703
+
704
+ const response = await client.uploadAttachment('456', tempFile, { replace: true });
705
+ expect(response.results[0].title).toBe('replace.txt');
706
+ } finally {
707
+ mock.restore();
708
+ removeDirRecursive(tempDir);
709
+ }
710
+ });
711
+
712
+ test('deleteAttachment should call delete endpoint', async () => {
713
+ const mock = new MockAdapter(client.client);
714
+ mock.onDelete('/content/123/child/attachment/999').reply(204);
715
+
716
+ await expect(client.deleteAttachment('123', '999')).resolves.toEqual({ id: '999', pageId: '123' });
717
+
718
+ mock.restore();
719
+ });
463
720
  });
464
721
  });