src/main/ts/util.ts
import type { StdioOptions } from 'node:child_process'
import crypto from 'node:crypto'
import { createRequire } from 'node:module'
import path, { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import os from 'node:os'
import chalk from 'chalk'
import fse, { SymlinkType } from 'fs-extra'
import fg, { Options as GlobOptions } from 'fast-glob'
import yaml from 'js-yaml'
import { reduce } from 'lodash-es'
import { TFlags, TFlagsMapping } from './ifaces'
const glob = fg.sync
// FIXME Jest workaround: cannot properly mock `child_process` with import API
const cp = createRequire(import.meta.url)('child_process')
const { ensureDirSync, readFileSync } = fse
const __dirname = dirname(fileURLToPath(import.meta.url))
export const invoke = (
cmd: string,
args: string[],
cwd: string,
silent = false,
inherit = true,
skipError = false,
): string | ReturnType<typeof cp.spawnSync> => {
!silent && console.log(chalk.bold('invoke'), cmd, ...args)
const stdio: StdioOptions = inherit
? ['inherit', 'inherit', 'inherit']
: [null, null, null] // eslint-disable-line
const result = cp.spawnSync(cmd, args, { cwd, stdio, shell: true })
if (!skipError && (result.error || result.status)) {
throw result
}
return String(result.stdout?.toString().trim())
}
const checkValue = (
key: string,
value: any,
omitlist: any[],
picklist: any[],
): boolean =>
value !== 'false' &&
!omitlist.includes(key) &&
(picklist.length === 0 || picklist.includes(key))
const formatFlag = (key: string): string =>
(key.length === 1 ? '-' : '--') + key
// https://gist.github.com/nblackburn/875e6ff75bc8ce171c758bf75f304707
const camelToKebab = (string: string): string =>
string.replace(/([\da-z]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase()
export const normalizeFlags = (flags: TFlags): TFlags =>
Object.keys(flags).reduce<TFlags>((m, key) => {
m[camelToKebab(key)] = flags[key]
return m
}, {})
export const formatFlags = (flags: TFlags, ...picklist: string[]): string[] =>
Object.keys(flags).reduce<string[]>((memo, key: string) => {
const omitlist = ['_', '--']
const value = flags[key]
const flag = formatFlag(key)
if (checkValue(key, value, omitlist, picklist)) {
if (Array.isArray(value)) {
value.forEach((val) => {
memo.push(flag, String(val))
})
} else {
memo.push(flag)
if (value !== true) {
memo.push(String(value))
}
}
}
return memo
}, [])
export const mapFlags = (flags: TFlags, mapping: TFlagsMapping): TFlags =>
reduce(
flags,
(memo: TFlags, value: any, key: string) => {
const repl = mapping[key]
let k = key
let v = value
if (repl) {
if (typeof repl === 'string') {
k = repl
} else {
k = repl?.key ?? k
v = repl?.value ?? repl?.values?.[value] ?? v
}
}
memo[k] = v
return memo
},
{},
)
export const isWindows = (): boolean =>
process.platform === 'win32' ||
/^(msys|cygwin)$/.test(process.env.OSTYPE as string)
export const getSymlinkType = (type?: string): SymlinkType =>
(type as SymlinkType) || (isWindows() ? 'junction' : 'dir')
// https://github.com/facebook/jest/issues/2993
export const getYarn = (): string => (isWindows() ? 'yarn.cmd' : 'yarn')
export const getClosestBin = (cmd: string): string => findClosest(`./node_modules/.bin/${cmd}`) as string
export const getNpm = (npmPath = 'system', isWin = isWindows()): string => {
const cmd = isWin ? 'npm.cmd' : 'npm'
if (npmPath === 'system') {
return cmd
}
if (npmPath === 'local') {
return getClosestBin(cmd)
}
return npmPath
}
export const getWorkspaces = (
cwd: string,
manifest: Record<string, any>,
): string[] => {
let packages = manifest.workspaces
if (packages && packages.packages) {
packages = packages.packages
}
if (!packages || packages.length === 0) {
return []
}
// Turn workspaces into list of package.json files.
return glob(
packages.map((p: string) => p.replace(/\/?$/, '/package.json')),
{
cwd,
onlyFiles: true,
absolute: true,
gitignore: true,
} as GlobOptions,
)
}
export const readJson = (path: string): any =>
JSON.parse(readFileSync(path).toString('utf-8').trim())
export const ensureDir = (dir: string): string => {
ensureDirSync(dir)
return dir
}
export const getTemp = (cwd: string, temp?: string) =>
temp
? ensureDir(resolve(cwd, temp))
: fse.mkdtempSync(path.join(os.tmpdir(), `tempy-${crypto.randomBytes(16).toString('hex')}`))
export const attempt = <T>(f: () => T): T | null => {
try {
return f()
} catch {
return null // eslint-disable-line
}
}
export const parseYaml = <T = Record<string, any>>(contents: string): T => {
try {
return yaml.load(contents) as T
} catch (e) {
throw new Error(`YAML required: ${e}`)
}
}
export const formatYaml = yaml.dump
export const getBinVersion = (bin: string, cwd = process.cwd()): string =>
invoke(bin, ['--version'], cwd, true, false)
export const getSelfManifest = () => readJson(findClosest('package.json') as string)
export const addHiddenProp = (obj: Record<string, any>, prop: string, value: any) =>
Object.defineProperty(obj, prop, {
value,
enumerable: false
})
const findParent = (dir: string, target: string): string | null => {
if (fse.existsSync(path.join(dir, target))) {
return dir
}
const parentDir = path.resolve(dir, '..')
return dir === parentDir
? null
: findParent(parentDir, target)
}
const findClosest = (target: string, cwd = __dirname): string | null => {
const found = findParent(cwd, target)
return found
? path.join(found, target)
: null
}
export const sortObject = <T extends Record<any, any>>(obj: T): T =>
obj ? Object.keys(obj).sort((a: string, b: string) => a.localeCompare(b)).reduce((result, key) => {
result[key] = obj[key]
return result
}, Object.create(null)) : obj