瀏覽代碼

grafana/toolkit: improve CircleCI stubs (#17995)

* validate type and id

* copy all svg and png, useful if people don't use the img folder

* update comments

* add stubs for each ci task

* use ci-work folder rather than build

* use axios for basic testing

* Packages: publish packages@6.3.0-alpha.39

* bump version

* add download task

* Packages: publish packages@6.3.0-alpha.40

* merge all dist folders into one

* fix folder paths

* Fix ts error

* Packages: publish packages@6.3.0-beta.0

* Packages: publish packages@6.3.0-beta.1

* bump next to 6.4

* Packages: publish packages@6.4.0-alpha.2

* better build and bundle tasks

* fix lint

* Packages: publish packages@6.4.0-alpha.3

* copy the file to start grafana

* Packages: publish packages@6.4.0-alpha.4

* use sudo for copy

* Packages: publish packages@6.4.0-alpha.5

* add missing service

* add service and homepath

* Packages: publish packages@6.4.0-alpha.6

* make the folder

* Update packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts

* Update packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts
Ryan McKinley 6 年之前
父節點
當前提交
7ec87ee76b

+ 1 - 1
lerna.json

@@ -2,5 +2,5 @@
   "npmClient": "yarn",
   "useWorkspaces": true,
   "packages": ["packages/*"],
-  "version": "6.3.0-alpha.36"
+  "version": "6.4.0-alpha.6"
 }

+ 1 - 1
package.json

@@ -148,7 +148,7 @@
     "themes:generate": "ts-node --project ./scripts/cli/tsconfig.json ./scripts/cli/generateSassVariableFiles.ts",
     "packages:prepare": "lerna run clean && npm run test && lerna version --tag-version-prefix=\"packages@\" -m \"Packages: publish %s\" --no-push",
     "packages:build": "lerna run clean && lerna run build",
