Skip to content
Merged
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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,33 @@ confluence create "Notes" ENG --content "hi" --json | jq -r '.id' # capture th

> **Deprecation:** the per-command `--format json` form is deprecated in favor of the global `--json` flag. It still works but prints a warning to stderr and will be removed in a future major version.

#### Structured errors

When `--json` is active, command failures are machine-parseable: the command prints exactly one JSON object to **stderr** (stdout stays empty) and, except as noted below, exits with status `1`. This lets scripts and agents branch on failures without scraping prose. The shape is:

```json
{
"error": "Authentication failed (401 Unauthorized).",
"code": "AUTH_FAILED",
"status": 401,
"details": { "message": "...", "status-code": 401 }
}
```

- `error` — the human-readable message.
- `code` — a stable machine code, one of `AUTH_FAILED`, `NOT_FOUND`, `VALIDATION`, `API_ERROR`, `NETWORK`, `UNKNOWN`.
- `status` — the HTTP status when the failure came from the server, otherwise `null`.
- `details` — the raw API response body when present, otherwise `null`.

```bash
# Read errors from stderr and inspect the code
confluence info 123 --json 2> >(jq -r '.code') 1>/dev/null
```

Without `--json`, error output is unchanged (human-readable prose on stderr).

Exceptions: `api` failures caused by missing `jq` or a failing `--jq` expression still use the structured stderr format but preserve exit status `2`. Partial failures from `copy-tree --fail-on-error` and `versions-purge` emit their result JSON to stdout and signal the partial failure with exit status `1` instead of a structured error on stderr.

### Read a Page
```bash
# Read by page ID
Expand Down
45 changes: 33 additions & 12 deletions bin/commands/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const chalk = require('chalk');
const ConfluenceClient = require('../../lib/confluence-client');
const { getConfig } = require('../../lib/config');
const Analytics = require('../../lib/analytics');
const { emitJsonError } = require('../../lib/output');

const WRITE_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];

Expand Down Expand Up @@ -35,15 +36,21 @@ Endpoint resolution:
`)
.action(async (endpoint, options) => {
const analytics = new Analytics();
// When the global --json flag is active, failures emit a single structured
// JSON object on stderr instead of prose (agents/scripts can then parse
// errors). Non-JSON output stays byte-identical.
const jsonMode = Boolean(program.opts().json);
try {
const config = getConfig(getProfileName());
const config = getConfig(getProfileName(), { throwOnError: jsonMode });
const client = new ConfluenceClient(config);

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.`));
const message = `Invalid field "${raw}". Must be key=value.`;
if (jsonMode) emitJsonError(null, { message, code: 'VALIDATION' });
else console.error(chalk.red(`Error: ${message}`));
trackAndExit(analytics, 1);
}
fields[raw.slice(0, idx)] = raw.slice(idx + 1);
Expand All @@ -53,7 +60,9 @@ Endpoint resolution:
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.`));
const message = `Invalid header "${raw}". Must be key:value.`;
if (jsonMode) emitJsonError(null, { message, code: 'VALIDATION' });
else console.error(chalk.red(`Error: ${message}`));
trackAndExit(analytics, 1);
}
extraHeaders[raw.slice(0, idx).trim()] = raw.slice(idx + 1).trim();
Expand All @@ -76,8 +85,15 @@ Endpoint resolution:
const method = (options.method || (hasBody ? 'POST' : 'GET')).toUpperCase();

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.'));
if (jsonMode) {
emitJsonError(null, {
message: 'This profile is in read-only mode. Write operations are not allowed.',
code: 'VALIDATION',
});
} else {
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.'));
}
trackAndExit(analytics, 1);
}

