repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
scripts/codegen.ts
Raw#!/usr/bin/env node
import {
lstatSync,
mkdirSync,
readdirSync,
readFileSync,
writeFileSync,
} from "node:fs";
import { isAbsolute, join, relative, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { ROOT } from "./lib/common.ts";
export const CONFIG_PATH = "scripts/codegen/targets.json";
export const EXTENSION_SLUG = "{{extension-slug}}";
export const GENERATED_MARKER =
"// Generated by scripts/codegen.ts. DO NOT EDIT.\n";
function generatedWarning(template: string) {
return [
"// ============================================================================",
"// THIS FILE IS GENERATED. DO NOT EDIT IT DIRECTLY.",
`// Instead, edit ${template}.`,
"// Then run: mise run //:codegen",
"// ============================================================================",
"",
].join("\n");
}
function pathError(label: string, message: string): never {
throw new Error(`${label}: ${message}`);
}
function validatePlaceholders(value: string, label: string) {
for (const token of value.match(/{{[^{}]*}}|{{|}}/g) ?? [])
if (token !== EXTENSION_SLUG)
pathError(label, `unsupported placeholder ${token}`);
}
function safeRelativePath(root: string, value: unknown, label: string) {
if (typeof value !== "string" || !value) pathError(label, "must be a path");
if (value.includes("\\") || value.includes("\0") || isAbsolute(value))
pathError(label, "must be a relative slash-separated path");
const parts = value.split("/");
if (
parts.some(
(part) => !part || part === "." || part === ".." || part.includes(":"),
)
)
pathError(
label,
"must not contain traversal or platform-specific segments",
);
const path = resolve(root, ...parts);
const rel = relative(root, path);
if (rel === "" || rel.startsWith("..") || isAbsolute(rel))
pathError(label, "must stay inside repository root");
return path;
}
function assertNoSymlink(root: string, path: string, label: string) {
let current = root;
for (const part of relative(root, path).split(/[\\/]/)) {
current = join(current, part);
try {
if (lstatSync(current).isSymbolicLink())
pathError(label, `must not cross symlink ${relative(root, current)}`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
break;
}
}
}
function readUtf8(path: string, label: string): string {
let bytes: Buffer;
try {
bytes = readFileSync(path);
} catch (error) {
pathError(label, (error as Error).message);
}
try {
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
} catch {
pathError(label, "must be valid UTF-8 text");
}
}
function extensionDirectories(root: string) {
const extensions = safeRelativePath(root, "extensions", "extensions root");
assertNoSymlink(root, extensions, "extensions root");
try {
return readdirSync(extensions, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((slug) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug))
.filter((slug) => {
const pkg = join(extensions, slug, "package.json");
assertNoSymlink(root, pkg, `extension ${slug}`);
try {
return lstatSync(pkg).isFile();
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
throw error;
}
})
.sort();
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
}
}
function readTargets(root: string): unknown[] {
const path = safeRelativePath(root, CONFIG_PATH, "codegen config");
assertNoSymlink(root, path, "codegen config");
try {
if (!lstatSync(path).isFile())
pathError("codegen config", "must be a file");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
throw error;
}
let config: unknown;
try {
config = JSON.parse(readUtf8(path, "codegen config"));
} catch (error) {
pathError(
"codegen config",
`must contain valid JSON: ${(error as Error).message}`,
);
}
if (!config || typeof config !== "object" || Array.isArray(config))
pathError("codegen config", "must be an object with a targets array");
const c = config as Record<string, unknown>;
if (!Array.isArray(c["targets"]))
pathError("codegen config", "targets must be an array");
return c["targets"];
}
function targetExtensions(
target: { extensions?: unknown },
available: string[],
label: string,
) {
if (target.extensions === "all") return available;
if (!Array.isArray(target.extensions) || !target.extensions.length)
pathError(label, "extensions must be 'all' or a non-empty slug list");
const selected = new Set<string>();
for (const slug of target.extensions) {
if (typeof slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug))
pathError(label, "extension selectors must be directory slugs");
if (!available.includes(slug))
pathError(label, `unknown extension ${slug}`);
if (selected.has(slug)) pathError(label, `duplicate extension ${slug}`);
selected.add(slug);
}
return [...selected].sort();
}
function validateTarget(
root: string,
target: unknown,
index: number,
extensions: string[],
) {
const label = `target ${index + 1}`;
if (!target || typeof target !== "object" || Array.isArray(target))
pathError(label, "must be an object");
const t = target as Record<string, unknown>;
if (typeof t["name"] !== "string" || !t["name"])
pathError(label, "name must be a non-empty string");
const template = safeRelativePath(root, t["template"], `${label} template`);
assertNoSymlink(root, template, `${label} template`);
try {
if (!lstatSync(template).isFile())
pathError(`${label} template`, "must be a regular file");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT")
pathError(`${label} template`, "does not exist");
throw error;
}
const output = t["output"];
if (typeof output !== "string") pathError(label, "output must be a path");
validatePlaceholders(output, `${label} output`);
const matches = output.split(EXTENSION_SLUG).length - 1;
if (matches !== 1 || !output.startsWith(`extensions/${EXTENSION_SLUG}/`))
pathError(label, `output must be inside extensions/${EXTENSION_SLUG}/`);
const selected = targetExtensions(t, extensions, label);
return { label, template, output, selected };
}
export function expectedGenerated(
root: string = ROOT,
): { slug: string; output: string; bytes: Buffer }[] {
const resolvedRoot = resolve(root);
const extensions = extensionDirectories(resolvedRoot);
const expected: { slug: string; output: string; bytes: Buffer }[] = [];
const outputs = new Set<string>();
for (const [index, target] of readTargets(resolvedRoot).entries()) {
const validated = validateTarget(resolvedRoot, target, index, extensions);
const template = readUtf8(
validated.template,
`${validated.label} template`,
);
validatePlaceholders(template, `${validated.label} template`);
for (const slug of validated.selected) {
const output = safeRelativePath(
resolvedRoot,
validated.output.replace(EXTENSION_SLUG, slug),
`${validated.label} output`,
);
assertNoSymlink(resolvedRoot, output, `${validated.label} output`);
if (outputs.has(output))
pathError(
validated.label,
`output collision at ${relative(resolvedRoot, output)}`,
);
outputs.add(output);
expected.push({
slug,
output,
bytes: Buffer.from(
`${GENERATED_MARKER}${generatedWarning((target as { template: string }).template)}${template.replaceAll(EXTENSION_SLUG, slug)}`,
"utf8",
),
});
}
}
return expected;
}
function outputBytes(path: string, label: string): Buffer | undefined {
try {
const stat = lstatSync(path);
if (stat.isSymbolicLink() || !stat.isFile())
pathError(label, "must be a regular file");
return readFileSync(path);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw error;
}
}
function extensionSlug(ext: string, root: string): string {
const path = resolve(ext);
const extensions = join(root, "extensions");
if (resolve(path, "..") !== extensions)
throw new Error("extension must be a direct child of extensions");
const slug = path.split(/[\\/]/).at(-1);
if (!slug || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug))
throw new Error("extension must have a valid directory slug");
return slug;
}
export function checkGenerated(ext: string, root: string = ROOT): string[] {
let slug: string | undefined;
try {
const resolvedRoot = resolve(root);
const expected = expectedGenerated(resolvedRoot);
if (!expected.length) return [];
slug = extensionSlug(ext, resolvedRoot);
const errors: string[] = [];
for (const generated of expected) {
if (generated.slug !== slug) continue;
const actual = outputBytes(generated.output, "generated output");
if (!actual?.equals(generated.bytes))
errors.push(
`${slug}: stale generated ${relative(resolvedRoot, generated.output)}`,
);
}
return errors;
} catch (error) {
return [`${slug ?? "codegen"}: ${(error as Error).message}`];
}
}
export function generate(
root: string = ROOT,
): { slug: string; output: string; bytes: Buffer }[] {
const resolvedRoot = resolve(root);
const expected = expectedGenerated(resolvedRoot);
for (const generated of expected) {
const actual = outputBytes(generated.output, "generated output");
if (actual && !actual.toString("utf8").startsWith(GENERATED_MARKER))
throw new Error(
`refusing to overwrite unmarked file ${relative(resolvedRoot, generated.output)}`,
);
}
for (const generated of expected) {
if (
outputBytes(generated.output, "generated output")?.equals(generated.bytes)
)
continue;
mkdirSync(resolve(generated.output, ".."), { recursive: true });
assertNoSymlink(resolvedRoot, generated.output, "generated output");
writeFileSync(generated.output, generated.bytes);
console.log(`OK generated ${relative(resolvedRoot, generated.output)}`);
}
return expected;
}
export function main() {
generate();
return 0;
}
const entryPoint = process.argv[1];
if (
entryPoint !== undefined &&
import.meta.url === pathToFileURL(resolve(entryPoint)).href
) {
try {
process.exitCode = main();
} catch (error) {
console.error((error as Error).message);
process.exitCode = 1;
}
}