From d7dc8fa4cf39c5cb4508fde76c405812ff509d40 Mon Sep 17 00:00:00 2001 From: chenos Date: Sun, 15 Sep 2024 01:37:46 +0800 Subject: [PATCH] feat: simplify the process of adding and updating plugins (#5275) * feat: auto download pro * feat: improve code * feat: improve code * feat: improve code * feat: improve code * fix: test error * fix: improve code * fix: yarn install error * fix: build error * fix: generatePlugins * fix: test error * fix: download pro command * fix: run error * feat: version * fix: require packageJson * fix: improve code * feat: improve code * fix: improve code * fix: test error * fix: test error * fix: improve code * fix: removable * fix: error * fix: error --- .gitignore | 1 + packages/core/app/client/.umirc.ts | 12 +- packages/core/cli/src/commands/dev.js | 25 +- packages/core/cli/src/commands/global.js | 12 +- packages/core/cli/src/commands/index.js | 1 + packages/core/cli/src/commands/pkg.js | 218 +++++++++++++ packages/core/cli/src/commands/postinstall.js | 4 +- packages/core/cli/src/commands/start.js | 20 +- packages/core/cli/src/commands/upgrade.js | 11 +- packages/core/cli/src/plugin-generator.js | 2 +- packages/core/cli/src/util.js | 14 + .../core/client/src/api-client/APIClient.ts | 4 +- packages/core/client/src/pm/PluginCard.tsx | 104 ++++--- packages/core/client/src/pm/PluginDetail.tsx | 30 +- .../pm/PluginForm/form/PluginUploadForm.tsx | 36 ++- .../pm/PluginForm/modal/PluginAddModal.tsx | 2 +- packages/core/client/src/pm/PluginManager.tsx | 2 +- packages/core/client/src/pm/types.ts | 1 + packages/core/devtools/umiConfig.d.ts | 2 + packages/core/devtools/umiConfig.js | 36 ++- packages/core/server/src/__tests__/pm.test.ts | 154 ++++++--- packages/core/server/src/commands/start.ts | 15 +- .../src/plugin-manager/options/resource.ts | 8 +- .../plugin-manager-repository.ts | 21 +- .../src/plugin-manager/plugin-manager.ts | 294 +++++++----------- .../server/__tests__/collection-sync.test.ts | 4 +- .../src/server/plugin.ts | 2 +- packages/presets/nocobase/package.json | 54 +++- .../nocobase/src/server/findPackageNames.ts | 114 +++++++ packages/presets/nocobase/src/server/index.ts | 150 +++++---- 30 files changed, 942 insertions(+), 411 deletions(-) create mode 100644 packages/core/cli/src/commands/pkg.js create mode 100644 packages/presets/nocobase/src/server/findPackageNames.ts diff --git a/.gitignore b/.gitignore index e671380205..a2e9662b72 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ storage/plugins storage/tar storage/tmp storage/app.watch.ts +storage/.upgrading storage/logs-e2e storage/uploads-e2e storage/.pm2-* diff --git a/packages/core/app/client/.umirc.ts b/packages/core/app/client/.umirc.ts index c1cc4b7a7f..e070635481 100644 --- a/packages/core/app/client/.umirc.ts +++ b/packages/core/app/client/.umirc.ts @@ -1,4 +1,4 @@ -import { getUmiConfig, IndexGenerator } from '@nocobase/devtools/umiConfig'; +import { generatePlugins, getUmiConfig } from '@nocobase/devtools/umiConfig'; import path from 'path'; import { defineConfig } from 'umi'; @@ -8,17 +8,11 @@ process.env.MFSU_AD = 'none'; process.env.DID_YOU_KNOW = 'none'; const pluginPrefix = (process.env.PLUGIN_PACKAGE_PREFIX || '').split(',').filter((item) => !item.includes('preset')); // 因为现在 preset 是直接引入的,所以不能忽略,如果以后 preset 也是动态插件的形式引入,那么这里可以去掉 - -const pluginDirs = (process.env.PLUGIN_PATH || 'packages/plugins/,packages/samples/,packages/pro-plugins/') - .split(',').map(item => path.join(process.cwd(), item)); - -const outputPluginPath = path.join(__dirname, 'src', '.plugins'); -const indexGenerator = new IndexGenerator(outputPluginPath, pluginDirs); -indexGenerator.generate(); - const isDevCmd = !!process.env.IS_DEV_CMD; const appPublicPath = isDevCmd ? '/' : '{{env.APP_PUBLIC_PATH}}'; +generatePlugins(); + export default defineConfig({ title: 'Loading...', devtool: process.env.NODE_ENV === 'development' ? 'source-map' : false, diff --git a/packages/core/cli/src/commands/dev.js b/packages/core/cli/src/commands/dev.js index d830f0b3cf..8a805adb60 100644 --- a/packages/core/cli/src/commands/dev.js +++ b/packages/core/cli/src/commands/dev.js @@ -9,8 +9,12 @@ const chalk = require('chalk'); const { Command } = require('commander'); -const { runAppCommand, runInstall, run, postCheck, nodeCheck, promptForTs } = require('../util'); +const { generatePlugins, run, postCheck, nodeCheck, promptForTs } = require('../util'); const { getPortPromise } = require('portfinder'); +const chokidar = require('chokidar'); +const { uid } = require('@formily/shared'); +const path = require('path'); +const fs = require('fs'); /** * @@ -27,6 +31,25 @@ module.exports = (cli) => { .option('--inspect [port]') .allowUnknownOption() .action(async (opts) => { + const watcher = chokidar.watch('./storage/plugins/**/*', { + cwd: process.cwd(), + ignored: /(^|[\/\\])\../, // 忽略隐藏文件 + persistent: true, + depth: 1, // 只监听第一层目录 + }); + + watcher + .on('addDir', async (pathname) => { + generatePlugins(); + const file = path.resolve(process.cwd(), 'storage/app.watch.ts'); + await fs.promises.writeFile(file, `export const watchId = '${uid()}';`, 'utf-8'); + }) + .on('unlinkDir', async (pathname) => { + generatePlugins(); + const file = path.resolve(process.cwd(), 'storage/app.watch.ts'); + await fs.promises.writeFile(file, `export const watchId = '${uid()}';`, 'utf-8'); + }); + promptForTs(); const { SERVER_TSCONFIG_PATH } = process.env; process.env.IS_DEV_CMD = true; diff --git a/packages/core/cli/src/commands/global.js b/packages/core/cli/src/commands/global.js index 526c22ecfe..524ad3cd09 100644 --- a/packages/core/cli/src/commands/global.js +++ b/packages/core/cli/src/commands/global.js @@ -8,7 +8,7 @@ */ const { Command } = require('commander'); -const { run, isDev, isProd, promptForTs } = require('../util'); +const { run, isDev, isProd, promptForTs, downloadPro } = require('../util'); /** * @@ -20,10 +20,14 @@ module.exports = (cli) => { .allowUnknownOption() .option('-h, --help') .option('--ts-node-dev') - .action((options) => { + .action(async (options) => { + const cmd = process.argv.slice(2)?.[0]; + if (cmd === 'install') { + await downloadPro(); + } if (isDev()) { promptForTs(); - run('tsx', [ + await run('tsx', [ '--tsconfig', SERVER_TSCONFIG_PATH, '-r', @@ -32,7 +36,7 @@ module.exports = (cli) => { ...process.argv.slice(2), ]); } else if (isProd()) { - run('node', [`${APP_PACKAGE_ROOT}/lib/index.js`, ...process.argv.slice(2)]); + await run('node', [`${APP_PACKAGE_ROOT}/lib/index.js`, ...process.argv.slice(2)]); } }); }; diff --git a/packages/core/cli/src/commands/index.js b/packages/core/cli/src/commands/index.js index 7f12331d9a..45daf3c483 100644 --- a/packages/core/cli/src/commands/index.js +++ b/packages/core/cli/src/commands/index.js @@ -31,6 +31,7 @@ module.exports = (cli) => { require('./umi')(cli); require('./upgrade')(cli); require('./postinstall')(cli); + require('./pkg')(cli); if (isPackageValid('@umijs/utils')) { require('./create-plugin')(cli); } diff --git a/packages/core/cli/src/commands/pkg.js b/packages/core/cli/src/commands/pkg.js new file mode 100644 index 0000000000..1ca981cffa --- /dev/null +++ b/packages/core/cli/src/commands/pkg.js @@ -0,0 +1,218 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +const { Command } = require('commander'); +const axios = require('axios'); +const fs = require('fs-extra'); +const zlib = require('zlib'); +const tar = require('tar'); +const path = require('path'); +const { createStoragePluginsSymlink } = require('@nocobase/utils/plugin-symlink'); +const chalk = require('chalk'); + +class Package { + data; + constructor(packageName, packageManager) { + this.packageName = packageName; + this.packageManager = packageManager; + this.outputDir = path.resolve(process.cwd(), `storage/plugins/${this.packageName}`); + } + + get token() { + return this.packageManager.getToken(); + } + + url(path) { + return this.packageManager.url(path); + } + + async mkdir() { + if (await fs.exists(this.outputDir)) { + await fs.rm(this.outputDir, { recursive: true, force: true }); + } + await fs.mkdirp(this.outputDir); + } + + async getInfo() { + try { + const res = await axios.get(this.url(this.packageName), { + headers: { + Authorization: `Bearer ${this.token}`, + }, + responseType: 'json', + }); + this.data = res.data; + } catch (error) { + return; + } + } + + getTarball(version = 'latest') { + if (this.data.versions[version]) { + return [version, this.data.versions[version].dist.tarball]; + } + + if (version.includes('beta')) { + version = version.split('beta')[0] + 'beta'; + } else if (version.includes('alpha')) { + const prefix = (version = version.split('alpha')[0]); + version = Object.keys(this.data.versions) + .filter((ver) => ver.startsWith(`${prefix}alpha`)) + .sort() + .pop(); + } + + if (version === 'latest') { + version = this.data['dist-tags']['latest']; + } else if (version === 'next') { + version = this.data['dist-tags']['next']; + } + + if (!this.data.versions[version]) { + console.log(chalk.redBright(`Download failed: ${this.packageName}@${version} package does not exist`)); + } + + return [version, this.data.versions[version].dist.tarball]; + } + + async isDevPackage() { + let file = path.resolve(process.cwd(), 'packages/plugins', this.packageName, 'package.json'); + if (await fs.exists(file)) { + return true; + } + file = path.resolve(process.cwd(), 'packages/pro-plugins', this.packageName, 'package.json'); + if (await fs.exists(file)) { + return true; + } + return false; + } + + async download(options = {}) { + if (await this.isDevPackage()) { + console.log(chalk.yellowBright(`Skipped: ${this.packageName} is dev package`)); + return; + } + await this.getInfo(); + if (!this.data) { + console.log(chalk.redBright(`Download failed: ${this.packageName} package does not exist`)); + return; + } + try { + const [version, url] = this.getTarball(options.version); + const response = await axios({ + url, + responseType: 'stream', + method: 'GET', + headers: { + Authorization: `Bearer ${this.token}`, + }, + }); + await this.mkdir(); + await new Promise((resolve, reject) => { + response.data + .pipe(zlib.createGunzip()) // 解压 gzip + .pipe(tar.extract({ cwd: this.outputDir, strip: 1 })) // 解压 tar + .on('finish', resolve) + .on('error', reject); + }); + console.log(chalk.greenBright(`Download success: ${this.packageName}@${version}`)); + } catch (error) { + console.log(chalk.redBright(`Download failed: ${this.packageName}`)); + } + } +} + +class PackageManager { + token; + baseURL; + + constructor({ baseURL }) { + this.baseURL = baseURL; + } + + getToken() { + return this.token; + } + + getBaseURL() { + return this.baseURL; + } + + url(path) { + return this.baseURL + path; + } + + async login(credentials) { + try { + const res1 = await axios.post(`${this.baseURL}-/verdaccio/sec/login`, credentials, { + responseType: 'json', + }); + this.token = res1.data.token; + } catch (error) { + console.error(chalk.redBright(`Login failed: ${this.baseURL}`)); + } + } + + getPackage(packageName) { + return new Package(packageName, this); + } + + async getProPackages() { + const res = await axios.get(this.url('pro-packages'), { + headers: { + Authorization: `Bearer ${this.token}`, + }, + responseType: 'json', + }); + return res.data.data; + } + + async getPackages() { + const pkgs = await this.getProPackages(); + return pkgs; + } + + async download(options = {}) { + const { version } = options; + if (!this.token) { + return; + } + const pkgs = await this.getPackages(); + for (const pkg of pkgs) { + await this.getPackage(pkg).download({ version }); + } + } +} + +/** + * + * @param {Command} cli + */ +module.exports = (cli) => { + const pkg = cli.command('pkg'); + pkg + .command('download-pro') + .option('-V, --version [version]') + .action(async () => { + const { NOCOBASE_PKG_URL, NOCOBASE_PKG_USERNAME, NOCOBASE_PKG_PASSWORD } = process.env; + if (!(NOCOBASE_PKG_URL && NOCOBASE_PKG_USERNAME && NOCOBASE_PKG_PASSWORD)) { + return; + } + const credentials = { username: NOCOBASE_PKG_USERNAME, password: NOCOBASE_PKG_PASSWORD }; + const pm = new PackageManager({ baseURL: NOCOBASE_PKG_URL }); + await pm.login(credentials); + const file = path.resolve(__dirname, '../../package.json'); + const json = await fs.readJson(file); + await pm.download({ version: json.version }); + await createStoragePluginsSymlink(); + }); + pkg.command('export-all').action(async () => { + console.log('Todo...'); + }); +}; diff --git a/packages/core/cli/src/commands/postinstall.js b/packages/core/cli/src/commands/postinstall.js index 539afdf8b7..cbe7bf48e6 100644 --- a/packages/core/cli/src/commands/postinstall.js +++ b/packages/core/cli/src/commands/postinstall.js @@ -8,7 +8,7 @@ */ const { Command } = require('commander'); -const { run, isDev, isPackageValid, generatePlaywrightPath } = require('../util'); +const { run, isDev, isPackageValid, generatePlaywrightPath, generatePlugins } = require('../util'); const { dirname, resolve } = require('path'); const { existsSync, mkdirSync, readFileSync, appendFileSync } = require('fs'); const { readFile, writeFile } = require('fs').promises; @@ -41,7 +41,7 @@ module.exports = (cli) => { .option('--skip-umi') .action(async (options) => { writeToExclude(); - + generatePlugins(); generatePlaywrightPath(true); await createStoragePluginsSymlink(); if (!isDev()) { diff --git a/packages/core/cli/src/commands/start.js b/packages/core/cli/src/commands/start.js index 2437ea3dfb..467acd19bf 100644 --- a/packages/core/cli/src/commands/start.js +++ b/packages/core/cli/src/commands/start.js @@ -8,10 +8,11 @@ */ const { Command } = require('commander'); -const { isDev, run, postCheck, runInstall, promptForTs } = require('../util'); +const { isDev, run, postCheck, downloadPro, promptForTs } = require('../util'); const { existsSync, rmSync } = require('fs'); const { resolve } = require('path'); const chalk = require('chalk'); +const chokidar = require('chokidar'); function deleteSockFiles() { const { SOCKET_PATH, PM2_HOME } = process.env; @@ -38,6 +39,23 @@ module.exports = (cli) => { .option('--quickstart') .allowUnknownOption() .action(async (opts) => { + if (opts.quickstart) { + await downloadPro(); + } + + const watcher = chokidar.watch('./storage/plugins/**/*', { + cwd: process.cwd(), + ignoreInitial: true, + ignored: /(^|[\/\\])\../, // 忽略隐藏文件 + persistent: true, + depth: 1, // 只监听第一层目录 + }); + + watcher.on('addDir', async (pathname) => { + console.log('pathname', pathname); + await run('yarn', ['nocobase', 'pm2-restart']); + }); + if (opts.port) { process.env.APP_PORT = opts.port; } diff --git a/packages/core/cli/src/commands/upgrade.js b/packages/core/cli/src/commands/upgrade.js index a36c0ef1fe..2e0fcb20a7 100644 --- a/packages/core/cli/src/commands/upgrade.js +++ b/packages/core/cli/src/commands/upgrade.js @@ -10,7 +10,7 @@ const chalk = require('chalk'); const { Command } = require('commander'); const { resolve } = require('path'); -const { run, promptForTs, runAppCommand, hasCorePackages, updateJsonFile, hasTsNode } = require('../util'); +const { run, promptForTs, runAppCommand, hasCorePackages, downloadPro, hasTsNode } = require('../util'); const { existsSync, rmSync } = require('fs'); /** @@ -29,15 +29,18 @@ module.exports = (cli) => { if (hasTsNode()) promptForTs(); if (hasCorePackages()) { // await run('yarn', ['install']); + await downloadPro(); await runAppCommand('upgrade'); return; } if (options.skipCodeUpdate) { + await downloadPro(); await runAppCommand('upgrade'); return; } // await runAppCommand('upgrade'); if (!hasTsNode()) { + await downloadPro(); await runAppCommand('upgrade'); return; } @@ -54,8 +57,9 @@ module.exports = (cli) => { stdio: 'pipe', }); if (pkg.version === stdout) { + await downloadPro(); await runAppCommand('upgrade'); - rmAppDir(); + await rmAppDir(); return; } const currentY = 1 * pkg.version.split('.')[1]; @@ -66,7 +70,8 @@ module.exports = (cli) => { await run('yarn', ['add', '@nocobase/cli', '@nocobase/devtools', '-W']); } await run('yarn', ['install']); + await downloadPro(); await runAppCommand('upgrade'); - rmAppDir(); + await rmAppDir(); }); }; diff --git a/packages/core/cli/src/plugin-generator.js b/packages/core/cli/src/plugin-generator.js index a1d77c28eb..29c0c97913 100644 --- a/packages/core/cli/src/plugin-generator.js +++ b/packages/core/cli/src/plugin-generator.js @@ -72,7 +72,7 @@ class PluginGenerator extends Generator { }); this.log(''); genTsConfigPaths(); - execa.sync('yarn', ['postinstall', '--skip-umi'], { shell: true, stdio: 'inherit' }); + execa.sync('yarn', ['postinstall'], { shell: true, stdio: 'inherit' }); this.log(`The plugin folder is in ${chalk.green(`packages/plugins/${name}`)}`); } } diff --git a/packages/core/cli/src/util.js b/packages/core/cli/src/util.js index eb3acd54f8..66c834b047 100644 --- a/packages/core/cli/src/util.js +++ b/packages/core/cli/src/util.js @@ -163,6 +163,10 @@ exports.promptForTs = () => { console.log(chalk.green('WAIT: ') + 'TypeScript compiling...'); }; +exports.downloadPro = async () => { + await exports.run('yarn', ['nocobase', 'pkg', 'download-pro']); +}; + exports.updateJsonFile = async (target, fn) => { const content = await readFile(target, 'utf-8'); const json = JSON.parse(content); @@ -416,3 +420,13 @@ exports.initEnv = function initEnv() { ); } }; + +exports.generatePlugins = function () { + try { + require.resolve('@nocobase/devtools/umiConfig'); + const { generatePlugins } = require('@nocobase/devtools/umiConfig'); + generatePlugins(); + } catch (error) { + return; + } +}; diff --git a/packages/core/client/src/api-client/APIClient.ts b/packages/core/client/src/api-client/APIClient.ts index a51df1d928..9fa5b7e561 100644 --- a/packages/core/client/src/api-client/APIClient.ts +++ b/packages/core/client/src/api-client/APIClient.ts @@ -135,7 +135,9 @@ export class APIClient extends APIClientSDK { }, async (error) => { if (this.silence) { - throw error; + console.error(error); + return; + // throw error; } const redirectTo = error?.response?.data?.redirectTo; if (redirectTo) { diff --git a/packages/core/client/src/pm/PluginCard.tsx b/packages/core/client/src/pm/PluginCard.tsx index 45b0c42db0..9971006f8e 100644 --- a/packages/core/client/src/pm/PluginCard.tsx +++ b/packages/core/client/src/pm/PluginCard.tsx @@ -7,14 +7,13 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ -import { App, Card, Divider, Popconfirm, Space, Switch, Typography } from 'antd'; +import { DeleteOutlined, LoadingOutlined, ReadOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons'; +import { css } from '@emotion/css'; +import { App, Card, Divider, Modal, Popconfirm, Result, Space, Switch, Typography } 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 { useApp } from '../application'; import { PluginDetail } from './PluginDetail'; @@ -29,8 +28,19 @@ interface IPluginInfo extends IPluginCard { function PluginInfo(props: IPluginInfo) { const { data, onClick } = props; const app = useApp(); - const { name, displayName, isCompatible, packageName, updatable, builtIn, enabled, description, error, homepage } = - data; + const { + name, + displayName, + isCompatible, + packageName, + updatable, + builtIn, + enabled, + removable, + description, + error, + homepage, + } = data; const { styles, theme } = useStyles(); const navigate = useNavigate(); const { t } = useTranslation(); @@ -109,30 +119,58 @@ function PluginInfo(props: IPluginInfo) { {t('Update')} )} - {enabled ? ( - app.pluginSettingsManager.has(name) && ( - { - e.stopPropagation(); - navigate(app.pluginSettingsManager.getRoutePath(name)); - }} - > - {t('Settings')} - - ) - ) : ( + {enabled && app.pluginSettingsManager.has(name) && ( + { + e.stopPropagation(); + navigate(app.pluginSettingsManager.getRoutePath(name)); + }} + > + {t('Settings')} + + )} + {removable && ( { e.stopPropagation(); - api.request({ + await api.request({ url: `pm:remove`, params: { filterByTk: name, }, }); + Modal.info({ + icon: null, + width: 520, + content: ( + } + title={t('Plugin removing')} + subTitle={t('Plugin is removing, please wait...')} + /> + ), + footer: null, + }); + function __health_check() { + api + .silent() + .request({ + url: `__health_check`, + method: 'get', + }) + .then((response) => { + if (response?.data === 'ok') { + window.location.reload(); + } + }) + .catch((error) => { + // console.error('Health check failed:', error); + }); + } + setInterval(__health_check, 1000); }} onCancel={(e) => e.stopPropagation()} okText={t('Yes')} @@ -215,34 +253,6 @@ function PluginInfo(props: IPluginInfo) { ) } /> - {/* {!isCompatible && !error && ( - - )} */} - {/* - - - {type && ( - - )} - - {!error && ( - - )} - - */} ); diff --git a/packages/core/client/src/pm/PluginDetail.tsx b/packages/core/client/src/pm/PluginDetail.tsx index 343e839a69..46d45570e2 100644 --- a/packages/core/client/src/pm/PluginDetail.tsx +++ b/packages/core/client/src/pm/PluginDetail.tsx @@ -13,7 +13,6 @@ 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'; @@ -121,7 +120,7 @@ export const PluginDetail: FC = ({ plugin, onCancel }) => { children: ( {plugin.name && ( - +
{t('Name')} {plugin.name} @@ -129,7 +128,7 @@ export const PluginDetail: FC = ({ plugin, onCancel }) => { )} {plugin.displayName && ( - +
{t('DisplayName')} {plugin.displayName} @@ -169,7 +168,7 @@ export const PluginDetail: FC = ({ plugin, onCancel }) => { )} {data?.data?.packageJson.license && ( - +
{t('License')} {data?.data?.packageJson.license} @@ -177,20 +176,14 @@ export const PluginDetail: FC = ({ plugin, onCancel }) => { )} {author && ( - +
{t('Author')} {author}
)} - -
- {t('Last updated')} - {dayjs(data?.data?.lastUpdated).fromNow()} -
- - +
{t('Version')} {plugin?.version} @@ -231,11 +224,11 @@ export const PluginDetail: FC = ({ plugin, onCancel }) => { ), }, - { - key: 'changelog', - label: t('Changelog'), - children: plugin?.changelogUrl ? : t('No CHANGELOG.md file'), - }, + // { + // key: 'changelog', + // label: t('Changelog'), + // children: plugin?.changelogUrl ? : t('No CHANGELOG.md file'), + // }, ]; return ( @@ -248,9 +241,6 @@ export const PluginDetail: FC = ({ plugin, onCancel }) => { {plugin.packageName}  • }> {plugin.version} - - {t('Last updated')} {dayjs(data?.data?.lastUpdated).fromNow()} - void; isUpgrade: boolean; @@ -40,12 +43,41 @@ export const PluginUploadForm: FC = ({ onClose, pluginDa if (pluginData?.packageName) { formData.append('packageName', pluginData.packageName); } - api.request({ + await api.request({ url: `pm:${isUpgrade ? 'update' : 'add'}`, method: 'post', data: formData, }); + Modal.info({ + icon: null, + width: 520, + content: ( + } + title={t('Plugin uploading')} + subTitle={t('Plugin is uploading, please wait...')} + /> + ), + footer: null, + }); onClose(true); + function __health_check() { + api + .silent() + .request({ + url: `__health_check`, + method: 'get', + }) + .then((response) => { + if (response?.data === 'ok') { + window.location.reload(); + } + }) + .catch((error) => { + // console.error('Health check failed:', error); + }); + } + setInterval(__health_check, 1000); }, }; }; diff --git a/packages/core/client/src/pm/PluginForm/modal/PluginAddModal.tsx b/packages/core/client/src/pm/PluginForm/modal/PluginAddModal.tsx index a312b05bfc..c78190b725 100644 --- a/packages/core/client/src/pm/PluginForm/modal/PluginAddModal.tsx +++ b/packages/core/client/src/pm/PluginForm/modal/PluginAddModal.tsx @@ -27,7 +27,7 @@ export const PluginAddModal: FC = ({ onClose, isShow }) => { const [type, setType] = useState<'npm' | 'upload' | 'url'>('npm'); return ( - onClose()} footer={null} destroyOnClose title={t('Add plugin')} width={580} open={isShow}> + onClose()} footer={null} destroyOnClose title={t('Add & update')} width={580} open={isShow}> {/* */}
setType(e.target.value)}> diff --git a/packages/core/client/src/pm/PluginManager.tsx b/packages/core/client/src/pm/PluginManager.tsx index 0b53d8ccbe..ead08439bb 100644 --- a/packages/core/client/src/pm/PluginManager.tsx +++ b/packages/core/client/src/pm/PluginManager.tsx @@ -197,7 +197,7 @@ const LocalPlugins = () => {
diff --git a/packages/core/client/src/pm/types.ts b/packages/core/client/src/pm/types.ts index b82d588de2..d2f58650bd 100644 --- a/packages/core/client/src/pm/types.ts +++ b/packages/core/client/src/pm/types.ts @@ -16,6 +16,7 @@ export interface IPluginData { packageName: string; version: string; enabled: boolean; + removable?: boolean; installed: boolean; builtIn: boolean; registry?: string; diff --git a/packages/core/devtools/umiConfig.d.ts b/packages/core/devtools/umiConfig.d.ts index c26d8f726c..1b543e008e 100644 --- a/packages/core/devtools/umiConfig.d.ts +++ b/packages/core/devtools/umiConfig.d.ts @@ -24,3 +24,5 @@ export declare class IndexGenerator { constructor(outputPath: string, pluginsPath: string[]): void; generate(): void; }; + +export declare function generatePlugins(): {} diff --git a/packages/core/devtools/umiConfig.js b/packages/core/devtools/umiConfig.js index 56cc67601a..ff56d142fb 100644 --- a/packages/core/devtools/umiConfig.js +++ b/packages/core/devtools/umiConfig.js @@ -137,14 +137,14 @@ class IndexGenerator { generate() { this.generatePluginContent(); if (process.env.NODE_ENV === 'production') return; - this.pluginsPath.forEach((pluginPath) => { - if (!fs.existsSync(pluginPath)) { - return; - } - fs.watch(pluginPath, { recursive: false }, () => { - this.generatePluginContent(); - }); - }); + // this.pluginsPath.forEach((pluginPath) => { + // if (!fs.existsSync(pluginPath)) { + // return; + // } + // fs.watch(pluginPath, { recursive: false }, () => { + // this.generatePluginContent(); + // }); + // }); } get indexContent() { @@ -156,7 +156,11 @@ function devDynamicImport(packageName: string): Promise { if (!fileName) { return Promise.resolve(null); } - return import(\`./packages/\${fileName}\`) + try { + return import(\`./packages/\${fileName}\`) + } catch (error) { + return Promise.resolve(null); + } } export default devDynamicImport;`; } @@ -170,9 +174,9 @@ export default function devDynamicImport(packageName: string): Promise { generatePluginContent() { if (fs.existsSync(this.outputPath)) { - fs.rmdirSync(this.outputPath, { recursive: true, force: true }); + fs.rmSync(this.outputPath, { recursive: true, force: true }); } - fs.mkdirSync(this.outputPath); + fs.mkdirSync(this.outputPath, { recursive: true, force: true }); const validPluginPaths = this.pluginsPath.filter((pluginsPath) => fs.existsSync(pluginsPath)); if (!validPluginPaths.length || process.env.NODE_ENV === 'production') { fs.writeFileSync(this.indexPath, this.emptyIndexContent); @@ -247,3 +251,13 @@ export default function devDynamicImport(packageName: string): Promise { exports.getUmiConfig = getUmiConfig; exports.resolveNocobasePackagesAlias = resolveNocobasePackagesAlias; exports.IndexGenerator = IndexGenerator; + +exports.generatePlugins = function () { + const pluginDirs = (process.env.PLUGIN_PATH || 'packages/plugins/,packages/samples/,packages/pro-plugins/') + .split(',') + .map((item) => path.join(process.cwd(), item)); + + const outputPluginPath = path.join(process.env.APP_PACKAGE_ROOT, 'client', 'src', '.plugins'); + const indexGenerator = new IndexGenerator(outputPluginPath, pluginDirs); + indexGenerator.generate(); +}; diff --git a/packages/core/server/src/__tests__/pm.test.ts b/packages/core/server/src/__tests__/pm.test.ts index 820fdff09e..5f5a059651 100644 --- a/packages/core/server/src/__tests__/pm.test.ts +++ b/packages/core/server/src/__tests__/pm.test.ts @@ -7,10 +7,10 @@ * For more information, please refer to: https://www.nocobase.com/agreement. */ +import { MockServer, mockServer } from '@nocobase/test'; +import { vi } from 'vitest'; import Plugin from '../plugin'; import { PluginManager } from '../plugin-manager'; -import { vi } from 'vitest'; -import { MockServer, mockServer } from '@nocobase/test'; describe('pm', () => { let app: MockServer; @@ -216,7 +216,40 @@ describe('pm', () => { PluginManager.resolvePlugin = resolvePlugin; }); - test('enable12', async () => { + test('afterAdd + beforeLoad + load', async () => { + const resolvePlugin = PluginManager.resolvePlugin; + PluginManager.resolvePlugin = async (pluginName) => { + return Plugin1; + }; + + const loadFn = vi.fn(); + + class Plugin1 extends Plugin { + async afterAdd() { + loadFn(); + } + async beforeLoad() { + loadFn(); + } + async load() { + loadFn(); + } + } + app = mockServer(); + await app.cleanDb(); + await app.load(); + await app.install(); + await app.pm.enable('Plugin1'); + expect(loadFn).toBeCalledTimes(6); + await app.pm.enable('Plugin1'); + expect(loadFn).toBeCalledTimes(6); + await app.pm.disable('Plugin1'); + await app.pm.enable('Plugin1'); + expect(loadFn).toBeCalledTimes(12); + PluginManager.resolvePlugin = resolvePlugin; + }); + + test('beforeEnable + install + afterEnable', async () => { const resolvePlugin = PluginManager.resolvePlugin; PluginManager.resolvePlugin = async (pluginName) => { return Plugin1; @@ -239,26 +272,72 @@ describe('pm', () => { await app.cleanDb(); await app.load(); await app.install(); - await app.pm.repository.create({ - values: { - name: 'Plugin1', - }, - }); - await app.reload(); await app.pm.enable('Plugin1'); + expect(loadFn).toBeCalledTimes(3); expect(app.pm.get('Plugin1').enabled).toBeTruthy(); expect(app.pm.get('Plugin1').installed).toBeTruthy(); - expect(loadFn).toBeCalled(); - expect(loadFn).toBeCalledTimes(3); await app.pm.enable('Plugin1'); expect(loadFn).toBeCalledTimes(3); + expect(app.pm.get('Plugin1').enabled).toBeTruthy(); + expect(app.pm.get('Plugin1').installed).toBeTruthy(); await app.pm.disable('Plugin1'); await app.pm.enable('Plugin1'); expect(loadFn).toBeCalledTimes(5); PluginManager.resolvePlugin = resolvePlugin; }); - test('enable11', async () => { + + test('afterAdd + beforeLoad + load', async () => { + const resolvePlugin = PluginManager.resolvePlugin; + + class Plugin1 extends Plugin { + async afterAdd() { + loadFn(); + } + async beforeLoad() { + loadFn(); + } + async load() { + loadFn(); + } + } + + class Plugin2 extends Plugin { + async afterAdd() { + loadFn(); + } + async beforeLoad() { + loadFn(); + } + async load() { + loadFn(); + } + } + PluginManager.resolvePlugin = async (pluginName: string) => { + return { + Plugin1, + Plugin2, + }[pluginName]; + }; + const loadFn = vi.fn(); + + app = mockServer(); + await app.cleanDb(); + await app.load(); + await app.install(); + await app.pm.enable(['Plugin1', 'Plugin2']); + expect(loadFn).toBeCalledTimes(12); + await app.pm.enable('Plugin1'); + expect(loadFn).toBeCalledTimes(12); + await app.pm.disable('Plugin1'); + await app.pm.enable('Plugin1'); + expect(loadFn).toBeCalledTimes(24); + PluginManager.resolvePlugin = resolvePlugin; + }); + + test('beforeEnable + install + afterEnable', async () => { + const resolvePlugin = PluginManager.resolvePlugin; + class Plugin1 extends Plugin { async beforeEnable() { loadFn(); @@ -270,6 +349,7 @@ describe('pm', () => { loadFn(); } } + class Plugin2 extends Plugin { async beforeEnable() { loadFn(); @@ -281,39 +361,33 @@ describe('pm', () => { loadFn(); } } - const resolvePlugin = PluginManager.resolvePlugin; PluginManager.resolvePlugin = async (pluginName: string) => { return { Plugin1, Plugin2, }[pluginName]; }; + + const loadFn = vi.fn(); + app = mockServer(); await app.cleanDb(); await app.load(); await app.install(); - await app.pm.repository.create({ - values: [ - { - name: 'Plugin1', - }, - { - name: 'Plugin2', - }, - ], - }); - await app.reload(); await app.pm.enable(['Plugin1', 'Plugin2']); + expect(loadFn).toBeCalledTimes(6); expect(app.pm.get('Plugin1').enabled).toBeTruthy(); expect(app.pm.get('Plugin1').installed).toBeTruthy(); - expect(app.pm.get('Plugin2').enabled).toBeTruthy(); - expect(app.pm.get('Plugin2').installed).toBeTruthy(); - expect(loadFn).toBeCalled(); - expect(loadFn).toBeCalledTimes(6); await app.pm.enable(['Plugin1', 'Plugin2']); expect(loadFn).toBeCalledTimes(6); + expect(app.pm.get('Plugin1').enabled).toBeTruthy(); + expect(app.pm.get('Plugin1').installed).toBeTruthy(); + await app.pm.disable(['Plugin1', 'Plugin2']); + await app.pm.enable(['Plugin1', 'Plugin2']); + expect(loadFn).toBeCalledTimes(10); PluginManager.resolvePlugin = resolvePlugin; }); + test('disable', async () => { const resolvePlugin = PluginManager.resolvePlugin; PluginManager.resolvePlugin = async (pluginName) => { @@ -338,10 +412,9 @@ describe('pm', () => { }, }); await app.reload(); - expect(app.pm.get('Plugin1').enabled).toBeFalsy(); - expect(app.pm.get('Plugin1').installed).toBeFalsy(); - await app.pm.disable('Plugin1'); + expect(app.pm.get('Plugin1')).toBeUndefined(); expect(loadFn).not.toBeCalled(); + await expect(() => app.pm.disable('Plugin1')).rejects.toThrow('Plugin1 plugin does not exist'); PluginManager.resolvePlugin = resolvePlugin; }); test('disable', async () => { @@ -375,8 +448,13 @@ describe('pm', () => { await app.pm.disable('Plugin1'); expect(loadFn).toBeCalled(); expect(loadFn).toBeCalledTimes(2); - expect(app.pm.get('Plugin1').enabled).toBeFalsy(); - expect(app.pm.get('Plugin1').installed).toBeTruthy(); + const instance = await app.pm.repository.findOne({ + filter: { + name: 'Plugin1', + }, + }); + expect(instance.enabled).toBeFalsy(); + expect(instance.installed).toBeTruthy(); PluginManager.resolvePlugin = resolvePlugin; }); test('install', async () => { @@ -464,22 +542,22 @@ describe('pm', () => { await app.cleanDb(); await app.load(); await app.install(); - const plugin = await app.pm.repository.create({ + await app.pm.repository.create({ values: { name: 'Plugin1', }, }); await app.reload(); - expect(app.pm.get('Plugin1')['prop']).toBeUndefined(); + expect(app.pm.has('Plugin1')).toBeFalsy(); expect(result).toEqual([]); await app.pm.enable('Plugin1'); expect(app.pm.get('Plugin1')['prop']).toBe('a'); // console.log(hooks.join('/')); - expect(result).toEqual([false, true, true]); + expect(result).toEqual([true, true, true]); await app.pm.disable('Plugin1'); // console.log(hooks.join('/')); - expect(app.pm.get('Plugin1')['prop']).toBeUndefined(); - expect(result).toEqual([false, true, true, true, false]); + expect(app.pm.has('Plugin1')).toBeFalsy(); + expect(result).toEqual([true, true, true, true, true]); // console.log(hooks.join('/')); PluginManager.resolvePlugin = resolvePlugin; }); diff --git a/packages/core/server/src/commands/start.ts b/packages/core/server/src/commands/start.ts index fd081ab1ae..d8f962f77d 100644 --- a/packages/core/server/src/commands/start.ts +++ b/packages/core/server/src/commands/start.ts @@ -9,8 +9,7 @@ /* istanbul ignore file -- @preserve */ -import { fsExists } from '@nocobase/utils'; -import fs from 'fs'; +import fs from 'fs-extra'; import { resolve } from 'path'; import Application from '../application'; import { ApplicationNotInstall } from '../errors/application-not-install'; @@ -23,12 +22,16 @@ export default (app: Application) => { .option('--quickstart') .action(async (...cliArgs) => { const [options] = cliArgs; - const file = resolve(process.cwd(), 'storage/app-upgrading'); - const upgrading = await fsExists(file); + const file = resolve(process.cwd(), 'storage/.upgrading'); + const upgrading = await fs.exists(file); if (upgrading) { - await app.upgrade(); + if (!process.env.VITEST) { + if (await app.isInstalled()) { + await app.upgrade(); + } + } try { - await fs.promises.rm(file); + await fs.rm(file, { recursive: true, force: true }); } catch (error) { // skip } diff --git a/packages/core/server/src/plugin-manager/options/resource.ts b/packages/core/server/src/plugin-manager/options/resource.ts index 5e758f1be7..902117c672 100644 --- a/packages/core/server/src/plugin-manager/options/resource.ts +++ b/packages/core/server/src/plugin-manager/options/resource.ts @@ -123,7 +123,9 @@ export default { async list(ctx, next) { const locale = ctx.getCurrentLocale(); const pm = ctx.app.pm as PluginManager; - ctx.body = await pm.list({ locale, isPreset: false }); + // ctx.body = await pm.list({ locale, isPreset: false }); + const plugin = pm.get('nocobase') as any; + ctx.body = await plugin.getAllPlugins(locale); await next(); }, async listEnabled(ctx, next) { @@ -156,7 +158,9 @@ export default { if (!filterByTk) { ctx.throw(400, 'plugin name invalid'); } - ctx.body = await pm.get(filterByTk).toJSON({ locale }); + const plugin = pm.get('nocobase') as any; + ctx.body = await plugin.getPluginInfo(filterByTk, locale); + // ctx.body = await pm.get(filterByTk).toJSON({ locale }); await next(); }, }, diff --git a/packages/core/server/src/plugin-manager/plugin-manager-repository.ts b/packages/core/server/src/plugin-manager/plugin-manager-repository.ts index a245399234..6a38ed1161 100644 --- a/packages/core/server/src/plugin-manager/plugin-manager-repository.ts +++ b/packages/core/server/src/plugin-manager/plugin-manager-repository.ts @@ -24,6 +24,8 @@ export class PluginManagerRepository extends Repository { this.pm = pm; } + async createByName(nameOrPkgs) {} + async has(nameOrPkg: string) { const { name } = await PluginManager.parseName(nameOrPkg); const instance = await this.findOne({ @@ -79,11 +81,19 @@ export class PluginManagerRepository extends Repository { } async updateVersions() { - const items = await this.find(); + const items = await this.find({ + filter: { + enabled: true, + }, + }); for (const item of items) { - const json = await PluginManager.getPackageJson(item.packageName); - item.set('version', json.version); - await item.save(); + try { + const json = await PluginManager.getPackageJson(item.packageName); + item.set('version', json.version); + await item.save(); + } catch (error) { + this.pm.app.log.error(error); + } } } @@ -117,6 +127,9 @@ export class PluginManagerRepository extends Repository { } return await this.find({ sort: 'id', + filter: { + enabled: true, + }, }); } diff --git a/packages/core/server/src/plugin-manager/plugin-manager.ts b/packages/core/server/src/plugin-manager/plugin-manager.ts index 5fcf31a6e4..44f70a5445 100644 --- a/packages/core/server/src/plugin-manager/plugin-manager.ts +++ b/packages/core/server/src/plugin-manager/plugin-manager.ts @@ -10,10 +10,9 @@ import Topo from '@hapi/topo'; import { CleanOptions, Collection, SyncOptions } from '@nocobase/database'; import { importModule, isURL } from '@nocobase/utils'; -import { fsExists } from '@nocobase/utils/plugin-symlink'; import execa from 'execa'; import fg from 'fast-glob'; -import fs from 'fs'; +import fs from 'fs-extra'; import _ from 'lodash'; import net from 'net'; import { basename, join, resolve, sep } from 'path'; @@ -26,12 +25,12 @@ import resourceOptions from './options/resource'; import { PluginManagerRepository } from './plugin-manager-repository'; import { PluginData } from './types'; import { + checkAndGetCompatible, copyTempPackageToStorageAndLinkToNodeModules, downloadAndUnzipToTempDir, getNpmInfo, getPluginBasePath, getPluginInfoByNpm, - removeTmpDir, updatePluginByCompressedFileUrl, } from './utils'; @@ -56,6 +55,8 @@ export interface InstallOptions { export class AddPresetError extends Error {} export class PluginManager { + static checkAndGetCompatible = checkAndGetCompatible; + /** * @internal */ @@ -118,12 +119,19 @@ export class PluginManager { return this.app.db.getRepository('applicationPlugins') as PluginManagerRepository; } + static async packageExists(nameOrPkg: string) { + const { packageName } = await this.parseName(nameOrPkg); + const file = resolve(process.env.NODE_MODULES_PATH, packageName, 'package.json'); + return fs.exists(file); + } + /** * @internal */ - static async getPackageJson(packageName: string) { - const file = await fs.promises.realpath(resolve(process.env.NODE_MODULES_PATH, packageName, 'package.json')); - const data = await fs.promises.readFile(file, { encoding: 'utf-8' }); + static async getPackageJson(nameOrPkg: string) { + const { packageName } = await this.parseName(nameOrPkg); + const file = await fs.realpath(resolve(process.env.NODE_MODULES_PATH, packageName, 'package.json')); + const data = await fs.readFile(file, { encoding: 'utf-8' }); return JSON.parse(data); } @@ -134,7 +142,7 @@ export class PluginManager { const prefixes = this.getPluginPkgPrefix(); for (const prefix of prefixes) { const pkg = resolve(process.env.NODE_MODULES_PATH, `${prefix}${name}`, 'package.json'); - const exists = await fsExists(pkg); + const exists = await fs.exists(pkg); if (exists) { return `${prefix}${name}`; } @@ -196,9 +204,7 @@ export class PluginManager { */ static async resolvePlugin(pluginName: string | typeof Plugin, isUpgrade = false, isPkg = false) { if (typeof pluginName === 'string') { - const packageName = isPkg ? pluginName : await this.getPackageName(pluginName); - this.clearCache(packageName); - + const { packageName } = await this.parseName(pluginName); return await importModule(packageName); } else { return pluginName; @@ -226,7 +232,7 @@ export class PluginManager { return this.parsedNames[nameOrPkg]; } const exists = async (name: string, isPreset = false) => { - return fsExists( + return fs.exists( resolve(process.env.NODE_MODULES_PATH, `@nocobase/${isPreset ? 'preset' : 'plugin'}-${name}`, 'package.json'), ); }; @@ -285,7 +291,7 @@ export class PluginManager { const createPlugin = async (name) => { const pluginDir = resolve(process.cwd(), 'packages/plugins', name); if (options?.forceRecreate) { - await fs.promises.rm(pluginDir, { recursive: true, force: true }); + await fs.rm(pluginDir, { recursive: true, force: true }); } const { PluginGenerator } = require('@nocobase/cli/src/plugin-generator'); const generator = new PluginGenerator({ @@ -298,16 +304,6 @@ export class PluginManager { await generator.run(); }; await createPlugin(pluginName); - try { - await this.app.db.auth({ retry: 1 }); - const installed = await this.app.isInstalled(); - if (!installed) { - console.log(`yarn pm add ${pluginName}`); - return; - } - } catch (error) { - return; - } this.app.log.info('attempt to add the plugin to the app'); const { name, packageName } = await PluginManager.parseName(pluginName); const json = await PluginManager.getPackageJson(packageName); @@ -316,15 +312,6 @@ export class PluginManager { packageName, version: json.version, }); - await this.repository.updateOrCreate({ - values: { - name, - packageName, - version: json.version, - }, - filterKeys: ['name'], - }); - await sleep(1000); await tsxRerunning(); } @@ -374,14 +361,6 @@ export class PluginManager { if (options.packageName) { this.pluginAliases.set(options.packageName, instance); } - if (insert && options.name) { - await this.repository.updateOrCreate({ - values: { - ...options, - }, - filterKeys: ['name'], - }); - } await instance.afterAdd(); } @@ -524,16 +503,69 @@ export class PluginManager { async enable(nameOrPkg: string | string[]) { let pluginNames = nameOrPkg; if (nameOrPkg === '*') { - const items = await this.repository.find(); - pluginNames = items.map((item: any) => item.name); + const plugin = this.get('nocobase') as any; + pluginNames = await plugin.findLocalPlugins(); + } + pluginNames = await this.sort(pluginNames); + try { + const added = {}; + for (const name of pluginNames) { + const { name: pluginName } = await PluginManager.parseName(name); + if (this.has(pluginName)) { + added[pluginName] = true; + continue; + } + await this.add(pluginName); + } + for (const name of pluginNames) { + const { name: pluginName } = await PluginManager.parseName(name); + const plugin = this.get(pluginName); + if (!plugin) { + throw new Error(`${pluginName} plugin does not exist`); + } + if (added[pluginName]) { + continue; + } + const instance = await this.repository.findOne({ + filter: { + name: pluginName, + }, + }); + if (instance) { + plugin.enabled = instance.enabled; + plugin.installed = instance.installed; + } + if (plugin.enabled) { + continue; + } + await plugin.beforeLoad(); + } + for (const name of pluginNames) { + const { name: pluginName } = await PluginManager.parseName(name); + const plugin = this.get(pluginName); + if (!plugin) { + throw new Error(`${pluginName} plugin does not exist`); + } + if (added[pluginName]) { + continue; + } + if (plugin.enabled) { + continue; + } + await plugin.loadCollections(); + await plugin.load(); + } + } catch (error) { + await this.app.tryReloadOrRestart({ + recover: true, + }); + throw error; } - pluginNames = this.sort(pluginNames); this.app.log.debug(`enabling plugin ${pluginNames.join(',')}`); this.app.setMaintainingMessage(`enabling plugin ${pluginNames.join(',')}`); const toBeUpdated = []; for (const name of pluginNames) { const { name: pluginName } = await PluginManager.parseName(name); - console.log('pluginName', pluginName); const plugin = this.get(pluginName); if (!plugin) { throw new Error(`${pluginName} plugin does not exist`); @@ -544,7 +576,6 @@ export class PluginManager { await this.app.emitAsync('beforeEnablePlugin', pluginName); try { await plugin.beforeEnable(); - plugin.enabled = true; toBeUpdated.push(pluginName); } catch (error) { if (nameOrPkg === '*') { @@ -557,16 +588,7 @@ export class PluginManager { if (toBeUpdated.length === 0) { return; } - await this.repository.update({ - filter: { - name: toBeUpdated, - }, - values: { - enabled: true, - }, - }); try { - await this.app.reload(); this.app.log.debug(`syncing database in enable plugin ${toBeUpdated.join(',')}...`); this.app.setMaintainingMessage(`syncing database in enable plugin ${toBeUpdated.join(',')}...`); await this.app.db.sync(); @@ -579,32 +601,31 @@ export class PluginManager { plugin.installed = true; } } - await this.repository.update({ - filter: { - name: toBeUpdated, - }, - values: { + for (const pluginName of toBeUpdated) { + const { name } = await PluginManager.parseName(pluginName); + const packageJson = await PluginManager.getPackageJson(pluginName); + const values = { + name, + packageName: packageJson?.name, + enabled: true, installed: true, - }, - }); + version: packageJson?.version, + }; + await this.repository.updateOrCreate({ + values, + filterKeys: ['name'], + }); + } for (const pluginName of toBeUpdated) { const plugin = this.get(pluginName); this.app.log.debug(`emit afterEnablePlugin event...`); await plugin.afterEnable(); + plugin.enabled = true; await this.app.emitAsync('afterEnablePlugin', pluginName); this.app.log.debug(`afterEnablePlugin event emitted`); } await this.app.tryReloadOrRestart(); } catch (error) { - await this.repository.update({ - filter: { - name: toBeUpdated, - }, - values: { - enabled: false, - installed: false, - }, - }); await this.app.tryReloadOrRestart({ recover: true, }); @@ -634,32 +655,24 @@ export class PluginManager { if (toBeUpdated.length === 0) { return; } - await this.repository.update({ - filter: { - name: toBeUpdated, - }, - values: { - enabled: false, - }, - }); try { - await this.app.tryReloadOrRestart(); - for (const pluginName of pluginNames) { + for (const pluginName of toBeUpdated) { const plugin = this.get(pluginName); this.app.log.debug(`emit afterDisablePlugin event...`); await plugin.afterDisable(); await this.app.emitAsync('afterDisablePlugin', pluginName); this.app.log.debug(`afterDisablePlugin event emitted`); } - } catch (error) { await this.repository.update({ filter: { name: toBeUpdated, }, values: { - enabled: true, + enabled: false, }, }); + await this.app.tryReloadOrRestart(); + } catch (error) { await this.app.tryReloadOrRestart({ recover: true, }); @@ -684,72 +697,43 @@ export class PluginManager { records.map(async (plugin) => { const dir = resolve(process.env.NODE_MODULES_PATH, plugin.packageName); try { - const realDir = await fs.promises.realpath(dir); + const realDir = await fs.realpath(dir); + console.log('realDir', realDir); this.app.log.debug(`rm -rf ${realDir}`); - return fs.promises.rm(realDir, { force: true, recursive: true }); + return fs.rm(realDir, { force: true, recursive: true }); } catch (error) { return false; } }), ); - await execa('yarn', ['nocobase', 'postinstall']); }; - if (options?.force) { - await this.repository.destroy({ - filter: { - name: pluginNames, - }, - }); - this.app.log.warn(`force remove plugins ${pluginNames.join(',')}`); - } else { - await this.app.load(); - for (const pluginName of pluginNames) { - const plugin = this.get(pluginName); - if (!plugin) { - continue; - } - if (plugin.enabled) { - throw new Error(`plugin is enabled [${pluginName}]`); - } - await plugin.beforeRemove(); - } - await this.repository.destroy({ - filter: { - name: pluginNames, - }, - }); - const plugins: Plugin[] = []; - for (const pluginName of pluginNames) { - const plugin = this.get(pluginName); - if (!plugin) { - continue; - } - plugins.push(plugin); - this.del(pluginName); - await plugin.afterRemove(); - } - if (await this.app.isStarted()) { - await this.app.tryReloadOrRestart(); - } - } - if (options?.removeDir) { + await this.repository.destroy({ + filter: { + name: pluginNames, + }, + }); + if (!this.app.db.getCollection('applications')) { await removeDir(); } - await execa('yarn', ['nocobase', 'refresh'], { - env: process.env, - }); } /** * @internal */ async addViaCLI(urlOrName: string | string[], options?: PluginData, emitStartedEvent = true) { + const writeFile = async () => { + if (process.env.VITEST) { + return; + } + const file = resolve(process.cwd(), 'storage/.upgrading'); + this.app.log.debug('pending upgrade'); + await fs.writeFile(file, 'upgrading'); + }; + await writeFile(); if (Array.isArray(urlOrName)) { for (const packageName of urlOrName) { await this.addViaCLI(packageName, _.omit(options, 'name'), false); } - await this.app.emitStartedEvent(); - await execa('yarn', ['nocobase', 'postinstall']); return; } if (isURL(urlOrName)) { @@ -760,7 +744,7 @@ export class PluginManager { }, emitStartedEvent, ); - } else if (await fsExists(urlOrName)) { + } else if (await fs.exists(urlOrName)) { await this.addByCompressedFileUrl( { ...(options as any), @@ -778,20 +762,6 @@ export class PluginManager { }, emitStartedEvent, ); - } else { - const { name, packageName } = await PluginManager.parseName(urlOrName); - const opts = { - ...options, - name, - packageName, - }; - // 下面这行代码删了,测试会报错 packages/core/server/src/__tests__/gateway.test.ts:407:29 - await this.repository.findOne({ filter: { packageName } }); - await this.add(name, opts, true); - } - if (emitStartedEvent) { - await this.app.emitStartedEvent(); - await execa('yarn', ['nocobase', 'postinstall']); } } @@ -826,19 +796,7 @@ export class PluginManager { const { packageName, tempFile, tempPackageContentDir } = await downloadAndUnzipToTempDir(file, authToken); - const { name } = await PluginManager.parseName(packageName); - - if (this.has(name)) { - await removeTmpDir(tempFile, tempPackageContentDir); - if (throwError) { - throw new Error(`plugin name [${name}] already exists`); - } else { - this.app.log.warn(`plugin name [${name}] already exists`); - return; - } - } await copyTempPackageToStorageAndLinkToNodeModules(tempFile, tempPackageContentDir, packageName); - return this.add(name, { packageName }, true); } /** @@ -861,19 +819,7 @@ export class PluginManager { authToken, ); - const { name } = await PluginManager.parseName(packageName); - - if (this.has(name)) { - await removeTmpDir(tempFile, tempPackageContentDir); - if (throwError) { - throw new Error(`plugin name [${name}] already exists`); - } else { - this.app.log.warn(`plugin name [${name}] already exists`); - return; - } - } await copyTempPackageToStorageAndLinkToNodeModules(tempFile, tempPackageContentDir, packageName); - return this.add(name, { packageName }, true); } async update(nameOrPkg: string | string[], options: PluginData, emitStartedEvent = true) { @@ -888,7 +834,7 @@ export class PluginManager { return; } const file = resolve(process.cwd(), 'storage/app-upgrading'); - await fs.promises.writeFile(file, '', 'utf-8'); + await fs.writeFile(file, '', 'utf-8'); // await this.app.upgrade(); await tsxRerunning(); await execa('yarn', ['nocobase', 'pm2-restart'], { @@ -904,7 +850,7 @@ export class PluginManager { const opts = { ...options }; if (isURL(nameOrPkg)) { opts.compressedFileUrl = nameOrPkg; - } else if (await fsExists(nameOrPkg)) { + } else if (await fs.exists(nameOrPkg)) { opts.compressedFileUrl = nameOrPkg; } if (opts.compressedFileUrl) { @@ -958,7 +904,7 @@ export class PluginManager { repository: this.repository, }); const { name } = await PluginManager.parseName(packageName); - await this.add(name, { name, version, packageName }, true, true); + // await this.add(name, { name, version, packageName }, true, true); } /** @@ -1152,16 +1098,16 @@ export class PluginManager { this['_initPresetPlugins'] = true; } - private sort(names: string | string[]) { + private async sort(names: string | string[]) { const pluginNames = _.castArray(names); if (pluginNames.length === 1) { return pluginNames; } const sorter = new Topo.Sorter(); for (const pluginName of pluginNames) { - const plugin = this.get(pluginName); - const peerDependencies = Object.keys(plugin.options?.packageJson?.peerDependencies || {}); - sorter.add(pluginName, { after: peerDependencies, group: plugin.options?.packageName || pluginName }); + const packageJson = await PluginManager.getPackageJson(pluginName); + const peerDependencies = Object.keys(packageJson?.peerDependencies || {}); + sorter.add(pluginName, { after: peerDependencies, group: packageJson?.packageName || pluginName }); } return sorter.nodes; } diff --git a/packages/plugins/@nocobase/plugin-multi-app-share-collection/src/server/__tests__/collection-sync.test.ts b/packages/plugins/@nocobase/plugin-multi-app-share-collection/src/server/__tests__/collection-sync.test.ts index f7601f4041..8eff78923d 100644 --- a/packages/plugins/@nocobase/plugin-multi-app-share-collection/src/server/__tests__/collection-sync.test.ts +++ b/packages/plugins/@nocobase/plugin-multi-app-share-collection/src/server/__tests__/collection-sync.test.ts @@ -255,12 +255,12 @@ describe.runIf(isPg())('collection sync', () => { }); }; - expect((await getSubAppMapRecord(sub1)).get('enabled')).toBeFalsy(); + expect(await getSubAppMapRecord(sub1)).toBeNull(); await mainApp.pm.enable(['map']); await new Promise((resolve) => setTimeout(resolve, 1000)); - expect((await getSubAppMapRecord(sub1)).get('enabled')).toBeTruthy(); + expect((await getSubAppMapRecord(sub1)).enabled).toBeTruthy(); // create new app sub2 await mainApp.db.getRepository('applications').create({ values: { diff --git a/packages/plugins/@nocobase/plugin-multi-app-share-collection/src/server/plugin.ts b/packages/plugins/@nocobase/plugin-multi-app-share-collection/src/server/plugin.ts index 37035d24b0..5219f7ba24 100644 --- a/packages/plugins/@nocobase/plugin-multi-app-share-collection/src/server/plugin.ts +++ b/packages/plugins/@nocobase/plugin-multi-app-share-collection/src/server/plugin.ts @@ -127,7 +127,7 @@ export class MultiAppShareCollectionPlugin extends Plugin { throw new Error('multi-app-share-collection plugin only support postgres'); } const plugin = this.pm.get('multi-app-manager'); - if (!plugin.enabled) { + if (!plugin?.enabled) { throw new Error(`${this.name} plugin need multi-app-manager plugin enabled`); } } diff --git a/packages/presets/nocobase/package.json b/packages/presets/nocobase/package.json index abc22716df..c585423693 100644 --- a/packages/presets/nocobase/package.json +++ b/packages/presets/nocobase/package.json @@ -66,12 +66,62 @@ "@nocobase/plugin-workflow-request": "1.4.0-alpha", "@nocobase/plugin-workflow-sql": "1.4.0-alpha", "@nocobase/server": "1.4.0-alpha", - "cronstrue": "^2.11.0" + "cronstrue": "^2.11.0", + "fs-extra": "^11.1.1" }, + "deprecated": [ + "@nocobase/plugin-audit-logs", + "@nocobase/plugin-charts", + "@nocobase/plugin-mobile-client", + "@nocobase/plugin-snapshot-field" + ], + "builtIn": [ + "@nocobase/plugin-acl", + "@nocobase/plugin-action-bulk-edit", + "@nocobase/plugin-action-bulk-update", + "@nocobase/plugin-action-custom-request", + "@nocobase/plugin-action-duplicate", + "@nocobase/plugin-action-export", + "@nocobase/plugin-action-import", + "@nocobase/plugin-action-print", + "@nocobase/plugin-auth", + "@nocobase/plugin-block-iframe", + "@nocobase/plugin-block-workbench", + "@nocobase/plugin-calendar", + "@nocobase/plugin-client", + "@nocobase/plugin-collection-sql", + "@nocobase/plugin-collection-tree", + "@nocobase/plugin-data-source-main", + "@nocobase/plugin-data-source-manager", + "@nocobase/plugin-data-visualization", + "@nocobase/plugin-error-handler", + "@nocobase/plugin-field-china-region", + "@nocobase/plugin-field-formula", + "@nocobase/plugin-field-sequence", + "@nocobase/plugin-file-manager", + "@nocobase/plugin-gantt", + "@nocobase/plugin-kanban", + "@nocobase/plugin-logger", + "@nocobase/plugin-system-settings", + "@nocobase/plugin-ui-schema-storage", + "@nocobase/plugin-user-data-sync", + "@nocobase/plugin-users", + "@nocobase/plugin-verification", + "@nocobase/plugin-workflow", + "@nocobase/plugin-workflow-action-trigger", + "@nocobase/plugin-workflow-aggregate", + "@nocobase/plugin-workflow-delay", + "@nocobase/plugin-workflow-dynamic-calculation", + "@nocobase/plugin-workflow-loop", + "@nocobase/plugin-workflow-manual", + "@nocobase/plugin-workflow-parallel", + "@nocobase/plugin-workflow-request", + "@nocobase/plugin-workflow-sql" + ], "repository": { "type": "git", "url": "git+https://github.com/nocobase/nocobase.git", "directory": "packages/presets/nocobase" }, "gitHead": "d0b4efe4be55f8c79a98a331d99d9f8cf99021a1" -} +} \ No newline at end of file diff --git a/packages/presets/nocobase/src/server/findPackageNames.ts b/packages/presets/nocobase/src/server/findPackageNames.ts new file mode 100644 index 0000000000..912d0e26df --- /dev/null +++ b/packages/presets/nocobase/src/server/findPackageNames.ts @@ -0,0 +1,114 @@ +/** + * This file is part of the NocoBase (R) project. + * Copyright (c) 2020-2024 NocoBase Co., Ltd. + * Authors: NocoBase Team. + * + * This project is dual-licensed under AGPL-3.0 and NocoBase Commercial License. + * For more information, please refer to: https://www.nocobase.com/agreement. + */ + +import { PluginManager } from '@nocobase/server'; +import fg from 'fast-glob'; +import fs from 'fs-extra'; +import _ from 'lodash'; +import path from 'path'; + +function splitNames(name: string) { + return (name || '').split(',').filter(Boolean); +} + +export async function trim(packageNames: string[]) { + const nameOrPkgs = _.uniq(packageNames).filter(Boolean); + const names = []; + for (const nameOrPkg of nameOrPkgs) { + const { name, packageName } = await PluginManager.parseName(nameOrPkg); + try { + await PluginManager.getPackageJson(packageName); + names.push(name); + } catch (error) { + // + } + } + return names; +} + +export async function findPackageNames() { + const patterns = [ + './packages/plugins/*/package.json', + './packages/plugins/*/*/package.json', + './packages/pro-plugins/*/*/package.json', + './storage/plugins/*/package.json', + './storage/plugins/*/*/package.json', + ]; + try { + const packageJsonPaths = await fg(patterns, { + cwd: process.cwd(), + absolute: true, + ignore: ['**/external-db-data-source/**'], + }); + const packageNames = await Promise.all( + packageJsonPaths.map(async (packageJsonPath) => { + const packageJson = await fs.readJson(packageJsonPath); + return packageJson.name; + }), + ); + const excludes = [ + '@nocobase/plugin-audit-logs', + '@nocobase/plugin-backup-restore', + '@nocobase/plugin-charts', + '@nocobase/plugin-disable-pm-add', + '@nocobase/plugin-mobile-client', + '@nocobase/plugin-mock-collections', + '@nocobase/plugin-multi-app-share-collection', + '@nocobase/plugin-notifications', + '@nocobase/plugin-snapshot-field', + '@nocobase/plugin-workflow-test', + ]; + const nocobasePlugins = await findNocobasePlugins(); + const { APPEND_PRESET_BUILT_IN_PLUGINS = '', APPEND_PRESET_LOCAL_PLUGINS = '' } = process.env; + return trim( + _.difference(packageNames, excludes) + .filter(Boolean) + .concat(nocobasePlugins) + .concat(splitNames(APPEND_PRESET_BUILT_IN_PLUGINS)) + .concat(splitNames(APPEND_PRESET_LOCAL_PLUGINS)), + ); + } catch (error) { + return []; + } +} + +async function findNocobasePlugins() { + try { + const packageJson = await fs.readJson(path.resolve(__dirname, '../../package.json')); + const pluginNames = Object.keys(packageJson.dependencies).filter((name) => name.startsWith('@nocobase/plugin-')); + return trim(pluginNames); + } catch (error) { + return []; + } +} + +export async function findBuiltInPlugins() { + const { APPEND_PRESET_BUILT_IN_PLUGINS = '' } = process.env; + try { + const packageJson = await fs.readJson(path.resolve(__dirname, '../../package.json')); + return trim(packageJson.builtIn.concat(splitNames(APPEND_PRESET_BUILT_IN_PLUGINS))); + } catch (error) { + return []; + } +} + +export async function findLocalPlugins() { + const { APPEND_PRESET_LOCAL_PLUGINS = '' } = process.env; + const plugins1 = await findNocobasePlugins(); + const plugins2 = await findPackageNames(); + const builtInPlugins = await findBuiltInPlugins(); + const packageJson = await fs.readJson(path.resolve(__dirname, '../../package.json')); + const items = await trim( + _.difference( + plugins1.concat(plugins2).concat(splitNames(APPEND_PRESET_LOCAL_PLUGINS)), + builtInPlugins.concat(await trim(packageJson.deprecated)), + ), + ); + return items; +} diff --git a/packages/presets/nocobase/src/server/index.ts b/packages/presets/nocobase/src/server/index.ts index 60fe8cd598..fae0c9a7f1 100644 --- a/packages/presets/nocobase/src/server/index.ts +++ b/packages/presets/nocobase/src/server/index.ts @@ -9,88 +9,78 @@ import { Plugin, PluginManager } from '@nocobase/server'; import _ from 'lodash'; +import { findBuiltInPlugins, findLocalPlugins, trim } from './findPackageNames'; export class PresetNocoBase extends Plugin { - builtInPlugins = [ - 'data-source-manager', - 'error-handler', - 'data-source-main', - 'ui-schema-storage', - // 'ui-routes-storage', - 'file-manager', - 'system-settings', - 'field-sequence', - 'verification', - 'users', - 'user-data-sync', - 'acl', - 'field-china-region', - 'workflow', - 'workflow-action-trigger', - 'workflow-aggregate', - 'workflow-delay', - 'workflow-dynamic-calculation', - 'workflow-loop', - 'workflow-manual', - 'workflow-parallel', - 'workflow-request', - 'workflow-sql', - 'client', - 'action-import', - 'action-export', - 'block-iframe', - 'block-workbench', - 'field-formula', - 'data-visualization', - 'auth', - 'logger', - 'action-custom-request', - 'calendar', - 'action-bulk-update', - 'action-bulk-edit', - 'gantt', - 'kanban', - 'action-duplicate', - 'action-print', - 'collection-sql', - 'collection-tree', - ]; - - localPlugins = [ - 'multi-app-manager>=0.7.0-alpha.1', - // 'audit-logs>=0.7.1-alpha.4', - 'map>=0.8.1-alpha.3', - // 'snapshot-field>=0.8.1-alpha.3', - 'graph-collection-manager>=0.9.0-alpha.1', - // 'multi-app-share-collection>=0.9.2-alpha.1', - 'mobile', - // 'mobile-client>=0.10.0-alpha.2', - 'api-keys>=0.10.1-alpha.1', - 'localization>=0.11.1-alpha.1', - 'theme-editor>=0.11.1-alpha.1', - 'api-doc>=0.13.0-alpha.1', - 'auth-sms>=0.10.0-alpha.2', - 'field-markdown-vditor>=0.21.0-alpha.16', - 'workflow-mailer', - 'field-m2m-array', - 'backup-restore', - ]; - splitNames(name: string) { return (name || '').split(',').filter(Boolean); } - getBuiltInPlugins() { - const { APPEND_PRESET_BUILT_IN_PLUGINS } = process.env; - return _.uniq(this.splitNames(APPEND_PRESET_BUILT_IN_PLUGINS).concat(this.builtInPlugins)); + async getBuiltInPlugins() { + return await findBuiltInPlugins(); } - getLocalPlugins() { - const { APPEND_PRESET_LOCAL_PLUGINS } = process.env; - const plugins = this.splitNames(APPEND_PRESET_LOCAL_PLUGINS) - .concat(this.localPlugins) - .map((name) => name.split('>=')); - return plugins; + async getLocalPlugins() { + return []; + return (await findLocalPlugins()).map((name) => name.split('>=')); + } + + async findLocalPlugins() { + return await findLocalPlugins(); + } + + async getAllPluginNames() { + const plugins1 = await findBuiltInPlugins(); + const plugins2 = await findLocalPlugins(); + return [...plugins1, ...plugins2]; + } + + async getAllPluginNamesAndDB() { + const items = await this.pm.repository.find({ + filter: { + enabled: true, + }, + }); + const plugins1 = await findBuiltInPlugins(); + const plugins2 = await findLocalPlugins(); + return trim(_.uniq([...plugins1, ...plugins2, ...items.map((item) => item.name)])); + } + + async getAllPlugins(locale = 'en-US') { + const plugins = await this.getAllPluginNamesAndDB(); + const packageJsons = []; + for (const name of plugins) { + packageJsons.push(await this.getPluginInfo(name, locale)); + } + return packageJsons; + } + + async getPluginInfo(name, locale = 'en-US') { + const repository = this.app.db.getRepository('applicationPlugins'); + // const packageJson = await this.getPackageJson(name); + const { packageName } = await PluginManager.parseName(name); + const packageJson = require(`${packageName}/package.json`); + const deps = await PluginManager.checkAndGetCompatible(packageJson.name); + const instance = await repository.findOne({ + filter: { + packageName: packageJson.name, + }, + }); + return { + packageName: packageJson.name, + name: name, + version: packageJson.version, + enabled: !!instance?.enabled, + installed: !!instance?.installed, + builtIn: !!instance?.builtIn, + keywords: packageJson.keywords, + author: packageJson.author, + packageJson, + removable: !instance?.enabled && !this.app.db.hasCollection('applications'), + displayName: packageJson?.[`displayName.${locale}`] || packageJson?.displayName || name, + description: packageJson?.[`description.${locale}`] || packageJson.description, + ...deps, + }; } async getPackageJson(name) { @@ -100,9 +90,11 @@ export class PresetNocoBase extends Plugin { } async allPlugins() { + const builtInPlugins = await this.getBuiltInPlugins(); + const localPlugins = await this.getLocalPlugins(); return ( await Promise.all( - this.getBuiltInPlugins().map(async (pkgOrName) => { + builtInPlugins.map(async (pkgOrName) => { const { name } = await PluginManager.parseName(pkgOrName); const packageJson = await this.getPackageJson(pkgOrName); return { @@ -116,7 +108,7 @@ export class PresetNocoBase extends Plugin { ) ).concat( await Promise.all( - this.getLocalPlugins().map(async (plugin) => { + localPlugins.map(async (plugin) => { const { name } = await PluginManager.parseName(plugin[0]); const packageJson = await this.getPackageJson(plugin[0]); return { name, packageName: packageJson.name, version: packageJson.version }; @@ -128,8 +120,10 @@ export class PresetNocoBase extends Plugin { async getPluginToBeUpgraded() { const repository = this.app.db.getRepository('applicationPlugins'); const items = (await repository.find()).map((item) => item.name); + const builtInPlugins = await this.getBuiltInPlugins(); + const localPlugins = await this.getLocalPlugins(); const plugins = await Promise.all( - this.getBuiltInPlugins().map(async (pkgOrName) => { + builtInPlugins.map(async (pkgOrName) => { const { name } = await PluginManager.parseName(pkgOrName); const packageJson = await this.getPackageJson(pkgOrName); return { @@ -141,7 +135,7 @@ export class PresetNocoBase extends Plugin { } as any; }), ); - for (const plugin of this.getLocalPlugins()) { + for (const plugin of localPlugins) { if (plugin[1]) { // 不在插件列表,并且插件最低版本小于当前应用版本,跳过不处理 if (!items.includes(plugin[0]) && (await this.app.version.satisfies(`>${plugin[1]}`))) {