From 779a5e6604373d85a447c4ffa1e7bf524b16ebf1 Mon Sep 17 00:00:00 2001 From: Radon Rosborough Date: Sat, 12 Sep 2020 12:00:56 -0700 Subject: [PATCH] Part II --- .gitignore | 1 + backend/src/api-repl.ts | 71 +++++++++++++++++++++++++ backend/src/api.ts | 103 ++++++++++++++++++++++++++++++++++--- backend/src/langs.ts | 2 + backend/src/lsp-repl.ts | 64 +++++------------------ backend/src/test-runner.ts | 35 +++---------- backend/src/util.ts | 92 +++++++++++++++++++++++++++++++++ frontend/pages/app.ejs | 21 ++++---- frontend/src/app.ts | 60 ++++++++++++++++----- package.json | 7 ++- yarn.lock | 43 +++++++++++++++- 11 files changed, 384 insertions(+), 115 deletions(-) create mode 100644 backend/src/api-repl.ts diff --git a/.gitignore b/.gitignore index cc99f2c..2661b41 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .git *.log .log +.api-repl-history .lsp-repl-history node_modules out diff --git a/backend/src/api-repl.ts b/backend/src/api-repl.ts new file mode 100644 index 0000000..bdde8b2 --- /dev/null +++ b/backend/src/api-repl.ts @@ -0,0 +1,71 @@ +import * as process from "process"; + +import * as JSON5 from "json5"; +import * as stringifyObject from "stringify-object"; + +import * as api from "./api"; +import { langs } from "./langs"; +import { mockSocket, startRepl } from "./util"; + +const args = process.argv.slice(2); + +function die(msg: any) { + console.error(msg); + process.exit(1); +} + +function printUsage() { + console.log(`usage: yarn api-repl LANG`); +} + +if (args.length !== 1) { + printUsage(); + process.exit(1); +} + +if (["-h", "-help", "--help", "help"].includes(args[0])) { + printUsage(); + process.exit(0); +} + +const lang = args[0]; + +const config = langs[lang]; +if (!config) { + console.error(`yarn api-repl: no such language: ${lang}`); + process.exit(1); +} + +const ws = mockSocket(); +ws.on("send", (data: string) => { + try { + data = stringifyObject(JSON.parse(data), { indent: "\0" }) + .replace(/\0/g, "") + .replace(/\n/g, " "); + } catch (err) { + console.error(`Invalid JSON: ${err}`); + } + console.log("<<< " + data + "\n"); +}); + +startRepl({ + historyFile: ".api-repl-history", + onLine: (line) => { + let data; + try { + data = JSON5.parse(line); + } catch (err) { + console.error(`Invalid JSON: ${err}`); + return; + } + console.log(); + ws.onMessage(JSON.stringify(data)); + }, +}); + +const session = new api.Session(ws as any, lang, (msg) => + console.log(msg + "\n") +); +session.setup().catch(die); + +// https://www.w3schools.com/howto/howto_js_autocomplete.asp diff --git a/backend/src/api.ts b/backend/src/api.ts index f53fe9f..8e8689d 100644 --- a/backend/src/api.ts +++ b/backend/src/api.ts @@ -13,6 +13,9 @@ import { borrowUser } from "./users"; import * as util from "./util"; import { Context, Options, bash } from "./util"; +const PACKAGE_MAX_SEARCH_RESULTS = 100; +const PACKAGE_NAME_REGEX = /[-_a-zA-Z0-9.+:]/; + const allSessions: Set = new Set(); export class Session { @@ -42,6 +45,12 @@ export class Session { input: string; output: string; } | null = null; + packageSearcher: { + proc: ChildProcess; + live: boolean; + input: string; + output: string; + } | null = null; logPrimitive: (msg: string) => void; @@ -243,6 +252,9 @@ export class Session { this.logBadMessage(msg); break; } + if (!this.config.format) { + this.log("formatCode ignored because format is null"); + } await this.formatCode(msg.code); break; case "lspInput": @@ -258,11 +270,22 @@ export class Session { break; case "ensure": if (!this.config.ensure) { - this.log(`ensure ignored because of missing configuration`); + this.log(`ensure ignored because ensure is null`); break; } await this.ensure(this.config.ensure); break; + case "packageSearch": + if (!this.config.pkg || !this.config.pkg.search) { + this.log(`packageSearch ignored because pkg.search is null`); + break; + } + if (typeof msg.search !== "string") { + this.logBadMessage(msg); + break; + } + await this.packageSearch(msg.search); + break; default: this.logBadMessage(msg); break; @@ -368,10 +391,6 @@ export class Session { formatCode = async (code: string) => { try { - if (!this.config.format) { - this.log("formatCode ignored because format is null"); - return; - } if (this.formatter) { const pid = this.formatter.proc.pid; const args = this.privilegedSpawn( @@ -381,7 +400,7 @@ export class Session { this.formatter.live = false; this.formatter = null; } - const args = this.privilegedSpawn(bash(this.config.format.run)); + const args = this.privilegedSpawn(bash(this.config.format!.run)); const formatter = { proc: spawn(args[0], args.slice(1)), live: true, @@ -440,6 +459,78 @@ export class Session { this.send({ event: "ensured", code }); }; + packageSearch = async (search: string) => { + try { + if (this.packageSearcher) { + const pid = this.packageSearcher.proc.pid; + const args = this.privilegedSpawn( + bash(`kill -SIGTERM ${pid}; sleep 1; kill -SIGKILL ${pid}`) + ); + spawn(args[0], args.slice(1)); + this.packageSearcher.live = false; + this.packageSearcher = null; + } + if (!search) { + this.send({ + event: "packageSearched", + results: this.config.pkg!.popular || [], + search: "", + }); + return; + } + const args = this.privilegedSpawn( + bash(this.config.pkg!.search!.replace(/NAME/g, search)) + ); + const packageSearcher = { + proc: spawn(args[0], args.slice(1)), + live: true, + input: search, + output: "", + }; + packageSearcher.proc.stdout!.on("data", (data) => { + if (!packageSearcher.live) return; + packageSearcher.output += data.toString("utf8"); + }); + packageSearcher.proc.stderr!.on("data", (data) => { + if (!packageSearcher.live) return; + this.send({ + event: "serviceLog", + service: "packageSearch", + output: data.toString("utf8"), + }); + }); + packageSearcher.proc.on("close", (code, signal) => { + if (!packageSearcher.live) return; + if (code === 0) { + this.send({ + event: "packageSearched", + results: packageSearcher.output.split("\n").filter((x) => x), + search: packageSearcher.input, + }); + } else { + this.send({ + event: "serviceFailed", + service: "packageSearch", + error: `Exited with status ${signal || code}`, + }); + } + }); + packageSearcher.proc.on("error", (err) => { + if (!packageSearcher.live) return; + this.send({ + event: "serviceFailed", + service: "packageSearch", + error: `${err}`, + }); + }); + this.packageSearcher = packageSearcher; + } catch (err) { + this.log(`Error while running package search`); + console.log(err); + this.sendError(err); + } + }; + teardown = async () => { try { if (this.tearingDown) { diff --git a/backend/src/langs.ts b/backend/src/langs.ts index 05f8148..fd54507 100644 --- a/backend/src/langs.ts +++ b/backend/src/langs.ts @@ -32,6 +32,7 @@ export interface LangConfig { output?: string; }; pkg?: { + index: string | string[]; install: string; uninstall?: string; all?: string; @@ -1928,6 +1929,7 @@ main = do `, }, pkg: { + index: "https://pypi.org/", install: "pip3 install --user NAME", uninstall: "pip3 uninstall NAME", search: `python3 -c 'import json; from xmlrpc import client; print(json.dumps(client.ServerProxy("https://pypi.org/pypi").search({"name": "NAME"})))' | jq -r 'map(.name) | .[]'`, diff --git a/backend/src/lsp-repl.ts b/backend/src/lsp-repl.ts index 4ee0ba0..ff00d0b 100755 --- a/backend/src/lsp-repl.ts +++ b/backend/src/lsp-repl.ts @@ -1,13 +1,12 @@ import * as child_process from "child_process"; import * as process from "process"; -import * as nodeReadline from "readline"; import * as appRoot from "app-root-path"; -import * as readline from "historic-readline"; import { quote } from "shell-quote"; import * as rpc from "vscode-jsonrpc"; import { langs } from "./langs"; +import { startRepl } from "./util"; const args = process.argv.slice(2); @@ -57,56 +56,17 @@ reader.listen((data) => { console.log("<<< " + JSON.stringify(data) + "\n"); }); -// https://stackoverflow.com/a/10608048/3538165 -function fixStdoutFor(cli: any) { - var oldStdout = process.stdout; - var newStdout = Object.create(oldStdout); - newStdout.write = function () { - cli.output.write("\x1b[2K\r"); - var result = oldStdout.write.apply( - this, - (Array.prototype.slice as any).call(arguments) - ); - cli._refreshLine(); - return result; - }; - (process as any).__defineGetter__("stdout", function () { - return newStdout; - }); -} - -readline.createInterface({ - input: process.stdin, - output: process.stdout, - path: appRoot.resolve(".lsp-repl-history"), - next: (cli: nodeReadline.Interface) => { - fixStdoutFor(cli); - cli.setPrompt(">>> "); - cli.on("line", (line: string) => { - if (line) { - let data; - try { - data = JSON.parse(line); - } catch (err) { - console.error(`Invalid JSON: ${err}`); - cli.prompt(); - return; - } - console.log(); - writer.write(data); - } - cli.prompt(); - }); - cli.on("SIGINT", () => { - console.error("^C"); - cli.write("", { ctrl: true, name: "u" }); - cli.prompt(); - }); - cli.on("close", () => { - console.error(); - process.exit(0); - }); +startRepl({ + historyFile: ".lsp-repl-history", + onLine: (line) => { + let data; + try { + data = JSON.parse(line); + } catch (err) { + console.error(`Invalid JSON: ${err}`); + return; + } console.log(); - cli.prompt(); + writer.write(data); }, }); diff --git a/backend/src/test-runner.ts b/backend/src/test-runner.ts index 999ee83..4e4b5a7 100644 --- a/backend/src/test-runner.ts +++ b/backend/src/test-runner.ts @@ -11,6 +11,7 @@ import { v4 as getUUID } from "uuid"; import * as api from "./api"; import { LangConfig, langs } from "./langs"; +import { mockSocket } from "./util"; function parseIntOr(thing: any, def: number) { const num = parseInt(thing); @@ -88,35 +89,11 @@ class Test { let session = null; let timeout = null; try { - const that = this; - this.ws = { - on: function (type: string, handler: any) { - switch (type) { - case "message": - this.onMessage = handler; - for (const msg of this.messageQueue) { - this.onMessage(msg); - } - this.messageQueue = []; - break; - case "close": - case "error": - // No need to clean up, we'll call teardown() explicitly. - break; - default: - throw new Error(`unexpected websocket handler type: ${type}`); - } - }, - onMessage: function (msg: any) { - this.messageQueue.push(msg); - }, - messageQueue: [] as any[], - send: function (data: string) { - that.record(JSON.parse(data)); - that.handleUpdate(); - }, - terminate: function () {}, - }; + this.ws = mockSocket(); + this.ws.on("send", (data: string) => { + this.record(JSON.parse(data)); + this.handleUpdate(); + }); session = new api.Session(this.ws, this.lang, (msg: string) => { this.record({ event: "serverLog", message: msg }); }); diff --git a/backend/src/util.ts b/backend/src/util.ts index bffbce4..188d026 100644 --- a/backend/src/util.ts +++ b/backend/src/util.ts @@ -1,8 +1,10 @@ import { SpawnOptions, spawn, spawnSync } from "child_process"; import * as os from "os"; import * as process from "process"; +import * as nodeReadline from "readline"; import * as appRoot from "app-root-path"; +import * as readline from "historic-readline"; import { quote } from "shell-quote"; import { MIN_UID, MAX_UID } from "./users"; @@ -138,3 +140,93 @@ export function bash(cmdline: string) { } return ["bash", "-c", cmdline]; } + +// https://stackoverflow.com/a/10608048/3538165 +function fixStreamFor(cli: any, streamName: string) { + var oldStream = (process as any)[streamName]; + var newStream = Object.create(oldStream); + newStream.write = function () { + cli.output.write("\x1b[2K\r"); + var result = oldStream.write.apply( + this, + (Array.prototype.slice as any).call(arguments) + ); + cli._refreshLine(); + return result; + }; + (process as any).__defineGetter__("old" + streamName, () => oldStream); + (process as any).__defineGetter__(streamName, () => newStream); +} + +export interface ReplOptions { + onLine: (line: any) => void; + historyFile: string; +} + +export function startRepl(options: ReplOptions) { + readline.createInterface({ + input: process.stdin, + output: process.stdout, + path: appRoot.resolve(options.historyFile), + next: (cli: nodeReadline.Interface) => { + fixStreamFor(cli, "stdout"); + fixStreamFor(cli, "stderr"); + cli.setPrompt(">>> "); + cli.on("line", (line: string) => { + if (line) { + options.onLine(line); + } + cli.prompt(); + }); + cli.on("SIGINT", () => { + (process as any).oldstderr.write("^C\n"); + cli.write("", { ctrl: true, name: "u" }); + cli.prompt(); + }); + cli.on("close", () => { + (process as any).oldstderr.write("^D\n"); + process.exit(0); + }); + console.log(); + cli.prompt(); + }, + }); +} + +export function mockSocket() { + return { + on: function (type: string, handler: any) { + switch (type) { + case "message": + this.onMessage = handler; + for (const msg of this.messageReceivedQueue) { + this.onMessage(msg); + } + this.messageReceivedQueue = []; + break; + case "send": + this.send = handler; + for (const msg of this.messageSentQueue) { + this.send(msg); + } + this.messageSentQueue = []; + break; + case "close": + case "error": + // No need to clean up, we'll call teardown() explicitly. + break; + default: + throw new Error(`unexpected websocket handler type: ${type}`); + } + }, + onMessage: function (msg: any) { + this.messageReceivedQueue.push(msg); + }, + send: function (msg: any) { + this.messageSentQueue.push(msg); + }, + messageReceivedQueue: [] as any[], + messageSentQueue: [] as any[], + terminate: function () {}, + }; +} diff --git a/frontend/pages/app.ejs b/frontend/pages/app.ejs index 815ca89..928d06f 100644 --- a/frontend/pages/app.ejs +++ b/frontend/pages/app.ejs @@ -37,17 +37,16 @@ diff --git a/frontend/src/app.ts b/frontend/src/app.ts index e83092b..0dd9db0 100644 --- a/frontend/src/app.ts +++ b/frontend/src/app.ts @@ -1,4 +1,5 @@ import * as $ from "jquery"; +import * as _ from "lodash"; import * as monaco from "monaco-editor"; import { createConnection, @@ -138,6 +139,7 @@ async function main() { window.addEventListener("resize", () => fitAddon.fit()); let packagesTermOpened = false; + let handlePackageSearchResults: (results: string[]) => void = () => {}; await new Promise((resolve) => term.write("Connecting to server...", resolve) @@ -284,7 +286,24 @@ async function main() { } return; case "serviceCrashed": + if (typeof message.service !== "string") { + console.error("Unexpected message from server:", message); + return; + } + if (message.service === "packageSearch") { + handlePackageSearchResults([]); + } return; + case "packageSearched": + if ( + !Array.isArray(message.results) || + !message.results.every((x: any) => typeof x === "string") + ) { + console.error("Unexpected message from server:", message); + return; + } + handlePackageSearchResults(message.results); + break; default: console.error("Unexpected message from server:", message); return; @@ -342,21 +361,34 @@ async function main() { } if (config.pkg) { document.getElementById("packagesButton")!.classList.add("visible"); + $("#packagesModal").on("shown.bs.modal", () => { + if (!packagesTermOpened) { + packagesTermOpened = true; + + const packagesTerm = new Terminal(); + const packagesFitAddon = new FitAddon(); + packagesTerm.loadAddon(packagesFitAddon); + + packagesTerm.open(document.getElementById("packagesTerminal")!); + + packagesFitAddon.fit(); + window.addEventListener("resize", () => packagesFitAddon.fit()); + + const searchInput = document.getElementById( + "packagesSearch" + ) as HTMLInputElement; + searchInput.addEventListener( + "input", + _.debounce(() => { + sendMessage({ event: "packageSearch", search: searchInput.value }); + }, 100) + ); + handlePackageSearchResults = (results: string[]) => { + console.log("got results:", results); + }; + } + }); } - $("#packagesModal").on("shown.bs.modal", () => { - if (!packagesTermOpened) { - packagesTermOpened = true; - - const packagesTerm = new Terminal(); - const packagesFitAddon = new FitAddon(); - packagesTerm.loadAddon(packagesFitAddon); - - packagesTerm.open(document.getElementById("packagesTerminal")!); - - packagesFitAddon.fit(); - window.addEventListener("resize", () => packagesFitAddon.fit()); - } - }); } main().catch(console.error); diff --git a/package.json b/package.json index cb89df0..a4ad5c5 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,14 @@ "@types/express": "^4.17.6", "@types/express-ws": "^3.0.0", "@types/jquery": "^3.5.1", + "@types/json5": "^0.0.30", "@types/lodash": "^4.14.155", "@types/mkdirp": "^1.0.1", "@types/node-cleanup": "^2.1.1", "@types/parse-passwd": "^1.0.0", "@types/rimraf": "^3.0.0", "@types/shell-quote": "^1.7.0", + "@types/stringify-object": "^3.3.0", "@types/tmp": "^0.2.0", "@types/uuid": "^8.0.0", "app-root-path": "^3.0.0", @@ -30,7 +32,8 @@ "file-loader": "^6.0.0", "historic-readline": "^1.0.8", "jquery": "^3.5.1", - "lodash": "^4.17.15", + "json5": "^2.1.3", + "lodash": "^4.17.20", "moment": "^2.27.0", "monaco-editor": "^0.20.0", "monaco-editor-webpack-plugin": "^1.9.0", @@ -42,6 +45,7 @@ "popper.js": "^1.16.1", "rimraf": "^3.0.2", "shell-quote": "^1.7.2", + "stringify-object": "^3.3.0", "style-loader": "^1.2.1", "ts-loader": "^7.0.5", "typescript": "^3.9.5", @@ -64,6 +68,7 @@ "system-dev": "watchexec --no-vcs-ignore -w system/src -n scripts/compile-system.bash", "build": "run-s backend frontend system", "dev": "run-p backend-dev frontend-dev system-dev server-dev", + "api-repl": "node backend/out/api-repl.js", "lsp-repl": "node backend/out/lsp-repl.js", "sandbox": "node backend/out/sandbox.js", "test": "bash -c 'scripts/setup.bash && time node backend/out/test-runner.js \"$@\"' --" diff --git a/yarn.lock b/yarn.lock index d119a37..51bc5d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -867,6 +867,11 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.4.tgz#38fd73ddfd9b55abb1e1b2ed578cb55bd7b7d339" integrity sha512-8+KAKzEvSUdeo+kmqnKrqgeE+LcA0tjYWFY7RPProVYwnqDjukzO+3b6dLD56rYX5TdWejnEOLJYOIeh4CXKuA== +"@types/json5@^0.0.30": + version "0.0.30" + resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.30.tgz#44cb52f32a809734ca562e685c6473b5754a7818" + integrity sha512-sqm9g7mHlPY/43fcSNrCYfOeX9zkTTK+euO5E6+CVijSMm5tTjkVdwdqRkY3ljjIAf8679vps5jKUoJBCLsMDA== + "@types/lodash@^4.14.155": version "4.14.155" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.155.tgz#e2b4514f46a261fd11542e47519c20ebce7bc23a" @@ -940,6 +945,11 @@ resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.2.tgz#a811b8c18e2babab7d542b3365887ae2e4d9de47" integrity sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg== +"@types/stringify-object@^3.3.0": + version "3.3.0" + resolved "https://registry.yarnpkg.com/@types/stringify-object/-/stringify-object-3.3.0.tgz#7ea04ff326e7c549fe311eae8f26e6211c880498" + integrity sha512-ryxTolaNg1l809rknW9q9T7wG8QHcjtZX6syJx7kpOLY2qev75VzC9HMVimUxlA1YzjpGsDI29yLjHBotqhUhA== + "@types/tmp@^0.2.0": version "0.2.0" resolved "https://registry.yarnpkg.com/@types/tmp/-/tmp-0.2.0.tgz#e3f52b4d7397eaa9193592ef3fdd44dc0af4298c" @@ -2447,6 +2457,11 @@ get-caller-file@^2.0.1: resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== +get-own-enumerable-property-symbols@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664" + integrity sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g== + get-stream@^4.0.0: version "4.1.0" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-4.1.0.tgz#c1b255575f3dc21d59bfc79cd3d2b46b1c3a54b5" @@ -2937,6 +2952,11 @@ is-number@^7.0.0: resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== +is-obj@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" + integrity sha1-PkcprB9f3gJc19g6iW2rn09n2w8= + is-plain-object@^2.0.3, is-plain-object@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" @@ -2951,6 +2971,11 @@ is-regex@^1.1.0: dependencies: has-symbols "^1.0.1" +is-regexp@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069" + integrity sha1-/S2INUXEa6xaYz57mgnof6LLUGk= + is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" @@ -3042,7 +3067,7 @@ json5@^1.0.1: dependencies: minimist "^1.2.0" -json5@^2.1.2: +json5@^2.1.2, json5@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/json5/-/json5-2.1.3.tgz#c9b0f7fa9233bfe5807fe66fcf3a5617ed597d43" integrity sha512-KXPvOm8K9IJKFM0bmdn8QXh7udDh1g/giieX0NLCaMnb4hEiVFqnop2ImTXCc5e0/oHz3LTqmHGtExn5hfMkOA== @@ -3157,11 +3182,16 @@ locate-path@^3.0.0: p-locate "^3.0.0" path-exists "^3.0.0" -lodash@^4.17.13, lodash@^4.17.15: +lodash@^4.17.13: version "4.17.15" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== +lodash@^4.17.20: + version "4.17.20" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" + integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== + loose-envify@^1.0.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -4590,6 +4620,15 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" +stringify-object@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-3.3.0.tgz#703065aefca19300d3ce88af4f5b3956d7556629" + integrity sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw== + dependencies: + get-own-enumerable-property-symbols "^3.0.0" + is-obj "^1.0.1" + is-regexp "^1.0.0" + strip-ansi@^5.0.0, strip-ansi@^5.1.0, strip-ansi@^5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-5.2.0.tgz#8c9a536feb6afc962bdfa5b104a5091c1ad9c0ae"