Expand Down Expand Up @@ -118,16 +134,19 @@ Endpoint resolution:
encoding: 'utf-8',
});
if (r.error) {
if (r.error.code === 'ENOENT') {
console.error(chalk.red('Error: jq is not installed (--jq requires jq in PATH).'));
} else {
console.error(chalk.red('Error: jq failed:'), r.error.message);
}
const message = r.error.code === 'ENOENT'
? 'jq is not installed (--jq requires jq in PATH).'
: `jq failed: ${r.error.message}`;
if (jsonMode) emitJsonError(null, { message, code: 'VALIDATION' });
else if (r.error.code === 'ENOENT') console.error(chalk.red(`Error: ${message}`));
else console.error(chalk.red('Error: jq failed:'), r.error.message);
trackAndExit(analytics, 2);
}
if (r.status !== 0) {
const stderr = (r.stderr || '').toString().trim();
console.error(chalk.red(`Error: jq exited with status ${r.status}${stderr ? `\n${stderr}` : ''}`));
const message = `jq exited with status ${r.status}${stderr ? `\n${stderr}` : ''}`;
if (jsonMode) emitJsonError(null, { message, code: 'VALIDATION' });
else console.error(chalk.red(`Error: ${message}`));
trackAndExit(analytics, 2);
}
output += r.stdout;
Expand All @@ -142,7 +161,9 @@ Endpoint resolution:
analytics.track('api', true);
} catch (error) {
analytics.track('api', false);
if (error.response) {
if (jsonMode) {
emitJsonError(error);
} else if (error.response) {
const errBody = error.response.data;
const errStr = typeof errBody === 'string' ? errBody : JSON.stringify(errBody, null, 2);
process.stderr.write(errStr + '\n');
Expand Down
43 changes: 34 additions & 9 deletions bin/confluence.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,16 @@ const registerCommentCommands = require('./commands/comment');
const registerExportCommand = require('./commands/export');
const registerApiCommand = require('./commands/api');
const { readStdin } = require('../lib/stdin-utils');
const { emitJson, jsonRequested } = require('../lib/output');
const { emitJson, emitJsonError, jsonRequested, setJsonMode } = require('../lib/output');

const READ_ONLY_MESSAGE = 'This profile is in read-only mode. Write operations are not allowed.';
const READ_ONLY_TIP = 'Tip: Use "confluence profile add <name>" without --read-only, or set readOnly to false in config.';

class ReadOnlyError extends Error {}

function assertWritable(config) {
if (config.readOnly) {
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);
throw new ReadOnlyError(READ_ONLY_MESSAGE);
}
}

Expand Down Expand Up @@ -63,6 +66,18 @@ function formatApiErrorBody(data) {

function handleCommandError(analytics, commandName, error, onExtra = null) {
analytics.track(commandName, false);
// In --json mode, emit a single structured error object (on stderr, preserving
// the stdout=data / stderr=diagnostics contract) so agents/scripts can parse
// failures. Non-JSON callers keep the exact human-readable prose below.
if (program.opts().json) {
emitJsonError(error);
process.exit(1);
}
if (error instanceof ReadOnlyError) {
console.error(chalk.red(`Error: ${error.message}`));
console.error(chalk.yellow(READ_ONLY_TIP));
process.exit(1);
}
console.error(chalk.red('Error:'), error.message);
const apiDetail = formatApiErrorBody(error.response?.data);
if (apiDetail && !onExtra) {
Expand All @@ -83,7 +98,7 @@ function withClient(commandName, handler, { writable = false, onError = null } =
return async (...actionArgs) => {
const analytics = new Analytics();
try {
const config = getConfig(getProfileName());
const config = getConfig(getProfileName(), { throwOnError: Boolean(program.opts().json) });
if (writable) assertWritable(config);
const client = new ConfluenceClient(config);
await handler({ client, config, analytics, emitJson, wantsJson }, ...actionArgs);
Expand Down Expand Up @@ -115,6 +130,16 @@ program
.option('--profile <name>', 'Use a specific configuration profile')
.option('--json', 'Output raw JSON to stdout (for scripting / piping to jq)');

program.configureOutput({
outputError: (message, write) => {
if (program.opts().json) {
emitJsonError(null, { message: message.trim(), code: 'VALIDATION' });
return;
}
write(message);
},
});

// Helper: resolve profile name from global --profile flag
function getProfileName() {
return program.opts().profile || undefined;
Expand Down Expand Up @@ -142,11 +167,11 @@ const JSON_COMMANDS = new Set([
]);

program.hook('preAction', (thisCommand, actionCommand) => {
setJsonMode(program.opts().json);
if (program.opts().json && !JSON_COMMANDS.has(actionCommand.name())) {
console.error(chalk.red(
`Error: --json is not supported by "${actionCommand.name()}". ` +
`Supported commands: ${[...JSON_COMMANDS].join(', ')}.`
));
const message = `--json is not supported by "${actionCommand.name()}". ` +
`Supported commands: ${[...JSON_COMMANDS].join(', ')}.`;
emitJsonError(null, { message, code: 'VALIDATION' });
process.exit(1);
}
});
Expand Down
24 changes: 18 additions & 6 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const AUTH_TYPES = ['basic', 'bearer', 'mtls', 'cookie', 'none'];

const { VALID_LINK_STYLES } = require('./link-style');
const { lookupNetrc, getNetrcPath } = require('./netrc');
const { isJsonMode } = require('./output');

const normalizeLinkStyle = (rawValue, source) => {
if (rawValue === undefined || rawValue === null || rawValue === '') {
Expand All @@ -62,9 +63,11 @@ const normalizeLinkStyle = (rawValue, source) => {
return value;
}
const label = source ? `${source} ` : '';
console.error(chalk.yellow(
`⚠ Invalid linkStyle ${label}"${rawValue}"; valid values: ${VALID_LINK_STYLES.join(', ')}. Falling back to auto-detection.`
));
if (!isJsonMode()) {
console.error(chalk.yellow(
`⚠ Invalid linkStyle ${label}"${rawValue}"; valid values: ${VALID_LINK_STYLES.join(', ')}. Falling back to auto-detection.`
));
}
return undefined;
};

Expand Down Expand Up @@ -254,7 +257,7 @@ const normalizeApiPath = (rawValue, domain) => {
};

// Read config file with backward compatibility for old flat format
function readConfigFile() {
function readConfigFile({ throwOnError = false } = {}) {
if (!fs.existsSync(CONFIG_FILE)) {
return null;
}
Expand Down Expand Up @@ -289,6 +292,7 @@ function readConfigFile() {

return raw;
} catch (error) {
if (throwOnError) throw error;
console.error(chalk.yellow(`⚠ Failed to parse config file at ${CONFIG_FILE}: ${error.message}`));
console.error(chalk.yellow(' Run "confluence init" to recreate it.'));
return null;
Expand Down Expand Up @@ -812,7 +816,7 @@ async function initConfig(cliOptions = {}) {
}
}

function getConfig(profileName) {
function getConfig(profileName, { throwOnError = false } = {}) {
const envDomain = process.env.CONFLUENCE_DOMAIN || process.env.CONFLUENCE_HOST;
const envToken = process.env.CONFLUENCE_API_TOKEN || process.env.CONFLUENCE_PASSWORD;
const envEmail = process.env.CONFLUENCE_EMAIL || process.env.CONFLUENCE_USERNAME;
Expand Down Expand Up @@ -848,6 +852,7 @@ function getConfig(profileName) {
try {
apiPath = normalizeApiPath(envApiPath, envDomain);
} catch (error) {
if (throwOnError) throw error;
console.error(chalk.red(`❌ ${error.message}`));
process.exit(1);
}
Expand All @@ -857,6 +862,7 @@ function getConfig(profileName) {
'CONFLUENCE_AUTH_TYPE=mtls'
);
if (authErrors.length > 0) {
if (throwOnError) throw new Error(authErrors.join(' '));
console.error(chalk.red(`❌ ${authErrors.join(' ')}`));
if (authType === 'basic' && !envEmail) {
console.log(chalk.yellow('Set CONFLUENCE_EMAIL (or CONFLUENCE_USERNAME for on-premise) or switch to bearer auth by setting CONFLUENCE_AUTH_TYPE=bearer.'));
Expand Down Expand Up @@ -890,9 +896,10 @@ function getConfig(profileName) {
|| process.env.CONFLUENCE_PROFILE
|| null;

const fileData = readConfigFile();
const fileData = readConfigFile({ throwOnError });

if (!fileData) {
if (throwOnError) throw new Error('No configuration found!');
console.error(chalk.red('❌ No configuration found!'));
console.log(chalk.yellow('Please run "confluence init" to set up your configuration.'));
console.log(chalk.gray('Or set environment variables: CONFLUENCE_DOMAIN, CONFLUENCE_API_TOKEN (or CONFLUENCE_PASSWORD), CONFLUENCE_EMAIL (or CONFLUENCE_USERNAME), and optionally CONFLUENCE_API_PATH, CONFLUENCE_PROTOCOL.'));
Expand All @@ -903,6 +910,7 @@ function getConfig(profileName) {
const storedConfig = fileData.profiles && fileData.profiles[targetProfile];

if (!storedConfig) {
if (throwOnError) throw new Error(`Profile "${targetProfile}" not found!`);
console.error(chalk.red(`❌ Profile "${targetProfile}" not found!`));
const available = fileData.profiles ? Object.keys(fileData.profiles) : [];
if (available.length > 0) {
Expand All @@ -922,6 +930,7 @@ function getConfig(profileName) {
let apiPath;

if (!trimmedDomain) {
if (throwOnError) throw new Error('Configuration file is missing required values.');
console.error(chalk.red('❌ Configuration file is missing required values.'));
console.log(chalk.yellow('Run "confluence init" to refresh your settings.'));
process.exit(1);
Expand All @@ -940,6 +949,7 @@ function getConfig(profileName) {
'mTLS authentication'
);
if (authErrors.length > 0) {
if (throwOnError) throw new Error(authErrors.join(' '));
console.error(chalk.red(`❌ ${authErrors.join(' ')}`));
if (netrcAttempted && !trimmedToken) {
console.log(chalk.yellow(
Expand All @@ -953,6 +963,7 @@ function getConfig(profileName) {
try {
apiPath = normalizeApiPath(storedConfig.apiPath, trimmedDomain);
} catch (error) {
if (throwOnError) throw error;
console.error(chalk.red(`❌ ${error.message}`));
console.log(chalk.yellow('Please rerun "confluence init" to update your API path.'));
process.exit(1);
Expand Down Expand Up @@ -983,6 +994,7 @@ function getConfig(profileName) {
linkStyle
};
} catch (error) {
if (throwOnError) throw error;
console.error(chalk.red('❌ Error reading configuration file:'), error.message);
console.log(chalk.yellow('Please run "confluence init" to recreate your configuration.'));
process.exit(1);
Expand Down
7 changes: 5 additions & 2 deletions lib/confluence-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const { Parser, DomHandler } = require('htmlparser2');
const { decodeHTML } = require('entities');
const MacroConverter = require('./macro-converter');
const { htmlToMarkdown, NAMED_ENTITIES } = require('./html-to-markdown');
const { isJsonMode } = require('./output');

const WRITE_FORMATS = ['auto', 'storage', 'html', 'markdown'];
const PAGE_LINK_LOOKUP_CONCURRENCY = 10;
Expand Down Expand Up @@ -288,7 +289,7 @@ class ConfluenceClient {
try {
const keyStats = fs.statSync(this.mtls.clientKey);
const keyMode = keyStats.mode & 0o777;
if (keyMode & 0o077) {
if ((keyMode & 0o077) && !isJsonMode()) {
console.error(
`Warning: Client key file "${this.mtls.clientKey}" has mode ${keyMode.toString(8)}. ` +
'Private keys should not be readable by other users (recommended: 0600). ' +
Expand Down Expand Up @@ -377,7 +378,9 @@ class ConfluenceClient {
}
} catch (error) {
// Ignore error and fall through
console.error('Error resolving page ID from display URL:', error);
if (!isJsonMode()) {
console.error('Error resolving page ID from display URL:', error);
}
}

throw new Error(`Could not resolve page ID from display URL: ${pageIdOrUrl}`);
Expand Down
5 changes: 4 additions & 1 deletion lib/netrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const fs = require('fs');
const path = require('path');
const os = require('os');
const chalk = require('chalk');
const { isJsonMode } = require('./output');

// Minimal reader for GNU .netrc files
// (https://www.gnu.org/software/inetutils/manual/html_node/The-_002enetrc-file.html).
Expand Down Expand Up @@ -101,7 +102,9 @@ function lookupNetrc({ machine, login } = {}) {
if (error.code === 'ENOENT') {
return null;
}
console.error(chalk.yellow(`⚠ Failed to read netrc file at ${filePath}: ${error.message}`));
if (!isJsonMode()) {
console.error(chalk.yellow(`⚠ Failed to read netrc file at ${filePath}: ${error.message}`));
}
return null;
}

Expand Down
Loading