diff --git a/README.md b/README.md index ed72bf7..1600e60 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ A powerful command-line interface for Atlassian Confluence that allows you to re - 🛠️ **Edit workflow** - Export page content for editing and re-import - 🔀 **Profiles** - Manage multiple Confluence instances with named configuration profiles - 🔒 **Read-only mode** - Profile-level write protection for safe AI agent usage +- 🌐 **Raw API requests** - Make arbitrary authenticated requests to any Confluence endpoint (like `gh api`) - 🔄 **Format conversion** - Convert between Markdown, HTML, Storage, and text formats locally (no server required) - 🔧 **Easy setup** - Simple configuration with environment variables or interactive setup @@ -529,6 +530,47 @@ When read-only mode is active, any write command (`create`, `create-child`, `upd `confluence profile list` shows a `[read-only]` badge next to protected profiles. +### Raw API Requests + +Make arbitrary authenticated requests to any Confluence REST endpoint, modeled after `gh api`. The CLI handles authentication so you don't need to manage tokens manually. + +```bash +# List labels on a page +confluence api /rest/api/content/123456789/label + +# Add a label to a page +confluence api /rest/api/content/123456789/label --input - <<< '[{"name":"reviewed"}]' + +# Remove a label from a page +confluence api /rest/api/content/123456789/label/reviewed -X DELETE + +# View page restrictions +confluence api /rest/api/content/123456789/restriction + +# List groups +confluence api /rest/api/group + +# Check long-running task status +confluence api /rest/api/longtask/123 + +# Query the v2 API (absolute path bypasses the configured API base) +confluence api /wiki/api/v2/pages -f spaceKey=DEV -f limit=10 -X GET + +# Filter response with jq +confluence api /rest/api/group --jq '.results[].name' + +# Include response status and headers +confluence api /rest/api/audit -i + +# Read request body from a file +confluence api /rest/api/content/123456789/restriction --input ./restrictions.json + +# Suppress output (useful in scripts that only care about exit code) +confluence api /rest/api/content/123456789/label --input - --silent <<< '[{"name":"approved"}]' +``` + +Read-only profiles block write methods (`POST`, `PUT`, `PATCH`, `DELETE`) while allowing `GET` and `HEAD`. + ### View Usage Statistics ```bash confluence stats @@ -567,6 +609,7 @@ confluence stats | `profile use ` | Set the active configuration profile | | | `profile add ` | Add a new configuration profile | `-d, --domain`, `-p, --api-path`, `-a, --auth-type`, `-e, --email`, `-t, --token`, `--protocol`, `--read-only` | | `profile remove ` | Remove a configuration profile | | +| `api ` | Make an authenticated API request | `-X, --method `, `-f, --field `, `-H, --header `, `--input `, `--jq `, `-i, --include`, `--silent` | | `convert` | Convert between content formats locally (no server required) | `--input-file `, `--output-file `, `--input-format `, `--output-format ` | | `stats` | View your usage statistics | | @@ -611,6 +654,9 @@ confluence profile list confluence profile use staging confluence --profile staging spaces +# List labels on a page via the raw API +confluence api /rest/api/content/123456789/label --jq '.[].name' + # Convert markdown to Confluence storage format (no server required) confluence convert --input-file doc.md --input-format markdown --output-format storage diff --git a/bin/confluence.js b/bin/confluence.js index c3a6f3a..a592080 100755 --- a/bin/confluence.js +++ b/bin/confluence.js @@ -1952,6 +1952,141 @@ profileCmd } }); +// API command (arbitrary authenticated requests, modeled after gh api) +program + .command('api ') + .description('Make an authenticated API request (like gh api)') + .option('-X, --method ', 'HTTP method (default: GET, auto-POST when body provided)') + .option('-f, --field ', 'Add a request field (repeatable)', (v, a) => { a.push(v); return a; }, []) + .option('-H, --header ', 'Add a request header (repeatable)', (v, a) => { a.push(v); return a; }, []) + .option('--input ', 'Read body from file (use - for stdin)') + .option('--jq ', 'Filter response with jq') + .option('-i, --include', 'Include response status and headers') + .option('--silent', 'Suppress all output') + .action(async (endpoint, options) => { + const analytics = new Analytics(); + try { + const config = getConfig(getProfileName()); + const client = new ConfluenceClient(config); + + // Parse fields + const fields = {}; + for (const raw of options.field) { + const idx = raw.indexOf('='); + if (idx === -1) { + console.error(chalk.red(`Error: Invalid field "${raw}". Must be key=value.`)); + process.exit(1); + } + fields[raw.slice(0, idx)] = raw.slice(idx + 1); + } + + // Parse headers + const extraHeaders = {}; + for (const raw of options.header) { + const idx = raw.indexOf(':'); + if (idx === -1) { + console.error(chalk.red(`Error: Invalid header "${raw}". Must be key:value.`)); + process.exit(1); + } + extraHeaders[raw.slice(0, idx).trim()] = raw.slice(idx + 1).trim(); + } + + // Determine method + const hasFields = Object.keys(fields).length > 0; + let body = undefined; + if (options.input) { + const fs = require('fs'); + let raw; + if (options.input === '-') { + raw = fs.readFileSync(process.stdin.fd, 'utf-8'); + } else { + raw = fs.readFileSync(options.input, 'utf-8'); + } + try { + body = JSON.parse(raw); + } catch { + body = raw; + } + } + + const hasBody = hasFields || body !== undefined; + const method = (options.method || (hasBody ? 'POST' : 'GET')).toUpperCase(); + + // Read-only enforcement + const WRITE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE']; + if (config.readOnly && WRITE_METHODS.includes(method)) { + console.error(chalk.red('Error: This profile is in read-only mode. Write operations are not allowed.')); + console.error(chalk.yellow('Tip: Use "confluence profile add " without --read-only, or set readOnly to false in config.')); + process.exit(1); + } + + // Build request options + const reqOpts = { headers: extraHeaders }; + if (method === 'GET' || method === 'HEAD') { + if (hasFields) { + reqOpts.params = fields; + } + } else { + if (body !== undefined && hasFields) { + reqOpts.data = typeof body === 'object' && body !== null ? { ...body, ...fields } : fields; + } else if (hasFields) { + reqOpts.data = fields; + } else if (body !== undefined) { + reqOpts.data = body; + } + } + + const result = await client.rawRequest(method, endpoint, reqOpts); + + if (options.silent) { + analytics.track('api', true); + return; + } + + let output = ''; + if (options.include) { + output += `HTTP ${result.status}\n`; + for (const [key, value] of Object.entries(result.headers)) { + output += `${key}: ${value}\n`; + } + output += '\n'; + } + + const bodyStr = typeof result.data === 'string' ? result.data : JSON.stringify(result.data, null, 2); + + if (options.jq) { + const { execSync } = require('child_process'); + try { + const filtered = execSync(`jq ${JSON.stringify(options.jq)}`, { + input: typeof result.data === 'string' ? result.data : JSON.stringify(result.data), + encoding: 'utf-8', + }); + output += filtered; + } catch { + process.exit(2); + } + } else { + output += bodyStr; + } + + process.stdout.write(output); + if (!output.endsWith('\n')) { + process.stdout.write('\n'); + } + analytics.track('api', true); + } catch (error) { + analytics.track('api', false); + if (error.response) { + const errBody = error.response.data; + const errStr = typeof errBody === 'string' ? errBody : JSON.stringify(errBody, null, 2); + process.stderr.write(errStr + '\n'); + } else { + console.error(chalk.red('Error:'), error.message); + } + process.exit(1); + } + }); + // Convert command (local format conversion, no server connection required) const VALID_INPUT_FORMATS = ['markdown', 'storage', 'html']; const VALID_OUTPUT_FORMATS = ['markdown', 'storage', 'html', 'text']; diff --git a/lib/confluence-client.js b/lib/confluence-client.js index 2d106f6..c299a43 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -2102,6 +2102,33 @@ class ConfluenceClient { } return parsed; } + + async rawRequest(method, endpoint, options = {}) { + const { headers = {}, params, data } = options; + + let url; + if (/^https?:\/\//i.test(endpoint)) { + url = endpoint; + } else if (endpoint.startsWith('/')) { + url = `${this.protocol}://${this.domain}${endpoint}`; + } else { + url = endpoint; + } + + const response = await this.client.request({ + method: method.toUpperCase(), + url, + headers, + params, + data, + }); + + return { + status: response.status, + headers: response.headers, + data: response.data, + }; + } } ConfluenceClient.createLocalConverter = function () { diff --git a/tests/api-command.test.js b/tests/api-command.test.js new file mode 100644 index 0000000..c860399 --- /dev/null +++ b/tests/api-command.test.js @@ -0,0 +1,183 @@ +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const CLI = path.resolve(__dirname, '../bin/index.js'); + +// Save and set up env-based config so getConfig() works without a config file +const ENV_KEYS = [ + 'CONFLUENCE_DOMAIN', 'CONFLUENCE_HOST', + 'CONFLUENCE_API_TOKEN', 'CONFLUENCE_PASSWORD', + 'CONFLUENCE_EMAIL', 'CONFLUENCE_USERNAME', + 'CONFLUENCE_AUTH_TYPE', 'CONFLUENCE_API_PATH', + 'CONFLUENCE_PROTOCOL', 'CONFLUENCE_READ_ONLY' +]; + +describe('api command', () => { + let tmpDir; + let baseEnv; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'confluence-api-')); + // Build a clean env that won't pick up any host config file + baseEnv = { ...process.env }; + for (const key of ENV_KEYS) { + delete baseEnv[key]; + } + baseEnv.CONFLUENCE_DOMAIN = 'test.atlassian.net'; + baseEnv.CONFLUENCE_API_TOKEN = 'test-token'; + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + function run(args, opts = {}) { + const { env: extraEnv, ...rest } = opts; + return execFileSync(process.execPath, [CLI, ...args], { + encoding: 'utf8', + timeout: 10000, + env: { ...baseEnv, ...extraEnv }, + ...rest, + }); + } + + function runErr(args, opts = {}) { + const { env: extraEnv, ...rest } = opts; + try { + execFileSync(process.execPath, [CLI, ...args], { + encoding: 'utf8', + timeout: 10000, + env: { ...baseEnv, ...extraEnv }, + ...rest, + }); + throw new Error('Expected command to fail'); + } catch (e) { + if (e.message === 'Expected command to fail') throw e; + return { stderr: e.stderr, stdout: e.stdout, status: e.status }; + } + } + + describe('field parsing', () => { + test('missing = in field causes error', () => { + const { stderr } = runErr(['api', '/rest/api/content', '-f', 'badfield']); + expect(stderr).toContain('Invalid field'); + expect(stderr).toContain('key=value'); + }); + + test('field value containing = is preserved', () => { + // This will fail connecting, but we can verify the field parsing doesn't error + const { stderr } = runErr(['api', '/rest/api/content', '-f', 'query=a=b']); + // Should NOT contain "Invalid field" — the error should be a connection/network error + expect(stderr).not.toContain('Invalid field'); + }); + }); + + describe('header parsing', () => { + test('missing : in header causes error', () => { + const { stderr } = runErr(['api', '/rest/api/content', '-H', 'BadHeader']); + expect(stderr).toContain('Invalid header'); + expect(stderr).toContain('key:value'); + }); + + test('header value containing : is preserved', () => { + const { stderr } = runErr(['api', '/rest/api/content', '-H', 'Accept:application/json:extra']); + // Should NOT contain "Invalid header" + expect(stderr).not.toContain('Invalid header'); + }); + }); + + describe('read-only enforcement', () => { + test('blocks DELETE in read-only mode', () => { + const { stderr } = runErr(['api', '/rest/api/content/123', '-X', 'DELETE'], { + env: { CONFLUENCE_READ_ONLY: 'true' }, + }); + expect(stderr).toContain('read-only'); + }); + + test('blocks POST in read-only mode', () => { + const { stderr } = runErr(['api', '/rest/api/content', '-X', 'POST'], { + env: { CONFLUENCE_READ_ONLY: 'true' }, + }); + expect(stderr).toContain('read-only'); + }); + + test('allows GET in read-only mode', () => { + // Will fail with connection error, not read-only error + const { stderr } = runErr(['api', '/rest/api/content'], { + env: { CONFLUENCE_READ_ONLY: 'true' }, + }); + expect(stderr).not.toContain('read-only'); + }); + }); + + describe('method auto-detection', () => { + test('defaults to GET when no fields', () => { + // Will fail connecting, but shouldn't show read-only error + const { stderr } = runErr(['api', '/rest/api/content'], { + env: { CONFLUENCE_READ_ONLY: 'true' }, + }); + expect(stderr).not.toContain('read-only'); + }); + + test('auto-POST when fields present', () => { + const { stderr } = runErr(['api', '/rest/api/content', '-f', 'title=Test'], { + env: { CONFLUENCE_READ_ONLY: 'true' }, + }); + expect(stderr).toContain('read-only'); + }); + + test('explicit method overrides auto-detection', () => { + // Explicit GET even with fields should not be blocked + const { stderr } = runErr(['api', '/rest/api/content', '-f', 'title=Test', '-X', 'GET'], { + env: { CONFLUENCE_READ_ONLY: 'true' }, + }); + expect(stderr).not.toContain('read-only'); + }); + }); + + describe('--input option', () => { + test('reads body from file', () => { + const inputFile = path.join(tmpDir, 'body.json'); + fs.writeFileSync(inputFile, '{"title":"FromFile"}'); + // --input triggers auto-POST, read-only should block it + const { stderr } = runErr(['api', '/rest/api/content', '--input', inputFile], { + env: { CONFLUENCE_READ_ONLY: 'true' }, + }); + expect(stderr).toContain('read-only'); + }); + + test('reads body from stdin via -', () => { + // --input - triggers auto-POST, read-only should block it + const { stderr } = runErr(['api', '/rest/api/content', '--input', '-'], { + env: { CONFLUENCE_READ_ONLY: 'true' }, + input: '{"title":"FromStdin"}', + }); + expect(stderr).toContain('read-only'); + }); + }); + + describe('--silent option', () => { + test('suppresses output on error', () => { + // Even with --silent, errors still go to stderr and exit 1 + const { stderr } = runErr(['api', '/rest/api/content', '--silent']); + // Should still have some error (connection failure), but command ran + expect(stderr).toBeDefined(); + }); + }); + + describe('help output', () => { + test('api command appears in help', () => { + const output = run(['api', '--help']); + expect(output).toContain('authenticated API request'); + expect(output).toContain('--method'); + expect(output).toContain('--field'); + expect(output).toContain('--header'); + expect(output).toContain('--input'); + expect(output).toContain('--jq'); + expect(output).toContain('--include'); + expect(output).toContain('--silent'); + }); + }); +}); diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index 237bc39..13c842f 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -1350,4 +1350,158 @@ describe('ConfluenceClient', () => { mock.restore(); }); }); + + describe('rawRequest', () => { + test('GET with relative endpoint', async () => { + const mock = new MockAdapter(client.client); + mock.onGet('/content/123').reply(200, { id: '123', title: 'Test' }); + + const result = await client.rawRequest('GET', 'content/123'); + expect(result.status).toBe(200); + expect(result.data).toEqual({ id: '123', title: 'Test' }); + + mock.restore(); + }); + + test('GET with query params', async () => { + const mock = new MockAdapter(client.client); + mock.onGet('/content').reply(config => { + expect(config.params).toEqual({ spaceKey: 'DEV', limit: '10' }); + return [200, { results: [] }]; + }); + + const result = await client.rawRequest('GET', 'content', { + params: { spaceKey: 'DEV', limit: '10' }, + }); + expect(result.status).toBe(200); + + mock.restore(); + }); + + test('POST with JSON body', async () => { + const mock = new MockAdapter(client.client); + mock.onPost('/content').reply(config => { + expect(JSON.parse(config.data)).toEqual({ title: 'New Page', type: 'page' }); + return [200, { id: '456' }]; + }); + + const result = await client.rawRequest('POST', 'content', { + data: { title: 'New Page', type: 'page' }, + }); + expect(result.status).toBe(200); + expect(result.data).toEqual({ id: '456' }); + + mock.restore(); + }); + + test('absolute path bypasses apiPath', async () => { + const mock = new MockAdapter(client.client); + mock.onGet('https://test.atlassian.net/wiki/api/v2/pages').reply(200, { results: [] }); + + const result = await client.rawRequest('GET', '/wiki/api/v2/pages'); + expect(result.status).toBe(200); + + mock.restore(); + }); + + test('full URL used as-is', async () => { + const mock = new MockAdapter(client.client); + mock.onGet('https://other.example.com/api/data').reply(200, { ok: true }); + + const result = await client.rawRequest('GET', 'https://other.example.com/api/data'); + expect(result.status).toBe(200); + expect(result.data).toEqual({ ok: true }); + + mock.restore(); + }); + + test('custom headers sent alongside auth headers', async () => { + const mock = new MockAdapter(client.client); + mock.onGet('/content').reply(config => { + expect(config.headers['X-Custom']).toBe('value'); + expect(config.headers.Authorization).toBeDefined(); + return [200, {}]; + }); + + await client.rawRequest('GET', 'content', { + headers: { 'X-Custom': 'value' }, + }); + + mock.restore(); + }); + + test('PUT method', async () => { + const mock = new MockAdapter(client.client); + mock.onPut('/content/123').reply(200, { updated: true }); + + const result = await client.rawRequest('PUT', 'content/123', { + data: { title: 'Updated' }, + }); + expect(result.status).toBe(200); + + mock.restore(); + }); + + test('DELETE method', async () => { + const mock = new MockAdapter(client.client); + mock.onDelete('/content/123').reply(204); + + const result = await client.rawRequest('DELETE', 'content/123'); + expect(result.status).toBe(204); + + mock.restore(); + }); + + test('HTTP error propagation', async () => { + const mock = new MockAdapter(client.client); + mock.onGet('/content/999').reply(404, { message: 'Not found' }); + + await expect(client.rawRequest('GET', 'content/999')).rejects.toThrow(); + + mock.restore(); + }); + + test('auth headers preserved with basic auth', async () => { + const basicClient = new ConfluenceClient({ + domain: 'test.atlassian.net', + token: 'test-token', + email: 'user@example.com', + authType: 'basic', + }); + const mock = new MockAdapter(basicClient.client); + mock.onGet('/content').reply(config => { + expect(config.headers.Authorization).toMatch(/^Basic /); + return [200, {}]; + }); + + await basicClient.rawRequest('GET', 'content'); + + mock.restore(); + }); + + test('auth headers preserved with bearer token', async () => { + const mock = new MockAdapter(client.client); + mock.onGet('/content').reply(config => { + expect(config.headers.Authorization).toBe('Bearer test-token'); + return [200, {}]; + }); + + await client.rawRequest('GET', 'content'); + + mock.restore(); + }); + + test('returns status, headers, and data', async () => { + const mock = new MockAdapter(client.client); + mock.onGet('/content').reply(200, { items: [] }, { 'x-request-id': 'abc123' }); + + const result = await client.rawRequest('GET', 'content'); + expect(result).toHaveProperty('status', 200); + expect(result).toHaveProperty('headers'); + expect(result).toHaveProperty('data'); + expect(result.headers['x-request-id']).toBe('abc123'); + + mock.restore(); + }); + }); });