feat: build CLI core framework with Commander.js

Add CLI entry point with Commander.js, config management (~/.mcpctl/config.json
with Zod validation), output formatters (table/json/yaml), config and status
commands with dependency injection for testing. Fix sanitizeString regex ordering.
67 tests passing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michal
2026-02-21 04:17:31 +00:00
parent 981585a943
commit 1b66e235fc
21 changed files with 922 additions and 72 deletions

View File

@@ -475,7 +475,7 @@
"dependencies": [ "dependencies": [
"1" "1"
], ],
"status": "pending", "status": "done",
"subtasks": [ "subtasks": [
{ {
"id": 1, "id": 1,
@@ -499,7 +499,8 @@
"testStrategy": "TDD tests for config loading, saving, validation, and credential encryption.", "testStrategy": "TDD tests for config loading, saving, validation, and credential encryption.",
"parentId": "undefined" "parentId": "undefined"
} }
] ],
"updatedAt": "2026-02-21T04:17:17.744Z"
}, },
{ {
"id": "8", "id": "8",
@@ -729,9 +730,9 @@
], ],
"metadata": { "metadata": {
"version": "1.0.0", "version": "1.0.0",
"lastModified": "2026-02-21T04:10:25.433Z", "lastModified": "2026-02-21T04:17:17.744Z",
"taskCount": 24, "taskCount": 24,
"completedCount": 2, "completedCount": 3,
"tags": [ "tags": [
"master" "master"
] ]

182
pnpm-lock.yaml generated
View File

@@ -16,7 +16,7 @@ importers:
version: 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) version: 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)
'@vitest/coverage-v8': '@vitest/coverage-v8':
specifier: ^4.0.18 specifier: ^4.0.18
version: 4.0.18(vitest@4.0.18(jiti@2.6.1)(tsx@4.21.0)) version: 4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0))
eslint: eslint:
specifier: ^10.0.1 specifier: ^10.0.1
version: 10.0.1(jiti@2.6.1) version: 10.0.1(jiti@2.6.1)
@@ -34,7 +34,7 @@ importers:
version: 5.9.3 version: 5.9.3
vitest: vitest:
specifier: ^4.0.18 specifier: ^4.0.18
version: 4.0.18(jiti@2.6.1)(tsx@4.21.0) version: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)
src/cli: src/cli:
dependencies: dependencies:
@@ -52,10 +52,20 @@ importers:
version: 13.1.0 version: 13.1.0
inquirer: inquirer:
specifier: ^12.0.0 specifier: ^12.0.0
version: 12.11.1 version: 12.11.1(@types/node@25.3.0)
js-yaml: js-yaml:
specifier: ^4.1.0 specifier: ^4.1.0
version: 4.1.1 version: 4.1.1
zod:
specifier: ^3.24.0
version: 3.25.76
devDependencies:
'@types/js-yaml':
specifier: ^4.0.9
version: 4.0.9
'@types/node':
specifier: ^25.3.0
version: 25.3.0
src/db: src/db:
dependencies: dependencies:
@@ -698,9 +708,15 @@ packages:
'@types/estree@1.0.8': '@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/js-yaml@4.0.9':
resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==}
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
'@types/node@25.3.0':
resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==}
'@typescript-eslint/eslint-plugin@8.56.0': '@typescript-eslint/eslint-plugin@8.56.0':
resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==} resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -1795,6 +1811,9 @@ packages:
engines: {node: '>=14.17'} engines: {node: '>=14.17'}
hasBin: true hasBin: true
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
unpipe@1.0.0: unpipe@1.0.0:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -2098,100 +2117,128 @@ snapshots:
'@inquirer/ansi@1.0.2': {} '@inquirer/ansi@1.0.2': {}
'@inquirer/checkbox@4.3.2': '@inquirer/checkbox@4.3.2(@types/node@25.3.0)':
dependencies: dependencies:
'@inquirer/ansi': 1.0.2 '@inquirer/ansi': 1.0.2
'@inquirer/core': 10.3.2 '@inquirer/core': 10.3.2(@types/node@25.3.0)
'@inquirer/figures': 1.0.15 '@inquirer/figures': 1.0.15
'@inquirer/type': 3.0.10 '@inquirer/type': 3.0.10(@types/node@25.3.0)
yoctocolors-cjs: 2.1.3 yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 25.3.0
'@inquirer/confirm@5.1.21': '@inquirer/confirm@5.1.21(@types/node@25.3.0)':
dependencies: dependencies:
'@inquirer/core': 10.3.2 '@inquirer/core': 10.3.2(@types/node@25.3.0)
'@inquirer/type': 3.0.10 '@inquirer/type': 3.0.10(@types/node@25.3.0)
optionalDependencies:
'@types/node': 25.3.0
'@inquirer/core@10.3.2': '@inquirer/core@10.3.2(@types/node@25.3.0)':
dependencies: dependencies:
'@inquirer/ansi': 1.0.2 '@inquirer/ansi': 1.0.2
'@inquirer/figures': 1.0.15 '@inquirer/figures': 1.0.15
'@inquirer/type': 3.0.10 '@inquirer/type': 3.0.10(@types/node@25.3.0)
cli-width: 4.1.0 cli-width: 4.1.0
mute-stream: 2.0.0 mute-stream: 2.0.0
signal-exit: 4.1.0 signal-exit: 4.1.0
wrap-ansi: 6.2.0 wrap-ansi: 6.2.0
yoctocolors-cjs: 2.1.3 yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 25.3.0
'@inquirer/editor@4.2.23': '@inquirer/editor@4.2.23(@types/node@25.3.0)':
dependencies: dependencies:
'@inquirer/core': 10.3.2 '@inquirer/core': 10.3.2(@types/node@25.3.0)
'@inquirer/external-editor': 1.0.3 '@inquirer/external-editor': 1.0.3(@types/node@25.3.0)
'@inquirer/type': 3.0.10 '@inquirer/type': 3.0.10(@types/node@25.3.0)
optionalDependencies:
'@types/node': 25.3.0
'@inquirer/expand@4.0.23': '@inquirer/expand@4.0.23(@types/node@25.3.0)':
dependencies: dependencies:
'@inquirer/core': 10.3.2 '@inquirer/core': 10.3.2(@types/node@25.3.0)
'@inquirer/type': 3.0.10 '@inquirer/type': 3.0.10(@types/node@25.3.0)
yoctocolors-cjs: 2.1.3 yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 25.3.0
'@inquirer/external-editor@1.0.3': '@inquirer/external-editor@1.0.3(@types/node@25.3.0)':
dependencies: dependencies:
chardet: 2.1.1 chardet: 2.1.1
iconv-lite: 0.7.2 iconv-lite: 0.7.2
optionalDependencies:
'@types/node': 25.3.0
'@inquirer/figures@1.0.15': {} '@inquirer/figures@1.0.15': {}
'@inquirer/input@4.3.1': '@inquirer/input@4.3.1(@types/node@25.3.0)':
dependencies: dependencies:
'@inquirer/core': 10.3.2 '@inquirer/core': 10.3.2(@types/node@25.3.0)
'@inquirer/type': 3.0.10 '@inquirer/type': 3.0.10(@types/node@25.3.0)
optionalDependencies:
'@types/node': 25.3.0
'@inquirer/number@3.0.23': '@inquirer/number@3.0.23(@types/node@25.3.0)':
dependencies: dependencies:
'@inquirer/core': 10.3.2 '@inquirer/core': 10.3.2(@types/node@25.3.0)
'@inquirer/type': 3.0.10 '@inquirer/type': 3.0.10(@types/node@25.3.0)
optionalDependencies:
'@types/node': 25.3.0
'@inquirer/password@4.0.23': '@inquirer/password@4.0.23(@types/node@25.3.0)':
dependencies: dependencies:
'@inquirer/ansi': 1.0.2 '@inquirer/ansi': 1.0.2
'@inquirer/core': 10.3.2 '@inquirer/core': 10.3.2(@types/node@25.3.0)
'@inquirer/type': 3.0.10 '@inquirer/type': 3.0.10(@types/node@25.3.0)
optionalDependencies:
'@types/node': 25.3.0
'@inquirer/prompts@7.10.1': '@inquirer/prompts@7.10.1(@types/node@25.3.0)':
dependencies: dependencies:
'@inquirer/checkbox': 4.3.2 '@inquirer/checkbox': 4.3.2(@types/node@25.3.0)
'@inquirer/confirm': 5.1.21 '@inquirer/confirm': 5.1.21(@types/node@25.3.0)
'@inquirer/editor': 4.2.23 '@inquirer/editor': 4.2.23(@types/node@25.3.0)
'@inquirer/expand': 4.0.23 '@inquirer/expand': 4.0.23(@types/node@25.3.0)
'@inquirer/input': 4.3.1 '@inquirer/input': 4.3.1(@types/node@25.3.0)
'@inquirer/number': 3.0.23 '@inquirer/number': 3.0.23(@types/node@25.3.0)
'@inquirer/password': 4.0.23 '@inquirer/password': 4.0.23(@types/node@25.3.0)
'@inquirer/rawlist': 4.1.11 '@inquirer/rawlist': 4.1.11(@types/node@25.3.0)
'@inquirer/search': 3.2.2 '@inquirer/search': 3.2.2(@types/node@25.3.0)
'@inquirer/select': 4.4.2 '@inquirer/select': 4.4.2(@types/node@25.3.0)
optionalDependencies:
'@types/node': 25.3.0
'@inquirer/rawlist@4.1.11': '@inquirer/rawlist@4.1.11(@types/node@25.3.0)':
dependencies: dependencies:
'@inquirer/core': 10.3.2 '@inquirer/core': 10.3.2(@types/node@25.3.0)
'@inquirer/type': 3.0.10 '@inquirer/type': 3.0.10(@types/node@25.3.0)
yoctocolors-cjs: 2.1.3 yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 25.3.0
'@inquirer/search@3.2.2': '@inquirer/search@3.2.2(@types/node@25.3.0)':
dependencies: dependencies:
'@inquirer/core': 10.3.2 '@inquirer/core': 10.3.2(@types/node@25.3.0)
'@inquirer/figures': 1.0.15 '@inquirer/figures': 1.0.15
'@inquirer/type': 3.0.10 '@inquirer/type': 3.0.10(@types/node@25.3.0)
yoctocolors-cjs: 2.1.3 yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 25.3.0
'@inquirer/select@4.4.2': '@inquirer/select@4.4.2(@types/node@25.3.0)':
dependencies: dependencies:
'@inquirer/ansi': 1.0.2 '@inquirer/ansi': 1.0.2
'@inquirer/core': 10.3.2 '@inquirer/core': 10.3.2(@types/node@25.3.0)
'@inquirer/figures': 1.0.15 '@inquirer/figures': 1.0.15
'@inquirer/type': 3.0.10 '@inquirer/type': 3.0.10(@types/node@25.3.0)
yoctocolors-cjs: 2.1.3 yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 25.3.0
'@inquirer/type@3.0.10': {} '@inquirer/type@3.0.10(@types/node@25.3.0)':
optionalDependencies:
'@types/node': 25.3.0
'@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/resolve-uri@3.1.2': {}
@@ -2351,8 +2398,14 @@ snapshots:
'@types/estree@1.0.8': {} '@types/estree@1.0.8': {}
'@types/js-yaml@4.0.9': {}
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/node@25.3.0':
dependencies:
undici-types: 7.18.2
'@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)':
dependencies: dependencies:
'@eslint-community/regexpp': 4.12.2 '@eslint-community/regexpp': 4.12.2
@@ -2444,7 +2497,7 @@ snapshots:
'@typescript-eslint/types': 8.56.0 '@typescript-eslint/types': 8.56.0
eslint-visitor-keys: 5.0.1 eslint-visitor-keys: 5.0.1
'@vitest/coverage-v8@4.0.18(vitest@4.0.18(jiti@2.6.1)(tsx@4.21.0))': '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0))':
dependencies: dependencies:
'@bcoe/v8-coverage': 1.0.2 '@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.0.18 '@vitest/utils': 4.0.18
@@ -2456,7 +2509,7 @@ snapshots:
obug: 2.1.1 obug: 2.1.1
std-env: 3.10.0 std-env: 3.10.0
tinyrainbow: 3.0.3 tinyrainbow: 3.0.3
vitest: 4.0.18(jiti@2.6.1)(tsx@4.21.0) vitest: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)
'@vitest/expect@4.0.18': '@vitest/expect@4.0.18':
dependencies: dependencies:
@@ -2467,13 +2520,13 @@ snapshots:
chai: 6.2.2 chai: 6.2.2
tinyrainbow: 3.0.3 tinyrainbow: 3.0.3
'@vitest/mocker@4.0.18(vite@7.3.1(jiti@2.6.1)(tsx@4.21.0))': '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0))':
dependencies: dependencies:
'@vitest/spy': 4.0.18 '@vitest/spy': 4.0.18
estree-walker: 3.0.3 estree-walker: 3.0.3
magic-string: 0.30.21 magic-string: 0.30.21
optionalDependencies: optionalDependencies:
vite: 7.3.1(jiti@2.6.1)(tsx@4.21.0) vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)
'@vitest/pretty-format@4.0.18': '@vitest/pretty-format@4.0.18':
dependencies: dependencies:
@@ -3033,15 +3086,17 @@ snapshots:
inherits@2.0.4: {} inherits@2.0.4: {}
inquirer@12.11.1: inquirer@12.11.1(@types/node@25.3.0):
dependencies: dependencies:
'@inquirer/ansi': 1.0.2 '@inquirer/ansi': 1.0.2
'@inquirer/core': 10.3.2 '@inquirer/core': 10.3.2(@types/node@25.3.0)
'@inquirer/prompts': 7.10.1 '@inquirer/prompts': 7.10.1(@types/node@25.3.0)
'@inquirer/type': 3.0.10 '@inquirer/type': 3.0.10(@types/node@25.3.0)
mute-stream: 2.0.0 mute-stream: 2.0.0
run-async: 4.0.6 run-async: 4.0.6
rxjs: 7.8.2 rxjs: 7.8.2
optionalDependencies:
'@types/node': 25.3.0
ip-address@10.0.1: {} ip-address@10.0.1: {}
@@ -3532,6 +3587,8 @@ snapshots:
typescript@5.9.3: {} typescript@5.9.3: {}
undici-types@7.18.2: {}
unpipe@1.0.0: {} unpipe@1.0.0: {}
uri-js@4.4.1: uri-js@4.4.1:
@@ -3540,7 +3597,7 @@ snapshots:
vary@1.1.2: {} vary@1.1.2: {}
vite@7.3.1(jiti@2.6.1)(tsx@4.21.0): vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0):
dependencies: dependencies:
esbuild: 0.27.3 esbuild: 0.27.3
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
@@ -3549,14 +3606,15 @@ snapshots:
rollup: 4.58.0 rollup: 4.58.0
tinyglobby: 0.2.15 tinyglobby: 0.2.15
optionalDependencies: optionalDependencies:
'@types/node': 25.3.0
fsevents: 2.3.3 fsevents: 2.3.3
jiti: 2.6.1 jiti: 2.6.1
tsx: 4.21.0 tsx: 4.21.0
vitest@4.0.18(jiti@2.6.1)(tsx@4.21.0): vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0):
dependencies: dependencies:
'@vitest/expect': 4.0.18 '@vitest/expect': 4.0.18
'@vitest/mocker': 4.0.18(vite@7.3.1(jiti@2.6.1)(tsx@4.21.0)) '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0))
'@vitest/pretty-format': 4.0.18 '@vitest/pretty-format': 4.0.18
'@vitest/runner': 4.0.18 '@vitest/runner': 4.0.18
'@vitest/snapshot': 4.0.18 '@vitest/snapshot': 4.0.18
@@ -3573,8 +3631,10 @@ snapshots:
tinyexec: 1.0.2 tinyexec: 1.0.2
tinyglobby: 0.2.15 tinyglobby: 0.2.15
tinyrainbow: 3.0.3 tinyrainbow: 3.0.3
vite: 7.3.1(jiti@2.6.1)(tsx@4.21.0) vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)
why-is-node-running: 2.3.0 why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.3.0
transitivePeerDependencies: transitivePeerDependencies:
- jiti - jiti
- less - less

