@kaiord/cli 0.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 ADDED
@@ -0,0 +1,227 @@
1
+ # @kaiord/cli
2
+
3
+ Command-line interface for Kaiord workout file conversion. Convert workout files between FIT, KRD, TCX, and ZWO formats with ease.
4
+
5
+ ## Installation
6
+
7
+ Install globally using npm or pnpm:
8
+
9
+ ```bash
10
+ npm install -g @kaiord/cli
11
+ ```
12
+
13
+ or
14
+
15
+ ```bash
16
+ pnpm add -g @kaiord/cli
17
+ ```
18
+
19
+ After installation, the `kaiord` command will be available globally.
20
+
21
+ ## Usage
22
+
23
+ ### Convert Command
24
+
25
+ Convert workout files between different formats.
26
+
27
+ #### Basic Usage
28
+
29
+ ```bash
30
+ kaiord convert --input workout.fit --output workout.krd
31
+ ```
32
+
33
+ #### FIT to KRD
34
+
35
+ ```bash
36
+ kaiord convert --input workout.fit --output workout.krd
37
+ ```
38
+
39
+ #### KRD to FIT
40
+
41
+ ```bash
42
+ kaiord convert --input workout.krd --output workout.fit
43
+ ```
44
+
45
+ #### Batch Conversion
46
+
47
+ Convert multiple files using glob patterns:
48
+
49
+ ```bash
50
+ kaiord convert --input "workouts/*.fit" --output-dir converted/
51
+ ```
52
+
53
+ #### Format Override
54
+
55
+ Override automatic format detection:
56
+
57
+ ```bash
58
+ kaiord convert --input data.bin --input-format fit --output workout.krd
59
+ ```
60
+
61
+ ### Validate Command
62
+
63
+ Perform round-trip validation to verify data integrity.
64
+
65
+ #### Basic Validation
66
+
67
+ ```bash
68
+ kaiord validate --input workout.fit
69
+ ```
70
+
71
+ #### Custom Tolerances
72
+
73
+ Use a custom tolerance configuration file:
74
+
75
+ ```bash
76
+ kaiord validate --input workout.fit --tolerance-config tolerance.json
77
+ ```
78
+
79
+ Example `tolerance.json`:
80
+
81
+ ```json
82
+ {
83
+ "time": { "absolute": 1, "unit": "seconds" },
84
+ "power": { "absolute": 1, "percentage": 1, "unit": "watts" },
85
+ "heartRate": { "absolute": 1, "unit": "bpm" },
86
+ "cadence": { "absolute": 1, "unit": "rpm" }
87
+ }
88
+ ```
89
+
90
+ ## Global Options
91
+
92
+ ### Verbosity Control
93
+
94
+ ```bash
95
+ # Verbose output (detailed logging)
96
+ kaiord convert --input workout.fit --output workout.krd --verbose
97
+
98
+ # Quiet mode (errors only)
99
+ kaiord convert --input workout.fit --output workout.krd --quiet
100
+ ```
101
+
102
+ ### Output Format
103
+
104
+ ```bash
105
+ # JSON output (machine-readable)
106
+ kaiord convert --input workout.fit --output workout.krd --json
107
+
108
+ # Force pretty terminal output
109
+ kaiord convert --input workout.fit --output workout.krd --log-format pretty
110
+
111
+ # Force structured JSON logs
112
+ kaiord convert --input workout.fit --output workout.krd --log-format json
113
+ ```
114
+
115
+ ## Environment Variables
116
+
117
+ The CLI automatically detects the environment and adjusts its behavior:
118
+
119
+ - **CI=true**: Enables structured JSON logging
120
+ - **NODE_ENV=production**: Enables structured JSON logging
121
+ - **TTY detection**: Automatically disables colors and spinners in non-interactive environments
122
+
123
+ ## Supported Formats
124
+
125
+ - **FIT** (.fit) - Garmin's binary workout file format
126
+ - **KRD** (.krd) - Kaiord's canonical JSON format
127
+ - **TCX** (.tcx) - Training Center XML format
128
+ - **ZWO** (.zwo) - Zwift workout XML format
129
+
130
+ ## Exit Codes
131
+
132
+ - **0**: Success
133
+ - **1**: Error (invalid arguments, file not found, parsing error, validation error)
134
+
135
+ ## Troubleshooting
136
+
137
+ ### File Not Found
138
+
139
+ Ensure the input file path is correct and the file exists:
140
+
141
+ ```bash
142
+ ls -la workout.fit
143
+ kaiord convert --input workout.fit --output workout.krd
144
+ ```
145
+
146
+ ### Permission Errors
147
+
148
+ Check file permissions:
149
+
150
+ ```bash
151
+ chmod 644 workout.fit
152
+ kaiord convert --input workout.fit --output workout.krd
153
+ ```
154
+
155
+ ### Format Detection Issues
156
+
157
+ If automatic format detection fails, use explicit format flags:
158
+
159
+ ```bash
160
+ kaiord convert --input data.bin --input-format fit --output workout.krd --output-format krd
161
+ ```
162
+
163
+ ### Corrupted Files
164
+
165
+ If a file is corrupted, the CLI will display a descriptive error message:
166
+
167
+ ```bash
168
+ kaiord convert --input corrupted.fit --output workout.krd
169
+ # Error: Failed to parse FIT file
170
+ # Details: Corrupted file header
171
+ ```
172
+
173
+ ## Testing
174
+
175
+ Run the test suites:
176
+
177
+ ```bash
178
+ # Run all tests
179
+ pnpm test
180
+
181
+ # Run unit tests only
182
+ pnpm test:unit
183
+
184
+ # Run integration tests
185
+ pnpm test:integration
186
+
187
+ # Run smoke tests
188
+ pnpm test:smoke
189
+
190
+ # Run tests in watch mode
191
+ pnpm test:watch
192
+ ```
193
+
194
+ ## Development
195
+
196
+ ```bash
197
+ # Install dependencies
198
+ pnpm install
199
+
200
+ # Build the CLI
201
+ pnpm build
202
+
203
+ # Run in development mode
204
+ pnpm dev -- convert --input workout.fit --output workout.krd
205
+
206
+ # Link for local testing
207
+ npm link
208
+ kaiord --version
209
+ npm unlink -g
210
+ ```
211
+
212
+ ## Documentation
213
+
214
+ For more information about the Kaiord project and the KRD format, see:
215
+
216
+ - [Kaiord Core Library](https://github.com/your-org/kaiord/tree/main/packages/core)
217
+ - [KRD Format Specification](https://github.com/your-org/kaiord/blob/main/docs/KRD_FORMAT.md)
218
+ - [Contributing Guide](https://github.com/your-org/kaiord/blob/main/CONTRIBUTING.md)
219
+
220
+ ## License
221
+
222
+ MIT - See [LICENSE](../../LICENSE) file for details.
223
+
224
+ ## Support
225
+
226
+ - Report issues: [GitHub Issues](https://github.com/your-org/kaiord/issues)
227
+ - Ask questions: [GitHub Discussions](https://github.com/your-org/kaiord/discussions)
@@ -0,0 +1,884 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ isTTY
4
+ } from "../chunk-TI3WVGXE.js";
5
+
6
+ // src/bin/kaiord.ts
7
+ import chalk3 from "chalk";
8
+ import { readFileSync } from "fs";
9
+ import { dirname as dirname2, join as join2 } from "path";
10
+ import { fileURLToPath } from "url";
11
+ import yargs from "yargs";
12
+ import { hideBin } from "yargs/helpers";
13
+
14
+ // src/commands/convert.ts
15
+ import {
16
+ createDefaultProviders,
17
+ FitParsingError as FitParsingError2,
18
+ KrdValidationError as KrdValidationError2,
19
+ ToleranceExceededError as ToleranceExceededError2
20
+ } from "@kaiord/core";
21
+ import chalk2 from "chalk";
22
+ import ora from "ora";
23
+ import { basename, join } from "path";
24
+ import { z as z2 } from "zod";
25
+
26
+ // src/utils/error-formatter.ts
27
+ import {
28
+ FitParsingError,
29
+ KrdValidationError,
30
+ ToleranceExceededError
31
+ } from "@kaiord/core";
32
+ import chalk from "chalk";
33
+ var shouldUseColors = () => {
34
+ return isTTY() || process.env.FORCE_COLOR === "1";
35
+ };
36
+ var formatError = (error, options = {}) => {
37
+ if (options.json) {
38
+ return formatErrorAsJson(error);
39
+ }
40
+ return formatErrorAsPretty(error);
41
+ };
42
+ var formatValidationErrors = (errors) => {
43
+ if (errors.length === 0) {
44
+ return "";
45
+ }
46
+ const useColors = shouldUseColors();
47
+ const lines = [
48
+ useColors ? chalk.red("Validation errors:") : "Validation errors:"
49
+ ];
50
+ for (const error of errors) {
51
+ const fieldPath = useColors ? chalk.yellow(error.field) : error.field;
52
+ const bullet = useColors ? chalk.red("\u2022") : "\u2022";
53
+ lines.push(` ${bullet} ${fieldPath}: ${error.message}`);
54
+ }
55
+ return lines.join("\n");
56
+ };
57
+ var formatToleranceViolations = (violations) => {
58
+ if (violations.length === 0) {
59
+ return "";
60
+ }
61
+ const useColors = shouldUseColors();
62
+ const lines = [
63
+ useColors ? chalk.red("Tolerance violations:") : "Tolerance violations:"
64
+ ];
65
+ for (const violation of violations) {
66
+ const fieldPath = useColors ? chalk.yellow(violation.field) : violation.field;
67
+ const bullet = useColors ? chalk.red("\u2022") : "\u2022";
68
+ const expected = violation.expected;
69
+ const actual = violation.actual;
70
+ const deviation = Math.abs(violation.deviation);
71
+ const tolerance = violation.tolerance;
72
+ lines.push(
73
+ ` ${bullet} ${fieldPath}: expected ${expected}, got ${actual} (deviation: ${deviation}, tolerance: \xB1${tolerance})`
74
+ );
75
+ }
76
+ return lines.join("\n");
77
+ };
78
+ var formatErrorAsPretty = (error) => {
79
+ const useColors = shouldUseColors();
80
+ if (error instanceof FitParsingError) {
81
+ const lines = [
82
+ useColors ? chalk.red("\u2716 Error: Failed to parse FIT file") : "\u2716 Error: Failed to parse FIT file",
83
+ "",
84
+ useColors ? chalk.gray("Details:") : "Details:",
85
+ ` ${error.message}`
86
+ ];
87
+ if (error.cause) {
88
+ lines.push("", useColors ? chalk.gray("Cause:") : "Cause:");
89
+ lines.push(` ${String(error.cause)}`);
90
+ }
91
+ lines.push(
92
+ "",
93
+ useColors ? chalk.cyan("Suggestion:") : "Suggestion:",
94
+ " Verify the file is a valid FIT workout file.",
95
+ " Try opening it in Garmin Connect to confirm."
96
+ );
97
+ return lines.join("\n");
98
+ }
99
+ if (error instanceof KrdValidationError) {
100
+ const lines = [
101
+ useColors ? chalk.red("\u2716 Error: Invalid KRD format") : "\u2716 Error: Invalid KRD format",
102
+ "",
103
+ formatValidationErrors(error.errors)
104
+ ];
105
+ lines.push(
106
+ "",
107
+ useColors ? chalk.cyan("Suggestion:") : "Suggestion:",
108
+ " Check the KRD file against the schema.",
109
+ " Ensure all required fields are present and have valid values."
110
+ );
111
+ return lines.join("\n");
112
+ }
113
+ if (error instanceof ToleranceExceededError) {
114
+ const lines = [
115
+ useColors ? chalk.red("\u2716 Error: Round-trip conversion failed") : "\u2716 Error: Round-trip conversion failed",
116
+ "",
117
+ formatToleranceViolations(error.violations)
118
+ ];
119
+ lines.push(
120
+ "",
121
+ useColors ? chalk.cyan("Suggestion:") : "Suggestion:",
122
+ " The conversion may have lost precision.",
123
+ " Consider adjusting tolerance values if the deviations are acceptable."
124
+ );
125
+ return lines.join("\n");
126
+ }
127
+ if (error instanceof Error) {
128
+ return [
129
+ useColors ? chalk.red("\u2716 Error: An unexpected error occurred") : "\u2716 Error: An unexpected error occurred",
130
+ "",
131
+ useColors ? chalk.gray("Details:") : "Details:",
132
+ ` ${error.message}`,
133
+ "",
134
+ ...error.stack ? [
135
+ useColors ? chalk.gray("Stack trace:") : "Stack trace:",
136
+ ` ${error.stack}`
137
+ ] : []
138
+ ].join("\n");
139
+ }
140
+ return [
141
+ useColors ? chalk.red("\u2716 Error: An unexpected error occurred") : "\u2716 Error: An unexpected error occurred",
142
+ "",
143
+ useColors ? chalk.gray("Details:") : "Details:",
144
+ ` ${String(error)}`
145
+ ].join("\n");
146
+ };
147
+ var formatErrorAsJson = (error) => {
148
+ if (error instanceof FitParsingError) {
149
+ return JSON.stringify(
150
+ {
151
+ success: false,
152
+ error: {
153
+ type: "FitParsingError",
154
+ message: error.message,
155
+ cause: error.cause ? String(error.cause) : void 0,
156
+ suggestion: "Verify the file is a valid FIT workout file."
157
+ }
158
+ },
159
+ null,
160
+ 2
161
+ );
162
+ }
163
+ if (error instanceof KrdValidationError) {
164
+ return JSON.stringify(
165
+ {
166
+ success: false,
167
+ error: {
168
+ type: "KrdValidationError",
169
+ message: error.message,
170
+ errors: error.errors,
171
+ suggestion: "Check the KRD file against the schema."
172
+ }
173
+ },
174
+ null,
175
+ 2
176
+ );
177
+ }
178
+ if (error instanceof ToleranceExceededError) {
179
+ return JSON.stringify(
180
+ {
181
+ success: false,
182
+ error: {
183
+ type: "ToleranceExceededError",
184
+ message: error.message,
185
+ violations: error.violations,
186
+ suggestion: "Consider adjusting tolerance values if acceptable."
187
+ }
188
+ },
189
+ null,
190
+ 2
191
+ );
192
+ }
193
+ if (error instanceof Error) {
194
+ return JSON.stringify(
195
+ {
196
+ success: false,
197
+ error: {
198
+ type: error.name || "Error",
199
+ message: error.message,
200
+ stack: error.stack
201
+ }
202
+ },
203
+ null,
204
+ 2
205
+ );
206
+ }
207
+ return JSON.stringify(
208
+ {
209
+ success: false,
210
+ error: {
211
+ type: "UnknownError",
212
+ message: String(error)
213
+ }
214
+ },
215
+ null,
216
+ 2
217
+ );
218
+ };
219
+
220
+ // src/utils/file-handler.ts
221
+ import {
222
+ readFile as fsReadFile,
223
+ writeFile as fsWriteFile,
224
+ mkdir
225
+ } from "fs/promises";
226
+ import { glob } from "glob";
227
+ import { dirname } from "path";
228
+ var readFile = async (path, format) => {
229
+ try {
230
+ if (format === "fit") {
231
+ const buffer = await fsReadFile(path);
232
+ return new Uint8Array(buffer);
233
+ } else {
234
+ return await fsReadFile(path, "utf-8");
235
+ }
236
+ } catch (error) {
237
+ if (error instanceof Error && "code" in error) {
238
+ if (error.code === "ENOENT") {
239
+ throw new Error(`File not found: ${path}`);
240
+ }
241
+ if (error.code === "EACCES") {
242
+ throw new Error(`Permission denied: ${path}`);
243
+ }
244
+ }
245
+ throw new Error(`Failed to read file: ${path}`);
246
+ }
247
+ };
248
+ var writeFile = async (path, data, format) => {
249
+ try {
250
+ const dir = dirname(path);
251
+ await mkdir(dir, { recursive: true });
252
+ if (format === "fit") {
253
+ if (!(data instanceof Uint8Array)) {
254
+ throw new Error("FIT files require Uint8Array data");
255
+ }
256
+ await fsWriteFile(path, data);
257
+ } else {
258
+ if (typeof data !== "string") {
259
+ throw new Error("Text files require string data");
260
+ }
261
+ await fsWriteFile(path, data, "utf-8");
262
+ }
263
+ } catch (error) {
264
+ if (error instanceof Error && "code" in error) {
265
+ if (error.code === "EACCES") {
266
+ throw new Error(`Permission denied: ${path}`);
267
+ }
268
+ }
269
+ if (error instanceof Error && error.message.includes("require")) {
270
+ throw error;
271
+ }
272
+ throw new Error(`Failed to write file: ${path}`);
273
+ }
274
+ };
275
+ var findFiles = async (pattern) => {
276
+ const files = await glob(pattern, {
277
+ nodir: true,
278
+ absolute: false
279
+ });
280
+ return files.sort();
281
+ };
282
+
283
+ // src/utils/format-detector.ts
284
+ import { extname } from "path";
285
+ import { z } from "zod";
286
+ var fileFormatSchema = z.enum(["fit", "krd", "tcx", "zwo"]);
287
+ var EXTENSION_TO_FORMAT = {
288
+ ".fit": "fit",
289
+ ".krd": "krd",
290
+ ".tcx": "tcx",
291
+ ".zwo": "zwo"
292
+ };
293
+ var detectFormat = (filePath) => {
294
+ const ext = extname(filePath).toLowerCase();
295
+ return EXTENSION_TO_FORMAT[ext] || null;
296
+ };
297
+
298
+ // src/utils/logger-factory.ts
299
+ var isCI = () => {
300
+ return process.env.CI === "true" || process.env.NODE_ENV === "production" || !process.stdout.isTTY;
301
+ };
302
+ var createLogger = async (options = {}) => {
303
+ const loggerType = options.type || (isCI() ? "structured" : "pretty");
304
+ if (loggerType === "structured") {
305
+ const { createStructuredLogger } = await import("../structured-logger-HMEQGEES.js");
306
+ return createStructuredLogger(options);
307
+ } else {
308
+ const { createPrettyLogger } = await import("../pretty-logger-P2OWMOGW.js");
309
+ return createPrettyLogger(options);
310
+ }
311
+ };
312
+
313
+ // src/commands/convert.ts
314
+ var convertOptionsSchema = z2.object({
315
+ input: z2.string(),
316
+ output: z2.string().optional(),
317
+ outputDir: z2.string().optional(),
318
+ inputFormat: fileFormatSchema.optional(),
319
+ outputFormat: fileFormatSchema.optional(),
320
+ verbose: z2.boolean().optional(),
321
+ quiet: z2.boolean().optional(),
322
+ json: z2.boolean().optional(),
323
+ logFormat: z2.enum(["pretty", "structured"]).optional()
324
+ });
325
+ var isBatchMode = (input) => {
326
+ return input.includes("*") || input.includes("?");
327
+ };
328
+ var convertSingleFile = async (inputFile, outputFile, inputFormat, outputFormat, providers) => {
329
+ const inputData = await readFile(inputFile, inputFormat);
330
+ let krd;
331
+ if (inputFormat === "fit") {
332
+ if (!(inputData instanceof Uint8Array)) {
333
+ throw new Error("FIT input must be Uint8Array");
334
+ }
335
+ krd = await providers.convertFitToKrd({ fitBuffer: inputData });
336
+ } else if (inputFormat === "tcx") {
337
+ if (typeof inputData !== "string") {
338
+ throw new Error("TCX input must be string");
339
+ }
340
+ krd = await providers.convertTcxToKrd({ tcxString: inputData });
341
+ } else if (inputFormat === "zwo") {
342
+ if (typeof inputData !== "string") {
343
+ throw new Error("ZWO input must be string");
344
+ }
345
+ krd = await providers.convertZwiftToKrd({ zwiftString: inputData });
346
+ } else if (inputFormat === "krd") {
347
+ if (typeof inputData !== "string") {
348
+ throw new Error("KRD input must be string");
349
+ }
350
+ krd = JSON.parse(inputData);
351
+ } else {
352
+ throw new Error(`Unsupported input format: ${inputFormat}`);
353
+ }
354
+ let outputData;
355
+ if (outputFormat === "fit") {
356
+ outputData = await providers.convertKrdToFit({ krd });
357
+ } else if (outputFormat === "tcx") {
358
+ outputData = await providers.convertKrdToTcx({ krd });
359
+ } else if (outputFormat === "zwo") {
360
+ outputData = await providers.convertKrdToZwift({ krd });
361
+ } else if (outputFormat === "krd") {
362
+ outputData = JSON.stringify(krd, null, 2);
363
+ } else {
364
+ throw new Error(`Unsupported output format: ${outputFormat}`);
365
+ }
366
+ await writeFile(outputFile, outputData, outputFormat);
367
+ };
368
+ var convertCommand = async (options) => {
369
+ const validatedOptions = convertOptionsSchema.parse(options);
370
+ const logger = await createLogger({
371
+ type: validatedOptions.logFormat,
372
+ level: validatedOptions.verbose ? "debug" : validatedOptions.quiet ? "error" : "info",
373
+ quiet: validatedOptions.quiet
374
+ });
375
+ try {
376
+ const batchMode = isBatchMode(validatedOptions.input);
377
+ if (batchMode) {
378
+ if (!validatedOptions.outputDir) {
379
+ const error = new Error("Batch mode requires --output-dir flag");
380
+ error.name = "InvalidArgumentError";
381
+ throw error;
382
+ }
383
+ const outputFormat = validatedOptions.outputFormat;
384
+ if (!outputFormat) {
385
+ const error = new Error(
386
+ "Batch mode requires --output-format flag to specify target format"
387
+ );
388
+ error.name = "InvalidArgumentError";
389
+ throw error;
390
+ }
391
+ const providers = createDefaultProviders(logger);
392
+ const startTime = Date.now();
393
+ const files = await findFiles(validatedOptions.input);
394
+ if (files.length === 0) {
395
+ const error = new Error(
396
+ `No files found matching pattern: ${validatedOptions.input}`
397
+ );
398
+ error.name = "InvalidArgumentError";
399
+ throw error;
400
+ }
401
+ logger.debug("Batch conversion started", {
402
+ pattern: validatedOptions.input,
403
+ fileCount: files.length,
404
+ outputDir: validatedOptions.outputDir,
405
+ outputFormat
406
+ });
407
+ const isTTY2 = process.stdout.isTTY && !validatedOptions.quiet && !validatedOptions.json;
408
+ const spinner = isTTY2 ? ora("Processing batch conversion...").start() : null;
409
+ const results = [];
410
+ for (const [index, file] of files.entries()) {
411
+ const fileNum = index + 1;
412
+ const fileName = basename(file);
413
+ if (spinner) {
414
+ spinner.text = `Converting ${fileNum}/${files.length}: ${fileName}`;
415
+ } else {
416
+ logger.info(`Converting ${fileNum}/${files.length}: ${fileName}`);
417
+ }
418
+ try {
419
+ const inputFormat = validatedOptions.inputFormat || detectFormat(file);
420
+ if (!inputFormat) {
421
+ throw new Error(`Unable to detect format for file: ${file}`);
422
+ }
423
+ const outputFileName = fileName.replace(
424
+ /\.(fit|krd|tcx|zwo)$/i,
425
+ `.${outputFormat}`
426
+ );
427
+ const outputFile = join(validatedOptions.outputDir, outputFileName);
428
+ await convertSingleFile(
429
+ file,
430
+ outputFile,
431
+ inputFormat,
432
+ outputFormat,
433
+ providers
434
+ );
435
+ results.push({
436
+ success: true,
437
+ inputFile: file,
438
+ outputFile
439
+ });
440
+ } catch (error) {
441
+ results.push({
442
+ success: false,
443
+ inputFile: file,
444
+ error: error instanceof Error ? error.message : String(error)
445
+ });
446
+ logger.error(`Failed to convert ${file}`, { error });
447
+ }
448
+ }
449
+ const totalTime = Date.now() - startTime;
450
+ const successful = results.filter((r) => r.success);
451
+ const failed = results.filter((r) => !r.success);
452
+ if (spinner) {
453
+ spinner.stop();
454
+ }
455
+ if (!validatedOptions.json) {
456
+ console.log("\nBatch conversion complete:");
457
+ console.log(
458
+ chalk2.green(` \u2713 Successful: ${successful.length}/${files.length}`)
459
+ );
460
+ if (failed.length > 0) {
461
+ console.log(
462
+ chalk2.red(` \u2717 Failed: ${failed.length}/${files.length}`)
463
+ );
464
+ }
465
+ console.log(` Total time: ${(totalTime / 1e3).toFixed(2)}s`);
466
+ if (failed.length > 0) {
467
+ console.log(chalk2.red("\nFailed conversions:"));
468
+ for (const result of failed) {
469
+ console.log(chalk2.red(` ${result.inputFile}: ${result.error}`));
470
+ }
471
+ }
472
+ } else {
473
+ console.log(
474
+ JSON.stringify(
475
+ {
476
+ success: failed.length === 0,
477
+ total: files.length,
478
+ successful: successful.length,
479
+ failed: failed.length,
480
+ totalTime,
481
+ results
482
+ },
483
+ null,
484
+ 2
485
+ )
486
+ );
487
+ }
488
+ if (failed.length > 0) {
489
+ process.exit(1);
490
+ }
491
+ } else {
492
+ const inputFormat = validatedOptions.inputFormat || detectFormat(validatedOptions.input);
493
+ if (!inputFormat) {
494
+ const error = new Error(
495
+ `Unable to detect input format from file: ${validatedOptions.input}. Supported formats: .fit, .krd, .tcx, .zwo`
496
+ );
497
+ error.name = "InvalidArgumentError";
498
+ throw error;
499
+ }
500
+ if (!validatedOptions.output) {
501
+ const error = new Error("Output file is required");
502
+ error.name = "InvalidArgumentError";
503
+ throw error;
504
+ }
505
+ const outputFormat = validatedOptions.outputFormat || detectFormat(validatedOptions.output);
506
+ if (!outputFormat) {
507
+ const error = new Error(
508
+ `Unable to detect output format from file: ${validatedOptions.output}. Supported formats: .fit, .krd, .tcx, .zwo`
509
+ );
510
+ error.name = "InvalidArgumentError";
511
+ throw error;
512
+ }
513
+ const providers = createDefaultProviders(logger);
514
+ logger.debug("Convert command initialized", {
515
+ input: validatedOptions.input,
516
+ output: validatedOptions.output,
517
+ inputFormat,
518
+ outputFormat
519
+ });
520
+ const isTTY2 = process.stdout.isTTY && !validatedOptions.quiet && !validatedOptions.json;
521
+ const spinner = isTTY2 ? ora("Converting...").start() : null;
522
+ try {
523
+ await convertSingleFile(
524
+ validatedOptions.input,
525
+ validatedOptions.output,
526
+ inputFormat,
527
+ outputFormat,
528
+ providers
529
+ );
530
+ if (validatedOptions.json) {
531
+ console.log(
532
+ JSON.stringify(
533
+ {
534
+ success: true,
535
+ inputFile: validatedOptions.input,
536
+ outputFile: validatedOptions.output,
537
+ inputFormat,
538
+ outputFormat
539
+ },
540
+ null,
541
+ 2
542
+ )
543
+ );
544
+ } else if (spinner) {
545
+ spinner.succeed(
546
+ `Conversion complete: ${validatedOptions.input} \u2192 ${validatedOptions.output}`
547
+ );
548
+ } else {
549
+ logger.info("Conversion complete", {
550
+ input: validatedOptions.input,
551
+ output: validatedOptions.output
552
+ });
553
+ }
554
+ } catch (error) {
555
+ if (spinner) {
556
+ spinner.fail("Conversion failed");
557
+ }
558
+ throw error;
559
+ }
560
+ }
561
+ } catch (error) {
562
+ logger.error("Conversion failed", { error });
563
+ const formattedError = formatError(error, {
564
+ json: validatedOptions.json
565
+ });
566
+ if (validatedOptions.json) {
567
+ console.log(formattedError);
568
+ } else {
569
+ console.error(formattedError);
570
+ }
571
+ let exitCode = 1;
572
+ if (error instanceof Error) {
573
+ if (error.message.includes("File not found")) {
574
+ exitCode = 1;
575
+ } else if (error.message.includes("Permission denied")) {
576
+ exitCode = 1;
577
+ } else if (error instanceof FitParsingError2) {
578
+ exitCode = 1;
579
+ } else if (error instanceof KrdValidationError2) {
580
+ exitCode = 1;
581
+ } else if (error instanceof ToleranceExceededError2) {
582
+ exitCode = 1;
583
+ } else if (error.name === "InvalidArgumentError") {
584
+ exitCode = 1;
585
+ }
586
+ }
587
+ process.exit(exitCode);
588
+ }
589
+ };
590
+
591
+ // src/commands/validate.ts
592
+ import {
593
+ createDefaultProviders as createDefaultProviders2,
594
+ createToleranceChecker,
595
+ toleranceConfigSchema,
596
+ validateRoundTrip
597
+ } from "@kaiord/core";
598
+ import { readFile as fsReadFile2 } from "fs/promises";
599
+ import ora2 from "ora";
600
+ import { z as z3 } from "zod";
601
+ var validateOptionsSchema = z3.object({
602
+ input: z3.string(),
603
+ toleranceConfig: z3.string().optional(),
604
+ verbose: z3.boolean().optional(),
605
+ quiet: z3.boolean().optional(),
606
+ json: z3.boolean().optional(),
607
+ logFormat: z3.enum(["pretty", "json"]).optional()
608
+ });
609
+ var validateCommand = async (options) => {
610
+ const opts = validateOptionsSchema.parse(options);
611
+ const loggerType = opts.logFormat === "json" ? "structured" : opts.logFormat;
612
+ const logger = await createLogger({
613
+ type: loggerType,
614
+ level: opts.verbose ? "debug" : opts.quiet ? "error" : "info",
615
+ quiet: opts.quiet
616
+ });
617
+ try {
618
+ const format = detectFormat(opts.input);
619
+ if (!format) {
620
+ throw new Error(`Unable to detect format from file: ${opts.input}`);
621
+ }
622
+ if (format !== "fit") {
623
+ throw new Error(
624
+ `Validation currently only supports FIT files. Got: ${format}`
625
+ );
626
+ }
627
+ logger.debug("Reading input file", { path: opts.input, format });
628
+ const inputData = await readFile(opts.input, format);
629
+ if (typeof inputData === "string") {
630
+ throw new Error("Expected binary data for FIT file");
631
+ }
632
+ let toleranceConfig;
633
+ if (opts.toleranceConfig) {
634
+ logger.debug("Loading custom tolerance config", {
635
+ path: opts.toleranceConfig
636
+ });
637
+ const configContent = await fsReadFile2(opts.toleranceConfig, "utf-8");
638
+ const configJson = JSON.parse(configContent);
639
+ toleranceConfig = toleranceConfigSchema.parse(configJson);
640
+ logger.debug("Custom tolerance config loaded", {
641
+ config: toleranceConfig
642
+ });
643
+ }
644
+ const providers = createDefaultProviders2(logger);
645
+ const toleranceChecker = toleranceConfig ? createToleranceChecker(toleranceConfig) : providers.toleranceChecker;
646
+ const roundTripValidator = validateRoundTrip(
647
+ providers.fitReader,
648
+ providers.fitWriter,
649
+ toleranceChecker,
650
+ logger
651
+ );
652
+ const spinner = opts.quiet || opts.json ? null : ora2("Validating round-trip conversion...").start();
653
+ logger.info("Starting round-trip validation", { file: opts.input });
654
+ const violations = await roundTripValidator.validateFitToKrdToFit({
655
+ originalFit: inputData
656
+ });
657
+ if (spinner) {
658
+ if (violations.length === 0) {
659
+ spinner.succeed("Validation complete - no tolerance violations");
660
+ } else {
661
+ spinner.fail(
662
+ `Validation failed - ${violations.length} tolerance violation(s)`
663
+ );
664
+ }
665
+ }
666
+ if (violations.length === 0) {
667
+ logger.info("Round-trip validation passed");
668
+ if (opts.json) {
669
+ console.log(
670
+ JSON.stringify(
671
+ {
672
+ success: true,
673
+ file: opts.input,
674
+ format,
675
+ violations: []
676
+ },
677
+ null,
678
+ 2
679
+ )
680
+ );
681
+ } else if (!opts.quiet) {
682
+ console.log("\u2713 Round-trip validation passed");
683
+ }
684
+ process.exit(0);
685
+ } else {
686
+ logger.warn("Round-trip validation failed", {
687
+ violationCount: violations.length
688
+ });
689
+ if (opts.json) {
690
+ console.log(
691
+ JSON.stringify(
692
+ {
693
+ success: false,
694
+ file: opts.input,
695
+ format,
696
+ violations
697
+ },
698
+ null,
699
+ 2
700
+ )
701
+ );
702
+ } else {
703
+ console.error("\u2716 Round-trip validation failed\n");
704
+ console.error(formatToleranceViolations(violations));
705
+ }
706
+ process.exit(1);
707
+ }
708
+ } catch (error) {
709
+ logger.error("Validation failed", { error });
710
+ if (opts.json) {
711
+ console.log(
712
+ JSON.stringify(
713
+ {
714
+ success: false,
715
+ error: formatError(error, { json: true })
716
+ },
717
+ null,
718
+ 2
719
+ )
720
+ );
721
+ } else {
722
+ console.error(formatError(error, { json: false }));
723
+ }
724
+ process.exit(1);
725
+ }
726
+ };
727
+
728
+ // src/bin/kaiord.ts
729
+ var __filename2 = fileURLToPath(import.meta.url);
730
+ var __dirname2 = dirname2(__filename2);
731
+ var packageJsonPath = __dirname2.includes("/dist") ? join2(__dirname2, "../../package.json") : join2(__dirname2, "../../package.json");
732
+ var packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
733
+ var version = packageJson.version;
734
+ var showKiroEasterEgg = () => {
735
+ console.log(
736
+ chalk3.cyan(`
737
+ \u2554\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2557
738
+ \u2551 \u2551
739
+ \u2551 \u{1F47B} Built with Kiro AI during Kiroween Hackathon \u{1F47B} \u2551
740
+ \u2551 \u2551
741
+ \u2551 Kiro helped design, architect, and implement this \u2551
742
+ \u2551 entire CLI tool through spec-driven development. \u2551
743
+ \u2551 \u2551
744
+ \u2551 Learn more about Kiroween: \u2551
745
+ \u2551 \u{1F449} http://kiroween.devpost.com/ \u2551
746
+ \u2551 \u2551
747
+ \u2551 Kiro: Your AI pair programmer for building better \u2551
748
+ \u2551 software, faster. \u{1F680} \u2551
749
+ \u2551 \u2551
750
+ \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u255D
751
+ `)
752
+ );
753
+ process.exit(0);
754
+ };
755
+ var main = async () => {
756
+ try {
757
+ const args = process.argv.slice(2);
758
+ if (args.includes("--kiro") || args.includes("--kiroween")) {
759
+ showKiroEasterEgg();
760
+ }
761
+ await yargs(hideBin(process.argv)).scriptName("kaiord").usage("$0 <command> [options]").command(
762
+ "convert",
763
+ "Convert workout files between formats",
764
+ (yargs2) => {
765
+ return yargs2.option("input", {
766
+ alias: "i",
767
+ type: "string",
768
+ description: "Input file path or glob pattern",
769
+ demandOption: true
770
+ }).option("output", {
771
+ alias: "o",
772
+ type: "string",
773
+ description: "Output file path"
774
+ }).option("output-dir", {
775
+ type: "string",
776
+ description: "Output directory for batch conversion"
777
+ }).option("input-format", {
778
+ type: "string",
779
+ choices: ["fit", "krd", "tcx", "zwo"],
780
+ description: "Override input format detection"
781
+ }).option("output-format", {
782
+ type: "string",
783
+ choices: ["fit", "krd", "tcx", "zwo"],
784
+ description: "Override output format detection"
785
+ }).example(
786
+ "$0 convert -i workout.fit -o workout.krd",
787
+ "Convert FIT to KRD"
788
+ ).example(
789
+ "$0 convert -i workout.krd -o workout.fit",
790
+ "Convert KRD to FIT"
791
+ ).example(
792
+ '$0 convert -i "workouts/*.fit" --output-dir converted/',
793
+ "Batch convert all FIT files"
794
+ );
795
+ },
796
+ async (argv) => {
797
+ await convertCommand({
798
+ input: argv.input,
799
+ output: argv.output,
800
+ outputDir: argv.outputDir,
801
+ inputFormat: argv.inputFormat,
802
+ outputFormat: argv.outputFormat,
803
+ verbose: argv.verbose,
804
+ quiet: argv.quiet,
805
+ json: argv.json,
806
+ logFormat: argv.logFormat
807
+ });
808
+ }
809
+ ).command(
810
+ "validate",
811
+ "Validate round-trip conversion of workout files",
812
+ (yargs2) => {
813
+ return yargs2.option("input", {
814
+ alias: "i",
815
+ type: "string",
816
+ description: "Input file path",
817
+ demandOption: true
818
+ }).option("tolerance-config", {
819
+ type: "string",
820
+ description: "Path to custom tolerance configuration JSON"
821
+ }).example(
822
+ "$0 validate -i workout.fit",
823
+ "Validate round-trip conversion"
824
+ ).example(
825
+ "$0 validate -i workout.fit --tolerance-config custom.json",
826
+ "Validate with custom tolerances"
827
+ );
828
+ },
829
+ async (argv) => {
830
+ await validateCommand({
831
+ input: argv.input,
832
+ toleranceConfig: argv.toleranceConfig,
833
+ verbose: argv.verbose,
834
+ quiet: argv.quiet,
835
+ json: argv.json,
836
+ logFormat: argv.logFormat
837
+ });
838
+ }
839
+ ).option("verbose", {
840
+ type: "boolean",
841
+ description: "Enable verbose logging",
842
+ global: true
843
+ }).option("quiet", {
844
+ type: "boolean",
845
+ description: "Suppress all output except errors",
846
+ global: true
847
+ }).option("json", {
848
+ type: "boolean",
849
+ description: "Output results in JSON format",
850
+ global: true
851
+ }).option("log-format", {
852
+ type: "string",
853
+ choices: ["pretty", "structured"],
854
+ description: "Force specific log format",
855
+ global: true
856
+ }).version(version).alias("version", "v").help().alias("help", "h").demandCommand(1, "You must specify a command").strict().parse();
857
+ } catch (error) {
858
+ const formattedError = formatError(error, { json: false });
859
+ console.error(formattedError);
860
+ if (error && typeof error === "object" && "name" in error) {
861
+ const errorName = error.name;
862
+ if (errorName === "FitParsingError") {
863
+ process.exit(4);
864
+ } else if (errorName === "KrdValidationError") {
865
+ process.exit(5);
866
+ } else if (errorName === "ToleranceExceededError") {
867
+ process.exit(6);
868
+ }
869
+ }
870
+ process.exit(99);
871
+ }
872
+ };
873
+ process.on("unhandledRejection", (reason) => {
874
+ console.error("Unhandled rejection:", reason);
875
+ process.exit(99);
876
+ });
877
+ process.on("uncaughtException", (error) => {
878
+ console.error("Uncaught exception:", error);
879
+ process.exit(99);
880
+ });
881
+ main().catch((error) => {
882
+ console.error("Fatal error:", error);
883
+ process.exit(99);
884
+ });
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/utils/is-tty.ts
4
+ var isTTY = () => {
5
+ return process.stdout.isTTY === true;
6
+ };
7
+
8
+ export {
9
+ isTTY
10
+ };
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ isTTY
4
+ } from "./chunk-TI3WVGXE.js";
5
+
6
+ // src/adapters/logger/pretty-logger.ts
7
+ import chalk from "chalk";
8
+ var createPrettyLogger = (options = {}) => {
9
+ const level = options.level || "info";
10
+ const quiet = options.quiet || false;
11
+ const forceColor = process.env.FORCE_COLOR === "1";
12
+ const useColors = isTTY() || forceColor;
13
+ const levels = ["debug", "info", "warn", "error"];
14
+ const minLevelIndex = levels.indexOf(level);
15
+ const shouldLog = (messageLevel) => {
16
+ if (quiet && messageLevel !== "error") {
17
+ return false;
18
+ }
19
+ const messageLevelIndex = levels.indexOf(messageLevel);
20
+ return messageLevelIndex >= minLevelIndex;
21
+ };
22
+ const formatContext = (context) => {
23
+ if (!context || Object.keys(context).length === 0) {
24
+ return "";
25
+ }
26
+ const contextStr = JSON.stringify(context);
27
+ return " " + (useColors ? chalk.gray(contextStr) : contextStr);
28
+ };
29
+ return {
30
+ debug: (message, context) => {
31
+ if (shouldLog("debug")) {
32
+ const formatted = `\u{1F41B} ${message}${formatContext(context)}`;
33
+ console.log(useColors ? chalk.gray(formatted) : formatted);
34
+ }
35
+ },
36
+ info: (message, context) => {
37
+ if (shouldLog("info")) {
38
+ const formatted = `\u2139 ${message}${formatContext(context)}`;
39
+ console.log(useColors ? chalk.blue(formatted) : formatted);
40
+ }
41
+ },
42
+ warn: (message, context) => {
43
+ if (shouldLog("warn")) {
44
+ const formatted = `\u26A0 ${message}${formatContext(context)}`;
45
+ console.warn(useColors ? chalk.yellow(formatted) : formatted);
46
+ }
47
+ },
48
+ error: (message, context) => {
49
+ if (shouldLog("error")) {
50
+ const formatted = `\u2716 ${message}${formatContext(context)}`;
51
+ console.error(useColors ? chalk.red(formatted) : formatted);
52
+ }
53
+ }
54
+ };
55
+ };
56
+ export {
57
+ createPrettyLogger
58
+ };
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/adapters/logger/structured-logger.ts
4
+ import winston from "winston";
5
+ var createStructuredLogger = (options = {}) => {
6
+ const level = options.level || "info";
7
+ const quiet = options.quiet || false;
8
+ const winstonLogger = winston.createLogger({
9
+ level: quiet ? "error" : level,
10
+ format: winston.format.combine(
11
+ winston.format.timestamp(),
12
+ winston.format.json()
13
+ ),
14
+ transports: [
15
+ new winston.transports.Console({
16
+ stderrLevels: ["error", "warn", "info", "debug"]
17
+ })
18
+ ]
19
+ });
20
+ return {
21
+ debug: (message, context) => {
22
+ winstonLogger.debug(message, context);
23
+ },
24
+ info: (message, context) => {
25
+ winstonLogger.info(message, context);
26
+ },
27
+ warn: (message, context) => {
28
+ winstonLogger.warn(message, context);
29
+ },
30
+ error: (message, context) => {
31
+ winstonLogger.error(message, context);
32
+ }
33
+ };
34
+ };
35
+ export {
36
+ createStructuredLogger
37
+ };
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@kaiord/cli",
3
+ "version": "0.1.1",
4
+ "description": "Command-line interface for Kaiord workout file conversion",
5
+ "type": "module",
6
+ "bin": {
7
+ "kaiord": "./dist/bin/kaiord.js"
8
+ },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
14
+ "scripts": {
15
+ "build": "tsup",
16
+ "test": "vitest run",
17
+ "test:unit": "vitest run --exclude '**/*-{integration,smoke,snapshot}.test.ts'",
18
+ "test:integration": "vitest run src/commands/convert-integration.test.ts src/commands/validate-integration.test.ts",
19
+ "test:smoke": "vitest run src/tests/cli-smoke.test.ts",
20
+ "test:watch": "vitest",
21
+ "dev": "tsx src/bin/kaiord.ts",
22
+ "prepublishOnly": "pnpm build && pnpm test",
23
+ "check-licenses": "license-checker --onlyAllow 'MIT;Apache-2.0;BSD;BSD-2-Clause;BSD-3-Clause;ISC' --production"
24
+ },
25
+ "keywords": [
26
+ "kaiord",
27
+ "fit",
28
+ "tcx",
29
+ "zwo",
30
+ "workout",
31
+ "garmin",
32
+ "zwift",
33
+ "cli",
34
+ "converter"
35
+ ],
36
+ "author": "Kaiord Contributors",
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/your-org/kaiord.git",
41
+ "directory": "packages/cli"
42
+ },
43
+ "homepage": "https://www.npmjs.com/package/@kaiord/cli",
44
+ "bugs": {
45
+ "url": "https://github.com/your-org/kaiord/issues"
46
+ },
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "dependencies": {
51
+ "@kaiord/core": "workspace:*",
52
+ "chalk": "^5.3.0",
53
+ "glob": "^10.3.10",
54
+ "ora": "^8.0.1",
55
+ "winston": "^3.11.0",
56
+ "yargs": "^17.7.2",
57
+ "zod": "^3.22.4"
58
+ },
59
+ "devDependencies": {
60
+ "@types/yargs": "^17.0.32",
61
+ "execa": "^8.0.1",
62
+ "license-checker": "^25.0.1",
63
+ "strip-ansi": "^7.1.0",
64
+ "tmp-promise": "^3.0.3",
65
+ "tsup": "^8.0.1",
66
+ "tsx": "^4.7.0",
67
+ "typescript": "^5.3.3",
68
+ "vitest": "^1.2.0"
69
+ }
70
+ }