confluence-cli 1.29.1 → 1.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -102,7 +102,7 @@ This creates `.claude/skills/confluence/SKILL.md` in your current directory. Cla
102
102
  confluence init
103
103
  ```
104
104
 
105
- The wizard helps you choose the right API endpoint and authentication method. It recommends `/wiki/rest/api` for Atlassian Cloud domains (e.g., `*.atlassian.net`) and `/rest/api` for self-hosted/Data Center instances, then prompts for Basic (email/username + token/password) or Bearer authentication.
105
+ The wizard helps you choose the right API endpoint and authentication method. It recommends `/wiki/rest/api` for Atlassian Cloud domains (e.g., `*.atlassian.net`) and `/rest/api` for self-hosted/Data Center instances, then prompts for Basic (email/username + token/password), Bearer, or client-certificate (mTLS) authentication.
106
106
 
107
107
  ### Option 2: Non-interactive Setup (CLI Flags)
108
108
 
@@ -138,6 +138,17 @@ confluence --profile staging init \
138
138
  --token "your-personal-access-token"
139
139
  ```
140
140
 
141
+ **mTLS profile** (self-hosted or reverse-proxied Confluence APIs):
142
+ ```bash
143
+ confluence --profile corp init \
144
+ --domain "docs.example.com" \
145
+ --api-path "/confluence/rest/api" \
146
+ --auth-type "mtls" \
147
+ --tls-client-cert "~/.certs/client.pem" \
148
+ --tls-client-key "~/.certs/client.key" \
149
+ --tls-ca-cert "~/.certs/ca-chain.pem"
150
+ ```
151
+
141
152
  **Hybrid mode** (some fields provided, rest via prompts):
142
153
  ```bash
143
154
  # Domain and token provided, will prompt for auth method and email
@@ -150,9 +161,12 @@ confluence init --email "user@example.com" --token "your-api-token"
150
161
  **Available flags:**
151
162
  - `-d, --domain <domain>` - Confluence domain (e.g., `company.atlassian.net`)
152
163
  - `-p, --api-path <path>` - REST API path (e.g., `/wiki/rest/api`)
153
- - `-a, --auth-type <type>` - Authentication type: `basic` or `bearer`
164
+ - `-a, --auth-type <type>` - Authentication type: `basic`, `bearer`, or `mtls`
154
165
  - `-e, --email <email>` - Email or username for basic authentication
155
166
  - `-t, --token <token>` - API token or password
167
+ - `--tls-client-cert <path>` - Client certificate for mTLS authentication
168
+ - `--tls-client-key <path>` - Client private key for mTLS authentication
169
+ - `--tls-ca-cert <path>` - Optional CA certificate chain for mTLS authentication
156
170
  - `--read-only` - Enable read-only mode (blocks all write operations)
157
171
 
158
172
  ⚠️ **Security note:** While flags work, storing tokens in shell history is risky. Prefer environment variables (Option 3) for production environments.
@@ -169,6 +183,16 @@ export CONFLUENCE_AUTH_TYPE="basic"
169
183
  export CONFLUENCE_PROFILE="default"
170
184
  ```
171
185
 
186
+ **mTLS environment variables**:
187
+ ```bash
188
+ export CONFLUENCE_DOMAIN="docs.example.com"
189
+ export CONFLUENCE_API_PATH="/confluence/rest/api"
190
+ export CONFLUENCE_AUTH_TYPE="mtls"
191
+ export CONFLUENCE_TLS_CLIENT_CERT="~/.certs/client.pem"
192
+ export CONFLUENCE_TLS_CLIENT_KEY="~/.certs/client.key"
193
+ export CONFLUENCE_TLS_CA_CERT="~/.certs/ca-chain.pem" # optional
194
+ ```
195
+
172
196
  **Scoped API token** (recommended for agents):
173
197
  ```bash
174
198
  export CONFLUENCE_DOMAIN="api.atlassian.com"
@@ -178,7 +202,7 @@ export CONFLUENCE_EMAIL="user@example.com"
178
202
  export CONFLUENCE_API_TOKEN="your-scoped-token"
179
203
  ```
180
204
 
181
- `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.
205
+ `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.
182
206
 
183
207
  **Read-only mode** (recommended for AI agents):
184
208
  ```bash
@@ -224,6 +248,8 @@ For **read-only** usage, select at minimum: `read:confluence-content.all`, `read
224
248
 
