mirror of
https://github.com/nocobase/nocobase.git
synced 2026-10-13 07:47:55 +08:00
562 lines
15 KiB
JavaScript
562 lines
15 KiB
JavaScript
import { spawnSync } from 'node:child_process';
|
|
import { constants } from 'node:fs';
|
|
import {
|
|
access,
|
|
lstat,
|
|
readFile,
|
|
readdir,
|
|
rename,
|
|
rm,
|
|
writeFile,
|
|
} from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import {
|
|
createClientPluginsEditor,
|
|
readClientPlugins,
|
|
} from '../packages/tools/cli/src/lib/client-plugins.ts';
|
|
import {
|
|
createServerPluginsEditor,
|
|
readServerPlugins,
|
|
} from '../packages/tools/cli/src/lib/server-plugins.ts';
|
|
import { normalizePluginName } from '../packages/tools/create-plugin/src/lib/names.ts';
|
|
|
|
const packagePrefix = '@nocobase/app-plugin-';
|
|
const directoryPrefix = 'app-plugin-';
|
|
const scriptPath = fileURLToPath(import.meta.url);
|
|
const defaultRepoRoot = path.resolve(path.dirname(scriptPath), '..');
|
|
const clientPluginsEditor = await createClientPluginsEditor(defaultRepoRoot);
|
|
const serverPluginsEditor = await createServerPluginsEditor(defaultRepoRoot);
|
|
const dependencyFields = [
|
|
'dependencies',
|
|
'devDependencies',
|
|
'optionalDependencies',
|
|
'peerDependencies',
|
|
];
|
|
const skippedDirectoryNames = new Set([
|
|
'.git',
|
|
'.pnpm',
|
|
'build',
|
|
'coverage',
|
|
'dist',
|
|
'node_modules',
|
|
]);
|
|
|
|
const help = `Remove a NocoBase plugin package from packages/plugins/.
|
|
|
|
Usage:
|
|
pnpm plugin:remove <name> [options]
|
|
|
|
Arguments:
|
|
<name> Short kebab-case name, for example audit-log
|
|
(full @nocobase/app-plugin-* names also work)
|
|
|
|
Options:
|
|
--no-install Do not synchronize pnpm-lock.yaml after removal
|
|
--dry-run Validate and print the target without removing it
|
|
--json Print one machine-readable JSON result
|
|
-h, --help Show this help
|
|
|
|
Removal is refused while another workspace package references the plugin in a
|
|
dependency field, nocobase.plugins, client/plugins.ts, or server/plugins.ts.
|
|
Remove those registrations first.`;
|
|
|
|
export function parseRemovePluginArgs(args) {
|
|
const options = {
|
|
dryRun: false,
|
|
help: false,
|
|
install: true,
|
|
json: false,
|
|
name: undefined,
|
|
};
|
|
const positionals = [];
|
|
|
|
for (const argument of args) {
|
|
if (argument === '--help' || argument === '-h') {
|
|
options.help = true;
|
|
continue;
|
|
}
|
|
if (argument === '--dry-run') {
|
|
options.dryRun = true;
|
|
continue;
|
|
}
|
|
if (argument === '--no-install') {
|
|
options.install = false;
|
|
continue;
|
|
}
|
|
if (argument === '--json') {
|
|
options.json = true;
|
|
continue;
|
|
}
|
|
if (argument.startsWith('-')) {
|
|
throw new Error(`Unknown option: ${argument}`);
|
|
}
|
|
positionals.push(argument);
|
|
}
|
|
|
|
if (positionals.length > 1) {
|
|
throw new Error('Expected exactly one plugin name.');
|
|
}
|
|
options.name = positionals[0];
|
|
|
|
if (!options.help && options.name === undefined) {
|
|
throw new Error('A plugin name is required.');
|
|
}
|
|
|
|
return options;
|
|
}
|
|
|
|
export async function removePlugin({
|
|
dryRun = false,
|
|
install = true,
|
|
name,
|
|
repoRoot = defaultRepoRoot,
|
|
synchronize = synchronizeWorkspace,
|
|
}) {
|
|
const shortName = normalizePluginName(name);
|
|
const packageName = `${packagePrefix}${shortName}`;
|
|
const directoryName = `${directoryPrefix}${shortName}`;
|
|
const resolvedRepoRoot = path.resolve(repoRoot);
|
|
// Plugins are scaffolded into `packages/plugins/`, so that is the only directory removal targets.
|
|
const packagesDirectory = path.join(resolvedRepoRoot, 'packages', 'plugins');
|
|
const targetDirectory = path.join(packagesDirectory, directoryName);
|
|
|
|
await access(packagesDirectory, constants.W_OK);
|
|
await validatePluginTarget(targetDirectory, packageName);
|
|
|
|
const references = await findWorkspaceReferences({
|
|
packageName,
|
|
repoRoot: resolvedRepoRoot,
|
|
targetDirectory,
|
|
});
|
|
if (references.length > 0) {
|
|
throw new PluginStillReferencedError(packageName, references);
|
|
}
|
|
|
|
const result = {
|
|
directoryName,
|
|
packageName,
|
|
shortName,
|
|
targetDirectory,
|
|
};
|
|
if (dryRun) {
|
|
return result;
|
|
}
|
|
|
|
const backupDirectory = path.join(
|
|
resolvedRepoRoot,
|
|
`.plugin-remove-${directoryName}-${process.pid}-${Date.now()}`,
|
|
);
|
|
const lockfilePath = path.join(resolvedRepoRoot, 'pnpm-lock.yaml');
|
|
const lockfileSnapshot = await readOptionalFile(lockfilePath);
|
|
|
|
await rename(targetDirectory, backupDirectory);
|
|
try {
|
|
if (install) {
|
|
synchronize(resolvedRepoRoot);
|
|
}
|
|
await rm(backupDirectory, { force: true, recursive: true });
|
|
} catch (error) {
|
|
const recoveryErrors = [];
|
|
try {
|
|
await rename(backupDirectory, targetDirectory);
|
|
} catch (recoveryError) {
|
|
recoveryErrors.push(recoveryError);
|
|
}
|
|
try {
|
|
await restoreLockfile(lockfilePath, lockfileSnapshot);
|
|
} catch (recoveryError) {
|
|
recoveryErrors.push(recoveryError);
|
|
}
|
|
|
|
if (recoveryErrors.length > 0) {
|
|
throw new AggregateError(
|
|
[error, ...recoveryErrors],
|
|
`Plugin removal failed and recovery was incomplete for ${targetDirectory}.`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
|
|
const reason = error instanceof Error ? error.message : String(error);
|
|
throw new Error(
|
|
`Plugin removal failed and ${targetDirectory} was restored: ${reason}`,
|
|
{ cause: error },
|
|
);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
export class PluginStillReferencedError extends Error {
|
|
constructor(packageName, references) {
|
|
super(formatReferenceError(packageName, references));
|
|
this.name = 'PluginStillReferencedError';
|
|
this.packageName = packageName;
|
|
this.references = references;
|
|
}
|
|
}
|
|
|
|
async function validatePluginTarget(targetDirectory, packageName) {
|
|
let targetStats;
|
|
try {
|
|
targetStats = await lstat(targetDirectory);
|
|
} catch (error) {
|
|
if (isNodeError(error, 'ENOENT')) {
|
|
throw new Error(`Plugin does not exist: ${targetDirectory}`, {
|
|
cause: error,
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
if (!targetStats.isDirectory() || targetStats.isSymbolicLink()) {
|
|
throw new Error(
|
|
`Plugin target must be a real directory: ${targetDirectory}`,
|
|
);
|
|
}
|
|
|
|
const packageJsonPath = path.join(targetDirectory, 'package.json');
|
|
const packageJson = await readJson(packageJsonPath);
|
|
if (packageJson.name !== packageName) {
|
|
throw new Error(
|
|
`Refusing to remove ${targetDirectory}: expected package name ${packageName}, found ${String(packageJson.name)}.`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function findWorkspaceReferences({
|
|
packageName,
|
|
repoRoot,
|
|
targetDirectory,
|
|
}) {
|
|
// `packages/` now holds every workspace package, examples included, and the walk below is recursive, so one root
|
|
// covers all of them.
|
|
const packageJsonPaths = [path.join(repoRoot, 'package.json')];
|
|
const packagesRoot = path.join(repoRoot, 'packages');
|
|
if (await pathExists(packagesRoot)) {
|
|
packageJsonPaths.push(
|
|
...(await findPackageJsonFiles(packagesRoot, targetDirectory)),
|
|
);
|
|
}
|
|
|
|
const references = [];
|
|
for (const packageJsonPath of packageJsonPaths) {
|
|
if (!(await pathExists(packageJsonPath))) {
|
|
continue;
|
|
}
|
|
const packageJson = await readJson(packageJsonPath);
|
|
const locations = [];
|
|
|
|
for (const field of dependencyFields) {
|
|
if (hasOwn(packageJson[field], packageName)) {
|
|
locations.push(field);
|
|
}
|
|
}
|
|
if (hasOwn(packageJson.nocobase?.plugins, packageName)) {
|
|
locations.push('nocobase.plugins');
|
|
}
|
|
// An application may import the plugin from client/plugins.ts without
|
|
// declaring it anywhere in package.json; removing the package while that
|
|
// import stands would leave the application unbuildable.
|
|
if (
|
|
await referencesClientPlugin(path.dirname(packageJsonPath), packageName)
|
|
) {
|
|
locations.push('client/plugins.ts');
|
|
}
|
|
if (
|
|
await referencesServerPlugin(path.dirname(packageJsonPath), packageName)
|
|
) {
|
|
locations.push('server/plugins.ts');
|
|
}
|
|
|
|
if (locations.length > 0) {
|
|
references.push({
|
|
locations,
|
|
packageJsonPath: path
|
|
.relative(repoRoot, packageJsonPath)
|
|
.split(path.sep)
|
|
.join('/'),
|
|
});
|
|
}
|
|
}
|
|
|
|
return references;
|
|
}
|
|
|
|
async function referencesClientPlugin(appRoot, packageName) {
|
|
const { exists, sourceText } = await readClientPlugins(appRoot);
|
|
if (!exists) {
|
|
return false;
|
|
}
|
|
try {
|
|
return clientPluginsEditor
|
|
.list(sourceText)
|
|
.some((entry) => entry.packageName === packageName);
|
|
} catch {
|
|
// An unparsable file is the application author's to fix; treat it as no
|
|
// reference rather than blocking an unrelated removal.
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function referencesServerPlugin(appRoot, packageName) {
|
|
const { exists, sourceText } = await readServerPlugins(appRoot);
|
|
if (!exists) {
|
|
return false;
|
|
}
|
|
try {
|
|
return serverPluginsEditor
|
|
.list(sourceText)
|
|
.some((entry) => entry.packageName === packageName);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function findPackageJsonFiles(directory, targetDirectory) {
|
|
if (path.resolve(directory) === path.resolve(targetDirectory)) {
|
|
return [];
|
|
}
|
|
|
|
const files = [];
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (entry.isSymbolicLink()) {
|
|
continue;
|
|
}
|
|
const entryPath = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (!skippedDirectoryNames.has(entry.name)) {
|
|
files.push(...(await findPackageJsonFiles(entryPath, targetDirectory)));
|
|
}
|
|
continue;
|
|
}
|
|
if (entry.isFile() && entry.name === 'package.json') {
|
|
files.push(entryPath);
|
|
}
|
|
}
|
|
return files;
|
|
}
|
|
|
|
function formatReferenceError(packageName, references) {
|
|
const details = references
|
|
.map(
|
|
({ locations, packageJsonPath }) =>
|
|
` ${packageJsonPath} (${locations.join(', ')})`,
|
|
)
|
|
.join('\n');
|
|
const shortName = packageName.slice(packagePrefix.length);
|
|
const unregisterCommands = [
|
|
...new Set(
|
|
references.flatMap(({ locations, packageJsonPath }) => {
|
|
if (
|
|
!locations.includes('devDependencies') &&
|
|
!locations.includes('nocobase.plugins') &&
|
|
!locations.includes('client/plugins.ts') &&
|
|
!locations.includes('server/plugins.ts')
|
|
) {
|
|
return [];
|
|
}
|
|
const app = path.posix.basename(path.posix.dirname(packageJsonPath));
|
|
return app === '.'
|
|
? []
|
|
: [` pnpm plugin:unregister ${shortName} --app ${app}`];
|
|
}),
|
|
),
|
|
];
|
|
const suggestion =
|
|
unregisterCommands.length > 0
|
|
? `\nUnregister the plugin before retrying:\n${unregisterCommands.join('\n')}`
|
|
: '';
|
|
return `Cannot remove ${packageName}; remove these workspace references first:\n${details}${suggestion}`;
|
|
}
|
|
|
|
async function readJson(file) {
|
|
let contents;
|
|
try {
|
|
contents = await readFile(file, 'utf8');
|
|
} catch (error) {
|
|
if (isNodeError(error, 'ENOENT')) {
|
|
throw new Error(`Required package.json does not exist: ${file}`, {
|
|
cause: error,
|
|
});
|
|
}
|
|
throw error;
|
|
}
|
|
|
|
try {
|
|
return JSON.parse(contents);
|
|
} catch (error) {
|
|
throw new Error(`Invalid JSON in ${file}.`, { cause: error });
|
|
}
|
|
}
|
|
|
|
async function readOptionalFile(file) {
|
|
try {
|
|
return await readFile(file);
|
|
} catch (error) {
|
|
if (isNodeError(error, 'ENOENT')) {
|
|
return undefined;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function restoreLockfile(file, snapshot) {
|
|
if (snapshot === undefined) {
|
|
await rm(file, { force: true });
|
|
return;
|
|
}
|
|
await writeFile(file, snapshot);
|
|
}
|
|
|
|
function synchronizeWorkspace(repoRoot) {
|
|
const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm';
|
|
const result = spawnSync(pnpm, ['install', '--no-frozen-lockfile'], {
|
|
cwd: repoRoot,
|
|
env: { ...process.env, CI: 'true' },
|
|
stdio: 'inherit',
|
|
});
|
|
|
|
if (result.error) {
|
|
throw result.error;
|
|
}
|
|
if (result.status !== 0) {
|
|
throw new Error(`pnpm install failed with exit code ${result.status}.`);
|
|
}
|
|
}
|
|
|
|
async function pathExists(target) {
|
|
try {
|
|
await access(target);
|
|
return true;
|
|
} catch (error) {
|
|
if (isNodeError(error, 'ENOENT')) {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function hasOwn(value, key) {
|
|
return (
|
|
value !== null &&
|
|
typeof value === 'object' &&
|
|
Object.prototype.hasOwnProperty.call(value, key)
|
|
);
|
|
}
|
|
|
|
function isNodeError(error, code) {
|
|
return error !== null && typeof error === 'object' && error.code === code;
|
|
}
|
|
|
|
export async function main(
|
|
args = process.argv.slice(2),
|
|
repoRoot = defaultRepoRoot,
|
|
) {
|
|
const json = args.includes('--json');
|
|
try {
|
|
const options = parseRemovePluginArgs(args);
|
|
if (options.help) {
|
|
console.log(help);
|
|
return;
|
|
}
|
|
|
|
const result = await removePlugin({ ...options, repoRoot });
|
|
if (options.json) {
|
|
console.log(
|
|
JSON.stringify(
|
|
{
|
|
schemaVersion: 1,
|
|
ok: true,
|
|
operation: 'plugin:remove',
|
|
status: 'success',
|
|
result: {
|
|
mode: options.dryRun ? 'dry-run' : 'remove',
|
|
...result,
|
|
commands:
|
|
options.dryRun && options.install
|
|
? ['CI=true pnpm install --no-frozen-lockfile']
|
|
: [],
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
if (options.dryRun) {
|
|
console.log(
|
|
`Would remove ${result.packageName} from ${result.targetDirectory}`,
|
|
);
|
|
return;
|
|
}
|
|
|
|
console.log(`Removed ${result.packageName} from ${result.targetDirectory}`);
|
|
if (!options.install) {
|
|
console.log(
|
|
'Skipped dependency installation. Run CI=true pnpm install --no-frozen-lockfile before committing.',
|
|
);
|
|
}
|
|
} catch (error) {
|
|
if (json) {
|
|
const referenced = error instanceof PluginStillReferencedError;
|
|
console.error(
|
|
JSON.stringify(
|
|
{
|
|
schemaVersion: 1,
|
|
ok: false,
|
|
operation: 'plugin:remove',
|
|
status: 'failure',
|
|
error: {
|
|
code: referenced
|
|
? 'PLUGIN_STILL_REFERENCED'
|
|
: String(error?.message ?? error).startsWith(
|
|
'Plugin does not exist:',
|
|
)
|
|
? 'PLUGIN_NOT_FOUND'
|
|
: 'PLUGIN_REMOVE_FAILED',
|
|
message: error instanceof Error ? error.message : String(error),
|
|
...(referenced
|
|
? { details: { references: error.references } }
|
|
: {}),
|
|
suggestions: referenced
|
|
? unregisterSuggestions(error.packageName, error.references)
|
|
: ['Run plugin:remove --help and correct the request.'],
|
|
},
|
|
},
|
|
null,
|
|
2,
|
|
),
|
|
);
|
|
process.exitCode = 1;
|
|
return;
|
|
}
|
|
console.error(error instanceof Error ? error.message : error);
|
|
console.error('Run pnpm plugin:remove --help for usage.');
|
|
process.exitCode = 1;
|
|
}
|
|
}
|
|
|
|
function unregisterSuggestions(packageName, references) {
|
|
const shortName = packageName.slice(packagePrefix.length);
|
|
const suggestions = new Map();
|
|
for (const { packageJsonPath } of references) {
|
|
const app = path.posix.basename(path.posix.dirname(packageJsonPath));
|
|
if (app === '.') continue;
|
|
const suggestion = {
|
|
command: 'pnpm',
|
|
args: ['plugin:unregister', shortName, '--app', app],
|
|
};
|
|
suggestions.set(JSON.stringify(suggestion), suggestion);
|
|
}
|
|
return [...suggestions.values()];
|
|
}
|
|
|
|
const invokedPath = process.argv[1] && path.resolve(process.argv[1]);
|
|
if (invokedPath === scriptPath) {
|
|
await main();
|
|
}
|