From 0ea03200ee72a010c51951897dca85afffbfeca1 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Mon, 11 May 2026 16:54:31 +0100 Subject: [PATCH 1/2] refactor: delegate `ol changelog` to `@doist/cli-core/commands` + route cli-core errors through formatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `src/commands/changelog.ts` is now a thin wrapper over `registerChangelogCommand` from `@doist/cli-core/commands`, configured with outline's repo URL, package version, and `bulletMarkers: ['*', '-']`. Local parsing/formatting (~110 lines) deleted — cli-core covers the behavior. - `src/lib/errors.ts` re-exports `CoreCliError` as `BaseCliError` so the top-level handler matches both outline-thrown and cli-core-thrown errors. - `src/lib/output.ts` widens `formatError` to accept a `BaseCliError` instance and adds `formatErrorJson` for `{error: {code, message, hints}}` envelopes. - `src/lib/global-args.ts` adds `isJsonMode()` backed by cli-core's `parseGlobalArgs` argv scan. - `src/index.ts` declares `--json` / `--ndjson` at the program level (with `enablePositionalOptions()`) and routes `BaseCliError` through `formatError` / `formatErrorJson` based on `isJsonMode()`. - Local changelog tests slimmed to two wrapper smoke tests; output tests cover the new instance/JSON paths. Mirrors todoist-cli#319. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 2 +- src/__tests__/changelog.test.ts | 146 ++++++-------------------------- src/__tests__/output.test.ts | 40 ++++++++- src/commands/changelog.ts | 112 ++---------------------- src/index.ts | 12 ++- src/lib/errors.ts | 3 + src/lib/global-args.ts | 5 ++ src/lib/output.ts | 45 +++++++++- 8 files changed, 133 insertions(+), 232 deletions(-) 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.test.ts b/src/__tests__/changelog.test.ts index 03a4cdb..8ce5902 100644 --- a/src/__tests__/changelog.test.ts +++ b/src/__tests__/changelog.test.ts @@ -4,158 +4,62 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('node:fs/promises') import { readFile } from 'node:fs/promises' +import packageJson from '../../package.json' with { type: 'json' } import { registerChangelogCommand } from '../commands/changelog.js' const mockReadFile = vi.mocked(readFile) const SAMPLE_CHANGELOG = `# Changelog -## [1.5.0](https://example.com) (2026-03-15) +## [9.9.0](https://example.com) (2026-05-10) ### Features -* feature five +* delegated to cli-core -## [1.4.0](https://example.com) (2026-03-14) +## [9.8.0](https://example.com) (2026-05-09) ### 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) - -### 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 +* prior release ` -function createProgram() { - const program = new Command() - program.exitOverride() - registerChangelogCommand(program) - return program -} - -describe('changelog command', () => { - let consoleSpy: ReturnType - let consoleErrorSpy: ReturnType +describe('changelog wrapper', () => { + let logSpy: ReturnType beforeEach(() => { - consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) - consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) - process.exitCode = undefined + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) }) afterEach(() => { vi.restoreAllMocks() - process.exitCode = undefined + mockReadFile.mockReset() }) - it('shows last 5 versions by default', async () => { + it('passes the outline CHANGELOG.md path through to cli-core', async () => { mockReadFile.mockResolvedValue(SAMPLE_CHANGELOG) + const program = new Command() + program.exitOverride() + registerChangelogCommand(program) - const program = createProgram() - await program.parseAsync(['node', 'ol', 'changelog']) + await program.parseAsync(['node', 'ol', 'changelog', '-n', '1']) - 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')) + expect(mockReadFile).toHaveBeenCalledTimes(1) + const [path] = mockReadFile.mock.calls[0] + expect(String(path)).toMatch(/\/CHANGELOG\.md$/) }) - 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 () => { + it('emits a footer link pointing at the outline repo and current version', async () => { mockReadFile.mockResolvedValue(SAMPLE_CHANGELOG) + const program = new Command() + program.exitOverride() + registerChangelogCommand(program) - 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']) - - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('Could not read changelog file'), - ) - expect(process.exitCode).toBe(1) - }) - - it('handles invalid count', async () => { - const program = createProgram() - await program.parseAsync(['node', 'ol', 'changelog', '-n', 'abc']) + await program.parseAsync(['node', 'ol', 'changelog', '-n', '1']) - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('Count must be a positive number'), + const all = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n') + expect(all).toContain( + `View full changelog: https://github.com/Doist/outline-cli/blob/v${packageJson.version}/CHANGELOG.md`, ) - expect(process.exitCode).toBe(1) }) }) 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..1a74520 100644 --- a/src/commands/changelog.ts +++ b/src/commands/changelog.ts @@ -1,112 +1,16 @@ -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}`)) - } -} 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: 'https://github.com/Doist/outline-cli', + version: packageJson.version, + bulletMarkers: ['*', '-'], + }) } diff --git a/src/index.ts b/src/index.ts index d62d0fc..0b76680 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() @@ -15,6 +18,9 @@ program .name('ol') .version(packageJson.version) .description('CLI for the Outline wiki/knowledge base API') + .enablePositionalOptions() + .option('--json', 'Output JSON (subcommand results or errors)') + .option('--ndjson', 'Output NDJSON') .option('--no-spinner', 'Disable loading animations') .option('--accessible', 'Render output in screen-reader-friendly mode') .addHelpText( @@ -34,6 +40,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..f5ad362 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 type { BaseCliError, ErrorType } from './errors.js' + +type AnyCliError = BaseCliError export type OutputOptions = ViewOptions & { full?: boolean @@ -79,13 +82,47 @@ 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 resolveErrorParts( + codeOrError: string | AnyCliError, + message?: string, + hints?: string[], +): { code: string; message: string; hints: string[] | undefined; type: ErrorType } { + if (typeof codeOrError === 'string') { + return { code: codeOrError, message: message ?? '', hints, type: 'error' } + } + return { + code: codeOrError.code, + message: codeOrError.message, + hints: codeOrError.hints, + type: codeOrError.type ?? 'error', + } +} + +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 { code, message: msg, hints: h } = resolveErrorParts(codeOrError, message, hints) + const lines = [`Error: ${code}`, msg] + if (h && h.length > 0) { lines.push('') - for (const hint of hints) { + for (const hint of h) { 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 { code, message: msg, hints: h } = resolveErrorParts(codeOrError, message, hints) + return JSON.stringify({ error: { code, message: msg, hints: h } }) +} From 5e0b12f3116e648a2bfbe7ddbf69424b35bd5664 Mon Sep 17 00:00:00 2001 From: Scott Lovegrove Date: Mon, 11 May 2026 17:25:55 +0100 Subject: [PATCH 2/2] refactor: address review on cli-core delegation PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop the program-level --json/--ndjson + enablePositionalOptions experiment. It regressed `ol --no-spinner` / `--accessible` (root-only flags became unknown options on subcommands) and the description was a lie (root --json never reached getOutputOptions). isJsonMode() still works via cli-core's argv scan; the catch in src/index.ts is unchanged. - Simplify resolveErrorParts → toCliError: when given a string, return a real BaseCliError instead of mapping properties by hand. - Derive changelog repoUrl from package.json's repository.url (strip git+/.git) instead of hard-coding the URL. - Rewrite changelog.test.ts to mock @doist/cli-core/commands directly and assert the config object (path basename, repoUrl, version, bulletMarkers ['*', '-']). Drops the brittle coupling to cli-core's internal fs reads and uses basename() for Windows-portable assertions. - Add changelog-integration.test.ts: registers the real changelog command and asserts parseAsync rejects with BaseCliError('INVALID_TYPE') for `-n abc`, then runs that error through formatError + formatErrorJson. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/__tests__/changelog-integration.test.ts | 50 +++++++++++++++ src/__tests__/changelog.test.ts | 67 +++++++-------------- src/commands/changelog.ts | 3 +- src/index.ts | 3 - src/lib/output.ts | 27 ++++----- 5 files changed, 85 insertions(+), 65 deletions(-) create mode 100644 src/__tests__/changelog-integration.test.ts 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 8ce5902..24f0b03 100644 --- a/src/__tests__/changelog.test.ts +++ b/src/__tests__/changelog.test.ts @@ -1,65 +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(), +})) + +vi.mock('@doist/cli-core/commands', () => ({ + registerChangelogCommand: registerCoreChangelogCommand, +})) -import { readFile } from 'node:fs/promises' import packageJson from '../../package.json' with { type: 'json' } import { registerChangelogCommand } from '../commands/changelog.js' -const mockReadFile = vi.mocked(readFile) - -const SAMPLE_CHANGELOG = `# Changelog - -## [9.9.0](https://example.com) (2026-05-10) - -### Features - -* delegated to cli-core - -## [9.8.0](https://example.com) (2026-05-09) - -### Features - -* prior release -` - describe('changelog wrapper', () => { - let logSpy: ReturnType - beforeEach(() => { - logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}) + registerCoreChangelogCommand.mockReset() }) - afterEach(() => { - vi.restoreAllMocks() - mockReadFile.mockReset() - }) - - it('passes the outline CHANGELOG.md path through to cli-core', async () => { - mockReadFile.mockResolvedValue(SAMPLE_CHANGELOG) + it('delegates to @doist/cli-core/commands with the expected config', () => { const program = new Command() - program.exitOverride() registerChangelogCommand(program) - await program.parseAsync(['node', 'ol', 'changelog', '-n', '1']) - - expect(mockReadFile).toHaveBeenCalledTimes(1) - const [path] = mockReadFile.mock.calls[0] - expect(String(path)).toMatch(/\/CHANGELOG\.md$/) + 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('emits a footer link pointing at the outline repo and current version', async () => { - mockReadFile.mockResolvedValue(SAMPLE_CHANGELOG) + it('derives repoUrl from package.json (strips .git / git+ prefix)', () => { const program = new Command() - program.exitOverride() registerChangelogCommand(program) - await program.parseAsync(['node', 'ol', 'changelog', '-n', '1']) - - const all = logSpy.mock.calls.map((c: unknown[]) => c[0]).join('\n') - expect(all).toContain( - `View full changelog: https://github.com/Doist/outline-cli/blob/v${packageJson.version}/CHANGELOG.md`, - ) + const [, config] = registerCoreChangelogCommand.mock.calls[0] + expect(config.repoUrl).not.toMatch(/\.git$/) + expect(config.repoUrl).not.toMatch(/^git\+/) }) }) diff --git a/src/commands/changelog.ts b/src/commands/changelog.ts index 1a74520..5f7dc12 100644 --- a/src/commands/changelog.ts +++ b/src/commands/changelog.ts @@ -5,11 +5,12 @@ 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 REPO_URL = packageJson.repository.url.replace(/^git\+/, '').replace(/\.git$/, '') export function registerChangelogCommand(program: Command): void { registerCoreChangelogCommand(program, { path: CHANGELOG_PATH, - repoUrl: 'https://github.com/Doist/outline-cli', + repoUrl: REPO_URL, version: packageJson.version, bulletMarkers: ['*', '-'], }) diff --git a/src/index.ts b/src/index.ts index 0b76680..03619c2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,9 +18,6 @@ program .name('ol') .version(packageJson.version) .description('CLI for the Outline wiki/knowledge base API') - .enablePositionalOptions() - .option('--json', 'Output JSON (subcommand results or errors)') - .option('--ndjson', 'Output NDJSON') .option('--no-spinner', 'Disable loading animations') .option('--accessible', 'Render output in screen-reader-friendly mode') .addHelpText( diff --git a/src/lib/output.ts b/src/lib/output.ts index f5ad362..c7b105f 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -1,7 +1,7 @@ import { formatJson, formatNdjson, printEmpty, type ViewOptions } from '@doist/cli-core' import chalk from 'chalk' import type { Pagination } from './api.js' -import type { BaseCliError, ErrorType } from './errors.js' +import { BaseCliError } from './errors.js' type AnyCliError = BaseCliError @@ -82,20 +82,15 @@ function pick(obj: T, keys: (keyof T)[]): Partial { return result } -function resolveErrorParts( +function toCliError( codeOrError: string | AnyCliError, message?: string, hints?: string[], -): { code: string; message: string; hints: string[] | undefined; type: ErrorType } { +): AnyCliError { if (typeof codeOrError === 'string') { - return { code: codeOrError, message: message ?? '', hints, type: 'error' } - } - return { - code: codeOrError.code, - message: codeOrError.message, - hints: codeOrError.hints, - type: codeOrError.type ?? 'error', + return new BaseCliError(codeOrError, message ?? '', { hints }) } + return codeOrError } export function formatError(error: AnyCliError): string @@ -105,11 +100,11 @@ export function formatError( message?: string, hints?: string[], ): string { - const { code, message: msg, hints: h } = resolveErrorParts(codeOrError, message, hints) - const lines = [`Error: ${code}`, msg] - if (h && h.length > 0) { + 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 h) { + for (const hint of err.hints) { lines.push(` - ${hint}`) } } @@ -123,6 +118,6 @@ export function formatErrorJson( message?: string, hints?: string[], ): string { - const { code, message: msg, hints: h } = resolveErrorParts(codeOrError, message, hints) - return JSON.stringify({ error: { code, message: msg, hints: h } }) + const err = toCliError(codeOrError, message, hints) + return JSON.stringify({ error: { code: err.code, message: err.message, hints: err.hints } }) }