Skip to content

feat: add api command for arbitrary authenticated requests#189

Merged
pchuri merged 1 commit into
pchuri:mainfrom
LoganBarnett:feature/api-command
May 28, 2026
Merged

feat: add api command for arbitrary authenticated requests#189
pchuri merged 1 commit into
pchuri:mainfrom
LoganBarnett:feature/api-command

Conversation

@LoganBarnett
Copy link
Copy Markdown
Contributor

@LoganBarnett LoganBarnett commented May 18, 2026

Summary

  • Adds a confluence api <endpoint> command modeled after gh api, allowing users to make authenticated requests to any Confluence REST endpoint (labels, restrictions, groups, audit, v2 API, etc.) without falling back to curl. It's kind of a release valve for API calls that confluence-api hasn't had a chance to support yet.
  • Adds rawRequest method to ConfluenceClient supporting relative, absolute, and full URL endpoints.
  • Supports -X, -f, -H, --input, --jq, -i, --silent options with read-only profile enforcement.

Test plan

  • 12 unit tests for rawRequest (relative/absolute/full URLs, methods, headers, auth, errors).
  • 14 integration tests for the api command (field/header parsing, read-only enforcement, method auto-detection, --input, --silent, help output).
  • All 197 existing tests still pass.
  • Lint clean.

@LoganBarnett LoganBarnett marked this pull request as ready for review May 18, 2026 18:32
Copy link
Copy Markdown
Owner

@pchuri pchuri left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks a lot for the contribution! 🙌 A gh api-style escape hatch is genuinely useful for endpoints we haven't wrapped yet, and the test coverage (12 unit + 14 integration) is impressive — that's the right amount of rigor for something this generic.

I left a few inline comments. The two blocking ones (from my perspective) are (1) the file layout and (2) how absolute paths interact with apiPath on Cloud. The rest are nice-to-haves that could be follow-ups if you'd rather keep this PR focused. Either way, very nice work.

Comment thread bin/confluence.js Outdated
});

// API command (arbitrary authenticated requests, modeled after gh api)
program
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Architecture nit. PR #186 split this file into per-domain modules under bin/commands/. To stay consistent with that direction, would you mind moving these ~135 lines into a new bin/commands/api.js and wiring it up the same way the other domain commands are? Right now this is the only new command added directly to bin/confluence.js since the refactor.

