validate
tcmts.cli.manifests.validate · #bsk2hms6 · term · runs anywhere
import type { Manifest } from "#codebase/tcmts/cli/manifests";
import { ROLES } from "#codebase/tcmts/cli/roles";
import type { Role } from "#codebase/tcmts/cli/roles";
export function validate(
at: string,
v: Record<string, unknown>,
doc: string | undefined,
): Manifest {
const fail = (what: string) => {
throw new Error(`${at}: ${what}`);
};
if (typeof v.name !== "string" || !/^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*$/.test(v.name))
fail("`name` must be the npm package's name (lowercase, like `greeter` or `@me/greeter`)");
const strings = (x: unknown, what: string): string[] => {
const xs = typeof x === "string" ? [x] : x;
if (!Array.isArray(xs) || xs.some((s) => typeof s !== "string"))
fail(`${what} must be a name or a list of names`);
return xs as string[];
};
const exports: Record<string, string[]> = {};
const given = v.exports ?? { ".": "" };
if (typeof given === "string" || Array.isArray(given)) exports["."] = strings(given, "`exports`");
else if (typeof given === "object" && given) {
for (const [sub, t] of Object.entries(given)) {
if (sub !== "." && !/^\.\/[\w./-]+$/.test(sub))
fail(`\`exports\` subpaths are "." or "./something", not '${sub}'`);
exports[sub] = strings(t, `\`exports["${sub}"]\``);
}
} else fail("`exports` must map subpaths to names");
const bin: Record<string, string> = {};
if (v.bin !== undefined) {
if (typeof v.bin !== "object" || !v.bin) fail("`bin` must map commands to definitions");
for (const [cmd, t] of Object.entries(v.bin as object)) {
if (typeof t !== "string") fail(`\`bin.${cmd}\` must be a definition's name`);
bin[cmd] = t as string;
}
}
const suites: Record<string, string[]> = {};
if (v.suites !== undefined) {
if (typeof v.suites !== "object" || !v.suites || Array.isArray(v.suites))
fail("`suites` must map commands to test files");
for (const [cmd, files] of Object.entries(v.suites as object)) {
if (!(cmd in bin)) fail(`\`suites.${cmd}\` is for a command \`bin\` doesn't have`);
suites[cmd] = strings(files, `\`suites.${cmd}\``);
}
}
const apps: Record<string, string> = {};
if (v.apps !== undefined) {
if (typeof v.apps !== "object" || !v.apps || Array.isArray(v.apps))
fail("`apps` must map names to apps (definitions)");
for (const [name, t] of Object.entries(v.apps as object)) {
if (!/^[a-z][\w-]*$/i.test(name))
fail(`\`apps\`: '${name}' isn't a name (letters, digits, - and _)`);
if (typeof t !== "string") fail(`\`apps.${name}\` must be a definition's name`);
apps[name] = t as string;
}
}
const list = (k: "with" | "files" | "libraries") =>
v[k] === undefined ? [] : strings(v[k], `\`${k}\``);
const description =
typeof v.description === "string"
? v.description
: doc
?.split(/\n\s*\n/)[0]!
.replace(/\s+/g, " ")
.trim() || undefined;
if (v.packageJson !== undefined && typeof v.packageJson !== "string")
fail("`packageJson` must name a JSON file of the codebase");
if (v.npm !== undefined && v.npm !== false)
fail("`npm` is false for a tcm library (shared by tcm lib add, not npm), or left out");
if (v.host !== undefined && !(Number.isInteger(v.host) && (v.host as number) > 0))
fail("`host` is the version of tcm's host protocol its code speaks (1, say)");
const roles: Record<string, Role> = {};
if (v.roles !== undefined) {
if (typeof v.roles !== "object" || !v.roles) fail("`roles` must map names to roles");
for (const [name, role] of Object.entries(v.roles as object)) {
if (typeof role !== "string" || !Object.hasOwn(ROLES, role))
fail(`\`roles.${name}\` must be one of tcm's roles (${Object.keys(ROLES).join(", ")})`);
roles[name] = role as Role;
}
}
return {
name: v.name as string,
description,
exports,
bin,
with: list("with"),
files: list("files"),
suites,
apps,
libraries: list("libraries"),
...(typeof v.packageJson === "string" ? { packageJson: v.packageJson } : {}),
...(v.npm === false ? { npm: false as const } : {}),
...(typeof v.host === "number" ? { host: v.host } : {}),
...(Object.keys(roles).length ? { roles } : {}),
};
}
uses
used by