UNPKG

@authzkit/prisma-tenant-guard-generator

Version:

Generate tenant guard metadata from Prisma schemas

402 lines (398 loc) 13 kB
#!/usr/bin/env node // src/cli.ts import { realpathSync } from "fs"; import { dirname as dirname2, resolve as resolve2 } from "path"; import { fileURLToPath } from "url"; import kleur from "kleur"; // src/index.ts import { mkdir, readFile, writeFile } from "fs/promises"; import { dirname, resolve } from "path"; import { getDMMF } from "@prisma/internals"; function normalizeModel(model) { const serialized = JSON.stringify(model); const parsed = JSON.parse(serialized); return parsed; } async function generateTenantMeta(options) { const schemaPath = resolve(options.schemaPath); const datamodel = await readFile(schemaPath, "utf8"); const dmmf = await getDMMF({ datamodel }); const tenantFieldCandidates = (options.tenantField ?? "tenantId").split(",").map((field) => field.trim()).filter(Boolean); if (tenantFieldCandidates.length === 0) { tenantFieldCandidates.push("tenantId"); } const includeSet = options.include ? new Set(options.include) : void 0; const excludeSet = options.exclude ? new Set(options.exclude) : void 0; const candidates = []; for (const model of dmmf.datamodel.models) { if (includeSet && !includeSet.has(model.name)) { continue; } if (excludeSet && excludeSet.has(model.name)) { continue; } const tenantField = model.fields.find( (field) => field.kind === "scalar" && tenantFieldCandidates.includes(field.name) ); if (!tenantField) { continue; } const normalizedModel = normalizeModel(model); const compositeSelector = detectCompositeSelector(normalizedModel, tenantField.name); const relations = []; for (const field of model.fields) { if (field.kind !== "object") { continue; } if (!field.type || typeof field.type !== "string") { continue; } relations.push({ fieldName: field.name, targetModel: field.type }); } const candidate = { name: model.name, tenantField: tenantField.name, relations }; if (compositeSelector) { candidate.compositeSelector = compositeSelector; } candidates.push(candidate); } const candidateByName = new Map( candidates.map((candidate) => [candidate.name, candidate]) ); const meta = {}; for (const candidate of candidates) { const nestedTargets = {}; for (const relation of candidate.relations) { if (!candidateByName.has(relation.targetModel)) { continue; } if (relation.targetModel === candidate.name) { nestedTargets[relation.fieldName] = relation.targetModel; continue; } nestedTargets[relation.fieldName] = relation.targetModel; } meta[candidate.name] = { tenantField: candidate.tenantField, ...candidate.compositeSelector ? { compositeSelector: candidate.compositeSelector } : {}, ...Object.keys(nestedTargets).length > 0 ? { nestedTargets } : {} }; } const artifacts = {}; if (options.outputPath && options.emitTs !== false) { artifacts.ts = await writeMetaFile(meta, options.outputPath); } if (options.emitJson) { const inferredJsonPath = options.outputPath ? replaceExtension(options.outputPath, ".json") : void 0; const jsonPath = resolve( options.jsonOutputPath ?? inferredJsonPath ?? "tenant-guard.meta.json" ); artifacts.json = await writeMetaJson(meta, jsonPath); } const result = { meta, artifacts }; const writtenTo = artifacts.ts ?? artifacts.json; if (writtenTo) { result.writtenTo = writtenTo; } return result; } function detectCompositeSelector(model, tenantField) { for (const index of model.uniqueIndexes ?? []) { if (index.fields.includes(tenantField) && index.fields.length > 1) { return index.name ?? index.fields.join("_"); } } for (const uniqueFields of model.uniqueFields ?? []) { if (uniqueFields.includes(tenantField) && uniqueFields.length > 1) { return uniqueFields.join("_"); } } const primaryFields = model.primaryKey?.fields ?? []; if (primaryFields.includes(tenantField) && primaryFields.length > 1) { return (model.primaryKey?.name ?? primaryFields.join("_")) || void 0; } return void 0; } async function writeMetaFile(meta, outputPath) { const filePath = resolve(outputPath); const outputDir = dirname(filePath); await mkdir(outputDir, { recursive: true }); const contents = formatMeta(meta); await writeFile(filePath, contents, "utf8"); return filePath; } async function writeMetaJson(meta, outputPath) { const filePath = resolve(outputPath); const outputDir = dirname(filePath); await mkdir(outputDir, { recursive: true }); await writeFile(filePath, JSON.stringify(meta, null, 2) + "\n", "utf8"); return filePath; } function formatMeta(meta) { const lines = []; lines.push("import type { TenantMeta } from '@authzkit/prisma-tenant-guard';"); lines.push(""); lines.push( "export const tenantMeta = " + serializeMeta(meta) + " satisfies TenantMeta;" ); lines.push(""); lines.push("export default tenantMeta;"); lines.push(""); return lines.join("\n"); } function serializeMeta(meta) { const entries = Object.entries(meta).sort(([a], [b]) => a.localeCompare(b)); if (entries.length === 0) { return "{}"; } const indent = (level) => " ".repeat(level); const lines = ["{"]; for (const [model, config] of entries) { const modelLines = []; if (config.tenantField) { modelLines.push(`${indent(2)}tenantField: '${config.tenantField}',`); } if (config.compositeSelector) { modelLines.push(`${indent(2)}compositeSelector: '${config.compositeSelector}',`); } if (config.nestedTargets && Object.keys(config.nestedTargets).length > 0) { modelLines.push(`${indent(2)}nestedTargets: {`); const nestedEntries = Object.entries(config.nestedTargets).sort( ([a], [b]) => a.localeCompare(b) ); for (const [field, target] of nestedEntries) { if (typeof target === "string") { modelLines.push(`${indent(3)}${field}: '${target}',`); continue; } const targetLines = [`${indent(3)}${field}: {`]; const entries2 = Object.entries(target).sort(([a], [b]) => a.localeCompare(b)); for (const [key, value] of entries2) { targetLines.push(`${indent(4)}${key}: '${value}',`); } targetLines.push(`${indent(3)}}`); modelLines.push(...targetLines); } modelLines.push(`${indent(2)}},`); } if (modelLines.length === 0) { lines.push(`${indent(1)}${model}: {},`); continue; } lines.push(`${indent(1)}${model}: {`); lines.push(...modelLines); lines.push(`${indent(1)}},`); } lines.push("}"); return lines.join("\n"); } function replaceExtension(path, nextExtension) { const resolved = resolve(path); const lastDot = resolved.lastIndexOf("."); if (lastDot === -1) { return `${resolved}${nextExtension}`; } return `${resolved.slice(0, lastDot)}${nextExtension}`; } // src/cli.ts async function run(argv = process.argv.slice(2)) { const parsed = parseArgs(argv); if (parsed.flags.help) { printHelp(); return { status: "ok" }; } const cwd = process.cwd(); const schemaPath = resolve2(cwd, parsed.flags.schema ?? defaultSchemaPath(cwd)); const schemaDir = dirname2(schemaPath); const defaultOutput = resolve2(schemaDir, "..", ".prisma", "tenant-guard", "meta.ts"); const outputPath = resolve2(cwd, parsed.flags.out ?? defaultOutput); const include = parsed.flags.include ?? []; const exclude = parsed.flags.exclude ?? []; try { const generationOptions = { schemaPath }; if (parsed.flags.tenantField) { generationOptions.tenantField = parsed.flags.tenantField; } if (include.length > 0) { generationOptions.include = include; } if (exclude.length > 0) { generationOptions.exclude = exclude; } const emitTs = !parsed.flags.print && !parsed.flags.jsonOnly; if (emitTs) { generationOptions.outputPath = outputPath; } else if (parsed.flags.jsonOnly) { generationOptions.emitTs = false; } const shouldEmitJson = !parsed.flags.print && !parsed.flags.tsOnly; if (shouldEmitJson) { generationOptions.emitJson = true; if (parsed.flags.jsonOnly) { generationOptions.jsonOutputPath = resolve2( cwd, parsed.flags.out ?? defaultOutput.replace(/\.ts$/, ".json") ); } } const result = await generateTenantMeta(generationOptions); if (parsed.flags.print) { process.stdout.write(formatMeta(result.meta)); return { status: "ok" }; } const models = Object.keys(result.meta); const artifactParts = []; if (result.artifacts?.ts) { artifactParts.push(`${kleur.cyan(result.artifacts.ts)} (.ts)`); } if (result.artifacts?.json) { artifactParts.push(`${kleur.magenta(result.artifacts.json)} (.json)`); } const artifactsSummary = artifactParts.length > 0 ? artifactParts.join(", ") : "stdout"; console.log( kleur.green("\u2714"), `Generated tenant guard meta for ${kleur.bold(String(models.length))} models -> ${artifactsSummary}` ); return { status: "ok", models: result.meta }; } catch (error) { const details = error instanceof Error ? error.message : String(error); console.error(kleur.red("\u2716"), "Failed to generate tenant guard meta"); console.error(kleur.gray(details)); return { status: "error", error }; } } function defaultSchemaPath(cwd) { return resolve2(cwd, "prisma/schema.prisma"); } function parseArgs(argv) { const flags = {}; const pending = {}; for (const token of argv) { if (token.startsWith("--")) { const [rawKey, rawValue] = token.includes("=") ? token.slice(2).split("=", 2) : [token.slice(2), void 0]; const key = rawKey.trim(); if (key === "help") { flags.help = true; continue; } if (key === "print") { flags.print = true; continue; } if (key === "ts-only") { flags.tsOnly = true; continue; } if (key === "json-only") { flags.jsonOnly = true; continue; } if (rawValue !== void 0) { setFlag(flags, key, rawValue); continue; } pending[key] = true; continue; } if (token.startsWith("-")) { const key = token.slice(1); if (key === "h") { flags.help = true; continue; } pending[key] = true; continue; } const pendingKey = Object.keys(pending)[0]; if (pendingKey) { setFlag(flags, pendingKey, token); delete pending[pendingKey]; } } return { flags }; } function setFlag(flags, key, value) { switch (key) { case "schema": case "s": flags.schema = value; break; case "out": case "output": case "o": flags.out = value; break; case "tenant-field": case "tenantField": case "t": flags.tenantField = value; break; case "include": case "includes": case "i": flags.include = [ ...flags.include ?? [], ...value.split(",").map((item) => item.trim()).filter(Boolean) ]; break; case "exclude": case "excludes": case "e": flags.exclude = [ ...flags.exclude ?? [], ...value.split(",").map((item) => item.trim()).filter(Boolean) ]; break; default: break; } } function printHelp() { const banner = kleur.bold("AuthzKit Prisma Tenant Guard Generator"); const usage = kleur.cyan("authzkit-tenant-guard-gen [options]"); const lines = [ banner, "", `Usage: ${usage}`, "", "Options:", " --schema <path> Path to schema.prisma (default: prisma/schema.prisma)", " --out <path> Output file (default: ../.prisma/tenant-guard/meta.ts)", " --tenant-field <name> Tenant field to detect (default: tenantId)", " --include <Model[,..]> Only emit metadata for specific models", " --exclude <Model[,..]> Skip metadata for specific models", " --print Print generated meta to stdout instead of writing to disk", " --ts-only Emit only the TypeScript artifact", " --json-only Emit only the JSON artifact", " --help Show this help message", "" ]; console.log(lines.join("\n")); } if (isMainModule()) { void run().then((result) => { if (result.status === "error") { process.exitCode = 1; } }); } function isMainModule() { const entry = process.argv[1]; if (!entry) { return false; } try { const resolvedEntry = realpathSync(entry); const modulePath = realpathSync(fileURLToPath(import.meta.url)); return resolvedEntry === modulePath; } catch { const resolvedModule = fileURLToPath(import.meta.url); return entry === resolvedModule; } } export { run };