Files
nocobase/scripts/check-openapi.mjs
YANG QIA 164abe70e5 chore: move NocoBase Studio back to its private repository (#10936)
* chore: move NocoBase Studio back to its private repository

Remove packages/apps/studio and packages/tools/studio-cli, which are now
maintained in nocobase/studio, together with everything in the monorepo
that only existed for them: the fixed changeset group and their pending
and prerelease changeset entries, the Studio release image workflow and
its call from the beta release, the Studio test shards of the release
workflows, the Studio OpenAPI and database-matrix targets, and the
script tests covering the image and the CLI brand.

* docs(app-installer): describe --app with a generic published application

Replace the NocoBase Studio examples in the installer's help, flag
description, usage error and README, and in the deployment Skill, with
a generic scoped package, and use a neutral fixture name in its tests.

* chore: rerun checks on the updated CI
2026-10-16 09:40:01 +08:00

331 lines
12 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 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();
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));
}