diff --git a/src/lib/http.ts b/src/lib/http.ts index 6f96376..7b173f6 100644 --- a/src/lib/http.ts +++ b/src/lib/http.ts @@ -427,11 +427,15 @@ export class HttpClient { return request(url, init, opts.timeoutMs ?? 30000); } - private confluenceHeaders(): Record { + private confluenceToken(): string { const { apiToken } = this.config.confluence; if (!apiToken) throw new PncliError('Confluence credentials not configured. Run: pncli config init'); + return apiToken; + } + + private confluenceHeaders(): Record { return { - 'Authorization': `Bearer ${apiToken}`, + 'Authorization': `Bearer ${this.confluenceToken()}`, 'Content-Type': 'application/json', 'Accept': 'application/json', 'Connection': 'close' @@ -912,6 +916,33 @@ export class HttpClient { return results; } + async confluenceUpload( + path: string, + formData: FormData, + opts: { timeoutMs?: number } = {} + ): Promise { + const baseUrl = this.config.confluence.baseUrl; + if (!baseUrl) throw new PncliError('Confluence baseUrl not configured. Run: pncli config init'); + + const url = buildUrl(baseUrl, path); + const headers: Record = { + 'Authorization': `Bearer ${this.confluenceToken()}`, + 'X-Atlassian-Token': 'no-check', + 'Accept': 'application/json', + 'Connection': 'close' + }; + + if (this.dryRun) { + const safeHeaders = { ...headers, Authorization: '[REDACTED]' }; + const msg = `DRY RUN: POST ${url}\nHeaders: ${JSON.stringify(safeHeaders, null, 2)}\nBody: \n`; + fs.writeSync(process.stderr.fd, msg); + process.exitCode = ExitCode.SUCCESS; + throw new PncliError('dry-run', 0); + } + + return request(url, { method: 'POST', headers, body: formData }, opts.timeoutMs ?? 60000); + } + async confluencePaginate( fetchPage: (start: number, limit: number) => Promise<{ results: T[]; start: number; limit: number; size: number; _links: { next?: string } }>, maxTotal?: number diff --git a/src/services/confluence/client.ts b/src/services/confluence/client.ts index 47f3e95..fd53403 100644 --- a/src/services/confluence/client.ts +++ b/src/services/confluence/client.ts @@ -1,10 +1,14 @@ +import { readFileSync } from 'fs'; +import { basename } from 'path'; import type { HttpClient } from '../../lib/http.js'; +import { guessMimeType } from '../../lib/mime.js'; import type { ConfluencePage, ConfluenceSpace, ConfluenceComment, ConfluenceLabel, ConfluenceAttachment, + ConfluencePageHistory, ConfluencePageResponse, ConfluenceSearchResult } from '../../types/confluence.js'; @@ -201,6 +205,28 @@ export class ConfluenceClient { }); } + async uploadAttachment(pageId: string, filePath: string, comment?: string): Promise { + const fileContent = readFileSync(filePath); + const fileName = basename(filePath); + const mimeType = guessMimeType(filePath); + const formData = new FormData(); + formData.append('file', new Blob([fileContent], { type: mimeType }), fileName); + if (comment) formData.append('comment', comment); + const result = await this.http.confluenceUpload>( + `${API}/content/${pageId}/child/attachment`, + formData + ); + return result.results; + } + + async deleteAttachment(attachmentId: string): Promise { + return this.http.confluence(`${API}/content/${attachmentId}`, { method: 'DELETE' }); + } + + async getPageHistory(id: string): Promise { + return this.http.confluence(`${API}/content/${id}/history`); + } + async convertToStorage(value: string, fromRepresentation = 'markdown'): Promise { const result = await this.http.confluence<{ value: string; representation: string }>( `${API}/contentbody/convert/storage`, diff --git a/src/services/confluence/commands.ts b/src/services/confluence/commands.ts index 7c4c801..d7069b5 100644 --- a/src/services/confluence/commands.ts +++ b/src/services/confluence/commands.ts @@ -1,3 +1,4 @@ +import { existsSync } from 'fs'; import { Command } from 'commander'; import { ConfluenceClient } from './client.js'; import { createHttpClient } from '../../lib/http.js'; @@ -322,4 +323,45 @@ export function registerConfluenceCommands(program: Command): void { success(data, 'confluence', 'list-attachments', start); } catch (err) { fail(err, 'confluence', 'list-attachments', start); } }); + + confluence.command('upload-attachment') + .description('Upload a file as an attachment to a Confluence page') + .requiredOption('--id ', 'Page ID') + .requiredOption('--file ', 'Path to the file to upload') + .option('--comment ', 'Optional comment to attach to the file version') + .action(async (opts: { id: string; file: string; comment?: string }) => { + const start = Date.now(); + try { + if (!existsSync(opts.file)) throw new PncliError(`File not found: ${opts.file}`, 1); + const client = getClient(program); + const data = await client.uploadAttachment(opts.id, opts.file, opts.comment); + success(data, 'confluence', 'upload-attachment', start); + } catch (err) { fail(err, 'confluence', 'upload-attachment', start); } + }); + + confluence.command('delete-attachment') + .description('Delete an attachment from Confluence by its content ID') + .requiredOption('--id ', 'Attachment content ID') + .action(async (opts: { id: string }) => { + const start = Date.now(); + try { + const client = getClient(program); + await client.deleteAttachment(opts.id); + success({ deleted: opts.id }, 'confluence', 'delete-attachment', start); + } catch (err) { fail(err, 'confluence', 'delete-attachment', start); } + }); + + // ── History ─────────────────────────────────────────────────────────────── + + confluence.command('get-page-history') + .description('Get the version history metadata for a Confluence page') + .requiredOption('--id ', 'Page ID') + .action(async (opts: { id: string }) => { + const start = Date.now(); + try { + const client = getClient(program); + const data = await client.getPageHistory(opts.id); + success(data, 'confluence', 'get-page-history', start); + } catch (err) { fail(err, 'confluence', 'get-page-history', start); } + }); } diff --git a/src/types/confluence.ts b/src/types/confluence.ts index a758766..b2aa496 100644 --- a/src/types/confluence.ts +++ b/src/types/confluence.ts @@ -74,6 +74,15 @@ export interface ConfluencePageResponse { _links: { next?: string; self: string }; } +export interface ConfluencePageHistory { + latest: boolean; + createdBy: ConfluenceUser; + createdDate: string; + lastUpdated: ConfluenceVersion; + previousVersion?: ConfluenceVersion; + nextVersion?: ConfluenceVersion; +} + export interface ConfluenceSearchResult { results: Array<{ content: ConfluencePage;