命令行工具
这个示例构建一个处理 CSV 文件的命令行工具 tally:列出文件中的列,统计某个数值列,把各行转换为 JSON。它从 cli 模板开始,展示 lakutata 命令行工具的各个部分:带参数和选项的命令(@CLIAction 及其 DTO)、输入校验、在终端中询问缺少的选项、输出与退出码、测试,以及不需要 node_modules 就能运行的单文件包。
| 命令 | 作用 |
|---|---|
tally columns <file> | 列出文件中的列,以及每列的类型和值的个数 |
tally stats <file> --column <name> | 给出数值列的个数、总和、最小值、最大值、平均值和中位数 |
tally json <file> | 把各行转换为 JSON:可以选全部列或部分列,数字可以保留为文本,也可以输出为数字 |
1. 创建项目
npx @lakutata/cli create --name tally --template cli --yes
cd tally
rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts项目是一个工作区,工具在 packages/app 中(见 ProjectStructure)。需要的依赖模板里都有:commander 负责读取命令行,@inquirer/prompts 负责在终端中提问。CSV 文件由工具自带的一个小解析器读取,所以不用再安装别的包。这里删掉了模板中的示例命令和它的 DTO。
2. 输入
packages/app/src/dtos/CsvDTO.ts
import {DTO} from 'lakutata'
import {Expect} from 'lakutata/decorator/dto'
/**
* 一个 CSV 文件:<file> [--delimiter <value>]
*/
export class FileDTO extends DTO {
@Expect(DTO.String().required().description('The CSV file'))
public file: string
@Expect(DTO.String().length(1).default(',').description('The character between the values'))
public delimiter: string
}
/**
* 一列的统计:--column <value> [--decimals <value>] [--format <value>]
*/
export class StatsDTO extends FileDTO {
@Expect(DTO.String().required().description('The column to summarize'))
public column: string
@Expect(DTO.Number().integer().min(0).max(6).default(2).description('The decimals of the results'))
public decimals: number
@Expect(DTO.String().valid('text', 'json').default('text').description('The output'))
public format: 'text' | 'json'
}
/**
* 转换为 JSON:[--columns <values...>] [--numbers] [--limit <value>]
*/
export class JsonDTO extends FileDTO {
@Expect(DTO.Array(DTO.String()).min(1).optional().description('The columns to keep, all of them by default'))
public columns?: string[]
@Expect(DTO.Boolean().default(false).description('Write the numeric values as numbers'))
public numbers: boolean
@Expect(DTO.Number().integer().min(1).optional().description('The number of rows, all of them by default'))
public limit?: number
}每个命令都有一个 DTO,DTO 的属性就是命令的参数和选项(见 CLI):
file写在命令里('stats <file>'),所以它是参数。其他属性都是选项,以属性名命名:--delimiter、--column、--decimals……- 选项的形式由属性的类型决定:数字写成
--decimals 3,布尔值是开关--numbers,数组写成--columns product units。 - 帮助中会显示每个属性的
.description()、默认值和允许的取值(.valid())。 - 命令行上都是文本:适配器先把每个值转换为对应属性的类型,再交给 DTO 校验(见 DTOValidation),所以数字不需要
.strict(false)。 StatsDTO和JsonDTO继承自FileDTO:每个命令都接受文件和分隔符。column是必填的,也没有默认值:在终端中运行时如果缺少这个选项,工具会提问(见第 7 节)。
3. 异常
packages/app/src/exceptions/CsvException.ts
import {Exception} from 'lakutata'
/**
* 无法读取的 CSV 文件,或无法使用的列
*/
export class CsvException extends Exception {
public errno: string = 'E_CSV'
}文件和列的错误都用 CsvException 表示(见 Exceptions)。异常的消息是一个模板,抛出时用传入的值填充:new CsvException('The file {file} does not exist', {file: file})。在命令行上,失败的命令会输出 error: 加上这条消息,并以退出码 1 结束。
4. 读取 CSV 文件
packages/app/src/lib/Csv.ts
import {readFile} from 'node:fs/promises'
import {CsvException} from '../exceptions/CsvException.js'
/**
* 一个 CSV 文件:列名取自第一行,每一行按列名存放各自的值
*/
export type CsvTable = {
columns: string[]
rows: Record<string, string>[]
}
/**
* 把 CSV 文本拆成行,每行是一组值。双引号里的值可以包含分隔符、换行和连续两个双引号("")
* @param text
* @param delimiter
*/
export function parseCsv(text: string, delimiter: string): string[][] {
const lines: string[][] = []
let line: string[] = []
let value: string = ''
let quoted: boolean = false
for (let index: number = 0; index < text.length; index++) {
const character: string = text[index]
if (quoted) {
if (character !== '"') value += character
else if (text[index + 1] === '"') value += text[++index]
else quoted = false
} else if (character === '"') {
quoted = true
} else if (character === delimiter) {
line.push(value)
value = ''
} else if (character === '\n' || character === '\r') {
//行以 \n 或 \r\n 结尾
if (character === '\r' && text[index + 1] === '\n') index++
lines.push([...line, value])
line = []
value = ''
} else {
value += character
}
}
//文本末尾没有换行时,补上最后一行
if (line.length || value) lines.push([...line, value])
return lines
}
/**
* 读取 CSV 文件
* @param file
* @param delimiter
*/
export async function readCsv(file: string, delimiter: string): Promise<CsvTable> {
let text: string
try {
text = await readFile(file, 'utf8')
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new CsvException('The file {file} does not exist', {file: file})
throw error
}
//跳过空行
const [columns, ...lines] = parseCsv(text, delimiter).filter((line: string[]): boolean => line.join('') !== '')
if (!columns) throw new CsvException('The file {file} is empty', {file: file})
const rows: Record<string, string>[] = lines.map((line: string[], index: number): Record<string, string> => {
if (line.length !== columns.length) throw new CsvException('The row {row} of {file} has {count} values, not {expected}', {row: index + 1, file: file, count: line.length, expected: columns.length})
return Object.fromEntries(columns.map((column: string, position: number): [string, string] => [column, line[position]]))
})
return {columns: columns, rows: rows}
}
/**
* 判断一个值是不是数字
* @param value
*/
export function isNumber(value: string): boolean {
return value.trim() !== '' && Number.isFinite(Number(value))
}这些是普通函数,不是容器中的对象,由命令直接调用。
parseCsv()按 RFC 4180 的规则把文本拆成行和值:双引号里的值可以包含分隔符、换行和连续两个双引号,行以\n或\r\n结尾。readCsv()用第一行作为列名,并跳过空行。文件不存在、文件为空、某一行的值个数和列数对不上,都会抛出CsvException;文件系统的其他错误(比如给的是目录,或者没有读权限)原样抛出。
5. 命令
packages/app/src/controllers/CsvController.ts
import {Controller} from 'lakutata/com/entrypoint'
import {CLIAction} from 'lakutata/decorator/ctrl'
import {FileDTO, JsonDTO, StatsDTO} from '../dtos/CsvDTO.js'
import {CsvException} from '../exceptions/CsvException.js'
import {type CsvTable, isNumber, readCsv} from '../lib/Csv.js'
/**
* 一列的统计结果
*/
export type ColumnStats = {
column: string
count: number
empty: number
sum: number
min: number
max: number
mean: number
median: number
}
export class CsvController extends Controller {
/**
* 检查这些列是否都在文件中
* @param table
* @param file
* @param columns
*/
protected checkColumns(table: CsvTable, file: string, columns: string[]): void {
for (const column of columns) {
if (!table.columns.includes(column)) throw new CsvException('The column "{column}" is not in {file}: {columns}', {column: column, file: file, columns: table.columns.join(', ')})
}
}
/**
* tally columns sales.csv:列出各列,以及它们的类型和值的个数
* @param input
*/
@CLIAction('columns <file>', FileDTO, {description: 'List the columns of a CSV file'})
public async columns(input: FileDTO): Promise<string> {
const table: CsvTable = await readCsv(input.file, input.delimiter)
const width: number = Math.max(...table.columns.map((column: string): number => column.length))
return table.columns.map((column: string): string => {
const values: string[] = table.rows.map((row: Record<string, string>): string => row[column]).filter((value: string): boolean => value !== '')
const type: string = values.length && values.every(isNumber) ? 'number' : 'text'
const empty: number = table.rows.length - values.length
return `${column.padEnd(width)} ${type.padEnd(6)} ${values.length} values${empty ? `, ${empty} empty` : ''}`
}).join('\n')
}
/**
* tally stats sales.csv --column units:个数、总和、最小值、最大值、平均值和中位数
* @param input
*/
@CLIAction('stats <file>', StatsDTO, {description: 'Compute the statistics of a numeric column'})
public async stats(input: StatsDTO): Promise<string | ColumnStats> {
const table: CsvTable = await readCsv(input.file, input.delimiter)
this.checkColumns(table, input.file, [input.column])
const values: number[] = []
table.rows.forEach((row: Record<string, string>, index: number): void => {
const value: string = row[input.column]
if (value === '') return
if (!isNumber(value)) throw new CsvException('The value "{value}" of the column "{column}" (row {row}) is not a number', {value: value, column: input.column, row: index + 1})
values.push(Number(value))
})
if (!values.length) throw new CsvException('The column "{column}" has no values', {column: input.column})
const sorted: number[] = [...values].sort((a: number, b: number): number => a - b)
const middle: number = Math.floor(sorted.length / 2)
const sum: number = values.reduce((total: number, value: number): number => total + value, 0)
const round = (value: number): number => Number(value.toFixed(input.decimals))
const stats: ColumnStats = {
column: input.column,
count: values.length,
empty: table.rows.length - values.length,
sum: round(sum),
min: sorted[0],
max: sorted[sorted.length - 1],
mean: round(sum / values.length),
median: round(sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2)
}
//对象输出为 JSON,字符串原样输出
if (input.format === 'json') return stats
return Object.entries(stats).map(([name, value]: [string, string | number]): string => `${name.padEnd(6)} ${value}`).join('\n')
}
/**
* tally json sales.csv --columns product units --numbers --limit 2:把各行输出为对象数组
* @param input
*/
@CLIAction('json <file>', JsonDTO, {description: 'Convert a CSV file to JSON'})
public async json(input: JsonDTO): Promise<Record<string, string | number>[]> {
const table: CsvTable = await readCsv(input.file, input.delimiter)
const columns: string[] = input.columns ?? table.columns
this.checkColumns(table, input.file, columns)
return table.rows.slice(0, input.limit).map((row: Record<string, string>): Record<string, string | number> => {
return Object.fromEntries(columns.map((column: string): [string, string | number] => {
return [column, input.numbers && isNumber(row[column]) ? Number(row[column]) : row[column]]
}))
})
}
}- 每个
@CLIAction声明一个命令:命令名和参数('stats <file>')、DTO,以及description,即帮助中这个命令的说明(见 Controllers)。 - 动作收到的是校验后的输入,已经补上默认值:没有给出时,
delimiter为',',decimals为 2。 - 动作的返回值写到 stdout:字符串原样输出(
columns,以及默认的stats),其他值输出为带缩进的 JSON(stats --format json、json)。 - 抛出的异常就是这个命令的错误。
6. 工具的选项
packages/app/src/config/Config.ts
import type {ApplicationOptions} from 'lakutata'
import {buildEntrypoints} from 'lakutata/com/entrypoint'
import {commanderCLIEntrypoint} from 'lakutata/adapter/commander'
import manifest from '../../package.json' with {type: 'json'}
import {CsvController} from '../controllers/CsvController.js'
/**
* 工具的选项
*/
export function config(): ApplicationOptions {
return {
id: 'tally',
name: 'tally',
components: {
entrypoint: buildEntrypoints({
controllers: [CsvController],
//控制器的动作就是命令;在终端里运行时,缺少的必填选项会向用户提问
cli: commanderCLIEntrypoint({bin: 'tally', description: 'Statistics of CSV files, and their conversion to JSON', version: manifest.version})
})
},
bootstrap: ['entrypoint']
}
}这是模板中的 Config.ts,用 CsvController 替换了示例控制器。工具的说明直接写在这里,而模板是从 packages/app/package.json 读取说明的,那里的说明由 lakutata create --description 写入;版本号仍然来自这个文件。bin 是工具在帮助中显示的名字。
7. 运行
构建工具,并在项目根目录保存一个 CSV 文件:
npm run buildsales.csv
date,region,product,units,price
2026-10-01,north,"Tea, green",12,4.50
2026-10-01,south,Coffee,30,3.20
2026-10-02,north,Coffee,18,3.20
2026-10-02,east,"Tea, green",,4.50
2026-10-03,south,Cocoa,7,5.10
2026-10-03,east,Coffee,25,3.40
2026-10-04,north,Cocoa,11,5.10下面的命令用 node 运行构建好的工具;执行 npm link -w app 后,也可以直接用 tally 命令。
$ node packages/app/build/App.js --help
Usage: tally [options] [command]
Statistics of CSV files, and their conversion to JSON
Options:
-V, --version output the version number
-h, --help display help for command
Commands:
columns [options] <file> List the columns of a CSV file
stats [options] <file> Compute the statistics of a numeric column
json [options] <file> Convert a CSV file to JSON
help [command] display help for command
$ node packages/app/build/App.js stats --help
Usage: tally stats [options] <file>
Compute the statistics of a numeric column
Arguments:
file The CSV file
Options:
--delimiter <value> The character between the values (default: ",")
--column <value> The column to summarize
--decimals <value> The decimals of the results (default: 2)
--format <value> The output (choices: "text", "json", default: "text")
-h, --help display help for command$ node packages/app/build/App.js columns sales.csv
date text 7 values
region text 7 values
product text 7 values
units number 6 values, 1 empty
price number 7 values
$ node packages/app/build/App.js stats sales.csv --column units
column units
count 6
empty 1
sum 103
min 7
max 30
mean 17.17
median 15
$ node packages/app/build/App.js stats sales.csv --column price --decimals 3 --format json
{
"column": "price",
"count": 7,
"empty": 0,
"sum": 29,
"min": 3.2,
"max": 5.1,
"mean": 4.143,
"median": 4.5
}
$ node packages/app/build/App.js json sales.csv --columns product units --numbers --limit 2
[
{
"product": "Tea, green",
"units": 12
},
{
"product": "Coffee",
"units": 30
}
]用分号分隔值的文件可以加 --delimiter ';' 读取,TSV 文件则用 --delimiter $'\t'。
缺少选项时提问。 在终端中运行时如果缺少必填选项,工具会向用户提问。问题的文字是属性的说明加上属性名。回答(这里是 price)会按该属性的规则检查,不合法时重新提问:
$ node packages/app/build/App.js stats sales.csv
✔ The column to summarize (column) price
column price
count 7
empty 0
sum 29
min 3.2
max 5.1
mean 4.14
median 4.5只有 stdin 和 stdout 都是终端时,工具才会提问。在脚本或管道中,它不会提问,缺少的选项直接报错:
$ node packages/app/build/App.js stats sales.csv < /dev/null
error: required option '--column <value>' not specified错误与退出码。 不合法的输入会在三个环节之一被拒绝。无论哪个环节,都会向 stderr 写出 error: 和一条消息,工具以退出码 1 结束:
$ node packages/app/build/App.js stats sales.csv --column units --decimals two
error: option '--decimals <value>' argument 'two' is invalid. Not a number.
$ node packages/app/build/App.js stats sales.csv --column units --format xml
error: option '--format <value>' argument 'xml' is invalid. Allowed choices are "text", "json".
$ node packages/app/build/App.js stats sales.csv --column units --decimals 9
error: "decimals" must be less than or equal to 6
$ node packages/app/build/App.js stats sales.csv --column region
error: The value "north" of the column "region" (row 1) is not a number
$ node packages/app/build/App.js columns sale.csv
error: The file sale.csv does not exist
$ echo $?
1- 命令行解析器在 DTO 之前就拒绝不符合命令格式的输入:该是数字的地方给了文本、取值不在
.valid()之内、缺少参数或选项、命令不存在。 - DTO 拒绝违反其他规则的值,比如
.max(6)。 - 只有动作自己才能检查的问题,由动作抛出异常来拒绝:文件不存在,或者某列不全是数字。
命令成功时退出码为 0,--help 和 --version 也是如此,所以脚本可以根据退出码判断每个命令是否成功。
8. 测试
模板中的测试换成了针对这些命令的测试。和模板一样,它在子进程中运行构建好的工具,这里还会先在临时目录中写一个 CSV 文件。子进程的 stdin 不是终端,所以工具不会提问:
packages/app/src/tests/App.spec.ts
import {after, before, describe, it} from 'node:test'
import assert from 'node:assert'
import {execFile, type ExecFileException} from 'node:child_process'
import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'
import {tmpdir} from 'node:os'
import path from 'node:path'
import {fileURLToPath} from 'node:url'
/**
* 编译后的工具
*/
const TOOL: string = fileURLToPath(new URL('../App.js', import.meta.url))
/**
* 带参数运行工具(它的 stdin 不是终端,所以不会提问)
* @param args
*/
function tool(...args: string[]): Promise<{ code: number, stdout: string, stderr: string }> {
return new Promise((resolve: (result: { code: number, stdout: string, stderr: string }) => void): void => {
execFile(process.execPath, [TOOL, ...args], (error: ExecFileException | null, stdout: string, stderr: string): void => resolve({code: error ? (typeof error.code === 'number' ? error.code : 1) : 0, stdout: stdout, stderr: stderr}))
})
}
describe('tally', (): void => {
let directory: string
let file: string
//临时目录里的 CSV 文件:有一个带逗号的引号值、一个空值,换行用的是 Windows 的 \r\n
before((): void => {
directory = mkdtempSync(path.join(tmpdir(), 'tally-'))
file = path.join(directory, 'sales.csv')
writeFileSync(file, 'region,product,units\r\nnorth,"Tea, green",12\r\nsouth,Coffee,30\r\nnorth,Coffee,\r\neast,Cocoa,7\r\n')
})
after((): void => {
rmSync(directory, {recursive: true, force: true})
})
it('lists the columns', async (): Promise<void> => {
const result = await tool('columns', file)
assert.strictEqual(result.code, 0, result.stderr)
assert.strictEqual(result.stdout, 'region text 4 values\nproduct text 4 values\nunits number 3 values, 1 empty\n')
})
it('computes the statistics of a column', async (): Promise<void> => {
const result = await tool('stats', file, '--column', 'units', '--format', 'json')
assert.strictEqual(result.code, 0, result.stderr)
assert.deepStrictEqual(JSON.parse(result.stdout), {column: 'units', count: 3, empty: 1, sum: 49, min: 7, max: 30, mean: 16.33, median: 12})
})
it('converts the rows to JSON', async (): Promise<void> => {
const result = await tool('json', file, '--columns', 'product', 'units', '--numbers', '--limit', '3')
assert.strictEqual(result.code, 0, result.stderr)
assert.deepStrictEqual(JSON.parse(result.stdout), [{product: 'Tea, green', units: 12}, {product: 'Coffee', units: 30}, {product: 'Coffee', units: ''}])
})
it('requires the column outside a terminal', async (): Promise<void> => {
const result = await tool('stats', file)
assert.strictEqual(result.code, 1)
assert.strictEqual(result.stderr, `error: required option '--column <value>' not specified\n`)
})
it('refuses an invalid option', async (): Promise<void> => {
const result = await tool('stats', file, '--column', 'units', '--decimals', '9')
assert.strictEqual(result.code, 1)
assert.strictEqual(result.stderr, 'error: "decimals" must be less than or equal to 6\n')
})
it('fails on a column that is not in the file', async (): Promise<void> => {
const result = await tool('stats', file, '--column', 'price')
assert.strictEqual(result.code, 1)
assert.strictEqual(result.stderr, `error: The column "price" is not in ${file}: region, product, units\n`)
})
it('fails on a value that is not a number', async (): Promise<void> => {
const result = await tool('stats', file, '--column', 'product')
assert.strictEqual(result.code, 1)
assert.strictEqual(result.stderr, 'error: The value "Tea, green" of the column "product" (row 1) is not a number\n')
})
it('shows its version', async (): Promise<void> => {
const {version} = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
assert.strictEqual((await tool('--version')).stdout.trim(), version)
})
})npm testapp │ ✔ lists the columns
app │ ✔ computes the statistics of a column
app │ ✔ converts the rows to JSON
app │ ✔ requires the column outside a terminal
app │ ✔ refuses an invalid option
app │ ✔ fails on a column that is not in the file
app │ ✔ fails on a value that is not a number
app │ ✔ shows its version
app │ ℹ pass 8
✔ test ran in app9. 打包成一个文件
npm run package:bundle✔ ../../dist/tally.cjs (1.1 MB): node ../../dist/tally.cjs这个脚本先构建工具,再由 lakutata package bundle 把工具连同 lakutata、commander 和 @inquirer/prompts 一起打成一个 JavaScript 文件,即项目根目录下的 dist/tally.cjs(输出的路径相对于脚本运行的目录 packages/app)。Node.js 可以在任何目录中运行它,不需要 node_modules,比如在项目旁边的目录中:
mkdir ../tally-bundle && cp dist/tally.cjs sales.csv ../tally-bundle && cd ../tally-bundle
node tally.cjs stats sales.csv --column units --format json{
"column": "units",
"count": 6,
"empty": 1,
"sum": 103,
"min": 7,
"max": 30,
"mean": 17.17,
"median": 15
}- 单文件包是 CommonJS 文件,所以源码中不能使用顶层
await。 npm run package:exe生成dist/tally,这是一个不需要安装 Node.js 就能运行的可执行文件:Node.js 已经嵌在里面(约 110 MB)。它只能在生成它的平台上运行,其他平台的可执行文件怎么生成,项目的 README 里有说明(见 Packaging)。- 模板的
installer功能(lakutata create --with installer)可以生成安装包,即各个系统的软件包(deb、rpm、pkg、msi 等),它们会把tally加入 PATH(见 Installers)。