confluence-cli 1.16.0 → 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,10 @@
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
+
1
8
  # [1.16.0](https://github.com/pchuri/confluence-cli/compare/v1.15.1...v1.16.0) (2026-02-13)
2
9
 
3
10
 
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)
@@ -361,6 +382,8 @@ confluence stats
361
382
  | `delete <pageId_or_url>` | Delete a page by ID or URL | `--yes` |
362
383
  | `edit <pageId>` | Export page content for editing | `--output <file>` |
363
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` |
364
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` |
365
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>` |
366
389
  | `comment-delete <commentId>` | Delete a comment by ID | `--yes` |
@@ -394,6 +417,10 @@ confluence move 123456789 987654321
394
417
  # Move and rename
395
418
  confluence move 123456789 987654321 --title "New Title"
396
419
 
420
+ # Upload and delete an attachment
421
+ confluence attachment-upload 123456789 --file ./report.pdf
422
+ confluence attachment-delete 123456789 998877 --yes
423
+
397
424
  # View usage statistics
398
425
  confluence stats
399
426
  ```
@@ -437,7 +464,7 @@ This project is licensed under the MIT License - see the [LICENSE](LICENSE) file
437
464
  - [ ] Bulk operations
438
465
  - [ ] Export pages to different formats
439
466
  - [ ] Integration with other Atlassian tools (Jira)
440
- - [ ] Page attachments management
467
+ - [x] Page attachments management (list, download, upload, delete)
441
468
  - [x] Comments
442
469
  - [ ] Reviews
443
470
 
package/bin/confluence.js CHANGED
@@ -495,6 +495,108 @@ program
495
495
  }
496
496
  });
497
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
+
498
600
  // Comments command
499
601
  program
500
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
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-cli",
3
- "version": "1.16.0",
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
 
@@ -599,6 +637,8 @@ describe('ConfluenceClient', () => {
599
637
  expect(typeof client.listAttachments).toBe('function');
600
638
  expect(typeof client.getAllAttachments).toBe('function');
601
639
  expect(typeof client.downloadAttachment).toBe('function');
640
+ expect(typeof client.uploadAttachment).toBe('function');
641
+ expect(typeof client.deleteAttachment).toBe('function');
602
642
  });
603
643
 
604
644
  test('matchesPattern should respect glob patterns', () => {
@@ -614,5 +654,68 @@ describe('ConfluenceClient', () => {
614
654
  expect(client.parseNextStart('/rest/api/content/1/child/attachment?limit=50')).toBeNull();
615
655
  expect(client.parseNextStart(null)).toBeNull();
616
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
+ });
617
720
  });
618
721
  });