Skip to content

Commit

Permalink
feat(cli): support environmental variables
Browse files Browse the repository at this point in the history
  • Loading branch information
shigma committed Jan 23, 2022
1 parent 6d04402 commit 4da89fb
Show file tree
Hide file tree
Showing 7 changed files with 114 additions and 83 deletions.
36 changes: 10 additions & 26 deletions packages/cli/src/addons/watcher.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { App, coerce, Context, Dict, Logger, Plugin, Schema } from 'koishi'
import { App, coerce, Context, Dict, Logger, Plugin, Schema, unwrapExports } from 'koishi'
import { FSWatcher, watch, WatchOptions } from 'chokidar'
import { relative, resolve } from 'path'
import { debounce } from 'throttle-debounce'
Expand All @@ -14,10 +14,6 @@ function loadDependencies(filename: string, ignored: Set<string>) {
return dependencies
}

function unwrap(module: any) {
return module.default || module
}

function deepEqual(a: any, b: any) {
if (a === b) return true
if (typeof a !== typeof b) return false
Expand Down Expand Up @@ -136,27 +132,15 @@ class Watcher {
}

// check plugin changes
const oldPlugins = oldConfig.plugins ||= {}
const newPlugins = newConfig.plugins ||= {}
const oldPlugins = oldConfig.plugins
const newPlugins = newConfig.plugins
for (const name in { ...oldPlugins, ...newPlugins }) {
if (name.startsWith('~') || deepEqual(oldPlugins[name], newPlugins[name])) continue

// resolve plugin
let plugin: any
try {
plugin = this.ctx.loader.resolve(name)
} catch (err) {
logger.warn(err.message)
continue
}

// reload plugin
const state = this.ctx.dispose(plugin)
if (name.startsWith('~')) continue
if (deepEqual(oldPlugins[name], newPlugins[name])) continue
if (name in newPlugins) {
logger.info(`%s plugin %c`, state ? 'reload' : 'apply', name)
this.ctx.app.plugin(plugin, newPlugins[name])
} else if (state) {
logger.info(`dispose plugin %c`, name)
this.ctx.loader.reloadPlugin(name)
} else {
this.ctx.loader.unloadPlugin(name)
}
}
}
Expand Down Expand Up @@ -229,7 +213,7 @@ class Watcher {
// that is, reloading them will not cause any other reloads
for (const filename in require.cache) {
const module = require.cache[filename]
const plugin = unwrap(module.exports)
const plugin = unwrapExports(module.exports)
const state = this.ctx.app.registry.get(plugin)
if (!state || this.declined.has(filename)) continue
pending.set(filename, state)
Expand Down Expand Up @@ -268,7 +252,7 @@ class Watcher {
const attempts = {}
try {
for (const [, filename] of reloads) {
attempts[filename] = unwrap(require(filename))
attempts[filename] = unwrapExports(require(filename))
}
} catch (err) {
// rollback require.cache
Expand Down
86 changes: 65 additions & 21 deletions packages/cli/src/loader.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { resolve, extname, dirname, isAbsolute } from 'path'
import { yellow } from 'kleur'
import { readdirSync, readFileSync, writeFileSync } from 'fs'
import { App, Dict, Logger, Modules, Plugin, Schema } from 'koishi'
import { App, Dict, Logger, interpolate, Modules, Schema, unwrapExports, valueMap } from 'koishi'
import * as yaml from 'js-yaml'

declare module 'koishi' {
Expand Down Expand Up @@ -30,13 +29,21 @@ App.Config.list.push(Schema.object({
let cwd = process.cwd()
const logger = new Logger('app')

const writableExts = ['.json', '.yml', '.yaml']
const supportedExts = ['.js', '.json', '.ts', '.coffee', '.yaml', '.yml']

const context = {
env: process.env,
}

export class Loader {
dirname: string
filename: string
extname: string
app: App
config: App.Config
cache: Dict<Plugin> = {}
cache: Dict<string> = {}
isWritable: boolean

constructor() {
const basename = 'koishi.config'
Expand All @@ -46,13 +53,26 @@ export class Loader {
this.dirname = cwd = dirname(this.filename)
} else {
const files = readdirSync(cwd)
this.extname = ['.js', '.json', '.ts', '.coffee', '.yaml', '.yml'].find(ext => files.includes(basename + ext))
this.extname = supportedExts.find(ext => files.includes(basename + ext))
if (!this.extname) {
throw new Error(`config file not found. use ${yellow('koishi init')} command to initialize a config file.`)
throw new Error(`config file not found`)
}
this.dirname = cwd
this.filename = cwd + '/' + basename + this.extname
}
this.isWritable = writableExts.includes(this.extname)
}

interpolate(source: any) {
if (typeof source === 'string') {
return interpolate(source, context, /\$\{\{(.+?)\}\}/g)
} else if (!source || typeof source !== 'object') {
return source
} else if (Array.isArray(source)) {
return source.map(item => this.interpolate(item))
} else {
return valueMap(source, item => this.interpolate(item))
}
}

loadConfig(): App.Config {
Expand All @@ -67,8 +87,16 @@ export class Loader {
config = module.default || module
}

// validate config before saving
const resolved = new App.Config(config)
let resolved = new App.Config(config)
if (this.isWritable) {
// schemastery may change original config
// so we need to validate config twice
resolved = new App.Config(this.interpolate(config))
} else {
resolved.allowWrite = false
}

config.plugins ||= {}
this.config = config
return resolved
}
Expand All @@ -83,27 +111,43 @@ export class Loader {
}
}

resolve(name: string) {
const path = Modules.resolve(name)
return this.cache[path] = Modules.require(name, true)
resolvePlugin(name: string) {
try {
this.cache[name] ||= Modules.resolve(name)
} catch (err) {
logger.error(err.message)
return
}
return unwrapExports(require(this.cache[name]))
}

unloadPlugin(name: string) {
const plugin = this.resolvePlugin(name)
if (!plugin) return

const state = this.app.dispose(plugin)
if (state) logger.info(`dispose plugin %c`, name)
}

reloadPlugin(name: string) {
const plugin = this.resolvePlugin(name)
if (!plugin) return

const state = this.app.dispose(plugin)
const config = this.config.plugins[name]
logger.info(`%s plugin %c`, state ? 'reload' : 'apply', name)
this.app.validate(plugin, config)
this.app.plugin(plugin, this.interpolate(config))
}

createApp() {
const app = this.app = new App(this.config)
app.loader = this
app.baseDir = this.dirname
const plugins = app.options.plugins ||= {}
const { plugins } = this.config
for (const name in plugins) {
if (name.startsWith('~')) {
this.resolve(name.slice(1))
} else {
logger.info(`apply plugin %c`, name)
const plugin = this.resolve(name)
this.app.plugin(plugin, plugins[name])
}
}
if (!['.json', '.yaml', '.yml'].includes(this.extname)) {
app.options.allowWrite = false
if (name.startsWith('~')) continue
this.reloadPlugin(name)
}
return app
}
Expand Down
37 changes: 25 additions & 12 deletions packages/core/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,35 +242,48 @@ export class Context {
return this.plugin({ using, apply: callback, name: callback.name })
}

plugin(name: string, options?: any): this
plugin<T extends Plugin>(plugin: T, options?: boolean | Plugin.Config<T>): this
plugin(entry: string | Plugin, options?: any) {
if (options === false) return this
if (options === true) options = undefined
options ??= {}
validate<T extends Plugin>(plugin: T, config: any) {
if (config === false) return
if (config === true) config = undefined
config ??= {}

const schema = plugin['Config'] || plugin['schema']
if (schema) config = schema(config)
return config
}

plugin(name: string, config?: any): this
plugin<T extends Plugin>(plugin: T, config?: boolean | Plugin.Config<T>): this
plugin(entry: string | Plugin, config?: any) {
// load plugin by name
const plugin: Plugin = typeof entry === 'string' ? Modules.require(entry, true) : entry

// check duplication
if (this.app.registry.has(plugin)) {
this.logger('app').warn(new Error('duplicate plugin detected'))
return this
}

// check if it's a valid plugin
if (typeof plugin !== 'function' && !isApplicable(plugin)) {
throw new Error('invalid plugin, expect function or object with an "apply" method')
}

const ctx = new Context(this.filter, this.app, plugin).select(options)
// validate plugin config
config = this.validate(plugin, config)
if (!config) return this

const ctx = new Context(this.filter, this.app, plugin).select(config)
const schema = plugin['Config'] || plugin['schema']
const using = plugin['using'] || []
if (schema) options = schema(options)

this.app.registry.set(plugin, {
plugin,
schema,
using,
id: Random.id(),
context: this,
config: options,
config: config,
parent: this.state,
children: [],
disposables: [],
Expand All @@ -290,11 +303,11 @@ export class Context {
const callback = () => {
if (using.some(name => !this[name])) return
if (typeof plugin !== 'function') {
plugin.apply(ctx, options)
plugin.apply(ctx, config)
} else if (isConstructor(plugin)) {
new plugin(ctx, options)
new plugin(ctx, config)
} else {
plugin(ctx, options)
plugin(ctx, config)
}
}

Expand Down
8 changes: 6 additions & 2 deletions packages/core/src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,10 @@ export namespace Database {
}
}

export function unwrapExports(module: any) {
return module.default || module
}

export interface Modules {}

export namespace Modules {
Expand Down Expand Up @@ -172,8 +176,8 @@ export namespace Modules {
export function require(name: string, forced = false) {
try {
const path = resolve(name)
const module = internal.require(path)
return module.default || module
const exports = internal.require(path)
return unwrapExports(exports)
} catch (error) {
if (forced) throw error
}
Expand Down
4 changes: 1 addition & 3 deletions plugins/frontend/manager/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,10 @@
"koishi": "^4.1.0"
},
"devDependencies": {
"@types/cross-spawn": "^6.0.2",
"@types/js-yaml": "^4.0.5"
"@types/cross-spawn": "^6.0.2"
},
"dependencies": {
"cross-spawn": "^7.0.3",
"js-yaml": "^4.1.0",
"semver": "^7.3.5"
}
}
4 changes: 1 addition & 3 deletions plugins/frontend/manager/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,7 @@ export {
declare module '@koishijs/plugin-console' {
interface Events {
'plugin/load'(name: string, config: any): void
'plugin/unload'(name: string): void
'plugin/reload'(name: string, config: any): void
'plugin/save'(name: string, config: any): void
'plugin/unload'(name: string, config: any): void
'bot/create'(platform: string, config: any): void
}

Expand Down
22 changes: 6 additions & 16 deletions plugins/frontend/manager/src/writer.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
import { Context } from 'koishi'
import { writeFileSync } from 'fs'
import { dump } from 'js-yaml'
import { Loader } from '@koishijs/cli'

export default class ConfigWriter {
private loader: Loader
private plugins: {}

constructor(private ctx: Context) {
this.loader = ctx.app.loader
this.plugins = this.loader.config.plugins
this.loader = ctx.loader
this.plugins = ctx.loader.config.plugins

ctx.console.addListener('plugin/load', (name, config) => {
this.loadPlugin(name, config)
Expand All @@ -25,24 +23,17 @@ export default class ConfigWriter {
}

loadPlugin(name: string, config: any) {
const plugin = this.loader.resolvePlugin(name)
const state = this.ctx.dispose(plugin)
if (state) {
state.context.plugin(plugin, config)
} else {
this.ctx.app.plugin(plugin, config)
}
delete this.plugins['~' + name]
this.plugins[name] = config
this.loader.writeConfig()
this.loader.reloadPlugin(name)
}

unloadPlugin(name: string, config: any) {
const plugin = this.loader.resolvePlugin(name)
this.ctx.dispose(plugin)
delete this.plugins[name]
this.plugins['~' + name] = config
this.loader.writeConfig()
this.loader.unloadPlugin(name)
}

async createBot(platform: string, config: any) {
Expand All @@ -53,10 +44,9 @@ export default class ConfigWriter {
} else if (!this.plugins[name]) {
this.plugins[name] = { bots: [] }
}
const adapterConfig = this.plugins[name]
adapterConfig['bots'].push(config)
this.loader.loadPlugin(name, adapterConfig)
this.plugins[name].bots.push(config)
this.loader.writeConfig()
this.loader.reloadPlugin(name)
}

async removeBot() {}
Expand Down

0 comments on commit 4da89fb

Please sign in to comment.