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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,21 @@ export CONFLUENCE_API_TOKEN="your-scoped-token"

`CONFLUENCE_API_PATH` defaults to `/wiki/rest/api` for Atlassian Cloud domains and `/rest/api` otherwise. Override it when your site lives under a custom reverse proxy or on-premises path. `CONFLUENCE_AUTH_TYPE` defaults to `basic` when an email is present and falls back to `bearer` otherwise. For `mtls`, set `CONFLUENCE_TLS_CLIENT_CERT` and `CONFLUENCE_TLS_CLIENT_KEY`; `CONFLUENCE_TLS_CA_CERT` is optional.

### Option 4: `.netrc` file

To keep your API token out of `config.json`, store it in a standard [`.netrc`](https://www.gnu.org/software/inetutils/manual/html_node/The-_002enetrc-file.html) file (the same mechanism used by `curl` and Git). Configure a profile as usual but omit the token, then add an entry to your `~/.netrc`:

```
machine your-domain.atlassian.net
login your.email@example.com
password your-api-token
```

- The `machine` must match the profile's domain, and `login` must match the profile's email (basic auth). For bearer auth (no email), only the `machine` is matched.
- The token is resolved with this precedence: environment variable / `--token` → profile `token` in `config.json` → `.netrc`. A token from a higher-priority source wins.
- `.netrc` supplies the token for `basic` and `bearer` auth only (not `mtls`, `cookie`, or `none`).
- The file location is `~/.netrc` (`~/_netrc` on Windows), or the path in the `NETRC` environment variable if set.

**Config file location:**

confluence-cli supports the [XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/latest/). The config directory is resolved in this order:
Expand Down
76 changes: 60 additions & 16 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ const AUTH_CHOICES = [
const AUTH_TYPES = ['basic', 'bearer', 'mtls', 'cookie', 'none'];

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

const normalizeLinkStyle = (rawValue, source) => {
if (rawValue === undefined || rawValue === null || rawValue === '') {
Expand Down Expand Up @@ -89,6 +90,24 @@ const normalizeProtocol = (rawValue) => {
return 'https';
};

// Reduce a domain to its bare host: strip the scheme and everything from the
// first slash onward.
const extractHost = (rawValue) => {
return (rawValue || '')
.trim()
.replace(/^https?:\/\//i, '')
.replace(/\/.*$/, '');
};

// Normalize a domain into the authority used to build the base URL: strip the
// scheme and any trailing slashes but preserve a context path.
const normalizeDomainForBaseUrl = (rawValue) => {
return (rawValue || '')
.trim()
.replace(/^https?:\/\//i, '')
.replace(/\/+$/, '');
};

const normalizeAuthType = (rawValue, hasEmail) => {
const normalized = (rawValue || '').trim().toLowerCase();
if (AUTH_TYPES.includes(normalized)) {
Expand Down Expand Up @@ -210,12 +229,8 @@ const mtlsCertQuestion = (name, message, required, whenFn) => ({
});

const inferApiPath = (domain) => {
if (!domain) {
return '/rest/api';
}

const normalizedDomain = domain.trim().toLowerCase();
if (normalizedDomain.endsWith('.atlassian.net')) {
const host = extractHost(domain);
if (host.endsWith('.atlassian.net')) {
return '/wiki/rest/api';
}

Expand Down Expand Up @@ -355,7 +370,7 @@ const validateCliOptions = (options) => {
// Helper function to save configuration with validation
const saveConfig = (configData, profileName) => {
const config = {
domain: configData.domain.trim(),
domain: normalizeDomainForBaseUrl(configData.domain),
protocol: normalizeProtocol(configData.protocol),
apiPath: normalizeApiPath(configData.apiPath, configData.domain),
authType: configData.authType
Expand Down Expand Up @@ -493,12 +508,11 @@ const promptForMissingValues = async (providedValues) => {
questions.push({
type: 'password',
name: 'token',
message: 'API token / password:',
message: 'API token / password (optional, can be left blank):',
when: (responses) => {
const authType = providedValues.authType || responses.authType;
return authType !== 'mtls' && authType !== 'cookie' && authType !== 'none';
},
validate: requiredInput('API token / password')
}
});
}

Expand Down Expand Up @@ -540,6 +554,24 @@ const promptForMissingValues = async (providedValues) => {
return { ...providedValues, ...answers };
};

// Resolve a token from ~/.netrc as a fallback for basic/bearer auth. Matches the
// entry by host and, for basic auth, by email (login).
//
// Returns { token, attempted } where `attempted` reports whether a netrc lookup
// actually ran (basic/bearer with a resolvable host).
const resolveNetrcToken = (authType, domain, email) => {
if (authType !== 'basic' && authType !== 'bearer') {
return { token: undefined, attempted: false };
}
const host = extractHost(domain);
if (!host) {
return { token: undefined, attempted: false };
}
const login = authType === 'basic' ? email : undefined;
const entry = lookupNetrc({ machine: host, login });
return { token: entry ? entry.password : undefined, attempted: true };
};

async function initConfig(cliOptions = {}) {
const profileName = cliOptions.profile;

Expand Down Expand Up @@ -635,9 +667,8 @@ async function initConfig(cliOptions = {}) {
{
type: 'password',
name: 'token',
message: 'API token / password:',
when: (responses) => responses.authType !== 'mtls' && responses.authType !== 'cookie' && responses.authType !== 'none',
validate: requiredInput('API token / password')
message: 'API token / password (optional, can be left blank):',
when: (responses) => responses.authType !== 'mtls' && responses.authType !== 'cookie' && responses.authType !== 'none'
},
{
type: 'password',
Expand Down Expand Up @@ -839,7 +870,7 @@ function getConfig(profileName) {
}

return {
domain: envDomain.trim(),
domain: normalizeDomainForBaseUrl(envDomain),
protocol: normalizeProtocol(envProtocol),
apiPath,
token: envToken ? envToken.trim() : undefined,
Expand Down Expand Up @@ -881,8 +912,8 @@ function getConfig(profileName) {
}

try {
const trimmedDomain = (storedConfig.domain || '').trim();
const trimmedToken = trimOptional(storedConfig.token);
const trimmedDomain = normalizeDomainForBaseUrl(storedConfig.domain);
let trimmedToken = trimOptional(storedConfig.token);
const trimmedEmail = storedConfig.email ? storedConfig.email.trim() : undefined;
const trimmedCookie = trimOptional(storedConfig.cookie);
const authType = normalizeAuthType(storedConfig.authType, Boolean(trimmedEmail));
Expand All @@ -895,12 +926,25 @@ function getConfig(profileName) {
process.exit(1);
}

// Fall back to ~/.netrc for the token when the profile has none stored.
let netrcAttempted = false;
if (!trimmedToken) {
const netrc = resolveNetrcToken(authType, trimmedDomain, trimmedEmail);
trimmedToken = netrc.token;
netrcAttempted = netrc.attempted;
}

const authErrors = validateAuthConfig(
{ authType, token: trimmedToken, email: trimmedEmail, cookie: trimmedCookie, mtls, protocol: storedConfig.protocol },
'mTLS authentication'
);
if (authErrors.length > 0) {
console.error(chalk.red(`❌ ${authErrors.join(' ')}`));
if (netrcAttempted && !trimmedToken) {
console.log(chalk.yellow(
`No profile token found, and no matching ${getNetrcPath()} entry for machine "${extractHost(trimmedDomain)}".`
));
}
console.log(chalk.yellow('Please rerun "confluence init" to refresh your settings.'));
process.exit(1);
}
Expand Down
122 changes: 122 additions & 0 deletions lib/netrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const chalk = require('chalk');

// Minimal reader for GNU .netrc files
// (https://www.gnu.org/software/inetutils/manual/html_node/The-_002enetrc-file.html).
// netrc supplies only the token/password; the machine (host) and login come from
// the CLI's own configuration.

// Resolve the .netrc path: $NETRC if set, else ~/.netrc (~/_netrc on Windows).
function getNetrcPath() {
if (process.env.NETRC) {
return process.env.NETRC;
}
const base = process.platform === 'win32' ? '_netrc' : '.netrc';
return path.join(os.homedir(), base);
}

function tokenizeLine(line) {
const tokens = [];
const pattern = /"((?:[^"\\]|\\.)*)"|(\S+)/g;
let match;
while ((match = pattern.exec(line)) !== null) {
tokens.push(match[1] !== undefined ? match[1].replace(/\\(.)/g, '$1') : match[2]);
}
return tokens;
}

// Parse .netrc contents into an array of { machine, login, password } entries.
// A new entry starts at each `machine` (or `default`) token; `macdef` macro
// bodies are skipped up to the next blank line; comment lines and unknown
// tokens are ignored.
function parseNetrc(data) {
const entries = [];
let current = null;
let inMacro = false;

for (const line of data.split('\n')) {
if (inMacro) {
// A macro body runs until the first empty line.
if (line.trim() === '') {
inMacro = false;
}
continue;
}

if (line.trimStart().startsWith('#')) {
continue;
}

const fields = tokenizeLine(line);
for (let i = 0; i < fields.length; i++) {
const token = fields[i];
switch (token) {
case 'machine':
current = { machine: fields[++i], login: undefined, password: undefined };
entries.push(current);
break;
case 'default':
// Applies to any machine; recorded with a null host so host matching
// never selects it (no default fallback).
current = { machine: null, login: undefined, password: undefined };
entries.push(current);
break;
case 'macdef':
inMacro = true;
i = fields.length;
break;
case 'login':
if (current) current.login = fields[++i];
break;
case 'password':
if (current) current.password = fields[++i];
break;
default:
// Ignore account, port, and any other unrecognized tokens (and their value).
i++;
break;
}
}
}

return entries;
}

// Read ~/.netrc and return the first entry matching `machine` (host, compared
// case-insensitively) and, when `login` is provided, `login`. Returns null when
// the file is absent or no entry matches. Other read errors emit a warning.
function lookupNetrc({ machine, login } = {}) {
const host = (machine || '').trim().toLowerCase();
if (!host) {
return null;
}

const filePath = getNetrcPath();
let data;
try {
data = fs.readFileSync(filePath, 'utf8');
} catch (error) {
if (error.code === 'ENOENT') {
return null;
}
console.error(chalk.yellow(`⚠ Failed to read netrc file at ${filePath}: ${error.message}`));
return null;
}

const entries = parseNetrc(data);
const match = entries.find((entry) =>
entry.machine
&& entry.machine.toLowerCase() === host
&& (login == null || entry.login === login)
);

if (!match) {
return null;
}

return { machine: match.machine, login: match.login, password: match.password };
}

module.exports = { getNetrcPath, parseNetrc, lookupNetrc };
2 changes: 2 additions & 0 deletions plugins/confluence/skills/confluence/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ Config resolution works in two stages:
- **Direct env config:** If both `CONFLUENCE_DOMAIN` and `CONFLUENCE_API_TOKEN` are set, they are used directly and the config file / profiles are not consulted.
- **Profile-based config:** Otherwise, a profile is selected in this order: `--profile` flag > `CONFLUENCE_PROFILE` env > `activeProfile` in config > `default`.

For `basic`/`bearer` profiles that omit a stored `token`, the token falls back to a `~/.netrc` entry matched by domain (and email for basic auth) — env/`--token` still take precedence. Override the path with `NETRC`.

**Non-interactive init (good for CI/CD scripts):**

```sh
Expand Down
19 changes: 19 additions & 0 deletions tests/config.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,25 @@ describe('getConfig env var aliases', () => {
expect(config.domain).toBe('host.example.com');
});

test('infers the Cloud API path and strips a trailing slash from the domain', () => {
process.env.CONFLUENCE_DOMAIN = 'cloud.atlassian.net/';
process.env.CONFLUENCE_API_TOKEN = 'token';

const config = getConfig();
expect(config.apiPath).toBe('/wiki/rest/api');
expect(config.domain).toBe('cloud.atlassian.net');
});

test('preserves an on-prem context path in the env domain for URL building', () => {
process.env.CONFLUENCE_DOMAIN = 'wiki.example.com/confluence';
process.env.CONFLUENCE_API_TOKEN = 'token';
process.env.CONFLUENCE_AUTH_TYPE = 'bearer';

const config = getConfig();
expect(config.domain).toBe('wiki.example.com/confluence');
expect(config.apiPath).toBe('/rest/api');
});

test('protocol defaults to https when CONFLUENCE_PROTOCOL is not set', () => {
process.env.CONFLUENCE_DOMAIN = 'example.com';
process.env.CONFLUENCE_API_TOKEN = 'token';
Expand Down
Loading