import Print from '../class/print.js'
import Plugin from '../class/plugin.js'
import Log from '../class/log.js'
import os from 'os'
import fs from 'fs-extra'
import lodash from 'lodash'
import semver from 'semver'
import aneka from 'aneka/index.js'
import outmatch from 'outmatch'
import fastGlob from 'fast-glob'
import * as readline from 'readline/promises'
import { sprintf } from 'sprintf-js'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc.js'
import customParseFormat from 'dayjs/plugin/customParseFormat.js'
import localizedFormat from 'dayjs/plugin/localizedFormat.js'
import weekOfYear from 'dayjs/plugin/weekOfYear.js'
import defConfig from './config.js'
import path from 'path'
const { resolvePath } = aneka
const {
isFunction, isPlainObject, cloneDeep, merge, forOwn, groupBy, find, reduce, map,
trim, keys, intersection, each, camelCase, get, orderBy, pick, values, set,
without, uniq, isEmpty, kebabCase, isString
} = lodash
/**
* # Helper
*
* Internal helpers called by Bajo and other classes. It should remains
* hidden and not to be imported by any program. It listed here for documentation purpose only.
*
* @module Helper
*/
const omitted = ['spawn', 'cwd', 'name', 'alias', 'applet', 'a', 'plugins']
/**
* Main plugin class. This is the special plugin created by Bajo during the boot process, solely made
* for you to write your own code for your application. It behaves like any other plugin, with a few differences:
*
* - It always there, can't be disabled nor removed, reside in the app's root directory named `main`.
* If it's folder is missing, Bajo will create it during the boot process with the default template and settings.
* - You don't have to put this plugin in your `.plugin` file or `package.json` file,
* it is always present and always loaded as the **last** plugin to load.
* - You write your app the same way you would do it with plugin:
* - by creating properties & methods within your class
* - by extending other plugins inside `/main/extend/{pluginNs}` folder
* - by tapping hooks inside `/main/extend/bajo/hook` folder or `/main/extend/bajo/hook.js` file
* - you even have your own config file in `{dataDir}/config/main.{ext}`
*
* Why it has to be a plugin? Because Bajo is a plugin-based framework, and everything is a plugin.
* This brings some flexibilities, e.g. when someday you think your main plugin become too big,
* just turn it into a separate plugin and move it to its own package with almost no code changes at all.
*
* For more on this, see {@link Plugin} and {@link module:Hook}
*
* @class
* @global
* @name Main
*/
const defMain = `async function factory (pkgName) {
const me = this
return class Main extends this.app.baseClass.Base {
constructor () {
super(pkgName, me.app)
this.config = {}
}
}
}
export default factory
`
/**
* Outmacth with support for scoped source and pattern. Scoped source/pattern is a source/pattern that has a plugin's namespace prefix,
* separated by `:` symbol.
*
* This function is usefull for matching a source/pattern that is scoped to a specific plugin's namespace.
* For example, if you have a source `myPlugin:foo/bar` and a pattern `myPlugin:foo/*`,
* this function will return `true` because the source matches the pattern within the same namespace.
*
* @param {string} source - Scoped source to match against the pattern. It should be in the format of `ns:path`
* @param {string} pattern - Scoped pattern to match against the source. It should be in the format of `ns:path`
* @returns {boolean} Returns `true` if the source matches the pattern, otherwise `false`.
*/
export function outmatchNs (source, pattern) {
const { breakNsPath } = this.bajo
const [src, subSrc] = source.split(':')
if (!subSrc) return pattern === src
try {
const { fullNs, path } = breakNsPath(pattern)
const isMatch = outmatch(path)
return src === fullNs && isMatch(subSrc)
} catch (err) {
return false
}
}
/**
* Parse object and normalize their values.
*
* Support automatic value translation for keys with `t:` prefix. The keys are then
* converted to the one without the `t:` prefix, and the original key is removed from the object.
*
* See {@link https://ardhi.github.io/aneka/global.html#parseObject|aneka.parseObject()} for more details.
*
* @method
* @param {*} obj - The object to parse and normalize.
* @param {object} [options={}] - Options for parsing and translation.
* @returns {object} The parsed and normalized object.
*/
export function parseObject (obj, options = {}) {
const me = this
const { ns = 'bajo', lang } = options
options.translator = {
lang,
prefix: 't:',
handler: val => {
const [text, ...args] = val.split('|')
args.push({ lang })
return me[ns].t(text, ...args)
}
}
return aneka.parseObject(obj, options)
}
/**
* Normalize file path to lock file. If the provided `file` is an absolute path, it will be returned as is.
* Otherwise, it will be converted to a lock file path in the temporary directory of the application.
* @param {string} file File path
* @param {boolean} [ensureFile=true] Whether to ensure the lock file exists. Defaults to true.
* @returns {string} Normalized lock file path.
*/
function getLockFile (file, ensureFile = true) {
if (path.isAbsolute(file)) return file
file = `${this.bajo.dir.tmp}/${kebabCase(file)}.lock`
if (ensureFile) fs.ensureFileSync(file)
return file
}
/**
* Acquire a lock on a specified file to prevent concurrent access.
*
* @param {string} file - The path to the file to lock. If no path found, it will be created in `{tmpDir}`. Can be in many formats, see {@link https://ardhi.github.io/bajo/App.html#getPluginFile|getPluginFile} for details.
* @param {Object} [options={}] - The options for locking the file.
* @returns {Promise<function>} - A promise that resolves to the lock release function.
*/
async function lockFile (file, options = {}) {
const { ensureFile = true, silent = true } = options
const lockfile = await this.bajo.importPkg('bajo:@bybrave/proper-lockfile2')
options.fs = fs
try {
file = getLockFile.call(this, file)
if (ensureFile) await fs.ensureFile(file)
return await lockfile.lock(file, options)
} catch (err) {
if (silent) return null
throw err
}
}
/**
* Unlock a specified file that was previously locked, allowing concurrent access again.
*
* If file does not exist, it will return `true` immediately.
* @param {string} file - The path to the file to unlock. If no path found, it will be searched in `{tmpDir}`. Can be in many formats, see {@link https://ardhi.github.io/bajo/App.html#getPluginFile|getPluginFile} for details.
* @param {Object} [options={}] - The options for unlocking the file.
* @returns {Promise<boolean|null>} - A promise that resolves to `true` if the file was successfully unlocked, `null` if silent and an error occurred.
*/
async function unlockFile (file, options = {}) {
const { silent = true } = options
const lockfile = await this.bajo.importPkg('bajo:@bybrave/proper-lockfile2')
options.fs = fs
try {
file = getLockFile.call(this, file)
if (!fs.existsSync(file)) return true
await lockfile.unlock(file)
return true
} catch (err) {
if (silent) return null
throw err
}
}
/**
* Shortcut to `aneka.setInterval()` with Bajo's lockFile support.
* @param {function} handler - The function to be executed at each interval
* @param {number|string} timeout - In milliseconds or a string that can be parsed by {@link https://ardhi.github.io/aneka/global.html#parseDuration|aneka.parseDuration()}.
* @param {Object|string} [options={}] - If a string, it will be treated as `lockFile` path. Otherwise, it will be passed to `aneka.setInterval()` as is.
*/
async function setInterval (handler, timeout, options = {}) {
if (isString(options)) options = { lockFile: options }
if (!path.isAbsolute(options.lockFile) && options.scope instanceof Plugin) options.lockFile = options.scope.ns + ':' + options.lockFile
options.lockFile = getLockFile.call(this, options.lockFile)
await aneka.setInterval(handler, timeout, options)
}
dayjs.extend(utc)
dayjs.extend(customParseFormat)
dayjs.extend(localizedFormat)
dayjs.extend(weekOfYear)
/**
* Supported data types.
*
* @typedef {Object} TDataType
* @memberof Bajo
* @type {Array}
* @property {string} 0 string
* @property {string} 1 float
* @property {string} 2 double
* @property {string} 3 integer
* @property {string} 4 smallint
* @property {string} 5 date
* @property {string} 6 time
* @property {string} 7 datetime
* @property {string} 8 array
* @property {string} 9 object
*/
const dataTypes = Object.keys(defConfig.intl.formatter)
/**
* Most commonly used libraries by Bajo and its plugins. They are already imported and ready to use,
* so you don't have to import them again in your plugin.
*
* Example:
* ```javascript
* const { fs, dayjs } = this.app.lib
* fs.ensureDirSync('/path/to/dir')
* const now = dayjs().format('YYYY-MM-DD HH:mm:ss')
* ```
* @typedef {Object} TLib
* @memberof App
* @property {Object} _ Access to {@link https://lodash.com|lodash}.
* @property {Object} fs Access to {@link https://github.com/jprichardson/node-fs-extra|fs-extra}.
* @property {Object} fastGlob Access to {@link https://github.com/mrmlnc/fast-glob|fast-glob}.
* @property {Object} sprintf Access to {@link https://github.com/alexei/sprintf.js|sprintf}.
* @property {Object} aneka Access to {@link https://github.com/ardhi/aneka|aneka}.
* @property {Object} outmatch Access to {@link https://github.com/axtgr/outmatch|outmatch}.
* @property {Object} dayjs Access to {@link https://day.js.org|dayjs} with utc & customParseFormat plugin already applied.
* @property {function} freeze Freeze object. See {@link module:Helper.freeze|freeze} for more details.
* @property {function} findDeep Deep file file in an array of files. See {@link module:Helper.findDeep|findDeep} for more details.
* @property {function} outmatchNs Like outmatch, but support scoped source & pattern. See {@link module:Helper.outmatchNs|outmatchNs} for more details.
* @property {function} parseObject Parse object and normalize their values. Also support translation. See {@link module:Helper.parseObject|parseObject} for more details.
* @property {function} lockFile Lock a file. See {@link module:Helper.lockFile|lockFile} for more details.
* @property {function} unlockFile Unlock a file. See {@link module:Helper.unlockFile|unlockFile} for more details.
* @property {function} setInterval Like `aneka.setInterval()`, but with Bajo's lockFile support. See {@link module:Helper.setInterval|setInterval} for more details.
* @property {Array} dataTypes Supported data types. See {@link Bajo.TDataType|TDataType} for more details.
* @property {Object} [anekaSpatial] Access to {@link https://ardhi.github.io/bajo-spatial|aneka-spatial} helpers if `bajoSpatial` plugin is loaded.
* @see App
*/
export const lib = {
_: lodash,
fs,
fastGlob,
sprintf,
outmatch,
dayjs,
aneka,
freeze,
findDeep,
outmatchNs,
parseObject,
lockFile,
unlockFile,
setInterval,
dataTypes
}
export async function ask (question, ...args) {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
let answer
try {
answer = await rl.question(this.t(question, ...args))
} catch (err) {
console.error(err)
} finally {
rl.close()
}
return answer
}
/**
* Ensure the existence of necessary directories for Bajo. This function is called by Bajo
* during the {@link Bajo#init|initialization process}:
* - it sets up the base directory,
* - it checks the validity of the data directory,
* - it ensures the temporary directory exists,
*
* @async
* @method
*/
export async function ensureDirs () {
const { defaultsDeep, currentLoc, resolvePath } = this.app.lib.aneka
this.config = defaultsDeep({}, this.app.envVars._, this.app.argv._)
set(this, 'dir.base', this.app.dir)
const path = currentLoc(import.meta).dir + '/..'
set(this, 'dir.pkg', resolvePath(path))
if (get(this, 'config.dir.data')) set(this, 'dir.data', this.config.dir.data)
if (!get(this, 'dir.data')) set(this, 'dir.data', `${this.dir.base}/data`)
this.dir.data = resolvePath(this.dir.data)
if (!fs.existsSync(this.dir.data)) {
console.error("Data directory does not exist: '%s'", this.dir.data)
let answer
do {
answer = await ask.call(this, 'Create one now? (y/n) ', this.dir.data)
answer = answer.trim().toLowerCase()
} while (!['y', 'n'].includes(answer))
if (answer === 'n') this.app.exit(true, 'Aborted')
fs.ensureDirSync(this.dir.data)
}
fs.ensureDirSync(`${this.dir.data}/config`)
if (!this.dir.tmp) {
this.dir.tmp = `${resolvePath(os.tmpdir())}/${this.ns}`
fs.ensureDirSync(this.dir.tmp)
}
}
/**
* Collect all plugins defined in `package.json` or `.plugins` file, and add them to the
* app's plugin list. It will also make sure the `main` plugin is there too, if not, it will be
* created with the default template and settings.
*/
export async function collectPlugins () {
const { textToArray } = this.app.lib.aneka
this.pkg = await this.getPkgInfo()
let pluginPkgs = this.app.pluginPkgs
if (isEmpty(pluginPkgs)) {
const mainPkg = await this.getPkgInfo(this.app.dir)
pluginPkgs = get(mainPkg, 'bajo.plugins', [])
if (isEmpty(pluginPkgs)) {
const pluginsFile = `${this.dir.data}/config/.plugins`
if (fs.existsSync(pluginsFile)) {
pluginPkgs = textToArray(fs.readFileSync(pluginsFile, 'utf8'))
}
}
}
this.app.pluginPkgs = without(uniq(pluginPkgs), this.app.mainNs)
this.app.pluginPkgs.push(this.app.mainNs)
}
/**
* Load, build and sanitize all plugins defined in `package.json` or `.plugins` file, and add them to the app's plugin list.
* It will also make sure the `main` plugin is there too, if not, it will be created with the default template and settings.
*
* Finally, it will loop through all loaded plugins and its config and language files.
*
* This function is called by Bajo during the {@link Bajo#init|initialization process}.
* @async
*/
export async function buildPlugins () {
const { resolvePath } = this.app.lib.aneka
this.log.trace('buildPluginsStart')
for (const pkg of this.app.pluginPkgs) {
const ns = camelCase(pkg)
let dir
if (ns === 'main') {
dir = `${this.dir.base}/${this.app.mainNs}`
fs.ensureDirSync(dir)
if (!fs.existsSync(`${dir}/index.js`)) {
fs.writeFileSync(`${dir}/index.js`, defMain, 'utf8')
}
} else dir = this.getModuleDir(pkg)
const factory = `${dir}/index.js`
if (!fs.existsSync(factory)) throw this.error('pluginPackageNotFound%s', pkg)
const { default: builder } = await import(resolvePath(factory, true))
const ClassDef = await builder.call(this, pkg)
const plugin = new ClassDef()
if (!(plugin instanceof this.app.baseClass.Base)) throw this.error('pluginPackageInvalid%s', pkg)
plugin.pkg = plugin.getPkgInfo(ns === 'main' ? this.dir.base : dir)
plugin.alias = ns === 'main' ? this.app.mainNs : get(plugin.pkg, 'bajo.alias', (pkg.slice(0, 5) === 'bajo-' ? pkg.slice(5) : ns).toLowerCase())
plugin.dependencies = get(plugin.pkg, 'bajo.dependencies', [])
this.app.addPlugin(plugin, ClassDef)
this.log.trace('- ' + pkg)
}
// load all plugins' config & language files
for (const ns of this.app.getAllNs()) {
await this.app[ns].loadConfig()
this.app[ns].print = new Print(this.app[ns])
await this.app.loadIntl(ns)
}
this.log.debug('buildPluginsComplete')
}
/**
* Collect all plugins' config handlers and push them to the app's {@link App#configHandlers|configHandlers} array.
* @async
*/
export async function collectConfigHandlers () {
for (const pkg of this.app.pluginPkgs) {
let dir
try {
dir = this.getModuleDir(pkg)
} catch (err) {}
if (!dir) continue
const file = `${dir}/extend/bajo/config-handlers.js`
let mod = await this.importModule(file)
if (!mod) continue
if (isFunction(mod)) mod = await mod.call(this.app[camelCase(pkg)])
if (isPlainObject(mod)) mod = [mod]
mod.forEach(m => set(m, 'ns', camelCase(pkg)))
this.app.configHandlers = this.app.configHandlers.concat(mod)
}
}
/**
* Building Bajo configuration object. Called after all config handlers are collected,
* this function will read config files written in any of config handlers' supported formats:
* - if runtime configuration object is provided (the one passed to the app constructor), it will be
* used as the base. Otherwise, files are read from the `{dataDir}/config`
* - then it will be merged with the configuration coming from the command line arguments and environment variables
* - doing some normalization and validation works, including setting the language, environment, log level, and cache purge settings
* - and setting up the app's log instance with the proper log level
*
* @async
*/
export async function buildConfig () {
// config merging
const { defaultsDeep, includes } = this.app.lib.aneka
const { parseObject } = this.app.lib
const { isEmpty, get, isString, without, omit } = this.app.lib._
let resp = get(this, `app.options.config.${this.ns}`, {})
if (isEmpty(resp)) resp = await this.readAllConfigs(`${this.dir.data}/config/${this.ns}`)
resp = omit(resp, omitted)
const envs = this.app.envs
this.config = defaultsDeep({}, this.config, resp, defConfig)
// language
this.config.lang = (this.config.lang ?? '').split('.')[0]
await this.app.loadIntl(this.ns)
this.print = new Print(this)
// environment
if (values(envs).includes(this.config.env)) this.config.env = this.app.lib.aneka.getKeyByValue(envs, this.config.env)
if (!keys(envs).includes(this.config.env)) throw this.error('unknownEnv%s%s', this.config.env, this.join(keys(envs), { lastSeparator: this.t('or') }))
process.env.NODE_ENV = envs[this.config.env]
if (!this.config.log.level) this.config.log.level = this.config.env === 'dev' ? 'debug' : 'info'
// misc
const obj = this.app.applet ? this.config : pick(this.config, keys(defConfig))
this.config = parseObject(obj, { parseValue: true })
const exts = this.app.getConfigFormats()
if (this.app.applet) {
if (!this.app.pluginPkgs.includes('bajo-cli')) throw this.error('appletNeedsBajoCli')
if (!this.config.log.applet) this.config.log.level = 'silent'
this.config.exitHandler = false
}
if (this.config.runtime.noWarning) process.removeAllListeners('warning')
if (isString(this.config.cache.purge)) this.config.cache.purge = [this.config.cache.purge]
this.config.cache.purge = without(this.config.cache.purge, '', null, undefined)
if (this.config.cache.purge.length > 0) {
if (includes(['all', '*'], this.config.cache.purge)) this.app.cache.purge('*')
else {
for (const name of this.config.cache.purge) {
this.app.cache.purge(name)
}
}
}
this.app.log = new Log(this.app)
this.log.trace('dataDir%s', this.dir.data)
this.log.debug('configHandlers%s', this.join(exts))
}
/**
* Determine the boot order of plugins based on their `bajo.bootorder` property in their `package.json` file.
* If not provided, the plugin will be booted in the order they are listed in the `package.json` file or `.plugins` file.
*
* @async
*/
export async function bootOrder () {
const { freeze } = this.app.lib
const { isNumber } = this.app.lib._
this.log.debug('setupBootOrder')
let counter = 1000
const orders = []
for (const pkg of this.app.pluginPkgs) {
const item = { pkg }
const ns = camelCase(pkg)
const order = get(this.app[ns], 'pkg.bajo.bootorder')
if (isNumber(order)) item.val = order
else {
item.val = counter
counter++
}
orders.push(item)
}
this.app.pluginPkgs = map(orderBy(orders, ['val']), 'pkg')
this.log.debug('runInEnv%s', this.t(this.app.envs[this.config.env]))
// misc
freeze(this.config)
}
/**
* Ensure the uniqueness of all plugins' name and alias. If there is a clash, an error will be thrown.
* @async
*/
export async function checkNameAliases () {
this.log.debug('checkAliasNameClash')
const refs = []
for (const pkg of this.app.pluginPkgs) {
const plugin = this.app[camelCase(pkg)]
const { ns, alias } = plugin
let item = find(refs, { ns })
if (item) throw this.error('pluginNameClash%s%s%s%s', ns, pkg, item.ns, item.pkg, { code: 'BAJO_NAME_CLASH' })
item = find(refs, { alias })
if (item) throw this.error('pluginNameClash%s%s%s%s', alias, pkg, item.alias, item.pkg, { code: 'BAJO_ALIAS_CLASH' })
refs.push({ ns, alias, pkg })
}
}
/**
* Ensure all plugins' dependencies are met. If there is a missing dependency, an error will be thrown.
*
* Semver is also supported, so if a plugin requires a specific version of another plugin, it will be checked as well.
* @async
*/
export async function checkDependencies () {
const { join } = this
this.log.debug('checkDeps')
for (const pkg of this.app.pluginPkgs) {
const plugin = this.app[camelCase(pkg)]
const { ns, dependencies } = plugin
this.log.trace('- %s', ns)
const odep = reduce(dependencies, (o, k) => {
const item = map(k.split('@'), m => trim(m))
if (k[0] === '@') o['@' + item[1]] = item[2]
else o[item[0]] = item[1]
return o
}, {})
const deps = keys(odep)
if (deps.length > 0) {
if (intersection(this.app.pluginPkgs, deps).length !== deps.length) {
throw this.error('dependencyUnfulfilled%s%s', pkg, join(deps), { code: 'BAJO_DEPENDENCY' })
}
each(deps, d => {
if (!odep[d]) return
const ver = get(this.app[camelCase(d)], 'pkg.version')
if (!ver) return
if (!semver.satisfies(ver, odep[d])) {
throw this.error('semverCheckFailed%s%s', pkg, `${d}@${odep[d]}`, { code: 'BAJO_DEPENDENCY_SEMVER' })
}
})
}
}
}
/**
* Collect all plugins' hooks and push them to the app's {@link App#hooks|hooks} array.
* @async
*/
export async function collectHooks () {
const { eachPlugins, runHook, isLogInRange, importModule } = this
const { isArray, isPlainObject } = this.app.lib._
const me = this // "this" is "bajo"
this.log.trace('collecting%s', this.t('hooks'))
await eachPlugins(async function ({ dir, file }) {
let mod = await importModule(file, { asHandler: true })
if (!mod) return undefined
if (file.includes('hook.js')) mod = await mod.handler.call(this)
if (isArray(mod)) {
for (const m of mod) {
if (!isPlainObject(m)) continue
if (!m.name) throw me.bajo.error('missing%s%s', 'name', file)
if (isArray(m.name)) {
for (const name of m.name) {
me.hooks.push(merge({}, m, { name, src: this.ns }))
}
} else {
m.src = this.ns
me.hooks.push(m)
}
}
} else {
const _file = file.replace(dir + '/hook/', '').replace('.js', '')
let [names, path] = _file.split('@')
names = names.split('$').map(n => trim(n))
for (let name of names) {
name = name.split('.').map(n => camelCase(n)).join('.')
const m = merge({}, mod, { name: `${name}:${camelCase(path)}`, src: this.ns })
me.hooks.push(m)
}
}
}, { glob: ['hook/*.js', 'hook.js'], prefix: this.ns })
// for log trace purpose only
if (isLogInRange('trace')) {
const items = groupBy(this.hooks, item => item.name)
forOwn(items, (v, k) => {
const [name, path] = k.split(':')
this.log.trace('- %s:%s (%s)', name, path, v.length)
})
}
await runHook('bajo:afterCollectHooks', this.hooks)
this.log.debug('collected%s%d', this.t('hooks'), this.hooks.length)
}
/**
* Run all loaded plugins' `init()` and `start()` methods in the order determined previously during boot process.
*
* @async
* @see module:Hook
*/
export async function runPlugins () {
const me = this
const { runHook, eachPlugins, join } = this
const { freeze } = this.app.lib
const methods = ['init']
if (!this.app.applet) methods.push('start')
for (const method of methods) {
await runHook(`bajo:${camelCase(`before all ${method}`)}`)
await eachPlugins(async function () {
const { ns } = this
await runHook(`${ns}:${camelCase(`before ${method}`)}`)
if (me.app[ns].pkg.bajo.appletSupport === false && me.app.applet) {
me.log.warn('pluginNoAppletSupport%s', ns)
return false
}
await me.app[ns][method]()
await runHook(`${ns}:${camelCase(`after ${method}`)}`)
if (method === 'start') freeze(me.app[ns].config)
})
await runHook(`bajo:${camelCase(`after all ${method}`)}`)
}
if (this.config.log.level === 'trace') {
let text = join(map(this.app.pluginPkgs, b => camelCase(b)))
text += ` (${this.app.pluginPkgs.length})`
this.log.trace('loadedPlugins%s', text)
} else this.log.debug('loadedPlugins%s', this.app.pluginPkgs.length)
}
/**
* Attach plugins exit handlers to make sure when the app shuts down, all plugins shut down gracefully first
*
* @async
*/
export async function exitHandler () {
if (!this.config.exitHandler) return
async function exit (signal) {
if (signal) this.log.warn('signalReceived%s', signal)
const allNs = this.app.getAllNs().reverse()
for (const ns of allNs) {
try {
await this.app[ns].exit()
} catch (err) {}
this.log.trace('exited%s', ns)
}
this.log.debug('appShutdown')
process.exit(0)
}
process.on('SIGINT', async () => {
await exit.call(this, 'SIGINT')
})
process.on('SIGTERM', async () => {
await exit.call(this, 'SIGTERM')
})
process.on('beforeExit', async () => {
await exit.call(this)
})
process.on('uncaughtException', (error, origin) => {
setTimeout(() => {
console.error(error)
// process.exit(1)
}, 50)
})
process.on('unhandledRejection', (reason, promise) => {
const stackFile = reason.stack.split('\n')[1]
if (!stackFile) {
console.error(reason)
return
}
let file
const info = stackFile.match(/\((.*)\)/) // file is in (<file>)
if (info) file = info[1]
else if (stackFile.startsWith(' at ')) file = stackFile.slice(7) // file is stackFile itself
if (!file) return
const parts = file.split(':')
const column = parseInt(parts[parts.length - 1])
const line = parseInt(parts[parts.length - 2])
parts.pop()
parts.pop()
file = parts.join(':')
this.log.error({ file, line, column }, '%s', reason.message)
})
process.on('warning', warning => {
this.log.error('%s', warning.message)
})
}
/**
* If app is in `applet` mode, this little helper should take care plugin's applet boot process
*
* @async
* @see module:Applet
*/
export async function runAsApplet () {
const { isString, map, find } = this.app.lib._
await this.eachPlugins(async function ({ file }) {
const { ns, alias } = this
this.app.applets.push({ ns, file, alias })
}, { glob: 'applet.js', prefix: 'bajoCli' })
this.log.debug('appletModeActivated')
this.print.info('appRunningAsApplet')
if (this.app.applets.length === 0) this.print.fatal('noAppletLoaded')
let name = this.app.applet
if (!isString(name)) {
const select = await this.importPkg('bajoCli:@inquirer/select')
name = await select({
message: this.t('Please select:'),
choices: map(this.app.applets, t => ({ value: t.ns }))
})
}
const [ns, path] = name.split(':')
const applet = find(this.app.applets, a => (a.ns === ns || a.alias === ns))
if (!applet) this.print.fatal('notFound%s%s', this.app.t('applet'), name)
await this.runHook(`${applet.ns}:beforeAppletRun`, ...this.app.args)
await this.app.bajoCli.runApplet(applet, path, ...this.app.args)
await this.runHook(`${applet.ns}:afterAppletRun`, ...this.app.args)
this.app.exit()
}
/**
* Import file/module from any loaded plugins.
*
* E.g. your plugin structure looks like this:
* `
* |- src
* | |- lib
* | | |- my-module.js
* |- index.js
* |- package.json
* `
*
* And this is how to import `my-module.js`:
* `javascript
* const { importModule } = this.app.bajo
* const myModule = await importModule('myPlugin:/src/lib/my-module.js')
* `
*
* @method
* @async
* @param {TNsPathPairs} file File to import.
* @param {Object} [options={}] Options.
* @param {boolean} [options.asDefaultImport=true] If `true` (default), return default imported module.
* @param {boolean} [options.asHandler] If `true`, return as a {@link HandlerType|handler}.
* @param {boolean} [options.noCache] If `true`, always import as a fresh copy.
* @returns {any}
* @see Bajo#importModule
*/
export async function importModule (file, { asDefaultImport = true, asHandler, noCache } = {}) {
const load = async (file, asDefaultImport, noCache = false) => {
file = resolvePath(file, true)
if (noCache) file += `?_=${Date.now()}`
const imported = await import(file)
if (asDefaultImport) return imported.default
return imported
}
if (this) file = this.app.getPluginFile(file)
if (!fs.existsSync(file)) return
let mod = await load(file, asDefaultImport, noCache)
if (!mod) return
if (!asHandler) return mod
if (isFunction(mod)) mod = { level: 999, handler: mod }
if (!isPlainObject(mod)) {
if (this) throw this.error('fileNotModuleHandler%s', file)
throw new Error(`File '${file}' is NOT a handler module`)
}
return mod
}
/**
* Freeze object.
*
* @method
* @param {Object} obj Object to freeze.
* @param {boolean} [shallow=false] If `false` (default), deep freeze object.
*/
export function freeze (obj, options = {}) {
const { shallow = false, clone = false } = options
if (shallow) Object.freeze(obj)
else deepFreeze(obj, clone)
}
// taken from https://github.com/3imed-jaberi/deepfreeze/blob/master/index.js
export function deepFreeze (object, clone = false) {
function _deepFreeze (_object) {
if (Object.isFrozen(_object)) return _object
if (_object instanceof Map) {
_object.set = _object.clear = _object.delete = function () {
throw new Error('Map is read-only')
}
return _object
}
if (_object instanceof Set) {
_object.add = _object.clear = _object.delete = function () {
throw new Error('Set is read-only')
}
return _object
}
Object.freeze(_object)
Object.getOwnPropertyNames(_object).forEach(function (key) {
if (Object.hasOwn(_object, key) && _object[key] !== null &&
(typeof _object[key] === 'object' || typeof _object[key] === 'function') &&
!Object.isFrozen(_object[key])
) _deepFreeze(_object[key])
})
return _object
}
if (!Object.isFrozen(object)) Object.defineProperty(object, 'isDeepFrozen', { value: () => true })
object = clone ? cloneDeep(object) : object
return _deepFreeze(object)
}
/**
* Find item deep in paths.
*
* @method
* @param {string} item Item to find.
* @param {Array} paths Array of path to look for.
* @returns {string}
*/
export function findDeep (item, paths) {
let dir
for (const p of paths) {
const d = `${p}/${item}`
if (fs.existsSync(d)) {
dir = d
break
}
}
return dir
}
/**
* Formatter field mapping.
*
* @typedef {Object} TFormatterFieldMap
* @memberof Bajo
* @type {Object}
* @property {string} [speed='speed']
* @property {string} [distance='distance']
* @property {string} [area='area']
* @property {string} [degree='degree']
* @property {string} [heading='degree']
* @property {string} [course='degree']
*/
export const formatterFieldMap = {
speed: 'speed',
distance: 'distance',
area: 'area',
degree: 'degree',
heading: 'degree',
course: 'degree'
}
/**
* @typedef {Object} TFormatter
* @memberof Bajo
* @type {Object}
* @property {Object} metric - Metric formatter
* @property {Object} metric.speedFn - Function to format speed in metric
* @property {string} metric.speedUnit - Speed unit in metric
* @property {Object} metric.distanceFn - Function to format distance in metric
* @property {string} metric.distanceUnit - Distance unit in metric
* @property {Object} metric.areaFn - Function to format area in metric
* @property {string} metric.areaUnit - Area unit in metric
* @property {Object} metric.degreeFn - Function to format degree in metric
* @property {string} metric.degreeUnit - Degree unit in metric
* @property {string} metric.degreeUnitSep - Degree unit separator in metric
* @property {Object} imperial - Imperial formatter
* @property {Object} imperial.speedFn - Function to format speed in imperial
* @property {string} imperial.speedUnit - Speed unit in imperial
* @property {Object} imperial.distanceFn - Function to format distance in imperial
* @property {string} imperial.distanceUnit - Distance unit in imperial
* @property {Object} imperial.areaFn - Function to format area in imperial
* @property {string} imperial.areaUnit - Area unit in imperial
* @property {Object} imperial.degreeFn - Function to format degree in imperial
* @property {string} imperial.degreeUnit - Degree unit in imperial
* @property {string} imperial.degreeUnitSep - Degree unit separator in imperial
* @property {Object} nautical - Nautical formatter
* @property {Object} nautical.speedFn - Function to format speed in nautical
* @property {string} nautical.speedUnit - Speed unit in nautical
* @property {Object} nautical.distanceFn - Function to format distance in nautical
* @property {string} nautical.distanceUnit - Distance unit in nautical
* @property {Object} nautical.areaFn - Function to format area in nautical
* @property {string} nautical.areaUnit - Area unit in nautical
* @property {Object} nautical.degreeFn - Function to format degree in nautical
* @property {string} nautical.degreeUnit - Degree unit in nautical
* @property {string} nautical.degreeUnitSep - Degree unit separator in nautical
*/
export const formatter = {
metric: {
speedFn: (val) => val,
speedUnit: 'kmh',
distanceFn: (val) => val,
distanceUnit: 'km',
areaFn: (val) => val,
areaUnit: 'km²',
degreeFn: (val) => val,
degreeUnit: '°',
degreeUnitSep: ''
},
imperial: {
speedFn: (val) => val / 1.609,
speedUnit: 'mih',
distanceFn: (val) => val / 1.609,
distanceUnit: 'mi',
areaFn: (val) => val / 2.59,
areaUnit: 'mi²',
degreeFn: (val) => val,
degreeUnit: '°',
degreeUnitSep: ''
},
nautical: {
speedFn: (val) => val / 1.852,
speedUnit: 'kn',
distanceFn: (val) => val / 1.852,
distanceUnit: 'nm',
areaFn: (val) => val / 2.92,
areaUnit: 'nm²',
degreeFn: (val) => val,
degreeUnit: '°',
degreeUnitSep: ''
}
}