Files
nocobase/scripts/remove-plugin.mjs
T
chenos 02d54029a8 refactor(app-cli)!: merge nb3-cli and app-tools into one application command line (#431)
* refactor(templates): drop the forwarding files in cli/

The templates re-exported @nocobase/app-cli through cli/database-command.ts,
cli/hub-publishing.ts and cli/commands/i18n-check.ts, and passed
createAppCommands through cli/standard-commands.ts. cli/commands/index.ts now
calls createAppCommands itself, the shared i18n check test lives in app-cli,
and app-plugin-hub imports publishToHub from the package rather than from a
template. app-cli drops the ./database-command and ./commands/i18n-check
subpaths that only those files used.

* refactor(app-cli)!: absorb nb3-cli and app-tools into one command line

@nocobase/app-cli now provides the nocobase bin and carries the command
assembly, the plugin contract and plugin/Skill management from nb3-cli, and
the dev, build, start and server-deps tooling from app-tools. Both packages
are removed.

Commands are found by path under src/commands and an application's
cli/commands, and the runner locates the application from the nearest
package.json (nocobase.templateKind or nocobase.buildTarget) or
NOCOBASE_APP_ROOT. A built-in command is dispatched without importing the
application's plugins. Development commands are not registered in a built
dist/, and the tooling they need is declared as optional peers. build and
dev hand the plugins' hooks to the scripts in NOCOBASE_CLI_HOOKS instead of
asking a second process.

Consumed changesets in .changeset/pre name the removed packages and are
renamed to @nocobase/app-cli, as the app-client consolidation did, so the
next prerelease run can still read them.

* refactor(plugins)!: take the CLI topic from the package name

A plugin's CLI topic is now its package name without the scope and
app-plugin- prefix, so the scheduler's commands move from schedule to
scheduler and the CLI example's from demo to cli-example; the built-in
topics avoid every plugin name. The workflow plugin's check and build are
devCommands, which a built dist/ leaves out.

Plugins and the create-plugin template peer on @nocobase/app-cli instead of
@nocobase/nb3-cli, and the workflow tests and skill-eval harness run the
Default template's CLI through the bin with NOCOBASE_APP_ROOT.

* refactor(templates)!: run every command through the nocobase bin

The templates drop scripts/*.mjs, cli/index.ts and cli/commands/index.ts.
Their scripts are postinstall, dev, build, start and the quality checks;
every other command is pnpm nocobase <topic> <command>, which pnpm resolves
to the bin without a script. The Default template enables the Hub
commands with nocobase.cli.publishing, and all three declare tar for the
build's --tar.

create-app runs pnpm nocobase skills sync and prints the new next steps.
The smoke script and the repository's template tests follow the new layout.

* docs: describe the merged CLI and use the new command ids

AGENTS.md, the application and plugin-development Skills, the docs site,
package READMEs and runtime messages now name nocobase db repair,
config init, locales check, release upload and the other new ids instead
of the removed app-topic ids and script aliases. The upgrade Skill gains the
step-by-step move to the new layout, which the changeset points to.

The global nocobase-create-app Skill and skills/README.md are left for a
follow-up after the release, because they reach users on merge.

* chore: cover the packages whose Skills name the new command ids

* refactor(app-cli)!: name the Collection and build commands by what they act on

db doctor compares Collection metadata with the schema, not migrations, so
it becomes collections doctor beside collections generate, and db holds only
migrations, seeds and locks. server-deps retarget and verify are two steps of
the build, run again on an existing dist/, so they become build retarget and
build verify. A dist topic was considered and rejected: a commands/dist
directory is ignored by the repository's .gitignore, Prettier and ESLint.

Topic and command descriptions now say application rather than app and Skill
rather than skill, package describes the one thing it does, and skills sync
mentions the .claude/skills links it writes.

* refactor(app-cli)!: move retarget and verify to a dist topic as dist retarget and dist check

Both act on an existing dist/ and never build, so they belong to a dist
topic rather than under build, which returns to being a plain command.
verify becomes check, the verb every other checking command uses
(config check, locales check, workflow check).

A dist directory is ignored by the repository's .gitignore, both Prettier
ignore files and the shared ESLint preset, and commands are found by their
directory, so src/commands/dist/ gets an exception in each of the four with a
comment saying why. tests/command-files.test.ts fails if any command file is
ignored by git, Prettier or ESLint, so losing an exception cannot go
unnoticed. The templates' AGENTS.md warns applications away from those
directory names in cli/commands/.

* docs(app-skills): add a command-line reference for the application CLI

nocobase-app-development gains references/cli.md: a task-to-command map, the
agent conventions --help does not state (--json, exit codes, --dry-run, and
that db reset, rollback, redo and repair refuse without --force outside a
terminal, which is not to be worked around), how to read the data-model
snapshot and keep it fresh, how to write an application command, and what a
built dist/ registers. The Skill links it before any pnpm nocobase command;
the deployment Skill notes dist retarget, dist check and running commands in
dist/; the three templates' AGENTS.md keep only the essentials and point to
the reference instead of repeating it.
2026-09-25 01:28:45 -07:00

564 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/app/app-cli/src/lib/client-plugins.ts';
import {
createServerPluginsEditor,
readServerPlugins,
} from '../packages/app/app-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 nocobase plugin unregister ${shortName} --workspace-root . --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();
}