@nmakarov/cli-toolkit 0.2.0 → 0.3.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 CHANGED
@@ -4,11 +4,21 @@ A comprehensive TypeScript toolkit for building professional CLI applications wi
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/@nmakarov/cli-toolkit.svg)](https://www.npmjs.com/package/@nmakarov/cli-toolkit)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
+ [![CI](https://github.com/nmakarov/cli-toolkit/workflows/CI/badge.svg)](https://github.com/nmakarov/cli-toolkit/actions)
8
+ [![codecov](https://codecov.io/gh/nmakarov/cli-toolkit/branch/main/graph/badge.svg)](https://codecov.io/gh/nmakarov/cli-toolkit)
9
+ [![Known Vulnerabilities](https://snyk.io/test/github/nmakarov/cli-toolkit/badge.svg)](https://snyk.io/test/github/nmakarov/cli-toolkit)
10
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue.svg)](https://www.typescriptlang.org/)
11
+ [![Node Version](https://img.shields.io/node/v/@nmakarov/cli-toolkit.svg)](https://nodejs.org)
12
+ [![npm downloads](https://img.shields.io/npm/dm/@nmakarov/cli-toolkit.svg)](https://www.npmjs.com/package/@nmakarov/cli-toolkit)
13
+ [![Bundle Size](https://img.shields.io/bundlephobia/minzip/@nmakarov/cli-toolkit)](https://bundlephobia.com/package/@nmakarov/cli-toolkit)
7
14
 
8
15
  ## Features
9
16
 
10
17
  - 🎯 **Args** - Powerful argument parser with config files, environment variables, and precedence rules
11
18
  - ✅ **Params** - Type-safe parameter validation with Joi schemas and cross-parameter references
19
+ - 🌐 **HttpClient** - Resilient HTTP client with automatic retry, error classification, and unified responses
20
+ - 🧪 **MockServer** - HTTP mock server with FileDatabase integration for API testing
21
+ - 💾 **FileDatabase** - Versioned file storage with chunking, pagination, and legacy compatibility
12
22
  - 🖥️ **Screen** - Interactive terminal UIs with React/Ink (lists, menus, grids, navigation)
13
23
  - 📝 **Logger** - Structured logging with levels, progress tracking, and IPC routing
14
24
  - ⚡ **Errors** - Custom error classes for framework-specific error handling
@@ -52,7 +62,7 @@ node app.js --verbose --port=8080 build deploy
52
62
 
53
63
  [📖 Full Args Documentation](docs/ARGS.md)
54
64
 
55
- ### Params - Validate Parameters
65
+ ### Params - Validate Parameters.
56
66
 
57
67
  ```typescript
58
68
  import { Params } from '@nmakarov/cli-toolkit/params';
@@ -80,6 +90,107 @@ node app.js --name="My App" --port=8080 --tags="api,web" --startDate="-7d"
80
90
 
81
91
  [📖 Full Params Documentation](docs/PARAMS.md)
82
92
 
93
+ ### HttpClient - Resilient HTTP Requests
94
+
95
+ ```typescript
96
+ import { HttpClient } from '@nmakarov/cli-toolkit/http-client';
97
+
98
+ const client = new HttpClient({
99
+ timeout: 10000,
100
+ retryCount: 3,
101
+ retryDelay: 1000
102
+ });
103
+
104
+ // Always returns unified response - never throws!
105
+ const response = await client.get('https://api.example.com/users', {
106
+ params: { limit: 10 },
107
+ headers: { 'Authorization': 'Bearer token' }
108
+ });
109
+
110
+ if (response.status === 'success') {
111
+ console.log('Users:', response.data);
112
+ } else {
113
+ console.log('Error:', response.error); // Human-readable: 'connectionFailed', 'timeout', etc.
114
+ }
115
+ ```
116
+
117
+ ```bash
118
+ # Features:
119
+ # - Automatic retry with exponential backoff
120
+ # - Human-readable error classification
121
+ # - All HTTP methods (GET, POST, PUT, DELETE, PATCH, etc.)
122
+ # - Per-request configuration overrides
123
+ # - Comprehensive logging
124
+ ```
125
+
126
+ [📖 Full HttpClient Documentation](docs/HTTP_CLIENT.md)
127
+
128
+ ### FileDatabase - Structured File Storage
129
+
130
+ ```typescript
131
+ import { FileDatabase } from '@nmakarov/cli-toolkit/filedatabase';
132
+
133
+ // Versioned mode (default) - creates timestamped folders
134
+ const db = new FileDatabase({
135
+ basePath: './data',
136
+ namespace: 'api',
137
+ tableName: 'responses'
138
+ });
139
+
140
+ // Non-versioned mode - for single objects
141
+ const db = new FileDatabase({
142
+ basePath: './data',
143
+ namespace: 'cache',
144
+ tableName: 'user-profile',
145
+ versioned: false
146
+ });
147
+
148
+ await db.write(userData);
149
+ const data = await db.read(); // Auto-detects latest version
150
+ const hasData = await db.hasData();
151
+ ```
152
+
153
+ ```bash
154
+ # Features:
155
+ # - Versioned/non-versioned storage modes
156
+ # - Automatic chunking for large datasets
157
+ # - Pagination for efficient reading
158
+ # - Legacy format compatibility
159
+ # - Custom synopsis functions
160
+ ```
161
+
162
+ [📖 Full FileDatabase Documentation](docs/FILEDATABASE.md)
163
+
164
+ ### MockServer - HTTP Mock Server
165
+
166
+ ```typescript
167
+ import { MockServer } from '@nmakarov/cli-toolkit/mock-server';
168
+
169
+ const mockServer = new MockServer({
170
+ basePath: './mocks',
171
+ port: 5030
172
+ });
173
+
174
+ await mockServer.start();
175
+
176
+ // Capture responses
177
+ await mockServer.storeMock('https://api.example.com/users', null, response);
178
+
179
+ // Use with HttpClient for testing
180
+ const client = new HttpClient({ useTestServer: 'http://localhost:5030' });
181
+ ```
182
+
183
+ ```bash
184
+ # Features:
185
+ # - Express.js HTTP server with FileDatabase storage
186
+ # - Request/response capture and replay
187
+ # - Sensitive data masking
188
+ # - Automatic catalog management
189
+ # - Test server redirection support
190
+ ```
191
+
192
+ [📖 Full MockServer Documentation](docs/MOCK_SERVER.md)
193
+
83
194
  ### Screen - Interactive Terminal UIs
84
195
 
85
196
  ```typescript
@@ -27,17 +27,17 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
27
27
  ));
28
28
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
29
 
30
- // src/filestore.ts
31
- var filestore_exports = {};
32
- __export(filestore_exports, {
30
+ // src/filedatabase.ts
31
+ var filedatabase_exports = {};
32
+ __export(filedatabase_exports, {
33
33
  FileDatabase: () => FileDatabase,
34
34
  FileDatabaseError: () => FileDatabaseError,
35
35
  defaultFileSynopsisFunction: () => defaultFileSynopsisFunction,
36
36
  defaultVersionSynopsisFunction: () => defaultVersionSynopsisFunction
37
37
  });
38
- module.exports = __toCommonJS(filestore_exports);
38
+ module.exports = __toCommonJS(filedatabase_exports);
39
39
 
40
- // src/filestore/index.ts
40
+ // src/filedatabase/index.ts
41
41
  var import_fs3 = __toESM(require("fs"), 1);
42
42
  var import_path3 = __toESM(require("path"), 1);
43
43
 
@@ -113,7 +113,7 @@ function isTimestampFolder(folderName) {
113
113
  return !isNaN(date.getTime()) && date.getTime() > 0;
114
114
  }
115
115
 
116
- // src/filestore/serializers.ts
116
+ // src/filedatabase/serializers.ts
117
117
  function detectDataType(data) {
118
118
  if (Array.isArray(data)) {
119
119
  return "json-array";
@@ -159,7 +159,7 @@ var ParamError = class extends FrameworkError {
159
159
  }
160
160
  };
161
161
 
162
- // src/filestore/synopsis-functions.ts
162
+ // src/filedatabase/synopsis-functions.ts
163
163
  function defaultFileSynopsisFunction(fileEntry, data) {
164
164
  if (!Array.isArray(data) || data.length === 0) {
165
165
  return { ...fileEntry };
@@ -227,7 +227,7 @@ function defaultVersionSynopsisFunction(metadata) {
227
227
  return result;
228
228
  }
229
229
 
230
- // src/filestore/index.ts
230
+ // src/filedatabase/index.ts
231
231
  var FileDatabaseError = class extends Error {
232
232
  constructor(message) {
233
233
  super(message);
@@ -238,6 +238,7 @@ var FileDatabase = class {
238
238
  basePath;
239
239
  namespace;
240
240
  tableName = null;
241
+ versioned;
241
242
  maxVersions;
242
243
  pageSize;
243
244
  useMetadata;
@@ -261,6 +262,7 @@ var FileDatabase = class {
261
262
  this.basePath = config.basePath;
262
263
  this.namespace = config.namespace || "default";
263
264
  this.tableName = config.tableName || null;
265
+ this.versioned = config.versioned ?? true;
264
266
  this.maxVersions = config.maxVersions || 5;
265
267
  this.pageSize = config.pageSize || 5e3;
266
268
  this.useMetadata = config.useMetadata !== false;
@@ -283,14 +285,21 @@ var FileDatabase = class {
283
285
  };
284
286
  }
285
287
  /**
286
- * Get the destination path (basePath/namespace/tableName)
288
+ * Get the destination path (basePath/namespace/tableName[/version])
287
289
  */
288
- getDestinationPath() {
290
+ getDestinationPath(version) {
289
291
  const errors = ["basePath", "namespace", "tableName"].filter((prop) => !this[prop]).map((prop) => `${prop} is not set`);
290
292
  if (errors.length) {
291
293
  throw new FileDatabaseError(`[FileDatabase] ${errors.join("; ")}`);
292
294
  }
293
- return import_path3.default.resolve(this.basePath, this.namespace, this.tableName);
295
+ let parts = [this.basePath, this.namespace];
296
+ if (this.tableName) {
297
+ parts.push(...this.tableName.split("/"));
298
+ }
299
+ if (this.versioned && version) {
300
+ parts.push(version);
301
+ }
302
+ return import_path3.default.resolve(...parts);
294
303
  }
295
304
  /**
296
305
  * Set current version and version folder
@@ -301,8 +310,13 @@ var FileDatabase = class {
301
310
  }
302
311
  /**
303
312
  * Create a new version folder with comprehensive timestamp logic
313
+ * Only works in versioned mode
304
314
  */
305
315
  async makeNewVersion() {
316
+ if (!this.versioned) {
317
+ throw new FileDatabaseError("makeNewVersion() only works in versioned mode");
318
+ }
319
+ this.metadata = this.getDefaultMetadata();
306
320
  const existingVersions = await this.getVersions();
307
321
  let versionName;
308
322
  if (existingVersions.length > 0) {
@@ -330,8 +344,12 @@ var FileDatabase = class {
330
344
  }
331
345
  /**
332
346
  * Get list of all versions (sorted chronologically)
347
+ * Only works in versioned mode
333
348
  */
334
349
  async getVersions() {
350
+ if (!this.versioned) {
351
+ return [];
352
+ }
335
353
  const destPath = this.getDestinationPath();
336
354
  try {
337
355
  await ensurePath(destPath);
@@ -346,6 +364,86 @@ var FileDatabase = class {
346
364
  return [];
347
365
  }
348
366
  }
367
+ /**
368
+ * Get the latest version (most recent timestamp)
369
+ * Only works in versioned mode
370
+ * @returns Latest version string or null if no versions
371
+ */
372
+ async getLatestVersion() {
373
+ if (!this.versioned) {
374
+ throw new FileDatabaseError("getLatestVersion() only works in versioned mode");
375
+ }
376
+ const versions = await this.getVersions();
377
+ if (versions.length === 0) {
378
+ return null;
379
+ }
380
+ return versions[versions.length - 1];
381
+ }
382
+ /**
383
+ * Check if any data exists in this table
384
+ * Works for both versioned and non-versioned modes
385
+ * @returns true if data exists
386
+ */
387
+ async hasData() {
388
+ const tablePath = this.getDestinationPath();
389
+ if (!import_fs3.default.existsSync(tablePath)) {
390
+ return false;
391
+ }
392
+ if (this.versioned) {
393
+ const versions = await this.getVersions();
394
+ return versions.length > 0;
395
+ } else {
396
+ const items = await import_fs3.default.promises.readdir(tablePath);
397
+ return items.some(
398
+ (item) => item === "metadata.json" || item.match(/^\d{6}\.(json|txt|xml)$/) || item.endsWith(".json")
399
+ );
400
+ }
401
+ }
402
+ /**
403
+ * Auto-detect the data format in this table
404
+ * Used when reading existing data
405
+ * @returns Format detection result
406
+ */
407
+ async detectDataFormat() {
408
+ const tablePath = this.getDestinationPath();
409
+ if (!import_fs3.default.existsSync(tablePath)) {
410
+ return { versioned: false, hasMetadata: false, dataType: null };
411
+ }
412
+ const items = await import_fs3.default.promises.readdir(tablePath);
413
+ if (items.includes("metadata.json")) {
414
+ const metadata = JSON.parse(
415
+ await import_fs3.default.promises.readFile(import_path3.default.join(tablePath, "metadata.json"), "utf8")
416
+ );
417
+ return {
418
+ versioned: false,
419
+ hasMetadata: true,
420
+ dataType: metadata.dataType || null
421
+ };
422
+ }
423
+ const versionFolders = items.filter((item) => {
424
+ const itemPath = import_path3.default.join(tablePath, item);
425
+ const stat = import_fs3.default.statSync(itemPath);
426
+ return stat.isDirectory() && isTimestampFolder(item);
427
+ });
428
+ if (versionFolders.length > 0) {
429
+ const latestVersion = versionFolders.sort().pop();
430
+ const versionMetadataPath = import_path3.default.join(tablePath, latestVersion, "metadata.json");
431
+ return {
432
+ versioned: true,
433
+ hasMetadata: import_fs3.default.existsSync(versionMetadataPath),
434
+ dataType: null
435
+ };
436
+ }
437
+ const dataFiles = items.filter((f) => f.match(/^\d{6}\.(json|txt|xml)$/));
438
+ if (dataFiles.length > 0) {
439
+ return {
440
+ versioned: false,
441
+ hasMetadata: false,
442
+ dataType: null
443
+ };
444
+ }
445
+ return { versioned: false, hasMetadata: false, dataType: null };
446
+ }
349
447
  /**
350
448
  * Load metadata from JSON file
351
449
  */
@@ -489,11 +587,19 @@ var FileDatabase = class {
489
587
  * Save version metadata to file
490
588
  */
491
589
  async saveVersionMetadata(metadata) {
492
- if (!this.useMetadata || !this.currentVersion) {
590
+ if (!this.useMetadata) {
493
591
  return;
494
592
  }
495
593
  const metadataToSave = metadata || this.metadata;
496
- const metadataFile = import_path3.default.join(this.getDestinationPath(), this.currentVersion, "metadata.json");
594
+ let metadataFile;
595
+ if (this.versioned) {
596
+ if (!this.currentVersion) {
597
+ return;
598
+ }
599
+ metadataFile = import_path3.default.join(this.getDestinationPath(), this.currentVersion, "metadata.json");
600
+ } else {
601
+ metadataFile = import_path3.default.join(this.getDestinationPath(), "metadata.json");
602
+ }
497
603
  await import_fs3.default.promises.writeFile(metadataFile, JSON.stringify(metadataToSave, null, 4), "utf8");
498
604
  }
499
605
  /**
@@ -621,31 +727,75 @@ var FileDatabase = class {
621
727
  */
622
728
  async prepare({ write, read, version }) {
623
729
  if (write) {
624
- if (this.currentVersion === null) {
625
- await this.makeNewVersion();
626
- this.metadata = this.getDefaultMetadata();
627
- this.metadata.version = this.currentVersion;
628
- this.makeNewFile();
730
+ if (this.versioned) {
731
+ if (this.currentVersion === null) {
732
+ await this.makeNewVersion();
733
+ this.metadata = this.getDefaultMetadata();
734
+ this.metadata.version = this.currentVersion;
735
+ this.makeNewFile();
736
+ } else {
737
+ if (!this.metadata.files.length) {
738
+ this.metadata = await this.figureMetadata(this.currentVersion);
739
+ }
740
+ }
629
741
  } else {
630
- if (!this.metadata.files.length) {
631
- this.metadata = await this.figureMetadata(this.currentVersion);
742
+ await ensurePath(this.getDestinationPath());
743
+ if (this.useMetadata === true) {
744
+ const metadataPath = import_path3.default.join(this.getDestinationPath(), "metadata.json");
745
+ if (import_fs3.default.existsSync(metadataPath)) {
746
+ try {
747
+ const rawData = await import_fs3.default.promises.readFile(metadataPath, "utf8");
748
+ this.metadata = JSON.parse(rawData);
749
+ } catch (e) {
750
+ this.metadata = this.getDefaultMetadata();
751
+ }
752
+ } else {
753
+ this.metadata = this.getDefaultMetadata();
754
+ this.makeNewFile();
755
+ }
756
+ } else {
757
+ this.metadata = this.getDefaultMetadata();
758
+ this.makeNewFile();
632
759
  }
633
760
  }
634
761
  } else if (read) {
635
- const versions = await this.getVersions();
636
- if (versions.length === 0) {
637
- throw new FileDatabaseError("[FileDatabase] No versions found, cannot read");
638
- }
639
- if (version) {
640
- if (!versions.includes(version)) {
641
- throw new FileDatabaseError(`[FileDatabase] Version "${version}" not found`);
762
+ if (this.versioned) {
763
+ const versions = await this.getVersions();
764
+ if (versions.length === 0) {
765
+ throw new FileDatabaseError("[FileDatabase] No versions found, cannot read");
766
+ }
767
+ if (version) {
768
+ if (!versions.includes(version)) {
769
+ throw new FileDatabaseError(`[FileDatabase] Version "${version}" not found`);
770
+ }
771
+ await this.setCurrentVersion(version);
772
+ } else {
773
+ await this.setCurrentVersion(versions[versions.length - 1]);
774
+ }
775
+ if (!this.metadata.files.length) {
776
+ this.metadata = await this.figureMetadata(this.currentVersion);
642
777
  }
643
- await this.setCurrentVersion(version);
644
778
  } else {
645
- await this.setCurrentVersion(versions[versions.length - 1]);
646
- }
647
- if (!this.metadata.files.length) {
648
- this.metadata = await this.figureMetadata(this.currentVersion);
779
+ this.currentVersion = null;
780
+ if (this.useMetadata === void 0) {
781
+ const format = await this.detectDataFormat();
782
+ this.useMetadata = format.hasMetadata;
783
+ }
784
+ if (this.useMetadata) {
785
+ const metadataPath = import_path3.default.join(this.getDestinationPath(), "metadata.json");
786
+ if (import_fs3.default.existsSync(metadataPath)) {
787
+ try {
788
+ const rawData = await import_fs3.default.promises.readFile(metadataPath, "utf8");
789
+ this.metadata = JSON.parse(rawData);
790
+ } catch (e) {
791
+ throw new FileDatabaseError(`Failed to read metadata: ${e.message}`);
792
+ }
793
+ } else {
794
+ throw new FileDatabaseError("[FileDatabase] No metadata found in non-versioned mode");
795
+ }
796
+ } else {
797
+ this.metadata = await this.figureMetadataFromVersionFiles("");
798
+ }
649
799
  }
650
800
  }
651
801
  }
@@ -653,6 +803,9 @@ var FileDatabase = class {
653
803
  * Write data to the file database
654
804
  */
655
805
  async write(data, options = {}) {
806
+ if (options.forceNewVersion && !this.versioned) {
807
+ throw new FileDatabaseError("Cannot use forceNewVersion in non-versioned mode");
808
+ }
656
809
  await this.prepare({ write: true });
657
810
  if (options.forceNewVersion) {
658
811
  await this.makeNewVersion();
@@ -661,11 +814,12 @@ var FileDatabase = class {
661
814
  this.makeNewFile();
662
815
  }
663
816
  let { dataToWrite, dataLeftOver, fileName } = this.figureOutDataAndFileToWrite(data);
664
- await this.safeWrite(import_path3.default.join(this.currentVersionFolder, fileName), dataToWrite);
817
+ const destPath = this.getDestinationPath(this.currentVersion || void 0);
818
+ await this.safeWrite(import_path3.default.join(destPath, fileName), dataToWrite);
665
819
  this.updateMetadata(dataToWrite, fileName);
666
820
  while (dataLeftOver && dataLeftOver.length > 0) {
667
821
  const writeContext = this.figureOutDataAndFileToWrite(dataLeftOver);
668
- await this.safeWrite(import_path3.default.join(this.currentVersionFolder, writeContext.fileName), writeContext.dataToWrite);
822
+ await this.safeWrite(import_path3.default.join(destPath, writeContext.fileName), writeContext.dataToWrite);
669
823
  this.updateMetadata(writeContext.dataToWrite, writeContext.fileName);
670
824
  dataLeftOver = writeContext.dataLeftOver;
671
825
  }
@@ -683,7 +837,7 @@ var FileDatabase = class {
683
837
  const isNonPaginatedData = this.metadata.dataType === "text" || this.metadata.dataType === "xml" || this.metadata.dataType === "json-object";
684
838
  if (isNonPaginatedData) {
685
839
  const file = this.metadata.files[0];
686
- const filePath = import_path3.default.join(this.getDestinationPath(), this.currentVersion, file.fileName);
840
+ const filePath = import_path3.default.join(this.getDestinationPath(this.currentVersion || void 0), file.fileName);
687
841
  try {
688
842
  const rawData = await import_fs3.default.promises.readFile(filePath, "utf8");
689
843
  return deserializeData(rawData, this.metadata.dataType);
@@ -721,7 +875,7 @@ var FileDatabase = class {
721
875
  let cumulativeRecords = currentFileOffset;
722
876
  for (let i = currentFileIndex; i < this.metadata.files.length && recordsRead < effectivePageSize; i++) {
723
877
  const file = this.metadata.files[i];
724
- const filePath = import_path3.default.join(this.getDestinationPath(), this.currentVersion, file.fileName);
878
+ const filePath = import_path3.default.join(this.getDestinationPath(this.currentVersion || void 0), file.fileName);
725
879
  try {
726
880
  const rawData = await import_fs3.default.promises.readFile(filePath, "utf8");
727
881
  const fileData = deserializeData(rawData, this.metadata.dataType);
@@ -791,4 +945,4 @@ var FileDatabase = class {
791
945
  defaultFileSynopsisFunction,
792
946
  defaultVersionSynopsisFunction
793
947
  });
794
- //# sourceMappingURL=filestore.cjs.map
948
+ //# sourceMappingURL=filedatabase.cjs.map