diff --git a/packages/cli/changelog/@unreleased/pr-15.v2.yml b/packages/cli/changelog/@unreleased/pr-15.v2.yml new file mode 100644 index 000000000..ff20a55fe --- /dev/null +++ b/packages/cli/changelog/@unreleased/pr-15.v2.yml @@ -0,0 +1,5 @@ +type: improvement +improvement: + description: '[1/] Improvement: Add utility functions for CLI improvements' + links: + - https://github.com/palantir/osdk-ts/pull/15 diff --git a/packages/cli/package.json b/packages/cli/package.json index f6dc2ee88..4374ea8e5 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -31,6 +31,7 @@ "@osdk/gateway": "workspace:*", "@osdk/generator": "workspace:*", "@osdk/shared.net": "workspace:*", + "ajv": "^8.12.0", "archiver": "^6.0.1", "conjure-lite": "^0.3.3", "consola": "^3.2.3", @@ -52,7 +53,7 @@ "imports": { "#net": "./src/net/index.mts" }, - "keywords": [ ], + "keywords": [], "bin": { "osdk": "./bin/osdk.mjs" }, diff --git a/packages/cli/src/util/autoVersion.test.ts b/packages/cli/src/util/autoVersion.test.ts new file mode 100644 index 000000000..389c54366 --- /dev/null +++ b/packages/cli/src/util/autoVersion.test.ts @@ -0,0 +1,83 @@ +/* + * Copyright 2023 Palantir Technologies, Inc. All rights reserved. + * + * 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. + */ + +import { execSync } from "node:child_process"; +import { describe, expect, it, vi } from "vitest"; +import { autoVersion } from "./autoVersion.js"; + +vi.mock("node:child_process"); + +describe("autoVersion", () => { + const execSyncMock = vi.mocked(execSync); + + it("should return a valid SemVer version from git describe", async () => { + const validGitVersion = "1.2.3"; + execSyncMock.mockReturnValue(validGitVersion); + + const version = await autoVersion(); + expect(version).toBe("1.2.3"); + + expect(execSyncMock).toHaveBeenCalledWith( + "git describe --tags --first-parent --dirty", + { encoding: "utf8" }, + ); + }); + + it("should replace default prefix v from git describe output", async () => { + const validGitVersion = "v1.2.3"; + execSyncMock.mockReturnValue(validGitVersion); + const version = await autoVersion(); + + expect(version).toBe("1.2.3"); + expect(execSyncMock).toHaveBeenCalledWith( + "git describe --tags --first-parent --dirty", + { encoding: "utf8" }, + ); + }); + + it("should replace the prefix from the found git tag", async () => { + const validGitVersion = "@package@1.2.3"; + execSyncMock.mockReturnValue(validGitVersion); + + const version = await autoVersion("@package@"); + + expect(version).toBe("1.2.3"); + expect(execSyncMock).toHaveBeenCalledWith( + "git describe --tags --first-parent --dirty --match=\"/^@package@/*\"", + { encoding: "utf8" }, + ); + }); + + it("should only replace the prefix if found at the start of the tag only", async () => { + const validGitVersion = "1.2.3-package"; + execSyncMock.mockReturnValue(validGitVersion); + + const version = await autoVersion("-package"); + + expect(version).toBe("1.2.3-package"); + expect(execSyncMock).toHaveBeenCalledWith( + "git describe --tags --first-parent --dirty --match=\"/^-package/*\"", + { encoding: "utf8" }, + ); + }); + + it("should throw an error if git describe returns a non-SemVer string", async () => { + const nonSemVerGitVersion = "not-semver"; + execSyncMock.mockReturnValue(nonSemVerGitVersion); + + await expect(autoVersion()).rejects.toThrow(); + }); +}); diff --git a/packages/cli/src/util/autoVersion.ts b/packages/cli/src/util/autoVersion.ts new file mode 100644 index 000000000..a784e0d49 --- /dev/null +++ b/packages/cli/src/util/autoVersion.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2023 Palantir Technologies, Inc. All rights reserved. + * + * 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. + */ + +import { execSync } from "node:child_process"; +import { isValidSemver } from "./isValidSemver.js"; + +/** + * Gets the version string using git describe. If the @param tagPrefix is empty, git describe will return the + * latest tag (without any filtering) and if the tag starts with "v", it will be removed. + * @param tagPrefix The prefix to use for matching against tags. Defaults to an empty string. + * @returns A promise that resolves to the version string. + * @throws An error if the version string is not SemVer compliant or if the version cannot be determined. + */ +export async function autoVersion(tagPrefix: string = ""): Promise { + const matchRegExp = new RegExp(tagPrefix == "" ? "^v?" : `^${tagPrefix}`); + const matchClause = tagPrefix != "" ? ` --match="${matchRegExp}*"` : ""; + try { + const gitVersion = execSync( + `git describe --tags --first-parent --dirty${matchClause}`, + { encoding: "utf8" }, + ); + const version = gitVersion.trim().replace(matchRegExp, ""); + if (!isValidSemver(version)) { + throw new Error(`The version string ${version} is not SemVer compliant.`); + } + + return version; + // TODO(zka): Find out possible error messages from git describe and show specific messages. + } catch (error) { + throw new Error( + `Unable to determine the version automatically. Please supply a --version argument. ${error}`, + ); + } +} diff --git a/packages/cli/src/util/config.test.ts b/packages/cli/src/util/config.test.ts new file mode 100644 index 000000000..0e5283776 --- /dev/null +++ b/packages/cli/src/util/config.test.ts @@ -0,0 +1,152 @@ +/* + * Copyright 2023 Palantir Technologies, Inc. All rights reserved. + * + * 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. + */ + +import { findUp } from "find-up"; +import { promises as fsPromises } from "node:fs"; +import { describe, expect, it, vi } from "vitest"; +import { loadFoundryConfig } from "./config.js"; + +vi.mock("find-up"); +vi.mock("node:fs"); + +describe("loadFoundryConfig", () => { + vi.mocked(findUp).mockResolvedValue("/path/foundry.config.json"); + + it("should load and parse the configuration file correctly", async () => { + const correctConfig = { + foundryUrl: "http://localhost", + site: { + application: "test-app", + directory: "/test/directory", + autoVersion: { + type: "git-describe", + }, + }, + }; + + vi.mocked(fsPromises.readFile).mockResolvedValue( + JSON.stringify(correctConfig), + ); + await expect(loadFoundryConfig()).resolves.toEqual({ + configFilePath: "/path/foundry.config.json", + foundryConfig: { + ...correctConfig, + }, + }); + }); + + it("should throw an error if autoVersion type isn't allowed", async () => { + const inCorrectConfig = { + foundryUrl: "http://localhost", + site: { + application: "test-app", + directory: "/test/directory", + autoVersion: { + type: "invalid", + }, + }, + }; + + vi.mocked(fsPromises.readFile).mockResolvedValue( + JSON.stringify(inCorrectConfig), + ); + + await expect(loadFoundryConfig()).rejects.toThrow( + "Config file schema is invalid.", + ); + }); + + it("should throw an error if autoVersion type is missing", async () => { + const inCorrectConfig = { + foundryUrl: "http://localhost", + site: { + application: "test-app", + directory: "/test/directory", + autoVersion: {}, + }, + }; + + vi.mocked(fsPromises.readFile).mockResolvedValue( + JSON.stringify(inCorrectConfig), + ); + + await expect(loadFoundryConfig()).rejects.toThrow( + "Config file schema is invalid.", + ); + }); + + it("should throw an error if the configuration file cannot be read", async () => { + vi.mocked(fsPromises.readFile).mockRejectedValue(new Error("Read error")); + + await expect(loadFoundryConfig()).rejects.toThrow( + "Couldn't read or parse config", + ); + }); + + it("should throw an error if the site key isn't found", async () => { + const fakeConfig = { + foundryUrl: "http://localhost", + }; + + vi.mocked(fsPromises.readFile).mockResolvedValue( + JSON.stringify(fakeConfig), + ); + + await expect(loadFoundryConfig()).rejects.toThrow( + "Config file schema is invalid.", + ); + }); + + it("should throw an error if the site configuration is missing required keys", async () => { + const fakeConfig = { + foundryUrl: "http://localhost", + site: { + directory: "/test/directory", + }, + }; + + vi.mocked(fsPromises.readFile).mockResolvedValue( + JSON.stringify(fakeConfig), + ); + + await expect(loadFoundryConfig()).rejects.toThrow( + "Config file schema is invalid.", + ); + }); + + it("should throw an error if foundryUrl isn't set on top level", async () => { + const fakeConfig = { + site: { + foundryUrl: "http://localhost", + directory: "/test/directory", + application: "test-app", + }, + }; + + vi.mocked(fsPromises.readFile).mockResolvedValue( + JSON.stringify(fakeConfig), + ); + + await expect(loadFoundryConfig()).rejects.toThrow( + "Config file schema is invalid.", + ); + }); + + it("should return undefined if the configuration file is not found", async () => { + vi.mocked(findUp).mockResolvedValue(undefined); + await expect(loadFoundryConfig()).resolves.toBeUndefined(); + }); +}); diff --git a/packages/cli/src/util/config.ts b/packages/cli/src/util/config.ts new file mode 100644 index 000000000..085da0c38 --- /dev/null +++ b/packages/cli/src/util/config.ts @@ -0,0 +1,117 @@ +/* + * Copyright 2023 Palantir Technologies, Inc. All rights reserved. + * + * 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. + */ + +import type { JSONSchemaType } from "ajv"; +import { promises as fsPromises } from "node:fs"; + +interface GitDescribeAutoVersionConfig { + type: "git-describe"; + tagPrefix?: string; +} +type AutoVersionConfig = GitDescribeAutoVersionConfig; + +export interface SiteConfig { + application: string; + directory: string; + autoVersion?: AutoVersionConfig; +} + +export interface FoundryConfig { + foundryUrl: string; + site: SiteConfig; +} + +export interface LoadedFoundryConfig { + foundryConfig: FoundryConfig; + configFilePath: string; +} + +const CONFIG_FILE_NAMES: string[] = [ + "foundry.config.json", +]; + +const CONFIG_FILE_SCHEMA: JSONSchemaType = { + type: "object", + properties: { + foundryUrl: { type: "string" }, + site: { + type: "object", + properties: { + application: { type: "string" }, + directory: { type: "string" }, + autoVersion: { + type: "object", + nullable: true, + oneOf: [ + { + properties: { + "type": { enum: ["git-describe"], type: "string" }, + "tagPrefix": { type: "string", nullable: true }, + }, + }, + ], + required: ["type"], + }, + }, + required: ["application", "directory"], + }, + }, + required: ["foundryUrl", "site"], + additionalProperties: false, +}; + +/** + * Asynchronously loads a configuration file. Looks for any of the CONFIG_FILE_NAMES in the current directory going up to the root directory. + * @returns A promise that resolves to the configuration JSON object, or undefined if not found. + * @throws Will throw an error if the configuration file is found but cannot be read or parsed. + */ +export async function loadFoundryConfig(): Promise< + LoadedFoundryConfig | undefined +> { + const ajvModule = await import("ajv"); + const Ajv = ajvModule.default.default; // https://github.com/ajv-validator/ajv/issues/2132 + const ajv = new Ajv({ allErrors: true }); + const validate = ajv.compile(CONFIG_FILE_SCHEMA); + + const Consola = await import("consola"); + const consola = Consola.consola; + + const { findUp } = await import("find-up"); + const configFilePath = await findUp(CONFIG_FILE_NAMES); + + if (configFilePath) { + let foundryConfig: FoundryConfig; + try { + const fileContent = await fsPromises.readFile(configFilePath, "utf-8"); + // TODO(zka): Parsing the file should be dependent on the file extension. + foundryConfig = JSON.parse(fileContent); + } catch { + throw Error(`Couldn't read or parse config file ${configFilePath}`); + } + + if (!validate(foundryConfig)) { + consola.error( + "The configuration file does not match the expected schema:", + ajv.errorsText(validate.errors), + ); + throw new Error("Config file schema is invalid."); + } + + return { foundryConfig, configFilePath }; + } + + return undefined; +} diff --git a/packages/cli/src/util/isValidSemver.test.ts b/packages/cli/src/util/isValidSemver.test.ts new file mode 100644 index 000000000..b2b3819be --- /dev/null +++ b/packages/cli/src/util/isValidSemver.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright 2023 Palantir Technologies, Inc. All rights reserved. + * + * 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. + */ + +import { describe, expect, it } from "vitest"; +import { isValidSemver } from "./isValidSemver.js"; + +describe("isValidSemver", () => { + it("should return true for a valid SemVer version", () => { + const validVersion = "1.0.0"; + expect(isValidSemver(validVersion)).toBe(true); + }); + + it("should return true for a valid SemVer version with prerelease", () => { + const validVersionWithPrerelease = "1.0.0-alpha.1"; + expect(isValidSemver(validVersionWithPrerelease)).toBe(true); + }); + + it("should return true for a valid SemVer version with build metadata", () => { + const validVersionWithBuild = "1.0.0+20130313144700"; + expect(isValidSemver(validVersionWithBuild)).toBe(true); + }); + + it("should return false for a version missing patch number", () => { + const invalidVersionMissingPatch = "1.0"; + expect(isValidSemver(invalidVersionMissingPatch)).toBe(false); + }); + + it("should return false for a version with non-numeric components", () => { + const invalidVersionNonNumeric = "1.a.b"; + expect(isValidSemver(invalidVersionNonNumeric)).toBe(false); + }); + + it("should return false for a version with a single number", () => { + const invalidVersionNonNumeric = "1"; + expect(isValidSemver(invalidVersionNonNumeric)).toBe(false); + }); + + it("should return false for a completely non-compliant string", () => { + const nonCompliantString = "not-a-version"; + expect(isValidSemver(nonCompliantString)).toBe(false); + }); +}); diff --git a/packages/cli/src/util/token.test.ts b/packages/cli/src/util/token.test.ts new file mode 100644 index 000000000..c2ce45e95 --- /dev/null +++ b/packages/cli/src/util/token.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright 2023 Palantir Technologies, Inc. All rights reserved. + * + * 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. + */ + +import { promises as fsPromises } from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { loadToken, loadTokenFile, validate } from "./token.js"; + +vi.mock("node:fs"); + +// {"header": {"alg":"HS256","typ":"JWT"}, "payload": {"sub":"1234567890","name":"TestUser","iat":1516239022}} +const validToken = + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IlRlc3RVc2VyIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"; + +describe("loadToken", () => { + it("should load a valid token from a direct argument", async () => { + const token = await loadToken(validToken); + expect(token).toBe(validToken); + }); + + it("should load a valid token from a file", async () => { + vi.mocked(fsPromises.readFile).mockResolvedValue( + validToken, + ); + const token = await loadToken(undefined, "valid-token.txt"); + + expect(token).toBe(validToken); + }); + + it("should load a valid token from an environment variable", async () => { + vi.stubEnv("FOUNDRY_TOKEN", validToken); + + const token = await loadToken(); + expect(token).toBe(validToken); + }); + + it("should throw an error if no token is found", async () => { + expect(() => loadToken()).rejects.toThrow("No token found."); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); +}); + +describe("loadTokenFile", async () => { + it("should throw an error if the token file is not found", () => { + expect(() => loadTokenFile("doesnt-exist.txt")) + .rejects.toThrow(`Unable to read token file "doesnt-exist.txt"`); + }); +}); + +describe("validate", () => { + it("should throw an error if the token is invalid", () => { + expect(() => validate("token")) + .toThrow(`Token does not appear to be a JWT`); + }); +}); diff --git a/packages/cli/src/util/token.ts b/packages/cli/src/util/token.ts new file mode 100644 index 000000000..1c3f6aabe --- /dev/null +++ b/packages/cli/src/util/token.ts @@ -0,0 +1,104 @@ +/* + * Copyright 2023 Palantir Technologies, Inc. All rights reserved. + * + * 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. + */ + +import { consola } from "consola"; +import { promises as fsPromises } from "node:fs"; +import path from "node:path"; + +const TOKEN_ENV_VARS = ["FOUNDRY_TOKEN", "FOUNDRY_SDK_AUTH_TOKEN"] as const; + +/** + * Loads a JWT Auth Token from an argument, a file, or environment variable returning the first found. + * @param token The token as a string. + * @param tokenFile The path to the token file. + * @returns The token as a string. + * @throws An error if no token is found. + */ +export async function loadToken( + token?: string, + tokenFile?: string, +): Promise { + if (token) { + consola.debug(`Using token from --token argument`); + validate(token); + return token; + } + + if (tokenFile) { + const loadedToken = await loadTokenFile(tokenFile); + consola.debug( + `Using token from --tokenFile=${loadedToken.filePath} argument`, + ); + validate(loadedToken.token); + return loadedToken.token; + } + + for (const envVar of TOKEN_ENV_VARS) { + const environmentToken = process.env[envVar]; + if (environmentToken) { + consola.debug(`Using token from ${envVar} environment variable`); + validate(environmentToken); + if (envVar === "FOUNDRY_SDK_AUTH_TOKEN") { + consola.warn( + `Using FOUNDRY_SDK_AUTH_TOKEN environment variable is deprecated. Please use FOUNDRY_TOKEN instead.`, + ); + } + return environmentToken; + } + } + + throw new Error( + `No token found. Please supply a --token argument, a --token-file argument or set the ${ + TOKEN_ENV_VARS[0] + } environment variable.`, + ); +} + +interface LoadedToken { + filePath: string; + token: string; +} +/** + * Synchronously reads a JWT Auth Token from a file. + * @param filePath The path to the token file. + * @returns The token as a string. + * @throws An error if the file cannot be read or if the file does not contain a valid JWT. + */ +export async function loadTokenFile(filePath: string): Promise { + let token: string; + let resolvedPath: string; + try { + resolvedPath = path.resolve(filePath); + token = await fsPromises.readFile(resolvedPath, "utf8"); + token = token.trim(); + } catch (error) { + throw new Error(`Unable to read token file "${filePath}": ${error}`); + } + + return { filePath: resolvedPath, token }; +} + +export function validate(token: string): void { + if (!isJWT(token)) { + throw new Error(`Token does not appear to be a JWT`); + } +} + +function isJWT(token: string): boolean { + // https://stackoverflow.com/a/65755789 + const jwtPattern = /^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]*$/; + return jwtPattern.test(token); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a791bbf38..aa9f400cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -305,6 +305,9 @@ importers: '@osdk/shared.net': specifier: workspace:* version: link:../shared.net + ajv: + specifier: ^8.12.0 + version: 8.12.0 archiver: specifier: ^6.0.1 version: 6.0.1 @@ -2802,6 +2805,15 @@ packages: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + /ajv@8.12.0: + resolution: {integrity: sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==} + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js: 4.4.1 + dev: false + /ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -5014,6 +5026,10 @@ packages: /json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + /json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + dev: false + /json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -6119,6 +6135,11 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + /require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + dev: false + /require-main-filename@2.0.0: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} dev: true