Files
Termix/packages/plugin-sdk/cli/commands/validate.mjs
T

275 lines
8.2 KiB
JavaScript

import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { readManifest } from "../lib/plugin-dir.mjs";
/**
* The manifest rules live in src/manifest.ts, which the server uses too, so
* there is one implementation rather than a copy here that drifts.
*/
export async function loadSdkModule(name) {
const entry = new URL(`../../dist/${name}.js`, import.meta.url);
if (!fs.existsSync(fileURLToPath(entry))) {
throw new Error("The plugin SDK is not built yet. Run: npm run build:sdk");
}
return import(entry.href);
}
export async function validate({ cwd }) {
const raw = readManifest(cwd);
const { parseManifest } = await loadSdkModule("manifest");
const { errors } = parseManifest(raw);
const problems = [...errors];
// The manifest names files. They have to be there.
const referenced = [
raw.backend ?? "dist/backend.js",
raw.frontend ?? "dist/frontend.js",
raw.locales ?? "locales",
];
for (const rel of referenced) {
if (!fs.existsSync(path.join(cwd, rel))) {
problems.push(`manifest references ${rel}, which does not exist`);
}
}
problems.push(
...(await validateMigrations(cwd, raw.id ?? path.basename(cwd))),
);
problems.push(...validateNativeDependencies(cwd, raw));
problems.push(...validatePackage(cwd, raw));
problems.push(...(await validateChangelogFile(cwd, raw)));
for (const warning of docsWarnings(cwd, raw)) {
console.warn(` warn ${warning}`);
}
if (problems.length > 0) {
for (const problem of problems) console.error(` ${problem}`);
throw new Error(`${raw.id ?? path.basename(cwd)}: manifest is not valid.`);
}
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
}
/** Env vars core sets for everyone, so a plugin need not list them. */
const CORE_ENV = new Set([
"DATA_DIR",
"NODE_ENV",
"PORT",
"BASE_PATH",
"VERSION",
"HOME",
"TMPDIR",
"TEMP",
"TMP",
"PATH",
"APPDATA",
"LOCALAPPDATA",
"USERPROFILE",
"ELECTRON_EMBEDDED",
]);
const ENV_READS = [
/process\.env\.([A-Z][A-Z0-9_]*)/g,
/process\.env\[\s*["'`]([A-Z][A-Z0-9_]*)["'`]\s*\]/g,
/\benv\.([A-Z][A-Z0-9_]*)\b/g,
/\benv\[\s*["'`]([A-Z][A-Z0-9_]*)["'`]\s*\]/g,
];
function walkSources(dir, out = []) {
if (!fs.existsSync(dir)) return out;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walkSources(full, out);
else if (/\.(ts|tsx|js|mjs|cjs)$/.test(entry.name)) out.push(full);
}
return out;
}
/**
* Docs gaps that do not block a release: no docs/index.md, or a process.env
* read the manifest does not list in env.
*/
export function docsWarnings(cwd, raw) {
const warnings = [];
if (!fs.existsSync(path.join(cwd, "docs", "index.md"))) {
warnings.push("docs/index.md is missing, so the plugin has no docs page");
}
const declared = new Set(
Array.isArray(raw.env) ? raw.env.map((e) => e?.name) : [],
);
const missing = new Set();
for (const file of walkSources(path.join(cwd, "src"))) {
const text = fs.readFileSync(file, "utf8");
for (const pattern of ENV_READS) {
for (const match of text.matchAll(pattern)) {
const name = match[1];
if (!declared.has(name) && !CORE_ENV.has(name)) missing.add(name);
}
}
}
for (const name of [...missing].sort()) {
warnings.push(`reads ${name} but manifest env does not list it`);
}
return warnings;
}
/**
* package.json has to agree with the manifest: the same version, and a real
* SDK range rather than "*", so a plugin built against a newer SDK says so.
*/
function validatePackage(cwd, raw) {
const problems = [];
const pkgPath = path.join(cwd, "package.json");
if (!fs.existsSync(pkgPath)) return problems;
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
if (pkg.version && raw.version && pkg.version !== raw.version) {
problems.push(
`package.json version ${pkg.version} does not match manifest version ${raw.version}`,
);
}
const sdkRange =
pkg.peerDependencies?.["@termix-ssh/plugin-sdk"] ??
pkg.dependencies?.["@termix-ssh/plugin-sdk"] ??
pkg.devDependencies?.["@termix-ssh/plugin-sdk"];
if (!sdkRange) {
problems.push("package.json does not depend on @termix-ssh/plugin-sdk");
} else if (sdkRange === "*" || sdkRange === "latest") {
problems.push(
`@termix-ssh/plugin-sdk is "${sdkRange}"; pin a range such as "^1.0.0"`,
);
}
return problems;
}
/**
* CHANGELOG.md is optional, but when it is there it has to parse and its
* newest release has to match the manifest version.
*/
async function validateChangelogFile(cwd, raw) {
if (fs.existsSync(path.join(cwd, "CHANGELOG.json"))) {
return [
"CHANGELOG.json is no longer read; move the release notes to CHANGELOG.md",
];
}
const mdPath = path.join(cwd, "CHANGELOG.md");
if (!fs.existsSync(mdPath)) return [];
const { validateChangelog } = await loadSdkModule("changelog");
return validateChangelog(fs.readFileSync(mdPath, "utf8"), raw.version).map(
(problem) => `CHANGELOG.md: ${problem}`,
);
}
/**
* A native dependency has to actually be a real dependency of the plugin, or
* the build's external declaration points at nothing node_modules can
* resolve at runtime.
*/
function validateNativeDependencies(cwd, raw) {
const problems = [];
const nativeDependencies = raw.nativeDependencies ?? [];
if (nativeDependencies.length === 0) return problems;
const pkgPath = path.join(cwd, "package.json");
const pkg = fs.existsSync(pkgPath)
? JSON.parse(fs.readFileSync(pkgPath, "utf8"))
: {};
const deps = { ...pkg.dependencies };
for (const name of nativeDependencies) {
if (!(name in deps)) {
problems.push(
`nativeDependencies names "${name}", which is not in this plugin's own package.json dependencies`,
);
}
}
return problems;
}
const DIALECTS = ["sqlite", "postgres", "mysql"];
/**
* Checks that a plugin's migrations only touch tables it owns.
*
* The server enforces this too, because a plugin installed from a tarball
* never ran this command. Here it is a build-time error with a file name
* attached, rather than a plugin that fails to activate later.
*/
async function validateMigrations(cwd, pluginId) {
const { LEGACY_TABLE_OWNERS: LEGACY_TABLES } =
await import("../../dist/db.js");
const { collectOwnedIndexes, findUnownedTableWrites } =
await import("../../dist/ddl.js");
const problems = [];
const legacy = new Set(
Object.entries(LEGACY_TABLES)
.filter(([, owner]) => owner === pluginId)
.map(([table]) => table),
);
const root = path.join(cwd, "migrations");
if (!fs.existsSync(root)) return problems;
const present = DIALECTS.filter((dialect) =>
fs.existsSync(path.join(root, dialect)),
);
const byDialect = new Map();
for (const dialect of present) {
const dir = path.join(root, dialect);
const files = fs
.readdirSync(dir)
.filter((file) => file.endsWith(".sql"))
.sort();
byDialect.set(dialect, files);
const sqls = files.map((file) =>
fs.readFileSync(path.join(dir, file), "utf8"),
);
const indexes = collectOwnedIndexes(pluginId, sqls, legacy);
for (const [index, file] of files.entries()) {
if (!/^\d{4}_[a-z0-9_]+\.sql$/.test(file)) {
problems.push(
`migrations/${dialect}/${file} must be named NNNN_name.sql, lower snake_case`,
);
}
const sql = sqls[index];
for (const problem of findUnownedTableWrites(
pluginId,
sql,
legacy,
indexes,
)) {
problems.push(`migrations/${dialect}/${file} ${problem}`);
}
}
}
// A migration that exists for one engine and not another leaves that
// deployment without the table, which is how alert_rules ended up
// SQLite-only in core.
const [first, ...rest] = present;
for (const dialect of rest) {
const a = byDialect.get(first);
const b = byDialect.get(dialect);
for (const file of a) {
if (!b.includes(file)) {
problems.push(`migrations/${dialect}/${file} is missing`);
}
}
for (const file of b) {
if (!a.includes(file)) {
problems.push(`migrations/${first}/${file} is missing`);
}
}
}
return problems;
}