Improve use of Typescript (#474)

* Switch out needle with node-fetch

* Iterate

* Update asar package and switch to canonical name

* Use ts-node for scripts

* Iterate

* Update yarn.lock

* Use node:stream.promises

* Remove logfile

* Fix types

* Fix types
This commit is contained in:
Michael Telatynski 2022-12-05 11:50:49 +00:00 committed by GitHub
parent b52787a49e
commit 56370de568
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 162 additions and 107 deletions

1
.gitignore vendored
View file

@ -14,3 +14,4 @@ node_modules/
.vscode/
/test_artifacts/
/coverage/
yarn-error.log

View file

@ -18,12 +18,20 @@ import path from 'path';
import childProcess from 'child_process';
import fs from 'fs';
import fsProm from 'fs/promises';
import needle from 'needle';
import tar from 'tar';
import fetch from 'node-fetch';
import { promises as stream } from "stream";
import HakEnv from '../../scripts/hak/hakEnv';
import { DependencyInfo } from '../../scripts/hak/dep';
async function download(url: string, filename: string): Promise<void> {
const resp = await fetch(url);
if (!resp.ok) throw new Error(`unexpected response ${resp.statusText}`);
if (!resp.body) throw new Error(`unexpected response has no body ${resp.statusText}`);
await stream.pipeline(resp.body, fs.createWriteStream(filename));
}
export default async function(hakEnv: HakEnv, moduleInfo: DependencyInfo): Promise<void> {
if (hakEnv.wantsStaticSqlCipher()) {
await getSqlCipher(hakEnv, moduleInfo);
@ -57,11 +65,7 @@ async function getSqlCipher(hakEnv: HakEnv, moduleInfo: DependencyInfo): Promise
haveSqlcipherTar = false;
}
if (!haveSqlcipherTar) {
const bob = needle('get', `https://github.com/sqlcipher/sqlcipher/archive/v${version}.tar.gz`, {
follow: 10,
output: sqlCipherTarball,
});
await bob;
await download(`https://github.com/sqlcipher/sqlcipher/archive/v${version}.tar.gz`, sqlCipherTarball);
}
// Extract the tarball to per-target directories, then we avoid cross-contaiminating archs
@ -118,10 +122,7 @@ async function getOpenSsl(hakEnv: HakEnv, moduleInfo: DependencyInfo): Promise<v
haveOpenSslTar = false;
}
if (!haveOpenSslTar) {
await needle('get', `https://www.openssl.org/source/openssl-${version}.tar.gz`, {
follow: 10,
output: openSslTarball,
});
await download(`https://www.openssl.org/source/openssl-${version}.tar.gz`, openSslTarball);
}
console.log("extracting " + openSslTarball + " in " + moduleInfo.moduleTargetDotHakDir);

View file

@ -16,13 +16,13 @@
"prunei18n": "matrix-prune-i18n",
"diff-i18n": "cp src/i18n/strings/en_EN.json src/i18n/strings/en_EN_orig.json && matrix-gen-i18n && matrix-compare-i18n-files src/i18n/strings/en_EN_orig.json src/i18n/strings/en_EN.json",
"mkdirs": "mkdirp packages deploys",
"fetch": "yarn run mkdirs && node scripts/fetch-package.js",
"fetch": "yarn run mkdirs && ts-node scripts/fetch-package.ts",
"asar-webapp": "asar p webapp webapp.asar",
"start": "yarn run build:ts && yarn run build:res && electron .",
"lint": "yarn lint:types && yarn lint:js",
"lint:js": "eslint --max-warnings 0 src scripts hak",
"lint:js-fix": "eslint --fix src scripts hak",
"lint:types": "tsc --noEmit && tsc -p scripts/hak/tsconfig.json --noEmit && tsc -p hak/tsconfig.json --noEmit",
"lint:types": "tsc --noEmit && tsc -p scripts/tsconfig.json --noEmit && tsc -p hak/tsconfig.json --noEmit",
"build:native": "yarn run hak",
"build:native:universal": "yarn run hak --target x86_64-apple-darwin fetchandbuild && yarn run hak --target aarch64-apple-darwin fetchandbuild && yarn run hak --target x86_64-apple-darwin --target aarch64-apple-darwin copyandlink",
"build:32": "yarn run build:ts && yarn run build:res && electron-builder --ia32",
@ -30,7 +30,7 @@
"build:universal": "yarn run build:ts && yarn run build:res && electron-builder --universal",
"build": "yarn run build:ts && yarn run build:res && electron-builder",
"build:ts": "tsc",
"build:res": "node scripts/copy-res.js",
"build:res": "ts-node scripts/copy-res.ts",
"docker:setup": "docker build -t element-desktop-dockerbuild dockerbuild",
"docker:build:native": "scripts/in-docker.sh yarn run hak",
"docker:build": "scripts/in-docker.sh yarn run build",
@ -54,6 +54,7 @@
"@babel/core": "^7.18.10",
"@babel/preset-env": "^7.18.10",
"@babel/preset-typescript": "^7.18.6",
"@electron/asar": "^3.2.0",
"@electron/notarize": "^1.2.3",
"@types/auto-launch": "^5.0.1",
"@types/counterpart": "^0.18.1",
@ -64,11 +65,11 @@
"@types/node": "16.18.4",
"@types/pacote": "^11.1.1",
"@types/rimraf": "^3.0.2",
"@types/tar": "^6.1.3",
"@typescript-eslint/eslint-plugin": "^5.42.0",
"@typescript-eslint/parser": "^5.42.0",
"allchange": "^1.0.6",
"app-builder-lib": "^22.14.10",
"asar": "^3.0.0",
"babel-jest": "^29.0.0",
"chokidar": "^3.5.2",
"detect-libc": "^1.0.3",
@ -88,7 +89,6 @@
"jest": "^29.0.0",
"matrix-web-i18n": "^1.3.0",
"mkdirp": "^1.0.3",
"needle": "^2.5.0",
"node-pre-gyp": "^0.17.0",
"pacote": "^15.0.0",
"playwright": "^1.25.0",

View file

@ -1,18 +1,18 @@
#!/usr/bin/env node
#!/usr/bin/env -S npx ts-node
// copies resources into the lib directory.
const parseArgs = require('minimist');
const chokidar = require('chokidar');
const path = require('path');
const fs = require('fs');
import parseArgs from "minimist";
import * as chokidar from "chokidar";
import * as path from "path";
import * as fs from "fs";
const argv = parseArgs(process.argv.slice(2), {});
const watch = argv.w;
const verbose = argv.v;
function errCheck(err) {
function errCheck(err?: Error): void {
if (err) {
console.error(err.message);
process.exit(1);
@ -25,15 +25,14 @@ const INCLUDE_LANGS = fs.readdirSync(I18N_BASE_PATH).filter(fn => fn.endsWith(".
// Ensure lib, lib/i18n and lib/i18n/strings all exist
fs.mkdirSync('lib/i18n/strings', { recursive: true });
function genLangFile(file, dest) {
let translations = {};
type Translations = Record<string, Record<string, string> | string>;
function genLangFile(file: string, dest: string): void {
const inTrs: Record<string, string> = {};
[file].forEach(function(f) {
if (fs.existsSync(f)) {
try {
Object.assign(
translations,
JSON.parse(fs.readFileSync(f).toString()),
);
Object.assign(inTrs, JSON.parse(fs.readFileSync(f).toString()));
} catch (e) {
console.error("Failed: " + f, e);
throw e;
@ -41,8 +40,7 @@ function genLangFile(file, dest) {
}
});
translations = weblateToCounterpart(translations);
const translations = weblateToCounterpart(inTrs);
const json = JSON.stringify(translations, null, 4);
const filename = path.basename(file);
@ -66,8 +64,8 @@ function genLangFile(file, dest) {
* "other": "%(count)s badgers"
* }
*/
function weblateToCounterpart(inTrs) {
const outTrs = {};
function weblateToCounterpart(inTrs: Record<string, string>): Translations {
const outTrs: Translations = {};
for (const key of Object.keys(inTrs)) {
const keyParts = key.split('|', 2);
@ -96,12 +94,12 @@ function weblateToCounterpart(inTrs) {
watch the input files for a given language,
regenerate the file, and regenerating languages.json with the new filename
*/
function watchLanguage(file, dest) {
function watchLanguage(file: string, dest: string): void {
// XXX: Use a debounce because for some reason if we read the language
// file immediately after the FS event is received, the file contents
// appears empty. Possibly https://github.com/nodejs/node/issues/6112
let makeLangDebouncer;
const makeLang = () => {
let makeLangDebouncer: NodeJS.Timeout | undefined;
const makeLang = (): void => {
if (makeLangDebouncer) {
clearTimeout(makeLangDebouncer);
}
@ -118,7 +116,7 @@ function watchLanguage(file, dest) {
// language resources
const I18N_DEST = "lib/i18n/strings/";
INCLUDE_LANGS.forEach((file) => {
INCLUDE_LANGS.forEach((file): void => {
genLangFile(I18N_BASE_PATH + file, I18N_DEST);
}, {});

View file

@ -1,42 +1,40 @@
#!/usr/bin/env node
#!/usr/bin/env -S npx ts-node --resolveJsonModule
const process = require('process');
const path = require('path');
const fs = require('fs');
const fsPromises = require('fs').promises;
const childProcess = require('child_process');
const tar = require('tar');
const asar = require('asar');
const needle = require('needle');
import * as path from "path";
import { createWriteStream, promises as fs } from "fs";
import * as childProcess from "child_process";
import tar from "tar";
import * as asar from "asar";
import fetch from "node-fetch";
import { promises as stream } from "stream";
const riotDesktopPackageJson = require('../package.json');
const { setPackageVersion } = require('./set-version.js');
import riotDesktopPackageJson from "../package.json";
import { setPackageVersion } from "./set-version";
const PUB_KEY_URL = "https://packages.riot.im/element-release-key.asc";
const PACKAGE_URL_PREFIX = "https://github.com/vector-im/element-web/releases/download/";
const DEVELOP_TGZ_URL = "https://develop.element.io/develop.tar.gz";
const ASAR_PATH = 'webapp.asar';
async function downloadToFile(url, filename) {
async function downloadToFile(url: string, filename: string): Promise<void> {
console.log("Downloading " + url + "...");
try {
await needle('get', url, null,
{
follow_max: 5,
output: filename,
},
);
const resp = await fetch(url);
if (!resp.ok) throw new Error(`unexpected response ${resp.statusText}`);
if (!resp.body) throw new Error(`unexpected response has no body ${resp.statusText}`);
await stream.pipeline(resp.body, createWriteStream(filename));
} catch (e) {
console.error(e);
try {
await fsPromises.unlink(filename);
await fs.unlink(filename);
} catch (_) {}
throw e;
}
}
async function verifyFile(filename) {
return new Promise((resolve, reject) => {
async function verifyFile(filename: string): Promise<void> {
return new Promise<void>((resolve, reject) => {
childProcess.execFile('gpg', ['--verify', filename + '.asc', filename], (error) => {
if (error) {
reject(error);
@ -47,15 +45,15 @@ async function verifyFile(filename) {
});
}
async function main() {
async function main(): Promise<number | undefined> {
let verify = true;
let importkey = false;
let pkgDir = 'packages';
let deployDir = 'deploys';
let cfgDir;
let targetVersion;
let filename;
let url;
let cfgDir: string | undefined;
let targetVersion: string | undefined;
let filename: string | undefined;
let url: string | undefined;
let setVersion = false;
while (process.argv.length > 2) {
@ -104,7 +102,7 @@ async function main() {
url = PACKAGE_URL_PREFIX + targetVersion + '/' + filename;
}
const haveGpg = await new Promise((resolve) => {
const haveGpg = await new Promise<boolean>((resolve) => {
childProcess.execFile('gpg', ['--version'], (error) => {
resolve(!error);
});
@ -116,7 +114,7 @@ async function main() {
return 1;
}
await new Promise((resolve) => {
await new Promise<boolean>((resolve) => {
const gpgProc = childProcess.execFile('gpg', ['--import'], (error) => {
if (error) {
console.log("Failed to import key", error);
@ -125,7 +123,9 @@ async function main() {
}
resolve(!error);
});
needle.get(PUB_KEY_URL).pipe(gpgProc.stdin);
fetch(PUB_KEY_URL).then(resp => {
stream.pipeline(resp.body, gpgProc.stdin!);
});
});
return 0;
}
@ -154,7 +154,7 @@ async function main() {
if (!haveDeploy) {
const outPath = path.join(pkgDir, filename);
try {
await fsPromises.stat(outPath);
await fs.stat(outPath);
console.log("Already have " + filename + ": not redownloading");
} catch (e) {
try {
@ -167,7 +167,7 @@ async function main() {
if (verify) {
try {
await fsPromises.stat(outPath+'.asc');
await fs.stat(outPath+'.asc');
console.log("Already have " + filename + ".asc: not redownloading");
} catch (e) {
try {
@ -202,9 +202,9 @@ async function main() {
}
try {
await fsPromises.stat(ASAR_PATH);
await fs.stat(ASAR_PATH);
console.log(ASAR_PATH + " already present: removing");
await fsPromises.unlink(ASAR_PATH);
await fs.unlink(ASAR_PATH);
} catch (e) {
}
@ -212,7 +212,7 @@ async function main() {
const configJsonSource = path.join(cfgDir, 'config.json');
const configJsonDest = path.join(expectedDeployDir, 'config.json');
console.log(configJsonSource + ' -> ' + configJsonDest);
await fsPromises.copyFile(configJsonSource, configJsonDest);
await fs.copyFile(configJsonSource, configJsonDest);
} else {
console.log("Skipping config file");
}
@ -221,7 +221,7 @@ async function main() {
await asar.createPackage(expectedDeployDir, ASAR_PATH);
if (setVersion) {
const semVer = fs.readFileSync(path.join(expectedDeployDir, "version"), "utf-8").trim();
const semVer = (await fs.readFile(path.join(expectedDeployDir, "version"), "utf-8")).trim();
console.log("Updating version to " + semVer);
await setPackageVersion(semVer);
}

View file

@ -22,7 +22,7 @@ import HakEnv from './hakEnv';
export default async function clean(hakEnv: HakEnv, moduleInfo: DependencyInfo): Promise<void> {
await new Promise<void>((resolve, reject) => {
rimraf(moduleInfo.moduleDotHakDir, (err: Error) => {
rimraf(moduleInfo.moduleDotHakDir, (err?: Error | null) => {
if (err) {
reject(err);
} else {
@ -32,7 +32,7 @@ export default async function clean(hakEnv: HakEnv, moduleInfo: DependencyInfo):
});
await new Promise<void>((resolve, reject) => {
rimraf(path.join(hakEnv.dotHakDir, 'links', moduleInfo.name), (err: Error) => {
rimraf(path.join(hakEnv.dotHakDir, 'links', moduleInfo.name), (err?: Error | null) => {
if (err) {
reject(err);
} else {
@ -42,7 +42,7 @@ export default async function clean(hakEnv: HakEnv, moduleInfo: DependencyInfo):
});
await new Promise<void>((resolve, reject) => {
rimraf(path.join(hakEnv.projectRoot, 'node_modules', moduleInfo.name), (err: Error) => {
rimraf(path.join(hakEnv.projectRoot, 'node_modules', moduleInfo.name), (err?: Error | null) => {
if (err) {
reject(err);
} else {

19
scripts/hak/find-npm-prefix.d.ts vendored Normal file
View file

@ -0,0 +1,19 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
declare module "find-npm-prefix" {
export default function findPrefix(dir: string): Promise<string>;
}

View file

@ -37,20 +37,17 @@ async function getRuntimeVersion(projectRoot: string): Promise<string> {
export default class HakEnv {
public readonly target: Target;
public runtime: string;
public runtimeVersion: string;
public runtime?: string;
public runtimeVersion?: string;
public dotHakDir: string;
public constructor(public readonly projectRoot: string, targetId: TargetId | null) {
if (targetId) {
this.target = TARGETS[targetId];
} else {
this.target = getHost();
}
const target = targetId ? TARGETS[targetId] : getHost();
if (!this.target) {
if (!target) {
throw new Error(`Unknown target ${targetId}!`);
}
this.target = target;
this.dotHakDir = path.join(this.projectRoot, '.hak');
}
@ -60,10 +57,7 @@ export default class HakEnv {
}
public getRuntimeAbi(): string {
return nodePreGypVersioning.get_runtime_abi(
this.runtime,
this.runtimeVersion,
);
return nodePreGypVersioning.get_runtime_abi(this.runtime!, this.runtimeVersion!);
}
// {node_abi}-{platform}-{arch}
@ -95,7 +89,7 @@ export default class HakEnv {
return isHostId(this.target.id);
}
public makeGypEnv(): Record<string, string> {
public makeGypEnv(): Record<string, string | undefined> {
return Object.assign({}, process.env, {
npm_config_arch: this.target.arch,
npm_config_target_arch: this.target.arch,
@ -107,10 +101,6 @@ export default class HakEnv {
});
}
public getNodeModuleBin(name: string): string {
return path.join(this.projectRoot, 'node_modules', '.bin', name);
}
public wantsStaticSqlCipherUnix(): boolean {
return this.isMac() || process.env.SQLCIPHER_STATIC == '1';
}

View file

@ -38,7 +38,7 @@ const MODULECOMMANDS = [
// Shortcuts for multiple commands at once (useful for building universal binaries
// because you can run the fetch/fetchDeps/build for each arch and then copy/link once)
const METACOMMANDS = {
const METACOMMANDS: Record<string, string[]> = {
'fetchandbuild': ['check', 'fetch', 'fetchDeps', 'build'],
'copyandlink': ['copy', 'link'],
};

20
scripts/hak/node-pre-gyp.d.ts vendored Normal file
View file

@ -0,0 +1,20 @@
/*
Copyright 2022 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
declare module "node-pre-gyp/lib/util/versioning" {
// eslint-disable-next-line @typescript-eslint/naming-convention
export function get_runtime_abi(runtime: string, version: string): string;
}

View file

@ -1,29 +1,28 @@
#!/usr/bin/env node
#!/usr/bin/env -S npx ts-node
/*
* Checks for the presence of a webapp, inspects its version and sets the
* version metadata of the package to match.
*/
const fs = require('fs').promises;
const asar = require('asar');
const childProcess = require('child_process');
import { promises as fs } from "fs";
import * as asar from "asar";
import * as childProcess from "child_process";
async function versionFromAsar() {
export async function versionFromAsar(): Promise<string> {
try {
await fs.stat('webapp.asar');
} catch (e) {
console.log("No 'webapp.asar' found. Run 'yarn run fetch'");
return 1;
throw new Error("No 'webapp.asar' found. Run 'yarn run fetch'");
}
return asar.extractFile('webapp.asar', 'version').toString().trim();
}
async function setPackageVersion(ver) {
export async function setPackageVersion(ver: string): Promise<void> {
// set version in package.json: electron-builder will use this to populate
// all the various version fields
await new Promise((resolve, reject) => {
await new Promise<void>((resolve, reject) => {
childProcess.execFile(process.platform === 'win32' ? 'yarn.cmd' : 'yarn', [
'version',
'-s',
@ -40,16 +39,20 @@ async function setPackageVersion(ver) {
});
}
async function main(args) {
async function main(args: string[]): Promise<number> {
let version = args[0];
if (version === undefined) version = await versionFromAsar();
await setPackageVersion(version);
return 0;
}
if (require.main === module) {
main(process.argv.slice(2)).then((ret) => process.exit(ret));
main(process.argv.slice(2)).then((ret) => {
process.exit(ret);
}).catch(e => {
console.error(e);
process.exit(1);
});
}
module.exports = { versionFromAsar, setPackageVersion };

View file

@ -1,12 +1,15 @@
{
"compilerOptions": {
"resolveJsonModule": true,
"moduleResolution": "node",
"esModuleInterop": true,
"target": "es2017",
"module": "commonjs",
"sourceMap": false,
"strict": true,
"lib": [
"es2019",
"dom"
]
},
"include": [

View file

@ -1947,6 +1947,18 @@
ajv "^6.12.0"
ajv-keywords "^3.4.1"
"@electron/asar@^3.2.0":
version "3.2.2"
resolved "https://registry.yarnpkg.com/@electron/asar/-/asar-3.2.2.tgz#f6ae4eb4343ad00b994c40db3f09f71f968ff9c0"
integrity sha512-32fMU68x8a6zvxtC1IC/BhPDKTh8rQjdmwEplj3CDpnkcwBzZVN9v/8cK0LJqQ0FOQQVZW8BWZ1S6UU53TYR4w==
dependencies:
chromium-pickle-js "^0.2.0"
commander "^5.0.0"
glob "^7.1.6"
minimatch "^3.0.4"
optionalDependencies:
"@types/glob" "^7.1.1"
"@electron/get@^1.14.1":
version "1.14.1"
resolved "https://registry.yarnpkg.com/@electron/get/-/get-1.14.1.tgz#16ba75f02dffb74c23965e72d617adc721d27f40"
@ -2792,6 +2804,14 @@
resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.1.tgz#20f18294f797f2209b5f65c8e3b5c8e8261d127c"
integrity sha512-Hl219/BT5fLAaz6NDkSuhzasy49dwQS/DSdu4MdggFB8zcXv7vflBI3xp7FEmkmdDkBUI2bPUNeMttp2knYdxw==
"@types/tar@^6.1.3":
version "6.1.3"
resolved "https://registry.yarnpkg.com/@types/tar/-/tar-6.1.3.tgz#46a2ce7617950c4852dfd7e9cd41aa8161b9d750"
integrity sha512-YzDOr5kdAeqS8dcO6NTTHTMJ44MUCBDoLEIyPtwEn7PssKqUYL49R1iCVJPeiPzPlKi6DbH33eZkpeJ27e4vHg==
dependencies:
"@types/node" "*"
minipass "^3.3.5"
"@types/verror@^1.10.3":
version "1.10.6"
resolved "https://registry.yarnpkg.com/@types/verror/-/verror-1.10.6.tgz#3e600c62d210c5826460858f84bcbb65805460bb"
@ -3209,7 +3229,7 @@ array.prototype.flat@^1.2.5:
es-abstract "^1.20.4"
es-shim-unscopables "^1.0.0"
asar@^3.0.0, asar@^3.0.3, asar@^3.1.0:
asar@^3.0.3, asar@^3.1.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/asar/-/asar-3.2.0.tgz#e6edb5edd6f627ebef04db62f771c61bea9c1221"
integrity sha512-COdw2ZQvKdFGFxXwX3oYh2/sOsJWJegrdJCGxnN4MZ7IULgRBp9P6665aqj9z1v9VwP4oP1hRBojRDQ//IGgAg==
@ -6467,7 +6487,7 @@ minipass@^2.6.0, minipass@^2.9.0:
safe-buffer "^5.1.2"
yallist "^3.0.0"
minipass@^3.0.0, minipass@^3.1.1, minipass@^3.1.6:
minipass@^3.0.0, minipass@^3.1.1, minipass@^3.1.6, minipass@^3.3.5:
version "3.3.6"
resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.3.6.tgz#7bba384db3a1520d18c9c0e5251c3444e95dd94a"
integrity sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==
@ -6526,7 +6546,7 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
needle@^2.5.0, needle@^2.5.2:
needle@^2.5.2:
version "2.9.1"
resolved "https://registry.yarnpkg.com/needle/-/needle-2.9.1.tgz#22d1dffbe3490c2b83e301f7709b6736cd8f2684"
integrity sha512-6R9fqJ5Zcmf+uYaFgdIHmLwNldn5HbK8L5ybn7Uz+ylX/rnOsSp1AHcvQSrCaFN+qNM1wpymHqD7mVasEOlHGQ==