View File

@@ -16,11 +16,16 @@
"test:run": "vitest run" "test:run": "vitest run"
}, },
"dependencies": { "dependencies": {
"commander": "^13.0.0", "@mcpctl/db": "workspace:*",
"@mcpctl/shared": "workspace:*",
"chalk": "^5.4.0", "chalk": "^5.4.0",
"commander": "^13.0.0",
"inquirer": "^12.0.0", "inquirer": "^12.0.0",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"@mcpctl/shared": "workspace:*", "zod": "^3.24.0"
"@mcpctl/db": "workspace:*" },
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.3.0"
} }
} }

View File

@@ -0,0 +1,69 @@
import { Command } from 'commander';
import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../config/index.js';
import type { McpctlConfig, ConfigLoaderDeps } from '../config/index.js';
import { formatJson, formatYaml } from '../formatters/index.js';
export interface ConfigCommandDeps {
configDeps: Partial<ConfigLoaderDeps>;
log: (...args: string[]) => void;
}
const defaultDeps: ConfigCommandDeps = {
configDeps: {},
log: (...args) => console.log(...args),
};
export function createConfigCommand(deps?: Partial<ConfigCommandDeps>): Command {
const { configDeps, log } = { ...defaultDeps, ...deps };
const config = new Command('config').description('Manage mcpctl configuration');
config
.command('view')
.description('Show current configuration')
.option('-o, --output <format>', 'output format (json, yaml)', 'json')
.action((opts: { output: string }) => {
const cfg = loadConfig(configDeps);
const out = opts.output === 'yaml' ? formatYaml(cfg) : formatJson(cfg);
log(out);
});
config
.command('set')
.description('Set a configuration value')
.argument('<key>', 'configuration key (e.g., daemonUrl, outputFormat)')
.argument('<value>', 'value to set')
.action((key: string, value: string) => {
const updates: Record<string, unknown> = {};
// Handle typed conversions
if (key === 'cacheTTLMs') {
updates[key] = parseInt(value, 10);
} else if (key === 'registries') {
updates[key] = value.split(',').map((s) => s.trim());
} else {
updates[key] = value;
}
const updated = mergeConfig(updates as Partial<McpctlConfig>, configDeps);
saveConfig(updated, configDeps);
log(`Set ${key} = ${value}`);
});
config
.command('path')
.description('Show configuration file path')
.action(() => {
log(getConfigPath(configDeps?.configDir));
});
config
.command('reset')
.description('Reset configuration to defaults')
.action(() => {
saveConfig(DEFAULT_CONFIG, configDeps);
log('Configuration reset to defaults');
});
return config;
}

