From 71d65f50e650d971f09a331da84f89888a2f019d Mon Sep 17 00:00:00 2001 From: Ilia Dmitriev Date: Thu, 25 Jun 2026 23:05:40 +0300 Subject: [PATCH 1/2] feat: support XDG Base Directory Specification for config location Resolves config directory using XDG Base Directory spec: 1. CONFLUENCE_CONFIG_DIR env var (explicit override) 2. ~/.confluence-cli/ if it already exists (backwards compat) 3. $XDG_CONFIG_HOME/confluence-cli/ (defaults to ~/.config/confluence-cli/) New installs go to XDG path. Existing users at legacy path are unaffected. Exports getConfigDir(), getConfigFile(), and _resetConfigDirCache() for downstream consumers and testing. Updated analytics module to use getConfigDir() instead of its own hardcoded path, so stats.json follows config resolution. --- README.md | 22 ++++- lib/analytics.js | 4 +- lib/config.js | 43 +++++++++- tests/analytics.test.js | 24 ++++-- tests/config.test.js | 173 ++++++++++++++++++++++++++++++++++++++ tests/with-client.test.js | 2 + 6 files changed, 257 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index d7405d3..e969e46 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,24 @@ 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. +**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: + +1. `CONFLUENCE_CONFIG_DIR` env var — explicit override (e.g., `/custom/path`) +2. Legacy `~/.confluence-cli/` — if it already exists (backwards compatibility) +3. `$XDG_CONFIG_HOME/confluence-cli/` — defaults to `~/.config/confluence-cli/` + +New installations go to `~/.config/confluence-cli/` by default. Existing users at `~/.confluence-cli/` are unaffected — the CLI continues to use the legacy location until you move it. To migrate, simply move the directory: + +```bash +mkdir -p ~/.config/confluence-cli +mv ~/.confluence-cli/* ~/.config/confluence-cli/ +rmdir ~/.confluence-cli +``` + +The stats file (`stats.json`) follows the same resolution and lives alongside `config.json` in the same directory. + **Custom domains on Confluence Cloud:** If your Confluence Cloud instance uses a custom domain (e.g., `wiki.example.org` instead of `*.atlassian.net`), the CLI may misidentify it as a Server/Data Center instance and produce broken link formats. Set `CONFLUENCE_FORCE_CLOUD=true` to override the automatic detection: @@ -252,7 +270,7 @@ If your Confluence Cloud instance uses a custom domain (e.g., `wiki.example.org` export CONFLUENCE_FORCE_CLOUD=true ``` -Or add `"forceCloud": true` to your profile in `~/.confluence-cli/config.json`: +Or add `"forceCloud": true` to your profile in the config file (see [Config file location](#config-file-location)): ```json { @@ -1022,7 +1040,7 @@ Check out our [Contributing Guide](CONTRIBUTING.md) - all contributions are welc ### 📈 Usage Analytics -confluence-cli tracks command usage statistics **locally** on your machine (`~/.confluence-cli/stats.json`). No data is sent to any external server. This includes: +confluence-cli tracks command usage statistics **locally** on your machine (in `stats.json` alongside your config file — see [Config file location](#config-file-location) above). No data is sent to any external server. This includes: - Command usage counts (success/error) You can view your stats with `confluence stats`, or disable tracking by setting: `export CONFLUENCE_CLI_ANALYTICS=false` diff --git a/lib/analytics.js b/lib/analytics.js index e131e26..c3ba4c8 100644 --- a/lib/analytics.js +++ b/lib/analytics.js @@ -1,6 +1,6 @@ -const os = require('os'); const path = require('path'); const fs = require('fs'); +const { getConfigDir } = require('./config'); /** * Simple anonymous usage analytics @@ -9,7 +9,7 @@ const fs = require('fs'); class Analytics { constructor() { this.enabled = process.env.CONFLUENCE_CLI_ANALYTICS !== 'false'; - this.configDir = path.join(os.homedir(), '.confluence-cli'); + this.configDir = getConfigDir(); this.statsFile = path.join(this.configDir, 'stats.json'); } diff --git a/lib/config.js b/lib/config.js index 0ff7a39..dd329ee 100644 --- a/lib/config.js +++ b/lib/config.js @@ -4,10 +4,42 @@ const os = require('os'); const inquirer = require('inquirer'); const chalk = require('chalk'); -const CONFIG_DIR = path.join(os.homedir(), '.confluence-cli'); -const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json'); const DEFAULT_PROFILE = 'default'; +let _configDir = null; + +function resolveConfigDir() { + // Explicit override via environment variable + if (process.env.CONFLUENCE_CONFIG_DIR) { + return process.env.CONFLUENCE_CONFIG_DIR; + } + + const legacy = path.join(os.homedir(), '.confluence-cli'); + const xdgHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'); + const xdg = path.join(xdgHome, 'confluence-cli'); + + // If legacy dir already exists and XDG does not, keep using it for backwards compatibility + if (fs.existsSync(legacy) && !fs.existsSync(xdg)) { + return legacy; + } + + // XDG exists OR neither exists (fresh install) — use XDG + return xdg; +} + +function getConfigDir() { + if (!_configDir) _configDir = resolveConfigDir(); + return _configDir; +} + +function getConfigFile() { + return path.join(getConfigDir(), 'config.json'); +} + +// Eagerly resolve once for convenience exports +const CONFIG_DIR = getConfigDir(); +const CONFIG_FILE = getConfigFile(); + const AUTH_CHOICES = [ { name: 'Basic (credentials)', value: 'basic' }, { name: 'Bearer token', value: 'bearer' }, @@ -959,6 +991,10 @@ function deleteProfile(profileName) { saveConfigFile(fileData); } +function _resetConfigDirCache() { + _configDir = null; +} + module.exports = { initConfig, getConfig, @@ -966,6 +1002,9 @@ module.exports = { setActiveProfile, deleteProfile, isValidProfileName, + getConfigDir, + getConfigFile, + _resetConfigDirCache, CONFIG_DIR, CONFIG_FILE, DEFAULT_PROFILE diff --git a/tests/analytics.test.js b/tests/analytics.test.js index 940efc8..6c3a6a6 100644 --- a/tests/analytics.test.js +++ b/tests/analytics.test.js @@ -3,6 +3,8 @@ const os = require('os'); const path = require('path'); const ORIGINAL_ANALYTICS_ENV = process.env.CONFLUENCE_CLI_ANALYTICS; +const ORIGINAL_XDG_CONFIG_HOME = process.env.XDG_CONFIG_HOME; +const ORIGINAL_CONFLUENCE_CONFIG_DIR = process.env.CONFLUENCE_CONFIG_DIR; const removeDirRecursive = (dir) => { if (!dir || !fs.existsSync(dir)) return; @@ -32,6 +34,8 @@ describe('Analytics', () => { os.homedir = () => tempHome; delete process.env.CONFLUENCE_CLI_ANALYTICS; + delete process.env.XDG_CONFIG_HOME; + delete process.env.CONFLUENCE_CONFIG_DIR; jest.resetModules(); Analytics = require('../lib/analytics'); @@ -46,12 +50,22 @@ describe('Analytics', () => { } else { process.env.CONFLUENCE_CLI_ANALYTICS = ORIGINAL_ANALYTICS_ENV; } + if (ORIGINAL_XDG_CONFIG_HOME === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = ORIGINAL_XDG_CONFIG_HOME; + } + if (ORIGINAL_CONFLUENCE_CONFIG_DIR === undefined) { + delete process.env.CONFLUENCE_CONFIG_DIR; + } else { + process.env.CONFLUENCE_CONFIG_DIR = ORIGINAL_CONFLUENCE_CONFIG_DIR; + } }); describe('track', () => { test('creates the stats directory and file on first run', () => { const analytics = new Analytics(); - const expectedFile = path.join(tempHome, '.confluence-cli', 'stats.json'); + const expectedFile = path.join(tempHome, '.config', 'confluence-cli', 'stats.json'); analytics.track('create-page'); @@ -92,7 +106,7 @@ describe('Analytics', () => { // Force lastUsed to differ const earlier = new Date(Date.now() - 60_000).toISOString(); - const file = path.join(tempHome, '.confluence-cli', 'stats.json'); + const file = path.join(tempHome, '.config', 'confluence-cli', 'stats.json'); const tampered = { ...after1, firstUsed: earlier, lastUsed: earlier }; fs.writeFileSync(file, JSON.stringify(tampered)); @@ -111,11 +125,11 @@ describe('Analytics', () => { analytics.track('search'); - expect(fs.existsSync(path.join(tempHome, '.confluence-cli', 'stats.json'))).toBe(false); + expect(fs.existsSync(path.join(tempHome, '.config', 'confluence-cli', 'stats.json'))).toBe(false); }); test('does not throw when the stats file is corrupted JSON', () => { - const dir = path.join(tempHome, '.confluence-cli'); + const dir = path.join(tempHome, '.config', 'confluence-cli'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'stats.json'), '{not valid json'); @@ -132,7 +146,7 @@ describe('Analytics', () => { }); test('returns null when the stats file contains malformed JSON', () => { - const dir = path.join(tempHome, '.confluence-cli'); + const dir = path.join(tempHome, '.config', 'confluence-cli'); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'stats.json'), '{broken'); diff --git a/tests/config.test.js b/tests/config.test.js index 6dc1f77..5767db3 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -1,3 +1,6 @@ +const fs = require('fs'); +const path = require('path'); + const { getConfig, initConfig } = require('../lib/config'); // Save and restore all relevant env vars around each test @@ -421,3 +424,173 @@ describe('initConfig CLI option validation', () => { ); }); }); + +describe('config directory resolution', () => { + const HOMEDIR = '/mock/home'; + const XDG_HOME = '/mock/xdg'; + const LEGACY_PATH = path.join(HOMEDIR, '.confluence-cli'); + const XDG_PATH = path.join(HOMEDIR, '.config', 'confluence-cli'); + const CUSTOM_XDG_PATH = path.join(XDG_HOME, 'confluence-cli'); + const CUSTOM_PATH = '/custom/confluence-config'; + + const DIR_RESOLUTION_ENV_KEYS = [ + 'CONFLUENCE_CONFIG_DIR', + 'XDG_CONFIG_HOME', + ]; + + beforeEach(() => { + // Clean env vars that affect config dir resolution + for (const key of DIR_RESOLUTION_ENV_KEYS) { + delete process.env[key]; + } + }); + + function getFreshConfig() { + jest.resetModules(); + + // We must reset the mock before requiring the fresh module + jest.doMock('os', () => ({ + homedir: jest.fn(() => HOMEDIR), + })); + + return require('../lib/config'); + } + + test('fresh install resolves to XDG path', () => { + // Neither legacy nor XDG dir exists + jest.doMock('fs', () => ({ + existsSync: jest.fn(() => false), + readFileSync: jest.fn(), + writeFileSync: jest.fn(), + mkdirSync: jest.fn(), + chmodSync: jest.fn(), + })); + + const { CONFIG_DIR } = getFreshConfig(); + expect(CONFIG_DIR).toBe(XDG_PATH); + }); + + test('legacy dir takes precedence when XDG does not exist', () => { + // Legacy dir exists, XDG does not + jest.doMock('fs', () => ({ + existsSync: jest.fn((filePath) => filePath === LEGACY_PATH), + readFileSync: jest.fn(), + writeFileSync: jest.fn(), + mkdirSync: jest.fn(), + chmodSync: jest.fn(), + })); + + const { CONFIG_DIR } = getFreshConfig(); + expect(CONFIG_DIR).toBe(LEGACY_PATH); + }); + + test('XDG dir takes precedence when both exist', () => { + // Both legacy and XDG dir exist — user has migrated + jest.doMock('fs', () => ({ + existsSync: jest.fn(() => true), + readFileSync: jest.fn(), + writeFileSync: jest.fn(), + mkdirSync: jest.fn(), + chmodSync: jest.fn(), + })); + + const { CONFIG_DIR } = getFreshConfig(); + expect(CONFIG_DIR).toBe(XDG_PATH); + }); + + test('CONFLUENCE_CONFIG_DIR overrides all other paths', () => { + process.env.CONFLUENCE_CONFIG_DIR = CUSTOM_PATH; + + // Simulate legacy existing — should be ignored because CONFLUENCE_CONFIG_DIR is set + jest.doMock('fs', () => ({ + existsSync: jest.fn((filePath) => filePath === LEGACY_PATH), + readFileSync: jest.fn(), + writeFileSync: jest.fn(), + mkdirSync: jest.fn(), + chmodSync: jest.fn(), + })); + + const { CONFIG_DIR } = getFreshConfig(); + expect(CONFIG_DIR).toBe(CUSTOM_PATH); + }); + + test('XDG_CONFIG_HOME env var is respected on fresh install', () => { + process.env.XDG_CONFIG_HOME = XDG_HOME; + + jest.doMock('fs', () => ({ + existsSync: jest.fn(() => false), + readFileSync: jest.fn(), + writeFileSync: jest.fn(), + mkdirSync: jest.fn(), + chmodSync: jest.fn(), + })); + + const { CONFIG_DIR } = getFreshConfig(); + expect(CONFIG_DIR).toBe(CUSTOM_XDG_PATH); + }); + + test('XDG_CONFIG_HOME is ignored when legacy dir exists', () => { + process.env.XDG_CONFIG_HOME = XDG_HOME; + + // Legacy dir exists, custom XDG does not — legacy wins + jest.doMock('fs', () => ({ + existsSync: jest.fn((filePath) => { + if (filePath === LEGACY_PATH) return true; + if (filePath === CUSTOM_XDG_PATH) return false; + return false; + }), + readFileSync: jest.fn(), + writeFileSync: jest.fn(), + mkdirSync: jest.fn(), + chmodSync: jest.fn(), + })); + + const { CONFIG_DIR } = getFreshConfig(); + expect(CONFIG_DIR).toBe(LEGACY_PATH); + }); + + test('getConfigDir is exported and returns the resolved directory', () => { + jest.doMock('fs', () => ({ + existsSync: jest.fn(() => false), + readFileSync: jest.fn(), + writeFileSync: jest.fn(), + mkdirSync: jest.fn(), + chmodSync: jest.fn(), + })); + + const { getConfigDir } = getFreshConfig(); + expect(getConfigDir()).toBe(XDG_PATH); + }); + + test('getConfigFile is exported and returns the resolved file path', () => { + jest.doMock('fs', () => ({ + existsSync: jest.fn(() => false), + readFileSync: jest.fn(), + writeFileSync: jest.fn(), + mkdirSync: jest.fn(), + chmodSync: jest.fn(), + })); + + const { getConfigFile } = getFreshConfig(); + expect(getConfigFile()).toBe(path.join(XDG_PATH, 'config.json')); + }); + + test('_resetConfigDirCache allows re-resolution after env changes', () => { + jest.doMock('fs', () => ({ + existsSync: jest.fn(() => false), + readFileSync: jest.fn(), + writeFileSync: jest.fn(), + mkdirSync: jest.fn(), + chmodSync: jest.fn(), + })); + + const mod = getFreshConfig(); + expect(mod.getConfigDir()).toBe(XDG_PATH); + + // Simulate setting CONFLUENCE_CONFIG_DIR after first resolution + process.env.CONFLUENCE_CONFIG_DIR = '/new/path'; + // Without cache reset, it would return the old value + mod._resetConfigDirCache(); + expect(mod.getConfigDir()).toBe('/new/path'); + }); +}); diff --git a/tests/with-client.test.js b/tests/with-client.test.js index c0530b0..9439c0f 100644 --- a/tests/with-client.test.js +++ b/tests/with-client.test.js @@ -5,6 +5,8 @@ jest.mock('../lib/config', () => ({ setActiveProfile: jest.fn(), deleteProfile: jest.fn(), isValidProfileName: jest.fn(), + getConfigDir: jest.fn(() => '/mock/conf'), + getConfigFile: jest.fn(() => '/mock/conf/config.json'), })); jest.mock('../lib/confluence-client', () => { From d95571a86c2b52dbfa4d0e693da28f1cc05a396c Mon Sep 17 00:00:00 2001 From: Ilia Dmitriev Date: Fri, 26 Jun 2026 05:36:51 +0300 Subject: [PATCH 2/2] fix: remove unused fs import in config test --- tests/config.test.js | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/config.test.js b/tests/config.test.js index 5767db3..8f976f0 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -1,4 +1,3 @@ -const fs = require('fs'); const path = require('path'); const { getConfig, initConfig } = require('../lib/config');