diff --git a/bin/confluence.js b/bin/confluence.js index 3fe20b7..5f154e3 100755 --- a/bin/confluence.js +++ b/bin/confluence.js @@ -76,6 +76,20 @@ function withClient(commandName, handler, { writable = false, onError = null } = }; } +// Same analytics + error pipeline as withClient, but without loading config or +// constructing a ConfluenceClient. For commands that work entirely locally +// (convert, stats, …). +function withLocal(commandName, handler) { + return async (...actionArgs) => { + const analytics = new Analytics(); + try { + await handler({ analytics }, ...actionArgs); + } catch (error) { + handleCommandError(analytics, commandName, error); + } + }; +} + program .name('confluence') .description('CLI tool for Atlassian Confluence') @@ -187,15 +201,9 @@ program program .command('stats') .description('Show usage statistics') - .action(async () => { - try { - const analytics = new Analytics(); - analytics.showStats(); - } catch (error) { - console.error(chalk.red('Error:'), error.message); - process.exit(1); - } - }); + .action(withLocal('stats', async ({ analytics }) => { + analytics.showStats(); + })); // Install skill command program @@ -1968,82 +1976,77 @@ program .option('-o, --output-file ', 'Output file path (writes to stdout if omitted)') .option('--input-format ', `Input format (${VALID_INPUT_FORMATS.join(', ')})`) .option('--output-format ', `Output format (${VALID_OUTPUT_FORMATS.join(', ')})`) - .action(async (options) => { - const analytics = new Analytics(); - try { - if (!options.inputFormat) { - console.error(chalk.red('Error: --input-format is required.')); - process.exit(1); - } - if (!options.outputFormat) { - console.error(chalk.red('Error: --output-format is required.')); - process.exit(1); - } - if (!VALID_INPUT_FORMATS.includes(options.inputFormat)) { - console.error(chalk.red(`Error: Invalid input format "${options.inputFormat}". Valid: ${VALID_INPUT_FORMATS.join(', ')}`)); - process.exit(1); - } - if (!VALID_OUTPUT_FORMATS.includes(options.outputFormat)) { - console.error(chalk.red(`Error: Invalid output format "${options.outputFormat}". Valid: ${VALID_OUTPUT_FORMATS.join(', ')}`)); - process.exit(1); - } - if (options.inputFormat === options.outputFormat) { - console.error(chalk.red('Error: Input and output formats must be different.')); - process.exit(1); - } - - let input; - if (options.inputFile) { - input = fs.readFileSync(options.inputFile, 'utf-8'); - } else { - if (process.stdin.isTTY) { - console.error(chalk.red('Error: No input provided. Use --input-file or pipe content via stdin.')); - process.exit(1); - } - input = await readStdin(); - } + .action(withLocal('convert', async ({ analytics }, options) => { + if (!options.inputFormat) { + console.error(chalk.red('Error: --input-format is required.')); + process.exit(1); + } + if (!options.outputFormat) { + console.error(chalk.red('Error: --output-format is required.')); + process.exit(1); + } + if (!VALID_INPUT_FORMATS.includes(options.inputFormat)) { + console.error(chalk.red(`Error: Invalid input format "${options.inputFormat}". Valid: ${VALID_INPUT_FORMATS.join(', ')}`)); + process.exit(1); + } + if (!VALID_OUTPUT_FORMATS.includes(options.outputFormat)) { + console.error(chalk.red(`Error: Invalid output format "${options.outputFormat}". Valid: ${VALID_OUTPUT_FORMATS.join(', ')}`)); + process.exit(1); + } + if (options.inputFormat === options.outputFormat) { + console.error(chalk.red('Error: Input and output formats must be different.')); + process.exit(1); + } - const converter = ConfluenceClient.createLocalConverter(); - let output; - - if (options.inputFormat === 'markdown' && options.outputFormat === 'storage') { - output = converter.markdownToStorage(input); - } else if (options.inputFormat === 'markdown' && options.outputFormat === 'html') { - output = converter.markdown.render(input); - } else if (options.inputFormat === 'html' && options.outputFormat === 'storage') { - output = converter.htmlToConfluenceStorage(input); - } else if (options.inputFormat === 'storage' && options.outputFormat === 'markdown') { - output = converter.storageToMarkdown(input); - } else if (options.inputFormat === 'storage' && options.outputFormat === 'text') { - const { convert: htmlToText } = require('html-to-text'); - output = htmlToText(input, { wordwrap: 130 }); - } else if (options.inputFormat === 'storage' && options.outputFormat === 'html') { - output = input; // storage format is already HTML-based - } else if (options.inputFormat === 'html' && options.outputFormat === 'text') { - const { convert: htmlToText } = require('html-to-text'); - output = htmlToText(input, { wordwrap: 130 }); - } else if (options.inputFormat === 'html' && options.outputFormat === 'markdown') { - output = converter.htmlToMarkdown(input); - } else if (options.inputFormat === 'markdown' && options.outputFormat === 'text') { - const html = converter.markdown.render(input); - const { convert: htmlToText } = require('html-to-text'); - output = htmlToText(html, { wordwrap: 130 }); - } else { - console.error(chalk.red(`Error: Conversion from "${options.inputFormat}" to "${options.outputFormat}" is not supported.`)); + let input; + if (options.inputFile) { + input = fs.readFileSync(options.inputFile, 'utf-8'); + } else { + if (process.stdin.isTTY) { + console.error(chalk.red('Error: No input provided. Use --input-file or pipe content via stdin.')); process.exit(1); } + input = await readStdin(); + } + + const converter = ConfluenceClient.createLocalConverter(); + let output; + + if (options.inputFormat === 'markdown' && options.outputFormat === 'storage') { + output = converter.markdownToStorage(input); + } else if (options.inputFormat === 'markdown' && options.outputFormat === 'html') { + output = converter.markdown.render(input); + } else if (options.inputFormat === 'html' && options.outputFormat === 'storage') { + output = converter.htmlToConfluenceStorage(input); + } else if (options.inputFormat === 'storage' && options.outputFormat === 'markdown') { + output = converter.storageToMarkdown(input); + } else if (options.inputFormat === 'storage' && options.outputFormat === 'text') { + const { convert: htmlToText } = require('html-to-text'); + output = htmlToText(input, { wordwrap: 130 }); + } else if (options.inputFormat === 'storage' && options.outputFormat === 'html') { + output = input; // storage format is already HTML-based + } else if (options.inputFormat === 'html' && options.outputFormat === 'text') { + const { convert: htmlToText } = require('html-to-text'); + output = htmlToText(input, { wordwrap: 130 }); + } else if (options.inputFormat === 'html' && options.outputFormat === 'markdown') { + output = converter.htmlToMarkdown(input); + } else if (options.inputFormat === 'markdown' && options.outputFormat === 'text') { + const html = converter.markdown.render(input); + const { convert: htmlToText } = require('html-to-text'); + output = htmlToText(html, { wordwrap: 130 }); + } else { + console.error(chalk.red(`Error: Conversion from "${options.inputFormat}" to "${options.outputFormat}" is not supported.`)); + process.exit(1); + } - if (options.outputFile) { - fs.writeFileSync(options.outputFile, output, 'utf-8'); - console.error(chalk.green(`Converted ${options.inputFormat} → ${options.outputFormat}: ${options.outputFile}`)); - } else { - process.stdout.write(output); - } - analytics.track('convert', true); - } catch (error) { - handleCommandError(analytics, 'convert', error); + if (options.outputFile) { + fs.writeFileSync(options.outputFile, output, 'utf-8'); + console.error(chalk.green(`Converted ${options.inputFormat} → ${options.outputFormat}: ${options.outputFile}`)); + } else { + process.stdout.write(output); } - }); + analytics.track('convert', true); + })); // Exported for testing module.exports = { @@ -2062,6 +2065,7 @@ module.exports = { assertNoBodyForFolder, handleCommandError, withClient, + withLocal, }, }; diff --git a/tests/with-client.test.js b/tests/with-client.test.js index 5ed6189..198cea4 100644 --- a/tests/with-client.test.js +++ b/tests/with-client.test.js @@ -17,7 +17,7 @@ const { getConfig } = require('../lib/config'); const ConfluenceClient = require('../lib/confluence-client'); const Analytics = require('../lib/analytics'); -const { _test: { withClient } } = require('../bin/confluence'); +const { _test: { withClient, withLocal } } = require('../bin/confluence'); describe('withClient wrapper', () => { let trackSpy; @@ -131,3 +131,53 @@ describe('withClient wrapper', () => { expect(exitSpy).toHaveBeenCalledWith(1); }); }); + +describe('withLocal wrapper', () => { + let trackSpy; + let exitSpy; + let errorSpy; + + beforeEach(() => { + trackSpy = jest.spyOn(Analytics.prototype, 'track').mockImplementation(() => {}); + exitSpy = jest.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit called'); + }); + errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + ConfluenceClient.mockClear(); + }); + + afterEach(() => { + trackSpy.mockRestore(); + exitSpy.mockRestore(); + errorSpy.mockRestore(); + }); + + test('invokes handler with { analytics } and forwards action args; never builds a client', async () => { + const handler = jest.fn().mockResolvedValue(); + const action = withLocal('convert', handler); + + await action({ inputFormat: 'markdown' }); + + expect(handler).toHaveBeenCalledTimes(1); + const [ctx, options] = handler.mock.calls[0]; + expect(ctx).toEqual({ analytics: expect.anything() }); + expect(ctx.client).toBeUndefined(); + expect(ctx.config).toBeUndefined(); + expect(options).toEqual({ inputFormat: 'markdown' }); + expect(ConfluenceClient).not.toHaveBeenCalled(); + expect(exitSpy).not.toHaveBeenCalled(); + }); + + test('handler throw tracks failure, logs Error message, and exits 1', async () => { + const handler = jest.fn().mockRejectedValue(new Error('boom')); + const action = withLocal('convert', handler); + + await expect(action({})).rejects.toThrow('process.exit called'); + expect(trackSpy).toHaveBeenCalledWith('convert', false); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error:'), + 'boom' + ); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +});