mirror of
https://gitee.com/nocobase/nocobase.git
synced 2025-05-08 23:19:26 +08:00
* fix: add license code * fix: bug * fix: bug * fix: upgrade * fix: improve * chore: add copyright information to the file header * fix: d.ts bug * fix: bug * fix: e2e bug * fix: merge main --------- Co-authored-by: chenos <chenlinxh@gmail.com>
588 lines
18 KiB
TypeScript
588 lines
18 KiB
TypeScript
/**
|
|
* 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.
|
|
*/
|
|
|
|
/* istanbul ignore next -- @preserve */
|
|
|
|
import { importModule, isURL } from '@nocobase/utils';
|
|
import { createStoragePluginSymLink } from '@nocobase/utils/plugin-symlink';
|
|
import axios, { AxiosRequestConfig } from 'axios';
|
|
import decompress from 'decompress';
|
|
import fg from 'fast-glob';
|
|
import fs from 'fs-extra';
|
|
import ini from 'ini';
|
|
import { builtinModules } from 'module';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import semver from 'semver';
|
|
import { getDepPkgPath, getPackageDir, getPackageFilePathWithExistCheck } from './clientStaticUtils';
|
|
import {
|
|
APP_NAME,
|
|
DEFAULT_PLUGIN_PATH,
|
|
DEFAULT_PLUGIN_STORAGE_PATH,
|
|
EXTERNAL,
|
|
importRegex,
|
|
pluginPrefix,
|
|
requireRegex,
|
|
} from './constants';
|
|
import deps from './deps';
|
|
import { PluginManagerRepository } from './plugin-manager-repository';
|
|
import { PluginData } from './types';
|
|
|
|
/**
|
|
* get temp dir
|
|
*
|
|
* @example
|
|
* getTempDir() => '/tmp/nocobase'
|
|
*/
|
|
export async function getTempDir() {
|
|
const temporaryDirectory = await fs.realpath(os.tmpdir());
|
|
return path.join(temporaryDirectory, APP_NAME);
|
|
}
|
|
|
|
export function getPluginStoragePath() {
|
|
const pluginStoragePath = process.env.PLUGIN_STORAGE_PATH || DEFAULT_PLUGIN_STORAGE_PATH;
|
|
return path.isAbsolute(pluginStoragePath) ? pluginStoragePath : path.join(process.cwd(), pluginStoragePath);
|
|
}
|
|
|
|
export function getLocalPluginPackagesPathArr(): string[] {
|
|
const pluginPackagesPathArr = process.env.PLUGIN_PATH || DEFAULT_PLUGIN_PATH;
|
|
return pluginPackagesPathArr.split(',').map((pluginPackagesPath) => {
|
|
pluginPackagesPath = pluginPackagesPath.trim();
|
|
return path.isAbsolute(pluginPackagesPath) ? pluginPackagesPath : path.join(process.cwd(), pluginPackagesPath);
|
|
});
|
|
}
|
|
|
|
export function getStoragePluginDir(packageName: string) {
|
|
const pluginStoragePath = getPluginStoragePath();
|
|
return path.join(pluginStoragePath, packageName);
|
|
}
|
|
|
|
export function getLocalPluginDir(packageDirBasename: string) {
|
|
const localPluginDir = getLocalPluginPackagesPathArr()
|
|
.map((pluginPackagesPath) => path.join(pluginPackagesPath, packageDirBasename))
|
|
.find((pluginDir) => fs.existsSync(pluginDir));
|
|
|
|
if (!localPluginDir) {
|
|
throw new Error(`local plugin "${packageDirBasename}" not found`);
|
|
}
|
|
|
|
return localPluginDir;
|
|
}
|
|
|
|
export function getNodeModulesPluginDir(packageName: string) {
|
|
return path.join(process.env.NODE_MODULES_PATH, packageName);
|
|
}
|
|
|
|
export function getAuthorizationHeaders(registry?: string, authToken?: string) {
|
|
const headers = {};
|
|
if (registry && !authToken) {
|
|
const npmrcPath = path.join(os.homedir(), '.npmrc');
|
|
const url = new URL(registry);
|
|
let envConfig: Record<string, string> = process.env;
|
|
if (fs.existsSync(npmrcPath)) {
|
|
const content = fs.readFileSync(npmrcPath, 'utf-8');
|
|
envConfig = {
|
|
...envConfig,
|
|
...ini.parse(content),
|
|
};
|
|
}
|
|
const key = Object.keys(envConfig).find((key) => key.includes(url.host) && key.includes('_authToken'));
|
|
if (key) {
|
|
authToken = envConfig[key];
|
|
}
|
|
}
|
|
if (authToken) {
|
|
headers['Authorization'] = `Bearer ${authToken}`;
|
|
}
|
|
return headers;
|
|
}
|
|
|
|
/**
|
|
* get latest version from npm
|
|
*
|
|
* @example
|
|
* getLatestVersion('dayjs', 'https://registry.npmjs.org') => '1.10.6'
|
|
*/
|
|
export async function getLatestVersion(packageName: string, registry: string, token?: string) {
|
|
const npmInfo = await getNpmInfo(packageName, registry, token);
|
|
const latestVersion = npmInfo['dist-tags'].latest;
|
|
return latestVersion;
|
|
}
|
|
|
|
export async function getNpmInfo(packageName: string, registry: string, token?: string) {
|
|
registry.endsWith('/') && (registry = registry.slice(0, -1));
|
|
const response = await axios.get(`${registry}/${packageName}`, {
|
|
headers: getAuthorizationHeaders(registry, token),
|
|
});
|
|
try {
|
|
const data = response.data;
|
|
return data;
|
|
} catch (e) {
|
|
console.error(e);
|
|
throw new Error(`${registry} is not a valid registry, '${registry}/${packageName}' response is not a valid json.`);
|
|
}
|
|
}
|
|
|
|
export async function download(url: string, destination: string, options: AxiosRequestConfig = {}) {
|
|
const response = await axios.get(url, {
|
|
...options,
|
|
responseType: 'stream',
|
|
});
|
|
|
|
fs.mkdirpSync(path.dirname(destination));
|
|
const writer = fs.createWriteStream(destination);
|
|
|
|
response.data.pipe(writer);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
writer.on('finish', resolve);
|
|
writer.on('error', reject);
|
|
});
|
|
}
|
|
|
|
export async function removeTmpDir(tempFile: string, tempPackageContentDir: string) {
|
|
await fs.remove(tempFile);
|
|
await fs.remove(tempPackageContentDir);
|
|
}
|
|
|
|
/**
|
|
* download and unzip to node_modules
|
|
*/
|
|
export async function downloadAndUnzipToTempDir(fileUrl: string, authToken?: string) {
|
|
const fileName = path.basename(fileUrl);
|
|
const tempDir = await getTempDir();
|
|
const tempFile = path.join(tempDir, fileName);
|
|
const tempPackageDir = tempFile.replace(path.extname(fileName), '');
|
|
|
|
// download and unzip to temp dir
|
|
await fs.remove(tempPackageDir);
|
|
await fs.remove(tempFile);
|
|
|
|
if (isURL(fileUrl)) {
|
|
await download(fileUrl, tempFile, {
|
|
headers: getAuthorizationHeaders(fileUrl, authToken),
|
|
});
|
|
} else if (await fs.exists(fileUrl)) {
|
|
await fs.copy(fileUrl, tempFile);
|
|
} else {
|
|
throw new Error(`${fileUrl} does not exist`);
|
|
}
|
|
|
|
if (!fs.existsSync(tempFile)) {
|
|
throw new Error(`download ${fileUrl} failed`);
|
|
}
|
|
|
|
await decompress(tempFile, tempPackageDir);
|
|
|
|
if (!fs.existsSync(tempPackageDir)) {
|
|
await fs.remove(tempFile);
|
|
throw new Error(`File is not a valid compressed file. Maybe the file need authorization.`);
|
|
}
|
|
|
|
let tempPackageContentDir = tempPackageDir;
|
|
const files = fs
|
|
.readdirSync(tempPackageDir, { recursive: false, withFileTypes: true })
|
|
.filter((item) => item.name !== '__MACOSX');
|
|
if (
|
|
files.length === 1 &&
|
|
files[0].isDirectory() &&
|
|
fs.existsSync(path.join(tempPackageDir, files[0]['name'], 'package.json'))
|
|
) {
|
|
tempPackageContentDir = path.join(tempPackageDir, files[0]['name']);
|
|
}
|
|
const packageJsonPath = path.join(tempPackageContentDir, 'package.json');
|
|
|
|
if (!fs.existsSync(packageJsonPath)) {
|
|
await removeTmpDir(tempFile, tempPackageContentDir);
|
|
throw new Error(`decompress ${fileUrl} failed`);
|
|
}
|
|
|
|
const packageJson = await readJSONFileContent(packageJsonPath);
|
|
const mainFile = path.join(tempPackageContentDir, packageJson.main);
|
|
if (!fs.existsSync(mainFile)) {
|
|
await removeTmpDir(tempFile, tempPackageContentDir);
|
|
throw new Error(`main file ${packageJson.main} not found, Please check if the plugin has been built.`);
|
|
}
|
|
|
|
return {
|
|
packageName: packageJson.name,
|
|
version: packageJson.version,
|
|
tempPackageContentDir,
|
|
tempFile,
|
|
};
|
|
}
|
|
|
|
export async function copyTempPackageToStorageAndLinkToNodeModules(
|
|
tempFile: string,
|
|
tempPackageContentDir: string,
|
|
packageName: string,
|
|
) {
|
|
const packageDir = getStoragePluginDir(packageName);
|
|
|
|
// move to plugin storage dir
|
|
await fs.remove(packageDir);
|
|
await fs.move(tempPackageContentDir, packageDir, { overwrite: true });
|
|
|
|
// symlink to node_modules
|
|
await createStoragePluginSymLink(packageName);
|
|
|
|
// remove temp dir
|
|
await removeTmpDir(tempFile, tempPackageContentDir);
|
|
|
|
return {
|
|
packageDir,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* get package info from npm
|
|
*
|
|
* @example
|
|
* getPluginInfoByNpm('dayjs', 'https://registry.npmjs.org')
|
|
* => { fileUrl: 'https://registry.npmjs.org/dayjs/-/dayjs-1.10.6.tgz', latestVersion: '1.10.6' }
|
|
*
|
|
* getPluginInfoByNpm('dayjs', 'https://registry.npmjs.org', '1.1.0')
|
|
* => { fileUrl: 'https://registry.npmjs.org/dayjs/-/dayjs-1.1.0.tgz', latestVersion: '1.1.0' }
|
|
*/
|
|
|
|
interface GetPluginInfoOptions {
|
|
packageName: string;
|
|
registry: string;
|
|
version?: string;
|
|
authToken?: string;
|
|
}
|
|
|
|
export async function getPluginInfoByNpm(options: GetPluginInfoOptions) {
|
|
let { registry, version } = options;
|
|
const { packageName, authToken } = options;
|
|
if (registry.endsWith('/')) {
|
|
registry = registry.slice(0, -1);
|
|
}
|
|
if (!version) {
|
|
version = await getLatestVersion(packageName, registry, authToken);
|
|
}
|
|
|
|
const compressedFileUrl = `${registry}/${packageName}/-/${packageName.split('/').pop()}-${version}.tgz`;
|
|
|
|
return { compressedFileUrl, version };
|
|
}
|
|
|
|
/**
|
|
* scan `src/server` directory to get server packages
|
|
*
|
|
* @example
|
|
* getServerPackages('src/server') => ['dayjs', '@nocobase/plugin-bbb']
|
|
*/
|
|
export function getServerPackages(packageDir: string) {
|
|
function isBuiltinModule(packageName: string) {
|
|
return builtinModules.includes(packageName);
|
|
}
|
|
|
|
function getSrcPlugins(sourceDir: string): string[] {
|
|
const importedPlugins = new Set<string>();
|
|
const exts = ['.js', '.ts', '.jsx', '.tsx'];
|
|
const importRegex = /import\s+.*?\s+from\s+['"]([^'"\s.].+?)['"];?/g;
|
|
const requireRegex = /require\s*\(\s*[`'"]([^`'"\s.].+?)[`'"]\s*\)/g;
|
|
|
|
function setPluginsFromContent(reg: RegExp, content: string) {
|
|
let match: RegExpExecArray | null;
|
|
while ((match = reg.exec(content))) {
|
|
let importedPlugin = match[1];
|
|
if (importedPlugin.startsWith('@')) {
|
|
// @aa/bb/ccFile => @aa/bb
|
|
importedPlugin = importedPlugin.split('/').slice(0, 2).join('/');
|
|
} else {
|
|
// aa/bbFile => aa
|
|
importedPlugin = importedPlugin.split('/')[0];
|
|
}
|
|
|
|
if (!isBuiltinModule(importedPlugin)) {
|
|
importedPlugins.add(importedPlugin);
|
|
}
|
|
}
|
|
}
|
|
|
|
function traverseDirectory(directory: string) {
|
|
const files = fs.readdirSync(directory);
|
|
|
|
for (const file of files) {
|
|
const filePath = path.join(directory, file);
|
|
const stat = fs.statSync(filePath);
|
|
|
|
if (stat.isDirectory()) {
|
|
// recursive
|
|
traverseDirectory(filePath);
|
|
} else if (stat.isFile() && !filePath.includes('__tests__')) {
|
|
if (exts.includes(path.extname(filePath).toLowerCase())) {
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
|
|
setPluginsFromContent(importRegex, content);
|
|
setPluginsFromContent(requireRegex, content);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
traverseDirectory(sourceDir);
|
|
|
|
return [...importedPlugins];
|
|
}
|
|
|
|
const srcServerPlugins = getSrcPlugins(path.join(packageDir, 'src/server'));
|
|
return srcServerPlugins;
|
|
}
|
|
|
|
export function removePluginPackage(packageName: string) {
|
|
const packageDir = getStoragePluginDir(packageName);
|
|
const nodeModulesPluginDir = getNodeModulesPluginDir(packageName);
|
|
return Promise.all([fs.remove(packageDir), fs.remove(nodeModulesPluginDir)]);
|
|
}
|
|
|
|
/**
|
|
* get package.json
|
|
*
|
|
* @example
|
|
* getPackageJson('dayjs') => { name: 'dayjs', version: '1.0.0', ... }
|
|
*/
|
|
export async function getPackageJson(pluginName: string) {
|
|
const packageDir = getStoragePluginDir(pluginName);
|
|
return await getPackageJsonByLocalPath(packageDir);
|
|
}
|
|
|
|
export async function getPackageJsonByLocalPath(localPath: string) {
|
|
if (!fs.existsSync(localPath)) {
|
|
return null;
|
|
} else {
|
|
const fullPath = path.join(localPath, 'package.json');
|
|
const data = await fs.promises.readFile(fullPath, { encoding: 'utf-8' });
|
|
return JSON.parse(data);
|
|
}
|
|
}
|
|
|
|
export async function updatePluginByCompressedFileUrl(
|
|
options: Partial<Pick<PluginData, 'compressedFileUrl' | 'packageName' | 'authToken'>> & {
|
|
repository: PluginManagerRepository;
|
|
},
|
|
) {
|
|
const { packageName, version, tempFile, tempPackageContentDir } = await downloadAndUnzipToTempDir(
|
|
options.compressedFileUrl,
|
|
options.authToken,
|
|
);
|
|
|
|
const instance = await options.repository.findOne({
|
|
filter: { packageName },
|
|
});
|
|
|
|
if (!instance) {
|
|
await removeTmpDir(tempFile, tempPackageContentDir);
|
|
throw new Error(`plugin ${packageName} does not exist`);
|
|
}
|
|
|
|
const { packageDir } = await copyTempPackageToStorageAndLinkToNodeModules(
|
|
tempFile,
|
|
tempPackageContentDir,
|
|
packageName,
|
|
);
|
|
|
|
return {
|
|
packageName,
|
|
packageDir,
|
|
version,
|
|
};
|
|
}
|
|
|
|
export async function getNewVersion(plugin: PluginData): Promise<string | false> {
|
|
if (!(plugin.packageName && plugin.registry)) return false;
|
|
|
|
// 1. Check plugin version by npm registry
|
|
const { version } = await getPluginInfoByNpm({
|
|
packageName: plugin.packageName,
|
|
registry: plugin.registry,
|
|
authToken: plugin.authToken,
|
|
});
|
|
// 2. has new version, return true
|
|
return version !== plugin.version ? version : false;
|
|
}
|
|
|
|
export function removeRequireCache(fileOrPackageName: string) {
|
|
delete require.cache[require.resolve(fileOrPackageName)];
|
|
delete require.cache[fileOrPackageName];
|
|
}
|
|
|
|
export async function requireNoCache(fileOrPackageName: string) {
|
|
return await importModule(fileOrPackageName);
|
|
}
|
|
|
|
export async function readJSONFileContent(filePath: string) {
|
|
const data = await fs.promises.readFile(filePath, { encoding: 'utf-8' });
|
|
return JSON.parse(data);
|
|
}
|
|
|
|
export function requireModule(m: any) {
|
|
if (typeof m === 'string') {
|
|
m = require(m);
|
|
}
|
|
if (typeof m !== 'object') {
|
|
return m;
|
|
}
|
|
return m.__esModule ? m.default : m;
|
|
}
|
|
|
|
async function getExternalVersionFromDistFile(packageName: string): Promise<false | Record<string, string>> {
|
|
const { exists, filePath } = getPackageFilePathWithExistCheck(packageName, 'dist/externalVersion.js');
|
|
if (!exists) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return await requireNoCache(filePath);
|
|
} catch (e) {
|
|
console.error(e);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export function isNotBuiltinModule(packageName: string) {
|
|
return !builtinModules.includes(packageName);
|
|
}
|
|
|
|
export const isValidPackageName = (str: string) => {
|
|
const pattern = /^(?:@[a-zA-Z0-9_-]+\/)?[a-zA-Z0-9_-]+$/;
|
|
return pattern.test(str);
|
|
};
|
|
|
|
export function getPackageNameFromString(str: string) {
|
|
// ./xx or ../xx
|
|
if (str.startsWith('.')) return null;
|
|
|
|
const arr = str.split('/');
|
|
let packageName: string;
|
|
if (arr[0].startsWith('@')) {
|
|
// @aa/bb/ccFile => @aa/bb
|
|
packageName = arr.slice(0, 2).join('/');
|
|
} else {
|
|
// aa/bbFile => aa
|
|
packageName = arr[0];
|
|
}
|
|
|
|
packageName = packageName.trim();
|
|
|
|
return isValidPackageName(packageName) ? packageName : null;
|
|
}
|
|
|
|
export function getPackagesFromFiles(files: string[]): string[] {
|
|
const packageNames = files
|
|
.map((item) => [
|
|
...[...item.matchAll(importRegex)].map((item) => item[2]),
|
|
...[...item.matchAll(requireRegex)].map((item) => item[1]),
|
|
])
|
|
.flat()
|
|
.map(getPackageNameFromString)
|
|
.filter(Boolean)
|
|
.filter(isNotBuiltinModule);
|
|
|
|
return [...new Set(packageNames)];
|
|
}
|
|
|
|
export function getIncludePackages(sourcePackages: string[], external: string[], pluginPrefix: string[]): string[] {
|
|
return sourcePackages
|
|
.filter((packageName) => !external.includes(packageName)) // exclude external
|
|
.filter((packageName) => !pluginPrefix.some((prefix) => packageName.startsWith(prefix))); // exclude other plugin
|
|
}
|
|
|
|
export function getExcludePackages(sourcePackages: string[], external: string[], pluginPrefix: string[]): string[] {
|
|
const includePackages = getIncludePackages(sourcePackages, external, pluginPrefix);
|
|
return sourcePackages.filter((packageName) => !includePackages.includes(packageName));
|
|
}
|
|
|
|
export async function getExternalVersionFromSource(packageName: string) {
|
|
const packageDir = getPackageDir(packageName);
|
|
const sourceGlobalFiles: string[] = ['src/**/*.{ts,js,tsx,jsx}', '!src/**/__tests__'];
|
|
const sourceFilePaths = await fg.glob(sourceGlobalFiles, { cwd: packageDir, absolute: true });
|
|
const sourceFiles = await Promise.all(sourceFilePaths.map((item) => fs.readFile(item, 'utf-8')));
|
|
const sourcePackages = getPackagesFromFiles(sourceFiles);
|
|
const excludePackages = getExcludePackages(sourcePackages, EXTERNAL, pluginPrefix);
|
|
const data = excludePackages.reduce<Record<string, string>>((prev, packageName) => {
|
|
const depPkgPath = getDepPkgPath(packageName, packageDir);
|
|
const depPkg = require(depPkgPath);
|
|
prev[packageName] = depPkg.version;
|
|
return prev;
|
|
}, {});
|
|
return data;
|
|
}
|
|
|
|
export interface DepCompatible {
|
|
name: string;
|
|
result: boolean;
|
|
versionRange: string;
|
|
packageVersion: string;
|
|
}
|
|
|
|
export async function getCompatible(packageName: string) {
|
|
let externalVersion: Record<string, string>;
|
|
const hasSrc = fs.existsSync(path.join(getPackageDir(packageName), 'src'));
|
|
let hasError = false;
|
|
if (hasSrc) {
|
|
try {
|
|
externalVersion = await getExternalVersionFromSource(packageName);
|
|
} catch {
|
|
hasError = true;
|
|
}
|
|
}
|
|
|
|
if (hasError || !hasSrc) {
|
|
const res = await getExternalVersionFromDistFile(packageName);
|
|
if (!res) {
|
|
return false;
|
|
} else {
|
|
externalVersion = res;
|
|
}
|
|
}
|
|
|
|
return Object.keys(externalVersion).reduce<DepCompatible[]>((result, packageName) => {
|
|
const packageVersion = externalVersion[packageName];
|
|
const globalPackageName = deps[packageName]
|
|
? packageName
|
|
: deps[packageName.split('/')[0]] // @nocobase and @formily
|
|
? packageName.split('/')[0]
|
|
: undefined;
|
|
|
|
if (globalPackageName) {
|
|
const versionRange = deps[globalPackageName];
|
|
result.push({
|
|
name: packageName,
|
|
result: semver.satisfies(packageVersion, versionRange, { includePrerelease: true }),
|
|
versionRange,
|
|
packageVersion,
|
|
});
|
|
}
|
|
return result;
|
|
}, []);
|
|
}
|
|
|
|
export async function checkCompatible(packageName: string) {
|
|
const compatible = await getCompatible(packageName);
|
|
if (!compatible) return false;
|
|
return compatible.every((item) => item.result);
|
|
}
|
|
|
|
export async function checkAndGetCompatible(packageName: string) {
|
|
const compatible = await getCompatible(packageName);
|
|
if (!compatible) {
|
|
return {
|
|
isCompatible: false,
|
|
depsCompatible: [],
|
|
};
|
|
}
|
|
return {
|
|
isCompatible: compatible.every((item) => item.result),
|
|
depsCompatible: compatible,
|
|
};
|
|
}
|