diff --git a/CLAUDE.md b/CLAUDE.md index 68b24c2..67cddcb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ Each file in `src/commands/` exports a `registerXxxCommand(program)` function th - **api.ts**: Single `apiRequest(path, body)` function. All Outline API calls are POST with Bearer auth. Wraps requests in spinner with per-endpoint messages (SPINNER_MESSAGES map). - **auth.ts**: Config stored at `~/.config/outline-cli/config.json`. Token/URL resolution: env var → config file → default. -- **output.ts**: `outputItem()` and `outputList()` handle three output modes (human/json/ndjson). Commands define a `humanFormatter` function and an `essentialKeys` array for JSON field filtering. +- **output.ts**: `outputItem()` and `outputList()` handle three output modes (human/json/ndjson). Commands define a `humanFormatter` function and an `essentialKeys` array for JSON field filtering. `formatError` / `formatErrorJson` accept either positional `(code, message, hints?)` or a `BaseCliError` instance — errors thrown by `@doist/cli-core` helpers (e.g. the delegated `changelog` command) route through the top-level `parseAsync().catch` in `src/index.ts`, which dispatches via `isJsonMode()`. - **spinner.ts**: `withSpinner(opts, fn)` wrapper using yocto-spinner. Auto-disables on non-TTY, JSON output, CI, `--no-spinner` flag. - **markdown.ts**: Terminal markdown rendering via marked + marked-terminal. diff --git a/src/__tests__/changelog-integration.test.ts b/src/__tests__/changelog-integration.test.ts new file mode 100644 index 0000000..c8697e2 --- /dev/null +++ b/src/__tests__/changelog-integration.test.ts @@ -0,0 +1,50 @@ +import { Command } from 'commander' +import { describe, expect, it } from 'vitest' +import { registerChangelogCommand } from '../commands/changelog.js' +import { BaseCliError } from '../lib/errors.js' +import { formatError, formatErrorJson } from '../lib/output.js' + +describe('changelog command end-to-end', () => { + it('rejects with BaseCliError(INVALID_TYPE) when --count is not a number', async () => { + const program = new Command() + program.exitOverride() + registerChangelogCommand(program) + + await expect( + program.parseAsync(['node', 'ol', 'changelog', '-n', 'abc']), + ).rejects.toBeInstanceOf(BaseCliError) + + const err = await program + .parseAsync(['node', 'ol', 'changelog', '-n', 'abc']) + .catch((e: Error) => e) + expect(err).toBeInstanceOf(BaseCliError) + expect((err as BaseCliError).code).toBe('INVALID_TYPE') + }) + + it('formats the rejected BaseCliError through formatError (human)', async () => { + const program = new Command() + program.exitOverride() + registerChangelogCommand(program) + + const err = await program + .parseAsync(['node', 'ol', 'changelog', '-n', 'abc']) + .catch((e: Error) => e) + expect(err).toBeInstanceOf(BaseCliError) + const out = formatError(err as BaseCliError) + expect(out).toContain('Error: INVALID_TYPE') + expect(out).toContain('Count must be a positive integer') + }) + + it('formats the rejected BaseCliError through formatErrorJson', async () => { + const program = new Command() + program.exitOverride() + registerChangelogCommand(program) + + const err = await program + .parseAsync(['node', 'ol', 'changelog', '-n', 'abc']) + .catch((e: Error) => e) + const parsed = JSON.parse(formatErrorJson(err as BaseCliError)) + expect(parsed.error.code).toBe('INVALID_TYPE') + expect(parsed.error.message).toBe('Count must be a positive integer') + }) +}) diff --git a/src/__tests__/changelog.test.ts b/src/__tests__/changelog.test.ts index 03a4cdb..24f0b03 100644 --- a/src/__tests__/changelog.test.ts +++ b/src/__tests__/changelog.test.ts @@ -1,161 +1,42 @@ +import { basename } from 'node:path' import { Command } from 'commander' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('node:fs/promises') +const { registerCoreChangelogCommand } = vi.hoisted(() => ({ + registerCoreChangelogCommand: vi.fn(), +})) -import { readFile } from 'node:fs/promises' -import { registerChangelogCommand } from '../commands/changelog.js' - -const mockReadFile = vi.mocked(readFile) - -const SAMPLE_CHANGELOG = `# Changelog - -## [1.5.0](https://example.com) (2026-03-15) - -### Features - -* feature five - -## [1.4.0](https://example.com) (2026-03-14) - -### Features - -* feature four - -## [1.3.0](https://example.com) (2026-03-13) - -### Bug Fixes - -* fix three - -## [1.2.0](https://example.com) (2026-03-12) +vi.mock('@doist/cli-core/commands', () => ({ + registerChangelogCommand: registerCoreChangelogCommand, +})) -### Features - -* feature two - -## [1.1.0](https://example.com) (2026-03-11) - -### Features - -* feature one - -## [1.0.0](https://example.com) (2026-03-10) - -### Features - -* initial release -` - -function createProgram() { - const program = new Command() - program.exitOverride() - registerChangelogCommand(program) - return program -} - -describe('changelog command', () => { - let consoleSpy: ReturnType - let consoleErrorSpy: ReturnType +import packageJson from '../../package.json' with { type: 'json' } +import { registerChangelogCommand } from '../commands/changelog.js' +describe('changelog wrapper', () => { beforeEach(() => { - consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - process.exitCode = undefined - }) - - afterEach(() => { - vi.restoreAllMocks() - process.exitCode = undefined + registerCoreChangelogCommand.mockReset() }) - it('shows last 5 versions by default', async () => { - mockReadFile.mockResolvedValue(SAMPLE_CHANGELOG) - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'changelog']) - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('1.5.0')) - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('1.1.0')) - // Should show "view full changelog" link since there are 6 versions - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('View full changelog')) - }) - - it('includes latest version when changelog has no preamble', async () => { - const noPreambleChangelog = `## [2.0.0](https://example.com) (2026-03-20) - -### Features - -* new major version - -## [1.5.0](https://example.com) (2026-03-15) - -### Features - -* feature five -` - mockReadFile.mockResolvedValue(noPreambleChangelog) - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'changelog']) - - const output = consoleSpy.mock.calls[0][0] as string - expect(output).toContain('2.0.0') - expect(output).toContain('1.5.0') - }) - - it('respects --count option', async () => { - mockReadFile.mockResolvedValue(SAMPLE_CHANGELOG) - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'changelog', '-n', '2']) - - const output = consoleSpy.mock.calls[0][0] as string - expect(output).toContain('1.5.0') - expect(output).toContain('1.4.0') - expect(output).not.toContain('1.3.0') - }) - - it('handles fewer entries than requested', async () => { - const shortChangelog = `# Changelog - -## [1.1.0](https://example.com) (2026-03-11) - -### Features - -* only version -` - mockReadFile.mockResolvedValue(shortChangelog) - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'changelog']) - - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('1.1.0')) - // Should NOT show "view full changelog" link since all versions are shown - expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('View full changelog')) - }) - - it('handles missing changelog file', async () => { - mockReadFile.mockRejectedValue(new Error('ENOENT')) - - const program = createProgram() - await program.parseAsync(['node', 'ol', 'changelog']) + it('delegates to @doist/cli-core/commands with the expected config', () => { + const program = new Command() + registerChangelogCommand(program) - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('Could not read changelog file'), - ) - expect(process.exitCode).toBe(1) + expect(registerCoreChangelogCommand).toHaveBeenCalledTimes(1) + const [passedProgram, config] = registerCoreChangelogCommand.mock.calls[0] + expect(passedProgram).toBe(program) + expect(basename(config.path)).toBe('CHANGELOG.md') + expect(config.repoUrl).toBe('https://github.com/Doist/outline-cli') + expect(config.version).toBe(packageJson.version) + expect(config.bulletMarkers).toEqual(['*', '-']) }) - it('handles invalid count', async () => { - const program = createProgram() - await program.parseAsync(['node', 'ol', 'changelog', '-n', 'abc']) + it('derives repoUrl from package.json (strips .git / git+ prefix)', () => { + const program = new Command() + registerChangelogCommand(program) - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('Count must be a positive number'), - ) - expect(process.exitCode).toBe(1) + const [, config] = registerCoreChangelogCommand.mock.calls[0] + expect(config.repoUrl).not.toMatch(/\.git$/) + expect(config.repoUrl).not.toMatch(/^git\+/) }) }) diff --git a/src/__tests__/output.test.ts b/src/__tests__/output.test.ts index 1db5a4a..5c36935 100644 --- a/src/__tests__/output.test.ts +++ b/src/__tests__/output.test.ts @@ -1,5 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { formatError, getOutputOptions, outputItem, outputList } from '../lib/output.js' +import { BaseCliError } from '../lib/errors.js' +import { + formatError, + formatErrorJson, + getOutputOptions, + outputItem, + outputList, +} from '../lib/output.js' describe('output', () => { let logs: string[] @@ -87,5 +94,36 @@ describe('output', () => { expect(result).toContain('No hints provided') expect(result).not.toContain(' - ') }) + + it('formats a cli-core CliError instance (code, message, hints)', () => { + const err = new BaseCliError('FILE_READ_ERROR', 'Could not read changelog file', { + hints: ['Check the file path'], + }) + const result = formatError(err) + expect(result).toContain('Error: FILE_READ_ERROR') + expect(result).toContain('Could not read changelog file') + expect(result).toContain('Check the file path') + }) + }) + + describe('formatErrorJson', () => { + it('serializes a cli-core CliError instance', () => { + const err = new BaseCliError('INVALID_TYPE', 'Count must be a positive integer') + const parsed = JSON.parse(formatErrorJson(err)) + expect(parsed).toEqual({ + error: { + code: 'INVALID_TYPE', + message: 'Count must be a positive integer', + hints: undefined, + }, + }) + }) + + it('serializes from positional args', () => { + const parsed = JSON.parse(formatErrorJson('CODE', 'msg', ['hint'])) + expect(parsed).toEqual({ + error: { code: 'CODE', message: 'msg', hints: ['hint'] }, + }) + }) }) }) diff --git a/src/commands/changelog.ts b/src/commands/changelog.ts index db882c7..5f7dc12 100644 --- a/src/commands/changelog.ts +++ b/src/commands/changelog.ts @@ -1,112 +1,17 @@ -import { readFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import chalk from 'chalk' -import { Command } from 'commander' +import { registerChangelogCommand as registerCoreChangelogCommand } from '@doist/cli-core/commands' +import type { Command } from 'commander' import packageJson from '../../package.json' with { type: 'json' } const CHANGELOG_PATH = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'CHANGELOG.md') -const CHANGELOG_URL = `https://github.com/Doist/outline-cli/blob/v${packageJson.version}/CHANGELOG.md` - -function formatInline(text: string): string { - return text - .replace(/\*\*([^*]+)\*\*/g, (_, content) => chalk.bold(content)) - .replace(/`([^`]+)`/g, (_, code) => chalk.cyan(code)) -} - -function formatForTerminal(text: string): string { - return text - .split('\n') - .map((line) => { - // Version headers: ## 1.25.0 (date) → bold green - if (line.startsWith('## ')) { - return chalk.green.bold(line.slice(3)) - } - // Section headers: ### Features → bold - if (line.startsWith('### ')) { - return chalk.bold(line.slice(4)) - } - // Bullet items: * or - description → dimmed bullet + text - if (line.startsWith('* ')) { - return ` ${chalk.dim('•')} ${formatInline(line.slice(2))}` - } - if (line.startsWith('- ')) { - return ` ${chalk.dim('•')} ${formatInline(line.slice(2))}` - } - return formatInline(line) - }) - .join('\n') -} - -function cleanChangelog(text: string): string { - return ( - text - // Version headers: ## [1.25.0](https://...) (date) → ## 1.25.0 (date) - .replace(/## \[([^\]]+)\]\([^)]*\)/g, '## $1') - // Remove commit hash links: ([abc1234](https://...)) - .replace(/ \([a-f0-9]{7}\)/g, '') - .replace(/ \(\[[a-f0-9]{7}\]\([^)]*\)\)/g, '') - // Issue/PR links: [#151](https://...) → #151 - .replace(/\[#(\d+)\]\([^)]*\)/g, '#$1') - // Remove **deps:** dependency update lines (not useful to end users) - .replace(/^[*-] \*\*deps:\*\*.*$/gm, '') - // Remove **scope:** prefixes but keep the text: **task:** foo → foo - .replace(/\*\*[\w-]+:\*\* /g, '') - // Clean up blank lines left by removed dep lines - .replace(/\n{3,}/g, '\n\n') - // Remove section headers left empty after filtering (e.g. ### Bug Fixes with no items) - .replace(/### [\w ]+\n\n(?=#|$)/gm, '') - ) -} - -function parseChangelog(content: string, count: number): { text: string; hasMore: boolean } { - const sections = content.split(/\n(?=## (?:\d|\[))/) - const versionSections = sections.filter((s) => /^## (?:\d|\[)/.test(s)) - const selected = versionSections.slice(0, count) - - if (selected.length === 0) { - return { text: 'No changelog entries found.', hasMore: false } - } - - return { - text: cleanChangelog(selected.join('\n').trimEnd()), - hasMore: versionSections.length > count, - } -} - -interface ChangelogOptions { - count: string -} - -export async function changelogAction(options: ChangelogOptions): Promise { - const count = Number(options.count) - if (!Number.isInteger(count) || count < 1) { - console.error(chalk.red('Error:'), 'Count must be a positive number') - process.exitCode = 1 - return - } - - let content: string - try { - content = await readFile(CHANGELOG_PATH, 'utf-8') - } catch { - console.error(chalk.red('Error:'), 'Could not read changelog file') - process.exitCode = 1 - return - } - - const { text, hasMore } = parseChangelog(content, count) - console.log(formatForTerminal(text)) - - if (hasMore) { - console.log(chalk.dim(`\nView full changelog: ${CHANGELOG_URL}`)) - } -} +const REPO_URL = packageJson.repository.url.replace(/^git\+/, '').replace(/\.git$/, '') export function registerChangelogCommand(program: Command): void { - program - .command('changelog') - .description('Show recent changelog entries') - .option('-n, --count ', 'Number of versions to show', '5') - .action(changelogAction) + registerCoreChangelogCommand(program, { + path: CHANGELOG_PATH, + repoUrl: REPO_URL, + version: packageJson.version, + bulletMarkers: ['*', '-'], + }) } diff --git a/src/index.ts b/src/index.ts index d62d0fc..03619c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,9 @@ import { registerDocumentCommand } from './commands/document.js' import { registerSearchCommand } from './commands/search.js' import { registerSkillCommand } from './commands/skill.js' import { registerUpdateCommand } from './commands/update/index.js' +import { BaseCliError } from './lib/errors.js' +import { isJsonMode } from './lib/global-args.js' +import { formatError, formatErrorJson } from './lib/output.js' const program = new Command() @@ -34,6 +37,10 @@ registerChangelogCommand(program) registerUpdateCommand(program) program.parseAsync().catch((err: Error) => { - console.error(err.message) + if (err instanceof BaseCliError) { + console.error(isJsonMode() ? formatErrorJson(err) : formatError(err)) + } else { + console.error(err.message) + } process.exit(1) }) diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 12c11ab..2f4ae4a 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -1,5 +1,8 @@ import { CliError as CoreCliError, type CliErrorCode, type ErrorType } from '@doist/cli-core' +export { CoreCliError as BaseCliError } +export type { ErrorType } from '@doist/cli-core' + export type ErrorCode = | 'AUTH_VERIFICATION_FAILED' | 'CONFIRMATION_REQUIRED' diff --git a/src/lib/global-args.ts b/src/lib/global-args.ts index 6f7e55d..66b61ff 100644 --- a/src/lib/global-args.ts +++ b/src/lib/global-args.ts @@ -14,3 +14,8 @@ export const shouldDisableSpinner = createSpinnerGate({ envVar: 'OL_SPINNER', getArgs: store.get, }) + +export function isJsonMode(): boolean { + const args = store.get() + return args.json || args.ndjson +} diff --git a/src/lib/output.ts b/src/lib/output.ts index c0a90c6..c7b105f 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -1,6 +1,9 @@ import { formatJson, formatNdjson, printEmpty, type ViewOptions } from '@doist/cli-core' import chalk from 'chalk' import type { Pagination } from './api.js' +import { BaseCliError } from './errors.js' + +type AnyCliError = BaseCliError export type OutputOptions = ViewOptions & { full?: boolean @@ -79,13 +82,42 @@ function pick(obj: T, keys: (keyof T)[]): Partial { return result } -export function formatError(code: string, message: string, hints?: string[]): string { - const lines = [`Error: ${code}`, message] - if (hints && hints.length > 0) { +function toCliError( + codeOrError: string | AnyCliError, + message?: string, + hints?: string[], +): AnyCliError { + if (typeof codeOrError === 'string') { + return new BaseCliError(codeOrError, message ?? '', { hints }) + } + return codeOrError +} + +export function formatError(error: AnyCliError): string +export function formatError(code: string, message: string, hints?: string[]): string +export function formatError( + codeOrError: string | AnyCliError, + message?: string, + hints?: string[], +): string { + const err = toCliError(codeOrError, message, hints) + const lines = [`Error: ${err.code}`, err.message] + if (err.hints && err.hints.length > 0) { lines.push('') - for (const hint of hints) { + for (const hint of err.hints) { lines.push(` - ${hint}`) } } return chalk.red(lines.join('\n')) } + +export function formatErrorJson(error: AnyCliError): string +export function formatErrorJson(code: string, message: string, hints?: string[]): string +export function formatErrorJson( + codeOrError: string | AnyCliError, + message?: string, + hints?: string[], +): string { + const err = toCliError(codeOrError, message, hints) + return JSON.stringify({ error: { code: err.code, message: err.message, hints: err.hints } }) +}