diff --git a/package-lock.json b/package-lock.json index 39f760efa..70e27849f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,7 +23,7 @@ "ie": "build/index.js" }, "devDependencies": { - "@babel/core": "7.22.10", + "@babel/core": "^7.22.10", "@babel/preset-typescript": "^7.22.5", "@jest/globals": "^29.6.1", "@types/jest": "^29.5.7", diff --git a/src/__mocks__/fs/index.ts b/src/__mocks__/fs/index.ts index 7a139a188..1490a63c4 100644 --- a/src/__mocks__/fs/index.ts +++ b/src/__mocks__/fs/index.ts @@ -60,7 +60,7 @@ export const mkdir = (dirPath: string) => dirPath; export const writeFile = async (pathToFile: string, content: string) => { if (pathToFile === 'reject') { - throw Error('Wrong file path'); + throw new Error('Wrong file path'); } const mockPathToFile = 'mock-pathToFile'; diff --git a/src/__tests__/unit/lib/environment.test.ts b/src/__tests__/unit/lib/environment.test.ts new file mode 100644 index 000000000..e4d1c579b --- /dev/null +++ b/src/__tests__/unit/lib/environment.test.ts @@ -0,0 +1,35 @@ +/* eslint-disable @typescript-eslint/ban-ts-comment */ +import {injectEnvironment} from '../../../lib/environment'; + +describe('lib/envirnoment: ', () => { + describe('injectEnvironment(): ', () => { + const context = {}; + + it('checks response to have `execution` property.', async () => { + // @ts-ignore + const response = await injectEnvironment(context); + expect(response).toHaveProperty('execution'); + }); + + it('checks `execution` to have `command` and `environment` props.', async () => { + // @ts-ignore + const response = await injectEnvironment(context); + const {execution} = response; + + expect(execution).toHaveProperty('command'); + expect(execution).toHaveProperty('environment'); + }); + + it('checks environment response type.', async () => { + // @ts-ignore + const response = await injectEnvironment(context); + const {environment} = response.execution; + + expect(typeof environment['date-time']).toEqual('string'); + expect(Array.isArray(environment.dependencies)).toBeTruthy(); + expect(typeof environment['node-version']).toEqual('string'); + expect(typeof environment.os).toEqual('string'); + expect(typeof environment['os-version']).toEqual('string'); + }); + }); +}); diff --git a/src/__tests__/unit/lib/load.test.ts b/src/__tests__/unit/lib/load.test.ts index d4f65f063..0e40636dc 100644 --- a/src/__tests__/unit/lib/load.test.ts +++ b/src/__tests__/unit/lib/load.test.ts @@ -53,7 +53,7 @@ describe('lib/load: ', () => { }, }, }, - context: { + rawContext: { name: 'gsf-demo', description: 'Hello', tags: { @@ -120,7 +120,7 @@ describe('lib/load: ', () => { }, }, }, - context: { + rawContext: { name: 'gsf-demo', description: 'Hello', tags: { diff --git a/src/__tests__/unit/util/json.test.ts b/src/__tests__/unit/util/json.test.ts index 5e7baedf6..1bb1cd451 100644 --- a/src/__tests__/unit/util/json.test.ts +++ b/src/__tests__/unit/util/json.test.ts @@ -17,8 +17,7 @@ describe('util/json: ', () => { expect.assertions(1); try { - const response = await readAndParseJson(path); - console.log(response); + await readAndParseJson(path); } catch (error) { expect(error).toBeInstanceOf(Error); } diff --git a/src/__tests__/unit/util/os-checker.test.ts b/src/__tests__/unit/util/os-checker.test.ts new file mode 100644 index 000000000..d01a7a8da --- /dev/null +++ b/src/__tests__/unit/util/os-checker.test.ts @@ -0,0 +1,151 @@ +/* eslint-disable no-restricted-properties */ +jest.mock('os', () => ({ + platform: () => { + if (process.env.KIND === 'darwin') return 'darwin'; + if (process.env.KIND === 'linux') return 'linux'; + if (process.env.KIND === 'win32') return 'win32'; + + return 'sunos'; + }, + release: () => 'm.m.m', +})); +jest.mock('../../../util/helpers', () => ({ + execPromise: async () => { + if (process.env.KIND === 'darwin' && process.env.REJECT === 'true') + return { + stdout: '', + }; + if (process.env.KIND === 'linux' && process.env.REJECT === 'true') { + return { + stdout: '', + }; + } + if (process.env.KIND === 'win32' && process.env.REJECT === 'true') + return { + stdout: '', + }; + if (process.env.KIND === 'darwin') { + return { + stdout: ` +ProductName: macOS +ProductVersion: 14.3.1 +BuildVersion: 23D60 + `, + }; + } + + if (process.env.KIND === 'linux') { + return { + stdout: ` +Distributor ID: Ubuntu +Description: Ubuntu 22.04.4 LTS +Release: 22.04 +Codename: jammy + `, + }; + } + + if (process.env.KIND === 'win32') { + return { + stdout: ` +OS Name: Microsoft Windows 11 Enterprise +OS Version: 10.0.22631 N/A Build 22631 + `, + }; + } + + return ''; + }, +})); + +import {osInfo} from '../../../util/os-checker'; + +describe('util/os-checker: ', () => { + describe('osInfo(): ', () => { + it('returns object with `os` and `os-version` properties.', async () => { + const response = await osInfo(); + expect.assertions(2); + + expect(response).toHaveProperty('os'); + expect(response).toHaveProperty('os-version'); + }); + + it('returns mac os information.', async () => { + process.env.KIND = 'darwin'; + expect.assertions(1); + + const expectedResponse = { + os: 'macOS', + 'os-version': '14.3.1', + }; + const response = await osInfo(); + expect(response).toEqual(expectedResponse); + }); + + it('returns windows information.', async () => { + process.env.KIND = 'win32'; + expect.assertions(1); + + const expectedResponse = { + os: 'Microsoft Windows 11 Enterprise', + 'os-version': '10.0.22631 N/A Build 22631', + }; + const response = await osInfo(); + expect(response).toEqual(expectedResponse); + }); + + it('returns linux information.', async () => { + process.env.KIND = 'linux'; + expect.assertions(1); + + const expectedResponse = { + os: 'Ubuntu', + 'os-version': '22.04.4 LTS', + }; + const response = await osInfo(); + expect(response).toEqual(expectedResponse); + }); + + it('returns default information.', async () => { + process.env.KIND = 'other'; + expect.assertions(2); + + const response = await osInfo(); + expect(typeof response.os).toEqual('string'); + expect(typeof response['os-version']).toEqual('string'); + }); + + it('returns info from node os on linux.', async () => { + process.env.KIND = 'linux'; + process.env.REJECT = 'true'; + + const response = await osInfo(); + const expectedOS = 'linux'; + const expectedOSVersion = 'm.m.m'; + expect(response.os).toEqual(expectedOS); + expect(response['os-version']).toEqual(expectedOSVersion); + }); + + it('returns info from node os on darwin.', async () => { + process.env.KIND = 'darwin'; + process.env.REJECT = 'true'; + + const response = await osInfo(); + const expectedOS = 'darwin'; + const expectedOSVersion = 'm.m.m'; + expect(response.os).toEqual(expectedOS); + expect(response['os-version']).toEqual(expectedOSVersion); + }); + + it('returns info from node os on win32.', async () => { + process.env.KIND = 'win32'; + process.env.REJECT = 'true'; + + const response = await osInfo(); + const expectedOS = 'win32'; + const expectedOSVersion = 'm.m.m'; + expect(response.os).toEqual(expectedOS); + expect(response['os-version']).toEqual(expectedOSVersion); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 630a8536e..0191f24f3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import {aggregate} from './lib/aggregate'; import {compute} from './lib/compute'; +import {injectEnvironment} from './lib/environment'; import {exhaust} from './lib/exhaust'; import {initalize} from './lib/initialize'; import {load} from './lib/load'; @@ -9,9 +10,8 @@ import {parameterize} from './lib/parameterize'; import {parseArgs} from './util/args'; import {andHandle} from './util/helpers'; import {logger} from './util/logger'; -import {STRINGS} from './config'; -const packageJson = require('../package.json'); +import {STRINGS} from './config'; const {DISCLAIMER_MESSAGE} = STRINGS; @@ -25,12 +25,12 @@ const impactEngine = async () => { logger.info(DISCLAIMER_MESSAGE); const {inputPath, paramPath, outputOptions} = options; - const {tree, context, parameters} = await load(inputPath!, paramPath); + const {tree, rawContext, parameters} = await load(inputPath!, paramPath); + const context = await injectEnvironment(rawContext); parameterize.combine(context.params, parameters); const pluginStorage = await initalize(context.initialize.plugins); const computedTree = await compute(tree, {context, pluginStorage}); const aggregatedTree = aggregate(computedTree, context.aggregation); - context['if-version'] = packageJson.version; exhaust(aggregatedTree, context, outputOptions); return; diff --git a/src/lib/environment.ts b/src/lib/environment.ts new file mode 100644 index 000000000..b1f421bb4 --- /dev/null +++ b/src/lib/environment.ts @@ -0,0 +1,85 @@ +import {DateTime} from 'luxon'; + +import {execPromise} from '../util/helpers'; + +import {Context, ContextWithExec} from '../types/manifest'; +import {NpmListResponse, PackageDependency} from '../types/environment'; +import {osInfo} from '../util/os-checker'; + +const packageJson = require('../../package.json'); + +/** + * 1. Gets the high-resolution real time when the application starts. + * 2. Converts the high-resolution time to milliseconds. + * 3. Gets the current DateTime. + * 4. Subtracts the milliseconds from the current DateTime. + */ +const getProcessStartingTimestamp = () => { + const startTime = process.hrtime(); + + const [seconds, nanoseconds] = process.hrtime(startTime); + const milliseconds = seconds * 1000 + nanoseconds / 1e6; + + const currentDateTime = DateTime.local(); + + const applicationStartDateTime = currentDateTime.minus({ + milliseconds: milliseconds, + }); + + return applicationStartDateTime.toUTC().toString(); +}; + +/** + * Goes through the dependencies, converts them into oneliner. + */ +const flattenDependencies = (dependencies: [string, PackageDependency][]) => + dependencies.map(dependency => { + const [packageName, versionInfo] = dependency; + const {version, extraneous, resolved} = versionInfo; + const ifExtraneous = extraneous ? ` extraneous -> ${resolved}` : ''; + const ifFromGithub = + resolved && resolved.startsWith('git') ? ` (${resolved})` : ''; + const formattedString = `${packageName}@${version}${ + ifExtraneous || ifFromGithub + }`; + + return formattedString; + }); + +/** + * 1. Runs `npm list --json`. + * 2. Parses json data and converts to list. + */ +const listDependencies = async () => { + const {stdout} = await execPromise('npm list --json'); + const npmListResponse: NpmListResponse = JSON.parse(stdout); + const dependencies = Object.entries(npmListResponse.dependencies); + + return flattenDependencies(dependencies); +}; + +/** + * Injects execution information (command, environment) to existing context. + */ +export const injectEnvironment = async (context: Context) => { + const dependencies = await listDependencies(); + const info = await osInfo(); + const dateTime = `${getProcessStartingTimestamp()} (UTC)`; + + const contextWithExec: ContextWithExec = { + ...context, + execution: { + command: process.argv.join(' '), + environment: { + 'if-version': packageJson.version, + os: info.os, + 'os-version': info['os-version'], + 'node-version': process.versions.node, + 'date-time': dateTime, + dependencies, + }, + }, + }; + + return contextWithExec; +}; diff --git a/src/lib/load.ts b/src/lib/load.ts index 568981860..c3c3912ec 100644 --- a/src/lib/load.ts +++ b/src/lib/load.ts @@ -11,7 +11,7 @@ import {Parameters} from '../types/parameters'; */ export const load = async (inputPath: string, paramPath?: string) => { const rawManifest = await openYamlFileAsObject(inputPath); - const {tree, ...context} = validateManifest(rawManifest); + const {tree, ...rawContext} = validateManifest(rawManifest); const parametersFromCli = paramPath && (await readAndParseJson(paramPath)); /** @todo validate json */ @@ -21,7 +21,7 @@ export const load = async (inputPath: string, paramPath?: string) => { return { tree, - context, + rawContext, parameters, }; }; diff --git a/src/types/environment.ts b/src/types/environment.ts new file mode 100644 index 000000000..94c3e6a82 --- /dev/null +++ b/src/types/environment.ts @@ -0,0 +1,19 @@ +export type PackageDependency = { + version: string; + resolved?: string; + overridden: boolean; + extraneous?: boolean; +}; + +type PackageProblem = { + extraneous: string; +}; + +export type NpmListResponse = { + version: string; + name: string; + problems?: PackageProblem[]; + dependencies: { + [key: string]: PackageDependency; + }; +}; diff --git a/src/types/manifest.ts b/src/types/manifest.ts index b641875c3..ce096f779 100644 --- a/src/types/manifest.ts +++ b/src/types/manifest.ts @@ -12,5 +12,18 @@ export type AggregationParams = Manifest['aggregation']; export type AggregationParamsSure = Extract; export type Context = Omit; +export type ContextWithExec = Omit & { + execution: { + command: string; + environment: { + 'if-version': string; + os: string; + 'os-version': string; + 'node-version': string; + 'date-time': string; + dependencies: string[]; + }; + }; +}; export type ManifestParameter = Extract[number]; diff --git a/src/util/errors.ts b/src/util/errors.ts index a82c13bc8..9781b1f5d 100644 --- a/src/util/errors.ts +++ b/src/util/errors.ts @@ -1,7 +1,5 @@ const CUSTOM_ERRORS = [ 'CliInputError', - 'FileNotFoundError', - 'MakeDirectoryError', 'ManifestValidationError', 'ModuleInitializationError', 'InputValidationError', diff --git a/src/util/helpers.ts b/src/util/helpers.ts index 88050c268..55698cac6 100644 --- a/src/util/helpers.ts +++ b/src/util/helpers.ts @@ -1,7 +1,11 @@ -import {STRINGS} from '../config'; +import {exec} from 'node:child_process'; +import {promisify} from 'node:util'; + import {ERRORS} from './errors'; import {logger} from './logger'; +import {STRINGS} from '../config'; + const {ISSUE_TEMPLATE} = STRINGS; /** @@ -35,3 +39,8 @@ export const mergeObjects = (defaults: any, input: any) => { return merged; }; + +/** + * Promise version of Node's `exec` from `child-process`. + */ +export const execPromise = promisify(exec); diff --git a/src/util/os-checker.ts b/src/util/os-checker.ts new file mode 100644 index 000000000..c795ecc61 --- /dev/null +++ b/src/util/os-checker.ts @@ -0,0 +1,112 @@ +import {release, platform} from 'os'; + +import {execPromise} from './helpers'; + +/** + * Executes `lsb_release -a` command in terminal. + * + * ``` + * Distributor ID: Ubuntu + * Description: Ubuntu 22.04.4 LTS + * Release: 22.04 + * Codename: jammy + * ``` + * + * Parses os and os-version from the response. + */ +const getLinuxInfo = async () => { + const {stdout} = await execPromise('lsb_release -a'); + + const parseLinuxVersion = (lsbReleaseResponse: string) => { + const regex = + /Distributor ID: ([^\n]+)\nDescription: +([^ ]+) +([^ ]+) +(.+)\n/; + const match = lsbReleaseResponse.match(regex); + + return { + os: match ? match[1] : platform(), + 'os-version': match ? `${match[3]} ${match[4]}` : release(), + }; + }; + + return parseLinuxVersion(stdout); +}; + +/** + * Executes in CMD `systeminfo | findstr /B /C:"OS Name" /B /C:"OS Version"` command. + * + * ``` + * OS Name: Microsoft Windows 11 Enterprise + * OS Version: 10.0.22631 N/A Build 22631 + * ``` + * + * Parses os and os-version from the response. + */ +const getWindowsInfo = async () => { + const {stdout} = await execPromise( + 'systeminfo | findstr /B /C:"OS Name" /B /C:"OS Version"' + ); + + const parseWindowsInfo = (systemInfoResponse: string) => { + const regex = + /OS Name:\s+([^\n]+)\nOS Version:\s+([\d.]+)\s+(N\/A\s+Build\s+(\d+))/; + const match = systemInfoResponse.match(regex); + + return { + os: match ? match[1] : platform(), + 'os-version': match ? `${match[2]} ${match[3]}` : release(), + }; + }; + + return parseWindowsInfo(stdout); +}; + +/** + * Executes `sw_vers` command in terminal. + * + * ``` + * ProductName: macOS + * ProductVersion: 14.3.1 + * BuildVersion: 23D60 + * ``` + * + * Parses os and os version from the response. + */ +const getMacVersion = async () => { + const {stdout} = await execPromise('sw_vers'); + + const parseMacInfo = (swVersResponse: string) => { + const productNameRegex = /ProductName:\s*(.+)/; + const productVersionRegex = /ProductVersion:\s*(.+)/; + + const nameMatch = swVersResponse.match(productNameRegex); + const versionMatch = swVersResponse.match(productVersionRegex); + + return { + os: nameMatch ? nameMatch[1].trim() : platform(), + 'os-version': versionMatch ? versionMatch[1].trim() : release(), + }; + }; + + return parseMacInfo(stdout); +}; + +/** + * Finds operating system information like name and version. + */ +export const osInfo = async () => { + const osKind = platform(); + + switch (osKind) { + case 'darwin': + return getMacVersion(); + case 'linux': + return getLinuxInfo(); + case 'win32': + return getWindowsInfo(); + default: + return { + os: osKind, + 'os-version': release(), + }; + } +};