Comment thread lib/confluence-client.js
if (/^https?:\/\//i.test(endpoint)) {
url = endpoint;
} else if (endpoint.startsWith('/')) {
url = `${this.protocol}://${this.domain}${endpoint}`;
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cloud-vs-Server path mismatch — probably the most important comment.

On Confluence Cloud, apiPath is typically /wiki/rest/api. By treating any /-prefixed endpoint as bypassing apiPath, the README's marquee example

confluence api /rest/api/content/123456789/label

resolves to https://{domain}/rest/api/content/... (404 on Cloud) instead of https://{domain}/wiki/rest/api/content/.... So the documented examples only work on Server/DC, while Cloud users have to know to write /wiki/rest/api/....

A couple of options:

  1. Document explicitly that absolute paths bypass apiPath, and update the examples to use /wiki/rest/api/... for Cloud.
  2. Introduce a third tier: treat rest/api/... (no leading slash) as "relative to apiPath" (which axios already does via baseURL), /... as "relative to host but auto-prepend apiPath when it looks like a REST endpoint", and https://... as absolute.

Option 1 is cheaper and matches the gh api mental model. Either is fine — but the current behavior + current README will silently break for Cloud users.

Comment thread bin/confluence.js Outdated
if (options.jq) {
const { execSync } = require('child_process');
try {
const filtered = execSync(`jq ${JSON.stringify(options.jq)}`, {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shell quoting / minor injection surface.

JSON.stringify wraps the expression in double quotes, but the shell still interprets $, backticks, and \ inside double quotes. For example --jq '.[] | select(.name == "$HOME")' will have $HOME expanded by the shell before jq ever sees it. The blast radius is the user's own machine, but it's also trivial to sidestep:

const { spawnSync } = require('child_process');
const r = spawnSync('jq', [options.jq], {
  input: typeof result.data === 'string' ? result.data : JSON.stringify(result.data),
  encoding: 'utf-8',
});

Args go straight to the binary, no shell involved.

Comment thread bin/confluence.js Outdated
});
output += filtered;
} catch {
process.exit(2);
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When jq isn't installed (ENOENT) or the expression is invalid, this swallows the cause and exits with code 2 with no message. A one-line console.error('jq failed:', err.message) (or specifically detecting ENOENT and saying "jq is not installed") would save users a debugging round-trip.

Comment thread bin/confluence.js Outdated
const fs = require('fs');
let raw;
if (options.input === '-') {
raw = fs.readFileSync(process.stdin.fd, 'utf-8');
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR #184 (fix(convert): read stdin as a stream and guard against TTY-only invocations) hit this exact pattern in the convert command — fs.readFileSync(process.stdin.fd, ...) can hang on a TTY and has issues with large inputs. Could you reuse the stream-based stdin helper from that fix to stay consistent?

Comment thread bin/confluence.js Outdated
.option('--input <file>', 'Read body from file (use - for stdin)')
.option('--jq <expression>', 'Filter response with jq')
.option('-i, --include', 'Include response status and headers')
.option('--silent', 'Suppress all output')
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the description says "Suppress all output", but the read-only block and the catch handler still write to stderr. Either tighten the implementation (--silent ⇒ also swallow stderr on errors) or relax the doc to "Suppress success output." Most CLIs go with the latter — script consumers usually still want to see errors.

Comment thread bin/confluence.js Outdated
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 <name>" without --read-only, or set readOnly to false in config.'));
process.exit(1);
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

analytics.track('api', false) is missing here, and on the field/header parse error paths above (lines 1978, 1989). The bottom catch tracks failures, but these early process.exit(1) branches don't, so the metric will under-count failures.

@LoganBarnett
Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review! I'll take a look at these ASAP :)

@pchuri
Copy link
Copy Markdown
Owner

pchuri commented May 28, 2026

Hey @LoganBarnett — thanks again for the PR! Looks like you've been heads-down on other projects (no pressure!). I'd like to get this landed, so I'm going to push the review fixes directly to your branch with you as co-author. Happy to revert if you'd prefer to do it yourself — just let me know!

Adds `confluence api <endpoint>` modeled after `gh api`, allowing
authenticated requests to any Confluence REST endpoint (labels,
restrictions, groups, audit, v2 API, etc.) without falling back to curl.

- New ConfluenceClient.rawRequest() supporting relative, absolute, and
  full URL endpoints (absolute paths bypass apiPath — Cloud users need
  the /wiki prefix, documented in --help and README).
- New bin/commands/api.js module following the per-domain pattern from
  PR pchuri#186.
- Extracts the readStdin helper into lib/stdin-utils.js so the api
  command can reuse the stream-based reader added in PR pchuri#184.
- jq invocation uses spawnSync (no shell), with clear error messages
  when jq is missing or fails.
- --silent suppresses success output only; errors still go to stderr.
- All early-exit paths track analytics failures.

Tests: 12 unit tests for rawRequest, 15 integration tests for the api
command. All 716 existing tests still pass.

Co-Authored-By: Logan Barnett <logustus@gmail.com>
@pchuri pchuri force-pushed the feature/api-command branch from 83b838c to 77f4656 Compare May 28, 2026 04:49
@pchuri pchuri merged commit 4852b77 into pchuri:main May 28, 2026
6 checks passed
github-actions Bot pushed a commit that referenced this pull request May 28, 2026
# [2.9.0](v2.8.0...v2.9.0) (2026-05-28)

### Features

* add `api` command for arbitrary authenticated requests ([#189](#189)) ([4852b77](4852b77)), closes [#186](#186) [#184](#184)
@github-actions
Copy link
Copy Markdown

🎉 This PR is included in version 2.9.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

@LoganBarnett
Copy link
Copy Markdown
Contributor Author

@pchuri thanks! Sorry about that - I've been traveling and computer time has been sporadic. Looks like stuff went in quickly though. Thanks for your work on this!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants