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:
jack zhang 2023-09-12 22:39:23 +08:00 committed by GitHub
parent b918ec5e41
commit 705b7449f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2108 changed files with 4662 additions and 1860 deletions

View File

@ -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
View File

@ -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

View File

@ -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

View File

@ -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 \

View File

@ -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)

View File

@ -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)

View File

@ -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;

View File

@ -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',

View File

@ -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" />

View 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)

View File

@ -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" />

View 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)

View File

@ -34,5 +34,6 @@ module.exports = {
'package.json', 'package.json',
'/demo/', '/demo/',
'package-lock.json', 'package-lock.json',
'/storage/',
], ],
}; };

View File

@ -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",

View File

@ -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": {

View File

@ -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> = {}) {

View File

@ -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);
} }

View File

@ -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');

View 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);
}

View File

@ -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');
} }
} }

View File

@ -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 });
} }

View File

@ -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-',
}; };

View File

@ -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",

View File

@ -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',

View File

@ -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);

View File

@ -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;
} }

View 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' : '']);
});
};

View File

@ -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}`)}`);
} }
} }

View File

@ -0,0 +1 @@
# {{{packageName}}}

View File

@ -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;

View File

@ -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;

View File

@ -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,

View File

@ -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: [],
}); });
}); });

View File

@ -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: [],
}); });
}); });

View File

@ -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();

View File

@ -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;
}, {}), }, {}),
}); });

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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.": "プラグインのロードに失敗しました。サーバーログを確認してください。",
} }

View File

@ -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",

View File

@ -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.": "Не удалось загрузить плагин. Пожалуйста, проверьте журналы сервера.",
} }

View File

@ -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.",
} }

View File

@ -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": "Закладка",

View File

@ -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: '书签',

View File

@ -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] };
} }

View File

@ -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}`);
}}
/>,
]}
/>
</>
);
};

View 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}
/>
</>
);
};

View 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>&nbsp;&nbsp;</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>
);
};

View 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>
);
});

View 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';

View 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} />;
};

View 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} />;
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View 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." />
);
};

View 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>;
};

View File

@ -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', {

View File

@ -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',

View 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;
}

View File

@ -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();
} }

View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -21,7 +21,7 @@
"packages/app/*/src" "packages/app/*/src"
], ],
"@{{{name}}}/plugin-*": [ "@{{{name}}}/plugin-*": [
"packages/plugins/*/src" "packages/plugins/plugin-*/src"
] ]
} }
} }

View File

@ -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",

View File

@ -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,9 +73,9 @@ 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)) {
@ -89,6 +94,7 @@ function getPackagePaths() {
} }
} }
} }
}
return pkgs; return pkgs;
} }
@ -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 };
}); });

View File

@ -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"

View File

@ -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');
});
});

View File

@ -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,

View File

@ -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,8 +304,10 @@ 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();
if (!oldDb.closed()) {
await oldDb.close(); await oldDb.close();
} }
}
this.setMaintainingMessage('init plugins'); this.setMaintainingMessage('init plugins');
await this.pm.initPlugins(); await this.pm.initPlugins();
@ -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;

View File

@ -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')

View File

@ -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;
}

View File

@ -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

View File

@ -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');
};

View File

@ -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'],
});
}
}

View File

@ -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}`;
};

View 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));
}

View 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',
];

View 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;

View File

@ -1,2 +1,2 @@
export * from './clientStaticMiddleware'; export * from './clientStaticUtils';
export * from './plugin-manager'; export * from './plugin-manager';

View 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();
}

View File

@ -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' },

View File

@ -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();
},
}, },
}; };

View File

@ -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,

View File

@ -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;

View 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;
}

View 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,
};
}

View File

@ -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;

View 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>;

View 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;

View File

@ -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 };

View File

@ -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