View File

@@ -0,0 +1,63 @@
import { Command } from 'commander';
import http from 'node:http';
import { loadConfig } from '../config/index.js';
import type { ConfigLoaderDeps } from '../config/index.js';
import { formatJson, formatYaml } from '../formatters/index.js';
import { APP_VERSION } from '@mcpctl/shared';
export interface StatusCommandDeps {
configDeps: Partial<ConfigLoaderDeps>;
log: (...args: string[]) => void;
checkDaemon: (url: string) => Promise<boolean>;
}
function defaultCheckDaemon(url: string): Promise<boolean> {
return new Promise((resolve) => {
const req = http.get(`${url}/health`, { timeout: 3000 }, (res) => {
resolve(res.statusCode !== undefined && res.statusCode >= 200 && res.statusCode < 400);
res.resume();
});
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
});
}
const defaultDeps: StatusCommandDeps = {
configDeps: {},
log: (...args) => console.log(...args),
checkDaemon: defaultCheckDaemon,
};
export function createStatusCommand(deps?: Partial<StatusCommandDeps>): Command {
const { configDeps, log, checkDaemon } = { ...defaultDeps, ...deps };
return new Command('status')
.description('Show mcpctl status and connectivity')
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
.action(async (opts: { output: string }) => {
const config = loadConfig(configDeps);
const daemonReachable = await checkDaemon(config.daemonUrl);
const status = {
version: APP_VERSION,
daemonUrl: config.daemonUrl,
daemonReachable,
registries: config.registries,
outputFormat: config.outputFormat,
};
if (opts.output === 'json') {
log(formatJson(status));
} else if (opts.output === 'yaml') {
log(formatYaml(status));
} else {
log(`mcpctl v${status.version}`);
log(`Daemon: ${status.daemonUrl} (${daemonReachable ? 'connected' : 'unreachable'})`);
log(`Registries: ${status.registries.join(', ')}`);
log(`Output: ${status.outputFormat}`);
}
});
}

