Files
nocobase/tests/scripts/remove-plugin.test.mjs
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

352 lines
11 KiB
JavaScript

import assert from 'node:assert/strict';
import { execFile } from 'node:child_process';
import { writeFileSync } from 'node:fs';
import {
access,
mkdtemp,
mkdir,
readFile,
readdir,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { promisify } from 'node:util';
import test from 'node:test';
import {
parseRemovePluginArgs,
removePlugin,
} from '../../scripts/remove-plugin.mjs';
const run = promisify(execFile);
const removeScriptUrl = new URL(
'../../scripts/remove-plugin.mjs',
import.meta.url,
).href;
test('parses removal options', () => {
assert.deepEqual(
parseRemovePluginArgs([
'@nocobase/app-plugin-audit-log',
'--dry-run',
'--no-install',
]),
{
dryRun: true,
help: false,
install: false,
json: false,
name: '@nocobase/app-plugin-audit-log',
},
);
});
test('parses JSON removal output mode', () => {
assert.deepEqual(parseRemovePluginArgs(['audit-log', '--json']), {
dryRun: false,
help: false,
install: true,
json: true,
name: 'audit-log',
});
});
test('prints one JSON success document to stdout for a dry run', async (t) => {
const repoRoot = await createTestRepo(t);
await createPluginPackage(repoRoot, 'audit-log');
const source = `import { main } from ${JSON.stringify(removeScriptUrl)}; await main(['audit-log', '--dry-run', '--json'], ${JSON.stringify(repoRoot)});`;
const result = await run(process.execPath, [
'--input-type=module',
'--eval',
source,
]);
assert.equal(result.stderr, '');
assert.deepEqual(JSON.parse(result.stdout), {
schemaVersion: 1,
ok: true,
operation: 'plugin:remove',
status: 'success',
result: {
mode: 'dry-run',
directoryName: 'app-plugin-audit-log',
packageName: '@nocobase/app-plugin-audit-log',
shortName: 'audit-log',
targetDirectory: path.join(
repoRoot,
'packages/plugins/app-plugin-audit-log',
),
commands: ['CI=true pnpm install --no-frozen-lockfile'],
},
});
});
test('prints one structured reference error to stderr and exits non-zero', async (t) => {
const repoRoot = await createTestRepo(t);
await createPluginPackage(repoRoot, 'audit-log');
const appDirectory = path.join(repoRoot, 'packages/templates/app');
await mkdir(appDirectory);
await writeJson(path.join(appDirectory, 'package.json'), {
name: '@nocobase/app',
dependencies: { '@nocobase/app-plugin-audit-log': 'workspace:^' },
});
const source = `import { main } from ${JSON.stringify(removeScriptUrl)}; await main(['audit-log', '--dry-run', '--json'], ${JSON.stringify(repoRoot)});`;
await assert.rejects(
run(process.execPath, ['--input-type=module', '--eval', source]),
(error) => {
assert.equal(error.stdout, '');
const response = JSON.parse(error.stderr);
assert.equal(response.error.code, 'PLUGIN_STILL_REFERENCED');
assert.equal(response.error.details.references.length, 1);
assert.deepEqual(response.error.suggestions, [
{
command: 'pnpm',
args: ['plugin:unregister', 'audit-log', '--app', 'app'],
},
]);
return true;
},
);
});
test('removes an unreferenced plugin without changing the lockfile', async (t) => {
const repoRoot = await createTestRepo(t);
const targetDirectory = await createPluginPackage(repoRoot, 'audit-log');
const lockfilePath = path.join(repoRoot, 'pnpm-lock.yaml');
await writeFile(lockfilePath, 'lockfileVersion: 9.0\n');
const result = await removePlugin({
install: false,
name: 'audit-log',
repoRoot,
});
assert.equal(result.packageName, '@nocobase/app-plugin-audit-log');
await assert.rejects(access(targetDirectory), { code: 'ENOENT' });
assert.equal(await readFile(lockfilePath, 'utf8'), 'lockfileVersion: 9.0\n');
});
test('dry-run validates but preserves the plugin', async (t) => {
const repoRoot = await createTestRepo(t);
const targetDirectory = await createPluginPackage(repoRoot, 'audit-log');
await removePlugin({
dryRun: true,
install: false,
name: 'app-plugin-audit-log',
repoRoot,
});
await access(targetDirectory);
});
test('refuses removal while a workspace package references the plugin', async (t) => {
const repoRoot = await createTestRepo(t);
const targetDirectory = await createPluginPackage(repoRoot, 'audit-log');
const appDirectory = path.join(repoRoot, 'packages/templates/app');
await mkdir(appDirectory);
await writeJson(path.join(appDirectory, 'package.json'), {
name: '@nocobase/app',
dependencies: {
'@nocobase/app-plugin-audit-log': 'workspace:^',
},
nocobase: {
plugins: {
'@nocobase/app-plugin-audit-log': { enabled: true },
},
},
});
await assert.rejects(
removePlugin({ install: false, name: 'audit-log', repoRoot }),
(error) => {
assert.match(error.message, /packages\/templates\/app\/package\.json/u);
assert.match(error.message, /dependencies/u);
assert.match(error.message, /nocobase\.plugins/u);
assert.match(
error.message,
/pnpm nocobase plugin unregister audit-log --workspace-root . --app app/u,
);
return true;
},
);
await access(targetDirectory);
});
test('refuses a directory whose package name does not match', async (t) => {
const repoRoot = await createTestRepo(t);
const targetDirectory = path.join(
repoRoot,
'packages/plugins/app-plugin-audit-log',
);
await mkdir(targetDirectory, { recursive: true });
await writeJson(path.join(targetDirectory, 'package.json'), {
name: '@nocobase/app-plugin-something-else',
});
await assert.rejects(
removePlugin({ install: false, name: 'audit-log', repoRoot }),
/expected package name/u,
);
await access(targetDirectory);
});
test('refuses removal while server/plugins.ts imports the plugin', async (t) => {
const repoRoot = await createTestRepo(t);
const targetDirectory = await createPluginPackage(repoRoot, 'audit-log');
const appDirectory = path.join(repoRoot, 'packages/templates/app');
await mkdir(path.join(appDirectory, 'server'), { recursive: true });
await writeJson(path.join(appDirectory, 'package.json'), {
name: '@nocobase/app',
});
await writeFile(
path.join(appDirectory, 'server/plugins.ts'),
`import auditLog from '@nocobase/app-plugin-audit-log/server';
import { defineServerPlugins } from '@nocobase/app-server/plugins';
export default defineServerPlugins([auditLog]);
`,
);
await assert.rejects(
removePlugin({ install: false, name: 'audit-log', repoRoot }),
(error) => {
assert.match(error.message, /server\/plugins\.ts/u);
assert.match(
error.message,
/pnpm nocobase plugin unregister audit-log --workspace-root . --app app/u,
);
return true;
},
);
await access(targetDirectory);
});
test('refuses removal while client/plugins.ts imports the plugin', async (t) => {
const repoRoot = await createTestRepo(t);
const targetDirectory = await createPluginPackage(repoRoot, 'audit-log');
const appDirectory = path.join(repoRoot, 'packages/templates/app');
await mkdir(path.join(appDirectory, 'client'), { recursive: true });
await writeJson(path.join(appDirectory, 'package.json'), {
name: '@nocobase/app',
});
await writeFile(
path.join(appDirectory, 'client/plugins.ts'),
`import auditLog from '@nocobase/app-plugin-audit-log/client';
import { defineClientPlugins } from '@nocobase/app-client/plugins';
export default defineClientPlugins([auditLog()]);
`,
);
await assert.rejects(
removePlugin({ install: false, name: 'audit-log', repoRoot }),
(error) => {
assert.match(error.message, /client\/plugins\.ts/u);
assert.match(
error.message,
/pnpm nocobase plugin unregister audit-log --workspace-root . --app app/u,
);
return true;
},
);
await access(targetDirectory);
});
test('removes a plugin after every application reference is gone', async (t) => {
const repoRoot = await createTestRepo(t);
const targetDirectory = await createPluginPackage(repoRoot, 'audit-log');
const appDirectory = path.join(repoRoot, 'packages/templates/app');
await mkdir(path.join(appDirectory, 'client'), { recursive: true });
await mkdir(path.join(appDirectory, 'server'), { recursive: true });
await writeJson(path.join(appDirectory, 'package.json'), {
name: '@nocobase/app',
devDependencies: {},
nocobase: { plugins: {} },
});
await writeFile(
path.join(appDirectory, 'client/plugins.ts'),
`import { defineClientPlugins } from '@nocobase/app-client/plugins';
export default defineClientPlugins([]);
`,
);
await writeFile(
path.join(appDirectory, 'server/plugins.ts'),
`import { defineServerPlugins } from '@nocobase/app-server/plugins';
export default defineServerPlugins([]);
`,
);
await removePlugin({ install: false, name: 'audit-log', repoRoot });
await assert.rejects(access(targetDirectory), { code: 'ENOENT' });
});
test('restores the plugin and lockfile when synchronization fails', async (t) => {
const repoRoot = await createTestRepo(t);
const targetDirectory = await createPluginPackage(repoRoot, 'audit-log');
const lockfilePath = path.join(repoRoot, 'pnpm-lock.yaml');
await writeFile(lockfilePath, 'lockfileVersion: 9.0\noriginal: true\n');
await assert.rejects(
removePlugin({
name: 'audit-log',
repoRoot,
synchronize() {
writeFileSync(lockfilePath, 'lockfileVersion: 9.0\nmodified: true\n');
throw new Error('simulated install failure');
},
}),
/was restored: simulated install failure/u,
);
await access(targetDirectory);
assert.equal(
await readFile(lockfilePath, 'utf8'),
'lockfileVersion: 9.0\noriginal: true\n',
);
assert.deepEqual(
(await readdir(repoRoot)).filter((entry) =>
entry.startsWith('.plugin-remove-'),
),
[],
);
});
async function createTestRepo(t) {
const repoRoot = await mkdtemp(
path.join(tmpdir(), 'nocobase-remove-plugin-'),
);
// Mirror the two-level layout: plugins live in packages/plugins/, and the application fixtures the reference walk
// has to reach live in a different category.
await mkdir(path.join(repoRoot, 'packages/plugins'), { recursive: true });
await mkdir(path.join(repoRoot, 'packages/templates'), { recursive: true });
await writeJson(path.join(repoRoot, 'package.json'), {
name: 'test-workspace',
private: true,
});
t.after(() => rm(repoRoot, { force: true, recursive: true }));
return repoRoot;
}
async function createPluginPackage(repoRoot, shortName) {
const targetDirectory = path.join(
repoRoot,
`packages/plugins/app-plugin-${shortName}`,
);
await mkdir(targetDirectory, { recursive: true });
await writeJson(path.join(targetDirectory, 'package.json'), {
name: `@nocobase/app-plugin-${shortName}`,
});
return targetDirectory;
}
async function writeJson(file, value) {
await writeFile(file, `${JSON.stringify(value, null, 2)}\n`);
}