This commit is contained in:
Radon Rosborough 2020-09-12 12:00:56 -07:00
parent 29417cd431
commit 779a5e6604
11 changed files with 384 additions and 115 deletions

1
.gitignore vendored
View File

@ -1,6 +1,7 @@
.git
*.log
.log
.api-repl-history
.lsp-repl-history
node_modules
out

71
backend/src/api-repl.ts Normal file
View File

@ -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

View File

@ -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<Session> = 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) {

View File

@ -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) | .[]'`,

View File

@ -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);
},
});

View File

@ -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 });
});

View File

@ -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 () {},
};
}

View File

@ -37,17 +37,16 @@
</button>
</div>
<div class="modal-body">
<form autocomplete="off">
<input type="button" class="btn btn-primary" value="Add package" id="packagesAdd"/>
<div id="packagesSearchContainer">
<input
type="text"
class="form-control shadow-none"
placeholder="Search for packages..."
id="packagesSearch"
/>
</div>
</form>
<input type="button" class="btn btn-primary" value="Add package" id="packagesAdd"/>
<div id="packagesSearchContainer">
<input
type="text"
class="form-control shadow-none"
placeholder="Search for packages..."
id="packagesSearch"
autocomplete="off"
/>
</div>
<hr/>
<div id="packagesTerminal"></div>
</div>

View File

@ -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);

View File

@ -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 \"$@\"' --"

View File

@ -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"