diff --git a/plugins/config/client/components/forks.vue b/plugins/config/client/components/forks.vue new file mode 100644 index 0000000..40a399b --- /dev/null +++ b/plugins/config/client/components/forks.vue @@ -0,0 +1,116 @@ + + + + + diff --git a/plugins/config/client/components/global.vue b/plugins/config/client/components/global.vue new file mode 100644 index 0000000..896e984 --- /dev/null +++ b/plugins/config/client/components/global.vue @@ -0,0 +1,23 @@ + + + diff --git a/plugins/config/client/components/group.vue b/plugins/config/client/components/group.vue new file mode 100644 index 0000000..bdcd29c --- /dev/null +++ b/plugins/config/client/components/group.vue @@ -0,0 +1,29 @@ + + + + + diff --git a/plugins/config/client/components/index.vue b/plugins/config/client/components/index.vue new file mode 100644 index 0000000..928bcdb --- /dev/null +++ b/plugins/config/client/components/index.vue @@ -0,0 +1,262 @@ + + + + + diff --git a/plugins/config/client/components/plugin.vue b/plugins/config/client/components/plugin.vue new file mode 100644 index 0000000..32e12d6 --- /dev/null +++ b/plugins/config/client/components/plugin.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/plugins/config/client/components/select.vue b/plugins/config/client/components/select.vue new file mode 100644 index 0000000..9f80089 --- /dev/null +++ b/plugins/config/client/components/select.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/plugins/config/client/components/tree.vue b/plugins/config/client/components/tree.vue new file mode 100644 index 0000000..e8f77b0 --- /dev/null +++ b/plugins/config/client/components/tree.vue @@ -0,0 +1,203 @@ + + + + + diff --git a/plugins/config/client/components/utils.ts b/plugins/config/client/components/utils.ts new file mode 100644 index 0000000..5116d4f --- /dev/null +++ b/plugins/config/client/components/utils.ts @@ -0,0 +1,194 @@ +import { Dict } from 'cosmokit' +import { computed, ref } from 'vue' +import { router, ScopeStatus, send, store } from '@cordisjs/client' +import type { Entry } from '@cordisjs/loader' + +interface DepInfo { + required: boolean +} + +interface PeerInfo { + required: boolean + active: boolean +} + +export interface EnvInfo { + impl: string[] + using: Dict + peer: Dict + warning?: boolean +} + +export const dialogFork = ref() +export const dialogSelect = ref() + +export const coreDeps = [ + '@cordisjs/plugin-webui', + '@cordisjs/plugin-config', + '@cordisjs/server', +] + +export function hasCoreDeps(tree: Tree) { + if (coreDeps.includes('@cordisjs/plugin-' + tree.name)) return true + if (tree.children) return tree.children.some(hasCoreDeps) +} + +function getEnvInfo(name: string) { + function setService(name: string, required: boolean) { + if (services.has(name)) return + if (name === 'console') return + result.using[name] = { required } + } + + const local = store.packages[name] + const result: EnvInfo = { impl: [], using: {}, peer: {} } + const services = new Set() + + // check peer dependencies + for (const name in local.package.peerDependencies ?? {}) { + if (!name.includes('@cordisjs/plugin-') && !name.includes('cordis-plugin-')) continue + if (coreDeps.includes(name)) continue + const required = !local.package.peerDependenciesMeta?.[name]?.optional + const active = !!store.packages[name]?.runtime?.id + result.peer[name] = { required, active } + for (const service of store.packages[name]?.manifest?.service.implements ?? []) { + services.add(service) + } + } + + // check implementations + for (const name of local.manifest.service.implements) { + if (name === 'adapter') continue + result.impl.push(name) + } + + // check services + for (const name of local.runtime?.required ?? []) { + setService(name, true) + } + for (const name of local.runtime?.optional ?? []) { + setService(name, false) + } + + // check reusability + if (local.runtime?.id && !local.runtime?.forkable) { + result.warning = true + } + + // check schema + if (!local.runtime?.schema) { + result.warning = true + } + + return result +} + +export const envMap = computed(() => { + return Object.fromEntries(Object + .keys(store.packages) + .filter(x => x) + .map(name => [name, getEnvInfo(name)])) +}) + +declare module '@cordisjs/client' { + interface ActionContext { + 'config.tree': Tree + } +} + +export interface Tree { + id: string + name: string + path: string + label?: string + config?: any + parent?: Tree + disabled?: boolean + children?: Tree[] +} + +export const current = ref() + +export const name = computed(() => { + if (!(current.value?.name in store.packages)) return + return current.value.name +}) + +export const type = computed(() => { + const env = envMap.value[name.value] + if (!env) return + if (env.warning && current.value.disabled) return 'warning' + for (const name in env.using) { + if (name in store.services || {}) { + if (env.impl.includes(name)) return 'warning' + } else { + if (env.using[name].required) return 'warning' + } + } +}) + +function getTree(parent: Tree, plugins: any): Tree[] { + const trees: Tree[] = [] + for (let key in plugins) { + if (key.startsWith('$')) continue + const config = plugins[key] + const node = { config, parent } as Tree + if (key.startsWith('~')) { + node.disabled = true + key = key.slice(1) + } + node.name = key.split(':', 1)[0] + node.id = key + node.path = key.slice(node.name.length + 1) + node.label = config?.$label + if (key.startsWith('group:')) { + node.children = getTree(node, config) + } + trees.push(node) + } + return trees +} + +export const plugins = computed(() => { + const expanded: string[] = [] + const forks: Dict = {} + const paths: Dict = {} + function handle(config: Entry.Options[]) { + return config.map(options => { + const node: Tree = { + id: options.id, + name: options.name, + path: options.id, + config: options.config, + } + if (options.name === 'cordis/group') { + node.children = handle(options.config) + } + if (!options.collapsed && node.children) { + expanded.push(node.path) + } + forks[node.name] ||= [] + forks[node.name].push(node.path) + paths[node.path] = node + return node + }) + } + const data = handle(store.config) + return { data, forks, paths, expanded } +}) + +export function getStatus(tree: Tree) { + switch (store.packages?.[tree.name]?.runtime?.forks?.[tree.path]?.status) { + case ScopeStatus.PENDING: return 'pending' + case ScopeStatus.LOADING: return 'loading' + case ScopeStatus.ACTIVE: return 'active' + case ScopeStatus.FAILED: return 'failed' + case ScopeStatus.DISPOSED: return 'disposed' + default: return 'disabled' + } +} + +export async function removeItem(tree: Tree) { + send('manager/remove', tree.id) + await router.replace('/plugins/' + tree.parent!.path) +} diff --git a/plugins/config/client/icons/add-group.vue b/plugins/config/client/icons/add-group.vue new file mode 100644 index 0000000..ebfc94e --- /dev/null +++ b/plugins/config/client/icons/add-group.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/plugins/config/client/icons/add-plugin.vue b/plugins/config/client/icons/add-plugin.vue new file mode 100644 index 0000000..871523b --- /dev/null +++ b/plugins/config/client/icons/add-plugin.vue @@ -0,0 +1,10 @@ + \ No newline at end of file diff --git a/plugins/config/client/icons/check.vue b/plugins/config/client/icons/check.vue new file mode 100644 index 0000000..199445a --- /dev/null +++ b/plugins/config/client/icons/check.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/plugins/config/client/icons/clone.vue b/plugins/config/client/icons/clone.vue new file mode 100644 index 0000000..90e3c83 --- /dev/null +++ b/plugins/config/client/icons/clone.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/plugins/config/client/icons/index.ts b/plugins/config/client/icons/index.ts new file mode 100644 index 0000000..c226a4c --- /dev/null +++ b/plugins/config/client/icons/index.ts @@ -0,0 +1,23 @@ +import { icons } from '@cordisjs/client' + +import AddGroup from './add-group.vue' +import AddPlugin from './add-plugin.vue' +import TrashCan from './trash-can.vue' +import Check from './check.vue' +import Clone from './clone.vue' +import Manage from './manage.vue' +import Plugin from './plugin.vue' +import Play from './play.vue' +import Stop from './stop.vue' +import Save from './save.vue' + +icons.register('activity:plugin', Plugin) +icons.register('add-group', AddGroup) +icons.register('add-plugin', AddPlugin) +icons.register('trash-can', TrashCan) +icons.register('check', Check) +icons.register('clone', Clone) +icons.register('manage', Manage) +icons.register('play', Play) +icons.register('stop', Stop) +icons.register('save', Save) diff --git a/plugins/config/client/icons/manage.vue b/plugins/config/client/icons/manage.vue new file mode 100644 index 0000000..78eb354 --- /dev/null +++ b/plugins/config/client/icons/manage.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/plugins/config/client/icons/play.vue b/plugins/config/client/icons/play.vue new file mode 100644 index 0000000..19f45d0 --- /dev/null +++ b/plugins/config/client/icons/play.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/plugins/config/client/icons/plugin.vue b/plugins/config/client/icons/plugin.vue new file mode 100644 index 0000000..ffc1691 --- /dev/null +++ b/plugins/config/client/icons/plugin.vue @@ -0,0 +1,5 @@ + diff --git a/plugins/config/client/icons/save.vue b/plugins/config/client/icons/save.vue new file mode 100644 index 0000000..abd74d3 --- /dev/null +++ b/plugins/config/client/icons/save.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/plugins/config/client/icons/stop.vue b/plugins/config/client/icons/stop.vue new file mode 100644 index 0000000..f1e7d87 --- /dev/null +++ b/plugins/config/client/icons/stop.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/plugins/config/client/icons/trash-can.vue b/plugins/config/client/icons/trash-can.vue new file mode 100644 index 0000000..d9ea36c --- /dev/null +++ b/plugins/config/client/icons/trash-can.vue @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/plugins/config/client/index.scss b/plugins/config/client/index.scss new file mode 100644 index 0000000..7aa1b01 --- /dev/null +++ b/plugins/config/client/index.scss @@ -0,0 +1,37 @@ + +.dialog-config-fork, .plugin-tree { + .status-light { + display: inline-block; + width: 0.625rem; + height: 0.625rem; + border-radius: 100%; + transition: var(--color-transition); + + &.active { + background-color: var(--k-color-success); + } + &.pending { + background-color: var(--k-color-warning); + } + &.loading { + animation: status-flash 1s infinite; + background-color: var(--k-color-warning); + } + &.failed { + background-color: var(--k-color-danger); + } + &.disabled:not(.ignore-disabled) { + opacity: 0.5; + background-color: var(--k-color-disabled); + } + } +} + +@keyframes status-flash { + 0%, 100% { + opacity: 0; + } + 50% { + opacity: 1; + } +} diff --git a/plugins/config/client/index.ts b/plugins/config/client/index.ts new file mode 100644 index 0000000..8eaaee1 --- /dev/null +++ b/plugins/config/client/index.ts @@ -0,0 +1,128 @@ +import { Context, router, send, Service } from '@cordisjs/client' +import { defineComponent, h, resolveComponent } from 'vue' +import type {} from '@cordisjs/plugin-config' +import { dialogFork, plugins, type } from './components/utils' +import Settings from './components/index.vue' +import Forks from './components/forks.vue' +import Select from './components/select.vue' + +import 'virtual:uno.css' +import './index.scss' +import './icons' + +export * from './components/utils' + +declare module '@cordisjs/client' { + interface Context { + configWriter: ConfigWriter + } +} + +export default class ConfigWriter extends Service { + constructor(ctx: Context) { + super(ctx, 'configWriter', true) + + ctx.slot({ + type: 'global', + component: defineComponent(() => () => { + return h(resolveComponent('k-slot'), { name: 'plugin-select', single: true }) + }), + }) + + ctx.slot({ + type: 'plugin-select-base', + component: Select, + order: -1000, + }) + + ctx.slot({ + type: 'plugin-select', + component: Select, + order: -1000, + }) + + ctx.slot({ + type: 'global', + component: Forks, + }) + + ctx.page({ + id: 'config', + path: '/plugins/:name*', + name: '插件配置', + icon: 'activity:plugin', + order: 800, + authority: 4, + fields: ['config', 'packages', 'services'], + component: Settings, + }) + + ctx.menu('config.tree', [{ + id: 'config.tree.toggle', + type: ({ config }) => config.tree?.disabled ? '' : type.value, + icon: ({ config }) => config.tree?.disabled ? 'play' : 'stop', + label: ({ config }) => (config.tree?.disabled ? '启用' : '停用') + + (config.tree?.name === 'group' ? '分组' : '插件'), + }, { + id: '.save', + icon: ({ config }) => config.tree?.disabled ? 'save' : 'check', + label: ({ config }) => config.tree?.disabled ? '保存配置' : '重载配置', + }, { + id: '@separator', + }, { + id: '.rename', + icon: 'edit', + label: '重命名', + }, { + id: '.remove', + type: 'danger', + icon: 'delete', + label: ({ config }) => config.tree?.children ? '移除分组' : '移除插件', + }, { + id: '@separator', + }, { + id: '.clone', + icon: 'clone', + label: '克隆配置', + }, { + id: '.manage', + icon: 'manage', + label: '管理多份配置', + }, { + id: '.add-plugin', + icon: 'add-plugin', + label: '添加插件', + }, { + id: '.add-group', + icon: 'add-group', + label: '添加分组', + }]) + } + + ensure(name: string, passive?: boolean) { + const forks = plugins.value.forks[name] + if (!forks?.length) { + const key = Math.random().toString(36).slice(2, 8) + send('manager/add', { name, disabled: true }, '') + if (!passive) router.push('/plugins/' + key) + } else if (forks.length === 1) { + if (!passive) router.push('/plugins/' + forks[0]) + } else { + if (!passive) dialogFork.value = name + } + } + + remove(name: string) { + const shortname = name.replace(/(cordis-|^@cordisjs\/)plugin-/, '') + const forks = plugins.value.forks[shortname] + for (const id of forks) { + const tree = plugins.value.paths[id] + send('manager/remove', tree.id) + } + } + + get(name: string) { + const shortname = name.replace(/(cordis-|^@cordisjs\/)plugin-/, '') + return plugins.value.forks[shortname]?.map(id => plugins.value.paths[id]) + } +} diff --git a/plugins/config/client/tsconfig.json b/plugins/config/client/tsconfig.json new file mode 100644 index 0000000..2b4971d --- /dev/null +++ b/plugins/config/client/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../tsconfig.client", + "compilerOptions": { + "rootDir": ".", + }, + "include": [ + ".", + ], +} \ No newline at end of file diff --git a/plugins/config/package.json b/plugins/config/package.json new file mode 100644 index 0000000..db84279 --- /dev/null +++ b/plugins/config/package.json @@ -0,0 +1,70 @@ +{ + "name": "@cordisjs/plugin-config", + "description": "Manage your bots and plugins with console", + "version": "2.8.5", + "main": "lib/node/index.js", + "types": "lib/index.d.ts", + "exports": { + ".": { + "node": "./lib/node/index.js", + "browser": "./lib/browser/index.mjs", + "types": "./lib/index.d.ts" + }, + "./shared": { + "require": "./lib/shared/index.js", + "import": "./lib/shared/index.mjs" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib", + "dist", + "src" + ], + "author": "Shigma ", + "license": "AGPL-3.0", + "repository": { + "type": "git", + "url": "git+https://github.com/cordisjs/webui.git", + "directory": "plugins/config" + }, + "bugs": { + "url": "https://github.com/cordisjs/webui/issues" + }, + "homepage": "https://koishi.chat/plugins/console/config.html", + "keywords": [ + "cordis", + "plugin", + "config", + "manager", + "server" + ], + "cordis": { + "public": [ + "dist" + ], + "description": { + "en": "Configure your plugins with console", + "zh": "使用控制台查看、配置你的插件" + }, + "service": { + "required": [ + "console" + ] + } + }, + "peerDependencies": { + "@cordisjs/loader": "^0.8.6", + "@cordisjs/plugin-webui": "^0.28.3", + "cordis": "^4.17.4" + }, + "devDependencies": { + "@cordisjs/client": "^0.28.3", + "@cordisjs/plugin-hmr": "^0.2.0" + }, + "dependencies": { + "@cordisjs/registry": "^7.0.3", + "@cordisjs/webui": "^0.28.3" + } +} diff --git a/plugins/config/src/browser/index.ts b/plugins/config/src/browser/index.ts new file mode 100644 index 0000000..3ff6f2d --- /dev/null +++ b/plugins/config/src/browser/index.ts @@ -0,0 +1,26 @@ +import { Context, Schema } from 'cordis' +import { PackageProvider } from './packages' +import { ConfigWriter, ServiceProvider } from '../shared' + +export * from '../shared' + +export const name = 'config' +export const inject = ['console', 'loader'] + +export interface Config {} + +export const Config: Schema = Schema.object({}) + +export function apply(ctx: Context, config: Config) { + ctx.plugin(PackageProvider) + ctx.plugin(ConfigWriter) + ctx.plugin(ServiceProvider) + + ctx.console.addEntry(process.env.KOISHI_BASE ? [ + process.env.KOISHI_BASE + '/dist/index.js', + process.env.KOISHI_BASE + '/dist/style.css', + ] : [ + // @ts-ignore + import.meta.url.replace(/\/src\/[^/]+\/[^/]+$/, '/client/index.ts'), + ]) +} diff --git a/plugins/config/src/browser/packages.ts b/plugins/config/src/browser/packages.ts new file mode 100644 index 0000000..fc01a13 --- /dev/null +++ b/plugins/config/src/browser/packages.ts @@ -0,0 +1,7 @@ +import * as shared from '../shared' + +export class PackageProvider extends shared.PackageProvider { + async collect(forced: boolean) { + return this.ctx.loader.market.objects + } +} diff --git a/plugins/config/src/index.ts b/plugins/config/src/index.ts new file mode 100644 index 0000000..bb04ea2 --- /dev/null +++ b/plugins/config/src/index.ts @@ -0,0 +1,2 @@ +// placeholder file, do not modify +export * from './node' diff --git a/plugins/config/src/node/index.ts b/plugins/config/src/node/index.ts new file mode 100644 index 0000000..fc57c88 --- /dev/null +++ b/plugins/config/src/node/index.ts @@ -0,0 +1,28 @@ +import { Context, Schema } from 'cordis' +import { resolve } from 'path' +import { PackageProvider } from './packages' +import { ConfigWriter, ServiceProvider } from '../shared' + +export * from '../shared' + +export const name = 'config' +export const inject = ['console'] + +export interface Config {} + +export const Config: Schema = Schema.object({}) + +export function apply(ctx: Context, config: Config) { + if (!ctx.loader?.writable) { + return ctx.logger('app').warn('@cordisjs/plugin-config is only available for json/yaml config file') + } + + ctx.plugin(PackageProvider) + ctx.plugin(ServiceProvider) + ctx.plugin(ConfigWriter) + + ctx.console.addEntry({ + dev: resolve(__dirname, '../../client/index.ts'), + prod: resolve(__dirname, '../../dist'), + }) +} diff --git a/plugins/config/src/node/packages.ts b/plugins/config/src/node/packages.ts new file mode 100644 index 0000000..cc87d69 --- /dev/null +++ b/plugins/config/src/node/packages.ts @@ -0,0 +1,40 @@ +import { Logger } from 'cordis' +import { LocalScanner } from '@cordisjs/registry' +import * as shared from '../shared' + +const logger = new Logger('config') + +class PackageScanner extends LocalScanner { + constructor(private service: PackageProvider) { + super(service.ctx.baseDir) + } + + async onError(error: any, name: string) { + logger.warn('failed to resolve %c', name) + logger.warn(error) + } + + async parsePackage(name: string) { + const result = await super.parsePackage(name) + try { + // require.resolve(name) may be different from require.resolve(path) + // because tsconfig-paths may resolve the path differently + const entry = require.resolve(name) + if (require.cache[entry]) { + this.service.cache[name] = await this.service.parseExports(name) + } + } catch (error) { + this.onError(error, name) + } + return result + } +} + +export class PackageProvider extends shared.PackageProvider { + scanner = new PackageScanner(this) + + async collect(forced: boolean) { + await this.scanner.collect(forced) + return this.scanner.objects + } +} diff --git a/plugins/config/src/shared/index.ts b/plugins/config/src/shared/index.ts new file mode 100644 index 0000000..7794102 --- /dev/null +++ b/plugins/config/src/shared/index.ts @@ -0,0 +1,17 @@ +import { PackageProvider } from './packages' +import { ServiceProvider } from './services' +import { ConfigWriter } from './writer' + +declare module '@cordisjs/webui' { + namespace Console { + interface Services { + packages: PackageProvider + services: ServiceProvider + config: ConfigWriter + } + } +} + +export * from './packages' +export * from './services' +export * from './writer' diff --git a/plugins/config/src/shared/packages.ts b/plugins/config/src/shared/packages.ts new file mode 100644 index 0000000..eaed325 --- /dev/null +++ b/plugins/config/src/shared/packages.ts @@ -0,0 +1,127 @@ +import { Context, Logger, MainScope, Plugin, Schema, ScopeStatus } from 'cordis' +import { Dict } from 'cosmokit' +import { DataService } from '@cordisjs/webui' +import { PackageJson, SearchObject, SearchResult } from '@cordisjs/registry' +import {} from '@cordisjs/plugin-hmr' + +declare module '@cordisjs/loader' { + interface Loader { + market: SearchResult + } +} + +declare module '@cordisjs/webui' { + interface Events { + 'config/request-runtime'(name: string): void + } +} + +const logger = new Logger('config') + +export abstract class PackageProvider extends DataService> { + cache: Dict = {} + debouncedRefresh: () => void + + store = new WeakMap() + + constructor(public ctx: Context) { + super(ctx, 'packages', { authority: 4 }) + + this.debouncedRefresh = ctx.debounce(() => this.refresh(false), 0) + ctx.on('internal/runtime', scope => this.update(scope.runtime.plugin)) + ctx.on('internal/fork', scope => this.update(scope.runtime.plugin)) + ctx.on('internal/status', scope => this.update(scope.runtime.plugin)) + ctx.on('hmr/reload', (reloads) => { + for (const [plugin] of reloads) { + this.update(plugin) + } + }) + + ctx.console.addListener('config/request-runtime', async (name) => { + this.cache[name] = await this.parseExports(name) + this.refresh(false) + }, { authority: 4 }) + } + + abstract collect(forced: boolean): Promise + + async update(plugin: Plugin) { + const name = this.store.get(plugin) + if (!this.cache[name]) return + this.cache[name] = await this.parseExports(name) + this.debouncedRefresh() + } + + parseRuntime(state: MainScope, result: PackageProvider.RuntimeData) { + result.id = state.runtime.uid + result.forkable = state.runtime.isForkable + result.forks = Object.fromEntries(state.children + .filter(fork => fork.entry) + .map(fork => [fork.entry!.options.id, { status: fork.status }])) + } + + async get(forced = false) { + const objects = (await this.collect(forced)).slice() + for (const object of objects) { + object.name = object.package?.name || '' + if (!this.cache[object.name]) continue + object.runtime = this.cache[object.name] + } + + return Object.fromEntries(objects.map(data => [data.name, data])) + } + + async parseExports(name: string) { + try { + const exports = await this.ctx.loader.resolve(name) + if (exports) this.store.set(exports, name) + const result: PackageProvider.RuntimeData = {} + result.schema = exports?.Config || exports?.schema + result.usage = exports?.usage + result.filter = exports?.filter + const inject = exports?.using || exports?.inject || [] + if (Array.isArray(inject)) { + result.required = inject + result.optional = [] + } else { + result.required = inject.required || [] + result.optional = inject.optional || [] + } + + // make sure that result can be serialized into json + JSON.stringify(result) + + if (exports) { + const runtime = this.ctx.registry.get(exports) + if (runtime) this.parseRuntime(runtime, result) + } + return result + } catch (error) { + logger.warn('failed to load %c', name) + logger.warn(error) + return { failed: true } + } + } +} + +export namespace PackageProvider { + export interface Data extends Pick { + name?: string + runtime?: RuntimeData + package: Pick + } + + export interface RuntimeData { + id?: number + filter?: boolean + forkable?: boolean + schema?: Schema + usage?: string + required?: string[] + optional?: string[] + failed?: boolean + forks?: Dict<{ + status?: ScopeStatus + }> + } +} diff --git a/plugins/config/src/shared/services.ts b/plugins/config/src/shared/services.ts new file mode 100644 index 0000000..526a272 --- /dev/null +++ b/plugins/config/src/shared/services.ts @@ -0,0 +1,29 @@ +import { DataService } from '@cordisjs/webui' +import { Context } from 'cordis' +import { Dict } from 'cosmokit' + +export class ServiceProvider extends DataService> { + constructor(ctx: Context) { + super(ctx, 'services') + ctx.on('internal/service', () => this.refresh()) + } + + async get() { + const services = {} as Dict + const attach = (internal: Context[typeof Context.internal]) => { + if (!internal) return + attach(Object.getPrototypeOf(internal)) + for (const [key, { type }] of Object.entries(internal)) { + if (type !== 'service') continue + const instance = this.ctx.get(key) + if (!(instance instanceof Object)) continue + const ctx: Context = Reflect.getOwnPropertyDescriptor(instance, Context.current)?.value + if (!ctx) continue + const name = key.replace(/^__/, '').replace(/__$/, '') + services[name] = ctx.scope.uid + } + } + attach(this.ctx.root[Context.internal]) + return services + } +} diff --git a/plugins/config/src/shared/writer.ts b/plugins/config/src/shared/writer.ts new file mode 100644 index 0000000..24b5875 --- /dev/null +++ b/plugins/config/src/shared/writer.ts @@ -0,0 +1,106 @@ +import { DataService } from '@cordisjs/webui' +import { Context, Logger } from 'cordis' +import { Entry, Loader } from '@cordisjs/loader' + +declare module '@cordisjs/webui' { + interface Events { + 'manager/app-reload'(config: any): void + 'manager/teleport'(source: string, key: string, target: string, index: number): void + 'manager/reload'(id: string, config: any): void + 'manager/unload'(id: string, config: any): void + 'manager/add'(options: any, parent: string, index?: number): string + 'manager/remove'(id: string): void + 'manager/meta'(ident: string, config: any): void + } +} + +const logger = new Logger('loader') + +export class ConfigWriter extends DataService { + protected loader: Loader + + constructor(ctx: Context) { + super(ctx, 'config', { authority: 4 }) + this.loader = ctx.loader + + for (const key of ['teleport', 'reload', 'unload', 'remove', 'meta', 'add'] as const) { + ctx.console.addListener(`manager/${key}`, async (...args: any[]) => { + try { + await this[key].apply(this, args) + } catch (error) { + logger.error(error) + throw new Error('failed') + } + }, { authority: 4 }) + } + + ctx.on('config', () => this.refresh()) + } + + async get() { + return this.loader.config + } + + async meta(ident: string, config: any) { + // const [parent, key] = this.resolveConfig(ident) + // const target = parent[key] + // for (const key of Object.keys(config)) { + // delete target[key] + // if (config[key] === null) { + // delete config[key] + // } + // } + // insertKey(target, config, Object.keys(target)) + // await this.loader.writeConfig(true) + } + + async reload(id: string, config: any) { + const entry = this.loader.entries[id] + if (!entry) throw new Error('entry not found') + entry.options.config = config + delete entry.options.disabled + entry.resume() + } + + async unload(id: string, config: any) { + const entry = this.loader.entries[id] + if (!entry) throw new Error('entry not found') + entry.options.config = config + entry.options.disabled = true + entry.resume() + } + + async add(options: Omit, parent: string, index?: number) { + return this.loader.add(options, parent, index) + } + + async remove(id: string) { + this.loader.remove(id) + } + + async teleport(source: string, key: string, target: string, index: number) { + // const parentS = this.resolveFork(source) + // const parentT = this.resolveFork(target) + + // // teleport fork + // const fork = parentS?.[Loader.kRecord]?.[key] + // if (fork && parentS !== parentT) { + // delete parentS[Loader.kRecord][key] + // parentT[Loader.kRecord][key] = fork + // remove(parentS.disposables, fork.dispose) + // parentT.disposables.push(fork.dispose) + // fork.parent = parentT.ctx + // Object.setPrototypeOf(fork.ctx, parentT.ctx) + // fork.ctx.emit('internal/fork', fork) + // if (fork.runtime.using.some(name => parentS[name] !== parentT[name])) { + // fork.restart() + // } + // } + + // // teleport config + // const temp = dropKey(parentS.config, key) + // const rest = Object.keys(parentT.config).slice(index) + // insertKey(parentT.config, temp, rest) + // await this.loader.writeConfig() + } +} diff --git a/plugins/config/tsconfig.json b/plugins/config/tsconfig.json new file mode 100644 index 0000000..e193a11 --- /dev/null +++ b/plugins/config/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib", + }, + "include": [ + "src", + ], +} \ No newline at end of file