lakutata 3.0.0 → 3.1.0
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 +507 -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 +62 -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 +504 -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/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/README.md +16 -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 +445 -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 +46 -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 +459 -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/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/README.md +14 -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,664 @@
|
|
|
1
|
+
# A command line tool
|
|
2
|
+
|
|
3
|
+
This example builds `tally`, a command line tool for CSV files: it lists the columns of a file, computes the statistics
|
|
4
|
+
of a numeric column, and converts the rows to JSON. It starts from the `cli` template and shows the parts of a lakutata
|
|
5
|
+
tool: the commands with their arguments and options (`@CLIAction` and its DTO), input validation, asking for a missing
|
|
6
|
+
option in the terminal, output and exit codes, the tests, and a bundle that runs without `node_modules`.
|
|
7
|
+
|
|
8
|
+
| Command | What it does |
|
|
9
|
+
|---|---|
|
|
10
|
+
| `tally columns <file>` | Lists the columns of the file, with the type and the number of values of each |
|
|
11
|
+
| `tally stats <file> --column <name>` | Gives the count, the sum, the minimum, the maximum, the mean and the median of a numeric column |
|
|
12
|
+
| `tally json <file>` | Converts the rows to JSON: all the columns or only some, with the numbers kept as text or written as numbers |
|
|
13
|
+
|
|
14
|
+
## 1. Create the project
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx @lakutata/cli create --name tally --template cli --yes
|
|
18
|
+
cd tally
|
|
19
|
+
rm packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The project is a workspace whose tool is `packages/app` ([ProjectStructure](../ProjectStructure.md)). Its dependencies
|
|
23
|
+
are already there: `commander`, which reads the command line, and `@inquirer/prompts`, which asks the questions in a
|
|
24
|
+
terminal. The tool reads the CSV files with a small parser of its own, so nothing else is installed. The example
|
|
25
|
+
command of the template and its DTO are removed.
|
|
26
|
+
|
|
27
|
+
## 2. The input
|
|
28
|
+
|
|
29
|
+
**`packages/app/src/dtos/CsvDTO.ts`**
|
|
30
|
+
|
|
31
|
+
```typescript
|
|
32
|
+
import {DTO} from 'lakutata'
|
|
33
|
+
import {Expect} from 'lakutata/decorator/dto'
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A CSV file: <file> [--delimiter <value>]
|
|
37
|
+
*/
|
|
38
|
+
export class FileDTO extends DTO {
|
|
39
|
+
@Expect(DTO.String().required().description('The CSV file'))
|
|
40
|
+
public file: string
|
|
41
|
+
|
|
42
|
+
@Expect(DTO.String().length(1).default(',').description('The character between the values'))
|
|
43
|
+
public delimiter: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The statistics of a column: --column <value> [--decimals <value>] [--format <value>]
|
|
48
|
+
*/
|
|
49
|
+
export class StatsDTO extends FileDTO {
|
|
50
|
+
@Expect(DTO.String().required().description('The column to summarize'))
|
|
51
|
+
public column: string
|
|
52
|
+
|
|
53
|
+
@Expect(DTO.Number().integer().min(0).max(6).default(2).description('The decimals of the results'))
|
|
54
|
+
public decimals: number
|
|
55
|
+
|
|
56
|
+
@Expect(DTO.String().valid('text', 'json').default('text').description('The output'))
|
|
57
|
+
public format: 'text' | 'json'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The conversion to JSON: [--columns <values...>] [--numbers] [--limit <value>]
|
|
62
|
+
*/
|
|
63
|
+
export class JsonDTO extends FileDTO {
|
|
64
|
+
@Expect(DTO.Array(DTO.String()).min(1).optional().description('The columns to keep, all of them by default'))
|
|
65
|
+
public columns?: string[]
|
|
66
|
+
|
|
67
|
+
@Expect(DTO.Boolean().default(false).description('Write the numeric values as numbers'))
|
|
68
|
+
public numbers: boolean
|
|
69
|
+
|
|
70
|
+
@Expect(DTO.Number().integer().min(1).optional().description('The number of rows, all of them by default'))
|
|
71
|
+
public limit?: number
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Each command takes a DTO, whose properties are its arguments and its options ([CLI](../CLI.md)):
|
|
76
|
+
|
|
77
|
+
- `file` is named in the commands (`'stats <file>'`), so it is an argument. The other properties are options of the
|
|
78
|
+
same name: `--delimiter`, `--column`, `--decimals`…
|
|
79
|
+
- The type of a property decides the form of its option: `--decimals 3` for a number, `--numbers` for a boolean (a
|
|
80
|
+
flag), `--columns product units` for an array.
|
|
81
|
+
- The help shows the `.description()` of each property, its default and its allowed values (`.valid()`).
|
|
82
|
+
- The command line is text: the adapter converts each value to the type of its property before the DTO validates the
|
|
83
|
+
input ([DTOValidation](../DTOValidation.md)), so the numbers need no `.strict(false)`.
|
|
84
|
+
- `StatsDTO` and `JsonDTO` extend `FileDTO`: every command takes the file and the delimiter.
|
|
85
|
+
- `column` is required and has no default: in a terminal, the tool asks for it when it is missing (section 7).
|
|
86
|
+
|
|
87
|
+
## 3. The exception
|
|
88
|
+
|
|
89
|
+
**`packages/app/src/exceptions/CsvException.ts`**
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
import {Exception} from 'lakutata'
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* A CSV file that cannot be read, or a column that cannot be used
|
|
96
|
+
*/
|
|
97
|
+
export class CsvException extends Exception {
|
|
98
|
+
public errno: string = 'E_CSV'
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Errors in a file or in its columns are `CsvException`s ([Exceptions](../Exceptions.md)). The message is a template,
|
|
103
|
+
filled in from the values given when the exception is thrown:
|
|
104
|
+
`new CsvException('The file {file} does not exist', {file: file})`. On the command line, a failed command writes
|
|
105
|
+
`error:` and the message, and exits with code 1.
|
|
106
|
+
|
|
107
|
+
## 4. Reading a CSV file
|
|
108
|
+
|
|
109
|
+
**`packages/app/src/lib/Csv.ts`**
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
import {readFile} from 'node:fs/promises'
|
|
113
|
+
import {CsvException} from '../exceptions/CsvException.js'
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A CSV file: its columns, named by its first line, and its rows, each holding one value per column
|
|
117
|
+
*/
|
|
118
|
+
export type CsvTable = {
|
|
119
|
+
columns: string[]
|
|
120
|
+
rows: Record<string, string>[]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The lines of a CSV text, each a list of values. A value in double quotes may hold the delimiter, line breaks and
|
|
125
|
+
* doubled quotes ("")
|
|
126
|
+
* @param text
|
|
127
|
+
* @param delimiter
|
|
128
|
+
*/
|
|
129
|
+
export function parseCsv(text: string, delimiter: string): string[][] {
|
|
130
|
+
const lines: string[][] = []
|
|
131
|
+
let line: string[] = []
|
|
132
|
+
let value: string = ''
|
|
133
|
+
let quoted: boolean = false
|
|
134
|
+
for (let index: number = 0; index < text.length; index++) {
|
|
135
|
+
const character: string = text[index]
|
|
136
|
+
if (quoted) {
|
|
137
|
+
if (character !== '"') value += character
|
|
138
|
+
else if (text[index + 1] === '"') value += text[++index]
|
|
139
|
+
else quoted = false
|
|
140
|
+
} else if (character === '"') {
|
|
141
|
+
quoted = true
|
|
142
|
+
} else if (character === delimiter) {
|
|
143
|
+
line.push(value)
|
|
144
|
+
value = ''
|
|
145
|
+
} else if (character === '\n' || character === '\r') {
|
|
146
|
+
//A line ends with \n or \r\n
|
|
147
|
+
if (character === '\r' && text[index + 1] === '\n') index++
|
|
148
|
+
lines.push([...line, value])
|
|
149
|
+
line = []
|
|
150
|
+
value = ''
|
|
151
|
+
} else {
|
|
152
|
+
value += character
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
//The last line, when the text does not end with a line break
|
|
156
|
+
if (line.length || value) lines.push([...line, value])
|
|
157
|
+
return lines
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Read a CSV file
|
|
162
|
+
* @param file
|
|
163
|
+
* @param delimiter
|
|
164
|
+
*/
|
|
165
|
+
export async function readCsv(file: string, delimiter: string): Promise<CsvTable> {
|
|
166
|
+
let text: string
|
|
167
|
+
try {
|
|
168
|
+
text = await readFile(file, 'utf8')
|
|
169
|
+
} catch (error: unknown) {
|
|
170
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw new CsvException('The file {file} does not exist', {file: file})
|
|
171
|
+
throw error
|
|
172
|
+
}
|
|
173
|
+
//The blank lines are skipped
|
|
174
|
+
const [columns, ...lines] = parseCsv(text, delimiter).filter((line: string[]): boolean => line.join('') !== '')
|
|
175
|
+
if (!columns) throw new CsvException('The file {file} is empty', {file: file})
|
|
176
|
+
const rows: Record<string, string>[] = lines.map((line: string[], index: number): Record<string, string> => {
|
|
177
|
+
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})
|
|
178
|
+
return Object.fromEntries(columns.map((column: string, position: number): [string, string] => [column, line[position]]))
|
|
179
|
+
})
|
|
180
|
+
return {columns: columns, rows: rows}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Whether a value is a number
|
|
185
|
+
* @param value
|
|
186
|
+
*/
|
|
187
|
+
export function isNumber(value: string): boolean {
|
|
188
|
+
return value.trim() !== '' && Number.isFinite(Number(value))
|
|
189
|
+
}
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
These are plain functions, not objects in the container: the commands call them directly.
|
|
193
|
+
|
|
194
|
+
- `parseCsv()` splits the text into lines and values as RFC 4180 describes them: a value in double quotes may hold the
|
|
195
|
+
delimiter, line breaks and doubled quotes, and the lines end with `\n` or `\r\n`.
|
|
196
|
+
- `readCsv()` takes the names of the columns from the first line and skips the blank lines. A file that does not
|
|
197
|
+
exist, an empty file, or a row with more or fewer values than columns is a `CsvException`; other file system errors
|
|
198
|
+
(a directory, a file it may not read) are thrown as they are.
|
|
199
|
+
|
|
200
|
+
## 5. The commands
|
|
201
|
+
|
|
202
|
+
**`packages/app/src/controllers/CsvController.ts`**
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
import {Controller} from 'lakutata/com/entrypoint'
|
|
206
|
+
import {CLIAction} from 'lakutata/decorator/ctrl'
|
|
207
|
+
import {FileDTO, JsonDTO, StatsDTO} from '../dtos/CsvDTO.js'
|
|
208
|
+
import {CsvException} from '../exceptions/CsvException.js'
|
|
209
|
+
import {type CsvTable, isNumber, readCsv} from '../lib/Csv.js'
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The statistics of a column
|
|
213
|
+
*/
|
|
214
|
+
export type ColumnStats = {
|
|
215
|
+
column: string
|
|
216
|
+
count: number
|
|
217
|
+
empty: number
|
|
218
|
+
sum: number
|
|
219
|
+
min: number
|
|
220
|
+
max: number
|
|
221
|
+
mean: number
|
|
222
|
+
median: number
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
export class CsvController extends Controller {
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Check that the columns are in the file
|
|
229
|
+
* @param table
|
|
230
|
+
* @param file
|
|
231
|
+
* @param columns
|
|
232
|
+
*/
|
|
233
|
+
protected checkColumns(table: CsvTable, file: string, columns: string[]): void {
|
|
234
|
+
for (const column of columns) {
|
|
235
|
+
if (!table.columns.includes(column)) throw new CsvException('The column "{column}" is not in {file}: {columns}', {column: column, file: file, columns: table.columns.join(', ')})
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* tally columns sales.csv: the columns, with the type and the number of values of each
|
|
241
|
+
* @param input
|
|
242
|
+
*/
|
|
243
|
+
@CLIAction('columns <file>', FileDTO, {description: 'List the columns of a CSV file'})
|
|
244
|
+
public async columns(input: FileDTO): Promise<string> {
|
|
245
|
+
const table: CsvTable = await readCsv(input.file, input.delimiter)
|
|
246
|
+
const width: number = Math.max(...table.columns.map((column: string): number => column.length))
|
|
247
|
+
return table.columns.map((column: string): string => {
|
|
248
|
+
const values: string[] = table.rows.map((row: Record<string, string>): string => row[column]).filter((value: string): boolean => value !== '')
|
|
249
|
+
const type: string = values.length && values.every(isNumber) ? 'number' : 'text'
|
|
250
|
+
const empty: number = table.rows.length - values.length
|
|
251
|
+
return `${column.padEnd(width)} ${type.padEnd(6)} ${values.length} values${empty ? `, ${empty} empty` : ''}`
|
|
252
|
+
}).join('\n')
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* tally stats sales.csv --column units: the count, the sum, the minimum, the maximum, the mean and the median
|
|
257
|
+
* @param input
|
|
258
|
+
*/
|
|
259
|
+
@CLIAction('stats <file>', StatsDTO, {description: 'Compute the statistics of a numeric column'})
|
|
260
|
+
public async stats(input: StatsDTO): Promise<string | ColumnStats> {
|
|
261
|
+
const table: CsvTable = await readCsv(input.file, input.delimiter)
|
|
262
|
+
this.checkColumns(table, input.file, [input.column])
|
|
263
|
+
const values: number[] = []
|
|
264
|
+
table.rows.forEach((row: Record<string, string>, index: number): void => {
|
|
265
|
+
const value: string = row[input.column]
|
|
266
|
+
if (value === '') return
|
|
267
|
+
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})
|
|
268
|
+
values.push(Number(value))
|
|
269
|
+
})
|
|
270
|
+
if (!values.length) throw new CsvException('The column "{column}" has no values', {column: input.column})
|
|
271
|
+
const sorted: number[] = [...values].sort((a: number, b: number): number => a - b)
|
|
272
|
+
const middle: number = Math.floor(sorted.length / 2)
|
|
273
|
+
const sum: number = values.reduce((total: number, value: number): number => total + value, 0)
|
|
274
|
+
const round = (value: number): number => Number(value.toFixed(input.decimals))
|
|
275
|
+
const stats: ColumnStats = {
|
|
276
|
+
column: input.column,
|
|
277
|
+
count: values.length,
|
|
278
|
+
empty: table.rows.length - values.length,
|
|
279
|
+
sum: round(sum),
|
|
280
|
+
min: sorted[0],
|
|
281
|
+
max: sorted[sorted.length - 1],
|
|
282
|
+
mean: round(sum / values.length),
|
|
283
|
+
median: round(sorted.length % 2 ? sorted[middle] : (sorted[middle - 1] + sorted[middle]) / 2)
|
|
284
|
+
}
|
|
285
|
+
//An object is written as JSON, a string as it is
|
|
286
|
+
if (input.format === 'json') return stats
|
|
287
|
+
return Object.entries(stats).map(([name, value]: [string, string | number]): string => `${name.padEnd(6)} ${value}`).join('\n')
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* tally json sales.csv --columns product units --numbers --limit 2: the rows, as an array of objects
|
|
292
|
+
* @param input
|
|
293
|
+
*/
|
|
294
|
+
@CLIAction('json <file>', JsonDTO, {description: 'Convert a CSV file to JSON'})
|
|
295
|
+
public async json(input: JsonDTO): Promise<Record<string, string | number>[]> {
|
|
296
|
+
const table: CsvTable = await readCsv(input.file, input.delimiter)
|
|
297
|
+
const columns: string[] = input.columns ?? table.columns
|
|
298
|
+
this.checkColumns(table, input.file, columns)
|
|
299
|
+
return table.rows.slice(0, input.limit).map((row: Record<string, string>): Record<string, string | number> => {
|
|
300
|
+
return Object.fromEntries(columns.map((column: string): [string, string | number] => {
|
|
301
|
+
return [column, input.numbers && isNumber(row[column]) ? Number(row[column]) : row[column]]
|
|
302
|
+
}))
|
|
303
|
+
})
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
- Each `@CLIAction` declares a command: its name and its arguments (`'stats <file>'`), its DTO, and its
|
|
309
|
+
`description`, the line that the help shows for the command ([Controllers](../Controllers.md)).
|
|
310
|
+
- The action receives the validated input, with the defaults applied: `delimiter` is `','` and `decimals` is 2 when
|
|
311
|
+
they are not given.
|
|
312
|
+
- What it returns is written to stdout: a string as it is (`columns`, and `stats` by default), any other value as
|
|
313
|
+
indented JSON (`stats --format json`, `json`).
|
|
314
|
+
- A thrown exception is the error of the command.
|
|
315
|
+
|
|
316
|
+
## 6. The options of the tool
|
|
317
|
+
|
|
318
|
+
**`packages/app/src/config/Config.ts`**
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
import type {ApplicationOptions} from 'lakutata'
|
|
322
|
+
import {buildEntrypoints} from 'lakutata/com/entrypoint'
|
|
323
|
+
import {commanderCLIEntrypoint} from 'lakutata/adapter/commander'
|
|
324
|
+
import manifest from '../../package.json' with {type: 'json'}
|
|
325
|
+
import {CsvController} from '../controllers/CsvController.js'
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* The options of the tool
|
|
329
|
+
*/
|
|
330
|
+
export function config(): ApplicationOptions {
|
|
331
|
+
return {
|
|
332
|
+
id: 'tally',
|
|
333
|
+
name: 'tally',
|
|
334
|
+
components: {
|
|
335
|
+
entrypoint: buildEntrypoints({
|
|
336
|
+
controllers: [CsvController],
|
|
337
|
+
//The actions of the controllers are the commands; the missing required options are asked for in a terminal
|
|
338
|
+
cli: commanderCLIEntrypoint({bin: 'tally', description: 'Statistics of CSV files, and their conversion to JSON', version: manifest.version})
|
|
339
|
+
})
|
|
340
|
+
},
|
|
341
|
+
bootstrap: ['entrypoint']
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
This is the `Config.ts` of the template, with `CsvController` instead of the example controller. The description of
|
|
347
|
+
the tool is written here, whereas the template reads it from `packages/app/package.json`, where
|
|
348
|
+
`lakutata create --description` puts it; the version still comes from that file. `bin` is the name of the tool in the
|
|
349
|
+
help.
|
|
350
|
+
|
|
351
|
+
## 7. Run it
|
|
352
|
+
|
|
353
|
+
Build the tool, and save a CSV file at the root of the project:
|
|
354
|
+
|
|
355
|
+
```bash
|
|
356
|
+
npm run build
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
**`sales.csv`**
|
|
360
|
+
|
|
361
|
+
```csv
|
|
362
|
+
date,region,product,units,price
|
|
363
|
+
2026-10-01,north,"Tea, green",12,4.50
|
|
364
|
+
2026-10-01,south,Coffee,30,3.20
|
|
365
|
+
2026-10-02,north,Coffee,18,3.20
|
|
366
|
+
2026-10-02,east,"Tea, green",,4.50
|
|
367
|
+
2026-10-03,south,Cocoa,7,5.10
|
|
368
|
+
2026-10-03,east,Coffee,25,3.40
|
|
369
|
+
2026-10-04,north,Cocoa,11,5.10
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
The commands below run the built tool with node; `npm link -w app` would install it as the command `tally`.
|
|
373
|
+
|
|
374
|
+
```text
|
|
375
|
+
$ node packages/app/build/App.js --help
|
|
376
|
+
Usage: tally [options] [command]
|
|
377
|
+
|
|
378
|
+
Statistics of CSV files, and their conversion to JSON
|
|
379
|
+
|
|
380
|
+
Options:
|
|
381
|
+
-V, --version output the version number
|
|
382
|
+
-h, --help display help for command
|
|
383
|
+
|
|
384
|
+
Commands:
|
|
385
|
+
columns [options] <file> List the columns of a CSV file
|
|
386
|
+
stats [options] <file> Compute the statistics of a numeric column
|
|
387
|
+
json [options] <file> Convert a CSV file to JSON
|
|
388
|
+
help [command] display help for command
|
|
389
|
+
|
|
390
|
+
$ node packages/app/build/App.js stats --help
|
|
391
|
+
Usage: tally stats [options] <file>
|
|
392
|
+
|
|
393
|
+
Compute the statistics of a numeric column
|
|
394
|
+
|
|
395
|
+
Arguments:
|
|
396
|
+
file The CSV file
|
|
397
|
+
|
|
398
|
+
Options:
|
|
399
|
+
--delimiter <value> The character between the values (default: ",")
|
|
400
|
+
--column <value> The column to summarize
|
|
401
|
+
--decimals <value> The decimals of the results (default: 2)
|
|
402
|
+
--format <value> The output (choices: "text", "json", default: "text")
|
|
403
|
+
-h, --help display help for command
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
```text
|
|
407
|
+
$ node packages/app/build/App.js columns sales.csv
|
|
408
|
+
date text 7 values
|
|
409
|
+
region text 7 values
|
|
410
|
+
product text 7 values
|
|
411
|
+
units number 6 values, 1 empty
|
|
412
|
+
price number 7 values
|
|
413
|
+
|
|
414
|
+
$ node packages/app/build/App.js stats sales.csv --column units
|
|
415
|
+
column units
|
|
416
|
+
count 6
|
|
417
|
+
empty 1
|
|
418
|
+
sum 103
|
|
419
|
+
min 7
|
|
420
|
+
max 30
|
|
421
|
+
mean 17.17
|
|
422
|
+
median 15
|
|
423
|
+
|
|
424
|
+
$ node packages/app/build/App.js stats sales.csv --column price --decimals 3 --format json
|
|
425
|
+
{
|
|
426
|
+
"column": "price",
|
|
427
|
+
"count": 7,
|
|
428
|
+
"empty": 0,
|
|
429
|
+
"sum": 29,
|
|
430
|
+
"min": 3.2,
|
|
431
|
+
"max": 5.1,
|
|
432
|
+
"mean": 4.143,
|
|
433
|
+
"median": 4.5
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
$ node packages/app/build/App.js json sales.csv --columns product units --numbers --limit 2
|
|
437
|
+
[
|
|
438
|
+
{
|
|
439
|
+
"product": "Tea, green",
|
|
440
|
+
"units": 12
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
"product": "Coffee",
|
|
444
|
+
"units": 30
|
|
445
|
+
}
|
|
446
|
+
]
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
`--delimiter ';'` reads the files whose values are separated by semicolons, and `--delimiter $'\t'` the TSV files.
|
|
450
|
+
|
|
451
|
+
**A question for a missing option.** In a terminal, the tool asks for a missing required option. The question is the
|
|
452
|
+
description of the property followed by its name. The answer, `price` here, is checked against the rules of the
|
|
453
|
+
property, and the question is asked again if the answer is invalid:
|
|
454
|
+
|
|
455
|
+
```text
|
|
456
|
+
$ node packages/app/build/App.js stats sales.csv
|
|
457
|
+
✔ The column to summarize (column) price
|
|
458
|
+
column price
|
|
459
|
+
count 7
|
|
460
|
+
empty 0
|
|
461
|
+
sum 29
|
|
462
|
+
min 3.2
|
|
463
|
+
max 5.1
|
|
464
|
+
mean 4.14
|
|
465
|
+
median 4.5
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
The tool asks only when its stdin and its stdout are both a terminal. In a script or a pipe, it asks nothing, and the
|
|
469
|
+
missing option is an error:
|
|
470
|
+
|
|
471
|
+
```text
|
|
472
|
+
$ node packages/app/build/App.js stats sales.csv < /dev/null
|
|
473
|
+
error: required option '--column <value>' not specified
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
**Errors and exit codes.** Invalid input is refused at one of three steps. Each step writes `error:` and a message to
|
|
477
|
+
stderr, and the tool exits with code 1:
|
|
478
|
+
|
|
479
|
+
```text
|
|
480
|
+
$ node packages/app/build/App.js stats sales.csv --column units --decimals two
|
|
481
|
+
error: option '--decimals <value>' argument 'two' is invalid. Not a number.
|
|
482
|
+
$ node packages/app/build/App.js stats sales.csv --column units --format xml
|
|
483
|
+
error: option '--format <value>' argument 'xml' is invalid. Allowed choices are "text", "json".
|
|
484
|
+
$ node packages/app/build/App.js stats sales.csv --column units --decimals 9
|
|
485
|
+
error: "decimals" must be less than or equal to 6
|
|
486
|
+
$ node packages/app/build/App.js stats sales.csv --column region
|
|
487
|
+
error: The value "north" of the column "region" (row 1) is not a number
|
|
488
|
+
$ node packages/app/build/App.js columns sale.csv
|
|
489
|
+
error: The file sale.csv does not exist
|
|
490
|
+
$ echo $?
|
|
491
|
+
1
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
- The command line parser refuses what does not fit the command, before the DTO sees it: text instead of a number, a
|
|
495
|
+
value outside `.valid()`, a missing argument or option, an unknown command.
|
|
496
|
+
- The DTO refuses a value that breaks its other rules, such as `.max(6)`.
|
|
497
|
+
- The action refuses what only it can check, with an exception: a file that does not exist, a column that does not
|
|
498
|
+
hold numbers.
|
|
499
|
+
|
|
500
|
+
A command that succeeds exits with code 0, as do `--help` and `--version`, so scripts can test the result of each
|
|
501
|
+
command.
|
|
502
|
+
|
|
503
|
+
## 8. Test it
|
|
504
|
+
|
|
505
|
+
The test of the template is replaced by a test of the commands. As in the template, it runs the built tool in a child
|
|
506
|
+
process, here with a CSV file written to a temporary directory. The stdin of the child is not a terminal, so the tool
|
|
507
|
+
asks nothing:
|
|
508
|
+
|
|
509
|
+
**`packages/app/src/tests/App.spec.ts`**
|
|
510
|
+
|
|
511
|
+
```typescript
|
|
512
|
+
import {after, before, describe, it} from 'node:test'
|
|
513
|
+
import assert from 'node:assert'
|
|
514
|
+
import {execFile, type ExecFileException} from 'node:child_process'
|
|
515
|
+
import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'
|
|
516
|
+
import {tmpdir} from 'node:os'
|
|
517
|
+
import path from 'node:path'
|
|
518
|
+
import {fileURLToPath} from 'node:url'
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* The compiled tool
|
|
522
|
+
*/
|
|
523
|
+
const TOOL: string = fileURLToPath(new URL('../App.js', import.meta.url))
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Run the tool with arguments (its stdin is not a terminal: it asks for nothing)
|
|
527
|
+
* @param args
|
|
528
|
+
*/
|
|
529
|
+
function tool(...args: string[]): Promise<{ code: number, stdout: string, stderr: string }> {
|
|
530
|
+
return new Promise((resolve: (result: { code: number, stdout: string, stderr: string }) => void): void => {
|
|
531
|
+
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}))
|
|
532
|
+
})
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
describe('tally', (): void => {
|
|
536
|
+
|
|
537
|
+
let directory: string
|
|
538
|
+
let file: string
|
|
539
|
+
|
|
540
|
+
//A CSV file in a temporary directory: a quoted value holding a comma, an empty value, Windows line breaks
|
|
541
|
+
before((): void => {
|
|
542
|
+
directory = mkdtempSync(path.join(tmpdir(), 'tally-'))
|
|
543
|
+
file = path.join(directory, 'sales.csv')
|
|
544
|
+
writeFileSync(file, 'region,product,units\r\nnorth,"Tea, green",12\r\nsouth,Coffee,30\r\nnorth,Coffee,\r\neast,Cocoa,7\r\n')
|
|
545
|
+
})
|
|
546
|
+
|
|
547
|
+
after((): void => {
|
|
548
|
+
rmSync(directory, {recursive: true, force: true})
|
|
549
|
+
})
|
|
550
|
+
|
|
551
|
+
it('lists the columns', async (): Promise<void> => {
|
|
552
|
+
const result = await tool('columns', file)
|
|
553
|
+
assert.strictEqual(result.code, 0, result.stderr)
|
|
554
|
+
assert.strictEqual(result.stdout, 'region text 4 values\nproduct text 4 values\nunits number 3 values, 1 empty\n')
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it('computes the statistics of a column', async (): Promise<void> => {
|
|
558
|
+
const result = await tool('stats', file, '--column', 'units', '--format', 'json')
|
|
559
|
+
assert.strictEqual(result.code, 0, result.stderr)
|
|
560
|
+
assert.deepStrictEqual(JSON.parse(result.stdout), {column: 'units', count: 3, empty: 1, sum: 49, min: 7, max: 30, mean: 16.33, median: 12})
|
|
561
|
+
})
|
|
562
|
+
|
|
563
|
+
it('converts the rows to JSON', async (): Promise<void> => {
|
|
564
|
+
const result = await tool('json', file, '--columns', 'product', 'units', '--numbers', '--limit', '3')
|
|
565
|
+
assert.strictEqual(result.code, 0, result.stderr)
|
|
566
|
+
assert.deepStrictEqual(JSON.parse(result.stdout), [{product: 'Tea, green', units: 12}, {product: 'Coffee', units: 30}, {product: 'Coffee', units: ''}])
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
it('requires the column outside a terminal', async (): Promise<void> => {
|
|
570
|
+
const result = await tool('stats', file)
|
|
571
|
+
assert.strictEqual(result.code, 1)
|
|
572
|
+
assert.strictEqual(result.stderr, `error: required option '--column <value>' not specified\n`)
|
|
573
|
+
})
|
|
574
|
+
|
|
575
|
+
it('refuses an invalid option', async (): Promise<void> => {
|
|
576
|
+
const result = await tool('stats', file, '--column', 'units', '--decimals', '9')
|
|
577
|
+
assert.strictEqual(result.code, 1)
|
|
578
|
+
assert.strictEqual(result.stderr, 'error: "decimals" must be less than or equal to 6\n')
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
it('fails on a column that is not in the file', async (): Promise<void> => {
|
|
582
|
+
const result = await tool('stats', file, '--column', 'price')
|
|
583
|
+
assert.strictEqual(result.code, 1)
|
|
584
|
+
assert.strictEqual(result.stderr, `error: The column "price" is not in ${file}: region, product, units\n`)
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
it('fails on a value that is not a number', async (): Promise<void> => {
|
|
588
|
+
const result = await tool('stats', file, '--column', 'product')
|
|
589
|
+
assert.strictEqual(result.code, 1)
|
|
590
|
+
assert.strictEqual(result.stderr, 'error: The value "Tea, green" of the column "product" (row 1) is not a number\n')
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
it('shows its version', async (): Promise<void> => {
|
|
594
|
+
const {version} = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'))
|
|
595
|
+
assert.strictEqual((await tool('--version')).stdout.trim(), version)
|
|
596
|
+
})
|
|
597
|
+
})
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
```bash
|
|
601
|
+
npm test
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
```
|
|
605
|
+
app │ ✔ lists the columns
|
|
606
|
+
app │ ✔ computes the statistics of a column
|
|
607
|
+
app │ ✔ converts the rows to JSON
|
|
608
|
+
app │ ✔ requires the column outside a terminal
|
|
609
|
+
app │ ✔ refuses an invalid option
|
|
610
|
+
app │ ✔ fails on a column that is not in the file
|
|
611
|
+
app │ ✔ fails on a value that is not a number
|
|
612
|
+
app │ ✔ shows its version
|
|
613
|
+
app │ ℹ pass 8
|
|
614
|
+
✔ test ran in app
|
|
615
|
+
```
|
|
616
|
+
|
|
617
|
+
## 9. One file to ship
|
|
618
|
+
|
|
619
|
+
```bash
|
|
620
|
+
npm run package:bundle
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
```
|
|
624
|
+
✔ ../../dist/tally.cjs (1.1 MB): node ../../dist/tally.cjs
|
|
625
|
+
```
|
|
626
|
+
|
|
627
|
+
The script builds the tool, then `lakutata package bundle` writes it, with lakutata, commander and
|
|
628
|
+
`@inquirer/prompts`, into one JavaScript file: `dist/tally.cjs` at the root of the project (the printed path is
|
|
629
|
+
relative to `packages/app`, where the script runs). Node.js runs it from any directory, without `node_modules`; here,
|
|
630
|
+
from a directory next to the project:
|
|
631
|
+
|
|
632
|
+
```bash
|
|
633
|
+
mkdir ../tally-bundle && cp dist/tally.cjs sales.csv ../tally-bundle && cd ../tally-bundle
|
|
634
|
+
node tally.cjs stats sales.csv --column units --format json
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
```
|
|
638
|
+
{
|
|
639
|
+
"column": "units",
|
|
640
|
+
"count": 6,
|
|
641
|
+
"empty": 1,
|
|
642
|
+
"sum": 103,
|
|
643
|
+
"min": 7,
|
|
644
|
+
"max": 30,
|
|
645
|
+
"mean": 17.17,
|
|
646
|
+
"median": 15
|
|
647
|
+
}
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
- The bundle is a CommonJS file, so the sources cannot use top-level `await`.
|
|
651
|
+
- `npm run package:exe` makes `dist/tally`, an executable that runs without Node.js installed: Node.js is embedded in
|
|
652
|
+
it (about 110 MB). It runs on the platform it is made on; the README of the project shows how to make it for the
|
|
653
|
+
other platforms ([Packaging](../Packaging.md)).
|
|
654
|
+
- The `installer` feature of the template (`lakutata create --with installer`) makes the installers, the packages of
|
|
655
|
+
each system (deb, rpm, pkg, msi…) that put `tally` in the PATH ([Installers](../Installers.md)).
|
|
656
|
+
|
|
657
|
+
<!--
|
|
658
|
+
check:
|
|
659
|
+
create: tally cli
|
|
660
|
+
remove: packages/app/src/controllers/ExampleController.ts packages/app/src/dtos/GreetDTO.ts
|
|
661
|
+
run: npm test
|
|
662
|
+
run: npm run package:bundle
|
|
663
|
+
run: node dist/tally.cjs stats sales.csv --column units
|
|
664
|
+
-->
|