import readAllConfigs from '../../lib/read-all-configs.js'
import currentLoc from '../../lib/current-loc.js'
import resolvePath from '../../lib/resolve-path.js'
import omitDeep from 'omit-deep'
import os from 'os'
import fs from 'fs-extra'
import lodash from 'lodash'
import {
buildConfigs,
checkDependencies,
checkNameAliases,
attachMethods,
collectHooks,
run
} from './plugin.js'
const {
reduce,
isNaN,
forOwn,
orderBy,
isFunction,
isPlainObject,
map,
pick,
values,
keys,
set,
get,
isString,
filter,
trim,
without,
uniq,
camelCase,
isEmpty,
omit
} = lodash
const omitted = ['spawn', 'cwd', 'name', 'alias', 'applet', 'a', 'plugins']
const defConfig = {
log: {
dateFormat: 'YYYY-MM-DDTHH:MM:ss.SSS[Z]',
plain: false,
applet: false,
traceHook: false
},
lang: Intl.DateTimeFormat().resolvedOptions().lang ?? 'en-US',
intl: {
supported: ['en-US', 'id'],
fallback: 'en-US',
lookupOrder: [],
format: {
emptyValue: '',
datetime: { dateStyle: 'medium', timeStyle: 'short' },
date: { dateStyle: 'medium' },
time: { timeStyle: 'short' },
float: { maximumFractionDigits: 2 },
double: { maximumFractionDigits: 5 },
smallint: {},
integer: {}
},
unitSys: {
'en-US': 'imperial',
id: 'metric'
}
},
exitHandler: true
}
/**
* @module
*/
/**
* Building bajo base config. Mostly dealing with directory setups:
* - determine base directory
* - check whether data directory is valid
* - ensure data config directory is there
*
* @async
*/
export async function buildBaseConfig () {
const { defaultsDeep } = this.lib.aneka
this.applet = this.app.argv._.applet
this.config = defaultsDeep({}, this.app.env._, this.app.argv._)
set(this, 'dir.base', this.app.dir)
const path = currentLoc(import.meta).dir + '/../..'
set(this, 'dir.pkg', this.resolvePath(path))
if (!get(this, 'dir.data')) set(this, 'dir.data', `${this.dir.base}/data`)
this.dir.data = this.resolvePath(this.dir.data)
if (!fs.existsSync(this.dir.data)) {
console.log('Data directory (%s) doesn\'t exist yet', this.dir.data)
process.exit(1)
}
fs.ensureDirSync(`${this.dir.data}/config`)
if (!this.dir.tmp) {
this.dir.tmp = `${this.resolvePath(os.tmpdir())}/${this.name}`
fs.ensureDirSync(this.dir.tmp)
}
this.app.addPlugin(this)
}
/**
* Building all plugins:
* - read the list of plugins from ```.plugins``` file
* - iterate through the list and build related plugins
* - attach these plugins to the app instance
*
* @async
*/
export async function buildPlugins () {
let pluginPkgs = this.config.plugins ?? []
if (isString(pluginPkgs)) pluginPkgs = [pluginPkgs]
const pluginsFile = `${this.dir.data}/config/.plugins`
if (fs.existsSync(pluginsFile)) {
pluginPkgs = pluginPkgs.concat(filter(map(trim(fs.readFileSync(pluginsFile, 'utf8')).split('\n'), p => trim(p)), b => !isEmpty(b)))
}
this.pluginPkgs = map(filter(without(uniq(pluginPkgs), this.mainNs), p => {
return p[0] !== '#'
}), p => {
return trim(p.split('#')[0])
})
this.pluginPkgs.push(this.mainNs)
for (const pkg of this.pluginPkgs) {
const ns = camelCase(pkg)
let dir
if (ns === 'main') {
dir = `${this.dir.base}/${this.mainNs}`
fs.ensureDirSync(dir)
fs.ensureDirSync(`${dir}/plugin`)
} else dir = this.getModuleDir(pkg)
const factory = `${dir}/index.js`
if (!fs.existsSync(factory)) throw new Error(`Plugin package '${pkg}' file not found!`)
const { default: builder } = await import(resolvePath(factory, true))
const ClassFactory = await builder.call(this, pkg)
const plugin = new ClassFactory()
if (!(plugin instanceof this.lib.Plugin)) throw new Error(`Plugin package '${pkg}' should be an instance of BajoPlugin`)
this.app.addPlugin(plugin, ClassFactory)
}
this.config = omit(this.config, this.app.getPluginNames())
}
/**
* Collect all config handlers, including the one provided by plugins
*
* @async
*/
export async function collectConfigHandlers () {
for (const pkg of this.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]
this.configHandlers = this.configHandlers.concat(mod)
}
}
/**
* Bajo extra config:
* - reading config file
* - merge config with arguments & environments values
* - Set environment (```dev``` or ```prod```)
*
* @async
*/
export async function buildExtConfig () {
// config merging
const { defaultsDeep } = this.lib.aneka
let resp = await readAllConfigs.call(this.app, `${this.dir.data}/config/${this.name}`)
resp = omitDeep(pick(resp, ['log', 'exitHandler', 'env']), omitted)
this.config = defaultsDeep({}, this.config, resp, defConfig)
this.config.env = (this.config.env ?? 'dev').toLowerCase()
if (values(this.envs).includes(this.config.env)) this.config.env = this.lib.aneka.getKeyByValue(this.envs, this.config.env)
if (!keys(this.envs).includes(this.config.env)) throw new Error(`Unknown environment '${this.config.env}'. Supported: ${this.join(keys(this.envs))}`)
process.env.NODE_ENV = this.envs[this.config.env]
if (!this.config.log.level) this.config.log.level = this.config.env === 'dev' ? 'debug' : 'info'
if (this.config.silent) this.config.log.level = 'silent'
if (this.applet) {
if (!this.pluginPkgs.includes('bajo-cli')) throw new Error('Applet needs to have \'bajo-cli\' loaded first')
if (!this.config.log.applet) this.config.log.level = 'silent'
this.config.exitHandler = false
}
const exts = map(this.configHandlers, 'ext')
this.initPrint()
this.initLog()
this.log.debug('configHandlers%s', this.join(exts))
this.config = this.parseObject(this.config, { parseValue: true })
}
/**
* Setup plugins boot orders by reading plugin's ```.bootorder``` file if provided.
*
* @async
*/
export async function bootOrder () {
this.log.debug('setupBootOrder')
const order = reduce(this.pluginPkgs, (o, k, i) => {
const key = map(k.split(':'), m => trim(m))
if (key[1] && !isNaN(Number(key[1]))) o[key[0]] = Number(key[1])
else o[key[0]] = 10000 + i
return o
}, {})
const norder = {}
for (let n of this.pluginPkgs) {
n = map(n.split(':'), m => trim(m))[0]
const dir = n === this.mainNs ? (`${this.dir.base}/${this.mainNs}`) : this.getModuleDir(n)
if (n !== this.mainNs && !fs.existsSync(dir)) throw this.error('packageNotFoundOrNotBajo%s', n)
norder[n] = NaN
try {
norder[n] = Number(trim(await fs.readFile(`${dir}/.bootorder`, 'utf8')))
} catch (err) {}
}
const result = []
forOwn(order, (v, k) => {
const item = { k, v: isNaN(norder[k]) ? v : norder[k] }
result.push(item)
})
this.pluginPkgs = map(orderBy(result, ['v']), 'k')
this.log.info('runInEnv%s', this.print.write(this.envs[this.config.env]))
// misc
this.freeze(this.config)
}
/**
* Iterate through all plugins loaded and do:
* 1. {@link module:class/helper/bajo-plugin.buildConfigs|build configs}
* 2. {@link module:class/helper/bajo-plugin.checkNameAliases|ensure names & aliases uniqueness}
* 3. {@link module:class/helper/bajo-plugin.checkDependencies|ensure dependencies are met}
* 4. {@link module:class/helper/bajo-plugin.attachMethods|build and attach dynamic methods}
* 5. {@link module:class/helper/bajo-plugin.collectHooks|collect hooks}
* 6. {@link module:class/helper/bajo-plugin.run|run plugins}
*
* @async
*/
export async function bootPlugins () {
await buildConfigs.call(this.app)
await checkNameAliases.call(this.app)
await checkDependencies.call(this.app)
await attachMethods.call(this.app)
await collectHooks.call(this.app)
await run.call(this.app)
}
/**
* Attach plugins exit handlers and make sure the app shutdowns gracefully
*
* @async
*/
export async function exitHandler () {
if (!this.config.exitHandler) return
async function exit (signal) {
const { eachPlugins } = this
this.log.warn('signalReceived%s', signal)
await eachPlugins(async function () {
try {
await this.stop()
} catch (err) {}
this.log.trace('exited')
})
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('uncaughtException', (error, origin) => {
setTimeout(() => {
console.error(error)
// process.exit(1)
}, 50)
})
process.on('unhandledRejection', (reason, promise) => {
const stackFile = reason.stack.split('\n')[1]
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
*/
export async function runAsApplet () {
const { isString, map, find } = this.lib._
await this.eachPlugins(async function ({ file }) {
const { name: ns } = this
const { alias } = this.constructor
this.app.bajo.applets.push({ ns, file, alias })
}, { glob: 'applet.js', prefix: 'bajoCli' })
this.log.debug('appletModeActivated')
this.print.info('appRunningAsApplet')
if (this.applets.length === 0) this.print.fatal('noAppletLoaded')
let name = this.applet
if (!isString(this.applet)) {
const select = await this.importPkg('bajoCli:@inquirer/select')
name = await select({
message: this.print.write('Please select:'),
choices: map(this.applets, t => ({ value: t.ns }))
})
}
const [ns, path] = name.split(':')
const applet = find(this.applets, a => (a.ns === ns || a.alias === ns))
if (!applet) this.print.fatal('notFound%s%s', this.print.write('applet'), name)
await this.runHook(`${this.app[applet.ns]}:beforeAppletRun`)
await this.app.bajoCli.runApplet(applet, path, ...this.app.args)
await this.runHook(`${this.app[applet.ns]}:afterAppletRun`)
}