225
249
  **On-premise / Data Center:** Use your Confluence username and password for basic authentication.
226
250
 
251
+ **mTLS-protected Confluence APIs:** Some self-hosted or reverse-proxied deployments authenticate at the TLS layer with a client certificate instead of sending an application-level token. In these environments, configure `authType=mtls` and provide certificate paths via CLI flags or environment variables. No `Authorization` header will be sent in mTLS mode.
252
+
227
253
  ## Usage
228
254
 
229
255
  ### Read a Page
package/bin/confluence.js CHANGED
@@ -34,9 +34,12 @@ program
34
34
  .option('-d, --domain <domain>', 'Confluence domain')
35
35
  .option('--protocol <protocol>', 'Protocol (http or https)')
36
36
  .option('-p, --api-path <path>', 'REST API path')
37
- .option('-a, --auth-type <type>', 'Authentication type (basic or bearer)')
37
+ .option('-a, --auth-type <type>', 'Authentication type (basic, bearer, or mtls)')
38
38
  .option('-e, --email <email>', 'Email or username for basic auth')
39
39
  .option('-t, --token <token>', 'API token')
40
+ .option('--tls-ca-cert <path>', 'CA certificate for mTLS connections')
41
+ .option('--tls-client-cert <path>', 'Client certificate for mTLS connections')
42
+ .option('--tls-client-key <path>', 'Client private key for mTLS connections')
40
43
  .option('--read-only', 'Set profile to read-only mode (blocks write operations)')
