Files
nocobase/scripts/check-openapi.mjs
YANG QIA 0cc0327806 feat(studio): import NocoBase Studio into packages/apps (#10666)
NocoBase Studio moves from the private nocobase/studio repository
(archived at e4a18fd) into packages/apps/studio and is published as
@nocobase/studio. The framework submodule, the mirrored workspace file,
the lockfile and the submodule check go away; Studio now resolves the
framework packages and plugins from this workspace.

- packages/apps is a new package category for product applications.
  check-openapi checks Studio, and the database test portability check
  covers it; Studio's tests now take their databases from
  @nocobase/app-testing.
- A new migration, 202610220010_studio_rename_package, moves the
  migration and seed history recorded under the old package name
  `studio` to `@nocobase/studio` and rewrites the i18n namespace of
  workflow definitions copied from the software template. Released
  migrations are unchanged.
- Studio follows the framework changes since its last submodule
  pointer: the workflow plugin and the client's dev and settings route
  surfaces are gone.
- Prepared for publication: internal infrastructure, ticket keys and
  references to the predecessor product are removed, the demo data is
  in English, and tests are no longer published.
2026-10-10 19:53:49 +08:00

336 lines
13 KiB
JavaScript

// Builds the API document of each application template and fails when it is incomplete: a `/api` route that declares
// neither `describeRoute({...})` nor `describeRoute({ hide: true })` (as `findUndeclaredApiRoutes` reports it), a
// documented operation without `tags`, `summary` or `operationId`, an `operationId` two operations share, and a schema
// reference `findApiDocumentSchemaProblems` cannot resolve.
//
// Each application starts the way its tests start it — its own runtime, providers and plugins on SQLite test
// databases, so no database server is needed — in a child process that `scripts/check-openapi-app.mjs` runs under tsx.
// There is no stored snapshot to compare against: the check judges the document the current sources produce.
//
// Usage:
// node scripts/check-openapi.mjs # every template
// node scripts/check-openapi.mjs default # the named templates only
//
// Another repository reuses the check by importing `runOpenApiCheck` and passing its own targets, each an application
// directory plus an optional `extend` module that adds plugins to it. NocoBase 3 Pro does this through its
// `vendor/nocobase3` submodule, checking the OSS default template with its commercial plugins added.
import { spawn } from 'node:child_process';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { resolveFrom } from './check-openapi-app.mjs';
const repositoryRoot = path.resolve(
fileURLToPath(new URL('..', import.meta.url)),
);
const runnerPath = fileURLToPath(
new URL('./check-openapi-app.mjs', import.meta.url),
);
const httpMethods = [
'get',
'put',
'post',
'delete',
'options',
'head',
'patch',
'trace',
];
/** The application templates this repository checks, by name. */
export function templateTargets(root = repositoryRoot) {
const template = (name) =>
path.join(root, 'packages/templates', `app-template-${name}`);
return [
{ name: 'default', appDir: template('default') },
// `externalCrm` is an external connection: the application reads it but does not own it, so it keeps the
// configuration the template gives it.
{
name: 'examples',
appDir: template('examples'),
connections: ['main', 'analytics'],
},
];
}
/** The product applications under `packages/apps` this repository checks, by name. */
export function applicationTargets(root = repositoryRoot) {
return [{ name: 'studio', appDir: path.join(root, 'packages/apps/studio') }];
}
/**
* The security schemes of credentials a run carries: a route that accepts one acts for someone else within the actions
* the run was given, so it names the business action it performs.
*/
export const runSchemes = Object.freeze(['runToken']);
/** The command manifest and its reference take every caller's credential and perform nothing. */
const RUN_ACTION_EXEMPT = new Set(['cliGetManifest', 'cliGetReference']);
/** Every documented operation in the document, as `{ operation, operationId, tags, summary, security, cli }`. */
export function documentOperations(document) {
return Object.entries(document?.paths ?? {}).flatMap(([route, item]) =>
httpMethods.flatMap((method) => {
const operation = item?.[method];
if (!operation || typeof operation !== 'object') return [];
return [
{
operation: `${method.toUpperCase()} ${route}`,
operationId: operation.operationId,
tags: operation.tags,
summary: operation.summary,
security: operation.security,
cli: operation['x-cli'],
},
];
}),
);
}
const SPEC =
'packages/app/app-skills/skills/nocobase-app-development/references/http-api.md';
/** How to fix each kind of problem, printed under it so a failing run says what to change, not only what is wrong. */
export const openApiFixes = Object.freeze({
undeclared:
'Add describeRoute({ tags, summary, operationId, responses }) to the route, after its authentication and ' +
'permission middleware and before its apiValidator(...) calls, with tags the plugin name in PascalCase and an ' +
'operationId of namespace + verb + resource. Only a route the specification lets you hide (the application ' +
'shell, a browser-only flow, the docs routes, a transport endpoint, an unconfigured fallback) takes ' +
'describeRoute({ hide: true }) instead, with a comment saying why.',
forwarded:
'A runtime dispatcher forwards this path to something the document cannot describe. Register a Hono router ' +
'whose routes declare describeRoute(...) instead: through authz.routes.add(path, createRouteHandler(router)) ' +
"for the authorization plugin, or apiDocs.addApiRouter({ owner, prefix, scope, router }) for a plugin's own " +
'dispatcher. A route declared outside the forwarded path is never reached: move it under that path or delete it.',
missing:
'Complete its describeRoute(...): tags is the plugin name in PascalCase, summary an English verb phrase, and ' +
'operationId namespace + verb + resource in camelCase, such as workflowsListWorkflowRuns.',
duplicate:
'Rename one of the two operationIds. They are unique across the application, so start each with the namespace ' +
'of the plugin that owns the route.',
runAction:
"Name the business action the route performs in its x-cli extension, ...cliRoute({ action: 'pm.issues/edit' }), " +
"so the run principal can check the run holds it; a route about the run itself uses the agents plugin's " +
'RUN_SELF_ACTION. Do not borrow an unrelated action: add a grantable one to the plugin and to the agent catalog.',
schema:
'A $ref points nowhere or a component has a generated name. Give a schema that several routes share a unique ' +
".meta({ ref: '<PluginName><Thing>' }) and do not reuse a ref for two different schemas; run " +
"findApiDocumentSchemaProblems(document) in the plugin's tests to see the same list.",
});
/**
* The problems in one application's inspection, each `{ message, fix }`: what is wrong, then what to change.
* `inspection` is what `check-openapi-app.mjs` writes: `{ routes, schemaProblems, document }`.
*/
export function findOpenApiProblems(inspection) {
const problems = [];
for (const route of inspection.routes ?? []) {
if (!route.declared) {
// A route registered as undeclared, or one declared where its dispatcher never forwards, says why itself.
problems.push(
route.reason
? {
message: `${route.method} ${route.path}: ${route.reason}`,
fix: openApiFixes.forwarded,
}
: {
message: `${route.method} ${route.path}: declares neither describeRoute({...}) nor describeRoute({ hide: true }).`,
fix: openApiFixes.undeclared,
},
);
}
}
const seen = new Map();
for (const {
operation,
operationId,
tags,
summary,
security,
cli,
} of documentOperations(inspection.document)) {
const takesRun = (security ?? []).some((requirement) =>
runSchemes.some((scheme) => scheme in (requirement ?? {})),
);
if (
takesRun &&
!RUN_ACTION_EXEMPT.has(operationId) &&
!(cli && typeof cli === 'object' && cli.action)
) {
problems.push({
message: `${operation}: accepts a run token but declares no business action.`,
fix: openApiFixes.runAction,
});
}
const missing = [
...(Array.isArray(tags) && tags.length > 0 ? [] : ['tags']),
...(typeof summary === 'string' && summary.trim() ? [] : ['summary']),
...(typeof operationId === 'string' && operationId
? []
: ['operationId']),
];
if (missing.length > 0) {
problems.push({
message: `${operation}: is documented without ${missing.join(', ')}.`,
fix: openApiFixes.missing,
});
}
if (typeof operationId !== 'string' || !operationId) continue;
const first = seen.get(operationId);
if (first) {
problems.push({
message: `${operation}: operationId ${operationId} is already used by ${first}.`,
fix: openApiFixes.duplicate,
});
} else {
seen.set(operationId, operation);
}
}
for (const problem of inspection.schemaProblems ?? []) {
problems.push({ message: `schema: ${problem}`, fix: openApiFixes.schema });
}
return problems;
}
function run(command, args, options) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: ['ignore', 'inherit', 'inherit'],
...options,
});
child.once('error', reject);
child.once('exit', (code, signal) => resolve(signal ? 1 : (code ?? 1)));
});
}
/**
* Starts the target's application in a child process and returns its inspection. Output from the application goes to
* this process's stderr and stdout; the inspection travels through a file.
*/
export async function inspectApplication(target, { cwd = process.cwd() } = {}) {
const directory = await mkdtemp(
path.join(tmpdir(), 'nocobase-openapi-check-'),
);
try {
const requestPath = path.join(directory, 'request.json');
const output = path.join(directory, 'inspection.json');
await writeFile(
requestPath,
JSON.stringify({
appDir: target.appDir,
connections: target.connections,
config: target.config,
extend: target.extend,
output,
}),
);
const code = await run(
process.execPath,
['--import', resolveFrom(target.appDir, 'tsx'), runnerPath, requestPath],
{
cwd,
env: {
...process.env,
// The check runs on SQLite whatever the environment selects for the test suites.
NOCOBASE_TEST_DB_DIALECT: 'sqlite',
NODE_ENV: process.env.NODE_ENV ?? 'test',
},
},
);
if (code !== 0) {
throw new Error(
`the application did not start (exit code ${code}); see the output above.`,
);
}
return JSON.parse(await readFile(output, 'utf8'));
} finally {
await rm(directory, { recursive: true, force: true });
}
}
/**
* Checks every target and prints a report; resolves to the process exit code. A target is
* `{ name, appDir, connections?, config?, extend? }`: the application directory (holding `server/runtime.ts` and
* `server/app.ts`), the connections that get a test database (`['main']` by default), further configuration, and a
* module whose default export adds plugins, as `check-openapi-app.mjs` describes.
*/
export async function runOpenApiCheck(
targets,
{
inspect = inspectApplication,
log = console.log,
rerun = 'node scripts/check-openapi.mjs <default|examples>',
error = console.error,
} = {},
) {
let failed = false;
for (const target of targets) {
log(
`Checking the API document of ${target.name} (${path.relative(process.cwd(), target.appDir) || '.'})`,
);
let problems;
let inspection;
try {
inspection = await inspect(target);
problems = findOpenApiProblems(inspection);
} catch (cause) {
problems = [
{
message: `could not build the document: ${cause instanceof Error ? cause.message : String(cause)}`,
fix: 'Fix the start-up error printed above; the check starts the application on SQLite test databases the way its tests do.',
},
];
}
if (problems.length === 0) {
const operations = documentOperations(inspection.document).length;
log(
` ok: ${inspection.routes.length} routes, ${operations} documented operations`,
);
continue;
}
failed = true;
error(
` ${target.name}: ${problems.length} problem${problems.length === 1 ? '' : 's'}`,
);
for (const { message, fix } of problems) {
error(` ${message}`);
error(` Fix: ${fix}`);
}
}
if (failed) {
error(
`\nThe rules are in ${SPEC} ("API documentation"). Re-run the check with \`${rerun}\`.`,
);
}
return failed ? 1 : 0;
}
async function main(argv) {
const all = [...templateTargets(), ...applicationTargets()];
const names = argv.filter((argument) => !argument.startsWith('-'));
const unknown = names.filter(
(name) => !all.some((target) => target.name === name),
);
if (unknown.length > 0) {
console.error(
`Unknown application ${unknown.join(', ')}; expected ${all.map(({ name }) => name).join(', ')}.`,
);
return 2;
}
return runOpenApiCheck(
names.length > 0 ? all.filter(({ name }) => names.includes(name)) : all,
);
}
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
process.exitCode = await main(process.argv.slice(2));
}