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
This commit is contained in:
chenos 2024-09-15 01:37:46 +08:00 committed by GitHub
parent 475be58aa7
commit d7dc8fa4cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 942 additions and 411 deletions

1
.gitignore vendored
View File

@ -32,6 +32,7 @@ storage/plugins
storage/tar
storage/tmp
storage/app.watch.ts
storage/.upgrading
storage/logs-e2e
storage/uploads-e2e
storage/.pm2-*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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) {
<ReloadOutlined /> {t('Update')}
</a>
)}
{enabled ? (
app.pluginSettingsManager.has(name) && (
<a
onClick={(e) => {
e.stopPropagation();
navigate(app.pluginSettingsManager.getRoutePath(name));
}}
>
<SettingOutlined /> {t('Settings')}
</a>
)
) : (
{enabled && app.pluginSettingsManager.has(name) && (
<a
onClick={(e) => {
e.stopPropagation();
navigate(app.pluginSettingsManager.getRoutePath(name));
}}
>
<SettingOutlined /> {t('Settings')}
</a>
)}
{removable && (
<Popconfirm
key={'delete'}
disabled={builtIn}
title={t('Are you sure to delete this plugin?')}
onConfirm={async (e) => {
e.stopPropagation();
api.request({
await api.request({
url: `pm:remove`,
params: {
filterByTk: name,
},
});
Modal.info({
icon: null,
width: 520,
content: (
<Result
icon={<LoadingOutlined />}
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 && (
<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>
</>
);

View File

@ -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<IPluginDetail> = ({ plugin, onCancel }) => {
children: (
<Row gutter={20}>
{plugin.name && (
<Col span={12}>
<Col span={24}>
<div className={styles.PluginDetailBaseInfo}>
<Typography.Text type="secondary">{t('Name')}</Typography.Text>
<Typography.Text strong>{plugin.name}</Typography.Text>
@ -129,7 +128,7 @@ export const PluginDetail: FC<IPluginDetail> = ({ plugin, onCancel }) => {
</Col>
)}
{plugin.displayName && (
<Col span={12}>
<Col span={24}>
<div className={styles.PluginDetailBaseInfo}>
<Typography.Text type="secondary">{t('DisplayName')}</Typography.Text>
<Typography.Text strong>{plugin.displayName}</Typography.Text>
@ -169,7 +168,7 @@ export const PluginDetail: FC<IPluginDetail> = ({ plugin, onCancel }) => {
</Col>
)}
{data?.data?.packageJson.license && (
<Col span={12}>
<Col span={24}>
<div className={styles.PluginDetailBaseInfo}>
<Typography.Text type="secondary">{t('License')}</Typography.Text>
<Typography.Text strong>{data?.data?.packageJson.license}</Typography.Text>
@ -177,20 +176,14 @@ export const PluginDetail: FC<IPluginDetail> = ({ plugin, onCancel }) => {
</Col>
)}
{author && (
<Col span={12}>
<Col span={24}>
<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}>
<Col span={24}>
<div className={styles.PluginDetailBaseInfo}>
<Typography.Text type="secondary">{t('Version')}</Typography.Text>
<Typography.Text strong>{plugin?.version}</Typography.Text>
@ -231,11 +224,11 @@ export const PluginDetail: FC<IPluginDetail> = ({ plugin, onCancel }) => {
</>
),
},
{
key: 'changelog',
label: t('Changelog'),
children: plugin?.changelogUrl ? <PluginDocument url={plugin?.changelogUrl} /> : t('No CHANGELOG.md file'),
},
// {
// key: 'changelog',
// label: t('Changelog'),
// children: plugin?.changelogUrl ? <PluginDocument url={plugin?.changelogUrl} /> : t('No CHANGELOG.md file'),
// },
];
return (
@ -248,9 +241,6 @@ export const PluginDetail: FC<IPluginDetail> = ({ plugin, onCancel }) => {
<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' }}

View File

@ -7,10 +7,11 @@
* For more information, please refer to: https://www.nocobase.com/agreement.
*/
import { LoadingOutlined } from '@ant-design/icons';
import { ISchema } from '@formily/json-schema';
import { useForm } from '@formily/react';
import { uid } from '@formily/shared';
import { App } from 'antd';
import { App, Modal, Result } from 'antd';
import type { RcFile } from 'antd/es/upload';
import React, { FC, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
@ -19,6 +20,8 @@ import { useAPIClient } from '../../../api-client';
import { SchemaComponent } from '../../../schema-component';
import { IPluginData } from '../../types';
const { confirm } = Modal;
interface IPluginUploadFormProps {
onClose: (refresh?: boolean) => void;
isUpgrade: boolean;
@ -40,12 +43,41 @@ export const PluginUploadForm: FC<IPluginUploadFormProps> = ({ 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: (
<Result
icon={<LoadingOutlined />}
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);
},
};
};

View File

@ -27,7 +27,7 @@ export const PluginAddModal: FC<IPluginFormProps> = ({ onClose, isShow }) => {
const [type, setType] = useState<'npm' | 'upload' | 'url'>('npm');
return (
<Modal onCancel={() => onClose()} footer={null} destroyOnClose title={t('Add plugin')} width={580} open={isShow}>
<Modal onCancel={() => onClose()} footer={null} destroyOnClose title={t('Add & update')} 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)}>

View File

@ -197,7 +197,7 @@ const LocalPlugins = () => {
<div>
<Space>
<Button onClick={() => setShowAddForm(true)} type="primary">
{t('Add new')}
{t('Add & Update')}
</Button>
</Space>
</div>

View File

@ -16,6 +16,7 @@ export interface IPluginData {
packageName: string;
version: string;
enabled: boolean;
removable?: boolean;
installed: boolean;
builtIn: boolean;
registry?: string;

View File

@ -24,3 +24,5 @@ export declare class IndexGenerator {
constructor(outputPath: string, pluginsPath: string[]): void;
generate(): void;
};
export declare function generatePlugins(): {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -66,8 +66,58 @@
"@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",

View File

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

View File

@ -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<any>('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<any>('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]}`))) {