diff --git a/packages/cli/index.d.ts b/packages/cli/index.d.ts index 2bc2904ecb..9b15121114 100644 --- a/packages/cli/index.d.ts +++ b/packages/cli/index.d.ts @@ -1,7 +1,9 @@ import { App } from 'koishi' export * from 'koishi' -export * from './lib/addons' -export * from './lib/worker' + +export type * from './lib/addons' +export type * from './lib/loader' +export type * from './lib/worker' export function defineConfig(config: App.Config): App.Config diff --git a/packages/cli/src/addons/index.ts b/packages/cli/src/addons/index.ts index 9bf3871319..e507477286 100644 --- a/packages/cli/src/addons/index.ts +++ b/packages/cli/src/addons/index.ts @@ -1,25 +1,19 @@ import { App, Context } from 'koishi' import * as daemon from './daemon' import * as logger from './logger' -import FileWatcher, { WatchConfig } from './watcher' -import ConfigWriter from './writer' +import Watcher from './watcher' declare module 'koishi' { - namespace Context { - interface Services { - configWriter: ConfigWriter - fileWatcher: FileWatcher - } - } - interface App { _prolog: string[] + watcher: Watcher } namespace App { interface Config extends daemon.Config { + allowWrite?: boolean logger?: logger.Config - watch?: WatchConfig + watch?: Watcher.Config } } } @@ -31,13 +25,12 @@ export function prepare(config: App.Config) { } export function apply(ctx: Context, config: App.Config) { - ctx.plugin(logger) + logger.apply(ctx.app) ctx.plugin(daemon, config) if (process.env.KOISHI_WATCH_ROOT !== undefined) { (config.watch ??= {}).root = process.env.KOISHI_WATCH_ROOT } - if (ctx.app.loader.enableWriter) ctx.plugin(ConfigWriter) - if (config.watch) ctx.plugin(FileWatcher, config.watch) + if (config.watch) ctx.plugin(Watcher, config.watch) } diff --git a/packages/cli/src/addons/logger.ts b/packages/cli/src/addons/logger.ts index 9c9121eb01..daa87206fd 100644 --- a/packages/cli/src/addons/logger.ts +++ b/packages/cli/src/addons/logger.ts @@ -1,4 +1,4 @@ -import { App, Context, defineProperty, Logger, remove, Schema, version } from 'koishi' +import { App, defineProperty, Logger, remove, Schema, version } from 'koishi' interface LogLevelConfig { // a little different from koishi-utils @@ -76,11 +76,9 @@ export function prepare(config: Config = {}) { Logger.timestamp = Date.now() } -export const name = 'logger' - -export function apply(ctx: Context) { - ctx.app._prolog = prolog - ctx.on('ready', () => { +export function apply(app: App) { + app._prolog = prolog + app.once('ready', () => { remove(Logger.targets, target) }) } diff --git a/packages/cli/src/addons/watcher.ts b/packages/cli/src/addons/watcher.ts index e6836d9732..9fb862fc4b 100644 --- a/packages/cli/src/addons/watcher.ts +++ b/packages/cli/src/addons/watcher.ts @@ -1,23 +1,8 @@ -import { App, coerce, Context, Dict, Logger, Plugin, Schema, Service } from 'koishi' +import { App, coerce, Context, Dict, Logger, Plugin, Schema } from 'koishi' import { FSWatcher, watch, WatchOptions } from 'chokidar' import { relative, resolve } from 'path' import { debounce } from 'throttle-debounce' -export interface WatchConfig extends WatchOptions { - root?: string - debounce?: number -} - -export const WatchConfig = Schema.object({ - root: Schema.string().description('要监听的根目录,相对于当前工作路径。'), - debounce: Schema.number().default(100).description('延迟触发更新的等待时间。'), - ignored: Schema.array(Schema.string()).description('要忽略的文件或目录。'), -}).default(null).description('热重载设置') - -App.Config.list.push(Schema.object({ - watch: WatchConfig, -})) - function loadDependencies(filename: string, ignored: Set) { const dependencies = new Set() function traverse({ filename, children }: NodeJS.Module) { @@ -35,7 +20,7 @@ function unwrap(module: any) { const logger = new Logger('watch') -export default class FileWatcher extends Service { +class Watcher { public suspend = false private root: string @@ -68,8 +53,8 @@ export default class FileWatcher extends Service { /** stashed changes */ private stashed = new Set() - constructor(ctx: Context, private config: WatchConfig) { - super(ctx, 'fileWatcher') + constructor(private ctx: Context, private config: Watcher.Config) { + ctx.app.watcher = this } private triggerFullReload() { @@ -79,20 +64,24 @@ export default class FileWatcher extends Service { start() { const { root = '', ignored = [] } = this.config - this.root = resolve(this.ctx.app.loader.dirname, root) + this.root = resolve(this.ctx.loader.dirname, root) this.watcher = watch(this.root, { ...this.config, ignored: ['**/node_modules/**', '**/.git/**', '**/logs/**', ...ignored], }) - this.externals = loadDependencies(__filename, new Set(Object.keys(this.ctx.app.loader.cache))) + this.externals = loadDependencies(__filename, new Set(Object.keys(this.ctx.loader.cache))) const flushChanges = debounce(this.config.debounce || 100, () => this.flushChanges()) this.watcher.on('change', (path) => { - if (this.suspend) return + if (this.suspend) { + this.suspend = false + return + } + logger.debug('change detected:', path) - const isEntry = path === this.ctx.app.loader.filename + const isEntry = path === this.ctx.loader.filename if (!require.cache[path] && !isEntry) return // files independent from any plugins will trigger a full reload @@ -268,3 +257,22 @@ export default class FileWatcher extends Service { this.stashed = new Set() } } + +namespace Watcher { + export interface Config extends WatchOptions { + root?: string + debounce?: number + } + + export const Config = Schema.object({ + root: Schema.string().description('要监听的根目录,相对于当前工作路径。'), + debounce: Schema.number().default(100).description('延迟触发更新的等待时间。'), + ignored: Schema.array(Schema.string()).description('要忽略的文件或目录。'), + }).default(null).description('热重载设置') + + App.Config.list.push(Schema.object({ + watch: Config, + })) +} + +export default Watcher diff --git a/packages/cli/src/addons/writer.ts b/packages/cli/src/addons/writer.ts deleted file mode 100644 index bc09d0be45..0000000000 --- a/packages/cli/src/addons/writer.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { App, Context, Service } from 'koishi' -import { writeFileSync } from 'fs' -import { dump } from 'js-yaml' -import { Loader } from '../loader' - -Context.service('configWriter') - -export default class ConfigWriter extends Service { - private allowWrite: boolean - private loader: Loader - private config: App.Config - - constructor(ctx: Context) { - super(ctx, 'configWriter') - this.loader = ctx.app.loader - this.config = this.loader.config - this.allowWrite = ['.yml', '.yaml'].includes(this.loader.extname) - } - - writeConfig() { - if (!this.allowWrite) return - // prevent hot reload when it's being written - const fileWatcher = this.ctx.fileWatcher - fileWatcher && (fileWatcher.suspend = true) - writeFileSync(this.loader.filename, dump(this.config)) - fileWatcher && (fileWatcher.suspend = false) - } - - async loadPlugin(name: string, config: any) { - this.loader.loadPlugin(name, config) - this.config.plugins[name] = config - delete this.config.plugins['~' + name] - this.writeConfig() - } - - async unloadPlugin(name: string) { - const plugin = this.loader.resolvePlugin(name) - await this.ctx.dispose(plugin) - this.config.plugins['~' + name] = this.config.plugins[name] - delete this.config.plugins[name] - this.writeConfig() - } - - async reloadPlugin(name: string, config: any) { - const plugin = this.loader.resolvePlugin(name) - const state = this.ctx.app.registry.get(plugin) - await this.ctx.dispose(plugin) - state.context.plugin(plugin, config) - this.config.plugins[name] = config - this.writeConfig() - } - - async savePlugin(name: string, config: any) { - this.loader.resolvePlugin(name) - this.config.plugins['~' + name] = config - this.writeConfig() - } - - async createBot(platform: string, config: any) { - const name = 'adapter-' + platform - if (this.config.plugins['~' + name]) { - this.config.plugins[name] = this.config.plugins['~' + name] - delete this.config.plugins['~' + name] - } else if (!this.config.plugins[name]) { - this.config.plugins[name] = { bots: [] } - } - const adapterConfig = this.config.plugins[name] - adapterConfig['bots'].push(config) - this.loader.loadPlugin(name, adapterConfig) - this.writeConfig() - } - - async removeBot() {} - - async startBot() {} - - async stopBot() {} -} diff --git a/packages/cli/src/loader.ts b/packages/cli/src/loader.ts index 745c20e042..74edcdc7de 100644 --- a/packages/cli/src/loader.ts +++ b/packages/cli/src/loader.ts @@ -1,12 +1,14 @@ import { resolve, extname, dirname, isAbsolute } from 'path' import { yellow } from 'kleur' -import { readdirSync, readFileSync } from 'fs' +import { readdirSync, readFileSync, writeFileSync } from 'fs' import { App, Dict, Logger, Modules, Plugin, Schema } from 'koishi' -import { load } from 'js-yaml' +import { dump, load } from 'js-yaml' declare module 'koishi' { - interface App { - loader: Loader + namespace Context { + interface Services { + loader: Loader + } } } @@ -20,6 +22,7 @@ Modules.internal.paths = function (name: string) { } App.Config.list.push(Schema.object({ + allowWrite: Schema.boolean().description('允许在运行时修改配置文件。').default(true), autoRestart: Schema.boolean().description('应用在运行时崩溃自动重启。').default(true), plugins: Schema.any().hidden(), }).description('CLI 设置')) @@ -34,7 +37,6 @@ export class Loader { app: App config: App.Config cache: Dict = {} - enableWriter = true constructor() { const basename = 'koishi.config' @@ -51,7 +53,6 @@ export class Loader { this.dirname = cwd this.filename = cwd + '/' + basename + this.extname } - this.enableWriter = ['.json', '.yaml', '.yml'].includes(this.extname) } loadConfig(): App.Config { @@ -78,6 +79,12 @@ export class Loader { return plugin } + writeConfig() { + // prevent hot reload when it's being written + if (this.app.watcher) this.app.watcher.suspend = true + writeFileSync(this.filename, dump(this.config)) + } + createApp() { const app = this.app = new App(this.config) app.loader = this @@ -91,6 +98,9 @@ export class Loader { this.loadPlugin(name, plugins[name] ?? {}) } } + if (!['.json', '.yaml', '.yml'].includes(this.extname)) { + app.options.allowWrite = false + } return app } } diff --git a/packages/cli/src/worker.ts b/packages/cli/src/worker.ts index a1fbe0a839..ac0f07ee15 100644 --- a/packages/cli/src/worker.ts +++ b/packages/cli/src/worker.ts @@ -13,7 +13,6 @@ declare module 'koishi' { interface EventMap { 'exit'(signal: NodeJS.Signals): Promise - 'reload'(path: string): Promise } } diff --git a/plugins/frontend/manager/package.json b/plugins/frontend/manager/package.json index 65305d0ea3..29e7547886 100644 --- a/plugins/frontend/manager/package.json +++ b/plugins/frontend/manager/package.json @@ -37,10 +37,12 @@ "koishi": "^4.0.1" }, "devDependencies": { - "@types/cross-spawn": "^6.0.2" + "@types/cross-spawn": "^6.0.2", + "@types/js-yaml": "^4.0.5" }, "dependencies": { "cross-spawn": "^7.0.3", + "js-yaml": "^4.1.0", "semver": "^7.3.5" } } diff --git a/plugins/frontend/manager/src/index.ts b/plugins/frontend/manager/src/index.ts index 2d69197a6b..1b7b455bde 100644 --- a/plugins/frontend/manager/src/index.ts +++ b/plugins/frontend/manager/src/index.ts @@ -5,6 +5,7 @@ import MarketProvider from './market' import PackageProvider from './packages' import AdapterProvider from './protocols' import ServiceProvider from './services' +import ConfigWriter from './writer' export * from './bots' export * from './market' @@ -40,7 +41,7 @@ declare module '@koishijs/plugin-console' { } export const name = 'manager' -export const using = ['console'] as const +export const using = ['console', 'loader'] as const export interface Config extends MarketProvider.Config {} @@ -54,29 +55,8 @@ export function apply(ctx: Context, config: Config = {}) { ctx.plugin(AdapterProvider) ctx.plugin(PackageProvider) ctx.plugin(ServiceProvider) + ctx.plugin(ConfigWriter, ctx.app.options.allowWrite) const filename = ctx.console.config.devMode ? '../client/index.ts' : '../dist/index.js' ctx.console.addEntry(resolve(__dirname, filename)) - - ctx.using(['configWriter'], (ctx) => { - ctx.console.addListener('plugin/load', (name, config) => { - ctx.configWriter.loadPlugin(name, config) - }) - - ctx.console.addListener('plugin/unload', (name) => { - ctx.configWriter.unloadPlugin(name) - }) - - ctx.console.addListener('plugin/reload', (name, config) => { - ctx.configWriter.reloadPlugin(name, config) - }) - - ctx.console.addListener('plugin/save', (name, config) => { - ctx.configWriter.savePlugin(name, config) - }) - - ctx.console.addListener('bot/create', (platform, config) => { - ctx.configWriter.createBot(platform, config) - }) - }) } diff --git a/plugins/frontend/manager/src/writer.ts b/plugins/frontend/manager/src/writer.ts new file mode 100644 index 0000000000..9cf107e87d --- /dev/null +++ b/plugins/frontend/manager/src/writer.ts @@ -0,0 +1,92 @@ +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 + + ctx.console.addListener('plugin/load', (name, config) => { + this.loadPlugin(name, config) + }) + + ctx.console.addListener('plugin/unload', (name) => { + this.unloadPlugin(name) + }) + + ctx.console.addListener('plugin/reload', (name, config) => { + this.reloadPlugin(name, config) + }) + + ctx.console.addListener('plugin/save', (name, config) => { + this.savePlugin(name, config) + }) + + ctx.console.addListener('bot/create', (platform, config) => { + this.createBot(platform, config) + }) + } + + writeConfig() { + // prevent hot reload when it's being written + const fileWatcher = this.ctx.fileWatcher + fileWatcher && (fileWatcher.suspend = true) + writeFileSync(this.loader.filename, dump(this.loader.config)) + fileWatcher && (fileWatcher.suspend = false) + } + + async loadPlugin(name: string, config: any) { + this.loader.loadPlugin(name, config) + this.plugins[name] = config + delete this.plugins['~' + name] + this.writeConfig() + } + + async unloadPlugin(name: string) { + const plugin = this.loader.resolvePlugin(name) + await this.ctx.dispose(plugin) + this.plugins['~' + name] = this.plugins[name] + delete this.plugins[name] + this.writeConfig() + } + + async reloadPlugin(name: string, config: any) { + const plugin = this.loader.resolvePlugin(name) + const state = this.ctx.app.registry.get(plugin) + await this.ctx.dispose(plugin) + state.context.plugin(plugin, config) + this.plugins[name] = config + this.writeConfig() + } + + async savePlugin(name: string, config: any) { + this.loader.resolvePlugin(name) + this.plugins['~' + name] = config + this.writeConfig() + } + + async createBot(platform: string, config: any) { + const name = 'adapter-' + platform + if (this.plugins['~' + name]) { + this.plugins[name] = this.plugins['~' + name] + delete this.plugins['~' + name] + } else if (!this.plugins[name]) { + this.plugins[name] = { bots: [] } + } + const adapterConfig = this.plugins[name] + adapterConfig['bots'].push(config) + this.loader.loadPlugin(name, adapterConfig) + this.writeConfig() + } + + async removeBot() {} + + async startBot() {} + + async stopBot() {} +}