41
44
  .action(async (options) => {
42
45
  const profile = getProfileName();
@@ -1917,9 +1920,12 @@ profileCmd
1917
1920
  .option('-d, --domain <domain>', 'Confluence domain')
1918
1921
  .option('--protocol <protocol>', 'Protocol (http or https)')
1919
1922
  .option('-p, --api-path <path>', 'REST API path')
1920
- .option('-a, --auth-type <type>', 'Authentication type (basic or bearer)')
1923
+ .option('-a, --auth-type <type>', 'Authentication type (basic, bearer, or mtls)')
1921
1924
  .option('-e, --email <email>', 'Email or username for basic auth')
1922
1925
  .option('-t, --token <token>', 'API token')
1926
+ .option('--tls-ca-cert <path>', 'CA certificate for mTLS connections')
1927
+ .option('--tls-client-cert <path>', 'Client certificate for mTLS connections')
1928
+ .option('--tls-client-key <path>', 'Client private key for mTLS connections')
1923
1929
  .option('--read-only', 'Set profile to read-only mode (blocks write operations)')
1924
1930
  .action(async (name, options) => {
1925
1931
  if (!isValidProfileName(name)) {
package/lib/config.js CHANGED
@@ -10,7 +10,8 @@ const DEFAULT_PROFILE = 'default';
10
10
 
11
11
  const AUTH_CHOICES = [
12
12
  { name: 'Basic (credentials)', value: 'basic' },
13
- { name: 'Bearer token', value: 'bearer' }
13
+ { name: 'Bearer token', value: 'bearer' },
14
+ { name: 'Client certificate (mTLS)', value: 'mtls' }
14
15
  ];
15
16
 
16
17
  const isValidProfileName = (name) => /^[a-zA-Z0-9_-]+$/.test(name);
@@ -37,12 +38,92 @@ const normalizeProtocol = (rawValue) => {
37
38
 
38
39
  const normalizeAuthType = (rawValue, hasEmail) => {
39
40
  const normalized = (rawValue || '').trim().toLowerCase();
40
- if (normalized === 'basic' || normalized === 'bearer') {
41
+ if (normalized === 'basic' || normalized === 'bearer' || normalized === 'mtls') {
41
42
  return normalized;
42
43
  }
43
44
  return hasEmail ? 'basic' : 'bearer';
44
45
  };
45
46
 
47
+ const trimOptional = (value) => {
48
+ if (typeof value !== 'string') {
49
+ return undefined;
50
+ }
51
+ const trimmed = value.trim();
52
+ return trimmed || undefined;
53
+ };
54
+
55
+ const normalizeMtlsConfig = (mtls) => {
56
+ if (!mtls) {
57
+ return undefined;
58
+ }
59
+
60
+ const normalized = {
61
+ caCert: trimOptional(mtls.caCert),
62
+ clientCert: trimOptional(mtls.clientCert),
63
+ clientKey: trimOptional(mtls.clientKey),
64
+ };
65
+
66
+ if (!normalized.caCert && !normalized.clientCert && !normalized.clientKey) {
67
+ return undefined;
68
+ }
69
+
70
+ return normalized;
71
+ };
72
+
73
+ const validateMtlsConfig = (mtls, labelPrefix = 'mTLS') => {
74
+ const normalized = normalizeMtlsConfig(mtls);
75
+ if (!normalized) {
76
+ return [`${labelPrefix} requires a client certificate and client key.`];
77
+ }
78
+
79
+ const errors = [];
80
+ if (!normalized.clientCert) {
81
+ errors.push(`${labelPrefix} requires a client certificate.`);
82
+ } else if (!fs.existsSync(normalized.clientCert)) {
83
+ errors.push(`${labelPrefix} client certificate file not found: ${normalized.clientCert}`);
84
+ }
85
+ if (!normalized.clientKey) {
86
+ errors.push(`${labelPrefix} requires a client key.`);
87
+ } else if (!fs.existsSync(normalized.clientKey)) {
88
+ errors.push(`${labelPrefix} client key file not found: ${normalized.clientKey}`);
89
+ }
90
+ if (normalized.caCert && !fs.existsSync(normalized.caCert)) {
91
+ errors.push(`${labelPrefix} CA certificate file not found: ${normalized.caCert}`);
92
+ }
93
+ return errors;
94
+ };
95
+
96
+ const validateMtlsProtocol = (protocol) => {
97
+ if (normalizeProtocol(protocol) === 'http') {
98
+ return 'mTLS authentication requires HTTPS and is not compatible with HTTP.';
99
+ }
100
+ return null;
101
+ };
102
+
103
+ /**
104
+ * Build an inquirer question for an mTLS file path prompt.
105
+ * @param {string} name - answer key (e.g. 'tlsClientCert')
106
+ * @param {string} message - prompt text
107
+ * @param {boolean} required - whether the field is mandatory
108
+ * @param {Function} [whenFn] - optional custom `when` predicate; defaults to authType === 'mtls'
109
+ */
110
+ const mtlsCertQuestion = (name, message, required, whenFn) => ({
111
+ type: 'input',
112
+ name,
113
+ message,
114
+ when: whenFn || ((responses) => responses.authType === 'mtls'),
115
+ validate: (input) => {
116
+ const value = (input || '').trim();
117
+ if (!value) {
118
+ return required ? `${message.replace(/:$/, '')} is required for mTLS.` : true;
119
+ }
120
+ if (!fs.existsSync(value)) {
121
+ return `File not found: ${value}`;
122
+ }
123
+ return true;
124
+ }
125
+ });
126
+
46
127
  const inferApiPath = (domain) => {
47
128
  if (!domain) {
48
129
  return '/rest/api';
@@ -89,6 +170,10 @@ function readConfigFile() {
89
170
  token: raw.token,
90
171
  authType: raw.authType
91
172
  };
173
+ const mtls = normalizeMtlsConfig(raw.mtls);
174
+ if (mtls) {
175
+ profile.mtls = mtls;
176
+ }
92
177
  if (raw.email) {
93
178
  profile.email = raw.email;
94
179
  }
@@ -123,7 +208,7 @@ const validateCliOptions = (options) => {
123
208
  errors.push('--domain cannot be empty');
124
209
  }
125
210
 
126
- if (options.token && !options.token.trim()) {
211
+ if (options.token !== undefined && !options.token.trim()) {
127
212
  errors.push('--token cannot be empty');
128
213
  }
129
214
 
@@ -148,8 +233,8 @@ const validateCliOptions = (options) => {
148
233
  errors.push('--protocol must be "http" or "https"');
149
234
  }
150
235
 
151
- if (options.authType && !['basic', 'bearer'].includes(options.authType.toLowerCase())) {
152
- errors.push('--auth-type must be "basic" or "bearer"');
236
+ if (options.authType && !['basic', 'bearer', 'mtls'].includes(options.authType.toLowerCase())) {
237
+ errors.push('--auth-type must be "basic", "bearer", or "mtls"');
153
238
  }
154
239
 
155
240
  // Check if basic auth is provided with email
@@ -158,6 +243,16 @@ const validateCliOptions = (options) => {
158
243
  errors.push('--email is required when using basic authentication (use your username for on-premise)');
159
244
  }
160
245
 
246
+ if (normAuthType === 'mtls') {
247
+ validateMtlsConfig(options.mtls, '--auth-type mtls').forEach((error) => {
248
+ errors.push(error);
249
+ });
250
+ const protocolError = validateMtlsProtocol(options.protocol);
251
+ if (protocolError) {
252
+ errors.push(protocolError);
253
+ }
254
+ }
255
+
161
256
  return errors;
162
257
  };
163
258
 
@@ -167,14 +262,22 @@ const saveConfig = (configData, profileName) => {
167
262
  domain: configData.domain.trim(),
168
263
  protocol: normalizeProtocol(configData.protocol),
169
264
  apiPath: normalizeApiPath(configData.apiPath, configData.domain),
170
- token: configData.token.trim(),
171
265
  authType: configData.authType
172
266
  };
173
267
 
268
+ if (configData.token) {
269
+ config.token = configData.token.trim();
270
+ }
271
+
174
272
  if (configData.authType === 'basic' && configData.email) {
175
273
  config.email = configData.email.trim();
176
274
  }
177
275
 
276
+ const mtls = normalizeMtlsConfig(configData.mtls);
277
+ if (mtls) {
278
+ config.mtls = mtls;
279
+ }
280
+
178
281
  if (configData.readOnly) {
179
282
  config.readOnly = true;
180
283
  }
@@ -283,10 +386,30 @@ const promptForMissingValues = async (providedValues) => {
283
386
  type: 'password',
284
387
  name: 'token',
285
388
  message: 'API token / password:',
389
+ when: (responses) => {
390
+ const authType = providedValues.authType || responses.authType;
391
+ return authType !== 'mtls';
392
+ },
286
393
  validate: requiredInput('API token / password')
287
394
  });
288
395
  }
289
396
 
397
+ // mTLS certificate path questions
398
+ const mtls = normalizeMtlsConfig(providedValues.mtls);
399
+ const mtlsWhen = (responses) => {
400
+ const authType = providedValues.authType || responses.authType;
401
+ return authType === 'mtls';
402
+ };
403
+ if (!mtls || !mtls.clientCert) {
404
+ questions.push(mtlsCertQuestion('tlsClientCert', 'Path to client certificate file (PEM):', true, mtlsWhen));
405
+ }
406
+ if (!mtls || !mtls.clientKey) {
407
+ questions.push(mtlsCertQuestion('tlsClientKey', 'Path to client key file (PEM):', true, mtlsWhen));
408
+ }
409
+ if (!mtls || !mtls.caCert) {
410
+ questions.push(mtlsCertQuestion('tlsCaCert', 'Path to CA certificate file (PEM, optional):', false, mtlsWhen));
411
+ }
412
+
290
413
  if (questions.length === 0) {
291
414
  return providedValues;
292
415
  }
@@ -313,7 +436,12 @@ async function initConfig(cliOptions = {}) {
313
436
  apiPath: cliOptions.apiPath,
314
437
  authType: cliOptions.authType,
315
438
  email: cliOptions.email,
316
- token: cliOptions.token
439
+ token: cliOptions.token,
440
+ mtls: cliOptions.mtls || {
441
+ caCert: cliOptions.tlsCaCert,
442
+ clientCert: cliOptions.tlsClientCert,
443
+ clientKey: cliOptions.tlsClientKey,
444
+ }
317
445
  };
318
446
 
319
447
  // Check if any CLI options were provided
@@ -380,11 +508,24 @@ async function initConfig(cliOptions = {}) {
380
508
  type: 'password',
381
509
  name: 'token',
382
510
  message: 'API token / password:',
511
+ when: (responses) => responses.authType !== 'mtls',
383
512
  validate: requiredInput('API token / password')
384
- }
513
+ },
514
+ mtlsCertQuestion('tlsClientCert', 'Path to client certificate file (PEM):', true),
515
+ mtlsCertQuestion('tlsClientKey', 'Path to client key file (PEM):', true),
516
+ mtlsCertQuestion('tlsCaCert', 'Path to CA certificate file (PEM, optional):', false)
385
517
  ]);
386
518
 
387
- saveConfig({ ...answers, readOnly }, profileName);
519
+ const configData = { ...answers, readOnly };
520
+ if (answers.authType === 'mtls') {
521
+ configData.mtls = {
522
+ clientCert: answers.tlsClientCert,
523
+ clientKey: answers.tlsClientKey,
524
+ caCert: answers.tlsCaCert || undefined,
525
+ };
526
+ }
527
+
528
+ saveConfig(configData, profileName);
388
529
  return;
389
530
  }
390
531
 
@@ -403,8 +544,13 @@ async function initConfig(cliOptions = {}) {
403
544
  // Non-interactive requires: domain, token, and either authType or email (for inference)
404
545
  const hasRequiredValues = Boolean(
405
546
  providedValues.domain &&
406
- providedValues.token &&
407
- (providedValues.authType || providedValues.email)
547
+ (
548
+ providedValues.authType === 'mtls'
549
+ || (
550
+ providedValues.token &&
551
+ (providedValues.authType || providedValues.email)
552
+ )
553
+ )
408
554
  );
409
555
 
410
556
  if (hasRequiredValues) {
@@ -425,6 +571,11 @@ async function initConfig(cliOptions = {}) {
425
571
  process.exit(1);
426
572
  }
427
573
 
574
+ if (normalizedAuthType !== 'mtls' && !providedValues.token) {
575
+ console.error(chalk.red('❌ Token is required for basic or bearer authentication'));
576
+ process.exit(1);
577
+ }
578
+
428
579
  // Verify API path format if provided
429
580
  if (providedValues.apiPath) {
430
581
  normalizeApiPath(providedValues.apiPath, normalizedDomain);
@@ -437,6 +588,7 @@ async function initConfig(cliOptions = {}) {
437
588
  token: providedValues.token,
438
589
  authType: normalizedAuthType,
439
590
  email: providedValues.email,
591
+ mtls: providedValues.mtls,
440
592
  readOnly
441
593
  };
442
594
 
@@ -461,6 +613,15 @@ async function initConfig(cliOptions = {}) {
461
613
  // Normalize auth type
462
614
  mergedValues.authType = normalizeAuthType(mergedValues.authType, Boolean(mergedValues.email));
463
615
 
616
+ // Build mTLS config from prompted values if needed
617
+ if (mergedValues.authType === 'mtls') {
618
+ mergedValues.mtls = normalizeMtlsConfig({
619
+ clientCert: mergedValues.tlsClientCert || (mergedValues.mtls && mergedValues.mtls.clientCert),
620
+ clientKey: mergedValues.tlsClientKey || (mergedValues.mtls && mergedValues.mtls.clientKey),
621
+ caCert: mergedValues.tlsCaCert || (mergedValues.mtls && mergedValues.mtls.caCert),
622
+ });
623
+ }
624
+
464
625
  saveConfig({ ...mergedValues, readOnly }, profileName);
465
626
  } catch (error) {
466
627
  console.error(chalk.red(`❌ ${error.message}`));
@@ -476,9 +637,15 @@ function getConfig(profileName) {
476
637
  const envApiPath = process.env.CONFLUENCE_API_PATH;
477
638
  const envProtocol = process.env.CONFLUENCE_PROTOCOL;
478
639
  const envReadOnly = process.env.CONFLUENCE_READ_ONLY;
479
-
480
- if (envDomain && envToken) {
481
- const authType = normalizeAuthType(envAuthType, Boolean(envEmail));
640
+ const envMtls = normalizeMtlsConfig({
641
+ caCert: process.env.CONFLUENCE_TLS_CA_CERT,
642
+ clientCert: process.env.CONFLUENCE_TLS_CLIENT_CERT,
643
+ clientKey: process.env.CONFLUENCE_TLS_CLIENT_KEY,
644
+ });
645
+
646
+ if (envDomain && (envToken || envAuthType === 'mtls' || envMtls)) {
647
+ const inferredAuthType = envAuthType || (envMtls && !envToken ? 'mtls' : undefined);
648
+ const authType = normalizeAuthType(inferredAuthType, Boolean(envEmail));
482
649
  let apiPath;
483
650
 
484
651
  try {
@@ -494,13 +661,33 @@ function getConfig(profileName) {
494
661
  process.exit(1);
495
662
  }
496
663
 
664
+ if (authType !== 'mtls' && !envToken) {
665
+ console.error(chalk.red('❌ Bearer/basic authentication requires CONFLUENCE_API_TOKEN or CONFLUENCE_PASSWORD.'));
666
+ process.exit(1);
667
+ }
668
+
669
+ if (authType === 'mtls') {
670
+ const mtlsErrors = validateMtlsConfig(envMtls, 'CONFLUENCE_AUTH_TYPE=mtls');
671
+ if (mtlsErrors.length > 0) {
672
+ console.error(chalk.red(`❌ ${mtlsErrors.join(' ')}`));
673
+ console.log(chalk.yellow('Set CONFLUENCE_TLS_CLIENT_CERT and CONFLUENCE_TLS_CLIENT_KEY. Optionally set CONFLUENCE_TLS_CA_CERT.'));
674
+ process.exit(1);
675
+ }
676
+ if (normalizeProtocol(envProtocol) === 'http') {
677
+ const protocolError = validateMtlsProtocol(envProtocol);
678
+ console.error(chalk.red(`❌ ${protocolError}`));
679
+ process.exit(1);
680
+ }
681
+ }
682
+
497
683
  return {
498
684
  domain: envDomain.trim(),
499
685
  protocol: normalizeProtocol(envProtocol),
500
686
  apiPath,
501
- token: envToken.trim(),
687
+ token: envToken ? envToken.trim() : undefined,
502
688
  email: envEmail ? envEmail.trim() : undefined,
503
689
  authType,
690
+ mtls: envMtls,
504
691
  readOnly: envReadOnly === 'true'
505
692
  };
506
693
  }
@@ -534,12 +721,13 @@ function getConfig(profileName) {
534
721
 
535
722
  try {
536
723
  const trimmedDomain = (storedConfig.domain || '').trim();
537
- const trimmedToken = (storedConfig.token || '').trim();
724
+ const trimmedToken = trimOptional(storedConfig.token);
538
725
  const trimmedEmail = storedConfig.email ? storedConfig.email.trim() : undefined;
539
726
  const authType = normalizeAuthType(storedConfig.authType, Boolean(trimmedEmail));
727
+ const mtls = normalizeMtlsConfig(storedConfig.mtls);
540
728
  let apiPath;
541
729
 
542
- if (!trimmedDomain || !trimmedToken) {
730
+ if (!trimmedDomain || (authType !== 'mtls' && !trimmedToken)) {
543
731
  console.error(chalk.red('❌ Configuration file is missing required values.'));
544
732
  console.log(chalk.yellow('Run "confluence init" to refresh your settings.'));
545
733
  process.exit(1);
@@ -551,6 +739,21 @@ function getConfig(profileName) {
551
739
  process.exit(1);
552
740
  }
553
741
 
742
+ if (authType === 'mtls') {
743
+ const mtlsErrors = validateMtlsConfig(mtls, 'mTLS authentication');
744
+ if (mtlsErrors.length > 0) {
745
+ console.error(chalk.red(`❌ ${mtlsErrors.join(' ')}`));
746
+ console.log(chalk.yellow('Please rerun "confluence init" to add your mTLS certificate paths.'));
747
+ process.exit(1);
748
+ }
749
+ if (normalizeProtocol(storedConfig.protocol) === 'http') {
750
+ const protocolError = validateMtlsProtocol(storedConfig.protocol);
751
+ console.error(chalk.red(`❌ ${protocolError}`));
752
+ console.log(chalk.yellow('Please rerun "confluence init" to update your protocol to HTTPS.'));
753
+ process.exit(1);
754
+ }
755
+ }
756
+
554
757
  try {
555
758
  apiPath = normalizeApiPath(storedConfig.apiPath, trimmedDomain);
556
759
  } catch (error) {
@@ -570,6 +773,7 @@ function getConfig(profileName) {
570
773
  token: trimmedToken,
571
774
  email: trimmedEmail,
572
775
  authType,
776
+ mtls,
573
777
  readOnly
574
778
  };
575
779
  } catch (error) {
@@ -1,5 +1,6 @@
1
1
  const axios = require('axios');
2
2
  const fs = require('fs');
3
+ const https = require('https');
3
4
  const path = require('path');
4
5
  const FormData = require('form-data');
5
6
  const { convert } = require('html-to-text');
@@ -34,6 +35,7 @@ class ConfluenceClient {
34
35
  this.token = config.token;
35
36
  this.email = config.email;
36
37
  this.authType = (config.authType || (this.email ? 'basic' : 'bearer')).toLowerCase();
38
+ this.mtls = config.mtls;
37
39
  this.apiPath = this.sanitizeApiPath(config.apiPath);
38
40
  this.webUrlPrefix = this.apiPath.startsWith('/wiki/') ? '/wiki' : '';
39
41
  this.baseURL = `${this.protocol}://${this.domain}${this.apiPath}`;
@@ -41,14 +43,23 @@ class ConfluenceClient {
41
43
  this.setupConfluenceMarkdownExtensions();
42
44
 
43
45
  const headers = {
44
- 'Content-Type': 'application/json',
45
- 'Authorization': this.authType === 'basic' ? this.buildBasicAuthHeader() : `Bearer ${this.token}`
46
+ 'Content-Type': 'application/json'
46
47
  };
48
+ const authHeader = this.buildAuthHeader();
49
+ if (authHeader) {
50
+ headers.Authorization = authHeader;
51
+ }
47
52
 
48
- this.client = axios.create({
53
+ const clientOptions = {
49
54
  baseURL: this.baseURL,
50
55
  headers
51
- });
56
+ };
57
+ const httpsAgent = this.buildHttpsAgent();
58
+ if (httpsAgent) {
59
+ clientOptions.httpsAgent = httpsAgent;
60
+ }
61
+
62
+ this.client = axios.create(clientOptions);
52
63
 
53
64
  this.client.interceptors.response.use(
54
65
  response => response,
@@ -72,6 +83,10 @@ class ConfluenceClient {
72
83
  hints.push(
73
84
  'Please verify your username and password are correct.'
74
85
  );
86
+ } else if (this.authType === 'mtls') {
87
+ hints.push(
88
+ 'Please verify your client certificate, client key, and CA certificate are correct and trusted by the server.'
89
+ );
75
90
  } else {
76
91
  hints.push(
77
92
  'Please verify your personal access token is valid and not expired.'
@@ -115,6 +130,67 @@ class ConfluenceClient {
115
130
  return `Basic ${encodedCredentials}`;
116
131
  }
117
132
 
133
+ buildAuthHeader() {
134
+ if (this.authType === 'mtls') {
135
+ return null;
136
+ }
137
+
138
+ if (!this.token) {
139
+ throw new Error(`Authentication type "${this.authType}" requires a token or password.`);
140
+ }
141
+
142
+ return this.authType === 'basic' ? this.buildBasicAuthHeader() : `Bearer ${this.token}`;
143
+ }
144
+
145
+ buildHttpsAgent() {
146
+ if (this.protocol !== 'https' || !this.mtls) {
147
+ return null;
148
+ }
149
+
150
+ const options = {};
151
+
152
+ if (this.mtls.caCert) {
153
+ if (!fs.existsSync(this.mtls.caCert)) {
154
+ throw new Error(`CA certificate file not found: ${this.mtls.caCert}`);
155
+ }
156
+ options.ca = fs.readFileSync(this.mtls.caCert);
157
+ }
158
+ if (this.mtls.clientCert) {
159
+ if (!fs.existsSync(this.mtls.clientCert)) {
160
+ throw new Error(`Client certificate file not found: ${this.mtls.clientCert}`);
161
+ }
162
+ options.cert = fs.readFileSync(this.mtls.clientCert);
163
+ }
164
+ if (this.mtls.clientKey) {
165
+ if (!fs.existsSync(this.mtls.clientKey)) {
166
+ throw new Error(`Client key file not found: ${this.mtls.clientKey}`);
167
+ }
168
+ // Warn if private key file is readable by others (Unix only)
169
+ if (process.platform !== 'win32') {
170
+ try {
171
+ const keyStats = fs.statSync(this.mtls.clientKey);
172
+ const keyMode = keyStats.mode & 0o777;
173
+ if (keyMode & 0o077) {
174
+ console.error(
175
+ `Warning: Client key file "${this.mtls.clientKey}" has mode ${keyMode.toString(8)}. ` +
176
+ 'Private keys should not be readable by other users (recommended: 0600). ' +
177
+ `Fix with: chmod 600 "${this.mtls.clientKey}"`
178
+ );
179
+ }
180
+ } catch {
181
+ // Ignore stat errors — the read below will surface them
182
+ }
183
+ }
184
+ options.key = fs.readFileSync(this.mtls.clientKey);
185
+ }
186
+
187
+ if (Object.keys(options).length === 0) {
188
+ return null;
189
+ }
190
+
191
+ return new https.Agent(options);
192
+ }
193
+
118
194
  /**
119
195
  * Extract page ID from URL or return the ID if it's already a number
120
196
  */
@@ -136,6 +212,26 @@ class ConfluenceClient {
136
212
  return prettyMatch[1];
137
213
  }
138
214
 
215
+ // Handle tiny links (/wiki/x/<code>)
216
+ const tinyLinkMatch = pageIdOrUrl.match(/\/wiki\/x\/([A-Za-z0-9_-]+)/);
217
+ if (tinyLinkMatch) {
218
+ try {
219
+ const response = await this.client.get(pageIdOrUrl, {
220
+ maxRedirects: 0,
221
+ validateStatus: (status) => status >= 300 && status < 400
222
+ });
223
+ const redirectUrl = response.headers.location;
224
+ if (redirectUrl) {
225
+ return this.extractPageId(redirectUrl);
226
+ }
227
+ } catch (error) {
228
+ if (error.response && error.response.headers && error.response.headers.location) {
229
+ return this.extractPageId(error.response.headers.location);
230
+ }
231
+ }
232
+ throw new Error(`Could not resolve page ID from tiny link: ${pageIdOrUrl}`);
233
+ }
234
+
139
235
  // Handle display URLs - search by space and title
140
236
  const displayMatch = pageIdOrUrl.match(/\/display\/([^/]+)\/(.+)/);
141
237
  if (displayMatch) {
@@ -1403,9 +1499,12 @@ class ConfluenceClient {
1403
1499
  // Format: <ac:link><ri:page ri:space-key="xxx" ri:content-title="Page Title" /></ac:link>
1404
1500
  markdown = markdown.replace(/<ac:link>\s*<ri:page[^>]*ri:content-title="([^"]*)"[^>]*\/>\s*<\/ac:link>/g, '[$1]');
1405
1501
  markdown = markdown.replace(/<ac:link>\s*<ri:page[^>]*ri:content-title="([^"]*)"[^>]*>\s*<\/ri:page>\s*<\/ac:link>/g, '[$1]');
1406
-
1407
- // Remove any remaining ac:link tags that weren't matched
1408
- markdown = markdown.replace(/<ac:link>[\s\S]*?<\/ac:link>/g, '');
1502
+
1503
+ // Convert internal page links with custom display text (ac:link-body)
1504
+ markdown = markdown.replace(/<ac:link[^>]*>[\s\S]*?<ac:link-body>([\s\S]*?)<\/ac:link-body>[\s\S]*?<\/ac:link>/g, '$1');
1505
+
1506
+ // Remove any remaining ac:link tags that weren't matched (including those with attributes)
1507
+ markdown = markdown.replace(/<ac:link[^>]*>[\s\S]*?<\/ac:link>/g, '');
1409
1508
 
1410
1509
  // Convert remaining HTML to markdown
1411
1510
  markdown = this.htmlToMarkdown(markdown);
@@ -2112,4 +2211,4 @@ ConfluenceClient.createLocalConverter = function () {
2112
2211
  };
2113
2212
 
2114
2213
  module.exports = ConfluenceClient;
2115
- module.exports.NAMED_ENTITIES = NAMED_ENTITIES;
2214
+ module.exports.NAMED_ENTITIES = NAMED_ENTITIES;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "confluence-cli",
3
- "version": "1.29.1",
3
+ "version": "1.30.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "confluence-cli",
9
- "version": "1.29.1",
9
+ "version": "1.30.1",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "axios": "^1.15.0",
@@ -2684,9 +2684,9 @@
2684
2684
  "license": "ISC"
2685
2685
  },
2686
2686
  "node_modules/follow-redirects": {
2687
- "version": "1.15.11",
2688
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
2689
- "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
2687
+ "version": "1.16.0",
2688
+ "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
2689
+ "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
2690
2690
  "funding": [
2691
2691
  {
2692
2692
  "type": "individual",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "confluence-cli",
3
- "version": "1.29.1",
3
+ "version": "1.30.1",
4
4
  "description": "A command-line interface for Atlassian Confluence with page creation and editing capabilities",
5
5
  "main": "index.js",
6
6
  "bin": {