diff --git a/packages/pretty-cli/README.md b/packages/pretty-cli/README.md index 396787510..91d9ab51a 100644 --- a/packages/pretty-cli/README.md +++ b/packages/pretty-cli/README.md @@ -39,7 +39,7 @@ prettyCLI Pretty CLI also provides a common UI interface, providing log functions to output coloured messages to the UI: ``` -const ui = require('@tryghost/pretty-cli/ui');` +const ui = require('@tryghost/pretty-cli/lib/ui'); ui.log.info('Done'); ui.log.warn('Uh Oh', 'Something went wrong'); @@ -49,10 +49,21 @@ You can also grab a fresh instance of the api with `prettyCLI.Api.get()`. The style rules used are available at `prettyCLI.styles`. +## TypeScript + +The package includes declarations for its standard command, option, help and +output APIs, including the existing `lib/` entry points. Both ESM imports and +CommonJS `import = require()` are supported. Runtime code remains CommonJS. + +Parsed argument values are `unknown` because sywac's option configuration is +dynamic. Narrow them before use; command `setup` callbacks receive the typed API +and `parse()` returns a typed result. These declarations do not model sywac's +internal type factories. + ## Test - `pnpm lint` runs oxlint -- `pnpm test` runs lint and tests +- `pnpm test` runs type checks, runtime tests and lint # Copyright & License diff --git a/packages/pretty-cli/index.d.ts b/packages/pretty-cli/index.d.ts new file mode 100644 index 000000000..a334dd2fc --- /dev/null +++ b/packages/pretty-cli/index.d.ts @@ -0,0 +1,3 @@ +import prettyCli = require('./lib/pretty-cli'); + +export = prettyCli; diff --git a/packages/pretty-cli/lib/pretty-cli.d.ts b/packages/pretty-cli/lib/pretty-cli.d.ts new file mode 100644 index 000000000..9b7498a92 --- /dev/null +++ b/packages/pretty-cli/lib/pretty-cli.d.ts @@ -0,0 +1,197 @@ +import ui = require('./ui'); +import styles = require('./styles'); + +declare namespace prettyCli { + /** Parsed arguments are dynamic; narrow values before using them. */ + type Args = Record; + type Flags = string | string[]; + type Version = string | (() => string); + type Style = (text: string, details?: unknown) => string; + type Styles = Partial< + Record< + | 'group' + | 'groupError' + | 'flags' + | 'flagsError' + | 'desc' + | 'descError' + | 'hints' + | 'hintsError' + | 'messages' + | 'usagePrefix' + | 'usagePositionals' + | 'usageCommandPlaceholder' + | 'usageArgsPlaceholder' + | 'usageOptionsPlaceholder' + | 'example' + | 'all', + Style + > + >; + type OutputSettings = { + lineSep?: string; + sectionSep?: string; + pad?: string; + indent?: string; + split?: string | RegExp; + maxWidth?: number; + examplePrefix?: string; + showHelpOnError?: boolean; + }; + type ApiOptions = { + helpOpts?: OutputSettings; + name?: string; + parentName?: string; + strictMode?: boolean; + showHelpByDefault?: boolean; + }; + type OptionOptions = { + flags?: string; + aliases?: Flags; + type?: string; + desc?: string; + description?: string; + defaultValue?: unknown; + required?: boolean; + strict?: boolean; + hidden?: boolean; + hints?: string; + group?: string; + choices?: unknown[]; + version?: Version; + mustExist?: boolean; + dirAllowed?: boolean; + fileAllowed?: boolean; + normalize?: boolean; + asObject?: boolean; + asPosix?: boolean; + asWin32?: boolean; + coerce?: (value: unknown) => unknown; + }; + type PositionalParameters = Array | Record; + type PositionalOptions = OptionOptions & { + params?: PositionalParameters; + paramsDescription?: string | string[]; + paramsDesc?: string | string[]; + paramsGroup?: string; + ignore?: string[]; + }; + type CommandHandler = (argv: Args, context: Context) => unknown; + type CommandOptions = PositionalOptions & { + setup?: (api: Api) => void; + run?: CommandHandler; + }; + type CommandDirectoryOptions = CommandOptions & { extensions?: string[] }; + type CommandDefinition = CommandOptions & ({ flags: string } | { aliases: Flags }); + type ExampleOptions = { flags?: string; desc?: string; description?: string; group?: string }; + type UsageOptions = { + usage?: string; + prefix?: string; + commandPlaceholder?: string; + argsPlaceholder?: string; + optionsPlaceholder?: string; + }; + type Details = { args: string[]; types: unknown[] }; + type ParseResult = { + code: number; + output: string; + errors: unknown[]; + argv: Args; + details: Details; + }; + type Context = ParseResult & { + commandHandlerRun: boolean; + helpRequested: boolean | OutputSettings; + versionRequested: boolean | { version?: Version }; + cliMessage(message: unknown, ...args: unknown[]): void; + deferHelp(options?: OutputSettings): Context; + }; + type Api = CliApi; +} + +declare class CliApi { + constructor(options?: prettyCli.ApiOptions); + static get(options?: prettyCli.ApiOptions): CliApi; + static readonly DEFAULT_COMMAND_INDICATOR: string; + readonly name: string; + readonly parentName: string; + readonly types: unknown[]; + + configure(options?: prettyCli.ApiOptions): this; + newChild(name: string, options?: prettyCli.ApiOptions): CliApi; + strict(enabled?: boolean): this; + showHelpByDefault(enabled?: boolean): this; + preface(icon: string, slogan?: string): this; + usage(usage: string | prettyCli.UsageOptions): this; + groupOrder(groups?: string[]): this; + example(example: string | prettyCli.ExampleOptions, options?: prettyCli.ExampleOptions): this; + exampleOrder(groups?: string[]): this; + epilogue(text: string): this; + outputSettings(settings: prettyCli.OutputSettings): this; + style(hooks: prettyCli.Styles): this; + + command(options: prettyCli.CommandDefinition): this; + command(flags: string, options?: prettyCli.CommandOptions | prettyCli.CommandHandler): this; + commandDirectory(directory: string, options?: prettyCli.CommandDirectoryOptions): this; + commandDirectory(options?: prettyCli.CommandDirectoryOptions): this; + positional( + flags: string | prettyCli.PositionalParameters | prettyCli.PositionalOptions, + options?: prettyCli.PositionalOptions, + ): this; + option( + flags: prettyCli.Flags | prettyCli.OptionOptions, + options?: prettyCli.OptionOptions, + ): this; + boolean( + flags: prettyCli.Flags | prettyCli.OptionOptions, + options?: prettyCli.OptionOptions, + ): this; + string( + flags: prettyCli.Flags | prettyCli.OptionOptions, + options?: prettyCli.OptionOptions, + ): this; + number( + flags: prettyCli.Flags | prettyCli.OptionOptions, + options?: prettyCli.OptionOptions, + ): this; + path(flags: prettyCli.Flags | prettyCli.OptionOptions, options?: prettyCli.OptionOptions): this; + file(flags: prettyCli.Flags | prettyCli.OptionOptions, options?: prettyCli.OptionOptions): this; + dir(flags: prettyCli.Flags | prettyCli.OptionOptions, options?: prettyCli.OptionOptions): this; + enumeration( + flags: prettyCli.Flags | prettyCli.OptionOptions, + options?: prettyCli.OptionOptions, + ): this; + help( + flags?: prettyCli.Flags | prettyCli.OptionOptions, + options?: prettyCli.OptionOptions, + ): this; + version( + flags?: prettyCli.Flags | prettyCli.OptionOptions, + options?: prettyCli.OptionOptions, + ): this; + array( + flags: prettyCli.Flags | prettyCli.OptionOptions, + options?: prettyCli.OptionOptions, + ): this; + stringArray( + flags: prettyCli.Flags | prettyCli.OptionOptions, + options?: prettyCli.OptionOptions, + ): this; + numberArray( + flags: prettyCli.Flags | prettyCli.OptionOptions, + options?: prettyCli.OptionOptions, + ): this; + check(handler: prettyCli.CommandHandler): this; + parse(args?: string | string[]): Promise; + parseAndExit(args?: string | string[]): Promise; + getHelp(options?: prettyCli.OutputSettings): string; +} + +/** The styled process-wide instance; Api.get() creates an unstyled fresh instance. */ +declare const prettyCli: CliApi & { + Api: typeof CliApi; + styles: typeof styles; + ui: typeof ui; +}; + +export = prettyCli; diff --git a/packages/pretty-cli/lib/styles.d.ts b/packages/pretty-cli/lib/styles.d.ts new file mode 100644 index 000000000..f22c2d080 --- /dev/null +++ b/packages/pretty-cli/lib/styles.d.ts @@ -0,0 +1,13 @@ +declare const styles: { + usagePrefix(text: string): string; + group(text: string): string; + flags(text: string): string; + hints(text: string): string; + groupError(text: string): string; + flagsError(text: string): string; + descError(text: string): string; + hintsError(text: string): string; + messages(text: string): string; +}; + +export = styles; diff --git a/packages/pretty-cli/lib/ui.d.ts b/packages/pretty-cli/lib/ui.d.ts new file mode 100644 index 000000000..d959cb3d0 --- /dev/null +++ b/packages/pretty-cli/lib/ui.d.ts @@ -0,0 +1,12 @@ +type Log = ((...args: unknown[]) => void) & { + ok(...args: unknown[]): void; + trace(...args: unknown[]): void; + debug(...args: unknown[]): void; + info(...args: unknown[]): void; + warn(...args: unknown[]): void; + error(...args: unknown[]): void; + fatal(...args: unknown[]): void; +}; + +declare const ui: { log: Log }; +export = ui; diff --git a/packages/pretty-cli/package.json b/packages/pretty-cli/package.json index 3367b448f..76d54f5fb 100644 --- a/packages/pretty-cli/package.json +++ b/packages/pretty-cli/package.json @@ -11,23 +11,27 @@ }, "files": [ "index.js", + "index.d.ts", "lib" ], "main": "index.js", + "types": "index.d.ts", "publishConfig": { "access": "public" }, "scripts": { "dev": "echo \"Implement me!\"", - "test": "NODE_ENV=testing vitest run --config ../../vitest.config.ts --root . --coverage", + "test": "pnpm test:types && NODE_ENV=testing vitest run --config ../../vitest.config.ts --root . --coverage", "lint": "oxlint -c ../../.oxlintrc.json .", - "posttest": "pnpm run lint" + "posttest": "pnpm run lint", + "test:types": "tsc --noEmit -p test/types/tsconfig.json" }, "dependencies": { "chalk": "^6.0.0", "sywac": "^1.3.0" }, "devDependencies": { - "sinon": "catalog:" + "sinon": "catalog:", + "typescript": "catalog:" } } diff --git a/packages/pretty-cli/test/pretty-cli.test.js b/packages/pretty-cli/test/pretty-cli.test.js index f489b255f..5f822d392 100644 --- a/packages/pretty-cli/test/pretty-cli.test.js +++ b/packages/pretty-cli/test/pretty-cli.test.js @@ -1,5 +1,8 @@ const assert = require('assert/strict'); const sinon = require('sinon'); +const { mkdtempSync, writeFileSync, rmSync } = require('node:fs'); +const { tmpdir } = require('node:os'); +const { join } = require('node:path'); const prettyCLI = require('../'); describe('API', function () { @@ -38,6 +41,110 @@ describe('API', function () { }); }); +describe('sywac compatibility', function () { + it('accepts string option and command aliases', async function () { + let received; + const api = prettyCLI.Api.get({ name: 'test' }); + api.string({ aliases: 'name' }).command({ + aliases: 'run', + run(argv) { + received = argv.name; + }, + }); + const result = await api.parse(['run', '--name', 'example']); + assert.equal(result.code, 0, result.output); + assert.equal(received, 'example'); + }); + + for (const [name, definition] of [ + [ + 'a positional map', + { '': { desc: 'Name' }, '[destination]': { desc: 'Destination' } }, + ], + ['positional objects and strings', [{ flags: '', desc: 'Name' }, '[destination]']], + [ + 'positional options', + { params: { '': { desc: 'Name' }, '[destination]': { desc: 'Destination' } } }, + ], + ]) { + it(`parses ${name}`, async function () { + const api = prettyCLI.Api.get({ name: 'test' }); + assert.equal(api.positional(definition), api); + const result = await api.parse(['example', 'output']); + assert.equal(result.code, 0, result.output); + assert.equal(result.argv.name, 'example'); + assert.equal(result.argv.destination, 'output'); + }); + } + + for (const mode of ['options only', 'no arguments', 'directory and options']) { + it(`loads commands with ${mode}`, async function () { + const directory = mkdtempSync(join(tmpdir(), 'pretty-cli-commands-')); + const loaderPath = join(directory, 'load.cjs'); + const defaultPath = join(directory, 'default.js'); + const customPath = join(directory, 'custom.cjs'); + try { + writeFileSync( + loaderPath, + `module.exports = (api, options) => options + ? api.commandDirectory(options) : api.commandDirectory();`, + ); + for (const [path, command] of [ + [defaultPath, 'default'], + [customPath, 'custom'], + ]) { + writeFileSync( + path, + `module.exports = { + flags: '${command} ', + run(argv) { module.exports.received = argv.name; } + };`, + ); + } + const api = prettyCLI.Api.get({ name: 'test', strictMode: true }); + const load = require(loaderPath); + const returned = + mode === 'directory and options' + ? api.commandDirectory(directory, { extensions: ['.cjs'] }) + : load(api, mode === 'options only' ? { extensions: ['.cjs'] } : undefined); + assert.equal(returned, api); + const command = mode === 'no arguments' ? 'default' : 'custom'; + const result = await api.parse([command, 'example']); + assert.equal(result.code, 0, result.output); + assert.equal( + require(command === 'default' ? defaultPath : customPath).received, + 'example', + ); + const excluded = await api.parse([ + command === 'default' ? 'custom' : 'default', + 'example', + ]); + assert.notEqual(excluded.code, 0); + } finally { + for (const path of [loaderPath, defaultPath, customPath]) { + delete require.cache[path]; + } + rmSync(directory, { recursive: true, force: true }); + } + }); + } + + it('calls a function-valued version', async function () { + let called = 0; + const api = prettyCLI.Api.get({ name: 'test' }); + api.version('--version', { + version() { + called += 1; + return '1.0.0'; + }, + }); + const result = await api.parse(['--version']); + assert.equal(result.code, 0, result.output); + assert.equal(result.output, '1.0.0'); + assert.equal(called, 1); + }); +}); + describe('styles', function () { it('usagePrefix styles first word and indents the remainder', function () { const out = prettyCLI.styles.usagePrefix('Usage: app --help'); diff --git a/packages/pretty-cli/test/types/consumer.cts b/packages/pretty-cli/test/types/consumer.cts new file mode 100644 index 000000000..6215ab1e9 --- /dev/null +++ b/packages/pretty-cli/test/types/consumer.cts @@ -0,0 +1,50 @@ +import cli = require('../../index.js'); +import ui = require('../../lib/ui'); +import styles = require('../../lib/styles'); + +const api: cli.Api = new cli.Api({ name: 'test', strictMode: true }); +api.positional('', { hidden: true }) + .boolean('--force', { defaultValue: false }) + .string(['--name', '-n']) + .number('--count', { defaultValue: 1 }) + .option('--custom', { type: 'string' }) + .enumeration('--mode', { choices: ['a', 'b'] }) + .path('--path').file('--file').dir('--dir') + .array('--values', { type: 'string' }) + .stringArray('--names').numberArray('--counts') + .version('-v, --version', { version: '1.0.0' }) + .command('run', async (argv, context) => { + if (typeof argv.count === 'number') { + ui.log.info(argv.count); + } + context.deferHelp(); + }) + .check((argv, context) => { + if (argv.force === false) { + context.cliMessage('Confirmation required'); + } + }) + .usage({ prefix: 'Usage:', commandPlaceholder: '' }) + .groupOrder(['Commands:', 'Options:']) + .example('test run', { desc: 'Run a command' }) + .exampleOrder(['Examples:']) + .outputSettings({ maxWidth: 80 }).showHelpByDefault().epilogue('Done'); + +const help: string = api.getHelp(); +const label: string = styles.group(help); +cli.ui.log(label); + +// These sywac forms must work for both ESM and CommonJS consumers. +api.positional({'': {desc: 'Name', required: true}}).help(); +api.positional([{flags: '', desc: 'Name'}, '[destination]']).help(); +api.positional({params: {'': {desc: 'Name'}}}).help(); +api.commandDirectory('commands', {extensions: ['.cjs']}).help(); +api.commandDirectory({extensions: ['.cjs']}).help(); +api.commandDirectory().help(); + +// @ts-expect-error Positional map entries must use typed option values. +api.positional({'': {required: 'yes'}}); +// @ts-expect-error Positional array entries must use typed option values. +api.positional([{flags: '', required: 'yes'}]); +// @ts-expect-error Directory extensions must be an array of strings. +api.commandDirectory({extensions: '.cjs'}); diff --git a/packages/pretty-cli/test/types/consumer.mts b/packages/pretty-cli/test/types/consumer.mts new file mode 100644 index 000000000..2a0652053 --- /dev/null +++ b/packages/pretty-cli/test/types/consumer.mts @@ -0,0 +1,60 @@ +import cli from '../../index.js'; +import ui from '../../lib/ui.js'; +import styles from '../../lib/styles.js'; +import implementation from '../../lib/pretty-cli.js'; + +const fresh: cli.Api = cli.Api.get({ name: 'gx' }); +fresh.help('-h, --help').style(cli.styles).preface('Ghost Dev Tools').strict(); +fresh.command({ + flags: 'generate', + setup(group) { + group.command({ + flags: 'package ', + setup(command) { + command.string('--description', { defaultValue: 'Example' }); + }, + async run(argv, context) { + if (typeof argv.name === 'string') { + cli.ui.log.info(argv.name); + } else { + context.cliMessage('Expected a package name'); + } + } + }); + } +}); +fresh.string({ aliases: 'name' }); +fresh.command({ aliases: 'run', run() {} }); +fresh.version('--version', { version: () => '1.0.0' }); +const deferredVersion: cli.Context['versionRequested'] = { version: () => '1.0.0' }; +cli.ui.log(deferredVersion); +const result: cli.ParseResult = await fresh.parse(['--help']); +const code: number = result.code; +const output: string = result.output; +const args: cli.Args = await fresh.parseAndExit([]); +const styled: string = styles.flags(output); +ui.log('Result', code, styled, args); +implementation.ui.log.ok('Done'); + +// Catch accidental erosion of the declaration boundary to untyped results. +type Assert = T; +export type Checks = [ + Assert extends Promise ? true : false>, + Assert, + Assert +]; + +// These sywac forms must work for both ESM and CommonJS consumers. +fresh.positional({'': {desc: 'Name', required: true}}).help(); +fresh.positional([{flags: '', desc: 'Name'}, '[destination]']).help(); +fresh.positional({params: {'': {desc: 'Name'}}}).help(); +fresh.commandDirectory('commands', {extensions: ['.cjs']}).help(); +fresh.commandDirectory({extensions: ['.cjs']}).help(); +fresh.commandDirectory().help(); + +// @ts-expect-error Positional map entries must use typed option values. +fresh.positional({'': {required: 'yes'}}); +// @ts-expect-error Positional array entries must use typed option values. +fresh.positional([{flags: '', required: 'yes'}]); +// @ts-expect-error Directory extensions must be an array of strings. +fresh.commandDirectory({extensions: '.cjs'}); diff --git a/packages/pretty-cli/test/types/tsconfig.json b/packages/pretty-cli/test/types/tsconfig.json new file mode 100644 index 000000000..c3e00508f --- /dev/null +++ b/packages/pretty-cli/test/types/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "types": [] + }, + "include": ["*.mts", "*.cts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bc673eec..d2697a499 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -661,6 +661,9 @@ importers: sinon: specifier: 'catalog:' version: 22.1.0 + typescript: + specifier: 'catalog:' + version: 7.0.2 packages/pretty-stream: dependencies: diff --git a/vitest.config.ts b/vitest.config.ts index 096dc4bfa..69fc14baf 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ coverage: { provider: 'v8', include: ['**/lib/**'], - exclude: ['**/build/**', '**/test/**'], + exclude: ['**/build/**', '**/test/**', '**/*.d.ts'], reporter: ['text', 'cobertura'], thresholds: { lines: 90,