View File

@@ -0,0 +1,4 @@
export { McpctlConfigSchema, DEFAULT_CONFIG } from './schema.js';
export type { McpctlConfig } from './schema.js';
export { loadConfig, saveConfig, mergeConfig, getConfigPath } from './loader.js';
export type { ConfigLoaderDeps } from './loader.js';

View File

@@ -0,0 +1,45 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { McpctlConfigSchema, DEFAULT_CONFIG } from './schema.js';
import type { McpctlConfig } from './schema.js';
export interface ConfigLoaderDeps {
configDir: string;
}
function defaultConfigDir(): string {
return join(homedir(), '.mcpctl');
}
export function getConfigPath(configDir?: string): string {
return join(configDir ?? defaultConfigDir(), 'config.json');
}
export function loadConfig(deps?: Partial<ConfigLoaderDeps>): McpctlConfig {
const configPath = getConfigPath(deps?.configDir);
if (!existsSync(configPath)) {
return DEFAULT_CONFIG;
}
const raw = readFileSync(configPath, 'utf-8');
const parsed = JSON.parse(raw) as unknown;
return McpctlConfigSchema.parse(parsed);
}
export function saveConfig(config: McpctlConfig, deps?: Partial<ConfigLoaderDeps>): void {
const dir = deps?.configDir ?? defaultConfigDir();
const configPath = getConfigPath(dir);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
writeFileSync(configPath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
}
export function mergeConfig(overrides: Partial<McpctlConfig>, deps?: Partial<ConfigLoaderDeps>): McpctlConfig {
const current = loadConfig(deps);
return McpctlConfigSchema.parse({ ...current, ...overrides });
}

View File

@@ -0,0 +1,22 @@
import { z } from 'zod';
export const McpctlConfigSchema = z.object({
/** mcpd daemon endpoint */
daemonUrl: z.string().default('http://localhost:3000'),
/** Active registries for search */
registries: z.array(z.enum(['official', 'glama', 'smithery'])).default(['official', 'glama', 'smithery']),
/** Cache TTL in milliseconds */
cacheTTLMs: z.number().int().positive().default(3_600_000),
/** HTTP proxy URL */
httpProxy: z.string().optional(),
/** HTTPS proxy URL */
httpsProxy: z.string().optional(),
/** Default output format */
outputFormat: z.enum(['table', 'json', 'yaml']).default('table'),
/** Smithery API key */
smitheryApiKey: z.string().optional(),
});
export type McpctlConfig = z.infer<typeof McpctlConfigSchema>;
export const DEFAULT_CONFIG: McpctlConfig = McpctlConfigSchema.parse({});

View File

@@ -0,0 +1,4 @@
export { formatTable } from './table.js';
export type { Column } from './table.js';
export { formatJson, formatYaml } from './output.js';
export type { OutputFormat } from './output.js';

View File

@@ -0,0 +1,11 @@
import yaml from 'js-yaml';
export type OutputFormat = 'table' | 'json' | 'yaml';
export function formatJson(data: unknown): string {
return JSON.stringify(data, null, 2);
}
export function formatYaml(data: unknown): string {
return yaml.dump(data, { lineWidth: 120, noRefs: true }).trimEnd();
}

View File

@@ -0,0 +1,44 @@
export interface Column<T> {
header: string;
key: keyof T | ((row: T) => string);
width?: number;
align?: 'left' | 'right';
}
export function formatTable<T>(rows: T[], columns: Column<T>[]): string {
if (rows.length === 0) {
return 'No results found.';
}
const getValue = (row: T, col: Column<T>): string => {
if (typeof col.key === 'function') {
return col.key(row);
}
const val = row[col.key];
return val == null ? '' : String(val);
};
// Calculate column widths
const widths = columns.map((col) => {
if (col.width !== undefined) return col.width;
const headerLen = col.header.length;
const maxDataLen = rows.reduce((max, row) => {
const val = getValue(row, col);
return Math.max(max, val.length);
}, 0);
return Math.max(headerLen, maxDataLen);
});
const pad = (text: string, width: number, align: 'left' | 'right' = 'left'): string => {
const truncated = text.length > width ? text.slice(0, width - 1) + '\u2026' : text;
return align === 'right' ? truncated.padStart(width) : truncated.padEnd(width);
};
const headerLine = columns.map((col, i) => pad(col.header, widths[i] ?? 0, col.align ?? 'left')).join(' ');
const separator = widths.map((w) => '-'.repeat(w)).join(' ');
const dataLines = rows.map((row) =>
columns.map((col, i) => pad(getValue(row, col), widths[i] ?? 0, col.align ?? 'left')).join(' '),
);
return [headerLine, separator, ...dataLines].join('\n');
}

View File

@@ -1,2 +1,29 @@
// mcpctl CLI entry point #!/usr/bin/env node
// Will be implemented in Task 7 import { Command } from 'commander';
import { APP_NAME, APP_VERSION } from '@mcpctl/shared';
import { createConfigCommand } from './commands/config.js';
import { createStatusCommand } from './commands/status.js';
export function createProgram(): Command {
const program = new Command()
.name(APP_NAME)
.description('Manage MCP servers like kubectl manages containers')
.version(APP_VERSION, '-v, --version')
.option('-o, --output <format>', 'output format (table, json, yaml)', 'table')
.option('--daemon-url <url>', 'mcpd daemon URL');
program.addCommand(createConfigCommand());
program.addCommand(createStatusCommand());
return program;
}
// Run when invoked directly
const isDirectRun =
typeof process !== 'undefined' &&
process.argv[1] !== undefined &&
import.meta.url === `file://${process.argv[1]}`;
if (isDirectRun) {
createProgram().parseAsync(process.argv);
}

View File

@@ -173,7 +173,7 @@ export type SmitheryServerEntry = z.infer<typeof SmitheryServerSchema>;
// ── Security utilities ── // ── Security utilities ──
const ANSI_ESCAPE_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F]|\x1b\[[0-9;]*[a-zA-Z]|\033\[[0-9;]*[a-zA-Z]/g; const ANSI_ESCAPE_RE = /\x1b\[[0-9;]*[a-zA-Z]|[\x00-\x08\x0B\x0C\x0E-\x1F]/g;
export function sanitizeString(text: string): string { export function sanitizeString(text: string): string {
return text.replace(ANSI_ESCAPE_RE, ''); return text.replace(ANSI_ESCAPE_RE, '');

38
src/cli/tests/cli.test.ts Normal file
View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { createProgram } from '../src/index.js';
describe('createProgram', () => {
it('creates a Commander program', () => {
const program = createProgram();
expect(program.name()).toBe('mcpctl');
});
it('has version flag', () => {
const program = createProgram();
expect(program.version()).toBe('0.1.0');
});
it('has config subcommand', () => {
const program = createProgram();
const config = program.commands.find((c) => c.name() === 'config');
expect(config).toBeDefined();
});
it('has status subcommand', () => {
const program = createProgram();
const status = program.commands.find((c) => c.name() === 'status');
expect(status).toBeDefined();
});
it('has output option', () => {
const program = createProgram();
const opt = program.options.find((o) => o.long === '--output');
expect(opt).toBeDefined();
});
it('has daemon-url option', () => {
const program = createProgram();
const opt = program.options.find((o) => o.long === '--daemon-url');
expect(opt).toBeDefined();
});
});

View File

@@ -0,0 +1,99 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createConfigCommand } from '../../src/commands/config.js';
import { loadConfig, saveConfig, DEFAULT_CONFIG } from '../../src/config/index.js';
let tempDir: string;
let output: string[];
function log(...args: string[]) {
output.push(args.join(' '));
}
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-config-test-'));
output = [];
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
function makeCommand() {
return createConfigCommand({
configDeps: { configDir: tempDir },
log,
});
}
describe('config view', () => {
it('outputs default config as JSON', async () => {
const cmd = makeCommand();
await cmd.parseAsync(['view'], { from: 'user' });
expect(output).toHaveLength(1);
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
expect(parsed['daemonUrl']).toBe('http://localhost:3000');
});
it('outputs config as YAML with --output yaml', async () => {
const cmd = makeCommand();
await cmd.parseAsync(['view', '-o', 'yaml'], { from: 'user' });
expect(output[0]).toContain('daemonUrl:');
});
});
describe('config set', () => {
it('sets a string value', async () => {
const cmd = makeCommand();
await cmd.parseAsync(['set', 'daemonUrl', 'http://new:9000'], { from: 'user' });
expect(output[0]).toContain('daemonUrl');
const config = loadConfig({ configDir: tempDir });
expect(config.daemonUrl).toBe('http://new:9000');
});
it('sets cacheTTLMs as integer', async () => {
const cmd = makeCommand();
await cmd.parseAsync(['set', 'cacheTTLMs', '60000'], { from: 'user' });
const config = loadConfig({ configDir: tempDir });
expect(config.cacheTTLMs).toBe(60000);
});
it('sets registries as comma-separated list', async () => {
const cmd = makeCommand();
await cmd.parseAsync(['set', 'registries', 'official,glama'], { from: 'user' });
const config = loadConfig({ configDir: tempDir });
expect(config.registries).toEqual(['official', 'glama']);
});
it('sets outputFormat', async () => {
const cmd = makeCommand();
await cmd.parseAsync(['set', 'outputFormat', 'json'], { from: 'user' });
const config = loadConfig({ configDir: tempDir });
expect(config.outputFormat).toBe('json');
});
});
describe('config path', () => {
it('shows config file path', async () => {
const cmd = makeCommand();
await cmd.parseAsync(['path'], { from: 'user' });
expect(output[0]).toContain(tempDir);
expect(output[0]).toContain('config.json');
});
});
describe('config reset', () => {
it('resets to defaults', async () => {
// First set a custom value
saveConfig({ ...DEFAULT_CONFIG, daemonUrl: 'http://custom' }, { configDir: tempDir });
const cmd = makeCommand();
await cmd.parseAsync(['reset'], { from: 'user' });
expect(output[0]).toContain('reset');
const config = loadConfig({ configDir: tempDir });
expect(config.daemonUrl).toBe(DEFAULT_CONFIG.daemonUrl);
});
});

View File

@@ -0,0 +1,94 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createStatusCommand } from '../../src/commands/status.js';
import { saveConfig, DEFAULT_CONFIG } from '../../src/config/index.js';
let tempDir: string;
let output: string[];
function log(...args: string[]) {
output.push(args.join(' '));
}
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-status-test-'));
output = [];
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
describe('status command', () => {
it('shows status in table format', async () => {
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async () => true,
});
await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('mcpctl v');
expect(output.join('\n')).toContain('connected');
});
it('shows unreachable when daemon is down', async () => {
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async () => false,
});
await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('unreachable');
});
it('shows status in JSON format', async () => {
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async () => true,
});
await cmd.parseAsync(['-o', 'json'], { from: 'user' });
const parsed = JSON.parse(output[0]) as Record<string, unknown>;
expect(parsed['version']).toBe('0.1.0');
expect(parsed['daemonReachable']).toBe(true);
});
it('shows status in YAML format', async () => {
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async () => false,
});
await cmd.parseAsync(['-o', 'yaml'], { from: 'user' });
expect(output[0]).toContain('daemonReachable: false');
});
it('uses custom daemon URL from config', async () => {
saveConfig({ ...DEFAULT_CONFIG, daemonUrl: 'http://custom:5555' }, { configDir: tempDir });
let checkedUrl = '';
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async (url) => {
checkedUrl = url;
return false;
},
});
await cmd.parseAsync([], { from: 'user' });
expect(checkedUrl).toBe('http://custom:5555');
});
it('shows registries from config', async () => {
saveConfig({ ...DEFAULT_CONFIG, registries: ['official'] }, { configDir: tempDir });
const cmd = createStatusCommand({
configDeps: { configDir: tempDir },
log,
checkDaemon: async () => true,
});
await cmd.parseAsync([], { from: 'user' });
expect(output.join('\n')).toContain('official');
expect(output.join('\n')).not.toContain('glama');
});
});

