diff --git a/packages/embark/src/cmd/cmd.js b/packages/embark/src/cmd/cmd.js index 02f2d50351..b2ea753475 100644 --- a/packages/embark/src/cmd/cmd.js +++ b/packages/embark/src/cmd/cmd.js @@ -15,6 +15,7 @@ class Cmd { this.demo(); this.build(); this.run(); + this.exec(); this.console(); this.blockchain(); this.simulator(); @@ -174,6 +175,28 @@ class Cmd { }); } + exec() { + program + .command('exec [environment] [script|directory]') + .description(__("Executes specified scripts or all scripts in 'directory'")) + .action((env, target) => { + embark.exec({ + env, + target + }, (err) => { + if (err) { + console.error(err.message ? err.message : err); + process.exit(1); + } + console.log('Done.'); + // TODO(pascal): Ideally this shouldn't be needed. + // Seems like there's a pending child process at this point that needs + // to be stopped. + process.exit(0); + }); + }); + } + console() { program .command('console [environment]') diff --git a/packages/embark/src/cmd/cmd_controller.js b/packages/embark/src/cmd/cmd_controller.js index e66ff5759a..8cef6f201b 100644 --- a/packages/embark/src/cmd/cmd_controller.js +++ b/packages/embark/src/cmd/cmd_controller.js @@ -349,6 +349,42 @@ class EmbarkController { }); } + exec(options, callback) { + const engine = new Engine({ + env: options.env, + embarkConfig: options.embarkConfig || 'embark.json' + }); + + engine.init({}, () => { + engine.registerModuleGroup("coreComponents", { + disableServiceMonitor: true + }); + engine.registerModuleGroup("stackComponents"); + engine.registerModuleGroup("blockchain"); + engine.registerModuleGroup("compiler"); + engine.registerModuleGroup("contracts"); + engine.registerModulePackage('embark-deploy-tracker', { + plugins: engine.plugins + }); + engine.registerModulePackage('embark-scripts-runner'); + + engine.startEngine(async (err) => { + if (err) { + return callback(err); + } + try { + await engine.events.request2("blockchain:node:start", engine.config.blockchainConfig); + const [contractsList, contractDependencies] = await compileSmartContracts(engine); + await engine.events.request2("deployment:contracts:deploy", contractsList, contractDependencies); + await engine.events.request2('scripts-runner:execute', options.target, { env: options.env }); + } catch (err) { + return callback(err); + } + callback(); + }); + }); + } + console(options) { this.context = options.context || [constants.contexts.run, constants.contexts.console]; const REPL = require('./dashboard/repl.js'); diff --git a/packages/plugins/scripts-runner/README.md b/packages/plugins/scripts-runner/README.md new file mode 100644 index 0000000000..5992239be5 --- /dev/null +++ b/packages/plugins/scripts-runner/README.md @@ -0,0 +1,10 @@ +embark-scripts-runner +========================== + +> Embark Scripts Runner + +Plugin to run migration scripts for Smart Contract Deployment + +Visit [embark.status.im](https://embark.status.im/) to get started with +[Embark](https://github.com/embarklabs/embark). + diff --git a/packages/plugins/scripts-runner/package.json b/packages/plugins/scripts-runner/package.json new file mode 100644 index 0000000000..d6792e29aa --- /dev/null +++ b/packages/plugins/scripts-runner/package.json @@ -0,0 +1,76 @@ +{ + "name": "embark-scripts-runner", + "version": "5.1.0", + "description": "Embark Scripts Runner", + "repository": { + "directory": "packages/plugins/scripts-runner", + "type": "git", + "url": "https://github.com/embarklabs/embark/" + }, + "author": "Iuri Matias ", + "license": "MIT", + "bugs": "https://github.com/embarklabs/embark/issues", + "keywords": [], + "files": [ + "dist/" + ], + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "embark-collective": { + "build:node": true, + "typecheck": true + }, + "scripts": { + "_build": "npm run solo -- build", + "_typecheck": "npm run solo -- typecheck", + "ci": "npm run qa", + "clean": "npm run reset", + "lint": "npm-run-all lint:*", + "lint:ts": "tslint -c tslint.json \"src/**/*.ts\"", + "qa": "npm-run-all lint _typecheck _build test", + "reset": "npx rimraf dist embark-*.tgz package", + "solo": "embark-solo", + "test": "jest" + }, + "dependencies": { + "async": "2.6.1", + "@babel/runtime-corejs3": "7.7.4", + "embark-core": "^5.1.0", + "embark-i18n": "^5.1.0", + "embark-logger": "^5.1.0", + "embark-utils": "^5.1.0", + "web3": "1.2.4" + }, + "devDependencies": { + "@babel/core": "7.7.4", + "@types/node": "^10.5.3", + "babel-jest": "24.9.0", + "embark-solo": "^5.1.1-nightly.2", + "embark-testing": "^5.1.0", + "jest": "24.9.0", + "npm-run-all": "4.1.5", + "rimraf": "3.0.0", + "tmp-promise": "1.1.0" + }, + "engines": { + "node": ">=10.17.0", + "npm": ">=6.11.3", + "yarn": ">=1.19.1" + }, + "jest": { + "collectCoverage": true, + "testEnvironment": "node", + "testMatch": [ + "**/test/**/*.js" + ], + "transform": { + "\\.(js|ts)$": [ + "babel-jest", + { + "rootMode": "upward" + } + ] + } + } +} + diff --git a/packages/plugins/scripts-runner/src/index.ts b/packages/plugins/scripts-runner/src/index.ts new file mode 100644 index 0000000000..ae2a4637d6 --- /dev/null +++ b/packages/plugins/scripts-runner/src/index.ts @@ -0,0 +1,178 @@ +import { eachSeries } from 'async'; +import { promises as fsPromises, stat, Stats } from 'fs'; +import path from 'path'; +import { Embark, Callback } from 'embark-core'; +import { Logger } from 'embark-logger'; +import { dappPath } from 'embark-utils'; +import Web3 from "web3"; + +export enum ScriptsRunnerCommand { + Execute = 'scripts-runner:execute' +} + +export interface ExecuteOptions { + env: string; +} + +export interface ScriptDependencies { + web3: Web3 | null; + contracts: any; + logger: Logger; +} + +enum FileType { + SymbolicLink = 'symbolic link', + Socket = 'socket', + Unknown = 'unknown' +} + +class UnsupportedTargetError extends Error { + + name = 'UnsupportedTargetError'; + + constructor(public stats: Stats) { + super(); + // We can't access `this` before `super()` is called so we have to + // set `this.message` after that to get a dedicated error message. + this.setMessage(); + } + + private setMessage() { + let ftype = FileType.Unknown; + if (this.stats.isSymbolicLink()) { + ftype = FileType.SymbolicLink; + } else if (this.stats.isSocket()) { + ftype = FileType.Socket; + } + this.message = `Script execution target not supported. Expected file path or directory, got ${ftype} type.`; + } +} + +class ScriptExecutionError extends Error { + + name = 'ScriptExecutionError'; + + constructor(public target: string, public innerError: Error) { + super(); + this.message = `Script '${path.basename(target)}' failed to execute. Original error: ${innerError}`; + } +} + +export default class ScriptsRunnerPlugin { + + embark: Embark; + private _web3: Web3 | null = null; + + constructor(embark) { + this.embark = embark; + // TODO: it'd be wonderful if Embark called `registerCommandHandlers()` for us + this.registerCommandHandlers(); + } + + registerCommandHandlers() { + this.embark.events.setCommandHandler(ScriptsRunnerCommand.Execute, this.execute.bind(this)); + } + + private get web3() { + return (async () => { + if (!this._web3) { + const provider = await this.embark.events.request2('blockchain:client:provider', 'ethereum'); + this._web3 = new Web3(provider); + } + return this._web3; + })(); + } + + private async execute(target: string, options: ExecuteOptions, callback: Callback) { + const targetPath = !path.isAbsolute(target) ? dappPath(target) : target; + try { + const fstat = await fsPromises.stat(targetPath); + if (fstat.isDirectory()) { + const dependencies = await this.getScriptDependencies(); + await this.executeAll(targetPath, dependencies, callback); + } else if (fstat.isFile()) { + const dependencies = await this.getScriptDependencies(); + await this.executeSingle(targetPath, dependencies, callback); + } else { + callback(new UnsupportedTargetError(fstat)); + } + } catch (e) { + callback(e); + } + } + + private async executeSingle(target: string, dependencies: ScriptDependencies, callback: Callback) { + const scriptToRun = require(target); + this.embark.logger.info(`.. ${path.basename(target)} running....`); + try { + const result = await scriptToRun(dependencies); + this.embark.logger.info(`.. finished.`); + callback(null, result); + } catch (e) { + const error = e instanceof Error ? e : new Error(e); + callback(new ScriptExecutionError(target, error)); + } + } + + private async executeAll(target: string, dependencies: ScriptDependencies, callback: Callback) { + const files = await fsPromises.readdir(target); + const results: any[] = []; + + eachSeries(files, (file, cb) => { + const targetPath = !path.isAbsolute(target) ? dappPath(target, file) : path.join(target, file); + + stat(targetPath, (err, fstat) => { + if (err) { + return cb(err); + } + if (fstat.isFile()) { + return this.executeSingle(targetPath, dependencies, (e, result) => { + if (e) { + return cb(e); + } + results.push(result); + cb(); + }); + } + cb(); + }); + }, (err) => { + if (err) { + return callback(err); + } + callback(null, results); + }); + } + + private async getScriptDependencies() { + const contracts = await this.embark.events.request2('contracts:list'); + + const dependencies: ScriptDependencies = { + logger: this.embark.logger, + web3: null, + contracts: {} + }; + + dependencies.web3 = await this.web3; + + for (const contract of contracts) { + const registeredInVM = this.checkContractRegisteredInVM(contract); + if (!registeredInVM) { + await this.embark.events.request2("embarkjs:contract:runInVm", contract); + } + const contractInstance = await this.embark.events.request2("runcode:eval", contract.className); + dependencies.contracts[contract.className] = contractInstance; + } + return dependencies; + } + + // TODO(pascal): the same API is used in the specialconfigs plugin. + // Might make sense to either move this to code-runner and set up a + // command handler, or set up a command handler in specialconfigs. + private async checkContractRegisteredInVM(contract) { + const checkContract = ` + return typeof ${contract.className} !== 'undefined'; + `; + return this.embark.events.request2('runcode:eval', checkContract); + } +} diff --git a/packages/plugins/scripts-runner/test/script-runner.spec.js b/packages/plugins/scripts-runner/test/script-runner.spec.js new file mode 100644 index 0000000000..1fe4dc57b2 --- /dev/null +++ b/packages/plugins/scripts-runner/test/script-runner.spec.js @@ -0,0 +1,94 @@ +import sinon from 'sinon'; +import assert from 'assert'; +import { fakeEmbark } from 'embark-testing'; +import ScriptsRunnerPlugin, { ScriptsRunnerCommand } from '../src/'; +import { file as tmpFile, dir as tmpDir } from 'tmp-promise'; +import { promises } from 'fs'; + +const { embark } = fakeEmbark(); + +// Due to our `DAPP_PATH` dependency in `embark-utils` `dappPath()`, we need to +// ensure that this environment variable is defined. +process.env.DAPP_PATH = 'something'; + +async function prepareScriptFile(content, dir) { + const file = await tmpFile({ postfix: '.js', dir}); + await promises.writeFile(file.path, content); + return file; +} + +describe('plugins/scripts-runner', () => { + + let scriptRunner, + runCodeCommandHandler, + blockchainClientProviderCommandHandler, + contractsListCommandHandler; + + beforeEach(() => { + scriptRunner = new ScriptsRunnerPlugin(embark) + + runCodeCommandHandler = sinon.spy((code, cb) => { + // `ScriptsRunnerPlugin` requests code evaluation two times. + // It expects a boolean for the first one and an object for + // the second one. + if (code.indexOf('!==') > 0) { + cb(null, true); + } + cb(null, {}); + }); + + blockchainClientProviderCommandHandler = sinon.spy((name, cb) => { + cb(null, 'http://localhost:8545'); + }); + + contractsListCommandHandler = sinon.spy(cb => { + cb(null, [ + { className: 'SimpleStorage' }, + { className: 'AnotherOne' }, + { className: 'Foo' } + ]) + }); + + embark.events.setCommandHandler('contracts:list', contractsListCommandHandler); + embark.events.setCommandHandler('runcode:eval', runCodeCommandHandler); + embark.events.setCommandHandler('blockchain:client:provider', blockchainClientProviderCommandHandler); + }); + + afterEach(() => { + embark.teardown(); + sinon.restore(); + }); + + it('should execute script', async (done) => { + const options = { env: 'test' }; + const scriptFile = await prepareScriptFile(`module.exports = () => { return 'done'; }`); + + embark.events.request(ScriptsRunnerCommand.Execute, scriptFile.path, options, (err, result) => { + assert.equal(result, 'done'); + scriptFile.cleanup(); + done(); + }); + }); + + it('should execute all scripts in a directory', async (done) => { + const options = { env: 'test' }; + const scriptsDir = await tmpDir(); + + const scriptFile1 = await prepareScriptFile( + `module.exports = () => { return 'done' }`, + scriptsDir.path + ); + + const scriptFile2 = await prepareScriptFile( + `module.exports = () => { return 'done2' }`, + scriptsDir.path + ); + + embark.events.request(ScriptsRunnerCommand.Execute, scriptsDir.path, options, (err, result) => { + assert.ok(result.includes('done')); + assert.ok(result.includes('done2')); + scriptsDir.cleanup(); + done(); + }); + }); +}); diff --git a/packages/plugins/scripts-runner/tsconfig.json b/packages/plugins/scripts-runner/tsconfig.json new file mode 100644 index 0000000000..9ff5ad6c7a --- /dev/null +++ b/packages/plugins/scripts-runner/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "composite": true, + "declarationDir": "./dist", + "rootDir": "./src", + "tsBuildInfoFile": "./node_modules/.cache/tsc/tsconfig.embark-scripts-runner.tsbuildinfo" + }, + "extends": "../../../tsconfig.base.json", + "include": [ + "src/**/*" + ], + "references": [ + { + "path": "../../core/core" + }, + { + "path": "../../core/i18n" + }, + { + "path": "../../core/logger" + }, + { + "path": "../../core/utils" + }, + { + "path": "../../utils/testing" + } + ] +} diff --git a/packages/plugins/scripts-runner/tslint.json b/packages/plugins/scripts-runner/tslint.json new file mode 100644 index 0000000000..1bdfa34f91 --- /dev/null +++ b/packages/plugins/scripts-runner/tslint.json @@ -0,0 +1,4 @@ +{ + "extends": "../../../tslint.json" +} + diff --git a/site/source/_data/sidebar.yml b/site/source/_data/sidebar.yml index e7524c41ad..504671371c 100644 --- a/site/source/_data/sidebar.yml +++ b/site/source/_data/sidebar.yml @@ -13,6 +13,7 @@ docs: environments: environments.html configuration: configuration.html pipeline_and_webpack: pipeline_and_webpack.html + executing_migration_scripts: executing_scripts.html # setting_up_storages: foo.html # uploading_data: foo.html # configuring_whisper: foo.html diff --git a/site/source/docs/embark_commands.md b/site/source/docs/embark_commands.md index e5492e68f5..238e6fbcbb 100644 --- a/site/source/docs/embark_commands.md +++ b/site/source/docs/embark_commands.md @@ -151,6 +151,16 @@ $ embark reset Resets embarks state on this dapp including clearing cache. +## exec + +``` +$ embark exec [environment] [file|directory] +``` + +Executes a given (migration) script to perform complex after deployment operations. + +It's required to specifiy the `environment` in which the script(s) will be executed in. In addition it's possible to specificy a directory in which multiple script live in. Embark will execute them one by one. + ## upload ``` diff --git a/site/source/docs/executing_scripts.md b/site/source/docs/executing_scripts.md new file mode 100644 index 0000000000..6836c4120c --- /dev/null +++ b/site/source/docs/executing_scripts.md @@ -0,0 +1,83 @@ +title: Executing Scripts +layout: docs +--- + +There are various features in Embark that help you making the deployment of your DApps and Smart Contracts as smooth as possible. Next to general [Smart Contract Configurations](contracts_configuration.html), [Deployment Hooks](contracts_configuration.html#Deployment-hooks) and [Cockpit](cockpit_introduction.html), there's the possibility to run (migration) scripts as well. + +In this guide we'll explore why scripts are useful and how they can be run. + +## Why scripts? + +Given that Embark supports [afterDeploy](contracts_configuration.html#afterDeploy-hook) hooks that make it extremely easy to perform custom operations after all of your Smart Contracts have been deployed, you might wonder when and where scripts can be useful. + +It's important to note that `afterDeploy` hooks are executed every time all Smart Contracts have been deployed. Often there are cases where running a (migration) script manually is what you really need. + +Scripts let you exactly that as they can be run at any time, regardless of what your app's current deployment status is. + +## What's a script? + +A script is really just a file with an exported function that has special dependencies injected into it. Here's what it could look like: + +``` +modules.exports = async ({ contracts, web3, logger}) => { + ... +}; +``` + +The injected parameters are: + +- `contracts` - A map object containing all of your Smart Contracts as Embark Smart Contract instances. +- `web3` - A web3 instances to give you access to things like accounts. +- `logger` - Embark's custom logger. + +Scripts can be located anywhere on your machine but should most likely live inside your project's file tree in a dedicated folder. + +## Running scripts + +To run a script, use the CLI `exec` command and specify an environment as well as the script to be executed: + +``` +$ embark exec development scripts/001.js +``` + +The command above will execute the function in `scripts/001.js` and ensures that Smart Contracts are deployed in the `development` environment. + +If you have multiple scripts that should run in order it's also possible to specify the directory in which they live in: + +``` +$ embark exec development scripts +``` + +Embark will then find all script files inside the specified directory (in this case `scripts`) and then run them one by one. If any of the scripts fails by emitting an error, Embark will abort the execution. Scripts are executed in sequence which means, all following scripts won't be executed in case of an error. + +## Error Handling + +It's possible and recommended for scripts to emit proper errors in case they fail to do their job. There are several ways to emit an error depending on how you write your function. Scripts are executed asyncronously, so one way to emit an error is to reject a promise: + +``` +modules.exports = () => { + return new Promise((resolve, reject) => { + reject(new Error('Whoops, something went wrong')); + }); +}; + +// or +modules.exports = () => { + return Promise.reject(new Error ('Whoops, something went wrong')); +}; +``` + +If your script uses the `async/await` syntax, errors can also be emitted by using other `async` APIs that fail: + +``` +module.exports = async () => { + await someAPIThatFails(); // this will mit an error +}; +``` + +If an error is emitted Embark will do its best to give you information about the original error: + +``` +.. 001.js running.... +Script '001.js' failed to execute. Original error: Error: Whoops, something went wrong +``` diff --git a/site/themes/embark/languages/en.yml b/site/themes/embark/languages/en.yml index 4f7b04bf3e..a5550b8f84 100644 --- a/site/themes/embark/languages/en.yml +++ b/site/themes/embark/languages/en.yml @@ -288,6 +288,7 @@ sidebar: installation: Installation faq: FAQ creating_project: Creating apps + executing_migration_scripts: Executing Scripts structure: App structure running_apps: Running apps dashboard: Using the dashboard diff --git a/tsconfig.json b/tsconfig.json index 10d353f232..9a7d652c02 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -103,6 +103,9 @@ { "path": "packages/plugins/scaffolding" }, + { + "path": "packages/plugins/scripts-runner" + }, { "path": "packages/plugins/snark" }, diff --git a/tslint.json b/tslint.json index 838ae1baa4..7501f818ab 100644 --- a/tslint.json +++ b/tslint.json @@ -18,7 +18,8 @@ "ordered-imports": false, "quotemark": [false, "single"], "trailing-comma": false, - "no-irregular-whitespace": false + "no-irregular-whitespace": false, + "max-classes-per-file": false }, "rulesDirectory": [] } diff --git a/yarn.lock b/yarn.lock index 6c2a13978f..2e54696bc9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4512,6 +4512,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-10.14.17.tgz#b96d4dd3e427382482848948041d3754d40fd5ce" integrity sha512-p/sGgiPaathCfOtqu2fx5Mu1bcjuP8ALFg4xpGgNkcin7LwRyzUKniEHBKdcE1RPsenq5JVPIpMTJSygLboygQ== +"@types/node@^10.5.3": + version "10.17.14" + resolved "https://registry.yarnpkg.com/@types/node/-/node-10.17.14.tgz#b6c60ebf2fb5e4229fdd751ff9ddfae0f5f31541" + integrity sha512-G0UmX5uKEmW+ZAhmZ6PLTQ5eu/VPaT+d/tdLd5IFsKRPcbe6lPxocBtcYBFSaLaCW8O60AX90e91Nsp8lVHCNw== + "@types/node@^12.6.1": version "12.12.6" resolved "https://registry.yarnpkg.com/@types/node/-/node-12.12.6.tgz#a47240c10d86a9a57bb0c633f0b2e0aea9ce9253" @@ -22279,6 +22284,14 @@ title-case@^2.1.0: no-case "^2.2.0" upper-case "^1.0.3" +tmp-promise@1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/tmp-promise/-/tmp-promise-1.1.0.tgz#bb924d239029157b9bc1d506a6aa341f8b13e64c" + integrity sha512-8+Ah9aB1IRXCnIOxXZ0uFozV1nMU5xiu7hhFVUSxZ3bYu+psD4TzagCzVbexUCgNNGJnsmNDQlS4nG3mTyoNkw== + dependencies: + bluebird "^3.5.0" + tmp "0.1.0" + tmp@0.0.33, tmp@^0.0.33: version "0.0.33" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" @@ -22286,7 +22299,7 @@ tmp@0.0.33, tmp@^0.0.33: dependencies: os-tmpdir "~1.0.2" -tmp@^0.1.0: +tmp@0.1.0, tmp@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.1.0.tgz#ee434a4e22543082e294ba6201dcc6eafefa2877" integrity sha512-J7Z2K08jbGcdA1kkQpJSqLF6T0tdQqpR2pnSUXsIchbPdTI9v3e85cLW0d6WDhwuAleOV71j2xWs8qMPfK7nKw== @@ -23597,6 +23610,20 @@ web3-utils@1.2.6: underscore "1.9.1" utf8 "3.0.0" +web3@1.2.4, web3@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/web3/-/web3-1.2.4.tgz#6e7ab799eefc9b4648c2dab63003f704a1d5e7d9" + integrity sha512-xPXGe+w0x0t88Wj+s/dmAdASr3O9wmA9mpZRtixGZxmBexAF0MjfqYM+MS4tVl5s11hMTN3AZb8cDD4VLfC57A== + dependencies: + "@types/node" "^12.6.1" + web3-bzz "1.2.4" + web3-core "1.2.4" + web3-eth "1.2.4" + web3-eth-personal "1.2.4" + web3-net "1.2.4" + web3-shh "1.2.4" + web3-utils "1.2.4" + web3@1.2.6: version "1.2.6" resolved "https://registry.yarnpkg.com/web3/-/web3-1.2.6.tgz#c497dcb14cdd8d6d9fb6b445b3b68ff83f8ccf68" @@ -23611,20 +23638,6 @@ web3@1.2.6: web3-shh "1.2.6" web3-utils "1.2.6" -web3@^1.2.4: - version "1.2.4" - resolved "https://registry.yarnpkg.com/web3/-/web3-1.2.4.tgz#6e7ab799eefc9b4648c2dab63003f704a1d5e7d9" - integrity sha512-xPXGe+w0x0t88Wj+s/dmAdASr3O9wmA9mpZRtixGZxmBexAF0MjfqYM+MS4tVl5s11hMTN3AZb8cDD4VLfC57A== - dependencies: - "@types/node" "^12.6.1" - web3-bzz "1.2.4" - web3-core "1.2.4" - web3-eth "1.2.4" - web3-eth-personal "1.2.4" - web3-net "1.2.4" - web3-shh "1.2.4" - web3-utils "1.2.4" - webidl-conversions@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad"