feat: new plugin manager, supports adding plugins through UI (#2430)
* refactor: plugin manager page * fix: bug * feat: addByNpm api * fix: improve the addByNpm * feat: improve applicationPlugins:list api * fix: re-download npm package when restart app * fix: plugin delete api * feat: plugin detail api * feat: zipUrl add api * fix: upload api bug * fix: plugin detail info * feat: upgrade api * fix: upload api * feat: handle plugin load error * feat: support authToken * feat: muti lang * fix: build error * fix: self review * Update plugin-manager.ts * fix: bug * fix: bug * fix: bug * fix: bug * fix: bug * fix: bugs * fix: detail click and remove isOfficial * fix: upgrade no refresh * fix: file size and type check * fix: bug * fix: upgrade error * fix: bug * fix: bug * fix: plugin card layout * fix: handling exceptional cases * fix: tgz file support * fix: macos compress file * fix: bug * fix: bug * fix: bug * fix: bug * fix: add upgrade npm type * fix: bugs * fix: bug * fix: change plugins static expose url * fix: api prefix * fix: bug * fix: add nginx `/static/plugin/` path * fix: bugs and pr docker build no dts * fix: bug * fix: build tools bug * fix: improve code * fix: build bug * feat: improve plugin info * fix: ui bug * fix: plugin document bug * feat: improve code * feat: improve code * feat: process dev deps check * feat: improve code * feat: process.env.IS_DEV_CMD * fix: do not delete the plugin package * feat: plugin symlink * fix: tsx watch --ignore=./storage/plugins/** * fix: test error * fix: improve code * fix: improve code * fix: emitStartedEvent * fix: improve code * fix: type error * fix: test error * test: console.log * fix: createStoragePluginSymLink * fix: clientStaticMiddleware rename to clientStaticUtils * feat: build tools support plugins folder * fix: 350px * fix: error * feat: client dev support plugin folder * fix: clear cli options * fix: typeError: Converting circular structure to JSON * fix: plugin name * chore: restart application after command * feat: upgrade error & docs * Update v14-changelog.md * Update v14-changelog.md * Update v14-changelog.md * fix: gateway test * refactor(plugin-workflow): add ready state for gracefully tearing down * Revert "chore: restart application after command" This reverts commit 5015274f8e4e06e506e15754b672330330e8c7f8. * chore: stop application whe restart * T 1218 change plugin folder (#2629) * feat: change folder name * feat: change `pm create` command * feat: revert plugin name change * fix: delete samples * feat: change plugins folder * fix: pm create * feat: update docs * fix: link package error * fix: docs * fix: create command * fix: pm add error * fix: create add build * fix: pm creatre + add * feat: add tar command * fix: docs * fix: bug * fix: docs --------- Co-authored-by: chenos <chenlinxh@gmail.com> * feat: docs * Update your-fisrt-plugin.md * Update your-fisrt-plugin.md * chore: application reload * chore: test * fix: pm add error * chore: preset install skip exists plugin * fix: createIfNotExists --------- Co-authored-by: chenos <chenlinxh@gmail.com> Co-authored-by: chareice <chareice@live.com> Co-authored-by: Zhou <zhou.working@gmail.com> Co-authored-by: mytharcher <mytharcher@gmail.com>
This commit is contained in:
parent
b918ec5e41
commit
705b7449f0
@ -26,3 +26,4 @@ packages/core/database/src/sql-parser/index.js
|
|||||||
packages/core/cli/templates/plugin/src/client/*.tpl
|
packages/core/cli/templates/plugin/src/client/*.tpl
|
||||||
packages/app/client/src/.plugins
|
packages/app/client/src/.plugins
|
||||||
docker
|
docker
|
||||||
|
storage
|
||||||
|
4
.gitignore
vendored
4
.gitignore
vendored
@ -26,3 +26,7 @@ storage/duplicator/*
|
|||||||
**/.dumi/tmp-production
|
**/.dumi/tmp-production
|
||||||
packages/core/client/docs/contributing.md
|
packages/core/client/docs/contributing.md
|
||||||
packages/core/app/client/src/.plugins
|
packages/core/app/client/src/.plugins
|
||||||
|
storage/plugins
|
||||||
|
storage/tar
|
||||||
|
storage/tmp
|
||||||
|
storage/app.watch.ts
|
||||||
|
@ -16,3 +16,4 @@ packages/core/client/src/locale/*
|
|||||||
packages/core/cli/templates/plugin/src/client/*.tpl
|
packages/core/cli/templates/plugin/src/client/*.tpl
|
||||||
packages/app/client/src/.plugins
|
packages/app/client/src/.plugins
|
||||||
packages/**/locale/**
|
packages/**/locale/**
|
||||||
|
storage
|
||||||
|
@ -12,7 +12,7 @@ RUN cd /tmp && \
|
|||||||
NEWVERSION="$(cat lerna.json | jq '.version' | tr -d '"').$(date +'%Y%m%d%H%M%S')" \
|
NEWVERSION="$(cat lerna.json | jq '.version' | tr -d '"').$(date +'%Y%m%d%H%M%S')" \
|
||||||
&& tmp=$(mktemp) \
|
&& tmp=$(mktemp) \
|
||||||
&& jq ".version = \"${NEWVERSION}\"" lerna.json > "$tmp" && mv "$tmp" lerna.json
|
&& jq ".version = \"${NEWVERSION}\"" lerna.json > "$tmp" && mv "$tmp" lerna.json
|
||||||
RUN yarn install && yarn build
|
RUN yarn install && yarn build --no-dts
|
||||||
|
|
||||||
RUN git checkout -b release \
|
RUN git checkout -b release \
|
||||||
&& yarn version:alpha -y \
|
&& yarn version:alpha -y \
|
||||||
|
@ -8,6 +8,7 @@ NocoBase is in early stage of development and is subject to frequent changes, pl
|
|||||||
|
|
||||||
## Recent major updates
|
## Recent major updates
|
||||||
|
|
||||||
|
- [v0.14: New plugin manager, supports adding plugins through UI - 2023/09/11](https://docs.nocobase.com/welcome/release/v14-changelog)
|
||||||
- [v0.13: New application status flow - 2023/08/24](https://docs.nocobase.com/welcome/release/v13-changelog)
|
- [v0.13: New application status flow - 2023/08/24](https://docs.nocobase.com/welcome/release/v13-changelog)
|
||||||
- [v0.12: New plugin build tool - 2023/08/01](https://docs.nocobase.com/welcome/release/v12-changelog)
|
- [v0.12: New plugin build tool - 2023/08/01](https://docs.nocobase.com/welcome/release/v12-changelog)
|
||||||
- [v0.11: New client application, plugin and router - 2023/07/08](http://docs.nocobase.com/welcome/release/v11-changelog)
|
- [v0.11: New client application, plugin and router - 2023/07/08](http://docs.nocobase.com/welcome/release/v11-changelog)
|
||||||
|
@ -8,6 +8,7 @@ NocoBase 正处在早期开发阶段,可能变动频繁,请谨慎用于生
|
|||||||
|
|
||||||
## 最近重要更新
|
## 最近重要更新
|
||||||
|
|
||||||
|
- [v0.14:全新的插件管理器,支持通过界面添加插件 - 2023/09/11](https://docs-cn.nocobase.com/welcome/release/v14-changelog)
|
||||||
- [v0.13: 全新的应用状态流转 - 2023/08/24](https://docs-cn.nocobase.com/welcome/release/v13-changelog)
|
- [v0.13: 全新的应用状态流转 - 2023/08/24](https://docs-cn.nocobase.com/welcome/release/v13-changelog)
|
||||||
- [v0.12: 全新的插件构建工具 - 2023/08/01](https://docs-cn.nocobase.com/welcome/release/v12-changelog)
|
- [v0.12: 全新的插件构建工具 - 2023/08/01](https://docs-cn.nocobase.com/welcome/release/v12-changelog)
|
||||||
- [v0.11: 全新的客户端 Application、Plugin 和 Router - 2023/07/08](https://docs-cn.nocobase.com/welcome/release/v11-changelog)
|
- [v0.11: 全新的客户端 Application、Plugin 和 Router - 2023/07/08](https://docs-cn.nocobase.com/welcome/release/v11-changelog)
|
||||||
|
@ -66,6 +66,19 @@ server {
|
|||||||
send_timeout 600;
|
send_timeout 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location ^~ /static/plugins/ {
|
||||||
|
proxy_pass http://127.0.0.1:13000/static/plugins/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection 'upgrade';
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_cache_bypass $http_upgrade;
|
||||||
|
proxy_connect_timeout 600;
|
||||||
|
proxy_send_timeout 600;
|
||||||
|
proxy_read_timeout 600;
|
||||||
|
send_timeout 600;
|
||||||
|
}
|
||||||
|
|
||||||
location /ws {
|
location /ws {
|
||||||
proxy_pass http://127.0.0.1:13000/ws;
|
proxy_pass http://127.0.0.1:13000/ws;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
@ -84,6 +84,7 @@ const sidebar = {
|
|||||||
},
|
},
|
||||||
// '/welcome/release/index',
|
// '/welcome/release/index',
|
||||||
// '/welcome/release/v08-changelog',
|
// '/welcome/release/v08-changelog',
|
||||||
|
'/welcome/release/v14-changelog',
|
||||||
'/welcome/release/v13-changelog',
|
'/welcome/release/v13-changelog',
|
||||||
'/welcome/release/v12-changelog',
|
'/welcome/release/v12-changelog',
|
||||||
'/welcome/release/v11-changelog',
|
'/welcome/release/v11-changelog',
|
||||||
|
@ -12,10 +12,10 @@ Once NocoBase is installed, we can start our plugin development journey.
|
|||||||
First, you can quickly create an empty plugin via the CLI with the following command.
|
First, you can quickly create an empty plugin via the CLI with the following command.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn pm create hello
|
yarn pm create @my-project/plugin-hello
|
||||||
```
|
```
|
||||||
|
|
||||||
The directory where the plugin is located ``packages/plugins/hello`` and the plugin directory structure is
|
The directory where the plugin located is `packages/plugins/@my-project/plugin-hello` and the plugin directory structure is
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|- /hello
|
|- /hello
|
||||||
@ -27,34 +27,26 @@ The directory where the plugin is located ``packages/plugins/hello`` and the plu
|
|||||||
|- package.json # plugin package information
|
|- package.json # plugin package information
|
||||||
|- server.d.ts
|
|- server.d.ts
|
||||||
|- server.js
|
|- server.js
|
||||||
package.json
|
|
||||||
|
|
||||||
package.json information
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "@nocobase/plugin-hello",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"main": "lib/server/index.js",
|
|
||||||
"devDependencies": {
|
|
||||||
"@nocobase/client": "0.8.0-alpha.1",
|
|
||||||
"@nocobase/test": "0.8.0-alpha.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
A NocoBase plugin is also an NPM package, the correspondence rule between plugin name and NPM package name is `${PLUGIN_PACKAGE_PREFIX}-${pluginName}`.
|
Visit the Plugin Manager to view the plugin you just added, the default address is http://localhost:13000/admin/pm/list/local/
|
||||||
|
|
||||||
`PLUGIN_PACKAGE_PREFIX` is the plugin package prefix, which can be customized in .env, [click here for PLUGIN_PACKAGE_PREFIX description](/api/env#plugin_package_prefix).
|
<img src="https://nocobase.oss-cn-beijing.aliyuncs.com/b04d16851fc1bbc2796ecf8f9bc0c3f4.png" />
|
||||||
|
|
||||||
|
If the plugin is not shown in the plugin manager, you can add it manually with the `pm add` command.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn pm add @my-project/plugin-hello
|
||||||
|
```
|
||||||
|
|
||||||
## Code the plugin
|
## Code the plugin
|
||||||
|
|
||||||
Look at the `packages/plugins/hello/src/server/plugin.ts` file and modify it to
|
Look at the `packages/plugins/@my-project/plugin-hello/src/server/plugin.ts` file and modify it to
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { InstallOptions, Plugin } from '@nocobase/server';
|
import { InstallOptions, Plugin } from '@nocobase/server';
|
||||||
|
|
||||||
export class HelloPlugin extends Plugin {
|
export class PluginHelloServer extends Plugin {
|
||||||
afterAdd() {}
|
afterAdd() {}
|
||||||
|
|
||||||
beforeLoad() {}
|
beforeLoad() {}
|
||||||
@ -62,9 +54,7 @@ export class HelloPlugin extends Plugin {
|
|||||||
async load() {
|
async load() {
|
||||||
this.db.collection({
|
this.db.collection({
|
||||||
name: 'hello',
|
name: 'hello',
|
||||||
fields: [
|
fields: [{ type: 'string', name: 'name' }],
|
||||||
{ type: 'string', name: 'name' }
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
this.app.acl.allow('hello', '*');
|
this.app.acl.allow('hello', '*');
|
||||||
}
|
}
|
||||||
@ -78,24 +68,29 @@ export class HelloPlugin extends Plugin {
|
|||||||
async remove() {}
|
async remove() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default HelloPlugin;
|
export default PluginHelloServer;
|
||||||
```
|
|
||||||
|
|
||||||
## Register the plugin
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn pm add hello
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Activate the plugin
|
## Activate the plugin
|
||||||
|
|
||||||
When the plugin is activated, the hello table that you just configured is automatically created.
|
**Operated by command**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn pm enable hello
|
yarn pm enable @my-project/plugin-hello
|
||||||
```
|
```
|
||||||
|
|
||||||
## Start the application
|
**Operated by UI**
|
||||||
|
|
||||||
|
Visit the Plugin Manager to view the plugin you just added and click enable.
|
||||||
|
The Plugin Manager page defaults to http://localhost:13000/admin/pm/list/local/
|
||||||
|
|
||||||
|
<img src="https://nocobase.oss-cn-beijing.aliyuncs.com/7b7df26a8ecc32bb1ebc3f99767ff9f9.png" />
|
||||||
|
|
||||||
|
Node: When the plugin is activated, the hello collection that you just configured is automatically created.
|
||||||
|
|
||||||
|
## Debug the Plugin
|
||||||
|
|
||||||
|
If the app is not started, you need to start the app first
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# for development
|
# for development
|
||||||
@ -106,9 +101,7 @@ yarn build
|
|||||||
yarn start
|
yarn start
|
||||||
```
|
```
|
||||||
|
|
||||||
## Experience the plugin
|
Insert data into the hello collection of the plugin
|
||||||
|
|
||||||
Insert data into the hello table of the plugin
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl --location --request POST 'http://localhost:13000/api/hello:create' \
|
curl --location --request POST 'http://localhost:13000/api/hello:create' \
|
||||||
@ -123,3 +116,21 @@ View the data
|
|||||||
```bash
|
```bash
|
||||||
curl --location --request GET 'http://localhost:13000/api/hello:list'
|
curl --location --request GET 'http://localhost:13000/api/hello:list'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Build the plugin
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn build plugins/@my-project/plugin-hello --tar
|
||||||
|
|
||||||
|
# step-by-step
|
||||||
|
yarn build plugins/@my-project/plugin-hello
|
||||||
|
yarn nocobase tar plugins/@my-project/plugin-hello
|
||||||
|
```
|
||||||
|
|
||||||
|
The default saved path for the plugin tar is `storage/tar/@my-project/plugin-hello.tar.gz`
|
||||||
|
|
||||||
|
## Upload to other NocoBase applications
|
||||||
|
|
||||||
|
Only supported in v0.14 and above
|
||||||
|
|
||||||
|
<img src="https://nocobase.oss-cn-beijing.aliyuncs.com/8aa8a511aa8c1e87a8f7ee82cf8a1359.gif" />
|
||||||
|
59
docs/en-US/welcome/release/v14-changelog.md
Normal file
59
docs/en-US/welcome/release/v14-changelog.md
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
# v0.14: New plugin manager, supports adding plugins through UI
|
||||||
|
|
||||||
|
This release enables plug-and-play plugins in production environments. You can now add plugins directly through the UI, and support downloading from the npm registry (which can be private), local uploads, and URL downloads.
|
||||||
|
|
||||||
|
## New features
|
||||||
|
|
||||||
|
### New plugin manager interface
|
||||||
|
|
||||||
|
<img src="https://demo-cn.nocobase.com/storage/uploads/6de7c906518b6c6643570292523b06c8.png" />
|
||||||
|
|
||||||
|
### Uploaded plugins are located in the storage/plugins directory.
|
||||||
|
|
||||||
|
The storage/plugins directory is used to upload plugins, and is organized as npm packages.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|- /storage/
|
||||||
|
|- /plugins/
|
||||||
|
|- /@nocobase/
|
||||||
|
|- /plugin-hello1/
|
||||||
|
|- /plugin-hello2/
|
||||||
|
|- /@foo/
|
||||||
|
|- /bar/
|
||||||
|
|- /my-nocobase-plugin-hello/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Plugin updates
|
||||||
|
|
||||||
|
Currently, only plugins under storage/plugins can be updated, as shown here:
|
||||||
|
|
||||||
|
<img src="https://demo-cn.nocobase.com/storage/uploads/703809b8cd74cc95e1ab2ab766980817.gif" />
|
||||||
|
|
||||||
|
Note: In order to facilitate maintenance and upgrading, and to avoid unavailability of the storage plugins due to upgrading, you can put the new plugin directly into storage/plugins and then perform the upgrade operation.
|
||||||
|
|
||||||
|
## Incompatible changes
|
||||||
|
|
||||||
|
### Changes to plugin names
|
||||||
|
|
||||||
|
- PLUGIN_PACKAGE_PREFIX environment variable is no longer provided.
|
||||||
|
- Plugin names and package names are unified, old plugin names can still exist as aliases.
|
||||||
|
|
||||||
|
### Improvements to pm.add
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use packageName instead of pluginName, lookup locally, error if not found
|
||||||
|
pm add packageName
|
||||||
|
|
||||||
|
# Download from remote only if registry is provided, can also specify version
|
||||||
|
pm add packageName --registry=xx --auth-token=yy --version=zz
|
||||||
|
|
||||||
|
# You can also provide a local zip, add multiple times and replace it with the last one
|
||||||
|
pm add /a/plugin.zip
|
||||||
|
|
||||||
|
# Remote zip, replace it with the same name
|
||||||
|
pm add http://url/plugin.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
## Plugin development guide
|
||||||
|
|
||||||
|
[Develop the first plugin](/development/your-fisrt-plugin)
|
@ -9,16 +9,16 @@
|
|||||||
|
|
||||||
## 创建插件
|
## 创建插件
|
||||||
|
|
||||||
首先,你可以通过 CLI 快速的创建一个空插件,命令如下:
|
通过 CLI 快速地创建一个空插件,命令如下:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn pm create hello
|
yarn pm create @my-project/plugin-hello
|
||||||
```
|
```
|
||||||
|
|
||||||
插件所在目录 `packages/plugins/hello`,插件目录结构为:
|
插件所在目录 `packages/plugins/@my-project/plugin-hello`,插件目录结构为:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|- /hello
|
|- /packages/plugins/@my-project/plugin-hello
|
||||||
|- /src
|
|- /src
|
||||||
|- /client # 插件客户端代码
|
|- /client # 插件客户端代码
|
||||||
|- /server # 插件服务端代码
|
|- /server # 插件服务端代码
|
||||||
@ -29,32 +29,24 @@ yarn pm create hello
|
|||||||
|- server.js
|
|- server.js
|
||||||
```
|
```
|
||||||
|
|
||||||
package.json 信息
|
访问插件管理器界面,查看刚添加的插件,默认地址为 http://localhost:13000/admin/pm/list/local/
|
||||||
|
|
||||||
```json
|
<img src="https://nocobase.oss-cn-beijing.aliyuncs.com/b04d16851fc1bbc2796ecf8f9bc0c3f4.png" />
|
||||||
{
|
|
||||||
"name": "@nocobase/plugin-hello",
|
如果创建的插件未在插件管理器里显示,可以通过 `pm add` 命令手动添加
|
||||||
"version": "0.1.0",
|
|
||||||
"main": "lib/server/index.js",
|
```bash
|
||||||
"devDependencies": {
|
yarn pm add @my-project/plugin-hello
|
||||||
"@nocobase/client": "0.8.0-alpha.1",
|
|
||||||
"@nocobase/test": "0.8.0-alpha.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
NocoBase 插件也是 NPM 包,插件名和 NPM 包名的对应规则为 `${PLUGIN_PACKAGE_PREFIX}-${pluginName}`。
|
|
||||||
|
|
||||||
`PLUGIN_PACKAGE_PREFIX` 为插件包前缀,可以在 .env 里自定义,[点此查看 PLUGIN_PACKAGE_PREFIX 说明](/api/env#plugin_package_prefix)。
|
|
||||||
|
|
||||||
## 编写插件
|
## 编写插件
|
||||||
|
|
||||||
查看 `packages/plugins/hello/src/server/plugin.ts` 文件,并修改为:
|
查看 `packages/plugins/@my-project/plugin-hello/src/server/plugin.ts` 文件,并修改为:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { InstallOptions, Plugin } from '@nocobase/server';
|
import { InstallOptions, Plugin } from '@nocobase/server';
|
||||||
|
|
||||||
export class HelloPlugin extends Plugin {
|
export class PluginHelloServer extends Plugin {
|
||||||
afterAdd() {}
|
afterAdd() {}
|
||||||
|
|
||||||
beforeLoad() {}
|
beforeLoad() {}
|
||||||
@ -62,9 +54,7 @@ export class HelloPlugin extends Plugin {
|
|||||||
async load() {
|
async load() {
|
||||||
this.db.collection({
|
this.db.collection({
|
||||||
name: 'hello',
|
name: 'hello',
|
||||||
fields: [
|
fields: [{ type: 'string', name: 'name' }],
|
||||||
{ type: 'string', name: 'name' }
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
this.app.acl.allow('hello', '*');
|
this.app.acl.allow('hello', '*');
|
||||||
}
|
}
|
||||||
@ -78,24 +68,29 @@ export class HelloPlugin extends Plugin {
|
|||||||
async remove() {}
|
async remove() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default HelloPlugin;
|
export default PluginHelloServer;
|
||||||
```
|
|
||||||
|
|
||||||
## 注册插件
|
|
||||||
|
|
||||||
```bash
|
|
||||||
yarn pm add hello
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## 激活插件
|
## 激活插件
|
||||||
|
|
||||||
插件激活时,会自动创建刚才编辑插件配置的 hello 表。
|
**通过命令操作**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn pm enable hello
|
yarn pm enable @my-project/plugin-hello
|
||||||
```
|
```
|
||||||
|
|
||||||
## 启动应用
|
**通过界面操作**
|
||||||
|
|
||||||
|
访问插件管理器界面,查看刚添加的插件,点击激活。
|
||||||
|
插件管理器页面默认为 http://localhost:13000/admin/pm/list/local/
|
||||||
|
|
||||||
|
<img src="https://nocobase.oss-cn-beijing.aliyuncs.com/7b7df26a8ecc32bb1ebc3f99767ff9f9.png" />
|
||||||
|
|
||||||
|
备注:插件激活时,会自动创建刚才编辑插件配置的 hello 表。
|
||||||
|
|
||||||
|
## 调试插件
|
||||||
|
|
||||||
|
如果应用未启动,需要先启动应用
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# for development
|
# for development
|
||||||
@ -106,8 +101,6 @@ yarn build
|
|||||||
yarn start
|
yarn start
|
||||||
```
|
```
|
||||||
|
|
||||||
## 体验插件功能
|
|
||||||
|
|
||||||
向插件的 hello 表里插入数据
|
向插件的 hello 表里插入数据
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@ -123,3 +116,21 @@ curl --location --request POST 'http://localhost:13000/api/hello:create' \
|
|||||||
```bash
|
```bash
|
||||||
curl --location --request GET 'http://localhost:13000/api/hello:list'
|
curl --location --request GET 'http://localhost:13000/api/hello:list'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 构建并打包插件
|
||||||
|
|
||||||
|
```bash
|
||||||
|
yarn build plugins/@my-project/plugin-hello --tar
|
||||||
|
|
||||||
|
# 分步骤
|
||||||
|
yarn build plugins/@my-project/plugin-hello
|
||||||
|
yarn nocobase tar plugins/@my-project/plugin-hello
|
||||||
|
```
|
||||||
|
|
||||||
|
打包的插件默认保存路径为 `storage/tar/@my-project/plugin-hello.tar.gz`
|
||||||
|
|
||||||
|
## 上传至其他 NocoBase 应用
|
||||||
|
|
||||||
|
仅 v0.14 及以上版本支持
|
||||||
|
|
||||||
|
<img src="https://nocobase.oss-cn-beijing.aliyuncs.com/8aa8a511aa8c1e87a8f7ee82cf8a1359.gif" />
|
||||||
|
101
docs/zh-CN/welcome/release/v14-changelog.md
Normal file
101
docs/zh-CN/welcome/release/v14-changelog.md
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
# v0.14:全新的插件管理器,支持通过界面添加插件
|
||||||
|
|
||||||
|
v0.14 实现了生产环境下插件的即插即用,可以直接通过界面添加插件,支持从 npm registry(可以是私有的)下载、本地上传、URL 下载。
|
||||||
|
|
||||||
|
## 新特性
|
||||||
|
|
||||||
|
### 全新的插件管理器界面
|
||||||
|
|
||||||
|
<img src="https://demo-cn.nocobase.com/storage/uploads/6de7c906518b6c6643570292523b06c8.png" />
|
||||||
|
|
||||||
|
### 上传的插件位于 storage/plugins 目录
|
||||||
|
|
||||||
|
提供 storage/plugins 目录用于上传即插即用的插件,目录以 npm packages 的方式组织
|
||||||
|
|
||||||
|
```bash
|
||||||
|
|- /storage/
|
||||||
|
|- /plugins/
|
||||||
|
|- /@nocobase/
|
||||||
|
|- /plugin-hello1/
|
||||||
|
|- /plugin-hello2/
|
||||||
|
|- /my-nocobase-plugin-hello1/
|
||||||
|
|- /my-nocobase-plugin-hello2/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 插件的更新
|
||||||
|
|
||||||
|
目前仅 storage/plugins 下的插件才有更新操作,如图:
|
||||||
|
|
||||||
|
<img src="https://demo-cn.nocobase.com/storage/uploads/703809b8cd74cc95e1ab2ab766980817.gif" />
|
||||||
|
|
||||||
|
备注:为了便于维护和升级,避免因为升级导致 storage 插件不可用,也可以直接将新插件放到 storage/plugins 目录下,再执行升级操作
|
||||||
|
|
||||||
|
## 不兼容的变化
|
||||||
|
|
||||||
|
### 插件目录变更
|
||||||
|
|
||||||
|
开发中的插件统一都放到 packages/plugins 目录下,以 npm packages 的方式组织
|
||||||
|
|
||||||
|
```diff
|
||||||
|
|- /packages/
|
||||||
|
- |- /plugins/acl/
|
||||||
|
+ |- /plugins/@nocobase/plugin-acl/
|
||||||
|
- |- /samples/hello/
|
||||||
|
+ |- /plugins/@nocobase/plugin-sample-hello/
|
||||||
|
```
|
||||||
|
|
||||||
|
全新的目录结构为
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发中的插件
|
||||||
|
|- /packages/
|
||||||
|
|- /plugins/
|
||||||
|
|- /@nocobase/
|
||||||
|
|- /plugin-hello1/
|
||||||
|
|- /plugin-hello2/
|
||||||
|
|- /my-nocobase-plugin-hello1/
|
||||||
|
|- /my-nocobase-plugin-hello2/
|
||||||
|
|
||||||
|
# 通过界面添加的插件
|
||||||
|
|- /storage/
|
||||||
|
|- /plugins/
|
||||||
|
|- /@nocobase/
|
||||||
|
|- /plugin-hello1/
|
||||||
|
|- /plugin-hello2/
|
||||||
|
|- /my-nocobase-plugin-hello1/
|
||||||
|
|- /my-nocobase-plugin-hello2/
|
||||||
|
```
|
||||||
|
|
||||||
|
### 插件名的变化
|
||||||
|
|
||||||
|
- 不再提供 PLUGIN_PACKAGE_PREFIX 环境变量
|
||||||
|
- 插件名和包名统一,旧的插件名仍然可以以别名的形式存在
|
||||||
|
|
||||||
|
### pm add 的改进
|
||||||
|
|
||||||
|
变更情况
|
||||||
|
|
||||||
|
```diff
|
||||||
|
- pm add sample-hello
|
||||||
|
+ pm add @nocobase/plugin-sample-hello
|
||||||
|
```
|
||||||
|
|
||||||
|
pm add 参数说明
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 用 packageName 代替 pluginName,从本地查找,找不到报错
|
||||||
|
pm add packageName
|
||||||
|
|
||||||
|
# 只有提供了 registry 时,才从远程下载,也可以指定版本
|
||||||
|
pm add packageName --registry=xx --auth-token=yy --version=zz
|
||||||
|
|
||||||
|
# 也可以提供本地压缩包,多次 add 用最后的替换
|
||||||
|
pm add /a/plugin.zip
|
||||||
|
|
||||||
|
# 远程压缩包,同名直接替换
|
||||||
|
pm add http://url/plugin.zip
|
||||||
|
```
|
||||||
|
|
||||||
|
## 插件开发指南
|
||||||
|
|
||||||
|
[编写第一个插件](/development/your-fisrt-plugin)
|
@ -34,5 +34,6 @@ module.exports = {
|
|||||||
'package.json',
|
'package.json',
|
||||||
'/demo/',
|
'/demo/',
|
||||||
'package-lock.json',
|
'package-lock.json',
|
||||||
|
'/storage/',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
@ -2,7 +2,8 @@
|
|||||||
"name": "nocobase",
|
"name": "nocobase",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"packages/*/*"
|
"packages/*/*",
|
||||||
|
"packages/*/*/*"
|
||||||
],
|
],
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -21,6 +22,7 @@
|
|||||||
"dev-server": "nocobase dev --server",
|
"dev-server": "nocobase dev --server",
|
||||||
"start": "nocobase start",
|
"start": "nocobase start",
|
||||||
"build": "nocobase build",
|
"build": "nocobase build",
|
||||||
|
"tar": "nocobase tar",
|
||||||
"test": "nocobase test",
|
"test": "nocobase test",
|
||||||
"test:client": "vitest",
|
"test:client": "vitest",
|
||||||
"tc": "yarn test:client",
|
"tc": "yarn test:client",
|
||||||
|
@ -18,17 +18,17 @@
|
|||||||
"@types/lerna__project": "5.1.0",
|
"@types/lerna__project": "5.1.0",
|
||||||
"@vercel/ncc": "0.36.1",
|
"@vercel/ncc": "0.36.1",
|
||||||
"chalk": "2.4.2",
|
"chalk": "2.4.2",
|
||||||
"fast-glob": "3.3.0",
|
"fast-glob": "^3.3.1",
|
||||||
"glob": "^7.1.4",
|
|
||||||
"gulp": "4.0.2",
|
"gulp": "4.0.2",
|
||||||
"gulp-typescript": "6.0.0-alpha.1",
|
"gulp-typescript": "6.0.0-alpha.1",
|
||||||
"pkg-up": "3.1.0",
|
|
||||||
"tsup": "7.2.0",
|
"tsup": "7.2.0",
|
||||||
"typescript": "5.1.3",
|
"typescript": "5.1.3",
|
||||||
"update-notifier": "3.0.0",
|
"update-notifier": "3.0.0",
|
||||||
"vite-plugin-css-injected-by-js": "^3.2.1",
|
"vite-plugin-css-injected-by-js": "^3.2.1",
|
||||||
"vite-plugin-lib-inject-css": "1.2.0",
|
"vite-plugin-lib-inject-css": "1.2.0",
|
||||||
"yargs-parser": "13.1.2"
|
"yargs-parser": "13.1.2",
|
||||||
|
"tar": "^6.2.0",
|
||||||
|
"@types/tar": "^6.1.5"
|
||||||
},
|
},
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
@ -17,8 +17,11 @@ import { buildDeclaration } from './buildDeclaration';
|
|||||||
import { PkgLog, getPkgLog, toUnixPath, getPackageJson } from './utils';
|
import { PkgLog, getPkgLog, toUnixPath, getPackageJson } from './utils';
|
||||||
import { getPackages } from './utils/getPackages';
|
import { getPackages } from './utils/getPackages';
|
||||||
import { Package } from '@lerna/package';
|
import { Package } from '@lerna/package';
|
||||||
|
import { tarPlugin } from './tarPlugin'
|
||||||
|
|
||||||
export async function build(pkgs: string[]) {
|
export async function build(pkgs: string[]) {
|
||||||
|
process.env.NODE_ENV = 'production';
|
||||||
|
|
||||||
const packages = getPackages(pkgs);
|
const packages = getPackages(pkgs);
|
||||||
const pluginPackages = getPluginPackages(packages);
|
const pluginPackages = getPluginPackages(packages);
|
||||||
const cjsPackages = getCjsPackages(packages);
|
const cjsPackages = getCjsPackages(packages);
|
||||||
@ -63,8 +66,17 @@ export async function buildPackage(
|
|||||||
) {
|
) {
|
||||||
const sourcemap = process.argv.includes('--sourcemap');
|
const sourcemap = process.argv.includes('--sourcemap');
|
||||||
const noDeclaration = process.argv.includes('--no-dts');
|
const noDeclaration = process.argv.includes('--no-dts');
|
||||||
|
const hasTar = process.argv.includes('--tar');
|
||||||
|
const onlyTar = process.argv.includes('--only-tar');
|
||||||
|
|
||||||
const log = getPkgLog(pkg.name);
|
const log = getPkgLog(pkg.name);
|
||||||
const packageJson = getPackageJson(pkg.location);
|
const packageJson = getPackageJson(pkg.location);
|
||||||
|
|
||||||
|
if (onlyTar) {
|
||||||
|
await tarPlugin(pkg.location, log);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
log(`${chalk.bold(toUnixPath(pkg.location.replace(PACKAGES_PATH, '').slice(1)))} build start`);
|
log(`${chalk.bold(toUnixPath(pkg.location.replace(PACKAGES_PATH, '').slice(1)))} build start`);
|
||||||
|
|
||||||
// prebuild
|
// prebuild
|
||||||
@ -88,6 +100,11 @@ export async function buildPackage(
|
|||||||
log('postbuild');
|
log('postbuild');
|
||||||
await runScript(['postbuild'], pkg.location);
|
await runScript(['postbuild'], pkg.location);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tar
|
||||||
|
if (hasTar) {
|
||||||
|
await tarPlugin(pkg.location, log);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function runScript(args: string[], cwd: string, envs: Record<string, string> = {}) {
|
function runScript(args: string[], cwd: string, envs: Record<string, string> = {}) {
|
||||||
|
@ -18,7 +18,7 @@ import {
|
|||||||
getPackagesFromFiles,
|
getPackagesFromFiles,
|
||||||
getSourcePackages,
|
getSourcePackages,
|
||||||
} from './utils/buildPluginUtils';
|
} from './utils/buildPluginUtils';
|
||||||
import { getDepsConfig } from './utils/getDepsConfig';
|
import { getDepPkgPath, getDepsConfig } from './utils/getDepsConfig';
|
||||||
import { EsbuildSupportExts, globExcludeFiles } from './constant';
|
import { EsbuildSupportExts, globExcludeFiles } from './constant';
|
||||||
import { PkgLog, getPackageJson } from './utils';
|
import { PkgLog, getPackageJson } from './utils';
|
||||||
|
|
||||||
@ -26,6 +26,8 @@ const serverGlobalFiles: string[] = ['src/**', '!src/client/**', ...globExcludeF
|
|||||||
const clientGlobalFiles: string[] = ['src/**', '!src/server/**', ...globExcludeFiles];
|
const clientGlobalFiles: string[] = ['src/**', '!src/server/**', ...globExcludeFiles];
|
||||||
const dynamicImportRegexp = /import\((["'])(.*?)\1\)/g;
|
const dynamicImportRegexp = /import\((["'])(.*?)\1\)/g;
|
||||||
|
|
||||||
|
const sourceGlobalFiles: string[] = ['src/**/*.{ts,js,tsx,jsx}', '!src/**/__tests__'];
|
||||||
|
|
||||||
const external = [
|
const external = [
|
||||||
// nocobase
|
// nocobase
|
||||||
'@nocobase/acl',
|
'@nocobase/acl',
|
||||||
@ -138,6 +140,21 @@ export function deleteJsFiles(cwd: string, log: PkgLog) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function writeExternalPackageVersion(cwd: string, log: PkgLog) {
|
||||||
|
log('write external version');
|
||||||
|
const sourceFiles = fg.globSync(sourceGlobalFiles, { cwd, absolute: true }).map((item) => fs.readFileSync(item, 'utf-8'));
|
||||||
|
const sourcePackages = getSourcePackages(sourceFiles);
|
||||||
|
const excludePackages = getExcludePackages(sourcePackages, external, pluginPrefix);
|
||||||
|
const data = excludePackages.reduce<Record<string, string>>((prev, packageName) => {
|
||||||
|
const depPkgPath = getDepPkgPath(packageName, cwd);
|
||||||
|
const depPkg = require(depPkgPath);
|
||||||
|
prev[packageName] = depPkg.version;
|
||||||
|
return prev;
|
||||||
|
}, {});
|
||||||
|
const externalVersionPath = path.join(cwd, target_dir, 'externalVersion.js');
|
||||||
|
fs.writeFileSync(externalVersionPath, `module.exports = ${JSON.stringify(data, null, 2)};`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildServerDeps(cwd: string, serverFiles: string[], log: PkgLog) {
|
export async function buildServerDeps(cwd: string, serverFiles: string[], log: PkgLog) {
|
||||||
log('build plugin server dependencies');
|
log('build plugin server dependencies');
|
||||||
const outDir = path.join(cwd, target_dir, 'node_modules');
|
const outDir = path.join(cwd, target_dir, 'node_modules');
|
||||||
@ -382,4 +399,5 @@ export async function buildPlugin(cwd: string, sourcemap: boolean, log: PkgLog)
|
|||||||
console.error(error);
|
console.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
writeExternalPackageVersion(cwd, log);
|
||||||
}
|
}
|
||||||
|
@ -44,3 +44,7 @@ export const getCjsPackages = (packages: Package[]) =>
|
|||||||
.filter((item) => !PLUGINS_DIR.some((dir) => item.location.startsWith(dir)))
|
.filter((item) => !PLUGINS_DIR.some((dir) => item.location.startsWith(dir)))
|
||||||
.filter((item) => !item.location.startsWith(PRESETS_DIR))
|
.filter((item) => !item.location.startsWith(PRESETS_DIR))
|
||||||
.filter((item) => !CJS_EXCLUDE_PACKAGES.includes(item.location));
|
.filter((item) => !CJS_EXCLUDE_PACKAGES.includes(item.location));
|
||||||
|
|
||||||
|
// tar
|
||||||
|
export const tarIncludesFiles = ['package.json', 'README.md', 'LICENSE', 'dist', '!node_modules', '!src'];
|
||||||
|
export const TAR_OUTPUT_DIR = process.env.TAR_PATH ? process.env.TAR_PATH : path.join(ROOT_PATH, 'storage', 'tar');
|
||||||
|
29
packages/core/build/src/tarPlugin.ts
Normal file
29
packages/core/build/src/tarPlugin.ts
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import path from 'path';
|
||||||
|
import tar from 'tar';
|
||||||
|
import fg from 'fast-glob';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
|
||||||
|
import { PkgLog } from "./utils";
|
||||||
|
import { TAR_OUTPUT_DIR, tarIncludesFiles } from './constant'
|
||||||
|
|
||||||
|
export function tarPlugin(cwd: string, log: PkgLog) {
|
||||||
|
log('tar package');
|
||||||
|
const pkg = require(path.join(cwd, 'package.json'));
|
||||||
|
const npmIgnore = path.join(cwd, '.npmignore');
|
||||||
|
let files = pkg.files || [];
|
||||||
|
if (fs.existsSync(npmIgnore)) {
|
||||||
|
files = fs.readFileSync(npmIgnore, 'utf-8').split('\n').filter((item) => item.trim()).map(item => `!${item}`);
|
||||||
|
files.push('**/*');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 必须包含的文件
|
||||||
|
files.push(...tarIncludesFiles);
|
||||||
|
|
||||||
|
files = files.map((item: string) => item !== '**/*' && fs.existsSync(path.join(cwd, item.replace('!', ''))) && fs.statSync(path.join(cwd, item.replace('!', ''))).isDirectory() ? `${item}/**/*` : item);
|
||||||
|
|
||||||
|
const tarball = path.join(TAR_OUTPUT_DIR, `${pkg.name}-${pkg.version}.tgz`);
|
||||||
|
const tarFiles = fg.sync(files, { cwd });
|
||||||
|
|
||||||
|
fs.mkdirpSync(path.dirname(tarball));
|
||||||
|
return tar.c({ gzip: true, file: tarball, cwd }, tarFiles);
|
||||||
|
}
|
@ -1,5 +1,4 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import pkgUp from 'pkg-up';
|
|
||||||
|
|
||||||
export function winPath(path: string) {
|
export function winPath(path: string) {
|
||||||
const isExtendedLengthPath = /^\\\\\?\\/.test(path);
|
const isExtendedLengthPath = /^\\\\\?\\/.test(path);
|
||||||
@ -40,9 +39,9 @@ export function getDepPkgPath(dep: string, cwd: string) {
|
|||||||
try {
|
try {
|
||||||
return require.resolve(`${dep}/package.json`, { paths: [cwd] });
|
return require.resolve(`${dep}/package.json`, { paths: [cwd] });
|
||||||
} catch {
|
} catch {
|
||||||
return pkgUp.sync({
|
const mainFile = require.resolve(`${dep}`, { paths: cwd ? [cwd] : undefined });
|
||||||
cwd: require.resolve(dep, { paths: [cwd] }),
|
const packageDir = mainFile.slice(0, mainFile.indexOf(dep.replace('/', path.sep)) + dep.length);
|
||||||
})!;
|
return path.join(packageDir, 'package.json');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import Topo from '@hapi/topo';
|
import Topo from '@hapi/topo';
|
||||||
import fg from 'fast-glob';
|
import fg from 'fast-glob';
|
||||||
|
import path from 'path';
|
||||||
import { PACKAGES_PATH, ROOT_PATH } from '../constant';
|
import { PACKAGES_PATH, ROOT_PATH } from '../constant';
|
||||||
import { getPackagesSync } from '@lerna/project';
|
import { getPackagesSync } from '@lerna/project';
|
||||||
import { Package } from '@lerna/package';
|
import { Package } from '@lerna/package';
|
||||||
@ -13,7 +14,13 @@ import { toUnixPath } from './utils';
|
|||||||
*/
|
*/
|
||||||
function getPackagesPath(pkgs: string[]) {
|
function getPackagesPath(pkgs: string[]) {
|
||||||
if (pkgs.length === 0) {
|
if (pkgs.length === 0) {
|
||||||
pkgs = ['*/*'];
|
return fg
|
||||||
|
.sync(['*/*/package.json', '*/*/*/package.json'], {
|
||||||
|
cwd: PACKAGES_PATH,
|
||||||
|
absolute: true,
|
||||||
|
onlyFiles: true,
|
||||||
|
})
|
||||||
|
.map(toUnixPath).map(item => path.dirname(item))
|
||||||
}
|
}
|
||||||
return fg
|
return fg
|
||||||
.sync(pkgs, {
|
.sync(pkgs, {
|
||||||
@ -36,7 +43,7 @@ export function getPackages(pkgs: string[]) {
|
|||||||
export function sortPackages(packages: Package[]): Package[] {
|
export function sortPackages(packages: Package[]): Package[] {
|
||||||
const sorter = new Topo.Sorter<Package>();
|
const sorter = new Topo.Sorter<Package>();
|
||||||
for (const pkg of packages) {
|
for (const pkg of packages) {
|
||||||
const pkgJson = require(`${pkg.name}/package.json`);
|
const pkgJson = require(`${pkg.location}/package.json`,);
|
||||||
const after = Object.keys({ ...pkgJson.dependencies, ...pkgJson.devDependencies, ...pkgJson.peerDependencies });
|
const after = Object.keys({ ...pkgJson.dependencies, ...pkgJson.devDependencies, ...pkgJson.peerDependencies });
|
||||||
sorter.add(pkg, { after, group: pkg.name });
|
sorter.add(pkg, { after, group: pkg.name });
|
||||||
}
|
}
|
||||||
|
@ -15,7 +15,9 @@ const env = {
|
|||||||
DB_TIMEZONE: '+00:00',
|
DB_TIMEZONE: '+00:00',
|
||||||
DEFAULT_STORAGE_TYPE: 'local',
|
DEFAULT_STORAGE_TYPE: 'local',
|
||||||
LOCAL_STORAGE_DEST: 'storage/uploads',
|
LOCAL_STORAGE_DEST: 'storage/uploads',
|
||||||
|
PLUGIN_STORAGE_PATH: resolve(process.cwd(), 'storage/plugins'),
|
||||||
MFSU_AD: 'none',
|
MFSU_AD: 'none',
|
||||||
|
NODE_MODULES_PATH: resolve(process.cwd(), 'node_modules'),
|
||||||
PM2_HOME: resolve(process.cwd(), './storage/.pm2'),
|
PM2_HOME: resolve(process.cwd(), './storage/.pm2'),
|
||||||
PLUGIN_PACKAGE_PREFIX: '@nocobase/plugin-,@nocobase/plugin-sample-,@nocobase/preset-',
|
PLUGIN_PACKAGE_PREFIX: '@nocobase/plugin-,@nocobase/plugin-sample-,@nocobase/preset-',
|
||||||
};
|
};
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
"commander": "^9.2.0",
|
"commander": "^9.2.0",
|
||||||
"dotenv": "^10.0.0",
|
"dotenv": "^10.0.0",
|
||||||
"execa": "^5.1.1",
|
"execa": "^5.1.1",
|
||||||
"fast-glob": "^3.2.12",
|
"fast-glob": "^3.3.1",
|
||||||
"fs-extra": "^11.1.1",
|
"fs-extra": "^11.1.1",
|
||||||
"pm2": "^5.2.0",
|
"pm2": "^5.2.0",
|
||||||
"portfinder": "^1.0.28",
|
"portfinder": "^1.0.28",
|
||||||
|
@ -18,6 +18,7 @@ module.exports = (cli) => {
|
|||||||
.allowUnknownOption()
|
.allowUnknownOption()
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
promptForTs();
|
promptForTs();
|
||||||
|
process.env.IS_DEV_CMD = true;
|
||||||
|
|
||||||
if (process.argv.includes('-h') || process.argv.includes('--help')) {
|
if (process.argv.includes('-h') || process.argv.includes('--help')) {
|
||||||
run('ts-node', [
|
run('ts-node', [
|
||||||
@ -59,6 +60,7 @@ module.exports = (cli) => {
|
|||||||
|
|
||||||
const argv = [
|
const argv = [
|
||||||
'watch',
|
'watch',
|
||||||
|
'--ignore=./storage/plugins/**',
|
||||||
'--tsconfig',
|
'--tsconfig',
|
||||||
'./tsconfig.server.json',
|
'./tsconfig.server.json',
|
||||||
'-r',
|
'-r',
|
||||||
|
@ -9,6 +9,7 @@ module.exports = (cli) => {
|
|||||||
generateAppDir();
|
generateAppDir();
|
||||||
require('./global')(cli);
|
require('./global')(cli);
|
||||||
require('./build')(cli);
|
require('./build')(cli);
|
||||||
|
require('./tar')(cli);
|
||||||
require('./dev')(cli);
|
require('./dev')(cli);
|
||||||
require('./start')(cli);
|
require('./start')(cli);
|
||||||
require('./test')(cli);
|
require('./test')(cli);
|
||||||
|
@ -3,6 +3,7 @@ const { run, isDev, isPackageValid } = require('../util');
|
|||||||
const { resolve } = require('path');
|
const { resolve } = require('path');
|
||||||
const { existsSync } = require('fs');
|
const { existsSync } = require('fs');
|
||||||
const { readFile, writeFile } = require('fs').promises;
|
const { readFile, writeFile } = require('fs').promises;
|
||||||
|
const { createStoragePluginsSymlink } = require('@nocobase/utils/plugin-symlink');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Command} cli
|
* @param {Command} cli
|
||||||
@ -13,6 +14,7 @@ module.exports = (cli) => {
|
|||||||
.command('postinstall')
|
.command('postinstall')
|
||||||
.allowUnknownOption()
|
.allowUnknownOption()
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
|
await createStoragePluginsSymlink();
|
||||||
if (!isDev()) {
|
if (!isDev()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
27
packages/core/cli/src/commands/tar.js
Normal file
27
packages/core/cli/src/commands/tar.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
const { resolve } = require('path');
|
||||||
|
const { Command } = require('commander');
|
||||||
|
const { run, nodeCheck, isPackageValid } = require('../util');
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param {Command} cli
|
||||||
|
*/
|
||||||
|
module.exports = (cli) => {
|
||||||
|
cli
|
||||||
|
.command('tar')
|
||||||
|
.allowUnknownOption()
|
||||||
|
.argument('[packages...]')
|
||||||
|
.option('-v, --version', 'print version')
|
||||||
|
.option('-c, --compile', 'compile the @nocobase/build package')
|
||||||
|
.option('-w, --watch', 'watch compile the @nocobase/build package')
|
||||||
|
.action(async (pkgs, options) => {
|
||||||
|
nodeCheck();
|
||||||
|
if (options.compile || options.watch || isPackageValid('@nocobase/build/src/index.ts')) {
|
||||||
|
await run('yarn', ['build', options.watch ? '--watch' : ''], {
|
||||||
|
cwd: resolve(process.cwd(), 'packages/core/build'),
|
||||||
|
});
|
||||||
|
if (options.watch) return;
|
||||||
|
}
|
||||||
|
await run('nocobase-build', [...pkgs, '--only-tar', options.version ? '--version' : '']);
|
||||||
|
});
|
||||||
|
};
|
@ -2,7 +2,9 @@ const chalk = require('chalk');
|
|||||||
const { existsSync } = require('fs');
|
const { existsSync } = require('fs');
|
||||||
const { join, resolve } = require('path');
|
const { join, resolve } = require('path');
|
||||||
const { Generator } = require('@umijs/utils');
|
const { Generator } = require('@umijs/utils');
|
||||||
const { readFile } = require('fs').promises;
|
const { readFile, writeFile } = require('fs').promises;
|
||||||
|
|
||||||
|
const execa = require('execa');
|
||||||
|
|
||||||
function camelize(str) {
|
function camelize(str) {
|
||||||
return str.trim().replace(/[-_\s]+(.)?/g, (match, c) => c.toUpperCase());
|
return str.trim().replace(/[-_\s]+(.)?/g, (match, c) => c.toUpperCase());
|
||||||
@ -33,18 +35,27 @@ class PluginGenerator extends Generator {
|
|||||||
|
|
||||||
async getContext() {
|
async getContext() {
|
||||||
const { name } = this.context;
|
const { name } = this.context;
|
||||||
const packageName = await getProjectName();
|
|
||||||
const nocobaseVersion = require('@nocobase/server/package.json').version;
|
const nocobaseVersion = require('@nocobase/server/package.json').version;
|
||||||
const packageVersion = await getProjectVersion();
|
const packageVersion = await getProjectVersion();
|
||||||
return {
|
return {
|
||||||
...this.context,
|
...this.context,
|
||||||
packageName: `@${packageName}/plugin-${name}`,
|
packageName: name,
|
||||||
packageVersion,
|
packageVersion,
|
||||||
nocobaseVersion,
|
nocobaseVersion,
|
||||||
pascalCaseName: capitalize(camelize(name)),
|
pascalCaseName: capitalize(camelize(name.split('/').pop())),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addTsConfigPaths() {
|
||||||
|
const { name } = this.context;
|
||||||
|
if (name.startsWith('@') && name.split('/')[0] !== '@nocobase') {
|
||||||
|
const target = resolve(process.cwd(), 'tsconfig.json');
|
||||||
|
const content = require(target);
|
||||||
|
content.compilerOptions.paths[name] = [`packages/plugins/${name}/src`];
|
||||||
|
await writeFile(target, JSON.stringify(content, null, 2), 'utf-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async writing() {
|
async writing() {
|
||||||
const { name } = this.context;
|
const { name } = this.context;
|
||||||
const target = resolve(process.cwd(), 'packages/plugins/', name);
|
const target = resolve(process.cwd(), 'packages/plugins/', name);
|
||||||
@ -59,6 +70,9 @@ class PluginGenerator extends Generator {
|
|||||||
path: join(__dirname, '../templates/plugin'),
|
path: join(__dirname, '../templates/plugin'),
|
||||||
});
|
});
|
||||||
console.log('');
|
console.log('');
|
||||||
|
await this.addTsConfigPaths();
|
||||||
|
execa.sync('yarn', ['install'], { shell: true, stdio: 'inherit' });
|
||||||
|
// execa.sync('yarn', ['build', `plugins/${name}`], { shell: true, stdio: 'inherit' });
|
||||||
console.log(`The plugin folder is in ${chalk.green(`packages/plugins/${name}`)}`);
|
console.log(`The plugin folder is in ${chalk.green(`packages/plugins/${name}`)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
1
packages/core/cli/templates/plugin/README.md.tpl
Normal file
1
packages/core/cli/templates/plugin/README.md.tpl
Normal file
@ -0,0 +1 @@
|
|||||||
|
# {{{packageName}}}
|
@ -1,6 +1,6 @@
|
|||||||
import { Plugin } from '@nocobase/client';
|
import { Plugin } from '@nocobase/client';
|
||||||
|
|
||||||
export class {{{pascalCaseName}}}Plugin extends Plugin {
|
export class {{{pascalCaseName}}}Client extends Plugin {
|
||||||
async afterAdd() {
|
async afterAdd() {
|
||||||
// await this.app.pm.add()
|
// await this.app.pm.add()
|
||||||
}
|
}
|
||||||
@ -18,4 +18,4 @@ export class {{{pascalCaseName}}}Plugin extends Plugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {{{pascalCaseName}}}Plugin;
|
export default {{{pascalCaseName}}}Client;
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { InstallOptions, Plugin } from '@nocobase/server';
|
import { InstallOptions, Plugin } from '@nocobase/server';
|
||||||
|
|
||||||
export class {{{pascalCaseName}}}Plugin extends Plugin {
|
export class {{{pascalCaseName}}}Server extends Plugin {
|
||||||
afterAdd() {}
|
afterAdd() {}
|
||||||
|
|
||||||
beforeLoad() {}
|
beforeLoad() {}
|
||||||
@ -16,4 +16,4 @@ export class {{{pascalCaseName}}}Plugin extends Plugin {
|
|||||||
async remove() {}
|
async remove() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default {{{pascalCaseName}}}Plugin;
|
export default {{{pascalCaseName}}}Server;
|
||||||
|
@ -40,7 +40,7 @@ export class PluginManager {
|
|||||||
|
|
||||||
private async initRemotePlugins() {
|
private async initRemotePlugins() {
|
||||||
try {
|
try {
|
||||||
const res = await this.app.apiClient.request({ url: 'app:getPlugins' });
|
const res = await this.app.apiClient.request({ url: 'pm:listEnabled' });
|
||||||
const pluginList: PluginData[] = res?.data?.data || [];
|
const pluginList: PluginData[] = res?.data?.data || [];
|
||||||
const plugins = await getPlugins({
|
const plugins = await getPlugins({
|
||||||
requirejs: this.app.requirejs,
|
requirejs: this.app.requirejs,
|
||||||
|
@ -10,7 +10,7 @@ import { Plugin } from '../Plugin';
|
|||||||
describe('Application', () => {
|
describe('Application', () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
const mock = new MockAdapter(axios);
|
const mock = new MockAdapter(axios);
|
||||||
mock.onGet('app:getPlugins').reply(200, {
|
mock.onGet('pm:listEnabled').reply(200, {
|
||||||
data: [],
|
data: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -6,7 +6,7 @@ import { Plugin } from '../Plugin';
|
|||||||
describe('Plugin', () => {
|
describe('Plugin', () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
const mock = new MockAdapter(axios);
|
const mock = new MockAdapter(axios);
|
||||||
mock.onGet('app:getPlugins').reply(200, {
|
mock.onGet('pm:listEnabled').reply(200, {
|
||||||
data: [],
|
data: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -11,7 +11,7 @@ describe('Router', () => {
|
|||||||
let app: Application;
|
let app: Application;
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
const mock = new MockAdapter(axios);
|
const mock = new MockAdapter(axios);
|
||||||
mock.onGet('app:getPlugins').reply(200, {
|
mock.onGet('pm:listEnabled').reply(200, {
|
||||||
data: [],
|
data: [],
|
||||||
});
|
});
|
||||||
app = new Application();
|
app = new Application();
|
||||||
|
@ -21,13 +21,11 @@ export function getRemotePlugins(
|
|||||||
baseURL = baseURL.slice(0, -4);
|
baseURL = baseURL.slice(0, -4);
|
||||||
}
|
}
|
||||||
|
|
||||||
// for dynamic import `import()`
|
|
||||||
(window as any).staticBaseUrl = `/api/plugins/client`;
|
|
||||||
requirejs.requirejs.config({
|
requirejs.requirejs.config({
|
||||||
waitSeconds: 120,
|
waitSeconds: 120,
|
||||||
paths: pluginData.reduce<Record<string, string>>((memo, item) => {
|
paths: pluginData.reduce<Record<string, string>>((memo, item) => {
|
||||||
memo[item.packageName] = `${baseURL}${item.url}`;
|
memo[item.packageName] = `${baseURL}${item.url}?noExt`;
|
||||||
memo[`${item.packageName}/client`] = `${baseURL}${item.url}.js?client`;
|
memo[`${item.packageName}/client`] = `${baseURL}${item.url}?noExtAndIsClient`;
|
||||||
return memo;
|
return memo;
|
||||||
}, {}),
|
}, {}),
|
||||||
});
|
});
|
||||||
|
@ -74,6 +74,8 @@ export default {
|
|||||||
"Value":"Value",
|
"Value":"Value",
|
||||||
"Disabled":"Disabled",
|
"Disabled":"Disabled",
|
||||||
"Enabled":"Enabled",
|
"Enabled":"Enabled",
|
||||||
|
"Problematic": "Problematic",
|
||||||
|
"Setting": "Setting",
|
||||||
'On':'On',
|
'On':'On',
|
||||||
'Off':'Off',
|
'Off':'Off',
|
||||||
"Empty":"Empty",
|
"Empty":"Empty",
|
||||||
@ -653,6 +655,47 @@ export default {
|
|||||||
"Local": "Local",
|
"Local": "Local",
|
||||||
"Built-in": "Built-in",
|
"Built-in": "Built-in",
|
||||||
"Marketplace": "Marketplace",
|
"Marketplace": "Marketplace",
|
||||||
|
"Add plugin": "Add plugin",
|
||||||
|
"Plugin source": "Plugin source",
|
||||||
|
"Upgrade": "Upgrade",
|
||||||
|
"Plugin dependencies check failed": "Plugin dependencies check failed",
|
||||||
|
"More details": "More details",
|
||||||
|
"Upload new version": "Upload new version",
|
||||||
|
"Version": "Version",
|
||||||
|
"Npm package": "Npm package",
|
||||||
|
"Npm package name": "Npm package name",
|
||||||
|
"Upload plugin": "Upload plugin",
|
||||||
|
"Official plugin": "Official plugin",
|
||||||
|
"Add type": "Add type",
|
||||||
|
"Changelog": "Changelog",
|
||||||
|
"Dependencies check": "Dependencies check",
|
||||||
|
"Update plugin": "Update plugin",
|
||||||
|
"Installing": "Installing",
|
||||||
|
"The deletion was successful.": "The deletion was successful.",
|
||||||
|
"Plugin Zip File": "Plugin Zip File",
|
||||||
|
"Compressed file url": "Compressed file url",
|
||||||
|
"Last updated": "Last updated",
|
||||||
|
"PackageName": "PackageName",
|
||||||
|
"DisplayName": "DisplayName",
|
||||||
|
"Readme": "Readme",
|
||||||
|
"Dependencies compatibility check": "Dependencies compatibility check",
|
||||||
|
"Plugin dependencies check failed, you should change the dependent version to meet the version requirements.": "Plugin dependencies check failed, you should change the dependent version to meet the version requirements.",
|
||||||
|
"Version range": "Version range",
|
||||||
|
"Plugin's version": "Plugin's version",
|
||||||
|
"Result": "Result",
|
||||||
|
"No CHANGELOG.md file": "No CHANGELOG.md file",
|
||||||
|
"No README.md file": "No README.md file",
|
||||||
|
"Homepage": "Homepage",
|
||||||
|
'Drag and drop the file here or click to upload, file size should not exceed 30M': 'Drag and drop the file here or click to upload, file size should not exceed 30M',
|
||||||
|
"Dependencies check failed, can't enable.": "Dependencies check failed, can't enable.",
|
||||||
|
"Plugin starting...": "Plugin starting...",
|
||||||
|
"Plugin stopping...": "Plugin stopping...",
|
||||||
|
"Are you sure to delete this plugin?": "Are you sure to delete this plugin?",
|
||||||
|
"re-download file": "re-download file",
|
||||||
|
"Not enabled": "Not enabled",
|
||||||
|
"Search plugin": "Search plugin",
|
||||||
|
"Author": "Author",
|
||||||
|
"Plugin loading failed. Please check the server logs.": "Plugin loading failed. Please check the server logs.",
|
||||||
"Coming soon...": "Coming soon...",
|
"Coming soon...": "Coming soon...",
|
||||||
"All plugin settings": "All plugin settings",
|
"All plugin settings": "All plugin settings",
|
||||||
"Bookmark": "Bookmark",
|
"Bookmark": "Bookmark",
|
||||||
|
@ -652,6 +652,46 @@ export default {
|
|||||||
"Local": "Local",
|
"Local": "Local",
|
||||||
"Built-in": "Integrado",
|
"Built-in": "Integrado",
|
||||||
"Marketplace": "Mercado",
|
"Marketplace": "Mercado",
|
||||||
|
"New plugin": "Nuevo plugin",
|
||||||
|
"Upgrade": "Actualizar",
|
||||||
|
"Dependencies check failed": "Error en la comprobación de dependencias",
|
||||||
|
"More details": "Más detalles",
|
||||||
|
"Upload new version": "Subir nueva versión",
|
||||||
|
"Official plugin": "Plugin oficial",
|
||||||
|
"Version": "Versión",
|
||||||
|
"Npm package": "Paquete Npm",
|
||||||
|
"Upload plugin": "Subir plugin",
|
||||||
|
"Npm package name": "Nombre del paquete Npm",
|
||||||
|
"Add type": "Añadir tipo",
|
||||||
|
"Changelog": "Registro de cambios",
|
||||||
|
"Dependencies check": "Comprobación de dependencias",
|
||||||
|
"Update plugin": "Actualizar plugin",
|
||||||
|
"Installing": "Instalando",
|
||||||
|
"The deletion was successful.": "Eliminación correcta",
|
||||||
|
"Plugin Zip File": "Archivo Zip del plugin",
|
||||||
|
"Compressed file url": "URL del archivo comprimido",
|
||||||
|
"Last updated": "Última actualización",
|
||||||
|
"PackageName": "Nombre del paquete",
|
||||||
|
"DisplayName": "Nombre para mostrar",
|
||||||
|
"Readme": "Leerme",
|
||||||
|
"Dependencies compatibility check": "Comprobación de compatibilidad de dependencias",
|
||||||
|
"If the compatibility check fails, you should change the dependent version to meet the version requirements.": "Si la comprobación de compatibilidad falla, debe cambiar la versión dependiente para cumplir con los requisitos de versión.",
|
||||||
|
"Version range": "Rango de versión",
|
||||||
|
"Plugin's version": "Versión del plugin",
|
||||||
|
"Result": "Resultado",
|
||||||
|
"No CHANGELOG.md file": "No hay archivo CHANGELOG.md",
|
||||||
|
"No README.md file": "No hay archivo README.md",
|
||||||
|
"Homepage": "Página de inicio",
|
||||||
|
'Drag and drop the file here or click to upload, file size should not exceed 30M': "Arrastra y suelta el archivo aquí o haz clic para subirlo, el tamaño del archivo no debe superar los 30M",
|
||||||
|
"Dependencies check failed, can't enable.": "Error en la comprobación de dependencias, no se puede habilitar.",
|
||||||
|
"Plugin starting...": "Plugin iniciando...",
|
||||||
|
"Plugin stopping...": "Plugin deteniendo...",
|
||||||
|
"Are you sure to delete this plugin?": "¿Estás seguro de eliminar este plugin?",
|
||||||
|
"re-download file": "volver a descargar el archivo",
|
||||||
|
"Not enabled": "No habilitado",
|
||||||
|
"Search plugin": "Buscar plugin",
|
||||||
|
"Author": "Autor",
|
||||||
|
"Plugin loading failed. Please check the server logs.": "Error al cargar el plugin. Compruebe los registros del servidor.",
|
||||||
"Coming soon...": "Próximamente...",
|
"Coming soon...": "Próximamente...",
|
||||||
"All plugin settings": "Configuración de todos los plugins",
|
"All plugin settings": "Configuración de todos los plugins",
|
||||||
"Bookmark": "Marcador",
|
"Bookmark": "Marcador",
|
||||||
|
@ -649,6 +649,46 @@ export default {
|
|||||||
"Local": "Local",
|
"Local": "Local",
|
||||||
"Built-in": "Intégré",
|
"Built-in": "Intégré",
|
||||||
"Marketplace": "Place de marché",
|
"Marketplace": "Place de marché",
|
||||||
|
"New plugin": "Nouveau plugin",
|
||||||
|
"Upgrade": "Mise à jour",
|
||||||
|
"Dependencies check failed": "Échec de la vérification des dépendances",
|
||||||
|
"More details": "Plus de détails",
|
||||||
|
"Upload new version": "Télécharger une nouvelle version",
|
||||||
|
"Official plugin": "Plugin officiel",
|
||||||
|
"Version": "Version",
|
||||||
|
"Npm package": "Paquet Npm",
|
||||||
|
"Upload plugin": "Télécharger un plugin",
|
||||||
|
"Npm package name": "Nom du paquet Npm",
|
||||||
|
"Add type": "Ajouter un type",
|
||||||
|
"Changelog": "Journal des modifications",
|
||||||
|
"Dependencies check": "Vérification des dépendances",
|
||||||
|
"Update plugin": "Mettre à jour le plugin",
|
||||||
|
"Installing": "Installation",
|
||||||
|
"The deletion was successful.": "La suppression a réussi.",
|
||||||
|
"Plugin Zip File": "Fichier Zip du plugin",
|
||||||
|
"Compressed file url": "URL du fichier compressé",
|
||||||
|
"Last updated": "Dernière mise à jour",
|
||||||
|
"PackageName": "Nom du paquet",
|
||||||
|
"DisplayName": "Nom d'affichage",
|
||||||
|
"Readme": "Lisez-moi",
|
||||||
|
"Dependencies compatibility check": "Vérification de la compatibilité des dépendances",
|
||||||
|
"If the compatibility check fails, you should change the dependent version to meet the version requirements.": "Si la vérification de la compatibilité échoue, vous devez modifier la version dépendante pour répondre aux exigences de version.",
|
||||||
|
"Version range": "Plage de version",
|
||||||
|
"Plugin's version": "Version du plugin",
|
||||||
|
"Result": "Résultat",
|
||||||
|
"No CHANGELOG.md file": "Aucun fichier CHANGELOG.md",
|
||||||
|
"No README.md file": "Aucun fichier README.md",
|
||||||
|
"Homepage": "Page d'accueil",
|
||||||
|
'Drag and drop the file here or click to upload, file size should not exceed 30M': "Faites glisser et déposez le fichier ici ou cliquez pour télécharger, la taille du fichier ne doit pas dépasser 30M",
|
||||||
|
"Dependencies check failed, can't enable.": "Échec de la vérification des dépendances, impossible d'activer.",
|
||||||
|
"Plugin starting...": "Démarrage du plugin...",
|
||||||
|
"Plugin stopping...": "Arrêt du plugin...",
|
||||||
|
"Are you sure to delete this plugin?": "Êtes-vous sûr de vouloir supprimer ce plugin ?",
|
||||||
|
"re-download file": "re-télécharger le fichier",
|
||||||
|
"Not enabled": "Non activé",
|
||||||
|
"Search plugin": "Rechercher un plugin",
|
||||||
|
"Author": "Auteur",
|
||||||
|
"Plugin loading failed. Please check the server logs.": "Échec du chargement du plugin. Veuillez vérifier les journaux du serveur.",
|
||||||
"Coming soon...": "Bientôt...",
|
"Coming soon...": "Bientôt...",
|
||||||
"All plugin settings": "Tous les paramètres de plugin",
|
"All plugin settings": "Tous les paramètres de plugin",
|
||||||
"Bookmark": "Signet",
|
"Bookmark": "Signet",
|
||||||
|
@ -626,5 +626,45 @@ export default {
|
|||||||
"Tag color field":"ラベルの色フィールド",
|
"Tag color field":"ラベルの色フィールド",
|
||||||
"Sync successfully":"同期成功",
|
"Sync successfully":"同期成功",
|
||||||
"Sync from form fields":"フォームフィールドの同期",
|
"Sync from form fields":"フォームフィールドの同期",
|
||||||
"Select all":"すべて選択"
|
"Select all": "すべて選択",
|
||||||
|
"New plugin": "新しいプラグイン",
|
||||||
|
"Upgrade": "アップグレード",
|
||||||
|
"Dependencies check failed": "依存関係のチェックに失敗しました",
|
||||||
|
"More details": "詳細",
|
||||||
|
"Upload new version": "新しいバージョンをアップロード",
|
||||||
|
"Version": "バージョン",
|
||||||
|
"Npm package": "Npmパッケージ",
|
||||||
|
"Npm package name": "Npmパッケージ名",
|
||||||
|
"Upload plugin": "プラグインをアップロード",
|
||||||
|
"Official plugin": "公式プラグイン",
|
||||||
|
"Add type": "タイプを追加",
|
||||||
|
"Changelog": "変更履歴",
|
||||||
|
"Dependencies check": "依存関係のチェック",
|
||||||
|
"Update plugin": "プラグインをアップグレード",
|
||||||
|
"Installing": "インストール中",
|
||||||
|
"The deletion was successful.": "削除に成功しました。",
|
||||||
|
"Plugin Zip File": "プラグインZipファイル",
|
||||||
|
"Compressed file url": "圧縮ファイルのURL",
|
||||||
|
"Last updated": "最終更新",
|
||||||
|
"PackageName": "パッケージ名",
|
||||||
|
"DisplayName": "表示名",
|
||||||
|
"Readme": "Readme",
|
||||||
|
"Dependencies compatibility check": "依存関係の互換性チェック",
|
||||||
|
"If the compatibility check fails, you should change the dependent version to meet the version requirements.": "互換性チェックに失敗した場合は、依存関係のバージョンを変更して、バージョン要件を満たす必要があります。",
|
||||||
|
"Version range": "バージョン範囲",
|
||||||
|
"Plugin's version": "プラグインのバージョン",
|
||||||
|
"Result": "結果",
|
||||||
|
"No CHANGELOG.md file": "CHANGELOG.mdファイルがありません",
|
||||||
|
"No README.md file": "README.mdファイルがありません",
|
||||||
|
"Homepage": "ホームページ",
|
||||||
|
"Drag and drop the file here or click to upload, file size should not exceed 30M": "ファイルをここにドラッグ&ドロップするか、クリックしてアップロードしてください。ファイルサイズは10Mを超えてはいけません",
|
||||||
|
"Dependencies check failed, can't enable.": "依存関係のチェックに失敗しました。有効にできません。",
|
||||||
|
"Plugin starting...": "プラグインを起動しています...",
|
||||||
|
"Plugin stopping...": "プラグインを停止しています...",
|
||||||
|
"Are you sure to delete this plugin?": "このプラグインを削除してもよろしいですか?",
|
||||||
|
"re-download file": "ファイルを再ダウンロード",
|
||||||
|
"Not enabled": "有効になっていません",
|
||||||
|
"Search plugin": "プラグインを検索",
|
||||||
|
"Author": "著者",
|
||||||
|
"Plugin loading failed. Please check the server logs.": "プラグインのロードに失敗しました。サーバーログを確認してください。",
|
||||||
}
|
}
|
||||||
|
@ -615,6 +615,46 @@ export default {
|
|||||||
"Local": "Local",
|
"Local": "Local",
|
||||||
"Built-in": "Integrado",
|
"Built-in": "Integrado",
|
||||||
"Marketplace": "Loja de aplicativos",
|
"Marketplace": "Loja de aplicativos",
|
||||||
|
"New plugin": "Novo plugin",
|
||||||
|
"Upgrade": "Atualizar",
|
||||||
|
"Dependencies check failed": "Falha na verificação de dependências",
|
||||||
|
"More details": "Mais detalhes",
|
||||||
|
"Upload new version": "Enviar nova versão",
|
||||||
|
"Official plugin": "Plugin oficial",
|
||||||
|
"Version": "Versão",
|
||||||
|
"Npm package": "Pacote Npm",
|
||||||
|
"Upload plugin": "Enviar plugin",
|
||||||
|
"Npm package name": "Nome do pacote Npm",
|
||||||
|
"Add type": "Adicionar tipo",
|
||||||
|
"Changelog": "Registro de alterações",
|
||||||
|
"Dependencies check": "Verificação de dependências",
|
||||||
|
"Update plugin": "Atualizar plugin",
|
||||||
|
"Installing": "Instalando",
|
||||||
|
"The deletion was successful.": "A exclusão foi bem sucedida.",
|
||||||
|
"Plugin Zip File": "Arquivo Zip do plugin",
|
||||||
|
"Compressed file url": "URL do arquivo compactado",
|
||||||
|
"Last updated": "Última atualização",
|
||||||
|
"PackageName": "Nome do pacote",
|
||||||
|
"DisplayName": "Nome de exibição",
|
||||||
|
"Readme": "Leia-me",
|
||||||
|
"Dependencies compatibility check": "Verificação de compatibilidade de dependências",
|
||||||
|
"If the compatibility check fails, you should change the dependent version to meet the version requirements.": "Se a verificação de compatibilidade falhar, você deve alterar a versão dependente para atender aos requisitos de versão.",
|
||||||
|
"Version range": "Intervalo de versão",
|
||||||
|
"Plugin's version": "Versão do plugin",
|
||||||
|
"Result": "Resultado",
|
||||||
|
"No CHANGELOG.md file": "Nenhum arquivo CHANGELOG.md",
|
||||||
|
"No README.md file": "Nenhum arquivo README.md",
|
||||||
|
"Homepage": "Página inicial",
|
||||||
|
"Drag and drop the file here or click to upload, file size should not exceed 30M": "Arraste e solte o arquivo aqui ou clique para enviar, o tamanho do arquivo não deve exceder 30M",
|
||||||
|
"Dependencies check failed, can't enable.": "Falha na verificação de dependências, não é possível habilitar.",
|
||||||
|
"Plugin starting...": "Plugin iniciando...",
|
||||||
|
"Plugin stopping...": "Plugin parando...",
|
||||||
|
"Are you sure to delete this plugin?": "Tem certeza de que deseja excluir este plugin?",
|
||||||
|
"re-download file": "re-fazer download do arquivo",
|
||||||
|
"Not enabled": "Não habilitado",
|
||||||
|
"Search plugin": "Pesquisar plugin",
|
||||||
|
"Author": "Autor",
|
||||||
|
"Plugin loading failed. Please check the server logs.": "Falha ao carregar o plugin. Verifique os logs do servidor.",
|
||||||
"Coming soon...": "Em breve...",
|
"Coming soon...": "Em breve...",
|
||||||
"All plugin settings": "Configurações de todos os plugins",
|
"All plugin settings": "Configurações de todos os plugins",
|
||||||
"Bookmark": "Favoritar",
|
"Bookmark": "Favoritar",
|
||||||
|
@ -505,5 +505,46 @@ export default {
|
|||||||
'Form data templates': "Шаблоны данных формы",
|
'Form data templates': "Шаблоны данных формы",
|
||||||
"Data template": "Шаблон данных",
|
"Data template": "Шаблон данных",
|
||||||
"Not found":"Не найдено",
|
"Not found":"Не найдено",
|
||||||
"Add":"Добавить"
|
"Add": "Добавить",
|
||||||
|
"Select all": "Выбрать все",
|
||||||
|
"New plugin": "Новый плагин",
|
||||||
|
"Upgrade": "Обновить",
|
||||||
|
"Dependencies check failed": "Проверка зависимостей не удалась",
|
||||||
|
"More details": "Подробнее",
|
||||||
|
"Upload new version": "Загрузить новую версию",
|
||||||
|
"Version": "Версия",
|
||||||
|
"Npm package": "Npm пакет",
|
||||||
|
"Npm package name": "Имя npm пакета",
|
||||||
|
"Upload plugin": "Загрузить плагин",
|
||||||
|
"Official plugin": "Официальный плагин",
|
||||||
|
"Add type": "Добавить тип",
|
||||||
|
"Changelog": "Изменения",
|
||||||
|
"Dependencies check": "Проверка зависимостей",
|
||||||
|
"Update plugin": "Обновить плагин",
|
||||||
|
"Installing": "Установка",
|
||||||
|
"The deletion was successful.": "Удаление прошло успешно.",
|
||||||
|
"Plugin Zip File": "Zip файл плагина",
|
||||||
|
"Compressed file url": "URL сжатого файла",
|
||||||
|
"Last updated": "Последнее обновление",
|
||||||
|
"PackageName": "Имя пакета",
|
||||||
|
"DisplayName": "Отображаемое имя",
|
||||||
|
"Readme": "Инструкция",
|
||||||
|
"Dependencies compatibility check": "Проверка совместимости зависимостей",
|
||||||
|
"Plugin dependencies check failed, you should change the dependent version to meet the version requirements.": "Если проверка совместимости зависимостей не удалась, вы должны изменить версию зависимости, чтобы соответствовать требованиям версии.",
|
||||||
|
"Version range": "Диапазон версий",
|
||||||
|
"Plugin's version": "Версия плагина",
|
||||||
|
"Result": "Результат",
|
||||||
|
"No CHANGELOG.md file": "Нет файла CHANGELOG.md",
|
||||||
|
"No README.md file": "Нет файла README.md",
|
||||||
|
"Homepage": "Домашняя страница",
|
||||||
|
"Drag and drop the file here or click to upload, file size should not exceed 30M": "Перетащите файл сюда или нажмите, чтобы загрузить, размер файла не должен превышать 30M",
|
||||||
|
"Dependencies check failed, can't enable.": "Проверка зависимостей не удалась, невозможно включить.",
|
||||||
|
"Plugin starting...": "Запуск плагина...",
|
||||||
|
"Plugin stopping...": "Остановка плагина...",
|
||||||
|
"Are you sure to delete this plugin?": "Вы уверены, что хотите удалить этот плагин?",
|
||||||
|
"re-download file": "повторно загрузить файл",
|
||||||
|
"Not enabled": "Не включено",
|
||||||
|
"Search plugin": "Поиск плагина",
|
||||||
|
"Author": "Автор",
|
||||||
|
"Plugin loading failed. Please check the server logs.": "Не удалось загрузить плагин. Пожалуйста, проверьте журналы сервера.",
|
||||||
}
|
}
|
||||||
|
@ -504,4 +504,44 @@ export default {
|
|||||||
'Display data template selector': 'Veri şablonu seçicisini görüntüle',
|
'Display data template selector': 'Veri şablonu seçicisini görüntüle',
|
||||||
'Form data templates': 'Form veri şablonları',
|
'Form data templates': 'Form veri şablonları',
|
||||||
"Data template": "Veri şablonu",
|
"Data template": "Veri şablonu",
|
||||||
|
"New plugin": "Yeni eklenti",
|
||||||
|
"Upgrade": "Yükselt",
|
||||||
|
"Dependencies check failed": "Bağımlılıkların kontrolü başarısız oldu",
|
||||||
|
"More details": "Daha fazla detay",
|
||||||
|
"Upload new version": "Yeni sürüm yükle",
|
||||||
|
"Version": "Sürüm",
|
||||||
|
"Npm package": "Npm paketi",
|
||||||
|
"Npm package name": "Npm paket adı",
|
||||||
|
"Upload plugin": "Eklenti yükle",
|
||||||
|
"Official plugin": "Resmi eklenti",
|
||||||
|
"Add type": "Tür ekle",
|
||||||
|
"Changelog": "Değişiklik günlüğü",
|
||||||
|
"Dependencies check": "Bağımlılıkların kontrolü",
|
||||||
|
"Update plugin": "Eklentiyi yükselt",
|
||||||
|
"Installing": "Yükleniyor",
|
||||||
|
"The deletion was successful.": "Silme başarılı.",
|
||||||
|
"Plugin Zip File": "Eklenti Zip Dosyası",
|
||||||
|
"Compressed file url": "Sıkıştırılmış dosya bağlantısı",
|
||||||
|
"Last updated": "Son güncelleme",
|
||||||
|
"PackageName": "Paket Adı",
|
||||||
|
"DisplayName": "Görünen Ad",
|
||||||
|
"Readme": "Okuma Dosyası",
|
||||||
|
"Dependencies compatibility check": "Bağımlılık uyumluluğu kontrolü",
|
||||||
|
"If the compatibility check fails, you should change the dependent version to meet the version requirements.": "Uyumluluk kontrolü başarısız olursa, bağımlı olan sürümü değiştirmeniz gerekmektedir.",
|
||||||
|
"Version range": "Sürüm aralığı",
|
||||||
|
"Plugin's version": "Eklentinin sürümü",
|
||||||
|
"Result": "Sonuç",
|
||||||
|
"No CHANGELOG.md file": "CHANGELOG.md dosyası bulunmamaktadır",
|
||||||
|
"No README.md file": "README.md dosyası bulunmamaktadır",
|
||||||
|
"Homepage": "Anasayfa",
|
||||||
|
"Drag and drop the file here or click to upload, file size should not exceed 30M": "Dosyayı buraya sürükleyin veya yüklemek için tıklayın, dosya boyutu 30M'i geçmemelidir",
|
||||||
|
"Dependencies check failed, can't enable.": "Bağımlılık kontrolü başarısız oldu, etkinleştirilemiyor.",
|
||||||
|
"Plugin starting...": "Eklenti başlatılıyor...",
|
||||||
|
"Plugin stopping...": "Eklenti durduruluyor...",
|
||||||
|
"Are you sure to delete this plugin?": "Bu eklentiyi silmek istediğinizden emin misiniz?",
|
||||||
|
"re-download file": "dosyayı yeniden indir",
|
||||||
|
"Not enabled": "Etkin değil",
|
||||||
|
"Search plugin": "Eklenti ara",
|
||||||
|
"Author": "Yazar",
|
||||||
|
"Plugin loading failed. Please check the server logs.": "Eklenti yüklenemedi. Lütfen sunucu günlüklerini kontrol edin.",
|
||||||
}
|
}
|
||||||
|
@ -669,6 +669,46 @@ export default {
|
|||||||
"Local": "Локальний",
|
"Local": "Локальний",
|
||||||
"Built-in": "Вбудований",
|
"Built-in": "Вбудований",
|
||||||
"Marketplace": "Маркетплейс",
|
"Marketplace": "Маркетплейс",
|
||||||
|
"New plugin": "Новий плагін",
|
||||||
|
"Upgrade": "Оновити",
|
||||||
|
"Dependencies check failed": "Перевірка залежностей не вдалася",
|
||||||
|
"More details": "Детальніше",
|
||||||
|
"Upload new version": "Завантажити нову версію",
|
||||||
|
"Official plugin": "Офіційний плагін",
|
||||||
|
"Version": "Версія",
|
||||||
|
"Npm package": "Npm пакет",
|
||||||
|
"Upload plugin": "Завантажити плагін",
|
||||||
|
"Npm package name": "Ім'я пакета npm",
|
||||||
|
"Add type": "Додати тип",
|
||||||
|
"Changelog": "Журнал змін",
|
||||||
|
"Dependencies check": "Перевірка залежностей",
|
||||||
|
"Update plugin": "Оновити плагін",
|
||||||
|
"Installing": "Встановлення",
|
||||||
|
"The deletion was successful.": "Видалення пройшло успішно.",
|
||||||
|
"Plugin Zip File": "Файл плагіна Zip",
|
||||||
|
"Compressed file url": "URL стислого файлу",
|
||||||
|
"Last updated": "Останнє оновлення",
|
||||||
|
"PackageName": "Назва пакунка",
|
||||||
|
"DisplayName": "Відображуване ім'я",
|
||||||
|
"Readme": "Інструкція",
|
||||||
|
"Dependencies compatibility check": "Перевірка сумісності залежностей",
|
||||||
|
"Plugin dependencies check failed, you should change the dependent version to meet the version requirements.": "Якщо перевірка сумісності не вдається, вам слід змінити залежну версію, щоб вона відповідала вимогам до версії.",
|
||||||
|
"Version range": "Діапазон версій",
|
||||||
|
"Plugin's version": "Версія плагіна",
|
||||||
|
"Result": "Результат",
|
||||||
|
"No CHANGELOG.md file": "Файл CHANGELOG.md відсутній",
|
||||||
|
"No README.md file": "Файл README.md відсутній",
|
||||||
|
"Homepage": "Домашня сторінка",
|
||||||
|
"Drag and drop the file here or click to upload, file size should not exceed 30M": "Перетягніть файл сюди або натисніть, щоб завантажити, розмір файлу не повинен перевищувати 10 Мб",
|
||||||
|
"Dependencies check failed, can't enable.": "Перевірка залежностей не вдалась, неможливо увімкнути.",
|
||||||
|
"Plugin starting...": "Запуск плагіна...",
|
||||||
|
"Plugin stopping...": "Зупинка плагіна...",
|
||||||
|
"Are you sure to delete this plugin?": "Ви впевнені, що хочете видалити цей плагін?",
|
||||||
|
"re-download file": "перезавантажити файл",
|
||||||
|
"Not enabled": "Не увімкнено",
|
||||||
|
"Search plugin": "Пошук плагіна",
|
||||||
|
"Author": "Автор",
|
||||||
|
"Plugin loading failed. Please check the server logs.": "Не вдалося завантажити плагін. Будь ласка, перевірте журнали сервера.",
|
||||||
"Coming soon...": "Скоро буде...",
|
"Coming soon...": "Скоро буде...",
|
||||||
"All plugin settings": "Всі налаштування плагінів",
|
"All plugin settings": "Всі налаштування плагінів",
|
||||||
"Bookmark": "Закладка",
|
"Bookmark": "Закладка",
|
||||||
|
@ -80,6 +80,7 @@ export default {
|
|||||||
Value: '字段值',
|
Value: '字段值',
|
||||||
Disabled: '禁用',
|
Disabled: '禁用',
|
||||||
Enabled: '启用',
|
Enabled: '启用',
|
||||||
|
Problematic: '有问题',
|
||||||
Empty: '赋空值',
|
Empty: '赋空值',
|
||||||
'Linkage rule': '联动规则',
|
'Linkage rule': '联动规则',
|
||||||
'Linkage rules': '联动规则',
|
'Linkage rules': '联动规则',
|
||||||
@ -708,7 +709,50 @@ export default {
|
|||||||
'Plugin manager': '插件管理器',
|
'Plugin manager': '插件管理器',
|
||||||
Local: '本地',
|
Local: '本地',
|
||||||
'Built-in': '内置',
|
'Built-in': '内置',
|
||||||
Marketplace: '插件市场',
|
'Marketplace': '插件市场',
|
||||||
|
"Add plugin": "新增插件",
|
||||||
|
"Upgrade": "可供更新",
|
||||||
|
"Plugin dependencies check failed": "插件依赖检查失败",
|
||||||
|
"Remove": "移除",
|
||||||
|
"Docs": "文档",
|
||||||
|
"More details": "更多详情",
|
||||||
|
"Upload new version": "上传新版",
|
||||||
|
"Official plugin": "官方插件",
|
||||||
|
"Version": "版本",
|
||||||
|
"Npm package": "Npm 包",
|
||||||
|
"Upload plugin": "上传插件",
|
||||||
|
"Npm package name": "Npm 包名",
|
||||||
|
"Add type": "新增方式",
|
||||||
|
"Plugin source": "插件来源",
|
||||||
|
"Changelog": "更新日志",
|
||||||
|
"Dependencies check": "依赖检查",
|
||||||
|
"Update plugin": "更新插件",
|
||||||
|
"Installing": "安装中",
|
||||||
|
"The deletion was successful.": "删除成功",
|
||||||
|
"Plugin Zip File": "插件压缩包",
|
||||||
|
"Compressed file url": "压缩包地址",
|
||||||
|
"Last updated": "最后更新",
|
||||||
|
"PackageName": "包名",
|
||||||
|
"DisplayName": "显示名称",
|
||||||
|
"Readme": "说明文档",
|
||||||
|
"Dependencies compatibility check": "依赖兼容性检查",
|
||||||
|
"Plugin dependencies check failed, you should change the dependent version to meet the version requirements.": "插件兼容性检查失败,你应该修改依赖版本以满足版本要求。",
|
||||||
|
"Version range": "版本范围",
|
||||||
|
"Plugin's version": "插件的版本",
|
||||||
|
"Result": "结果",
|
||||||
|
"No CHANGELOG.md file": "没有 CHANGELOG.md 日志",
|
||||||
|
"No README.md file": "没有 README.md 文件",
|
||||||
|
"Homepage": "主页",
|
||||||
|
'Drag and drop the file here or click to upload, file size should not exceed 30M': '将文件拖放到此处或单击上传,文件大小不应超过 30M',
|
||||||
|
"Dependencies check failed, can't enable.": "依赖检查失败,无法启用。",
|
||||||
|
"Plugin starting...": "插件启动中...",
|
||||||
|
"Plugin stopping...": "插件停止中...",
|
||||||
|
"Are you sure to delete this plugin?": "确定要删除此插件吗?",
|
||||||
|
"re-download file": "重新下载文件",
|
||||||
|
"Not enabled": "未启用",
|
||||||
|
"Search plugin": "搜索插件",
|
||||||
|
"Author": "作者",
|
||||||
|
"Plugin loading failed. Please check the server logs.": "插件加载失败,请检查服务器日志。",
|
||||||
'Coming soon...': '敬请期待...',
|
'Coming soon...': '敬请期待...',
|
||||||
'All plugin settings': '所有插件配置',
|
'All plugin settings': '所有插件配置',
|
||||||
Bookmark: '书签',
|
Bookmark: '书签',
|
||||||
|
@ -132,12 +132,21 @@ const getProps = (app: Application) => {
|
|||||||
upgrade: {
|
upgrade: {
|
||||||
title: 'App upgrading',
|
title: 'App upgrading',
|
||||||
},
|
},
|
||||||
|
'pm.add': {
|
||||||
|
title: 'Adding plugin',
|
||||||
|
},
|
||||||
|
'pm.update': {
|
||||||
|
title: 'Updating plugin',
|
||||||
|
},
|
||||||
'pm.enable': {
|
'pm.enable': {
|
||||||
title: 'Enabling plugin',
|
title: 'Enabling plugin',
|
||||||
},
|
},
|
||||||
'pm.disable': {
|
'pm.disable': {
|
||||||
title: 'Disabling plugin',
|
title: 'Disabling plugin',
|
||||||
},
|
},
|
||||||
|
'pm.remove': {
|
||||||
|
title: 'Removing plugin',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
return { ...props, ...commands[app.error?.command?.name] };
|
return { ...props, ...commands[app.error?.command?.name] };
|
||||||
}
|
}
|
||||||
|
@ -1,359 +0,0 @@
|
|||||||
import { DeleteOutlined, SettingOutlined } from '@ant-design/icons';
|
|
||||||
import {
|
|
||||||
App,
|
|
||||||
Avatar,
|
|
||||||
Card,
|
|
||||||
Modal,
|
|
||||||
Popconfirm,
|
|
||||||
Spin,
|
|
||||||
Switch,
|
|
||||||
Tabs,
|
|
||||||
TabsProps,
|
|
||||||
Tag,
|
|
||||||
Tooltip,
|
|
||||||
Typography,
|
|
||||||
message,
|
|
||||||
} from 'antd';
|
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import type { IPluginData } from '.';
|
|
||||||
import { useAPIClient, useRequest } from '../api-client';
|
|
||||||
import { useGlobalTheme } from '../global-theme';
|
|
||||||
import { useStyles as useMarkdownStyles } from '../schema-component/antd/markdown/style';
|
|
||||||
import { useParseMarkdown } from '../schema-component/antd/markdown/util';
|
|
||||||
import { useStyles } from './style';
|
|
||||||
|
|
||||||
interface PluginDocumentProps {
|
|
||||||
path: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ICommonCard {
|
|
||||||
onClick: () => void;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
title: string;
|
|
||||||
displayName: string;
|
|
||||||
actions?: JSX.Element[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IPluginDetail {
|
|
||||||
plugin: any;
|
|
||||||
onCancel: () => void;
|
|
||||||
items: TabsProps['items'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* get color by string
|
|
||||||
* TODO: real avatar
|
|
||||||
* @param str
|
|
||||||
*/
|
|
||||||
const stringToColor = function (str: string) {
|
|
||||||
let hash = 0;
|
|
||||||
for (let i = 0; i < str.length; i++) {
|
|
||||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
|
||||||
}
|
|
||||||
let color = '#';
|
|
||||||
for (let i = 0; i < 3; i++) {
|
|
||||||
const value = (hash >> (i * 8)) & 0xff;
|
|
||||||
color += ('00' + value.toString(16)).substr(-2);
|
|
||||||
}
|
|
||||||
return color;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PluginDocument: React.FC<PluginDocumentProps> = (props) => {
|
|
||||||
const { styles } = useStyles();
|
|
||||||
const { isDarkTheme } = useGlobalTheme();
|
|
||||||
const { componentCls, hashId } = useMarkdownStyles({ isDarkTheme });
|
|
||||||
const [docLang, setDocLang] = useState('');
|
|
||||||
const { name, path } = props;
|
|
||||||
const { data, loading, error } = useRequest<{
|
|
||||||
data: {
|
|
||||||
content: string;
|
|
||||||
};
|
|
||||||
}>(
|
|
||||||
{
|
|
||||||
url: '/plugins:getTabInfo',
|
|
||||||
params: {
|
|
||||||
filterByTk: name,
|
|
||||||
path: path,
|
|
||||||
locale: docLang,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
refreshDeps: [name, path, docLang],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const { html, loading: parseLoading } = useParseMarkdown(data?.data?.content);
|
|
||||||
|
|
||||||
const htmlWithOutRelativeDirect = useMemo(() => {
|
|
||||||
if (html) {
|
|
||||||
const pattern = /<a\s+href="\..*?\/([^/]+)"/g;
|
|
||||||
return html.replace(pattern, (match, $1) => match + `onclick="return false;"`); // prevent the default event of <a/>
|
|
||||||
}
|
|
||||||
}, [html]);
|
|
||||||
|
|
||||||
const handleSwitchDocLang = useCallback((e: MouseEvent) => {
|
|
||||||
const lang = (e.target as HTMLDivElement).innerHTML;
|
|
||||||
if (lang.trim() === '中文') {
|
|
||||||
setDocLang('zh-CN');
|
|
||||||
} else if (lang.trim() === 'English') {
|
|
||||||
setDocLang('en-US');
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const md = document.getElementById('pm-md-preview');
|
|
||||||
md.addEventListener('click', handleSwitchDocLang);
|
|
||||||
return () => {
|
|
||||||
removeEventListener('click', handleSwitchDocLang);
|
|
||||||
};
|
|
||||||
}, [handleSwitchDocLang]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.PluginDocument} id="pm-md-preview">
|
|
||||||
{loading || parseLoading ? (
|
|
||||||
<Spin />
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
className={`${componentCls} ${hashId} nb-markdown nb-markdown-default nb-markdown-table`}
|
|
||||||
dangerouslySetInnerHTML={{ __html: error ? '' : htmlWithOutRelativeDirect }}
|
|
||||||
></div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function PluginDetail(props: IPluginDetail) {
|
|
||||||
const { plugin, onCancel, items } = props;
|
|
||||||
const { styles } = useStyles();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
footer={false}
|
|
||||||
className={styles.PluginDetail}
|
|
||||||
width="70%"
|
|
||||||
title={
|
|
||||||
<Typography.Title level={2} style={{ margin: 0 }}>
|
|
||||||
{plugin?.displayName || plugin?.name}
|
|
||||||
<Tag className={'version-tag'}>v{plugin?.version}</Tag>
|
|
||||||
</Typography.Title>
|
|
||||||
}
|
|
||||||
open={!!plugin}
|
|
||||||
onCancel={onCancel}
|
|
||||||
destroyOnClose
|
|
||||||
>
|
|
||||||
{plugin?.description && <div className={'plugin-desc'}>{plugin?.description}</div>}
|
|
||||||
<Tabs items={items} />
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CommonCard(props: ICommonCard) {
|
|
||||||
const { onClick, name, displayName, actions, description, title } = props;
|
|
||||||
const { styles } = useStyles();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
bordered={false}
|
|
||||||
className={styles.CommonCard}
|
|
||||||
onClick={onClick}
|
|
||||||
hoverable
|
|
||||||
// className={cls(css`
|
|
||||||
// &:hover {
|
|
||||||
// border: 1px solid var(--antd-wave-shadow-color);
|
|
||||||
// cursor: pointer;
|
|
||||||
// }
|
|
||||||
|
|
||||||
// border: 1px solid transparent;
|
|
||||||
// `)}
|
|
||||||
actions={actions}
|
|
||||||
// actions={[<a>Settings</a>, <a>Remove</a>, <Switch size={'small'} defaultChecked={true}></Switch>]}
|
|
||||||
>
|
|
||||||
<Card.Meta
|
|
||||||
className={styles.avatar}
|
|
||||||
avatar={<Avatar style={{ background: `${stringToColor(name)}` }}>{name?.[0]}</Avatar>}
|
|
||||||
description={
|
|
||||||
<Tooltip title={description} placement="bottom">
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
overflow: 'hidden',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
textOverflow: 'ellipsis',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{description || '-'}
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
}
|
|
||||||
title={
|
|
||||||
<span>
|
|
||||||
{displayName || name}
|
|
||||||
<span className={styles.version}>{title}</span>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PluginCard = (props: { data: IPluginData }) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { data } = props;
|
|
||||||
const api = useAPIClient();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { enabled, name, displayName, id, description, version } = data;
|
|
||||||
const [plugin, setPlugin] = useState<any>(null);
|
|
||||||
const { modal } = App.useApp();
|
|
||||||
const { data: tabsData, run } = useRequest<any>(
|
|
||||||
{
|
|
||||||
url: '/plugins:getTabs',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
manual: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const items = useMemo<TabsProps['items']>(() => {
|
|
||||||
return tabsData?.data?.tabs.map((item) => {
|
|
||||||
return {
|
|
||||||
label: item.title,
|
|
||||||
key: item.path,
|
|
||||||
children: React.createElement(PluginDocument, {
|
|
||||||
name: tabsData?.data.filterByTk,
|
|
||||||
path: item.path,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [tabsData?.data]);
|
|
||||||
|
|
||||||
const actions = useMemo(
|
|
||||||
() =>
|
|
||||||
[
|
|
||||||
enabled ? (
|
|
||||||
<SettingOutlined
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate(`/admin/settings/${name}`);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : null,
|
|
||||||
<Popconfirm
|
|
||||||
key={id}
|
|
||||||
title={t('Are you sure to delete this plugin?')}
|
|
||||||
onConfirm={async (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
await api.request({
|
|
||||||
url: `pm:remove/${name}`,
|
|
||||||
});
|
|
||||||
message.success(t('插件删除成功'));
|
|
||||||
// window.location.reload();
|
|
||||||
}}
|
|
||||||
onCancel={(e) => e.stopPropagation()}
|
|
||||||
okText={t('Yes')}
|
|
||||||
cancelText={t('No')}
|
|
||||||
>
|
|
||||||
<DeleteOutlined onClick={(e) => e.stopPropagation()} />
|
|
||||||
</Popconfirm>,
|
|
||||||
<Switch
|
|
||||||
key={id}
|
|
||||||
size={'small'}
|
|
||||||
onChange={async (checked, e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
await api.request({
|
|
||||||
url: `pm:${checked ? 'enable' : 'disable'}/${name}`,
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
checked={enabled}
|
|
||||||
></Switch>,
|
|
||||||
].filter(Boolean),
|
|
||||||
[api, enabled, navigate, id, name, t],
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PluginDetail plugin={plugin} onCancel={() => setPlugin(null)} items={items} />
|
|
||||||
<CommonCard
|
|
||||||
onClick={() => {
|
|
||||||
setPlugin(data);
|
|
||||||
run({
|
|
||||||
params: {
|
|
||||||
filterByTk: name,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
name={name}
|
|
||||||
description={description}
|
|
||||||
title={version}
|
|
||||||
actions={actions}
|
|
||||||
displayName={displayName}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const BuiltInPluginCard = (props: { data: IPluginData }) => {
|
|
||||||
const {
|
|
||||||
data: { description, name, version, displayName },
|
|
||||||
data,
|
|
||||||
} = props;
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [plugin, setPlugin] = useState<any>(null);
|
|
||||||
const { data: tabsData, run } = useRequest<{
|
|
||||||
data: {
|
|
||||||
tabs: {
|
|
||||||
title: string;
|
|
||||||
path: string;
|
|
||||||
}[];
|
|
||||||
filterByTk: string;
|
|
||||||
};
|
|
||||||
}>(
|
|
||||||
{
|
|
||||||
url: '/plugins:getTabs',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
manual: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const items = useMemo(() => {
|
|
||||||
return tabsData?.data?.tabs.map((item) => {
|
|
||||||
return {
|
|
||||||
label: item.title,
|
|
||||||
key: item.path,
|
|
||||||
children: React.createElement(PluginDocument, {
|
|
||||||
name: tabsData?.data.filterByTk,
|
|
||||||
path: item.path,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [tabsData?.data]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<PluginDetail plugin={plugin} onCancel={() => setPlugin(null)} items={items} />
|
|
||||||
<CommonCard
|
|
||||||
onClick={() => {
|
|
||||||
setPlugin(data);
|
|
||||||
run({
|
|
||||||
params: {
|
|
||||||
filterByTk: name,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
name={name}
|
|
||||||
displayName={displayName}
|
|
||||||
description={description}
|
|
||||||
title={version}
|
|
||||||
actions={[
|
|
||||||
<div key="placeholder-comp"></div>,
|
|
||||||
<SettingOutlined
|
|
||||||
key="settings"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
navigate(`/admin/settings/${name}`);
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
219
packages/core/client/src/pm/PluginCard.tsx
Normal file
219
packages/core/client/src/pm/PluginCard.tsx
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
import { App, Card, Divider, Popconfirm, Space, Switch, Typography, message } from 'antd';
|
||||||
|
import classnames from 'classnames';
|
||||||
|
import React, { FC, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { DeleteOutlined, ReadOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons';
|
||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { useAPIClient } from '../api-client';
|
||||||
|
import { PluginDetail } from './PluginDetail';
|
||||||
|
import { PluginUpgradeModal } from './PluginForm/modal/PluginUpgradeModal';
|
||||||
|
import { useStyles } from './style';
|
||||||
|
import type { IPluginData } from './types';
|
||||||
|
|
||||||
|
interface IPluginInfo extends IPluginCard {
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PluginInfo(props: IPluginInfo) {
|
||||||
|
const { data, onClick } = props;
|
||||||
|
const { name, displayName, isCompatible, packageName, updatable, builtIn, enabled, description, type, error } = data;
|
||||||
|
const { styles, theme } = useStyles();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const api = useAPIClient();
|
||||||
|
const { modal } = App.useApp();
|
||||||
|
const [showUploadForm, setShowUploadForm] = useState(false);
|
||||||
|
const [enabledVal, setEnabledVal] = useState(enabled);
|
||||||
|
const reload = () => window.location.reload();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showUploadForm && (
|
||||||
|
<PluginUpgradeModal
|
||||||
|
isShow={showUploadForm}
|
||||||
|
pluginData={data}
|
||||||
|
onClose={(isRefresh) => {
|
||||||
|
setShowUploadForm(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Card
|
||||||
|
size={'small'}
|
||||||
|
bordered={false}
|
||||||
|
onClick={() => {
|
||||||
|
!error && onClick();
|
||||||
|
}}
|
||||||
|
headStyle={{ border: 'none', minHeight: 'inherit', paddingTop: 14 }}
|
||||||
|
bodyStyle={{ paddingTop: 10 }}
|
||||||
|
// style={{ marginBottom: theme.marginLG }}
|
||||||
|
title={<div>{displayName || name || packageName}</div>}
|
||||||
|
hoverable
|
||||||
|
className={css`
|
||||||
|
.ant-card-actions {
|
||||||
|
li .ant-space {
|
||||||
|
gap: 2px !important;
|
||||||
|
}
|
||||||
|
li a {
|
||||||
|
.anticon {
|
||||||
|
margin-right: 3px;
|
||||||
|
/* display: none; */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
li:last-child {
|
||||||
|
width: 20% !important;
|
||||||
|
}
|
||||||
|
li:first-child {
|
||||||
|
width: 80% !important;
|
||||||
|
border-inline-end: 0;
|
||||||
|
text-align: left;
|
||||||
|
padding-left: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
actions={[
|
||||||
|
<Space split={<Divider type="vertical" />} key={'1'}>
|
||||||
|
<a key={'5'}>
|
||||||
|
<ReadOutlined /> {t('Docs')}
|
||||||
|
</a>
|
||||||
|
{updatable && (
|
||||||
|
<a
|
||||||
|
key={'3'}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowUploadForm(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ReloadOutlined /> {t('Update')}
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{enabled ? (
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
navigate(`/admin/settings/${name}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SettingOutlined /> {t('Setting')}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Popconfirm
|
||||||
|
key={'delete'}
|
||||||
|
disabled={builtIn}
|
||||||
|
title={t('Are you sure to delete this plugin?')}
|
||||||
|
onConfirm={async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
api.request({
|
||||||
|
url: `pm:remove`,
|
||||||
|
params: {
|
||||||
|
filterByTk: name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onCancel={(e) => e.stopPropagation()}
|
||||||
|
okText={t('Yes')}
|
||||||
|
cancelText={t('No')}
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
className={classnames({ [styles.cardActionDisabled]: builtIn })}
|
||||||
|
>
|
||||||
|
<DeleteOutlined /> {t('Remove')}
|
||||||
|
</a>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Space>,
|
||||||
|
<Switch
|
||||||
|
key={'enable'}
|
||||||
|
size={'small'}
|
||||||
|
disabled={builtIn || error}
|
||||||
|
onChange={async (checked, e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!isCompatible && checked) {
|
||||||
|
message.error(t("Dependencies check failed, can't enable."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await api.request({
|
||||||
|
url: `pm:${checked ? 'enable' : 'disable'}`,
|
||||||
|
params: {
|
||||||
|
filterByTk: name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
checked={enabledVal}
|
||||||
|
></Switch>,
|
||||||
|
].filter(Boolean)}
|
||||||
|
>
|
||||||
|
<Card.Meta
|
||||||
|
description={
|
||||||
|
!error ? (
|
||||||
|
<Typography.Paragraph
|
||||||
|
style={{ height: theme.fontSize * theme.lineHeight * 3 }}
|
||||||
|
type={isCompatible ? 'secondary' : 'danger'}
|
||||||
|
ellipsis={{ rows: 3 }}
|
||||||
|
>
|
||||||
|
{isCompatible ? description : t('Plugin dependencies check failed')}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
) : (
|
||||||
|
<Typography.Text type="danger">
|
||||||
|
{t('Plugin loading failed. Please check the server logs.')}
|
||||||
|
</Typography.Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{/* {!isCompatible && !error && (
|
||||||
|
<Button style={{ padding: 0 }} type="link">
|
||||||
|
<Typography.Text type="danger">{t('Dependencies check failed')}</Typography.Text>
|
||||||
|
</Button>
|
||||||
|
)} */}
|
||||||
|
{/*
|
||||||
|
<Col span={8}>
|
||||||
|
<Space direction="vertical" align="end" style={{ display: 'flex', marginTop: -10 }}>
|
||||||
|
{type && (
|
||||||
|
<Button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowUploadForm(true);
|
||||||
|
}}
|
||||||
|
ghost
|
||||||
|
type="primary"
|
||||||
|
>
|
||||||
|
{t('Update plugin')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!error && (
|
||||||
|
<Button style={{ padding: 0 }} type="link">
|
||||||
|
{t('More details')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Col> */}
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPluginCard {
|
||||||
|
data: IPluginData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PluginCard: FC<IPluginCard> = (props) => {
|
||||||
|
const { data } = props;
|
||||||
|
const [plugin, setPlugin] = useState<IPluginData>(undefined);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{plugin && <PluginDetail plugin={plugin} onCancel={() => setPlugin(undefined)} />}
|
||||||
|
<PluginInfo
|
||||||
|
onClick={() => {
|
||||||
|
setPlugin(data);
|
||||||
|
}}
|
||||||
|
data={data}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
265
packages/core/client/src/pm/PluginDetail.tsx
Normal file
265
packages/core/client/src/pm/PluginDetail.tsx
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
import { Alert, Col, Modal, Row, Space, Spin, Table, Tabs, TabsProps, Tag, Typography } from 'antd';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
import React, { FC, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useRequest } from '../api-client';
|
||||||
|
import { PluginDocument } from './PluginDocument';
|
||||||
|
import { useStyles } from './style';
|
||||||
|
import { IPluginData } from './types';
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
|
type Author =
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
url?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface PackageJSON {
|
||||||
|
name: string;
|
||||||
|
version: string;
|
||||||
|
description?: string;
|
||||||
|
repository?: string | { type: string; url: string };
|
||||||
|
homepage?: string;
|
||||||
|
license?: string;
|
||||||
|
author?: Author;
|
||||||
|
devDependencies?: Record<string, string>;
|
||||||
|
dependencies?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DepCompatible {
|
||||||
|
name: string;
|
||||||
|
result: boolean;
|
||||||
|
versionRange: string;
|
||||||
|
packageVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPluginDetailData {
|
||||||
|
packageJson: PackageJSON;
|
||||||
|
depsCompatible: DepCompatible[] | false;
|
||||||
|
lastUpdated: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IPluginDetail {
|
||||||
|
plugin: IPluginData;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PluginDetail: FC<IPluginDetail> = ({ plugin, onCancel }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const dependenciesCompatibleTableColumns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
title: t('Name'),
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('Version range'),
|
||||||
|
dataIndex: 'versionRange',
|
||||||
|
key: 'versionRange',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t("Plugin's version"),
|
||||||
|
dataIndex: 'packageVersion',
|
||||||
|
key: 'packageVersion',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('Result'),
|
||||||
|
dataIndex: 'result',
|
||||||
|
key: 'result',
|
||||||
|
render: (result: boolean) => <Tag color={result ? 'success' : 'error'}>{result ? 'Yes' : 'No'}</Tag>,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[t],
|
||||||
|
);
|
||||||
|
const { data, loading } = useRequest<{ data: IPluginDetailData }>(
|
||||||
|
{
|
||||||
|
url: `pm:get`,
|
||||||
|
params: {
|
||||||
|
filterByTk: plugin.name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
refreshDeps: [plugin.name],
|
||||||
|
ready: !!plugin.name,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const repository = useMemo(() => {
|
||||||
|
if (!data?.data?.packageJson?.repository) return null;
|
||||||
|
const repository = data?.data?.packageJson.repository;
|
||||||
|
const url = typeof repository === 'string' ? repository : repository.url;
|
||||||
|
return url.replace(/\.git$/, '').replace(/^git\+/, '');
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const author = useMemo(() => {
|
||||||
|
const author = data?.data?.packageJson.author;
|
||||||
|
if (!author) return null;
|
||||||
|
if (typeof author === 'string') return author;
|
||||||
|
return author.name;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const { styles, theme } = useStyles();
|
||||||
|
|
||||||
|
const tabItems: TabsProps['items'] = [
|
||||||
|
{
|
||||||
|
key: 'readme',
|
||||||
|
label: t('Readme'),
|
||||||
|
children: (
|
||||||
|
<Row gutter={20}>
|
||||||
|
<Col span={16}>
|
||||||
|
{plugin?.readmeUrl ? (
|
||||||
|
<PluginDocument url={plugin?.readmeUrl} packageName={plugin.packageName} />
|
||||||
|
) : (
|
||||||
|
t('No README.md file')
|
||||||
|
)}
|
||||||
|
</Col>
|
||||||
|
<Col span={8}>
|
||||||
|
<Row>
|
||||||
|
{plugin.name && (
|
||||||
|
<Col span={12}>
|
||||||
|
<div className={styles.PluginDetailBaseInfo}>
|
||||||
|
<Typography.Text type="secondary">{t('Name')}</Typography.Text>
|
||||||
|
<Typography.Text strong>{plugin.name}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
{plugin.displayName && (
|
||||||
|
<Col span={12}>
|
||||||
|
<div className={styles.PluginDetailBaseInfo}>
|
||||||
|
<Typography.Text type="secondary">{t('DisplayName')}</Typography.Text>
|
||||||
|
<Typography.Text strong>{plugin.displayName}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
<Col span={24}>
|
||||||
|
<div className={styles.PluginDetailBaseInfo}>
|
||||||
|
<Typography.Text type="secondary">{t('PackageName')}</Typography.Text>
|
||||||
|
<Typography.Text strong>{plugin.packageName}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
{repository && (
|
||||||
|
<Col span={24}>
|
||||||
|
<div className={styles.PluginDetailBaseInfo}>
|
||||||
|
<Typography.Text type="secondary">{t('Repository')}</Typography.Text>
|
||||||
|
<Typography.Text strong>{repository}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
{data?.data?.packageJson.homepage && (
|
||||||
|
<Col span={24}>
|
||||||
|
<div className={styles.PluginDetailBaseInfo}>
|
||||||
|
<Typography.Text type="secondary">{t('Homepage')}</Typography.Text>
|
||||||
|
<Typography.Text strong>{data?.data?.packageJson.homepage}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
{plugin.description && (
|
||||||
|
<Col span={24}>
|
||||||
|
<div className={styles.PluginDetailBaseInfo}>
|
||||||
|
<Typography.Text type="secondary">{t('Description')}</Typography.Text>
|
||||||
|
<Typography.Text strong>{plugin.description}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
{data?.data?.packageJson.license && (
|
||||||
|
<Col span={12}>
|
||||||
|
<div className={styles.PluginDetailBaseInfo}>
|
||||||
|
<Typography.Text type="secondary">{t('License')}</Typography.Text>
|
||||||
|
<Typography.Text strong>{data?.data?.packageJson.license}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
{author && (
|
||||||
|
<Col span={12}>
|
||||||
|
<div className={styles.PluginDetailBaseInfo}>
|
||||||
|
<Typography.Text type="secondary">{t('Author')}</Typography.Text>
|
||||||
|
<Typography.Text strong>{author}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
)}
|
||||||
|
<Col span={12}>
|
||||||
|
<div className={styles.PluginDetailBaseInfo}>
|
||||||
|
<Typography.Text type="secondary">{t('Last updated')}</Typography.Text>
|
||||||
|
<Typography.Text strong>{dayjs(data?.data?.lastUpdated).fromNow()}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
<Col span={12}>
|
||||||
|
<div className={styles.PluginDetailBaseInfo}>
|
||||||
|
<Typography.Text type="secondary">{t('Version')}</Typography.Text>
|
||||||
|
<Typography.Text strong>{plugin?.version}</Typography.Text>
|
||||||
|
</div>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dependencies',
|
||||||
|
label: t('Dependencies compatibility check'),
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
{data?.data?.depsCompatible === false ? (
|
||||||
|
<Typography.Text type="danger">
|
||||||
|
{t('`dist/externalVersion.js` not found or failed to `require`. Please rebuild this plugin.')}
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!data?.data?.['isCompatible'] && (
|
||||||
|
<Alert
|
||||||
|
showIcon
|
||||||
|
type={'error'}
|
||||||
|
message={t(
|
||||||
|
'Plugin dependencies check failed, you should change the dependent version to meet the version requirements.',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Table
|
||||||
|
style={{ marginTop: theme.margin }}
|
||||||
|
rowKey={'name'}
|
||||||
|
pagination={false}
|
||||||
|
columns={dependenciesCompatibleTableColumns}
|
||||||
|
dataSource={data?.data?.depsCompatible}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'changelog',
|
||||||
|
label: t('Changelog'),
|
||||||
|
children: plugin?.changelogUrl ? <PluginDocument url={plugin?.changelogUrl} /> : t('No CHANGELOG.md file'),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={!!plugin} footer={false} destroyOnClose width={1200} onCancel={onCancel}>
|
||||||
|
{loading ? (
|
||||||
|
<Spin />
|
||||||
|
) : (
|
||||||
|
plugin && (
|
||||||
|
<>
|
||||||
|
<Typography.Title level={3}>{plugin.packageName}</Typography.Title>
|
||||||
|
<Space split={<span> • </span>}>
|
||||||
|
<span>{plugin.version}</span>
|
||||||
|
<span>
|
||||||
|
{t('Last updated')} {dayjs(data?.data?.lastUpdated).fromNow()}
|
||||||
|
</span>
|
||||||
|
</Space>
|
||||||
|
<Tabs
|
||||||
|
style={{ minHeight: '50vh' }}
|
||||||
|
items={tabItems}
|
||||||
|
defaultActiveKey={!plugin.isCompatible ? 'dependencies' : undefined}
|
||||||
|
></Tabs>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
75
packages/core/client/src/pm/PluginDocument.tsx
Normal file
75
packages/core/client/src/pm/PluginDocument.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { Spin } from 'antd';
|
||||||
|
import React, { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { useRequest } from '../api-client';
|
||||||
|
import { useStyles as useMarkdownStyles } from '../schema-component/antd/markdown/style';
|
||||||
|
import { useParseMarkdown } from '../schema-component/antd/markdown/util';
|
||||||
|
import { useStyles } from './style';
|
||||||
|
import { useGlobalTheme } from '../global-theme';
|
||||||
|
|
||||||
|
const PLUGIN_STATICS_PATH = '/static/plugins/';
|
||||||
|
|
||||||
|
interface PluginDocumentProps {
|
||||||
|
url: string;
|
||||||
|
packageName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PluginDocument: React.FC<PluginDocumentProps> = memo((props) => {
|
||||||
|
const { isDarkTheme } = useGlobalTheme();
|
||||||
|
const { componentCls, hashId } = useMarkdownStyles({ isDarkTheme });
|
||||||
|
const { styles } = useStyles();
|
||||||
|
const { url, packageName } = props;
|
||||||
|
const [docUrl, setDocUrl] = useState(url);
|
||||||
|
const { data, loading, error } = useRequest<string>(
|
||||||
|
{ url: docUrl, baseURL: '/' },
|
||||||
|
{
|
||||||
|
refreshDeps: [docUrl],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const { html, loading: parseLoading } = useParseMarkdown(data);
|
||||||
|
|
||||||
|
const htmlWithOutRelativeDirect = useMemo(() => {
|
||||||
|
if (html) {
|
||||||
|
let res = html;
|
||||||
|
const pattern = /<a\s+href="\..*?\/([^/]+)"/g;
|
||||||
|
res = res.replace(pattern, (match, $1) => match + `onclick="return false;"`); // prevent the default event of <a/>
|
||||||
|
|
||||||
|
// replace img src
|
||||||
|
res = res.replace(/src="(.*?)"/g, (match, src: string) => {
|
||||||
|
if (src.startsWith('http') || src.startsWith('//:')) return match;
|
||||||
|
return `src="${PLUGIN_STATICS_PATH}${packageName}/${src}"`;
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}, [html, packageName]);
|
||||||
|
|
||||||
|
const handleSwitchDocLang = useCallback((e: MouseEvent) => {
|
||||||
|
const url = (e.target as HTMLDivElement).getAttribute('href');
|
||||||
|
if (!url) return;
|
||||||
|
const parsedUrl = new URL(docUrl, window.location.origin);
|
||||||
|
const combinedUrl = new URL(url, parsedUrl);
|
||||||
|
setDocUrl(combinedUrl.pathname);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const md = document.getElementById('pm-md-preview');
|
||||||
|
md.addEventListener('click', handleSwitchDocLang);
|
||||||
|
return () => {
|
||||||
|
removeEventListener('click', handleSwitchDocLang);
|
||||||
|
};
|
||||||
|
}, [handleSwitchDocLang]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.PluginDocument} id="pm-md-preview">
|
||||||
|
{loading || parseLoading ? (
|
||||||
|
<Spin />
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={`${componentCls} ${hashId} nb-markdown nb-markdown-default nb-markdown-table`}
|
||||||
|
dangerouslySetInnerHTML={{ __html: error ? '' : htmlWithOutRelativeDirect }}
|
||||||
|
></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
164
packages/core/client/src/pm/PluginForm/form/PluginNpmForm.tsx
Normal file
164
packages/core/client/src/pm/PluginForm/form/PluginNpmForm.tsx
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import { ISchema } from '@formily/json-schema';
|
||||||
|
import { useForm } from '@formily/react';
|
||||||
|
import { uid } from '@formily/shared';
|
||||||
|
import { App } from 'antd';
|
||||||
|
import React, { FC, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { pick } from 'lodash';
|
||||||
|
import { useAPIClient, useRequest } from '../../../api-client';
|
||||||
|
import { SchemaComponent } from '../../../schema-component';
|
||||||
|
import { IPluginData } from '../../types';
|
||||||
|
|
||||||
|
interface IPluginNpmFormProps {
|
||||||
|
onClose: (refresh?: boolean) => void;
|
||||||
|
isUpgrade: boolean;
|
||||||
|
pluginData?: IPluginData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PluginNpmForm: FC<IPluginNpmFormProps> = ({ onClose, isUpgrade, pluginData }) => {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
// const { data, loading } = useRequest<{ data: string[] }>(
|
||||||
|
// {
|
||||||
|
// url: `/pm:npmVersionList/${pluginData?.name}`,
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// refreshDeps: [pluginData?.name],
|
||||||
|
// ready: !!pluginData?.name,
|
||||||
|
// },
|
||||||
|
// );
|
||||||
|
|
||||||
|
const versionList = [];
|
||||||
|
// useMemo(() => {
|
||||||
|
// return data?.data.map((item) => ({ label: item, value: item })) || [];
|
||||||
|
// }, [data?.data]);
|
||||||
|
|
||||||
|
const useSaveValues = () => {
|
||||||
|
const api = useAPIClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const form = useForm();
|
||||||
|
|
||||||
|
return {
|
||||||
|
async run() {
|
||||||
|
await form.submit();
|
||||||
|
api.request({
|
||||||
|
url: isUpgrade ? 'pm:update' : 'pm:add',
|
||||||
|
method: 'post',
|
||||||
|
data: {
|
||||||
|
...form.values,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
onClose(true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const useValuesFromProps = (options) => {
|
||||||
|
return useRequest(
|
||||||
|
() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: isUpgrade ? pick(pluginData, ['name', 'packageName', 'version']) : {},
|
||||||
|
}),
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useCancel = () => {
|
||||||
|
return {
|
||||||
|
run() {
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const schema = useMemo<ISchema>(() => {
|
||||||
|
const id = uid();
|
||||||
|
const schema = {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
[id]: {
|
||||||
|
'x-decorator': 'Form',
|
||||||
|
'x-component': 'div',
|
||||||
|
type: 'void',
|
||||||
|
'x-decorator-props': {
|
||||||
|
useValues: '{{ useValuesFromProps }}',
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
packageName: {
|
||||||
|
type: 'string',
|
||||||
|
title: "{{t('Npm package name')}}",
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Input',
|
||||||
|
required: true,
|
||||||
|
'x-component-props': {
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
registry: {
|
||||||
|
type: 'string',
|
||||||
|
title: "{{t('Registry url')}}",
|
||||||
|
default: 'https://registry.npmjs.org/',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Input',
|
||||||
|
// required: true,
|
||||||
|
'x-decorator-props': {
|
||||||
|
tooltip: 'Example: https://registry.npmjs.org/',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authToken: {
|
||||||
|
type: 'string',
|
||||||
|
title: "{{t('Npm authToken')}}",
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Input',
|
||||||
|
},
|
||||||
|
version: {
|
||||||
|
type: 'string',
|
||||||
|
title: "{{t('Version')}}",
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Input',
|
||||||
|
// enum: '{{versionList}}',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'ActionBar',
|
||||||
|
'x-component-props': {
|
||||||
|
layout: 'one-column',
|
||||||
|
style: {
|
||||||
|
justifyContent: 'right',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
cancel: {
|
||||||
|
title: 'Cancel',
|
||||||
|
'x-component': 'Action',
|
||||||
|
'x-component-props': {
|
||||||
|
useAction: '{{ useCancel }}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
title: '{{t("Submit")}}',
|
||||||
|
'x-component': 'Action',
|
||||||
|
'x-component-props': {
|
||||||
|
type: 'primary',
|
||||||
|
htmlType: 'submit',
|
||||||
|
useAction: '{{ useSaveValues }}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
schema.properties[id].properties.packageName['x-component-props'].disabled = isUpgrade;
|
||||||
|
if (!isUpgrade) {
|
||||||
|
delete schema.properties[id].properties['version'];
|
||||||
|
}
|
||||||
|
return schema;
|
||||||
|
}, [isUpgrade]);
|
||||||
|
// if (loading) {
|
||||||
|
// return <Spin />;
|
||||||
|
// }
|
||||||
|
return <SchemaComponent scope={{ useCancel, useSaveValues, versionList, useValuesFromProps }} schema={schema} />;
|
||||||
|
};
|
||||||
|
PluginNpmForm.displayName = 'PluginNpmForm';
|
123
packages/core/client/src/pm/PluginForm/form/PluginUploadForm.tsx
Normal file
123
packages/core/client/src/pm/PluginForm/form/PluginUploadForm.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { ISchema } from '@formily/json-schema';
|
||||||
|
import { useForm } from '@formily/react';
|
||||||
|
import { uid } from '@formily/shared';
|
||||||
|
import { App } from 'antd';
|
||||||
|
import type { RcFile } from 'antd/es/upload';
|
||||||
|
import React, { FC, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useAPIClient } from '../../../api-client';
|
||||||
|
import { SchemaComponent } from '../../../schema-component';
|
||||||
|
import { IPluginData } from '../../types';
|
||||||
|
|
||||||
|
interface IPluginUploadFormProps {
|
||||||
|
onClose: (refresh?: boolean) => void;
|
||||||
|
isUpgrade: boolean;
|
||||||
|
pluginData?: IPluginData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PluginUploadForm: FC<IPluginUploadFormProps> = ({ onClose, pluginData, isUpgrade }) => {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const useSaveValues = () => {
|
||||||
|
const api = useAPIClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const form = useForm();
|
||||||
|
|
||||||
|
return {
|
||||||
|
async run() {
|
||||||
|
await form.submit();
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', form.values.uploadFile?.[0]?.originFileObj);
|
||||||
|
if (pluginData?.packageName) {
|
||||||
|
formData.append('packageName', pluginData.packageName);
|
||||||
|
}
|
||||||
|
api.request({
|
||||||
|
url: `pm:${isUpgrade ? 'update' : 'add'}`,
|
||||||
|
method: 'post',
|
||||||
|
data: formData,
|
||||||
|
});
|
||||||
|
onClose(true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const useCancel = () => {
|
||||||
|
return {
|
||||||
|
run() {
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const schema = useMemo<ISchema>(() => {
|
||||||
|
return {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
[uid()]: {
|
||||||
|
'x-decorator': 'Form',
|
||||||
|
'x-component': 'div',
|
||||||
|
type: 'void',
|
||||||
|
properties: {
|
||||||
|
uploadFile: {
|
||||||
|
type: 'string',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Upload.Dragger',
|
||||||
|
required: true,
|
||||||
|
'x-component-props': {
|
||||||
|
action: '',
|
||||||
|
multiple: false,
|
||||||
|
maxCount: 1,
|
||||||
|
height: '150px',
|
||||||
|
tipContent: `{{t('Drag and drop the file here or click to upload, file size should not exceed 30M')}}`,
|
||||||
|
beforeUpload: (file: RcFile) => {
|
||||||
|
const compressedFileRegex = /\.(zip|rar|tar|gz|bz2|tgz)$/;
|
||||||
|
const isCompressedFile = compressedFileRegex.test(file.name);
|
||||||
|
if (!isCompressedFile) {
|
||||||
|
message.error('File only support zip, rar, tar, gz, bz2!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileSizeLimit = file.size / 1024 / 1024 < 30;
|
||||||
|
if (!fileSizeLimit) {
|
||||||
|
message.error('File must smaller than 30MB!');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
return isCompressedFile && fileSizeLimit;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'ActionBar',
|
||||||
|
'x-component-props': {
|
||||||
|
layout: 'one-column',
|
||||||
|
style: {
|
||||||
|
justifyContent: 'right',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
cancel: {
|
||||||
|
title: 'Cancel',
|
||||||
|
'x-component': 'Action',
|
||||||
|
'x-component-props': {
|
||||||
|
useAction: '{{ useCancel }}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
title: '{{t("Submit")}}',
|
||||||
|
'x-component': 'Action',
|
||||||
|
'x-component-props': {
|
||||||
|
type: 'primary',
|
||||||
|
htmlType: 'submit',
|
||||||
|
useAction: '{{ useSaveValues }}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [message]);
|
||||||
|
|
||||||
|
return <SchemaComponent scope={{ useCancel, useSaveValues }} schema={schema} />;
|
||||||
|
};
|
117
packages/core/client/src/pm/PluginForm/form/PluginUrlForm.tsx
Normal file
117
packages/core/client/src/pm/PluginForm/form/PluginUrlForm.tsx
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { ISchema } from '@formily/json-schema';
|
||||||
|
import { useForm } from '@formily/react';
|
||||||
|
import { uid } from '@formily/shared';
|
||||||
|
import { App } from 'antd';
|
||||||
|
import React, { FC, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useAPIClient, useRequest } from '../../../api-client';
|
||||||
|
import { SchemaComponent } from '../../../schema-component';
|
||||||
|
import { IPluginData } from '../../types';
|
||||||
|
|
||||||
|
interface IPluginUrlFormProps {
|
||||||
|
onClose: (refresh?: boolean) => void;
|
||||||
|
isUpgrade?: boolean;
|
||||||
|
pluginData?: IPluginData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PluginUrlForm: FC<IPluginUrlFormProps> = ({ onClose, pluginData, isUpgrade }) => {
|
||||||
|
const { message } = App.useApp();
|
||||||
|
const useSaveValues = () => {
|
||||||
|
const api = useAPIClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const form = useForm();
|
||||||
|
|
||||||
|
return {
|
||||||
|
async run() {
|
||||||
|
const compressedFileUrl = form.values.compressedFileUrl;
|
||||||
|
if (!compressedFileUrl) return;
|
||||||
|
await form.submit();
|
||||||
|
const data = {
|
||||||
|
compressedFileUrl,
|
||||||
|
};
|
||||||
|
if (pluginData?.packageName) {
|
||||||
|
data['packageName'] = pluginData.packageName;
|
||||||
|
}
|
||||||
|
api.request({
|
||||||
|
url: `pm:${isUpgrade ? 'update' : 'add'}`,
|
||||||
|
method: 'post',
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
onClose(true);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const useValuesFromProps = (options) => {
|
||||||
|
return useRequest(
|
||||||
|
() =>
|
||||||
|
Promise.resolve({
|
||||||
|
data: { compressedFileUrl: pluginData.compressedFileUrl },
|
||||||
|
}),
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useCancel = () => {
|
||||||
|
return {
|
||||||
|
run() {
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const schema = useMemo<ISchema>(() => {
|
||||||
|
return {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
[uid()]: {
|
||||||
|
'x-decorator': 'Form',
|
||||||
|
'x-component': 'div',
|
||||||
|
type: 'void',
|
||||||
|
'x-decorator-props': {
|
||||||
|
useValues: '{{ useValuesFromProps }}',
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
compressedFileUrl: {
|
||||||
|
type: 'string',
|
||||||
|
'x-decorator': 'FormItem',
|
||||||
|
'x-component': 'Input',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
type: 'void',
|
||||||
|
'x-component': 'ActionBar',
|
||||||
|
'x-component-props': {
|
||||||
|
layout: 'one-column',
|
||||||
|
style: {
|
||||||
|
justifyContent: 'right',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
properties: {
|
||||||
|
cancel: {
|
||||||
|
title: 'Cancel',
|
||||||
|
'x-component': 'Action',
|
||||||
|
'x-component-props': {
|
||||||
|
useAction: '{{ useCancel }}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
submit: {
|
||||||
|
title: '{{t("Submit")}}',
|
||||||
|
'x-component': 'Action',
|
||||||
|
'x-component-props': {
|
||||||
|
type: 'primary',
|
||||||
|
htmlType: 'submit',
|
||||||
|
useAction: '{{ useSaveValues }}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return <SchemaComponent scope={{ useCancel, useValuesFromProps, useSaveValues }} schema={schema} />;
|
||||||
|
};
|
@ -0,0 +1,35 @@
|
|||||||
|
import { Modal, Radio } from 'antd';
|
||||||
|
import React, { FC, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useStyles } from '../../style';
|
||||||
|
|
||||||
|
import { PluginNpmForm } from '../form/PluginNpmForm';
|
||||||
|
import { PluginUploadForm } from '../form/PluginUploadForm';
|
||||||
|
import { PluginUrlForm } from '../form/PluginUrlForm';
|
||||||
|
|
||||||
|
interface IPluginFormProps {
|
||||||
|
onClose: (refresh?: boolean) => void;
|
||||||
|
isShow: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PluginAddModal: FC<IPluginFormProps> = ({ onClose, isShow }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { theme } = useStyles();
|
||||||
|
const [type, setType] = useState<'npm' | 'upload' | 'url'>('npm');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onCancel={() => onClose()} footer={null} destroyOnClose title={t('Add plugin')} width={580} open={isShow}>
|
||||||
|
{/* <label style={{ fontWeight: 'bold' }}>{t('Source')}:</label> */}
|
||||||
|
<div style={{ marginTop: theme.marginLG, marginBottom: theme.marginLG }}>
|
||||||
|
<Radio.Group optionType="button" defaultValue={type} onChange={(e) => setType(e.target.value)}>
|
||||||
|
<Radio value="npm">{t('Npm package')}</Radio>
|
||||||
|
<Radio value="upload">{t('Upload plugin')}</Radio>
|
||||||
|
<Radio value="url">{t('Compressed file url')}</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
{type === 'npm' && <PluginNpmForm onClose={onClose} isUpgrade={false} />}
|
||||||
|
{type === 'upload' && <PluginUploadForm onClose={onClose} isUpgrade={false} />}
|
||||||
|
{type === 'url' && <PluginUrlForm onClose={onClose} />}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1,38 @@
|
|||||||
|
import { Modal, Radio } from 'antd';
|
||||||
|
import React, { FC, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useStyles } from '../../style';
|
||||||
|
import { IPluginData } from '../../types';
|
||||||
|
import { PluginNpmForm } from '../form/PluginNpmForm';
|
||||||
|
import { PluginUploadForm } from '../form/PluginUploadForm';
|
||||||
|
import { PluginUrlForm } from '../form/PluginUrlForm';
|
||||||
|
|
||||||
|
interface IPluginUpgradeModalProps {
|
||||||
|
onClose: (refresh?: boolean) => void;
|
||||||
|
isShow: boolean;
|
||||||
|
pluginData: IPluginData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PluginUpgradeModal: FC<IPluginUpgradeModalProps> = ({ onClose, isShow, pluginData }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { theme } = useStyles();
|
||||||
|
|
||||||
|
const [type, setType] = useState<'npm' | 'upload' | 'url'>('npm');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal onCancel={() => onClose()} footer={null} destroyOnClose title={t('Update plugin')} width={580} open={isShow}>
|
||||||
|
{/* <label style={{ fontWeight: 'bold' }}>{t('Source')}:</label> */}
|
||||||
|
<div style={{ marginTop: theme.marginLG, marginBottom: theme.marginLG }}>
|
||||||
|
<Radio.Group optionType="button" defaultValue={type} onChange={(e) => setType(e.target.value)}>
|
||||||
|
<Radio value="npm">{t('Npm package')}</Radio>
|
||||||
|
<Radio value="upload">{t('Upload plugin')}</Radio>
|
||||||
|
<Radio value="url">{t('Compressed file url')}</Radio>
|
||||||
|
</Radio.Group>
|
||||||
|
</div>
|
||||||
|
{type === 'npm' && <PluginNpmForm isUpgrade onClose={onClose} pluginData={pluginData} />}
|
||||||
|
{type === 'upload' && <PluginUploadForm isUpgrade onClose={onClose} pluginData={pluginData} />}
|
||||||
|
{type === 'url' && <PluginUrlForm isUpgrade onClose={onClose} pluginData={pluginData} />}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
222
packages/core/client/src/pm/PluginManager.tsx
Normal file
222
packages/core/client/src/pm/PluginManager.tsx
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
export * from './PluginManagerLink';
|
||||||
|
import { PageHeader } from '@ant-design/pro-layout';
|
||||||
|
import { useDebounce } from 'ahooks';
|
||||||
|
import { Button, Divider, Input, Result, Space, Spin, Tabs } from 'antd';
|
||||||
|
import _ from 'lodash';
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { useACLRoleContext } from '../acl/ACLProvider';
|
||||||
|
import { useRequest } from '../api-client';
|
||||||
|
import { useToken } from '../style';
|
||||||
|
import { PluginCard } from './PluginCard';
|
||||||
|
import { PluginAddModal } from './PluginForm/modal/PluginAddModal';
|
||||||
|
import { useStyles } from './style';
|
||||||
|
import { IPluginData } from './types';
|
||||||
|
|
||||||
|
export interface TData {
|
||||||
|
data: IPluginData[];
|
||||||
|
meta: IMetaData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IMetaData {
|
||||||
|
count: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPage: number;
|
||||||
|
allowedActions: AllowedActions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AllowedActions {
|
||||||
|
view: number[];
|
||||||
|
update: number[];
|
||||||
|
destroy: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const LocalPlugins = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { theme } = useStyles();
|
||||||
|
const { data, loading, refresh } = useRequest<TData>({
|
||||||
|
url: 'pm:list',
|
||||||
|
});
|
||||||
|
const filterList = useMemo(() => {
|
||||||
|
let list = data?.data || [];
|
||||||
|
list = list.reverse();
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
type: 'All',
|
||||||
|
list: list,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Built-in',
|
||||||
|
list: _.filter(list, (item) => item.builtIn),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Enabled',
|
||||||
|
list: _.filter(list, (item) => item.enabled),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Not enabled',
|
||||||
|
list: _.filter(list, (item) => !item.enabled),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'Problematic',
|
||||||
|
list: _.filter(list, (item) => !item.isCompatible),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [data?.data]);
|
||||||
|
|
||||||
|
const [filterIndex, setFilterIndex] = useState(0);
|
||||||
|
const [isShowAddForm, setShowAddForm] = useState(false);
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
const debouncedSearchValue = useDebounce(searchValue, { wait: 100 });
|
||||||
|
|
||||||
|
const pluginList = useMemo(() => {
|
||||||
|
let list = filterList[filterIndex]?.list || [];
|
||||||
|
if (debouncedSearchValue) {
|
||||||
|
list = _.filter(
|
||||||
|
list,
|
||||||
|
(item) =>
|
||||||
|
item.name?.includes(debouncedSearchValue) ||
|
||||||
|
item.description?.includes(debouncedSearchValue) ||
|
||||||
|
item.displayName?.includes(debouncedSearchValue) ||
|
||||||
|
item.packageName?.includes(debouncedSearchValue),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}, [filterIndex, filterList, debouncedSearchValue]);
|
||||||
|
|
||||||
|
const handleSearch = (value: string) => {
|
||||||
|
setSearchValue(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <Spin />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PluginAddModal
|
||||||
|
isShow={isShowAddForm}
|
||||||
|
onClose={(isRefresh) => {
|
||||||
|
setShowAddForm(false);
|
||||||
|
// if (isRefresh) refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ width: '100%' }}>
|
||||||
|
<div
|
||||||
|
style={{ marginBottom: theme.marginLG }}
|
||||||
|
className={css`
|
||||||
|
justify-content: space-between;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<Space size={theme.marginXXS} split={<Divider type="vertical" />}>
|
||||||
|
{filterList.map((item, index) => (
|
||||||
|
<a
|
||||||
|
onClick={() => setFilterIndex(index)}
|
||||||
|
key={item.type}
|
||||||
|
style={{ fontWeight: filterIndex === index ? 'bold' : 'normal' }}
|
||||||
|
>
|
||||||
|
{t(item.type)}({item.list?.length})
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
<Input
|
||||||
|
allowClear
|
||||||
|
placeholder={t('Search plugin')}
|
||||||
|
onChange={(e) => handleSearch(e.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Space>
|
||||||
|
<Button onClick={() => setShowAddForm(true)} type="primary">
|
||||||
|
{t('Add new')}
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={css`
|
||||||
|
--grid-gutter: ${theme.margin}px;
|
||||||
|
--extensions-card-width: 350px;
|
||||||
|
display: grid;
|
||||||
|
grid-column-gap: var(--grid-gutter);
|
||||||
|
grid-row-gap: var(--grid-gutter);
|
||||||
|
grid-template-columns: repeat(auto-fill, var(--extensions-card-width));
|
||||||
|
justify-content: center;
|
||||||
|
margin: auto;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{pluginList.map((item) => (
|
||||||
|
<PluginCard key={item.name} data={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const MarketplacePlugins = () => {
|
||||||
|
const { token } = useToken();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return <div style={{ fontSize: token.fontSizeXL, color: token.colorText }}>{t('Coming soon...')}</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PluginManager = () => {
|
||||||
|
const params = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { tabName = 'local' } = params;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { snippets = [] } = useACLRoleContext();
|
||||||
|
const { styles } = useStyles();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const { tabName } = params;
|
||||||
|
if (!tabName) {
|
||||||
|
navigate(`/admin/pm/list/local/`, { replace: true });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return snippets.includes('pm') ? (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
className={styles.pageHeader}
|
||||||
|
ghost={false}
|
||||||
|
title={t('Plugin manager')}
|
||||||
|
footer={
|
||||||
|
<Tabs
|
||||||
|
activeKey={tabName}
|
||||||
|
onChange={(activeKey) => {
|
||||||
|
navigate(`/admin/pm/list/${activeKey}`);
|
||||||
|
}}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
key: 'local',
|
||||||
|
label: t('Local'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'marketplace',
|
||||||
|
label: t('Marketplace'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className={styles.pageContent} style={{ display: 'flex', flexFlow: 'row wrap' }}>
|
||||||
|
{React.createElement(
|
||||||
|
{
|
||||||
|
local: LocalPlugins,
|
||||||
|
marketplace: MarketplacePlugins,
|
||||||
|
}[tabName],
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Result status="404" title="404" subTitle="Sorry, the page you visited does not exist." />
|
||||||
|
);
|
||||||
|
};
|
205
packages/core/client/src/pm/PluginSetting.tsx
Normal file
205
packages/core/client/src/pm/PluginSetting.tsx
Normal file
@ -0,0 +1,205 @@
|
|||||||
|
export * from './PluginManagerLink';
|
||||||
|
import { PageHeader } from '@ant-design/pro-layout';
|
||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { Layout, Menu, Result, Tabs } from 'antd';
|
||||||
|
import _, { sortBy } from 'lodash';
|
||||||
|
import React, { createContext, useContext, useMemo } from 'react';
|
||||||
|
import { Navigate, useNavigate, useParams } from 'react-router-dom';
|
||||||
|
import { useACLRoleContext } from '../acl/ACLProvider';
|
||||||
|
import { ACLPane } from '../acl/ACLShortcut';
|
||||||
|
import { CollectionManagerPane } from '../collection-manager';
|
||||||
|
import { Icon } from '../icon';
|
||||||
|
import { useCompile } from '../schema-component';
|
||||||
|
import { BlockTemplatesPane } from '../schema-templates';
|
||||||
|
import { SystemSettingsPane } from '../system-settings';
|
||||||
|
import { useStyles } from './style';
|
||||||
|
|
||||||
|
export const SettingsCenterContext = createContext<any>({});
|
||||||
|
|
||||||
|
export const settings = {
|
||||||
|
acl: {
|
||||||
|
title: '{{t("ACL")}}',
|
||||||
|
icon: 'LockOutlined',
|
||||||
|
tabs: {
|
||||||
|
roles: {
|
||||||
|
isBookmark: true,
|
||||||
|
title: '{{t("Roles & Permissions")}}',
|
||||||
|
component: () => <ACLPane />,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'ui-schema-storage': {
|
||||||
|
title: '{{t("Block templates")}}',
|
||||||
|
icon: 'LayoutOutlined',
|
||||||
|
tabs: {
|
||||||
|
'block-templates': {
|
||||||
|
title: '{{t("Block templates")}}',
|
||||||
|
component: BlockTemplatesPane,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'collection-manager': {
|
||||||
|
icon: 'DatabaseOutlined',
|
||||||
|
title: '{{t("Collection manager")}}',
|
||||||
|
tabs: {
|
||||||
|
collections: {
|
||||||
|
isBookmark: true,
|
||||||
|
title: '{{t("Collections & Fields")}}',
|
||||||
|
component: CollectionManagerPane,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'system-settings': {
|
||||||
|
icon: 'SettingOutlined',
|
||||||
|
title: '{{t("System settings")}}',
|
||||||
|
tabs: {
|
||||||
|
'system-settings': {
|
||||||
|
isBookmark: true,
|
||||||
|
title: '{{t("System settings")}}',
|
||||||
|
component: SystemSettingsPane,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPluginsTabs = _.memoize((items, snippets) => {
|
||||||
|
const pluginsTabs = Object.keys(items).map((plugin) => {
|
||||||
|
const tabsObj = items[plugin].tabs;
|
||||||
|
const tabs = sortBy(
|
||||||
|
Object.keys(tabsObj).map((tab) => {
|
||||||
|
return {
|
||||||
|
key: tab,
|
||||||
|
...tabsObj[tab],
|
||||||
|
isAllow: snippets.includes('pm.*') && !snippets?.includes(`!pm.${plugin}.${tab}`),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
(o) => !o.isAllow,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
...items[plugin],
|
||||||
|
key: plugin,
|
||||||
|
tabs,
|
||||||
|
isAllow: !tabs.every((v) => !v.isAllow),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return sortBy(pluginsTabs, (o) => !o.isAllow);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const SettingsCenter = () => {
|
||||||
|
const { styles } = useStyles();
|
||||||
|
const { snippets = [] } = useACLRoleContext();
|
||||||
|
const params = useParams<any>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const items = useContext(SettingsCenterContext);
|
||||||
|
const pluginsTabs = getPluginsTabs(items, snippets);
|
||||||
|
const compile = useCompile();
|
||||||
|
const firstUri = useMemo(() => {
|
||||||
|
const pluginName = pluginsTabs[0].key;
|
||||||
|
const tabName = pluginsTabs[0].tabs[0].key;
|
||||||
|
return `/admin/settings/${pluginName}/${tabName}`;
|
||||||
|
}, [pluginsTabs]);
|
||||||
|
const { pluginName, tabName } = params;
|
||||||
|
const activePlugin = pluginsTabs.find((v) => v.key === pluginName);
|
||||||
|
const aclPluginTabCheck = activePlugin?.isAllow && activePlugin.tabs.find((v) => v.key === tabName)?.isAllow;
|
||||||
|
if (!pluginName) {
|
||||||
|
return <Navigate replace to={firstUri} />;
|
||||||
|
}
|
||||||
|
if (!items[pluginName]) {
|
||||||
|
return <Navigate replace to={firstUri} />;
|
||||||
|
}
|
||||||
|
if (!tabName) {
|
||||||
|
const firstTabName = Object.keys(items[pluginName]?.tabs).shift();
|
||||||
|
return <Navigate replace to={`/admin/settings/${pluginName}/${firstTabName}`} />;
|
||||||
|
}
|
||||||
|
const component = items[pluginName]?.tabs?.[tabName]?.component;
|
||||||
|
const plugin: any = pluginsTabs.find((v) => v.key === pluginName);
|
||||||
|
const menuItems: any = pluginsTabs
|
||||||
|
.filter((plugin) => plugin.isAllow)
|
||||||
|
.map((plugin) => {
|
||||||
|
return {
|
||||||
|
label: compile(plugin.title),
|
||||||
|
key: plugin.key,
|
||||||
|
icon: plugin.icon ? <Icon type={plugin.icon} /> : null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Layout>
|
||||||
|
<Layout.Sider
|
||||||
|
className={css`
|
||||||
|
height: 100%;
|
||||||
|
/* position: fixed;
|
||||||
|
padding-top: 46px; */
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
background: rgba(0, 0, 0, 0);
|
||||||
|
z-index: 100;
|
||||||
|
.ant-layout-sider-children {
|
||||||
|
top: 46px;
|
||||||
|
position: fixed;
|
||||||
|
width: 200px;
|
||||||
|
height: calc(100vh - 46px);
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
theme={'light'}
|
||||||
|
>
|
||||||
|
<Menu
|
||||||
|
selectedKeys={[pluginName]}
|
||||||
|
style={{ height: 'calc(100vh - 46px)', overflowY: 'auto', overflowX: 'hidden' }}
|
||||||
|
onClick={(e) => {
|
||||||
|
const item = items[e.key];
|
||||||
|
const tabKey = Object.keys(item.tabs).shift();
|
||||||
|
navigate(`/admin/settings/${e.key}/${tabKey}`);
|
||||||
|
}}
|
||||||
|
items={menuItems as any}
|
||||||
|
/>
|
||||||
|
</Layout.Sider>
|
||||||
|
<Layout.Content>
|
||||||
|
{aclPluginTabCheck && (
|
||||||
|
<PageHeader
|
||||||
|
className={styles.pageHeader}
|
||||||
|
ghost={false}
|
||||||
|
title={compile(items[pluginName]?.title)}
|
||||||
|
footer={
|
||||||
|
<Tabs
|
||||||
|
activeKey={tabName}
|
||||||
|
onChange={(activeKey) => {
|
||||||
|
navigate(`/admin/settings/${pluginName}/${activeKey}`);
|
||||||
|
}}
|
||||||
|
items={plugin.tabs?.map((tab) => {
|
||||||
|
if (!tab.isAllow) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: compile(tab?.title),
|
||||||
|
key: tab.key,
|
||||||
|
};
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className={styles.pageContent}>
|
||||||
|
{aclPluginTabCheck ? (
|
||||||
|
component && React.createElement(component)
|
||||||
|
) : (
|
||||||
|
<Result status="404" title="404" subTitle="Sorry, the page you visited does not exist." />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Layout.Content>
|
||||||
|
</Layout>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SettingsCenterProvider = (props) => {
|
||||||
|
const { settings = {} } = props;
|
||||||
|
const items = useContext(SettingsCenterContext);
|
||||||
|
return (
|
||||||
|
<SettingsCenterContext.Provider value={{ ...items, ...settings }}>{props.children}</SettingsCenterContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PMProvider = (props) => {
|
||||||
|
return <SettingsCenterProvider settings={settings}>{props.children}</SettingsCenterProvider>;
|
||||||
|
};
|
@ -1,367 +1,13 @@
|
|||||||
export * from './PluginManagerLink';
|
import React from 'react';
|
||||||
import { PageHeader } from '@ant-design/pro-layout';
|
|
||||||
import { css } from '@emotion/css';
|
|
||||||
import { Layout, Menu, Result, Spin, Tabs } from 'antd';
|
|
||||||
import _, { sortBy } from 'lodash';
|
|
||||||
import React, { createContext, useContext, useEffect, useMemo } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Navigate, useNavigate, useParams } from 'react-router-dom';
|
|
||||||
import { useACLRoleContext } from '../acl/ACLProvider';
|
|
||||||
import { ACLPane } from '../acl/ACLShortcut';
|
|
||||||
import { useRequest } from '../api-client';
|
|
||||||
import { Plugin } from '../application/Plugin';
|
import { Plugin } from '../application/Plugin';
|
||||||
import { CollectionManagerPane } from '../collection-manager';
|
|
||||||
import { Icon } from '../icon';
|
|
||||||
import { useCompile } from '../schema-component';
|
|
||||||
import { BlockTemplatesPane } from '../schema-templates';
|
|
||||||
import { useToken } from '../style';
|
|
||||||
import { SystemSettingsPane } from '../system-settings';
|
|
||||||
import { BuiltInPluginCard, PluginCard } from './Card';
|
|
||||||
import { PluginManagerLink, SettingsCenterDropdown } from './PluginManagerLink';
|
import { PluginManagerLink, SettingsCenterDropdown } from './PluginManagerLink';
|
||||||
import { useStyles } from './style';
|
import { PMProvider, SettingsCenter } from './PluginSetting';
|
||||||
|
import { PluginManager } from './PluginManager';
|
||||||
|
|
||||||
export interface TData {
|
export * from './PluginManagerLink';
|
||||||
data: IPluginData[];
|
export * from './PluginSetting';
|
||||||
meta: IMetaData;
|
export * from './PluginManager';
|
||||||
}
|
|
||||||
|
|
||||||
export interface IPluginData {
|
|
||||||
id: number;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
name: string;
|
|
||||||
displayName: string;
|
|
||||||
version: string;
|
|
||||||
enabled: boolean;
|
|
||||||
installed: boolean;
|
|
||||||
builtIn: boolean;
|
|
||||||
options: Record<string, unknown>;
|
|
||||||
description?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IMetaData {
|
|
||||||
count: number;
|
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
totalPage: number;
|
|
||||||
allowedActions: AllowedActions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AllowedActions {
|
|
||||||
view: number[];
|
|
||||||
update: number[];
|
|
||||||
destroy: number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: refactor card/built-int card
|
|
||||||
|
|
||||||
export const SettingsCenterContext = createContext<any>({});
|
|
||||||
|
|
||||||
const LocalPlugins = () => {
|
|
||||||
// TODO: useRequest types for data ts type
|
|
||||||
const { data, loading } = useRequest<TData>({
|
|
||||||
url: 'applicationPlugins:list',
|
|
||||||
params: {
|
|
||||||
filter: {
|
|
||||||
'builtIn.$isFalsy': true,
|
|
||||||
},
|
|
||||||
sort: 'name',
|
|
||||||
paginate: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (loading) {
|
|
||||||
return <Spin />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{data?.data?.map((item) => {
|
|
||||||
const { id } = item;
|
|
||||||
return <PluginCard data={item} key={id} />;
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const BuiltinPlugins = () => {
|
|
||||||
const { data, loading } = useRequest<{
|
|
||||||
data: IPluginData[];
|
|
||||||
meta: IMetaData;
|
|
||||||
}>({
|
|
||||||
url: 'applicationPlugins:list',
|
|
||||||
params: {
|
|
||||||
filter: {
|
|
||||||
'builtIn.$isTruly': true,
|
|
||||||
},
|
|
||||||
sort: 'name',
|
|
||||||
paginate: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (loading) {
|
|
||||||
return <Spin />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{data?.data?.map((item) => {
|
|
||||||
const { id } = item;
|
|
||||||
return <BuiltInPluginCard data={item} key={id} />;
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const MarketplacePlugins = () => {
|
|
||||||
const { token } = useToken();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return <div style={{ fontSize: token.fontSizeXL, color: token.colorText }}>{t('Coming soon...')}</div>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PluginList = () => {
|
|
||||||
const params = useParams<any>();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { tabName = 'local' } = params;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { snippets = [] } = useACLRoleContext();
|
|
||||||
const { styles } = useStyles();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const { tabName } = params;
|
|
||||||
if (!tabName) {
|
|
||||||
navigate(`/admin/pm/list/local/`, { replace: true });
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return snippets.includes('pm') ? (
|
|
||||||
<div>
|
|
||||||
<PageHeader
|
|
||||||
className={styles.pageHeader}
|
|
||||||
ghost={false}
|
|
||||||
title={t('Plugin manager')}
|
|
||||||
footer={
|
|
||||||
<Tabs
|
|
||||||
activeKey={tabName}
|
|
||||||
onChange={(activeKey) => {
|
|
||||||
navigate(`/admin/pm/list/${activeKey}`);
|
|
||||||
}}
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
key: 'local',
|
|
||||||
label: t('Local'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'built-in',
|
|
||||||
label: t('Built-in'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'marketplace',
|
|
||||||
label: t('Marketplace'),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className={styles.pageContent} style={{ display: 'flex', flexFlow: 'row wrap' }}>
|
|
||||||
{React.createElement(
|
|
||||||
{
|
|
||||||
local: LocalPlugins,
|
|
||||||
'built-in': BuiltinPlugins,
|
|
||||||
marketplace: MarketplacePlugins,
|
|
||||||
}[tabName],
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<Result status="404" title="404" subTitle="Sorry, the page you visited does not exist." />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const settings = {
|
|
||||||
acl: {
|
|
||||||
title: '{{t("ACL")}}',
|
|
||||||
icon: 'LockOutlined',
|
|
||||||
tabs: {
|
|
||||||
roles: {
|
|
||||||
isBookmark: true,
|
|
||||||
title: '{{t("Roles & Permissions")}}',
|
|
||||||
component: () => <ACLPane />,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'ui-schema-storage': {
|
|
||||||
title: '{{t("Block templates")}}',
|
|
||||||
icon: 'LayoutOutlined',
|
|
||||||
tabs: {
|
|
||||||
'block-templates': {
|
|
||||||
title: '{{t("Block templates")}}',
|
|
||||||
component: BlockTemplatesPane,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'collection-manager': {
|
|
||||||
icon: 'DatabaseOutlined',
|
|
||||||
title: '{{t("Collection manager")}}',
|
|
||||||
tabs: {
|
|
||||||
collections: {
|
|
||||||
isBookmark: true,
|
|
||||||
title: '{{t("Collections & Fields")}}',
|
|
||||||
component: CollectionManagerPane,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'system-settings': {
|
|
||||||
icon: 'SettingOutlined',
|
|
||||||
title: '{{t("System settings")}}',
|
|
||||||
tabs: {
|
|
||||||
'system-settings': {
|
|
||||||
isBookmark: true,
|
|
||||||
title: '{{t("System settings")}}',
|
|
||||||
component: SystemSettingsPane,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getPluginsTabs = _.memoize((items, snippets) => {
|
|
||||||
const pluginsTabs = Object.keys(items).map((plugin) => {
|
|
||||||
const tabsObj = items[plugin].tabs;
|
|
||||||
const tabs = sortBy(
|
|
||||||
Object.keys(tabsObj).map((tab) => {
|
|
||||||
return {
|
|
||||||
key: tab,
|
|
||||||
...tabsObj[tab],
|
|
||||||
isAllow: snippets.includes('pm.*') && !snippets?.includes(`!pm.${plugin}.${tab}`),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
(o) => !o.isAllow,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...items[plugin],
|
|
||||||
key: plugin,
|
|
||||||
tabs,
|
|
||||||
isAllow: !tabs.every((v) => !v.isAllow),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return sortBy(pluginsTabs, (o) => !o.isAllow);
|
|
||||||
});
|
|
||||||
|
|
||||||
const SettingsCenter = () => {
|
|
||||||
const { styles } = useStyles();
|
|
||||||
const { snippets = [] } = useACLRoleContext();
|
|
||||||
const params = useParams<any>();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const items = useContext(SettingsCenterContext);
|
|
||||||
const pluginsTabs = getPluginsTabs(items, snippets);
|
|
||||||
const compile = useCompile();
|
|
||||||
const firstUri = useMemo(() => {
|
|
||||||
const pluginName = pluginsTabs[0].key;
|
|
||||||
const tabName = pluginsTabs[0].tabs[0].key;
|
|
||||||
return `/admin/settings/${pluginName}/${tabName}`;
|
|
||||||
}, [pluginsTabs]);
|
|
||||||
const { pluginName, tabName } = params;
|
|
||||||
const activePlugin = pluginsTabs.find((v) => v.key === pluginName);
|
|
||||||
const aclPluginTabCheck = activePlugin?.isAllow && activePlugin.tabs.find((v) => v.key === tabName)?.isAllow;
|
|
||||||
if (!pluginName) {
|
|
||||||
return <Navigate replace to={firstUri} />;
|
|
||||||
}
|
|
||||||
if (!items[pluginName]) {
|
|
||||||
return <Navigate replace to={firstUri} />;
|
|
||||||
}
|
|
||||||
if (!tabName) {
|
|
||||||
const firstTabName = Object.keys(items[pluginName]?.tabs).shift();
|
|
||||||
return <Navigate replace to={`/admin/settings/${pluginName}/${firstTabName}`} />;
|
|
||||||
}
|
|
||||||
const component = items[pluginName]?.tabs?.[tabName]?.component;
|
|
||||||
const plugin: any = pluginsTabs.find((v) => v.key === pluginName);
|
|
||||||
const menuItems: any = pluginsTabs
|
|
||||||
.filter((plugin) => plugin.isAllow)
|
|
||||||
.map((plugin) => {
|
|
||||||
return {
|
|
||||||
label: compile(plugin.title),
|
|
||||||
key: plugin.key,
|
|
||||||
icon: plugin.icon ? <Icon type={plugin.icon} /> : null,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Layout>
|
|
||||||
<Layout.Sider
|
|
||||||
className={css`
|
|
||||||
height: 100%;
|
|
||||||
/* position: fixed;
|
|
||||||
padding-top: 46px; */
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
background: rgba(0, 0, 0, 0);
|
|
||||||
z-index: 100;
|
|
||||||
.ant-layout-sider-children {
|
|
||||||
top: 46px;
|
|
||||||
position: fixed;
|
|
||||||
width: 200px;
|
|
||||||
height: calc(100vh - 46px);
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
theme={'light'}
|
|
||||||
>
|
|
||||||
<Menu
|
|
||||||
selectedKeys={[pluginName]}
|
|
||||||
style={{ height: 'calc(100vh - 46px)', overflowY: 'auto', overflowX: 'hidden' }}
|
|
||||||
onClick={(e) => {
|
|
||||||
const item = items[e.key];
|
|
||||||
const tabKey = Object.keys(item.tabs).shift();
|
|
||||||
navigate(`/admin/settings/${e.key}/${tabKey}`);
|
|
||||||
}}
|
|
||||||
items={menuItems as any}
|
|
||||||
/>
|
|
||||||
</Layout.Sider>
|
|
||||||
<Layout.Content>
|
|
||||||
{aclPluginTabCheck && (
|
|
||||||
<PageHeader
|
|
||||||
className={styles.pageHeader}
|
|
||||||
ghost={false}
|
|
||||||
title={compile(items[pluginName]?.title)}
|
|
||||||
footer={
|
|
||||||
<Tabs
|
|
||||||
activeKey={tabName}
|
|
||||||
onChange={(activeKey) => {
|
|
||||||
navigate(`/admin/settings/${pluginName}/${activeKey}`);
|
|
||||||
}}
|
|
||||||
items={plugin.tabs?.map((tab) => {
|
|
||||||
if (!tab.isAllow) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
label: compile(tab?.title),
|
|
||||||
key: tab.key,
|
|
||||||
};
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<div className={styles.pageContent}>
|
|
||||||
{aclPluginTabCheck ? (
|
|
||||||
component && React.createElement(component)
|
|
||||||
) : (
|
|
||||||
<Result status="404" title="404" subTitle="Sorry, the page you visited does not exist." />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Layout.Content>
|
|
||||||
</Layout>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const SettingsCenterProvider = (props) => {
|
|
||||||
const { settings = {} } = props;
|
|
||||||
const items = useContext(SettingsCenterContext);
|
|
||||||
return (
|
|
||||||
<SettingsCenterContext.Provider value={{ ...items, ...settings }}>{props.children}</SettingsCenterContext.Provider>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PMProvider = (props) => {
|
|
||||||
return <SettingsCenterProvider settings={settings}>{props.children}</SettingsCenterProvider>;
|
|
||||||
};
|
|
||||||
export class PMPlugin extends Plugin {
|
export class PMPlugin extends Plugin {
|
||||||
async load() {
|
async load() {
|
||||||
this.addComponents();
|
this.addComponents();
|
||||||
@ -379,15 +25,15 @@ export class PMPlugin extends Plugin {
|
|||||||
addRoutes() {
|
addRoutes() {
|
||||||
this.app.router.add('admin.pm.list', {
|
this.app.router.add('admin.pm.list', {
|
||||||
path: '/admin/pm/list',
|
path: '/admin/pm/list',
|
||||||
element: <PluginList />,
|
element: <PluginManager />,
|
||||||
});
|
});
|
||||||
this.app.router.add('admin.pm.list-tab', {
|
this.app.router.add('admin.pm.list-tab', {
|
||||||
path: '/admin/pm/list/:tabName',
|
path: '/admin/pm/list/:tabName',
|
||||||
element: <PluginList />,
|
element: <PluginManager />,
|
||||||
});
|
});
|
||||||
this.app.router.add('admin.pm.list-tab-mdfile', {
|
this.app.router.add('admin.pm.list-tab-mdfile', {
|
||||||
path: '/admin/pm/list/:tabName/:mdfile',
|
path: '/admin/pm/list/:tabName/:mdfile',
|
||||||
element: <PluginList />,
|
element: <PluginManager />,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.app.router.add('admin.settings.list', {
|
this.app.router.add('admin.settings.list', {
|
||||||
|
@ -2,9 +2,13 @@ import { createStyles } from 'antd-style';
|
|||||||
|
|
||||||
export const useStyles = createStyles(({ token }) => {
|
export const useStyles = createStyles(({ token }) => {
|
||||||
return {
|
return {
|
||||||
|
cardActionDisabled: {
|
||||||
|
color: token.colorTextDisabled,
|
||||||
|
cursor: 'not-allowed',
|
||||||
|
},
|
||||||
pageHeader: {
|
pageHeader: {
|
||||||
backgroundColor: token.colorBgContainer,
|
|
||||||
paddingBottom: 0,
|
paddingBottom: 0,
|
||||||
|
backgroundColor: token.colorBgContainer,
|
||||||
paddingTop: token.paddingSM,
|
paddingTop: token.paddingSM,
|
||||||
paddingInline: token.paddingLG,
|
paddingInline: token.paddingLG,
|
||||||
'.ant-page-header-footer': { marginBlockStart: '0' },
|
'.ant-page-header-footer': { marginBlockStart: '0' },
|
||||||
@ -15,22 +19,20 @@ export const useStyles = createStyles(({ token }) => {
|
|||||||
color: token.colorText,
|
color: token.colorText,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
pageContent: {
|
pageContent: {
|
||||||
margin: token.marginLG,
|
margin: token.marginLG,
|
||||||
},
|
},
|
||||||
|
// pageContent: {
|
||||||
|
// marginTop: token.margin,
|
||||||
|
// marginBottom: token.marginLG,
|
||||||
|
// background: 'transparent',
|
||||||
|
// minHeight: '80vh',
|
||||||
|
// },
|
||||||
|
|
||||||
PluginDetail: {
|
PluginDetailBaseInfo: {
|
||||||
'.ant-modal-header': { paddingBottom: token.paddingXS },
|
display: 'flex',
|
||||||
'.ant-modal-body': { paddingTop: 0 },
|
flexDirection: 'column',
|
||||||
'.ant-modal-content': {
|
marginBottom: token.margin,
|
||||||
'.plugin-desc': { paddingBottom: token.paddingXS },
|
|
||||||
},
|
|
||||||
'.version-tag': {
|
|
||||||
verticalAlign: 'middle',
|
|
||||||
marginTop: -token.marginXXS,
|
|
||||||
marginLeft: token.marginXS,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
|
||||||
PluginDocument: {
|
PluginDocument: {
|
||||||
@ -40,13 +42,6 @@ export const useStyles = createStyles(({ token }) => {
|
|||||||
overflowY: 'auto',
|
overflowY: 'auto',
|
||||||
},
|
},
|
||||||
|
|
||||||
CommonCard: {
|
|
||||||
width: `calc(20% - ${token.marginLG}px)`,
|
|
||||||
marginRight: token.marginLG,
|
|
||||||
marginBottom: token.marginLG,
|
|
||||||
transition: 'all 0.35s ease-in-out',
|
|
||||||
},
|
|
||||||
|
|
||||||
avatar: {
|
avatar: {
|
||||||
'.ant-card-meta-avatar': {
|
'.ant-card-meta-avatar': {
|
||||||
marginTop: '8px',
|
marginTop: '8px',
|
||||||
|
23
packages/core/client/src/pm/types.ts
Normal file
23
packages/core/client/src/pm/types.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export interface IPluginData {
|
||||||
|
id: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
name: string;
|
||||||
|
displayName: string;
|
||||||
|
packageName: string;
|
||||||
|
version: string;
|
||||||
|
enabled: boolean;
|
||||||
|
installed: boolean;
|
||||||
|
builtIn: boolean;
|
||||||
|
registry?: string;
|
||||||
|
authToken?: string;
|
||||||
|
compressedFileUrl?: string;
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
description?: string;
|
||||||
|
type: 'npm' | 'upload' | 'url';
|
||||||
|
isCompatible?: boolean;
|
||||||
|
readmeUrl: string;
|
||||||
|
changelogUrl: string;
|
||||||
|
error: boolean;
|
||||||
|
updatable?: boolean;
|
||||||
|
}
|
@ -1,7 +1,8 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import { Layout } from 'antd';
|
import { useSessionStorageState } from 'ahooks';
|
||||||
|
import { App, Layout } from 'antd';
|
||||||
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Outlet, useMatch, useNavigate, useParams } from 'react-router-dom';
|
import { Link, Outlet, useMatch, useNavigate, useParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
ACLRolesCheckProvider,
|
ACLRolesCheckProvider,
|
||||||
CurrentAppInfoProvider,
|
CurrentAppInfoProvider,
|
||||||
@ -60,6 +61,8 @@ const useMenuProps = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MenuEditor = (props) => {
|
const MenuEditor = (props) => {
|
||||||
|
const { notification } = App.useApp();
|
||||||
|
const [hasNotice, setHasNotice] = useSessionStorageState('plugin-notice', { defaultValue: false });
|
||||||
const { setTitle } = useDocumentTitle();
|
const { setTitle } = useDocumentTitle();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const params = useParams<any>();
|
const params = useParams<any>();
|
||||||
@ -140,6 +143,45 @@ const MenuEditor = (props) => {
|
|||||||
}
|
}
|
||||||
return s;
|
return s;
|
||||||
}, [data?.data]);
|
}, [data?.data]);
|
||||||
|
|
||||||
|
useRequest(
|
||||||
|
{
|
||||||
|
url: 'applicationPlugins:list',
|
||||||
|
params: {
|
||||||
|
sort: 'id',
|
||||||
|
paginate: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: ({ data }) => {
|
||||||
|
setHasNotice(true);
|
||||||
|
const errorPlugins = data.filter((item) => !item.isCompatible);
|
||||||
|
if (errorPlugins.length) {
|
||||||
|
notification.error({
|
||||||
|
message: 'Plugin dependencies check failed',
|
||||||
|
description: (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
These plugins failed dependency checks. Please go to the{' '}
|
||||||
|
<Link to="/admin/pm/list/local/">plugin management page</Link> for more details.{' '}
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{errorPlugins.map((item) => (
|
||||||
|
<li key={item.id}>
|
||||||
|
{item.displayName} - {item.packageName}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
manual: true,
|
||||||
|
// ready: !hasNotice,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return render();
|
return render();
|
||||||
}
|
}
|
||||||
|
2
packages/core/create-nocobase-app/templates/app/storage/plugins/.gitignore
vendored
Normal file
2
packages/core/create-nocobase-app/templates/app/storage/plugins/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
*
|
||||||
|
!.gitignore
|
@ -21,7 +21,7 @@
|
|||||||
"packages/app/*/src"
|
"packages/app/*/src"
|
||||||
],
|
],
|
||||||
"@{{{name}}}/plugin-*": [
|
"@{{{name}}}/plugin-*": [
|
||||||
"packages/plugins/*/src"
|
"packages/plugins/plugin-*/src"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -46,7 +46,8 @@
|
|||||||
"ts-node-dev": "1.1.8",
|
"ts-node-dev": "1.1.8",
|
||||||
"tsconfig-paths": "^3.12.0",
|
"tsconfig-paths": "^3.12.0",
|
||||||
"typescript": "5.1.3",
|
"typescript": "5.1.3",
|
||||||
"umi": "^4.0.69"
|
"umi": "^4.0.69",
|
||||||
|
"fast-glob": "^3.3.1"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
@ -2,7 +2,7 @@ const { existsSync } = require('fs');
|
|||||||
const { resolve, sep } = require('path');
|
const { resolve, sep } = require('path');
|
||||||
const packageJson = require('./package.json');
|
const packageJson = require('./package.json');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const glob = require('glob');
|
const glob = require('fast-glob');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
console.log('VERSION: ', packageJson.version);
|
console.log('VERSION: ', packageJson.version);
|
||||||
@ -11,7 +11,8 @@ function getUmiConfig() {
|
|||||||
const { APP_PORT, API_BASE_URL } = process.env;
|
const { APP_PORT, API_BASE_URL } = process.env;
|
||||||
const API_BASE_PATH = process.env.API_BASE_PATH || '/api/';
|
const API_BASE_PATH = process.env.API_BASE_PATH || '/api/';
|
||||||
const PROXY_TARGET_URL = process.env.PROXY_TARGET_URL || `http://127.0.0.1:${APP_PORT}`;
|
const PROXY_TARGET_URL = process.env.PROXY_TARGET_URL || `http://127.0.0.1:${APP_PORT}`;
|
||||||
const LOCAL_STORAGE_BASE_URL = process.env.LOCAL_STORAGE_BASE_URL || '/storage/uploads/';
|
const LOCAL_STORAGE_BASE_URL = '/storage/uploads/';
|
||||||
|
const STATIC_PATH = '/static/';
|
||||||
|
|
||||||
function getLocalStorageProxy() {
|
function getLocalStorageProxy() {
|
||||||
if (LOCAL_STORAGE_BASE_URL.startsWith('http')) {
|
if (LOCAL_STORAGE_BASE_URL.startsWith('http')) {
|
||||||
@ -23,6 +24,10 @@ function getUmiConfig() {
|
|||||||
target: PROXY_TARGET_URL,
|
target: PROXY_TARGET_URL,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
[STATIC_PATH]: {
|
||||||
|
target: PROXY_TARGET_URL,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -68,24 +73,25 @@ function getPackagePaths() {
|
|||||||
const pkgs = [];
|
const pkgs = [];
|
||||||
for (const key in paths) {
|
for (const key in paths) {
|
||||||
if (Object.hasOwnProperty.call(paths, key)) {
|
if (Object.hasOwnProperty.call(paths, key)) {
|
||||||
const dir = paths[key][0];
|
for (let dir of paths[key]) {
|
||||||
if (dir.includes('*')) {
|
if (dir.includes('*')) {
|
||||||
const files = glob.sync(dir);
|
const files = glob.sync(dir, { cwd: process.cwd(), onlyDirectories: true });
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const dirname = resolve(process.cwd(), file);
|
const dirname = resolve(process.cwd(), file);
|
||||||
if (existsSync(dirname)) {
|
if (existsSync(dirname)) {
|
||||||
const re = new RegExp(dir.replace('*', '(.+)'));
|
const re = new RegExp(dir.replace('*', '(.+)'));
|
||||||
const p = dirname
|
const p = dirname
|
||||||
.substring(process.cwd().length + 1)
|
.substring(process.cwd().length + 1)
|
||||||
.split(sep)
|
.split(sep)
|
||||||
.join('/');
|
.join('/');
|
||||||
const match = re.exec(p);
|
const match = re.exec(p);
|
||||||
pkgs.push([key.replace('*', match?.[1]), dirname]);
|
pkgs.push([key.replace('*', match?.[1]), dirname]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
const dirname = resolve(process.cwd(), dir);
|
||||||
|
pkgs.push([key, dirname]);
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
const dirname = resolve(process.cwd(), dir);
|
|
||||||
pkgs.push([key, dirname]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -157,13 +163,13 @@ export default function devDynamicImport(packageName: string): Promise<any> {
|
|||||||
fs.rmdirSync(this.outputPath, { recursive: true, force: true });
|
fs.rmdirSync(this.outputPath, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
fs.mkdirSync(this.outputPath);
|
fs.mkdirSync(this.outputPath);
|
||||||
const validPluginPaths = this.pluginsPath.filter((pluginPath) => fs.existsSync(pluginPath));
|
const validPluginPaths = this.pluginsPath.filter((pluginsPath) => fs.existsSync(pluginsPath));
|
||||||
if (!validPluginPaths.length || process.env.NODE_ENV === 'production') {
|
if (!validPluginPaths.length || process.env.NODE_ENV === 'production') {
|
||||||
fs.writeFileSync(this.indexPath, this.emptyIndexContent);
|
fs.writeFileSync(this.indexPath, this.emptyIndexContent);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pluginInfos = validPluginPaths.map((pluginPath) => this.getContent(pluginPath)).flat();
|
const pluginInfos = validPluginPaths.map((pluginsPath) => this.getContent(pluginsPath)).flat();
|
||||||
|
|
||||||
// index.ts
|
// index.ts
|
||||||
fs.writeFileSync(this.indexPath, this.indexContent);
|
fs.writeFileSync(this.indexPath, this.indexContent);
|
||||||
@ -181,21 +187,23 @@ export default function devDynamicImport(packageName: string): Promise<any> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getContent(pluginPath) {
|
getContent(pluginsPath) {
|
||||||
const pluginFolders = fs.readdirSync(pluginPath);
|
const pluginFolders = glob
|
||||||
|
.sync(['*/package.json', '*/*/package.json'], { cwd: pluginsPath, onlyFiles: true, absolute: true })
|
||||||
|
.map((item) => path.dirname(item));
|
||||||
const pluginInfos = pluginFolders
|
const pluginInfos = pluginFolders
|
||||||
.filter((folder) => {
|
.filter((folder) => {
|
||||||
const pluginPackageJsonPath = path.join(pluginPath, folder, 'package.json');
|
const pluginPackageJsonPath = path.join(folder, 'package.json');
|
||||||
const pluginSrcClientPath = path.join(pluginPath, folder, 'src', 'client');
|
const pluginSrcClientPath = path.join(folder, 'src', 'client');
|
||||||
return fs.existsSync(pluginPackageJsonPath) && fs.existsSync(pluginSrcClientPath);
|
return fs.existsSync(pluginPackageJsonPath) && fs.existsSync(pluginSrcClientPath);
|
||||||
})
|
})
|
||||||
.map((folder) => {
|
.map((folder) => {
|
||||||
const pluginPackageJsonPath = path.join(pluginPath, folder, 'package.json');
|
const pluginPackageJsonPath = path.join(folder, 'package.json');
|
||||||
const pluginPackageJson = require(pluginPackageJsonPath);
|
const pluginPackageJson = require(pluginPackageJsonPath);
|
||||||
const pluginSrcClientPath = path
|
const pluginSrcClientPath = path
|
||||||
.relative(this.packagesPath, path.join(pluginPath, folder, 'src', 'client'))
|
.relative(this.packagesPath, path.join(folder, 'src', 'client'))
|
||||||
.replaceAll('\\', '/');
|
.replaceAll('\\', '/');
|
||||||
const pluginFileName = `${path.basename(pluginPath)}_${folder.replaceAll('-', '_')}`;
|
const pluginFileName = `${path.basename(pluginsPath)}_${path.basename(folder).replaceAll('-', '_')}`;
|
||||||
const exportStatement = `export { default } from '${pluginSrcClientPath}';`;
|
const exportStatement = `export { default } from '${pluginSrcClientPath}';`;
|
||||||
return { exportStatement, pluginFileName, packageJsonName: pluginPackageJson.name };
|
return { exportStatement, pluginFileName, packageJsonName: pluginPackageJson.name };
|
||||||
});
|
});
|
||||||
|
@ -37,6 +37,11 @@
|
|||||||
"multer": "^1.4.2",
|
"multer": "^1.4.2",
|
||||||
"nanoid": "3.3.4",
|
"nanoid": "3.3.4",
|
||||||
"semver": "^7.3.7",
|
"semver": "^7.3.7",
|
||||||
|
"ini": "^4.1.1",
|
||||||
|
"@types/ini": "^1.3.31",
|
||||||
|
"decompress": "4.2.1",
|
||||||
|
"@types/decompress": "4.2.4",
|
||||||
|
"fs-extra": "^11.1.1",
|
||||||
"serve-handler": "^6.1.5",
|
"serve-handler": "^6.1.5",
|
||||||
"ws": "^8.13.0",
|
"ws": "^8.13.0",
|
||||||
"xpipe": "^1.0.5"
|
"xpipe": "^1.0.5"
|
||||||
|
@ -1,31 +0,0 @@
|
|||||||
import Application from '../application';
|
|
||||||
import Plugin from '../plugin';
|
|
||||||
|
|
||||||
class TestPlugin extends Plugin {}
|
|
||||||
|
|
||||||
describe('upgrade test', () => {
|
|
||||||
let app: Application;
|
|
||||||
beforeEach(async () => {
|
|
||||||
app = new Application({
|
|
||||||
database: {
|
|
||||||
dialect: 'sqlite',
|
|
||||||
dialectModule: require('sqlite3'),
|
|
||||||
storage: ':memory:',
|
|
||||||
logging: false,
|
|
||||||
},
|
|
||||||
resourcer: {
|
|
||||||
prefix: '/api',
|
|
||||||
},
|
|
||||||
acl: false,
|
|
||||||
dataWrapping: false,
|
|
||||||
registerActions: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
app.plugin(TestPlugin, { name: 'test-plugin' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should call upgrade', async () => {
|
|
||||||
await app.upgrade();
|
|
||||||
console.log('1231');
|
|
||||||
});
|
|
||||||
});
|
|
@ -263,7 +263,9 @@ export class AppSupervisor extends EventEmitter implements AsyncEmitter {
|
|||||||
const { maintainingStatus } = options;
|
const { maintainingStatus } = options;
|
||||||
if (
|
if (
|
||||||
maintainingStatus &&
|
maintainingStatus &&
|
||||||
['install', 'upgrade', 'pm.enable', 'pm.disable'].includes(maintainingStatus.command.name)
|
['install', 'upgrade', 'pm.add', 'pm.update', 'pm.enable', 'pm.disable', 'pm.remove'].includes(
|
||||||
|
maintainingStatus.command.name,
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
this.setAppStatus(app.name, 'running', {
|
this.setAppStatus(app.name, 'running', {
|
||||||
refresh: true,
|
refresh: true,
|
||||||
|
@ -4,7 +4,7 @@ import { actions as authActions, AuthManager } from '@nocobase/auth';
|
|||||||
import { Cache, createCache, ICacheConfig } from '@nocobase/cache';
|
import { Cache, createCache, ICacheConfig } from '@nocobase/cache';
|
||||||
import Database, { CollectionOptions, IDatabaseOptions } from '@nocobase/database';
|
import Database, { CollectionOptions, IDatabaseOptions } from '@nocobase/database';
|
||||||
import { AppLoggerOptions, createAppLogger, Logger } from '@nocobase/logger';
|
import { AppLoggerOptions, createAppLogger, Logger } from '@nocobase/logger';
|
||||||
import { Resourcer, ResourceOptions } from '@nocobase/resourcer';
|
import { ResourceOptions, Resourcer } from '@nocobase/resourcer';
|
||||||
import { applyMixins, AsyncEmitter, Toposort, ToposortOptions } from '@nocobase/utils';
|
import { applyMixins, AsyncEmitter, Toposort, ToposortOptions } from '@nocobase/utils';
|
||||||
import chalk from 'chalk';
|
import chalk from 'chalk';
|
||||||
import { Command, CommandOptions, ParseOptions } from 'commander';
|
import { Command, CommandOptions, ParseOptions } from 'commander';
|
||||||
@ -117,6 +117,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
private _maintaining = false;
|
private _maintaining = false;
|
||||||
private _maintainingCommandStatus: MaintainingCommandStatus;
|
private _maintainingCommandStatus: MaintainingCommandStatus;
|
||||||
private _maintainingStatusBeforeCommand: MaintainingCommandStatus | null;
|
private _maintainingStatusBeforeCommand: MaintainingCommandStatus | null;
|
||||||
|
private _actionCommand: Command;
|
||||||
|
|
||||||
constructor(public options: ApplicationOptions) {
|
constructor(public options: ApplicationOptions) {
|
||||||
super();
|
super();
|
||||||
@ -303,7 +304,9 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
this.log.info(`app.reload()`);
|
this.log.info(`app.reload()`);
|
||||||
const oldDb = this._db;
|
const oldDb = this._db;
|
||||||
this.init();
|
this.init();
|
||||||
await oldDb.close();
|
if (!oldDb.closed()) {
|
||||||
|
await oldDb.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setMaintainingMessage('init plugins');
|
this.setMaintainingMessage('init plugins');
|
||||||
@ -363,6 +366,8 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
const command = new Command('nocobase')
|
const command = new Command('nocobase')
|
||||||
.usage('[command] [options]')
|
.usage('[command] [options]')
|
||||||
.hook('preAction', async (_, actionCommand) => {
|
.hook('preAction', async (_, actionCommand) => {
|
||||||
|
this._actionCommand = actionCommand;
|
||||||
|
|
||||||
this.activatedCommand = {
|
this.activatedCommand = {
|
||||||
name: getCommandFullName(actionCommand),
|
name: getCommandFullName(actionCommand),
|
||||||
};
|
};
|
||||||
@ -426,7 +431,13 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
const _actionCommand = this._actionCommand;
|
||||||
|
if (_actionCommand) {
|
||||||
|
_actionCommand['_optionValues'] = {};
|
||||||
|
_actionCommand['_optionValueSources'] = {};
|
||||||
|
}
|
||||||
this.activatedCommand = null;
|
this.activatedCommand = null;
|
||||||
|
this._actionCommand = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -454,11 +465,15 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
|
|
||||||
this.setMaintainingMessage('emit afterStart');
|
this.setMaintainingMessage('emit afterStart');
|
||||||
await this.emitAsync('afterStart', this, options);
|
await this.emitAsync('afterStart', this, options);
|
||||||
|
await this.emitStartedEvent();
|
||||||
|
|
||||||
|
this.stopped = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async emitStartedEvent() {
|
||||||
await this.emitAsync('__started', this, {
|
await this.emitAsync('__started', this, {
|
||||||
maintainingStatus: lodash.cloneDeep(this._maintainingCommandStatus),
|
maintainingStatus: lodash.cloneDeep(this._maintainingCommandStatus),
|
||||||
});
|
});
|
||||||
|
|
||||||
this.stopped = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async isStarted() {
|
async isStarted() {
|
||||||
@ -477,7 +492,9 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
if (!this._started) {
|
if (!this._started) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this._started = false;
|
this._started = false;
|
||||||
|
await this.emitAsync('beforeStop');
|
||||||
await this.reload(options);
|
await this.reload(options);
|
||||||
await this.start(options);
|
await this.start(options);
|
||||||
this.emit('__restarted', this, options);
|
this.emit('__restarted', this, options);
|
||||||
@ -505,6 +522,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
|
|||||||
}
|
}
|
||||||
|
|
||||||
await this.emitAsync('afterStop', this, options);
|
await this.emitAsync('afterStop', this, options);
|
||||||
|
|
||||||
this.stopped = true;
|
this.stopped = true;
|
||||||
this.log.info(`${this.name} is stopped`);
|
this.log.info(`${this.name} is stopped`);
|
||||||
this._started = false;
|
this._started = false;
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
import Application from '../application';
|
import Application from '../application';
|
||||||
import { PluginCommandError } from '../errors/plugin-command-error';
|
import { PluginCommandError } from '../errors/plugin-command-error';
|
||||||
|
|
||||||
@ -11,9 +12,35 @@ export default (app: Application) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
pm.command('add')
|
pm.command('add')
|
||||||
.arguments('plugin')
|
.argument('<pkg>')
|
||||||
.action(async (plugin) => {
|
.option('--registry [registry]')
|
||||||
await app.pm.add(plugin, {}, true);
|
.option('--auth-token [authToken]')
|
||||||
|
.option('--version [version]')
|
||||||
|
.action(async (name, options, cli) => {
|
||||||
|
console.log('pm.add', name, options);
|
||||||
|
try {
|
||||||
|
await app.pm.addViaCLI(name, _.cloneDeep(options));
|
||||||
|
} catch (error) {
|
||||||
|
throw new PluginCommandError(`Failed to add plugin: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
pm.command('update')
|
||||||
|
.argument('<packageName>')
|
||||||
|
.option('--path [path]')
|
||||||
|
.option('--url [url]')
|
||||||
|
.option('--registry [registry]')
|
||||||
|
.option('--auth-token [authToken]')
|
||||||
|
.option('--version [version]')
|
||||||
|
.action(async (packageName, options) => {
|
||||||
|
try {
|
||||||
|
await app.pm.update({
|
||||||
|
...options,
|
||||||
|
packageName,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
throw new PluginCommandError(`Failed to update plugin: ${error.message}`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
pm.command('enable')
|
pm.command('enable')
|
||||||
|
@ -1,81 +0,0 @@
|
|||||||
import { IncomingMessage, ServerResponse } from 'http';
|
|
||||||
import path from 'path';
|
|
||||||
import fs from 'fs';
|
|
||||||
|
|
||||||
const cwd = process.cwd();
|
|
||||||
const NODE_MODULES = path.join(cwd, 'node_modules');
|
|
||||||
|
|
||||||
const PREFIX = '/api/plugins/client/';
|
|
||||||
|
|
||||||
const isMatchClientStaticUrl = (url: string) => {
|
|
||||||
return url.startsWith(PREFIX);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* get package name from url
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* /api/plugins/client/@nocobase/plugin-acl/index.js => @nocobase/plugin-acl
|
|
||||||
* /api/plugins/client/my-plugin/README.md => my-plugin
|
|
||||||
*/
|
|
||||||
const getPackageName = (url: string) => {
|
|
||||||
const urlArr = url.split('/');
|
|
||||||
return urlArr[4].startsWith('@') ? `${urlArr[4]}/${urlArr[5]}` : urlArr[4];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* get plugin client static file real path
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* /api/plugins/client/@nocobase/plugin-acl/index.js => /node_modules/@nocobase/plugin-acl/dist/client/index.js
|
|
||||||
* /api/plugins/client/my-plugin/README.md => /node_modules/my-plugin/dist/client/README.md
|
|
||||||
*/
|
|
||||||
const getRealPath = (packageName: string, url: string) => {
|
|
||||||
const ext = path.extname(url);
|
|
||||||
const filePath = url.replace(`${PREFIX}${packageName}/`, '');
|
|
||||||
if (ext.toLowerCase() === '.md') {
|
|
||||||
return path.join(NODE_MODULES, packageName, filePath);
|
|
||||||
} else {
|
|
||||||
return path.join(NODE_MODULES, packageName, 'dist', 'client', filePath);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function handlePluginStaticFile(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
||||||
if (isMatchClientStaticUrl(req.url)) {
|
|
||||||
// TODO: check packageName in plugins
|
|
||||||
const packageName = getPackageName(req.url);
|
|
||||||
|
|
||||||
const realPath = getRealPath(packageName, req.url);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// get file stats
|
|
||||||
const stats = await fs.promises.stat(realPath);
|
|
||||||
|
|
||||||
const ifModifiedSince = req.headers['if-modified-since'];
|
|
||||||
|
|
||||||
const lastModified = stats.mtime.toUTCString();
|
|
||||||
|
|
||||||
// check cache headers
|
|
||||||
if (ifModifiedSince === lastModified) {
|
|
||||||
res.statusCode = 304;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const relativePath = path.relative(cwd, realPath);
|
|
||||||
|
|
||||||
res.writeHead(200, {
|
|
||||||
'Content-Length': stats.size,
|
|
||||||
});
|
|
||||||
|
|
||||||
const readStream = fs.createReadStream(relativePath);
|
|
||||||
readStream.pipe(res);
|
|
||||||
} catch (e) {
|
|
||||||
res.writeHead(404);
|
|
||||||
res.end();
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
@ -1,6 +1,9 @@
|
|||||||
|
import { uid } from '@nocobase/utils';
|
||||||
|
import { createStoragePluginsSymlink } from '@nocobase/utils/plugin-symlink';
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
import compression from 'compression';
|
import compression from 'compression';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
|
import fs from 'fs';
|
||||||
import http, { IncomingMessage, ServerResponse } from 'http';
|
import http, { IncomingMessage, ServerResponse } from 'http';
|
||||||
import { promisify } from 'node:util';
|
import { promisify } from 'node:util';
|
||||||
import { resolve } from 'path';
|
import { resolve } from 'path';
|
||||||
@ -10,6 +13,7 @@ import { parse } from 'url';
|
|||||||
import xpipe from 'xpipe';
|
import xpipe from 'xpipe';
|
||||||
import { AppSupervisor } from '../app-supervisor';
|
import { AppSupervisor } from '../app-supervisor';
|
||||||
import { ApplicationOptions } from '../application';
|
import { ApplicationOptions } from '../application';
|
||||||
|
import { PLUGIN_STATICS_PATH, getPackageDirByExposeUrl, getPackageNameByExposeUrl } from '../plugin-manager';
|
||||||
import { applyErrorWithArgs, getErrorWithCode } from './errors';
|
import { applyErrorWithArgs, getErrorWithCode } from './errors';
|
||||||
import { IPCSocketClient } from './ipc-socket-client';
|
import { IPCSocketClient } from './ipc-socket-client';
|
||||||
import { IPCSocketServer } from './ipc-socket-server';
|
import { IPCSocketServer } from './ipc-socket-server';
|
||||||
@ -123,18 +127,21 @@ export class Gateway extends EventEmitter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.startsWith('/api/plugins/client/')) {
|
// pathname example: /static/plugins/@nocobase/plugins-acl/README.md
|
||||||
|
// protect server files
|
||||||
|
if (pathname.startsWith(PLUGIN_STATICS_PATH) && !pathname.includes('/server/')) {
|
||||||
await compress(req, res);
|
await compress(req, res);
|
||||||
|
const packageName = getPackageNameByExposeUrl(pathname);
|
||||||
|
// /static/plugins/@nocobase/plugins-acl/README.md => /User/projects/nocobase/plugins/acl
|
||||||
|
const publicDir = getPackageDirByExposeUrl(pathname);
|
||||||
|
// /static/plugins/@nocobase/plugins-acl/README.md => README.md
|
||||||
|
const destination = pathname.replace(PLUGIN_STATICS_PATH, '').replace(packageName, '');
|
||||||
return handler(req, res, {
|
return handler(req, res, {
|
||||||
public: resolve(process.cwd(), 'node_modules'),
|
public: publicDir,
|
||||||
rewrites: [
|
rewrites: [
|
||||||
{
|
{
|
||||||
source: '/api/plugins/client/:plugin/:file',
|
source: pathname,
|
||||||
destination: '/:plugin/dist/client/:file',
|
destination,
|
||||||
},
|
|
||||||
{
|
|
||||||
source: '/api/plugins/client/@:org/:plugin/:file',
|
|
||||||
destination: '/@:org/:plugin/dist/client/:file',
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@ -192,9 +199,23 @@ export class Gateway extends EventEmitter {
|
|||||||
return this.requestHandler.bind(this);
|
return this.requestHandler.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async watch() {
|
||||||
|
if (!process.env.IS_DEV_CMD) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const file = resolve(process.cwd(), 'storage/app.watch.ts');
|
||||||
|
if (!fs.existsSync(file)) {
|
||||||
|
await fs.promises.writeFile(file, `export const watchId = '${uid()}';`, 'utf-8');
|
||||||
|
}
|
||||||
|
require(file);
|
||||||
|
}
|
||||||
|
|
||||||
async run(options: RunOptions) {
|
async run(options: RunOptions) {
|
||||||
const isStart = this.isStart();
|
const isStart = this.isStart();
|
||||||
|
let ipcClient: IPCSocketClient | false;
|
||||||
if (isStart) {
|
if (isStart) {
|
||||||
|
await this.watch();
|
||||||
|
|
||||||
const startOptions = this.getStartOptions();
|
const startOptions = this.getStartOptions();
|
||||||
const port = startOptions.port || process.env.APP_PORT || 13000;
|
const port = startOptions.port || process.env.APP_PORT || 13000;
|
||||||
const host = startOptions.host || process.env.APP_HOST || '0.0.0.0';
|
const host = startOptions.host || process.env.APP_HOST || '0.0.0.0';
|
||||||
@ -204,7 +225,7 @@ export class Gateway extends EventEmitter {
|
|||||||
host,
|
host,
|
||||||
});
|
});
|
||||||
} else if (!this.isHelp()) {
|
} else if (!this.isHelp()) {
|
||||||
const ipcClient = await this.tryConnectToIPCServer();
|
ipcClient = await this.tryConnectToIPCServer();
|
||||||
|
|
||||||
if (ipcClient) {
|
if (ipcClient) {
|
||||||
await ipcClient.write({ type: 'passCliArgv', payload: { argv: process.argv } });
|
await ipcClient.write({ type: 'passCliArgv', payload: { argv: process.argv } });
|
||||||
@ -214,6 +235,10 @@ export class Gateway extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isStart || !ipcClient) {
|
||||||
|
await createStoragePluginsSymlink();
|
||||||
|
}
|
||||||
|
|
||||||
const mainApp = AppSupervisor.getInstance().bootMainApp(options.mainAppOptions);
|
const mainApp = AppSupervisor.getInstance().bootMainApp(options.mainAppOptions);
|
||||||
|
|
||||||
mainApp
|
mainApp
|
||||||
|
@ -1,9 +1,12 @@
|
|||||||
import cors from '@koa/cors';
|
import cors from '@koa/cors';
|
||||||
import Database from '@nocobase/database';
|
import Database from '@nocobase/database';
|
||||||
import { Resourcer } from '@nocobase/resourcer';
|
import { Resourcer } from '@nocobase/resourcer';
|
||||||
|
import { uid } from '@nocobase/utils';
|
||||||
import { Command } from 'commander';
|
import { Command } from 'commander';
|
||||||
|
import fs from 'fs';
|
||||||
import i18next from 'i18next';
|
import i18next from 'i18next';
|
||||||
import bodyParser from 'koa-bodyparser';
|
import bodyParser from 'koa-bodyparser';
|
||||||
|
import { resolve } from 'path';
|
||||||
import Application, { ApplicationOptions } from './application';
|
import Application, { ApplicationOptions } from './application';
|
||||||
import { parseVariables } from './middlewares';
|
import { parseVariables } from './middlewares';
|
||||||
import { dateTemplate } from './middlewares/data-template';
|
import { dateTemplate } from './middlewares/data-template';
|
||||||
@ -111,3 +114,8 @@ export const getCommandFullName = (command: Command) => {
|
|||||||
}
|
}
|
||||||
return names.join('.');
|
return names.join('.');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const tsxRerunning = async () => {
|
||||||
|
const file = resolve(process.cwd(), 'storage/app.watch.ts');
|
||||||
|
await fs.promises.writeFile(file, `export const watchId = '${uid()}';`, 'utf-8');
|
||||||
|
};
|
||||||
|
@ -0,0 +1,23 @@
|
|||||||
|
import { DataTypes } from '@nocobase/database';
|
||||||
|
import { Migration } from '../migration';
|
||||||
|
|
||||||
|
export default class extends Migration {
|
||||||
|
async up() {
|
||||||
|
const collection = this.db.getCollection('applicationPlugins');
|
||||||
|
if (!collection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tableNameWithSchema = collection.getTableNameWithSchema();
|
||||||
|
const field = collection.getField('packageName');
|
||||||
|
if (await field.existsInDb()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.db.sequelize.getQueryInterface().addColumn(tableNameWithSchema, 'packageName', {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
});
|
||||||
|
await this.db.sequelize.getQueryInterface().addConstraint(tableNameWithSchema, {
|
||||||
|
type: 'unique',
|
||||||
|
fields: ['packageName'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -1,12 +0,0 @@
|
|||||||
const PREFIX = '/api/plugins/client/';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* get plugin client static file url
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* @nocobase/plugin-acl, index.js => /api/plugins/client/@nocobase/plugin-acl/index.js
|
|
||||||
* my-plugin, README.md => /api/plugins/client/my-plugin/README.md
|
|
||||||
*/
|
|
||||||
export const getPackageClientStaticUrl = (packageName: string, filePath: string) => {
|
|
||||||
return `${PREFIX}${packageName}/${filePath}`;
|
|
||||||
};
|
|
76
packages/core/server/src/plugin-manager/clientStaticUtils.ts
Normal file
76
packages/core/server/src/plugin-manager/clientStaticUtils.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
|
||||||
|
export const PLUGIN_STATICS_PATH = '/static/plugins/';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get package.json path for specific NPM package
|
||||||
|
*/
|
||||||
|
export function getDepPkgPath(packageName: string, cwd?: string) {
|
||||||
|
try {
|
||||||
|
return require.resolve(`${packageName}/package.json`, { paths: cwd ? [cwd] : undefined });
|
||||||
|
} catch {
|
||||||
|
const mainFile = require.resolve(`${packageName}`, { paths: cwd ? [cwd] : undefined });
|
||||||
|
const packageDir = mainFile.slice(0, mainFile.indexOf(packageName.replace('/', path.sep)) + packageName.length);
|
||||||
|
return path.join(packageDir, 'package.json');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPackageDir(packageName: string) {
|
||||||
|
const packageJsonPath = getDepPkgPath(packageName);
|
||||||
|
return path.dirname(packageJsonPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPackageFilePath(packageName: string, filePath: string) {
|
||||||
|
const packageDir = getPackageDir(packageName);
|
||||||
|
return path.join(packageDir, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPackageFilePathWithExistCheck(packageName: string, filePath: string) {
|
||||||
|
const absolutePath = getPackageFilePath(packageName, filePath);
|
||||||
|
const exists = fs.existsSync(absolutePath);
|
||||||
|
return {
|
||||||
|
filePath: absolutePath,
|
||||||
|
exists,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExposeUrl(packageName: string, filePath: string) {
|
||||||
|
return `${PLUGIN_STATICS_PATH}${packageName}/${filePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExposeReadmeUrl(packageName: string, lang: string) {
|
||||||
|
let READMEPath = null;
|
||||||
|
if (getPackageFilePathWithExistCheck(packageName, `README.${lang}.md`).exists) {
|
||||||
|
READMEPath = `README.${lang}.md`;
|
||||||
|
} else if (getPackageFilePathWithExistCheck(packageName, 'README.md').exists) {
|
||||||
|
READMEPath = 'README.md';
|
||||||
|
}
|
||||||
|
|
||||||
|
return READMEPath ? getExposeUrl(packageName, READMEPath) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExposeChangelogUrl(packageName: string) {
|
||||||
|
const { exists } = getPackageFilePathWithExistCheck(packageName, 'CHANGELOG.md');
|
||||||
|
return exists ? getExposeUrl(packageName, 'CHANGELOG.md') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get package name by client static url
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* getPluginNameByClientStaticUrl('/static/plugins/dayjs/index.js') => 'dayjs'
|
||||||
|
* getPluginNameByClientStaticUrl('/static/plugins/@nocobase/foo/README.md') => '@nocobase/foo'
|
||||||
|
*/
|
||||||
|
export function getPackageNameByExposeUrl(pathname: string) {
|
||||||
|
pathname = pathname.replace(PLUGIN_STATICS_PATH, '');
|
||||||
|
const pathArr = pathname.split('/');
|
||||||
|
if (pathname.startsWith('@')) {
|
||||||
|
return pathArr.slice(0, 2).join('/');
|
||||||
|
}
|
||||||
|
return pathArr[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPackageDirByExposeUrl(pathname: string) {
|
||||||
|
return getPackageDir(getPackageNameByExposeUrl(pathname));
|
||||||
|
}
|
103
packages/core/server/src/plugin-manager/constants.ts
Normal file
103
packages/core/server/src/plugin-manager/constants.ts
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
export const APP_NAME = 'nocobase';
|
||||||
|
export const DEFAULT_PLUGIN_STORAGE_PATH = 'storage/plugins';
|
||||||
|
export const DEFAULT_PLUGIN_PATH = 'packages/plugins/';
|
||||||
|
export const pluginPrefix = (
|
||||||
|
process.env.PLUGIN_PACKAGE_PREFIX || '@nocobase/plugin-,@nocobase/preset-,@nocobase/plugin-pro-'
|
||||||
|
).split(',');
|
||||||
|
export const requireRegex = /require\s*\(['"`](.*?)['"`]\)/g;
|
||||||
|
export const importRegex = /^import(?:['"\s]*([\w*${}\s,]+)from\s*)?['"\s]['"\s](.*[@\w_-]+)['"\s].*/gm;
|
||||||
|
export const EXTERNAL = [
|
||||||
|
// nocobase
|
||||||
|
'@nocobase/acl',
|
||||||
|
'@nocobase/actions',
|
||||||
|
'@nocobase/auth',
|
||||||
|
'@nocobase/cache',
|
||||||
|
'@nocobase/client',
|
||||||
|
'@nocobase/database',
|
||||||
|
'@nocobase/evaluators',
|
||||||
|
'@nocobase/logger',
|
||||||
|
'@nocobase/resourcer',
|
||||||
|
'@nocobase/sdk',
|
||||||
|
'@nocobase/server',
|
||||||
|
'@nocobase/test',
|
||||||
|
'@nocobase/utils',
|
||||||
|
|
||||||
|
// @nocobase/auth
|
||||||
|
'jsonwebtoken',
|
||||||
|
|
||||||
|
// @nocobase/cache
|
||||||
|
'cache-manager',
|
||||||
|
|
||||||
|
// @nocobase/database
|
||||||
|
'sequelize',
|
||||||
|
'umzug',
|
||||||
|
'async-mutex',
|
||||||
|
|
||||||
|
// @nocobase/evaluators
|
||||||
|
'@formulajs/formulajs',
|
||||||
|
'mathjs',
|
||||||
|
|
||||||
|
// @nocobase/logger
|
||||||
|
'winston',
|
||||||
|
'winston-daily-rotate-file',
|
||||||
|
|
||||||
|
// koa
|
||||||
|
'koa',
|
||||||
|
'@koa/cors',
|
||||||
|
'@koa/router',
|
||||||
|
'multer',
|
||||||
|
'@koa/multer',
|
||||||
|
'koa-bodyparser',
|
||||||
|
'koa-static',
|
||||||
|
'koa-send',
|
||||||
|
|
||||||
|
// react
|
||||||
|
'react',
|
||||||
|
'react-dom',
|
||||||
|
'react/jsx-runtime',
|
||||||
|
|
||||||
|
// react-router
|
||||||
|
'react-router',
|
||||||
|
'react-router-dom',
|
||||||
|
|
||||||
|
// antd
|
||||||
|
'antd',
|
||||||
|
'antd-style',
|
||||||
|
'@ant-design/icons',
|
||||||
|
'@ant-design/cssinjs',
|
||||||
|
|
||||||
|
// i18next
|
||||||
|
'i18next',
|
||||||
|
'react-i18next',
|
||||||
|
|
||||||
|
// dnd-kit 相关
|
||||||
|
'@dnd-kit/accessibility',
|
||||||
|
'@dnd-kit/core',
|
||||||
|
'@dnd-kit/modifiers',
|
||||||
|
'@dnd-kit/sortable',
|
||||||
|
'@dnd-kit/utilities',
|
||||||
|
|
||||||
|
// formily 相关
|
||||||
|
'@formily/antd-v5',
|
||||||
|
'@formily/core',
|
||||||
|
'@formily/react',
|
||||||
|
'@formily/json-schema',
|
||||||
|
'@formily/path',
|
||||||
|
'@formily/validator',
|
||||||
|
'@formily/shared',
|
||||||
|
'@formily/reactive',
|
||||||
|
'@formily/reactive-react',
|
||||||
|
|
||||||
|
// utils
|
||||||
|
'dayjs',
|
||||||
|
'mysql2',
|
||||||
|
'pg',
|
||||||
|
'pg-hstore',
|
||||||
|
'sqlite3',
|
||||||
|
'supertest',
|
||||||
|
'axios',
|
||||||
|
'@emotion/css',
|
||||||
|
'ahooks',
|
||||||
|
'lodash',
|
||||||
|
'china-division',
|
||||||
|
];
|
55
packages/core/server/src/plugin-manager/deps.ts
Normal file
55
packages/core/server/src/plugin-manager/deps.ts
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
// @ts-ignore
|
||||||
|
import { version } from '../../package.json';
|
||||||
|
|
||||||
|
const deps: Record<string, string> = {
|
||||||
|
'@nocobase': `${version.split('.').slice(0, 2).join('.')}.x`, // 0.12.x
|
||||||
|
'@formily': '2.x',
|
||||||
|
|
||||||
|
'@formily/antd-v5': '1.x',
|
||||||
|
jsonwebtoken: '8.x',
|
||||||
|
'cache-manager': '4.x',
|
||||||
|
sequelize: '6.x',
|
||||||
|
umzug: '3.x',
|
||||||
|
'async-mutex': '0.3.x',
|
||||||
|
'@formulajs/formulajs': '4.x',
|
||||||
|
mathjs: '10.x',
|
||||||
|
winston: '3.x',
|
||||||
|
'winston-daily-rotate-file': '4.x',
|
||||||
|
koa: '2.x',
|
||||||
|
'@koa/cors': '3.x',
|
||||||
|
'@koa/router': '9.x',
|
||||||
|
multer: '1.x',
|
||||||
|
'@koa/multer': '3.x',
|
||||||
|
'koa-bodyparser': '4.x',
|
||||||
|
'koa-static': '5.x',
|
||||||
|
'koa-send': '5.x',
|
||||||
|
react: '18.x',
|
||||||
|
'react-dom': '18.x',
|
||||||
|
'react-router': '6.x',
|
||||||
|
'react-router-dom': '6.x',
|
||||||
|
antd: '5.x',
|
||||||
|
'antd-style': '3.x',
|
||||||
|
'@ant-design/icons': '5.x',
|
||||||
|
'@ant-design/cssinjs': '1.x',
|
||||||
|
i18next: '22.x',
|
||||||
|
'react-i18next': '11.x',
|
||||||
|
'@dnd-kit/accessibility': '3.x',
|
||||||
|
'@dnd-kit/core': '5.x',
|
||||||
|
'@dnd-kit/modifiers': '6.x',
|
||||||
|
'@dnd-kit/sortable': '6.x',
|
||||||
|
'@dnd-kit/utilities': '3.x',
|
||||||
|
dayjs: '1.x',
|
||||||
|
mysql2: '2.x',
|
||||||
|
pg: '8.x',
|
||||||
|
'pg-hstore': '2.x',
|
||||||
|
sqlite3: '5.x',
|
||||||
|
supertest: '6.x',
|
||||||
|
axios: '0.26.x',
|
||||||
|
'@emotion/css': '11.x',
|
||||||
|
ahooks: '3.x',
|
||||||
|
lodash: '4.x',
|
||||||
|
'china-division': '2.x',
|
||||||
|
cronstrue: '2.x',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default deps;
|
@ -1,2 +1,2 @@
|
|||||||
export * from './clientStaticMiddleware';
|
export * from './clientStaticUtils';
|
||||||
export * from './plugin-manager';
|
export * from './plugin-manager';
|
||||||
|
10
packages/core/server/src/plugin-manager/middleware.ts
Normal file
10
packages/core/server/src/plugin-manager/middleware.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Context, Next } from '@nocobase/actions';
|
||||||
|
import { koaMulter as multer } from '@nocobase/utils';
|
||||||
|
|
||||||
|
export async function uploadMiddleware(ctx: Context, next: Next) {
|
||||||
|
if (ctx.action.resourceName === 'pm' && ['add', 'update'].includes(ctx.action.actionName)) {
|
||||||
|
const upload = multer().single('file');
|
||||||
|
return upload(ctx, next);
|
||||||
|
}
|
||||||
|
return next();
|
||||||
|
}
|
@ -7,6 +7,7 @@ export default defineCollection({
|
|||||||
repository: 'PluginManagerRepository',
|
repository: 'PluginManagerRepository',
|
||||||
fields: [
|
fields: [
|
||||||
{ type: 'string', name: 'name', unique: true },
|
{ type: 'string', name: 'name', unique: true },
|
||||||
|
{ type: 'string', name: 'packageName', unique: true },
|
||||||
{ type: 'string', name: 'version' },
|
{ type: 'string', name: 'version' },
|
||||||
{ type: 'boolean', name: 'enabled' },
|
{ type: 'boolean', name: 'enabled' },
|
||||||
{ type: 'boolean', name: 'installed' },
|
{ type: 'boolean', name: 'installed' },
|
||||||
|
@ -1,20 +1,86 @@
|
|||||||
|
import { uid } from '@nocobase/utils';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
import Application from '../../application';
|
import Application from '../../application';
|
||||||
|
import { getExposeUrl } from '../clientStaticUtils';
|
||||||
|
import PluginManager from '../plugin-manager';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'pm',
|
name: 'pm',
|
||||||
actions: {
|
actions: {
|
||||||
async add(ctx, next) {
|
async add(ctx, next) {
|
||||||
const pm = ctx.app.pm;
|
const app = ctx.app as Application;
|
||||||
|
const { values = {} } = ctx.action.params;
|
||||||
|
if (values?.packageName) {
|
||||||
|
const args = [];
|
||||||
|
if (values.registry) {
|
||||||
|
args.push('--registry=' + values.registry);
|
||||||
|
}
|
||||||
|
if (values.version) {
|
||||||
|
args.push('--version=' + values.version);
|
||||||
|
}
|
||||||
|
if (values.authToken) {
|
||||||
|
args.push('--auth-token=' + values.authToken);
|
||||||
|
}
|
||||||
|
app.runAsCLI(['pm', 'add', values.packageName, ...args], { from: 'user' });
|
||||||
|
} else if (ctx.file) {
|
||||||
|
const tmpDir = path.resolve(process.cwd(), 'storage', 'tmp');
|
||||||
|
try {
|
||||||
|
await fs.promises.mkdir(tmpDir, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
// empty
|
||||||
|
}
|
||||||
|
const tempFile = path.join(process.cwd(), 'storage/tmp', uid() + path.extname(ctx.file.originalname));
|
||||||
|
await fs.promises.writeFile(tempFile, ctx.file.buffer, 'binary');
|
||||||
|
app.runAsCLI(['pm', 'add', tempFile], { from: 'user' });
|
||||||
|
} else if (values.compressedFileUrl) {
|
||||||
|
app.runAsCLI(['pm', 'add', values.compressedFileUrl], { from: 'user' });
|
||||||
|
}
|
||||||
|
ctx.body = 'ok';
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
async update(ctx, next) {
|
||||||
|
const app = ctx.app as Application;
|
||||||
|
const values = ctx.action.params.values || {};
|
||||||
|
const args = [];
|
||||||
|
if (values.registry) {
|
||||||
|
args.push('--registry=' + values.registry);
|
||||||
|
}
|
||||||
|
if (values.version) {
|
||||||
|
args.push('--version=' + values.version);
|
||||||
|
}
|
||||||
|
if (values.authToken) {
|
||||||
|
args.push('--auth-token=' + values.authToken);
|
||||||
|
}
|
||||||
|
if (values.compressedFileUrl) {
|
||||||
|
args.push('--url=' + values.compressedFileUrl);
|
||||||
|
}
|
||||||
|
if (ctx.file) {
|
||||||
|
values.packageName = ctx.request.body.packageName;
|
||||||
|
const tmpDir = path.resolve(process.cwd(), 'storage', 'tmp');
|
||||||
|
try {
|
||||||
|
await fs.promises.mkdir(tmpDir, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
// empty
|
||||||
|
}
|
||||||
|
const tempFile = path.join(process.cwd(), 'storage/tmp', uid() + path.extname(ctx.file.originalname));
|
||||||
|
await fs.promises.writeFile(tempFile, ctx.file.buffer, 'binary');
|
||||||
|
args.push(`--url=${tempFile}`);
|
||||||
|
}
|
||||||
|
app.runAsCLI(['pm', 'update', values.packageName, ...args], { from: 'user' });
|
||||||
|
ctx.body = 'ok';
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
async npmVersionList(ctx, next) {
|
||||||
const { filterByTk } = ctx.action.params;
|
const { filterByTk } = ctx.action.params;
|
||||||
if (!filterByTk) {
|
if (!filterByTk) {
|
||||||
ctx.throw(400, 'plugin name invalid');
|
ctx.throw(400, 'plugin name invalid');
|
||||||
}
|
}
|
||||||
await pm.add(filterByTk);
|
const pm = ctx.app.pm;
|
||||||
ctx.body = filterByTk;
|
ctx.body = await pm.getNpmVersionList(filterByTk);
|
||||||
await next();
|
await next();
|
||||||
},
|
},
|
||||||
async enable(ctx, next) {
|
async enable(ctx, next) {
|
||||||
const pm = ctx.app.pm;
|
|
||||||
const { filterByTk } = ctx.action.params;
|
const { filterByTk } = ctx.action.params;
|
||||||
const app = ctx.app as Application;
|
const app = ctx.app as Application;
|
||||||
if (!filterByTk) {
|
if (!filterByTk) {
|
||||||
@ -25,7 +91,6 @@ export default {
|
|||||||
await next();
|
await next();
|
||||||
},
|
},
|
||||||
async disable(ctx, next) {
|
async disable(ctx, next) {
|
||||||
const pm = ctx.app.pm;
|
|
||||||
const { filterByTk } = ctx.action.params;
|
const { filterByTk } = ctx.action.params;
|
||||||
if (!filterByTk) {
|
if (!filterByTk) {
|
||||||
ctx.throw(400, 'plugin name invalid');
|
ctx.throw(400, 'plugin name invalid');
|
||||||
@ -35,12 +100,7 @@ export default {
|
|||||||
ctx.body = filterByTk;
|
ctx.body = filterByTk;
|
||||||
await next();
|
await next();
|
||||||
},
|
},
|
||||||
async upgrade(ctx, next) {
|
|
||||||
ctx.body = 'ok';
|
|
||||||
await next();
|
|
||||||
},
|
|
||||||
async remove(ctx, next) {
|
async remove(ctx, next) {
|
||||||
const pm = ctx.app.pm;
|
|
||||||
const { filterByTk } = ctx.action.params;
|
const { filterByTk } = ctx.action.params;
|
||||||
if (!filterByTk) {
|
if (!filterByTk) {
|
||||||
ctx.throw(400, 'plugin name invalid');
|
ctx.throw(400, 'plugin name invalid');
|
||||||
@ -50,5 +110,45 @@ export default {
|
|||||||
ctx.body = filterByTk;
|
ctx.body = filterByTk;
|
||||||
await next();
|
await next();
|
||||||
},
|
},
|
||||||
|
async list(ctx, next) {
|
||||||
|
const locale = ctx.getCurrentLocale();
|
||||||
|
const pm = ctx.app.pm as PluginManager;
|
||||||
|
ctx.body = await pm.list({ locale, isPreset: false });
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
async listEnabled(ctx, next) {
|
||||||
|
const pm = ctx.db.getRepository('applicationPlugins');
|
||||||
|
const PLUGIN_CLIENT_ENTRY_FILE = 'dist/client/index.js';
|
||||||
|
const items = await pm.find({
|
||||||
|
filter: {
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
ctx.body = items
|
||||||
|
.map((item) => {
|
||||||
|
try {
|
||||||
|
const packageName = PluginManager.getPackageName(item.name);
|
||||||
|
return {
|
||||||
|
...item.toJSON(),
|
||||||
|
packageName,
|
||||||
|
url: getExposeUrl(packageName, PLUGIN_CLIENT_ENTRY_FILE),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
await next();
|
||||||
|
},
|
||||||
|
async get(ctx, next) {
|
||||||
|
const locale = ctx.getCurrentLocale();
|
||||||
|
const pm = ctx.app.pm as PluginManager;
|
||||||
|
const { filterByTk } = ctx.action.params;
|
||||||
|
if (!filterByTk) {
|
||||||
|
ctx.throw(400, 'plugin name invalid');
|
||||||
|
}
|
||||||
|
ctx.body = await pm.get(filterByTk).toJSON({ locale });
|
||||||
|
await next();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { Repository } from '@nocobase/database';
|
import { Repository } from '@nocobase/database';
|
||||||
import lodash from 'lodash';
|
import lodash from 'lodash';
|
||||||
import { PluginManager } from './plugin-manager';
|
import { PluginManager } from './plugin-manager';
|
||||||
|
import { PluginData } from './types';
|
||||||
|
|
||||||
export class PluginManagerRepository extends Repository {
|
export class PluginManagerRepository extends Repository {
|
||||||
pm: PluginManager;
|
pm: PluginManager;
|
||||||
@ -47,6 +48,15 @@ export class PluginManagerRepository extends Repository {
|
|||||||
return pluginNames;
|
return pluginNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async upgrade(name: string, data: PluginData) {
|
||||||
|
return this.update({
|
||||||
|
filter: {
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
values: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async disable(name: string | string[]) {
|
async disable(name: string | string[]) {
|
||||||
name = lodash.cloneDeep(name);
|
name = lodash.cloneDeep(name);
|
||||||
|
|
||||||
@ -74,6 +84,7 @@ export class PluginManagerRepository extends Repository {
|
|||||||
sort: 'id',
|
sort: 'id',
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
await this.database.migrator.up();
|
||||||
await this.collection.sync({
|
await this.collection.sync({
|
||||||
alter: {
|
alter: {
|
||||||
drop: false,
|
drop: false,
|
||||||
|
@ -1,15 +1,28 @@
|
|||||||
import { CleanOptions, Collection, SyncOptions } from '@nocobase/database';
|
import { CleanOptions, Collection, SyncOptions } from '@nocobase/database';
|
||||||
import { requireModule } from '@nocobase/utils';
|
import { isURL } from '@nocobase/utils';
|
||||||
|
import { fsExists } from '@nocobase/utils/plugin-symlink';
|
||||||
import execa from 'execa';
|
import execa from 'execa';
|
||||||
import _ from 'lodash';
|
import _ from 'lodash';
|
||||||
import net from 'net';
|
import net from 'net';
|
||||||
import { resolve } from 'path';
|
import { resolve, sep } from 'path';
|
||||||
import Application from '../application';
|
import Application from '../application';
|
||||||
import { createAppProxy } from '../helper';
|
import { createAppProxy, tsxRerunning } from '../helper';
|
||||||
import { Plugin } from '../plugin';
|
import { Plugin } from '../plugin';
|
||||||
|
import { uploadMiddleware } from './middleware';
|
||||||
import collectionOptions from './options/collection';
|
import collectionOptions from './options/collection';
|
||||||
import resourceOptions from './options/resource';
|
import resourceOptions from './options/resource';
|
||||||
import { PluginManagerRepository } from './plugin-manager-repository';
|
import { PluginManagerRepository } from './plugin-manager-repository';
|
||||||
|
import { PluginData } from './types';
|
||||||
|
import {
|
||||||
|
copyTempPackageToStorageAndLinkToNodeModules,
|
||||||
|
downloadAndUnzipToTempDir,
|
||||||
|
getNpmInfo,
|
||||||
|
getPluginInfoByNpm,
|
||||||
|
removeTmpDir,
|
||||||
|
requireModule,
|
||||||
|
requireNoCache,
|
||||||
|
updatePluginByCompressedFileUrl,
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
export interface PluginManagerOptions {
|
export interface PluginManagerOptions {
|
||||||
app: Application;
|
app: Application;
|
||||||
@ -43,31 +56,16 @@ export class PluginManager {
|
|||||||
this._repository = this.collection.repository as PluginManagerRepository;
|
this._repository = this.collection.repository as PluginManagerRepository;
|
||||||
this._repository.setPluginManager(this);
|
this._repository.setPluginManager(this);
|
||||||
this.app.resourcer.define(resourceOptions);
|
this.app.resourcer.define(resourceOptions);
|
||||||
|
this.app.acl.allow('pm', 'listEnabled', 'public');
|
||||||
this.app.resourcer.use(async (ctx, next) => {
|
|
||||||
await next();
|
|
||||||
const { resourceName, actionName } = ctx.action;
|
|
||||||
if (resourceName === 'applicationPlugins' && actionName === 'list') {
|
|
||||||
const lng = ctx.getCurrentLocale();
|
|
||||||
if (Array.isArray(ctx.body)) {
|
|
||||||
ctx.body = ctx.body.map((plugin) => {
|
|
||||||
const json = plugin.toJSON();
|
|
||||||
const packageName = PluginManager.getPackageName(json.name);
|
|
||||||
const packageJson = PluginManager.getPackageJson(packageName);
|
|
||||||
return {
|
|
||||||
displayName: packageJson[`displayName.${lng}`] || packageJson.displayName,
|
|
||||||
description: packageJson[`description.${lng}`] || packageJson.description,
|
|
||||||
...json,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.app.acl.registerSnippet({
|
this.app.acl.registerSnippet({
|
||||||
name: 'pm',
|
name: 'pm',
|
||||||
actions: ['pm:*', 'applicationPlugins:list'],
|
actions: ['pm:*'],
|
||||||
});
|
});
|
||||||
|
this.app.db.addMigrations({
|
||||||
|
namespace: 'core/pm',
|
||||||
|
directory: resolve(__dirname, '../migrations'),
|
||||||
|
});
|
||||||
|
this.app.resourcer.use(uploadMiddleware);
|
||||||
}
|
}
|
||||||
|
|
||||||
get repository() {
|
get repository() {
|
||||||
@ -75,7 +73,7 @@ export class PluginManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static getPackageJson(packageName: string) {
|
static getPackageJson(packageName: string) {
|
||||||
return require(`${packageName}/package.json`);
|
return requireNoCache(`${packageName}/package.json`);
|
||||||
}
|
}
|
||||||
|
|
||||||
static getPackageName(name: string) {
|
static getPackageName(name: string) {
|
||||||
@ -121,9 +119,19 @@ export class PluginManager {
|
|||||||
throw new Error(`No available packages found, ${name} plugin does not exist`);
|
throw new Error(`No available packages found, ${name} plugin does not exist`);
|
||||||
}
|
}
|
||||||
|
|
||||||
static resolvePlugin(pluginName: string | typeof Plugin) {
|
static clearCache(packageName: string) {
|
||||||
|
const packageNamePath = packageName.replace('/', sep);
|
||||||
|
Object.keys(require.cache).forEach((key) => {
|
||||||
|
if (key.includes(packageNamePath)) {
|
||||||
|
delete require.cache[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static resolvePlugin(pluginName: string | typeof Plugin, isUpgrade = false, isPkg = false) {
|
||||||
if (typeof pluginName === 'string') {
|
if (typeof pluginName === 'string') {
|
||||||
const packageName = this.getPackageName(pluginName);
|
const packageName = isPkg ? pluginName : this.getPackageName(pluginName);
|
||||||
|
this.clearCache(packageName);
|
||||||
return requireModule(packageName);
|
return requireModule(packageName);
|
||||||
} else {
|
} else {
|
||||||
return pluginName;
|
return pluginName;
|
||||||
@ -170,10 +178,8 @@ export class PluginManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(name: string | string[]) {
|
async create(pluginName: string) {
|
||||||
console.log('creating...');
|
console.log('creating...');
|
||||||
const pluginNames = Array.isArray(name) ? name : [name];
|
|
||||||
const { run } = require('@nocobase/cli/src/util');
|
|
||||||
const createPlugin = async (name) => {
|
const createPlugin = async (name) => {
|
||||||
const { PluginGenerator } = require('@nocobase/cli/src/plugin-generator');
|
const { PluginGenerator } = require('@nocobase/cli/src/plugin-generator');
|
||||||
const generator = new PluginGenerator({
|
const generator = new PluginGenerator({
|
||||||
@ -185,12 +191,21 @@ export class PluginManager {
|
|||||||
});
|
});
|
||||||
await generator.run();
|
await generator.run();
|
||||||
};
|
};
|
||||||
await Promise.all(pluginNames.map((pluginName) => createPlugin(pluginName)));
|
await createPlugin(pluginName);
|
||||||
await run('yarn', ['install']);
|
await this.repository.create({
|
||||||
|
values: {
|
||||||
|
name: pluginName,
|
||||||
|
packageName: pluginName,
|
||||||
|
version: '0.1.0',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tsxRerunning();
|
||||||
|
// await createDevPluginSymLink(pluginName);
|
||||||
|
// await this.add(pluginName, { packageName: pluginName }, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
async add(plugin?: any, options: any = {}, insert = false) {
|
async add(plugin?: any, options: any = {}, insert = false, isUpgrade = false) {
|
||||||
if (this.has(plugin)) {
|
if (!isUpgrade && this.has(plugin)) {
|
||||||
const name = typeof plugin === 'string' ? plugin : plugin.name;
|
const name = typeof plugin === 'string' ? plugin : plugin.name;
|
||||||
this.app.log.warn(`plugin [${name}] added`);
|
this.app.log.warn(`plugin [${name}] added`);
|
||||||
return;
|
return;
|
||||||
@ -198,10 +213,24 @@ export class PluginManager {
|
|||||||
if (!options.name && typeof plugin === 'string') {
|
if (!options.name && typeof plugin === 'string') {
|
||||||
options.name = plugin;
|
options.name = plugin;
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
if (typeof plugin === 'string' && options.name && !options.packageName) {
|
||||||
|
const packageName = PluginManager.getPackageName(options.name);
|
||||||
|
options['packageName'] = packageName;
|
||||||
|
}
|
||||||
|
if (options.packageName) {
|
||||||
|
const packageJson = PluginManager.getPackageJson(options.packageName);
|
||||||
|
options['packageJson'] = packageJson;
|
||||||
|
options['version'] = packageJson.version;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
// empty
|
||||||
|
}
|
||||||
this.app.log.debug(`adding plugin [${options.name}]...`);
|
this.app.log.debug(`adding plugin [${options.name}]...`);
|
||||||
let P: any;
|
let P: any;
|
||||||
try {
|
try {
|
||||||
P = PluginManager.resolvePlugin(plugin);
|
P = PluginManager.resolvePlugin(options.packageName || plugin, isUpgrade, !!options.packageName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.app.log.warn('plugin not found', error);
|
this.app.log.warn('plugin not found', error);
|
||||||
return;
|
return;
|
||||||
@ -212,12 +241,9 @@ export class PluginManager {
|
|||||||
this.pluginAliases.set(options.name, instance);
|
this.pluginAliases.set(options.name, instance);
|
||||||
}
|
}
|
||||||
if (insert && options.name) {
|
if (insert && options.name) {
|
||||||
const packageName = PluginManager.getPackageName(options.name);
|
|
||||||
const packageJson = PluginManager.getPackageJson(packageName);
|
|
||||||
await this.repository.updateOrCreate({
|
await this.repository.updateOrCreate({
|
||||||
values: {
|
values: {
|
||||||
...options,
|
...options,
|
||||||
version: packageJson.version,
|
|
||||||
},
|
},
|
||||||
filterKeys: ['name'],
|
filterKeys: ['name'],
|
||||||
});
|
});
|
||||||
@ -451,7 +477,7 @@ export class PluginManager {
|
|||||||
for (const pluginName of pluginNames) {
|
for (const pluginName of pluginNames) {
|
||||||
const plugin = this.get(pluginName);
|
const plugin = this.get(pluginName);
|
||||||
if (!plugin) {
|
if (!plugin) {
|
||||||
throw new Error(`${pluginName} plugin does not exist`);
|
continue;
|
||||||
}
|
}
|
||||||
if (plugin.enabled) {
|
if (plugin.enabled) {
|
||||||
throw new Error(`${pluginName} plugin is enabled`);
|
throw new Error(`${pluginName} plugin is enabled`);
|
||||||
@ -466,13 +492,20 @@ export class PluginManager {
|
|||||||
const plugins: Plugin[] = [];
|
const plugins: Plugin[] = [];
|
||||||
for (const pluginName of pluginNames) {
|
for (const pluginName of pluginNames) {
|
||||||
const plugin = this.get(pluginName);
|
const plugin = this.get(pluginName);
|
||||||
|
if (!plugin) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
plugins.push(plugin);
|
plugins.push(plugin);
|
||||||
this.del(pluginName);
|
this.del(pluginName);
|
||||||
|
// if (plugin.options.type && plugin.options.packageName) {
|
||||||
|
// await removePluginPackage(plugin.options.packageName);
|
||||||
|
// }
|
||||||
}
|
}
|
||||||
await this.app.reload();
|
await this.app.reload();
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
await plugin.afterRemove();
|
await plugin.afterRemove();
|
||||||
}
|
}
|
||||||
|
await this.app.emitStartedEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async initPresetPlugins() {
|
protected async initPresetPlugins() {
|
||||||
@ -481,6 +514,200 @@ export class PluginManager {
|
|||||||
await this.add(p, { enabled: true, isPreset: true, ...opts });
|
await this.add(p, { enabled: true, isPreset: true, ...opts });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadOne(plugin: Plugin) {
|
||||||
|
this.app.setMaintainingMessage(`loading plugin ${plugin.name}...`);
|
||||||
|
if (plugin.state.loaded || !plugin.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = plugin.getName();
|
||||||
|
await plugin.beforeLoad();
|
||||||
|
|
||||||
|
await this.app.emitAsync('beforeLoadPlugin', plugin, {});
|
||||||
|
this.app.logger.debug(`loading plugin [${name}]...`);
|
||||||
|
await plugin.load();
|
||||||
|
plugin.state.loaded = true;
|
||||||
|
await this.app.emitAsync('afterLoadPlugin', plugin, {});
|
||||||
|
this.app.logger.debug(`after load plugin [${name}]...`);
|
||||||
|
|
||||||
|
this.app.setMaintainingMessage(`loaded plugin ${plugin.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addViaCLI(urlOrName: string, options?: PluginData) {
|
||||||
|
if (isURL(urlOrName)) {
|
||||||
|
await this.addByCompressedFileUrl({
|
||||||
|
...options,
|
||||||
|
compressedFileUrl: urlOrName,
|
||||||
|
});
|
||||||
|
} else if (await fsExists(urlOrName)) {
|
||||||
|
await this.addByCompressedFileUrl({
|
||||||
|
...(options as any),
|
||||||
|
compressedFileUrl: urlOrName,
|
||||||
|
});
|
||||||
|
} else if (options?.registry) {
|
||||||
|
if (!options.name) {
|
||||||
|
const model = await this.repository.findOne({ filter: { packageName: urlOrName } });
|
||||||
|
if (model) {
|
||||||
|
options['name'] = model?.name;
|
||||||
|
}
|
||||||
|
if (!options.name) {
|
||||||
|
options['name'] = urlOrName.replace('@nocobase/plugin-', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await this.addByNpm({
|
||||||
|
...(options as any),
|
||||||
|
packageName: urlOrName,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const opts = {
|
||||||
|
...options,
|
||||||
|
};
|
||||||
|
const model = await this.repository.findOne({ filter: { packageName: urlOrName } });
|
||||||
|
if (model) {
|
||||||
|
opts['name'] = model.name;
|
||||||
|
}
|
||||||
|
if (!opts['name']) {
|
||||||
|
opts['packageName'] = urlOrName;
|
||||||
|
}
|
||||||
|
await this.add(opts['name'] || urlOrName, opts, true);
|
||||||
|
}
|
||||||
|
await this.app.emitStartedEvent();
|
||||||
|
}
|
||||||
|
|
||||||
|
async addByNpm(options: { packageName: string; name?: string; registry: string; authToken?: string }) {
|
||||||
|
let { name = '', registry, packageName, authToken } = options;
|
||||||
|
name = name.trim();
|
||||||
|
registry = registry.trim();
|
||||||
|
packageName = packageName.trim();
|
||||||
|
authToken = authToken?.trim();
|
||||||
|
const { compressedFileUrl } = await getPluginInfoByNpm({
|
||||||
|
packageName,
|
||||||
|
registry,
|
||||||
|
authToken,
|
||||||
|
});
|
||||||
|
return this.addByCompressedFileUrl({ name, compressedFileUrl, registry, authToken, type: 'npm' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async addByFile(options: { file: string; registry?: string; authToken?: string; type?: string; name?: string }) {
|
||||||
|
const { file, authToken } = options;
|
||||||
|
|
||||||
|
const { packageName, tempFile, tempPackageContentDir } = await downloadAndUnzipToTempDir(file, authToken);
|
||||||
|
|
||||||
|
const name = options.name || packageName;
|
||||||
|
|
||||||
|
if (this.has(name)) {
|
||||||
|
await removeTmpDir(tempFile, tempPackageContentDir);
|
||||||
|
throw new Error(`plugin name [${name}] already exists`);
|
||||||
|
}
|
||||||
|
await copyTempPackageToStorageAndLinkToNodeModules(tempFile, tempPackageContentDir, packageName);
|
||||||
|
return this.add(name, { packageName }, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addByCompressedFileUrl(options: {
|
||||||
|
compressedFileUrl: string;
|
||||||
|
registry?: string;
|
||||||
|
authToken?: string;
|
||||||
|
type?: string;
|
||||||
|
name?: string;
|
||||||
|
}) {
|
||||||
|
const { compressedFileUrl, authToken } = options;
|
||||||
|
|
||||||
|
const { packageName, tempFile, tempPackageContentDir } = await downloadAndUnzipToTempDir(
|
||||||
|
compressedFileUrl,
|
||||||
|
authToken,
|
||||||
|
);
|
||||||
|
|
||||||
|
const name = options.name || packageName;
|
||||||
|
|
||||||
|
if (this.has(name)) {
|
||||||
|
await removeTmpDir(tempFile, tempPackageContentDir);
|
||||||
|
throw new Error(`plugin name [${name}] already exists`);
|
||||||
|
}
|
||||||
|
await copyTempPackageToStorageAndLinkToNodeModules(tempFile, tempPackageContentDir, packageName);
|
||||||
|
return this.add(name, { packageName }, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(options: PluginData) {
|
||||||
|
if (options['url']) {
|
||||||
|
options.compressedFileUrl = options['url'];
|
||||||
|
}
|
||||||
|
if (!options.name) {
|
||||||
|
const model = await this.repository.findOne({ filter: { packageName: options.packageName } });
|
||||||
|
options['name'] = model.name;
|
||||||
|
}
|
||||||
|
if (options.compressedFileUrl) {
|
||||||
|
await this.upgradeByCompressedFileUrl(options);
|
||||||
|
} else {
|
||||||
|
await this.upgradeByNpm(options as any);
|
||||||
|
}
|
||||||
|
await this.app.upgrade();
|
||||||
|
}
|
||||||
|
|
||||||
|
async upgradeByNpm(values: PluginData) {
|
||||||
|
const name = values.name;
|
||||||
|
const plugin = this.get(name);
|
||||||
|
if (!this.has(name)) {
|
||||||
|
throw new Error(`plugin name [${name}] not exists`);
|
||||||
|
}
|
||||||
|
if (!plugin.options.packageName || !values.registry) {
|
||||||
|
throw new Error(`plugin name [${name}] not installed by npm`);
|
||||||
|
}
|
||||||
|
const version = values.version?.trim();
|
||||||
|
const registry = values.registry?.trim() || plugin.options.registry;
|
||||||
|
const authToken = values.authToken?.trim() || plugin.options.authToken;
|
||||||
|
const { compressedFileUrl } = await getPluginInfoByNpm({
|
||||||
|
packageName: plugin.options.packageName,
|
||||||
|
registry: registry,
|
||||||
|
authToken: authToken,
|
||||||
|
version,
|
||||||
|
});
|
||||||
|
return this.upgradeByCompressedFileUrl({ compressedFileUrl, name, version, registry, authToken });
|
||||||
|
}
|
||||||
|
|
||||||
|
async upgradeByCompressedFileUrl(options: PluginData) {
|
||||||
|
const { name, compressedFileUrl, authToken } = options;
|
||||||
|
const data = await this.repository.findOne({ filter: { name } });
|
||||||
|
const { version } = await updatePluginByCompressedFileUrl({
|
||||||
|
compressedFileUrl,
|
||||||
|
packageName: data.packageName,
|
||||||
|
authToken: authToken,
|
||||||
|
});
|
||||||
|
await this.add(name, { version, packageName: data.packageName }, true, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
getNameByPackageName(packageName: string) {
|
||||||
|
const prefixes = PluginManager.getPluginPkgPrefix();
|
||||||
|
const prefix = prefixes.find((prefix) => packageName.startsWith(prefix));
|
||||||
|
if (!prefix) {
|
||||||
|
throw new Error(
|
||||||
|
`package name [${packageName}] invalid, just support ${prefixes.join(
|
||||||
|
', ',
|
||||||
|
)}. You can modify process.env.PLUGIN_PACKAGE_PREFIX add more prefix.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return packageName.replace(prefix, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(options: any = {}) {
|
||||||
|
const { locale = 'en-US', isPreset = false } = options;
|
||||||
|
return Promise.all(
|
||||||
|
[...this.getAliases()]
|
||||||
|
.map((name) => {
|
||||||
|
const plugin = this.get(name);
|
||||||
|
if (!isPreset && plugin.options.isPreset) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return plugin.toJSON({ locale });
|
||||||
|
})
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNpmVersionList(name: string) {
|
||||||
|
const plugin = this.get(name);
|
||||||
|
const npmInfo = await getNpmInfo(plugin.options.packageName, plugin.options.registry, plugin.options.authToken);
|
||||||
|
return Object.keys(npmInfo.versions);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default PluginManager;
|
export default PluginManager;
|
||||||
|
15
packages/core/server/src/plugin-manager/types.ts
Normal file
15
packages/core/server/src/plugin-manager/types.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export interface PluginData {
|
||||||
|
name?: string;
|
||||||
|
packageName?: string;
|
||||||
|
version?: string;
|
||||||
|
preVersion?: string;
|
||||||
|
registry?: string;
|
||||||
|
clientUrl?: string;
|
||||||
|
compressedFileUrl?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
type?: 'url' | 'npm' | 'upload';
|
||||||
|
authToken?: string;
|
||||||
|
installed?: boolean;
|
||||||
|
builtIn?: boolean;
|
||||||
|
options?: any;
|
||||||
|
}
|
552
packages/core/server/src/plugin-manager/utils.ts
Normal file
552
packages/core/server/src/plugin-manager/utils.ts
Normal file
@ -0,0 +1,552 @@
|
|||||||
|
import { isURL } from '@nocobase/utils';
|
||||||
|
import { createStoragePluginSymLink } from '@nocobase/utils/plugin-symlink';
|
||||||
|
import axios, { AxiosRequestConfig } from 'axios';
|
||||||
|
import decompress from 'decompress';
|
||||||
|
import fg from 'fast-glob';
|
||||||
|
import fs from 'fs-extra';
|
||||||
|
import ini from 'ini';
|
||||||
|
import { builtinModules } from 'module';
|
||||||
|
import os from 'os';
|
||||||
|
import path from 'path';
|
||||||
|
import semver from 'semver';
|
||||||
|
import {
|
||||||
|
APP_NAME,
|
||||||
|
DEFAULT_PLUGIN_PATH,
|
||||||
|
DEFAULT_PLUGIN_STORAGE_PATH,
|
||||||
|
EXTERNAL,
|
||||||
|
importRegex,
|
||||||
|
pluginPrefix,
|
||||||
|
requireRegex,
|
||||||
|
} from './constants';
|
||||||
|
import deps from './deps';
|
||||||
|
import { PluginData } from './types';
|
||||||
|
import { getDepPkgPath, getPackageDir, getPackageFilePathWithExistCheck } from './clientStaticUtils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get temp dir
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* getTempDir() => '/tmp/nocobase'
|
||||||
|
*/
|
||||||
|
export async function getTempDir() {
|
||||||
|
const temporaryDirectory = await fs.realpath(os.tmpdir());
|
||||||
|
return path.join(temporaryDirectory, APP_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPluginStoragePath() {
|
||||||
|
const pluginStoragePath = process.env.PLUGIN_STORAGE_PATH || DEFAULT_PLUGIN_STORAGE_PATH;
|
||||||
|
return path.isAbsolute(pluginStoragePath) ? pluginStoragePath : path.join(process.cwd(), pluginStoragePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalPluginPackagesPathArr(): string[] {
|
||||||
|
const pluginPackagesPathArr = process.env.PLUGIN_PATH || DEFAULT_PLUGIN_PATH;
|
||||||
|
return pluginPackagesPathArr.split(',').map((pluginPackagesPath) => {
|
||||||
|
pluginPackagesPath = pluginPackagesPath.trim();
|
||||||
|
return path.isAbsolute(pluginPackagesPath) ? pluginPackagesPath : path.join(process.cwd(), pluginPackagesPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoragePluginDir(packageName: string) {
|
||||||
|
const pluginStoragePath = getPluginStoragePath();
|
||||||
|
return path.join(pluginStoragePath, packageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalPluginDir(packageDirBasename: string) {
|
||||||
|
const localPluginDir = getLocalPluginPackagesPathArr()
|
||||||
|
.map((pluginPackagesPath) => path.join(pluginPackagesPath, packageDirBasename))
|
||||||
|
.find((pluginDir) => fs.existsSync(pluginDir));
|
||||||
|
|
||||||
|
if (!localPluginDir) {
|
||||||
|
throw new Error(`local plugin "${packageDirBasename}" not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return localPluginDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNodeModulesPluginDir(packageName: string) {
|
||||||
|
return path.join(process.env.NODE_MODULES_PATH, packageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthorizationHeaders(registry?: string, authToken?: string) {
|
||||||
|
const headers = {};
|
||||||
|
if (registry && !authToken) {
|
||||||
|
const npmrcPath = path.join(process.cwd(), '.npmrc');
|
||||||
|
const url = new URL(registry);
|
||||||
|
let envConfig: Record<string, string> = process.env;
|
||||||
|
if (fs.existsSync(npmrcPath)) {
|
||||||
|
const content = fs.readFileSync(path.join(process.cwd(), '.npmrc'), 'utf-8');
|
||||||
|
envConfig = {
|
||||||
|
...envConfig,
|
||||||
|
...ini.parse(content),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const key = Object.keys(envConfig).find((key) => key.includes(url.host) && key.includes('_authToken'));
|
||||||
|
if (key) {
|
||||||
|
authToken = envConfig[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (authToken) {
|
||||||
|
headers['Authorization'] = `Bearer ${authToken}`;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get latest version from npm
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* getLatestVersion('dayjs', 'https://registry.npmjs.org') => '1.10.6'
|
||||||
|
*/
|
||||||
|
export async function getLatestVersion(packageName: string, registry: string, token?: string) {
|
||||||
|
const npmInfo = await getNpmInfo(packageName, registry, token);
|
||||||
|
const latestVersion = npmInfo['dist-tags'].latest;
|
||||||
|
return latestVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNpmInfo(packageName: string, registry: string, token?: string) {
|
||||||
|
registry.endsWith('/') && (registry = registry.slice(0, -1));
|
||||||
|
const response = await axios.get(`${registry}/${packageName}`, {
|
||||||
|
headers: getAuthorizationHeaders(registry, token),
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const data = response.data;
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
throw new Error(`${registry} is not a valid registry, '${registry}/${packageName}' response is not a valid json.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function download(url: string, destination: string, options: AxiosRequestConfig = {}) {
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
...options,
|
||||||
|
responseType: 'stream',
|
||||||
|
});
|
||||||
|
|
||||||
|
fs.mkdirpSync(path.dirname(destination));
|
||||||
|
const writer = fs.createWriteStream(destination);
|
||||||
|
|
||||||
|
response.data.pipe(writer);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
writer.on('finish', resolve);
|
||||||
|
writer.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeTmpDir(tempFile: string, tempPackageContentDir: string) {
|
||||||
|
await fs.remove(tempFile);
|
||||||
|
await fs.remove(tempPackageContentDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* download and unzip to node_modules
|
||||||
|
*/
|
||||||
|
export async function downloadAndUnzipToTempDir(fileUrl: string, authToken?: string) {
|
||||||
|
const fileName = path.basename(fileUrl);
|
||||||
|
const tempDir = await getTempDir();
|
||||||
|
const tempFile = path.join(tempDir, fileName);
|
||||||
|
const tempPackageDir = tempFile.replace(path.extname(fileName), '');
|
||||||
|
|
||||||
|
// download and unzip to temp dir
|
||||||
|
await fs.remove(tempPackageDir);
|
||||||
|
await fs.remove(tempFile);
|
||||||
|
|
||||||
|
if (isURL(fileUrl)) {
|
||||||
|
await download(fileUrl, tempFile, {
|
||||||
|
headers: getAuthorizationHeaders(fileUrl, authToken),
|
||||||
|
});
|
||||||
|
} else if (await fs.exists(fileUrl)) {
|
||||||
|
await fs.copy(fileUrl, tempFile);
|
||||||
|
} else {
|
||||||
|
throw new Error(`${fileUrl} does not exist`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(tempFile)) {
|
||||||
|
throw new Error(`download ${fileUrl} failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await decompress(tempFile, tempPackageDir);
|
||||||
|
|
||||||
|
if (!fs.existsSync(tempPackageDir)) {
|
||||||
|
await fs.remove(tempFile);
|
||||||
|
throw new Error(`File is not a valid compressed file. Maybe the file need authorization.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tempPackageContentDir = tempPackageDir;
|
||||||
|
const files = fs
|
||||||
|
.readdirSync(tempPackageDir, { recursive: false, withFileTypes: true })
|
||||||
|
.filter((item) => item.name !== '__MACOSX');
|
||||||
|
if (
|
||||||
|
files.length === 1 &&
|
||||||
|
files[0].isDirectory() &&
|
||||||
|
fs.existsSync(path.join(tempPackageDir, files[0]['name'], 'package.json'))
|
||||||
|
) {
|
||||||
|
tempPackageContentDir = path.join(tempPackageDir, files[0]['name']);
|
||||||
|
}
|
||||||
|
const packageJsonPath = path.join(tempPackageContentDir, 'package.json');
|
||||||
|
|
||||||
|
if (!fs.existsSync(packageJsonPath)) {
|
||||||
|
await removeTmpDir(tempFile, tempPackageContentDir);
|
||||||
|
throw new Error(`decompress ${fileUrl} failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageJson = requireNoCache(packageJsonPath);
|
||||||
|
const mainFile = path.join(tempPackageContentDir, packageJson.main);
|
||||||
|
if (!fs.existsSync(mainFile)) {
|
||||||
|
await removeTmpDir(tempFile, tempPackageContentDir);
|
||||||
|
throw new Error(`main file ${packageJson.main} not found, Please check if the plugin has been built.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
packageName: packageJson.name,
|
||||||
|
version: packageJson.version,
|
||||||
|
tempPackageContentDir,
|
||||||
|
tempFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copyTempPackageToStorageAndLinkToNodeModules(
|
||||||
|
tempFile: string,
|
||||||
|
tempPackageContentDir: string,
|
||||||
|
packageName: string,
|
||||||
|
) {
|
||||||
|
const packageDir = getStoragePluginDir(packageName);
|
||||||
|
|
||||||
|
// move to plugin storage dir
|
||||||
|
await fs.remove(packageDir);
|
||||||
|
await fs.move(tempPackageContentDir, packageDir, { overwrite: true });
|
||||||
|
|
||||||
|
// symlink to node_modules
|
||||||
|
await createStoragePluginSymLink(packageName);
|
||||||
|
|
||||||
|
// remove temp dir
|
||||||
|
await removeTmpDir(tempFile, tempPackageContentDir);
|
||||||
|
|
||||||
|
return {
|
||||||
|
packageDir,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get package info from npm
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* getPluginInfoByNpm('dayjs', 'https://registry.npmjs.org')
|
||||||
|
* => { fileUrl: 'https://registry.npmjs.org/dayjs/-/dayjs-1.10.6.tgz', latestVersion: '1.10.6' }
|
||||||
|
*
|
||||||
|
* getPluginInfoByNpm('dayjs', 'https://registry.npmjs.org', '1.1.0')
|
||||||
|
* => { fileUrl: 'https://registry.npmjs.org/dayjs/-/dayjs-1.1.0.tgz', latestVersion: '1.1.0' }
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface GetPluginInfoOptions {
|
||||||
|
packageName: string;
|
||||||
|
registry: string;
|
||||||
|
version?: string;
|
||||||
|
authToken?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPluginInfoByNpm(options: GetPluginInfoOptions) {
|
||||||
|
let { registry, version } = options;
|
||||||
|
const { packageName, authToken } = options;
|
||||||
|
if (registry.endsWith('/')) {
|
||||||
|
registry = registry.slice(0, -1);
|
||||||
|
}
|
||||||
|
if (!version) {
|
||||||
|
version = await getLatestVersion(packageName, registry, authToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
const compressedFileUrl = `${registry}/${packageName}/-/${packageName.split('/').pop()}-${version}.tgz`;
|
||||||
|
|
||||||
|
return { compressedFileUrl, version };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* scan `src/server` directory to get server packages
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* getServerPackages('src/server') => ['dayjs', '@nocobase/plugin-bbb']
|
||||||
|
*/
|
||||||
|
export function getServerPackages(packageDir: string) {
|
||||||
|
function isBuiltinModule(packageName: string) {
|
||||||
|
return builtinModules.includes(packageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSrcPlugins(sourceDir: string): string[] {
|
||||||
|
const importedPlugins = new Set<string>();
|
||||||
|
const exts = ['.js', '.ts', '.jsx', '.tsx'];
|
||||||
|
const importRegex = /import\s+.*?\s+from\s+['"]([^'"\s.].+?)['"];?/g;
|
||||||
|
const requireRegex = /require\s*\(\s*[`'"]([^`'"\s.].+?)[`'"]\s*\)/g;
|
||||||
|
function setPluginsFromContent(reg: RegExp, content: string) {
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = reg.exec(content))) {
|
||||||
|
let importedPlugin = match[1];
|
||||||
|
if (importedPlugin.startsWith('@')) {
|
||||||
|
// @aa/bb/ccFile => @aa/bb
|
||||||
|
importedPlugin = importedPlugin.split('/').slice(0, 2).join('/');
|
||||||
|
} else {
|
||||||
|
// aa/bbFile => aa
|
||||||
|
importedPlugin = importedPlugin.split('/')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isBuiltinModule(importedPlugin)) {
|
||||||
|
importedPlugins.add(importedPlugin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function traverseDirectory(directory: string) {
|
||||||
|
const files = fs.readdirSync(directory);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = path.join(directory, file);
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
|
||||||
|
if (stat.isDirectory()) {
|
||||||
|
// recursive
|
||||||
|
traverseDirectory(filePath);
|
||||||
|
} else if (stat.isFile() && !filePath.includes('__tests__')) {
|
||||||
|
if (exts.includes(path.extname(filePath).toLowerCase())) {
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
|
||||||
|
setPluginsFromContent(importRegex, content);
|
||||||
|
setPluginsFromContent(requireRegex, content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
traverseDirectory(sourceDir);
|
||||||
|
|
||||||
|
return [...importedPlugins];
|
||||||
|
}
|
||||||
|
|
||||||
|
const srcServerPlugins = getSrcPlugins(path.join(packageDir, 'src/server'));
|
||||||
|
return srcServerPlugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removePluginPackage(packageName: string) {
|
||||||
|
const packageDir = getStoragePluginDir(packageName);
|
||||||
|
const nodeModulesPluginDir = getNodeModulesPluginDir(packageName);
|
||||||
|
return Promise.all([fs.remove(packageDir), fs.remove(nodeModulesPluginDir)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get package.json
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* getPackageJson('dayjs') => { name: 'dayjs', version: '1.0.0', ... }
|
||||||
|
*/
|
||||||
|
export function getPackageJson(pluginName: string) {
|
||||||
|
const packageDir = getStoragePluginDir(pluginName);
|
||||||
|
return getPackageJsonByLocalPath(packageDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPackageJsonByLocalPath(localPath: string) {
|
||||||
|
if (!fs.existsSync(localPath)) {
|
||||||
|
return null;
|
||||||
|
} else {
|
||||||
|
return requireNoCache(path.join(localPath, 'package.json'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePluginByCompressedFileUrl(
|
||||||
|
options: Partial<Pick<PluginData, 'compressedFileUrl' | 'packageName' | 'authToken'>>,
|
||||||
|
) {
|
||||||
|
const { packageName, version, tempFile, tempPackageContentDir } = await downloadAndUnzipToTempDir(
|
||||||
|
options.compressedFileUrl,
|
||||||
|
options.authToken,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (options.packageName && options.packageName !== packageName) {
|
||||||
|
await removeTmpDir(tempFile, tempPackageContentDir);
|
||||||
|
throw new Error(`Plugin name in package.json must be ${options.packageName}, but got ${packageName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { packageDir } = await copyTempPackageToStorageAndLinkToNodeModules(
|
||||||
|
tempFile,
|
||||||
|
tempPackageContentDir,
|
||||||
|
packageName,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
packageName,
|
||||||
|
packageDir,
|
||||||
|
version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNewVersion(plugin: PluginData): Promise<string | false> {
|
||||||
|
if (!(plugin.packageName && plugin.registry)) return false;
|
||||||
|
|
||||||
|
// 1. Check plugin version by npm registry
|
||||||
|
const { version } = await getPluginInfoByNpm({
|
||||||
|
packageName: plugin.packageName,
|
||||||
|
registry: plugin.registry,
|
||||||
|
authToken: plugin.authToken,
|
||||||
|
});
|
||||||
|
// 2. has new version, return true
|
||||||
|
return version !== plugin.version ? version : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeRequireCache(fileOrPackageName: string) {
|
||||||
|
delete require.cache[require.resolve(fileOrPackageName)];
|
||||||
|
delete require.cache[fileOrPackageName];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireNoCache(fileOrPackageName: string) {
|
||||||
|
removeRequireCache(fileOrPackageName);
|
||||||
|
return require(fileOrPackageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireModule(m: any) {
|
||||||
|
if (typeof m === 'string') {
|
||||||
|
m = require(m);
|
||||||
|
}
|
||||||
|
if (typeof m !== 'object') {
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
return m.__esModule ? m.default : m;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExternalVersionFromDistFile(packageName: string): false | Record<string, string> {
|
||||||
|
const { exists, filePath } = getPackageFilePathWithExistCheck(packageName, 'dist/externalVersion.js');
|
||||||
|
if (!exists) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return requireNoCache(filePath);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function isNotBuiltinModule(packageName: string) {
|
||||||
|
return !builtinModules.includes(packageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isValidPackageName = (str: string) => {
|
||||||
|
const pattern = /^(?:@[a-zA-Z0-9_-]+\/)?[a-zA-Z0-9_-]+$/;
|
||||||
|
return pattern.test(str);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getPackageNameFromString(str: string) {
|
||||||
|
// ./xx or ../xx
|
||||||
|
if (str.startsWith('.')) return null;
|
||||||
|
|
||||||
|
const arr = str.split('/');
|
||||||
|
let packageName: string;
|
||||||
|
if (arr[0].startsWith('@')) {
|
||||||
|
// @aa/bb/ccFile => @aa/bb
|
||||||
|
packageName = arr.slice(0, 2).join('/');
|
||||||
|
} else {
|
||||||
|
// aa/bbFile => aa
|
||||||
|
packageName = arr[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
packageName = packageName.trim();
|
||||||
|
|
||||||
|
return isValidPackageName(packageName) ? packageName : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPackagesFromFiles(files: string[]): string[] {
|
||||||
|
const packageNames = files
|
||||||
|
.map((item) => [
|
||||||
|
...[...item.matchAll(importRegex)].map((item) => item[2]),
|
||||||
|
...[...item.matchAll(requireRegex)].map((item) => item[1]),
|
||||||
|
])
|
||||||
|
.flat()
|
||||||
|
.map(getPackageNameFromString)
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter(isNotBuiltinModule);
|
||||||
|
|
||||||
|
return [...new Set(packageNames)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIncludePackages(sourcePackages: string[], external: string[], pluginPrefix: string[]): string[] {
|
||||||
|
return sourcePackages
|
||||||
|
.filter((packageName) => !external.includes(packageName)) // exclude external
|
||||||
|
.filter((packageName) => !pluginPrefix.some((prefix) => packageName.startsWith(prefix))); // exclude other plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExcludePackages(sourcePackages: string[], external: string[], pluginPrefix: string[]): string[] {
|
||||||
|
const includePackages = getIncludePackages(sourcePackages, external, pluginPrefix);
|
||||||
|
return sourcePackages.filter((packageName) => !includePackages.includes(packageName));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getExternalVersionFromSource(packageName: string) {
|
||||||
|
const packageDir = getPackageDir(packageName);
|
||||||
|
const sourceGlobalFiles: string[] = ['src/**/*.{ts,js,tsx,jsx}', '!src/**/__tests__'];
|
||||||
|
const sourceFilePaths = await fg.glob(sourceGlobalFiles, { cwd: packageDir, absolute: true });
|
||||||
|
const sourceFiles = await Promise.all(sourceFilePaths.map((item) => fs.readFile(item, 'utf-8')));
|
||||||
|
const sourcePackages = getPackagesFromFiles(sourceFiles);
|
||||||
|
const excludePackages = getExcludePackages(sourcePackages, EXTERNAL, pluginPrefix);
|
||||||
|
const data = excludePackages.reduce<Record<string, string>>((prev, packageName) => {
|
||||||
|
const depPkgPath = getDepPkgPath(packageName, packageDir);
|
||||||
|
const depPkg = require(depPkgPath);
|
||||||
|
prev[packageName] = depPkg.version;
|
||||||
|
return prev;
|
||||||
|
}, {});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DepCompatible {
|
||||||
|
name: string;
|
||||||
|
result: boolean;
|
||||||
|
versionRange: string;
|
||||||
|
packageVersion: string;
|
||||||
|
}
|
||||||
|
export async function getCompatible(packageName: string) {
|
||||||
|
let externalVersion: Record<string, string>;
|
||||||
|
if (!process.env.IS_DEV_CMD) {
|
||||||
|
const res = getExternalVersionFromDistFile(packageName);
|
||||||
|
if (!res) {
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
externalVersion = res;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
externalVersion = await getExternalVersionFromSource(packageName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(externalVersion).reduce<DepCompatible[]>((result, packageName) => {
|
||||||
|
const packageVersion = externalVersion[packageName];
|
||||||
|
const globalPackageName = deps[packageName]
|
||||||
|
? packageName
|
||||||
|
: deps[packageName.split('/')[0]] // @nocobase and @formily
|
||||||
|
? packageName.split('/')[0]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
if (globalPackageName) {
|
||||||
|
const versionRange = deps[globalPackageName];
|
||||||
|
result.push({
|
||||||
|
name: packageName,
|
||||||
|
result: semver.satisfies(packageVersion, versionRange, { includePrerelease: true }),
|
||||||
|
versionRange,
|
||||||
|
packageVersion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkCompatible(packageName: string) {
|
||||||
|
const compatible = await getCompatible(packageName);
|
||||||
|
if (!compatible) return false;
|
||||||
|
return compatible.every((item) => item.result);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkAndGetCompatible(packageName: string) {
|
||||||
|
const compatible = await getCompatible(packageName);
|
||||||
|
if (!compatible) {
|
||||||
|
return {
|
||||||
|
isCompatible: false,
|
||||||
|
depsCompatible: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
isCompatible: compatible.every((item) => item.result),
|
||||||
|
depsCompatible: compatible,
|
||||||
|
};
|
||||||
|
}
|
@ -1,6 +1,9 @@
|
|||||||
import { Model } from '@nocobase/database';
|
import { Model } from '@nocobase/database';
|
||||||
|
import fs from 'fs';
|
||||||
|
import { resolve } from 'path';
|
||||||
import { Application } from './application';
|
import { Application } from './application';
|
||||||
import { InstallOptions } from './plugin-manager';
|
import { InstallOptions, getExposeChangelogUrl, getExposeReadmeUrl } from './plugin-manager';
|
||||||
|
import { checkAndGetCompatible } from './plugin-manager/utils';
|
||||||
|
|
||||||
export interface PluginInterface {
|
export interface PluginInterface {
|
||||||
beforeLoad?: () => void;
|
beforeLoad?: () => void;
|
||||||
@ -104,6 +107,30 @@ export abstract class Plugin<O = any> implements PluginInterface {
|
|||||||
requiredPlugins() {
|
requiredPlugins() {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async toJSON(options: any = {}) {
|
||||||
|
const { locale = 'en-US' } = options;
|
||||||
|
const { packageName, packageJson } = this.options;
|
||||||
|
if (!packageName) {
|
||||||
|
return {
|
||||||
|
...this.options,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const file = await fs.promises.realpath(resolve(process.env.NODE_MODULES_PATH, packageName));
|
||||||
|
const lastUpdated = (await fs.promises.stat(file)).ctime;
|
||||||
|
const others = await checkAndGetCompatible(packageName);
|
||||||
|
return {
|
||||||
|
...this.options,
|
||||||
|
...others,
|
||||||
|
readmeUrl: getExposeReadmeUrl(packageName, locale),
|
||||||
|
changelogUrl: getExposeChangelogUrl(packageName),
|
||||||
|
lastUpdated,
|
||||||
|
file,
|
||||||
|
updatable: file.startsWith(process.env.PLUGIN_STORAGE_PATH),
|
||||||
|
displayName: packageJson[`displayName.${locale}`] || packageJson.displayName,
|
||||||
|
description: packageJson[`description.${locale}`] || packageJson.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Plugin;
|
export default Plugin;
|
||||||
|
5
packages/core/utils/plugin-symlink.d.ts
vendored
Normal file
5
packages/core/utils/plugin-symlink.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export declare function getStoragePluginNames(target: any): Promise<any[]>;
|
||||||
|
export declare function fsExists(path: any): Promise<boolean>;
|
||||||
|
export declare function createStoragePluginSymLink(pluginName: any): Promise<void>;
|
||||||
|
export declare function createStoragePluginsSymlink(): Promise<void>;
|
||||||
|
export declare function createDevPluginSymLink(pluginName: any): Promise<void>;
|
87
packages/core/utils/plugin-symlink.js
Normal file
87
packages/core/utils/plugin-symlink.js
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
const { dirname, resolve } = require('path');
|
||||||
|
const { readFile, writeFile, readdir, symlink, unlink, mkdir, stat } = require('fs').promises;
|
||||||
|
|
||||||
|
async function getStoragePluginNames(target) {
|
||||||
|
const plugins = [];
|
||||||
|
const items = await readdir(target);
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.startsWith('@')) {
|
||||||
|
const children = await getStoragePluginNames(resolve(target, item));
|
||||||
|
plugins.push(
|
||||||
|
...children.map((child) => {
|
||||||
|
return `${item}/${child}`;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (await fsExists(resolve(target, item, 'package.json'))) {
|
||||||
|
plugins.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return plugins;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fsExists(path) {
|
||||||
|
try {
|
||||||
|
await stat(path);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.fsExists = fsExists;
|
||||||
|
|
||||||
|
async function createStoragePluginSymLink(pluginName) {
|
||||||
|
const storagePluginsPath = resolve(process.cwd(), 'storage/plugins');
|
||||||
|
const nodeModulesPath = process.env.NODE_MODULES_PATH; // resolve(dirname(require.resolve('@nocobase/server/package.json')), 'node_modules');
|
||||||
|
// const nodeModulesPath = resolve(process.cwd(), 'node_modules');
|
||||||
|
try {
|
||||||
|
if (pluginName.startsWith('@')) {
|
||||||
|
const [orgName] = pluginName.split('/');
|
||||||
|
if (!(await fsExists(resolve(nodeModulesPath, orgName)))) {
|
||||||
|
await mkdir(resolve(nodeModulesPath, orgName), { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const link = resolve(nodeModulesPath, pluginName);
|
||||||
|
if (await fsExists(link)) {
|
||||||
|
await unlink(link);
|
||||||
|
}
|
||||||
|
await symlink(resolve(storagePluginsPath, pluginName), link);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.createStoragePluginSymLink = createStoragePluginSymLink;
|
||||||
|
|
||||||
|
async function createStoragePluginsSymlink() {
|
||||||
|
const storagePluginsPath = resolve(process.cwd(), 'storage/plugins');
|
||||||
|
if (!(await fsExists(storagePluginsPath))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pluginNames = await getStoragePluginNames(storagePluginsPath);
|
||||||
|
await Promise.all(pluginNames.map((pluginName) => createStoragePluginSymLink(pluginName)));
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.createStoragePluginsSymlink = createStoragePluginsSymlink;
|
||||||
|
|
||||||
|
async function createDevPluginSymLink(pluginName) {
|
||||||
|
const packagePluginsPath = resolve(process.cwd(), 'packages/plugins');
|
||||||
|
const nodeModulesPath = process.env.NODE_MODULES_PATH; // resolve(dirname(require.resolve('@nocobase/server/package.json')), 'node_modules');
|
||||||
|
try {
|
||||||
|
if (pluginName.startsWith('@')) {
|
||||||
|
const [orgName] = pluginName.split('/');
|
||||||
|
if (!(await fsExists(resolve(nodeModulesPath, orgName)))) {
|
||||||
|
await mkdir(resolve(nodeModulesPath, orgName), { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const link = resolve(nodeModulesPath, pluginName);
|
||||||
|
if (await fsExists(link)) {
|
||||||
|
await unlink(link);
|
||||||
|
}
|
||||||
|
await symlink(resolve(packagePluginsPath, pluginName), link);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.createDevPluginSymLink = createDevPluginSymLink;
|
@ -19,4 +19,6 @@ export * from './registry';
|
|||||||
export * from './requireModule';
|
export * from './requireModule';
|
||||||
export * from './toposort';
|
export * from './toposort';
|
||||||
export * from './uid';
|
export * from './uid';
|
||||||
|
export * from './url';
|
||||||
|
|
||||||
export { dayjs, lodash };
|
export { dayjs, lodash };
|
||||||
|
@ -7,3 +7,4 @@ export * from './registry';
|
|||||||
export * from './requireModule';
|
export * from './requireModule';
|
||||||
export * from './toposort';
|
export * from './toposort';
|
||||||
export * from './uid';
|
export * from './uid';
|
||||||
|
export * from './url';
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user