From 54ebf174a09970a0eb65603de0971691f88decaa Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Fri, 20 Sep 2019 09:47:49 +0200 Subject: [PATCH] grafana/toolkit: Add plugin scaffolding (#19207) --- packages/grafana-toolkit/README.md | 75 +++++---- .../grafana-toolkit/bin/grafana-toolkit.js | 1 + packages/grafana-toolkit/package.json | 6 +- packages/grafana-toolkit/src/cli/index.ts | 12 +- .../src/cli/tasks/plugin.create.ts | 47 ++++++ .../src/cli/tasks/plugin/create.ts | 150 ++++++++++++++++++ .../grafana-toolkit/src/cli/utils/execTask.ts | 11 +- .../grafana-toolkit/src/cli/utils/prompt.ts | 58 +++++++ .../grafana-toolkit/src/cli/utils/rmdir.ts | 23 +++ .../src/cli/utils/useSpinner.ts | 2 +- yarn.lock | 15 +- 11 files changed, 359 insertions(+), 41 deletions(-) create mode 100644 packages/grafana-toolkit/src/cli/tasks/plugin.create.ts create mode 100644 packages/grafana-toolkit/src/cli/tasks/plugin/create.ts create mode 100644 packages/grafana-toolkit/src/cli/utils/prompt.ts create mode 100644 packages/grafana-toolkit/src/cli/utils/rmdir.ts diff --git a/packages/grafana-toolkit/README.md b/packages/grafana-toolkit/README.md index f940b635433..21085954190 100644 --- a/packages/grafana-toolkit/README.md +++ b/packages/grafana-toolkit/README.md @@ -2,23 +2,27 @@ > **@grafana/toolkit is currently in ALPHA**. Core API is unstable and can be a subject of breaking changes! # grafana-toolkit -grafana-toolkit is CLI that enables efficient development of Grafana extensions +grafana-toolkit is CLI that enables efficient development of Grafana plugins ## Rationale -Historically, creating Grafana extension was an exercise of reverse engineering and ceremony around testing, developing and eventually building the plugin. We want to help our community to focus on the core value of their plugins rather than all the setup required to develop an extension. +Historically, creating Grafana plugin was an exercise of reverse engineering and ceremony around testing, developing and eventually building the plugin. We want to help our community to focus on the core value of their plugins rather than all the setup required to develop them. -## Installation +## Getting started -You can either add grafana-toolkit to your extension's `package.json` file by running -`yarn add @grafana/toolkit` or `npm instal @grafana/toolkit`, or use one of our extension templates: -- [React Panel](https://github.com/grafana/simple-react-panel) -- [Angular Panel](https://github.com/grafana/simple-angular-panel) +Setup new plugin with `grafana-toolkit plugin:create` command: -### Updating your extension to use grafana-toolkit -In order to start using grafana-toolkit in your extension you need to follow the steps below: -1. Add `@grafana/toolkit` package to your project -2. Create `tsconfig.json` file in the root dir of your extension and paste the code below: +```sh +npx grafana-toolkit plugin:create my-grafana-plugin +cd my-grafana-plugin +yarn install +yarn dev +``` + +### Updating your plugin to use grafana-toolkit +In order to start using grafana-toolkit in your existing plugin you need to follow the steps below: +1. Add `@grafana/toolkit` package to your project by running `yarn add @grafana/toolkit` or `npm install @grafana/toolkit` +2. Create `tsconfig.json` file in the root dir of your plugin and paste the code below: ```json { "extends": "./node_modules/@grafana/toolkit/src/config/tsconfig.plugin.json", @@ -31,7 +35,7 @@ In order to start using grafana-toolkit in your extension you need to follow the } ``` -3. Create `.prettierrc.js` file in the root dir of your extension and paste the code below: +3. Create `.prettierrc.js` file in the root dir of your plugin and paste the code below: ```js module.exports = { ...require("./node_modules/@grafana/toolkit/src/config/prettier.plugin.config.json"), @@ -49,13 +53,21 @@ module.exports = { ``` ## Usage -With grafana-toolkit we put in your hands a CLI that addresses common tasks performed when working on Grafana extension: -- `grafana-toolkit plugin:test` +With grafana-toolkit we put in your hands a CLI that addresses common tasks performed when working on Grafana plugin: +- `grafana-toolkit plugin:create` - `grafana-toolkit plugin:dev` +- `grafana-toolkit plugin:test` - `grafana-toolkit plugin:build` -### Developing extensions +### Creating plugin +`grafana-toolkit plugin:create plugin-name` + +Creates new Grafana plugin from template. + +If `plugin-name` is provided, the template will be downloaded to `./plugin-name` directory. Otherwise, it will be downloaded to current directory. + +### Developing plugin `grafana-toolkit plugin:dev` Creates development build that's easy to play with and debug using common browser tooling @@ -63,7 +75,7 @@ Creates development build that's easy to play with and debug using common browse Available options: - `-w`, `--watch` - run development task in a watch mode -### Testing extensions +### Testing plugin `grafana-toolkit plugin:test` Runs Jest against your codebase @@ -76,26 +88,29 @@ Available options: - `--testPathPattern=` - runs test with paths that match provided regex (https://jestjs.io/docs/en/cli#testpathpattern-regex) -### Building extensions +### Building plugin `grafana-toolkit plugin:build` -Creates production ready build of your extension +Creates production ready build of your plugin ## FAQ +### Which version should I use? +Please refer to [Grafana packages versioning guide](https://github.com/grafana/grafana/blob/master/packages/README.md#versioning) ### What tools does grafana-toolkit use? grafana-toolkit comes with Typescript, TSLint, Prettier, Jest, CSS and SASS support. -### How to start using grafana-toolkit in my extension? -See [Updating your extension to use grafana-toolkit](#updating-your-extension-to-use-grafana-toolkit) -### Can I use Typescript to develop Grafana extensions? +### How to start using grafana-toolkit in my plugin? +See [Updating your plugin to use grafana-toolkit](#updating-your-plugin-to-use-grafana-toolkit) + +### Can I use Typescript to develop Grafana plugins? Yes! grafana-toolkit supports Typescript by default. -### How can I test my extension? +### How can I test my plugin? grafana-toolkit comes with Jest as a test runner. -Internally at Grafana we use Enzyme. If you are developing React extension and you want to configure Enzyme as a testing utility, you need to configure `enzyme-adapter-react`. To do so create `/config/jest-setup.ts` file that will provide necessary setup. Copy the following code into that file to get Enzyme working with React: +Internally at Grafana we use Enzyme. If you are developing React plugin and you want to configure Enzyme as a testing utility, you need to configure `enzyme-adapter-react`. To do so create `/config/jest-setup.ts` file that will provide necessary setup. Copy the following code into that file to get Enzyme working with React: ```ts import { configure } from 'enzyme'; @@ -104,7 +119,7 @@ import Adapter from 'enzyme-adapter-react-16'; configure({ adapter: new Adapter() }); ``` -You can also setup Jest with shims of your needs by creating `jest-shim.ts` file in the same directory: `/config/jest-shim.ts` +You can also setup Jest with shims of your needs by creating `jest-shim.ts` file in the same directory: `/config/jest-shim.ts` ### Can I provide custom setup for Jest? @@ -114,7 +129,7 @@ Currently we support following Jest config properties: - [`snapshotSerializers`](https://jest-bot.github.io/jest/docs/configuration.html#snapshotserializers-array-string) - [`moduleNameMapper`](https://jestjs.io/docs/en/configuration#modulenamemapper-object-string-string) -### How can I style my extension? +### How can I style my plugin? We support pure CSS, SASS and CSS-in-JS approach (via [Emotion](https://emotion.sh/)). #### Single CSS or SASS file @@ -132,18 +147,18 @@ The styles will be injected via `style` tag during runtime. If you want to provide different stylesheets for dark/light theme, create `dark.[css|scss]` and `light.[css|scss]` files in `src/styles` directory of your plugin. grafana-toolkit will generate theme specific stylesheets that will end up in `dist/styles` directory. -In order for Grafana to pickup up you theme stylesheets you need to use `loadPluginCss` from `@grafana/runtime` package. Typically you would do that in the entrypoint of your extension: +In order for Grafana to pickup up you theme stylesheets you need to use `loadPluginCss` from `@grafana/runtime` package. Typically you would do that in the entrypoint of your plugin: ```ts import { loadPluginCss } from '@grafana/runtime'; loadPluginCss({ - dark: 'plugins//styles/dark.css', - light: 'plugins//styles/light.css', + dark: 'plugins//styles/dark.css', + light: 'plugins//styles/light.css', }); ``` -You need to add `@grafana/runtime` to your extension dependencies by running `yarn add @grafana/runtime` or `npm instal @grafana/runtime` +You need to add `@grafana/runtime` to your plugin dependencies by running `yarn add @grafana/runtime` or `npm instal @grafana/runtime` > Note that in this case static files (png, svg, json, html) are all copied to dist directory when the plugin is bundled. Relative paths to those files does not change! @@ -194,7 +209,7 @@ grafana-toolkit comes with [default config for TSLint](https://github.com/grafan ### How is Prettier integrated into grafana-toolkit workflow? -When building extension with [`grafana-toolkit plugin:build`](#building-extensions) task, grafana-toolkit performs Prettier check. If the check detects any Prettier issues, the build will not pass. To avoid such situation we suggest developing plugin with [`grafana-toolkit plugin:dev --watch`](#developing-extensions) task running. This task tries to fix Prettier issues automatically. +When building plugin with [`grafana-toolkit plugin:build`](#building-plugin) task, grafana-toolkit performs Prettier check. If the check detects any Prettier issues, the build will not pass. To avoid such situation we suggest developing plugin with [`grafana-toolkit plugin:dev --watch`](#developing-plugin) task running. This task tries to fix Prettier issues automatically. ### My editor does not respect Prettier config, what should I do? In order for your editor to pickup our Prettier config you need to create `.prettierrc.js` file in the root directory of your plugin with following content: diff --git a/packages/grafana-toolkit/bin/grafana-toolkit.js b/packages/grafana-toolkit/bin/grafana-toolkit.js index 9e867d9374d..9d1b79dc5d1 100755 --- a/packages/grafana-toolkit/bin/grafana-toolkit.js +++ b/packages/grafana-toolkit/bin/grafana-toolkit.js @@ -11,4 +11,5 @@ require('ts-node').register({ transpileOnly: true }); + require('../src/cli/index.ts').run(true); diff --git a/packages/grafana-toolkit/package.json b/packages/grafana-toolkit/package.json index 3d1357d8c0c..cae530db5a6 100644 --- a/packages/grafana-toolkit/package.json +++ b/packages/grafana-toolkit/package.json @@ -28,6 +28,9 @@ "dependencies": { "@babel/core": "7.4.5", "@babel/preset-env": "7.4.5", + "@grafana/data": "^6.4.0-alpha", + "@grafana/ui": "^6.4.0-alpha", + "@types/command-exists": "^1.2.0", "@types/execa": "^0.9.0", "@types/expect-puppeteer": "3.3.1", "@types/inquirer": "^6.0.3", @@ -40,12 +43,11 @@ "@types/tmp": "^0.1.0", "@types/webpack": "4.4.34", "aws-sdk": "^2.495.0", - "@grafana/data": "^6.4.0-alpha", - "@grafana/ui": "^6.4.0-alpha", "axios": "0.19.0", "babel-loader": "8.0.6", "babel-plugin-angularjs-annotate": "0.10.0", "chalk": "^2.4.2", + "command-exists": "^1.2.8", "commander": "^2.20.0", "concurrently": "4.1.0", "copy-webpack-plugin": "5.0.3", diff --git a/packages/grafana-toolkit/src/cli/index.ts b/packages/grafana-toolkit/src/cli/index.ts index ad2fa7c1c1b..b12b3d28814 100644 --- a/packages/grafana-toolkit/src/cli/index.ts +++ b/packages/grafana-toolkit/src/cli/index.ts @@ -21,6 +21,7 @@ import { ciPluginReportTask, } from './tasks/plugin.ci'; import { buildPackageTask } from './tasks/package.build'; +import { pluginCreateTask } from './tasks/plugin.create'; export const run = (includeInternalScripts = false) => { if (includeInternalScripts) { @@ -117,11 +118,18 @@ export const run = (includeInternalScripts = false) => { }); } + program + .command('plugin:create [name]') + .description('Creates plugin from template') + .action(async cmd => { + await execTask(pluginCreateTask)({ name: cmd, silent: true }); + }); + program .command('plugin:build') .description('Prepares plugin dist package') .action(async cmd => { - await execTask(pluginBuildTask)({ coverage: false }); + await execTask(pluginBuildTask)({ coverage: false, silent: true }); }); program @@ -133,6 +141,7 @@ export const run = (includeInternalScripts = false) => { await execTask(pluginDevTask)({ watch: !!cmd.watch, yarnlink: !!cmd.yarnlink, + silent: true, }); }); @@ -151,6 +160,7 @@ export const run = (includeInternalScripts = false) => { watch: !!cmd.watch, testPathPattern: cmd.testPathPattern, testNamePattern: cmd.testNamePattern, + silent: true, }); }); diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin.create.ts b/packages/grafana-toolkit/src/cli/tasks/plugin.create.ts new file mode 100644 index 00000000000..f95834bd83c --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/plugin.create.ts @@ -0,0 +1,47 @@ +import { prompt } from 'inquirer'; +import path from 'path'; + +import { Task, TaskRunner } from './task'; +import { promptConfirm } from '../utils/prompt'; +import { + getPluginIdFromName, + verifyGitExists, + promptPluginType, + fetchTemplate, + promptPluginDetails, + formatPluginDetails, + prepareJsonFiles, + removeGitFiles, +} from './plugin/create'; + +interface PluginCreateOptions { + name?: string; +} + +const pluginCreateRunner: TaskRunner = async ({ name }) => { + const destPath = path.resolve(process.cwd(), getPluginIdFromName(name || '')); + let pluginDetails; + + // 1. Verifying if git exists in user's env as templates are cloned from git templates + await verifyGitExists(); + + // 2. Prompt plugin template + const { type } = await promptPluginType(); + + // 3. Fetch plugin template from Github + await fetchTemplate({ type, dest: destPath }); + + // 4. Prompt plugin details + do { + pluginDetails = await promptPluginDetails(name); + formatPluginDetails(pluginDetails); + } while ((await prompt<{ confirm: boolean }>(promptConfirm('confirm', 'Is that ok?'))).confirm === false); + + // 5. Update json files (package.json, src/plugin.json) + await prepareJsonFiles({ pluginDetails, pluginPath: destPath }); + + // 6. Remove cloned repository .git dir + await removeGitFiles(destPath); +}; + +export const pluginCreateTask = new Task('plugin:create task', pluginCreateRunner); diff --git a/packages/grafana-toolkit/src/cli/tasks/plugin/create.ts b/packages/grafana-toolkit/src/cli/tasks/plugin/create.ts new file mode 100644 index 00000000000..d2a4c9b2f52 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/tasks/plugin/create.ts @@ -0,0 +1,150 @@ +import commandExists from 'command-exists'; +import { readFileSync, promises as fs } from 'fs'; +import { prompt } from 'inquirer'; +import kebabCase from 'lodash/kebabCase'; +import path from 'path'; +import gitPromise from 'simple-git/promise'; + +import { useSpinner } from '../../utils/useSpinner'; +import { rmdir } from '../../utils/rmdir'; +import { promptInput, promptConfirm } from '../../utils/prompt'; +import chalk from 'chalk'; + +const simpleGit = gitPromise(process.cwd()); + +interface PluginDetails { + name: string; + org: string; + description: string; + author: boolean | string; + url: string; + keywords: string; +} + +type PluginType = 'angular-panel' | 'react-panel' | 'datasource-plugin'; + +const RepositoriesPaths = { + 'angular-panel': 'git@github.com:grafana/simple-angular-panel.git', + 'react-panel': 'git@github.com:grafana/simple-react-panel.git', + 'datasource-plugin': 'git@github.com:grafana/simple-datasource.git', +}; + +export const getGitUsername = async () => await simpleGit.raw(['config', '--global', 'user.name']); +export const getPluginIdFromName = (name: string) => kebabCase(name); +export const getPluginId = (pluginDetails: PluginDetails) => + `${kebabCase(pluginDetails.org)}-${getPluginIdFromName(pluginDetails.name)}`; + +export const getPluginKeywords = (pluginDetails: PluginDetails) => + pluginDetails.keywords + .split(',') + .map(k => k.trim()) + .filter(k => k !== ''); + +export const verifyGitExists = async () => { + return new Promise((resolve, reject) => { + commandExists('git', (err, exists) => { + if (exists) { + resolve(true); + } + reject(new Error('git is not installed')); + }); + }); +}; + +export const promptPluginType = async () => + prompt<{ type: PluginType }>([ + { + type: 'list', + message: 'Select plugin type', + name: 'type', + choices: [ + { name: 'Angular panel', value: 'angular-panel' }, + { name: 'React panel', value: 'react-panel' }, + { name: 'Datasource plugin', value: 'datasource-plugin' }, + ], + }, + ]); + +export const promptPluginDetails = async (name?: string) => { + const username = (await getGitUsername()).trim(); + const responses = await prompt([ + promptInput('name', 'Plugin name', true, name), + promptInput('org', 'Organization (used as part of plugin ID)', true), + promptInput('description', 'Description'), + promptInput('keywords', 'Keywords (separated by comma)'), + // Try using git specified username + promptConfirm('author', `Author (${username})`, username, username !== ''), + // Prompt for manual author entry if no git user.name specifed + promptInput('author', `Author`, true, undefined, answers => !answers.author || username === ''), + promptInput('url', 'Your URL (i.e. organisation url)'), + ]); + + return { + ...responses, + author: responses.author === true ? username : responses.author, + }; +}; + +export const fetchTemplate = useSpinner<{ type: PluginType; dest: string }>( + 'Fetching plugin template...', + async ({ type, dest }) => { + const url = RepositoriesPaths[type]; + if (!url) { + throw new Error('Unknown plugin type'); + } + + await simpleGit.clone(url, dest); + } +); + +export const prepareJsonFiles = useSpinner<{ pluginDetails: PluginDetails; pluginPath: string }>( + 'Saving package.json and plugin.json files', + async ({ pluginDetails, pluginPath }) => { + const packageJsonPath = path.resolve(pluginPath, 'package.json'); + const pluginJsonPath = path.resolve(pluginPath, 'src/plugin.json'); + const packageJson: any = JSON.parse(readFileSync(packageJsonPath, 'utf8')); + const pluginJson: any = JSON.parse(readFileSync(pluginJsonPath, 'utf8')); + + const pluginId = `${kebabCase(pluginDetails.org)}-${getPluginIdFromName(pluginDetails.name)}`; + packageJson.name = pluginId; + packageJson.author = pluginDetails.author; + packageJson.description = pluginDetails.description; + + pluginJson.name = pluginDetails.name; + pluginJson.id = pluginId; + pluginJson.info = { + ...pluginJson.info, + description: pluginDetails.description, + author: { + name: pluginDetails.author, + url: pluginDetails.url, + }, + keywords: getPluginKeywords(pluginDetails), + }; + + await Promise.all( + [packageJson, pluginJson].map((f, i) => { + const filePath = i === 0 ? packageJsonPath : pluginJsonPath; + return fs.writeFile(filePath, JSON.stringify(f, null, 2)); + }) + ); + } +); + +export const removeGitFiles = useSpinner('Cleaning', async pluginPath => rmdir(`${path.resolve(pluginPath, '.git')}`)); + +export const formatPluginDetails = (details: PluginDetails) => { + console.group(); + console.log(); + console.log(chalk.bold.yellow('Your plugin details')); + console.log('---'); + console.log(chalk.bold('Name: '), details.name); + console.log(chalk.bold('ID: '), getPluginId(details)); + console.log(chalk.bold('Description: '), details.description); + console.log(chalk.bold('Keywords: '), getPluginKeywords(details)); + console.log(chalk.bold('Author: '), details.author); + console.log(chalk.bold('Organisation: '), details.org); + console.log(chalk.bold('Website: '), details.url); + console.log(); + console.groupEnd(); +}; diff --git a/packages/grafana-toolkit/src/cli/utils/execTask.ts b/packages/grafana-toolkit/src/cli/utils/execTask.ts index 8bbcc4d9dbf..3ae891e65bb 100644 --- a/packages/grafana-toolkit/src/cli/utils/execTask.ts +++ b/packages/grafana-toolkit/src/cli/utils/execTask.ts @@ -1,8 +1,15 @@ import { Task } from '../tasks/task'; import chalk from 'chalk'; -export const execTask = (task: Task) => async (options: TOptions) => { - console.log(chalk.yellow(`Running ${chalk.bold(task.name)} task`)); +interface TaskBasicOptions { + // Don't print task details when running + silent?: boolean; +} + +export const execTask = (task: Task) => async (options: TOptions & TaskBasicOptions) => { + if (!options.silent) { + console.log(chalk.yellow(`Running ${chalk.bold(task.name)} task`)); + } task.setOptions(options); try { console.group(); diff --git a/packages/grafana-toolkit/src/cli/utils/prompt.ts b/packages/grafana-toolkit/src/cli/utils/prompt.ts new file mode 100644 index 00000000000..5cdf9211056 --- /dev/null +++ b/packages/grafana-toolkit/src/cli/utils/prompt.ts @@ -0,0 +1,58 @@ +import { + Question, + InputQuestion, + CheckboxQuestion, + NumberQuestion, + PasswordQuestion, + EditorQuestion, + ConfirmQuestion, +} from 'inquirer'; + +type QuestionWithValidation = + | InputQuestion + | CheckboxQuestion + | NumberQuestion + | PasswordQuestion + | EditorQuestion; + +export const answerRequired = (question: QuestionWithValidation): Question => { + return { + ...question, + validate: (answer: any) => answer.trim() !== '' || `${question.name} is required`, + }; +}; + +export const promptInput = ( + name: string, + message: string | ((answers: A) => string), + required = false, + def: any = undefined, + when: boolean | ((answers: A) => boolean | Promise) = true +) => { + const model: InputQuestion = { + type: 'input', + name, + message, + default: def, + when, + }; + + return required ? answerRequired(model) : model; +}; + +export const promptConfirm = ( + name: string, + message: string | ((answers: A) => string), + def: any = undefined, + when: boolean | ((answers: A) => boolean | Promise) = true +) => { + const model: ConfirmQuestion = { + type: 'confirm', + name, + message, + default: def, + when, + }; + + return model; +}; diff --git a/packages/grafana-toolkit/src/cli/utils/rmdir.ts b/packages/grafana-toolkit/src/cli/utils/rmdir.ts new file mode 100644 index 00000000000..b352081c8ff --- /dev/null +++ b/packages/grafana-toolkit/src/cli/utils/rmdir.ts @@ -0,0 +1,23 @@ +import fs = require('fs'); +import path = require('path'); + +/** + * Remove directory recursively + * Ref https://stackoverflow.com/a/42505874 + */ +export const rmdir = (dirPath: string) => { + if (!fs.existsSync(dirPath)) { + return; + } + + fs.readdirSync(dirPath).forEach(entry => { + const entryPath = path.join(dirPath, entry); + if (fs.lstatSync(entryPath).isDirectory()) { + rmdir(entryPath); + } else { + fs.unlinkSync(entryPath); + } + }); + + fs.rmdirSync(dirPath); +}; diff --git a/packages/grafana-toolkit/src/cli/utils/useSpinner.ts b/packages/grafana-toolkit/src/cli/utils/useSpinner.ts index 8f0311cc6ee..8deeca514d5 100644 --- a/packages/grafana-toolkit/src/cli/utils/useSpinner.ts +++ b/packages/grafana-toolkit/src/cli/utils/useSpinner.ts @@ -2,7 +2,7 @@ import ora from 'ora'; type FnToSpin = (options: T) => Promise; -export const useSpinner = (spinnerLabel: string, fn: FnToSpin, killProcess = true) => { +export const useSpinner = (spinnerLabel: string, fn: FnToSpin, killProcess = true) => { return async (options: T) => { const spinner = ora(spinnerLabel); spinner.start(); diff --git a/yarn.lock b/yarn.lock index 3493e5c28db..3d61290ab67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2711,6 +2711,11 @@ version "2.0.1" resolved "https://registry.yarnpkg.com/@types/clipboard/-/clipboard-2.0.1.tgz#75a74086c293d75b12bc93ff13bc7797fef05a40" +"@types/command-exists@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@types/command-exists/-/command-exists-1.2.0.tgz#d97e0ed10097090e4ab0367ed425b0312fad86f3" + integrity sha512-ugsxEJfsCuqMLSuCD4PIJkp5Uk2z6TCMRCgYVuhRo5cYQY3+1xXTQkSlPtkpGHuvWMjS2KTeVQXxkXRACMbM6A== + "@types/connect-history-api-fallback@*": version "1.3.3" resolved "https://registry.yarnpkg.com/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.3.3.tgz#4772b79b8b53185f0f4c9deab09236baf76ee3b4" @@ -5207,11 +5212,6 @@ caniuse-api@^3.0.0: lodash.memoize "^4.1.2" lodash.uniq "^4.5.0" -caniuse-db@1.0.30000772: - version "1.0.30000772" - resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000772.tgz#51aae891768286eade4a3d8319ea76d6a01b512b" - integrity sha1-UarokXaChureSj2DGep21qAbUSs= - caniuse-lite@^1.0.0, caniuse-lite@^1.0.30000929, caniuse-lite@^1.0.30000947, caniuse-lite@^1.0.30000957, caniuse-lite@^1.0.30000963: version "1.0.30000966" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30000966.tgz#f3c6fefacfbfbfb981df6dfa68f2aae7bff41b64" @@ -5635,6 +5635,11 @@ comma-separated-tokens@^1.0.0: version "1.0.6" resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-1.0.6.tgz#3cd3d8adc725ab473843db338bcdfd4a7bb087bf" +command-exists@^1.2.8: + version "1.2.8" + resolved "https://registry.yarnpkg.com/command-exists/-/command-exists-1.2.8.tgz#715acefdd1223b9c9b37110a149c6392c2852291" + integrity sha512-PM54PkseWbiiD/mMsbvW351/u+dafwTJ0ye2qB60G1aGQP9j3xK2gmMDc+R34L3nDtx4qMCitXT75mkbkGJDLw== + commander@2, commander@^2.12.1, commander@^2.14.1, commander@^2.18.0, commander@^2.19.0, commander@^2.20.0, commander@^2.8.1, commander@^2.9.0, commander@~2.20.0: version "2.20.0" resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422"