mirror of
https://github.com/nocobase/nocobase.git
synced 2026-10-13 16:19:28 +08:00
* 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.
352 lines
11 KiB
JavaScript
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`);
|
|
}
|