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.
Files changed (117) hide show
  1. package/README.md +50 -30
  2. package/README.zh-CN.md +130 -0
  3. package/dist/cjs/exports/QueueComponent.d.ts +1 -0
  4. package/dist/cjs/exports/QueueComponent.js +20 -0
  5. package/dist/esm/exports/QueueComponent.js +1 -0
  6. package/dist/types/exports/QueueComponent.d.ts +1 -0
  7. package/doc/en/Application.md +107 -0
  8. package/doc/en/CLI.md +274 -0
  9. package/doc/en/Cache.md +379 -0
  10. package/doc/en/Command.md +110 -0
  11. package/doc/en/ContainerdIntegration.md +17 -16
  12. package/doc/en/Controllers.md +256 -0
  13. package/doc/en/DBus.md +291 -0
  14. package/doc/en/DTOValidation.md +72 -43
  15. package/doc/en/Database.md +188 -0
  16. package/doc/en/DatabaseDrivers.md +287 -0
  17. package/doc/en/Decorator.md +52 -40
  18. package/doc/en/DockerIntegration.md +7 -6
  19. package/doc/en/Entities.md +240 -0
  20. package/doc/en/Entrypoint.md +216 -0
  21. package/doc/en/Exceptions.md +174 -0
  22. package/doc/en/GRPC.md +251 -0
  23. package/doc/en/GettingStarted.md +165 -0
  24. package/doc/en/HTTP.md +507 -0
  25. package/doc/en/HelperFunctions.md +11 -11
  26. package/doc/en/I18n.md +119 -115
  27. package/doc/en/Injection.md +144 -0
  28. package/doc/en/Installers.md +280 -0
  29. package/doc/en/Logger.md +206 -0
  30. package/doc/en/MCP.md +293 -0
  31. package/doc/en/Middlewares.md +213 -0
  32. package/doc/en/Migrations.md +202 -0
  33. package/doc/en/MongoEmbedded.md +149 -0
  34. package/doc/en/Monitor.md +164 -0
  35. package/doc/en/Mutex.md +27 -25
  36. package/doc/en/NATS.md +246 -0
  37. package/doc/en/NativeAddons.md +212 -0
  38. package/doc/en/Objects.md +143 -0
  39. package/doc/en/Overview.md +62 -85
  40. package/doc/en/Packaging.md +193 -0
  41. package/doc/en/ProjectStructure.md +124 -0
  42. package/doc/en/Queue.md +269 -0
  43. package/doc/en/Schedules.md +217 -0
  44. package/doc/en/Security.md +10 -9
  45. package/doc/en/ServiceCalls.md +504 -0
  46. package/doc/en/Time.md +19 -20
  47. package/doc/en/UpgradingFrom1.md +435 -0
  48. package/doc/en/UpgradingFrom2.md +315 -0
  49. package/doc/en/Workspaces.md +189 -0
  50. package/doc/en/examples/BackgroundJobs.md +677 -0
  51. package/doc/en/examples/CommandLineTool.md +664 -0
  52. package/doc/en/examples/DesktopApp.md +809 -0
  53. package/doc/en/examples/DeviceService.md +607 -0
  54. package/doc/en/examples/MCPServer.md +653 -0
  55. package/doc/en/examples/README.md +16 -0
  56. package/doc/en/examples/RestAPI.md +490 -0
  57. package/doc/en/examples/ServiceCalls.md +829 -0
  58. package/doc/en/examples/WebApp.md +568 -0
  59. package/doc/zh/Application.md +75 -0
  60. package/doc/zh/CLI.md +250 -0
  61. package/doc/zh/Cache.md +328 -0
  62. package/doc/zh/Command.md +78 -0
  63. package/doc/zh/ContainerdIntegration.md +9 -9
  64. package/doc/zh/Controllers.md +228 -0
  65. package/doc/zh/DBus.md +257 -0
  66. package/doc/zh/DTOValidation.md +49 -19
  67. package/doc/zh/Database.md +175 -0
  68. package/doc/zh/DatabaseDrivers.md +261 -0
  69. package/doc/zh/Decorator.md +25 -18
  70. package/doc/zh/DockerIntegration.md +6 -6
  71. package/doc/zh/Entities.md +224 -0
  72. package/doc/zh/Entrypoint.md +193 -0
  73. package/doc/zh/Exceptions.md +158 -0
  74. package/doc/zh/GRPC.md +226 -0
  75. package/doc/zh/GettingStarted.md +148 -0
  76. package/doc/zh/HTTP.md +445 -0
  77. package/doc/zh/HelperFunctions.md +2 -2
  78. package/doc/zh/I18n.md +26 -26
  79. package/doc/zh/Injection.md +129 -0
  80. package/doc/zh/Installers.md +232 -0
  81. package/doc/zh/Logger.md +188 -0
  82. package/doc/zh/MCP.md +265 -0
  83. package/doc/zh/Middlewares.md +198 -0
  84. package/doc/zh/Migrations.md +178 -0
  85. package/doc/zh/MongoEmbedded.md +118 -0
  86. package/doc/zh/Monitor.md +145 -0
  87. package/doc/zh/Mutex.md +10 -10
  88. package/doc/zh/NATS.md +207 -0
  89. package/doc/zh/NativeAddons.md +180 -0
  90. package/doc/zh/Objects.md +119 -0
  91. package/doc/zh/Overview.md +46 -71
  92. package/doc/zh/Packaging.md +154 -0
  93. package/doc/zh/ProjectStructure.md +102 -0
  94. package/doc/zh/Queue.md +214 -0
  95. package/doc/zh/Schedules.md +193 -0
  96. package/doc/zh/Security.md +6 -6
  97. package/doc/zh/ServiceCalls.md +459 -0
  98. package/doc/zh/Time.md +2 -2
  99. package/doc/zh/UpgradingFrom1.md +347 -0
  100. package/doc/zh/UpgradingFrom2.md +230 -0
  101. package/doc/zh/Workspaces.md +150 -0
  102. package/doc/zh/examples/BackgroundJobs.md +618 -0
  103. package/doc/zh/examples/CommandLineTool.md +618 -0
  104. package/doc/zh/examples/DesktopApp.md +748 -0
  105. package/doc/zh/examples/DeviceService.md +546 -0
  106. package/doc/zh/examples/MCPServer.md +604 -0
  107. package/doc/zh/examples/README.md +14 -0
  108. package/doc/zh/examples/RestAPI.md +458 -0
  109. package/doc/zh/examples/ServiceCalls.md +765 -0
  110. package/doc/zh/examples/WebApp.md +520 -0
  111. package/package.json +46 -17
  112. package/doc/en/ActionPattern.md +0 -408
  113. package/doc/en/Core.md +0 -312
  114. package/doc/en/ORMIntegration.md +0 -341
  115. package/doc/zh/ActionPattern.md +0 -334
  116. package/doc/zh/Core.md +0 -256
  117. 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
+ -->