View File

@@ -0,0 +1,83 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../../src/config/index.js';
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-test-'));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
describe('getConfigPath', () => {
it('returns path within config dir', () => {
const path = getConfigPath('/tmp/mcpctl');
expect(path).toBe('/tmp/mcpctl/config.json');
});
});
describe('loadConfig', () => {
it('returns defaults when no config file exists', () => {
const config = loadConfig({ configDir: tempDir });
expect(config).toEqual(DEFAULT_CONFIG);
});
it('loads config from file', () => {
saveConfig({ ...DEFAULT_CONFIG, daemonUrl: 'http://custom:5000' }, { configDir: tempDir });
const config = loadConfig({ configDir: tempDir });
expect(config.daemonUrl).toBe('http://custom:5000');
});
it('applies defaults for missing fields', () => {
const { writeFileSync } = require('node:fs') as typeof import('node:fs');
writeFileSync(join(tempDir, 'config.json'), '{"daemonUrl":"http://x:1"}');
const config = loadConfig({ configDir: tempDir });
expect(config.daemonUrl).toBe('http://x:1');
expect(config.registries).toEqual(['official', 'glama', 'smithery']);
});
});
describe('saveConfig', () => {
it('creates config file', () => {
saveConfig(DEFAULT_CONFIG, { configDir: tempDir });
expect(existsSync(join(tempDir, 'config.json'))).toBe(true);
});
it('creates config directory if missing', () => {
const nested = join(tempDir, 'nested', 'dir');
saveConfig(DEFAULT_CONFIG, { configDir: nested });
expect(existsSync(join(nested, 'config.json'))).toBe(true);
});
it('round-trips configuration', () => {
const custom = {
...DEFAULT_CONFIG,
daemonUrl: 'http://custom:9000',
registries: ['official' as const],
outputFormat: 'json' as const,
};
saveConfig(custom, { configDir: tempDir });
const loaded = loadConfig({ configDir: tempDir });
expect(loaded).toEqual(custom);
});
});
describe('mergeConfig', () => {
it('merges overrides into existing config', () => {
saveConfig(DEFAULT_CONFIG, { configDir: tempDir });
const merged = mergeConfig({ daemonUrl: 'http://new:1234' }, { configDir: tempDir });
expect(merged.daemonUrl).toBe('http://new:1234');
expect(merged.registries).toEqual(DEFAULT_CONFIG.registries);
});
it('works when no config file exists', () => {
const merged = mergeConfig({ outputFormat: 'yaml' }, { configDir: tempDir });
expect(merged.outputFormat).toBe('yaml');
expect(merged.daemonUrl).toBe('http://localhost:3000');
});
});

