diff --git a/README.md b/README.md index f2aa1d45..2654453a 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ Then any argument will be passed as-is to the `sdk:query` method. * [`kourou collection:export INDEX COLLECTION`](#kourou-collectionexport-index-collection) * [`kourou collection:import PATH`](#kourou-collectionimport-path) * [`kourou config:diff FIRST SECOND`](#kourou-configdiff-first-second) -* [`kourou document:search INDEX COLLECTION`](#kourou-documentsearch-index-collection) +* [`kourou document:search INDEX COLLECTION [QUERY]`](#kourou-documentsearch-index-collection-query) * [`kourou es:get INDEX ID`](#kourou-esget-index-id) * [`kourou es:insert INDEX`](#kourou-esinsert-index) * [`kourou es:list-index`](#kourou-eslist-index) @@ -147,11 +147,11 @@ Then any argument will be passed as-is to the `sdk:query` method. * [`kourou instance:spawn`](#kourou-instancespawn) * [`kourou profile:export`](#kourou-profileexport) * [`kourou profile:import PATH`](#kourou-profileimport-path) +* [`kourou realtime:subscribe INDEX COLLECTION`](#kourou-realtimesubscribe-index-collection) * [`kourou role:export`](#kourou-roleexport) * [`kourou role:import PATH`](#kourou-roleimport-path) * [`kourou sdk:execute`](#kourou-sdkexecute) * [`kourou sdk:query CONTROLLER:ACTION`](#kourou-sdkquery-controlleraction) -* [`kourou subscribe INDEX COLLECTION`](#kourou-subscribe-index-collection) * [`kourou user:export`](#kourou-userexport) * [`kourou user:export-mappings`](#kourou-userexport-mappings) * [`kourou user:import PATH`](#kourou-userimport-path) @@ -417,17 +417,18 @@ EXAMPLE _See code: [src/commands/config/diff.ts](src/commands/config/diff.ts)_ -## `kourou document:search INDEX COLLECTION` +## `kourou document:search INDEX COLLECTION [QUERY]` Searches for documents ``` USAGE - $ kourou document:search INDEX COLLECTION + $ kourou document:search INDEX COLLECTION [QUERY] ARGUMENTS INDEX Index name COLLECTION Collection name + QUERY Query in JS or JSON format. OPTIONS --api-key=api-key Kuzzle user api-key @@ -439,7 +440,6 @@ OPTIONS --password=password Kuzzle user password --port=port [default: 7512] Kuzzle server port --protocol=protocol [default: http] Kuzzle protocol (http or ws) - --query=query [default: {}] Query in JS or JSON format. --scroll=scroll Optional scroll TTL --size=size Optional page size --sort=sort [default: {}] Sort in JS or JSON format. @@ -447,7 +447,7 @@ OPTIONS --username=username [default: anonymous] Kuzzle username (local strategy) EXAMPLES - kourou document:search iot sensors --query '{ term: { name: "corona" } }' + kourou document:search iot sensors '{ term: { name: "corona" } }' kourou document:search iot sensors --editor ``` @@ -785,6 +785,59 @@ OPTIONS _See code: [src/commands/profile/import.ts](src/commands/profile/import.ts)_ +## `kourou realtime:subscribe INDEX COLLECTION` + +Subscribes to realtime notifications + +``` +USAGE + $ kourou realtime:subscribe INDEX COLLECTION + +ARGUMENTS + INDEX Index name + COLLECTION Collection name + +OPTIONS + --api-key=api-key Kuzzle user api-key + --as=as Impersonate a user + + --display=display [default: result] Path of the property to display from the notification (empty string to display + everything) + + --editor Open an editor (EDITOR env variable) to edit the filters before subscribing. + + --filters=filters [default: {}] Set of Koncorde filters + + --help show CLI help + + --host=host [default: localhost] Kuzzle server host + + --password=password Kuzzle user password + + --port=port [default: 7512] Kuzzle server port + + --protocol=protocol [default: websocket] Kuzzle protocol (only websocket for realtime) + + --scope=scope [default: all] Subscribe to document entering or leaving the scope (all, in, out, none) + + --ssl Use SSL to connect to Kuzzle + + --username=username [default: anonymous] Kuzzle username (local strategy) + + --users=users [default: all] Subscribe to users entering or leaving the room (all, in, out, none) + + --volatile=volatile [default: {}] Additional subscription information used in user join/leave notifications + +EXAMPLES + kourou realtime:subscribe iot-data sensors + kourou realtime:subscribe iot-data sensors --filters '{ range: { temperature: { gt: 0 } } }' + kourou realtime:subscribe iot-data sensors --filters '{ exists: "position" }' --scope out + kourou realtime:subscribe iot-data sensors --users all --volatile '{ clientId: "citizen-kane" }' + kourou realtime:subscribe iot-data sensors --display result._source.temperature +``` + +_See code: [src/commands/realtime/subscribe.ts](src/commands/realtime/subscribe.ts)_ + ## `kourou role:export` Exports roles @@ -967,6 +1020,7 @@ DESCRIPTION and action as first argument. Kourou will try to infer the first arguments to one the following pattern: - + - - - - @@ -976,6 +1030,7 @@ DESCRIPTION Examples: - kourou collection:list iot + - kourou security:createUser '{"content":{"profileIds":["default"]}}' --id yagmur - kourou collection:delete iot sensors - kourou document:createOrReplace iot sensors sigfox-1 '{}' - kourou bulk:import iot sensors '{bulkData: [...]}' @@ -984,59 +1039,6 @@ DESCRIPTION _See code: [src/commands/sdk/query.ts](src/commands/sdk/query.ts)_ -## `kourou subscribe INDEX COLLECTION` - -Subscribes to realtime notifications - -``` -USAGE - $ kourou subscribe INDEX COLLECTION - -ARGUMENTS - INDEX Index name - COLLECTION Collection name - -OPTIONS - --api-key=api-key Kuzzle user api-key - --as=as Impersonate a user - - --display=display [default: result] Path of the property to display from the notification (empty string to display - everything) - - --editor Open an editor (EDITOR env variable) to edit the filters before subscribing. - - --filters=filters [default: {}] Set of Koncorde filters - - --help show CLI help - - --host=host [default: localhost] Kuzzle server host - - --password=password Kuzzle user password - - --port=port [default: 7512] Kuzzle server port - - --protocol=protocol [default: websocket] Kuzzle protocol (only websocket for realtime) - - --scope=scope [default: all] Subscribe to document entering or leaving the scope (all, in, out, none) - - --ssl Use SSL to connect to Kuzzle - - --username=username [default: anonymous] Kuzzle username (local strategy) - - --users=users [default: all] Subscribe to users entering or leaving the room (all, in, out, none) - - --volatile=volatile [default: {}] Additional subscription information used in user join/leave notifications - -EXAMPLES - kourou subscribe iot-data sensors - kourou subscribe iot-data sensors --filters '{ range: { temperature: { gt: 0 } } }' - kourou subscribe iot-data sensors --filters '{ exists: "position" }' --scope out - kourou subscribe iot-data sensors --users all --volatile '{ clientId: "citizen-kane" }' - kourou subscribe iot-data sensors --display result._source.temperature -``` - -_See code: [src/commands/subscribe.ts](src/commands/subscribe.ts)_ - ## `kourou user:export` Exports users to JSON. diff --git a/features/Document.feature b/features/Document.feature index d776ffdb..cd2fe724 100644 --- a/features/Document.feature +++ b/features/Document.feature @@ -11,8 +11,8 @@ Feature: Document Management | body | { "name": "Sebastien", "city": "Cassis" } | And I refresh the collection When I run the command "document:search" with: - | arg | nyc-open-data | | - | arg | yellow-taxi | | - | flag | --query | { term: { city: "Saigon" } } | + | arg | nyc-open-data | + | arg | yellow-taxi | + | arg | { term: { city: "Saigon" } } | Then I should match stdout with "Adrien" And I should not match stdout with "Sebastien" diff --git a/features/Subscribe.feature b/features/RealtimeSubscribe.feature similarity index 100% rename from features/Subscribe.feature rename to features/RealtimeSubscribe.feature diff --git a/features/step_definitions/cli-steps.js b/features/step_definitions/cli-steps.js index e86f5fe5..b5c6d0b6 100644 --- a/features/step_definitions/cli-steps.js +++ b/features/step_definitions/cli-steps.js @@ -1,12 +1,13 @@ -const _ = require('lodash') const fs = require('fs') + +const _ = require('lodash') const { Then } = require('cucumber') // this need to build the lib with "npm run build" first const { execute } = require('../../lib/support/execute') Then('I subscribe to {string}:{string}', async function (index, collection) { - this.props.executor = execute('./bin/run', 'subscribe', index, collection) + this.props.executor = execute('./bin/run', 'realtime:subscribe', index, collection) // wait to connect to Kuzzle await new Promise(resolve => setTimeout(resolve, 4000)) diff --git a/src/commands/api-key/check.ts b/src/commands/api-key/check.ts index badb5230..4193d41b 100644 --- a/src/commands/api-key/check.ts +++ b/src/commands/api-key/check.ts @@ -20,7 +20,7 @@ class ApiKeyCheck extends Kommand { ] async runSafe() { - const { valid } = await this.sdk?.auth.checkToken(this.args.token) + const { valid } = await this.sdk.auth.checkToken(this.args.token) if (valid) { this.logOk('API key is still valid') diff --git a/src/commands/api-key/create.ts b/src/commands/api-key/create.ts index 1054bd11..6f2edd3a 100644 --- a/src/commands/api-key/create.ts +++ b/src/commands/api-key/create.ts @@ -28,7 +28,7 @@ class ApiKeyCreate extends Kommand { ] async runSafe() { - const apiKey = await this.sdk?.security.createApiKey( + const apiKey = await this.sdk.security.createApiKey( this.args.user, this.flags.description, { diff --git a/src/commands/api-key/delete.ts b/src/commands/api-key/delete.ts index 9ad353fb..b2fc940f 100644 --- a/src/commands/api-key/delete.ts +++ b/src/commands/api-key/delete.ts @@ -21,7 +21,7 @@ class ApiKeyDelete extends Kommand { ]; async runSafe() { - await this.sdk?.security.deleteApiKey(this.args.user, this.args.id) + await this.sdk.security.deleteApiKey(this.args.user, this.args.id) this.logOk(`Successfully deleted API Key "${this.args.id}" of user "${this.args.user}"`) } diff --git a/src/commands/api-key/search.ts b/src/commands/api-key/search.ts index e6c6c1ef..b91be147 100644 --- a/src/commands/api-key/search.ts +++ b/src/commands/api-key/search.ts @@ -28,7 +28,7 @@ class ApiKeySearch extends Kommand { } } - const result = await this.sdk?.security.searchApiKeys( + const result = await this.sdk.security.searchApiKeys( this.args.user, query, { @@ -43,6 +43,7 @@ class ApiKeySearch extends Kommand { for (const { _id, _source } of result.hits) { this.log(` - Key "${_id}"`) this.log(` Description: ${_source.description}`) + this.log(` Fingerprint: ${_source.fingerprint}`) this.log(` Expires at: ${_source.expiresAt}`) } } diff --git a/src/commands/collection/create.ts b/src/commands/collection/create.ts index 848a7b9b..b941c6c5 100644 --- a/src/commands/collection/create.ts +++ b/src/commands/collection/create.ts @@ -22,13 +22,13 @@ export default class CollectionCreate extends Kommand { async runSafe() { const body = this.stdin ? this.stdin : this.args.body || '{}' - if (!await this.sdk?.index.exists(this.args.index)) { - await this.sdk?.index.create(this.args.index) + if (!await this.sdk.index.exists(this.args.index)) { + await this.sdk.index.create(this.args.index) this.logInfo(`Index "${this.args.index}" created`) } - await this.sdk?.collection.create(this.args.index, this.args.collection, this.parseJs(body)) + await this.sdk.collection.create(this.args.index, this.args.collection, this.parseJs(body)) this.logOk(`Collection "${this.args.index}":"${this.args.collection}" created`) } diff --git a/src/commands/collection/export.ts b/src/commands/collection/export.ts index 8fb7b092..9d54996f 100644 --- a/src/commands/collection/export.ts +++ b/src/commands/collection/export.ts @@ -53,8 +53,8 @@ export default class CollectionExport extends Kommand { query = this.fromEditor(query, { json: true }) } - const countAll = await this.sdk?.document.count(this.args.index, this.args.collection) - const count = await this.sdk?.document.count(this.args.index, this.args.collection, { query }) + const countAll = await this.sdk.document.count(this.args.index, this.args.collection) + const count = await this.sdk.document.count(this.args.index, this.args.collection, { query }) this.logInfo(`Dumping ${count} of ${countAll} documents from collection "${this.args.index}:${this.args.collection}" in ${path} ...`) diff --git a/src/commands/document/search.ts b/src/commands/document/search.ts index 243e518c..7ac298d5 100644 --- a/src/commands/document/search.ts +++ b/src/commands/document/search.ts @@ -7,15 +7,11 @@ export default class DocumentSearch extends Kommand { static description = 'Searches for documents' static examples = [ - 'kourou document:search iot sensors --query \'{ term: { name: "corona" } }\'', + 'kourou document:search iot sensors \'{ term: { name: "corona" } }\'', 'kourou document:search iot sensors --editor', ] static flags = { - query: flags.string({ - description: 'Query in JS or JSON format.', - default: '{}' - }), sort: flags.string({ description: 'Sort in JS or JSON format.', default: '{}' @@ -38,7 +34,8 @@ export default class DocumentSearch extends Kommand { static args = [ { name: 'index', description: 'Index name', required: true }, - { name: 'collection', description: 'Collection name', required: true } + { name: 'collection', description: 'Collection name', required: true }, + { name: 'query', description: 'Query in JS or JSON format.' }, ] async runSafe() { @@ -51,7 +48,7 @@ export default class DocumentSearch extends Kommand { size: this.flags.size, scroll: this.flags.scroll, body: { - query: this.parseJs(this.flags.query), + query: this.parseJs(this.args.query || '{}'), sort: this.parseJs(this.flags.sort) } } @@ -61,13 +58,13 @@ export default class DocumentSearch extends Kommand { request = this.fromEditor(request, { json: true }) } - const { result } = await this.sdk?.query(request) + const { result } = await this.sdk.query(request) - for (const document of result.hits) { + for (const document of result?.hits) { this.logInfo(`Document ID: ${document._id}`) this.log(`Content: ${JSON.stringify(document._source, null, 2)}`) } - this.logOk(`${result.hits.length} documents fetched on a total of ${result.total}`) + this.logOk(`${result?.hits.length} documents fetched on a total of ${result?.total}`) } } diff --git a/src/commands/import.ts b/src/commands/import.ts index da200ed3..ce4b56e9 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -98,7 +98,7 @@ export default class Import extends Kommand { const dump = JSON.parse(fs.readFileSync(file, 'utf8')) const mapping = dump.content.mapping - await this.sdk?.security.updateUserMapping({ properties: mapping }) + await this.sdk.security.updateUserMapping({ properties: mapping }) this.logOk('[users] collection mappings imported') } diff --git a/src/commands/index/export.ts b/src/commands/index/export.ts index aac829f4..3da84eb3 100644 --- a/src/commands/index/export.ts +++ b/src/commands/index/export.ts @@ -39,7 +39,7 @@ export default class IndexExport extends Kommand { fs.mkdirSync(exportPath, { recursive: true }) - const { collections } = await this.sdk?.collection.list(this.args.index) + const { collections } = await this.sdk.collection.list(this.args.index) for (const collection of collections) { try { diff --git a/src/commands/profile/export.ts b/src/commands/profile/export.ts index 47dc2579..7f5f460c 100644 --- a/src/commands/profile/export.ts +++ b/src/commands/profile/export.ts @@ -1,4 +1,4 @@ -import fs from 'fs' +import fs from 'fs' import path from 'path' import { flags } from '@oclif/command' @@ -46,7 +46,7 @@ export default class ProfileExport extends Kommand { size: 100 } - let result = await this.sdk?.security.searchProfiles({}, options) + let result = await this.sdk.security.searchProfiles({}, options) const profiles: any = {} diff --git a/src/commands/subscribe.ts b/src/commands/realtime/subscribe.ts similarity index 73% rename from src/commands/subscribe.ts rename to src/commands/realtime/subscribe.ts index 106e2d93..ce45ab85 100644 --- a/src/commands/subscribe.ts +++ b/src/commands/realtime/subscribe.ts @@ -1,25 +1,21 @@ import { flags } from '@oclif/command' import _ from 'lodash' -import { Kommand } from '../common' -import { kuzzleFlags } from '../support/kuzzle' +import { Kommand } from '../../common' +import { kuzzleFlags } from '../../support/kuzzle' export default class RealtimeSubscribe extends Kommand { static description = 'Subscribes to realtime notifications' static examples = [ - 'kourou subscribe iot-data sensors', - 'kourou subscribe iot-data sensors --filters \'{ range: { temperature: { gt: 0 } } }\'', - 'kourou subscribe iot-data sensors --filters \'{ exists: "position" }\' --scope out', - 'kourou subscribe iot-data sensors --users all --volatile \'{ clientId: "citizen-kane" }\'', - 'kourou subscribe iot-data sensors --display result._source.temperature', + 'kourou realtime:subscribe iot-data sensors', + 'kourou realtime:subscribe iot-data sensors \'{ range: { temperature: { gt: 0 } } }\'', + 'kourou realtime:subscribe iot-data sensors \'{ exists: "position" }\' --scope out', + 'kourou realtime:subscribe iot-data sensors --users all --volatile \'{ clientId: "citizen-kane" }\'', + 'kourou realtime:subscribe iot-data sensors --display result._source.temperature', ] static flags = { - filters: flags.string({ - description: 'Set of Koncorde filters', - default: '{}' - }), scope: flags.string({ description: 'Subscribe to document entering or leaving the scope (all, in, out, none)', default: 'all' @@ -49,7 +45,8 @@ export default class RealtimeSubscribe extends Kommand { static args = [ { name: 'index', description: 'Index name', required: true }, - { name: 'collection', description: 'Collection name', required: true } + { name: 'collection', description: 'Collection name', required: true }, + { name: 'filters', description: 'Set of Koncorde filters' }, ] static readStdin = true @@ -61,19 +58,19 @@ export default class RealtimeSubscribe extends Kommand { } async runSafe() { - let filters = this.stdin ? this.stdin : this.flags.filters + let filters = this.stdin ? this.stdin : this.args.filters || '{}' // content from user editor if (this.flags.editor) { filters = this.fromEditor(filters, { json: true }) } - await this.sdk?.realtime.subscribe( + await this.sdk.realtime.subscribe( this.args.index, this.args.collection, this.parseJs(filters), (notification: any) => { - this.logInfo('New notification') + this.logInfo(`New notification triggered by API action "${notification.controller}:${notification.action}"`) const display = this.flags.display === '' ? notification diff --git a/src/commands/role/export.ts b/src/commands/role/export.ts index 3dfec0c4..7f4c21cb 100644 --- a/src/commands/role/export.ts +++ b/src/commands/role/export.ts @@ -46,7 +46,7 @@ export default class RoleDump extends Kommand { size: 100 } - let result = await this.sdk?.security.searchRoles({}, options) + let result = await this.sdk.security.searchRoles({}, options) const roles: any = {} diff --git a/src/commands/sdk/execute.ts b/src/commands/sdk/execute.ts index 7475913c..5bbeeb02 100644 --- a/src/commands/sdk/execute.ts +++ b/src/commands/sdk/execute.ts @@ -101,7 +101,7 @@ ${variables} } // eslint-disable-next-line @typescript-eslint/no-unused-vars - const sdk: any = this.sdk?.sdk + const sdk: any = this.sdk.sdk let result try { diff --git a/src/commands/sdk/query.ts b/src/commands/sdk/query.ts index 9298f29a..d00b00d0 100644 --- a/src/commands/sdk/query.ts +++ b/src/commands/sdk/query.ts @@ -103,10 +103,6 @@ Default fallback to API method async runSafe() { const [controller, action] = this.args['controller:action'].split(':') - if (controller === 'realtime' && action === 'subscribe') { - throw new Error('Use the "subscribe" command to listen to realtime notifications') - } - const requestArgs: any = {} requestArgs.index = this.flags.index @@ -138,7 +134,7 @@ Default fallback to API method request.body = this.fromEditor(request.body, { json: true }) } - const response = await this.sdk?.query(request) + const response = await this.sdk.query(request) const display = this.flags.display === '' ? response diff --git a/src/commands/user/export-mappings.ts b/src/commands/user/export-mappings.ts index 865c2487..69d04530 100644 --- a/src/commands/user/export-mappings.ts +++ b/src/commands/user/export-mappings.ts @@ -28,7 +28,7 @@ export default class UserExportMappings extends Kommand { fs.mkdirSync(this.flags.path, { recursive: true }) - const mapping = await this.sdk?.security.getUserMapping() + const mapping = await this.sdk.security.getUserMapping() const dump = { type: 'usersMappings', diff --git a/src/commands/user/export.ts b/src/commands/user/export.ts index 53cba8bd..fe6e4f42 100644 --- a/src/commands/user/export.ts +++ b/src/commands/user/export.ts @@ -101,7 +101,7 @@ Examples: async _dumpUsers() { const users: any = {} - let results = await this.sdk?.security.searchUsers( + let results = await this.sdk.security.searchUsers( {}, { scroll: '5s', size: this.flags['batch-size'] }) diff --git a/src/commands/user/import-mappings.ts b/src/commands/user/import-mappings.ts index 339ade43..e7f875b0 100644 --- a/src/commands/user/import-mappings.ts +++ b/src/commands/user/import-mappings.ts @@ -27,7 +27,7 @@ export default class UserImportMappings extends Kommand { const mapping = dump.content.mapping - await this.sdk?.security.updateUserMapping({ properties: mapping }) + await this.sdk.security.updateUserMapping({ properties: mapping }) this.logOk('Users collecction mappings restored') } } diff --git a/src/common.ts b/src/common.ts index 442b2a14..6cf358cb 100644 --- a/src/common.ts +++ b/src/common.ts @@ -7,7 +7,8 @@ import { KuzzleSDK } from './support/kuzzle' import { Editor, EditorParams } from './support/editor' export abstract class Kommand extends Command { - protected sdk?: KuzzleSDK + // Instantiate a dummy SDK to avoid the this.sdk notation everywhere -_- + protected sdk: KuzzleSDK = new KuzzleSDK({ host: 'nowhere' }) private exitCode = 0 @@ -81,7 +82,7 @@ export abstract class Kommand extends Command { if (this.flags.as) { this.logInfo(`Impersonate user "${this.flags.as}"`) - await this.sdk?.impersonate(this.flags.as, async () => { + await this.sdk.impersonate(this.flags.as, async () => { await this.runSafe() }) } @@ -93,7 +94,7 @@ export abstract class Kommand extends Command { this.logKo(`${error.stack || error.message}\n\tstatus: ${error.status}\n\tid: ${error.id}`) } finally { - this.sdk?.disconnect() + this.sdk.disconnect() // eslint-disable-next-line process.exit(this.exitCode) } diff --git a/src/support/kuzzle.ts b/src/support/kuzzle.ts index 431f8ee0..825e5185 100644 --- a/src/support/kuzzle.ts +++ b/src/support/kuzzle.ts @@ -1,7 +1,6 @@ import { flags } from '@oclif/command' -// tslint:disable-next-line -const { Http, WebSocket, Kuzzle } = require('kuzzle-sdk') +import { Http, WebSocket, Kuzzle } from 'kuzzle-sdk' const SECOND = 1000 @@ -40,7 +39,7 @@ export const kuzzleFlags = { } export class KuzzleSDK { - public sdk: any; + public sdk: Kuzzle; private host: string; @@ -66,6 +65,9 @@ export class KuzzleSDK { this.password = options.password this.protocol = options.protocol this.apikey = options['api-key'] + + // Instantiate a fake SDK in the constructor to please TS + this.sdk = new Kuzzle(new WebSocket('nowhere')) } public async init(logger: any) { @@ -161,7 +163,7 @@ export class KuzzleSDK { } disconnect() { - this.sdk?.disconnect() + this.sdk.disconnect() if (this.refreshTimer) { clearInterval(this.refreshTimer) diff --git a/src/templates/app-scaffold/ts/tsconfig.json b/src/templates/app-scaffold/ts/tsconfig.json index 4af52bb7..02019851 100644 --- a/src/templates/app-scaffold/ts/tsconfig.json +++ b/src/templates/app-scaffold/ts/tsconfig.json @@ -10,10 +10,10 @@ "resolveJsonModule": true, "esModuleInterop": true }, - "rootDir": "src/", + "rootDir": "lib/", "include": [ - "index.ts", - "src/**/*.ts" + "app.ts", + "lib/**/*.ts" ], "exclude": [ "node_modules"