From b718863a53eb253fe027b03462d04ac6818a0f15 Mon Sep 17 00:00:00 2001 From: Nikolaos Karaolidis Date: Thu, 16 Jul 2026 10:36:37 +0000 Subject: [PATCH] feat: add NETRC support Signed-off-by: Nikolaos Karaolidis --- README.md | 15 + lib/config.js | 76 ++++- lib/netrc.js | 122 +++++++ plugins/confluence/skills/confluence/SKILL.md | 2 + tests/config.test.js | 19 ++ tests/netrc.test.js | 315 ++++++++++++++++++ 6 files changed, 533 insertions(+), 16 deletions(-) create mode 100644 lib/netrc.js create mode 100644 tests/netrc.test.js diff --git a/README.md b/README.md index 182d9d0..91dea39 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/lib/config.js b/lib/config.js index d38b240..b6ff445 100644 --- a/lib/config.js +++ b/lib/config.js @@ -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 === '') { @@ -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)) { @@ -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'; } @@ -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 @@ -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') + } }); } @@ -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; @@ -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', @@ -839,7 +870,7 @@ function getConfig(profileName) { } return { - domain: envDomain.trim(), + domain: normalizeDomainForBaseUrl(envDomain), protocol: normalizeProtocol(envProtocol), apiPath, token: envToken ? envToken.trim() : undefined, @@ -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)); @@ -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); } diff --git a/lib/netrc.js b/lib/netrc.js new file mode 100644 index 0000000..d7f0722 --- /dev/null +++ b/lib/netrc.js @@ -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 }; diff --git a/plugins/confluence/skills/confluence/SKILL.md b/plugins/confluence/skills/confluence/SKILL.md index e08db22..e4eee5b 100644 --- a/plugins/confluence/skills/confluence/SKILL.md +++ b/plugins/confluence/skills/confluence/SKILL.md @@ -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 diff --git a/tests/config.test.js b/tests/config.test.js index 8f976f0..02302da 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -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'; diff --git a/tests/netrc.test.js b/tests/netrc.test.js new file mode 100644 index 0000000..f5ab720 --- /dev/null +++ b/tests/netrc.test.js @@ -0,0 +1,315 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { parseNetrc, lookupNetrc, getNetrcPath } = require('../lib/netrc'); + +describe('parseNetrc', () => { + test('parses a single-line entry', () => { + const entries = parseNetrc('machine example.atlassian.net login me@example.com password tok123'); + expect(entries).toEqual([ + { machine: 'example.atlassian.net', login: 'me@example.com', password: 'tok123' } + ]); + }); + + test('parses a multi-line entry', () => { + const data = [ + 'machine example.atlassian.net', + ' login me@example.com', + ' password tok123', + ].join('\n'); + expect(parseNetrc(data)).toEqual([ + { machine: 'example.atlassian.net', login: 'me@example.com', password: 'tok123' } + ]); + }); + + test('parses multiple machines', () => { + const data = [ + 'machine one.example.com login a password p1', + 'machine two.example.com login b password p2', + ].join('\n'); + expect(parseNetrc(data)).toEqual([ + { machine: 'one.example.com', login: 'a', password: 'p1' }, + { machine: 'two.example.com', login: 'b', password: 'p2' }, + ]); + }); + + test('parses double-quoted values containing whitespace', () => { + const entries = parseNetrc('machine example.atlassian.net login "me@example.com" password "tok en 123"'); + expect(entries).toEqual([ + { machine: 'example.atlassian.net', login: 'me@example.com', password: 'tok en 123' } + ]); + }); + + test('unescapes backslash sequences inside quoted values', () => { + const entries = parseNetrc('machine host.example.com password "a\\"b"'); + expect(entries).toEqual([ + { machine: 'host.example.com', login: undefined, password: 'a"b' } + ]); + }); + + test('records an entry without a login (bearer style)', () => { + const entries = parseNetrc('machine pat.example.com password patToken'); + expect(entries).toEqual([ + { machine: 'pat.example.com', login: undefined, password: 'patToken' } + ]); + }); + + test('skips macdef macro bodies', () => { + const data = [ + 'macdef init', + ' put file1', + ' put file2', + '', + 'machine real.example.com login u password realpw', + ].join('\n'); + expect(parseNetrc(data)).toEqual([ + { machine: 'real.example.com', login: 'u', password: 'realpw' } + ]); + }); + + test('skips comment lines so a commented keyword cannot hijack an entry', () => { + const data = [ + 'machine real.example.com', + ' # my machine token below', + ' password pw', + ].join('\n'); + expect(parseNetrc(data)).toEqual([ + { machine: 'real.example.com', login: undefined, password: 'pw' } + ]); + }); + + test('ignores unknown tokens such as account and port', () => { + const data = 'machine host.example.com login u account acct port 8443 password pw'; + expect(parseNetrc(data)).toEqual([ + { machine: 'host.example.com', login: 'u', password: 'pw' } + ]); + }); + + test('records a default entry with a null host', () => { + const entries = parseNetrc('default login anyone password fallbackpw'); + expect(entries).toEqual([ + { machine: null, login: 'anyone', password: 'fallbackpw' } + ]); + }); +}); + +describe('getNetrcPath', () => { + const saved = process.env.NETRC; + afterEach(() => { + if (saved === undefined) delete process.env.NETRC; + else process.env.NETRC = saved; + }); + + test('honors the NETRC environment variable', () => { + process.env.NETRC = '/custom/location/.netrc'; + expect(getNetrcPath()).toBe('/custom/location/.netrc'); + }); + + test('defaults to a file in the home directory when NETRC is unset', () => { + delete process.env.NETRC; + const base = process.platform === 'win32' ? '_netrc' : '.netrc'; + expect(getNetrcPath()).toBe(path.join(os.homedir(), base)); + }); +}); + +describe('lookupNetrc', () => { + let tmpDir; + let netrcFile; + const savedNetrc = process.env.NETRC; + + beforeAll(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'netrc-lookup-')); + netrcFile = path.join(tmpDir, '.netrc'); + fs.writeFileSync(netrcFile, [ + 'machine one.example.com login alice password secret1', + 'machine one.example.com login bob password secret2', + 'machine PAT.Example.COM password bearerToken', + ].join('\n')); + process.env.NETRC = netrcFile; + }); + + afterAll(() => { + if (savedNetrc === undefined) delete process.env.NETRC; + else process.env.NETRC = savedNetrc; + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('matches by machine and login', () => { + expect(lookupNetrc({ machine: 'one.example.com', login: 'bob' })).toEqual({ + machine: 'one.example.com', login: 'bob', password: 'secret2' + }); + }); + + test('matches by machine only when login is omitted (returns first entry)', () => { + expect(lookupNetrc({ machine: 'one.example.com' })).toEqual({ + machine: 'one.example.com', login: 'alice', password: 'secret1' + }); + }); + + test('returns null when the login does not match', () => { + expect(lookupNetrc({ machine: 'one.example.com', login: 'carol' })).toBeNull(); + }); + + test('returns null for an unknown machine', () => { + expect(lookupNetrc({ machine: 'nope.example.com', login: 'alice' })).toBeNull(); + }); + + test('matches the host case-insensitively', () => { + expect(lookupNetrc({ machine: 'pat.example.com' })).toEqual({ + machine: 'PAT.Example.COM', login: undefined, password: 'bearerToken' + }); + }); + + test('returns null when no machine is provided', () => { + expect(lookupNetrc({})).toBeNull(); + }); + + test('returns null when the file does not exist', () => { + process.env.NETRC = path.join(tmpDir, 'does-not-exist'); + expect(lookupNetrc({ machine: 'one.example.com' })).toBeNull(); + process.env.NETRC = netrcFile; + }); +}); + +describe('getConfig netrc token fallback', () => { + let tmpDir; + let configDir; + let netrcFile; + const savedEnv = {}; + const ENV_KEYS = [ + 'NETRC', 'CONFLUENCE_CONFIG_DIR', + 'CONFLUENCE_DOMAIN', 'CONFLUENCE_HOST', + 'CONFLUENCE_API_TOKEN', 'CONFLUENCE_PASSWORD', + 'CONFLUENCE_EMAIL', 'CONFLUENCE_USERNAME', + 'CONFLUENCE_AUTH_TYPE', 'CONFLUENCE_PROFILE', + ]; + + const writeConfig = (profiles, activeProfile = 'default') => { + fs.writeFileSync( + path.join(configDir, 'config.json'), + JSON.stringify({ activeProfile, profiles }, null, 2) + ); + }; + + const writeNetrc = (contents) => { + fs.writeFileSync(netrcFile, contents); + }; + + // Re-require config fresh so the cached CONFIG_DIR/CONFIG_FILE pick up + // CONFLUENCE_CONFIG_DIR, then run getConfig. + const loadConfig = () => { + jest.resetModules(); + return require('../lib/config'); + }; + + beforeEach(() => { + for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'netrc-config-')); + configDir = path.join(tmpDir, 'config'); + fs.mkdirSync(configDir); + netrcFile = path.join(tmpDir, '.netrc'); + process.env.CONFLUENCE_CONFIG_DIR = configDir; + process.env.NETRC = netrcFile; + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + for (const key of ENV_KEYS) { + if (savedEnv[key] === undefined) delete process.env[key]; + else process.env[key] = savedEnv[key]; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + test('fills a basic-auth token from netrc when the profile has none', () => { + writeConfig({ + default: { domain: 'example.atlassian.net', authType: 'basic', email: 'me@example.com' } + }); + writeNetrc('machine example.atlassian.net login me@example.com password netrcSecret'); + + const { getConfig } = loadConfig(); + const config = getConfig(); + expect(config.token).toBe('netrcSecret'); + expect(config.authType).toBe('basic'); + expect(config.email).toBe('me@example.com'); + }); + + test('fills a bearer token from netrc via machine-only match', () => { + writeConfig({ + default: { domain: 'onprem.example.com', authType: 'bearer' } + }); + writeNetrc('machine onprem.example.com password patToken'); + + const { getConfig } = loadConfig(); + expect(getConfig().token).toBe('patToken'); + }); + + test('fills a bearer token from netrc even when a stale email lingers in the profile', () => { + writeConfig({ + default: { domain: 'onprem.example.com', authType: 'bearer', email: 'stale@example.com' } + }); + writeNetrc('machine onprem.example.com password patToken'); + + const { getConfig } = loadConfig(); + expect(getConfig().token).toBe('patToken'); + }); + + test('preserves an on-prem context path while matching netrc by host', () => { + writeConfig({ + default: { domain: 'wiki.example.com/confluence', authType: 'bearer' } + }); + writeNetrc('machine wiki.example.com password patToken'); + + const { getConfig } = loadConfig(); + const config = getConfig(); + expect(config.token).toBe('patToken'); + expect(config.domain).toBe('wiki.example.com/confluence'); + expect(config.apiPath).toBe('/rest/api'); + }); + + test('a stored profile token takes precedence over netrc', () => { + writeConfig({ + default: { domain: 'example.atlassian.net', authType: 'basic', email: 'me@example.com', token: 'storedToken' } + }); + writeNetrc('machine example.atlassian.net login me@example.com password netrcSecret'); + + const { getConfig } = loadConfig(); + expect(getConfig().token).toBe('storedToken'); + }); + + test('does not consult netrc for a basic profile whose email does not match', () => { + writeConfig({ + default: { domain: 'example.atlassian.net', authType: 'basic', email: 'me@example.com' } + }); + writeNetrc('machine example.atlassian.net login someone-else@example.com password netrcSecret'); + + const exitSpy = jest.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit ${code}`); + }); + const { getConfig } = loadConfig(); + expect(() => getConfig()).toThrow('process.exit 1'); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + test('does not use netrc for mTLS auth', () => { + writeConfig({ + default: { + domain: 'example.atlassian.net', + authType: 'mtls', + mtls: { clientCert: netrcFile, clientKey: netrcFile } + } + }); + writeNetrc('machine example.atlassian.net login me@example.com password netrcSecret'); + + const { getConfig } = loadConfig(); + const config = getConfig(); + expect(config.authType).toBe('mtls'); + expect(config.token).toBeUndefined(); + }); +});