Typescript port (#35)
* converted all files to ts, updated errors, except for __audits in test/basic.ts * passes tslint working on compiling * working on build and travis * Update travis file * Update package.json * add linting command * add rimraf to cleanup script * fix spelling of clutter * clean up configurations and add comments * clean up package scripts * update path param * fix new linting and ts errors
This commit is contained in:
Родитель
834cec3658
Коммит
e45b1a8691
|
@ -57,3 +57,5 @@ typings/
|
||||||
# dotenv environment variables file
|
# dotenv environment variables file
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
# typescript compiled files
|
||||||
|
dist/
|
||||||
|
|
|
@ -1,4 +1,11 @@
|
||||||
language: node_js
|
language: node_js
|
||||||
|
|
||||||
|
before_script:
|
||||||
|
- npm run compile
|
||||||
|
|
||||||
|
script:
|
||||||
|
- npm run test
|
||||||
|
|
||||||
node_js:
|
node_js:
|
||||||
- "node"
|
- "node"
|
||||||
- "lts/*"
|
- "lts/*"
|
5
index.js
5
index.js
|
@ -1,5 +0,0 @@
|
||||||
module.exports = {
|
|
||||||
parsers: require('./parsers'),
|
|
||||||
validators: require('./validators'),
|
|
||||||
auditer: require('./audit')
|
|
||||||
}
|
|
Разница между файлами не показана из-за своего большого размера
Загрузить разницу
18
package.json
18
package.json
|
@ -2,9 +2,12 @@
|
||||||
"name": "platform-chaos",
|
"name": "platform-chaos",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "A node sdk for building services capable of injecting chaos into PaaS offerings",
|
"description": "A node sdk for building services capable of injecting chaos into PaaS offerings",
|
||||||
"main": "index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "mocha"
|
"lint": "npx tslint --project .",
|
||||||
|
"test": "npm run lint && mocha dist/test/",
|
||||||
|
"compile": "npm run cleanup && npx tsc",
|
||||||
|
"cleanup": "rimraf dist/"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"azure",
|
"azure",
|
||||||
|
@ -14,6 +17,11 @@
|
||||||
"author": "microsoft",
|
"author": "microsoft",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/lodash.isequal": "^4.5.3",
|
||||||
|
"@types/mocha": "^5.2.5",
|
||||||
|
"@types/shimmer": "^1.0.1",
|
||||||
|
"@types/sinon": "^5.0.2",
|
||||||
|
"@types/uuid": "^3.4.4",
|
||||||
"eslint": "^5.4.0",
|
"eslint": "^5.4.0",
|
||||||
"eslint-config-standard": "^12.0.0",
|
"eslint-config-standard": "^12.0.0",
|
||||||
"eslint-plugin-import": "^2.14.0",
|
"eslint-plugin-import": "^2.14.0",
|
||||||
|
@ -22,7 +30,11 @@
|
||||||
"eslint-plugin-standard": "^4.0.0",
|
"eslint-plugin-standard": "^4.0.0",
|
||||||
"lodash.isequal": "^4.5.0",
|
"lodash.isequal": "^4.5.0",
|
||||||
"mocha": "^5.2.0",
|
"mocha": "^5.2.0",
|
||||||
"sinon": "^6.2.0"
|
"rimraf": "^2.6.2",
|
||||||
|
"sinon": "^6.2.0",
|
||||||
|
"tslint": "^5.11.0",
|
||||||
|
"tslint-config-standard": "^8.0.1",
|
||||||
|
"typescript": "^3.0.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"shimmer": "^1.2.0",
|
"shimmer": "^1.2.0",
|
||||||
|
|
|
@ -1,43 +1,58 @@
|
||||||
const shimmer = require('shimmer')
|
import * as assert from 'assert'
|
||||||
const assert = require('assert')
|
import * as os from 'os'
|
||||||
const os = require('os')
|
import * as shimmer from 'shimmer'
|
||||||
const uuidv4 = require('uuid/v4')
|
import * as uuidv4 from 'uuid'
|
||||||
|
|
||||||
|
/* interface implementation of Audit defined in
|
||||||
|
* https://github.com/Azure/platform-chaos/wiki/Auditing
|
||||||
|
* */
|
||||||
|
export interface IAudit {
|
||||||
|
auditId: string,
|
||||||
|
date: string,
|
||||||
|
eventName: string,
|
||||||
|
extensionLog: string[],
|
||||||
|
resources: string,
|
||||||
|
system: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IAuditOptions {
|
||||||
|
eventName: string,
|
||||||
|
resources: string
|
||||||
|
}
|
||||||
|
|
||||||
const auditSystem = `${os.hostname()}-${os.platform()}`
|
const auditSystem = `${os.hostname()}-${os.platform()}`
|
||||||
let auditQueue = []
|
let auditQueue: IAudit[] = []
|
||||||
|
|
||||||
const audit = (extensionLogArgs, auditOptions) => {
|
const audit = (extensionLogArgs, auditOptions: IAuditOptions) => {
|
||||||
const { eventName, resources } = auditOptions
|
const { eventName, resources } = auditOptions
|
||||||
|
|
||||||
auditQueue.push({
|
auditQueue.push({
|
||||||
auditId: uuidv4(),
|
auditId: uuidv4(),
|
||||||
eventName: eventName,
|
|
||||||
system: auditSystem,
|
|
||||||
date: new Date().toISOString(),
|
date: new Date().toISOString(),
|
||||||
resources: resources,
|
eventName,
|
||||||
extensionLog: typeof extensionLogArgs === 'string' ? [ extensionLogArgs ] : Array.from(extensionLogArgs)
|
extensionLog: typeof extensionLogArgs === 'string' ? [ extensionLogArgs ] : Array.from(extensionLogArgs),
|
||||||
|
resources,
|
||||||
|
system: auditSystem
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
export default {
|
||||||
audit: audit,
|
audit,
|
||||||
initialize: (context, opts) => {
|
initialize: (context: any, opts: IAuditOptions) => {
|
||||||
assert(opts, 'Options object must be defined')
|
assert(opts, 'Options object must be defined')
|
||||||
assert(typeof opts.eventName === 'string', 'Event name must be a string')
|
|
||||||
assert(typeof opts.resources === 'string', 'Resources must be a string')
|
|
||||||
|
|
||||||
const auditOptions = opts
|
const auditOptions = opts
|
||||||
|
|
||||||
auditQueue = []
|
auditQueue = []
|
||||||
|
|
||||||
shimmer.wrap(context, 'log', function (original) {
|
shimmer.wrap(context, 'log', (original: (message: any) => void) => {
|
||||||
return function () {
|
return function () {
|
||||||
audit(arguments, auditOptions)
|
audit(arguments, auditOptions)
|
||||||
return original.apply(this, arguments)
|
return original.apply(this, arguments)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
shimmer.wrap(context, 'done', function (original) {
|
shimmer.wrap(context, 'done', (original: (err: any, propertyBag: any) => void) => {
|
||||||
return function () {
|
return function () {
|
||||||
const audits = auditQueue
|
const audits = auditQueue
|
||||||
|
|
||||||
|
@ -47,10 +62,10 @@ module.exports = {
|
||||||
}
|
}
|
||||||
} else if (typeof context.res.body === 'string') {
|
} else if (typeof context.res.body === 'string') {
|
||||||
const body = JSON.parse(context.res.body)
|
const body = JSON.parse(context.res.body)
|
||||||
body['__audits'] = audits
|
body.__audits = audits
|
||||||
context.res.body = JSON.stringify(body)
|
context.res.body = JSON.stringify(body)
|
||||||
} else if (typeof context.res.body === 'object') {
|
} else if (typeof context.res.body === 'object') {
|
||||||
context.res.body['__audits'] = audits
|
context.res.body.__audits = audits
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
* If context.res.body is not of type string or object
|
* If context.res.body is not of type string or object
|
|
@ -0,0 +1,9 @@
|
||||||
|
import Auditers from './audit'
|
||||||
|
import Parsers from './parsers'
|
||||||
|
import Validators from './validators'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
auditer: Auditers,
|
||||||
|
parsers: Parsers,
|
||||||
|
validators: Validators
|
||||||
|
}
|
|
@ -1,15 +1,16 @@
|
||||||
const validate = require('./validators')
|
import validate from './validators'
|
||||||
|
|
||||||
module.exports = {
|
export default {
|
||||||
accessTokenToCredentials: (req) => {
|
accessTokenToCredentials: (req) => {
|
||||||
// ensure we have a valid accessToken before proceeding
|
// ensure we have a valid accessToken before proceeding
|
||||||
validate.accessToken(req)
|
validate.accessToken(req)
|
||||||
|
|
||||||
// this implements the contract defined by msRestAzure
|
// this implements the contract defined by msRestAzure
|
||||||
// see https://github.com/Azure/azure-sdk-for-node/blob/0a52678ea7b3a24a478975f7169fc30e9fc9759e/runtime/ms-rest-azure/lib/credentials/deviceTokenCredentials.js
|
// tslint:disable-next-line:max-line-length
|
||||||
|
// see https://github.com/Azure/azure-sdk-for-node/blob/master/runtime/ms-rest-azure/lib/credentials/deviceTokenCredentials.js
|
||||||
return {
|
return {
|
||||||
signRequest: (webResource, callback) => {
|
signRequest: (webResource, callback) => {
|
||||||
webResource.headers['Authorization'] = req.body.accessToken
|
webResource.headers.Authorization = req.body.accessToken
|
||||||
callback(null)
|
callback(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -19,11 +20,11 @@ module.exports = {
|
||||||
validate.resources(req)
|
validate.resources(req)
|
||||||
|
|
||||||
// parse the resources into objects
|
// parse the resources into objects
|
||||||
return req.body.resources.map(r => r.split('/')).map(r => {
|
return req.body.resources.map((r) => r.split('/')).map((r) => {
|
||||||
return {
|
return {
|
||||||
subscriptionId: r[0],
|
|
||||||
resourceGroupName: r[1],
|
resourceGroupName: r[1],
|
||||||
resourceName: r[2]
|
resourceName: r[2],
|
||||||
|
subscriptionId: r[0]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
|
@ -1,195 +1,206 @@
|
||||||
const assert = require('assert')
|
import * as assert from 'assert'
|
||||||
const index = require('../')
|
import * as sinon from 'sinon'
|
||||||
const sinon = require('sinon')
|
|
||||||
const isEqual = require('lodash.isequal')
|
import isEqual = require('lodash.isequal')
|
||||||
|
|
||||||
/* eslint-env node, mocha */
|
import { IAudit } from '../audit'
|
||||||
|
import index from '../index'
|
||||||
describe('platform-chaos', () => {
|
|
||||||
it('is named properly', () => {
|
interface IHeader {
|
||||||
assert.equal(require('../package.json').name, 'platform-chaos')
|
Authorization?: string
|
||||||
assert.equal(require('../package-lock.json').name, 'platform-chaos')
|
}
|
||||||
})
|
|
||||||
it('parses resources', () => {
|
interface IBody {
|
||||||
assert.throws(() => {
|
__audits?: IAudit[]
|
||||||
index.validators.resources({
|
}
|
||||||
body: {
|
|
||||||
resources: [1]
|
describe('platform-chaos', () => {
|
||||||
}
|
it('is named properly', () => {
|
||||||
})
|
assert.strictEqual(require('../../package.json').name, 'platform-chaos')
|
||||||
})
|
assert.strictEqual(require('../../package-lock.json').name, 'platform-chaos')
|
||||||
|
})
|
||||||
assert.throws(() => {
|
it('parses resources', () => {
|
||||||
index.validators.resources({
|
assert.throws(() => {
|
||||||
body: {
|
index.validators.resources({
|
||||||
resources: ['']
|
body: {
|
||||||
}
|
resources: [1]
|
||||||
})
|
}
|
||||||
})
|
})
|
||||||
|
})
|
||||||
assert.throws(() => {
|
|
||||||
index.validators.resources({
|
assert.throws(() => {
|
||||||
body: {
|
index.validators.resources({
|
||||||
resources: ['one/two/three/four']
|
body: {
|
||||||
}
|
resources: ['']
|
||||||
})
|
}
|
||||||
})
|
})
|
||||||
|
})
|
||||||
const instance = index.parsers.resourcesToObjects({
|
|
||||||
body: {
|
assert.throws(() => {
|
||||||
resources: ['sub/rg/resource']
|
index.validators.resources({
|
||||||
}
|
body: {
|
||||||
})[0]
|
resources: ['one/two/three/four']
|
||||||
|
}
|
||||||
assert.equal(instance.subscriptionId, 'sub')
|
})
|
||||||
assert.equal(instance.resourceGroupName, 'rg')
|
})
|
||||||
assert.equal(instance.resourceName, 'resource')
|
|
||||||
})
|
const instance = index.parsers.resourcesToObjects({
|
||||||
|
body: {
|
||||||
it('parses accessTokens', () => {
|
resources: ['sub/rg/resource']
|
||||||
// invalid at
|
}
|
||||||
assert.throws(() => {
|
})[0]
|
||||||
index.validators.accessToken({
|
|
||||||
body: {
|
assert.strictEqual(instance.subscriptionId, 'sub')
|
||||||
accessToken: 1
|
assert.strictEqual(instance.resourceGroupName, 'rg')
|
||||||
}
|
assert.strictEqual(instance.resourceName, 'resource')
|
||||||
})
|
})
|
||||||
})
|
|
||||||
|
it('parses accessTokens', () => {
|
||||||
// empty resourceIds
|
// invalid at
|
||||||
assert.throws(() => {
|
assert.throws(() => {
|
||||||
index.validators.accessToken({
|
index.validators.accessToken({
|
||||||
body: {
|
body: {
|
||||||
accessToken: 'valid type'
|
accessToken: 1
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// invalid resourceIds type
|
// empty resourceIds
|
||||||
assert.throws(() => {
|
assert.throws(() => {
|
||||||
index.validators.accessToken({
|
index.validators.accessToken({
|
||||||
body: {
|
body: {
|
||||||
accessToken: 'valid type'
|
accessToken: 'valid type'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// invalid resourceIds format
|
// invalid resourceIds type
|
||||||
assert.throws(() => {
|
assert.throws(() => {
|
||||||
index.validators.accessToken({
|
index.validators.accessToken({
|
||||||
body: {
|
body: {
|
||||||
accessToken: 'valid type'
|
accessToken: 'valid type'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// valid
|
// invalid resourceIds format
|
||||||
const expectedAccessToken = 'Bearer 12345234r2'
|
assert.throws(() => {
|
||||||
const instance = index.parsers.accessTokenToCredentials({
|
index.validators.accessToken({
|
||||||
body: {
|
body: {
|
||||||
accessToken: expectedAccessToken
|
accessToken: 'valid type'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
})
|
||||||
const res = {
|
|
||||||
headers: {}
|
// valid
|
||||||
}
|
const expectedAccessToken = 'Bearer 12345234r2'
|
||||||
|
const instance = index.parsers.accessTokenToCredentials({
|
||||||
instance.signRequest(res, () => {})
|
body: {
|
||||||
|
accessToken: expectedAccessToken
|
||||||
assert.equal(res.headers['Authorization'], expectedAccessToken)
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
it('audits correctly', () => {
|
const res = {
|
||||||
function contextLog () {
|
headers: {} as IHeader
|
||||||
// in reality this would be `console.log(...arguments)`
|
}
|
||||||
// but in order to not cluter test we noop this
|
|
||||||
return () => null
|
instance.signRequest(res, () => null)
|
||||||
}
|
|
||||||
function contextDone () {
|
assert.strictEqual(res.headers.Authorization, expectedAccessToken)
|
||||||
return () => null
|
})
|
||||||
}
|
|
||||||
|
it('audits correctly', () => {
|
||||||
const contextLogSpy = sinon.spy(contextLog)
|
function contextLog () {
|
||||||
const contextDoneSpy = sinon.spy(contextDone)
|
// in reality this would be `console.log(...arguments)`
|
||||||
|
// but in order to not clutter test we noop this
|
||||||
const context = {
|
return () => null
|
||||||
log: contextLogSpy,
|
}
|
||||||
done: contextDoneSpy,
|
function contextDone () {
|
||||||
res: { body: {} }
|
return () => null
|
||||||
}
|
}
|
||||||
|
|
||||||
index.auditer.initialize(context, {
|
const contextLogSpy = sinon.spy(contextLog)
|
||||||
eventName: 'testEvent',
|
const contextDoneSpy = sinon.spy(contextDone)
|
||||||
resources: 'testResource'
|
|
||||||
})
|
const context = {
|
||||||
|
done: contextDoneSpy,
|
||||||
const logItem1 = {
|
log: contextLogSpy,
|
||||||
'prop1': 'important information',
|
res: { body: {} }
|
||||||
'anotherProp': 'more important info'
|
}
|
||||||
}
|
|
||||||
const logItem2 = 'Hello, World!'
|
index.auditer.initialize(context, {
|
||||||
const logItem3 = ['abc', { a: 12 }]
|
eventName: 'testEvent',
|
||||||
|
resources: 'testResource'
|
||||||
context.log(logItem1)
|
})
|
||||||
context.log(logItem2)
|
|
||||||
context.log(...logItem3)
|
const logItem1 = {
|
||||||
|
anotherProp: 'more important info',
|
||||||
context.res.body = {
|
prop1: 'important information'
|
||||||
message: 'I am writing to the body'
|
}
|
||||||
}
|
const logItem2 = 'Hello, World!'
|
||||||
|
const logItem3 = ['abc', { a: 12 }]
|
||||||
context.done()
|
|
||||||
|
context.log(logItem1)
|
||||||
assert(contextLogSpy.called, 'context.log should be called')
|
context.log(logItem2)
|
||||||
assert(contextDoneSpy.called, 'context.done should be called')
|
context.log(...logItem3)
|
||||||
|
|
||||||
const body = context.res.body
|
context.res.body = {
|
||||||
|
message: 'I am writing to the body'
|
||||||
assert(typeof body === 'object', 'context.res.body should exist as an object')
|
}
|
||||||
assert(body.hasOwnProperty('__audits'), 'body contains __audits property')
|
|
||||||
assert(isEqual(body.__audits[0].extensionLog[0], logItem1), 'log item 1 is added to audit correctly')
|
context.done()
|
||||||
assert(isEqual(body.__audits[1].extensionLog[0], logItem2), 'log item 2 is added to audit correctly')
|
|
||||||
assert(isEqual(body.__audits[2].extensionLog, logItem3), 'log item 3 is added to audit correctly')
|
assert(contextLogSpy.called, 'context.log should be called')
|
||||||
})
|
assert(contextDoneSpy.called, 'context.done should be called')
|
||||||
|
|
||||||
it('allows user to audit directly', () => {
|
const body: IBody = context.res.body
|
||||||
function contextLog () {
|
|
||||||
// in reality this would be `console.log(...arguments)`
|
assert(body.hasOwnProperty('__audits'), 'body contains __audits property')
|
||||||
// but in order to not cluter test we noop this
|
assert(isEqual(body.__audits && body.__audits[0].extensionLog[0], logItem1),
|
||||||
return () => null
|
'log item 1 is added to audit correctly')
|
||||||
}
|
assert(isEqual(body.__audits && body.__audits[1].extensionLog[0], logItem2),
|
||||||
function contextDone () {
|
'log item 2 is added to audit correctly')
|
||||||
return () => null
|
assert(isEqual(body.__audits && body.__audits[2].extensionLog, logItem3),
|
||||||
}
|
'log item 3 is added to audit correctly')
|
||||||
|
})
|
||||||
const contextLogSpy = sinon.spy(contextLog)
|
|
||||||
const contextDoneSpy = sinon.spy(contextDone)
|
it('allows user to audit directly', () => {
|
||||||
|
function contextLog () {
|
||||||
const context = {
|
// in reality this would be `console.log(...arguments)`
|
||||||
log: contextLogSpy,
|
// but in order to not cluter test we noop this
|
||||||
done: contextDoneSpy,
|
return () => null
|
||||||
res: { body: {} }
|
}
|
||||||
}
|
function contextDone () {
|
||||||
|
return () => null
|
||||||
const opts = {
|
}
|
||||||
eventName: 'testEvent',
|
|
||||||
resources: 'testResource'
|
const contextLogSpy = sinon.spy(contextLog)
|
||||||
}
|
const contextDoneSpy = sinon.spy(contextDone)
|
||||||
|
|
||||||
index.auditer.initialize(context, opts)
|
const context = {
|
||||||
|
done: contextDoneSpy,
|
||||||
index.auditer.audit('Hello, World!', opts)
|
log: contextLogSpy,
|
||||||
|
res: { body: {} }
|
||||||
context.done()
|
}
|
||||||
|
|
||||||
assert(contextLogSpy.notCalled, 'context.log should not be called')
|
const opts = {
|
||||||
assert(contextDoneSpy.called, 'context.done should be called')
|
eventName: 'testEvent',
|
||||||
|
resources: 'testResource'
|
||||||
const body = context.res.body
|
}
|
||||||
|
|
||||||
assert(typeof body === 'object', 'context.res.body should exist as an object')
|
index.auditer.initialize(context, opts)
|
||||||
assert(body.hasOwnProperty('__audits'), 'body contains __audits property')
|
|
||||||
assert(body.__audits[0].extensionLog[0] === 'Hello, World!', 'log item 1 is added to audit correctly')
|
index.auditer.audit('Hello, World!', opts)
|
||||||
})
|
|
||||||
})
|
context.done()
|
||||||
|
|
||||||
|
assert(contextLogSpy.notCalled, 'context.log should not be called')
|
||||||
|
assert(contextDoneSpy.called, 'context.done should be called')
|
||||||
|
|
||||||
|
const body: IBody = context.res.body
|
||||||
|
|
||||||
|
assert(body.hasOwnProperty('__audits'), 'body contains __audits property')
|
||||||
|
assert(body.__audits && body.__audits[0].extensionLog[0] === 'Hello, World!',
|
||||||
|
'log item 1 is added to audit correctly')
|
||||||
|
})
|
||||||
|
})
|
|
@ -1,6 +1,6 @@
|
||||||
const assert = require('assert')
|
import * as assert from 'assert'
|
||||||
|
|
||||||
module.exports = {
|
export default {
|
||||||
accessToken: (req) => {
|
accessToken: (req) => {
|
||||||
assert.ok(req.body.accessToken)
|
assert.ok(req.body.accessToken)
|
||||||
assert.ok(typeof req.body.accessToken === 'string')
|
assert.ok(typeof req.body.accessToken === 'string')
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["es6"],
|
||||||
|
"module": "commonjs",
|
||||||
|
"outDir": "dist",
|
||||||
|
"sourceMap": true,
|
||||||
|
"target": "es6",
|
||||||
|
"strictNullChecks": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"tslint:recommended",
|
||||||
|
"tslint-config-standard"
|
||||||
|
]
|
||||||
|
}
|
Загрузка…
Ссылка в новой задаче