View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { McpctlConfigSchema, DEFAULT_CONFIG } from '../../src/config/schema.js';
describe('McpctlConfigSchema', () => {
it('provides sensible defaults from empty object', () => {
const config = McpctlConfigSchema.parse({});
expect(config.daemonUrl).toBe('http://localhost:3000');
expect(config.registries).toEqual(['official', 'glama', 'smithery']);
expect(config.cacheTTLMs).toBe(3_600_000);
expect(config.outputFormat).toBe('table');
expect(config.httpProxy).toBeUndefined();
expect(config.httpsProxy).toBeUndefined();
expect(config.smitheryApiKey).toBeUndefined();
});
it('validates a full config', () => {
const config = McpctlConfigSchema.parse({
daemonUrl: 'http://custom:4000',
registries: ['official'],
cacheTTLMs: 60_000,
httpProxy: 'http://proxy:8080',
httpsProxy: 'http://proxy:8443',
outputFormat: 'json',
smitheryApiKey: 'sk-test',
});
expect(config.daemonUrl).toBe('http://custom:4000');
expect(config.registries).toEqual(['official']);
expect(config.outputFormat).toBe('json');
});
it('rejects invalid registry names', () => {
expect(() => McpctlConfigSchema.parse({ registries: ['invalid'] })).toThrow();
});
it('rejects invalid output format', () => {
expect(() => McpctlConfigSchema.parse({ outputFormat: 'xml' })).toThrow();
});
it('rejects negative cacheTTLMs', () => {
expect(() => McpctlConfigSchema.parse({ cacheTTLMs: -1 })).toThrow();
});
it('rejects non-integer cacheTTLMs', () => {
expect(() => McpctlConfigSchema.parse({ cacheTTLMs: 1.5 })).toThrow();
});
});
describe('DEFAULT_CONFIG', () => {
it('matches schema defaults', () => {
expect(DEFAULT_CONFIG).toEqual(McpctlConfigSchema.parse({}));
});
});