-    "packages:publish": "lerna publish from-package --contents dist --tag-version-prefix=\"packages@\" --dist-tag next"
+    "packages:publish": "lerna publish from-package --contents dist --dist-tag next --tag-version-prefix=\"packages@\""
   },
   "husky": {
     "hooks": {

+ 1 - 1
packages/grafana-data/README.md

@@ -1,3 +1,3 @@
 # Grafana Data Library
 
-The core data components
+This package holds the root data types and functions used within Grafana.

+ 1 - 1
packages/grafana-data/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@grafana/data",
-  "version": "6.3.0-alpha.36",
+  "version": "6.4.0-alpha.2",
   "description": "Grafana Data Library",
   "keywords": [
     "typescript"

+ 1 - 1
packages/grafana-runtime/README.md

@@ -1,3 +1,3 @@
 # Grafana Runtime library
 
-Interfaces that let you use the runtime...
+This package allows access to grafana services.  It requires Grafana to be running already and the functions to be imported as externals. 

+ 2 - 4
packages/grafana-runtime/package.json

@@ -1,11 +1,9 @@
 {
   "name": "@grafana/runtime",
-  "version": "6.3.0-alpha.36",
+  "version": "6.4.0-alpha.2",
   "description": "Grafana Runtime Library",
   "keywords": [
-    "typescript",
-    "react",
-    "react-component"
+    "grafana"
   ],
   "main": "src/index.ts",
   "scripts": {

+ 5 - 4
packages/grafana-toolkit/package.json

@@ -1,11 +1,11 @@
 {
   "name": "@grafana/toolkit",
-  "version": "6.3.0-alpha.36",
+  "version": "6.4.0-alpha.6",
   "description": "Grafana Toolkit",
   "keywords": [
-    "typescript",
-    "react",
-    "react-component"
+    "grafana",
+    "cli",
+    "plugins"
   ],
   "bin": {
     "grafana-toolkit": "./bin/grafana-toolkit.js"
@@ -30,6 +30,7 @@
     "@types/node": "^12.0.4",
     "@types/react-dev-utils": "^9.0.1",
     "@types/semver": "^6.0.0",
+    "@types/tmp": "^0.1.0",
     "@types/webpack": "4.4.34",
     "axios": "0.19.0",
     "babel-loader": "8.0.6",

+ 44 - 6
packages/grafana-toolkit/src/cli/index.ts

@@ -13,7 +13,13 @@ import { pluginTestTask } from './tasks/plugin.tests';
 import { searchTestDataSetupTask } from './tasks/searchTestDataSetup';
 import { closeMilestoneTask } from './tasks/closeMilestone';
 import { pluginDevTask } from './tasks/plugin.dev';
-import { pluginCITask } from './tasks/plugin.ci';
+import {
+  ciBuildPluginTask,
+  ciBundlePluginTask,
+  ciTestPluginTask,
+  ciDeployPluginTask,
+  ciSetupPluginTask,
+} from './tasks/plugin.ci';
 import { buildPackageTask } from './tasks/package.build';
 
 export const run = (includeInternalScripts = false) => {
@@ -141,15 +147,47 @@ export const run = (includeInternalScripts = false) => {
     });
 
   program
-    .command('plugin:ci')
-    .option('--dryRun', "Dry run (don't post results)")
-    .description('Run Plugin CI task')
+    .command('plugin:ci-build')
+    .option('--platform <platform>', 'For backend task, which backend to run')
+    .description('Build the plugin, leaving artifacts in /dist')
     .action(async cmd => {
-      await execTask(pluginCITask)({
-        dryRun: cmd.dryRun,
+      await execTask(ciBuildPluginTask)({
+        platform: cmd.platform,
       });
     });
 
+  program
+    .command('plugin:ci-bundle')
+    .description('Create a zip artifact for the plugin')
+    .action(async cmd => {
+      await execTask(ciBundlePluginTask)({});
+    });
+
+  program
+    .command('plugin:ci-setup')
+    .option('--installer <installer>', 'Name of installer to download and run')
+    .description('Install and configure grafana')
+    .action(async cmd => {
+      await execTask(ciSetupPluginTask)({
+        installer: cmd.installer,
+      });
+    });
+  program
+    .command('plugin:ci-test')
+    .description('end-to-end test using bundle in /artifacts')
+    .action(async cmd => {
+      await execTask(ciTestPluginTask)({
+        platform: cmd.platform,
+      });
+    });
+
+  program
+    .command('plugin:ci-deploy')
+    .description('Publish plugin CI results')
+    .action(async cmd => {
+      await execTask(ciDeployPluginTask)({});
+    });
+
   program.on('command:*', () => {
     console.error('Invalid command: %s\nSee --help for a list of available commands.', program.args.join(' '));
     process.exit(1);

+ 327 - 23
packages/grafana-toolkit/src/cli/tasks/plugin.ci.ts

@@ -9,7 +9,8 @@ import path = require('path');
 import fs = require('fs');
 
 export interface PluginCIOptions {
-  dryRun?: boolean;
+  platform?: string;
+  installer?: string;
 }
 
 const calcJavascriptSize = (base: string, files?: string[]): number => {
@@ -32,22 +33,164 @@ const calcJavascriptSize = (base: string, files?: string[]): number => {
   return size;
 };
 
-const pluginCIRunner: TaskRunner<PluginCIOptions> = async ({ dryRun }) => {
+const getJobFromProcessArgv = () => {
+  const arg = process.argv[2];
+  if (arg && arg.startsWith('plugin:ci-')) {
+    const task = arg.substring('plugin:ci-'.length);
+    if ('build' === task) {
+      if ('--platform' === process.argv[3] && process.argv[4]) {
+        return task + '_' + process.argv[4];
+      }
+      return 'build_nodejs';
+    }
+    return task;
+  }
+  return 'unknown_job';
+};
+
+// /**
+//  * Like cp -rn... BUT error if an destination file exists
+//  */
+// async function copyDirErrorIfExists(src:string,dest:string) {
+//   const entries = await fs.readdirSync(src,{withFileTypes:true});
+//   if(!fs.existsSync(dest)) {
+//     fs.mkdirSync(dest);
+//   }
+//   console.log( 'DIR', src );
+//   for(let entry of entries) {
+//     const srcPath = path.join(src,entry.name);
+//     const destPath = path.join(dest,entry.name);
+//     if(entry.isDirectory()) {
+//       await copyDirErrorIfExists(srcPath,destPath);
+//     } else if(fs.existsSync(destPath)) {
+//       console.log( 'XXXXXXXXXXXXXXX', destPath );
+//       console.log( 'XXXXXXXXXXXXXXX', destPath );
+//       throw new Error('Duplicate entry: '+destPath);
+//     }
+//     else {
+//     //  console.log( 'COPY', destPath );
+//       await fs.copyFileSync(srcPath,destPath);
+//     }
+//   }
+// }
+
+const job = process.env.CIRCLE_JOB || getJobFromProcessArgv();
+
+const getJobFolder = () => {
+  const dir = path.resolve(process.cwd(), 'ci', 'jobs', job);
+  if (!fs.existsSync(dir)) {
+    fs.mkdirSync(dir, { recursive: true });
+  }
+  return dir;
+};
+
+const getCiFolder = () => {
+  const dir = path.resolve(process.cwd(), 'ci');
+  if (!fs.existsSync(dir)) {
+    fs.mkdirSync(dir, { recursive: true });
+  }
+  return dir;
+};
+
+const writeJobStats = (startTime: number, workDir: string) => {
+  const stats = {
+    job,
+    startTime,
+    endTime: Date.now(),
+  };
+  const f = path.resolve(workDir, 'stats.json');
+  fs.writeFile(f, JSON.stringify(stats, null, 2), err => {
+    if (err) {
+      throw new Error('Unable to stats: ' + f);
+    }
+  });
+};
+
+/**
+ * 1. BUILD
+ *
+ *  when platform exists it is building backend, otherwise frontend
+ *
+ *  Each build writes data:
+ *   ~/work/build_xxx/
+ *
+ *  Anything that should be put into the final zip file should be put in:
+ *   ~/work/build_xxx/dist
+ */
+const buildPluginRunner: TaskRunner<PluginCIOptions> = async ({ platform }) => {
   const start = Date.now();
-  const distDir = `${process.cwd()}/dist`;
-  const artifactsDir = `${process.cwd()}/artifacts`;
-  await execa('rimraf', [`${process.cwd()}/coverage`]);
-  await execa('rimraf', [artifactsDir]);
+  const workDir = getJobFolder();
+  await execa('rimraf', [workDir]);
+  fs.mkdirSync(workDir);
 
-  // Do regular build process
-  await pluginBuildRunner({ coverage: true });
-  const elapsed = Date.now() - start;
+  if (platform) {
+    console.log('TODO, backend support?');
+    fs.mkdirSync(path.resolve(process.cwd(), 'dist'));
+    const file = path.resolve(process.cwd(), 'dist', `README_${platform}.txt`);
+    fs.writeFile(file, `TODO... build ${platform}!`, err => {
+      if (err) {
+        throw new Error('Unable to write: ' + file);
+      }
+    });
+  } else {
+    // Do regular build process with coverage
+    await pluginBuildRunner({ coverage: true });
+  }
+
+  // Move local folders to the scoped job folder
+  for (const name of ['dist', 'coverage']) {
+    const dir = path.resolve(process.cwd(), name);
+    if (fs.existsSync(dir)) {
+      fs.renameSync(dir, path.resolve(workDir, name));
+    }
+  }
+  writeJobStats(start, workDir);
+};
 
-  if (!fs.existsSync(artifactsDir)) {
-    fs.mkdirSync(artifactsDir);
+export const ciBuildPluginTask = new Task<PluginCIOptions>('Build Plugin', buildPluginRunner);
+
+/**
+ * 2. BUNDLE
+ *
+ *  Take everything from `~/ci/job/{any}/dist` and
+ *  1. merge it into: `~/ci/dist`
+ *  2. zip it into artifacts in `~/ci/artifacts`
+ *  3. prepare grafana environment in: `~/ci/grafana-test-env`
+ *
+ */
+const bundlePluginRunner: TaskRunner<PluginCIOptions> = async () => {
+  const start = Date.now();
+  const ciDir = getCiFolder();
+  const artifactsDir = path.resolve(ciDir, 'artifacts');
+  const distDir = path.resolve(ciDir, 'dist');
+  const grafanaEnvDir = path.resolve(ciDir, 'grafana-test-env');
+  await execa('rimraf', [artifactsDir, distDir, grafanaEnvDir]);
+  fs.mkdirSync(artifactsDir);
+  fs.mkdirSync(distDir);
+  fs.mkdirSync(grafanaEnvDir);
+
+  console.log('Build Dist Folder');
+
+  // 1. Check for a local 'dist' folder
+  const d = path.resolve(process.cwd(), 'dist');
+  if (fs.existsSync(d)) {
+    await execa('cp', ['-rn', d + '/.', distDir]);
   }
 
-  // TODO? can this typed from @grafana/ui?
+  // 2. Look for any 'dist' folders under ci/job/XXX/dist
+  const dirs = fs.readdirSync(path.resolve(ciDir, 'jobs'));
+  for (const j of dirs) {
+    const contents = path.resolve(ciDir, 'jobs', j, 'dist');
+    if (fs.existsSync(contents)) {
+      try {
+        await execa('cp', ['-rn', contents + '/.', distDir]);
+      } catch (er) {
+        throw new Error('Duplicate files found in dist folders');
+      }
+    }
+  }
+
+  console.log('Building ZIP');
   const pluginInfo = getPluginJson(`${distDir}/plugin.json`);
   const zipName = pluginInfo.id + '-' + pluginInfo.info.version + '.zip';
   const zipFile = path.resolve(artifactsDir, zipName);
@@ -55,23 +198,184 @@ const pluginCIRunner: TaskRunner<PluginCIOptions> = async ({ dryRun }) => {
   await execa('zip', ['-r', zipFile, '.']);
   restoreCwd();
 
+  const zipStats = fs.statSync(zipFile);
+  if (zipStats.size < 100) {
+    throw new Error('Invalid zip file: ' + zipFile);
+  }
+  let sha1 = undefined;
+  try {
+    const exe = await execa('shasum', [zipFile]);
+    const idx = exe.stdout.indexOf(' ');
+    sha1 = exe.stdout.substring(0, idx);
+    fs.writeFile(zipFile + '.sha1', sha1, err => {});
+  } catch {
+    console.warn('Unable to read SHA1 Checksum');
+  }
+
+  const info = {
+    name: zipName,
+    sha1,
+    size: zipStats.size,
+  };
+  let p = path.resolve(artifactsDir, 'info.json');
+  fs.writeFile(p, JSON.stringify(info, null, 2), err => {
+    if (err) {
+      throw new Error('Error writing artifact info: ' + p);
+    }
+  });
+
+  console.log('Setup Grafan Environment');
+  p = path.resolve(grafanaEnvDir, 'plugins', pluginInfo.id);
+  fs.mkdirSync(p, { recursive: true });
+  await execa('unzip', [zipFile, '-d', p]);
+
+  // Write the custom settings
+  p = path.resolve(grafanaEnvDir, 'custom.ini');
+  const customIniBody =
+    `# Autogenerated by @grafana/toolkit \n` +
+    `[paths] \n` +
+    `plugins = ${path.resolve(grafanaEnvDir, 'plugins')}\n` +
+    `\n`; // empty line
+  fs.writeFile(p, customIniBody, err => {
+    if (err) {
+      throw new Error('Unable to write: ' + p);
+    }
+  });
+
+  writeJobStats(start, getJobFolder());
+};
+
+export const ciBundlePluginTask = new Task<PluginCIOptions>('Bundle Plugin', bundlePluginRunner);
+
+/**
+ * 3. Setup (install grafana and setup provisioning)
+ *
+ *  deploy the zip to a running grafana instance
+ *
+ */
+const setupPluginRunner: TaskRunner<PluginCIOptions> = async ({ installer }) => {
+  const start = Date.now();
+
+  if (!installer) {
+    throw new Error('Missing installer path');
+  }
+
+  // Download the grafana installer
+  const installDir = path.resolve(process.cwd(), '.installer');
+  const installFile = path.resolve(installDir, installer);
+  if (!fs.existsSync(installFile)) {
+    if (!fs.existsSync(installDir)) {
+      fs.mkdirSync(installDir);
+    }
+    console.log('download', installer);
+    const exe = await execa('wget', ['-O', installFile, 'https://dl.grafana.com/oss/release/' + installer]);
+    console.log(exe.stdout);
+  }
+
+  console.log('Install Grafana');
+  let exe = await execa('sudo', ['apt-get', 'install', '-y', 'adduser', 'libfontconfig1']);
+  exe = await execa('sudo', ['dpkg', '-i', installFile]);
+  console.log(exe.stdout);
+
+  const customIniFile = path.resolve(getCiFolder(), 'grafana-test-env', 'custom.ini');
+  const configDir = '/usr/share/grafana/conf/';
+  exe = await execa('sudo', ['cp', '-f', customIniFile, configDir]);
+  console.log(exe.stdout);
+
+  // sudo service grafana-server start
+  console.log('Starting Grafana');
+  exe = await execa('sudo', ['service', 'grafana-server', 'start']);
+  console.log(exe.stdout);
+  // exe = await execa('grafana-cli', ['--version', '--homepath', '/usr/share/grafana']);
+  // console.log(exe.stdout);
+  // exe = await execa('grafana-cli', ['plugins', 'ls', '--homepath', '/usr/share/grafana']);
+  // console.log(exe.stdout);
+
+  const dir = getJobFolder() + '_setup';
+  await execa('rimraf', [dir]);
+  fs.mkdirSync(dir);
+  writeJobStats(start, dir);
+};
+
+export const ciSetupPluginTask = new Task<PluginCIOptions>('Setup Grafana', setupPluginRunner);
+
+/**
+ * 4. Test (end-to-end)
+ *
+ *  deploy the zip to a running grafana instance
+ *
+ */
+const testPluginRunner: TaskRunner<PluginCIOptions> = async ({ platform }) => {
+  const start = Date.now();
+  const workDir = getJobFolder();
+
+  const args = {
+    withCredentials: true,
+    baseURL: process.env.GRAFANA_URL || 'http://localhost:3000/',
+    responseType: 'json',
+    auth: {
+      username: 'admin',
+      password: 'admin',
+    },
+  };
+
+  const axios = require('axios');
+  const frontendSettings = await axios.get('api/frontend/settings', args);
+
+  console.log('Grafana Version: ' + JSON.stringify(frontendSettings.data.buildInfo, null, 2));
+
+  const pluginInfo = getPluginJson(`${process.cwd()}/src/plugin.json`);
+  const pluginSettings = await axios.get(`api/plugins/${pluginInfo.id}/settings`, args);
+
+  console.log('Plugin Info: ' + JSON.stringify(pluginSettings.data, null, 2));
+
+  console.log('TODO puppeteer');
+
+  const elapsed = Date.now() - start;
   const stats = {
+    job,
+    sha1: `${process.env.CIRCLE_SHA1}`,
     startTime: start,
     buildTime: elapsed,
-    jsSize: calcJavascriptSize(distDir),
-    zipSize: fs.statSync(zipFile).size,
     endTime: Date.now(),
   };
-  fs.writeFile(artifactsDir + '/stats.json', JSON.stringify(stats, null, 2), err => {
-    if (err) {
-      throw new Error('Unable to write stats');
-    }
-    console.log('Stats', stats);
-  });
 
-  if (!dryRun) {
-    console.log('TODO send info to github?');
+  console.log('TODO Puppeteer Tests', stats);
+  writeJobStats(start, workDir);
+};
+
+export const ciTestPluginTask = new Task<PluginCIOptions>('Test Plugin (e2e)', testPluginRunner);
+
+/**
+ * 4. Deploy
+ *
+ *  deploy the zip to a running grafana instance
+ *
+ */
+const deployPluginRunner: TaskRunner<PluginCIOptions> = async () => {
+  const start = Date.now();
+
+  // TASK Time
+  if (process.env.CIRCLE_INTERNAL_TASK_DATA) {
+    const timingInfo = fs.readdirSync(`${process.env.CIRCLE_INTERNAL_TASK_DATA}`);
+    if (timingInfo) {
+      timingInfo.forEach(file => {
+        console.log('TIMING INFO: ', file);
+      });
+    }
   }
+
+  const elapsed = Date.now() - start;
+  const stats = {
+    job,
+    sha1: `${process.env.CIRCLE_SHA1}`,
+    startTime: start,
+    buildTime: elapsed,
+    endTime: Date.now(),
+  };
+  console.log('TODO DEPLOY??', stats);
+  console.log(' if PR => write a comment to github with difference ');
+  console.log(' if master | vXYZ ==> upload artifacts to some repo ');
 };
 
-export const pluginCITask = new Task<PluginCIOptions>('Plugin CI', pluginCIRunner);
+export const ciDeployPluginTask = new Task<PluginCIOptions>('Deploy plugin', deployPluginRunner);

+ 1 - 1
packages/grafana-toolkit/src/config/utils/pluginValidation.test.ts

@@ -3,7 +3,7 @@ import { getPluginJson, validatePluginJson } from './pluginValidation';
 describe('pluginValdation', () => {
   describe('plugin.json', () => {
     test('missing plugin.json file', () => {
-      expect(() => getPluginJson(`${__dirname}/mocks/missing-plugin-json`)).toThrow('plugin.json file is missing!');
+      expect(() => getPluginJson(`${__dirname}/mocks/missing-plugin.json`)).toThrowError();
     });
   });
 

+ 13 - 6
packages/grafana-toolkit/src/config/utils/pluginValidation.ts

@@ -1,5 +1,3 @@
-import path = require('path');
-
 // See: packages/grafana-ui/src/types/plugin.ts
 interface PluginJSONSchema {
   id: string;
@@ -22,15 +20,24 @@ export const validatePluginJson = (pluginJson: any) => {
   if (!pluginJson.info.version) {
     throw new Error('Plugin info.version is missing in plugin.json');
   }
+
+  const types = ['panel', 'datasource', 'app'];
+  const type = pluginJson.type;
+  if (!types.includes(type)) {
+    throw new Error('Invalid plugin type in plugin.json: ' + type);
+  }
+
+  if (!pluginJson.id.endsWith('-' + type)) {
+    throw new Error('[plugin.json] id should end with: -' + type);
+  }
 };
 
-export const getPluginJson = (root: string = process.cwd()): PluginJSONSchema => {
+export const getPluginJson = (path: string): PluginJSONSchema => {
   let pluginJson;
-
   try {
-    pluginJson = require(path.resolve(root, 'src/plugin.json'));
+    pluginJson = require(path);
   } catch (e) {
-    throw new Error('plugin.json file is missing!');
+    throw new Error('Unable to find: ' + path);
   }
 
   validatePluginJson(pluginJson);

+ 2 - 2
packages/grafana-ui/package.json

@@ -1,9 +1,9 @@
 {
   "name": "@grafana/ui",
-  "version": "6.3.0-alpha.36",
+  "version": "6.4.0-alpha.2",
   "description": "Grafana Components Library",
   "keywords": [
-    "typescript",
+    "grafana",
     "react",
     "react-component"
   ],

+ 12 - 0
yarn.lock

@@ -3371,6 +3371,11 @@
   resolved "https://registry.yarnpkg.com/@types/tinycolor2/-/tinycolor2-1.4.2.tgz#721ca5c5d1a2988b4a886e35c2ffc5735b6afbdf"
   integrity sha512-PeHg/AtdW6aaIO2a+98Xj7rWY4KC1E6yOy7AFknJQ7VXUGNrMlyxDFxJo7HqLtjQms/ZhhQX52mLVW/EX3JGOw==
 
+"@types/tmp@^0.1.0":
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/@types/tmp/-/tmp-0.1.0.tgz#19cf73a7bcf641965485119726397a096f0049bd"
+  integrity sha512-6IwZ9HzWbCq6XoQWhxLpDjuADodH/MKXRUIDFudvgjcVdjFknvmR+DNsoUeer4XPrEnrZs04Jj+kfV9pFsrhmA==
+
 "@types/uglify-js@*":
   version "3.0.4"
   resolved "https://registry.yarnpkg.com/@types/uglify-js/-/uglify-js-3.0.4.tgz#96beae23df6f561862a830b4288a49e86baac082"
@@ -17202,6 +17207,13 @@ tmp@^0.0.33:
   dependencies:
     os-tmpdir "~1.0.2"
 
+tmp@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.1.0.tgz#ee434a4e22543082e294ba6201dcc6eafefa2877"
+  integrity sha512-J7Z2K08jbGcdA1kkQpJSqLF6T0tdQqpR2pnSUXsIchbPdTI9v3e85cLW0d6WDhwuAleOV71j2xWs8qMPfK7nKw==
+  dependencies:
+    rimraf "^2.6.3"
+
 tmpl@1.0.x:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1"