From 2a5befde034342bdd9d694cae55dd37c2dff57d1 Mon Sep 17 00:00:00 2001 From: Christian Sagstetter Date: Thu, 19 Mar 2026 13:50:28 +0100 Subject: [PATCH 1/2] fix: make wiki path prefix configurable based on apiPath MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit URLs no longer hardcode /wiki — they derive the prefix from apiPath, fixing broken links for Server/Data Center instances without /wiki. Co-Authored-By: Claude Opus 4.6 --- bin/confluence.js | 20 +++++++++++--------- lib/confluence-client.js | 9 +++++---- tests/confluence-client.test.js | 29 +++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/bin/confluence.js b/bin/confluence.js index e4d24f9..feed38c 100755 --- a/bin/confluence.js +++ b/bin/confluence.js @@ -8,6 +8,8 @@ const { getConfig, initConfig, listProfiles, setActiveProfile, deleteProfile, is const Analytics = require('../lib/analytics'); const pkg = require('../package.json'); +const getWebUrlPrefix = (apiPath) => apiPath && apiPath.startsWith('/wiki/') ? '/wiki' : ''; + function buildPageUrl(config, path) { const protocol = config.protocol || 'https'; return `${protocol}://${config.domain}${path}`; @@ -240,7 +242,7 @@ program console.log(`Title: ${chalk.blue(result.title)}`); console.log(`ID: ${chalk.blue(result.id)}`); console.log(`Space: ${chalk.blue(result.space.name)} (${result.space.key})`); - console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `/wiki${result._links.webui}`)}`)}`); + console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `${getWebUrlPrefix(config.apiPath)}${result._links.webui}`)}`)}`); analytics.track('create', true); } catch (error) { @@ -289,7 +291,7 @@ program console.log(`ID: ${chalk.blue(result.id)}`); console.log(`Parent: ${chalk.blue(parentInfo.title)} (${parentId})`); console.log(`Space: ${chalk.blue(result.space.name)} (${result.space.key})`); - console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `/wiki${result._links.webui}`)}`)}`); + console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `${getWebUrlPrefix(config.apiPath)}${result._links.webui}`)}`)}`); analytics.track('create_child', true); } catch (error) { @@ -337,7 +339,7 @@ program console.log(`Title: ${chalk.blue(result.title)}`); console.log(`ID: ${chalk.blue(result.id)}`); console.log(`Version: ${chalk.blue(result.version.number)}`); - console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `/wiki${result._links.webui}`)}`)}`); + console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `${getWebUrlPrefix(config.apiPath)}${result._links.webui}`)}`)}`); analytics.track('update', true); } catch (error) { @@ -365,7 +367,7 @@ program console.log(`ID: ${chalk.blue(result.id)}`); console.log(`New Parent: ${chalk.blue(newParentId)}`); console.log(`Version: ${chalk.blue(result.version.number)}`); - console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `/wiki${result._links.webui}`)}`)}`); + console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `${getWebUrlPrefix(config.apiPath)}${result._links.webui}`)}`)}`); analytics.track('move', true); } catch (error) { @@ -473,7 +475,7 @@ program console.log(`Title: ${chalk.green(pageInfo.title)}`); console.log(`ID: ${chalk.green(pageInfo.id)}`); console.log(`Space: ${chalk.green(pageInfo.space.name)} (${pageInfo.space.key})`); - console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `/wiki${pageInfo.url}`)}`)}`); + console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `${getWebUrlPrefix(config.apiPath)}${pageInfo.url}`)}`)}`); analytics.track('find', true); } catch (error) { @@ -1713,7 +1715,7 @@ program console.log(` - ...and ${result.failures.length - 10} more`); } } - console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `/wiki${result.rootPage._links.webui}`)}`)}`); + console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `${getWebUrlPrefix(config.apiPath)}${result.rootPage._links.webui}`)}`)}`); if (options.failOnError && result.failures?.length) { analytics.track('copy_tree', false); console.error(chalk.red('Completed with failures and --fail-on-error is set.')); @@ -1775,7 +1777,7 @@ program type: page.type, status: page.status, spaceKey: page.space?.key, - url: `${buildPageUrl(config, `/wiki/spaces/${page.space?.key}/pages/${page.id}`)}`, + url: `${buildPageUrl(config, `${getWebUrlPrefix(config.apiPath)}/spaces/${page.space?.key}/pages/${page.id}`)}`, parentId: page.parentId || resolvedPageId })) }; @@ -1804,7 +1806,7 @@ program } if (options.showUrl) { - const url = `${buildPageUrl(config, `/wiki/spaces/${page.space?.key}/pages/${page.id}`)}`; + const url = `${buildPageUrl(config, `${getWebUrlPrefix(config.apiPath)}/spaces/${page.space?.key}/pages/${page.id}`)}`; output += `\n ${chalk.gray(url)}`; } @@ -1869,7 +1871,7 @@ function printTree(nodes, config, options, depth = 1) { } if (options.showUrl) { - const url = `${buildPageUrl(config, `/wiki/spaces/${node.space?.key}/pages/${node.id}`)}`; + const url = `${buildPageUrl(config, `${getWebUrlPrefix(config.apiPath)}/spaces/${node.space?.key}/pages/${node.id}`)}`; output += `\n${indent}${isLast ? ' ' : '│ '}${chalk.gray(url)}`; } diff --git a/lib/confluence-client.js b/lib/confluence-client.js index f3a2e65..3239abf 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -35,6 +35,7 @@ class ConfluenceClient { this.email = config.email; this.authType = (config.authType || (this.email ? 'basic' : 'bearer')).toLowerCase(); this.apiPath = this.sanitizeApiPath(config.apiPath); + this.webUrlPrefix = this.apiPath.startsWith('/wiki/') ? '/wiki' : ''; this.baseURL = `${this.protocol}://${this.domain}${this.apiPath}`; this.markdown = new MarkdownIt(); this.setupConfluenceMarkdownExtensions(); @@ -412,7 +413,7 @@ class ConfluenceClient { const webui = page._links?.webui || ''; return { title: page.title, - url: webui ? this.buildUrl(`/wiki${webui}`) : '' + url: webui ? this.buildUrl(`${this.webUrlPrefix}${webui}`) : '' }; } return null; @@ -506,7 +507,7 @@ class ConfluenceClient { // Format: - [Page Title](URL) const childPagesList = childPages.map(page => { const webui = page._links?.webui || ''; - const url = webui ? this.buildUrl(`/wiki${webui}`) : ''; + const url = webui ? this.buildUrl(`${this.webUrlPrefix}${webui}`) : ''; if (url) { return `- [${page.title}](${url})`; } else { @@ -1351,11 +1352,11 @@ class ConfluenceClient { // Try to build a proper URL - if spaceKey starts with ~, it's a user space if (spaceKey.startsWith('~')) { const spacePath = `display/${spaceKey}/${encodeURIComponent(title)}`; - return `\n> 📄 **${labels.includePage}**: [${title}](${this.buildUrl(`/wiki/${spacePath}`)})\n`; + return `\n> 📄 **${labels.includePage}**: [${title}](${this.buildUrl(`${this.webUrlPrefix}/${spacePath}`)})\n`; } else { // For non-user spaces, we cannot construct a valid link without the page ID. // Document that manual correction is required. - return `\n> 📄 **${labels.includePage}**: [${title}](${this.buildUrl(`/wiki/spaces/${spaceKey}/pages/[PAGE_ID_HERE]`)}) _(manual link correction required)_\n`; + return `\n> 📄 **${labels.includePage}**: [${title}](${this.buildUrl(`${this.webUrlPrefix}/spaces/${spaceKey}/pages/[PAGE_ID_HERE]`)}) _(manual link correction required)_\n`; } }); diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index 9e8b921..a78dafd 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -126,6 +126,35 @@ describe('ConfluenceClient', () => { expect(customClient.baseURL).toBe('https://cloud.example/wiki/rest/api'); }); + + test('sets webUrlPrefix to /wiki when apiPath starts with /wiki/', () => { + const cloudClient = new ConfluenceClient({ + domain: 'test.atlassian.net', + token: 'cloud-token', + apiPath: '/wiki/rest/api' + }); + + expect(cloudClient.webUrlPrefix).toBe('/wiki'); + }); + + test('sets webUrlPrefix to empty string when apiPath does not start with /wiki/', () => { + const serverClient = new ConfluenceClient({ + domain: 'confluence.example.com', + token: 'server-token', + apiPath: '/rest/api' + }); + + expect(serverClient.webUrlPrefix).toBe(''); + }); + + test('sets webUrlPrefix to empty string when apiPath is not provided', () => { + const defaultClient = new ConfluenceClient({ + domain: 'example.com', + token: 'default-token' + }); + + expect(defaultClient.webUrlPrefix).toBe(''); + }); }); describe('authentication setup', () => { From 7be81cdfbe73769a77fdcdad9cf17b5378a6b3fe Mon Sep 17 00:00:00 2001 From: Christian Sagstetter Date: Mon, 23 Mar 2026 09:32:39 +0100 Subject: [PATCH 2/2] fix: consolidate web URL prefix logic to single source of truth Addresses review feedback by removing logic duplication between bin/confluence.js and ConfluenceClient that could cause inconsistent behavior. Changes: - Remove getWebUrlPrefix() and buildPageUrl() functions from bin/confluence.js - Use client.webUrlPrefix and client.buildUrl() for all URL construction - Add test for edge case: apiPath without leading slash (e.g., 'wiki/rest/api/') This ensures consistent URL generation by always using the sanitized apiPath through ConfluenceClient, preventing discrepancies when users configure apiPath with or without a leading slash. Resolves feedback in https://github.com/pchuri/confluence-cli/pull/83#issuecomment-4108336611 Co-Authored-By: Claude Opus 4.6 --- bin/confluence.js | 41 ++++++++++++++------------------- tests/confluence-client.test.js | 10 ++++++++ 2 files changed, 27 insertions(+), 24 deletions(-) diff --git a/bin/confluence.js b/bin/confluence.js index feed38c..2623843 100755 --- a/bin/confluence.js +++ b/bin/confluence.js @@ -8,13 +8,6 @@ const { getConfig, initConfig, listProfiles, setActiveProfile, deleteProfile, is const Analytics = require('../lib/analytics'); const pkg = require('../package.json'); -const getWebUrlPrefix = (apiPath) => apiPath && apiPath.startsWith('/wiki/') ? '/wiki' : ''; - -function buildPageUrl(config, path) { - const protocol = config.protocol || 'https'; - return `${protocol}://${config.domain}${path}`; -} - function assertWritable(config) { if (config.readOnly) { console.error(chalk.red('Error: This profile is in read-only mode. Write operations are not allowed.')); @@ -242,8 +235,8 @@ program console.log(`Title: ${chalk.blue(result.title)}`); console.log(`ID: ${chalk.blue(result.id)}`); console.log(`Space: ${chalk.blue(result.space.name)} (${result.space.key})`); - console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `${getWebUrlPrefix(config.apiPath)}${result._links.webui}`)}`)}`); - + console.log(`URL: ${chalk.gray(`${client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`)}`)}`); + analytics.track('create', true); } catch (error) { analytics.track('create', false); @@ -291,8 +284,8 @@ program console.log(`ID: ${chalk.blue(result.id)}`); console.log(`Parent: ${chalk.blue(parentInfo.title)} (${parentId})`); console.log(`Space: ${chalk.blue(result.space.name)} (${result.space.key})`); - console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `${getWebUrlPrefix(config.apiPath)}${result._links.webui}`)}`)}`); - + console.log(`URL: ${chalk.gray(`${client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`)}`)}`); + analytics.track('create_child', true); } catch (error) { analytics.track('create_child', false); @@ -339,8 +332,8 @@ program console.log(`Title: ${chalk.blue(result.title)}`); console.log(`ID: ${chalk.blue(result.id)}`); console.log(`Version: ${chalk.blue(result.version.number)}`); - console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `${getWebUrlPrefix(config.apiPath)}${result._links.webui}`)}`)}`); - + console.log(`URL: ${chalk.gray(`${client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`)}`)}`); + analytics.track('update', true); } catch (error) { analytics.track('update', false); @@ -367,7 +360,7 @@ program console.log(`ID: ${chalk.blue(result.id)}`); console.log(`New Parent: ${chalk.blue(newParentId)}`); console.log(`Version: ${chalk.blue(result.version.number)}`); - console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `${getWebUrlPrefix(config.apiPath)}${result._links.webui}`)}`)}`); + console.log(`URL: ${chalk.gray(`${client.buildUrl(`${client.webUrlPrefix}${result._links.webui}`)}`)}`); analytics.track('move', true); } catch (error) { @@ -475,8 +468,8 @@ program console.log(`Title: ${chalk.green(pageInfo.title)}`); console.log(`ID: ${chalk.green(pageInfo.id)}`); console.log(`Space: ${chalk.green(pageInfo.space.name)} (${pageInfo.space.key})`); - console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `${getWebUrlPrefix(config.apiPath)}${pageInfo.url}`)}`)}`); - + console.log(`URL: ${chalk.gray(`${client.buildUrl(`${client.webUrlPrefix}${pageInfo.url}`)}`)}`); + analytics.track('find', true); } catch (error) { analytics.track('find', false); @@ -1715,7 +1708,7 @@ program console.log(` - ...and ${result.failures.length - 10} more`); } } - console.log(`URL: ${chalk.gray(`${buildPageUrl(config, `${getWebUrlPrefix(config.apiPath)}${result.rootPage._links.webui}`)}`)}`); + console.log(`URL: ${chalk.gray(`${client.buildUrl(`${client.webUrlPrefix}${result.rootPage._links.webui}`)}`)}`); if (options.failOnError && result.failures?.length) { analytics.track('copy_tree', false); console.error(chalk.red('Completed with failures and --fail-on-error is set.')); @@ -1777,7 +1770,7 @@ program type: page.type, status: page.status, spaceKey: page.space?.key, - url: `${buildPageUrl(config, `${getWebUrlPrefix(config.apiPath)}/spaces/${page.space?.key}/pages/${page.id}`)}`, + url: `${client.buildUrl(`${client.webUrlPrefix}/spaces/${page.space?.key}/pages/${page.id}`)}`, parentId: page.parentId || resolvedPageId })) }; @@ -1789,7 +1782,7 @@ program // Build tree structure const tree = buildTree(children, resolvedPageId); - printTree(tree, config, options, 1); + printTree(tree, client, config, options, 1); console.log(''); console.log(chalk.gray(`Total: ${children.length} child page${children.length === 1 ? '' : 's'}`)); @@ -1806,7 +1799,7 @@ program } if (options.showUrl) { - const url = `${buildPageUrl(config, `${getWebUrlPrefix(config.apiPath)}/spaces/${page.space?.key}/pages/${page.id}`)}`; + const url = `${client.buildUrl(`${client.webUrlPrefix}/spaces/${page.space?.key}/pages/${page.id}`)}`; output += `\n ${chalk.gray(url)}`; } @@ -1858,7 +1851,7 @@ function buildTree(pages, rootId) { } // Helper function to print tree -function printTree(nodes, config, options, depth = 1) { +function printTree(nodes, client, config, options, depth = 1) { nodes.forEach((node, index) => { const isLast = index === nodes.length - 1; const indent = ' '.repeat(depth - 1); @@ -1871,14 +1864,14 @@ function printTree(nodes, config, options, depth = 1) { } if (options.showUrl) { - const url = `${buildPageUrl(config, `${getWebUrlPrefix(config.apiPath)}/spaces/${node.space?.key}/pages/${node.id}`)}`; + const url = `${client.buildUrl(`${client.webUrlPrefix}/spaces/${node.space?.key}/pages/${node.id}`)}`; output += `\n${indent}${isLast ? ' ' : '│ '}${chalk.gray(url)}`; } console.log(output); - + if (node.children && node.children.length > 0) { - printTree(node.children, config, options, depth + 1); + printTree(node.children, client, config, options, depth + 1); } }); } diff --git a/tests/confluence-client.test.js b/tests/confluence-client.test.js index a78dafd..83b99e1 100644 --- a/tests/confluence-client.test.js +++ b/tests/confluence-client.test.js @@ -155,6 +155,16 @@ describe('ConfluenceClient', () => { expect(defaultClient.webUrlPrefix).toBe(''); }); + + test('sets webUrlPrefix to /wiki when apiPath is wiki/rest/api/ (missing leading slash)', () => { + const clientWithMissingSlash = new ConfluenceClient({ + domain: 'confluence.example.com', + token: 'test-token', + apiPath: 'wiki/rest/api/' + }); + + expect(clientWithMissingSlash.webUrlPrefix).toBe('/wiki'); + }); }); describe('authentication setup', () => {