View File

@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { formatJson, formatYaml } from '../../src/formatters/output.js';
describe('formatJson', () => {
it('formats object as indented JSON', () => {
const result = formatJson({ key: 'value', num: 42 });
expect(JSON.parse(result)).toEqual({ key: 'value', num: 42 });
expect(result).toContain('\n'); // indented
});
it('formats arrays', () => {
const result = formatJson([1, 2, 3]);
expect(JSON.parse(result)).toEqual([1, 2, 3]);
});
it('handles null and undefined values', () => {
const result = formatJson({ a: null, b: undefined });
const parsed = JSON.parse(result) as Record<string, unknown>;
expect(parsed['a']).toBeNull();
expect('b' in parsed).toBe(false); // undefined stripped by JSON
});
});
describe('formatYaml', () => {
it('formats object as YAML', () => {
const result = formatYaml({ key: 'value', num: 42 });
expect(result).toContain('key: value');
expect(result).toContain('num: 42');
});
it('formats arrays', () => {
const result = formatYaml(['a', 'b']);
expect(result).toContain('- a');
expect(result).toContain('- b');
});
it('does not end with trailing newline', () => {
const result = formatYaml({ x: 1 });
expect(result.endsWith('\n')).toBe(false);
});
});

View File

@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import { formatTable } from '../../src/formatters/table.js';
import type { Column } from '../../src/formatters/table.js';
interface TestRow {
name: string;
age: number;
city: string;
}
const columns: Column<TestRow>[] = [
{ header: 'NAME', key: 'name' },
{ header: 'AGE', key: 'age', align: 'right' },
{ header: 'CITY', key: 'city' },
];
describe('formatTable', () => {
it('returns empty message for no rows', () => {
expect(formatTable([], columns)).toBe('No results found.');
});
it('formats a single row', () => {
const rows = [{ name: 'Alice', age: 30, city: 'NYC' }];
const result = formatTable(rows, columns);
const lines = result.split('\n');
expect(lines).toHaveLength(3); // header, separator, data
expect(lines[0]).toContain('NAME');
expect(lines[0]).toContain('AGE');
expect(lines[0]).toContain('CITY');
expect(lines[2]).toContain('Alice');
expect(lines[2]).toContain('NYC');
});
it('right-aligns numeric columns', () => {
const rows = [{ name: 'Bob', age: 5, city: 'LA' }];
const result = formatTable(rows, columns);
const lines = result.split('\n');
// AGE column should be right-aligned: " 5" or "5" padded
const ageLine = lines[2];
// The age value should have leading space(s) for right alignment
expect(ageLine).toMatch(/\s+5/);
});
it('auto-sizes columns to content', () => {
const rows = [
{ name: 'A', age: 1, city: 'X' },
{ name: 'LongName', age: 100, city: 'LongCityName' },
];
const result = formatTable(rows, columns);
const lines = result.split('\n');
// Header should be at least as wide as longest data
expect(lines[0]).toContain('NAME');
expect(lines[2]).toContain('A');
expect(lines[3]).toContain('LongName');
expect(lines[3]).toContain('LongCityName');
});
it('truncates long values when width is fixed', () => {
const narrowCols: Column<TestRow>[] = [
{ header: 'NAME', key: 'name', width: 5 },
];
const rows = [{ name: 'VeryLongName', age: 0, city: '' }];
const result = formatTable(rows, narrowCols);
const lines = result.split('\n');
// Should be truncated with ellipsis
expect(lines[2].trim().length).toBeLessThanOrEqual(5);
expect(lines[2]).toContain('\u2026');
});
it('supports function-based column keys', () => {
const fnCols: Column<TestRow>[] = [
{ header: 'INFO', key: (row) => `${row.name} (${row.age})` },
];
const rows = [{ name: 'Eve', age: 25, city: 'SF' }];
const result = formatTable(rows, fnCols);
expect(result).toContain('Eve (25)');
});
it('handles separator line matching column widths', () => {
const rows = [{ name: 'Test', age: 1, city: 'Here' }];
const result = formatTable(rows, columns);
const lines = result.split('\n');
const separator = lines[1];
// Separator should consist of dashes and spaces
expect(separator).toMatch(/^[-\s]+$/);
});
});

View File

@@ -2,7 +2,8 @@
"extends": "../../tsconfig.base.json", "extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"rootDir": "src", "rootDir": "src",
"outDir": "dist" "outDir": "dist",
"types": ["node"]
}, },
"include": ["src/**/*.ts"], "include": ["src/**/*.ts"],
"references": [ "references": [