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
22 changes: 20 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
{
Expand Down Expand Up @@ -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`
Expand Down
4 changes: 2 additions & 2 deletions lib/analytics.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
const os = require('os');
const path = require('path');
const fs = require('fs');
const { getConfigDir } = require('./config');

/**
* Simple anonymous usage analytics
Expand All @@ -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');
}

Expand Down
43 changes: 41 additions & 2 deletions lib/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -959,13 +991,20 @@ function deleteProfile(profileName) {
saveConfigFile(fileData);
}

function _resetConfigDirCache() {
_configDir = null;
}

module.exports = {
initConfig,
getConfig,
listProfiles,
setActiveProfile,
deleteProfile,
isValidProfileName,
getConfigDir,
getConfigFile,
_resetConfigDirCache,
CONFIG_DIR,
CONFIG_FILE,
DEFAULT_PROFILE
Expand Down
24 changes: 19 additions & 5 deletions tests/analytics.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand All @@ -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');

Expand Down Expand Up @@ -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));

Expand All @@ -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');

Expand All @@ -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');

Expand Down
172 changes: 172 additions & 0 deletions tests/config.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const path = require('path');

const { getConfig, initConfig } = require('../lib/config');

// Save and restore all relevant env vars around each test
Expand Down Expand Up @@ -421,3 +423,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');
});
});
2 changes: 2 additions & 0 deletions tests/with-client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down