Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 33 additions & 2 deletions src/lib/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -427,11 +427,15 @@ export class HttpClient {
return request<T>(url, init, opts.timeoutMs ?? 30000);
}

private confluenceHeaders(): Record<string, string> {
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<string, string> {
return {
'Authorization': `Bearer ${apiToken}`,
'Authorization': `Bearer ${this.confluenceToken()}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
'Connection': 'close'
Expand Down Expand Up @@ -912,6 +916,33 @@ export class HttpClient {
return results;
}

async confluenceUpload<T>(
path: string,
formData: FormData,
opts: { timeoutMs?: number } = {}
): Promise<T> {
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<string, string> = {
'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: <multipart/form-data>\n`;
fs.writeSync(process.stderr.fd, msg);
process.exitCode = ExitCode.SUCCESS;
throw new PncliError('dry-run', 0);
}

return request<T>(url, { method: 'POST', headers, body: formData }, opts.timeoutMs ?? 60000);
}

async confluencePaginate<T>(
fetchPage: (start: number, limit: number) => Promise<{ results: T[]; start: number; limit: number; size: number; _links: { next?: string } }>,
maxTotal?: number
Expand Down
26 changes: 26 additions & 0 deletions src/services/confluence/client.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -201,6 +205,28 @@ export class ConfluenceClient {
});
}

async uploadAttachment(pageId: string, filePath: string, comment?: string): Promise<ConfluenceAttachment[]> {
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<ConfluencePageResponse<ConfluenceAttachment>>(
`${API}/content/${pageId}/child/attachment`,
formData
);
return result.results;
}

async deleteAttachment(attachmentId: string): Promise<void> {
return this.http.confluence<void>(`${API}/content/${attachmentId}`, { method: 'DELETE' });
}

async getPageHistory(id: string): Promise<ConfluencePageHistory> {
return this.http.confluence<ConfluencePageHistory>(`${API}/content/${id}/history`);
}

async convertToStorage(value: string, fromRepresentation = 'markdown'): Promise<string> {
const result = await this.http.confluence<{ value: string; representation: string }>(
`${API}/contentbody/convert/storage`,
Expand Down
42 changes: 42 additions & 0 deletions src/services/confluence/commands.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { existsSync } from 'fs';
import { Command } from 'commander';
import { ConfluenceClient } from './client.js';
import { createHttpClient } from '../../lib/http.js';
Expand Down Expand Up @@ -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>', 'Page ID')
.requiredOption('--file <path>', 'Path to the file to upload')
.option('--comment <text>', '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-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>', '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); }
});
}
9 changes: 9 additions & 0 deletions src/types/confluence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,15 @@ export interface ConfluencePageResponse<T = ConfluencePage> {
_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;
Expand Down
Loading