Skip to content

feat: emit structured errors in JSON mode - #221

Merged
pchuri merged 10 commits into
mainfrom
fm/confcli-json-errors-w1
Jul 23, 2026
Merged

feat: emit structured errors in JSON mode#221
pchuri merged 10 commits into
mainfrom
fm/confcli-json-errors-w1

Conversation

@pchuri

@pchuri pchuri commented Jul 23, 2026

Copy link
Copy Markdown
Owner

Intent

Add structured error output for --json mode in confluence-cli (roadmap item #1). Problem: success output was structured JSON (lib/output.js emitJson) but failures always printed chalk-colored prose to stderr even under --json, so agents/scripts (jq pipelines, the Claude skill) could parse success but not failure. Goal: when the global --json flag is active, any command failure emits exactly ONE machine-parseable JSON object on stderr (stdout stays empty) and nothing else, shape {error, code, status, details}. Decisions/tradeoffs: (1) errors stay on stderr to preserve the existing stdout=data / stderr=diagnostics contract the README/SKILL.md already teach; (2) exit codes are unchanged (1; the api command's pre-existing jq exit-2 is preserved); (3) code is a small stable enum derived from existing classification: AUTH_FAILED (401/403), NOT_FOUND (404), API_ERROR (other 4xx/5xx), NETWORK (connection/DNS/timeout error codes), VALIDATION (thrown Error with no HTTP response), UNKNOWN; (4) details carries the raw API response body or null. Both error paths are covered: the global handleCommandError in bin/confluence.js and the api command's custom catch plus its inline validation/read-only/jq sites in bin/commands/api.js. Non-JSON output is intentionally byte-identical (existing users see the same prose, including the read-only Tip line and jq messages). Deliberately did NOT refactor handleCommandError beyond structured emission and did NOT touch the markdown/storage conversion pipeline (that is a separate roadmap item). Added unit/integration tests (auth-failure, API-error-with-body, validation-error under --json, plus a non-json-prose-unchanged assertion, and real-binary api-command --json tests) and updated README JSON section and SKILL.md Error Patterns.

What Changed

  • Emit a single structured {error, code, status, details} object on stderr for command failures in --json mode while keeping stdout empty and human-readable output unchanged.
  • Cover API, validation, configuration, read-only, jq, HTTP, and network failures while suppressing warning noise and preserving existing exit-code exceptions.
  • Document the error contract and add unit and real-CLI regression coverage.

Risk Assessment

✅ Low: The follow-up accurately documents jq’s preserved exit status, and no remaining material issues were found in the reviewed scope.

Testing

After installing missing locked dependencies, all focused and full automated tests passed; real CLI runs confirmed single parseable JSON stderr objects, empty stdout, correct classifications/details and exit codes, while preserving human diagnostics, and generated dependencies were cleaned up.

Evidence: End-to-end CLI transcript
# Structured JSON CLI end-to-end evidence

## HTTP authentication failure

Command: `node bin/index.js --json spaces`
Exit status: `1`
Stdout: `0 bytes`
Whole-stderr `JSON.parse`: `PASS`

`` `json
{
  "error": "Authentication failed (401 Unauthorized).\nPlease verify your personal access token is valid and not expired.",
  "code": "AUTH_FAILED",
  "status": 401,
  "details": {
    "message": "Unauthorized evidence",
    "requestId": "auth-401"
  }
}
`` `

## API command failure with raw response details

Command: `node bin/index.js --json api /rest/api/failure`
Exit status: `1`
Stdout: `0 bytes`
Whole-stderr `JSON.parse`: `PASS`

`` `json
{
  "error": "Request failed with status code 500",
  "code": "API_ERROR",
  "status": 500,
  "details": {
    "message": "Server evidence failure",
    "traceId": "api-500"
  }
}
`` `

## Invalid link-style warning suppressed in JSON mode

Command: `node bin/index.js --json spaces`
Exit status: `1`
Stdout: `0 bytes`
Whole-stderr `JSON.parse`: `PASS`

`` `json
{
  "error": "Request failed with status code 500",
  "code": "API_ERROR",
  "status": 500,
  "details": {
    "message": "Warning-gating evidence"
  }
}
`` `

## Read-only validation failure

Command: `node bin/index.js --json delete 123 --yes`
Exit status: `1`
Stdout: `0 bytes`
Whole-stderr `JSON.parse`: `PASS`

`` `json
{
  "error": "This profile is in read-only mode. Write operations are not allowed.",
  "code": "VALIDATION",
  "status": null,
  "details": null
}
`` `

## jq failure preserves exit status 2

Command: `node bin/index.js --json api /rest/api/ok --jq [`
Exit status: `2`
Stdout: `0 bytes`
Whole-stderr `JSON.parse`: `PASS`

`` `json
{
  "error": "jq exited with status 3\njq: error: syntax error, unexpected end of file at <top-level>, line 1, column 1:\n    [\n    ^\njq: 1 compile error",
  "code": "VALIDATION",
  "status": null,
  "details": null
}
`` `

## Non-JSON diagnostics remain human-readable

Command: `node bin/index.js spaces` with `CONFLUENCE_LINK_STYLE=smrt`
Exit status: `1`
Stdout: `0 bytes`

`` `text
⚠ Invalid linkStyle from CONFLUENCE_LINK_STYLE "smrt"; valid values: smart, plain, wiki. Falling back to auto-detection.
Error: Request failed with status code 500
API response: {
  "message": "Human-output evidence"
}
`` `

## Network failure classification

Command: `node bin/index.js --json spaces`
Exit status: `1`
Stdout: `0 bytes`
Whole-stderr `JSON.parse`: `PASS`

`` `json
{
  "error": "connect ECONNREFUSED 127.0.0.1:60728",
  "code": "NETWORK",
  "status": null,
  "details": null
}
`` `
- Outcome: 🔧 1 issue found → auto-fixed (6) ✅ across 7 runs (48m40s)

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 3 issues found → auto-fixed (2) ✅
  • 🚨 bin/confluence.js:69 - Required: “any command failure emits exactly ONE…JSON object on stderr (stdout stays empty).” This handler is bypassed by getConfig() and assertWritable(), which call process.exit() after prose output; missing config also writes guidance to stdout. Route these failures through structured handling or explicitly narrow the requirement.
  • 🚨 bin/confluence.js:69 - Commander parsing and preAction failures never reach this handler. Missing arguments, unknown options, and unsupported --json commands still emit prose, contradicting the required “any command failure” behavior. These should emit a single VALIDATION object unless explicitly exempted.
  • 🚨 README.md:396 - The new guarantee says failure leaves stdout empty, but copy-tree --json --fail-on-error and versions-purge --json emit success JSON to stdout before setting exit status 1 for partial failures. Decide whether these nonzero outcomes require structured stderr errors or are an intentional exception.

🔧 Fix: Clarify structured JSON error coverage and exceptions
1 error still open:

  • 🚨 README.md:396 - Intent requires “the api command's pre-existing jq exit-2 is preserved,” but this says the API error path exits 1; jq failures still call trackAndExit(..., 2). Qualify this sentence and the matching SKILL.md text with the jq exception.

🔧 Fix: Document preserved jq failure exit status
✅ Re-checked - no issues remain.

🔧 **Test** - 1 issue found → auto-fixed (6) ✅
  • 🚨 bin/confluence.js:22 - A supported global --json command bypasses structured emission: --json delete 123 --yes with a read-only profile exits 1 and prints separate Error and Tip prose lines instead of exactly one {error, code, status, details} object. assertWritable() calls process.exit(1) before withClient() can route the failure through handleCommandError. Route this precondition through JSON-aware error handling and add a real-binary regression test.
  • npm test -- --runInBand tests/json-output.test.js tests/api-command.test.js (initially blocked by absent dependencies, then passed after npm ci --ignore-scripts)
  • npm test -- --runInBand
  • Real CLI against a deterministic local HTTP mock: global 401 and 404 paths, API 500 path, inline validation, network failure, jq compile failure, and missing jq
  • Real CLI with CONFLUENCE_READ_ONLY=true: node bin/index.js --json delete 123 --yes
  • Real CLI without JSON: node bin/index.js api /rest/api/content -f badfield
  • jq -e &#39;keys == [&#34;code&#34;,&#34;details&#34;,&#34;error&#34;,&#34;status&#34;]&#39; over captured JSON stderr and zero-byte assertions over captured stdout
  • Removed node_modules, stopped the mock server, and confirmed the worktree remained clean

🔧 Fix: fix: route read-only failures through JSON handler
1 error still open:

  • 🚨 lib/config.js:896 - A supported --json spaces invocation with no configuration exits 1 but writes setup instructions to stdout and non-JSON prose to stderr. getConfig() calls process.exit(1) before handleCommandError, violating the required single JSON stderr object and empty stdout contract. Route configuration failures through JSON-aware handling while preserving non-JSON output, and add a real-binary regression test.
  • npm ci --ignore-scripts
  • npm test -- --runInBand tests/json-output.test.js tests/api-command.test.js tests/read-only.test.js tests/with-client.test.js
  • npm test -- --runInBand
  • Real CLI checks for 401, 404, 500, validation, read-only, jq exit 2, network failure, and non-JSON prose
  • CONFLUENCE_CONFIG_DIR=<empty> node bin/index.js --json spaces

🔧 Fix: fix JSON output for configuration failures
3 issues (2 errors, 1 warning) still open:

  • 🚨 bin/confluence.js:154 - confluence --json info and unsupported-command failures still emit Commander/plain prose rather than one structured JSON object, contrary to the required any-failure contract.
  • 🚨 lib/config.js:292 - Malformed config files print recovery prose before the JSON payload, making stderr unparsable in both standard and api commands.
  • ⚠️ README.md:419 - The exceptions paragraph incorrectly says read-only and configuration-loading failures remain prose, contradicting current behavior.
  • npm ci --ignore-scripts
  • npm test -- --runInBand tests/json-output.test.js tests/api-command.test.js tests/read-only.test.js tests/with-client.test.js tests/config.test.js
  • npm test -- --runInBand
  • node /var/folders/mm/mzvsb9xn40vd3pqkp4y8n_sm0000gn/T/no-mistakes-evidence/01KY7CH68RKFRX3CZ9WNSQ9NHK/capture-cli-errors.js "$PWD"
  • Real CLI checks against a controlled HTTP server for 401, 404, 503, network, validation, read-only, config, and jq failures

🔧 Fix: fix JSON handling for usage and config errors
1 error still open:

  • 🚨 lib/confluence-client.js:380 - A supported --json info &lt;display URL&gt; failure logs the caught Axios error before the structured handler runs, producing a diagnostic stack plus JSON on stderr. The stream cannot be parsed as one JSON object. Suppress or route this intermediate diagnostic in JSON mode and add a real-binary regression test.
  • npm ci --ignore-scripts
  • npm test -- --runInBand tests/json-output.test.js tests/api-command.test.js tests/read-only.test.js tests/with-client.test.js
  • npm test -- --runInBand
  • Real-binary local-server checks for 401, 404, 500, network, read-only, usage, jq, and non-JSON prose paths
  • node bin/index.js --json info http://127.0.0.1:43129/display/SPACE/Title
  • Missing-jq check using a restricted PATH

🔧 Fix: Suppress display URL diagnostics in JSON mode
2 issues (1 error, 1 warning) still open:

  • 🚨 lib/confluence-client.js:293 - A mode-0644 mTLS client key prints a permission warning before the structured NETWORK payload. Whole-stderr JSON.parse fails. Suppress this warning only in JSON mode and add a real-binary regression test.
  • ⚠️ plugins/confluence/skills/confluence/SKILL.md:828 - The bundled skill incorrectly says usage, unsupported-JSON, read-only, and configuration failures remain prose, although they now emit structured JSON.
  • Initial focused npm test -- --runInBand ... attempt (Jest unavailable)
  • npm ci --ignore-scripts
  • npm test -- --runInBand tests/display-url-errors.test.js tests/json-output.test.js tests/api-command.test.js tests/read-only.test.js tests/with-client.test.js
  • npm test -- --runInBand
  • Real-binary JSON and non-JSON display-URL failures
  • Real-binary JSON network failure with a mode-0644 mTLS key
  • rm -rf node_modules && git status --short --branch

🔧 Fix: Suppress mTLS warning noise in JSON mode
2 errors still open:

  • 🚨 lib/netrc.js:104 - An unreadable .netrc emits a warning before the structured error. Consequently, JSON.parse(stderr) fails. Suppress this warning only in JSON mode and add a real-binary regression test.
  • 🚨 lib/config.js:65 - An invalid CONFLUENCE_LINK_STYLE emits a fallback warning before a later structured error. Consequently, JSON.parse(stderr) fails. Gate this warning in JSON mode while preserving human output, with a regression test.
  • npm test -- --runInBand tests/json-output.test.js tests/api-command.test.js tests/read-only.test.js tests/display-url-errors.test.js tests/mtls-errors.test.js tests/with-client.test.js
  • npm test -- --runInBand
  • Real CLI: confluence --json api /auth against a local 401 server
  • Real CLI: confluence --json api /ok --jq &#39;.[&#39; against a local server
  • Real CLI display-URL resolution failure
  • Real CLI mTLS failure with a mode-0644 key, in JSON and human modes
  • Real CLI unreadable-netrc and invalid-link-style failure probes

🔧 Fix: Suppress configuration warnings in JSON mode
✅ Re-checked - no issues remain.

  • Initial focused Jest command (dependencies absent: jest: command not found)
  • npm ci --ignore-scripts
  • npm test -- --runInBand tests/json-output.test.js tests/api-command.test.js tests/read-only.test.js tests/with-client.test.js tests/display-url-errors.test.js tests/mtls-errors.test.js tests/config-warning-errors.test.js
  • npm test -- --runInBand
  • Real CLI checks for HTTP 401/500, network, read-only, link-style warning suppression, non-JSON diagnostics, and jq exit status 2
  • Removed node_modules and verified a clean worktree
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

pchuri and others added 10 commits July 23, 2026 20:40
When the global --json flag is active, command failures now emit exactly
one machine-parseable JSON object on stderr instead of chalk-colored prose,
so agents and scripts (jq pipelines, the Claude skill) can parse failures
the same way they parse success.

Shape: { error, code, status, details } where code is a stable enum
(AUTH_FAILED, NOT_FOUND, VALIDATION, API_ERROR, NETWORK, UNKNOWN) derived
from the existing error classification. Errors stay on stderr (stdout=data,
stderr=diagnostics) and exit codes are unchanged (1).

Both error paths are covered: the global handleCommandError and the api
command's custom catch/validation paths. Non-JSON output is byte-identical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@pchuri
pchuri merged commit 45a0b87 into main Jul 23, 2026
6 checks passed
github-actions Bot pushed a commit that referenced this pull request Jul 23, 2026
# [2.19.0](v2.18.1...v2.19.0) (2026-07-23)

### Features

* emit structured errors in JSON mode ([#221](#221)) ([45a0b87](45a0b87))
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.19.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

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.

1 participant