lakutata 3.0.0 → 3.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -30
- package/README.zh-CN.md +130 -0
- package/dist/cjs/exports/QueueComponent.d.ts +1 -0
- package/dist/cjs/exports/QueueComponent.js +20 -0
- package/dist/esm/exports/QueueComponent.js +1 -0
- package/dist/types/exports/QueueComponent.d.ts +1 -0
- package/doc/en/Application.md +107 -0
- package/doc/en/CLI.md +274 -0
- package/doc/en/Cache.md +379 -0
- package/doc/en/Command.md +110 -0
- package/doc/en/ContainerdIntegration.md +17 -16
- package/doc/en/Controllers.md +256 -0
- package/doc/en/DBus.md +291 -0
- package/doc/en/DTOValidation.md +72 -43
- package/doc/en/Database.md +188 -0
- package/doc/en/DatabaseDrivers.md +287 -0
- package/doc/en/Decorator.md +52 -40
- package/doc/en/DockerIntegration.md +7 -6
- package/doc/en/Entities.md +240 -0
- package/doc/en/Entrypoint.md +216 -0
- package/doc/en/Exceptions.md +174 -0
- package/doc/en/GRPC.md +251 -0
- package/doc/en/GettingStarted.md +165 -0
- package/doc/en/HTTP.md +511 -0
- package/doc/en/HelperFunctions.md +11 -11
- package/doc/en/I18n.md +119 -115
- package/doc/en/Injection.md +144 -0
- package/doc/en/Installers.md +280 -0
- package/doc/en/Logger.md +206 -0
- package/doc/en/MCP.md +293 -0
- package/doc/en/Middlewares.md +213 -0
- package/doc/en/Migrations.md +202 -0
- package/doc/en/MongoEmbedded.md +149 -0
- package/doc/en/Monitor.md +164 -0
- package/doc/en/Mutex.md +27 -25
- package/doc/en/NATS.md +246 -0
- package/doc/en/NativeAddons.md +212 -0
- package/doc/en/Objects.md +143 -0
- package/doc/en/Overview.md +63 -85
- package/doc/en/Packaging.md +193 -0
- package/doc/en/ProjectStructure.md +124 -0
- package/doc/en/Queue.md +269 -0
- package/doc/en/Schedules.md +217 -0
- package/doc/en/Security.md +10 -9
- package/doc/en/ServiceCalls.md +508 -0
- package/doc/en/Time.md +19 -20
- package/doc/en/UpgradingFrom1.md +435 -0
- package/doc/en/UpgradingFrom2.md +315 -0
- package/doc/en/WebRTC.md +1364 -0
- package/doc/en/Workspaces.md +189 -0
- package/doc/en/examples/BackgroundJobs.md +677 -0
- package/doc/en/examples/CommandLineTool.md +664 -0
- package/doc/en/examples/DesktopApp.md +809 -0
- package/doc/en/examples/DeviceService.md +607 -0
- package/doc/en/examples/MCPServer.md +653 -0
- package/doc/en/examples/Meeting.md +573 -0
- package/doc/en/examples/README.md +17 -0
- package/doc/en/examples/RestAPI.md +490 -0
- package/doc/en/examples/ServiceCalls.md +829 -0
- package/doc/en/examples/WebApp.md +568 -0
- package/doc/zh/Application.md +75 -0
- package/doc/zh/CLI.md +250 -0
- package/doc/zh/Cache.md +328 -0
- package/doc/zh/Command.md +78 -0
- package/doc/zh/ContainerdIntegration.md +9 -9
- package/doc/zh/Controllers.md +228 -0
- package/doc/zh/DBus.md +257 -0
- package/doc/zh/DTOValidation.md +49 -19
- package/doc/zh/Database.md +175 -0
- package/doc/zh/DatabaseDrivers.md +261 -0
- package/doc/zh/Decorator.md +25 -18
- package/doc/zh/DockerIntegration.md +6 -6
- package/doc/zh/Entities.md +224 -0
- package/doc/zh/Entrypoint.md +193 -0
- package/doc/zh/Exceptions.md +158 -0
- package/doc/zh/GRPC.md +226 -0
- package/doc/zh/GettingStarted.md +148 -0
- package/doc/zh/HTTP.md +446 -0
- package/doc/zh/HelperFunctions.md +2 -2
- package/doc/zh/I18n.md +26 -26
- package/doc/zh/Injection.md +129 -0
- package/doc/zh/Installers.md +232 -0
- package/doc/zh/Logger.md +188 -0
- package/doc/zh/MCP.md +265 -0
- package/doc/zh/Middlewares.md +198 -0
- package/doc/zh/Migrations.md +178 -0
- package/doc/zh/MongoEmbedded.md +118 -0
- package/doc/zh/Monitor.md +145 -0
- package/doc/zh/Mutex.md +10 -10
- package/doc/zh/NATS.md +207 -0
- package/doc/zh/NativeAddons.md +180 -0
- package/doc/zh/Objects.md +119 -0
- package/doc/zh/Overview.md +47 -71
- package/doc/zh/Packaging.md +154 -0
- package/doc/zh/ProjectStructure.md +102 -0
- package/doc/zh/Queue.md +214 -0
- package/doc/zh/Schedules.md +193 -0
- package/doc/zh/Security.md +6 -6
- package/doc/zh/ServiceCalls.md +462 -0
- package/doc/zh/Time.md +2 -2
- package/doc/zh/UpgradingFrom1.md +347 -0
- package/doc/zh/UpgradingFrom2.md +230 -0
- package/doc/zh/WebRTC.md +1049 -0
- package/doc/zh/Workspaces.md +150 -0
- package/doc/zh/examples/BackgroundJobs.md +618 -0
- package/doc/zh/examples/CommandLineTool.md +618 -0
- package/doc/zh/examples/DesktopApp.md +748 -0
- package/doc/zh/examples/DeviceService.md +546 -0
- package/doc/zh/examples/MCPServer.md +604 -0
- package/doc/zh/examples/Meeting.md +524 -0
- package/doc/zh/examples/README.md +15 -0
- package/doc/zh/examples/RestAPI.md +458 -0
- package/doc/zh/examples/ServiceCalls.md +765 -0
- package/doc/zh/examples/WebApp.md +520 -0
- package/package.json +46 -17
- package/doc/en/ActionPattern.md +0 -408
- package/doc/en/Core.md +0 -312
- package/doc/en/ORMIntegration.md +0 -341
- package/doc/zh/ActionPattern.md +0 -334
- package/doc/zh/Core.md +0 -256
- package/doc/zh/ORMIntegration.md +0 -319
|
@@ -0,0 +1,618 @@
|
|
|
1
|
+
# 命令行工具
|
|
2
|
+
|
|
3
|
+
这个示例构建一个处理 CSV 文件的命令行工具 `tally`:列出文件中的列,统计某个数值列,把各行转换为 JSON。它从 `cli` 模板开始,展示 lakutata 命令行工具的各个部分:带参数和选项的命令(`@CLIAction` 及其 DTO)、输入校验、在终端中询问缺少的选项、输出与退出码、测试,以及不需要 `node_modules` 就能运行的单文件包。
|
|
4
|
+
|
|
5
|
+
| 命令 | 作用 |
|
|
6
|
+
|---|---|
|
|
7
|
+
| `tally columns <file>` | 列出文件中的列,以及每列的类型和值的个数 |
|
|
8
|
+
| `tally stats <file> --column <name>` | 给出数值列的个数、总和、最小值、最大值、平均值和中位数 |
|
|
9
|
+
| `tally json <file>` | 把各行转换为 JSON:可以选全部列或部分列,数字可以保留为文本,也可以输出为数字 |
|
|
10
|
+
|
|
11
|
+
## 1. 创建项目
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx @lakutata/cli create --name tally --template cli --yes
|
|
15
|
+
cd tally
|
|
16
|
+
rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
项目是一个工作区,工具在 `packages/app` 中(见 [ProjectStructure](../ProjectStructure.md))。需要的依赖模板里都有:`commander` 负责读取命令行,`@inquirer/prompts` 负责在终端中提问。CSV 文件由工具自带的一个小解析器读取,所以不用再安装别的包。这里删掉了模板中的示例命令和它的 DTO。
|
|
20
|
+
|
|
21
|
+
## 2. 输入
|
|
22
|
+
|
|
23
|
+
**`packages/app/src/dtos/CsvDTO.ts`**
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
import {DTO} from 'lakutata'
|
|
27
|
+
import {Expect} from 'lakutata/decorator/dto'
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 一个 CSV 文件:<file> [--delimiter <value>]
|
|
31
|
+
*/
|
|
32
|
+
export class FileDTO extends DTO {
|
|
33
|
+
@Expect(DTO.String().required().description('The CSV file'))
|
|
34
|
+
public file: string
|
|
35
|
+
|
|
36
|
+
@Expect(DTO.String().length(1).default(',').description('The character between the values'))
|
|
37
|
+
public delimiter: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 一列的统计:--column <value> [--decimals <value>] [--format <value>]
|
|
42
|
+
*/
|
|
43
|
+
export class StatsDTO extends FileDTO {
|
|
44
|
+
@Expect(DTO.String().required().description('The column to summarize'))
|
|
45
|
+
public column: string
|
|
46
|
+
|
|
47
|
+
@Expect(DTO.Number().integer().min(0).max(6).default(2).description('The decimals of the results'))
|
|
48
|
+
public decimals: number
|
|
49
|
+
|
|
50
|
+
@Expect(DTO.String().valid('text', 'json').default('text').description('The output'))
|
|
51
|
+
public format: 'text' | 'json'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 转换为 JSON:[--columns <values...>] [--numbers] [--limit <value>]
|
|
56
|
+
*/
|
|
57
|
+
export class JsonDTO extends FileDTO {
|
|
58
|
+
@Expect(DTO.Array(DTO.String()).min(1).optional().description('The columns to keep, all of them by default'))
|
|
59
|
+
public columns?: string[]
|
|
60
|
+
|
|
61
|
+
@Expect(DTO.Boolean().default(false).description('Write the numeric values as numbers'))
|
|
62
|
+
public numbers: boolean
|
|
63
|
+
|
|
64
|
+
@Expect(DTO.Number().integer().min(1).optional().description('The number of rows, all of them by default'))
|
|
65
|
+
public limit?: number
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
每个命令都有一个 DTO,DTO 的属性就是命令的参数和选项(见 [CLI](../CLI.md)):
|
|
70
|
+
|
|
71
|
+
- `file` 写在命令里(`'stats <file>'`),所以它是参数。其他属性都是选项,以属性名命名:`--delimiter`、`--column`、`--decimals`……
|
|
72
|
+
- 选项的形式由属性的类型决定:数字写成 `--decimals 3`,布尔值是开关 `--numbers`,数组写成 `--columns product units`。
|
|
73
|
+
- 帮助中会显示每个属性的 `.description()`、默认值和允许的取值(`.valid()`)。
|
|
74
|
+
- 命令行上都是文本:适配器先把每个值转换为对应属性的类型,再交给 DTO 校验(见 [DTOValidation](../DTOValidation.md)),所以数字不需要 `.strict(false)`。
|
|
75
|
+
- `StatsDTO` 和 `JsonDTO` 继承自 `FileDTO`:每个命令都接受文件和分隔符。
|
|
76
|
+
- `column` 是必填的,也没有默认值:在终端中运行时如果缺少这个选项,工具会提问(见第 7 节)。
|
|
77
|
+
|
|
78
|
+
## 3. 异常
|
|
79
|
+
|
|
80
|
+
**`packages/app/src/exceptions/CsvException.ts`**
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
import {Exception} from 'lakutata'
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 无法读取的 CSV 文件,或无法使用的列
|
|
87
|
+
*/
|
|
88
|
+
export class CsvException extends Exception {
|
|
89
|
+
public errno: string = 'E_CSV'
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
文件和列的错误都用 `CsvException` 表示(见 [Exceptions](../Exceptions.md))。异常的消息是一个模板,抛出时用传入的值填充:`new CsvException('The file {file} does not exist', {file: file})`。在命令行上,失败的命令会输出 `error:` 加上这条消息,并以退出码 1 结束。
|
|
94
|
+
|
|
95
|
+
## 4. 读取 CSV 文件
|
|
96
|
+
|
|
97
|
+
**`packages/app/src/lib/Csv.ts`**
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
import {readFile} from 'node:fs/promises'
|
|
101
|
+
import {CsvException} from '../exceptions/CsvException.js'
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 一个 CSV 文件:列名取自第一行,每一行按列名存放各自的值
|
|
105
|
+
*/
|
|
106
|
+
export type CsvTable = {
|
|
107
|
+
columns: string[]
|
|
108
|
+
rows: Record<string, string>[]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 把 CSV 文本拆成行,每行是一组值。双引号里的值可以包含分隔符、换行和连续两个双引号("")
|
|
113
|
+
* @param text
|
|
114
|
+
* @param delimiter
|
|
115
|
+
*/
|
|
116
|
+
export function parseCsv(text: string, delimiter: string): string[][] {
|
|
117
|
+
const lines: string[][] = []
|
|
118
|
+
let line: string[] = []
|
|
119
|
+
let value: string = ''
|
|
120
|
+
let quoted: boolean = false
|
|
121
|
+
for (let index: number = 0; index < text.length; index++) {
|
|
122
|
+
const character: string = text[index]
|
|
123
|
+
if (quoted) {
|
|
124
|
+
if (character !== '"') value += character
|
|
125
|
+
else if (text[index + 1] === '"') value += text[++index]
|
|
126
|
+
else quoted = false
|
|
127
|
+
} else if (character === '"') {
|
|
128
|
+
quoted = true
|
|
129
|
+
} else if (character === delimiter) {
|
|
130
|
+
line.push(value)
|
|
131
|
+
value = ''
|
|
132
|
+
} else if (character === '\n' || character === '\r') {
|
|
133
|
+
//行以 \n 或 \r\n 结尾
|
|
134
|
+
if (character === '\r' && text[index + 1] === '\n') index++
|
|
135
|
+
lines.push([...line, value])
|
|
136
|
+
line = []
|
|
137
|
+
value = ''
|
|
138
|
+
} else {
|
|
139
|
+
value += character
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
//文本末尾没有换行时,补上最后一行
|
|
143
|
+
if (line.length || value) lines.push([...line, value])
|
|
144
|
+
return lines
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 读取 CSV 文件
|
|
149
|
+
* @param file
|
|
150
|
+
* @param delimiter
|
|
151
|
+
*/
|
|
152
|
+
export async function readCsv(file: string, delimiter: string): Promise<CsvTable> {
|
|
153
|
+
let text: string
|
|
154
|
+
try {
|
|
155
|
+
text = await readFile(file, 'utf8')
|
|
156
|
+
} catch (error: unknown) {
|
|
157
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new CsvException('The file {file} does not exist', {file: file})
|
|
158
|
+
throw error
|
|
159
|
+
}
|
|
160
|
+
//跳过空行
|
|
161
|
+
const [columns, ...lines] = parseCsv(text, delimiter).filter((line: string[]): boolean => line.join('') !== '')
|
|
162
|
+
if (!columns) throw new CsvException('The file {file} is empty', {file: file})
|
|
163
|
+
const rows: Record<string, string>[] = lines.map((line: string[], index: number): Record<string, string> => {
|
|
164
|
+
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})
|
|
165
|
+
return Object.fromEntries(columns.map((column: string, position: number): [string, string] => [column, line[position]]))
|
|
166
|
+
})
|
|
167
|
+
return {columns: columns, rows: rows}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 判断一个值是不是数字
|
|
172
|
+
* @param value
|
|
173
|
+
*/
|
|
174
|
+
export function isNumber(value: string): boolean {
|
|
175
|
+
return value.trim() !== '' && Number.isFinite(Number(value))
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
这些是普通函数,不是容器中的对象,由命令直接调用。
|
|
180
|
+
|
|
181
|
+
- `parseCsv()` 按 RFC 4180 的规则把文本拆成行和值:双引号里的值可以包含分隔符、换行和连续两个双引号,行以 `\n` 或 `\r\n` 结尾。
|
|
182
|
+
- `readCsv()` 用第一行作为列名,并跳过空行。文件不存在、文件为空、某一行的值个数和列数对不上,都会抛出 `CsvException`;文件系统的其他错误(比如给的是目录,或者没有读权限)原样抛出。
|
|
183
|
+
|
|
184
|
+
## 5. 命令
|
|
185
|
+
|
|
186
|
+
**`packages/app/src/controllers/CsvController.ts`**
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
import {Controller} from 'lakutata/com/entrypoint'
|
|
190
|
+
import {CLIAction} from 'lakutata/decorator/ctrl'
|
|
191
|
+
import {FileDTO, JsonDTO, StatsDTO} from '../dtos/CsvDTO.js'
|
|
192
|
+
import {CsvException} from '../exceptions/CsvException.js'
|
|
193
|
+
import {type CsvTable, isNumber, readCsv} from '../lib/Csv.js'
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 一列的统计结果
|
|
197
|
+
*/
|
|
198
|
+
export type ColumnStats = {
|
|
199
|
+
column: string
|
|
200
|
+
count: number
|
|
201
|
+
empty: number
|
|
202
|
+
sum: number
|
|
203
|
+
min: number
|
|
204
|
+
max: number
|
|
205
|
+
mean: number
|
|
206
|
+
median: number
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export class CsvController extends Controller {
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 检查这些列是否都在文件中
|
|
213
|
+
* @param table
|
|
214
|
+
* @param file
|
|
215
|
+
* @param columns
|
|
216
|
+
*/
|
|
217
|
+
protected checkColumns(table: CsvTable, file: string, columns: string[]): void {
|
|
218
|
+
for (const column of columns) {
|
|
219
|
+
if (!table.columns.includes(column)) throw new CsvException('The column "{column}" is not in {file}: {columns}', {column: column, file: file, columns: table.columns.join(', ')})
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* tally columns sales.csv:列出各列,以及它们的类型和值的个数
|
|
225
|
+
* @param input
|
|
226
|
+
*/
|
|
227
|
+
@CLIAction('columns <file>', FileDTO, {description: 'List the columns of a CSV file'})
|
|
228
|
+
public async columns(input: FileDTO): Promise<string> {
|
|
229
|
+
const table: CsvTable = await readCsv(input.file, input.delimiter)
|
|
230
|
+
const width: number = Math.max(...table.columns.map((column: string): number => column.length))
|
|
231
|
+
return table.columns.map((column: string): string => {
|
|
232
|
+
const values: string[] = table.rows.map((row: Record<string, string>): string => row[column]).filter((value: string): boolean => value !== '')
|
|
233
|
+
const type: string = values.length && values.every(isNumber) ? 'number' : 'text'
|
|
234
|
+
const empty: number = table.rows.length - values.length
|
|
235
|
+
return `${column.padEnd(width)} ${type.padEnd(6)} ${values.length} values${empty ? `, ${empty} empty` : ''}`
|
|
236
|
+
}).join('\n')
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* tally stats sales.csv --column units:个数、总和、最小值、最大值、平均值和中位数
|
|
241
|
+
* @param input
|
|
242
|
+
*/
|
|
243
|
+
@CLIAction('stats <file>', StatsDTO, {description: 'Compute the statistics of a numeric column'})
|
|
244
|
+
public async stats(input: StatsDTO): Promise<string | ColumnStats> {
|
|
245
|
+
const table: CsvTable = await readCsv(input.file, input.delimiter)
|
|
246
|
+
this.checkColumns(table, input.file, [input.column])
|
|
247
|
+
const values: number[] = []
|
|
248
|
+
table.rows.forEach((row: Record<string, string>, index: number): void => {
|
|
249
|
+
const value: string = row[input.column]
|
|
250
|
+
if (value === '') return
|
|
251
|
+
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})
|
|
252
|
+
values.push(Number(value))
|
|
253
|
+
})
|
|
254
|
+
if (!values.length) throw new CsvException('The column "{column}" has no values', {column: input.column})
|
|
255
|
+
const sorted: number[] = [...values].sort((a: number, b: number): number => a - b)
|
|
256
|
+
const middle: number = Math.floor(sorted.length / 2)
|
|
257
|
+
const sum: number = values.reduce((total: number, value: number): number => total + value, 0)
|
|
258
|
+
const round = (value: number): number => Number(value.toFixed(input.decimals))
|
|
259
|
+
const stats: ColumnStats = {
|
|
260
|
+
column: input.column,
|
|
261
|
+
count: values.length,
|
|
262
|
+
empty: table.rows.length - values.length,
|
|
263
|
+
sum: round(sum),
|
|
264
|
+
min: sorted[0],
|
|
265
|
+
max: sorted[sorted.length - 1],
|
|
266
|
+
mean: round(sum / values.length),
|
|
267
|
+
median: round(sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2)
|
|
268
|
+
}
|
|
269
|
+
//对象输出为 JSON,字符串原样输出
|
|
270
|
+
if (input.format === 'json') return stats
|
|
271
|
+
return Object.entries(stats).map(([name, value]: [string, string | number]): string => `${name.padEnd(6)} ${value}`).join('\n')
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* tally json sales.csv --columns product units --numbers --limit 2:把各行输出为对象数组
|
|
276
|
+
* @param input
|
|
277
|
+
*/
|
|
278
|
+
@CLIAction('json <file>', JsonDTO, {description: 'Convert a CSV file to JSON'})
|
|
279
|
+
public async json(input: JsonDTO): Promise<Record<string, string | number>[]> {
|
|
280
|
+
const table: CsvTable = await readCsv(input.file, input.delimiter)
|
|
281
|
+
const columns: string[] = input.columns ?? table.columns
|
|
282
|
+
this.checkColumns(table, input.file, columns)
|
|
283
|
+
return table.rows.slice(0, input.limit).map((row: Record<string, string>): Record<string, string | number> => {
|
|
284
|
+
return Object.fromEntries(columns.map((column: string): [string, string | number] => {
|
|
285
|
+
return [column, input.numbers && isNumber(row[column]) ? Number(row[column]) : row[column]]
|
|
286
|
+
}))
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
- 每个 `@CLIAction` 声明一个命令:命令名和参数(`'stats <file>'`)、DTO,以及 `description`,即帮助中这个命令的说明(见 [Controllers](../Controllers.md))。
|
|
293
|
+
- 动作收到的是校验后的输入,已经补上默认值:没有给出时,`delimiter` 为 `','`,`decimals` 为 2。
|
|
294
|
+
- 动作的返回值写到 stdout:字符串原样输出(`columns`,以及默认的 `stats`),其他值输出为带缩进的 JSON(`stats --format json`、`json`)。
|
|
295
|
+
- 抛出的异常就是这个命令的错误。
|
|
296
|
+
|
|
297
|
+
## 6. 工具的选项
|
|
298
|
+
|
|
299
|
+
**`packages/app/src/config/Config.ts`**
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
import type {ApplicationOptions} from 'lakutata'
|
|
303
|
+
import {buildEntrypoints} from 'lakutata/com/entrypoint'
|
|
304
|
+
import {commanderCLIEntrypoint} from 'lakutata/adapter/commander'
|
|
305
|
+
import manifest from '../../package.json' with {type: 'json'}
|
|
306
|
+
import {CsvController} from '../controllers/CsvController.js'
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* 工具的选项
|
|
310
|
+
*/
|
|
311
|
+
export function config(): ApplicationOptions {
|
|
312
|
+
return {
|
|
313
|
+
id: 'tally',
|
|
314
|
+
name: 'tally',
|
|
315
|
+
components: {
|
|
316
|
+
entrypoint: buildEntrypoints({
|
|
317
|
+
controllers: [CsvController],
|
|
318
|
+
//控制器的动作就是命令;在终端里运行时,缺少的必填选项会向用户提问
|
|
319
|
+
cli: commanderCLIEntrypoint({bin: 'tally', description: 'Statistics of CSV files, and their conversion to JSON', version: manifest.version})
|
|
320
|
+
})
|
|
321
|
+
},
|
|
322
|
+
bootstrap: ['entrypoint']
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
这是模板中的 `Config.ts`,用 `CsvController` 替换了示例控制器。工具的说明直接写在这里,而模板是从 `packages/app/package.json` 读取说明的,那里的说明由 `lakutata create --description` 写入;版本号仍然来自这个文件。`bin` 是工具在帮助中显示的名字。
|
|
328
|
+
|
|
329
|
+
## 7. 运行
|
|
330
|
+
|
|
331
|
+
构建工具,并在项目根目录保存一个 CSV 文件:
|
|
332
|
+
|
|
333
|
+
```bash
|
|
334
|
+
npm run build
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
**`sales.csv`**
|
|
338
|
+
|
|
339
|
+
```csv
|
|
340
|
+
date,region,product,units,price
|
|
341
|
+
2026-10-01,north,"Tea, green",12,4.50
|
|
342
|
+
2026-10-01,south,Coffee,30,3.20
|
|
343
|
+
2026-10-02,north,Coffee,18,3.20
|
|
344
|
+
2026-10-02,east,"Tea, green",,4.50
|
|
345
|
+
2026-10-03,south,Cocoa,7,5.10
|
|
346
|
+
2026-10-03,east,Coffee,25,3.40
|
|
347
|
+
2026-10-04,north,Cocoa,11,5.10
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
下面的命令用 node 运行构建好的工具;执行 `npm link -w app` 后,也可以直接用 `tally` 命令。
|
|
351
|
+
|
|
352
|
+
```text
|
|
353
|
+
$ node packages/app/build/App.js --help
|
|
354
|
+
Usage: tally [options] [command]
|
|
355
|
+
|
|
356
|
+
Statistics of CSV files, and their conversion to JSON
|
|
357
|
+
|
|
358
|
+
Options:
|
|
359
|
+
-V, --version output the version number
|
|
360
|
+
-h, --help display help for command
|
|
361
|
+
|
|
362
|
+
Commands:
|
|
363
|
+
columns [options] <file> List the columns of a CSV file
|
|
364
|
+
stats [options] <file> Compute the statistics of a numeric column
|
|
365
|
+
json [options] <file> Convert a CSV file to JSON
|
|
366
|
+
help [command] display help for command
|
|
367
|
+
|
|
368
|
+
$ node packages/app/build/App.js stats --help
|
|
369
|
+
Usage: tally stats [options] <file>
|
|
370
|
+
|
|
371
|
+
Compute the statistics of a numeric column
|
|
372
|
+
|
|
373
|
+
Arguments:
|
|
374
|
+
file The CSV file
|
|
375
|
+
|
|
376
|
+
Options:
|
|
377
|
+
--delimiter <value> The character between the values (default: ",")
|
|
378
|
+
--column <value> The column to summarize
|
|
379
|
+
--decimals <value> The decimals of the results (default: 2)
|
|
380
|
+
--format <value> The output (choices: "text", "json", default: "text")
|
|
381
|
+
-h, --help display help for command
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
```text
|
|
385
|
+
$ node packages/app/build/App.js columns sales.csv
|
|
386
|
+
date text 7 values
|
|
387
|
+
region text 7 values
|
|
388
|
+
product text 7 values
|
|
389
|
+
units number 6 values, 1 empty
|
|
390
|
+
price number 7 values
|
|
391
|
+
|
|
392
|
+
$ node packages/app/build/App.js stats sales.csv --column units
|
|
393
|
+
column units
|
|
394
|
+
count 6
|
|
395
|
+
empty 1
|
|
396
|
+
sum 103
|
|
397
|
+
min 7
|
|
398
|
+
max 30
|
|
399
|
+
mean 17.17
|
|
400
|
+
median 15
|
|
401
|
+
|
|
402
|
+
$ node packages/app/build/App.js stats sales.csv --column price --decimals 3 --format json
|
|
403
|
+
{
|
|
404
|
+
"column": "price",
|
|
405
|
+
"count": 7,
|
|
406
|
+
"empty": 0,
|
|
407
|
+
"sum": 29,
|
|
408
|
+
"min": 3.2,
|
|
409
|
+
"max": 5.1,
|
|
410
|
+
"mean": 4.143,
|
|
411
|
+
"median": 4.5
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
$ node packages/app/build/App.js json sales.csv --columns product units --numbers --limit 2
|
|
415
|
+
[
|
|
416
|
+
{
|
|
417
|
+
"product": "Tea, green",
|
|
418
|
+
"units": 12
|
|
419
|
+
},
|
|
420
|
+
{
|
|
421
|
+
"product": "Coffee",
|
|
422
|
+
"units": 30
|
|
423
|
+
}
|
|
424
|
+
]
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
用分号分隔值的文件可以加 `--delimiter ';'` 读取,TSV 文件则用 `--delimiter $'\t'`。
|
|
428
|
+
|
|
429
|
+
**缺少选项时提问。** 在终端中运行时如果缺少必填选项,工具会向用户提问。问题的文字是属性的说明加上属性名。回答(这里是 `price`)会按该属性的规则检查,不合法时重新提问:
|
|
430
|
+
|
|
431
|
+
```text
|
|
432
|
+
$ node packages/app/build/App.js stats sales.csv
|
|
433
|
+
✔ The column to summarize (column) price
|
|
434
|
+
column price
|
|
435
|
+
count 7
|
|
436
|
+
empty 0
|
|
437
|
+
sum 29
|
|
438
|
+
min 3.2
|
|
439
|
+
max 5.1
|
|
440
|
+
mean 4.14
|
|
441
|
+
median 4.5
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
只有 stdin 和 stdout 都是终端时,工具才会提问。在脚本或管道中,它不会提问,缺少的选项直接报错:
|
|
445
|
+
|
|
446
|
+
```text
|
|
447
|
+
$ node packages/app/build/App.js stats sales.csv < /dev/null
|
|
448
|
+
error: required option '--column <value>' not specified
|
|
449
|
+
```
|
|
450
|
+
|
|
451
|
+
**错误与退出码。** 不合法的输入会在三个环节之一被拒绝。无论哪个环节,都会向 stderr 写出 `error:` 和一条消息,工具以退出码 1 结束:
|
|
452
|
+
|
|
453
|
+
```text
|
|
454
|
+
$ node packages/app/build/App.js stats sales.csv --column units --decimals two
|
|
455
|
+
error: option '--decimals <value>' argument 'two' is invalid. Not a number.
|
|
456
|
+
$ node packages/app/build/App.js stats sales.csv --column units --format xml
|
|
457
|
+
error: option '--format <value>' argument 'xml' is invalid. Allowed choices are "text", "json".
|
|
458
|
+
$ node packages/app/build/App.js stats sales.csv --column units --decimals 9
|
|
459
|
+
error: "decimals" must be less than or equal to 6
|
|
460
|
+
$ node packages/app/build/App.js stats sales.csv --column region
|
|
461
|
+
error: The value "north" of the column "region" (row 1) is not a number
|
|
462
|
+
$ node packages/app/build/App.js columns sale.csv
|
|
463
|
+
error: The file sale.csv does not exist
|
|
464
|
+
$ echo $?
|
|
465
|
+
1
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
- 命令行解析器在 DTO 之前就拒绝不符合命令格式的输入:该是数字的地方给了文本、取值不在 `.valid()` 之内、缺少参数或选项、命令不存在。
|
|
469
|
+
- DTO 拒绝违反其他规则的值,比如 `.max(6)`。
|
|
470
|
+
- 只有动作自己才能检查的问题,由动作抛出异常来拒绝:文件不存在,或者某列不全是数字。
|
|
471
|
+
|
|
472
|
+
命令成功时退出码为 0,`--help` 和 `--version` 也是如此,所以脚本可以根据退出码判断每个命令是否成功。
|
|
473
|
+
|
|
474
|
+
## 8. 测试
|
|
475
|
+
|
|
476
|
+
模板中的测试换成了针对这些命令的测试。和模板一样,它在子进程中运行构建好的工具,这里还会先在临时目录中写一个 CSV 文件。子进程的 stdin 不是终端,所以工具不会提问:
|
|
477
|
+
|
|
478
|
+
**`packages/app/src/tests/App.spec.ts`**
|
|
479
|
+
|
|
480
|
+
```typescript
|
|
481
|
+
import {after, before, describe, it} from 'node:test'
|
|
482
|
+
import assert from 'node:assert'
|
|
483
|
+
import {execFile, type ExecFileException} from 'node:child_process'
|
|
484
|
+
import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'
|
|
485
|
+
import {tmpdir} from 'node:os'
|
|
486
|
+
import path from 'node:path'
|
|
487
|
+
import {fileURLToPath} from 'node:url'
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* 编译后的工具
|
|
491
|
+
*/
|
|
492
|
+
const TOOL: string = fileURLToPath(new URL('../App.js', import.meta.url))
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* 带参数运行工具(它的 stdin 不是终端,所以不会提问)
|
|
496
|
+
* @param args
|
|
497
|
+
*/
|
|
498
|
+
function tool(...args: string[]): Promise<{ code: number, stdout: string, stderr: string }> {
|
|
499
|
+
return new Promise((resolve: (result: { code: number, stdout: string, stderr: string }) => void): void => {
|
|
500
|
+
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}))
|
|
501
|
+
})
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
describe('tally', (): void => {
|
|
505
|
+
|
|
506
|
+
let directory: string
|
|
507
|
+
let file: string
|
|
508
|
+
|
|
509
|
+
//临时目录里的 CSV 文件:有一个带逗号的引号值、一个空值,换行用的是 Windows 的 \r\n
|
|
510
|
+
before((): void => {
|
|
511
|
+
directory = mkdtempSync(path.join(tmpdir(), 'tally-'))
|
|
512
|
+
file = path.join(directory, 'sales.csv')
|
|
513
|
+
writeFileSync(file, 'region,product,units\r\nnorth,"Tea, green",12\r\nsouth,Coffee,30\r\nnorth,Coffee,\r\neast,Cocoa,7\r\n')
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
after((): void => {
|
|
517
|
+
rmSync(directory, {recursive: true, force: true})
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
it('lists the columns', async (): Promise<void> => {
|
|
521
|
+
const result = await tool('columns', file)
|
|
522
|
+
assert.strictEqual(result.code, 0, result.stderr)
|
|
523
|
+
assert.strictEqual(result.stdout, 'region text 4 values\nproduct text 4 values\nunits number 3 values, 1 empty\n')
|
|
524
|
+
})
|
|
525
|
+
|
|
526
|
+
it('computes the statistics of a column', async (): Promise<void> => {
|
|
527
|
+
const result = await tool('stats', file, '--column', 'units', '--format', 'json')
|
|
528
|
+
assert.strictEqual(result.code, 0, result.stderr)
|
|
529
|
+
assert.deepStrictEqual(JSON.parse(result.stdout), {column: 'units', count: 3, empty: 1, sum: 49, min: 7, max: 30, mean: 16.33, median: 12})
|
|
530
|
+
})
|
|
531
|
+
|
|
532
|
+
it('converts the rows to JSON', async (): Promise<void> => {
|
|
533
|
+
const result = await tool('json', file, '--columns', 'product', 'units', '--numbers', '--limit', '3')
|
|
534
|
+
assert.strictEqual(result.code, 0, result.stderr)
|
|
535
|
+
assert.deepStrictEqual(JSON.parse(result.stdout), [{product: 'Tea, green', units: 12}, {product: 'Coffee', units: 30}, {product: 'Coffee', units: ''}])
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
it('requires the column outside a terminal', async (): Promise<void> => {
|
|
539
|
+
const result = await tool('stats', file)
|
|
540
|
+
assert.strictEqual(result.code, 1)
|
|
541
|
+
assert.strictEqual(result.stderr, `error: required option '--column <value>' not specified\n`)
|
|
542
|
+
})
|
|
543
|
+
|
|
544
|
+
it('refuses an invalid option', async (): Promise<void> => {
|
|
545
|
+
const result = await tool('stats', file, '--column', 'units', '--decimals', '9')
|
|
546
|
+
assert.strictEqual(result.code, 1)
|
|
547
|
+
assert.strictEqual(result.stderr, 'error: "decimals" must be less than or equal to 6\n')
|
|
548
|
+
})
|
|
549
|
+
|
|
550
|
+
it('fails on a column that is not in the file', async (): Promise<void> => {
|
|
551
|
+
const result = await tool('stats', file, '--column', 'price')
|
|
552
|
+
assert.strictEqual(result.code, 1)
|
|
553
|
+
assert.strictEqual(result.stderr, `error: The column "price" is not in ${file}: region, product, units\n`)
|
|
554
|
+
})
|
|
555
|
+
|
|
556
|
+
it('fails on a value that is not a number', async (): Promise<void> => {
|
|
557
|
+
const result = await tool('stats', file, '--column', 'product')
|
|
558
|
+
assert.strictEqual(result.code, 1)
|
|
559
|
+
assert.strictEqual(result.stderr, 'error: The value "Tea, green" of the column "product" (row 1) is not a number\n')
|
|
560
|
+
})
|
|
561
|
+
|
|
562
|
+
it('shows its version', async (): Promise<void> => {
|
|
563
|
+
const {version} = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
|
|
564
|
+
assert.strictEqual((await tool('--version')).stdout.trim(), version)
|
|
565
|
+
})
|
|
566
|
+
})
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
```bash
|
|
570
|
+
npm test
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
```
|
|
574
|
+
app │ ✔ lists the columns
|
|
575
|
+
app │ ✔ computes the statistics of a column
|
|
576
|
+
app │ ✔ converts the rows to JSON
|
|
577
|
+
app │ ✔ requires the column outside a terminal
|
|
578
|
+
app │ ✔ refuses an invalid option
|
|
579
|
+
app │ ✔ fails on a column that is not in the file
|
|
580
|
+
app │ ✔ fails on a value that is not a number
|
|
581
|
+
app │ ✔ shows its version
|
|
582
|
+
app │ ℹ pass 8
|
|
583
|
+
✔ test ran in app
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
## 9. 打包成一个文件
|
|
587
|
+
|
|
588
|
+
```bash
|
|
589
|
+
npm run package:bundle
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
```
|
|
593
|
+
✔ ../../dist/tally.cjs (1.1 MB): node ../../dist/tally.cjs
|
|
594
|
+
```
|
|
595
|
+
|
|
596
|
+
这个脚本先构建工具,再由 `lakutata package bundle` 把工具连同 lakutata、commander 和 `@inquirer/prompts` 一起打成一个 JavaScript 文件,即项目根目录下的 `dist/tally.cjs`(输出的路径相对于脚本运行的目录 `packages/app`)。Node.js 可以在任何目录中运行它,不需要 `node_modules`,比如在项目旁边的目录中:
|
|
597
|
+
|
|
598
|
+
```bash
|
|
599
|
+
mkdir ../tally-bundle && cp dist/tally.cjs sales.csv ../tally-bundle && cd ../tally-bundle
|
|
600
|
+
node tally.cjs stats sales.csv --column units --format json
|
|
601
|
+
```
|
|
602
|
+
|
|
603
|
+
```
|
|
604
|
+
{
|
|
605
|
+
"column": "units",
|
|
606
|
+
"count": 6,
|
|
607
|
+
"empty": 1,
|
|
608
|
+
"sum": 103,
|
|
609
|
+
"min": 7,
|
|
610
|
+
"max": 30,
|
|
611
|
+
"mean": 17.17,
|
|
612
|
+
"median": 15
|
|
613
|
+
}
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
- 单文件包是 CommonJS 文件,所以源码中不能使用顶层 `await`。
|
|
617
|
+
- `npm run package:exe` 生成 `dist/tally`,这是一个不需要安装 Node.js 就能运行的可执行文件:Node.js 已经嵌在里面(约 110 MB)。它只能在生成它的平台上运行,其他平台的可执行文件怎么生成,项目的 README 里有说明(见 [Packaging](../Packaging.md))。
|
|
618
|
+
- 模板的 `installer` 功能(`lakutata create --with installer`)可以生成安装包,即各个系统的软件包(deb、rpm、pkg、msi 等),它们会把 `tally` 加入 PATH(见 [Installers](../Installers.md))。
|