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/app/client/src/.plugins
docker
storage

4
.gitignore vendored
View File

@ -26,3 +26,7 @@ storage/duplicator/*
**/.dumi/tmp-production
packages/core/client/docs/contributing.md
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/app/client/src/.plugins
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')" \
&& tmp=$(mktemp) \
&& 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 \
&& 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
- [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.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)

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

View File

@ -66,6 +66,19 @@ server {
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 {
proxy_pass http://127.0.0.1:13000/ws;
proxy_http_version 1.1;

View File

@ -84,6 +84,7 @@ const sidebar = {
},
// '/welcome/release/index',
// '/welcome/release/v08-changelog',
'/welcome/release/v14-changelog',
'/welcome/release/v13-changelog',
'/welcome/release/v12-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.
```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
|- /hello
@ -27,34 +27,26 @@ The directory where the plugin is located ``packages/plugins/hello`` and the plu
|- package.json # plugin package information
|- server.d.ts
|- 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
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
import { InstallOptions, Plugin } from '@nocobase/server';
export class HelloPlugin extends Plugin {
export class PluginHelloServer extends Plugin {
afterAdd() {}
beforeLoad() {}
@ -62,9 +54,7 @@ export class HelloPlugin extends Plugin {
async load() {
this.db.collection({
name: 'hello',
fields: [
{ type: 'string', name: 'name' }
],
fields: [{ type: 'string', name: 'name' }],
});
this.app.acl.allow('hello', '*');
}
@ -78,24 +68,29 @@ export class HelloPlugin extends Plugin {
async remove() {}
}
export default HelloPlugin;
```
## Register the plugin
```bash
yarn pm add hello
export default PluginHelloServer;
```
## Activate the plugin
When the plugin is activated, the hello table that you just configured is automatically created.
**Operated by command**
```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
# for development
@ -106,9 +101,7 @@ yarn build
yarn start
```
## Experience the plugin
Insert data into the hello table of the plugin
Insert data into the hello collection of the plugin
```bash
curl --location --request POST 'http://localhost:13000/api/hello:create' \
@ -123,3 +116,21 @@ View the data
```bash
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
yarn pm create hello
yarn pm create @my-project/plugin-hello
```
插件所在目录 `packages/plugins/hello`,插件目录结构为:
插件所在目录 `packages/plugins/@my-project/plugin-hello`,插件目录结构为:
```bash
|- /hello
|- /packages/plugins/@my-project/plugin-hello
|- /src
|- /client # 插件客户端代码
|- /server # 插件服务端代码
@ -29,32 +29,24 @@ yarn pm create hello
|- server.js
```
package.json 信息
访问插件管理器界面,查看刚添加的插件,默认地址为 http://localhost:13000/admin/pm/list/local/
```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"
}
}
<img src="https://nocobase.oss-cn-beijing.aliyuncs.com/b04d16851fc1bbc2796ecf8f9bc0c3f4.png" />
如果创建的插件未在插件管理器里显示,可以通过 `pm add` 命令手动添加
```bash
yarn pm add @my-project/plugin-hello
```
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
import { InstallOptions, Plugin } from '@nocobase/server';
export class HelloPlugin extends Plugin {
export class PluginHelloServer extends Plugin {
afterAdd() {}
beforeLoad() {}
@ -62,9 +54,7 @@ export class HelloPlugin extends Plugin {
async load() {
this.db.collection({
name: 'hello',
fields: [
{ type: 'string', name: 'name' }
],
fields: [{ type: 'string', name: 'name' }],
});
this.app.acl.allow('hello', '*');
}
@ -78,24 +68,29 @@ export class HelloPlugin extends Plugin {
async remove() {}
}
export default HelloPlugin;
```
## 注册插件
```bash
yarn pm add hello
export default PluginHelloServer;
```
## 激活插件
插件激活时,会自动创建刚才编辑插件配置的 hello 表。
**通过命令操作**
```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
# for development
@ -106,8 +101,6 @@ yarn build
yarn start
```
## 体验插件功能
向插件的 hello 表里插入数据
```bash
@ -123,3 +116,21 @@ curl --location --request POST 'http://localhost:13000/api/hello:create' \
```bash
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',
'/demo/',
'package-lock.json',
'/storage/',
],
};

View File

@ -2,7 +2,8 @@
"name": "nocobase",
"private": true,
"workspaces": [
"packages/*/*"
"packages/*/*",
"packages/*/*/*"
],
"license": "Apache-2.0",
"engines": {
@ -21,6 +22,7 @@
"dev-server": "nocobase dev --server",
"start": "nocobase start",
"build": "nocobase build",
"tar": "nocobase tar",
"test": "nocobase test",
"test:client": "vitest",
"tc": "yarn test:client",

View File

@ -18,17 +18,17 @@
"@types/lerna__project": "5.1.0",
"@vercel/ncc": "0.36.1",
"chalk": "2.4.2",
"fast-glob": "3.3.0",
"glob": "^7.1.4",
"fast-glob": "^3.3.1",
"gulp": "4.0.2",
"gulp-typescript": "6.0.0-alpha.1",
"pkg-up": "3.1.0",
"tsup": "7.2.0",
"typescript": "5.1.3",
"update-notifier": "3.0.0",
"vite-plugin-css-injected-by-js": "^3.2.1",
"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",
"scripts": {

View File

@ -17,8 +17,11 @@ import { buildDeclaration } from './buildDeclaration';
import { PkgLog, getPkgLog, toUnixPath, getPackageJson } from './utils';
import { getPackages } from './utils/getPackages';
import { Package } from '@lerna/package';
import { tarPlugin } from './tarPlugin'
export async function build(pkgs: string[]) {
process.env.NODE_ENV = 'production';
const packages = getPackages(pkgs);
const pluginPackages = getPluginPackages(packages);
const cjsPackages = getCjsPackages(packages);
@ -63,8 +66,17 @@ export async function buildPackage(
) {
const sourcemap = process.argv.includes('--sourcemap');
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 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`);
// prebuild
@ -88,6 +100,11 @@ export async function buildPackage(
log('postbuild');
await runScript(['postbuild'], pkg.location);
}
// tar
if (hasTar) {
await tarPlugin(pkg.location, log);
}
}
function runScript(args: string[], cwd: string, envs: Record<string, string> = {}) {

View File

@ -18,7 +18,7 @@ import {
getPackagesFromFiles,
getSourcePackages,
} from './utils/buildPluginUtils';
import { getDepsConfig } from './utils/getDepsConfig';
import { getDepPkgPath, getDepsConfig } from './utils/getDepsConfig';
import { EsbuildSupportExts, globExcludeFiles } from './constant';
import { PkgLog, getPackageJson } from './utils';
@ -26,6 +26,8 @@ const serverGlobalFiles: string[] = ['src/**', '!src/client/**', ...globExcludeF
const clientGlobalFiles: string[] = ['src/**', '!src/server/**', ...globExcludeFiles];
const dynamicImportRegexp = /import\((["'])(.*?)\1\)/g;
const sourceGlobalFiles: string[] = ['src/**/*.{ts,js,tsx,jsx}', '!src/**/__tests__'];
const external = [
// nocobase
'@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) {
log('build plugin server dependencies');
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);
}
}
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) => !item.location.startsWith(PRESETS_DIR))
.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 pkgUp from 'pkg-up';
export function winPath(path: string) {
const isExtendedLengthPath = /^\\\\\?\\/.test(path);
@ -40,9 +39,9 @@ export function getDepPkgPath(dep: string, cwd: string) {
try {
return require.resolve(`${dep}/package.json`, { paths: [cwd] });
} catch {
return pkgUp.sync({
cwd: require.resolve(dep, { paths: [cwd] }),
})!;
const mainFile = require.resolve(`${dep}`, { paths: cwd ? [cwd] : undefined });
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 fg from 'fast-glob';
import path from 'path';
import { PACKAGES_PATH, ROOT_PATH } from '../constant';
import { getPackagesSync } from '@lerna/project';
import { Package } from '@lerna/package';
@ -13,7 +14,13 @@ import { toUnixPath } from './utils';
*/
function getPackagesPath(pkgs: string[]) {
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
.sync(pkgs, {
@ -36,7 +43,7 @@ export function getPackages(pkgs: string[]) {
export function sortPackages(packages: Package[]): Package[] {
const sorter = new Topo.Sorter<Package>();
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 });
sorter.add(pkg, { after, group: pkg.name });
}

View File

@ -15,7 +15,9 @@ const env = {
DB_TIMEZONE: '+00:00',
DEFAULT_STORAGE_TYPE: 'local',
LOCAL_STORAGE_DEST: 'storage/uploads',
PLUGIN_STORAGE_PATH: resolve(process.cwd(), 'storage/plugins'),
MFSU_AD: 'none',
NODE_MODULES_PATH: resolve(process.cwd(), 'node_modules'),
PM2_HOME: resolve(process.cwd(), './storage/.pm2'),
PLUGIN_PACKAGE_PREFIX: '@nocobase/plugin-,@nocobase/plugin-sample-,@nocobase/preset-',
};

View File

@ -15,7 +15,7 @@
"commander": "^9.2.0",
"dotenv": "^10.0.0",
"execa": "^5.1.1",
"fast-glob": "^3.2.12",
"fast-glob": "^3.3.1",
"fs-extra": "^11.1.1",
"pm2": "^5.2.0",
"portfinder": "^1.0.28",

View File

@ -18,6 +18,7 @@ module.exports = (cli) => {
.allowUnknownOption()
.action(async (opts) => {
promptForTs();
process.env.IS_DEV_CMD = true;
if (process.argv.includes('-h') || process.argv.includes('--help')) {
run('ts-node', [
@ -59,6 +60,7 @@ module.exports = (cli) => {
const argv = [
'watch',
'--ignore=./storage/plugins/**',
'--tsconfig',
'./tsconfig.server.json',
'-r',

View File

@ -9,6 +9,7 @@ module.exports = (cli) => {
generateAppDir();
require('./global')(cli);
require('./build')(cli);
require('./tar')(cli);
require('./dev')(cli);
require('./start')(cli);
require('./test')(cli);

View File

@ -3,6 +3,7 @@ const { run, isDev, isPackageValid } = require('../util');
const { resolve } = require('path');
const { existsSync } = require('fs');
const { readFile, writeFile } = require('fs').promises;
const { createStoragePluginsSymlink } = require('@nocobase/utils/plugin-symlink');
/**
* @param {Command} cli
@ -13,6 +14,7 @@ module.exports = (cli) => {
.command('postinstall')
.allowUnknownOption()
.action(async () => {
await createStoragePluginsSymlink();
if (!isDev()) {
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 { join, resolve } = require('path');
const { Generator } = require('@umijs/utils');
const { readFile } = require('fs').promises;
const { readFile, writeFile } = require('fs').promises;
const execa = require('execa');
function camelize(str) {
return str.trim().replace(/[-_\s]+(.)?/g, (match, c) => c.toUpperCase());
@ -33,18 +35,27 @@ class PluginGenerator extends Generator {
async getContext() {
const { name } = this.context;
const packageName = await getProjectName();
const nocobaseVersion = require('@nocobase/server/package.json').version;
const packageVersion = await getProjectVersion();
return {
...this.context,
packageName: `@${packageName}/plugin-${name}`,
packageName: name,
packageVersion,
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() {
const { name } = this.context;
const target = resolve(process.cwd(), 'packages/plugins/', name);
@ -59,6 +70,9 @@ class PluginGenerator extends Generator {
path: join(__dirname, '../templates/plugin'),
});
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}`)}`);
}
}

View File

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

View File

@ -1,6 +1,6 @@
import { Plugin } from '@nocobase/client';
export class {{{pascalCaseName}}}Plugin extends Plugin {
export class {{{pascalCaseName}}}Client extends Plugin {
async afterAdd() {
// 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';
export class {{{pascalCaseName}}}Plugin extends Plugin {
export class {{{pascalCaseName}}}Server extends Plugin {
afterAdd() {}
beforeLoad() {}
@ -16,4 +16,4 @@ export class {{{pascalCaseName}}}Plugin extends Plugin {
async remove() {}
}
export default {{{pascalCaseName}}}Plugin;
export default {{{pascalCaseName}}}Server;

View File

@ -40,7 +40,7 @@ export class PluginManager {
private async initRemotePlugins() {
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 plugins = await getPlugins({
requirejs: this.app.requirejs,

View File

@ -10,7 +10,7 @@ import { Plugin } from '../Plugin';
describe('Application', () => {
beforeAll(() => {
const mock = new MockAdapter(axios);
mock.onGet('app:getPlugins').reply(200, {
mock.onGet('pm:listEnabled').reply(200, {
data: [],
});
});

View File

@ -6,7 +6,7 @@ import { Plugin } from '../Plugin';
describe('Plugin', () => {
beforeAll(() => {
const mock = new MockAdapter(axios);
mock.onGet('app:getPlugins').reply(200, {
mock.onGet('pm:listEnabled').reply(200, {
data: [],
});
});

View File

@ -11,7 +11,7 @@ describe('Router', () => {
let app: Application;
beforeAll(() => {
const mock = new MockAdapter(axios);
mock.onGet('app:getPlugins').reply(200, {
mock.onGet('pm:listEnabled').reply(200, {
data: [],
});
app = new Application();

View File

@ -21,13 +21,11 @@ export function getRemotePlugins(
baseURL = baseURL.slice(0, -4);
}
// for dynamic import `import()`
(window as any).staticBaseUrl = `/api/plugins/client`;
requirejs.requirejs.config({
waitSeconds: 120,
paths: pluginData.reduce<Record<string, string>>((memo, item) => {
memo[item.packageName] = `${baseURL}${item.url}`;
memo[`${item.packageName}/client`] = `${baseURL}${item.url}.js?client`;
memo[item.packageName] = `${baseURL}${item.url}?noExt`;
memo[`${item.packageName}/client`] = `${baseURL}${item.url}?noExtAndIsClient`;
return memo;
}, {}),
});

View File

@ -74,6 +74,8 @@ export default {
"Value":"Value",
"Disabled":"Disabled",
"Enabled":"Enabled",
"Problematic": "Problematic",
"Setting": "Setting",
'On':'On',
'Off':'Off',
"Empty":"Empty",
@ -653,6 +655,47 @@ export default {
"Local": "Local",
"Built-in": "Built-in",
"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...",
"All plugin settings": "All plugin settings",
"Bookmark": "Bookmark",

View File

@ -652,6 +652,46 @@ export default {
"Local": "Local",
"Built-in": "Integrado",
"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...",
"All plugin settings": "Configuración de todos los plugins",
"Bookmark": "Marcador",

View File

@ -649,6 +649,46 @@ export default {
"Local": "Local",
"Built-in": "Intégré",
"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...",
"All plugin settings": "Tous les paramètres de plugin",
"Bookmark": "Signet",

View File

@ -626,5 +626,45 @@ export default {
"Tag color field":"ラベルの色フィールド",
"Sync successfully":"同期成功",
"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",
"Built-in": "Integrado",
"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...",
"All plugin settings": "Configurações de todos os plugins",
"Bookmark": "Favoritar",

View File

@ -505,5 +505,46 @@ export default {
'Form data templates': "Шаблоны данных формы",
"Data template": "Шаблон данных",
"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',
'Form data templates': 'Form veri şablonları',
"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": "Локальний",
"Built-in": "Вбудований",
"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...": "Скоро буде...",
"All plugin settings": "Всі налаштування плагінів",
"Bookmark": "Закладка",

View File

@ -80,6 +80,7 @@ export default {
Value: '字段值',
Disabled: '禁用',
Enabled: '启用',
Problematic: '有问题',
Empty: '赋空值',
'Linkage rule': '联动规则',
'Linkage rules': '联动规则',
@ -708,7 +709,50 @@ export default {
'Plugin manager': '插件管理器',
Local: '本地',
'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...': '敬请期待...',
'All plugin settings': '所有插件配置',
Bookmark: '书签',

View File

@ -132,12 +132,21 @@ const getProps = (app: Application) => {
upgrade: {
title: 'App upgrading',
},
'pm.add': {
title: 'Adding plugin',
},
'pm.update': {
title: 'Updating plugin',
},
'pm.enable': {
title: 'Enabling plugin',
},
'pm.disable': {
title: 'Disabling plugin',
},
'pm.remove': {
title: 'Removing plugin',
},
};
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 { 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 React from 'react';
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 { useStyles } from './style';
import { PMProvider, SettingsCenter } from './PluginSetting';
import { PluginManager } from './PluginManager';
export interface TData {
data: IPluginData[];
meta: IMetaData;
}
export * from './PluginManagerLink';
export * from './PluginSetting';
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 {
async load() {
this.addComponents();
@ -379,15 +25,15 @@ export class PMPlugin extends Plugin {
addRoutes() {
this.app.router.add('admin.pm.list', {
path: '/admin/pm/list',
element: <PluginList />,
element: <PluginManager />,
});
this.app.router.add('admin.pm.list-tab', {
path: '/admin/pm/list/:tabName',
element: <PluginList />,
element: <PluginManager />,
});
this.app.router.add('admin.pm.list-tab-mdfile', {
path: '/admin/pm/list/:tabName/:mdfile',
element: <PluginList />,
element: <PluginManager />,
});
this.app.router.add('admin.settings.list', {

View File

@ -2,9 +2,13 @@ import { createStyles } from 'antd-style';
export const useStyles = createStyles(({ token }) => {
return {
cardActionDisabled: {
color: token.colorTextDisabled,
cursor: 'not-allowed',
},
pageHeader: {
backgroundColor: token.colorBgContainer,
paddingBottom: 0,
backgroundColor: token.colorBgContainer,
paddingTop: token.paddingSM,
paddingInline: token.paddingLG,
'.ant-page-header-footer': { marginBlockStart: '0' },
@ -15,22 +19,20 @@ export const useStyles = createStyles(({ token }) => {
color: token.colorText,
},
},
pageContent: {
margin: token.marginLG,
},
// pageContent: {
// marginTop: token.margin,
// marginBottom: token.marginLG,
// background: 'transparent',
// minHeight: '80vh',
// },
PluginDetail: {
'.ant-modal-header': { paddingBottom: token.paddingXS },
'.ant-modal-body': { paddingTop: 0 },
'.ant-modal-content': {
'.plugin-desc': { paddingBottom: token.paddingXS },
},
'.version-tag': {
verticalAlign: 'middle',
marginTop: -token.marginXXS,
marginLeft: token.marginXS,
},
PluginDetailBaseInfo: {
display: 'flex',
flexDirection: 'column',
marginBottom: token.margin,
},
PluginDocument: {
@ -40,13 +42,6 @@ export const useStyles = createStyles(({ token }) => {
overflowY: 'auto',
},
CommonCard: {
width: `calc(20% - ${token.marginLG}px)`,
marginRight: token.marginLG,
marginBottom: token.marginLG,
transition: 'all 0.35s ease-in-out',
},
avatar: {
'.ant-card-meta-avatar': {
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 { Layout } from 'antd';
import { useSessionStorageState } from 'ahooks';
import { App, Layout } from 'antd';
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 {
ACLRolesCheckProvider,
CurrentAppInfoProvider,
@ -60,6 +61,8 @@ const useMenuProps = () => {
};
const MenuEditor = (props) => {
const { notification } = App.useApp();
const [hasNotice, setHasNotice] = useSessionStorageState('plugin-notice', { defaultValue: false });
const { setTitle } = useDocumentTitle();
const navigate = useNavigate();
const params = useParams<any>();
@ -140,6 +143,45 @@ const MenuEditor = (props) => {
}
return s;
}, [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) {
return render();
}

View File

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

View File

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

View File

@ -46,7 +46,8 @@
"ts-node-dev": "1.1.8",
"tsconfig-paths": "^3.12.0",
"typescript": "5.1.3",
"umi": "^4.0.69"
"umi": "^4.0.69",
"fast-glob": "^3.3.1"
},
"repository": {
"type": "git",

View File

@ -2,7 +2,7 @@ const { existsSync } = require('fs');
const { resolve, sep } = require('path');
const packageJson = require('./package.json');
const fs = require('fs');
const glob = require('glob');
const glob = require('fast-glob');
const path = require('path');
console.log('VERSION: ', packageJson.version);
@ -11,7 +11,8 @@ function getUmiConfig() {
const { APP_PORT, API_BASE_URL } = process.env;
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 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() {
if (LOCAL_STORAGE_BASE_URL.startsWith('http')) {
@ -23,6 +24,10 @@ function getUmiConfig() {
target: PROXY_TARGET_URL,
changeOrigin: true,
},
[STATIC_PATH]: {
target: PROXY_TARGET_URL,
changeOrigin: true,
},
};
}
@ -68,24 +73,25 @@ function getPackagePaths() {
const pkgs = [];
for (const key in paths) {
if (Object.hasOwnProperty.call(paths, key)) {
const dir = paths[key][0];
if (dir.includes('*')) {
const files = glob.sync(dir);
for (const file of files) {
const dirname = resolve(process.cwd(), file);
if (existsSync(dirname)) {
const re = new RegExp(dir.replace('*', '(.+)'));
const p = dirname
.substring(process.cwd().length + 1)
.split(sep)
.join('/');
const match = re.exec(p);
pkgs.push([key.replace('*', match?.[1]), dirname]);
for (let dir of paths[key]) {
if (dir.includes('*')) {
const files = glob.sync(dir, { cwd: process.cwd(), onlyDirectories: true });
for (const file of files) {
const dirname = resolve(process.cwd(), file);
if (existsSync(dirname)) {
const re = new RegExp(dir.replace('*', '(.+)'));
const p = dirname
.substring(process.cwd().length + 1)
.split(sep)
.join('/');
const match = re.exec(p);
pkgs.push([key.replace('*', match?.[1]), dirname]);
}
}
} else {
const dirname = resolve(process.cwd(), dir);
pkgs.push([key, dirname]);
}
} else {
const dirname = resolve(process.cwd(), dir);
pkgs.push([key, dirname]);
}
}
}
@ -157,13 +163,13 @@ export default function devDynamicImport(packageName: string): Promise<any> {
fs.rmdirSync(this.outputPath, { recursive: true, force: true });
}
fs.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') {
fs.writeFileSync(this.indexPath, this.emptyIndexContent);
return;
}
const pluginInfos = validPluginPaths.map((pluginPath) => this.getContent(pluginPath)).flat();
const pluginInfos = validPluginPaths.map((pluginsPath) => this.getContent(pluginsPath)).flat();
// index.ts
fs.writeFileSync(this.indexPath, this.indexContent);
@ -181,21 +187,23 @@ export default function devDynamicImport(packageName: string): Promise<any> {
});
}
getContent(pluginPath) {
const pluginFolders = fs.readdirSync(pluginPath);
getContent(pluginsPath) {
const pluginFolders = glob
.sync(['*/package.json', '*/*/package.json'], { cwd: pluginsPath, onlyFiles: true, absolute: true })
.map((item) => path.dirname(item));
const pluginInfos = pluginFolders
.filter((folder) => {
const pluginPackageJsonPath = path.join(pluginPath, folder, 'package.json');
const pluginSrcClientPath = path.join(pluginPath, folder, 'src', 'client');
const pluginPackageJsonPath = path.join(folder, 'package.json');
const pluginSrcClientPath = path.join(folder, 'src', 'client');
return fs.existsSync(pluginPackageJsonPath) && fs.existsSync(pluginSrcClientPath);
})
.map((folder) => {
const pluginPackageJsonPath = path.join(pluginPath, folder, 'package.json');
const pluginPackageJsonPath = path.join(folder, 'package.json');
const pluginPackageJson = require(pluginPackageJsonPath);
const pluginSrcClientPath = path
.relative(this.packagesPath, path.join(pluginPath, folder, 'src', 'client'))
.relative(this.packagesPath, path.join(folder, 'src', 'client'))
.replaceAll('\\', '/');
const pluginFileName = `${path.basename(pluginPath)}_${folder.replaceAll('-', '_')}`;
const pluginFileName = `${path.basename(pluginsPath)}_${path.basename(folder).replaceAll('-', '_')}`;
const exportStatement = `export { default } from '${pluginSrcClientPath}';`;
return { exportStatement, pluginFileName, packageJsonName: pluginPackageJson.name };
});

View File

@ -37,6 +37,11 @@
"multer": "^1.4.2",
"nanoid": "3.3.4",
"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",
"ws": "^8.13.0",
"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;
if (
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', {
refresh: true,

View File

@ -4,7 +4,7 @@ import { actions as authActions, AuthManager } from '@nocobase/auth';
import { Cache, createCache, ICacheConfig } from '@nocobase/cache';
import Database, { CollectionOptions, IDatabaseOptions } from '@nocobase/database';
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 chalk from 'chalk';
import { Command, CommandOptions, ParseOptions } from 'commander';
@ -117,6 +117,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
private _maintaining = false;
private _maintainingCommandStatus: MaintainingCommandStatus;
private _maintainingStatusBeforeCommand: MaintainingCommandStatus | null;
private _actionCommand: Command;
constructor(public options: ApplicationOptions) {
super();
@ -303,7 +304,9 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
this.log.info(`app.reload()`);
const oldDb = this._db;
this.init();
await oldDb.close();
if (!oldDb.closed()) {
await oldDb.close();
}
}
this.setMaintainingMessage('init plugins');
@ -363,6 +366,8 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
const command = new Command('nocobase')
.usage('[command] [options]')
.hook('preAction', async (_, actionCommand) => {
this._actionCommand = actionCommand;
this.activatedCommand = {
name: getCommandFullName(actionCommand),
};
@ -426,7 +431,13 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
throw error;
}
} finally {
const _actionCommand = this._actionCommand;
if (_actionCommand) {
_actionCommand['_optionValues'] = {};
_actionCommand['_optionValueSources'] = {};
}
this.activatedCommand = null;
this._actionCommand = null;
}
}
@ -454,11 +465,15 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
this.setMaintainingMessage('emit afterStart');
await this.emitAsync('afterStart', this, options);
await this.emitStartedEvent();
this.stopped = false;
}
async emitStartedEvent() {
await this.emitAsync('__started', this, {
maintainingStatus: lodash.cloneDeep(this._maintainingCommandStatus),
});
this.stopped = false;
}
async isStarted() {
@ -477,7 +492,9 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
if (!this._started) {
return;
}
this._started = false;
await this.emitAsync('beforeStop');
await this.reload(options);
await this.start(options);
this.emit('__restarted', this, options);
@ -505,6 +522,7 @@ export class Application<StateT = DefaultState, ContextT = DefaultContext> exten
}
await this.emitAsync('afterStop', this, options);
this.stopped = true;
this.log.info(`${this.name} is stopped`);
this._started = false;

View File

@ -1,3 +1,4 @@
import _ from 'lodash';
import Application from '../application';
import { PluginCommandError } from '../errors/plugin-command-error';
@ -11,9 +12,35 @@ export default (app: Application) => {
});
pm.command('add')
.arguments('plugin')
.action(async (plugin) => {
await app.pm.add(plugin, {}, true);
.argument('<pkg>')
.option('--registry [registry]')
.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')

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 compression from 'compression';
import { EventEmitter } from 'events';
import fs from 'fs';
import http, { IncomingMessage, ServerResponse } from 'http';
import { promisify } from 'node:util';
import { resolve } from 'path';
@ -10,6 +13,7 @@ import { parse } from 'url';
import xpipe from 'xpipe';
import { AppSupervisor } from '../app-supervisor';
import { ApplicationOptions } from '../application';
import { PLUGIN_STATICS_PATH, getPackageDirByExposeUrl, getPackageNameByExposeUrl } from '../plugin-manager';
import { applyErrorWithArgs, getErrorWithCode } from './errors';
import { IPCSocketClient } from './ipc-socket-client';
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);
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, {
public: resolve(process.cwd(), 'node_modules'),
public: publicDir,
rewrites: [
{
source: '/api/plugins/client/:plugin/:file',
destination: '/:plugin/dist/client/:file',
},
{
source: '/api/plugins/client/@:org/:plugin/:file',
destination: '/@:org/:plugin/dist/client/:file',
source: pathname,
destination,
},
],
});
@ -192,9 +199,23 @@ export class Gateway extends EventEmitter {
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) {
const isStart = this.isStart();
let ipcClient: IPCSocketClient | false;
if (isStart) {
await this.watch();
const startOptions = this.getStartOptions();
const port = startOptions.port || process.env.APP_PORT || 13000;
const host = startOptions.host || process.env.APP_HOST || '0.0.0.0';
@ -204,7 +225,7 @@ export class Gateway extends EventEmitter {
host,
});
} else if (!this.isHelp()) {
const ipcClient = await this.tryConnectToIPCServer();
ipcClient = await this.tryConnectToIPCServer();
if (ipcClient) {
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);
mainApp

View File

@ -1,9 +1,12 @@
import cors from '@koa/cors';
import Database from '@nocobase/database';
import { Resourcer } from '@nocobase/resourcer';
import { uid } from '@nocobase/utils';
import { Command } from 'commander';
import fs from 'fs';
import i18next from 'i18next';
import bodyParser from 'koa-bodyparser';
import { resolve } from 'path';
import Application, { ApplicationOptions } from './application';
import { parseVariables } from './middlewares';
import { dateTemplate } from './middlewares/data-template';
@ -111,3 +114,8 @@ export const getCommandFullName = (command: Command) => {
}
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';

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',
fields: [
{ type: 'string', name: 'name', unique: true },
{ type: 'string', name: 'packageName', unique: true },
{ type: 'string', name: 'version' },
{ type: 'boolean', name: 'enabled' },
{ 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 { getExposeUrl } from '../clientStaticUtils';
import PluginManager from '../plugin-manager';
export default {
name: 'pm',
actions: {
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;
if (!filterByTk) {
ctx.throw(400, 'plugin name invalid');
}
await pm.add(filterByTk);
ctx.body = filterByTk;
const pm = ctx.app.pm;
ctx.body = await pm.getNpmVersionList(filterByTk);
await next();
},
async enable(ctx, next) {
const pm = ctx.app.pm;
const { filterByTk } = ctx.action.params;
const app = ctx.app as Application;
if (!filterByTk) {
@ -25,7 +91,6 @@ export default {
await next();
},
async disable(ctx, next) {
const pm = ctx.app.pm;
const { filterByTk } = ctx.action.params;
if (!filterByTk) {
ctx.throw(400, 'plugin name invalid');
@ -35,12 +100,7 @@ export default {
ctx.body = filterByTk;
await next();
},
async upgrade(ctx, next) {
ctx.body = 'ok';
await next();
},
async remove(ctx, next) {
const pm = ctx.app.pm;
const { filterByTk } = ctx.action.params;
if (!filterByTk) {
ctx.throw(400, 'plugin name invalid');
@ -50,5 +110,45 @@ export default {
ctx.body = filterByTk;
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 lodash from 'lodash';
import { PluginManager } from './plugin-manager';
import { PluginData } from './types';
export class PluginManagerRepository extends Repository {
pm: PluginManager;
@ -47,6 +48,15 @@ export class PluginManagerRepository extends Repository {
return pluginNames;
}
async upgrade(name: string, data: PluginData) {
return this.update({
filter: {
name,
},
values: data,
});
}
async disable(name: string | string[]) {
name = lodash.cloneDeep(name);
@ -74,6 +84,7 @@ export class PluginManagerRepository extends Repository {
sort: 'id',
});
} catch (error) {
await this.database.migrator.up();
await this.collection.sync({
alter: {
drop: false,

View File

@ -1,15 +1,28 @@
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 _ from 'lodash';
import net from 'net';
import { resolve } from 'path';
import { resolve, sep } from 'path';
import Application from '../application';
import { createAppProxy } from '../helper';
import { createAppProxy, tsxRerunning } from '../helper';
import { Plugin } from '../plugin';
import { uploadMiddleware } from './middleware';
import collectionOptions from './options/collection';
import resourceOptions from './options/resource';
import { PluginManagerRepository } from './plugin-manager-repository';
import { PluginData } from './types';
import {
copyTempPackageToStorageAndLinkToNodeModules,
downloadAndUnzipToTempDir,
getNpmInfo,
getPluginInfoByNpm,
removeTmpDir,
requireModule,
requireNoCache,
updatePluginByCompressedFileUrl,
} from './utils';
export interface PluginManagerOptions {
app: Application;
@ -43,31 +56,16 @@ export class PluginManager {
this._repository = this.collection.repository as PluginManagerRepository;
this._repository.setPluginManager(this);
this.app.resourcer.define(resourceOptions);
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.allow('pm', 'listEnabled', 'public');
this.app.acl.registerSnippet({
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() {
@ -75,7 +73,7 @@ export class PluginManager {
}
static getPackageJson(packageName: string) {
return require(`${packageName}/package.json`);
return requireNoCache(`${packageName}/package.json`);
}
static getPackageName(name: string) {
@ -121,9 +119,19 @@ export class PluginManager {
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') {
const packageName = this.getPackageName(pluginName);
const packageName = isPkg ? pluginName : this.getPackageName(pluginName);
this.clearCache(packageName);
return requireModule(packageName);
} else {
return pluginName;
@ -170,10 +178,8 @@ export class PluginManager {
}
}
async create(name: string | string[]) {
async create(pluginName: string) {
console.log('creating...');
const pluginNames = Array.isArray(name) ? name : [name];
const { run } = require('@nocobase/cli/src/util');
const createPlugin = async (name) => {
const { PluginGenerator } = require('@nocobase/cli/src/plugin-generator');
const generator = new PluginGenerator({
@ -185,12 +191,21 @@ export class PluginManager {
});
await generator.run();
};
await Promise.all(pluginNames.map((pluginName) => createPlugin(pluginName)));
await run('yarn', ['install']);
await createPlugin(pluginName);
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) {
if (this.has(plugin)) {
async add(plugin?: any, options: any = {}, insert = false, isUpgrade = false) {
if (!isUpgrade && this.has(plugin)) {
const name = typeof plugin === 'string' ? plugin : plugin.name;
this.app.log.warn(`plugin [${name}] added`);
return;
@ -198,10 +213,24 @@ export class PluginManager {
if (!options.name && typeof plugin === 'string') {
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}]...`);
let P: any;
try {
P = PluginManager.resolvePlugin(plugin);
P = PluginManager.resolvePlugin(options.packageName || plugin, isUpgrade, !!options.packageName);
} catch (error) {
this.app.log.warn('plugin not found', error);
return;
@ -212,12 +241,9 @@ export class PluginManager {
this.pluginAliases.set(options.name, instance);
}
if (insert && options.name) {
const packageName = PluginManager.getPackageName(options.name);
const packageJson = PluginManager.getPackageJson(packageName);
await this.repository.updateOrCreate({
values: {
...options,
version: packageJson.version,
},
filterKeys: ['name'],
});
@ -451,7 +477,7 @@ export class PluginManager {
for (const pluginName of pluginNames) {
const plugin = this.get(pluginName);
if (!plugin) {
throw new Error(`${pluginName} plugin does not exist`);
continue;
}
if (plugin.enabled) {
throw new Error(`${pluginName} plugin is enabled`);
@ -466,13 +492,20 @@ export class PluginManager {
const plugins: Plugin[] = [];
for (const pluginName of pluginNames) {
const plugin = this.get(pluginName);
if (!plugin) {
continue;
}
plugins.push(plugin);
this.del(pluginName);
// if (plugin.options.type && plugin.options.packageName) {
// await removePluginPackage(plugin.options.packageName);
// }
}
await this.app.reload();
for (const plugin of plugins) {
await plugin.afterRemove();
}
await this.app.emitStartedEvent();
}
protected async initPresetPlugins() {
@ -481,6 +514,200 @@ export class PluginManager {
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;

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 fs from 'fs';
import { resolve } from 'path';
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 {
beforeLoad?: () => void;
@ -104,6 +107,30 @@ export abstract class Plugin<O = any> implements PluginInterface {
requiredPlugins() {
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;

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 './toposort';
export * from './uid';
export * from './url';
export { dayjs, lodash };

View File

@ -7,3 +7,4 @@ export * from './registry';
export * from './requireModule';
export * from './toposort';
export * from './uid';
export * from './url';

Some files were not shown because too many files have changed in this diff Show More